tt-help-cli-ycl 1.4.10 → 1.4.12

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.10",
3
+ "version": "1.4.12",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,9 @@
5
5
  // 手动模式: tt-help comments <URL> [N] [--save] 单视频评论提取
6
6
  // ────────────────────────────────────────
7
7
 
8
+ import { writeFileSync, unlinkSync } from "fs";
8
9
  import os from "os";
10
+ import path from "path";
9
11
  import { execSync } from "child_process";
10
12
 
11
13
  import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
@@ -65,6 +67,10 @@ const CFG = {
65
67
  periodicRecycleEvery: 0,
66
68
  /** 内存回收 — 回收时是否切换端口 */
67
69
  switchPortOnRecycle: true,
70
+ /** 连续任务处理错误阈值(达到后上报服务端) */
71
+ consecutiveTaskErrorsMax: 20,
72
+ /** 单端口最大运行时间(ms),超时自动切换端口(默认 48 分钟) */
73
+ maxSessionRunTimeMs: 48 * 60 * 1000,
68
74
  };
69
75
 
70
76
  /** 从环境变量覆写配置 */
@@ -97,6 +103,14 @@ function loadEnvConfig() {
97
103
  );
98
104
  CFG.switchPortOnRecycle =
99
105
  String(process.env.COMMENTS_SWITCH_PORT_ON_RECYCLE || "1") !== "0";
106
+ CFG.consecutiveTaskErrorsMax = Math.max(
107
+ 1,
108
+ int("COMMENTS_CONSECUTIVE_TASK_ERRORS_MAX", CFG.consecutiveTaskErrorsMax),
109
+ );
110
+ CFG.maxSessionRunTimeMs = Math.max(
111
+ 60000,
112
+ int("COMMENTS_MAX_SESSION_RUN_TIME_MS", CFG.maxSessionRunTimeMs),
113
+ );
100
114
  }
101
115
 
102
116
  // ────────────────────────────────────────
@@ -108,32 +122,82 @@ function formatMemoryUsage(mem = process.memoryUsage()) {
108
122
  return `rss:${mb(mem.rss)}MB heap:${mb(mem.heapUsed)}MB ext:${mb(mem.external)}MB ab:${mb(mem.arrayBuffers)}MB`;
109
123
  }
110
124
 
111
- function getBrowserMemory(port) {
125
+ /**
126
+ * 获取 Edge 浏览器进程的内存占用(跨平台:macOS/Linux/Windows)
127
+ */
128
+ function getBrowserMemory(port = null) {
112
129
  try {
113
130
  const platform = os.platform();
114
- if (platform !== "darwin" && platform !== "linux") return null;
115
- const procName = platform === "darwin" ? "Microsoft Edge" : "msedge";
116
- const c = procName[0];
117
- const rest = procName.slice(1);
118
131
  let cmd;
119
- if (port) {
120
- cmd = `ps aux | grep "[${c}]${rest}" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
121
- } else {
122
- cmd = `ps aux | grep "[${c}]${rest}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
132
+ if (platform === "darwin") {
133
+ if (port) {
134
+ cmd = `ps aux | grep "[M]icrosoft Edge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
135
+ } else {
136
+ cmd =
137
+ 'ps aux | grep "[M]icrosoft Edge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
138
+ }
139
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
140
+ const mb = parseInt(result, 10);
141
+ return mb > 0 ? mb : null;
142
+ } else if (platform === "linux") {
143
+ if (port) {
144
+ cmd = `ps aux | grep "[m]sedge" | grep "remote-debugging-port=${port}" | awk '{sum += $6} END {printf "%d", sum/1024}'`;
145
+ } else {
146
+ cmd =
147
+ 'ps aux | grep "[m]sedge" | awk \'{sum += $6} END {printf "%d", sum/1024}\'';
148
+ }
149
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
150
+ const mb = parseInt(result, 10);
151
+ return mb > 0 ? mb : null;
152
+ } else if (platform === "win32") {
153
+ const ps1Path = path.join(os.tmpdir(), `edge-mem-${Date.now()}.ps1`);
154
+ try {
155
+ let ps1Content;
156
+ if (port) {
157
+ ps1Content =
158
+ `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
159
+ `$sum = 0; ` +
160
+ `foreach ($p in $procs) { ` +
161
+ ` if ($p.CommandLine -and $p.CommandLine -like "*--remote-debugging-port=${port}*") { ` +
162
+ ` $sum += $p.WorkingSetSize ` +
163
+ ` } ` +
164
+ `}; ` +
165
+ `Write-Output $sum`;
166
+ } else {
167
+ ps1Content =
168
+ `$procs = Get-CimInstance Win32_Process -Filter "Name LIKE 'msedge%'" -ErrorAction SilentlyContinue; ` +
169
+ `$sum = 0; ` +
170
+ `foreach ($p in $procs) { $sum += $p.WorkingSetSize }; ` +
171
+ `Write-Output $sum`;
172
+ }
173
+ writeFileSync(ps1Path, ps1Content);
174
+ cmd = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${ps1Path}"`;
175
+ const result = execSync(cmd, { timeout: 5000 }).toString().trim();
176
+ try {
177
+ unlinkSync(ps1Path);
178
+ } catch {}
179
+ const bytes = parseInt(result, 10);
180
+ const mb = bytes > 0 ? Math.round(bytes / 1048576) : 0;
181
+ return mb > 0 ? mb : null;
182
+ } catch {
183
+ try {
184
+ unlinkSync(ps1Path);
185
+ } catch {}
186
+ return null;
187
+ }
123
188
  }
124
- const mb = parseInt(execSync(cmd, { timeout: 5000 }).toString().trim(), 10);
125
- return mb > 0 ? mb : null;
189
+ return null;
126
190
  } catch {
127
191
  return null;
128
192
  }
129
193
  }
130
194
 
131
- function formatMemoryWithBrowser(port) {
195
+ function formatMemoryWithBrowser(port = null) {
132
196
  const nodeMem = formatMemoryUsage();
133
197
  const edgeMB = getBrowserMemory(port);
134
198
  if (!edgeMB) return nodeMem;
135
- const label = port ? ` edge:${port}:${edgeMB}MB` : ` edge:${edgeMB}MB`;
136
- return nodeMem + label;
199
+ const portLabel = port ? `:${port}` : "";
200
+ return `${nodeMem} edge${portLabel}:${edgeMB}MB`;
137
201
  }
138
202
 
139
203
  // ────────────────────────────────────────
@@ -225,6 +289,7 @@ class CdpSessionManager {
225
289
  this.browser = null;
226
290
  this.page = null;
227
291
  this.lastRecycleAt = 0;
292
+ this.sessionStartTime = Date.now();
228
293
  this._lastMemCheck = 0;
229
294
  this._shuttingDown = false;
230
295
  }
@@ -304,14 +369,28 @@ class CdpSessionManager {
304
369
  this.browser = await ensureBrowserReady(opts);
305
370
  this.page = await this._ensurePage();
306
371
  this.lastRecycleAt = Date.now();
372
+ // 端口切换时重置运行计时
373
+ if (oldPort !== undefined && oldPort !== this.port) {
374
+ this.sessionStartTime = Date.now();
375
+ }
307
376
  }
308
377
 
309
- /** 内存检查 + 按需回收(每 30s 最多采样一次) */
378
+ /** 内存检查 + 定时轮换 + 按需回收(每 30s 最多采样一次) */
310
379
  async maybeRecycleForMemory(processedCount) {
311
380
  const now = Date.now();
312
381
  if (now - this._lastMemCheck < 30000) return;
313
382
  this._lastMemCheck = now;
314
383
 
384
+ // 定时轮换:单端口运行超时自动切换
385
+ const sessionElapsed = now - this.sessionStartTime;
386
+ if (sessionElapsed >= CFG.maxSessionRunTimeMs) {
387
+ const mins = Math.round(sessionElapsed / 60000);
388
+ await this.recycleSession(`定时轮换 (运行 ${mins} 分钟)`, {
389
+ switchPort: true,
390
+ });
391
+ return;
392
+ }
393
+
315
394
  const mem = process.memoryUsage();
316
395
  const rssMB = mem.rss / 1024 / 1024;
317
396
  const heapMB = mem.heapUsed / 1024 / 1024;
@@ -481,7 +560,8 @@ async function runAutoMode(options) {
481
560
  browserRestartCount = 0;
482
561
  let consecutiveErrors = 0,
483
562
  consecutive403 = 0,
484
- consecutiveCaptchaCount = 0;
563
+ consecutiveCaptchaCount = 0,
564
+ consecutiveTaskErrors = 0;
485
565
 
486
566
  // ── CDP 会话 ──
487
567
  const session = new CdpSessionManager({
@@ -720,15 +800,21 @@ async function runAutoMode(options) {
720
800
  await apiPut(`${serverUrl}/api/comment-task/${id}`);
721
801
  processedCount++;
722
802
  consecutive403 = 0;
803
+ consecutiveTaskErrors = 0;
723
804
 
724
805
  // 每 10 个任务输出内存汇总
725
806
  if (totalTasks() % 10 === 0) {
807
+ const remainingMs =
808
+ session.sessionStartTime + CFG.maxSessionRunTimeMs - Date.now();
809
+ const remainingMins =
810
+ remainingMs > 0 ? Math.round(remainingMs / 60000) : 0;
726
811
  console.error(
727
- `── #${totalTasks()} ── ${formatMemoryWithBrowser(session.port)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount}`,
812
+ `── #${totalTasks()} ── ${formatMemoryWithBrowser(session.port)} 处理=${processedCount} 跳过=${skippedCount} 错误=${errorCount} 距切换≈${remainingMins}min`,
728
813
  );
729
814
  }
730
815
  } catch (err) {
731
816
  errorCount++;
817
+ consecutiveTaskErrors++;
732
818
  const isBrowserClosed = isBrowserClosedError(err);
733
819
  const is403 =
734
820
  err.message &&
@@ -754,6 +840,22 @@ async function runAutoMode(options) {
754
840
  } else {
755
841
  console.error(` ${err.message}`);
756
842
  }
843
+
844
+ // 连续任务错误达到阈值,汇报服务端
845
+ if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
846
+ console.error(
847
+ ` ⚠️ 连续任务错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端`,
848
+ );
849
+ apiPost(`${serverUrl}/api/error-report`, {
850
+ userId,
851
+ username: "",
852
+ errorType: "consecutive_task_errors",
853
+ errorMessage: `连续 ${consecutiveTaskErrors} 个任务处理失败 (port=${session.port})`,
854
+ stage: "comments",
855
+ errorStack: "",
856
+ }).catch(() => {});
857
+ consecutiveTaskErrors = 0;
858
+ }
757
859
  }
758
860
 
759
861
  // 任务间内存检查
@@ -761,6 +863,7 @@ async function runAutoMode(options) {
761
863
  }
762
864
  } catch (err) {
763
865
  errorCount++;
866
+ consecutiveTaskErrors++;
764
867
  if (isBrowserClosedError(err)) {
765
868
  browserRestartCount++;
766
869
  console.error(` ⚠ 浏览器断开 (#${browserRestartCount}) → 重启`);
@@ -768,6 +871,22 @@ async function runAutoMode(options) {
768
871
  } else {
769
872
  console.error(` × ${err.message}`);
770
873
  }
874
+
875
+ // 连续轮间错误达到阈值,汇报服务端
876
+ if (consecutiveTaskErrors >= CFG.consecutiveTaskErrorsMax) {
877
+ console.error(
878
+ ` ⚠️ 连续轮间错误 ${consecutiveTaskErrors}/${CFG.consecutiveTaskErrorsMax} → 汇报服务端`,
879
+ );
880
+ apiPost(`${serverUrl}/api/error-report`, {
881
+ userId,
882
+ username: "",
883
+ errorType: "consecutive_task_errors",
884
+ errorMessage: `连续 ${consecutiveTaskErrors} 个轮次处理失败 (port=${session.port})`,
885
+ stage: "comments",
886
+ errorStack: "",
887
+ }).catch(() => {});
888
+ consecutiveTaskErrors = 0;
889
+ }
771
890
  }
772
891
 
773
892
  // 轮间内存检查 + 等待
@@ -794,7 +913,9 @@ async function runManualMode(url, options) {
794
913
  save: shouldSave,
795
914
  serverUrl = defaultServer,
796
915
  basePort = 9222,
916
+ proxy: proxyOption,
797
917
  } = options;
918
+ const effectiveProxy = proxyOption || configuredProxy;
798
919
 
799
920
  let browser;
800
921
  try {
@@ -930,5 +1051,6 @@ export async function handleComments(options) {
930
1051
  save: commentsSave,
931
1052
  serverUrl: commentsServer || defaultServer,
932
1053
  basePort: commentsBasePort,
1054
+ proxy: commentsProxy,
933
1055
  });
934
1056
  }
@@ -2647,8 +2647,7 @@ export function createStore(filePath, options = {}) {
2647
2647
  let sql = `
2648
2648
  SELECT *
2649
2649
  FROM jobs_base
2650
- WHERE (COALESCE(tt_seller, '') = '' OR tt_seller = 1)
2651
- AND COALESCE(user_update_count, 0) <= 0
2650
+ WHERE COALESCE(user_update_count, 0) <= 0
2652
2651
  `;
2653
2652
  const sqlParams = [];
2654
2653
 
@@ -2698,13 +2697,7 @@ export function createStore(filePath, options = {}) {
2698
2697
  const pending = data
2699
2698
  .filter((u) => {
2700
2699
  const updateCount = u.userUpdateCount;
2701
- // ttSeller 为空 或 ttSeller=1(商家重处理)都可以领取
2702
- const ttSellerEligible =
2703
- u.ttSeller === null ||
2704
- u.ttSeller === undefined ||
2705
- u.ttSeller === "" ||
2706
- u.ttSeller === 1;
2707
- if (!ttSellerEligible) return false;
2700
+ // jobs_base 中所有 user_update_count <= 0 的记录都可以领取
2708
2701
  if (
2709
2702
  updateCount === null ||
2710
2703
  updateCount === undefined ||
@@ -3395,14 +3388,13 @@ export function createStore(filePath, options = {}) {
3395
3388
  };
3396
3389
  }
3397
3390
 
3398
- // 5. 验证:统计 jobs_base 中可被 attach 领取的记录数(tt_seller=1 且 user_update_count<=0)
3391
+ // 5. 验证:统计 jobs_base 中可被 attach 领取的记录数(user_update_count<=0)
3399
3392
  let available = 0;
3400
3393
  try {
3401
3394
  const row = db
3402
3395
  .prepare(
3403
3396
  `SELECT COUNT(*) as total FROM jobs_base
3404
- WHERE tt_seller = 1
3405
- AND COALESCE(user_update_count, 0) <= 0
3397
+ WHERE COALESCE(user_update_count, 0) <= 0
3406
3398
  AND video_count = 0`,
3407
3399
  )
3408
3400
  .get();
@@ -50,7 +50,7 @@ export function getDashboardStatsFromDb(targetLocations = []) {
50
50
 
51
51
  const userUpdateTasksRow = db
52
52
  .prepare(
53
- `SELECT COUNT(*) as userUpdateTasks FROM jobs_base WHERE COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0`,
53
+ `SELECT COUNT(*) as userUpdateTasks FROM jobs_base WHERE COALESCE(user_update_count, 0) <= 0`,
54
54
  )
55
55
  .get();
56
56
 
@@ -147,7 +147,7 @@ export function getUserUpdateByCountryFromDb() {
147
147
  .prepare(
148
148
  `SELECT COALESCE(guessed_location, '未知') as country, COUNT(*) as count
149
149
  FROM jobs_base
150
- WHERE tt_seller IS NULL AND COALESCE(user_update_count, 0) <= 0
150
+ WHERE COALESCE(user_update_count, 0) <= 0
151
151
  GROUP BY COALESCE(guessed_location, '未知')
152
152
  ORDER BY count DESC`,
153
153
  )
@@ -161,7 +161,7 @@ export function getAttachStuckByCountryFromDb() {
161
161
  .prepare(
162
162
  `SELECT COALESCE(guessed_location, '未知') as country, COUNT(*) as count
163
163
  FROM jobs_base
164
- WHERE tt_seller IS NULL AND COALESCE(user_update_count, 0) = 1
164
+ WHERE COALESCE(user_update_count, 0) = 1
165
165
  GROUP BY COALESCE(guessed_location, '未知')
166
166
  ORDER BY count DESC`,
167
167
  )
@@ -194,7 +194,7 @@ export function restoreAttachStuckByCountry(country) {
194
194
  };
195
195
  }
196
196
 
197
- const whereSql = `COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) = 1 AND COALESCE(guessed_location, '未知') = ?`;
197
+ const whereSql = `COALESCE(user_update_count, 0) = 1 AND COALESCE(guessed_location, '未知') = ?`;
198
198
  const count =
199
199
  db
200
200
  .prepare(`SELECT COUNT(*) as c FROM jobs_base WHERE ${whereSql}`)
@@ -163,7 +163,6 @@ function renderStats() {
163
163
  if (statJobsBase)
164
164
  statJobsBase.textContent = `jobs_base: ${formatStatNum(d.jobsBaseTotal || 0)}`;
165
165
  flashEl("statError", d.errorUsers);
166
- flashEl("statRestricted", d.restrictedUsers);
167
166
  flashEl("statTarget", d.targetUsers, { full: true });
168
167
  flashEl("statUserUpdateTasks", d.userUpdateTasks || 0, { full: true });
169
168
  flashEl("statRawJobs", d.rawJobs || 0);
@@ -376,7 +375,6 @@ function renderSourceChart(sources) {
376
375
  following: "关注",
377
376
  follower: "粉丝",
378
377
  processed: "已处理",
379
- restricted: "受限(跳过)",
380
378
  error: "错误(待重试)",
381
379
  noVideo: "无视频",
382
380
  };
@@ -419,13 +417,10 @@ function renderTable(users) {
419
417
  const nowStatus = u.status;
420
418
  const changed =
421
419
  wasStatus !== nowStatus &&
422
- (nowStatus === "done" ||
423
- nowStatus === "restricted" ||
424
- nowStatus === "error");
420
+ (nowStatus === "done" || nowStatus === "error");
425
421
  const rowClass = changed ? ' class="row-flash"' : "";
426
422
 
427
423
  const statusTags = {
428
- restricted: '<span class="tag error">受限(跳过)</span>',
429
424
  error: '<span class="tag error">错误(待重试)</span>',
430
425
  done: '<span class="tag processed">已完成</span>',
431
426
  processing: '<span class="tag processing">处理中</span>',
@@ -991,33 +986,6 @@ async function resetJob(uniqueId) {
991
986
  }
992
987
  }
993
988
 
994
- async function moveRestrictedToBase() {
995
- const count = currentStats?.restrictedUsers || 0;
996
- if (count === 0) {
997
- showToast("没有受限用户");
998
- return;
999
- }
1000
- if (!confirm(`确定将 ${count} 个受限用户移到 jobs_base 吗?`)) return;
1001
- showLoading("迁移中...");
1002
- try {
1003
- const res = await fetch("/api/jobs/move-restricted-to-base", {
1004
- method: "POST",
1005
- });
1006
- const data = await res.json();
1007
- if (data.error) {
1008
- showToast(data.error, true);
1009
- return;
1010
- }
1011
- showToast(`已迁移 ${data.moved} 个受限用户`);
1012
- fetchStats();
1013
- fetchUsers();
1014
- } catch (e) {
1015
- showToast("迁移失败: " + e.message, true);
1016
- } finally {
1017
- hideLoading();
1018
- }
1019
- }
1020
-
1021
989
  async function batchResetErrors() {
1022
990
  const errorUsers = currentUsers.filter((u) => u.status === "error");
1023
991
  if (errorUsers.length === 0) {
@@ -1762,8 +1730,6 @@ function renderTargetTable() {
1762
1730
  statusTag = '<span class="tag pending">待处理</span>';
1763
1731
  else if (u.status === "error")
1764
1732
  statusTag = '<span class="tag error">错误</span>';
1765
- else if (u.status === "restricted")
1766
- statusTag = '<span class="tag error">受限</span>';
1767
1733
  else statusTag = u.status || "-";
1768
1734
 
1769
1735
  return `<tr data-user="${u.uniqueId}">
@@ -41,10 +41,6 @@
41
41
  <div class="label">错误</div>
42
42
  <div class="value error" id="statError">0</div>
43
43
  </div>
44
- <div class="stat-card clickable" onclick="moveRestrictedToBase()">
45
- <div class="label">受限</div>
46
- <div class="value error" id="statRestricted">0</div>
47
- </div>
48
44
  <div class="stat-card clickable pending-card" id="statUserUpdateCard" onclick="navigateToUserUpdate()">
49
45
  <div class="label">待补资料</div>
50
46
  <div class="value target" id="statUserUpdateTasks">0</div>
@@ -122,7 +118,6 @@
122
118
  <button data-filter="pending" onclick="setFilter('pending')">待处理</button>
123
119
  <button data-filter="done" onclick="setFilter('done')">已完成</button>
124
120
  <button data-filter="error" onclick="setFilter('error')">错误</button>
125
- <button data-filter="restricted" onclick="setFilter('restricted')">受限</button>
126
121
  <button data-filter="target" onclick="setFilter('target')" style="background:#7c3aed;color:#fff">目标商家</button>
127
122
  <select id="targetLocationFilter" onchange="onTargetLocationChange()"
128
123
  style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;display:none">