tt-help-cli-ycl 1.4.134 → 1.4.136

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.
@@ -41,6 +41,33 @@ export function resetDbConnection() {
41
41
  dbPath = null;
42
42
  }
43
43
 
44
+ // ===== 全局 tag 库连接(独立于主库,模块级共享) =====
45
+ let tagDb = null;
46
+ let tagDbPath = null;
47
+
48
+ export function getTagDb() {
49
+ return tagDb;
50
+ }
51
+
52
+ export function getTagDbPath() {
53
+ return tagDbPath;
54
+ }
55
+
56
+ /**
57
+ * 由主库路径派生 tag 库路径:result.db → result-tags.db
58
+ */
59
+ export function deriveTagDbPath(mainDbPath) {
60
+ return mainDbPath.replace(/\.db$/i, "-tags.db");
61
+ }
62
+
63
+ export function resetTagDbConnection() {
64
+ if (tagDb) {
65
+ tagDb.close();
66
+ tagDb = null;
67
+ }
68
+ tagDbPath = null;
69
+ }
70
+
44
71
  // ===== 建表与迁移 =====
45
72
 
46
73
  /**
@@ -183,6 +210,43 @@ export function initDb(filePath) {
183
210
  recent_ec_videos: "TEXT",
184
211
  });
185
212
 
213
+ // ===== jobs_done 表(冷归档:status='done' 且非商家的已完成任务) =====
214
+ // 结构完全复制 jobs(含所有历史迁移列),仅存 done 非商家数据,
215
+ // 用于给 jobs 表瘦身(jobs 只保留商家 done + error/restricted 等)。
216
+ const jdExists = db
217
+ .prepare(
218
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='jobs_done'",
219
+ )
220
+ .get();
221
+ if (!jdExists) {
222
+ db.exec(`CREATE TABLE jobs_done AS SELECT * FROM jobs WHERE 1=0`);
223
+ // 补主键(CREATE TABLE ... AS 不会复制约束)
224
+ db.exec(
225
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_done_pk ON jobs_done(unique_id)`,
226
+ );
227
+ }
228
+ // 后续迁移也要给 jobs_done 补列(与 jobs 保持一致)
229
+ migrateColumns("jobs_done", {
230
+ pending_refresh: "INTEGER",
231
+ status_code: "INTEGER",
232
+ latest_video_time: "INTEGER",
233
+ confirmed_location: "TEXT",
234
+ modified_at: "INTEGER",
235
+ bio_link: "TEXT",
236
+ top_video_play_count: "INTEGER",
237
+ top_video_href: "TEXT",
238
+ top_video_is_ad: "INTEGER",
239
+ top_video_product_id: "TEXT",
240
+ top_video_product_title: "TEXT",
241
+ top_video_seo_url: "TEXT",
242
+ product_rating: "REAL",
243
+ product_review_count: "INTEGER",
244
+ product_sold_count: "INTEGER",
245
+ product_name: "TEXT",
246
+ user_create_time: "INTEGER",
247
+ recent_ec_videos: "TEXT",
248
+ });
249
+
186
250
  // ===== jobs_base 表 =====
187
251
  db.exec(`
188
252
  CREATE TABLE IF NOT EXISTS jobs_base (
@@ -334,8 +398,169 @@ export function initDb(filePath) {
334
398
  claimed_at: "INTEGER",
335
399
  });
336
400
 
401
+ // ===== tags 相关表已迁移到独立 tags.db(见 initTagSchema) =====
402
+
403
+ // ===== cross_country_learnings / discover_log / reflection_rules 已迁移到 tags.db =====
404
+
405
+ // ===== 索引 =====
406
+ const indexes = [
407
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status_video ON jobs(status, video_count DESC)`,
408
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claimed_by_status ON jobs(claimed_by, status, claimed_at)`,
409
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status_claimed_at ON jobs(status, claimed_at)`,
410
+ `CREATE INDEX IF NOT EXISTS idx_jobs_redo_target ON jobs(tt_seller, verified, location_created, refresh_time)`,
411
+ `CREATE INDEX IF NOT EXISTS idx_jobs_pending_priority ON jobs(status, pinned DESC, guessed_location, follower_count DESC)`,
412
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_pinned ON jobs(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
413
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_seller ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
414
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_follow ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
415
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_other ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
416
+ `CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue ON jobs(created_at ASC, unique_id ASC) WHERE (tt_seller IS NULL OR tt_seller = '') AND (user_update_count IS NULL OR user_update_count <= 0)`,
417
+ `CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue_expr ON jobs(created_at ASC, unique_id ASC) WHERE COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0`,
418
+ `CREATE INDEX IF NOT EXISTS idx_videos_comment_queue ON videos(user_update_count, tt_seller DESC, registered_at ASC)`,
419
+ `CREATE INDEX IF NOT EXISTS idx_videos_comment_queue_pending ON videos(tt_seller DESC, registered_at ASC, id) WHERE user_update_count IS NULL OR user_update_count <= 0`,
420
+ // tag 相关索引已迁移到独立 tags.db(见 initTagSchema)
421
+ `CREATE INDEX IF NOT EXISTS idx_jobs_recheck ON jobs(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
422
+ // 监控页面用户列表查询加速(简化排序:pinned DESC, processed_at DESC, follower_count DESC, unique_id ASC)
423
+ `CREATE INDEX IF NOT EXISTS idx_jobs_list_done ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
424
+ `CREATE INDEX IF NOT EXISTS idx_jobs_list_error ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'error'`,
425
+ `CREATE INDEX IF NOT EXISTS idx_jobs_list_restricted ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'restricted'`,
426
+ // jobs_done 冷归档表索引(done 列表 / 国家统计 / 重检)
427
+ `CREATE INDEX IF NOT EXISTS idx_jobs_done_list ON jobs_done(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
428
+ `CREATE INDEX IF NOT EXISTS idx_jobs_done_loc ON jobs_done(location_created)`,
429
+ `CREATE INDEX IF NOT EXISTS idx_jobs_done_status_updated ON jobs_done(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
430
+ ];
431
+ for (const sql of indexes) {
432
+ db.exec(sql);
433
+ }
434
+ // videos_old 索引(该表仅历史 rename 后存在,全新库跳过)
435
+ if (
436
+ db
437
+ .prepare(
438
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='videos_old'",
439
+ )
440
+ .get()
441
+ ) {
442
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_old_id ON videos_old(id)`);
443
+ }
444
+
445
+ // ===== task_queue 索引(对应 claimNextJob UNION 查询的 4 个部分索引) =====
446
+ const tqIndexes = [
447
+ `CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)`,
448
+ `CREATE INDEX IF NOT EXISTS idx_tq_claimed_by_status ON task_queue(claimed_by, status, claimed_at)`,
449
+ `CREATE INDEX IF NOT EXISTS idx_tq_status_claimed_at ON task_queue(status, claimed_at)`,
450
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_pinned ON task_queue(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
451
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_seller ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
452
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_follow ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
453
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_other ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
454
+ // 监控页面用户列表查询加速
455
+ `CREATE INDEX IF NOT EXISTS idx_tq_list_pending ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'pending'`,
456
+ `CREATE INDEX IF NOT EXISTS idx_tq_list_processing ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'processing'`,
457
+ ];
458
+ for (const sql of tqIndexes) {
459
+ db.exec(sql);
460
+ }
461
+
462
+ // 首次迁移:将 jobs 中的 pending/processing 行移动到 task_queue
463
+ const tqCount = db.prepare("SELECT COUNT(*) as c FROM task_queue").get().c;
464
+ if (tqCount === 0) {
465
+ const moved = db
466
+ .prepare(
467
+ `
468
+ INSERT INTO task_queue SELECT * FROM jobs WHERE status IN ('pending', 'processing')
469
+ `,
470
+ )
471
+ .run().changes;
472
+ if (moved > 0) {
473
+ db.prepare(
474
+ `DELETE FROM jobs WHERE status IN ('pending', 'processing')`,
475
+ ).run();
476
+ console.log(
477
+ `[data-store] 迁移 ${moved} 条 pending/processing → task_queue`,
478
+ );
479
+ }
480
+ }
481
+
482
+ // 兜底迁移:jobs 中 done 且非商家的记录 → jobs_done(幂等,含历史存量 + 日常增量)
483
+ migrateDoneToJobsDone();
484
+
485
+ const count = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
486
+ console.log(`[data-store] SQLite users 表初始化完成: ${count} 条`);
487
+ }
488
+
489
+ // ===== jobs_done 冷归档迁移 =====
490
+
491
+ /**
492
+ * 将 jobs 表中 status='done' 且非商家(tt_seller IS NULL OR tt_seller != 1)
493
+ * 的记录分批并入 jobs_done 冷归档表,并从 jobs 删除。
494
+ *
495
+ * 幂等:LEFT JOIN 跳过 jobs_done 已存在的记录,DELETE 只清理已并入的。
496
+ * 首次启动处理存量(百万级),日常启动只处理两次启动之间新增的少量 done 非商家,
497
+ * 因此不会随数据量线性拖慢每次启动。
498
+ *
499
+ * @param {number} batchSize 每批处理条数,默认 50000
500
+ * @returns {{ok:boolean, moved:number, error?:string}}
501
+ */
502
+ export function migrateDoneToJobsDone(batchSize = 50000) {
503
+ const db = getDb();
504
+ if (!db) return { ok: false, error: "db not ready", moved: 0 };
505
+ const BATCH = Math.max(1, Math.min(100000, parseInt(batchSize) || 50000));
506
+ const DONE_NON_SELLER =
507
+ "j.status = 'done' AND (j.tt_seller IS NULL OR j.tt_seller != 1)";
508
+
509
+ let total = 0;
510
+ for (;;) {
511
+ // 1. 插入新的(跳过 jobs_done 已存在的)
512
+ const inserted = db
513
+ .prepare(
514
+ `INSERT OR IGNORE INTO jobs_done
515
+ SELECT j.* FROM jobs j
516
+ LEFT JOIN jobs_done jd ON jd.unique_id = j.unique_id
517
+ WHERE ${DONE_NON_SELLER} AND jd.unique_id IS NULL
518
+ LIMIT ?`,
519
+ )
520
+ .run(BATCH).changes;
521
+ // 2. 删除 jobs 中已并入 jobs_done 的非商家 done(含上次中断的残留)
522
+ const deleted = db
523
+ .prepare(
524
+ `DELETE FROM jobs WHERE unique_id IN (
525
+ SELECT j.unique_id FROM jobs j
526
+ JOIN jobs_done jd ON jd.unique_id = j.unique_id
527
+ WHERE ${DONE_NON_SELLER}
528
+ LIMIT ?
529
+ )`,
530
+ )
531
+ .run(BATCH).changes;
532
+ if (inserted === 0 && deleted === 0) break;
533
+ total += Math.max(inserted, deleted);
534
+ if (total >= BATCH) {
535
+ console.error(`[data-store] jobs_done 迁移中: 已迁移 ${total} 条`);
536
+ }
537
+ }
538
+ if (total > 0) {
539
+ console.error(`[data-store] 迁移完成: ${total} 条 done 非商家 → jobs_done`);
540
+ }
541
+ return { ok: true, moved: total };
542
+ }
543
+
544
+ // ===== Tag 库初始化(独立 tags.db) =====
545
+
546
+ /**
547
+ * 初始化独立 tag 库连接与 schema。
548
+ * 注:tags.db 是全新库,建表时即含全部列,无需 migrateColumns。
549
+ */
550
+ export function initTagDb(filePath) {
551
+ tagDbPath = normalizeDbFilePath(filePath);
552
+ fs.mkdirSync(path.dirname(tagDbPath), { recursive: true });
553
+ tagDb = new Database(tagDbPath);
554
+ tagDb.pragma("journal_mode = WAL");
555
+ tagDb.pragma("busy_timeout = 5000");
556
+ initTagSchema();
557
+ }
558
+
559
+ function initTagSchema() {
560
+ if (!tagDb) return;
561
+
337
562
  // ===== tags 表 =====
338
- db.exec(`
563
+ tagDb.exec(`
339
564
  CREATE TABLE IF NOT EXISTS tags (
340
565
  id INTEGER PRIMARY KEY AUTOINCREMENT,
341
566
  tag TEXT NOT NULL UNIQUE,
@@ -352,25 +577,21 @@ export function initDb(filePath) {
352
577
  pushed_users INTEGER DEFAULT 0,
353
578
  source TEXT NOT NULL DEFAULT 'llm',
354
579
  user_prompt TEXT,
355
- last_error TEXT
580
+ last_error TEXT,
581
+ discover_round INTEGER DEFAULT 0,
582
+ strategy TEXT,
583
+ dead_reason TEXT,
584
+ direction_id INTEGER,
585
+ refreshing INTEGER DEFAULT 0,
586
+ refreshed_at TEXT,
587
+ searching INTEGER DEFAULT 0,
588
+ searched_at TEXT,
589
+ searched_users INTEGER DEFAULT 0
356
590
  )
357
591
  `);
358
592
 
359
- // ===== tags 表新增字段(兼容已存在的表) =====
360
- migrateColumns("tags", {
361
- discover_round: "INTEGER DEFAULT 0",
362
- strategy: "TEXT",
363
- dead_reason: "TEXT",
364
- direction_id: "INTEGER",
365
- refreshing: "INTEGER DEFAULT 0",
366
- refreshed_at: "TEXT",
367
- searching: "INTEGER DEFAULT 0",
368
- searched_at: "TEXT",
369
- searched_users: "INTEGER DEFAULT 0",
370
- });
371
-
372
593
  // ===== tag_directions 表(分层探索:通用词/分类/产品) =====
373
- db.exec(`
594
+ tagDb.exec(`
374
595
  CREATE TABLE IF NOT EXISTS tag_directions (
375
596
  id INTEGER PRIMARY KEY AUTOINCREMENT,
376
597
  name TEXT NOT NULL,
@@ -387,12 +608,9 @@ export function initDb(filePath) {
387
608
  updated_at TEXT
388
609
  )
389
610
  `);
390
- migrateColumns("tag_directions", {
391
- lang_group: "TEXT",
392
- });
393
611
 
394
612
  // ===== tag_experiments 表(每次探索的实验记录) =====
395
- db.exec(`
613
+ tagDb.exec(`
396
614
  CREATE TABLE IF NOT EXISTS tag_experiments (
397
615
  id INTEGER PRIMARY KEY AUTOINCREMENT,
398
616
  direction_id INTEGER,
@@ -411,7 +629,7 @@ export function initDb(filePath) {
411
629
  `);
412
630
 
413
631
  // ===== tag_analyst_reports 表(LLM 分析师定期体检报告) =====
414
- db.exec(`
632
+ tagDb.exec(`
415
633
  CREATE TABLE IF NOT EXISTS tag_analyst_reports (
416
634
  id INTEGER PRIMARY KEY AUTOINCREMENT,
417
635
  report_type TEXT NOT NULL DEFAULT 'daily',
@@ -427,7 +645,7 @@ export function initDb(filePath) {
427
645
  `);
428
646
 
429
647
  // ===== cross_country_learnings 表(跨国家经验复用记录) =====
430
- db.exec(`
648
+ tagDb.exec(`
431
649
  CREATE TABLE IF NOT EXISTS cross_country_learnings (
432
650
  id INTEGER PRIMARY KEY AUTOINCREMENT,
433
651
  source_country TEXT NOT NULL,
@@ -444,7 +662,7 @@ export function initDb(filePath) {
444
662
  `);
445
663
 
446
664
  // ===== discover_log 表(记录每次 LLM discover 的策略和思考) =====
447
- db.exec(`
665
+ tagDb.exec(`
448
666
  CREATE TABLE IF NOT EXISTS discover_log (
449
667
  id INTEGER PRIMARY KEY AUTOINCREMENT,
450
668
  country TEXT NOT NULL,
@@ -460,13 +678,9 @@ export function initDb(filePath) {
460
678
  avg_dead_score REAL DEFAULT 0
461
679
  )
462
680
  `);
463
- // 迁移:tags_raw 列(用于记录 LLM 原始生成的 tag,去重前)
464
- try {
465
- db.exec("ALTER TABLE discover_log ADD COLUMN tags_raw TEXT");
466
- } catch {}
467
681
 
468
682
  // ===== reflection_rules 表(反思闭环:LLM 自我学习规则) =====
469
- db.exec(`
683
+ tagDb.exec(`
470
684
  CREATE TABLE IF NOT EXISTS reflection_rules (
471
685
  id INTEGER PRIMARY KEY AUTOINCREMENT,
472
686
  country TEXT NOT NULL,
@@ -483,23 +697,11 @@ export function initDb(filePath) {
483
697
  )
484
698
  `);
485
699
 
486
- // ===== 索引 =====
487
- const indexes = [
488
- `CREATE INDEX IF NOT EXISTS idx_jobs_status_video ON jobs(status, video_count DESC)`,
489
- `CREATE INDEX IF NOT EXISTS idx_jobs_claimed_by_status ON jobs(claimed_by, status, claimed_at)`,
490
- `CREATE INDEX IF NOT EXISTS idx_jobs_status_claimed_at ON jobs(status, claimed_at)`,
491
- `CREATE INDEX IF NOT EXISTS idx_jobs_redo_target ON jobs(tt_seller, verified, location_created, refresh_time)`,
492
- `CREATE INDEX IF NOT EXISTS idx_jobs_pending_priority ON jobs(status, pinned DESC, guessed_location, follower_count DESC)`,
493
- `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_pinned ON jobs(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
494
- `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_seller ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
495
- `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_follow ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
496
- `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_other ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
497
- `CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue ON jobs(created_at ASC, unique_id ASC) WHERE (tt_seller IS NULL OR tt_seller = '') AND (user_update_count IS NULL OR user_update_count <= 0)`,
498
- `CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue_expr ON jobs(created_at ASC, unique_id ASC) WHERE COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0`,
499
- `CREATE INDEX IF NOT EXISTS idx_videos_comment_queue ON videos(user_update_count, tt_seller DESC, registered_at ASC)`,
500
- `CREATE INDEX IF NOT EXISTS idx_videos_comment_queue_pending ON videos(tt_seller DESC, registered_at ASC, id) WHERE user_update_count IS NULL OR user_update_count <= 0`,
700
+ // ===== Tag 索引 =====
701
+ const tagIndexes = [
501
702
  `CREATE INDEX IF NOT EXISTS idx_tags_status ON tags(status)`,
502
703
  `CREATE INDEX IF NOT EXISTS idx_tags_score ON tags(score DESC)`,
704
+ `CREATE INDEX IF NOT EXISTS idx_tags_dead_reason ON tags(dead_reason)`,
503
705
  `CREATE INDEX IF NOT EXISTS idx_discover_log_country ON discover_log(country, round DESC)`,
504
706
  `CREATE INDEX IF NOT EXISTS idx_reflection_rules_active ON reflection_rules(country, is_active)`,
505
707
  `CREATE INDEX IF NOT EXISTS idx_tag_directions_country ON tag_directions(country, level)`,
@@ -507,66 +709,13 @@ export function initDb(filePath) {
507
709
  `CREATE INDEX IF NOT EXISTS idx_tag_experiments_direction ON tag_experiments(direction_id)`,
508
710
  `CREATE INDEX IF NOT EXISTS idx_cross_country_source ON cross_country_learnings(source_country, result_status)`,
509
711
  `CREATE INDEX IF NOT EXISTS idx_cross_country_target ON cross_country_learnings(target_country)`,
510
- `CREATE INDEX IF NOT EXISTS idx_tags_dead_reason ON tags(dead_reason)`,
511
- `CREATE INDEX IF NOT EXISTS idx_jobs_recheck ON jobs(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
512
- // 监控页面用户列表查询加速(简化排序:pinned DESC, processed_at DESC, follower_count DESC, unique_id ASC)
513
- `CREATE INDEX IF NOT EXISTS idx_jobs_list_done ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'done'`,
514
- `CREATE INDEX IF NOT EXISTS idx_jobs_list_error ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'error'`,
515
- `CREATE INDEX IF NOT EXISTS idx_jobs_list_restricted ON jobs(pinned, processed_at, follower_count, unique_id) WHERE status = 'restricted'`,
516
- ];
517
- for (const sql of indexes) {
518
- db.exec(sql);
519
- }
520
- // videos_old 索引(该表仅历史 rename 后存在,全新库跳过)
521
- if (
522
- db
523
- .prepare(
524
- "SELECT 1 FROM sqlite_master WHERE type='table' AND name='videos_old'",
525
- )
526
- .get()
527
- ) {
528
- db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_old_id ON videos_old(id)`);
529
- }
530
-
531
- // ===== task_queue 索引(对应 claimNextJob UNION 查询的 4 个部分索引) =====
532
- const tqIndexes = [
533
- `CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)`,
534
- `CREATE INDEX IF NOT EXISTS idx_tq_claimed_by_status ON task_queue(claimed_by, status, claimed_at)`,
535
- `CREATE INDEX IF NOT EXISTS idx_tq_status_claimed_at ON task_queue(status, claimed_at)`,
536
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_pinned ON task_queue(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
537
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_seller ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
538
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_follow ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
539
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_other ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
540
- // 监控页面用户列表查询加速
541
- `CREATE INDEX IF NOT EXISTS idx_tq_list_pending ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'pending'`,
542
- `CREATE INDEX IF NOT EXISTS idx_tq_list_processing ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'processing'`,
543
712
  ];
544
- for (const sql of tqIndexes) {
545
- db.exec(sql);
546
- }
547
-
548
- // 首次迁移:将 jobs 中的 pending/processing 行移动到 task_queue
549
- const tqCount = db.prepare("SELECT COUNT(*) as c FROM task_queue").get().c;
550
- if (tqCount === 0) {
551
- const moved = db
552
- .prepare(
553
- `
554
- INSERT INTO task_queue SELECT * FROM jobs WHERE status IN ('pending', 'processing')
555
- `,
556
- )
557
- .run().changes;
558
- if (moved > 0) {
559
- db.prepare(
560
- `DELETE FROM jobs WHERE status IN ('pending', 'processing')`,
561
- ).run();
562
- console.log(
563
- `[data-store] 迁移 ${moved} 条 pending/processing → task_queue`,
564
- );
565
- }
713
+ for (const sql of tagIndexes) {
714
+ tagDb.exec(sql);
566
715
  }
567
716
 
568
- const count = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
569
- console.log(`[data-store] SQLite users 表初始化完成: ${count} 条`);
717
+ const count = tagDb.prepare("SELECT COUNT(*) as c FROM tags").get().c;
718
+ console.log(`[data-store] tags.db 初始化完成: ${count} 条`);
570
719
  }
571
720
 
572
721
  // ===== 历史数据加载 =====
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { getDb } from "./db-schema.js";
8
8
  import { getRawJobsCount } from "./db-crud.js";
9
+ // tagCount 从独立 tags.db 读取(跨库)
10
+ import { getTagStats } from "./db-tags.js";
9
11
 
10
12
  export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
11
13
  const db = getDb();
@@ -41,17 +43,35 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
41
43
  const jobsAgg = db
42
44
  .prepare(
43
45
  `SELECT
44
- COUNT(*) as jobsTotal,
45
- SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
46
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
47
- SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
48
- SUM(CASE WHEN tt_seller = 1 AND verified = 0 ${
49
- targetLocations.length
50
- ? `AND location_created IN (${targetPlaceholders})`
51
- : "AND 1 = 0"
52
- } THEN 1 ELSE 0 END) as targetUsers,
53
- SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
54
- FROM jobs`,
46
+ SUM(jobsTotal) as jobsTotal,
47
+ SUM(done) as done,
48
+ SUM(error) as error,
49
+ SUM(restricted) as restricted,
50
+ SUM(targetUsers) as targetUsers,
51
+ SUM(noVideo) as noVideo
52
+ FROM (
53
+ SELECT
54
+ COUNT(*) as jobsTotal,
55
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
56
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
57
+ SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
58
+ SUM(CASE WHEN tt_seller = 1 AND verified = 0 ${
59
+ targetLocations.length
60
+ ? `AND location_created IN (${targetPlaceholders})`
61
+ : "AND 1 = 0"
62
+ } THEN 1 ELSE 0 END) as targetUsers,
63
+ SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
64
+ FROM jobs
65
+ UNION ALL
66
+ SELECT
67
+ COUNT(*) as jobsTotal,
68
+ COUNT(*) as done,
69
+ 0 as error,
70
+ 0 as restricted,
71
+ 0 as targetUsers,
72
+ SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
73
+ FROM jobs_done
74
+ )`,
55
75
  )
56
76
  .get(...targetParams);
57
77
 
@@ -81,15 +101,26 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
81
101
  const countryStats = db
82
102
  .prepare(
83
103
  `
84
- SELECT
85
- COALESCE(location_created, '未知') as country,
86
- COUNT(*) as count,
87
- SUM(CASE
88
- WHEN tt_seller = 1 AND verified = 0
89
- THEN 1 ELSE 0 END) as targetCount
90
- FROM jobs
91
- WHERE status = 'done'
92
- GROUP BY COALESCE(location_created, '未知')
104
+ SELECT country, SUM(cnt) as count, SUM(targetCount) as targetCount
105
+ FROM (
106
+ SELECT
107
+ COALESCE(location_created, '未知') as country,
108
+ COUNT(*) as cnt,
109
+ SUM(CASE
110
+ WHEN tt_seller = 1 AND verified = 0
111
+ THEN 1 ELSE 0 END) as targetCount
112
+ FROM jobs
113
+ WHERE status = 'done'
114
+ GROUP BY COALESCE(location_created, '未知')
115
+ UNION ALL
116
+ SELECT
117
+ COALESCE(location_created, '未知') as country,
118
+ COUNT(*) as cnt,
119
+ 0 as targetCount
120
+ FROM jobs_done
121
+ GROUP BY COALESCE(location_created, '未知')
122
+ )
123
+ GROUP BY country
93
124
  ORDER BY count DESC
94
125
  `,
95
126
  )
@@ -113,9 +144,8 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
113
144
  .prepare("SELECT COUNT(*) as total FROM jobs_base")
114
145
  .get().total;
115
146
 
116
- const tagCount = skipSlow
117
- ? 0
118
- : db.prepare("SELECT COUNT(*) as total FROM tags").get().total;
147
+ // tagCount 从独立 tags.db 读取(跨库,tags 表已迁出主库)
148
+ const tagCount = skipSlow ? 0 : getTagStats()?.total || 0;
119
149
 
120
150
  const commentTaskCount = skipSlow
121
151
  ? 0
@@ -4,7 +4,9 @@
4
4
  * 管理 tags 表:插入、查询、认领、打分、清理。
5
5
  */
6
6
 
7
- import { getDb } from "./db-schema.js";
7
+ // 注意:tag 域数据已迁移到独立 tags.db,tag CRUD 使用 tag 库连接(别名 getDb 保持调用点不变)
8
+ // rawQuery 是通用查询器,供主库 stats/db-query 使用,故单独保留主库连接 getMainDb
9
+ import { getDb as getMainDb, getTagDb as getDb } from "./db-schema.js";
8
10
 
9
11
  function parseTagRow(r) {
10
12
  return {
@@ -373,7 +375,8 @@ export function getAllTags(
373
375
  }
374
376
 
375
377
  export function rawQuery(sql, params = []) {
376
- const db = getDb();
378
+ // 通用查询器:默认查主库(stats/db-query 复用),勿切到 tag 库
379
+ const db = getMainDb();
377
380
  if (!db) return { error: "db not ready" };
378
381
  try {
379
382
  const rows = db.prepare(sql).all(...params);
@@ -14,6 +14,12 @@ import { getDb } from "./db-schema.js";
14
14
  // ====== Provider + Model 定义 ======
15
15
 
16
16
  export const LLM_PROVIDERS = [
17
+ {
18
+ id: "none",
19
+ name: "无",
20
+ url: "",
21
+ models: [{ id: "none", label: "无" }],
22
+ },
17
23
  {
18
24
  id: "legacy",
19
25
  name: "旧 LLM (zc-fast)",
@@ -136,13 +142,23 @@ export function reloadLlmConfig() {
136
142
 
137
143
  function _doLoadLlmConfig() {
138
144
  const dbConfig = loadFromDb();
145
+ _cachedProvider =
146
+ dbConfig.llm_provider || process.env.LLM_PROVIDER || LLM_PROVIDERS[0].id;
147
+ // 如果选择了 'none',短路为禁用状态
148
+ if (_cachedProvider === "none") {
149
+ _cachedUrl = "";
150
+ _cachedModel = "none";
151
+ _cachedApiKey = "";
152
+ _loaded = true;
153
+ console.error(`[llm-config] 加载完成: provider=none (LLM 已禁用)`);
154
+ return;
155
+ }
139
156
 
140
157
  _cachedUrl = dbConfig.llm_url || process.env.LLM_URL || LLM_PROVIDERS[0].url;
141
158
  _cachedModel =
142
159
  dbConfig.llm_model ||
143
160
  process.env.LLM_MODEL ||
144
161
  LLM_PROVIDERS[0].models[0].id;
145
- _cachedProvider = dbConfig.llm_provider || LLM_PROVIDERS[0].id;
146
162
  // 按 provider 隔离读取 API Key:优先 llm_apikey_{provider},兼容旧 llm_apikey,最后 env
147
163
  _cachedApiKey =
148
164
  dbConfig[`llm_apikey_${_cachedProvider}`] ||
@@ -310,6 +326,11 @@ export async function llmFetch({
310
326
  } = {}) {
311
327
  if (!_loaded) loadLlmConfig();
312
328
 
329
+ // 如果 provider 为 none,则 LLM 被禁用,短路返回错误
330
+ if (_cachedProvider === "none") {
331
+ throw new Error("LLM is disabled by configuration (provider=none)");
332
+ }
333
+
313
334
  // 全部模型不可用冷却中:同批次直接跳过,不重复遍历 25 个模型
314
335
  if (_allDeadUntil > Date.now()) {
315
336
  throw new Error("全部模型不可用(冷却中)");
@@ -448,6 +469,15 @@ export function updateLlmConfig(updates = {}) {
448
469
  saveToDb("llm_provider", provider.id);
449
470
  saveToDb("llm_url", provider.url);
450
471
  saveToDb("llm_model", _cachedModel);
472
+ // 如果切换到 none,则清除相关 API Key 持久化并短路
473
+ if (provider.id === "none") {
474
+ // 明确存储 provider=none,并移除任何 provider 专属 apikey
475
+ saveToDb("llm_provider", "none");
476
+ saveToDb("llm_url", "");
477
+ saveToDb("llm_model", "none");
478
+ // 清除内存的 apikey
479
+ _cachedApiKey = "";
480
+ }
451
481
  } else if (updates.model !== undefined) {
452
482
  // Model 单独变更(同 provider 内切换)
453
483
  _cachedModel = updates.model;