From c203ae6ad32f834773a10ef8fd74a55171d97ffc Mon Sep 17 00:00:00 2001 From: zzgz <325153468@qq.com> Date: Sat, 6 Jun 2026 03:44:43 +0800 Subject: [PATCH] feat(sns): export linux moments images via CDN --- README.md | 7 +- SKILL.md | 9 +- src/daemon/query.rs | 307 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 318 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4ab48a7..4c2629d 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,17 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。 -本地已加载的朋友圈图片可从 `cache//Sns/Img` 解密导出: +已加载的朋友圈图片可导出到本地目录: ```bash wx sns-extract -o ./sns-images --month 2026-06 -n 100 --overwrite wx sns-extract -o ./sns-images --month 2026-06 --xor-key 0xe7 --json ``` -Windows 微信 4.x 的朋友圈 V2 缓存图复用聊天图片 AES key,但 SNS 尾部 XOR byte 实测为 `0xe7`。保持微信运行,并先在桌面微信里打开对应朋友圈或大图,命令才能导出本地缓存里已有的图片。 +- Windows 微信 4.x:从 `cache//Sns/Img` 解本地 V2 缓存图,复用聊天图片 AES key,SNS 尾部 XOR byte 实测为 `0xe7`。保持微信运行,便于 wx-cli 扫内存提取 V2 AES image key。 +- Linux/XWeChat:从 `sns.db` 的 media URL 重新下载 CDN 图片,用 `url_key` 经 WxIsaac64 生成密钥流后全量 XOR,并裁到 JPEG EOI。需要系统有 `curl`、`node`,并设置 `WX_SNS_WASM_DIR` 指向包含 `wasm_video_decode.js` 和 `wasm_video_decode.wasm` 的目录。 + +命令只处理本地朋友圈数据库已经记录的 media;如果要大图,先在桌面微信里打开对应朋友圈或大图,让 XML 中的 token/key 可用。 ### 公众号文章 diff --git a/SKILL.md b/SKILL.md index 8335aa4..ffdaac3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -240,16 +240,19 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50 > 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。 -#### 朋友圈 / SNS 缓存图片导出 +#### 朋友圈 / SNS 图片导出 -Windows 微信 4.x 已加载过的朋友圈图片会缓存到 `xwechat_files//cache//Sns/Img`。这类 V2 文件可复用聊天图片的 AES image key provider 解密,但 SNS 尾部 raw 区的 XOR byte 实测为 `0xe7`,不是聊天图片的默认值。 +`sns-extract` 按平台自动选择导出方式: + +- Windows 微信 4.x:已加载过的朋友圈图片会缓存到 `xwechat_files//cache//Sns/Img`。这类 V2 文件可复用聊天图片的 AES image key provider 解密,但 SNS 尾部 raw 区的 XOR byte 实测为 `0xe7`,不是聊天图片的默认值。 +- Linux/XWeChat:从 `sns.db` 里的 media URL 重新下载 CDN 图片,用 `url_key` 经 WxIsaac64 生成密钥流后全量 XOR,并裁到 JPEG EOI。需要 `curl`、`node`,并设置 `WX_SNS_WASM_DIR` 指向包含 `wasm_video_decode.js` 和 `wasm_video_decode.wasm` 的目录。 ```bash wx sns-extract -o ./sns-images --month 2026-06 -n 100 --overwrite wx sns-extract -o ./sns-images --month 2026-06 --xor-key 0xe7 --json ``` -使用时保持 `Weixin.exe` 运行,便于 wx-cli 扫内存提取 V2 AES image key。命令只导出本地缓存里已经加载过的图;如果要大图,先在桌面微信里打开对应朋友圈或大图,再运行导出。若导出的文件花屏或半张图,优先尝试用 `--xor-key` 覆盖 SNS tail XOR byte。 +Windows 使用时保持 `Weixin.exe` 运行,便于 wx-cli 扫内存提取 V2 AES image key。命令只处理本地朋友圈数据库已经记录的 media;如果要大图,先在桌面微信里打开对应朋友圈或大图。Windows 若导出的文件花屏或半张图,优先尝试用 `--xor-key` 覆盖 SNS tail XOR byte。 ### 公众号文章 diff --git a/src/daemon/query.rs b/src/daemon/query.rs index c46f13e..94e8378 100644 --- a/src/daemon/query.rs +++ b/src/daemon/query.rs @@ -7,6 +7,8 @@ use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use std::process::Command; use std::sync::{Arc, OnceLock}; use super::cache::{CacheMode, DbCache}; @@ -4609,6 +4611,14 @@ pub async fn q_sns_extract( overwrite: bool, xor_key: u8, ) -> Result { + #[cfg(target_os = "linux")] + { + let _ = xor_key; + return q_sns_extract_cdn(db, output_dir, month, limit, overwrite).await; + } + + #[cfg(not(target_os = "linux"))] + { let db_dir = db.db_dir().to_path_buf(); let output_dir = PathBuf::from(output_dir); let month = month.map(str::to_string); @@ -4715,8 +4725,303 @@ pub async fn q_sns_extract( })) }) .await? + } } +#[cfg(target_os = "linux")] +pub async fn q_sns_extract_cdn( + db: &DbCache, + output_dir: &str, + month: Option<&str>, + limit: usize, + overwrite: bool, +) -> Result { + let path = db.get("sns/sns.db").await?.context("无法解密 sns.db")?; + let output_dir = PathBuf::from(output_dir); + let month = month.map(str::to_string); + + tokio::task::spawn_blocking(move || -> Result { + fs::create_dir_all(&output_dir) + .with_context(|| format!("创建输出目录失败: {}", output_dir.display()))?; + let tmp_dir = output_dir.join(".wx-sns-tmp"); + fs::create_dir_all(&tmp_dir) + .with_context(|| format!("创建临时目录失败: {}", tmp_dir.display()))?; + + let conn = Connection::open(&path)?; + let mut stmt = conn.prepare("SELECT tid, user_name, content FROM SnsTimeLine ORDER BY tid DESC")?; + let rows = stmt.query_map([], |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )))?; + + let max = limit.max(1); + let mut exported = Vec::new(); + let mut failed = Vec::new(); + let mut skipped_existing = 0usize; + let mut skipped_no_key = 0usize; + let mut skipped_month = 0usize; + let mut scanned_posts = 0usize; + let mut scanned_media = 0usize; + + for row in rows { + if exported.len() >= max { + break; + } + scanned_posts += 1; + if scanned_posts > SNS_MAX_SCAN { + eprintln!( + "[sns_extract] scan 超过硬上限 {},结果可能不完整。建议加 --month 缩小范围。", + SNS_MAX_SCAN + ); + break; + } + + let (tid, uname, content) = row?; + let post = parse_post_xml(tid, &uname, &content); + if let Some(month) = month.as_deref() { + let post_month = fmt_time(post.create_time, "%Y-%m"); + if post_month != month { + skipped_month += post.media.len(); + continue; + } + } + + for (idx, media) in post.media.iter().enumerate() { + if exported.len() >= max { + break; + } + scanned_media += 1; + let Some(obj) = media.as_object() else { + skipped_no_key += 1; + continue; + }; + let url = obj.get("url").and_then(Value::as_str).unwrap_or_default(); + let token = obj.get("url_token").and_then(Value::as_str).unwrap_or_default(); + let key = obj.get("url_key").and_then(Value::as_str).unwrap_or_default(); + if url.is_empty() || token.is_empty() || key.is_empty() { + skipped_no_key += 1; + continue; + } + + let stem = sns_cdn_output_stem(tid, idx, obj); + let out_path = output_dir.join(format!("{stem}.jpg")); + if out_path.exists() && !overwrite { + skipped_existing += 1; + continue; + } + + let raw_path = tmp_dir.join(format!("{stem}.enc")); + let headers_path = tmp_dir.join(format!("{stem}.headers")); + let dl_url = sns_cdn_download_url(url, token); + + let result = (|| -> Result<()> { + download_sns_cdn_media(&dl_url, &raw_path, &headers_path)?; + decrypt_sns_cdn_media(key, &raw_path, &out_path)?; + Ok(()) + })(); + + match result { + Ok(()) => { + let _ = fs::remove_file(&raw_path); + let _ = fs::remove_file(&headers_path); + let output_size = fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0); + exported.push(json!({ + "source": dl_url, + "output": out_path.display().to_string(), + "format": "jpg", + "decoder": "sns_cdn_isaac64", + "output_size": output_size, + "tid": tid, + "media_index": idx, + "author_username": post.author_username, + "time": fmt_time(post.create_time, "%Y-%m-%d %H:%M"), + "md5": obj.get("md5").and_then(Value::as_str).unwrap_or_default(), + "width": obj.get("width").and_then(Value::as_i64).unwrap_or_default(), + "height": obj.get("height").and_then(Value::as_i64).unwrap_or_default(), + })); + } + Err(e) => { + failed.push(json!({ + "url": url, + "tid": tid, + "media_index": idx, + "error": e.to_string(), + })); + } + } + } + } + + let total_exported = exported.len(); + let total_failed = failed.len(); + + Ok(json!({ + "output_dir": output_dir.display().to_string(), + "month": month, + "method": "linux_sns_cdn_isaac64", + "exported": exported, + "total_exported": total_exported, + "skipped_existing": skipped_existing, + "skipped_no_key": skipped_no_key, + "skipped_month": skipped_month, + "scanned_posts": scanned_posts, + "scanned_media": scanned_media, + "failed": failed, + "total_failed": total_failed, + })) + }) + .await? +} + +#[cfg(target_os = "linux")] +fn sns_cdn_download_url(url: &str, token: &str) -> String { + let mut out = if let Some(rest) = url.strip_prefix("http://") { + format!("https://{rest}") + } else { + url.to_string() + }; + if out.contains('?') { + out.push_str("&token="); + } else { + out.push_str("?token="); + } + out.push_str(token); + out.push_str("&idx=1"); + out +} + +#[cfg(target_os = "linux")] +fn sns_cdn_output_stem(tid: i64, idx: usize, media: &serde_json::Map) -> String { + let md5 = media + .get("md5") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .unwrap_or("no-md5"); + format!("sns_{}_{}_{}", tid, idx, md5) + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }) + .collect() +} + +#[cfg(target_os = "linux")] +fn download_sns_cdn_media(url: &str, raw_path: &Path, headers_path: &Path) -> Result<()> { + let status = Command::new("curl") + .arg("-L") + .arg("--fail") + .arg("--http1.1") + .arg("-A") + .arg("MicroMessenger Client") + .arg("-H") + .arg("Accept: */*") + .arg("-D") + .arg(headers_path) + .arg("-o") + .arg(raw_path) + .arg("-sS") + .arg(url) + .status() + .context("执行 curl 下载朋友圈图片失败;请确认系统已安装 curl")?; + if !status.success() { + anyhow::bail!("curl 下载失败: {}", status); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn decrypt_sns_cdn_media(decode_key: &str, raw_path: &Path, out_path: &Path) -> Result<()> { + let (wasm_js, wasm_bin) = sns_wasm_paths()?; + let script = r#" +const fs = require('fs'); +const [decodeKey, rawPath, outPath, jsPath, wasmPath] = process.argv.slice(1); +global.self = { location: { href: 'file:///wx-cli-sns-worker.html' } }; +global.window = global; +global.VTS_WASM_URL = 'wasm_video_decode.wasm'; +global.MAX_HEAP_SIZE = 33554432; +global.Module = { wasmBinary: fs.readFileSync(wasmPath) }; +global.keystreamData = null; +global.wasm_isaac_generate = function(ptr, size) { + const wasmArray = new Uint8Array(Module.HEAPU8.buffer, ptr, size); + keystreamData = new Uint8Array(Array.from(wasmArray).reverse()); +}; +eval(fs.readFileSync(jsPath, 'utf8')); +function waitReady() { + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (global.Module && Module.WxIsaac64) { + clearInterval(timer); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(timer); + reject(new Error('WxIsaac64 WASM load timeout')); + }, 10000); + }); +} +(async () => { + await waitReady(); + const raw = fs.readFileSync(rawPath); + const genLen = Math.ceil(raw.length / 8) * 8; + const decryptor = new Module.WxIsaac64(decodeKey); + decryptor.generate(genLen); + decryptor.delete(); + if (!keystreamData || keystreamData.length < raw.length) { + throw new Error('keystream too short'); + } + const out = Buffer.alloc(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw[i] ^ keystreamData[i]; + const eoi = out.lastIndexOf(Buffer.from([0xff, 0xd9])); + const finalOut = eoi >= 0 ? out.subarray(0, eoi + 2) : out; + if (!(finalOut[0] === 0xff && finalOut[1] === 0xd8 && finalOut[2] === 0xff)) { + throw new Error('decrypted data is not JPEG'); + } + fs.writeFileSync(outPath, finalOut); +})().catch((err) => { + console.error(err && err.stack ? err.stack : String(err)); + process.exit(1); +}); +"#; + + let output = Command::new("node") + .arg("-e") + .arg(script) + .arg(decode_key) + .arg(raw_path) + .arg(out_path) + .arg(&wasm_js) + .arg(&wasm_bin) + .output() + .context("执行 node 解密朋友圈图片失败;请确认系统已安装 node")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Isaac64 解密失败: {}", stderr.trim()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn sns_wasm_paths() -> Result<(PathBuf, PathBuf)> { + let mut roots = Vec::new(); + if let Ok(root) = std::env::var("WX_SNS_WASM_DIR") { + roots.push(PathBuf::from(root)); + } + roots.push(PathBuf::from("wechat_files")); + + for root in roots { + let js = root.join("wasm_video_decode.js"); + let wasm = root.join("wasm_video_decode.wasm"); + if js.is_file() && wasm.is_file() { + return Ok((js, wasm)); + } + } + + anyhow::bail!( + "找不到 WxIsaac64 WASM 文件;请设置 WX_SNS_WASM_DIR 指向包含 wasm_video_decode.js 和 wasm_video_decode.wasm 的目录" + ) +} + +#[cfg(not(target_os = "linux"))] fn sns_img_roots(wxchat_base: &Path, month: Option<&str>) -> Result> { let cache_root = wxchat_base.join("cache"); if let Some(month) = month { @@ -4737,6 +5042,7 @@ fn sns_img_roots(wxchat_base: &Path, month: Option<&str>) -> Result Ok(roots) } +#[cfg(not(target_os = "linux"))] fn collect_sns_img_files(dir: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir)? { let path = entry?.path(); @@ -4749,6 +5055,7 @@ fn collect_sns_img_files(dir: &Path, out: &mut Vec) -> Result<()> { Ok(()) } +#[cfg(not(target_os = "linux"))] fn sns_output_stem(path: &Path) -> String { let parent = path .parent()