tt-help-cli-ycl 1.4.21 → 1.4.23

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.4.21",
3
+ "version": "1.4.23",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@ import {
35
35
  } from "../scraper/modules/captcha-handler.js";
36
36
  import { detectModals, closeAllModals } from "../lib/modal-handler.js";
37
37
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
38
+ import { parseVideoInfo } from "../lib/parse-ssr.mjs";
38
39
 
39
40
  // ────────────────────────────────────────
40
41
  // Section 1: 常量 & 配置默认值
@@ -492,6 +493,49 @@ function extractVideoInfo() {
492
493
  return result;
493
494
  }
494
495
 
496
+ /**
497
+ * 从视频页 SSR 数据中提取 challenges(标签/hashtags)
498
+ * @param {Page} page - Playwright page
499
+ * @returns {Promise<{ tagNames: string[], challenges: Array }>}
500
+ */
501
+ async function extractVideoTags(page) {
502
+ try {
503
+ const html = await page.evaluate(() => document.documentElement.outerHTML);
504
+ const videoInfo = parseVideoInfo(html);
505
+ if (
506
+ !videoInfo ||
507
+ !videoInfo.challenges ||
508
+ videoInfo.challenges.length === 0
509
+ ) {
510
+ return { tagNames: [], challenges: [] };
511
+ }
512
+ const tagNames = videoInfo.challenges.map((c) => c.title).filter(Boolean);
513
+ return { tagNames, challenges: videoInfo.challenges };
514
+ } catch (e) {
515
+ console.error(` [tags] 提取失败: ${e.message}`);
516
+ return { tagNames: [], challenges: [] };
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 将视频 tag 保存到服务端(只插入不存在的,不覆盖已有)
522
+ * @returns {Promise<{ inserted: number }>}
523
+ */
524
+ async function saveVideoTags(apiPost, serverUrl, tagNames, country) {
525
+ if (!tagNames || tagNames.length === 0) return { inserted: 0 };
526
+ try {
527
+ const result = await apiPost(`${serverUrl}/api/tags/batch-add-if-new`, {
528
+ tags: tagNames,
529
+ countries: [country],
530
+ source: "video",
531
+ });
532
+ return { inserted: result.inserted || 0 };
533
+ } catch (e) {
534
+ console.error(` [tags] 保存失败: ${e.message}`);
535
+ return { inserted: 0 };
536
+ }
537
+ }
538
+
495
539
  function extractUniqueAuthors(comments) {
496
540
  return [
497
541
  ...new Set(
@@ -668,7 +712,7 @@ async function runAutoMode(options) {
668
712
  if (shuttingDown) break;
669
713
 
670
714
  try {
671
- const { id, href, locationCreated } = task;
715
+ const { id, href, locationCreated, ttSeller } = task;
672
716
  const loc = normalizeLocation(locationCreated);
673
717
  const taskNum = totalTasks() + 1;
674
718
 
@@ -691,6 +735,24 @@ async function runAutoMode(options) {
691
735
  // 导航到视频页
692
736
  await prepareVideoPage(session.page, href);
693
737
 
738
+ // ── 提取视频 tag(仅 ttSeller 视频)──
739
+ if (ttSeller && loc) {
740
+ const { tagNames } = await extractVideoTags(session.page);
741
+ if (tagNames.length > 0) {
742
+ const { inserted } = await saveVideoTags(
743
+ apiPost,
744
+ serverUrl,
745
+ tagNames,
746
+ loc,
747
+ );
748
+ if (inserted > 0) {
749
+ console.error(
750
+ ` [tags] 提取到 ${tagNames.length} 个: ${tagNames.join(", ")} → 新增 ${inserted} 个`,
751
+ );
752
+ }
753
+ }
754
+ }
755
+
694
756
  // 获取评论
695
757
  const result = await fetchUserCommentsAPI(session.page, {
696
758
  maxComments: actualMaxComments,
@@ -541,7 +541,7 @@ export async function handleRefresh(options) {
541
541
 
542
542
  const payload = {
543
543
  userInfo: refreshUserInfo,
544
- topRecentVideo: result.topRecentVideo || null,
544
+ recentEcVideos: result.recentEcVideos || null,
545
545
  discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
546
546
  handle: Array.isArray(f) ? f[0] : f,
547
547
  displayName: Array.isArray(f) ? f[1] : null,
@@ -138,11 +138,17 @@ export function parseVideoInfo(rawHtml) {
138
138
  const item = vd.itemInfo.itemStruct;
139
139
  const author = item.author || {};
140
140
  const stats = item.stats || {};
141
+ const challenges = (item.challenges || []).map((c) => ({
142
+ id: c.id,
143
+ title: c.title,
144
+ desc: c.desc || "",
145
+ }));
141
146
  return {
142
147
  id: item.id,
143
148
  desc: item.desc || "",
144
149
  createTime: item.createTime || null,
145
150
  locationCreated: item.locationCreated || null,
151
+ challenges,
146
152
  author: {
147
153
  uniqueId: author.uniqueId,
148
154
  nickname: author.nickname,
@@ -158,11 +158,15 @@ async function discoverTagsForCountryCli(
158
158
  ` 🏥 ${country} health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%\n`,
159
159
  );
160
160
 
161
- // 动态温度调整
162
- if (health.healthStatus === "critical") {
163
- temperature = 0.3;
164
- } else if (health.healthStatus === "warning") {
161
+ // 动态温度调整:卡死时升温打破 LLM 确定性重复
162
+ if (health.consecutiveLowProductiveRounds >= 3) {
163
+ temperature = 1.0;
164
+ } else if (health.consecutiveLowProductiveRounds >= 2) {
165
+ temperature = 0.9;
166
+ } else if (health.healthStatus === "critical") {
165
167
  temperature = 0.5;
168
+ } else if (health.healthStatus === "warning") {
169
+ temperature = 0.6;
166
170
  }
167
171
 
168
172
  // 自动 niche 切换
@@ -166,6 +166,11 @@ export async function fetchTagData(tag, options = {}) {
166
166
  throw new Error("标签页加载失败,TikTok 返回了错误页面");
167
167
  }
168
168
 
169
+ // 检测 CDN/网络拦截:15 秒后仍无任何 API 数据到达,说明被拦截而非真没数据
170
+ if (seenVideoIds.size === 0 && challengeInfo === null) {
171
+ throw new Error("无法获取标签页 API 数据(可能被 CDN 拦截或网络异常)");
172
+ }
173
+
169
174
  let lastCount = 0;
170
175
  let staleRounds = 0;
171
176
  const startTime = Date.now();
@@ -42,7 +42,7 @@ async function processExplore(page, username, options, log) {
42
42
  keepFollow: false,
43
43
  locationCreated: null,
44
44
  latestVideoTime: null,
45
- topRecentVideo: null,
45
+ recentEcVideos: null,
46
46
  noVideo: false,
47
47
  restricted: false,
48
48
  error: null,
@@ -97,6 +97,19 @@ async function processExplore(page, username, options, log) {
97
97
  nowSeconds - v.createTime <= SEVEN_DAYS_SECONDS,
98
98
  );
99
99
  if (recentVideos.length > 0) {
100
+ result.recentEcVideos = recentVideos.map((v) => ({
101
+ id: v.id,
102
+ href: v.href,
103
+ playCount: v.playCount,
104
+ createTime: v.createTime,
105
+ isAd: v.isAd,
106
+ }));
107
+ const maxPlay = recentVideos.reduce(
108
+ (max, v) => (v.playCount > max ? v.playCount : max),
109
+ 0,
110
+ );
111
+ log(` 7天内 EC视频: ${recentVideos.length} 个, 最高播放 ${maxPlay}`);
112
+ // 兼容旧字段 topRecentVideo
100
113
  const topVideo = recentVideos.reduce((max, v) =>
101
114
  v.playCount > max.playCount ? v : max,
102
115
  );
@@ -107,9 +120,6 @@ async function processExplore(page, username, options, log) {
107
120
  createTime: topVideo.createTime,
108
121
  isAd: topVideo.isAd,
109
122
  };
110
- log(
111
- ` 7天内 EC视频最高播放: ${topVideo.playCount} 次播放 (${recentVideos.length} 个EC候选)`,
112
- );
113
123
  }
114
124
 
115
125
  if (videoArray.length <= 0) {
@@ -86,6 +86,7 @@ import {
86
86
  // Tag CRUD
87
87
  import {
88
88
  insertTag,
89
+ insertTagsIfNew,
89
90
  getTagsByStatus,
90
91
  getTagsByCountry,
91
92
  getDeadTags,
@@ -319,7 +320,9 @@ function getTargetUsersFromDb(targetLocations = []) {
319
320
  tt_seller, verified, follower_count, following_count,
320
321
  location_created, latest_video_time, refresh_time,
321
322
  guessed_location, pinned, processed_at, video_count,
322
- no_video, claimed_by, claimed_at, created_at, updated_at
323
+ no_video, claimed_by, claimed_at, created_at, updated_at,
324
+ top_video_play_count, top_video_href, top_video_is_ad,
325
+ recent_ec_videos
323
326
  FROM jobs
324
327
  WHERE tt_seller = 1
325
328
  AND verified = 0
@@ -348,6 +351,8 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
348
351
  search,
349
352
  limit,
350
353
  offset,
354
+ sort,
355
+ sortOrder,
351
356
  } = options;
352
357
  const placeholders = targetLocations.map(() => "?").join(", ");
353
358
  const baseParams = [...targetLocations];
@@ -400,6 +405,7 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
400
405
  top_video_play_count,
401
406
  top_video_href,
402
407
  top_video_is_ad,
408
+ recent_ec_videos,
403
409
  status,
404
410
  sources
405
411
  FROM jobs
@@ -440,6 +446,50 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
440
446
  .prepare(countSql)
441
447
  .get(...params)?.cnt || 0;
442
448
 
449
+ // 服务端排序:当 sort=topVideoPlayCount 时,全量取出后按 EC 最大播放量排序再分页
450
+ if (sort === "topVideoPlayCount") {
451
+ const allRows = getDb()
452
+ .prepare(sql)
453
+ .all(...params)
454
+ .map(mapJobRow);
455
+
456
+ const getMaxPlay = (u) => {
457
+ try {
458
+ const ec =
459
+ typeof u.recentEcVideos === "string"
460
+ ? JSON.parse(u.recentEcVideos)
461
+ : Array.isArray(u.recentEcVideos)
462
+ ? u.recentEcVideos
463
+ : [];
464
+ if (ec.length > 0) {
465
+ return ec.reduce(
466
+ (max, v) => (v.playCount > max ? v.playCount : max),
467
+ 0,
468
+ );
469
+ }
470
+ } catch (e) {}
471
+ return 0;
472
+ };
473
+
474
+ const isAsc = sortOrder === "asc";
475
+ allRows.sort((a, b) => {
476
+ const va = getMaxPlay(a);
477
+ const vb = getMaxPlay(b);
478
+ return isAsc ? va - vb : vb - va;
479
+ });
480
+
481
+ const safeLimit = Math.min(Math.floor(limit), 10000);
482
+ const safeOffset = Math.max(Math.floor(offset), 0);
483
+ const rows = allRows.slice(safeOffset, safeOffset + safeLimit);
484
+
485
+ return {
486
+ total,
487
+ limit: safeLimit,
488
+ offset: safeOffset,
489
+ users: rows,
490
+ };
491
+ }
492
+
443
493
  sql += ` LIMIT ? OFFSET ?`;
444
494
  const safeLimit = Math.min(Math.floor(limit), 10000);
445
495
  const safeOffset = Math.max(Math.floor(offset), 0);
@@ -2413,6 +2463,7 @@ export function createStore(filePath, options = {}) {
2413
2463
  "uniqueId",
2414
2464
  "sources",
2415
2465
  "topRecentVideo", // 单独处理,不进入通用循环
2466
+ "recentEcVideos", // 单独处理,不进入通用循环
2416
2467
  ];
2417
2468
  for (const key of Object.keys(result)) {
2418
2469
  if (extraFields.includes(key)) continue;
@@ -2430,6 +2481,15 @@ export function createStore(filePath, options = {}) {
2430
2481
  user.topVideoHref = result.topRecentVideo.href || null;
2431
2482
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2432
2483
  }
2484
+ // 将 recentEcVideos 数组序列化为 JSON 字符串
2485
+ if (
2486
+ Array.isArray(result.recentEcVideos) &&
2487
+ result.recentEcVideos.length > 0
2488
+ ) {
2489
+ user.recentEcVideos = JSON.stringify(result.recentEcVideos);
2490
+ } else if (result.recentEcVideos === null) {
2491
+ user.recentEcVideos = null;
2492
+ }
2433
2493
  user.sources = [...new Set([...(user.sources || []), "processed"])];
2434
2494
  }
2435
2495
  if (user.status !== oldStatus) markStatsDirty();
@@ -2657,6 +2717,15 @@ export function createStore(filePath, options = {}) {
2657
2717
  user.topVideoHref = result.topRecentVideo.href || null;
2658
2718
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2659
2719
  }
2720
+ // 将 recentEcVideos 数组序列化为 JSON 字符串
2721
+ if (
2722
+ Array.isArray(result.recentEcVideos) &&
2723
+ result.recentEcVideos.length > 0
2724
+ ) {
2725
+ user.recentEcVideos = JSON.stringify(result.recentEcVideos);
2726
+ } else if (result.recentEcVideos === null) {
2727
+ user.recentEcVideos = null;
2728
+ }
2660
2729
  const newUsers = processDiscoveredUsers(result);
2661
2730
  const ret = updateJobInfo(uniqueId, user, false);
2662
2731
  if (ret.error) return { saved: false, error: ret.error };
@@ -2683,6 +2752,15 @@ export function createStore(filePath, options = {}) {
2683
2752
  user.topVideoHref = result.topRecentVideo.href || null;
2684
2753
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2685
2754
  }
2755
+ // 将 recentEcVideos 数组序列化为 JSON 字符串
2756
+ if (
2757
+ Array.isArray(result.recentEcVideos) &&
2758
+ result.recentEcVideos.length > 0
2759
+ ) {
2760
+ user.recentEcVideos = JSON.stringify(result.recentEcVideos);
2761
+ } else if (result.recentEcVideos === null) {
2762
+ user.recentEcVideos = null;
2763
+ }
2686
2764
  const newUsers = processDiscoveredUsers(result);
2687
2765
  return { saved: true, newUsers };
2688
2766
  }
@@ -3601,6 +3679,7 @@ export function createStore(filePath, options = {}) {
3601
3679
  getLlmSampleOffsets, // 获取 LLM 采样偏移量状态
3602
3680
  // Tag 发现与打分
3603
3681
  insertTag,
3682
+ insertTagsIfNew,
3604
3683
  getTagsByStatus,
3605
3684
  getTagsByCountry,
3606
3685
  getDeadTags,
@@ -49,6 +49,7 @@ export const JOB_EXT_COLUMNS = [
49
49
  "top_video_play_count",
50
50
  "top_video_href",
51
51
  "top_video_is_ad",
52
+ "recent_ec_videos",
52
53
  ];
53
54
 
54
55
  // 完整的 jobs 列 = 核心列 + 扩展列
@@ -91,6 +92,7 @@ export const WRITABLE_JOB_COLUMNS = new Set([
91
92
  "top_video_href",
92
93
  "top_video_is_ad",
93
94
  "user_create_time",
95
+ "recent_ec_videos",
94
96
  ]);
95
97
 
96
98
  // ===== Boolean 列(存储时 0/1,读取时转为 true/false/null) =====
@@ -129,6 +129,7 @@ export function initDb(filePath) {
129
129
  top_video_href: "TEXT",
130
130
  top_video_is_ad: "INTEGER",
131
131
  user_create_time: "INTEGER",
132
+ recent_ec_videos: "TEXT",
132
133
  });
133
134
 
134
135
  // ===== jobs_base 表 =====