tt-help-cli-ycl 1.3.43 → 1.3.44

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.43",
3
+ "version": "1.3.44",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,22 +2,11 @@ import { chromium } from "playwright";
2
2
  import { fetchUserCommentsAPI } from "../lib/api-interceptor-comment.js";
3
3
  import { closeCommentPanel } from "../lib/browser/page.js";
4
4
  import { server as defaultServer } from "../lib/constants.js";
5
-
6
- const TARGET_LOCATIONS = [
7
- "CZ",
8
- "GR",
9
- "HU",
10
- "PT",
11
- "ES",
12
- "PL",
13
- "NL",
14
- "BE",
15
- "DE",
16
- "FR",
17
- "IT",
18
- "IE",
19
- "AT",
20
- ];
5
+ import {
6
+ DEFAULT_TARGET_LOCATIONS,
7
+ isLocationInList,
8
+ normalizeLocation,
9
+ } from "../lib/target-locations.js";
21
10
 
22
11
  async function waitForPageReady(page, timeout = 30000) {
23
12
  const startTime = Date.now();
@@ -130,7 +119,7 @@ async function runAutoMode(options) {
130
119
  `\n[Comments Auto] 并行: ${actualParallel}, 间隔: ${actualInterval}s, 评论数: ${actualMaxComments}`,
131
120
  );
132
121
  console.error(`服务器: ${serverUrl}`);
133
- console.error(`目标国家: ${TARGET_LOCATIONS.join(", ")}`);
122
+ console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
134
123
  console.error(`开始循环接收任务...\n`);
135
124
 
136
125
  let browser;
@@ -228,10 +217,10 @@ async function runAutoMode(options) {
228
217
  for (const task of tasks) {
229
218
  try {
230
219
  const { id, href, locationCreated, ttSeller } = task;
231
- const loc = (locationCreated || "").toUpperCase();
220
+ const loc = normalizeLocation(locationCreated);
232
221
 
233
222
  // 检查目标国家
234
- if (loc && !TARGET_LOCATIONS.includes(loc)) {
223
+ if (loc && !isLocationInList(loc, DEFAULT_TARGET_LOCATIONS)) {
235
224
  // 非目标国家,直接 commit 跳过
236
225
  await apiPut(`${serverUrl}/api/comment-task/${id}`);
237
226
  skippedCount++;
@@ -487,12 +476,15 @@ export async function handleComments(options) {
487
476
  ),
488
477
  ];
489
478
 
490
- const guessedLocation = (videoInfo?.locationCreated || "").toUpperCase();
479
+ const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
491
480
  const serverUrl = commentsServer || defaultServer;
492
481
 
493
- if (guessedLocation && !TARGET_LOCATIONS.includes(guessedLocation)) {
482
+ if (
483
+ guessedLocation &&
484
+ !isLocationInList(guessedLocation, DEFAULT_TARGET_LOCATIONS)
485
+ ) {
494
486
  console.error(`\n猜测国家: ${guessedLocation},非目标国家,跳过保存`);
495
- console.error(`目标国家: ${TARGET_LOCATIONS.join(", ")}`);
487
+ console.error(`目标国家: ${DEFAULT_TARGET_LOCATIONS.join(", ")}`);
496
488
  } else {
497
489
  console.error(`\n正在提交 ${authors.length} 个评论作者到服务端...`);
498
490
  console.error(`服务端: ${serverUrl}`);
package/src/lib/args.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { server as defaultServer } from "./constants.js";
3
+ import { DEFAULT_TARGET_LOCATIONS_CSV } from "./target-locations.js";
3
4
 
4
5
  const PRESETS = ["fast", "normal", "slow", "stealth"];
5
6
 
@@ -171,7 +172,7 @@ function parseExploreArgs(args) {
171
172
  let exploreEnableFollow = true;
172
173
  let exploreMaxFollowing = 50;
173
174
  let exploreMaxFollowers = 50;
174
- let exploreLocation = "CZ,GR,HU,PT,PL,NL,BE,DE,FR,IT,ES,IE,AT";
175
+ let exploreLocation = DEFAULT_TARGET_LOCATIONS_CSV;
175
176
  let exploreJobLocations = null;
176
177
  let exploreMaxUsers = 0;
177
178
  let explorePort = null;
@@ -67,19 +67,20 @@ export async function withBrowserRecovery(fn, browser, page, cdpOptions, port) {
67
67
  }
68
68
 
69
69
  /**
70
- * 通过 Cookie 判断登录状态(推荐,最可靠)
71
- * 已登录:存在 sessionid Cookie
72
- * 未登录:不存在 sessionid Cookie
70
+ * 稳定判断登录状态:先检查 sessionid Cookie,再用页面 DOM 做验真。
71
+ * 仅有 sessionid 不足以说明会话仍然有效,因此需要二次确认。
73
72
  */
74
73
  export async function isLoggedIn(page) {
75
74
  const cookies = await page.context().cookies("https://www.tiktok.com");
76
- return cookies.some((c) => c.name === "sessionid");
75
+ const hasSessionId = cookies.some((c) => c.name === "sessionid");
76
+ if (!hasSessionId) return false;
77
+
78
+ return await isLoggedInByDom(page);
77
79
  }
78
80
 
79
81
  /**
80
- * 通过 DOM 元素判断登录状态(备用方案)
81
- * 依赖页面渲染完成,可能存在误判
82
- * @deprecated 推荐使用 isLoggedIn(基于 Cookie)
82
+ * 通过 DOM 元素判断登录状态(验真方案)
83
+ * 依赖页面渲染完成,因此不单独作为主判断,而是和 Cookie 组合使用。
83
84
  */
84
85
  export async function isLoggedInByDom(page) {
85
86
  // 先等客户端渲染完成:登录态元素或登录按钮,哪个先出现就停止等待
@@ -1,6 +1,7 @@
1
1
  import { join, dirname } from "path";
2
2
  import { readFileSync, writeFileSync, existsSync } from "fs";
3
3
  import { fileURLToPath } from "url";
4
+ import { DEFAULT_TARGET_LOCATIONS_CSV } from "./target-locations.js";
4
5
 
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = dirname(__filename);
@@ -121,7 +122,7 @@ const HELP_TEXT = [
121
122
  " 预设: fast, normal(默认), slow, stealth",
122
123
  " 选项:",
123
124
  " --server <URL> 服务端地址,默认 http://127.0.0.1:3001",
124
- " --location <国家代码> 国家筛选,逗号分隔,默认 CZ,GR,HU,PT,PL,NL,BE,DE,FR,IT,ES,IE,AT",
125
+ ` --location <国家代码> 国家筛选,逗号分隔,默认 ${DEFAULT_TARGET_LOCATIONS_CSV}`,
125
126
  " --job-locations <国家> 任务国家筛选,逗号分隔(仅筛选服务端任务)",
126
127
  " --max-comments <数量> 每视频最大评论数,默认 10",
127
128
  " --max-guess <数量> 每视频最大猜你喜欢数,默认 0",
@@ -0,0 +1,61 @@
1
+ const DEFAULT_TARGET_LOCATIONS = [
2
+ "CZ",
3
+ "GR",
4
+ "HU",
5
+ "PT",
6
+ "ES",
7
+ "PL",
8
+ "NL",
9
+ "BE",
10
+ "DE",
11
+ "FR",
12
+ "IT",
13
+ "IE",
14
+ "AT",
15
+ ];
16
+
17
+ const DEFAULT_TARGET_LOCATIONS_CSV = DEFAULT_TARGET_LOCATIONS.join(",");
18
+
19
+ function normalizeLocation(location) {
20
+ if (location == null) return null;
21
+ const normalized = String(location).trim().toUpperCase();
22
+ return normalized || null;
23
+ }
24
+
25
+ function normalizeLocationList(locations, fallback = DEFAULT_TARGET_LOCATIONS) {
26
+ const source = Array.isArray(locations)
27
+ ? locations
28
+ : String(locations == null ? "" : locations).split(",");
29
+ const normalized = source
30
+ .map((location) => normalizeLocation(location))
31
+ .filter(Boolean);
32
+ return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
33
+ }
34
+
35
+ function isLocationInList(location, locations) {
36
+ const normalizedLocation = normalizeLocation(location);
37
+ if (!normalizedLocation) return false;
38
+ return normalizeLocationList(locations).includes(normalizedLocation);
39
+ }
40
+
41
+ function findFirstMatchingLocation(
42
+ locations,
43
+ targetLocations = DEFAULT_TARGET_LOCATIONS,
44
+ ) {
45
+ const normalizedTargetLocations = normalizeLocationList(targetLocations);
46
+ const normalizedLocations = normalizeLocationList(locations, []);
47
+ return (
48
+ normalizedLocations.find((location) =>
49
+ normalizedTargetLocations.includes(location),
50
+ ) || null
51
+ );
52
+ }
53
+
54
+ export {
55
+ DEFAULT_TARGET_LOCATIONS,
56
+ DEFAULT_TARGET_LOCATIONS_CSV,
57
+ findFirstMatchingLocation,
58
+ isLocationInList,
59
+ normalizeLocation,
60
+ normalizeLocationList,
61
+ };
@@ -10,13 +10,18 @@ import {
10
10
  } from "./modules/page-helpers.js";
11
11
  import { extractCommentAuthors } from "./modules/comment-extractor.js";
12
12
  import { extractGuessVideos } from "./modules/guess-extractor.js";
13
+ import {
14
+ DEFAULT_TARGET_LOCATIONS_CSV,
15
+ isLocationInList,
16
+ normalizeLocationList,
17
+ } from "../lib/target-locations.js";
13
18
 
14
19
  async function scrapeSingleVideo(
15
20
  page,
16
21
  maxComments,
17
22
  maxGuess,
18
23
  log,
19
- location = "CZ,GR,HU,PT,PL,NL,BE,DE,FR,IT,ES,IE",
24
+ location = DEFAULT_TARGET_LOCATIONS_CSV,
20
25
  ) {
21
26
  const config = getDelayConfig();
22
27
 
@@ -52,14 +57,8 @@ async function scrapeSingleVideo(
52
57
  let captchaStage = "";
53
58
  let captchaMessage = "";
54
59
 
55
- const locationList = (location || "ES")
56
- .split(",")
57
- .map((s) => s.trim().toUpperCase());
58
- if (
59
- locationList.includes(
60
- userData.locationCreated?.toUpperCase?.() || userData.locationCreated,
61
- )
62
- ) {
60
+ const locationList = normalizeLocationList(location);
61
+ if (isLocationInList(userData.locationCreated, locationList)) {
63
62
  if (maxGuess > 0) {
64
63
  guessVideos = await extractGuessVideos(page, maxGuess);
65
64
  }
@@ -4,6 +4,12 @@ export { ensureBrowserReady };
4
4
  import { getUserInfo, collectVideos } from "../videos/core.js";
5
5
  import { extractFollowAndFollowers } from "./modules/follow-extractor.js";
6
6
  import { extractVideoLocation } from "../lib/scrape.js";
7
+ import {
8
+ DEFAULT_TARGET_LOCATIONS_CSV,
9
+ findFirstMatchingLocation,
10
+ isLocationInList,
11
+ normalizeLocationList,
12
+ } from "../lib/target-locations.js";
7
13
  import {
8
14
  maxFollowing as globalMaxFollowing,
9
15
  maxFollowers as globalMaxFollowers,
@@ -16,7 +22,7 @@ async function processExplore(page, username, options, log) {
16
22
  loggedIn = false, // 由外部传入登录状态,避免每次调用 isLoggedIn(page)
17
23
  maxFollowing = 50,
18
24
  maxFollowers = 50,
19
- location = "CZ,GR,HU,PT,PL,NL,BE,DE,FR,IT,ES,IE",
25
+ location = DEFAULT_TARGET_LOCATIONS_CSV,
20
26
  } = options;
21
27
 
22
28
  const result = {
@@ -35,6 +41,8 @@ async function processExplore(page, username, options, log) {
35
41
  error: null,
36
42
  };
37
43
 
44
+ const locationList = normalizeLocationList(location);
45
+
38
46
  try {
39
47
  log(` 访问 @${username} 主页...`);
40
48
  const videoList = await collectVideos(page, username, maxVideos, log);
@@ -66,9 +74,11 @@ async function processExplore(page, username, options, log) {
66
74
  return result;
67
75
  }
68
76
 
69
- // 从最多 5 个视频并发获取 locationCreated,取众数
77
+ // 从最多 5 个视频并发获取 locationCreated
78
+ // 新规则:采样列表里只要有任一国家命中目标列表,就直接采用该国家;否则回退到众数。
70
79
  const SAMPLE_SIZE = 5;
71
80
  let locationCreated = null;
81
+ let locationDecision = null;
72
82
  const sampleVideos = videoArray.slice(0, SAMPLE_SIZE);
73
83
  if (sampleVideos.length > 0) {
74
84
  const sampleUrls = sampleVideos.map((v) =>
@@ -80,26 +90,34 @@ async function processExplore(page, username, options, log) {
80
90
  log(
81
91
  ` 国家采样(${locations.length}个): [${locations.filter(Boolean).join(", ") || "无数据"}]`,
82
92
  );
93
+ const normalizedLocations = normalizeLocationList(locations, []);
94
+ const matchedTargetLocation = findFirstMatchingLocation(
95
+ normalizedLocations,
96
+ locationList,
97
+ );
83
98
  const freq = {};
84
- for (const loc of locations) {
85
- if (loc) {
86
- const key = loc.toUpperCase();
87
- freq[key] = (freq[key] || 0) + 1;
88
- }
99
+ for (const key of normalizedLocations) {
100
+ freq[key] = (freq[key] || 0) + 1;
89
101
  }
90
102
  const entries = Object.entries(freq).sort((a, b) => b[1] - a[1]);
91
- locationCreated = entries.length > 0 ? entries[0][0] : null;
103
+ if (matchedTargetLocation) {
104
+ locationCreated = matchedTargetLocation;
105
+ locationDecision = "命中目标国家";
106
+ } else if (entries.length > 0) {
107
+ locationCreated = entries[0][0];
108
+ locationDecision = "回退众数";
109
+ }
92
110
  }
93
111
 
94
112
  result.locationCreated = locationCreated || null;
95
- log(` 国家: ${result.locationCreated || "未知"} (众数)`);
113
+ log(
114
+ ` 国家: ${result.locationCreated || "未知"}${locationDecision ? ` (${locationDecision})` : ""}`,
115
+ );
96
116
 
97
117
  // 国家筛选
98
- const locationList = (location || "ES")
99
- .split(",")
100
- .map((s) => s.trim().toUpperCase());
101
- const isTargetLocation = locationList.includes(
102
- result.locationCreated?.toUpperCase?.() || result.locationCreated,
118
+ const isTargetLocation = isLocationInList(
119
+ result.locationCreated,
120
+ locationList,
103
121
  );
104
122
 
105
123
  if (isTargetLocation) {
@@ -1,5 +1,6 @@
1
1
  import { delay, getDelayConfig } from "./page-helpers.js";
2
2
  import { scrollAndCollect } from "./scroll-collector.js";
3
+ import { extractUniqueId, toProfileUrl } from "../../lib/url.js";
3
4
 
4
5
  const FILTER_WORDS = ["主页", "已关注", "粉丝", "推荐"];
5
6
 
@@ -48,7 +49,14 @@ async function waitForListContent(page, minChildren = 1, timeout = 15000) {
48
49
  .catch(() => {});
49
50
  }
50
51
 
51
- async function openFollowModal(page) {
52
+ function getProfileUrlForRetry(page) {
53
+ const currentUrl = page.url();
54
+ const uniqueId = extractUniqueId(currentUrl);
55
+ if (uniqueId) return toProfileUrl(uniqueId);
56
+ return currentUrl;
57
+ }
58
+
59
+ async function openFollowModal(page, log = () => {}) {
52
60
  const tryOpen = async () =>
53
61
  page.evaluate((selectors) => {
54
62
  const clickTarget = (node) => {
@@ -83,7 +91,16 @@ async function openFollowModal(page) {
83
91
  for (let attempt = 1; attempt <= 3; attempt++) {
84
92
  await waitForFollowTrigger(page, attempt === 1 ? 15000 : 8000);
85
93
  opened = await tryOpen();
86
- if (opened) break;
94
+ if (opened) {
95
+ if (attempt >= 2) {
96
+ log(` [关注弹窗] 第 ${attempt} 次已点击关注入口: ${opened}`);
97
+ }
98
+ break;
99
+ }
100
+
101
+ if (attempt >= 2) {
102
+ log(` [关注弹窗] 第 ${attempt} 次仍未找到可点击的关注入口`);
103
+ }
87
104
 
88
105
  await page
89
106
  .evaluate(() => {
@@ -94,6 +111,7 @@ async function openFollowModal(page) {
94
111
  }
95
112
 
96
113
  if (!opened) {
114
+ log(" [关注弹窗] 重试后仍未找到关注入口");
97
115
  throw new Error(
98
116
  "未找到关注入口元素,请确认当前页面为用户主页或页面结构已变化",
99
117
  );
@@ -107,20 +125,31 @@ async function openFollowModal(page) {
107
125
  timeout: 30000,
108
126
  });
109
127
  containerReady = true;
128
+ if (attempt >= 2) {
129
+ log(` [关注弹窗] 第 ${attempt} 次等待后,关注列表容器已出现`);
130
+ }
110
131
  break;
111
132
  } catch (e) {
112
133
  if (attempt === 1) {
113
- // 第一次超时,刷新页面重试
114
- await page.reload({ waitUntil: "domcontentloaded" });
134
+ // 第一次超时,重新 goto 用户主页后重试
135
+ const retryUrl = getProfileUrlForRetry(page);
136
+ log(
137
+ ` [关注弹窗] 第 1 次等待列表超时,准备 goto 主页重试: ${retryUrl} | ${e.message}`,
138
+ );
139
+ await page.goto(retryUrl, { waitUntil: "domcontentloaded" });
115
140
  await delay(2000, 3000);
141
+ log(" [关注弹窗] 主页跳转完成,重新点击关注入口");
116
142
  // 重新点击 follow 入口
117
143
  opened = await tryOpen();
118
144
  if (!opened) {
145
+ log(" [关注弹窗] goto 主页后仍未找到关注入口");
119
146
  throw new Error(
120
- "刷新后仍未找到关注入口元素,请确认当前页面为用户主页",
147
+ "goto 主页后仍未找到关注入口元素,请确认当前页面为用户主页",
121
148
  );
122
149
  }
150
+ log(` [关注弹窗] goto 主页后已重新点击关注入口: ${opened}`);
123
151
  } else {
152
+ log(` [关注弹窗] 第 2 次等待列表仍超时,结束本次提取: ${e.message}`);
124
153
  throw e;
125
154
  }
126
155
  }
@@ -200,7 +229,7 @@ async function extractUsersFromModal(page, maxUsers) {
200
229
  async function extractFollowAndFollowers(page, options = {}) {
201
230
  const { maxFollowing = 999, maxFollowers = 999, log = () => {} } = options;
202
231
 
203
- await openFollowModal(page);
232
+ await openFollowModal(page, log);
204
233
 
205
234
  const following = await extractUsersFromModal(page, maxFollowing);
206
235
  log(` 已关注: ${following.length}`);
@@ -8,6 +8,7 @@ import { detectCaptcha } from "./modules/captcha-handler.js";
8
8
  import { getUserInfo, collectVideos } from "../videos/core.js";
9
9
  import { extractFollowAndFollowers } from "./modules/follow-extractor.js";
10
10
  import { processExplore } from "./explore-core.js";
11
+ import { DEFAULT_TARGET_LOCATIONS_CSV } from "../lib/target-locations.js";
11
12
 
12
13
  export async function processRefresh(page, username, serverUrl, options, log) {
13
14
  const { maxFollowing = 100, maxFollowers = 100, maxVideos = 100 } = options;
@@ -131,7 +132,7 @@ export async function processRefresh(page, username, serverUrl, options, log) {
131
132
  enableFollow: true,
132
133
  maxFollowing: 5,
133
134
  maxFollowers: 5,
134
- location: "CZ,GR,HU,PT,PL,NL,BE,DE,FR,IT,ES,IE",
135
+ location: DEFAULT_TARGET_LOCATIONS_CSV,
135
136
  },
136
137
  log,
137
138
  );
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import Database from "better-sqlite3";
4
+ import { isLocationInList } from "../lib/target-locations.js";
4
5
 
5
6
  // SQLite 用户表(用于判重)
6
7
  let db = null;
@@ -1911,8 +1912,7 @@ export function createStore(filePath) {
1911
1912
  // 国家过滤:如果指定了 locations,只保留 guessedLocation 在列表中的用户
1912
1913
  function locationFilter(u) {
1913
1914
  if (!locations || locations.length === 0) return true;
1914
- const loc = (u.guessedLocation || "").toUpperCase();
1915
- return locations.includes(loc);
1915
+ return isLocationInList(u.guessedLocation, locations);
1916
1916
  }
1917
1917
 
1918
1918
  // 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller(仅登录) > follow > other
@@ -6,22 +6,7 @@ import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { spawn } from "child_process";
8
8
  import { createStore } from "./data-store.js";
9
-
10
- const TARGET_LOCATIONS = [
11
- "CZ",
12
- "GR",
13
- "HU",
14
- "PT",
15
- "ES",
16
- "PL",
17
- "NL",
18
- "BE",
19
- "DE",
20
- "FR",
21
- "IT",
22
- "IE",
23
- "AT",
24
- ];
9
+ import { DEFAULT_TARGET_LOCATIONS } from "../lib/target-locations.js";
25
10
 
26
11
  const __filename = fileURLToPath(import.meta.url);
27
12
 
@@ -40,7 +25,7 @@ const __dirname = dirname(__filename);
40
25
  const publicDir = join(__dirname, "public");
41
26
 
42
27
  function computeStatsIncremental(st) {
43
- return st.getDashboardStats(TARGET_LOCATIONS);
28
+ return st.getDashboardStats(DEFAULT_TARGET_LOCATIONS);
44
29
  }
45
30
 
46
31
  function readBody(req) {
@@ -328,7 +313,7 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
328
313
 
329
314
  if (req.method === "GET" && routePath === "/api/stats") {
330
315
  const stats = computeStatsIncremental(store);
331
- stats.targetLocations = TARGET_LOCATIONS;
316
+ stats.targetLocations = DEFAULT_TARGET_LOCATIONS;
332
317
  sendJSON(res, 200, stats);
333
318
  return;
334
319
  }
@@ -459,7 +444,7 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
459
444
  }
460
445
 
461
446
  if (req.method === "GET" && routePath === "/api/target-users") {
462
- const targetResult = store.getTargetUsers(TARGET_LOCATIONS);
447
+ const targetResult = store.getTargetUsers(DEFAULT_TARGET_LOCATIONS);
463
448
  const targets = targetResult.users;
464
449
  if (req.headers["accept"]?.includes("text/csv")) {
465
450
  const columns = [
@@ -623,7 +608,7 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
623
608
  targetLocation: params.targetLocation,
624
609
  limit: params.limit,
625
610
  offset: params.offset,
626
- targetLocations: TARGET_LOCATIONS,
611
+ targetLocations: DEFAULT_TARGET_LOCATIONS,
627
612
  });
628
613
 
629
614
  if (params.view === "light") {