tt-help-cli-ycl 1.4.27 → 1.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,90 +1,57 @@
1
1
  /**
2
- * raw_jobs 同步程序
2
+ * raw_jobs 同步与任务补充程序
3
3
  *
4
- * 定时检测主库中各目标国家的 raw_jobs 数量,不足时从归档库搬移补充。
5
- * 搬移逻辑复用 refillJobsFromRaw 非 LLM 模式的排序规则。
4
+ * 职责:
5
+ * 1. 入库 — main.raw_jobs → archive.raw_jobs
6
+ * 2. 补任务 — archive.raw_jobs → main.jobs(每国 pending<50 时触发)
7
+ *
8
+ * 模式:LLM 多轮打分凑够 50 条,不可用时回退常规(tag > comment > video_count)
6
9
  *
7
10
  * 用法:
8
- * node scripts/sync-raw-jobs.mjs --db <主库> --archive <归档库>
9
- * node scripts/sync-raw-jobs.mjs --db data/result.db --archive data/result-archive.db
10
- * node scripts/sync-raw-jobs.mjs --db data/result.db --archive data/result-archive.db --once
11
- * node scripts/sync-raw-jobs.mjs --db data/result.db --archive data/result-archive.db --interval 60 --threshold 2000 --batch 300
11
+ * node scripts/sync-raw-jobs.mjs --db <主库> --archive <归档库> [--llm] [--once] [--interval 120]
12
12
  */
13
13
 
14
14
  import Database from "better-sqlite3";
15
+ import { initDb, getDb } from "../src/watch/db-schema.js";
16
+ import { loadLlmConfig, reloadLlmConfig } from "../src/watch/llm-config.js";
17
+ import { scoreJobsBatch } from "../src/watch/llm-scoring.js";
18
+ import { DEFAULT_TARGET_LOCATIONS } from "../src/lib/target-locations.js";
15
19
 
20
+ // ═══════════════════════════════════════════════════════════════════
21
+ // 常量
22
+ // ═══════════════════════════════════════════════════════════════════
16
23
 
17
- // ===== 命令行参数 =====
18
- function parseArgs() {
19
- const args = process.argv.slice(2);
20
- const opts = {
21
- db: null,
22
- archive: null,
23
- interval: 30,
24
- max: 10000,
25
- batch: 500,
26
- once: false,
27
- };
28
- for (let i = 0; i < args.length; i++) {
29
- switch (args[i]) {
30
- case "--db":
31
- opts.db = args[++i];
32
- break;
33
- case "--archive":
34
- opts.archive = args[++i];
35
- break;
36
- case "--interval":
37
- opts.interval = parseInt(args[++i]) || 30;
38
- break;
39
- case "--max":
40
- opts.max = parseInt(args[++i]) || 10000;
41
- break;
42
- case "--batch":
43
- opts.batch = parseInt(args[++i]) || 500;
44
- break;
45
- case "--once":
46
- opts.once = true;
47
- break;
48
- }
49
- }
50
- return opts;
51
- }
24
+ const LOCATIONS = DEFAULT_TARGET_LOCATIONS;
52
25
 
53
- const opts = parseArgs();
26
+ const FILTER = "video_count>6 AND follower_count>0 AND following_count>0";
54
27
 
55
- if (!opts.db || !opts.archive) {
56
- console.error(
57
- "用法: node scripts/sync-raw-jobs.mjs --db <主库路径> --archive <归档库路径> [选项]",
58
- );
59
- console.error("");
60
- console.error("选项:");
61
- console.error(" --db <path> 主库路径(必填)");
62
- console.error(" --archive <path> 归档库路径(必填)");
63
- console.error(" --interval <秒> 检测间隔,默认 30");
64
- console.error(" --max <条> 主库 raw_jobs 保有量上限,默认 10000");
65
- console.error(" --batch <条> 每次搬移上限,默认 500");
66
- console.error(" --once 只执行一次后退出");
67
- process.exit(1);
68
- }
69
-
70
- // ===== 过滤与排序 =====
71
- // 注意: 索引依赖的 WHERE 用原始列名(不用 COALESCE),与 idx_raw_archive_sync 匹配
72
- const FILTER_INDEXED = [
73
- "video_count > 6",
74
- "follower_count > 0",
75
- "following_count > 0",
28
+ const REFILL_COLS = [
29
+ "unique_id",
30
+ "nickname",
31
+ "sources",
32
+ "pinned",
33
+ "tt_seller",
34
+ "verified",
35
+ "video_count",
36
+ "comment_count",
37
+ "guessed_location",
38
+ "location_created",
39
+ "confirmed_location",
40
+ "follower_count",
41
+ "following_count",
42
+ "heart_count",
43
+ "created_at",
44
+ "updated_at",
45
+ "region",
46
+ "signature",
47
+ "bio_link",
48
+ "sec_uid",
49
+ "status_code",
50
+ "latest_video_time",
51
+ "user_create_time",
76
52
  ];
77
53
 
78
- const ORDER_CLAUSE = `
79
- ORDER BY
80
- CASE WHEN sources LIKE '%tag%' THEN 0 ELSE 1 END,
81
- CASE WHEN sources LIKE '%comment%' THEN 0 ELSE 1 END,
82
- video_count DESC,
83
- created_at DESC
84
- `;
85
-
86
- // raw_jobs 表完整列(与 db-schema.js 对齐)
87
- const RAW_JOB_COLUMNS = [
54
+ const ARCHIVE_COLS = [
88
55
  "unique_id",
89
56
  "nickname",
90
57
  "status",
@@ -121,134 +88,436 @@ const RAW_JOB_COLUMNS = [
121
88
  "user_create_time",
122
89
  ];
123
90
 
124
- // ===== 核心:从归档库搬移一批到主库(全局排序,不区分国家)=====
125
- function moveBatch(mainDb, archiveDb, limit) {
126
- const whereSql = FILTER_INDEXED.join(" AND ");
127
- const cols = RAW_JOB_COLUMNS.join(", ");
128
-
129
- const insertSql = `
130
- INSERT OR IGNORE INTO raw_jobs (${cols})
131
- SELECT ${cols}
132
- FROM (SELECT * FROM raw_jobs WHERE ${whereSql} ${ORDER_CLAUSE} LIMIT ?)
133
- `;
134
-
135
- const deleteSql = `
136
- DELETE FROM raw_jobs
137
- WHERE unique_id IN (
138
- SELECT unique_id FROM raw_jobs
139
- WHERE ${whereSql} ${ORDER_CLAUSE} LIMIT ?
91
+ const LLM_MIN = 60;
92
+ const LLM_FALLBACK_KW = [
93
+ "全部模型失败",
94
+ "全部模型不可用",
95
+ "解析失败",
96
+ "调用异常",
97
+ "使用默认分",
98
+ ];
99
+
100
+ const PER_COUNTRY = 50;
101
+
102
+ function parseArgs() {
103
+ const a = process.argv.slice(2);
104
+ const o = { db: null, archive: null, interval: 120, once: false, llm: false };
105
+ for (let i = 0; i < a.length; i++) {
106
+ const v = a[i + 1];
107
+ switch (a[i]) {
108
+ case "--db":
109
+ o.db = v;
110
+ i++;
111
+ break;
112
+ case "--archive":
113
+ o.archive = v;
114
+ i++;
115
+ break;
116
+ case "--interval":
117
+ o.interval = parseInt(v) || 120;
118
+ i++;
119
+ break;
120
+ case "--llm":
121
+ o.llm = true;
122
+ break;
123
+ case "--once":
124
+ o.once = true;
125
+ break;
126
+ }
127
+ }
128
+ return o;
129
+ }
130
+
131
+ // ═══════════════════════════════════════════════════════════════════
132
+ // 工具
133
+ // ═══════════════════════════════════════════════════════════════════
134
+
135
+ const log = (tag, msg) =>
136
+ console.error(
137
+ `[sync ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${tag}] ${msg}`,
138
+ );
139
+ /** 按 tag > comment > other 分拣 unique_id,返回去重数组 */
140
+ function pickBySource(rows, limit) {
141
+ const ids = [
142
+ ...rows.filter((r) => r.sources?.includes('"tag"')).map((r) => r.unique_id),
143
+ ...rows
144
+ .filter(
145
+ (r) =>
146
+ r.sources?.includes('"comment"') && !r.sources?.includes('"tag"'),
147
+ )
148
+ .map((r) => r.unique_id),
149
+ ...rows
150
+ .filter(
151
+ (r) =>
152
+ !r.sources?.includes('"tag"') && !r.sources?.includes('"comment"'),
153
+ )
154
+ .map((r) => r.unique_id),
155
+ ];
156
+ ids.length = Math.min(ids.length, limit);
157
+ return ids;
158
+ }
159
+
160
+ const vals = (row, cols) => cols.map((c) => row[c] ?? null);
161
+ const ph = (n) => Array(n).fill("?").join(", ");
162
+
163
+ /** 构建 WHERE 子句(含国家过滤)。strict=true 时仅匹配明确国家,不含 NULL/空 */
164
+ function buildWhere(locations = LOCATIONS, strict = false) {
165
+ const locs = [
166
+ ...new Set(
167
+ locations.map((l) => String(l).trim().toUpperCase()).filter(Boolean),
168
+ ),
169
+ ];
170
+ const nullClause = strict
171
+ ? ""
172
+ : " OR guessed_location IS NULL OR guessed_location=''";
173
+ const sql =
174
+ locs.length === 1
175
+ ? ` AND (guessed_location = ?${nullClause})`
176
+ : locs.length > 1
177
+ ? ` AND (guessed_location IN (${ph(locs.length)})${nullClause})`
178
+ : "";
179
+ return { sql: FILTER + sql, args: locs.length ? locs : [] };
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════════════════════════
183
+ // 共享:写入 jobs 并清理归档库
184
+ // ═══════════════════════════════════════════════════════════════════
185
+
186
+ function moveToJobs(archiveDb, uniqueIds, targetLocation = null) {
187
+ if (!uniqueIds.length) return 0;
188
+ const cols = REFILL_COLS.join(", ");
189
+
190
+ const rows = archiveDb
191
+ .prepare(
192
+ `SELECT ${cols} FROM raw_jobs WHERE unique_id IN (${ph(uniqueIds.length)})`,
140
193
  )
141
- `;
194
+ .all(...uniqueIds);
195
+ if (!rows.length) return 0;
142
196
 
143
- const moved = archiveDb.transaction(() => {
144
- mainDb.prepare(insertSql).run(limit);
145
- const del = archiveDb.prepare(deleteSql).run(limit);
146
- return del.changes;
197
+ const inserted = [];
198
+ archiveDb.transaction(() => {
199
+ const insert = getDb().prepare(
200
+ `INSERT OR IGNORE INTO task_queue (${cols}, status) VALUES (${ph(REFILL_COLS.length)}, 'pending')`,
201
+ );
202
+ for (const row of rows) {
203
+ const v = vals(row, REFILL_COLS);
204
+ if (targetLocation && !v[REFILL_COLS.indexOf("guessed_location")]) {
205
+ v[REFILL_COLS.indexOf("guessed_location")] = targetLocation;
206
+ }
207
+ if (insert.run(...v).changes) inserted.push(row.unique_id);
208
+ }
209
+ if (inserted.length) {
210
+ archiveDb
211
+ .prepare(
212
+ `DELETE FROM raw_jobs WHERE unique_id IN (${ph(inserted.length)})`,
213
+ )
214
+ .run(...inserted);
215
+ }
147
216
  })();
148
-
149
- return moved;
217
+ return inserted.length;
150
218
  }
151
219
 
152
- // ===== 单次同步 =====
153
- function syncOnce(mainDb, archiveDb, maxTotal, batch) {
154
- const start = Date.now();
220
+ // ═══════════════════════════════════════════════════════════════════
221
+ // 入库(分批,防止大表 OOM)
222
+ // ═══════════════════════════════════════════════════════════════════
223
+
224
+ function archiveNewUsers(archiveDb) {
225
+ const mainDb = getDb();
226
+ const BATCH = 1000;
227
+ const cols = ARCHIVE_COLS.join(", ");
228
+ const insert = archiveDb.prepare(
229
+ `INSERT OR IGNORE INTO raw_jobs (${cols}) VALUES (${ph(ARCHIVE_COLS.length)})`,
230
+ );
231
+ let moved = 0;
155
232
 
156
- // 主库 raw_jobs 总数够了就不搬
157
- const currentTotal =
158
- mainDb.prepare("SELECT COUNT(*) as c FROM raw_jobs").get()?.c || 0;
233
+ while (true) {
234
+ const rows = mainDb
235
+ .prepare(`SELECT ${cols} FROM raw_jobs LIMIT ?`)
236
+ .all(BATCH);
237
+ if (!rows.length) break;
159
238
 
160
- if (currentTotal >= maxTotal) {
161
- return { moved: 0, total: currentTotal };
239
+ const ids = rows.map((r) => r.unique_id);
240
+
241
+ archiveDb.transaction(() => {
242
+ for (const r of rows) {
243
+ if (insert.run(...vals(r, ARCHIVE_COLS)).changes) moved++;
244
+ }
245
+ })();
246
+
247
+ mainDb
248
+ .prepare(`DELETE FROM raw_jobs WHERE unique_id IN (${ph(ids.length)})`)
249
+ .run(...ids);
162
250
  }
163
251
 
164
- const need = maxTotal - currentTotal;
165
- const limit = Math.min(need, batch);
252
+ if (moved) log(" ", `归档 +${moved}`);
253
+ return moved;
254
+ }
166
255
 
167
- const moved = moveBatch(mainDb, archiveDb, limit);
168
- const elapsed = ((Date.now() - start) / 1000).toFixed(1);
256
+ // ═══════════════════════════════════════════════════════════════════
257
+ // 检测(按国家)
258
+ // ═══════════════════════════════════════════════════════════════════
169
259
 
170
- console.error(
171
- `[sync] ${currentTotal} → ${currentTotal + moved} (搬入 ${moved} 条),耗时 ${elapsed}s`,
172
- );
260
+ function countPendingByCountry() {
261
+ const rows = getDb()
262
+ .prepare(
263
+ `SELECT COALESCE(guessed_location,'') as loc, COUNT(*) as c
264
+ FROM task_queue WHERE status='pending'
265
+ GROUP BY loc`,
266
+ )
267
+ .all();
268
+ const map = {};
269
+ for (const r of rows) map[r.loc] = r.c;
270
+ // 确保所有目标国家都有值
271
+ for (const loc of LOCATIONS) {
272
+ if (!(loc in map)) map[loc] = 0;
273
+ }
274
+ return map;
275
+ }
173
276
 
174
- return { moved, total: currentTotal + moved };
277
+ function findNeedyCountries(byCountry, threshold) {
278
+ return LOCATIONS.filter((loc) => (byCountry[loc] || 0) < threshold);
175
279
  }
176
280
 
177
- // ===== 主流程 =====
178
- console.error("[sync] raw_jobs 同步程序启动");
179
- console.error(`[sync] 主库: ${opts.db}`);
180
- console.error(`[sync] 归档库: ${opts.archive}`);
181
- console.error(`[sync] 主库上限: ${opts.max} 条,每次搬 ${opts.batch} 条`);
182
- console.error(`[sync] 检测间隔: ${opts.once ? "仅一次" : opts.interval + "s"}`);
183
- console.error("");
184
-
185
- // 打开数据库
186
- const mainDb = new Database(opts.db);
187
- mainDb.pragma("journal_mode = WAL");
188
- const archiveDb = new Database(opts.archive);
189
- archiveDb.pragma("journal_mode = WAL");
190
-
191
- // 检查主库 raw_jobs 是否存在
192
- const mainTables = mainDb
193
- .prepare(
194
- "SELECT name FROM sqlite_master WHERE type='table' AND name='raw_jobs'",
195
- )
196
- .get();
197
- if (!mainTables) {
198
- console.error("[sync] 错误: 主库中不存在 raw_jobs 表");
199
- process.exit(1);
281
+ // ═══════════════════════════════════════════════════════════════════
282
+ // 常规 refill(索引直查 + JS 分拣,避免 sources LIKE 大表扫描)
283
+ // ═══════════════════════════════════════════════════════════════════
284
+
285
+ function refillRegular(archiveDb, limit, locations) {
286
+ const { sql: w, args: wa } = buildWhere(locations);
287
+
288
+ // 取 limit×3 条,JS 分拣 tag > comment > other
289
+ const rows = archiveDb
290
+ .prepare(
291
+ `SELECT unique_id, sources FROM raw_jobs WHERE ${w} ORDER BY video_count DESC LIMIT ?`,
292
+ )
293
+ .all(...wa, limit * 3);
294
+ const ids = pickBySource(rows, limit);
295
+
296
+ if (!ids.length) return { moved: 0 };
297
+ const moved = moveToJobs(archiveDb, ids);
298
+ return { moved };
200
299
  }
201
300
 
202
- // 检查归档库 raw_jobs 是否存在
203
- const archiveTables = archiveDb
204
- .prepare(
205
- "SELECT name FROM sqlite_master WHERE type='table' AND name='raw_jobs'",
206
- )
207
- .get();
208
- if (!archiveTables) {
209
- console.error("[sync] 错误: 归档库中不存在 raw_jobs 表");
210
- process.exit(1);
301
+ // ═══════════════════════════════════════════════════════════════════
302
+ // LLM refill — 逐国:按 scored_count 升序取 PER_COUNTRY 条,LLM 打分,通过进 jobs,不通过 +1
303
+ async function refillLLM(archiveDb, needyLocations) {
304
+ let totalMoved = 0;
305
+
306
+ log("--", `LLM开始 ${needyLocations.length}国: ${needyLocations.join(" ")}`);
307
+
308
+ for (const loc of needyLocations) {
309
+ const { sql: w, args: wa } = buildWhere([loc], true);
310
+ const { sql: wf, args: wfa } = buildWhere([loc]);
311
+
312
+ // 1. 查询候选:取 100 条给 LLM(多于目标 50,增加通过率减少回退)
313
+ const LLM_BATCH = 100;
314
+ const tq = Date.now();
315
+ const samples = archiveDb
316
+ .prepare(
317
+ `SELECT * FROM raw_jobs WHERE ${w} AND (llm_scored_count IS NULL OR llm_scored_count<3) ORDER BY COALESCE(llm_scored_count,0) ASC, video_count DESC LIMIT ?`,
318
+ )
319
+ .all(...wa, LLM_BATCH);
320
+ log(" ", `${loc} 候选${samples.length}条 ${Date.now() - tq}ms`);
321
+
322
+ if (!samples.length) {
323
+ log(" ", `${loc} 回退常规取 ${PER_COUNTRY}条...`);
324
+ const rows = archiveDb
325
+ .prepare(
326
+ `SELECT unique_id, sources FROM raw_jobs WHERE ${wf} ORDER BY video_count DESC LIMIT ?`,
327
+ )
328
+ .all(...wfa, PER_COUNTRY * 3);
329
+ const ids = pickBySource(rows, PER_COUNTRY);
330
+ const moved = moveToJobs(archiveDb, ids, loc);
331
+ totalMoved += moved;
332
+ log(" ", `${loc} → ${moved}条 回退`);
333
+ continue;
334
+ }
335
+
336
+ // 2. LLM 打分
337
+ const ts = Date.now();
338
+ const scores = await scoreJobsBatch(samples, LOCATIONS);
339
+ log(" ", `${loc} 打分${scores.length}条 ${Date.now() - ts}ms`);
340
+
341
+ if (
342
+ scores.every(
343
+ (s) =>
344
+ s.score === 50 && LLM_FALLBACK_KW.some((kw) => s.reason.includes(kw)),
345
+ )
346
+ ) {
347
+ log(" ", `${loc} LLM全部不可用,回退...`);
348
+ const rows = archiveDb
349
+ .prepare(
350
+ `SELECT unique_id, sources FROM raw_jobs WHERE ${wf} ORDER BY video_count DESC LIMIT ?`,
351
+ )
352
+ .all(...wfa, PER_COUNTRY * 3);
353
+ const ids = pickBySource(rows, PER_COUNTRY);
354
+ const moved = moveToJobs(archiveDb, ids, loc);
355
+ totalMoved += moved;
356
+ log(" ", `${loc} → ${moved}条 回退`);
357
+ continue;
358
+ }
359
+
360
+ // 3. 通过 → jobs+归档删;不通过 → scored_count+1
361
+ const passIds = [],
362
+ failIds = [];
363
+ for (const s of scores) {
364
+ if (s.score >= LLM_MIN) passIds.push(s.uniqueId);
365
+ else failIds.push(s.uniqueId);
366
+ }
367
+ log(" ", `${loc} 通过${passIds.length} 淘汰${failIds.length}`);
368
+
369
+ if (failIds.length) {
370
+ archiveDb
371
+ .prepare(
372
+ `UPDATE raw_jobs SET llm_scored_count=COALESCE(llm_scored_count,0)+1 WHERE unique_id IN (${ph(failIds.length)})`,
373
+ )
374
+ .run(...failIds);
375
+ }
376
+
377
+ // 4. 写入 jobs
378
+ let moved = 0;
379
+ if (passIds.length) {
380
+ moved = moveToJobs(archiveDb, passIds, loc);
381
+ }
382
+ // 不够 → 回退补
383
+ if (moved < PER_COUNTRY) {
384
+ const gap = PER_COUNTRY - moved;
385
+ const rows = archiveDb
386
+ .prepare(
387
+ `SELECT unique_id, sources FROM raw_jobs WHERE ${wf} ORDER BY video_count DESC LIMIT ?`,
388
+ )
389
+ .all(...wfa, gap * 3);
390
+ const fbIds = pickBySource(rows, gap);
391
+ moved += moveToJobs(archiveDb, fbIds, loc);
392
+ }
393
+ totalMoved += moved;
394
+ log(" ", `${loc} → ${moved}条 LLM`);
395
+ }
396
+
397
+ log("--", `LLM完成 共写入${totalMoved}条`);
398
+ return { moved: totalMoved };
211
399
  }
400
+ // ═══════════════════════════════════════════════════════════════════
401
+ // 单轮调度
402
+ let tickN = 0;
403
+
404
+ async function tick(archiveDb, opts) {
405
+ tickN++;
406
+ const t0 = Date.now();
407
+
408
+ archiveNewUsers(archiveDb);
409
+ if (opts.llm) reloadLlmConfig(); // 每轮重载,支持热切换模型
212
410
 
213
- const archiveTotal =
214
- archiveDb.prepare("SELECT COUNT(*) as c FROM raw_jobs").get()?.c || 0;
215
- console.error(
216
- `[sync] 归档库 raw_jobs 总量: ${archiveTotal.toLocaleString()} 条`,
217
- );
218
-
219
- // 确保归档库有加速索引(首次运行自动创建)
220
- const startIdx = Date.now();
221
- archiveDb.exec(`
222
- CREATE INDEX IF NOT EXISTS idx_raw_archive_sync
223
- ON raw_jobs(video_count DESC, created_at DESC)
224
- WHERE video_count > 6 AND follower_count > 0 AND following_count > 0;
225
- `);
226
- const idxElapsed = ((Date.now() - startIdx) / 1000).toFixed(1);
227
- if (idxElapsed > 0.1) {
228
- console.error(`[sync] 归档库索引就绪,耗时 ${idxElapsed}s`);
411
+ const byCountry = countPendingByCountry();
412
+ const needy = findNeedyCountries(byCountry, PER_COUNTRY);
413
+
414
+ if (!needy.length) {
415
+ log(
416
+ "--",
417
+ `轮${tickN} 全部充足 | ${LOCATIONS.map((l) => `${l}:${byCountry[l]}`).join(" ")}`,
418
+ );
419
+ return;
420
+ }
421
+
422
+ log(
423
+ "==",
424
+ `轮${tickN} 需补 ${needy.map((l) => `${l}(${byCountry[l]}→+${PER_COUNTRY})`).join(" ")}`,
425
+ );
426
+
427
+ if (opts.llm) {
428
+ const r = await refillLLM(archiveDb, needy);
429
+ const s = ((Date.now() - t0) / 1000).toFixed(1);
430
+ log(">>", `轮${tickN} 写入${r.moved}条 ${s}s`);
431
+ } else {
432
+ let total = 0;
433
+ for (const loc of needy) {
434
+ const r = refillRegular(archiveDb, PER_COUNTRY, [loc]);
435
+ total += r.moved;
436
+ log(" ", `${loc} +${r.moved}/${PER_COUNTRY}`);
437
+ }
438
+ const s = ((Date.now() - t0) / 1000).toFixed(1);
439
+ log(">>", `轮${tickN} 写入${total}条 ${s}s`);
440
+ }
229
441
  }
230
- console.error("");
231
442
 
232
- // 执行同步
233
- const MAX_TOTAL = opts.max ?? 10000;
234
- syncOnce(mainDb, archiveDb, MAX_TOTAL, opts.batch);
443
+ // ═══════════════════════════════════════════════════════════════════
444
+ // 主入口
445
+ // ═══════════════════════════════════════════════════════════════════
446
+
447
+ async function main() {
448
+ const opts = parseArgs();
449
+ if (!opts.db || !opts.archive) {
450
+ console.error(
451
+ "用法: node scripts/sync-raw-jobs.mjs --db <主库> --archive <归档库> [--llm] [--once] [--interval 120]",
452
+ );
453
+ process.exit(1);
454
+ }
455
+
456
+ log(
457
+ "INIT",
458
+ `LLM=${opts.llm ? "ON" : "OFF"} 每国${PER_COUNTRY}条 ${opts.once ? "单次" : "间隔" + opts.interval + "s"}`,
459
+ );
235
460
 
236
- if (opts.once) {
237
- mainDb.close();
238
- archiveDb.close();
239
- console.error("[sync] 完成,退出");
240
- process.exit(0);
461
+ initDb(opts.db);
462
+ loadLlmConfig();
463
+
464
+ const archiveDb = new Database(opts.archive);
465
+ archiveDb.pragma("journal_mode = WAL");
466
+ if (
467
+ !archiveDb
468
+ .prepare(
469
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='raw_jobs'",
470
+ )
471
+ .get()
472
+ ) {
473
+ console.error("[sync] 归档库无 raw_jobs 表");
474
+ process.exit(1);
475
+ }
476
+ log(
477
+ "INIT",
478
+ `归档 ${archiveDb.prepare("SELECT COUNT(*) as c FROM raw_jobs").get().c.toLocaleString()} 条`,
479
+ );
480
+
481
+ archiveDb.exec(
482
+ `CREATE INDEX IF NOT EXISTS idx_raw_refill ON raw_jobs(guessed_location, video_count DESC, created_at DESC) WHERE video_count>6 AND follower_count>0 AND following_count>0`,
483
+ );
484
+ // tag/回退查询用:无国家过滤,按 video_count 排序
485
+ archiveDb.exec(
486
+ `CREATE INDEX IF NOT EXISTS idx_raw_vc ON raw_jobs(video_count DESC) WHERE video_count>6 AND follower_count>0 AND following_count>0`,
487
+ );
488
+
489
+ // LLM 打分排序索引
490
+ archiveDb.exec(
491
+ `CREATE INDEX IF NOT EXISTS idx_raw_llm_score ON raw_jobs(llm_scored_count, video_count DESC) WHERE video_count>6 AND follower_count>0 AND following_count>0`,
492
+ );
493
+ try {
494
+ archiveDb.exec(
495
+ "ALTER TABLE raw_jobs ADD COLUMN llm_scored_count INTEGER DEFAULT 0",
496
+ );
497
+ } catch {
498
+ /* exists */
499
+ }
500
+
501
+ await tick(archiveDb, opts);
502
+ if (opts.once) {
503
+ archiveDb.close();
504
+ getDb().close();
505
+ log("DONE", "退出");
506
+ process.exit(0);
507
+ }
508
+
509
+ const timer = setInterval(() => tick(archiveDb, opts), opts.interval * 1000);
510
+ const stop = () => {
511
+ clearInterval(timer);
512
+ archiveDb.close();
513
+ getDb().close();
514
+ process.exit(0);
515
+ };
516
+ process.on("SIGINT", stop);
517
+ process.on("SIGTERM", stop);
241
518
  }
242
519
 
243
- // 定时循环
244
- setInterval(() => {
245
- syncOnce(mainDb, archiveDb, MAX_TOTAL, opts.batch);
246
- }, opts.interval * 1000);
247
-
248
- // 优雅退出
249
- process.on("SIGINT", () => {
250
- console.error("\n[sync] 收到中断信号,退出...");
251
- mainDb.close();
252
- archiveDb.close();
253
- process.exit(0);
520
+ main().catch((e) => {
521
+ console.error("[sync] FATAL:", e);
522
+ process.exit(1);
254
523
  });