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.
- package/package.json +1 -1
- package/scripts/test-api-interceptor-captcha.mjs +134 -0
- package/scripts/test-photo-post.mjs +156 -0
- package/scripts/test-precheck-perf.mjs +270 -0
- package/scripts/test-product-fill-cdp.mjs +44 -0
- package/scripts/test-product-fill-db.mjs +43 -0
- package/scripts/test-sample-all-fail.mjs +142 -0
- package/scripts/test-seller-any-country.mjs +167 -0
- package/src/cli/explore.js +22 -2
- package/src/cli/product-fill.js +13 -1
- package/src/cli/refresh-mini.js +11 -0
- package/src/cli/refresh.js +11 -0
- package/src/lib/api-interceptor.js +25 -1
- package/src/lib/args.js +26 -0
- package/src/lib/constants.js +13 -2
- package/src/lib/shop-scraper.mjs +79 -0
- package/src/scraper/auto-core.js +61 -37
- package/src/scraper/explore-core.js +45 -4
- package/src/videos/core.js +5 -0
- package/src/watch/data-store.js +67 -20
- package/src/watch/db-columns.js +1 -0
- package/src/watch/db-schema.js +3 -0
- package/src/watch/db-tags.js +15 -5
- package/src/watch/public/app.js +89 -17
- package/src/watch/public/index.html +60 -55
- package/src/watch/public/style.css +80 -4
- package/src/watch/server.js +14 -0
|
@@ -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
|
+
);
|
package/src/cli/explore.js
CHANGED
|
@@ -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(
|
|
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;
|
package/src/cli/product-fill.js
CHANGED
|
@@ -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++;
|
package/src/cli/refresh-mini.js
CHANGED
|
@@ -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}`);
|
package/src/cli/refresh.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/lib/constants.js
CHANGED
|
@@ -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
|
-
"--
|
|
143
|
-
"--
|
|
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 [选项]",
|