mirror of https://github.com/jackwener/wx-cli.git
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
parent
f0dcd4ea05
commit
1a943b418c
21
README.md
21
README.md
|
|
@ -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
|
||||
|
|
|
|||
26
SKILL.md
26
SKILL.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
||||
// 包一层内部 Arc:IPC 请求取 guard 后只做 Arc::clone(O(1)),
|
||||
|
|
|
|||
|
|
@ -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 不是列,只能扫 XML;11k+ 行 × 若干 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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
src/ipc.rs
36
src/ipc.rs
|
|
@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue