tt-help-cli-ycl 1.3.32 → 1.3.33

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.32",
3
+ "version": "1.3.33",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,40 @@
1
1
  @ECHO OFF
2
- SETLOCAL
2
+ SETLOCAL EnableDelayedExpansion
3
3
 
4
4
  SET "PACKAGENAME=tt-help-cli-ycl"
5
5
  SET "TARGET_SERVER=http://117.71.53.99:17301"
6
+ SET "LOCAL_IP="
7
+ SET "GET_IP_PS1=%TEMP%\tt_get_local_ip.ps1"
8
+ >"%GET_IP_PS1%" ECHO $ip = $null
9
+ >>"%GET_IP_PS1%" ECHO try {
10
+ >>"%GET_IP_PS1%" ECHO $ip = Get-WmiObject Win32_NetworkAdapterConfiguration ^| Where-Object { $_.IPEnabled -eq $true } ^| ForEach-Object { $_.IPAddress } ^| Where-Object { $_ -match '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -and $_ -ne '127.0.0.1' -and $_ -notlike '169.254.*' } ^| Select-Object -First 1
11
+ >>"%GET_IP_PS1%" ECHO } catch {}
12
+ >>"%GET_IP_PS1%" ECHO if (-not $ip) {
13
+ >>"%GET_IP_PS1%" ECHO try {
14
+ >>"%GET_IP_PS1%" ECHO $ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop ^| Where-Object { $_.IPAddress -ne '127.0.0.1' -and $_.IPAddress -notlike '169.254.*' } ^| Select-Object -First 1 -ExpandProperty IPAddress
15
+ >>"%GET_IP_PS1%" ECHO } catch {}
16
+ >>"%GET_IP_PS1%" ECHO }
17
+ >>"%GET_IP_PS1%" ECHO if (-not $ip) {
18
+ >>"%GET_IP_PS1%" ECHO try {
19
+ >>"%GET_IP_PS1%" ECHO $ip = [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) ^| Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and $_.IPAddressToString -ne '127.0.0.1' -and $_.IPAddressToString -notlike '169.254.*' } ^| Select-Object -First 1 -ExpandProperty IPAddressToString
20
+ >>"%GET_IP_PS1%" ECHO } catch {}
21
+ >>"%GET_IP_PS1%" ECHO }
22
+ >>"%GET_IP_PS1%" ECHO if ($ip) { [Console]::Write($ip) }
23
+ FOR /F "usebackq delims=" %%I IN (`powershell -NoProfile -ExecutionPolicy Bypass -File "%GET_IP_PS1%"`) DO (
24
+ SET "LOCAL_IP=%%I"
25
+ )
26
+
27
+ ECHO [INFO] Local IP: %LOCAL_IP%
28
+
29
+ IF DEFINED LOCAL_IP IF "%LOCAL_IP:~0,11%"=="172.18.154." SET "TARGET_SERVER=http://172.18.154.201:3001"
30
+
31
+ IF NOT DEFINED LOCAL_IP (
32
+ ECHO [INFO] No local IPv4 detected, using public server
33
+ ) ELSE IF "%LOCAL_IP:~0,11%"=="172.18.154." (
34
+ ECHO [INFO] Intranet IP detected, using intranet server
35
+ ) ELSE (
36
+ ECHO [INFO] No intranet IP detected, using public server
37
+ )
6
38
  SET "CONFIG_PATH=%USERPROFILE%\.tt-help.json"
7
39
 
8
40
  ECHO ========================================
@@ -44,7 +76,7 @@ IF NOT DEFINED INSTALLED_VERSION (
44
76
  REM ---------- 2. Check/set server config ----------
45
77
  SET "CURRENT_SERVER="
46
78
  IF EXIST "%CONFIG_PATH%" (
47
- FOR /F "delims=" %%S IN ('node -e "try{const c=JSON.parse(require('fs').readFileSync('%CONFIG_PATH%','utf-8'));console.log(c.server||'')}catch(e){}" 2^>nul') DO SET "CURRENT_SERVER=%%S"
79
+ FOR /F "usebackq delims=" %%S IN (`powershell -NoProfile -Command "$p=$env:CONFIG_PATH; if (Test-Path $p) { try { $cfg = Get-Content $p -Raw | ConvertFrom-Json; if ($null -ne $cfg.server) { [Console]::Write($cfg.server) } } catch {} }"`) DO SET "CURRENT_SERVER=%%S"
48
80
  )
49
81
 
50
82
  IF "%CURRENT_SERVER%"=="%TARGET_SERVER%" (
@@ -66,3 +98,4 @@ ECHO ========================================
66
98
  ECHO Starting tt-help explore
67
99
  ECHO ========================================
68
100
  tt-help explore --port 9223 --profile p9223
101
+ DEL "%GET_IP_PS1%" 2>NUL
@@ -1,5 +1,5 @@
1
1
  @ECHO OFF
2
- SETLOCAL
2
+ SETLOCAL EnableDelayedExpansion
3
3
 
4
4
  SET "PACKAGENAME=tt-help-cli-ycl"
5
5
  SET "CONFIG_PATH=%USERPROFILE%\.tt-help.json"
@@ -11,16 +11,38 @@ ECHO ========================================
11
11
  REM ---------- 0. Auto-select server based on local IP ----------
12
12
  SET "TARGET_SERVER=http://117.71.53.99:17301"
13
13
  SET "LOCAL_IP="
14
- FOR /F "delims=" %%I IN ('cmd /c "ipconfig ^| findstr /R /C:"172\.18\.154\.[0-9]+""') DO (
14
+ SET "GET_IP_PS1=%TEMP%\tt_get_local_ip.ps1"
15
+ >"%GET_IP_PS1%" ECHO $ip = $null
16
+ >>"%GET_IP_PS1%" ECHO try {
17
+ >>"%GET_IP_PS1%" ECHO $ip = Get-WmiObject Win32_NetworkAdapterConfiguration ^| Where-Object { $_.IPEnabled -eq $true } ^| ForEach-Object { $_.IPAddress } ^| Where-Object { $_ -match '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -and $_ -ne '127.0.0.1' -and $_ -notlike '169.254.*' } ^| Select-Object -First 1
18
+ >>"%GET_IP_PS1%" ECHO } catch {}
19
+ >>"%GET_IP_PS1%" ECHO if (-not $ip) {
20
+ >>"%GET_IP_PS1%" ECHO try {
21
+ >>"%GET_IP_PS1%" ECHO $ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop ^| Where-Object { $_.IPAddress -ne '127.0.0.1' -and $_.IPAddress -notlike '169.254.*' } ^| Select-Object -First 1 -ExpandProperty IPAddress
22
+ >>"%GET_IP_PS1%" ECHO } catch {}
23
+ >>"%GET_IP_PS1%" ECHO }
24
+ >>"%GET_IP_PS1%" ECHO if (-not $ip) {
25
+ >>"%GET_IP_PS1%" ECHO try {
26
+ >>"%GET_IP_PS1%" ECHO $ip = [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) ^| Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and $_.IPAddressToString -ne '127.0.0.1' -and $_.IPAddressToString -notlike '169.254.*' } ^| Select-Object -First 1 -ExpandProperty IPAddressToString
27
+ >>"%GET_IP_PS1%" ECHO } catch {}
28
+ >>"%GET_IP_PS1%" ECHO }
29
+ >>"%GET_IP_PS1%" ECHO if ($ip) { [Console]::Write($ip) }
30
+ FOR /F "usebackq delims=" %%I IN (`powershell -NoProfile -ExecutionPolicy Bypass -File "%GET_IP_PS1%"`) DO (
15
31
  SET "LOCAL_IP=%%I"
16
- SET "TARGET_SERVER=http://172.18.154.201:3001"
17
32
  )
18
33
 
34
+ ECHO [INFO] Local IP: %LOCAL_IP%
35
+
36
+ IF DEFINED LOCAL_IP IF "%LOCAL_IP:~0,11%"=="172.18.154." SET "TARGET_SERVER=http://172.18.154.201:3001"
37
+
19
38
  IF NOT DEFINED LOCAL_IP (
20
- ECHO [INFO] No intranet IP detected, using public server
21
- ) ELSE (
39
+ ECHO [INFO] No local IPv4 detected, using public server
40
+ ) ELSE IF "%LOCAL_IP:~0,11%"=="172.18.154." (
22
41
  ECHO [INFO] Intranet IP detected, using intranet server
42
+ ) ELSE (
43
+ ECHO [INFO] No intranet IP detected, using public server
23
44
  )
45
+ DEL "%GET_IP_PS1%" 2>NUL
24
46
 
25
47
  REM ---------- 1. Check/install latest version ----------
26
48
  FOR /F "delims=" %%V IN ('npm view %PACKAGENAME% version 2^>nul') DO SET "LATEST_VERSION=%%V"
@@ -57,7 +79,7 @@ IF NOT DEFINED INSTALLED_VERSION (
57
79
  REM ---------- 2. Check/set server config ----------
58
80
  SET "CURRENT_SERVER="
59
81
  IF EXIST "%CONFIG_PATH%" (
60
- FOR /F "delims=" %%S IN ('node -e "try{const c=JSON.parse(require('fs').readFileSync('%CONFIG_PATH%','utf-8'));console.log(c.server||'')}catch(e){}" 2^>nul') DO SET "CURRENT_SERVER=%%S"
82
+ FOR /F "usebackq delims=" %%S IN (`powershell -NoProfile -Command "$p=$env:CONFIG_PATH; if (Test-Path $p) { try { $cfg = Get-Content $p -Raw | ConvertFrom-Json; if ($null -ne $cfg.server) { [Console]::Write($cfg.server) } } catch {} }"`) DO SET "CURRENT_SERVER=%%S"
61
83
  )
62
84
 
63
85
  IF "%CURRENT_SERVER%"=="%TARGET_SERVER%" (
@@ -85,13 +107,21 @@ IF "%BASE_PORT%"=="" SET "BASE_PORT=9222"
85
107
  SET /P "PORT_COUNT=Port count (--port-count) [default: 10]: "
86
108
  IF "%PORT_COUNT%"=="" SET "PORT_COUNT=10"
87
109
 
88
- REM ---------- 4. Start tt-help explore ----------
110
+ REM ---------- 4. Get user ID from config and compose ----------
111
+ SET "CONFIG_USER_ID=user"
112
+ IF EXIST "%CONFIG_PATH%" (
113
+ FOR /F "usebackq delims=" %%S IN (`powershell -NoProfile -Command "$p=$env:CONFIG_PATH; if (Test-Path $p) { try { $cfg = Get-Content $p -Raw | ConvertFrom-Json; if ($null -ne $cfg.userId) { [Console]::Write($cfg.userId) } } catch {} }"`) DO SET "CONFIG_USER_ID=%%S"
114
+ )
115
+ CALL SET "USER_ID=%%CONFIG_USER_ID%%_%%BASE_PORT%%_%%PORT_COUNT%%"
116
+
117
+ REM ---------- 5. Start tt-help explore ----------
89
118
  ECHO.
90
119
  ECHO ========================================
91
120
  ECHO Starting tt-help explore
92
121
  ECHO ========================================
93
- ECHO Base port: %BASE_PORT%
94
- ECHO Port count: %PORT_COUNT%
122
+ CALL ECHO User ID: %%USER_ID%%
123
+ CALL ECHO Base port: %%BASE_PORT%%
124
+ CALL ECHO Port count: %%PORT_COUNT%%
95
125
  ECHO Speed: stealth (slowest)
96
126
  ECHO ========================================
97
- tt-help explore stealth --base-port %BASE_PORT% --port-count %PORT_COUNT%
127
+ CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%%
@@ -6,8 +6,41 @@ Write-Host " tt-help-cli-ycl 一键启动脚本 (Windows)"
6
6
  Write-Host "========================================"
7
7
 
8
8
  # ---------- 0. 根据本机 IP 自动选择 server ----------
9
- $localIp = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -match '^172\.18\.154\.' }).IPAddress
10
- if ($localIp) {
9
+ $localIp = $null
10
+ try {
11
+ $localIp = Get-WmiObject Win32_NetworkAdapterConfiguration |
12
+ Where-Object { $_.IPEnabled -eq $true } |
13
+ ForEach-Object { $_.IPAddress } |
14
+ Where-Object {
15
+ $_ -match '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -and
16
+ $_ -ne '127.0.0.1' -and
17
+ $_ -notlike '169.254.*'
18
+ } |
19
+ Select-Object -First 1
20
+ } catch {
21
+ }
22
+ if (-not $localIp) {
23
+ try {
24
+ $localIp = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
25
+ Where-Object { $_.IPAddress -ne '127.0.0.1' -and $_.IPAddress -notlike '169.254.*' } |
26
+ Select-Object -First 1 -ExpandProperty IPAddress
27
+ } catch {
28
+ }
29
+ }
30
+ if (-not $localIp) {
31
+ try {
32
+ $localIp = [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName()) |
33
+ Where-Object {
34
+ $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and
35
+ $_.IPAddressToString -ne '127.0.0.1' -and
36
+ $_.IPAddressToString -notlike '169.254.*'
37
+ } |
38
+ Select-Object -First 1 -ExpandProperty IPAddressToString
39
+ } catch {
40
+ }
41
+ }
42
+ Write-Host "[提示] 本机 IP: $(if ($localIp) { $localIp } else { '未检测到' })"
43
+ if ($localIp -and $localIp -match '^172\.18\.154\.') {
11
44
  $targetServer = "http://172.18.154.201:3001"
12
45
  Write-Host "[提示] 检测到内网 IP: $localIp,使用内网 server"
13
46
  } else {
@@ -94,14 +127,27 @@ $basePort = if ($inputBasePort) { $inputBasePort } else { "9222" }
94
127
  $inputPortCount = Read-Host "端口数量 (--port-count) [默认: 10]"
95
128
  $portCount = if ($inputPortCount) { $inputPortCount } else { "10" }
96
129
 
97
- # ---------- 4. 启动 tt-help explore ----------
130
+ # ---------- 4. 获取配置中的用户编号并拼接 ----------
131
+ $configUserId = ""
132
+ if (Test-Path $configPath) {
133
+ try {
134
+ $config = Get-Content $configPath -Raw | ConvertFrom-Json
135
+ $configUserId = $config.userId
136
+ } catch {
137
+ $configUserId = ""
138
+ }
139
+ }
140
+ if (-not $configUserId) { $configUserId = "user" }
141
+ $userId = "${configUserId}_${basePort}_${portCount}"
142
+
143
+ # ---------- 5. 启动 tt-help explore ----------
98
144
  Write-Host ""
99
145
  Write-Host "========================================"
100
146
  Write-Host " 启动 tt-help explore"
101
147
  Write-Host "========================================"
148
+ Write-Host " 用户编号: $userId"
102
149
  Write-Host " 起始端口: $basePort"
103
150
  Write-Host " 端口数量: $portCount"
104
151
  Write-Host " 速度: stealth (最慢)"
105
152
  Write-Host "========================================"
106
- tt-help explore stealth --base-port $basePort --port-count $portCount
107
- tt-help explore
153
+ tt-help explore stealth --user-id $userId --base-port $basePort --port-count $portCount
@@ -8,9 +8,11 @@ echo " tt-help-cli-ycl 一键启动脚本 (macOS)"
8
8
  echo "========================================"
9
9
 
10
10
  # ---------- 0. 根据本机 IP 自动选择 server ----------
11
- LOCAL_IP=$(ifconfig | grep -oE 'inet (172\.18\.154\.[0-9]+)' | head -1 | awk '{print $2}')
11
+ LOCAL_IP=$(ifconfig | awk '/inet / && $2 != "127.0.0.1" { print $2; exit }')
12
12
 
13
- if [ -n "$LOCAL_IP" ]; then
13
+ echo "[提示] 本机 IP: ${LOCAL_IP:-未检测到}"
14
+
15
+ if [[ "$LOCAL_IP" =~ ^172\.18\.154\.[0-9]+$ ]]; then
14
16
  TARGET_SERVER="http://172.18.154.201:3001"
15
17
  echo "[提示] 检测到内网 IP: $LOCAL_IP,使用内网 server"
16
18
  else
@@ -81,18 +83,31 @@ echo "========================================"
81
83
  echo " 参数配置"
82
84
  echo "========================================"
83
85
 
84
- read -p "起始端口 (--base-port) [默认: 9222]: " INPUT_BASE_PORT
86
+ printf "起始端口 (--base-port) [默认: 9222]: "
87
+ read INPUT_BASE_PORT
85
88
  BASE_PORT="${INPUT_BASE_PORT:-9222}"
86
89
 
87
- read -p "端口数量 (--port-count) [默认: 10]: " INPUT_PORT_COUNT
90
+ printf "端口数量 (--port-count) [默认: 10]: "
91
+ read INPUT_PORT_COUNT
88
92
  PORT_COUNT="${INPUT_PORT_COUNT:-10}"
89
93
 
94
+ # ---------- 4. 获取配置中的用户编号并拼接 ----------
95
+ CONFIG_USER_ID=""
96
+ if [ -f "$CONFIG_PATH" ]; then
97
+ CONFIG_USER_ID=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('$CONFIG_PATH','utf-8'));console.log(c.userId||'')}catch(e){}" 2>/dev/null)
98
+ fi
99
+ if [ -z "$CONFIG_USER_ID" ]; then
100
+ CONFIG_USER_ID="user"
101
+ fi
102
+ USER_ID="${CONFIG_USER_ID}_${BASE_PORT}_${PORT_COUNT}"
103
+
90
104
  echo ""
91
105
  echo "========================================"
92
106
  echo " 启动 tt-help explore"
93
107
  echo "========================================"
108
+ echo " 用户编号: $USER_ID"
94
109
  echo " 起始端口: $BASE_PORT"
95
110
  echo " 端口数量: $PORT_COUNT"
96
111
  echo " 速度: stealth (最慢)"
97
112
  echo "========================================"
98
- tt-help explore stealth --base-port "$BASE_PORT" --port-count "$PORT_COUNT"
113
+ tt-help explore stealth --user-id "$USER_ID" --base-port "$BASE_PORT" --port-count "$PORT_COUNT"
@@ -68,6 +68,7 @@ export async function handleExplore(options) {
68
68
  exploreMaxFollowing,
69
69
  exploreMaxFollowers,
70
70
  exploreLocation,
71
+ exploreJobLocations,
71
72
  exploreMaxUsers,
72
73
  serverUrl,
73
74
  explorePort,
@@ -96,6 +97,7 @@ export async function handleExplore(options) {
96
97
  }
97
98
 
98
99
  console.error(`\n国家筛选: ${exploreLocation}`);
100
+ if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
99
101
  console.error(`视频采集: ${exploreMaxVideos || 1}`);
100
102
  console.error(`关注/粉丝: ${exploreEnableFollow ? "启用" : "禁用"}`);
101
103
  console.error(`服务器: ${serverUrl}(断开会自动重连)`);
@@ -206,9 +208,10 @@ export async function handleExplore(options) {
206
208
  const FOLLOW_BLOCK_THRESHOLD = 10 * 60 * 1000; // 10分钟
207
209
 
208
210
  while (true) {
209
- const job = await apiGet(
210
- `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}`,
211
- );
211
+ const jobQuery = exploreJobLocations
212
+ ? `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}&locations=${encodeURIComponent(exploreJobLocations)}`
213
+ : `${serverUrl}/api/job?userId=${encodeURIComponent(userId)}`;
214
+ const job = await apiGet(jobQuery);
212
215
  if (!job.hasJob) break;
213
216
 
214
217
  const username = job.user.uniqueId;
@@ -78,16 +78,21 @@ async function processAPIResponse(
78
78
  async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
79
79
  const url = `https://www.tiktok.com/@${username}`;
80
80
  let apiRequestUrl = null;
81
+ let sawApiRequest = false;
82
+ let sawApiResponse = false;
81
83
 
82
84
  const responseHandler = async (response) => {
83
85
  if (response.url().includes("/api/post/item_list/")) {
84
- // 只记录 URL,数据通过 waitForResponse 获取
86
+ sawApiResponse = true;
85
87
  }
86
88
  };
87
89
 
88
90
  const requestHandler = (request) => {
89
91
  if (request.url().includes("/api/post/item_list/") && !apiRequestUrl) {
92
+ sawApiRequest = true;
90
93
  apiRequestUrl = request.url();
94
+ } else if (request.url().includes("/api/post/item_list/")) {
95
+ sawApiRequest = true;
91
96
  }
92
97
  };
93
98
 
@@ -106,29 +111,88 @@ async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
106
111
  { timeout: apiTimeout },
107
112
  );
108
113
 
114
+ sawApiResponse = true;
109
115
  const data = await response.json();
110
116
  const elapsed = Date.now() - t0;
111
- return { data, apiRequestUrl, elapsed };
117
+ return { data, apiRequestUrl, elapsed, sawApiRequest, sawApiResponse };
112
118
  } catch (e) {
113
119
  const elapsed = Date.now() - t0;
114
- return { data: null, apiRequestUrl, elapsed, error: e.message };
120
+ return {
121
+ data: null,
122
+ apiRequestUrl,
123
+ elapsed,
124
+ error: e.message,
125
+ sawApiRequest,
126
+ sawApiResponse,
127
+ };
115
128
  } finally {
116
129
  page.off("response", responseHandler);
117
130
  page.off("request", requestHandler);
118
131
  }
119
132
  }
120
133
 
134
+ /**
135
+ * 软重试:不重新 goto,直接在当前页继续等待 item_list API。
136
+ */
137
+ async function tryWaitCurrentPageAPI(page, apiTimeout = 12000) {
138
+ let apiRequestUrl = null;
139
+ let sawApiRequest = false;
140
+ let sawApiResponse = false;
141
+
142
+ const requestHandler = (request) => {
143
+ if (request.url().includes("/api/post/item_list/") && !apiRequestUrl) {
144
+ sawApiRequest = true;
145
+ apiRequestUrl = request.url();
146
+ } else if (request.url().includes("/api/post/item_list/")) {
147
+ sawApiRequest = true;
148
+ }
149
+ };
150
+
151
+ const responseHandler = (response) => {
152
+ if (response.url().includes("/api/post/item_list/")) {
153
+ sawApiResponse = true;
154
+ }
155
+ };
156
+
157
+ const t0 = Date.now();
158
+ page.on("request", requestHandler);
159
+ page.on("response", responseHandler);
160
+
161
+ try {
162
+ const response = await page.waitForResponse(
163
+ (res) => res.url().includes("/api/post/item_list/"),
164
+ { timeout: apiTimeout },
165
+ );
166
+
167
+ sawApiResponse = true;
168
+ const data = await response.json();
169
+ const elapsed = Date.now() - t0;
170
+ return { data, apiRequestUrl, elapsed, sawApiRequest, sawApiResponse };
171
+ } catch (e) {
172
+ const elapsed = Date.now() - t0;
173
+ return {
174
+ data: null,
175
+ apiRequestUrl,
176
+ elapsed,
177
+ error: e.message,
178
+ sawApiRequest,
179
+ sawApiResponse,
180
+ };
181
+ } finally {
182
+ page.off("request", requestHandler);
183
+ page.off("response", responseHandler);
184
+ }
185
+ }
186
+
121
187
  /**
122
188
  * 通过拦截 TikTok 内部 API 获取用户视频列表
123
189
  * 比 DOM 滚动解析快 5-10 倍
124
190
  *
125
191
  * 弱网优化:
192
+ * - 第 1 轮:正常 goto,等待 15s
193
+ * - 第 1.5 轮:不重新 goto,留在当前页继续等待 12s
194
+ * - 第 2 轮:重新 goto,等待 30s
126
195
  * - 使用 waitForResponse 精确等待 API 响应(而非固定超时)
127
- * - 最多重试 3 次,递增等待 + 递增超时:
128
- * - 第 1 次:等待 0s, 超时 15s → 正常尝试
129
- * - 第 2 次:等待 3s, 超时 20s → 短暂波动恢复
130
- * - 第 3 次:等待 10s, 超时 30s → 网络缓慢给更多时间
131
- * - 每次重试前清理拦截器
132
196
  *
133
197
  * @param {import('playwright').Page} page - Playwright page (CDP 连接)
134
198
  * @param {string} username - TikTok 用户名
@@ -139,13 +203,6 @@ async function tryInterceptAPI(page, username, log, apiTimeout = 15000) {
139
203
  */
140
204
  async function fetchUserVideosAPI(page, username, maxVideos, log) {
141
205
  const items = [];
142
- const maxRetries = 3;
143
- // 重试策略:[等待时间(ms), API超时(ms)]
144
- const retryStrategy = [
145
- [0, 15000], // 第 1 次:不等待,15s 超时
146
- [3000, 20000], // 第 2 次:等 3s,20s 超时(短暂波动)
147
- [10000, 30000], // 第 3 次:等 10s,30s 超时(网络缓慢)
148
- ];
149
206
  const noRetryErrors = new Set([
150
207
  "service_error",
151
208
  "not_found",
@@ -153,22 +210,39 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
153
210
  "login_required",
154
211
  ]);
155
212
 
156
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
157
- const [, apiTimeout] = retryStrategy[attempt - 1];
158
- if (attempt > 1) {
159
- log(
160
- ` [API拦截] 重试 ${attempt}/${maxRetries} (超时 ${apiTimeout / 1000}s)`,
161
- );
162
- } else {
163
- log(` [API拦截] 获取 @${username} 视频 ...`);
164
- }
213
+ const attempts = [
214
+ {
215
+ label: ` [API拦截] 获取 @${username} 视频 ...`,
216
+ mode: "goto",
217
+ apiTimeout: 15000,
218
+ },
219
+ {
220
+ label: " [API拦截] 首轮未命中,当前页继续补等 12s ...",
221
+ mode: "wait",
222
+ apiTimeout: 12000,
223
+ },
224
+ {
225
+ label: " [API拦截] 重新加载主页后重试 (超时 30s)",
226
+ mode: "goto",
227
+ apiTimeout: 30000,
228
+ },
229
+ ];
165
230
 
166
- const { data, apiRequestUrl, elapsed, error } = await tryInterceptAPI(
167
- page,
168
- username,
169
- log,
170
- apiTimeout,
171
- );
231
+ for (let attempt = 0; attempt < attempts.length; attempt++) {
232
+ const currentAttempt = attempts[attempt];
233
+ log(currentAttempt.label);
234
+
235
+ const {
236
+ data,
237
+ apiRequestUrl,
238
+ elapsed,
239
+ error,
240
+ sawApiRequest,
241
+ sawApiResponse,
242
+ } =
243
+ currentAttempt.mode === "goto"
244
+ ? await tryInterceptAPI(page, username, log, currentAttempt.apiTimeout)
245
+ : await tryWaitCurrentPageAPI(page, currentAttempt.apiTimeout);
172
246
 
173
247
  if (data && Array.isArray(data.itemList) && data.itemList.length > 0) {
174
248
  // 成功拦截且拿到了视频,处理数据
@@ -208,20 +282,19 @@ async function fetchUserVideosAPI(page, username, maxVideos, log) {
208
282
  return new Map();
209
283
  }
210
284
 
285
+ log(
286
+ ` [API拦截] 未命中: request=${sawApiRequest ? "Y" : "N"}, response=${sawApiResponse ? "Y" : "N"}, elapsed=${elapsed}ms${error ? `, error=${error}` : ""}${apiRequestUrl ? ", apiUrl=Y" : ", apiUrl=N"}`,
287
+ );
288
+
211
289
  // 最后一次重试也失败了
212
- if (attempt === maxRetries) {
290
+ if (attempt === attempts.length - 1) {
213
291
  if (!pageError) {
214
292
  throw new Error(
215
- `@${username} 页面异常(${maxRetries} 次重试均未拦截到 API)`,
293
+ `@${username} 页面异常(首轮 + 补等 + 重载后仍未拦截到 API)`,
216
294
  );
217
295
  }
218
296
  return new Map();
219
297
  }
220
-
221
- // 还有重试次数,等待后重试
222
- const [waitTime] = retryStrategy[attempt - 1];
223
- log(` [API拦截] 失败,${waitTime / 1000}s 后重试...`);
224
- await new Promise((r) => setTimeout(r, waitTime));
225
298
  }
226
299
  }
227
300
 
package/src/lib/args.js CHANGED
@@ -171,6 +171,7 @@ function parseExploreArgs(args) {
171
171
  let exploreMaxFollowing = 50;
172
172
  let exploreMaxFollowers = 50;
173
173
  let exploreLocation = "PL,NL,BE,DE,FR,IT,ES,IE";
174
+ let exploreJobLocations = null;
174
175
  let exploreMaxUsers = 0;
175
176
  let explorePort = null;
176
177
  let exploreBasePort = null;
@@ -191,6 +192,8 @@ function parseExploreArgs(args) {
191
192
  exploreMaxGuess = parseInt(args[++i]) || 0;
192
193
  } else if (arg === "--location") {
193
194
  exploreLocation = args[++i];
195
+ } else if (arg === "--job-locations") {
196
+ exploreJobLocations = args[++i];
194
197
  } else if (arg === "--enable-follow") {
195
198
  exploreEnableFollow = true;
196
199
  } else if (arg === "--disable-follow") {
@@ -242,6 +245,7 @@ function parseExploreArgs(args) {
242
245
  exploreMaxFollowing,
243
246
  exploreMaxFollowers,
244
247
  exploreLocation,
248
+ exploreJobLocations,
245
249
  serverUrl,
246
250
  exploreMaxUsers,
247
251
  explorePort,
@@ -3,6 +3,36 @@ import { scrollAndCollect } from "./scroll-collector.js";
3
3
 
4
4
  const FILTER_WORDS = ["主页", "已关注", "粉丝", "推荐"];
5
5
 
6
+ const FOLLOW_TRIGGER_SELECTORS = [
7
+ "[data-e2e=following]",
8
+ 'a[href$="/following"]',
9
+ 'a[href*="/following"]',
10
+ '[data-e2e*="following"]',
11
+ ];
12
+
13
+ async function waitForFollowTrigger(page, timeout = 15000) {
14
+ await page
15
+ .waitForFunction(
16
+ (selectors) => {
17
+ for (const selector of selectors) {
18
+ if (document.querySelector(selector)) return true;
19
+ }
20
+
21
+ const textMatchers = [/^关注$/, /^Following$/i, /^已关注$/];
22
+ const nodes = document.querySelectorAll("a,button,div,span");
23
+ for (const node of nodes) {
24
+ const text = (node.textContent || "").trim();
25
+ if (textMatchers.some((matcher) => matcher.test(text))) return true;
26
+ }
27
+
28
+ return false;
29
+ },
30
+ FOLLOW_TRIGGER_SELECTORS,
31
+ { timeout },
32
+ )
33
+ .catch(() => {});
34
+ }
35
+
6
36
  async function waitForListContent(page, minChildren = 1, timeout = 15000) {
7
37
  await page
8
38
  .waitForFunction(
@@ -19,14 +49,58 @@ async function waitForListContent(page, minChildren = 1, timeout = 15000) {
19
49
  }
20
50
 
21
51
  async function openFollowModal(page) {
22
- const el = await page.$("[data-e2e=following]");
23
- if (!el) {
52
+ const tryOpen = async () =>
53
+ page.evaluate((selectors) => {
54
+ const clickTarget = (node) => {
55
+ if (!node) return false;
56
+ const clickable =
57
+ node.closest('a,button,[role="button"]') ||
58
+ node.parentElement ||
59
+ node;
60
+ clickable.click();
61
+ return true;
62
+ };
63
+
64
+ for (const selector of selectors) {
65
+ const node = document.querySelector(selector);
66
+ if (clickTarget(node)) return selector;
67
+ }
68
+
69
+ const textMatchers = [/^关注$/, /^Following$/i, /^已关注$/];
70
+ const nodes = Array.from(document.querySelectorAll("a,button,div,span"));
71
+ for (const node of nodes) {
72
+ const text = (node.textContent || "").trim();
73
+ if (!text) continue;
74
+ if (textMatchers.some((matcher) => matcher.test(text))) {
75
+ if (clickTarget(node)) return `text:${text}`;
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }, FOLLOW_TRIGGER_SELECTORS);
81
+
82
+ let opened = null;
83
+ for (let attempt = 1; attempt <= 3; attempt++) {
84
+ await waitForFollowTrigger(page, attempt === 1 ? 15000 : 8000);
85
+ opened = await tryOpen();
86
+ if (opened) break;
87
+
88
+ await page
89
+ .evaluate(() => {
90
+ window.scrollTo({ top: 0, behavior: "instant" });
91
+ })
92
+ .catch(() => {});
93
+ await delay(800, 1500);
94
+ }
95
+
96
+ if (!opened) {
24
97
  throw new Error(
25
- "未找到 [data-e2e=following] 元素,请确认当前页面为用户主页",
98
+ "未找到关注入口元素,请确认当前页面为用户主页或页面结构已变化",
26
99
  );
27
100
  }
28
- await el.evaluate((el) => el.parentElement.click());
29
- await page.waitForSelector("[class*=DivUserListContainer]", { timeout: 30000 });
101
+ await page.waitForSelector("[class*=DivUserListContainer]", {
102
+ timeout: 30000,
103
+ });
30
104
  await waitForListContent(page, 1, 5000);
31
105
  }
32
106
 
@@ -41,7 +115,9 @@ async function switchToFollowersTab(page) {
41
115
  }
42
116
  throw new Error("未找到粉丝 Tab");
43
117
  });
44
- await page.waitForSelector("[class*=DivUserListContainer]", { timeout: 30000 });
118
+ await page.waitForSelector("[class*=DivUserListContainer]", {
119
+ timeout: 30000,
120
+ });
45
121
  await waitForListContent(page, 1, 5000);
46
122
  }
47
123