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.
- package/package.json +1 -1
- package/scripts/backup-db.mjs +73 -0
- package/scripts/check-queues.sh +37 -0
- package/scripts/migrate-jobs-done.mjs +124 -0
- package/scripts/migrate-tags-db.mjs +163 -0
- package/src/cli/tag.js +68 -9
- package/src/lib/tag-discover.js +21 -0
- package/src/watch/data-store.js +131 -77
- package/src/watch/db-crud.js +35 -10
- package/src/watch/db-schema.js +249 -100
- package/src/watch/db-stats.js +53 -23
- package/src/watch/db-tags.js +5 -2
- package/src/watch/llm-config.js +31 -1
- package/src/watch/public/app.js +162 -11
- package/src/watch/public/index.html +19 -1
- package/src/watch/server.js +338 -62
- package/src/watch/tag-selflearn.js +2 -1
- package/src/watch/watchdog-server.js +129 -1
- package/src/watchdog/agent.js +76 -0
- package/src/watchdog/process-manager.js +21 -0
- package/src/watchdog/self-log-buffer.js +25 -0
package/src/watch/db-schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
545
|
-
|
|
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 =
|
|
569
|
-
console.log(`[data-store]
|
|
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
|
// ===== 历史数据加载 =====
|
package/src/watch/db-stats.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
SUM(
|
|
46
|
-
SUM(
|
|
47
|
-
SUM(
|
|
48
|
-
SUM(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
package/src/watch/db-tags.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* 管理 tags 表:插入、查询、认领、打分、清理。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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);
|
package/src/watch/llm-config.js
CHANGED
|
@@ -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;
|