agentlinkops 0.5.0 → 0.6.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +8 -0
- package/CHANGELOG.md +15 -1
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +2 -1
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/agent-setup.js +0 -0
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +0 -0
- package/cli/check.js +0 -0
- package/cli/citation.js +124 -2
- package/cli/client.js +1 -1
- package/cli/commands.js +0 -0
- package/cli/config.js +0 -0
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/doctor.js +19 -0
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/ledger.js +0 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +0 -0
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +0 -0
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +0 -0
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +0 -0
- package/package.json +14 -3
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/agent-clients.js +0 -0
- package/shared/observation-event.js +0 -0
- package/shared/response-contract.js +0 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +0 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +0 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +0 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +0 -0
- package/skills/agentlinkops-crm/SKILL.md +0 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +173 -0
- package/src/citations/alert.js +53 -0
- package/src/citations/browser/engine.js +267 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +4 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +15 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +22 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +8 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +70 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +59 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +60 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +18 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +62 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +28 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +39 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +66 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +11 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +16 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +54 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +9 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +40 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +65 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +14 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +45 -0
- package/src/citations/browser/gen/core/providers/index.js +15 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +31 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +84 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +38 -0
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +587 -0
- package/src/citations/browser/gen/lib/browser/display.js +147 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +339 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +121 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +46 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +321 -0
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +251 -0
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +71 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +54 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +52 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +54 -0
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +21 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +117 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +101 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +14 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +13 -0
- package/src/citations/browser/gen/lib/input/response/getText.js +10 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +59 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +105 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +41 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +11 -0
- package/src/citations/browser/gen/utils/agent-constants.js +87 -0
- package/src/citations/browser/providers.js +166 -0
- package/src/citations/browser/shims/errors.js +26 -0
- package/src/citations/browser/shims/types.js +6 -0
- package/src/citations/browser/shims/utils.js +32 -0
- package/src/citations/contract.js +19 -3
- package/src/citations/fixtures/dataforseo-aio.json +82 -0
- package/src/citations/match.js +0 -0
- package/src/citations/panel-builder.js +44 -0
- package/src/citations/report.js +165 -0
- package/src/citations/runner.js +43 -16
- package/src/citations/stats.js +63 -0
- package/src/citations/watch.js +85 -0
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +0 -0
- package/src/corpus/seeds.js +0 -0
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/tool-views.js +0 -0
- package/src/verifier/fetch.js +0 -0
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
package/src/citations/adapter.js
CHANGED
|
@@ -139,3 +139,176 @@ export function createEngine(spec, options = {}) {
|
|
|
139
139
|
if (spec.engine === 'perplexity') return createPerplexityEngine({ model: spec.model ?? 'sonar', ...(options.perplexity ?? {}) });
|
|
140
140
|
throw new EngineError(`unknown engine: ${spec.engine}`);
|
|
141
141
|
}
|
|
142
|
+
|
|
143
|
+
// duck.ai anonymous engine: DuckDuckGo's AI chat serves Claude Haiku 4.5
|
|
144
|
+
// without an account. The VQD handshake follows Duck2api (MIT): the status
|
|
145
|
+
// call returns a token; when it returns a JS hash challenge instead, the
|
|
146
|
+
// documented fallback payload is accepted. Pure HTTP through the proxy —
|
|
147
|
+
// cheaper than a browser, and the surface has no bot wall at personal cadence.
|
|
148
|
+
const DUCK_BASE = 'https://duck.ai';
|
|
149
|
+
const DUCK_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
|
|
150
|
+
const DUCK_ORIGIN = 'https://duck.ai';
|
|
151
|
+
const DUCK_STACK = 'Error\n at l (https://duck.ai/dist/duckai-dist/entry.duckai.c0a8c794abcbc8ee2d3c.js:2:1446307)\n at async https://duck.ai/dist/duckai-dist/entry.duckai.c0a8c794abcbc8ee2d3c.js:2:1294181';
|
|
152
|
+
|
|
153
|
+
async function duckDispatcher(proxyUrl) {
|
|
154
|
+
if (!proxyUrl) return undefined;
|
|
155
|
+
const { ProxyAgent } = await import('undici');
|
|
156
|
+
return new ProxyAgent(proxyUrl);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function createDuckAiEngine({ model = 'claude-haiku-4-5', proxyUrl } = {}) {
|
|
160
|
+
let dispatcher = null;
|
|
161
|
+
const getDispatcher = async () => dispatcher ?? (dispatcher = await duckDispatcher(proxyUrl));
|
|
162
|
+
|
|
163
|
+
const duckFetch = async (path, init = {}) => {
|
|
164
|
+
const d = await getDispatcher();
|
|
165
|
+
return fetch(`${DUCK_BASE}${path}`, {
|
|
166
|
+
...init,
|
|
167
|
+
headers: { 'user-agent': DUCK_UA, origin: DUCK_ORIGIN, referer: `${DUCK_BASE}/`, ...init.headers },
|
|
168
|
+
...(d ? { dispatcher: d } : {}),
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const vqdFallback = (challengeB64) => {
|
|
173
|
+
const decoded = Buffer.from(challengeB64, 'base64').toString('utf8');
|
|
174
|
+
const payload = `${decoded}::no-exec::${DUCK_STACK}::${DUCK_ORIGIN}`;
|
|
175
|
+
return Buffer.from(payload).toString('base64');
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
identity: { engine: 'duckai', provider: 'anon-api', model },
|
|
180
|
+
estimateCostUsd: () => 0.0002,
|
|
181
|
+
async run({ prompt }) {
|
|
182
|
+
const status = await duckFetch('/duckchat/v1/status', { headers: { accept: '*/*' } });
|
|
183
|
+
if (!status.ok) throw new EngineError(`duckai status HTTP ${status.status}`, { retriable: true });
|
|
184
|
+
let vqd = status.headers.get('x-vqd-4') ?? status.headers.get('x-vqd');
|
|
185
|
+
const hashChallenge = status.headers.get('x-vqd-hash-1');
|
|
186
|
+
const headers = { accept: 'text/event-stream', 'content-type': 'application/json' };
|
|
187
|
+
if (!vqd && hashChallenge) {
|
|
188
|
+
vqd = vqdFallback(hashChallenge);
|
|
189
|
+
headers['x-vqd-hash-1'] = vqd;
|
|
190
|
+
}
|
|
191
|
+
if (!vqd) throw new EngineError('duckai: no vqd token or challenge in status response', { retriable: true });
|
|
192
|
+
headers['x-vqd-4'] = vqd;
|
|
193
|
+
if (vqd !== headers['x-vqd-hash-1']) headers['x-vqd'] = vqd;
|
|
194
|
+
|
|
195
|
+
const chat = await duckFetch('/duckchat/v1/chat', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers,
|
|
198
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], stream: true }),
|
|
199
|
+
});
|
|
200
|
+
if (!chat.ok) throw new EngineError(`duckai chat HTTP ${chat.status}`, { retriable: true });
|
|
201
|
+
const body = await chat.text();
|
|
202
|
+
let message = '';
|
|
203
|
+
for (const line of body.split('\n')) {
|
|
204
|
+
const t = line.trim();
|
|
205
|
+
if (!t.startsWith('data:') || t.includes('[DONE]')) continue;
|
|
206
|
+
try { message += (JSON.parse(t.slice(5).trim()).message ?? ''); } catch { /* chunk */ }
|
|
207
|
+
}
|
|
208
|
+
if (message.trim().length < 20) throw new EngineError('duckai: empty chat message', { retriable: true });
|
|
209
|
+
return {
|
|
210
|
+
engine: 'duckai', provider: 'anon-api', model,
|
|
211
|
+
providerModelVersion: `duck:${model}`,
|
|
212
|
+
answer: message, citations: [], fanOut: [],
|
|
213
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
214
|
+
costEstimateUsd: 0.0002,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// OpenAI-compatible engine: any endpoint speaking /v1/chat/completions —
|
|
221
|
+
// including self-hosted Duck2api on the VPS, which serves the whole Claude
|
|
222
|
+
// family (haiku/sonnet/opus) plus GPT-5.6 anonymously through duck.ai.
|
|
223
|
+
// The engine identity names the model explicitly; never averaged with others.
|
|
224
|
+
export function createOpenAiCompatibleEngine({ baseUrl, model, apiKey, fetchImpl, costEstimateUsd = 0.0002, providerLabel = 'openai-compatible' } = {}) {
|
|
225
|
+
if (!baseUrl || !model) throw new EngineError('openai-compatible engine needs baseUrl and model');
|
|
226
|
+
const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
227
|
+
return {
|
|
228
|
+
identity: { engine: model.split(':')[0] ?? model, provider: providerLabel, model },
|
|
229
|
+
estimateCostUsd: () => costEstimateUsd,
|
|
230
|
+
async run({ prompt }) {
|
|
231
|
+
const headers = { 'content-type': 'application/json' };
|
|
232
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
233
|
+
const response = await doFetch(`${baseUrl}/chat/completions`, {
|
|
234
|
+
method: 'POST', headers,
|
|
235
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }] }),
|
|
236
|
+
signal: AbortSignal.timeout(CITATION_LIMITS.timeoutMs * 2),
|
|
237
|
+
}).catch((cause) => { throw new EngineError(`openai-compatible request failed: ${cause.message}`, { retriable: true, cause }); });
|
|
238
|
+
if (!response.ok) throw new EngineError(`openai-compatible responded ${response.status}`, { retriable: response.status >= 500 || [429, 418, 408, 425].includes(response.status) });
|
|
239
|
+
const body = await response.json().catch(() => { throw new EngineError('openai-compatible response was not JSON'); });
|
|
240
|
+
const answer = body?.choices?.[0]?.message?.content ?? '';
|
|
241
|
+
if (answer.trim().length < 20) throw new EngineError('openai-compatible returned an empty answer', { retriable: true });
|
|
242
|
+
return {
|
|
243
|
+
engine: model.split(':')[0] ?? model, provider: providerLabel, model,
|
|
244
|
+
providerModelVersion: `${providerLabel}:${model}`,
|
|
245
|
+
answer, citations: [], fanOut: [],
|
|
246
|
+
usage: { input_tokens: body?.usage?.prompt_tokens ?? 0, output_tokens: body?.usage?.completion_tokens ?? 0 },
|
|
247
|
+
costEstimateUsd,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Google AI Overview engine via DataForSEO SERP API (fixture-first, DP-0044).
|
|
254
|
+
// AIO is a SERP feature, not a chat: the engine sends a search query and reads
|
|
255
|
+
// the ai_overview block from the returned items. Cited sources are domains the
|
|
256
|
+
// overview linked to — the citation equivalent of a chat engine's source list.
|
|
257
|
+
// The fixture below proves the parser against the documented response shape;
|
|
258
|
+
// the live adapter activates with credentials in fleet, never before.
|
|
259
|
+
export function createGoogleAioEngine({ fetchImpl, credentials, costEstimateUsd = 0.0012 } = {}) {
|
|
260
|
+
const doFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
|
261
|
+
const hasCredentials = Boolean(credentials?.login && credentials?.password);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
identity: { engine: 'google-aio', provider: hasCredentials ? 'dataforseo-serp' : 'fixture', model: 'ai-overview' },
|
|
265
|
+
estimateCostUsd: () => costEstimateUsd,
|
|
266
|
+
|
|
267
|
+
async run({ prompt }) {
|
|
268
|
+
if (!hasCredentials) {
|
|
269
|
+
// Fixture mode: prove the parser against the recorded response.
|
|
270
|
+
const fixture = await import('./fixtures/dataforseo-aio.json', { with: { type: 'json' } });
|
|
271
|
+
return parseAioResponse(fixture.default, prompt);
|
|
272
|
+
}
|
|
273
|
+
// Live mode: DataForSEO live/advanced POST.
|
|
274
|
+
const auth = Buffer.from(`${credentials.login}:${credentials.password}`).toString('base64');
|
|
275
|
+
const response = await doFetch('https://api.dataforseo.com/v3/serp/google/organic/live/advanced', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'authorization': `Basic ${auth}`, 'content-type': 'application/json' },
|
|
278
|
+
body: JSON.stringify([{ keyword: prompt, location_code: 2840, language_code: 'en' }]),
|
|
279
|
+
signal: AbortSignal.timeout(120_000),
|
|
280
|
+
}).catch((cause) => { throw new EngineError(`dataforseo request failed: ${cause.message}`, { retriable: true, cause }); });
|
|
281
|
+
if (!response.ok) throw new EngineError(`dataforseo responded ${response.status}`, { retriable: response.status >= 500 });
|
|
282
|
+
const body = await response.json().catch(() => { throw new EngineError('dataforseo response was not JSON'); });
|
|
283
|
+
return parseAioResponse(body, prompt);
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parseAioResponse(body, prompt) {
|
|
289
|
+
const result = body?.tasks?.[0]?.result?.[0];
|
|
290
|
+
if (!result) throw new EngineError('google-aio: no result in response', { retriable: true });
|
|
291
|
+
const aioBlock = (result.items ?? []).find((item) => item.type === 'ai_overview');
|
|
292
|
+
if (!aioBlock) {
|
|
293
|
+
// An honest absence: the SERP rendered without an AI Overview block.
|
|
294
|
+
throw new EngineError('google-aio: no AI Overview block rendered for this query', { retriable: true });
|
|
295
|
+
}
|
|
296
|
+
const answer = aioBlock.text ?? '';
|
|
297
|
+
if (answer.trim().length < 40) {
|
|
298
|
+
throw new EngineError('google-aio: AI Overview text too short to interpret', { retriable: true });
|
|
299
|
+
}
|
|
300
|
+
const citations = (aioBlock.items ?? [])
|
|
301
|
+
.filter((src) => typeof src.url === 'string' && src.url.length > 0)
|
|
302
|
+
.map((src) => ({ url: src.url, title: src.title ?? null }));
|
|
303
|
+
return {
|
|
304
|
+
engine: 'google-aio',
|
|
305
|
+
provider: 'dataforseo-serp',
|
|
306
|
+
model: 'ai-overview',
|
|
307
|
+
providerModelVersion: 'google-aio-serp-v1',
|
|
308
|
+
answer,
|
|
309
|
+
citations,
|
|
310
|
+
fanOut: [],
|
|
311
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
312
|
+
costEstimateUsd: body?.cost ?? 0.0012,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Citation alert: deliver the report when a cell classifies as declined.
|
|
2
|
+
// Uses the same SMTP pattern as the send-email skill (zero-dependency Python
|
|
3
|
+
// script). The alert only fires on complete evidence — insufficient_data
|
|
4
|
+
// and first_epoch never trigger, exactly as the contract requires.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export function shouldAlert(cells) {
|
|
10
|
+
return cells.some((c) => c.classification === 'declined');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function sendAlertEmail({ to, subject, htmlPath, smtp = {} }) {
|
|
14
|
+
if (!to || !htmlPath) throw new Error('sendAlertEmail needs to and htmlPath');
|
|
15
|
+
const { host, port, user, pass, from } = smtp;
|
|
16
|
+
if (!host || !user || !pass) {
|
|
17
|
+
// No SMTP configured — log the alert locally; the report file IS the alert.
|
|
18
|
+
return { delivered: false, reason: 'no smtp configured', htmlPath };
|
|
19
|
+
}
|
|
20
|
+
const html = await readFile(htmlPath, 'utf8');
|
|
21
|
+
// Strip the outer HTML tags for the plain-text body (the HTML stays as an
|
|
22
|
+
// attachment in a real deployment; for v1 the file path is the artifact).
|
|
23
|
+
const textBody = html
|
|
24
|
+
.replace(/<style[^>]*>.*?<\/style>/gs, '')
|
|
25
|
+
.replace(/<[^>]+>/g, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, 2000);
|
|
29
|
+
|
|
30
|
+
const python = `
|
|
31
|
+
import smtplib
|
|
32
|
+
from email.mime.multipart import MIMEMultipart
|
|
33
|
+
from email.mime.text import MIMEText
|
|
34
|
+
msg = MIMEMultipart('alternative')
|
|
35
|
+
msg['Subject'] = ${JSON.stringify(subject)}
|
|
36
|
+
msg['From'] = ${JSON.stringify(from ?? user)}
|
|
37
|
+
msg['To'] = ${JSON.stringify(to)}
|
|
38
|
+
msg.attach(MIMEText(${JSON.stringify(textBody)}, 'plain'))
|
|
39
|
+
with open(${JSON.stringify(htmlPath)}, 'r') as f:
|
|
40
|
+
msg.attach(MIMEText(f.read(), 'html'))
|
|
41
|
+
with smtplib.SMTP(${JSON.stringify(host)}, ${parseInt(port ?? 587, 10)}) as s:
|
|
42
|
+
s.starttls()
|
|
43
|
+
s.login(${JSON.stringify(user)}, ${JSON.stringify(pass)})
|
|
44
|
+
s.send_message(msg)
|
|
45
|
+
print('sent')
|
|
46
|
+
`;
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
execFile('python3', ['-c', python], (err, stdout) => {
|
|
49
|
+
if (err) reject(new Error(`smtp send failed: ${err.message}`));
|
|
50
|
+
else resolve({ delivered: true, htmlPath });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// The browser engine provider: one AI surface, measured through a real
|
|
2
|
+
// Camoufox-driven Firefox at consumer URLs, as our citation adapter contract.
|
|
3
|
+
//
|
|
4
|
+
// Everything here follows the DP-0031 contract's posture rules: a fresh
|
|
5
|
+
// browser context per sample (session history can never contaminate the
|
|
6
|
+
// panel), the vendored oneglanse per-engine configs for wait/extract/citations
|
|
7
|
+
// (MIT, attributed), our proxy from AGENTLINKOPS_PROXY_URL, and a screenshot
|
|
8
|
+
// captured into evidence on every run so an observation is never just a claim.
|
|
9
|
+
//
|
|
10
|
+
// This module imports playwright-core lazily: a CLI install without the
|
|
11
|
+
// browser tooling still runs every non-browser engine, and `citation doctor`
|
|
12
|
+
// reports the gap instead of the whole CLI failing to load.
|
|
13
|
+
import { mkdir } from 'node:fs/promises';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { EngineError } from '../adapter.js';
|
|
18
|
+
|
|
19
|
+
const lazy = async (path) => import(path);
|
|
20
|
+
|
|
21
|
+
let playwrightFirefox = null;
|
|
22
|
+
async function firefox() {
|
|
23
|
+
if (!playwrightFirefox) {
|
|
24
|
+
try {
|
|
25
|
+
playwrightFirefox = (await lazy('playwright-core')).firefox;
|
|
26
|
+
} catch (cause) {
|
|
27
|
+
throw new EngineError('playwright-core is not installed; browser engines need it (npm install playwright-core)', { cause });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return playwrightFirefox;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const BROWSER_PROVIDERS = Object.freeze(['chatgpt', 'perplexity', 'gemini', 'claude', 'ai-overview', 'grok', 'copilot', 'bing', 'duckai']);
|
|
34
|
+
|
|
35
|
+
// Nominal per-answer estimate: ~0.3 MB through a residential proxy at ~$7/GB,
|
|
36
|
+
// plus slack for the page itself. Checked BEFORE the run by the budget cap.
|
|
37
|
+
const NOMINAL_COST_USD = 0.03;
|
|
38
|
+
|
|
39
|
+
function parseProxyUrl(raw) {
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(raw);
|
|
43
|
+
return {
|
|
44
|
+
server: `${url.protocol}//${url.hostname}:${url.port}`,
|
|
45
|
+
username: decodeURIComponent(url.username),
|
|
46
|
+
password: decodeURIComponent(url.password),
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
throw new EngineError(`AGENTLINKOPS_PROXY_URL is not a valid proxy URL (length ${raw.length}, value never printed)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {} } = {}) {
|
|
54
|
+
if (!BROWSER_PROVIDERS.includes(engineName)) {
|
|
55
|
+
throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
|
|
56
|
+
}
|
|
57
|
+
const identity = { engine: engineName, provider: 'web-own-browser', model: engineName };
|
|
58
|
+
const sessionDir = join(homedir(), '.agentlinkops', 'citations', 'sessions');
|
|
59
|
+
|
|
60
|
+
let browserHandle = null; // { browser, cleanup } for the whole epoch
|
|
61
|
+
let displayHandle = null;
|
|
62
|
+
let providerConfig = null;
|
|
63
|
+
|
|
64
|
+
async function ensureProviderConfig() {
|
|
65
|
+
if (providerConfig) return providerConfig;
|
|
66
|
+
const { PROVIDER_CONFIGS } = await lazy('./gen/core/providers/index.js');
|
|
67
|
+
const { OWN_PROVIDER_CONFIGS } = await lazy('./providers.js');
|
|
68
|
+
providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
|
|
69
|
+
if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
|
|
70
|
+
return providerConfig;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureBrowser() {
|
|
74
|
+
if (browserHandle) return browserHandle;
|
|
75
|
+
const [{ resolveCamoufoxLaunchOptions }, display, fw] = [
|
|
76
|
+
await lazy('./gen/lib/browser/camoufox.js'),
|
|
77
|
+
await lazy('./gen/lib/browser/display.js'),
|
|
78
|
+
await firefox(),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Linux without a display gets the vendored self-bootstrapped Xvfb — the
|
|
82
|
+
// engines that block headless render headfully inside a virtual display.
|
|
83
|
+
if (process.platform === 'linux' && !display.detectDisplay()) {
|
|
84
|
+
displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
|
|
85
|
+
onEvent(`display: ${displayHandle.display}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const proxy = parseProxyUrl(env.AGENTLINKOPS_PROXY_URL);
|
|
89
|
+
if (proxy) onEvent(`proxy: ${proxy.server}`);
|
|
90
|
+
|
|
91
|
+
const options = await resolveCamoufoxLaunchOptions({
|
|
92
|
+
provider: engineName,
|
|
93
|
+
proxy: proxy ?? undefined,
|
|
94
|
+
// The virtual display handle must travel into the launch payload: the
|
|
95
|
+
// resolver exports DISPLAY for the browser process it configures.
|
|
96
|
+
display: displayHandle?.display,
|
|
97
|
+
// "virtual" on Linux = headful Firefox inside Xvfb; true headless is the
|
|
98
|
+
// detection magnet the research warned about.
|
|
99
|
+
headlessMode: process.platform === 'linux' ? 'virtual' : 'headful',
|
|
100
|
+
});
|
|
101
|
+
const launchOpts = { ...options, executablePath: options.executablePath };
|
|
102
|
+
if (proxy) launchOpts.proxy = proxy;
|
|
103
|
+
const browser = await fw.launch(launchOpts);
|
|
104
|
+
|
|
105
|
+
browserHandle = {
|
|
106
|
+
browser,
|
|
107
|
+
cleanup: async () => {
|
|
108
|
+
await browser.close().catch(() => null);
|
|
109
|
+
await displayHandle?.cleanup?.().catch(() => null);
|
|
110
|
+
displayHandle = null;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return browserHandle;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function loadStorageState() {
|
|
117
|
+
try {
|
|
118
|
+
const { readFile } = await lazy('node:fs/promises');
|
|
119
|
+
return JSON.parse(await readFile(join(sessionDir, `${engineName}.json`), 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined; // anonymous session: several surfaces work logged-out
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
identity,
|
|
127
|
+
estimateCostUsd: () => NOMINAL_COST_USD,
|
|
128
|
+
|
|
129
|
+
async run({ prompt }) {
|
|
130
|
+
const config = await ensureProviderConfig();
|
|
131
|
+
const { browser, cleanup } = await ensureBrowser();
|
|
132
|
+
const context = await browser.newContext({
|
|
133
|
+
storageState: await loadStorageState(),
|
|
134
|
+
locale: 'en-US',
|
|
135
|
+
timezoneId: 'America/New_York',
|
|
136
|
+
});
|
|
137
|
+
// Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
|
|
138
|
+
// images and media are the bulk of the 3-8 MB measured per observation.
|
|
139
|
+
// Fonts stay — font metrics are part of the fingerprint surface.
|
|
140
|
+
if (env.AGENTLINKOPS_BROWSER_KEEP_ASSETS !== '1') {
|
|
141
|
+
await context.route('**/*', (route) => {
|
|
142
|
+
const type = route.request().resourceType();
|
|
143
|
+
if (type === 'image' || type === 'media') return route.abort();
|
|
144
|
+
return route.continue();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const page = await context.newPage();
|
|
149
|
+
page.setDefaultTimeout(45_000);
|
|
150
|
+
|
|
151
|
+
if (config.navigateToPrompt) {
|
|
152
|
+
await config.navigateToPrompt(page, prompt);
|
|
153
|
+
} else if (engineName === 'grok') {
|
|
154
|
+
// Probe-proven grok flow: the composer's position moves with each
|
|
155
|
+
// randomized fingerprint, so its live rect is located in-page and
|
|
156
|
+
// clicked at the computed center — never a fixed coordinate.
|
|
157
|
+
await page.goto('https://grok.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
158
|
+
await page.waitForTimeout(12_000);
|
|
159
|
+
let spot = null;
|
|
160
|
+
for (let attempt = 0; attempt < 3 && !spot; attempt += 1) {
|
|
161
|
+
spot = await page.evaluate(() => {
|
|
162
|
+
const els = [...document.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]')];
|
|
163
|
+
for (const el of els) {
|
|
164
|
+
const r = el.getBoundingClientRect();
|
|
165
|
+
if (r.width > 60 && r.height > 10) return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + Math.min(Math.max(r.height / 2, 12), 40)) };
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
});
|
|
169
|
+
if (!spot) await page.waitForTimeout(7000);
|
|
170
|
+
}
|
|
171
|
+
if (!spot) throw new EngineError('grok: no composer found after settle retries (surface may be gated or changed)', { retriable: true });
|
|
172
|
+
await page.mouse.click(spot.x, spot.y);
|
|
173
|
+
await page.waitForTimeout(1000);
|
|
174
|
+
await page.keyboard.type(prompt, { delay: 90 });
|
|
175
|
+
await page.waitForTimeout(1200);
|
|
176
|
+
await page.keyboard.press('Enter');
|
|
177
|
+
} else if (engineName === 'duckai') {
|
|
178
|
+
// The page's own JS solves the VQD challenge; the chat call runs
|
|
179
|
+
// in-page with native cookies and headers. Claude Haiku, anonymous.
|
|
180
|
+
await page.waitForTimeout(6000);
|
|
181
|
+
const { duckChat } = await lazy('./providers.js');
|
|
182
|
+
const chat = await duckChat(page, prompt);
|
|
183
|
+
if (!chat.message || chat.message.trim().length < 40) {
|
|
184
|
+
throw new EngineError(`duckai: empty chat response (models seen: ${(chat.models ?? []).slice(0, 5).join(',')})`, { retriable: true });
|
|
185
|
+
}
|
|
186
|
+
answer = chat.message;
|
|
187
|
+
} else if (engineName === 'bing') {
|
|
188
|
+
// Pure navigation: the prompt IS the URL. No editor, no typing, no
|
|
189
|
+
// modal — the generative answer block renders with its citations.
|
|
190
|
+
await page.goto(`https://www.bing.com/search?q=${encodeURIComponent(prompt)}`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
191
|
+
await config.waitForResponse(page);
|
|
192
|
+
} else {
|
|
193
|
+
await page.goto(config.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
194
|
+
await config.postNavigationHook?.(page);
|
|
195
|
+
await config.beforePromptHook?.(page);
|
|
196
|
+
|
|
197
|
+
const { findActiveEditorCandidate } = await lazy('./gen/lib/input/editor/findEditor.js');
|
|
198
|
+
const { PROVIDER_EDITOR_SELECTORS } = await lazy('./shims/utils.js');
|
|
199
|
+
const editor = await findActiveEditorCandidate(page, PROVIDER_EDITOR_SELECTORS[engineName]);
|
|
200
|
+
if (!editor) throw new EngineError(`${engineName}: no prompt editor found (surface may be gated or changed)`);
|
|
201
|
+
|
|
202
|
+
const { insertPromptIntoEditor } = await lazy('./gen/lib/input/editor/promptInput.js');
|
|
203
|
+
await insertPromptIntoEditor(page, editor.locator, prompt, engineName);
|
|
204
|
+
await config.afterTypingHook?.(page);
|
|
205
|
+
await config.beforeSubmitHook?.(page);
|
|
206
|
+
|
|
207
|
+
const { findEnabledSendButton } = await lazy('./gen/lib/input/editor/findSendButton.js');
|
|
208
|
+
const { PROVIDER_SUBMIT_BTN_SELECTORS } = await lazy('./shims/utils.js');
|
|
209
|
+
const send = await findEnabledSendButton(page, PROVIDER_SUBMIT_BTN_SELECTORS[engineName]);
|
|
210
|
+
if (send) { await send.click(); }
|
|
211
|
+
else { await editor.locator.press('Enter'); }
|
|
212
|
+
}
|
|
213
|
+
await config.afterSubmitHook?.(page);
|
|
214
|
+
|
|
215
|
+
await config.waitForResponse(page);
|
|
216
|
+
let answer = '';
|
|
217
|
+
if (!answer) answer = await config.extractResponse(page);
|
|
218
|
+
// Anonymous/mobile layouts render the conversation without the
|
|
219
|
+
// logged-in app's message attributes. The honest fallback is the
|
|
220
|
+
// page's own text: it is the answer surface the user saw, and the
|
|
221
|
+
// screenshot beside it shows exactly what was read.
|
|
222
|
+
if ((!answer || answer.trim().length === 0) && engineName !== 'bing') {
|
|
223
|
+
answer = await page.evaluate(() => (document.body.innerText || '').trim());
|
|
224
|
+
}
|
|
225
|
+
if (!answer || answer.trim().length < 40) {
|
|
226
|
+
const reason = engineName === 'bing'
|
|
227
|
+
? 'bing: no AI answer block rendered for this prompt (organic results only — never a citation verdict)'
|
|
228
|
+
: `${engineName}: answer extracted empty`;
|
|
229
|
+
throw new EngineError(reason, { retriable: true }); }
|
|
230
|
+
// A sources-panel failure must never discard a completed answer: the
|
|
231
|
+
// answer was measured, the screenshot was taken — citations can be
|
|
232
|
+
// empty while the observation still counts, honestly, as rendered.
|
|
233
|
+
const sources = await config.extractSources(page).catch((cause) => {
|
|
234
|
+
onEvent(`sources extraction failed (answer kept, citations empty): ${String(cause?.message ?? cause).slice(0, 120)}`);
|
|
235
|
+
return [];
|
|
236
|
+
});
|
|
237
|
+
const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
engine: engineName,
|
|
241
|
+
provider: 'web-own-browser',
|
|
242
|
+
model: engineName,
|
|
243
|
+
providerModelVersion: `${engineName}-web`,
|
|
244
|
+
answer,
|
|
245
|
+
citations: (sources ?? []).map((s) => ({ url: s.url, title: s.title ?? s.cited_text ?? null })).filter((c) => typeof c.url === 'string' && c.url.length > 0),
|
|
246
|
+
fanOut: [],
|
|
247
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
248
|
+
costEstimateUsd: NOMINAL_COST_USD,
|
|
249
|
+
screenshotPng,
|
|
250
|
+
};
|
|
251
|
+
} catch (cause) {
|
|
252
|
+
if (cause instanceof EngineError) throw cause;
|
|
253
|
+
const msg = String(cause?.message ?? cause);
|
|
254
|
+
const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
|
|
255
|
+
throw new EngineError(`${engineName}: browser run failed: ${msg}`, { retriable, cause });
|
|
256
|
+
} finally {
|
|
257
|
+
await context.close().catch(() => null);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
// Called by the runner at epoch end.
|
|
262
|
+
async close() {
|
|
263
|
+
await browserHandle?.cleanup?.();
|
|
264
|
+
browserHandle = null;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AI_OVERVIEW_RAW_SOURCES_DOM_EXTRACTOR } from "../ai-overview/lib/extractSources.js";
|
|
2
|
+
import { CHATGPT_RAW_SOURCES_DOM_EXTRACTOR } from "../chatgpt/lib/extractSources.js";
|
|
3
|
+
import { CLAUDE_RAW_SOURCES_DOM_EXTRACTOR } from "../claude/lib/extractSources.js";
|
|
4
|
+
import { GEMINI_RAW_SOURCES_DOM_EXTRACTOR } from "../gemini/lib/extractSources.js";
|
|
5
|
+
import { PERPLEXITY_RAW_SOURCES_DOM_EXTRACTOR } from "../perplexity/lib/extractSources.js";
|
|
6
|
+
const PROVIDER_RAW_SOURCES_DOM_EXTRACTORS = {
|
|
7
|
+
chatgpt: CHATGPT_RAW_SOURCES_DOM_EXTRACTOR,
|
|
8
|
+
perplexity: PERPLEXITY_RAW_SOURCES_DOM_EXTRACTOR,
|
|
9
|
+
gemini: GEMINI_RAW_SOURCES_DOM_EXTRACTOR,
|
|
10
|
+
claude: CLAUDE_RAW_SOURCES_DOM_EXTRACTOR,
|
|
11
|
+
"ai-overview": AI_OVERVIEW_RAW_SOURCES_DOM_EXTRACTOR
|
|
12
|
+
};
|
|
13
|
+
export {
|
|
14
|
+
PROVIDER_RAW_SOURCES_DOM_EXTRACTORS
|
|
15
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { logger } from "../../../../shims/utils.js";
|
|
2
|
+
import { preInteractionIdle, randomBetween } from "../../../lib/browser/humanBehavior.js";
|
|
3
|
+
import { navigateWithRetry } from "../../../lib/browser/navigate.js";
|
|
4
|
+
import { detectBotPage } from "../../../lib/input/response/detectBotPage.js";
|
|
5
|
+
async function resetProviderPage(page, provider, url, options = {}) {
|
|
6
|
+
logger.log(`[${provider}] resetting page for next prompt`);
|
|
7
|
+
await preInteractionIdle(page).catch(() => {
|
|
8
|
+
});
|
|
9
|
+
await page.waitForTimeout(randomBetween(600, 1400));
|
|
10
|
+
await navigateWithRetry(page, url, {
|
|
11
|
+
waitUntil: "domcontentloaded",
|
|
12
|
+
timeout: 3e4
|
|
13
|
+
});
|
|
14
|
+
logger.log(`[${provider}] redirected back to provider page: ${page.url()}`);
|
|
15
|
+
await detectBotPage(page, provider);
|
|
16
|
+
await options.postNavigationHook?.(page);
|
|
17
|
+
await page.waitForTimeout(randomBetween(1200, 2600));
|
|
18
|
+
logger.log(`[${provider}] page reset ready: ${page.url()}`);
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
resetProviderPage
|
|
22
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { PROVIDER_EDITOR_SELECTORS } from "../../../../shims/utils.js";
|
|
2
|
+
import { logger } from "../../../../shims/utils.js";
|
|
3
|
+
import { navigateWithRetry } from "../../../lib/browser/navigate.js";
|
|
4
|
+
import {
|
|
5
|
+
findActiveEditorCandidateFromSelectors
|
|
6
|
+
} from "../../../lib/input/editor/findEditor.js";
|
|
7
|
+
import { extractAssistantMarkdown } from "../../../lib/input/markdown/toMarkdown.js";
|
|
8
|
+
import { insertPromptIntoEditor } from "../../../lib/input/editor/promptInput.js";
|
|
9
|
+
import { waitForAssistantToFinish } from "../../../lib/input/response/waitForFinish.js";
|
|
10
|
+
import { extractAIOverviewSources } from "./lib/extractSources.js";
|
|
11
|
+
import { expandAIOverviewSources } from "./lib/expand.js";
|
|
12
|
+
import {
|
|
13
|
+
AI_OVERVIEW_URL,
|
|
14
|
+
resetAIOverviewPage
|
|
15
|
+
} from "./lib/pageLifecycle.js";
|
|
16
|
+
import {
|
|
17
|
+
assertAIOverviewPageNotBlocked,
|
|
18
|
+
dismissGoogleConsentDialog,
|
|
19
|
+
ensureAIOverviewGoogleSession,
|
|
20
|
+
waitForAIOverviewSearchResults
|
|
21
|
+
} from "./lib/session.js";
|
|
22
|
+
const aiOverviewConfig = {
|
|
23
|
+
url: AI_OVERVIEW_URL,
|
|
24
|
+
label: "AI Overview",
|
|
25
|
+
displayName: "AI Overview",
|
|
26
|
+
skipInitialNavigation: true,
|
|
27
|
+
navigateToPrompt: async (page, prompt) => {
|
|
28
|
+
await ensureAIOverviewGoogleSession(page);
|
|
29
|
+
await navigateWithRetry(page, AI_OVERVIEW_URL, {
|
|
30
|
+
waitUntil: "domcontentloaded",
|
|
31
|
+
timeout: 3e4
|
|
32
|
+
});
|
|
33
|
+
assertAIOverviewPageNotBlocked(page);
|
|
34
|
+
await dismissGoogleConsentDialog(page);
|
|
35
|
+
const searchInput = await findActiveEditorCandidateFromSelectors(page, [
|
|
36
|
+
...PROVIDER_EDITOR_SELECTORS["ai-overview"]
|
|
37
|
+
]);
|
|
38
|
+
logger.debug(`[ai-overview] using search selector: ${searchInput.selector}`);
|
|
39
|
+
logger.debug(`[ai-overview] pasting ${prompt.length} chars\u2026`);
|
|
40
|
+
await insertPromptIntoEditor(
|
|
41
|
+
page,
|
|
42
|
+
searchInput.locator,
|
|
43
|
+
prompt,
|
|
44
|
+
"ai-overview"
|
|
45
|
+
);
|
|
46
|
+
logger.debug(`[ai-overview] pasting ${prompt.length} chars complete`);
|
|
47
|
+
await page.waitForTimeout(400);
|
|
48
|
+
logger.debug("[ai-overview] attempting submission\u2026");
|
|
49
|
+
await page.keyboard.press("Enter");
|
|
50
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(
|
|
51
|
+
() => {
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
await waitForAIOverviewSearchResults(page);
|
|
55
|
+
await dismissGoogleConsentDialog(page);
|
|
56
|
+
assertAIOverviewPageNotBlocked(page);
|
|
57
|
+
logger.log(`[ai-overview] search ready: ${page.url()}`);
|
|
58
|
+
},
|
|
59
|
+
waitForResponse: (page) => waitForAssistantToFinish(page, "ai-overview"),
|
|
60
|
+
extractResponse: (page) => extractAssistantMarkdown(page, "ai-overview"),
|
|
61
|
+
beforeRetryHook: resetAIOverviewPage,
|
|
62
|
+
betweenPromptsHook: resetAIOverviewPage,
|
|
63
|
+
extractSources: async (page) => {
|
|
64
|
+
await expandAIOverviewSources(page);
|
|
65
|
+
return extractAIOverviewSources(page);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
export {
|
|
69
|
+
aiOverviewConfig
|
|
70
|
+
};
|