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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -22,7 +22,23 @@ export const TargetScope = z.strictObject({ target_kind: z.enum(['domain', 'exac
22
22
  if (!DiscoveryQuery.safeParse({ ...scope, ...policy }).success)
23
23
  ctx.addIssue({ code: 'custom', message: 'Expected an explicit canonical discovery target scope.' });
24
24
  });
25
- const memberSchema = z.strictObject({ id: identifier, role: z.enum(['customer', 'competitor']), scope: TargetScope });
25
+ // Customer-authored selection context, not a provider verdict or evidence of a link.
26
+ // Keep this optional with no defaults: parsing legacy revisions must not add bytes
27
+ // to the set hash embedded in their immutable inventory snapshots.
28
+ export const CompetitorSelectionRationale = z.strictObject({
29
+ relationship: z.enum(['business', 'search', 'shared_backlink_neighbor', 'unknown']),
30
+ audience: z.string().trim().min(1).max(500).nullable(),
31
+ topic: z.string().trim().min(1).max(500).nullable(),
32
+ evidence: z.array(z.strictObject({
33
+ source_url: z.string().min(1).max(2048).refine(value => {
34
+ const checked = validatePublicUrl(value);
35
+ return checked.valid && checked.url === value;
36
+ }, 'Expected a canonical public HTTP(S) source URL.'),
37
+ note: z.string().trim().min(1).max(1000),
38
+ })).max(5),
39
+ });
40
+ const memberSchema = z.strictObject({ id: identifier, role: z.enum(['customer', 'competitor']), scope: TargetScope,
41
+ selection_rationale: CompetitorSelectionRationale.optional() });
26
42
  export const ApprovedCompetitorSet = z.strictObject({
27
43
  v: z.literal(COMPETITOR_VERSION), id: identifier, revision: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),
28
44
  workspace_id: identifier, project_id: identifier, approved_by: identifier, approved_at: timestamp,
File without changes
File without changes
File without changes
@@ -29,7 +29,8 @@
29
29
  import { SEED_LIMITS, parseSitemap, sitemapDirectives, withinSitemapScope } from '../corpus/seeds.js';
30
30
  import { createAcquisition } from '../corpus/acquire.js';
31
31
  import { validatePublicUrl } from '../verifier/url.js';
32
- import { gunzipSync } from 'node:zlib';
32
+ import { createDocumentTransport } from '../page-acquisition/documents.js';
33
+ import { extractPageMetadata } from '../page-extraction/metadata.js';
33
34
  import { readJsonl, appendJsonl, sha256Hex } from './util.js';
34
35
  import { readManual } from './gsc.js';
35
36
 
@@ -52,93 +53,10 @@ export class ProfileError extends Error {
52
53
  // decompression (the acquisition layer's job per seeds.js), and per-host courtesy pacing.
53
54
  // ---------------------------------------------------------------------------
54
55
 
55
- /**
56
- * Reads a body to raw bytes under the same bound the verifier's reader enforces, WITHOUT the
57
- * UTF-8 decode: a gzipped sitemap is binary on the wire, and the decode must wait until after
58
- * the gunzip decision. Content-length is pre-checked, the stream is capped, the reader cancelled.
59
- */
60
- async function readBoundedBytes(response, maxBytes, signal) {
61
- const length = response.headers.get('content-length');
62
- const encoded = response.headers.get('content-encoding');
63
- if (!encoded && length && Number(length) > maxBytes) {
64
- response.body?.cancel().catch(() => {});
65
- throw new ProfileError('body_too_large');
66
- }
67
- const reader = response.body?.getReader();
68
- if (!reader) return { data: new Uint8Array(0), bytes: 0 };
69
- const chunks = [];
70
- let bytes = 0;
71
- try {
72
- for (;;) {
73
- const { done, value } = await reader.read();
74
- if (done) break;
75
- bytes += value.byteLength;
76
- if (bytes > maxBytes) throw new ProfileError('body_too_large');
77
- chunks.push(value);
78
- }
79
- const combined = new Uint8Array(bytes);
80
- let offset = 0;
81
- for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
82
- return { data: combined, bytes };
83
- } finally {
84
- reader.cancel().catch(() => {});
85
- reader.releaseLock();
86
- }
87
- }
88
-
89
- export function createSiteTransport({ fetchImpl = globalThis.fetch, timeoutMs = 20_000, maxBytes = SEED_LIMITS.documentBytes, maxRedirects = 5, hostDelayMs = PROFILE_LIMITS.hostDelayMs, now = () => Date.now(), wait = ms => new Promise(r => setTimeout(r, ms)) } = {}) {
90
- const nextAllowed = new Map();
91
- async function pace(host) {
92
- const allowed = nextAllowed.get(host) ?? 0;
93
- const delay = allowed - now();
94
- if (delay > 0) await wait(delay);
95
- nextAllowed.set(host, now() + hostDelayMs);
96
- }
97
-
98
- async function fetchText(rawUrl, { accept = 'text/plain,application/xml,text/xml' } = {}) {
99
- const checked = validatePublicUrl(rawUrl);
100
- if (!checked.valid) return { ok: false, url: rawUrl, reason: `unsafe_url:${checked.reason}` };
101
- let url = checked.url;
102
- const seen = new Set();
103
- for (let redirects = 0; redirects <= maxRedirects; redirects++) {
104
- if (seen.has(url)) return { ok: false, url, reason: 'redirect_loop' };
105
- seen.add(url);
106
- await pace(new URL(url).hostname);
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), timeoutMs);
109
- let response;
110
- try {
111
- response = await fetchImpl(url, { method: 'GET', redirect: 'manual', credentials: 'omit', signal: controller.signal, headers: { 'User-Agent': 'LinktrailBot/0.1', Accept: accept } });
112
- } catch { return { ok: false, url, reason: 'timeout_or_network_error' }; }
113
- finally { clearTimeout(timer); }
114
- if ([301, 302, 303, 307, 308].includes(response.status)) {
115
- response.body?.cancel().catch(() => {});
116
- const location = response.headers.get('location');
117
- const next = location ? validatePublicUrl(new URL(location, url).href) : null;
118
- if (!next?.valid) return { ok: false, url, reason: 'unsafe_or_missing_redirect' };
119
- url = next.url;
120
- continue;
121
- }
122
- if (response.status === 404 || response.status === 410) { response.body?.cancel().catch(() => {}); return { ok: false, url, status: response.status, reason: 'not_found' }; }
123
- if (!response.ok) { response.body?.cancel().catch(() => {}); return { ok: false, url, status: response.status, reason: `http_${response.status}` }; }
124
- let body;
125
- try { body = await readBoundedBytes(response, maxBytes, controller.signal); }
126
- catch (error) { return { ok: false, url, status: response.status, reason: error?.reason ?? 'read_failed' }; }
127
- let data = body.data, gzipped = false;
128
- if (data?.length >= 2 && data[0] === 0x1f && data[1] === 0x8b) {
129
- gzipped = true;
130
- try { data = gunzipSync(data); } catch { return { ok: false, url, status: response.status, reason: 'sitemap_gunzip_failed' }; }
131
- if (data.byteLength > maxBytes) return { ok: false, url, status: response.status, reason: 'sitemap_too_large' };
132
- }
133
- let text;
134
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(data); }
135
- catch { return { ok: false, url, status: response.status, reason: 'unsupported_or_invalid_encoding' }; }
136
- return { ok: true, url, final_url: url, status: response.status, text, bytes: body.bytes, gzipped };
137
- }
138
- return { ok: false, url, reason: 'redirect_limit' };
139
- }
140
-
141
- return { fetchText };
56
+ /** Compatibility entrypoint; byte limits and abortable document reads live in one core. */
57
+ export function createSiteTransport(options = {}) {
58
+ return createDocumentTransport({ maxBytes: SEED_LIMITS.documentBytes,
59
+ hostDelayMs: PROFILE_LIMITS.hostDelayMs, ...options });
142
60
  }
143
61
 
144
62
  // ---------------------------------------------------------------------------
@@ -146,17 +64,6 @@ export function createSiteTransport({ fetchImpl = globalThis.fetch, timeoutMs =
146
64
  // fetch date, matching the verifier's rule that evidence HTML never lives in a payload.
147
65
  // ---------------------------------------------------------------------------
148
66
 
149
- const firstMatch = (pattern, html, group = 1) => pattern.exec(html)?.[group]?.replace(/\s+/gu, ' ').trim() ?? null;
150
-
151
- function boundedMatches(pattern, html, limit) {
152
- const found = [];
153
- for (const match of html.matchAll(pattern)) {
154
- found.push(match[1].replace(/\s+/gu, ' ').trim());
155
- if (found.length >= limit) break;
156
- }
157
- return found;
158
- }
159
-
160
67
  /** Asset candidates are NOMINATIONS for judgment, never findings: path conventions the publisher chose plus names the manual declared. */
161
68
  const ASSET_PATH_PATTERNS = Object.freeze([
162
69
  [/\/(tools?|calculators?|templates?|checklists?|guides?)\//iu, 'asset_path_convention'],
@@ -164,19 +71,8 @@ const ASSET_PATH_PATTERNS = Object.freeze([
164
71
  [/\.(pdf|xlsx?|csv)$/iu, 'downloadable_file'],
165
72
  ]);
166
73
 
167
- export function derivePageFields(html) {
168
- if (typeof html !== 'string' || !html) return { title: null, h1s: [], meta_description: null, canonical: null, link_counts: null, asset_candidate_reasons: null };
169
- const links = [...html.matchAll(/<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/giu)];
170
- return {
171
- title: firstMatch(/<title[^>]*>([\s\S]*?)<\/title>/iu, html),
172
- h1s: boundedMatches(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu, html, 5),
173
- meta_description: (firstMatch(/<meta\b[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/iu, html)
174
- ?? firstMatch(/<meta\b[^>]*content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/iu, html))?.slice(0, 500) ?? null,
175
- canonical: firstMatch(/<link\b[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']+)["']/iu, html)
176
- ?? firstMatch(/<link\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']canonical["']/iu, html),
177
- link_counts: { total: links.length },
178
- asset_candidate_reasons: [], // filled by the caller, who knows the URL and the manual assets
179
- };
74
+ export function derivePageFields(html, { baseUrl } = {}) {
75
+ return extractPageMetadata(html, { baseUrl });
180
76
  }
181
77
 
182
78
  function assetCandidateReasons(url, derived, manualAssets) {
@@ -290,6 +186,7 @@ function classifySelection(url, manualTargets, homepage, sitemapUrls) {
290
186
  */
291
187
  export async function buildSiteProfile({
292
188
  site = null, paths, manual = null, transport = null, acquisition = null,
189
+ recovery = null,
293
190
  extraPages = [], now = () => new Date().toISOString(), limits = {},
294
191
  }) {
295
192
  const cap = { ...PROFILE_LIMITS, ...limits };
@@ -312,7 +209,7 @@ export async function buildSiteProfile({
312
209
  // refused rather than guessing at /sitemap.xml, and the page lane proceeds under the
313
210
  // verifier's own current robots posture.
314
211
  const robotsUrl = `${origin}/robots.txt`;
315
- const robotsFetch = await docFetch.fetchText(robotsUrl);
212
+ const robotsFetch = await docFetch.fetchText(robotsUrl, { purpose: 'robots' });
316
213
  const robotsText = robotsFetch.ok ? robotsFetch.text : '';
317
214
  const directives = robotsFetch.ok ? sitemapDirectives(robotsText, { baseUrl: robotsUrl }) : [];
318
215
  const robotsFact = await siteFactRow('site.robots', robotsUrl, {
@@ -383,24 +280,39 @@ export async function buildSiteProfile({
383
280
  const selectedFinal = selected.slice(0, cap.selectedPages);
384
281
 
385
282
  // 4. Page fetches on the verifier's public fetch boundary, with the CLI's courtesy pacing.
386
- const pageAcquisition = acquisition ?? createAcquisition({ publicFetchSafe: true });
283
+ // This local producer owns no Worker job or native reservation. Explicit caller
284
+ // adapters may recover a page; omitted privileged routes remain unsupported.
285
+ const pageAcquisition = acquisition ?? createAcquisition({ publicFetchSafe: true,purpose:'research',...(recovery?{recovery}:{}) });
387
286
  const pacer = createPacer({ hostDelayMs: cap.hostDelayMs });
388
287
  const pageRows = [];
389
288
  for (const url of selectedFinal) {
390
289
  const fetchAt = now();
391
- const result = await pageAcquisition.fetchPage(url, { kind: 'direct', beforeFetch: pacer.beforeFetch });
392
- const fetched = result.outcome === 'fetched';
393
- const derived = fetched ? derivePageFields(result.html) : { title: null, h1s: [], meta_description: null, canonical: null, link_counts: null };
394
- derived.asset_candidate_reasons = fetched ? assetCandidateReasons(url, derived, manualInputs.assets) : null; // unknown, never "no assets"
290
+ const result = await pageAcquisition.fetchPage(url, { kind: 'direct',purpose:'research', beforeFetch: pacer.beforeFetch,...(recovery?{recovery}:{}) });
291
+ const acquired = result.outcome === 'fetched';
292
+ let outcome = result.outcome, reason = result.reason ?? null;
293
+ let derived = { title: null, h1s: [], meta_description: null, canonical: null, link_counts: null };
294
+ if (acquired) {
295
+ try {
296
+ if (result.body_truncated === true) throw new ProfileError('body_truncated');
297
+ derived = derivePageFields(result.html, { baseUrl: result.final_url ?? url });
298
+ }
299
+ catch (error) {
300
+ // A refused/incomplete parse is an explicit unknown, never an empty page or "no assets".
301
+ outcome = 'unavailable'; reason = error?.reason ?? 'unreadable_document';
302
+ }
303
+ }
304
+ const fetched = outcome === 'fetched';
305
+ derived.asset_candidate_reasons = fetched ? assetCandidateReasons(url, derived, manualInputs.assets) : null;
395
306
  const fact = await siteFactRow('site.page', url, {
396
- outcome: result.outcome, reason: result.reason ?? null, http_status: result.http_status ?? null,
307
+ outcome, reason, http_status: result.http_status ?? null,
397
308
  final_url: result.final_url ?? url, robots: result.robots?.reason ?? null,
398
309
  title: derived.title, h1s: derived.h1s, meta_description: derived.meta_description,
399
310
  canonical: derived.canonical, link_counts: derived.link_counts,
400
311
  asset_candidate_reasons: derived.asset_candidate_reasons,
401
312
  in_sitemap: sitemapUrls.includes(url),
402
313
  selection_reasons: classifySelection(url, manualInputs.target_pages ?? [], homepage, sitemapUrls),
403
- bytes: result.bytes ?? null, content_sha256: fetched ? await sha256Hex(result.html ?? '') : null,
314
+ bytes: result.bytes ?? null, content_sha256: acquired ? await sha256Hex(result.html ?? '') : null,
315
+ ...(result.acquisition?{acquisition:result.acquisition}:{}),
404
316
  fetched_at: fetchAt,
405
317
  });
406
318
  facts.push(fact); pageRows.push(fact);
@@ -417,7 +329,8 @@ export async function buildSiteProfile({
417
329
  capped: sitemapCapped, directives_seen: directives.length,
418
330
  failures: sitemapFailures, skipped: sitemapSkipped,
419
331
  },
420
- pages: pageRows.map(row => ({ url: row.url, outcome: row.outcome, reason: row.reason, title: row.title, in_sitemap: row.in_sitemap, selection_reasons: row.selection_reasons, asset_candidate_reasons: row.asset_candidate_reasons })),
332
+ pages: pageRows.map(row => ({ url: row.url, outcome: row.outcome, reason: row.reason, title: row.title, in_sitemap: row.in_sitemap, selection_reasons: row.selection_reasons, asset_candidate_reasons: row.asset_candidate_reasons,
333
+ ...(row.acquisition?{acquisition:row.acquisition}:{}) })),
421
334
  limits: { sitemap_documents: cap.sitemapDocuments, selected_pages: cap.selectedPages, pages_capped: pagesCapped, pages_selected: pageRows.length },
422
335
  rows_written: facts.length,
423
336
  notes: [
File without changes
File without changes
@@ -1,5 +1,7 @@
1
- import { publicFetcher, VerificationError } from '../verifier/fetch.js';
1
+ import { createPageAcquisition } from '../page-acquisition/acquire.js';
2
2
  import { validatePublicUrl } from '../verifier/url.js';
3
+ import {runRecovery,recoverySummary} from '../page-acquisition/recovery.js';
4
+ import {extractEdges,ExtractionError} from './extract.js';
3
5
 
4
6
  // One interface over every way we acquire a page, so a runtime can be added without
5
7
  // rebuilding the corpus. Direct fetching is implemented here on the verifier's public
@@ -51,24 +53,22 @@ export function createAcquisition(options = {}) {
51
53
  const runtimes = options.runtimes ?? {};
52
54
  const clock = options.now ?? (() => new Date().toISOString());
53
55
 
54
- async function direct(url, beforeFetch, { validators = null, document = false } = {}) {
55
- // The public boundary is a property of the transport, not of this module. Refuse rather
56
- // than fetch on an unasserted one; a lexically public hostname can still resolve inside.
56
+ async function direct(url, beforeFetch, { validators = null, document = false, alternate = null, method = 'workers_http',
57
+ signal = options.signal, deadlineAt = options.deadlineAt } = {}) {
58
+ // Preserve the established compatibility refusal. New shared capture metadata does
59
+ // not establish public egress qualification or activate the stopped corpus machinery.
57
60
  if (options.publicFetchSafe !== true) throw new AcquisitionError('public_fetch_boundary_not_asserted');
58
- const controller = new AbortController();
59
- const timer = setTimeout(() => controller.abort(), timeoutMs);
60
- const trace = { finalUrl: url, redirects: [], robots: null, httpStatus: null };
61
- try {
62
- const fetcher = publicFetcher({ ...options, beforeFetch: beforeFetch ?? options.beforeFetch, maxBytes, maxRedirects: ACQUISITION_LIMITS.maxRedirects }, controller.signal);
63
- const { response, body, notModified, validators: earned } = await fetcher.source(url, trace, { validators, document });
64
- if (notModified) return { outcome: 'unchanged', trace, html: null, validators: earned };
65
- if (NOT_FOUND.has(response.status)) return { outcome: 'not_found', trace, html: null };
66
- return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0,
67
- linkHeader: response.headers.get('link'),
68
- validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
69
- } finally {
70
- clearTimeout(timer);
71
- }
61
+ const acquisition = createPageAcquisition({ ...options, beforeFetch: beforeFetch ?? options.beforeFetch,
62
+ ...(alternate?{fetchImpl:(target,settings)=>alternate.fetch(target,{...settings,deadlineAt})}:{}),
63
+ transportId: alternate?method:options.captureTransport?.id ?? 'public_http',
64
+ transportVersion: alternate?'alo-public-node-v1':options.captureTransport?.version ?? '1' });
65
+ const { response, body, notModified, validators: earned, trace, capture } = await acquisition.acquire(url,
66
+ { maxBytes, timeoutMs, validators, document, signal, deadlineAt });
67
+ if (notModified) return { outcome: 'unchanged', trace, html: null, validators: earned, capture: null };
68
+ if (NOT_FOUND.has(response.status)) return { outcome: 'not_found', trace, html: null, capture: null };
69
+ return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0, capture,
70
+ linkHeader: response.headers.get('link'),
71
+ validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
72
72
  }
73
73
 
74
74
  /**
@@ -85,6 +85,8 @@ export function createAcquisition(options = {}) {
85
85
  }
86
86
 
87
87
  return {
88
+ recoveryManaged:true,
89
+ maxFetches:1+(options.recovery?.alternate?1:0)+(runtimes.rendered?.publicFetchSafe===true?1:0)+(runtimes.proxied?.publicFetchSafe===true?1:0),
88
90
  /**
89
91
  * Whether a fetch kind can actually be served. The crawl loop asks before escalating a
90
92
  * page to a browser, because queueing work no runtime can serve would leave the frontier
@@ -104,7 +106,7 @@ export function createAcquisition(options = {}) {
104
106
  * previous fetch of this URL earned, and an unchanged page comes back as outcome
105
107
  * `unchanged` with `http_status` 304 and no body — a dated observation, not an absence.
106
108
  */
107
- async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null, validators = null } = {}) {
109
+ async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null, validators = null, recovery = options.recovery??{},purpose = options.purpose??'discovery', signal = options.signal, deadlineAt = options.deadlineAt } = {}) {
108
110
  const at = clock();
109
111
  const base = { url: rawUrl, final_url: null, kind, http_status: null, robots: null,
110
112
  html: null, body_truncated: false, fetched_at: at, reason: null, etag: null, last_modified: null };
@@ -112,21 +114,30 @@ export function createAcquisition(options = {}) {
112
114
  const checked = validatePublicUrl(rawUrl);
113
115
  if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
114
116
 
115
- try {
116
- if (kind === 'direct') {
117
- const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators });
118
- return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
119
- robots: trace.robots, redirects: trace.redirects, html,
117
+ const consumed=[];
118
+ const acquireOne=async(routeKind,settings,provider=null,method='workers_http')=>{
119
+ try {
120
+ if (routeKind === 'direct') {
121
+ const { outcome, trace, html, validators: earned, linkHeader, bytes, capture } = await direct(checked.url, beforeFetch, { validators,...settings,alternate:provider,method });
122
+ const result={ ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
123
+ robots: trace.robots, redirects: trace.redirects, html, bytes, capture,
124
+ robotsHistory:trace.robotsHistory,sourceResponse:trace.sourceResponse,
120
125
  etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
121
126
  link_header: linkHeader ?? null,
122
127
  // The verifier's reader refuses an oversized body outright, so a returned body was
123
128
  // read to the end. Truncation reaches extraction from runtimes with their own caps.
124
129
  body_truncated: false, render_ms: 0, proxy_bytes: 0 };
130
+ // Acquisition and parsing failures enter the same router. No fetched shell
131
+ // can silently become an empty publisher in context or the owned corpus.
132
+ if(result.outcome==='fetched')try{extractEdges(html,{pageUrl:trace.finalUrl,fetchKind:'direct'});}
133
+ catch(error){if(!(error instanceof ExtractionError))throw error;return {...result,outcome:classifyFailure(error.reason),reason:error.reason,html:null};}
134
+ return result;
125
135
  }
126
- const runtime = assertRuntime(kind);
127
- const result = await runtime.fetchPage(checked.url, { maxBytes, timeoutMs });
128
- return { ...base, outcome: result.outcome ?? 'fetched', final_url: result.finalUrl ?? checked.url,
136
+ const runtime = assertRuntime(routeKind);
137
+ const result = await runtime.fetchPage(checked.url, { maxBytes,...settings,beforeFetch });
138
+ const observed={ ...base,kind:routeKind,outcome: result.outcome ?? 'fetched', final_url: result.finalUrl ?? checked.url,
129
139
  http_status: result.httpStatus ?? null, html: result.html ?? null,
140
+ positive_only:result.positiveOnly===true,robots:result.robots??null,robotsHistory:result.robotsHistory,sourceResponse:result.sourceResponse,
130
141
  body_truncated: result.bodyTruncated === true, reason: result.reason ?? null,
131
142
  // The units a runtime actually consumed. Rendering and proxy traffic are what make
132
143
  // these paths expensive, so dropping them here would leave every metered run
@@ -134,13 +145,38 @@ export function createAcquisition(options = {}) {
134
145
  // one. A direct fetch reports neither, which is correct: it spends neither.
135
146
  render_ms: Number.isInteger(result.renderMs) ? result.renderMs : 0,
136
147
  proxy_bytes: Number.isInteger(result.proxyBytes) ? result.proxyBytes : 0 };
137
- } catch (error) {
148
+ consumed.push(observed);
149
+ if(observed.outcome==='fetched')try{extractEdges(observed.html??'',{pageUrl:observed.final_url,fetchKind:routeKind,bodyTruncated:observed.body_truncated||observed.positive_only});}
150
+ catch(error){if(!(error instanceof ExtractionError))throw error;return {...observed,outcome:classifyFailure(error.reason),reason:error.reason,html:null};}
151
+ return observed;
152
+ } catch (error) {
138
153
  // Any error carrying its own reason keeps it, including the pacing hook's, so a
139
154
  // slot we could not take is classified as deferrable rather than as a failure.
140
155
  const reason = typeof error?.reason === 'string' ? error.reason : 'acquisition_error';
141
156
  const outcome = error instanceof AcquisitionError && /not_configured|not_asserted|_invalid$/.test(reason)
142
157
  ? 'not_configured' : classifyFailure(reason);
143
- return { ...base, outcome, reason };
158
+ const trace=error.acquisitionTrace??{};
159
+ return { ...base,kind:routeKind,outcome,reason,robots:trace.robots,robotsHistory:trace.robotsHistory,sourceResponse:trace.sourceResponse,
160
+ http_status:trace.httpStatus??null,final_url:trace.finalUrl??null};
161
+ }
162
+ };
163
+ try{
164
+ if(kind!=='direct')return await acquireOne(kind,{signal,deadlineAt,timeoutMs});
165
+ const provider=recovery.alternate;
166
+ const runtimeRoute=(runtimeKind,method)=>({capability:recovery.capabilities?.[method]??{available:runtimes[runtimeKind]?.publicFetchSafe===true&&typeof runtimes[runtimeKind]?.fetchPage==='function',reason:'unsupported'},
167
+ admit:recovery[`${runtimeKind}Admission`],execute:async settings=>[await acquireOne(runtimeKind,settings)]});
168
+ const [result]=await runRecovery({purpose,directMethod:options.directMethod??'workers_http',authorize:recovery.authorize,options:{signal,deadlineAt,timeoutMs},
169
+ directFailure:reason=>[{...base,outcome:'unavailable',reason}],
170
+ executeDirect:async settings=>[await acquireOne('direct',settings)],routes:{
171
+ node_http:{capability:recovery.capabilities?.node_http??{available:!!provider,reason:'unsupported'},receipts:provider?.receipts,
172
+ execute:provider?async settings=>[await acquireOne('direct',settings,provider,'node_http')]:undefined},
173
+ node_browser:runtimeRoute('rendered','node_browser'),proxy_http:runtimeRoute('proxied','proxy_http')}});
174
+ result.acquisition=recoverySummary(result.evidence?.acquisition);
175
+ result.fetches=result.acquisition?.attempts.length??1;
176
+ result.render_ms=consumed.reduce((sum,row)=>sum+(row.render_ms??0),0);result.proxy_bytes=consumed.reduce((sum,row)=>sum+(row.proxy_bytes??0),0);
177
+ return result;
178
+ }catch(error){
179
+ return {...base,outcome:classifyFailure(error.reason??'acquisition_error'),reason:error.reason??'acquisition_error'};
144
180
  }
145
181
  },
146
182
 
@@ -153,16 +189,16 @@ export function createAcquisition(options = {}) {
153
189
  * The crawl loop never calls this; it exists for the freshness lanes (sitemap diffing,
154
190
  * feed polling) that read publisher declarations rather than pages.
155
191
  */
156
- async fetchDocument(rawUrl, { beforeFetch = null, validators = null } = {}) {
192
+ async fetchDocument(rawUrl, { beforeFetch = null, validators = null, signal = options.signal, deadlineAt = options.deadlineAt } = {}) {
157
193
  const at = clock();
158
194
  const base = { url: rawUrl, final_url: null, kind: 'direct', http_status: null,
159
195
  body: null, fetched_at: at, reason: null, etag: null, last_modified: null };
160
196
  const checked = validatePublicUrl(rawUrl);
161
197
  if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
162
198
  try {
163
- const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators, document: true });
199
+ const { outcome, trace, html, validators: earned, linkHeader, bytes, capture } = await direct(checked.url, beforeFetch, { validators, document: true, signal, deadlineAt });
164
200
  return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
165
- body: outcome === 'fetched' ? html : null,
201
+ body: outcome === 'fetched' ? html : null, bytes, capture,
166
202
  etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
167
203
  link_header: linkHeader ?? null };
168
204
  } catch (error) {
@@ -0,0 +1,147 @@
1
+ import { parseDocument, pageDirectives } from '../verifier/parser.js';
2
+ import { validatePublicUrl } from '../verifier/url.js';
3
+ import { frontierKey } from './frontier.js';
4
+ import { analyzeReadiness, isAccessChallenge } from '../verifier/readiness.js';
5
+
6
+ // Turns one fetched public HTML page into the outbound edges our corpus stores.
7
+ // Pure: no fetch, no persistence, no scheduling. The caller owns acquisition.
8
+ //
9
+ // This deliberately reuses the verifier's parser rather than adopting an audit crawler's.
10
+ // That parser already keeps every anchor/area occurrence instead of collapsing them by
11
+ // destination, keeps raw rel tokens rather than a nofollow boolean, and THROWS on its
12
+ // complexity limits instead of returning a short list. The last property is the one that
13
+ // matters most here: a page we could not fully read must never be recorded as a page with
14
+ // few links, because absence is what the whole product must not fake.
15
+ export const EXTRACTION_VERSION = 1;
16
+
17
+ // A link carrying any of these is a hint, not an endorsement, so it does not pass equity.
18
+ const UNFOLLOWED_REL = new Set(['nofollow', 'sponsored', 'ugc']);
19
+
20
+ export class ExtractionError extends Error {
21
+ constructor(reason) { super(reason); this.name = 'ExtractionError'; this.reason = reason; }
22
+ }
23
+
24
+ /** Same host, or one host is a subdomain of the other. */
25
+ function relatedHost(host, other) {
26
+ return host === other || host.endsWith(`.${other}`) || other.endsWith(`.${host}`);
27
+ }
28
+
29
+ /**
30
+ * @param html Decoded response body. The caller must say if it was cut short.
31
+ * @param pageUrl Canonical URL of the page the body came from, after redirects.
32
+ * @param options.fetchKind How the body was acquired: direct, rendered or proxied.
33
+ * @param options.bodyTruncated True when the byte budget stopped the read.
34
+ * @param options.xRobotsTag Raw X-Robots-Tag response header, if any.
35
+ * @param options.internalHosts Hosts to treat as the source's own. Defaults to the source
36
+ * host and its subdomains. Pass the site's real host list
37
+ * when siblings such as blog. and www. must count as internal;
38
+ * without a public suffix list this cannot be inferred.
39
+ * @throws ExtractionError with `.reason` when the page could not be read completely enough
40
+ * to enumerate its links. Callers record an unknown page, never an empty one.
41
+ */
42
+ export function extractEdges(html, { pageUrl, fetchKind, bodyTruncated = false, xRobotsTag = null, internalHosts } = {}) {
43
+ if (!['direct', 'rendered', 'proxied'].includes(fetchKind)) throw new ExtractionError('unsupported_fetch_kind');
44
+ const page = validatePublicUrl(pageUrl);
45
+ if (!page.valid) throw new ExtractionError(`source_${page.reason}`);
46
+
47
+ let document;
48
+ // Complexity limits reject the page rather than returning part of it. Re-thrown as an
49
+ // ExtractionError so a caller cannot mistake a refusal for a result.
50
+ try { document = parseDocument(html, page.url); }
51
+ catch (error) { throw new ExtractionError(error.reason ?? 'unreadable_document'); }
52
+
53
+ // A page whose links we could not have seen must not be stored as a page with few links.
54
+ // These are the verifier's own "cannot conclude absence" rules, which matter even more
55
+ // here: a plain-fetch crawler that records a JavaScript-rendered publisher as link-free
56
+ // poisons the corpus silently, and that is the single most common false-negative in this
57
+ // category. The rules are asymmetric on purpose — a real article that happens to carry a
58
+ // login form still extracts, because it produced links.
59
+ const { readiness, managedClientChallenge } = analyzeReadiness({ html, parsed: document });
60
+ if (isAccessChallenge(document, { managedClientChallenge })) throw new ExtractionError('access_challenge');
61
+ if (!document.links.length) {
62
+ if (document.meta.some(item => item.name === 'refresh')) throw new ExtractionError('navigation_requires_browser');
63
+ if (readiness.possibleLoginWall) throw new ExtractionError('possible_login_wall');
64
+ if (document.hasScript && document.visibleText.length < 80) throw new ExtractionError('possible_render_required');
65
+ }
66
+ // Navigation links around an empty application root do not make its content complete.
67
+ if(readiness.requiresRender)throw new ExtractionError('possible_render_required');
68
+
69
+ const directives = pageDirectives(document.meta, xRobotsTag);
70
+ const owned = internalHosts?.length
71
+ ? internalHosts.map(host => host.toLowerCase())
72
+ : [page.hostname];
73
+
74
+ // Group occurrences by canonical destination. Discovery reports that an edge exists and
75
+ // how often; per-occurrence anchor, rel and evidence belong to a verification check,
76
+ // which the verifier already produces on demand for a selected candidate.
77
+ const byTarget = new Map();
78
+ let outlinks = 0, externalOutlinks = 0;
79
+ for (const link of document.links) {
80
+ const target = validatePublicUrl(link.targetUrl);
81
+ if (!target.valid) continue;
82
+ outlinks++;
83
+ const internal = owned.some(host => relatedHost(target.hostname, host));
84
+ if (!internal) externalOutlinks++;
85
+ const existing = byTarget.get(target.url);
86
+ const followed = !directives.nofollow && !link.rel.some(token => UNFOLLOWED_REL.has(token));
87
+ if (existing) {
88
+ existing.links_count++;
89
+ // Any followed occurrence makes the edge followed: a nofollow copy in a comment does
90
+ // not undo a followed link in the body. `rel` stays the first occurrence's tokens,
91
+ // so `dofollow` is NOT a function of the `rel` reported beside it.
92
+ existing.dofollow ||= followed;
93
+ continue;
94
+ }
95
+ byTarget.set(target.url, {
96
+ target_url: target.url, target_hostname: target.hostname, internal,
97
+ anchor: link.anchor || null, rel: link.rel.length ? link.rel : null, dofollow: followed,
98
+ links_count: 1, context: link.context || null, locator: link.locator,
99
+ });
100
+ }
101
+
102
+ return {
103
+ v: EXTRACTION_VERSION,
104
+ source_url: page.url, source_hostname: page.hostname, fetch_kind: fetchKind,
105
+ // A body the byte budget cut short, or a document with unfinished markup, was not read
106
+ // to the end. The contract refuses `complete_for_query` coverage on such a page.
107
+ extraction_complete: !bodyTruncated && !document.incomplete,
108
+ page_nofollow: directives.nofollow, page_noindex: directives.noindex,
109
+ // Non-null when the page declares a DIFFERENT URL as canonical, i.e. it is a duplicate
110
+ // of that one. Recorded and surfaced, never used to silently drop the page: cross-domain
111
+ // and plain wrong canonicals are common, and discarding them would lose real links.
112
+ // Compared through the frontier key, so a canonical differing only by a trailing slash,
113
+ // an index filename or a tracking parameter is the SAME page, not a duplicate of another.
114
+ // Measured against real publishers, raw string comparison flagged 11 of 14 pages as
115
+ // duplicates when almost all of them were declaring themselves.
116
+ canonical_url: document.canonical && frontierKey(document.canonical) !== frontierKey(page.url)
117
+ ? document.canonical : null,
118
+ source_outlink_count: outlinks, source_external_outlink_count: externalOutlinks,
119
+ title: document.title || null,
120
+ edges: [...byTarget.values()],
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Shapes one extracted edge as a discovery candidate for the owned corpus. The caller
126
+ * supplies run lineage and the crawl clocks; nothing here invents a date or a check.
127
+ */
128
+ export function edgeToCandidate(edge, page, { runId, workspaceId, projectId, candidateId, taskId, retrievedAt, firstSeen = null, prevSeen = null, lastSeen = null }) {
129
+ return {
130
+ v: 1, id: candidateId, workspace_id: workspaceId, project_id: projectId, discovery_run_id: runId,
131
+ source_url: page.source_url, target_url: edge.target_url,
132
+ provider: 'linktrail_corpus', data_mode: 'owned_corpus',
133
+ provider_task_id: taskId, provider_retrieved_at: retrievedAt,
134
+ provider_first_seen: firstSeen, provider_prev_seen: prevSeen, provider_last_seen: lastSeen,
135
+ // A crawl that saw the link says nothing about loss or breakage, and our own crawl
136
+ // clocks are still upstream data rather than a check against a customer expectation.
137
+ provider_status: { is_lost: null, is_broken: null, is_new: null },
138
+ anchor: edge.anchor, rel: edge.rel, dofollow: edge.dofollow, link_type: 'anchor',
139
+ source_http_status: null, target_http_status: null, links_count: edge.links_count,
140
+ provider_metrics: { linktrail_corpus: {
141
+ source_outlink_count: page.source_outlink_count,
142
+ source_external_outlink_count: page.source_external_outlink_count,
143
+ fetch_kind: page.fetch_kind, extraction_complete: page.extraction_complete,
144
+ } },
145
+ verification_status: 'not_checked', verified_at: null, observation_id: null, evidence_id: null,
146
+ };
147
+ }