tt-help-cli-ycl 1.4.150 → 1.4.152

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.
@@ -0,0 +1,142 @@
1
+ // 验证:国家采样全失败(通道故障)→ 任务按 error 回流,不再静默 done
2
+ // 运行:node scripts/test-sample-all-fail.mjs
3
+ let pass = 0;
4
+ let fail = 0;
5
+ function check(name, ok, detail = "") {
6
+ if (ok) {
7
+ pass++;
8
+ console.log(`✓ ${name}${detail ? `: ${detail}` : ""}`);
9
+ } else {
10
+ fail++;
11
+ console.error(`✗ ${name}${detail ? `: ${detail}` : ""}`);
12
+ }
13
+ }
14
+
15
+ // [1] 全失败判定(等价于实现中 Promise.all + sampleFailCount 计数)
16
+ {
17
+ const sampleUrls = ["u1", "u2", "u3", "u4", "u5"];
18
+ // 模拟通道故障:每个 fetch 都抛错 → catch 内计数 +1,返回 null
19
+ let sampleFailCount = 0;
20
+ const locations = await Promise.all(
21
+ sampleUrls.map(async () => {
22
+ try {
23
+ throw new Error("fetch failed");
24
+ } catch {
25
+ sampleFailCount++;
26
+ return null;
27
+ }
28
+ }),
29
+ );
30
+ const allFailed =
31
+ sampleUrls.length > 0 && sampleFailCount === sampleUrls.length;
32
+ check(
33
+ "全失败判定:5/5 fetch 抛错 → allFailed=true",
34
+ allFailed === true && locations.every((l) => l === null),
35
+ );
36
+ }
37
+
38
+ // [2] 部分失败判定:2/5 失败 → allFailed=false(保持众数逻辑)
39
+ {
40
+ const sampleUrls = ["u1", "u2", "u3", "u4", "u5"];
41
+ let sampleFailCount = 0;
42
+ const locations = await Promise.all(
43
+ sampleUrls.map(async (u) => {
44
+ try {
45
+ if (u === "u1" || u === "u2") throw new Error("timeout");
46
+ return "TH";
47
+ } catch {
48
+ sampleFailCount++;
49
+ return null;
50
+ }
51
+ }),
52
+ );
53
+ const allFailed =
54
+ sampleUrls.length > 0 && sampleFailCount === sampleUrls.length;
55
+ check(
56
+ "部分失败判定:2/5 失败 → allFailed=false(走众数)",
57
+ allFailed === false && locations.filter(Boolean).length === 3,
58
+ );
59
+ }
60
+
61
+ // [3] 全成功但无数据判定:sampleFailCount=0 → 不转 error(保持 done 无国家回炉机制)
62
+ {
63
+ const sampleUrls = ["u1", "u2", "u3"];
64
+ let sampleFailCount = 0;
65
+ const locations = await Promise.all(
66
+ sampleUrls.map(async () => {
67
+ const data = { locationCreated: null }; // 成功响应但无字段
68
+ return data?.locationCreated || null;
69
+ }),
70
+ );
71
+ const allFailed =
72
+ sampleUrls.length > 0 && sampleFailCount === sampleUrls.length;
73
+ check(
74
+ "全成功无数据:failCount=0 → 不转 error(done 无国家,2h 回炉兜底)",
75
+ allFailed === false && locations.every((l) => l === null),
76
+ );
77
+ }
78
+
79
+ // [4] 不可达 webserver → fetch 必抛错(通道故障可稳定复现)
80
+ {
81
+ let fetchThrows = false;
82
+ try {
83
+ await fetch("http://127.0.0.1:1/api/tiktok/video", {
84
+ method: "POST",
85
+ signal: AbortSignal.timeout(2000),
86
+ });
87
+ } catch {
88
+ fetchThrows = true;
89
+ }
90
+ check(
91
+ "不可达 webserver → fetch 抛错(通道故障可复现)",
92
+ fetchThrows === true,
93
+ );
94
+ }
95
+
96
+ // [5] 真实 processExplore:page mock 让 collectVideos 走不通(API 拦截失败抛错)
97
+ // 无论命中哪条错误路径,都不应出现"processed=true 且无 error 且无国家"的静默 done
98
+ {
99
+ const fakePage = {
100
+ async evaluate() {
101
+ return {};
102
+ },
103
+ async goto() {},
104
+ on() {
105
+ return this;
106
+ },
107
+ off() {
108
+ return this;
109
+ },
110
+ url: () => "https://www.tiktok.com/@test",
111
+ waitForResponse() {
112
+ return Promise.reject(new Error("timeout"));
113
+ },
114
+ };
115
+ const { processExplore } = await import("../src/scraper/explore-core.js");
116
+ const result = await processExplore(
117
+ fakePage,
118
+ "testuser",
119
+ {
120
+ maxVideos: 1,
121
+ enableFollow: false,
122
+ loggedIn: false,
123
+ mini: false,
124
+ webserverUrl: "http://127.0.0.1:1",
125
+ location: "TH",
126
+ },
127
+ () => {},
128
+ );
129
+ const silentDone =
130
+ result.processed === true &&
131
+ !result.error &&
132
+ !result.noVideo &&
133
+ !result.locationCreated;
134
+ check(
135
+ "通道故障下不出现静默 done",
136
+ silentDone === false,
137
+ `error=${result.error ? `"${result.error.slice(0, 40)}"` : "无"}`,
138
+ );
139
+ }
140
+
141
+ console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
142
+ process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 商家任务国家放开领取测试(claimNextJob 分支顺序)
3
+ *
4
+ * 验证(临时 SQLite 库):
5
+ * 1. 传了国家子集:先领对应国家的 ttseller,领完再领任意国家 ttseller
6
+ * 2. 未传国家:领商家(任意国家均可)
7
+ * 3. 其他来源级顺序不变:seed 仍排商家前,非商家任务仍按国家分层
8
+ * 4. 未登录客户端不领商家任务(保持原逻辑)
9
+ *
10
+ * 运行: TMPDIR=$(mktemp -d) node scripts/test-seller-any-country.mjs
11
+ */
12
+ import { createStore } from "../src/watch/data-store.js";
13
+ import { getDb } from "../src/watch/db-schema.js";
14
+ import { join } from "node:path";
15
+ import { mkdtempSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+
18
+ const tmp = mkdtempSync(join(tmpdir(), "seller-any-"));
19
+ const store = createStore(join(tmp, "test.db"));
20
+ const db = getDb();
21
+ if (!db) {
22
+ console.error("❌ db 未初始化");
23
+ process.exit(1);
24
+ }
25
+
26
+ let passed = 0;
27
+ function check(name, fn) {
28
+ try {
29
+ fn();
30
+ passed++;
31
+ console.log(` ✅ ${name}`);
32
+ } catch (e) {
33
+ console.error(` ❌ ${name}\n ${e.message}`);
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+ function assert(cond, msg = "断言失败") {
38
+ if (!cond) throw new Error(msg);
39
+ }
40
+
41
+ const now = Date.now();
42
+ // 直接插 task_queue(refill 后形态)
43
+ const insert = (id, loc, seller, sources, opts = {}) =>
44
+ db
45
+ .prepare(
46
+ `INSERT INTO task_queue (unique_id, status, pinned, guessed_location, tt_seller, verified, sources, video_count, follower_count, created_at, updated_at)
47
+ VALUES (?, 'pending', 0, ?, ?, 0, ?, ?, ?, ?, ?)`,
48
+ )
49
+ .run(
50
+ id,
51
+ loc,
52
+ seller,
53
+ JSON.stringify(sources),
54
+ opts.videoCount ?? 3,
55
+ opts.followers ?? 100,
56
+ now,
57
+ now,
58
+ );
59
+
60
+ console.log("===== 商家任务国家放开领取测试 =====");
61
+
62
+ // ── 场景 1:传 US,队列里有 US 商家 + DE 商家 ──
63
+ console.log("\n[1] 传国家子集 US:US 商家优先于 DE 商家");
64
+ insert("seller_de", "DE", 1, ["following"]);
65
+ insert("seller_us", "US", 1, ["following"]);
66
+ {
67
+ const job = await store.claimNextJob("t1", 5 * 60 * 1000, ["US"], true);
68
+ check("先领到 US 商家", () =>
69
+ assert(job?.uniqueId === "seller_us", `实际: ${job?.uniqueId}`),
70
+ );
71
+ }
72
+
73
+ // ── 场景 2:US 商家已不在(被领走),传 US → 兜任意国家 DE 商家 ──
74
+ console.log("\n[2] 无 US 商家可领时:兜到任意国家商家(DE)");
75
+ {
76
+ // seller_us 已被 t1 领走(processing),队列只剩 DE 商家 pending
77
+ const job = await store.claimNextJob("t2", 5 * 60 * 1000, ["US"], true);
78
+ check("兜到 DE 商家", () =>
79
+ assert(job?.uniqueId === "seller_de", `实际: ${job?.uniqueId}`),
80
+ );
81
+ }
82
+
83
+ // ── 场景 3:传 US 但队列只有 FR 商家 → 领 FR ──
84
+ console.log("\n[3] 传 US 但无 US 商家 → 领 FR 商家(任意国家兜底)");
85
+ db.prepare(`DELETE FROM task_queue`).run();
86
+ insert("seller_fr", "FR", 1, ["following"]);
87
+ {
88
+ const job = await store.claimNextJob("t3", 5 * 60 * 1000, ["US"], true);
89
+ check("领到 FR 商家", () =>
90
+ assert(job?.uniqueId === "seller_fr", `实际: ${job?.uniqueId}`),
91
+ );
92
+ }
93
+
94
+ // ── 场景 4:空位置商家(guessed_location='')→ 任意国家分支领到 ──
95
+ console.log("\n[4] 空位置商家可被任意国家分支领到");
96
+ db.prepare(`DELETE FROM task_queue`).run();
97
+ insert("seller_noloc", "", 1, ["following"]);
98
+ {
99
+ const job = await store.claimNextJob("t4", 5 * 60 * 1000, ["US"], true);
100
+ check("领到空位置商家", () =>
101
+ assert(job?.uniqueId === "seller_noloc", `实际: ${job?.uniqueId}`),
102
+ );
103
+ }
104
+
105
+ // ── 场景 5:未传国家,队列只有配置外国商家 → 直接领到 ──
106
+ console.log("\n[5] 未传国家:配置外国商家(FR)也能领到");
107
+ db.prepare(`DELETE FROM task_queue`).run();
108
+ insert("seller_fr2", "FR", 1, ["following"]);
109
+ {
110
+ const job = await store.claimNextJob("t5", 5 * 60 * 1000, null, true);
111
+ check("领到 FR 商家(未传国家不再被挡)", () =>
112
+ assert(job?.uniqueId === "seller_fr2", `实际: ${job?.uniqueId}`),
113
+ );
114
+ }
115
+
116
+ // ── 场景 6:seed 仍优先于商家(顺序不变)──
117
+ console.log("\n[6] seed 优先级仍高于商家");
118
+ db.prepare(`DELETE FROM task_queue`).run();
119
+ insert("seed_pl", "PL", 0, ["seed"]);
120
+ insert("seller_xx", "XX", 1, ["following"]);
121
+ {
122
+ const job = await store.claimNextJob("t6", 5 * 60 * 1000, null, true);
123
+ check("先领 seed", () =>
124
+ assert(job?.uniqueId === "seed_pl", `实际: ${job?.uniqueId}`),
125
+ );
126
+ }
127
+
128
+ // ── 场景 7:非商家任务仍按国家分层(PL 配置内优先于 ZZ 配置外)──
129
+ console.log("\n[7] 商家清空后:非商家按国家顺序领取");
130
+ db.prepare(`DELETE FROM task_queue`).run();
131
+ insert("other_pl", "PL", 0, ["search"]);
132
+ insert("other_zz", "ZZ", 0, ["search"]);
133
+ {
134
+ const job = await store.claimNextJob("t7", 5 * 60 * 1000, null, true);
135
+ check("PL 非商家先于 ZZ(配置内优先)", () =>
136
+ assert(job?.uniqueId === "other_pl", `实际: ${job?.uniqueId}`),
137
+ );
138
+ }
139
+
140
+ // ── 场景 8:未登录客户端不领商家(原逻辑不变)──
141
+ console.log("\n[8] 未登录不领商家任务");
142
+ db.prepare(`DELETE FROM task_queue`).run();
143
+ insert("seller_only", "US", 1, ["following"]);
144
+ insert("other_task", "PL", 0, ["search"]);
145
+ {
146
+ const job = await store.claimNextJob("t8", 5 * 60 * 1000, null, false);
147
+ check("未登录跳过商家领普通任务", () =>
148
+ assert(job?.uniqueId === "other_task", `实际: ${job?.uniqueId}`),
149
+ );
150
+ }
151
+
152
+ // ── 场景 9:非商家任务不享受任意国家兜底(配置内先于配置外)──
153
+ console.log("\n[9] 非商家配置外国家不越级");
154
+ db.prepare(`DELETE FROM task_queue`).run();
155
+ insert("plain_de", "DE", 0, ["search"]); // DE 在默认配置内
156
+ insert("plain_zz", "ZZ", 0, ["search"]); // ZZ 不在配置
157
+ {
158
+ const job = await store.claimNextJob("t9", 5 * 60 * 1000, null, true);
159
+ check("DE(配置内)先于 ZZ(配置外)", () =>
160
+ assert(job?.uniqueId === "plain_de", `实际: ${job?.uniqueId}`),
161
+ );
162
+ }
163
+
164
+ store.stopBackup?.();
165
+ console.log(
166
+ `\n结果: ${passed} 通过${process.exitCode ? ", 有失败" : ", 0 失败"}`,
167
+ );
@@ -73,6 +73,8 @@ export async function handleExplore(options) {
73
73
  exploreProxy,
74
74
  exploreWebserver,
75
75
  exploreNoProduct,
76
+ exploreSwitchDelay,
77
+ exploreCommentDelay,
76
78
  } = options;
77
79
 
78
80
  let userId = exploreUserId || configuredUserId;
@@ -111,6 +113,17 @@ export async function handleExplore(options) {
111
113
  }
112
114
 
113
115
  setDelayConfig(explorePreset);
116
+ // 自定义延迟:数值参数优先于预设(仅覆盖给出的项)
117
+ if (exploreSwitchDelay != null || exploreCommentDelay != null) {
118
+ const overrides = {};
119
+ if (exploreSwitchDelay != null) overrides.switchMax = exploreSwitchDelay;
120
+ if (exploreCommentDelay != null)
121
+ overrides.commentMax = exploreCommentDelay;
122
+ setDelayConfig(overrides);
123
+ console.error(
124
+ `自定义延迟: 切换 ${overrides.switchMax ?? "预设值"}ms, 评论 ${overrides.commentMax ?? "预设值"}ms`,
125
+ );
126
+ }
114
127
 
115
128
  console.error(`\n国家筛选: ${exploreLocation}`);
116
129
  if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
@@ -562,14 +575,21 @@ export async function handleExplore(options) {
562
575
  // 可能在验证码延迟渲染前已执行,或已识别到( captchaDetected=true )但后续流程又抛了异常出现 error。
563
576
  // 这里在把错误上报为 network/other 前对当前页面再检测一次,识别到则转走验证码分支。
564
577
  if (result.error) {
578
+ // 先记住原错误类型:兜底转验证码时带上,服务端可同时看到"被判+验证码"
579
+ const wasBlocked = result.error.startsWith("被封:");
565
580
  try {
566
581
  const lateCaptcha = await detectCaptcha(page);
567
582
  if (lateCaptcha && lateCaptcha.visible) {
568
- console.error(` [验证码] 兜底识别到验证码 @${username}`);
583
+ console.error(
584
+ ` [验证码] 兜底识别到验证码 @${username}${wasBlocked ? "(被判后弹出)" : ""}`,
585
+ );
569
586
  result.captchaDetected = true;
570
587
  result.captchaStage = result.captchaStage || "process";
571
588
  result.captchaMessage =
572
- result.captchaMessage || "上报错误前识别到验证码";
589
+ result.captchaMessage ||
590
+ (wasBlocked
591
+ ? `被判后弹出验证码(原错误: ${result.error})`
592
+ : "上报错误前识别到验证码");
573
593
  result.error = null;
574
594
  result.errorStack = "";
575
595
  result.retryable = false;
@@ -540,9 +540,21 @@ export async function handleProductFill(options) {
540
540
  batchSuccess++;
541
541
  totalSuccess++;
542
542
  failCount.delete(uniqueId);
543
+ const skus = detail.skus;
544
+ const skuInfo = skus
545
+ ? ` | SKU ${skus.length}个 总库存 ${skus.reduce((sum, s) => sum + (s.stock || 0), 0)}`
546
+ : "";
543
547
  log(
544
- ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"})`,
548
+ ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"}${skuInfo})`,
545
549
  );
550
+ if (skus) {
551
+ // SKU 明细日志:规格 | 库存 | 状态(先观察数据质量,暂不入库)
552
+ for (const s of skus) {
553
+ log(
554
+ ` · ${s.skuId} | ${s.specs || "(无规格名)"} | 库存${s.stock ?? "?"} | 状态${s.stockStatus ?? "?"}`,
555
+ );
556
+ }
557
+ }
546
558
  } else {
547
559
  batchFailed++;
548
560
  totalFailed++;
@@ -191,6 +191,17 @@ export async function handleRefreshMini(options) {
191
191
  }
192
192
 
193
193
  setDelayConfig(explorePreset);
194
+ // 自定义延迟:数值参数优先于预设(仅覆盖给出的项)
195
+ if (exploreSwitchDelay != null || exploreCommentDelay != null) {
196
+ const overrides = {};
197
+ if (exploreSwitchDelay != null) overrides.switchMax = exploreSwitchDelay;
198
+ if (exploreCommentDelay != null)
199
+ overrides.commentMax = exploreCommentDelay;
200
+ setDelayConfig(overrides);
201
+ console.error(
202
+ `自定义延迟: 切换 ${overrides.switchMax ?? "预设值"}ms, 评论 ${overrides.commentMax ?? "预设值"}ms`,
203
+ );
204
+ }
194
205
 
195
206
  console.error(`\n=== refresh-mini 模式(视频信息更新) ===`);
196
207
  console.error(`服务器: ${serverUrl}`);
@@ -119,6 +119,17 @@ export async function handleRefresh(options) {
119
119
  }
120
120
 
121
121
  setDelayConfig(explorePreset);
122
+ // 自定义延迟:数值参数优先于预设(仅覆盖给出的项)
123
+ if (exploreSwitchDelay != null || exploreCommentDelay != null) {
124
+ const overrides = {};
125
+ if (exploreSwitchDelay != null) overrides.switchMax = exploreSwitchDelay;
126
+ if (exploreCommentDelay != null)
127
+ overrides.commentMax = exploreCommentDelay;
128
+ setDelayConfig(overrides);
129
+ console.error(
130
+ `自定义延迟: 切换 ${overrides.switchMax ?? "预设值"}ms, 评论 ${overrides.commentMax ?? "预设值"}ms`,
131
+ );
132
+ }
122
133
 
123
134
  console.error(`\n=== Refresh 模式(基于 explore) ===`);
124
135
  console.error(`服务器: ${serverUrl}`);
@@ -1,5 +1,6 @@
1
1
  import { delay } from "./delay.js";
2
2
  import { assertPageUrl } from "./browser/page.js";
3
+ import { detectCaptcha } from "../scraper/modules/captcha-handler.js";
3
4
  import {
4
5
  detectPageError,
5
6
  detectPageErrorWithWait,
@@ -46,9 +47,17 @@ function extractProductInfo(item) {
46
47
 
47
48
  /**
48
49
  * 构建统一的视频对象
50
+ *
51
+ * 图文帖(photo post)与视频混在 item_list 响应里,用 imagePost 字段区分
52
+ * (实测 @zoecole.fr:图文 item 有 imagePost={cover,images,...},
53
+ * video.duration=0 且无 PlayAddrStruct/codecType;视频 duration>0)。
54
+ * - isPhoto 标记贯穿全链路:EC 筛选/入库照常保留图文,仅国家采样排除
55
+ * - href 按真实路径生成:图文 /photo/{id},视频 /video/{id}
49
56
  */
50
57
  function buildVideoObject(item, username) {
51
- const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
58
+ // 双保险:优先官方 imagePost 字段,duration=0 兜底(防字段偶发缺失)
59
+ const isPhoto = item.imagePost !== undefined || item.video?.duration === 0;
60
+ const href = `https://www.tiktok.com/@${username}/${isPhoto ? "photo" : "video"}/${item.id}`;
52
61
  const productInfo = extractProductInfo(item);
53
62
  return {
54
63
  id: item.id,
@@ -57,6 +66,7 @@ function buildVideoObject(item, username) {
57
66
  playCount: item.stats?.playCount || 0,
58
67
  isECVideo: item.isECVideo ? 1 : 0,
59
68
  isAd: item.isAd ? 1 : 0,
69
+ isPhoto: isPhoto ? 1 : 0,
60
70
  productId: productInfo?.productId || null,
61
71
  productTitle: productInfo?.productTitle || null,
62
72
  seoUrl: productInfo?.seoUrl || null,
@@ -381,6 +391,20 @@ async function fetchUserVideosAPI(
381
391
  rawBodyBytes === 0
382
392
  ? "空响应(0 字节)"
383
393
  : `限流(HTTP ${httpStatus}, ${bodyText.slice(0, 60)})`;
394
+ // 被封形态(尤其 429/ratelimit)常伴随页面弹出验证码:此时继续重试无意义,
395
+ // 人工解决验证码才是正解。检测到立即抛 "验证码:",跳过剩余 30/40/50s 空转重试
396
+ try {
397
+ const captcha = await detectCaptcha(page);
398
+ if (captcha && captcha.visible) {
399
+ log(
400
+ ` [API拦截] item_list ${reason},页面已弹出验证码,转验证码处理(不再重试)`,
401
+ );
402
+ throw new Error(`验证码: ${username}`);
403
+ }
404
+ } catch (e) {
405
+ if (e.message && e.message.startsWith("验证码:")) throw e;
406
+ // 检测本身失败(页面导航中/上下文销毁等):忽略,继续按被封重试
407
+ }
384
408
  const retryIndex = attempt - 1;
385
409
  if (retryIndex < retryWaits.length) {
386
410
  const waitMs = retryWaits[retryIndex];
package/src/lib/args.js CHANGED
@@ -185,6 +185,8 @@ function parseExploreArgs(args) {
185
185
  let exploreProxy = null;
186
186
  let exploreWebserver = null;
187
187
  let exploreNoProduct = false;
188
+ let exploreSwitchDelay = null;
189
+ let exploreCommentDelay = null;
188
190
 
189
191
  const positional = [];
190
192
  const PRESETS = ["fast", "normal", "slow", "stealth"];
@@ -229,6 +231,12 @@ function parseExploreArgs(args) {
229
231
  exploreWebserver = args[++i];
230
232
  } else if (arg === "--no-product") {
231
233
  exploreNoProduct = true;
234
+ } else if (arg === "--preset") {
235
+ explorePreset = args[++i];
236
+ } else if (arg === "--switch-delay") {
237
+ exploreSwitchDelay = parseInt(args[++i], 10) || null;
238
+ } else if (arg === "--comment-delay") {
239
+ exploreCommentDelay = parseInt(args[++i], 10) || null;
232
240
  } else {
233
241
  positional.push(arg);
234
242
  }
@@ -254,6 +262,8 @@ function parseExploreArgs(args) {
254
262
  subcommand: "explore",
255
263
  exploreUsernames: usernames,
256
264
  explorePreset,
265
+ exploreSwitchDelay,
266
+ exploreCommentDelay,
257
267
  exploreInterval,
258
268
  exploreMaxComments,
259
269
  exploreMaxGuess,
@@ -542,6 +552,8 @@ function parseRefreshArgs(args) {
542
552
  let exploreWebserver = null;
543
553
  let exploreNoProduct = false;
544
554
  let exploreMini = false;
555
+ let exploreSwitchDelay = null;
556
+ let exploreCommentDelay = null;
545
557
 
546
558
  for (let i = 0; i < args.length; i++) {
547
559
  const arg = args[i];
@@ -551,6 +563,10 @@ function parseRefreshArgs(args) {
551
563
  exploreMini = true;
552
564
  } else if (arg === "--preset") {
553
565
  explorePreset = args[++i];
566
+ } else if (arg === "--switch-delay") {
567
+ exploreSwitchDelay = parseInt(args[++i], 10) || null;
568
+ } else if (arg === "--comment-delay") {
569
+ exploreCommentDelay = parseInt(args[++i], 10) || null;
554
570
  } else if (arg === "-i" || arg === "--interval") {
555
571
  exploreInterval = parseInt(args[++i], 10) || 30;
556
572
  } else if (arg === "--port") {
@@ -593,6 +609,8 @@ function parseRefreshArgs(args) {
593
609
  return {
594
610
  subcommand: "refresh",
595
611
  explorePreset,
612
+ exploreSwitchDelay,
613
+ exploreCommentDelay,
596
614
  exploreInterval,
597
615
  exploreEnableFollow,
598
616
  exploreMaxFollowing,
@@ -640,6 +658,8 @@ function parseRefreshMiniArgs(args) {
640
658
  let rotateMin = 10;
641
659
  let captchaWaitMin = 10;
642
660
  let ensureDaemon = false;
661
+ let exploreSwitchDelay = null;
662
+ let exploreCommentDelay = null;
643
663
 
644
664
  for (let i = 0; i < args.length; i++) {
645
665
  const arg = args[i];
@@ -647,6 +667,10 @@ function parseRefreshMiniArgs(args) {
647
667
  serverUrl = args[++i];
648
668
  } else if (arg === "--preset") {
649
669
  explorePreset = args[++i];
670
+ } else if (arg === "--switch-delay") {
671
+ exploreSwitchDelay = parseInt(args[++i], 10) || null;
672
+ } else if (arg === "--comment-delay") {
673
+ exploreCommentDelay = parseInt(args[++i], 10) || null;
650
674
  } else if (arg === "-i" || arg === "--interval") {
651
675
  exploreInterval = parseInt(args[++i], 10) || 30;
652
676
  } else if (arg === "--port") {
@@ -677,6 +701,8 @@ function parseRefreshMiniArgs(args) {
677
701
  return {
678
702
  subcommand: "refresh-mini",
679
703
  explorePreset,
704
+ exploreSwitchDelay,
705
+ exploreCommentDelay,
680
706
  exploreInterval,
681
707
  exploreLocation,
682
708
  exploreJobLocations,
@@ -124,6 +124,9 @@ const HELP_TEXT = [
124
124
  " 预设: fast, normal(默认), slow, stealth",
125
125
  " 选项:",
126
126
  " --server <URL> 服务端地址,默认 http://127.0.0.1:3001",
127
+ " --preset <预设> 延迟预设 fast/normal/slow/stealth,默认 normal",
128
+ " --switch-delay <毫秒> 任务切换延迟上限,覆盖预设值(实际等待随机 delay(switchMax, switchMax*3))",
129
+ " --comment-delay <毫秒> 评论滚动延迟上限,覆盖预设值",
127
130
  ` --location <国家代码> 国家筛选,逗号分隔,默认 ${DEFAULT_TARGET_LOCATIONS_CSV}`,
128
131
  " --job-locations <国家> 任务国家筛选,逗号分隔(仅筛选服务端任务)",
129
132
  " --max-comments <数量> 每视频最大评论数,默认 10",
@@ -139,8 +142,11 @@ const HELP_TEXT = [
139
142
  " --base-port <端口号> 起始端口,默认 9222",
140
143
  " --port-count <数量> 端口数量(账户数),默认 10",
141
144
  " --user-id <编号> 客户端编号(设备ID),默认自动生成",
142
- "--proxy <地址> 浏览器代理(如 socks5://127.0.0.1:1080),未指定时使用配置文件 proxy",
143
- "--webserver <URL> 集中式视频国家查询服务(如 http://127.0.0.1:3000),省去本地 Playwright 浏览器",
145
+ " --preset <预设> 延迟预设 fast/normal/slow/stealth,默认 normal",
146
+ " --switch-delay <毫秒> 任务切换延迟上限,覆盖预设值",
147
+ " --comment-delay <毫秒> 评论滚动延迟上限,覆盖预设值",
148
+ " --proxy <地址> 浏览器代理(如 socks5://127.0.0.1:1080),未指定时使用配置文件 proxy",
149
+ " --webserver <URL> 集中式视频国家查询服务(如 http://127.0.0.1:3000),省去本地 Playwright 浏览器",
144
150
  "",
145
151
  " tt-help refresh [选项]",
146
152
  " 对目标商家用户进行轮回刷新,重新采集视频 + 关注 + 粉丝",
@@ -162,6 +168,9 @@ const HELP_TEXT = [
162
168
  " --base-port <端口号> 起始端口,默认 9222",
163
169
  " --port-count <数量> 端口数量(账户数),默认 10",
164
170
  " --user-id <编号> 客户端编号(设备ID),默认自动生成",
171
+ " --preset <预设> 延迟预设 fast/normal/slow/stealth,默认 normal",
172
+ " --switch-delay <毫秒> 任务切换延迟上限,覆盖预设值",
173
+ " --comment-delay <毫秒> 评论滚动延迟上限,覆盖预设值",
165
174
  "--proxy <地址> 浏览器代理(如 socks5://127.0.0.1:1080),未指定时使用配置文件 proxy",
166
175
  "--webserver <URL> 集中式视频国家查询服务(如 http://127.0.0.1:3000),省去本地 Playwright 浏览器",
167
176
  "",
@@ -184,6 +193,8 @@ const HELP_TEXT = [
184
193
  " --captcha-wait-min <分钟> 验证码等待人工解决上限,默认 10",
185
194
  " --ensure-daemon 启动时检查 clash-daemon 是否存活,未存活则自动拉起(默认不检测)",
186
195
  " --preset <预设> 延迟预设 fast/normal/slow/stealth,默认 normal",
196
+ " --switch-delay <毫秒> 任务切换延迟上限,覆盖预设值",
197
+ " --comment-delay <毫秒> 评论滚动延迟上限,覆盖预设值",
187
198
  " --proxy <地址> 浏览器代理(默认走 Clash 本地端口,切换节点才有效)",
188
199
  "",
189
200
  " tt-help precheck [选项]",