Files
wechat-radar/lib/dashboard-intelligence.ts
T
2026-05-24 13:42:56 +08:00

837 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) : ['观点'];
}