@apmantza/greedysearch-pi 1.9.1 → 2.0.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.
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+
3
+ // extractors/logically.mjs
4
+ // Navigate logically.app/research-assistant, submit a question, wait for the
5
+ // answer to stream, then extract the rendered answer HTML and citation popovers.
6
+ //
7
+ // Usage:
8
+ // node extractors/logically.mjs "<query>" [--tab <prefix>]
9
+ //
10
+ // Output (stdout): JSON { query, url, answer, answerHtml, sources }
11
+ // Errors go to stderr only — stdout is always clean JSON for piping.
12
+
13
+ import {
14
+ buildEnvelope,
15
+ cdp,
16
+ cdpWithInput,
17
+ formatAnswer,
18
+ getOrOpenTab,
19
+ handleError,
20
+ jitter,
21
+ logStage,
22
+ outputJson,
23
+ parseArgs,
24
+ prepareArgs,
25
+ TIMING,
26
+ validateQuery,
27
+ waitForSelector,
28
+ } from "./common.mjs";
29
+ import { readFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { ensureChrome } from "../src/search/chrome.mjs";
32
+
33
+ const START_URL = "https://logically.app/research-assistant/";
34
+
35
+ const SELECTORS = {
36
+ input:
37
+ '.chat-control div.ProseMirror[contenteditable="true"][role="textbox"]',
38
+ submitButton: '.chat-control button[class*="MuiButton-black"]',
39
+ answerContainer: "#last-message .chat-content",
40
+ citationSpan: "#last-message .chat-content span[title]",
41
+ };
42
+
43
+ async function startNewChat(tab) {
44
+ // Best-effort. A new navigation usually lands on a blank prompt, but if the
45
+ // SPA restores the previous anonymous chat, the sidebar Create button resets it.
46
+ const code = `(() => {
47
+ const buttons = Array.from(document.querySelectorAll('button'));
48
+ const create = buttons.find((b) => (b.innerText || '').trim() === 'Create');
49
+ if (!create) return 'missing';
50
+ create.click();
51
+ return 'clicked';
52
+ })()`;
53
+ return cdp(["eval", tab, code], 5000).catch(() => "error");
54
+ }
55
+
56
+ async function detectLoginWall(tab) {
57
+ const code = `(() => {
58
+ const url = document.location.href || '';
59
+ if (url.includes('/login') || url.includes('/signup') || url.includes('/sign-up')) return true;
60
+ const modalText = Array.from(document.querySelectorAll('[role="dialog"], .MuiModal-root'))
61
+ .map((el) => el.innerText || '')
62
+ .join('\n');
63
+ return /create a free account|continue with google|have an account[?] log in|log in|sign up/i.test(modalText);
64
+ })()`;
65
+ return (await cdp(["eval", tab, code], 5000).catch(() => "false")) === "true";
66
+ }
67
+
68
+ async function activateTab(tab) {
69
+ try {
70
+ await cdp(["list"]);
71
+ const cachePath = `${tmpdir().replaceAll("\\\\", "/")}/cdp-pages.json`;
72
+ const pages = JSON.parse(readFileSync(cachePath, "utf8"));
73
+ const fullTargetId = pages.find((p) =>
74
+ p.targetId?.startsWith(tab),
75
+ )?.targetId;
76
+ if (fullTargetId) {
77
+ await cdp([
78
+ "browse",
79
+ tab,
80
+ "Target.activateTarget",
81
+ JSON.stringify({ targetId: fullTargetId }),
82
+ ]).catch(() => null);
83
+ await new Promise((r) => setTimeout(r, 250));
84
+ }
85
+ } catch {
86
+ // Best-effort only. Headless does not need activation, and visible Chrome
87
+ // still often accepts CDP input without it.
88
+ }
89
+ }
90
+
91
+ async function typeIntoLogically(tab, text) {
92
+ await activateTab(tab);
93
+ const pointRaw = await cdp([
94
+ "eval",
95
+ tab,
96
+ `(() => {
97
+ const inputs = Array.from(document.querySelectorAll('${SELECTORS.input}'));
98
+ const input = inputs.find((el) => {
99
+ const r = el.getBoundingClientRect();
100
+ return r.width > 20 && r.height > 5;
101
+ });
102
+ if (!input) return '';
103
+ input.scrollIntoView({ block: 'center', inline: 'center' });
104
+ const r = input.getBoundingClientRect();
105
+ return JSON.stringify({ x: Math.round(r.left + Math.min(80, r.width / 2)), y: Math.round(r.top + r.height / 2) });
106
+ })()`,
107
+ ]);
108
+ if (!pointRaw) throw new Error("Logically visible input not found");
109
+ const point = JSON.parse(pointRaw);
110
+ // Use both selector click and coordinate click. The ProseMirror editor is
111
+ // sometimes nested in animated MUI layout; selector click can land on a stale
112
+ // box, while coordinate click reliably focuses the visible editor after the
113
+ // in-page scrollIntoView above.
114
+ await cdp(["click", tab, SELECTORS.input]).catch(() => null);
115
+ await new Promise((r) => setTimeout(r, jitter(120)));
116
+ await cdp(["clickxy", tab, String(point.x), String(point.y)]);
117
+ await new Promise((r) => setTimeout(r, jitter(TIMING.postClick)));
118
+ await cdpWithInput(["type", tab, "--stdin"], text);
119
+ await new Promise((r) => setTimeout(r, jitter(TIMING.postType)));
120
+
121
+ const inserted = await cdp([
122
+ "eval",
123
+ tab,
124
+ `Array.from(document.querySelectorAll('${SELECTORS.input}')).some((el) => (el.innerText || '').length >= ${Math.floor(text.length * 0.8)})`,
125
+ ]);
126
+ if (inserted !== "true") {
127
+ throw new Error(
128
+ "Logically input did not accept text — input verification failed",
129
+ );
130
+ }
131
+ }
132
+
133
+ async function waitForLogicallyAnswer(tab, timeoutMs = 90000) {
134
+ const code = String.raw`
135
+ new Promise((resolve, reject) => {
136
+ const deadline = Date.now() + ${timeoutMs};
137
+ let last = '';
138
+ let stable = 0;
139
+ function poll() {
140
+ try {
141
+ const answer = document.querySelector('${SELECTORS.answerContainer}');
142
+ const text = (answer?.innerText || '').trim();
143
+ const body = document.body.innerText || '';
144
+ const stillGenerating = /Generating answer|Thinking\.\.\.|Searching the internet|Discovered \d+/.test(body) && text.length < 200;
145
+ if (text.length >= 200 && !stillGenerating) {
146
+ if (text === last) stable += 1;
147
+ else { last = text; stable = 0; }
148
+ if (stable >= 3) { resolve(text.length); return; }
149
+ }
150
+ if (Date.now() < deadline) setTimeout(poll, 700 + Math.random() * 250);
151
+ else reject(new Error('Logically answer did not stabilise within ${timeoutMs}ms'));
152
+ } catch (e) { reject(e); }
153
+ }
154
+ poll();
155
+ })`;
156
+ return cdp(["eval", tab, code], timeoutMs + 10000);
157
+ }
158
+
159
+ async function extractAnswer(tab) {
160
+ const code = `(() => {
161
+ const el = document.querySelector('${SELECTORS.answerContainer}');
162
+ if (!el) return JSON.stringify({ answer: '', answerHtml: '' });
163
+ const clone = el.cloneNode(true);
164
+ clone.querySelectorAll('svg').forEach((n) => n.remove());
165
+ clone.querySelectorAll('span[width], span[height]').forEach((n) => {
166
+ if (!(n.innerText || '').trim()) n.remove();
167
+ });
168
+ return JSON.stringify({
169
+ answer: (el.innerText || '').trim(),
170
+ answerHtml: clone.innerHTML,
171
+ });
172
+ })()`;
173
+ return JSON.parse(await cdp(["eval", tab, code], 10000));
174
+ }
175
+
176
+ async function extractFullCitationSources(tab) {
177
+ // The rendered answer has inline citations, but the complete citation set is
178
+ // behind the `Citations (N)` button. Click it and parse the popover's Academic
179
+ // cards plus Web URL blocks. This is the only place where Logically exposes
180
+ // the full citation count (for example Academic (20) + Web (29) = 49).
181
+ const code = String.raw`
182
+ (async () => {
183
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
184
+
185
+ function clickElement(el) {
186
+ el.scrollIntoView({ block: 'center', inline: 'center' });
187
+ for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) {
188
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
189
+ }
190
+ }
191
+
192
+ function citationCardCount(root) {
193
+ return Array.from(root.querySelectorAll('div')).filter((el) => {
194
+ const text = (el.innerText || '').trim();
195
+ return /^(Academic|Web)\n\n/.test(text) && text.includes('\nView');
196
+ }).length;
197
+ }
198
+
199
+ function findFullCitationsPopover() {
200
+ return Array.from(document.querySelectorAll('.MuiPopover-root, .MuiModal-root, .MuiDrawer-root, [role="dialog"]'))
201
+ .find((el) => {
202
+ const text = el.innerText || '';
203
+ // Visible mode includes the tab header. Headless sometimes omits the
204
+ // header and renders only a scrollable card list in a popover with no id.
205
+ if (/Academic \(\d+\)|Web \(\d+\)/.test(text)) return true;
206
+ if (el.id?.startsWith('citation-')) return false;
207
+ return citationCardCount(el) > 0;
208
+ });
209
+ }
210
+
211
+ function waitForFullCitationsPopover() {
212
+ return new Promise((resolve) => {
213
+ const deadline = Date.now() + 5000;
214
+ function poll() {
215
+ const pop = findFullCitationsPopover();
216
+ if (pop) { resolve(pop); return; }
217
+ if (Date.now() < deadline) setTimeout(poll, 100);
218
+ else resolve(null);
219
+ }
220
+ poll();
221
+ });
222
+ }
223
+
224
+ function linesOf(el) {
225
+ return (el?.innerText || '').split(/\n+/).map((s) => s.trim()).filter(Boolean);
226
+ }
227
+
228
+ function textAfter(label, text) {
229
+ const re = new RegExp(label + ':\\s*([^\\n]+)', 'i');
230
+ return text.match(re)?.[1]?.trim() || '';
231
+ }
232
+
233
+ function parseAcademicCard(card, idx) {
234
+ const lines = linesOf(card);
235
+ const text = lines.join('\n');
236
+ const citationCount = Number((lines[1] || '').match(/\d+/)?.[0] || '') || null;
237
+ return {
238
+ type: 'academic',
239
+ citationIndex: idx + 1,
240
+ url: '',
241
+ title: lines[2] || '',
242
+ authors: lines[3] || textAfter('Authors', text),
243
+ venue: lines[4] || textAfter('Journal', text),
244
+ publicationDate: textAfter('Publication Date', text),
245
+ citationCount,
246
+ references: Number(text.match(/References:\s*(\d+)/i)?.[1] || '') || null,
247
+ fieldsOfStudy: textAfter('Fields of Study', text),
248
+ publicationTypes: textAfter('Publication Types', text),
249
+ snippet: lines.find((line) => /^(TLDR|Abstract):/i.test(line)) || '',
250
+ };
251
+ }
252
+
253
+ function isUrlBlockAnchor(a) {
254
+ let n = a;
255
+ for (let i = 0; n && i < 5; i++, n = n.parentElement) {
256
+ if (/^URL:\s*/.test((n.innerText || '').trim())) return true;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ function parseWebAnchor(a, idx) {
262
+ let card = a;
263
+ for (let i = 0; card && i < 10; i++, card = card.parentElement) {
264
+ const text = card.innerText || '';
265
+ if (/\nUpdated:\s*/.test(text) && /\nURL:\s*/.test(text)) break;
266
+ }
267
+ const lines = linesOf(card || a.parentElement);
268
+ const url = a.href || '';
269
+ let title = lines.find((line) => line && !/^URL:|^Updated:|^Add to library$|^View$/.test(line) && line !== a.innerText) || '';
270
+ if (/^[\w.-]+\.[a-z]{2,}$/i.test(title)) title = lines[1] || title;
271
+ return {
272
+ type: 'web',
273
+ citationIndex: idx + 1,
274
+ url,
275
+ title: title || a.innerText || url,
276
+ domain: (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } })(),
277
+ updated: textAfter('Updated', lines.join('\n')),
278
+ snippet: lines.filter((line) => !/^URL:|^Updated:|^Add to library$|^View$/.test(line)).slice(0, 8).join('\n'),
279
+ };
280
+ }
281
+
282
+ const btn = Array.from(document.querySelectorAll('button'))
283
+ .find((b) => /Citations\s*\n*\s*\(\d+\)/i.test(b.innerText || ''));
284
+ if (!btn) return JSON.stringify({ sources: [], summary: { expectedTotal: 0, academic: 0, web: 0, reason: 'button-not-found' } });
285
+ const buttonTotal = Number((btn.innerText || '').match(/\((\d+)\)/)?.[1] || '0');
286
+ // Inline citation popovers are independent MUI modals and can remain mounted
287
+ // after we click answer citations. Remove those stale id-bearing roots before
288
+ // opening the full Citations popover so headless mode does not keep focus in
289
+ // old inline popovers.
290
+ document.querySelectorAll('.MuiPopover-root[id^="citation-"]').forEach((el) => el.remove());
291
+ clickElement(btn);
292
+ const pop = await waitForFullCitationsPopover();
293
+ if (!pop) return JSON.stringify({ sources: [], summary: { expectedTotal: buttonTotal, academic: 0, web: 0, reason: 'popover-not-found' } });
294
+
295
+ const header = pop.innerText || '';
296
+ const academicExpected = Number(header.match(/Academic \((\d+)\)/)?.[1] || '0');
297
+ const webExpected = Number(header.match(/Web \((\d+)\)/)?.[1] || '0');
298
+ const allDivs = Array.from(pop.querySelectorAll('div'));
299
+ const academicCandidates = allDivs.filter((el) => {
300
+ const text = (el.innerText || '').trim();
301
+ return /^Academic\n\n/.test(text) && text.includes('\nView');
302
+ });
303
+ const academicCards = academicCandidates.filter((el) =>
304
+ !academicCandidates.some((other) => other !== el && el.contains(other))
305
+ );
306
+ const academicSources = academicCards.map(parseAcademicCard).filter((s) => s.title);
307
+
308
+ const webAnchors = Array.from(pop.querySelectorAll('a[href]')).filter(isUrlBlockAnchor);
309
+ const webSources = webAnchors.map(parseWebAnchor).filter((s) => s.url);
310
+
311
+ return JSON.stringify({
312
+ sources: [...academicSources, ...webSources],
313
+ summary: {
314
+ expectedTotal: academicExpected + webExpected || buttonTotal,
315
+ academicExpected,
316
+ webExpected,
317
+ academicCaptured: academicSources.length,
318
+ webCaptured: webSources.length,
319
+ },
320
+ });
321
+ })()`;
322
+ const raw = await cdp(["eval", tab, code], 20000);
323
+ return JSON.parse(raw);
324
+ }
325
+
326
+ async function extractCitationSources(tab) {
327
+ // Logically renders citation labels inside the answer as clickable spans.
328
+ // The actual source URL and metadata are mounted into a MUI Popover only
329
+ // after clicking the span. This single browser-side async routine clicks the
330
+ // distinct citation spans, walks grouped citations with the next button, and
331
+ // returns the popover metadata without Node-side DOM polling.
332
+ const code = String.raw`
333
+ (async () => {
334
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
335
+ const answer = document.querySelector('${SELECTORS.answerContainer}');
336
+ if (!answer) return JSON.stringify([]);
337
+
338
+ function parseUrlFromId(id) {
339
+ const m = String(id || '').match(/^citation-(https?:\/\/.+)-(\d+)$/);
340
+ return m ? { url: m[1], index: Number(m[2]) } : { url: '', index: null };
341
+ }
342
+
343
+ function scrapePopover() {
344
+ const pop = document.querySelector('.MuiPopover-root[id^="citation-"]');
345
+ if (!pop) return null;
346
+ const lines = (pop.innerText || '').split(/\n+/).map((s) => s.trim()).filter(Boolean);
347
+ const posIdx = lines.findIndex((line) => /^\d+\s+of\s+\d+$/i.test(line));
348
+ const { url, index } = parseUrlFromId(pop.id);
349
+ const citationCountLine = lines.find((line) => /^\d+\s+citations?$/i.test(line));
350
+ return {
351
+ url,
352
+ index,
353
+ type: lines[0] || '',
354
+ citationCount: citationCountLine ? Number(citationCountLine.match(/\d+/)?.[0]) : null,
355
+ position: posIdx !== -1 ? lines[posIdx] : '',
356
+ title: posIdx !== -1 ? (lines[posIdx + 1] || '') : '',
357
+ authors: posIdx !== -1 ? (lines[posIdx + 2] || '') : '',
358
+ venue: posIdx !== -1 ? (lines[posIdx + 3] || '') : '',
359
+ snippet: lines.find((line) => /^(TLDR|Abstract|Snippet|Description):/i.test(line)) || '',
360
+ };
361
+ }
362
+
363
+ async function waitForPopover(previousId = '') {
364
+ const deadline = Date.now() + 4000;
365
+ while (Date.now() < deadline) {
366
+ const pop = document.querySelector('.MuiPopover-root[id^="citation-"]');
367
+ if (pop && pop.id !== previousId) return pop;
368
+ await sleep(100);
369
+ }
370
+ return document.querySelector('.MuiPopover-root[id^="citation-"]');
371
+ }
372
+
373
+ function clickElement(el) {
374
+ el.scrollIntoView({ block: 'center', inline: 'center' });
375
+ for (const type of ['pointerdown', 'mousedown', 'mouseup', 'click']) {
376
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
377
+ }
378
+ }
379
+
380
+ const spans = Array.from(answer.querySelectorAll('span[title]'))
381
+ .filter((s) => (s.innerText || s.title || '').trim());
382
+ const groups = [];
383
+ const seenGroups = new Set();
384
+ for (const span of spans) {
385
+ const text = (span.innerText || '').trim();
386
+ const plus = Number(text.match(/\(\+(\d+)\)/)?.[1] || '0');
387
+ const key = String(span.title) + '::' + String(plus);
388
+ if (seenGroups.has(key)) continue;
389
+ seenGroups.add(key);
390
+ groups.push({ span, title: span.title, count: Math.min(plus + 1, 12) });
391
+ if (groups.length >= 25) break;
392
+ }
393
+
394
+ const sources = [];
395
+ const seenUrls = new Set();
396
+ for (const group of groups) {
397
+ const previous = document.querySelector('.MuiPopover-root[id^="citation-"]')?.id || '';
398
+ clickElement(group.span);
399
+ await waitForPopover(previous);
400
+ for (let i = 0; i < group.count; i++) {
401
+ await sleep(150);
402
+ const src = scrapePopover();
403
+ if (src) {
404
+ if (!src.title) src.title = group.title;
405
+ if (src.url && !seenUrls.has(src.url)) {
406
+ seenUrls.add(src.url);
407
+ sources.push(src);
408
+ } else if (!src.url) {
409
+ const key = 'title:' + String(src.title || '');
410
+ if (!seenUrls.has(key)) { seenUrls.add(key); sources.push(src); }
411
+ }
412
+ }
413
+ if (i < group.count - 1) {
414
+ const pop = document.querySelector('.MuiPopover-root[id^="citation-"]');
415
+ const before = pop?.id || '';
416
+ const buttons = Array.from(pop?.querySelectorAll('button') || []);
417
+ const next = buttons.find((b) => !b.disabled && !(b.innerText || '').trim());
418
+ if (!next) break;
419
+ next.click();
420
+ await waitForPopover(before);
421
+ }
422
+ }
423
+ }
424
+
425
+ if (sources.length === 0) {
426
+ for (const span of spans) {
427
+ const title = span.title || (span.innerText || '').trim();
428
+ const key = 'title:' + String(title || '');
429
+ if (title && !seenUrls.has(key)) {
430
+ seenUrls.add(key);
431
+ sources.push({ title, url: '', type: 'citation-label' });
432
+ }
433
+ }
434
+ }
435
+ return JSON.stringify(sources);
436
+ })()`;
437
+ const raw = await cdp(["eval", tab, code], 45000);
438
+ return JSON.parse(raw);
439
+ }
440
+
441
+ const USAGE =
442
+ 'Usage: node extractors/logically.mjs "<query>" [--tab <prefix>]\n';
443
+
444
+ async function main() {
445
+ const args = await prepareArgs(process.argv.slice(2));
446
+ validateQuery(args, USAGE);
447
+
448
+ const { query, tabPrefix, short } = parseArgs(args);
449
+ const startTime = Date.now();
450
+ const mode =
451
+ process.env.GREEDY_SEARCH_VISIBLE === "1" ? "visible" : "headless";
452
+ const env = {
453
+ engine: "logically",
454
+ mode,
455
+ clipboardEmpty: null,
456
+ fallbackUsed: null,
457
+ blockedBy: null,
458
+ verificationResult: null,
459
+ inputReady: null,
460
+ };
461
+
462
+ try {
463
+ if (
464
+ process.env.GREEDY_SEARCH_VISIBLE !== "1" &&
465
+ process.env.GREEDY_SEARCH_ALWAYS_VISIBLE !== "1"
466
+ ) {
467
+ process.env.GREEDY_SEARCH_HEADLESS = "1";
468
+ }
469
+ await ensureChrome();
470
+
471
+ if (!tabPrefix) await cdp(["list"]);
472
+ const tab = await getOrOpenTab(tabPrefix);
473
+
474
+ const currentUrl = await cdp(["eval", tab, "document.location.href"]).catch(
475
+ () => "",
476
+ );
477
+ let onLogically = false;
478
+ try {
479
+ const host = new URL(currentUrl).hostname.toLowerCase();
480
+ onLogically = host === "logically.app" || host.endsWith(".logically.app");
481
+ } catch {}
482
+
483
+ if (!onLogically) {
484
+ logStage(env, "nav", startTime);
485
+ await cdp(["nav", tab, START_URL], 25000);
486
+ await new Promise((r) => setTimeout(r, 900));
487
+ }
488
+
489
+ logStage(env, "new-chat", startTime);
490
+ await startNewChat(tab);
491
+ await new Promise((r) => setTimeout(r, 700));
492
+
493
+ logStage(env, "input-wait", startTime);
494
+ const inputReady = await waitForSelector(tab, SELECTORS.input, 20000, 400);
495
+ env.inputReady = inputReady;
496
+ if (!inputReady) {
497
+ throw new Error(
498
+ "Logically input not found — page may not have loaded or is in unexpected state",
499
+ );
500
+ }
501
+
502
+ logStage(env, "type-and-submit", startTime);
503
+ await typeIntoLogically(tab, query);
504
+ const submitted = await cdp([
505
+ "eval",
506
+ tab,
507
+ `(() => { const btn = document.querySelector('${SELECTORS.submitButton}'); if (!btn) return 'missing'; btn.click(); return 'clicked'; })()`,
508
+ ]);
509
+ if (submitted !== "clicked")
510
+ throw new Error("Logically submit button not found");
511
+
512
+ // Anonymous sessions have a small free-message quota. Once exhausted,
513
+ // Logically opens a sign-up/login modal instead of generating an answer.
514
+ // Surface this like Consensus so the orchestrator can switch to visible
515
+ // Chrome and leave the browser open for the user to authenticate.
516
+ await new Promise((r) => setTimeout(r, 1500));
517
+ if (await detectLoginWall(tab)) {
518
+ env.blockedBy = "signin";
519
+ env.verificationResult = "needs-human";
520
+ throw new Error(
521
+ "Logically login required — please log in or create a free account in the visible browser window. Once signed in, cookies persist for future runs.",
522
+ );
523
+ }
524
+
525
+ logStage(env, "answer-wait", startTime);
526
+ await waitForLogicallyAnswer(tab, 90000);
527
+
528
+ logStage(env, "extract-answer-html", startTime);
529
+ const { answer, answerHtml } = await extractAnswer(tab);
530
+ if (!answer)
531
+ throw new Error("No answer extracted — Logically may not have responded");
532
+
533
+ logStage(env, "extract-inline-citations", startTime);
534
+ const inlineSources = await extractCitationSources(tab);
535
+
536
+ logStage(env, "extract-full-citations", startTime);
537
+ const fullCitations = await extractFullCitationSources(tab);
538
+ const sources = fullCitations.sources?.length
539
+ ? fullCitations.sources
540
+ : inlineSources;
541
+
542
+ const finalUrl = await cdp(["eval", tab, "document.location.href"]).catch(
543
+ () => "",
544
+ );
545
+ env.durationMs = Date.now() - startTime;
546
+ env.sourcePath = fullCitations.sources?.length
547
+ ? "citations-popover"
548
+ : "inline-citation-popovers";
549
+ logStage(env, "done", startTime);
550
+
551
+ outputJson({
552
+ query,
553
+ url: finalUrl,
554
+ answer: formatAnswer(answer, short),
555
+ answerHtml,
556
+ sources,
557
+ inlineSources,
558
+ citationSummary: fullCitations.summary,
559
+ _envelope: buildEnvelope(env),
560
+ });
561
+ } catch (e) {
562
+ env.durationMs = Date.now() - startTime;
563
+ handleError(e, buildEnvelope(env));
564
+ }
565
+ }
566
+
567
+ main();
@@ -43,8 +43,9 @@ export const SELECTORS = {
43
43
  gemini: {
44
44
  input: "rich-textarea .ql-editor",
45
45
  // Language-agnostic: use Material icon data attributes (work across locales)
46
- copyButton: 'button:has(mat-icon[data-mat-icon-name="content_copy"])',
47
- sendButton: 'button:has(mat-icon[data-mat-icon-name="send"]), .send-button',
46
+ copyButton: 'button:has(mat-icon[data-mat-icon-name="copy"])',
47
+ sendButton:
48
+ 'button:has(mat-icon[data-mat-icon-name="arrow_upward"]), [data-test-id="send-button"], .send-button',
48
49
  sourcesSidebarButton: "button.legacy-sources-sidebar-button",
49
50
  sourcesExclude: ["gemini.google", "gstatic", "google.com/search"],
50
51
  citationButtonPattern: 'button[aria-label*="citation from"]',