tt-help-cli-ycl 1.3.35 → 1.3.37
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/README.md +17 -1
- package/cli.js +3 -3
- package/package.json +7 -2
- package/scripts/test-watch-db-smoke.mjs +246 -0
- package/src/cli/attach.js +149 -89
- package/src/cli/auto.js +180 -155
- package/src/cli/comments.js +301 -210
- package/src/cli/config.js +62 -44
- package/src/cli/db-import.js +51 -0
- package/src/cli/explore.js +373 -342
- package/src/cli/refresh.js +223 -151
- package/src/cli/videostats.js +140 -25
- package/src/cli/watch.js +15 -16
- package/src/lib/args.js +50 -6
- package/src/lib/constants.js +159 -92
- package/src/lib/tiktok-scraper.mjs +59 -21
- package/src/main.js +42 -20
- package/src/npm-main.js +69 -0
- package/src/watch/data-store.js +1560 -236
- package/src/watch/public/index.html +51 -15
- package/src/watch/server.js +63 -374
package/src/cli/refresh.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
getOrCreatePage,
|
|
3
|
+
isBrowserClosedError,
|
|
4
|
+
relaunchBrowser,
|
|
5
|
+
} from "../lib/browser/page.js";
|
|
6
|
+
import { delay, setDelayConfig } from "../scraper/modules/page-helpers.js";
|
|
7
|
+
import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
|
|
8
|
+
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
9
|
+
import { ensureBrowserReady as ensureBrowserReadyCDP } from "../lib/browser/cdp.js";
|
|
10
|
+
import { processRefresh } from "../scraper/refresh-core.js";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import os from "os";
|
|
9
13
|
|
|
10
14
|
async function withRetry(fn, maxRetries = 5) {
|
|
11
15
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
@@ -13,12 +17,15 @@ async function withRetry(fn, maxRetries = 5) {
|
|
|
13
17
|
return await fn();
|
|
14
18
|
} catch (e) {
|
|
15
19
|
if (attempt < maxRetries) {
|
|
16
|
-
const waitTime =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const waitTime =
|
|
21
|
+
attempt <= 2
|
|
22
|
+
? 5000 + Math.random() * 5000
|
|
23
|
+
: attempt <= 4
|
|
24
|
+
? 10000 + Math.random() * 10000
|
|
25
|
+
: 20000 + Math.random() * 10000;
|
|
26
|
+
console.error(
|
|
27
|
+
` [网络] 请求失败 (${attempt}/${maxRetries}),${Math.round(waitTime / 1000)}s 后重试...`,
|
|
28
|
+
);
|
|
22
29
|
await delay(waitTime / 1000, waitTime / 1000);
|
|
23
30
|
} else {
|
|
24
31
|
throw e;
|
|
@@ -35,8 +42,8 @@ async function apiGet(url) {
|
|
|
35
42
|
|
|
36
43
|
async function apiPost(url, body) {
|
|
37
44
|
const resp = await fetch(url, {
|
|
38
|
-
method:
|
|
39
|
-
headers: {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
40
47
|
body: JSON.stringify(body),
|
|
41
48
|
});
|
|
42
49
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
@@ -46,171 +53,236 @@ async function apiPost(url, body) {
|
|
|
46
53
|
export async function handleRefresh(options) {
|
|
47
54
|
const {
|
|
48
55
|
explorePreset,
|
|
49
|
-
explorePort,
|
|
56
|
+
explorePort,
|
|
57
|
+
exploreProfile,
|
|
58
|
+
exploreUserId,
|
|
59
|
+
serverUrl,
|
|
50
60
|
} = options;
|
|
61
|
+
let browser = null;
|
|
62
|
+
let shuttingDown = false;
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const shutdown = async (signal) => {
|
|
65
|
+
if (shuttingDown) return;
|
|
66
|
+
shuttingDown = true;
|
|
67
|
+
console.error(`\n[Refresh] 收到 ${signal},正在关闭浏览器...`);
|
|
68
|
+
await browser?.close().catch(() => {});
|
|
69
|
+
console.error("[Refresh] 已退出");
|
|
70
|
+
process.exit(0);
|
|
71
|
+
};
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
const onSigint = () => {
|
|
74
|
+
void shutdown("SIGINT");
|
|
75
|
+
};
|
|
76
|
+
const onSigterm = () => {
|
|
77
|
+
void shutdown("SIGTERM");
|
|
78
|
+
};
|
|
67
79
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (exploreProfile) {
|
|
71
|
-
cdpOptions.userDataDir = path.join(os.homedir(), 'Library', 'Application Support', `Microsoft Edge For Testing_${exploreProfile}`);
|
|
72
|
-
}
|
|
80
|
+
process.once("SIGINT", onSigint);
|
|
81
|
+
process.once("SIGTERM", onSigterm);
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
try {
|
|
84
|
+
let userId = exploreUserId || configuredUserId;
|
|
85
|
+
if (!userId) {
|
|
86
|
+
userId = await getMacOrUuid();
|
|
87
|
+
saveUserId(userId);
|
|
88
|
+
console.error(`[初始化] 未检测到本地用户编号,已生成并使用: ${userId}`);
|
|
89
|
+
}
|
|
76
90
|
|
|
77
|
-
|
|
78
|
-
let errorCount = 0;
|
|
79
|
-
let consecutiveNetworkErrors = 0;
|
|
91
|
+
setDelayConfig(explorePreset);
|
|
80
92
|
|
|
81
|
-
|
|
93
|
+
console.error(`\n=== Refresh 模式 ===`);
|
|
94
|
+
console.error(`服务器: ${serverUrl}`);
|
|
95
|
+
console.error(`CDP 端口: ${explorePort || 9222}, 用户编号: ${userId}`);
|
|
96
|
+
if (exploreProfile) console.error(`浏览器配置: ${exploreProfile}`);
|
|
97
|
+
console.error(`刷新: 视频 100 + 关注 100 + 粉丝 100`);
|
|
98
|
+
console.error(`新用户探索: 评论 + 猜你喜欢 + 关注/粉丝`);
|
|
82
99
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
const cdpOptions = {};
|
|
101
|
+
if (explorePort) cdpOptions.port = explorePort;
|
|
102
|
+
if (exploreProfile) {
|
|
103
|
+
cdpOptions.userDataDir = path.join(
|
|
104
|
+
os.homedir(),
|
|
105
|
+
"Library",
|
|
106
|
+
"Application Support",
|
|
107
|
+
`Microsoft Edge For Testing_${exploreProfile}`,
|
|
87
108
|
);
|
|
109
|
+
}
|
|
88
110
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await delay(30000, 30000);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
111
|
+
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
112
|
+
const page = await getOrCreatePage(browser);
|
|
94
113
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
let processedCount = 0;
|
|
115
|
+
let errorCount = 0;
|
|
116
|
+
let consecutiveNetworkErrors = 0;
|
|
98
117
|
|
|
99
|
-
|
|
118
|
+
console.error(`\n开始循环刷新任务...\n`);
|
|
100
119
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
while (!shuttingDown) {
|
|
121
|
+
try {
|
|
122
|
+
const jobData = await withRetry(() =>
|
|
123
|
+
apiGet(`${serverUrl}/api/redo-job?userId=${userId}`),
|
|
124
|
+
);
|
|
106
125
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
if (!jobData.hasJob) {
|
|
127
|
+
console.error(`\n[空闲] 暂无 redo 任务,30s 后重试...`);
|
|
128
|
+
await delay(30000, 30000);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { uniqueId, nickname } = jobData.user;
|
|
133
|
+
consecutiveNetworkErrors = 0;
|
|
134
|
+
processedCount++;
|
|
135
|
+
|
|
136
|
+
console.error(
|
|
137
|
+
`\n[${processedCount}] 刷新 @${uniqueId} (${nickname || "未知"})...`,
|
|
138
|
+
);
|
|
115
139
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const newPage = await getOrCreatePage(browser);
|
|
122
|
-
Object.assign(page, newPage);
|
|
123
|
-
// 重试当前用户
|
|
124
|
-
const retryResult = await processRefresh(page, uniqueId, serverUrl, {
|
|
140
|
+
const result = await processRefresh(
|
|
141
|
+
page,
|
|
142
|
+
uniqueId,
|
|
143
|
+
serverUrl,
|
|
144
|
+
{
|
|
125
145
|
maxFollowing: 100,
|
|
126
146
|
maxFollowers: 100,
|
|
127
147
|
maxVideos: 100,
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
} else {
|
|
132
|
-
consecutiveNetworkErrors++;
|
|
133
|
-
errorCount++;
|
|
134
|
-
console.error(` [错误] ${result.error}`);
|
|
135
|
-
|
|
136
|
-
if (consecutiveNetworkErrors >= 3) {
|
|
137
|
-
console.error(` [警告] 连续 ${consecutiveNetworkErrors} 次错误,等待 60s 后重试...`);
|
|
138
|
-
await delay(60000, 60000);
|
|
139
|
-
consecutiveNetworkErrors = 0;
|
|
140
|
-
}
|
|
148
|
+
},
|
|
149
|
+
console.error,
|
|
150
|
+
);
|
|
141
151
|
|
|
152
|
+
if (result.restricted) {
|
|
153
|
+
console.error(` @${uniqueId} 页面受限,跳过`);
|
|
142
154
|
await apiPost(`${serverUrl}/api/redo-job/${uniqueId}`, {
|
|
143
|
-
|
|
155
|
+
restricted: true,
|
|
144
156
|
userInfo: result.userInfo || {},
|
|
145
157
|
});
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (result.error) {
|
|
162
|
+
// 浏览器关闭检测
|
|
163
|
+
if (isBrowserClosedError(new Error(result.error))) {
|
|
164
|
+
const newBrowser = await relaunchBrowser(
|
|
165
|
+
cdpOptions,
|
|
166
|
+
explorePort || 9222,
|
|
167
|
+
);
|
|
168
|
+
browser = newBrowser;
|
|
169
|
+
const newPage = await getOrCreatePage(browser);
|
|
170
|
+
Object.assign(page, newPage);
|
|
171
|
+
// 重试当前用户
|
|
172
|
+
const retryResult = await processRefresh(
|
|
173
|
+
page,
|
|
174
|
+
uniqueId,
|
|
175
|
+
serverUrl,
|
|
176
|
+
{
|
|
177
|
+
maxFollowing: 100,
|
|
178
|
+
maxFollowers: 100,
|
|
179
|
+
maxVideos: 100,
|
|
180
|
+
},
|
|
181
|
+
console.error,
|
|
182
|
+
);
|
|
183
|
+
Object.assign(result, retryResult);
|
|
184
|
+
// 继续下方逻辑,检查重试后的 result
|
|
185
|
+
} else {
|
|
186
|
+
consecutiveNetworkErrors++;
|
|
187
|
+
errorCount++;
|
|
188
|
+
console.error(` [错误] ${result.error}`);
|
|
189
|
+
|
|
190
|
+
if (consecutiveNetworkErrors >= 3) {
|
|
191
|
+
console.error(
|
|
192
|
+
` [警告] 连续 ${consecutiveNetworkErrors} 次错误,等待 60s 后重试...`,
|
|
193
|
+
);
|
|
194
|
+
await delay(60000, 60000);
|
|
195
|
+
consecutiveNetworkErrors = 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await apiPost(`${serverUrl}/api/redo-job/${uniqueId}`, {
|
|
199
|
+
error: result.error,
|
|
200
|
+
userInfo: result.userInfo || {},
|
|
201
|
+
});
|
|
202
|
+
const errorType =
|
|
203
|
+
consecutiveNetworkErrors > 1 ? "network" : "other";
|
|
204
|
+
apiPost(`${serverUrl}/api/error-report`, {
|
|
205
|
+
userId,
|
|
206
|
+
username: uniqueId,
|
|
207
|
+
errorType,
|
|
208
|
+
errorMessage: result.error,
|
|
209
|
+
stage: "process",
|
|
210
|
+
errorStack: result.errorStack || "",
|
|
211
|
+
}).catch(() => {});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (result.captchaDetected) {
|
|
217
|
+
await apiPost(`${serverUrl}/api/error-report`, {
|
|
148
218
|
userId,
|
|
149
219
|
username: uniqueId,
|
|
150
|
-
errorType,
|
|
151
|
-
errorMessage: result.
|
|
152
|
-
stage:
|
|
153
|
-
errorStack:
|
|
154
|
-
})
|
|
155
|
-
continue;
|
|
220
|
+
errorType: "captcha",
|
|
221
|
+
errorMessage: result.captchaMessage || "页面出现验证码",
|
|
222
|
+
stage: result.captchaStage || "video-page",
|
|
223
|
+
errorStack: "",
|
|
224
|
+
});
|
|
156
225
|
}
|
|
157
|
-
}
|
|
158
226
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
227
|
+
consecutiveNetworkErrors = 0;
|
|
228
|
+
|
|
229
|
+
const guessedLocation = result.locationCreated || null;
|
|
230
|
+
|
|
231
|
+
await apiPost(`${serverUrl}/api/redo-job/${uniqueId}`, {
|
|
232
|
+
userInfo: result.userInfo || {},
|
|
233
|
+
discoveredVideoAuthors: (result.discoveredVideoAuthors || []).map(
|
|
234
|
+
(item) =>
|
|
235
|
+
typeof item === "object" ? { ...item, guessedLocation } : item,
|
|
236
|
+
),
|
|
237
|
+
discoveredCommentAuthors: (result.discoveredCommentAuthors || []).map(
|
|
238
|
+
(author) => ({ author, guessedLocation }),
|
|
239
|
+
),
|
|
240
|
+
discoveredGuessAuthors: (result.discoveredGuessAuthors || []).map(
|
|
241
|
+
(author) => ({ author, guessedLocation }),
|
|
242
|
+
),
|
|
243
|
+
discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
|
|
244
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
245
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
246
|
+
guessedLocation,
|
|
247
|
+
})),
|
|
248
|
+
discoveredFollowers: (result.discoveredFollowers || []).map((f) => ({
|
|
249
|
+
handle: Array.isArray(f) ? f[0] : f,
|
|
250
|
+
displayName: Array.isArray(f) ? f[1] : null,
|
|
251
|
+
guessedLocation,
|
|
252
|
+
})),
|
|
253
|
+
newUsersAdded: result.newUsersAdded || 0,
|
|
254
|
+
collectedVideos: result.collectedVideos || 0,
|
|
167
255
|
});
|
|
168
|
-
}
|
|
169
256
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
await apiPost(`${serverUrl}/api/redo-job/${uniqueId}`, {
|
|
175
|
-
userInfo: result.userInfo || {},
|
|
176
|
-
discoveredVideoAuthors: (result.discoveredVideoAuthors || []).map(item =>
|
|
177
|
-
typeof item === 'object' ? { ...item, guessedLocation } : item
|
|
178
|
-
),
|
|
179
|
-
discoveredCommentAuthors: (result.discoveredCommentAuthors || []).map(author => ({ author, guessedLocation })),
|
|
180
|
-
discoveredGuessAuthors: (result.discoveredGuessAuthors || []).map(author => ({ author, guessedLocation })),
|
|
181
|
-
discoveredFollowing: (result.discoveredFollowing || []).map(f => ({
|
|
182
|
-
handle: Array.isArray(f) ? f[0] : f,
|
|
183
|
-
displayName: Array.isArray(f) ? f[1] : null,
|
|
184
|
-
guessedLocation,
|
|
185
|
-
})),
|
|
186
|
-
discoveredFollowers: (result.discoveredFollowers || []).map(f => ({
|
|
187
|
-
handle: Array.isArray(f) ? f[0] : f,
|
|
188
|
-
displayName: Array.isArray(f) ? f[1] : null,
|
|
189
|
-
guessedLocation,
|
|
190
|
-
})),
|
|
191
|
-
newUsersAdded: result.newUsersAdded || 0,
|
|
192
|
-
collectedVideos: result.collectedVideos || 0,
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
console.error(` [完成] 视频: ${result.collectedVideos}, 评论作者: ${result.discoveredCommentAuthors?.length || 0}, 关注: ${result.discoveredFollowing?.length || 0}, 粉丝: ${result.discoveredFollowers?.length || 0}, 新增用户: ${result.newUsersAdded}`);
|
|
196
|
-
|
|
197
|
-
await delay(3000, 5000);
|
|
198
|
-
} catch (e) {
|
|
199
|
-
consecutiveNetworkErrors++;
|
|
200
|
-
errorCount++;
|
|
201
|
-
console.error(`\n[错误] ${e.message}`);
|
|
257
|
+
console.error(
|
|
258
|
+
` [完成] 视频: ${result.collectedVideos}, 评论作者: ${result.discoveredCommentAuthors?.length || 0}, 关注: ${result.discoveredFollowing?.length || 0}, 粉丝: ${result.discoveredFollowers?.length || 0}, 新增用户: ${result.newUsersAdded}`,
|
|
259
|
+
);
|
|
202
260
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
261
|
+
await delay(3000, 5000);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
consecutiveNetworkErrors++;
|
|
264
|
+
errorCount++;
|
|
265
|
+
console.error(`\n[错误] ${e.message}`);
|
|
266
|
+
|
|
267
|
+
if (consecutiveNetworkErrors >= 3) {
|
|
268
|
+
console.error(
|
|
269
|
+
`[警告] 连续 ${consecutiveNetworkErrors} 次网络异常,等待 60s 后重试...`,
|
|
270
|
+
);
|
|
271
|
+
await delay(60000, 60000);
|
|
272
|
+
consecutiveNetworkErrors = 0;
|
|
273
|
+
} else {
|
|
274
|
+
const waitTime =
|
|
275
|
+
consecutiveNetworkErrors <= 2
|
|
276
|
+
? 5000 + Math.random() * 5000
|
|
277
|
+
: 10000 + Math.random() * 10000;
|
|
278
|
+
console.error(` 等待 ${Math.round(waitTime / 1000)}s 后重试...`);
|
|
279
|
+
await delay(waitTime / 1000, waitTime / 1000);
|
|
280
|
+
}
|
|
213
281
|
}
|
|
214
282
|
}
|
|
283
|
+
} finally {
|
|
284
|
+
process.removeListener("SIGINT", onSigint);
|
|
285
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
286
|
+
await browser?.close().catch(() => {});
|
|
215
287
|
}
|
|
216
288
|
}
|
package/src/cli/videostats.js
CHANGED
|
@@ -1,33 +1,144 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import path from "path";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
4
|
+
|
|
5
|
+
function resolveVideoSource(inputPath) {
|
|
6
|
+
if (!inputPath) return null;
|
|
7
|
+
|
|
8
|
+
const resolved = path.resolve(inputPath);
|
|
9
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
10
|
+
|
|
11
|
+
if (ext === ".db") {
|
|
12
|
+
return { kind: "db", dbPath: resolved, label: resolved };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ensureVideoStatsColumns(db) {
|
|
19
|
+
const existing = new Set(
|
|
20
|
+
db
|
|
21
|
+
.prepare("PRAGMA table_info(videos)")
|
|
22
|
+
.all()
|
|
23
|
+
.map((column) => column.name),
|
|
24
|
+
);
|
|
25
|
+
const required = {
|
|
26
|
+
play_count: "INTEGER",
|
|
27
|
+
digg_count: "INTEGER",
|
|
28
|
+
comment_count: "INTEGER",
|
|
29
|
+
share_count: "INTEGER",
|
|
30
|
+
collect_count: "INTEGER",
|
|
31
|
+
stats_updated_at: "INTEGER",
|
|
32
|
+
};
|
|
33
|
+
for (const [column, type] of Object.entries(required)) {
|
|
34
|
+
if (!existing.has(column)) {
|
|
35
|
+
db.exec(`ALTER TABLE videos ADD COLUMN ${column} ${type}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadVideosFromDb(dbPath) {
|
|
41
|
+
const db = new Database(dbPath);
|
|
42
|
+
ensureVideoStatsColumns(db);
|
|
43
|
+
const rows = db
|
|
44
|
+
.prepare("SELECT * FROM videos ORDER BY registered_at ASC")
|
|
45
|
+
.all();
|
|
46
|
+
const videos = rows.map((row) => ({
|
|
47
|
+
id: row.id,
|
|
48
|
+
href: row.href,
|
|
49
|
+
authorUniqueId: row.author_unique_id,
|
|
50
|
+
locationCreated: row.location_created,
|
|
51
|
+
ttSeller: !!row.tt_seller,
|
|
52
|
+
registeredAt: row.registered_at,
|
|
53
|
+
userUpdateCount: row.user_update_count,
|
|
54
|
+
stats:
|
|
55
|
+
row.play_count === null &&
|
|
56
|
+
row.digg_count === null &&
|
|
57
|
+
row.comment_count === null &&
|
|
58
|
+
row.share_count === null &&
|
|
59
|
+
row.collect_count === null
|
|
60
|
+
? undefined
|
|
61
|
+
: {
|
|
62
|
+
playCount: row.play_count,
|
|
63
|
+
diggCount: row.digg_count,
|
|
64
|
+
commentCount: row.comment_count,
|
|
65
|
+
shareCount: row.share_count,
|
|
66
|
+
collectCount: row.collect_count,
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
return { db, videos };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeVideosToDb(db, videos) {
|
|
73
|
+
const stmt = db.prepare(`
|
|
74
|
+
UPDATE videos
|
|
75
|
+
SET play_count = ?,
|
|
76
|
+
digg_count = ?,
|
|
77
|
+
comment_count = ?,
|
|
78
|
+
share_count = ?,
|
|
79
|
+
collect_count = ?,
|
|
80
|
+
stats_updated_at = ?
|
|
81
|
+
WHERE id = ?
|
|
82
|
+
`);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const txn = db.transaction((items) => {
|
|
85
|
+
for (const video of items) {
|
|
86
|
+
if (!video.stats) continue;
|
|
87
|
+
stmt.run(
|
|
88
|
+
video.stats.playCount ?? null,
|
|
89
|
+
video.stats.diggCount ?? null,
|
|
90
|
+
video.stats.commentCount ?? null,
|
|
91
|
+
video.stats.shareCount ?? null,
|
|
92
|
+
video.stats.collectCount ?? null,
|
|
93
|
+
now,
|
|
94
|
+
video.id,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
txn(videos);
|
|
99
|
+
}
|
|
3
100
|
|
|
4
101
|
export async function handleVideoStats(options) {
|
|
5
|
-
const
|
|
102
|
+
const statsSource = options.statsSource || options.statsFile;
|
|
103
|
+
const { statsParallel } = options;
|
|
6
104
|
|
|
7
|
-
if (!
|
|
8
|
-
console.error(
|
|
9
|
-
console.error(
|
|
10
|
-
console.error(
|
|
11
|
-
console.error(
|
|
105
|
+
if (!statsSource) {
|
|
106
|
+
console.error("用法: tt-help videostats <db路径> -p <并发数>");
|
|
107
|
+
console.error(" tt-help videostats data/result.db -p 3");
|
|
108
|
+
console.error("");
|
|
109
|
+
console.error("选项: -p, --parallel <N> 并发数(默认: 3)");
|
|
12
110
|
process.exit(1);
|
|
13
111
|
}
|
|
14
112
|
|
|
15
|
-
|
|
16
|
-
|
|
113
|
+
const source = resolveVideoSource(statsSource);
|
|
114
|
+
if (!source) {
|
|
115
|
+
console.error("[videostats] 仅支持 .db 路径");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let db = null;
|
|
120
|
+
let videos = [];
|
|
121
|
+
({ db, videos } = loadVideosFromDb(source.dbPath));
|
|
122
|
+
|
|
17
123
|
if (!Array.isArray(videos) || videos.length === 0) {
|
|
18
|
-
console.error(
|
|
124
|
+
console.error("[videostats] 视频源为空或格式错误");
|
|
125
|
+
db?.close();
|
|
19
126
|
process.exit(1);
|
|
20
127
|
}
|
|
21
128
|
|
|
22
129
|
const poolSize = Math.min(statsParallel, videos.length);
|
|
23
|
-
console.error(
|
|
130
|
+
console.error(
|
|
131
|
+
`[videostats] 读取 ${source.label}: ${videos.length} 个视频,并发: ${poolSize}`,
|
|
132
|
+
);
|
|
24
133
|
|
|
25
134
|
// 初始化 scraper
|
|
26
135
|
const scraper = new TikTokScraper({ poolSize });
|
|
27
136
|
await scraper.init();
|
|
28
|
-
console.error(
|
|
137
|
+
console.error("[videostats] 浏览器初始化完成,开始刷新...");
|
|
29
138
|
|
|
30
|
-
let success = 0,
|
|
139
|
+
let success = 0,
|
|
140
|
+
failed = 0,
|
|
141
|
+
skipped = 0;
|
|
31
142
|
|
|
32
143
|
try {
|
|
33
144
|
for (let i = 0; i < videos.length; i += poolSize) {
|
|
@@ -35,27 +146,29 @@ export async function handleVideoStats(options) {
|
|
|
35
146
|
|
|
36
147
|
const results = await Promise.allSettled(
|
|
37
148
|
batch.map(async (v) => {
|
|
38
|
-
if (!v.href) return { video: v, error:
|
|
149
|
+
if (!v.href) return { video: v, error: "no href" };
|
|
39
150
|
try {
|
|
40
151
|
const info = await scraper.getVideoInfo(v.href);
|
|
41
|
-
if (!info) return { video: v, error:
|
|
152
|
+
if (!info) return { video: v, error: "no info" };
|
|
42
153
|
return { video: v, info };
|
|
43
154
|
} catch (err) {
|
|
44
155
|
return { video: v, error: err.message };
|
|
45
156
|
}
|
|
46
|
-
})
|
|
157
|
+
}),
|
|
47
158
|
);
|
|
48
159
|
|
|
49
160
|
for (const result of results) {
|
|
50
|
-
if (result.status ===
|
|
161
|
+
if (result.status === "fulfilled") {
|
|
51
162
|
const { video, info, error } = result.value;
|
|
52
163
|
if (info && info.stats) {
|
|
53
164
|
video.stats = info.stats;
|
|
54
165
|
success++;
|
|
55
166
|
} else {
|
|
56
167
|
failed++;
|
|
57
|
-
const id = video.id || video.href ||
|
|
58
|
-
console.error(
|
|
168
|
+
const id = video.id || video.href || "?";
|
|
169
|
+
console.error(
|
|
170
|
+
` [失败] ${id.substring(0, 30)} - ${error || "获取失败"}`,
|
|
171
|
+
);
|
|
59
172
|
}
|
|
60
173
|
} else {
|
|
61
174
|
failed++;
|
|
@@ -63,19 +176,21 @@ export async function handleVideoStats(options) {
|
|
|
63
176
|
}
|
|
64
177
|
|
|
65
178
|
const processed = Math.min(i + poolSize, videos.length);
|
|
66
|
-
console.error(
|
|
179
|
+
console.error(
|
|
180
|
+
`[videostats] 进度: ${processed}/${videos.length} (成功: ${success}, 失败: ${failed})`,
|
|
181
|
+
);
|
|
67
182
|
}
|
|
68
183
|
|
|
69
|
-
|
|
70
|
-
writeFileSync(statsFile, JSON.stringify(videos, null, 2), 'utf-8');
|
|
184
|
+
writeVideosToDb(db, videos);
|
|
71
185
|
console.error(``);
|
|
72
186
|
console.error(`[videostats] 完成: 成功 ${success}, 失败 ${failed}`);
|
|
73
|
-
console.error(`[videostats] 已更新 ${
|
|
74
|
-
|
|
187
|
+
console.error(`[videostats] 已更新 ${source.dbPath}`);
|
|
75
188
|
} catch (err) {
|
|
76
189
|
console.error(`[videostats] 异常: ${err.message}`);
|
|
190
|
+
db?.close();
|
|
77
191
|
process.exit(1);
|
|
78
192
|
} finally {
|
|
193
|
+
db?.close();
|
|
79
194
|
await scraper.close();
|
|
80
195
|
}
|
|
81
196
|
}
|