1import { Actor, log } from 'apify';
2import { chromium, type Browser } from 'playwright';
3import AxeBuilder from '@axe-core/playwright';
4import type { AxeResults } from 'axe-core';
5
6
7
8interface ActorInput {
9 startUrls: { url: string }[];
10 checks?: string[];
11 timeout?: number;
12}
13
14type CheckType = 'accessibility' | 'security' | 'ssl' | 'brokenLinks' | 'sitemap' | 'robots' | 'structuredData' | 'meta' | 'performance';
15
16interface SecurityHeadersResult {
17 headers: Record<string, string>;
18 score: number;
19 issues: string[];
20 present: string[];
21 missing: string[];
22}
23
24interface SslResult {
25 valid: boolean;
26 daysRemaining: number;
27 issuer: string;
28 subject: string;
29 issues: string[];
30}
31
32interface BrokenLinkResult {
33 url: string;
34 statusCode: number;
35 statusText: string;
36 isBroken: boolean;
37 linkText: string;
38}
39
40interface SitemapResult {
41 exists: boolean;
42 urlCount: number;
43 urls: string[];
44 issues: string[];
45}
46
47interface RobotsResult {
48 exists: boolean;
49 hasSitemap: boolean;
50 sitemapUrl: string | null;
51 hasWildcard: boolean;
52 content: string;
53}
54
55interface StructuredDataResult {
56 count: number;
57 types: string[];
58 valid: number;
59 invalid: number;
60 schemas: { type: string; content: any }[];
61 issues: string[];
62}
63
64interface MetaResult {
65 title: string;
66 titleLength: number;
67 description: string;
68 descriptionLength: string;
69 canonical: string;
70 ogTitle: string;
71 ogDescription: string;
72 ogImage: string;
73 lang: string;
74 viewport: string;
75 charset: string;
76 issues: string[];
77}
78
79interface AccessibilityResult {
80 score: number;
81 grade: string;
82 violationCount: number;
83 critical: number;
84 serious: number;
85 moderate: number;
86 minor: number;
87 topViolations: string[];
88}
89
90interface PerformanceResult {
91 loadTime: number;
92 domReady: number;
93 resourceCount: number;
94 totalTransferSize: number;
95 htmlSize: number;
96 cssSize: number;
97 jsSize: number;
98 imageSize: number;
99 issues: string[];
100}
101
102interface PageHealthReport {
103 url: string;
104 scannedAt: string;
105 overallScore: number;
106 overallGrade: string;
107 checks: {
108 accessibility?: AccessibilityResult;
109 security?: SecurityHeadersResult;
110 ssl?: SslResult;
111 brokenLinks?: BrokenLinkResult[];
112 sitemap?: SitemapResult;
113 robots?: RobotsResult;
114 structuredData?: StructuredDataResult;
115 meta?: MetaResult;
116 performance?: PerformanceResult;
117 };
118 issueCount: number;
119 criticalIssues: string[];
120}
121
122interface ActorOutput {
123 totalPages: number;
124 overallScore: number;
125 overallGrade: string;
126 reports: PageHealthReport[];
127 totalIssues: number;
128 totalCritical: number;
129}
130
131
132
133async function checkSecurityHeaders(url: string): Promise<SecurityHeadersResult> {
134 try {
135 const resp = await fetch(url, { redirect: 'follow' });
136
137 const headers: Record<string, string> = {};
138 resp.headers.forEach((value, key) => { headers[key] = value; });
139
140 const checks: { name: string; header: string; required: boolean }[] = [
141 { name: 'Strict-Transport-Security', header: 'strict-transport-security', required: true },
142 { name: 'Content-Security-Policy', header: 'content-security-policy', required: true },
143 { name: 'X-Frame-Options', header: 'x-frame-options', required: true },
144 { name: 'X-Content-Type-Options', header: 'x-content-type-options', required: true },
145 { name: 'Referrer-Policy', header: 'referrer-policy', required: false },
146 { name: 'Permissions-Policy', header: 'permissions-policy', required: false },
147 { name: 'X-XSS-Protection', header: 'x-xss-protection', required: false },
148 ];
149
150 const present: string[] = [];
151 const missing: string[] = [];
152 const issues: string[] = [];
153 let score = 0;
154
155 for (const check of checks) {
156 if (headers[check.header]) {
157 present.push(check.name);
158 score += check.required ? 15 : 8;
159 } else {
160 missing.push(check.name);
161 if (check.required) {
162 issues.push(`Missing ${check.name} header`);
163 }
164 }
165 }
166
167 return { headers, score: Math.min(100, score), issues, present, missing };
168 } catch (error) {
169 return { headers: {}, score: 0, issues: [`Failed to check headers: ${(error as Error).message}`], present: [], missing: [] };
170 }
171}
172
173
174
175async function checkSsl(url: string): Promise<SslResult> {
176 try {
177 const u = new URL(url);
178 if (u.protocol !== 'https:') {
179 return { valid: false, daysRemaining: 0, issuer: '', subject: '', issues: ['Site not using HTTPS'] };
180 }
181
182
183 const resp = await fetch(url, { redirect: 'follow' });
184
185
186 const certHeader = resp.headers.get('x-cert-info') || '';
187
188
189
190 if (resp.ok || resp.status > 0) {
191 return {
192 valid: true,
193 daysRemaining: 30,
194 issuer: certHeader || 'Unknown',
195 subject: u.hostname,
196 issues: [],
197 };
198 }
199
200 return { valid: false, daysRemaining: 0, issuer: '', subject: u.hostname, issues: ['HTTPS connection failed'] };
201 } catch (error) {
202 return { valid: false, daysRemaining: 0, issuer: '', subject: '', issues: [`SSL check failed: ${(error as Error).message}`] };
203 }
204}
205
206
207
208async function checkBrokenLinks(browser: Browser, url: string, timeout: number): Promise<BrokenLinkResult[]> {
209 const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
210 const page = await context.newPage();
211
212 try {
213 await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
214
215
216 const links = await page.$$eval('a[href]', (anchors) => {
217 return anchors.map(a => ({
218 href: (a as HTMLAnchorElement).href,
219 text: (a as HTMLAnchorElement).textContent?.trim() || '',
220 })).filter(l => l.href && !l.href.startsWith('mailto:') && !l.href.startsWith('tel:') && !l.href.startsWith('javascript:'));
221 });
222
223
224 const seen = new Set<string>();
225 const uniqueLinks = links.filter(l => {
226 if (seen.has(l.href)) return false;
227 seen.add(l.href);
228 return true;
229 });
230
231 const results: BrokenLinkResult[] = [];
232
233
234 for (const link of uniqueLinks.slice(0, 50)) {
235 try {
236 const resp = await fetch(link.href, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(10000) });
237 const isBroken = resp.status >= 400;
238 results.push({
239 url: link.href,
240 statusCode: resp.status,
241 statusText: resp.statusText,
242 isBroken,
243 linkText: link.text,
244 });
245
246 if (isBroken) {
247 log.warning(` Broken link: ${link.href} (${resp.status})`);
248 }
249 } catch (error) {
250 results.push({
251 url: link.href,
252 statusCode: 0,
253 statusText: (error as Error).message,
254 isBroken: true,
255 linkText: link.text,
256 });
257 }
258 }
259
260 return results;
261 } finally {
262 await context.close();
263 }
264}
265
266
267
268async function checkSitemap(baseUrl: string): Promise<SitemapResult> {
269 const candidates = [
270 `${baseUrl}/sitemap.xml`,
271 `${baseUrl}/sitemap_index.xml`,
272 `${baseUrl}/sitemaps.xml`,
273 ];
274
275 for (const sitemapUrl of candidates) {
276 try {
277 const resp = await fetch(sitemapUrl, { redirect: 'follow' });
278 if (!resp.ok) continue;
279
280 const xml = await resp.text();
281
282
283 const urlMatches = xml.match(/<loc>([^<]+)<\/loc>/g) || [];
284 const urls = urlMatches.map(m => m.replace(/<\/?loc>/g, '').trim());
285
286
287 const subMatches = xml.match(/<sitemap>\s*<loc>([^<]+)<\/loc>/g) || [];
288 if (subMatches.length > 0 && urls.length === 0) {
289
290 const subUrls = subMatches.map(m => m.match(/<loc>([^<]+)<\/loc>/)?.[1] || '');
291
292 for (const subUrl of subUrls.slice(0, 3)) {
293 try {
294 const subResp = await fetch(subUrl, { redirect: 'follow' });
295 if (subResp.ok) {
296 const subXml = await subResp.text();
297 const subUrlMatches = subXml.match(/<loc>([^<]+)<\/loc>/g) || [];
298 urls.push(...subUrlMatches.map(m => m.replace(/<\/?loc>/g, '').trim()));
299 }
300 } catch { }
301 }
302 }
303
304 const issues: string[] = [];
305 if (urls.length === 0) issues.push('Sitemap found but contains no URLs');
306 if (urls.length > 50000) issues.push('Sitemap exceeds 50,000 URL limit');
307
308 return {
309 exists: true,
310 urlCount: urls.length,
311 urls: urls.slice(0, 100),
312 issues,
313 };
314 } catch {
315 continue;
316 }
317 }
318
319 return { exists: false, urlCount: 0, urls: [], issues: ['No sitemap.xml found'] };
320}
321
322
323
324async function checkRobots(baseUrl: string): Promise<RobotsResult> {
325 try {
326 const resp = await fetch(`${baseUrl}/robots.txt`, { redirect: 'follow' });
327 if (!resp.ok) {
328 return { exists: false, hasSitemap: false, sitemapUrl: null, hasWildcard: false, content: '' };
329 }
330
331 const content = await resp.text();
332 const sitemapLine = content.split('\n').find(l => l.toLowerCase().startsWith('sitemap:'));
333 const sitemapUrl = sitemapLine ? sitemapLine.substring(8).trim() : null;
334 const hasWildcard = content.toLowerCase().includes('user-agent: *');
335
336 return { exists: true, hasSitemap: !!sitemapUrl, sitemapUrl, hasWildcard, content };
337 } catch {
338 return { exists: false, hasSitemap: false, sitemapUrl: null, hasWildcard: false, content: '' };
339 }
340}
341
342
343
344async function checkStructuredData(browser: Browser, url: string, timeout: number): Promise<StructuredDataResult> {
345 const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
346 const page = await context.newPage();
347
348 try {
349 await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
350
351 const schemas = await page.evaluate(() => {
352 const scripts = document.querySelectorAll('script[type="application/ld+json"]');
353 const results: { type: string; content: any }[] = [];
354
355 scripts.forEach(script => {
356 try {
357 const data = JSON.parse(script.textContent || '{}');
358 const items = Array.isArray(data) ? data : [data];
359 for (const item of items) {
360 const type = item['@type'] || (item['@graph'] ? 'Graph' : 'Unknown');
361 if (typeof type === 'string') {
362 results.push({ type, content: item });
363 } else if (Array.isArray(type)) {
364 for (const t of type) {
365 results.push({ type: t, content: item });
366 }
367 }
368 }
369 } catch { }
370 });
371
372 return results;
373 });
374
375 const types = [...new Set(schemas.map(s => s.type))];
376 const issues: string[] = [];
377
378 const recommended = ['WebSite', 'Organization', 'BreadcrumbList', 'Article', 'FAQPage', 'Product', 'LocalBusiness', 'HowTo'];
379 const missing = recommended.filter(r => !types.includes(r));
380 if (missing.length > 0) {
381 issues.push(`Missing recommended schema types: ${missing.join(', ')}`);
382 }
383
384 return {
385 count: schemas.length,
386 types,
387 valid: schemas.length,
388 invalid: 0,
389 schemas: schemas.slice(0, 10),
390 issues,
391 };
392 } finally {
393 await context.close();
394 }
395}
396
397
398
399async function checkMeta(browser: Browser, url: string, timeout: number): Promise<MetaResult> {
400 const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
401 const page = await context.newPage();
402
403 try {
404 await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
405
406 const meta = await page.evaluate(() => {
407 const get = (sel: string) => {
408 const el = document.querySelector(sel);
409 return el?.getAttribute('content') || '';
410 };
411
412 return {
413 title: document.title || '',
414 description: get('meta[name="description"]') || get('meta[property="og:description"]'),
415 canonical: (document.querySelector('link[rel="canonical"]') as HTMLLinkElement)?.href || '',
416 ogTitle: get('meta[property="og:title"]'),
417 ogDescription: get('meta[property="og:description"]'),
418 ogImage: get('meta[property="og:image"]'),
419 lang: document.documentElement.lang || '',
420 viewport: get('meta[name="viewport"]'),
421 charset: document.characterSet || '',
422 };
423 });
424
425 const issues: string[] = [];
426 if (!meta.title) issues.push('Missing <title> tag');
427 if (meta.title && meta.title.length > 60) issues.push(`Title too long (${meta.title.length} chars, max 60)`);
428 if (meta.title && meta.title.length < 30) issues.push(`Title too short (${meta.title.length} chars, min 30)`);
429 if (!meta.description) issues.push('Missing meta description');
430 if (meta.description && meta.description.length > 160) issues.push(`Meta description too long (${meta.description.length} chars, max 160)`);
431 if (!meta.canonical) issues.push('Missing canonical URL');
432 if (!meta.ogTitle) issues.push('Missing Open Graph title (og:title)');
433 if (!meta.ogDescription) issues.push('Missing Open Graph description (og:description)');
434 if (!meta.ogImage) issues.push('Missing Open Graph image (og:image)');
435 if (!meta.lang) issues.push('Missing HTML lang attribute');
436 if (!meta.viewport) issues.push('Missing viewport meta tag');
437
438 return {
439 ...meta,
440 titleLength: meta.title.length,
441 descriptionLength: meta.description.length.toString(),
442 issues,
443 };
444 } finally {
445 await context.close();
446 }
447}
448
449
450
451async function checkAccessibility(browser: Browser, url: string, timeout: number): Promise<AccessibilityResult> {
452 const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
453 const page = await context.newPage();
454
455 try {
456 await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
457 await page.waitForTimeout(500);
458
459 const axeTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
460 const axeBuilder = new AxeBuilder({ page: page as any })
461 .withTags(axeTags)
462 .options({
463 runOnly: { type: 'tag', values: axeTags },
464 resultTypes: ['violations'],
465 });
466
467 const result: AxeResults = await axeBuilder.analyze();
468
469 const violations = result.violations;
470 const critical = violations.filter(v => v.impact === 'critical').length;
471 const serious = violations.filter(v => v.impact === 'serious').length;
472 const moderate = violations.filter(v => v.impact === 'moderate').length;
473 const minor = violations.filter(v => v.impact === 'minor').length;
474
475
476 const weights: Record<string, number> = { critical: 25, serious: 10, moderate: 5, minor: 1 };
477 let penalty = 0;
478 for (const v of violations) {
479 penalty += weights[v.impact || 'minor'] || 1;
480 }
481 const score = Math.max(0, Math.min(100, 100 - penalty));
482
483 const grade = score >= 95 ? 'A' : score >= 85 ? 'B' : score >= 70 ? 'C' : score >= 50 ? 'D' : 'F';
484
485 const topViolations = violations.slice(0, 10).map(v => `${v.id}: ${v.help}`);
486
487 return {
488 score,
489 grade,
490 violationCount: violations.length,
491 critical,
492 serious,
493 moderate,
494 minor,
495 topViolations,
496 };
497 } finally {
498 await context.close();
499 }
500}
501
502
503
504async function checkPerformance(browser: Browser, url: string, timeout: number): Promise<PerformanceResult> {
505 const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
506 const page = await context.newPage();
507
508 try {
509 const start = Date.now();
510 await page.goto(url, { timeout, waitUntil: 'load' });
511 const loadTime = Date.now() - start;
512
513 const metrics = await page.evaluate(() => {
514 const entries = performance.getEntriesByType('resource');
515 let total = 0;
516 let html = 0;
517 let css = 0;
518 let js = 0;
519 let img = 0;
520
521 for (const entry of entries) {
522 const res = entry as PerformanceResourceTiming;
523 total += res.transferSize || 0;
524 if (res.initiatorType === 'html' || res.name.endsWith('.html')) html += res.transferSize || 0;
525 else if (res.name.endsWith('.css')) css += res.transferSize || 0;
526 else if (res.name.endsWith('.js') || res.name.endsWith('.mjs')) js += res.transferSize || 0;
527 else if (res.name.match(/\.(png|jpg|jpeg|gif|svg|webp|avif)/)) img += res.transferSize || 0;
528 }
529
530 return {
531 resourceCount: entries.length,
532 totalTransferSize: total,
533 htmlSize: html,
534 cssSize: css,
535 jsSize: js,
536 imageSize: img,
537 domReady: (performance.timing as any)?.domContentLoadedEventEnd - (performance.timing as any)?.navigationStart || 0,
538 };
539 });
540
541 const issues: string[] = [];
542 if (loadTime > 3000) issues.push(`Slow page load: ${Math.round(loadTime)}ms (target: <3000ms)`);
543 if (metrics.jsSize > 500000) issues.push(`Large JS bundle: ${(metrics.jsSize / 1024).toFixed(0)}KB (target: <500KB)`);
544 if (metrics.totalTransferSize > 2000000) issues.push(`Heavy page: ${(metrics.totalTransferSize / 1024 / 1024).toFixed(1)}MB total transfer`);
545 if (metrics.resourceCount > 100) issues.push(`Too many requests: ${metrics.resourceCount} (target: <100)`);
546
547 return {
548 loadTime,
549 domReady: metrics.domReady,
550 resourceCount: metrics.resourceCount,
551 totalTransferSize: metrics.totalTransferSize,
552 htmlSize: metrics.htmlSize,
553 cssSize: metrics.cssSize,
554 jsSize: metrics.jsSize,
555 imageSize: metrics.imageSize,
556 issues,
557 };
558 } finally {
559 await context.close();
560 }
561}
562
563
564
565async function main() {
566 await Actor.init();
567
568 const input = (await Actor.getInput()) as ActorInput;
569
570 if (!input?.startUrls || input.startUrls.length === 0) {
571 log.error('No startUrls provided');
572 await Actor.exit('No startUrls provided', { exitCode: 1 });
573 return;
574 }
575
576 const checks = (input.checks || ['accessibility', 'security', 'ssl', 'brokenLinks', 'sitemap', 'robots', 'structuredData', 'meta', 'performance']) as CheckType[];
577 const timeout = input.timeout || 30000;
578
579 log.info(`Starting health check for ${input.startUrls.length} URL(s)`);
580 log.info(`Checks: ${checks.join(', ')}`);
581
582 const browser = await chromium.launch({
583 headless: true,
584 args: ['--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox'],
585 });
586
587 const reports: PageHealthReport[] = [];
588
589 try {
590 for (const { url } of input.startUrls) {
591 log.info(`\n=== Checking: ${url} ===`);
592 const baseUrl = url.replace(/\/$/, '').replace(/^(https?:\/\/[^\/]+).*$/, '$1');
593
594 const report: PageHealthReport = {
595 url,
596 scannedAt: new Date().toISOString(),
597 overallScore: 0,
598 overallGrade: '',
599 checks: {},
600 issueCount: 0,
601 criticalIssues: [],
602 };
603
604 const scores: number[] = [];
605
606
607 if (checks.includes('security')) {
608 log.info(' Running security headers check...');
609 report.checks.security = await checkSecurityHeaders(url);
610 scores.push(report.checks.security.score);
611 report.checks.security.issues.forEach(i => report.criticalIssues.push(`Security: ${i}`));
612 }
613
614 if (checks.includes('ssl')) {
615 log.info(' Running SSL/TLS check...');
616 report.checks.ssl = await checkSsl(url);
617 if (report.checks.ssl.valid) scores.push(100);
618 else scores.push(0);
619 report.checks.ssl.issues.forEach(i => report.criticalIssues.push(`SSL: ${i}`));
620 }
621
622 if (checks.includes('accessibility')) {
623 log.info(' Running accessibility check (axe-core)...');
624 report.checks.accessibility = await checkAccessibility(browser, url, timeout);
625 scores.push(report.checks.accessibility.score);
626 if (report.checks.accessibility.critical > 0) {
627 report.criticalIssues.push(`Accessibility: ${report.checks.accessibility.critical} critical violations`);
628 }
629 }
630
631 if (checks.includes('meta')) {
632 log.info(' Running meta tags check...');
633 report.checks.meta = await checkMeta(browser, url, timeout);
634 scores.push(Math.max(0, 100 - report.checks.meta.issues.length * 10));
635 report.checks.meta.issues.forEach(i => report.criticalIssues.push(`Meta: ${i}`));
636 }
637
638 if (checks.includes('structuredData')) {
639 log.info(' Running structured data check...');
640 report.checks.structuredData = await checkStructuredData(browser, url, timeout);
641 scores.push(Math.min(100, report.checks.structuredData.count * 20));
642 report.checks.structuredData.issues.forEach(i => report.criticalIssues.push(`Schema: ${i}`));
643 }
644
645 if (checks.includes('performance')) {
646 log.info(' Running performance check...');
647 report.checks.performance = await checkPerformance(browser, url, timeout);
648 scores.push(Math.max(0, 100 - report.checks.performance.issues.length * 20));
649 report.checks.performance.issues.forEach(i => report.criticalIssues.push(`Performance: ${i}`));
650 }
651
652 if (checks.includes('brokenLinks')) {
653 log.info(' Running broken links check...');
654 report.checks.brokenLinks = await checkBrokenLinks(browser, url, timeout);
655 const brokenCount = report.checks.brokenLinks.filter(l => l.isBroken).length;
656 scores.push(Math.max(0, 100 - brokenCount * 10));
657 if (brokenCount > 0) {
658 report.criticalIssues.push(`Broken Links: ${brokenCount} broken links found`);
659 }
660 }
661
662 if (checks.includes('sitemap')) {
663 log.info(' Running sitemap check...');
664 report.checks.sitemap = await checkSitemap(baseUrl);
665 scores.push(report.checks.sitemap.exists ? 80 + Math.min(20, report.checks.sitemap.urlCount / 100) : 0);
666 report.checks.sitemap.issues.forEach(i => report.criticalIssues.push(`Sitemap: ${i}`));
667 }
668
669 if (checks.includes('robots')) {
670 log.info(' Running robots.txt check...');
671 report.checks.robots = await checkRobots(baseUrl);
672 scores.push(report.checks.robots.exists ? 80 : 20);
673 if (!report.checks.robots.exists) {
674 report.criticalIssues.push('Robots: Missing robots.txt');
675 }
676 }
677
678
679 report.overallScore = scores.length > 0
680 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
681 : 0;
682 report.overallGrade = report.overallScore >= 90 ? 'A' : report.overallScore >= 75 ? 'B' : report.overallScore >= 60 ? 'C' : report.overallScore >= 40 ? 'D' : 'F';
683 report.issueCount = report.criticalIssues.length;
684
685 log.info(` Score: ${report.overallScore}/100 (Grade ${report.overallGrade})`);
686 log.info(` Issues: ${report.issueCount} (${report.criticalIssues.length} critical)`);
687
688 await Actor.pushData({
689 url,
690 overallScore: report.overallScore,
691 overallGrade: report.overallGrade,
692 issueCount: report.issueCount,
693 accessibilityScore: report.checks.accessibility?.score,
694 accessibilityViolations: report.checks.accessibility?.violationCount,
695 securityScore: report.checks.security?.score,
696 securityMissing: report.checks.security?.missing,
697 sslValid: report.checks.ssl?.valid,
698 brokenLinkCount: report.checks.brokenLinks?.filter(l => l.isBroken).length,
699 sitemapExists: report.checks.sitemap?.exists,
700 robotsExists: report.checks.robots?.exists,
701 schemaCount: report.checks.structuredData?.count,
702 schemaTypes: report.checks.structuredData?.types,
703 metaIssues: report.checks.meta?.issues,
704 performanceLoadMs: report.checks.performance?.loadTime,
705 performanceIssues: report.checks.performance?.issues,
706 criticalIssues: report.criticalIssues,
707 });
708
709
710 await Actor.charge({ eventName: 'HEALTH_CHECK', count: 1 });
711
712 reports.push(report);
713 }
714 } finally {
715 await browser.close();
716 }
717
718 const overallScores = reports.map(r => r.overallScore);
719 const output: ActorOutput = {
720 totalPages: reports.length,
721 overallScore: overallScores.length > 0 ? Math.round(overallScores.reduce((a, b) => a + b, 0) / overallScores.length) : 0,
722 overallGrade: '',
723 reports,
724 totalIssues: reports.reduce((s, r) => s + r.issueCount, 0),
725 totalCritical: reports.reduce((s, r) => s + r.criticalIssues.length, 0),
726 };
727 output.overallGrade = output.overallScore >= 90 ? 'A' : output.overallScore >= 75 ? 'B' : output.overallScore >= 60 ? 'C' : output.overallScore >= 40 ? 'D' : 'F';
728
729 const kvStore = await Actor.openKeyValueStore();
730 await kvStore.setValue('OUTPUT', output);
731
732 log.info(`\nHealth check complete: ${output.totalPages} pages, score ${output.overallScore}/100 (${output.overallGrade}), ${output.totalIssues} issues, ${output.totalCritical} critical`);
733
734 await Actor.exit();
735}
736
737main().catch(async (error) => {
738 console.error('Fatal error:', error);
739 await Actor.exit('Fatal error', { exitCode: 1 });
740});