feat(sns): export linux moments images via CDN

pull/100/head
zzgz 2026-06-06 03:44:43 +08:00
parent 8b3f63deea
commit c203ae6ad3
3 changed files with 318 additions and 5 deletions

View File

@ -215,14 +215,17 @@ 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`。保持微信运行,并先在桌面微信里打开对应朋友圈或大图,命令才能导出本地缓存里已有的图片。
- Windows 微信 4.x`cache/<YYYY-MM>/Sns/Img` 解本地 V2 缓存图,复用聊天图片 AES keySNS 尾部 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 可用。
### 公众号文章

View File

@ -240,16 +240,19 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50
> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。
#### 朋友圈 / SNS 缓存图片导出
#### 朋友圈 / SNS 图片导出
Windows 微信 4.x 已加载过的朋友圈图片会缓存到 `xwechat_files/<wxid>/cache/<YYYY-MM>/Sns/Img`。这类 V2 文件可复用聊天图片的 AES image key provider 解密,但 SNS 尾部 raw 区的 XOR byte 实测为 `0xe7`,不是聊天图片的默认值。
`sns-extract` 按平台自动选择导出方式:
- Windows 微信 4.x已加载过的朋友圈图片会缓存到 `xwechat_files/<wxid>/cache/<YYYY-MM>/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。
### 公众号文章

View File

@ -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<Value> {
#[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<Value> {
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<Value> {
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, Value>) -> 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<Vec<PathBuf>> {
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<Vec<PathBuf>
Ok(roots)
}
#[cfg(not(target_os = "linux"))]
fn collect_sns_img_files(dir: &Path, out: &mut Vec<PathBuf>) -> 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<PathBuf>) -> Result<()> {
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn sns_output_stem(path: &Path) -> String {
let parent = path
.parent()