mirror of https://github.com/jackwener/wx-cli.git
feat: export cached sns images
parent
1a638c8510
commit
8b3f63deea
|
|
@ -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` 单独查:
|
||||
|
|
|
|||
11
SKILL.md
11
SKILL.md
|
|
@ -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` 分开:
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
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 都可以)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/<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(
|
||||
|
|
|
|||
|
|
@ -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
src/ipc.rs
21
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<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 的响应
|
||||
|
|
|
|||
Loading…
Reference in New Issue