Initial open source release

This commit is contained in:
joeseesun
2026-05-24 13:42:56 +08:00
commit 8abb29e13e
75 changed files with 13298 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { execFile } = require('node:child_process');
const { homedir } = require('node:os');
const { join } = require('node:path');
const { promisify } = require('node:util');
const Database = require('better-sqlite3');
const run = promisify(execFile);
const DATA_DIR = process.env.WECHAT_RADAR_DATA_DIR || join(homedir(), '.wechat-radar');
const DB_PATH = join(DATA_DIR, 'radar.db');
const SYSTEM_TYPES = new Set(['system', '系统']);
const REVOKE_RE = /撤回了一条消息|recalled a message/i;
function arg(name, fallback) {
const i = process.argv.indexOf(name);
if (i === -1) return fallback;
return process.argv[i + 1] || fallback;
}
function hasFlag(name) {
return process.argv.includes(name);
}
function ymd(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function daysBefore(n) {
const d = new Date();
d.setDate(d.getDate() - n);
return ymd(d);
}
function dateOfMessage(m) {
if (m.time && m.time.length >= 10) return m.time.slice(0, 10);
if (m.timestamp) return ymd(new Date(m.timestamp * 1000));
return 'unknown';
}
function dateList(since, until) {
const out = [];
const start = new Date(since);
const end = new Date(until);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
out.push(ymd(d));
}
return out;
}
function monthChunks(since, until) {
const chunks = [];
const start = new Date(since);
const end = new Date(until);
let cur = new Date(start.getFullYear(), start.getMonth(), 1);
while (cur <= end) {
const chunkStart = cur < start ? start : cur;
const monthEnd = new Date(cur.getFullYear(), cur.getMonth() + 1, 0);
const chunkEnd = monthEnd > end ? end : monthEnd;
chunks.push({ since: ymd(chunkStart), until: ymd(chunkEnd) });
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
}
return chunks;
}
function ensureColumn(db, table, name, definition) {
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
if (rows.some((r) => r.name === name)) return;
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`).run();
}
function ensureSchema(db) {
ensureColumn(db, 'sync_state', 'status', "TEXT NOT NULL DEFAULT 'unknown'");
ensureColumn(db, 'sync_state', 'last_error', 'TEXT');
ensureColumn(db, 'sync_state', 'failed_chunks', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(db, 'sync_state', 'empty_chunks', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(db, 'sync_state', 'total_chunks', 'INTEGER NOT NULL DEFAULT 0');
}
async function wxJson(args, opts = {}) {
const { stdout } = await run('wx', [...args, '--json'], {
maxBuffer: 256 * 1024 * 1024,
timeout: 180_000,
...opts,
});
return JSON.parse(stdout);
}
function makeInserters(db) {
const insertMessage = db.prepare(`
INSERT OR IGNORE INTO messages
(chatroom_id, local_id, sender, content, time, timestamp, type, date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertStats = db.prepare(`
INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(chatroom_id, date) DO UPDATE SET
total = excluded.total,
top_senders = excluded.top_senders,
by_hour = excluded.by_hour,
refreshed_at = excluded.refreshed_at
`);
const upsertSync = db.prepare(`
INSERT INTO sync_state (
chatroom_id,
last_synced_at,
first_message_date,
last_message_date,
total_messages,
status,
last_error,
failed_chunks,
empty_chunks,
total_chunks
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chatroom_id) DO UPDATE SET
last_synced_at = excluded.last_synced_at,
first_message_date = COALESCE(excluded.first_message_date, sync_state.first_message_date),
last_message_date = COALESCE(excluded.last_message_date, sync_state.last_message_date),
total_messages = excluded.total_messages,
status = excluded.status,
last_error = excluded.last_error,
failed_chunks = excluded.failed_chunks,
empty_chunks = excluded.empty_chunks,
total_chunks = excluded.total_chunks
`);
return { insertMessage, insertStats, upsertSync };
}
function insertMessages(db, stmt, chatroomId, messages) {
let inserted = 0;
const tx = db.transaction((rows) => {
for (const m of rows) {
if (SYSTEM_TYPES.has(m.type) && REVOKE_RE.test(m.content || '')) continue;
const r = stmt.run(
chatroomId,
m.local_id,
m.sender || '',
m.content || '',
m.time || '',
m.timestamp || 0,
m.type || '',
dateOfMessage(m),
);
if (r.changes > 0) inserted++;
}
});
tx(messages);
return inserted;
}
function aggregate(db, insertStats, chatroomId, dates) {
const rows = db
.prepare(
`SELECT date, sender, timestamp
FROM messages
WHERE chatroom_id = ? AND date >= ? AND date <= ?`,
)
.all(chatroomId, dates[0], dates[dates.length - 1]);
const byDate = new Map();
for (const d of dates) byDate.set(d, { total: 0, senders: new Map(), hours: new Array(24).fill(0) });
for (const r of rows) {
const slot = byDate.get(r.date);
if (!slot) continue;
slot.total++;
slot.senders.set(r.sender, (slot.senders.get(r.sender) || 0) + 1);
if (r.timestamp) {
const h = new Date(r.timestamp * 1000).getHours();
if (h >= 0 && h < 24) slot.hours[h]++;
}
}
const now = Date.now();
const tx = db.transaction(() => {
for (const [date, s] of byDate.entries()) {
const top = Array.from(s.senders.entries())
.map(([sender, count]) => ({ sender, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
insertStats.run(
chatroomId,
date,
s.total,
JSON.stringify(top),
JSON.stringify(s.hours.map((count, hour) => ({ hour, count }))),
now,
);
}
});
tx();
}
async function main() {
const since = arg('--since', daysBefore(Number(arg('--days', '30')) - 1));
const until = arg('--until', ymd(new Date()));
const activeDays = Number(arg('--active-days', '7'));
const only = arg('--only', '');
const includeExisting = hasFlag('--include-existing');
const nowSeconds = Math.floor(Date.now() / 1000);
const activeSince = nowSeconds - activeDays * 86400;
const db = new Database(DB_PATH);
ensureSchema(db);
const { insertMessage, insertStats, upsertSync } = makeInserters(db);
const sessions = (await wxJson(['sessions', '-n', '500'])).filter((s) => s.is_group);
const existing = new Map(
db.prepare('SELECT chatroom_id, total_messages FROM sync_state').all().map((r) => [r.chatroom_id, r.total_messages]),
);
const candidates = sessions.filter((s) => {
if (only && !s.chat.includes(only) && !s.username.includes(only)) return false;
if (!includeExisting && existing.has(s.username) && existing.get(s.username) > 0) return false;
return s.timestamp >= activeSince;
});
const chunks = monthChunks(since, until);
const dates = dateList(since, until);
console.log(`Backfilling ${candidates.length} groups from ${since} to ${until}`);
for (const [index, group] of candidates.entries()) {
let fetched = 0;
let inserted = 0;
let failedChunks = 0;
let emptyChunks = 0;
const errors = [];
for (const c of chunks) {
try {
const messages = await wxJson([
'history',
group.username,
'--since',
c.since,
'--until',
c.until,
'-n',
'50000',
]);
fetched += messages.length;
if (messages.length === 0) emptyChunks++;
inserted += insertMessages(db, insertMessage, group.username, messages);
} catch (e) {
failedChunks++;
errors.push(`${c.since}~${c.until}: ${e instanceof Error ? e.message : String(e)}`);
}
}
aggregate(db, insertStats, group.username, dates);
const row = db
.prepare('SELECT COUNT(*) AS n, MIN(date) AS first_date, MAX(date) AS last_date FROM messages WHERE chatroom_id = ?')
.get(group.username);
const status =
failedChunks === chunks.length
? 'failed'
: failedChunks > 0
? 'partial'
: row.n === 0 && fetched === 0
? 'empty'
: 'ok';
upsertSync.run(
group.username,
Date.now(),
row.first_date,
row.last_date,
row.n,
status,
errors.slice(-3).join('\n') || null,
failedChunks,
emptyChunks,
chunks.length,
);
console.log(
`${index + 1}/${candidates.length} ${group.chat} fetched=${fetched} inserted=${inserted} total=${row.n} status=${status}`,
);
}
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});
+71
View File
@@ -0,0 +1,71 @@
const Database = require('better-sqlite3');
const { existsSync, mkdirSync, writeFileSync } = require('node:fs');
const { homedir } = require('node:os');
const { dirname, join } = require('node:path');
const dataDir = process.env.WECHAT_RADAR_DATA_DIR || join(homedir(), '.wechat-radar');
const dbPath = join(dataDir, 'radar.db');
if (!existsSync(dirname(dbPath))) mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, color TEXT NOT NULL, emoji TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS daily_stats (chatroom_id TEXT NOT NULL, date TEXT NOT NULL, total INTEGER NOT NULL, top_senders TEXT NOT NULL, by_hour TEXT NOT NULL, refreshed_at INTEGER NOT NULL, PRIMARY KEY (chatroom_id, date));
CREATE TABLE IF NOT EXISTS messages (chatroom_id TEXT NOT NULL, local_id INTEGER NOT NULL, sender TEXT NOT NULL, content TEXT NOT NULL, time TEXT NOT NULL, timestamp INTEGER NOT NULL, type TEXT NOT NULL, date TEXT NOT NULL, PRIMARY KEY (chatroom_id, local_id));
CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date);
`);
const categories = [
['AI / Coding', '#7dd3a8', '💻'], ['Tools', '#f59e0b', '🛠️'], ['Articles', '#06b6d4', '📚'],
['Business', '#10b981', '💼'], ['Events', '#f97316', '📅'], ['Research', '#a855f7', '🔬'], ['Lifestyle', '#fb7185', '🏠'],
];
const now = Date.now();
const insertGroup = db.prepare('INSERT OR IGNORE INTO groups (name, color, emoji, sort_order, created_at) VALUES (?, ?, ?, ?, ?)');
categories.forEach((g, i) => insertGroup.run(g[0], g[1], g[2], i, now));
const groups = [
['demo-ai@chatroom', 'AI 产品讨论群'], ['demo-coding@chatroom', 'Vibe Coding 交流群'],
['demo-tools@chatroom', '效率工具分享群'], ['demo-business@chatroom', 'AI 商业增长群'], ['demo-life@chatroom', '生活与阅读群'],
];
const senders = ['Alex', 'Ming', 'Luna', 'Kai', 'River', 'Yuki', 'Chen'];
const contents = [
'有没有适合团队知识库的 AI 工具?最好支持飞书和 Notion,同步成本低一点。',
'实测 Codex 处理中型前端改版很稳,关键是先给它足够清楚的验收标准。',
'分享一个开源项目 https://github.com/example/agent-workflow 可以把多 Agent 编排可视化。',
'这篇文章值得读:AI Agent 落地为什么卡在组织流程 https://mp.weixin.qq.com/s/demo-agent-org',
'下周有一个 AI 工具内测名额,想找 20 个真实团队试用,感兴趣可以报名。',
'GEO 和 SEO 的差别今天讨论很多,核心不是关键词,而是结构化证据和可信来源。',
'有没有人熟悉 Chrome Extension 上架流程?需要一个 checklist。',
'@你的微信名 这个话题你可能有经验:如何把群聊素材整理成公众号选题?',
'新的语音转文字工具体验不错 https://example.com/voice-note 支持批量导出 Markdown。',
'今天最值得关注的是 AI 工具开始从个人效率走向团队工作流。',
];
function ymd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
const insertMessage = db.prepare('INSERT OR IGNORE INTO messages (chatroom_id, local_id, sender, content, time, timestamp, type, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const insertStats = db.prepare('INSERT INTO daily_stats (chatroom_id, date, total, top_senders, by_hour, refreshed_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(chatroom_id, date) DO UPDATE SET total = excluded.total, top_senders = excluded.top_senders, by_hour = excluded.by_hour, refreshed_at = excluded.refreshed_at');
db.transaction(() => {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const d = new Date(); d.setDate(d.getDate() - dayOffset);
const date = ymd(d);
for (let gi = 0; gi < groups.length; gi++) {
const [chatroomId] = groups[gi];
const count = Math.max(8, 42 - dayOffset * 2 + gi * 5);
const byHour = Array.from({ length: 24 }, (_, hour) => ({ hour, count: hour >= 9 && hour <= 23 ? Math.floor(count / 15) + ((hour + gi) % 3) : 0 }));
const topSenders = senders.slice(0, 3).map((sender, index) => ({ sender, count: Math.max(1, Math.floor(count / (index + 2))) }));
insertStats.run(chatroomId, date, count, JSON.stringify(topSenders), JSON.stringify(byHour), Date.now());
for (let i = 0; i < Math.min(count, 18); i++) {
const localId = dayOffset * 10000 + gi * 1000 + i + 1;
const hour = 9 + ((i + gi) % 12);
const minute = (i * 7) % 60;
const time = `${date} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
insertMessage.run(chatroomId, localId, senders[(i + gi) % senders.length], contents[(i + gi + dayOffset) % contents.length], time, Math.floor(new Date(time).getTime() / 1000), 'text', date);
}
}
}
})();
writeFileSync(join(dataDir, 'config.json'), JSON.stringify({ myNicknames: ['你的微信名'], defaultRange: 'week', rescanConcurrency: 5, privacyConfirmed: true, setupCompleted: true, demoMode: true, defaultSyncDays: 7 }, null, 2));
console.log(`Seeded demo data at ${dbPath}`);