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
+161 -2
View File
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { wxSessions } from '@/lib/wx';
import { listGroups, listAllTags, tagGroup } from '@/lib/groups';
import { classifyGroupHeuristic } from '@/lib/group-classifier';
export const dynamic = 'force-dynamic';
@@ -16,6 +15,166 @@ interface Suggestion {
reason: string;
}
function classifyHeuristic(name: string, summary: string, groups: ReturnType<typeof listGroups>) {
const lookup = (target: string) => groups.find((g) => g.name.includes(target));
// 顺序很重要 — 高优先规则先匹配
// 1. 蝗虫团(最强信号)
if (/蝗虫团|huangchong/i.test(name)) {
const t = lookup('蝗虫');
if (t) return { group_id: t.id, group_name: t.name, reason: '蝗虫团系列' };
}
// 2. 自营/读者群
if (
/自营|用户群|粉丝群|公众号读者|读者群/.test(name) ||
/自营|用户群|粉丝群|公众号读者|读者群/.test(summary)
) {
const t = lookup('自营/读者群');
if (t) return { group_id: t.id, group_name: t.name, reason: '自营 / 读者群' };
}
// 3. WaytoAGI
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 系列' };
}
// 4. HowOneAI
if (/howoneai|howone/i.test(name)) {
const t = lookup('HowOneAI');
if (t) return { group_id: t.id, group_name: t.name, reason: 'HowOneAI 系列' };
}
// 5. Vibe Coding / 编程
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(
name,
)
) {
const t = lookup('Vibe Coding');
if (t) return { group_id: t.id, group_name: t.name, reason: '编程 / Skills / CLI' };
}
// 6. AI 学术 / 论文 / 未来硅世界
if (
/学术|论文|paper|未来硅世界|研究室|nixy|simonlin|博文视点|《|knowledge|灵枭/i.test(
name,
)
) {
const t = lookup('AI 学术');
if (t) return { group_id: t.id, group_name: t.name, reason: '学术 / 论文' };
}
// 7. AI 商业 / 营销
if (
/seo|geo|商业化|营销|kol|gaidn|adg|vip|生财|appsail|tutti|商业|broker|出版|2026 共读群|阅读\d|收付款|社交新品|社群/i.test(
name,
)
) {
const t = lookup('AI 商业');
if (t) return { group_id: t.id, group_name: t.name, reason: '商业 / 营销 / KOL' };
}
// 8. AIGC / 内容创作(视频、音乐、图、媒体、AIGC)
if (
/aigc|图|视频|音乐|spy|拍我ai|ai媒体|ai音视频|创意|graceful|创作|graphic|listenhub|notetomp|youmind|完全ai生成|羊毛|社区|molthuman|aiwriter|短视频|歌|video|music|illustrat|ai春晚|nettalk|dolphin/i.test(
name,
)
) {
const t = lookup('AIGC');
if (t) return { group_id: t.id, group_name: t.name, reason: 'AIGC / 内容创作' };
}
// 9. 付费社区
if (
/vip|烟花|修饼|传术师|生财有术|沃垠|兔子ai|ai领导力|hicool|早鸟|内测|种子用户|订阅用户|车友|豪车|私董|学员|拍我ai|hosi|hosi.ai|api 渠道|大白|一人公司|小鱼名人|pec/i.test(
name,
)
) {
const t = lookup('付费社区');
if (t) return { group_id: t.id, group_name: t.name, reason: '付费 / 内测 / VIP' };
}
// 10. AI 工具用户群(产品周边)
if (
/用户群|用户中文|内测群|jackywine|mindcode|remio|listenhub|notetomp|cherry|camel|refly|cola|geoflow|hosi|aigocode|appsail|tutti|爱贝壳|内容同步|api/i.test(
name,
)
) {
const t = lookup('AI 工具用户群');
if (t) return { group_id: t.id, group_name: t.name, reason: '工具用户群' };
}
// 11. AI 圈社交(散群、神的孩子、明人明言、agi bar、先行者、智能体成精了)
if (
/神的孩子|明人明言|先行者|agi bar|智能体成精|life hacker|超级玩家|love.*death.*agent|未来趋势|agent橘|新物种|创造营|不息为体|未知书社|新互联网/i.test(
name,
)
) {
const t = lookup('AI 圈社交');
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI 圈社交' };
}
// 12. 大佬 / 媒体圈(自媒体、大佬、对接群、媒体)
if (
/donews|何夕|辛亥|对接群|百度世界|央馆|火山方舟|43talks|tgo|商务|《ai营销/i.test(name)
) {
const t = lookup('大佬');
if (t) return { group_id: t.id, group_name: t.name, reason: '大佬 / 媒体圈' };
}
// 13. 行业活动(一次性活动群)
if (
/活动现场|聚餐|筹备组|聚会|开播|直播|线下|大会|分享会|一年五班|落户/i.test(name)
) {
const t = lookup('行业活动');
if (t) return { group_id: t.id, group_name: t.name, reason: '一次性活动群' };
}
// 14. 生活 / 兴趣(钓友、邻居、果粉、班级)
if (
/钓友|路亚|果粉|大家庭|班级|班·班|喜相逢|邻里|苑|🏘|楼|班|小区|羽毛球|健身|跑步|徒步|阅读|共读|英语|班级群|6年级|六年级|恩小|对接|车友|校友|曲|歌友|羽毛|篮球/i.test(
name,
)
) {
const t = lookup('生活');
if (t) return { group_id: t.id, group_name: t.name, reason: '生活 / 兴趣' };
}
// 15. 粉丝团 / 读者群 → AI 圈社交(除非已经被自营/读者群匹配)
if (/粉丝|fans|读者/i.test(name)) {
const t = lookup('AI 圈社交');
if (t) return { group_id: t.id, group_name: t.name, reason: '粉丝团 / 读者群' };
}
// 16. 财经 / 投资类
if (/财经|股票|投资|基金|币圈|crypto|trade/i.test(name)) {
const t = lookup('AI 商业');
if (t) return { group_id: t.id, group_name: t.name, reason: '财经 / 投资' };
}
// 17. X / 推特相关
if (/x boost|twitter|推特|x kol/i.test(name)) {
const t = lookup('AIGC');
if (t) return { group_id: t.id, group_name: t.name, reason: 'X / 推特运营' };
}
// 18. 课程 / 训练营
if (/课群|训练营|实训|训练|内训|课程|早鸟|2026共创|日历/i.test(name)) {
const t = lookup('付费社区');
if (t) return { group_id: t.id, group_name: t.name, reason: '课程 / 训练营' };
}
// 兜底:含 AI / Agent / Coding 关键词 → AI 圈社交
if (/ai|agent|gpt|claude|llm|coding|开源/i.test(name)) {
const t = lookup('AI 圈社交');
if (t) return { group_id: t.id, group_name: t.name, reason: '通用 AI(兜底)' };
}
return null;
}
const ApplySchema = z.object({
picks: z.array(
z.object({
@@ -41,7 +200,7 @@ export async function GET() {
.filter((g) => !tagged.has(g.username))
.slice(0, 200)
.map((g) => {
const guess = classifyGroupHeuristic(g.chat, g.summary, groups);
const guess = classifyHeuristic(g.chat, g.summary, groups);
return {
chatroom_id: g.username,
name: g.chat,
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const rows = db()
.prepare(
`SELECT date, COUNT(*) AS count
FROM messages
GROUP BY date
ORDER BY date DESC
LIMIT 90`,
)
.all() as Array<{ date: string; count: number }>;
return NextResponse.json({ ok: true, dates: rows });
}
+2 -2
View File
@@ -1,13 +1,13 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { homedir } from 'node:os';
import { DATA_DIR } from '@/lib/config';
import { join } from 'node:path';
import { existsSync, statSync } from 'node:fs';
export const dynamic = 'force-dynamic';
export async function GET() {
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
const dataDir = DATA_DIR;
const dbPath = join(dataDir, 'radar.db');
const dbSize = existsSync(dbPath) ? statSync(dbPath).size : 0;
const counts = {
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { backfillMessageLinks } from '@/lib/message-links';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => ({}))) as {
since?: string;
until?: string;
};
if (body.since && !DATE_RE.test(body.since)) {
return NextResponse.json({ ok: false, error: 'invalid since' }, { status: 400 });
}
if (body.until && !DATE_RE.test(body.until)) {
return NextResponse.json({ ok: false, error: 'invalid until' }, { status: 400 });
}
const result = backfillMessageLinks(body.since, body.until);
return NextResponse.json({ ok: true, ...result });
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { wxSessions } from '@/lib/wx';
import { todayStr } from '@/lib/range';
export const dynamic = 'force-dynamic';
type RawLinkRow = {
chatroom_id: string;
local_id: number;
sender: string;
time: string;
url: string;
canonical_url: string;
title: string | null;
domain: string;
source: string;
raw_kind: string;
};
export async function GET(req: NextRequest) {
const date = new URL(req.url).searchParams.get('date') ?? todayStr();
const names = await groupNames();
const rows = db()
.prepare(
`SELECT chatroom_id, local_id, sender, time, url, canonical_url, title, domain, source, raw_kind
FROM message_links
WHERE date = ?
AND canonical_url LIKE '%://mp.weixin.qq.com/%'
ORDER BY timestamp DESC
LIMIT 200`,
)
.all(date) as RawLinkRow[];
return NextResponse.json({
ok: true,
date,
links: rows.map((row) => ({
...row,
chat_name: names.get(row.chatroom_id) ?? row.chatroom_id,
})),
});
}
async function groupNames() {
const names = new Map<string, string>();
try {
const sessions = await wxSessions(500);
for (const s of sessions) names.set(s.username, s.chat);
} catch {}
return names;
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { upsertResolvedLinkForMessage } from '@/lib/message-links';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => ({}))) as {
chatroom_id?: string;
local_id?: number;
url?: string;
title?: string;
description?: string;
source?: 'public_search' | 'manual';
confidence?: number;
};
if (!body.chatroom_id || !Number.isInteger(body.local_id) || !body.url) {
return NextResponse.json(
{ ok: false, error: 'chatroom_id, local_id and url are required' },
{ status: 400 },
);
}
const localId = body.local_id;
if (localId === undefined) {
return NextResponse.json({ ok: false, error: 'local_id is required' }, { status: 400 });
}
const source = body.source ?? 'manual';
if (source !== 'manual' && source !== 'public_search') {
return NextResponse.json({ ok: false, error: 'invalid source' }, { status: 400 });
}
const result = upsertResolvedLinkForMessage({
chatroom_id: body.chatroom_id,
local_id: localId,
url: body.url,
title: body.title,
description: body.description,
source,
confidence: body.confidence,
});
if (!result.ok) {
return NextResponse.json(result, { status: 400 });
}
return NextResponse.json({ ok: true });
}
+2 -2
View File
@@ -1,13 +1,13 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { DATA_DIR } from '@/lib/config';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const dynamic = 'force-dynamic';
export async function POST() {
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
const dataDir = DATA_DIR;
const dest = join(dataDir, 'radar-recovered.db');
try {
db().pragma('wal_checkpoint(TRUNCATE)');
+33
View File
@@ -4,6 +4,7 @@ import { syncFullHistory } from '@/lib/stats-aggregator';
import { normalizeDate, normalizeRangeKey, rangeToWindow, type RangeKey } from '@/lib/range';
import { readConfig } from '@/lib/config';
import { cache, CK } from '@/lib/cache';
import { buildTopicsForDate } from '@/lib/topics';
export const dynamic = 'force-dynamic';
export const maxDuration = 1800; // 30 min
@@ -64,6 +65,14 @@ export async function POST(req: NextRequest) {
concurrency,
onProgress: (p) => send(p),
});
const topicDates = datesBetween(since, until).slice(-autoTopicDays(body.full));
send({ type: 'topics_start', dates: topicDates.length });
for (const date of topicDates) {
send({ type: 'topics_date', date, message: '开始构建话题…' });
await buildTopicsForDate(date, (p) => send({ ...p, type: `topics_${p.type}`, date }));
}
cache.del(CK.sessions());
send({
type: 'finished',
@@ -87,3 +96,27 @@ export async function POST(req: NextRequest) {
},
});
}
function datesBetween(since: string, until: string): string[] {
const out: string[] = [];
const start = parseLocalDate(since);
const end = parseLocalDate(until);
for (const d = start; d.getTime() <= end.getTime(); d.setDate(d.getDate() + 1)) {
out.push(formatLocalDate(d));
}
return out;
}
function parseLocalDate(date: string): Date {
const [y, m, d] = date.split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
function formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function autoTopicDays(full?: boolean): number {
const configured = Number(process.env.WECHAT_RADAR_AUTO_TOPIC_DAYS ?? (full ? 14 : 31));
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 31;
}
+177
View File
@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { wxSessions } from '@/lib/wx';
export const dynamic = 'force-dynamic';
type SearchResult = {
id: string;
type: 'group' | 'topic' | 'person' | 'message' | 'link';
title: string;
subtitle: string;
href: string;
external?: boolean;
};
type MessageRow = {
chatroom_id: string;
sender: string;
content: string;
date: string;
time: string;
};
type TopicRow = {
id: number;
date: string;
title: string;
summary: string | null;
message_count: number;
group_count: number;
};
type LinkRow = {
canonical_url: string;
title: string | null;
domain: string;
date: string;
};
type PersonRow = {
sender: string;
hits: number;
groups: number;
latest: string;
};
export async function GET(req: NextRequest) {
const q = (new URL(req.url).searchParams.get('q') ?? '').trim();
if (q.length < 2) return NextResponse.json({ ok: true, results: [] });
const like = `%${q}%`;
const nameMap = await loadGroupNames();
const results: SearchResult[] = [];
for (const [chatroomId, name] of nameMap) {
if (!name.toLowerCase().includes(q.toLowerCase())) continue;
results.push({
id: `group:${chatroomId}`,
type: 'group',
title: name,
subtitle: chatroomId,
href: `/groups/${encodeURIComponent(chatroomId)}`,
});
if (results.length >= 8) break;
}
const topics = db()
.prepare(
`SELECT id, date, title, summary, message_count, group_count
FROM topics
WHERE title LIKE ? OR COALESCE(summary, '') LIKE ?
ORDER BY date DESC, message_count DESC
LIMIT 8`,
)
.all(like, like) as TopicRow[];
for (const t of topics) {
results.push({
id: `topic:${t.id}`,
type: 'topic',
title: t.title,
subtitle: `${t.date} · ${t.message_count} 条 · ${t.group_count}${t.summary ? ` · ${t.summary}` : ''}`,
href: `/topics?date=${t.date}`,
});
}
const people = db()
.prepare(
`SELECT sender, COUNT(*) AS hits, COUNT(DISTINCT chatroom_id) AS groups, MAX(date) AS latest
FROM messages
WHERE sender LIKE ?
GROUP BY sender
ORDER BY hits DESC
LIMIT 8`,
)
.all(like) as PersonRow[];
for (const p of people) {
results.push({
id: `person:${p.sender}`,
type: 'person',
title: p.sender,
subtitle: `${p.hits} 条消息 · ${p.groups} 个群 · 最近 ${p.latest}`,
href: `/signals?q=${encodeURIComponent(p.sender)}`,
});
}
const messages = db()
.prepare(
`SELECT chatroom_id, sender, content, date, time
FROM messages
WHERE content LIKE ? OR sender LIKE ?
ORDER BY timestamp DESC
LIMIT 10`,
)
.all(like, like) as MessageRow[];
for (const m of messages) {
results.push({
id: `message:${m.chatroom_id}:${m.time}:${m.sender}`,
type: 'message',
title: compact(m.content || m.sender, 80),
subtitle: `${nameMap.get(m.chatroom_id) ?? m.chatroom_id} · ${m.sender} · ${m.time}`,
href: `/groups/${encodeURIComponent(m.chatroom_id)}?date=${m.date}`,
});
}
const links = db()
.prepare(
`SELECT canonical_url, title, domain, MAX(date) AS date
FROM message_links
WHERE canonical_url LIKE ? OR COALESCE(title, '') LIKE ? OR domain LIKE ?
GROUP BY canonical_url
ORDER BY MAX(timestamp) DESC
LIMIT 8`,
)
.all(like, like, like) as LinkRow[];
for (const l of links) {
results.push({
id: `link:${l.canonical_url}`,
type: 'link',
title: l.title || l.canonical_url,
subtitle: `${l.domain} · ${l.date}`,
href: l.canonical_url,
external: true,
});
}
return NextResponse.json({ ok: true, results: results.slice(0, 32) });
}
async function loadGroupNames(): Promise<Map<string, string>> {
const names = new Map<string, string>();
try {
const sessions = await wxSessions(500);
for (const s of sessions) {
if (s.is_group) names.set(s.username, s.chat);
}
} catch {}
const local = db()
.prepare(
`SELECT chatroom_id, COUNT(*) AS n
FROM messages
GROUP BY chatroom_id
ORDER BY n DESC
LIMIT 500`,
)
.all() as Array<{ chatroom_id: string }>;
for (const row of local) {
if (!names.has(row.chatroom_id)) names.set(row.chatroom_id, row.chatroom_id);
}
return names;
}
function compact(s: string, max: number): string {
const text = s.replace(/\s+/g, ' ').trim();
if (text.length <= max) return text;
return `${text.slice(0, max - 1)}`;
}
-1
View File
@@ -70,7 +70,6 @@ export async function GET() {
}
}
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
+1 -1
View File
@@ -30,7 +30,7 @@ export async function GET(req: NextRequest) {
return new Response('image not found in wx cache', { status: 404 });
}
const buf = await readFile(/*turbopackIgnore: true*/ found.path);
const buf = await readFile(found.path);
return new Response(new Uint8Array(buf), {
headers: {
'Content-Type': mimeFor(found.format),
+1 -1
View File
@@ -72,7 +72,7 @@ export default function ClassifyPage() {
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div className="flex items-center gap-3">
<Link href="/" className="text-[var(--text-3)] hover:text-[var(--text)]">
<ArrowLeft size={16} />
+100 -23
View File
@@ -1,16 +1,41 @@
@import "tailwindcss";
:root {
--bg: #0a0d0b;
--bg-2: #10110e;
--surface: #141713;
--surface-2: #1a1e19;
--surface-3: #24281f;
--border: #33382f;
--border-soft: rgba(184, 176, 145, 0.16);
--text: #edf1e8;
--text-2: #b0b3a8;
--text-3: #7d8177;
--bg: #f1efea;
--bg-2: #e8e5de;
--surface: #faf9f6;
--surface-2: #f1eee8;
--surface-3: #e5dfd6;
--border: #d8d2c8;
--border-soft: rgba(46, 42, 34, 0.12);
--text: #171814;
--text-2: #5e6459;
--text-3: #8b8b80;
--accent: #28745b;
--accent-2: #3a9d71;
--accent-soft: rgba(40, 116, 91, 0.12);
--warn: #9c6728;
--warn-soft: rgba(156, 103, 40, 0.12);
--danger: #b34242;
--danger-soft: rgba(179, 66, 66, 0.12);
--shadow: 0 18px 55px rgba(55, 47, 34, 0.09);
--chrome-bg: rgba(250, 249, 246, 0.86);
--sidebar-bg: rgba(248, 246, 239, 0.92);
--control-bg: rgba(250, 249, 246, 0.78);
--input-scheme: light;
}
[data-theme='dark'] {
--bg: #080b0a;
--bg-2: #0d1110;
--surface: #111511;
--surface-2: #171c17;
--surface-3: #20261f;
--border: #293029;
--border-soft: rgba(175, 190, 167, 0.13);
--text: #eef2eb;
--text-2: #b6bdb1;
--text-3: #80887d;
--accent: #7dd3a8;
--accent-2: #46b978;
--accent-soft: rgba(125, 211, 168, 0.13);
@@ -18,7 +43,11 @@
--warn-soft: rgba(213, 162, 83, 0.14);
--danger: #df6b6b;
--danger-soft: rgba(223, 107, 107, 0.14);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
--shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
--chrome-bg: rgba(8, 11, 10, 0.84);
--sidebar-bg: rgba(8, 11, 10, 0.9);
--control-bg: rgba(17, 22, 18, 0.86);
--input-scheme: dark;
}
@theme inline {
@@ -53,11 +82,38 @@ body {
body {
background:
linear-gradient(120deg, rgba(125, 211, 168, 0.055), transparent 34%),
linear-gradient(180deg, rgba(213, 162, 83, 0.075), transparent 30%),
linear-gradient(120deg, rgba(40, 116, 91, 0.08), transparent 34%),
linear-gradient(180deg, rgba(156, 103, 40, 0.08), transparent 30%),
var(--bg);
}
button,
input,
select,
textarea {
color: var(--text);
}
input,
select,
textarea {
color-scheme: var(--input-scheme);
}
input::placeholder,
textarea::placeholder {
color: var(--text-3);
}
:where(a, button, input, select, textarea):focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 58%, transparent);
outline-offset: 2px;
}
[data-theme='dark'] body {
background: var(--bg);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
@@ -66,22 +122,27 @@ body {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #26362d;
background: color-mix(in srgb, var(--text-3) 42%, transparent);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #355044;
background: color-mix(in srgb, var(--accent) 55%, transparent);
}
.card {
background:
linear-gradient(180deg, rgba(237, 241, 232, 0.035), transparent 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0.35), transparent 48%),
var(--surface);
border: 1px solid var(--border-soft);
border-radius: 8px;
box-shadow: var(--shadow);
}
[data-theme='dark'] .card {
background: var(--surface);
box-shadow: var(--shadow);
}
.btn {
display: inline-flex;
align-items: center;
@@ -89,8 +150,8 @@ body {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: rgba(16, 24, 18, 0.86);
color: var(--text-2);
background: color-mix(in srgb, var(--control-bg) 86%, var(--surface));
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s;
@@ -116,19 +177,26 @@ body {
}
.btn-primary {
background: linear-gradient(180deg, var(--accent), var(--accent-2));
color: #07120c;
color: #fdfbf7;
border-color: var(--accent);
font-weight: 600;
}
.btn-primary:hover {
background: linear-gradient(180deg, #98e2bb, var(--accent-2));
color: #07120c;
background: linear-gradient(180deg, var(--accent-2), var(--accent));
color: #fdfbf7;
}
[data-theme='dark'] .btn-primary,
[data-theme='dark'] .btn-primary:hover {
background: var(--accent);
color: #06100b;
}
.control-surface {
border: 1px solid var(--border-soft);
background: rgba(16, 24, 18, 0.82);
box-shadow: 0 1px 0 rgba(237, 241, 232, 0.03) inset;
background: color-mix(in srgb, var(--control-bg) 88%, var(--surface));
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.28) inset;
color: var(--text);
}
.report-kicker {
@@ -144,3 +212,12 @@ body {
background: var(--accent-soft);
color: var(--accent);
}
.theme-date-input {
color-scheme: var(--input-scheme);
}
.report-title {
font-family: "Noto Serif SC", "Songti SC", Georgia, serif;
letter-spacing: 0;
}
+51 -34
View File
@@ -6,7 +6,18 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import MessageContent from '@/components/MessageContent';
import { ArrowLeft, BarChart3, Calendar, History, ListFilter, MessageSquare, Star, Trophy } from 'lucide-react';
import {
ArrowLeft,
BarChart3,
Calendar,
Check,
Copy,
History,
ListFilter,
MessageSquare,
Star,
Trophy,
} from 'lucide-react';
import type { EChartsOption } from 'echarts';
const ReactECharts = dynamicImport(() => import('echarts-for-react'), { ssr: false });
@@ -60,6 +71,7 @@ export default function GroupDetailPage({
const [fav, setFav] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const load = async (d: string) => {
setLoading(true);
@@ -112,6 +124,24 @@ export default function GroupDetailPage({
});
};
const copyMessages = async () => {
const messages = data?.recent ?? [];
if (messages.length === 0) return;
const title = data?.stats?.chat ?? chatroomId;
const text = [
`# ${title} ${date}`,
'',
...messages.map((m) => {
const time = m.time.slice(11, 16);
const content = m.content.replace(/\s+/g, ' ').trim();
return `[${time}] ${m.sender}: ${content}`;
}),
].join('\n');
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const hourOption: EChartsOption | null = data?.stats
? {
grid: { top: 20, right: 16, bottom: 28, left: 36 },
@@ -177,20 +207,11 @@ export default function GroupDetailPage({
}
: null;
const dateOptions = useMemo(() => {
if (!data?.daily_history) return [];
return data.daily_history
.filter((d) => d.total > 0 || d.date === date)
.map((d) => d.date)
.sort()
.reverse();
}, [data, date]);
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div className="flex items-center gap-3 min-w-0">
<Link href="/" className="shrink-0 text-[var(--text-3)] hover:text-[var(--text)]">
<ArrowLeft size={16} />
@@ -208,27 +229,12 @@ export default function GroupDetailPage({
<div className="flex items-center gap-2">
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
<Calendar size={13} className="text-[var(--text-3)]" />
{dateOptions.length > 0 ? (
<select
value={date}
onChange={(e) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none"
>
{!dateOptions.includes(date) && <option value={date}>{date}</option>}
{dateOptions.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
) : (
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
/>
)}
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="theme-date-input bg-transparent text-[12px] outline-none"
/>
</div>
<button className={`btn ${fav ? 'btn-warn' : ''}`} onClick={toggleFav}>
<Star size={13} />
@@ -326,8 +332,19 @@ export default function GroupDetailPage({
<MessageSquare size={14} className="text-[var(--accent)]" />
{date}
</div>
<div className="text-[11px] text-[var(--text-3)]">
{loading ? '加载中…' : `${data?.recent.length ?? 0}`}
<div className="flex items-center gap-2">
<button
className="btn py-1 text-[12px]"
onClick={copyMessages}
disabled={loading || (data?.recent.length ?? 0) === 0}
title="复制当日完整消息"
>
{copied ? <Check size={13} /> : <Copy size={13} />}
<span>{copied ? '已复制' : '复制'}</span>
</button>
<div className="text-[11px] text-[var(--text-3)]">
{loading ? '加载中…' : `${data?.recent.length ?? 0}`}
</div>
</div>
</div>
{!loading && data?.recent && data.recent.length === 0 ? (
+2 -2
View File
@@ -99,7 +99,7 @@ function GroupsListContent() {
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div>
<div className="report-kicker">Group Directory</div>
<div className="text-[15px] font-semibold">{title}</div>
@@ -178,7 +178,7 @@ function GroupsListFallback() {
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div>
<div className="report-kicker">Group Directory</div>
<div className="text-[15px] font-semibold"></div>
+6 -4
View File
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "微信雷达",
description: "本地优先的微信群聊情报看板",
title: "WeChat Radar",
description: "Local-first WeChat group intelligence dashboard",
};
export default function RootLayout({
@@ -12,8 +12,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<body className="min-h-full">{children}</body>
<html lang="zh-CN" className="h-full" suppressHydrationWarning>
<body className="min-h-full">
{children}
</body>
</html>
);
}
+98 -4
View File
@@ -22,6 +22,7 @@ type LinkInsight = {
time: string;
local_id: number;
snippet: string;
source: string;
}>;
};
@@ -32,6 +33,20 @@ type LinkInsightResp = {
tools: LinkInsight[];
};
type RawWechatLink = {
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
time: string;
url: string;
canonical_url: string;
title: string | null;
domain: string;
source: string;
raw_kind: string;
};
function localToday(): string {
const d = new Date();
const y = d.getFullYear();
@@ -43,6 +58,7 @@ function localToday(): string {
export default function LinksPage() {
const [date, setDate] = useState(() => localToday());
const [links, setLinks] = useState<LinkInsightResp | null>(null);
const [rawLinks, setRawLinks] = useState<RawWechatLink[]>([]);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
@@ -53,6 +69,11 @@ export default function LinksPage() {
const j = (await r.json()) as LinkInsightResp;
if (!cancelled && j.ok) setLinks(j);
} catch {}
try {
const r = await fetch(`/api/message-links/raw?date=${date}`, { cache: 'no-store' });
const j = (await r.json()) as { ok: boolean; links?: RawWechatLink[] };
if (!cancelled && j.ok) setRawLinks(j.links ?? []);
} catch {}
})();
return () => {
cancelled = true;
@@ -76,7 +97,7 @@ export default function LinksPage() {
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div>
<div className="report-kicker">Link Intelligence</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
@@ -96,7 +117,7 @@ export default function LinksPage() {
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
className="theme-date-input bg-transparent text-[12px] outline-none"
/>
</div>
<button
@@ -112,7 +133,7 @@ export default function LinksPage() {
</div>
</div>
<div className="grid flex-1 grid-cols-1 gap-5 overflow-hidden p-5 xl:grid-cols-2">
<div className="grid flex-1 grid-cols-1 gap-5 overflow-hidden p-5 xl:grid-cols-3">
<LinkInsightPanel
title="文章链接"
icon={<Newspaper size={14} className="text-[var(--accent)]" />}
@@ -129,12 +150,68 @@ export default function LinksPage() {
loading={loading}
empty="当天还没有工具链接"
/>
<RawWechatPanel date={date} items={loading ? [] : rawLinks} loading={loading} />
</div>
</main>
</div>
);
}
function RawWechatPanel({
date,
items,
loading,
}: {
date: string;
items: RawWechatLink[];
loading: boolean;
}) {
return (
<section className="card flex min-h-0 min-w-0 flex-col">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] px-3 py-2">
<div className="flex items-center gap-2 text-[12px] font-semibold">
<ExternalLink size={14} className="text-[var(--accent)]" />
<span></span>
</div>
<div className="text-[10px] text-[var(--text-3)]">{loading ? '加载中' : `${items.length}`}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<div className="py-16 text-center text-[11px] text-[var(--text-3)]"></div>
) : items.length === 0 ? (
<div className="py-16 text-center text-[11px] text-[var(--text-3)]"></div>
) : (
<div className="space-y-1.5">
{items.map((item) => (
<div key={`${item.chatroom_id}:${item.local_id}:${item.canonical_url}`} className="rounded-md border border-transparent px-2 py-2 hover:border-[var(--border-soft)] hover:bg-[var(--surface-2)]">
<a href={item.url} target="_blank" rel="noreferrer" className="group block min-w-0" title={item.url}>
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)] group-hover:text-[var(--accent)]">
{item.title || item.domain || item.raw_kind}
</div>
<div className="mt-1 break-all text-[10px] leading-snug text-[var(--text-3)]">
{item.url}
</div>
</a>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-[var(--text-3)]">
<Link
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
className="min-w-0 truncate text-[var(--text-2)] hover:text-[var(--accent)]"
>
{item.chat_name} · {item.sender}
</Link>
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[var(--accent)]">
{item.raw_kind}
</span>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}
function LinkInsightPanel({
title,
icon,
@@ -191,7 +268,10 @@ function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
<span className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)] group-hover:text-[var(--accent)]">
{item.title}
</span>
<span className="mt-1 block truncate text-[10px] text-[var(--text-3)]">{item.domain}</span>
<span className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--text-3)]">
<span className={sourceClass(first?.source)}>{sourceLabel(first?.source)}</span>
<span className="truncate">{item.domain}</span>
</span>
</span>
<ExternalLink size={12} className="mt-0.5 shrink-0 text-[var(--text-3)] group-hover:text-[var(--accent)]" />
</a>
@@ -214,3 +294,17 @@ function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
</div>
);
}
function sourceLabel(source?: string): string {
if (source === 'wechat_raw') return '微信原文';
if (source === 'public_search') return '公开补链';
if (source === 'manual') return '手动补链';
return '网页链接';
}
function sourceClass(source?: string): string {
const base = 'shrink-0 rounded px-1.5 py-0.5';
if (source === 'wechat_raw') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
if (source === 'public_search' || source === 'manual') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
}
+13 -7
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react';
import Sidebar from '@/components/Sidebar';
import TopBar, { type RangeKey, type RefreshMode } from '@/components/TopBar';
import TopBar, { type RangeKey } from '@/components/TopBar';
import StatGrid, { type CardsData } from '@/components/StatGrid';
import TrendChart, { type TrendPoint } from '@/components/TrendChart';
import ActiveGroupsList, { type ActiveGroup } from '@/components/ActiveGroupsList';
@@ -24,13 +24,11 @@ type StatsResponse = {
export default function Page() {
const [range, setRange] = useState<RangeKey>('month');
const [date, setDate] = useState(() => localToday());
const [mode, setMode] = useState<RefreshMode>('auto');
const [stats, setStats] = useState<StatsResponse | null>(null);
const [rescanning, setRescanning] = useState(false);
const [rescanInfo, setRescanInfo] = useState<string | undefined>(undefined);
const [setupChecked, setSetupChecked] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -113,6 +111,12 @@ export default function Page() {
setRescanInfo(
`完成 · ${evt.messages ?? evt.inserted_messages ?? 0} 条消息已入库`,
);
} else if (evt.type === 'topics_start') {
setRescanInfo(`消息已入库 · 开始构建 ${evt.dates} 天话题`);
} else if (evt.type === 'topics_date') {
setRescanInfo(`构建话题 · ${evt.date}`);
} else if (typeof evt.type === 'string' && evt.type.startsWith('topics_')) {
setRescanInfo(`构建话题 · ${evt.date}${evt.message ? ` · ${evt.message}` : ''}`);
}
} catch {}
}
@@ -128,7 +132,11 @@ export default function Page() {
);
if (!setupChecked) {
return <div className="flex h-screen items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--text-3)]"></div>;
return (
<div className="flex h-screen items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--text-3)]">
</div>
);
}
return (
@@ -141,8 +149,6 @@ export default function Page() {
date={date}
onRangeChange={setRange}
onDateChange={setDate}
mode={mode}
onModeChange={setMode}
rescanning={rescanning}
onRescan={() => runRescan(false)}
onFullSync={() => runRescan(true)}
@@ -166,7 +172,7 @@ export default function Page() {
</div>
<div className="mt-4 grid grid-cols-1 gap-4 2xl:grid-cols-[1.4fr_1fr]">
<ActiveGroupsList groups={stats?.active_groups ?? []} />
<ActiveGroupsList groups={stats?.active_groups ?? []} date={stats?.window.until ?? date} />
<CategoryChart categories={stats?.categories ?? []} />
</div>
</div>
+602
View File
@@ -0,0 +1,602 @@
'use client';
import { use, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import { ArrowLeft, Check, Copy, ExternalLink, FileText, Link2, MessageSquare, Trophy } from 'lucide-react';
type Detail = {
ok: boolean;
chatroom_id: string;
date: string;
stats: {
chat: string;
total: number;
top_senders: Array<{ sender: string; count: number }>;
} | null;
recent: Array<{
local_id: number;
sender: string;
content: string;
time: string;
type: string;
}>;
};
export const dynamic = 'force-dynamic';
export default function GroupDailyReportPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const chatroomId = decodeURIComponent(id);
const date = useSearchParams().get('date') ?? localToday();
const [data, setData] = useState<Detail | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const r = await fetch(`/api/group/${encodeURIComponent(chatroomId)}?date=${date}&limit=500`, {
cache: 'no-store',
});
const j = (await r.json()) as Detail;
if (!cancelled) setData(j);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [chatroomId, date]);
const reportText = useMemo(() => buildReportText(data, chatroomId, date), [data, chatroomId, date]);
const copyReport = useCallback(async () => {
await navigator.clipboard.writeText(reportText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}, [reportText]);
const title = data?.stats?.chat ?? chatroomId;
const report = useMemo(() => analyzeReport(data?.recent ?? []), [data?.recent]);
return (
<div className="flex h-screen bg-[var(--bg)]">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<header className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div className="flex min-w-0 items-center gap-3">
<Link href="/" className="shrink-0 text-[var(--text-3)] hover:text-[var(--text)]">
<ArrowLeft size={16} />
</Link>
<div className="min-w-0">
<div className="report-kicker">Group Daily Report</div>
<h1 className="truncate text-[16px] font-semibold">{title}</h1>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{date} · {loading ? '加载中…' : `${data?.stats?.total ?? 0} 条消息`} ·
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button className="btn" onClick={copyReport} disabled={!data?.ok}>
{copied ? <Check size={13} /> : <Copy size={13} />}
<span>{copied ? '已复制' : '复制日报'}</span>
</button>
<Link
className="btn"
href={`/groups/${encodeURIComponent(chatroomId)}?date=${date}`}
title="查看完整群消息"
>
<ExternalLink size={13} />
<span></span>
</Link>
</div>
</header>
<div className="flex-1 overflow-y-auto px-6 py-5">
<section className="card p-5">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<FileText size={15} className="text-[var(--accent)]" />
</div>
<p className="mt-3 text-[13px] leading-6 text-[var(--text-2)]">
{loading
? '正在生成日报视图…'
: `今天这个群共产生 ${data?.stats?.total ?? 0} 条消息,按摘要策略提炼为核心主题、证据引用、资源链接和可跟进项。`}
</p>
</section>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[1.25fr_0.75fr]">
<section className="card p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<MessageSquare size={15} className="text-[var(--accent)]" />
</div>
<div className="text-[11px] text-[var(--text-3)]">{report.topics.length} </div>
</div>
<div className="mt-3 space-y-3">
{report.topics.length === 0 ? (
<div className="py-10 text-center text-[12px] text-[var(--text-3)]"></div>
) : (
report.topics.map((topic, i) => (
<article key={topic.title} className="rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] px-3 py-3">
<div className="flex items-start justify-between gap-3">
<h2 className="report-title text-[16px] font-semibold text-[var(--text)]">
{i + 1}. {topic.title}
</h2>
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] text-[var(--accent)]">
{topic.count}
</span>
</div>
<div className="mt-2 text-[12px] leading-5 text-[var(--text-2)]">
<span className="font-medium text-[var(--text)]"></span>
{topic.what}
</div>
<div className="mt-1 text-[12px] leading-5 text-[var(--text-2)]">
<span className="font-medium text-[var(--text)]"></span>
{topic.why}
</div>
{topic.quote && (
<blockquote className="mt-2 border-l-2 border-[var(--accent)] pl-3 text-[11px] leading-5 text-[var(--text-3)]">
{topic.quote.sender}{topic.quote.text}
</blockquote>
)}
</article>
))
)}
</div>
</section>
<div className="space-y-4">
<section className="card p-5">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<Link2 size={15} className="text-[var(--accent)]" />
//
</div>
<div className="mt-3 space-y-2">
{report.links.length === 0 ? (
<div className="py-6 text-center text-[12px] text-[var(--text-3)]"></div>
) : (
report.links.slice(0, 10).map((link) => (
<a key={link.key} href={link.url} target="_blank" rel="noreferrer" className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
<div className="line-clamp-2 text-[12px] font-medium text-[var(--text)]">{link.title}</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--text-3)]">
<span className="truncate">{link.url}</span>
{(link.count > 1 || link.urlCount > 1) && (
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[var(--accent)]">
{link.count} / {link.urlCount}
</span>
)}
</div>
</a>
))
)}
</div>
</section>
<section className="card p-5">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<FileText size={15} className="text-[var(--warn)]" />
</div>
<div className="mt-3 space-y-2">
{report.followups.length === 0 ? (
<div className="py-6 text-center text-[12px] text-[var(--text-3)]"></div>
) : (
report.followups.slice(0, 8).map((m) => (
<div key={m.local_id} className="rounded-md px-2 py-2 text-[12px] leading-5 text-[var(--text-2)] hover:bg-[var(--surface-2)]">
<span className="font-medium text-[var(--text)]">{m.sender}</span>
{cleanMessage(m.content)}
</div>
))
)}
</div>
</section>
<section className="card p-5">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<Trophy size={15} className="text-[var(--warn)]" />
Top
</div>
<div className="mt-3 space-y-2">
{(data?.stats?.top_senders ?? []).slice(0, 8).map((sender, i) => (
<div key={`${sender.sender}:${i}`} className="flex items-center justify-between gap-3 text-[13px]">
<span className="truncate text-[var(--text-2)]">{i + 1}. {sender.sender}</span>
<span className="tabular-nums text-[var(--text)]">{sender.count}</span>
</div>
))}
</div>
</section>
</div>
</div>
</div>
</main>
</div>
);
}
type ReportMessage = Detail['recent'][number];
type TopicReport = {
title: string;
count: number;
what: string;
why: string;
quote?: { sender: string; text: string };
};
type TopicRule = {
title: string;
re: RegExp;
why: string;
priority: number;
};
const TOPIC_RULES: TopicRule[] = [
{
title: 'AI 编程与 Agent 工作流',
re: /codex|claude code|agent|mcp|skills?|cli|vibe.?coding|编程|代码/i,
why: '这类讨论通常直接影响个人和团队的生产方式,适合沉淀成工作流、工具清单或实践经验。',
priority: 5,
},
{
title: '新工具、新模型与产品体验',
re: /工具|产品|模型|内测|API|插件|开源|github|huggingface|modelscope/i,
why: '群里对工具和模型的实测反馈,比单纯发布新闻更接近真实采用价值,适合优先试用和收藏。',
priority: 4,
},
{
title: '活动、报名与社群机会',
re: /报名|活动|直播|大会|训练营|课程|线下|名额|招募/i,
why: '这些信息有时间窗口,错过就失效,适合当天判断是否参与、转发或对接。',
priority: 6,
},
{
title: '内容创作、AIGC 与传播',
re: /内容|小红书|公众号|视频|图像|封面|传播|选题|日报|文章/i,
why: '这类讨论可以转化为公开内容、选题储备或素材库,对后续写作和传播有复用价值。',
priority: 3,
},
{
title: '需求、问题与可回复线索',
re: /求推荐|有没有|谁有|怎么|如何|能不能|可不可以|问题|方案/i,
why: '明确问题背后往往有真实需求,适合用你的经验、资源或产品线索去回应。',
priority: 7,
},
];
function analyzeReport(messages: ReportMessage[]) {
const clean = messages
.map((m) => ({ ...m, content: cleanMessage(m.content) }))
.map((m) => ({ ...m, content: meaningfulMessageText(m.content) }))
.filter((m) => m.content.length >= 6 && !isLowValueMessage(m.content));
const topics = clusterTopics(clean);
const links = extractLinks(clean).slice(0, 24);
const followups = clean
.filter((m) => /求推荐|有没有|谁有|怎么|如何|能不能|可不可以|请教|帮忙/i.test(m.content))
.slice(0, 12);
return {
topics: topics.sort((a, b) => b.count - a.count).slice(0, 5),
links,
followups,
};
}
function clusterTopics(messages: ReportMessage[]): TopicReport[] {
const assigned = new Set<number>();
const clusters = new Map<string, { rule: TopicRule; messages: ReportMessage[] }>();
for (const m of messages) {
if (assigned.has(m.local_id)) continue;
const rule = bestTopicRule(m.content);
if (!rule) continue;
const key = discussionKey(m.content, rule.title);
const existing = clusters.get(key);
if (existing) {
existing.messages.push(m);
} else {
clusters.set(key, { rule, messages: [m] });
}
assigned.add(m.local_id);
}
return Array.from(clusters.values())
.map(({ rule, messages: hits }) => {
const unique = dedupeMessages(hits);
const representative = unique
.slice()
.sort((a, b) => scoreMessage(b.content) - scoreMessage(a.content))[0];
return {
title: specificTopicTitle(representative?.content ?? rule.title, rule.title),
count: unique.length,
what: summarizeTopic(unique),
why: rule.why,
quote: representative
? { sender: representative.sender, text: stripUrls(representative.content).slice(0, 120) }
: undefined,
};
})
.filter((topic) => topic.count >= 2 || scoreMessage(topic.what) >= 8)
.sort((a, b) => b.count - a.count || b.what.length - a.what.length)
.slice(0, 5);
}
function bestTopicRule(content: string): TopicRule | null {
const matches = TOPIC_RULES.filter((rule) => rule.re.test(content));
if (matches.length === 0) return null;
return matches.sort((a, b) => b.priority - a.priority)[0];
}
function dedupeMessages(messages: ReportMessage[]): ReportMessage[] {
const seen = new Set<string>();
const out: ReportMessage[] = [];
for (const m of messages) {
const key = messageFingerprint(m.content);
if (seen.has(key)) continue;
seen.add(key);
out.push(m);
}
return out;
}
function discussionKey(content: string, fallback: string): string {
const url = firstUrl(content);
if (url) return `url:${normalizeResourceUrl(url)}`;
const title = bracketTitle(content);
if (title) return `title:${textFingerprint(title)}`;
return `text:${textFingerprint(content).slice(0, 48) || fallback}`;
}
function messageFingerprint(content: string): string {
const url = firstUrl(content);
if (url) return `url:${normalizeResourceUrl(url)}`;
return textFingerprint(stripUrls(content)).slice(0, 80);
}
function textFingerprint(text: string): string {
return text
.toLowerCase()
.replace(/https?:\/\/\S+/g, ' ')
.replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, '')
.slice(0, 120);
}
function firstUrl(content: string): string | null {
const match = content.match(/https?:\/\/[^\s<>"']+/);
return match ? match[0].replace(/[),,。;;!?!?、\]}>]+$/g, '') : null;
}
function bracketTitle(content: string): string | null {
const match = content.match(/\[(?:链接|链接\/文件)\]\s*([^\n。;;]{4,80})/);
const title = match?.[1]?.trim();
if (!title || isMeaninglessTitle(title)) return null;
return title;
}
function specificTopicTitle(content: string, fallback: string): string {
const title = bracketTitle(content);
if (title) return title.replace(/^【|】$/g, '').slice(0, 28);
const cleaned = meaningfulMessageText(content)
.replace(/^\[引用\]\s*/g, '')
.replace(/^\[(?:链接|链接\/文件)\]\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
const product = cleaned.match(/(Claude Code|GPT-Image-2|ChatGPT|Codex|MCP|InkOS|Munger Models|awesome-mac|Sloth-Poster-Den|Bloome|Stitch)/i)?.[0];
if (product) return `${product} 相关讨论`.slice(0, 28);
return (cleaned.split(/[。!?!?;]/)[0] || fallback).slice(0, 28);
}
function summarizeTopic(messages: ReportMessage[]): string {
const top = messages
.slice()
.sort((a, b) => scoreMessage(b.content) - scoreMessage(a.content))
.slice(0, 3)
.map((m) => meaningfulMessageText(m.content))
.filter((text) => text.length >= 6 && !isMeaninglessTitle(text));
if (top.length === 0) return '当天有零散讨论,但缺少足够完整的上下文。';
return top.join('').slice(0, 220);
}
function scoreMessage(content: string): number {
let score = Math.min(content.length / 18, 8);
if (/https?:\/\/|<url>|github|工具|模型|报名|求推荐/i.test(content)) score += 4;
if (/我觉得|实测|经验|方案|问题|原因|为什么/i.test(content)) score += 2;
return score;
}
function extractLinks(messages: ReportMessage[]) {
const groups = new Map<string, { title: string; urls: Set<string>; count: number; score: number }>();
for (const m of messages) {
for (const match of m.content.matchAll(/https?:\/\/[^\s<>"']+/g)) {
const url = match[0].replace(/[),,。;;!?!?、\]}>]+$/g, '');
if (isNoiseUrl(url)) continue;
const title = titleFromMessage(m.content, url);
if (isMeaninglessTitle(title)) continue;
const key = linkGroupKey(title, url);
const current = groups.get(key);
const nextScore = scoreMessage(m.content);
if (current) {
current.urls.add(url);
current.count++;
if (nextScore > current.score) {
current.title = title;
current.score = nextScore;
}
} else {
groups.set(key, { title, urls: new Set([url]), count: 1, score: nextScore });
}
}
}
const rows = Array.from(groups.entries())
.map(([key, item]) => ({
key,
title: item.title,
url: Array.from(item.urls)[0],
count: item.count,
urlCount: item.urls.size,
}));
const byUrl = new Map<string, (typeof rows)[number]>();
for (const row of rows) {
const urlKey = normalizeResourceUrl(row.url);
const current = byUrl.get(urlKey);
if (!current) {
byUrl.set(urlKey, row);
continue;
}
current.key = `${current.key}|${row.key}`;
current.count += row.count;
current.urlCount = Math.max(current.urlCount, row.urlCount);
if (row.title.length > current.title.length) current.title = row.title;
}
return Array.from(byUrl.values())
.sort((a, b) => b.count - a.count || b.urlCount - a.urlCount);
}
function titleFromMessage(content: string, url: string): string {
const text = meaningfulMessageText(content)
.replace(/^\[(?:链接|链接\/文件)\]\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
return (text || domainFromUrl(url)).slice(0, 96);
}
function linkGroupKey(title: string, url: string): string {
const titleKey = textFingerprint(title).slice(0, 48);
if (titleKey.length >= 12) return `title:${titleKey}`;
return `url:${normalizeResourceUrl(url)}`;
}
function normalizeResourceUrl(url: string): string {
try {
const u = new URL(url);
u.hash = '';
for (const key of Array.from(u.searchParams.keys())) {
if (/^(utm_|spm|from|share_|vd_source|scene|clicktime|enterid)/i.test(key)) {
u.searchParams.delete(key);
}
}
return `${u.hostname}${u.pathname}${u.searchParams.toString() ? `?${u.searchParams.toString()}` : ''}`;
} catch {
return url;
}
}
function domainFromUrl(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
function stripUrls(content: string): string {
return content.replace(/https?:\/\/\S+/g, '').trim();
}
const MEANINGLESS_TITLE_RE =
/^(?:\[?链接\]?\s*)?(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持|请升级至最新版本)[。.]?$/i;
const NOISE_URL_HOSTS = new Set([
'support.weixin.qq.com',
'wx.qlogo.cn',
'dldir1v6.qq.com',
'finder.video.qq.com',
]);
function meaningfulMessageText(content: string): string {
const withoutUrls = stripUrls(content)
.replace(/&amp;/g, '&')
.replace(/\[(?:链接|链接\/文件)\]\s*(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持展示该内容,请升级至最新版本)[。.]?/gi, ' ')
.replace(/当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?/gi, ' ')
.replace(/\[图片\]\s*local_id=\d+/gi, ' ')
.replace(/\b[a-f0-9]{24,}\b/gi, ' ')
.replace(/\b\d{12,}\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const parts = withoutUrls
.split(/↳|\\n|\n/)
.map((part) =>
part
.replace(/^\[引用\]\s*/g, '')
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
.replace(/\s+/g, ' ')
.trim(),
)
.filter((part) => part.length >= 6 && !isMeaninglessTitle(part));
return (parts[0] ?? withoutUrls).trim();
}
function isMeaninglessTitle(title: string): boolean {
const text = stripUrls(title)
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return true;
if (MEANINGLESS_TITLE_RE.test(text)) return true;
return /^(?:xml_url_text|网页链接|视频号|finder|wx\.qlogo\.cn)$/i.test(text);
}
function isLowValueMessage(content: string): boolean {
const text = meaningfulMessageText(content);
if (isMeaninglessTitle(text)) return true;
return /^[\d\s._-]+$/.test(text) || text.length < 6;
}
function isNoiseUrl(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return NOISE_URL_HOSTS.has(host);
} catch {
return false;
}
}
function buildReportText(data: Detail | null, chatroomId: string, date: string): string {
const title = data?.stats?.chat ?? chatroomId;
const messages = data?.recent ?? [];
const report = analyzeReport(messages);
return [
`# ${title} ${date} 群日报`,
'',
`今天共 ${data?.stats?.total ?? 0} 条消息。`,
'',
'## 核心主题',
...report.topics.flatMap((topic, i) => [
'',
`### ${i + 1}. ${topic.title}`,
`聊了什么:${topic.what}`,
`为什么重要:${topic.why}`,
topic.quote ? `引用/证据:${topic.quote.sender}:「${topic.quote.text}` : '',
]),
'',
'## 工具/文章/链接',
...report.links.map((link) => `- ${link.title}: ${link.url}`),
'',
'## 未解决/可跟进',
...report.followups.map((m) => `- ${m.sender}: ${cleanMessage(m.content)}`),
].filter(Boolean).join('\n');
}
function cleanMessage(content: string): string {
return content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function localToday(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
+1 -1
View File
@@ -76,7 +76,7 @@ export default function SignalsPage() {
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div>
<div className="report-kicker">Live Signals</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
+11 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import Sidebar from '@/components/Sidebar';
import MessageContent from '@/components/MessageContent';
@@ -42,6 +42,7 @@ export default function TopicsPage() {
const [detail, setDetail] = useState<{ topic: Topic; messages: TopicMessage[] } | null>(null);
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string | undefined>(undefined);
const autoBuildDates = useRef(new Set<string>());
const reload = useCallback(async () => {
try {
@@ -148,11 +149,17 @@ export default function TopicsPage() {
}
}, [date, reload]);
useEffect(() => {
if (busy || topics.length > 0 || autoBuildDates.current.has(date)) return;
autoBuildDates.current.add(date);
build();
}, [build, busy, date, topics.length]);
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div>
<div className="report-kicker">Cross-Group Topics</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
@@ -170,7 +177,7 @@ export default function TopicsPage() {
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
className="theme-date-input bg-transparent text-[12px] outline-none"
/>
</div>
<button className={`btn ${busy ? 'btn-warn' : 'btn-primary'}`} onClick={build} disabled={busy}>
@@ -184,7 +191,7 @@ export default function TopicsPage() {
<div className="overflow-y-auto border-r border-[var(--border-soft)] p-4">
{topics.length === 0 ? (
<div className="py-16 text-center text-[12px] text-[var(--text-3)]">
·
{busy ? '正在自动构建当日话题…' : '当日暂无可聚合话题'}
</div>
) : (
<div className="space-y-2">