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,67 @@
1
+ import {validatePublicUrl} from '../verifier/url.js';
2
+ import {recoveryEligibility} from './policy.js';
3
+
4
+ export const CAPTURE_VERSION = 1;
5
+ export const ACQUISITION_LIMITS = Object.freeze({maxBytes:2*1024*1024,documentBytes:10*1024*1024,robotsBytes:512*1024,maxRedirects:5,timeoutMs:20_000,maxAttempts:4});
6
+ export const PURPOSE_PROFILES = Object.freeze({
7
+ placement:Object.freeze({representation:'raw_html',allowAlternate:true,allowRender:true}),
8
+ target:Object.freeze({representation:'raw_html',allowAlternate:true,allowRender:false}),
9
+ research:Object.freeze({representation:'raw_html',allowAlternate:false,allowRender:false}),
10
+ document:Object.freeze({representation:'document',allowAlternate:false,allowRender:false}),
11
+ free:Object.freeze({representation:'raw_html',allowAlternate:false,allowRender:false}),
12
+ });
13
+ export class PageAcquisitionError extends Error {
14
+ constructor(reason){super(reason);this.name='PageAcquisitionError';this.reason=reason;}
15
+ }
16
+ const fail=reason=>{throw new PageAcquisitionError(reason);};
17
+ const hashPattern=/^[a-f0-9]{64}$/u;
18
+ export function boundedAcquisitionLimit(value,fallback,cap){
19
+ if(value===undefined||value===null)return fallback;
20
+ if(typeof value!=='number'||!Number.isFinite(value)||value<1)fail('acquisition_limits_invalid');
21
+ return Math.min(Math.floor(value),cap);
22
+ }
23
+ const pin=value=>typeof value==='string'&&value.length>0&&value.length<=128&&value.trim()===value&&!/[\x00-\x20\x7f]/u.test(value);
24
+ const captureByteLimit=representation=>representation==='document'?ACQUISITION_LIMITS.documentBytes:ACQUISITION_LIMITS.maxBytes;
25
+ export async function sha256Bytes(bytes){return [...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');}
26
+ export async function decodedDigest(html){return sha256Bytes(new TextEncoder().encode(html));}
27
+
28
+ export function validateCaptureReceipt(receipt,{finalUrl,allowUnqualified=false}={}){
29
+ if(!receipt||receipt.version!==CAPTURE_VERSION||!['raw_html','rendered_dom','document'].includes(receipt.representation)
30
+ ||!validatePublicUrl(receipt.requestedUrl).valid||!validatePublicUrl(receipt.finalUrl).valid
31
+ ||!Number.isInteger(receipt.httpStatus)||receipt.httpStatus<100||receipt.httpStatus>599
32
+ ||typeof receipt.acquiredAt!=='string'||!Number.isFinite(Date.parse(receipt.acquiredAt))
33
+ ||!receipt.body||typeof receipt.body.complete!=='boolean'||typeof receipt.body.truncated!=='boolean'
34
+ ||!Number.isSafeInteger(receipt.body.bytes)||receipt.body.bytes<0||receipt.body.bytes>captureByteLimit(receipt.representation)||!hashPattern.test(receipt.body.sha256??'')
35
+ ||!hashPattern.test(receipt.body.decodedSha256??'')||!receipt.transport||!pin(receipt.transport.id)
36
+ ||!pin(receipt.transport.version)||typeof receipt.transport.qualified!=='boolean'
37
+ ||(receipt.body.charset!==undefined&&receipt.body.charset!==null&&(typeof receipt.body.charset!=='string'||receipt.body.charset.length>64))
38
+ ||!receipt.coverage||receipt.coverage.mainDocument!==true||typeof receipt.coverage.iframes!=='boolean'
39
+ ||typeof receipt.coverage.shadowRoots!=='boolean')fail('capture_invalid');
40
+ if(finalUrl&&finalUrl!==receipt.finalUrl)fail('capture_source_mismatch');
41
+ if(!receipt.body.complete||receipt.body.truncated)fail('capture_incomplete');
42
+ if(!allowUnqualified&&!receipt.transport.qualified)fail('capture_unqualified');
43
+ if(receipt.body.charset!=null){try{new TextDecoder(receipt.body.charset,{fatal:true});}catch{fail('capture_invalid_charset');}}
44
+ return structuredClone(receipt);
45
+ }
46
+ export async function validateDecodedCapture(receipt,html){
47
+ const valid=validateCaptureReceipt(receipt);
48
+ if(typeof html!=='string')fail('capture_digest_mismatch');
49
+ const limit=captureByteLimit(valid.representation);
50
+ // Supported legacy decoders cannot produce more UTF-16 units than consumed bytes;
51
+ // their UTF-8 re-encoding can use up to three bytes per unit (e.g. Windows-1252).
52
+ if(html.length>limit)fail('capture_decoded_too_large');
53
+ const bytes=new TextEncoder().encode(html);
54
+ if(bytes.byteLength>limit*3)fail('capture_decoded_too_large');
55
+ if(await sha256Bytes(bytes)!==valid.body.decodedSha256)fail('capture_digest_mismatch');
56
+ return valid;
57
+ }
58
+
59
+ export function acquisitionRouteEligible(results){
60
+ return Array.isArray(results)&&results.length>0&&results.every(r=>recoveryEligibility(r,'node_http').allowed);
61
+ }
62
+
63
+ export function captureCacheKey({workspaceId,projectId,url,representation='raw_html',profileVersion='1',transportVersion,locale='en-US',viewport='none'}){
64
+ if(![workspaceId,projectId,transportVersion].every(x=>typeof x==='string'&&x.length>0)||!validatePublicUrl(url).valid)fail('capture_cache_scope_invalid');
65
+ // Tenant scope and representation are inseparable from artifact identity.
66
+ return JSON.stringify([workspaceId,projectId,url,representation,profileVersion,transportVersion,locale,viewport]);
67
+ }
@@ -0,0 +1,166 @@
1
+ import { validatePublicUrl } from '../verifier/url.js';
2
+ import { PRODUCT_TOKEN } from './http.js';
3
+ import { VerificationError, cancelBody, withAbort, readBoundedBytes, decodeBytes, inflateGzipBounded } from './bytes.js';
4
+
5
+ const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
6
+ const MAX_ROBOTS_BYTES = 512 * 1024;
7
+ const MAX_TIMEOUT_MS = 20_000;
8
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
9
+
10
+ function bounded(value, fallback, cap, minimum = 0) {
11
+ return Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), minimum), cap) : fallback;
12
+ }
13
+
14
+ function abortableWait(ms, { signal } = {}) {
15
+ return new Promise((resolve, reject) => {
16
+ if (signal?.aborted) { reject(new VerificationError('timeout')); return; }
17
+ const onAbort = () => { clearTimeout(timer); reject(new VerificationError('timeout')); };
18
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
19
+ signal?.addEventListener('abort', onAbort, { once: true });
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Bounded bootstrap documents: robots.txt must not recursively authorize itself.
25
+ * The caller owns transport egress qualification; lexical URL checks are not DNS proof.
26
+ * Fetching an XML document here does not enroll a crawl or establish link presence.
27
+ *
28
+ * One absolute budget covers pacing, every redirect, headers, body and inflation.
29
+ * `signal` and `deadlineAt` can shorten that budget, never extend it.
30
+ */
31
+ export function createDocumentTransport({
32
+ fetchImpl = globalThis.fetch, timeoutMs = MAX_TIMEOUT_MS, maxBytes = MAX_DOCUMENT_BYTES,
33
+ maxRedirects = 5, hostDelayMs = 2_000, now = () => Date.now(), wait = abortableWait,
34
+ remainingBytes = null, retainKnownAbsentRobots = false,
35
+ } = {}) {
36
+ const byteCap = bounded(maxBytes, MAX_DOCUMENT_BYTES, MAX_DOCUMENT_BYTES, 1);
37
+ const timeout = bounded(timeoutMs, MAX_TIMEOUT_MS, MAX_TIMEOUT_MS, 1);
38
+ const redirectCap = bounded(maxRedirects, 5, 5);
39
+ const nextAllowed = new Map();
40
+
41
+ async function pace(host, signal) {
42
+ const delay = (nextAllowed.get(host) ?? 0) - now();
43
+ if (delay > 0) await withAbort(Promise.resolve().then(() => wait(delay, { signal })), signal);
44
+ if (signal.aborted) throw new VerificationError('timeout');
45
+ nextAllowed.set(host, now() + Math.max(0, hostDelayMs));
46
+ }
47
+
48
+ async function fetchText(rawUrl, {
49
+ accept = 'text/plain,application/xml,text/xml', signal = null, deadlineAt = null,
50
+ purpose = 'document',
51
+ } = {}) {
52
+ const checked = validatePublicUrl(rawUrl);
53
+ if (!checked.valid) return { ok: false, url: rawUrl, reason: `unsafe_url:${checked.reason}` };
54
+ let url = checked.url;
55
+ const controller = new AbortController();
56
+ const ownerAbort = () => controller.abort();
57
+ signal?.addEventListener('abort', ownerAbort, { once: true });
58
+ if (signal?.aborted) controller.abort();
59
+ const remaining = Math.min(timeout, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeout);
60
+ const absoluteDeadline = Date.now() + remaining;
61
+ if (remaining <= 0) controller.abort();
62
+ const timer = setTimeout(() => controller.abort(), Math.max(0, remaining));
63
+ const limit = purpose === 'robots' ? Math.min(byteCap, MAX_ROBOTS_BYTES) : byteCap;
64
+ const seen = new Set();
65
+ let status;
66
+ let phase = 'request';
67
+ try {
68
+ for (let redirects = 0; redirects <= redirectCap; redirects++) {
69
+ status = undefined;
70
+ if (controller.signal.aborted) throw new VerificationError('timeout');
71
+ if (seen.has(url)) return { ok: false, url, reason: 'redirect_loop' };
72
+ seen.add(url);
73
+ phase = 'request';
74
+ await pace(new URL(url).hostname, controller.signal);
75
+ const pending = Promise.resolve().then(() => {
76
+ if (controller.signal.aborted) throw new VerificationError('timeout');
77
+ return fetchImpl(url, { method: 'GET', redirect: 'manual', credentials: 'omit',
78
+ signal: controller.signal, headers: { 'User-Agent': `${PRODUCT_TOKEN}/0.1`, Accept: accept } });
79
+ });
80
+ // A custom transport may ignore abort. Dispose of its late body as soon as it appears.
81
+ pending.then(response => { if (controller.signal.aborted) cancelBody(response); }, () => {});
82
+ const response = await withAbort(pending, controller.signal);
83
+ status = response.status;
84
+ let followed;
85
+ try { followed = response.redirected || (response.url && new URL(response.url).href !== url); }
86
+ catch { followed = true; }
87
+ if (followed) {
88
+ cancelBody(response);
89
+ return { ok: false, url, status, reason: 'transport_followed_redirect' };
90
+ }
91
+ if (response.headers.get('cf-mitigated') === 'challenge') {
92
+ cancelBody(response);
93
+ return { ok: false, url, status, reason: 'access_challenge' };
94
+ }
95
+ if (REDIRECTS.has(status)) {
96
+ cancelBody(response);
97
+ const location = response.headers.get('location');
98
+ let next;
99
+ try { next = location ? validatePublicUrl(new URL(location, url).href) : null; }
100
+ catch { next = null; }
101
+ if (!next?.valid) return { ok: false, url, reason: 'unsafe_or_missing_redirect' };
102
+ url = next.url;
103
+ continue;
104
+ }
105
+ if (status === 404 || status === 410) {
106
+ // Private durable discovery may retain truthful robots404 evidence on
107
+ // this SAME response. Ordinary callers keep their exact old result.
108
+ if (retainKnownAbsentRobots === true && purpose === 'robots' && status === 404) {
109
+ phase = 'body';
110
+ const remaining = remainingBytes ? remainingBytes() : limit;
111
+ const readLimit = Number.isFinite(remaining) ? Math.min(limit, Math.max(0, Math.floor(remaining))) : 0;
112
+ if (!readLimit) { cancelBody(response); throw new VerificationError('body_too_large'); }
113
+ const body = await readBoundedBytes(response, readLimit, controller.signal);
114
+ let data = body.data;
115
+ const gzipped = data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b;
116
+ if (gzipped) { phase = 'inflate'; data = (await inflateGzipBounded(data, readLimit, controller.signal)).data; }
117
+ phase = 'decode'; const text = decodeBytes(data);
118
+ if (controller.signal.aborted || Date.now() >= absoluteDeadline) throw new VerificationError('timeout');
119
+ return { ok: false, url, final_url: url, status, reason: 'not_found', text, bytes: body.bytes, gzipped };
120
+ }
121
+ cancelBody(response);
122
+ return { ok: false, url, status, reason: 'not_found' };
123
+ }
124
+ if (!response.ok) {
125
+ cancelBody(response);
126
+ return { ok: false, url, status, reason: `http_${status}` };
127
+ }
128
+ phase = 'body';
129
+ // Authorization hooks may read robots for a redirected origin. Take the
130
+ // shared remaining allowance after those hooks, immediately before body use.
131
+ const remaining = remainingBytes ? remainingBytes() : limit;
132
+ const readLimit = Number.isFinite(remaining) ? Math.min(limit, Math.max(0, Math.floor(remaining))) : 0;
133
+ if (!readLimit) { cancelBody(response); throw new VerificationError('body_too_large'); }
134
+ const body = await readBoundedBytes(response, readLimit, controller.signal);
135
+ let data = body.data;
136
+ const gzipped = data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b;
137
+ if (gzipped) {
138
+ phase = 'inflate';
139
+ data = (await inflateGzipBounded(data, readLimit, controller.signal)).data;
140
+ }
141
+ if (controller.signal.aborted) throw new VerificationError('timeout');
142
+ phase = 'decode';
143
+ const text = decodeBytes(data);
144
+ if (controller.signal.aborted || Date.now() >= absoluteDeadline) throw new VerificationError('timeout');
145
+ if (purpose === 'robots' && (/text\/html/iu.test(response.headers.get('content-type') ?? '')
146
+ || /<(?:!doctype|html|body)\b/iu.test(text))) {
147
+ return { ok: false, url, status, reason: 'robots_not_plain_text' };
148
+ }
149
+ return { ok: true, url, final_url: url, status, text, bytes: body.bytes, gzipped };
150
+ }
151
+ return { ok: false, url, reason: 'redirect_limit' };
152
+ } catch (error) {
153
+ let reason = error?.reason ?? (phase === 'request' ? 'timeout_or_network_error' : 'read_failed');
154
+ if (phase === 'inflate' && reason !== 'timeout') {
155
+ reason = reason === 'body_too_large' ? 'sitemap_too_large' : 'sitemap_gunzip_failed';
156
+ }
157
+ return { ok: false, url, ...(status === undefined ? {} : { status }), reason };
158
+ } finally {
159
+ clearTimeout(timer);
160
+ signal?.removeEventListener('abort', ownerAbort);
161
+ controller.abort();
162
+ }
163
+ }
164
+
165
+ return { fetchText };
166
+ }
@@ -0,0 +1,307 @@
1
+ import { validatePublicUrl } from '../verifier/url.js';
2
+ import { robotsDecision } from '../verifier/robots.js';
3
+ import { VerificationError, readBounded, readBodyDiagnostic, withAbort, cancelBody } from './bytes.js';
4
+
5
+ export { VerificationError, readBounded } from './bytes.js';
6
+
7
+ /** One shared budget for robots, redirects, hooks and source-body reads. */
8
+ export const PRODUCT_TOKEN = 'LinktrailBot';
9
+
10
+ // A day. Anything longer is a publisher saying "not today", and a scheduler that honoured a
11
+ // month-long Retry-After would silently retire the check instead of retrying it.
12
+ export const MAX_RETRY_AFTER_SECONDS = 86_400;
13
+
14
+ /**
15
+ * RFC 9110 `Retry-After`: delta-seconds or an HTTP-date. Returns null for anything else.
16
+ *
17
+ * Both forms appear in the wild and a parser that reads only the integer form silently discards
18
+ * the date form, which is the one a CDN tends to send.
19
+ */
20
+ export function retryAfterSeconds(value, now = Date.now()) {
21
+ if (!value) return null;
22
+ const text = String(value).trim();
23
+ if (/^\d+$/u.test(text)) {
24
+ const seconds = Number(text);
25
+ return Number.isFinite(seconds) ? Math.min(seconds, MAX_RETRY_AFTER_SECONDS) : null;
26
+ }
27
+ // `Date.parse` accepts far more than an HTTP-date — it reads "-5" as a year and returns a real
28
+ // timestamp, which turned a malformed header into a wait of zero. An IMF-fixdate always carries
29
+ // a month and day name, so requiring a letter is enough to keep the loose parser off junk.
30
+ if (!/[A-Za-z]/u.test(text)) return null;
31
+ const at = Date.parse(text);
32
+ if (!Number.isFinite(at)) return null;
33
+ // A date already in the past means "now", never a negative wait.
34
+ return Math.min(Math.max(0, Math.ceil((at - now) / 1000)), MAX_RETRY_AFTER_SECONDS);
35
+ }
36
+
37
+ export function publicFetcher(options, signal) {
38
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
39
+ // A caller may share one robots record per origin across consecutive captures
40
+ // (a bounded scan step); by default every fetcher reads robots itself.
41
+ const robotsByOrigin = options.robotsCache instanceof Map ? options.robotsCache : new Map();
42
+ // The product token is what robots matches on and never changes. A contact URL may be appended
43
+ // so a publisher who sees us in their logs can find out who we are — the convention every
44
+ // reputable crawler follows, and the thing most likely to move a reflexive 403 or 429.
45
+ //
46
+ // It defaults to ABSENT on purpose. It was written before AgentLinkOps had a public domain, and a bot
47
+ // advertising a URL that does not resolve is worse than one advertising nothing: it reads as
48
+ // impersonation rather than identification. Set it when a real domain exists.
49
+ const userAgent = options.userAgentContact
50
+ ? `${PRODUCT_TOKEN}/0.1 (+${String(options.userAgentContact).slice(0, 200)})`
51
+ : `${PRODUCT_TOKEN}/0.1`;
52
+ const maxRedirects = Math.min(options.maxRedirects ?? 5, 5);
53
+ const maxBytes = Math.min(options.maxBytes ?? 2 * 1024 * 1024, 2 * 1024 * 1024);
54
+ // Feeds and sitemaps may ask for up to 10 MiB; pages stay at the page bound.
55
+ const documentMaxBytes = Math.min(options.maxBytes ?? 2 * 1024 * 1024, 10 * 1024 * 1024);
56
+ let monitorOverrideConsumed = false;
57
+
58
+ async function request(url, context = {}) {
59
+ const checked = validatePublicUrl(url);
60
+ if (!checked.valid) throw new VerificationError(`unsafe_url:${checked.reason}`);
61
+ if (options.beforeFetch) await withAbort(Promise.resolve().then(() => options.beforeFetch(checked.url, { ...context, signal })), signal);
62
+ let response;
63
+ try {
64
+ const pending = Promise.resolve().then(() => {
65
+ if (signal.aborted) throw new VerificationError('timeout');
66
+ // Conditional headers ride only on the request that earned them. A validator belongs
67
+ // to one resource; across a redirect hop it would be re-sent to a different one, so
68
+ // the caller drops validators after the first hop and the redirect loop revalidates
69
+ // the destination from scratch rather than trusting a moved resource's old validators.
70
+ const conditional = {};
71
+ if (context.kind === 'source' && context.validators?.etag) conditional['If-None-Match'] = context.validators.etag;
72
+ if (context.kind === 'source' && context.validators?.lastModified) conditional['If-Modified-Since'] = context.validators.lastModified;
73
+ return fetchImpl(checked.url, {
74
+ method: 'GET',
75
+ redirect: 'manual',
76
+ credentials: 'omit',
77
+ signal,
78
+ headers: {
79
+ 'User-Agent': userAgent,
80
+ Accept: context.kind === 'robots' ? 'text/plain' : context.accept ?? 'text/html',
81
+ ...conditional,
82
+ },
83
+ });
84
+ });
85
+ // An injected transport may ignore cancellation. Its eventual body still
86
+ // belongs to this aborted attempt and must never remain open or be read.
87
+ pending.then(late => { if (signal.aborted) cancelBody(late); }, () => {});
88
+ response = await withAbort(pending, signal);
89
+ } catch (error) {
90
+ if (error instanceof VerificationError) throw error;
91
+ throw new VerificationError(signal.aborted ? 'timeout' : 'network_error');
92
+ }
93
+ // Custom transports must never silently follow redirects or bypass destination checks.
94
+ if (response.redirected || (response.url && new URL(response.url).href !== checked.url)) {
95
+ cancelBody(response);
96
+ throw new VerificationError('transport_followed_redirect');
97
+ }
98
+ return response;
99
+ }
100
+
101
+ async function fetchRobots(origin, diagnostic) {
102
+ let url = `${origin}/robots.txt`;
103
+ const seen = new Set();
104
+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
105
+ diagnostic.robotsUrl = url;
106
+ diagnostic.httpStatus = null;
107
+ if (seen.has(url)) throw new VerificationError('robots_redirect_loop');
108
+ seen.add(url);
109
+ const response = await request(url, { kind: 'robots' });
110
+ diagnostic.httpStatus = response.status;
111
+ // A challenge replaces the requested resource, even when its status would
112
+ // otherwise mean missing robots or a redirect. Never infer policy from it.
113
+ if (response.headers.get('cf-mitigated') === 'challenge') {
114
+ cancelBody(response);
115
+ const retry = retryAfterSeconds(response.headers.get('retry-after'));
116
+ if (retry !== null) diagnostic.retryAfterSeconds = retry;
117
+ throw new VerificationError('robots_access_challenge', retry === null ? {} : { retryAfterSeconds: retry });
118
+ }
119
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
120
+ cancelBody(response);
121
+ const location = response.headers.get('location');
122
+ if (!location) throw new VerificationError('robots_redirect_missing_location');
123
+ const next = validatePublicUrl(new URL(location, url).href);
124
+ if (!next.valid) throw new VerificationError(`unsafe_robots_redirect:${next.reason}`);
125
+ if (diagnostic.redirects.length < 5) diagnostic.redirects.push({ from: url, to: next.url, status: response.status });
126
+ url = next.url;
127
+ continue;
128
+ }
129
+ // RFC 9309 §2.3.1.3 and Google's implementation agree: a 4xx on robots.txt means the file is
130
+ // UNAVAILABLE, and an unavailable robots.txt imposes no restrictions. Only 404 and 410 were
131
+ // honoured that way here, so a 401 or 403 — a WAF answering before the file is even looked
132
+ // for — was read as "this publisher forbids us" and refused the whole fetch. Measured on 800
133
+ // real destinations, that was 39 refusals, about 5% of the run, attributed to publishers who
134
+ // had said nothing.
135
+ //
136
+ // Proceeding is also BETTER REPORTING even when the page then fails: `target_http_403` says
137
+ // the publisher refused the page, where `robots_http_403` said we never asked.
138
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
139
+ diagnostic.bodyDiagnostic = await readBodyDiagnostic(response, signal);
140
+ return { text: '', reason: response.status === 404 || response.status === 410 ? 'robots_not_found' : `robots_unavailable_${response.status}` };
141
+ }
142
+ // 429 and 5xx are the other half of the same rule and stay a refusal: they mean TEMPORARILY
143
+ // unavailable, and the spec's answer to that is to stay away rather than to assume consent.
144
+ if (!response.ok) {
145
+ diagnostic.bodyDiagnostic = await readBodyDiagnostic(response, signal);
146
+ const retry = retryAfterSeconds(response.headers.get('retry-after'));
147
+ if (retry !== null) diagnostic.retryAfterSeconds = retry;
148
+ throw new VerificationError(`robots_http_${response.status}`, retry === null ? {} : { retryAfterSeconds: retry });
149
+ }
150
+ const body = await readBounded(response, 512 * 1024, signal);
151
+ if (/text\/html/iu.test(response.headers.get('content-type') ?? '') || /<(?:!doctype|html|body)\b/iu.test(body.text)) {
152
+ diagnostic.bodyDiagnostic = await readBodyDiagnostic(new Response(body.data,{headers:{'content-length':String(body.bytes)}}),signal);
153
+ throw new VerificationError('robots_not_plain_text');
154
+ }
155
+ return { text: body.text, reason: 'robots_fetched' };
156
+ }
157
+ throw new VerificationError('robots_redirect_limit');
158
+ }
159
+
160
+ async function checkRobots(url, trace, permitMonitorPolicy = false) {
161
+ const origin = new URL(url).origin;
162
+ const diagnostic = { sourceUrl: url, robotsUrl: `${origin}/robots.txt`, httpStatus: null,
163
+ productToken: PRODUCT_TOKEN, allowed: null, reason: null, matchedRule: null,
164
+ crawlDelaySeconds: null, fetched: null, redirects: [],
165
+ googlebot: { productToken: 'Googlebot', allowed: null, reason: null, matchedRule: null, crawlDelaySeconds: null } };
166
+ // Write before fetching/evaluating so refusals keep the failing hop, not the
167
+ // last allowed origin. Both histories are capped by our redirect budget.
168
+ trace.robots = diagnostic;
169
+ trace.robotsHistory ??= [];
170
+ if (trace.robotsHistory.length < 6) trace.robotsHistory.push(diagnostic);
171
+ try {
172
+ if (!robotsByOrigin.has(origin)) {
173
+ const record = await fetchRobots(origin, diagnostic);
174
+ robotsByOrigin.set(origin, { ...record, diagnostic: structuredClone(diagnostic) });
175
+ }
176
+ const record = robotsByOrigin.get(origin);
177
+ Object.assign(diagnostic, structuredClone(record.diagnostic), { sourceUrl: url });
178
+ const decision = robotsDecision(record.text, url);
179
+ // Google crawl permission is a separate publisher fact, never inferred from
180
+ // our crawler's permission or from this check's monitoring authorization.
181
+ diagnostic.googlebot = { productToken: 'Googlebot', matchedRule: null, crawlDelaySeconds: null,
182
+ ...robotsDecision(record.text, url, 'Googlebot') };
183
+ Object.assign(diagnostic, decision, { decisionReason: decision.reason, fetched: record.reason });
184
+ const monitorPolicy = permitMonitorPolicy && options.robotsPolicy === 'customer_supplied_monitor';
185
+ const override = monitorPolicy && !monitorOverrideConsumed && decision.allowed === false && decision.reason === 'robots_disallowed';
186
+ if (monitorPolicy) Object.assign(diagnostic, { policy: 'customer_supplied_monitor',
187
+ fetchAuthorized: decision.allowed === true || override, overrideUsed: override });
188
+ options.onRobots?.(url, decision);
189
+ if (decision.allowed !== true && !override) throw new VerificationError(decision.reason);
190
+ if (decision.crawlDelaySeconds > 0 && !options.beforeFetch) {
191
+ throw new VerificationError('robots_crawl_delay_requires_scheduler');
192
+ }
193
+ if (override) monitorOverrideConsumed = true;
194
+ return diagnostic;
195
+ } catch (error) {
196
+ diagnostic.reason = error instanceof VerificationError ? error.reason : 'verification_error';
197
+ if (diagnostic.googlebot.allowed === null && diagnostic.googlebot.reason === null) diagnostic.googlebot.reason = diagnostic.reason;
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ return {
203
+ // Authorize a native browser document without refetching its body through HTTP.
204
+ // The caller still needs an independently verified browser egress boundary.
205
+ async authorizeNavigation(url, trace = {}) {
206
+ const checked = validatePublicUrl(url);
207
+ if (!checked.valid) throw new VerificationError('unsafe_render_navigation');
208
+ const robots = await checkRobots(checked.url, trace);
209
+ if (signal.aborted) throw new VerificationError('timeout');
210
+ if (options.beforeFetch) await withAbort(Promise.resolve().then(() => options.beforeFetch(checked.url,
211
+ { kind: 'source', crawlDelaySeconds: robots.crawlDelaySeconds, signal })), signal);
212
+ if (signal.aborted) throw new VerificationError('timeout');
213
+ return robots;
214
+ },
215
+ /**
216
+ * Fetches one source document. `{ validators }` turns this into a conditional GET: the
217
+ * caller passes the ETag/Last-Modified a previous fetch earned, and a 304 comes back as
218
+ * `{ notModified: true }` rather than an error. A 304 we did not ask for stays an error —
219
+ * the server revalidating nobody's cache is a defect, not evidence of anything.
220
+ *
221
+ * `{ document: true }` fetches a feed or sitemap instead of a page: the Accept header and
222
+ * the content-type gate admit the XML family rather than HTML. Everything else — robots per
223
+ * hop, public-destination screening, the redirect budget — is identical, because a feed is
224
+ * fetched from the same publishers under the same politeness rules as a page.
225
+ */
226
+ async source(initialUrl, trace, { validators = null, document = false } = {}) {
227
+ const accept = document
228
+ ? 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, text/plain;q=0.8'
229
+ : 'text/html';
230
+ let url = initialUrl;
231
+ let revalidators = validators;
232
+ const seen = new Set();
233
+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
234
+ trace.finalUrl = url;
235
+ if (seen.has(url)) throw new VerificationError('redirect_loop');
236
+ seen.add(url);
237
+ // The exception belongs only to the customer's supplied page. It never
238
+ // grants a redirect destination, feed, sitemap or another denied request.
239
+ const robots = await checkRobots(url, trace, !document && redirects === 0);
240
+ trace.robots = robots;
241
+ const response = await request(url, { kind: 'source', crawlDelaySeconds: robots.crawlDelaySeconds, validators: revalidators, accept });
242
+ trace.httpStatus = response.status;
243
+ trace.sourceResponse = { url, httpStatus: response.status,
244
+ contentType: /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+/iu.exec(response.headers.get('content-type') ?? '')?.[0].slice(0, 128) ?? null,
245
+ retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')),
246
+ challenge: response.headers.get('cf-mitigated') === 'challenge' };
247
+ if (trace.sourceResponse.challenge) {
248
+ cancelBody(response);
249
+ const retry = trace.sourceResponse.retryAfterSeconds;
250
+ throw new VerificationError('access_challenge', retry === null ? {} : { retryAfterSeconds: retry });
251
+ }
252
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
253
+ cancelBody(response);
254
+ if (robots.overrideUsed === true) throw new VerificationError('monitor_robots_override_fetch_limit');
255
+ const location = response.headers.get('location');
256
+ if (!location) throw new VerificationError('redirect_missing_location');
257
+ let next;
258
+ try { next = validatePublicUrl(new URL(location, url).href); }
259
+ catch { throw new VerificationError('invalid_redirect'); }
260
+ if (!next.valid) throw new VerificationError(`unsafe_redirect:${next.reason}`);
261
+ trace.redirects.push({ from: url, to: next.url, status: response.status });
262
+ url = next.url;
263
+ // Validators were earned for the pre-redirect resource; the destination revalidates
264
+ // from scratch (see the conditional-header note in `request`).
265
+ revalidators = null;
266
+ continue;
267
+ }
268
+ if ([404, 410].includes(response.status)) {
269
+ trace.sourceResponse.bodyDiagnostic = await readBodyDiagnostic(response, signal);
270
+ return { response, body: null };
271
+ }
272
+ if (response.status === 304) {
273
+ cancelBody(response);
274
+ if (!revalidators) throw new VerificationError('not_modified_without_baseline');
275
+ return { response, body: null, notModified: true,
276
+ validators: { etag: response.headers.get('etag') ?? revalidators.etag ?? null,
277
+ lastModified: response.headers.get('last-modified') ?? revalidators.lastModified ?? null } };
278
+ }
279
+ if (response.status !== 200) {
280
+ trace.sourceResponse.bodyDiagnostic = await readBodyDiagnostic(response, signal);
281
+ // A 429 or a 503 may state WHEN to come back. We keep it and never act on it here:
282
+ // sleeping inside a request holds an origin lease and a worker for someone else's
283
+ // backoff, and this system already decided that **the cron is the sleep**. Surfacing it
284
+ // lets a scheduler re-queue at the stated time, the same way a robots crawl delay is
285
+ // handed to the scheduler rather than slept on.
286
+ throw new VerificationError(`source_http_${response.status}`,
287
+ retryAfterSeconds(response.headers.get('retry-after')) === null ? {}
288
+ : { retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')) });
289
+ }
290
+ const contentType = response.headers.get('content-type') ?? '';
291
+ // The document gate admits the XML family (`application/xml`, `text/xml`,
292
+ // `application/rss+xml`, `application/atom+xml`) and plain text (the sitemap protocol's
293
+ // text format). It is still a gate: a feed URL serving HTML is a page wearing a feed's
294
+ // name, and parsing it as a feed would read markup as entries.
295
+ const expected = document ? /(?:[+/]xml|text\/plain)(?:\s*;|$)/iu : /^text\/html(?:;|$)/iu;
296
+ if (!expected.test(contentType)) {
297
+ trace.sourceResponse.bodyDiagnostic = await readBodyDiagnostic(response, signal);
298
+ throw new VerificationError('unsupported_content_type');
299
+ }
300
+ const body = await readBounded(response, document ? documentMaxBytes : maxBytes, signal);
301
+ return { response, body, notModified: false,
302
+ validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
303
+ }
304
+ throw new VerificationError('redirect_limit');
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,20 @@
1
+ const HTTP_REASONS=new Set(['access_challenge','source_http_403','target_http_403','network_error','route_timeout','robots_access_challenge','robots_fetch_failed','robots_not_plain_text']);
2
+ const BROWSER_REASONS=new Set([...HTTP_REASONS,'possible_render_required','navigation_requires_browser']);
3
+ const PROXY_REASONS=new Set([...HTTP_REASONS,'render_failed','render_timeout','render_not_admitted',
4
+ 'acquisition_origin_connection_failed','acquisition_dns_resolution_failed','acquisition_executor_failed','acquisition_executor_busy']);
5
+ export const acquisitionState=value=>value?.state??(value?.outcome==='fetched'&&value.reason?'unknown':({fetched:'healthy',not_found:'unavailable',blocked:'unknown',unavailable:'unknown',not_configured:'unknown',deferred:'deferred',unchanged:'healthy'}[value?.outcome]??'unknown'));
6
+ export const completedAcquisition=value=>value?.evidence?.complete===true||value?.outcome==='fetched'&&!value.reason&&value.body_truncated!==true||['unchanged','not_found'].includes(value?.outcome);
7
+
8
+ /** Recovery never waives access policy, earned-monitor limits or publisher backoff. */
9
+ export function recoveryEligibility(value,method){
10
+ const history=value?.robotsHistory??(value?.robots?[value.robots]:[]);
11
+ if(history.some(row=>row?.overrideUsed===true))return {allowed:false,reason:'robots_override'};
12
+ if(Number.isFinite(value?.retryAfterSeconds)||Number.isFinite(value?.sourceResponse?.retryAfterSeconds)
13
+ ||/^((?:source|target|robots)_http_429|origin_busy|robots_crawl_delay_)/u.test(value?.reason??''))return {allowed:false,reason:'rate_limit'};
14
+ if(completedAcquisition(value))return {allowed:false,reason:'complete'};
15
+ if(['present','absent','healthy','source_unavailable'].includes(acquisitionState(value)))return {allowed:false,reason:'complete'};
16
+ if(!['unknown','deferred'].includes(acquisitionState(value)))return {allowed:false,reason:'unsupported'};
17
+ if(value?.occurrences?.length)return {allowed:false,reason:'complete'};
18
+ const allowed=(method==='node_browser'?BROWSER_REASONS:method==='proxy_http'?PROXY_REASONS:HTTP_REASONS).has(value?.reason);
19
+ return {allowed,reason:allowed?null:'unsupported'};
20
+ }