pagesight 0.16.0 → 0.18.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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +26 -97
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +59 -20
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -852
  70. package/src/tools/speed.ts +0 -956
package/src/tools/page.ts DELETED
@@ -1,1241 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { z } from "zod";
3
-
4
- // --- Types ---
5
-
6
- interface MetaTag {
7
- name?: string;
8
- property?: string;
9
- content: string;
10
- }
11
-
12
- interface ParsedHead {
13
- title: string | null;
14
- charset: string | null;
15
- canonical: string | null;
16
- meta: MetaTag[];
17
- hreflang: Array<{ lang: string; href: string }>;
18
- jsonLd: unknown[];
19
- }
20
-
21
- interface SchemaRule {
22
- required: string[];
23
- recommended: string[];
24
- imageFields: string[];
25
- nestedRequired?: Record<string, string[]>;
26
- }
27
-
28
- interface ValidationIssue {
29
- type: string;
30
- level: "required" | "recommended";
31
- field: string;
32
- }
33
-
34
- interface RedirectHop {
35
- url: string;
36
- status: number;
37
- }
38
-
39
- interface ImageCheck {
40
- url: string;
41
- tag: string;
42
- status: number | null;
43
- contentType: string | null;
44
- contentLength: number | null;
45
- error: string | null;
46
- }
47
-
48
- interface LinkResult {
49
- href: string;
50
- status: number | null;
51
- redirectChain: Array<{ url: string; status: number }>;
52
- finalUrl: string | null;
53
- error: string | null;
54
- }
55
-
56
- // --- Meta tags helpers ---
57
-
58
- function parseHead(html: string): ParsedHead {
59
- // Extract <head> content (case-insensitive, handles attributes on <head>)
60
- const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
61
- const head = headMatch?.[1] ?? html;
62
-
63
- // Title
64
- const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
65
- const title = titleMatch ? titleMatch[1].trim() : null;
66
-
67
- // Charset
68
- const charsetMatch = head.match(/<meta\s+charset=["']([^"']+)["']/i) ?? head.match(/charset=["']?([^"'\s;>]+)/i);
69
- const charset = charsetMatch ? charsetMatch[1] : null;
70
-
71
- // Canonical
72
- const canonicalMatch = head.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i);
73
- const canonical = canonicalMatch ? canonicalMatch[1] : null;
74
-
75
- // Meta tags (name=... content=... and property=... content=...)
76
- const meta: MetaTag[] = [];
77
- for (const m of head.matchAll(/<meta\s+([^>]*?)>/gi)) {
78
- const attrs = m[1];
79
- const nameMatch = attrs.match(/name=["']([^"']+)["']/i);
80
- const propMatch = attrs.match(/property=["']([^"']+)["']/i);
81
- const contentMatch = attrs.match(/content=["']([^"']*?)["']/i);
82
- if (contentMatch && (nameMatch || propMatch)) {
83
- meta.push({
84
- name: nameMatch?.[1],
85
- property: propMatch?.[1],
86
- content: contentMatch[1],
87
- });
88
- }
89
- }
90
-
91
- // Hreflang
92
- const hreflang: Array<{ lang: string; href: string }> = [];
93
- const hreflangRegex =
94
- /<link[^>]*rel=["']alternate["'][^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
95
- const hreflangRegex2 =
96
- /<link[^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*>/gi;
97
- const hreflangRegex3 =
98
- /<link[^>]*href=["']([^"']+)["'][^>]*hreflang=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*>/gi;
99
- for (const rx of [hreflangRegex, hreflangRegex2]) {
100
- for (const hm of head.matchAll(rx)) {
101
- hreflang.push({ lang: hm[1], href: hm[2] });
102
- }
103
- }
104
- for (const hm of head.matchAll(hreflangRegex3)) {
105
- hreflang.push({ lang: hm[2], href: hm[1] });
106
- }
107
-
108
- // Dedup hreflang entries
109
- const seen = new Set<string>();
110
- const dedupedHreflang = hreflang.filter((h) => {
111
- const key = `${h.lang}:${h.href}`;
112
- if (seen.has(key)) return false;
113
- seen.add(key);
114
- return true;
115
- });
116
-
117
- // JSON-LD
118
- const jsonLd: unknown[] = [];
119
- for (const ld of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
120
- try {
121
- jsonLd.push(JSON.parse(ld[1]));
122
- } catch {
123
- // malformed JSON-LD, skip
124
- }
125
- }
126
-
127
- return { title, charset, canonical, meta, hreflang: dedupedHreflang, jsonLd };
128
- }
129
-
130
- function getMeta(meta: MetaTag[], key: string): string | null {
131
- const tag = meta.find(
132
- (m) => m.name?.toLowerCase() === key.toLowerCase() || m.property?.toLowerCase() === key.toLowerCase(),
133
- );
134
- return tag?.content ?? null;
135
- }
136
-
137
- function getAllMeta(meta: MetaTag[], prefix: string): Array<{ key: string; value: string }> {
138
- const results: Array<{ key: string; value: string }> = [];
139
- for (const tag of meta) {
140
- const key = tag.property ?? tag.name ?? "";
141
- if (key.toLowerCase().startsWith(prefix.toLowerCase())) {
142
- results.push({ key, value: tag.content });
143
- }
144
- }
145
- return results;
146
- }
147
-
148
- function formatJsonLd(data: unknown, indent = 0): string[] {
149
- const lines: string[] = [];
150
- const pad = " ".repeat(indent);
151
-
152
- if (Array.isArray(data)) {
153
- for (const item of data) {
154
- lines.push(...formatJsonLd(item, indent));
155
- }
156
- return lines;
157
- }
158
-
159
- if (data && typeof data === "object") {
160
- const obj = data as Record<string, unknown>;
161
- const type = obj["@type"];
162
- if (type) lines.push(`${pad}@type: ${Array.isArray(type) ? type.join(", ") : type}`);
163
-
164
- for (const [key, val] of Object.entries(obj)) {
165
- if (key.startsWith("@") && key !== "@type") continue;
166
- if (key === "@type") continue;
167
- if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
168
- const str = String(val);
169
- lines.push(`${pad}${key}: ${str.length > 100 ? `${str.slice(0, 100)}...` : str}`);
170
- } else if (Array.isArray(val)) {
171
- lines.push(`${pad}${key}: [${val.length} items]`);
172
- } else if (val && typeof val === "object") {
173
- const nested = val as Record<string, unknown>;
174
- if (nested["@type"]) {
175
- lines.push(`${pad}${key}:`);
176
- lines.push(...formatJsonLd(nested, indent + 1));
177
- }
178
- }
179
- }
180
- }
181
-
182
- return lines;
183
- }
184
-
185
- // --- Structured data validation (per Google Rich Results docs) ---
186
-
187
- // Required/recommended fields sourced from Google's Rich Results documentation
188
- // https://developers.google.com/search/docs/appearance/structured-data
189
- const SCHEMA_RULES: Record<string, SchemaRule> = {
190
- WebSite: {
191
- required: ["name", "url"],
192
- recommended: ["potentialAction"],
193
- imageFields: [],
194
- },
195
- Organization: {
196
- required: ["name", "url"],
197
- recommended: ["logo", "sameAs", "contactPoint"],
198
- imageFields: ["logo"],
199
- },
200
- Article: {
201
- required: ["headline", "image", "datePublished", "author"],
202
- recommended: ["dateModified", "publisher"],
203
- imageFields: ["image"],
204
- },
205
- NewsArticle: {
206
- required: ["headline", "image", "datePublished", "author"],
207
- recommended: ["dateModified", "publisher"],
208
- imageFields: ["image"],
209
- },
210
- Product: {
211
- required: ["name", "image"],
212
- recommended: ["description", "offers", "brand", "review", "aggregateRating"],
213
- imageFields: ["image"],
214
- nestedRequired: { offers: ["price", "priceCurrency"] },
215
- },
216
- LocalBusiness: {
217
- required: ["name", "address"],
218
- recommended: ["telephone", "openingHoursSpecification", "image", "url"],
219
- imageFields: ["image"],
220
- },
221
- FAQPage: {
222
- required: ["mainEntity"],
223
- recommended: [],
224
- imageFields: [],
225
- },
226
- BreadcrumbList: {
227
- required: ["itemListElement"],
228
- recommended: [],
229
- imageFields: [],
230
- },
231
- Event: {
232
- required: ["name", "startDate", "location"],
233
- recommended: ["endDate", "image", "description", "offers", "organizer"],
234
- imageFields: ["image"],
235
- },
236
- Recipe: {
237
- required: ["name", "image"],
238
- recommended: ["author", "datePublished", "description", "recipeIngredient", "recipeInstructions"],
239
- imageFields: ["image"],
240
- },
241
- VideoObject: {
242
- required: ["name", "description", "thumbnailUrl", "uploadDate"],
243
- recommended: ["duration", "contentUrl", "embedUrl"],
244
- imageFields: ["thumbnailUrl"],
245
- },
246
- SoftwareApplication: {
247
- required: ["name", "offers"],
248
- recommended: ["applicationCategory", "operatingSystem", "review", "aggregateRating"],
249
- imageFields: ["image"],
250
- nestedRequired: { offers: ["price", "priceCurrency"] },
251
- },
252
- Dataset: {
253
- required: ["name", "description"],
254
- recommended: ["distribution", "creator", "license"],
255
- imageFields: [],
256
- nestedRequired: { distribution: ["contentUrl", "encodingFormat"] },
257
- },
258
- TechArticle: {
259
- required: ["headline", "image", "datePublished", "author"],
260
- recommended: ["dateModified", "publisher"],
261
- imageFields: ["image"],
262
- },
263
- BlogPosting: {
264
- required: ["headline", "image", "datePublished", "author"],
265
- recommended: ["dateModified", "publisher"],
266
- imageFields: ["image"],
267
- },
268
- HowTo: {
269
- required: ["name", "step"],
270
- recommended: ["image", "totalTime", "estimatedCost"],
271
- imageFields: ["image"],
272
- },
273
- Course: {
274
- required: ["name", "description"],
275
- recommended: ["provider", "offers"],
276
- imageFields: [],
277
- },
278
- JobPosting: {
279
- required: ["title", "description", "datePosted", "hiringOrganization"],
280
- recommended: ["employmentType", "jobLocation", "baseSalary", "validThrough"],
281
- imageFields: [],
282
- },
283
- };
284
-
285
- function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
286
- const val = obj[field];
287
- if (val !== undefined && val !== null && val !== "") return val;
288
- return undefined;
289
- }
290
-
291
- function validateJsonLd(blocks: unknown[]): {
292
- issues: ValidationIssue[];
293
- imageUrls: string[];
294
- validatedTypes: Set<string>;
295
- } {
296
- const issues: ValidationIssue[] = [];
297
- const imageUrls: string[] = [];
298
- const validatedTypes = new Set<string>();
299
-
300
- function validateBlock(data: unknown) {
301
- if (Array.isArray(data)) {
302
- for (const item of data) validateBlock(item);
303
- return;
304
- }
305
- if (!data || typeof data !== "object") return;
306
-
307
- const obj = data as Record<string, unknown>;
308
- const rawType = obj["@type"];
309
- const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
310
-
311
- for (const type of types) {
312
- const rule = SCHEMA_RULES[String(type)];
313
- if (!rule) continue;
314
- validatedTypes.add(String(type));
315
-
316
- for (const field of rule.required) {
317
- if (getNestedValue(obj, field) === undefined) {
318
- issues.push({ type: String(type), level: "required", field });
319
- }
320
- }
321
-
322
- for (const field of rule.recommended) {
323
- if (getNestedValue(obj, field) === undefined) {
324
- issues.push({ type: String(type), level: "recommended", field });
325
- }
326
- }
327
-
328
- // Check nested required fields (e.g., offers.price inside Product)
329
- if (rule.nestedRequired) {
330
- for (const [parent, fields] of Object.entries(rule.nestedRequired)) {
331
- const parentVal = obj[parent];
332
- if (parentVal && typeof parentVal === "object") {
333
- const targets = Array.isArray(parentVal) ? parentVal : [parentVal];
334
- for (const target of targets) {
335
- if (target && typeof target === "object") {
336
- const nested = target as Record<string, unknown>;
337
- for (const field of fields) {
338
- if (nested[field] === undefined || nested[field] === null || nested[field] === "") {
339
- issues.push({ type: String(type), level: "required", field: `${parent}.${field}` });
340
- }
341
- }
342
- break; // Only check the first item in arrays
343
- }
344
- }
345
- }
346
- }
347
- }
348
-
349
- // Collect image URLs for validation
350
- for (const field of rule.imageFields) {
351
- const val = obj[field];
352
- if (typeof val === "string" && val.startsWith("http")) {
353
- imageUrls.push(val);
354
- } else if (val && typeof val === "object") {
355
- const nested = val as Record<string, unknown>;
356
- const url = nested.url ?? nested.contentUrl;
357
- if (typeof url === "string" && url.startsWith("http")) {
358
- imageUrls.push(url);
359
- }
360
- }
361
- }
362
- }
363
-
364
- // Recurse into nested objects and arrays (e.g., @graph)
365
- for (const val of Object.values(obj)) {
366
- if (Array.isArray(val)) {
367
- for (const item of val) validateBlock(item);
368
- } else if (val && typeof val === "object") {
369
- validateBlock(val);
370
- }
371
- }
372
- }
373
-
374
- for (const block of blocks) validateBlock(block);
375
- return { issues, imageUrls, validatedTypes };
376
- }
377
-
378
- // What each recommended field enables (sourced from Google Rich Results docs)
379
- const FIELD_HINTS: Record<string, string> = {
380
- "WebSite.potentialAction": "enables sitelinks searchbox",
381
- "Organization.logo": "appears in knowledge panel",
382
- "Organization.contactPoint": "may appear in knowledge panel",
383
- "Organization.sameAs": "links social profiles in knowledge panel",
384
- "Article.dateModified": "shows freshness in search results",
385
- "Article.publisher": "required for some article rich results",
386
- "Product.offers": "enables price display in search",
387
- "Product.review": "enables star ratings in search",
388
- "Product.aggregateRating": "enables aggregate star ratings",
389
- "Product.brand": "shown in product rich results",
390
- "LocalBusiness.openingHoursSpecification": "shows business hours in maps",
391
- "LocalBusiness.image": "shown in local pack results",
392
- "Event.image": "shown in event rich results",
393
- "Event.offers": "shows ticket prices in search",
394
- "Recipe.author": "shown in recipe rich results",
395
- "VideoObject.duration": "shown in video rich results",
396
- "SoftwareApplication.applicationCategory": "shown in software rich results",
397
- "SoftwareApplication.operatingSystem": "shown in software rich results",
398
- "SoftwareApplication.review": "enables star ratings",
399
- "SoftwareApplication.aggregateRating": "enables aggregate star ratings",
400
- "Dataset.distribution": "enables dataset download in search",
401
- "Dataset.creator": "shown in dataset rich results",
402
- "Dataset.license": "shown in dataset rich results",
403
- "Course.provider": "shown in course rich results",
404
- "Course.offers": "enables price display for courses",
405
- "JobPosting.employmentType": "shown in job search results",
406
- "JobPosting.jobLocation": "shown in job search results",
407
- "JobPosting.baseSalary": "enables salary display in job search",
408
- };
409
-
410
- function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>): string[] {
411
- const lines: string[] = [];
412
-
413
- // Show PASS for types with no required issues
414
- const typesWithRequiredIssues = new Set(issues.filter((i) => i.level === "required").map((i) => i.type));
415
- for (const type of validatedTypes) {
416
- if (!typesWithRequiredIssues.has(type)) {
417
- lines.push(`PASS ${type} — all required fields present`);
418
- }
419
- }
420
-
421
- const required = issues.filter((i) => i.level === "required");
422
- const recommended = issues.filter((i) => i.level === "recommended");
423
-
424
- if (required.length > 0) {
425
- for (const i of required) {
426
- lines.push(`MISSING ${i.type}.${i.field} (required for Rich Results)`);
427
- }
428
- }
429
- if (recommended.length > 0) {
430
- for (const i of recommended) {
431
- const hint = FIELD_HINTS[`${i.type}.${i.field}`];
432
- lines.push(`OPTIONAL ${i.type}.${i.field}${hint ? ` — ${hint}` : ""}`);
433
- }
434
- }
435
-
436
- return lines;
437
- }
438
-
439
- async function followRedirects(
440
- url: string,
441
- ua: string,
442
- maxHops = 10,
443
- ): Promise<{ chain: RedirectHop[]; response: Response }> {
444
- const chain: RedirectHop[] = [];
445
- let current = url;
446
-
447
- for (let i = 0; i < maxHops; i++) {
448
- const controller = new AbortController();
449
- const timeout = setTimeout(() => controller.abort(), 15_000);
450
- const res = await fetch(current, {
451
- headers: { "User-Agent": ua, Accept: "text/html" },
452
- redirect: "manual",
453
- signal: controller.signal,
454
- });
455
- clearTimeout(timeout);
456
-
457
- chain.push({ url: current, status: res.status });
458
-
459
- if (res.status >= 300 && res.status < 400) {
460
- const location = res.headers.get("location");
461
- if (!location) break;
462
- current = new URL(location, current).href;
463
- continue;
464
- }
465
-
466
- return { chain, response: res };
467
- }
468
-
469
- // If we exhausted hops, do a final follow-redirect fetch
470
- const res = await fetch(current, {
471
- headers: { "User-Agent": ua, Accept: "text/html" },
472
- redirect: "follow",
473
- });
474
- return { chain, response: res };
475
- }
476
-
477
- async function checkImage(imageUrl: string, tag: string): Promise<ImageCheck> {
478
- try {
479
- const controller = new AbortController();
480
- const timeout = setTimeout(() => controller.abort(), 10_000);
481
- const res = await fetch(imageUrl, { method: "HEAD", redirect: "follow", signal: controller.signal });
482
- clearTimeout(timeout);
483
- return {
484
- url: imageUrl,
485
- tag,
486
- status: res.status,
487
- contentType: res.headers.get("content-type"),
488
- contentLength: res.headers.has("content-length") ? Number(res.headers.get("content-length")) : null,
489
- error: null,
490
- };
491
- } catch (err) {
492
- return {
493
- url: imageUrl,
494
- tag,
495
- status: null,
496
- contentType: null,
497
- contentLength: null,
498
- error: err instanceof Error ? err.message : String(err),
499
- };
500
- }
501
- }
502
-
503
- function formatMetatags(url: string, parsed: ParsedHead): string {
504
- const lines: string[] = [`=== Meta Tags: ${url} ===`, ""];
505
-
506
- // Document
507
- lines.push("--- Document ---", "");
508
- lines.push(`Title: ${parsed.title ? `"${parsed.title}"` : "(missing)"}`);
509
- lines.push(
510
- `Description: ${getMeta(parsed.meta, "description") ? `"${getMeta(parsed.meta, "description")}"` : "(missing)"}`,
511
- );
512
- lines.push(`Charset: ${parsed.charset ?? "(missing)"}`);
513
- lines.push(`Viewport: ${getMeta(parsed.meta, "viewport") ?? "(missing)"}`);
514
- lines.push(`Canonical: ${parsed.canonical ?? "(not set)"}`);
515
- const robots = getMeta(parsed.meta, "robots");
516
- if (robots) lines.push(`Robots: ${robots}`);
517
- const author = getMeta(parsed.meta, "author");
518
- if (author) lines.push(`Author: ${author}`);
519
-
520
- // Length warnings
521
- if (parsed.title && parsed.title.length > 60) {
522
- lines.push(`WARN: title is ${parsed.title.length} chars — may truncate in search results (recommended: ≤60)`);
523
- }
524
- const desc = getMeta(parsed.meta, "description");
525
- if (desc && desc.length > 155) {
526
- lines.push(`WARN: description is ${desc.length} chars — may truncate in search results (recommended: ≤155)`);
527
- }
528
-
529
- // HTML entity warnings
530
- const entityCheck = [
531
- { label: "title", value: parsed.title },
532
- { label: "description", value: getMeta(parsed.meta, "description") },
533
- { label: "og:title", value: getMeta(parsed.meta, "og:title") },
534
- { label: "og:description", value: getMeta(parsed.meta, "og:description") },
535
- ];
536
- for (const { label, value } of entityCheck) {
537
- if (value && /&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/i.test(value)) {
538
- lines.push(`WARN: ${label} contains HTML entities — may render incorrectly in social previews`);
539
- }
540
- }
541
-
542
- lines.push("");
543
-
544
- // Open Graph
545
- const ogTags = getAllMeta(parsed.meta, "og:");
546
- lines.push("--- Open Graph ---", "");
547
- if (ogTags.length > 0) {
548
- for (const tag of ogTags) {
549
- lines.push(`${tag.key}: ${tag.value}`);
550
- }
551
- } else {
552
- lines.push("(none found)");
553
- }
554
- lines.push("");
555
-
556
- // Twitter Card
557
- const twitterTags = getAllMeta(parsed.meta, "twitter:");
558
- lines.push("--- Twitter Card ---", "");
559
- if (twitterTags.length > 0) {
560
- for (const tag of twitterTags) {
561
- lines.push(`${tag.key}: ${tag.value}`);
562
- }
563
- } else {
564
- lines.push("(none found)");
565
- }
566
- lines.push("");
567
-
568
- // Structured Data
569
- lines.push("--- Structured Data (JSON-LD) ---", "");
570
- if (parsed.jsonLd.length > 0) {
571
- for (let i = 0; i < parsed.jsonLd.length; i++) {
572
- if (i > 0) lines.push("");
573
- lines.push(`Block ${i + 1}:`);
574
- lines.push(...formatJsonLd(parsed.jsonLd[i], 1));
575
- }
576
- } else {
577
- lines.push("(none found)");
578
- }
579
- lines.push("");
580
-
581
- // Hreflang
582
- if (parsed.hreflang.length > 0) {
583
- lines.push("--- Alternate Languages ---", "");
584
- for (const h of parsed.hreflang) {
585
- lines.push(`${h.lang}: ${h.href}`);
586
- }
587
- lines.push("");
588
- } else {
589
- // Warn if URL suggests locale-specific content but no hreflang
590
- const localePattern =
591
- /\/(?:en|es|fr|de|ja|ko|pt|zh|ru|it|nl|sv|da|fi|nb|pl|tr|ar|hi|th|vi|id|ms|uk|cs|ro|hu|el|he|bg|hr|sk|sl|sr|lt|lv|et|ca|gl|eu|cy)(?:[-_][a-z]{2,4})?(?:\/|$)/i;
592
- const checkUrl = parsed.canonical ?? url;
593
- if (localePattern.test(checkUrl)) {
594
- lines.push(
595
- "WARN: URL appears locale-specific but no hreflang tags found — search engines may not discover alternate language versions",
596
- );
597
- lines.push("");
598
- }
599
- }
600
-
601
- // Summary of missing tags relevant to search and social
602
- const missing: string[] = [];
603
- if (!parsed.title) missing.push("title");
604
- if (!getMeta(parsed.meta, "description")) missing.push("meta description");
605
- if (!parsed.canonical) missing.push("canonical URL");
606
- if (ogTags.length === 0) missing.push("Open Graph tags");
607
- else {
608
- if (!getMeta(parsed.meta, "og:image")) missing.push("og:image");
609
- }
610
- if (twitterTags.length === 0) missing.push("Twitter Card tags");
611
- if (parsed.jsonLd.length === 0) missing.push("structured data (JSON-LD)");
612
-
613
- if (missing.length > 0) {
614
- lines.push("--- Not Found ---", "");
615
- for (const m of missing) {
616
- lines.push(`- ${m}`);
617
- }
618
- }
619
-
620
- return lines.join("\n");
621
- }
622
-
623
- // --- Links helpers ---
624
-
625
- async function checkLink(href: string): Promise<LinkResult> {
626
- const chain: Array<{ url: string; status: number }> = [];
627
- let current = href;
628
-
629
- try {
630
- for (let i = 0; i < 10; i++) {
631
- const controller = new AbortController();
632
- const timeout = setTimeout(() => controller.abort(), 10_000);
633
- const res = await fetch(current, {
634
- method: "HEAD",
635
- headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
636
- redirect: "manual",
637
- signal: controller.signal,
638
- });
639
- clearTimeout(timeout);
640
-
641
- chain.push({ url: current, status: res.status });
642
-
643
- if (res.status >= 300 && res.status < 400) {
644
- const location = res.headers.get("location");
645
- if (!location) break;
646
- current = new URL(location, current).href;
647
- continue;
648
- }
649
-
650
- return {
651
- href,
652
- status: res.status,
653
- redirectChain: chain,
654
- finalUrl: chain.length > 1 ? current : null,
655
- error: null,
656
- };
657
- }
658
-
659
- return {
660
- href,
661
- status: chain[chain.length - 1]?.status ?? null,
662
- redirectChain: chain,
663
- finalUrl: current,
664
- error: "Too many redirects",
665
- };
666
- } catch (err) {
667
- return {
668
- href,
669
- status: null,
670
- redirectChain: chain,
671
- finalUrl: null,
672
- error: err instanceof Error ? err.message : String(err),
673
- };
674
- }
675
- }
676
-
677
- function extractInternalLinks(html: string, origin: string): string[] {
678
- const links = new Set<string>();
679
-
680
- for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
681
- const raw = match[1].trim();
682
- if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
683
-
684
- try {
685
- const resolved = new URL(raw, origin).href;
686
- if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
687
- links.add(resolved);
688
- }
689
- } catch {
690
- // Invalid URL, skip
691
- }
692
- }
693
-
694
- return [...links];
695
- }
696
-
697
- function formatLinkResults(url: string, results: LinkResult[]): string {
698
- const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
699
-
700
- const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
701
- const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
702
- const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
703
-
704
- // Summary
705
- lines.push("--- Summary ---", "");
706
- lines.push(`OK: ${ok.length}`);
707
- if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
708
- if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
709
- lines.push("");
710
-
711
- // Broken links
712
- if (broken.length > 0) {
713
- lines.push("--- Broken Links ---", "");
714
- for (const r of broken) {
715
- if (r.error) {
716
- lines.push(`FAIL ${r.href}`);
717
- lines.push(` Error: ${r.error}`);
718
- } else {
719
- lines.push(`FAIL ${r.href} → ${r.status}`);
720
- }
721
- }
722
- lines.push("");
723
- }
724
-
725
- // Redirect chains
726
- if (redirected.length > 0) {
727
- lines.push("--- Redirect Chains ---", "");
728
- for (const r of redirected) {
729
- const hops = r.redirectChain.length - 1;
730
- const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
731
- lines.push(`REDIRECT ${r.href}`);
732
- lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
733
- }
734
- lines.push("");
735
- }
736
-
737
- return lines.join("\n");
738
- }
739
-
740
- // --- Contrast helpers ---
741
-
742
- function parseHex(hex: string): [number, number, number] | null {
743
- const clean = hex.replace(/^#/, "");
744
- if (clean.length === 3) {
745
- const r = Number.parseInt(clean[0] + clean[0], 16);
746
- const g = Number.parseInt(clean[1] + clean[1], 16);
747
- const b = Number.parseInt(clean[2] + clean[2], 16);
748
- return [r, g, b];
749
- }
750
- if (clean.length === 6) {
751
- const r = Number.parseInt(clean.slice(0, 2), 16);
752
- const g = Number.parseInt(clean.slice(2, 4), 16);
753
- const b = Number.parseInt(clean.slice(4, 6), 16);
754
- return [r, g, b];
755
- }
756
- return null;
757
- }
758
-
759
- function toHex(r: number, g: number, b: number): string {
760
- return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
761
- }
762
-
763
- function relativeLuminance(r: number, g: number, b: number): number {
764
- const [rs, gs, bs] = [r, g, b].map((c) => {
765
- const s = c / 255;
766
- return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
767
- });
768
- return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
769
- }
770
-
771
- function contrastRatio(l1: number, l2: number): number {
772
- const lighter = Math.max(l1, l2);
773
- const darker = Math.min(l1, l2);
774
- return (lighter + 0.05) / (darker + 0.05);
775
- }
776
-
777
- function wcagLevel(ratio: number, isLargeText: boolean): string {
778
- if (isLargeText) {
779
- if (ratio >= 4.5) return "AAA";
780
- if (ratio >= 3) return "AA";
781
- return "FAIL";
782
- }
783
- if (ratio >= 7) return "AAA";
784
- if (ratio >= 4.5) return "AA";
785
- return "FAIL";
786
- }
787
-
788
- function findNearestPassing(fg: [number, number, number], bg: [number, number, number], targetRatio: number): string {
789
- const bgLum = relativeLuminance(...bg);
790
-
791
- // Try both directions: darken and lighten, pick the one closest to original
792
- let bestDarken: [number, number, number] | null = null;
793
- let bestLighten: [number, number, number] | null = null;
794
-
795
- for (let step = 0; step <= 100; step++) {
796
- const t = step / 100;
797
- const dark: [number, number, number] = [fg[0] * (1 - t), fg[1] * (1 - t), fg[2] * (1 - t)];
798
- if (!bestDarken && contrastRatio(relativeLuminance(...dark), bgLum) >= targetRatio) {
799
- bestDarken = dark;
800
- }
801
- const light: [number, number, number] = [
802
- fg[0] + (255 - fg[0]) * t,
803
- fg[1] + (255 - fg[1]) * t,
804
- fg[2] + (255 - fg[2]) * t,
805
- ];
806
- if (!bestLighten && contrastRatio(relativeLuminance(...light), bgLum) >= targetRatio) {
807
- bestLighten = light;
808
- }
809
- if (bestDarken && bestLighten) break;
810
- }
811
-
812
- // Pick the direction that changes the color least
813
- if (bestDarken && bestLighten) {
814
- const darkDist =
815
- Math.abs(fg[0] - bestDarken[0]) + Math.abs(fg[1] - bestDarken[1]) + Math.abs(fg[2] - bestDarken[2]);
816
- const lightDist =
817
- Math.abs(fg[0] - bestLighten[0]) + Math.abs(fg[1] - bestLighten[1]) + Math.abs(fg[2] - bestLighten[2]);
818
- return toHex(...(darkDist <= lightDist ? bestDarken : bestLighten));
819
- }
820
- return toHex(...(bestDarken ?? bestLighten ?? fg));
821
- }
822
-
823
- // --- Tool registration ---
824
-
825
- export function registerPageTool(server: McpServer): void {
826
- server.tool(
827
- "page",
828
- "Analyze what's on a page — meta tags, Open Graph, Twitter Card, structured data (JSON-LD), internal links, and redirect chains. Also includes a WCAG contrast checker for accessibility fixes.",
829
- {
830
- url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
831
- urls: z
832
- .array(z.string().url())
833
- .min(2)
834
- .max(10)
835
- .optional()
836
- .describe(
837
- "Multiple URLs (2-10) for batch analysis. Returns a summary table of meta tags, structured data, and link health per page.",
838
- ),
839
- check_links: z
840
- .boolean()
841
- .optional()
842
- .describe("Check internal links for broken links and redirect chains. Default: true. Set false to skip."),
843
- user_agent: z.string().optional().describe("Custom User-Agent for page fetch. Default: Googlebot-compatible."),
844
- foreground: z
845
- .string()
846
- .optional()
847
- .describe("Foreground (text) hex color for WCAG contrast check (e.g., '#D4594C'). Use with background."),
848
- background: z.string().optional().describe("Background hex color for contrast check (e.g., '#FFFFFF')."),
849
- large_text: z
850
- .boolean()
851
- .optional()
852
- .describe("Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1."),
853
- },
854
- async ({ url, urls, check_links, user_agent, foreground, background, large_text }) => {
855
- // --- Batch mode ---
856
- if (urls) {
857
- const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
858
- const results: Array<{
859
- url: string;
860
- title: string;
861
- description: string;
862
- canonical: string;
863
- jsonLd: string;
864
- links: string;
865
- issues: string[];
866
- error?: string;
867
- }> = [];
868
-
869
- for (const pageUrl of urls) {
870
- try {
871
- const { chain, response: res } = await followRedirects(pageUrl, ua);
872
- if (!res.ok) {
873
- results.push({
874
- url: pageUrl,
875
- title: "-",
876
- description: "-",
877
- canonical: "-",
878
- jsonLd: "-",
879
- links: "-",
880
- issues: [`HTTP ${res.status}`],
881
- });
882
- continue;
883
- }
884
- const html = await res.text();
885
- const parsed = parseHead(html);
886
- const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : pageUrl;
887
-
888
- const issues: string[] = [];
889
- if (!parsed.title) issues.push("no title");
890
- if (!getMeta(parsed.meta, "description")) issues.push("no description");
891
- else if ((getMeta(parsed.meta, "description") ?? "").length > 155) issues.push("description >155 chars");
892
- if (!parsed.canonical) issues.push("no canonical");
893
- if (parsed.jsonLd.length === 0) issues.push("no JSON-LD");
894
- if (chain.length > 2) issues.push(`${chain.length - 1} redirects`);
895
-
896
- // Quick link check
897
- let linkSummary = "-";
898
- if (check_links !== false) {
899
- const origin = new URL(finalUrl).origin;
900
- const links = extractInternalLinks(html, origin);
901
- if (links.length > 0) {
902
- const checked = await Promise.all(links.slice(0, 20).map((href) => checkLink(href)));
903
- const broken = checked.filter((r) => r.error || (r.status && r.status >= 400)).length;
904
- const redirected = checked.filter(
905
- (r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400,
906
- ).length;
907
- const parts: string[] = [`${links.length} found`];
908
- if (broken > 0) parts.push(`${broken} broken`);
909
- if (redirected > 0) parts.push(`${redirected} redirected`);
910
- if (broken === 0 && redirected === 0) parts.push("all OK");
911
- linkSummary = parts.join(", ");
912
- if (broken > 0) issues.push(`${broken} broken links`);
913
- if (redirected > 0) issues.push(`${redirected} redirect chains`);
914
- } else {
915
- linkSummary = "0 (SPA?)";
916
- }
917
- }
918
-
919
- const jsonLdTypes = parsed.jsonLd
920
- .map((block) => {
921
- if (block && typeof block === "object" && "@type" in block)
922
- return String((block as Record<string, unknown>)["@type"]);
923
- return null;
924
- })
925
- .filter(Boolean);
926
-
927
- // Run structured data validation for batch summary
928
- let jsonLdSummary = "none";
929
- if (parsed.jsonLd.length > 0) {
930
- const { issues: valIssues } = validateJsonLd(parsed.jsonLd);
931
- const requiredMissing = valIssues.filter((i) => i.level === "required").length;
932
- if (jsonLdTypes.length > 0) {
933
- jsonLdSummary = jsonLdTypes.join(", ");
934
- if (requiredMissing > 0) {
935
- jsonLdSummary += ` (${requiredMissing} required missing)`;
936
- issues.push(`${requiredMissing} missing required fields in JSON-LD`);
937
- }
938
- }
939
- }
940
-
941
- // Description with length
942
- const descVal = getMeta(parsed.meta, "description");
943
- let descSummary: string;
944
- if (descVal) {
945
- descSummary = descVal.length > 155 ? `yes (${descVal.length} chars ⚠)` : `yes (${descVal.length})`;
946
- } else {
947
- descSummary = "no";
948
- }
949
-
950
- results.push({
951
- url: finalUrl,
952
- title: parsed.title
953
- ? parsed.title.length > 60
954
- ? `${parsed.title.slice(0, parsed.title.lastIndexOf(" ", 60) > 20 ? parsed.title.lastIndexOf(" ", 60) : 60)}...`
955
- : parsed.title
956
- : "(missing)",
957
- description: descSummary,
958
- canonical: parsed.canonical ? "yes" : "no",
959
- jsonLd: jsonLdSummary,
960
- links: linkSummary,
961
- issues,
962
- });
963
- } catch (err) {
964
- results.push({
965
- url: pageUrl,
966
- title: "-",
967
- description: "-",
968
- canonical: "-",
969
- jsonLd: "-",
970
- links: "-",
971
- issues: [err instanceof Error ? err.message : String(err)],
972
- });
973
- }
974
- }
975
-
976
- const lines: string[] = [`=== Batch Page Analysis (${results.length} URLs) ===`, ""];
977
-
978
- for (const r of results) {
979
- let label: string;
980
- try {
981
- const u = new URL(r.url);
982
- const allSameHost = results.every((x) => {
983
- try {
984
- return new URL(x.url).hostname === u.hostname;
985
- } catch {
986
- return false;
987
- }
988
- });
989
- label = allSameHost ? u.pathname : `${u.hostname}${u.pathname}`;
990
- } catch {
991
- label = r.url;
992
- }
993
- lines.push(label);
994
- lines.push(` Title: ${r.title}`);
995
- lines.push(` Description: ${r.description} Canonical: ${r.canonical} JSON-LD: ${r.jsonLd}`);
996
- if (check_links !== false) lines.push(` Links: ${r.links}`);
997
- if (r.issues.length > 0) {
998
- lines.push(` Issues: ${r.issues.join(", ")}`);
999
- } else {
1000
- lines.push(" Issues: none");
1001
- }
1002
- lines.push("");
1003
- }
1004
-
1005
- const issueCount = results.reduce((sum, r) => sum + r.issues.length, 0);
1006
- lines.push(`Total: ${results.length} pages, ${issueCount} issues`);
1007
-
1008
- return { content: [{ type: "text", text: lines.join("\n") }] };
1009
- }
1010
-
1011
- // --- Contrast-only mode (no URL) ---
1012
- if (foreground && background && !url) {
1013
- const fg = parseHex(foreground);
1014
- const bg = parseHex(background);
1015
-
1016
- if (!fg) return { content: [{ type: "text", text: `Invalid foreground color: ${foreground}` }] };
1017
- if (!bg) return { content: [{ type: "text", text: `Invalid background color: ${background}` }] };
1018
-
1019
- const fgLum = relativeLuminance(...fg);
1020
- const bgLum = relativeLuminance(...bg);
1021
- const ratio = contrastRatio(fgLum, bgLum);
1022
- const isLarge = large_text ?? false;
1023
- const level = wcagLevel(ratio, isLarge);
1024
-
1025
- const aaThreshold = isLarge ? 3 : 4.5;
1026
- const aaaThreshold = isLarge ? 4.5 : 7;
1027
-
1028
- const lines: string[] = [
1029
- "=== Contrast Check ===",
1030
- "",
1031
- `Foreground: ${toHex(...fg)}`,
1032
- `Background: ${toHex(...bg)}`,
1033
- `Text size: ${isLarge ? "large (≥18pt)" : "normal"}`,
1034
- "",
1035
- `Contrast ratio: ${ratio.toFixed(2)}:1`,
1036
- `WCAG AA (${aaThreshold}:1): ${ratio >= aaThreshold ? "PASS" : "FAIL"}`,
1037
- `WCAG AAA (${aaaThreshold}:1): ${ratio >= aaaThreshold ? "PASS" : "FAIL"}`,
1038
- `Result: ${level}`,
1039
- ];
1040
-
1041
- if (ratio < aaThreshold) {
1042
- const suggested = findNearestPassing(fg, bg, aaThreshold);
1043
- lines.push("", `Nearest AA-passing foreground: ${suggested}`);
1044
-
1045
- const sugParsed = parseHex(suggested);
1046
- if (sugParsed) {
1047
- const sugRatio = contrastRatio(relativeLuminance(...sugParsed), bgLum);
1048
- lines.push(`New ratio: ${sugRatio.toFixed(2)}:1`);
1049
- }
1050
- }
1051
-
1052
- return { content: [{ type: "text", text: lines.join("\n") }] };
1053
- }
1054
-
1055
- // --- Page analysis mode ---
1056
- if (url) {
1057
- try {
1058
- const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
1059
- const { chain, response: res } = await followRedirects(url, ua);
1060
-
1061
- if (!res.ok) {
1062
- return {
1063
- content: [
1064
- {
1065
- type: "text",
1066
- text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
1067
- },
1068
- ],
1069
- };
1070
- }
1071
-
1072
- const html = await res.text();
1073
- const parsed = parseHead(html);
1074
- const output: string[] = [];
1075
-
1076
- // Redirect chain (only if there were redirects)
1077
- if (chain.length > 1) {
1078
- output.push("--- Redirect Chain ---", "");
1079
- const parts: string[] = [];
1080
- for (const hop of chain) {
1081
- parts.push(hop.url);
1082
- parts.push(String(hop.status));
1083
- }
1084
- // Format: URL → status → URL → status (drop trailing status)
1085
- parts.pop();
1086
- output.push(parts.join(" → "));
1087
- output.push(`Hops: ${chain.length - 1}`);
1088
- output.push("");
1089
- }
1090
-
1091
- // Main meta tag report
1092
- const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
1093
- output.push(formatMetatags(finalUrl, parsed));
1094
-
1095
- // OG/Twitter image validation
1096
- const ogImage = getMeta(parsed.meta, "og:image");
1097
- const twitterImage = getMeta(parsed.meta, "twitter:image");
1098
- const imagesToCheck: Array<{ url: string; tag: string }> = [];
1099
- if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
1100
- if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
1101
-
1102
- if (imagesToCheck.length > 0) {
1103
- const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
1104
- output.push("", "--- Image Validation ---", "");
1105
- for (const check of checks) {
1106
- if (check.error) {
1107
- output.push(`${check.tag}: FAILED — ${check.error}`);
1108
- output.push(` URL: ${check.url}`);
1109
- } else if (check.status && check.status >= 400) {
1110
- output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
1111
- output.push(` URL: ${check.url}`);
1112
- } else {
1113
- const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
1114
- const size = sizeKB ? ` (${sizeKB} KB)` : "";
1115
- const type = check.contentType ? ` ${check.contentType}` : "";
1116
- output.push(`${check.tag}: OK —${type}${size}`);
1117
- if (sizeKB && sizeKB > 1024) {
1118
- output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
1119
- }
1120
- }
1121
-
1122
- // Check declared dimensions from meta tags
1123
- if (check.tag === "og:image") {
1124
- const w = getMeta(parsed.meta, "og:image:width");
1125
- const h = getMeta(parsed.meta, "og:image:height");
1126
- if (w && h) {
1127
- output.push(` Declared dimensions: ${w}x${h}`);
1128
- const wn = Number(w);
1129
- const hn = Number(h);
1130
- if (wn && hn && (wn < 1200 || hn < 630)) {
1131
- output.push(" Note: recommended minimum for og:image is 1200x630");
1132
- }
1133
- }
1134
- }
1135
- }
1136
- }
1137
-
1138
- // Structured data validation
1139
- if (parsed.jsonLd.length > 0) {
1140
- const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
1141
- if (validatedTypes.size > 0 || imageUrls.length > 0) {
1142
- output.push("", "--- Structured Data Validation ---", "");
1143
- output.push(...formatValidation(issues, validatedTypes));
1144
-
1145
- // HEAD-check image URLs from structured data
1146
- if (imageUrls.length > 0) {
1147
- const deduped = [...new Set(imageUrls)];
1148
- const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
1149
- for (const check of imgChecks) {
1150
- if (check.error) {
1151
- output.push(`BROKEN Image: ${check.url} — ${check.error}`);
1152
- } else if (check.status && check.status >= 400) {
1153
- output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
1154
- } else {
1155
- const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
1156
- output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
1157
- }
1158
- }
1159
- }
1160
- }
1161
- }
1162
-
1163
- // Contrast check (before links, after structured data)
1164
- if (foreground && background) {
1165
- const fg = parseHex(foreground);
1166
- const bg = parseHex(background);
1167
- if (fg && bg) {
1168
- const fgLum = relativeLuminance(...fg);
1169
- const bgLum = relativeLuminance(...bg);
1170
- const ratio = contrastRatio(fgLum, bgLum);
1171
- const isLarge = large_text ?? false;
1172
- const level = wcagLevel(ratio, isLarge);
1173
- const aaThreshold = isLarge ? 3 : 4.5;
1174
- const aaaThreshold = isLarge ? 4.5 : 7;
1175
- output.push(
1176
- "",
1177
- "--- Contrast Check ---",
1178
- "",
1179
- `Foreground: ${toHex(...fg)} Background: ${toHex(...bg)}`,
1180
- `Ratio: ${ratio.toFixed(2)}:1 AA: ${ratio >= aaThreshold ? "PASS" : "FAIL"} AAA: ${ratio >= aaaThreshold ? "PASS" : "FAIL"} Result: ${level}`,
1181
- );
1182
- if (ratio < aaThreshold) {
1183
- const suggested = findNearestPassing(fg, bg, aaThreshold);
1184
- output.push(`Nearest AA-passing foreground: ${suggested}`);
1185
- }
1186
- }
1187
- }
1188
-
1189
- // --- Link check (last section) ---
1190
- if (check_links !== false) {
1191
- const origin = new URL(url).origin;
1192
- const links = extractInternalLinks(html, origin);
1193
-
1194
- if (links.length === 0) {
1195
- output.push(
1196
- "",
1197
- "--- Internal Links ---",
1198
- "",
1199
- "No internal links found.",
1200
- "Note: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.",
1201
- );
1202
- } else {
1203
- // Check links with concurrency 5
1204
- const concurrency = 5;
1205
- const results: LinkResult[] = [];
1206
- let i = 0;
1207
-
1208
- while (i < links.length) {
1209
- const batch = links.slice(i, i + concurrency);
1210
- const settled = await Promise.all(batch.map((href) => checkLink(href)));
1211
- results.push(...settled);
1212
- i += concurrency;
1213
- }
1214
-
1215
- output.push("", formatLinkResults(url, results));
1216
- }
1217
- }
1218
-
1219
- return {
1220
- content: [{ type: "text", text: output.join("\n") }],
1221
- };
1222
- } catch (err) {
1223
- const msg = err instanceof Error ? err.message : String(err);
1224
- return {
1225
- content: [{ type: "text", text: `Error analyzing page: ${msg}` }],
1226
- };
1227
- }
1228
- }
1229
-
1230
- // --- No valid input ---
1231
- return {
1232
- content: [
1233
- {
1234
- type: "text",
1235
- text: "Provide 'url' for page analysis or 'foreground' + 'background' for contrast check.",
1236
- },
1237
- ],
1238
- };
1239
- },
1240
- );
1241
- }