@nebulacomponents/citable 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +12 -1
  3. package/dist/universal/.agents/skills/citable/SKILL.md +11 -2
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/manifest.json +15 -12
  6. package/dist/universal/.agents/skills/citable/schemas/action-plan.schema.json +38 -1
  7. package/dist/universal/.agents/skills/citable/schemas/audit-coverage.schema.json +118 -0
  8. package/dist/universal/.agents/skills/citable/schemas/collection-result.schema.json +35 -0
  9. package/dist/universal/.agents/skills/citable/schemas/config.schema.json +2 -0
  10. package/dist/universal/.agents/skills/citable/schemas/cro-report.schema.json +8 -1
  11. package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +105 -2
  12. package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +12 -1
  13. package/dist/universal/.agents/skills/citable/schemas/run.schema.json +15 -2
  14. package/dist/universal/.agents/skills/citable/schemas/search-report.schema.json +8 -1
  15. package/dist/universal/.agents/skills/citable/schemas/summary.schema.json +41 -0
  16. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  17. package/dist/universal/.claude/skills/citable/SKILL.md +11 -2
  18. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  19. package/dist/universal/.claude/skills/citable/manifest.json +15 -12
  20. package/dist/universal/.claude/skills/citable/schemas/action-plan.schema.json +38 -1
  21. package/dist/universal/.claude/skills/citable/schemas/audit-coverage.schema.json +118 -0
  22. package/dist/universal/.claude/skills/citable/schemas/collection-result.schema.json +35 -0
  23. package/dist/universal/.claude/skills/citable/schemas/config.schema.json +2 -0
  24. package/dist/universal/.claude/skills/citable/schemas/cro-report.schema.json +8 -1
  25. package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +105 -2
  26. package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +12 -1
  27. package/dist/universal/.claude/skills/citable/schemas/run.schema.json +15 -2
  28. package/dist/universal/.claude/skills/citable/schemas/search-report.schema.json +8 -1
  29. package/dist/universal/.claude/skills/citable/schemas/summary.schema.json +41 -0
  30. package/dist/universal/.cursor/skills/citable/SKILL.md +11 -2
  31. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  32. package/dist/universal/.cursor/skills/citable/manifest.json +15 -12
  33. package/dist/universal/.cursor/skills/citable/schemas/action-plan.schema.json +38 -1
  34. package/dist/universal/.cursor/skills/citable/schemas/audit-coverage.schema.json +118 -0
  35. package/dist/universal/.cursor/skills/citable/schemas/collection-result.schema.json +35 -0
  36. package/dist/universal/.cursor/skills/citable/schemas/config.schema.json +2 -0
  37. package/dist/universal/.cursor/skills/citable/schemas/cro-report.schema.json +8 -1
  38. package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +105 -2
  39. package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +12 -1
  40. package/dist/universal/.cursor/skills/citable/schemas/run.schema.json +15 -2
  41. package/dist/universal/.cursor/skills/citable/schemas/search-report.schema.json +8 -1
  42. package/dist/universal/.cursor/skills/citable/schemas/summary.schema.json +41 -0
  43. package/dist/universal/.gemini/skills/citable/SKILL.md +11 -2
  44. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  45. package/dist/universal/.gemini/skills/citable/manifest.json +15 -12
  46. package/dist/universal/.gemini/skills/citable/schemas/action-plan.schema.json +38 -1
  47. package/dist/universal/.gemini/skills/citable/schemas/audit-coverage.schema.json +118 -0
  48. package/dist/universal/.gemini/skills/citable/schemas/collection-result.schema.json +35 -0
  49. package/dist/universal/.gemini/skills/citable/schemas/config.schema.json +2 -0
  50. package/dist/universal/.gemini/skills/citable/schemas/cro-report.schema.json +8 -1
  51. package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +105 -2
  52. package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +12 -1
  53. package/dist/universal/.gemini/skills/citable/schemas/run.schema.json +15 -2
  54. package/dist/universal/.gemini/skills/citable/schemas/search-report.schema.json +8 -1
  55. package/dist/universal/.gemini/skills/citable/schemas/summary.schema.json +41 -0
  56. package/dist/universal/.github/skills/citable/SKILL.md +11 -2
  57. package/dist/universal/.github/skills/citable/VERSION +1 -1
  58. package/dist/universal/.github/skills/citable/manifest.json +15 -12
  59. package/dist/universal/.github/skills/citable/schemas/action-plan.schema.json +38 -1
  60. package/dist/universal/.github/skills/citable/schemas/audit-coverage.schema.json +118 -0
  61. package/dist/universal/.github/skills/citable/schemas/collection-result.schema.json +35 -0
  62. package/dist/universal/.github/skills/citable/schemas/config.schema.json +2 -0
  63. package/dist/universal/.github/skills/citable/schemas/cro-report.schema.json +8 -1
  64. package/dist/universal/.github/skills/citable/schemas/finding.schema.json +105 -2
  65. package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +12 -1
  66. package/dist/universal/.github/skills/citable/schemas/run.schema.json +15 -2
  67. package/dist/universal/.github/skills/citable/schemas/search-report.schema.json +8 -1
  68. package/dist/universal/.github/skills/citable/schemas/summary.schema.json +41 -0
  69. package/dist/universal/.kiro/skills/citable/SKILL.md +11 -2
  70. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  71. package/dist/universal/.kiro/skills/citable/manifest.json +15 -12
  72. package/dist/universal/.kiro/skills/citable/schemas/action-plan.schema.json +38 -1
  73. package/dist/universal/.kiro/skills/citable/schemas/audit-coverage.schema.json +118 -0
  74. package/dist/universal/.kiro/skills/citable/schemas/collection-result.schema.json +35 -0
  75. package/dist/universal/.kiro/skills/citable/schemas/config.schema.json +2 -0
  76. package/dist/universal/.kiro/skills/citable/schemas/cro-report.schema.json +8 -1
  77. package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +105 -2
  78. package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +12 -1
  79. package/dist/universal/.kiro/skills/citable/schemas/run.schema.json +15 -2
  80. package/dist/universal/.kiro/skills/citable/schemas/search-report.schema.json +8 -1
  81. package/dist/universal/.kiro/skills/citable/schemas/summary.schema.json +41 -0
  82. package/dist/universal/.opencode/skills/citable/SKILL.md +11 -2
  83. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  84. package/dist/universal/.opencode/skills/citable/manifest.json +15 -12
  85. package/dist/universal/.opencode/skills/citable/schemas/action-plan.schema.json +38 -1
  86. package/dist/universal/.opencode/skills/citable/schemas/audit-coverage.schema.json +118 -0
  87. package/dist/universal/.opencode/skills/citable/schemas/collection-result.schema.json +35 -0
  88. package/dist/universal/.opencode/skills/citable/schemas/config.schema.json +2 -0
  89. package/dist/universal/.opencode/skills/citable/schemas/cro-report.schema.json +8 -1
  90. package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +105 -2
  91. package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +12 -1
  92. package/dist/universal/.opencode/skills/citable/schemas/run.schema.json +15 -2
  93. package/dist/universal/.opencode/skills/citable/schemas/search-report.schema.json +8 -1
  94. package/dist/universal/.opencode/skills/citable/schemas/summary.schema.json +41 -0
  95. package/dist/universal/.pi/agent/skills/citable/SKILL.md +11 -2
  96. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  97. package/dist/universal/.pi/agent/skills/citable/manifest.json +15 -12
  98. package/dist/universal/.pi/agent/skills/citable/schemas/action-plan.schema.json +38 -1
  99. package/dist/universal/.pi/agent/skills/citable/schemas/audit-coverage.schema.json +118 -0
  100. package/dist/universal/.pi/agent/skills/citable/schemas/collection-result.schema.json +35 -0
  101. package/dist/universal/.pi/agent/skills/citable/schemas/config.schema.json +2 -0
  102. package/dist/universal/.pi/agent/skills/citable/schemas/cro-report.schema.json +8 -1
  103. package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +105 -2
  104. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +12 -1
  105. package/dist/universal/.pi/agent/skills/citable/schemas/run.schema.json +15 -2
  106. package/dist/universal/.pi/agent/skills/citable/schemas/search-report.schema.json +8 -1
  107. package/dist/universal/.pi/agent/skills/citable/schemas/summary.schema.json +41 -0
  108. package/dist/universal/.qoder/skills/citable/SKILL.md +11 -2
  109. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  110. package/dist/universal/.qoder/skills/citable/manifest.json +15 -12
  111. package/dist/universal/.qoder/skills/citable/schemas/action-plan.schema.json +38 -1
  112. package/dist/universal/.qoder/skills/citable/schemas/audit-coverage.schema.json +118 -0
  113. package/dist/universal/.qoder/skills/citable/schemas/collection-result.schema.json +35 -0
  114. package/dist/universal/.qoder/skills/citable/schemas/config.schema.json +2 -0
  115. package/dist/universal/.qoder/skills/citable/schemas/cro-report.schema.json +8 -1
  116. package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +105 -2
  117. package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +12 -1
  118. package/dist/universal/.qoder/skills/citable/schemas/run.schema.json +15 -2
  119. package/dist/universal/.qoder/skills/citable/schemas/search-report.schema.json +8 -1
  120. package/dist/universal/.qoder/skills/citable/schemas/summary.schema.json +41 -0
  121. package/dist/universal/.rovodev/skills/citable/SKILL.md +11 -2
  122. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  123. package/dist/universal/.rovodev/skills/citable/manifest.json +15 -12
  124. package/dist/universal/.rovodev/skills/citable/schemas/action-plan.schema.json +38 -1
  125. package/dist/universal/.rovodev/skills/citable/schemas/audit-coverage.schema.json +118 -0
  126. package/dist/universal/.rovodev/skills/citable/schemas/collection-result.schema.json +35 -0
  127. package/dist/universal/.rovodev/skills/citable/schemas/config.schema.json +2 -0
  128. package/dist/universal/.rovodev/skills/citable/schemas/cro-report.schema.json +8 -1
  129. package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +105 -2
  130. package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +12 -1
  131. package/dist/universal/.rovodev/skills/citable/schemas/run.schema.json +15 -2
  132. package/dist/universal/.rovodev/skills/citable/schemas/search-report.schema.json +8 -1
  133. package/dist/universal/.rovodev/skills/citable/schemas/summary.schema.json +41 -0
  134. package/dist/universal/.trae/skills/citable/SKILL.md +11 -2
  135. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  136. package/dist/universal/.trae/skills/citable/manifest.json +15 -12
  137. package/dist/universal/.trae/skills/citable/schemas/action-plan.schema.json +38 -1
  138. package/dist/universal/.trae/skills/citable/schemas/audit-coverage.schema.json +118 -0
  139. package/dist/universal/.trae/skills/citable/schemas/collection-result.schema.json +35 -0
  140. package/dist/universal/.trae/skills/citable/schemas/config.schema.json +2 -0
  141. package/dist/universal/.trae/skills/citable/schemas/cro-report.schema.json +8 -1
  142. package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +105 -2
  143. package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +12 -1
  144. package/dist/universal/.trae/skills/citable/schemas/run.schema.json +15 -2
  145. package/dist/universal/.trae/skills/citable/schemas/search-report.schema.json +8 -1
  146. package/dist/universal/.trae/skills/citable/schemas/summary.schema.json +41 -0
  147. package/dist/universal/.trae-cn/skills/citable/SKILL.md +11 -2
  148. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  149. package/dist/universal/.trae-cn/skills/citable/manifest.json +15 -12
  150. package/dist/universal/.trae-cn/skills/citable/schemas/action-plan.schema.json +38 -1
  151. package/dist/universal/.trae-cn/skills/citable/schemas/audit-coverage.schema.json +118 -0
  152. package/dist/universal/.trae-cn/skills/citable/schemas/collection-result.schema.json +35 -0
  153. package/dist/universal/.trae-cn/skills/citable/schemas/config.schema.json +2 -0
  154. package/dist/universal/.trae-cn/skills/citable/schemas/cro-report.schema.json +8 -1
  155. package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +105 -2
  156. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +12 -1
  157. package/dist/universal/.trae-cn/skills/citable/schemas/run.schema.json +15 -2
  158. package/dist/universal/.trae-cn/skills/citable/schemas/search-report.schema.json +8 -1
  159. package/dist/universal/.trae-cn/skills/citable/schemas/summary.schema.json +41 -0
  160. package/dist/universal/manifest.json +184 -148
  161. package/package.json +1 -1
  162. package/schemas/action-plan.schema.json +38 -1
  163. package/schemas/audit-coverage.schema.json +118 -0
  164. package/schemas/collection-result.schema.json +35 -0
  165. package/schemas/config.schema.json +2 -0
  166. package/schemas/cro-report.schema.json +8 -1
  167. package/schemas/finding.schema.json +105 -2
  168. package/schemas/remediation-verification.schema.json +12 -1
  169. package/schemas/run.schema.json +15 -2
  170. package/schemas/search-report.schema.json +8 -1
  171. package/schemas/summary.schema.json +41 -0
  172. package/skill/SKILL.md +11 -2
  173. package/src/acceptance/reproducibility.js +5 -25
  174. package/src/analysis/croRoadmap.js +3 -3
  175. package/src/analysis/strategicRoadmap.js +3 -3
  176. package/src/artifacts/interchange.js +2 -6
  177. package/src/cli/index.js +15 -3
  178. package/src/commands/actionPlan.js +46 -5
  179. package/src/commands/applyRemediation.js +5 -5
  180. package/src/commands/audit.js +75 -8
  181. package/src/commands/ciWorkflow.js +7 -2
  182. package/src/commands/compareSnapshots.js +155 -47
  183. package/src/commands/connect.js +24 -4
  184. package/src/commands/context.js +41 -2
  185. package/src/commands/croSuite.js +51 -13
  186. package/src/commands/delivery.js +7 -7
  187. package/src/commands/governance.js +9 -10
  188. package/src/commands/implementationKit.js +2 -1
  189. package/src/commands/init.js +8 -1
  190. package/src/commands/previewCro.js +1 -1
  191. package/src/commands/prioritize.js +3 -2
  192. package/src/commands/reviews.js +31 -5
  193. package/src/commands/roadmapCmd.js +7 -13
  194. package/src/commands/sweep.js +15 -2
  195. package/src/commands/verifyRemediation.js +94 -19
  196. package/src/connectors/collectionResult.js +80 -0
  197. package/src/connectors/ga4.js +46 -11
  198. package/src/connectors/gsc.js +41 -6
  199. package/src/connectors/index.js +1 -1
  200. package/src/connectors/webflow.js +140 -10
  201. package/src/connectors/wordpress.js +92 -8
  202. package/src/crawler/fetch.js +220 -24
  203. package/src/crawler/resourceValidity.js +119 -0
  204. package/src/crawler/sitemap.js +435 -16
  205. package/src/crawler/sitemapCollector.js +364 -0
  206. package/src/crawler/urlIdentity.js +97 -0
  207. package/src/detectors/agent.js +11 -0
  208. package/src/detectors/ans.js +16 -1
  209. package/src/detectors/arch.js +6 -0
  210. package/src/detectors/claim.js +9 -0
  211. package/src/detectors/crawl.js +7 -0
  212. package/src/detectors/cro.js +21 -0
  213. package/src/detectors/cwv.js +4 -0
  214. package/src/detectors/entity.js +7 -0
  215. package/src/detectors/evd.js +9 -0
  216. package/src/detectors/framework.js +28 -2
  217. package/src/detectors/geoReco.js +16 -0
  218. package/src/detectors/hreflang.js +3 -0
  219. package/src/detectors/lifeMeas.js +9 -0
  220. package/src/detectors/link.js +6 -1
  221. package/src/detectors/page.js +11 -0
  222. package/src/detectors/schemaData.js +16 -1
  223. package/src/detectors/tech.js +34 -3
  224. package/src/evidence/coverage.js +448 -0
  225. package/src/evidence/determination.js +157 -0
  226. package/src/evidence/downstream.js +110 -0
  227. package/src/evidence/hashes.js +109 -0
  228. package/src/evidence/run.js +68 -8
  229. package/src/extractor/page.js +29 -2
  230. package/src/extractor/site.js +298 -50
  231. package/src/reporting/executiveCroReport.js +36 -13
  232. package/src/reporting/executiveExport.js +45 -16
  233. package/src/reporting/executiveSearchReport.js +56 -22
  234. package/src/reporting/report.js +56 -9
  235. package/src/shared/epistemicLanguage.js +86 -0
  236. package/src/shared/evidenceSourceResolver.js +48 -17
  237. package/src/shared/htmlEscape.js +8 -4
  238. package/src/shared/runPackageVerifier.js +99 -8
  239. package/src/shared/verifiedRunLoader.js +129 -0
@@ -1,5 +1,6 @@
1
1
  import { providerRequest } from './http.js';
2
2
  import { sha256, nowIso } from '../shared/io.js';
3
+ import { collectionResult, errorMessage, paginationBoundary } from './collectionResult.js';
3
4
 
4
5
  const METRICS = {
5
6
  published_posts: { unit: 'count', value_type: 'integer' },
@@ -25,6 +26,53 @@ function computeCmsPayloadHash(payload) {
25
26
  return sha256(JSON.stringify(canonical));
26
27
  }
27
28
 
29
+ async function collectWordPressEndpoint(base, endpoint, context) {
30
+ const maxPages = paginationBoundary(context);
31
+ const items = [];
32
+ const limitations = [];
33
+ const errors = [];
34
+ let providerTotal = null;
35
+ let totalPages = null;
36
+ let page = 1;
37
+ let pagesRequested = 0;
38
+ let pagesRetrieved = 0;
39
+ let continuationState = null;
40
+ let providerCompleteness = 'declared';
41
+ let connectorError = null;
42
+
43
+ while (page <= maxPages) {
44
+ pagesRequested += 1;
45
+ const suffix = page === 1 ? '?per_page=100&status=publish' : `?per_page=100&status=publish&page=${page}`;
46
+ try {
47
+ const response = await providerRequest(`${base}/wp-json/wp/v2/${endpoint}${suffix}`, { ...context, rawResponse: true });
48
+ const pageItems = await response.json();
49
+ if (!Array.isArray(pageItems)) throw new Error(`WordPress ${endpoint} page ${page} did not return an array`);
50
+ pagesRetrieved += 1;
51
+ items.push(...pageItems.map((item) => ({ ...item, _collection_endpoint: endpoint })));
52
+ const headerTotal = Number(response.headers.get('x-wp-total'));
53
+ const headerPages = Number(response.headers.get('x-wp-totalpages'));
54
+ if (Number.isInteger(headerTotal) && headerTotal >= 0) providerTotal = headerTotal;
55
+ if (Number.isInteger(headerPages) && headerPages >= 1) totalPages = headerPages;
56
+ if (!totalPages) providerCompleteness = 'unknown';
57
+ if (pageItems.length === 0 || (totalPages && page >= totalPages)) break;
58
+ if (!totalPages && pageItems.length >= 100) {
59
+ continuationState = { endpoint, page: page + 1 };
60
+ limitations.push('WordPress did not report total pages for a full page of results.');
61
+ break;
62
+ }
63
+ page += 1;
64
+ } catch (error) {
65
+ connectorError ||= error;
66
+ errors.push(`${endpoint} page ${page}: ${errorMessage(error)}`);
67
+ continuationState = { endpoint, page };
68
+ break;
69
+ }
70
+ }
71
+ if (!continuationState && totalPages && page < totalPages) continuationState = { endpoint, page };
72
+ if (continuationState && page > maxPages) limitations.push(`WordPress ${endpoint} collection reached the ${maxPages}-page boundary.`);
73
+ return { items, providerTotal, providerCompleteness, connectorError, pagesRequested, pagesRetrieved, continuationState, limitations, errors };
74
+ }
75
+
28
76
  export const wordpressConnector = {
29
77
  provider: 'wordpress',
30
78
  defaultCredentialEnv: 'WORDPRESS_AUTH_TOKEN',
@@ -78,35 +126,71 @@ export const wordpressConnector = {
78
126
  const rows = [];
79
127
  const metricNames = new Set(metrics.map((m) => m.external_name));
80
128
 
129
+ const collections = [];
130
+ const collectionErrors = [];
131
+ const collectionLimitations = [];
132
+ let pagesRequested = 0;
133
+ let pagesRetrieved = 0;
134
+ let continuationState = null;
135
+ let providerTotal = 0;
136
+ let providerTotalKnown = true;
137
+ let connectorError = null;
138
+
81
139
  if (metricNames.has('published_posts')) {
82
- const posts = await providerRequest(`${base}/wp-json/wp/v2/posts?per_page=100&status=publish`, context);
140
+ const postsResult = await collectWordPressEndpoint(base, 'posts', context);
141
+ collections.push(...postsResult.items);
142
+ collectionErrors.push(...postsResult.errors);
143
+ collectionLimitations.push(...postsResult.limitations);
144
+ pagesRequested += postsResult.pagesRequested;
145
+ pagesRetrieved += postsResult.pagesRetrieved;
146
+ continuationState ||= postsResult.continuationState;
147
+ connectorError ||= postsResult.connectorError;
148
+ if (postsResult.providerCompleteness === 'unknown') providerTotalKnown = false;
149
+ if (postsResult.providerTotal === null) providerTotalKnown = false; else providerTotal += postsResult.providerTotal;
83
150
  const postMetric = metrics.find((m) => m.external_name === 'published_posts');
84
151
  rows.push({
85
152
  metric: postMetric,
86
- value: Array.isArray(posts) ? posts.length : 0,
153
+ value: postsResult.items.length,
87
154
  dimensions: { date: endDate },
88
155
  observed_at: `${endDate}T00:00:00.000Z`,
89
156
  });
90
157
  }
91
158
 
92
159
  if (metricNames.has('published_pages')) {
93
- const pages = await providerRequest(`${base}/wp-json/wp/v2/pages?per_page=100&status=publish`, context);
160
+ const pagesResult = await collectWordPressEndpoint(base, 'pages', context);
161
+ collections.push(...pagesResult.items);
162
+ collectionErrors.push(...pagesResult.errors);
163
+ collectionLimitations.push(...pagesResult.limitations);
164
+ pagesRequested += pagesResult.pagesRequested;
165
+ pagesRetrieved += pagesResult.pagesRetrieved;
166
+ continuationState ||= pagesResult.continuationState;
167
+ connectorError ||= pagesResult.connectorError;
168
+ if (pagesResult.providerCompleteness === 'unknown') providerTotalKnown = false;
169
+ if (pagesResult.providerTotal === null) providerTotalKnown = false; else providerTotal += pagesResult.providerTotal;
94
170
  const pageMetric = metrics.find((m) => m.external_name === 'published_pages');
95
171
  rows.push({
96
172
  metric: pageMetric,
97
- value: Array.isArray(pages) ? pages.length : 0,
173
+ value: pagesResult.items.length,
98
174
  dimensions: { date: endDate },
99
175
  observed_at: `${endDate}T00:00:00.000Z`,
100
176
  });
101
177
  }
102
178
 
179
+ const collection = collectionResult({
180
+ items: collections,
181
+ paginationState: { pages_requested: pagesRequested, pages_retrieved: pagesRetrieved, boundary: { max_pages: paginationBoundary(context) } },
182
+ providerReportedTotal: providerTotalKnown ? providerTotal : null,
183
+ continuationState,
184
+ providerCompleteness: providerTotalKnown ? 'declared' : 'unknown',
185
+ limitations: [...collectionLimitations, 'WordPress REST API collection is bounded to accessible posts and pages.', 'Custom post types outside standard posts and pages require dedicated endpoint parameters.'],
186
+ errors: collectionErrors,
187
+ });
103
188
  return {
104
189
  rows,
105
190
  cursor: endDate,
106
- limitations: [
107
- 'WordPress REST API collection is bounded to accessible posts and pages.',
108
- 'Custom post types outside standard posts and pages require dedicated endpoint parameters.',
109
- ],
191
+ limitations: collection.limitations,
192
+ collection,
193
+ ...(connectorError ? { connectorError } : {}),
110
194
  };
111
195
  },
112
196
 
@@ -1,7 +1,12 @@
1
1
  import dns from 'node:dns';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
2
4
  import net from 'node:net';
5
+ import { Readable } from 'node:stream';
6
+ import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
3
7
 
4
8
  const blockedAddresses = new net.BlockList();
9
+ const GUARDED_LOOKUP_TRANSPORT = Symbol('citable.guardedLookupTransport');
5
10
  for (const [network, prefix] of [
6
11
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
7
12
  ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
@@ -22,18 +27,89 @@ function isBlockedAddress(address) {
22
27
  }
23
28
 
24
29
  export async function validatePublicUrl(url, { lookup = dns.promises.lookup } = {}) {
30
+ const parsed = parseNetworkUrl(url);
31
+ await resolvePublicAddresses(parsed.hostname, lookup);
32
+ return parsed;
33
+ }
34
+
35
+ function parseNetworkUrl(url) {
25
36
  const parsed = new URL(url);
26
37
  if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(`unsupported URL protocol: ${parsed.protocol}`);
27
38
  if (parsed.username || parsed.password) throw new Error('URL credentials are not permitted');
28
- const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
39
+ return parsed;
40
+ }
41
+
42
+ async function resolvePublicAddresses(rawHostname, lookup, options = {}) {
43
+ const hostname = rawHostname.replace(/^\[|\]$/g, '');
29
44
  const literalFamily = net.isIP(hostname);
30
45
  const addresses = literalFamily
31
46
  ? [{ address: hostname, family: literalFamily }]
32
- : await lookup(hostname, { all: true, verbatim: true });
47
+ : await lookup(hostname, { ...options, all: true, verbatim: true });
33
48
  if (!addresses.length || addresses.some(({ address }) => isBlockedAddress(address))) {
34
49
  throw new Error(`refusing private, loopback, or non-public destination: ${hostname}`);
35
50
  }
36
- return parsed;
51
+ return addresses;
52
+ }
53
+
54
+ function createGuardedLookup(lookup) {
55
+ return (hostname, options, callback) => {
56
+ const normalizedOptions = typeof options === 'number' ? { family: options } : (options ?? {});
57
+ resolvePublicAddresses(hostname, lookup, { family: normalizedOptions.family || 0 })
58
+ .then((addresses) => {
59
+ if (normalizedOptions.all) callback(null, addresses);
60
+ else callback(null, addresses[0].address, addresses[0].family);
61
+ })
62
+ .catch((error) => callback(error));
63
+ };
64
+ }
65
+
66
+ function fetchWithGuardedLookup(url, { headers, signal, lookup }) {
67
+ const parsed = new URL(url);
68
+ const transport = parsed.protocol === 'https:' ? https : http;
69
+ return new Promise((resolve, reject) => {
70
+ const request = transport.request(parsed, {
71
+ method: 'GET',
72
+ headers,
73
+ lookup,
74
+ servername: parsed.protocol === 'https:' ? parsed.hostname : undefined,
75
+ signal,
76
+ }, (response) => {
77
+ const responseHeaders = new Headers();
78
+ for (const [name, value] of Object.entries(response.headers)) {
79
+ if (Array.isArray(value)) for (const item of value) responseHeaders.append(name, item);
80
+ else if (value !== undefined) responseHeaders.set(name, value);
81
+ }
82
+ const encoding = String(response.headers['content-encoding'] ?? '').toLowerCase();
83
+ let body = response;
84
+ if (encoding === 'gzip' || encoding === 'x-gzip') body = response.pipe(createGunzip());
85
+ else if (encoding === 'deflate') body = response.pipe(createInflate());
86
+ else if (encoding === 'br') body = response.pipe(createBrotliDecompress());
87
+ const noBody = response.statusCode === 204 || response.statusCode === 205 || response.statusCode === 304;
88
+ resolve(new Response(noBody ? null : Readable.toWeb(body), {
89
+ status: response.statusCode,
90
+ statusText: response.statusMessage,
91
+ headers: responseHeaders,
92
+ }));
93
+ });
94
+ request.on('error', reject);
95
+ request.end();
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Declare that a custom transport uses the `lookup` callback passed by fetchUrl
101
+ * for its actual socket connection. Undeclared transports are rejected before
102
+ * execution because URL preflight alone does not prevent DNS rebinding.
103
+ */
104
+ export function declareGuardedLookupTransport(fetchImpl) {
105
+ if (typeof fetchImpl !== 'function') throw new TypeError('custom transport must be a function');
106
+ Object.defineProperty(fetchImpl, GUARDED_LOOKUP_TRANSPORT, {
107
+ value: true,
108
+ enumerable: false,
109
+ configurable: false,
110
+ writable: false,
111
+ });
112
+ return fetchImpl;
37
113
  }
38
114
 
39
115
  /** Enforce the public-network policy for each Playwright request. */
@@ -62,12 +138,12 @@ export async function handlePublicBrowserRoute(route, { lookup = dns.promises.lo
62
138
  }
63
139
  }
64
140
 
65
- async function readBodyLimited(res, maxBodyBytes) {
141
+ async function readBodyLimited(res, maxBodyBytes, responseType = 'text') {
66
142
  const declared = Number(res.headers.get('content-length'));
67
143
  if (Number.isFinite(declared) && declared > maxBodyBytes) {
68
- throw new Error(`response body exceeds ${maxBodyBytes} bytes`);
144
+ throw bodyLimitError(maxBodyBytes);
69
145
  }
70
- if (!res.body) return '';
146
+ if (!res.body) return { body: responseType === 'buffer' ? Buffer.alloc(0) : '', bodyBytes: Buffer.alloc(0) };
71
147
  const reader = res.body.getReader();
72
148
  const chunks = [];
73
149
  let total = 0;
@@ -76,7 +152,7 @@ async function readBodyLimited(res, maxBodyBytes) {
76
152
  const { done, value } = await reader.read();
77
153
  if (done) break;
78
154
  total += value.byteLength;
79
- if (total > maxBodyBytes) throw new Error(`response body exceeds ${maxBodyBytes} bytes`);
155
+ if (total > maxBodyBytes) throw bodyLimitError(maxBodyBytes);
80
156
  chunks.push(value);
81
157
  }
82
158
  } finally {
@@ -85,42 +161,127 @@ async function readBodyLimited(res, maxBodyBytes) {
85
161
  const bytes = new Uint8Array(total);
86
162
  let offset = 0;
87
163
  for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
88
- return new TextDecoder().decode(bytes);
164
+ const bodyBytes = Buffer.from(bytes);
165
+ return { body: responseType === 'buffer' ? bodyBytes : new TextDecoder().decode(bytes), bodyBytes };
166
+ }
167
+
168
+ function bodyLimitError(maxBodyBytes) {
169
+ const error = new Error(`response body exceeds ${maxBodyBytes} bytes`);
170
+ error.code = 'FETCH_BODY_LIMIT';
171
+ return error;
172
+ }
173
+
174
+ function timeoutError(timeoutMs) {
175
+ const error = new Error(`request timeout after ${timeoutMs}ms`);
176
+ error.code = 'FETCH_TIMEOUT';
177
+ return error;
178
+ }
179
+
180
+ function errorCode(error) {
181
+ const code = typeof error?.code === 'string' && error.code ? error.code : null;
182
+ if (code && /^[A-Z][A-Z0-9_]{0,63}$/.test(code)) return code;
183
+ if (error?.name === 'AbortError') return 'FETCH_ABORTED';
184
+ return 'FETCH_ERROR';
185
+ }
186
+
187
+ function attemptUrl(url) {
188
+ const parsed = new URL(url);
189
+ parsed.hash = '';
190
+ if (parsed.search) parsed.search = '?[REDACTED]';
191
+ return parsed.href;
192
+ }
193
+
194
+ function isRetryableResponseStatus(status) {
195
+ return status === 500 || status === 502 || status === 503 || status === 504;
196
+ }
197
+
198
+ function codedFetchError(message, code) {
199
+ const error = new Error(message);
200
+ error.code = code;
201
+ return error;
202
+ }
203
+
204
+ function attachAttempts(error, attempts) {
205
+ const target = error instanceof Error ? error : new Error('request failed');
206
+ Object.defineProperty(target, 'attempts', {
207
+ value: attempts.map((attempt) => ({ ...attempt })),
208
+ enumerable: true,
209
+ configurable: true,
210
+ });
211
+ return target;
89
212
  }
90
213
 
91
214
  /** Fetch a public URL with bounded retries, redirects, time, and response size. */
92
215
  export async function fetchUrl(url, {
93
216
  userAgent = 'CitableAudit/0.1', maxRedirects = 10, timeoutMs = 20000,
94
217
  maxRetries = 3, retryDelayMs = 1000, maxBodyBytes = 5 * 1024 * 1024,
95
- fetchImpl = globalThis.fetch, lookup = dns.promises.lookup,
218
+ responseType = 'text',
219
+ fetchImpl, lookup = dns.promises.lookup,
220
+ allowUnsafeCustomTransportForTest = false,
96
221
  } = {}) {
97
- const requested = await validatePublicUrl(url, { lookup });
222
+ if (!['text', 'buffer'].includes(responseType)) throw new TypeError('responseType must be text or buffer');
223
+ const requested = parseNetworkUrl(url);
98
224
  const allowedOrigin = requested.origin;
99
225
  const chain = [];
226
+ const attempts = [];
227
+ let attemptNumber = 0;
100
228
  let current = requested.href;
229
+ const customFetch = fetchImpl && fetchImpl !== globalThis.fetch;
230
+ if (customFetch && allowUnsafeCustomTransportForTest !== true && fetchImpl[GUARDED_LOOKUP_TRANSPORT] !== true) {
231
+ throw new TypeError('custom transport must declare and implement the guarded lookup contract');
232
+ }
233
+ const effectiveFetch = customFetch ? fetchImpl : fetchWithGuardedLookup;
101
234
 
102
235
  for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
103
- await validatePublicUrl(current, { lookup });
104
236
  let res;
105
237
  let lastError;
238
+ let responseStartedAtMs = null;
106
239
  for (let attempt = 0; attempt < Math.max(1, maxRetries); attempt += 1) {
240
+ attemptNumber += 1;
241
+ const startedAtMs = performance.now();
242
+ try {
243
+ await validatePublicUrl(current, { lookup });
244
+ } catch (error) {
245
+ const endedAtMs = performance.now();
246
+ attempts.push({
247
+ attempt: attemptNumber, url: attemptUrl(current), startedAtMs, endedAtMs,
248
+ elapsedMs: Math.max(0, endedAtMs - startedAtMs), outcome: 'error', status: null,
249
+ errorCode: errorCode(error), retryDecision: 'stop',
250
+ });
251
+ throw attachAttempts(error, attempts);
252
+ }
107
253
  const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(new Error(`request timeout after ${timeoutMs}ms`)), timeoutMs);
254
+ const timer = setTimeout(() => controller.abort(timeoutError(timeoutMs)), timeoutMs);
109
255
  try {
110
- res = await fetchImpl(current, {
256
+ res = await effectiveFetch(current, {
111
257
  redirect: 'manual',
112
258
  headers: { 'user-agent': userAgent },
113
259
  signal: controller.signal,
260
+ lookup: createGuardedLookup(lookup),
114
261
  });
115
- if (res.status < 500 || attempt === Math.max(1, maxRetries) - 1) {
262
+ const retry = isRetryableResponseStatus(res.status) && attempt < Math.max(1, maxRetries) - 1;
263
+ if (!retry) {
116
264
  lastError = null;
265
+ responseStartedAtMs = startedAtMs;
117
266
  break;
118
267
  }
268
+ const endedAtMs = performance.now();
269
+ attempts.push({
270
+ attempt: attemptNumber, url: attemptUrl(current), startedAtMs, endedAtMs,
271
+ elapsedMs: Math.max(0, endedAtMs - startedAtMs), outcome: 'response',
272
+ status: res.status, errorCode: null, retryDecision: 'retry',
273
+ });
119
274
  await res.body?.cancel();
120
275
  lastError = new Error(`server returned ${res.status}`);
121
276
  } catch (err) {
122
- lastError = err;
123
- if (controller.signal.aborted) throw controller.signal.reason ?? err;
277
+ lastError = controller.signal.aborted ? (controller.signal.reason ?? err) : err;
278
+ const retry = attempt < Math.max(1, maxRetries) - 1;
279
+ const endedAtMs = performance.now();
280
+ attempts.push({
281
+ attempt: attemptNumber, url: attemptUrl(current), startedAtMs, endedAtMs,
282
+ elapsedMs: Math.max(0, endedAtMs - startedAtMs), outcome: 'error', status: null,
283
+ errorCode: errorCode(lastError), retryDecision: retry ? 'retry' : 'stop',
284
+ });
124
285
  } finally {
125
286
  clearTimeout(timer);
126
287
  }
@@ -129,21 +290,56 @@ export async function fetchUrl(url, {
129
290
  await new Promise((resolve) => setTimeout(resolve, delay));
130
291
  }
131
292
  }
132
- if (lastError) throw lastError;
293
+ if (lastError) throw attachAttempts(lastError, attempts);
133
294
 
134
295
  const headers = Object.fromEntries(res.headers.entries());
135
296
  if (res.status >= 300 && res.status < 400 && headers.location) {
297
+ const endedAtMs = performance.now();
298
+ attempts.push({
299
+ attempt: attemptNumber, url: attemptUrl(current), startedAtMs: responseStartedAtMs, endedAtMs,
300
+ elapsedMs: Math.max(0, endedAtMs - responseStartedAtMs), outcome: 'response',
301
+ status: res.status, errorCode: null,
302
+ retryDecision: redirects === maxRedirects ? 'stop' : 'redirect',
303
+ });
136
304
  if (redirects === maxRedirects) break;
137
- const next = new URL(headers.location, current);
138
- if (next.origin !== allowedOrigin) throw new Error(`redirect leaves audited origin: ${next.href}`);
139
- await validatePublicUrl(next.href, { lookup });
140
- chain.push({ url: current, status: res.status, location: headers.location });
305
+ let next;
306
+ try {
307
+ next = new URL(headers.location, current);
308
+ } catch {
309
+ throw attachAttempts(codedFetchError('invalid redirect location', 'FETCH_REDIRECT_INVALID'), attempts);
310
+ }
311
+ if (next.origin !== allowedOrigin) {
312
+ throw attachAttempts(codedFetchError('redirect leaves audited origin', 'FETCH_REDIRECT_ORIGIN'), attempts);
313
+ }
314
+ chain.push({ url: attemptUrl(current), status: res.status, location: attemptUrl(next.href) });
141
315
  current = next.href;
142
316
  await res.body?.cancel();
143
317
  continue;
144
318
  }
145
- const body = await readBodyLimited(res, maxBodyBytes);
146
- return { url: current, requestedUrl: url, status: res.status, headers, body, redirectChain: chain };
319
+ try {
320
+ const { body, bodyBytes } = await readBodyLimited(res, maxBodyBytes, responseType);
321
+ const endedAtMs = performance.now();
322
+ attempts.push({
323
+ attempt: attemptNumber, url: attemptUrl(current), startedAtMs: responseStartedAtMs, endedAtMs,
324
+ elapsedMs: Math.max(0, endedAtMs - responseStartedAtMs), outcome: 'response',
325
+ status: res.status, errorCode: null, retryDecision: res.status >= 500 ? 'stop' : 'complete',
326
+ });
327
+ return {
328
+ url: current, requestedUrl: url, status: res.status, headers, body, bodyBytes,
329
+ bodyComplete: true, redirectChain: chain, attempts,
330
+ };
331
+ } catch (error) {
332
+ const endedAtMs = performance.now();
333
+ attempts.push({
334
+ attempt: attemptNumber, url: attemptUrl(current), startedAtMs: responseStartedAtMs, endedAtMs,
335
+ elapsedMs: Math.max(0, endedAtMs - responseStartedAtMs), outcome: 'error', status: res.status,
336
+ errorCode: errorCode(error), retryDecision: 'stop',
337
+ });
338
+ throw attachAttempts(error, attempts);
339
+ }
147
340
  }
148
- throw new Error(`redirect chain exceeded ${maxRedirects} hops for ${url}`);
341
+ throw attachAttempts(codedFetchError(
342
+ `redirect chain exceeded ${maxRedirects} hops`,
343
+ 'FETCH_REDIRECT_LIMIT',
344
+ ), attempts);
149
345
  }
@@ -0,0 +1,119 @@
1
+ const PATTERN_SCAN_MAX_CHARS = 256 * 1024;
2
+ const MIN_SUBSTANTIVE_TEXT_CHARS = 40;
3
+
4
+ function headerValue(headers, name) {
5
+ if (headers instanceof Headers) return headers.get(name) || '';
6
+ const target = name.toLowerCase();
7
+ for (const [key, value] of Object.entries(headers || {})) {
8
+ if (key.toLowerCase() === target) return Array.isArray(value) ? value.join(', ') : String(value ?? '');
9
+ }
10
+ return '';
11
+ }
12
+
13
+ function bodyString(body) {
14
+ if (body == null) return '';
15
+ if (Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body).toString('utf8');
16
+ return String(body);
17
+ }
18
+
19
+ function bodyByteLength(body, converted) {
20
+ if (Buffer.isBuffer(body) || body instanceof Uint8Array) return body.byteLength;
21
+ return Buffer.byteLength(converted);
22
+ }
23
+
24
+ function visibleText(html) {
25
+ const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body\s*>/i);
26
+ return (bodyMatch?.[1] ?? html)
27
+ .replace(/<script\b[^>]*>[\s\S]*?(?:<\/script\s*>|$)/gi, ' ')
28
+ .replace(/<style\b[^>]*>[\s\S]*?(?:<\/style\s*>|$)/gi, ' ')
29
+ .replace(/<!--[^]*?-->/g, ' ')
30
+ .replace(/<[^>]*>/g, ' ')
31
+ .replace(/&(?:nbsp|#160);/gi, ' ')
32
+ .replace(/\s+/g, ' ')
33
+ .trim();
34
+ }
35
+
36
+ function hasUnclosedRawTextElement(sample) {
37
+ const lower = sample.toLowerCase();
38
+ return ['script', 'style'].some((tag) => lower.lastIndexOf(`<${tag}`) > lower.lastIndexOf(`</${tag}`));
39
+ }
40
+
41
+ function scanWallSignals(sample) {
42
+ // These patterns intentionally require bounded, multi-word phrases or a
43
+ // title/heading context. A bare number (including 404), "login", or
44
+ // "cookies" is not evidence that the retrieved resource is a wall.
45
+ const challenge = /(?:<\s*(?:title|h1)[^>]*>[^<]{0,80}(?:just a moment|attention required|security check)|\b(?:verify (?:that )?you are human|complete the security check|checking your browser)\b)/i.test(sample);
46
+ const login = /(?:<\s*(?:title|h1)[^>]*>[^<]{0,80}(?:sign in required|login required)|\b(?:please )?(?:sign in|log in) to continue\b)/i.test(sample);
47
+ const consent = /(?:<\s*(?:title|h1)[^>]*>[^<]{0,80}before you continue|\b(?:accept|manage) (?:our )?(?:cookies|consent) to continue\b)/i.test(sample);
48
+ const soft404 = /<\s*(?:title|h1)[^>]*>\s*(?:(?:error\s*)?404(?:\s|<|[-:])|page (?:not found|does not exist)|not found\s*(?:<|$))/i.test(sample)
49
+ || /\b(?:the page|page you (?:requested|are looking for)) (?:does not exist|could not be found|is no longer available)\b/i.test(sample);
50
+ return { challenge, login, consent, soft404 };
51
+ }
52
+
53
+ /**
54
+ * Conservatively classify whether a response is usable by the HTML page
55
+ * evaluator. This does not classify sitemap documents or prove that a page is
56
+ * indexable. Wall/soft-404 heuristics only scan the first 256 Ki characters and
57
+ * result in `indeterminate`, never a claim that the page is definitively absent.
58
+ */
59
+ export function classifyResource({
60
+ status,
61
+ headers = {},
62
+ body,
63
+ bodyComplete = true,
64
+ requestedUrl = null,
65
+ effectiveUrl = null,
66
+ } = {}) {
67
+ const rawBody = bodyString(body);
68
+ const contentTypeHeader = headerValue(headers, 'content-type');
69
+ const contentType = contentTypeHeader.split(';', 1)[0].trim().toLowerCase() || null;
70
+ const sample = rawBody.slice(0, PATTERN_SCAN_MAX_CHARS);
71
+ const walls = scanWallSignals(sample);
72
+ const text = visibleText(sample);
73
+ const patternScanTruncated = rawBody.length > PATTERN_SCAN_MAX_CHARS;
74
+ const rawTextElementTruncated = patternScanTruncated && hasUnclosedRawTextElement(sample);
75
+ const signals = {
76
+ http_status: Number.isInteger(status) ? status : null,
77
+ content_type: contentType,
78
+ html_mime: contentType === 'text/html' || contentType === 'application/xhtml+xml',
79
+ body_complete: bodyComplete === true,
80
+ body_bytes: bodyByteLength(body, rawBody),
81
+ substantive_text_chars: text.length,
82
+ pattern_scan_max_chars: PATTERN_SCAN_MAX_CHARS,
83
+ pattern_scan_truncated: patternScanTruncated,
84
+ raw_text_element_truncated: rawTextElementTruncated,
85
+ challenge_wall: walls.challenge,
86
+ login_wall: walls.login,
87
+ consent_wall: walls.consent,
88
+ soft_404: walls.soft404,
89
+ requested_url: requestedUrl ?? null,
90
+ effective_url: effectiveUrl ?? null,
91
+ };
92
+
93
+ if (!Number.isInteger(status)) return { state: 'indeterminate', reason_codes: ['http_status_missing'], signals };
94
+ if (status < 200 || status >= 400) return { state: 'invalid_resource', reason_codes: [`http_status_${status}`], signals };
95
+ if (status >= 300) return { state: 'indeterminate', reason_codes: ['redirect_response'], signals };
96
+ if (bodyComplete !== true) return { state: 'indeterminate', reason_codes: ['body_truncated'], signals };
97
+ if (contentType && !signals.html_mime) return { state: 'invalid_resource', reason_codes: ['unexpected_mime'], signals };
98
+ if (!rawBody.trim()) return { state: 'indeterminate', reason_codes: ['empty_body'], signals };
99
+ if (!contentType) return { state: 'indeterminate', reason_codes: ['content_type_missing'], signals };
100
+ if (rawTextElementTruncated) {
101
+ return { state: 'indeterminate', reason_codes: ['pattern_scan_incomplete_markup'], signals };
102
+ }
103
+
104
+ const wallReasons = [];
105
+ if (walls.challenge) wallReasons.push('challenge_wall_signal');
106
+ if (walls.login) wallReasons.push('login_wall_signal');
107
+ if (walls.consent) wallReasons.push('consent_wall_signal');
108
+ if (walls.soft404) wallReasons.push('soft_404_signal');
109
+ if (wallReasons.length) return { state: 'indeterminate', reason_codes: wallReasons, signals };
110
+ if (text.length < MIN_SUBSTANTIVE_TEXT_CHARS) {
111
+ return { state: 'indeterminate', reason_codes: ['insubstantial_html'], signals };
112
+ }
113
+ return { state: 'valid_resource', reason_codes: ['substantive_html'], signals };
114
+ }
115
+
116
+ export const RESOURCE_VALIDITY_LIMITS = Object.freeze({
117
+ pattern_scan_max_chars: PATTERN_SCAN_MAX_CHARS,
118
+ minimum_substantive_text_chars: MIN_SUBSTANTIVE_TEXT_CHARS,
119
+ });