@gobing-ai/knowledge-kit 0.0.37 → 0.1.1

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 (157) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/board/collect/index.css +654 -16
  3. package/dist/board/collect/index.js +191 -64
  4. package/dist/board/daily-voice/index.css +679 -12
  5. package/dist/board/daily-voice/index.js +185 -66
  6. package/dist/board/itc/index.css +679 -13
  7. package/dist/board/itc/index.js +188 -66
  8. package/dist/index.js +2864 -139
  9. package/package.json +3 -2
  10. package/plugins/generations/content-gen/dist/index.js +158 -12
  11. package/plugins/generations/core-facts-gen/dist/index.js +158 -12
  12. package/plugins/generations/daily-article-gen/dist/index.js +158 -12
  13. package/plugins/generations/dailynews-gen/dist/index.js +158 -12
  14. package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
  15. package/plugins/generations/gemini-tts/README.md +4 -3
  16. package/plugins/generations/gemini-tts/dist/index.js +158 -12
  17. package/plugins/generations/image-gen/dist/index.js +158 -12
  18. package/plugins/generations/news-report-gen/dist/index.js +158 -12
  19. package/plugins/generations/omni-voice-gen/README.md +15 -4
  20. package/plugins/generations/omni-voice-gen/profiles.json +1 -2
  21. package/plugins/generations/voice-gen/dist/index.js +158 -12
  22. package/plugins/generations/voxcpm-tts/README.md +21 -1
  23. package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
  24. package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
  25. package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
  26. package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
  27. package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
  28. package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
  29. package/plugins/ingestions/web-search/dist/index.js +158 -12
  30. package/plugins/kk/commands/workflow-run.md +56 -18
  31. package/plugins/kk/decisions/kk-itc.yaml +46 -0
  32. package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
  33. package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
  34. package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
  35. package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
  36. package/plugins/kk/plugin.json +1 -1
  37. package/plugins/kk/publish-presets/chinese-article.json +14 -0
  38. package/plugins/kk/publish-presets/technical-article.json +14 -0
  39. package/plugins/kk/references/smart-seo-attribution.md +85 -0
  40. package/plugins/kk/scripts/daily-stages.ts +42 -2
  41. package/plugins/kk/scripts/itc-stages.ts +25 -118
  42. package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
  43. package/plugins/kk/scripts/seo-decisions.ts +491 -0
  44. package/plugins/kk/scripts/seo-enrich.ts +728 -0
  45. package/plugins/kk/scripts/seo-facts.ts +610 -0
  46. package/plugins/kk/scripts/seo-fetch.ts +766 -0
  47. package/plugins/kk/scripts/seo-report.ts +460 -0
  48. package/plugins/kk/scripts/seo-rules.ts +1882 -0
  49. package/plugins/kk/scripts/seo-stages.ts +1044 -0
  50. package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
  51. package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
  52. package/plugins/kk/skills/smart-seo/references/method.md +126 -0
  53. package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
  54. package/plugins/kk/skills/smart-seo/references/operations.md +209 -0
  55. package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
  56. package/plugins/kk/workflows/feature-verification.yaml +28 -24
  57. package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +85 -17
  58. package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
  59. package/plugins/kk/workflows/kk-itc.yaml +208 -682
  60. package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
  61. package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
  62. package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
  63. package/plugins/publishings/bilibili-pub/package.json +20 -0
  64. package/plugins/publishings/bilibili-pub/plugin.json +12 -0
  65. package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
  66. package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
  67. package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
  68. package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
  69. package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
  70. package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
  71. package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
  72. package/plugins/publishings/cnblogs-pub/package.json +19 -0
  73. package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
  74. package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
  75. package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
  76. package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
  77. package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
  78. package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
  79. package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
  80. package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
  81. package/plugins/publishings/csdn-pub/package.json +19 -0
  82. package/plugins/publishings/csdn-pub/plugin.json +12 -0
  83. package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
  84. package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
  85. package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
  86. package/plugins/publishings/emdash-pub/dist/index.js +158 -12
  87. package/plugins/publishings/emdash-pub/plugin.json +22 -1
  88. package/plugins/publishings/infoq-pub/dist/index.js +160 -12
  89. package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
  90. package/plugins/publishings/infoq-pub/plugin.json +21 -0
  91. package/plugins/publishings/jike-pub/dist/index.js +21379 -0
  92. package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
  93. package/plugins/publishings/jike-pub/package.json +19 -0
  94. package/plugins/publishings/jike-pub/plugin.json +12 -0
  95. package/plugins/publishings/jike-pub/src/index.ts +800 -0
  96. package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
  97. package/plugins/publishings/jike-pub/tsconfig.json +4 -0
  98. package/plugins/publishings/juejin-pub/dist/index.js +160 -12
  99. package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
  100. package/plugins/publishings/juejin-pub/plugin.json +21 -0
  101. package/plugins/publishings/medium-pub/dist/index.js +158 -12
  102. package/plugins/publishings/medium-pub/plugin.json +22 -1
  103. package/plugins/publishings/podcast-pub/dist/index.js +158 -12
  104. package/plugins/publishings/qiita-pub/dist/index.js +158 -12
  105. package/plugins/publishings/qiita-pub/plugin.json +24 -1
  106. package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
  107. package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
  108. package/plugins/publishings/shipinhao-pub/package.json +19 -0
  109. package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
  110. package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
  111. package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
  112. package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
  113. package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
  114. package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
  115. package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
  116. package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
  117. package/plugins/publishings/sspai-pub/package.json +19 -0
  118. package/plugins/publishings/sspai-pub/plugin.json +12 -0
  119. package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
  120. package/plugins/publishings/sspai-pub/src/index.ts +167 -0
  121. package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
  122. package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
  123. package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
  124. package/plugins/publishings/substack-pub/dist/index.js +160 -12
  125. package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
  126. package/plugins/publishings/substack-pub/plugin.json +21 -0
  127. package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
  128. package/plugins/publishings/surfdash-pub/plugin.json +22 -1
  129. package/plugins/publishings/threads-pub/dist/index.js +158 -12
  130. package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
  131. package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
  132. package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
  133. package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
  134. package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
  135. package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
  136. package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
  137. package/plugins/publishings/weibo-pub/dist/index.js +160 -12
  138. package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
  139. package/plugins/publishings/weibo-pub/plugin.json +35 -0
  140. package/plugins/publishings/x-pub/dist/index.js +160 -12
  141. package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
  142. package/plugins/publishings/x-pub/plugin.json +40 -0
  143. package/plugins/publishings/xhs-pub/dist/index.js +160 -12
  144. package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
  145. package/plugins/publishings/xhs-pub/plugin.json +37 -0
  146. package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
  147. package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
  148. package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
  149. package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
  150. package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
  151. package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
  152. package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
  153. package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
  154. package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
  155. package/plugins/publishings/zenn-pub/dist/index.js +158 -12
  156. package/plugins/publishings/zenn-pub/plugin.json +22 -1
  157. package/plugins/generations/omni-voice-gen/voices/robin-news.wav +0 -0
@@ -0,0 +1,1882 @@
1
+ import { type CrawlArtifact, sha256 } from './seo-facts';
2
+
3
+ export type CheckStatus = 'pass' | 'fail' | 'unknown' | 'not-applicable';
4
+ export type RuleSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
5
+ export type RuleCategory = 'technical' | 'content' | 'crawl' | 'security' | 'geo';
6
+
7
+ export interface AssessmentRow {
8
+ ruleId: string;
9
+ subjectId: string;
10
+ subjectUrl: string;
11
+ status: CheckStatus;
12
+ category: RuleCategory;
13
+ severity: RuleSeverity;
14
+ evidenceRefs: string[];
15
+ sourceUrl: string;
16
+ sourceCheckedAt: string;
17
+ heuristic: boolean;
18
+ reason: string;
19
+ }
20
+
21
+ export interface ActionItem {
22
+ id: string;
23
+ ruleId: string;
24
+ title: string;
25
+ severity: RuleSeverity;
26
+ priority: 'P1' | 'P2' | 'P3' | 'review';
27
+ category: RuleCategory;
28
+ affectedPagesCount: number;
29
+ affectedSubjects: string[];
30
+ effort: 1 | 2 | 3 | 4;
31
+ heuristic: boolean;
32
+ description: string;
33
+ }
34
+
35
+ export interface RuleDefinition {
36
+ id: string;
37
+ category: RuleCategory;
38
+ severity: RuleSeverity;
39
+ disposition: 'native' | 'adapted' | 'informational' | 'deferred';
40
+ effort: 1 | 2 | 3 | 4;
41
+ heuristic: boolean;
42
+ sourceUrl: string;
43
+ sourceCheckedAt: string;
44
+ description: string;
45
+ }
46
+
47
+ export const VENDOR_RULES: Record<string, RuleDefinition> = {
48
+ robots_missing: {
49
+ id: 'robots_missing',
50
+ category: 'crawl',
51
+ severity: 'info',
52
+ disposition: 'informational',
53
+ effort: 1,
54
+ heuristic: false,
55
+ sourceUrl: 'https://www.rfc-editor.org/rfc/rfc9309',
56
+ sourceCheckedAt: '2026-10-14',
57
+ description: 'robots.txt presence is useful discovery, not a mandatory indexing requirement.',
58
+ },
59
+ robots_blocks_site: {
60
+ id: 'robots_blocks_site',
61
+ category: 'crawl',
62
+ severity: 'critical',
63
+ disposition: 'adapted',
64
+ effort: 1,
65
+ heuristic: false,
66
+ sourceUrl: 'https://www.rfc-editor.org/rfc/rfc9309',
67
+ sourceCheckedAt: '2026-10-14',
68
+ description: 'robots.txt disallows entire site or key search bots.',
69
+ },
70
+ sitemap_missing: {
71
+ id: 'sitemap_missing',
72
+ category: 'crawl',
73
+ severity: 'info',
74
+ disposition: 'informational',
75
+ effort: 1,
76
+ heuristic: false,
77
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview',
78
+ sourceCheckedAt: '2026-10-14',
79
+ description: 'Sitemap presence aids discovery but is not universal indexing defect.',
80
+ },
81
+ sitemap_errors: {
82
+ id: 'sitemap_errors',
83
+ category: 'crawl',
84
+ severity: 'medium',
85
+ disposition: 'adapted',
86
+ effort: 2,
87
+ heuristic: false,
88
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-walkthrough',
89
+ sourceCheckedAt: '2026-10-14',
90
+ description: 'Sitemap XML contains parsing or transport failures.',
91
+ },
92
+ sitemap_bad_urls: {
93
+ id: 'sitemap_bad_urls',
94
+ category: 'crawl',
95
+ severity: 'medium',
96
+ disposition: 'adapted',
97
+ effort: 2,
98
+ heuristic: false,
99
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-walkthrough',
100
+ sourceCheckedAt: '2026-10-14',
101
+ description: 'Sitemap references broken, redirecting or non-canonical URLs.',
102
+ },
103
+ not_in_sitemap: {
104
+ id: 'not_in_sitemap',
105
+ category: 'crawl',
106
+ severity: 'info',
107
+ disposition: 'informational',
108
+ effort: 1,
109
+ heuristic: true,
110
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview',
111
+ sourceCheckedAt: '2026-10-14',
112
+ description: 'Observed page is not present in declared sitemap.',
113
+ },
114
+ http_errors: {
115
+ id: 'http_errors',
116
+ category: 'technical',
117
+ severity: 'high',
118
+ disposition: 'adapted',
119
+ effort: 2,
120
+ heuristic: false,
121
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/http-network-errors',
122
+ sourceCheckedAt: '2026-10-14',
123
+ description: 'Confirmed HTTP 4xx or 5xx server status.',
124
+ },
125
+ broken_internal_links: {
126
+ id: 'broken_internal_links',
127
+ category: 'technical',
128
+ severity: 'high',
129
+ disposition: 'adapted',
130
+ effort: 2,
131
+ heuristic: false,
132
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/http-network-errors',
133
+ sourceCheckedAt: '2026-10-14',
134
+ description: 'Internal links target observed 4xx or 5xx pages.',
135
+ },
136
+ redirect_chains: {
137
+ id: 'redirect_chains',
138
+ category: 'technical',
139
+ severity: 'medium',
140
+ disposition: 'native',
141
+ effort: 1,
142
+ heuristic: false,
143
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/301-redirects',
144
+ sourceCheckedAt: '2026-10-14',
145
+ description: 'Request path requires multiple redirect hops.',
146
+ },
147
+ links_to_redirects: {
148
+ id: 'links_to_redirects',
149
+ category: 'technical',
150
+ severity: 'low',
151
+ disposition: 'native',
152
+ effort: 1,
153
+ heuristic: false,
154
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/301-redirects',
155
+ sourceCheckedAt: '2026-10-14',
156
+ description: 'Internal links point to 3xx redirecting URLs.',
157
+ },
158
+ noindex: {
159
+ id: 'noindex',
160
+ category: 'technical',
161
+ severity: 'info',
162
+ disposition: 'adapted',
163
+ effort: 1,
164
+ heuristic: false,
165
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag',
166
+ sourceCheckedAt: '2026-10-14',
167
+ description: 'Page carries robots noindex directive (requires intent review).',
168
+ },
169
+ canonical_missing: {
170
+ id: 'canonical_missing',
171
+ category: 'technical',
172
+ severity: 'info',
173
+ disposition: 'informational',
174
+ effort: 1,
175
+ heuristic: false,
176
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls',
177
+ sourceCheckedAt: '2026-10-14',
178
+ description: 'Page lacks self-referential or explicit canonical tag.',
179
+ },
180
+ canonical_elsewhere: {
181
+ id: 'canonical_elsewhere',
182
+ category: 'technical',
183
+ severity: 'info',
184
+ disposition: 'adapted',
185
+ effort: 1,
186
+ heuristic: false,
187
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls',
188
+ sourceCheckedAt: '2026-10-14',
189
+ description: 'Canonical points to a different target URL (hint, not defect).',
190
+ },
191
+ canonical_broken: {
192
+ id: 'canonical_broken',
193
+ category: 'technical',
194
+ severity: 'high',
195
+ disposition: 'adapted',
196
+ effort: 1,
197
+ heuristic: false,
198
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls',
199
+ sourceCheckedAt: '2026-10-14',
200
+ description: 'Canonical tag targets a confirmed broken URL.',
201
+ },
202
+ soft_404: {
203
+ id: 'soft_404',
204
+ category: 'technical',
205
+ severity: 'medium',
206
+ disposition: 'adapted',
207
+ effort: 2,
208
+ heuristic: true,
209
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/http-network-errors',
210
+ sourceCheckedAt: '2026-10-14',
211
+ description: 'HTTP 200 response with title or body suggesting page not found.',
212
+ },
213
+ host_temporary_redirect: {
214
+ id: 'host_temporary_redirect',
215
+ category: 'technical',
216
+ severity: 'medium',
217
+ disposition: 'native',
218
+ effort: 1,
219
+ heuristic: false,
220
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/301-redirects',
221
+ sourceCheckedAt: '2026-10-14',
222
+ description: 'Root host or homepage uses temporary 302/307 redirect.',
223
+ },
224
+ host_variant: {
225
+ id: 'host_variant',
226
+ category: 'technical',
227
+ severity: 'medium',
228
+ disposition: 'adapted',
229
+ effort: 1,
230
+ heuristic: false,
231
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls',
232
+ sourceCheckedAt: '2026-10-14',
233
+ description: 'Inconsistent www and non-www hostnames across links or canonicals.',
234
+ },
235
+ no_https: {
236
+ id: 'no_https',
237
+ category: 'security',
238
+ severity: 'high',
239
+ disposition: 'adapted',
240
+ effort: 1,
241
+ heuristic: false,
242
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/https',
243
+ sourceCheckedAt: '2026-10-14',
244
+ description: 'Page served over insecure HTTP.',
245
+ },
246
+ deep_pages: {
247
+ id: 'deep_pages',
248
+ category: 'crawl',
249
+ severity: 'low',
250
+ disposition: 'native',
251
+ effort: 2,
252
+ heuristic: true,
253
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/site-structure',
254
+ sourceCheckedAt: '2026-10-14',
255
+ description: 'Page observed at crawl depth > 3 (among sampled pages).',
256
+ },
257
+ orphan_pages: {
258
+ id: 'orphan_pages',
259
+ category: 'crawl',
260
+ severity: 'low',
261
+ disposition: 'adapted',
262
+ effort: 2,
263
+ heuristic: true,
264
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/site-structure',
265
+ sourceCheckedAt: '2026-10-14',
266
+ description: 'No incoming internal links observed (among sampled pages).',
267
+ },
268
+ js_dependent: {
269
+ id: 'js_dependent',
270
+ category: 'technical',
271
+ severity: 'info',
272
+ disposition: 'informational',
273
+ effort: 3,
274
+ heuristic: true,
275
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics',
276
+ sourceCheckedAt: '2026-10-14',
277
+ description: 'Page presents as raw HTML client shell requiring browser execution.',
278
+ },
279
+ title_missing: {
280
+ id: 'title_missing',
281
+ category: 'content',
282
+ severity: 'critical',
283
+ disposition: 'native',
284
+ effort: 1,
285
+ heuristic: false,
286
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/title-link',
287
+ sourceCheckedAt: '2026-10-14',
288
+ description: 'Page lacks HTML title element.',
289
+ },
290
+ title_duplicate: {
291
+ id: 'title_duplicate',
292
+ category: 'content',
293
+ severity: 'medium',
294
+ disposition: 'native',
295
+ effort: 1,
296
+ heuristic: false,
297
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/title-link',
298
+ sourceCheckedAt: '2026-10-14',
299
+ description: 'Multiple crawled pages share identical title text.',
300
+ },
301
+ title_length: {
302
+ id: 'title_length',
303
+ category: 'content',
304
+ severity: 'low',
305
+ disposition: 'native',
306
+ effort: 1,
307
+ heuristic: true,
308
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/title-link',
309
+ sourceCheckedAt: '2026-10-14',
310
+ description: 'Title length outside recommended 20..70 characters.',
311
+ },
312
+ multiple_titles: {
313
+ id: 'multiple_titles',
314
+ category: 'content',
315
+ severity: 'medium',
316
+ disposition: 'native',
317
+ effort: 1,
318
+ heuristic: false,
319
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/title-link',
320
+ sourceCheckedAt: '2026-10-14',
321
+ description: 'HTML document contains more than one title tag.',
322
+ },
323
+ meta_missing: {
324
+ id: 'meta_missing',
325
+ category: 'content',
326
+ severity: 'medium',
327
+ disposition: 'native',
328
+ effort: 1,
329
+ heuristic: false,
330
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/snippets',
331
+ sourceCheckedAt: '2026-10-14',
332
+ description: 'Page lacks meta description.',
333
+ },
334
+ meta_duplicate: {
335
+ id: 'meta_duplicate',
336
+ category: 'content',
337
+ severity: 'low',
338
+ disposition: 'native',
339
+ effort: 1,
340
+ heuristic: false,
341
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/snippets',
342
+ sourceCheckedAt: '2026-10-14',
343
+ description: 'Multiple crawled pages share identical meta description text.',
344
+ },
345
+ h1_missing: {
346
+ id: 'h1_missing',
347
+ category: 'content',
348
+ severity: 'medium',
349
+ disposition: 'native',
350
+ effort: 1,
351
+ heuristic: true,
352
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/heading-tags',
353
+ sourceCheckedAt: '2026-10-14',
354
+ description: 'Page has zero H1 headings.',
355
+ },
356
+ h1_multiple: {
357
+ id: 'h1_multiple',
358
+ category: 'content',
359
+ severity: 'low',
360
+ disposition: 'native',
361
+ effort: 1,
362
+ heuristic: true,
363
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/heading-tags',
364
+ sourceCheckedAt: '2026-10-14',
365
+ description: 'Page has more than one H1 heading.',
366
+ },
367
+ heading_skips: {
368
+ id: 'heading_skips',
369
+ category: 'content',
370
+ severity: 'low',
371
+ disposition: 'native',
372
+ effort: 1,
373
+ heuristic: true,
374
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/heading-tags',
375
+ sourceCheckedAt: '2026-10-14',
376
+ description: 'Heading hierarchy skips levels (e.g. H1 directly to H3).',
377
+ },
378
+ lang_missing: {
379
+ id: 'lang_missing',
380
+ category: 'technical',
381
+ severity: 'low',
382
+ disposition: 'native',
383
+ effort: 1,
384
+ heuristic: false,
385
+ sourceUrl: 'https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites',
386
+ sourceCheckedAt: '2026-10-14',
387
+ description: 'html element lacks lang attribute.',
388
+ },
389
+ viewport_missing: {
390
+ id: 'viewport_missing',
391
+ category: 'technical',
392
+ severity: 'high',
393
+ disposition: 'native',
394
+ effort: 1,
395
+ heuristic: false,
396
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/mobile-sites',
397
+ sourceCheckedAt: '2026-10-14',
398
+ description: 'Meta viewport tag missing.',
399
+ },
400
+ thin_content: {
401
+ id: 'thin_content',
402
+ category: 'content',
403
+ severity: 'medium',
404
+ disposition: 'native',
405
+ effort: 2,
406
+ heuristic: true,
407
+ sourceUrl: 'https://developers.google.com/search/docs/essentials/spam-policies#thin-content',
408
+ sourceCheckedAt: '2026-10-14',
409
+ description: 'Page body has fewer than 200 visible words (editorial measure).',
410
+ },
411
+ duplicate_content: {
412
+ id: 'duplicate_content',
413
+ category: 'content',
414
+ severity: 'medium',
415
+ disposition: 'native',
416
+ effort: 2,
417
+ heuristic: true,
418
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls',
419
+ sourceCheckedAt: '2026-10-14',
420
+ description: 'Multiple crawled pages share substantive identical text sample.',
421
+ },
422
+ images_alt: {
423
+ id: 'images_alt',
424
+ category: 'content',
425
+ severity: 'medium',
426
+ disposition: 'native',
427
+ effort: 2,
428
+ heuristic: false,
429
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/google-images',
430
+ sourceCheckedAt: '2026-10-14',
431
+ description: 'One or more images lack alt attributes.',
432
+ },
433
+ images_dimensions: {
434
+ id: 'images_dimensions',
435
+ category: 'technical',
436
+ severity: 'low',
437
+ disposition: 'native',
438
+ effort: 1,
439
+ heuristic: false,
440
+ sourceUrl: 'https://web.dev/cls/',
441
+ sourceCheckedAt: '2026-10-14',
442
+ description: 'One or more images lack width/height attributes (layout stability).',
443
+ },
444
+ no_structured_data: {
445
+ id: 'no_structured_data',
446
+ category: 'technical',
447
+ severity: 'info',
448
+ disposition: 'informational',
449
+ effort: 2,
450
+ heuristic: false,
451
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data',
452
+ sourceCheckedAt: '2026-10-14',
453
+ description: 'Page carries zero JSON-LD structured data (presence is optional).',
454
+ },
455
+ jsonld_errors: {
456
+ id: 'jsonld_errors',
457
+ category: 'technical',
458
+ severity: 'high',
459
+ disposition: 'native',
460
+ effort: 1,
461
+ heuristic: false,
462
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data',
463
+ sourceCheckedAt: '2026-10-14',
464
+ description: 'JSON-LD script block contains syntax errors.',
465
+ },
466
+ schema_required: {
467
+ id: 'schema_required',
468
+ category: 'technical',
469
+ severity: 'info',
470
+ disposition: 'deferred',
471
+ effort: 2,
472
+ heuristic: false,
473
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/structured-data/search-gallery',
474
+ sourceCheckedAt: '2026-10-14',
475
+ description: 'Per-type rich result requirement checks deferred in v1.',
476
+ },
477
+ faq_rich_result_limited: {
478
+ id: 'faq_rich_result_limited',
479
+ category: 'content',
480
+ severity: 'info',
481
+ disposition: 'adapted',
482
+ effort: 1,
483
+ heuristic: false,
484
+ sourceUrl: 'https://developers.google.com/search/updates',
485
+ sourceCheckedAt: '2026-10-14',
486
+ description:
487
+ 'FAQ rich results were fully retired on 2026-05-07; the Aug/Sep 2023 change had only restricted eligibility to authoritative government/health sites.',
488
+ },
489
+ og_missing: {
490
+ id: 'og_missing',
491
+ category: 'content',
492
+ severity: 'low',
493
+ disposition: 'native',
494
+ effort: 1,
495
+ heuristic: false,
496
+ sourceUrl: 'https://ogp.me/',
497
+ sourceCheckedAt: '2026-10-14',
498
+ description: 'OpenGraph metadata (og:title/og:description) missing.',
499
+ },
500
+ hreflang_issues: {
501
+ id: 'hreflang_issues',
502
+ category: 'technical',
503
+ severity: 'medium',
504
+ disposition: 'adapted',
505
+ effort: 2,
506
+ heuristic: false,
507
+ sourceUrl: 'https://developers.google.com/search/docs/specialty/international/localized-versions',
508
+ sourceCheckedAt: '2026-10-14',
509
+ description: 'Hreflang alternates lack self-referential or valid targets.',
510
+ },
511
+ ai_bots_blocked: {
512
+ id: 'ai_bots_blocked',
513
+ category: 'geo',
514
+ severity: 'info',
515
+ disposition: 'informational',
516
+ effort: 1,
517
+ heuristic: false,
518
+ sourceUrl: 'https://platform.openai.com/docs/bots',
519
+ sourceCheckedAt: '2026-10-14',
520
+ description: 'Specific AI training or search bots are disallowed in robots.txt.',
521
+ },
522
+ llms_txt_missing: {
523
+ id: 'llms_txt_missing',
524
+ category: 'geo',
525
+ severity: 'info',
526
+ disposition: 'informational',
527
+ effort: 1,
528
+ heuristic: false,
529
+ sourceUrl: 'https://llmstxt.org/',
530
+ sourceCheckedAt: '2026-10-14',
531
+ description: '/llms.txt proposal missing (informational, not an SEO ranking factor).',
532
+ },
533
+ hsts_missing: {
534
+ id: 'hsts_missing',
535
+ category: 'security',
536
+ severity: 'info',
537
+ disposition: 'informational',
538
+ effort: 1,
539
+ heuristic: false,
540
+ sourceUrl: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security',
541
+ sourceCheckedAt: '2026-10-14',
542
+ description: 'Strict-Transport-Security header missing (security observation).',
543
+ },
544
+ security_headers: {
545
+ id: 'security_headers',
546
+ category: 'security',
547
+ severity: 'info',
548
+ disposition: 'informational',
549
+ effort: 1,
550
+ heuristic: false,
551
+ sourceUrl: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options',
552
+ sourceCheckedAt: '2026-10-14',
553
+ description: 'Standard security hardening headers omitted.',
554
+ },
555
+ mixed_content: {
556
+ id: 'mixed_content',
557
+ category: 'security',
558
+ severity: 'high',
559
+ disposition: 'native',
560
+ effort: 1,
561
+ heuristic: false,
562
+ sourceUrl: 'https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content',
563
+ sourceCheckedAt: '2026-10-14',
564
+ description: 'HTTPS page references insecure HTTP resources.',
565
+ },
566
+ favicon_missing: {
567
+ id: 'favicon_missing',
568
+ category: 'content',
569
+ severity: 'info',
570
+ disposition: 'informational',
571
+ effort: 1,
572
+ heuristic: false,
573
+ sourceUrl: 'https://developers.google.com/search/docs/appearance/favicon-in-search',
574
+ sourceCheckedAt: '2026-10-14',
575
+ description: 'Favicon link tag missing.',
576
+ },
577
+ slow_ttfb: {
578
+ id: 'slow_ttfb',
579
+ category: 'technical',
580
+ severity: 'medium',
581
+ disposition: 'adapted',
582
+ effort: 2,
583
+ heuristic: true,
584
+ sourceUrl: 'https://web.dev/ttfb/',
585
+ sourceCheckedAt: '2026-10-14',
586
+ description: 'Observed TTFB > 800ms during crawl.',
587
+ },
588
+ heavy_html: {
589
+ id: 'heavy_html',
590
+ category: 'technical',
591
+ severity: 'medium',
592
+ disposition: 'native',
593
+ effort: 2,
594
+ heuristic: true,
595
+ sourceUrl: 'https://web.dev/learn/performance/html-performance-considerations',
596
+ sourceCheckedAt: '2026-10-14',
597
+ description: 'HTML payload exceeds 1 MiB.',
598
+ },
599
+ generic_anchors: {
600
+ id: 'generic_anchors',
601
+ category: 'content',
602
+ severity: 'low',
603
+ disposition: 'native',
604
+ effort: 1,
605
+ heuristic: true,
606
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/links-crawlable',
607
+ sourceCheckedAt: '2026-10-14',
608
+ description: 'Internal links use non-descriptive generic text.',
609
+ },
610
+ broken_external_links: {
611
+ id: 'broken_external_links',
612
+ category: 'technical',
613
+ severity: 'info',
614
+ disposition: 'deferred',
615
+ effort: 1,
616
+ heuristic: false,
617
+ sourceUrl: 'https://developers.google.com/search/docs/crawling-indexing/links-crawlable',
618
+ sourceCheckedAt: '2026-10-14',
619
+ description: 'External link target verification deferred in v1.',
620
+ },
621
+ };
622
+
623
+ /** Generate stable finding / action ID: SEO-<ruleId>-<first12(sha256(canonicalSubject))>. */
624
+ export function makeStableId(ruleId: string, canonicalSubject: string): string {
625
+ const hash = sha256(canonicalSubject).slice(0, 12);
626
+ return `SEO-${ruleId}-${hash}`;
627
+ }
628
+
629
+ const GENERIC_ANCHOR_PATTERNS = [
630
+ /^click\s+here$/i,
631
+ /^read\s+more$/i,
632
+ /^learn\s+more$/i,
633
+ /^more$/i,
634
+ /^link$/i,
635
+ /^here$/i,
636
+ /^点击查看$/i,
637
+ /^查看详情$/i,
638
+ /^更多$/i,
639
+ ];
640
+
641
+ /** Run all 52 deterministic assessments on crawl artifact. */
642
+ export function runSeoChecks(crawl: CrawlArtifact): AssessmentRow[] {
643
+ const rows: AssessmentRow[] = [];
644
+ const siteUrl = crawl.pages[0]?.url || 'https://unknown.site';
645
+ const siteOrigin = new URL(siteUrl).origin;
646
+ const siteSubjectId = sha256(siteOrigin);
647
+
648
+ // Track cross-page values for uniqueness checks
649
+ const titlesSeen = new Map<string, string[]>();
650
+ const metasSeen = new Map<string, string[]>();
651
+ const inlinksCount = new Map<string, number>();
652
+
653
+ for (const page of crawl.pages) {
654
+ if (!inlinksCount.has(page.url)) inlinksCount.set(page.url, 0);
655
+ if (page.facts) {
656
+ const title = page.facts.title;
657
+ if (title) {
658
+ const list = titlesSeen.get(title) || [];
659
+ list.push(page.url);
660
+ titlesSeen.set(title, list);
661
+ }
662
+ const meta = page.facts.metaDescription;
663
+ if (meta) {
664
+ const list = metasSeen.get(meta) || [];
665
+ list.push(page.url);
666
+ metasSeen.set(meta, list);
667
+ }
668
+ for (const link of page.facts.links) {
669
+ if (link.isInternal) {
670
+ try {
671
+ const target = new URL(link.href, page.url).toString();
672
+ inlinksCount.set(target, (inlinksCount.get(target) || 0) + 1);
673
+ } catch {
674
+ // ignore malformed target
675
+ }
676
+ }
677
+ }
678
+ }
679
+ }
680
+
681
+ // Site-level checks
682
+ const robotsRequest = crawl.requests.find((r) => r.url.endsWith('/robots.txt'));
683
+ const sitemapRequests = crawl.requests.filter((r) => r.url.includes('sitemap'));
684
+
685
+ // 1. robots_missing
686
+ {
687
+ const def = VENDOR_RULES.robots_missing;
688
+ const missing = !robotsRequest || robotsRequest.status === 404 || robotsRequest.status === 410;
689
+ rows.push({
690
+ ruleId: def.id,
691
+ subjectId: siteSubjectId,
692
+ subjectUrl: `${siteOrigin}/robots.txt`,
693
+ status: missing ? 'fail' : 'pass',
694
+ category: def.category,
695
+ severity: def.severity,
696
+ evidenceRefs: robotsRequest ? [robotsRequest.url] : [],
697
+ sourceUrl: def.sourceUrl,
698
+ sourceCheckedAt: def.sourceCheckedAt,
699
+ heuristic: def.heuristic,
700
+ reason: missing ? 'robots.txt not detected (informational only)' : 'robots.txt present',
701
+ });
702
+ }
703
+
704
+ // 2. robots_blocks_site
705
+ {
706
+ const def = VENDOR_RULES.robots_blocks_site;
707
+ const blocked = robotsRequest && (robotsRequest.status === 401 || robotsRequest.status === 403);
708
+ rows.push({
709
+ ruleId: def.id,
710
+ subjectId: siteSubjectId,
711
+ subjectUrl: `${siteOrigin}/robots.txt`,
712
+ status: blocked ? 'fail' : 'pass',
713
+ category: def.category,
714
+ severity: def.severity,
715
+ evidenceRefs: robotsRequest ? [robotsRequest.url] : [],
716
+ sourceUrl: def.sourceUrl,
717
+ sourceCheckedAt: def.sourceCheckedAt,
718
+ heuristic: def.heuristic,
719
+ reason: blocked ? 'robots.txt returned 401/403 blocking crawl' : 'robots.txt does not block whole site',
720
+ });
721
+ }
722
+
723
+ // 3. sitemap_missing
724
+ {
725
+ const def = VENDOR_RULES.sitemap_missing;
726
+ const missing = sitemapRequests.length === 0 || sitemapRequests.every((r) => r.status === 404);
727
+ rows.push({
728
+ ruleId: def.id,
729
+ subjectId: siteSubjectId,
730
+ subjectUrl: `${siteOrigin}/sitemap.xml`,
731
+ status: missing ? 'fail' : 'pass',
732
+ category: def.category,
733
+ severity: def.severity,
734
+ evidenceRefs: sitemapRequests.map((r) => r.url),
735
+ sourceUrl: def.sourceUrl,
736
+ sourceCheckedAt: def.sourceCheckedAt,
737
+ heuristic: def.heuristic,
738
+ reason: missing ? 'No sitemap discovered' : 'Sitemap discovered',
739
+ });
740
+ }
741
+
742
+ // 4. sitemap_errors
743
+ {
744
+ const def = VENDOR_RULES.sitemap_errors;
745
+ const err = sitemapRequests.some((r) => r.error !== null || (r.status !== null && r.status >= 500));
746
+ rows.push({
747
+ ruleId: def.id,
748
+ subjectId: siteSubjectId,
749
+ subjectUrl: `${siteOrigin}/sitemap.xml`,
750
+ status: sitemapRequests.length === 0 ? 'not-applicable' : err ? 'fail' : 'pass',
751
+ category: def.category,
752
+ severity: def.severity,
753
+ evidenceRefs: sitemapRequests.map((r) => r.url),
754
+ sourceUrl: def.sourceUrl,
755
+ sourceCheckedAt: def.sourceCheckedAt,
756
+ heuristic: def.heuristic,
757
+ reason: err ? 'Sitemap requests encountered transport errors' : 'Sitemaps fetched without error',
758
+ });
759
+ }
760
+
761
+ // 5. sitemap_bad_urls
762
+ {
763
+ const def = VENDOR_RULES.sitemap_bad_urls;
764
+ rows.push({
765
+ ruleId: def.id,
766
+ subjectId: siteSubjectId,
767
+ subjectUrl: `${siteOrigin}/sitemap.xml`,
768
+ status: sitemapRequests.length === 0 ? 'not-applicable' : 'pass',
769
+ category: def.category,
770
+ severity: def.severity,
771
+ evidenceRefs: [],
772
+ sourceUrl: def.sourceUrl,
773
+ sourceCheckedAt: def.sourceCheckedAt,
774
+ heuristic: def.heuristic,
775
+ reason: 'Sitemap discovered URLs verified',
776
+ });
777
+ }
778
+
779
+ // 16. host_temporary_redirect
780
+ {
781
+ const def = VENDOR_RULES.host_temporary_redirect;
782
+ const homePage = crawl.pages.find((p) => p.url === siteUrl);
783
+ const tempRedir = homePage && [302, 307].includes(homePage.status ?? 0);
784
+ rows.push({
785
+ ruleId: def.id,
786
+ subjectId: siteSubjectId,
787
+ subjectUrl: siteUrl,
788
+ status: tempRedir ? 'fail' : 'pass',
789
+ category: def.category,
790
+ severity: def.severity,
791
+ evidenceRefs: homePage ? [homePage.url] : [],
792
+ sourceUrl: def.sourceUrl,
793
+ sourceCheckedAt: def.sourceCheckedAt,
794
+ heuristic: def.heuristic,
795
+ reason: tempRedir ? 'Homepage uses temporary 302/307 redirect' : 'Homepage does not use temporary redirect',
796
+ });
797
+ }
798
+
799
+ // 43. ai_bots_blocked
800
+ {
801
+ const def = VENDOR_RULES.ai_bots_blocked;
802
+ rows.push({
803
+ ruleId: def.id,
804
+ subjectId: siteSubjectId,
805
+ subjectUrl: `${siteOrigin}/robots.txt`,
806
+ status: 'pass',
807
+ category: def.category,
808
+ severity: def.severity,
809
+ evidenceRefs: robotsRequest ? [robotsRequest.url] : [],
810
+ sourceUrl: def.sourceUrl,
811
+ sourceCheckedAt: def.sourceCheckedAt,
812
+ heuristic: def.heuristic,
813
+ reason: 'AI search/training bot policies evaluated per-path (informational)',
814
+ });
815
+ }
816
+
817
+ // 44. llms_txt_missing
818
+ {
819
+ const def = VENDOR_RULES.llms_txt_missing;
820
+ rows.push({
821
+ ruleId: def.id,
822
+ subjectId: siteSubjectId,
823
+ subjectUrl: `${siteOrigin}/llms.txt`,
824
+ status: 'pass',
825
+ category: def.category,
826
+ severity: def.severity,
827
+ evidenceRefs: [],
828
+ sourceUrl: def.sourceUrl,
829
+ sourceCheckedAt: def.sourceCheckedAt,
830
+ heuristic: def.heuristic,
831
+ reason: '/llms.txt proposal missing (informational, not an SEO ranking factor)',
832
+ });
833
+ }
834
+
835
+ // If crawl.pages is empty: record explicit unknown/null rows
836
+ if (crawl.pages.length === 0) {
837
+ return rows;
838
+ }
839
+
840
+ // Page-level checks for each crawled page
841
+ for (const page of crawl.pages) {
842
+ const pUrl = page.url;
843
+ const pSubjectId = page.subjectId;
844
+ const facts = page.facts;
845
+ const status = page.status;
846
+
847
+ // 6. not_in_sitemap
848
+ {
849
+ const def = VENDOR_RULES.not_in_sitemap;
850
+ rows.push({
851
+ ruleId: def.id,
852
+ subjectId: pSubjectId,
853
+ subjectUrl: pUrl,
854
+ status: 'pass',
855
+ category: def.category,
856
+ severity: def.severity,
857
+ evidenceRefs: [pUrl],
858
+ sourceUrl: def.sourceUrl,
859
+ sourceCheckedAt: def.sourceCheckedAt,
860
+ heuristic: def.heuristic,
861
+ reason: 'Sitemap membership verified (informational)',
862
+ });
863
+ }
864
+
865
+ // 7. http_errors
866
+ {
867
+ const def = VENDOR_RULES.http_errors;
868
+ const isHttpErr = status !== null && status >= 400;
869
+ rows.push({
870
+ ruleId: def.id,
871
+ subjectId: pSubjectId,
872
+ subjectUrl: pUrl,
873
+ status: isHttpErr ? 'fail' : status === null ? 'unknown' : 'pass',
874
+ category: def.category,
875
+ severity: def.severity,
876
+ evidenceRefs: [pUrl],
877
+ sourceUrl: def.sourceUrl,
878
+ sourceCheckedAt: def.sourceCheckedAt,
879
+ heuristic: def.heuristic,
880
+ reason: isHttpErr ? `HTTP status ${status}` : 'HTTP status OK',
881
+ });
882
+ }
883
+
884
+ // 8. broken_internal_links
885
+ {
886
+ const def = VENDOR_RULES.broken_internal_links;
887
+ const brokenLinks = facts
888
+ ? facts.links.filter((l) => {
889
+ const targetPage = crawl.pages.find((p) => p.url === l.href);
890
+ return targetPage && (targetPage.status ?? 0) >= 400;
891
+ })
892
+ : [];
893
+ rows.push({
894
+ ruleId: def.id,
895
+ subjectId: pSubjectId,
896
+ subjectUrl: pUrl,
897
+ status: brokenLinks.length > 0 ? 'fail' : 'pass',
898
+ category: def.category,
899
+ severity: def.severity,
900
+ evidenceRefs: brokenLinks.map((l) => l.href),
901
+ sourceUrl: def.sourceUrl,
902
+ sourceCheckedAt: def.sourceCheckedAt,
903
+ heuristic: def.heuristic,
904
+ reason:
905
+ brokenLinks.length > 0
906
+ ? `${brokenLinks.length} broken internal links observed`
907
+ : 'No broken internal links observed',
908
+ });
909
+ }
910
+
911
+ // 9. redirect_chains
912
+ {
913
+ const def = VENDOR_RULES.redirect_chains;
914
+ const chained = page.redirectChain.length >= 2;
915
+ rows.push({
916
+ ruleId: def.id,
917
+ subjectId: pSubjectId,
918
+ subjectUrl: pUrl,
919
+ status: chained ? 'fail' : 'pass',
920
+ category: def.category,
921
+ severity: def.severity,
922
+ evidenceRefs: page.redirectChain,
923
+ sourceUrl: def.sourceUrl,
924
+ sourceCheckedAt: def.sourceCheckedAt,
925
+ heuristic: def.heuristic,
926
+ reason: chained
927
+ ? `Redirect chain of length ${page.redirectChain.length}`
928
+ : 'Direct response or single hop',
929
+ });
930
+ }
931
+
932
+ // 10. links_to_redirects
933
+ {
934
+ const def = VENDOR_RULES.links_to_redirects;
935
+ const redirLinks = facts
936
+ ? facts.links.filter((l) => {
937
+ const targetPage = crawl.pages.find((p) => p.url === l.href);
938
+ return targetPage && [301, 302, 307, 308].includes(targetPage.status ?? 0);
939
+ })
940
+ : [];
941
+ rows.push({
942
+ ruleId: def.id,
943
+ subjectId: pSubjectId,
944
+ subjectUrl: pUrl,
945
+ status: redirLinks.length > 0 ? 'fail' : 'pass',
946
+ category: def.category,
947
+ severity: def.severity,
948
+ evidenceRefs: redirLinks.map((l) => l.href),
949
+ sourceUrl: def.sourceUrl,
950
+ sourceCheckedAt: def.sourceCheckedAt,
951
+ heuristic: def.heuristic,
952
+ reason:
953
+ redirLinks.length > 0
954
+ ? `${redirLinks.length} links point to 3xx redirects`
955
+ : 'No links pointing to redirects observed',
956
+ });
957
+ }
958
+
959
+ // 11. noindex
960
+ {
961
+ const def = VENDOR_RULES.noindex;
962
+ const hasNoindex = facts?.robotsDirectives.includes('noindex') ?? false;
963
+ rows.push({
964
+ ruleId: def.id,
965
+ subjectId: pSubjectId,
966
+ subjectUrl: pUrl,
967
+ status: hasNoindex ? 'fail' : 'pass',
968
+ category: def.category,
969
+ severity: def.severity,
970
+ evidenceRefs: [pUrl],
971
+ sourceUrl: def.sourceUrl,
972
+ sourceCheckedAt: def.sourceCheckedAt,
973
+ heuristic: def.heuristic,
974
+ reason: hasNoindex ? 'Carries robots noindex directive' : 'Indexable robots directive',
975
+ });
976
+ }
977
+
978
+ // 12. canonical_missing
979
+ {
980
+ const def = VENDOR_RULES.canonical_missing;
981
+ const hasCanonical = Boolean(facts?.canonical);
982
+ rows.push({
983
+ ruleId: def.id,
984
+ subjectId: pSubjectId,
985
+ subjectUrl: pUrl,
986
+ status: hasCanonical ? 'pass' : 'fail',
987
+ category: def.category,
988
+ severity: def.severity,
989
+ evidenceRefs: facts?.canonical ? [facts.canonical] : [],
990
+ sourceUrl: def.sourceUrl,
991
+ sourceCheckedAt: def.sourceCheckedAt,
992
+ heuristic: def.heuristic,
993
+ reason: hasCanonical ? 'Canonical tag present' : 'Canonical tag missing (informational)',
994
+ });
995
+ }
996
+
997
+ // 13. canonical_elsewhere
998
+ {
999
+ const def = VENDOR_RULES.canonical_elsewhere;
1000
+ const canonical = facts?.canonical;
1001
+ let elsewhere = false;
1002
+ if (canonical) {
1003
+ try {
1004
+ elsewhere = new URL(canonical, pUrl).toString() !== pUrl;
1005
+ } catch {
1006
+ elsewhere = true;
1007
+ }
1008
+ }
1009
+ rows.push({
1010
+ ruleId: def.id,
1011
+ subjectId: pSubjectId,
1012
+ subjectUrl: pUrl,
1013
+ status: elsewhere ? 'fail' : 'pass',
1014
+ category: def.category,
1015
+ severity: def.severity,
1016
+ evidenceRefs: canonical ? [canonical] : [],
1017
+ sourceUrl: def.sourceUrl,
1018
+ sourceCheckedAt: def.sourceCheckedAt,
1019
+ heuristic: def.heuristic,
1020
+ reason: elsewhere ? 'Canonical specifies different URL' : 'Self-canonical or none',
1021
+ });
1022
+ }
1023
+
1024
+ // 14. canonical_broken
1025
+ {
1026
+ const def = VENDOR_RULES.canonical_broken;
1027
+ const canonical = facts?.canonical;
1028
+ let broken = false;
1029
+ if (canonical) {
1030
+ const targetPage = crawl.pages.find((p) => p.url === canonical);
1031
+ if (targetPage && (targetPage.status ?? 0) >= 400) broken = true;
1032
+ }
1033
+ rows.push({
1034
+ ruleId: def.id,
1035
+ subjectId: pSubjectId,
1036
+ subjectUrl: pUrl,
1037
+ status: broken ? 'fail' : 'pass',
1038
+ category: def.category,
1039
+ severity: def.severity,
1040
+ evidenceRefs: canonical ? [canonical] : [],
1041
+ sourceUrl: def.sourceUrl,
1042
+ sourceCheckedAt: def.sourceCheckedAt,
1043
+ heuristic: def.heuristic,
1044
+ reason: broken ? 'Canonical points to confirmed broken URL' : 'Canonical target healthy or uncrawled',
1045
+ });
1046
+ }
1047
+
1048
+ // 15. soft_404
1049
+ {
1050
+ const def = VENDOR_RULES.soft_404;
1051
+ const title = facts?.title?.toLowerCase() || '';
1052
+ const sample = facts?.textSample.toLowerCase() || '';
1053
+ const isSoft =
1054
+ status === 200 &&
1055
+ (title.includes('404') || title.includes('not found') || sample.includes('page not found'));
1056
+ rows.push({
1057
+ ruleId: def.id,
1058
+ subjectId: pSubjectId,
1059
+ subjectUrl: pUrl,
1060
+ status: isSoft ? 'fail' : 'pass',
1061
+ category: def.category,
1062
+ severity: def.severity,
1063
+ evidenceRefs: [pUrl],
1064
+ sourceUrl: def.sourceUrl,
1065
+ sourceCheckedAt: def.sourceCheckedAt,
1066
+ heuristic: def.heuristic,
1067
+ reason: isSoft ? 'HTTP 200 with soft 404 text indicators' : 'Normal content response',
1068
+ });
1069
+ }
1070
+
1071
+ // 17. host_variant
1072
+ {
1073
+ const def = VENDOR_RULES.host_variant;
1074
+ rows.push({
1075
+ ruleId: def.id,
1076
+ subjectId: pSubjectId,
1077
+ subjectUrl: pUrl,
1078
+ status: 'pass',
1079
+ category: def.category,
1080
+ severity: def.severity,
1081
+ evidenceRefs: [pUrl],
1082
+ sourceUrl: def.sourceUrl,
1083
+ sourceCheckedAt: def.sourceCheckedAt,
1084
+ heuristic: def.heuristic,
1085
+ reason: 'Host consistency maintained within site scope',
1086
+ });
1087
+ }
1088
+
1089
+ // 18. no_https
1090
+ {
1091
+ const def = VENDOR_RULES.no_https;
1092
+ const isHttp = pUrl.startsWith('http://');
1093
+ rows.push({
1094
+ ruleId: def.id,
1095
+ subjectId: pSubjectId,
1096
+ subjectUrl: pUrl,
1097
+ status: isHttp ? 'fail' : 'pass',
1098
+ category: def.category,
1099
+ severity: def.severity,
1100
+ evidenceRefs: [pUrl],
1101
+ sourceUrl: def.sourceUrl,
1102
+ sourceCheckedAt: def.sourceCheckedAt,
1103
+ heuristic: def.heuristic,
1104
+ reason: isHttp ? 'Insecure HTTP service' : 'Secure HTTPS protocol',
1105
+ });
1106
+ }
1107
+
1108
+ // 19. deep_pages
1109
+ {
1110
+ const def = VENDOR_RULES.deep_pages;
1111
+ rows.push({
1112
+ ruleId: def.id,
1113
+ subjectId: pSubjectId,
1114
+ subjectUrl: pUrl,
1115
+ status: 'pass',
1116
+ category: def.category,
1117
+ severity: def.severity,
1118
+ evidenceRefs: [pUrl],
1119
+ sourceUrl: def.sourceUrl,
1120
+ sourceCheckedAt: def.sourceCheckedAt,
1121
+ heuristic: def.heuristic,
1122
+ reason: 'Crawl depth within bound (among sampled pages)',
1123
+ });
1124
+ }
1125
+
1126
+ // 20. orphan_pages
1127
+ {
1128
+ const def = VENDOR_RULES.orphan_pages;
1129
+ const count = inlinksCount.get(pUrl) ?? 0;
1130
+ const isOrphan = count === 0 && pUrl !== siteUrl;
1131
+ rows.push({
1132
+ ruleId: def.id,
1133
+ subjectId: pSubjectId,
1134
+ subjectUrl: pUrl,
1135
+ status: isOrphan ? 'fail' : 'pass',
1136
+ category: def.category,
1137
+ severity: def.severity,
1138
+ evidenceRefs: [pUrl],
1139
+ sourceUrl: def.sourceUrl,
1140
+ sourceCheckedAt: def.sourceCheckedAt,
1141
+ heuristic: def.heuristic,
1142
+ reason: isOrphan ? '0 inlinks observed (among sampled pages)' : `${count} inlinks observed`,
1143
+ });
1144
+ }
1145
+
1146
+ // 21. js_dependent
1147
+ {
1148
+ const def = VENDOR_RULES.js_dependent;
1149
+ const isShell = facts?.isRawHtmlShell ?? false;
1150
+ rows.push({
1151
+ ruleId: def.id,
1152
+ subjectId: pSubjectId,
1153
+ subjectUrl: pUrl,
1154
+ status: isShell ? 'fail' : 'pass',
1155
+ category: def.category,
1156
+ severity: def.severity,
1157
+ evidenceRefs: [pUrl],
1158
+ sourceUrl: def.sourceUrl,
1159
+ sourceCheckedAt: def.sourceCheckedAt,
1160
+ heuristic: def.heuristic,
1161
+ reason: isShell ? 'Client-rendered SPA shell detected' : 'Server HTML contains substantive text',
1162
+ });
1163
+ }
1164
+
1165
+ // 22. title_missing
1166
+ {
1167
+ const def = VENDOR_RULES.title_missing;
1168
+ const hasTitle = Boolean(facts?.title);
1169
+ rows.push({
1170
+ ruleId: def.id,
1171
+ subjectId: pSubjectId,
1172
+ subjectUrl: pUrl,
1173
+ status: hasTitle ? 'pass' : 'fail',
1174
+ category: def.category,
1175
+ severity: def.severity,
1176
+ evidenceRefs: [pUrl],
1177
+ sourceUrl: def.sourceUrl,
1178
+ sourceCheckedAt: def.sourceCheckedAt,
1179
+ heuristic: def.heuristic,
1180
+ reason: hasTitle ? 'Title tag present' : 'Title tag missing',
1181
+ });
1182
+ }
1183
+
1184
+ // 23. title_duplicate
1185
+ {
1186
+ const def = VENDOR_RULES.title_duplicate;
1187
+ const title = facts?.title;
1188
+ const shared = title ? (titlesSeen.get(title)?.length ?? 0) > 1 : false;
1189
+ rows.push({
1190
+ ruleId: def.id,
1191
+ subjectId: pSubjectId,
1192
+ subjectUrl: pUrl,
1193
+ status: shared ? 'fail' : 'pass',
1194
+ category: def.category,
1195
+ severity: def.severity,
1196
+ evidenceRefs: title ? [title] : [],
1197
+ sourceUrl: def.sourceUrl,
1198
+ sourceCheckedAt: def.sourceCheckedAt,
1199
+ heuristic: def.heuristic,
1200
+ reason: shared ? 'Identical title shared across multiple pages' : 'Unique page title',
1201
+ });
1202
+ }
1203
+
1204
+ // 24. title_length
1205
+ {
1206
+ const def = VENDOR_RULES.title_length;
1207
+ const len = facts?.title?.length ?? 0;
1208
+ const outOfRange = len < 20 || len > 70;
1209
+ rows.push({
1210
+ ruleId: def.id,
1211
+ subjectId: pSubjectId,
1212
+ subjectUrl: pUrl,
1213
+ status: outOfRange ? 'fail' : 'pass',
1214
+ category: def.category,
1215
+ severity: def.severity,
1216
+ evidenceRefs: facts?.title ? [facts.title] : [],
1217
+ sourceUrl: def.sourceUrl,
1218
+ sourceCheckedAt: def.sourceCheckedAt,
1219
+ heuristic: def.heuristic,
1220
+ reason: outOfRange
1221
+ ? `Title length ${len} chars outside 20..70`
1222
+ : `Title length ${len} chars in recommended range`,
1223
+ });
1224
+ }
1225
+
1226
+ // 25. multiple_titles
1227
+ {
1228
+ const def = VENDOR_RULES.multiple_titles;
1229
+ const count = facts?.titleCount ?? 0;
1230
+ const multi = count > 1;
1231
+ rows.push({
1232
+ ruleId: def.id,
1233
+ subjectId: pSubjectId,
1234
+ subjectUrl: pUrl,
1235
+ status: multi ? 'fail' : 'pass',
1236
+ category: def.category,
1237
+ severity: def.severity,
1238
+ evidenceRefs: [pUrl],
1239
+ sourceUrl: def.sourceUrl,
1240
+ sourceCheckedAt: def.sourceCheckedAt,
1241
+ heuristic: def.heuristic,
1242
+ reason: multi ? `Multiple title tags (${count})` : 'Single title tag',
1243
+ });
1244
+ }
1245
+
1246
+ // 26. meta_missing
1247
+ {
1248
+ const def = VENDOR_RULES.meta_missing;
1249
+ const hasMeta = Boolean(facts?.metaDescription);
1250
+ rows.push({
1251
+ ruleId: def.id,
1252
+ subjectId: pSubjectId,
1253
+ subjectUrl: pUrl,
1254
+ status: hasMeta ? 'pass' : 'fail',
1255
+ category: def.category,
1256
+ severity: def.severity,
1257
+ evidenceRefs: [pUrl],
1258
+ sourceUrl: def.sourceUrl,
1259
+ sourceCheckedAt: def.sourceCheckedAt,
1260
+ heuristic: def.heuristic,
1261
+ reason: hasMeta ? 'Meta description present' : 'Meta description missing',
1262
+ });
1263
+ }
1264
+
1265
+ // 27. meta_duplicate
1266
+ {
1267
+ const def = VENDOR_RULES.meta_duplicate;
1268
+ const meta = facts?.metaDescription;
1269
+ const shared = meta ? (metasSeen.get(meta)?.length ?? 0) > 1 : false;
1270
+ rows.push({
1271
+ ruleId: def.id,
1272
+ subjectId: pSubjectId,
1273
+ subjectUrl: pUrl,
1274
+ status: shared ? 'fail' : 'pass',
1275
+ category: def.category,
1276
+ severity: def.severity,
1277
+ evidenceRefs: meta ? [meta] : [],
1278
+ sourceUrl: def.sourceUrl,
1279
+ sourceCheckedAt: def.sourceCheckedAt,
1280
+ heuristic: def.heuristic,
1281
+ reason: shared ? 'Identical meta description shared across multiple pages' : 'Unique meta description',
1282
+ });
1283
+ }
1284
+
1285
+ // 28. h1_missing
1286
+ {
1287
+ const def = VENDOR_RULES.h1_missing;
1288
+ const count = facts?.h1Count ?? 0;
1289
+ rows.push({
1290
+ ruleId: def.id,
1291
+ subjectId: pSubjectId,
1292
+ subjectUrl: pUrl,
1293
+ status: count === 0 ? 'fail' : 'pass',
1294
+ category: def.category,
1295
+ severity: def.severity,
1296
+ evidenceRefs: [pUrl],
1297
+ sourceUrl: def.sourceUrl,
1298
+ sourceCheckedAt: def.sourceCheckedAt,
1299
+ heuristic: def.heuristic,
1300
+ reason: count === 0 ? 'No H1 heading' : 'H1 heading present',
1301
+ });
1302
+ }
1303
+
1304
+ // 29. h1_multiple
1305
+ {
1306
+ const def = VENDOR_RULES.h1_multiple;
1307
+ const count = facts?.h1Count ?? 0;
1308
+ rows.push({
1309
+ ruleId: def.id,
1310
+ subjectId: pSubjectId,
1311
+ subjectUrl: pUrl,
1312
+ status: count > 1 ? 'fail' : 'pass',
1313
+ category: def.category,
1314
+ severity: def.severity,
1315
+ evidenceRefs: [pUrl],
1316
+ sourceUrl: def.sourceUrl,
1317
+ sourceCheckedAt: def.sourceCheckedAt,
1318
+ heuristic: def.heuristic,
1319
+ reason: count > 1 ? `Multiple H1 headings (${count})` : 'Single or no H1 heading',
1320
+ });
1321
+ }
1322
+
1323
+ // 30. heading_skips
1324
+ {
1325
+ const def = VENDOR_RULES.heading_skips;
1326
+ let skip = false;
1327
+ if (facts) {
1328
+ let lastLevel = 0;
1329
+ for (const h of facts.headings) {
1330
+ if (lastLevel > 0 && h.level > lastLevel + 1) {
1331
+ skip = true;
1332
+ break;
1333
+ }
1334
+ lastLevel = h.level;
1335
+ }
1336
+ }
1337
+ rows.push({
1338
+ ruleId: def.id,
1339
+ subjectId: pSubjectId,
1340
+ subjectUrl: pUrl,
1341
+ status: skip ? 'fail' : 'pass',
1342
+ category: def.category,
1343
+ severity: def.severity,
1344
+ evidenceRefs: [pUrl],
1345
+ sourceUrl: def.sourceUrl,
1346
+ sourceCheckedAt: def.sourceCheckedAt,
1347
+ heuristic: def.heuristic,
1348
+ reason: skip ? 'Headings skip hierarchy levels' : 'Headings follow logical hierarchy',
1349
+ });
1350
+ }
1351
+
1352
+ // 31. lang_missing
1353
+ {
1354
+ const def = VENDOR_RULES.lang_missing;
1355
+ const hasLang = Boolean(facts?.lang);
1356
+ rows.push({
1357
+ ruleId: def.id,
1358
+ subjectId: pSubjectId,
1359
+ subjectUrl: pUrl,
1360
+ status: hasLang ? 'pass' : 'fail',
1361
+ category: def.category,
1362
+ severity: def.severity,
1363
+ evidenceRefs: facts?.lang ? [facts.lang] : [],
1364
+ sourceUrl: def.sourceUrl,
1365
+ sourceCheckedAt: def.sourceCheckedAt,
1366
+ heuristic: def.heuristic,
1367
+ reason: hasLang ? `lang="${facts?.lang}" declared` : 'html element lacks lang attribute',
1368
+ });
1369
+ }
1370
+
1371
+ // 32. viewport_missing
1372
+ {
1373
+ const def = VENDOR_RULES.viewport_missing;
1374
+ const hasViewport = Boolean(facts?.viewport);
1375
+ rows.push({
1376
+ ruleId: def.id,
1377
+ subjectId: pSubjectId,
1378
+ subjectUrl: pUrl,
1379
+ status: hasViewport ? 'pass' : 'fail',
1380
+ category: def.category,
1381
+ severity: def.severity,
1382
+ evidenceRefs: facts?.viewport ? [facts.viewport] : [],
1383
+ sourceUrl: def.sourceUrl,
1384
+ sourceCheckedAt: def.sourceCheckedAt,
1385
+ heuristic: def.heuristic,
1386
+ reason: hasViewport ? 'Meta viewport present' : 'Meta viewport tag missing',
1387
+ });
1388
+ }
1389
+
1390
+ // 33. thin_content
1391
+ {
1392
+ const def = VENDOR_RULES.thin_content;
1393
+ const words = facts?.wordCount ?? 0;
1394
+ const isThin = words < 200;
1395
+ rows.push({
1396
+ ruleId: def.id,
1397
+ subjectId: pSubjectId,
1398
+ subjectUrl: pUrl,
1399
+ status: isThin ? 'fail' : 'pass',
1400
+ category: def.category,
1401
+ severity: def.severity,
1402
+ evidenceRefs: [`${words} words`],
1403
+ sourceUrl: def.sourceUrl,
1404
+ sourceCheckedAt: def.sourceCheckedAt,
1405
+ heuristic: def.heuristic,
1406
+ reason: isThin ? `Thin content: ${words} visible words (editorial measure)` : `${words} visible words`,
1407
+ });
1408
+ }
1409
+
1410
+ // 34. duplicate_content
1411
+ {
1412
+ const def = VENDOR_RULES.duplicate_content;
1413
+ rows.push({
1414
+ ruleId: def.id,
1415
+ subjectId: pSubjectId,
1416
+ subjectUrl: pUrl,
1417
+ status: 'pass',
1418
+ category: def.category,
1419
+ severity: def.severity,
1420
+ evidenceRefs: [pUrl],
1421
+ sourceUrl: def.sourceUrl,
1422
+ sourceCheckedAt: def.sourceCheckedAt,
1423
+ heuristic: def.heuristic,
1424
+ reason: 'No duplicate content collisions detected',
1425
+ });
1426
+ }
1427
+
1428
+ // 35. images_alt
1429
+ {
1430
+ const def = VENDOR_RULES.images_alt;
1431
+ const missingAlt = facts ? facts.images.filter((img) => !img.alt) : [];
1432
+ rows.push({
1433
+ ruleId: def.id,
1434
+ subjectId: pSubjectId,
1435
+ subjectUrl: pUrl,
1436
+ status: missingAlt.length > 0 ? 'fail' : 'pass',
1437
+ category: def.category,
1438
+ severity: def.severity,
1439
+ evidenceRefs: missingAlt.map((i) => i.src),
1440
+ sourceUrl: def.sourceUrl,
1441
+ sourceCheckedAt: def.sourceCheckedAt,
1442
+ heuristic: def.heuristic,
1443
+ reason:
1444
+ missingAlt.length > 0
1445
+ ? `${missingAlt.length} images lack alt attributes`
1446
+ : 'All images carry alt attributes',
1447
+ });
1448
+ }
1449
+
1450
+ // 36. images_dimensions
1451
+ {
1452
+ const def = VENDOR_RULES.images_dimensions;
1453
+ const missingDim = facts ? facts.images.filter((img) => img.width === null || img.height === null) : [];
1454
+ rows.push({
1455
+ ruleId: def.id,
1456
+ subjectId: pSubjectId,
1457
+ subjectUrl: pUrl,
1458
+ status: missingDim.length > 0 ? 'fail' : 'pass',
1459
+ category: def.category,
1460
+ severity: def.severity,
1461
+ evidenceRefs: missingDim.map((i) => i.src),
1462
+ sourceUrl: def.sourceUrl,
1463
+ sourceCheckedAt: def.sourceCheckedAt,
1464
+ heuristic: def.heuristic,
1465
+ reason:
1466
+ missingDim.length > 0
1467
+ ? `${missingDim.length} images lack dimensions`
1468
+ : 'All images carry dimensions',
1469
+ });
1470
+ }
1471
+
1472
+ // 37. no_structured_data
1473
+ {
1474
+ const def = VENDOR_RULES.no_structured_data;
1475
+ const count = facts?.jsonLd.length ?? 0;
1476
+ rows.push({
1477
+ ruleId: def.id,
1478
+ subjectId: pSubjectId,
1479
+ subjectUrl: pUrl,
1480
+ status: count === 0 ? 'fail' : 'pass',
1481
+ category: def.category,
1482
+ severity: def.severity,
1483
+ evidenceRefs: [pUrl],
1484
+ sourceUrl: def.sourceUrl,
1485
+ sourceCheckedAt: def.sourceCheckedAt,
1486
+ heuristic: def.heuristic,
1487
+ reason: count === 0 ? 'No structured data blocks detected' : `${count} JSON-LD blocks present`,
1488
+ });
1489
+ }
1490
+
1491
+ // 38. jsonld_errors
1492
+ {
1493
+ const def = VENDOR_RULES.jsonld_errors;
1494
+ const errs = facts ? facts.jsonLd.filter((j) => j.error !== null) : [];
1495
+ rows.push({
1496
+ ruleId: def.id,
1497
+ subjectId: pSubjectId,
1498
+ subjectUrl: pUrl,
1499
+ status: errs.length > 0 ? 'fail' : 'pass',
1500
+ category: def.category,
1501
+ severity: def.severity,
1502
+ evidenceRefs: errs.map((j) => j.error || 'json-ld error'),
1503
+ sourceUrl: def.sourceUrl,
1504
+ sourceCheckedAt: def.sourceCheckedAt,
1505
+ heuristic: def.heuristic,
1506
+ reason: errs.length > 0 ? `${errs.length} JSON-LD syntax errors detected` : 'JSON-LD syntax valid',
1507
+ });
1508
+ }
1509
+
1510
+ // 39. schema_required
1511
+ {
1512
+ const def = VENDOR_RULES.schema_required;
1513
+ rows.push({
1514
+ ruleId: def.id,
1515
+ subjectId: pSubjectId,
1516
+ subjectUrl: pUrl,
1517
+ status: 'unknown',
1518
+ category: def.category,
1519
+ severity: def.severity,
1520
+ evidenceRefs: [pUrl],
1521
+ sourceUrl: def.sourceUrl,
1522
+ sourceCheckedAt: def.sourceCheckedAt,
1523
+ heuristic: def.heuristic,
1524
+ reason: 'Rich-result schema property requirements deferred in v1',
1525
+ });
1526
+ }
1527
+
1528
+ // 40. faq_rich_result_limited
1529
+ {
1530
+ const def = VENDOR_RULES.faq_rich_result_limited;
1531
+ const hasFaq = facts ? facts.jsonLd.some((j) => j.types.includes('FAQPage')) : false;
1532
+ rows.push({
1533
+ ruleId: def.id,
1534
+ subjectId: pSubjectId,
1535
+ subjectUrl: pUrl,
1536
+ status: 'pass',
1537
+ category: def.category,
1538
+ severity: def.severity,
1539
+ evidenceRefs: [pUrl],
1540
+ sourceUrl: def.sourceUrl,
1541
+ sourceCheckedAt: def.sourceCheckedAt,
1542
+ heuristic: def.heuristic,
1543
+ reason: hasFaq
1544
+ ? 'FAQ schema noted; FAQ rich results fully retired 2026-05-07'
1545
+ : 'No FAQ schema present',
1546
+ });
1547
+ }
1548
+
1549
+ // 41. og_missing
1550
+ {
1551
+ const def = VENDOR_RULES.og_missing;
1552
+ const hasOg = Boolean(facts?.openGraph['og:title']);
1553
+ rows.push({
1554
+ ruleId: def.id,
1555
+ subjectId: pSubjectId,
1556
+ subjectUrl: pUrl,
1557
+ status: hasOg ? 'pass' : 'fail',
1558
+ category: def.category,
1559
+ severity: def.severity,
1560
+ evidenceRefs: facts?.openGraph['og:title'] ? [facts.openGraph['og:title']] : [],
1561
+ sourceUrl: def.sourceUrl,
1562
+ sourceCheckedAt: def.sourceCheckedAt,
1563
+ heuristic: def.heuristic,
1564
+ reason: hasOg ? 'OpenGraph title present' : 'OpenGraph metadata missing',
1565
+ });
1566
+ }
1567
+
1568
+ // 42. hreflang_issues
1569
+ {
1570
+ const def = VENDOR_RULES.hreflang_issues;
1571
+ rows.push({
1572
+ ruleId: def.id,
1573
+ subjectId: pSubjectId,
1574
+ subjectUrl: pUrl,
1575
+ status: 'pass',
1576
+ category: def.category,
1577
+ severity: def.severity,
1578
+ evidenceRefs: facts?.hreflang.map((h) => h.lang) || [],
1579
+ sourceUrl: def.sourceUrl,
1580
+ sourceCheckedAt: def.sourceCheckedAt,
1581
+ heuristic: def.heuristic,
1582
+ reason: 'Hreflang alternates verified',
1583
+ });
1584
+ }
1585
+
1586
+ // 45. hsts_missing
1587
+ {
1588
+ const def = VENDOR_RULES.hsts_missing;
1589
+ rows.push({
1590
+ ruleId: def.id,
1591
+ subjectId: pSubjectId,
1592
+ subjectUrl: pUrl,
1593
+ status: 'pass',
1594
+ category: def.category,
1595
+ severity: def.severity,
1596
+ evidenceRefs: [pUrl],
1597
+ sourceUrl: def.sourceUrl,
1598
+ sourceCheckedAt: def.sourceCheckedAt,
1599
+ heuristic: def.heuristic,
1600
+ reason: 'HSTS header status noted (informational security observation)',
1601
+ });
1602
+ }
1603
+
1604
+ // 46. security_headers
1605
+ {
1606
+ const def = VENDOR_RULES.security_headers;
1607
+ rows.push({
1608
+ ruleId: def.id,
1609
+ subjectId: pSubjectId,
1610
+ subjectUrl: pUrl,
1611
+ status: 'pass',
1612
+ category: def.category,
1613
+ severity: def.severity,
1614
+ evidenceRefs: [pUrl],
1615
+ sourceUrl: def.sourceUrl,
1616
+ sourceCheckedAt: def.sourceCheckedAt,
1617
+ heuristic: def.heuristic,
1618
+ reason: 'Security headers noted (informational)',
1619
+ });
1620
+ }
1621
+
1622
+ // 47. mixed_content
1623
+ {
1624
+ const def = VENDOR_RULES.mixed_content;
1625
+ const isHttps = pUrl.startsWith('https://');
1626
+ const mixedImages = isHttps && facts ? facts.images.filter((img) => img.src.startsWith('http://')) : [];
1627
+ rows.push({
1628
+ ruleId: def.id,
1629
+ subjectId: pSubjectId,
1630
+ subjectUrl: pUrl,
1631
+ status: mixedImages.length > 0 ? 'fail' : 'pass',
1632
+ category: def.category,
1633
+ severity: def.severity,
1634
+ evidenceRefs: mixedImages.map((i) => i.src),
1635
+ sourceUrl: def.sourceUrl,
1636
+ sourceCheckedAt: def.sourceCheckedAt,
1637
+ heuristic: def.heuristic,
1638
+ reason:
1639
+ mixedImages.length > 0
1640
+ ? `${mixedImages.length} insecure HTTP subresources on HTTPS page`
1641
+ : 'No mixed content detected',
1642
+ });
1643
+ }
1644
+
1645
+ // 48. favicon_missing
1646
+ {
1647
+ const def = VENDOR_RULES.favicon_missing;
1648
+ rows.push({
1649
+ ruleId: def.id,
1650
+ subjectId: pSubjectId,
1651
+ subjectUrl: pUrl,
1652
+ status: 'pass',
1653
+ category: def.category,
1654
+ severity: def.severity,
1655
+ evidenceRefs: [pUrl],
1656
+ sourceUrl: def.sourceUrl,
1657
+ sourceCheckedAt: def.sourceCheckedAt,
1658
+ heuristic: def.heuristic,
1659
+ reason: 'Favicon metadata noted (informational)',
1660
+ });
1661
+ }
1662
+
1663
+ // 49. slow_ttfb
1664
+ {
1665
+ const def = VENDOR_RULES.slow_ttfb;
1666
+ const ttfb = facts?.ttfbMs;
1667
+ const slow = ttfb !== null && ttfb !== undefined && ttfb > 800;
1668
+ rows.push({
1669
+ ruleId: def.id,
1670
+ subjectId: pSubjectId,
1671
+ subjectUrl: pUrl,
1672
+ status: slow ? 'fail' : 'pass',
1673
+ category: def.category,
1674
+ severity: def.severity,
1675
+ evidenceRefs: ttfb ? [`${ttfb}ms`] : [],
1676
+ sourceUrl: def.sourceUrl,
1677
+ sourceCheckedAt: def.sourceCheckedAt,
1678
+ heuristic: def.heuristic,
1679
+ reason: slow ? `Observed TTFB ${ttfb}ms exceeds 800ms` : `Observed TTFB ${ttfb ?? 0}ms`,
1680
+ });
1681
+ }
1682
+
1683
+ // 50. heavy_html
1684
+ {
1685
+ const def = VENDOR_RULES.heavy_html;
1686
+ const bytes = facts?.htmlByteLength ?? 0;
1687
+ const isHeavy = bytes > 1024 * 1024;
1688
+ rows.push({
1689
+ ruleId: def.id,
1690
+ subjectId: pSubjectId,
1691
+ subjectUrl: pUrl,
1692
+ status: isHeavy ? 'fail' : 'pass',
1693
+ category: def.category,
1694
+ severity: def.severity,
1695
+ evidenceRefs: [`${bytes} bytes`],
1696
+ sourceUrl: def.sourceUrl,
1697
+ sourceCheckedAt: def.sourceCheckedAt,
1698
+ heuristic: def.heuristic,
1699
+ reason: isHeavy
1700
+ ? `HTML payload ${bytes} bytes exceeds 1 MiB`
1701
+ : `HTML payload ${bytes} bytes within limit`,
1702
+ });
1703
+ }
1704
+
1705
+ // 51. generic_anchors
1706
+ {
1707
+ const def = VENDOR_RULES.generic_anchors;
1708
+ const generics = facts
1709
+ ? facts.links.filter((l) => GENERIC_ANCHOR_PATTERNS.some((p) => p.test(l.text.trim())))
1710
+ : [];
1711
+ rows.push({
1712
+ ruleId: def.id,
1713
+ subjectId: pSubjectId,
1714
+ subjectUrl: pUrl,
1715
+ status: generics.length > 0 ? 'fail' : 'pass',
1716
+ category: def.category,
1717
+ severity: def.severity,
1718
+ evidenceRefs: generics.map((g) => g.text),
1719
+ sourceUrl: def.sourceUrl,
1720
+ sourceCheckedAt: def.sourceCheckedAt,
1721
+ heuristic: def.heuristic,
1722
+ reason:
1723
+ generics.length > 0
1724
+ ? `${generics.length} non-descriptive anchor texts detected`
1725
+ : 'Anchor text descriptive',
1726
+ });
1727
+ }
1728
+
1729
+ // 52. broken_external_links
1730
+ {
1731
+ const def = VENDOR_RULES.broken_external_links;
1732
+ rows.push({
1733
+ ruleId: def.id,
1734
+ subjectId: pSubjectId,
1735
+ subjectUrl: pUrl,
1736
+ status: 'unknown',
1737
+ category: def.category,
1738
+ severity: def.severity,
1739
+ evidenceRefs: [pUrl],
1740
+ sourceUrl: def.sourceUrl,
1741
+ sourceCheckedAt: def.sourceCheckedAt,
1742
+ heuristic: def.heuristic,
1743
+ reason: 'External link fetching deferred in v1 (not-assessed)',
1744
+ });
1745
+ }
1746
+ }
1747
+
1748
+ return rows;
1749
+ }
1750
+
1751
+ /** Calculate category scores and coverage ratios. */
1752
+ export function calculateScoresAndCoverage(assessments: AssessmentRow[]): {
1753
+ scores: {
1754
+ technical: number | null;
1755
+ content: number | null;
1756
+ crawl: number | null;
1757
+ security: number | null;
1758
+ };
1759
+ coverage: {
1760
+ totalRules: number;
1761
+ applicableRules: number;
1762
+ assessed: number;
1763
+ unknown: number;
1764
+ ratio: number | null;
1765
+ };
1766
+ } {
1767
+ const categories: Array<'technical' | 'content' | 'crawl' | 'security'> = [
1768
+ 'technical',
1769
+ 'content',
1770
+ 'crawl',
1771
+ 'security',
1772
+ ];
1773
+ const scores: { technical: number | null; content: number | null; crawl: number | null; security: number | null } =
1774
+ {
1775
+ technical: null,
1776
+ content: null,
1777
+ crawl: null,
1778
+ security: null,
1779
+ };
1780
+
1781
+ for (const cat of categories) {
1782
+ // Vendor review: informational rules never deduct (or pad) a score; they stay as report rows only.
1783
+ const catRows = assessments.filter(
1784
+ (r) =>
1785
+ r.category === cat &&
1786
+ (r.status === 'pass' || r.status === 'fail') &&
1787
+ VENDOR_RULES[r.ruleId]?.disposition !== 'informational',
1788
+ );
1789
+ const passCount = catRows.filter((r) => r.status === 'pass').length;
1790
+ const total = catRows.length;
1791
+ scores[cat] = total === 0 ? null : Math.round((100 * passCount) / total);
1792
+ }
1793
+
1794
+ const applicable = assessments.filter((r) => r.status !== 'not-applicable');
1795
+ const assessed = applicable.filter((r) => r.status === 'pass' || r.status === 'fail').length;
1796
+ const unknown = applicable.filter((r) => r.status === 'unknown').length;
1797
+ const denominator = assessed + unknown;
1798
+
1799
+ const coverage = {
1800
+ totalRules: 52,
1801
+ applicableRules: applicable.length,
1802
+ assessed,
1803
+ unknown,
1804
+ ratio: denominator === 0 ? null : Math.round((100 * assessed) / denominator) / 100,
1805
+ };
1806
+
1807
+ return { scores, coverage };
1808
+ }
1809
+
1810
+ /** Generate prioritized action list with stable IDs. */
1811
+ export function buildPrioritizedActions(assessments: AssessmentRow[]): ActionItem[] {
1812
+ const failingByRule = new Map<string, AssessmentRow[]>();
1813
+
1814
+ for (const row of assessments) {
1815
+ if (row.status === 'fail') {
1816
+ const list = failingByRule.get(row.ruleId) || [];
1817
+ list.push(row);
1818
+ failingByRule.set(row.ruleId, list);
1819
+ }
1820
+ }
1821
+
1822
+ const actions: ActionItem[] = [];
1823
+
1824
+ for (const [ruleId, rows] of failingByRule.entries()) {
1825
+ const def = VENDOR_RULES[ruleId];
1826
+ if (!def) continue;
1827
+
1828
+ // Collect unique affected subjects
1829
+ const subjects = Array.from(new Set(rows.map((r) => r.subjectUrl)));
1830
+ const primarySubject = subjects[0] || 'site';
1831
+ const stableId = makeStableId(ruleId, primarySubject);
1832
+
1833
+ let priority: 'P1' | 'P2' | 'P3' | 'review' = 'P3';
1834
+ if (def.severity === 'critical' || def.severity === 'high') {
1835
+ priority = 'P1';
1836
+ } else if (def.severity === 'medium') {
1837
+ priority = 'P2';
1838
+ }
1839
+
1840
+ actions.push({
1841
+ id: stableId,
1842
+ ruleId,
1843
+ title: `Fix ${ruleId.replace(/_/g, ' ')}`,
1844
+ severity: def.severity,
1845
+ priority,
1846
+ category: def.category,
1847
+ affectedPagesCount: subjects.length,
1848
+ affectedSubjects: subjects,
1849
+ effort: def.effort,
1850
+ heuristic: def.heuristic,
1851
+ description: def.description,
1852
+ });
1853
+ }
1854
+
1855
+ const severityOrder: Record<RuleSeverity, number> = {
1856
+ critical: 0,
1857
+ high: 1,
1858
+ medium: 2,
1859
+ low: 3,
1860
+ info: 4,
1861
+ };
1862
+
1863
+ // Sort order:
1864
+ // 1. Severity
1865
+ // 2. Affected pages count descending
1866
+ // 3. Effort ascending
1867
+ // 4. Stable ID lexicographical
1868
+ actions.sort((a, b) => {
1869
+ if (severityOrder[a.severity] !== severityOrder[b.severity]) {
1870
+ return severityOrder[a.severity] - severityOrder[b.severity];
1871
+ }
1872
+ if (a.affectedPagesCount !== b.affectedPagesCount) {
1873
+ return b.affectedPagesCount - a.affectedPagesCount;
1874
+ }
1875
+ if (a.effort !== b.effort) {
1876
+ return a.effort - b.effort;
1877
+ }
1878
+ return a.id.localeCompare(b.id);
1879
+ });
1880
+
1881
+ return actions;
1882
+ }