tt-help-cli-ycl 1.3.44 → 1.3.46

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.
Files changed (67) hide show
  1. package/README.md +33 -33
  2. package/cli.js +9 -9
  3. package/package.json +52 -52
  4. package/scripts/run-explore copy.bat +101 -101
  5. package/scripts/run-explore.bat +134 -134
  6. package/scripts/run-explore.ps1 +159 -159
  7. package/scripts/run-explore.sh +121 -121
  8. package/src/cli/attach.js +331 -301
  9. package/src/cli/auto.js +265 -265
  10. package/src/cli/comments.js +620 -620
  11. package/src/cli/config.js +170 -170
  12. package/src/cli/db-import.js +51 -51
  13. package/src/cli/explore.js +555 -555
  14. package/src/cli/info.js +10 -16
  15. package/src/cli/open.js +111 -111
  16. package/src/cli/progress.js +111 -111
  17. package/src/cli/refresh.js +288 -288
  18. package/src/cli/scrape.js +47 -47
  19. package/src/cli/utils.js +18 -18
  20. package/src/cli/videos.js +41 -41
  21. package/src/cli/videostats.js +196 -196
  22. package/src/cli/watch.js +30 -30
  23. package/src/cli/webserver.js +19 -0
  24. package/src/lib/api-interceptor.js +161 -161
  25. package/src/lib/args.js +809 -771
  26. package/src/lib/browser/anti-detect.js +23 -23
  27. package/src/lib/browser/cdp.js +261 -261
  28. package/src/lib/browser/health-checker.js +114 -114
  29. package/src/lib/browser/launch.js +43 -43
  30. package/src/lib/browser/page.js +184 -184
  31. package/src/lib/constants.js +297 -285
  32. package/src/lib/delay.js +54 -54
  33. package/src/lib/explore-fetch.js +118 -118
  34. package/src/lib/fetcher.js +45 -45
  35. package/src/lib/filter.js +66 -66
  36. package/src/lib/io.js +54 -54
  37. package/src/lib/output.js +80 -80
  38. package/src/lib/page-error-detector.js +109 -109
  39. package/src/lib/parse-ssr.mjs +69 -69
  40. package/src/lib/parser.js +47 -47
  41. package/src/lib/retry.js +45 -45
  42. package/src/lib/scrape.js +90 -89
  43. package/src/lib/target-locations.js +61 -61
  44. package/src/lib/tiktok-scraper.mjs +160 -106
  45. package/src/lib/url.js +52 -52
  46. package/src/main.js +73 -70
  47. package/src/npm-main.js +70 -69
  48. package/src/scraper/auto-core.js +203 -203
  49. package/src/scraper/core.js +255 -255
  50. package/src/scraper/explore-core.js +208 -208
  51. package/src/scraper/modules/captcha-handler.js +114 -114
  52. package/src/scraper/modules/follow-extractor.js +250 -250
  53. package/src/scraper/modules/guess-extractor.js +51 -51
  54. package/src/scraper/modules/page-helpers.js +48 -48
  55. package/src/scraper/refresh-core.js +213 -213
  56. package/src/videos/core.js +143 -143
  57. package/src/watch/data-store.js +2980 -2846
  58. package/src/watch/public/index.html +2355 -2285
  59. package/src/watch/server.js +727 -711
  60. package/src/webserver/server.mjs +174 -0
  61. package/scripts/test-captcha-lib.mjs +0 -68
  62. package/scripts/test-captcha.mjs +0 -81
  63. package/scripts/test-incognito-lib.mjs +0 -36
  64. package/scripts/test-login-state.mjs +0 -128
  65. package/scripts/test-safe-click.mjs +0 -45
  66. package/scripts/test-watch-db-smoke.mjs +0 -246
  67. package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
@@ -1,3 +1,5 @@
1
+ import os from "os";
2
+ import path from "path";
1
3
  import { chromium } from "playwright";
2
4
  import { detectBrowser } from "./browser/launch.js";
3
5
  import { parseUserInfo, parseVideoInfo } from "./parse-ssr.mjs";
@@ -7,11 +9,22 @@ const DEFAULT_WAF_TTL = 120000;
7
9
  const DEFAULT_WARM_URL = "https://www.tiktok.com/@nike";
8
10
  const BROWSER_CLOSE_TIMEOUT = 5000;
9
11
  const DEFAULT_MAX_REQUESTS_PER_PAGE = 50;
12
+ const FALLBACK_PROFILE_PORT = 9222;
10
13
 
11
14
  function delay(ms) {
12
15
  return new Promise((r) => setTimeout(r, ms));
13
16
  }
14
17
 
18
+ function getFallbackProfileDir() {
19
+ const profile = `p${FALLBACK_PROFILE_PORT}`;
20
+ return path.join(
21
+ os.homedir(),
22
+ "Library",
23
+ "Application Support",
24
+ `Microsoft Edge For Testing_${profile}`,
25
+ );
26
+ }
27
+
15
28
  class PageSlot {
16
29
  constructor(page) {
17
30
  this.page = page;
@@ -47,6 +60,46 @@ class PromiseQueue {
47
60
  }
48
61
  }
49
62
 
63
+ function createLaunchOptions(executablePath) {
64
+ return {
65
+ headless: true,
66
+ executablePath,
67
+ handleSIGINT: false,
68
+ handleSIGTERM: false,
69
+ handleSIGHUP: false,
70
+ args: [
71
+ "--no-sandbox",
72
+ "--disable-setuid-sandbox",
73
+ "--disable-dev-shm-usage",
74
+ ],
75
+ };
76
+ }
77
+
78
+ async function initContext(executablePath, poolSize, userDataDir) {
79
+ let context;
80
+ let browser = null;
81
+ const slots = [];
82
+
83
+ if (userDataDir) {
84
+ context = await chromium.launchPersistentContext(userDataDir, createLaunchOptions(executablePath));
85
+ const existing = context.pages();
86
+ if (existing.length > 0) {
87
+ slots.push(new PageSlot(existing[0]));
88
+ }
89
+ for (let i = slots.length; i < poolSize; i++) {
90
+ slots.push(new PageSlot(await context.newPage()));
91
+ }
92
+ } else {
93
+ browser = await chromium.launch(createLaunchOptions(executablePath));
94
+ context = await browser.newContext();
95
+ for (let i = 0; i < poolSize; i++) {
96
+ slots.push(new PageSlot(await context.newPage()));
97
+ }
98
+ }
99
+
100
+ return { browser, context, slots };
101
+ }
102
+
50
103
  export class TikTokScraper {
51
104
  constructor({
52
105
  poolSize = DEFAULT_POOL_SIZE,
@@ -64,6 +117,10 @@ export class TikTokScraper {
64
117
  this.slotIdx = 0;
65
118
  this.lastWarmTime = 0;
66
119
  this.warmPromise = null;
120
+ this._fallbackBrowser = null;
121
+ this._fallbackContext = null;
122
+ this._fallbackSlots = [];
123
+ this._fallbackSlotIdx = 0;
67
124
  }
68
125
 
69
126
  async init() {
@@ -73,49 +130,56 @@ export class TikTokScraper {
73
130
  "未找到本地浏览器(Chrome/Edge),请先安装浏览器或执行 npx playwright install",
74
131
  );
75
132
  }
76
- this.browser = await chromium.launch({
77
- headless: true,
78
- executablePath,
79
- handleSIGINT: false,
80
- handleSIGTERM: false,
81
- handleSIGHUP: false,
82
- args: [
83
- "--no-sandbox",
84
- "--disable-setuid-sandbox",
85
- "--disable-dev-shm-usage",
86
- ],
87
- });
88
- this.context = await this.browser.newContext();
89
- for (let i = 0; i < this.poolSize; i++) {
90
- this.slots.push(new PageSlot(await this.context.newPage()));
91
- }
133
+ const { browser, context, slots } = await initContext(executablePath, this.poolSize, null);
134
+ this.browser = browser;
135
+ this.context = context;
136
+ this.slots = slots;
92
137
  await this.warmWaf();
93
138
  }
94
139
 
140
+ async _ensureFallback(executablePath) {
141
+ if (this._fallbackContext) return;
142
+ const fallbackDir = getFallbackProfileDir();
143
+ console.error(`[TikTokScraper] 无登录态获取失败,fallback 到登录态 profile: ${fallbackDir}`);
144
+ const { browser, context, slots } = await initContext(executablePath, 1, fallbackDir);
145
+ this._fallbackBrowser = browser;
146
+ this._fallbackContext = context;
147
+ this._fallbackSlots = slots;
148
+ }
149
+
95
150
  async close() {
96
- if (this.browser) {
97
- const browser = this.browser;
98
- let closeTimedOut = false;
99
- const closePromise = browser.close().catch((error) => {
100
- console.error(
101
- `[TikTokScraper] browser.close() failed: ${error.message}`,
102
- );
103
- });
104
- await Promise.race([
105
- closePromise,
106
- delay(BROWSER_CLOSE_TIMEOUT).then(() => {
107
- closeTimedOut = true;
108
- }),
109
- ]);
110
- if (closeTimedOut) {
111
- console.error(
112
- `[TikTokScraper] browser.close() 超时 ${BROWSER_CLOSE_TIMEOUT}ms,跳过等待并继续退出`,
113
- );
151
+ const closeAll = async (browser) => {
152
+ if (browser) {
153
+ let closeTimedOut = false;
154
+ const closePromise = browser.close().catch((error) => {
155
+ console.error(
156
+ `[TikTokScraper] browser.close() failed: ${error.message}`,
157
+ );
158
+ });
159
+ await Promise.race([
160
+ closePromise,
161
+ delay(BROWSER_CLOSE_TIMEOUT).then(() => { closeTimedOut = true; }),
162
+ ]);
163
+ if (closeTimedOut) {
164
+ console.error(
165
+ `[TikTokScraper] browser.close() 超时 ${BROWSER_CLOSE_TIMEOUT}ms,跳过等待并继续退出`,
166
+ );
167
+ }
114
168
  }
115
- this.browser = null;
116
- this.context = null;
117
- this.slots = [];
169
+ };
170
+
171
+ // 无登录态的 browser 是 chromium.launch() 返回的,直接 close
172
+ await closeAll(this.browser);
173
+ // fallback 的 context 是 launchPersistentContext 返回的,直接 close
174
+ if (this._fallbackContext) {
175
+ try { await this._fallbackContext.close(); } catch {}
118
176
  }
177
+ this.browser = null;
178
+ this.context = null;
179
+ this.slots = [];
180
+ this._fallbackBrowser = null;
181
+ this._fallbackContext = null;
182
+ this._fallbackSlots = [];
119
183
  }
120
184
 
121
185
  async restart() {
@@ -125,7 +189,7 @@ export class TikTokScraper {
125
189
 
126
190
  isAlive() {
127
191
  try {
128
- return !!(this.browser && this.browser.isConnected());
192
+ return !!(this.context && (!this.browser || this.browser.isConnected()));
129
193
  } catch {
130
194
  return false;
131
195
  }
@@ -152,98 +216,88 @@ export class TikTokScraper {
152
216
  return this.warmPromise;
153
217
  }
154
218
 
155
- _needWarm() {
219
+ isWarmExpired() {
156
220
  return Date.now() - this.lastWarmTime > this.wafTtl;
157
221
  }
158
222
 
159
- _pickSlot() {
160
- const slot = this.slots[this.slotIdx % this.poolSize];
161
- this.slotIdx++;
223
+ _pickSlot(slots, idx, setIdx) {
224
+ const slot = slots[idx % slots.length];
162
225
  return slot;
163
226
  }
164
227
 
165
- async _ensurePage(slot) {
228
+ async _ensurePage(slot, context) {
166
229
  try {
167
- if (
168
- !slot.page.isClosed() &&
169
- slot.requestCount < this.maxRequestsPerPage
170
- ) {
230
+ if (!slot.page.isClosed() && slot.requestCount < this.maxRequestsPerPage) {
171
231
  return slot.page;
172
232
  }
173
233
  } catch {}
174
234
  await slot.page?.close().catch(() => {});
175
- slot.page = await this.context.newPage();
235
+ slot.page = await context.newPage();
176
236
  slot.requestCount = 0;
177
237
  return slot.page;
178
238
  }
179
239
 
180
- async _fetchViewSource(url, slot) {
181
- const page = await this._ensurePage(slot);
182
-
183
- await page.goto("view-source:" + url, {
184
- waitUntil: "domcontentloaded",
185
- timeout: 15000,
186
- });
240
+ async _fetchViewSource(url, ctx) {
241
+ const slots = ctx === this._fallbackContext ? this._fallbackSlots : this.slots;
242
+ const slot = slots[this.slotIdx % slots.length];
243
+ this.slotIdx++;
187
244
 
188
- const content = await page.evaluate(() => {
189
- const rows = document.querySelectorAll("tr");
190
- let content = "";
191
- rows.forEach((r) => {
192
- const lc = r.querySelector(".line-content");
193
- if (lc) content += lc.textContent + "\n";
245
+ try {
246
+ return await slot.lock.run(async () => {
247
+ const page = await this._ensurePage(slot, ctx);
248
+ slot.requestCount++;
249
+ if (ctx === this.context && this.isWarmExpired()) {
250
+ await this.warmWaf();
251
+ }
252
+ await page.goto(url, {
253
+ waitUntil: "domcontentloaded",
254
+ timeout: 15000,
255
+ });
256
+ await delay(1500);
257
+ return await page.content();
194
258
  });
195
- return content;
196
- });
259
+ } catch (e) {
260
+ console.error(`[TikTokScraper] _fetchViewSource failed: ${e.message}`);
261
+ return null;
262
+ }
263
+ }
197
264
 
198
- // 导航到 about:blank 释放当前页面的 DOM 和 JS 堆
199
- await page
200
- .goto("about:blank", {
201
- waitUntil: "domcontentloaded",
202
- timeout: 5000,
203
- })
204
- .catch(() => {});
265
+ async getUserInfo(username) {
266
+ const normalizedUsername = username.startsWith("@")
267
+ ? username.slice(1)
268
+ : username;
269
+ const url = `https://www.tiktok.com/@${normalizedUsername}`;
270
+ const executablePath = detectBrowser();
205
271
 
206
- slot.requestCount += 1;
272
+ // 第一趟:无登录态
273
+ let rawHtml = await this._fetchViewSource(url, this.context);
274
+ let result = rawHtml ? parseUserInfo(rawHtml) : null;
207
275
 
208
- return content;
209
- }
276
+ // 解析失败:fallback 到登录态
277
+ if (!result) {
278
+ await this._ensureFallback(executablePath);
279
+ rawHtml = await this._fetchViewSource(url, this._fallbackContext);
280
+ result = rawHtml ? parseUserInfo(rawHtml) : null;
281
+ }
210
282
 
211
- async getUserInfo(uniqueId) {
212
- const slot = this._pickSlot();
213
- return slot.lock.run(async () => {
214
- let rawHtml = await this._fetchViewSource(
215
- `https://www.tiktok.com/@${uniqueId}`,
216
- slot,
217
- );
218
- let result = parseUserInfo(rawHtml);
219
- if (!result) {
220
- try {
221
- await this.warmWaf();
222
- } catch {}
223
- rawHtml = await this._fetchViewSource(
224
- `https://www.tiktok.com/@${uniqueId}`,
225
- slot,
226
- );
227
- result = parseUserInfo(rawHtml);
228
- }
229
- return result || null;
230
- });
283
+ return result || null;
231
284
  }
232
285
 
233
286
  async getVideoInfo(videoUrl) {
234
- const slot = this._pickSlot();
235
- return slot.lock.run(async () => {
236
- let rawHtml = await this._fetchViewSource(videoUrl, slot);
237
- let result = parseVideoInfo(rawHtml);
238
- if (!result) {
239
- try {
240
- await this.warmWaf();
241
- } catch {}
242
- rawHtml = await this._fetchViewSource(videoUrl, slot);
243
- result = parseVideoInfo(rawHtml);
244
- }
245
- return result || null;
246
- });
287
+ const executablePath = detectBrowser();
288
+
289
+ // 第一趟:无登录态
290
+ let rawHtml = await this._fetchViewSource(videoUrl, this.context);
291
+ let result = rawHtml ? parseVideoInfo(rawHtml) : null;
292
+
293
+ // 解析失败:fallback 到登录态
294
+ if (!result) {
295
+ await this._ensureFallback(executablePath);
296
+ rawHtml = await this._fetchViewSource(videoUrl, this._fallbackContext);
297
+ result = rawHtml ? parseVideoInfo(rawHtml) : null;
298
+ }
299
+
300
+ return result || null;
247
301
  }
248
302
 
249
303
  async getUserAndVideo(videoUrl) {
package/src/lib/url.js CHANGED
@@ -1,52 +1,52 @@
1
- const BASE_URL = 'https://www.tiktok.com';
2
-
3
- export function extractUniqueId(url) {
4
- const m = url.match(/\/@([\w.-]+)/);
5
- return m ? m[1] : null;
6
- }
7
-
8
- export function extractVideoId(url) {
9
- const m = url.match(/\/video\/(\d+)/);
10
- return m ? m[1] : null;
11
- }
12
-
13
- export function normalizeUsername(input) {
14
- return (input || '').replace(/^@/, '');
15
- }
16
-
17
- export function toProfileUrl(handle) {
18
- const clean = normalizeUsername(handle);
19
- return `${BASE_URL}/@${clean}`;
20
- }
21
-
22
- export function toVideoUrl(handle, videoId) {
23
- const clean = normalizeUsername(handle);
24
- return `${BASE_URL}/@${clean}/video/${videoId}`;
25
- }
26
-
27
- export function ensureAbsoluteUrl(href) {
28
- if (href.startsWith('http')) return href;
29
- return `${BASE_URL}${href}`;
30
- }
31
-
32
- export function isProfileUrl(url) {
33
- return /\/@[\w.-]+(?:$|[?#])/.test(url);
34
- }
35
-
36
- export function isVideoUrl(url) {
37
- return /\/video\/\d+/.test(url);
38
- }
39
-
40
- export function extractDisplayPath(url) {
41
- try {
42
- const parts = new URL(url).pathname.split('/').filter(Boolean);
43
- return parts.slice(-2).join('/');
44
- } catch {
45
- return url;
46
- }
47
- }
48
-
49
- export function extractAuthorFromVideoUrl(url) {
50
- const m = url.match(/@([^/]+)\/video/);
51
- return m ? '@' + m[1] : null;
52
- }
1
+ const BASE_URL = 'https://www.tiktok.com';
2
+
3
+ export function extractUniqueId(url) {
4
+ const m = url.match(/\/@([\w.-]+)/);
5
+ return m ? m[1] : null;
6
+ }
7
+
8
+ export function extractVideoId(url) {
9
+ const m = url.match(/\/video\/(\d+)/);
10
+ return m ? m[1] : null;
11
+ }
12
+
13
+ export function normalizeUsername(input) {
14
+ return (input || '').replace(/^@/, '');
15
+ }
16
+
17
+ export function toProfileUrl(handle) {
18
+ const clean = normalizeUsername(handle);
19
+ return `${BASE_URL}/@${clean}`;
20
+ }
21
+
22
+ export function toVideoUrl(handle, videoId) {
23
+ const clean = normalizeUsername(handle);
24
+ return `${BASE_URL}/@${clean}/video/${videoId}`;
25
+ }
26
+
27
+ export function ensureAbsoluteUrl(href) {
28
+ if (href.startsWith('http')) return href;
29
+ return `${BASE_URL}${href}`;
30
+ }
31
+
32
+ export function isProfileUrl(url) {
33
+ return /\/@[\w.-]+(?:$|[?#])/.test(url);
34
+ }
35
+
36
+ export function isVideoUrl(url) {
37
+ return /\/video\/\d+/.test(url);
38
+ }
39
+
40
+ export function extractDisplayPath(url) {
41
+ try {
42
+ const parts = new URL(url).pathname.split('/').filter(Boolean);
43
+ return parts.slice(-2).join('/');
44
+ } catch {
45
+ return url;
46
+ }
47
+ }
48
+
49
+ export function extractAuthorFromVideoUrl(url) {
50
+ const m = url.match(/@([^/]+)\/video/);
51
+ return m ? '@' + m[1] : null;
52
+ }
package/src/main.js CHANGED
@@ -1,70 +1,73 @@
1
- import { parseArgs } from "./lib/args.js";
2
- import { proxy, HELP_TEXT, getConfigText } from "./lib/constants.js";
3
- import { handleInfo } from "./cli/info.js";
4
- import { handleExplore } from "./cli/explore.js";
5
- import { handleAttach } from "./cli/attach.js";
6
- import { handleWatch } from "./cli/watch.js";
7
- import { handleConfig, showConfig, showUsage, version } from "./cli/config.js";
8
- import { handleOpen } from "./cli/open.js";
9
- import { handleComments } from "./cli/comments.js";
10
- import { handleVideoStats } from "./cli/videostats.js";
11
- import { handleDbImport } from "./cli/db-import.js";
12
-
13
- async function main() {
14
- const parsed = parseArgs();
15
-
16
- switch (parsed.subcommand) {
17
- case "explore":
18
- return handleExplore(parsed);
19
- case "info":
20
- return handleInfo(parsed);
21
- case "attach":
22
- return handleAttach(parsed);
23
- case "watch":
24
- return handleWatch(parsed);
25
- case "open":
26
- return handleOpen(parsed);
27
- case "comments":
28
- return handleComments(parsed);
29
- case "videostats":
30
- return handleVideoStats(parsed);
31
- case "db-import":
32
- return handleDbImport(parsed);
33
- }
34
-
35
- const {
36
- urls,
37
- outputFile,
38
- outputFormat,
39
- exploreCount,
40
- showConfig: showCfg,
41
- showHelp,
42
- showVersion,
43
- customProxy,
44
- configAction,
45
- configKey,
46
- configValue,
47
- } = parsed;
48
-
49
- if (showVersion) {
50
- console.log(version);
51
- process.exit(0);
52
- }
53
- if (showHelp) return showUsage();
54
- if (configAction) return handleConfig(configAction, configKey, configValue);
55
- if (showCfg) return showConfig(urls, outputFile);
56
- if (urls.length === 0 && exploreCount === 0) return showUsage();
57
-
58
- // 默认行为:URL info,--explore 走 explore
59
- if (exploreCount > 0) {
60
- return handleExplore({ ...parsed, subcommand: "explore" });
61
- }
62
-
63
- // URL 默认走 info
64
- return handleInfo(parsed);
65
- }
66
-
67
- main().catch((err) => {
68
- console.error(`错误: ${err.message}`);
69
- process.exit(1);
70
- });
1
+ import { parseArgs } from "./lib/args.js";
2
+ import { proxy, HELP_TEXT, getConfigText } from "./lib/constants.js";
3
+ import { handleInfo } from "./cli/info.js";
4
+ import { handleExplore } from "./cli/explore.js";
5
+ import { handleAttach } from "./cli/attach.js";
6
+ import { handleWatch } from "./cli/watch.js";
7
+ import { handleConfig, showConfig, showUsage, version } from "./cli/config.js";
8
+ import { handleOpen } from "./cli/open.js";
9
+ import { handleComments } from "./cli/comments.js";
10
+ import { handleVideoStats } from "./cli/videostats.js";
11
+ import { handleDbImport } from "./cli/db-import.js";
12
+ import { handleWebserver } from "./cli/webserver.js";
13
+
14
+ async function main() {
15
+ const parsed = parseArgs();
16
+
17
+ switch (parsed.subcommand) {
18
+ case "explore":
19
+ return handleExplore(parsed);
20
+ case "info":
21
+ return handleInfo(parsed);
22
+ case "attach":
23
+ return handleAttach(parsed);
24
+ case "watch":
25
+ return handleWatch(parsed);
26
+ case "webserver":
27
+ return handleWebserver(parsed);
28
+ case "open":
29
+ return handleOpen(parsed);
30
+ case "comments":
31
+ return handleComments(parsed);
32
+ case "videostats":
33
+ return handleVideoStats(parsed);
34
+ case "db-import":
35
+ return handleDbImport(parsed);
36
+ }
37
+
38
+ const {
39
+ urls,
40
+ outputFile,
41
+ outputFormat,
42
+ exploreCount,
43
+ showConfig: showCfg,
44
+ showHelp,
45
+ showVersion,
46
+ customProxy,
47
+ configAction,
48
+ configKey,
49
+ configValue,
50
+ } = parsed;
51
+
52
+ if (showVersion) {
53
+ console.log(version);
54
+ process.exit(0);
55
+ }
56
+ if (showHelp) return showUsage();
57
+ if (configAction) return handleConfig(configAction, configKey, configValue);
58
+ if (showCfg) return showConfig(urls, outputFile);
59
+ if (urls.length === 0 && exploreCount === 0) return showUsage();
60
+
61
+ // 默认行为:URL 走 info,--explore 走 explore
62
+ if (exploreCount > 0) {
63
+ return handleExplore({ ...parsed, subcommand: "explore" });
64
+ }
65
+
66
+ // 有 URL 默认走 info
67
+ return handleInfo(parsed);
68
+ }
69
+
70
+ main().catch((err) => {
71
+ console.error(`错误: ${err.message}`);
72
+ process.exit(1);
73
+ });