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
package/package.json
CHANGED
package/src/cli/attach.js
CHANGED
|
@@ -244,10 +244,16 @@ export async function handleAttach(options) {
|
|
|
244
244
|
);
|
|
245
245
|
failCount++;
|
|
246
246
|
} else if (info) {
|
|
247
|
-
|
|
247
|
+
// info 可能是 { error: true, statusCode: xxx } 表示 TikTok 给了明确响应
|
|
248
|
+
if (info.error) {
|
|
249
|
+
// 有 statusCode 说明 TikTok 已给出明确响应,提交到后端记录,不算错误
|
|
250
|
+
successTasks.push({ uniqueId, info });
|
|
251
|
+
} else {
|
|
252
|
+
successTasks.push({ uniqueId, info });
|
|
253
|
+
}
|
|
248
254
|
} else {
|
|
249
|
-
attachLog(`
|
|
250
|
-
|
|
255
|
+
attachLog(` ✗ @${uniqueId} 未获取到用户信息`);
|
|
256
|
+
failCount++;
|
|
251
257
|
}
|
|
252
258
|
} else {
|
|
253
259
|
attachLog(
|
|
@@ -263,26 +269,32 @@ export async function handleAttach(options) {
|
|
|
263
269
|
const batchRet = await apiPost(`${serverUrl}/api/user-info-batch`, {
|
|
264
270
|
updates: successTasks,
|
|
265
271
|
});
|
|
266
|
-
if (batchRet && batchRet.results) {
|
|
267
|
-
const nicknameMap = {};
|
|
268
|
-
for (const { uniqueId, info } of successTasks) {
|
|
269
|
-
if (info?.nickname) {
|
|
270
|
-
nicknameMap[uniqueId] = info.nickname;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
272
|
+
if (batchRet && batchRet.results && Array.isArray(batchRet.results)) {
|
|
273
273
|
for (const r of batchRet.results) {
|
|
274
274
|
if (r.ok) {
|
|
275
275
|
successCount++;
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
// 查找对应的 info 判断是否有 statusCode
|
|
277
|
+
const task = successTasks.find(
|
|
278
|
+
(t) => t.uniqueId === r.uniqueId,
|
|
279
|
+
);
|
|
280
|
+
if (task && task.info && task.info.error) {
|
|
281
|
+
attachLog(
|
|
282
|
+
` ⚠ @${r.uniqueId} 已记录 (statusCode=${task.info.statusCode})`,
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
attachLog(` ✓ @${r.uniqueId} 已提交更新`);
|
|
286
|
+
}
|
|
278
287
|
} else {
|
|
279
288
|
failCount++;
|
|
280
289
|
attachLog(` ✗ @${r.uniqueId} 提交失败: ${r.error}`);
|
|
281
290
|
}
|
|
282
291
|
}
|
|
283
292
|
} else {
|
|
293
|
+
// 后端返回格式异常,降级处理
|
|
294
|
+
attachLog(
|
|
295
|
+
` ⚠ 后端响应格式异常 (batchRet=${JSON.stringify(batchRet).slice(0, 200)}), 降级为批量成功`,
|
|
296
|
+
);
|
|
284
297
|
successCount = successTasks.length;
|
|
285
|
-
attachLog(` ✓ 批量提交完成 (${successTasks.length} 条)`);
|
|
286
298
|
}
|
|
287
299
|
} catch (err) {
|
|
288
300
|
failCount += successTasks.length;
|
package/src/cli/info.js
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
|
-
import { TikTokScraper } from
|
|
2
|
-
import {
|
|
1
|
+
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
2
|
+
import {
|
|
3
|
+
isProfileUrl,
|
|
4
|
+
isVideoUrl,
|
|
5
|
+
extractUniqueId,
|
|
6
|
+
normalizeUsername,
|
|
7
|
+
} from "../lib/url.js";
|
|
3
8
|
|
|
4
9
|
async function handleInfo(options) {
|
|
5
10
|
const { infoUrls, infoOnlyVideo } = options;
|
|
6
11
|
|
|
7
12
|
if (!infoUrls || infoUrls.length === 0) {
|
|
8
|
-
console.error(
|
|
9
|
-
console.error(
|
|
10
|
-
console.error(
|
|
11
|
-
console.error(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
console.error(
|
|
15
|
-
console.error(
|
|
16
|
-
console.error(
|
|
17
|
-
console.error(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
console.error(
|
|
21
|
-
console.error(
|
|
22
|
-
console.error(
|
|
13
|
+
console.error("用法: tt-help info <URL> [URL2 URL3...] [--onlyvideo]");
|
|
14
|
+
console.error("");
|
|
15
|
+
console.error("参数:");
|
|
16
|
+
console.error(
|
|
17
|
+
" <URL> TikTok 主页或视频 URL,支持多个 URL 同时查询",
|
|
18
|
+
);
|
|
19
|
+
console.error(" --onlyvideo 只返回视频信息(不返回用户信息)");
|
|
20
|
+
console.error("");
|
|
21
|
+
console.error("默认行为:");
|
|
22
|
+
console.error(
|
|
23
|
+
" 主页 URL → 返回用户信息(bio、region、粉丝数等)",
|
|
24
|
+
);
|
|
25
|
+
console.error(" 视频 URL → 返回用户信息 + 视频信息");
|
|
26
|
+
console.error(" 视频 URL + --onlyvideo → 只返回视频信息");
|
|
27
|
+
console.error("");
|
|
28
|
+
console.error("示例:");
|
|
29
|
+
console.error(" tt-help info https://www.tiktok.com/@nike");
|
|
30
|
+
console.error(
|
|
31
|
+
" tt-help info https://www.tiktok.com/@nike/video/7234567890",
|
|
32
|
+
);
|
|
33
|
+
console.error(
|
|
34
|
+
" tt-help info https://www.tiktok.com/@nike https://www.tiktok.com/@apple",
|
|
35
|
+
);
|
|
23
36
|
process.exit(1);
|
|
24
37
|
}
|
|
25
38
|
|
|
@@ -34,28 +47,35 @@ async function handleInfo(options) {
|
|
|
34
47
|
const uniqueId = extractUniqueId(url);
|
|
35
48
|
const normalized = normalizeUsername(uniqueId);
|
|
36
49
|
const user = await scraper.getUserInfo(normalized);
|
|
37
|
-
if (user) {
|
|
38
|
-
|
|
39
|
-
console.error(
|
|
50
|
+
if (!user || user.error) {
|
|
51
|
+
const code = user?.statusCode;
|
|
52
|
+
console.error(
|
|
53
|
+
`无法获取用户 @${uniqueId} 的信息${code !== undefined ? ` (statusCode=${code})` : ""}`,
|
|
54
|
+
);
|
|
55
|
+
continue;
|
|
40
56
|
}
|
|
57
|
+
result[normalized] = { user };
|
|
58
|
+
console.error(`用户: @${user.uniqueId} (${user.nickname})`);
|
|
41
59
|
} else if (isVideoUrl(url)) {
|
|
42
60
|
const uniqueId = extractUniqueId(url);
|
|
43
61
|
const normalized = normalizeUsername(uniqueId);
|
|
44
62
|
|
|
45
63
|
if (infoOnlyVideo) {
|
|
46
64
|
const video = await scraper.getVideoInfo(url);
|
|
47
|
-
if (video) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
console.error(`视频: ${video.id}`);
|
|
65
|
+
if (!video) {
|
|
66
|
+
console.error(`无法获取视频信息: ${url}`);
|
|
67
|
+
continue;
|
|
51
68
|
}
|
|
69
|
+
const key = normalized + "/video/" + video.id;
|
|
70
|
+
result[key] = { video };
|
|
71
|
+
console.error(`视频: ${video.id}`);
|
|
52
72
|
} else {
|
|
53
73
|
const [user, video] = await Promise.all([
|
|
54
74
|
scraper.getUserInfo(normalized),
|
|
55
75
|
scraper.getVideoInfo(url),
|
|
56
76
|
]);
|
|
57
77
|
const entry = {};
|
|
58
|
-
if (user) {
|
|
78
|
+
if (user && !user.error) {
|
|
59
79
|
entry.user = user;
|
|
60
80
|
console.error(`用户: @${user.uniqueId} (${user.nickname})`);
|
|
61
81
|
}
|
|
@@ -63,10 +83,12 @@ async function handleInfo(options) {
|
|
|
63
83
|
entry.video = video;
|
|
64
84
|
console.error(`视频: ${video.id}`);
|
|
65
85
|
}
|
|
66
|
-
if (user || video) {
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
if ((!user || user.error) && !video) {
|
|
87
|
+
console.error(`无法获取信息: ${url}`);
|
|
88
|
+
continue;
|
|
69
89
|
}
|
|
90
|
+
const key = normalized + "/video/" + (video ? video.id : "unknown");
|
|
91
|
+
result[key] = entry;
|
|
70
92
|
}
|
|
71
93
|
} else {
|
|
72
94
|
console.error(`无法识别 URL: ${url}`);
|
package/src/cli/open.js
CHANGED
|
@@ -33,13 +33,14 @@ export async function handleOpen(parsed) {
|
|
|
33
33
|
if (!openPort) {
|
|
34
34
|
console.error("用法: tt-help open <端口>");
|
|
35
35
|
console.error("示例: tt-help open 9222");
|
|
36
|
-
console.error(
|
|
36
|
+
console.error("");
|
|
37
|
+
console.error('运行 "tt-help open --list" 查看内置浏览器配置');
|
|
37
38
|
process.exit(1);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const port = parseInt(openPort);
|
|
41
42
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
42
|
-
console.error(`端口 ${openPort}
|
|
43
|
+
console.error(`端口 ${openPort} 无效 (请使用 1 - 65535)`);
|
|
43
44
|
process.exit(1);
|
|
44
45
|
}
|
|
45
46
|
|
package/src/lib/parse-ssr.mjs
CHANGED
|
@@ -1,20 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 判断失败是否可重试
|
|
3
|
+
* - 有 statusCode(无论值是多少):TikTok 给了明确响应,不可重试
|
|
4
|
+
* - 没有 statusCode(空壳 HTML,无 SSR):并发限流,可重试
|
|
5
|
+
*/
|
|
6
|
+
export function isRetryableFailure(rawHtml) {
|
|
7
|
+
if (!rawHtml || typeof rawHtml !== "string") return false;
|
|
8
|
+
// 没有 SSR 标记 = 空壳 HTML = 可重试
|
|
9
|
+
if (!rawHtml.includes("__UNIVERSAL_DATA_FOR_REHYDRATION__")) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
// 有 SSR 数据,检查是否有 statusCode
|
|
13
|
+
try {
|
|
14
|
+
const idx = rawHtml.indexOf("__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
|
15
|
+
const sIdx = rawHtml.indexOf(">", idx) + 1;
|
|
16
|
+
const eIdx = rawHtml.indexOf("</script>", sIdx);
|
|
17
|
+
if (sIdx < 0 || eIdx < 0) return true;
|
|
18
|
+
const data = JSON.parse(rawHtml.substring(sIdx, eIdx));
|
|
19
|
+
const ud = data.__DEFAULT_SCOPE__?.["webapp.user-detail"];
|
|
20
|
+
// 有 statusCode 说明 TikTok 给了明确响应(0=成功,10202=被封,10221=不存在等),不可重试
|
|
21
|
+
if (ud && "statusCode" in ud) return false;
|
|
22
|
+
} catch {}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
1
26
|
function parseSSR(rawHtml) {
|
|
2
|
-
if (!rawHtml
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
27
|
+
if (!rawHtml || typeof rawHtml !== "string") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (!rawHtml.includes("__UNIVERSAL_DATA_FOR_REHYDRATION__")) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const dataStart = rawHtml.indexOf("__UNIVERSAL_DATA_FOR_REHYDRATION__");
|
|
34
|
+
const scriptStart = rawHtml.lastIndexOf("<script", dataStart);
|
|
35
|
+
const sIdx =
|
|
36
|
+
(scriptStart >= 0
|
|
37
|
+
? rawHtml.indexOf(">", scriptStart)
|
|
38
|
+
: rawHtml.indexOf(">", dataStart)) + 1;
|
|
39
|
+
const eIdx = rawHtml.indexOf("</script>", sIdx);
|
|
40
|
+
if (sIdx < 0 || eIdx < 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
9
43
|
const jsonStr = rawHtml.substring(sIdx, eIdx);
|
|
10
|
-
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(jsonStr);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
11
49
|
}
|
|
12
50
|
|
|
13
51
|
export function parseUserInfo(rawHtml) {
|
|
14
52
|
const data = parseSSR(rawHtml);
|
|
15
53
|
if (!data) return null;
|
|
16
|
-
const
|
|
17
|
-
|
|
54
|
+
const scopeKeys = data.__DEFAULT_SCOPE__
|
|
55
|
+
? Object.keys(data.__DEFAULT_SCOPE__)
|
|
56
|
+
: [];
|
|
57
|
+
const ud =
|
|
58
|
+
data.__DEFAULT_SCOPE__ && data.__DEFAULT_SCOPE__["webapp.user-detail"];
|
|
59
|
+
if (!ud || !ud.userInfo) {
|
|
60
|
+
const code = ud?.statusCode;
|
|
61
|
+
return { error: true, statusCode: code };
|
|
62
|
+
}
|
|
18
63
|
const u = ud.userInfo.user;
|
|
19
64
|
const s = ud.userInfo.stats;
|
|
20
65
|
return {
|
|
@@ -24,8 +69,8 @@ export function parseUserInfo(rawHtml) {
|
|
|
24
69
|
verified: u.verified,
|
|
25
70
|
privateAccount: u.privateAccount,
|
|
26
71
|
language: u.language,
|
|
27
|
-
bio: u.signature ||
|
|
28
|
-
avatar: u.avatarLarger || u.avatarMedium || u.avatarThumb ||
|
|
72
|
+
bio: u.signature || "",
|
|
73
|
+
avatar: u.avatarLarger || u.avatarMedium || u.avatarThumb || "",
|
|
29
74
|
followerCount: s.followerCount,
|
|
30
75
|
followingCount: s.followingCount,
|
|
31
76
|
heartCount: s.heartCount,
|
|
@@ -35,20 +80,30 @@ export function parseUserInfo(rawHtml) {
|
|
|
35
80
|
secUid: u.secUid,
|
|
36
81
|
ttSeller: u.ttSeller || false,
|
|
37
82
|
locationCreated: u.locationCreated || null,
|
|
83
|
+
statusCode: 0,
|
|
38
84
|
};
|
|
39
85
|
}
|
|
40
86
|
|
|
41
87
|
export function parseVideoInfo(rawHtml) {
|
|
42
88
|
const data = parseSSR(rawHtml);
|
|
43
89
|
if (!data) return null;
|
|
44
|
-
const
|
|
45
|
-
|
|
90
|
+
const scopeKeys = data.__DEFAULT_SCOPE__
|
|
91
|
+
? Object.keys(data.__DEFAULT_SCOPE__)
|
|
92
|
+
: [];
|
|
93
|
+
const vd =
|
|
94
|
+
data.__DEFAULT_SCOPE__ && data.__DEFAULT_SCOPE__["webapp.video-detail"];
|
|
95
|
+
if (!vd || !vd.itemInfo || !vd.itemInfo.itemStruct) {
|
|
96
|
+
console.error(
|
|
97
|
+
`[parseVideoInfo] webapp.video-detail 不存在, scope keys: ${JSON.stringify(scopeKeys)}`,
|
|
98
|
+
);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
46
101
|
const item = vd.itemInfo.itemStruct;
|
|
47
102
|
const author = item.author || {};
|
|
48
103
|
const stats = item.stats || {};
|
|
49
104
|
return {
|
|
50
105
|
id: item.id,
|
|
51
|
-
desc: item.desc ||
|
|
106
|
+
desc: item.desc || "",
|
|
52
107
|
createTime: item.createTime || null,
|
|
53
108
|
locationCreated: item.locationCreated || null,
|
|
54
109
|
author: {
|
package/src/lib/scrape.js
CHANGED
|
@@ -57,7 +57,7 @@ export async function extractUserData(url) {
|
|
|
57
57
|
const uniqueId = extractUniqueId(url);
|
|
58
58
|
if (!uniqueId) throw new Error(`无法从URL提取用户名: ${url}`);
|
|
59
59
|
const user = await scraper.getUserInfo(normalizeUsername(uniqueId));
|
|
60
|
-
if (!user)
|
|
60
|
+
if (!user) throw new Error('无法解析用户信息');
|
|
61
61
|
return mapUserInfo(user);
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -70,7 +70,7 @@ export async function extractVideoLocation(videoUrl) {
|
|
|
70
70
|
export async function processUrl(url) {
|
|
71
71
|
if (isProfileUrl(url)) {
|
|
72
72
|
const profileData = await extractUserData(url);
|
|
73
|
-
return
|
|
73
|
+
return [profileData];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
if (isVideoUrl(url)) {
|
|
@@ -82,7 +82,6 @@ export async function processUrl(url) {
|
|
|
82
82
|
extractVideoLocation(url),
|
|
83
83
|
]);
|
|
84
84
|
|
|
85
|
-
if (!profileData) return [];
|
|
86
85
|
return [{ ...profileData, locationCreated }];
|
|
87
86
|
}
|
|
88
87
|
|