@apmantza/greedysearch-pi 1.8.9 → 1.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.8.10] — 2026-05-11
6
+
7
+ ### Removed
8
+
9
+ - **Dead `bing-aria` extractor** (`extractors/bing-aria.mjs`) — Removed the unused ARIA-tree-based Bing Copilot extractor and its `bing2` engine alias. Nothing in the system referenced it (`bing2` was not in `ALL_ENGINES`, not documented in the tool schema, and had no callers).
10
+
11
+ ### Fixed
12
+
13
+ - **Perplexity sign-in mis-click** (`extractors/consent.mjs`) — `handleVerification` matched any button containing "continue", including "Continue with Google" OAuth buttons on Perplexity sign-in modals. This caused the automation to accidentally open Google/Microsoft login flows. Added explicit exclusions for `sign.in`, `log.in`, `google`, `microsoft`, `apple`, `facebook`, `github`, and `auth` text patterns in both `VERIFY_DETECT_JS` and `VERIFY_RETRY_JS`.
14
+
15
+ - **Gemini synthesis typing failure** (`extractors/gemini.mjs`) — `document.execCommand('insertText')` silently failed for long synthesis prompts (~8-10k chars), causing the extractor to submit an empty input and wait forever (45s stream + 180s timeout). Replaced with CDP `Input.insertText` + explicit focus click + content-length verification. Now fails fast with a clear error if text doesn't land.
16
+
17
+ - **Gemini answer extraction — query echo** (`extractors/gemini.mjs`) — When the assistant response copy button hadn't hydrated yet, clicking `buttons[buttons.length - 1]` hit the user's message copy button instead of Gemini's response, returning the query text as the "answer". Added wait for the assistant copy button to appear (2+ buttons on page), plus retry logic that detects exact query-text echo and re-clicks after a settle delay.
18
+
19
+ - **Bing Copilot Cloudflare auto-bypass** (`extractors/consent.mjs`) — Copilot's Turnstile challenge lives inside a **closed shadow DOM**, invisible to `document.querySelector('iframe')`. Added detection for the queryable host container (`#cf-turnstile`) and hidden response input (`[id^="cf-chl-widget-"]`), returning center coordinates for `humanClickXY`. During visible recovery, the challenge now auto-clicks and resolves transparently.
20
+
5
21
  ## [1.8.9] — 2026-05-11
6
22
 
7
23
  ### Changed
@@ -53,6 +53,15 @@ const VERIFY_DETECT_JS = `
53
53
  return JSON.stringify({t:'xy',x:r.left+30,y:r.top+r.height/2});
54
54
  }
55
55
 
56
+ // --- Cloudflare Turnstile widget inside closed shadow DOM (Copilot, etc.) ---
57
+ // The iframe is not queryable from main document, but the host container
58
+ // (#cf-turnstile) and the hidden response input are.
59
+ var cfTurnstileHost = document.querySelector('#cf-turnstile, [id^="cf-chl-widget-"]');
60
+ if (cfTurnstileHost) {
61
+ var r2 = cfTurnstileHost.getBoundingClientRect();
62
+ return JSON.stringify({t:'xy',x:r2.left+r2.width/2,y:r2.top+r2.height/2});
63
+ }
64
+
56
65
  // --- Cloudflare challenge page ---
57
66
  var cfCheckbox = document.querySelector('#cf-stage input[type="checkbox"], .ctp-checkbox-container input');
58
67
  if (cfCheckbox) { cfCheckbox.setAttribute('data-gs-verify','1'); return JSON.stringify({t:'sel',s:'[data-gs-verify="1"]',txt:'cloudflare-checkbox'}); }
@@ -66,11 +75,16 @@ const VERIFY_DETECT_JS = `
66
75
  }
67
76
 
68
77
  // --- Generic verify/continue/proceed buttons (catch-all) ---
78
+ // IMPORTANT: exclude sign-in / OAuth buttons (e.g. "Continue with Google")
69
79
  var btns = Array.from(document.querySelectorAll('button, input[type=submit], a[role=button]'));
70
80
  var verify = btns.find(b => {
71
81
  var t = (b.innerText?.trim() || b.value || '').toLowerCase();
72
- return (t.includes('verify') || t.includes('human') || t.includes('robot') || t.includes('continue') || t.includes('proceed')) &&
82
+ var isVerifyLike = (t.includes('verify') || t.includes('human') || t.includes('robot') || t.includes('continue') || t.includes('proceed')) &&
73
83
  !t.includes('verified') && !document.querySelector('iframe[src*="recaptcha"]');
84
+ if (!isVerifyLike) return false;
85
+ // Exclude OAuth / sign-in buttons to prevent accidental login flows
86
+ var isSignIn = /sign.in|log.in|google|microsoft|apple|facebook|github|auth/i.test(t);
87
+ return !isSignIn;
74
88
  });
75
89
  if (verify) { verify.setAttribute('data-gs-verify','1'); return JSON.stringify({t:'sel',s:'[data-gs-verify="1"]',txt:verify.innerText?.trim()||verify.value}); }
76
90
 
@@ -95,13 +109,20 @@ const VERIFY_RETRY_JS = `
95
109
  var btns = Array.from(document.querySelectorAll('button, input[type=submit], a[role=button]'));
96
110
  var btn = btns.find(b => {
97
111
  var t = (b.innerText?.trim() || b.value || '').toLowerCase();
98
- return t.includes('verify') || t.includes('human') || t.includes('robot') || t.includes('continue') || t.includes('next') || t.includes('submit');
112
+ var isVerifyLike = t.includes('verify') || t.includes('human') || t.includes('robot') || t.includes('continue') || t.includes('next') || t.includes('submit');
113
+ if (!isVerifyLike) return false;
114
+ var isSignIn = /sign.in|log.in|google|microsoft|apple|facebook|github|auth/i.test(t);
115
+ return !isSignIn;
99
116
  });
100
117
  if (btn) { btn.setAttribute('data-gs-verify','1'); return JSON.stringify({t:'sel',s:'[data-gs-verify="1"]',txt:btn.innerText?.trim()||btn.value}); }
101
118
 
102
119
  var cf = document.querySelector('#cf-stage input[type="checkbox"], .cf-turnstile input');
103
120
  if (cf) { cf.setAttribute('data-gs-verify','1'); return JSON.stringify({t:'sel',s:'[data-gs-verify="1"]',txt:'turnstile'}); }
104
121
 
122
+ // Cloudflare Turnstile widget inside closed shadow DOM (detected via host container)
123
+ var cfTurnstileHost = document.querySelector('#cf-turnstile, [id^="cf-chl-widget-"]');
124
+ if (cfTurnstileHost) { return 'still-verifying'; }
125
+
105
126
  var modal = document.querySelector('[role="dialog"], .b_modal, [class*="verify"]');
106
127
  if (modal) {
107
128
  var modalBtn = modal.querySelector('button, a[role="button"]');
@@ -36,19 +36,30 @@ const GLOBAL_VAR = "__geminiClipboard";
36
36
  // ============================================================================
37
37
 
38
38
  async function typeIntoGemini(tab, text) {
39
- await cdp([
39
+ // 1. Focus the input area via click (more reliable than eval focus for shadow-DOM editors)
40
+ await cdp(["click", tab, S.input]);
41
+ await new Promise((r) => setTimeout(r, jitter(200)));
42
+
43
+ // 2. Type using CDP Input.insertText (more reliable than document.execCommand)
44
+ await cdp(["type", tab, text]);
45
+ await new Promise((r) => setTimeout(r, jitter(300)));
46
+
47
+ // 3. Verify the text was actually inserted
48
+ const inserted = await cdp([
40
49
  "eval",
41
50
  tab,
42
- `
43
- (function(t) {
44
- var el = document.querySelector('${S.input}');
45
- if (!el) return false;
46
- el.focus();
47
- document.execCommand('insertText', false, t);
48
- return true;
49
- })(${JSON.stringify(text)})
50
- `,
51
+ `(function() {
52
+ var el = document.querySelector('${S.input}');
53
+ if (!el) return false;
54
+ var content = el.innerText || el.textContent || '';
55
+ return content.trim().length >= ${Math.floor(text.length * 0.8)};
56
+ })()`,
51
57
  ]);
58
+ if (inserted !== "true") {
59
+ throw new Error(
60
+ "Gemini input field did not accept text — input verification failed",
61
+ );
62
+ }
52
63
  }
53
64
 
54
65
  async function scrollToBottom(tab) {
@@ -62,9 +73,32 @@ async function scrollToBottom(tab) {
62
73
  ]);
63
74
  }
64
75
 
65
- async function extractAnswer(tab) {
66
- // Click the LAST copy button (assistant's response at the bottom),
67
- // not the first (which could be the user's echoed query).
76
+ async function extractAnswer(tab, query = "") {
77
+ const queryNorm = query.toLowerCase().trim();
78
+
79
+ // Wait for the assistant response copy button to appear.
80
+ // A fresh conversation has 1 copy button (user message); after the
81
+ // assistant responds there are 2+. This prevents clicking the user's
82
+ // copy button before React hydrates the assistant's.
83
+ let copyReady = false;
84
+ const copyDeadline = Date.now() + 12000;
85
+ while (Date.now() < copyDeadline) {
86
+ const count = await cdp([
87
+ "eval",
88
+ tab,
89
+ `document.querySelectorAll('${S.copyButton}').length`,
90
+ ]);
91
+ if (parseInt(count, 10) >= 2) {
92
+ copyReady = true;
93
+ break;
94
+ }
95
+ await new Promise((r) => setTimeout(r, 800));
96
+ }
97
+ if (!copyReady) {
98
+ console.error("[gemini] Warning: assistant copy button did not appear");
99
+ }
100
+
101
+ // Click the LAST copy button (assistant's response at the bottom)
68
102
  await cdp([
69
103
  "eval",
70
104
  tab,
@@ -73,9 +107,32 @@ async function extractAnswer(tab) {
73
107
  buttons[buttons.length - 1]?.click();
74
108
  })()`,
75
109
  ]);
76
- await new Promise((r) => setTimeout(r, 400));
110
+ await new Promise((r) => setTimeout(r, 600));
111
+
112
+ let answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
113
+
114
+ // Retry once if clipboard contains the user's query instead of the response.
115
+ // This can happen when the assistant response hasn't rendered its copy button yet.
116
+ if (
117
+ answer &&
118
+ queryNorm &&
119
+ (answer.toLowerCase().trim() === queryNorm ||
120
+ answer.trim().length < queryNorm.length)
121
+ ) {
122
+ console.error("[gemini] Clipboard echoed query, retrying in 2s...");
123
+ await new Promise((r) => setTimeout(r, 2000));
124
+ await cdp([
125
+ "eval",
126
+ tab,
127
+ `(() => {
128
+ const buttons = document.querySelectorAll('${S.copyButton}');
129
+ buttons[buttons.length - 1]?.click();
130
+ })()`,
131
+ ]);
132
+ await new Promise((r) => setTimeout(r, 600));
133
+ answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
134
+ }
77
135
 
78
- const answer = await cdp(["eval", tab, `window.${GLOBAL_VAR} || ''`]);
79
136
  if (!answer) throw new Error("Clipboard interceptor returned empty text");
80
137
 
81
138
  const sources = parseSourcesFromMarkdown(answer);
@@ -130,7 +187,7 @@ async function main() {
130
187
  clearInterval(scrollInterval);
131
188
  }
132
189
 
133
- const { answer, sources } = await extractAnswer(tab);
190
+ const { answer, sources } = await extractAnswer(tab, query);
134
191
  if (!answer) throw new Error("No answer captured from Gemini clipboard");
135
192
 
136
193
  const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apmantza/greedysearch-pi",
3
- "version": "1.8.9",
3
+ "version": "1.8.10",
4
4
  "description": "Headless multi-engine AI search (Perplexity, Bing Copilot, Google AI) via browser automation -- NO API KEYS needed. Extracts answers with sources, optional synthesis. Grounded AI answers from real browser interactions.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,44 +1,43 @@
1
- // src/search/constants.mjs — Shared constants for GreedySearch search pipeline
2
-
3
- import { tmpdir } from "node:os";
4
-
5
- export const GREEDY_PORT = 9222;
6
- export const GREEDY_PROFILE_DIR = `${tmpdir().replaceAll("\\", "/")}/greedysearch-chrome-profile`;
7
- export const ACTIVE_PORT_FILE = `${GREEDY_PROFILE_DIR}/DevToolsActivePort`;
8
- export const PAGES_CACHE = `${tmpdir().replaceAll("\\", "/")}/cdp-pages.json`;
9
- export const CHROME_MODE_FILE = `${tmpdir().replaceAll("\\", "/")}/greedysearch-chrome-mode`;
10
-
11
- // ALL_ENGINES drives the "all" fan-out. Add engines here to include them in multi-engine searches.
12
- // Engines in ENGINES but not in ALL_ENGINES are available for explicit use only.
13
- export const ALL_ENGINES = ["perplexity", "bing", "google"];
14
-
15
- export const ENGINE_DOMAINS = {
16
- perplexity: "perplexity.ai",
17
- bing: "copilot.microsoft.com",
18
- google: "google.com",
19
- gemini: "gemini.google.com",
20
- };
21
-
22
- export const ENGINES = {
23
- perplexity: "perplexity.mjs",
24
- pplx: "perplexity.mjs",
25
- p: "perplexity.mjs",
26
- bing: "bing-copilot.mjs",
27
- bing2: "bing-aria.mjs",
28
- copilot: "bing-copilot.mjs",
29
- b: "bing-copilot.mjs",
30
- google: "google-ai.mjs",
31
- g: "google-ai.mjs",
32
- gemini: "gemini.mjs",
33
- gem: "gemini.mjs",
34
- googlesearch: "google-search.mjs",
35
- gs: "google-search.mjs",
36
- };
37
-
38
- export const SOURCE_FETCH_CONCURRENCY = Math.max(
39
- 1,
40
- Number.parseInt(process.env.GREEDY_FETCH_CONCURRENCY || "4", 10) || 4,
41
- );
42
-
43
- // Tell cdp.mjs to prefer the GreedySearch Chrome profile's DevToolsActivePort
44
- process.env.CDP_PROFILE_DIR = GREEDY_PROFILE_DIR;
1
+ // src/search/constants.mjs — Shared constants for GreedySearch search pipeline
2
+
3
+ import { tmpdir } from "node:os";
4
+
5
+ export const GREEDY_PORT = 9222;
6
+ export const GREEDY_PROFILE_DIR = `${tmpdir().replaceAll("\\", "/")}/greedysearch-chrome-profile`;
7
+ export const ACTIVE_PORT_FILE = `${GREEDY_PROFILE_DIR}/DevToolsActivePort`;
8
+ export const PAGES_CACHE = `${tmpdir().replaceAll("\\", "/")}/cdp-pages.json`;
9
+ export const CHROME_MODE_FILE = `${tmpdir().replaceAll("\\", "/")}/greedysearch-chrome-mode`;
10
+
11
+ // ALL_ENGINES drives the "all" fan-out. Add engines here to include them in multi-engine searches.
12
+ // Engines in ENGINES but not in ALL_ENGINES are available for explicit use only.
13
+ export const ALL_ENGINES = ["perplexity", "bing", "google"];
14
+
15
+ export const ENGINE_DOMAINS = {
16
+ perplexity: "perplexity.ai",
17
+ bing: "copilot.microsoft.com",
18
+ google: "google.com",
19
+ gemini: "gemini.google.com",
20
+ };
21
+
22
+ export const ENGINES = {
23
+ perplexity: "perplexity.mjs",
24
+ pplx: "perplexity.mjs",
25
+ p: "perplexity.mjs",
26
+ bing: "bing-copilot.mjs",
27
+ copilot: "bing-copilot.mjs",
28
+ b: "bing-copilot.mjs",
29
+ google: "google-ai.mjs",
30
+ g: "google-ai.mjs",
31
+ gemini: "gemini.mjs",
32
+ gem: "gemini.mjs",
33
+ googlesearch: "google-search.mjs",
34
+ gs: "google-search.mjs",
35
+ };
36
+
37
+ export const SOURCE_FETCH_CONCURRENCY = Math.max(
38
+ 1,
39
+ Number.parseInt(process.env.GREEDY_FETCH_CONCURRENCY || "4", 10) || 4,
40
+ );
41
+
42
+ // Tell cdp.mjs to prefer the GreedySearch Chrome profile's DevToolsActivePort
43
+ process.env.CDP_PROFILE_DIR = GREEDY_PROFILE_DIR;
@@ -1,539 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // extractors/bing-aria.mjs — ARIA-tree-based Bing Copilot extractor
4
- //
5
- // Instead of copy button → clipboard polling → DOM fallback → iframe spelunking,
6
- // this extractor builds an ARIA accessibility tree of the page, finds the
7
- // Copilot answer region, and extracts structured text + sources directly.
8
- //
9
- // Inspiration: browser-use-rs extract_dom.js (Playwright's ariaSnapshot)
10
- //
11
- // Usage:
12
- // node extractors/bing-aria.mjs "<query>" [--tab <prefix>]
13
- //
14
- // Output (stdout): JSON { answer, sources, query, url }
15
- // Errors to stderr only — stdout is always clean JSON for piping.
16
-
17
- import {
18
- cdp,
19
- formatAnswer,
20
- getOrOpenTab,
21
- handleError,
22
- injectClipboardInterceptor,
23
- jitter,
24
- outputJson,
25
- parseArgs,
26
- parseSourcesFromMarkdown,
27
- prepareArgs,
28
- TIMING,
29
- validateQuery,
30
- waitForSelector,
31
- waitForStreamComplete,
32
- } from "./common.mjs";
33
- import { dismissConsent, handleVerification } from "./consent.mjs";
34
- import { SELECTORS } from "./selectors.mjs";
35
-
36
- const S = SELECTORS.bing;
37
-
38
- // ============================================================================
39
- // ARIA-tree answer extraction
40
- // ============================================================================
41
-
42
- const EXTRACT_ARIA_JS = String.raw`
43
- (async function() {
44
- 'use strict';
45
-
46
- // ── visibility helpers ──────────────────────────
47
- function isHidden(el) {
48
- if (['STYLE','SCRIPT','NOSCRIPT','TEMPLATE'].includes(el.tagName)) return true;
49
- const s = window.getComputedStyle(el);
50
- if (s.visibility !== 'visible') return true;
51
- if (s.display === 'none') return true;
52
- if (el.getAttribute('aria-hidden') === 'true') return true;
53
- return false;
54
- }
55
-
56
- function isVisible(el) {
57
- const r = el.getBoundingClientRect();
58
- return r.width > 0 && r.height > 0;
59
- }
60
-
61
- function getRole(el) {
62
- const explicit = el.getAttribute('role');
63
- if (explicit) return explicit.split(' ')[0];
64
- const tag = el.tagName;
65
- const map = {
66
- BUTTON:'button', A: el.hasAttribute('href')?'link':null,
67
- INPUT: (()=>{ const t=(el.type||'text').toLowerCase(); return {button:'button',checkbox:'checkbox',radio:'radio',range:'slider',search:'searchbox',text:'textbox',email:'textbox',tel:'textbox',url:'textbox',number:'spinbutton'}[t]||'textbox'; })(),
68
- TEXTAREA:'textbox', SELECT: el.hasAttribute('multiple')||el.size>1?'listbox':'combobox',
69
- H1:'heading',H2:'heading',H3:'heading',H4:'heading',H5:'heading',H6:'heading',
70
- IMG: el.getAttribute('alt')===''?'presentation':'img',
71
- NAV:'navigation', MAIN:'main', ARTICLE:'article',
72
- HEADER:'banner', FOOTER:'contentinfo', ASIDE:'complementary',
73
- FORM:'form', TABLE:'table', UL:'list', OL:'list', LI:'listitem',
74
- P:'paragraph', DIALOG:'dialog', IFRAME:'iframe'
75
- };
76
- return map[tag] || 'generic';
77
- }
78
-
79
- function getName(el) {
80
- const label = el.getAttribute('aria-label');
81
- if (label) return label;
82
- const labelledBy = el.getAttribute('aria-labelledby');
83
- if (labelledBy) {
84
- const texts = labelledBy.split(/\s+/).map(id => {
85
- const e = document.getElementById(id);
86
- return e ? e.textContent : '';
87
- }).filter(Boolean);
88
- if (texts.length) return texts.join(' ');
89
- }
90
- if (['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
91
- const id = el.id;
92
- if (id) {
93
- const lbl = document.querySelector('label[for="'+id+'"]');
94
- if (lbl) return lbl.textContent || '';
95
- }
96
- const parentLbl = el.closest('label');
97
- if (parentLbl) return parentLbl.textContent || '';
98
- const ph = el.getAttribute('placeholder');
99
- if (ph) return ph;
100
- }
101
- if (el.tagName === 'IMG') return el.getAttribute('alt') || '';
102
- const title = el.getAttribute('title');
103
- if (title) return title;
104
- return '';
105
- }
106
-
107
- // ── Build ARIA tree ──────────────────────────
108
- let indexCounter = 0;
109
-
110
- function buildTree(node, visited = new Set()) {
111
- if (visited.has(node)) return null;
112
- visited.add(node);
113
-
114
- if (node.nodeType === 3) { // text node
115
- return node.nodeValue;
116
- }
117
- if (node.nodeType !== 1) return null;
118
-
119
- const el = node;
120
- if (isHidden(el)) return null;
121
-
122
- const role = getRole(el);
123
- if (!role || role === 'presentation' || role === 'none') return null;
124
-
125
- const name = (getName(el) || '').replace(/\s+/g, ' ').trim();
126
- const box = el.getBoundingClientRect();
127
- const visible = isVisible(el);
128
- const cursor = window.getComputedStyle(el).cursor;
129
-
130
- const result = {
131
- role,
132
- name,
133
- children: [],
134
- visible,
135
- cursor,
136
- tag: el.tagName,
137
- };
138
-
139
- // index visible interactive + pointer-cursor elements
140
- if (visible && (cursor === 'pointer' || ['button','link','textbox','searchbox',
141
- 'checkbox','radio','combobox','listbox','option','menuitem',
142
- 'slider','spinbutton','switch','tab','heading'].includes(role))) {
143
- result.index = indexCounter++;
144
- if (el.tagName === 'A' && el.href) result.href = el.href;
145
- if (el.id) result.id = el.id;
146
- }
147
-
148
- // shadow DOM
149
- if (el.shadowRoot) {
150
- for (let c = el.shadowRoot.firstChild; c; c = c.nextSibling) {
151
- const child = buildTree(c, visited);
152
- if (child) result.children.push(child);
153
- }
154
- }
155
-
156
- // regular children
157
- for (let c = el.firstChild; c; c = c.nextSibling) {
158
- if (c.assignedSlot) continue;
159
- const child = buildTree(c, visited);
160
- if (child) result.children.push(child);
161
- }
162
-
163
- // aria-owns
164
- if (el.hasAttribute('aria-owns')) {
165
- for (const id of el.getAttribute('aria-owns').split(/\s+/)) {
166
- const owned = document.getElementById(id);
167
- if (owned) {
168
- const child = buildTree(owned, visited);
169
- if (child) result.children.push(child);
170
- }
171
- }
172
- }
173
-
174
- return result;
175
- }
176
-
177
- // ── Find answer region ──────────────────────────
178
- // Locale-agnostic: finds the LAST Copilot AI message container.
179
- // Copilot uses consistent CSS patterns across locales:
180
- // - AI messages: class contains "ai-message" or "response"
181
- // - User messages: different class prefix
182
- // - We take the LAST ai-message container in DOM order.
183
- function findAnswerRegion() {
184
- // Look for AI message containers with the known Copilot class pattern
185
- // Tailwind-based: group/ai-message, .response-content, etc.
186
- const allDivs = document.querySelectorAll('div[class*="ai-message"], div[class*="response-content"], div[class*="message"]');
187
- let best = null;
188
- for (const el of allDivs) {
189
- const text = (el.innerText || '');
190
- if (text.length > 100) best = el; // take last one with substantial text
191
- }
192
- if (best) return best;
193
-
194
- // Fallback: walk the DOM looking for containers with role=region/article
195
- // and substantial text, take the last one.
196
- const containers = [];
197
- (function walk(el) {
198
- if (!el || el.nodeType !== 1) return;
199
- const role = el.getAttribute('role');
200
- const cls = (el.className || '').toString();
201
- if (role === 'region' || role === 'article' ||
202
- cls.includes('ac-container')) {
203
- const text = (el.innerText || '');
204
- if (text.length > 100) containers.push(el);
205
- }
206
- for (const c of el.children) walk(c);
207
- })(document.body);
208
- if (containers.length > 0) return containers[containers.length - 1];
209
-
210
- return document.body;
211
- }
212
-
213
- // ── Extract text from tree ──────────────────────
214
- // Returns a normalized string. Block elements get newlines.
215
- // Inline elements flow with whitespace separation.
216
- // Locale-agnostic: filters by role/structure only, never by text content.
217
- function extractText(node, isInline = false) {
218
- if (typeof node === 'string') return node;
219
- if (!node) return '';
220
-
221
- // Skip UI buttons entirely
222
- if (node.role === 'button') return '';
223
-
224
- const parts = [];
225
-
226
- // heading → markdown heading with surrounding newlines (block)
227
- if (node.role === 'heading') {
228
- const level = parseInt(node.tag?.[1]) || 2;
229
- const inner = node.children.map(c => extractText(c)).join('');
230
- if (inner.trim()) parts.push('\n' + '#'.repeat(level) + ' ' + inner.trim() + '\n');
231
- }
232
-
233
- // link → markdown link (inline if inside text, block otherwise)
234
- else if (node.role === 'link' && node.href) {
235
- const text = node.children.map(c => extractText(c)).join('').trim();
236
- if (text && node.href.startsWith('http')) {
237
- parts.push('[' + text + '](' + node.href + ')');
238
- } else if (text) {
239
- parts.push(text);
240
- }
241
- }
242
-
243
- // listitem → preserve structure (block)
244
- else if (node.role === 'listitem') {
245
- const text = node.children.map(c => extractText(c)).join('').trim();
246
- if (text) parts.push('\n- ' + text);
247
- }
248
-
249
- // code blocks
250
- else if (node.tag === 'CODE' || node.tag === 'PRE') {
251
- const text = node.children.map(c => extractText(c)).join('').trim();
252
- if (text) parts.push('\n\x60\x60\x60\n' + text + '\n\x60\x60\x60\n');
253
- }
254
-
255
- // paragraph — block level, newlines around
256
- else if (node.role === 'paragraph') {
257
- const text = node.children.map(c => extractText(c)).join('').trim();
258
- if (text) parts.push('\n' + text + '\n');
259
- }
260
-
261
- // generic/inline — flow text, join tight (whitespace already in text nodes)
262
- else {
263
- for (const child of node.children) {
264
- parts.push(extractText(child));
265
- }
266
- }
267
-
268
- return parts.join('');
269
- }
270
-
271
- // ── Collect sources ──────────────────────────
272
- function collectLinks(node) {
273
- const links = [];
274
- function walk(n) {
275
- if (typeof n === 'string') return;
276
- if (!n) return;
277
- if (n.role === 'link' && n.href && n.href.startsWith('http') &&
278
- !n.href.includes('copilot.microsoft.com') &&
279
- !n.href.includes('bing.com') &&
280
- !n.href.includes('microsoft.com/privacy')) {
281
- links.push({ title: n.name || '', url: n.href });
282
- }
283
- for (const c of n.children) walk(c);
284
- }
285
- walk(node);
286
- // deduplicate by url
287
- const seen = new Set();
288
- return links.filter(l => { if (seen.has(l.url)) return false; seen.add(l.url); return true; });
289
- }
290
-
291
- // ── Execute ──────────────────────────────────
292
- try {
293
- // Wait for the answer to actually render — the stream may be "complete"
294
- // but React hasn't painted the AI message yet. Poll for ai-message content.
295
- await new Promise(r => setTimeout(r, 400));
296
-
297
- const deadline = Date.now() + 8000;
298
- let answerEl = null;
299
- while (Date.now() < deadline) {
300
- answerEl = findAnswerRegion();
301
- if (answerEl && (answerEl.innerText || '').length > 200) break;
302
- answerEl = null;
303
- await new Promise(r => setTimeout(r, 500));
304
- }
305
-
306
- if (!answerEl) {
307
- return JSON.stringify({ error: 'No answer region found (content too short or not rendered)', answer: '', sources: [] });
308
- }
309
-
310
- if (!answerEl) {
311
- return JSON.stringify({ error: 'No answer region found', answer: '', sources: [] });
312
- }
313
-
314
- const tree = buildTree(answerEl);
315
- if (!tree) {
316
- return JSON.stringify({ error: 'ARIA tree build failed', answer: '', sources: [] });
317
- }
318
-
319
- const text = extractText(tree);
320
-
321
- // Post-process: structural normalization only (locale-agnostic)
322
- // Buttons are already filtered by role in extractText.
323
- // Deduplication handles Copilot's mobile+desktop DOM variants.
324
- let clean = text
325
- .replace(/\n{3,}/g, '\n\n')
326
- .replace(/^\s+|\s+$/g, '')
327
- .trim();
328
-
329
- // Strip leading heading if it's the Copilot "X said" label
330
- // (locale-agnostic: just checks for markdown heading syntax at start)
331
- clean = clean.replace(/^#{1,6}\s+.+?\n\n/, '');
332
-
333
- // Deduplicate: Copilot sends duplicate DOM for responsive variants
334
- const lines = clean.split('\n');
335
- const seen = new Set();
336
- const deduped = [];
337
- for (const line of lines) {
338
- const normalized = line.trim();
339
- if (!normalized) { deduped.push(''); continue; }
340
- if (normalized.length <= 2 && /^[-–—•·]$/.test(normalized)) continue;
341
- if (seen.has(normalized)) continue;
342
- seen.add(normalized);
343
- deduped.push(line);
344
- }
345
- clean = deduped.join('\n').replace(/\n{3,}/g, '\n\n').trim();
346
-
347
- const sources = collectLinks(tree).slice(0, 10);
348
-
349
- return JSON.stringify({ answer: clean, sources });
350
- } catch (e) {
351
- return JSON.stringify({ error: e.toString(), answer: '', sources: [] });
352
- }
353
- })()
354
- `;
355
-
356
- async function extractAnswer(tab) {
357
- console.error("[bing-aria] Extracting answer via ARIA tree...");
358
-
359
- const resultRaw = await cdp(["eval", tab, EXTRACT_ARIA_JS], 45000);
360
-
361
- let result;
362
- try {
363
- result = JSON.parse(resultRaw);
364
- } catch {
365
- throw new Error(
366
- `ARIA extraction returned invalid JSON: ${resultRaw.slice(0, 200)}`,
367
- );
368
- }
369
-
370
- if (result.error) {
371
- throw new Error(`ARIA extraction failed: ${result.error}`);
372
- }
373
-
374
- const { answer, sources: ariaSources } = result;
375
-
376
- if (!answer || answer.length < 10) {
377
- throw new Error(
378
- `ARIA extraction returned insufficient content (${answer?.length || 0} chars)`,
379
- );
380
- }
381
-
382
- // Hybrid: click copy button for markdown sources only (answer already extracted via ARIA).
383
- // At this point the copy button is guaranteed rendered — no hydration race, no retries.
384
- const GLOBAL_VAR = "__bingAriaClipboard";
385
- await injectClipboardInterceptor(tab, GLOBAL_VAR);
386
- const clipSources = await grabClipboardSources(tab, GLOBAL_VAR);
387
- console.error(`[bing-aria] Clipboard sources: ${clipSources.length}`);
388
-
389
- // Merge: ARIA DOM sources + clipboard markdown sources
390
- const allSources = [...ariaSources, ...clipSources]
391
- .filter((v, i, arr) => arr.findIndex((x) => x.url === v.url) === i)
392
- .slice(0, 10);
393
-
394
- console.error(
395
- `[bing-aria] Extracted ${answer.length} chars, ${allSources.length} sources`,
396
- );
397
- return { answer: answer.trim(), sources: allSources };
398
- }
399
-
400
- /**
401
- * Click the Bing copy button, grab clipboard markdown, extract just the [title](url) sources.
402
- * Lightweight — no retries, no hydration delay (button is already visible at this point).
403
- */
404
- async function grabClipboardSources(tab, globalVar) {
405
- try {
406
- // Click last copy button (the most recent AI message)
407
- const copyBtn = S.copyButton;
408
- await cdp([
409
- "eval",
410
- tab,
411
- `(() => {
412
- window.${globalVar} = '';
413
- const buttons = document.querySelectorAll('${copyBtn}');
414
- buttons[buttons.length - 1]?.click();
415
- })()`,
416
- ]);
417
-
418
- // Poll clipboard briefly (2s max — if it doesn't work, no big deal)
419
- const deadline = Date.now() + 2000;
420
- while (Date.now() < deadline) {
421
- const text = await cdp(["eval", tab, `window.${globalVar} || ''`]).catch(
422
- () => "",
423
- );
424
- if (text && text.length > 20) {
425
- return parseSourcesFromMarkdown(text);
426
- }
427
- await new Promise((r) => setTimeout(r, 200));
428
- }
429
- } catch (e) {
430
- console.error(`[bing-aria] Clipboard source grab failed: ${e.message}`);
431
- }
432
- return [];
433
- }
434
-
435
- // ============================================================================
436
- // Main
437
- // ============================================================================
438
-
439
- const USAGE =
440
- 'Usage: node extractors/bing-aria.mjs "<query>" [--tab <prefix>]\n';
441
-
442
- async function main() {
443
- const args = await prepareArgs(process.argv.slice(2));
444
- validateQuery(args, USAGE);
445
-
446
- const { query, tabPrefix, short } = parseArgs(args);
447
-
448
- try {
449
- if (!tabPrefix) await cdp(["list"]);
450
- const tab = await getOrOpenTab(tabPrefix);
451
-
452
- const currentUrl = await cdp(["eval", tab, "document.location.href"]).catch(
453
- () => "",
454
- );
455
- let onCopilot = false;
456
- try {
457
- const host = new URL(currentUrl).hostname.toLowerCase();
458
- onCopilot =
459
- host === "copilot.microsoft.com" ||
460
- host.endsWith(".copilot.microsoft.com");
461
- } catch {}
462
-
463
- if (!onCopilot) {
464
- await cdp(["nav", tab, "https://copilot.microsoft.com/"], 20000);
465
- await new Promise((r) => setTimeout(r, 600));
466
- }
467
- await dismissConsent(tab, cdp);
468
-
469
- const verifyResult = await handleVerification(tab, cdp, 10000);
470
- if (verifyResult === "needs-human") {
471
- throw new Error(
472
- "Copilot verification required — please solve it manually in the browser window",
473
- );
474
- }
475
-
476
- if (verifyResult === "clicked") {
477
- await new Promise((r) => setTimeout(r, TIMING.afterVerify));
478
- const currentUrl = await cdp([
479
- "eval",
480
- tab,
481
- "document.location.href",
482
- ]).catch(() => "");
483
- let onCopilot = false;
484
- try {
485
- const host = new URL(currentUrl).hostname.toLowerCase();
486
- onCopilot =
487
- host === "copilot.microsoft.com" ||
488
- host.endsWith(".copilot.microsoft.com");
489
- } catch {}
490
- if (!onCopilot) {
491
- await cdp(["nav", tab, "https://copilot.microsoft.com/"], 20000);
492
- await new Promise((r) => setTimeout(r, 600));
493
- await dismissConsent(tab, cdp);
494
- }
495
- }
496
-
497
- const inputReady = await waitForSelector(tab, S.input, 15000, 500);
498
- await new Promise((r) => setTimeout(r, jitter(300)));
499
-
500
- if (!inputReady) {
501
- throw new Error(
502
- "Copilot input not found — verification may have failed or page is in unexpected state",
503
- );
504
- }
505
-
506
- // NO clipboard interceptor needed — ARIA extraction reads the DOM directly
507
- await cdp(["click", tab, S.input]);
508
- await new Promise((r) => setTimeout(r, TIMING.postClick));
509
- await cdp(["type", tab, query]);
510
- await new Promise((r) => setTimeout(r, TIMING.postType));
511
-
512
- await cdp([
513
- "eval",
514
- tab,
515
- `document.querySelector('${S.input}')?.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,keyCode:13})), 'ok'`,
516
- ]);
517
-
518
- // Wait for Copilot's response to finish streaming
519
- await waitForStreamComplete(tab, { timeout: 60000, minLength: 50 });
520
-
521
- const { answer, sources } = await extractAnswer(tab);
522
- if (!answer)
523
- throw new Error("No answer extracted — Copilot may not have responded");
524
-
525
- const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
526
- () => "",
527
- );
528
- outputJson({
529
- query,
530
- url: finalUrl,
531
- answer: formatAnswer(answer, short),
532
- sources,
533
- });
534
- } catch (e) {
535
- handleError(e);
536
- }
537
- }
538
-
539
- main();