mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-08 16:58:30 +09:00
Publish updated WeChat Radar
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(/'/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) =>
|
||||
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user