create-backlist 10.0.3 → 10.0.5

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.
@@ -0,0 +1,125 @@
1
+ // Real API validator — every result from actual HTTP calls
2
+ import { shortId, formatBytes } from '../qa-engine.js';
3
+
4
+ const HTTP_TIMEOUT = 12_000;
5
+
6
+ export class RealAPIValidator {
7
+ #session;
8
+
9
+ constructor(session) { this.#session = session; }
10
+
11
+ async probe(url, method = 'GET', options = {}) {
12
+ const t0 = Date.now();
13
+
14
+ try {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT);
17
+
18
+ const response = await fetch(url, {
19
+ method,
20
+ signal : controller.signal,
21
+ headers: {
22
+ 'User-Agent': 'Backlist-QA/12.0',
23
+ 'Accept' : 'application/json, text/html, */*',
24
+ ...options.headers,
25
+ },
26
+ redirect: 'follow',
27
+ });
28
+ clearTimeout(timer);
29
+
30
+ const responseTime = Date.now() - t0;
31
+ const contentType = response.headers.get('content-type') || '';
32
+ const headers = {};
33
+ response.headers.forEach((v, k) => { headers[k] = v; });
34
+
35
+ let body = '';
36
+ let bodySize = 0;
37
+ try {
38
+ body = await response.text();
39
+ bodySize = new TextEncoder().encode(body).length;
40
+ } catch {}
41
+
42
+ let parsedBody = null;
43
+ if (contentType.includes('json')) {
44
+ try { parsedBody = JSON.parse(body); } catch {}
45
+ }
46
+
47
+ const statusCode = response.status;
48
+ const pass = statusCode >= 200 && statusCode < 400;
49
+
50
+ return {
51
+ url,
52
+ method,
53
+ statusCode,
54
+ pass,
55
+ message : pass
56
+ ? `${method} ${url} → ${statusCode} (${responseTime}ms)`
57
+ : `${method} ${url} → ${statusCode} FAIL`,
58
+ responseTime,
59
+ contentType,
60
+ headers,
61
+ body : body.slice(0, 2000),
62
+ parsedBody,
63
+ bodySize,
64
+ bodySizeStr: formatBytes(bodySize),
65
+ };
66
+
67
+ } catch (err) {
68
+ const duration = Date.now() - t0;
69
+ return {
70
+ url,
71
+ method,
72
+ statusCode : 0,
73
+ pass : false,
74
+ message : `Connection failed: ${err.message}`,
75
+ responseTime: duration,
76
+ contentType: '',
77
+ headers : {},
78
+ body : '',
79
+ error : err.message,
80
+ };
81
+ }
82
+ }
83
+
84
+ async discoverFromNetworkLog(networkLog) {
85
+ const seen = new Set();
86
+ const apis = [];
87
+
88
+ for (const entry of networkLog) {
89
+ if (!entry.url || seen.has(entry.url)) continue;
90
+ if (!entry.url.includes('/api/') && !entry.url.includes('/graphql')) continue;
91
+ seen.add(entry.url);
92
+ apis.push({
93
+ id : shortId(),
94
+ url : entry.url,
95
+ method: entry.method || 'GET',
96
+ type : 'api',
97
+ source: 'network-log',
98
+ });
99
+ }
100
+
101
+ return apis;
102
+ }
103
+
104
+ // Validate API contract: response structure, required fields
105
+ async validateContract(url, expectedSchema = {}) {
106
+ const result = await this.probe(url);
107
+ if (!result.pass || !result.parsedBody) return result;
108
+
109
+ const violations = [];
110
+ for (const [key, type] of Object.entries(expectedSchema)) {
111
+ const val = result.parsedBody[key];
112
+ if (val === undefined) {
113
+ violations.push(`Missing field: ${key}`);
114
+ } else if (type && typeof val !== type) {
115
+ violations.push(`Field ${key}: expected ${type}, got ${typeof val}`);
116
+ }
117
+ }
118
+
119
+ return {
120
+ ...result,
121
+ contractPass : violations.length === 0,
122
+ contractViolations: violations,
123
+ };
124
+ }
125
+ }
@@ -0,0 +1,124 @@
1
+ // Performance profiler with HTTP fallback
2
+ import { getBrowserLaunchOptions } from '../browser/installer.js';
3
+ import { formatBytes } from '../qa-engine.js';
4
+
5
+ export class PerformanceProfiler {
6
+ #session;
7
+
8
+ constructor(session) { this.#session = session; }
9
+
10
+ async profile(url) {
11
+ const launchOpts = await getBrowserLaunchOptions();
12
+
13
+ if (!launchOpts.available) {
14
+ return this.#httpFallback(url);
15
+ }
16
+
17
+ let playwright;
18
+ try { playwright = await import('playwright'); }
19
+ catch { return this.#httpFallback(url); }
20
+
21
+ const { executablePath, headless, args } = launchOpts;
22
+ let browser;
23
+
24
+ try {
25
+ browser = await playwright.chromium.launch({ executablePath, headless, args });
26
+ } catch {
27
+ return this.#httpFallback(url);
28
+ }
29
+
30
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
31
+ const page = await context.newPage();
32
+ const slowResources = [];
33
+ const resourceTimings = [];
34
+
35
+ page.on('response', async res => {
36
+ try {
37
+ const timing = res.timing();
38
+ if (!timing || timing.responseEnd <= 0) return;
39
+ const duration = Math.round(timing.responseEnd - timing.requestStart);
40
+ const entry = { url: res.url(), type: res.request().resourceType(), status: res.status(), duration, size: 0 };
41
+ try { const body = await res.body(); entry.size = body?.length || 0; } catch {}
42
+ resourceTimings.push(entry);
43
+ if (duration > 2000) slowResources.push(entry);
44
+ } catch {}
45
+ });
46
+
47
+ try {
48
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
49
+
50
+ const metrics = await page.evaluate(() => new Promise(resolve => {
51
+ const result = { lcp: null, fcp: null, cls: null, fid: null, ttfb: null, tbt: null, memoryUsed: null, domNodes: document.querySelectorAll('*').length };
52
+
53
+ try {
54
+ new PerformanceObserver(list => {
55
+ for (const e of list.getEntries()) {
56
+ if (e.entryType === 'paint' && e.name === 'first-contentful-paint') result.fcp = Math.round(e.startTime);
57
+ if (e.entryType === 'largest-contentful-paint') result.lcp = Math.round(e.startTime);
58
+ }
59
+ }).observe({ entryTypes: ['paint','largest-contentful-paint'] });
60
+ } catch {}
61
+
62
+ let cls = 0;
63
+ try {
64
+ new PerformanceObserver(list => {
65
+ for (const e of list.getEntries()) if (!e.hadRecentInput) cls += e.value;
66
+ }).observe({ entryTypes: ['layout-shift'] });
67
+ } catch {}
68
+
69
+ const nav = performance.getEntriesByType('navigation')[0];
70
+ if (nav) result.ttfb = Math.round(nav.responseStart);
71
+ if (performance.memory) result.memoryUsed = performance.memory.usedJSHeapSize;
72
+
73
+ setTimeout(() => { result.cls = parseFloat(cls.toFixed(4)); resolve(result); }, 5000);
74
+ }));
75
+
76
+ return { ...metrics, slowResources, resourceTimings: resourceTimings.slice(0, 50), url, mode: 'browser' };
77
+
78
+ } catch (err) {
79
+ return { ...this.#emptyMetrics(), error: err.message, url, mode: 'browser-error' };
80
+ } finally {
81
+ await page.close().catch(() => {});
82
+ await context.close().catch(() => {});
83
+ await browser.close().catch(() => {});
84
+ }
85
+ }
86
+
87
+ // HTTP-only fallback: measures real TTFB + response time
88
+ async #httpFallback(url) {
89
+ const t0 = Date.now();
90
+ try {
91
+ const controller = new AbortController();
92
+ const timer = setTimeout(() => controller.abort(), 15_000);
93
+ const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
94
+ clearTimeout(timer);
95
+ const ttfb = Date.now() - t0;
96
+ const body = await res.text().catch(() => '');
97
+ const totalTime = Date.now() - t0;
98
+ const bodySize = new TextEncoder().encode(body).length;
99
+
100
+ return {
101
+ lcp : null, // requires browser
102
+ fcp : null,
103
+ cls : null,
104
+ fid : null,
105
+ tbt : null,
106
+ ttfb, // real TTFB from HTTP
107
+ totalTime, // real total response time
108
+ bodySize,
109
+ statusCode : res.status,
110
+ slowResources: totalTime > 3000 ? [{ url, duration: totalTime, size: bodySize, type: 'document' }] : [],
111
+ resourceTimings: [],
112
+ url,
113
+ mode : 'http-fallback',
114
+ note : 'LCP/FCP/CLS require Playwright browser — run: npx playwright install chromium',
115
+ };
116
+ } catch (err) {
117
+ return { ...this.#emptyMetrics(), ttfb: null, error: err.message, url, mode: 'http-fallback' };
118
+ }
119
+ }
120
+
121
+ #emptyMetrics() {
122
+ return { lcp: null, fcp: null, cls: null, fid: null, ttfb: null, tbt: null, slowResources: [], resourceTimings: [] };
123
+ }
124
+ }
@@ -0,0 +1,207 @@
1
+ // Real security scanner — every result from actual HTTP responses
2
+ const SECURITY_CHECKS = [
3
+ {
4
+ id : 'csp',
5
+ name : 'Content-Security-Policy',
6
+ header: 'content-security-policy',
7
+ sev : 'P1',
8
+ category: 'headers',
9
+ validate: (v) => !!v,
10
+ recommendation: 'Add CSP header to prevent XSS attacks',
11
+ },
12
+ {
13
+ id : 'hsts',
14
+ name : 'HSTS',
15
+ header: 'strict-transport-security',
16
+ sev : 'P1',
17
+ category: 'headers',
18
+ validate: (v) => !!v,
19
+ recommendation: 'Add HSTS to enforce HTTPS',
20
+ },
21
+ {
22
+ id : 'xframe',
23
+ name : 'X-Frame-Options',
24
+ header: 'x-frame-options',
25
+ sev : 'P1',
26
+ category: 'headers',
27
+ validate: (v) => v && ['DENY','SAMEORIGIN'].includes(v.toUpperCase()),
28
+ recommendation: 'Set X-Frame-Options: DENY or SAMEORIGIN',
29
+ },
30
+ {
31
+ id : 'xcto',
32
+ name : 'X-Content-Type-Options',
33
+ header: 'x-content-type-options',
34
+ sev : 'P2',
35
+ category: 'headers',
36
+ validate: (v) => v === 'nosniff',
37
+ recommendation: 'Set X-Content-Type-Options: nosniff',
38
+ },
39
+ {
40
+ id : 'rp',
41
+ name : 'Referrer-Policy',
42
+ header: 'referrer-policy',
43
+ sev : 'P2',
44
+ category: 'headers',
45
+ validate: (v) => !!v,
46
+ recommendation: 'Add Referrer-Policy header',
47
+ },
48
+ {
49
+ id : 'pp',
50
+ name : 'Permissions-Policy',
51
+ header: 'permissions-policy',
52
+ sev : 'P3',
53
+ category: 'headers',
54
+ validate: (v) => !!v,
55
+ recommendation: 'Add Permissions-Policy header',
56
+ },
57
+ {
58
+ id : 'server',
59
+ name : 'Server version not exposed',
60
+ header: 'server',
61
+ sev : 'P2',
62
+ category: 'information-disclosure',
63
+ validate: (v) => !v || (!v.includes('/') && !(/\d+\.\d+/.test(v))),
64
+ recommendation: 'Remove or genericize the Server header',
65
+ },
66
+ {
67
+ id : 'xpb',
68
+ name : 'X-Powered-By not exposed',
69
+ header: 'x-powered-by',
70
+ sev : 'P2',
71
+ category: 'information-disclosure',
72
+ validate: (v) => !v,
73
+ recommendation: 'Remove X-Powered-By header (app.disable("x-powered-by"))',
74
+ },
75
+ ];
76
+
77
+ const CORS_CHECKS = [
78
+ {
79
+ id : 'cors-wildcard-creds',
80
+ name : 'CORS wildcard + credentials',
81
+ sev : 'P0',
82
+ category: 'cors',
83
+ check : (h) => !(h['access-control-allow-origin'] === '*' && h['access-control-allow-credentials'] === 'true'),
84
+ recommendation: 'Never combine CORS wildcard with allow-credentials',
85
+ },
86
+ ];
87
+
88
+ const HTTPS_TIMEOUT = 10_000;
89
+
90
+ export class SecurityScanner {
91
+ #session;
92
+
93
+ constructor(session) { this.#session = session; }
94
+
95
+ async scan(url) {
96
+ const findings = [];
97
+
98
+ // Real HTTP request to get actual headers
99
+ let headers = {};
100
+ let statusCode = 0;
101
+ try {
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), HTTPS_TIMEOUT);
104
+ const res = await fetch(url, {
105
+ method : 'GET',
106
+ signal : controller.signal,
107
+ headers: { 'User-Agent': 'Backlist-SecurityScanner/12.0' },
108
+ redirect: 'follow',
109
+ });
110
+ clearTimeout(timer);
111
+ statusCode = res.status;
112
+ res.headers.forEach((v, k) => { headers[k] = v; });
113
+ } catch (err) {
114
+ findings.push({
115
+ check : 'Server reachability',
116
+ detail : `Cannot reach ${url}: ${err.message}`,
117
+ pass : false,
118
+ severity: 'P0',
119
+ category: 'connectivity',
120
+ evidence: { error: err.message },
121
+ recommendation: 'Ensure server is running and accessible',
122
+ });
123
+ return findings;
124
+ }
125
+
126
+ // Check all security headers against real response
127
+ for (const check of SECURITY_CHECKS) {
128
+ const value = headers[check.header] || '';
129
+ const pass = check.validate(value);
130
+ findings.push({
131
+ check : check.name,
132
+ detail : pass
133
+ ? `${check.header}: ${value || '(present)'}`
134
+ : `Missing or invalid ${check.header} — ${check.recommendation}`,
135
+ pass,
136
+ severity: pass ? 'INFO' : check.sev,
137
+ category: check.category,
138
+ evidence: { header: check.header, value: value || null, allHeaders: headers },
139
+ recommendation: check.recommendation,
140
+ });
141
+ }
142
+
143
+ // CORS checks on real headers
144
+ for (const check of CORS_CHECKS) {
145
+ const pass = check.check(headers);
146
+ findings.push({
147
+ check : check.name,
148
+ detail : pass ? 'CORS configuration looks safe' : `CORS misconfiguration: ${check.recommendation}`,
149
+ pass,
150
+ severity: pass ? 'INFO' : check.sev,
151
+ category: check.category,
152
+ evidence: {
153
+ 'access-control-allow-origin' : headers['access-control-allow-origin'],
154
+ 'access-control-allow-credentials': headers['access-control-allow-credentials'],
155
+ },
156
+ recommendation: check.recommendation,
157
+ });
158
+ }
159
+
160
+ // HTTPS check
161
+ const isHTTPS = url.startsWith('https://');
162
+ findings.push({
163
+ check : 'HTTPS enforced',
164
+ detail : isHTTPS ? 'Site uses HTTPS' : 'Site is using HTTP — not encrypted',
165
+ pass : isHTTPS,
166
+ severity: isHTTPS ? 'INFO' : 'P1',
167
+ category: 'encryption',
168
+ evidence: { url, protocol: new URL(url).protocol },
169
+ recommendation: 'Enforce HTTPS with valid SSL certificate',
170
+ });
171
+
172
+ // Probe sensitive files
173
+ const sensitiveProbes = [
174
+ { path: '/.env', name: '.env exposed' },
175
+ { path: '/.git/config', name: 'Git config exposed' },
176
+ { path: '/wp-config.php', name: 'WP config exposed' },
177
+ { path: '/phpinfo.php', name: 'phpinfo exposed' },
178
+ { path: '/server-status', name: 'Apache server-status' },
179
+ { path: '/actuator', name: 'Spring actuator exposed' },
180
+ { path: '/graphql', name: 'GraphQL introspection' },
181
+ ];
182
+
183
+ const base = new URL(url).origin;
184
+ for (const probe of sensitiveProbes) {
185
+ try {
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), 5000);
188
+ const r = await fetch(`${base}${probe.path}`, { signal: controller.signal, redirect: 'manual' });
189
+ clearTimeout(timer);
190
+ const exposed = r.status === 200;
191
+ findings.push({
192
+ check : probe.name,
193
+ detail : exposed
194
+ ? `EXPOSED at ${base}${probe.path} — critical security risk`
195
+ : `Not exposed: ${probe.path}`,
196
+ pass : !exposed,
197
+ severity: exposed ? 'P0' : 'INFO',
198
+ category: 'information-disclosure',
199
+ evidence: { url: `${base}${probe.path}`, status: r.status },
200
+ recommendation: exposed ? `Immediately restrict access to ${probe.path}` : null,
201
+ });
202
+ } catch {}
203
+ }
204
+
205
+ return findings;
206
+ }
207
+ }
@@ -0,0 +1,248 @@
1
+ // Real SEO scanner — fetches and parses actual HTML
2
+ export class SEOScanner {
3
+ #session;
4
+
5
+ constructor(session) { this.#session = session; }
6
+
7
+ async scan(url) {
8
+ let html = '';
9
+ let statusCode = 0;
10
+ let headers = {};
11
+ let responseTime = 0;
12
+
13
+ const t0 = Date.now();
14
+ try {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), 12_000);
17
+ const res = await fetch(url, {
18
+ headers : { 'User-Agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
19
+ signal : controller.signal,
20
+ redirect: 'follow',
21
+ });
22
+ clearTimeout(timer);
23
+ statusCode = res.status;
24
+ responseTime = Date.now() - t0;
25
+ res.headers.forEach((v, k) => { headers[k] = v; });
26
+ html = await res.text();
27
+ } catch (err) {
28
+ return {
29
+ pass : false,
30
+ checks: [{ name: 'Page reachable', pass: false, detail: err.message, severity: 'P0' }],
31
+ url,
32
+ };
33
+ }
34
+
35
+ const checks = [];
36
+
37
+ // Real HTML parsing checks
38
+ const has = (pattern) => pattern.test(html);
39
+ const get = (pattern) => (html.match(pattern) || [])[1]?.trim() || null;
40
+
41
+ // Title
42
+ const title = get(/<title[^>]*>([^<]+)<\/title>/i);
43
+ checks.push({
44
+ name : 'Title tag',
45
+ pass : !!title,
46
+ detail : title ? `"${title.slice(0, 60)}"` : 'Missing <title> tag',
47
+ category: 'meta',
48
+ severity: 'P1',
49
+ data : { title, length: title?.length },
50
+ recommendation: 'Add a unique, descriptive title (50-60 chars)',
51
+ });
52
+
53
+ if (title) {
54
+ checks.push({
55
+ name : 'Title length optimal',
56
+ pass : title.length >= 30 && title.length <= 60,
57
+ detail : `Title is ${title.length} chars (optimal: 30-60)`,
58
+ category: 'meta',
59
+ severity: 'P2',
60
+ data : { length: title.length },
61
+ recommendation: 'Keep title between 30-60 characters',
62
+ });
63
+ }
64
+
65
+ // Meta description
66
+ const desc = get(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
67
+ || get(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
68
+ checks.push({
69
+ name : 'Meta description',
70
+ pass : !!desc,
71
+ detail : desc ? `"${desc.slice(0, 80)}..."` : 'Missing meta description',
72
+ category: 'meta',
73
+ severity: 'P1',
74
+ data : { description: desc, length: desc?.length },
75
+ recommendation: 'Add meta description (120-160 chars)',
76
+ });
77
+
78
+ if (desc) {
79
+ checks.push({
80
+ name : 'Meta description length',
81
+ pass : desc.length >= 120 && desc.length <= 160,
82
+ detail : `Description is ${desc.length} chars (optimal: 120-160)`,
83
+ category: 'meta',
84
+ severity: 'P2',
85
+ data : { length: desc.length },
86
+ recommendation: 'Keep description between 120-160 characters',
87
+ });
88
+ }
89
+
90
+ // H1
91
+ const h1Count = (html.match(/<h1[^>]*>/gi) || []).length;
92
+ checks.push({
93
+ name : 'H1 tag',
94
+ pass : h1Count === 1,
95
+ detail : h1Count === 0 ? 'No H1 found' : h1Count > 1 ? `${h1Count} H1 tags (should be 1)` : 'Exactly 1 H1',
96
+ category: 'structure',
97
+ severity: 'P1',
98
+ data : { count: h1Count },
99
+ recommendation: 'Use exactly one H1 per page',
100
+ });
101
+
102
+ // Viewport
103
+ const hasViewport = has(/<meta[^>]+name=["']viewport["']/i);
104
+ checks.push({
105
+ name : 'Viewport meta',
106
+ pass : hasViewport,
107
+ detail : hasViewport ? 'Viewport meta found' : 'Missing viewport meta — mobile SEO broken',
108
+ category: 'mobile',
109
+ severity: 'P1',
110
+ recommendation: 'Add <meta name="viewport" content="width=device-width,initial-scale=1">',
111
+ });
112
+
113
+ // Lang
114
+ const lang = get(/<html[^>]+lang=["']([^"']+)["']/i);
115
+ checks.push({
116
+ name : 'HTML lang attribute',
117
+ pass : !!lang,
118
+ detail : lang ? `lang="${lang}"` : 'Missing lang attribute on <html>',
119
+ category: 'accessibility-seo',
120
+ severity: 'P1',
121
+ data : { lang },
122
+ recommendation: 'Add lang attribute to <html> element',
123
+ });
124
+
125
+ // Canonical
126
+ const canonical = get(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
127
+ checks.push({
128
+ name : 'Canonical link',
129
+ pass : !!canonical,
130
+ detail : canonical ? `Canonical: ${canonical}` : 'Missing canonical link',
131
+ category: 'technical-seo',
132
+ severity: 'P2',
133
+ data : { canonical },
134
+ recommendation: 'Add <link rel="canonical"> to prevent duplicate content',
135
+ });
136
+
137
+ // OG tags
138
+ const ogTitle = has(/<meta[^>]+property=["']og:title["']/i);
139
+ const ogDesc = has(/<meta[^>]+property=["']og:description["']/i);
140
+ const ogImage = has(/<meta[^>]+property=["']og:image["']/i);
141
+ const ogUrl = has(/<meta[^>]+property=["']og:url["']/i);
142
+ checks.push({
143
+ name : 'Open Graph tags',
144
+ pass : ogTitle && ogDesc && ogImage,
145
+ detail : `og:title=${ogTitle} og:description=${ogDesc} og:image=${ogImage} og:url=${ogUrl}`,
146
+ category: 'social',
147
+ severity: 'P2',
148
+ data : { ogTitle, ogDesc, ogImage, ogUrl },
149
+ recommendation: 'Add og:title, og:description, og:image for social sharing',
150
+ });
151
+
152
+ // Twitter card
153
+ const twitterCard = has(/<meta[^>]+name=["']twitter:card["']/i);
154
+ checks.push({
155
+ name : 'Twitter Card',
156
+ pass : twitterCard,
157
+ detail : twitterCard ? 'Twitter card found' : 'No Twitter card meta',
158
+ category: 'social',
159
+ severity: 'P3',
160
+ recommendation: 'Add twitter:card meta for Twitter sharing',
161
+ });
162
+
163
+ // Images with alt
164
+ const imgTotal = (html.match(/<img[^>]*>/gi) || []).length;
165
+ const imgNoAlt = (html.match(/<img(?![^>]*\balt=)[^>]*>/gi) || []).length;
166
+ checks.push({
167
+ name : 'Images have alt text',
168
+ pass : imgNoAlt === 0,
169
+ detail : imgNoAlt === 0
170
+ ? `All ${imgTotal} images have alt text`
171
+ : `${imgNoAlt}/${imgTotal} images missing alt text`,
172
+ category: 'accessibility-seo',
173
+ severity: 'P2',
174
+ data : { total: imgTotal, missing: imgNoAlt },
175
+ recommendation: 'Add descriptive alt text to all images',
176
+ });
177
+
178
+ // Structured data
179
+ const hasStructuredData = has(/application\/ld\+json/i);
180
+ checks.push({
181
+ name : 'Structured data (JSON-LD)',
182
+ pass : hasStructuredData,
183
+ detail : hasStructuredData ? 'JSON-LD structured data found' : 'No structured data',
184
+ category: 'structured-data',
185
+ severity: 'P3',
186
+ recommendation: 'Add JSON-LD structured data for rich search results',
187
+ });
188
+
189
+ // robots.txt
190
+ try {
191
+ const base = new URL(url).origin;
192
+ const robotsRes = await fetch(`${base}/robots.txt`, { signal: AbortSignal.timeout(5000) });
193
+ checks.push({
194
+ name : 'robots.txt',
195
+ pass : robotsRes.status === 200,
196
+ detail : robotsRes.status === 200 ? 'robots.txt accessible' : `robots.txt returned ${robotsRes.status}`,
197
+ category: 'crawling',
198
+ severity: 'P1',
199
+ recommendation: 'Ensure robots.txt exists and is properly configured',
200
+ });
201
+ } catch {
202
+ checks.push({ name: 'robots.txt', pass: false, detail: 'robots.txt unreachable', category: 'crawling', severity: 'P2' });
203
+ }
204
+
205
+ // sitemap.xml
206
+ try {
207
+ const base = new URL(url).origin;
208
+ const sitemapRes = await fetch(`${base}/sitemap.xml`, { signal: AbortSignal.timeout(5000) });
209
+ const sitemapOk = sitemapRes.status === 200;
210
+ let sitemapValid = false;
211
+ if (sitemapOk) {
212
+ const sitemapText = await sitemapRes.text().catch(() => '');
213
+ sitemapValid = sitemapText.includes('<urlset') || sitemapText.includes('<sitemapindex');
214
+ }
215
+ checks.push({
216
+ name : 'sitemap.xml',
217
+ pass : sitemapOk && sitemapValid,
218
+ detail : sitemapOk
219
+ ? (sitemapValid ? 'Valid sitemap.xml found' : 'sitemap.xml present but invalid XML')
220
+ : `sitemap.xml returned ${sitemapRes.status}`,
221
+ category: 'crawling',
222
+ severity: 'P1',
223
+ recommendation: 'Add a valid sitemap.xml and submit to Google Search Console',
224
+ });
225
+ } catch {
226
+ checks.push({ name: 'sitemap.xml', pass: false, detail: 'sitemap.xml unreachable', category: 'crawling', severity: 'P2' });
227
+ }
228
+
229
+ // Page speed (response time as SEO factor)
230
+ checks.push({
231
+ name : 'Server response time',
232
+ pass : responseTime < 800,
233
+ detail : `TTFB: ${responseTime}ms (Google recommends < 800ms)`,
234
+ category: 'performance-seo',
235
+ severity: responseTime > 2000 ? 'P1' : 'P2',
236
+ data : { responseTime },
237
+ recommendation: 'Optimize TTFB — use CDN, caching, or faster hosting',
238
+ });
239
+
240
+ return {
241
+ pass : checks.every(c => c.pass || c.severity === 'P3'),
242
+ checks,
243
+ url,
244
+ statusCode,
245
+ responseTime,
246
+ };
247
+ }
248
+ }