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
@@ -1,9 +1,9 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { inspectUrl, listSitemaps } from "../lib/gsc.js";
4
- import { type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
5
- import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../lib/robots.js";
6
- import { fetchSitemap, inspectSingle, sampleUrls } from "../lib/sitemap.js";
3
+ import { type GscSitemap, inspectUrl, listSitemaps } from "../providers/gsc.js";
4
+ import { RequestError } from "../shared/http.js";
5
+ import { type PsiCategoryType, type PsiResult, runPagespeed } from "../providers/pagespeed.js";
6
+ import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../web/robots.js";
7
7
 
8
8
  type Severity = "HIGH" | "MEDIUM" | "LOW";
9
9
 
@@ -13,8 +13,6 @@ interface Finding {
13
13
  source: string;
14
14
  }
15
15
 
16
- // --- Metatags (inline, no external dep needed) ---
17
-
18
16
  interface MetaCheckResult {
19
17
  title: string | null;
20
18
  description: string | null;
@@ -66,7 +64,7 @@ async function checkMeta(url: string): Promise<MetaCheckResult> {
66
64
  };
67
65
  }
68
66
 
69
- // Exhausted redirects
67
+ // The hop limit or a redirect without Location prevented a page response.
70
68
  return {
71
69
  title: null,
72
70
  description: null,
@@ -80,8 +78,6 @@ async function checkMeta(url: string): Promise<MetaCheckResult> {
80
78
  };
81
79
  }
82
80
 
83
- // --- Audit logic ---
84
-
85
81
  function addMetaFindings(meta: MetaCheckResult, findings: Finding[]) {
86
82
  if (!meta.title) findings.push({ severity: "HIGH", message: "Missing <title> tag", source: "metatags" });
87
83
  if (!meta.description) findings.push({ severity: "HIGH", message: "Missing meta description", source: "metatags" });
@@ -182,70 +178,30 @@ function addRobotsFindings(
182
178
  }
183
179
  }
184
180
 
185
- function addSitemapFindings(sitemapCount: number, totalSubmitted: number, totalIndexed: number, findings: Finding[]) {
186
- if (sitemapCount === 0) {
181
+ export function addSitemapFindings(sitemaps: GscSitemap[], findings: Finding[]) {
182
+ if (sitemaps.length === 0) {
187
183
  findings.push({ severity: "MEDIUM", message: "No sitemaps submitted to GSC", source: "sitemaps" });
188
- return;
189
184
  }
190
- if (totalSubmitted > 0 && totalIndexed === 0) {
191
- findings.push({
192
- severity: "HIGH",
193
- message: `${totalSubmitted.toLocaleString()} sitemap URLs submitted, 0 indexed`,
194
- source: "sitemaps",
195
- });
196
- } else if (totalSubmitted > 0) {
197
- const pct = Math.round((totalIndexed / totalSubmitted) * 100);
198
- if (pct < 50) {
185
+ // contents[].indexed is deprecated; sitemap submission is not indexing evidence.
186
+ // https://developers.google.com/webmaster-tools/v1/sitemaps
187
+ for (const sitemap of sitemaps) {
188
+ if (Number(sitemap.errors) > 0) {
199
189
  findings.push({
200
190
  severity: "HIGH",
201
- message: `${totalSubmitted.toLocaleString()} sitemap URLs, only ${pct}% indexed (${totalIndexed.toLocaleString()})`,
191
+ message: `${sitemap.path}: ${sitemap.errors} sitemap errors`,
202
192
  source: "sitemaps",
203
193
  });
204
- } else if (pct < 80) {
194
+ }
195
+ if (Number(sitemap.warnings) > 0) {
205
196
  findings.push({
206
197
  severity: "MEDIUM",
207
- message: `${totalSubmitted.toLocaleString()} sitemap URLs, ${pct}% indexed (${totalIndexed.toLocaleString()})`,
198
+ message: `${sitemap.path}: ${sitemap.warnings} sitemap warnings`,
208
199
  source: "sitemaps",
209
200
  });
210
201
  }
211
202
  }
212
203
  }
213
204
 
214
- function formatDrillDown(
215
- inspections: Array<{ url: string; verdict: string; coverageState: string; error: string | null }>,
216
- ): string {
217
- const valid = inspections.filter((r) => !r.error);
218
- if (valid.length === 0) return "";
219
-
220
- const indexed = valid.filter((r) => r.verdict === "PASS").length;
221
- const lines: string[] = [` Auto-inspected ${valid.length} URLs:`];
222
- lines.push(` - ${indexed}/${valid.length} indexed`);
223
-
224
- const stateCounts: Record<string, { count: number; urls: string[] }> = {};
225
- for (const r of valid) {
226
- if (r.verdict !== "PASS") {
227
- let path: string;
228
- try {
229
- path = new URL(r.url).pathname;
230
- } catch {
231
- path = r.url;
232
- }
233
- const existing = stateCounts[r.coverageState];
234
- if (existing) {
235
- existing.count++;
236
- existing.urls.push(path);
237
- } else {
238
- stateCounts[r.coverageState] = { count: 1, urls: [path] };
239
- }
240
- }
241
- }
242
- for (const [state, { count, urls }] of Object.entries(stateCounts)) {
243
- lines.push(` - ${count}/${valid.length} ${state}: ${urls.join(", ")}`);
244
- }
245
-
246
- return lines.join("\n");
247
- }
248
-
249
205
  function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
250
206
  if (verdict === "FAIL") {
251
207
  findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
@@ -266,7 +222,6 @@ function formatAudit(url: string, findings: Finding[], errors: string[]): string
266
222
  lines.push("");
267
223
  }
268
224
 
269
- // Sort: HIGH first, then MEDIUM, then LOW
270
225
  const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
271
226
  findings.sort((a, b) => order[a.severity] - order[b.severity]);
272
227
 
@@ -299,7 +254,6 @@ export function registerAuditTool(server: McpServer): void {
299
254
  const errors: string[] = [];
300
255
  const origin = new URL(url).origin;
301
256
 
302
- // Run independent checks in parallel
303
257
  const [metaResult, pagespeedResult, robotsResult, sitemapResult, inspectResult] = await Promise.allSettled([
304
258
  checkMeta(url),
305
259
  runPagespeed(url, {
@@ -312,7 +266,7 @@ export function registerAuditTool(server: McpServer): void {
312
266
  return { robotsTxt, crawlers, googlebotAllowed };
313
267
  }),
314
268
  site_url ? listSitemaps(site_url) : Promise.resolve(null),
315
- site_url ? inspectUrl(url, site_url).catch(() => null) : Promise.resolve(null),
269
+ site_url ? inspectUrl(url, site_url) : Promise.resolve(null),
316
270
  ]);
317
271
 
318
272
  // Process meta
@@ -326,7 +280,15 @@ export function registerAuditTool(server: McpServer): void {
326
280
  if (pagespeedResult.status === "fulfilled") {
327
281
  addPagespeedFindings(pagespeedResult.value, findings);
328
282
  } else {
329
- errors.push(`PageSpeed: ${pagespeedResult.reason}`);
283
+ const psiErr = String(pagespeedResult.reason);
284
+ const status = pagespeedResult.reason instanceof RequestError ? pagespeedResult.reason.status : null;
285
+ if (status === 403) {
286
+ errors.push("PageSpeed: SKIPPED (API key not authorized — enable PageSpeed Insights API in Google Cloud)");
287
+ } else if (status === 429) {
288
+ errors.push("PageSpeed: SKIPPED (rate limited — try again later or set GOOGLE_API_KEY)");
289
+ } else {
290
+ errors.push(`PageSpeed: ${psiErr.replace(/:\s*\{[\s\S]*$/, "")}`);
291
+ }
330
292
  }
331
293
 
332
294
  // Process robots
@@ -340,40 +302,7 @@ export function registerAuditTool(server: McpServer): void {
340
302
 
341
303
  // Process sitemaps
342
304
  if (sitemapResult.status === "fulfilled" && sitemapResult.value) {
343
- const sitemaps = sitemapResult.value;
344
- let totalSubmitted = 0;
345
- let totalIndexed = 0;
346
- for (const sm of sitemaps) {
347
- for (const c of sm.contents ?? []) {
348
- totalSubmitted += Number(c.submitted ?? 0);
349
- totalIndexed += Number(c.indexed ?? 0);
350
- }
351
- }
352
- addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
353
-
354
- // Auto-drill-down: when indexing is low, sample-inspect to explain why
355
- const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
356
- if (site_url && indexPct < 50 && sitemaps.length > 0) {
357
- try {
358
- const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
359
- let parsed = await fetchSitemap(sitemapPath);
360
- if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
361
- parsed = await fetchSitemap(parsed.childSitemaps[0]);
362
- }
363
- if (parsed.urls.length > 0) {
364
- const sampled = sampleUrls(parsed.urls, 5, "spread");
365
- const inspections = await Promise.all(sampled.map((u) => inspectSingle(u, site_url)));
366
- const drillDown = formatDrillDown(inspections);
367
- // Append drill-down to the sitemap finding
368
- const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
369
- if (sitemapFinding) {
370
- sitemapFinding.message += `\n${drillDown}`;
371
- }
372
- }
373
- } catch {
374
- // Drill-down is best-effort — don't fail the audit
375
- }
376
- }
305
+ addSitemapFindings(sitemapResult.value, findings);
377
306
  } else if (sitemapResult.status === "rejected") {
378
307
  errors.push(`Sitemaps: ${sitemapResult.reason}`);
379
308
  }
@@ -0,0 +1,28 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { execute, operationSchema } from "../api/index.js";
3
+ import { RequestError } from "../shared/http.js";
4
+
5
+ export function registerObserveTool(server: McpServer): void {
6
+ server.tool(
7
+ "observe",
8
+ "Run the shared Pagesight API. Read-only operations: discover; bing.sites/queries/pages/traffic/crawl-stats/crawl-issues/url-info/link-counts/url-links; evidence.import (unverified UI findings); gsc.sites/sitemaps/inspect/report; ga.accounts/property/key-events/report; page (metadata and image ALT evidence); speed.psi/crux/history; doctor; snapshot; compare. Pass the API operation object as request. Returns structured evidence, exact effective requests, provider responses and partial errors. See the CLI --help and README for report/config shapes.",
9
+ { request: operationSchema },
10
+ async ({ request }) => {
11
+ try {
12
+ const result = await execute(request);
13
+ return {
14
+ content: [{ type: "text" as const, text: JSON.stringify(result) }],
15
+ structuredContent: { ...result },
16
+ isError: result.status === "error",
17
+ };
18
+ } catch (error) {
19
+ return {
20
+ content: [
21
+ { type: "text" as const, text: error instanceof RequestError ? error.message : "Invalid operation" },
22
+ ],
23
+ isError: true,
24
+ };
25
+ }
26
+ },
27
+ );
28
+ }
@@ -0,0 +1,194 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { parseHead, getMeta, formatMetatags } from "./metadata.js";
3
+ import { validateJsonLd, formatValidation } from "./structured-data.js";
4
+ import {
5
+ followRedirects,
6
+ checkImage,
7
+ checkLink,
8
+ extractInternalLinks,
9
+ formatLinkResults,
10
+ type LinkResult,
11
+ } from "./links.js";
12
+ import { parseHex, relativeLuminance, contrastRatio, wcagLevel, toHex, findNearestPassing } from "./contrast.js";
13
+
14
+ export async function analyzePage(
15
+ url: string,
16
+ options: {
17
+ check_links?: boolean;
18
+ user_agent?: string;
19
+ foreground?: string;
20
+ background?: string;
21
+ large_text?: boolean;
22
+ },
23
+ ): Promise<CallToolResult> {
24
+ const { check_links, user_agent, foreground, background, large_text } = options;
25
+ try {
26
+ const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
27
+ const { chain, response: res } = await followRedirects(url, ua);
28
+
29
+ if (!res.ok) {
30
+ return {
31
+ content: [
32
+ {
33
+ type: "text",
34
+ text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
35
+ },
36
+ ],
37
+ };
38
+ }
39
+
40
+ const html = await res.text();
41
+ const parsed = parseHead(html);
42
+ const output: string[] = [];
43
+
44
+ // Redirect chain (only if there were redirects)
45
+ if (chain.length > 1) {
46
+ output.push("--- Redirect Chain ---", "");
47
+ const parts: string[] = [];
48
+ for (const hop of chain) {
49
+ parts.push(hop.url);
50
+ parts.push(String(hop.status));
51
+ }
52
+ // Format: URL → status → URL → status (drop trailing status)
53
+ parts.pop();
54
+ output.push(parts.join(" → "));
55
+ output.push(`Hops: ${chain.length - 1}`);
56
+ output.push("");
57
+ }
58
+
59
+ // Main meta tag report
60
+ const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
61
+ output.push(formatMetatags(finalUrl, parsed));
62
+
63
+ // OG/Twitter image validation
64
+ const ogImage = getMeta(parsed.meta, "og:image");
65
+ const twitterImage = getMeta(parsed.meta, "twitter:image");
66
+ const imagesToCheck: Array<{ url: string; tag: string }> = [];
67
+ if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
68
+ if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
69
+
70
+ if (imagesToCheck.length > 0) {
71
+ const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
72
+ output.push("", "--- Image Validation ---", "");
73
+ for (const check of checks) {
74
+ if (check.error) {
75
+ output.push(`${check.tag}: FAILED — ${check.error}`);
76
+ output.push(` URL: ${check.url}`);
77
+ } else if (check.status && check.status >= 400) {
78
+ output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
79
+ output.push(` URL: ${check.url}`);
80
+ } else {
81
+ const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
82
+ const size = sizeKB ? ` (${sizeKB} KB)` : "";
83
+ const type = check.contentType ? ` ${check.contentType}` : "";
84
+ output.push(`${check.tag}: OK —${type}${size}`);
85
+ if (sizeKB && sizeKB > 1024) {
86
+ output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
87
+ }
88
+ }
89
+
90
+ // Check declared dimensions from meta tags
91
+ if (check.tag === "og:image") {
92
+ const w = getMeta(parsed.meta, "og:image:width");
93
+ const h = getMeta(parsed.meta, "og:image:height");
94
+ if (w && h) {
95
+ output.push(` Declared dimensions: ${w}x${h}`);
96
+ const wn = Number(w);
97
+ const hn = Number(h);
98
+ if (wn && hn && (wn < 1200 || hn < 630)) {
99
+ output.push(" Note: recommended minimum for og:image is 1200x630");
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // Structured data validation
107
+ if (parsed.jsonLd.length > 0) {
108
+ const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
109
+ if (validatedTypes.size > 0 || imageUrls.length > 0) {
110
+ output.push("", "--- Structured Data Validation ---", "");
111
+ output.push(...formatValidation(issues, validatedTypes));
112
+
113
+ // HEAD-check image URLs from structured data
114
+ if (imageUrls.length > 0) {
115
+ const deduped = [...new Set(imageUrls)];
116
+ const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
117
+ for (const check of imgChecks) {
118
+ if (check.error) {
119
+ output.push(`BROKEN Image: ${check.url} — ${check.error}`);
120
+ } else if (check.status && check.status >= 400) {
121
+ output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
122
+ } else {
123
+ const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
124
+ output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ if (foreground && background) {
132
+ const fg = parseHex(foreground);
133
+ const bg = parseHex(background);
134
+ if (fg && bg) {
135
+ const fgLum = relativeLuminance(...fg);
136
+ const bgLum = relativeLuminance(...bg);
137
+ const ratio = contrastRatio(fgLum, bgLum);
138
+ const isLarge = large_text ?? false;
139
+ const level = wcagLevel(ratio, isLarge);
140
+ const aaThreshold = isLarge ? 3 : 4.5;
141
+ const aaaThreshold = isLarge ? 4.5 : 7;
142
+ output.push(
143
+ "",
144
+ "--- Contrast Check ---",
145
+ "",
146
+ `Foreground: ${toHex(...fg)} Background: ${toHex(...bg)}`,
147
+ `Ratio: ${ratio.toFixed(2)}:1 AA: ${ratio >= aaThreshold ? "PASS" : "FAIL"} AAA: ${ratio >= aaaThreshold ? "PASS" : "FAIL"} Result: ${level}`,
148
+ );
149
+ if (ratio < aaThreshold) {
150
+ const suggested = findNearestPassing(fg, bg, aaThreshold);
151
+ output.push(`Nearest AA-passing foreground: ${suggested}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ // --- Link check (last section) ---
157
+ if (check_links !== false) {
158
+ const origin = new URL(url).origin;
159
+ const links = extractInternalLinks(html, origin);
160
+
161
+ if (links.length === 0) {
162
+ output.push(
163
+ "",
164
+ "--- Internal Links ---",
165
+ "",
166
+ "No internal links found.",
167
+ "Note: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.",
168
+ );
169
+ } else {
170
+ const concurrency = 5;
171
+ const results: LinkResult[] = [];
172
+ let i = 0;
173
+
174
+ while (i < links.length) {
175
+ const batch = links.slice(i, i + concurrency);
176
+ const settled = await Promise.all(batch.map((href) => checkLink(href)));
177
+ results.push(...settled);
178
+ i += concurrency;
179
+ }
180
+
181
+ output.push("", formatLinkResults(url, results));
182
+ }
183
+ }
184
+
185
+ return {
186
+ content: [{ type: "text", text: output.join("\n") }],
187
+ };
188
+ } catch (err) {
189
+ const msg = err instanceof Error ? err.message : String(err);
190
+ return {
191
+ content: [{ type: "text", text: `Error analyzing page: ${msg}` }],
192
+ };
193
+ }
194
+ }
@@ -0,0 +1,163 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { parseHead, getMeta } from "./metadata.js";
3
+ import { formatJsonLdType, validateJsonLd } from "./structured-data.js";
4
+ import { followRedirects, checkLink, extractInternalLinks } from "./links.js";
5
+
6
+ export async function analyzeBatch(
7
+ urls: string[],
8
+ options: { check_links?: boolean; user_agent?: string },
9
+ ): Promise<CallToolResult> {
10
+ const { check_links, user_agent } = options;
11
+ const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
12
+ const results: Array<{
13
+ url: string;
14
+ title: string;
15
+ description: string;
16
+ canonical: string;
17
+ jsonLd: string;
18
+ links: string;
19
+ issues: string[];
20
+ error?: string;
21
+ }> = [];
22
+
23
+ for (const pageUrl of urls) {
24
+ try {
25
+ const { chain, response: res } = await followRedirects(pageUrl, ua);
26
+ if (!res.ok) {
27
+ results.push({
28
+ url: pageUrl,
29
+ title: "-",
30
+ description: "-",
31
+ canonical: "-",
32
+ jsonLd: "-",
33
+ links: "-",
34
+ issues: [`HTTP ${res.status}`],
35
+ });
36
+ continue;
37
+ }
38
+ const html = await res.text();
39
+ const parsed = parseHead(html);
40
+ const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : pageUrl;
41
+
42
+ const issues: string[] = [];
43
+ if (!parsed.title) issues.push("no title");
44
+ if (!getMeta(parsed.meta, "description")) issues.push("no description");
45
+ else if ((getMeta(parsed.meta, "description") ?? "").length > 155) issues.push("description >155 chars");
46
+ if (!parsed.canonical) issues.push("no canonical");
47
+ if (parsed.jsonLd.length === 0) issues.push("no JSON-LD");
48
+ if (chain.length > 2) issues.push(`${chain.length - 1} redirects`);
49
+
50
+ // Quick link check
51
+ let linkSummary = "-";
52
+ if (check_links !== false) {
53
+ const origin = new URL(finalUrl).origin;
54
+ const links = extractInternalLinks(html, origin);
55
+ if (links.length > 0) {
56
+ const checked = await Promise.all(links.slice(0, 20).map((href) => checkLink(href)));
57
+ const broken = checked.filter((r) => r.error || (r.status && r.status >= 400)).length;
58
+ const redirected = checked.filter(
59
+ (r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400,
60
+ ).length;
61
+ const parts: string[] = [`${links.length} found`];
62
+ if (broken > 0) parts.push(`${broken} broken`);
63
+ if (redirected > 0) parts.push(`${redirected} redirected`);
64
+ if (broken === 0 && redirected === 0) parts.push("all OK");
65
+ linkSummary = parts.join(", ");
66
+ if (broken > 0) issues.push(`${broken} broken links`);
67
+ if (redirected > 0) issues.push(`${redirected} redirect chains`);
68
+ } else {
69
+ linkSummary = "0 (SPA?)";
70
+ }
71
+ }
72
+
73
+ const jsonLdTypes = parsed.jsonLd
74
+ .map((block) => {
75
+ if (block && typeof block === "object" && "@type" in block)
76
+ return formatJsonLdType((block as Record<string, unknown>)["@type"], ",");
77
+ return null;
78
+ })
79
+ .filter(Boolean);
80
+
81
+ // Run structured data validation for batch summary
82
+ let jsonLdSummary = "none";
83
+ if (parsed.jsonLd.length > 0) {
84
+ const { issues: valIssues } = validateJsonLd(parsed.jsonLd);
85
+ const requiredMissing = valIssues.filter((i) => i.level === "required").length;
86
+ if (jsonLdTypes.length > 0) {
87
+ jsonLdSummary = `${jsonLdTypes.join(", ")} (selected fields only)`;
88
+ if (requiredMissing > 0) {
89
+ jsonLdSummary += ` (${requiredMissing} required missing)`;
90
+ issues.push(`${requiredMissing} missing required fields in checked JSON-LD subset`);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Description with length
96
+ const descVal = getMeta(parsed.meta, "description");
97
+ let descSummary: string;
98
+ if (descVal) {
99
+ descSummary = descVal.length > 155 ? `yes (${descVal.length} chars ⚠)` : `yes (${descVal.length})`;
100
+ } else {
101
+ descSummary = "no";
102
+ }
103
+
104
+ results.push({
105
+ url: finalUrl,
106
+ title: parsed.title
107
+ ? parsed.title.length > 60
108
+ ? `${parsed.title.slice(0, parsed.title.lastIndexOf(" ", 60) > 20 ? parsed.title.lastIndexOf(" ", 60) : 60)}...`
109
+ : parsed.title
110
+ : "(missing)",
111
+ description: descSummary,
112
+ canonical: parsed.canonical ? "yes" : "no",
113
+ jsonLd: jsonLdSummary,
114
+ links: linkSummary,
115
+ issues,
116
+ });
117
+ } catch (err) {
118
+ results.push({
119
+ url: pageUrl,
120
+ title: "-",
121
+ description: "-",
122
+ canonical: "-",
123
+ jsonLd: "-",
124
+ links: "-",
125
+ issues: [err instanceof Error ? err.message : String(err)],
126
+ });
127
+ }
128
+ }
129
+
130
+ const lines: string[] = [`=== Batch Page Analysis (${results.length} URLs) ===`, ""];
131
+
132
+ for (const r of results) {
133
+ let label: string;
134
+ try {
135
+ const u = new URL(r.url);
136
+ const allSameHost = results.every((x) => {
137
+ try {
138
+ return new URL(x.url).hostname === u.hostname;
139
+ } catch {
140
+ return false;
141
+ }
142
+ });
143
+ label = allSameHost ? u.pathname : `${u.hostname}${u.pathname}`;
144
+ } catch {
145
+ label = r.url;
146
+ }
147
+ lines.push(label);
148
+ lines.push(` Title: ${r.title}`);
149
+ lines.push(` Description: ${r.description} Canonical: ${r.canonical} JSON-LD: ${r.jsonLd}`);
150
+ if (check_links !== false) lines.push(` Links: ${r.links}`);
151
+ if (r.issues.length > 0) {
152
+ lines.push(` Issues: ${r.issues.join(", ")}`);
153
+ } else {
154
+ lines.push(" Issues: none");
155
+ }
156
+ lines.push("");
157
+ }
158
+
159
+ const issueCount = results.reduce((sum, r) => sum + r.issues.length, 0);
160
+ lines.push(`Total: ${results.length} pages, ${issueCount} issues`);
161
+
162
+ return { content: [{ type: "text", text: lines.join("\n") }] };
163
+ }