tt-help-cli-ycl 1.4.12 → 1.4.14
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 +10 -4
- package/src/cli/tag.js +513 -13
- package/src/lib/api-interceptor.js +2 -0
- package/src/lib/args.js +35 -0
- package/src/lib/constants.js +5 -2
- package/src/main.js +3 -0
- package/src/scraper/explore-core.js +1 -0
- package/src/watch/data-store.js +90 -4
- package/src/watch/db-columns.js +2 -0
- package/src/watch/db-crud.js +28 -0
- package/src/watch/db-schema.js +85 -0
- package/src/watch/db-tags.js +114 -0
- package/src/watch/public/app.js +80 -13
- package/src/watch/public/index.html +7 -1
- package/src/watch/public/style.css +43 -0
- package/src/watch/server.js +422 -19
- package/src/watch/tag-selflearn.js +986 -0
package/src/lib/constants.js
CHANGED
|
@@ -217,10 +217,13 @@ const HELP_TEXT = [
|
|
|
217
217
|
" -i, --interval 空闲间隔上限秒(默认: 10,实际 1~N 随机)",
|
|
218
218
|
" -s, --server 服务端地址(默认: http://127.0.0.1:3001)",
|
|
219
219
|
" -m, --max-comments 每视频最大评论数(默认: 200)",
|
|
220
|
-
"
|
|
220
|
+
" -l, --location 按国家领取任务,逗号分隔(如: DE,FR,IT)",
|
|
221
|
+
" 未指定时领取所有国家的任务",
|
|
222
|
+
" --base-port 浏览器CDP端口(默认: 6222)",
|
|
221
223
|
" --save 去重后保存到服务端,来源标记为 comment",
|
|
222
224
|
" 示例: tt-help comments",
|
|
223
|
-
" tt-help comments -p 2 -i 15 --base-port
|
|
225
|
+
" tt-help comments -p 2 -i 15 --base-port 6223",
|
|
226
|
+
" tt-help comments -l DE,FR,IT -p 5",
|
|
224
227
|
" tt-help comments <视频URL> 50 --save",
|
|
225
228
|
"",
|
|
226
229
|
" tag <标签名> [...] [选项]",
|
package/src/main.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
handleDiscover,
|
|
17
17
|
handleScore,
|
|
18
18
|
handleScoreAll,
|
|
19
|
+
handleTagRefresh,
|
|
19
20
|
} from "./cli/tag.js";
|
|
20
21
|
|
|
21
22
|
async function main() {
|
|
@@ -50,6 +51,8 @@ async function main() {
|
|
|
50
51
|
return handleScore(parsed);
|
|
51
52
|
case "tag-score-all":
|
|
52
53
|
return handleScoreAll(parsed);
|
|
54
|
+
case "tag-refresh":
|
|
55
|
+
return handleTagRefresh(parsed);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
const {
|
|
@@ -105,6 +105,7 @@ async function processExplore(page, username, options, log) {
|
|
|
105
105
|
href: topVideo.href,
|
|
106
106
|
playCount: topVideo.playCount,
|
|
107
107
|
createTime: topVideo.createTime,
|
|
108
|
+
isAd: topVideo.isAd,
|
|
108
109
|
};
|
|
109
110
|
log(
|
|
110
111
|
` 7天内 EC视频最高播放: ${topVideo.playCount} 次播放 (${recentVideos.length} 个EC候选)`,
|
package/src/watch/data-store.js
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
getJobRow,
|
|
51
51
|
getJobBaseRow,
|
|
52
52
|
getJob,
|
|
53
|
+
getBulkAuthorLocations,
|
|
53
54
|
getAllJobs,
|
|
54
55
|
getVideoRow,
|
|
55
56
|
getAllVideoRows,
|
|
@@ -96,6 +97,9 @@ import {
|
|
|
96
97
|
rawQuery,
|
|
97
98
|
normalizeTags,
|
|
98
99
|
clearTags,
|
|
100
|
+
getProductiveRefreshTask,
|
|
101
|
+
claimRefreshTag,
|
|
102
|
+
reportRefreshResult,
|
|
99
103
|
} from "./db-tags.js";
|
|
100
104
|
|
|
101
105
|
// LLM 打分
|
|
@@ -391,6 +395,7 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
391
395
|
refresh_time,
|
|
392
396
|
top_video_play_count,
|
|
393
397
|
top_video_href,
|
|
398
|
+
top_video_is_ad,
|
|
394
399
|
status,
|
|
395
400
|
sources
|
|
396
401
|
FROM jobs
|
|
@@ -490,6 +495,54 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
490
495
|
};
|
|
491
496
|
}
|
|
492
497
|
|
|
498
|
+
/**
|
|
499
|
+
* 查询已修改(modified_at IS NOT NULL)且 location_created 不在 targetLocations 中的目标商家,
|
|
500
|
+
* 按国家分组返回。
|
|
501
|
+
*/
|
|
502
|
+
function getModifiedTargetUsersOutsideLocations(targetLocations = []) {
|
|
503
|
+
if (!getDb()) return { countries: [], total: 0 };
|
|
504
|
+
|
|
505
|
+
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
506
|
+
const rows = getDb()
|
|
507
|
+
.prepare(
|
|
508
|
+
`
|
|
509
|
+
SELECT unique_id, nickname, follower_count, video_count,
|
|
510
|
+
location_created, confirmed_location, modified_at, latest_video_time, refresh_time
|
|
511
|
+
FROM jobs
|
|
512
|
+
WHERE tt_seller = 1
|
|
513
|
+
AND verified = 0
|
|
514
|
+
AND modified_at IS NOT NULL
|
|
515
|
+
AND location_created IS NOT NULL
|
|
516
|
+
AND location_created NOT IN (${placeholders})
|
|
517
|
+
ORDER BY location_created ASC, COALESCE(latest_video_time, 0) DESC
|
|
518
|
+
`,
|
|
519
|
+
)
|
|
520
|
+
.all(...targetLocations)
|
|
521
|
+
.map(mapJobRow);
|
|
522
|
+
|
|
523
|
+
const countryMap = new Map();
|
|
524
|
+
for (const row of rows) {
|
|
525
|
+
const country = row.locationCreated || "未知";
|
|
526
|
+
if (!countryMap.has(country)) {
|
|
527
|
+
countryMap.set(country, { users: [], userCount: 0 });
|
|
528
|
+
}
|
|
529
|
+
const entry = countryMap.get(country);
|
|
530
|
+
entry.users.push(row);
|
|
531
|
+
entry.userCount++;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const countries = [];
|
|
535
|
+
for (const [country, entry] of countryMap) {
|
|
536
|
+
countries.push({
|
|
537
|
+
country,
|
|
538
|
+
count: entry.userCount,
|
|
539
|
+
users: entry.users,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { total: rows.length, countries };
|
|
544
|
+
}
|
|
545
|
+
|
|
493
546
|
export function createStore(filePath, options = {}) {
|
|
494
547
|
if (!filePath) {
|
|
495
548
|
throw new Error("createStore requires an explicit .db path");
|
|
@@ -2292,6 +2345,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2292
2345
|
if (result.topRecentVideo && typeof result.topRecentVideo === "object") {
|
|
2293
2346
|
user.topVideoPlayCount = result.topRecentVideo.playCount || null;
|
|
2294
2347
|
user.topVideoHref = result.topRecentVideo.href || null;
|
|
2348
|
+
user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
|
|
2295
2349
|
}
|
|
2296
2350
|
user.sources = [...new Set([...(user.sources || []), "processed"])];
|
|
2297
2351
|
}
|
|
@@ -2518,6 +2572,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2518
2572
|
if (result.topRecentVideo && typeof result.topRecentVideo === "object") {
|
|
2519
2573
|
user.topVideoPlayCount = result.topRecentVideo.playCount || null;
|
|
2520
2574
|
user.topVideoHref = result.topRecentVideo.href || null;
|
|
2575
|
+
user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
|
|
2521
2576
|
}
|
|
2522
2577
|
const newUsers = processDiscoveredUsers(result);
|
|
2523
2578
|
const ret = updateJobInfo(uniqueId, user, false);
|
|
@@ -2543,6 +2598,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2543
2598
|
if (result.topRecentVideo && typeof result.topRecentVideo === "object") {
|
|
2544
2599
|
user.topVideoPlayCount = result.topRecentVideo.playCount || null;
|
|
2545
2600
|
user.topVideoHref = result.topRecentVideo.href || null;
|
|
2601
|
+
user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
|
|
2546
2602
|
}
|
|
2547
2603
|
const newUsers = processDiscoveredUsers(result);
|
|
2548
2604
|
return { saved: true, newUsers };
|
|
@@ -3130,20 +3186,32 @@ export function createStore(filePath, options = {}) {
|
|
|
3130
3186
|
return videos.length;
|
|
3131
3187
|
}
|
|
3132
3188
|
|
|
3133
|
-
function getPendingCommentTasks(limit) {
|
|
3189
|
+
function getPendingCommentTasks(limit, locations = null) {
|
|
3134
3190
|
if (getDb()) {
|
|
3135
3191
|
const l = Math.max(1, parseInt(limit) || 1);
|
|
3192
|
+
const normalizedLocations =
|
|
3193
|
+
Array.isArray(locations) && locations.length
|
|
3194
|
+
? locations
|
|
3195
|
+
.map((loc) => String(loc).trim().toUpperCase())
|
|
3196
|
+
.filter(Boolean)
|
|
3197
|
+
: null;
|
|
3198
|
+
|
|
3199
|
+
const locationFilter = normalizedLocations
|
|
3200
|
+
? `AND UPPER(COALESCE(location_created, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`
|
|
3201
|
+
: "";
|
|
3202
|
+
|
|
3136
3203
|
const rows = getDb()
|
|
3137
3204
|
.prepare(
|
|
3138
3205
|
`
|
|
3139
3206
|
SELECT *
|
|
3140
3207
|
FROM videos
|
|
3141
|
-
WHERE user_update_count IS NULL OR user_update_count <= 0
|
|
3208
|
+
WHERE (user_update_count IS NULL OR user_update_count <= 0)
|
|
3209
|
+
${locationFilter}
|
|
3142
3210
|
ORDER BY tt_seller DESC, registered_at ASC
|
|
3143
3211
|
LIMIT ?
|
|
3144
3212
|
`,
|
|
3145
3213
|
)
|
|
3146
|
-
.all(l);
|
|
3214
|
+
.all(...(normalizedLocations || []), l);
|
|
3147
3215
|
if (rows.length === 0) return [];
|
|
3148
3216
|
const bumpStmt = getDb().prepare(
|
|
3149
3217
|
`
|
|
@@ -3164,7 +3232,19 @@ export function createStore(filePath, options = {}) {
|
|
|
3164
3232
|
}
|
|
3165
3233
|
|
|
3166
3234
|
// 筛选待处理视频(userUpdateCount <= 0 或 null/undefined)
|
|
3167
|
-
|
|
3235
|
+
let pending = videos.filter((v) => (v.userUpdateCount || 0) <= 0);
|
|
3236
|
+
// 按 locations 过滤
|
|
3237
|
+
if (Array.isArray(locations) && locations.length > 0) {
|
|
3238
|
+
const upperLocs = locations.map((loc) =>
|
|
3239
|
+
String(loc).trim().toUpperCase(),
|
|
3240
|
+
);
|
|
3241
|
+
pending = pending.filter((v) => {
|
|
3242
|
+
const loc = String(v.locationCreated || "")
|
|
3243
|
+
.trim()
|
|
3244
|
+
.toUpperCase();
|
|
3245
|
+
return upperLocs.includes(loc);
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3168
3248
|
// ttSeller=true 优先
|
|
3169
3249
|
pending.sort((a, b) => {
|
|
3170
3250
|
if (a.ttSeller && !b.ttSeller) return -1;
|
|
@@ -3217,6 +3297,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3217
3297
|
getProcessedUsers,
|
|
3218
3298
|
getAllUsers,
|
|
3219
3299
|
getUserDbCount,
|
|
3300
|
+
getBulkAuthorLocations,
|
|
3220
3301
|
getJobsCount,
|
|
3221
3302
|
getRawJobsCount,
|
|
3222
3303
|
getPendingJobsCount,
|
|
@@ -3236,6 +3317,8 @@ export function createStore(filePath, options = {}) {
|
|
|
3236
3317
|
getRawJobsPage: getRawJobsPageFromDb,
|
|
3237
3318
|
getTargetUsers: getTargetUsersFromDb,
|
|
3238
3319
|
getTargetUsersByCountry: getTargetUsersByCountryFromDb,
|
|
3320
|
+
getModifiedTargetUsersOutsideLocations: (loc) =>
|
|
3321
|
+
getModifiedTargetUsersOutsideLocations(loc),
|
|
3239
3322
|
getStats,
|
|
3240
3323
|
getStatusGroups,
|
|
3241
3324
|
markGroupsDirty,
|
|
@@ -3286,6 +3369,9 @@ export function createStore(filePath, options = {}) {
|
|
|
3286
3369
|
getTagStats,
|
|
3287
3370
|
normalizeTags,
|
|
3288
3371
|
clearTags,
|
|
3372
|
+
getProductiveRefreshTask,
|
|
3373
|
+
claimRefreshTag,
|
|
3374
|
+
reportRefreshResult,
|
|
3289
3375
|
data,
|
|
3290
3376
|
};
|
|
3291
3377
|
|
package/src/watch/db-columns.js
CHANGED
|
@@ -48,6 +48,7 @@ export const JOB_EXT_COLUMNS = [
|
|
|
48
48
|
"user_create_time",
|
|
49
49
|
"top_video_play_count",
|
|
50
50
|
"top_video_href",
|
|
51
|
+
"top_video_is_ad",
|
|
51
52
|
];
|
|
52
53
|
|
|
53
54
|
// 完整的 jobs 列 = 核心列 + 扩展列
|
|
@@ -88,6 +89,7 @@ export const WRITABLE_JOB_COLUMNS = new Set([
|
|
|
88
89
|
"latest_video_time",
|
|
89
90
|
"top_video_play_count",
|
|
90
91
|
"top_video_href",
|
|
92
|
+
"top_video_is_ad",
|
|
91
93
|
"user_create_time",
|
|
92
94
|
]);
|
|
93
95
|
|
package/src/watch/db-crud.js
CHANGED
|
@@ -327,6 +327,34 @@ export function getJob(uniqueId) {
|
|
|
327
327
|
return mapJobRow(getJobRow(uniqueId));
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* 批量查询作者的国家信息
|
|
332
|
+
* 从 jobs 和 jobs_base 表中查找已知的 location_created
|
|
333
|
+
* @param {string[]} uniqueIds
|
|
334
|
+
* @returns {Record<string, string>} { uniqueId: locationCreated }
|
|
335
|
+
*/
|
|
336
|
+
export function getBulkAuthorLocations(uniqueIds) {
|
|
337
|
+
const db = getDb();
|
|
338
|
+
if (!db || uniqueIds.length === 0) return {};
|
|
339
|
+
// SQLite 的 IN 子句不支持数组参数,动态构建占位符
|
|
340
|
+
const placeholders = uniqueIds.map(() => "?").join(",");
|
|
341
|
+
const sql = `
|
|
342
|
+
SELECT unique_id, location_created FROM jobs
|
|
343
|
+
WHERE unique_id IN (${placeholders}) AND location_created IS NOT NULL AND location_created != ''
|
|
344
|
+
UNION
|
|
345
|
+
SELECT unique_id, location_created FROM jobs_base
|
|
346
|
+
WHERE unique_id IN (${placeholders}) AND location_created IS NOT NULL AND location_created != ''
|
|
347
|
+
`;
|
|
348
|
+
const rows = db.prepare(sql).all(...uniqueIds, ...uniqueIds);
|
|
349
|
+
const result = {};
|
|
350
|
+
for (const r of rows) {
|
|
351
|
+
if (!result[r.unique_id]) {
|
|
352
|
+
result[r.unique_id] = r.location_created;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
|
|
330
358
|
export function getAllJobs() {
|
|
331
359
|
const db = getDb();
|
|
332
360
|
if (!db) return [];
|
package/src/watch/db-schema.js
CHANGED
|
@@ -127,6 +127,7 @@ export function initDb(filePath) {
|
|
|
127
127
|
bio_link: "TEXT",
|
|
128
128
|
top_video_play_count: "INTEGER",
|
|
129
129
|
top_video_href: "TEXT",
|
|
130
|
+
top_video_is_ad: "INTEGER",
|
|
130
131
|
user_create_time: "INTEGER",
|
|
131
132
|
});
|
|
132
133
|
|
|
@@ -278,8 +279,86 @@ export function initDb(filePath) {
|
|
|
278
279
|
migrateColumns("tags", {
|
|
279
280
|
discover_round: "INTEGER DEFAULT 0",
|
|
280
281
|
strategy: "TEXT",
|
|
282
|
+
dead_reason: "TEXT",
|
|
283
|
+
direction_id: "INTEGER",
|
|
284
|
+
refreshing: "INTEGER DEFAULT 0",
|
|
285
|
+
refreshed_at: "TEXT",
|
|
281
286
|
});
|
|
282
287
|
|
|
288
|
+
// ===== tag_directions 表(分层探索:通用词/分类/产品) =====
|
|
289
|
+
db.exec(`
|
|
290
|
+
CREATE TABLE IF NOT EXISTS tag_directions (
|
|
291
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
292
|
+
name TEXT NOT NULL,
|
|
293
|
+
level INTEGER NOT NULL DEFAULT 1,
|
|
294
|
+
parent_id INTEGER,
|
|
295
|
+
country TEXT,
|
|
296
|
+
lang_group TEXT,
|
|
297
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
298
|
+
total_experiments INTEGER DEFAULT 0,
|
|
299
|
+
productive_experiments INTEGER DEFAULT 0,
|
|
300
|
+
best_score REAL DEFAULT 0,
|
|
301
|
+
notes TEXT,
|
|
302
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
303
|
+
updated_at TEXT
|
|
304
|
+
)
|
|
305
|
+
`);
|
|
306
|
+
migrateColumns("tag_directions", {
|
|
307
|
+
lang_group: "TEXT",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ===== tag_experiments 表(每次探索的实验记录) =====
|
|
311
|
+
db.exec(`
|
|
312
|
+
CREATE TABLE IF NOT EXISTS tag_experiments (
|
|
313
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
314
|
+
direction_id INTEGER,
|
|
315
|
+
country TEXT NOT NULL,
|
|
316
|
+
hypothesis TEXT,
|
|
317
|
+
strategy TEXT,
|
|
318
|
+
tags_tried TEXT NOT NULL DEFAULT '[]',
|
|
319
|
+
tags_productive TEXT NOT NULL DEFAULT '[]',
|
|
320
|
+
tags_dead_not_exist TEXT NOT NULL DEFAULT '[]',
|
|
321
|
+
tags_dead_no_match TEXT NOT NULL DEFAULT '[]',
|
|
322
|
+
conclusion TEXT,
|
|
323
|
+
productive_count INTEGER DEFAULT 0,
|
|
324
|
+
dead_count INTEGER DEFAULT 0,
|
|
325
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
326
|
+
)
|
|
327
|
+
`);
|
|
328
|
+
|
|
329
|
+
// ===== tag_analyst_reports 表(LLM 分析师定期体检报告) =====
|
|
330
|
+
db.exec(`
|
|
331
|
+
CREATE TABLE IF NOT EXISTS tag_analyst_reports (
|
|
332
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
333
|
+
report_type TEXT NOT NULL DEFAULT 'daily',
|
|
334
|
+
countries TEXT NOT NULL DEFAULT '[]',
|
|
335
|
+
summary TEXT,
|
|
336
|
+
top_directions TEXT NOT NULL DEFAULT '[]',
|
|
337
|
+
worst_directions TEXT NOT NULL DEFAULT '[]',
|
|
338
|
+
cross_country_suggestions TEXT NOT NULL DEFAULT '[]',
|
|
339
|
+
cleanup_suggestions TEXT NOT NULL DEFAULT '[]',
|
|
340
|
+
next_strategy TEXT,
|
|
341
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
342
|
+
)
|
|
343
|
+
`);
|
|
344
|
+
|
|
345
|
+
// ===== cross_country_learnings 表(跨国家经验复用记录) =====
|
|
346
|
+
db.exec(`
|
|
347
|
+
CREATE TABLE IF NOT EXISTS cross_country_learnings (
|
|
348
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
349
|
+
source_country TEXT NOT NULL,
|
|
350
|
+
source_tag TEXT NOT NULL,
|
|
351
|
+
source_direction_id INTEGER,
|
|
352
|
+
target_country TEXT NOT NULL,
|
|
353
|
+
target_tag TEXT,
|
|
354
|
+
transferred_at TEXT,
|
|
355
|
+
result_status TEXT,
|
|
356
|
+
score REAL,
|
|
357
|
+
notes TEXT,
|
|
358
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
359
|
+
)
|
|
360
|
+
`);
|
|
361
|
+
|
|
283
362
|
// ===== discover_log 表(记录每次 LLM discover 的策略和思考) =====
|
|
284
363
|
db.exec(`
|
|
285
364
|
CREATE TABLE IF NOT EXISTS discover_log (
|
|
@@ -334,6 +413,12 @@ export function initDb(filePath) {
|
|
|
334
413
|
`CREATE INDEX IF NOT EXISTS idx_tags_score ON tags(score DESC)`,
|
|
335
414
|
`CREATE INDEX IF NOT EXISTS idx_discover_log_country ON discover_log(country, round DESC)`,
|
|
336
415
|
`CREATE INDEX IF NOT EXISTS idx_reflection_rules_active ON reflection_rules(country, is_active)`,
|
|
416
|
+
`CREATE INDEX IF NOT EXISTS idx_tag_directions_country ON tag_directions(country, level)`,
|
|
417
|
+
`CREATE INDEX IF NOT EXISTS idx_tag_experiments_country ON tag_experiments(country, created_at DESC)`,
|
|
418
|
+
`CREATE INDEX IF NOT EXISTS idx_tag_experiments_direction ON tag_experiments(direction_id)`,
|
|
419
|
+
`CREATE INDEX IF NOT EXISTS idx_cross_country_source ON cross_country_learnings(source_country, result_status)`,
|
|
420
|
+
`CREATE INDEX IF NOT EXISTS idx_cross_country_target ON cross_country_learnings(target_country)`,
|
|
421
|
+
`CREATE INDEX IF NOT EXISTS idx_tags_dead_reason ON tags(dead_reason)`,
|
|
337
422
|
];
|
|
338
423
|
for (const sql of indexes) {
|
|
339
424
|
db.exec(sql);
|
package/src/watch/db-tags.js
CHANGED
|
@@ -171,6 +171,23 @@ export function reportTagScore(tag, fields) {
|
|
|
171
171
|
: null;
|
|
172
172
|
const now = new Date().toISOString();
|
|
173
173
|
|
|
174
|
+
// 自动分类 dead_reason
|
|
175
|
+
let deadReason = null;
|
|
176
|
+
if (status === "dead" && error) {
|
|
177
|
+
const errLower = error.toLowerCase();
|
|
178
|
+
if (
|
|
179
|
+
errLower.includes("no videos found") ||
|
|
180
|
+
errLower.includes("tag_not_found") ||
|
|
181
|
+
errLower.includes("找不到")
|
|
182
|
+
) {
|
|
183
|
+
deadReason = "not_exist";
|
|
184
|
+
} else if ((matchedAuthors ?? 0) === 0) {
|
|
185
|
+
deadReason = "no_match";
|
|
186
|
+
} else {
|
|
187
|
+
deadReason = "wrong_country";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
try {
|
|
175
192
|
const result = db
|
|
176
193
|
.prepare(
|
|
@@ -183,6 +200,7 @@ export function reportTagScore(tag, fields) {
|
|
|
183
200
|
matched_countries = COALESCE(?, matched_countries),
|
|
184
201
|
pushed_users = COALESCE(?, pushed_users),
|
|
185
202
|
last_error = COALESCE(?, last_error),
|
|
203
|
+
dead_reason = COALESCE(?, dead_reason),
|
|
186
204
|
scored_at = ?,
|
|
187
205
|
score_count = score_count + 1
|
|
188
206
|
WHERE tag = ?`,
|
|
@@ -196,6 +214,7 @@ export function reportTagScore(tag, fields) {
|
|
|
196
214
|
matchedCountriesJson,
|
|
197
215
|
pushedUsers ?? null,
|
|
198
216
|
error ?? null,
|
|
217
|
+
deadReason,
|
|
199
218
|
now,
|
|
200
219
|
tag,
|
|
201
220
|
);
|
|
@@ -1215,3 +1234,98 @@ function getLang(country) {
|
|
|
1215
1234
|
};
|
|
1216
1235
|
return COUNTRY_LANG[country] || "en";
|
|
1217
1236
|
}
|
|
1237
|
+
|
|
1238
|
+
// ===== Tag 刷新 CRUD(productive 标签轮询重新抓取) =====
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* 获取一个待刷新的 productive 标签(score > 70 且未被刷新锁定)
|
|
1242
|
+
* 按 refreshed_at ASC 排序(最久未刷新的优先),NULL 排最前
|
|
1243
|
+
*/
|
|
1244
|
+
export function getProductiveRefreshTask() {
|
|
1245
|
+
const db = getDb();
|
|
1246
|
+
if (!db) return null;
|
|
1247
|
+
|
|
1248
|
+
// 先清理超时的 refreshing 锁(超过 30 分钟未释放)
|
|
1249
|
+
db.prepare(
|
|
1250
|
+
`UPDATE tags SET refreshing = 0
|
|
1251
|
+
WHERE refreshing = 1 AND refreshed_at IS NOT NULL
|
|
1252
|
+
AND refreshed_at < datetime('now', '-30 minutes')`,
|
|
1253
|
+
).run();
|
|
1254
|
+
|
|
1255
|
+
const row = db
|
|
1256
|
+
.prepare(
|
|
1257
|
+
`SELECT * FROM tags
|
|
1258
|
+
WHERE status = 'productive' AND score > 70 AND refreshing = 0
|
|
1259
|
+
ORDER BY refreshed_at IS NULL DESC, refreshed_at ASC
|
|
1260
|
+
LIMIT 1`,
|
|
1261
|
+
)
|
|
1262
|
+
.get();
|
|
1263
|
+
return row ? parseTagRow(row) : null;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* 锁定一个 productive 标签用于刷新(防并发冲突)
|
|
1268
|
+
*/
|
|
1269
|
+
export function claimRefreshTag(tag) {
|
|
1270
|
+
const db = getDb();
|
|
1271
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
1272
|
+
|
|
1273
|
+
const normalized = tag.replace(/^#+/, "").trim().toLowerCase();
|
|
1274
|
+
if (!normalized || normalized.length < 2) {
|
|
1275
|
+
return { ok: false, error: "invalid tag" };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// 同时尝试匹配带 # 和不带 # 的格式(兼容旧数据)
|
|
1279
|
+
const result = db
|
|
1280
|
+
.prepare(
|
|
1281
|
+
`UPDATE tags SET refreshing = 1, refreshed_at = datetime('now')
|
|
1282
|
+
WHERE (tag = ? OR tag = ?) AND status = 'productive' AND score > 70 AND refreshing = 0`,
|
|
1283
|
+
)
|
|
1284
|
+
.run(normalized, `#${normalized}`);
|
|
1285
|
+
if (result.changes === 0) {
|
|
1286
|
+
const row = db
|
|
1287
|
+
.prepare("SELECT status, refreshing FROM tags WHERE tag = ? OR tag = ?")
|
|
1288
|
+
.get(normalized, `#${normalized}`);
|
|
1289
|
+
if (!row) return { ok: false, error: "tag not found" };
|
|
1290
|
+
if (row.refreshing) return { ok: false, error: "already being refreshed" };
|
|
1291
|
+
return {
|
|
1292
|
+
ok: false,
|
|
1293
|
+
error: `tag status is ${row.status}, not eligible for refresh`,
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
return { ok: true, tag: normalized };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* 报告刷新结果并解锁标签(不改变 score/status,只更新 refreshed_at 和 pushed_users)
|
|
1301
|
+
*/
|
|
1302
|
+
export function reportRefreshResult(tag, fields) {
|
|
1303
|
+
const db = getDb();
|
|
1304
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
1305
|
+
const { pushedUsers, error } = fields;
|
|
1306
|
+
const now = new Date().toISOString();
|
|
1307
|
+
const normalized = tag.replace(/^#+/, "").trim().toLowerCase();
|
|
1308
|
+
|
|
1309
|
+
try {
|
|
1310
|
+
const result = db
|
|
1311
|
+
.prepare(
|
|
1312
|
+
`UPDATE tags SET
|
|
1313
|
+
refreshing = 0,
|
|
1314
|
+
refreshed_at = ?,
|
|
1315
|
+
pushed_users = COALESCE(pushed_users, 0) + COALESCE(?, 0),
|
|
1316
|
+
last_error = COALESCE(?, last_error),
|
|
1317
|
+
score_count = score_count + 1
|
|
1318
|
+
WHERE tag = ? OR tag = ?`,
|
|
1319
|
+
)
|
|
1320
|
+
.run(
|
|
1321
|
+
now,
|
|
1322
|
+
pushedUsers ?? null,
|
|
1323
|
+
error ?? null,
|
|
1324
|
+
normalized,
|
|
1325
|
+
`#${normalized}`,
|
|
1326
|
+
);
|
|
1327
|
+
return { ok: result.changes > 0, tag: normalized };
|
|
1328
|
+
} catch (e) {
|
|
1329
|
+
return { ok: false, error: e.message };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
package/src/watch/public/app.js
CHANGED
|
@@ -735,6 +735,9 @@ async function selectLocation(uniqueId, location) {
|
|
|
735
735
|
userInMem.locationCreated = location;
|
|
736
736
|
userInMem.modifiedAt = Date.now();
|
|
737
737
|
}
|
|
738
|
+
|
|
739
|
+
// 刷新"其他国家"区域(修改后可能需要更新统计)
|
|
740
|
+
fetchTargetOtherCountries();
|
|
738
741
|
} catch (e) {
|
|
739
742
|
showToast("更新失败: " + e.message, true);
|
|
740
743
|
} finally {
|
|
@@ -1023,6 +1026,30 @@ document.getElementById("statTargetCard").addEventListener("click", () => {
|
|
|
1023
1026
|
window.addEventListener("hashchange", handleRoute);
|
|
1024
1027
|
window.addEventListener("DOMContentLoaded", handleRoute);
|
|
1025
1028
|
|
|
1029
|
+
// 回顶部按钮:仅在 targetPage 激活时监听滚动
|
|
1030
|
+
window.addEventListener(
|
|
1031
|
+
"scroll",
|
|
1032
|
+
function () {
|
|
1033
|
+
const btn = document.getElementById("backToTop");
|
|
1034
|
+
if (!btn) return;
|
|
1035
|
+
const targetPage = document.getElementById("targetPage");
|
|
1036
|
+
if (!targetPage || !targetPage.classList.contains("active")) {
|
|
1037
|
+
btn.classList.remove("visible");
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (window.scrollY > 400) {
|
|
1041
|
+
btn.classList.add("visible");
|
|
1042
|
+
} else {
|
|
1043
|
+
btn.classList.remove("visible");
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
{ passive: true },
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
function scrollToTop() {
|
|
1050
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1026
1053
|
function handleRoute() {
|
|
1027
1054
|
const hash = window.location.hash;
|
|
1028
1055
|
if (hash === "#pending") {
|
|
@@ -1410,18 +1437,20 @@ function renderTagsTable() {
|
|
|
1410
1437
|
const created = t.created_at || "-";
|
|
1411
1438
|
const score = t.score != null ? t.score.toFixed(1) : "-";
|
|
1412
1439
|
const authorCount = t.author_count ?? "-";
|
|
1413
|
-
const totalPosts = t.total_posts ?? "-";
|
|
1414
1440
|
const matchedAuthors = t.matched_authors ?? "-";
|
|
1415
1441
|
const pushedUsers = t.pushed_users ?? "-";
|
|
1442
|
+
const refreshedAt = t.refreshed_at
|
|
1443
|
+
? t.refreshed_at.slice(0, 16).replace("T", " ")
|
|
1444
|
+
: "-";
|
|
1416
1445
|
|
|
1417
1446
|
return `<tr>
|
|
1418
1447
|
<td style="color:#9ca3af;font-size:12px;text-align:center">${i + 1}</td>
|
|
1419
1448
|
<td style="font-weight:600;color:#e0e0e0">#${escapeHtml(t.tag)}</td>
|
|
1420
|
-
<td style="color:${t.score >= 5 ? "#22c55e" : t.score >= 3 ? "#facc15" : "#888"}">${score}</td>
|
|
1421
|
-
<td>${authorCount}</td>
|
|
1422
|
-
<td>${
|
|
1423
|
-
<td>${matchedAuthors}</td>
|
|
1424
|
-
<td>${pushedUsers}</td>
|
|
1449
|
+
<td style="text-align:center;color:${t.score >= 5 ? "#22c55e" : t.score >= 3 ? "#facc15" : "#888"}">${score}</td>
|
|
1450
|
+
<td style="text-align:center">${authorCount}</td>
|
|
1451
|
+
<td style="text-align:center;font-size:11px;color:#9ca3af">${refreshedAt}</td>
|
|
1452
|
+
<td style="text-align:center">${matchedAuthors}</td>
|
|
1453
|
+
<td style="text-align:center">${pushedUsers}</td>
|
|
1425
1454
|
<td style="font-size:11px">${countries}</td>
|
|
1426
1455
|
<td>${statusTag}</td>
|
|
1427
1456
|
<td style="font-size:11px;color:#888">${source}</td>
|
|
@@ -1517,6 +1546,7 @@ function showTargetPage() {
|
|
|
1517
1546
|
document.getElementById("tagsPage").classList.remove("active");
|
|
1518
1547
|
showLoading("正在加载目标商家数据...");
|
|
1519
1548
|
fetchTargetByCountry();
|
|
1549
|
+
fetchTargetOtherCountries();
|
|
1520
1550
|
// 同步统计
|
|
1521
1551
|
if (currentStats) {
|
|
1522
1552
|
document.getElementById("targetPageStatTotal").textContent = formatStatNum(
|
|
@@ -1535,6 +1565,44 @@ let currentTargetSeq = 0;
|
|
|
1535
1565
|
let currentTargetSort = { key: null, asc: true };
|
|
1536
1566
|
const TARGET_PAGE_SIZE = 200;
|
|
1537
1567
|
|
|
1568
|
+
async function fetchTargetOtherCountries() {
|
|
1569
|
+
try {
|
|
1570
|
+
const res = await fetch("/api/target-users-other-countries");
|
|
1571
|
+
const data = await res.json();
|
|
1572
|
+
renderTargetOtherCountries(data.countries || []);
|
|
1573
|
+
} catch (e) {
|
|
1574
|
+
console.error("获取其他国家数据失败:", e);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function renderTargetOtherCountries(countries) {
|
|
1579
|
+
const section = document.getElementById("targetOtherSection");
|
|
1580
|
+
const grid = document.getElementById("targetOtherGrid");
|
|
1581
|
+
if (!section || !grid) return;
|
|
1582
|
+
|
|
1583
|
+
if (!countries.length) {
|
|
1584
|
+
section.style.display = "none";
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
section.style.display = "block";
|
|
1588
|
+
const total = countries.reduce((sum, c) => sum + (c.count || 0), 0);
|
|
1589
|
+
grid.innerHTML = countries
|
|
1590
|
+
.map((c) => {
|
|
1591
|
+
const cnt = c.count || 0;
|
|
1592
|
+
const pct = total > 0 ? ((cnt / total) * 100).toFixed(1) : "0.0";
|
|
1593
|
+
const safeCountry = escapeJsString(c.country);
|
|
1594
|
+
const sel = currentTargetLocation === c.country ? " selected" : "";
|
|
1595
|
+
return `
|
|
1596
|
+
<div class="pending-country-item other-country-item${sel}"
|
|
1597
|
+
onclick="filterTargetByCountry('${safeCountry}')">
|
|
1598
|
+
<div class="country-name">${c.country}</div>
|
|
1599
|
+
<div class="country-count">${cnt}</div>
|
|
1600
|
+
<div class="country-label">${pct}%</div>
|
|
1601
|
+
</div>`;
|
|
1602
|
+
})
|
|
1603
|
+
.join("");
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1538
1606
|
async function fetchTargetByCountry() {
|
|
1539
1607
|
try {
|
|
1540
1608
|
const res = await fetch("/api/target-users-by-country?summary=1");
|
|
@@ -1712,9 +1780,12 @@ function renderTargetTable() {
|
|
|
1712
1780
|
u.topVideoPlayCount != null && u.topVideoPlayCount >= 1000
|
|
1713
1781
|
? formatNum(u.topVideoPlayCount)
|
|
1714
1782
|
: "-";
|
|
1783
|
+
const adTag = u.topVideoIsAd
|
|
1784
|
+
? ' <span style="color:red">(AD)</span>'
|
|
1785
|
+
: "";
|
|
1715
1786
|
const topPlayCountCell = u.topVideoHref
|
|
1716
|
-
? `<td data-label="最大播放量" style="font-size:11px;color:#888"><a href="${u.topVideoHref}" target="_blank" style="color:#3b82f6;text-decoration:none" title="点击查看视频">${topPlayCount}</a></td>`
|
|
1717
|
-
: `<td data-label="最大播放量" style="font-size:11px;color:#888">${topPlayCount}</td>`;
|
|
1787
|
+
? `<td data-label="最大播放量" style="font-size:11px;color:#888"><a href="${u.topVideoHref}" target="_blank" style="color:#3b82f6;text-decoration:none" title="点击查看视频">${topPlayCount}${adTag}</a></td>`
|
|
1788
|
+
: `<td data-label="最大播放量" style="font-size:11px;color:#888">${topPlayCount}${adTag}</td>`;
|
|
1718
1789
|
|
|
1719
1790
|
const editIcon = ' <span style="font-size:10px;opacity:0.5">✏️</span>';
|
|
1720
1791
|
const locationCell = u.modifiedAt
|
|
@@ -1782,13 +1853,9 @@ function filterTargetByCountry(country) {
|
|
|
1782
1853
|
}
|
|
1783
1854
|
|
|
1784
1855
|
function clearTargetFilters() {
|
|
1785
|
-
|
|
1856
|
+
// 只清空搜索关键词,保留当前选中的国家筛选
|
|
1786
1857
|
const searchInput = document.getElementById("targetSearchInput");
|
|
1787
|
-
const locationFilter = document.getElementById("targetLocationFilter");
|
|
1788
1858
|
if (searchInput) searchInput.value = "";
|
|
1789
|
-
if (locationFilter) locationFilter.value = "";
|
|
1790
|
-
const btn = document.getElementById("targetReprocessBtn");
|
|
1791
|
-
if (btn) btn.style.display = "none";
|
|
1792
1859
|
if (currentTargetSummary) {
|
|
1793
1860
|
renderTargetCountryGrid(currentTargetSummary.countries || []);
|
|
1794
1861
|
loadTargetUsersPage();
|