agentlinkops 0.6.11 → 0.6.16

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 (311) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +35 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/PACK-ARTIFACT.json +16 -0
  8. package/PACKS.json +591 -0
  9. package/README.md +34 -48
  10. package/bin/agentlinkops.mjs +0 -0
  11. package/bin/linktrail.mjs +0 -0
  12. package/cli/adapters/csv.js +0 -0
  13. package/cli/adapters/sqlite-crm.js +0 -0
  14. package/cli/adapters/suppliers.js +1 -1
  15. package/cli/admission.js +0 -0
  16. package/cli/agent-setup.js +101 -17
  17. package/cli/agentlinkops.mjs +0 -0
  18. package/cli/args.js +0 -0
  19. package/cli/campaign-bridge.js +325 -0
  20. package/cli/campaign-outcomes.js +107 -0
  21. package/cli/catalog.js +47686 -16398
  22. package/cli/check.js +40 -10
  23. package/cli/chronology.js +39 -0
  24. package/cli/citation.js +0 -0
  25. package/cli/client.js +2 -1
  26. package/cli/cloud-error.js +0 -0
  27. package/cli/commands.js +17 -8
  28. package/cli/config.js +0 -0
  29. package/cli/connection.js +92 -18
  30. package/cli/contact-adoption.js +91 -0
  31. package/cli/context-mcp.js +0 -0
  32. package/cli/context.js +13 -2
  33. package/cli/delivery.js +0 -0
  34. package/cli/disavow.js +0 -0
  35. package/cli/doctor.js +15 -4
  36. package/cli/env.js +0 -0
  37. package/cli/fleet.js +30 -19
  38. package/cli/gsc-browser.js +0 -0
  39. package/cli/import.js +7 -1
  40. package/cli/index-observations.js +0 -0
  41. package/cli/index-submissions.js +0 -0
  42. package/cli/key-store.js +115 -0
  43. package/cli/ledger.js +1 -0
  44. package/cli/lifecycle.js +0 -0
  45. package/cli/linktrail.mjs +0 -0
  46. package/cli/local-result.js +41 -6
  47. package/cli/main.js +85 -20
  48. package/cli/mcp-client.js +2 -2
  49. package/cli/migration.js +308 -0
  50. package/cli/mirror.js +0 -0
  51. package/cli/mix.js +0 -0
  52. package/cli/native-lock.js +0 -0
  53. package/cli/oauth-credentials.js +0 -0
  54. package/cli/oauth-flow.js +0 -0
  55. package/cli/placement-projection.js +106 -0
  56. package/cli/platforms.js +0 -0
  57. package/cli/prompt.js +25 -0
  58. package/cli/public-http.js +168 -0
  59. package/cli/receipt-performance.js +0 -0
  60. package/cli/receipts.js +0 -0
  61. package/cli/report.js +51 -19
  62. package/cli/setup-plan.js +0 -0
  63. package/cli/skill.js +0 -0
  64. package/cli/state.js +76 -29
  65. package/cli/status.js +14 -18
  66. package/cli/sync-lock.js +0 -0
  67. package/cli/sync.js +23 -4
  68. package/package.json +7 -8
  69. package/references/citation-panels.md +2 -0
  70. package/references/cli.md +2 -2
  71. package/references/disavow.md +1 -1
  72. package/references/discovery-handoff.md +1 -1
  73. package/references/local-crm-draft-handoff.md +1 -1
  74. package/references/portable-ledger-handoff.md +11 -10
  75. package/references/recipes/catalog.json +263 -7
  76. package/references/recipes/command-notes.md +5 -3
  77. package/references/recipes/internal-link-improvement.md +25 -0
  78. package/references/recipes/placement-reconciliation.md +53 -1
  79. package/references/recipes/qualified-campaign-handoff.md +0 -0
  80. package/references/recipes/site-context-brief.md +0 -0
  81. package/references/recipes/sourced-linkable-asset.md +0 -0
  82. package/references/reports.md +0 -0
  83. package/scripts/agentlinkops.py +0 -0
  84. package/scripts/disavow.py +0 -0
  85. package/scripts/linktrail.py +0 -0
  86. package/scripts/migrations/001_crm.sql +0 -0
  87. package/scripts/migrations/002_cloud_events.sql +0 -0
  88. package/scripts/migrations/003_disavow.sql +0 -0
  89. package/scripts/migrations/004_placement_costs.sql +0 -0
  90. package/scripts/outreach-crm-draft.py +0 -0
  91. package/scripts/portable-handoff.py +17 -4
  92. package/scripts/report.py +0 -0
  93. package/shared/admission-contract.js +3 -2
  94. package/shared/admission-report-contract.js +3 -2
  95. package/shared/agent-clients.js +0 -0
  96. package/shared/analytics-history-contract.js +15 -0
  97. package/shared/campaign-outcome-contract.js +12 -0
  98. package/shared/campaign-prospect-contract.js +23 -0
  99. package/shared/citation-evidence-contract.js +0 -0
  100. package/shared/classification-contract.js +54 -0
  101. package/shared/contact-record-contract.js +27 -0
  102. package/shared/disavow-contract.js +37 -9
  103. package/shared/exec-grammar.js +0 -0
  104. package/shared/index-observation-contract.js +5 -4
  105. package/shared/internal-links-contract.js +143 -0
  106. package/shared/lifecycle-contract.js +0 -0
  107. package/shared/lifecycle-money.js +0 -0
  108. package/shared/link-category-contract.js +35 -0
  109. package/shared/link-category.js +138 -0
  110. package/shared/link-labels.js +26 -0
  111. package/shared/link-summary-contract.js +17 -0
  112. package/shared/monitoring-chronology-export.js +57 -0
  113. package/shared/monitoring-read-contract.js +41 -0
  114. package/shared/observation-event.js +1 -1
  115. package/shared/onboarding.js +20 -0
  116. package/shared/opportunity-record.js +0 -0
  117. package/shared/response-contract.js +41 -5
  118. package/shared/search-connection-contract.js +2 -1
  119. package/shared/table-page-contract.js +13 -0
  120. package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
  121. package/skills/agentlinkops-assets/SKILL.md +3 -1
  122. package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
  123. package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
  124. package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
  125. package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
  126. package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
  127. package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
  128. package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
  129. package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  130. package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
  131. package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  132. package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
  133. package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
  134. package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
  135. package/skills/agentlinkops-campaigns/SKILL.md +7 -5
  136. package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
  137. package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
  138. package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
  139. package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
  140. package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
  141. package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
  142. package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
  143. package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
  144. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
  145. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  146. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
  147. package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  148. package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
  149. package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
  150. package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
  151. package/skills/agentlinkops-connect/SKILL.md +2 -0
  152. package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
  153. package/skills/agentlinkops-connect/references/agentlinkops.md +164 -61
  154. package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
  155. package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
  156. package/skills/agentlinkops-crm/SKILL.md +26 -9
  157. package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
  158. package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
  159. package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
  160. package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
  161. package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
  162. package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
  163. package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
  164. package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
  165. package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
  166. package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  167. package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
  168. package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  169. package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
  170. package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
  171. package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
  172. package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
  173. package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
  174. package/skills/agentlinkops-discovery/SKILL.md +4 -2
  175. package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
  176. package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
  177. package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
  178. package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
  179. package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
  180. package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
  181. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
  182. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
  183. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
  184. package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
  185. package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
  186. package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
  187. package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
  188. package/src/citations/adapter.js +0 -0
  189. package/src/citations/alert.js +0 -0
  190. package/src/citations/browser/chatgpt-citations.js +0 -0
  191. package/src/citations/browser/engine.js +0 -0
  192. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  193. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  194. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  195. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  196. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  197. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  198. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  199. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  200. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  201. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  202. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  203. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  204. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  205. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  206. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  207. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  208. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  209. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  210. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  211. package/src/citations/browser/gen/core/providers/index.js +0 -0
  212. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  213. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  214. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  215. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  216. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  217. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  218. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  219. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  220. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  221. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  222. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  223. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  224. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  225. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  226. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  227. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  228. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  229. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  230. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  231. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  232. package/src/citations/browser/providers.js +0 -0
  233. package/src/citations/browser/proxy-admission.js +0 -0
  234. package/src/citations/browser/shims/errors.js +0 -0
  235. package/src/citations/browser/shims/types.js +0 -0
  236. package/src/citations/browser/shims/utils.js +0 -0
  237. package/src/citations/contract.js +0 -0
  238. package/src/citations/egress-receipts.js +0 -0
  239. package/src/citations/evidence-integrity.js +0 -0
  240. package/src/citations/inventory.js +0 -0
  241. package/src/citations/login.js +0 -0
  242. package/src/citations/match.js +0 -0
  243. package/src/citations/panel-builder.js +0 -0
  244. package/src/citations/report.js +0 -0
  245. package/src/citations/runner.js +0 -0
  246. package/src/citations/stats.js +0 -0
  247. package/src/citations/sync.js +0 -0
  248. package/src/citations/watch.js +0 -0
  249. package/src/competitors/contract.js +17 -1
  250. package/src/competitors/domain-mix.js +0 -0
  251. package/src/context/ga4.js +0 -0
  252. package/src/context/gsc.js +0 -0
  253. package/src/context/site-profile.js +34 -121
  254. package/src/context/util.js +0 -0
  255. package/src/context-tool-registry.js +0 -0
  256. package/src/corpus/acquire.js +68 -32
  257. package/src/corpus/extract.js +147 -0
  258. package/src/corpus/frontier.js +151 -0
  259. package/src/corpus/seeds.js +0 -0
  260. package/src/discovery/contract.js +8 -0
  261. package/src/discovery/import.js +13 -1
  262. package/src/evidence-reference.js +0 -0
  263. package/src/index-observation.js +0 -0
  264. package/src/locator.js +0 -0
  265. package/src/mentions.js +193 -0
  266. package/src/page-acquisition/acquire.js +50 -0
  267. package/src/page-acquisition/bytes.js +138 -0
  268. package/src/page-acquisition/capture.js +11 -0
  269. package/src/page-acquisition/contracts.js +67 -0
  270. package/src/page-acquisition/documents.js +166 -0
  271. package/src/page-acquisition/http.js +307 -0
  272. package/src/page-acquisition/policy.js +20 -0
  273. package/src/page-acquisition/recovery.js +164 -0
  274. package/src/page-acquisition/render-failure.js +82 -0
  275. package/src/page-extraction/html.js +359 -0
  276. package/src/page-extraction/index.js +30 -0
  277. package/src/page-extraction/metadata.js +16 -0
  278. package/src/response-shaping.js +4 -3
  279. package/src/tool-views.js +23 -12
  280. package/src/verifier/fetch.js +4 -339
  281. package/src/verifier/index.js +81 -23
  282. package/src/verifier/observation-kind.js +0 -0
  283. package/src/verifier/page-quality.js +14 -0
  284. package/src/verifier/parser.js +2 -241
  285. package/src/verifier/readiness.js +64 -13
  286. package/src/verifier/render-evidence.js +25 -2
  287. package/src/verifier/robots.js +0 -0
  288. package/src/verifier/target.js +201 -0
  289. package/src/verifier/transition.js +18 -3
  290. package/src/verifier/url.js +31 -0
  291. package/src/watch-crawlability.js +144 -0
  292. package/templates/SITE.template.md +0 -0
  293. package/templates/campaigns.v1.json +0 -0
  294. package/skills/content-defingerprinting/SKILL.md +0 -29
  295. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
  296. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
  297. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
  298. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
  299. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
  300. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
  301. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
  302. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
  303. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
  304. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
  305. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
  306. package/skills/internal-linking-optimizer/SKILL.md +0 -56
  307. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
  308. package/skills/pre-publish-review/SKILL.md +0 -179
  309. package/skills/source-cited-content-builder/SKILL.md +0 -56
  310. package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
  311. package/skills/source-cited-humanizer/SKILL.md +0 -20
@@ -0,0 +1,151 @@
1
+ import { validatePublicUrl } from '../verifier/url.js';
2
+
3
+ // Decides what the crawler is allowed to queue, and in what order. Pure: no fetch, no
4
+ // persistence, no clock of its own. Persistence and leasing belong to the store; this
5
+ // module owns the judgement that keeps a crawl finite and on-topic.
6
+ export const FRONTIER_VERSION = 1;
7
+
8
+ export const FRONTIER_LIMITS = Object.freeze({
9
+ pathDepth: 12, // deeper than any editorial page we want; archives spiral past this
10
+ querySize: 4, // faceted navigation multiplies past this
11
+ repeatedSegment: 3, // /a/b/a/b/a/b is a cycle, not a hierarchy
12
+ urlLength: 2048,
13
+ hostPages: 500, // per host, per run
14
+ });
15
+
16
+ // Stripped before deduplication, never a reason to reject. These change nothing about the
17
+ // page served, so keeping them would fetch one page once per inbound campaign link.
18
+ const TRACKING_PARAMS = [/^utm_/i, /^(gclid|fbclid|msclkid|mc_[ce]id|igshid|ref|ref_src|source|_ga|yclid|dclid|s_kwcid|vero_id|oly_enc_id)$/i];
19
+ // Parameters whose whole purpose is to enumerate. Presence is a trap signal, not a strip.
20
+ const ENUMERATING_PARAMS = /^(sid|sessionid|jsessionid|phpsessid|calendar|cal_date|date|from_date|to_date|start_date|end_date|month|year|day|week|filter|facet|orderby|order_by|sort_by|price_min|price_max|replytocom)$/i;
21
+ // Endpoints that generate results rather than publish pages.
22
+ const GENERATED_PATHS = /(^|\/)(search|cart|checkout|login|signin|signup|register|logout|account|wp-admin|wp-login\.php|xmlrpc\.php|feed|rss|atom|print|amp|tag|author|comment-page-\d+|page\/\d{3,})(\/|$)/i;
23
+ // Files that are not pages we can extract editorial links from.
24
+ const NON_PAGE_EXTENSION = /\.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?|mp4|webm|mov|avi|mp3|wav|ogg|flac|pdf|zip|gz|tar|rar|7z|dmg|exe|msi|pkg|deb|rpm|css|js|mjs|json|xml|txt|csv|xlsx?|docx?|pptx?|woff2?|ttf|otf|eot)$/i;
25
+
26
+ export class FrontierRejection extends Error {
27
+ constructor(reason) { super(reason); this.name = 'FrontierRejection'; this.reason = reason; }
28
+ }
29
+
30
+ /**
31
+ * Canonical deduplication key. Two URLs sharing a key serve the same page for our purposes,
32
+ * so the frontier fetches one of them. Returns null when the URL is not crawlable at all.
33
+ */
34
+ export function frontierKey(rawUrl) {
35
+ const checked = validatePublicUrl(rawUrl);
36
+ if (!checked.valid) return null;
37
+ const url = new URL(checked.url);
38
+ // Sorting alone is not enough. Scrapy's canonicalize_url sorts query keys and keeps
39
+ // duplicates, so `?a=1&a=1` and `?a=1` fetch the same page twice; collapse identical pairs.
40
+ const seen = new Set();
41
+ const kept = [...url.searchParams.entries()]
42
+ .filter(([name]) => !TRACKING_PARAMS.some(pattern => pattern.test(name)))
43
+ .filter(([name, value]) => { const pair = `${name}=${value}`; if (seen.has(pair)) return false; seen.add(pair); return true; })
44
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
45
+ url.search = '';
46
+ for (const [name, value] of kept) url.searchParams.append(name, value);
47
+ // A trailing slash and an explicit directory index address the same document far more
48
+ // often than not, and treating them as two pages doubles every crawl.
49
+ url.pathname = url.pathname.replace(/\/index\.(html?|php|aspx?)$/i, '/').replace(/(.)\/$/, '$1');
50
+ return url.href;
51
+ }
52
+
53
+ /** Longest run of a repeated path segment, which marks a cycle rather than a hierarchy. */
54
+ function repeatedSegmentRun(segments) {
55
+ const seen = new Map();
56
+ let worst = 0;
57
+ for (const segment of segments) {
58
+ const count = (seen.get(segment) ?? 0) + 1;
59
+ seen.set(segment, count);
60
+ if (count > worst) worst = count;
61
+ }
62
+ return worst;
63
+ }
64
+
65
+ /**
66
+ * Decides whether a discovered URL may enter the frontier.
67
+ *
68
+ * @param rawUrl Absolute URL found on a crawled page.
69
+ * @param context.reason How it was found: seed, sitemap, outlink or refresh.
70
+ * @param context.seenKeys Keys already queued or crawled. A Set, read only.
71
+ * @param context.hostPages Pages already queued or crawled for this host, this run.
72
+ * @param context.allowedHosts When present, only these hosts (and their subdomains) admit.
73
+ * @param context.robotsAllows Callback (pathname) => boolean. Defaults to allowing.
74
+ * @returns {{admit: boolean, reason: string, key: string|null, hostname: string|null, priority: number}}
75
+ */
76
+ export function admitUrl(rawUrl, context = {}) {
77
+ const { reason = 'outlink', seenKeys, hostPages = 0, allowedHosts, robotsAllows } = context;
78
+ const reject = why => ({ admit: false, reason: why, key: null, hostname: null, priority: 0 });
79
+
80
+ if (typeof rawUrl !== 'string' || rawUrl.length > FRONTIER_LIMITS.urlLength) return reject('url_too_long');
81
+ const checked = validatePublicUrl(rawUrl);
82
+ if (!checked.valid) return reject(checked.reason);
83
+ const key = frontierKey(rawUrl);
84
+ if (!key) return reject('invalid_url');
85
+ const url = new URL(key), hostname = checked.hostname;
86
+
87
+ if (allowedHosts?.length && !allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`)))
88
+ return reject('host_not_in_scope');
89
+ if (seenKeys?.has(key)) return reject('already_seen');
90
+ // The budget is per host so one large publisher cannot consume a whole run.
91
+ if (hostPages >= FRONTIER_LIMITS.hostPages) return reject('host_budget_exhausted');
92
+ if (NON_PAGE_EXTENSION.test(url.pathname)) return reject('not_a_page');
93
+ if (GENERATED_PATHS.test(url.pathname)) return reject('generated_endpoint');
94
+
95
+ const segments = url.pathname.split('/').filter(Boolean);
96
+ if (segments.length > FRONTIER_LIMITS.pathDepth) return reject('path_too_deep');
97
+ if (repeatedSegmentRun(segments) >= FRONTIER_LIMITS.repeatedSegment) return reject('repeated_path_segment');
98
+
99
+ const params = [...url.searchParams.keys()];
100
+ if (params.length > FRONTIER_LIMITS.querySize) return reject('query_too_large');
101
+ if (params.some(name => ENUMERATING_PARAMS.test(name))) return reject('enumerating_parameter');
102
+ // robots is consulted last so a rejected URL is described by its own defect first.
103
+ if (robotsAllows && !robotsAllows(url.pathname + url.search)) return reject('robots_disallowed');
104
+
105
+ return { admit: true, reason, key, hostname, priority: priorityFor(reason, segments.length) };
106
+ }
107
+
108
+ /**
109
+ * Lower sorts first. Seeds and sitemaps outrank pages we merely stumbled onto, and a
110
+ * shallow page outranks a deep one, because editorial resource pages sit near the root.
111
+ */
112
+ export function priorityFor(reason, depth) {
113
+ const base = { seed: 0, sitemap: 100, refresh: 200, outlink: 300 }[reason] ?? 400;
114
+ return base + Math.min(depth, FRONTIER_LIMITS.pathDepth);
115
+ }
116
+
117
+ /**
118
+ * Selects the next batch to fetch: cheapest priority first, at most one URL per host per
119
+ * batch so a batch never stacks requests on one origin, and nothing whose host is still
120
+ * inside its crawl delay.
121
+ *
122
+ * @param entries [{ key, hostname, priority, nextEligibleAt }]
123
+ * @param now ISO timestamp.
124
+ * @param batchSize Maximum URLs to return.
125
+ */
126
+ export function selectBatch(entries, { now, batchSize = 10 } = {}) {
127
+ const at = Date.parse(now);
128
+ if (!Number.isFinite(at)) throw new FrontierRejection('invalid_clock');
129
+ const ready = entries
130
+ .filter(entry => !entry.nextEligibleAt || Date.parse(entry.nextEligibleAt) <= at)
131
+ .sort((a, b) => a.priority - b.priority || (a.key < b.key ? -1 : 1));
132
+ const hosts = new Set(), batch = [];
133
+ for (const entry of ready) {
134
+ if (hosts.has(entry.hostname)) continue;
135
+ hosts.add(entry.hostname);
136
+ batch.push(entry);
137
+ if (batch.length >= batchSize) break;
138
+ }
139
+ return batch;
140
+ }
141
+
142
+ /**
143
+ * When a host may next be fetched. A robots crawl delay is honoured as stated; otherwise a
144
+ * courtesy floor applies. Never returns a time earlier than the delay a publisher asked for.
145
+ */
146
+ export function nextEligibleAt(now, { crawlDelaySeconds = null, courtesySeconds = 5 } = {}) {
147
+ const at = Date.parse(now);
148
+ if (!Number.isFinite(at)) throw new FrontierRejection('invalid_clock');
149
+ const seconds = Math.max(courtesySeconds, crawlDelaySeconds ?? 0);
150
+ return new Date(at + seconds * 1000).toISOString();
151
+ }
File without changes
@@ -17,6 +17,7 @@ export const DISCOVERY_DATA_MODES = Object.freeze(['synthetic', 'owned_corpus',
17
17
  export const IMPORT_SUPPLIERS = Object.freeze([
18
18
  'ahrefs', 'google_search_console', 'bing_webmaster_tools', 'semrush', 'majestic', 'moz', 'dataforseo', 'linkody', 'csv',
19
19
  ]);
20
+ export const ImportTargetBasis = z.enum(['supplier_observed', 'user_supplied_target_expectation']);
20
21
 
21
22
  // A data mode belongs to exactly one source, except fixtures, which any source may model.
22
23
  const SOURCE_DATA_MODES = Object.freeze({ linktrail_corpus: 'owned_corpus', dataforseo: 'provider_index', imported: 'imported' });
@@ -92,12 +93,16 @@ export const DiscoveryCandidate = z.strictObject({
92
93
  provider_metrics: z.union([
93
94
  z.strictObject({ dataforseo: z.strictObject({
94
95
  rank: count.max(1000).nullable(), page_from_rank: count.max(1000).nullable(),
96
+ domain_from: z.string().min(1).max(253).optional(),
95
97
  domain_from_rank: count.max(1000).nullable(), backlink_spam_score: count.max(100).nullable(),
96
98
  rank_scale: z.literal('one_thousand'),
97
99
  }) }),
98
100
  z.strictObject({ imported: z.strictObject({
99
101
  supplier: z.enum(IMPORT_SUPPLIERS),
100
102
  supplier_row_id: nullableText(256),
103
+ // Caller-authored destination provenance stays separate from opaque supplier columns.
104
+ // Older imports omit it and keep their exact destination behavior.
105
+ target_basis: ImportTargetBasis.optional(),
101
106
  // What the EXPORT says about its own age, which is often nothing. `provider_retrieved_at`
102
107
  // on the row above is when WE read the file; these are different facts and conflating
103
108
  // them would let an import of a two-year-old export read as fresh data.
@@ -108,6 +113,9 @@ export const DiscoveryCandidate = z.strictObject({
108
113
  // the scale is the supplier's name itself.
109
114
  supplier_metrics: z.record(z.string().min(1).max(64), z.union([z.number(), z.string().max(256), z.null()]))
110
115
  .refine(value => Object.keys(value).length <= 32, 'At most 32 supplier metrics.').nullable(),
116
+ }).superRefine((imported, ctx) => {
117
+ if (imported.target_basis === 'user_supplied_target_expectation' && imported.supplier !== 'google_search_console')
118
+ ctx.addIssue({ code: 'custom', message: 'A target expectation is supported only for Google Search Console imports.', path: ['target_basis'] });
111
119
  }) }),
112
120
  z.strictObject({ linktrail_corpus: z.strictObject({
113
121
  // Real crawl facts. Outlink counts qualify a page; they are not an authority metric.
@@ -19,7 +19,7 @@
19
19
  // nothing here turns them into an AgentLinkOps score.
20
20
  import { z } from 'zod';
21
21
  import { validatePublicUrl, matchesTarget } from '../verifier/url.js';
22
- import { DISCOVERY_VERSION, DISCOVERY_LIMITS, IMPORT_SUPPLIERS, DiscoveryCandidate, candidateId, sourceQueryTerms } from './contract.js';
22
+ import { DISCOVERY_VERSION, DISCOVERY_LIMITS, IMPORT_SUPPLIERS, ImportTargetBasis, DiscoveryCandidate, candidateId, sourceQueryTerms } from './contract.js';
23
23
 
24
24
  export class ImportError extends Error {
25
25
  constructor(code, status = 400) { super(code); this.name = 'ImportError'; this.code = code; this.status = status; }
@@ -30,6 +30,7 @@ const fail = (code, status) => { throw new ImportError(code, status); };
30
30
  export const ImportRow = z.object({
31
31
  source_url: z.string().max(4096),
32
32
  target_url: z.string().max(4096),
33
+ target_basis: ImportTargetBasis.optional(),
33
34
  anchor: z.string().max(4096).nullish(),
34
35
  rel: z.union([z.string().max(1024), z.array(z.string().max(128)).max(32)]).nullish(),
35
36
  dofollow: z.boolean().nullish(),
@@ -71,6 +72,16 @@ export function normalizeRow(row, { runId, workspaceId, projectId, supplier, tas
71
72
  // A row about someone else's site is not a row about this import's target. Accepting it
72
73
  // would quietly widen the run's scope past what its coverage statement describes.
73
74
  if (!matchesTarget(destination.url, target, scope)) return { error: 'target_url: outside the scope of this import' };
75
+ if (value.target_basis === 'user_supplied_target_expectation') {
76
+ if (supplier !== 'google_search_console')
77
+ return { error: 'target_basis: a target expectation is supported only for Google Search Console imports' };
78
+ const expected = validatePublicUrl(target);
79
+ const siteScope = scope === 'domain' || scope === 'subdomain';
80
+ const rootExpectation = expected.valid && new URL(expected.url).pathname === '/' && !new URL(expected.url).search;
81
+ if (!expected.valid || expected.url !== target || (!siteScope && scope !== 'exact')
82
+ || (siteScope && !rootExpectation) || destination.url !== target)
83
+ return { error: 'target_basis: the target expectation must match the canonical import target' };
84
+ }
74
85
 
75
86
  const dates = {};
76
87
  for (const field of ['first_seen', 'last_seen']) {
@@ -101,6 +112,7 @@ export function normalizeRow(row, { runId, workspaceId, projectId, supplier, tas
101
112
  source_http_status: null, target_http_status: null, links_count: null,
102
113
  provider_metrics: { imported: {
103
114
  supplier, supplier_row_id: value.supplier_row_id ?? null,
115
+ ...(supplier === 'google_search_console' && value.target_basis !== undefined ? { target_basis: value.target_basis } : {}),
104
116
  supplier_generated_at: generatedAt, supplier_metrics: value.supplier_metrics ?? null,
105
117
  } },
106
118
  verification_status: 'not_checked', verified_at: null, observation_id: null, evidence_id: null,
File without changes
File without changes
package/src/locator.js CHANGED
File without changes
@@ -0,0 +1,193 @@
1
+ import {pageQualityReason} from './verifier/page-quality.js';
2
+ import { analyzeReadiness } from './verifier/readiness.js';
3
+ // Unlinked mentions: a page that names the customer and does not link to them (DP-0009-T03).
4
+ //
5
+ // The [scope rule](../docs/PRODUCT-DIRECTION-2026-09-10.md) admits anything that fits declare,
6
+ // verify, watch, diff, and names reclamation first. This fits exactly, and it is the cheapest new
7
+ // observation available because both hard parts already exist: the verifier parses a page into
8
+ // per-occurrence links with raw rel tokens, and the corpus holds real pages to test against.
9
+ //
10
+ // **Everything interesting here is a refusal.** Finding the string is trivial; the work is in the
11
+ // four cases where finding it means nothing.
12
+ import { extractPage, STATIC_TEXT_LIMIT } from './page-extraction/index.js';
13
+ import { matchesTarget, validatePublicUrl, hasRedirectWrapperToTarget } from './verifier/url.js';
14
+
15
+ export const MENTION_LIMITS = Object.freeze({
16
+ occurrences: 25,
17
+ contextChars: 200,
18
+ brands: 5,
19
+ textChars: STATIC_TEXT_LIMIT,
20
+ });
21
+
22
+ export class MentionError extends Error {
23
+ constructor(code) { super(code); this.name = 'MentionError'; this.code = code; }
24
+ }
25
+
26
+ /** Escapes a literal for use inside a regular expression. */
27
+ const literal = value => String(value).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
28
+
29
+ /**
30
+ * Where a needle appears in text, with bounded context and a character offset.
31
+ *
32
+ * Matching is word-bounded on both sides so `customer.com` does not match inside
33
+ * `notcustomer.community` — the same near-miss the URL layer refuses for links, applied to text.
34
+ */
35
+ export function findOccurrences(text, needle, { limit = MENTION_LIMITS.occurrences, contextChars = MENTION_LIMITS.contextChars } = {}) {
36
+ const found = [];
37
+ // Counted past the cap on purpose. `found.length` is how many we KEPT, and reporting that as
38
+ // the number of mentions would turn a storage limit into a measurement — a page naming a brand
39
+ // eighty times would read as naming it twenty-five, and nothing in the output would say so.
40
+ let total = 0;
41
+ // The lookahead refuses a following label too, so `customer.com.evil.com` is not
42
+ // `customer.com` — the same near-miss the URL layer refuses for links. A trailing full stop at
43
+ // the end of a sentence still matches, because `\.?` consumes it and a space follows.
44
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}@._-])${literal(needle)}(?!\\.?[\\p{L}\\p{N}-])`, 'giu');
45
+ for (const match of String(text).matchAll(pattern)) {
46
+ total += 1;
47
+ if (found.length >= limit) continue;
48
+ const start = Math.max(0, match.index - Math.floor(contextChars / 2));
49
+ found.push({
50
+ offset: match.index,
51
+ text: match[0],
52
+ context: String(text).slice(start, start + contextChars).trim(),
53
+ });
54
+ }
55
+ found.total = total;
56
+ found.truncated = total > found.length;
57
+ return found;
58
+ }
59
+
60
+ const hostOf = url => { try { return new URL(url).hostname.replace(/^www\./u, ''); } catch { return null; } };
61
+
62
+ /**
63
+ * Scans one fetched page for mentions of a target, and decides whether they are reclaimable.
64
+ *
65
+ * Takes HTML that has already been fetched — by the verifier, the crawler, or a caller holding a
66
+ * corpus page — because a mention scan should cost nothing when we already hold the bytes.
67
+ *
68
+ * @returns {{state: 'mentioned_not_linked'|'mentioned_and_linked'|'not_mentioned'|'unknown'}}
69
+ */
70
+ export function scanMentions(html, pageUrl, { targetUrl, targetScope = 'domain', brands = [], receipt = null, bodyTruncated = false, textLimit = MENTION_LIMITS.textChars } = {}) {
71
+ const target = validatePublicUrl(targetUrl);
72
+ if (!target.valid) throw new MentionError(`invalid_target:${target.reason}`);
73
+ const page = validatePublicUrl(pageUrl);
74
+ if (!page.valid) throw new MentionError(`invalid_page:${page.reason}`);
75
+
76
+ const result = {
77
+ v: 1, state: 'unknown', reason: 'not_scanned', page_url: page.url, target_url: target.url,
78
+ target_scope: targetScope, occurrences: [], occurrence_count: 0, links_to_target: 0, complete: false,
79
+ provenance: { kind: receipt === null ? 'caller_supplied_html' : 'capture_receipt', qualified: false },
80
+ coverage: { documentComplete: false, textComplete: false, bodyComplete: receipt === null ? null : false },
81
+ };
82
+
83
+ if (typeof html !== 'string') { result.reason = 'html_required'; return result; }
84
+ if (bodyTruncated === true) { result.reason = 'incomplete_body'; return result; }
85
+ let parsed, analysis;
86
+ try {
87
+ parsed = extractPage(html, { baseUrl: page.url, receipt, textLimit });
88
+ analysis = analyzeReadiness({ html, parsed, targetUrl: target.url });
89
+ }
90
+ catch (error) { result.reason = error?.reason ?? 'html_parse_error'; return result; }
91
+ result.coverage = parsed.coverage;
92
+ result.provenance.qualified = parsed.coverage.transportQualified;
93
+ if (receipt !== null) result.provenance.representation = parsed.provenance.receipt.representation;
94
+
95
+ // The verifier's "cannot conclude absence" rules carry over WHOLE, because the conclusion this
96
+ // tool draws is an absence: no link on this page. On a page we could not read completely, that
97
+ // is not a conclusion, and a reclamation tool that forgot it would generate outreach about
98
+ // links that are already there.
99
+ const qualityReason = pageQualityReason(parsed, analysis);
100
+ if (qualityReason) { result.reason = qualityReason; return result; }
101
+ // The complete link index alone cannot prove an absent mention when text hit its cap.
102
+ if (!parsed.coverage.textComplete) { result.reason = 'text_extraction_truncated'; return result; }
103
+
104
+ const linked = parsed.links.filter(link => matchesTarget(link.targetUrl, target.url, targetScope));
105
+ // A physical wrapper may already lead to this target. Complete captured bytes
106
+ // do not resolve that destination; never turn this hint into reclamation.
107
+ // Evaluate each target separately, and retain genuine direct-link precedence.
108
+ if (!linked.length && hasRedirectWrapperToTarget(parsed.links, target.url, targetScope)) {
109
+ result.reason = 'target_in_redirect_parameter';
110
+ return result;
111
+ }
112
+ result.complete = true;
113
+ result.links_to_target = linked.length;
114
+
115
+ // Reader-visible text only: a brand name inside a script, a comment or a JSON payload is not a
116
+ // mention anyone read.
117
+ const text = parsed.staticText.text;
118
+ const host = hostOf(target.url);
119
+ const needles = [];
120
+ if (host) needles.push({ kind: 'domain', value: host });
121
+ for (const brand of brands.slice(0, MENTION_LIMITS.brands)) {
122
+ const value = String(typeof brand === 'string' ? brand : brand?.value ?? '').trim();
123
+ const forced = typeof brand === 'object' && brand?.force === true;
124
+ if (!value) continue;
125
+ // **Length cannot tell a brand from an ordinary word.** "Settled" is seven characters,
126
+ // "Notion" and "Stripe" are six, and a customer called any of them cannot be matched on the
127
+ // word without a dictionary we do not have. So the rule is structural rather than a magic
128
+ // number: a SINGLE-TOKEN brand needs the customer to say explicitly that they mean it, and a
129
+ // multi-word one is distinctive enough on its own. The refusal is reported, so a customer
130
+ // whose brand produced nothing can see why.
131
+ if (!forced && !/\s/u.test(value)) {
132
+ result.rejected_brands = [...(result.rejected_brands ?? []),
133
+ { brand: value, reason: 'single_word_brand_needs_force', note: 'A one-word brand cannot be told apart from ordinary use. Pass { value, force: true } to match it anyway.' }];
134
+ continue;
135
+ }
136
+ needles.push({ kind: 'brand', value });
137
+ }
138
+ let total = 0;
139
+ for (const needle of needles) {
140
+ const found = findOccurrences(text, needle.value);
141
+ total += found.total;
142
+ for (const occurrence of found) result.occurrences.push({ kind: needle.kind, matched: needle.value, ...occurrence });
143
+ }
144
+ const kept = result.occurrences.slice(0, MENTION_LIMITS.occurrences);
145
+ // Three separate numbers, because they answer three different questions: how many times the
146
+ // page names the target, how many of those we kept context for, and whether anything was cut.
147
+ result.occurrence_count = total;
148
+ result.occurrences_kept = kept.length;
149
+ result.occurrences_truncated = total > kept.length;
150
+ result.occurrences = kept;
151
+
152
+ if (!total) { result.state = 'not_mentioned'; result.reason = 'no_mention_in_complete_page'; return result; }
153
+ // A mention that is already linked is not an opportunity, it is a placement — and telling a
154
+ // customer to go ask for a link they already have is the fastest way to lose a publisher.
155
+ if (linked.length) {
156
+ result.state = 'mentioned_and_linked';
157
+ result.reason = 'mention_and_link_present';
158
+ result.anchors = linked.slice(0, 5).map(link => ({ anchor: link.anchor, rel: link.rel, href: link.href }));
159
+ return result;
160
+ }
161
+ result.state = 'mentioned_not_linked';
162
+ result.reason = 'mention_without_link_in_complete_page';
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * Scans many already-fetched pages and reports the reclaimable ones.
168
+ *
169
+ * Deliberately takes pages rather than fetching: **a scan over bytes we already hold costs
170
+ * nothing and is not billed**, which is the pricing contract's fourth principle, and a corpus of
171
+ * two thousand pages is the obvious first place to point this.
172
+ */
173
+ export function scanPages(pages, { targetUrl, targetScope = 'domain', brands = [] } = {}) {
174
+ const results = [], counts = {};
175
+ for (const page of pages) {
176
+ let scanned;
177
+ try { scanned = scanMentions(page.html, page.url, { targetUrl, targetScope, brands,
178
+ receipt: page.receipt ?? page.capture ?? null, bodyTruncated: page.body_truncated === true || page.bodyTruncated === true }); }
179
+ catch (error) { scanned = { state: 'unknown', reason: error?.code ?? 'scan_failed', page_url: page.url }; }
180
+ counts[scanned.state] = (counts[scanned.state] ?? 0) + 1;
181
+ if (scanned.state !== 'not_mentioned') results.push(scanned);
182
+ }
183
+ const readable = (counts.mentioned_not_linked ?? 0) + (counts.mentioned_and_linked ?? 0) + (counts.not_mentioned ?? 0);
184
+ return {
185
+ v: 1, target_url: targetUrl, target_scope: targetScope,
186
+ pages_scanned: pages.length, counts,
187
+ // Stated because the denominator is the whole question: 12 reclaimable out of 20 readable is
188
+ // a different fact from 12 out of 2,000, and an unknown is neither a yes nor a no.
189
+ coverage: { readable, unreadable: pages.length - readable },
190
+ reclaimable: results.filter(row => row.state === 'mentioned_not_linked'),
191
+ already_linked: results.filter(row => row.state === 'mentioned_and_linked').length,
192
+ };
193
+ }
@@ -0,0 +1,50 @@
1
+ import {publicFetcher} from '../verifier/fetch.js';
2
+ import {ACQUISITION_LIMITS,CAPTURE_VERSION,PageAcquisitionError,sha256Bytes,decodedDigest,boundedAcquisitionLimit,validateCaptureReceipt} from './contracts.js';
3
+ import {validatePublicUrl} from '../verifier/url.js';
4
+ import {acquisitionBudget,runRecovery} from './recovery.js';
5
+ import {verifyRenderedLinks,unknownLinkObservation} from '../verifier/index.js';
6
+ import {unknownTargetObservation} from '../verifier/target.js';
7
+
8
+ const budget=acquisitionBudget;
9
+
10
+ /** A bounded capture primitive. Owner jobs retain storage, entitlement and verdict authority. */
11
+ export function createPageAcquisition({fetchImpl=globalThis.fetch,publicFetchSafe=false,beforeFetch,onRobots,robotsCache=null,clock=Date.now,userAgentContact='https://agentlinkops.com',transportId='workers_http',transportVersion='1'}={}){
12
+ return {async acquire(url,{signal,timeoutMs=ACQUISITION_LIMITS.timeoutMs,maxBytes=ACQUISITION_LIMITS.maxBytes,deadlineAt=null,document=false,validators=null}={}){
13
+ if(publicFetchSafe!==true)throw new PageAcquisitionError('public_fetch_not_configured');
14
+ const checked=validatePublicUrl(url);if(!checked.valid)throw new PageAcquisitionError(`unsafe_url:${checked.reason}`);
15
+ // Documents (sitemaps) may ask for up to the document bound; pages keep the page bound.
16
+ const byteCap=boundedAcquisitionLimit(maxBytes,ACQUISITION_LIMITS.maxBytes,document?ACQUISITION_LIMITS.documentBytes:ACQUISITION_LIMITS.maxBytes);
17
+ const owner=budget({signal,timeoutMs,deadlineAt,clock});
18
+ const trace={finalUrl:checked.url,httpStatus:null,redirects:[]};
19
+ try{
20
+ owner.assert();
21
+ const result=await publicFetcher({fetchImpl,publicFetchSafe,beforeFetch,onRobots,robotsCache,maxBytes:byteCap,userAgentContact},owner.signal).source(checked.url,trace,{document,validators});
22
+ owner.assert();
23
+ if(result.notModified||!result.body)return {...result,trace,capture:null};
24
+ const {body}=result;
25
+ const capture={version:CAPTURE_VERSION,representation:document?'document':'raw_html',requestedUrl:checked.url,finalUrl:trace.finalUrl,httpStatus:trace.httpStatus,
26
+ acquiredAt:new Date(clock()).toISOString(),body:{complete:true,truncated:false,bytes:body.bytes,sha256:await sha256Bytes(body.data??new Uint8Array(0)),decodedSha256:await decodedDigest(body.text),charset:body.charset??null},
27
+ transport:{id:transportId,version:transportVersion,qualified:true},coverage:{mainDocument:true,iframes:false,shadowRoots:false}};
28
+ owner.assert();
29
+ return {...result,trace,capture:validateCaptureReceipt(capture)};
30
+ }catch(error){error.acquisitionTrace=trace;throw error;}finally{owner.close();}
31
+ }};
32
+ }
33
+
34
+ /** One logical observation, with a shared absolute budget across direct and alternate routes. */
35
+ export async function verifyWithAcquisition(inputs,options={},{verifyGroup,alternate=null,proxy=null,authorize=async()=>true,recoveryCapabilities={},renderAdmission,proxyAdmission,clock=Date.now}={}){
36
+ const raw=settings=>({...settings,renderPage:undefined,compareRendered:false});
37
+ const transport=(provider,method)=>({capability:recoveryCapabilities[method]??{available:!!provider,reason:'disabled'},receipts:provider?.receipts,
38
+ execute:settings=>verifyGroup(inputs,{...raw(settings),fetchImpl:(url,requestOptions)=>provider.fetch(url,{...requestOptions,deadlineAt:settings.deadlineAt}),
39
+ captureTransport:{id:method,version:'alo-public-node-v1'}})});
40
+ return runRecovery({purpose:options.acquisitionPurpose??'customer_monitor',options,authorize,clock,
41
+ directFailure:reason=>inputs.map(input=>input.sourceUrl===undefined?unknownTargetObservation(input,{now:options.now,reason}):unknownLinkObservation(input,{now:options.now,reason})),
42
+ executeDirect:settings=>verifyGroup(inputs,raw(settings)),routes:{
43
+ node_http:transport(alternate,'node_http'),proxy_http:{...transport(proxy,'proxy_http'),admit:proxyAdmission},
44
+ node_browser:{capability:recoveryCapabilities.node_browser??{available:!!options.renderPage,reason:'disabled'},admit:renderAdmission,
45
+ receipts:options.renderPage?.receipts,execute:async(settings,previous,indices)=>{
46
+ const observed=await verifyRenderedLinks(indices.map(i=>inputs[i]),indices.map(i=>previous[i]),settings);
47
+ const next=[...previous];indices.forEach((index,i)=>{next[index]=observed[i];});return next;
48
+ }},
49
+ }});
50
+ }
@@ -0,0 +1,138 @@
1
+ /** Shared error identity, including callers of the legacy verifier/fetch.js facade. */
2
+ export class VerificationError extends Error {
3
+ constructor(reason, details = {}) {
4
+ super(reason);
5
+ this.name = 'VerificationError';
6
+ this.reason = reason;
7
+ Object.assign(this, details);
8
+ }
9
+ }
10
+
11
+ /** Cancellation is best effort and must not extend the owner's deadline. */
12
+ export function cancelBody(response) {
13
+ try { response.body?.cancel().catch(() => {}); } catch { /* Already locked/closed. */ }
14
+ }
15
+
16
+ /** Race every blocking phase against the same caller-owned signal. */
17
+ export function withAbort(promise, signal) {
18
+ if (!signal) return Promise.resolve(promise);
19
+ if (signal.aborted) return Promise.reject(new VerificationError('timeout'));
20
+ return new Promise((resolve, reject) => {
21
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
22
+ const onAbort = () => { cleanup(); reject(new VerificationError('timeout')); };
23
+ signal.addEventListener('abort', onAbort, { once: true });
24
+ Promise.resolve(promise).then(
25
+ value => { cleanup(); resolve(value); },
26
+ error => { cleanup(); reject(error); },
27
+ );
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Consume a body without decoding it. A transport may expose decoded response bytes
33
+ * even with Content-Encoding present, so that header disables wire-length equality.
34
+ * The returned bytes describe exactly the representation the reader consumed.
35
+ */
36
+ export async function readBoundedBytes(response, maxBytes, signal) {
37
+ const length = response.headers.get('content-length');
38
+ const encoded = response.headers.get('content-encoding');
39
+ if (!encoded && length && Number(length) > maxBytes) {
40
+ cancelBody(response);
41
+ throw new VerificationError('body_too_large');
42
+ }
43
+ const reader = response.body?.getReader();
44
+ if (!reader) return { data: new Uint8Array(0), bytes: 0 };
45
+ const chunks = [];
46
+ let bytes = 0;
47
+ try {
48
+ while (true) {
49
+ const { done, value } = await withAbort(reader.read(), signal);
50
+ if (done) break;
51
+ bytes += value.byteLength;
52
+ if (bytes > maxBytes) throw new VerificationError('body_too_large');
53
+ chunks.push(value);
54
+ }
55
+ if (!encoded && length && Number(length) !== bytes) throw new VerificationError('incomplete_body');
56
+ const data = new Uint8Array(bytes);
57
+ let offset = 0;
58
+ for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; }
59
+ return { data, bytes };
60
+ } catch (error) {
61
+ reader.cancel().catch(() => {});
62
+ throw error;
63
+ } finally {
64
+ reader.releaseLock();
65
+ }
66
+ }
67
+
68
+ /** Decode only a complete bounded representation; invalid input is never empty HTML. */
69
+ export function decodeBytes(data, charset = 'utf-8') {
70
+ try { return new TextDecoder(charset, { fatal: true }).decode(data); }
71
+ catch { throw new VerificationError('unsupported_or_invalid_encoding'); }
72
+ }
73
+
74
+ /** Existing verifier API, including its no-body return shape and charset behavior. */
75
+ export async function readBounded(response, maxBytes, signal) {
76
+ const body = await readBoundedBytes(response, maxBytes, signal);
77
+ if (!response.body) return { text: '', bytes: 0 };
78
+ const contentType = response.headers.get('content-type') ?? '';
79
+ const charset = /charset\s*=\s*["']?([^\s;"']+)/iu.exec(contentType)?.[1] ?? 'utf-8';
80
+ return { text: decodeBytes(body.data, charset), bytes: body.bytes, data: body.data };
81
+ }
82
+
83
+ /** A refusal may retain a small diagnostic prefix; it is never a complete page capture. */
84
+ export async function readBodyDiagnostic(response, signal, maxBytes = 64 * 1024) {
85
+ const reader=response.body?.getReader(),chunks=[];
86
+ const controller=new AbortController(),abort=()=>controller.abort();
87
+ signal?.addEventListener('abort',abort,{once:true});
88
+ if(signal?.aborted)abort();
89
+ // Diagnostics must not consume the recovery window or wait for a publisher
90
+ // to finish an error stream. The caller's earlier deadline still wins.
91
+ const timer=setTimeout(abort,250);
92
+ let bytes=0,complete=!reader,truncated=false;
93
+ try {
94
+ if(reader)while(true){
95
+ const {done,value}=await withAbort(reader.read(),controller.signal);
96
+ if(done){complete=true;break;}
97
+ const keep=Math.min(value.byteLength,maxBytes-bytes);
98
+ if(keep){chunks.push(value.slice(0,keep));bytes+=keep;}
99
+ if(keep<value.byteLength||bytes===maxBytes){truncated=true;reader.cancel().catch(()=>{});break;}
100
+ }
101
+ }catch(error){
102
+ reader?.cancel().catch(()=>{});
103
+ if(signal?.aborted||!controller.signal.aborted)throw error;
104
+ truncated=true;
105
+ }finally{clearTimeout(timer);signal?.removeEventListener('abort',abort);reader?.releaseLock();}
106
+ const data=new Uint8Array(bytes);let offset=0;
107
+ for(const chunk of chunks){data.set(chunk,offset);offset+=chunk.byteLength;}
108
+ const length=response.headers.get('content-length');
109
+ if(!response.headers.get('content-encoding')&&length&&Number(length)!==bytes)complete=false;
110
+ const sha256=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',data)),b=>b.toString(16).padStart(2,'0')).join('');
111
+ const text=new TextDecoder('utf-8').decode(data);
112
+ // Saved diagnostics carry facts and a digest, never publisher/error text or cookies.
113
+ return {version:1,bytes,complete,truncated,sha256,
114
+ looksLikeHtml:/<(?:!doctype\s+html|html|body|head)\b/iu.test(text)};
115
+ }
116
+
117
+ /**
118
+ * Inflate incrementally and apply the cap to output chunks before retaining them.
119
+ * Unlike synchronous gunzip, this never materializes an unbounded expanded document.
120
+ * Web streams keep the implementation usable in both Workers and Node runtimes.
121
+ */
122
+ export async function inflateGzipBounded(data, maxBytes, signal) {
123
+ if (signal?.aborted) throw new VerificationError('timeout');
124
+ // Feed small slices: a transform must not be handed an entire compressed bomb in
125
+ // one write before its output consumer has an opportunity to enforce the bound.
126
+ let offset = 0;
127
+ const compressed = new ReadableStream({
128
+ pull(controller) {
129
+ if (signal?.aborted) { controller.error(new VerificationError('timeout')); return; }
130
+ if (offset >= data.length) { controller.close(); return; }
131
+ const next = Math.min(offset + 1024, data.length);
132
+ controller.enqueue(data.subarray(offset, next));
133
+ offset = next;
134
+ },
135
+ });
136
+ const expanded = compressed.pipeThrough(new DecompressionStream('gzip'));
137
+ return readBoundedBytes(new Response(expanded), maxBytes, signal);
138
+ }
@@ -0,0 +1,11 @@
1
+ import {CAPTURE_VERSION} from './contracts.js';
2
+
3
+ /** Constructed only after the bounded reader has finished and both digests exist. */
4
+ export function capturedDocument(result,{requestedUrl,representation='raw_html',transportId='public_http',transportVersion='1',qualified=false,charset=null}={}){
5
+ const evidence=result.evidence;
6
+ if(!evidence||!Number.isSafeInteger(evidence.bytes)||!evidence.sha256||!evidence.decodedSha256)return null;
7
+ return {version:CAPTURE_VERSION,representation,requestedUrl,finalUrl:result.finalUrl,httpStatus:result.httpStatus,
8
+ acquiredAt:evidence.fetchedAt??result.checkedAt,
9
+ body:{complete:true,truncated:false,bytes:evidence.bytes,sha256:evidence.sha256,decodedSha256:evidence.decodedSha256,charset},
10
+ transport:{id:transportId,version:transportVersion,qualified},coverage:{mainDocument:true,iframes:false,shadowRoots:false}};
11
+ }