tt-help-cli-ycl 1.4.16 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.16",
3
+ "version": "1.4.17",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- } else {
532
- log(` ✗ 没有视频数据,标记为 dead(可能是网络问题或页面未加载)`);
533
- result.status = "dead";
534
- result.error = "no videos found";
531
+ await reportToServer(baseUrl, result);
532
+ return result;
535
533
  }
536
- await reportToServer(baseUrl, result);
537
- return result;
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) mem=${formatMemoryWithBrowser(cdpPort)},标记 dead`,
1080
+ ` ⚠️ 无视频 (${deadSec}s),可能网络/风控/加载慢 → 抛异常重试`,
1069
1081
  );
1070
- result.status = "dead";
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,
@@ -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 禁用关注/粉丝提取",
@@ -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 = 3;
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 || "";
@@ -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 = [...targetLocations];
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
- // jobs_base 中所有 user_update_count <= 0 的记录都可以领取
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();
@@ -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,
@@ -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>
@@ -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 job = store.getNextRedoJob(userId, maxAge);
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", { user: job.uniqueId, clientId: userId });
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", { result: "no-job", clientId: userId });
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;