tt-help-cli-ycl 1.4.21 → 1.4.23
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 +63 -1
- package/src/cli/refresh.js +1 -1
- package/src/lib/parse-ssr.mjs +6 -0
- package/src/lib/tag-discover.js +8 -4
- package/src/lib/tag-fetcher.js +5 -0
- package/src/scraper/explore-core.js +14 -4
- package/src/watch/data-store.js +80 -1
- package/src/watch/db-columns.js +2 -0
- package/src/watch/db-schema.js +1 -0
- package/src/watch/db-tags.js +237 -17
- package/src/watch/llm-config.js +319 -0
- package/src/watch/llm-scoring.js +103 -63
- package/src/watch/public/app.js +248 -23
- package/src/watch/public/index.html +39 -0
- package/src/watch/public/style.css +154 -0
- package/src/watch/server.js +109 -6
- package/src/watch/tag-service.js +119 -41
package/package.json
CHANGED
package/src/cli/comments.js
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from "../scraper/modules/captcha-handler.js";
|
|
36
36
|
import { detectModals, closeAllModals } from "../lib/modal-handler.js";
|
|
37
37
|
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
38
|
+
import { parseVideoInfo } from "../lib/parse-ssr.mjs";
|
|
38
39
|
|
|
39
40
|
// ────────────────────────────────────────
|
|
40
41
|
// Section 1: 常量 & 配置默认值
|
|
@@ -492,6 +493,49 @@ function extractVideoInfo() {
|
|
|
492
493
|
return result;
|
|
493
494
|
}
|
|
494
495
|
|
|
496
|
+
/**
|
|
497
|
+
* 从视频页 SSR 数据中提取 challenges(标签/hashtags)
|
|
498
|
+
* @param {Page} page - Playwright page
|
|
499
|
+
* @returns {Promise<{ tagNames: string[], challenges: Array }>}
|
|
500
|
+
*/
|
|
501
|
+
async function extractVideoTags(page) {
|
|
502
|
+
try {
|
|
503
|
+
const html = await page.evaluate(() => document.documentElement.outerHTML);
|
|
504
|
+
const videoInfo = parseVideoInfo(html);
|
|
505
|
+
if (
|
|
506
|
+
!videoInfo ||
|
|
507
|
+
!videoInfo.challenges ||
|
|
508
|
+
videoInfo.challenges.length === 0
|
|
509
|
+
) {
|
|
510
|
+
return { tagNames: [], challenges: [] };
|
|
511
|
+
}
|
|
512
|
+
const tagNames = videoInfo.challenges.map((c) => c.title).filter(Boolean);
|
|
513
|
+
return { tagNames, challenges: videoInfo.challenges };
|
|
514
|
+
} catch (e) {
|
|
515
|
+
console.error(` [tags] 提取失败: ${e.message}`);
|
|
516
|
+
return { tagNames: [], challenges: [] };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* 将视频 tag 保存到服务端(只插入不存在的,不覆盖已有)
|
|
522
|
+
* @returns {Promise<{ inserted: number }>}
|
|
523
|
+
*/
|
|
524
|
+
async function saveVideoTags(apiPost, serverUrl, tagNames, country) {
|
|
525
|
+
if (!tagNames || tagNames.length === 0) return { inserted: 0 };
|
|
526
|
+
try {
|
|
527
|
+
const result = await apiPost(`${serverUrl}/api/tags/batch-add-if-new`, {
|
|
528
|
+
tags: tagNames,
|
|
529
|
+
countries: [country],
|
|
530
|
+
source: "video",
|
|
531
|
+
});
|
|
532
|
+
return { inserted: result.inserted || 0 };
|
|
533
|
+
} catch (e) {
|
|
534
|
+
console.error(` [tags] 保存失败: ${e.message}`);
|
|
535
|
+
return { inserted: 0 };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
495
539
|
function extractUniqueAuthors(comments) {
|
|
496
540
|
return [
|
|
497
541
|
...new Set(
|
|
@@ -668,7 +712,7 @@ async function runAutoMode(options) {
|
|
|
668
712
|
if (shuttingDown) break;
|
|
669
713
|
|
|
670
714
|
try {
|
|
671
|
-
const { id, href, locationCreated } = task;
|
|
715
|
+
const { id, href, locationCreated, ttSeller } = task;
|
|
672
716
|
const loc = normalizeLocation(locationCreated);
|
|
673
717
|
const taskNum = totalTasks() + 1;
|
|
674
718
|
|
|
@@ -691,6 +735,24 @@ async function runAutoMode(options) {
|
|
|
691
735
|
// 导航到视频页
|
|
692
736
|
await prepareVideoPage(session.page, href);
|
|
693
737
|
|
|
738
|
+
// ── 提取视频 tag(仅 ttSeller 视频)──
|
|
739
|
+
if (ttSeller && loc) {
|
|
740
|
+
const { tagNames } = await extractVideoTags(session.page);
|
|
741
|
+
if (tagNames.length > 0) {
|
|
742
|
+
const { inserted } = await saveVideoTags(
|
|
743
|
+
apiPost,
|
|
744
|
+
serverUrl,
|
|
745
|
+
tagNames,
|
|
746
|
+
loc,
|
|
747
|
+
);
|
|
748
|
+
if (inserted > 0) {
|
|
749
|
+
console.error(
|
|
750
|
+
` [tags] 提取到 ${tagNames.length} 个: ${tagNames.join(", ")} → 新增 ${inserted} 个`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
694
756
|
// 获取评论
|
|
695
757
|
const result = await fetchUserCommentsAPI(session.page, {
|
|
696
758
|
maxComments: actualMaxComments,
|
package/src/cli/refresh.js
CHANGED
|
@@ -541,7 +541,7 @@ export async function handleRefresh(options) {
|
|
|
541
541
|
|
|
542
542
|
const payload = {
|
|
543
543
|
userInfo: refreshUserInfo,
|
|
544
|
-
|
|
544
|
+
recentEcVideos: result.recentEcVideos || null,
|
|
545
545
|
discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
|
|
546
546
|
handle: Array.isArray(f) ? f[0] : f,
|
|
547
547
|
displayName: Array.isArray(f) ? f[1] : null,
|
package/src/lib/parse-ssr.mjs
CHANGED
|
@@ -138,11 +138,17 @@ export function parseVideoInfo(rawHtml) {
|
|
|
138
138
|
const item = vd.itemInfo.itemStruct;
|
|
139
139
|
const author = item.author || {};
|
|
140
140
|
const stats = item.stats || {};
|
|
141
|
+
const challenges = (item.challenges || []).map((c) => ({
|
|
142
|
+
id: c.id,
|
|
143
|
+
title: c.title,
|
|
144
|
+
desc: c.desc || "",
|
|
145
|
+
}));
|
|
141
146
|
return {
|
|
142
147
|
id: item.id,
|
|
143
148
|
desc: item.desc || "",
|
|
144
149
|
createTime: item.createTime || null,
|
|
145
150
|
locationCreated: item.locationCreated || null,
|
|
151
|
+
challenges,
|
|
146
152
|
author: {
|
|
147
153
|
uniqueId: author.uniqueId,
|
|
148
154
|
nickname: author.nickname,
|
package/src/lib/tag-discover.js
CHANGED
|
@@ -158,11 +158,15 @@ async function discoverTagsForCountryCli(
|
|
|
158
158
|
` 🏥 ${country} health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%\n`,
|
|
159
159
|
);
|
|
160
160
|
|
|
161
|
-
//
|
|
162
|
-
if (health.
|
|
163
|
-
temperature = 0
|
|
164
|
-
} else if (health.
|
|
161
|
+
// 动态温度调整:卡死时升温打破 LLM 确定性重复
|
|
162
|
+
if (health.consecutiveLowProductiveRounds >= 3) {
|
|
163
|
+
temperature = 1.0;
|
|
164
|
+
} else if (health.consecutiveLowProductiveRounds >= 2) {
|
|
165
|
+
temperature = 0.9;
|
|
166
|
+
} else if (health.healthStatus === "critical") {
|
|
165
167
|
temperature = 0.5;
|
|
168
|
+
} else if (health.healthStatus === "warning") {
|
|
169
|
+
temperature = 0.6;
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
// 自动 niche 切换
|
package/src/lib/tag-fetcher.js
CHANGED
|
@@ -166,6 +166,11 @@ export async function fetchTagData(tag, options = {}) {
|
|
|
166
166
|
throw new Error("标签页加载失败,TikTok 返回了错误页面");
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// 检测 CDN/网络拦截:15 秒后仍无任何 API 数据到达,说明被拦截而非真没数据
|
|
170
|
+
if (seenVideoIds.size === 0 && challengeInfo === null) {
|
|
171
|
+
throw new Error("无法获取标签页 API 数据(可能被 CDN 拦截或网络异常)");
|
|
172
|
+
}
|
|
173
|
+
|
|
169
174
|
let lastCount = 0;
|
|
170
175
|
let staleRounds = 0;
|
|
171
176
|
const startTime = Date.now();
|
|
@@ -42,7 +42,7 @@ async function processExplore(page, username, options, log) {
|
|
|
42
42
|
keepFollow: false,
|
|
43
43
|
locationCreated: null,
|
|
44
44
|
latestVideoTime: null,
|
|
45
|
-
|
|
45
|
+
recentEcVideos: null,
|
|
46
46
|
noVideo: false,
|
|
47
47
|
restricted: false,
|
|
48
48
|
error: null,
|
|
@@ -97,6 +97,19 @@ async function processExplore(page, username, options, log) {
|
|
|
97
97
|
nowSeconds - v.createTime <= SEVEN_DAYS_SECONDS,
|
|
98
98
|
);
|
|
99
99
|
if (recentVideos.length > 0) {
|
|
100
|
+
result.recentEcVideos = recentVideos.map((v) => ({
|
|
101
|
+
id: v.id,
|
|
102
|
+
href: v.href,
|
|
103
|
+
playCount: v.playCount,
|
|
104
|
+
createTime: v.createTime,
|
|
105
|
+
isAd: v.isAd,
|
|
106
|
+
}));
|
|
107
|
+
const maxPlay = recentVideos.reduce(
|
|
108
|
+
(max, v) => (v.playCount > max ? v.playCount : max),
|
|
109
|
+
0,
|
|
110
|
+
);
|
|
111
|
+
log(` 7天内 EC视频: ${recentVideos.length} 个, 最高播放 ${maxPlay}`);
|
|
112
|
+
// 兼容旧字段 topRecentVideo
|
|
100
113
|
const topVideo = recentVideos.reduce((max, v) =>
|
|
101
114
|
v.playCount > max.playCount ? v : max,
|
|
102
115
|
);
|
|
@@ -107,9 +120,6 @@ async function processExplore(page, username, options, log) {
|
|
|
107
120
|
createTime: topVideo.createTime,
|
|
108
121
|
isAd: topVideo.isAd,
|
|
109
122
|
};
|
|
110
|
-
log(
|
|
111
|
-
` 7天内 EC视频最高播放: ${topVideo.playCount} 次播放 (${recentVideos.length} 个EC候选)`,
|
|
112
|
-
);
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
if (videoArray.length <= 0) {
|
package/src/watch/data-store.js
CHANGED
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
// Tag CRUD
|
|
87
87
|
import {
|
|
88
88
|
insertTag,
|
|
89
|
+
insertTagsIfNew,
|
|
89
90
|
getTagsByStatus,
|
|
90
91
|
getTagsByCountry,
|
|
91
92
|
getDeadTags,
|
|
@@ -319,7 +320,9 @@ function getTargetUsersFromDb(targetLocations = []) {
|
|
|
319
320
|
tt_seller, verified, follower_count, following_count,
|
|
320
321
|
location_created, latest_video_time, refresh_time,
|
|
321
322
|
guessed_location, pinned, processed_at, video_count,
|
|
322
|
-
no_video, claimed_by, claimed_at, created_at, updated_at
|
|
323
|
+
no_video, claimed_by, claimed_at, created_at, updated_at,
|
|
324
|
+
top_video_play_count, top_video_href, top_video_is_ad,
|
|
325
|
+
recent_ec_videos
|
|
323
326
|
FROM jobs
|
|
324
327
|
WHERE tt_seller = 1
|
|
325
328
|
AND verified = 0
|
|
@@ -348,6 +351,8 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
348
351
|
search,
|
|
349
352
|
limit,
|
|
350
353
|
offset,
|
|
354
|
+
sort,
|
|
355
|
+
sortOrder,
|
|
351
356
|
} = options;
|
|
352
357
|
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
353
358
|
const baseParams = [...targetLocations];
|
|
@@ -400,6 +405,7 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
400
405
|
top_video_play_count,
|
|
401
406
|
top_video_href,
|
|
402
407
|
top_video_is_ad,
|
|
408
|
+
recent_ec_videos,
|
|
403
409
|
status,
|
|
404
410
|
sources
|
|
405
411
|
FROM jobs
|
|
@@ -440,6 +446,50 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
440
446
|
.prepare(countSql)
|
|
441
447
|
.get(...params)?.cnt || 0;
|
|
442
448
|
|
|
449
|
+
// 服务端排序:当 sort=topVideoPlayCount 时,全量取出后按 EC 最大播放量排序再分页
|
|
450
|
+
if (sort === "topVideoPlayCount") {
|
|
451
|
+
const allRows = getDb()
|
|
452
|
+
.prepare(sql)
|
|
453
|
+
.all(...params)
|
|
454
|
+
.map(mapJobRow);
|
|
455
|
+
|
|
456
|
+
const getMaxPlay = (u) => {
|
|
457
|
+
try {
|
|
458
|
+
const ec =
|
|
459
|
+
typeof u.recentEcVideos === "string"
|
|
460
|
+
? JSON.parse(u.recentEcVideos)
|
|
461
|
+
: Array.isArray(u.recentEcVideos)
|
|
462
|
+
? u.recentEcVideos
|
|
463
|
+
: [];
|
|
464
|
+
if (ec.length > 0) {
|
|
465
|
+
return ec.reduce(
|
|
466
|
+
(max, v) => (v.playCount > max ? v.playCount : max),
|
|
467
|
+
0,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
} catch (e) {}
|
|
471
|
+
return 0;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const isAsc = sortOrder === "asc";
|
|
475
|
+
allRows.sort((a, b) => {
|
|
476
|
+
const va = getMaxPlay(a);
|
|
477
|
+
const vb = getMaxPlay(b);
|
|
478
|
+
return isAsc ? va - vb : vb - va;
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const safeLimit = Math.min(Math.floor(limit), 10000);
|
|
482
|
+
const safeOffset = Math.max(Math.floor(offset), 0);
|
|
483
|
+
const rows = allRows.slice(safeOffset, safeOffset + safeLimit);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
total,
|
|
487
|
+
limit: safeLimit,
|
|
488
|
+
offset: safeOffset,
|
|
489
|
+
users: rows,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
443
493
|
sql += ` LIMIT ? OFFSET ?`;
|
|
444
494
|
const safeLimit = Math.min(Math.floor(limit), 10000);
|
|
445
495
|
const safeOffset = Math.max(Math.floor(offset), 0);
|
|
@@ -2413,6 +2463,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2413
2463
|
"uniqueId",
|
|
2414
2464
|
"sources",
|
|
2415
2465
|
"topRecentVideo", // 单独处理,不进入通用循环
|
|
2466
|
+
"recentEcVideos", // 单独处理,不进入通用循环
|
|
2416
2467
|
];
|
|
2417
2468
|
for (const key of Object.keys(result)) {
|
|
2418
2469
|
if (extraFields.includes(key)) continue;
|
|
@@ -2430,6 +2481,15 @@ export function createStore(filePath, options = {}) {
|
|
|
2430
2481
|
user.topVideoHref = result.topRecentVideo.href || null;
|
|
2431
2482
|
user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
|
|
2432
2483
|
}
|
|
2484
|
+
// 将 recentEcVideos 数组序列化为 JSON 字符串
|
|
2485
|
+
if (
|
|
2486
|
+
Array.isArray(result.recentEcVideos) &&
|
|
2487
|
+
result.recentEcVideos.length > 0
|
|
2488
|
+
) {
|
|
2489
|
+
user.recentEcVideos = JSON.stringify(result.recentEcVideos);
|
|
2490
|
+
} else if (result.recentEcVideos === null) {
|
|
2491
|
+
user.recentEcVideos = null;
|
|
2492
|
+
}
|
|
2433
2493
|
user.sources = [...new Set([...(user.sources || []), "processed"])];
|
|
2434
2494
|
}
|
|
2435
2495
|
if (user.status !== oldStatus) markStatsDirty();
|
|
@@ -2657,6 +2717,15 @@ export function createStore(filePath, options = {}) {
|
|
|
2657
2717
|
user.topVideoHref = result.topRecentVideo.href || null;
|
|
2658
2718
|
user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
|
|
2659
2719
|
}
|
|
2720
|
+
// 将 recentEcVideos 数组序列化为 JSON 字符串
|
|
2721
|
+
if (
|
|
2722
|
+
Array.isArray(result.recentEcVideos) &&
|
|
2723
|
+
result.recentEcVideos.length > 0
|
|
2724
|
+
) {
|
|
2725
|
+
user.recentEcVideos = JSON.stringify(result.recentEcVideos);
|
|
2726
|
+
} else if (result.recentEcVideos === null) {
|
|
2727
|
+
user.recentEcVideos = null;
|
|
2728
|
+
}
|
|
2660
2729
|
const newUsers = processDiscoveredUsers(result);
|
|
2661
2730
|
const ret = updateJobInfo(uniqueId, user, false);
|
|
2662
2731
|
if (ret.error) return { saved: false, error: ret.error };
|
|
@@ -2683,6 +2752,15 @@ export function createStore(filePath, options = {}) {
|
|
|
2683
2752
|
user.topVideoHref = result.topRecentVideo.href || null;
|
|
2684
2753
|
user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
|
|
2685
2754
|
}
|
|
2755
|
+
// 将 recentEcVideos 数组序列化为 JSON 字符串
|
|
2756
|
+
if (
|
|
2757
|
+
Array.isArray(result.recentEcVideos) &&
|
|
2758
|
+
result.recentEcVideos.length > 0
|
|
2759
|
+
) {
|
|
2760
|
+
user.recentEcVideos = JSON.stringify(result.recentEcVideos);
|
|
2761
|
+
} else if (result.recentEcVideos === null) {
|
|
2762
|
+
user.recentEcVideos = null;
|
|
2763
|
+
}
|
|
2686
2764
|
const newUsers = processDiscoveredUsers(result);
|
|
2687
2765
|
return { saved: true, newUsers };
|
|
2688
2766
|
}
|
|
@@ -3601,6 +3679,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3601
3679
|
getLlmSampleOffsets, // 获取 LLM 采样偏移量状态
|
|
3602
3680
|
// Tag 发现与打分
|
|
3603
3681
|
insertTag,
|
|
3682
|
+
insertTagsIfNew,
|
|
3604
3683
|
getTagsByStatus,
|
|
3605
3684
|
getTagsByCountry,
|
|
3606
3685
|
getDeadTags,
|
package/src/watch/db-columns.js
CHANGED
|
@@ -49,6 +49,7 @@ export const JOB_EXT_COLUMNS = [
|
|
|
49
49
|
"top_video_play_count",
|
|
50
50
|
"top_video_href",
|
|
51
51
|
"top_video_is_ad",
|
|
52
|
+
"recent_ec_videos",
|
|
52
53
|
];
|
|
53
54
|
|
|
54
55
|
// 完整的 jobs 列 = 核心列 + 扩展列
|
|
@@ -91,6 +92,7 @@ export const WRITABLE_JOB_COLUMNS = new Set([
|
|
|
91
92
|
"top_video_href",
|
|
92
93
|
"top_video_is_ad",
|
|
93
94
|
"user_create_time",
|
|
95
|
+
"recent_ec_videos",
|
|
94
96
|
]);
|
|
95
97
|
|
|
96
98
|
// ===== Boolean 列(存储时 0/1,读取时转为 true/false/null) =====
|