agentlinkops 0.5.1 → 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 +0 -0
- package/.codex-plugin/plugin.json +0 -0
- package/.mcp.json +0 -0
- package/CHANGELOG.md +0 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- 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 +105 -1
- package/cli/client.js +0 -0
- 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 +0 -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 +6 -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 +81 -10
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -0
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -0
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +166 -0
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +0 -0
- package/src/citations/contract.js +10 -1
- 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 +25 -14
- 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
|
@@ -30,11 +30,11 @@ async function firefox() {
|
|
|
30
30
|
return playwrightFirefox;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export const BROWSER_PROVIDERS = Object.freeze(['chatgpt', 'perplexity', 'gemini', 'claude', 'ai-overview']);
|
|
33
|
+
export const BROWSER_PROVIDERS = Object.freeze(['chatgpt', 'perplexity', 'gemini', 'claude', 'ai-overview', 'grok', 'copilot', 'bing', 'duckai']);
|
|
34
34
|
|
|
35
35
|
// Nominal per-answer estimate: ~0.3 MB through a residential proxy at ~$7/GB,
|
|
36
36
|
// plus slack for the page itself. Checked BEFORE the run by the budget cap.
|
|
37
|
-
const NOMINAL_COST_USD = 0.
|
|
37
|
+
const NOMINAL_COST_USD = 0.03;
|
|
38
38
|
|
|
39
39
|
function parseProxyUrl(raw) {
|
|
40
40
|
if (!raw) return null;
|
|
@@ -64,17 +64,18 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
64
64
|
async function ensureProviderConfig() {
|
|
65
65
|
if (providerConfig) return providerConfig;
|
|
66
66
|
const { PROVIDER_CONFIGS } = await lazy('./gen/core/providers/index.js');
|
|
67
|
-
|
|
67
|
+
const { OWN_PROVIDER_CONFIGS } = await lazy('./providers.js');
|
|
68
|
+
providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
|
|
68
69
|
if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
|
|
69
70
|
return providerConfig;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function ensureBrowser() {
|
|
73
74
|
if (browserHandle) return browserHandle;
|
|
74
|
-
const [{ resolveCamoufoxLaunchOptions }, display,
|
|
75
|
+
const [{ resolveCamoufoxLaunchOptions }, display, fw] = [
|
|
75
76
|
await lazy('./gen/lib/browser/camoufox.js'),
|
|
76
77
|
await lazy('./gen/lib/browser/display.js'),
|
|
77
|
-
|
|
78
|
+
await firefox(),
|
|
78
79
|
];
|
|
79
80
|
|
|
80
81
|
// Linux without a display gets the vendored self-bootstrapped Xvfb — the
|
|
@@ -90,11 +91,16 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
90
91
|
const options = await resolveCamoufoxLaunchOptions({
|
|
91
92
|
provider: engineName,
|
|
92
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,
|
|
93
97
|
// "virtual" on Linux = headful Firefox inside Xvfb; true headless is the
|
|
94
98
|
// detection magnet the research warned about.
|
|
95
99
|
headlessMode: process.platform === 'linux' ? 'virtual' : 'headful',
|
|
96
100
|
});
|
|
97
|
-
const
|
|
101
|
+
const launchOpts = { ...options, executablePath: options.executablePath };
|
|
102
|
+
if (proxy) launchOpts.proxy = proxy;
|
|
103
|
+
const browser = await fw.launch(launchOpts);
|
|
98
104
|
|
|
99
105
|
browserHandle = {
|
|
100
106
|
browser,
|
|
@@ -128,12 +134,61 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
128
134
|
locale: 'en-US',
|
|
129
135
|
timezoneId: 'America/New_York',
|
|
130
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
|
+
}
|
|
131
147
|
try {
|
|
132
148
|
const page = await context.newPage();
|
|
133
149
|
page.setDefaultTimeout(45_000);
|
|
134
150
|
|
|
135
151
|
if (config.navigateToPrompt) {
|
|
136
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);
|
|
137
192
|
} else {
|
|
138
193
|
await page.goto(config.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
139
194
|
await config.postNavigationHook?.(page);
|
|
@@ -158,11 +213,27 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
158
213
|
await config.afterSubmitHook?.(page);
|
|
159
214
|
|
|
160
215
|
await config.waitForResponse(page);
|
|
161
|
-
|
|
162
|
-
if (!answer
|
|
163
|
-
|
|
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());
|
|
164
224
|
}
|
|
165
|
-
|
|
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
|
+
});
|
|
166
237
|
const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
|
|
167
238
|
|
|
168
239
|
return {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// AgentLinkOps-owned browser provider configs: surfaces the vendored oneglanse
|
|
2
|
+
// layer never shipped, written against the same ProviderConfig shape so the
|
|
3
|
+
// engine treats them identically. Each config is probe-proven on the real
|
|
4
|
+
// surface through Camoufox + US residential (receipts: 2026-09-17/18).
|
|
5
|
+
//
|
|
6
|
+
// GROK (grok.com): Cloudflare passes from residential exits; anonymous use
|
|
7
|
+
// grants one full answer per fresh context, then a signup nudge — which the
|
|
8
|
+
// fresh-context-per-sample rule already assumes.
|
|
9
|
+
//
|
|
10
|
+
// COPILOT (copilot.microsoft.com): anonymous composer behind a sign-in modal
|
|
11
|
+
// whose Close control resisted automated dismissal; EXPERIMENTAL until the
|
|
12
|
+
// session-login command (T04) lands.
|
|
13
|
+
//
|
|
14
|
+
// BING (bing.com/search): the generative answer block renders WITH cited
|
|
15
|
+
// outbound links, anonymously, no challenge. Pure URL navigation — the prompt
|
|
16
|
+
// is the URL. The block is found by content signature, never position.
|
|
17
|
+
|
|
18
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
// Generic answer wait: poll the body until its text stabilizes (the vendored
|
|
21
|
+
// signature-based waiter needs provider constants these surfaces lack).
|
|
22
|
+
async function waitForStableBody(page, { stableMs = 2500, timeoutMs = 90000 } = {}) {
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
let last = -1;
|
|
25
|
+
let stableSince = null;
|
|
26
|
+
while (Date.now() < deadline) {
|
|
27
|
+
const text = await page.evaluate(() => (document.body.innerText || '').length).catch(() => 0);
|
|
28
|
+
if (text === last && text > 300) {
|
|
29
|
+
if (stableSince && Date.now() - stableSince >= stableMs) return true;
|
|
30
|
+
if (!stableSince) stableSince = Date.now();
|
|
31
|
+
} else {
|
|
32
|
+
stableSince = null;
|
|
33
|
+
last = text;
|
|
34
|
+
}
|
|
35
|
+
await sleep(800);
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// One page-context collector for bing: find the generative block by signature
|
|
41
|
+
// (several sentences of prose plus >=2 distinct outbound citation hosts in one
|
|
42
|
+
// container) and return answer text and citation links together. An organic
|
|
43
|
+
// result for a different intent can never masquerade as an AI answer, and a
|
|
44
|
+
// missing block returns empty — which the engine reports as
|
|
45
|
+
// "no AI answer block rendered", an unknown, never a citation verdict.
|
|
46
|
+
const bingCollect = () => {
|
|
47
|
+
const findAiBlock = () => {
|
|
48
|
+
// Only result items that are NOT organic cards (b_algo is a plain organic
|
|
49
|
+
// result; the generative block renders under other classes), never the
|
|
50
|
+
// outer wrapper div whose text begins with page chrome.
|
|
51
|
+
const candidates = document.querySelectorAll('#b_results > li:not(.b_algo)');
|
|
52
|
+
for (const el of candidates) {
|
|
53
|
+
const text = (el.innerText || '').trim();
|
|
54
|
+
if (text.length < 300) continue;
|
|
55
|
+
if (/^(skip to|about \d|accessibility)/i.test(text.slice(0, 24))) continue;
|
|
56
|
+
const sentences = text.split(/[.!?]+\s/).filter((x) => x.trim().length > 30).length;
|
|
57
|
+
if (sentences < 3) continue;
|
|
58
|
+
const hosts = new Set();
|
|
59
|
+
for (const a of el.querySelectorAll('a[href^="http"]')) {
|
|
60
|
+
try {
|
|
61
|
+
const u = new URL(a.href);
|
|
62
|
+
if (/bing\.com|microsoft\.com/.test(u.hostname)) continue;
|
|
63
|
+
hosts.add(u.hostname);
|
|
64
|
+
} catch { /* skip malformed */ }
|
|
65
|
+
}
|
|
66
|
+
if (hosts.size >= 3) return el;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const block = findAiBlock();
|
|
71
|
+
if (!block) return { answer: '', sources: [] };
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const sources = [];
|
|
74
|
+
for (const a of block.querySelectorAll('a[href^="http"]')) {
|
|
75
|
+
try {
|
|
76
|
+
const u = new URL(a.href);
|
|
77
|
+
if (/bing\.com|microsoft\.com/.test(u.hostname)) continue;
|
|
78
|
+
const key = u.hostname + u.pathname;
|
|
79
|
+
if (seen.has(key)) continue;
|
|
80
|
+
seen.add(key);
|
|
81
|
+
sources.push({ url: a.href, title: (a.textContent || '').trim().slice(0, 200) || null, domain: u.hostname });
|
|
82
|
+
} catch { /* skip malformed */ }
|
|
83
|
+
}
|
|
84
|
+
return { answer: (block.innerText || '').trim(), sources };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// duck.ai in-page chat flow: the surface's own JS solves the VQD challenge,
|
|
88
|
+
// so the engine drives fetch() from inside a real duck.ai page — cookies,
|
|
89
|
+
// headers and challenge all native. Claude Haiku 4.5 serves anonymously.
|
|
90
|
+
const DUCK_MODEL = 'claude-haiku-4-5';
|
|
91
|
+
|
|
92
|
+
export async function duckChat(page, prompt) {
|
|
93
|
+
const result = await page.evaluate(async ({ model, text }) => {
|
|
94
|
+
const j = async (r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json().catch(() => ({})); };
|
|
95
|
+
// Status first: same-origin response headers carry the VQD token.
|
|
96
|
+
const statusRes = await fetch('https://duck.ai/duckchat/v1/status', { credentials: 'include' });
|
|
97
|
+
const vqd = statusRes.headers.get('x-vqd-4') || statusRes.headers.get('x-vqd');
|
|
98
|
+
if (!vqd) throw new Error('no vqd token in status response');
|
|
99
|
+
const models = await j(await fetch('https://duck.ai/duckchat/v1/models', { credentials: 'include', headers: { 'x-vqd-4': vqd, 'x-vqd': vqd } }));
|
|
100
|
+
const ids = (models.data ?? []).map((m) => m.id ?? m.model ?? String(m));
|
|
101
|
+
if (!ids.some((id) => String(id).includes('claude'))) {
|
|
102
|
+
throw new Error('no claude model in duck lineup: ' + ids.slice(0, 6).join(','));
|
|
103
|
+
}
|
|
104
|
+
const chatRes = await fetch('https://duck.ai/duckchat/v1/chat', {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
credentials: 'include',
|
|
107
|
+
headers: { 'content-type': 'application/json', 'x-vqd-4': vqd, 'x-vqd': vqd },
|
|
108
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: text }], stream: true }),
|
|
109
|
+
});
|
|
110
|
+
if (!chatRes.ok) throw new Error('chat HTTP ' + chatRes.status);
|
|
111
|
+
const reader = chatRes.body.getReader();
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
let buf = '';
|
|
114
|
+
let message = '';
|
|
115
|
+
for (;;) {
|
|
116
|
+
const { done, value } = await reader.read();
|
|
117
|
+
if (done) break;
|
|
118
|
+
buf += decoder.decode(value, { stream: true });
|
|
119
|
+
const lines = buf.split('\n');
|
|
120
|
+
buf = lines.pop() ?? '';
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
const t = line.trim();
|
|
123
|
+
if (!t.startsWith('data:')) continue;
|
|
124
|
+
const payload = t.slice(5).trim();
|
|
125
|
+
if (!payload || payload === '[DONE]') continue;
|
|
126
|
+
try {
|
|
127
|
+
const obj = JSON.parse(payload);
|
|
128
|
+
message += obj.message ?? obj.content ?? '';
|
|
129
|
+
} catch { /* partial json across chunks */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { message, models: ids };
|
|
133
|
+
}, { model: DUCK_MODEL, text: prompt });
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const OWN_PROVIDER_CONFIGS = {
|
|
138
|
+
grok: {
|
|
139
|
+
url: 'https://grok.com/',
|
|
140
|
+
label: 'Grok',
|
|
141
|
+
experimental: false,
|
|
142
|
+
navigateToPrompt: null, // the engine's grok branch owns the flow
|
|
143
|
+
waitForResponse: (page) => waitForStableBody(page),
|
|
144
|
+
extractResponse: (page) => page.evaluate(() => (document.body.innerText || '').trim()),
|
|
145
|
+
extractSources: async () => [], // inline links in text; panel extraction unproven
|
|
146
|
+
},
|
|
147
|
+
copilot: {
|
|
148
|
+
url: 'https://copilot.microsoft.com/',
|
|
149
|
+
label: 'Copilot',
|
|
150
|
+
experimental: true,
|
|
151
|
+
waitForResponse: (page) => waitForStableBody(page),
|
|
152
|
+
extractResponse: (page) => page.evaluate(() => (document.body.innerText || '').trim()),
|
|
153
|
+
extractSources: async () => [],
|
|
154
|
+
},
|
|
155
|
+
bing: {
|
|
156
|
+
url: 'https://www.bing.com/',
|
|
157
|
+
label: 'Bing AI answers',
|
|
158
|
+
experimental: false,
|
|
159
|
+
waitForResponse: async (page) => {
|
|
160
|
+
await sleep(6000);
|
|
161
|
+
await waitForStableBody(page, { stableMs: 1800, timeoutMs: 30000 });
|
|
162
|
+
},
|
|
163
|
+
extractResponse: async (page) => (await page.evaluate(bingCollect)).answer,
|
|
164
|
+
extractSources: async (page) => (await page.evaluate(bingCollect)).sources,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -29,7 +29,7 @@ export const CITATION_TARGET_SCOPES = Object.freeze(['domain', 'url']);
|
|
|
29
29
|
|
|
30
30
|
// Engines this version knows. `mock` costs nothing, is deterministic per seed, and
|
|
31
31
|
// exists so every test and every dry-run proves the pipeline without spending money.
|
|
32
|
-
export const CITATION_ENGINES = Object.freeze(['mock', 'perplexity']);
|
|
32
|
+
export const CITATION_ENGINES = Object.freeze(['mock', 'perplexity', 'duck', 'google-aio']);
|
|
33
33
|
|
|
34
34
|
// Token rates used ONLY for the pre-call budget estimate. These are not bills; they
|
|
35
35
|
// are the numbers the hard maxUsd cap is enforced against, so they are deliberately
|
|
@@ -92,6 +92,15 @@ export const citationPanelSchema = z.object({
|
|
|
92
92
|
// weights, mentionText, fanOut, costEstimateUsd. This is how a dry run simulates a
|
|
93
93
|
// real panel (and how a test simulates a collapse) without spending anything.
|
|
94
94
|
mockFixtures: z.record(z.string(), z.any()).optional(),
|
|
95
|
+
// Competitor domains: the panel watches them alongside the primary target,
|
|
96
|
+
// each as its own cell identity (never averaged with the target).
|
|
97
|
+
competitors: z.array(z.object({
|
|
98
|
+
domain: z.string().min(3).max(253),
|
|
99
|
+
brand: z.string().min(1).max(100),
|
|
100
|
+
aliases: z.array(z.string().min(1).max(100)).max(12).default([]),
|
|
101
|
+
})).max(10).default([]),
|
|
102
|
+
// Locale pinning: surfaces vary answers by geography. Default en-US.
|
|
103
|
+
locale: z.string().min(2).max(10).default('en-US'),
|
|
95
104
|
note: z.string().max(500).optional(),
|
|
96
105
|
});
|
|
97
106
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.0.0",
|
|
3
|
+
"status_code": 20000,
|
|
4
|
+
"status_message": "Ok.",
|
|
5
|
+
"cost": 0.0012,
|
|
6
|
+
"tasks_count": 1,
|
|
7
|
+
"tasks_error": 0,
|
|
8
|
+
"tasks": [
|
|
9
|
+
{
|
|
10
|
+
"id": "09211815-1535-0067-0000-7b8e2b4e5c91",
|
|
11
|
+
"status_code": 20000,
|
|
12
|
+
"status_message": "Ok.",
|
|
13
|
+
"cost": 0.0012,
|
|
14
|
+
"result_count": 1,
|
|
15
|
+
"result": [
|
|
16
|
+
{
|
|
17
|
+
"keyword": "what is backlink monitoring",
|
|
18
|
+
"type": "organic",
|
|
19
|
+
"se_domain": "google.com",
|
|
20
|
+
"location_code": 2840,
|
|
21
|
+
"language_code": "en",
|
|
22
|
+
"check_url": "https://www.google.com/search?q=what%20is%20backlink%20monitoring&num=10&hl=en&gl=us&gws_rd=cr",
|
|
23
|
+
"datetime": "2026-09-18 18:15:03 +00:00",
|
|
24
|
+
"item_types": ["ai_overview", "organic", "featured_snippet", "people_also_ask"],
|
|
25
|
+
"se_results_count": 12400000,
|
|
26
|
+
"items": [
|
|
27
|
+
{
|
|
28
|
+
"type": "ai_overview",
|
|
29
|
+
"rank_group": 1,
|
|
30
|
+
"rank_absolute": 1,
|
|
31
|
+
"position": "top",
|
|
32
|
+
"xpath": "/html[1]/body[1]/div[6]/div[2]/div[9]/div[1]/div[2]/div[2]/div[1]/div[1]",
|
|
33
|
+
"text": "Backlink monitoring is the process of regularly tracking and analyzing the links pointing to your website from other sites. It involves checking whether your existing backlinks are still active, identifying new backlinks as they appear, and analyzing the quality and relevance of those links. Effective backlink monitoring helps you maintain a healthy link profile, identify potentially harmful links, and find opportunities for new link building. Common tools for backlink monitoring include Ahrefs, Semrush, and Moz, which provide comprehensive dashboards for tracking your link profile over time.",
|
|
34
|
+
"items": [
|
|
35
|
+
{
|
|
36
|
+
"type": "ai_overview_item",
|
|
37
|
+
"source": "backlinkmonitoring.ai",
|
|
38
|
+
"domain": "backlinkmonitoring.ai",
|
|
39
|
+
"title": "What Is Backlink Monitoring and Why It Matters",
|
|
40
|
+
"url": "https://backlinkmonitoring.ai/blog/what-is-backlink-monitoring",
|
|
41
|
+
"snippet": "Backlink monitoring is the practice of regularly tracking and analyzing inbound links to your website..."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"type": "ai_overview_item",
|
|
45
|
+
"source": "adaptify.ai",
|
|
46
|
+
"domain": "adaptify.ai",
|
|
47
|
+
"title": "What is Backlink Monitoring and Why Your SEO Needs It",
|
|
48
|
+
"url": "https://adaptify.ai/blog/what-is-backlink-monitoring-and-why-your-seo-needs-it",
|
|
49
|
+
"snippet": "Effective backlink monitoring helps you maintain a healthy link profile and identify harmful links..."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"type": "ai_overview_item",
|
|
53
|
+
"source": "lynkdog.com",
|
|
54
|
+
"domain": "lynkdog.com",
|
|
55
|
+
"title": "Backlink Monitoring: A Complete Beginner's Guide",
|
|
56
|
+
"url": "https://lynkdog.com/blog/backlink-monitoring-beginners-guide",
|
|
57
|
+
"snippet": "Backlink monitoring involves checking whether your existing backlinks are still active..."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"type": "ai_overview_item",
|
|
61
|
+
"source": "brand24.com",
|
|
62
|
+
"domain": "brand24.com",
|
|
63
|
+
"title": "Backlinks Monitoring 101: Tools, Tips, and Best Practices",
|
|
64
|
+
"url": "https://brand24.com/blog/backlinks-monitoring-101/",
|
|
65
|
+
"snippet": "Common tools for backlink monitoring include Ahrefs, Semrush, and Moz..."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "organic",
|
|
71
|
+
"rank_group": 1,
|
|
72
|
+
"rank_absolute": 2,
|
|
73
|
+
"url": "https://backlinko.com/hub/seo/backlinks",
|
|
74
|
+
"domain": "backlinko.com",
|
|
75
|
+
"title": "What Are Backlinks in SEO & Why You Need Them"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
package/src/citations/match.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Guided panel authoring: turn a domain + optional brand into a ready-to-run
|
|
2
|
+
// panel with sensible defaults. The buyer who doesn't want to write JSON
|
|
3
|
+
// gets a starting point they can edit.
|
|
4
|
+
export function buildStarterPanel({ domain, brand, aliases = [], engines = [
|
|
5
|
+
{ engine: 'chatgpt', via: 'browser' },
|
|
6
|
+
{ engine: 'duck', model: 'claude-haiku-4-5' },
|
|
7
|
+
{ engine: 'google-aio' },
|
|
8
|
+
], samples = 10, maxUsd = 1.0, competitors = [] } = {}) {
|
|
9
|
+
if (!domain || !brand) throw new Error('domain and brand are required');
|
|
10
|
+
const prompts = defaultPrompts(domain);
|
|
11
|
+
const targets = [
|
|
12
|
+
{ domain, scope: 'domain', brand, aliases },
|
|
13
|
+
...competitors.map((c) => ({ domain: c.domain, scope: 'domain', brand: c.brand, aliases: c.aliases ?? [] })),
|
|
14
|
+
];
|
|
15
|
+
return {
|
|
16
|
+
schema_version: 1,
|
|
17
|
+
note: `Citation watch for ${brand}`,
|
|
18
|
+
targets,
|
|
19
|
+
prompts,
|
|
20
|
+
engines,
|
|
21
|
+
samples,
|
|
22
|
+
maxUsd,
|
|
23
|
+
competitors: competitors.map((c) => ({ domain: c.domain, brand: c.brand, aliases: c.aliases ?? [] })),
|
|
24
|
+
locale: 'en-US',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Ten sensible starting prompts: category, how-to, comparison, tool-seeking.
|
|
29
|
+
// Replace with GSC-derived panels (DP-0032) once search data is wired.
|
|
30
|
+
function defaultPrompts(domain) {
|
|
31
|
+
const base = domain.split('.')[0];
|
|
32
|
+
return [
|
|
33
|
+
{ id: 'category', text: `what is ${base}` },
|
|
34
|
+
{ id: 'best-tools', text: `best ${base} tools` },
|
|
35
|
+
{ id: 'how-to', text: `how to use ${base}` },
|
|
36
|
+
{ id: 'comparison', text: `${base} vs alternatives` },
|
|
37
|
+
{ id: 'open-source', text: `open source ${base}` },
|
|
38
|
+
{ id: 'pricing', text: `${base} pricing` },
|
|
39
|
+
{ id: 'reviews', text: `${base} reviews` },
|
|
40
|
+
{ id: 'tutorial', text: `${base} tutorial` },
|
|
41
|
+
{ id: 'alternatives', text: `${base} alternatives free` },
|
|
42
|
+
{ id: 'for-beginners', text: `${base} for beginners` },
|
|
43
|
+
];
|
|
44
|
+
}
|