tt-help-cli-ycl 1.3.28 → 1.3.30

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.28",
3
+ "version": "1.3.30",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/open.js CHANGED
@@ -1,6 +1,7 @@
1
- import { ensureBrowserReady, killEdgeProcesses } from '../lib/browser/cdp.js';
2
- import path from 'path';
3
- import os from 'os';
1
+ import { ensureBrowserReady, killEdgeProcesses } from "../lib/browser/cdp.js";
2
+ import { getOrCreatePage } from "../lib/browser/page.js";
3
+ import path from "path";
4
+ import os from "os";
4
5
 
5
6
  const BASE_PORT = 9222;
6
7
  const TOTAL_ACCOUNTS = 10;
@@ -9,24 +10,31 @@ export async function handleOpen(parsed) {
9
10
  const { openPort, openList } = parsed;
10
11
 
11
12
  if (openList) {
12
- console.error('内置浏览器配置:');
13
+ console.error("内置浏览器配置:");
13
14
  for (let i = 0; i < TOTAL_ACCOUNTS; i++) {
14
15
  const port = BASE_PORT + i;
15
16
  const profile = `p${port}`;
16
- const userDataDir = path.join(os.homedir(), 'Library', 'Application Support', `Microsoft Edge For Testing_${profile}`);
17
- console.error(` open ${port} → profile: ${profile}, userDataDir: ${userDataDir}`);
17
+ const userDataDir = path.join(
18
+ os.homedir(),
19
+ "Library",
20
+ "Application Support",
21
+ `Microsoft Edge For Testing_${profile}`,
22
+ );
23
+ console.error(
24
+ ` open ${port} → profile: ${profile}, userDataDir: ${userDataDir}`,
25
+ );
18
26
  }
19
- console.error('');
20
- console.error('用法: tt-help open <端口>');
21
- console.error('示例: tt-help open 9222');
27
+ console.error("");
28
+ console.error("用法: tt-help open <端口>");
29
+ console.error("示例: tt-help open 9222");
22
30
  process.exit(0);
23
31
  }
24
32
 
25
33
  if (!openPort) {
26
- console.error('用法: tt-help open <端口>');
27
- console.error('示例: tt-help open 9222');
28
- console.error('');
29
- console.error('可用端口: 9222 - 9231 (共 10 个)');
34
+ console.error("用法: tt-help open <端口>");
35
+ console.error("示例: tt-help open 9222");
36
+ console.error("");
37
+ console.error("可用端口: 9222 - 9231 (共 10 个)");
30
38
  console.error('运行 "tt-help open --list" 查看所有配置');
31
39
  process.exit(1);
32
40
  }
@@ -38,15 +46,20 @@ export async function handleOpen(parsed) {
38
46
  }
39
47
 
40
48
  const profile = `p${port}`;
41
- const userDataDir = path.join(os.homedir(), 'Library', 'Application Support', `Microsoft Edge For Testing_${profile}`);
49
+ const userDataDir = path.join(
50
+ os.homedir(),
51
+ "Library",
52
+ "Application Support",
53
+ `Microsoft Edge For Testing_${profile}`,
54
+ );
42
55
 
43
56
  console.error(`正在启动浏览器... 端口: ${port}, profile: ${profile}`);
44
57
  console.error(`userDataDir: ${userDataDir}`);
45
- console.error('');
46
- console.error('启动后请在浏览器中登录 TikTok 账户');
47
- console.error('登录完成后关闭浏览器即可,下次 open 会保留登录状态');
48
- console.error('');
49
- console.error('按 Ctrl+C 退出并关闭浏览器');
58
+ console.error("");
59
+ console.error("启动后请在浏览器中登录 TikTok 账户");
60
+ console.error("登录完成后关闭浏览器即可,下次 open 会保留登录状态");
61
+ console.error("");
62
+ console.error("按 Ctrl+C 退出并关闭浏览器");
50
63
 
51
64
  let browser = null;
52
65
 
@@ -58,39 +71,35 @@ export async function handleOpen(parsed) {
58
71
  browser = await ensureBrowserReady(cdpOptions);
59
72
 
60
73
  // Ctrl+C 关闭浏览器
61
- process.on('SIGINT', async () => {
62
- console.error('\n正在关闭浏览器...');
74
+ process.on("SIGINT", async () => {
75
+ console.error("\n正在关闭浏览器...");
63
76
  try {
64
77
  await killEdgeProcesses(userDataDir);
65
78
  await browser.close();
66
- } catch { /* ignore */ }
67
- console.error('浏览器已关闭');
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ console.error("浏览器已关闭");
68
83
  process.exit(0);
69
84
  });
70
85
 
71
- console.error('');
72
- console.error('浏览器已启动并连接成功');
73
- console.error('TikTok 页面将在浏览器中自动打开...');
86
+ console.error("");
87
+ console.error("浏览器已启动并连接成功");
88
+ console.error("TikTok 页面将在浏览器中自动打开...");
74
89
 
75
90
  // 打开 TikTok 首页
76
91
  try {
77
- const pages = await browser.pages();
78
- let page = pages.find(p => !p.url().startsWith('devtools'));
79
- if (!page) {
80
- const context = await browser.newBrowserCDPSession();
81
- await context.send('Target.createTarget', { url: '' });
82
- await new Promise(r => setTimeout(r, 1000));
83
- const newPages = await browser.pages();
84
- page = newPages.find(p => !p.url().startsWith('devtools'));
85
- }
86
-
92
+ const page = await getOrCreatePage(browser);
87
93
  if (page) {
88
- await page.goto('https://www.tiktok.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
89
- console.error('已打开 https://www.tiktok.com');
94
+ await page.goto("https://www.tiktok.com", {
95
+ waitUntil: "domcontentloaded",
96
+ timeout: 30000,
97
+ });
98
+ console.error("已打开 https://www.tiktok.com");
90
99
  }
91
100
  } catch (e) {
92
101
  console.error(`打开页面时出错: ${e.message}`);
93
- console.error('浏览器已启动,可以手动在浏览器中打开 TikTok');
102
+ console.error("浏览器已启动,可以手动在浏览器中打开 TikTok");
94
103
  }
95
104
 
96
105
  // 保持进程运行
@@ -80,35 +80,16 @@ async function processAPIResponse(
80
80
  }
81
81
 
82
82
  /**
83
- * 通过拦截 TikTok 内部 API 获取用户视频列表
84
- * DOM 滚动解析快 5-10
85
- *
86
- * @param {import('playwright').Page} page - Playwright page (CDP 连接)
87
- * @param {string} username - TikTok 用户名
88
- * @param {number} maxVideos - 最大视频数
89
- * @param {Function} log - 日志函数
90
- * @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
91
- * @throws {Error} API 超时或网络异常时抛出
83
+ * 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
84
+ * @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
92
85
  */
93
- async function fetchUserVideosAPI(page, username, maxVideos, log) {
86
+ async function tryInterceptAPI(page, username, log) {
94
87
  const url = `https://www.tiktok.com/@${username}`;
95
- const items = [];
96
-
97
- // 1. 注册 response 拦截器 + request URL 捕获
98
- let apiResolve = null;
99
- const apiPromise = new Promise((r) => {
100
- apiResolve = r;
101
- });
102
-
103
88
  let apiRequestUrl = null;
104
89
 
105
90
  const responseHandler = async (response) => {
106
91
  if (response.url().includes("/api/post/item_list/")) {
107
- try {
108
- apiResolve(await response.json());
109
- } catch (e) {
110
- apiResolve(null);
111
- }
92
+ // 只记录 URL,数据通过 waitForResponse 获取
112
93
  }
113
94
  };
114
95
 
@@ -121,61 +102,134 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
121
102
  page.on("response", responseHandler);
122
103
  page.on("request", requestHandler);
123
104
 
105
+ const t0 = Date.now();
106
+
124
107
  try {
125
- // 2. 导航并等待 API 响应
126
- log(" [API拦截] 导航到用户页,等待 /api/post/item_list/ ...");
127
- const t0 = Date.now();
128
-
129
- await retryWithBackoff(
130
- async () => {
131
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
132
- await assertPageUrl(page, `@${username}`);
133
- },
134
- { maxRetries: 3, baseDelay: 3000, log },
108
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
109
+ await assertPageUrl(page, `@${username}`);
110
+
111
+ // 用 waitForResponse 精确等待 API 响应(15 秒超时,适合弱网)
112
+ const response = await page.waitForResponse(
113
+ (res) => res.url().includes("/api/post/item_list/"),
114
+ { timeout: 15000 },
135
115
  );
136
116
 
137
- const data = await Promise.race([
138
- apiPromise,
139
- new Promise((r) => setTimeout(() => r(null), 8000)),
140
- ]);
117
+ const data = await response.json();
118
+ const elapsed = Date.now() - t0;
119
+ log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
141
120
 
121
+ return { data, apiRequestUrl, elapsed };
122
+ } catch (e) {
142
123
  const elapsed = Date.now() - t0;
124
+ log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
125
+ return { data: null, apiRequestUrl, elapsed, error: e.message };
126
+ } finally {
127
+ page.off("response", responseHandler);
128
+ page.off("request", requestHandler);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * 通过拦截 TikTok 内部 API 获取用户视频列表
134
+ * 比 DOM 滚动解析快 5-10 倍
135
+ *
136
+ * 弱网优化:
137
+ * - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
138
+ * - 最多重试 3 次,每次重试前清理拦截器
139
+ * - 超时 15s,适合弱网环境
140
+ *
141
+ * @param {import('playwright').Page} page - Playwright page (CDP 连接)
142
+ * @param {string} username - TikTok 用户名
143
+ * @param {number} maxVideos - 最大视频数
144
+ * @param {Function} log - 日志函数
145
+ * @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
146
+ * @throws {Error} API 超时或网络异常时抛出
147
+ */
148
+ async function fetchUserVideosAPI(page, username, maxVideos, log) {
149
+ const items = [];
150
+ const maxRetries = 3;
151
+ const noRetryErrors = new Set([
152
+ "service_error",
153
+ "not_found",
154
+ "region_blocked",
155
+ "login_required",
156
+ ]);
157
+
158
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
159
+ log(
160
+ ` [API拦截] 导航到用户页,等待 /api/post/item_list/ ... (尝试 ${attempt}/${maxRetries})`,
161
+ );
143
162
 
144
- if (!data || !data.itemList) {
145
- // 使用 waitForFunction 等待错误信息出现(最多 8 秒)
146
- const pageError = await detectPageErrorWithWait(page, 8000);
163
+ const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
164
+ page,
165
+ username,
166
+ log,
167
+ );
147
168
 
148
- if (pageError === "service_error") {
149
- throw new Error(`被封: ${username}`);
150
- }
169
+ if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
170
+ // 成功拦截且拿到了视频,处理数据
171
+ return await processAPIResponse(
172
+ data,
173
+ username,
174
+ maxVideos,
175
+ items,
176
+ page,
177
+ apiRequestUrl,
178
+ log,
179
+ );
180
+ }
151
181
 
152
- // API 超时了,页面肯定有问题
153
- // 如果识别不出具体错误类型,抛异常(而不是静默返回空 Map)
154
- if (!pageError) {
155
- throw new Error(`@${username} 页面异常(无法识别错误类型)`);
182
+ // API 已返回但没有视频时,优先判定是否是确定性的页面错误。
183
+ let pageError = await detectPageError(page);
184
+
185
+ // service_error 经常比 API 判定晚一点渲染,补一个短等待避免漏判“被封”。
186
+ if (!pageError) {
187
+ const delayedPageError = await detectPageErrorWithWait(page, 3000);
188
+ if (delayedPageError) pageError = delayedPageError;
189
+ }
190
+
191
+ if (pageError === "service_error") {
192
+ throw new Error(`被封: ${username}`);
193
+ }
194
+
195
+ if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
196
+ if (noRetryErrors.has(pageError)) {
197
+ log(
198
+ ` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
199
+ );
200
+ return new Map();
156
201
  }
157
202
 
158
- // 其他已知错误(login_required, captcha, rate_limited 等)返回空 Map
203
+ log(` [API拦截] ${elapsed}ms API 返回 0 条视频`);
204
+ return new Map();
205
+ }
206
+
207
+ if (noRetryErrors.has(pageError)) {
159
208
  log(
160
209
  ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
161
210
  );
162
211
  return new Map();
163
212
  }
164
213
 
165
- // 处理数据(提取首页 + 翻页)
166
- return await processAPIResponse(
167
- data,
168
- username,
169
- maxVideos,
170
- items,
171
- page,
172
- apiRequestUrl,
173
- log,
214
+ // 最后一次重试也失败了
215
+ if (attempt === maxRetries) {
216
+ if (!pageError) {
217
+ throw new Error(
218
+ `@${username} 页面异常(${elapsed}ms,${maxRetries} 次重试均未拦截到 API)`,
219
+ );
220
+ }
221
+ log(
222
+ ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
223
+ );
224
+ return new Map();
225
+ }
226
+
227
+ // 还有重试次数,等待后重试
228
+ const waitTime = 2000 * attempt;
229
+ log(
230
+ ` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
174
231
  );
175
- } finally {
176
- // 5. 必须清理拦截器,防止累积
177
- page.off("response", responseHandler);
178
- page.off("request", requestHandler);
232
+ await new Promise((r) => setTimeout(r, waitTime));
179
233
  }
180
234
  }
181
235