tt-help-cli-ycl 1.3.29 → 1.3.31

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.
@@ -71,7 +71,8 @@ export async function handleExplore(options) {
71
71
  exploreMaxUsers,
72
72
  serverUrl,
73
73
  explorePort,
74
- exploreProfile,
74
+ exploreBasePort,
75
+ explorePortCount,
75
76
  exploreUserId,
76
77
  exploreMaxVideos,
77
78
  } = options;
@@ -100,28 +101,35 @@ export async function handleExplore(options) {
100
101
  console.error(`服务器: ${serverUrl}(断开会自动重连)`);
101
102
  if (exploreMaxUsers > 0) console.error(`上限: ${exploreMaxUsers} 个用户`);
102
103
 
103
- const healthChecker = new HealthChecker();
104
+ const healthChecker = new HealthChecker({
105
+ basePort: exploreBasePort,
106
+ totalAccounts: explorePortCount,
107
+ });
104
108
  let currentAccount = healthChecker.getCurrentAccount();
105
109
 
106
110
  const cdpOptions = {};
107
- if (explorePort) cdpOptions.port = explorePort;
108
- if (exploreProfile) {
111
+ if (explorePort) {
112
+ // 固定端口模式(调试用,关闭自动轮换)
113
+ cdpOptions.port = explorePort;
109
114
  cdpOptions.userDataDir = path.join(
110
115
  os.homedir(),
111
116
  "Library",
112
117
  "Application Support",
113
- `Microsoft Edge For Testing_${exploreProfile}`,
118
+ `Microsoft Edge For Testing_p${explorePort}`,
114
119
  );
115
120
  } else {
116
121
  cdpOptions.port = currentAccount.port;
117
122
  cdpOptions.userDataDir = currentAccount.userDataDir;
118
123
  }
119
124
 
120
- console.error(`CDP 端口: ${cdpOptions.port || 9222}, 用户编号: ${userId}`);
121
- if (exploreProfile || cdpOptions.userDataDir)
125
+ console.error(`CDP 端口: ${cdpOptions.port}, 用户编号: ${userId}`);
126
+ console.error(`浏览器配置: ${path.basename(cdpOptions.userDataDir)}`);
127
+ if (!explorePort) {
128
+ const portRange = `${currentAccount.port}-${currentAccount.port + healthChecker.accounts.length - 1}`;
122
129
  console.error(
123
- `浏览器配置: ${exploreProfile || path.basename(cdpOptions.userDataDir)}`,
130
+ `端口轮换范围: ${portRange}(共 ${healthChecker.accounts.length} 个)`,
124
131
  );
132
+ }
125
133
 
126
134
  let browser = await ensureBrowserReadyCDP(cdpOptions);
127
135
  const { processExplore } = await import("../scraper/explore-core.js");
@@ -0,0 +1,81 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { TikTokScraper } from '../lib/tiktok-scraper.mjs';
3
+
4
+ export async function handleVideoStats(options) {
5
+ const { statsFile, statsParallel } = options;
6
+
7
+ if (!statsFile) {
8
+ console.error('用法: tt-help videostats <文件路径> -p <并发数>');
9
+ console.error('示例: tt-help videostats data/result-videos.json -p 3');
10
+ console.error('');
11
+ console.error('选项: -p, --parallel <N> 并发数(默认: 3)');
12
+ process.exit(1);
13
+ }
14
+
15
+ // 读取文件
16
+ const videos = JSON.parse(readFileSync(statsFile, 'utf-8'));
17
+ if (!Array.isArray(videos) || videos.length === 0) {
18
+ console.error('[videostats] 文件为空或格式错误');
19
+ process.exit(1);
20
+ }
21
+
22
+ const poolSize = Math.min(statsParallel, videos.length);
23
+ console.error(`[videostats] 读取 ${statsFile}: ${videos.length} 个视频,并发: ${poolSize}`);
24
+
25
+ // 初始化 scraper
26
+ const scraper = new TikTokScraper({ poolSize });
27
+ await scraper.init();
28
+ console.error('[videostats] 浏览器初始化完成,开始刷新...');
29
+
30
+ let success = 0, failed = 0, skipped = 0;
31
+
32
+ try {
33
+ for (let i = 0; i < videos.length; i += poolSize) {
34
+ const batch = videos.slice(i, i + poolSize);
35
+
36
+ const results = await Promise.allSettled(
37
+ batch.map(async (v) => {
38
+ if (!v.href) return { video: v, error: 'no href' };
39
+ try {
40
+ const info = await scraper.getVideoInfo(v.href);
41
+ if (!info) return { video: v, error: 'no info' };
42
+ return { video: v, info };
43
+ } catch (err) {
44
+ return { video: v, error: err.message };
45
+ }
46
+ })
47
+ );
48
+
49
+ for (const result of results) {
50
+ if (result.status === 'fulfilled') {
51
+ const { video, info, error } = result.value;
52
+ if (info && info.stats) {
53
+ video.stats = info.stats;
54
+ success++;
55
+ } else {
56
+ failed++;
57
+ const id = video.id || video.href || '?';
58
+ console.error(` [失败] ${id.substring(0, 30)} - ${error || '获取失败'}`);
59
+ }
60
+ } else {
61
+ failed++;
62
+ }
63
+ }
64
+
65
+ const processed = Math.min(i + poolSize, videos.length);
66
+ console.error(`[videostats] 进度: ${processed}/${videos.length} (成功: ${success}, 失败: ${failed})`);
67
+ }
68
+
69
+ // 写回文件
70
+ writeFileSync(statsFile, JSON.stringify(videos, null, 2), 'utf-8');
71
+ console.error(``);
72
+ console.error(`[videostats] 完成: 成功 ${success}, 失败 ${failed}`);
73
+ console.error(`[videostats] 已更新 ${statsFile}`);
74
+
75
+ } catch (err) {
76
+ console.error(`[videostats] 异常: ${err.message}`);
77
+ process.exit(1);
78
+ } finally {
79
+ await scraper.close();
80
+ }
81
+ }
@@ -0,0 +1,126 @@
1
+ import { delay } from "./delay.js";
2
+
3
+ /**
4
+ * 拦截 /api/comment/list/ API,获取评论数据
5
+ *
6
+ * 流程:注册拦截器 → 点击评论 tab → 捕获 API response → 翻页获取全部评论
7
+ *
8
+ * @param {Page} page - Playwright page
9
+ * @param {object} options
10
+ * @param {number} options.maxComments - 最大评论数
11
+ * @param {function} options.log - 日志函数
12
+ * @param {function} options.onCaptcha - 验证码检测回调 (page) => Promise<{detected: boolean}>
13
+ * @returns {Promise<{comments: Array, total: number, captchaDetected: boolean, error: string|null}>}
14
+ */
15
+ async function fetchUserCommentsAPI(page, { maxComments = 100, log = console.log, onCaptcha } = {}) {
16
+ // 先注册 API 拦截器,再点 tab(顺序不能反)
17
+ let apiResolve = null;
18
+ let apiRequestUrl = null;
19
+ const apiPromise = new Promise(r => { apiResolve = r; });
20
+
21
+ const handler = async (response) => {
22
+ const url = response.url();
23
+ if (response.status() === 200 && url.includes('/api/comment/list/') && !apiRequestUrl) {
24
+ apiRequestUrl = url;
25
+ try {
26
+ apiResolve(await response.json());
27
+ } catch (e) {
28
+ apiResolve(null);
29
+ }
30
+ }
31
+ };
32
+
33
+ page.on('response', handler);
34
+
35
+ try {
36
+ // 点击评论 tab 触发 API
37
+ log(' [API拦截] 点击评论 tab...');
38
+ const tabs = page.locator('[class*="tabbar-item"]');
39
+ const commentTab = tabs.filter({ hasText: /评论|Comment/ });
40
+ const count = await commentTab.count();
41
+
42
+ if (count === 0) {
43
+ return { comments: [], total: 0, captchaDetected: false, error: '未找到评论 tab' };
44
+ }
45
+
46
+ await commentTab.first().click({ force: true });
47
+
48
+ // 等待 API 响应
49
+ const t0 = Date.now();
50
+ const data = await Promise.race([
51
+ apiPromise,
52
+ page.waitForTimeout(10000).then(() => null),
53
+ ]);
54
+ const elapsed = Date.now() - t0;
55
+
56
+ if (!data || !apiRequestUrl) {
57
+ log(` [API拦截] 点击评论 tab 后 ${elapsed}ms 未拿到 API 响应`);
58
+ return { comments: [], total: 0, captchaDetected: false, error: 'API 超时或未响应' };
59
+ }
60
+
61
+ // 验证码检测(API 拿完后检测)
62
+ let captchaDetected = false;
63
+ if (onCaptcha) {
64
+ try {
65
+ const captchaResult = await onCaptcha(page);
66
+ captchaDetected = !!captchaResult.detected;
67
+ if (captchaDetected) {
68
+ log(' [API拦截] 检测到验证码');
69
+ }
70
+ } catch (e) {
71
+ log(` [API拦截] 验证码检测异常: ${e.message}`);
72
+ }
73
+ }
74
+
75
+ const items = data.comments || [];
76
+ log(` [API拦截] ${elapsed}ms 后拿到 ${items.length} 条评论 (total: ${data.total || '?'})`);
77
+
78
+ if (items.length >= maxComments) {
79
+ return { comments: items.slice(0, maxComments), total: data.total || 0, captchaDetected };
80
+ }
81
+
82
+ // 翻页
83
+ let cursor = data.cursor;
84
+ let hasMore = data.has_more;
85
+ let pageNum = 1;
86
+
87
+ while (hasMore && cursor && items.length < maxComments) {
88
+ pageNum++;
89
+ const pageUrl = apiRequestUrl.replace(/cursor=([^&]+)/, `cursor=${cursor}`);
90
+
91
+ const pageData = await page.evaluate(async (u) => {
92
+ try {
93
+ const res = await fetch(u);
94
+ return await res.json();
95
+ } catch (e) {
96
+ return { error: e.message };
97
+ }
98
+ }, pageUrl);
99
+
100
+ if (pageData.error) {
101
+ log(` [API拦截] 翻页 ${pageNum} 失败: ${pageData.error}`);
102
+ break;
103
+ }
104
+
105
+ const pageComments = pageData.comments || [];
106
+ log(` [API拦截] 翻页 ${pageNum}: ${pageComments.length} 条 (累计: ${items.length + pageComments.length})`);
107
+
108
+ items.push(...pageComments);
109
+ cursor = pageData.cursor;
110
+ hasMore = pageData.has_more;
111
+
112
+ if (items.length >= maxComments) break;
113
+
114
+ await delay(200, 500);
115
+ }
116
+
117
+ const result = items.slice(0, maxComments);
118
+ log(` [API拦截] 共 ${result.length} 条评论, ${pageNum} 页`);
119
+
120
+ return { comments: result, total: data.total || 0, captchaDetected };
121
+ } finally {
122
+ page.off('response', handler);
123
+ }
124
+ }
125
+
126
+ export { fetchUserCommentsAPI };
@@ -1,7 +1,10 @@
1
1
  import { delay } from "./delay.js";
2
2
  import { retryWithBackoff } from "./retry.js";
3
3
  import { assertPageUrl } from "./browser/page.js";
4
- import { detectPageError } from "./page-error-detector.js";
4
+ import {
5
+ detectPageError,
6
+ detectPageErrorWithWait,
7
+ } from "./page-error-detector.js";
5
8
 
6
9
  /**
7
10
  * 处理 API 响应数据:提取首页视频 + 翻页
@@ -23,18 +26,13 @@ async function processAPIResponse(
23
26
  items.push({ id: item.id, href });
24
27
  }
25
28
 
26
- log(` [API拦截] 获取首页 ${firstPageItems.length} 条视频`);
27
-
28
29
  // 翻页获取后续视频
29
30
  let cursor = data.cursor;
30
31
  let hasMore = data.hasMore;
31
32
 
32
33
  while (hasMore && cursor && items.length < maxVideos) {
33
34
  const reqUrl = apiRequestUrl || data.apiRequestUrl;
34
- if (!reqUrl) {
35
- log(" [API拦截] 未捕获到 API 请求 URL,无法翻页");
36
- break;
37
- }
35
+ if (!reqUrl) break;
38
36
 
39
37
  const newUrl = reqUrl.replace(/cursor=\d+/, `cursor=${cursor}`);
40
38
 
@@ -50,22 +48,18 @@ async function processAPIResponse(
50
48
  const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
51
49
  items.push({ id: item.id, href });
52
50
  }
53
- log(
54
- ` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`,
55
- );
56
51
  }
57
52
 
58
53
  cursor = pageData.cursor;
59
54
  hasMore = pageData.hasMore;
60
- } catch (e) {
61
- log(` [API拦截] 翻页失败: ${e.message}`);
55
+ } catch {
62
56
  break;
63
57
  }
64
58
 
65
59
  await delay(300, 600);
66
60
  }
67
61
 
68
- log(` [API拦截] 总计获取 ${items.length} 条视频`);
62
+ log(` [API拦截] ${items.length} 条视频`);
69
63
 
70
64
  // 转成 Map 返回,与 collectVideos 一致
71
65
  const videoMap = new Map();
@@ -78,9 +72,10 @@ async function processAPIResponse(
78
72
 
79
73
  /**
80
74
  * 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
75
+ * @param {number} apiTimeout - API 响应超时时间(ms),默认 15000
81
76
  * @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
82
77
  */
83
- async function tryInterceptAPI(page, username, log) {
78
+ async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
84
79
  const url = `https://www.tiktok.com/@${username}`;
85
80
  let apiRequestUrl = null;
86
81
 
@@ -105,20 +100,17 @@ async function tryInterceptAPI(page, username, log) {
105
100
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
106
101
  await assertPageUrl(page, `@${username}`);
107
102
 
108
- // 用 waitForResponse 精确等待 API 响应(15 秒超时,适合弱网)
103
+ // 用 waitForResponse 精确等待 API 响应(超时时间可配置)
109
104
  const response = await page.waitForResponse(
110
105
  (res) => res.url().includes("/api/post/item_list/"),
111
- { timeout: 15000 },
106
+ { timeout: apiTimeout },
112
107
  );
113
108
 
114
109
  const data = await response.json();
115
110
  const elapsed = Date.now() - t0;
116
- log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
117
-
118
111
  return { data, apiRequestUrl, elapsed };
119
112
  } catch (e) {
120
113
  const elapsed = Date.now() - t0;
121
- log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
122
114
  return { data: null, apiRequestUrl, elapsed, error: e.message };
123
115
  } finally {
124
116
  page.off("response", responseHandler);
@@ -132,8 +124,11 @@ async function tryInterceptAPI(page, username, log) {
132
124
  *
133
125
  * 弱网优化:
134
126
  * - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
135
- * - 最多重试 3 次,每次重试前清理拦截器
136
- * - 超时 15s,适合弱网环境
127
+ * - 最多重试 3 次,递增等待 + 递增超时:
128
+ * - 第 1 次:等待 0s, 超时 15s → 正常尝试
129
+ * - 第 2 次:等待 3s, 超时 20s → 短暂波动恢复
130
+ * - 第 3 次:等待 10s, 超时 30s → 网络缓慢给更多时间
131
+ * - 每次重试前清理拦截器
137
132
  *
138
133
  * @param {import('playwright').Page} page - Playwright page (CDP 连接)
139
134
  * @param {string} username - TikTok 用户名
@@ -145,6 +140,12 @@ async function tryInterceptAPI(page, username, log) {
145
140
  async function fetchUserVideosAPI(page, username, maxVideos, log) {
146
141
  const items = [];
147
142
  const maxRetries = 3;
143
+ // 重试策略:[等待时间(ms), API超时(ms)]
144
+ const retryStrategy = [
145
+ [0, 15000], // 第 1 次:不等待,15s 超时
146
+ [3000, 20000], // 第 2 次:等 3s,20s 超时(短暂波动)
147
+ [10000, 30000], // 第 3 次:等 10s,30s 超时(网络缓慢)
148
+ ];
148
149
  const noRetryErrors = new Set([
149
150
  "service_error",
150
151
  "not_found",
@@ -153,14 +154,20 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
153
154
  ]);
154
155
 
155
156
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
156
- log(
157
- ` [API拦截] 导航到用户页,等待 /api/post/item_list/ ... (尝试 ${attempt}/${maxRetries})`,
158
- );
157
+ const [, apiTimeout] = retryStrategy[attempt - 1];
158
+ if (attempt > 1) {
159
+ log(
160
+ ` [API拦截] 重试 ${attempt}/${maxRetries} (超时 ${apiTimeout / 1000}s)`,
161
+ );
162
+ } else {
163
+ log(` [API拦截] 获取 @${username} 视频 ...`);
164
+ }
159
165
 
160
166
  const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
161
167
  page,
162
168
  username,
163
169
  log,
170
+ apiTimeout,
164
171
  );
165
172
 
166
173
  if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
@@ -177,7 +184,13 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
177
184
  }
178
185
 
179
186
  // API 已返回但没有视频时,优先判定是否是确定性的页面错误。
180
- const pageError = await detectPageError(page);
187
+ let pageError = await detectPageError(page);
188
+
189
+ // service_error 经常比 API 判定晚一点渲染,补一个短等待避免漏判“被封”。
190
+ if (!pageError) {
191
+ const delayedPageError = await detectPageErrorWithWait(page, 3000);
192
+ if (delayedPageError) pageError = delayedPageError;
193
+ }
181
194
 
182
195
  if (pageError === "service_error") {
183
196
  throw new Error(`被封: ${username}`);
@@ -185,20 +198,13 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
185
198
 
186
199
  if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
187
200
  if (noRetryErrors.has(pageError)) {
188
- log(
189
- ` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
190
- );
191
201
  return new Map();
192
202
  }
193
203
 
194
- log(` [API拦截] ${elapsed}ms 后 API 返回 0 条视频`);
195
204
  return new Map();
196
205
  }
197
206
 
198
207
  if (noRetryErrors.has(pageError)) {
199
- log(
200
- ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
201
- );
202
208
  return new Map();
203
209
  }
204
210
 
@@ -206,20 +212,15 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
206
212
  if (attempt === maxRetries) {
207
213
  if (!pageError) {
208
214
  throw new Error(
209
- `@${username} 页面异常(${elapsed}ms,${maxRetries} 次重试均未拦截到 API)`,
215
+ `@${username} 页面异常(${maxRetries} 次重试均未拦截到 API)`,
210
216
  );
211
217
  }
212
- log(
213
- ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
214
- );
215
218
  return new Map();
216
219
  }
217
220
 
218
221
  // 还有重试次数,等待后重试
219
- const waitTime = 2000 * attempt;
220
- log(
221
- ` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
222
- );
222
+ const [waitTime] = retryStrategy[attempt - 1];
223
+ log(` [API拦截] 失败,${waitTime / 1000}s 后重试...`);
223
224
  await new Promise((r) => setTimeout(r, waitTime));
224
225
  }
225
226
  }