tt-help-cli-ycl 1.3.16 → 1.3.18
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 +54 -34
- package/src/lib/tiktok-scraper.mjs +2 -1
- package/src/watch/data-store.js +158 -14
- package/src/watch/server.js +37 -2
package/package.json
CHANGED
package/src/cli/attach.js
CHANGED
|
@@ -22,10 +22,11 @@ async function apiGet(url) {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
|
|
26
|
+
async function apiPost(url, body) {
|
|
27
|
+
return withRetry(`POST ${url}`, async () => {
|
|
27
28
|
const res = await fetch(url, {
|
|
28
|
-
method: '
|
|
29
|
+
method: 'POST',
|
|
29
30
|
headers: { 'Content-Type': 'application/json' },
|
|
30
31
|
body: JSON.stringify(body),
|
|
31
32
|
});
|
|
@@ -111,37 +112,56 @@ export async function handleAttach(options) {
|
|
|
111
112
|
})
|
|
112
113
|
);
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
115
|
+
let successCount = 0;
|
|
116
|
+
let failCount = 0;
|
|
117
|
+
let needRestart = false;
|
|
118
|
+
|
|
119
|
+
// 收集抓取成功的任务,记录抓取失败的
|
|
120
|
+
const successTasks = [];
|
|
121
|
+
for (const result of results) {
|
|
122
|
+
if (result.status === 'fulfilled') {
|
|
123
|
+
const { uniqueId, info, error } = result.value;
|
|
124
|
+
if (error) {
|
|
125
|
+
if (isBrowserClosedError(error)) {
|
|
126
|
+
needRestart = true;
|
|
127
|
+
}
|
|
128
|
+
console.error(` ✗ @${uniqueId} 获取失败: ${error.message || '未知错误'}`);
|
|
129
|
+
failCount++;
|
|
130
|
+
} else if (info) {
|
|
131
|
+
successTasks.push({ uniqueId, info });
|
|
132
|
+
} else {
|
|
133
|
+
console.error(` ✗ @${uniqueId} 未获取到用户信息`);
|
|
134
|
+
failCount++;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
console.error(` ✗ 任务执行异常: ${result.reason?.message || '未知错误'}`);
|
|
138
|
+
failCount++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 批量提交成功的结果
|
|
143
|
+
if (successTasks.length > 0) {
|
|
144
|
+
try {
|
|
145
|
+
const batchRet = await apiPost(`${serverUrl}/api/user-info-batch`, { updates: successTasks });
|
|
146
|
+
if (batchRet && batchRet.results) {
|
|
147
|
+
for (const r of batchRet.results) {
|
|
148
|
+
if (r.ok) {
|
|
149
|
+
successCount++;
|
|
150
|
+
console.error(` ✓ @${r.uniqueId} 已提交更新`);
|
|
151
|
+
} else {
|
|
152
|
+
failCount++;
|
|
153
|
+
console.error(` ✗ @${r.uniqueId} 提交失败: ${r.error}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
successCount = successTasks.length;
|
|
158
|
+
console.error(` ✓ 批量提交完成 (${successTasks.length} 条)`);
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
failCount += successTasks.length;
|
|
162
|
+
console.error(` ✗ 批量提交失败: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
145
165
|
|
|
146
166
|
console.error(` 本批结果: ${successCount} 成功, ${failCount} 失败\n`);
|
|
147
167
|
|
|
@@ -99,14 +99,15 @@ export class TikTokScraper {
|
|
|
99
99
|
async warmWaf() {
|
|
100
100
|
if (this.warmPromise) return this.warmPromise;
|
|
101
101
|
this.warmPromise = (async () => {
|
|
102
|
+
const page = await this.context.newPage();
|
|
102
103
|
try {
|
|
103
|
-
const page = this.slots[0].page;
|
|
104
104
|
await page.goto(this.warmUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
105
105
|
await delay(1500);
|
|
106
106
|
this.lastWarmTime = Date.now();
|
|
107
107
|
} catch (e) {
|
|
108
108
|
console.error(`[warmWaf] failed: ${e.message}`);
|
|
109
109
|
} finally {
|
|
110
|
+
await page.close();
|
|
110
111
|
this.warmPromise = null;
|
|
111
112
|
}
|
|
112
113
|
})();
|
package/src/watch/data-store.js
CHANGED
|
@@ -10,8 +10,53 @@ function inferStatus(u) {
|
|
|
10
10
|
|
|
11
11
|
export function createStore(filePath) {
|
|
12
12
|
let data = [];
|
|
13
|
+
// uniqueId → index 内存索引,O(1) 查找
|
|
14
|
+
let uidIndex = new Map();
|
|
13
15
|
let clientErrors = new Map();
|
|
14
16
|
|
|
17
|
+
// done 用户归档(独立文件,主文件只保留活跃用户)
|
|
18
|
+
let doneArchive = [];
|
|
19
|
+
let doneArchivePath = null;
|
|
20
|
+
let doneUidIndex = new Map();
|
|
21
|
+
if (filePath) {
|
|
22
|
+
doneArchivePath = path.resolve(filePath).replace(/\.json$/, '-done.json');
|
|
23
|
+
if (fs.existsSync(doneArchivePath)) {
|
|
24
|
+
try {
|
|
25
|
+
const content = fs.readFileSync(doneArchivePath, 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(content);
|
|
27
|
+
if (Array.isArray(parsed)) doneArchive = parsed;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error(`[data-store] 读取 done 归档失败: ${e.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// stats 缓存
|
|
35
|
+
let statsCache = null;
|
|
36
|
+
let statsDirty = true;
|
|
37
|
+
|
|
38
|
+
function markStatsDirty() {
|
|
39
|
+
statsDirty = true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function computeStatsInternal() {
|
|
43
|
+
const total = data.length;
|
|
44
|
+
const statusCounts = { pending: 0, processing: 0, done: 0, error: 0, restricted: 0 };
|
|
45
|
+
for (const u of data) {
|
|
46
|
+
statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
|
|
47
|
+
}
|
|
48
|
+
statsCache = { total, statusCounts };
|
|
49
|
+
statsDirty = false;
|
|
50
|
+
return statsCache;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getStats() {
|
|
54
|
+
if (statsDirty) {
|
|
55
|
+
return computeStatsInternal();
|
|
56
|
+
}
|
|
57
|
+
return statsCache;
|
|
58
|
+
}
|
|
59
|
+
|
|
15
60
|
// 视频存储(独立 JSON 文件)
|
|
16
61
|
let videos = [];
|
|
17
62
|
let videoFilePath = null;
|
|
@@ -74,15 +119,64 @@ export function createStore(filePath) {
|
|
|
74
119
|
backupTimer = setInterval(runBackup, 60 * 60 * 1000);
|
|
75
120
|
}
|
|
76
121
|
|
|
77
|
-
|
|
122
|
+
// 构建索引 + 推断 status
|
|
123
|
+
for (let i = 0; i < data.length; i++) {
|
|
124
|
+
const u = data[i];
|
|
78
125
|
if (!u.status) u.status = inferStatus(u);
|
|
126
|
+
uidIndex.set(u.uniqueId, i);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 构建 done 归档索引
|
|
130
|
+
for (let i = 0; i < doneArchive.length; i++) {
|
|
131
|
+
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rebuildIndex() {
|
|
135
|
+
uidIndex = new Map();
|
|
136
|
+
for (let i = 0; i < data.length; i++) {
|
|
137
|
+
uidIndex.set(data[i].uniqueId, i);
|
|
138
|
+
}
|
|
79
139
|
}
|
|
80
140
|
|
|
81
141
|
function save() {
|
|
82
142
|
if (!filePath) return;
|
|
83
143
|
const resolved = path.resolve(filePath);
|
|
84
|
-
|
|
85
|
-
|
|
144
|
+
|
|
145
|
+
// 将 done 用户归档到独立文件
|
|
146
|
+
if (doneArchivePath && data.length > 1000) {
|
|
147
|
+
const active = [];
|
|
148
|
+
const toArchive = [];
|
|
149
|
+
for (const u of data) {
|
|
150
|
+
if (u.status === 'done') {
|
|
151
|
+
toArchive.push(u);
|
|
152
|
+
} else {
|
|
153
|
+
active.push(u);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (toArchive.length > 0) {
|
|
157
|
+
doneArchive = doneArchive.concat(toArchive);
|
|
158
|
+
data = active;
|
|
159
|
+
rebuildIndex();
|
|
160
|
+
// 重建 done 归档索引
|
|
161
|
+
doneUidIndex = new Map();
|
|
162
|
+
for (let i = 0; i < doneArchive.length; i++) {
|
|
163
|
+
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const json = JSON.stringify(data);
|
|
169
|
+
const tmpPath = resolved + '.tmp';
|
|
170
|
+
fs.writeFileSync(tmpPath, json, 'utf-8');
|
|
171
|
+
fs.renameSync(tmpPath, resolved);
|
|
172
|
+
|
|
173
|
+
// 写 done 归档
|
|
174
|
+
if (doneArchivePath && doneArchive.length > 0) {
|
|
175
|
+
const doneJson = JSON.stringify(doneArchive);
|
|
176
|
+
const doneTmp = doneArchivePath + '.tmp';
|
|
177
|
+
fs.writeFileSync(doneTmp, doneJson, 'utf-8');
|
|
178
|
+
fs.renameSync(doneTmp, doneArchivePath);
|
|
179
|
+
}
|
|
86
180
|
}
|
|
87
181
|
|
|
88
182
|
function saveVideos() {
|
|
@@ -99,15 +193,19 @@ export function createStore(filePath) {
|
|
|
99
193
|
}
|
|
100
194
|
|
|
101
195
|
function getUser(uid) {
|
|
102
|
-
|
|
196
|
+
const idx = uidIndex.get(uid);
|
|
197
|
+
if (idx !== undefined) return data[idx];
|
|
198
|
+
const doneIdx = doneUidIndex.get(uid);
|
|
199
|
+
if (doneIdx !== undefined) return doneArchive[doneIdx];
|
|
200
|
+
return undefined;
|
|
103
201
|
}
|
|
104
202
|
|
|
105
203
|
function hasUser(uid) {
|
|
106
|
-
return
|
|
204
|
+
return uidIndex.has(uid) || doneUidIndex.has(uid);
|
|
107
205
|
}
|
|
108
206
|
|
|
109
207
|
function userExists(uid) {
|
|
110
|
-
return
|
|
208
|
+
return uidIndex.has(uid) || doneUidIndex.has(uid);
|
|
111
209
|
}
|
|
112
210
|
|
|
113
211
|
function addUser(user, append) {
|
|
@@ -122,8 +220,15 @@ export function createStore(filePath) {
|
|
|
122
220
|
} else {
|
|
123
221
|
if (!user.status) user.status = inferStatus(user);
|
|
124
222
|
if (user.processed) user.processedAt = user.processedAt || Date.now();
|
|
125
|
-
if (append)
|
|
126
|
-
|
|
223
|
+
if (append) {
|
|
224
|
+
const idx = data.length;
|
|
225
|
+
data.push(user);
|
|
226
|
+
uidIndex.set(user.uniqueId, idx);
|
|
227
|
+
} else {
|
|
228
|
+
data.unshift(user);
|
|
229
|
+
uidIndex.set(user.uniqueId, 0);
|
|
230
|
+
}
|
|
231
|
+
markStatsDirty();
|
|
127
232
|
}
|
|
128
233
|
}
|
|
129
234
|
|
|
@@ -132,10 +237,13 @@ export function createStore(filePath) {
|
|
|
132
237
|
}
|
|
133
238
|
|
|
134
239
|
function getProcessedUsers() {
|
|
135
|
-
return data.filter(u => u.status === 'done');
|
|
240
|
+
return doneArchive.length > 0 ? data.filter(u => u.status === 'done').concat(doneArchive) : data.filter(u => u.status === 'done');
|
|
136
241
|
}
|
|
137
242
|
|
|
138
243
|
function getAllUsers() {
|
|
244
|
+
if (doneArchive.length > 0) {
|
|
245
|
+
return data.concat(doneArchive);
|
|
246
|
+
}
|
|
139
247
|
return data;
|
|
140
248
|
}
|
|
141
249
|
|
|
@@ -173,6 +281,7 @@ export function createStore(filePath) {
|
|
|
173
281
|
);
|
|
174
282
|
if (expired) {
|
|
175
283
|
expired.status = 'pending';
|
|
284
|
+
markStatsDirty();
|
|
176
285
|
delete expired.claimedAt;
|
|
177
286
|
next = expired;
|
|
178
287
|
}
|
|
@@ -204,6 +313,7 @@ export function createStore(filePath) {
|
|
|
204
313
|
|
|
205
314
|
if (next) {
|
|
206
315
|
next.status = 'processing';
|
|
316
|
+
markStatsDirty();
|
|
207
317
|
next.claimedAt = now;
|
|
208
318
|
next.claimedBy = userId;
|
|
209
319
|
return { uniqueId: next.uniqueId, nickname: next.nickname, claimedAt: next.claimedAt, claimedBy: userId };
|
|
@@ -241,10 +351,19 @@ export function createStore(filePath) {
|
|
|
241
351
|
}),
|
|
242
352
|
].filter(u => u.uniqueId);
|
|
243
353
|
|
|
244
|
-
|
|
354
|
+
// 先对 discovered 内部去重,再用 uidIndex 批量判断
|
|
355
|
+
const seen = new Set();
|
|
356
|
+
const unique = [];
|
|
245
357
|
for (const d of discovered) {
|
|
246
|
-
|
|
247
|
-
|
|
358
|
+
if (!seen.has(d.uniqueId)) {
|
|
359
|
+
seen.add(d.uniqueId);
|
|
360
|
+
unique.push(d);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const newUsers = [];
|
|
365
|
+
for (const d of unique) {
|
|
366
|
+
if (!hasUser(d.uniqueId)) {
|
|
248
367
|
addUser(d, true);
|
|
249
368
|
newUsers.push(d.uniqueId);
|
|
250
369
|
}
|
|
@@ -253,6 +372,7 @@ export function createStore(filePath) {
|
|
|
253
372
|
}
|
|
254
373
|
|
|
255
374
|
function updateUserFromResult(user, result) {
|
|
375
|
+
const oldStatus = user.status;
|
|
256
376
|
if (result.restricted) {
|
|
257
377
|
user.status = 'restricted';
|
|
258
378
|
if (result.userInfo) {
|
|
@@ -311,6 +431,7 @@ export function createStore(filePath) {
|
|
|
311
431
|
}
|
|
312
432
|
user.sources = [...new Set([...(user.sources || []), 'processed'])];
|
|
313
433
|
}
|
|
434
|
+
if (user.status !== oldStatus) markStatsDirty();
|
|
314
435
|
}
|
|
315
436
|
|
|
316
437
|
function commitJob(uniqueId, result) {
|
|
@@ -351,6 +472,7 @@ export function createStore(filePath) {
|
|
|
351
472
|
const user = getUser(uniqueId);
|
|
352
473
|
if (!user) return { saved: false, error: 'user not found' };
|
|
353
474
|
user.status = 'pending';
|
|
475
|
+
markStatsDirty();
|
|
354
476
|
delete user.claimedAt;
|
|
355
477
|
delete user.processedAt;
|
|
356
478
|
delete user.processed;
|
|
@@ -483,6 +605,28 @@ export function createStore(filePath) {
|
|
|
483
605
|
return { ok: true, userUpdateCount: user.userUpdateCount };
|
|
484
606
|
}
|
|
485
607
|
|
|
608
|
+
function batchUpdateUserInfo(updates) {
|
|
609
|
+
const results = [];
|
|
610
|
+
for (const item of updates) {
|
|
611
|
+
const user = getUser(item.uniqueId);
|
|
612
|
+
if (!user) {
|
|
613
|
+
results.push({ uniqueId: item.uniqueId, error: 'user not found' });
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
for (const key of Object.keys(item.info)) {
|
|
617
|
+
if (key === 'uniqueId' || key === 'sources') continue;
|
|
618
|
+
if (item.info[key] !== undefined && item.info[key] !== null && item.info[key] !== '') {
|
|
619
|
+
user[key] = item.info[key];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
user.userUpdateCount = (user.userUpdateCount || 0) + 1;
|
|
623
|
+
user.updatedAt = Date.now();
|
|
624
|
+
results.push({ uniqueId: item.uniqueId, ok: true, userUpdateCount: user.userUpdateCount });
|
|
625
|
+
}
|
|
626
|
+
save();
|
|
627
|
+
return results;
|
|
628
|
+
}
|
|
629
|
+
|
|
486
630
|
// 视频登记
|
|
487
631
|
function registerVideos(sourceUser, videoList, locationCreated, ttSeller) {
|
|
488
632
|
if (!videoList || !Array.isArray(videoList) || videoList.length === 0) {
|
|
@@ -524,10 +668,10 @@ export function createStore(filePath) {
|
|
|
524
668
|
|
|
525
669
|
return {
|
|
526
670
|
save, getUser, hasUser, userExists, addUser,
|
|
527
|
-
getPendingUsers, getProcessedUsers, getAllUsers,
|
|
671
|
+
getPendingUsers, getProcessedUsers, getAllUsers, getStats,
|
|
528
672
|
claimNextJob, commitJob, commitNewExplore, resetJob, togglePin,
|
|
529
673
|
getNextRedoJob, commitRedoJob,
|
|
530
|
-
getPendingUserUpdateTasks, updateUserInfo,
|
|
674
|
+
getPendingUserUpdateTasks, updateUserInfo, batchUpdateUserInfo,
|
|
531
675
|
reportClientError, deleteClientError, getClientErrors,
|
|
532
676
|
registerVideos, getVideos, getVideoCount,
|
|
533
677
|
stopBackup,
|
package/src/watch/server.js
CHANGED
|
@@ -280,6 +280,8 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
280
280
|
|
|
281
281
|
if (req.method === 'GET' && routePath === '/api/stats') {
|
|
282
282
|
const stats = computeStats(store.getAllUsers());
|
|
283
|
+
const quick = store.getStats();
|
|
284
|
+
if (quick) stats.quickStats = quick;
|
|
283
285
|
sendJSON(res, 200, stats);
|
|
284
286
|
return;
|
|
285
287
|
}
|
|
@@ -307,7 +309,23 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
307
309
|
return;
|
|
308
310
|
}
|
|
309
311
|
|
|
310
|
-
|
|
312
|
+
if (req.method === 'POST' && routePath === '/api/user-info-batch') {
|
|
313
|
+
try {
|
|
314
|
+
const body = await readBody(req);
|
|
315
|
+
const updates = body.updates || [];
|
|
316
|
+
const results = store.batchUpdateUserInfo(updates);
|
|
317
|
+
const okCount = results.filter(r => r.ok).length;
|
|
318
|
+
const errCount = results.filter(r => r.error).length;
|
|
319
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
320
|
+
console.error(`[JOB ${ts}] USER-INFO-BATCH: ${okCount} ok, ${errCount} error (total=${updates.length})`);
|
|
321
|
+
sendJSON(res, 200, { results, total: updates.length, ok: okCount, error: errCount });
|
|
322
|
+
} catch (e) {
|
|
323
|
+
sendJSON(res, 400, { error: e.message });
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const userInfoCommitMatch = routePath.match(/^\/api\/user-info\/([^/]+)$/);
|
|
311
329
|
if (req.method === 'PUT' && userInfoCommitMatch) {
|
|
312
330
|
const uniqueId = userInfoCommitMatch[1];
|
|
313
331
|
try {
|
|
@@ -467,7 +485,24 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
|
|
|
467
485
|
|
|
468
486
|
const limit = parseInt(params.limit) || 50;
|
|
469
487
|
const offset = parseInt(params.offset) || 0;
|
|
470
|
-
|
|
488
|
+
let paged = filtered.slice(offset, offset + limit);
|
|
489
|
+
|
|
490
|
+
// 字段裁剪:轻量视图只返回必要字段
|
|
491
|
+
if (params.view === 'light') {
|
|
492
|
+
paged = paged.map(u => ({
|
|
493
|
+
uniqueId: u.uniqueId,
|
|
494
|
+
nickname: u.nickname,
|
|
495
|
+
status: u.status,
|
|
496
|
+
sources: u.sources,
|
|
497
|
+
ttSeller: u.ttSeller,
|
|
498
|
+
verified: u.verified,
|
|
499
|
+
followerCount: u.followerCount,
|
|
500
|
+
locationCreated: u.locationCreated,
|
|
501
|
+
guessedLocation: u.guessedLocation,
|
|
502
|
+
pinned: u.pinned,
|
|
503
|
+
processedAt: u.processedAt,
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
471
506
|
|
|
472
507
|
sendJSON(res, 200, { total: filtered.length, users: paged });
|
|
473
508
|
return;
|