tt-help-cli-ycl 1.3.13 → 1.3.15

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.
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  delay,
3
3
  ensureBrowserReady,
4
- setDelayConfig,
5
- closeCommentPanel,
6
4
  retryWithBackoff,
7
5
  detectPageError,
8
6
  isLoggedIn,
@@ -10,19 +8,14 @@ import {
10
8
  } from './modules/page-helpers.js';
11
9
  import { detectCaptcha } from './modules/captcha-handler.js';
12
10
  export { ensureBrowserReady };
13
- import {
14
- getUserInfo,
15
- collectVideos,
16
- } from '../videos/core.js';
17
- import { scrapeSingleVideo } from './core.js';
11
+ import { getUserInfo, collectVideos } from '../videos/core.js';
18
12
  import { extractFollowAndFollowers } from './modules/follow-extractor.js';
19
- import { extractCommentAuthors } from './modules/comment-extractor.js';
20
- import { extractGuessVideos } from './modules/guess-extractor.js';
13
+ import { extractVideoLocation } from '../lib/scrape.js';
14
+ import { maxFollowing as globalMaxFollowing, maxFollowers as globalMaxFollowers, maxVideos as globalMaxVideos } from '../lib/constants.js';
21
15
 
22
16
  async function processExplore(page, username, options, log) {
23
17
  const {
24
- maxComments = 0,
25
- maxGuess = 0,
18
+ maxVideos = 16,
26
19
  enableFollow = true,
27
20
  maxFollowing = 5,
28
21
  maxFollowers = 5,
@@ -70,7 +63,10 @@ async function processExplore(page, username, options, log) {
70
63
  result.captchaMessage = result.captchaMessage || '视频页出现验证码';
71
64
  }
72
65
 
73
- const videoList = await collectVideos(page, username, 1, log);
66
+ const isSeller = result.userInfo?.ttSeller === true;
67
+ const effectiveMaxVideos = isSeller ? globalMaxVideos : maxVideos;
68
+ if (isSeller) log(` 商家用户,视频采集数: ${effectiveMaxVideos}`);
69
+ const videoList = await collectVideos(page, username, effectiveMaxVideos, log);
74
70
  const videoArray = videoList ? [...videoList.values()] : [];
75
71
  result.collectedVideos = videoArray.length;
76
72
 
@@ -87,84 +83,70 @@ async function processExplore(page, username, options, log) {
87
83
  return result;
88
84
  }
89
85
 
90
- if (enableFollow) {
91
- const loggedIn = await isLoggedIn(page);
92
- if (!loggedIn) {
93
- log(' [跳过] 获取关注/粉丝:未登录,请先登录 TikTok');
94
- result.hasFollowData = false;
95
- result.discoveredFollowing = [];
96
- result.discoveredFollowers = [];
97
- } else {
98
- try {
99
- log(' 获取关注/粉丝...');
100
- const { following, followers } = await extractFollowAndFollowers(
101
- page, { maxFollowing, maxFollowers, log }
102
- );
103
- result.discoveredFollowing = following || [];
104
- result.discoveredFollowers = followers || [];
105
- result.hasFollowData = true;
106
- log(` 关注: ${result.discoveredFollowing.length}, 粉丝: ${result.discoveredFollowers.length}`);
107
- } catch (e) {
108
- log(` 关注/粉丝提取失败: ${e.message}`);
109
- result.hasFollowData = false;
110
- result.discoveredFollowing = [];
111
- result.discoveredFollowers = [];
112
- }
86
+ // 从第一个视频获取 locationCreated
87
+ let locationCreated = null;
88
+ if (videoArray.length > 0) {
89
+ const firstVideo = videoArray[0];
90
+ const firstVideoUrl = firstVideo.href.startsWith('http')
91
+ ? firstVideo.href
92
+ : `https://www.tiktok.com${firstVideo.href}`;
93
+
94
+ try {
95
+ locationCreated = await extractVideoLocation(firstVideoUrl);
96
+ } catch (e) {
97
+ log(` 获取视频国家失败: ${e.message}`);
113
98
  }
114
99
  }
115
100
 
116
- const firstVideo = videoArray[0];
117
- const videoUrl = firstVideo.href.startsWith('http')
118
- ? firstVideo.href
119
- : `https://www.tiktok.com${firstVideo.href}`;
120
-
121
- log(` 进入第一个视频: ${videoUrl}`);
122
- await retryWithBackoff(() => page.goto(videoUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), { log });
123
- assertPageUrl(page, videoUrl.split('/video/')[0]);
124
- await delay(1500, 2500);
125
-
126
- const videoData = await scrapeSingleVideo(page, 0, 0, log, 'NEVER_MATCH');
127
- result.locationCreated = videoData.locationCreated || null;
128
- log(` 视频作者: ${videoData.videoAuthor} | 国家: ${result.locationCreated || '未知'}`);
101
+ result.locationCreated = locationCreated || null;
102
+ log(` 国家: ${result.locationCreated || '未知'}`);
129
103
 
104
+ // 国家筛选
130
105
  const locationList = (location || 'ES').split(',').map(s => s.trim().toUpperCase());
131
106
  const isTargetLocation = locationList.includes(result.locationCreated?.toUpperCase?.() || result.locationCreated);
132
107
 
133
108
  if (isTargetLocation) {
134
109
  result.keepFollow = true;
135
- log(` 国家匹配 (${result.locationCreated} in [${location}]),获取评论和猜你喜欢...`);
136
-
137
- if (maxComments > 0) {
138
- const commentResult = await extractCommentAuthors(page, maxComments);
139
- result.discoveredCommentAuthors = commentResult.authors || [];
140
- if (commentResult.captchaDetected) {
141
- result.captchaDetected = true;
142
- result.captchaStage = 'comment';
143
- result.captchaMessage = '评论阶段出现验证码';
144
- }
145
- await closeCommentPanel(page);
146
- await delay(500, 1000);
147
- log(` 评论用户: ${result.discoveredCommentAuthors.length}`);
148
- }
110
+ log(` 国家匹配,获取关注/粉丝...`);
149
111
 
150
- if (maxGuess > 0) {
151
- const guessResult = await extractGuessVideos(page, maxGuess);
152
- result.discoveredGuessAuthors = (guessResult || []).map(v => v.author).filter(Boolean);
153
- await closeCommentPanel(page);
154
- await delay(500, 1000);
155
- log(` 猜你喜欢作者: ${result.discoveredGuessAuthors.length}`);
112
+ // 提取关注/粉丝
113
+ if (enableFollow) {
114
+ const loggedIn = await isLoggedIn(page);
115
+ if (!loggedIn) {
116
+ log(' [跳过] 获取关注/粉丝:未登录,请先登录 TikTok');
117
+ result.hasFollowData = false;
118
+ result.discoveredFollowing = [];
119
+ result.discoveredFollowers = [];
120
+ } else {
121
+ try {
122
+ const effectiveMaxFollowing = isSeller ? globalMaxFollowing : maxFollowing;
123
+ const effectiveMaxFollowers = isSeller ? globalMaxFollowers : maxFollowers;
124
+ if (isSeller) log(` 商家用户,关注采集: ${effectiveMaxFollowing}, 粉丝采集: ${effectiveMaxFollowers}`);
125
+ const { following, followers } = await extractFollowAndFollowers(
126
+ page, { maxFollowing: effectiveMaxFollowing, maxFollowers: effectiveMaxFollowers, log }
127
+ );
128
+ result.discoveredFollowing = following || [];
129
+ result.discoveredFollowers = followers || [];
130
+ result.hasFollowData = true;
131
+ log(` 关注: ${result.discoveredFollowing.length}, 粉丝: ${result.discoveredFollowers.length}`);
132
+ } catch (e) {
133
+ log(` 关注/粉丝提取失败: ${e.message}`);
134
+ result.hasFollowData = false;
135
+ result.discoveredFollowing = [];
136
+ result.discoveredFollowers = [];
137
+ }
138
+ }
156
139
  }
157
140
 
158
- result.discoveredVideoAuthors = [{
159
- uniqueId: videoData.uniqueId,
160
- nickname: videoData.nickname,
161
- locationCreated: videoData.locationCreated,
162
- }];
141
+ // 携带视频列表供登记
142
+ result.videoList = videoArray;
163
143
  } else {
144
+ // 国家不匹配
164
145
  result.keepFollow = false;
165
- log(` 国家不匹配 (${result.locationCreated} not in [${location}]),跳过评论/猜你喜欢,丢弃关注/粉丝`);
166
146
  result.discoveredFollowing = [];
167
147
  result.discoveredFollowers = [];
148
+ result.hasFollowData = false;
149
+ log(` 国家不匹配,跳过`);
168
150
  }
169
151
 
170
152
  result.processed = true;
@@ -1,5 +1,5 @@
1
1
  import { delay, ensureBrowserReady, ensureTikTokPage, retryWithBackoff } from '../scraper/modules/page-helpers.js';
2
- import { scrollAndCollect } from '../scraper/modules/scroll-collector.js';
2
+ import { fetchUserVideosAPI } from '../lib/api-interceptor.js';
3
3
 
4
4
  async function getUserInfo(page) {
5
5
  return await page.evaluate(() => {
@@ -41,41 +41,12 @@ async function getUserInfo(page) {
41
41
  }
42
42
 
43
43
  async function collectVideos(page, username, maxVideos, log) {
44
- const allLinks = await scrollAndCollect(page, {
45
- container: '[class*="ColumnListContainer"]',
46
- extraArgs: { handle: username },
47
- collectFn: (container, args) => {
48
- const pattern = '/@' + args.handle + '/video/';
49
- return {
50
- items: Array.from(document.querySelectorAll('a'))
51
- .filter(el => (el.getAttribute('href') || '').includes(pattern))
52
- .map(el => {
53
- const href = el.getAttribute('href') || '';
54
- const idMatch = href.match(/\/video\/(\d+)/);
55
- return { id: idMatch ? idMatch[1] : null, href };
56
- })
57
- .filter(v => v.id),
58
- };
59
- },
60
- maxItems: maxVideos,
61
- delayRange: [2000, 3000],
62
- staleThreshold: 5,
63
- maxRounds: 500,
64
- onRound: (round, items, allItems) => {
65
- const uniqueCount = new Set(allItems.map(v => v.id)).size;
66
- if (uniqueCount > 0 && (uniqueCount % 10 === 0 || items.length > 0)) {
67
- log(`滚动 ${round + 1}: ${uniqueCount} 个视频 (本轮 ${items.length} 条)`);
68
- }
69
- },
70
- });
71
-
72
- const uniqueVideos = new Map();
73
- allLinks.forEach(v => {
74
- if (!uniqueVideos.has(v.id)) uniqueVideos.set(v.id, v);
75
- });
76
-
77
- log(`收集完成: ${uniqueVideos.size} 个视频`);
78
- return uniqueVideos;
44
+ const apiResult = await fetchUserVideosAPI(page, username, maxVideos, log);
45
+ if (apiResult && apiResult.size > 0) {
46
+ log(`收集完成: ${apiResult.size} 个视频`);
47
+ return apiResult;
48
+ }
49
+ return new Map();
79
50
  }
80
51
 
81
52
  async function runGetUserVideos(options) {
@@ -12,6 +12,25 @@ export function createStore(filePath) {
12
12
  let data = [];
13
13
  let clientErrors = new Map();
14
14
 
15
+ // 视频存储(独立 JSON 文件)
16
+ let videos = [];
17
+ let videoFilePath = null;
18
+ if (filePath) {
19
+ const resolved = path.resolve(filePath);
20
+ videoFilePath = resolved.replace(/\.json$/, '-videos.json');
21
+ if (fs.existsSync(videoFilePath)) {
22
+ try {
23
+ const content = fs.readFileSync(videoFilePath, 'utf-8');
24
+ const parsed = JSON.parse(content);
25
+ if (Array.isArray(parsed)) {
26
+ videos = parsed;
27
+ }
28
+ } catch (e) {
29
+ console.error(`[data-store] 读取视频文件失败: ${e.message}`);
30
+ }
31
+ }
32
+ }
33
+
15
34
  let backupTimer = null;
16
35
 
17
36
  if (filePath) {
@@ -66,6 +85,12 @@ export function createStore(filePath) {
66
85
  fs.writeFileSync(resolved, json, 'utf-8');
67
86
  }
68
87
 
88
+ function saveVideos() {
89
+ if (!videoFilePath) return;
90
+ const json = JSON.stringify(videos, null, 2);
91
+ fs.writeFileSync(videoFilePath, json, 'utf-8');
92
+ }
93
+
69
94
  function stopBackup() {
70
95
  if (backupTimer) {
71
96
  clearInterval(backupTimer);
@@ -159,6 +184,12 @@ export function createStore(filePath) {
159
184
  next = pickFirst(seed);
160
185
  }
161
186
 
187
+ if (!next) {
188
+ const ttSeller = data.filter(u => u.status === 'pending' && u.ttSeller === true && u.verified === false);
189
+ ttSeller.sort((a, b) => locationTier(a) - locationTier(b));
190
+ next = pickFirst(ttSeller);
191
+ }
192
+
162
193
  if (!next) {
163
194
  const follow = data.filter(u => u.status === 'pending' && u.sources && (u.sources.includes('following') || u.sources.includes('follower')));
164
195
  follow.sort((a, b) => locationTier(a) - locationTier(b));
@@ -188,25 +219,25 @@ export function createStore(filePath) {
188
219
  nickname: typeof v === 'string' ? null : v.nickname || null,
189
220
  locationCreated: typeof v === 'string' ? null : v.locationCreated || null,
190
221
  guessedLocation: typeof v === 'string' ? guessedLocation : (v.guessedLocation || guessedLocation),
191
- source: 'video',
222
+ sources: ['video'],
192
223
  })),
193
224
  ...(result.discoveredCommentAuthors || []).map(c => {
194
- if (typeof c === 'string') return { uniqueId: c.replace(/^@/, ''), source: 'comment', guessedLocation };
195
- return { uniqueId: (c.author || c.uniqueId || '').replace(/^@/, ''), nickname: c.nickname || null, source: 'comment', guessedLocation: c.guessedLocation || guessedLocation };
225
+ if (typeof c === 'string') return { uniqueId: c.replace(/^@/, ''), sources: ['comment'], guessedLocation };
226
+ return { uniqueId: (c.author || c.uniqueId || '').replace(/^@/, ''), nickname: c.nickname || null, sources: ['comment'], guessedLocation: c.guessedLocation || guessedLocation };
196
227
  }),
197
228
  ...(result.discoveredGuessAuthors || []).map(g => {
198
- if (typeof g === 'string') return { uniqueId: g.replace(/^@/, ''), source: 'guess', guessedLocation };
199
- return { uniqueId: (g.author || g.uniqueId || '').replace(/^@/, ''), nickname: g.nickname || null, source: 'guess', guessedLocation: g.guessedLocation || guessedLocation };
229
+ if (typeof g === 'string') return { uniqueId: g.replace(/^@/, ''), sources: ['guess'], guessedLocation };
230
+ return { uniqueId: (g.author || g.uniqueId || '').replace(/^@/, ''), nickname: g.nickname || null, sources: ['guess'], guessedLocation: g.guessedLocation || guessedLocation };
200
231
  }),
201
232
  ...(result.discoveredFollowing || []).map(f => {
202
233
  const handle = Array.isArray(f) ? f[0] : (f.handle || '');
203
234
  const name = Array.isArray(f) ? f[1] : (f.displayName || null);
204
- return { uniqueId: handle.replace(/^@/, ''), nickname: name, source: 'following', guessedLocation: (typeof f === 'object' && f.guessedLocation) || guessedLocation };
235
+ return { uniqueId: handle.replace(/^@/, ''), nickname: name, sources: ['following'], guessedLocation: (typeof f === 'object' && f.guessedLocation) || guessedLocation };
205
236
  }),
206
237
  ...(result.discoveredFollowers || []).map(f => {
207
238
  const handle = Array.isArray(f) ? f[0] : (f.handle || '');
208
239
  const name = Array.isArray(f) ? f[1] : (f.displayName || null);
209
- return { uniqueId: handle.replace(/^@/, ''), nickname: name, source: 'follower', guessedLocation: (typeof f === 'object' && f.guessedLocation) || guessedLocation };
240
+ return { uniqueId: handle.replace(/^@/, ''), nickname: name, sources: ['follower'], guessedLocation: (typeof f === 'object' && f.guessedLocation) || guessedLocation };
210
241
  }),
211
242
  ].filter(u => u.uniqueId);
212
243
 
@@ -278,6 +309,7 @@ export function createStore(filePath) {
278
309
  user[key] = result[key];
279
310
  }
280
311
  }
312
+ user.sources = [...new Set([...(user.sources || []), 'processed'])];
281
313
  }
282
314
  }
283
315
 
@@ -419,12 +451,85 @@ export function createStore(filePath) {
419
451
  return Array.from(clientErrors.values());
420
452
  }
421
453
 
454
+ function getPendingUserUpdateTasks(limit) {
455
+ const l = Math.max(1, parseInt(limit) || 5);
456
+ const pending = data.filter(u => {
457
+ const updateCount = u.userUpdateCount;
458
+ const ttSellerEmpty = u.ttSeller === null || u.ttSeller === undefined || u.ttSeller === '';
459
+ if (!ttSellerEmpty) return false;
460
+ return updateCount === null || updateCount === undefined || updateCount <= 0;
461
+ }).slice(0, l);
462
+ // 接受任务时 userUpdateCount + 1
463
+ pending.forEach(u => {
464
+ u.userUpdateCount = (u.userUpdateCount || 0) + 1;
465
+ u.updatedAt = Date.now();
466
+ });
467
+ save();
468
+ return pending;
469
+ }
470
+
471
+ function updateUserInfo(uniqueId, info) {
472
+ const user = getUser(uniqueId);
473
+ if (!user) return { error: 'user not found' };
474
+ for (const key of Object.keys(info)) {
475
+ if (key === 'uniqueId' || key === 'sources') continue;
476
+ if (info[key] !== undefined && info[key] !== null && info[key] !== '') {
477
+ user[key] = info[key];
478
+ }
479
+ }
480
+ user.userUpdateCount = (user.userUpdateCount || 0) + 1;
481
+ user.updatedAt = Date.now();
482
+ save();
483
+ return { ok: true, userUpdateCount: user.userUpdateCount };
484
+ }
485
+
486
+ // 视频登记
487
+ function registerVideos(sourceUser, videoList, locationCreated, ttSeller) {
488
+ if (!videoList || !Array.isArray(videoList) || videoList.length === 0) {
489
+ return { registered: 0, skipped: 0 };
490
+ }
491
+
492
+ const existingIds = new Set(videos.map(v => v.id));
493
+ let registered = 0;
494
+ let skipped = 0;
495
+
496
+ for (const item of videoList) {
497
+ if (existingIds.has(item.id)) {
498
+ skipped++;
499
+ continue;
500
+ }
501
+ videos.push({
502
+ id: item.id,
503
+ href: item.href,
504
+ authorUniqueId: sourceUser,
505
+ locationCreated: locationCreated || null,
506
+ ttSeller: ttSeller || false,
507
+ registeredAt: Date.now(),
508
+ });
509
+ existingIds.add(item.id);
510
+ registered++;
511
+ }
512
+
513
+ saveVideos();
514
+ return { registered, skipped };
515
+ }
516
+
517
+ function getVideos() {
518
+ return videos;
519
+ }
520
+
521
+ function getVideoCount() {
522
+ return videos.length;
523
+ }
524
+
422
525
  return {
423
526
  save, getUser, hasUser, userExists, addUser,
424
527
  getPendingUsers, getProcessedUsers, getAllUsers,
425
528
  claimNextJob, commitJob, commitNewExplore, resetJob, togglePin,
426
529
  getNextRedoJob, commitRedoJob,
530
+ getPendingUserUpdateTasks, updateUserInfo,
427
531
  reportClientError, deleteClientError, getClientErrors,
532
+ registerVideos, getVideos, getVideoCount,
428
533
  stopBackup,
429
534
  data,
430
535
  };
@@ -257,6 +257,19 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
257
257
  return;
258
258
  }
259
259
 
260
+ // 视频登记
261
+ if (req.method === 'POST' && routePath === '/api/videos') {
262
+ const body = await readBody(req);
263
+ const { sourceUser, videoList, locationCreated, ttSeller } = body;
264
+ if (!sourceUser) {
265
+ sendJSON(res, 400, { error: 'sourceUser 不能为空' });
266
+ return;
267
+ }
268
+ const ret = store.registerVideos(sourceUser, videoList || [], locationCreated, ttSeller);
269
+ sendJSON(res, 200, ret);
270
+ return;
271
+ }
272
+
260
273
  const jobPinMatch = routePath.match(/^\/api\/job\/([^/]+)\/pin$/);
261
274
  if (req.method === 'POST' && jobPinMatch) {
262
275
  const uniqueId = jobPinMatch[1];
@@ -285,6 +298,34 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
285
298
  return;
286
299
  }
287
300
 
301
+ if (req.method === 'GET' && routePath === '/api/user-update-tasks') {
302
+ const limit = params.limit;
303
+ const tasks = store.getPendingUserUpdateTasks(limit);
304
+ const ts = new Date().toISOString().slice(11, 19);
305
+ console.error(`[JOB ${ts}] USER-UPDATE-TASKS: ${tasks.length} tasks`);
306
+ sendJSON(res, 200, { total: tasks.length, tasks });
307
+ return;
308
+ }
309
+
310
+ const userInfoCommitMatch = routePath.match(/^\/api\/user-info\/([^/]+)$/);
311
+ if (req.method === 'PUT' && userInfoCommitMatch) {
312
+ const uniqueId = userInfoCommitMatch[1];
313
+ try {
314
+ const body = await readBody(req);
315
+ const ret = store.updateUserInfo(uniqueId, body);
316
+ if (ret.error) {
317
+ sendJSON(res, 404, { error: ret.error });
318
+ return;
319
+ }
320
+ const ts = new Date().toISOString().slice(11, 19);
321
+ console.error(`[JOB ${ts}] USER-INFO-UPDATE: ${uniqueId} (userUpdateCount=${ret.userUpdateCount})`);
322
+ sendJSON(res, 200, ret);
323
+ } catch (e) {
324
+ sendJSON(res, 400, { error: e.message });
325
+ }
326
+ return;
327
+ }
328
+
288
329
  const userExistsMatch = routePath.match(/^\/api\/user-exists\/([^/]+)$/);
289
330
  if (req.method === 'GET' && userExistsMatch) {
290
331
  const uniqueId = userExistsMatch[1];
@@ -415,6 +456,9 @@ export function startWatchServer(outputFile, port = 3000, existingStore) {
415
456
  if (sa !== sb) return sa - sb;
416
457
  if (a.status === 'done' && b.status === 'done') return (b.processedAt || 0) - (a.processedAt || 0);
417
458
  if (a.status === 'pending' && b.status === 'pending') {
459
+ const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
460
+ const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
461
+ if (aSeller !== bSeller) return aSeller - bSeller;
418
462
  const la = locationTier(a), lb = locationTier(b);
419
463
  if (la !== lb) return la - lb;
420
464
  }