tt-help-cli-ycl 1.3.52 → 1.3.57

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.
@@ -1,31 +1,21 @@
1
- import os from "os";
2
- import path from "path";
3
- import fs from "fs";
4
1
  import { chromium } from "playwright";
5
2
  import { detectBrowser } from "./browser/launch.js";
6
- import { parseUserInfo, parseVideoInfo } from "./parse-ssr.mjs";
3
+ import {
4
+ parseUserInfo,
5
+ parseVideoInfo,
6
+ isRetryableFailure,
7
+ } from "./parse-ssr.mjs";
7
8
 
8
9
  const DEFAULT_POOL_SIZE = 3;
9
10
  const DEFAULT_WAF_TTL = 120000;
10
11
  const DEFAULT_WARM_URL = "https://www.tiktok.com/@nike";
11
12
  const BROWSER_CLOSE_TIMEOUT = 5000;
12
13
  const DEFAULT_MAX_REQUESTS_PER_PAGE = 50;
13
- const FALLBACK_PROFILE_PORT = 9999;
14
14
 
15
15
  function delay(ms) {
16
16
  return new Promise((r) => setTimeout(r, ms));
17
17
  }
18
18
 
19
- function getFallbackProfileDir() {
20
- const profile = `p${FALLBACK_PROFILE_PORT}`;
21
- return path.join(
22
- os.homedir(),
23
- "Library",
24
- "Application Support",
25
- `Microsoft Edge For Testing_${profile}`,
26
- );
27
- }
28
-
29
19
  class PageSlot {
30
20
  constructor(page) {
31
21
  this.page = page;
@@ -61,49 +51,6 @@ class PromiseQueue {
61
51
  }
62
52
  }
63
53
 
64
- function createLaunchOptions(executablePath) {
65
- return {
66
- headless: true,
67
- executablePath,
68
- handleSIGINT: false,
69
- handleSIGTERM: false,
70
- handleSIGHUP: false,
71
- args: [
72
- "--no-sandbox",
73
- "--disable-setuid-sandbox",
74
- "--disable-dev-shm-usage",
75
- ],
76
- };
77
- }
78
-
79
- async function initContext(executablePath, poolSize, userDataDir) {
80
- let context;
81
- let browser = null;
82
- const slots = [];
83
-
84
- if (userDataDir) {
85
- context = await chromium.launchPersistentContext(
86
- userDataDir,
87
- createLaunchOptions(executablePath),
88
- );
89
- const existing = context.pages();
90
- if (existing.length > 0) {
91
- slots.push(new PageSlot(existing[0]));
92
- }
93
- for (let i = slots.length; i < poolSize; i++) {
94
- slots.push(new PageSlot(await context.newPage()));
95
- }
96
- } else {
97
- browser = await chromium.launch(createLaunchOptions(executablePath));
98
- context = await browser.newContext();
99
- for (let i = 0; i < poolSize; i++) {
100
- slots.push(new PageSlot(await context.newPage()));
101
- }
102
- }
103
-
104
- return { browser, context, slots };
105
- }
106
-
107
54
  export class TikTokScraper {
108
55
  constructor({
109
56
  poolSize = DEFAULT_POOL_SIZE,
@@ -121,11 +68,6 @@ export class TikTokScraper {
121
68
  this.slotIdx = 0;
122
69
  this.lastWarmTime = 0;
123
70
  this.warmPromise = null;
124
- // 登录态 pool(init 时直接启动)
125
- this.authBrowser = null;
126
- this.authContext = null;
127
- this.authSlots = [];
128
- this.authSlotIdx = 0;
129
71
  }
130
72
 
131
73
  async init() {
@@ -135,73 +77,49 @@ export class TikTokScraper {
135
77
  "未找到本地浏览器(Chrome/Edge),请先安装浏览器或执行 npx playwright install",
136
78
  );
137
79
  }
138
- const { browser, context, slots } = await initContext(
80
+ this.browser = await chromium.launch({
81
+ headless: true,
139
82
  executablePath,
140
- this.poolSize,
141
- null,
142
- );
143
- this.browser = browser;
144
- this.context = context;
145
- this.slots = slots;
146
-
147
- // 启动登录态 pool(1 个 slot)
148
- // profile 不存在则跳过,不影响主流程
149
- const fallbackDir = getFallbackProfileDir();
150
- if (fs.existsSync(fallbackDir)) {
151
- const {
152
- browser: authBrowser,
153
- context: authContext,
154
- slots: authSlots,
155
- } = await initContext(executablePath, 1, fallbackDir);
156
- this.authBrowser = authBrowser;
157
- this.authContext = authContext;
158
- this.authSlots = authSlots;
159
- } else {
160
- console.error(
161
- `[TikTokScraper] 登录态 profile 不存在 (${fallbackDir}),跳过登录态 pool。请先运行 tt-help open 9999 登录 TikTok`,
162
- );
83
+ handleSIGINT: false,
84
+ handleSIGTERM: false,
85
+ handleSIGHUP: false,
86
+ args: [
87
+ "--no-sandbox",
88
+ "--disable-setuid-sandbox",
89
+ "--disable-dev-shm-usage",
90
+ ],
91
+ });
92
+ this.context = await this.browser.newContext();
93
+ for (let i = 0; i < this.poolSize; i++) {
94
+ this.slots.push(new PageSlot(await this.context.newPage()));
163
95
  }
164
-
165
96
  await this.warmWaf();
166
97
  }
167
98
 
168
99
  async close() {
169
- const closeAll = async (browser) => {
170
- if (browser) {
171
- let closeTimedOut = false;
172
- const closePromise = browser.close().catch((error) => {
173
- console.error(
174
- `[TikTokScraper] browser.close() failed: ${error.message}`,
175
- );
176
- });
177
- await Promise.race([
178
- closePromise,
179
- delay(BROWSER_CLOSE_TIMEOUT).then(() => {
180
- closeTimedOut = true;
181
- }),
182
- ]);
183
- if (closeTimedOut) {
184
- console.error(
185
- `[TikTokScraper] browser.close() 超时 ${BROWSER_CLOSE_TIMEOUT}ms,跳过等待并继续退出`,
186
- );
187
- }
100
+ if (this.browser) {
101
+ const browser = this.browser;
102
+ let closeTimedOut = false;
103
+ const closePromise = browser.close().catch((error) => {
104
+ console.error(
105
+ `[TikTokScraper] browser.close() failed: ${error.message}`,
106
+ );
107
+ });
108
+ await Promise.race([
109
+ closePromise,
110
+ delay(BROWSER_CLOSE_TIMEOUT).then(() => {
111
+ closeTimedOut = true;
112
+ }),
113
+ ]);
114
+ if (closeTimedOut) {
115
+ console.error(
116
+ `[TikTokScraper] browser.close() 超时 ${BROWSER_CLOSE_TIMEOUT}ms,跳过等待并继续退出`,
117
+ );
188
118
  }
189
- };
190
-
191
- // 无登录态的 browser
192
- await closeAll(this.browser);
193
- // 登录态的 context(launchPersistentContext 返回的是 context 当 browser)
194
- if (this.authContext) {
195
- try {
196
- await this.authContext.close();
197
- } catch {}
119
+ this.browser = null;
120
+ this.context = null;
121
+ this.slots = [];
198
122
  }
199
- this.browser = null;
200
- this.context = null;
201
- this.slots = [];
202
- this.authBrowser = null;
203
- this.authContext = null;
204
- this.authSlots = [];
205
123
  }
206
124
 
207
125
  async restart() {
@@ -211,7 +129,7 @@ export class TikTokScraper {
211
129
 
212
130
  isAlive() {
213
131
  try {
214
- return !!(this.context && (!this.browser || this.browser.isConnected()));
132
+ return !!(this.browser && this.browser.isConnected());
215
133
  } catch {
216
134
  return false;
217
135
  }
@@ -238,11 +156,17 @@ export class TikTokScraper {
238
156
  return this.warmPromise;
239
157
  }
240
158
 
241
- isWarmExpired() {
159
+ _needWarm() {
242
160
  return Date.now() - this.lastWarmTime > this.wafTtl;
243
161
  }
244
162
 
245
- async _ensurePage(slot, context) {
163
+ _pickSlot() {
164
+ const slot = this.slots[this.slotIdx % this.poolSize];
165
+ this.slotIdx++;
166
+ return slot;
167
+ }
168
+
169
+ async _ensurePage(slot) {
246
170
  try {
247
171
  if (
248
172
  !slot.page.isClosed() &&
@@ -252,111 +176,88 @@ export class TikTokScraper {
252
176
  }
253
177
  } catch {}
254
178
  await slot.page?.close().catch(() => {});
255
- slot.page = await context.newPage();
179
+ slot.page = await this.context.newPage();
256
180
  slot.requestCount = 0;
257
181
  return slot.page;
258
182
  }
259
183
 
260
- async _fetchViewSource(url, ctx) {
261
- const slots = ctx === this.authContext ? this.authSlots : this.slots;
262
- const slotIdx = ctx === this.authContext ? this.authSlotIdx : this.slotIdx;
263
- const slot = slots[slotIdx % slots.length];
264
- if (ctx === this.authContext) {
265
- this.authSlotIdx++;
266
- } else {
267
- this.slotIdx++;
268
- }
269
-
270
- try {
271
- return await slot.lock.run(async () => {
272
- const page = await this._ensurePage(slot, ctx);
273
-
274
- if (ctx === this.context && this.isWarmExpired()) {
275
- await this.warmWaf();
276
- }
184
+ async _fetchViewSource(url, slot) {
185
+ const page = await this._ensurePage(slot);
277
186
 
278
- await page.goto("view-source:" + url, {
279
- waitUntil: "domcontentloaded",
280
- timeout: 15000,
281
- });
282
-
283
- const content = await page.evaluate(() => {
284
- const rows = document.querySelectorAll("tr");
285
- let content = "";
286
- rows.forEach((r) => {
287
- const lc = r.querySelector(".line-content");
288
- if (lc) content += lc.textContent + "\n";
289
- });
290
- return content;
291
- });
292
-
293
- // 导航到 about:blank 释放当前页面的 DOM 和 JS 堆
294
- await page
295
- .goto("about:blank", {
296
- waitUntil: "domcontentloaded",
297
- timeout: 5000,
298
- })
299
- .catch(() => {});
300
-
301
- slot.requestCount += 1;
187
+ await page.goto("view-source:" + url, {
188
+ waitUntil: "domcontentloaded",
189
+ timeout: 15000,
190
+ });
302
191
 
303
- return content;
192
+ const content = await page.evaluate(() => {
193
+ const rows = document.querySelectorAll("tr");
194
+ let content = "";
195
+ rows.forEach((r) => {
196
+ const lc = r.querySelector(".line-content");
197
+ if (lc) content += lc.textContent + "\n";
304
198
  });
305
- } catch (e) {
306
- console.error(`[TikTokScraper] _fetchViewSource failed: ${e.message}`);
307
- return null;
308
- }
309
- }
310
-
311
- async getUserInfo(username) {
312
- const normalizedUsername = username.startsWith("@")
313
- ? username.slice(1)
314
- : username;
315
- const url = `https://www.tiktok.com/@${normalizedUsername}`;
316
-
317
- // 第一趟:无登录态
318
- let rawHtml = await this._fetchViewSource(url, this.context);
319
- let result = rawHtml ? parseUserInfo(rawHtml) : null;
199
+ return content;
200
+ });
320
201
 
321
- // 解析失败:先尝试 warmWaf 后重试
322
- if (!result) {
323
- try {
324
- await this.warmWaf();
325
- } catch {}
326
- rawHtml = await this._fetchViewSource(url, this.context);
327
- result = rawHtml ? parseUserInfo(rawHtml) : null;
328
- }
202
+ // 导航到 about:blank 释放当前页面的 DOM 和 JS 堆
203
+ await page
204
+ .goto("about:blank", {
205
+ waitUntil: "domcontentloaded",
206
+ timeout: 5000,
207
+ })
208
+ .catch(() => {});
329
209
 
330
- // 仍然失败:使用登录态 pool
331
- if (!result && this.authContext) {
332
- rawHtml = await this._fetchViewSource(url, this.authContext);
333
- result = rawHtml ? parseUserInfo(rawHtml) : null;
334
- }
210
+ slot.requestCount += 1;
335
211
 
336
- return result || null;
212
+ return content;
337
213
  }
338
214
 
339
- async getVideoInfo(videoUrl) {
340
- // 第一趟:无登录态
341
- let rawHtml = await this._fetchViewSource(videoUrl, this.context);
342
- let result = rawHtml ? parseVideoInfo(rawHtml) : null;
343
-
344
- // 解析失败:先尝试 warmWaf 后重试
345
- if (!result) {
346
- try {
347
- await this.warmWaf();
348
- } catch {}
349
- rawHtml = await this._fetchViewSource(videoUrl, this.context);
350
- result = rawHtml ? parseVideoInfo(rawHtml) : null;
351
- }
352
-
353
- // 仍然失败:使用登录态 pool
354
- if (!result && this.authContext) {
355
- rawHtml = await this._fetchViewSource(videoUrl, this.authContext);
356
- result = rawHtml ? parseVideoInfo(rawHtml) : null;
357
- }
215
+ async getUserInfo(uniqueId, maxRetries = 3) {
216
+ const slot = this._pickSlot();
217
+ return slot.lock.run(async () => {
218
+ let rawHtml = await this._fetchViewSource(
219
+ `https://www.tiktok.com/@${uniqueId}`,
220
+ slot,
221
+ );
222
+ let result = parseUserInfo(rawHtml);
223
+ for (let attempt = 1; !result && attempt <= maxRetries; attempt++) {
224
+ // 检查是否值得重试:用户异常/不存在则跳过重试
225
+ if (!isRetryableFailure(rawHtml)) {
226
+ break;
227
+ }
228
+ try {
229
+ await this.warmWaf();
230
+ } catch {}
231
+ await delay(500 * attempt);
232
+ rawHtml = await this._fetchViewSource(
233
+ `https://www.tiktok.com/@${uniqueId}`,
234
+ slot,
235
+ );
236
+ result = parseUserInfo(rawHtml);
237
+ }
238
+ return result || null;
239
+ });
240
+ }
358
241
 
359
- return result || null;
242
+ async getVideoInfo(videoUrl, maxRetries = 3) {
243
+ const slot = this._pickSlot();
244
+ return slot.lock.run(async () => {
245
+ let rawHtml = await this._fetchViewSource(videoUrl, slot);
246
+ let result = parseVideoInfo(rawHtml);
247
+ for (let attempt = 1; !result && attempt <= maxRetries; attempt++) {
248
+ // 检查是否值得重试
249
+ if (!isRetryableFailure(rawHtml)) {
250
+ break;
251
+ }
252
+ try {
253
+ await this.warmWaf();
254
+ } catch {}
255
+ await delay(500 * attempt);
256
+ rawHtml = await this._fetchViewSource(videoUrl, slot);
257
+ result = parseVideoInfo(rawHtml);
258
+ }
259
+ return result || null;
260
+ });
360
261
  }
361
262
 
362
263
  async getUserAndVideo(videoUrl) {
@@ -38,6 +38,7 @@ async function processExplore(page, username, options, log) {
38
38
  keepFollow: false,
39
39
  locationCreated: null,
40
40
  noVideo: false,
41
+ restricted: false,
41
42
  error: null,
42
43
  };
43
44
 
@@ -172,6 +173,7 @@ async function processExplore(page, username, options, log) {
172
173
  result.videoList = videoArray;
173
174
  } else {
174
175
  // 国家不匹配
176
+ result.restricted = true;
175
177
  result.keepFollow = false;
176
178
  result.discoveredFollowing = [];
177
179
  result.discoveredFollowers = [];