tt-help-cli-ycl 1.3.33 → 1.3.34
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/README.md +17 -17
- package/cli.js +9 -9
- package/package.json +47 -47
- package/scripts/run-explore copy.bat +101 -101
- package/scripts/run-explore.bat +132 -127
- package/scripts/run-explore.ps1 +157 -153
- package/scripts/run-explore.sh +119 -113
- package/src/cli/attach.js +180 -180
- package/src/cli/auto.js +240 -240
- package/src/cli/config.js +152 -152
- package/src/cli/explore.js +488 -481
- package/src/cli/info.js +88 -88
- package/src/cli/open.js +111 -111
- package/src/cli/progress.js +111 -111
- package/src/cli/refresh.js +216 -216
- package/src/cli/scrape.js +47 -47
- package/src/cli/utils.js +18 -18
- package/src/cli/videos.js +41 -41
- package/src/cli/watch.js +31 -31
- package/src/lib/api-interceptor.js +43 -202
- package/src/lib/args.js +722 -722
- package/src/lib/browser/anti-detect.js +23 -23
- package/src/lib/browser/cdp.js +261 -261
- package/src/lib/browser/health-checker.js +114 -114
- package/src/lib/browser/launch.js +43 -43
- package/src/lib/browser/page.js +183 -183
- package/src/lib/constants.js +216 -213
- package/src/lib/delay.js +54 -54
- package/src/lib/explore-fetch.js +118 -118
- package/src/lib/fetcher.js +45 -45
- package/src/lib/filter.js +66 -66
- package/src/lib/io.js +54 -54
- package/src/lib/output.js +80 -80
- package/src/lib/page-error-detector.js +105 -105
- package/src/lib/parse-ssr.mjs +69 -69
- package/src/lib/parser.js +47 -47
- package/src/lib/retry.js +45 -45
- package/src/lib/scrape.js +89 -89
- package/src/lib/tiktok-scraper.mjs +194 -194
- package/src/lib/url.js +52 -52
- package/src/main.js +48 -48
- package/src/scraper/auto-core.js +203 -203
- package/src/scraper/core.js +211 -211
- package/src/scraper/explore-core.js +167 -167
- package/src/scraper/modules/captcha-handler.js +114 -114
- package/src/scraper/modules/follow-extractor.js +194 -194
- package/src/scraper/modules/guess-extractor.js +51 -51
- package/src/scraper/modules/page-helpers.js +48 -48
- package/src/scraper/refresh-core.js +179 -179
- package/src/videos/core.js +125 -125
- package/src/watch/data-store.js +1030 -1030
- package/src/watch/public/index.html +753 -753
- package/src/watch/server.js +933 -933
- package/scripts/test-captcha-lib.mjs +0 -68
- package/scripts/test-captcha.mjs +0 -81
- package/scripts/test-incognito-lib.mjs +0 -36
- package/scripts/test-login-state.mjs +0 -128
- package/scripts/test-safe-click.mjs +0 -45
- package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
package/src/cli/watch.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import { existsSync } from 'fs';
|
|
2
|
-
import { createStore } from '../watch/data-store.js';
|
|
3
|
-
import { startWatchServer, openBrowser } from '../watch/server.js';
|
|
4
|
-
|
|
5
|
-
export async function handleWatch(options) {
|
|
6
|
-
const { outputFile, watchPort } = options;
|
|
7
|
-
|
|
8
|
-
if (!outputFile) {
|
|
9
|
-
console.error('用法: tt-help watch -o <数据文件> [-p 端口]');
|
|
10
|
-
console.error('示例: tt-help watch -o data.json');
|
|
11
|
-
console.error(' tt-help watch -o data.json -p 8080');
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
if (!existsSync(outputFile)) {
|
|
16
|
-
console.error(`文件不存�? ${outputFile}`);
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const store = createStore(outputFile);
|
|
21
|
-
const { server, port } = await startWatchServer(outputFile, watchPort, store);
|
|
22
|
-
openBrowser(port);
|
|
23
|
-
|
|
24
|
-
process.once('SIGINT', () => {
|
|
25
|
-
store.stopBackup();
|
|
26
|
-
server.close();
|
|
27
|
-
process.exit(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
console.error('�?Ctrl+C 停止监控服务');
|
|
31
|
-
}
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { createStore } from '../watch/data-store.js';
|
|
3
|
+
import { startWatchServer, openBrowser } from '../watch/server.js';
|
|
4
|
+
|
|
5
|
+
export async function handleWatch(options) {
|
|
6
|
+
const { outputFile, watchPort } = options;
|
|
7
|
+
|
|
8
|
+
if (!outputFile) {
|
|
9
|
+
console.error('用法: tt-help watch -o <数据文件> [-p 端口]');
|
|
10
|
+
console.error('示例: tt-help watch -o data.json');
|
|
11
|
+
console.error(' tt-help watch -o data.json -p 8080');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!existsSync(outputFile)) {
|
|
16
|
+
console.error(`文件不存�? ${outputFile}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const store = createStore(outputFile);
|
|
21
|
+
const { server, port } = await startWatchServer(outputFile, watchPort, store);
|
|
22
|
+
openBrowser(port);
|
|
23
|
+
|
|
24
|
+
process.once('SIGINT', () => {
|
|
25
|
+
store.stopBackup();
|
|
26
|
+
server.close();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
console.error('�?Ctrl+C 停止监控服务');
|
|
31
|
+
}
|
|
@@ -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 };
|