A CLI tool to query local WeChat data with 11 commands: sessions, history, search, contacts, members, stats, export, favorites, unread, new-messages, and init. Features: - Self-contained init with key extraction (no external deps) - On-the-fly SQLCipher decryption with caching - JSON output by default for LLM/AI tool integration - Message type filtering and chat statistics - Markdown/txt export for conversations - Cross-platform: macOS, Windows, Linux
125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""search-messages 命令"""
|
||
|
||
import click
|
||
|
||
from ..core.contacts import get_contact_names
|
||
from ..core.messages import (
|
||
MSG_TYPE_FILTERS,
|
||
MSG_TYPE_NAMES,
|
||
collect_chat_search,
|
||
parse_time_range,
|
||
resolve_chat_context,
|
||
resolve_chat_contexts,
|
||
search_all_messages,
|
||
validate_pagination,
|
||
_candidate_page_size,
|
||
_page_ranked_entries,
|
||
)
|
||
from ..output.formatter import output
|
||
|
||
|
||
@click.command("search")
|
||
@click.argument("keyword")
|
||
@click.option("--chat", multiple=True, help="限定聊天对象(可多次指定)")
|
||
@click.option("--start-time", default="", help="起始时间")
|
||
@click.option("--end-time", default="", help="结束时间")
|
||
@click.option("--limit", default=20, help="返回数量(最大500)")
|
||
@click.option("--offset", default=0, help="分页偏移量")
|
||
@click.option("--format", "fmt", default="json", type=click.Choice(["json", "text"]), help="输出格式")
|
||
@click.option("--type", "msg_type", default=None, type=click.Choice(MSG_TYPE_NAMES), help="消息类型过滤")
|
||
@click.pass_context
|
||
def search(ctx, keyword, chat, start_time, end_time, limit, offset, fmt, msg_type):
|
||
"""搜索消息内容
|
||
|
||
\b
|
||
示例:
|
||
wechat-cli search "Claude" # 全局搜索
|
||
wechat-cli search "Claude" --chat "AI交流群" # 在指定群搜索
|
||
wechat-cli search "开会" --chat "群A" --chat "群B" # 同时搜多个群
|
||
wechat-cli search "你好" --start-time "2026-04-01" --limit 50
|
||
"""
|
||
app = ctx.obj
|
||
|
||
try:
|
||
validate_pagination(limit, offset)
|
||
start_ts, end_ts = parse_time_range(start_time, end_time)
|
||
except ValueError as e:
|
||
click.echo(f"错误: {e}", err=True)
|
||
ctx.exit(2)
|
||
|
||
names = get_contact_names(app.cache, app.decrypted_dir)
|
||
candidate_limit = _candidate_page_size(limit, offset)
|
||
chat_names = list(chat)
|
||
type_filter = MSG_TYPE_FILTERS[msg_type] if msg_type else None
|
||
|
||
if len(chat_names) == 1:
|
||
# 单聊搜索
|
||
chat_ctx = resolve_chat_context(chat_names[0], app.msg_db_keys, app.cache, app.decrypted_dir)
|
||
if not chat_ctx:
|
||
click.echo(f"找不到聊天对象: {chat_names[0]}", err=True)
|
||
ctx.exit(1)
|
||
if not chat_ctx['db_path']:
|
||
click.echo(f"找不到 {chat_ctx['display_name']} 的消息记录", err=True)
|
||
ctx.exit(1)
|
||
entries, failures = collect_chat_search(
|
||
chat_ctx, names, keyword, app.display_name_fn,
|
||
start_ts=start_ts, end_ts=end_ts, candidate_limit=candidate_limit,
|
||
msg_type_filter=type_filter,
|
||
)
|
||
scope = chat_ctx['display_name']
|
||
|
||
elif len(chat_names) > 1:
|
||
# 多聊搜索
|
||
resolved, unresolved, missing = resolve_chat_contexts(chat_names, app.msg_db_keys, app.cache, app.decrypted_dir)
|
||
if not resolved:
|
||
click.echo("错误: 没有可查询的聊天对象", err=True)
|
||
ctx.exit(1)
|
||
entries = []
|
||
failures = []
|
||
for rc in resolved:
|
||
e, f = collect_chat_search(
|
||
rc, names, keyword, app.display_name_fn,
|
||
start_ts=start_ts, end_ts=end_ts, candidate_limit=candidate_limit,
|
||
msg_type_filter=type_filter,
|
||
)
|
||
entries.extend(e)
|
||
failures.extend(f)
|
||
if unresolved:
|
||
failures.append("未找到: " + "、".join(unresolved))
|
||
scope = f"{len(resolved)} 个聊天对象"
|
||
|
||
else:
|
||
# 全局搜索
|
||
entries, failures = search_all_messages(
|
||
app.msg_db_keys, app.cache, names, keyword, app.display_name_fn,
|
||
start_ts=start_ts, end_ts=end_ts, candidate_limit=candidate_limit,
|
||
msg_type_filter=type_filter,
|
||
)
|
||
scope = "全部消息"
|
||
|
||
paged = _page_ranked_entries(entries, limit, offset)
|
||
|
||
if fmt == 'json':
|
||
output({
|
||
'scope': scope,
|
||
'keyword': keyword,
|
||
'count': len(paged),
|
||
'offset': offset,
|
||
'limit': limit,
|
||
'start_time': start_time or None,
|
||
'end_time': end_time or None,
|
||
'type': msg_type or None,
|
||
'results': [item[1] for item in paged],
|
||
'failures': failures if failures else None,
|
||
}, 'json')
|
||
else:
|
||
if not paged:
|
||
output(f"在 {scope} 中未找到包含 \"{keyword}\" 的消息", 'text')
|
||
return
|
||
header = f"在 {scope} 中搜索 \"{keyword}\" 找到 {len(paged)} 条结果(offset={offset}, limit={limit})"
|
||
if start_time or end_time:
|
||
header += f"\n时间范围: {start_time or '最早'} ~ {end_time or '最新'}"
|
||
if failures:
|
||
header += "\n查询失败: " + ";".join(failures)
|
||
output(header + ":\n\n" + "\n\n".join(item[1] for item in paged), 'text')
|