Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01

朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。

本地已加载的朋友圈图片可从 `cache/<YYYY-MM>/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` 单独查:
Expand Down
11 changes: 11 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50

> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。

#### 朋友圈 / SNS 缓存图片导出

Windows 微信 4.x 已加载过的朋友圈图片会缓存到 `xwechat_files/<wxid>/cache/<YYYY-MM>/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` 分开:
Expand Down
47 changes: 45 additions & 2 deletions src/cli/extract.rs
Original file line number Diff line number Diff line change
@@ -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` 对应的资源解密写到指定路径。
///
Expand All @@ -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<String>,
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<u8> {
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::<u8>()
.with_context(|| format!("invalid xor key: {raw}"))
}
}

fn absolutize_output_dir(raw: &str) -> Result<String> {
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())
}
29 changes: 29 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 最多导出多少张
#[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 都可以)
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,13 @@ fn detect_db_dir_impl() -> Option<PathBuf> {

let mut candidates: Vec<PathBuf> = 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");
Expand Down
161 changes: 161 additions & 0 deletions src/daemon/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -4598,6 +4600,165 @@ pub async fn q_extract(
Ok(report)
}

/// 解码本地朋友圈 cache/<month>/Sns/Img 中已加载的图片。
pub async fn q_sns_extract(
db: &DbCache,
output_dir: &str,
month: Option<&str>,
limit: usize,
overwrite: bool,
xor_key: u8,
) -> Result<Value> {
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<Value> {
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<Vec<PathBuf>> {
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<PathBuf>) -> 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(
Expand Down
12 changes: 12 additions & 0 deletions src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,5 +357,17 @@ async fn dispatch(req: Request, db: &DbCache, names: &tokio::sync::RwLock<Arc<Na
Ok(v) => 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()),
},
}
}
21 changes: 21 additions & 0 deletions src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 最多导出多少张
#[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 的响应
Expand Down