mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-10 00:28:30 +09:00
Initial open source release
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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) : ['观点'];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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] : [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user