@apmantza/greedysearch-pi 1.9.2 → 2.1.2

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 (39) hide show
  1. package/CHANGELOG.md +132 -2
  2. package/README.md +82 -47
  3. package/bin/cdp.mjs +1153 -1108
  4. package/bin/launch.mjs +9 -0
  5. package/bin/search.mjs +318 -81
  6. package/extractors/bing-copilot.mjs +48 -18
  7. package/extractors/chatgpt.mjs +553 -0
  8. package/extractors/common.mjs +213 -22
  9. package/extractors/consensus.mjs +655 -0
  10. package/extractors/consent.mjs +182 -18
  11. package/extractors/gemini.mjs +350 -217
  12. package/extractors/google-ai.mjs +129 -128
  13. package/extractors/logically.mjs +629 -0
  14. package/extractors/perplexity.mjs +547 -217
  15. package/extractors/selectors.mjs +3 -2
  16. package/extractors/semantic-scholar.mjs +219 -0
  17. package/package.json +8 -4
  18. package/skills/greedy-search/skill.md +20 -12
  19. package/src/fetcher.mjs +23 -1
  20. package/src/formatters/results.ts +185 -128
  21. package/src/search/browser-lifecycle.mjs +27 -5
  22. package/src/search/challenge-detect.mjs +205 -0
  23. package/src/search/chrome.mjs +653 -590
  24. package/src/search/constants.mjs +155 -39
  25. package/src/search/engines.mjs +114 -76
  26. package/src/search/fetch-source.mjs +566 -451
  27. package/src/search/pdf.mjs +68 -0
  28. package/src/search/progress.mjs +145 -0
  29. package/src/search/recovery.mjs +73 -45
  30. package/src/search/research.mjs +1419 -62
  31. package/src/search/scale-aware.mjs +93 -0
  32. package/src/search/simple-research.mjs +520 -0
  33. package/src/search/sources.mjs +52 -22
  34. package/src/search/synthesis-runner.mjs +105 -26
  35. package/src/search/synthesis.mjs +286 -246
  36. package/src/tools/greedy-search-handler.ts +129 -59
  37. package/src/tools/shared.ts +312 -186
  38. package/src/types.ts +110 -104
  39. package/test.mjs +537 -18
@@ -41,21 +41,31 @@ const GLOBAL_VAR = "__bingClipboard";
41
41
  // Bing Copilot-specific helpers
42
42
  // ============================================================================
43
43
 
44
+ async function detectSignInWall(tab) {
45
+ // Language-agnostic: if the chat input is absent but the page hosts
46
+ // known OAuth provider endpoints, we're on the Copilot login wall.
47
+ const code = `(() => {
48
+ if (document.querySelector('#userInput')) return false;
49
+ const links = Array.from(document.querySelectorAll('a[href], button'));
50
+ const hasOAuth = links.some(el => {
51
+ const h = (el.href || el.getAttribute('formaction') || '').toLowerCase();
52
+ return h.includes('login.microsoftonline.com')
53
+ || h.includes('appleid.apple.com')
54
+ || h.includes('accounts.google.com');
55
+ });
56
+ return hasOAuth;
57
+ })()`;
58
+ const result = await cdp(["eval", tab, code]).catch(() => "false");
59
+ return result === "true";
60
+ }
61
+
44
62
  async function extractAnswer(tab, env, query = "") {
45
- // In headless mode: snap the accessibility tree before spending ~18s on
46
- // clipboard polls. Copilot loads its input fine in headless but renders
47
- // responses behind a Cloudflare-protected iframe detecting that here
48
- // fast-fails to the visible retry instead of burning all the poll time.
49
- if (process.env.GREEDY_SEARCH_HEADLESS === "1") {
50
- const verification = await detectVerificationChallenge(tab, cdp);
51
- if (verification) {
52
- console.error(
53
- "[bing] Verification challenge detected — fast-failing to visible retry",
54
- );
55
- env.blockedBy = "verification";
56
- throw new Error("Verification challenge detected — headless blocked");
57
- }
58
- }
63
+ // Note: removed the prior headless fast-fail on Cloudflare detection.
64
+ // The new CDP-pierce + browser-level-click path in handleVerification
65
+ // can auto-clear the Turnstile checkbox from a fresh headless session,
66
+ // so we let the main flow run handleVerification and either click
67
+ // through or surface needs-human. We keep the env.blockedBy / signal
68
+ // surface so callers still see why an answer came back empty.
59
69
 
60
70
  // Wait for the assistant copy button to exist. On fresh Copilot
61
71
  // sessions the answer text can render before the button handler is
@@ -181,10 +191,15 @@ async function extractFromAccessibilityTree(tab, query = "") {
181
191
  const snap = await cdp(["snap", tab]).catch(() => "");
182
192
  if (!snap || (await detectVerificationChallenge(tab, cdp))) return "";
183
193
 
184
- const articleLines = snap
185
- .split("\n")
186
- .map((line) => line.match(/^\s*\[article\]\s+(.+)$/i)?.[1])
187
- .filter(Boolean);
194
+ // Linear article extraction — no regex. Avoids the ReDoS-prone
195
+ // /^\s*\[article\]\s+(.+)$/i pattern (SonarCloud hotspot js:S5852).
196
+ const articleLines = [];
197
+ for (const line of snap.split("\n")) {
198
+ const trimmed = line.trimStart();
199
+ if (!trimmed.toLowerCase().startsWith("[article]")) continue;
200
+ const after = trimmed.slice("[article]".length).trimStart();
201
+ if (after) articleLines.push(after);
202
+ }
188
203
  if (articleLines.length === 0) return "";
189
204
 
190
205
  const answer = pickAnswerArticle(articleLines, query);
@@ -419,12 +434,27 @@ async function main() {
419
434
  }
420
435
  }
421
436
 
437
+ // Detect sign-in wall before burning time waiting for an input that
438
+ // will never appear. Copilot now gates the chat behind Microsoft/Apple/Google
439
+ // login on fresh sessions.
440
+ if (await detectSignInWall(tab)) {
441
+ throw new Error(
442
+ "Copilot requires sign-in — please sign in with Microsoft, Apple, or Google in the visible browser window. Once signed in, cookies persist for future runs.",
443
+ );
444
+ }
445
+
422
446
  // Wait for React app to mount input (up to 15s, longer after verification)
423
447
  const inputReady = await waitForSelector(tab, S.input, 15000, 500);
424
448
  env.inputReady = inputReady;
425
449
  await new Promise((r) => setTimeout(r, jitter(300)));
426
450
 
427
451
  if (!inputReady) {
452
+ // If input still missing, double-check we didn't land on the login wall
453
+ if (await detectSignInWall(tab)) {
454
+ throw new Error(
455
+ "Copilot requires sign-in — please sign in with Microsoft, Apple, or Google in the visible browser window. Once signed in, cookies persist for future runs.",
456
+ );
457
+ }
428
458
  throw new Error(
429
459
  "Copilot input not found — verification may have failed or page is in unexpected state",
430
460
  );
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env node
2
+
3
+ // extractors/chatgpt.mjs
4
+ // Navigate chatgpt.com, submit query, wait for answer, extract answer + sources.
5
+ //
6
+ // Usage:
7
+ // node extractors/chatgpt.mjs "<query>" [--tab <prefix>]
8
+ //
9
+ // Output (stdout): JSON { answer, sources, query, url }
10
+ // Errors go to stderr only — stdout is always clean JSON for piping.
11
+
12
+ import {
13
+ buildEnvelope,
14
+ cdp,
15
+ formatAnswer,
16
+ getOrOpenTab,
17
+ handleError,
18
+ injectClipboardInterceptor,
19
+ jitter,
20
+ logStage,
21
+ outputJson,
22
+ parseArgs,
23
+ parseSourcesFromMarkdown,
24
+ parseSourcesFromMarkdownRefStyle,
25
+ prepareArgs,
26
+ validateQuery,
27
+ waitForSelector,
28
+ waitForStreamComplete,
29
+ } from "./common.mjs";
30
+ import { dismissConsent, handleVerification } from "./consent.mjs";
31
+
32
+ const GLOBAL_VAR = "__chatgptClipboard";
33
+ const PROSE_SELECTOR = "div.ProseMirror";
34
+ const SEND_SELECTOR = 'button[data-testid="send-button"]';
35
+ const COPY_SELECTOR = 'button[data-testid="copy-turn-action-button"]';
36
+
37
+ // ============================================================================
38
+ // ChatGPT-specific helpers
39
+ // ============================================================================
40
+
41
+ async function typeAndSubmit(tab, query) {
42
+ // Focus the ProseMirror editor
43
+ await cdp(["click", tab, PROSE_SELECTOR]);
44
+ await new Promise((r) => setTimeout(r, jitter(200)));
45
+
46
+ // Type via execCommand — this is the only reliable way to insert text into
47
+ // a ProseMirror editor (ChatGPT's input). CDP's Input.insertText targets
48
+ // input/textarea elements and doesn't dispatch the synthetic events that
49
+ // ProseMirror's editor view listens for, causing the send button to stay
50
+ // disabled in all-mode under CDP contention.
51
+ const typeResult = await cdp(
52
+ [
53
+ "eval",
54
+ tab,
55
+ `(() => {
56
+ const editor = document.querySelector('${PROSE_SELECTOR}');
57
+ if (!editor) return 'no-editor';
58
+ editor.focus();
59
+ const ok = document.execCommand('insertText', false, ${JSON.stringify(query)});
60
+ return ok ? 'ok' : 'exec-failed';
61
+ })()`,
62
+ ],
63
+ 5000,
64
+ );
65
+ if (typeResult !== "ok") {
66
+ throw new Error(`ChatGPT type failed: ${typeResult}`);
67
+ }
68
+ await new Promise((r) => setTimeout(r, jitter(300)));
69
+
70
+ // Click send button
71
+ const sendCode = `
72
+ (() => {
73
+ const btn = document.querySelector('${SEND_SELECTOR}');
74
+ if (!btn) return 'no-send';
75
+ if (btn.disabled) return 'send-disabled';
76
+ btn.click();
77
+ return 'ok';
78
+ })()
79
+ `;
80
+ const sendResult = await cdp(["eval", tab, sendCode]);
81
+ if (sendResult === "no-send")
82
+ throw new Error("ChatGPT send button not found");
83
+ if (sendResult === "send-disabled")
84
+ throw new Error("ChatGPT send button disabled — query was not registered");
85
+ await new Promise((r) => setTimeout(r, jitter(300)));
86
+ }
87
+
88
+ /**
89
+ * Inline selector for waitForStreamComplete: returns the assistant message
90
+ * that comes AFTER the last user message, or null if none exists. This
91
+ * skips chatgpt.com's static pre-rendered greeting card (which is
92
+ * `data-turn-start-message="true"` and lives on the homepage before any
93
+ * conversation) so short answers like "Hello! 👋" don't get confused with
94
+ * the 32-char placeholder.
95
+ */
96
+ const CHATGPT_RESPONSE_SELECTOR = String.raw`(() => {
97
+ const all = document.querySelectorAll('[data-message-author-role]');
98
+ let lastUserIdx = -1;
99
+ for (let i = 0; i < all.length; i++) {
100
+ if (all[i].getAttribute('data-message-author-role') === 'user') lastUserIdx = i;
101
+ }
102
+ if (lastUserIdx < 0) return null;
103
+ let bestEl = null;
104
+ let bestLen = 0;
105
+ for (let i = lastUserIdx + 1; i < all.length; i++) {
106
+ if (all[i].getAttribute('data-message-author-role') === 'assistant') {
107
+ const len = (all[i].innerText || '').length;
108
+ if (len > bestLen) { bestLen = len; bestEl = all[i]; }
109
+ }
110
+ }
111
+ return bestEl;
112
+ })()`;
113
+
114
+ /**
115
+ * Wait for ChatGPT's response to finish streaming. Delegates to the shared
116
+ * waitForStreamComplete in common.mjs with a custom selector that skips
117
+ * the static homepage greeting card.
118
+ *
119
+ * Tuning (fixes premature-stability race for complex answers):
120
+ * minLength: 1 — kept low so short factual answers (e.g. "2 + 2 = 4.")
121
+ * stabilize correctly. The previous run reported a 10-char
122
+ * answer after 35s of waiting because minLength: 50 was
123
+ * too high for short replies.
124
+ * stableRounds: 6 — require 6 rounds (~3.6s) of stable text. Complex
125
+ * answers stream a header/title block ("Next.jsReactNext.js",
126
+ * citation strips, etc.) that often stays at 19-40 chars
127
+ * for ~1.5-2s before the body arrives. The previous
128
+ * stableRounds: 3 (~1.8s) wasn't enough headroom; 6 rounds
129
+ * forces the body content to land before the wait resolves.
130
+ * Short answers like "2+2=4" stay stable at low length
131
+ * and resolve quickly because the entire response
132
+ * actually has finished.
133
+ */
134
+ async function waitForResponse(tab, timeoutMs = 20000) {
135
+ return waitForStreamComplete(tab, {
136
+ timeout: timeoutMs,
137
+ interval: 600,
138
+ stableRounds: 6,
139
+ minLength: 1,
140
+ selector: CHATGPT_RESPONSE_SELECTOR,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Node-side fallback for chatgpt stream completion. Used when the in-browser
146
+ * poll times out (typically because Chrome throttles background tabs to 1Hz
147
+ * when 3+ extractors run in parallel in `all` mode). Polls the same
148
+ * greeting-card-skipping selector via short independent Runtime.evaluate
149
+ * calls so the WebSocket is free between polls.
150
+ */
151
+ async function pollForResponseNodeSide(tab, maxMs = 15000) {
152
+ const deadline = Date.now() + maxMs;
153
+ let lastLen = 0;
154
+ let stableRounds = 0;
155
+ while (Date.now() < deadline) {
156
+ const result = await cdp(
157
+ ["eval", tab, `${CHATGPT_RESPONSE_SELECTOR}?.innerText?.length ?? 0`],
158
+ 4000,
159
+ ).catch(() => "0");
160
+ const len = parseInt(result, 10) || 0;
161
+ if (len >= 1 && len === lastLen) {
162
+ stableRounds++;
163
+ if (stableRounds >= 3) return len;
164
+ } else {
165
+ lastLen = len;
166
+ stableRounds = 0;
167
+ }
168
+ await new Promise((r) => setTimeout(r, 1200));
169
+ }
170
+ return lastLen;
171
+ }
172
+
173
+ async function extractAnswerFromDom(tab) {
174
+ const raw = await cdp([
175
+ "eval",
176
+ tab,
177
+ String.raw`
178
+ (() => {
179
+ // Find the assistant message that comes AFTER the last user message,
180
+ // not the absolute last assistant element. The chatgpt.com homepage
181
+ // has a static pre-rendered greeting card that renders as a
182
+ // [data-message-author-role="assistant"] element with
183
+ // data-turn-start-message="true" — it must be skipped or the
184
+ // static "Hello! How can I help you today?" placeholder gets
185
+ // returned as the answer to a query the assistant never answered.
186
+ const all = Array.from(document.querySelectorAll('[data-message-author-role]'));
187
+ let lastUserIdx = -1;
188
+ for (let i = 0; i < all.length; i++) {
189
+ if (all[i].getAttribute('data-message-author-role') === 'user') {
190
+ lastUserIdx = i;
191
+ }
192
+ }
193
+ if (lastUserIdx < 0) {
194
+ // No user message at all — page is still on the homepage.
195
+ return JSON.stringify({
196
+ answer: '',
197
+ sources: [],
198
+ skipped: 'no-user-message',
199
+ });
200
+ }
201
+ let assistant = null;
202
+ for (let i = lastUserIdx + 1; i < all.length; i++) {
203
+ if (all[i].getAttribute('data-message-author-role') === 'assistant') {
204
+ assistant = all[i];
205
+ }
206
+ }
207
+ if (!assistant) {
208
+ return JSON.stringify({
209
+ answer: '',
210
+ sources: [],
211
+ skipped: 'no-assistant-response',
212
+ });
213
+ }
214
+ const answer = (assistant.innerText || assistant.textContent || '').trim();
215
+ const seen = new Set();
216
+ const sources = [];
217
+ for (const link of assistant.querySelectorAll('a[href]')) {
218
+ const url = link.href;
219
+ if (!url || seen.has(url)) continue;
220
+ seen.add(url);
221
+ const title = (link.innerText || link.textContent || '').replace(/\s+/g, ' ').trim();
222
+ sources.push({ title, url });
223
+ if (sources.length >= 10) break;
224
+ }
225
+ return JSON.stringify({ answer, sources });
226
+ })()
227
+ `,
228
+ ]);
229
+ try {
230
+ return JSON.parse(raw);
231
+ } catch {
232
+ return { answer: "", sources: [], skipped: "parse-error" };
233
+ }
234
+ }
235
+
236
+ async function extractAnswer(tab, env) {
237
+ // Click the copy button on the assistant's response (after the last
238
+ // user message). The old `buttons[buttons.length - 1]` picked the
239
+ // absolute last copy button on the page — which is the USER message's
240
+ // copy button when the assistant response is still empty (0 chars) and
241
+ // has no copy button of its own. That copied the user's query into
242
+ // the clipboard interceptor and returned it as the "answer".
243
+ //
244
+ // If the assistant message has no copy button yet (still streaming, or
245
+ // the React tree hasn't rendered the button after streaming completed),
246
+ // we deliberately click NOTHING rather than falling back to the last
247
+ // copy button on the page. An empty clipboard routes us to the DOM
248
+ // fallback, which correctly targets the assistant message after the
249
+ // last user message and returns its innerText.
250
+ await cdp([
251
+ "eval",
252
+ tab,
253
+ `(() => {
254
+ const all = document.querySelectorAll('[data-message-author-role]');
255
+ let lastUserIdx = -1;
256
+ for (let i = 0; i < all.length; i++) {
257
+ if (all[i].getAttribute('data-message-author-role') === 'user') lastUserIdx = i;
258
+ }
259
+ if (lastUserIdx < 0) return 'no-user';
260
+ let assistantCopy = null;
261
+ for (let i = lastUserIdx + 1; i < all.length; i++) {
262
+ if (all[i].getAttribute('data-message-author-role') === 'assistant') {
263
+ const btn = all[i].querySelector('${COPY_SELECTOR}');
264
+ if (btn) assistantCopy = btn;
265
+ }
266
+ }
267
+ if (assistantCopy) { assistantCopy.click(); return 'clicked'; }
268
+ return 'no-assistant-copy';
269
+ })()`,
270
+ ]);
271
+ await new Promise((r) => setTimeout(r, 600));
272
+
273
+ let answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
274
+ env.clipboardEmpty = !answer;
275
+
276
+ // Retry once if clipboard is empty — the assistant message may have
277
+ // finished streaming and the copy button may have rendered in the
278
+ // meantime.
279
+ if (!answer) {
280
+ console.error("[chatgpt] Clipboard empty, retrying in 2s...");
281
+ await cdp([
282
+ "eval",
283
+ tab,
284
+ `(() => {
285
+ const all = document.querySelectorAll('[data-message-author-role]');
286
+ let lastUserIdx = -1;
287
+ for (let i = 0; i < all.length; i++) {
288
+ if (all[i].getAttribute('data-message-author-role') === 'user') lastUserIdx = i;
289
+ }
290
+ if (lastUserIdx < 0) return 'no-user';
291
+ let assistantCopy = null;
292
+ for (let i = lastUserIdx + 1; i < all.length; i++) {
293
+ if (all[i].getAttribute('data-message-author-role') === 'assistant') {
294
+ const btn = all[i].querySelector('${COPY_SELECTOR}');
295
+ if (btn) assistantCopy = btn;
296
+ }
297
+ }
298
+ if (assistantCopy) { assistantCopy.click(); return 'clicked'; }
299
+ return 'no-assistant-copy';
300
+ })()`,
301
+ ]);
302
+ await new Promise((r) => setTimeout(r, 2000));
303
+ answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
304
+ env.clipboardEmpty = !answer;
305
+ }
306
+
307
+ let domFallback = null;
308
+ if (!answer) {
309
+ domFallback = await extractAnswerFromDom(tab);
310
+ answer = domFallback.answer;
311
+ env.fallbackUsed = answer ? "dom" : null;
312
+ }
313
+
314
+ // Reject suspicious DOM-fallback answers: header-only text (e.g. the
315
+ // "Next.jsReactNext.js" title block ChatGPT renders before the body
316
+ // streams in) and query-echoed text. These were the failure modes the
317
+ // earlier stream-wait race was producing — minLength: 1 + stableRounds: 3
318
+ // resolved too early on the header. The tightened stream-wait covers
319
+ // the common case; this guard catches the tail where the wait still
320
+ // resolved prematurely under CDP contention with parallel extractors.
321
+ //
322
+ // Heuristic: a real answer is either long (> 50 chars) or matches the
323
+ // shape of a short factual answer (10-50 chars and contains at least
324
+ // one punctuation/space-delimited word). The 5-char absolute floor
325
+ // catches the "Gemini said"/"Next.jsReactNext.js" header stubs that
326
+ // the old path let through.
327
+ //
328
+ // Return an empty result (NOT throw) so the caller's retry loop can
329
+ // re-wait and try again. The retry path itself is the right place
330
+ // for backoff, not here.
331
+ if (answer) {
332
+ const trimmed = answer.trim();
333
+ const looksLikeShortAnswer =
334
+ trimmed.length >= 5 &&
335
+ trimmed.length <= 50 &&
336
+ /\s|[.,!?;:]/.test(trimmed);
337
+ const looksLikeLongAnswer = trimmed.length > 50;
338
+ if (!looksLikeShortAnswer && !looksLikeLongAnswer) {
339
+ console.error(
340
+ `[chatgpt] DOM fallback answer suspiciously short (${trimmed.length} chars: ${JSON.stringify(trimmed.slice(0, 80))}) — returning empty for caller to retry`,
341
+ );
342
+ env.fallbackUsed = null;
343
+ return {
344
+ answer: "",
345
+ sources: [],
346
+ skipped: "header-stub",
347
+ };
348
+ }
349
+ }
350
+ if (!answer) {
351
+ return { answer: "", sources: [], skipped: "no-answer" };
352
+ }
353
+
354
+ // Parse sources from both inline/reference-style markdown links and DOM links
355
+ // (DOM fallback preserves sources even when native clipboard copy fails).
356
+ const sourcesInline = parseSourcesFromMarkdown(answer);
357
+ const sourcesRef = parseSourcesFromMarkdownRefStyle(answer);
358
+ const sourceMap = new Map();
359
+ for (const s of [
360
+ ...(domFallback?.sources || []),
361
+ ...sourcesRef,
362
+ ...sourcesInline,
363
+ ]) {
364
+ if (s?.url && !sourceMap.has(s.url)) sourceMap.set(s.url, s);
365
+ }
366
+ const sources = Array.from(sourceMap.values()).slice(0, 10);
367
+
368
+ return { answer: answer.trim(), sources };
369
+ }
370
+
371
+ // ============================================================================
372
+ // Main
373
+ // ============================================================================
374
+
375
+ const USAGE = 'Usage: node extractors/chatgpt.mjs "<query>" [--tab <prefix>]\n';
376
+
377
+ async function main() {
378
+ const args = await prepareArgs(process.argv.slice(2));
379
+ validateQuery(args, USAGE);
380
+
381
+ const { query, tabPrefix, short } = parseArgs(args);
382
+ const startTime = Date.now();
383
+ const mode =
384
+ process.env.GREEDY_SEARCH_VISIBLE === "1" ? "visible" : "headless";
385
+
386
+ const env = {
387
+ engine: "chatgpt",
388
+ mode,
389
+ clipboardEmpty: null,
390
+ fallbackUsed: null,
391
+ blockedBy: null,
392
+ verificationResult: null,
393
+ inputReady: null,
394
+ };
395
+
396
+ try {
397
+ if (!tabPrefix) await cdp(["list"]);
398
+ const tab = await getOrOpenTab(tabPrefix);
399
+
400
+ const currentUrl = await cdp(["eval", tab, "document.location.href"]).catch(
401
+ () => "",
402
+ );
403
+ let onChatGPT = false;
404
+ try {
405
+ onChatGPT = new URL(currentUrl).hostname.toLowerCase() === "chatgpt.com";
406
+ } catch {}
407
+
408
+ if (!onChatGPT) {
409
+ logStage(env, "nav", startTime);
410
+ await cdp(["nav", tab, "https://chatgpt.com"], 20000);
411
+ await new Promise((r) => setTimeout(r, 600));
412
+ }
413
+ logStage(env, "consent", startTime);
414
+ await dismissConsent(tab, cdp);
415
+ logStage(env, "verification", startTime);
416
+ const verificationResult = await handleVerification(tab, cdp, 10000);
417
+ env.verificationResult = verificationResult;
418
+ if (verificationResult === "needs-human") {
419
+ env.blockedBy = "cloudflare-closed-shadow-dom";
420
+ throw new Error(
421
+ "ChatGPT is showing a Cloudflare Turnstile challenge that auto-clicking could not clear — please solve it in the visible browser window",
422
+ );
423
+ }
424
+ // Verification was auto-cleared (button clicked via CDP pierce).
425
+ // Wait for the chat UI to render before continuing.
426
+ if (verificationResult === "clicked") {
427
+ await new Promise((r) => setTimeout(r, 2500));
428
+ }
429
+
430
+ logStage(env, "input-wait", startTime);
431
+ const inputReady = await waitForSelector(tab, PROSE_SELECTOR, 8000, 400);
432
+ env.inputReady = inputReady;
433
+ if (!inputReady) {
434
+ const bodyText = await cdp([
435
+ "eval",
436
+ tab,
437
+ `document.body?.innerText || ''`,
438
+ ]).catch(() => "");
439
+ if (
440
+ /sign in|log in|sign up|\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7|login/i.test(
441
+ bodyText,
442
+ )
443
+ ) {
444
+ throw new Error(
445
+ "ChatGPT requires sign-in — please sign in in the visible browser window",
446
+ );
447
+ }
448
+ throw new Error(
449
+ "ChatGPT input not found — page may be blocked or in unexpected state",
450
+ );
451
+ }
452
+
453
+ logStage(env, "clipboard-inject", startTime);
454
+ await injectClipboardInterceptor(tab, GLOBAL_VAR);
455
+ logStage(env, "type-and-submit", startTime);
456
+ await typeAndSubmit(tab, query);
457
+
458
+ logStage(env, "stream-wait", startTime);
459
+ // waitForStreamComplete handles the in-browser poll in a single
460
+ // Runtime.evaluate call. If the response is still streaming past
461
+ // 20s (slow under tab throttling in `all` mode), fall back to
462
+ // node-side polls that release the WebSocket between each call.
463
+ // Together they stay well within the engine's 80s outer budget.
464
+ let asstLen = 0;
465
+ try {
466
+ asstLen = await waitForResponse(tab, 20000);
467
+ } catch (e) {
468
+ logStage(env, "stream-poll-fallback", startTime);
469
+ asstLen = await pollForResponseNodeSide(tab, 15000);
470
+ }
471
+ env.assistantTextLen = asstLen;
472
+ if (asstLen < 1) {
473
+ console.error(
474
+ "[chatgpt] Warning: assistant response may not have completed",
475
+ );
476
+ }
477
+
478
+ logStage(env, "extract", startTime);
479
+ // Retry extract up to 3 times with 2s delays. After stream-wait
480
+ // times out in all-mode under CDP contention, the assistant message
481
+ // may still be rendering. A short retry loop catches the response
482
+ // once it lands without burning the full 60s engine budget.
483
+ //
484
+ // Each retry first re-runs waitForResponse (which the tightened
485
+ // minLength=50 + stableRounds=5 makes more accurate), so we don't
486
+ // just blindly re-click the copy button on a still-streaming
487
+ // assistant message.
488
+ let extractResult;
489
+ for (let attempt = 0; attempt < 3; attempt++) {
490
+ // Re-wait on retries (attempt 0 already waited; attempts 1-2
491
+ // didn't because we already passed waitForResponse once). Skip
492
+ // the wait on attempt 0 to avoid a redundant 20s budget burn.
493
+ if (attempt > 0) {
494
+ try {
495
+ await waitForResponse(tab, 10000);
496
+ } catch {
497
+ // Best-effort: fall through to extract which itself
498
+ // returns empty on a still-streaming page.
499
+ }
500
+ }
501
+ extractResult = await extractAnswer(tab, env);
502
+ if (extractResult.answer) break;
503
+ if (attempt < 2) {
504
+ console.error(
505
+ `[chatgpt] Extract attempt ${attempt + 1} returned empty, retrying in 2s...`,
506
+ );
507
+ await new Promise((r) => setTimeout(r, 2000));
508
+ }
509
+ }
510
+ const { answer, sources, skipped } = extractResult;
511
+ // If the DOM fallback skipped the response (no real assistant
512
+ // message after the user's query), surface a clear error so the
513
+ // caller doesn't silently consume the static homepage greeting
514
+ // card as a real answer. The static card lives on chatgpt.com
515
+ // before any conversation; without this guard the extractor used
516
+ // to return "Hello! How can I help you today?" as a successful
517
+ // response to every query.
518
+ if (!answer) {
519
+ env.blockedBy = "no-response";
520
+ env.skipped = skipped || null;
521
+ throw new Error(
522
+ skipped === "no-user-message"
523
+ ? "ChatGPT still on homepage — query was not submitted"
524
+ : skipped === "no-assistant-response"
525
+ ? "ChatGPT did not return an assistant response after submit"
526
+ : skipped === "header-stub"
527
+ ? "ChatGPT response appeared to be a header stub after 3 retries — assistant never rendered the body"
528
+ : "ChatGPT returned no answer — assistant never responded",
529
+ );
530
+ }
531
+ logStage(env, "done", startTime);
532
+
533
+ const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
534
+ () => "https://chatgpt.com",
535
+ );
536
+ env.durationMs = Date.now() - startTime;
537
+ outputJson({
538
+ query,
539
+ url: finalUrl,
540
+ answer: formatAnswer(answer, short),
541
+ sources,
542
+ _envelope: buildEnvelope(env),
543
+ });
544
+ } catch (e) {
545
+ env.durationMs = Date.now() - startTime;
546
+ console.error(
547
+ `[chatgpt] error during stage '${env.lastStage || "unknown"}': ${e.message}`,
548
+ );
549
+ handleError(e, buildEnvelope(env));
550
+ }
551
+ }
552
+
553
+ main();