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.
Files changed (174) hide show
  1. package/.claude-plugin/plugin.json +0 -0
  2. package/.codex-plugin/plugin.json +0 -0
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +0 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/README.md +0 -0
  8. package/bin/agentlinkops.mjs +0 -0
  9. package/bin/linktrail.mjs +0 -0
  10. package/cli/adapters/csv.js +0 -0
  11. package/cli/adapters/sqlite-crm.js +0 -0
  12. package/cli/adapters/suppliers.js +0 -0
  13. package/cli/agent-setup.js +0 -0
  14. package/cli/agentlinkops.mjs +0 -0
  15. package/cli/args.js +0 -0
  16. package/cli/catalog.js +0 -0
  17. package/cli/check.js +0 -0
  18. package/cli/citation.js +105 -1
  19. package/cli/client.js +0 -0
  20. package/cli/commands.js +0 -0
  21. package/cli/config.js +0 -0
  22. package/cli/connection.js +0 -0
  23. package/cli/context-mcp.js +0 -0
  24. package/cli/context.js +0 -0
  25. package/cli/delivery.js +0 -0
  26. package/cli/doctor.js +0 -0
  27. package/cli/env.js +0 -0
  28. package/cli/fleet.js +0 -0
  29. package/cli/gsc-browser.js +0 -0
  30. package/cli/import.js +0 -0
  31. package/cli/ledger.js +0 -0
  32. package/cli/linktrail.mjs +0 -0
  33. package/cli/local-result.js +0 -0
  34. package/cli/main.js +0 -0
  35. package/cli/mirror.js +0 -0
  36. package/cli/mix.js +0 -0
  37. package/cli/platforms.js +0 -0
  38. package/cli/receipt-performance.js +0 -0
  39. package/cli/receipts.js +0 -0
  40. package/cli/report.js +0 -0
  41. package/cli/setup-plan.js +0 -0
  42. package/cli/skill.js +0 -0
  43. package/cli/state.js +0 -0
  44. package/cli/status.js +0 -0
  45. package/cli/sync.js +0 -0
  46. package/package.json +6 -3
  47. package/references/cli.md +0 -0
  48. package/references/disavow.md +0 -0
  49. package/references/reports.md +0 -0
  50. package/scripts/agentlinkops.py +0 -0
  51. package/scripts/disavow.py +0 -0
  52. package/scripts/linktrail.py +0 -0
  53. package/scripts/migrations/001_crm.sql +0 -0
  54. package/scripts/migrations/002_cloud_events.sql +0 -0
  55. package/scripts/migrations/003_disavow.sql +0 -0
  56. package/scripts/migrations/004_placement_costs.sql +0 -0
  57. package/scripts/report.py +0 -0
  58. package/shared/agent-clients.js +0 -0
  59. package/shared/observation-event.js +0 -0
  60. package/shared/response-contract.js +0 -0
  61. package/shared/search-connection-contract.js +0 -0
  62. package/skills/agentlinkops-assets/SKILL.md +0 -0
  63. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  64. package/skills/agentlinkops-campaigns/SKILL.md +0 -0
  65. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  66. package/skills/agentlinkops-connect/SKILL.md +0 -0
  67. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  68. package/skills/agentlinkops-connect/references/agentlinkops.md +0 -0
  69. package/skills/agentlinkops-crm/SKILL.md +0 -0
  70. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  71. package/skills/content-defingerprinting/SKILL.md +0 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  82. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  83. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  84. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  85. package/skills/pre-publish-review/SKILL.md +0 -0
  86. package/skills/source-cited-content-builder/SKILL.md +0 -0
  87. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  88. package/skills/source-cited-humanizer/SKILL.md +0 -0
  89. package/src/citations/adapter.js +173 -0
  90. package/src/citations/alert.js +53 -0
  91. package/src/citations/browser/engine.js +81 -10
  92. package/src/citations/browser/gen/core/providers/_shared/google.js +0 -0
  93. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  94. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  95. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  96. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  97. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  98. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  99. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  100. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  101. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  102. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  103. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  104. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  105. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  106. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  107. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  108. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  109. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  110. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  111. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  112. package/src/citations/browser/gen/core/providers/index.js +0 -0
  113. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  114. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  115. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  116. package/src/citations/browser/gen/core/providers/types.js +0 -0
  117. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  118. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  119. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  120. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  121. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  122. package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -0
  123. package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -0
  124. package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
  125. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  126. package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -0
  127. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  128. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  129. package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -0
  130. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  131. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  132. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  133. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  134. package/src/citations/browser/gen/lib/input/response/getText.js +0 -0
  135. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  136. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  137. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  138. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  139. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  140. package/src/citations/browser/providers.js +166 -0
  141. package/src/citations/browser/shims/errors.js +0 -0
  142. package/src/citations/browser/shims/types.js +0 -0
  143. package/src/citations/browser/shims/utils.js +0 -0
  144. package/src/citations/contract.js +10 -1
  145. package/src/citations/fixtures/dataforseo-aio.json +82 -0
  146. package/src/citations/match.js +0 -0
  147. package/src/citations/panel-builder.js +44 -0
  148. package/src/citations/report.js +165 -0
  149. package/src/citations/runner.js +25 -14
  150. package/src/citations/stats.js +63 -0
  151. package/src/citations/watch.js +85 -0
  152. package/src/competitors/contract.js +0 -0
  153. package/src/competitors/domain-mix.js +0 -0
  154. package/src/context/ga4.js +0 -0
  155. package/src/context/gsc.js +0 -0
  156. package/src/context/site-profile.js +0 -0
  157. package/src/context/util.js +0 -0
  158. package/src/context-tool-registry.js +0 -0
  159. package/src/corpus/acquire.js +0 -0
  160. package/src/corpus/seeds.js +0 -0
  161. package/src/discovery/contract.js +0 -0
  162. package/src/discovery/import.js +0 -0
  163. package/src/tool-views.js +0 -0
  164. package/src/verifier/fetch.js +0 -0
  165. package/src/verifier/index.js +0 -0
  166. package/src/verifier/observation-kind.js +0 -0
  167. package/src/verifier/parser.js +0 -0
  168. package/src/verifier/readiness.js +0 -0
  169. package/src/verifier/render-evidence.js +0 -0
  170. package/src/verifier/robots.js +0 -0
  171. package/src/verifier/transition.js +0 -0
  172. package/src/verifier/url.js +0 -0
  173. package/templates/SITE.template.md +0 -0
  174. 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.004;
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
- providerConfig = PROVIDER_CONFIGS[engineName];
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, { firefox: launchFirefox }] = [
75
+ const [{ resolveCamoufoxLaunchOptions }, display, fw] = [
75
76
  await lazy('./gen/lib/browser/camoufox.js'),
76
77
  await lazy('./gen/lib/browser/display.js'),
77
- { firefox: await firefox() },
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 browser = await launchFirefox({ ...options, executablePath: options.executablePath, proxy });
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
- const answer = await config.extractResponse(page);
162
- if (!answer || answer.trim().length === 0) {
163
- throw new EngineError(`${engineName}: answer extracted empty`, { retriable: true });
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
- const sources = await config.extractSources(page);
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
@@ -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
+ }
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
+ }