@gobing-ai/knowledge-kit 0.0.36 → 0.1.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/board/collect/index.css +654 -16
- package/dist/board/collect/index.js +191 -64
- package/dist/board/daily-voice/index.css +679 -12
- package/dist/board/daily-voice/index.js +185 -66
- package/dist/board/itc/index.css +679 -13
- package/dist/board/itc/index.js +188 -66
- package/dist/index.js +2878 -152
- package/package.json +3 -2
- package/plugins/generations/content-gen/dist/index.js +158 -12
- package/plugins/generations/core-facts-gen/dist/index.js +158 -12
- package/plugins/generations/daily-article-gen/dist/index.js +158 -12
- package/plugins/generations/dailynews-gen/dist/index.js +180 -12
- package/plugins/generations/dailynews-gen/src/script-plan.ts +32 -0
- package/plugins/generations/episode-plan-gen/dist/index.js +158 -12
- package/plugins/generations/gemini-tts/README.md +4 -3
- package/plugins/generations/gemini-tts/dist/index.js +158 -12
- package/plugins/generations/image-gen/dist/index.js +158 -12
- package/plugins/generations/news-report-gen/dist/index.js +158 -12
- package/plugins/generations/omni-voice-gen/README.md +15 -4
- package/plugins/generations/omni-voice-gen/profiles.json +1 -2
- package/plugins/generations/voice-gen/dist/index.js +158 -12
- package/plugins/generations/voxcpm-tts/README.md +21 -1
- package/plugins/generations/voxcpm-tts/dist/index.js +173 -13
- package/plugins/generations/voxcpm-tts/scripts/audit-speaker-consistency.py +109 -0
- package/plugins/generations/voxcpm-tts/src/voxcpm-client.ts +28 -1
- package/plugins/ingestions/aihot-ingest/dist/index.js +158 -12
- package/plugins/ingestions/horizon-ingest/dist/index.js +158 -12
- package/plugins/ingestions/last30days-ingest/dist/index.js +158 -12
- package/plugins/ingestions/web-search/dist/index.js +158 -12
- package/plugins/kk/commands/workflow-run.md +56 -18
- package/plugins/kk/decisions/kk-itc.yaml +46 -0
- package/plugins/kk/decisions/kk-seo-keyword.yaml +57 -0
- package/plugins/kk/decisions/kk-seo-page.yaml +230 -0
- package/plugins/kk/decisions/kk-seo-pair.yaml +21 -0
- package/plugins/kk/decisions/kk-seo-site.yaml +90 -0
- package/plugins/kk/plugin.json +1 -1
- package/plugins/kk/publish-presets/chinese-article.json +14 -0
- package/plugins/kk/publish-presets/technical-article.json +14 -0
- package/plugins/kk/references/smart-seo-attribution.md +85 -0
- package/plugins/kk/scripts/daily-stages.ts +42 -2
- package/plugins/kk/scripts/daily-voice-style.zh.md +1 -1
- package/plugins/kk/scripts/itc-stages.ts +25 -118
- package/plugins/kk/scripts/kk-workflow-stages.ts +38 -1
- package/plugins/kk/scripts/seo-decisions.ts +477 -0
- package/plugins/kk/scripts/seo-enrich.ts +728 -0
- package/plugins/kk/scripts/seo-facts.ts +610 -0
- package/plugins/kk/scripts/seo-fetch.ts +766 -0
- package/plugins/kk/scripts/seo-report.ts +460 -0
- package/plugins/kk/scripts/seo-rules.ts +1882 -0
- package/plugins/kk/scripts/seo-stages.ts +1044 -0
- package/plugins/kk/skills/article-adapt/SKILL.md +2 -2
- package/plugins/kk/skills/smart-seo/SKILL.md +183 -0
- package/plugins/kk/skills/smart-seo/references/method.md +126 -0
- package/plugins/kk/skills/smart-seo/references/narrative.md +125 -0
- package/plugins/kk/skills/smart-seo/references/operations.md +202 -0
- package/plugins/kk/skills/smart-seo/references/seo-geo-sources.md +90 -0
- package/plugins/kk/workflows/feature-verification.yaml +28 -24
- package/plugins/kk/workflows/kk-daily-ai-voice2.yaml +86 -18
- package/plugins/kk/workflows/kk-itc-author.yaml +406 -0
- package/plugins/kk/workflows/kk-itc.yaml +208 -682
- package/plugins/kk/workflows/kk-smart-seo.yaml +251 -0
- package/plugins/publishings/bilibili-pub/dist/index.js +21782 -0
- package/plugins/publishings/bilibili-pub/dist/profile-check.js +20410 -0
- package/plugins/publishings/bilibili-pub/package.json +20 -0
- package/plugins/publishings/bilibili-pub/plugin.json +12 -0
- package/plugins/publishings/bilibili-pub/src/flow.ts +1296 -0
- package/plugins/publishings/bilibili-pub/src/index.ts +139 -0
- package/plugins/publishings/bilibili-pub/src/profile-check.ts +19 -0
- package/plugins/publishings/bilibili-pub/src/selectors.ts +222 -0
- package/plugins/publishings/bilibili-pub/tsconfig.json +4 -0
- package/plugins/publishings/cnblogs-pub/dist/index.js +21383 -0
- package/plugins/publishings/cnblogs-pub/dist/profile-check.js +20412 -0
- package/plugins/publishings/cnblogs-pub/package.json +19 -0
- package/plugins/publishings/cnblogs-pub/plugin.json +12 -0
- package/plugins/publishings/cnblogs-pub/src/flow.ts +708 -0
- package/plugins/publishings/cnblogs-pub/src/index.ts +172 -0
- package/plugins/publishings/cnblogs-pub/src/profile-check.ts +24 -0
- package/plugins/publishings/cnblogs-pub/src/selectors.ts +104 -0
- package/plugins/publishings/cnblogs-pub/tsconfig.json +4 -0
- package/plugins/publishings/csdn-pub/dist/index.js +21552 -0
- package/plugins/publishings/csdn-pub/dist/profile-check.js +20963 -0
- package/plugins/publishings/csdn-pub/package.json +19 -0
- package/plugins/publishings/csdn-pub/plugin.json +12 -0
- package/plugins/publishings/csdn-pub/src/index.ts +1178 -0
- package/plugins/publishings/csdn-pub/src/profile-check.ts +23 -0
- package/plugins/publishings/csdn-pub/tsconfig.json +4 -0
- package/plugins/publishings/emdash-pub/dist/index.js +158 -12
- package/plugins/publishings/emdash-pub/plugin.json +22 -1
- package/plugins/publishings/infoq-pub/dist/index.js +160 -12
- package/plugins/publishings/infoq-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/infoq-pub/plugin.json +21 -0
- package/plugins/publishings/jike-pub/dist/index.js +21379 -0
- package/plugins/publishings/jike-pub/dist/profile-check.js +20951 -0
- package/plugins/publishings/jike-pub/package.json +19 -0
- package/plugins/publishings/jike-pub/plugin.json +12 -0
- package/plugins/publishings/jike-pub/src/index.ts +800 -0
- package/plugins/publishings/jike-pub/src/profile-check.ts +21 -0
- package/plugins/publishings/jike-pub/tsconfig.json +4 -0
- package/plugins/publishings/juejin-pub/dist/index.js +160 -12
- package/plugins/publishings/juejin-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/juejin-pub/plugin.json +21 -0
- package/plugins/publishings/medium-pub/dist/index.js +158 -12
- package/plugins/publishings/medium-pub/plugin.json +22 -1
- package/plugins/publishings/podcast-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/dist/index.js +158 -12
- package/plugins/publishings/qiita-pub/plugin.json +24 -1
- package/plugins/publishings/shipinhao-pub/dist/index.js +21715 -0
- package/plugins/publishings/shipinhao-pub/dist/profile-check.js +20431 -0
- package/plugins/publishings/shipinhao-pub/package.json +19 -0
- package/plugins/publishings/shipinhao-pub/plugin.json +12 -0
- package/plugins/publishings/shipinhao-pub/src/flow.ts +1323 -0
- package/plugins/publishings/shipinhao-pub/src/index.ts +173 -0
- package/plugins/publishings/shipinhao-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/shipinhao-pub/src/selectors.ts +241 -0
- package/plugins/publishings/shipinhao-pub/tsconfig.json +4 -0
- package/plugins/publishings/sspai-pub/dist/index.js +21371 -0
- package/plugins/publishings/sspai-pub/dist/profile-check.js +20414 -0
- package/plugins/publishings/sspai-pub/package.json +19 -0
- package/plugins/publishings/sspai-pub/plugin.json +12 -0
- package/plugins/publishings/sspai-pub/src/flow.ts +624 -0
- package/plugins/publishings/sspai-pub/src/index.ts +167 -0
- package/plugins/publishings/sspai-pub/src/profile-check.ts +25 -0
- package/plugins/publishings/sspai-pub/src/selectors.ts +122 -0
- package/plugins/publishings/sspai-pub/tsconfig.json +4 -0
- package/plugins/publishings/substack-pub/dist/index.js +160 -12
- package/plugins/publishings/substack-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/substack-pub/plugin.json +21 -0
- package/plugins/publishings/surfdash-pub/dist/index.js +158 -12
- package/plugins/publishings/surfdash-pub/plugin.json +22 -1
- package/plugins/publishings/threads-pub/dist/index.js +158 -12
- package/plugins/publishings/wechatmp-pub/dist/index.js +1669 -502
- package/plugins/publishings/wechatmp-pub/dist/profile-check.js +204 -21
- package/plugins/publishings/wechatmp-pub/plugin.json +32 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-draft.ts +540 -0
- package/plugins/publishings/wechatmp-pub/src/cgi-media.ts +380 -0
- package/plugins/publishings/wechatmp-pub/src/cgi.ts +485 -0
- package/plugins/publishings/wechatmp-pub/src/flow.ts +104 -0
- package/plugins/publishings/weibo-pub/dist/index.js +160 -12
- package/plugins/publishings/weibo-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/weibo-pub/plugin.json +35 -0
- package/plugins/publishings/x-pub/dist/index.js +160 -12
- package/plugins/publishings/x-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/x-pub/plugin.json +40 -0
- package/plugins/publishings/xhs-pub/dist/index.js +160 -12
- package/plugins/publishings/xhs-pub/dist/profile-check.js +160 -12
- package/plugins/publishings/xhs-pub/plugin.json +37 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/index.js +21491 -0
- package/plugins/publishings/xiaoyuzhou-pub/dist/profile-check.js +20417 -0
- package/plugins/publishings/xiaoyuzhou-pub/package.json +19 -0
- package/plugins/publishings/xiaoyuzhou-pub/plugin.json +12 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/flow.ts +826 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/index.ts +174 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/profile-check.ts +26 -0
- package/plugins/publishings/xiaoyuzhou-pub/src/selectors.ts +125 -0
- package/plugins/publishings/xiaoyuzhou-pub/tsconfig.json +4 -0
- package/plugins/publishings/zenn-pub/dist/index.js +158 -12
- package/plugins/publishings/zenn-pub/plugin.json +22 -1
- 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
|
+
}
|