ppxc-leads-mcp 0.1.0

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 (35) hide show
  1. package/README.md +115 -0
  2. package/dist/backend/config.js +13 -0
  3. package/dist/backend/ppxc-client.js +156 -0
  4. package/dist/backend/ppxc-login-window.js +168 -0
  5. package/dist/backend/token-store.js +65 -0
  6. package/dist/browser/comments.js +9 -0
  7. package/dist/browser/douyin-runner.js +15 -0
  8. package/dist/browser/kernel/electron-profile.js +32 -0
  9. package/dist/browser/kernel/logger.js +57 -0
  10. package/dist/browser/kernel/page-scripts/index.js +1422 -0
  11. package/dist/browser/kernel/runner-page-manager.js +145 -0
  12. package/dist/browser/kernel/runner-page-session.js +1465 -0
  13. package/dist/browser/kernel/runner-page-session.search-parser.js +187 -0
  14. package/dist/browser/kernel/runner-page-session.user-agent.js +32 -0
  15. package/dist/browser/platform-runner.js +312 -0
  16. package/dist/browser/platforms/detect-platform.js +33 -0
  17. package/dist/browser/platforms/douyin/adapter.js +162 -0
  18. package/dist/browser/platforms/douyin/comments.js +130 -0
  19. package/dist/browser/platforms/kuaishou/adapter.js +178 -0
  20. package/dist/browser/platforms/kuaishou/comments.js +170 -0
  21. package/dist/browser/platforms/registry.js +23 -0
  22. package/dist/browser/platforms/shared/cdp-json-waiter.js +75 -0
  23. package/dist/browser/platforms/types.js +3 -0
  24. package/dist/browser/platforms/xiaohongshu/adapter.js +233 -0
  25. package/dist/browser/platforms/xiaohongshu/comments.js +184 -0
  26. package/dist/browser/usage-throttle.js +72 -0
  27. package/dist/main.js +64 -0
  28. package/dist/mcp/battle-report.js +325 -0
  29. package/dist/mcp/content-insights.js +66 -0
  30. package/dist/mcp/diagnostics.js +79 -0
  31. package/dist/mcp/server.js +829 -0
  32. package/dist/version.js +19 -0
  33. package/package.json +43 -0
  34. package/scripts/launch-mcp.cjs +96 -0
  35. package/skills/ppxc-find-customers/SKILL.md +110 -0
@@ -0,0 +1,1465 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RunnerPageSession = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const electron_1 = require("electron");
6
+ const logger_1 = require("./logger");
7
+ const page_scripts_1 = require("./page-scripts");
8
+ const runner_page_session_search_parser_1 = require("./runner-page-session.search-parser");
9
+ const runner_page_session_user_agent_1 = require("./runner-page-session.user-agent");
10
+ const DOUYIN_SCROLL_MORE_SCRIPT = `(async () => {
11
+ try {
12
+ const opts = (globalThis.__OPC1_SCROLL_OPTS || {});
13
+ const times = Math.max(1, Math.min(12, Number(opts.times) || 3));
14
+ const pauseMs = Math.max(200, Math.min(2000, Number(opts.pauseMs) || 450));
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ const before = Math.max(
17
+ document.documentElement.scrollHeight || 0,
18
+ document.body ? document.body.scrollHeight || 0 : 0,
19
+ );
20
+ for (let i = 0; i < times; i++) {
21
+ window.scrollBy({ top: window.innerHeight, behavior: 'instant' });
22
+ await sleep(pauseMs);
23
+ }
24
+ const afterHeight = Math.max(
25
+ document.documentElement.scrollHeight || 0,
26
+ document.body ? document.body.scrollHeight || 0 : 0,
27
+ );
28
+ return { ok: true, before, afterHeight, stepsDone: times };
29
+ } catch (err) {
30
+ return { ok: false, message: (err && err.message) || String(err) };
31
+ }
32
+ })();`;
33
+ const DOUYIN_OPEN_LOGIN_PANEL_SCRIPT = `(async () => {
34
+ try {
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+ const visible = (el) => {
37
+ if (!el) return false;
38
+ const rect = el.getBoundingClientRect();
39
+ const style = window.getComputedStyle(el);
40
+ return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
41
+ };
42
+ const hasSessionId = /(?:^|;\\s*)(sessionid|sessionid_ss|sid_guard)=/.test(String(document.cookie || ''));
43
+ const findLoginModal = () => document.querySelector(
44
+ '[data-e2e="login-modal"], [class*="login-mask"], [id="login-pannel"], [class*="login-modal"]'
45
+ );
46
+ const findQrSignal = () => document.querySelector(
47
+ 'canvas, img[src*="qr"], img[src*="scan"], [class*="qr"], [class*="qrcode"], [class*="scan"]'
48
+ );
49
+ const modalNow = findLoginModal();
50
+ if (modalNow && visible(modalNow)) {
51
+ return { ok: true, status: 'login_required', opened: true, clicked: false, hasQrSignal: Boolean(findQrSignal()) };
52
+ }
53
+ if (hasSessionId) {
54
+ return { ok: true, status: 'logged_in', opened: false, clicked: false, hasQrSignal: false };
55
+ }
56
+
57
+ const selectors = [
58
+ '[data-e2e="login-button"]',
59
+ '[data-e2e*="login"]',
60
+ '[class*="login"]',
61
+ 'button',
62
+ 'a',
63
+ '[role="button"]',
64
+ '[tabindex]'
65
+ ];
66
+ const candidates = [];
67
+ for (const selector of selectors) {
68
+ for (const el of Array.from(document.querySelectorAll(selector))) {
69
+ if (!visible(el)) continue;
70
+ const text = String(el.innerText || el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '')
71
+ .replace(/\\s+/g, ' ')
72
+ .trim();
73
+ const cls = String(el.className || '');
74
+ const id = String(el.id || '');
75
+ const attrs = [text, cls, id, String(el.getAttribute('data-e2e') || '')].join(' ');
76
+ if (/扫码登录|登录\\/注册|登录后|立即登录|手机号登录|密码登录|未登录|\\blogin\\b/i.test(attrs) || /^登录$/.test(text)) {
77
+ candidates.push({ el, text });
78
+ }
79
+ }
80
+ }
81
+
82
+ const target = candidates[0] && candidates[0].el;
83
+ if (target) {
84
+ target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
85
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
86
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
87
+ target.click();
88
+ }
89
+
90
+ const startedAt = Date.now();
91
+ while (Date.now() - startedAt < 6000) {
92
+ const modal = findLoginModal();
93
+ const qr = findQrSignal();
94
+ if ((modal && visible(modal)) || (qr && visible(qr))) {
95
+ return {
96
+ ok: true,
97
+ status: 'login_required',
98
+ opened: true,
99
+ clicked: Boolean(target),
100
+ hasQrSignal: Boolean(qr),
101
+ };
102
+ }
103
+ await sleep(250);
104
+ }
105
+
106
+ return {
107
+ ok: false,
108
+ status: 'unknown',
109
+ code: target ? 'login_panel_not_visible' : 'login_trigger_missing',
110
+ message: target ? 'login trigger clicked but panel did not become visible' : 'login trigger not found',
111
+ opened: false,
112
+ clicked: Boolean(target),
113
+ hasQrSignal: false,
114
+ };
115
+ } catch (err) {
116
+ return {
117
+ ok: false,
118
+ status: 'unknown',
119
+ code: 'page_throw',
120
+ message: (err && err.message) || String(err),
121
+ opened: false,
122
+ clicked: false,
123
+ hasQrSignal: false,
124
+ };
125
+ }
126
+ })();`;
127
+ const log = logger_1.logger.scope("page-session");
128
+ const DEFAULT_PROBE_TIMEOUT_MS = 15000;
129
+ const DEFAULT_COOKIE_SNAPSHOT_TIMEOUT_MS = 3000;
130
+ const DEFAULT_LOGIN_PANEL_TIMEOUT_MS = 8500;
131
+ const DEFAULT_PROFILE_SNAPSHOT_TIMEOUT_MS = 2500;
132
+ const DEFAULT_DOM_MAX_SCROLL_ROUNDS = 5;
133
+ const DEFAULT_DOM_MIN_SCROLL_ROUNDS = 3;
134
+ const DEFAULT_DOM_RENDER_WAIT_MS = 650;
135
+ const DOUYIN_SEARCH_HOOK_BINDING = "__OPC1_DOUYIN_SEARCH_CAPTURE__";
136
+ const MAX_PAGE_HOOK_PAYLOAD_CHARS = 5 * 1024 * 1024;
137
+ const ENABLE_SEARCH_SNIFFER = process.env.OPC1_DISABLE_DOUYIN_SEARCH_SNIFFER !== "1";
138
+ const ENABLE_PAGE_SEARCH_HOOK = process.env.OPC1_DISABLE_DOUYIN_PAGE_SEARCH_HOOK !== "1";
139
+ function readBoundedIntEnv(name, fallback, min, max) {
140
+ const parsed = Math.floor(Number(process.env[name]));
141
+ if (!Number.isFinite(parsed))
142
+ return fallback;
143
+ return Math.max(min, Math.min(max, parsed));
144
+ }
145
+ function summarizePayloadShape(payload) {
146
+ const keys = Object.keys(payload).slice(0, 12);
147
+ const counts = keys
148
+ .map((key) => {
149
+ const value = payload[key];
150
+ if (Array.isArray(value))
151
+ return `${key}[${value.length}]`;
152
+ if (value && typeof value === "object")
153
+ return `${key}{}`;
154
+ return key;
155
+ })
156
+ .join(",");
157
+ return counts.slice(0, 300);
158
+ }
159
+ class AsyncSemaphore {
160
+ constructor(_limit) {
161
+ this._limit = _limit;
162
+ this._active = 0;
163
+ this._queue = [];
164
+ }
165
+ async run(body) {
166
+ if (this._active >= this._limit) {
167
+ await new Promise((resolve) => this._queue.push(resolve));
168
+ }
169
+ this._active += 1;
170
+ try {
171
+ return await body();
172
+ }
173
+ finally {
174
+ this._active = Math.max(0, this._active - 1);
175
+ const next = this._queue.shift();
176
+ if (next)
177
+ next();
178
+ }
179
+ }
180
+ }
181
+ const DEFAULT_NAV_CONCURRENCY = process.env.NODE_ENV === "production" ? 2 : 3;
182
+ const DOUYIN_NAV_CONCURRENCY = readBoundedIntEnv("OPC1_DOUYIN_NAV_CONCURRENCY", DEFAULT_NAV_CONCURRENCY, 1, 8);
183
+ const DOUYIN_NAV_STAGGER_MS = readBoundedIntEnv("OPC1_DOUYIN_NAV_STAGGER_MS", 900, 0, 5000);
184
+ const douyinNavigationSemaphore = new AsyncSemaphore(DOUYIN_NAV_CONCURRENCY);
185
+ let lastDouyinNavigationStartedAt = 0;
186
+ async function withDouyinNavigationPermit(slotId, body) {
187
+ return douyinNavigationSemaphore.run(async () => {
188
+ const sinceLast = Date.now() - lastDouyinNavigationStartedAt;
189
+ const waitMs = Math.max(0, DOUYIN_NAV_STAGGER_MS - sinceLast);
190
+ if (waitMs > 0) {
191
+ log.info(`slot ${slotId} waiting douyin navigation gate waitMs=${waitMs}`);
192
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
193
+ }
194
+ lastDouyinNavigationStartedAt = Date.now();
195
+ return await body();
196
+ });
197
+ }
198
+ function withTimeout(promise, timeoutMs, onTimeoutMessage) {
199
+ return new Promise((resolve, reject) => {
200
+ let settled = false;
201
+ const timer = setTimeout(() => {
202
+ if (settled)
203
+ return;
204
+ settled = true;
205
+ reject(new Error(onTimeoutMessage()));
206
+ }, timeoutMs);
207
+ promise.then((v) => {
208
+ if (settled)
209
+ return;
210
+ settled = true;
211
+ clearTimeout(timer);
212
+ resolve(v);
213
+ }, (err) => {
214
+ if (settled)
215
+ return;
216
+ settled = true;
217
+ clearTimeout(timer);
218
+ reject(err instanceof Error ? err : new Error(String(err)));
219
+ });
220
+ });
221
+ }
222
+ class RunnerPageSession {
223
+ constructor(opts) {
224
+ this._win = null;
225
+ this._crashed = false;
226
+ this._closed = false;
227
+ this._warmed = false;
228
+ this._loggedInCached = null;
229
+ this._debugger = null;
230
+ this._debuggerAttached = false;
231
+ this._searchRequests = new Map();
232
+ this._searchBatches = [];
233
+ this._searchBatchWaiters = [];
234
+ this._lastSnifferError = null;
235
+ this._lastSearchBodyUnavailableError = null;
236
+ this._searchResponseSeenCount = 0;
237
+ this._lastSearchResponseStatus = null;
238
+ this._lastSearchResponseBodyReadable = null;
239
+ this._lastSearchResponseShape = null;
240
+ this._pageSearchHookInstalled = false;
241
+ this._pageSearchHookReady = null;
242
+ this._keywordsSinceWindowCreated = 0;
243
+ this._lastRecycleReason = null;
244
+ this._willRecycleSession = false;
245
+ this._slotId = opts.slotId;
246
+ this._partition = opts.partition ?? "persist:opc1-runner-0";
247
+ this._cookieDomain = opts.cookieDomain ?? ".douyin.com";
248
+ this._enableSearchSniffer = opts.enableSearchSniffer !== false;
249
+ this._userAgentOverride = opts.userAgent?.trim() || null;
250
+ this._sessionId = (0, node_crypto_1.randomUUID)();
251
+ this._windowCreateIndex = Math.max(1, Math.floor(Number(opts.windowCreateIndex) || 1));
252
+ this._createdAt = new Date().toISOString();
253
+ this._lastRecycleReason = opts.lastRecycleReason ?? null;
254
+ this._navigationTimeoutMs = opts.navigationTimeoutMs ?? 25000;
255
+ this._collectMaxWaitMs = opts.collectMaxWaitMs ?? 12000;
256
+ this._collectMaxItems = opts.collectMaxItems ?? 30;
257
+ this._probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
258
+ this._cookieSnapshotTimeoutMs =
259
+ opts.cookieSnapshotTimeoutMs ?? DEFAULT_COOKIE_SNAPSHOT_TIMEOUT_MS;
260
+ this._collectMaxScrollRounds = readBoundedIntEnv("OPC1_DOUYIN_DOM_MAX_SCROLL_ROUNDS", DEFAULT_DOM_MAX_SCROLL_ROUNDS, 3, 8);
261
+ this._collectMinScrollRounds = Math.min(this._collectMaxScrollRounds, readBoundedIntEnv("OPC1_DOUYIN_DOM_MIN_SCROLL_ROUNDS", DEFAULT_DOM_MIN_SCROLL_ROUNDS, 1, 5));
262
+ this._collectRenderWaitMs = readBoundedIntEnv("OPC1_DOUYIN_DOM_RENDER_WAIT_MS", DEFAULT_DOM_RENDER_WAIT_MS, 250, 2000);
263
+ this._win = new electron_1.BrowserWindow({
264
+ width: 1200,
265
+ height: 800,
266
+ show: opts.visible === true,
267
+ title: opts.windowTitle ?? `OPC1 Runner #${this._slotId}`,
268
+ webPreferences: {
269
+ contextIsolation: true,
270
+ nodeIntegration: false,
271
+ partition: this._partition,
272
+ sandbox: true,
273
+ backgroundThrottling: false,
274
+ },
275
+ });
276
+ this._win.webContents.setAudioMuted(true);
277
+ this._win.webContents.on("did-attach-webview", (_e, attached) => {
278
+ try {
279
+ attached.setAudioMuted(true);
280
+ }
281
+ catch {
282
+ }
283
+ });
284
+ const userAgent = this._userAgentOverride ?? (0, runner_page_session_user_agent_1.resolveDouyinUserAgent)(process.platform, process.versions);
285
+ this._win.webContents.setUserAgent(userAgent);
286
+ log.info(`slot ${this._slotId} ua applied platform=${process.platform} partition=${this._partition}`);
287
+ const wc = this._win.webContents;
288
+ wc.setWindowOpenHandler(({ url }) => {
289
+ if (/^https?:\/\//i.test(url)) {
290
+ wc.loadURL(url).catch((err) => {
291
+ log.warn(`slot ${this._slotId} load popup url failed`, {
292
+ url,
293
+ msg: err instanceof Error ? err.message : String(err),
294
+ });
295
+ });
296
+ }
297
+ else {
298
+ log.info(`slot ${this._slotId} blocked external popup url`, {
299
+ scheme: extractUrlScheme(url),
300
+ });
301
+ }
302
+ return { action: "deny" };
303
+ });
304
+ const blockExternalNavigation = (event, url, source) => {
305
+ if (isHttpLikeUrl(url))
306
+ return;
307
+ event.preventDefault();
308
+ log.info(`slot ${this._slotId} blocked external navigation`, {
309
+ source,
310
+ scheme: extractUrlScheme(url),
311
+ });
312
+ };
313
+ wc.on("will-navigate", (event, url) => {
314
+ blockExternalNavigation(event, url, "will-navigate");
315
+ });
316
+ const frameNavigationContents = wc;
317
+ frameNavigationContents.on("will-frame-navigate", (event, url) => {
318
+ blockExternalNavigation(event, url, "will-navigate");
319
+ });
320
+ wc.on("will-redirect", (event, url) => {
321
+ blockExternalNavigation(event, url, "will-redirect");
322
+ });
323
+ wc.on("did-start-navigation", (event, url, isInPlace, isMainFrame) => {
324
+ if (isInPlace || isHttpLikeUrl(url))
325
+ return;
326
+ event.preventDefault();
327
+ log.info(`slot ${this._slotId} blocked external navigation`, {
328
+ source: "did-start-navigation",
329
+ isMainFrame,
330
+ scheme: extractUrlScheme(url),
331
+ });
332
+ });
333
+ wc.session.webRequest.onBeforeRequest((details, callback) => {
334
+ const url = String(details.url || "");
335
+ if (isHttpLikeUrl(url)) {
336
+ callback({ cancel: false });
337
+ return;
338
+ }
339
+ log.info(`slot ${this._slotId} blocked external request`, {
340
+ scheme: extractUrlScheme(url),
341
+ resourceType: details.resourceType,
342
+ });
343
+ callback({ cancel: true });
344
+ });
345
+ wc.on("render-process-gone", (_e, det) => {
346
+ this._crashed = true;
347
+ log.warn(`slot ${this._slotId} render-process-gone`, { reason: det.reason });
348
+ });
349
+ wc.on("destroyed", () => {
350
+ this._closed = true;
351
+ });
352
+ this._win.on("closed", () => {
353
+ this._closed = true;
354
+ this._win = null;
355
+ });
356
+ if (this._enableSearchSniffer && ENABLE_SEARCH_SNIFFER) {
357
+ this._attachSearchSniffer();
358
+ }
359
+ else {
360
+ log.info(`slot ${this._slotId} cdp search sniffer disabled`);
361
+ }
362
+ }
363
+ get slotId() {
364
+ return this._slotId;
365
+ }
366
+ getWebContents() {
367
+ if (!this._win || this._win.isDestroyed())
368
+ return null;
369
+ return this._win.webContents;
370
+ }
371
+ get sessionId() {
372
+ return this._sessionId;
373
+ }
374
+ get windowCreateIndex() {
375
+ return this._windowCreateIndex;
376
+ }
377
+ get keywordsSinceWindowCreated() {
378
+ return this._keywordsSinceWindowCreated;
379
+ }
380
+ get createdAt() {
381
+ return this._createdAt;
382
+ }
383
+ get lastRecycleReason() {
384
+ return this._lastRecycleReason;
385
+ }
386
+ shouldRecycleOnRelease() {
387
+ return this._willRecycleSession;
388
+ }
389
+ markKeywordCompleted() {
390
+ this._keywordsSinceWindowCreated += 1;
391
+ return this._keywordsSinceWindowCreated;
392
+ }
393
+ markForRecycle(reason) {
394
+ this._willRecycleSession = true;
395
+ this._lastRecycleReason = reason;
396
+ }
397
+ getSessionDiagnostics() {
398
+ return {
399
+ sessionId: this._sessionId,
400
+ windowCreateIndex: this._windowCreateIndex,
401
+ slotId: this._slotId,
402
+ keywordsSinceWindowCreated: this._keywordsSinceWindowCreated,
403
+ createdAt: this._createdAt,
404
+ lastRecycleReason: this._lastRecycleReason,
405
+ willRecycleSession: this._willRecycleSession,
406
+ };
407
+ }
408
+ isAlive() {
409
+ return !this._closed && !this._crashed && !!this._win && !this._win.isDestroyed();
410
+ }
411
+ reveal() {
412
+ return this.revealStatus().alive;
413
+ }
414
+ revealStatus() {
415
+ const platform = process.platform;
416
+ if (!this._win || this._win.isDestroyed()) {
417
+ return {
418
+ alive: false,
419
+ visible: false,
420
+ focused: false,
421
+ minimized: false,
422
+ bounds: null,
423
+ platform,
424
+ };
425
+ }
426
+ const win = this._win;
427
+ const safeBool = (fn) => {
428
+ try {
429
+ return fn() === true;
430
+ }
431
+ catch {
432
+ return false;
433
+ }
434
+ };
435
+ try {
436
+ if (safeBool(() => win.isMinimized())) {
437
+ try {
438
+ win.restore();
439
+ }
440
+ catch {
441
+ }
442
+ }
443
+ if (!safeBool(() => win.isVisible())) {
444
+ try {
445
+ win.show();
446
+ }
447
+ catch {
448
+ }
449
+ }
450
+ try {
451
+ win.setAlwaysOnTop(true, "screen-saver");
452
+ }
453
+ catch {
454
+ }
455
+ try {
456
+ win.focus();
457
+ }
458
+ catch {
459
+ }
460
+ try {
461
+ win.moveTop();
462
+ }
463
+ catch {
464
+ }
465
+ if (platform === "win32") {
466
+ try {
467
+ win.flashFrame(true);
468
+ }
469
+ catch {
470
+ }
471
+ setTimeout(() => {
472
+ if (this._win && !this._win.isDestroyed()) {
473
+ try {
474
+ if (this._win.isMinimized())
475
+ this._win.restore();
476
+ this._win.show();
477
+ this._win.focus();
478
+ this._win.moveTop();
479
+ this._win.flashFrame(false);
480
+ }
481
+ catch {
482
+ }
483
+ }
484
+ }, 180);
485
+ }
486
+ setTimeout(() => {
487
+ try {
488
+ if (this._win && !this._win.isDestroyed()) {
489
+ this._win.setAlwaysOnTop(false);
490
+ }
491
+ }
492
+ catch {
493
+ }
494
+ }, 800);
495
+ }
496
+ catch (err) {
497
+ log.warn(`slot ${this._slotId} reveal failed`, {
498
+ msg: err instanceof Error ? err.message : String(err),
499
+ });
500
+ }
501
+ let bounds = null;
502
+ try {
503
+ const b = win.getBounds();
504
+ bounds = { x: b.x, y: b.y, width: b.width, height: b.height };
505
+ }
506
+ catch {
507
+ }
508
+ return {
509
+ alive: !win.isDestroyed(),
510
+ visible: safeBool(() => win.isVisible()),
511
+ focused: safeBool(() => win.isFocused()),
512
+ minimized: safeBool(() => win.isMinimized()),
513
+ bounds,
514
+ platform,
515
+ };
516
+ }
517
+ hide() {
518
+ if (!this._win || this._win.isDestroyed())
519
+ return;
520
+ if (this._win.isVisible())
521
+ this._win.hide();
522
+ }
523
+ async loadUrl(url) {
524
+ return await withDouyinNavigationPermit(this._slotId, async () => {
525
+ this._assertAlive();
526
+ const wc = this._win.webContents;
527
+ const timeoutMs = this._navigationTimeoutMs;
528
+ let timer = null;
529
+ const cleanupListeners = [];
530
+ const waitSettled = new Promise((resolve, reject) => {
531
+ let done = false;
532
+ const finish = (err) => {
533
+ if (done)
534
+ return;
535
+ done = true;
536
+ if (timer) {
537
+ clearTimeout(timer);
538
+ timer = null;
539
+ }
540
+ for (const off of cleanupListeners) {
541
+ try {
542
+ off();
543
+ }
544
+ catch {
545
+ }
546
+ }
547
+ if (err)
548
+ reject(err);
549
+ else
550
+ resolve();
551
+ };
552
+ const onLoad = () => finish();
553
+ const onFail = (_e, errCode, errDesc, validatedUrl, isMain) => {
554
+ if (!isMain)
555
+ return;
556
+ if (errCode === -3) {
557
+ log.info(`slot ${this._slotId} loadUrl aborted path=event url=${validatedUrl}`);
558
+ return;
559
+ }
560
+ finish(new Error(`navigation failed: ${errCode} ${errDesc} (${validatedUrl})`));
561
+ };
562
+ const onCrash = () => finish(new Error("render process gone during navigation"));
563
+ wc.on("did-finish-load", onLoad);
564
+ wc.on("dom-ready", onLoad);
565
+ wc.on("did-fail-load", onFail);
566
+ wc.on("render-process-gone", onCrash);
567
+ cleanupListeners.push(() => wc.removeListener("did-finish-load", onLoad), () => wc.removeListener("dom-ready", onLoad), () => wc.removeListener("did-fail-load", onFail), () => wc.removeListener("render-process-gone", onCrash));
568
+ timer = setTimeout(() => {
569
+ finish(new Error(`navigation timeout after ${timeoutMs}ms`));
570
+ }, timeoutMs);
571
+ wc.loadURL(url).then(() => finish(), (err) => {
572
+ const raw = err instanceof Error ? err : new Error(String(err));
573
+ if (isNavigationAbortedError(raw)) {
574
+ log.info(`slot ${this._slotId} loadUrl aborted path=promise url=${url} msg=${raw.message}`);
575
+ return;
576
+ }
577
+ finish(raw);
578
+ });
579
+ });
580
+ await waitSettled;
581
+ return {
582
+ finalUrl: wc.getURL(),
583
+ pageTitle: wc.getTitle() || undefined,
584
+ };
585
+ });
586
+ }
587
+ navigateNoWait(url) {
588
+ this._assertAlive();
589
+ this._win.webContents.loadURL(url).catch((err) => {
590
+ const raw = err instanceof Error ? err : new Error(String(err));
591
+ if (isNavigationAbortedError(raw)) {
592
+ log.info(`slot ${this._slotId} navigateNoWait aborted url=${url} msg=${raw.message}`);
593
+ return;
594
+ }
595
+ log.warn(`slot ${this._slotId} navigateNoWait failed`, {
596
+ url,
597
+ msg: raw.message,
598
+ });
599
+ });
600
+ }
601
+ currentUrl() {
602
+ if (!this.isAlive())
603
+ return "";
604
+ return this._win.webContents.getURL();
605
+ }
606
+ async reloadCurrentOrLoadFallback(fallbackUrl) {
607
+ const current = this.currentUrl();
608
+ const target = /^https?:\/\//i.test(current) ? current : fallbackUrl;
609
+ return await this.loadUrl(target);
610
+ }
611
+ async probe() {
612
+ this._assertAlive();
613
+ const started = Date.now();
614
+ try {
615
+ const raw = await withTimeout(this._win.webContents.executeJavaScript(page_scripts_1.DOUYIN_PROBE_SCRIPT, true), this._probeTimeoutMs, () => `probe timed out after ${this._probeTimeoutMs}ms (executeJavaScript stuck)`);
616
+ if (!raw || typeof raw !== "object") {
617
+ return {
618
+ ok: false,
619
+ status: "unknown",
620
+ code: "page_invalid",
621
+ message: "probe returned non-object",
622
+ };
623
+ }
624
+ const parsed = raw;
625
+ return {
626
+ ok: parsed.ok !== false,
627
+ status: parsed.status ?? "unknown",
628
+ details: parsed.details,
629
+ code: parsed.code,
630
+ message: parsed.message,
631
+ };
632
+ }
633
+ catch (err) {
634
+ const elapsedMs = Date.now() - started;
635
+ const msg = err instanceof Error ? err.message : String(err);
636
+ if (/probe timed out/i.test(msg)) {
637
+ log.warn(`slot ${this._slotId} probe timed out timeoutMs=${this._probeTimeoutMs} elapsedMs=${elapsedMs}`);
638
+ return {
639
+ ok: true,
640
+ status: "unknown",
641
+ details: { url: "", title: "" },
642
+ message: msg,
643
+ };
644
+ }
645
+ log.warn(`slot ${this._slotId} probe threw elapsedMs=${elapsedMs} msg=${msg}`);
646
+ return {
647
+ ok: false,
648
+ status: "unknown",
649
+ code: "execute_throw",
650
+ message: msg,
651
+ };
652
+ }
653
+ }
654
+ async openLoginPanel() {
655
+ this._assertAlive();
656
+ const raw = await safeJson(this._win.webContents, DOUYIN_OPEN_LOGIN_PANEL_SCRIPT, "openLoginPanel", DEFAULT_LOGIN_PANEL_TIMEOUT_MS);
657
+ return {
658
+ ok: raw.ok === true,
659
+ status: raw.status ?? "unknown",
660
+ opened: raw.opened === true,
661
+ clicked: raw.clicked === true,
662
+ hasQrSignal: raw.hasQrSignal === true,
663
+ code: raw.code,
664
+ message: raw.message,
665
+ };
666
+ }
667
+ async getSessionCookieSnapshot() {
668
+ this._assertAlive();
669
+ const started = Date.now();
670
+ const cookies = await withTimeout(this._win.webContents.session.cookies.get({ domain: this._cookieDomain }), this._cookieSnapshotTimeoutMs, () => `cookie snapshot timed out after ${this._cookieSnapshotTimeoutMs}ms`);
671
+ const elapsedMs = Date.now() - started;
672
+ if (elapsedMs > 1000) {
673
+ log.warn(`slot ${this._slotId} cookie snapshot slow elapsedMs=${elapsedMs}`);
674
+ }
675
+ const names = cookies.map((cookie) => cookie.name);
676
+ return {
677
+ hasSessionId: names.includes("sessionid"),
678
+ hasSessionIdSs: names.includes("sessionid_ss"),
679
+ hasSidGuard: names.includes("sid_guard"),
680
+ names,
681
+ };
682
+ }
683
+ async hasAnyLoginCookies(cookieNames) {
684
+ const snap = await this.getSessionCookieSnapshot();
685
+ return cookieNames.some((n) => snap.names.includes(n));
686
+ }
687
+ async fetchJsonInPage(url, timeoutMs = 15000) {
688
+ this._assertAlive();
689
+ const script = `(async () => {
690
+ const controller = new AbortController();
691
+ const timer = setTimeout(() => controller.abort(), ${Math.max(1000, Math.floor(timeoutMs))});
692
+ try {
693
+ const res = await fetch(${JSON.stringify(url)}, {
694
+ method: "GET",
695
+ credentials: "include",
696
+ headers: {
697
+ "accept": "application/json, text/plain, */*",
698
+ },
699
+ signal: controller.signal,
700
+ });
701
+ const text = await res.text();
702
+ let json = null;
703
+ try { json = text ? JSON.parse(text) : null; } catch {}
704
+ return {
705
+ ok: res.ok,
706
+ status: res.status,
707
+ json,
708
+ textHead: text.slice(0, 200),
709
+ textLength: text.length,
710
+ };
711
+ } catch (err) {
712
+ return {
713
+ ok: false,
714
+ status: 0,
715
+ error: (err && err.message) || String(err),
716
+ };
717
+ } finally {
718
+ clearTimeout(timer);
719
+ }
720
+ })();`;
721
+ const hostTimeoutMs = Math.max(2000, Math.floor(timeoutMs) + 2000);
722
+ const raw = await Promise.race([
723
+ this._win.webContents.executeJavaScript(script, true),
724
+ new Promise((resolve) => {
725
+ setTimeout(() => {
726
+ resolve({
727
+ ok: false,
728
+ status: 0,
729
+ error: `fetchJsonInPage host timeout after ${hostTimeoutMs}ms`,
730
+ });
731
+ }, hostTimeoutMs);
732
+ }),
733
+ ]);
734
+ if (!raw || typeof raw !== "object") {
735
+ return {
736
+ ok: false,
737
+ status: 0,
738
+ error: "fetchJsonInPage returned non-object",
739
+ };
740
+ }
741
+ return raw;
742
+ }
743
+ async getDouyinProfileSnapshot() {
744
+ this._assertAlive();
745
+ const script = `(() => {
746
+ const banned = /^(登录|注册|上传|发布|消息|关注|推荐|首页|我的|朋友|直播|商城|创作服务平台|抖音|搜索)$/;
747
+ const clean = (value) => {
748
+ const text = String(value || '').replace(/\\s+/g, ' ').trim();
749
+ if (!text || text.length < 2 || text.length > 32 || banned.test(text)) return '';
750
+ return text;
751
+ };
752
+ const pickText = (el) => clean(
753
+ el && (el.getAttribute('aria-label') || el.getAttribute('title') || el.textContent)
754
+ );
755
+ const candidates = [];
756
+ const push = (value) => {
757
+ const text = clean(value);
758
+ if (text && !candidates.includes(text)) candidates.push(text);
759
+ };
760
+ document.querySelectorAll('a[href*="/user/"], [class*="user"], [class*="avatar"], [data-e2e*="user"]').forEach((el) => {
761
+ push(pickText(el));
762
+ el.querySelectorAll && el.querySelectorAll('img[alt]').forEach((img) => push(img.getAttribute('alt')));
763
+ });
764
+ const seen = new Set();
765
+ const visit = (value, depth = 0) => {
766
+ if (!value || depth > 5) return;
767
+ if (typeof value === 'string') {
768
+ push(value);
769
+ return;
770
+ }
771
+ if (typeof value !== 'object' || seen.has(value)) return;
772
+ seen.add(value);
773
+ if (Array.isArray(value)) {
774
+ value.slice(0, 20).forEach((item) => visit(item, depth + 1));
775
+ return;
776
+ }
777
+ for (const [key, val] of Object.entries(value)) {
778
+ if (/^(nickname|nickName|nick_name|name|displayName|userName)$/i.test(key)) push(val);
779
+ if (/user|account|profile|author|self/i.test(key)) visit(val, depth + 1);
780
+ }
781
+ };
782
+ try {
783
+ for (let i = 0; i < localStorage.length; i++) {
784
+ const key = localStorage.key(i) || '';
785
+ if (!/user|account|profile|login|self|douyin/i.test(key)) continue;
786
+ const raw = localStorage.getItem(key);
787
+ if (!raw || raw.length > 200000) continue;
788
+ try { visit(JSON.parse(raw)); } catch { push(raw); }
789
+ }
790
+ } catch {}
791
+ return { displayName: candidates[0] || null };
792
+ })();`;
793
+ try {
794
+ const raw = await withTimeout(this._win.webContents.executeJavaScript(script, true), DEFAULT_PROFILE_SNAPSHOT_TIMEOUT_MS, () => `profile snapshot timed out after ${DEFAULT_PROFILE_SNAPSHOT_TIMEOUT_MS}ms`);
795
+ const displayName = raw && typeof raw === "object" && typeof raw.displayName === "string"
796
+ ? (raw.displayName.trim() || null)
797
+ : null;
798
+ return { displayName };
799
+ }
800
+ catch (err) {
801
+ log.warn(`slot ${this._slotId} profile snapshot failed`, {
802
+ msg: err instanceof Error ? err.message : String(err),
803
+ });
804
+ return { displayName: null };
805
+ }
806
+ }
807
+ async preScroll() {
808
+ this._assertAlive();
809
+ try {
810
+ await this._win.webContents.executeJavaScript(page_scripts_1.DOUYIN_PRESCROLL_SCRIPT, true);
811
+ }
812
+ catch (e) {
813
+ log.warn(`slot ${this._slotId} pre-scroll ignored`, {
814
+ msg: e instanceof Error ? e.message : String(e),
815
+ });
816
+ }
817
+ }
818
+ async scrollMore(times = 3, pauseMs = 450) {
819
+ if (!this.isAlive())
820
+ return { ok: false, heightGrew: false };
821
+ const wc = this._win.webContents;
822
+ const setOpts = `globalThis.__OPC1_SCROLL_OPTS = ${JSON.stringify({ times, pauseMs })};`;
823
+ try {
824
+ await wc.executeJavaScript(setOpts, true);
825
+ }
826
+ catch (e) {
827
+ log.warn(`slot ${this._slotId} scroll-more set-opts failed`, {
828
+ msg: e instanceof Error ? e.message : String(e),
829
+ });
830
+ }
831
+ try {
832
+ const raw = await wc.executeJavaScript(DOUYIN_SCROLL_MORE_SCRIPT, true);
833
+ if (!raw || typeof raw !== "object") {
834
+ return { ok: false, heightGrew: false };
835
+ }
836
+ const obj = raw;
837
+ const before = Number(obj.before ?? 0);
838
+ const after = Number(obj.afterHeight ?? 0);
839
+ return {
840
+ ok: obj.ok !== false,
841
+ heightGrew: after > before,
842
+ before,
843
+ after,
844
+ };
845
+ }
846
+ catch (e) {
847
+ log.warn(`slot ${this._slotId} scroll-more threw`, {
848
+ msg: e instanceof Error ? e.message : String(e),
849
+ });
850
+ return { ok: false, heightGrew: false };
851
+ }
852
+ }
853
+ async getSearchReadinessSnapshot() {
854
+ this._assertAlive();
855
+ const script = `(() => {
856
+ function count(sel) {
857
+ try { return document.querySelectorAll(sel).length; } catch (_) { return 0; }
858
+ }
859
+ function has(sel) {
860
+ try { return !!document.querySelector(sel); } catch (_) { return false; }
861
+ }
862
+ return {
863
+ readyState: String(document.readyState || ''),
864
+ videoLinkCount: count('a[href*="/video/"], a[href*="douyin.com/video/"]'),
865
+ candidateVideoSignals: count('[data-e2e*="video" i], [class*="video" i], [class*="aweme" i], [class*="card" i], [class*="result" i]'),
866
+ hasLoadingSignal: has('[class*="skeleton" i], [class*="loading" i], [class*="spinner" i], [class*="placeholder" i]'),
867
+ scrollHeight: Math.max(document.documentElement.scrollHeight || 0, document.body ? document.body.scrollHeight || 0 : 0),
868
+ };
869
+ })();`;
870
+ const raw = await this._win.webContents.executeJavaScript(script, true);
871
+ const obj = raw && typeof raw === "object" ? raw : {};
872
+ return {
873
+ readyState: typeof obj.readyState === "string" ? obj.readyState : "",
874
+ videoLinkCount: Number.isFinite(Number(obj.videoLinkCount)) ? Number(obj.videoLinkCount) : 0,
875
+ candidateVideoSignals: Number.isFinite(Number(obj.candidateVideoSignals))
876
+ ? Number(obj.candidateVideoSignals)
877
+ : 0,
878
+ hasLoadingSignal: obj.hasLoadingSignal === true,
879
+ scrollHeight: Number.isFinite(Number(obj.scrollHeight)) ? Number(obj.scrollHeight) : 0,
880
+ pendingSearchRequests: this._searchRequests.size,
881
+ };
882
+ }
883
+ async getSearchPageDomSnapshot() {
884
+ this._assertAlive();
885
+ const script = `(() => {
886
+ const text = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
887
+ const lowerBody = text(document.body && document.body.innerText).toLowerCase();
888
+ const title = text(document.title);
889
+ const inputSelectors = [
890
+ 'input[type="search"]',
891
+ 'input[placeholder*="搜索"]',
892
+ 'input[aria-label*="搜索"]',
893
+ 'input',
894
+ '[contenteditable="true"]'
895
+ ];
896
+ let searchInputValue = '';
897
+ for (const selector of inputSelectors) {
898
+ const el = document.querySelector(selector);
899
+ if (!el) continue;
900
+ searchInputValue = text(el.value || el.getAttribute('value') || el.textContent || el.getAttribute('aria-label'));
901
+ if (searchInputValue) break;
902
+ }
903
+ const count = (selector) => {
904
+ try { return document.querySelectorAll(selector).length; } catch (_) { return 0; }
905
+ };
906
+ const hasVideoLinkSignal = count('a[href*="/video/"], a[href*="douyin.com/video/"]') > 0;
907
+ const candidateVideoSignals = count('[data-e2e*="video" i], [class*="video" i], [class*="aweme" i], [class*="card" i], [class*="result" i]');
908
+ const homeHints = ['精选', '推荐', '首页', '朋友', '直播', '商城'];
909
+ const verificationHints = ['验证码中间页', '验证码', '安全验证', '人机验证', '请完成验证', '访问异常', '环境异常', '滑动验证', '请拖动滑块'];
910
+ const loginHints = ['扫码登录', '登录后', '登录抖音', '手机号登录'];
911
+ return {
912
+ pageTitle: title,
913
+ searchInputValue,
914
+ hasVideoLinkSignal,
915
+ candidateVideoSignals,
916
+ pageLooksLikeHome: homeHints.some((hit) => title.includes(hit) || lowerBody.includes(hit.toLowerCase())),
917
+ pageLooksLikeLogin: loginHints.some((hit) => lowerBody.includes(hit.toLowerCase())),
918
+ pageLooksLikeVerification: verificationHints.some((hit) => title.includes(hit) || lowerBody.includes(hit.toLowerCase())),
919
+ };
920
+ })();`;
921
+ const raw = await this._win.webContents.executeJavaScript(script, true);
922
+ const obj = raw && typeof raw === "object" ? raw : {};
923
+ return {
924
+ pageTitle: typeof obj.pageTitle === "string" ? obj.pageTitle : "",
925
+ searchInputValue: typeof obj.searchInputValue === "string" ? obj.searchInputValue : "",
926
+ hasVideoLinkSignal: obj.hasVideoLinkSignal === true,
927
+ candidateVideoSignals: Number.isFinite(Number(obj.candidateVideoSignals))
928
+ ? Number(obj.candidateVideoSignals)
929
+ : 0,
930
+ pageLooksLikeHome: obj.pageLooksLikeHome === true,
931
+ pageLooksLikeLogin: obj.pageLooksLikeLogin === true,
932
+ pageLooksLikeVerification: obj.pageLooksLikeVerification === true,
933
+ };
934
+ }
935
+ async collect() {
936
+ this._assertAlive();
937
+ const wc = this._win.webContents;
938
+ const setOpts = `globalThis.__OPC1_COLLECT_OPTS = ${JSON.stringify({
939
+ maxWaitMs: this._collectMaxWaitMs,
940
+ maxItems: this._collectMaxItems,
941
+ maxScrollRounds: this._collectMaxScrollRounds,
942
+ minScrollRounds: this._collectMinScrollRounds,
943
+ renderWaitMs: this._collectRenderWaitMs,
944
+ })};`;
945
+ try {
946
+ await wc.executeJavaScript(setOpts, true);
947
+ }
948
+ catch (e) {
949
+ log.warn(`slot ${this._slotId} collect set-opts failed`, {
950
+ msg: e instanceof Error ? e.message : String(e),
951
+ });
952
+ }
953
+ return safeJson(wc, page_scripts_1.DOUYIN_COLLECT_SCRIPT, "collect");
954
+ }
955
+ async runUserLikeSearch(keyword, opts = {}) {
956
+ this._assertAlive();
957
+ const wc = this._win.webContents;
958
+ const maxWaitMs = Math.max(3000, Math.min(20000, Math.floor(Number(opts.maxWaitMs) || 12000)));
959
+ const timeoutMs = Math.max(maxWaitMs + 3000, Math.floor(Number(opts.timeoutMs) || maxWaitMs + 5000));
960
+ const setOpts = `globalThis.__OPC1_USER_LIKE_SEARCH_OPTS = ${JSON.stringify({
961
+ keyword,
962
+ maxWaitMs,
963
+ pollIntervalMs: 300,
964
+ })};`;
965
+ try {
966
+ await withTimeout(wc.executeJavaScript(setOpts, true), 2000, () => "user-like search set opts timed out after 2000ms");
967
+ }
968
+ catch (e) {
969
+ log.warn(`slot ${this._slotId} user-like search set-opts failed`, {
970
+ msg: e instanceof Error ? e.message : String(e),
971
+ });
972
+ }
973
+ try {
974
+ const raw = await withTimeout(wc.executeJavaScript(page_scripts_1.DOUYIN_USER_LIKE_SEARCH_SCRIPT, true), timeoutMs, () => `user-like search timed out after ${timeoutMs}ms`);
975
+ if (!raw || typeof raw !== "object") {
976
+ return {
977
+ ok: false,
978
+ code: "page_invalid",
979
+ message: "user-like search returned non-object",
980
+ };
981
+ }
982
+ return raw;
983
+ }
984
+ catch (err) {
985
+ return {
986
+ ok: false,
987
+ code: "execute_throw",
988
+ message: err instanceof Error ? err.message : String(err),
989
+ };
990
+ }
991
+ }
992
+ async reset() {
993
+ if (!this.isAlive())
994
+ return;
995
+ const wc = this._win.webContents;
996
+ try {
997
+ if (wc.isLoading())
998
+ wc.stop();
999
+ await wc.loadURL("about:blank");
1000
+ }
1001
+ catch {
1002
+ }
1003
+ }
1004
+ async cancelActiveWork() {
1005
+ this.clearSearchBuffer({ resolveWaiters: true });
1006
+ await this.reset();
1007
+ }
1008
+ async close() {
1009
+ this._closed = true;
1010
+ this._detachSearchSniffer();
1011
+ const w = this._win;
1012
+ this._win = null;
1013
+ if (!w || w.isDestroyed())
1014
+ return;
1015
+ try {
1016
+ w.destroy();
1017
+ }
1018
+ catch {
1019
+ }
1020
+ }
1021
+ isWarmed() {
1022
+ return this._warmed;
1023
+ }
1024
+ markWarmed(loggedIn) {
1025
+ this._warmed = true;
1026
+ this._loggedInCached = loggedIn;
1027
+ }
1028
+ getCachedLoggedIn() {
1029
+ return this._loggedInCached;
1030
+ }
1031
+ invalidateWarmed() {
1032
+ this._warmed = false;
1033
+ this._loggedInCached = null;
1034
+ }
1035
+ isSearchSnifferReady() {
1036
+ return (this._debuggerAttached &&
1037
+ this.isAlive() &&
1038
+ this._lastSnifferError === null);
1039
+ }
1040
+ lastSnifferError() {
1041
+ return this._lastSnifferError;
1042
+ }
1043
+ lastSearchBodyUnavailableError() {
1044
+ return this._lastSearchBodyUnavailableError;
1045
+ }
1046
+ getSearchResponseDiagnostics() {
1047
+ return {
1048
+ cdpResponseSeen: this._searchResponseSeenCount > 0,
1049
+ responseBodyReadable: this._lastSearchResponseBodyReadable,
1050
+ responseStatusCode: this._lastSearchResponseStatus,
1051
+ rawResponseShape: this._lastSearchResponseShape,
1052
+ };
1053
+ }
1054
+ clearSearchBuffer(opts = {}) {
1055
+ this._searchRequests.clear();
1056
+ this._searchBatches.length = 0;
1057
+ this._lastSearchBodyUnavailableError = null;
1058
+ this._searchResponseSeenCount = 0;
1059
+ this._lastSearchResponseStatus = null;
1060
+ this._lastSearchResponseBodyReadable = null;
1061
+ this._lastSearchResponseShape = null;
1062
+ if (opts.resolveWaiters) {
1063
+ const waiters = this._searchBatchWaiters.splice(0);
1064
+ for (const resolve of waiters) {
1065
+ try {
1066
+ resolve(null);
1067
+ }
1068
+ catch {
1069
+ }
1070
+ }
1071
+ }
1072
+ }
1073
+ async awaitNextSearchBatch(timeoutMs) {
1074
+ if (this._searchBatches.length > 0) {
1075
+ return this._searchBatches.shift() ?? null;
1076
+ }
1077
+ return await new Promise((resolve) => {
1078
+ let settled = false;
1079
+ const onBatch = (b) => {
1080
+ if (settled)
1081
+ return;
1082
+ settled = true;
1083
+ clearTimeout(timer);
1084
+ resolve(b);
1085
+ };
1086
+ this._searchBatchWaiters.push(onBatch);
1087
+ const timer = setTimeout(() => {
1088
+ if (settled)
1089
+ return;
1090
+ settled = true;
1091
+ const idx = this._searchBatchWaiters.indexOf(onBatch);
1092
+ if (idx >= 0)
1093
+ this._searchBatchWaiters.splice(idx, 1);
1094
+ resolve(null);
1095
+ }, Math.max(50, timeoutMs));
1096
+ });
1097
+ }
1098
+ _attachSearchSniffer() {
1099
+ if (!this._win || this._win.isDestroyed())
1100
+ return;
1101
+ if (this._debuggerAttached)
1102
+ return;
1103
+ const wc = this._win.webContents;
1104
+ try {
1105
+ const dbg = wc.debugger;
1106
+ if (!dbg.isAttached()) {
1107
+ dbg.attach("1.3");
1108
+ }
1109
+ this._debugger = dbg;
1110
+ this._debuggerAttached = true;
1111
+ this._lastSnifferError = null;
1112
+ dbg.on("detach", (_e, reason) => {
1113
+ this._debuggerAttached = false;
1114
+ log.warn(`slot ${this._slotId} cdp detach reason=${reason}`);
1115
+ });
1116
+ dbg.on("message", (_e, method, params) => {
1117
+ const p = params;
1118
+ if (method === "Runtime.bindingCalled") {
1119
+ this._handlePageSearchHookBinding(p);
1120
+ }
1121
+ else if (method === "Network.requestWillBeSent") {
1122
+ const req = p.request;
1123
+ if (!req)
1124
+ return;
1125
+ const url = String(req.url ?? "");
1126
+ if (!(0, runner_page_session_search_parser_1.isLikelyDouyinSearchJsonResponse)(url))
1127
+ return;
1128
+ const requestId = String(p.requestId ?? "");
1129
+ const { offset, count } = parseSearchUrlOffsetCount(url);
1130
+ this._searchRequests.set(requestId, { url, offset, count });
1131
+ }
1132
+ else if (method === "Network.responseReceived") {
1133
+ const resp = p.response;
1134
+ if (!resp)
1135
+ return;
1136
+ const url = String(resp.url ?? "");
1137
+ const mimeType = String(resp.mimeType ?? "");
1138
+ if (!(0, runner_page_session_search_parser_1.isLikelyDouyinSearchJsonResponse)(url, mimeType))
1139
+ return;
1140
+ const requestId = String(p.requestId ?? "");
1141
+ const cur = this._searchRequests.get(requestId) ?? {
1142
+ url,
1143
+ offset: 0,
1144
+ count: 0,
1145
+ };
1146
+ cur.url = url;
1147
+ cur.mimeType = mimeType;
1148
+ cur.status = Number(resp.status ?? 0);
1149
+ this._searchResponseSeenCount += 1;
1150
+ this._lastSearchResponseStatus = Number.isFinite(cur.status) ? cur.status : null;
1151
+ this._lastSearchResponseBodyReadable = null;
1152
+ this._searchRequests.set(requestId, cur);
1153
+ }
1154
+ else if (method === "Network.loadingFinished") {
1155
+ const requestId = String(p.requestId ?? "");
1156
+ const meta = this._searchRequests.get(requestId);
1157
+ if (!meta)
1158
+ return;
1159
+ this._searchRequests.delete(requestId);
1160
+ this._fetchAndPublishSearchBody(requestId, meta).catch((err) => {
1161
+ this._lastSnifferError = err instanceof Error ? err.message : String(err);
1162
+ log.warn(`slot ${this._slotId} sniffer fetch body failed`, {
1163
+ msg: this._lastSnifferError,
1164
+ });
1165
+ });
1166
+ }
1167
+ else if (method === "Network.loadingFailed") {
1168
+ const requestId = String(p.requestId ?? "");
1169
+ if (this._searchRequests.has(requestId)) {
1170
+ this._searchRequests.delete(requestId);
1171
+ }
1172
+ }
1173
+ });
1174
+ if (ENABLE_PAGE_SEARCH_HOOK) {
1175
+ void this._installPageSearchHook(dbg);
1176
+ }
1177
+ else {
1178
+ log.info(`slot ${this._slotId} page search hook disabled`);
1179
+ }
1180
+ dbg
1181
+ .sendCommand("Network.enable")
1182
+ .then(() => {
1183
+ log.info(`slot ${this._slotId} cdp attached + Network.enable`);
1184
+ })
1185
+ .catch((err) => {
1186
+ this._lastSnifferError = err instanceof Error ? err.message : String(err);
1187
+ log.warn(`slot ${this._slotId} cdp Network.enable failed`, {
1188
+ msg: this._lastSnifferError,
1189
+ });
1190
+ });
1191
+ }
1192
+ catch (err) {
1193
+ const msg = err instanceof Error ? err.message : String(err);
1194
+ this._lastSnifferError = msg;
1195
+ this._debuggerAttached = false;
1196
+ log.warn(`slot ${this._slotId} cdp attach failed`, { msg });
1197
+ }
1198
+ }
1199
+ async _installPageSearchHook(dbg) {
1200
+ if (this._pageSearchHookInstalled)
1201
+ return;
1202
+ try {
1203
+ await dbg.sendCommand("Runtime.enable");
1204
+ await dbg.sendCommand("Page.enable");
1205
+ await dbg.sendCommand("Runtime.addBinding", {
1206
+ name: DOUYIN_SEARCH_HOOK_BINDING,
1207
+ });
1208
+ await dbg.sendCommand("Page.addScriptToEvaluateOnNewDocument", {
1209
+ source: page_scripts_1.DOUYIN_SEARCH_RESPONSE_HOOK_SCRIPT,
1210
+ });
1211
+ this._pageSearchHookInstalled = true;
1212
+ log.info(`slot ${this._slotId} page search hook installed`);
1213
+ const wc = this._win?.webContents;
1214
+ if (wc && !wc.isDestroyed()) {
1215
+ wc.executeJavaScript(page_scripts_1.DOUYIN_SEARCH_RESPONSE_HOOK_SCRIPT, true).catch((err) => {
1216
+ log.info(`slot ${this._slotId} current page search hook skipped`, {
1217
+ msg: err instanceof Error ? err.message : String(err),
1218
+ });
1219
+ });
1220
+ }
1221
+ }
1222
+ catch (err) {
1223
+ log.warn(`slot ${this._slotId} page search hook install failed`, {
1224
+ msg: err instanceof Error ? err.message : String(err),
1225
+ });
1226
+ }
1227
+ }
1228
+ _handlePageSearchHookBinding(params) {
1229
+ if (String(params.name ?? "") !== DOUYIN_SEARCH_HOOK_BINDING)
1230
+ return;
1231
+ const rawPayload = String(params.payload ?? "");
1232
+ if (!rawPayload || rawPayload.length > MAX_PAGE_HOOK_PAYLOAD_CHARS) {
1233
+ log.warn(`slot ${this._slotId} page search hook payload ignored`, {
1234
+ reason: rawPayload ? "payload_too_large" : "empty_payload",
1235
+ payloadLength: rawPayload.length,
1236
+ });
1237
+ return;
1238
+ }
1239
+ let envelope;
1240
+ try {
1241
+ envelope = JSON.parse(rawPayload);
1242
+ }
1243
+ catch (err) {
1244
+ log.warn(`slot ${this._slotId} page search hook envelope parse failed`, {
1245
+ msg: err instanceof Error ? err.message : String(err),
1246
+ });
1247
+ return;
1248
+ }
1249
+ const url = String(envelope.url ?? "");
1250
+ const contentType = String(envelope.contentType ?? "");
1251
+ if (!(0, runner_page_session_search_parser_1.isLikelyDouyinSearchJsonResponse)(url, contentType))
1252
+ return;
1253
+ const bodyText = String(envelope.body ?? "");
1254
+ this._searchResponseSeenCount += 1;
1255
+ this._lastSearchResponseStatus = Number.isFinite(Number(envelope.status))
1256
+ ? Number(envelope.status)
1257
+ : null;
1258
+ if (!bodyText || bodyText.length > MAX_PAGE_HOOK_PAYLOAD_CHARS) {
1259
+ this._lastSearchResponseBodyReadable = false;
1260
+ this._lastSearchResponseShape = bodyText ? "body_too_large" : "empty_body";
1261
+ log.warn(`slot ${this._slotId} page search hook body ignored`, {
1262
+ reason: bodyText ? "body_too_large" : "empty_body",
1263
+ url: (0, runner_page_session_search_parser_1.redactSearchUrl)(url),
1264
+ });
1265
+ return;
1266
+ }
1267
+ let payload;
1268
+ try {
1269
+ payload = JSON.parse(bodyText);
1270
+ this._lastSearchResponseBodyReadable = true;
1271
+ this._lastSearchResponseShape = summarizePayloadShape(payload);
1272
+ }
1273
+ catch (err) {
1274
+ this._lastSearchResponseBodyReadable = false;
1275
+ this._lastSearchResponseShape = "json_parse_failed";
1276
+ log.warn(`slot ${this._slotId} page search hook json parse failed`, {
1277
+ msg: err instanceof Error ? err.message : String(err),
1278
+ url: (0, runner_page_session_search_parser_1.redactSearchUrl)(url),
1279
+ });
1280
+ return;
1281
+ }
1282
+ const batch = (0, runner_page_session_search_parser_1.parseSearchItemPayload)(payload, {
1283
+ url,
1284
+ ...parseSearchUrlOffsetCount(url),
1285
+ });
1286
+ if (!batch)
1287
+ return;
1288
+ log.info(`slot ${this._slotId} page hook search batch`, {
1289
+ source: String(envelope.source ?? "unknown"),
1290
+ status: Number(envelope.status ?? 0),
1291
+ offset: batch.offset,
1292
+ count: batch.count,
1293
+ sourceUrlPattern: batch.sourceUrlPattern,
1294
+ parsedVideoCount: batch.parsedVideoCount,
1295
+ items: batch.items.length,
1296
+ hasMore: batch.hasMore,
1297
+ verifyCheck: batch.verifyCheck,
1298
+ });
1299
+ this._publishSearchBatch(batch);
1300
+ }
1301
+ async _fetchAndPublishSearchBody(requestId, meta) {
1302
+ if (!this._debuggerAttached || !this._debugger)
1303
+ return;
1304
+ let body;
1305
+ try {
1306
+ body = (await this._debugger.sendCommand("Network.getResponseBody", {
1307
+ requestId,
1308
+ }));
1309
+ }
1310
+ catch (err) {
1311
+ const msg = err instanceof Error ? err.message : String(err);
1312
+ this._lastSearchBodyUnavailableError = classifyCdpBodyError(msg);
1313
+ this._lastSearchResponseBodyReadable = false;
1314
+ this._lastSearchResponseShape = "body_unavailable";
1315
+ log.warn(`slot ${this._slotId} getResponseBody throw`, {
1316
+ requestId,
1317
+ errorType: this._lastSearchBodyUnavailableError,
1318
+ msg,
1319
+ url: (0, runner_page_session_search_parser_1.redactSearchUrl)(meta.url),
1320
+ });
1321
+ return;
1322
+ }
1323
+ let raw;
1324
+ try {
1325
+ raw = body.base64Encoded ? Buffer.from(body.body, "base64").toString("utf8") : body.body;
1326
+ }
1327
+ catch (err) {
1328
+ const msg = err instanceof Error ? err.message : String(err);
1329
+ this._lastSearchResponseBodyReadable = false;
1330
+ this._lastSearchResponseShape = "decode_failed";
1331
+ log.warn(`slot ${this._slotId} decode response body failed`, {
1332
+ requestId,
1333
+ errorType: "decode_failed",
1334
+ msg,
1335
+ url: (0, runner_page_session_search_parser_1.redactSearchUrl)(meta.url),
1336
+ });
1337
+ return;
1338
+ }
1339
+ let payload;
1340
+ try {
1341
+ payload = JSON.parse(raw);
1342
+ this._lastSearchResponseBodyReadable = true;
1343
+ this._lastSearchResponseShape = summarizePayloadShape(payload);
1344
+ }
1345
+ catch (err) {
1346
+ const msg = err instanceof Error ? err.message : String(err);
1347
+ this._lastSearchResponseBodyReadable = false;
1348
+ this._lastSearchResponseShape = "json_parse_failed";
1349
+ log.warn(`slot ${this._slotId} sniffer parse json failed`, {
1350
+ requestId,
1351
+ errorType: "json_parse_failed",
1352
+ msg,
1353
+ url: (0, runner_page_session_search_parser_1.redactSearchUrl)(meta.url),
1354
+ bodyHead: raw.slice(0, 80),
1355
+ });
1356
+ return;
1357
+ }
1358
+ const batch = (0, runner_page_session_search_parser_1.parseSearchItemPayload)(payload, meta);
1359
+ if (!batch)
1360
+ return;
1361
+ log.info(`slot ${this._slotId} cdp search batch`, {
1362
+ offset: batch.offset,
1363
+ count: batch.count,
1364
+ sourceUrlPattern: batch.sourceUrlPattern,
1365
+ parsedVideoCount: batch.parsedVideoCount,
1366
+ items: batch.items.length,
1367
+ hasMore: batch.hasMore,
1368
+ verifyCheck: batch.verifyCheck,
1369
+ });
1370
+ this._publishSearchBatch(batch);
1371
+ }
1372
+ _publishSearchBatch(batch) {
1373
+ if (this._searchBatchWaiters.length > 0) {
1374
+ const next = this._searchBatchWaiters.shift();
1375
+ if (next) {
1376
+ next(batch);
1377
+ return;
1378
+ }
1379
+ }
1380
+ this._searchBatches.push(batch);
1381
+ }
1382
+ _detachSearchSniffer() {
1383
+ if (!this._debugger)
1384
+ return;
1385
+ try {
1386
+ if (this._debugger.isAttached()) {
1387
+ this._debugger.detach();
1388
+ }
1389
+ }
1390
+ catch {
1391
+ }
1392
+ this._debugger = null;
1393
+ this._debuggerAttached = false;
1394
+ this._pageSearchHookInstalled = false;
1395
+ this._pageSearchHookReady = null;
1396
+ this._searchRequests.clear();
1397
+ this._searchBatches.length = 0;
1398
+ this._searchBatchWaiters.length = 0;
1399
+ }
1400
+ _assertAlive() {
1401
+ if (this._crashed)
1402
+ throw new Error("page crashed");
1403
+ if (this._closed || !this._win || this._win.isDestroyed()) {
1404
+ throw new Error("session already closed");
1405
+ }
1406
+ }
1407
+ }
1408
+ exports.RunnerPageSession = RunnerPageSession;
1409
+ function isNavigationAbortedError(err) {
1410
+ const msg = err.message ?? "";
1411
+ return /\bERR_ABORTED\b/i.test(msg) || /\(-3\)/.test(msg);
1412
+ }
1413
+ function parseSearchUrlOffsetCount(url) {
1414
+ let offset = 0;
1415
+ let count = 0;
1416
+ try {
1417
+ const u = new URL(url);
1418
+ offset = Number(u.searchParams.get("offset") ?? 0) || 0;
1419
+ count = Number(u.searchParams.get("count") ?? 0) || 0;
1420
+ }
1421
+ catch {
1422
+ }
1423
+ return { offset, count };
1424
+ }
1425
+ function classifyCdpBodyError(message) {
1426
+ if (/no resource|not found|not available/i.test(message))
1427
+ return "body_unavailable";
1428
+ if (/frame|detached|navigation/i.test(message))
1429
+ return "frame_detached";
1430
+ if (/protocol/i.test(message))
1431
+ return "protocol_error";
1432
+ if (/timeout/i.test(message))
1433
+ return "timeout";
1434
+ return "unknown";
1435
+ }
1436
+ function isHttpLikeUrl(url) {
1437
+ return /^(https?|about|data|blob):/i.test(String(url || ""));
1438
+ }
1439
+ function extractUrlScheme(url) {
1440
+ const match = String(url || "").match(/^([a-z][a-z0-9+.-]*):/i);
1441
+ return match ? match[1].toLowerCase() : "unknown";
1442
+ }
1443
+ async function safeJson(wc, script, label, timeoutMs) {
1444
+ try {
1445
+ const raw = timeoutMs
1446
+ ? await withTimeout(wc.executeJavaScript(script, true), timeoutMs, () => `${label} timed out after ${timeoutMs}ms`)
1447
+ : await wc.executeJavaScript(script, true);
1448
+ if (!raw || typeof raw !== "object") {
1449
+ return {
1450
+ ok: false,
1451
+ code: "page_invalid",
1452
+ message: `${label} returned non-object`,
1453
+ };
1454
+ }
1455
+ return raw;
1456
+ }
1457
+ catch (err) {
1458
+ return {
1459
+ ok: false,
1460
+ code: "execute_throw",
1461
+ message: err instanceof Error ? err.message : String(err),
1462
+ };
1463
+ }
1464
+ }
1465
+ //# sourceMappingURL=runner-page-session.js.map