mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-08 15:48:31 +09:00
Publish updated WeChat Radar
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}…`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user