@apmantza/greedysearch-pi 1.8.2 → 1.8.4
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 +17 -0
- package/README.md +10 -1
- package/bin/launch.mjs +366 -366
- package/bin/search.mjs +388 -388
- package/extractors/common.mjs +291 -291
- package/extractors/gemini.mjs +146 -146
- package/extractors/google-ai.mjs +125 -125
- package/extractors/perplexity.mjs +147 -145
- package/extractors/selectors.mjs +54 -54
- package/index.ts +256 -278
- package/package.json +1 -1
- package/src/github.mjs +237 -237
- package/src/reddit.mjs +210 -0
- package/src/search/chrome.mjs +222 -222
- package/src/search/constants.mjs +37 -37
- package/src/search/defaults.mjs +14 -14
- package/src/search/engines.mjs +62 -62
- package/src/search/fetch-source.mjs +35 -3
- package/src/search/output.mjs +58 -58
- package/src/search/sources.mjs +445 -445
- package/src/search/synthesis-runner.mjs +63 -63
- package/src/search/synthesis.mjs +223 -223
- package/src/tools/deep-research-handler.ts +36 -36
- package/src/tools/greedy-search-handler.ts +53 -57
- package/src/tools/shared.ts +135 -130
- package/src/types.ts +103 -103
- package/test.mjs +423 -377
package/extractors/common.mjs
CHANGED
|
@@ -1,291 +1,291 @@
|
|
|
1
|
-
// extractors/common.mjs — shared utilities for CDP-based extractors
|
|
2
|
-
// Extracts common patterns: cdp wrapper, tab management, clipboard interception, source parsing
|
|
3
|
-
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
|
|
8
|
-
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
const CDP = join(__dir, "..", "bin", "cdp.mjs");
|
|
10
|
-
|
|
11
|
-
// ============================================================================
|
|
12
|
-
// CDP wrapper
|
|
13
|
-
// ============================================================================
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Execute a CDP command through the cdp.mjs CLI
|
|
17
|
-
* @param {string[]} args - Command arguments
|
|
18
|
-
* @param {number} [timeoutMs=30000] - Timeout in milliseconds
|
|
19
|
-
* @returns {Promise<string>} Command output
|
|
20
|
-
*/
|
|
21
|
-
export function cdp(args, timeoutMs = 30000) {
|
|
22
|
-
return new Promise((resolve, reject) => {
|
|
23
|
-
const proc = spawn("node", [CDP, ...args], {
|
|
24
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
-
});
|
|
26
|
-
let out = "";
|
|
27
|
-
let err = "";
|
|
28
|
-
proc.stdout.on("data", (d) => (out += d));
|
|
29
|
-
proc.stderr.on("data", (d) => (err += d));
|
|
30
|
-
const timer = setTimeout(() => {
|
|
31
|
-
proc.kill();
|
|
32
|
-
reject(new Error(`cdp timeout: ${args[0]}`));
|
|
33
|
-
}, timeoutMs);
|
|
34
|
-
proc.on("close", (code) => {
|
|
35
|
-
clearTimeout(timer);
|
|
36
|
-
if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
|
|
37
|
-
else resolve(out.trim());
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ============================================================================
|
|
43
|
-
// Tab management
|
|
44
|
-
// ============================================================================
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Get an existing tab by prefix or open a new one
|
|
48
|
-
* @param {string|null} tabPrefix - Existing tab prefix, or null to create new
|
|
49
|
-
* @returns {Promise<string>} Tab identifier
|
|
50
|
-
*/
|
|
51
|
-
export async function getOrOpenTab(tabPrefix) {
|
|
52
|
-
if (tabPrefix) return tabPrefix;
|
|
53
|
-
// Always open a fresh tab to avoid SPA navigation issues
|
|
54
|
-
const list = await cdp(["list"]);
|
|
55
|
-
const anchor = list.split("\n")[0]?.slice(0, 8);
|
|
56
|
-
if (!anchor)
|
|
57
|
-
throw new Error(
|
|
58
|
-
"No Chrome tabs found. Is Chrome running with --remote-debugging-port=9222?",
|
|
59
|
-
);
|
|
60
|
-
const raw = await cdp([
|
|
61
|
-
"evalraw",
|
|
62
|
-
anchor,
|
|
63
|
-
"Target.createTarget",
|
|
64
|
-
'{"url":"about:blank"}',
|
|
65
|
-
]);
|
|
66
|
-
const { targetId } = JSON.parse(raw);
|
|
67
|
-
await cdp(["list"]); // refresh cache
|
|
68
|
-
return targetId.slice(0, 8);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ============================================================================
|
|
72
|
-
// Clipboard interception (for extractors that use copy-to-clipboard)
|
|
73
|
-
// ============================================================================
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Inject clipboard interceptor to capture text when copy buttons are clicked.
|
|
77
|
-
* Each engine uses a unique global variable to avoid conflicts.
|
|
78
|
-
* @param {string} tab - Tab identifier
|
|
79
|
-
* @param {string} globalVar - Global variable name (e.g., '__pplxClipboard', '__geminiClipboard')
|
|
80
|
-
*/
|
|
81
|
-
export async function injectClipboardInterceptor(tab, globalVar) {
|
|
82
|
-
const code = `
|
|
83
|
-
window.${globalVar} = null;
|
|
84
|
-
const _origWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
|
|
85
|
-
navigator.clipboard.writeText = function(text) {
|
|
86
|
-
window.${globalVar} = text;
|
|
87
|
-
return _origWriteText(text);
|
|
88
|
-
};
|
|
89
|
-
const _origWrite = navigator.clipboard.write.bind(navigator.clipboard);
|
|
90
|
-
navigator.clipboard.write = async function(items) {
|
|
91
|
-
try {
|
|
92
|
-
for (const item of items) {
|
|
93
|
-
if (item.types && item.types.includes('text/plain')) {
|
|
94
|
-
const blob = await item.getType('text/plain');
|
|
95
|
-
window.${globalVar} = await blob.text();
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
} catch(e) {}
|
|
100
|
-
return _origWrite(items);
|
|
101
|
-
};
|
|
102
|
-
`;
|
|
103
|
-
await cdp(["eval", tab, code]);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ============================================================================
|
|
107
|
-
// Source extraction from markdown
|
|
108
|
-
// ============================================================================
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Parse Markdown links from text to extract sources
|
|
112
|
-
* @param {string} text - Text containing Markdown links like [title](url)
|
|
113
|
-
* @returns {Array<{title: string, url: string}>} Extracted sources
|
|
114
|
-
*/
|
|
115
|
-
export function parseSourcesFromMarkdown(text) {
|
|
116
|
-
return Array.from(text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g))
|
|
117
|
-
.map((m) => ({ title: m[1], url: m[2] }))
|
|
118
|
-
.filter((v, i, arr) => arr.findIndex((x) => x.url === v.url) === i)
|
|
119
|
-
.slice(0, 10);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ============================================================================
|
|
123
|
-
// Timing constants
|
|
124
|
-
// ============================================================================
|
|
125
|
-
|
|
126
|
-
export const TIMING = {
|
|
127
|
-
postNav: 1500, // settle after navigation
|
|
128
|
-
postNavSlow: 2000, // settle after slower navigations (Bing, Gemini)
|
|
129
|
-
postClick: 400, // settle after a UI click
|
|
130
|
-
postType: 400, // settle after typing
|
|
131
|
-
inputPoll: 400, // polling interval when waiting for input to appear
|
|
132
|
-
copyPoll: 600, // polling interval when waiting for copy button
|
|
133
|
-
afterVerify: 3000, // settle after a verification challenge completes
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
// ============================================================================
|
|
137
|
-
// Copy button polling
|
|
138
|
-
// ============================================================================
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Wait for a copy button to appear in the DOM.
|
|
142
|
-
* @param {string} tab - Tab identifier
|
|
143
|
-
* @param {string} selector - CSS selector for the copy button
|
|
144
|
-
* @param {object} [options]
|
|
145
|
-
* @param {number} [options.timeout=60000] - Max wait in ms
|
|
146
|
-
* @param {Function} [options.onPoll] - Optional async callback on each poll tick (e.g. scroll)
|
|
147
|
-
* @returns {Promise<void>}
|
|
148
|
-
*/
|
|
149
|
-
export async function waitForCopyButton(tab, selector, options = {}) {
|
|
150
|
-
const { timeout = 60000, onPoll } = options;
|
|
151
|
-
const deadline = Date.now() + timeout;
|
|
152
|
-
let tick = 0;
|
|
153
|
-
while (Date.now() < deadline) {
|
|
154
|
-
await new Promise((r) => setTimeout(r, TIMING.copyPoll));
|
|
155
|
-
if (onPoll) await onPoll(++tick).catch(() => null);
|
|
156
|
-
const found = await cdp([
|
|
157
|
-
"eval",
|
|
158
|
-
tab,
|
|
159
|
-
`!!document.querySelector('${selector}')`,
|
|
160
|
-
]).catch(() => "false");
|
|
161
|
-
if (found === "true") return;
|
|
162
|
-
}
|
|
163
|
-
throw new Error(
|
|
164
|
-
`Copy button ('${selector}') did not appear within ${timeout}ms`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ============================================================================
|
|
169
|
-
// Stream completion detection
|
|
170
|
-
// ============================================================================
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Wait for generation/streaming to complete by monitoring text length stability
|
|
174
|
-
* @param {string} tab - Tab identifier
|
|
175
|
-
* @param {object} options - Options
|
|
176
|
-
* @param {number} [options.timeout=30000] - Maximum wait time in ms
|
|
177
|
-
* @param {number} [options.interval=600] - Polling interval in ms
|
|
178
|
-
* @param {number} [options.stableRounds=3] - Required stable rounds to consider complete
|
|
179
|
-
* @param {string} [options.selector='document.body'] - Element to monitor (default: body)
|
|
180
|
-
* @returns {Promise<number>} Final text length
|
|
181
|
-
*/
|
|
182
|
-
export async function waitForStreamComplete(tab, options = {}) {
|
|
183
|
-
const {
|
|
184
|
-
timeout = 30000,
|
|
185
|
-
interval = 600,
|
|
186
|
-
stableRounds = 3,
|
|
187
|
-
selector = "document.body",
|
|
188
|
-
minLength = 0,
|
|
189
|
-
} = options;
|
|
190
|
-
|
|
191
|
-
const deadline = Date.now() + timeout;
|
|
192
|
-
let lastLen = -1;
|
|
193
|
-
let stableCount = 0;
|
|
194
|
-
|
|
195
|
-
while (Date.now() < deadline) {
|
|
196
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
197
|
-
const lenStr = await cdp([
|
|
198
|
-
"eval",
|
|
199
|
-
tab,
|
|
200
|
-
`${selector}?.innerText?.length ?? 0`,
|
|
201
|
-
]).catch(() => "0");
|
|
202
|
-
const currentLen = parseInt(lenStr, 10) || 0;
|
|
203
|
-
|
|
204
|
-
if (currentLen >= minLength) {
|
|
205
|
-
if (currentLen === lastLen) {
|
|
206
|
-
stableCount++;
|
|
207
|
-
if (stableCount >= stableRounds) return currentLen;
|
|
208
|
-
} else {
|
|
209
|
-
lastLen = currentLen;
|
|
210
|
-
stableCount = 0;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (lastLen >= minLength) return lastLen;
|
|
216
|
-
throw new Error(`Generation did not stabilise within ${timeout}ms`);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ============================================================================
|
|
220
|
-
// CLI argument parsing
|
|
221
|
-
// ============================================================================
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Parse standard extractor CLI arguments
|
|
225
|
-
* @param {string[]} args - process.argv.slice(2)
|
|
226
|
-
* @returns {{query: string, tabPrefix: string|null, short: boolean, locale: string|null}}
|
|
227
|
-
*/
|
|
228
|
-
export function parseArgs(args) {
|
|
229
|
-
const short = args.includes("--short");
|
|
230
|
-
let rest = args.filter((a) => a !== "--short");
|
|
231
|
-
|
|
232
|
-
const tabFlagIdx = rest.indexOf("--tab");
|
|
233
|
-
const tabPrefix = tabFlagIdx !== -1 ? rest[tabFlagIdx + 1] : null;
|
|
234
|
-
if (tabFlagIdx !== -1) {
|
|
235
|
-
rest = rest.filter((_, i) => i !== tabFlagIdx && i !== tabFlagIdx + 1);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const localeIdx = rest.indexOf("--locale");
|
|
239
|
-
const locale = localeIdx !== -1 ? rest[localeIdx + 1] : null;
|
|
240
|
-
if (localeIdx !== -1) {
|
|
241
|
-
rest = rest.filter((_, i) => i !== localeIdx && i !== localeIdx + 1);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const query = rest.join(" ");
|
|
245
|
-
return { query, tabPrefix, short, locale };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Validate that a query was provided, show usage and exit if not
|
|
250
|
-
* @param {string[]} args - process.argv.slice(2)
|
|
251
|
-
* @param {string} usage - Usage string for error message
|
|
252
|
-
*/
|
|
253
|
-
export function validateQuery(args, usage) {
|
|
254
|
-
if (!args.length || args[0] === "--help") {
|
|
255
|
-
process.stderr.write(usage);
|
|
256
|
-
process.exit(1);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// ============================================================================
|
|
261
|
-
// Output formatting
|
|
262
|
-
// ============================================================================
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Truncate answer if short mode is enabled
|
|
266
|
-
* @param {string} answer - Full answer text
|
|
267
|
-
* @param {boolean} short - Whether to truncate
|
|
268
|
-
* @param {number} [maxLen=300] - Maximum length in short mode
|
|
269
|
-
* @returns {string} Formatted answer
|
|
270
|
-
*/
|
|
271
|
-
export function formatAnswer(answer, short, maxLen = 300) {
|
|
272
|
-
if (!short || answer.length <= maxLen) return answer;
|
|
273
|
-
return `${answer.slice(0, maxLen).replace(/\s+\S*$/, "")}…`;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Output JSON result to stdout
|
|
278
|
-
* @param {object} data - Data to output
|
|
279
|
-
*/
|
|
280
|
-
export function outputJson(data) {
|
|
281
|
-
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Handle and output error, then exit
|
|
286
|
-
* @param {Error} error - Error to handle
|
|
287
|
-
*/
|
|
288
|
-
export function handleError(error) {
|
|
289
|
-
process.stderr.write(`Error: ${error.message}\n`);
|
|
290
|
-
process.exit(1);
|
|
291
|
-
}
|
|
1
|
+
// extractors/common.mjs — shared utilities for CDP-based extractors
|
|
2
|
+
// Extracts common patterns: cdp wrapper, tab management, clipboard interception, source parsing
|
|
3
|
+
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const CDP = join(__dir, "..", "bin", "cdp.mjs");
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// CDP wrapper
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Execute a CDP command through the cdp.mjs CLI
|
|
17
|
+
* @param {string[]} args - Command arguments
|
|
18
|
+
* @param {number} [timeoutMs=30000] - Timeout in milliseconds
|
|
19
|
+
* @returns {Promise<string>} Command output
|
|
20
|
+
*/
|
|
21
|
+
export function cdp(args, timeoutMs = 30000) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const proc = spawn("node", [CDP, ...args], {
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
let out = "";
|
|
27
|
+
let err = "";
|
|
28
|
+
proc.stdout.on("data", (d) => (out += d));
|
|
29
|
+
proc.stderr.on("data", (d) => (err += d));
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
proc.kill();
|
|
32
|
+
reject(new Error(`cdp timeout: ${args[0]}`));
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
proc.on("close", (code) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
if (code !== 0) reject(new Error(err.trim() || `cdp exit ${code}`));
|
|
37
|
+
else resolve(out.trim());
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Tab management
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get an existing tab by prefix or open a new one
|
|
48
|
+
* @param {string|null} tabPrefix - Existing tab prefix, or null to create new
|
|
49
|
+
* @returns {Promise<string>} Tab identifier
|
|
50
|
+
*/
|
|
51
|
+
export async function getOrOpenTab(tabPrefix) {
|
|
52
|
+
if (tabPrefix) return tabPrefix;
|
|
53
|
+
// Always open a fresh tab to avoid SPA navigation issues
|
|
54
|
+
const list = await cdp(["list"]);
|
|
55
|
+
const anchor = list.split("\n")[0]?.slice(0, 8);
|
|
56
|
+
if (!anchor)
|
|
57
|
+
throw new Error(
|
|
58
|
+
"No Chrome tabs found. Is Chrome running with --remote-debugging-port=9222?",
|
|
59
|
+
);
|
|
60
|
+
const raw = await cdp([
|
|
61
|
+
"evalraw",
|
|
62
|
+
anchor,
|
|
63
|
+
"Target.createTarget",
|
|
64
|
+
'{"url":"about:blank"}',
|
|
65
|
+
]);
|
|
66
|
+
const { targetId } = JSON.parse(raw);
|
|
67
|
+
await cdp(["list"]); // refresh cache
|
|
68
|
+
return targetId.slice(0, 8);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Clipboard interception (for extractors that use copy-to-clipboard)
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Inject clipboard interceptor to capture text when copy buttons are clicked.
|
|
77
|
+
* Each engine uses a unique global variable to avoid conflicts.
|
|
78
|
+
* @param {string} tab - Tab identifier
|
|
79
|
+
* @param {string} globalVar - Global variable name (e.g., '__pplxClipboard', '__geminiClipboard')
|
|
80
|
+
*/
|
|
81
|
+
export async function injectClipboardInterceptor(tab, globalVar) {
|
|
82
|
+
const code = `
|
|
83
|
+
window.${globalVar} = null;
|
|
84
|
+
const _origWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
|
|
85
|
+
navigator.clipboard.writeText = function(text) {
|
|
86
|
+
window.${globalVar} = text;
|
|
87
|
+
return _origWriteText(text);
|
|
88
|
+
};
|
|
89
|
+
const _origWrite = navigator.clipboard.write.bind(navigator.clipboard);
|
|
90
|
+
navigator.clipboard.write = async function(items) {
|
|
91
|
+
try {
|
|
92
|
+
for (const item of items) {
|
|
93
|
+
if (item.types && item.types.includes('text/plain')) {
|
|
94
|
+
const blob = await item.getType('text/plain');
|
|
95
|
+
window.${globalVar} = await blob.text();
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch(e) {}
|
|
100
|
+
return _origWrite(items);
|
|
101
|
+
};
|
|
102
|
+
`;
|
|
103
|
+
await cdp(["eval", tab, code]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Source extraction from markdown
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse Markdown links from text to extract sources
|
|
112
|
+
* @param {string} text - Text containing Markdown links like [title](url)
|
|
113
|
+
* @returns {Array<{title: string, url: string}>} Extracted sources
|
|
114
|
+
*/
|
|
115
|
+
export function parseSourcesFromMarkdown(text) {
|
|
116
|
+
return Array.from(text.matchAll(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g))
|
|
117
|
+
.map((m) => ({ title: m[1], url: m[2] }))
|
|
118
|
+
.filter((v, i, arr) => arr.findIndex((x) => x.url === v.url) === i)
|
|
119
|
+
.slice(0, 10);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// Timing constants
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
export const TIMING = {
|
|
127
|
+
postNav: 1500, // settle after navigation
|
|
128
|
+
postNavSlow: 2000, // settle after slower navigations (Bing, Gemini)
|
|
129
|
+
postClick: 400, // settle after a UI click
|
|
130
|
+
postType: 400, // settle after typing
|
|
131
|
+
inputPoll: 400, // polling interval when waiting for input to appear
|
|
132
|
+
copyPoll: 600, // polling interval when waiting for copy button
|
|
133
|
+
afterVerify: 3000, // settle after a verification challenge completes
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// Copy button polling
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wait for a copy button to appear in the DOM.
|
|
142
|
+
* @param {string} tab - Tab identifier
|
|
143
|
+
* @param {string} selector - CSS selector for the copy button
|
|
144
|
+
* @param {object} [options]
|
|
145
|
+
* @param {number} [options.timeout=60000] - Max wait in ms
|
|
146
|
+
* @param {Function} [options.onPoll] - Optional async callback on each poll tick (e.g. scroll)
|
|
147
|
+
* @returns {Promise<void>}
|
|
148
|
+
*/
|
|
149
|
+
export async function waitForCopyButton(tab, selector, options = {}) {
|
|
150
|
+
const { timeout = 60000, onPoll } = options;
|
|
151
|
+
const deadline = Date.now() + timeout;
|
|
152
|
+
let tick = 0;
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
await new Promise((r) => setTimeout(r, TIMING.copyPoll));
|
|
155
|
+
if (onPoll) await onPoll(++tick).catch(() => null);
|
|
156
|
+
const found = await cdp([
|
|
157
|
+
"eval",
|
|
158
|
+
tab,
|
|
159
|
+
`!!document.querySelector('${selector}')`,
|
|
160
|
+
]).catch(() => "false");
|
|
161
|
+
if (found === "true") return;
|
|
162
|
+
}
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Copy button ('${selector}') did not appear within ${timeout}ms`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// Stream completion detection
|
|
170
|
+
// ============================================================================
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Wait for generation/streaming to complete by monitoring text length stability
|
|
174
|
+
* @param {string} tab - Tab identifier
|
|
175
|
+
* @param {object} options - Options
|
|
176
|
+
* @param {number} [options.timeout=30000] - Maximum wait time in ms
|
|
177
|
+
* @param {number} [options.interval=600] - Polling interval in ms
|
|
178
|
+
* @param {number} [options.stableRounds=3] - Required stable rounds to consider complete
|
|
179
|
+
* @param {string} [options.selector='document.body'] - Element to monitor (default: body)
|
|
180
|
+
* @returns {Promise<number>} Final text length
|
|
181
|
+
*/
|
|
182
|
+
export async function waitForStreamComplete(tab, options = {}) {
|
|
183
|
+
const {
|
|
184
|
+
timeout = 30000,
|
|
185
|
+
interval = 600,
|
|
186
|
+
stableRounds = 3,
|
|
187
|
+
selector = "document.body",
|
|
188
|
+
minLength = 0,
|
|
189
|
+
} = options;
|
|
190
|
+
|
|
191
|
+
const deadline = Date.now() + timeout;
|
|
192
|
+
let lastLen = -1;
|
|
193
|
+
let stableCount = 0;
|
|
194
|
+
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
197
|
+
const lenStr = await cdp([
|
|
198
|
+
"eval",
|
|
199
|
+
tab,
|
|
200
|
+
`${selector}?.innerText?.length ?? 0`,
|
|
201
|
+
]).catch(() => "0");
|
|
202
|
+
const currentLen = parseInt(lenStr, 10) || 0;
|
|
203
|
+
|
|
204
|
+
if (currentLen >= minLength) {
|
|
205
|
+
if (currentLen === lastLen) {
|
|
206
|
+
stableCount++;
|
|
207
|
+
if (stableCount >= stableRounds) return currentLen;
|
|
208
|
+
} else {
|
|
209
|
+
lastLen = currentLen;
|
|
210
|
+
stableCount = 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (lastLen >= minLength) return lastLen;
|
|
216
|
+
throw new Error(`Generation did not stabilise within ${timeout}ms`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// CLI argument parsing
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Parse standard extractor CLI arguments
|
|
225
|
+
* @param {string[]} args - process.argv.slice(2)
|
|
226
|
+
* @returns {{query: string, tabPrefix: string|null, short: boolean, locale: string|null}}
|
|
227
|
+
*/
|
|
228
|
+
export function parseArgs(args) {
|
|
229
|
+
const short = args.includes("--short");
|
|
230
|
+
let rest = args.filter((a) => a !== "--short");
|
|
231
|
+
|
|
232
|
+
const tabFlagIdx = rest.indexOf("--tab");
|
|
233
|
+
const tabPrefix = tabFlagIdx !== -1 ? rest[tabFlagIdx + 1] : null;
|
|
234
|
+
if (tabFlagIdx !== -1) {
|
|
235
|
+
rest = rest.filter((_, i) => i !== tabFlagIdx && i !== tabFlagIdx + 1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const localeIdx = rest.indexOf("--locale");
|
|
239
|
+
const locale = localeIdx !== -1 ? rest[localeIdx + 1] : null;
|
|
240
|
+
if (localeIdx !== -1) {
|
|
241
|
+
rest = rest.filter((_, i) => i !== localeIdx && i !== localeIdx + 1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const query = rest.join(" ");
|
|
245
|
+
return { query, tabPrefix, short, locale };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate that a query was provided, show usage and exit if not
|
|
250
|
+
* @param {string[]} args - process.argv.slice(2)
|
|
251
|
+
* @param {string} usage - Usage string for error message
|
|
252
|
+
*/
|
|
253
|
+
export function validateQuery(args, usage) {
|
|
254
|
+
if (!args.length || args[0] === "--help") {
|
|
255
|
+
process.stderr.write(usage);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// Output formatting
|
|
262
|
+
// ============================================================================
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Truncate answer if short mode is enabled
|
|
266
|
+
* @param {string} answer - Full answer text
|
|
267
|
+
* @param {boolean} short - Whether to truncate
|
|
268
|
+
* @param {number} [maxLen=300] - Maximum length in short mode
|
|
269
|
+
* @returns {string} Formatted answer
|
|
270
|
+
*/
|
|
271
|
+
export function formatAnswer(answer, short, maxLen = 300) {
|
|
272
|
+
if (!short || answer.length <= maxLen) return answer;
|
|
273
|
+
return `${answer.slice(0, maxLen).replace(/\s+\S*$/, "")}…`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Output JSON result to stdout
|
|
278
|
+
* @param {object} data - Data to output
|
|
279
|
+
*/
|
|
280
|
+
export function outputJson(data) {
|
|
281
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Handle and output error, then exit
|
|
286
|
+
* @param {Error} error - Error to handle
|
|
287
|
+
*/
|
|
288
|
+
export function handleError(error) {
|
|
289
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|