tt-help-cli-ycl 1.3.28 → 1.3.29

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.29",
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
  // 保持进程运行
@@ -1,10 +1,7 @@
1
1
  import { delay } from "./delay.js";
2
2
  import { retryWithBackoff } from "./retry.js";
3
3
  import { assertPageUrl } from "./browser/page.js";
4
- import {
5
- detectPageError,
6
- detectPageErrorWithWait,
7
- } from "./page-error-detector.js";
4
+ import { detectPageError } from "./page-error-detector.js";
8
5
 
9
6
  /**
10
7
  * 处理 API 响应数据:提取首页视频 + 翻页
@@ -80,35 +77,16 @@ async function processAPIResponse(
80
77
  }
81
78
 
82
79
  /**
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 超时或网络异常时抛出
80
+ * 单次 API 拦截尝试:注册拦截器 → 导航 → 用 waitForResponse 精确等待 API
81
+ * @returns {{data: object|null, apiRequestUrl: string|null, elapsed: number}}
92
82
  */
93
- async function fetchUserVideosAPI(page, username, maxVideos, log) {
83
+ async function tryInterceptAPI(page, username, log) {
94
84
  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
85
  let apiRequestUrl = null;
104
86
 
105
87
  const responseHandler = async (response) => {
106
88
  if (response.url().includes("/api/post/item_list/")) {
107
- try {
108
- apiResolve(await response.json());
109
- } catch (e) {
110
- apiResolve(null);
111
- }
89
+ // 只记录 URL,数据通过 waitForResponse 获取
112
90
  }
113
91
  };
114
92
 
@@ -121,61 +99,128 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
121
99
  page.on("response", responseHandler);
122
100
  page.on("request", requestHandler);
123
101
 
102
+ const t0 = Date.now();
103
+
124
104
  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 },
105
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
106
+ await assertPageUrl(page, `@${username}`);
107
+
108
+ // 用 waitForResponse 精确等待 API 响应(15 秒超时,适合弱网)
109
+ const response = await page.waitForResponse(
110
+ (res) => res.url().includes("/api/post/item_list/"),
111
+ { timeout: 15000 },
135
112
  );
136
113
 
137
- const data = await Promise.race([
138
- apiPromise,
139
- new Promise((r) => setTimeout(() => r(null), 8000)),
140
- ]);
114
+ const data = await response.json();
115
+ const elapsed = Date.now() - t0;
116
+ log(` [API拦截] ${elapsed}ms 后成功拦截 API 响应`);
141
117
 
118
+ return { data, apiRequestUrl, elapsed };
119
+ } catch (e) {
142
120
  const elapsed = Date.now() - t0;
121
+ log(` [API拦截] ${elapsed}ms 后拦截失败: ${e.message}`);
122
+ return { data: null, apiRequestUrl, elapsed, error: e.message };
123
+ } finally {
124
+ page.off("response", responseHandler);
125
+ page.off("request", requestHandler);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 通过拦截 TikTok 内部 API 获取用户视频列表
131
+ * 比 DOM 滚动解析快 5-10 倍
132
+ *
133
+ * 弱网优化:
134
+ * - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
135
+ * - 最多重试 3 次,每次重试前清理拦截器
136
+ * - 超时 15s,适合弱网环境
137
+ *
138
+ * @param {import('playwright').Page} page - Playwright page (CDP 连接)
139
+ * @param {string} username - TikTok 用户名
140
+ * @param {number} maxVideos - 最大视频数
141
+ * @param {Function} log - 日志函数
142
+ * @returns {Map<string, {id: string, href: string}>} 与 collectVideos 返回格式一致
143
+ * @throws {Error} API 超时或网络异常时抛出
144
+ */
145
+ async function fetchUserVideosAPI(page, username, maxVideos, log) {
146
+ const items = [];
147
+ const maxRetries = 3;
148
+ const noRetryErrors = new Set([
149
+ "service_error",
150
+ "not_found",
151
+ "region_blocked",
152
+ "login_required",
153
+ ]);
154
+
155
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
156
+ log(
157
+ ` [API拦截] 导航到用户页,等待 /api/post/item_list/ ... (尝试 ${attempt}/${maxRetries})`,
158
+ );
143
159
 
144
- if (!data || !data.itemList) {
145
- // 使用 waitForFunction 等待错误信息出现(最多 8 秒)
146
- const pageError = await detectPageErrorWithWait(page, 8000);
160
+ const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
161
+ page,
162
+ username,
163
+ log,
164
+ );
147
165
 
148
- if (pageError === "service_error") {
149
- throw new Error(`被封: ${username}`);
150
- }
166
+ if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
167
+ // 成功拦截且拿到了视频,处理数据
168
+ return await processAPIResponse(
169
+ data,
170
+ username,
171
+ maxVideos,
172
+ items,
173
+ page,
174
+ apiRequestUrl,
175
+ log,
176
+ );
177
+ }
151
178
 
152
- // API 超时了,页面肯定有问题
153
- // 如果识别不出具体错误类型,抛异常(而不是静默返回空 Map)
154
- if (!pageError) {
155
- throw new Error(`@${username} 页面异常(无法识别错误类型)`);
179
+ // API 已返回但没有视频时,优先判定是否是确定性的页面错误。
180
+ const pageError = await detectPageError(page);
181
+
182
+ if (pageError === "service_error") {
183
+ throw new Error(`被封: ${username}`);
184
+ }
185
+
186
+ if (data && Array.isArray(data.itemList) && data.itemList.length === 0) {
187
+ if (noRetryErrors.has(pageError)) {
188
+ log(
189
+ ` [API拦截] ${elapsed}ms 后 API 返回空列表,页面异常(${pageError})`,
190
+ );
191
+ return new Map();
156
192
  }
157
193
 
158
- // 其他已知错误(login_required, captcha, rate_limited 等)返回空 Map
194
+ log(` [API拦截] ${elapsed}ms API 返回 0 条视频`);
195
+ return new Map();
196
+ }
197
+
198
+ if (noRetryErrors.has(pageError)) {
159
199
  log(
160
200
  ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError})`,
161
201
  );
162
202
  return new Map();
163
203
  }
164
204
 
165
- // 处理数据(提取首页 + 翻页)
166
- return await processAPIResponse(
167
- data,
168
- username,
169
- maxVideos,
170
- items,
171
- page,
172
- apiRequestUrl,
173
- log,
205
+ // 最后一次重试也失败了
206
+ if (attempt === maxRetries) {
207
+ if (!pageError) {
208
+ throw new Error(
209
+ `@${username} 页面异常(${elapsed}ms,${maxRetries} 次重试均未拦截到 API)`,
210
+ );
211
+ }
212
+ log(
213
+ ` [API拦截] ${elapsed}ms 后未拿到 API 数据,页面异常(${pageError}),${maxRetries} 次重试已用尽`,
214
+ );
215
+ return new Map();
216
+ }
217
+
218
+ // 还有重试次数,等待后重试
219
+ const waitTime = 2000 * attempt;
220
+ log(
221
+ ` [API拦截] 拦截失败(${error || "未拿到有效 API 数据"}),${waitTime / 1000}s 后重试...`,
174
222
  );
175
- } finally {
176
- // 5. 必须清理拦截器,防止累积
177
- page.off("response", responseHandler);
178
- page.off("request", requestHandler);
223
+ await new Promise((r) => setTimeout(r, waitTime));
179
224
  }
180
225
  }
181
226