feat(sns): add sns-notifications / sns-feed / sns-search

加 3 个独立命令读朋友圈本地缓存(sns/sns.db):

- sns-notifications:点赞 / 评论通知,对应微信 app 朋友圈右上角的红
  点入口,含"别人赞/评你的帖子"和"你评过的帖子下的跟帖"两类。默认
  仅未读,--include-read 返回全部。
- sns-feed:Timeline 列表,按时间 / 作者筛选本地缓存的朋友圈。
- sns-search:正文(contentDesc)全文搜索,支持叠加时间 / 作者过滤。

daemon 预热阶段增加 sns.db 解密。三个新命令复用既有 helper:
extract_xml_text / fmt_time / resolve_username / names.display /
parse_time / parse_time_end。

XML parse 全部放在 spawn_blocking 里跑,避免在 tokio executor 线程
上做 regex 扫描;SQL + params 构造统一为 clauses / params Vec 模
式,与 q_sns_notifications / q_stats 一致。sns-feed 先收齐全部匹配
再按 create_time 排序 truncate,避免 tid DESC 不严格单调时丢帖。
pull/14/head
docacola 2026-04-18 16:44:11 +08:00
parent f0dcd4ea05
commit 1a943b418c
10 changed files with 597 additions and 0 deletions

View File

@ -156,6 +156,27 @@ wx search "会议" --in "工作群" --since 2026-01-01
会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。
### 朋友圈SNS
三个独立命令,区分"通知"和"帖子"
```bash
wx sns-notifications # 点赞/评论通知(默认仅未读)
wx sns-notifications --include-read -n 100 # 含已读
wx sns-feed # 近 20 条朋友圈(时间线)
wx sns-feed --user "张三" # 限定作者
wx sns-feed --since 2026-04-01 -n 100 # 按时间
wx sns-search "关键词" # 全文搜索朋友圈正文
wx sns-search "婚礼" --user "李四" --since 2023-01-01
```
- **sns-notifications** 返回互动通知:`type``like`/`comment`)、`from_nickname`、`content`(评论正文)、`feed_preview` + `feed_author`(对应原帖)
- **sns-feed** / **sns-search** 返回朋友圈帖子:`author`、`content`(正文)、`media_count`、`location`、`timestamp`
朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。
### 联系人 & 群组
```bash

View File

@ -148,6 +148,32 @@ wx contacts --query "李"
wx members "AI交流群"
```
### 朋友圈SNS
三个命令,作用各不同:
```bash
# 1) 互动通知(点赞 / 评论,默认仅未读)
wx sns-notifications
wx sns-notifications --include-read --since 2026-04-01 -n 100
# 2) 时间线:浏览本地缓存的朋友圈帖子
wx sns-feed # 近 20 条
wx sns-feed --user "张三" # 只看某人
wx sns-feed --since 2026-04-01 --until 2026-04-18 -n 100
# 3) 全文搜索:在正文里找关键词
wx sns-search "关键词"
wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50
```
**字段区分**
- `sns-notifications` 返回"通知"条目:`type``like`/`comment`)、`from_nickname`、`content`(评论正文,点赞为空)、`feed_preview` + `feed_author`(对应的原帖)
- `sns-feed` / `sns-search` 返回"帖子"条目:`author`、`content`(朋友圈正文)、`media_count`(图片/视频数)、`location`、`timestamp`
> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。
### 收藏与统计
```bash

View File

@ -12,6 +12,9 @@ pub mod members;
pub mod new_messages;
pub mod stats;
pub mod favorites;
pub mod sns_notifications;
pub mod sns_feed;
pub mod sns_search;
use anyhow::Result;
use clap::{Parser, Subcommand};
@ -181,6 +184,62 @@ enum Commands {
#[arg(long)]
json: bool,
},
/// 朋友圈互动通知:别人对我的朋友圈点赞/评论 + 我评过的帖子下的跟帖
SnsNotifications {
/// 显示数量
#[arg(short = 'n', long, default_value = "50")]
limit: usize,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 包含已读通知(默认仅未读)
#[arg(long)]
include_read: bool,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 朋友圈时间线:按时间/作者筛选本地缓存的朋友圈
SnsFeed {
/// 显示数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 只看指定作者(昵称 / 备注名 / 微信 ID模糊匹配
#[arg(long)]
user: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 朋友圈全文搜索:匹配正文关键词
SnsSearch {
/// 关键词
keyword: String,
/// 结果数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 限定作者(昵称 / 备注名 / 微信 ID
#[arg(long)]
user: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 管理 wx-daemon
Daemon {
#[command(subcommand)]
@ -236,6 +295,15 @@ fn dispatch(cli: Cli) -> Result<()> {
Commands::Favorites { limit, fav_type, query, json } => {
favorites::cmd_favorites(limit, fav_type, query, json)
}
Commands::SnsNotifications { limit, since, until, include_read, json } => {
sns_notifications::cmd_sns_notifications(limit, since, until, include_read, json)
}
Commands::SnsFeed { limit, since, until, user, json } => {
sns_feed::cmd_sns_feed(limit, since, until, user, json)
}
Commands::SnsSearch { keyword, limit, since, until, user, json } => {
sns_search::cmd_sns_search(keyword, limit, since, until, user, json)
}
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
}
}

View File

@ -0,0 +1,28 @@
use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end};
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sns_feed(
limit: usize,
since: Option<String>,
until: Option<String>,
user: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let req = Request::SnsFeed {
limit,
since: since_ts,
until: until_ts,
user,
};
let resp = transport::send(req)?;
let data = resp.data.get("posts")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -0,0 +1,28 @@
use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end};
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sns_notifications(
limit: usize,
since: Option<String>,
until: Option<String>,
include_read: bool,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let req = Request::SnsNotifications {
limit,
since: since_ts,
until: until_ts,
include_read,
};
let resp = transport::send(req)?;
let data = resp.data.get("notifications")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -0,0 +1,30 @@
use anyhow::Result;
use crate::ipc::Request;
use super::history::{parse_time, parse_time_end};
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sns_search(
keyword: String,
limit: usize,
since: Option<String>,
until: Option<String>,
user: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let req = Request::SnsSearch {
keyword,
limit,
since: since_ts,
until: until_ts,
user,
};
let resp = transport::send(req)?;
let data = resp.data.get("posts")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -73,6 +73,7 @@ async fn async_run() -> Result<()> {
names.msg_db_keys = msg_db_keys;
let _ = db.get("session/session.db").await;
let _ = db.get("sns/sns.db").await;
eprintln!("[daemon] 预热完成,联系人 {}", names.map.len());
// 包一层内部 ArcIPC 请求取 guard 后只做 Arc::cloneO(1)

View File

@ -1502,3 +1502,344 @@ pub async fn q_stats(
}))
}
/// 查询朋友圈互动通知(点赞 + 评论),对应微信 app 右上角的红点入口。
/// 空 `content` 是点赞,非空是评论正文。
pub async fn q_sns_notifications(
db: &DbCache,
names: &Names,
limit: usize,
since: Option<i64>,
until: Option<i64>,
include_read: bool,
) -> Result<Value> {
let path = db.get("sns/sns.db").await?
.context("无法解密 sns.db")?;
let path2 = path.clone();
type Row = (i64, i64, i64, i64, String, String, String);
let rows: Vec<Row> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path2)?;
let mut clauses: Vec<&str> = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if !include_read {
clauses.push("is_unread = 1");
}
if let Some(s) = since {
clauses.push("create_time >= ?");
params.push(Box::new(s));
}
if let Some(u) = until {
clauses.push("create_time <= ?");
params.push(Box::new(u));
}
let where_clause = if clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", clauses.join(" AND "))
};
let sql = format!(
"SELECT local_id, create_time, type, feed_id, from_username, from_nickname, content
FROM SnsMessage_tmp3 {} ORDER BY create_time DESC LIMIT ?",
where_clause
);
params.push(Box::new(limit as i64));
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2).unwrap_or(0),
row.get::<_, i64>(3).unwrap_or(0),
row.get::<_, String>(4).unwrap_or_default(),
row.get::<_, String>(5).unwrap_or_default(),
row.get::<_, String>(6).unwrap_or_default(),
)))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok::<_, anyhow::Error>(rows)
}).await??;
// 一次性取出涉及的 feed 原帖,避免 N+1 查询
let feed_ids: Vec<i64> = {
let mut v: Vec<i64> = rows.iter().map(|r| r.3).collect();
v.sort_unstable();
v.dedup();
v
};
let path3 = path.clone();
let feed_ids_clone = feed_ids.clone();
let feeds: HashMap<i64, (String, String)> = tokio::task::spawn_blocking(move || {
if feed_ids_clone.is_empty() {
return Ok::<_, anyhow::Error>(HashMap::new());
}
let conn = Connection::open(&path3)?;
let placeholders = std::iter::repeat("?").take(feed_ids_clone.len()).collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT tid, user_name, content FROM SnsTimeLine WHERE tid IN ({})",
placeholders
);
let params: Vec<&dyn rusqlite::types::ToSql> =
feed_ids_clone.iter().map(|id| id as &dyn rusqlite::types::ToSql).collect();
let mut stmt = conn.prepare(&sql)?;
let mut map = HashMap::new();
let mut rows2 = stmt.query(params.as_slice())?;
while let Some(row) = rows2.next()? {
let tid: i64 = row.get(0)?;
let author: String = row.get::<_, String>(1).unwrap_or_default();
let content: String = row.get::<_, String>(2).unwrap_or_default();
let preview = extract_xml_text(&content, "contentDesc")
.map(|s| s.chars().take(60).collect::<String>())
.unwrap_or_default();
// 原帖 user_name 偶尔为空(转发帖),再从 XML 兜一下
let author = if author.is_empty() {
extract_xml_text(&content, "username").unwrap_or_default()
} else {
author
};
map.insert(tid, (author, preview));
}
Ok(map)
}).await??;
let mut out = Vec::with_capacity(rows.len());
for (_local_id, ct, _typ, fid, from_u, from_nick, content) in rows {
let kind = if content.trim().is_empty() { "like" } else { "comment" };
let display = if !from_nick.is_empty() {
from_nick.clone()
} else {
names.display(&from_u)
};
let (feed_author_u, feed_preview) = feeds.get(&fid)
.cloned()
.unwrap_or_default();
let feed_author_display = if feed_author_u.is_empty() {
String::new()
} else {
names.display(&feed_author_u)
};
out.push(json!({
"type": kind,
"time": fmt_time(ct, "%m-%d %H:%M"),
"timestamp": ct,
"from_username": from_u,
"from_nickname": display,
"content": content,
"feed_id": fid,
"feed_author_username": feed_author_u,
"feed_author": feed_author_display,
"feed_preview": feed_preview,
}));
}
let total = out.len();
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 {
static RE: OnceLock<Regex> = OnceLock::new();
// location 是自闭合标签poiName 在属性里
RE.get_or_init(|| Regex::new(r#"<location[^>]*poiName="([^"]*)""#).unwrap())
}
/// SnsTimeLine 行解析产物。不含 display name依赖 Names需要出 spawn_blocking 再补)。
struct ParsedPost {
tid: i64,
create_time: i64,
author_username: String,
content: String,
media_count: i64,
location: String,
}
/// 纯 XML 解析,无 Names 依赖,可以在 spawn_blocking 里跑。
/// user_name_column 为空时从 TimelineObject/<username> 兜底(转发帖)。
fn parse_post_xml(tid: i64, user_name_column: &str, content: &str) -> ParsedPost {
let create_time = extract_xml_text(content, "createTime")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let text = extract_xml_text(content, "contentDesc").unwrap_or_default();
let author_username = if user_name_column.is_empty() {
extract_xml_text(content, "username").unwrap_or_default()
} else {
user_name_column.to_string()
};
let media_count = sns_media_count_re().find_iter(content).count() as i64;
let location = sns_location_re()
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_default();
ParsedPost { tid, create_time, author_username, content: text, media_count, location }
}
fn post_to_value(p: ParsedPost, names: &Names) -> Value {
let author = if p.author_username.is_empty() {
String::new()
} else {
names.display(&p.author_username)
};
json!({
"tid": p.tid,
"timestamp": p.create_time,
"time": fmt_time(p.create_time, "%Y-%m-%d %H:%M"),
"author_username": p.author_username,
"author": author,
"content": p.content,
"media_count": p.media_count,
"location": p.location,
})
}
/// 查询朋友圈时间线:按时间/作者筛选。用于浏览自己或好友的朋友圈。
pub async fn q_sns_feed(
db: &DbCache,
names: &Names,
limit: usize,
since: Option<i64>,
until: Option<i64>,
user: Option<&str>,
) -> Result<Value> {
let path = db.get("sns/sns.db").await?
.context("无法解密 sns.db")?;
let user_uname = match user {
Some(q) => Some(
resolve_username(q, names)
.with_context(|| format!("找不到联系人: {}", q))?,
),
None => None,
};
// XML parse 和时间过滤都放 spawn_blocking 里跑:
// createTime 不是列,只能扫 XML11k+ 行 × 若干 regex 不能在 tokio executor 上做。
let path2 = path.clone();
let parsed: Vec<ParsedPost> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path2)?;
let mut clauses: Vec<&str> = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(u) = user_uname.as_ref() {
clauses.push("user_name = ?");
params.push(Box::new(u.clone()));
}
let where_clause = if clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", clauses.join(" AND "))
};
let sql = format!(
"SELECT tid, user_name, content FROM SnsTimeLine {} ORDER BY tid DESC",
where_clause
);
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1).unwrap_or_default(),
row.get::<_, String>(2).unwrap_or_default(),
)))?;
let mut out: Vec<ParsedPost> = Vec::new();
for row in rows {
let (tid, uname, content) = row?;
let p = parse_post_xml(tid, &uname, &content);
if let Some(s) = since { if p.create_time < s { continue; } }
if let Some(u) = until { if p.create_time > u { continue; } }
out.push(p);
}
// tid DESC 不严格等于 createTime DESC不同账号 tid 生成算法不同),
// 所以要先收齐全部匹配的、按 create_time 排序,再 truncate —— 否则会丢帖。
out.sort_by_key(|p| std::cmp::Reverse(p.create_time));
out.truncate(limit);
Ok::<_, anyhow::Error>(out)
}).await??;
let posts: Vec<Value> = parsed.into_iter().map(|p| post_to_value(p, names)).collect();
let total = posts.len();
Ok(json!({ "posts": posts, "total": total }))
}
/// 搜索朋友圈全文:在 contentDesc正文里匹配 keyword可叠加时间 / 作者过滤。
pub async fn q_sns_search(
db: &DbCache,
names: &Names,
keyword: &str,
limit: usize,
since: Option<i64>,
until: Option<i64>,
user: Option<&str>,
) -> Result<Value> {
if keyword.trim().is_empty() {
anyhow::bail!("搜索关键词不能为空");
}
let path = db.get("sns/sns.db").await?
.context("无法解密 sns.db")?;
let user_uname = match user {
Some(q) => Some(
resolve_username(q, names)
.with_context(|| format!("找不到联系人: {}", q))?,
),
None => None,
};
// SQL LIKE 先在 content 上粗筛Rust 侧再用 extract_xml_text 精确校验
// keyword 落在 contentDesc 里(否则可能命中 XML 里的 URL / attachment 字段)。
// 反斜杠自己要先转义,否则 ESCAPE '\\' 会吃掉后面的字符。
let like_pattern = format!(
"%{}%",
keyword
.replace('\\', r"\\")
.replace('%', r"\%")
.replace('_', r"\_")
);
let keyword_owned = keyword.to_string();
let path2 = path.clone();
let parsed: Vec<ParsedPost> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path2)?;
let mut clauses: Vec<&str> = vec!["content LIKE ? ESCAPE '\\'"];
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(like_pattern)];
if let Some(u) = user_uname.as_ref() {
clauses.push("user_name = ?");
params.push(Box::new(u.clone()));
}
let sql = format!(
"SELECT tid, user_name, content FROM SnsTimeLine WHERE {} ORDER BY tid DESC",
clauses.join(" AND ")
);
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1).unwrap_or_default(),
row.get::<_, String>(2).unwrap_or_default(),
)))?;
let needle = keyword_owned.to_lowercase();
let mut out: Vec<ParsedPost> = Vec::new();
for row in rows {
let (tid, uname, content) = row?;
let desc = extract_xml_text(&content, "contentDesc").unwrap_or_default();
if !desc.to_lowercase().contains(&needle) { continue; }
let p = parse_post_xml(tid, &uname, &content);
if let Some(s) = since { if p.create_time < s { continue; } }
if let Some(u) = until { if p.create_time > u { continue; } }
out.push(p);
}
out.sort_by_key(|p| std::cmp::Reverse(p.create_time));
out.truncate(limit);
Ok::<_, anyhow::Error>(out)
}).await??;
let posts: Vec<Value> = parsed.into_iter().map(|p| post_to_value(p, names)).collect();
let total = posts.len();
Ok(json!({ "keyword": keyword, "posts": posts, "total": total }))
}

View File

@ -213,5 +213,23 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()),
}
}
SnsNotifications { limit, since, until, include_read } => {
match query::q_sns_notifications(db, &names_arc, limit, since, until, include_read).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
SnsFeed { limit, since, until, user } => {
match query::q_sns_feed(db, &names_arc, limit, since, until, user.as_deref()).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
SnsSearch { keyword, limit, since, until, user } => {
match query::q_sns_search(db, &names_arc, &keyword, limit, since, until, user.as_deref()).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
}
}

View File

@ -78,6 +78,42 @@ pub enum Request {
#[serde(skip_serializing_if = "Option::is_none")]
query: Option<String>,
},
/// 朋友圈互动通知(点赞 + 评论)
SnsNotifications {
#[serde(default = "default_limit_50")]
limit: usize,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
/// 包含已读通知(默认仅未读)
#[serde(default)]
include_read: bool,
},
/// 朋友圈时间线(按时间 / 作者筛选帖子)
SnsFeed {
#[serde(default = "default_limit_20")]
limit: usize,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
/// 作者昵称 / 备注名 / 微信 username模糊匹配
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
},
/// 朋友圈全文搜索(匹配 contentDesc
SnsSearch {
keyword: String,
#[serde(default = "default_limit_20")]
limit: usize,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
},
}