feat(sns): sns-feed / sns-search 输出完整 media[] 字段 (#15)

#14 之上增量:把 sns-feed / sns-search 的 media_count 升级成完整 media[] 数组(含 url/thumb/key/token/md5/enc_idx/size + video_md5/duration),下游可直接做图片代理或离线渲染。

- 用 roxmltree(pure Rust,无 C 依赖)替代 regex 抽属性
- 字段命名对齐 artifacts 仓库 Python _parse_media,跨实现 diff 友好
- 14 个 sns 单测:作者新增 6 个 fixture(单图/三图/视频/纯文字/malformed/缺 totalSize)+ 已有 8 个保持
- 与之前 PR #14 的 --user XML fallback 修复 / SNS_MAX_LIMIT / SNS_MAX_SCAN / escape_like_pattern 完全兼容

Author: leeguooooo <guoli@zhihu.com>
Co-fixed-by: wx-cli-coder (rebase + 冲突解决 + 测试模块合并 + media_count 语义文档补充)
pull/17/head
郭立lee 2026-04-19 02:22:55 +08:00 committed by GitHub
parent e8939f315d
commit 2b5d872f0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 336 additions and 17 deletions

7
Cargo.lock generated
View File

@ -719,6 +719,12 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.31.0" version = "0.31.0"
@ -1315,6 +1321,7 @@ dependencies = [
"md5", "md5",
"pbkdf2", "pbkdf2",
"regex", "regex",
"roxmltree",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -52,6 +52,7 @@ md5 = "0.7"
# 正则表达式 # 正则表达式
regex = "1" regex = "1"
roxmltree = "0.20"
# IPC Windows named pipeUnix 直接用 tokio::net::UnixListener # IPC Windows named pipeUnix 直接用 tokio::net::UnixListener
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]

View File

@ -173,7 +173,7 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01
``` ```
- **sns-notifications** 返回互动通知:`type``like`/`comment`)、`from_nickname`、`content`(评论正文)、`feed_preview` + `feed_author`(对应原帖) - **sns-notifications** 返回互动通知:`type``like`/`comment`)、`from_nickname`、`content`(评论正文)、`feed_preview` + `feed_author`(对应原帖)
- **sns-feed** / **sns-search** 返回朋友圈帖子:`author`、`content`(正文)、`media_count`、`location`、`timestamp` - **sns-feed** / **sns-search** 返回朋友圈帖子:`author`、`content`(正文)、`media`、`media_count`、`location`、`timestamp``media` 字段含每张图的 url/thumb/key/token/md5/enc_idx/size供下游做图片代理或离线渲染。`media_count = media.len()`,按 DOM 解析的合法 `<media>` 子节点计数malformed XML 返回 0
朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。 朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。

View File

@ -170,7 +170,7 @@ wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50
**字段区分** **字段区分**
- `sns-notifications` 返回"通知"条目:`type``like`/`comment`)、`from_nickname`、`content`(评论正文,点赞为空)、`feed_preview` + `feed_author`(对应的原帖) - `sns-notifications` 返回"通知"条目:`type``like`/`comment`)、`from_nickname`、`content`(评论正文,点赞为空)、`feed_preview` + `feed_author`(对应的原帖)
- `sns-feed` / `sns-search` 返回"帖子"条目:`author`、`content`(朋友圈正文)、`media_count`(图片/视频数)、`location`、`timestamp` - `sns-feed` / `sns-search` 返回"帖子"条目:`author`、`content`(朋友圈正文)、`media`、`media_count`(图片/视频数)、`location`、`timestamp``media` 字段含每张图的 url/thumb/key/token/md5/enc_idx/size供下游做图片代理或离线渲染。`media_count = media.len()`,按 DOM 解析的合法 `<media>` 子节点计数malformed XML 返回 0
> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。 > 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。

View File

@ -1,6 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{Local, TimeZone, Timelike}; use chrono::{Local, TimeZone, Timelike};
use regex::Regex; use regex::Regex;
use roxmltree::{Document, Node};
use rusqlite::Connection; use rusqlite::Connection;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
@ -1634,12 +1635,6 @@ pub async fn q_sns_notifications(
Ok(json!({ "notifications": out, "total": total })) Ok(json!({ "notifications": out, "total": total }))
} }
fn sns_media_count_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
// 只在 <mediaList> 里数 <media> 开标签,避免匹配到嵌套的其他 <media*> 字段
RE.get_or_init(|| Regex::new(r"<media>").unwrap())
}
fn sns_location_re() -> &'static Regex { fn sns_location_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new(); static RE: OnceLock<Regex> = OnceLock::new();
// location 是自闭合标签poiName 在属性里 // location 是自闭合标签poiName 在属性里
@ -1660,13 +1655,114 @@ fn escape_like_pattern(s: &str) -> String {
.replace('_', r"\_") .replace('_', r"\_")
} }
fn xml_child<'a, 'input>(node: Node<'a, 'input>, tag: &str) -> Option<Node<'a, 'input>> {
node.children()
.find(|child| child.is_element() && child.has_tag_name(tag))
}
fn xml_text<'a, 'input>(node: Option<Node<'a, 'input>>) -> Option<String> {
node.and_then(|n| n.text())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn xml_attr<'a, 'input>(node: Option<Node<'a, 'input>>, attr: &str) -> Option<String> {
node.and_then(|n| n.attribute(attr))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn insert_media_string(out: &mut serde_json::Map<String, Value>, key: &str, value: Option<String>) {
if let Some(value) = value {
out.insert(key.to_string(), Value::String(value));
}
}
fn insert_media_i64(out: &mut serde_json::Map<String, Value>, key: &str, value: Option<i64>) {
if let Some(value) = value {
out.insert(key.to_string(), Value::from(value));
}
}
/// 从 `SnsTimeLine.content` XML 里抽每个 `<media>` 的完整字段。
///
/// 字段名与 artifacts 仓库 `wechat_sns_dump.py::_parse_media` 对齐,
/// 便于跨实现 diff。缺失字段直接省略不输出 null供下游代理图片 / 离线渲染。
fn parse_post_media(xml: &str) -> Vec<Value> {
let doc = match Document::parse(xml) {
Ok(doc) => doc,
Err(_) => return Vec::new(),
};
let Some(media_list) = doc
.descendants()
.find(|node| node.has_tag_name("TimelineObject"))
.and_then(|node| xml_child(node, "ContentObject"))
.and_then(|node| xml_child(node, "mediaList"))
else {
return Vec::new();
};
media_list
.children()
.filter(|node| node.is_element() && node.has_tag_name("media"))
.map(|media| {
let url_el = xml_child(media, "url");
let thumb_el = xml_child(media, "thumb");
let size_el = xml_child(media, "size");
let mut out = serde_json::Map::new();
insert_media_string(&mut out, "type", xml_text(xml_child(media, "type")));
insert_media_string(&mut out, "sub_type", xml_text(xml_child(media, "sub_type")));
insert_media_string(&mut out, "url", xml_text(url_el));
insert_media_string(&mut out, "thumb", xml_text(thumb_el));
insert_media_string(&mut out, "md5", xml_attr(url_el, "md5"));
insert_media_string(&mut out, "url_key", xml_attr(url_el, "key"));
insert_media_string(&mut out, "url_token", xml_attr(url_el, "token"));
insert_media_string(&mut out, "url_enc_idx", xml_attr(url_el, "enc_idx"));
insert_media_string(&mut out, "thumb_key", xml_attr(thumb_el, "key"));
insert_media_string(&mut out, "thumb_token", xml_attr(thumb_el, "token"));
insert_media_string(&mut out, "thumb_enc_idx", xml_attr(thumb_el, "enc_idx"));
insert_media_i64(
&mut out,
"width",
xml_attr(size_el, "width").and_then(|v| v.parse::<i64>().ok()),
);
insert_media_i64(
&mut out,
"height",
xml_attr(size_el, "height").and_then(|v| v.parse::<i64>().ok()),
);
insert_media_i64(
&mut out,
"total_size",
xml_attr(size_el, "totalSize").and_then(|v| v.parse::<i64>().ok()),
);
insert_media_string(
&mut out,
"video_md5",
xml_text(xml_child(media, "videomd5")),
);
insert_media_i64(
&mut out,
"video_duration",
xml_text(xml_child(media, "videoDuration")).and_then(|v| v.parse::<i64>().ok()),
);
Value::Object(out)
})
.collect()
}
/// SnsTimeLine 行解析产物。不含 display name依赖 Names需要出 spawn_blocking 再补)。 /// SnsTimeLine 行解析产物。不含 display name依赖 Names需要出 spawn_blocking 再补)。
struct ParsedPost { struct ParsedPost {
tid: i64, tid: i64,
create_time: i64, create_time: i64,
author_username: String, author_username: String,
content: String, content: String,
media_count: i64, media: Vec<Value>,
location: String, location: String,
} }
@ -1682,13 +1778,13 @@ fn parse_post_xml(tid: i64, user_name_column: &str, content: &str) -> ParsedPost
} else { } else {
user_name_column.to_string() user_name_column.to_string()
}; };
let media_count = sns_media_count_re().find_iter(content).count() as i64; let media = parse_post_media(content);
let location = sns_location_re() let location = sns_location_re()
.captures(content) .captures(content)
.and_then(|c| c.get(1)) .and_then(|c| c.get(1))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
.unwrap_or_default(); .unwrap_or_default();
ParsedPost { tid, create_time, author_username, content: text, media_count, location } ParsedPost { tid, create_time, author_username, content: text, media, location }
} }
fn post_to_value(p: ParsedPost, names: &Names) -> Value { fn post_to_value(p: ParsedPost, names: &Names) -> Value {
@ -1704,7 +1800,8 @@ fn post_to_value(p: ParsedPost, names: &Names) -> Value {
"author_username": p.author_username, "author_username": p.author_username,
"author": author, "author": author,
"content": p.content, "content": p.content,
"media_count": p.media_count, "media_count": p.media.len() as i64,
"media": p.media,
"location": p.location, "location": p.location,
}) })
} }
@ -1856,14 +1953,18 @@ mod sns_tests {
fn make_post_xml(create_time: &str, desc: &str, username_tag: Option<&str>, media: usize, location: Option<&str>) -> String { fn make_post_xml(create_time: &str, desc: &str, username_tag: Option<&str>, media: usize, location: Option<&str>) -> String {
let username = username_tag.map(|u| format!("<username>{}</username>", u)).unwrap_or_default(); let username = username_tag.map(|u| format!("<username>{}</username>", u)).unwrap_or_default();
let media_tags = "<media>...</media>".repeat(media); let media_tags = "<media><type>2</type></media>".repeat(media);
let media_list = if media > 0 { format!("<mediaList>{}</mediaList>", media_tags) } else { String::new() }; let content_object = if media > 0 {
format!("<ContentObject><mediaList>{}</mediaList></ContentObject>", media_tags)
} else {
String::new()
};
let loc = location let loc = location
.map(|p| format!(r#"<location poiName="{}" longitude="0" latitude="0" />"#, p)) .map(|p| format!(r#"<location poiName="{}" longitude="0" latitude="0" />"#, p))
.unwrap_or_default(); .unwrap_or_default();
format!( format!(
"<TimelineObject>{}<createTime>{}</createTime><contentDesc>{}</contentDesc>{}{}</TimelineObject>", "<TimelineObject>{}<createTime>{}</createTime><contentDesc>{}</contentDesc>{}{}</TimelineObject>",
username, create_time, desc, media_list, loc username, create_time, desc, content_object, loc
) )
} }
@ -1874,7 +1975,7 @@ mod sns_tests {
assert_eq!(p.author_username, "wxid_column"); assert_eq!(p.author_username, "wxid_column");
assert_eq!(p.create_time, 1700000000); assert_eq!(p.create_time, 1700000000);
assert_eq!(p.content, "hello"); assert_eq!(p.content, "hello");
assert_eq!(p.media_count, 0); assert_eq!(p.media.len(), 0);
assert_eq!(p.location, ""); assert_eq!(p.location, "");
} }
@ -1897,7 +1998,7 @@ mod sns_tests {
fn parse_counts_media_and_extracts_location() { fn parse_counts_media_and_extracts_location() {
let xml = make_post_xml("1700000002", "post", None, 3, Some("Wuxi")); let xml = make_post_xml("1700000002", "post", None, 3, Some("Wuxi"));
let p = parse_post_xml(4, "wxid", &xml); let p = parse_post_xml(4, "wxid", &xml);
assert_eq!(p.media_count, 3); assert_eq!(p.media.len(), 3);
assert_eq!(p.location, "Wuxi"); assert_eq!(p.location, "Wuxi");
} }
@ -1929,4 +2030,214 @@ mod sns_tests {
assert_eq!(escape_like_pattern("中文关键词"), "中文关键词"); assert_eq!(escape_like_pattern("中文关键词"), "中文关键词");
assert_eq!(escape_like_pattern(""), ""); assert_eq!(escape_like_pattern(""), "");
} }
fn media_object(value: &Value) -> &serde_json::Map<String, Value> {
value.as_object().expect("media entry should be an object")
}
#[test]
fn single_image_media() {
let xml = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<mediaList>
<media>
<type>2</type>
<url enc_idx="1" key="placeholder-key" token="placeholder-token" md5="placeholder-md5">https://szmmsns.qpic.cn/&lt;redacted&gt;/image.jpg</url>
<thumb enc_idx="0" key="placeholder-thumb-key" token="placeholder-thumb-token">https://szmmsns.qpic.cn/&lt;redacted&gt;/thumb.jpg</thumb>
<size width="1440" height="1080" totalSize="123456" />
</media>
</mediaList>
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
let media = parse_post_media(xml);
assert_eq!(media.len(), 1);
let item = media_object(&media[0]);
assert_eq!(item.get("type").and_then(Value::as_str), Some("2"));
assert_eq!(
item.get("url").and_then(Value::as_str),
Some("https://szmmsns.qpic.cn/<redacted>/image.jpg")
);
assert_eq!(
item.get("thumb").and_then(Value::as_str),
Some("https://szmmsns.qpic.cn/<redacted>/thumb.jpg")
);
assert_eq!(item.get("url_enc_idx").and_then(Value::as_str), Some("1"));
assert_eq!(
item.get("url_key").and_then(Value::as_str),
Some("placeholder-key")
);
assert_eq!(
item.get("url_token").and_then(Value::as_str),
Some("placeholder-token")
);
assert_eq!(
item.get("md5").and_then(Value::as_str),
Some("placeholder-md5")
);
assert_eq!(item.get("width").and_then(Value::as_i64), Some(1440));
assert_eq!(item.get("height").and_then(Value::as_i64), Some(1080));
assert_eq!(item.get("total_size").and_then(Value::as_i64), Some(123456));
}
#[test]
fn three_images_media() {
let xml = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<mediaList>
<media>
<type>2</type>
<sub_type>10</sub_type>
<url enc_idx="1" key="placeholder-key-1" token="placeholder-token-1">https://szmmsns.qpic.cn/&lt;redacted&gt;/image-1.jpg</url>
<thumb>https://szmmsns.qpic.cn/&lt;redacted&gt;/thumb-1.jpg</thumb>
<size width="100" height="200" totalSize="111" />
</media>
<media>
<type>2</type>
<sub_type>11</sub_type>
<url enc_idx="0" key="placeholder-key-2" token="placeholder-token-2">https://szmmsns.qpic.cn/&lt;redacted&gt;/image-2.jpg</url>
<thumb>https://szmmsns.qpic.cn/&lt;redacted&gt;/thumb-2.jpg</thumb>
<size width="300" height="400" totalSize="222" />
</media>
<media>
<type>6</type>
<url>https://szmmsns.qpic.cn/&lt;redacted&gt;/image-3.jpg</url>
<thumb enc_idx="1" key="placeholder-thumb-key-3" token="placeholder-thumb-token-3">https://szmmsns.qpic.cn/&lt;redacted&gt;/thumb-3.jpg</thumb>
<size width="500" height="600" totalSize="333" />
</media>
</mediaList>
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
let media = parse_post_media(xml);
assert_eq!(media.len(), 3);
let first = media_object(&media[0]);
assert_eq!(first.get("sub_type").and_then(Value::as_str), Some("10"));
assert_eq!(
first.get("url_key").and_then(Value::as_str),
Some("placeholder-key-1")
);
let second = media_object(&media[1]);
assert_eq!(second.get("sub_type").and_then(Value::as_str), Some("11"));
assert_eq!(second.get("width").and_then(Value::as_i64), Some(300));
let third = media_object(&media[2]);
assert_eq!(third.get("type").and_then(Value::as_str), Some("6"));
assert_eq!(
third.get("thumb_key").and_then(Value::as_str),
Some("placeholder-thumb-key-3")
);
}
#[test]
fn video_media() {
let xml = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<mediaList>
<media>
<type>15</type>
<url enc_idx="1" key="placeholder-video-key" token="placeholder-video-token">https://szmmsns.qpic.cn/&lt;redacted&gt;/video.mp4</url>
<thumb>https://szmmsns.qpic.cn/&lt;redacted&gt;/video-thumb.jpg</thumb>
<size width="720" height="1280" />
<videomd5>&lt;placeholder-video-md5&gt;</videomd5>
<videoDuration>37</videoDuration>
</media>
</mediaList>
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
let media = parse_post_media(xml);
assert_eq!(media.len(), 1);
let item = media_object(&media[0]);
assert_eq!(
item.get("video_md5").and_then(Value::as_str),
Some("<placeholder-video-md5>")
);
assert_eq!(item.get("video_duration").and_then(Value::as_i64), Some(37));
assert!(!item.contains_key("total_size"));
}
#[test]
fn text_only_post() {
let without_media_list = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<type>1</type>
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
let empty_media_list = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<mediaList />
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
assert!(parse_post_media(without_media_list).is_empty());
assert!(parse_post_media(empty_media_list).is_empty());
}
#[test]
fn malformed_xml() {
let xml = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<mediaList>
<media>
<type>2</type>
</mediaList>
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
assert!(parse_post_media(xml).is_empty());
}
#[test]
fn size_without_total_size_omits_total_size_key() {
let xml = r#"
<SnsDataItem>
<TimelineObject>
<ContentObject>
<mediaList>
<media>
<type>2</type>
<size width="640" height="480" />
</media>
</mediaList>
</ContentObject>
</TimelineObject>
</SnsDataItem>
"#;
let media = parse_post_media(xml);
assert_eq!(media.len(), 1);
let item = media_object(&media[0]);
assert_eq!(item.get("width").and_then(Value::as_i64), Some(640));
assert_eq!(item.get("height").and_then(Value::as_i64), Some(480));
assert!(!item.contains_key("total_size"));
}
} }