Files
wechat-radar/scripts/backfill_empty_groups.cjs
2026-05-24 13:42:56 +08:00

287 lines
8.8 KiB
JavaScript

/* 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;
});