From 1a638c8510e0785a22991b26c5d2562c8cc1193f Mon Sep 17 00:00:00 2001 From: zzgz <325153468@qq.com> Date: Fri, 5 Jun 2026 17:33:36 +0800 Subject: [PATCH 1/2] fix(linux): detect xwechat data under .xwechat --- src/config.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index ed91059..5306654 100644 --- a/src/config.rs +++ b/src/config.rs @@ -260,8 +260,13 @@ fn detect_db_dir_impl() -> Option { let mut candidates: Vec = Vec::new(); for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() { - let xwechat = base_home.join("Documents/xwechat_files"); - if xwechat.exists() { + for xwechat in [ + base_home.join("Documents/xwechat_files"), + base_home.join(".xwechat/xwechat_files"), + ] { + if !xwechat.exists() { + continue; + } if let Ok(entries) = std::fs::read_dir(&xwechat) { for entry in entries.flatten() { let storage = entry.path().join("db_storage"); From 8b3f63deea690d176b791615bb1fd4fe249dc9a7 Mon Sep 17 00:00:00 2001 From: henryczq Date: Sat, 6 Jun 2026 03:21:23 +0800 Subject: [PATCH 2/2] feat: export cached sns images --- README.md | 9 +++ SKILL.md | 11 +++ src/cli/extract.rs | 47 ++++++++++++- src/cli/mod.rs | 29 ++++++++ src/daemon/query.rs | 161 +++++++++++++++++++++++++++++++++++++++++++ src/daemon/server.rs | 12 ++++ src/ipc.rs | 21 ++++++ 7 files changed, 288 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c1c7b5..4ab48a7 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,15 @@ 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`。保持微信运行,并先在桌面微信里打开对应朋友圈或大图,命令才能导出本地缓存里已有的图片。 + ### 公众号文章 公众号文章推送存在独立的 `biz_message_*.db` 分片,用 `biz-articles` 单独查: diff --git a/SKILL.md b/SKILL.md index 61082fe..8335aa4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -240,6 +240,17 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50 > 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。 +#### 朋友圈 / SNS 缓存图片导出 + +Windows 微信 4.x 已加载过的朋友圈图片会缓存到 `xwechat_files//cache//Sns/Img`。这类 V2 文件可复用聊天图片的 AES image key provider 解密,但 SNS 尾部 raw 区的 XOR byte 实测为 `0xe7`,不是聊天图片的默认值。 + +```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。 + ### 公众号文章 公众号的文章推送存在独立的 `biz_message_*.db` 分片,与普通 `message_0.db` 分开: diff --git a/src/cli/extract.rs b/src/cli/extract.rs index a0eba0d..7ed4f1d 100644 --- a/src/cli/extract.rs +++ b/src/cli/extract.rs @@ -1,8 +1,8 @@ -use anyhow::Result; +use anyhow::{Context, Result}; -use crate::ipc::Request; use super::output::{print_value, resolve}; use super::transport; +use crate::ipc::Request; /// `wx extract` — 把单个 `attachment_id` 对应的资源解密写到指定路径。 /// @@ -23,3 +23,46 @@ pub fn cmd_extract( let resp = transport::send(req)?; print_value(&resp.data, &resolve(json)) } + +pub fn cmd_sns_extract( + output_dir: String, + month: Option, + limit: usize, + overwrite: bool, + xor_key: String, + json: bool, +) -> Result<()> { + let xor_key = parse_xor_key(&xor_key)?; + let output_dir = absolutize_output_dir(&output_dir)?; + let req = Request::SnsExtract { + output_dir, + month, + limit, + overwrite, + xor_key, + }; + let resp = transport::send(req)?; + print_value(&resp.data, &resolve(json)) +} + +fn parse_xor_key(raw: &str) -> Result { + let raw = raw.trim(); + if let Some(hex) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + u8::from_str_radix(hex, 16).with_context(|| format!("invalid xor key: {raw}")) + } else { + raw.parse::() + .with_context(|| format!("invalid xor key: {raw}")) + } +} + +fn absolutize_output_dir(raw: &str) -> Result { + let path = std::path::PathBuf::from(raw); + let path = if path.is_absolute() { + path + } else { + std::env::current_dir() + .context("failed to resolve current directory")? + .join(path) + }; + Ok(path.to_string_lossy().into_owned()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b4d6cf4..78c36d9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -271,6 +271,27 @@ enum Commands { #[arg(long)] json: bool, }, + /// 解密本地朋友圈 Sns/Img 缓存图片到目录 + SnsExtract { + /// 输出目录 + #[arg(short = 'o', long)] + output_dir: String, + /// 只扫描指定月份,例如 2026-06;默认扫描所有月份 + #[arg(long)] + month: Option, + /// 最多导出多少张 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 已存在时覆盖 + #[arg(long)] + overwrite: bool, + /// SNS V2 tail XOR key;Windows 4.x 实测默认 0xe7 + #[arg(long, default_value = "0xe7")] + xor_key: String, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, /// 列出某会话的图片附件,返回不透明 attachment_id Attachments { /// 会话名称(联系人显示名 / wxid / @chatroom username 都可以) @@ -514,6 +535,14 @@ fn dispatch(cli: Cli) -> Result<()> { debug_source: base_debug_source, }, ), + Commands::SnsExtract { + output_dir, + month, + limit, + overwrite, + xor_key, + json, + } => extract::cmd_sns_extract(output_dir, month, limit, overwrite, xor_key, json), Commands::Extract { attachment_id, output, diff --git a/src/daemon/query.rs b/src/daemon/query.rs index ac9ec0d..c46f13e 100644 --- a/src/daemon/query.rs +++ b/src/daemon/query.rs @@ -5,6 +5,8 @@ use roxmltree::{Document, Node}; use rusqlite::Connection; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use super::cache::{CacheMode, DbCache}; @@ -4598,6 +4600,165 @@ pub async fn q_extract( Ok(report) } +/// 解码本地朋友圈 cache//Sns/Img 中已加载的图片。 +pub async fn q_sns_extract( + db: &DbCache, + output_dir: &str, + month: Option<&str>, + limit: usize, + overwrite: bool, + xor_key: u8, +) -> Result { + let db_dir = db.db_dir().to_path_buf(); + let output_dir = PathBuf::from(output_dir); + let month = month.map(str::to_string); + + tokio::task::spawn_blocking(move || -> Result { + use crate::attachment::{ + decoder::{self, V2KeyMaterial}, + image_key, + }; + + let wxchat_base = db_dir + .parent() + .context("db_dir 缺少账号根目录,无法定位 cache/Sns/Img")? + .to_path_buf(); + let wxid = wxchat_base + .file_name() + .and_then(|s| s.to_str()) + .context("无法从账号根目录推断 wxid")? + .to_string(); + + let provider = image_key::default_provider().context("当前平台没有 V2 image key provider")?; + let key_material = provider.get_key(&wxid)?; + let v2_key = V2KeyMaterial { + aes_key: Some(&key_material.aes_key), + xor_key, + }; + + fs::create_dir_all(&output_dir) + .with_context(|| format!("创建输出目录失败: {}", output_dir.display()))?; + + let roots = sns_img_roots(&wxchat_base, month.as_deref())?; + let mut files = Vec::new(); + for root in roots { + collect_sns_img_files(&root, &mut files)?; + } + files.sort_by_key(|path| { + fs::metadata(path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + files.reverse(); + + let mut exported = Vec::new(); + let mut skipped_existing = 0usize; + let mut skipped_unsupported = 0usize; + let mut failed = Vec::new(); + let max = limit.max(1); + + for path in files { + if exported.len() >= max { + break; + } + let bytes = match fs::read(&path) { + Ok(v) => v, + Err(e) => { + failed.push(json!({"path": path.display().to_string(), "error": e.to_string()})); + continue; + } + }; + if !bytes.starts_with(&decoder::V2_MAGIC) { + skipped_unsupported += 1; + continue; + } + + let decoded = match decoder::dispatch(&bytes, v2_key) { + Ok(v) => v, + Err(e) => { + failed.push(json!({"path": path.display().to_string(), "error": e.to_string()})); + continue; + } + }; + + let out_path = output_dir.join(format!("{}.{}", sns_output_stem(&path), decoded.format)); + if out_path.exists() && !overwrite { + skipped_existing += 1; + continue; + } + fs::write(&out_path, &decoded.data) + .with_context(|| format!("写出 SNS 图片失败: {}", out_path.display()))?; + + exported.push(json!({ + "source": path.display().to_string(), + "output": out_path.display().to_string(), + "format": decoded.format, + "decoder": decoded.decoder, + "output_size": decoded.data.len(), + "xor_key": format!("0x{:02x}", xor_key), + })); + } + + let total_exported = exported.len(); + let total_failed = failed.len(); + + Ok(json!({ + "output_dir": output_dir.display().to_string(), + "month": month, + "exported": exported, + "total_exported": total_exported, + "skipped_existing": skipped_existing, + "skipped_unsupported": skipped_unsupported, + "failed": failed, + "total_failed": total_failed, + "xor_key": format!("0x{:02x}", xor_key), + })) + }) + .await? +} + +fn sns_img_roots(wxchat_base: &Path, month: Option<&str>) -> Result> { + let cache_root = wxchat_base.join("cache"); + if let Some(month) = month { + let root = cache_root.join(month).join("Sns").join("Img"); + return Ok(root.is_dir().then_some(root).into_iter().collect()); + } + + let mut roots = Vec::new(); + if !cache_root.is_dir() { + return Ok(roots); + } + for entry in fs::read_dir(&cache_root)? { + let path = entry?.path().join("Sns").join("Img"); + if path.is_dir() { + roots.push(path); + } + } + Ok(roots) +} + +fn collect_sns_img_files(dir: &Path, out: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + collect_sns_img_files(&path, out)?; + } else if path.is_file() { + out.push(path); + } + } + Ok(()) +} + +fn sns_output_stem(path: &Path) -> String { + let parent = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or("sns"); + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("image"); + format!("{}_{}", parent, name) +} + /// 解析 `kinds` 参数到 `(AttachmentKind, lo32_local_type)` 列表。 /// 当前只支持 image;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。 fn parse_attachment_kinds( diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 242edc1..0f4f214 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -357,5 +357,17 @@ async fn dispatch(req: Request, db: &DbCache, names: &tokio::sync::RwLock Response::ok(v), Err(e) => Response::err(e.to_string()), }, + SnsExtract { + output_dir, + month, + limit, + overwrite, + xor_key, + } => match query::q_sns_extract(db, &output_dir, month.as_deref(), limit, overwrite, xor_key) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, } } diff --git a/src/ipc.rs b/src/ipc.rs index 93306fb..c2adc5d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -185,6 +185,27 @@ pub enum Request { #[serde(default)] overwrite: bool, }, + /// 解密本地朋友圈 Sns/Img 缓存图片到目录 + SnsExtract { + /// 输出目录 + output_dir: String, + /// 只扫描指定月份,如 2026-06;为空则扫描所有月份 + #[serde(skip_serializing_if = "Option::is_none")] + month: Option, + /// 最多导出多少张 + #[serde(default = "default_limit_50")] + limit: usize, + /// 已存在时是否覆盖 + #[serde(default)] + overwrite: bool, + /// SNS V2 tail XOR key;Windows 4.x 实测默认 0xe7 + #[serde(default = "default_sns_xor_key")] + xor_key: u8, + }, +} + +fn default_sns_xor_key() -> u8 { + 0xe7 } /// daemon 的响应