tt-help-cli-ycl 1.3.33 → 1.3.35
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/run-explore.bat +6 -1
- package/scripts/run-explore.ps1 +5 -1
- package/scripts/run-explore.sh +7 -1
- package/src/cli/explore.js +10 -3
- package/src/lib/api-interceptor.js +43 -202
- package/src/lib/args.js +1 -1
- package/src/lib/constants.js +8 -5
- package/src/scraper/explore-core.js +19 -9
- package/src/watch/data-store.js +13 -3
- package/src/watch/public/index.html +1381 -676
- package/src/watch/server.js +8 -2
package/package.json
CHANGED
package/scripts/run-explore.bat
CHANGED
|
@@ -107,6 +107,10 @@ IF "%BASE_PORT%"=="" SET "BASE_PORT=9222"
|
|
|
107
107
|
SET /P "PORT_COUNT=Port count (--port-count) [default: 10]: "
|
|
108
108
|
IF "%PORT_COUNT%"=="" SET "PORT_COUNT=10"
|
|
109
109
|
|
|
110
|
+
SET /P "JOB_LOCATIONS=Job locations (--job-locations) [default: none]: "
|
|
111
|
+
SET "JOB_LOC_ARGS="
|
|
112
|
+
IF NOT "%JOB_LOCATIONS%"=="" SET "JOB_LOC_ARGS=--job-locations %JOB_LOCATIONS%"
|
|
113
|
+
|
|
110
114
|
REM ---------- 4. Get user ID from config and compose ----------
|
|
111
115
|
SET "CONFIG_USER_ID=user"
|
|
112
116
|
IF EXIST "%CONFIG_PATH%" (
|
|
@@ -122,6 +126,7 @@ ECHO ========================================
|
|
|
122
126
|
CALL ECHO User ID: %%USER_ID%%
|
|
123
127
|
CALL ECHO Base port: %%BASE_PORT%%
|
|
124
128
|
CALL ECHO Port count: %%PORT_COUNT%%
|
|
129
|
+
IF NOT "%JOB_LOCATIONS%"=="" ECHO Job locations: %JOB_LOCATIONS%
|
|
125
130
|
ECHO Speed: stealth (slowest)
|
|
126
131
|
ECHO ========================================
|
|
127
|
-
CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%%
|
|
132
|
+
CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%% %%JOB_LOC_ARGS%%
|
package/scripts/run-explore.ps1
CHANGED
|
@@ -127,6 +127,9 @@ $basePort = if ($inputBasePort) { $inputBasePort } else { "9222" }
|
|
|
127
127
|
$inputPortCount = Read-Host "端口数量 (--port-count) [默认: 10]"
|
|
128
128
|
$portCount = if ($inputPortCount) { $inputPortCount } else { "10" }
|
|
129
129
|
|
|
130
|
+
$inputJobLocations = Read-Host "任务国家 (--job-locations) [默认: 无,使用全部]"
|
|
131
|
+
$jobLocationsArg = if ($inputJobLocations) { "--job-locations $inputJobLocations" } else { "" }
|
|
132
|
+
|
|
130
133
|
# ---------- 4. 获取配置中的用户编号并拼接 ----------
|
|
131
134
|
$configUserId = ""
|
|
132
135
|
if (Test-Path $configPath) {
|
|
@@ -148,6 +151,7 @@ Write-Host "========================================"
|
|
|
148
151
|
Write-Host " 用户编号: $userId"
|
|
149
152
|
Write-Host " 起始端口: $basePort"
|
|
150
153
|
Write-Host " 端口数量: $portCount"
|
|
154
|
+
if ($inputJobLocations) { Write-Host " 任务国家: $inputJobLocations" }
|
|
151
155
|
Write-Host " 速度: stealth (最慢)"
|
|
152
156
|
Write-Host "========================================"
|
|
153
|
-
tt-help explore stealth --user-id $userId --base-port $basePort --port-count $portCount
|
|
157
|
+
tt-help explore stealth --user-id $userId --base-port $basePort --port-count $portCount $jobLocationsArg
|
package/scripts/run-explore.sh
CHANGED
|
@@ -91,6 +91,9 @@ printf "端口数量 (--port-count) [默认: 10]: "
|
|
|
91
91
|
read INPUT_PORT_COUNT
|
|
92
92
|
PORT_COUNT="${INPUT_PORT_COUNT:-10}"
|
|
93
93
|
|
|
94
|
+
printf "任务国家 (--job-locations) [默认: 无,使用全部]: "
|
|
95
|
+
read INPUT_JOB_LOCATIONS
|
|
96
|
+
|
|
94
97
|
# ---------- 4. 获取配置中的用户编号并拼接 ----------
|
|
95
98
|
CONFIG_USER_ID=""
|
|
96
99
|
if [ -f "$CONFIG_PATH" ]; then
|
|
@@ -108,6 +111,9 @@ echo "========================================"
|
|
|
108
111
|
echo " 用户编号: $USER_ID"
|
|
109
112
|
echo " 起始端口: $BASE_PORT"
|
|
110
113
|
echo " 端口数量: $PORT_COUNT"
|
|
114
|
+
if [ -n "$INPUT_JOB_LOCATIONS" ]; then echo " 任务国家: $INPUT_JOB_LOCATIONS"; fi
|
|
111
115
|
echo " 速度: stealth (最慢)"
|
|
112
116
|
echo "========================================"
|
|
113
|
-
|
|
117
|
+
JOB_LOC_ARGS=""
|
|
118
|
+
if [ -n "$INPUT_JOB_LOCATIONS" ]; then JOB_LOC_ARGS="--job-locations $INPUT_JOB_LOCATIONS"; fi
|
|
119
|
+
tt-help explore stealth --user-id "$USER_ID" --base-port "$BASE_PORT" --port-count "$PORT_COUNT" $JOB_LOC_ARGS
|
package/src/cli/explore.js
CHANGED
|
@@ -197,6 +197,7 @@ export async function handleExplore(options) {
|
|
|
197
197
|
);
|
|
198
198
|
lastFollowSuccessTime = Date.now(); // 切换账户后重置
|
|
199
199
|
captchaCount = 0; // 重置验证码计数
|
|
200
|
+
blockedCount = 0; // 重置被封计数
|
|
200
201
|
consecutiveNetworkErrors = 0; // 重置网络错误计数
|
|
201
202
|
}
|
|
202
203
|
|
|
@@ -204,13 +205,14 @@ export async function handleExplore(options) {
|
|
|
204
205
|
let errorCount = 0;
|
|
205
206
|
let consecutiveNetworkErrors = 0;
|
|
206
207
|
let captchaCount = 0; // 验证码累计计数
|
|
208
|
+
let blockedCount = 0; // 被封累计计数
|
|
207
209
|
let lastFollowSuccessTime = Date.now(); // 最近一次成功获取关注/粉丝的时间
|
|
208
210
|
const FOLLOW_BLOCK_THRESHOLD = 10 * 60 * 1000; // 10分钟
|
|
209
211
|
|
|
210
212
|
while (true) {
|
|
211
213
|
const jobQuery = exploreJobLocations
|
|
212
|
-
? `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&locations=${encodeURIComponent(exploreJobLocations)}`
|
|
213
|
-
: `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}`;
|
|
214
|
+
? `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&locations=${encodeURIComponent(exploreJobLocations)}&loggedIn=${loggedIn}`
|
|
215
|
+
: `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&loggedIn=${loggedIn}`;
|
|
214
216
|
const job = await apiGet(jobQuery);
|
|
215
217
|
if (!job.hasJob) break;
|
|
216
218
|
|
|
@@ -385,7 +387,12 @@ export async function handleExplore(options) {
|
|
|
385
387
|
}),
|
|
386
388
|
).catch(() => {});
|
|
387
389
|
if (errorType === "被封") {
|
|
388
|
-
|
|
390
|
+
blockedCount++;
|
|
391
|
+
console.error(` [被封] 累计 ${blockedCount} 次`);
|
|
392
|
+
if (blockedCount >= 3) {
|
|
393
|
+
await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
|
|
394
|
+
blockedCount = 0;
|
|
395
|
+
}
|
|
389
396
|
continue;
|
|
390
397
|
}
|
|
391
398
|
if (exploreMaxUsers > 0 && processedCount >= exploreMaxUsers) {
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { delay } from "./delay.js";
|
|
2
|
-
import { retryWithBackoff } from "./retry.js";
|
|
3
2
|
import { assertPageUrl } from "./browser/page.js";
|
|
4
3
|
import {
|
|
5
4
|
detectPageError,
|
|
6
5
|
detectPageErrorWithWait,
|
|
7
6
|
} from "./page-error-detector.js";
|
|
8
7
|
|
|
9
|
-
/**
|
|
10
|
-
* 处理 API 响应数据:提取首页视频 + 翻页
|
|
11
|
-
*/
|
|
12
8
|
async function processAPIResponse(
|
|
13
9
|
data,
|
|
14
10
|
username,
|
|
@@ -18,7 +14,6 @@ async function processAPIResponse(
|
|
|
18
14
|
apiRequestUrl,
|
|
19
15
|
log,
|
|
20
16
|
) {
|
|
21
|
-
// 提取首页视频
|
|
22
17
|
const firstPageItems = data.itemList || [];
|
|
23
18
|
for (const item of firstPageItems) {
|
|
24
19
|
if (items.length >= maxVideos) break;
|
|
@@ -26,7 +21,6 @@ async function processAPIResponse(
|
|
|
26
21
|
items.push({ id: item.id, href });
|
|
27
22
|
}
|
|
28
23
|
|
|
29
|
-
// 翻页获取后续视频
|
|
30
24
|
let cursor = data.cursor;
|
|
31
25
|
let hasMore = data.hasMore;
|
|
32
26
|
|
|
@@ -61,7 +55,6 @@ async function processAPIResponse(
|
|
|
61
55
|
|
|
62
56
|
log(` [API拦截] 共 ${items.length} 条视频`);
|
|
63
57
|
|
|
64
|
-
// 转成 Map 返回,与 collectVideos 一致
|
|
65
58
|
const videoMap = new Map();
|
|
66
59
|
for (const v of items) {
|
|
67
60
|
if (!videoMap.has(v.id)) videoMap.set(v.id, v);
|
|
@@ -70,232 +63,80 @@ async function processAPIResponse(
|
|
|
70
63
|
return videoMap;
|
|
71
64
|
}
|
|
72
65
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
* @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
|
|
77
|
-
*/
|
|
78
|
-
async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
|
|
79
|
-
const url = `https://www.tiktok.com/@${username}`;
|
|
66
|
+
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
67
|
+
log(` [API拦截] 获取 @${username} 视频 ...`);
|
|
68
|
+
|
|
80
69
|
let apiRequestUrl = null;
|
|
81
70
|
let sawApiRequest = false;
|
|
82
|
-
let sawApiResponse = false;
|
|
83
|
-
|
|
84
|
-
const responseHandler = async (response) => {
|
|
85
|
-
if (response.url().includes("/api/post/item_list/")) {
|
|
86
|
-
sawApiResponse = true;
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
71
|
|
|
90
72
|
const requestHandler = (request) => {
|
|
91
|
-
if (request.url().includes("/api/post/item_list/")
|
|
92
|
-
sawApiRequest = true;
|
|
93
|
-
apiRequestUrl = request.url();
|
|
94
|
-
} else if (request.url().includes("/api/post/item_list/")) {
|
|
73
|
+
if (request.url().includes("/api/post/item_list/")) {
|
|
95
74
|
sawApiRequest = true;
|
|
75
|
+
if (!apiRequestUrl) apiRequestUrl = request.url();
|
|
96
76
|
}
|
|
97
77
|
};
|
|
98
78
|
|
|
99
|
-
page.on("response", responseHandler);
|
|
100
79
|
page.on("request", requestHandler);
|
|
101
80
|
|
|
102
81
|
const t0 = Date.now();
|
|
82
|
+
let data = null;
|
|
83
|
+
let interceptionError = null;
|
|
103
84
|
|
|
104
85
|
try {
|
|
105
|
-
await page.goto(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// 用 waitForResponse 精确等待 API 响应(超时时间可配置)
|
|
109
|
-
const response = await page.waitForResponse(
|
|
110
|
-
(res) => res.url().includes("/api/post/item_list/"),
|
|
111
|
-
{ timeout: apiTimeout },
|
|
86
|
+
await page.goto(
|
|
87
|
+
`https://www.tiktok.com/@${username}`,
|
|
88
|
+
{ waitUntil: "domcontentloaded", timeout: 30000 },
|
|
112
89
|
);
|
|
90
|
+
await assertPageUrl(page, `@${username}`);
|
|
113
91
|
|
|
114
|
-
sawApiResponse = true;
|
|
115
|
-
const data = await response.json();
|
|
116
|
-
const elapsed = Date.now() - t0;
|
|
117
|
-
return { data, apiRequestUrl, elapsed, sawApiRequest, sawApiResponse };
|
|
118
|
-
} catch (e) {
|
|
119
|
-
const elapsed = Date.now() - t0;
|
|
120
|
-
return {
|
|
121
|
-
data: null,
|
|
122
|
-
apiRequestUrl,
|
|
123
|
-
elapsed,
|
|
124
|
-
error: e.message,
|
|
125
|
-
sawApiRequest,
|
|
126
|
-
sawApiResponse,
|
|
127
|
-
};
|
|
128
|
-
} finally {
|
|
129
|
-
page.off("response", responseHandler);
|
|
130
|
-
page.off("request", requestHandler);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 软重试:不重新 goto,直接在当前页继续等待 item_list API。
|
|
136
|
-
*/
|
|
137
|
-
async function tryWaitCurrentPageAPI(page, apiTimeout = 12000) {
|
|
138
|
-
let apiRequestUrl = null;
|
|
139
|
-
let sawApiRequest = false;
|
|
140
|
-
let sawApiResponse = false;
|
|
141
|
-
|
|
142
|
-
const requestHandler = (request) => {
|
|
143
|
-
if (request.url().includes("/api/post/item_list/") && !apiRequestUrl) {
|
|
144
|
-
sawApiRequest = true;
|
|
145
|
-
apiRequestUrl = request.url();
|
|
146
|
-
} else if (request.url().includes("/api/post/item_list/")) {
|
|
147
|
-
sawApiRequest = true;
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const responseHandler = (response) => {
|
|
152
|
-
if (response.url().includes("/api/post/item_list/")) {
|
|
153
|
-
sawApiResponse = true;
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const t0 = Date.now();
|
|
158
|
-
page.on("request", requestHandler);
|
|
159
|
-
page.on("response", responseHandler);
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
92
|
const response = await page.waitForResponse(
|
|
163
93
|
(res) => res.url().includes("/api/post/item_list/"),
|
|
164
|
-
{ timeout:
|
|
94
|
+
{ timeout: 30000 },
|
|
165
95
|
);
|
|
166
96
|
|
|
167
|
-
|
|
168
|
-
const data = await response.json();
|
|
169
|
-
const elapsed = Date.now() - t0;
|
|
170
|
-
return { data, apiRequestUrl, elapsed, sawApiRequest, sawApiResponse };
|
|
97
|
+
data = await response.json();
|
|
171
98
|
} catch (e) {
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
data: null,
|
|
175
|
-
apiRequestUrl,
|
|
176
|
-
elapsed,
|
|
177
|
-
error: e.message,
|
|
178
|
-
sawApiRequest,
|
|
179
|
-
sawApiResponse,
|
|
180
|
-
};
|
|
99
|
+
interceptionError = e.message;
|
|
181
100
|
} finally {
|
|
182
101
|
page.off("request", requestHandler);
|
|
183
|
-
page.off("response", responseHandler);
|
|
184
102
|
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* 通过拦截 TikTok 内部 API 获取用户视频列表
|
|
189
|
-
* 比 DOM 滚动解析快 5-10 倍
|
|
190
|
-
*
|
|
191
|
-
* 弱网优化:
|
|
192
|
-
* - 第 1 轮:正常 goto,等待 15s
|
|
193
|
-
* - 第 1.5 轮:不重新 goto,留在当前页继续等待 12s
|
|
194
|
-
* - 第 2 轮:重新 goto,等待 30s
|
|
195
|
-
* - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
|
|
196
|
-
*
|
|
197
|
-
* @param {import('playwright').Page} page - Playwright page (CDP 连接)
|
|
198
|
-
* @param {string} username - TikTok 用户名
|
|
199
|
-
* @param {number} maxVideos - 最大视频数
|
|
200
|
-
* @param {Function} log - 日志函数
|
|
201
|
-
* @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
|
|
202
|
-
* @throws {Error} API 超时或网络异常时抛出
|
|
203
|
-
*/
|
|
204
|
-
async function fetchUserVideosAPI(page, username, maxVideos, log) {
|
|
205
|
-
const items = [];
|
|
206
|
-
const noRetryErrors = new Set([
|
|
207
|
-
"service_error",
|
|
208
|
-
"not_found",
|
|
209
|
-
"region_blocked",
|
|
210
|
-
"login_required",
|
|
211
|
-
]);
|
|
212
103
|
|
|
213
|
-
const
|
|
214
|
-
{
|
|
215
|
-
label: ` [API拦截] 获取 @${username} 视频 ...`,
|
|
216
|
-
mode: "goto",
|
|
217
|
-
apiTimeout: 15000,
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
label: " [API拦截] 首轮未命中,当前页继续补等 12s ...",
|
|
221
|
-
mode: "wait",
|
|
222
|
-
apiTimeout: 12000,
|
|
223
|
-
},
|
|
224
|
-
{
|
|
225
|
-
label: " [API拦截] 重新加载主页后重试 (超时 30s)",
|
|
226
|
-
mode: "goto",
|
|
227
|
-
apiTimeout: 30000,
|
|
228
|
-
},
|
|
229
|
-
];
|
|
104
|
+
const elapsed = Date.now() - t0;
|
|
230
105
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const {
|
|
106
|
+
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
107
|
+
log(` [API拦截] 成功,耗时 ${elapsed}ms`);
|
|
108
|
+
return await processAPIResponse(
|
|
236
109
|
data,
|
|
110
|
+
username,
|
|
111
|
+
maxVideos,
|
|
112
|
+
[],
|
|
113
|
+
page,
|
|
237
114
|
apiRequestUrl,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
sawApiResponse,
|
|
242
|
-
} =
|
|
243
|
-
currentAttempt.mode === "goto"
|
|
244
|
-
? await tryInterceptAPI(page, username, log, currentAttempt.apiTimeout)
|
|
245
|
-
: await tryWaitCurrentPageAPI(page, currentAttempt.apiTimeout);
|
|
246
|
-
|
|
247
|
-
if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
|
|
248
|
-
// 成功拦截且拿到了视频,处理数据
|
|
249
|
-
return await processAPIResponse(
|
|
250
|
-
data,
|
|
251
|
-
username,
|
|
252
|
-
maxVideos,
|
|
253
|
-
items,
|
|
254
|
-
page,
|
|
255
|
-
apiRequestUrl,
|
|
256
|
-
log,
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// API 已返回但没有视频时,优先判定是否是确定性的页面错误。
|
|
261
|
-
let pageError = await detectPageError(page);
|
|
262
|
-
|
|
263
|
-
// service_error 经常比 API 判定晚一点渲染,补一个短等待避免漏判“被封”。
|
|
264
|
-
if (!pageError) {
|
|
265
|
-
const delayedPageError = await detectPageErrorWithWait(page, 3000);
|
|
266
|
-
if (delayedPageError) pageError = delayedPageError;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (pageError === "service_error") {
|
|
270
|
-
throw new Error(`被封: ${username}`);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
|
|
274
|
-
if (noRetryErrors.has(pageError)) {
|
|
275
|
-
return new Map();
|
|
276
|
-
}
|
|
115
|
+
log,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
277
118
|
|
|
278
|
-
|
|
279
|
-
|
|
119
|
+
let pageError = await detectPageError(page);
|
|
120
|
+
if (!pageError) {
|
|
121
|
+
pageError = await detectPageErrorWithWait(page, 5000);
|
|
122
|
+
}
|
|
280
123
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
124
|
+
if (pageError === "service_error") {
|
|
125
|
+
throw new Error(`被封: ${username}`);
|
|
126
|
+
}
|
|
284
127
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
);
|
|
128
|
+
if (pageError) {
|
|
129
|
+
log(` [API拦截] 数据为空,识别到页面错误: ${pageError}`);
|
|
130
|
+
return new Map();
|
|
131
|
+
}
|
|
288
132
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
throw new Error(
|
|
293
|
-
`@${username} 页面异常(首轮 + 补等 + 重载后仍未拦截到 API)`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
return new Map();
|
|
297
|
-
}
|
|
133
|
+
if (interceptionError) {
|
|
134
|
+
log(` [API拦截] 超时/拦截失败: ${interceptionError},耗时 ${elapsed}ms`);
|
|
135
|
+
throw new Error(`@${username} 页面异常(API拦截超时,未识别到错误类型)`);
|
|
298
136
|
}
|
|
137
|
+
|
|
138
|
+
log(` [API拦截] 数据为空,未识别到错误类型,当作无视频处理`);
|
|
139
|
+
return new Map();
|
|
299
140
|
}
|
|
300
141
|
|
|
301
|
-
export { fetchUserVideosAPI };
|
|
142
|
+
export { fetchUserVideosAPI };
|
package/src/lib/args.js
CHANGED
|
@@ -170,7 +170,7 @@ function parseExploreArgs(args) {
|
|
|
170
170
|
let exploreEnableFollow = true;
|
|
171
171
|
let exploreMaxFollowing = 50;
|
|
172
172
|
let exploreMaxFollowers = 50;
|
|
173
|
-
let exploreLocation = "PL,NL,BE,DE,FR,IT,ES,IE";
|
|
173
|
+
let exploreLocation = "PL,NL,BE,DE,FR,IT,ES,IE,AT";
|
|
174
174
|
let exploreJobLocations = null;
|
|
175
175
|
let exploreMaxUsers = 0;
|
|
176
176
|
let explorePort = null;
|
package/src/lib/constants.js
CHANGED
|
@@ -109,16 +109,19 @@ const HELP_TEXT = [
|
|
|
109
109
|
' 预设: fast, normal(默认), slow, stealth',
|
|
110
110
|
' 选项:',
|
|
111
111
|
' --server <URL> 服务端地址,默认 http://127.0.0.1:3001',
|
|
112
|
-
' --location <国家代码> 国家筛选,逗号分隔,默认 PL,NL,BE,DE,FR,IT,ES,IE',
|
|
112
|
+
' --location <国家代码> 国家筛选,逗号分隔,默认 PL,NL,BE,DE,FR,IT,ES,IE,AT',
|
|
113
|
+
' --job-locations <国家> 任务国家筛选,逗号分隔(仅筛选服务端任务)',
|
|
113
114
|
' --max-comments <数量> 每视频最大评论数,默认 10',
|
|
114
115
|
' --max-guess <数量> 每视频最大猜你喜欢数,默认 0',
|
|
116
|
+
' --max-videos <数量> 每用户最大视频数,默认 16',
|
|
115
117
|
' --enable-follow 启用关注/粉丝提取(默认启用)',
|
|
116
118
|
' --disable-follow 禁用关注/粉丝提取',
|
|
117
|
-
' --max-following <数量> 最大获取关注数,默认
|
|
118
|
-
' --max-followers <数量> 最大获取粉丝数,默认
|
|
119
|
+
' --max-following <数量> 最大获取关注数,默认 50(同时设置粉丝数)',
|
|
120
|
+
' --max-followers <数量> 最大获取粉丝数,默认 50',
|
|
119
121
|
' --max-users <数量> 最大处理用户数,默认无限制',
|
|
120
|
-
' --port <端口号> CDP
|
|
121
|
-
' --
|
|
122
|
+
' --port <端口号> 固定 CDP 端口(调试用,关闭自动轮换)',
|
|
123
|
+
' --base-port <端口号> 起始端口,默认 9222',
|
|
124
|
+
' --port-count <数量> 端口数量(账户数),默认 10',
|
|
122
125
|
' --user-id <编号> 客户端编号(设备ID),默认自动生成',
|
|
123
126
|
'',
|
|
124
127
|
' tt-help info <URL> [URL2 ...] [--onlyvideo]',
|
|
@@ -66,19 +66,29 @@ async function processExplore(page, username, options, log) {
|
|
|
66
66
|
return result;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
//
|
|
69
|
+
// 从最多 5 个视频并发获取 locationCreated,取众数
|
|
70
|
+
const SAMPLE_SIZE = 5;
|
|
70
71
|
let locationCreated = null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
?
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
const sampleVideos = videoArray.slice(0, SAMPLE_SIZE);
|
|
73
|
+
if (sampleVideos.length > 0) {
|
|
74
|
+
const sampleUrls = sampleVideos.map(v =>
|
|
75
|
+
v.href.startsWith("http") ? v.href : `https://www.tiktok.com${v.href}`
|
|
76
|
+
);
|
|
77
|
+
const locations = await Promise.all(sampleUrls.map(url => extractVideoLocation(url)));
|
|
78
|
+
log(` 国家采样(${locations.length}个): [${locations.filter(Boolean).join(", ") || "无数据"}]`);
|
|
79
|
+
const freq = {};
|
|
80
|
+
for (const loc of locations) {
|
|
81
|
+
if (loc) {
|
|
82
|
+
const key = loc.toUpperCase();
|
|
83
|
+
freq[key] = (freq[key] || 0) + 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const entries = Object.entries(freq).sort((a, b) => b[1] - a[1]);
|
|
87
|
+
locationCreated = entries.length > 0 ? entries[0][0] : null;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
result.locationCreated = locationCreated || null;
|
|
81
|
-
log(` 国家: ${result.locationCreated || "未知"}`);
|
|
91
|
+
log(` 国家: ${result.locationCreated || "未知"} (众数)`);
|
|
82
92
|
|
|
83
93
|
// 国家筛选
|
|
84
94
|
const locationList = (location || "ES")
|
package/src/watch/data-store.js
CHANGED
|
@@ -414,7 +414,12 @@ export function createStore(filePath) {
|
|
|
414
414
|
return data;
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
function claimNextJob(
|
|
417
|
+
function claimNextJob(
|
|
418
|
+
userId,
|
|
419
|
+
expireMs = 5 * 60 * 1000,
|
|
420
|
+
locations = null,
|
|
421
|
+
loggedIn = true,
|
|
422
|
+
) {
|
|
418
423
|
const now = Date.now();
|
|
419
424
|
|
|
420
425
|
// 0. 该客户端有未过期的任务,续期返回
|
|
@@ -452,7 +457,7 @@ export function createStore(filePath) {
|
|
|
452
457
|
return locations.includes(loc);
|
|
453
458
|
}
|
|
454
459
|
|
|
455
|
-
// 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller > follow > other
|
|
460
|
+
// 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller(仅登录) > follow > other
|
|
456
461
|
function pickCandidate(candidates) {
|
|
457
462
|
let next = candidates.find((u) => u.pinned);
|
|
458
463
|
|
|
@@ -479,7 +484,8 @@ export function createStore(filePath) {
|
|
|
479
484
|
next = seed[0] || null;
|
|
480
485
|
}
|
|
481
486
|
|
|
482
|
-
|
|
487
|
+
// 未登录时跳过 ttSeller 优先级
|
|
488
|
+
if (!next && loggedIn) {
|
|
483
489
|
const ttSeller = candidates.filter(
|
|
484
490
|
(u) => u.ttSeller === true && u.verified === false,
|
|
485
491
|
);
|
|
@@ -511,6 +517,10 @@ export function createStore(filePath) {
|
|
|
511
517
|
if (locations && locations.length > 0) {
|
|
512
518
|
pending = pending.filter(locationFilter);
|
|
513
519
|
}
|
|
520
|
+
// 未登录客户端不能领取 ttSeller 用户
|
|
521
|
+
if (!loggedIn) {
|
|
522
|
+
pending = pending.filter((u) => u.ttSeller !== true);
|
|
523
|
+
}
|
|
514
524
|
let hasVideo = pending.filter((u) => u.videoCount > 0);
|
|
515
525
|
const next = pickCandidate(hasVideo) || pickCandidate(pending);
|
|
516
526
|
|