Publish updated WeChat Radar

This commit is contained in:
joeseesun
2026-05-26 09:10:04 +08:00
parent 8abb29e13e
commit de7ec99fed
44 changed files with 2900 additions and 746 deletions
+68 -32
View File
@@ -98,6 +98,7 @@ export interface DashboardLinkHighlight {
title: string;
url: string;
domain: string;
source: string;
score: number;
verdict: string;
count: number;
@@ -149,7 +150,7 @@ export function buildDashboardIntelligence(
groupNames = new Map<string, string>(),
): DashboardIntelligence {
date = resolveIntelligenceDate(date);
const key = `dashboard-intelligence:${date}:v11`;
const key = `dashboard-intelligence:${date}:v14`;
const cached = cache.get(key) as DashboardIntelligence | undefined;
if (cached) return cached;
@@ -174,6 +175,37 @@ export function buildDashboardIntelligence(
LIMIT ?`,
)
.all(minusDays(date, 7), date, 9000) as MessageSignalRow[];
const linkRows = db()
.prepare(
`SELECT
ml.url,
ml.canonical_url,
ml.title,
ml.domain,
ml.source,
ml.confidence,
ml.time,
ml.chatroom_id,
m.content
FROM message_links ml
JOIN messages m
ON m.chatroom_id = ml.chatroom_id
AND m.local_id = ml.local_id
WHERE ml.date = ?
ORDER BY ml.timestamp DESC
LIMIT 600`,
)
.all(date) as Array<{
url: string;
canonical_url: string;
title: string | null;
domain: string;
source: string;
confidence: number;
time: string;
chatroom_id: string;
content: string;
}>;
const candidates: DashboardSignalItem[] = [];
const opportunities: DashboardOpportunityItem[] = [];
@@ -203,6 +235,7 @@ export function buildDashboardIntelligence(
groups: Set<string>;
last_seen: string;
snippets: string[];
sources: Set<string>;
}
>();
@@ -261,28 +294,30 @@ export function buildDashboardIntelligence(
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);
}
}
for (const row of linkRows) {
const kind = isArticleUrl(row.canonical_url) ? 'article' : isToolUrl(row.canonical_url, row.content) ? 'tool' : null;
if (!kind) continue;
const key = normalizeUrlKey(row.canonical_url);
const clean = cleanContent(row.content);
const bucket = linkBuckets.get(key) ?? {
kind,
title: (row.title || titleFromLinkContext(row.content, row.url)).slice(0, 80),
url: row.url,
domain: row.domain || domainOf(row.canonical_url),
count: 0,
groups: new Set<string>(),
last_seen: row.time,
snippets: [],
sources: new Set<string>(),
};
bucket.count++;
bucket.groups.add(row.chatroom_id);
bucket.sources.add(row.source);
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
@@ -430,6 +465,7 @@ function buildLinkHighlights(
groups: Set<string>;
last_seen: string;
snippets: string[];
sources: Set<string>;
}
>,
): DashboardLinkHighlight[] {
@@ -441,6 +477,7 @@ function buildLinkHighlights(
title: item.title,
url: item.url,
domain: item.domain,
source: preferredLinkSource(item.sources),
score,
verdict: verdictForLink(item.kind, item.count, item.groups.size, item.snippets.join(' ')),
count: item.count,
@@ -452,6 +489,13 @@ function buildLinkHighlights(
.slice(0, MAX_LINK_HIGHLIGHTS);
}
function preferredLinkSource(sources: Set<string>): string {
if (sources.has('wechat_raw')) return 'wechat_raw';
if (sources.has('public_search')) return 'public_search';
if (sources.has('manual')) return 'manual';
return Array.from(sources)[0] ?? 'plain_url';
}
function buildPeopleRadar(
sourceMap: Map<
string,
@@ -602,14 +646,6 @@ function minusDays(date: string, days: number): string {
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, '')
@@ -645,7 +681,7 @@ function isArticleUrl(raw: string): boolean {
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);
return /zhihu|toutiao|sohu|163\.com|qq\.com|medium\.com|substack\.com|juejin\.cn|podcasts\.apple\.com|podscan\.fm/i.test(host);
} catch {
return false;
}
+65 -12
View File
@@ -84,6 +84,34 @@ function migrate(d: Database.Database) {
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 message_links (
chatroom_id TEXT NOT NULL,
local_id INTEGER NOT NULL,
date TEXT NOT NULL,
sender TEXT NOT NULL,
time TEXT NOT NULL,
timestamp INTEGER NOT NULL,
url TEXT NOT NULL,
canonical_url TEXT NOT NULL,
title TEXT,
description TEXT,
domain TEXT NOT NULL,
source TEXT NOT NULL,
raw_kind TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
PRIMARY KEY (chatroom_id, local_id, canonical_url)
);
CREATE INDEX IF NOT EXISTS idx_message_links_date
ON message_links(date, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_message_links_canonical
ON message_links(canonical_url);
CREATE INDEX IF NOT EXISTS idx_message_links_domain
ON message_links(domain);
CREATE INDEX IF NOT EXISTS idx_message_links_source
ON message_links(source);
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
@@ -115,6 +143,9 @@ function migrate(d: Database.Database) {
PRIMARY KEY (date, version)
);
CREATE INDEX IF NOT EXISTS idx_link_intelligence_cache_generated
ON link_intelligence_cache(generated_at DESC);
CREATE TABLE IF NOT EXISTS sync_state (
chatroom_id TEXT PRIMARY KEY,
last_synced_at INTEGER NOT NULL,
@@ -136,30 +167,50 @@ function migrate(d: Database.Database) {
ensureColumn(d, 'sync_state', 'total_chunks', 'INTEGER NOT NULL DEFAULT 0');
}
function ensureColumn(d: Database.Database, table: string, name: string, definition: string) {
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 SEED_VERSION = 'qiaomu_v2_2026_05_23';
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: '🏠' },
{ name: 'AI产品蝗虫团', color: '#ef4444', emoji: '🐝' },
{ name: '自营/读者群', color: '#22c55e', emoji: '🌟' },
{ name: 'WaytoAGI', color: '#06b6d4', emoji: '🛸' },
{ name: 'HowOneAI', color: '#0ea5e9', emoji: '🚀' },
{ name: 'Vibe Coding · 编程', color: '#6366f1', emoji: '💻' },
{ name: 'AIGC · 内容创作', color: '#ec4899', emoji: '🎨' },
{ name: 'AI 学术', color: '#a855f7', emoji: '🎓' },
{ name: 'AI 商业 · 营销', color: '#10b981', emoji: '💰' },
{ name: 'AI 工具用户群', color: '#f59e0b', emoji: '🛠️' },
{ name: '付费社区', color: '#eab308', emoji: '💎' },
{ name: 'AI 圈社交', color: '#8b5cf6', emoji: '🤖' },
{ name: '大佬 · 媒体圈', color: '#f97316', emoji: '📰' },
{ name: '行业活动', color: '#22d3ee', emoji: '🎯' },
{ name: '生活 · 兴趣', 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;
const meta = d
.prepare("SELECT value FROM meta WHERE key = 'seed_version'")
.get() as { value: string } | undefined;
if (meta?.value === SEED_VERSION) return;
// Check if any groups have user tags — if so, leave them alone (additive seed).
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();
if (tagged.n === 0) {
// Safe to wipe and re-seed.
d.prepare('DELETE FROM groups').run();
}
const now = Date.now();
const insertOrIgnore = d.prepare(
@@ -169,5 +220,7 @@ function seed(d: Database.Database) {
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);
d.prepare(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('seed_version', ?)",
).run(SEED_VERSION);
}
+70 -22
View File
@@ -2,35 +2,83 @@ 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()));
const lookup = (target: string) => groups.find((g) => g.name.includes(target));
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 (/蝗虫团|huangchong/i.test(name)) {
const t = lookup('蝗虫');
if (t) return { group_id: t.id, group_name: t.name, reason: '蝗虫团系列' };
}
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 (/自营|公众号读者|读者群|用户群|粉丝群/i.test(name)) {
const t = lookup('自营/读者群');
if (t) return { group_id: t.id, group_name: t.name, reason: '自营 / 读者群' };
}
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 (/waytoagi|通往agi|通往ai|通往 ai/i.test(name)) {
const t = lookup('WaytoAGI');
if (t) return { group_id: t.id, group_name: t.name, reason: 'WaytoAGI 系列' };
}
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 (/howoneai|howone/i.test(name)) {
const t = lookup('HowOneAI');
if (t) return { group_id: t.id, group_name: t.name, reason: 'HowOneAI 系列' };
}
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 (
/vibe.?coding|vibecoding|vibe first|cherry studio|cli|claude.?skills|clawdbot|codepilot|mcp|cola|geoflow|refly|camel|eigent|thinkinai|skills|all in cli/i.test(
text,
)
) {
const t = lookup('Vibe Coding');
if (t) return { group_id: t.id, group_name: t.name, reason: '编程 / Skills / CLI' };
}
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 (/学术|论文|paper|未来硅世界|研究室|nixy|simonlin|博文视点|knowledge|灵枭/i.test(text)) {
const t = lookup('AI 学术');
if (t) return { group_id: t.id, group_name: t.name, reason: '学术 / 论文' };
}
if (/生活|阅读|运动|小区|邻里|钓鱼|健身|跑步|英语|校友|投资主题/i.test(text)) {
const t = lookup('Lifestyle');
if (t) return { group_id: t.id, group_name: t.name, reason: 'Lifestyle keywords' };
if (/seo|geo|商业化|营销|kol|gaidn|adg|vip|生财|appsail|tutti|商业|broker|出版|收付款|社交新品|社群/i.test(text)) {
const t = lookup('AI 商业');
if (t) return { group_id: t.id, group_name: t.name, reason: '商业 / 营销 / KOL' };
}
if (/aigc|图|视频|音乐|spy|拍我ai|ai媒体|ai音视频|创意|graceful|创作|graphic|listenhub|notetomp|youmind|短视频|video|music|illustrat/i.test(text)) {
const t = lookup('AIGC');
if (t) return { group_id: t.id, group_name: t.name, reason: 'AIGC / 内容创作' };
}
if (/vip|烟花|修饼|传术师|生财有术|兔子ai|ai领导力|早鸟|内测|种子用户|订阅用户|私董|学员|api 渠道|一人公司|训练营|课程/i.test(text)) {
const t = lookup('付费社区');
if (t) return { group_id: t.id, group_name: t.name, reason: '付费 / 内测 / VIP' };
}
if (/用户群|用户中文|内测群|jackywine|mindcode|remio|cherry|camel|refly|cola|geoflow|hosi|aigocode|appsail|tutti|api/i.test(text)) {
const t = lookup('AI 工具用户群');
if (t) return { group_id: t.id, group_name: t.name, reason: '工具用户群' };
}
if (/神的孩子|明人明言|先行者|agi bar|智能体成精|life hacker|超级玩家|未来趋势|agent橘|新物种|创造营|不息为体|未知书社|新互联网/i.test(text)) {
const t = lookup('AI 圈社交');
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI 圈社交' };
}
if (/donews|何夕|辛亥|对接群|百度世界|央馆|火山方舟|43talks|tgo|商务|媒体/i.test(text)) {
const t = lookup('大佬');
if (t) return { group_id: t.id, group_name: t.name, reason: '大佬 / 媒体圈' };
}
if (/活动现场|聚餐|筹备组|聚会|开播|直播|线下|大会|分享会|黑客松|日历/i.test(text)) {
const t = lookup('行业活动');
if (t) return { group_id: t.id, group_name: t.name, reason: '一次性活动群' };
}
if (/钓友|路亚|果粉|大家庭|班级|邻里|小区|羽毛球|健身|跑步|徒步|阅读|共读|英语|校友|歌友|篮球/i.test(text)) {
const t = lookup('生活');
if (t) return { group_id: t.id, group_name: t.name, reason: '生活 / 兴趣' };
}
if (/粉丝|fans|读者/i.test(text)) {
const t = lookup('AI 圈社交');
if (t) return { group_id: t.id, group_name: t.name, reason: '粉丝团 / 读者群' };
}
if (/财经|股票|投资|基金|币圈|crypto|trade/i.test(text)) {
const t = lookup('AI 商业');
if (t) return { group_id: t.id, group_name: t.name, reason: '财经 / 投资' };
}
if (/x boost|twitter|推特|x kol/i.test(text)) {
const t = lookup('AIGC');
if (t) return { group_id: t.id, group_name: t.name, reason: 'X / 推特运营' };
}
if (/ai|agent|gpt|claude|llm|coding|开源/i.test(text)) {
const t = lookup('AI 圈社交');
if (t) return { group_id: t.id, group_name: t.name, reason: '通用 AI(兜底)' };
}
return null;
}
+37 -54
View File
@@ -13,7 +13,7 @@ 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_VERSION = 'v8';
const LINK_INTELLIGENCE_CACHE_TTL_SECONDS = 60 * 60 * 24;
const TOOL_HINT_RE =
@@ -27,6 +27,10 @@ const ARTICLE_HOSTS = [
'page.om.qq.com',
'www.163.com',
'mparticle.uc.cn',
'podcasts.apple.com',
'open.spotify.com',
'podcasters.spotify.com',
'podscan.fm',
];
const TOOL_HOST_HINTS = [
@@ -61,6 +65,12 @@ interface MessageLinkRow {
time: string;
timestamp: number;
type: string;
url: string;
canonical_url: string;
link_title: string | null;
domain: string;
source: string;
confidence: number;
}
export interface LinkIntelligenceItem {
@@ -80,6 +90,7 @@ export interface LinkIntelligenceItem {
time: string;
local_id: number;
snippet: string;
source: string;
}>;
dedupe_key?: string;
}
@@ -134,46 +145,6 @@ function decodeHtmlEntities(s: string): string {
.replace(/&#39;/g, "'");
}
function cleanUrl(raw: string): string {
return decodeHtmlEntities(raw)
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
.replace(/\.{3,}$/g, '')
.trim();
}
function normalizeUrl(raw: string): string | null {
if (raw.includes('...') || raw.includes('…')) return null;
try {
const u = new URL(cleanUrl(raw));
u.hash = '';
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
u.searchParams.delete(key);
}
return u.toString();
} catch {
return null;
}
}
function extractUrls(content: string): string[] {
const decoded = decodeHtmlEntities(content);
const urls = new Set<string>();
for (const m of decoded.matchAll(/imgsourceurl="([^"]+)"/g)) {
try {
urls.add(decodeURIComponent(m[1]));
} catch {
urls.add(m[1]);
}
}
for (const m of decoded.matchAll(/https?:\/\/[^\s<>"']+/g)) {
urls.add(cleanUrl(m[0]));
}
return Array.from(urls).filter(Boolean);
}
function domainOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
@@ -532,11 +503,26 @@ export async function getDailyLinkIntelligence(
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
`SELECT
m.chatroom_id,
m.local_id,
m.sender,
m.content,
m.time,
m.timestamp,
m.type,
ml.url,
ml.canonical_url,
ml.title AS link_title,
ml.domain,
ml.source,
ml.confidence
FROM message_links ml
JOIN messages m
ON m.chatroom_id = ml.chatroom_id
AND m.local_id = ml.local_id
WHERE ml.date = ?
ORDER BY ml.timestamp DESC
LIMIT ?`,
)
.all(date, MAX_MESSAGES) as MessageLinkRow[];
@@ -548,10 +534,7 @@ export async function getDailyLinkIntelligence(
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 canonical = row.canonical_url;
const kind: LinkKind | null = isArticleLink(canonical)
? 'article'
: isToolLink(canonical, row.content)
@@ -568,6 +551,7 @@ export async function getDailyLinkIntelligence(
time: row.time,
local_id: row.local_id,
snippet: cleanSnippet(row.content),
source: row.source,
};
if (existing) {
@@ -581,10 +565,10 @@ export async function getDailyLinkIntelligence(
} else {
buckets.set(key, {
kind,
url: raw,
url: row.url,
canonical_url: canonical,
title: titleFromContext(row.content, raw),
domain: domainOf(canonical),
title: row.link_title || titleFromContext(row.content, row.url),
domain: row.domain || domainOf(canonical),
count: 1,
group_count: 1,
first_seen: row.time,
@@ -592,7 +576,6 @@ export async function getDailyLinkIntelligence(
sources: [source],
});
}
}
}
const sortItems = (kind: LinkKind, limit = MAX_ITEMS_PER_KIND) =>
+290
View File
@@ -0,0 +1,290 @@
import { db } from './db';
import type { MessageRow } from './messages-store';
export type MessageLinkSource = 'wechat_raw' | 'plain_url' | 'public_search' | 'manual';
export interface ParsedMessageLink {
url: string;
canonical_url: string;
title: string | null;
description: string | null;
domain: string;
source: MessageLinkSource;
raw_kind: string;
confidence: number;
}
type LinkInput = Pick<
MessageRow,
'chatroom_id' | 'local_id' | 'date' | 'sender' | 'content' | 'time' | 'timestamp'
>;
export function decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, num: string) => String.fromCodePoint(Number.parseInt(num, 10)));
}
export function cleanUrl(raw: string): string {
return decodeHtmlEntities(raw)
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
.replace(/\.{3,}$/g, '')
.trim();
}
export function normalizeUrl(raw: string): string | null {
if (!raw || 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;
}
}
export function domainOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
function isWechatArticleUrl(url: string): boolean {
try {
const u = new URL(cleanUrl(url));
return u.hostname === 'mp.weixin.qq.com' && (/^\/s\/?/.test(u.pathname) || u.searchParams.has('__biz'));
} catch {
return false;
}
}
function tagText(content: string, tag: string): string {
const text = content.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'))?.[1] ?? '';
return decodeHtmlEntities(text)
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function attrValues(content: string, attr: string): string[] {
return Array.from(content.matchAll(new RegExp(`${attr}=["']([^"']+)["']`, 'gi')))
.map((m) => decodeHtmlEntities(m[1]).trim())
.filter(Boolean);
}
function titleFromContext(content: string, url: string): string | null {
const xmlTitle = tagText(content, 'title');
if (xmlTitle) return xmlTitle.slice(0, 160);
const decoded = decodeHtmlEntities(content).replace(/<\?xml[\s\S]+?<\/msg>/g, ' ');
const lines = decoded
.split(/\n+/)
.map((line) =>
line
.replace(url, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/\[引用\]/g, '')
.replace(/^\s*↳\s*/, '')
.replace(/^\s*\[链接\]\s*/, '')
.trim(),
)
.filter((line) => line.length >= 4 && line.length <= 120);
return lines.find((line) => !/^[@#\d\s:-]+$/.test(line))?.slice(0, 160) ?? null;
}
export function extractMessageLinks(content: string): ParsedMessageLink[] {
const decoded = decodeHtmlEntities(content);
const hasXml = /<msg[\s>]|<appmsg[\s>]/i.test(decoded);
const xmlTitle = tagText(decoded, 'title') || null;
const xmlDescription = tagText(decoded, 'des') || tagText(decoded, 'digest') || null;
const candidates: Array<{ url: string; source: MessageLinkSource; raw_kind: string; confidence: number }> = [];
for (const tag of ['url', 'lowurl']) {
const url = tagText(decoded, tag);
if (url && isWechatArticleUrl(url)) {
candidates.push({ url, source: 'wechat_raw', raw_kind: `appmsg_${tag}`, confidence: 1 });
}
}
for (const value of attrValues(decoded, 'url')) {
if (!isWechatArticleUrl(value)) continue;
candidates.push({
url: value,
source: hasXml ? 'wechat_raw' : 'plain_url',
raw_kind: hasXml ? 'appmsg_attr_url' : 'plain_attr_url',
confidence: hasXml ? 0.98 : 0.9,
});
}
for (const m of decoded.matchAll(/https?:\/\/[^\s<>"']+/g)) {
const rawUrl = cleanUrl(m[0]);
const article = isWechatArticleUrl(rawUrl);
const source: MessageLinkSource = hasXml && article ? 'wechat_raw' : 'plain_url';
candidates.push({
url: rawUrl,
source,
raw_kind: hasXml && article ? 'appmsg_url_text' : 'plain_url',
confidence: article ? 0.96 : 0.9,
});
}
const out = new Map<string, ParsedMessageLink>();
for (const c of candidates) {
const canonical = normalizeUrl(c.url);
if (!canonical) continue;
const domain = domainOf(canonical);
if (!domain) continue;
const existing = out.get(canonical);
if (existing && existing.confidence >= c.confidence) continue;
out.set(canonical, {
url: cleanUrl(c.url),
canonical_url: canonical,
title: xmlTitle ?? titleFromContext(decoded, c.url),
description: xmlDescription,
domain,
source: c.source,
raw_kind: c.raw_kind,
confidence: c.confidence,
});
}
return Array.from(out.values());
}
const upsertMessageLink = () =>
db().prepare(`
INSERT INTO message_links (
chatroom_id, local_id, date, sender, time, timestamp,
url, canonical_url, title, description, domain, source, raw_kind, confidence, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chatroom_id, local_id, canonical_url) DO UPDATE SET
url = excluded.url,
title = COALESCE(excluded.title, message_links.title),
description = COALESCE(excluded.description, message_links.description),
domain = excluded.domain,
source = excluded.source,
raw_kind = excluded.raw_kind,
confidence = excluded.confidence
`);
export function upsertLinksForMessage(message: LinkInput): number {
const links = extractMessageLinks(message.content);
if (links.length === 0) return 0;
const stmt = upsertMessageLink();
let changed = 0;
for (const link of links) {
const r = stmt.run(
message.chatroom_id,
message.local_id,
message.date,
message.sender ?? '',
message.time ?? '',
message.timestamp ?? 0,
link.url,
link.canonical_url,
link.title,
link.description,
link.domain,
link.source,
link.raw_kind,
link.confidence,
Date.now(),
);
changed += r.changes;
}
return changed;
}
export function upsertResolvedLinkForMessage(input: {
chatroom_id: string;
local_id: number;
url: string;
title?: string | null;
description?: string | null;
source: Extract<MessageLinkSource, 'public_search' | 'manual'>;
confidence?: number;
}): { ok: boolean; error?: string } {
const message = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type, date
FROM messages
WHERE chatroom_id = ? AND local_id = ?`,
)
.get(input.chatroom_id, input.local_id) as MessageRow | undefined;
if (!message) return { ok: false, error: 'message not found' };
const canonical = normalizeUrl(input.url);
if (!canonical) return { ok: false, error: 'invalid url' };
const domain = domainOf(canonical);
if (!domain) return { ok: false, error: 'invalid domain' };
upsertMessageLink().run(
message.chatroom_id,
message.local_id,
message.date,
message.sender ?? '',
message.time ?? '',
message.timestamp ?? 0,
cleanUrl(input.url),
canonical,
input.title?.trim() || titleFromContext(message.content, input.url),
input.description?.trim() || null,
domain,
input.source,
input.source,
input.confidence ?? (input.source === 'manual' ? 0.95 : 0.72),
Date.now(),
);
return { ok: true };
}
export function backfillMessageLinks(since?: string, until?: string): { scanned: number; links: number } {
const clauses = ["(content LIKE '%http%' OR content LIKE '%<url>%' OR content LIKE '%imgsourceurl=%')"];
const params: string[] = [];
if (since) {
clauses.push('date >= ?');
params.push(since);
}
if (until) {
clauses.push('date <= ?');
params.push(until);
}
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type, date
FROM messages
WHERE ${clauses.join(' AND ')}
ORDER BY timestamp DESC`,
)
.all(...params) as MessageRow[];
let links = 0;
const tx = db().transaction(() => {
db()
.prepare(
`DELETE FROM message_links
WHERE source = 'wechat_raw'
AND canonical_url NOT LIKE '%://mp.weixin.qq.com/%'`,
)
.run();
for (const row of rows) links += upsertLinksForMessage(row);
});
tx();
return { scanned: rows.length, links };
}
+10
View File
@@ -1,4 +1,5 @@
import { db } from './db';
import { upsertLinksForMessage } from './message-links';
import type { WxMessage } from './wx-types';
export interface MessageRow extends WxMessage {
@@ -42,6 +43,15 @@ export function bulkInsertMessages(chatroomId: string, messages: WxMessage[]): n
m.type ?? '',
dateOfMessage(m),
);
upsertLinksForMessage({
chatroom_id: chatroomId,
local_id: m.local_id,
sender: m.sender ?? '',
content: m.content ?? '',
time: m.time ?? '',
timestamp: m.timestamp ?? 0,
date: dateOfMessage(m),
});
if (r.changes > 0) inserted++;
}
});
+45 -6
View File
@@ -42,19 +42,35 @@ type TopicWithMembers = {
};
function cleanContent(s: string): string {
return s
const text = 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(/\[(?:链接|链接\/文件)\]\s*(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持展示该内容,请升级至最新版本)[。.]?/gi, ' ')
.replace(/当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?/gi, ' ')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/\b[a-f0-9]{24,}\b/gi, ' ')
.replace(/\b\d{12,}\b/g, ' ')
.trim();
const parts = text
.split(/↳|\\n|\n/)
.map((part) =>
part
.replace(/^\[引用\]\s*/g, '')
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
.replace(/\s+/g, ' ')
.trim(),
)
.filter((part) => part.length >= MIN_MESSAGE_LENGTH && !isPlaceholderTitle(part));
return parts[0] ?? text.replace(/\s+/g, ' ').trim();
}
// 这些消息整体就是占位符 / wrapper,无实质内容
const PLACEHOLDER_PATTERNS = [
/^\[链接\]\s*当前版本不支持/,
/^\[链接\]\s*当前(?:微信)?版本不支持/,
/^当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?$/,
/^\[文件\]\s*[^\s]+\.\w+\s*$/,
/^\[视频\]\s*$/,
/^\[音频\]\s*$/,
@@ -70,7 +86,16 @@ const PLACEHOLDER_PATTERNS = [
function isPlaceholderOnly(content: string): boolean {
if (!content) return true;
return PLACEHOLDER_PATTERNS.some((p) => p.test(content));
return PLACEHOLDER_PATTERNS.some((p) => p.test(content)) || isPlaceholderTitle(content);
}
function isPlaceholderTitle(value: string): boolean {
const text = value
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return true;
return /^(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持|请升级至最新版本)[。.]?$/i.test(text);
}
function loadCandidateMessages(date: string): SourceMsg[] {
@@ -237,6 +262,8 @@ function buildExtractionPrompt(
- 合并同一事件、产品、工具、论文、观点、问题及其追问/回应/转述。
- 优先保留跨群出现的话题;同一群内高密度连续讨论也可以保留。
- 忽略问候、纯闲聊、广告、无上下文碎片、纯占位内容和过泛的「AI 很火」类讨论。
- 严禁把「当前版本不支持展示该内容」「当前微信版本不支持展示该内容」「请升级至最新版本」这类微信占位文案当作话题标题或摘要。
- 遇到链接/视频/小程序占位时,只能根据前后文里真正有人讨论的对象命名;没有可读上下文就丢弃。
- 每个话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
- 最多输出 ${maxTopics} 个话题,按重要性排序。
- title 用 8-15 个汉字,优先写产品名/事件名/讨论焦点。
@@ -268,6 +295,7 @@ function buildMergePrompt(date: string, drafts: LlmTopic[], maxTopics: number):
任务要求:
- 合并语义相同或强相关的话题草稿,message_ids 取并集。
- 删除过泛、重复、证据不足的话题。
- 删除微信升级提示、资源封面、头像链接、无语义数字串等占位话题。
- 每个最终话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
- 最多输出 ${maxTopics} 个最终话题,按重要性排序。
- title 用 8-15 个汉字,summary 用 1-2 句中文。
@@ -294,7 +322,7 @@ function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMs
seenSignatures.add(signature);
out.push({
title: (raw.title || members[0].content.slice(0, 16) || '未命名话题').slice(0, 80),
title: cleanTopicTitle(raw.title, members),
summary: (raw.summary || '').slice(0, 400),
members,
groupSet: new Set(members.map((m) => m.chatroom_id)),
@@ -304,6 +332,17 @@ function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMs
return out.sort((a, b) => b.members.length - a.members.length).slice(0, MAX_TOPICS_TO_SAVE);
}
function cleanTopicTitle(title: string, members: SourceMsg[]): string {
const cleaned = cleanContent(title || '');
if (cleaned && !isPlaceholderTitle(cleaned)) return cleaned.slice(0, 80);
const candidate = members
.slice()
.sort((a, b) => b.content.length - a.content.length)
.map((m) => cleanContent(m.content))
.find((text) => text.length >= 8 && !isPlaceholderTitle(text));
return (candidate || '未命名话题').slice(0, 80);
}
async function aggregateWithCodex(
date: string,
messages: SourceMsg[],
+16 -16
View File
@@ -9,21 +9,21 @@ const glob = (fsp as unknown as {
}).glob;
const WX_CACHE_ROOT = join(
/*turbopackIgnore: true*/ homedir(),
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 })
if (_userDirCache && existsSync(_userDirCache)) return _userDirCache;
if (!existsSync(WX_CACHE_ROOT)) return null;
const entries = readdirSync(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 };
const p = join(WX_CACHE_ROOT, e.name);
return { p, mtime: statSync(p).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
if (entries.length === 0) return null;
@@ -33,9 +33,9 @@ function findUserDir(): string | null {
/** 列出按月分的子目录,按时间倒序(最近月份优先) */
function listMonthDirs(userDir: string): string[] {
const cacheDir = join(/*turbopackIgnore: true*/ userDir, 'cache');
if (!existsSync(/*turbopackIgnore: true*/ cacheDir)) return [];
return readdirSync(/*turbopackIgnore: true*/ cacheDir)
const cacheDir = join(userDir, 'cache');
if (!existsSync(cacheDir)) return [];
return readdirSync(cacheDir)
.filter((d) => /^\d{4}-\d{2}$/.test(d))
.sort((a, b) => b.localeCompare(a));
}
@@ -49,7 +49,7 @@ export interface ResolvedImage {
/** 检测文件 magic bytes */
function detectFormat(path: string): ResolvedImage['format'] {
try {
const fd = readFileSync(/*turbopackIgnore: true*/ path, { flag: 'r' });
const fd = readFileSync(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';
@@ -69,8 +69,8 @@ async function buildMonthIndex(userDir: string, month: string): Promise<void> {
if (existing) return existing;
const p = (async () => {
const monthRoot = join(/*turbopackIgnore: true*/ userDir, 'cache', month, 'Message');
if (!existsSync(/*turbopackIgnore: true*/ monthRoot)) {
const monthRoot = join(userDir, 'cache', month, 'Message');
if (!existsSync(monthRoot)) {
monthIndexCache.set(month, new Map());
return;
}
@@ -89,13 +89,13 @@ async function buildMonthIndex(userDir: string, month: string): Promise<void> {
};
for await (const p of glob('*/ImageTemp/*hd_temp_convert', { cwd: monthRoot })) {
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'hd');
consider(join(monthRoot, String(p)), 'hd');
}
for await (const p of glob('*/ImageTemp/*mid_temp_convert', { cwd: monthRoot })) {
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'mid');
consider(join(monthRoot, String(p)), 'mid');
}
for await (const p of glob('*/Thumb/*thumb.jpg', { cwd: monthRoot })) {
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'thumb');
consider(join(monthRoot, String(p)), 'thumb');
}
monthIndexCache.set(month, idx);
})();