tt-help-cli-ycl 1.3.30 → 1.3.32

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 };
@@ -26,18 +26,13 @@ async function processAPIResponse(
26
26
  items.push({ id: item.id, href });
27
27
  }
28
28
 
29
- log(` [API拦截] 获取首页 ${firstPageItems.length} 条视频`);
30
-
31
29
  // 翻页获取后续视频
32
30
  let cursor = data.cursor;
33
31
  let hasMore = data.hasMore;
34
32
 
35
33
  while (hasMore && cursor && items.length < maxVideos) {
36
34
  const reqUrl = apiRequestUrl || data.apiRequestUrl;
37
- if (!reqUrl) {
38
- log(" [API拦截] 未捕获到 API 请求 URL,无法翻页");
39
- break;
40
- }
35
+ if (!reqUrl) break;
41
36
 
42
37
  const newUrl = reqUrl.replace(/cursor=\d+/, `cursor=${cursor}`);
43
38
 
@@ -53,22 +48,18 @@ async function processAPIResponse(
53
48
  const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
54
49
  items.push({ id: item.id, href });
55
50
  }
56
- log(
57
- ` [API拦截] 翻页 cursor=${cursor},获取 ${pageData.itemList.length} 条,累计 ${items.length}`,
58
- );
59
51
  }
60
52
 
61
53
  cursor = pageData.cursor;
62
54
  hasMore = pageData.hasMore;
63
- } catch (e) {
64
- log(` [API拦截] 翻页失败: ${e.message}`);
55
+ } catch {
65
56
  break;
66
57
  }
67
58
 
68
59
  await delay(300, 600);
69
60
  }
70
61
 
71
- log(` [API拦截] 总计获取 ${items.length} 条视频`);
62
+ log(` [API拦截] ${items.length} 条视频`);
72
63
 
73
64
  // 转成 Map 返回,与 collectVideos 一致
74
65
  const videoMap = new Map();
@@ -81,9 +72,10 @@ async function processAPIResponse(
81
72
 
82
73
  /**
83
74
  * 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
75
+ * @param {number} apiTimeout - API 响应超时时间(ms),默认 15000
84
76
  * @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
85
77
  */
86
- async function tryInterceptAPI(page, username, log) {
78
+ async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
87
79
  const url = `https://www.tiktok.com/@${username}`;
88
80
  let apiRequestUrl = null;
89
81
 
@@ -108,20 +100,17 @@ async function tryInterceptAPI(page, username, log) {
108
100
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
109
101
  await assertPageUrl(page, `@${username}`);
110
102
 
111
- // 用 waitForResponse 精确等待 API 响应(15 秒超时,适合弱网)
103
+ // 用 waitForResponse 精确等待 API 响应(超时时间可配置)
112
104
  const response = await page.waitForResponse(
113
105
  (res) => res.url().includes("/api/post/item_list/"),
114
- { timeout: 15000 },
106
+ { timeout: apiTimeout },
115
107
  );
116
108
 
117
109
  const data = await response.json();
118
110
  const elapsed = Date.now() - t0;
119
- log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
120
-
121
111
  return { data, apiRequestUrl, elapsed };
122
112
  } catch (e) {
123
113
  const elapsed = Date.now() - t0;
124
- log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
125
114
  return { data: null, apiRequestUrl, elapsed, error: e.message };
126
115
  } finally {
127
116
  page.off("response", responseHandler);
@@ -135,8 +124,11 @@ async function tryInterceptAPI(page, username, log) {
135
124
  *
136
125
  * 弱网优化:
137
126
  * - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
138
- * - 最多重试 3 次,每次重试前清理拦截器
139
- * - 超时 15s,适合弱网环境
127
+ * - 最多重试 3 次,递增等待 + 递增超时:
128
+ * - 第 1 次:等待 0s, 超时 15s → 正常尝试
129
+ * - 第 2 次:等待 3s, 超时 20s → 短暂波动恢复
130
+ * - 第 3 次:等待 10s, 超时 30s → 网络缓慢给更多时间
131
+ * - 每次重试前清理拦截器
140
132
  *
141
133
  * @param {import('playwright').Page} page - Playwright page (CDP 连接)
142
134
  * @param {string} username - TikTok 用户名
@@ -148,6 +140,12 @@ async function tryInterceptAPI(page, username, log) {
148
140
  async function fetchUserVideosAPI(page, username, maxVideos, log) {
149
141
  const items = [];
150
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
+ ];
151
149
  const noRetryErrors = new Set([
152
150
  "service_error",
153
151
  "not_found",
@@ -156,14 +154,20 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
156
154
  ]);
157
155
 
158
156
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
159
- log(
160
- ` [API拦截] 导航到用户页,等待 /api/post/item_list/ ... (尝试 ${attempt}/${maxRetries})`,
161
- );
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
+ }
162
165
 
163
166
  const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
164
167
  page,
165
168
  username,
166
169
  log,
170
+ apiTimeout,
167
171
  );
168
172
 
169
173
  if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
@@ -194,20 +198,13 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
194
198
 
195
199
  if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
196
200
  if (noRetryErrors.has(pageError)) {
197
- log(
198
- ` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
199
- );
200
201
  return new Map();
201
202
  }
202
203
 
203
- log(` [API拦截] ${elapsed}ms 后 API 返回 0 条视频`);
204
204
  return new Map();
205
205
  }
206
206
 
207
207
  if (noRetryErrors.has(pageError)) {
208
- log(
209
- ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
210
- );
211
208
  return new Map();
212
209
  }
213
210
 
@@ -215,20 +212,15 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
215
212
  if (attempt === maxRetries) {
216
213
  if (!pageError) {
217
214
  throw new Error(
218
- `@${username} 页面异常(${elapsed}ms,${maxRetries} 次重试均未拦截到 API)`,
215
+ `@${username} 页面异常(${maxRetries} 次重试均未拦截到 API)`,
219
216
  );
220
217
  }
221
- log(
222
- ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
223
- );
224
218
  return new Map();
225
219
  }
226
220
 
227
221
  // 还有重试次数,等待后重试
228
- const waitTime = 2000 * attempt;
229
- log(
230
- ` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
231
- );
222
+ const [waitTime] = retryStrategy[attempt - 1];
223
+ log(` [API拦截] 失败,${waitTime / 1000}s 后重试...`);
232
224
  await new Promise((r) => setTimeout(r, waitTime));
233
225
  }
234
226
  }