@pomelolabs/leads 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: pomelo
3
+ name: '@pomelolabs/leads'
Binary file
Binary file
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Real local-browser collector.
3
+ *
4
+ * Everything here runs in a visible Chromium window with a persistent profile.
5
+ * The user logs in once; searches then use ordinary mouse/scroll/keyboard input
6
+ * with human pacing. Platform DOM and risk controls change often, so selectors
7
+ * are intentionally defensive and failures return a readable error instead of
8
+ * fabricated rows.
9
+ */
10
+ import fs from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import puppeteer from 'puppeteer';
14
+
15
+ const PLATFORM_URLS = {
16
+ xhs: {
17
+ label: '小红书',
18
+ home: 'https://www.xiaohongshu.com/explore',
19
+ search: (keyword) =>
20
+ `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(keyword)}&source=web_search_result_notes`,
21
+ },
22
+ douyin: {
23
+ label: '抖音',
24
+ home: 'https://www.douyin.com/',
25
+ search: (keyword) => `https://www.douyin.com/search/${encodeURIComponent(keyword)}`,
26
+ },
27
+ };
28
+
29
+ function homeDir() {
30
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
31
+ }
32
+ function profileDir() {
33
+ return path.join(homeDir(), 'pomelo', 'browser-profile');
34
+ }
35
+ function logDir() {
36
+ return path.join(homeDir(), 'pomelo', 'logs');
37
+ }
38
+
39
+ const SYSTEM_BROWSERS = process.platform === 'darwin'
40
+ ? [
41
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
42
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
43
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
44
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
45
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
46
+ path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
47
+ ]
48
+ : process.platform === 'win32'
49
+ ? [
50
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
51
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
52
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
53
+ ]
54
+ : [
55
+ '/usr/bin/google-chrome',
56
+ '/usr/bin/google-chrome-stable',
57
+ '/usr/bin/chromium',
58
+ '/usr/bin/chromium-browser',
59
+ '/usr/bin/microsoft-edge',
60
+ ];
61
+
62
+ async function executableAt(candidate) {
63
+ if (!candidate) return false;
64
+ try {
65
+ await fs.access(candidate, fs.constants?.X_OK ?? 0);
66
+ return true;
67
+ } catch {
68
+ try {
69
+ await fs.access(candidate);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+ }
76
+
77
+ async function findPlaywrightChromium() {
78
+ const roots = [
79
+ path.join(os.homedir(), 'Library/Caches/ms-playwright'),
80
+ path.join(os.homedir(), '.cache/ms-playwright'),
81
+ ];
82
+ for (const root of roots) {
83
+ let names = [];
84
+ try {
85
+ names = await fs.readdir(root);
86
+ } catch {
87
+ continue;
88
+ }
89
+ for (const name of names.filter((item) => item.startsWith('chromium-')).sort().reverse()) {
90
+ const candidates = process.platform === 'darwin'
91
+ ? [
92
+ path.join(root, name, 'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium'),
93
+ path.join(root, name, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'),
94
+ path.join(root, name, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
95
+ path.join(root, name, 'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
96
+ ]
97
+ : [
98
+ path.join(root, name, 'chrome-linux/chrome'),
99
+ path.join(root, name, 'chrome-linux64/chrome'),
100
+ ];
101
+ for (const candidate of candidates) {
102
+ if (await executableAt(candidate)) return candidate;
103
+ }
104
+ }
105
+ }
106
+ return '';
107
+ }
108
+
109
+ async function resolveBrowserExecutable() {
110
+ const explicit = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH;
111
+ if (explicit && await executableAt(explicit)) {
112
+ return { executable: explicit, source: 'env' };
113
+ }
114
+ const preferredChromium = path.join(homeDir(), 'pomelo', 'Chromium.app/Contents/MacOS/Google Chrome for Testing');
115
+ if (await executableAt(preferredChromium)) {
116
+ return { executable: preferredChromium, source: 'chromium' };
117
+ }
118
+ const chromium = await findPlaywrightChromium();
119
+ if (chromium) {
120
+ return { executable: chromium, source: 'playwright-chromium' };
121
+ }
122
+ try {
123
+ const bundled = await puppeteer.executablePath();
124
+ if (bundled && await executableAt(bundled)) {
125
+ return { executable: bundled, source: 'puppeteer' };
126
+ }
127
+ } catch {
128
+ /* fall through to system browsers */
129
+ }
130
+ for (const candidate of SYSTEM_BROWSERS) {
131
+ if (await executableAt(candidate)) {
132
+ return { executable: candidate, source: 'system' };
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function sleep(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+
142
+ function humanPause(min = 280, max = 720) {
143
+ return sleep(min + Math.random() * (max - min));
144
+ }
145
+
146
+ let activeBrowser = null;
147
+ let activeLoginPage = null;
148
+
149
+ async function detectPlatformLogins() {
150
+ let browser = activeBrowser?.connected ? activeBrowser : null;
151
+ let temporary = false;
152
+ let borrowed = false;
153
+ if (!browser) {
154
+ // A previous Host or a visible login window may still own the profile.
155
+ // Attach through its DevTools port instead of launching a second browser.
156
+ try {
157
+ const port = Number.parseInt((await fs.readFile(path.join(profileDir(), 'DevToolsActivePort'), 'utf8')).split('\n')[0], 10);
158
+ if (port > 0) {
159
+ browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}` });
160
+ borrowed = true;
161
+ }
162
+ } catch {
163
+ browser = null;
164
+ }
165
+ }
166
+ if (!browser) {
167
+ const resolved = await resolveBrowserExecutable();
168
+ if (!resolved) return { known: false };
169
+ try {
170
+ browser = await puppeteer.launch({
171
+ headless: true,
172
+ executablePath: resolved.executable,
173
+ userDataDir: profileDir(),
174
+ args: ['--no-sandbox', '--disable-gpu', '--no-first-run', '--no-default-browser-check'],
175
+ });
176
+ temporary = true;
177
+ } catch {
178
+ return { known: false };
179
+ }
180
+ }
181
+ try {
182
+ const cookies = await browser.cookies();
183
+ const has = (domain, names) => cookies.some((cookie) => cookie.domain.includes(domain)
184
+ && names.includes(cookie.name) && String(cookie.value || '').length > 8);
185
+ return {
186
+ known: true,
187
+ checkedAt: new Date().toISOString(),
188
+ xhs: { loggedIn: has('xiaohongshu.com', ['web_session']) },
189
+ douyin: { loggedIn: has('douyin.com', ['sessionid', 'sessionid_ss', 'sid_tt']) },
190
+ };
191
+ } catch {
192
+ return { known: false };
193
+ } finally {
194
+ if (borrowed) await browser.disconnect().catch?.(() => {});
195
+ else if (temporary) await browser.close().catch(() => {});
196
+ }
197
+ }
198
+
199
+ export async function browserStatus() {
200
+ const resolved = await resolveBrowserExecutable();
201
+ const logins = await detectPlatformLogins();
202
+ return {
203
+ installed: Boolean(resolved),
204
+ executable: resolved?.executable || '',
205
+ source: resolved?.source || '',
206
+ profile: profileDir(),
207
+ running: Boolean(activeBrowser?.connected),
208
+ loginWindowOpen: Boolean(activeLoginPage && !activeLoginPage.isClosed()),
209
+ logins,
210
+ };
211
+ }
212
+
213
+ async function launchBrowser() {
214
+ if (activeBrowser?.connected) return activeBrowser;
215
+ const resolved = await resolveBrowserExecutable();
216
+ if (!resolved) {
217
+ const error = new Error('本机没有可用的 Chrome / Chromium / Edge。请先安装浏览器,或在设置里安装采集浏览器。');
218
+ error.code = 'BROWSER_MISSING';
219
+ throw error;
220
+ }
221
+ await fs.mkdir(profileDir(), { recursive: true, mode: 0o700 });
222
+ await fs.mkdir(logDir(), { recursive: true, mode: 0o700 });
223
+ activeBrowser = await puppeteer.launch({
224
+ headless: false,
225
+ executablePath: resolved.executable,
226
+ userDataDir: profileDir(),
227
+ defaultViewport: { width: 1440, height: 900 },
228
+ ignoreDefaultArgs: ['--enable-automation'],
229
+ args: [
230
+ '--no-first-run',
231
+ '--no-default-browser-check',
232
+ '--lang=zh-CN',
233
+ '--window-size=1440,900',
234
+ ],
235
+ });
236
+ activeBrowser.on('disconnected', () => {
237
+ activeBrowser = null;
238
+ activeLoginPage = null;
239
+ });
240
+ return activeBrowser;
241
+ }
242
+
243
+ async function humanMove(page, x, y) {
244
+ const steps = 8 + Math.floor(Math.random() * 8);
245
+ for (let i = 1; i <= steps; i += 1) {
246
+ await page.mouse.move(
247
+ x + (Math.random() - 0.5) * 8,
248
+ y + (Math.random() - 0.5) * 8,
249
+ { steps: 1 },
250
+ );
251
+ await sleep(12 + Math.random() * 22);
252
+ }
253
+ }
254
+
255
+ async function humanScroll(page, amount = 420) {
256
+ await page.mouse.move(700 + Math.random() * 120, 500 + Math.random() * 120);
257
+ const rounds = 3 + Math.floor(Math.random() * 3);
258
+ for (let i = 0; i < rounds; i += 1) {
259
+ await page.mouse.wheel({ deltaY: amount + Math.random() * 120 });
260
+ await humanPause(180, 460);
261
+ }
262
+ }
263
+
264
+ async function submitSearch(page, kind, keyword) {
265
+ const selectors = kind === 'xhs'
266
+ ? ['input#search-input', 'input[placeholder*="搜索"]', '.search-input input', 'input[type="search"]']
267
+ : ['input[data-e2e="searchbar-input"]', 'input[placeholder*="搜索"]', 'input[type="search"]'];
268
+ for (const selector of selectors) {
269
+ const input = await page.$(selector);
270
+ if (!input) continue;
271
+ const box = await input.boundingBox();
272
+ if (!box) continue;
273
+ await humanMove(page, box.x + box.width * 0.45, box.y + box.height * 0.55);
274
+ await page.mouse.click(box.x + box.width * 0.45, box.y + box.height * 0.55);
275
+ await humanPause(180, 420);
276
+ await input.click({ clickCount: 3 }).catch(() => {});
277
+ await page.keyboard.press('Backspace').catch(() => {});
278
+ for (const char of keyword) {
279
+ await page.keyboard.type(char);
280
+ await sleep(70 + Math.random() * 90);
281
+ }
282
+ await humanPause(250, 550);
283
+ await page.keyboard.press('Enter');
284
+ await humanPause(1400, 2400);
285
+ return true;
286
+ }
287
+ return false;
288
+ }
289
+
290
+ async function openPage(url) {
291
+ const browser = await launchBrowser();
292
+ const page = await browser.newPage();
293
+ page.setDefaultTimeout(20000);
294
+ page.setDefaultNavigationTimeout(45000);
295
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
296
+ await humanPause(1600, 2600);
297
+ return page;
298
+ }
299
+
300
+ async function looksLoggedOut(page, platform) {
301
+ return page.evaluate((kind) => {
302
+ const text = (document.body?.innerText || '').replace(/\s+/g, ' ');
303
+ const loginText = kind === 'xhs'
304
+ ? /登录后查看|登录后即可|扫码登录|手机号登录|登录小红书/
305
+ : /登录后查看|登录后即可|扫码登录|手机号登录|登录抖音/;
306
+ const visible = (selector) => {
307
+ const el = document.querySelector(selector);
308
+ if (!el) return false;
309
+ const rect = el.getBoundingClientRect();
310
+ return rect.width > 0 && rect.height > 0;
311
+ };
312
+ return visible('.login-container') || visible('.login-modal') || visible('[class*="login-mask"]') || loginText.test(text);
313
+ }, platform);
314
+ }
315
+
316
+ function cleanCount(value) {
317
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 20);
318
+ }
319
+
320
+ async function collectXhs(page, keyword) {
321
+ if (await looksLoggedOut(page, 'xhs')) {
322
+ const error = new Error('小红书需要登录。请先在设置里点“登录”,在弹出的浏览器窗口登录后重试。');
323
+ error.code = 'LOGIN_REQUIRED';
324
+ throw error;
325
+ }
326
+ await humanScroll(page, 480);
327
+ await humanScroll(page, 380);
328
+ await humanPause(700, 1200);
329
+
330
+ const posts = await page.evaluate(() => {
331
+ const seen = new Set();
332
+ const cards = [
333
+ ...document.querySelectorAll('section.note-item, div.note-item, div[class*="note-item"]'),
334
+ ];
335
+ const items = [];
336
+ for (const card of cards) {
337
+ const link = card.querySelector('a[href*="/explore/"], a[href*="/discovery/item/"]');
338
+ const url = link?.href || location.href;
339
+ if (seen.has(url)) continue;
340
+ seen.add(url);
341
+ const img = card.querySelector('a.cover img, .cover img, img[data-xhs-img]') || card.querySelector('img');
342
+ const title =
343
+ card.querySelector('.footer .title span, .footer .title, .title, .note-title, [class*="title"]')?.textContent?.trim() ||
344
+ img?.alt ||
345
+ '';
346
+ if (!title) continue;
347
+ const author =
348
+ card.querySelector('.author .name, .author-wrapper .name, .name, [class*="author"] [class*="name"]')?.textContent?.trim() ||
349
+ '';
350
+ const count =
351
+ card.querySelector('.like-wrapper .count, .like-wrapper, .count, [class*="like"] [class*="count"]')?.textContent?.trim() ||
352
+ '';
353
+ items.push({
354
+ title: title.slice(0, 180),
355
+ author: author.slice(0, 80),
356
+ likes: count.replace(/[^\d.万亿wWkK+]/g, '').slice(0, 20),
357
+ cover: img?.currentSrc || img?.src || '',
358
+ url,
359
+ platform: '小红书',
360
+ format: '图文',
361
+ });
362
+ }
363
+ return items.slice(0, 12);
364
+ });
365
+
366
+ if (!posts.length) {
367
+ const error = new Error('没有从小红书页面读到公开结果。可能是登录失效、页面结构变化或平台触发了验证,请在弹出的浏览器里检查后重试。');
368
+ error.code = 'NO_RESULTS';
369
+ throw error;
370
+ }
371
+ return posts.map((post) => ({ ...post, keyword }));
372
+ }
373
+
374
+ async function collectDouyin(page, keyword) {
375
+ if (await looksLoggedOut(page, 'douyin')) {
376
+ const error = new Error('抖音需要登录。请先在设置里点“登录”,在弹出的浏览器窗口登录后重试。');
377
+ error.code = 'LOGIN_REQUIRED';
378
+ throw error;
379
+ }
380
+ await humanScroll(page, 500);
381
+ await humanScroll(page, 360);
382
+ await humanPause(700, 1200);
383
+
384
+ const posts = await page.evaluate(() => {
385
+ const seen = new Set();
386
+ const anchors = [...document.querySelectorAll('a[href*="/video/"]')];
387
+ const items = [];
388
+ for (const anchor of anchors) {
389
+ const url = anchor.href;
390
+ if (seen.has(url)) continue;
391
+ seen.add(url);
392
+ const card = anchor.closest('li, div[data-e2e], article, div') || anchor;
393
+ const img = card.querySelector('a.cover img, .cover img, img[data-xhs-img]') || card.querySelector('img');
394
+ const title =
395
+ img?.alt ||
396
+ card.querySelector('[data-e2e="video-desc"], [data-e2e="search-card-title"], h3, .title')?.textContent?.trim() ||
397
+ anchor.getAttribute('title') ||
398
+ '';
399
+ if (!title) continue;
400
+ const author =
401
+ card.querySelector('[data-e2e="video-author"], [data-e2e="search-card-author"], .author, [class*="author"]')?.textContent?.trim() ||
402
+ '';
403
+ const count =
404
+ card.querySelector('[data-e2e="video-like"], [data-e2e="search-card-like"], .like, [class*="like"]')?.textContent?.trim() ||
405
+ '';
406
+ items.push({
407
+ title: title.slice(0, 180),
408
+ author: author.slice(0, 80),
409
+ likes: count.replace(/[^\d.万亿wWkK+]/g, '').slice(0, 20),
410
+ cover: img?.currentSrc || img?.src || '',
411
+ url,
412
+ platform: '抖音',
413
+ format: '视频',
414
+ });
415
+ }
416
+ return items.slice(0, 12);
417
+ });
418
+
419
+ if (!posts.length) {
420
+ const error = new Error('没有从抖音页面读到公开结果。可能是登录失效、页面结构变化或平台触发了验证,请在弹出的浏览器里检查后重试。');
421
+ error.code = 'NO_RESULTS';
422
+ throw error;
423
+ }
424
+ return posts.map((post) => ({ ...post, keyword }));
425
+ }
426
+
427
+ async function collectProfile(page, platform, fallbackAvatar = '') {
428
+ await humanPause(1100, 1800);
429
+ await humanScroll(page, 460);
430
+ await page.evaluate(() => {
431
+ for (const img of document.querySelectorAll('section.note-item img, div.note-item img, img[data-xhs-img]')) {
432
+ img.loading = 'eager';
433
+ const lazy = img.getAttribute('data-src') || img.getAttribute('data-original');
434
+ if (lazy) img.src = lazy;
435
+ }
436
+ });
437
+ await humanScroll(page, 360);
438
+ await humanPause(1100, 1600);
439
+ return page.evaluate((label, fallbackAvatar) => {
440
+ const text = document.body?.innerText || '';
441
+ const followerMatch = text.match(/([\d.]+\s*[万亿wWkK]?)\s*粉丝/);
442
+ const pick = (selectors) => {
443
+ for (const selector of selectors) {
444
+ const el = document.querySelector(selector);
445
+ const value = el?.getAttribute?.('alt') || el?.textContent?.trim();
446
+ if (value) return value;
447
+ }
448
+ return '';
449
+ };
450
+ const name = pick(['[class*="user-name"]', '.user-nickname', '.user-name', 'h1', '.name']) || document.title.replace(/[-_].*$/, '').trim();
451
+ const avatarImgs = [...document.querySelectorAll('img')]
452
+ .map((img) => ({ src: img.currentSrc || img.src || '', width: img.naturalWidth || 0 }))
453
+ .filter((item) => item.src && /sns-avatar|avatar|aweme-avatar|douyinpic.*avatar|byteimg.*avatar/i.test(item.src) && !/fe-static|logo|placeholder|default/i.test(item.src))
454
+ .sort((a, b) => b.width - a.width);
455
+ const avatarBg = [...document.querySelectorAll('[style*="background-image"]')]
456
+ .map((el) => getComputedStyle(el).backgroundImage)
457
+ .find((value) => /sns-avatar|avatar/.test(value) && !/fe-static|logo|placeholder|default/.test(value));
458
+ const bgUrl = avatarBg?.match(/url\(["']?(.*?)["']?\)/)?.[1] || '';
459
+ const avatar = avatarImgs[0]?.src || bgUrl || fallbackAvatar;
460
+ const seen = new Set();
461
+ const cards = [...document.querySelectorAll('section.note-item, div.note-item, li[class*="note"], a[href*="/video/"]')];
462
+ const posts = [];
463
+ for (const card of cards) {
464
+ const link = card.matches?.('a[href]') ? card : card.querySelector('a[href*="/explore/"], a[href*="/discovery/item/"], a[href*="/video/"]');
465
+ const url = link?.href || '';
466
+ if (!url || seen.has(url)) continue;
467
+ seen.add(url);
468
+ const img = card.querySelector('a.cover img, .cover img, img[data-xhs-img]') || card.querySelector('img');
469
+ const title = pickFrom(card, ['.footer .title span', '.footer .title', '.title', '.note-title', '[data-e2e="video-desc"]', 'h3']) || img?.alt || '';
470
+ if (!title) continue;
471
+ const likeText = pickFrom(card, ['.like-wrapper .count', '.like-wrapper', '.count', '[data-e2e="video-like"]', '[class*="like"]']);
472
+ posts.push({ title: title.slice(0, 180), cover: img?.currentSrc || img?.src || '', url, likes: String(likeText || '').replace(/[^\d.万亿wWkK+]/g, '').slice(0, 20), author: name, platform: label, format: url.includes('/video/') ? '视频' : '图文' });
473
+ if (posts.length >= 12) break;
474
+ }
475
+ function pickFrom(root, selectors) {
476
+ for (const selector of selectors) {
477
+ const el = root.querySelector(selector);
478
+ const value = el?.textContent?.trim();
479
+ if (value) return value;
480
+ }
481
+ return '';
482
+ }
483
+ const cleanCover = (value) => value && /sns-webpic|notes_pre_post|spectrum|douyinpic|byteimg/i.test(value) && !/fe-static|logo|placeholder|default/i.test(value) ? value : '';
484
+ return {
485
+ name,
486
+ avatar,
487
+ followers: followerMatch ? followerMatch[1].trim() : '—',
488
+ posts: posts.map((post) => ({ ...post, cover: cleanCover(post.cover) })),
489
+ };
490
+ }, platform, fallbackAvatar);
491
+ }
492
+
493
+ async function resolveXhsProfile(page, account) {
494
+ let profileFallbackAvatar = '';
495
+ const direct = /^https?:\/\/.*xiaohongshu\.com\/user\/profile\//i.test(account);
496
+ if (direct) {
497
+ await page.goto(account, { waitUntil: 'domcontentloaded' });
498
+ } else {
499
+ const term = account.replace(/^@/, '').trim();
500
+ await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(term)}&type=51`, { waitUntil: 'domcontentloaded' });
501
+ await humanPause(1400, 2200);
502
+ let users = await page.evaluate(() => [...document.querySelectorAll('a[href*="/user/profile/"]')].map((a) => ({ url: a.href, name: (a.innerText || a.querySelector('img')?.alt || '').trim().slice(0, 80), avatar: a.querySelector('img')?.currentSrc || a.querySelector('img')?.src || '' })));
503
+ if (!users.length) {
504
+ await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(term)}&source=web_search_result_notes`, { waitUntil: 'domcontentloaded' });
505
+ await humanPause(1400, 2200);
506
+ await humanScroll(page, 420);
507
+ users = await page.evaluate(() => [...document.querySelectorAll('a[href*="/user/profile/"]')].map((a) => ({ url: a.href, name: (a.innerText || a.querySelector('img')?.alt || '').trim().slice(0, 80), avatar: a.querySelector('img')?.currentSrc || a.querySelector('img')?.src || '' })));
508
+ }
509
+ if (!users.length) {
510
+ const error = new Error('没有找到这个博主的用户主页,请粘贴博主主页链接或更准确的昵称。');
511
+ error.code = 'PROFILE_NOT_FOUND';
512
+ throw error;
513
+ }
514
+ const chosen = users.find((item) => item.name.includes(term) || term.includes(item.name)) || users[0];
515
+ await page.goto(chosen.url, { waitUntil: 'domcontentloaded' });
516
+ profileFallbackAvatar = chosen.avatar || '';
517
+ }
518
+ const profile = await collectProfile(page, '小红书', profileFallbackAvatar);
519
+ if (!profile.posts.length) {
520
+ const error = new Error('打开了博主主页,但没有读到公开作品。可能是登录失效或页面结构变化。');
521
+ error.code = 'PROFILE_EMPTY';
522
+ throw error;
523
+ }
524
+ return { ...profile, profileUrl: page.url() };
525
+ }
526
+
527
+ async function resolveDouyinProfile(page, account) {
528
+ let profileFallbackAvatar = '';
529
+ const direct = /^https?:\/\/.*douyin\.com\/user\//i.test(account);
530
+ if (direct) {
531
+ await page.goto(account, { waitUntil: 'domcontentloaded' });
532
+ } else {
533
+ const term = account.replace(/^@/, '').trim();
534
+ await page.goto(`https://www.douyin.com/search/${encodeURIComponent(term)}?type=user`, { waitUntil: 'domcontentloaded' });
535
+ await humanPause(1500, 2300);
536
+ const users = await page.evaluate(() => [...document.querySelectorAll('a[href*="/user/"]')].map((a) => ({ url: a.href, name: (a.innerText || a.querySelector('img')?.alt || '').trim().slice(0, 80), avatar: a.querySelector('img')?.currentSrc || a.querySelector('img')?.src || '' })));
537
+ if (!users.length) {
538
+ const error = new Error('没有找到这个博主的用户主页,请粘贴博主主页链接或更准确的昵称。');
539
+ error.code = 'PROFILE_NOT_FOUND';
540
+ throw error;
541
+ }
542
+ const chosen = users.find((item) => item.name.includes(term) || term.includes(item.name)) || users[0];
543
+ await page.goto(chosen.url, { waitUntil: 'domcontentloaded' });
544
+ profileFallbackAvatar = chosen.avatar || '';
545
+ }
546
+ const profile = await collectProfile(page, '抖音', profileFallbackAvatar);
547
+ if (!profile.posts.length) {
548
+ const error = new Error('打开了博主主页,但没有读到公开作品。可能是登录失效或页面结构变化。');
549
+ error.code = 'PROFILE_EMPTY';
550
+ throw error;
551
+ }
552
+ return { ...profile, profileUrl: page.url() };
553
+ }
554
+
555
+ async function debugShot(page, prefix) {
556
+ try {
557
+ await fs.mkdir(logDir(), { recursive: true, mode: 0o700 });
558
+ await page.screenshot({ path: path.join(logDir(), `${prefix}-${Date.now()}.png`) });
559
+ } catch {
560
+ /* best effort */
561
+ }
562
+ }
563
+
564
+ export async function searchPublic({ platform = '小红书', keyword }) {
565
+ const key = platform === '抖音' ? 'douyin' : 'xhs';
566
+ const meta = PLATFORM_URLS[key];
567
+ const term = String(keyword || '').trim().slice(0, 80);
568
+ if (!term) {
569
+ const error = new Error('请输入关键词');
570
+ error.code = 'EMPTY_KEYWORD';
571
+ throw error;
572
+ }
573
+ const page = await openPage(meta.home);
574
+ try {
575
+ const submitted = await submitSearch(page, key, term);
576
+ if (!submitted) {
577
+ await page.goto(meta.search(term), { waitUntil: 'domcontentloaded' });
578
+ await humanPause(1600, 2600);
579
+ }
580
+ const posts = key === 'xhs' ? await collectXhs(page, term) : await collectDouyin(page, term);
581
+ return {
582
+ platform: meta.label,
583
+ keyword: term,
584
+ posts,
585
+ collectedAt: new Date().toISOString(),
586
+ source: 'local-chromium-visible-search',
587
+ };
588
+ } catch (error) {
589
+ await debugShot(page, `search-${key}`);
590
+ throw error;
591
+ } finally {
592
+ await page.close().catch(() => {});
593
+ }
594
+ }
595
+
596
+ export async function openLogin(platform = '小红书') {
597
+ const key = platform === '抖音' ? 'douyin' : 'xhs';
598
+ const meta = PLATFORM_URLS[key];
599
+ if (!activeLoginPage || activeLoginPage.isClosed()) {
600
+ const browser = await launchBrowser();
601
+ activeLoginPage = await browser.newPage();
602
+ activeLoginPage.setDefaultTimeout(20000);
603
+ await activeLoginPage.goto(meta.home, { waitUntil: 'domcontentloaded' });
604
+ } else {
605
+ await activeLoginPage.bringToFront();
606
+ }
607
+ return { opened: true, platform: meta.label, url: meta.home };
608
+ }
609
+
610
+ export async function closeBrowser() {
611
+ if (activeBrowser) {
612
+ await activeBrowser.close().catch(() => {});
613
+ activeBrowser = null;
614
+ activeLoginPage = null;
615
+ }
616
+ }
617
+
618
+ export async function searchPeerProfile({ platform = '小红书', account }) {
619
+ const key = platform === '抖音' ? 'douyin' : 'xhs';
620
+ const page = await openPage(PLATFORM_URLS[key].home);
621
+ try {
622
+ if (await looksLoggedOut(page, key)) {
623
+ const error = new Error(`${PLATFORM_URLS[key].label}需要登录。请先在设置里打开登录窗口。`);
624
+ error.code = 'LOGIN_REQUIRED';
625
+ throw error;
626
+ }
627
+ const profile = key === 'xhs'
628
+ ? await resolveXhsProfile(page, String(account || ''))
629
+ : await resolveDouyinProfile(page, String(account || ''));
630
+ return {
631
+ platform: PLATFORM_URLS[key].label,
632
+ name: profile.name || String(account).replace(/^@/, ''),
633
+ avatar: profile.avatar || '',
634
+ followers: profile.followers || '—',
635
+ postCount: profile.posts.length,
636
+ profileUrl: profile.profileUrl,
637
+ posts: profile.posts,
638
+ collectedAt: new Date().toISOString(),
639
+ };
640
+ } catch (error) {
641
+ await debugShot(page, `peer-${key}`);
642
+ throw error;
643
+ } finally {
644
+ await page.close().catch(() => {});
645
+ }
646
+ }
647
+
648
+ export async function openLink(url) {
649
+ let target;
650
+ try {
651
+ target = new URL(url);
652
+ } catch {
653
+ throw new Error('链接无效');
654
+ }
655
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') throw new Error('只支持 http/https 链接');
656
+ const browser = await launchBrowser();
657
+ const page = await browser.newPage();
658
+ page.setDefaultTimeout(20000);
659
+ await page.goto(target.href, { waitUntil: 'domcontentloaded' });
660
+ await page.bringToFront();
661
+ return { opened: true, url: target.href };
662
+ }