create-backlist 10.0.2 → 10.0.4
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/bin/qa.js +138 -183
- package/package.json +6 -1
- package/src/qa/analyzers/accessibility.js +81 -0
- package/src/qa/analyzers/api.js +125 -0
- package/src/qa/analyzers/performance.js +137 -0
- package/src/qa/analyzers/security.js +207 -0
- package/src/qa/analyzers/seo.js +248 -0
- package/src/qa/browser/crawler.js +223 -0
- package/src/qa/browser/interactions.js +317 -0
- package/src/qa/browser/screenshot.js +34 -0
- package/src/qa/qa-engine.js +748 -1286
- package/src/qa/reporters/html.js +623 -0
- package/src/qa/reporters/json.js +49 -0
- package/src/qa/reporters/terminal.js +184 -0
- package/src/qa/utils/ai-classifier.js +98 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Real performance profiler — Playwright + Performance API
|
|
2
|
+
export class PerformanceProfiler {
|
|
3
|
+
#session;
|
|
4
|
+
|
|
5
|
+
constructor(session) { this.#session = session; }
|
|
6
|
+
|
|
7
|
+
async profile(url) {
|
|
8
|
+
let playwright;
|
|
9
|
+
try { playwright = await import('playwright'); }
|
|
10
|
+
catch { return this.#empty(); }
|
|
11
|
+
|
|
12
|
+
const browser = await playwright.chromium.launch({
|
|
13
|
+
headless: true,
|
|
14
|
+
args : ['--no-sandbox'],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const context = await browser.newContext({
|
|
18
|
+
ignoreHTTPSErrors: true,
|
|
19
|
+
});
|
|
20
|
+
const page = await context.newPage();
|
|
21
|
+
|
|
22
|
+
const slowResources = [];
|
|
23
|
+
const resourceTimings = [];
|
|
24
|
+
|
|
25
|
+
page.on('response', async res => {
|
|
26
|
+
const timing = res.timing();
|
|
27
|
+
if (timing && timing.responseEnd > 0) {
|
|
28
|
+
const duration = timing.responseEnd - timing.requestStart;
|
|
29
|
+
const entry = {
|
|
30
|
+
url : res.url(),
|
|
31
|
+
type : res.request().resourceType(),
|
|
32
|
+
status : res.status(),
|
|
33
|
+
duration: Math.round(duration),
|
|
34
|
+
size : 0,
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
const body = await res.body().catch(() => null);
|
|
38
|
+
entry.size = body?.length || 0;
|
|
39
|
+
} catch {}
|
|
40
|
+
resourceTimings.push(entry);
|
|
41
|
+
if (duration > 2000) slowResources.push(entry);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
47
|
+
|
|
48
|
+
// Real Web Vitals via Performance API
|
|
49
|
+
const metrics = await page.evaluate(() => {
|
|
50
|
+
return new Promise(resolve => {
|
|
51
|
+
const result = {
|
|
52
|
+
lcp : null, fcp : null, cls : null,
|
|
53
|
+
fid : null, ttfb: null, tti : null, tbt: null,
|
|
54
|
+
memoryUsed: null, domNodes: document.querySelectorAll('*').length,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// FCP + LCP
|
|
58
|
+
const po = new PerformanceObserver(list => {
|
|
59
|
+
for (const entry of list.getEntries()) {
|
|
60
|
+
if (entry.entryType === 'paint') {
|
|
61
|
+
if (entry.name === 'first-contentful-paint') result.fcp = Math.round(entry.startTime);
|
|
62
|
+
}
|
|
63
|
+
if (entry.entryType === 'largest-contentful-paint') {
|
|
64
|
+
result.lcp = Math.round(entry.startTime);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
try { po.observe({ entryTypes: ['paint','largest-contentful-paint'] }); } catch {}
|
|
69
|
+
|
|
70
|
+
// CLS
|
|
71
|
+
let clsValue = 0;
|
|
72
|
+
const clsPo = new PerformanceObserver(list => {
|
|
73
|
+
for (const entry of list.getEntries()) {
|
|
74
|
+
if (!entry.hadRecentInput) clsValue += entry.value;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
try { clsPo.observe({ entryTypes: ['layout-shift'] }); } catch {}
|
|
78
|
+
|
|
79
|
+
// TTFB
|
|
80
|
+
const nav = performance.getEntriesByType('navigation')[0];
|
|
81
|
+
if (nav) result.ttfb = Math.round(nav.responseStart);
|
|
82
|
+
|
|
83
|
+
// Memory
|
|
84
|
+
if (performance.memory) {
|
|
85
|
+
result.memoryUsed = performance.memory.usedJSHeapSize;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
result.cls = parseFloat(clsValue.toFixed(4));
|
|
90
|
+
po.disconnect();
|
|
91
|
+
clsPo.disconnect();
|
|
92
|
+
resolve(result);
|
|
93
|
+
}, 5000);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// CPU timing via long task detection
|
|
98
|
+
const longTasks = await page.evaluate(() => {
|
|
99
|
+
const tasks = [];
|
|
100
|
+
const po = new PerformanceObserver(list => {
|
|
101
|
+
tasks.push(...list.getEntries().map(e => ({
|
|
102
|
+
duration : Math.round(e.duration),
|
|
103
|
+
startTime: Math.round(e.startTime),
|
|
104
|
+
})));
|
|
105
|
+
});
|
|
106
|
+
try { po.observe({ entryTypes: ['longtask'] }); } catch {}
|
|
107
|
+
return new Promise(r => setTimeout(() => { po.disconnect(); r(tasks); }, 3000));
|
|
108
|
+
}).catch(() => []);
|
|
109
|
+
|
|
110
|
+
const tbt = longTasks.reduce((sum, t) => sum + Math.max(t.duration - 50, 0), 0);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...metrics,
|
|
114
|
+
tbt : Math.round(tbt),
|
|
115
|
+
slowResources,
|
|
116
|
+
resourceTimings: resourceTimings.slice(0, 50),
|
|
117
|
+
longTasks,
|
|
118
|
+
url,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { ...this.#empty(), error: err.message, url };
|
|
123
|
+
} finally {
|
|
124
|
+
await page.close().catch(() => {});
|
|
125
|
+
await context.close().catch(() => {});
|
|
126
|
+
await browser.close().catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#empty() {
|
|
131
|
+
return {
|
|
132
|
+
lcp: null, fcp: null, cls: null, fid: null,
|
|
133
|
+
ttfb: null, tti: null, tbt: null,
|
|
134
|
+
slowResources: [], resourceTimings: [], longTasks: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|