tt-help-cli-ycl 1.3.55 → 1.3.58
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/open.js +3 -4
- package/src/lib/api-interceptor.js +6 -2
- package/src/scraper/explore-core.js +12 -0
- package/src/watch/data-store.js +457 -109
- package/src/watch/public/app.js +1266 -0
- package/src/watch/public/index.html +4 -2088
- package/src/watch/public/style.css +1070 -0
- package/src/watch/server.js +64 -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-html-analysis.mjs +0 -128
- 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
package/src/watch/server.js
CHANGED
|
@@ -180,6 +180,41 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
180
180
|
return;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
if (req.method === "GET" && routePath === "/api/job-debug") {
|
|
184
|
+
const userId = params.userId || "";
|
|
185
|
+
const locationsParam = params.locations || "";
|
|
186
|
+
const locations = locationsParam
|
|
187
|
+
? locationsParam
|
|
188
|
+
.split(",")
|
|
189
|
+
.map((s) => s.trim().toUpperCase())
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
: null;
|
|
192
|
+
const loggedIn = params.loggedIn === "true";
|
|
193
|
+
const debug = store.debugClaimNextJob(
|
|
194
|
+
userId,
|
|
195
|
+
5 * 60 * 1000,
|
|
196
|
+
locations,
|
|
197
|
+
loggedIn,
|
|
198
|
+
);
|
|
199
|
+
sendJSON(res, 200, debug);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 调试接口:直接查询数据库原始数据
|
|
204
|
+
if (req.method === "GET" && routePath === "/api/db-query") {
|
|
205
|
+
const sql = params.sql || "SELECT * FROM jobs LIMIT 10";
|
|
206
|
+
const limit = Math.min(parseInt(params.limit) || 100, 1000);
|
|
207
|
+
// 安全限制:自动加 LIMIT
|
|
208
|
+
const safeSql = sql.replace(/LIMIT\s+\d+/gi, "") + ` LIMIT ${limit}`;
|
|
209
|
+
try {
|
|
210
|
+
const result = store.rawQuery(safeSql);
|
|
211
|
+
sendJSON(res, 200, result);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
sendJSON(res, 400, { error: e.message });
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
183
218
|
const jobCommitMatch = routePath.match(/^\/api\/job\/([^/]+)$/);
|
|
184
219
|
if (req.method === "POST" && jobCommitMatch) {
|
|
185
220
|
const uniqueId = jobCommitMatch[1];
|
|
@@ -334,11 +369,16 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
334
369
|
if (req.method === "GET" && routePath === "/api/user-update-tasks") {
|
|
335
370
|
const limit = params.limit;
|
|
336
371
|
const countries = params.countries
|
|
337
|
-
? params.countries
|
|
372
|
+
? params.countries
|
|
373
|
+
.split(",")
|
|
374
|
+
.map((c) => c.trim().toUpperCase())
|
|
375
|
+
.filter(Boolean)
|
|
338
376
|
: [];
|
|
339
377
|
const tasks = store.getPendingUserUpdateTasks(limit, countries);
|
|
340
378
|
const ts = new Date().toISOString().slice(11, 19);
|
|
341
|
-
console.error(
|
|
379
|
+
console.error(
|
|
380
|
+
`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks${countries.length ? ` (countries: ${countries.join(",")})` : ""}`,
|
|
381
|
+
);
|
|
342
382
|
sendJSON(res, 200, { total: tasks.length, tasks });
|
|
343
383
|
return;
|
|
344
384
|
}
|
|
@@ -457,6 +497,7 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
457
497
|
"ttSeller",
|
|
458
498
|
"verified",
|
|
459
499
|
"locationCreated",
|
|
500
|
+
"latestVideoTime",
|
|
460
501
|
"status",
|
|
461
502
|
"sources",
|
|
462
503
|
];
|
|
@@ -467,6 +508,7 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
467
508
|
ttSeller: u.ttSeller,
|
|
468
509
|
verified: u.verified,
|
|
469
510
|
locationCreated: u.locationCreated || "",
|
|
511
|
+
latestVideoTime: u.latestVideoTime || "",
|
|
470
512
|
status: u.status || "",
|
|
471
513
|
sources: (u.sources || []).join(";"),
|
|
472
514
|
}));
|
|
@@ -554,7 +596,9 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
554
596
|
} else if (body.country) {
|
|
555
597
|
result = store.restoreRawJobsByCountry(body.country);
|
|
556
598
|
} else {
|
|
557
|
-
sendJSON(res, 400, {
|
|
599
|
+
sendJSON(res, 400, {
|
|
600
|
+
error: "missing filter: uniqueId, country, or search/location",
|
|
601
|
+
});
|
|
558
602
|
return;
|
|
559
603
|
}
|
|
560
604
|
if (result.error) {
|
|
@@ -637,6 +681,7 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
637
681
|
verified: u.verified,
|
|
638
682
|
followerCount: u.followerCount,
|
|
639
683
|
locationCreated: u.locationCreated,
|
|
684
|
+
latestVideoTime: u.latestVideoTime,
|
|
640
685
|
guessedLocation: u.guessedLocation,
|
|
641
686
|
pinned: u.pinned,
|
|
642
687
|
processedAt: u.processedAt,
|
|
@@ -657,6 +702,22 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
657
702
|
return;
|
|
658
703
|
}
|
|
659
704
|
|
|
705
|
+
// 静态资源:CSS / JS
|
|
706
|
+
if (req.method === "GET") {
|
|
707
|
+
const staticMatch = routePath.match(/^\/(style\.css|app\.js)$/);
|
|
708
|
+
if (staticMatch) {
|
|
709
|
+
const staticFile = join(publicDir, staticMatch[1]);
|
|
710
|
+
if (existsSync(staticFile)) {
|
|
711
|
+
const content = readFileSync(staticFile, "utf-8");
|
|
712
|
+
const ext = staticMatch[1].split(".").pop();
|
|
713
|
+
const mime = ext === "css" ? "text/css" : "text/javascript";
|
|
714
|
+
res.writeHead(200, { "Content-Type": `${mime}; charset=utf-8` });
|
|
715
|
+
res.end(content);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
660
721
|
const scriptMatch = routePath.match(/^\/scripts\/(.+)$/);
|
|
661
722
|
if (req.method === "GET" && scriptMatch) {
|
|
662
723
|
const scriptsDir = join(__dirname, "../../scripts");
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
@ECHO OFF
|
|
2
|
-
SETLOCAL EnableDelayedExpansion
|
|
3
|
-
|
|
4
|
-
SET "PACKAGENAME=tt-help-cli-ycl"
|
|
5
|
-
SET "TARGET_SERVER=http://117.71.53.99:17301"
|
|
6
|
-
SET "LOCAL_IP="
|
|
7
|
-
SET "GET_IP_PS1=%TEMP%\tt_get_local_ip.ps1"
|
|
8
|
-
>"%GET_IP_PS1%" ECHO $ip = $null
|
|
9
|
-
>>"%GET_IP_PS1%" ECHO try {
|
|
10
|
-
>>"%GET_IP_PS1%" ECHO $ip = Get-WmiObject Win32_NetworkAdapterConfiguration ^| Where-Object { $_.IPEnabled -eq $true } ^| ForEach-Object { $_.IPAddress } ^| Where-Object { $_ -match '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -and $_ -ne '127.0.0.1' -and $_ -notlike '169.254.*' } ^| Select-Object -First 1
|
|
11
|
-
>>"%GET_IP_PS1%" ECHO } catch {}
|
|
12
|
-
>>"%GET_IP_PS1%" ECHO if (-not $ip) {
|
|
13
|
-
>>"%GET_IP_PS1%" ECHO try {
|
|
14
|
-
>>"%GET_IP_PS1%" ECHO $ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop ^| Where-Object { $_.IPAddress -ne '127.0.0.1' -and $_.IPAddress -notlike '169.254.*' } ^| Select-Object -First 1 -ExpandProperty IPAddress
|
|
15
|
-
>>"%GET_IP_PS1%" ECHO } catch {}
|
|
16
|
-
>>"%GET_IP_PS1%" ECHO }
|
|
17
|
-
>>"%GET_IP_PS1%" ECHO if (-not $ip) {
|
|
18
|
-
>>"%GET_IP_PS1%" ECHO try {
|
|
19
|
-
>>"%GET_IP_PS1%" ECHO $ip = [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) ^| Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and $_.IPAddressToString -ne '127.0.0.1' -and $_.IPAddressToString -notlike '169.254.*' } ^| Select-Object -First 1 -ExpandProperty IPAddressToString
|
|
20
|
-
>>"%GET_IP_PS1%" ECHO } catch {}
|
|
21
|
-
>>"%GET_IP_PS1%" ECHO }
|
|
22
|
-
>>"%GET_IP_PS1%" ECHO if ($ip) { [Console]::Write($ip) }
|
|
23
|
-
FOR /F "usebackq delims=" %%I IN (`powershell -NoProfile -ExecutionPolicy Bypass -File "%GET_IP_PS1%"`) DO (
|
|
24
|
-
SET "LOCAL_IP=%%I"
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
ECHO [INFO] Local IP: %LOCAL_IP%
|
|
28
|
-
|
|
29
|
-
IF DEFINED LOCAL_IP IF "%LOCAL_IP:~0,11%"=="172.18.154." SET "TARGET_SERVER=http://172.18.154.201:3001"
|
|
30
|
-
|
|
31
|
-
IF NOT DEFINED LOCAL_IP (
|
|
32
|
-
ECHO [INFO] No local IPv4 detected, using public server
|
|
33
|
-
) ELSE IF "%LOCAL_IP:~0,11%"=="172.18.154." (
|
|
34
|
-
ECHO [INFO] Intranet IP detected, using intranet server
|
|
35
|
-
) ELSE (
|
|
36
|
-
ECHO [INFO] No intranet IP detected, using public server
|
|
37
|
-
)
|
|
38
|
-
SET "CONFIG_PATH=%USERPROFILE%\.tt-help.json"
|
|
39
|
-
|
|
40
|
-
ECHO ========================================
|
|
41
|
-
ECHO tt-help-cli-ycl one-click launcher (Windows CMD)
|
|
42
|
-
ECHO ========================================
|
|
43
|
-
|
|
44
|
-
REM ---------- 1. Check/install latest version ----------
|
|
45
|
-
FOR /F "delims=" %%V IN ('npm view %PACKAGENAME% version 2^>nul') DO SET "LATEST_VERSION=%%V"
|
|
46
|
-
|
|
47
|
-
IF NOT DEFINED LATEST_VERSION (
|
|
48
|
-
ECHO [ERROR] Cannot get latest version from npm
|
|
49
|
-
EXIT /B 1
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
FOR /F "tokens=2 delims=@" %%V IN ('npm list -g %PACKAGENAME% --depth=0 2^>nul ^| findstr /i "%PACKAGENAME%"') DO SET "INSTALLED_VERSION=%%V"
|
|
53
|
-
|
|
54
|
-
IF NOT DEFINED INSTALLED_VERSION (
|
|
55
|
-
ECHO [INFO] %PACKAGENAME% not installed, installing latest...
|
|
56
|
-
CALL npm install -g %PACKAGENAME%
|
|
57
|
-
IF %ERRORLEVEL% EQU 0 (
|
|
58
|
-
ECHO [OK] Installed: %LATEST_VERSION%
|
|
59
|
-
) ELSE (
|
|
60
|
-
ECHO [ERROR] Install failed, run manually: npm install -g %PACKAGENAME%
|
|
61
|
-
EXIT /B 1
|
|
62
|
-
)
|
|
63
|
-
) ELSE IF "%INSTALLED_VERSION%"=="%LATEST_VERSION%" (
|
|
64
|
-
ECHO [OK] %PACKAGENAME% is up to date: %LATEST_VERSION%
|
|
65
|
-
) ELSE (
|
|
66
|
-
ECHO [INFO] Current: %INSTALLED_VERSION%, Latest: %LATEST_VERSION%
|
|
67
|
-
ECHO [INFO] Upgrading to latest...
|
|
68
|
-
CALL npm install -g %PACKAGENAME%
|
|
69
|
-
IF %ERRORLEVEL% EQU 0 (
|
|
70
|
-
ECHO [OK] Upgraded: %LATEST_VERSION%
|
|
71
|
-
) ELSE (
|
|
72
|
-
ECHO [WARN] Upgrade failed, run manually: npm install -g %PACKAGENAME%
|
|
73
|
-
)
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
REM ---------- 2. Check/set server config ----------
|
|
77
|
-
SET "CURRENT_SERVER="
|
|
78
|
-
IF EXIST "%CONFIG_PATH%" (
|
|
79
|
-
FOR /F "usebackq delims=" %%S IN (`powershell -NoProfile -Command "$p=$env:CONFIG_PATH; if (Test-Path $p) { try { $cfg = Get-Content $p -Raw | ConvertFrom-Json; if ($null -ne $cfg.server) { [Console]::Write($cfg.server) } } catch {} }"`) DO SET "CURRENT_SERVER=%%S"
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
IF "%CURRENT_SERVER%"=="%TARGET_SERVER%" (
|
|
83
|
-
ECHO [OK] Server config is correct: %TARGET_SERVER%
|
|
84
|
-
) ELSE (
|
|
85
|
-
IF "%CURRENT_SERVER%"=="" (
|
|
86
|
-
ECHO [INFO] Current server: not set, target: %TARGET_SERVER%
|
|
87
|
-
) ELSE (
|
|
88
|
-
ECHO [INFO] Current server: %CURRENT_SERVER%, target: %TARGET_SERVER%
|
|
89
|
-
)
|
|
90
|
-
ECHO [INFO] Setting server config...
|
|
91
|
-
node -e "const fs=require('fs'),path=require('path');const p=path.join(require('os').homedir(),'.tt-help.json');let c={};try{c=JSON.parse(fs.readFileSync(p,'utf-8'))}catch(e){}c.server='%TARGET_SERVER%';fs.writeFileSync(p,JSON.stringify(c,null,2),'utf-8');console.log(' Written to: '+p);"
|
|
92
|
-
ECHO [OK] Server config set
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
REM ---------- 3. Start tt-help explore ----------
|
|
96
|
-
ECHO.
|
|
97
|
-
ECHO ========================================
|
|
98
|
-
ECHO Starting tt-help explore
|
|
99
|
-
ECHO ========================================
|
|
100
|
-
tt-help explore --port 9223 --profile p9223
|
|
101
|
-
DEL "%GET_IP_PS1%" 2>NUL
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { chromium } from 'playwright';
|
|
2
|
-
|
|
3
|
-
const URL = 'https://www.tiktok.com/@mariaelenasanchez607/video/7630110959650000150';
|
|
4
|
-
|
|
5
|
-
async function main() {
|
|
6
|
-
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
|
|
7
|
-
const page = browser.contexts()[0].pages()[0];
|
|
8
|
-
|
|
9
|
-
// 测试 detectCaptcha
|
|
10
|
-
console.error('=== 测试 detectCaptcha (无验证码) ===');
|
|
11
|
-
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
12
|
-
await page.waitForTimeout(3000);
|
|
13
|
-
|
|
14
|
-
const { detectCaptcha, closeCaptcha, handleCaptcha } = await import('../src/scraper/modules/captcha-handler.mjs');
|
|
15
|
-
|
|
16
|
-
const r1 = await detectCaptcha(page);
|
|
17
|
-
console.error('未点击评论:', JSON.stringify(r1));
|
|
18
|
-
|
|
19
|
-
// 点击评论触发验证码
|
|
20
|
-
await page.evaluate(() => {
|
|
21
|
-
const all = document.querySelectorAll('button');
|
|
22
|
-
for (const el of all) {
|
|
23
|
-
if (/^评论$/.test(el.textContent?.trim()) && el.offsetParent !== null && el.getBoundingClientRect().width > 0) {
|
|
24
|
-
el.click();
|
|
25
|
-
break;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
await page.waitForTimeout(3000);
|
|
30
|
-
|
|
31
|
-
console.error('\n=== 测试 detectCaptcha (有验证码) ===');
|
|
32
|
-
const r2 = await detectCaptcha(page);
|
|
33
|
-
console.error('点击评论后:', JSON.stringify(r2));
|
|
34
|
-
|
|
35
|
-
console.error('\n=== 测试 closeCaptcha ===');
|
|
36
|
-
const r3 = await closeCaptcha(page);
|
|
37
|
-
await page.waitForTimeout(1000);
|
|
38
|
-
console.error('关闭结果:', JSON.stringify(r3));
|
|
39
|
-
|
|
40
|
-
const r4 = await detectCaptcha(page);
|
|
41
|
-
console.error('关闭后检测:', JSON.stringify(r4));
|
|
42
|
-
|
|
43
|
-
console.error('\n=== 测试 handleCaptcha (完整流程) ===');
|
|
44
|
-
// 重新触发
|
|
45
|
-
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
46
|
-
await page.waitForTimeout(3000);
|
|
47
|
-
await page.evaluate(() => {
|
|
48
|
-
const all = document.querySelectorAll('button');
|
|
49
|
-
for (const el of all) {
|
|
50
|
-
if (/^评论$/.test(el.textContent?.trim()) && el.offsetParent !== null && el.getBoundingClientRect().width > 0) {
|
|
51
|
-
el.click();
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
await page.waitForTimeout(3000);
|
|
57
|
-
|
|
58
|
-
const r5 = await handleCaptcha(page);
|
|
59
|
-
console.error('handleCaptcha 结果:', JSON.stringify(r5));
|
|
60
|
-
|
|
61
|
-
await page.screenshot({ path: '/tmp/lib-test-final.png' });
|
|
62
|
-
console.error('\n最终截图: /tmp/lib-test-final.png');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
main().catch(err => {
|
|
66
|
-
console.error('错误:', err);
|
|
67
|
-
process.exit(1);
|
|
68
|
-
});
|
package/scripts/test-captcha.mjs
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { ensureBrowserReady } from '../src/lib/browser/cdp.js';
|
|
2
|
-
|
|
3
|
-
const url = 'https://www.tiktok.com/@mariaelenasanchez607/video/7630110959650000150';
|
|
4
|
-
|
|
5
|
-
async function main() {
|
|
6
|
-
const browser = await ensureBrowserReady();
|
|
7
|
-
const defaultContext = browser.contexts()[0];
|
|
8
|
-
const pages = defaultContext.pages();
|
|
9
|
-
const page = pages[0] || await defaultContext.newPage();
|
|
10
|
-
|
|
11
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
12
|
-
await page.waitForTimeout(5000);
|
|
13
|
-
|
|
14
|
-
// 用 force: true 点击评论按钮
|
|
15
|
-
const clicked = await page.evaluate(() => {
|
|
16
|
-
const btn = document.querySelector('[data-e2e="comments"]');
|
|
17
|
-
if (btn && btn.getBoundingClientRect().width > 0) {
|
|
18
|
-
btn.click();
|
|
19
|
-
return { success: true, rect: btn.getBoundingClientRect() };
|
|
20
|
-
}
|
|
21
|
-
return { success: false };
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
console.error('点击结果:', JSON.stringify(clicked));
|
|
25
|
-
|
|
26
|
-
// 等待可能的验证码
|
|
27
|
-
await page.waitForTimeout(5000);
|
|
28
|
-
|
|
29
|
-
// 截图
|
|
30
|
-
await page.screenshot({ path: '/tmp/tiktok-comment-clicked.png' });
|
|
31
|
-
console.error('截图: /tmp/tiktok-comment-clicked.png');
|
|
32
|
-
|
|
33
|
-
// 全面检测验证码
|
|
34
|
-
const captcha = await page.evaluate(() => {
|
|
35
|
-
const result = {};
|
|
36
|
-
|
|
37
|
-
// 大尺寸 Verify 元素
|
|
38
|
-
const verifyEls = Array.from(document.querySelectorAll('[class*="Verify"], [class*="verify"]'));
|
|
39
|
-
result.verifyElements = verifyEls.filter(el => {
|
|
40
|
-
const r = el.getBoundingClientRect();
|
|
41
|
-
return r.width > 100 && r.height > 100 && el.offsetParent !== null;
|
|
42
|
-
}).map(el => ({
|
|
43
|
-
class: el.className.substring(0, 200),
|
|
44
|
-
text: el.textContent?.substring(0, 300),
|
|
45
|
-
rect: { w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height), x: Math.round(el.getBoundingClientRect().x), y: Math.round(el.getBoundingClientRect().y) }
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
// 全屏遮罩
|
|
49
|
-
result.fullScreenOverlays = Array.from(document.querySelectorAll('div')).filter(d => {
|
|
50
|
-
const r = d.getBoundingClientRect();
|
|
51
|
-
const style = window.getComputedStyle(d);
|
|
52
|
-
return r.width > 500 && r.height > 500 && parseInt(style.zIndex) > 900 && d.offsetParent !== null;
|
|
53
|
-
}).map(d => ({
|
|
54
|
-
class: d.className.substring(0, 100),
|
|
55
|
-
zIndex: window.getComputedStyle(d).zIndex,
|
|
56
|
-
rect: { w: Math.round(d.getBoundingClientRect().width), h: Math.round(d.getBoundingClientRect().height) }
|
|
57
|
-
}));
|
|
58
|
-
|
|
59
|
-
result.iframes = Array.from(document.querySelectorAll('iframe')).map(f => ({
|
|
60
|
-
src: (f.src || f.getAttribute('src') || '').substring(0, 300)
|
|
61
|
-
}));
|
|
62
|
-
|
|
63
|
-
return result;
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
console.error('\n=== 验证码检测 ===');
|
|
67
|
-
console.error(JSON.stringify(captcha, null, 2));
|
|
68
|
-
|
|
69
|
-
if (captcha.verifyElements.length > 0 || captcha.fullScreenOverlays.length > 0 || captcha.iframes.length > 0) {
|
|
70
|
-
console.error('\n⚠️ 检测到验证码或遮罩层!');
|
|
71
|
-
} else {
|
|
72
|
-
console.error('\n✅ 未检测到验证码');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
await browser.close();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
main().catch(err => {
|
|
79
|
-
console.error('错误:', err);
|
|
80
|
-
process.exit(1);
|
|
81
|
-
});
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 测试工具:分析 TikTok view-source HTML 的三种情况
|
|
3
|
-
* 1. 正常用户(有 SSR 数据)
|
|
4
|
-
* 2. 空壳 HTML(11182 字节,无 SSR — 需要重试)
|
|
5
|
-
* 3. 异常用户(有 SSR 但 userInfo 为空,statusCode=10202 — 重试无效)
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { TikTokScraper } from "../src/lib/tiktok-scraper.mjs";
|
|
9
|
-
import fs from "fs";
|
|
10
|
-
|
|
11
|
-
const testUsers = [
|
|
12
|
-
{ id: "nike", type: "正常用户" },
|
|
13
|
-
{ id: "galb508", type: "异常用户(可能被封/删除)" },
|
|
14
|
-
{ id: "notexist_user_xxxxxx12345", type: "不存在的用户" },
|
|
15
|
-
];
|
|
16
|
-
|
|
17
|
-
async function analyzeUser(uniqueId, typeLabel) {
|
|
18
|
-
console.log(`\n${"=".repeat(60)}`);
|
|
19
|
-
console.log(`分析 @${uniqueId} (${typeLabel})`);
|
|
20
|
-
console.log("=".repeat(60));
|
|
21
|
-
|
|
22
|
-
const scraper = new TikTokScraper({ poolSize: 1 });
|
|
23
|
-
await scraper.init();
|
|
24
|
-
const slot = scraper._pickSlot();
|
|
25
|
-
|
|
26
|
-
// 多次采样
|
|
27
|
-
const samples = [];
|
|
28
|
-
for (let i = 0; i < 3; i++) {
|
|
29
|
-
const rawHtml = await scraper._fetchViewSource(
|
|
30
|
-
`https://www.tiktok.com/@${uniqueId}`,
|
|
31
|
-
slot,
|
|
32
|
-
);
|
|
33
|
-
const byteLen = Buffer.byteLength(rawHtml, "utf8");
|
|
34
|
-
const hasSSR = rawHtml.includes("__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
|
35
|
-
|
|
36
|
-
let analysis = {
|
|
37
|
-
round: i + 1,
|
|
38
|
-
size: rawHtml.length,
|
|
39
|
-
byteLen,
|
|
40
|
-
hasSSR,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// 如果有 SSR 数据,进一步分析
|
|
44
|
-
if (hasSSR) {
|
|
45
|
-
try {
|
|
46
|
-
const idx = rawHtml.indexOf("__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
|
47
|
-
const sIdx = rawHtml.indexOf(">", idx) + 1;
|
|
48
|
-
const eIdx = rawHtml.indexOf("</script>", sIdx);
|
|
49
|
-
const jsonStr = rawHtml.substring(sIdx, eIdx);
|
|
50
|
-
const data = JSON.parse(jsonStr);
|
|
51
|
-
const ud = data.__DEFAULT_SCOPE__?.["webapp.user-detail"];
|
|
52
|
-
|
|
53
|
-
analysis.scopeKeys = data.__DEFAULT_SCOPE__
|
|
54
|
-
? Object.keys(data.__DEFAULT_SCOPE__)
|
|
55
|
-
: [];
|
|
56
|
-
analysis.hasUserInfo = !!(ud && ud.userInfo);
|
|
57
|
-
analysis.statusCode = ud?.statusCode;
|
|
58
|
-
analysis.statusMsg = ud?.statusMsg;
|
|
59
|
-
analysis.needFix = ud?.needFix;
|
|
60
|
-
analysis.udKeys = ud ? Object.keys(ud) : [];
|
|
61
|
-
} catch (e) {
|
|
62
|
-
analysis.parseError = e.message;
|
|
63
|
-
}
|
|
64
|
-
} else {
|
|
65
|
-
// 空壳 HTML,检查特征
|
|
66
|
-
analysis.hasEmptyTitle = rawHtml.includes(
|
|
67
|
-
'<title data-rh="true"></title>',
|
|
68
|
-
);
|
|
69
|
-
analysis.hasEmotionStyle = rawHtml.includes('data-emotion="tiktok"');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
samples.push(analysis);
|
|
73
|
-
console.log(
|
|
74
|
-
` 第 ${i + 1} 次: ${rawHtml.length} 字符, ${byteLen} 字节, SSR: ${hasSSR ? "✓" : "✗"}`,
|
|
75
|
-
);
|
|
76
|
-
if (hasSSR && analysis.statusCode !== undefined) {
|
|
77
|
-
console.log(
|
|
78
|
-
` statusCode: ${analysis.statusCode}, hasUserInfo: ${analysis.hasUserInfo}, udKeys: [${analysis.udKeys.join(", ")}]`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 总结
|
|
84
|
-
const shellCount = samples.filter((s) => !s.hasSSR).length;
|
|
85
|
-
const hasDataCount = samples.filter((s) => s.hasUserInfo).length;
|
|
86
|
-
const statusCode10202 = samples.filter((s) => s.statusCode === 10202).length;
|
|
87
|
-
|
|
88
|
-
console.log("\n 总结:");
|
|
89
|
-
console.log(` 空壳 HTML 次数: ${shellCount}/3`);
|
|
90
|
-
console.log(` 有 userInfo 次数: ${hasDataCount}/3`);
|
|
91
|
-
console.log(` statusCode=10202 次数: ${statusCode10202}/3`);
|
|
92
|
-
|
|
93
|
-
// 判断类型
|
|
94
|
-
if (shellCount === 3) {
|
|
95
|
-
console.log(" → 判定: 持续空壳(可能是并发限流,重试可能有效)");
|
|
96
|
-
} else if (hasDataCount > 0) {
|
|
97
|
-
console.log(" → 判定: 正常用户(有完整数据)");
|
|
98
|
-
} else if (statusCode10202 > 0) {
|
|
99
|
-
console.log(" → 判定: 异常用户(statusCode=10202,重试无效)");
|
|
100
|
-
} else {
|
|
101
|
-
console.log(" → 判定: 无法确定");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
await scraper.close();
|
|
105
|
-
return samples;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function main() {
|
|
109
|
-
console.log("TikTok view-source HTML 分析工具");
|
|
110
|
-
console.log("测试三种情况的 HTML 特征差异\n");
|
|
111
|
-
|
|
112
|
-
const results = {};
|
|
113
|
-
for (const { id, type } of testUsers) {
|
|
114
|
-
results[id] = await analyzeUser(id, type);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 保存结果
|
|
118
|
-
fs.writeFileSync(
|
|
119
|
-
"./test-html-analysis-result.json",
|
|
120
|
-
JSON.stringify(results, null, 2),
|
|
121
|
-
);
|
|
122
|
-
console.log("\n\n结果已保存到 test-html-analysis-result.json");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
main().catch((err) => {
|
|
126
|
-
console.error("测试失败:", err);
|
|
127
|
-
process.exit(1);
|
|
128
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { chromium } from 'playwright';
|
|
2
|
-
import { detectCaptcha, closeCaptcha, handleCaptcha, getIncognitoPage } from '../src/scraper/modules/captcha-handler.mjs';
|
|
3
|
-
|
|
4
|
-
async function main() {
|
|
5
|
-
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
|
|
6
|
-
const url = 'https://www.tiktok.com/@mariaelenasanchez607/video/7630110959650000150';
|
|
7
|
-
|
|
8
|
-
// 测试1: 无痕模式打开 + 点击评论
|
|
9
|
-
console.error('=== 测试: 无痕模式 ===');
|
|
10
|
-
const { page, context } = await getIncognitoPage(browser, url);
|
|
11
|
-
console.error('URL:', page.url());
|
|
12
|
-
|
|
13
|
-
await page.evaluate(() => {
|
|
14
|
-
const all = document.querySelectorAll('button');
|
|
15
|
-
for (const el of all) {
|
|
16
|
-
if (/^评论$/.test(el.textContent?.trim()) && el.offsetParent !== null && el.getBoundingClientRect().width > 0) {
|
|
17
|
-
el.click();
|
|
18
|
-
break;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
23
|
-
|
|
24
|
-
const captcha = await detectCaptcha(page);
|
|
25
|
-
console.error('验证码:', captcha);
|
|
26
|
-
|
|
27
|
-
await page.screenshot({ path: '/tmp/incognito-lib-test.png' });
|
|
28
|
-
console.error('截图: /tmp/incognito-lib-test.png');
|
|
29
|
-
|
|
30
|
-
await context.close();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
main().catch(err => {
|
|
34
|
-
console.error('错误:', err);
|
|
35
|
-
process.exit(1);
|
|
36
|
-
});
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { ensureBrowserReady } from '../src/lib/browser/cdp.js';
|
|
2
|
-
|
|
3
|
-
async function main() {
|
|
4
|
-
const browser = await ensureBrowserReady();
|
|
5
|
-
const defaultContext = browser.contexts()[0];
|
|
6
|
-
const page = defaultContext.pages()[0] || await defaultContext.newPage();
|
|
7
|
-
|
|
8
|
-
// 确保在 tiktok 页面
|
|
9
|
-
if (!page.url().includes('tiktok.com')) {
|
|
10
|
-
console.error('当前不在 TikTok 页面:', page.url());
|
|
11
|
-
await page.goto('https://www.tiktok.com', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
12
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
console.error('当前 URL:', page.url());
|
|
16
|
-
|
|
17
|
-
// 1. 检测现有 isLoggedIn 的逻辑
|
|
18
|
-
const currentUserMenu = await page.evaluate(() => {
|
|
19
|
-
const selectors = [
|
|
20
|
-
'[class*="UserMenu"]',
|
|
21
|
-
'[class*="user-menu"]',
|
|
22
|
-
'[class*="CurrentUserInfo"]',
|
|
23
|
-
];
|
|
24
|
-
const results = {};
|
|
25
|
-
for (const sel of selectors) {
|
|
26
|
-
const el = document.querySelector(sel);
|
|
27
|
-
results[sel] = !!el;
|
|
28
|
-
if (el) {
|
|
29
|
-
results[sel + '_class'] = el.className;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return results;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
console.error('\n=== 当前选择器检测结果 ===');
|
|
36
|
-
console.error(JSON.stringify(currentUserMenu, null, 2));
|
|
37
|
-
|
|
38
|
-
const hasLoginButton = await page.evaluate(() => {
|
|
39
|
-
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
40
|
-
const loginButtons = buttons.filter(el =>
|
|
41
|
-
/^(登录|Log in|Sign in)$/i.test(el.textContent.trim())
|
|
42
|
-
);
|
|
43
|
-
return {
|
|
44
|
-
total: buttons.length,
|
|
45
|
-
loginCount: loginButtons.length,
|
|
46
|
-
samples: loginButtons.slice(0, 5).map(el => ({
|
|
47
|
-
text: el.textContent.trim(),
|
|
48
|
-
class: el.className,
|
|
49
|
-
})),
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
console.error('\n=== 登录按钮检测 ===');
|
|
54
|
-
console.error(JSON.stringify(hasLoginButton, null, 2));
|
|
55
|
-
|
|
56
|
-
// 2. 用更宽泛的方式找用户相关元素
|
|
57
|
-
const broadSearch = await page.evaluate(() => {
|
|
58
|
-
const allClasses = [];
|
|
59
|
-
|
|
60
|
-
// 搜索可能的用户头像/菜单元素
|
|
61
|
-
const candidates = [];
|
|
62
|
-
|
|
63
|
-
// 顶部导航区域
|
|
64
|
-
const navArea = document.querySelector('[class*="nav"], [class*="Header"], [class*="header"]');
|
|
65
|
-
if (navArea) {
|
|
66
|
-
const avatars = navArea.querySelectorAll('[class*="avatar"], [class*="Avatar"], [class*="photo"], [class*="Photo"], [class*="image"], [class*="Image"]');
|
|
67
|
-
avatars.forEach(el => {
|
|
68
|
-
candidates.push({
|
|
69
|
-
tag: el.tagName,
|
|
70
|
-
class: el.className.substring(0, 200),
|
|
71
|
-
text: el.textContent?.substring(0, 50),
|
|
72
|
-
parent: el.parentElement?.className?.substring(0, 100),
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 搜索包含用户信息的链接
|
|
78
|
-
const profileLinks = Array.from(document.querySelectorAll('a[href*="/@"]'));
|
|
79
|
-
const profileSamples = profileLinks.slice(0, 10).map(el => ({
|
|
80
|
-
href: el.href,
|
|
81
|
-
class: el.className?.substring(0, 100),
|
|
82
|
-
text: el.textContent?.substring(0, 50),
|
|
83
|
-
}));
|
|
84
|
-
|
|
85
|
-
// 搜索所有包含 User 关键字的类名
|
|
86
|
-
const userElements = Array.from(document.querySelectorAll('*')).filter(el =>
|
|
87
|
-
el.className && typeof el.className === 'string' &&
|
|
88
|
-
/User|user|Profile|profile/.test(el.className) &&
|
|
89
|
-
el.tagName !== 'STYLE' && el.tagName !== 'SCRIPT'
|
|
90
|
-
).slice(0, 30).map(el => ({
|
|
91
|
-
tag: el.tagName,
|
|
92
|
-
class: el.className.substring(0, 150),
|
|
93
|
-
text: el.textContent?.substring(0, 30),
|
|
94
|
-
}));
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
navAreaFound: !!navArea,
|
|
98
|
-
avatarCandidates: candidates,
|
|
99
|
-
profileLinks: profileSamples,
|
|
100
|
-
userElements,
|
|
101
|
-
};
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
console.error('\n=== 宽泛搜索 ===');
|
|
105
|
-
console.error(JSON.stringify(broadSearch, null, 2));
|
|
106
|
-
|
|
107
|
-
// 3. 截图
|
|
108
|
-
await page.screenshot({ path: '/tmp/login-debug.png' });
|
|
109
|
-
console.error('\n截图已保存到 /tmp/login-debug.png');
|
|
110
|
-
|
|
111
|
-
// 4. 汇总判断
|
|
112
|
-
const isLoggedIn = currentUserMenu['[class*="UserMenu"]'] ||
|
|
113
|
-
currentUserMenu['[class*="user-menu"]'] ||
|
|
114
|
-
currentUserMenu['[class*="CurrentUserInfo"]'] ||
|
|
115
|
-
broadSearch.userElements.length > 0;
|
|
116
|
-
|
|
117
|
-
console.error('\n=== 结论 ===');
|
|
118
|
-
console.error('isLoggedIn 函数返回:', !!(currentUserMenu['[class*="UserMenu"]'] || currentUserMenu['[class*="user-menu"]'] || currentUserMenu['[class*="CurrentUserInfo"]']) && !hasLoginButton.loginCount);
|
|
119
|
-
console.error('宽泛搜索是否找到用户元素:', broadSearch.userElements.length > 0);
|
|
120
|
-
console.error('找到用户相关元素数量:', broadSearch.userElements.length);
|
|
121
|
-
console.error('头像候选数量:', broadSearch.avatarCandidates.length);
|
|
122
|
-
console.error('Profile 链接数量:', broadSearch.profileLinks.length);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
main().catch(err => {
|
|
126
|
-
console.error('错误:', err);
|
|
127
|
-
process.exit(1);
|
|
128
|
-
});
|