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
@@ -8,7 +8,7 @@ const url = value => text(value, 2048) && validatePublicUrl(value).valid;
8
8
  // ready flag cannot turn a challenge, failed session or partial document into proof.
9
9
  export function nativeRenderEvidence(doc, version) {
10
10
  const invalid = reason => ({ reason });
11
- if (version !== 1 || doc.rendererVersion !== 'cf-native-1'
11
+ if (version !== 1 || !['cf-native-1','alo-native-1'].includes(doc.rendererVersion)
12
12
  || !text(doc.contentType) || !text(doc.cfMitigated)
13
13
  || typeof doc.challenge !== 'boolean' || typeof doc.visibleLogin !== 'boolean'
14
14
  || typeof doc.browserClosed !== 'boolean'
@@ -18,6 +18,9 @@ export function nativeRenderEvidence(doc, version) {
18
18
  || !(doc.terminationReason === null || text(doc.terminationReason, 80))
19
19
  || !Array.isArray(doc.robotsHistory) || !doc.robotsHistory.length || doc.robotsHistory.length > 150) return invalid('invalid_native_render_evidence');
20
20
  if (doc.browserClosed !== true) return invalid('render_close_unconfirmed');
21
+ if(doc.rendererVersion==='alo-native-1'&&(!doc.cleanup||doc.cleanup.unitStopped!==true||doc.cleanup.cgroupEmpty!==true
22
+ ||doc.cleanup.namespaceDestroyed!==true||doc.executor?.browserEgressPolicy!=='private-network-unix-bridge-v1'
23
+ ||doc.executor?.browserEgressEnforced!==true||doc.executor?.version!=='alo-public-node-v1'))return invalid('render_close_unconfirmed');
21
24
  if (doc.terminationReason) return invalid('render_acquisition_incomplete');
22
25
  if (doc.cfMitigated.toLowerCase() === 'challenge' || doc.challenge) return invalid('render_access_challenge');
23
26
  if (doc.cfMitigated) return invalid('render_access_mitigated');
@@ -29,9 +32,25 @@ export function nativeRenderEvidence(doc, version) {
29
32
  || row.productToken !== 'LinktrailBot' || !text(row.reason, 80)
30
33
  || !Number.isInteger(row.httpStatus) || row.httpStatus < 100 || row.httpStatus > 599
31
34
  || (row.crawlDelaySeconds !== null && (!Number.isFinite(row.crawlDelaySeconds) || row.crawlDelaySeconds < 0))) return invalid('invalid_render_robots_evidence');
35
+ let googlebot;
36
+ if (row.googlebot !== undefined) {
37
+ const google = row.googlebot;
38
+ if (!google || typeof google !== 'object' || Array.isArray(google)
39
+ || google.productToken !== 'Googlebot'
40
+ || ![true, false, null].includes(google.allowed)
41
+ || !(google.reason === null || typeof google.reason === 'string' && /^[a-z0-9_:.-]{1,100}$/u.test(google.reason))
42
+ || !(google.matchedRule === null || text(google.matchedRule, 8192))
43
+ || !(google.crawlDelaySeconds === null || Number.isFinite(google.crawlDelaySeconds) && google.crawlDelaySeconds >= 0)) {
44
+ return invalid('invalid_render_robots_evidence');
45
+ }
46
+ // Google permission is evidence, not authority for our browser. The
47
+ // Linktrail decision above must still independently permit this fetch.
48
+ googlebot = { productToken: 'Googlebot', allowed: google.allowed, reason: google.reason,
49
+ matchedRule: google.matchedRule, crawlDelaySeconds: google.crawlDelaySeconds };
50
+ }
32
51
  robotsHistory.push({ sourceUrl: row.sourceUrl, robotsUrl: row.robotsUrl, allowed: true,
33
52
  productToken: 'LinktrailBot', reason: row.reason, httpStatus: row.httpStatus,
34
- crawlDelaySeconds: row.crawlDelaySeconds });
53
+ crawlDelaySeconds: row.crawlDelaySeconds, ...(googlebot ? { googlebot } : {}) });
35
54
  }
36
55
  // Each main-document hop must have a recorded authorization, including the final URL.
37
56
  const authorized = new Set(robotsHistory.map(row => row.sourceUrl));
@@ -40,6 +59,10 @@ export function nativeRenderEvidence(doc, version) {
40
59
  cfMitigated: doc.cfMitigated, challenge: false, visibleLogin: false, browserClosed: true,
41
60
  browserVersion: doc.browserVersion, userAgent: doc.userAgent, requestCount: doc.requestCount,
42
61
  durationMs: doc.durationMs, terminationReason: null, robotsHistory };
62
+ if(doc.rendererVersion==='alo-native-1'){
63
+ evidence.cleanup={unitStopped:true,cgroupEmpty:true,namespaceDestroyed:true};
64
+ evidence.executor={version:doc.executor.version,browserEgressEnforced:true,browserEgressPolicy:'private-network-unix-bridge-v1'};
65
+ }
43
66
  for (const key of ['blockedMutationRequests','blockedEssentialRequests']) {
44
67
  if (doc[key] === undefined) continue;
45
68
  if (!Number.isInteger(doc[key]) || doc[key] < 0 || doc[key] > 150) return invalid('invalid_native_render_evidence');
File without changes
@@ -0,0 +1,201 @@
1
+ import { validatePublicUrl } from './url.js';
2
+ import { parseDocument, pageDirectives } from './parser.js';
3
+ import { publicFetcher, VerificationError } from './fetch.js';
4
+ import {capturedDocument} from '../page-acquisition/capture.js';
5
+ import { analyzeReadiness, isAccessChallenge } from './readiness.js';
6
+
7
+ export const TARGET_CHECKER_VERSION = '1';
8
+ export function unknownTargetObservation({targetUrl},{now,reason='timeout'}={}){
9
+ return {state:'unknown',reason,targetUrl,finalUrl:targetUrl,httpStatus:null,checkedAt:new Date(typeof now==='function'?now():now??Date.now()).toISOString(),
10
+ redirects:[],target_checker_version:TARGET_CHECKER_VERSION,evidence:{method:'http_html',checkerVersion:TARGET_CHECKER_VERSION,complete:false,fetchedAt:null}};
11
+ }
12
+ const CONFIRMATION_MS = 30 * 60 * 1000;
13
+ const timeOf = (value) => {
14
+ if (value === null || value === undefined || value === '') return null;
15
+ const time = new Date(value).getTime();
16
+ return Number.isFinite(time) ? time : null;
17
+ };
18
+ const bounded = (value, fallback, min, max) => Number.isFinite(value)
19
+ ? Math.max(min, Math.min(Math.floor(value), max)) : fallback;
20
+
21
+ async function sha256(data) {
22
+ return [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))]
23
+ .map((byte) => byte.toString(16).padStart(2, '0')).join('');
24
+ }
25
+
26
+ /**
27
+ * Checks only the supplied destination URL (plus robots and validated redirects).
28
+ * Healthy means a complete accessible static HTML response, not Google indexing,
29
+ * semantic page correctness, rendered availability, or any backlink's presence.
30
+ * publicFetchSafe must assert a transport-level public-only destination boundary.
31
+ */
32
+ export async function verifyTarget({ targetUrl }, options = {}) {
33
+ const checkedAt = new Date(typeof options.now === 'function' ? options.now() : options.now ?? Date.now()).toISOString();
34
+ const result = unknownTargetObservation({targetUrl},{now:checkedAt,reason:'not_checked'});
35
+ const target = validatePublicUrl(targetUrl);
36
+ if (!target.valid) { result.reason = `invalid_target:${target.reason}`; return result; }
37
+ result.targetUrl = result.finalUrl = target.url;
38
+ if (options.publicFetchSafe !== true) { result.reason = 'public_fetch_not_configured'; return result; }
39
+
40
+ if(options.signal?.aborted||(Number.isFinite(options.deadlineAt)&&options.deadlineAt<=Date.now())){result.reason='timeout';return result;}
41
+ const controller = new AbortController();
42
+ const ownerAbort=()=>controller.abort();
43
+ options.signal?.addEventListener('abort',ownerAbort,{once:true});
44
+ const timer = setTimeout(() => controller.abort(), Math.min(bounded(options.timeoutMs, 20_000, 1, 20_000),options.deadlineAt?Math.max(1,options.deadlineAt-Date.now()):20_000));
45
+ const fetchOptions = {
46
+ ...options,
47
+ robotsPolicy: 'respect',
48
+ maxBytes: bounded(options.maxBytes, 2 * 1024 * 1024, 1, 2 * 1024 * 1024),
49
+ maxRedirects: bounded(options.maxRedirects, 5, 0, 5),
50
+ };
51
+ try {
52
+ const { response, body } = await publicFetcher(fetchOptions, controller.signal).source(target.url, result);
53
+ if(controller.signal.aborted||(Number.isFinite(options.deadlineAt)&&Date.now()>=options.deadlineAt))throw new VerificationError('timeout');
54
+ result.evidence.fetchedAt = checkedAt;
55
+ result.evidence.contentType = response.headers.get('content-type');
56
+ if ([404, 410].includes(response.status)) {
57
+ result.state = 'unavailable';
58
+ result.reason = `target_http_${response.status}`;
59
+ result.evidence.method = 'http_status';
60
+ result.evidence.complete = true;
61
+ return result;
62
+ }
63
+ result.evidence.bytes = body.bytes;
64
+ result.evidence.sha256 = await sha256(body.data);
65
+ result.evidence.decodedSha256 = await sha256(new TextEncoder().encode(body.text));
66
+ result.evidence.capture=capturedDocument(result,{requestedUrl:target.url,transportId:options.captureTransport?.id??'public_http',
67
+ transportVersion:options.captureTransport?.version??'1',qualified:options.publicFetchSafe===true,
68
+ charset:/charset\s*=\s*["']?([^\s;"']+)/iu.exec(response.headers.get('content-type')??'')?.[1]??'utf-8'});
69
+ result.evidence.etag = response.headers.get('etag');
70
+ result.evidence.lastModified = response.headers.get('last-modified');
71
+ if (!body.text.trim()) throw new VerificationError('empty_document');
72
+ let parsed;
73
+ try { parsed = parseDocument(body.text, result.finalUrl); }
74
+ catch (error) { throw new VerificationError(error.reason ?? 'html_parse_error'); }
75
+ if (parsed.incomplete) throw new VerificationError('incomplete_html', { parseErrors: parsed.parseErrors });
76
+ const { readiness, managedClientChallenge } = analyzeReadiness({ html: body.text, parsed, targetUrl: target.url });
77
+ if (isAccessChallenge(parsed, { managedClientChallenge })) throw new VerificationError('access_challenge');
78
+ if (parsed.meta.some((item) => item.name === 'refresh')) throw new VerificationError('navigation_requires_browser');
79
+ if (readiness.possibleLoginWall) throw new VerificationError('possible_login_wall');
80
+ if (parsed.hasScript && !parsed.links.length && parsed.visibleText.length < 80) throw new VerificationError('possible_render_required');
81
+ if(readiness.requiresRender)throw new VerificationError('possible_render_required');
82
+ const robotsHeader = response.headers.get('x-robots-tag');
83
+ if (robotsHeader?.length > 4096) throw new VerificationError('page_directive_too_long');
84
+ result.directives = pageDirectives(parsed.meta, robotsHeader);
85
+ result.state = 'healthy';
86
+ result.reason = 'target_html_available';
87
+ result.evidence.complete = true;
88
+ result.evidence.rendered = false;
89
+ result.evidence.baseUrl = parsed.baseUrl;
90
+ if (options.includeHtml !== false) result.evidence.html = body.text;
91
+ if(controller.signal.aborted||(Number.isFinite(options.deadlineAt)&&Date.now()>=options.deadlineAt))throw new VerificationError('timeout');
92
+ result.warnings = ['Static HTML availability only; rendered content, soft 404s and Google indexing were not verified.'];
93
+ return result;
94
+ } catch (error) {
95
+ result.state = 'unknown';
96
+ result.evidence.complete = false;
97
+ const reason = error instanceof VerificationError ? error.reason : controller.signal.aborted ? 'timeout' : 'verification_error';
98
+ result.reason = reason.replace(/^source_http_/u, 'target_http_');
99
+ // Carried so a scheduler can come back when the publisher said to, rather than on our cadence.
100
+ if (Number.isFinite(error?.retryAfterSeconds)) result.retryAfterSeconds = error.retryAfterSeconds;
101
+ if (result.httpStatus !== null) result.evidence.fetchedAt = checkedAt;
102
+ if (error.parseErrors) result.evidence.parseErrors = error.parseErrors;
103
+ return result;
104
+ } finally {
105
+ clearTimeout(timer);
106
+ controller.abort();
107
+ options.signal?.removeEventListener('abort',ownerAbort);
108
+ }
109
+ }
110
+
111
+ function summary(observation, checkedAt = observation?.checkedAt) {
112
+ if (!observation) return null;
113
+ const evidence = observation.evidence;
114
+ return {
115
+ observationId: observation.observationId ?? observation.id ?? null,
116
+ state: observation.state, reason: observation.reason,
117
+ targetUrl: observation.targetUrl, finalUrl: observation.finalUrl,
118
+ httpStatus: observation.httpStatus, checkedAt,
119
+ target_checker_version: observation.target_checker_version,
120
+ // Compact receipts only: full bodies and headers belong to the private evidence object.
121
+ evidence: evidence ? {
122
+ complete: evidence.complete, method: evidence.method, checkerVersion: evidence.checkerVersion,
123
+ sha256: evidence.sha256, decodedSha256:evidence.decodedSha256, bytes: evidence.bytes, fetchedAt: evidence.fetchedAt,
124
+ } : undefined,
125
+ };
126
+ }
127
+
128
+ /** Pure reducer. Persist atomically with the target observation and target event. */
129
+ export function transitionTargetState(previous, observation, now = Date.now()) {
130
+ const prior = previous ?? {};
131
+ const checkedMs = timeOf(observation.checkedAt) ?? timeOf(typeof now === 'function' ? now() : now);
132
+ if (checkedMs === null) throw new TypeError('A valid observation timestamp is required');
133
+ const checkedAt = new Date(checkedMs).toISOString();
134
+ const base = {
135
+ state: prior.state ?? 'unknown',
136
+ confirmedState: prior.confirmedState ?? (['healthy', 'unavailable'].includes(prior.state) ? prior.state : null),
137
+ latestAttempt: prior.latestAttempt ?? null,
138
+ lastSuccessfulObservation: prior.lastSuccessfulObservation ?? null,
139
+ lastSuccessfulAt: prior.lastSuccessfulAt ?? null,
140
+ lastHealthyAt: prior.lastHealthyAt ?? null,
141
+ firstUnavailableAt: prior.firstUnavailableAt ?? null,
142
+ unavailableCount: prior.unavailableCount ?? 0,
143
+ wasEverHealthy: prior.wasEverHealthy ?? prior.lastSuccessfulObservation?.state === 'healthy',
144
+ uncertain: prior.uncertain ?? true,
145
+ // Carried forward so a later conclusive observation does not silently drop a floor the
146
+ // publisher set and the scheduler has not yet reached.
147
+ retryNotBefore: prior.retryNotBefore ?? null,
148
+ nextCheckAt: null, event: null, ignored: false,
149
+ };
150
+ const latestMs = timeOf(prior.latestAttempt?.checkedAt);
151
+ const observationId = observation.observationId ?? observation.id;
152
+ const repeatedId = observationId && [prior.latestAttempt?.observationId, prior.lastSuccessfulObservation?.observationId].includes(observationId);
153
+ if ((latestMs !== null && checkedMs <= latestMs) || repeatedId) {
154
+ return { ...base, nextCheckAt: prior.nextCheckAt ?? null, ignored: true };
155
+ }
156
+ base.latestAttempt = summary(observation, checkedAt);
157
+ if (!['healthy', 'unavailable', 'unknown'].includes(observation.state)) throw new TypeError('Invalid target observation state');
158
+ const conclusive = observation.evidence?.complete === true
159
+ && ((observation.state === 'healthy' && observation.httpStatus === 200)
160
+ || (observation.state === 'unavailable' && [404, 410].includes(observation.httpStatus)));
161
+ if (!conclusive) {
162
+ base.uncertain = true;
163
+ // A publisher that answered 429 or 503 may have said when to come back. It is a FLOOR, not a
164
+ // schedule: "not before" is not "then". So it is surfaced as its own field and the scheduler
165
+ // takes the later of this and the target's own cadence — writing it as the next check time
166
+ // would re-check a rate-limiting host every ninety seconds, which is worse than ignoring it.
167
+ if (Number.isFinite(observation.retryAfterSeconds)) {
168
+ base.retryNotBefore = new Date(checkedMs + observation.retryAfterSeconds * 1000).toISOString();
169
+ }
170
+ return base;
171
+ }
172
+
173
+ base.lastSuccessfulObservation = base.latestAttempt;
174
+ base.lastSuccessfulAt = checkedAt;
175
+ base.uncertain = false;
176
+ if (observation.state === 'healthy') {
177
+ base.state = base.confirmedState = 'healthy';
178
+ base.lastHealthyAt = checkedAt;
179
+ base.firstUnavailableAt = null;
180
+ base.unavailableCount = 0;
181
+ base.wasEverHealthy = true;
182
+ const type = prior.confirmedState === 'unavailable' || prior.state === 'unavailable'
183
+ ? 'target_recovered' : !prior.wasEverHealthy && !prior.lastHealthyAt ? 'target_healthy' : null;
184
+ if (type) base.event = { type, before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
185
+ return base;
186
+ }
187
+
188
+ const first = timeOf(base.firstUnavailableAt);
189
+ base.firstUnavailableAt = first === null ? checkedAt : base.firstUnavailableAt;
190
+ base.unavailableCount = first === null ? 1 : base.unavailableCount + 1;
191
+ const confirmed = first !== null && checkedMs - first >= CONFIRMATION_MS && base.unavailableCount >= 2;
192
+ base.state = confirmed ? 'unavailable' : 'suspected_unavailable';
193
+ if (!confirmed) base.nextCheckAt = new Date((first ?? checkedMs) + CONFIRMATION_MS).toISOString();
194
+ else {
195
+ base.confirmedState = 'unavailable';
196
+ if (prior.confirmedState !== 'unavailable' && prior.state !== 'unavailable') {
197
+ base.event = { type: 'target_unavailable', before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
198
+ }
199
+ }
200
+ return base;
201
+ }
@@ -1,4 +1,5 @@
1
1
  const CONFIRMATION_MS = 30 * 60 * 1000;
2
+ import {recoverySummary} from '../page-acquisition/recovery.js';
2
3
 
3
4
  const timeOf = (value) => {
4
5
  if (value === null || value === undefined || value === '') return null;
@@ -6,9 +7,13 @@ const timeOf = (value) => {
6
7
  return Number.isFinite(time) ? time : null;
7
8
  };
8
9
 
10
+ // Retain a compact access history without private transport receipts or captures.
11
+ const acquisitionSummary=recoverySummary;
12
+
9
13
  function summary(observation) {
10
14
  if (!observation) return null;
11
15
  const { evidence } = observation;
16
+ const acquisition = acquisitionSummary(evidence?.acquisition);
12
17
  const all = observation.occurrences ?? [];
13
18
  // State/event snapshots are small references to full observations in D1/R2.
14
19
  // linkSignature covers ALL occurrences, preserving change detection beyond excerpts.
@@ -31,7 +36,8 @@ function summary(observation) {
31
36
  directives: observation.directives ? { noindex: observation.directives.noindex, nofollow: observation.directives.nofollow, indexingStatus: observation.directives.indexingStatus } : undefined,
32
37
  evidence: evidence ? {
33
38
  complete: evidence.complete, method: evidence.method, checkerVersion: evidence.checkerVersion,
34
- sha256: evidence.sha256, bytes: evidence.bytes, fetchedAt: evidence.fetchedAt,
39
+ sha256: evidence.sha256, decodedSha256: evidence.decodedSha256, bytes: evidence.bytes, fetchedAt: evidence.fetchedAt,
40
+ ...(acquisition?{acquisition}:{}),
35
41
  } : undefined,
36
42
  };
37
43
  }
@@ -49,12 +55,17 @@ function fingerprint(observation) {
49
55
  /** Pure state reducer. Persist its result atomically with the observation + outbox. */
50
56
  export function transitionState(previous, observation, now = Date.now()) {
51
57
  const prior = previous ?? {};
58
+ // Keep a historical confirmed fact separate from the latest check's ability
59
+ // to verify it. Older saved states predate this explicit historical field.
60
+ const confirmedState = prior.confirmedState ?? (prior.state === 'present' || prior.state === 'confirmed_missing'
61
+ || prior.state === 'source_unavailable' && prior.sourceUnavailableConfirmed === true ? prior.state : null);
52
62
  const checkedMs = timeOf(observation.checkedAt) ?? timeOf(typeof now === 'function' ? now() : now);
53
63
  if (checkedMs === null) throw new TypeError('A valid observation timestamp is required');
54
64
  const checkedAt = new Date(checkedMs).toISOString();
55
65
  const latestMs = prior.latestAttempt ? timeOf(prior.latestAttempt.checkedAt) : null;
56
66
  const base = {
57
67
  state: prior.state ?? 'unknown',
68
+ confirmedState,
58
69
  latestAttempt: prior.latestAttempt ?? null,
59
70
  lastSuccessfulObservation: prior.lastSuccessfulObservation ?? null,
60
71
  firstAbsentAt: prior.firstAbsentAt ?? null,
@@ -72,6 +83,7 @@ export function transitionState(previous, observation, now = Date.now()) {
72
83
  if (latestMs !== null && checkedMs <= latestMs) return { ...base, nextCheckAt: prior.nextCheckAt ?? null, ignored: true };
73
84
  base.latestAttempt = summary({ ...observation, checkedAt });
74
85
  if (observation.state === 'unknown' || observation.evidence?.complete !== true) {
86
+ base.state = 'unknown';
75
87
  base.uncertain = true;
76
88
  // A scheduler decides retry/backoff after an unknown; no past due timestamp loop. The one
77
89
  // thing carried is a floor the publisher stated in a Retry-After — "not before", never "then",
@@ -86,6 +98,7 @@ export function transitionState(previous, observation, now = Date.now()) {
86
98
  base.uncertain = false;
87
99
  if (observation.state === 'present') {
88
100
  base.state = 'present';
101
+ base.confirmedState = 'present';
89
102
  base.wasEverPresent = true;
90
103
  base.firstAbsentAt = null;
91
104
  base.absentCount = 0;
@@ -94,7 +107,7 @@ export function transitionState(previous, observation, now = Date.now()) {
94
107
  base.sourceUnavailableConfirmed = false;
95
108
  let type;
96
109
  if (!prior.wasEverPresent && prior.lastSuccessfulObservation?.state !== 'present') type = 'placement_acquired';
97
- else if (prior.state === 'confirmed_missing' || prior.sourceUnavailableConfirmed) type = 'placement_recovered';
110
+ else if (confirmedState === 'confirmed_missing' || prior.sourceUnavailableConfirmed) type = 'placement_recovered';
98
111
  else if (prior.lastSuccessfulObservation?.state === 'present' && fingerprint(prior.lastSuccessfulObservation) !== fingerprint(observation)) type = 'placement_changed';
99
112
  if (type) base.event = { type, before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
100
113
  return base;
@@ -108,8 +121,9 @@ export function transitionState(previous, observation, now = Date.now()) {
108
121
  base.sourceUnavailableConfirmed = false;
109
122
  const confirmed = first !== null && checkedMs - first >= CONFIRMATION_MS && base.absentCount >= 2;
110
123
  base.state = confirmed ? 'confirmed_missing' : 'suspected_missing';
124
+ if (confirmed) base.confirmedState = 'confirmed_missing';
111
125
  if (!confirmed) base.nextCheckAt = new Date((first ?? checkedMs) + CONFIRMATION_MS).toISOString();
112
- if (confirmed && prior.state !== 'confirmed_missing' && base.wasEverPresent) {
126
+ if (confirmed && confirmedState !== 'confirmed_missing' && base.wasEverPresent) {
113
127
  base.event = { type: 'placement_lost', before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
114
128
  }
115
129
  return base;
@@ -121,6 +135,7 @@ export function transitionState(previous, observation, now = Date.now()) {
121
135
  base.absentCount = 0;
122
136
  base.state = 'source_unavailable';
123
137
  base.sourceUnavailableConfirmed = first !== null && checkedMs - first >= CONFIRMATION_MS && base.unavailableCount >= 2;
138
+ if (base.sourceUnavailableConfirmed) base.confirmedState = 'source_unavailable';
124
139
  if (!base.sourceUnavailableConfirmed) base.nextCheckAt = new Date((first ?? checkedMs) + CONFIRMATION_MS).toISOString();
125
140
  if (base.sourceUnavailableConfirmed && !prior.sourceUnavailableConfirmed) {
126
141
  base.event = { type: 'source_unavailable', before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
@@ -54,3 +54,34 @@ export function matchesTarget(candidate, target, scope) {
54
54
  return false;
55
55
  }
56
56
  }
57
+
58
+ const REDIRECT_URL_PARAMETERS = new Set(['url', 'redirect_url', 'redirect_uri', 'destination', 'target']);
59
+ const MAX_WRAPPER_PARAMETERS = 4096;
60
+
61
+ /**
62
+ * A physical anchor may declare a destination through a redirect service.
63
+ * This is only a reason to withhold absence, never a matching occurrence or
64
+ * permission to request that URL. Return no publisher URL or token-bearing data.
65
+ */
66
+ export function hasRedirectWrapperToTarget(links, targetUrl, targetScope = 'exact') {
67
+ if (!Array.isArray(links) || !['exact', 'domain', 'subdomain', 'path'].includes(targetScope)) return false;
68
+ const target = validatePublicUrl(targetUrl);
69
+ if (!target.valid) return false;
70
+ for (const link of links) {
71
+ if (typeof link?.targetUrl !== 'string' || !link.targetUrl.includes('?')) continue;
72
+ const wrapper = validatePublicUrl(link.targetUrl);
73
+ if (!wrapper.valid || wrapper.url.length > 8192) continue;
74
+ let parameters = 0;
75
+ // URLSearchParams decodes the query once. No recursive URL traversal,
76
+ // repeated decoding, substring search or hydration/text interpretation.
77
+ // The URL's 8192-character bound cannot contain over 4096 nonempty query
78
+ // entries, even with one-character names and no values; no valid tail is cut.
79
+ for (const [name, value] of new URL(wrapper.url).searchParams) {
80
+ if (++parameters > MAX_WRAPPER_PARAMETERS) break;
81
+ if (!REDIRECT_URL_PARAMETERS.has(name)) continue;
82
+ const destination = validatePublicUrl(value);
83
+ if (destination.valid && matchesTarget(destination.url, target.url, targetScope)) return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
@@ -0,0 +1,144 @@
1
+ import { pageDirectives } from './page-extraction/html.js';
2
+ import { validateCaptureReceipt } from './page-acquisition/contracts.js';
3
+ import { nativeRenderEvidence } from './verifier/render-evidence.js';
4
+
5
+ const reason = value => typeof value === 'string' && /^[a-z0-9_:.-]{1,100}$/u.test(value) ? value : null;
6
+ const date = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
7
+ const bool = value => typeof value === 'boolean' ? value : null;
8
+
9
+ function sourceObservation(watch, observation) {
10
+ const result = observation?.result;
11
+ const observedAt = date(observation?.checked_at ?? result?.checkedAt);
12
+ if (!result || result.sourceUrl !== watch.source_url || !observedAt || typeof observation.id !== 'string') return null;
13
+ return { result, observedAt, id: observation.id };
14
+ }
15
+ function completePage(observation) {
16
+ const result = observation?.result;
17
+ // The verifier initializes empty directives before fetching. A 404, failed parse,
18
+ // access wall or incomplete capture must never turn those defaults into facts.
19
+ if (result?.evidence?.complete === true && ['present', 'absent'].includes(result.state)) return true;
20
+ // This verdict is reached only after a complete HTML body, successful parsing,
21
+ // directives and the access/login-wall checks. Rendering can still be required
22
+ // to verify the link; it does not erase already observed page directives.
23
+ const evidence = result?.evidence;
24
+ const parsedShell = result?.reason === 'possible_render_required'
25
+ && evidence?.readiness?.possibleLoginWall === false
26
+ && typeof evidence?.readiness?.requiresRender === 'boolean';
27
+ const staticShell = parsedShell && evidence?.method === 'http_html';
28
+ const unconfirmedDom = (parsedShell || ['render_no_positive_evidence', 'render_readiness_unconfirmed'].includes(result?.reason))
29
+ && evidence?.method === 'browser_html' && evidence.rendered === true && typeof evidence.ready === 'boolean';
30
+ if (result?.state !== 'unknown' || !(staticShell || unconfirmedDom)
31
+ || result.httpStatus !== 200
32
+ || !/^text\/html(?:\s*;|$)/iu.test(evidence.contentType ?? '')
33
+ || !Array.isArray(result.directives?.meta)
34
+ || !result.directives.meta.every(item => typeof item?.name === 'string' && typeof item?.content === 'string')
35
+ || !Array.isArray(result.directives?.xRobotsTag)
36
+ || !result.directives.xRobotsTag.every(item => typeof item === 'string')) return false;
37
+ try {
38
+ const capture = validateCaptureReceipt(evidence.capture, { finalUrl: result.finalUrl });
39
+ if (unconfirmedDom && (!Array.isArray(result.redirects) || nativeRenderEvidence({ ...evidence.browser,
40
+ finalUrl: result.finalUrl, redirects: result.redirects }, 1).reason)) return false;
41
+ return capture.representation === (staticShell ? 'raw_html' : 'rendered_dom') && capture.requestedUrl === result.sourceUrl
42
+ && capture.httpStatus === 200 && capture.body.bytes > 0
43
+ && capture.body.bytes === evidence.bytes && capture.body.sha256 === evidence.sha256
44
+ && capture.body.decodedSha256 === evidence.decodedSha256
45
+ && capture.acquiredAt === evidence.fetchedAt;
46
+ } catch { return false; }
47
+ }
48
+ function completeLink(watch, observation) {
49
+ const result = observation?.result;
50
+ return result?.evidence?.complete === true && ['present', 'absent'].includes(result.state)
51
+ && result.targetUrl === watch.target_url
52
+ && result.targetScope === watch.target_scope;
53
+ }
54
+ function googleDirectives(directives) {
55
+ if (!directives || typeof directives !== 'object') return { noindex: null, nofollow: null };
56
+ if (directives.googlebot) return {
57
+ noindex: bool(directives.googlebot.noindex), nofollow: bool(directives.googlebot.nofollow),
58
+ };
59
+ // Old full observations retain the actual meta/header inputs. Reuse the same
60
+ // targeted-directive parser instead of inferring Google facts from our bot.
61
+ if (Array.isArray(directives.meta) && directives.meta.every(item => typeof item?.name === 'string' && typeof item?.content === 'string')
62
+ && Array.isArray(directives.xRobotsTag) && directives.xRobotsTag.every(item => typeof item === 'string')) {
63
+ return pageDirectives(directives.meta, directives.xRobotsTag.join(',')).googlebot;
64
+ }
65
+ return { noindex: directives.noindex === true ? true : null,
66
+ nofollow: directives.nofollow === true ? true : null };
67
+ }
68
+ function observedRel(observation) {
69
+ const result = observation?.result;
70
+ if (result?.state !== 'present' || result.occurrencesTruncated === true
71
+ || !Array.isArray(result.occurrences) || !result.occurrences.length) return null;
72
+ const tokens = new Set();
73
+ for (const occurrence of result.occurrences) {
74
+ if (!Array.isArray(occurrence?.rel)) return null;
75
+ for (const value of occurrence.rel) {
76
+ if (typeof value !== 'string' || !/^[a-z0-9_-]{1,40}$/iu.test(value)) return null;
77
+ tokens.add(value.toLowerCase());
78
+ }
79
+ }
80
+ return [...tokens].sort();
81
+ }
82
+ function latestRobots(observation) {
83
+ const result = observation?.result;
84
+ const native = result?.evidence?.browser?.robotsHistory;
85
+ if (Array.isArray(native) && native.length) {
86
+ const urls = new Set([result.sourceUrl, result.finalUrl].filter(value => typeof value === 'string'));
87
+ for (const hop of Array.isArray(result.redirects) ? result.redirects : []) {
88
+ if (typeof hop?.from === 'string') urls.add(hop.from);
89
+ if (typeof hop?.to === 'string') urls.add(hop.to);
90
+ }
91
+ // Native traces also contain script/font requests. Only the document's
92
+ // actual URL chain supports a claim about Google crawling this page.
93
+ const rows = native.filter(row => urls.has(row?.sourceUrl));
94
+ for (const url of urls) if (!rows.some(row => row.sourceUrl === url)) rows.push(null);
95
+ return { rows, observedAt: observation.observedAt };
96
+ }
97
+ if (Array.isArray(result?.robotsHistory) && result.robotsHistory.length) return { rows: result.robotsHistory, observedAt: observation.observedAt };
98
+ if (result?.robots) return { rows: [result.robots], observedAt: observation.observedAt };
99
+ const original = result?.evidence?.staticObservation;
100
+ return { rows: Array.isArray(original?.robotsHistory) ? original.robotsHistory : [],
101
+ observedAt: date(original?.checkedAt) ?? observation?.observedAt ?? null };
102
+ }
103
+
104
+ /** Read-only facts from saved observations; never a live Google indexing test. */
105
+ export function projectWatchCrawlability(watch, { latestObservation = null, latestPageObservation = null, lastSuccessfulObservation = null } = {}) {
106
+ const latest = sourceObservation(watch, latestObservation);
107
+ const savedPage = sourceObservation(watch, latestPageObservation);
108
+ const successful = sourceObservation(watch, lastSuccessfulObservation);
109
+ const page = [latest, savedPage, successful].filter(completePage)
110
+ .sort((a, b) => Date.parse(b.observedAt) - Date.parse(a.observedAt))[0] ?? null;
111
+ const verified = completeLink(watch, latest) ? latest : completeLink(watch, successful) ? successful : null;
112
+ const directives = googleDirectives(page?.result.directives);
113
+ const currentVerifiable = completeLink(watch, latest);
114
+ const projection = {
115
+ google_crawlable: null,
116
+ google_crawl_reason: latest ? 'googlebot_not_observed' : 'not_checked',
117
+ robots_rule: null,
118
+ noindex: directives.noindex,
119
+ page_nofollow: directives.nofollow,
120
+ link_rel: verified ? observedRel(verified) : null,
121
+ can_verify_reason: currentVerifiable ? null : reason(latest?.result.reason)
122
+ ?? (latest ? 'verification_unknown' : latestObservation ? 'observation_source_mismatch' : 'not_checked'),
123
+ last_verified_at: verified?.observedAt ?? null,
124
+ crawlability_provenance: {
125
+ robots_observed_at: null, page_observed_at: page?.observedAt ?? null,
126
+ link_observed_at: verified?.result.state === 'present' ? verified.observedAt : null,
127
+ robots_observation_id: null, page_observation_id: page?.id ?? null,
128
+ link_observation_id: verified?.result.state === 'present' ? verified.id : null,
129
+ },
130
+ };
131
+ const { rows, observedAt } = latestRobots(latest);
132
+ if (!rows.length) return projection;
133
+ const decisions = rows.map(row => row?.googlebot?.productToken === 'Googlebot' ? row.googlebot : null);
134
+ const blocked = decisions.find(item => item?.allowed === false);
135
+ const unknown = decisions.find(item => !item || bool(item.allowed) === null);
136
+ const decision = blocked ?? unknown ?? decisions.at(-1);
137
+ projection.google_crawlable = blocked ? false : unknown !== undefined ? null : true;
138
+ projection.google_crawl_reason = reason(decision?.reason)
139
+ ?? (blocked ? 'robots_disallowed' : unknown !== undefined ? 'googlebot_not_observed' : 'robots_allowed');
140
+ projection.robots_rule = typeof decision?.matchedRule === 'string' ? decision.matchedRule.slice(0, 8192) : null;
141
+ projection.crawlability_provenance.robots_observed_at = observedAt;
142
+ projection.crawlability_provenance.robots_observation_id = latest.id;
143
+ return projection;
144
+ }
File without changes
File without changes
@@ -1,29 +0,0 @@
1
- ---
2
- name: content-defingerprinting
3
- description: Run the required invisible-character and text-artifact cleanup after humanizing public content, using this package's watermark scanner. Preserve citations, source evidence and meaningful text.
4
- ---
5
-
6
- # Content defingerprinting
7
-
8
- This is stage 2b of the content workflow: deterministic text cleanup after the separate
9
- editorial humanize pass. Read this skill's `scripts/watermarks/README.md` for
10
- scanner behavior and the distinction between authored prose and archived evidence.
11
-
12
- Run against the final authored files, including titles, descriptions, email/social
13
- copy and page text embedded in templates:
14
-
15
- ```bash
16
- python3 scripts/watermarks/wm-scan.py <authored-file> # relative to this skill's directory
17
- ```
18
-
19
- Inspect findings before changing them. For removable artifacts in authored prose,
20
- use `--fix` and rerun the scanner until it passes. Preserve the scanner's protections
21
- for archived evidence, sourced quotations, meaningful Unicode and load-bearing code
22
- literals; resolve those findings individually. Do not rewrite sources to clear a check.
23
-
24
- For content composed directly in a browser, first save the final authored text to a
25
- local UTF-8 draft, scan that file, and paste the checked version. Repeat if text changes.
26
- This step does not replace the humanizer or alter source/provenance records.
27
-
28
- Then use `internal-linking-optimizer`. Record the scanned paths and outcome with the
29
- content's normal review notes. Do not call an unrun scan passed.