mirror of https://github.com/jackwener/wx-cli.git
Merge c203ae6ad3 into 08af894594
commit
0ed0e80b4f
12
README.md
12
README.md
|
|
@ -215,6 +215,18 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01
|
||||||
|
|
||||||
朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。
|
朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。
|
||||||
|
|
||||||
|
已加载的朋友圈图片可导出到本地目录:
|
||||||
|
|
||||||
|
```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:从 `cache/<YYYY-MM>/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 可用。
|
||||||
|
|
||||||
### 公众号文章
|
### 公众号文章
|
||||||
|
|
||||||
公众号文章推送存在独立的 `biz_message_*.db` 分片,用 `biz-articles` 单独查:
|
公众号文章推送存在独立的 `biz_message_*.db` 分片,用 `biz-articles` 单独查:
|
||||||
|
|
|
||||||
14
SKILL.md
14
SKILL.md
|
|
@ -240,6 +240,20 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50
|
||||||
|
|
||||||
> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。
|
> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。
|
||||||
|
|
||||||
|
#### 朋友圈 / SNS 图片导出
|
||||||
|
|
||||||
|
`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
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 使用时保持 `Weixin.exe` 运行,便于 wx-cli 扫内存提取 V2 AES image key。命令只处理本地朋友圈数据库已经记录的 media;如果要大图,先在桌面微信里打开对应朋友圈或大图。Windows 若导出的文件花屏或半张图,优先尝试用 `--xor-key` 覆盖 SNS tail XOR byte。
|
||||||
|
|
||||||
### 公众号文章
|
### 公众号文章
|
||||||
|
|
||||||
公众号的文章推送存在独立的 `biz_message_*.db` 分片,与普通 `message_0.db` 分开:
|
公众号的文章推送存在独立的 `biz_message_*.db` 分片,与普通 `message_0.db` 分开:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
use crate::ipc::Request;
|
|
||||||
use super::output::{print_value, resolve};
|
use super::output::{print_value, resolve};
|
||||||
use super::transport;
|
use super::transport;
|
||||||
|
use crate::ipc::Request;
|
||||||
|
|
||||||
/// `wx extract` — 把单个 `attachment_id` 对应的资源解密写到指定路径。
|
/// `wx extract` — 把单个 `attachment_id` 对应的资源解密写到指定路径。
|
||||||
///
|
///
|
||||||
|
|
@ -23,3 +23,46 @@ pub fn cmd_extract(
|
||||||
let resp = transport::send(req)?;
|
let resp = transport::send(req)?;
|
||||||
print_value(&resp.data, &resolve(json))
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,27 @@ enum Commands {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
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
|
/// 列出某会话的图片附件,返回不透明 attachment_id
|
||||||
Attachments {
|
Attachments {
|
||||||
/// 会话名称(联系人显示名 / wxid / @chatroom username 都可以)
|
/// 会话名称(联系人显示名 / wxid / @chatroom username 都可以)
|
||||||
|
|
@ -514,6 +535,14 @@ fn dispatch(cli: Cli) -> Result<()> {
|
||||||
debug_source: base_debug_source,
|
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 {
|
Commands::Extract {
|
||||||
attachment_id,
|
attachment_id,
|
||||||
output,
|
output,
|
||||||
|
|
|
||||||
|
|
@ -260,8 +260,13 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||||
|
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() {
|
for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() {
|
||||||
let xwechat = base_home.join("Documents/xwechat_files");
|
for xwechat in [
|
||||||
if xwechat.exists() {
|
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) {
|
if let Ok(entries) = std::fs::read_dir(&xwechat) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let storage = entry.path().join("db_storage");
|
let storage = entry.path().join("db_storage");
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ use roxmltree::{Document, Node};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{HashMap, HashSet};
|
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 std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
use super::cache::{CacheMode, DbCache};
|
use super::cache::{CacheMode, DbCache};
|
||||||
|
|
@ -4598,6 +4602,470 @@ pub async fn q_extract(
|
||||||
Ok(report)
|
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> {
|
||||||
|
#[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);
|
||||||
|
|
||||||
|
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?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_sns_img_files(&path, out)?;
|
||||||
|
} else if path.is_file() {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
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)` 列表。
|
/// 解析 `kinds` 参数到 `(AttachmentKind, lo32_local_type)` 列表。
|
||||||
/// 当前只支持 image;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。
|
/// 当前只支持 image;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。
|
||||||
fn parse_attachment_kinds(
|
fn parse_attachment_kinds(
|
||||||
|
|
|
||||||
|
|
@ -357,5 +357,17 @@ async fn dispatch(req: Request, db: &DbCache, names: &tokio::sync::RwLock<Arc<Na
|
||||||
Ok(v) => Response::ok(v),
|
Ok(v) => Response::ok(v),
|
||||||
Err(e) => Response::err(e.to_string()),
|
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
src/ipc.rs
21
src/ipc.rs
|
|
@ -185,6 +185,27 @@ pub enum Request {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
overwrite: bool,
|
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 的响应
|
/// daemon 的响应
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue