tt-help-cli-ycl 1.4.17 → 1.4.18
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/src/cli/comments.js +6 -4
- package/src/cli/tag.js +5 -7
- package/src/cli/watch.js +15 -4
- package/src/lib/args.js +10 -0
- package/src/lib/constants.js +12 -2
- package/src/watch/data-store.js +89 -0
- package/src/watch/db-crud.js +16 -1
- package/src/watch/db-schema.js +5 -0
- package/src/watch/db-stats.js +2 -6
- package/src/watch/db-tags.js +106 -6
- package/src/watch/public/app.js +307 -10
- package/src/watch/public/index.html +2 -1
- package/src/watch/public/style.css +30 -0
- package/src/watch/server.js +222 -9
- package/src/watch/tag-service.js +317 -10
package/package.json
CHANGED
package/src/cli/comments.js
CHANGED
|
@@ -646,11 +646,13 @@ async function runAutoMode(options) {
|
|
|
646
646
|
} catch (err) {
|
|
647
647
|
console.error(` × 获取任务失败: ${err.message}`);
|
|
648
648
|
consecutiveErrors++;
|
|
649
|
-
if (consecutiveErrors >
|
|
650
|
-
console.error(
|
|
651
|
-
|
|
649
|
+
if (consecutiveErrors > 0 && consecutiveErrors % 30 === 0) {
|
|
650
|
+
console.error(
|
|
651
|
+
` ⚠ 已连续获取失败 ${consecutiveErrors} 次,继续等待服务恢复...`,
|
|
652
|
+
);
|
|
652
653
|
}
|
|
653
|
-
|
|
654
|
+
const waitMs = Math.min(consecutiveErrors * 1000, 60000);
|
|
655
|
+
await delay(waitMs, waitMs + 1000);
|
|
654
656
|
continue;
|
|
655
657
|
}
|
|
656
658
|
consecutiveErrors = 0;
|
package/src/cli/tag.js
CHANGED
|
@@ -532,7 +532,9 @@ async function scoreSingleTag(
|
|
|
532
532
|
return result;
|
|
533
533
|
}
|
|
534
534
|
// 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
|
|
535
|
-
throw new Error(
|
|
535
|
+
throw new Error(
|
|
536
|
+
"no videos returned — possible network/captcha/slow-load, will retry",
|
|
537
|
+
);
|
|
536
538
|
}
|
|
537
539
|
|
|
538
540
|
// Step 2: 补充国家信息
|
|
@@ -1063,9 +1065,7 @@ export async function handleScoreAll(parsed) {
|
|
|
1063
1065
|
if (!videos || videos.length === 0) {
|
|
1064
1066
|
const deadSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
|
|
1065
1067
|
if (tagResult.error === "tag_not_found") {
|
|
1066
|
-
log(
|
|
1067
|
-
` ⚠️ 标签不存在 (${deadSec}s),标记 dead`,
|
|
1068
|
-
);
|
|
1068
|
+
log(` ⚠️ 标签不存在 (${deadSec}s),标记 dead`);
|
|
1069
1069
|
result.status = "dead";
|
|
1070
1070
|
result.error = "tag_not_found";
|
|
1071
1071
|
await reportToServer(baseUrl, result, clientId, clientMeta);
|
|
@@ -1076,9 +1076,7 @@ export async function handleScoreAll(parsed) {
|
|
|
1076
1076
|
continue;
|
|
1077
1077
|
}
|
|
1078
1078
|
// 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
|
|
1079
|
-
log(
|
|
1080
|
-
` ⚠️ 无视频 (${deadSec}s),可能网络/风控/加载慢 → 抛异常重试`,
|
|
1081
|
-
);
|
|
1079
|
+
log(` ⚠️ 无视频 (${deadSec}s),可能网络/风控/加载慢 → 抛异常重试`);
|
|
1082
1080
|
throw new Error("no videos returned (network/captcha/slow-load)");
|
|
1083
1081
|
}
|
|
1084
1082
|
|
package/src/cli/watch.js
CHANGED
|
@@ -5,16 +5,26 @@ import { startWatchServer, openBrowser } from "../watch/server.js";
|
|
|
5
5
|
|
|
6
6
|
export async function handleWatch(options) {
|
|
7
7
|
const dataAnchor = options.dataAnchor || options.outputFile;
|
|
8
|
-
const {
|
|
9
|
-
|
|
8
|
+
const {
|
|
9
|
+
watchPort,
|
|
10
|
+
llmRefill,
|
|
11
|
+
llmRefillMinScore,
|
|
12
|
+
llmRefillSampleSize,
|
|
13
|
+
llmDiscover,
|
|
14
|
+
} = options;
|
|
10
15
|
|
|
11
16
|
if (!dataAnchor) {
|
|
12
|
-
console.error(
|
|
17
|
+
console.error(
|
|
18
|
+
"用法: tt-help watch -o <db路径> [-p 端口] [--llm-refill] [--no-llm-discover]",
|
|
19
|
+
);
|
|
13
20
|
console.error(" tt-help watch -o data/result.db");
|
|
14
21
|
console.error(" tt-help watch -o data/result.db -p 8080");
|
|
15
22
|
console.error(
|
|
16
23
|
" tt-help watch -o data/result.db --llm-refill --llm-refill-min 60",
|
|
17
24
|
);
|
|
25
|
+
console.error(
|
|
26
|
+
" tt-help watch -o data/result.db --no-llm-discover --no-llm-refill",
|
|
27
|
+
);
|
|
18
28
|
process.exit(1);
|
|
19
29
|
}
|
|
20
30
|
|
|
@@ -37,7 +47,8 @@ export async function handleWatch(options) {
|
|
|
37
47
|
dataAnchor,
|
|
38
48
|
watchPort,
|
|
39
49
|
store,
|
|
40
|
-
storeOptions,
|
|
50
|
+
{ ...storeOptions, llmDiscover: llmDiscover ?? true },
|
|
51
|
+
options.watchPassword,
|
|
41
52
|
);
|
|
42
53
|
openBrowser(port);
|
|
43
54
|
|
package/src/lib/args.js
CHANGED
|
@@ -347,9 +347,11 @@ function parseInfoArgs(args) {
|
|
|
347
347
|
function parseWatchArgs(args) {
|
|
348
348
|
let dataAnchor = "./result.db";
|
|
349
349
|
let watchPort = 3001;
|
|
350
|
+
let watchPassword = null;
|
|
350
351
|
let llmRefill = true;
|
|
351
352
|
let llmRefillMinScore = null;
|
|
352
353
|
let llmRefillSampleSize = null;
|
|
354
|
+
let llmDiscover = true;
|
|
353
355
|
|
|
354
356
|
for (let i = 0; i < args.length; i++) {
|
|
355
357
|
const arg = args[i];
|
|
@@ -357,6 +359,8 @@ function parseWatchArgs(args) {
|
|
|
357
359
|
dataAnchor = args[++i];
|
|
358
360
|
} else if (arg === "-p") {
|
|
359
361
|
watchPort = parseInt(args[++i]) || 3001;
|
|
362
|
+
} else if (arg === "-P" || arg === "--password") {
|
|
363
|
+
watchPassword = args[++i] || null;
|
|
360
364
|
} else if (arg === "--llm-refill") {
|
|
361
365
|
llmRefill = true;
|
|
362
366
|
} else if (arg === "--no-llm-refill") {
|
|
@@ -365,6 +369,10 @@ function parseWatchArgs(args) {
|
|
|
365
369
|
llmRefillMinScore = parseInt(args[++i]) || 60;
|
|
366
370
|
} else if (arg === "--llm-refill-sample") {
|
|
367
371
|
llmRefillSampleSize = parseInt(args[++i]) || 100;
|
|
372
|
+
} else if (arg === "--llm-discover") {
|
|
373
|
+
llmDiscover = true;
|
|
374
|
+
} else if (arg === "--no-llm-discover") {
|
|
375
|
+
llmDiscover = false;
|
|
368
376
|
}
|
|
369
377
|
}
|
|
370
378
|
|
|
@@ -373,9 +381,11 @@ function parseWatchArgs(args) {
|
|
|
373
381
|
outputFile: dataAnchor,
|
|
374
382
|
dataAnchor,
|
|
375
383
|
watchPort,
|
|
384
|
+
watchPassword,
|
|
376
385
|
llmRefill,
|
|
377
386
|
llmRefillMinScore,
|
|
378
387
|
llmRefillSampleSize,
|
|
388
|
+
llmDiscover,
|
|
379
389
|
urls: [],
|
|
380
390
|
outputFormat: "json",
|
|
381
391
|
exploreCount: 0,
|
package/src/lib/constants.js
CHANGED
|
@@ -177,10 +177,20 @@ const HELP_TEXT = [
|
|
|
177
177
|
" 示例: tt-help attach -p 5 -i 10",
|
|
178
178
|
" tt-help attach -c PL,DE,FR -p 5",
|
|
179
179
|
"",
|
|
180
|
-
" tt-help watch -o <db路径> [-p 端口]",
|
|
180
|
+
" tt-help watch -o <db路径> [-p 端口] [--no-llm-refill] [--no-llm-discover]",
|
|
181
181
|
" 启动监控服务;运行期主数据仅来自 SQLite",
|
|
182
|
+
" 选项:",
|
|
183
|
+
" -p, --port <端口> 服务端口,默认 3000",
|
|
184
|
+
" -P, --password <密码> Web 面板登录密码",
|
|
185
|
+
" --llm-refill 启用 LLM 自动筛选毛料任务(默认开启)",
|
|
186
|
+
" --no-llm-refill 关闭 LLM 任务打分",
|
|
187
|
+
" --llm-discover 启用 LLM 自动发现标签(默认开启)",
|
|
188
|
+
" --no-llm-discover 关闭 LLM tag 发现",
|
|
189
|
+
" --llm-refill-min <分数> 最低入围分,默认 60",
|
|
190
|
+
" --llm-refill-sample <数量> 采样量,默认 100",
|
|
182
191
|
" 示例: tt-help watch -o data/result.db",
|
|
183
192
|
" tt-help watch -o data/result.db -p 8080",
|
|
193
|
+
" tt-help watch -o data/result.db --no-llm-discover --no-llm-refill",
|
|
184
194
|
"",
|
|
185
195
|
" tt-help open <端口号>",
|
|
186
196
|
" 打开指定端口的浏览器,用于配置 TikTok 登录账户",
|
|
@@ -316,7 +326,7 @@ const HELP_TEXT = [
|
|
|
316
326
|
];
|
|
317
327
|
|
|
318
328
|
const PUBLIC_HELP_HIDDEN_HEADERS = new Set([
|
|
319
|
-
" tt-help watch -o <db路径> [-p 端口]",
|
|
329
|
+
" tt-help watch -o <db路径> [-p 端口] [--no-llm-refill] [--no-llm-discover]",
|
|
320
330
|
" videostats <db路径> -p <并发数>",
|
|
321
331
|
" db-import --db <db路径> [--users users.json] [--done done.json] [--videos videos.json]",
|
|
322
332
|
]);
|
package/src/watch/data-store.js
CHANGED
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
getProductiveRefreshTask,
|
|
101
101
|
claimRefreshTag,
|
|
102
102
|
reportRefreshResult,
|
|
103
|
+
getRefreshStats,
|
|
103
104
|
} from "./db-tags.js";
|
|
104
105
|
|
|
105
106
|
// LLM 打分
|
|
@@ -1095,6 +1096,9 @@ export function createStore(filePath, options = {}) {
|
|
|
1095
1096
|
|
|
1096
1097
|
offsetStore.set("_tag", tagOffset);
|
|
1097
1098
|
|
|
1099
|
+
// 让出事件循环,避免长时间阻塞监控页面及其他客户端请求
|
|
1100
|
+
await new Promise(setImmediate);
|
|
1101
|
+
|
|
1098
1102
|
// ===== 第二步:一次性取 comment(全局,最多 llmCommentLimit 条,免打分)=====
|
|
1099
1103
|
let commentOffset = offsetStore.get("_comment") || 0;
|
|
1100
1104
|
const commentGlobalCount =
|
|
@@ -1146,6 +1150,9 @@ export function createStore(filePath, options = {}) {
|
|
|
1146
1150
|
|
|
1147
1151
|
offsetStore.set("_comment", commentOffset);
|
|
1148
1152
|
|
|
1153
|
+
// 让出事件循环,避免长时间阻塞监控页面及其他客户端请求
|
|
1154
|
+
await new Promise(setImmediate);
|
|
1155
|
+
|
|
1149
1156
|
// ===== 第三步:按国家取非 tag 非 comment,走 LLM 打分,直到合格数达到 llmLLMTarget =====
|
|
1150
1157
|
for (const location of normalizedLocations) {
|
|
1151
1158
|
if (allLLMQualified.length >= llmLLMTarget) break;
|
|
@@ -1194,6 +1201,9 @@ export function createStore(filePath, options = {}) {
|
|
|
1194
1201
|
|
|
1195
1202
|
if (!samples.length) break;
|
|
1196
1203
|
|
|
1204
|
+
// 让出事件循环,LLM API 调用期间允许处理其他请求
|
|
1205
|
+
await new Promise(setImmediate);
|
|
1206
|
+
|
|
1197
1207
|
const scores = await scoreJobsBatch(
|
|
1198
1208
|
samples,
|
|
1199
1209
|
DEFAULT_TARGET_LOCATIONS,
|
|
@@ -1234,6 +1244,9 @@ export function createStore(filePath, options = {}) {
|
|
|
1234
1244
|
`[data-store] LLM 调度完成: tag ${allTagQualified.length} + comment ${allCommentQualified.length} + LLM ${allLLMQualified.length} = 共 ${qualified.length} 条`,
|
|
1235
1245
|
);
|
|
1236
1246
|
|
|
1247
|
+
// 让出事件循环,避免事务写入阻塞监控页面
|
|
1248
|
+
await new Promise(setImmediate);
|
|
1249
|
+
|
|
1237
1250
|
// 移动符合条件的记录
|
|
1238
1251
|
const placeholders = qualified.map(() => "?").join(", ");
|
|
1239
1252
|
const moveTxn = getDb().transaction(() => {
|
|
@@ -3362,6 +3375,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3362
3375
|
getActiveClients,
|
|
3363
3376
|
moveSellerJobsToBase, // 将 jobs/raw_jobs 中 tt_seller=1 且 video_count=0 的记录移动到 jobs_base
|
|
3364
3377
|
moveRestrictedJobsToBase, // 将 jobs 中 status='restricted' 的记录移动到 jobs_base
|
|
3378
|
+
moveSingleJobToBase, // 将单个 job 移动到 jobs_base 并重置(目标商家页面用)
|
|
3365
3379
|
registerVideos,
|
|
3366
3380
|
getVideo,
|
|
3367
3381
|
getVideos,
|
|
@@ -3389,6 +3403,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3389
3403
|
getProductiveRefreshTask,
|
|
3390
3404
|
claimRefreshTag,
|
|
3391
3405
|
reportRefreshResult,
|
|
3406
|
+
getRefreshStats,
|
|
3392
3407
|
data,
|
|
3393
3408
|
};
|
|
3394
3409
|
|
|
@@ -3556,4 +3571,78 @@ export function createStore(filePath, options = {}) {
|
|
|
3556
3571
|
markStatsDirty();
|
|
3557
3572
|
return { ok: true, moved: deleted.changes || 0 };
|
|
3558
3573
|
}
|
|
3574
|
+
|
|
3575
|
+
// 将单个 job 移动到 jobs_base(用于目标商家页面"移到base重走流程")
|
|
3576
|
+
function moveSingleJobToBase(uniqueId) {
|
|
3577
|
+
const db = getDb();
|
|
3578
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
3579
|
+
|
|
3580
|
+
const safeId = String(uniqueId).trim();
|
|
3581
|
+
if (!safeId) return { ok: false, error: "invalid uniqueId" };
|
|
3582
|
+
|
|
3583
|
+
const existing = db
|
|
3584
|
+
.prepare("SELECT * FROM jobs WHERE unique_id = ?")
|
|
3585
|
+
.get(safeId);
|
|
3586
|
+
if (!existing) return { ok: false, error: "user not found in jobs" };
|
|
3587
|
+
|
|
3588
|
+
const COLUMNS = [
|
|
3589
|
+
"unique_id",
|
|
3590
|
+
"nickname",
|
|
3591
|
+
"status",
|
|
3592
|
+
"sources",
|
|
3593
|
+
"claimed_by",
|
|
3594
|
+
"claimed_at",
|
|
3595
|
+
"error",
|
|
3596
|
+
"pinned",
|
|
3597
|
+
"no_video",
|
|
3598
|
+
"restricted",
|
|
3599
|
+
"user_update_count",
|
|
3600
|
+
"tt_seller",
|
|
3601
|
+
"verified",
|
|
3602
|
+
"video_count",
|
|
3603
|
+
"comment_count",
|
|
3604
|
+
"guessed_location",
|
|
3605
|
+
"location_created",
|
|
3606
|
+
"confirmed_location",
|
|
3607
|
+
"modified_at",
|
|
3608
|
+
"follower_count",
|
|
3609
|
+
"following_count",
|
|
3610
|
+
"heart_count",
|
|
3611
|
+
"refresh_time",
|
|
3612
|
+
"processed",
|
|
3613
|
+
"processed_at",
|
|
3614
|
+
"created_at",
|
|
3615
|
+
"updated_at",
|
|
3616
|
+
"region",
|
|
3617
|
+
"signature",
|
|
3618
|
+
"bio_link",
|
|
3619
|
+
"sec_uid",
|
|
3620
|
+
"status_code",
|
|
3621
|
+
"latest_video_time",
|
|
3622
|
+
];
|
|
3623
|
+
const cols = COLUMNS.join(",");
|
|
3624
|
+
|
|
3625
|
+
try {
|
|
3626
|
+
db.prepare(
|
|
3627
|
+
`INSERT OR REPLACE INTO jobs_base (${cols})
|
|
3628
|
+
SELECT ${cols} FROM jobs WHERE unique_id = ?`,
|
|
3629
|
+
).run(safeId);
|
|
3630
|
+
|
|
3631
|
+
// 重置 user_update_count,保留 tt_seller,状态改为 pending
|
|
3632
|
+
db.prepare(
|
|
3633
|
+
`UPDATE jobs_base SET user_update_count = 0, status = 'pending',
|
|
3634
|
+
claimed_by = NULL, claimed_at = NULL, processed = 0, processed_at = NULL,
|
|
3635
|
+
error = NULL, restricted = 0
|
|
3636
|
+
WHERE unique_id = ?`,
|
|
3637
|
+
).run(safeId);
|
|
3638
|
+
|
|
3639
|
+
db.prepare("DELETE FROM jobs WHERE unique_id = ?").run(safeId);
|
|
3640
|
+
|
|
3641
|
+
markStatsDirty();
|
|
3642
|
+
console.error(`[DB] moveSingleJobToBase: ${safeId} → jobs_base`);
|
|
3643
|
+
return { ok: true };
|
|
3644
|
+
} catch (e) {
|
|
3645
|
+
return { ok: false, error: e.message };
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3559
3648
|
}
|
package/src/watch/db-crud.js
CHANGED
|
@@ -479,8 +479,23 @@ export function getPendingJobsUserUpdateCount() {
|
|
|
479
479
|
.get().c;
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
// raw_jobs 计数缓存:raw_jobs 表数据量大(千万级),COUNT(*) 全表扫描耗时长,
|
|
483
|
+
// 且该值变化频率低(仅在 batchUpdateUserInfo 移入非商家时变化),缓存 60 秒。
|
|
484
|
+
let _rawJobsCountCache = null;
|
|
485
|
+
let _rawJobsCountCacheTime = 0;
|
|
486
|
+
const RAW_JOBS_COUNT_CACHE_TTL = 60000; // 60 秒
|
|
487
|
+
|
|
482
488
|
export function getRawJobsCount() {
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
if (
|
|
491
|
+
_rawJobsCountCache !== null &&
|
|
492
|
+
now - _rawJobsCountCacheTime < RAW_JOBS_COUNT_CACHE_TTL
|
|
493
|
+
) {
|
|
494
|
+
return _rawJobsCountCache;
|
|
495
|
+
}
|
|
483
496
|
const db = getDb();
|
|
484
497
|
if (!db) return 0;
|
|
485
|
-
|
|
498
|
+
_rawJobsCountCache = db.prepare("SELECT COUNT(*) as c FROM raw_jobs").get().c;
|
|
499
|
+
_rawJobsCountCacheTime = now;
|
|
500
|
+
return _rawJobsCountCache;
|
|
486
501
|
}
|
package/src/watch/db-schema.js
CHANGED
|
@@ -367,6 +367,7 @@ export function initDb(filePath) {
|
|
|
367
367
|
round INTEGER NOT NULL DEFAULT 1,
|
|
368
368
|
strategy TEXT,
|
|
369
369
|
tags_generated TEXT NOT NULL DEFAULT '[]',
|
|
370
|
+
tags_raw TEXT,
|
|
370
371
|
tags_added INTEGER DEFAULT 0,
|
|
371
372
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
372
373
|
productive_sample TEXT,
|
|
@@ -375,6 +376,10 @@ export function initDb(filePath) {
|
|
|
375
376
|
avg_dead_score REAL DEFAULT 0
|
|
376
377
|
)
|
|
377
378
|
`);
|
|
379
|
+
// 迁移:tags_raw 列(用于记录 LLM 原始生成的 tag,去重前)
|
|
380
|
+
try {
|
|
381
|
+
db.exec("ALTER TABLE discover_log ADD COLUMN tags_raw TEXT");
|
|
382
|
+
} catch {}
|
|
378
383
|
|
|
379
384
|
// ===== reflection_rules 表(反思闭环:LLM 自我学习规则) =====
|
|
380
385
|
db.exec(`
|
package/src/watch/db-stats.js
CHANGED
|
@@ -61,11 +61,7 @@ export function getDashboardStatsFromDb(targetLocations = []) {
|
|
|
61
61
|
COALESCE(location_created, '未知') as country,
|
|
62
62
|
COUNT(*) as count,
|
|
63
63
|
SUM(CASE
|
|
64
|
-
WHEN tt_seller = 1 AND verified = 0
|
|
65
|
-
targetLocations.length
|
|
66
|
-
? `AND location_created IN (${targetPlaceholders})`
|
|
67
|
-
: "AND 1 = 0"
|
|
68
|
-
}
|
|
64
|
+
WHEN tt_seller = 1 AND verified = 0
|
|
69
65
|
THEN 1 ELSE 0 END) as targetCount
|
|
70
66
|
FROM jobs
|
|
71
67
|
WHERE status = 'done'
|
|
@@ -73,7 +69,7 @@ export function getDashboardStatsFromDb(targetLocations = []) {
|
|
|
73
69
|
ORDER BY count DESC
|
|
74
70
|
`,
|
|
75
71
|
)
|
|
76
|
-
.all(
|
|
72
|
+
.all();
|
|
77
73
|
|
|
78
74
|
const targetCountryStats = targetLocations.length
|
|
79
75
|
? db
|
package/src/watch/db-tags.js
CHANGED
|
@@ -22,7 +22,13 @@ export function insertTag(tag, countries, source = "llm") {
|
|
|
22
22
|
return { inserted: false, error: "invalid tag" };
|
|
23
23
|
}
|
|
24
24
|
try {
|
|
25
|
-
//
|
|
25
|
+
// 先查是否已存在且为 dead 状态,如果是则重置为 new(给二次机会)
|
|
26
|
+
const existing = db
|
|
27
|
+
.prepare("SELECT status FROM tags WHERE tag = ?")
|
|
28
|
+
.get(normalized);
|
|
29
|
+
const resetDead = existing && existing.status === "dead";
|
|
30
|
+
|
|
31
|
+
// ON CONFLICT: 合并 countries 数组;如果原状态是 dead 则重置为 new
|
|
26
32
|
const result = db
|
|
27
33
|
.prepare(
|
|
28
34
|
`INSERT INTO tags (tag, countries, source) VALUES (@tag, @countries, @source)
|
|
@@ -32,9 +38,12 @@ export function insertTag(tag, countries, source = "llm") {
|
|
|
32
38
|
UNION
|
|
33
39
|
SELECT value FROM json_each(@countries)
|
|
34
40
|
)
|
|
35
|
-
)`,
|
|
41
|
+
)${resetDead ? ", status = 'new', score = NULL, last_error = NULL, dead_reason = NULL, scored_at = NULL, score_count = 0" : ""}`,
|
|
36
42
|
)
|
|
37
43
|
.run({ tag: normalized, countries: JSON.stringify(countries), source });
|
|
44
|
+
if (resetDead) {
|
|
45
|
+
console.error(`[tags] 🔄 dead tag #${normalized} 重新生成,重置为 new`);
|
|
46
|
+
}
|
|
38
47
|
return { inserted: result.changes > 0, tag: normalized };
|
|
39
48
|
} catch (e) {
|
|
40
49
|
return { inserted: false, error: e.message };
|
|
@@ -55,7 +64,8 @@ export function getTagsByStatus(
|
|
|
55
64
|
sql += " AND countries LIKE ?";
|
|
56
65
|
params.push(`%"${country}"%`);
|
|
57
66
|
}
|
|
58
|
-
sql +=
|
|
67
|
+
sql +=
|
|
68
|
+
" ORDER BY refreshed_at DESC NULLS LAST, score ASC, created_at ASC LIMIT ? OFFSET ?";
|
|
59
69
|
params.push(limit, offset);
|
|
60
70
|
const rows = db.prepare(sql).all(...params);
|
|
61
71
|
return rows.map(parseTagRow);
|
|
@@ -96,6 +106,48 @@ export function getTagStats(country = null) {
|
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
export function getRefreshStats(hours = 24) {
|
|
110
|
+
const db = getDb();
|
|
111
|
+
if (!db) return { countries: [], total: 0, refreshed: 0 };
|
|
112
|
+
|
|
113
|
+
const rows = db
|
|
114
|
+
.prepare(
|
|
115
|
+
"SELECT countries FROM tags WHERE status = 'productive' AND score > 70",
|
|
116
|
+
)
|
|
117
|
+
.all();
|
|
118
|
+
const countrySet = new Set();
|
|
119
|
+
for (const r of rows) {
|
|
120
|
+
try {
|
|
121
|
+
for (const c of JSON.parse(r.countries || "[]")) countrySet.add(c);
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const results = [];
|
|
126
|
+
let totalAll = 0;
|
|
127
|
+
let refreshedAll = 0;
|
|
128
|
+
const countryList = [...countrySet].sort();
|
|
129
|
+
|
|
130
|
+
const stmtTotal = db.prepare(
|
|
131
|
+
"SELECT COUNT(*) as cnt FROM tags WHERE status = 'productive' AND score > 70 AND countries LIKE ?",
|
|
132
|
+
);
|
|
133
|
+
const stmtRefresh = db.prepare(
|
|
134
|
+
`SELECT COUNT(*) as cnt FROM tags WHERE status = 'productive' AND score > 70 AND countries LIKE ? AND refreshed_at >= datetime('now', ?)`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
for (const c of countryList) {
|
|
138
|
+
const like = `%"${c}"%`;
|
|
139
|
+
const total = stmtTotal.get(like).cnt;
|
|
140
|
+
const refreshed = stmtRefresh.get(like, `-${hours} hours`).cnt;
|
|
141
|
+
if (total > 0) {
|
|
142
|
+
totalAll += total;
|
|
143
|
+
refreshedAll += refreshed;
|
|
144
|
+
results.push({ country: c, total, refreshed });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { countries: results, total: totalAll, refreshed: refreshedAll };
|
|
149
|
+
}
|
|
150
|
+
|
|
99
151
|
export function getTagsByCountry(country, minScore = 0) {
|
|
100
152
|
const db = getDb();
|
|
101
153
|
if (!db) return [];
|
|
@@ -233,7 +285,8 @@ export function getAllTags(limit = 200, offset = 0, country = null) {
|
|
|
233
285
|
sql += " WHERE countries LIKE ?";
|
|
234
286
|
params.push(`%"${country}"%`);
|
|
235
287
|
}
|
|
236
|
-
sql +=
|
|
288
|
+
sql +=
|
|
289
|
+
" ORDER BY refreshed_at DESC NULLS LAST, score DESC, created_at DESC LIMIT ? OFFSET ?";
|
|
237
290
|
params.push(limit, offset);
|
|
238
291
|
const rows = db.prepare(sql).all(...params);
|
|
239
292
|
return rows.map(parseTagRow);
|
|
@@ -319,6 +372,7 @@ export function insertDiscoverLog({
|
|
|
319
372
|
round,
|
|
320
373
|
strategy,
|
|
321
374
|
tagsGenerated,
|
|
375
|
+
tagsRaw,
|
|
322
376
|
tagsAdded,
|
|
323
377
|
productiveSample,
|
|
324
378
|
deadSample,
|
|
@@ -332,15 +386,16 @@ export function insertDiscoverLog({
|
|
|
332
386
|
const result = db
|
|
333
387
|
.prepare(
|
|
334
388
|
`INSERT INTO discover_log
|
|
335
|
-
(country, round, strategy, tags_generated, tags_added,
|
|
389
|
+
(country, round, strategy, tags_generated, tags_raw, tags_added,
|
|
336
390
|
productive_sample, dead_sample, avg_productive_score, avg_dead_score)
|
|
337
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
391
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
338
392
|
)
|
|
339
393
|
.run(
|
|
340
394
|
country,
|
|
341
395
|
round,
|
|
342
396
|
strategy || null,
|
|
343
397
|
JSON.stringify(tagsGenerated || []),
|
|
398
|
+
tagsRaw ? JSON.stringify(tagsRaw) : null,
|
|
344
399
|
tagsAdded || 0,
|
|
345
400
|
JSON.stringify(productiveSample || []),
|
|
346
401
|
JSON.stringify(deadSample || []),
|
|
@@ -386,6 +441,51 @@ export function getAllExistingTagNames() {
|
|
|
386
441
|
return new Set(rows.map((r) => r.tag));
|
|
387
442
|
}
|
|
388
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 获取活跃 tag 名集合(仅 productive + scored),用于全局去重
|
|
446
|
+
* 排除 dead tag,因为 insertTag 会在冲突时将 dead 重置为 new。
|
|
447
|
+
*/
|
|
448
|
+
export function getActiveTagNames() {
|
|
449
|
+
const db = getDb();
|
|
450
|
+
if (!db) return new Set();
|
|
451
|
+
const rows = db
|
|
452
|
+
.prepare("SELECT tag FROM tags WHERE status IN ('productive', 'scored')")
|
|
453
|
+
.all();
|
|
454
|
+
return new Set(rows.map((r) => r.tag));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 获取某国家最近一轮 discover 中 LLM 原始生成的全部 tag(去重前)
|
|
459
|
+
* 用于反馈给 LLM:"你上轮生成了这些,全部已被数据库覆盖,换方向"
|
|
460
|
+
*/
|
|
461
|
+
export function getLastRoundAllGeneratedTags(country) {
|
|
462
|
+
const db = getDb();
|
|
463
|
+
if (!db) return null;
|
|
464
|
+
|
|
465
|
+
const row = db
|
|
466
|
+
.prepare(
|
|
467
|
+
`SELECT tags_raw, tags_generated, tags_added FROM discover_log
|
|
468
|
+
WHERE country = ?
|
|
469
|
+
ORDER BY round DESC
|
|
470
|
+
LIMIT 1`,
|
|
471
|
+
)
|
|
472
|
+
.get(country);
|
|
473
|
+
|
|
474
|
+
if (!row) return null;
|
|
475
|
+
|
|
476
|
+
const raw = JSON.parse(row.tags_raw || "[]");
|
|
477
|
+
const survived = JSON.parse(row.tags_generated || "[]");
|
|
478
|
+
const rejected = raw.filter((t) => !survived.includes(t));
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
raw,
|
|
482
|
+
survived,
|
|
483
|
+
rejected,
|
|
484
|
+
tagsAdded: row.tags_added,
|
|
485
|
+
allRejected: row.tags_added === 0 && raw.length > 0,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
389
489
|
export function getRecentGeneratedTags(country, lookback = 10) {
|
|
390
490
|
const db = getDb();
|
|
391
491
|
if (!db) return [];
|