Initial open source release

This commit is contained in:
joeseesun
2026-05-24 13:42:56 +08:00
commit 8abb29e13e
75 changed files with 13298 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
import NodeCache from 'node-cache';
export const cache = new NodeCache({
stdTTL: 30,
checkperiod: 60,
useClones: false,
});
export const CK = {
sessions: () => 'sessions:all',
daemon: () => 'daemon:status',
stats: (chatroomId: string, since: string, until: string) =>
`stats:${chatroomId}:${since}:${until}`,
} as const;
+72
View File
@@ -0,0 +1,72 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
export const DATA_DIR =
process.env.WECHAT_RADAR_DATA_DIR ||
join(homedir(), '.wechat-radar');
const CONFIG_PATH = join(DATA_DIR, 'config.json');
export interface Config {
myNicknames: string[];
defaultRange: 'day' | 'week' | 'month' | 'quarter' | 'year';
rescanConcurrency: number;
privacyConfirmed: boolean;
setupCompleted: boolean;
demoMode: boolean;
defaultSyncDays: number;
}
function envNames(): string[] {
return (process.env.WECHAT_RADAR_MY_NAMES || '')
.split(',')
.map((name) => name.trim())
.filter(Boolean);
}
const DEFAULTS: Config = {
myNicknames: envNames(),
defaultRange: 'week',
rescanConcurrency: 5,
privacyConfirmed: false,
setupCompleted: false,
demoMode: process.env.WECHAT_RADAR_DEMO === '1',
defaultSyncDays: 7,
};
export function readConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULTS, null, 2), 'utf-8');
return DEFAULTS;
}
try {
const raw = readFileSync(CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(raw) as Partial<Config>;
const merged = { ...DEFAULTS, ...parsed };
if (envNames().length > 0) merged.myNicknames = envNames();
if (process.env.WECHAT_RADAR_DEMO === '1') merged.demoMode = true;
return merged;
} catch {
return DEFAULTS;
}
}
export function writeConfig(patch: Partial<Config>): Config {
const cur = readConfig();
const merged = { ...cur, ...patch };
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8');
return merged;
}
export function configStatus() {
const cfg = readConfig();
return {
dataDir: DATA_DIR,
configPath: CONFIG_PATH,
configured: cfg.setupCompleted && cfg.privacyConfirmed && (cfg.demoMode || cfg.myNicknames.length > 0),
config: cfg,
};
}
+836
View File
@@ -0,0 +1,836 @@
import { db } from './db';
import { cache } from './cache';
import { todayStr } from './range';
const MAX_ROWS = 1600;
const MAX_MUST_READ = 8;
const MAX_OPPORTUNITIES = 5;
const MAX_SIGNAL_SOURCES = 8;
const MAX_ACTION_ITEMS = 8;
const MAX_TOPIC_LIFECYCLE = 6;
const MAX_LINK_HIGHLIGHTS = 8;
const MAX_PEOPLE_RADAR = 8;
const MAX_CONTENT_IDEAS = 6;
const MAX_ANOMALIES = 6;
const CACHE_TTL_SECONDS = 90;
const URL_RE = /https?:\/\/[^\s<>"']+/i;
const URL_GLOBAL_RE = /https?:\/\/[^\s<>"']+/g;
const TOOL_RE =
/工具|产品|项目|插件|模型|智能体|Agent|Claude|Gemini|Codex|API|CLI|MCP|开源|GitHub|Chrome|飞书|Notion|Obsidian|workflow|workspace/i;
const OPPORTUNITY_RE =
/求推荐|求一个|谁有|谁能.*(推荐|帮|做|开发|联系)|有没有.*(工具|方案|资源|推荐)|想找|找人|招募|报名|内测|名额|一起做|采购|团购|项目合作|合作.*(项目|机会|对接|商演|商务)|需要.*(推荐|合作|对接|开发|方案)/i;
const ACTION_RE = /帮忙|看看|回复|跟进|对接|联系|报名|填写|试试|评估|整理|发我|私信/i;
const QUESTION_RE = /[??]|怎么|如何|为啥|为什么|能不能|可不可以|有没有/i;
const NOISE_RE = /撤回了一条消息|邀请.*加入了群聊|移出了群聊|以下为新消息/i;
const DIGEST_RE = /日报|每日情报|群日报|资源分享|今日小结|知识库更新/i;
interface MessageSignalRow {
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
}
interface TopicDefinition {
title: string;
keywords: string[];
re: RegExp;
}
const TOPIC_DEFINITIONS: TopicDefinition[] = [
{ title: 'Codex / Claude Code 工作流', keywords: ['Codex', 'Claude Code', 'CLI'], re: /codex|claude code|claude.?skills|clawdbot|cli|vibe.?coding/i },
{ title: 'AI Agent 与智能体', keywords: ['Agent', '智能体', '多智能体'], re: /agent|智能体|multi.?agent|工作流|workflow/i },
{ title: 'AI 工具与产品体验', keywords: ['工具', '产品', '内测'], re: /工具|产品|插件|内测|体验|注册|api|模型/i },
{ title: 'MCP / Skills / 开源项目', keywords: ['MCP', 'Skills', 'GitHub'], re: /mcp|skills?|github|开源|repo|仓库/i },
{ title: '内容创作与 AIGC', keywords: ['AIGC', '视频', '小红书'], re: /aigc|视频|音乐|图像|小红书|公众号|内容|创作|封面/i },
{ title: 'GEO / SEO / AI 营销', keywords: ['GEO', 'SEO', '营销'], re: /geo|seo|营销|搜索|获客|品牌|公关/i },
{ title: '知识库与飞书文档', keywords: ['飞书', '知识库', '文档'], re: /飞书|知识库|文档|notion|obsidian|wiki|表格/i },
{ title: '活动 / 报名 / 社群运营', keywords: ['活动', '报名', '直播'], re: /活动|报名|直播|训练营|课程|大会|线下|分享会|名额/i },
{ title: '团购 / 采购 / 商务机会', keywords: ['团购', '采购', '合作'], re: /团购|采购|报价|预算|合作|商务|对接/i },
{ title: '投资 / 财经 / 宏观讨论', keywords: ['投资', '财经', '股票'], re: /投资|财经|股票|基金|币圈|crypto|美股|港股/i },
];
export interface DashboardSignalItem {
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
time: string;
title: string;
snippet: string;
score: number;
reasons: string[];
}
export interface DashboardOpportunityItem extends DashboardSignalItem {
action: string;
}
export interface DashboardSignalSource {
sender: string;
signal_count: number;
group_count: number;
top_group: string;
last_seen: string;
strengths: string[];
}
export interface DashboardActionItem extends DashboardOpportunityItem {
why: string;
urgency: 'high' | 'medium' | 'low';
}
export interface DashboardTopicLifecycle {
title: string;
status: 'rising' | 'spreading' | 'hot' | 'cooling';
today_count: number;
previous_avg: number;
group_count: number;
reason: string;
keywords: string[];
}
export interface DashboardLinkHighlight {
kind: 'article' | 'tool';
title: string;
url: string;
domain: string;
score: number;
verdict: string;
count: number;
group_count: number;
last_seen: string;
}
export interface DashboardPeopleRadar {
sender: string;
role: '分享者' | '需求提出者' | '连接者' | '观点源';
score: number;
group_count: number;
signal_count: number;
top_group: string;
reason: string;
}
export interface DashboardContentIdea {
title: string;
angle: string;
suggested_channel: '公众号' | 'X' | '小红书' | '博客';
evidence: string;
source_count: number;
}
export interface DashboardAnomalySignal {
kind: 'spike' | 'cross_group' | 'dense_links' | 'quiet_day';
title: string;
description: string;
severity: 'high' | 'medium' | 'low';
href?: string;
}
export interface DashboardIntelligence {
date: string;
must_read: DashboardSignalItem[];
opportunities: DashboardOpportunityItem[];
signal_sources: DashboardSignalSource[];
action_items: DashboardActionItem[];
topic_lifecycle: DashboardTopicLifecycle[];
link_highlights: DashboardLinkHighlight[];
people_radar: DashboardPeopleRadar[];
content_ideas: DashboardContentIdea[];
anomalies: DashboardAnomalySignal[];
}
export function buildDashboardIntelligence(
date = todayStr(),
groupNames = new Map<string, string>(),
): DashboardIntelligence {
date = resolveIntelligenceDate(date);
const key = `dashboard-intelligence:${date}:v11`;
const cached = cache.get(key) as DashboardIntelligence | undefined;
if (cached) return cached;
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp
FROM messages
WHERE date = ?
AND length(content) >= 8
ORDER BY timestamp DESC
LIMIT ?`,
)
.all(date, MAX_ROWS) as MessageSignalRow[];
const historyRows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp
FROM messages
WHERE date >= ?
AND date <= ?
AND length(content) >= 4
ORDER BY timestamp DESC
LIMIT ?`,
)
.all(minusDays(date, 7), date, 9000) as MessageSignalRow[];
const candidates: DashboardSignalItem[] = [];
const opportunities: DashboardOpportunityItem[] = [];
const seenOpportunities = new Set<string>();
const sourceMap = new Map<
string,
{
sender: string;
signal_count: number;
groups: Set<string>;
topGroups: Map<string, number>;
last_seen: string;
link_count: number;
opportunity_count: number;
tool_count: number;
}
>();
const linkBuckets = new Map<
string,
{
kind: 'article' | 'tool';
title: string;
url: string;
domain: string;
count: number;
groups: Set<string>;
last_seen: string;
snippets: string[];
}
>();
const seenSnippets = new Set<string>();
for (const row of rows) {
const clean = cleanContent(row.content);
if (!clean || NOISE_RE.test(row.content)) continue;
const score = scoreContent(clean);
if (score < 4) continue;
const title = titleFromContent(clean);
const dedupeKey = normalizeDedupe(title || clean);
if (seenSnippets.has(dedupeKey)) continue;
seenSnippets.add(dedupeKey);
const reasons = reasonsFor(clean);
const item: DashboardSignalItem = {
chatroom_id: row.chatroom_id,
chat_name: groupNames.get(row.chatroom_id) ?? row.chatroom_id,
local_id: row.local_id,
sender: row.sender || '未知成员',
time: row.time,
title,
snippet: clean.slice(0, 150),
score,
reasons,
};
candidates.push(item);
if (isOpportunity(clean)) {
const opportunityKey = opportunityDedupeKey(clean, item.title);
if (!seenOpportunities.has(opportunityKey)) {
seenOpportunities.add(opportunityKey);
opportunities.push({ ...item, action: actionFor(clean) });
}
}
const sourceKey = item.sender.trim() || '未知成员';
const source = sourceMap.get(sourceKey) ?? {
sender: sourceKey,
signal_count: 0,
groups: new Set<string>(),
topGroups: new Map<string, number>(),
last_seen: item.time,
link_count: 0,
opportunity_count: 0,
tool_count: 0,
};
source.signal_count++;
source.groups.add(row.chatroom_id);
source.topGroups.set(item.chat_name, (source.topGroups.get(item.chat_name) ?? 0) + 1);
source.last_seen = source.last_seen > item.time ? source.last_seen : item.time;
if (URL_RE.test(clean) || clean.includes('链接')) source.link_count++;
if (isOpportunity(clean)) source.opportunity_count++;
if (TOOL_RE.test(clean)) source.tool_count++;
sourceMap.set(sourceKey, source);
for (const url of extractUrls(row.content)) {
const domain = domainOf(url);
if (!domain) continue;
const kind = isArticleUrl(url) ? 'article' : isToolUrl(url, clean) ? 'tool' : null;
if (!kind) continue;
const key = normalizeUrlKey(url);
const bucket = linkBuckets.get(key) ?? {
kind,
title: titleFromLinkContext(row.content, url),
url,
domain,
count: 0,
groups: new Set<string>(),
last_seen: row.time,
snippets: [],
};
bucket.count++;
bucket.groups.add(row.chatroom_id);
bucket.last_seen = bucket.last_seen > row.time ? bucket.last_seen : row.time;
if (clean) bucket.snippets.push(clean.slice(0, 80));
linkBuckets.set(key, bucket);
}
}
const mustRead = candidates
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
.slice(0, MAX_MUST_READ);
const opportunityItems = opportunities
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
.slice(0, MAX_OPPORTUNITIES);
const signalSources = Array.from(sourceMap.values())
.map((s) => ({
sender: s.sender,
signal_count: s.signal_count,
group_count: s.groups.size,
top_group: topEntry(s.topGroups),
last_seen: s.last_seen,
strengths: strengthsFor(s),
}))
.filter((s) => s.signal_count >= 2)
.sort((a, b) => b.signal_count - a.signal_count || b.group_count - a.group_count)
.slice(0, MAX_SIGNAL_SOURCES);
const actionItems = buildActionItems(opportunityItems, mustRead);
const topicLifecycle = buildTopicLifecycle(date, historyRows);
const linkHighlights = buildLinkHighlights(linkBuckets);
const peopleRadar = buildPeopleRadar(sourceMap);
const contentIdeas = buildContentIdeas(topicLifecycle, mustRead, linkHighlights);
const anomalies = buildAnomalies(date, groupNames, linkBuckets, rows.length);
const result = {
date,
must_read: mustRead,
opportunities: opportunityItems,
signal_sources: signalSources,
action_items: actionItems,
topic_lifecycle: topicLifecycle,
link_highlights: linkHighlights,
people_radar: peopleRadar,
content_ideas: contentIdeas,
anomalies,
};
cache.set(key, result, CACHE_TTL_SECONDS);
return result;
}
function resolveIntelligenceDate(date: string): string {
const row = db()
.prepare('SELECT date FROM messages WHERE date <= ? GROUP BY date ORDER BY date DESC LIMIT 1')
.get(date) as { date: string } | undefined;
return row?.date ?? date;
}
function buildActionItems(
opportunities: DashboardOpportunityItem[],
mustRead: DashboardSignalItem[],
): DashboardActionItem[] {
const fromOpportunity = opportunities.map((item) => ({
...item,
why: whyForAction(item.snippet),
urgency: urgencyFor(item.snippet, item.score),
}));
const fallback = mustRead
.filter((item) => item.reasons.includes('问题') || item.reasons.includes('工具/产品'))
.slice(0, 3)
.map((item) => ({
...item,
action: item.reasons.includes('问题') ? '可回复观点' : '可试用/收藏',
why: item.reasons.includes('问题') ? '包含明确问题,适合补充观点或资源' : '包含工具/产品线索,适合试用或收入素材库',
urgency: 'medium' as const,
}));
const seen = new Set<string>();
return [...fromOpportunity, ...fallback]
.filter((item) => {
const key = normalizeDedupe(`${item.chatroom_id}:${item.title}`);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, MAX_ACTION_ITEMS);
}
function buildTopicLifecycle(date: string, rows: MessageSignalRow[]): DashboardTopicLifecycle[] {
const dayCounts = new Map<string, Map<string, { count: number; groups: Set<string> }>>();
for (const row of rows) {
const d = row.time.slice(0, 10);
if (!d) continue;
const clean = cleanContent(row.content);
for (const topic of TOPIC_DEFINITIONS) {
if (!topic.re.test(clean)) continue;
const perDay = dayCounts.get(topic.title) ?? new Map<string, { count: number; groups: Set<string> }>();
const bucket = perDay.get(d) ?? { count: 0, groups: new Set<string>() };
bucket.count++;
bucket.groups.add(row.chatroom_id);
perDay.set(d, bucket);
dayCounts.set(topic.title, perDay);
}
}
return TOPIC_DEFINITIONS.map((topic) => {
const perDay = dayCounts.get(topic.title) ?? new Map<string, { count: number; groups: Set<string> }>();
const today = perDay.get(date) ?? { count: 0, groups: new Set<string>() };
const previousValues = Array.from(perDay.entries())
.filter(([d]) => d < date)
.map(([, v]) => v.count);
const previousAvg =
previousValues.length > 0 ? previousValues.reduce((sum, n) => sum + n, 0) / previousValues.length : 0;
const ratio = today.count / Math.max(previousAvg, 1);
const status: DashboardTopicLifecycle['status'] =
today.groups.size >= 5
? 'spreading'
: ratio >= 1.8 && today.count >= 5
? 'rising'
: today.count >= 16
? 'hot'
: today.count < previousAvg * 0.45 && previousAvg >= 6
? 'cooling'
: 'hot';
return {
title: topic.title,
status,
today_count: today.count,
previous_avg: Number(previousAvg.toFixed(1)),
group_count: today.groups.size,
reason: topicReason(status, today.count, previousAvg, today.groups.size),
keywords: topic.keywords,
};
})
.filter((topic) => topic.today_count > 0 || topic.status === 'cooling')
.sort((a, b) => {
const priority = statusWeight(b.status) - statusWeight(a.status);
return priority || b.today_count - a.today_count || b.group_count - a.group_count;
})
.slice(0, MAX_TOPIC_LIFECYCLE);
}
function buildLinkHighlights(
linkBuckets: Map<
string,
{
kind: 'article' | 'tool';
title: string;
url: string;
domain: string;
count: number;
groups: Set<string>;
last_seen: string;
snippets: string[];
}
>,
): DashboardLinkHighlight[] {
return Array.from(linkBuckets.values())
.map((item) => {
const score = item.count * 2 + item.groups.size * 3 + (item.kind === 'tool' ? 2 : 0);
return {
kind: item.kind,
title: item.title,
url: item.url,
domain: item.domain,
score,
verdict: verdictForLink(item.kind, item.count, item.groups.size, item.snippets.join(' ')),
count: item.count,
group_count: item.groups.size,
last_seen: item.last_seen,
};
})
.sort((a, b) => b.score - a.score || b.last_seen.localeCompare(a.last_seen))
.slice(0, MAX_LINK_HIGHLIGHTS);
}
function buildPeopleRadar(
sourceMap: Map<
string,
{
sender: string;
signal_count: number;
groups: Set<string>;
topGroups: Map<string, number>;
last_seen: string;
link_count: number;
opportunity_count: number;
tool_count: number;
}
>,
): DashboardPeopleRadar[] {
return Array.from(sourceMap.values())
.map((s) => {
const score = s.signal_count * 2 + s.groups.size * 3 + s.link_count + s.opportunity_count * 2 + s.tool_count;
const role: DashboardPeopleRadar['role'] =
s.opportunity_count >= 2 ? '需求提出者' : s.groups.size >= 3 ? '连接者' : s.link_count >= s.tool_count ? '分享者' : '观点源';
return {
sender: s.sender,
role,
score,
group_count: s.groups.size,
signal_count: s.signal_count,
top_group: topEntry(s.topGroups),
reason: personReason(role, s.signal_count, s.groups.size),
};
})
.filter((p) => p.signal_count >= 2)
.sort((a, b) => b.score - a.score || b.group_count - a.group_count)
.slice(0, MAX_PEOPLE_RADAR);
}
function buildContentIdeas(
topics: DashboardTopicLifecycle[],
mustRead: DashboardSignalItem[],
links: DashboardLinkHighlight[],
): DashboardContentIdea[] {
const ideas: DashboardContentIdea[] = [];
for (const topic of topics.slice(0, 4)) {
ideas.push({
title: `${topic.title}:今天微信群里真正升温的信号`,
angle: topic.status === 'spreading' ? '从跨群扩散解释为什么它值得关注' : '从真实讨论里提炼一个可执行判断',
suggested_channel: topic.title.includes('工作流') || topic.title.includes('开源') ? '博客' : '公众号',
evidence: topic.reason,
source_count: topic.today_count,
});
}
for (const link of links.slice(0, 2)) {
ideas.push({
title: `${link.kind === 'tool' ? '新工具观察' : '文章拆解'}${link.title}`,
angle: link.verdict,
suggested_channel: link.kind === 'tool' ? 'X' : '公众号',
evidence: `${link.group_count} 个群提到,${link.count} 次出现`,
source_count: link.count,
});
}
if (ideas.length < MAX_CONTENT_IDEAS) {
for (const item of mustRead.slice(0, MAX_CONTENT_IDEAS - ideas.length)) {
ideas.push({
title: item.title,
angle: item.reasons.includes('问题') ? '从一个真实问题切入,给出判断和清单' : '把高信号讨论整理成一篇短观点',
suggested_channel: item.reasons.includes('工具/产品') ? 'X' : '公众号',
evidence: `${item.chat_name} · ${item.sender}`,
source_count: item.score,
});
}
}
return ideas.slice(0, MAX_CONTENT_IDEAS);
}
function buildAnomalies(
date: string,
groupNames: Map<string, string>,
linkBuckets: Map<string, { count: number; groups: Set<string>; title: string; kind: 'article' | 'tool'; url: string }>,
todayRows: number,
): DashboardAnomalySignal[] {
const anomalies: DashboardAnomalySignal[] = [];
const rows = db()
.prepare(
`SELECT chatroom_id, date, total
FROM daily_stats
WHERE date >= ? AND date <= ? AND total > 0`,
)
.all(minusDays(date, 7), date) as Array<{ chatroom_id: string; date: string; total: number }>;
const byGroup = new Map<string, Array<{ date: string; total: number }>>();
for (const row of rows) {
const arr = byGroup.get(row.chatroom_id) ?? [];
arr.push({ date: row.date, total: row.total });
byGroup.set(row.chatroom_id, arr);
}
for (const [chatroomId, values] of byGroup) {
const today = values.find((v) => v.date === date)?.total ?? 0;
const prev = values.filter((v) => v.date < date).map((v) => v.total);
if (today < 20 || prev.length === 0) continue;
const avg = prev.reduce((sum, n) => sum + n, 0) / prev.length;
if (today >= Math.max(30, avg * 2.2)) {
anomalies.push({
kind: 'spike',
title: `${groupNames.get(chatroomId) ?? chatroomId} 突然升温`,
description: `今日 ${today} 条,约为近 7 日均值 ${avg.toFixed(1)}${Math.round(today / Math.max(avg, 1))}`,
severity: today >= avg * 4 ? 'high' : 'medium',
href: `/groups/${encodeURIComponent(chatroomId)}?date=${date}`,
});
}
}
for (const link of Array.from(linkBuckets.values()).filter((l) => l.groups.size >= 3).slice(0, 3)) {
anomalies.push({
kind: 'cross_group',
title: `${link.kind === 'tool' ? '工具' : '文章'}跨群扩散`,
description: `${link.title}${link.groups.size} 个群同时提到,适合优先查看`,
severity: link.groups.size >= 5 ? 'high' : 'medium',
href: link.url,
});
}
if (todayRows === 0) {
anomalies.push({
kind: 'quiet_day',
title: '今日暂无本地消息',
description: '可能还未同步当天消息,建议重扫或检查 wx-daemon',
severity: 'low',
});
}
return anomalies
.sort((a, b) => severityWeight(b.severity) - severityWeight(a.severity))
.slice(0, MAX_ANOMALIES);
}
function cleanContent(content: string): string {
const xmlText = xmlSummary(content);
return (xmlText || content)
.replace(/https?:\/\/\S+/g, ' 链接 ')
.replace(/\[引用\]/g, '')
.replace(/↳/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function minusDays(date: string, days: number): string {
const [year, month, day] = date.split('-').map(Number);
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function extractUrls(content: string): string[] {
const decoded = decodeHtml(content);
return Array.from(decoded.matchAll(URL_GLOBAL_RE))
.map((m) => cleanUrl(m[0]))
.filter(Boolean)
.slice(0, 6);
}
function cleanUrl(raw: string): string {
return raw
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
.replace(/\.{3,}$/g, '')
.trim();
}
function normalizeUrlKey(raw: string): string {
try {
const u = new URL(cleanUrl(raw));
u.hash = '';
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
u.searchParams.delete(key);
}
return u.toString();
} catch {
return raw;
}
}
function domainOf(raw: string): string {
try {
return new URL(cleanUrl(raw)).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
function isArticleUrl(raw: string): boolean {
try {
const u = new URL(cleanUrl(raw));
const host = u.hostname.replace(/^www\./, '');
if (host === 'mp.weixin.qq.com') return true;
if ((host === 'x.com' || host === 'twitter.com') && /\/status\/\d{12,}/.test(u.pathname)) return true;
if (host === 'youtube.com' || host === 'youtu.be') return true;
return /zhihu|toutiao|sohu|163\.com|qq\.com|medium\.com|substack\.com|juejin\.cn/i.test(host);
} catch {
return false;
}
}
function isToolUrl(raw: string, content: string): boolean {
try {
const u = new URL(cleanUrl(raw));
const host = u.hostname.replace(/^www\./, '');
if (/qlogo|qpic|support\.weixin|res\.wx/i.test(host)) return false;
if (isArticleUrl(raw)) return false;
if (/github\.com|huggingface\.co|replicate\.com|vercel\.app|netlify\.app|feishu\.cn|notion\.so|notion\.site|docs\.google\.com/i.test(host)) {
return true;
}
return TOOL_RE.test(content);
} catch {
return false;
}
}
function titleFromLinkContext(content: string, url: string): string {
const xmlTitle = tagText(content, 'title');
if (xmlTitle) return xmlTitle.slice(0, 56);
const clean = decodeHtml(content)
.replace(url, '')
.replace(URL_GLOBAL_RE, '')
.replace(/\[引用\]/g, '')
.replace(/↳/g, '')
.replace(/\s+/g, ' ')
.trim();
const first = clean.split(/[。!?!?]\s*/).find((part) => part.trim().length >= 6);
return (first ?? domainOf(url)).slice(0, 56);
}
function whyForAction(content: string): string {
if (/团购|采购|报价|预算/i.test(content)) return '包含采购/团购信号,可能直接转化为资源或商务机会';
if (/报名|名额|活动|会议|直播/i.test(content)) return '包含时间敏感入口,适合尽快确认是否参与';
if (/合作|对接|找人|招募|一起做/i.test(content)) return '包含合作或找人需求,适合主动连接';
if (/求推荐|有没有|谁有|需要/i.test(content)) return '有人提出明确需求,适合用你的资源网络回复';
return '具备明确上下文和行动动词,适合进入原群查看';
}
function urgencyFor(content: string, score: number): DashboardActionItem['urgency'] {
if (/今天|今晚|明天|马上|名额|截止|限时|报名/i.test(content) || score >= 10) return 'high';
if (/合作|采购|团购|对接|求推荐/i.test(content) || score >= 7) return 'medium';
return 'low';
}
function topicReason(
status: DashboardTopicLifecycle['status'],
todayCount: number,
previousAvg: number,
groupCount: number,
): string {
if (status === 'spreading') return `${groupCount} 个群出现,已经不是单群噪音`;
if (status === 'rising') return `今日 ${todayCount} 条,高于近 7 日均值 ${previousAvg.toFixed(1)}`;
if (status === 'cooling') return `今日热度低于近 7 日均值,可能进入退潮期`;
return `今日 ${todayCount} 条讨论,保持高热度`;
}
function statusWeight(status: DashboardTopicLifecycle['status']): number {
if (status === 'spreading') return 4;
if (status === 'rising') return 3;
if (status === 'hot') return 2;
return 1;
}
function verdictForLink(kind: 'article' | 'tool', count: number, groupCount: number, snippets: string): string {
if (groupCount >= 3) return '跨群重复出现,优先查看';
if (kind === 'tool' && /实测|体验|教程|开源|github|保姆级/i.test(snippets)) return '有使用语境,值得试用';
if (kind === 'article' && /复盘|教程|深度|报告|访谈|经验/i.test(snippets)) return '具备可整理成内容的素材';
if (count >= 2) return '重复提到,适合收藏备查';
return kind === 'tool' ? '新工具线索,快速扫一眼' : '文章线索,按需阅读';
}
function personReason(role: DashboardPeopleRadar['role'], signalCount: number, groupCount: number): string {
if (role === '连接者') return `${groupCount} 个群出现,适合关注其连接的圈层`;
if (role === '需求提出者') return `提出多条可行动需求,适合跟进`;
if (role === '分享者') return `贡献 ${signalCount} 条链接/资料信号`;
return `贡献 ${signalCount} 条高信号观点`;
}
function severityWeight(severity: DashboardAnomalySignal['severity']): number {
if (severity === 'high') return 3;
if (severity === 'medium') return 2;
return 1;
}
function xmlSummary(content: string): string {
if (!content.includes('<msg>')) return '';
const title = tagText(content, 'title');
const des = tagText(content, 'des');
const url = tagText(content, 'url') || tagText(content, 'imgsourceurl');
return [title, des, url ? '链接' : ''].filter(Boolean).join(' ');
}
function tagText(content: string, tag: string): string {
const text = content.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))?.[1] ?? '';
return decodeHtml(text).replace(/\s+/g, ' ').trim();
}
function decodeHtml(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function scoreContent(content: string): number {
let score = 0;
if (URL_RE.test(content) || content.includes('链接')) score += 3;
if (TOOL_RE.test(content)) score += 3;
if (isOpportunity(content)) score += 4;
if (ACTION_RE.test(content)) score += 2;
if (QUESTION_RE.test(content)) score += 1;
if (content.length >= 80) score += 2;
if (content.length >= 180) score += 1;
return score;
}
function reasonsFor(content: string): string[] {
const reasons: string[] = [];
if (isOpportunity(content)) reasons.push('机会/需求');
if (TOOL_RE.test(content)) reasons.push('工具/产品');
if (URL_RE.test(content) || content.includes('链接')) reasons.push('链接信号');
if (ACTION_RE.test(content)) reasons.push('可跟进');
if (content.length >= 120) reasons.push('长观点');
if (QUESTION_RE.test(content)) reasons.push('问题');
return reasons.slice(0, 3);
}
function actionFor(content: string): string {
if (/团购|采购|报价|预算/i.test(content)) return '看采购/团购';
if (/报名|名额|活动|会议|直播/i.test(content)) return '看报名/活动';
if (/合作|对接|找人|招募|一起做/i.test(content)) return '看合作机会';
if (/求推荐|有没有|谁有|需要/i.test(content)) return '可回复推荐';
if (/帮忙|看看|评估|试试/i.test(content)) return '可协助跟进';
return '查看上下文';
}
function isOpportunity(content: string): boolean {
return OPPORTUNITY_RE.test(content.slice(0, 140)) && !DIGEST_RE.test(content);
}
function opportunityDedupeKey(content: string, title: string): string {
if (content.includes('飞书录音豆')) return normalizeDedupe('飞书录音豆团购');
if (content.includes('团购')) {
const groupBuy = content.match(/([\p{L}\p{N}A-Za-z]{2,16}(?:|)?)/u)?.[1];
if (groupBuy) return normalizeDedupe(groupBuy);
}
const phrase =
content.match(/[\p{L}\p{N}A-Za-z]{2,}.{0,18}(|||||||)/u)?.[0] ??
title;
return normalizeDedupe(phrase);
}
function titleFromContent(content: string): string {
const withoutPrefix = content
.replace(/^[@#\s:-]+/, '')
.replace(/\[[^\]]{0,8}\]/g, '')
.replace(/[*_`#>]+/g, '')
.replace(/链接/g, '')
.trim();
const first = withoutPrefix.split(/[。!?!?]\s*/).find((p) => p.trim().length >= 6);
return (first ?? withoutPrefix).trim().slice(0, 46) || '值得查看的讨论';
}
function normalizeDedupe(content: string): string {
return content.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 48);
}
function topEntry(values: Map<string, number>): string {
return Array.from(values.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
}
function strengthsFor(source: {
link_count: number;
opportunity_count: number;
tool_count: number;
}): string[] {
const out: string[] = [];
if (source.tool_count > 0) out.push('工具');
if (source.link_count > 0) out.push('链接');
if (source.opportunity_count > 0) out.push('机会');
return out.length > 0 ? out.slice(0, 3) : ['观点'];
}
+173
View File
@@ -0,0 +1,173 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { DATA_DIR } from './config';
const DB_PATH = join(DATA_DIR, 'radar.db');
let _db: Database.Database | null = null;
export function db(): Database.Database {
if (_db) return _db;
const dir = dirname(DB_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
migrate(_db);
seed(_db);
return _db;
}
function migrate(d: Database.Database) {
d.exec(`
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL,
emoji TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_tags (
chatroom_id TEXT NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (chatroom_id, group_id),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS favorites (
chatroom_id TEXT PRIMARY KEY,
starred_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_stats (
chatroom_id TEXT NOT NULL,
date TEXT NOT NULL,
total INTEGER NOT NULL,
top_senders TEXT NOT NULL,
by_hour TEXT NOT NULL,
refreshed_at INTEGER NOT NULL,
PRIMARY KEY (chatroom_id, date)
);
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
CREATE TABLE IF NOT EXISTS mentions (
chatroom_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
sender TEXT NOT NULL,
content TEXT NOT NULL,
time TEXT NOT NULL,
timestamp INTEGER NOT NULL,
seen INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (chatroom_id, local_id)
);
CREATE INDEX IF NOT EXISTS idx_mentions_time ON mentions(timestamp DESC);
CREATE TABLE IF NOT EXISTS messages (
chatroom_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
sender TEXT NOT NULL,
content TEXT NOT NULL,
time TEXT NOT NULL,
timestamp INTEGER NOT NULL,
type TEXT NOT NULL,
date TEXT NOT NULL,
PRIMARY KEY (chatroom_id, local_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_chatroom_date ON messages(chatroom_id, date);
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date);
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender);
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT,
message_count INTEGER NOT NULL,
group_count INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topics_date ON topics(date DESC, message_count DESC);
CREATE TABLE IF NOT EXISTS topic_messages (
topic_id INTEGER NOT NULL,
chatroom_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
score REAL NOT NULL DEFAULT 0,
PRIMARY KEY (topic_id, chatroom_id, local_id),
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic_messages_topic ON topic_messages(topic_id);
CREATE TABLE IF NOT EXISTS link_intelligence_cache (
date TEXT NOT NULL,
version TEXT NOT NULL,
payload TEXT NOT NULL,
generated_at INTEGER NOT NULL,
PRIMARY KEY (date, version)
);
CREATE TABLE IF NOT EXISTS sync_state (
chatroom_id TEXT PRIMARY KEY,
last_synced_at INTEGER NOT NULL,
first_message_date TEXT,
last_message_date TEXT,
total_messages INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
ensureColumn(d, 'sync_state', 'status', "TEXT NOT NULL DEFAULT 'unknown'");
ensureColumn(d, 'sync_state', 'last_error', 'TEXT');
ensureColumn(d, 'sync_state', 'failed_chunks', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(d, 'sync_state', 'empty_chunks', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(d, 'sync_state', 'total_chunks', 'INTEGER NOT NULL DEFAULT 0');
}
function ensureColumn(d: Database.Database, table: string, name: string, definition: string) {
const rows = d.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (rows.some((r) => r.name === name)) return;
d.prepare(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`).run();
}
const SEED_VERSION = 'wechat_radar_v1_2026_05_24';
const DEFAULT_GROUPS: Array<{ name: string; color: string; emoji: string }> = [
{ name: 'AI / Coding', color: '#7dd3a8', emoji: '💻' },
{ name: 'Tools', color: '#f59e0b', emoji: '🛠️' },
{ name: 'Articles', color: '#06b6d4', emoji: '📚' },
{ name: 'Business', color: '#10b981', emoji: '💼' },
{ name: 'Events', color: '#f97316', emoji: '📅' },
{ name: 'Research', color: '#a855f7', emoji: '🔬' },
{ name: 'Lifestyle', color: '#fb7185', emoji: '🏠' },
];
function seed(d: Database.Database) {
const meta = d.prepare("SELECT value FROM meta WHERE key = 'seed_version'").get() as { value: string } | undefined;
if (meta?.value === SEED_VERSION) return;
const tagged = d.prepare('SELECT COUNT(*) AS n FROM group_tags').get() as { n: number };
if (tagged.n === 0) d.prepare('DELETE FROM groups').run();
const now = Date.now();
const insertOrIgnore = d.prepare(
'INSERT OR IGNORE INTO groups (name, color, emoji, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
);
d.transaction(() => {
DEFAULT_GROUPS.forEach((g, i) => insertOrIgnore.run(g.name, g.color, g.emoji, i, now));
})();
d.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('seed_version', ?)").run(SEED_VERSION);
}
+79
View File
@@ -0,0 +1,79 @@
import { db } from './db';
import { writeConfig } from './config';
const GROUPS = [
{ id: 'demo-ai@chatroom', name: 'AI 产品讨论群' },
{ id: 'demo-coding@chatroom', name: 'Vibe Coding 交流群' },
{ id: 'demo-tools@chatroom', name: '效率工具分享群' },
{ id: 'demo-business@chatroom', name: 'AI 商业增长群' },
{ id: 'demo-life@chatroom', name: '生活与阅读群' },
];
const SENDERS = ['Alex', 'Ming', 'Luna', 'Kai', 'River', 'Yuki', 'Chen'];
const CONTENTS = [
'有没有适合团队知识库的 AI 工具?最好支持飞书和 Notion,同步成本低一点。',
'实测 Codex 处理中型前端改版很稳,关键是先给它足够清楚的验收标准。',
'分享一个开源项目 https://github.com/example/agent-workflow 可以把多 Agent 编排可视化。',
'这篇文章值得读:AI Agent 落地为什么卡在组织流程 https://mp.weixin.qq.com/s/demo-agent-org',
'下周有一个 AI 工具内测名额,想找 20 个真实团队试用,感兴趣可以报名。',
'GEO 和 SEO 的差别今天讨论很多,核心不是关键词,而是结构化证据和可信来源。',
'有没有人熟悉 Chrome Extension 上架流程?需要一个 checklist。',
'@你的微信名 这个话题你可能有经验:如何把群聊素材整理成公众号选题?',
'新的语音转文字工具体验不错 https://example.com/voice-note 支持批量导出 Markdown。',
'今天最值得关注的是 AI 工具开始从个人效率走向团队工作流。',
];
function ymd(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function seedDemoData() {
const database = db();
const now = new Date();
const insertMessage = database.prepare(`
INSERT OR IGNORE INTO messages
(chatroom_id, local_id, sender, content, time, timestamp, type, date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertStats = database.prepare(`
INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(chatroom_id, date) DO UPDATE SET
total = excluded.total,
top_senders = excluded.top_senders,
by_hour = excluded.by_hour,
refreshed_at = excluded.refreshed_at
`);
database.transaction(() => {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const d = new Date(now);
d.setDate(now.getDate() - dayOffset);
const date = ymd(d);
for (let gi = 0; gi < GROUPS.length; gi++) {
const group = GROUPS[gi];
const count = Math.max(8, 42 - dayOffset * 2 + gi * 5);
const byHour = Array.from({ length: 24 }, (_, hour) => ({ hour, count: hour >= 9 && hour <= 23 ? Math.floor(count / 15) + ((hour + gi) % 3) : 0 }));
const topSenders = SENDERS.slice(0, 3).map((sender, index) => ({ sender, count: Math.max(1, Math.floor(count / (index + 2))) }));
insertStats.run(group.id, date, count, JSON.stringify(topSenders), JSON.stringify(byHour), Date.now());
for (let i = 0; i < Math.min(count, 18); i++) {
const localId = dayOffset * 10000 + gi * 1000 + i + 1;
const hour = 9 + ((i + gi) % 12);
const minute = (i * 7) % 60;
const time = `${date} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
const timestamp = Math.floor(new Date(time).getTime() / 1000);
insertMessage.run(group.id, localId, SENDERS[(i + gi) % SENDERS.length], CONTENTS[(i + gi + dayOffset) % CONTENTS.length], time, timestamp, 'text', date);
}
}
}
})();
writeConfig({
demoMode: true,
setupCompleted: true,
privacyConfirmed: true,
myNicknames: ['你的微信名'],
});
return { groups: GROUPS.length, days: 14 };
}
+47
View File
@@ -0,0 +1,47 @@
import type { GroupRow } from './groups';
export function classifyGroupHeuristic(name: string, summary: string, groups: GroupRow[]) {
const text = `${name} ${summary}`.toLowerCase();
const lookup = (target: string) => groups.find((g) => g.name.toLowerCase().includes(target.toLowerCase()));
if (/vibe.?coding|coding|代码|编程|developer|dev|cli|mcp|skills?|github|开源|agent|gpt|claude|llm/i.test(text)) {
const t = lookup('AI / Coding');
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI / Coding keywords' };
}
if (/工具|产品|插件|内测|api|chrome|notion|obsidian|飞书|workflow|workspace|效率/i.test(text)) {
const t = lookup('Tools');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Tools / product keywords' };
}
if (/文章|公众号|日报|newsletter|读者|知识库|教程|报告|访谈|paper|论文/i.test(text)) {
const t = lookup('Articles');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Articles / knowledge keywords' };
}
if (/商业|营销|增长|seo|geo|销售|客户|采购|团购|合作|商务|创业|投资/i.test(text)) {
const t = lookup('Business');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Business keywords' };
}
if (/活动|报名|直播|大会|线下|分享会|训练营|课程|会议|meetup|workshop/i.test(text)) {
const t = lookup('Events');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Event keywords' };
}
if (/研究|学术|论文|paper|模型|实验|benchmark|评测/i.test(text)) {
const t = lookup('Research');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Research keywords' };
}
if (/生活|阅读|运动|小区|邻里|钓鱼|健身|跑步|英语|校友|投资主题/i.test(text)) {
const t = lookup('Lifestyle');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Lifestyle keywords' };
}
return null;
}
export function effectiveGroupIds(
name: string,
summary: string,
explicitIds: number[],
groups: GroupRow[],
): number[] {
if (explicitIds.length > 0) return explicitIds;
const guess = classifyGroupHeuristic(name, summary, groups);
return guess ? [guess.group_id] : [];
}
+95
View File
@@ -0,0 +1,95 @@
import { db } from './db';
export interface GroupRow {
id: number;
name: string;
color: string;
emoji: string | null;
sort_order: number;
created_at: number;
member_count?: number;
message_count?: number;
}
export function listGroups(): GroupRow[] {
return db()
.prepare(
`SELECT g.*,
(SELECT COUNT(*) FROM group_tags t WHERE t.group_id = g.id) AS member_count
FROM groups g
ORDER BY g.sort_order ASC, g.id ASC`,
)
.all() as GroupRow[];
}
export function createGroup(input: { name: string; color: string; emoji?: string }) {
const max = db().prepare('SELECT COALESCE(MAX(sort_order), 0) AS m FROM groups').get() as {
m: number;
};
const stmt = db().prepare(
'INSERT INTO groups (name, color, emoji, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
);
const info = stmt.run(input.name, input.color, input.emoji ?? null, max.m + 1, Date.now());
return Number(info.lastInsertRowid);
}
export function deleteGroup(id: number) {
db().prepare('DELETE FROM groups WHERE id = ?').run(id);
}
export function tagGroup(chatroomId: string, groupId: number) {
db()
.prepare('INSERT OR IGNORE INTO group_tags (chatroom_id, group_id) VALUES (?, ?)')
.run(chatroomId, groupId);
}
export function untagGroup(chatroomId: string, groupId: number) {
db()
.prepare('DELETE FROM group_tags WHERE chatroom_id = ? AND group_id = ?')
.run(chatroomId, groupId);
}
export function tagsForChatroom(chatroomId: string): number[] {
const rows = db()
.prepare('SELECT group_id FROM group_tags WHERE chatroom_id = ?')
.all(chatroomId) as Array<{ group_id: number }>;
return rows.map((r) => r.group_id);
}
export function chatroomsForGroup(groupId: number): string[] {
const rows = db()
.prepare('SELECT chatroom_id FROM group_tags WHERE group_id = ?')
.all(groupId) as Array<{ chatroom_id: string }>;
return rows.map((r) => r.chatroom_id);
}
export function listAllTags(): Array<{ chatroom_id: string; group_id: number }> {
return db()
.prepare('SELECT chatroom_id, group_id FROM group_tags')
.all() as Array<{ chatroom_id: string; group_id: number }>;
}
export function isFavorite(chatroomId: string): boolean {
const r = db()
.prepare('SELECT 1 AS x FROM favorites WHERE chatroom_id = ?')
.get(chatroomId) as { x: number } | undefined;
return !!r;
}
export function listFavorites(): string[] {
return (
db().prepare('SELECT chatroom_id FROM favorites ORDER BY starred_at DESC').all() as Array<{
chatroom_id: string;
}>
).map((r) => r.chatroom_id);
}
export function setFavorite(chatroomId: string, fav: boolean) {
if (fav) {
db()
.prepare('INSERT OR IGNORE INTO favorites (chatroom_id, starred_at) VALUES (?, ?)')
.run(chatroomId, Date.now());
} else {
db().prepare('DELETE FROM favorites WHERE chatroom_id = ?').run(chatroomId);
}
}
+620
View File
@@ -0,0 +1,620 @@
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { db } from './db';
import { wxSessions } from './wx';
import { cache } from './cache';
const MAX_MESSAGES = 5000;
const MAX_ITEMS_PER_KIND = 24;
const MAX_TITLE_FETCHES = 8;
const TITLE_FETCH_TIMEOUT_MS = 1400;
const MAX_TITLE_GENERATION_ITEMS = 80;
const CODEX_TIMEOUT_MS = Number(process.env.WECHAT_RADAR_LINK_CODEX_TIMEOUT_MS ?? 180_000);
const CODEX_MODEL = process.env.WECHAT_RADAR_CODEX_MODEL;
const LINK_INTELLIGENCE_CACHE_VERSION = 'v6';
const LINK_INTELLIGENCE_CACHE_TTL_SECONDS = 60 * 60 * 24;
const TOOL_HINT_RE =
/工具|开源|项目|产品|官网|体验|注册|插件|脚手架|模型|智能体|Agent|Claude|Gemini|Codex|API|CLI|MCP|浏览器|Demo|教程|指南|workflow|workspace/i;
const ARTICLE_HOSTS = [
'zhuanlan.zhihu.com',
'www.zhihu.com',
'www.toutiao.com',
'www.sohu.com',
'page.om.qq.com',
'www.163.com',
'mparticle.uc.cn',
];
const TOOL_HOST_HINTS = [
'github.com',
'huggingface.co',
'replicate.com',
'vercel.app',
'netlify.app',
'feishu.cn',
'larksuite.com',
'notion.site',
'notion.so',
'docs.google.com',
'my.feishu.cn',
];
const IGNORED_HOSTS = [
'support.weixin.qq.com',
'wx.qlogo.cn',
'wxapp.tc.qq.com',
'res.wx.qq.com',
'mmbiz.qpic.cn',
];
type LinkKind = 'article' | 'tool';
interface MessageLinkRow {
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
}
export interface LinkIntelligenceItem {
kind: LinkKind;
url: string;
canonical_url: string;
title: string;
domain: string;
count: number;
group_count: number;
first_seen: string;
last_seen: string;
sources: Array<{
chatroom_id: string;
chat_name: string;
sender: string;
time: string;
local_id: number;
snippet: string;
}>;
dedupe_key?: string;
}
export interface LinkIntelligenceResult {
date: string;
articles: LinkIntelligenceItem[];
tools: LinkIntelligenceItem[];
}
interface LinkIntelligenceOptions {
refresh?: boolean;
}
interface GeneratedLinkTitle {
canonical_url: string;
title: string;
group_key: string;
}
interface GeneratedLinkTitleResponse {
items: GeneratedLinkTitle[];
}
const LINK_TITLE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
items: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
canonical_url: { type: 'string' },
title: { type: 'string' },
group_key: { type: 'string' },
},
required: ['canonical_url', 'title', 'group_key'],
},
},
},
required: ['items'],
};
function decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function cleanUrl(raw: string): string {
return decodeHtmlEntities(raw)
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
.replace(/\.{3,}$/g, '')
.trim();
}
function normalizeUrl(raw: string): string | null {
if (raw.includes('...') || raw.includes('…')) return null;
try {
const u = new URL(cleanUrl(raw));
u.hash = '';
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
u.searchParams.delete(key);
}
return u.toString();
} catch {
return null;
}
}
function extractUrls(content: string): string[] {
const decoded = decodeHtmlEntities(content);
const urls = new Set<string>();
for (const m of decoded.matchAll(/imgsourceurl="([^"]+)"/g)) {
try {
urls.add(decodeURIComponent(m[1]));
} catch {
urls.add(m[1]);
}
}
for (const m of decoded.matchAll(/https?:\/\/[^\s<>"']+/g)) {
urls.add(cleanUrl(m[0]));
}
return Array.from(urls).filter(Boolean);
}
function domainOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
function isWeChatArticle(url: URL): boolean {
return url.hostname === 'mp.weixin.qq.com' && (
(url.pathname.startsWith('/s/') && url.pathname.length > 3) ||
url.searchParams.has('__biz') ||
url.searchParams.has('mid') ||
url.searchParams.has('sn')
);
}
function isArticleLink(url: string): boolean {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, '');
if (isWeChatArticle(u)) return true;
if ((host === 'x.com' || host === 'twitter.com') && /\/status\/\d{12,}/.test(u.pathname)) return true;
if ((host === 'youtube.com' || host === 'youtu.be') && (u.pathname === '/watch' || host === 'youtu.be')) {
return true;
}
return ARTICLE_HOSTS.includes(host);
} catch {
return false;
}
}
function isToolLink(url: string, content: string): boolean {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, '');
if (
host === 'x.com' ||
host === 'twitter.com' ||
(host === 'mp.weixin.qq.com' && !isWeChatArticle(u)) ||
/meeting\.tencent\.com$/.test(host) ||
IGNORED_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))
) {
return false;
}
if (isArticleLink(url)) return false;
if (TOOL_HOST_HINTS.some((h) => host === h || host.endsWith(`.${h}`))) return true;
return TOOL_HINT_RE.test(content);
} catch {
return false;
}
}
function cleanSnippet(content: string): string {
return decodeHtmlEntities(content)
.replace(/<\?xml[\s\S]+?<\/msg>/g, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/\[引用\]/g, '')
.replace(/↳/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
}
function titleFromContext(content: string, url: string): string {
const decoded = decodeHtmlEntities(content);
const withoutXml = decoded.replace(/<\?xml[\s\S]+?<\/msg>/g, ' ');
const lines = withoutXml
.split(/\n+/)
.map((line) =>
line
.replace(url, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/\[引用\]/g, '')
.replace(/↳/g, '')
.trim(),
)
.filter((line) => line.length >= 4 && line.length <= 90);
const preferred = lines.find((line) => !/^[@#\d\s:-]+$/.test(line));
return preferred ?? domainOf(url);
}
function decodeTitle(raw: string): string {
return decodeHtmlEntities(raw)
.replace(/\s+/g, ' ')
.replace(/ - 微信公众平台$/, '')
.replace(/_哔哩哔哩_bilibili$/, '')
.trim()
.slice(0, 120);
}
async function fetchTitle(url: string): Promise<string | null> {
const cacheKey = `link-title:${url}`;
const cached = cache.get(cacheKey) as string | undefined;
if (cached) return cached;
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), TITLE_FETCH_TIMEOUT_MS);
try {
const r = await fetch(url, {
signal: ctl.signal,
headers: {
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36',
accept: 'text/html,application/xhtml+xml',
},
});
const html = await r.text();
const title =
html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)?.[1] ??
html.match(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i)?.[1] ??
html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ??
null;
if (!title) return null;
const decoded = decodeTitle(title);
if (decoded) cache.set(cacheKey, decoded, 60 * 60 * 24);
return decoded || null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
async function hydrateTitles(items: LinkIntelligenceItem[]) {
const needsTitle = items
.filter((item) => item.title === item.domain || item.title.length < 8)
.slice(0, MAX_TITLE_FETCHES);
await Promise.all(
needsTitle.map(async (item) => {
const title = await fetchTitle(item.url);
if (title) item.title = title;
}),
);
}
function parseJsonOutput<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) return JSON.parse(fenced[1]) as T;
const obj = trimmed.match(/\{[\s\S]*\}/);
if (obj) return JSON.parse(obj[0]) as T;
throw new Error('codex returned non-JSON');
}
}
function runCodexJson<T>(prompt: string, schema: unknown, timeoutMs = CODEX_TIMEOUT_MS): Promise<T> {
return new Promise((resolve, reject) => {
const dir = mkdtempSync(join(tmpdir(), 'wechat-links-'));
const schemaPath = join(dir, 'schema.json');
const outPath = join(dir, 'response.json');
writeFileSync(schemaPath, JSON.stringify(schema), 'utf8');
const args = [
'-a',
'never',
'exec',
'--sandbox',
'read-only',
'--ephemeral',
'--ignore-rules',
'--output-schema',
schemaPath,
'--output-last-message',
outPath,
];
if (CODEX_MODEL) args.push('--model', CODEX_MODEL);
args.push('-');
const proc = spawn('codex', args, {
env: { ...process.env, NO_COLOR: '1' },
stdio: ['pipe', 'ignore', 'pipe'],
});
let stderr = '';
const t = setTimeout(() => {
proc.kill('SIGTERM');
rmSync(dir, { recursive: true, force: true });
reject(new Error('codex CLI timeout'));
}, timeoutMs);
proc.stderr.on('data', (d) => (stderr += d.toString()));
proc.on('error', (e) => {
clearTimeout(t);
rmSync(dir, { recursive: true, force: true });
reject(e);
});
proc.on('close', (code) => {
clearTimeout(t);
try {
if (code !== 0) {
reject(new Error(`codex exit ${code}: ${stderr.slice(0, 800)}`));
return;
}
resolve(parseJsonOutput<T>(readFileSync(outPath, 'utf8')));
} catch (e) {
reject(e);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
proc.stdin.write(prompt);
proc.stdin.end();
});
}
function fallbackDedupeKey(item: LinkIntelligenceItem): string {
const title = item.title
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '')
.slice(0, 40);
return title || item.canonical_url;
}
function buildTitleGenerationPrompt(items: LinkIntelligenceItem[]): string {
const rows = items
.map((item) =>
JSON.stringify({
canonical_url: item.canonical_url,
kind: item.kind,
domain: item.domain,
current_title: item.title,
snippets: item.sources.slice(0, 3).map((s) => s.snippet).filter(Boolean),
}),
)
.join('\n');
return `你是微信群链接情报的标题整理器。请为每个链接生成适合列表展示的中文标题,并给出语义去重 key。
要求:
- title 要短、具体、可点击,优先保留产品名、文章主题、工具名或资料名。
- 不要直接复制整段聊天长句;去掉寒暄、@人名、表情和“老师”等噪声。
- 文章链接 title 像文章标题;工具/资源 title 像工具名、项目名、资料库名或活动名。
- group_key 用于去重:同一个工具、同一篇文章、同一组资料更新、同一活动报名,即便 URL 不同,也给相同 group_key。
- group_key 使用小写英文/数字/短横线;无法判断时用域名加核心标题。
- canonical_url 必须原样来自输入;不要新增、删除或编造 URL。
只输出严格 JSON
{"items":[{"canonical_url":"...","title":"...","group_key":"..."}]}
输入 JSONL
${rows}`;
}
async function generateTitlesAndKeys(items: LinkIntelligenceItem[]) {
if (items.length === 0) return;
try {
const response = await runCodexJson<GeneratedLinkTitleResponse>(
buildTitleGenerationPrompt(items),
LINK_TITLE_SCHEMA,
);
const byUrl = new Map(response.items.map((item) => [item.canonical_url, item]));
for (const item of items) {
const generated = byUrl.get(item.canonical_url);
if (!generated) {
item.dedupe_key = fallbackDedupeKey(item);
continue;
}
item.title = generated.title.trim().slice(0, 80) || item.title;
item.dedupe_key = generated.group_key.trim().toLowerCase() || fallbackDedupeKey(item);
}
} catch {
for (const item of items) item.dedupe_key = fallbackDedupeKey(item);
}
}
function mergeDuplicateItems(items: LinkIntelligenceItem[]): LinkIntelligenceItem[] {
const merged = new Map<string, LinkIntelligenceItem>();
for (const item of items) {
const key = `${item.kind}:${item.dedupe_key ?? fallbackDedupeKey(item)}`;
const existing = merged.get(key);
if (!existing) {
merged.set(key, { ...item, sources: [...item.sources] });
continue;
}
existing.count += item.count;
existing.last_seen = existing.last_seen > item.last_seen ? existing.last_seen : item.last_seen;
existing.first_seen = existing.first_seen < item.first_seen ? existing.first_seen : item.first_seen;
for (const source of item.sources) {
if (!existing.sources.some((s) => s.chatroom_id === source.chatroom_id && s.local_id === source.local_id)) {
existing.sources.push(source);
}
}
existing.group_count = new Set(existing.sources.map((s) => s.chatroom_id)).size;
if (item.count > existing.count) {
existing.url = item.url;
existing.canonical_url = item.canonical_url;
}
}
return Array.from(merged.values());
}
function resultCacheKey(date: string) {
return `link-intelligence:${date}:${LINK_INTELLIGENCE_CACHE_VERSION}`;
}
function readPersistedLinkIntelligence(date: string): LinkIntelligenceResult | null {
const row = db()
.prepare('SELECT payload FROM link_intelligence_cache WHERE date = ? AND version = ?')
.get(date, LINK_INTELLIGENCE_CACHE_VERSION) as { payload: string } | undefined;
if (!row) return null;
try {
const parsed = JSON.parse(row.payload) as LinkIntelligenceResult;
if (parsed.date !== date || !Array.isArray(parsed.articles) || !Array.isArray(parsed.tools)) {
return null;
}
return parsed;
} catch {
return null;
}
}
function writePersistedLinkIntelligence(result: LinkIntelligenceResult) {
db()
.prepare(
`INSERT INTO link_intelligence_cache (date, version, payload, generated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(date, version) DO UPDATE SET
payload = excluded.payload,
generated_at = excluded.generated_at`,
)
.run(
result.date,
LINK_INTELLIGENCE_CACHE_VERSION,
JSON.stringify(result),
Date.now(),
);
}
export function clearDailyLinkIntelligence(date: string) {
cache.del(resultCacheKey(date));
db()
.prepare('DELETE FROM link_intelligence_cache WHERE date = ? AND version = ?')
.run(date, LINK_INTELLIGENCE_CACHE_VERSION);
}
export async function getDailyLinkIntelligence(
date: string,
options: LinkIntelligenceOptions = {},
): Promise<LinkIntelligenceResult> {
const refresh = options.refresh ?? false;
const key = resultCacheKey(date);
const cached = cache.get(key) as LinkIntelligenceResult | undefined;
if (cached && !refresh) return cached;
if (!refresh) {
const persisted = readPersistedLinkIntelligence(date);
if (persisted) {
cache.set(key, persisted, LINK_INTELLIGENCE_CACHE_TTL_SECONDS);
return persisted;
}
}
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type
FROM messages
WHERE date = ?
AND content LIKE '%http%'
ORDER BY timestamp DESC
LIMIT ?`,
)
.all(date, MAX_MESSAGES) as MessageLinkRow[];
const sessions = await wxSessions(500).catch(() => []);
const names = new Map<string, string>();
for (const s of sessions) names.set(s.username, s.chat);
const buckets = new Map<string, LinkIntelligenceItem>();
for (const row of rows) {
for (const raw of extractUrls(row.content)) {
const canonical = normalizeUrl(raw);
if (!canonical) continue;
const kind: LinkKind | null = isArticleLink(canonical)
? 'article'
: isToolLink(canonical, row.content)
? 'tool'
: null;
if (!kind) continue;
const key = `${kind}:${canonical}`;
const existing = buckets.get(key);
const source = {
chatroom_id: row.chatroom_id,
chat_name: names.get(row.chatroom_id) ?? row.chatroom_id,
sender: row.sender,
time: row.time,
local_id: row.local_id,
snippet: cleanSnippet(row.content),
};
if (existing) {
existing.count++;
existing.last_seen = existing.last_seen > row.time ? existing.last_seen : row.time;
existing.first_seen = existing.first_seen < row.time ? existing.first_seen : row.time;
if (!existing.sources.some((s) => s.chatroom_id === row.chatroom_id && s.local_id === row.local_id)) {
existing.sources.push(source);
}
existing.group_count = new Set(existing.sources.map((s) => s.chatroom_id)).size;
} else {
buckets.set(key, {
kind,
url: raw,
canonical_url: canonical,
title: titleFromContext(row.content, raw),
domain: domainOf(canonical),
count: 1,
group_count: 1,
first_seen: row.time,
last_seen: row.time,
sources: [source],
});
}
}
}
const sortItems = (kind: LinkKind, limit = MAX_ITEMS_PER_KIND) =>
Array.from(buckets.values())
.filter((item) => item.kind === kind)
.sort((a, b) => b.count - a.count || b.group_count - a.group_count || b.last_seen.localeCompare(a.last_seen))
.slice(0, limit);
const articleCandidates = sortItems('article', MAX_TITLE_GENERATION_ITEMS);
const toolCandidates = sortItems('tool', MAX_TITLE_GENERATION_ITEMS);
await Promise.all([hydrateTitles(articleCandidates), hydrateTitles(toolCandidates)]);
await generateTitlesAndKeys([...articleCandidates, ...toolCandidates]);
const articles = mergeDuplicateItems(articleCandidates)
.sort((a, b) => b.count - a.count || b.group_count - a.group_count || b.last_seen.localeCompare(a.last_seen))
.slice(0, MAX_ITEMS_PER_KIND);
const tools = mergeDuplicateItems(toolCandidates)
.sort((a, b) => b.count - a.count || b.group_count - a.group_count || b.last_seen.localeCompare(a.last_seen))
.slice(0, MAX_ITEMS_PER_KIND);
const result = { date, articles, tools };
writePersistedLinkIntelligence(result);
cache.set(key, result, LINK_INTELLIGENCE_CACHE_TTL_SECONDS);
return result;
}
+204
View File
@@ -0,0 +1,204 @@
import { db } from './db';
import { readConfig } from './config';
import { wxHistory } from './wx';
import type { WxMessage } from './wx-types';
export interface MentionRow {
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
seen: number;
}
function isMention(content: string, nicknames: string[]): boolean {
if (!content) return false;
return mentionNeedles(nicknames).some((n) => content.includes(n));
}
function normalizedNicknames(nicknames: string[]): string[] {
return Array.from(
new Set(nicknames.map((n) => n.trim()).filter((n) => n.length > 0)),
);
}
function mentionNeedles(nicknames: string[]): string[] {
return normalizedNicknames(nicknames).map((n) => `@${n}`);
}
function mentionPredicate(column: string, nicknames: string[]) {
const needles = mentionNeedles(nicknames);
return {
sql: needles.map(() => `instr(${column}, ?) > 0`).join(' OR ') || '0',
params: needles,
};
}
function currentMessageState(signature: string) {
const row = db()
.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(timestamp), 0) AS maxTimestamp FROM messages')
.get() as { count: number; maxTimestamp: number };
return {
signature,
messageCount: row.count,
maxTimestamp: row.maxTimestamp,
};
}
function mentionSignature(nicknames: string[]): string {
return JSON.stringify(normalizedNicknames(nicknames));
}
function readMentionIndexState() {
const row = db()
.prepare("SELECT value FROM meta WHERE key = 'mention_index_state'")
.get() as { value: string } | undefined;
if (!row) return null;
try {
return JSON.parse(row.value) as ReturnType<typeof currentMessageState>;
} catch {
return null;
}
}
function writeMentionIndexState(state: ReturnType<typeof currentMessageState>) {
db()
.prepare(
`INSERT INTO meta (key, value)
VALUES ('mention_index_state', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
)
.run(JSON.stringify(state));
}
export function rebuildMentionIndexFromMessages(): number {
const cfg = readConfig();
const signature = mentionSignature(cfg.myNicknames);
const state = currentMessageState(signature);
const predicate = mentionPredicate('content', cfg.myNicknames);
const tx = db().transaction(() => {
if (!predicate.params.length) {
db().prepare('DELETE FROM mentions').run();
writeMentionIndexState(state);
return 0;
}
db()
.prepare(`DELETE FROM mentions WHERE NOT (${predicate.sql})`)
.run(...predicate.params);
db()
.prepare(
`INSERT OR IGNORE INTO mentions
(chatroom_id, local_id, sender, content, time, timestamp, seen)
SELECT chatroom_id, local_id, sender, content, time, timestamp, 0
FROM messages
WHERE ${predicate.sql}`,
)
.run(...predicate.params);
writeMentionIndexState(state);
const row = db().prepare('SELECT COUNT(*) AS n FROM mentions').get() as { n: number };
return row.n;
});
return tx();
}
function ensureMentionIndexCurrent() {
const cfg = readConfig();
const state = currentMessageState(mentionSignature(cfg.myNicknames));
const indexed = readMentionIndexState();
if (
indexed?.signature === state.signature &&
indexed.messageCount === state.messageCount &&
indexed.maxTimestamp === state.maxTimestamp
) {
return;
}
rebuildMentionIndexFromMessages();
}
export async function scanMentions(
chatroomId: string,
since: string,
until: string,
): Promise<number> {
const cfg = readConfig();
if (!cfg.myNicknames.length) return 0;
let messages: WxMessage[] = [];
try {
messages = await wxHistory(chatroomId, since, until, 5000);
} catch {
return 0;
}
const upsert = db().prepare(`
INSERT OR REPLACE INTO mentions
(chatroom_id, local_id, sender, content, time, timestamp, seen)
VALUES (?, ?, ?, ?, ?, ?, COALESCE((SELECT seen FROM mentions WHERE chatroom_id = ? AND local_id = ?), 0))
`);
let inserted = 0;
const insert = db().transaction((items: WxMessage[]) => {
for (const m of items) {
if (!isMention(m.content, cfg.myNicknames)) continue;
upsert.run(
chatroomId,
m.local_id,
m.sender,
m.content,
m.time,
m.timestamp,
chatroomId,
m.local_id,
);
inserted++;
}
});
insert(messages);
return inserted;
}
export function listMentions(limit = 100): MentionRow[] {
ensureMentionIndexCurrent();
return db()
.prepare(
'SELECT chatroom_id, local_id, sender, content, time, timestamp, seen FROM mentions ORDER BY timestamp DESC LIMIT ?',
)
.all(limit) as MentionRow[];
}
export function countMentions(): number {
ensureMentionIndexCurrent();
const row = db().prepare('SELECT COUNT(*) AS n FROM mentions').get() as { n: number };
return row.n;
}
export function countMentionsSince(unixSeconds: number): number {
ensureMentionIndexCurrent();
const row = db()
.prepare('SELECT COUNT(*) AS n FROM mentions WHERE timestamp >= ?')
.get(unixSeconds) as { n: number };
return row.n;
}
export function countMentionsBetween(sinceUnixSeconds: number, untilUnixSeconds: number): number {
ensureMentionIndexCurrent();
const row = db()
.prepare('SELECT COUNT(*) AS n FROM mentions WHERE timestamp >= ? AND timestamp <= ?')
.get(sinceUnixSeconds, untilUnixSeconds) as { n: number };
return row.n;
}
export function markMentionsSeen(chatroomId?: string) {
if (chatroomId) {
db().prepare('UPDATE mentions SET seen = 1 WHERE chatroom_id = ?').run(chatroomId);
} else {
db().prepare('UPDATE mentions SET seen = 1').run();
}
}
+202
View File
@@ -0,0 +1,202 @@
import { db } from './db';
import type { WxMessage } from './wx-types';
export interface MessageRow extends WxMessage {
chatroom_id: string;
date: string;
}
const SYSTEM_TYPES = new Set(['系统', 'system']);
const REVOKE_RE = /撤回了一条消息|recalled a message/i;
export function dateOfMessage(m: WxMessage): string {
if (m.time && m.time.length >= 10) return m.time.slice(0, 10);
if (m.timestamp) {
const d = new Date(m.timestamp * 1000);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
return `${y}-${mo}-${da}`;
}
return 'unknown';
}
export function bulkInsertMessages(chatroomId: string, messages: WxMessage[]): number {
if (messages.length === 0) return 0;
const stmt = db().prepare(`
INSERT OR IGNORE INTO messages
(chatroom_id, local_id, sender, content, time, timestamp, type, date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
let inserted = 0;
const tx = db().transaction((msgs: WxMessage[]) => {
for (const m of msgs) {
if (SYSTEM_TYPES.has(m.type) && REVOKE_RE.test(m.content)) continue;
const r = stmt.run(
chatroomId,
m.local_id,
m.sender ?? '',
m.content ?? '',
m.time ?? '',
m.timestamp ?? 0,
m.type ?? '',
dateOfMessage(m),
);
if (r.changes > 0) inserted++;
}
});
tx(messages);
return inserted;
}
export function listMessagesForDate(chatroomId: string, date: string, limit = 1000): MessageRow[] {
return db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type, date
FROM messages
WHERE chatroom_id = ? AND date = ?
ORDER BY timestamp ASC, local_id ASC
LIMIT ?`,
)
.all(chatroomId, date, limit) as MessageRow[];
}
export interface DailyStatsAggregate {
date: string;
total: number;
by_hour: Array<{ hour: number; count: number }>;
top_senders: Array<{ sender: string; count: number }>;
}
export function aggregateDailyStats(chatroomId: string, dates: string[]): DailyStatsAggregate[] {
if (dates.length === 0) return [];
const placeholders = dates.map(() => '?').join(',');
const rows = db()
.prepare(
`SELECT date, sender, timestamp, type
FROM messages
WHERE chatroom_id = ? AND date IN (${placeholders})`,
)
.all(chatroomId, ...dates) as Array<{
date: string;
sender: string;
timestamp: number;
type: string;
}>;
const byDate = new Map<string, { total: number; senders: Map<string, number>; hours: number[] }>();
for (const d of dates) byDate.set(d, { total: 0, senders: new Map(), hours: new Array(24).fill(0) });
for (const r of rows) {
const slot = byDate.get(r.date);
if (!slot) continue;
slot.total++;
slot.senders.set(r.sender, (slot.senders.get(r.sender) ?? 0) + 1);
if (r.timestamp) {
const h = new Date(r.timestamp * 1000).getHours();
if (h >= 0 && h < 24) slot.hours[h]++;
}
}
return dates.map((date) => {
const s = byDate.get(date)!;
const top = Array.from(s.senders.entries())
.map(([sender, count]) => ({ sender, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const by_hour = s.hours.map((count, hour) => ({ hour, count }));
return { date, total: s.total, by_hour, top_senders: top };
});
}
export function getSyncState(chatroomId: string) {
return db()
.prepare('SELECT * FROM sync_state WHERE chatroom_id = ?')
.get(chatroomId) as
| {
chatroom_id: string;
last_synced_at: number;
first_message_date: string | null;
last_message_date: string | null;
total_messages: number;
status: string;
last_error: string | null;
failed_chunks: number;
empty_chunks: number;
total_chunks: number;
}
| undefined;
}
export type SyncStatus = 'ok' | 'partial' | 'failed' | 'empty' | 'unknown';
export function upsertSyncState(
chatroomId: string,
total: number,
firstDate: string | null,
lastDate: string | null,
meta: {
status?: SyncStatus;
lastError?: string | null;
failedChunks?: number;
emptyChunks?: number;
totalChunks?: number;
} = {},
) {
db()
.prepare(
`INSERT INTO sync_state (
chatroom_id,
last_synced_at,
first_message_date,
last_message_date,
total_messages,
status,
last_error,
failed_chunks,
empty_chunks,
total_chunks
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chatroom_id) DO UPDATE SET
last_synced_at = excluded.last_synced_at,
first_message_date = COALESCE(excluded.first_message_date, sync_state.first_message_date),
last_message_date = COALESCE(excluded.last_message_date, sync_state.last_message_date),
total_messages = excluded.total_messages,
status = excluded.status,
last_error = excluded.last_error,
failed_chunks = excluded.failed_chunks,
empty_chunks = excluded.empty_chunks,
total_chunks = excluded.total_chunks`,
)
.run(
chatroomId,
Date.now(),
firstDate,
lastDate,
total,
meta.status ?? 'unknown',
meta.lastError ?? null,
meta.failedChunks ?? 0,
meta.emptyChunks ?? 0,
meta.totalChunks ?? 0,
);
}
export function countMessagesInRange(chatroomId: string, since: string, until: string): number {
const r = db()
.prepare(
'SELECT COUNT(*) AS n FROM messages WHERE chatroom_id = ? AND date >= ? AND date <= ?',
)
.get(chatroomId, since, until) as { n: number };
return r.n;
}
export function listAllSyncedDates(chatroomId: string): string[] {
const rows = db()
.prepare(
'SELECT DISTINCT date FROM messages WHERE chatroom_id = ? ORDER BY date ASC',
)
.all(chatroomId) as Array<{ date: string }>;
return rows.map((r) => r.date);
}
+64
View File
@@ -0,0 +1,64 @@
export type RangeKey = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
const RANGE_KEYS = new Set<RangeKey>(['day', 'week', 'month', 'quarter', 'year', 'custom']);
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export function isRangeKey(value: string | null | undefined): value is RangeKey {
return !!value && RANGE_KEYS.has(value as RangeKey);
}
export function normalizeRangeKey(value: string | null | undefined, fallback: RangeKey): RangeKey {
return isRangeKey(value) ? value : fallback;
}
export function normalizeDate(value: string | null | undefined, fallback = todayStr()): string {
if (!value || !DATE_RE.test(value)) return fallback;
const [year, month, day] = value.split('-').map(Number);
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return fallback;
return ymd(d) === value ? value : fallback;
}
export function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function todayStr(): string {
return ymd(new Date());
}
export function daysBefore(n: number, anchor = todayStr()): string {
const [year, month, day] = normalizeDate(anchor).split('-').map(Number);
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() - n);
return ymd(d);
}
export function rangeToWindow(range: RangeKey, anchorDate = todayStr()): { since: string; until: string; days: number } {
const until = normalizeDate(anchorDate);
const map: Record<Exclude<RangeKey, 'custom'>, number> = {
day: 0,
week: 6,
month: 29,
quarter: 89,
year: 364,
};
if (range === 'custom') {
return { since: daysBefore(6, until), until, days: 7 };
}
const span = map[range];
return { since: daysBefore(span, until), until, days: span + 1 };
}
export function dateList(since: string, until: string): string[] {
const out: string[] = [];
const start = new Date(since);
const end = new Date(until);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
out.push(ymd(d));
}
return out;
}
+366
View File
@@ -0,0 +1,366 @@
import pLimit from 'p-limit';
import { db } from './db';
import { wxHistory, wxStats } from './wx';
import {
aggregateDailyStats,
bulkInsertMessages,
upsertSyncState,
} from './messages-store';
import { rebuildMentionIndexFromMessages } from './mentions';
import type { WxStats } from './wx-types';
export type StatsRow = {
chatroom_id: string;
date: string;
total: number;
top_senders: Array<{ sender: string; count: number }>;
by_hour: Array<{ hour: number; count: number }>;
};
export function getCachedStats(chatroomId: string, date: string): StatsRow | null {
const row = db()
.prepare(
'SELECT chatroom_id, date, total, top_senders, by_hour FROM daily_stats WHERE chatroom_id = ? AND date = ?',
)
.get(chatroomId, date) as
| {
chatroom_id: string;
date: string;
total: number;
top_senders: string;
by_hour: string;
}
| undefined;
if (!row) return null;
return {
chatroom_id: row.chatroom_id,
date: row.date,
total: row.total,
top_senders: JSON.parse(row.top_senders),
by_hour: JSON.parse(row.by_hour),
};
}
export function listCachedStatsForDate(date: string): StatsRow[] {
const rows = db()
.prepare(
'SELECT chatroom_id, date, total, top_senders, by_hour FROM daily_stats WHERE date = ? ORDER BY total DESC',
)
.all(date) as Array<{
chatroom_id: string;
date: string;
total: number;
top_senders: string;
by_hour: string;
}>;
return rows.map((r) => ({
chatroom_id: r.chatroom_id,
date: r.date,
total: r.total,
top_senders: JSON.parse(r.top_senders),
by_hour: JSON.parse(r.by_hour),
}));
}
export function listCachedStatsRange(since: string, until: string): StatsRow[] {
const rows = db()
.prepare(
'SELECT chatroom_id, date, total, top_senders, by_hour FROM daily_stats WHERE date >= ? AND date <= ?',
)
.all(since, until) as Array<{
chatroom_id: string;
date: string;
total: number;
top_senders: string;
by_hour: string;
}>;
return rows.map((r) => ({
chatroom_id: r.chatroom_id,
date: r.date,
total: r.total,
top_senders: JSON.parse(r.top_senders),
by_hour: JSON.parse(r.by_hour),
}));
}
const upsert = () =>
db().prepare(`
INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(chatroom_id, date) DO UPDATE SET
total = excluded.total,
top_senders = excluded.top_senders,
by_hour = excluded.by_hour,
refreshed_at = excluded.refreshed_at
`);
export function saveStats(row: StatsRow & { refreshed_at?: number }) {
upsert().run(
row.chatroom_id,
row.date,
row.total,
JSON.stringify(row.top_senders),
JSON.stringify(row.by_hour),
row.refreshed_at ?? Date.now(),
);
}
export interface RescanProgress {
type: 'progress' | 'done' | 'error' | 'start';
done: number;
total: number;
current?: string;
error?: string;
inserted_messages?: number;
}
export interface RescanTarget {
chatroomId: string;
display: string;
}
export interface SyncOptions {
targets: RescanTarget[];
since: string;
until: string;
concurrency?: number;
onProgress?: (p: RescanProgress) => void;
}
// Helper: split a date range into month chunks ([{since, until}, ...])
function monthChunks(since: string, until: string): Array<{ since: string; until: string }> {
const chunks: Array<{ since: string; until: string }> = [];
const start = new Date(since);
const end = new Date(until);
let cur = new Date(start.getFullYear(), start.getMonth(), 1);
while (cur <= end) {
const chunkStart = cur < start ? start : cur;
const nextMonth = new Date(cur.getFullYear(), cur.getMonth() + 1, 0); // last day of cur month
const chunkEnd = nextMonth > end ? end : nextMonth;
chunks.push({
since: ymd(chunkStart),
until: ymd(chunkEnd),
});
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
}
return chunks;
}
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function dateList(since: string, until: string): string[] {
const out: string[] = [];
const start = new Date(since);
const end = new Date(until);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
out.push(ymd(d));
}
return out;
}
/**
* 全量同步:每群按月分批拉 wx history → 本地存 messages → 本地聚合 daily_stats。
* 比起逐天调 wx stats 快 30 倍。
*/
export async function syncFullHistory({
targets,
since,
until,
concurrency = 6,
onProgress,
}: SyncOptions): Promise<{ ok: number; failed: number; messages: number }> {
const limit = pLimit(concurrency);
const chunks = monthChunks(since, until);
const total = targets.length * chunks.length;
let done = 0;
let ok = 0;
let failed = 0;
let totalMessages = 0;
const byTarget = new Map<
string,
{
fetched: number;
inserted: number;
failedChunks: number;
emptyChunks: number;
errors: string[];
}
>();
for (const t of targets) {
byTarget.set(t.chatroomId, {
fetched: 0,
inserted: 0,
failedChunks: 0,
emptyChunks: 0,
errors: [],
});
}
const tasks: Promise<void>[] = [];
for (const t of targets) {
for (const c of chunks) {
tasks.push(
limit(async () => {
const state = byTarget.get(t.chatroomId)!;
try {
const messages = await wxHistory(t.chatroomId, c.since, c.until, 50_000);
const inserted = bulkInsertMessages(t.chatroomId, messages);
state.fetched += messages.length;
state.inserted += inserted;
if (messages.length === 0) state.emptyChunks++;
totalMessages += inserted;
ok++;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
state.failedChunks++;
state.errors.push(`${c.since}~${c.until}: ${message}`);
failed++;
onProgress?.({
type: 'error',
done,
total,
current: `${t.display} ${c.since.slice(0, 7)}`,
error: message,
inserted_messages: totalMessages,
});
} finally {
done++;
onProgress?.({
type: 'progress',
done,
total,
current: `${t.display} ${c.since.slice(0, 7)}`,
inserted_messages: totalMessages,
});
}
}),
);
}
}
await Promise.all(tasks);
// Now aggregate daily_stats from the new messages for each target
const aggLimit = pLimit(8);
const dates = dateList(since, until);
await Promise.all(
targets.map((t) =>
aggLimit(async () => {
const buckets = aggregateDailyStats(t.chatroomId, dates);
for (const b of buckets) {
if (b.total === 0) {
// Don't overwrite if we already have non-zero stats from a prior wx-stats run
const existing = getCachedStats(t.chatroomId, b.date);
if (existing && existing.total > 0) continue;
}
saveStats({
chatroom_id: t.chatroomId,
date: b.date,
total: b.total,
top_senders: b.top_senders,
by_hour: b.by_hour,
});
}
// Update sync_state
const firstRow = db()
.prepare(
'SELECT MIN(date) AS d, MAX(date) AS dx, COUNT(*) AS n FROM messages WHERE chatroom_id = ?',
)
.get(t.chatroomId) as { d: string | null; dx: string | null; n: number };
const state = byTarget.get(t.chatroomId)!;
const status =
state.failedChunks === chunks.length
? 'failed'
: state.failedChunks > 0
? 'partial'
: firstRow.n === 0 && state.fetched === 0
? 'empty'
: 'ok';
upsertSyncState(t.chatroomId, firstRow.n, firstRow.d, firstRow.dx, {
status,
lastError: state.errors.slice(-3).join('\n') || null,
failedChunks: state.failedChunks,
emptyChunks: state.emptyChunks,
totalChunks: chunks.length,
});
}),
),
);
rebuildMentionIndexFromMessages();
onProgress?.({
type: 'done',
done: total,
total,
inserted_messages: totalMessages,
});
return { ok, failed, messages: totalMessages };
}
/**
* 兼容旧调用:单天 wx stats 模式(保留以备需要)
*/
export interface RescanOptions {
targets: RescanTarget[];
dates: string[];
concurrency?: number;
onProgress?: (p: RescanProgress) => void;
}
export async function rescan({
targets,
dates,
concurrency = 5,
onProgress,
}: RescanOptions): Promise<{ ok: number; failed: number }> {
const limit = pLimit(concurrency);
const total = targets.length * dates.length;
let done = 0;
let ok = 0;
let failed = 0;
const tasks: Promise<void>[] = [];
for (const t of targets) {
for (const d of dates) {
tasks.push(
limit(async () => {
try {
const res: WxStats = await wxStats(t.chatroomId, d, d);
saveStats({
chatroom_id: t.chatroomId,
date: d,
total: res.total ?? 0,
top_senders: res.top_senders ?? [],
by_hour: res.by_hour ?? [],
});
ok++;
} catch {
failed++;
saveStats({
chatroom_id: t.chatroomId,
date: d,
total: 0,
top_senders: [],
by_hour: [],
});
} finally {
done++;
onProgress?.({ type: 'progress', done, total, current: t.display });
}
}),
);
}
}
await Promise.all(tasks);
onProgress?.({ type: 'done', done, total });
return { ok, failed };
}
+496
View File
@@ -0,0 +1,496 @@
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { db } from './db';
import { wxSessions } from './wx';
const MIN_MESSAGES_PER_TOPIC = 4;
const MIN_MESSAGE_LENGTH = 20;
const MAX_MESSAGE_LENGTH = 400;
const MAX_MESSAGES_TO_PROCESS = 3000;
const MAX_TOPICS_TO_SAVE = 30;
const CODEX_CHUNK_SIZE = Number(process.env.WECHAT_RADAR_TOPIC_CHUNK_SIZE ?? 250);
const CODEX_TIMEOUT_MS = Number(process.env.WECHAT_RADAR_CODEX_TIMEOUT_MS ?? 300_000);
const CODEX_MODEL = process.env.WECHAT_RADAR_CODEX_MODEL;
const TOPICS_PER_CHUNK = 12;
interface SourceMsg {
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
}
interface LlmTopic {
title: string;
summary: string;
message_ids: string[];
}
interface LlmTopicResponse {
topics: LlmTopic[];
}
type TopicWithMembers = {
title: string;
summary: string;
members: SourceMsg[];
groupSet: Set<string>;
};
function cleanContent(s: string): string {
return s
.replace(/\[图片\]\s*local_id=\d+/g, '')
.replace(/\[引用\][^\n]*\n?/g, '')
.replace(/\[小程序\][^\n]*/g, '')
.replace(/↳\s*[^\n]*/g, '')
.replace(/<\?xml[\s\S]+?\?>[\s\S]*?<\/msg>/g, '')
.replace(/https?:\/\/\S+/g, ' ')
.trim();
}
// 这些消息整体就是占位符 / wrapper,无实质内容
const PLACEHOLDER_PATTERNS = [
/^\[链接\]\s*当前版本不支持/,
/^\[文件\]\s*[^\s]+\.\w+\s*$/,
/^\[视频\]\s*$/,
/^\[音频\]\s*$/,
/^\[语音\]\s*$/,
/^\[表情\]\s*$/,
/^\[图片\]\s*$/,
/^\[位置\]/,
/^\[名片\]/,
/^\[小程序\]\s*[^\s]*\s*$/,
/^\[转账\]/,
/^\[红包\]/,
];
function isPlaceholderOnly(content: string): boolean {
if (!content) return true;
return PLACEHOLDER_PATTERNS.some((p) => p.test(content));
}
function loadCandidateMessages(date: string): SourceMsg[] {
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp
FROM messages
WHERE date = ?
AND type IN ('文本', '链接/文件')
AND length(content) >= ?
ORDER BY timestamp ASC
LIMIT ?`,
)
.all(date, MIN_MESSAGE_LENGTH, MAX_MESSAGES_TO_PROCESS) as SourceMsg[];
// 1. 过滤占位符 + 清洗 + 长度筛选
const cleaned = rows
.map((r) => ({ ...r, content: cleanContent(r.content).slice(0, MAX_MESSAGE_LENGTH) }))
.filter((r) => !isPlaceholderOnly(r.content) && r.content.length >= MIN_MESSAGE_LENGTH);
// 2. 去重:相同内容(同一条转发消息)只保留第一次出现
// 这是真信号(同一篇文章被多群转发)但不应该堆成「话题」— 简化为信源(前 3 条群即可)
const seen = new Map<string, SourceMsg>();
for (const r of cleaned) {
const key = r.content.slice(0, 80); // 前 80 字相同 ≈ 同一条转发
if (!seen.has(key)) seen.set(key, r);
}
return Array.from(seen.values());
}
const TOPIC_RESPONSE_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
topics: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
title: { type: 'string' },
summary: { type: 'string' },
message_ids: {
type: 'array',
items: { type: 'string' },
},
},
required: ['title', 'summary', 'message_ids'],
},
},
},
required: ['topics'],
};
function sourceId(m: SourceMsg): string {
return `${m.chatroom_id}#${m.local_id}`;
}
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
function parseJsonOutput<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) return JSON.parse(fenced[1]) as T;
const obj = trimmed.match(/\{[\s\S]*\}/);
if (obj) return JSON.parse(obj[0]) as T;
throw new Error('codex returned non-JSON');
}
}
function runCodexJson<T>(prompt: string, timeoutMs = CODEX_TIMEOUT_MS): Promise<T> {
return new Promise((resolve, reject) => {
const dir = mkdtempSync(join(tmpdir(), 'wechat-topics-'));
const schemaPath = join(dir, 'schema.json');
const outPath = join(dir, 'response.json');
writeFileSync(schemaPath, JSON.stringify(TOPIC_RESPONSE_SCHEMA), 'utf8');
const args = [
'-a',
'never',
'exec',
'--sandbox',
'read-only',
'--ephemeral',
'--ignore-rules',
'--output-schema',
schemaPath,
'--output-last-message',
outPath,
];
if (CODEX_MODEL) args.push('--model', CODEX_MODEL);
args.push('-');
const proc = spawn(
'codex',
args,
{ env: { ...process.env, NO_COLOR: '1' }, stdio: ['pipe', 'pipe', 'pipe'] },
);
let stdout = '';
let stderr = '';
const t = setTimeout(() => {
proc.kill('SIGTERM');
rmSync(dir, { recursive: true, force: true });
reject(new Error('codex CLI timeout'));
}, timeoutMs);
proc.stdout.on('data', (d) => (stdout += d.toString()));
proc.stderr.on('data', (d) => (stderr += d.toString()));
proc.on('error', (e) => {
clearTimeout(t);
rmSync(dir, { recursive: true, force: true });
reject(e);
});
proc.on('close', (code) => {
clearTimeout(t);
try {
if (code !== 0) {
reject(new Error(`codex exit ${code}: ${stderr.slice(0, 800)}`));
return;
}
const raw = readFileSync(outPath, 'utf8') || stdout;
resolve(parseJsonOutput<T>(raw));
} catch (e) {
reject(e);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
proc.stdin.write(prompt);
proc.stdin.end();
});
}
function formatMessagesForPrompt(messages: SourceMsg[], groupNameMap: Map<string, string>): string {
return messages
.map((m) =>
JSON.stringify({
id: sourceId(m),
group: groupNameMap.get(m.chatroom_id) ?? m.chatroom_id,
sender: m.sender,
time: m.time,
content: m.content,
}),
)
.join('\n');
}
function buildExtractionPrompt(
date: string,
messages: SourceMsg[],
groupNameMap: Map<string, string>,
maxTopics: number,
): string {
return `你是微信群「话题雷达」的聚合引擎。请直接用 LLM 判断语义相关性,找出 ${date} 的主要讨论话题。
任务要求:
- 只做话题聚合,不要逐条摘要。
- 合并同一事件、产品、工具、论文、观点、问题及其追问/回应/转述。
- 优先保留跨群出现的话题;同一群内高密度连续讨论也可以保留。
- 忽略问候、纯闲聊、广告、无上下文碎片、纯占位内容和过泛的「AI 很火」类讨论。
- 每个话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
- 最多输出 ${maxTopics} 个话题,按重要性排序。
- title 用 8-15 个汉字,优先写产品名/事件名/讨论焦点。
- summary 用 1-2 句中文说明大家在讨论什么。
- message_ids 必须只使用输入消息的 id;不要编造 id;同一个 id 不要重复。
只输出严格 JSON,格式:
{"topics":[{"title":"...","summary":"...","message_ids":["群id#local_id"]}]}
输入消息为 JSONL
${formatMessagesForPrompt(messages, groupNameMap)}`;
}
function buildMergePrompt(date: string, drafts: LlmTopic[], maxTopics: number): string {
const lines = drafts
.map((t, i) =>
JSON.stringify({
id: `draft-${i + 1}`,
title: t.title,
summary: t.summary,
message_ids: t.message_ids,
message_count: t.message_ids.length,
}),
)
.join('\n');
return `下面是 ${date} 分批得到的话题草稿。请继续用 LLM 完成最终跨批合并。
任务要求:
- 合并语义相同或强相关的话题草稿,message_ids 取并集。
- 删除过泛、重复、证据不足的话题。
- 每个最终话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
- 最多输出 ${maxTopics} 个最终话题,按重要性排序。
- title 用 8-15 个汉字,summary 用 1-2 句中文。
- message_ids 必须来自输入草稿,不要编造。
只输出严格 JSON
{"topics":[{"title":"...","summary":"...","message_ids":["群id#local_id"]}]}
话题草稿 JSONL
${lines}`;
}
function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMsg>): TopicWithMembers[] {
const out: TopicWithMembers[] = [];
const seenSignatures = new Set<string>();
for (const raw of rawTopics) {
const ids = Array.from(new Set((raw.message_ids ?? []).filter((id) => messageMap.has(id))));
if (ids.length < MIN_MESSAGES_PER_TOPIC) continue;
const members = ids.map((id) => messageMap.get(id)!).sort((a, b) => a.timestamp - b.timestamp);
const signature = ids.slice().sort().join('|');
if (seenSignatures.has(signature)) continue;
seenSignatures.add(signature);
out.push({
title: (raw.title || members[0].content.slice(0, 16) || '未命名话题').slice(0, 80),
summary: (raw.summary || '').slice(0, 400),
members,
groupSet: new Set(members.map((m) => m.chatroom_id)),
});
}
return out.sort((a, b) => b.members.length - a.members.length).slice(0, MAX_TOPICS_TO_SAVE);
}
async function aggregateWithCodex(
date: string,
messages: SourceMsg[],
groupNameMap: Map<string, string>,
onProgress?: (p: TopicProgress) => void,
): Promise<TopicWithMembers[]> {
const messageMap = new Map(messages.map((m) => [sourceId(m), m]));
const chunks = chunk(messages, Math.max(50, CODEX_CHUNK_SIZE));
const drafts: LlmTopic[] = [];
onProgress?.({
type: 'llm',
done: 0,
total: chunks.length,
message: `Codex CLI 聚合 ${messages.length} 条消息…`,
});
for (let i = 0; i < chunks.length; i++) {
const response = await runCodexJson<LlmTopicResponse>(
buildExtractionPrompt(date, chunks[i], groupNameMap, TOPICS_PER_CHUNK),
);
drafts.push(...(response.topics ?? []));
onProgress?.({
type: 'llm',
done: i + 1,
total: chunks.length,
message: `Codex CLI 分批聚合 ${i + 1}/${chunks.length}`,
});
}
if (drafts.length === 0) return [];
if (chunks.length > 1) {
onProgress?.({
type: 'llm',
done: chunks.length,
total: chunks.length,
message: `Codex CLI 合并 ${drafts.length} 个话题草稿…`,
});
}
const final =
chunks.length === 1
? { topics: drafts }
: await runCodexJson<LlmTopicResponse>(buildMergePrompt(date, drafts, MAX_TOPICS_TO_SAVE));
return normalizeTopics(final.topics ?? [], messageMap);
}
export interface TopicProgress {
type: 'load' | 'llm' | 'save' | 'done' | 'error';
done?: number;
total?: number;
count?: number;
message?: string;
error?: string;
}
export async function buildTopicsForDate(
date: string,
onProgress?: (p: TopicProgress) => void,
): Promise<{ topics: number; messages: number }> {
onProgress?.({ type: 'load', message: '加载当日消息…' });
const msgs = loadCandidateMessages(date);
if (msgs.length === 0) {
onProgress?.({ type: 'done', count: 0 });
return { topics: 0, messages: 0 };
}
const sessions = await wxSessions(500).catch(() => []);
const groupNameMap = new Map<string, string>();
for (const s of sessions) groupNameMap.set(s.username, s.chat);
const valid = await aggregateWithCodex(date, msgs, groupNameMap, onProgress);
// 清空当日旧话题
db().prepare('DELETE FROM topics WHERE date = ?').run(date);
let savedTopics = 0;
let savedMessages = 0;
for (let i = 0; i < valid.length; i++) {
const c = valid[i];
onProgress?.({
type: 'save',
done: i + 1,
total: valid.length,
message: c.title,
});
const insertTopic = db().prepare(
'INSERT INTO topics (date, title, summary, message_count, group_count, created_at) VALUES (?, ?, ?, ?, ?, ?)',
);
const insertMsg = db().prepare(
'INSERT OR IGNORE INTO topic_messages (topic_id, chatroom_id, local_id, score) VALUES (?, ?, ?, ?)',
);
const tx = db().transaction(() => {
const info = insertTopic.run(
date,
c.title,
c.summary,
c.members.length,
c.groupSet.size,
Date.now(),
);
const tid = Number(info.lastInsertRowid);
for (let index = 0; index < c.members.length; index++) {
const member = c.members[index];
insertMsg.run(tid, member.chatroom_id, member.local_id, 1 - index / 1000);
savedMessages++;
}
});
tx();
savedTopics++;
}
onProgress?.({ type: 'done', count: savedTopics });
return { topics: savedTopics, messages: savedMessages };
}
export interface TopicListItem {
id: number;
date: string;
title: string;
summary: string;
message_count: number;
group_count: number;
}
export function listTopics(date: string): TopicListItem[] {
return db()
.prepare(
'SELECT id, date, title, summary, message_count, group_count FROM topics WHERE date = ? ORDER BY message_count DESC',
)
.all(date) as TopicListItem[];
}
export interface TopicDetail extends TopicListItem {
messages: Array<{
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
score: number;
}>;
}
export async function getTopicDetail(id: number): Promise<TopicDetail | null> {
const topic = db()
.prepare(
'SELECT id, date, title, summary, message_count, group_count FROM topics WHERE id = ?',
)
.get(id) as TopicListItem | undefined;
if (!topic) return null;
const rows = db()
.prepare(
`SELECT m.chatroom_id, m.local_id, m.sender, m.content, m.time, m.timestamp, m.type, tm.score
FROM topic_messages tm
JOIN messages m ON m.chatroom_id = tm.chatroom_id AND m.local_id = tm.local_id
WHERE tm.topic_id = ?
ORDER BY tm.score DESC, m.timestamp ASC`,
)
.all(id) as Array<{
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
score: number;
}>;
const sessions = await wxSessions(500).catch(() => []);
const nameMap = new Map<string, string>();
for (const s of sessions) nameMap.set(s.username, s.chat);
return {
...topic,
messages: rows.map((r) => ({
...r,
chat_name: nameMap.get(r.chatroom_id) ?? r.chatroom_id,
})),
};
}
+148
View File
@@ -0,0 +1,148 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import * as fsp from 'node:fs/promises';
// Node 22+ ships fs.promises.glob but TS types lag behind
const glob = (fsp as unknown as {
glob: (pattern: string, opts: { cwd: string }) => AsyncIterable<string>;
}).glob;
const WX_CACHE_ROOT = join(
/*turbopackIgnore: true*/ homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files',
);
let _userDirCache: string | null = null;
/** 找到当前微信用户目录(取最近修改的) */
function findUserDir(): string | null {
if (_userDirCache && existsSync(/*turbopackIgnore: true*/ _userDirCache)) return _userDirCache;
if (!existsSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT)) return null;
const entries = readdirSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name !== 'all_users' && e.name !== 'Backup')
.map((e) => {
const p = join(/*turbopackIgnore: true*/ WX_CACHE_ROOT, e.name);
return { p, mtime: statSync(/*turbopackIgnore: true*/ p).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
if (entries.length === 0) return null;
_userDirCache = entries[0].p;
return _userDirCache;
}
/** 列出按月分的子目录,按时间倒序(最近月份优先) */
function listMonthDirs(userDir: string): string[] {
const cacheDir = join(/*turbopackIgnore: true*/ userDir, 'cache');
if (!existsSync(/*turbopackIgnore: true*/ cacheDir)) return [];
return readdirSync(/*turbopackIgnore: true*/ cacheDir)
.filter((d) => /^\d{4}-\d{2}$/.test(d))
.sort((a, b) => b.localeCompare(a));
}
export interface ResolvedImage {
path: string;
type: 'hd' | 'mid' | 'thumb';
format: 'png' | 'jpeg' | 'gif' | 'bmp' | 'bin';
}
/** 检测文件 magic bytes */
function detectFormat(path: string): ResolvedImage['format'] {
try {
const fd = readFileSync(/*turbopackIgnore: true*/ path, { flag: 'r' });
const h = fd.subarray(0, 4);
if (h[0] === 0xff && h[1] === 0xd8) return 'jpeg';
if (h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47) return 'png';
if (h[0] === 0x47 && h[1] === 0x49 && h[2] === 0x46) return 'gif';
if (h[0] === 0x42 && h[1] === 0x4d) return 'bmp';
} catch {}
return 'bin';
}
// 月份 → { localId → ResolvedImage } 索引(懒加载)
const monthIndexCache = new Map<string, Map<number, ResolvedImage>>();
const monthIndexLoading = new Map<string, Promise<void>>();
async function buildMonthIndex(userDir: string, month: string): Promise<void> {
if (monthIndexCache.has(month)) return;
const existing = monthIndexLoading.get(month);
if (existing) return existing;
const p = (async () => {
const monthRoot = join(/*turbopackIgnore: true*/ userDir, 'cache', month, 'Message');
if (!existsSync(/*turbopackIgnore: true*/ monthRoot)) {
monthIndexCache.set(month, new Map());
return;
}
const idx = new Map<number, ResolvedImage>();
const priority: Record<ResolvedImage['type'], number> = { hd: 3, mid: 2, thumb: 1 };
const consider = (path: string, type: ResolvedImage['type']) => {
const m = /\/(\d+)_/.exec(path);
if (!m) return;
const id = Number(m[1]);
const cur = idx.get(id);
if (!cur || priority[type] > priority[cur.type]) {
// 推迟 detectFormat 到实际请求时
idx.set(id, { path, type, format: 'bin' });
}
};
for await (const p of glob('*/ImageTemp/*hd_temp_convert', { cwd: monthRoot })) {
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'hd');
}
for await (const p of glob('*/ImageTemp/*mid_temp_convert', { cwd: monthRoot })) {
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'mid');
}
for await (const p of glob('*/Thumb/*thumb.jpg', { cwd: monthRoot })) {
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'thumb');
}
monthIndexCache.set(month, idx);
})();
monthIndexLoading.set(month, p);
await p;
monthIndexLoading.delete(month);
}
/**
* 按 local_id 在 wx 缓存找图。优先 hint 月份,否则按月扫到旧。
* 用懒加载的内存索引加速:每月扫一次后命中 ~1ms。
*/
export async function resolveWxImage(
localId: number,
hintMonth?: string,
): Promise<ResolvedImage | null> {
const userDir = findUserDir();
if (!userDir) return null;
const months = listMonthDirs(userDir);
if (months.length === 0) return null;
const ordered = hintMonth && months.includes(hintMonth)
? [hintMonth, ...months.filter((m) => m !== hintMonth)]
: months;
for (const m of ordered) {
await buildMonthIndex(userDir, m);
const idx = monthIndexCache.get(m);
if (!idx) continue;
const hit = idx.get(localId);
if (hit) {
return { ...hit, format: detectFormat(hit.path) };
}
}
return null;
}
const MIME: Record<ResolvedImage['format'], string> = {
png: 'image/png',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
bin: 'application/octet-stream',
};
export function mimeFor(format: ResolvedImage['format']): string {
return MIME[format];
}
+64
View File
@@ -0,0 +1,64 @@
export interface WxSession {
chat: string;
chat_type: 'private' | 'group';
is_group: boolean;
last_msg_type: string;
last_sender: string;
summary: string;
time: string;
timestamp: number;
unread: number;
username: string;
}
export interface WxStatsBucket {
hour: number;
count: number;
}
export interface WxStatsSender {
sender: string;
count: number;
}
export interface WxStatsType {
type: string;
count: number;
}
export interface WxStats {
chat: string;
chat_type: 'private' | 'group';
is_group: boolean;
username: string;
total: number;
by_hour: WxStatsBucket[];
by_type: WxStatsType[];
top_senders: WxStatsSender[];
}
export interface WxMessage {
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
}
export interface WxNewMessage extends WxMessage {
username: string;
chat?: string;
}
export interface WxMember {
username: string;
nickname?: string;
display_name?: string;
}
export interface WxDaemonStatus {
running: boolean;
pid?: number;
uptime_seconds?: number;
}
+89
View File
@@ -0,0 +1,89 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type {
WxDaemonStatus,
WxMember,
WxMessage,
WxNewMessage,
WxSession,
WxStats,
} from './wx-types';
const run = promisify(execFile);
const DEFAULT_OPTS = {
maxBuffer: 64 * 1024 * 1024,
timeout: 60_000,
} as const;
async function wxRaw(args: string[], opts = DEFAULT_OPTS): Promise<string> {
const { stdout } = await run('wx', args, opts);
return stdout;
}
async function wxJson<T>(args: string[], opts = DEFAULT_OPTS): Promise<T> {
const stdout = await wxRaw([...args, '--json'], opts);
return JSON.parse(stdout) as T;
}
export async function wxSessions(limit = 500): Promise<WxSession[]> {
return wxJson<WxSession[]>(['sessions', '-n', String(limit)]);
}
export async function wxStats(
chat: string,
since: string,
until: string,
): Promise<WxStats> {
return wxJson<WxStats>(['stats', chat, '--since', since, '--until', until]);
}
export async function wxHistory(
chat: string,
since: string,
until: string,
limit = 1000,
): Promise<WxMessage[]> {
return wxJson<WxMessage[]>([
'history',
chat,
'--since',
since,
'--until',
until,
'-n',
String(limit),
]);
}
export async function wxNewMessages(limit = 50): Promise<WxNewMessage[]> {
return wxJson<WxNewMessage[]>(['new-messages', '-n', String(limit)]);
}
export async function wxMembers(chat: string): Promise<WxMember[]> {
return wxJson<WxMember[]>(['members', chat]);
}
export async function wxDaemonStatus(): Promise<WxDaemonStatus> {
try {
const out = await wxRaw(['daemon', 'status']);
const lower = out.toLowerCase();
const running = lower.includes('running') || lower.includes('运行');
const pidMatch = out.match(/pid[^\d]*(\d+)/i);
return {
running,
pid: pidMatch ? Number(pidMatch[1]) : undefined,
};
} catch {
return { running: false };
}
}
export async function wxAvailable(): Promise<boolean> {
try {
await run('wx', ['--version'], { timeout: 5_000 });
return true;
} catch {
return false;
}
}