site-agent-pro 1.0.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/README.md +689 -0
- package/dist/auth/credentialStore.js +62 -0
- package/dist/auth/inbox.js +193 -0
- package/dist/auth/profile.js +379 -0
- package/dist/auth/runner.js +1124 -0
- package/dist/backend/dashboardData.js +194 -0
- package/dist/backend/runArtifacts.js +48 -0
- package/dist/backend/runRepository.js +93 -0
- package/dist/bin.js +2 -0
- package/dist/cli/backfillSiteChecks.js +143 -0
- package/dist/cli/run.js +309 -0
- package/dist/cli/trade.js +69 -0
- package/dist/config.js +199 -0
- package/dist/core/agentProfiles.js +55 -0
- package/dist/core/aggregateReport.js +382 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/customTaskSuite.js +148 -0
- package/dist/core/evaluator.js +217 -0
- package/dist/core/executor.js +788 -0
- package/dist/core/fallbackReport.js +335 -0
- package/dist/core/formHeuristics.js +411 -0
- package/dist/core/gameplaySummary.js +164 -0
- package/dist/core/interaction.js +202 -0
- package/dist/core/pageState.js +201 -0
- package/dist/core/planner.js +1669 -0
- package/dist/core/processSubmissionBatch.js +204 -0
- package/dist/core/runAuditJob.js +170 -0
- package/dist/core/runner.js +2352 -0
- package/dist/core/siteBrief.js +107 -0
- package/dist/core/siteChecks.js +1526 -0
- package/dist/core/taskDirectives.js +279 -0
- package/dist/core/taskHeuristics.js +263 -0
- package/dist/dashboard/client.js +1256 -0
- package/dist/dashboard/contracts.js +95 -0
- package/dist/dashboard/narrative.js +277 -0
- package/dist/dashboard/server.js +458 -0
- package/dist/dashboard/theme.js +888 -0
- package/dist/index.js +84 -0
- package/dist/llm/client.js +188 -0
- package/dist/paystack/account.js +123 -0
- package/dist/paystack/client.js +100 -0
- package/dist/paystack/index.js +13 -0
- package/dist/paystack/test-paystack.js +83 -0
- package/dist/paystack/transfer.js +138 -0
- package/dist/paystack/types.js +74 -0
- package/dist/paystack/webhook.js +121 -0
- package/dist/prompts/browserAgent.js +124 -0
- package/dist/prompts/reviewer.js +71 -0
- package/dist/reporting/clickReplay.js +290 -0
- package/dist/reporting/html.js +930 -0
- package/dist/reporting/markdown.js +238 -0
- package/dist/reporting/template.js +1141 -0
- package/dist/schemas/types.js +361 -0
- package/dist/submissions/customTasks.js +196 -0
- package/dist/submissions/html.js +770 -0
- package/dist/submissions/model.js +56 -0
- package/dist/submissions/publicUrl.js +76 -0
- package/dist/submissions/service.js +74 -0
- package/dist/submissions/store.js +37 -0
- package/dist/submissions/types.js +65 -0
- package/dist/trade/engine.js +241 -0
- package/dist/trade/evm/erc20.js +44 -0
- package/dist/trade/extractor.js +148 -0
- package/dist/trade/policy.js +35 -0
- package/dist/trade/session.js +31 -0
- package/dist/trade/types.js +107 -0
- package/dist/trade/validator.js +148 -0
- package/dist/utils/files.js +59 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/playwrightCompat.js +14 -0
- package/dist/utils/time.js +3 -0
- package/dist/wallet/provider.js +345 -0
- package/dist/wallet/relay.js +129 -0
- package/dist/wallet/wallet.js +178 -0
- package/docs/01-installation.md +134 -0
- package/docs/02-running-your-first-audit.md +136 -0
- package/docs/03-configuration.md +233 -0
- package/docs/04-how-the-agent-thinks.md +41 -0
- package/docs/05-extending-personas-and-tasks.md +42 -0
- package/docs/06-hardening-for-production.md +92 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1526 @@
|
|
|
1
|
+
import { devices } from "playwright";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { PageProbeSchema, SiteChecksSchema } from "../schemas/types.js";
|
|
4
|
+
import { installPlaywrightPageCompat } from "../utils/playwrightCompat.js";
|
|
5
|
+
const CTA_KEYWORDS = [
|
|
6
|
+
"get started",
|
|
7
|
+
"start now",
|
|
8
|
+
"start free",
|
|
9
|
+
"book",
|
|
10
|
+
"buy",
|
|
11
|
+
"shop",
|
|
12
|
+
"sign up",
|
|
13
|
+
"sign in",
|
|
14
|
+
"subscribe",
|
|
15
|
+
"contact",
|
|
16
|
+
"request demo",
|
|
17
|
+
"schedule",
|
|
18
|
+
"try",
|
|
19
|
+
"join",
|
|
20
|
+
"create",
|
|
21
|
+
"checkout",
|
|
22
|
+
"add to cart",
|
|
23
|
+
"learn more"
|
|
24
|
+
];
|
|
25
|
+
const TRUST_SIGNAL_KEYWORDS = [
|
|
26
|
+
"testimonial",
|
|
27
|
+
"testimonials",
|
|
28
|
+
"review",
|
|
29
|
+
"reviews",
|
|
30
|
+
"trusted",
|
|
31
|
+
"secure",
|
|
32
|
+
"guarantee",
|
|
33
|
+
"guaranteed",
|
|
34
|
+
"refund",
|
|
35
|
+
"privacy",
|
|
36
|
+
"terms",
|
|
37
|
+
"contact",
|
|
38
|
+
"support",
|
|
39
|
+
"verified",
|
|
40
|
+
"customers"
|
|
41
|
+
];
|
|
42
|
+
const FRAMEWORK_PATTERNS = [
|
|
43
|
+
{ label: "WordPress", pattern: /wp-content|wp-includes|wordpress/i },
|
|
44
|
+
{ label: "Shopify", pattern: /cdn\.shopify|shopify/i },
|
|
45
|
+
{ label: "Next.js", pattern: /_next\/|__next|next-data/i },
|
|
46
|
+
{ label: "Nuxt", pattern: /_nuxt\/|__nuxt/i },
|
|
47
|
+
{ label: "Wix", pattern: /wixstatic|wix\.com/i },
|
|
48
|
+
{ label: "Webflow", pattern: /webflow/i },
|
|
49
|
+
{ label: "Squarespace", pattern: /static\.squarespace|squarespace/i }
|
|
50
|
+
];
|
|
51
|
+
const SECURITY_HEADERS = [
|
|
52
|
+
"strict-transport-security",
|
|
53
|
+
"content-security-policy",
|
|
54
|
+
"x-frame-options",
|
|
55
|
+
"x-content-type-options",
|
|
56
|
+
"referrer-policy",
|
|
57
|
+
"permissions-policy"
|
|
58
|
+
];
|
|
59
|
+
const IMAGE_URL_PATTERN = /\.(?:avif|gif|ico|jpe?g|png|svg|webp)(?:[?#]|$)/i;
|
|
60
|
+
const API_URL_PATTERN = /\/api(?:\/|$)|graphql|wp-json|\.json(?:[?#]|$)/i;
|
|
61
|
+
const NON_HTML_RESOURCE_PATTERN = /\.(?:pdf|jpe?g|png|gif|svg|zip|mp4|mp3|css|js)(?:[?#]|$)/i;
|
|
62
|
+
const GENERIC_ANCHOR_PATTERN = /^(?:click here|read more|learn more|more|here|details|view more|see more)$/i;
|
|
63
|
+
const NON_DESCRIPTIVE_IMAGE_PATTERN = /^(?:img|image|photo|pic|dsc|screenshot|banner|hero)[-_]?\d+/i;
|
|
64
|
+
const PLACEHOLDER_CONTENT_PATTERN = /\b(?:lorem ipsum|coming soon|under construction|placeholder|sample text|dummy text|tbd|todo)\b/i;
|
|
65
|
+
const SEO_CRAWL_MAX_PAGES = 50;
|
|
66
|
+
const SEO_CRAWL_MAX_DEPTH = 3;
|
|
67
|
+
const SITE_CHECK_METRICS_INIT_SCRIPT = `
|
|
68
|
+
window.__siteAgentMetrics = { fcp: null, lcp: null, cls: 0 };
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
new PerformanceObserver((list) => {
|
|
72
|
+
for (const entry of list.getEntries()) {
|
|
73
|
+
if (entry.name === "first-contentful-paint") {
|
|
74
|
+
window.__siteAgentMetrics.fcp = entry.startTime;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}).observe({ type: "paint", buffered: true });
|
|
78
|
+
|
|
79
|
+
new PerformanceObserver((list) => {
|
|
80
|
+
const entries = list.getEntries();
|
|
81
|
+
const last = entries[entries.length - 1];
|
|
82
|
+
if (last) {
|
|
83
|
+
window.__siteAgentMetrics.lcp = last.startTime;
|
|
84
|
+
}
|
|
85
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
86
|
+
|
|
87
|
+
new PerformanceObserver((list) => {
|
|
88
|
+
for (const entry of list.getEntries()) {
|
|
89
|
+
if (!entry.hadRecentInput) {
|
|
90
|
+
window.__siteAgentMetrics.cls += entry.value ?? 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// Some browsers or pages may block performance observers.
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
function cleanErrorMessage(error) {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
100
|
+
return message.replace(/\u001b\[[0-9;]*m/g, "").replace(/\s+/g, " ").trim() || "Unknown error";
|
|
101
|
+
}
|
|
102
|
+
function classifyCrawlRequestFailure(error) {
|
|
103
|
+
const message = cleanErrorMessage(error).toLowerCase();
|
|
104
|
+
if (/timed out|timeout/.test(message)) {
|
|
105
|
+
return "request timeout";
|
|
106
|
+
}
|
|
107
|
+
if (/enotfound|name_not_resolved|dns|domain/.test(message)) {
|
|
108
|
+
return "dns or host failure";
|
|
109
|
+
}
|
|
110
|
+
if (/econnrefused|connection refused|err_connection_refused/.test(message)) {
|
|
111
|
+
return "connection refused";
|
|
112
|
+
}
|
|
113
|
+
if (/ssl|tls|certificate|cert_/.test(message)) {
|
|
114
|
+
return "tls or certificate failure";
|
|
115
|
+
}
|
|
116
|
+
if (/net::err_|network/.test(message)) {
|
|
117
|
+
return "network request failure";
|
|
118
|
+
}
|
|
119
|
+
return "request failed";
|
|
120
|
+
}
|
|
121
|
+
function normalizeText(value) {
|
|
122
|
+
return value.replace(/\s+/g, " ").trim();
|
|
123
|
+
}
|
|
124
|
+
function uniqueItems(items, limit) {
|
|
125
|
+
return [...new Set(items.map((item) => normalizeText(item)).filter(Boolean))].slice(0, limit);
|
|
126
|
+
}
|
|
127
|
+
function ensureSentence(value) {
|
|
128
|
+
const trimmed = normalizeText(value);
|
|
129
|
+
if (!trimmed) {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
133
|
+
}
|
|
134
|
+
function buildCoverage(status, summary, evidence = [], blockers = []) {
|
|
135
|
+
return {
|
|
136
|
+
status,
|
|
137
|
+
summary: ensureSentence(summary),
|
|
138
|
+
evidence: uniqueItems(evidence.map((item) => ensureSentence(item)), 5),
|
|
139
|
+
blockers: uniqueItems(blockers.map((item) => ensureSentence(item)), 4)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function isHttpUrl(value) {
|
|
143
|
+
if (!value) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const protocol = new URL(value).protocol;
|
|
148
|
+
return protocol === "http:" || protocol === "https:";
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function hasProbeDomEvidence(probe) {
|
|
155
|
+
if (!probe) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return Boolean(probe.title ||
|
|
159
|
+
probe.metaDescription ||
|
|
160
|
+
probe.h1Count > 0 ||
|
|
161
|
+
probe.h2Count > 0 ||
|
|
162
|
+
probe.visibleLinkCount > 0 ||
|
|
163
|
+
probe.internalLinkSamples.length > 0 ||
|
|
164
|
+
probe.ctaSamples.length > 0 ||
|
|
165
|
+
probe.formCount > 0 ||
|
|
166
|
+
probe.wordCount > 0 ||
|
|
167
|
+
probe.mediaCount > 0 ||
|
|
168
|
+
probe.heroText);
|
|
169
|
+
}
|
|
170
|
+
function hasPerformanceEvidence(probe) {
|
|
171
|
+
if (!probe) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return Boolean(hasProbeDomEvidence(probe) ||
|
|
175
|
+
probe.performance.domContentLoadedMs !== null ||
|
|
176
|
+
probe.performance.loadMs !== null ||
|
|
177
|
+
probe.performance.firstContentfulPaintMs !== null ||
|
|
178
|
+
probe.performance.largestContentfulPaintMs !== null ||
|
|
179
|
+
probe.performance.cumulativeLayoutShift !== null);
|
|
180
|
+
}
|
|
181
|
+
function hasContentEvidence(probe) {
|
|
182
|
+
if (!probe) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return probe.wordCount > 0 || probe.mediaCount > 0 || probe.readabilityScore !== null || probe.longParagraphCount > 0;
|
|
186
|
+
}
|
|
187
|
+
function hasCroEvidence(probe) {
|
|
188
|
+
if (!probe) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return probe.ctaSamples.length > 0 || probe.formCount > 0 || probe.submitControlCount > 0 || probe.trustSignalCount > 0;
|
|
192
|
+
}
|
|
193
|
+
function scoreProbeCapture(capture) {
|
|
194
|
+
return [
|
|
195
|
+
capture.probe.loadOk ? 200 : 0,
|
|
196
|
+
capture.probe.statusCode !== null ? 80 : 0,
|
|
197
|
+
Object.keys(capture.headers).length > 0 ? 60 : 0,
|
|
198
|
+
hasPerformanceEvidence(capture.probe) ? 40 : 0,
|
|
199
|
+
hasProbeDomEvidence(capture.probe) ? 30 : 0,
|
|
200
|
+
capture.probe.wordCount > 0 ? 20 : 0,
|
|
201
|
+
isHttpUrl(capture.probe.finalUrl) ? 10 : 0
|
|
202
|
+
].reduce((sum, value) => sum + value, 0);
|
|
203
|
+
}
|
|
204
|
+
function choosePreferredCapture(captures) {
|
|
205
|
+
return [...captures].sort((left, right) => scoreProbeCapture(right) - scoreProbeCapture(left))[0] ?? captures[0];
|
|
206
|
+
}
|
|
207
|
+
function buildEmptySeoPageStats() {
|
|
208
|
+
return {
|
|
209
|
+
pagesMissingTitle: 0,
|
|
210
|
+
pagesBadTitleLength: 0,
|
|
211
|
+
pagesMissingMetaDescription: 0,
|
|
212
|
+
pagesBadMetaDescriptionLength: 0,
|
|
213
|
+
pagesMissingCanonical: 0,
|
|
214
|
+
pagesNonSelfCanonical: 0,
|
|
215
|
+
noindexPages: 0,
|
|
216
|
+
nofollowPages: 0,
|
|
217
|
+
pagesMissingViewport: 0,
|
|
218
|
+
pagesMissingCharset: 0,
|
|
219
|
+
pagesWithStructuredData: 0,
|
|
220
|
+
pagesMissingOpenGraphBasics: 0,
|
|
221
|
+
pagesMissingTwitterCard: 0,
|
|
222
|
+
pagesWithUrlIssues: 0,
|
|
223
|
+
pagesMissingH1: 0,
|
|
224
|
+
pagesWithMultipleH1: 0,
|
|
225
|
+
pagesWithHeadingOrderIssues: 0,
|
|
226
|
+
pagesLowWordCount: 0,
|
|
227
|
+
pagesThinOrPlaceholder: 0,
|
|
228
|
+
pagesWithGenericAnchors: 0,
|
|
229
|
+
imagesMissingAlt: 0,
|
|
230
|
+
imagesWithNonDescriptiveFilenames: 0,
|
|
231
|
+
pagesWithRenderBlockingHeadScripts: 0,
|
|
232
|
+
pagesWithNonLazyImages: 0,
|
|
233
|
+
pagesWithResourceHints: 0,
|
|
234
|
+
pagesMissingLang: 0,
|
|
235
|
+
pagesWithUnlabeledInputs: 0,
|
|
236
|
+
pagesWithUnlabeledInteractive: 0,
|
|
237
|
+
pagesMissingSkipNav: 0
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function buildEmptySeoCrawlResult() {
|
|
241
|
+
return {
|
|
242
|
+
crawlSummary: {
|
|
243
|
+
totalPagesAudited: 0,
|
|
244
|
+
crawlDepthReached: 0,
|
|
245
|
+
pagesSkipped: 0,
|
|
246
|
+
skipReasons: []
|
|
247
|
+
},
|
|
248
|
+
pageStats: buildEmptySeoPageStats(),
|
|
249
|
+
auditedPages: [],
|
|
250
|
+
evidence: []
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function normalizeCrawlUrl(value) {
|
|
254
|
+
if (!isHttpUrl(value)) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const parsed = new URL(value);
|
|
259
|
+
parsed.hash = "";
|
|
260
|
+
if (parsed.pathname.length > 1) {
|
|
261
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
262
|
+
}
|
|
263
|
+
return parsed.toString();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function recordSkip(skipCounts, reason) {
|
|
270
|
+
skipCounts.set(reason, (skipCounts.get(reason) ?? 0) + 1);
|
|
271
|
+
}
|
|
272
|
+
function summarizeSkipReasons(skipCounts) {
|
|
273
|
+
return Array.from(skipCounts.entries())
|
|
274
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
275
|
+
.slice(0, 6)
|
|
276
|
+
.map(([reason, count]) => `${reason} (${count})`);
|
|
277
|
+
}
|
|
278
|
+
function normalizeTimingValue(value) {
|
|
279
|
+
if (value === null || value === undefined || value <= 0) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
function estimateSyllables(word) {
|
|
285
|
+
const cleaned = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
286
|
+
if (!cleaned) {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
const vowelRuns = cleaned.match(/[aeiouy]+/g)?.length ?? 0;
|
|
290
|
+
const silentE = cleaned.endsWith("e") ? 1 : 0;
|
|
291
|
+
return Math.max(1, vowelRuns - silentE);
|
|
292
|
+
}
|
|
293
|
+
function computeReadability(text) {
|
|
294
|
+
const normalized = normalizeText(text);
|
|
295
|
+
if (!normalized) {
|
|
296
|
+
return { score: null, label: "Insufficient text", wordCount: 0, longParagraphCount: 0 };
|
|
297
|
+
}
|
|
298
|
+
const words = normalized.split(/\s+/).filter(Boolean);
|
|
299
|
+
const sentences = normalized.split(/[.!?]+/).map((item) => item.trim()).filter(Boolean);
|
|
300
|
+
const syllables = words.reduce((sum, word) => sum + estimateSyllables(word), 0);
|
|
301
|
+
const score = 206.835 - 1.015 * (words.length / Math.max(1, sentences.length)) - 84.6 * (syllables / Math.max(1, words.length));
|
|
302
|
+
const paragraphs = text
|
|
303
|
+
.split(/\n\s*\n/)
|
|
304
|
+
.map((item) => normalizeText(item))
|
|
305
|
+
.filter(Boolean);
|
|
306
|
+
const longParagraphCount = paragraphs.filter((paragraph) => paragraph.split(/\s+/).length >= 120).length;
|
|
307
|
+
if (score >= 70) {
|
|
308
|
+
return { score: Number(score.toFixed(1)), label: "Easy", wordCount: words.length, longParagraphCount };
|
|
309
|
+
}
|
|
310
|
+
if (score >= 50) {
|
|
311
|
+
return { score: Number(score.toFixed(1)), label: "Moderate", wordCount: words.length, longParagraphCount };
|
|
312
|
+
}
|
|
313
|
+
return { score: Number(score.toFixed(1)), label: "Complex", wordCount: words.length, longParagraphCount };
|
|
314
|
+
}
|
|
315
|
+
function detectFramework(html, headers) {
|
|
316
|
+
const haystack = `${html} ${Object.entries(headers)
|
|
317
|
+
.map(([key, value]) => `${key}:${value}`)
|
|
318
|
+
.join(" ")}`;
|
|
319
|
+
for (const candidate of FRAMEWORK_PATTERNS) {
|
|
320
|
+
if (candidate.pattern.test(haystack)) {
|
|
321
|
+
return candidate.label;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
function createContextOptions(args) {
|
|
327
|
+
const baseOptions = args.mobile
|
|
328
|
+
? {
|
|
329
|
+
...devices["iPhone 13"],
|
|
330
|
+
viewport: config.mobileViewport,
|
|
331
|
+
ignoreHTTPSErrors: args.ignoreHttpsErrors,
|
|
332
|
+
timezoneId: args.timezoneId
|
|
333
|
+
}
|
|
334
|
+
: {
|
|
335
|
+
viewport: config.desktopViewport,
|
|
336
|
+
ignoreHTTPSErrors: args.ignoreHttpsErrors,
|
|
337
|
+
timezoneId: args.timezoneId,
|
|
338
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
|
339
|
+
};
|
|
340
|
+
if (args.storageState) {
|
|
341
|
+
baseOptions.storageState = args.storageState;
|
|
342
|
+
}
|
|
343
|
+
return baseOptions;
|
|
344
|
+
}
|
|
345
|
+
async function collectProbe(args) {
|
|
346
|
+
const context = await args.browser.newContext(createContextOptions({
|
|
347
|
+
mobile: args.mobile,
|
|
348
|
+
ignoreHttpsErrors: args.ignoreHttpsErrors,
|
|
349
|
+
timezoneId: args.timezoneId,
|
|
350
|
+
storageState: args.storageState
|
|
351
|
+
}));
|
|
352
|
+
await installPlaywrightPageCompat(context);
|
|
353
|
+
await context.addInitScript({ content: SITE_CHECK_METRICS_INIT_SCRIPT });
|
|
354
|
+
const page = await context.newPage();
|
|
355
|
+
page.setDefaultNavigationTimeout(args.timeoutMs);
|
|
356
|
+
page.setDefaultTimeout(args.timeoutMs);
|
|
357
|
+
let responseStatus = null;
|
|
358
|
+
let headers = {};
|
|
359
|
+
let note = "Loaded successfully.";
|
|
360
|
+
let loadOk = false;
|
|
361
|
+
try {
|
|
362
|
+
const response = await page.goto(args.baseUrl, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
|
|
363
|
+
responseStatus = response?.status() ?? null;
|
|
364
|
+
headers = response?.headers() ?? {};
|
|
365
|
+
await page.waitForLoadState("load", { timeout: Math.min(5000, args.timeoutMs) }).catch(() => undefined);
|
|
366
|
+
await page.waitForTimeout(1200).catch(() => undefined);
|
|
367
|
+
loadOk = Boolean(responseStatus === null || responseStatus < 400);
|
|
368
|
+
note = responseStatus ? `Loaded with status ${responseStatus}.` : "Loaded without an explicit document response status.";
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
note = `Navigation error: ${cleanErrorMessage(error)}`;
|
|
372
|
+
await page.waitForTimeout(600).catch(() => undefined);
|
|
373
|
+
}
|
|
374
|
+
const html = await page.content().catch(() => "");
|
|
375
|
+
const snapshot = await page.evaluate(({ ctaKeywords, trustKeywords }) => {
|
|
376
|
+
const bodyText = (document.body?.innerText || "").replace(/\s+/g, " ").trim();
|
|
377
|
+
const lowerBodyText = bodyText.toLowerCase();
|
|
378
|
+
const visibleLinks = [];
|
|
379
|
+
const internalLinkSamples = [];
|
|
380
|
+
const ctaSamples = [];
|
|
381
|
+
const trustSignalSamples = [];
|
|
382
|
+
const paragraphs = [];
|
|
383
|
+
let tapTargetIssueCount = 0;
|
|
384
|
+
let smallTextIssueCount = 0;
|
|
385
|
+
let navigationLinkCount = 0;
|
|
386
|
+
for (const candidate of Array.from(document.querySelectorAll("a[href]"))) {
|
|
387
|
+
if (!(candidate instanceof HTMLAnchorElement)) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const rect = candidate.getBoundingClientRect();
|
|
391
|
+
const style = window.getComputedStyle(candidate);
|
|
392
|
+
if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!/^https?:/i.test(candidate.href)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const text = (candidate.innerText || candidate.getAttribute("aria-label") || candidate.title || "")
|
|
399
|
+
.replace(/\s+/g, " ")
|
|
400
|
+
.trim();
|
|
401
|
+
visibleLinks.push({ href: candidate.href, text });
|
|
402
|
+
try {
|
|
403
|
+
if (new URL(candidate.href).origin === window.location.origin && !internalLinkSamples.includes(candidate.href) && internalLinkSamples.length < 12) {
|
|
404
|
+
internalLinkSamples.push(candidate.href);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Ignore malformed href values.
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const candidate of Array.from(document.querySelectorAll("a, button, input[type='submit'], input[type='button'], [role='button']"))) {
|
|
412
|
+
if (!(candidate instanceof HTMLElement)) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const rect = candidate.getBoundingClientRect();
|
|
416
|
+
const style = window.getComputedStyle(candidate);
|
|
417
|
+
if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (rect.width < 44 || rect.height < 44) {
|
|
421
|
+
tapTargetIssueCount += 1;
|
|
422
|
+
}
|
|
423
|
+
const text = (candidate.innerText || candidate.getAttribute("aria-label") || candidate.getAttribute("value") || "")
|
|
424
|
+
.replace(/\s+/g, " ")
|
|
425
|
+
.trim();
|
|
426
|
+
const lowerText = text.toLowerCase();
|
|
427
|
+
if (text && ctaSamples.length < 8 && !ctaSamples.includes(text) && ctaKeywords.some((keyword) => lowerText.includes(keyword))) {
|
|
428
|
+
ctaSamples.push(text);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const candidate of Array.from(document.querySelectorAll("p, li, a, button, label, span"))) {
|
|
432
|
+
if (!(candidate instanceof HTMLElement)) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const rect = candidate.getBoundingClientRect();
|
|
436
|
+
const style = window.getComputedStyle(candidate);
|
|
437
|
+
if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const text = (candidate.innerText || "").replace(/\s+/g, " ").trim();
|
|
441
|
+
if (text.length < 20) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const fontSize = Number.parseFloat(style.fontSize || "0");
|
|
445
|
+
if (fontSize > 0 && fontSize < 14) {
|
|
446
|
+
smallTextIssueCount += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const candidate of Array.from(document.querySelectorAll("nav a, header a"))) {
|
|
450
|
+
if (!(candidate instanceof HTMLElement)) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const rect = candidate.getBoundingClientRect();
|
|
454
|
+
const style = window.getComputedStyle(candidate);
|
|
455
|
+
if (rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none") {
|
|
456
|
+
navigationLinkCount += 1;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const keyword of trustKeywords) {
|
|
460
|
+
if (lowerBodyText.includes(keyword) && trustSignalSamples.length < 8 && !trustSignalSamples.includes(keyword)) {
|
|
461
|
+
trustSignalSamples.push(keyword);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
for (const candidate of Array.from(document.querySelectorAll("p"))) {
|
|
465
|
+
const text = (candidate.textContent || "").replace(/\s+/g, " ").trim();
|
|
466
|
+
if (text) {
|
|
467
|
+
paragraphs.push(text);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const heroText = ((document.querySelector("h1")?.textContent || "").replace(/\s+/g, " ").trim() ||
|
|
471
|
+
(document.querySelector("main")?.textContent || "").slice(0, 160).replace(/\s+/g, " ").trim() ||
|
|
472
|
+
null);
|
|
473
|
+
return {
|
|
474
|
+
title: document.title || "",
|
|
475
|
+
finalUrl: window.location.href,
|
|
476
|
+
metaDescription: document.querySelector("meta[name='description']")?.getAttribute("content")?.trim() || null,
|
|
477
|
+
canonical: document.querySelector("link[rel='canonical']")?.getAttribute("href")?.trim() || null,
|
|
478
|
+
h1Count: document.querySelectorAll("h1").length,
|
|
479
|
+
h2Count: document.querySelectorAll("h2").length,
|
|
480
|
+
structuredDataCount: document.querySelectorAll("script[type='application/ld+json']").length,
|
|
481
|
+
visibleLinkCount: visibleLinks.length,
|
|
482
|
+
internalLinkSamples,
|
|
483
|
+
ctaSamples,
|
|
484
|
+
formCount: document.querySelectorAll("form").length,
|
|
485
|
+
submitControlCount: document.querySelectorAll("button[type='submit'], input[type='submit']").length,
|
|
486
|
+
trustSignalCount: trustSignalSamples.length,
|
|
487
|
+
trustSignalSamples,
|
|
488
|
+
bodyText,
|
|
489
|
+
mediaCount: document.querySelectorAll("img, video, picture, svg").length,
|
|
490
|
+
horizontalOverflow: ((document.documentElement?.scrollWidth ?? window.innerWidth) - window.innerWidth) > 4,
|
|
491
|
+
tapTargetIssueCount,
|
|
492
|
+
smallTextIssueCount,
|
|
493
|
+
navigationLinkCount,
|
|
494
|
+
heroText,
|
|
495
|
+
paragraphs
|
|
496
|
+
};
|
|
497
|
+
}, { ctaKeywords: CTA_KEYWORDS, trustKeywords: TRUST_SIGNAL_KEYWORDS });
|
|
498
|
+
const perf = await page.evaluate(() => {
|
|
499
|
+
const navigation = performance.getEntriesByType("navigation")[0];
|
|
500
|
+
const metricsWindow = window;
|
|
501
|
+
return {
|
|
502
|
+
domContentLoadedMs: navigation ? navigation.domContentLoadedEventEnd : null,
|
|
503
|
+
loadMs: navigation ? navigation.loadEventEnd : null,
|
|
504
|
+
firstContentfulPaintMs: metricsWindow.__siteAgentMetrics?.fcp ?? null,
|
|
505
|
+
largestContentfulPaintMs: metricsWindow.__siteAgentMetrics?.lcp ?? null,
|
|
506
|
+
cumulativeLayoutShift: metricsWindow.__siteAgentMetrics?.cls ?? null
|
|
507
|
+
};
|
|
508
|
+
}).catch(() => ({
|
|
509
|
+
domContentLoadedMs: null,
|
|
510
|
+
loadMs: null,
|
|
511
|
+
firstContentfulPaintMs: null,
|
|
512
|
+
largestContentfulPaintMs: null,
|
|
513
|
+
cumulativeLayoutShift: null
|
|
514
|
+
}));
|
|
515
|
+
const normalizedPerf = {
|
|
516
|
+
domContentLoadedMs: normalizeTimingValue(perf.domContentLoadedMs),
|
|
517
|
+
loadMs: normalizeTimingValue(perf.loadMs),
|
|
518
|
+
firstContentfulPaintMs: normalizeTimingValue(perf.firstContentfulPaintMs),
|
|
519
|
+
largestContentfulPaintMs: normalizeTimingValue(perf.largestContentfulPaintMs),
|
|
520
|
+
cumulativeLayoutShift: perf.cumulativeLayoutShift
|
|
521
|
+
};
|
|
522
|
+
await context.close().catch(() => undefined);
|
|
523
|
+
const readability = computeReadability(snapshot.bodyText);
|
|
524
|
+
const probe = PageProbeSchema.parse({
|
|
525
|
+
viewport: args.mobile ? "mobile" : "desktop",
|
|
526
|
+
finalUrl: snapshot.finalUrl,
|
|
527
|
+
title: snapshot.title,
|
|
528
|
+
loadOk,
|
|
529
|
+
note,
|
|
530
|
+
statusCode: responseStatus,
|
|
531
|
+
metaDescription: snapshot.metaDescription,
|
|
532
|
+
canonical: snapshot.canonical,
|
|
533
|
+
h1Count: snapshot.h1Count,
|
|
534
|
+
h2Count: snapshot.h2Count,
|
|
535
|
+
structuredDataCount: snapshot.structuredDataCount,
|
|
536
|
+
visibleLinkCount: snapshot.visibleLinkCount,
|
|
537
|
+
internalLinkSamples: snapshot.internalLinkSamples,
|
|
538
|
+
ctaSamples: snapshot.ctaSamples,
|
|
539
|
+
formCount: snapshot.formCount,
|
|
540
|
+
submitControlCount: snapshot.submitControlCount,
|
|
541
|
+
trustSignalCount: snapshot.trustSignalCount,
|
|
542
|
+
trustSignalSamples: snapshot.trustSignalSamples,
|
|
543
|
+
wordCount: readability.wordCount,
|
|
544
|
+
readabilityScore: readability.score,
|
|
545
|
+
readabilityLabel: readability.label,
|
|
546
|
+
longParagraphCount: readability.longParagraphCount,
|
|
547
|
+
mediaCount: snapshot.mediaCount,
|
|
548
|
+
horizontalOverflow: snapshot.horizontalOverflow,
|
|
549
|
+
tapTargetIssueCount: snapshot.tapTargetIssueCount,
|
|
550
|
+
smallTextIssueCount: snapshot.smallTextIssueCount,
|
|
551
|
+
navigationLinkCount: snapshot.navigationLinkCount,
|
|
552
|
+
heroText: snapshot.heroText,
|
|
553
|
+
performance: normalizedPerf,
|
|
554
|
+
evidence: uniqueItems([
|
|
555
|
+
note,
|
|
556
|
+
snapshot.h1Count > 0 ? `Detected ${snapshot.h1Count} H1 heading(s) and ${snapshot.h2Count} H2 heading(s).` : "No H1 heading was detected on the probed page.",
|
|
557
|
+
snapshot.horizontalOverflow ? "The layout overflowed horizontally in this viewport." : "No horizontal overflow was detected in this viewport.",
|
|
558
|
+
snapshot.tapTargetIssueCount > 0 ? `${snapshot.tapTargetIssueCount} small tap target(s) were detected.` : "Tap target sizing cleared the 44px threshold in this viewport."
|
|
559
|
+
], 5)
|
|
560
|
+
});
|
|
561
|
+
return {
|
|
562
|
+
probe,
|
|
563
|
+
html,
|
|
564
|
+
headers
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
async function checkUrl(args) {
|
|
568
|
+
try {
|
|
569
|
+
const response = await args.page.context().request.get(args.url, {
|
|
570
|
+
timeout: args.timeoutMs,
|
|
571
|
+
failOnStatusCode: false
|
|
572
|
+
});
|
|
573
|
+
const statusCode = response.status();
|
|
574
|
+
const ok = statusCode >= 200 && statusCode < 400;
|
|
575
|
+
return {
|
|
576
|
+
url: args.url,
|
|
577
|
+
ok,
|
|
578
|
+
statusCode,
|
|
579
|
+
note: ok ? `Responded with ${statusCode}.` : `Responded with ${statusCode}.`
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
return {
|
|
584
|
+
url: args.url,
|
|
585
|
+
ok: false,
|
|
586
|
+
statusCode: null,
|
|
587
|
+
note: `Request failed: ${cleanErrorMessage(error)}`
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async function fetchHtmlDocument(args) {
|
|
592
|
+
try {
|
|
593
|
+
const response = await args.page.context().request.get(args.url, {
|
|
594
|
+
timeout: args.timeoutMs,
|
|
595
|
+
failOnStatusCode: false
|
|
596
|
+
});
|
|
597
|
+
const statusCode = response.status();
|
|
598
|
+
const finalUrl = response.url();
|
|
599
|
+
const contentType = response.headers()["content-type"] ?? "";
|
|
600
|
+
const body = await response.text().catch(() => "");
|
|
601
|
+
const looksHtml = /html|xhtml/i.test(contentType) || /^\s*(?:<!doctype html|<html)\b/i.test(body);
|
|
602
|
+
if (statusCode >= 400) {
|
|
603
|
+
return {
|
|
604
|
+
finalUrl,
|
|
605
|
+
statusCode,
|
|
606
|
+
html: null,
|
|
607
|
+
skipReason: `non-success status ${statusCode}`
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (!looksHtml) {
|
|
611
|
+
return {
|
|
612
|
+
finalUrl,
|
|
613
|
+
statusCode,
|
|
614
|
+
html: null,
|
|
615
|
+
skipReason: "non-HTML response"
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
finalUrl,
|
|
620
|
+
statusCode,
|
|
621
|
+
html: body,
|
|
622
|
+
skipReason: null
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
return {
|
|
627
|
+
finalUrl: args.url,
|
|
628
|
+
statusCode: null,
|
|
629
|
+
html: null,
|
|
630
|
+
skipReason: classifyCrawlRequestFailure(error)
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function auditSeoHtml(args) {
|
|
635
|
+
const snapshot = await args.parserPage.evaluate(({ html, pageUrl, depth, seedOrigin, genericAnchorSource, nonDescriptiveImageSource, placeholderContentSource, nonHtmlResourceSource }) => {
|
|
636
|
+
const document = new DOMParser().parseFromString(html, "text/html");
|
|
637
|
+
const genericAnchorPattern = new RegExp(genericAnchorSource, "i");
|
|
638
|
+
const nonDescriptiveImagePattern = new RegExp(nonDescriptiveImageSource, "i");
|
|
639
|
+
const placeholderContentPattern = new RegExp(placeholderContentSource, "i");
|
|
640
|
+
const nonHtmlResourcePattern = new RegExp(nonHtmlResourceSource, "i");
|
|
641
|
+
const page = new URL(pageUrl);
|
|
642
|
+
page.hash = "";
|
|
643
|
+
if (page.pathname.length > 1) {
|
|
644
|
+
page.pathname = page.pathname.replace(/\/+$/, "");
|
|
645
|
+
}
|
|
646
|
+
const normalizedPageUrl = page.toString();
|
|
647
|
+
const seed = new URL(seedOrigin);
|
|
648
|
+
const bodyText = (document.body?.textContent || "").replace(/\s+/g, " ").trim();
|
|
649
|
+
const words = bodyText ? bodyText.split(/\s+/).filter(Boolean) : [];
|
|
650
|
+
const firstHundredWords = words.slice(0, 100).join(" ").toLowerCase();
|
|
651
|
+
const title = (document.title || "").replace(/\s+/g, " ").trim();
|
|
652
|
+
const metaDescription = (document.querySelector("meta[name='description']")?.getAttribute("content") || "").replace(/\s+/g, " ").trim();
|
|
653
|
+
const canonicalRaw = (document.querySelector("link[rel='canonical']")?.getAttribute("href") || "").replace(/\s+/g, " ").trim();
|
|
654
|
+
let canonical = null;
|
|
655
|
+
if (canonicalRaw) {
|
|
656
|
+
try {
|
|
657
|
+
const canonicalUrl = new URL(canonicalRaw, pageUrl);
|
|
658
|
+
canonicalUrl.hash = "";
|
|
659
|
+
if (canonicalUrl.pathname.length > 1) {
|
|
660
|
+
canonicalUrl.pathname = canonicalUrl.pathname.replace(/\/+$/, "");
|
|
661
|
+
}
|
|
662
|
+
canonical = canonicalUrl.toString();
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
canonical = null;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const robotsContent = ((document.querySelector("meta[name='robots']")?.getAttribute("content") || "").replace(/\s+/g, " ").trim()).toLowerCase();
|
|
669
|
+
const viewportPresent = Boolean(document.querySelector("meta[name='viewport']"));
|
|
670
|
+
const charsetPresent = Boolean(document.querySelector("meta[charset]") ||
|
|
671
|
+
document.querySelector("meta[http-equiv='content-type'][content*='charset']"));
|
|
672
|
+
const structuredDataTypes = [];
|
|
673
|
+
document.querySelectorAll("script[type='application/ld+json']").forEach((script) => {
|
|
674
|
+
const raw = script.textContent?.trim();
|
|
675
|
+
if (!raw) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const pending = [JSON.parse(raw)];
|
|
680
|
+
while (pending.length > 0) {
|
|
681
|
+
const current = pending.pop();
|
|
682
|
+
if (Array.isArray(current)) {
|
|
683
|
+
for (const item of current) {
|
|
684
|
+
pending.push(item);
|
|
685
|
+
}
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (!current || typeof current !== "object") {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const record = current;
|
|
692
|
+
const typeValue = record["@type"];
|
|
693
|
+
if (typeof typeValue === "string" && typeValue.trim()) {
|
|
694
|
+
structuredDataTypes.push(typeValue.trim());
|
|
695
|
+
}
|
|
696
|
+
else if (Array.isArray(typeValue)) {
|
|
697
|
+
for (const item of typeValue) {
|
|
698
|
+
if (typeof item === "string" && item.trim()) {
|
|
699
|
+
structuredDataTypes.push(item.trim());
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (record["@graph"]) {
|
|
704
|
+
pending.push(record["@graph"]);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// Ignore invalid JSON-LD blobs.
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
const ogTags = {
|
|
713
|
+
title: Boolean(document.querySelector("meta[property='og:title']")),
|
|
714
|
+
description: Boolean(document.querySelector("meta[property='og:description']")),
|
|
715
|
+
image: Boolean(document.querySelector("meta[property='og:image']")),
|
|
716
|
+
url: Boolean(document.querySelector("meta[property='og:url']"))
|
|
717
|
+
};
|
|
718
|
+
const twitterCardPresent = Boolean(document.querySelector("meta[name='twitter:card']"));
|
|
719
|
+
const h1Count = document.querySelectorAll("h1").length;
|
|
720
|
+
const headingLevels = Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading) => Number.parseInt(heading.tagName.replace(/[^0-9]/g, ""), 10));
|
|
721
|
+
const headingOrderIssue = headingLevels.some((level, index) => index > 0 && level > headingLevels[index - 1] + 1);
|
|
722
|
+
const lowWordCount = words.length > 0 && words.length < 300;
|
|
723
|
+
const thinOrPlaceholder = words.length > 0 && (words.length < 150 || placeholderContentPattern.test(bodyText));
|
|
724
|
+
const langPresent = Boolean(document.documentElement.getAttribute("lang")?.trim());
|
|
725
|
+
const skipNavPresent = Array.from(document.querySelectorAll("a[href^='#']")).some((link) => {
|
|
726
|
+
const text = (link.textContent || "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
727
|
+
return text.includes("skip") && (text.includes("content") || text.includes("main"));
|
|
728
|
+
});
|
|
729
|
+
let internalLinkCount = 0;
|
|
730
|
+
let externalLinkCount = 0;
|
|
731
|
+
let nofollowExternalLinkCount = 0;
|
|
732
|
+
let genericAnchorCount = 0;
|
|
733
|
+
const discoveredLinks = new Set();
|
|
734
|
+
document.querySelectorAll("a[href]").forEach((link) => {
|
|
735
|
+
const rawHref = link.getAttribute("href")?.trim() || "";
|
|
736
|
+
if (!rawHref || rawHref.startsWith("#") || /^(?:mailto|tel|javascript):/i.test(rawHref)) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
let resolved;
|
|
740
|
+
try {
|
|
741
|
+
resolved = new URL(rawHref, pageUrl);
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!/^https?:$/i.test(resolved.protocol)) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const text = (link.textContent || link.getAttribute("aria-label") || link.getAttribute("title") || "").replace(/\s+/g, " ").trim();
|
|
750
|
+
if (text && genericAnchorPattern.test(text)) {
|
|
751
|
+
genericAnchorCount += 1;
|
|
752
|
+
}
|
|
753
|
+
if (resolved.protocol === seed.protocol && resolved.host === seed.host) {
|
|
754
|
+
internalLinkCount += 1;
|
|
755
|
+
resolved.hash = "";
|
|
756
|
+
if (resolved.pathname.length > 1) {
|
|
757
|
+
resolved.pathname = resolved.pathname.replace(/\/+$/, "");
|
|
758
|
+
}
|
|
759
|
+
const normalizedResolved = resolved.toString();
|
|
760
|
+
if (!nonHtmlResourcePattern.test(normalizedResolved)) {
|
|
761
|
+
discoveredLinks.add(normalizedResolved);
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
externalLinkCount += 1;
|
|
766
|
+
if ((link.getAttribute("rel") || "").toLowerCase().includes("nofollow")) {
|
|
767
|
+
nofollowExternalLinkCount += 1;
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
let imagesMissingAlt = 0;
|
|
771
|
+
let imagesWithNonDescriptiveFilenames = 0;
|
|
772
|
+
let imagesWithoutLazyLoading = 0;
|
|
773
|
+
document.querySelectorAll("img[src]").forEach((image) => {
|
|
774
|
+
const alt = image.getAttribute("alt");
|
|
775
|
+
if (alt === null || !alt.trim()) {
|
|
776
|
+
imagesMissingAlt += 1;
|
|
777
|
+
}
|
|
778
|
+
if ((image.getAttribute("loading") || "").toLowerCase() !== "lazy") {
|
|
779
|
+
imagesWithoutLazyLoading += 1;
|
|
780
|
+
}
|
|
781
|
+
const rawSrc = image.getAttribute("src")?.trim();
|
|
782
|
+
if (!rawSrc) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
const resolved = new URL(rawSrc, pageUrl);
|
|
787
|
+
const filename = decodeURIComponent(resolved.pathname.split("/").pop() || "").toLowerCase();
|
|
788
|
+
if (filename && nonDescriptiveImagePattern.test(filename)) {
|
|
789
|
+
imagesWithNonDescriptiveFilenames += 1;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
// Ignore malformed image URLs.
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
const renderBlockingHeadScripts = Array.from(document.head?.querySelectorAll("script") || []).filter((script) => {
|
|
797
|
+
const type = (script.getAttribute("type") || "").toLowerCase();
|
|
798
|
+
if (type === "application/ld+json") {
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
return !script.hasAttribute("defer") && !script.hasAttribute("async");
|
|
802
|
+
}).length;
|
|
803
|
+
const resourceHintCount = document.querySelectorAll("link[rel='preload'], link[rel='prefetch'], link[rel='preconnect'], link[rel='dns-prefetch']").length;
|
|
804
|
+
const unlabeledInputCount = Array.from(document.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']), textarea, select")).filter((field) => {
|
|
805
|
+
const id = field.getAttribute("id");
|
|
806
|
+
const ariaLabel = field.getAttribute("aria-label")?.trim();
|
|
807
|
+
const ariaLabelledBy = field.getAttribute("aria-labelledby")?.trim();
|
|
808
|
+
const wrappingLabel = field.closest("label");
|
|
809
|
+
const matchingLabel = id ? document.querySelector(`label[for="${CSS.escape(id)}"]`) : null;
|
|
810
|
+
return !ariaLabel && !ariaLabelledBy && !wrappingLabel && !matchingLabel;
|
|
811
|
+
}).length;
|
|
812
|
+
const unlabeledInteractiveCount = Array.from(document.querySelectorAll("button, a[href], input[type='submit'], input[type='button'], [role='button']")).filter((element) => {
|
|
813
|
+
const visibleText = ((element.textContent || "") + " " + (element.getAttribute("value") || "")).replace(/\s+/g, " ").trim();
|
|
814
|
+
const ariaLabel = element.getAttribute("aria-label")?.trim();
|
|
815
|
+
const titleAttr = element.getAttribute("title")?.trim();
|
|
816
|
+
return !visibleText && !ariaLabel && !titleAttr;
|
|
817
|
+
}).length;
|
|
818
|
+
const pageUrlHasIssue = page.pathname.includes("_") || page.searchParams.size > 3;
|
|
819
|
+
const notableIssues = [];
|
|
820
|
+
if (!title) {
|
|
821
|
+
notableIssues.push("Missing title tag");
|
|
822
|
+
}
|
|
823
|
+
else if (title.length < 50 || title.length > 60) {
|
|
824
|
+
notableIssues.push("Title length outside ideal range");
|
|
825
|
+
}
|
|
826
|
+
if (!metaDescription) {
|
|
827
|
+
notableIssues.push("Missing meta description");
|
|
828
|
+
}
|
|
829
|
+
if (!canonical) {
|
|
830
|
+
notableIssues.push("Missing canonical");
|
|
831
|
+
}
|
|
832
|
+
else if (canonical !== normalizedPageUrl) {
|
|
833
|
+
notableIssues.push("Canonical is not self-referencing");
|
|
834
|
+
}
|
|
835
|
+
if (h1Count !== 1) {
|
|
836
|
+
notableIssues.push(h1Count === 0 ? "Missing H1" : "Multiple H1 tags");
|
|
837
|
+
}
|
|
838
|
+
if (headingOrderIssue) {
|
|
839
|
+
notableIssues.push("Heading hierarchy skips levels");
|
|
840
|
+
}
|
|
841
|
+
if (lowWordCount) {
|
|
842
|
+
notableIssues.push("Low visible word count");
|
|
843
|
+
}
|
|
844
|
+
if (thinOrPlaceholder) {
|
|
845
|
+
notableIssues.push("Thin or placeholder content");
|
|
846
|
+
}
|
|
847
|
+
if (!ogTags.title || !ogTags.description || !ogTags.image || !ogTags.url) {
|
|
848
|
+
notableIssues.push("Missing core Open Graph tags");
|
|
849
|
+
}
|
|
850
|
+
if (!twitterCardPresent) {
|
|
851
|
+
notableIssues.push("Missing Twitter card");
|
|
852
|
+
}
|
|
853
|
+
if (imagesMissingAlt > 0) {
|
|
854
|
+
notableIssues.push("Images missing alt text");
|
|
855
|
+
}
|
|
856
|
+
if (!langPresent) {
|
|
857
|
+
notableIssues.push("Missing lang attribute");
|
|
858
|
+
}
|
|
859
|
+
const issueCount = [
|
|
860
|
+
title.length === 0 || title.length < 50 || title.length > 60,
|
|
861
|
+
metaDescription.length === 0 || metaDescription.length < 150 || metaDescription.length > 160,
|
|
862
|
+
!canonical || canonical !== normalizedPageUrl,
|
|
863
|
+
h1Count !== 1,
|
|
864
|
+
headingOrderIssue,
|
|
865
|
+
lowWordCount,
|
|
866
|
+
thinOrPlaceholder,
|
|
867
|
+
!viewportPresent,
|
|
868
|
+
!charsetPresent,
|
|
869
|
+
structuredDataTypes.length === 0,
|
|
870
|
+
!ogTags.title || !ogTags.description || !ogTags.image || !ogTags.url,
|
|
871
|
+
!twitterCardPresent,
|
|
872
|
+
pageUrlHasIssue,
|
|
873
|
+
genericAnchorCount > 0,
|
|
874
|
+
imagesMissingAlt > 0,
|
|
875
|
+
imagesWithNonDescriptiveFilenames > 0,
|
|
876
|
+
renderBlockingHeadScripts > 0,
|
|
877
|
+
imagesWithoutLazyLoading > 0,
|
|
878
|
+
!langPresent,
|
|
879
|
+
unlabeledInputCount > 0,
|
|
880
|
+
unlabeledInteractiveCount > 0,
|
|
881
|
+
!skipNavPresent
|
|
882
|
+
].filter(Boolean).length;
|
|
883
|
+
return {
|
|
884
|
+
url: normalizedPageUrl,
|
|
885
|
+
depth,
|
|
886
|
+
titleLength: title.length,
|
|
887
|
+
metaDescriptionLength: metaDescription.length,
|
|
888
|
+
canonical,
|
|
889
|
+
canonicalSelfReferencing: canonical === normalizedPageUrl,
|
|
890
|
+
noindex: robotsContent.includes("noindex"),
|
|
891
|
+
nofollow: robotsContent.includes("nofollow"),
|
|
892
|
+
viewportPresent,
|
|
893
|
+
charsetPresent,
|
|
894
|
+
structuredDataTypes: Array.from(new Set(structuredDataTypes)),
|
|
895
|
+
missingOpenGraphBasics: !ogTags.title || !ogTags.description || !ogTags.image || !ogTags.url,
|
|
896
|
+
twitterCardPresent,
|
|
897
|
+
urlHasIssue: pageUrlHasIssue,
|
|
898
|
+
h1Count,
|
|
899
|
+
headingOrderIssue,
|
|
900
|
+
wordCount: words.length,
|
|
901
|
+
thinOrPlaceholder,
|
|
902
|
+
internalLinkCount,
|
|
903
|
+
externalLinkCount,
|
|
904
|
+
nofollowExternalLinkCount,
|
|
905
|
+
genericAnchorCount,
|
|
906
|
+
imagesMissingAlt,
|
|
907
|
+
imagesWithNonDescriptiveFilenames,
|
|
908
|
+
renderBlockingHeadScripts,
|
|
909
|
+
imagesWithoutLazyLoading,
|
|
910
|
+
resourceHintCount,
|
|
911
|
+
langPresent,
|
|
912
|
+
unlabeledInputCount,
|
|
913
|
+
unlabeledInteractiveCount,
|
|
914
|
+
skipNavPresent,
|
|
915
|
+
issueCount,
|
|
916
|
+
notableIssues: notableIssues.slice(0, 6),
|
|
917
|
+
discoveredLinks: Array.from(discoveredLinks),
|
|
918
|
+
keywordInFirstHundredWords: firstHundredWords.length > 0
|
|
919
|
+
};
|
|
920
|
+
}, {
|
|
921
|
+
html: args.html,
|
|
922
|
+
pageUrl: args.url,
|
|
923
|
+
depth: args.depth,
|
|
924
|
+
seedOrigin: args.seedOrigin,
|
|
925
|
+
genericAnchorSource: GENERIC_ANCHOR_PATTERN.source,
|
|
926
|
+
nonDescriptiveImageSource: NON_DESCRIPTIVE_IMAGE_PATTERN.source,
|
|
927
|
+
placeholderContentSource: PLACEHOLDER_CONTENT_PATTERN.source,
|
|
928
|
+
nonHtmlResourceSource: NON_HTML_RESOURCE_PATTERN.source
|
|
929
|
+
});
|
|
930
|
+
const pageStats = buildEmptySeoPageStats();
|
|
931
|
+
if (snapshot.titleLength === 0) {
|
|
932
|
+
pageStats.pagesMissingTitle += 1;
|
|
933
|
+
}
|
|
934
|
+
else if (snapshot.titleLength < 50 || snapshot.titleLength > 60) {
|
|
935
|
+
pageStats.pagesBadTitleLength += 1;
|
|
936
|
+
}
|
|
937
|
+
if (snapshot.metaDescriptionLength === 0) {
|
|
938
|
+
pageStats.pagesMissingMetaDescription += 1;
|
|
939
|
+
}
|
|
940
|
+
else if (snapshot.metaDescriptionLength < 150 || snapshot.metaDescriptionLength > 160) {
|
|
941
|
+
pageStats.pagesBadMetaDescriptionLength += 1;
|
|
942
|
+
}
|
|
943
|
+
if (!snapshot.canonical) {
|
|
944
|
+
pageStats.pagesMissingCanonical += 1;
|
|
945
|
+
}
|
|
946
|
+
else if (!snapshot.canonicalSelfReferencing) {
|
|
947
|
+
pageStats.pagesNonSelfCanonical += 1;
|
|
948
|
+
}
|
|
949
|
+
if (snapshot.noindex) {
|
|
950
|
+
pageStats.noindexPages += 1;
|
|
951
|
+
}
|
|
952
|
+
if (snapshot.nofollow) {
|
|
953
|
+
pageStats.nofollowPages += 1;
|
|
954
|
+
}
|
|
955
|
+
if (!snapshot.viewportPresent) {
|
|
956
|
+
pageStats.pagesMissingViewport += 1;
|
|
957
|
+
}
|
|
958
|
+
if (!snapshot.charsetPresent) {
|
|
959
|
+
pageStats.pagesMissingCharset += 1;
|
|
960
|
+
}
|
|
961
|
+
if (snapshot.structuredDataTypes.length > 0) {
|
|
962
|
+
pageStats.pagesWithStructuredData += 1;
|
|
963
|
+
}
|
|
964
|
+
if (snapshot.missingOpenGraphBasics) {
|
|
965
|
+
pageStats.pagesMissingOpenGraphBasics += 1;
|
|
966
|
+
}
|
|
967
|
+
if (!snapshot.twitterCardPresent) {
|
|
968
|
+
pageStats.pagesMissingTwitterCard += 1;
|
|
969
|
+
}
|
|
970
|
+
if (snapshot.urlHasIssue) {
|
|
971
|
+
pageStats.pagesWithUrlIssues += 1;
|
|
972
|
+
}
|
|
973
|
+
if (snapshot.h1Count === 0) {
|
|
974
|
+
pageStats.pagesMissingH1 += 1;
|
|
975
|
+
}
|
|
976
|
+
if (snapshot.h1Count > 1) {
|
|
977
|
+
pageStats.pagesWithMultipleH1 += 1;
|
|
978
|
+
}
|
|
979
|
+
if (snapshot.headingOrderIssue) {
|
|
980
|
+
pageStats.pagesWithHeadingOrderIssues += 1;
|
|
981
|
+
}
|
|
982
|
+
if (snapshot.wordCount > 0 && snapshot.wordCount < 300) {
|
|
983
|
+
pageStats.pagesLowWordCount += 1;
|
|
984
|
+
}
|
|
985
|
+
if (snapshot.thinOrPlaceholder) {
|
|
986
|
+
pageStats.pagesThinOrPlaceholder += 1;
|
|
987
|
+
}
|
|
988
|
+
if (snapshot.genericAnchorCount > 0) {
|
|
989
|
+
pageStats.pagesWithGenericAnchors += 1;
|
|
990
|
+
}
|
|
991
|
+
pageStats.imagesMissingAlt += snapshot.imagesMissingAlt;
|
|
992
|
+
pageStats.imagesWithNonDescriptiveFilenames += snapshot.imagesWithNonDescriptiveFilenames;
|
|
993
|
+
if (snapshot.renderBlockingHeadScripts > 0) {
|
|
994
|
+
pageStats.pagesWithRenderBlockingHeadScripts += 1;
|
|
995
|
+
}
|
|
996
|
+
if (snapshot.imagesWithoutLazyLoading > 0) {
|
|
997
|
+
pageStats.pagesWithNonLazyImages += 1;
|
|
998
|
+
}
|
|
999
|
+
if (snapshot.resourceHintCount > 0) {
|
|
1000
|
+
pageStats.pagesWithResourceHints += 1;
|
|
1001
|
+
}
|
|
1002
|
+
if (!snapshot.langPresent) {
|
|
1003
|
+
pageStats.pagesMissingLang += 1;
|
|
1004
|
+
}
|
|
1005
|
+
if (snapshot.unlabeledInputCount > 0) {
|
|
1006
|
+
pageStats.pagesWithUnlabeledInputs += 1;
|
|
1007
|
+
}
|
|
1008
|
+
if (snapshot.unlabeledInteractiveCount > 0) {
|
|
1009
|
+
pageStats.pagesWithUnlabeledInteractive += 1;
|
|
1010
|
+
}
|
|
1011
|
+
if (!snapshot.skipNavPresent) {
|
|
1012
|
+
pageStats.pagesMissingSkipNav += 1;
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
page: {
|
|
1016
|
+
url: snapshot.url,
|
|
1017
|
+
depth: snapshot.depth,
|
|
1018
|
+
statusCode: null,
|
|
1019
|
+
issueCount: snapshot.issueCount,
|
|
1020
|
+
wordCount: snapshot.wordCount,
|
|
1021
|
+
titleLength: snapshot.titleLength,
|
|
1022
|
+
metaDescriptionLength: snapshot.metaDescriptionLength,
|
|
1023
|
+
h1Count: snapshot.h1Count,
|
|
1024
|
+
notableIssues: snapshot.notableIssues
|
|
1025
|
+
},
|
|
1026
|
+
pageStats,
|
|
1027
|
+
discoveredLinks: snapshot.discoveredLinks
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
async function crawlSeoSite(args) {
|
|
1031
|
+
const empty = buildEmptySeoCrawlResult();
|
|
1032
|
+
const normalizedSeedUrl = normalizeCrawlUrl(args.seedUrl);
|
|
1033
|
+
if (!normalizedSeedUrl) {
|
|
1034
|
+
return empty;
|
|
1035
|
+
}
|
|
1036
|
+
const seed = new URL(normalizedSeedUrl);
|
|
1037
|
+
const queue = [{ url: normalizedSeedUrl, depth: 0 }];
|
|
1038
|
+
const visited = new Set();
|
|
1039
|
+
const skipCounts = new Map();
|
|
1040
|
+
const pageStats = buildEmptySeoPageStats();
|
|
1041
|
+
const auditedPages = [];
|
|
1042
|
+
let pagesSkipped = 0;
|
|
1043
|
+
let crawlDepthReached = 0;
|
|
1044
|
+
while (queue.length > 0 && auditedPages.length < SEO_CRAWL_MAX_PAGES && Date.now() < args.deadline) {
|
|
1045
|
+
const current = queue.shift();
|
|
1046
|
+
if (visited.has(current.url)) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
visited.add(current.url);
|
|
1050
|
+
if (NON_HTML_RESOURCE_PATTERN.test(current.url)) {
|
|
1051
|
+
pagesSkipped += 1;
|
|
1052
|
+
recordSkip(skipCounts, "non-HTML resource");
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const currentUrl = new URL(current.url);
|
|
1056
|
+
if (currentUrl.protocol !== seed.protocol || currentUrl.host !== seed.host) {
|
|
1057
|
+
pagesSkipped += 1;
|
|
1058
|
+
recordSkip(skipCounts, "external domain");
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const fetched = await fetchHtmlDocument({
|
|
1062
|
+
page: args.requestPage,
|
|
1063
|
+
url: current.url,
|
|
1064
|
+
timeoutMs: args.requestTimeoutMs
|
|
1065
|
+
});
|
|
1066
|
+
const normalizedFinalUrl = normalizeCrawlUrl(fetched.finalUrl) ?? current.url;
|
|
1067
|
+
const finalUrl = new URL(normalizedFinalUrl);
|
|
1068
|
+
if (finalUrl.protocol !== seed.protocol || finalUrl.host !== seed.host) {
|
|
1069
|
+
pagesSkipped += 1;
|
|
1070
|
+
recordSkip(skipCounts, "redirected outside crawl scope");
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (!fetched.html || fetched.skipReason) {
|
|
1074
|
+
pagesSkipped += 1;
|
|
1075
|
+
recordSkip(skipCounts, fetched.skipReason ?? "non-HTML response");
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
const audited = await auditSeoHtml({
|
|
1079
|
+
parserPage: args.parserPage,
|
|
1080
|
+
html: fetched.html,
|
|
1081
|
+
url: normalizedFinalUrl,
|
|
1082
|
+
depth: current.depth,
|
|
1083
|
+
seedOrigin: seed.origin
|
|
1084
|
+
});
|
|
1085
|
+
audited.page.statusCode = fetched.statusCode;
|
|
1086
|
+
auditedPages.push(audited.page);
|
|
1087
|
+
crawlDepthReached = Math.max(crawlDepthReached, current.depth);
|
|
1088
|
+
for (const key of Object.keys(pageStats)) {
|
|
1089
|
+
pageStats[key] += audited.pageStats[key];
|
|
1090
|
+
}
|
|
1091
|
+
if (current.depth < SEO_CRAWL_MAX_DEPTH) {
|
|
1092
|
+
for (const discoveredLink of audited.discoveredLinks) {
|
|
1093
|
+
if (!visited.has(discoveredLink) && !queue.some((entry) => entry.url === discoveredLink)) {
|
|
1094
|
+
queue.push({ url: discoveredLink, depth: current.depth + 1 });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (queue.length > 0) {
|
|
1100
|
+
pagesSkipped += queue.length;
|
|
1101
|
+
recordSkip(skipCounts, auditedPages.length >= SEO_CRAWL_MAX_PAGES ? "crawl page limit reached" : "crawl budget exhausted");
|
|
1102
|
+
}
|
|
1103
|
+
const crawlSummary = {
|
|
1104
|
+
totalPagesAudited: auditedPages.length,
|
|
1105
|
+
crawlDepthReached,
|
|
1106
|
+
pagesSkipped,
|
|
1107
|
+
skipReasons: summarizeSkipReasons(skipCounts)
|
|
1108
|
+
};
|
|
1109
|
+
const evidence = uniqueItems([
|
|
1110
|
+
auditedPages.length > 0 ? `Crawled ${auditedPages.length} same-origin HTML page(s) up to depth ${crawlDepthReached}.` : "",
|
|
1111
|
+
pagesSkipped > 0 ? `${pagesSkipped} URL(s) were skipped during the SEO crawl.` : "",
|
|
1112
|
+
pageStats.pagesMissingTitle > 0 || pageStats.pagesBadTitleLength > 0
|
|
1113
|
+
? `${pageStats.pagesMissingTitle + pageStats.pagesBadTitleLength} crawled page(s) had title-tag issues.`
|
|
1114
|
+
: "",
|
|
1115
|
+
pageStats.pagesMissingMetaDescription > 0 || pageStats.pagesBadMetaDescriptionLength > 0
|
|
1116
|
+
? `${pageStats.pagesMissingMetaDescription + pageStats.pagesBadMetaDescriptionLength} crawled page(s) had meta-description issues.`
|
|
1117
|
+
: "",
|
|
1118
|
+
pageStats.pagesMissingH1 > 0 || pageStats.pagesWithMultipleH1 > 0
|
|
1119
|
+
? `${pageStats.pagesMissingH1 + pageStats.pagesWithMultipleH1} crawled page(s) had H1 issues.`
|
|
1120
|
+
: ""
|
|
1121
|
+
], 5);
|
|
1122
|
+
return {
|
|
1123
|
+
crawlSummary,
|
|
1124
|
+
pageStats,
|
|
1125
|
+
auditedPages: auditedPages.sort((left, right) => right.issueCount - left.issueCount || left.depth - right.depth || left.url.localeCompare(right.url)),
|
|
1126
|
+
evidence
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
export async function runSiteChecks(args) {
|
|
1130
|
+
const blocked = (reason) => SiteChecksSchema.parse({
|
|
1131
|
+
generatedAt: new Date().toISOString(),
|
|
1132
|
+
baseUrl: args.baseUrl,
|
|
1133
|
+
finalResolvedUrl: null,
|
|
1134
|
+
coverage: {
|
|
1135
|
+
performance: buildCoverage("blocked", "Performance checks could not be completed.", [], [reason]),
|
|
1136
|
+
seo: buildCoverage("blocked", "SEO checks could not be completed.", [], [reason]),
|
|
1137
|
+
uiux: buildCoverage("inferred", "UI and UX findings were inferred from the interaction audit because supplemental probing did not run.", [], [reason]),
|
|
1138
|
+
security: buildCoverage("blocked", "Security checks could not be completed.", [], [reason]),
|
|
1139
|
+
technicalHealth: buildCoverage("inferred", "Technical health relies on the saved runtime signals because supplemental probing did not run.", [], [reason]),
|
|
1140
|
+
mobileOptimization: buildCoverage("blocked", "Mobile responsiveness could not be tested.", [], [reason]),
|
|
1141
|
+
contentQuality: buildCoverage("blocked", "Content-quality checks could not be completed.", [], [reason]),
|
|
1142
|
+
cro: buildCoverage("inferred", "CRO findings were inferred from the interaction audit because supplemental probing did not run.", [], [reason])
|
|
1143
|
+
},
|
|
1144
|
+
performance: {
|
|
1145
|
+
desktop: null,
|
|
1146
|
+
mobile: null,
|
|
1147
|
+
failedRequestCount: 0,
|
|
1148
|
+
imageFailureCount: 0,
|
|
1149
|
+
apiFailureCount: 0,
|
|
1150
|
+
navigationErrorCount: 0,
|
|
1151
|
+
stalledInteractionCount: 0,
|
|
1152
|
+
evidence: []
|
|
1153
|
+
},
|
|
1154
|
+
seo: {
|
|
1155
|
+
robotsTxt: { url: new URL("/robots.txt", args.baseUrl).toString(), ok: false, statusCode: null, note: reason },
|
|
1156
|
+
sitemap: { url: new URL("/sitemap.xml", args.baseUrl).toString(), ok: false, statusCode: null, note: reason },
|
|
1157
|
+
brokenLinkCount: 0,
|
|
1158
|
+
checkedLinkCount: 0,
|
|
1159
|
+
brokenLinks: [],
|
|
1160
|
+
evidence: []
|
|
1161
|
+
},
|
|
1162
|
+
security: {
|
|
1163
|
+
https: args.baseUrl.startsWith("https://"),
|
|
1164
|
+
secureTransportVerified: false,
|
|
1165
|
+
initialStatusCode: null,
|
|
1166
|
+
securityHeaders: SECURITY_HEADERS.map((name) => ({ name, present: false, value: null, note: reason })),
|
|
1167
|
+
missingHeaders: [...SECURITY_HEADERS],
|
|
1168
|
+
evidence: []
|
|
1169
|
+
},
|
|
1170
|
+
technicalHealth: {
|
|
1171
|
+
framework: null,
|
|
1172
|
+
consoleErrorCount: 0,
|
|
1173
|
+
consoleWarningCount: 0,
|
|
1174
|
+
pageErrorCount: 0,
|
|
1175
|
+
apiFailureCount: 0,
|
|
1176
|
+
evidence: []
|
|
1177
|
+
},
|
|
1178
|
+
mobileOptimization: {
|
|
1179
|
+
desktop: null,
|
|
1180
|
+
mobile: null,
|
|
1181
|
+
responsiveVerdict: "blocked",
|
|
1182
|
+
evidence: []
|
|
1183
|
+
},
|
|
1184
|
+
contentQuality: {
|
|
1185
|
+
readabilityScore: null,
|
|
1186
|
+
readabilityLabel: "Blocked",
|
|
1187
|
+
wordCount: 0,
|
|
1188
|
+
longParagraphCount: 0,
|
|
1189
|
+
mediaCount: 0,
|
|
1190
|
+
evidence: []
|
|
1191
|
+
},
|
|
1192
|
+
cro: {
|
|
1193
|
+
ctaCount: 0,
|
|
1194
|
+
primaryCtas: [],
|
|
1195
|
+
formCount: 0,
|
|
1196
|
+
submitControlCount: 0,
|
|
1197
|
+
trustSignalCount: 0,
|
|
1198
|
+
evidence: []
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
if (args.budgetMs < 12000) {
|
|
1202
|
+
return blocked(`Only ${Math.max(0, Math.round(args.budgetMs / 1000))} seconds remained for supplemental checks.`);
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const siteChecksStartedAt = Date.now();
|
|
1206
|
+
const linkCheckReserveMs = Math.max(6000, Math.min(12000, Math.round(args.budgetMs * 0.2)));
|
|
1207
|
+
const perProbeTimeoutMs = Math.max(6000, Math.min(config.navigationTimeoutMs, args.budgetMs - linkCheckReserveMs));
|
|
1208
|
+
const [desktopCapture, mobileCapture] = await Promise.all([
|
|
1209
|
+
collectProbe({
|
|
1210
|
+
browser: args.browser,
|
|
1211
|
+
baseUrl: args.baseUrl,
|
|
1212
|
+
mobile: false,
|
|
1213
|
+
ignoreHttpsErrors: args.ignoreHttpsErrors,
|
|
1214
|
+
timezoneId: args.browserTimezone || config.deviceTimezone,
|
|
1215
|
+
storageState: args.storageState,
|
|
1216
|
+
timeoutMs: perProbeTimeoutMs
|
|
1217
|
+
}),
|
|
1218
|
+
collectProbe({
|
|
1219
|
+
browser: args.browser,
|
|
1220
|
+
baseUrl: args.baseUrl,
|
|
1221
|
+
mobile: true,
|
|
1222
|
+
ignoreHttpsErrors: args.ignoreHttpsErrors,
|
|
1223
|
+
timezoneId: args.browserTimezone || config.deviceTimezone,
|
|
1224
|
+
storageState: args.storageState,
|
|
1225
|
+
timeoutMs: perProbeTimeoutMs
|
|
1226
|
+
})
|
|
1227
|
+
]);
|
|
1228
|
+
const captures = [desktopCapture, mobileCapture];
|
|
1229
|
+
const primaryCapture = choosePreferredCapture(captures);
|
|
1230
|
+
const headerCandidates = captures.filter((capture) => capture.probe.statusCode !== null || Object.keys(capture.headers).length > 0);
|
|
1231
|
+
const headerCapture = headerCandidates.length > 0 ? choosePreferredCapture(headerCandidates) : primaryCapture;
|
|
1232
|
+
const probeBaseUrl = isHttpUrl(primaryCapture.probe.finalUrl) ? primaryCapture.probe.finalUrl : args.baseUrl;
|
|
1233
|
+
const robotsUrl = new URL("/robots.txt", probeBaseUrl).toString();
|
|
1234
|
+
const sitemapUrl = new URL("/sitemap.xml", probeBaseUrl).toString();
|
|
1235
|
+
const linkProbeContext = await args.browser.newContext(createContextOptions({
|
|
1236
|
+
mobile: false,
|
|
1237
|
+
ignoreHttpsErrors: args.ignoreHttpsErrors,
|
|
1238
|
+
timezoneId: args.browserTimezone || config.deviceTimezone,
|
|
1239
|
+
storageState: args.storageState
|
|
1240
|
+
}));
|
|
1241
|
+
const linkProbePage = await linkProbeContext.newPage();
|
|
1242
|
+
const parserPage = await linkProbeContext.newPage();
|
|
1243
|
+
const linkCheckTimeoutMs = Math.max(4000, Math.min(10000, linkCheckReserveMs));
|
|
1244
|
+
const [robotsTxt, sitemap] = await Promise.all([
|
|
1245
|
+
checkUrl({
|
|
1246
|
+
page: linkProbePage,
|
|
1247
|
+
url: robotsUrl,
|
|
1248
|
+
timeoutMs: linkCheckTimeoutMs
|
|
1249
|
+
}).catch((error) => ({
|
|
1250
|
+
url: robotsUrl,
|
|
1251
|
+
ok: false,
|
|
1252
|
+
statusCode: null,
|
|
1253
|
+
note: `Request failed: ${cleanErrorMessage(error)}`
|
|
1254
|
+
})),
|
|
1255
|
+
checkUrl({
|
|
1256
|
+
page: linkProbePage,
|
|
1257
|
+
url: sitemapUrl,
|
|
1258
|
+
timeoutMs: linkCheckTimeoutMs
|
|
1259
|
+
}).catch((error) => ({
|
|
1260
|
+
url: sitemapUrl,
|
|
1261
|
+
ok: false,
|
|
1262
|
+
statusCode: null,
|
|
1263
|
+
note: `Request failed: ${cleanErrorMessage(error)}`
|
|
1264
|
+
}))
|
|
1265
|
+
]);
|
|
1266
|
+
const sampleLinks = uniqueItems([...desktopCapture.probe.internalLinkSamples, ...mobileCapture.probe.internalLinkSamples].filter((url) => isHttpUrl(url)), 8);
|
|
1267
|
+
const brokenLinkChecks = await Promise.all(sampleLinks.map((url) => checkUrl({
|
|
1268
|
+
page: linkProbePage,
|
|
1269
|
+
url,
|
|
1270
|
+
timeoutMs: Math.max(4000, Math.min(7000, linkCheckTimeoutMs))
|
|
1271
|
+
})));
|
|
1272
|
+
const crawlBudgetMs = Math.max(0, args.budgetMs - (Date.now() - siteChecksStartedAt));
|
|
1273
|
+
const crawlDeadline = Date.now() + Math.max(0, Math.min(20000, crawlBudgetMs));
|
|
1274
|
+
const seoCrawl = crawlDeadline - Date.now() >= 3000
|
|
1275
|
+
? await crawlSeoSite({
|
|
1276
|
+
requestPage: linkProbePage,
|
|
1277
|
+
parserPage,
|
|
1278
|
+
seedUrl: probeBaseUrl,
|
|
1279
|
+
requestTimeoutMs: Math.max(2500, Math.min(6000, linkCheckTimeoutMs)),
|
|
1280
|
+
deadline: crawlDeadline
|
|
1281
|
+
})
|
|
1282
|
+
: buildEmptySeoCrawlResult();
|
|
1283
|
+
await parserPage.close().catch(() => undefined);
|
|
1284
|
+
await linkProbeContext.close().catch(() => undefined);
|
|
1285
|
+
const brokenLinks = brokenLinkChecks.filter((check) => !check.ok);
|
|
1286
|
+
const consoleErrorCount = args.rawEvents.filter((event) => typeof event === "object" && event !== null && event.type === "console" && /error/i.test(String(event.level ?? ""))).length;
|
|
1287
|
+
const consoleWarningCount = args.rawEvents.filter((event) => typeof event === "object" && event !== null && event.type === "console" && /warn/i.test(String(event.level ?? ""))).length;
|
|
1288
|
+
const pageErrorCount = args.rawEvents.filter((event) => typeof event === "object" && event !== null && event.type === "pageerror").length;
|
|
1289
|
+
const requestFailures = args.rawEvents.filter((event) => typeof event === "object" && event !== null && event.type === "requestfailed");
|
|
1290
|
+
const navigationErrorCount = args.rawEvents.filter((event) => typeof event === "object" && event !== null && event.type === "navigation_error").length;
|
|
1291
|
+
const stalledInteractionCount = args.taskResults.reduce((sum, task) => sum +
|
|
1292
|
+
task.history.filter((entry) => !entry.result.success || /no clear visible change|timeout|unchanged page states/i.test(entry.result.note)).length, 0);
|
|
1293
|
+
const imageFailureCount = requestFailures.filter((event) => IMAGE_URL_PATTERN.test(String(event.url ?? "")) || /\/images?\//i.test(String(event.url ?? ""))).length;
|
|
1294
|
+
const apiFailureCount = requestFailures.filter((event) => API_URL_PATTERN.test(String(event.url ?? ""))).length;
|
|
1295
|
+
const framework = detectFramework(primaryCapture.html || desktopCapture.html || mobileCapture.html, headerCapture.headers);
|
|
1296
|
+
const primaryHasDomEvidence = hasProbeDomEvidence(primaryCapture.probe);
|
|
1297
|
+
const primaryHasPerformanceEvidence = hasPerformanceEvidence(primaryCapture.probe);
|
|
1298
|
+
const resolvedSecurityUrl = isHttpUrl(headerCapture.probe.finalUrl)
|
|
1299
|
+
? headerCapture.probe.finalUrl
|
|
1300
|
+
: isHttpUrl(primaryCapture.probe.finalUrl)
|
|
1301
|
+
? primaryCapture.probe.finalUrl
|
|
1302
|
+
: args.baseUrl;
|
|
1303
|
+
const securityHeaders = SECURITY_HEADERS.map((name) => {
|
|
1304
|
+
const rawValue = headerCapture.headers[name] ?? headerCapture.headers[name.toLowerCase()] ?? null;
|
|
1305
|
+
return {
|
|
1306
|
+
name,
|
|
1307
|
+
present: Boolean(rawValue),
|
|
1308
|
+
value: rawValue,
|
|
1309
|
+
note: rawValue ? `Present with value '${rawValue}'.` : "Missing from the main document response."
|
|
1310
|
+
};
|
|
1311
|
+
});
|
|
1312
|
+
const missingHeaders = securityHeaders.filter((header) => !header.present).map((header) => header.name);
|
|
1313
|
+
const desktopHasDomEvidence = hasProbeDomEvidence(desktopCapture.probe);
|
|
1314
|
+
const mobileHasDomEvidence = hasProbeDomEvidence(mobileCapture.probe);
|
|
1315
|
+
const desktopHasPerformanceEvidence = hasPerformanceEvidence(desktopCapture.probe);
|
|
1316
|
+
const mobileHasPerformanceEvidence = hasPerformanceEvidence(mobileCapture.probe);
|
|
1317
|
+
const primaryHasContentEvidence = hasContentEvidence(primaryCapture.probe);
|
|
1318
|
+
const primaryHasCroEvidence = hasCroEvidence(primaryCapture.probe);
|
|
1319
|
+
const interactionEvidenceAvailable = args.taskResults.some((task) => task.history.length > 0);
|
|
1320
|
+
const responsiveVerdict = !mobileHasDomEvidence
|
|
1321
|
+
? "blocked"
|
|
1322
|
+
: mobileCapture.probe.horizontalOverflow || mobileCapture.probe.tapTargetIssueCount >= 3 || mobileCapture.probe.smallTextIssueCount >= 6
|
|
1323
|
+
? "poor"
|
|
1324
|
+
: mobileCapture.probe.tapTargetIssueCount > 0 || mobileCapture.probe.smallTextIssueCount > 0
|
|
1325
|
+
? "mixed"
|
|
1326
|
+
: "responsive";
|
|
1327
|
+
const performanceCoverageStatus = desktopHasPerformanceEvidence || mobileHasPerformanceEvidence ? "verified" : "blocked";
|
|
1328
|
+
const seoCoverageStatus = primaryCapture.probe.loadOk || primaryHasDomEvidence || robotsTxt.ok || sitemap.ok || seoCrawl.crawlSummary.totalPagesAudited > 0
|
|
1329
|
+
? "verified"
|
|
1330
|
+
: "blocked";
|
|
1331
|
+
const securityCoverageStatus = headerCapture.probe.loadOk || headerCapture.probe.statusCode !== null || Object.keys(headerCapture.headers).length > 0
|
|
1332
|
+
? "verified"
|
|
1333
|
+
: "blocked";
|
|
1334
|
+
const uiuxCoverageStatus = desktopHasDomEvidence || mobileHasDomEvidence || interactionEvidenceAvailable ? "verified" : "blocked";
|
|
1335
|
+
const mobileCoverageStatus = mobileHasDomEvidence || mobileHasPerformanceEvidence ? "verified" : "blocked";
|
|
1336
|
+
const contentCoverageStatus = primaryCapture.probe.loadOk || primaryHasContentEvidence ? "verified" : "blocked";
|
|
1337
|
+
const croCoverageStatus = primaryCapture.probe.loadOk || primaryHasCroEvidence || interactionEvidenceAvailable ? "verified" : "blocked";
|
|
1338
|
+
const siteChecks = SiteChecksSchema.parse({
|
|
1339
|
+
generatedAt: new Date().toISOString(),
|
|
1340
|
+
baseUrl: args.baseUrl,
|
|
1341
|
+
finalResolvedUrl: isHttpUrl(primaryCapture.probe.finalUrl)
|
|
1342
|
+
? primaryCapture.probe.finalUrl
|
|
1343
|
+
: isHttpUrl(desktopCapture.probe.finalUrl)
|
|
1344
|
+
? desktopCapture.probe.finalUrl
|
|
1345
|
+
: isHttpUrl(mobileCapture.probe.finalUrl)
|
|
1346
|
+
? mobileCapture.probe.finalUrl
|
|
1347
|
+
: null,
|
|
1348
|
+
coverage: {
|
|
1349
|
+
performance: buildCoverage(performanceCoverageStatus, performanceCoverageStatus === "verified"
|
|
1350
|
+
? "Performance was verified from direct desktop and mobile probe measurements plus saved runtime request failures."
|
|
1351
|
+
: "Performance probing was blocked because no direct probe measurements were captured in the supplemental check.", [
|
|
1352
|
+
desktopCapture.probe.note,
|
|
1353
|
+
mobileCapture.probe.note,
|
|
1354
|
+
navigationErrorCount > 0 ? `${navigationErrorCount} navigation error(s) were recorded during the main run.` : ""
|
|
1355
|
+
], performanceCoverageStatus === "blocked" ? [desktopCapture.probe.note] : []),
|
|
1356
|
+
seo: buildCoverage(seoCoverageStatus, seoCoverageStatus === "verified"
|
|
1357
|
+
? "SEO was verified from the live page metadata, a same-origin HTML crawl, direct robots and sitemap fetches, and sampled internal-link checks."
|
|
1358
|
+
: "SEO checks were blocked because no direct crawl or metadata evidence was captured.", [
|
|
1359
|
+
primaryCapture.probe.h1Count > 0 ? `Detected ${primaryCapture.probe.h1Count} H1 heading(s).` : "No H1 heading was detected.",
|
|
1360
|
+
robotsTxt.note,
|
|
1361
|
+
sitemap.note,
|
|
1362
|
+
...seoCrawl.evidence,
|
|
1363
|
+
brokenLinks.length > 0 ? `${brokenLinks.length} sampled internal link(s) failed.` : "Sampled internal links responded successfully."
|
|
1364
|
+
], seoCoverageStatus === "blocked" ? [primaryCapture.probe.note] : []),
|
|
1365
|
+
uiux: buildCoverage(uiuxCoverageStatus, uiuxCoverageStatus === "verified"
|
|
1366
|
+
? "UI and UX findings were verified from the interaction audit plus direct desktop and mobile page probes."
|
|
1367
|
+
: "UI and UX checks were blocked because the run did not retain enough interaction or page evidence.", uniqueItems([
|
|
1368
|
+
desktopCapture.probe.note,
|
|
1369
|
+
mobileCapture.probe.note,
|
|
1370
|
+
interactionEvidenceAvailable ? `${args.taskResults.length} task path(s) contributed direct interaction evidence.` : "",
|
|
1371
|
+
desktopCapture.probe.horizontalOverflow ? "The desktop viewport showed horizontal overflow." : "",
|
|
1372
|
+
mobileCapture.probe.horizontalOverflow ? "The mobile viewport showed horizontal overflow." : ""
|
|
1373
|
+
], 4), uiuxCoverageStatus === "blocked" ? [desktopCapture.probe.note || mobileCapture.probe.note || "Interaction evidence was unavailable."] : []),
|
|
1374
|
+
security: buildCoverage(securityCoverageStatus, securityCoverageStatus === "verified"
|
|
1375
|
+
? "Security was verified from HTTPS transport status and sampled response headers."
|
|
1376
|
+
: "Security checks were blocked because the main document response could not be verified.", [
|
|
1377
|
+
headerCapture.probe.note,
|
|
1378
|
+
missingHeaders.length > 0 ? `${missingHeaders.length} recommended security header(s) were missing.` : "All sampled security headers were present."
|
|
1379
|
+
], securityCoverageStatus === "blocked" ? [headerCapture.probe.note] : []),
|
|
1380
|
+
technicalHealth: buildCoverage("verified", "Technical health was verified from console, page error, request failure, and framework fingerprint signals.", [
|
|
1381
|
+
consoleErrorCount > 0 ? `${consoleErrorCount} console error(s) were captured.` : "No console errors were captured.",
|
|
1382
|
+
pageErrorCount > 0 ? `${pageErrorCount} page error(s) were captured.` : "No page errors were captured.",
|
|
1383
|
+
framework ? `Detected likely framework: ${framework}.` : "The framework could not be confidently fingerprinted from the page source."
|
|
1384
|
+
]),
|
|
1385
|
+
mobileOptimization: buildCoverage(mobileCoverageStatus, mobileCoverageStatus === "verified"
|
|
1386
|
+
? "Mobile optimization was verified using direct evidence from the dedicated mobile viewport probe."
|
|
1387
|
+
: "Mobile optimization was blocked because the dedicated mobile probe did not capture direct evidence.", [
|
|
1388
|
+
mobileCapture.probe.note,
|
|
1389
|
+
mobileCapture.probe.horizontalOverflow ? "Horizontal overflow was detected on mobile." : "No horizontal overflow was detected on mobile.",
|
|
1390
|
+
mobileCapture.probe.tapTargetIssueCount > 0
|
|
1391
|
+
? `${mobileCapture.probe.tapTargetIssueCount} undersized tap target(s) were detected on mobile.`
|
|
1392
|
+
: "Tap target sizing cleared the 44px threshold on mobile."
|
|
1393
|
+
], mobileCoverageStatus === "blocked" ? [mobileCapture.probe.note] : []),
|
|
1394
|
+
contentQuality: buildCoverage(contentCoverageStatus, contentCoverageStatus === "verified"
|
|
1395
|
+
? "Content quality was verified from live page copy, readability, structure, and media counts."
|
|
1396
|
+
: "Content-quality checks were blocked because the probe did not capture direct content evidence.", [
|
|
1397
|
+
primaryCapture.probe.readabilityScore !== null
|
|
1398
|
+
? `Readability scored ${primaryCapture.probe.readabilityScore} (${primaryCapture.probe.readabilityLabel}).`
|
|
1399
|
+
: "Readability could not be scored from the available page text.",
|
|
1400
|
+
primaryCapture.probe.wordCount > 0 ? `The page exposed about ${primaryCapture.probe.wordCount} visible words.` : "Very little visible copy was available."
|
|
1401
|
+
], contentCoverageStatus === "blocked" ? [primaryCapture.probe.note] : []),
|
|
1402
|
+
cro: buildCoverage(croCoverageStatus, croCoverageStatus === "verified"
|
|
1403
|
+
? "CRO was verified from visible CTAs, forms, trust cues, and the interaction audit."
|
|
1404
|
+
: "CRO checks were blocked because the run did not capture direct conversion evidence.", [
|
|
1405
|
+
primaryCapture.probe.ctaSamples.length > 0 ? `Detected CTA labels such as ${primaryCapture.probe.ctaSamples.slice(0, 3).join(", ")}.` : "No strong CTA labels were detected on the sampled page.",
|
|
1406
|
+
primaryCapture.probe.formCount > 0 ? `Detected ${primaryCapture.probe.formCount} form(s).` : "No forms were detected on the sampled page.",
|
|
1407
|
+
interactionEvidenceAvailable ? `${args.taskResults.length} task path(s) contributed interaction evidence for conversion analysis.` : ""
|
|
1408
|
+
], croCoverageStatus === "blocked" ? [primaryCapture.probe.note || "Direct conversion evidence was unavailable."] : [])
|
|
1409
|
+
},
|
|
1410
|
+
performance: {
|
|
1411
|
+
desktop: desktopCapture.probe,
|
|
1412
|
+
mobile: mobileCapture.probe,
|
|
1413
|
+
failedRequestCount: requestFailures.length,
|
|
1414
|
+
imageFailureCount,
|
|
1415
|
+
apiFailureCount,
|
|
1416
|
+
navigationErrorCount,
|
|
1417
|
+
stalledInteractionCount,
|
|
1418
|
+
evidence: uniqueItems([
|
|
1419
|
+
desktopCapture.probe.performance.domContentLoadedMs !== null
|
|
1420
|
+
? `Desktop DOM content loaded in ${Math.round(desktopCapture.probe.performance.domContentLoadedMs)}ms.`
|
|
1421
|
+
: "",
|
|
1422
|
+
mobileCapture.probe.performance.domContentLoadedMs !== null
|
|
1423
|
+
? `Mobile DOM content loaded in ${Math.round(mobileCapture.probe.performance.domContentLoadedMs)}ms.`
|
|
1424
|
+
: "",
|
|
1425
|
+
requestFailures.length > 0 ? `${requestFailures.length} failed request(s) were recorded.` : "No failed requests were recorded during the main run."
|
|
1426
|
+
], 4)
|
|
1427
|
+
},
|
|
1428
|
+
seo: {
|
|
1429
|
+
robotsTxt,
|
|
1430
|
+
sitemap,
|
|
1431
|
+
brokenLinkCount: brokenLinks.length,
|
|
1432
|
+
checkedLinkCount: sampleLinks.length,
|
|
1433
|
+
brokenLinks,
|
|
1434
|
+
crawlSummary: seoCrawl.crawlSummary,
|
|
1435
|
+
pageStats: seoCrawl.pageStats,
|
|
1436
|
+
auditedPages: seoCrawl.auditedPages,
|
|
1437
|
+
evidence: uniqueItems([
|
|
1438
|
+
primaryCapture.probe.metaDescription ? "A meta description was present on the sampled page." : "The sampled page did not expose a meta description.",
|
|
1439
|
+
primaryCapture.probe.structuredDataCount > 0
|
|
1440
|
+
? `Detected ${primaryCapture.probe.structuredDataCount} structured-data block(s).`
|
|
1441
|
+
: "No structured-data blocks were detected on the sampled page.",
|
|
1442
|
+
robotsTxt.note,
|
|
1443
|
+
sitemap.note,
|
|
1444
|
+
...seoCrawl.evidence
|
|
1445
|
+
], 7)
|
|
1446
|
+
},
|
|
1447
|
+
security: {
|
|
1448
|
+
https: resolvedSecurityUrl.startsWith("https://"),
|
|
1449
|
+
secureTransportVerified: resolvedSecurityUrl.startsWith("https://") && (headerCapture.probe.loadOk || headerCapture.probe.statusCode !== null),
|
|
1450
|
+
initialStatusCode: headerCapture.probe.statusCode,
|
|
1451
|
+
securityHeaders,
|
|
1452
|
+
missingHeaders,
|
|
1453
|
+
evidence: uniqueItems([
|
|
1454
|
+
resolvedSecurityUrl.startsWith("https://")
|
|
1455
|
+
? "The sampled page loaded over HTTPS."
|
|
1456
|
+
: "The sampled page did not load over HTTPS.",
|
|
1457
|
+
missingHeaders.length > 0 ? `Missing security headers: ${missingHeaders.join(", ")}.` : "All sampled security headers were present on the main document response."
|
|
1458
|
+
], 4)
|
|
1459
|
+
},
|
|
1460
|
+
technicalHealth: {
|
|
1461
|
+
framework,
|
|
1462
|
+
consoleErrorCount,
|
|
1463
|
+
consoleWarningCount,
|
|
1464
|
+
pageErrorCount,
|
|
1465
|
+
apiFailureCount,
|
|
1466
|
+
evidence: uniqueItems([
|
|
1467
|
+
framework ? `Detected likely framework: ${framework}.` : "The framework could not be confidently detected from the sampled markup.",
|
|
1468
|
+
consoleErrorCount > 0 ? `${consoleErrorCount} console error(s) were captured.` : "No console errors were captured.",
|
|
1469
|
+
apiFailureCount > 0 ? `${apiFailureCount} API-like request failure(s) were captured.` : "No API-like request failures were captured."
|
|
1470
|
+
], 5)
|
|
1471
|
+
},
|
|
1472
|
+
mobileOptimization: {
|
|
1473
|
+
desktop: desktopCapture.probe,
|
|
1474
|
+
mobile: mobileCapture.probe,
|
|
1475
|
+
responsiveVerdict,
|
|
1476
|
+
evidence: uniqueItems([
|
|
1477
|
+
mobileCapture.probe.horizontalOverflow ? "The mobile viewport overflowed horizontally." : "The mobile viewport stayed within the viewport width.",
|
|
1478
|
+
mobileCapture.probe.smallTextIssueCount > 0
|
|
1479
|
+
? `${mobileCapture.probe.smallTextIssueCount} small-text issue(s) were detected on mobile.`
|
|
1480
|
+
: "Text sizing stayed above the small-text threshold on mobile.",
|
|
1481
|
+
mobileCapture.probe.tapTargetIssueCount > 0
|
|
1482
|
+
? `${mobileCapture.probe.tapTargetIssueCount} tap target issue(s) were detected on mobile.`
|
|
1483
|
+
: "Tap target sizing cleared the minimum touch target threshold on mobile."
|
|
1484
|
+
], 5)
|
|
1485
|
+
},
|
|
1486
|
+
contentQuality: {
|
|
1487
|
+
readabilityScore: primaryCapture.probe.readabilityScore,
|
|
1488
|
+
readabilityLabel: primaryCapture.probe.readabilityLabel,
|
|
1489
|
+
wordCount: primaryCapture.probe.wordCount,
|
|
1490
|
+
longParagraphCount: primaryCapture.probe.longParagraphCount,
|
|
1491
|
+
mediaCount: primaryCapture.probe.mediaCount,
|
|
1492
|
+
evidence: uniqueItems([
|
|
1493
|
+
primaryCapture.probe.readabilityScore !== null
|
|
1494
|
+
? `Readability score: ${primaryCapture.probe.readabilityScore} (${primaryCapture.probe.readabilityLabel}).`
|
|
1495
|
+
: "Readability could not be scored from the sampled page text.",
|
|
1496
|
+
primaryCapture.probe.longParagraphCount > 0
|
|
1497
|
+
? `${primaryCapture.probe.longParagraphCount} long paragraph(s) were detected.`
|
|
1498
|
+
: "No unusually long paragraphs were detected in the sampled page text.",
|
|
1499
|
+
primaryCapture.probe.mediaCount > 0 ? `Detected ${primaryCapture.probe.mediaCount} media element(s).` : "No media elements were detected."
|
|
1500
|
+
], 5)
|
|
1501
|
+
},
|
|
1502
|
+
cro: {
|
|
1503
|
+
ctaCount: primaryCapture.probe.ctaSamples.length,
|
|
1504
|
+
primaryCtas: primaryCapture.probe.ctaSamples,
|
|
1505
|
+
formCount: primaryCapture.probe.formCount,
|
|
1506
|
+
submitControlCount: primaryCapture.probe.submitControlCount,
|
|
1507
|
+
trustSignalCount: primaryCapture.probe.trustSignalCount,
|
|
1508
|
+
evidence: uniqueItems([
|
|
1509
|
+
primaryCapture.probe.ctaSamples.length > 0
|
|
1510
|
+
? `Visible CTA labels included ${primaryCapture.probe.ctaSamples.slice(0, 4).join(", ")}.`
|
|
1511
|
+
: "No clear high-intent CTA labels were detected on the sampled page.",
|
|
1512
|
+
primaryCapture.probe.trustSignalCount > 0
|
|
1513
|
+
? `Detected ${primaryCapture.probe.trustSignalCount} trust-signal keyword match(es).`
|
|
1514
|
+
: "Trust-signal text was light on the sampled page.",
|
|
1515
|
+
primaryCapture.probe.formCount > 0
|
|
1516
|
+
? `Detected ${primaryCapture.probe.formCount} form(s) with ${primaryCapture.probe.submitControlCount} submit control(s).`
|
|
1517
|
+
: "No forms were detected on the sampled page."
|
|
1518
|
+
], 5)
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
return siteChecks;
|
|
1522
|
+
}
|
|
1523
|
+
catch (error) {
|
|
1524
|
+
return blocked(`Supplemental checks failed: ${cleanErrorMessage(error)}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|