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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.16",
3
+ "version": "1.3.18",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/attach.js CHANGED
@@ -22,10 +22,11 @@ async function apiGet(url) {
22
22
  });
23
23
  }
24
24
 
25
- async function apiPut(url, body) {
26
- return withRetry(`PUT ${url}`, async () => {
25
+
26
+ async function apiPost(url, body) {
27
+ return withRetry(`POST ${url}`, async () => {
27
28
  const res = await fetch(url, {
28
- method: 'PUT',
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
- let successCount = 0;
115
- let failCount = 0;
116
- let needRestart = false;
117
-
118
- for (const result of results) {
119
- if (result.status === 'fulfilled') {
120
- const { uniqueId, info, error } = result.value;
121
- if (error) {
122
- if (isBrowserClosedError(error)) {
123
- needRestart = true;
124
- }
125
- console.error(` ✗ @${uniqueId} 获取失败: ${error.message || '未知错误'}`);
126
- failCount++;
127
- } else if (info) {
128
- try {
129
- await apiPut(`${serverUrl}/api/user-info/${encodeURIComponent(uniqueId)}`, info);
130
- console.error(` ✓ @${uniqueId} 已提交更新`);
131
- successCount++;
132
- } catch (err) {
133
- console.error(` ✗ @${uniqueId} 提交失败: ${err.message}`);
134
- failCount++;
135
- }
136
- } else {
137
- console.error(` ✗ @${uniqueId} 未获取到用户信息`);
138
- failCount++;
139
- }
140
- } else {
141
- console.error(` ✗ 任务执行异常: ${result.reason?.message || '未知错误'}`);
142
- failCount++;
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
  })();
@@ -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
- for (const u of data) {
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
- const json = JSON.stringify(data, null, 2);
85
- fs.writeFileSync(resolved, json, 'utf-8');
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
- return data.find(u => u.uniqueId === uid);
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 getUser(uid) !== undefined;
204
+ return uidIndex.has(uid) || doneUidIndex.has(uid);
107
205
  }
108
206
 
109
207
  function userExists(uid) {
110
- return hasUser(uid);
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) data.push(user);
126
- else data.unshift(user);
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
- const newUsers = [];
354
+ // 先对 discovered 内部去重,再用 uidIndex 批量判断
355
+ const seen = new Set();
356
+ const unique = [];
245
357
  for (const d of discovered) {
246
- const existing = getUser(d.uniqueId);
247
- if (!existing) {
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,
@@ -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
- const userInfoCommitMatch = routePath.match(/^\/api\/user-info\/([^/]+)$/);
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
- const paged = filtered.slice(offset, offset + limit);
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;