tt-help-cli-ycl 1.4.15 → 1.4.17
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/refresh.js +5 -0
- package/src/cli/tag.js +20 -16
- package/src/lib/args.js +4 -0
- package/src/lib/constants.js +1 -0
- package/src/lib/tag-fetcher.js +7 -1
- package/src/npm-main.js +3 -0
- package/src/watch/data-store.js +25 -8
- package/src/watch/db-stats.js +8 -0
- package/src/watch/public/app.js +1 -0
- package/src/watch/public/index.html +4 -0
- package/src/watch/server.js +18 -3
package/package.json
CHANGED
package/src/cli/refresh.js
CHANGED
|
@@ -54,6 +54,7 @@ export async function handleRefresh(options) {
|
|
|
54
54
|
exploreMaxFollowing,
|
|
55
55
|
exploreMaxFollowers,
|
|
56
56
|
exploreLocation,
|
|
57
|
+
exploreJobLocations,
|
|
57
58
|
exploreMaxUsers,
|
|
58
59
|
serverUrl,
|
|
59
60
|
explorePort,
|
|
@@ -106,6 +107,7 @@ export async function handleRefresh(options) {
|
|
|
106
107
|
`关注/粉丝: ${exploreEnableFollow ? "启用" : "禁用"} (${exploreMaxFollowing}/${exploreMaxFollowers})`,
|
|
107
108
|
);
|
|
108
109
|
console.error(`国家筛选: ${exploreLocation}`);
|
|
110
|
+
if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
|
|
109
111
|
console.error(`空闲间隔: ${exploreInterval || 30} 秒`);
|
|
110
112
|
if (effectiveProxy) {
|
|
111
113
|
console.error(
|
|
@@ -274,6 +276,9 @@ export async function handleRefresh(options) {
|
|
|
274
276
|
if (exploreRedoMaxAge > 0) {
|
|
275
277
|
redoUrl += `&maxAge=${exploreRedoMaxAge}`;
|
|
276
278
|
}
|
|
279
|
+
if (exploreJobLocations) {
|
|
280
|
+
redoUrl += `&locations=${encodeURIComponent(exploreJobLocations)}`;
|
|
281
|
+
}
|
|
277
282
|
let job = null;
|
|
278
283
|
try {
|
|
279
284
|
job = await apiGet(redoUrl);
|
package/src/cli/tag.js
CHANGED
|
@@ -528,13 +528,11 @@ async function scoreSingleTag(
|
|
|
528
528
|
log(` ✗ 标签不存在(TikTok 提示"找不到此话题标签"),标记为 dead`);
|
|
529
529
|
result.status = "dead";
|
|
530
530
|
result.error = "tag_not_found";
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
result.status = "dead";
|
|
534
|
-
result.error = "no videos found";
|
|
531
|
+
await reportToServer(baseUrl, result);
|
|
532
|
+
return result;
|
|
535
533
|
}
|
|
536
|
-
|
|
537
|
-
|
|
534
|
+
// 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
|
|
535
|
+
throw new Error("no videos returned — possible network/captcha/slow-load, will retry");
|
|
538
536
|
}
|
|
539
537
|
|
|
540
538
|
// Step 2: 补充国家信息
|
|
@@ -1064,18 +1062,24 @@ export async function handleScoreAll(parsed) {
|
|
|
1064
1062
|
|
|
1065
1063
|
if (!videos || videos.length === 0) {
|
|
1066
1064
|
const deadSec = ((Date.now() - fetchStart) / 1000).toFixed(1);
|
|
1065
|
+
if (tagResult.error === "tag_not_found") {
|
|
1066
|
+
log(
|
|
1067
|
+
` ⚠️ 标签不存在 (${deadSec}s),标记 dead`,
|
|
1068
|
+
);
|
|
1069
|
+
result.status = "dead";
|
|
1070
|
+
result.error = "tag_not_found";
|
|
1071
|
+
await reportToServer(baseUrl, result, clientId, clientMeta);
|
|
1072
|
+
totalScored++;
|
|
1073
|
+
await randomDelay(0, 5000);
|
|
1074
|
+
await recyclePage();
|
|
1075
|
+
await maybeRecycleForMemory();
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
// 非 tag_not_found 的 0 视频 → 抛异常让其走 error 重试路径,避免误标 dead
|
|
1067
1079
|
log(
|
|
1068
|
-
` ⚠️ 无视频 (${deadSec}s)
|
|
1080
|
+
` ⚠️ 无视频 (${deadSec}s),可能网络/风控/加载慢 → 抛异常重试`,
|
|
1069
1081
|
);
|
|
1070
|
-
|
|
1071
|
-
result.error = "no videos found";
|
|
1072
|
-
await reportToServer(baseUrl, result, clientId, clientMeta);
|
|
1073
|
-
totalScored++;
|
|
1074
|
-
// 随机等待 3-7 秒,避免连续访问 TikTok 触发风控
|
|
1075
|
-
await randomDelay(0, 5000);
|
|
1076
|
-
await recyclePage();
|
|
1077
|
-
await maybeRecycleForMemory();
|
|
1078
|
-
continue;
|
|
1082
|
+
throw new Error("no videos returned (network/captcha/slow-load)");
|
|
1079
1083
|
}
|
|
1080
1084
|
|
|
1081
1085
|
// enrich: 先查服务端缓存,只对未知视频发 HTTP 请求
|
package/src/lib/args.js
CHANGED
|
@@ -471,6 +471,7 @@ function parseRefreshArgs(args) {
|
|
|
471
471
|
let exploreMaxVideos = 16;
|
|
472
472
|
let exploreRedoMaxAge = 0;
|
|
473
473
|
let exploreProxy = null;
|
|
474
|
+
let exploreJobLocations = null;
|
|
474
475
|
|
|
475
476
|
for (let i = 0; i < args.length; i++) {
|
|
476
477
|
const arg = args[i];
|
|
@@ -502,6 +503,8 @@ function parseRefreshArgs(args) {
|
|
|
502
503
|
exploreProxy = args[++i];
|
|
503
504
|
} else if (arg === "--location") {
|
|
504
505
|
exploreLocation = args[++i];
|
|
506
|
+
} else if (arg === "--job-locations") {
|
|
507
|
+
exploreJobLocations = args[++i];
|
|
505
508
|
} else if (arg === "--enable-follow") {
|
|
506
509
|
exploreEnableFollow = true;
|
|
507
510
|
} else if (arg === "--disable-follow") {
|
|
@@ -519,6 +522,7 @@ function parseRefreshArgs(args) {
|
|
|
519
522
|
exploreMaxFollowing,
|
|
520
523
|
exploreMaxFollowers,
|
|
521
524
|
exploreLocation,
|
|
525
|
+
exploreJobLocations,
|
|
522
526
|
exploreMaxUsers,
|
|
523
527
|
explorePort,
|
|
524
528
|
exploreBasePort,
|
package/src/lib/constants.js
CHANGED
|
@@ -145,6 +145,7 @@ const HELP_TEXT = [
|
|
|
145
145
|
" 选项:",
|
|
146
146
|
" --server <URL> 服务端地址,默认 http://127.0.0.1:3001",
|
|
147
147
|
` --location <国家代码> 国家筛选,逗号分隔,默认 ${DEFAULT_TARGET_LOCATIONS_CSV}`,
|
|
148
|
+
" --job-locations <国家> 任务国家筛选,逗号分隔(仅筛选服务端任务),基于真实国家字段 location_created",
|
|
148
149
|
" --max-videos <数量> 每用户最大视频数,默认 16",
|
|
149
150
|
" --enable-follow 启用关注/粉丝提取(默认启用)",
|
|
150
151
|
" --disable-follow 禁用关注/粉丝提取",
|
package/src/lib/tag-fetcher.js
CHANGED
|
@@ -6,7 +6,7 @@ import { CDNBlockedError } from "./parse-ssr.mjs";
|
|
|
6
6
|
|
|
7
7
|
const TAG_URL = "https://www.tiktok.com/tag";
|
|
8
8
|
const SCROLL_INTERVAL = 3000;
|
|
9
|
-
const MAX_STALE_ROUNDS =
|
|
9
|
+
const MAX_STALE_ROUNDS = 5;
|
|
10
10
|
|
|
11
11
|
function sleep(ms) {
|
|
12
12
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -113,6 +113,12 @@ export async function fetchTagData(tag, options = {}) {
|
|
|
113
113
|
|
|
114
114
|
await page.waitForTimeout(3000);
|
|
115
115
|
|
|
116
|
+
// 等待首屏 API 数据到达(避免网络慢时 0 视频进入滚动循环)
|
|
117
|
+
const videoWaitStart = Date.now();
|
|
118
|
+
while (seenVideoIds.size === 0 && Date.now() - videoWaitStart < 15000) {
|
|
119
|
+
await sleep(1000);
|
|
120
|
+
}
|
|
121
|
+
|
|
116
122
|
// 检测页面异常:验证码、网络错误、标签不存在
|
|
117
123
|
const pageError = await page.evaluate(() => {
|
|
118
124
|
const text = document.body?.innerText || "";
|
package/src/npm-main.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
handleDiscover,
|
|
13
13
|
handleScore,
|
|
14
14
|
handleScoreAll,
|
|
15
|
+
handleTagRefresh,
|
|
15
16
|
} from "./cli/tag.js";
|
|
16
17
|
|
|
17
18
|
function exitUnsupportedCommand(command) {
|
|
@@ -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 {
|
package/src/watch/data-store.js
CHANGED
|
@@ -380,6 +380,9 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
380
380
|
|
|
381
381
|
// 分页模式:按国家或全局分页查询用户
|
|
382
382
|
if (limit !== undefined) {
|
|
383
|
+
const filterIsTarget =
|
|
384
|
+
filterCountry && targetLocations.includes(filterCountry);
|
|
385
|
+
|
|
383
386
|
let sql = `
|
|
384
387
|
SELECT
|
|
385
388
|
unique_id,
|
|
@@ -401,13 +404,22 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
|
|
|
401
404
|
FROM jobs
|
|
402
405
|
WHERE tt_seller = 1
|
|
403
406
|
AND verified = 0
|
|
404
|
-
AND location_created IN (${placeholders})
|
|
405
407
|
`;
|
|
406
|
-
const params = [
|
|
408
|
+
const params = [];
|
|
409
|
+
|
|
410
|
+
// 目标国家筛选:仅当 filterCountry 是目标国家或不筛选时才加 IN 限制
|
|
411
|
+
if (filterIsTarget || !filterCountry) {
|
|
412
|
+
sql += ` AND location_created IN (${placeholders})`;
|
|
413
|
+
params.push(...targetLocations);
|
|
414
|
+
}
|
|
407
415
|
|
|
408
416
|
if (filterCountry) {
|
|
409
417
|
sql += ` AND location_created = ?`;
|
|
410
418
|
params.push(filterCountry);
|
|
419
|
+
// 筛选非目标国家时,仅返回已修改的用户(与“其他国家”语义一致)
|
|
420
|
+
if (!filterIsTarget) {
|
|
421
|
+
sql += ` AND modified_at IS NOT NULL`;
|
|
422
|
+
}
|
|
411
423
|
}
|
|
412
424
|
|
|
413
425
|
if (search) {
|
|
@@ -2476,12 +2488,12 @@ export function createStore(filePath, options = {}) {
|
|
|
2476
2488
|
return { saved: true, pinned: user.pinned };
|
|
2477
2489
|
}
|
|
2478
2490
|
|
|
2479
|
-
function getNextRedoJob(userId, maxAgeSeconds = 43200) {
|
|
2491
|
+
function getNextRedoJob(userId, maxAgeSeconds = 43200, locations = null) {
|
|
2480
2492
|
if (getDb()) {
|
|
2481
2493
|
const now = Date.now();
|
|
2482
2494
|
const threshold = now - maxAgeSeconds * 1000;
|
|
2483
2495
|
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
2484
|
-
const targetLocations = DEFAULT_TARGET_LOCATIONS;
|
|
2496
|
+
const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
|
|
2485
2497
|
const placeholders = targetLocations.map(() => "?").join(",");
|
|
2486
2498
|
const row = getDb()
|
|
2487
2499
|
.prepare(
|
|
@@ -2515,7 +2527,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2515
2527
|
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
2516
2528
|
|
|
2517
2529
|
// 筛选目标国家用户,按 refreshTime 升序取最远的(没有则默认 2016-01-01)
|
|
2518
|
-
const targetLocations = DEFAULT_TARGET_LOCATIONS;
|
|
2530
|
+
const targetLocations = locations || DEFAULT_TARGET_LOCATIONS;
|
|
2519
2531
|
const targetUsers = data.filter(
|
|
2520
2532
|
(u) =>
|
|
2521
2533
|
u.ttSeller &&
|
|
@@ -2699,13 +2711,15 @@ export function createStore(filePath, options = {}) {
|
|
|
2699
2711
|
|
|
2700
2712
|
if (getDb()) {
|
|
2701
2713
|
const l = Math.max(1, parseInt(limit) || 5);
|
|
2714
|
+
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
2702
2715
|
|
|
2703
2716
|
let sql = `
|
|
2704
2717
|
SELECT *
|
|
2705
2718
|
FROM jobs_base
|
|
2706
2719
|
WHERE COALESCE(user_update_count, 0) <= 0
|
|
2720
|
+
OR (COALESCE(user_update_count, 0) > 0 AND updated_at < ?)
|
|
2707
2721
|
`;
|
|
2708
|
-
const sqlParams = [];
|
|
2722
|
+
const sqlParams = [fiveMinutesAgo];
|
|
2709
2723
|
|
|
2710
2724
|
if (hasCountryFilter) {
|
|
2711
2725
|
const placeholders = targetCountries.map(() => "?").join(", ");
|
|
@@ -2750,14 +2764,17 @@ export function createStore(filePath, options = {}) {
|
|
|
2750
2764
|
}
|
|
2751
2765
|
|
|
2752
2766
|
const l = Math.max(1, parseInt(limit) || 5);
|
|
2767
|
+
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
2753
2768
|
const pending = data
|
|
2754
2769
|
.filter((u) => {
|
|
2755
2770
|
const updateCount = u.userUpdateCount;
|
|
2756
|
-
//
|
|
2771
|
+
// user_update_count <= 0 的记录可直接领取
|
|
2772
|
+
// user_update_count > 0 但 updatedAt 超过 5 分钟的,视为客户端失败/超时,重新回收
|
|
2757
2773
|
if (
|
|
2758
2774
|
updateCount === null ||
|
|
2759
2775
|
updateCount === undefined ||
|
|
2760
|
-
updateCount <= 0
|
|
2776
|
+
updateCount <= 0 ||
|
|
2777
|
+
(u.updatedAt && u.updatedAt < fiveMinutesAgo)
|
|
2761
2778
|
) {
|
|
2762
2779
|
if (hasCountryFilter) {
|
|
2763
2780
|
const loc = (u.guessedLocation || "").toUpperCase();
|
package/src/watch/db-stats.js
CHANGED
|
@@ -95,9 +95,17 @@ export function getDashboardStatsFromDb(targetLocations = []) {
|
|
|
95
95
|
|
|
96
96
|
const tagCount = db.prepare("SELECT COUNT(*) as total FROM tags").get().total;
|
|
97
97
|
|
|
98
|
+
const commentTaskCount = db
|
|
99
|
+
.prepare(
|
|
100
|
+
`SELECT COUNT(*) as total FROM videos
|
|
101
|
+
WHERE user_update_count IS NULL OR user_update_count <= 0`,
|
|
102
|
+
)
|
|
103
|
+
.get().total;
|
|
104
|
+
|
|
98
105
|
return {
|
|
99
106
|
totalUsers: aggregateRow.total,
|
|
100
107
|
tagCount,
|
|
108
|
+
commentTaskCount,
|
|
101
109
|
rawJobs: getRawJobsCount(),
|
|
102
110
|
dbTotalUsers: getUserDbCount(),
|
|
103
111
|
jobsTotal: aggregateRow.total,
|
package/src/watch/public/app.js
CHANGED
|
@@ -166,6 +166,7 @@ function renderStats() {
|
|
|
166
166
|
flashEl("statTarget", d.targetUsers, { full: true });
|
|
167
167
|
flashEl("statUserUpdateTasks", d.userUpdateTasks || 0, { full: true });
|
|
168
168
|
flashEl("statRawJobs", d.rawJobs || 0);
|
|
169
|
+
flashEl("statCommentTask", d.commentTaskCount || 0, { full: true });
|
|
169
170
|
flashEl("statTags", d.tagCount || 0, { full: true });
|
|
170
171
|
// 同步子页面 stats
|
|
171
172
|
const pendingTotal = document.getElementById("pendingStatTotal");
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
<div class="label">目标商家</div>
|
|
54
54
|
<div class="value target" id="statTarget">0</div>
|
|
55
55
|
</div>
|
|
56
|
+
<div class="stat-card clickable pending-card" id="statCommentTaskCard">
|
|
57
|
+
<div class="label">待评论任务</div>
|
|
58
|
+
<div class="value target" id="statCommentTask">0</div>
|
|
59
|
+
</div>
|
|
56
60
|
<div class="stat-card clickable pending-card" id="statTagsCard" onclick="navigateToTags()" style="cursor:pointer">
|
|
57
61
|
<div class="label">关键词</div>
|
|
58
62
|
<div class="value target" id="statTags">0</div>
|
package/src/watch/server.js
CHANGED
|
@@ -551,12 +551,27 @@ export function startWatchServer(
|
|
|
551
551
|
if (req.method === "GET" && routePath === "/api/redo-job") {
|
|
552
552
|
const userId = params.userId || "";
|
|
553
553
|
const maxAge = parseInt(params.maxAge) || 43200;
|
|
554
|
-
const
|
|
554
|
+
const locationsParam = params.locations || "";
|
|
555
|
+
const locations = locationsParam
|
|
556
|
+
? locationsParam
|
|
557
|
+
.split(",")
|
|
558
|
+
.map((s) => s.trim().toUpperCase())
|
|
559
|
+
.filter(Boolean)
|
|
560
|
+
: null;
|
|
561
|
+
const job = store.getNextRedoJob(userId, maxAge, locations);
|
|
555
562
|
if (job) {
|
|
556
|
-
logJob("REDO-CLAIM", {
|
|
563
|
+
logJob("REDO-CLAIM", {
|
|
564
|
+
user: job.uniqueId,
|
|
565
|
+
clientId: userId,
|
|
566
|
+
locations,
|
|
567
|
+
});
|
|
557
568
|
sendJSON(res, 200, { hasJob: true, user: job });
|
|
558
569
|
} else {
|
|
559
|
-
logJob("REDO-CLAIM", {
|
|
570
|
+
logJob("REDO-CLAIM", {
|
|
571
|
+
result: "no-job",
|
|
572
|
+
clientId: userId,
|
|
573
|
+
locations,
|
|
574
|
+
});
|
|
560
575
|
sendJSON(res, 200, { hasJob: false });
|
|
561
576
|
}
|
|
562
577
|
return;
|