Initial open source release

This commit is contained in:
joeseesun
2026-05-24 13:42:56 +08:00
commit 8abb29e13e
75 changed files with 13298 additions and 0 deletions
+836
View File
@@ -0,0 +1,836 @@
import { db } from './db';
import { cache } from './cache';
import { todayStr } from './range';
const MAX_ROWS = 1600;
const MAX_MUST_READ = 8;
const MAX_OPPORTUNITIES = 5;
const MAX_SIGNAL_SOURCES = 8;
const MAX_ACTION_ITEMS = 8;
const MAX_TOPIC_LIFECYCLE = 6;
const MAX_LINK_HIGHLIGHTS = 8;
const MAX_PEOPLE_RADAR = 8;
const MAX_CONTENT_IDEAS = 6;
const MAX_ANOMALIES = 6;
const CACHE_TTL_SECONDS = 90;
const URL_RE = /https?:\/\/[^\s<>"']+/i;
const URL_GLOBAL_RE = /https?:\/\/[^\s<>"']+/g;
const TOOL_RE =
/工具|产品|项目|插件|模型|智能体|Agent|Claude|Gemini|Codex|API|CLI|MCP|开源|GitHub|Chrome|飞书|Notion|Obsidian|workflow|workspace/i;
const OPPORTUNITY_RE =
/求推荐|求一个|谁有|谁能.*(推荐|帮|做|开发|联系)|有没有.*(工具|方案|资源|推荐)|想找|找人|招募|报名|内测|名额|一起做|采购|团购|项目合作|合作.*(项目|机会|对接|商演|商务)|需要.*(推荐|合作|对接|开发|方案)/i;
const ACTION_RE = /帮忙|看看|回复|跟进|对接|联系|报名|填写|试试|评估|整理|发我|私信/i;
const QUESTION_RE = /[??]|怎么|如何|为啥|为什么|能不能|可不可以|有没有/i;
const NOISE_RE = /撤回了一条消息|邀请.*加入了群聊|移出了群聊|以下为新消息/i;
const DIGEST_RE = /日报|每日情报|群日报|资源分享|今日小结|知识库更新/i;
interface MessageSignalRow {
chatroom_id: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
}
interface TopicDefinition {
title: string;
keywords: string[];
re: RegExp;
}
const TOPIC_DEFINITIONS: TopicDefinition[] = [
{ title: 'Codex / Claude Code 工作流', keywords: ['Codex', 'Claude Code', 'CLI'], re: /codex|claude code|claude.?skills|clawdbot|cli|vibe.?coding/i },
{ title: 'AI Agent 与智能体', keywords: ['Agent', '智能体', '多智能体'], re: /agent|智能体|multi.?agent|工作流|workflow/i },
{ title: 'AI 工具与产品体验', keywords: ['工具', '产品', '内测'], re: /工具|产品|插件|内测|体验|注册|api|模型/i },
{ title: 'MCP / Skills / 开源项目', keywords: ['MCP', 'Skills', 'GitHub'], re: /mcp|skills?|github|开源|repo|仓库/i },
{ title: '内容创作与 AIGC', keywords: ['AIGC', '视频', '小红书'], re: /aigc|视频|音乐|图像|小红书|公众号|内容|创作|封面/i },
{ title: 'GEO / SEO / AI 营销', keywords: ['GEO', 'SEO', '营销'], re: /geo|seo|营销|搜索|获客|品牌|公关/i },
{ title: '知识库与飞书文档', keywords: ['飞书', '知识库', '文档'], re: /飞书|知识库|文档|notion|obsidian|wiki|表格/i },
{ title: '活动 / 报名 / 社群运营', keywords: ['活动', '报名', '直播'], re: /活动|报名|直播|训练营|课程|大会|线下|分享会|名额/i },
{ title: '团购 / 采购 / 商务机会', keywords: ['团购', '采购', '合作'], re: /团购|采购|报价|预算|合作|商务|对接/i },
{ title: '投资 / 财经 / 宏观讨论', keywords: ['投资', '财经', '股票'], re: /投资|财经|股票|基金|币圈|crypto|美股|港股/i },
];
export interface DashboardSignalItem {
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
time: string;
title: string;
snippet: string;
score: number;
reasons: string[];
}
export interface DashboardOpportunityItem extends DashboardSignalItem {
action: string;
}
export interface DashboardSignalSource {
sender: string;
signal_count: number;
group_count: number;
top_group: string;
last_seen: string;
strengths: string[];
}
export interface DashboardActionItem extends DashboardOpportunityItem {
why: string;
urgency: 'high' | 'medium' | 'low';
}
export interface DashboardTopicLifecycle {
title: string;
status: 'rising' | 'spreading' | 'hot' | 'cooling';
today_count: number;
previous_avg: number;
group_count: number;
reason: string;
keywords: string[];
}
export interface DashboardLinkHighlight {
kind: 'article' | 'tool';
title: string;
url: string;
domain: string;
score: number;
verdict: string;
count: number;
group_count: number;
last_seen: string;
}
export interface DashboardPeopleRadar {
sender: string;
role: '分享者' | '需求提出者' | '连接者' | '观点源';
score: number;
group_count: number;
signal_count: number;
top_group: string;
reason: string;
}
export interface DashboardContentIdea {
title: string;
angle: string;
suggested_channel: '公众号' | 'X' | '小红书' | '博客';
evidence: string;
source_count: number;
}
export interface DashboardAnomalySignal {
kind: 'spike' | 'cross_group' | 'dense_links' | 'quiet_day';
title: string;
description: string;
severity: 'high' | 'medium' | 'low';
href?: string;
}
export interface DashboardIntelligence {
date: string;
must_read: DashboardSignalItem[];
opportunities: DashboardOpportunityItem[];
signal_sources: DashboardSignalSource[];
action_items: DashboardActionItem[];
topic_lifecycle: DashboardTopicLifecycle[];
link_highlights: DashboardLinkHighlight[];
people_radar: DashboardPeopleRadar[];
content_ideas: DashboardContentIdea[];
anomalies: DashboardAnomalySignal[];
}
export function buildDashboardIntelligence(
date = todayStr(),
groupNames = new Map<string, string>(),
): DashboardIntelligence {
date = resolveIntelligenceDate(date);
const key = `dashboard-intelligence:${date}:v11`;
const cached = cache.get(key) as DashboardIntelligence | undefined;
if (cached) return cached;
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp
FROM messages
WHERE date = ?
AND length(content) >= 8
ORDER BY timestamp DESC
LIMIT ?`,
)
.all(date, MAX_ROWS) as MessageSignalRow[];
const historyRows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, content, time, timestamp
FROM messages
WHERE date >= ?
AND date <= ?
AND length(content) >= 4
ORDER BY timestamp DESC
LIMIT ?`,
)
.all(minusDays(date, 7), date, 9000) as MessageSignalRow[];
const candidates: DashboardSignalItem[] = [];
const opportunities: DashboardOpportunityItem[] = [];
const seenOpportunities = new Set<string>();
const sourceMap = new Map<
string,
{
sender: string;
signal_count: number;
groups: Set<string>;
topGroups: Map<string, number>;
last_seen: string;
link_count: number;
opportunity_count: number;
tool_count: number;
}
>();
const linkBuckets = new Map<
string,
{
kind: 'article' | 'tool';
title: string;
url: string;
domain: string;
count: number;
groups: Set<string>;
last_seen: string;
snippets: string[];
}
>();
const seenSnippets = new Set<string>();
for (const row of rows) {
const clean = cleanContent(row.content);
if (!clean || NOISE_RE.test(row.content)) continue;
const score = scoreContent(clean);
if (score < 4) continue;
const title = titleFromContent(clean);
const dedupeKey = normalizeDedupe(title || clean);
if (seenSnippets.has(dedupeKey)) continue;
seenSnippets.add(dedupeKey);
const reasons = reasonsFor(clean);
const item: DashboardSignalItem = {
chatroom_id: row.chatroom_id,
chat_name: groupNames.get(row.chatroom_id) ?? row.chatroom_id,
local_id: row.local_id,
sender: row.sender || '未知成员',
time: row.time,
title,
snippet: clean.slice(0, 150),
score,
reasons,
};
candidates.push(item);
if (isOpportunity(clean)) {
const opportunityKey = opportunityDedupeKey(clean, item.title);
if (!seenOpportunities.has(opportunityKey)) {
seenOpportunities.add(opportunityKey);
opportunities.push({ ...item, action: actionFor(clean) });
}
}
const sourceKey = item.sender.trim() || '未知成员';
const source = sourceMap.get(sourceKey) ?? {
sender: sourceKey,
signal_count: 0,
groups: new Set<string>(),
topGroups: new Map<string, number>(),
last_seen: item.time,
link_count: 0,
opportunity_count: 0,
tool_count: 0,
};
source.signal_count++;
source.groups.add(row.chatroom_id);
source.topGroups.set(item.chat_name, (source.topGroups.get(item.chat_name) ?? 0) + 1);
source.last_seen = source.last_seen > item.time ? source.last_seen : item.time;
if (URL_RE.test(clean) || clean.includes('链接')) source.link_count++;
if (isOpportunity(clean)) source.opportunity_count++;
if (TOOL_RE.test(clean)) source.tool_count++;
sourceMap.set(sourceKey, source);
for (const url of extractUrls(row.content)) {
const domain = domainOf(url);
if (!domain) continue;
const kind = isArticleUrl(url) ? 'article' : isToolUrl(url, clean) ? 'tool' : null;
if (!kind) continue;
const key = normalizeUrlKey(url);
const bucket = linkBuckets.get(key) ?? {
kind,
title: titleFromLinkContext(row.content, url),
url,
domain,
count: 0,
groups: new Set<string>(),
last_seen: row.time,
snippets: [],
};
bucket.count++;
bucket.groups.add(row.chatroom_id);
bucket.last_seen = bucket.last_seen > row.time ? bucket.last_seen : row.time;
if (clean) bucket.snippets.push(clean.slice(0, 80));
linkBuckets.set(key, bucket);
}
}
const mustRead = candidates
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
.slice(0, MAX_MUST_READ);
const opportunityItems = opportunities
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
.slice(0, MAX_OPPORTUNITIES);
const signalSources = Array.from(sourceMap.values())
.map((s) => ({
sender: s.sender,
signal_count: s.signal_count,
group_count: s.groups.size,
top_group: topEntry(s.topGroups),
last_seen: s.last_seen,
strengths: strengthsFor(s),
}))
.filter((s) => s.signal_count >= 2)
.sort((a, b) => b.signal_count - a.signal_count || b.group_count - a.group_count)
.slice(0, MAX_SIGNAL_SOURCES);
const actionItems = buildActionItems(opportunityItems, mustRead);
const topicLifecycle = buildTopicLifecycle(date, historyRows);
const linkHighlights = buildLinkHighlights(linkBuckets);
const peopleRadar = buildPeopleRadar(sourceMap);
const contentIdeas = buildContentIdeas(topicLifecycle, mustRead, linkHighlights);
const anomalies = buildAnomalies(date, groupNames, linkBuckets, rows.length);
const result = {
date,
must_read: mustRead,
opportunities: opportunityItems,
signal_sources: signalSources,
action_items: actionItems,
topic_lifecycle: topicLifecycle,
link_highlights: linkHighlights,
people_radar: peopleRadar,
content_ideas: contentIdeas,
anomalies,
};
cache.set(key, result, CACHE_TTL_SECONDS);
return result;
}
function resolveIntelligenceDate(date: string): string {
const row = db()
.prepare('SELECT date FROM messages WHERE date <= ? GROUP BY date ORDER BY date DESC LIMIT 1')
.get(date) as { date: string } | undefined;
return row?.date ?? date;
}
function buildActionItems(
opportunities: DashboardOpportunityItem[],
mustRead: DashboardSignalItem[],
): DashboardActionItem[] {
const fromOpportunity = opportunities.map((item) => ({
...item,
why: whyForAction(item.snippet),
urgency: urgencyFor(item.snippet, item.score),
}));
const fallback = mustRead
.filter((item) => item.reasons.includes('问题') || item.reasons.includes('工具/产品'))
.slice(0, 3)
.map((item) => ({
...item,
action: item.reasons.includes('问题') ? '可回复观点' : '可试用/收藏',
why: item.reasons.includes('问题') ? '包含明确问题,适合补充观点或资源' : '包含工具/产品线索,适合试用或收入素材库',
urgency: 'medium' as const,
}));
const seen = new Set<string>();
return [...fromOpportunity, ...fallback]
.filter((item) => {
const key = normalizeDedupe(`${item.chatroom_id}:${item.title}`);
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, MAX_ACTION_ITEMS);
}
function buildTopicLifecycle(date: string, rows: MessageSignalRow[]): DashboardTopicLifecycle[] {
const dayCounts = new Map<string, Map<string, { count: number; groups: Set<string> }>>();
for (const row of rows) {
const d = row.time.slice(0, 10);
if (!d) continue;
const clean = cleanContent(row.content);
for (const topic of TOPIC_DEFINITIONS) {
if (!topic.re.test(clean)) continue;
const perDay = dayCounts.get(topic.title) ?? new Map<string, { count: number; groups: Set<string> }>();
const bucket = perDay.get(d) ?? { count: 0, groups: new Set<string>() };
bucket.count++;
bucket.groups.add(row.chatroom_id);
perDay.set(d, bucket);
dayCounts.set(topic.title, perDay);
}
}
return TOPIC_DEFINITIONS.map((topic) => {
const perDay = dayCounts.get(topic.title) ?? new Map<string, { count: number; groups: Set<string> }>();
const today = perDay.get(date) ?? { count: 0, groups: new Set<string>() };
const previousValues = Array.from(perDay.entries())
.filter(([d]) => d < date)
.map(([, v]) => v.count);
const previousAvg =
previousValues.length > 0 ? previousValues.reduce((sum, n) => sum + n, 0) / previousValues.length : 0;
const ratio = today.count / Math.max(previousAvg, 1);
const status: DashboardTopicLifecycle['status'] =
today.groups.size >= 5
? 'spreading'
: ratio >= 1.8 && today.count >= 5
? 'rising'
: today.count >= 16
? 'hot'
: today.count < previousAvg * 0.45 && previousAvg >= 6
? 'cooling'
: 'hot';
return {
title: topic.title,
status,
today_count: today.count,
previous_avg: Number(previousAvg.toFixed(1)),
group_count: today.groups.size,
reason: topicReason(status, today.count, previousAvg, today.groups.size),
keywords: topic.keywords,
};
})
.filter((topic) => topic.today_count > 0 || topic.status === 'cooling')
.sort((a, b) => {
const priority = statusWeight(b.status) - statusWeight(a.status);
return priority || b.today_count - a.today_count || b.group_count - a.group_count;
})
.slice(0, MAX_TOPIC_LIFECYCLE);
}
function buildLinkHighlights(
linkBuckets: Map<
string,
{
kind: 'article' | 'tool';
title: string;
url: string;
domain: string;
count: number;
groups: Set<string>;
last_seen: string;
snippets: string[];
}
>,
): DashboardLinkHighlight[] {
return Array.from(linkBuckets.values())
.map((item) => {
const score = item.count * 2 + item.groups.size * 3 + (item.kind === 'tool' ? 2 : 0);
return {
kind: item.kind,
title: item.title,
url: item.url,
domain: item.domain,
score,
verdict: verdictForLink(item.kind, item.count, item.groups.size, item.snippets.join(' ')),
count: item.count,
group_count: item.groups.size,
last_seen: item.last_seen,
};
})
.sort((a, b) => b.score - a.score || b.last_seen.localeCompare(a.last_seen))
.slice(0, MAX_LINK_HIGHLIGHTS);
}
function buildPeopleRadar(
sourceMap: Map<
string,
{
sender: string;
signal_count: number;
groups: Set<string>;
topGroups: Map<string, number>;
last_seen: string;
link_count: number;
opportunity_count: number;
tool_count: number;
}
>,
): DashboardPeopleRadar[] {
return Array.from(sourceMap.values())
.map((s) => {
const score = s.signal_count * 2 + s.groups.size * 3 + s.link_count + s.opportunity_count * 2 + s.tool_count;
const role: DashboardPeopleRadar['role'] =
s.opportunity_count >= 2 ? '需求提出者' : s.groups.size >= 3 ? '连接者' : s.link_count >= s.tool_count ? '分享者' : '观点源';
return {
sender: s.sender,
role,
score,
group_count: s.groups.size,
signal_count: s.signal_count,
top_group: topEntry(s.topGroups),
reason: personReason(role, s.signal_count, s.groups.size),
};
})
.filter((p) => p.signal_count >= 2)
.sort((a, b) => b.score - a.score || b.group_count - a.group_count)
.slice(0, MAX_PEOPLE_RADAR);
}
function buildContentIdeas(
topics: DashboardTopicLifecycle[],
mustRead: DashboardSignalItem[],
links: DashboardLinkHighlight[],
): DashboardContentIdea[] {
const ideas: DashboardContentIdea[] = [];
for (const topic of topics.slice(0, 4)) {
ideas.push({
title: `${topic.title}:今天微信群里真正升温的信号`,
angle: topic.status === 'spreading' ? '从跨群扩散解释为什么它值得关注' : '从真实讨论里提炼一个可执行判断',
suggested_channel: topic.title.includes('工作流') || topic.title.includes('开源') ? '博客' : '公众号',
evidence: topic.reason,
source_count: topic.today_count,
});
}
for (const link of links.slice(0, 2)) {
ideas.push({
title: `${link.kind === 'tool' ? '新工具观察' : '文章拆解'}${link.title}`,
angle: link.verdict,
suggested_channel: link.kind === 'tool' ? 'X' : '公众号',
evidence: `${link.group_count} 个群提到,${link.count} 次出现`,
source_count: link.count,
});
}
if (ideas.length < MAX_CONTENT_IDEAS) {
for (const item of mustRead.slice(0, MAX_CONTENT_IDEAS - ideas.length)) {
ideas.push({
title: item.title,
angle: item.reasons.includes('问题') ? '从一个真实问题切入,给出判断和清单' : '把高信号讨论整理成一篇短观点',
suggested_channel: item.reasons.includes('工具/产品') ? 'X' : '公众号',
evidence: `${item.chat_name} · ${item.sender}`,
source_count: item.score,
});
}
}
return ideas.slice(0, MAX_CONTENT_IDEAS);
}
function buildAnomalies(
date: string,
groupNames: Map<string, string>,
linkBuckets: Map<string, { count: number; groups: Set<string>; title: string; kind: 'article' | 'tool'; url: string }>,
todayRows: number,
): DashboardAnomalySignal[] {
const anomalies: DashboardAnomalySignal[] = [];
const rows = db()
.prepare(
`SELECT chatroom_id, date, total
FROM daily_stats
WHERE date >= ? AND date <= ? AND total > 0`,
)
.all(minusDays(date, 7), date) as Array<{ chatroom_id: string; date: string; total: number }>;
const byGroup = new Map<string, Array<{ date: string; total: number }>>();
for (const row of rows) {
const arr = byGroup.get(row.chatroom_id) ?? [];
arr.push({ date: row.date, total: row.total });
byGroup.set(row.chatroom_id, arr);
}
for (const [chatroomId, values] of byGroup) {
const today = values.find((v) => v.date === date)?.total ?? 0;
const prev = values.filter((v) => v.date < date).map((v) => v.total);
if (today < 20 || prev.length === 0) continue;
const avg = prev.reduce((sum, n) => sum + n, 0) / prev.length;
if (today >= Math.max(30, avg * 2.2)) {
anomalies.push({
kind: 'spike',
title: `${groupNames.get(chatroomId) ?? chatroomId} 突然升温`,
description: `今日 ${today} 条,约为近 7 日均值 ${avg.toFixed(1)}${Math.round(today / Math.max(avg, 1))}`,
severity: today >= avg * 4 ? 'high' : 'medium',
href: `/groups/${encodeURIComponent(chatroomId)}?date=${date}`,
});
}
}
for (const link of Array.from(linkBuckets.values()).filter((l) => l.groups.size >= 3).slice(0, 3)) {
anomalies.push({
kind: 'cross_group',
title: `${link.kind === 'tool' ? '工具' : '文章'}跨群扩散`,
description: `${link.title}${link.groups.size} 个群同时提到,适合优先查看`,
severity: link.groups.size >= 5 ? 'high' : 'medium',
href: link.url,
});
}
if (todayRows === 0) {
anomalies.push({
kind: 'quiet_day',
title: '今日暂无本地消息',
description: '可能还未同步当天消息,建议重扫或检查 wx-daemon',
severity: 'low',
});
}
return anomalies
.sort((a, b) => severityWeight(b.severity) - severityWeight(a.severity))
.slice(0, MAX_ANOMALIES);
}
function cleanContent(content: string): string {
const xmlText = xmlSummary(content);
return (xmlText || content)
.replace(/https?:\/\/\S+/g, ' 链接 ')
.replace(/\[引用\]/g, '')
.replace(/↳/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function minusDays(date: string, days: number): string {
const [year, month, day] = date.split('-').map(Number);
const d = new Date(year, month - 1, day);
d.setDate(d.getDate() - days);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function extractUrls(content: string): string[] {
const decoded = decodeHtml(content);
return Array.from(decoded.matchAll(URL_GLOBAL_RE))
.map((m) => cleanUrl(m[0]))
.filter(Boolean)
.slice(0, 6);
}
function cleanUrl(raw: string): string {
return raw
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
.replace(/\.{3,}$/g, '')
.trim();
}
function normalizeUrlKey(raw: string): string {
try {
const u = new URL(cleanUrl(raw));
u.hash = '';
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
u.searchParams.delete(key);
}
return u.toString();
} catch {
return raw;
}
}
function domainOf(raw: string): string {
try {
return new URL(cleanUrl(raw)).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
function isArticleUrl(raw: string): boolean {
try {
const u = new URL(cleanUrl(raw));
const host = u.hostname.replace(/^www\./, '');
if (host === 'mp.weixin.qq.com') return true;
if ((host === 'x.com' || host === 'twitter.com') && /\/status\/\d{12,}/.test(u.pathname)) return true;
if (host === 'youtube.com' || host === 'youtu.be') return true;
return /zhihu|toutiao|sohu|163\.com|qq\.com|medium\.com|substack\.com|juejin\.cn/i.test(host);
} catch {
return false;
}
}
function isToolUrl(raw: string, content: string): boolean {
try {
const u = new URL(cleanUrl(raw));
const host = u.hostname.replace(/^www\./, '');
if (/qlogo|qpic|support\.weixin|res\.wx/i.test(host)) return false;
if (isArticleUrl(raw)) return false;
if (/github\.com|huggingface\.co|replicate\.com|vercel\.app|netlify\.app|feishu\.cn|notion\.so|notion\.site|docs\.google\.com/i.test(host)) {
return true;
}
return TOOL_RE.test(content);
} catch {
return false;
}
}
function titleFromLinkContext(content: string, url: string): string {
const xmlTitle = tagText(content, 'title');
if (xmlTitle) return xmlTitle.slice(0, 56);
const clean = decodeHtml(content)
.replace(url, '')
.replace(URL_GLOBAL_RE, '')
.replace(/\[引用\]/g, '')
.replace(/↳/g, '')
.replace(/\s+/g, ' ')
.trim();
const first = clean.split(/[。!?!?]\s*/).find((part) => part.trim().length >= 6);
return (first ?? domainOf(url)).slice(0, 56);
}
function whyForAction(content: string): string {
if (/团购|采购|报价|预算/i.test(content)) return '包含采购/团购信号,可能直接转化为资源或商务机会';
if (/报名|名额|活动|会议|直播/i.test(content)) return '包含时间敏感入口,适合尽快确认是否参与';
if (/合作|对接|找人|招募|一起做/i.test(content)) return '包含合作或找人需求,适合主动连接';
if (/求推荐|有没有|谁有|需要/i.test(content)) return '有人提出明确需求,适合用你的资源网络回复';
return '具备明确上下文和行动动词,适合进入原群查看';
}
function urgencyFor(content: string, score: number): DashboardActionItem['urgency'] {
if (/今天|今晚|明天|马上|名额|截止|限时|报名/i.test(content) || score >= 10) return 'high';
if (/合作|采购|团购|对接|求推荐/i.test(content) || score >= 7) return 'medium';
return 'low';
}
function topicReason(
status: DashboardTopicLifecycle['status'],
todayCount: number,
previousAvg: number,
groupCount: number,
): string {
if (status === 'spreading') return `${groupCount} 个群出现,已经不是单群噪音`;
if (status === 'rising') return `今日 ${todayCount} 条,高于近 7 日均值 ${previousAvg.toFixed(1)}`;
if (status === 'cooling') return `今日热度低于近 7 日均值,可能进入退潮期`;
return `今日 ${todayCount} 条讨论,保持高热度`;
}
function statusWeight(status: DashboardTopicLifecycle['status']): number {
if (status === 'spreading') return 4;
if (status === 'rising') return 3;
if (status === 'hot') return 2;
return 1;
}
function verdictForLink(kind: 'article' | 'tool', count: number, groupCount: number, snippets: string): string {
if (groupCount >= 3) return '跨群重复出现,优先查看';
if (kind === 'tool' && /实测|体验|教程|开源|github|保姆级/i.test(snippets)) return '有使用语境,值得试用';
if (kind === 'article' && /复盘|教程|深度|报告|访谈|经验/i.test(snippets)) return '具备可整理成内容的素材';
if (count >= 2) return '重复提到,适合收藏备查';
return kind === 'tool' ? '新工具线索,快速扫一眼' : '文章线索,按需阅读';
}
function personReason(role: DashboardPeopleRadar['role'], signalCount: number, groupCount: number): string {
if (role === '连接者') return `${groupCount} 个群出现,适合关注其连接的圈层`;
if (role === '需求提出者') return `提出多条可行动需求,适合跟进`;
if (role === '分享者') return `贡献 ${signalCount} 条链接/资料信号`;
return `贡献 ${signalCount} 条高信号观点`;
}
function severityWeight(severity: DashboardAnomalySignal['severity']): number {
if (severity === 'high') return 3;
if (severity === 'medium') return 2;
return 1;
}
function xmlSummary(content: string): string {
if (!content.includes('<msg>')) return '';
const title = tagText(content, 'title');
const des = tagText(content, 'des');
const url = tagText(content, 'url') || tagText(content, 'imgsourceurl');
return [title, des, url ? '链接' : ''].filter(Boolean).join(' ');
}
function tagText(content: string, tag: string): string {
const text = content.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i'))?.[1] ?? '';
return decodeHtml(text).replace(/\s+/g, ' ').trim();
}
function decodeHtml(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function scoreContent(content: string): number {
let score = 0;
if (URL_RE.test(content) || content.includes('链接')) score += 3;
if (TOOL_RE.test(content)) score += 3;
if (isOpportunity(content)) score += 4;
if (ACTION_RE.test(content)) score += 2;
if (QUESTION_RE.test(content)) score += 1;
if (content.length >= 80) score += 2;
if (content.length >= 180) score += 1;
return score;
}
function reasonsFor(content: string): string[] {
const reasons: string[] = [];
if (isOpportunity(content)) reasons.push('机会/需求');
if (TOOL_RE.test(content)) reasons.push('工具/产品');
if (URL_RE.test(content) || content.includes('链接')) reasons.push('链接信号');
if (ACTION_RE.test(content)) reasons.push('可跟进');
if (content.length >= 120) reasons.push('长观点');
if (QUESTION_RE.test(content)) reasons.push('问题');
return reasons.slice(0, 3);
}
function actionFor(content: string): string {
if (/团购|采购|报价|预算/i.test(content)) return '看采购/团购';
if (/报名|名额|活动|会议|直播/i.test(content)) return '看报名/活动';
if (/合作|对接|找人|招募|一起做/i.test(content)) return '看合作机会';
if (/求推荐|有没有|谁有|需要/i.test(content)) return '可回复推荐';
if (/帮忙|看看|评估|试试/i.test(content)) return '可协助跟进';
return '查看上下文';
}
function isOpportunity(content: string): boolean {
return OPPORTUNITY_RE.test(content.slice(0, 140)) && !DIGEST_RE.test(content);
}
function opportunityDedupeKey(content: string, title: string): string {
if (content.includes('飞书录音豆')) return normalizeDedupe('飞书录音豆团购');
if (content.includes('团购')) {
const groupBuy = content.match(/([\p{L}\p{N}A-Za-z]{2,16}(?:|)?)/u)?.[1];
if (groupBuy) return normalizeDedupe(groupBuy);
}
const phrase =
content.match(/[\p{L}\p{N}A-Za-z]{2,}.{0,18}(|||||||)/u)?.[0] ??
title;
return normalizeDedupe(phrase);
}
function titleFromContent(content: string): string {
const withoutPrefix = content
.replace(/^[@#\s:-]+/, '')
.replace(/\[[^\]]{0,8}\]/g, '')
.replace(/[*_`#>]+/g, '')
.replace(/链接/g, '')
.trim();
const first = withoutPrefix.split(/[。!?!?]\s*/).find((p) => p.trim().length >= 6);
return (first ?? withoutPrefix).trim().slice(0, 46) || '值得查看的讨论';
}
function normalizeDedupe(content: string): string {
return content.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 48);
}
function topEntry(values: Map<string, number>): string {
return Array.from(values.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
}
function strengthsFor(source: {
link_count: number;
opportunity_count: number;
tool_count: number;
}): string[] {
const out: string[] = [];
if (source.tool_count > 0) out.push('工具');
if (source.link_count > 0) out.push('链接');
if (source.opportunity_count > 0) out.push('机会');
return out.length > 0 ? out.slice(0, 3) : ['观点'];
}