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.
- package/package.json +1 -1
- package/src/cli/attach.js +25 -13
- package/src/cli/info.js +50 -28
- package/src/cli/open.js +3 -2
- package/src/lib/parse-ssr.mjs +70 -15
- package/src/lib/scrape.js +2 -3
- package/src/lib/tiktok-scraper.mjs +118 -217
- package/src/scraper/explore-core.js +2 -0
- package/src/watch/data-store.js +488 -112
- package/src/watch/public/index.html +115 -16
- package/src/watch/server.js +45 -3
- package/scripts/run-explore copy.bat +0 -101
- package/scripts/test-captcha-lib.mjs +0 -68
- package/scripts/test-captcha.mjs +0 -81
- package/scripts/test-incognito-lib.mjs +0 -36
- package/scripts/test-login-state.mjs +0 -128
- package/scripts/test-safe-click.mjs +0 -45
- package/scripts/test-watch-db-smoke.mjs +0 -246
|
@@ -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 {
|
|
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
|
-
|
|
80
|
+
this.browser = await chromium.launch({
|
|
81
|
+
headless: true,
|
|
139
82
|
executablePath,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
159
|
+
_needWarm() {
|
|
242
160
|
return Date.now() - this.lastWarmTime > this.wafTtl;
|
|
243
161
|
}
|
|
244
162
|
|
|
245
|
-
|
|
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,
|
|
261
|
-
const
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
212
|
+
return content;
|
|
337
213
|
}
|
|
338
214
|
|
|
339
|
-
async
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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 = [];
|