glippy-mcp 0.1.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.
package/src/index.js ADDED
@@ -0,0 +1,3318 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Glippy MCP Server
5
+ *
6
+ * Exposes Glippy's GEO (Generative Engine Optimization) analysis as MCP tools
7
+ * so that AI models can check any domain's AI-readiness directly.
8
+ *
9
+ * Tools:
10
+ * - analyze_domain: Full GEO analysis of a domain (robots.txt, llms.txt, HTML, sitemap, etc.)
11
+ * - check_robots_txt: Check a domain's robots.txt for AI crawler access rules
12
+ * - check_llms_txt: Check if a domain has an llms.txt file
13
+ * - get_geo_summary: Get a concise summary of a domain's GEO score and top issues
14
+ * - compare_domains: Analyse multiple domains in parallel and compare scores side by side
15
+ * - analyze_sitemap: Fetch a sitemap XML, extract URLs, and analyse each page
16
+ * - analyze_urls: Analyse a list of specific URLs across any domains
17
+ * - export_report: Generate a styled Markdown or HTML report (matches extension export)
18
+ * - export_bulk_report: Generate a styled report for multi-domain, multi-URL, or sitemap analysis
19
+ *
20
+ * Transport: stdio (JSON-RPC over stdin/stdout)
21
+ */
22
+
23
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+ import { z } from "zod";
26
+ import { randomUUID } from "node:crypto";
27
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+
31
+ import {
32
+ checkGEO,
33
+ analyseHTML,
34
+ analyseRobotsTxt,
35
+ throttledFetchUrl,
36
+ parseSitemapUrls,
37
+ aggregatePageScores,
38
+ } from "./geo-checker.js";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // License validation
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const API_BASE =
45
+ process.env.GLIPPY_API_URL || "https://glippy-mcp-api.info-8cb.workers.dev";
46
+
47
+ /** Unique instance ID for this MCP server process (used for activation tracking). */
48
+ const INSTANCE_ID = randomUUID();
49
+
50
+ /** Cached license validation result. */
51
+ let licenseCache = null; // { valid, tier, validUntil }
52
+
53
+ /** Cached feature limits from license. */
54
+ let licensedFeatures = null;
55
+
56
+ /** Default features for trial/unlicensed users (Personal tier equivalent). */
57
+ const DEFAULT_FEATURES = {
58
+ maxSitemapUrls: 50000,
59
+ maxBatchUrls: 100,
60
+ compareDomains: true,
61
+ bulkExport: true,
62
+ };
63
+
64
+ /** Number of free trial runs before license is required. */
65
+ const FREE_TRIAL_RUNS = 11;
66
+
67
+ /** Get the path to the usage tracking file. */
68
+ function getUsageFilePath() {
69
+ const configDir = join(homedir(), ".glippy");
70
+ return join(configDir, "mcp-usage.json");
71
+ }
72
+
73
+ /** Read the current usage count from the tracking file. */
74
+ function getUsageCount() {
75
+ const filePath = getUsageFilePath();
76
+ try {
77
+ if (existsSync(filePath)) {
78
+ const data = JSON.parse(readFileSync(filePath, "utf8"));
79
+ return data.count || 0;
80
+ }
81
+ } catch {
82
+ // If file is corrupted, start fresh
83
+ }
84
+ return 0;
85
+ }
86
+
87
+ /** Increment and persist the usage count. Returns the new count. */
88
+ function incrementUsageCount() {
89
+ const filePath = getUsageFilePath();
90
+ const configDir = join(homedir(), ".glippy");
91
+
92
+ // Ensure config directory exists
93
+ if (!existsSync(configDir)) {
94
+ mkdirSync(configDir, { recursive: true });
95
+ }
96
+
97
+ const currentCount = getUsageCount();
98
+ const newCount = currentCount + 1;
99
+
100
+ writeFileSync(filePath, JSON.stringify({ count: newCount }, null, 2), "utf8");
101
+ return newCount;
102
+ }
103
+
104
+ /**
105
+ * Validate the license key against the Glippy API.
106
+ * Caches a successful result for 24 hours (server-controlled TTL).
107
+ * Returns { valid: true, tier } on success or { valid: false, error } on failure.
108
+ */
109
+ async function validateLicense() {
110
+ // Return cached result if still valid
111
+ if (licenseCache && new Date(licenseCache.validUntil) > new Date()) {
112
+ return { valid: true, tier: licenseCache.tier };
113
+ }
114
+
115
+ const licenseKey = process.env.GLIPPY_LICENSE_KEY;
116
+ if (!licenseKey) {
117
+ return {
118
+ valid: false,
119
+ error:
120
+ "No license key configured. Set GLIPPY_LICENSE_KEY in your environment. " +
121
+ "Get a key at https://www.glippy.dev/mcp",
122
+ };
123
+ }
124
+
125
+ try {
126
+ const res = await fetch(`${API_BASE}/api/mcp/verify-license`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ licenseKey, instanceId: INSTANCE_ID }),
130
+ });
131
+
132
+ const data = await res.json();
133
+
134
+ if (!data.valid) {
135
+ licenseCache = null;
136
+ licensedFeatures = null;
137
+ return {
138
+ valid: false,
139
+ error: data.error || "License validation failed",
140
+ };
141
+ }
142
+
143
+ // Cache the successful validation and features
144
+ licenseCache = {
145
+ valid: true,
146
+ tier: data.tier,
147
+ validUntil: data.validUntil,
148
+ };
149
+ licensedFeatures = data.features || DEFAULT_FEATURES;
150
+
151
+ console.error(`License validated (tier: ${data.tier}, valid until: ${data.validUntil})`);
152
+ console.error(`Features: ${JSON.stringify(licensedFeatures)}`);
153
+ return { valid: true, tier: data.tier, features: licensedFeatures };
154
+ } catch (err) {
155
+ // If the server is unreachable but we have a recent cache, allow a grace period
156
+ if (licenseCache && licenseCache.valid) {
157
+ console.error(
158
+ `License server unreachable, using cached validation (expires: ${licenseCache.validUntil})`
159
+ );
160
+ return { valid: true, tier: licenseCache.tier };
161
+ }
162
+
163
+ return {
164
+ valid: false,
165
+ error: `Could not reach license server: ${err.message}. Check your internet connection.`,
166
+ };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Wraps a tool handler with license validation.
172
+ * Allows FREE_TRIAL_RUNS free uses before requiring a license.
173
+ * Returns a license error to the AI model if the key is invalid and trial is exhausted.
174
+ */
175
+ function withLicense(handler) {
176
+ return async (args) => {
177
+ const license = await validateLicense();
178
+
179
+ // If license is valid, just run the handler
180
+ if (license.valid) {
181
+ return handler(args);
182
+ }
183
+
184
+ // No valid license - check if we're still within free trial
185
+ const usageCount = getUsageCount();
186
+
187
+ if (usageCount >= FREE_TRIAL_RUNS) {
188
+ // Trial exhausted
189
+ return {
190
+ content: [
191
+ {
192
+ type: "text",
193
+ text: `Trial expired: You've used all ${FREE_TRIAL_RUNS} free runs. ` +
194
+ `Set GLIPPY_LICENSE_KEY in your environment to continue. ` +
195
+ `Get a key at https://www.glippy.dev/mcp`,
196
+ },
197
+ ],
198
+ isError: true,
199
+ };
200
+ }
201
+
202
+ // Still within trial - increment counter and run
203
+ const newCount = incrementUsageCount();
204
+ const remaining = FREE_TRIAL_RUNS - newCount;
205
+
206
+ console.error(
207
+ `Trial run ${newCount}/${FREE_TRIAL_RUNS} (${remaining} remaining). ` +
208
+ `Get a license at https://www.glippy.dev/mcp`
209
+ );
210
+
211
+ return handler(args);
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Get the current feature limits (from license or defaults).
217
+ */
218
+ function getFeatures() {
219
+ return licensedFeatures || DEFAULT_FEATURES;
220
+ }
221
+
222
+ /**
223
+ * Wraps a tool handler with tier-based feature gating.
224
+ * First validates the license (with trial support), then checks if the required
225
+ * feature is enabled for the user's tier.
226
+ *
227
+ * @param {string} featureName - The feature to check (e.g., "compareDomains", "bulkExport")
228
+ * @param {string} upgradeMessage - Message to show if feature is not available
229
+ * @param {Function} handler - The tool handler function
230
+ */
231
+ function withTierFeature(featureName, upgradeMessage, handler) {
232
+ return withLicense(async (args) => {
233
+ const features = getFeatures();
234
+
235
+ // Check if feature is enabled
236
+ if (!features[featureName]) {
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text",
241
+ text: `${upgradeMessage}\n\nUpgrade your license at https://www.glippy.dev/mcp`,
242
+ },
243
+ ],
244
+ isError: true,
245
+ };
246
+ }
247
+
248
+ return handler(args, features);
249
+ });
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Helpers
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /** Convert a numeric score (0-100) to a letter grade. */
257
+ function scoreToGrade(score) {
258
+ if (score >= 90) return "A+";
259
+ if (score >= 80) return "A";
260
+ if (score >= 70) return "B";
261
+ if (score >= 60) return "C";
262
+ if (score >= 50) return "D";
263
+ return "F";
264
+ }
265
+
266
+ /** Format a category result into a readable string. */
267
+ function formatCategory(cat) {
268
+ const grade = scoreToGrade(cat.score);
269
+ let text = `[${grade}] ${cat.category}: ${cat.score}%`;
270
+ if (cat.checks) {
271
+ const fails = cat.checks.filter((c) => c.status === "fail");
272
+ const warns = cat.checks.filter((c) => c.status === "warn");
273
+ if (fails.length > 0) {
274
+ text += `\n Issues: ${fails.map((f) => f.label).join("; ")}`;
275
+ }
276
+ if (warns.length > 0) {
277
+ text += `\n Warnings: ${warns.map((w) => w.label).join("; ")}`;
278
+ }
279
+ }
280
+ return text;
281
+ }
282
+
283
+ /** Escape HTML special characters to prevent XSS. */
284
+ function escapeHTML(str) {
285
+ if (str == null) return "";
286
+ return String(str)
287
+ .replace(/&/g, "&")
288
+ .replace(/</g, "&lt;")
289
+ .replace(/>/g, "&gt;")
290
+ .replace(/"/g, "&quot;")
291
+ .replace(/'/g, "&#039;");
292
+ }
293
+
294
+ /** Strip HTML tags for plain-text output. */
295
+ function htmlToPlainText(str) {
296
+ if (str == null) return "";
297
+ return String(str).replace(/<[^>]*>/g, "");
298
+ }
299
+
300
+ /** Map numeric score to a colour hex string. */
301
+ function getScoreColor(score) {
302
+ if (score >= 80) return "#34d399";
303
+ if (score >= 60) return "#fbbf24";
304
+ if (score >= 40) return "#fb923c";
305
+ return "#f87171";
306
+ }
307
+
308
+ /** Map numeric score to a grade object { letter, label }. */
309
+ function getGrade(score) {
310
+ if (score >= 90) return { letter: "A+", label: "Excellent agent readiness!" };
311
+ if (score >= 80) return { letter: "A", label: "Great! Minor improvements possible." };
312
+ if (score >= 70) return { letter: "B", label: "Good, but room to improve." };
313
+ if (score >= 60) return { letter: "C", label: "Needs work for agent readiness." };
314
+ if (score >= 40) return { letter: "D", label: "Significant issues found." };
315
+ return { letter: "F", label: "Major GEO gaps — let's fix this!" };
316
+ }
317
+
318
+ /** Category → emoji mapping (matches extension). */
319
+ const CATEGORY_ICONS = {
320
+ "Structured Data & Schema": "\u{1F9EC}",
321
+ "Semantic HTML": "\u{1F3D7}\uFE0F",
322
+ "Accessibility for Agents": "\u267F",
323
+ "Internal Linking": "\u{1F517}",
324
+ "Meta & Discoverability": "\u{1F50D}",
325
+ "Machine Readability": "\u{1F916}",
326
+ "Entity & Authority": "\u{1F3DB}\uFE0F",
327
+ "Citability & Answer-Readiness": "\u{1F4AC}",
328
+ "Performance & Crawlability": "\u26A1",
329
+ "Agent Interactivity": "\u{1F50C}",
330
+ };
331
+
332
+ /** Categories whose issues are always low priority. */
333
+ const LOW_PRIORITY_CATEGORIES = [
334
+ "Performance & Crawlability",
335
+ "Agent Interactivity",
336
+ ];
337
+
338
+ /** Format robots.txt crawler analysis into a readable string. */
339
+ function formatCrawlerAccess(blocksCrawlers) {
340
+ const lines = [];
341
+ for (const [crawler, blocked] of Object.entries(blocksCrawlers)) {
342
+ lines.push(` ${crawler}: ${blocked ? "BLOCKED" : "allowed"}`);
343
+ }
344
+ return lines.join("\n");
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Report generators (Markdown & HTML)
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Generate a Markdown report from a GEO analysis result.
353
+ *
354
+ * @param {object} result - The full checkGEO result object.
355
+ * @param {boolean} fullExport - If true, include every category and check.
356
+ * If false (default), output recommendations only (fail/warn checks).
357
+ * @returns {string} Markdown string.
358
+ */
359
+ function generateMarkdownReport(result, fullExport = false) {
360
+ const analysis = result.homepage?.analysis;
361
+ const score = analysis?.overallScore ?? 0;
362
+ const grade = getGrade(score);
363
+ const url = `https://${result.domain}`;
364
+
365
+ let md = `# Glippy GEO Agent-Readiness Report\n\n`;
366
+ md += `**URL:** ${url}\n`;
367
+ md += `**Date:** ${result.timestamp}\n`;
368
+ md += `**Overall Score:** ${score}/100 (Grade: ${grade.letter})\n`;
369
+ md += `\n---\n\n`;
370
+
371
+ const categories = analysis?.categories || [];
372
+
373
+ if (fullExport) {
374
+ // Full export: all categories and checks
375
+ for (const cat of categories) {
376
+ const icon = CATEGORY_ICONS[cat.category] || "\u{1F4CB}";
377
+ md += `## ${icon} ${cat.category} (${cat.score}/100)\n\n`;
378
+
379
+ for (const check of cat.checks || []) {
380
+ const emoji =
381
+ check.status === "pass"
382
+ ? "\u2705"
383
+ : check.status === "fail"
384
+ ? "\u274C"
385
+ : check.status === "warn"
386
+ ? "\u26A0\uFE0F"
387
+ : "\u2139\uFE0F";
388
+ md += `- ${emoji} **${htmlToPlainText(check.label)}**`;
389
+ if (check.detail) {
390
+ md += `\n - ${htmlToPlainText(check.detail)}`;
391
+ }
392
+ if (check.found && check.found.length > 0) {
393
+ md += `\n - Found:`;
394
+ for (const item of check.found) {
395
+ md += `\n - ${htmlToPlainText(item)}`;
396
+ }
397
+ }
398
+ md += `\n`;
399
+ }
400
+ md += `\n`;
401
+ }
402
+
403
+ // robots.txt summary
404
+ md += `## robots.txt\n\n`;
405
+ md += `- **Exists:** ${result.robotsTxt?.exists ? "Yes" : "No"}\n`;
406
+ if (result.robotsTxt?.exists) {
407
+ const blocked = Object.entries(result.robotsTxt.blocksCrawlers || {})
408
+ .filter(([, b]) => b)
409
+ .map(([name]) => name);
410
+ const allowed = Object.entries(result.robotsTxt.blocksCrawlers || {})
411
+ .filter(([, b]) => !b)
412
+ .map(([name]) => name);
413
+ if (blocked.length > 0) md += `- **Blocked crawlers:** ${blocked.join(", ")}\n`;
414
+ if (allowed.length > 0) md += `- **Allowed crawlers:** ${allowed.join(", ")}\n`;
415
+ }
416
+ md += `\n`;
417
+
418
+ // llms.txt summary
419
+ md += `## llms.txt\n\n`;
420
+ md += `- **Exists:** ${result.llmsTxt?.exists ? "Yes" : "No"}\n`;
421
+ md += `- *Note: llms.txt is not currently supported by major AI models or crawlers — having one cannot hurt but is not a meaningful optimization.*\n`;
422
+ md += `\n`;
423
+
424
+ // Sitemap summary
425
+ md += `## Sitemap\n\n`;
426
+ md += `- **Exists:** ${result.sitemap?.exists ? "Yes" : "No"}\n`;
427
+ if (result.sitemap?.urlsDiscovered > 0) {
428
+ md += `- **URLs discovered:** ${result.sitemap.urlsDiscovered}\n`;
429
+ }
430
+ md += `\n`;
431
+ } else {
432
+ // Recommendations only: fail and warn checks
433
+ md += `## Recommended Changes\n\n`;
434
+
435
+ const recommendations = [];
436
+ for (const cat of categories) {
437
+ for (const check of cat.checks || []) {
438
+ if (check.status === "fail" || check.status === "warn") {
439
+ const isLow = LOW_PRIORITY_CATEGORIES.includes(cat.category);
440
+ const priority = isLow ? "Low" : check.status === "fail" ? "High" : "Medium";
441
+ recommendations.push({
442
+ priority,
443
+ category: cat.category,
444
+ label: htmlToPlainText(check.label),
445
+ detail: check.detail ? htmlToPlainText(check.detail) : null,
446
+ });
447
+ }
448
+ }
449
+ }
450
+
451
+ if (recommendations.length === 0) {
452
+ md += `\u2705 **No issues found!** This page passes all checks.\n`;
453
+ } else {
454
+ const high = recommendations.filter((r) => r.priority === "High");
455
+ const medium = recommendations.filter((r) => r.priority === "Medium");
456
+ const low = recommendations.filter((r) => r.priority === "Low");
457
+
458
+ if (high.length > 0) {
459
+ md += `### \u{1F534} High Priority\n\n`;
460
+ for (const rec of high) {
461
+ md += `- **${rec.category}:** ${rec.label}`;
462
+ if (rec.detail) md += `\n - ${rec.detail}`;
463
+ md += `\n`;
464
+ }
465
+ md += `\n`;
466
+ }
467
+
468
+ if (medium.length > 0) {
469
+ md += `### \u{1F7E1} Medium Priority\n\n`;
470
+ for (const rec of medium) {
471
+ md += `- **${rec.category}:** ${rec.label}`;
472
+ if (rec.detail) md += `\n - ${rec.detail}`;
473
+ md += `\n`;
474
+ }
475
+ md += `\n`;
476
+ }
477
+
478
+ if (low.length > 0) {
479
+ md += `### \u{1F535} Low Priority\n\n`;
480
+ for (const rec of low) {
481
+ md += `- **${rec.category}:** ${rec.label}`;
482
+ if (rec.detail) md += `\n - ${rec.detail}`;
483
+ md += `\n`;
484
+ }
485
+ md += `\n`;
486
+ }
487
+ }
488
+ }
489
+
490
+ md += `---\n\n`;
491
+ md += `*Generated by [Glippy](https://www.glippy.dev) — GEO Agent-Readiness Checker*\n`;
492
+ return md;
493
+ }
494
+
495
+ /**
496
+ * Generate a standalone HTML report from a GEO analysis result.
497
+ * Matches the browser extension's export styling (dark/light toggle, score
498
+ * ring, category accordion, recommendations table).
499
+ *
500
+ * @param {object} result - The full checkGEO result object.
501
+ * @returns {string} Complete HTML document string.
502
+ */
503
+ function generateHTMLReport(result) {
504
+ const analysis = result.homepage?.analysis;
505
+ const score = analysis?.overallScore ?? 0;
506
+ const color = getScoreColor(score);
507
+ const grade = getGrade(score);
508
+ const url = `https://${result.domain}`;
509
+ const circumference = 2 * Math.PI * 34;
510
+ const offset = circumference - (score / 100) * circumference;
511
+
512
+ let origin = "";
513
+ try {
514
+ origin = new URL(url).origin;
515
+ } catch {}
516
+
517
+ const STATUS_CLASSES = { pass: "pass", warn: "warn", fail: "fail", info: "info" };
518
+ const STATUS_ICONS_HTML = { pass: "\u2713", warn: "!", fail: "\u2715", info: "i" };
519
+
520
+ function linkifyText(text) {
521
+ if (!text) return "";
522
+ let r = escapeHTML(htmlToPlainText(text));
523
+ r = r.replace(
524
+ /\b(robots\.txt)\b/gi,
525
+ `<a href="${origin}/robots.txt" target="_blank" rel="noopener">$1</a>`
526
+ );
527
+ r = r.replace(
528
+ /\b(llms-full\.txt)\b/gi,
529
+ `<a href="${origin}/llms-full.txt" target="_blank" rel="noopener">$1</a>`
530
+ );
531
+ r = r.replace(
532
+ /\b(llms\.txt)\b/gi,
533
+ `<a href="${origin}/llms.txt" target="_blank" rel="noopener">$1</a>`
534
+ );
535
+ r = r.replace(
536
+ /\b(sitemap\.xml)\b/gi,
537
+ `<a href="${origin}/sitemap.xml" target="_blank" rel="noopener">$1</a>`
538
+ );
539
+ r = r.replace(
540
+ /(\/?\.well-known\/ucp)\b/g,
541
+ `<a href="${origin}/.well-known/ucp" target="_blank" rel="noopener">$1</a>`
542
+ );
543
+ return r;
544
+ }
545
+
546
+ // Build categories HTML
547
+ const categories = analysis?.categories || [];
548
+ let categoriesHTML = "";
549
+ categories.forEach((cat, idx) => {
550
+ const catColor = getScoreColor(cat.score);
551
+ const icon = CATEGORY_ICONS[cat.category] || "\u{1F4CB}";
552
+
553
+ let checksHTML = "";
554
+ for (const check of cat.checks || []) {
555
+ const statusIcon = STATUS_ICONS_HTML[check.status] || "\u2022";
556
+ let foundHTML = "";
557
+ if (check.found && check.found.length > 0) {
558
+ foundHTML = `<ul class="check-found">${check.found.map((item) => `<li>${linkifyText(item)}</li>`).join("")}</ul>`;
559
+ }
560
+ checksHTML += `
561
+ <div class="check-item">
562
+ <div class="check-status ${STATUS_CLASSES[check.status] || ""}">${statusIcon}</div>
563
+ <div class="check-content">
564
+ <div class="check-label">${linkifyText(check.label)}</div>
565
+ ${check.detail ? `<div class="check-detail">${linkifyText(check.detail)}</div>` : ""}
566
+ ${foundHTML}
567
+ </div>
568
+ </div>`;
569
+ }
570
+
571
+ categoriesHTML += `
572
+ <div class="category" data-idx="${idx}">
573
+ <div class="category-header" onclick="this.parentElement.classList.toggle('open')">
574
+ <span class="category-icon">${icon}</span>
575
+ <span class="category-name">${escapeHTML(cat.category)}</span>
576
+ <span class="category-score" style="color: ${catColor}">${cat.score}</span>
577
+ <div class="category-bar">
578
+ <div class="category-bar-fill" style="width: ${cat.score}%; background: ${catColor};"></div>
579
+ </div>
580
+ <span class="category-chevron">\u203A</span>
581
+ </div>
582
+ <div class="category-details">
583
+ ${checksHTML}
584
+ </div>
585
+ </div>`;
586
+ });
587
+
588
+ // Build recommendations
589
+ const recommendations = [];
590
+ for (const cat of categories) {
591
+ for (const check of cat.checks || []) {
592
+ if (check.status === "fail" || check.status === "warn") {
593
+ recommendations.push({
594
+ category: cat.category,
595
+ status: check.status,
596
+ label: htmlToPlainText(check.label),
597
+ detail: check.detail ? htmlToPlainText(check.detail) : null,
598
+ });
599
+ }
600
+ }
601
+ }
602
+
603
+ let recommendationsHTML = "";
604
+ if (recommendations.length > 0) {
605
+ const failCount = recommendations.filter((r) => r.status === "fail").length;
606
+ const warnCount = recommendations.filter((r) => r.status === "warn").length;
607
+
608
+ const recommendationsText = recommendations
609
+ .map((rec) => {
610
+ const isLow = LOW_PRIORITY_CATEGORIES.includes(rec.category);
611
+ const priority = isLow ? "[LOW]" : rec.status === "fail" ? "[HIGH]" : "[MEDIUM]";
612
+ const detail = rec.detail ? ` - ${rec.detail}` : "";
613
+ return `${priority} ${rec.category}: ${rec.label}${detail}`;
614
+ })
615
+ .join("\n");
616
+
617
+ const rowsHTML = recommendations
618
+ .map((rec) => {
619
+ const isLow = LOW_PRIORITY_CATEGORIES.includes(rec.category);
620
+ const priorityClass = isLow ? "low" : rec.status;
621
+ const priorityLabel = isLow ? "Low" : rec.status === "fail" ? "High" : "Medium";
622
+ return `
623
+ <tr class="${priorityClass}">
624
+ <td><span class="priority-badge ${priorityClass}">${priorityLabel}</span></td>
625
+ <td>${escapeHTML(rec.category)}</td>
626
+ <td>${linkifyText(rec.label)}</td>
627
+ <td>${rec.detail ? linkifyText(rec.detail) : "-"}</td>
628
+ </tr>`;
629
+ })
630
+ .join("");
631
+
632
+ recommendationsHTML = `
633
+ <div class="recommendations">
634
+ <div class="recommendations-header">
635
+ <div>
636
+ <h2>Recommended Changes</h2>
637
+ <p class="recommendations-summary">${failCount} critical issue${failCount !== 1 ? "s" : ""}, ${warnCount} warning${warnCount !== 1 ? "s" : ""}</p>
638
+ </div>
639
+ <button class="copy-btn" onclick="copyRecommendations(this)" data-text="${escapeHTML(recommendationsText).replace(/"/g, "&quot;")}">
640
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
641
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
642
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
643
+ </svg>
644
+ Copy
645
+ </button>
646
+ </div>
647
+ <table class="recommendations-table">
648
+ <thead>
649
+ <tr><th>Priority</th><th>Category</th><th>Issue</th><th>Recommendation</th></tr>
650
+ </thead>
651
+ <tbody>${rowsHTML}</tbody>
652
+ </table>
653
+ </div>`;
654
+ } else {
655
+ recommendationsHTML = `
656
+ <div class="recommendations success">
657
+ <h2>\u2713 No Issues Found</h2>
658
+ <p>Great job! This page passes all checks.</p>
659
+ </div>`;
660
+ }
661
+
662
+ return `<!DOCTYPE html>
663
+ <html lang="en" data-theme="dark">
664
+ <head>
665
+ <meta charset="UTF-8">
666
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
667
+ <title>Glippy Report - ${escapeHTML(result.domain)}</title>
668
+ <style>
669
+ :root {
670
+ --bg-primary: #181b24;
671
+ --bg-secondary: #1e222d;
672
+ --bg-tertiary: #252a37;
673
+ --text-primary: #e4e6eb;
674
+ --text-secondary: #b0b3b8;
675
+ --text-muted: #6b7280;
676
+ --accent-purple: #a78bfa;
677
+ --accent-green: #34d399;
678
+ --status-pass: #34d399;
679
+ --status-warn: #fbbf24;
680
+ --status-fail: #f87171;
681
+ --status-info: #60a5fa;
682
+ --border-color: #2d3341;
683
+ }
684
+ [data-theme="light"] {
685
+ --bg-primary: #f8f9fc;
686
+ --bg-secondary: #ffffff;
687
+ --bg-tertiary: #f0f2f5;
688
+ --text-primary: #1a1d26;
689
+ --text-secondary: #5a5f70;
690
+ --text-muted: #8a8f9f;
691
+ --accent-purple: #7c3aed;
692
+ --accent-green: #16a34a;
693
+ --status-pass: #16a34a;
694
+ --status-warn: #ca8a04;
695
+ --status-fail: #dc2626;
696
+ --status-info: #2563eb;
697
+ --border-color: #dce0e8;
698
+ }
699
+ * { box-sizing: border-box; margin: 0; padding: 0; }
700
+ body {
701
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
702
+ background: var(--bg-primary);
703
+ color: var(--text-primary);
704
+ line-height: 1.5;
705
+ padding: 24px;
706
+ max-width: 800px;
707
+ margin: 0 auto;
708
+ }
709
+ @media (min-width: 1200px) { body { max-width: 1400px; } }
710
+ .header {
711
+ text-align: center;
712
+ margin-bottom: 24px;
713
+ padding-bottom: 16px;
714
+ border-bottom: 1px solid var(--border-color);
715
+ position: relative;
716
+ }
717
+ .header h1 {
718
+ font-size: 24px;
719
+ font-weight: 600;
720
+ background: linear-gradient(135deg, var(--accent-purple), var(--accent-green));
721
+ -webkit-background-clip: text;
722
+ -webkit-text-fill-color: transparent;
723
+ background-clip: text;
724
+ }
725
+ .header .meta { color: var(--text-muted); font-size: 13px; margin-top: 8px; }
726
+ .header .url { color: var(--text-secondary); font-size: 14px; word-break: break-all; margin-top: 4px; }
727
+ .theme-toggle {
728
+ position: absolute; top: 0; right: 0;
729
+ width: 36px; height: 36px;
730
+ border: 1px solid var(--border-color);
731
+ border-radius: 8px;
732
+ background: var(--bg-secondary);
733
+ color: var(--text-secondary);
734
+ cursor: pointer;
735
+ display: flex; align-items: center; justify-content: center;
736
+ transition: all 0.2s;
737
+ }
738
+ .theme-toggle:hover { background: var(--bg-tertiary); border-color: var(--accent-purple); color: var(--accent-purple); }
739
+ .theme-toggle svg { width: 18px; height: 18px; }
740
+ .theme-toggle .icon-sun { display: none; }
741
+ .theme-toggle .icon-moon { display: none; }
742
+ [data-theme="dark"] .theme-toggle .icon-sun { display: block; }
743
+ [data-theme="light"] .theme-toggle .icon-moon { display: block; }
744
+ .score-section {
745
+ display: flex; align-items: center; gap: 20px;
746
+ background: var(--bg-secondary);
747
+ border-radius: 12px; padding: 20px; margin-bottom: 20px;
748
+ }
749
+ .score-ring { position: relative; width: 100px; height: 100px; flex-shrink: 0; }
750
+ .score-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
751
+ .score-ring-bg { fill: none; stroke: var(--bg-tertiary); stroke-width: 6; }
752
+ .score-ring-fill { fill: none; stroke-width: 6; stroke-linecap: round; transition: stroke-dashoffset 0.5s ease; }
753
+ .score-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 28px; font-weight: 700; }
754
+ .score-meta { flex: 1; }
755
+ .score-grade { font-size: 20px; font-weight: 600; }
756
+ .score-summary { color: var(--text-secondary); font-size: 14px; }
757
+ .main-content { display: flex; flex-direction: column; gap: 24px; }
758
+ @media (min-width: 1200px) {
759
+ .main-content { flex-direction: row; align-items: flex-start; }
760
+ .main-content > .categories { flex: 1; min-width: 0; }
761
+ .main-content > .recommendations { flex: 1; min-width: 0; margin-top: 0; position: sticky; top: 24px; max-height: calc(100vh - 48px); overflow-y: auto; }
762
+ }
763
+ .categories { display: flex; flex-direction: column; gap: 8px; }
764
+ .category { background: var(--bg-secondary); border-radius: 10px; overflow: hidden; }
765
+ .category-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; cursor: pointer; user-select: none; transition: background 0.15s; }
766
+ .category-header:hover { background: var(--bg-tertiary); }
767
+ .category-icon { font-size: 18px; }
768
+ .category-name { font-weight: 500; font-size: 14px; flex: 1; }
769
+ .category-score { font-weight: 600; font-size: 14px; min-width: 28px; text-align: right; }
770
+ .category-bar { width: 60px; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; }
771
+ .category-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease; }
772
+ .category-chevron { color: var(--text-muted); font-size: 16px; transition: transform 0.2s; }
773
+ .category.open .category-chevron { transform: rotate(90deg); }
774
+ .category-details { display: none; padding: 0 16px 14px 16px; border-top: 1px solid var(--border-color); }
775
+ .category.open .category-details { display: block; }
776
+ .check-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border-color); }
777
+ .check-item:last-child { border-bottom: none; }
778
+ .check-status { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
779
+ .check-status.pass { background: rgba(52,211,153,0.15); color: var(--status-pass); }
780
+ .check-status.warn { background: rgba(251,191,36,0.15); color: var(--status-warn); }
781
+ .check-status.fail { background: rgba(248,113,113,0.15); color: var(--status-fail); }
782
+ .check-status.info { background: rgba(96,165,250,0.15); color: var(--status-info); }
783
+ .check-content { flex: 1; min-width: 0; }
784
+ .check-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
785
+ .check-detail { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
786
+ .check-found { margin-top: 6px; padding-left: 16px; font-size: 12px; color: var(--text-secondary); }
787
+ .check-found li { margin: 2px 0; }
788
+ .footer { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color); text-align: center; color: var(--text-muted); font-size: 12px; }
789
+ .footer a { color: var(--accent-purple); text-decoration: none; }
790
+ .footer a:hover { text-decoration: underline; }
791
+ .recommendations { margin-top: 24px; background: var(--bg-secondary); border-radius: 12px; padding: 20px; }
792
+ .recommendations-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
793
+ .recommendations h2 { font-size: 18px; font-weight: 600; margin-bottom: 4px; color: var(--text-primary); }
794
+ .copy-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-secondary); font-size: 13px; cursor: pointer; transition: all 0.15s; }
795
+ .copy-btn:hover { background: var(--bg-primary); color: var(--text-primary); border-color: var(--accent-purple); }
796
+ .copy-btn.copied { background: rgba(52,211,153,0.15); border-color: var(--status-pass); color: var(--status-pass); }
797
+ .recommendations.success { text-align: center; color: var(--status-pass); }
798
+ .recommendations.success p { color: var(--text-secondary); }
799
+ .recommendations-summary { color: var(--text-muted); font-size: 13px; margin-bottom: 16px; }
800
+ .recommendations-table { width: 100%; border-collapse: collapse; font-size: 13px; }
801
+ .recommendations-table th, .recommendations-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border-color); }
802
+ .recommendations-table th { font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
803
+ .recommendations-table tr.fail { background: rgba(248,113,113,0.05); }
804
+ .recommendations-table tr.warn { background: rgba(251,191,36,0.03); }
805
+ .recommendations-table tr.low { background: rgba(148,163,184,0.03); }
806
+ .priority-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
807
+ .priority-badge.fail { background: rgba(248,113,113,0.15); color: var(--status-fail); }
808
+ .priority-badge.warn { background: rgba(251,191,36,0.15); color: var(--status-warn); }
809
+ .priority-badge.low { background: rgba(148,163,184,0.15); color: var(--text-muted); }
810
+ .recommendations-table a, .check-content a { color: var(--accent-purple); text-decoration: none; }
811
+ .recommendations-table a:hover, .check-content a:hover { text-decoration: underline; }
812
+ @media print {
813
+ :root { --bg-primary:#fff;--bg-secondary:#fff;--bg-tertiary:#f5f5f5;--text-primary:#1a1a1a;--text-secondary:#4a4a4a;--text-muted:#6a6a6a;--border-color:#e0e0e0; }
814
+ body { background: white; color: #1a1a1a; }
815
+ .category-details { display: block !important; }
816
+ .category-header { cursor: default; }
817
+ .category-header:hover { background: transparent; }
818
+ .theme-toggle, .copy-btn { display: none; }
819
+ }
820
+ </style>
821
+ </head>
822
+ <body>
823
+ <div class="header">
824
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode">
825
+ <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
826
+ <circle cx="12" cy="12" r="5"/>
827
+ <line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
828
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
829
+ <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
830
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
831
+ </svg>
832
+ <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
833
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
834
+ </svg>
835
+ </button>
836
+ <h1>Glippy GEO Agent-Readiness Report</h1>
837
+ <div class="url">${escapeHTML(url)}</div>
838
+ <div class="meta">Generated ${escapeHTML(result.timestamp)}</div>
839
+ </div>
840
+
841
+ <div class="score-section">
842
+ <div class="score-ring">
843
+ <svg viewBox="0 0 80 80">
844
+ <circle class="score-ring-bg" cx="40" cy="40" r="34"/>
845
+ <circle class="score-ring-fill" cx="40" cy="40" r="34"
846
+ stroke="${color}"
847
+ stroke-dasharray="${circumference}"
848
+ stroke-dashoffset="${offset}"/>
849
+ </svg>
850
+ <div class="score-text" style="color: ${color}">${score}</div>
851
+ </div>
852
+ <div class="score-meta">
853
+ <div class="score-grade" style="color: ${color}">Grade: ${escapeHTML(grade.letter)}</div>
854
+ <div class="score-summary">${escapeHTML(grade.label)}</div>
855
+ </div>
856
+ </div>
857
+
858
+ <div class="main-content">
859
+ <div class="categories">${categoriesHTML}</div>
860
+ ${recommendationsHTML}
861
+ </div>
862
+
863
+ <div class="footer">
864
+ Generated by <a href="https://www.glippy.dev" target="_blank" rel="noopener">Glippy</a> — GEO Agent-Readiness Checker
865
+ </div>
866
+ <script>
867
+ function copyRecommendations(btn) {
868
+ const text = btn.dataset.text;
869
+ navigator.clipboard.writeText(text).then(() => {
870
+ const orig = btn.innerHTML;
871
+ btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Copied!';
872
+ btn.classList.add('copied');
873
+ setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('copied'); }, 2000);
874
+ });
875
+ }
876
+ function toggleTheme() {
877
+ const html = document.documentElement;
878
+ const cur = html.getAttribute('data-theme');
879
+ const next = cur === 'dark' ? 'light' : 'dark';
880
+ html.setAttribute('data-theme', next);
881
+ localStorage.setItem('glippy-report-theme', next);
882
+ }
883
+ (function() {
884
+ const saved = localStorage.getItem('glippy-report-theme');
885
+ if (saved) { document.documentElement.setAttribute('data-theme', saved); }
886
+ else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
887
+ document.documentElement.setAttribute('data-theme', 'light');
888
+ }
889
+ })();
890
+ </script>
891
+ </body>
892
+ </html>`;
893
+ }
894
+
895
+ // ---------------------------------------------------------------------------
896
+ // Bulk report generators (multi-domain comparison & multi-URL/sitemap)
897
+ // ---------------------------------------------------------------------------
898
+
899
+ /**
900
+ * Normalised entry for a single domain in a comparison report.
901
+ * Built from the raw checkGEO result by the export_bulk_report handler.
902
+ *
903
+ * @typedef {object} DomainEntry
904
+ * @property {string} domain
905
+ * @property {number|null} score
906
+ * @property {string} grade
907
+ * @property {object[]} categories
908
+ * @property {boolean} robotsTxt
909
+ * @property {boolean} llmsTxt
910
+ * @property {boolean} sitemap
911
+ * @property {string[]} blockedCrawlers
912
+ * @property {string|null} error
913
+ */
914
+
915
+ /**
916
+ * Generate a Markdown report for a multi-domain comparison.
917
+ *
918
+ * @param {DomainEntry[]} entries
919
+ * @returns {string}
920
+ */
921
+ function generateComparisonMarkdownReport(entries) {
922
+ const scored = entries.filter((e) => !e.error && e.score !== null);
923
+ const ranked = [...scored].sort((a, b) => b.score - a.score);
924
+ const errored = entries.filter((e) => e.error);
925
+
926
+ let md = `# Glippy GEO Bulk Comparison Report\n\n`;
927
+ md += `**Domains analysed:** ${entries.length}\n`;
928
+ md += `**Date:** ${new Date().toISOString()}\n`;
929
+ if (scored.length > 0) {
930
+ const avg = Math.round(scored.reduce((s, e) => s + e.score, 0) / scored.length);
931
+ md += `**Average score:** ${avg}/100 (${getGrade(avg).letter})\n`;
932
+ }
933
+ md += `\n---\n\n`;
934
+
935
+ // Rankings
936
+ md += `## Rankings\n\n`;
937
+ for (let i = 0; i < ranked.length; i++) {
938
+ const e = ranked[i];
939
+ const g = getGrade(e.score);
940
+ md += `${i + 1}. **${e.domain}** — ${e.score}/100 (${g.letter})\n`;
941
+ }
942
+ if (errored.length > 0) {
943
+ md += `\n`;
944
+ for (const e of errored) {
945
+ md += `- **${e.domain}** — Error: ${e.error}\n`;
946
+ }
947
+ }
948
+ md += `\n`;
949
+
950
+ // Category comparison table
951
+ if (scored.length >= 2) {
952
+ const catNames = [
953
+ ...new Set(scored.flatMap((e) => e.categories.map((c) => c.category))),
954
+ ];
955
+ md += `## Category Comparison\n\n`;
956
+ md += `| Category | ${scored.map((e) => e.domain).join(" | ")} |\n`;
957
+ md += `|---|${scored.map(() => "---").join("|")}|\n`;
958
+ for (const catName of catNames) {
959
+ const scores = scored.map((e) => {
960
+ const cat = e.categories.find((c) => c.category === catName);
961
+ return cat ? `${cat.score}%` : "\u2014";
962
+ });
963
+ md += `| ${catName} | ${scores.join(" | ")} |\n`;
964
+ }
965
+ md += `| **Overall** | ${scored.map((e) => `**${e.score}%**`).join(" | ")} |\n`;
966
+ md += `\n`;
967
+ }
968
+
969
+ // Quick facts
970
+ md += `## Quick Facts\n\n`;
971
+ md += `| | ${scored.map((e) => e.domain).join(" | ")} |\n`;
972
+ md += `|---|${scored.map(() => "---").join("|")}|\n`;
973
+ md += `| robots.txt | ${scored.map((e) => (e.robotsTxt ? "Yes" : "No")).join(" | ")} |\n`;
974
+ md += `| llms.txt | ${scored.map((e) => (e.llmsTxt ? "Yes" : "No")).join(" | ")} |\n`;
975
+ md += `| Sitemap | ${scored.map((e) => (e.sitemap ? "Yes" : "No")).join(" | ")} |\n`;
976
+ md += `| AI crawlers blocked | ${scored.map((e) => (e.blockedCrawlers.length > 0 ? e.blockedCrawlers.join(", ") : "None")).join(" | ")} |\n`;
977
+ md += `\n`;
978
+
979
+ // Per-domain recommendations
980
+ md += `## Recommendations by Domain\n\n`;
981
+ for (const entry of scored) {
982
+ const recommendations = [];
983
+ for (const cat of entry.categories) {
984
+ for (const check of cat.checks || []) {
985
+ if (check.status === "fail" || check.status === "warn") {
986
+ const isLow = LOW_PRIORITY_CATEGORIES.includes(cat.category);
987
+ const priority = isLow ? "Low" : check.status === "fail" ? "High" : "Medium";
988
+ recommendations.push({
989
+ priority,
990
+ category: cat.category,
991
+ label: htmlToPlainText(check.label),
992
+ detail: check.detail ? htmlToPlainText(check.detail) : null,
993
+ });
994
+ }
995
+ }
996
+ }
997
+ const g = getGrade(entry.score);
998
+ md += `### ${entry.domain} (${entry.score}% \u2014 ${g.letter})\n\n`;
999
+ if (recommendations.length === 0) {
1000
+ md += `\u2705 No issues found.\n\n`;
1001
+ } else {
1002
+ for (const rec of recommendations) {
1003
+ const badge = rec.priority === "High" ? "\u{1F534}" : rec.priority === "Medium" ? "\u{1F7E1}" : "\u{1F535}";
1004
+ md += `- ${badge} **${rec.category}:** ${rec.label}`;
1005
+ if (rec.detail) md += `\n - ${rec.detail}`;
1006
+ md += `\n`;
1007
+ }
1008
+ md += `\n`;
1009
+ }
1010
+ }
1011
+
1012
+ md += `---\n\n`;
1013
+ md += `*Generated by [Glippy](https://www.glippy.dev) \u2014 GEO Agent-Readiness Checker*\n`;
1014
+ return md;
1015
+ }
1016
+
1017
+ /**
1018
+ * Generate a Markdown report for multi-URL / sitemap analysis.
1019
+ *
1020
+ * @param {string} title - Report title (e.g. sitemap URL or "URL Analysis").
1021
+ * @param {object[]} pageResults - Per-URL analysis results from analyseUrls.
1022
+ * @param {object} aggregated - Output of aggregatePageScores.
1023
+ * @returns {string}
1024
+ */
1025
+ function generateUrlsMarkdownReport(title, pageResults, aggregated) {
1026
+ let md = `# Glippy GEO Bulk URL Report\n\n`;
1027
+ md += `**Source:** ${title}\n`;
1028
+ md += `**Date:** ${new Date().toISOString()}\n`;
1029
+ md += `**Pages analysed:** ${aggregated.pagesAnalysed}\n`;
1030
+ if (aggregated.pagesFailed > 0) {
1031
+ md += `**Pages failed:** ${aggregated.pagesFailed}\n`;
1032
+ }
1033
+ if (aggregated.pagesAnalysed > 0) {
1034
+ md += `**Average score:** ${aggregated.averageScore}/100 (${getGrade(aggregated.averageScore).letter})\n`;
1035
+ }
1036
+ md += `\n---\n\n`;
1037
+
1038
+ // Per-page results table
1039
+ md += `## Per-Page Results\n\n`;
1040
+ md += `| URL | Score | Grade | Page Type |\n`;
1041
+ md += `|-----|-------|-------|----------|\n`;
1042
+ for (const page of pageResults) {
1043
+ if (page.error) {
1044
+ md += `| ${page.url} | \u2014 | \u2014 | Error: ${page.error} |\n`;
1045
+ } else {
1046
+ const score = page.analysis?.overallScore ?? 0;
1047
+ md += `| ${page.url} | ${score}% | ${scoreToGrade(score)} | ${page.analysis?.pageType || "unknown"} |\n`;
1048
+ }
1049
+ }
1050
+ md += `\n`;
1051
+
1052
+ // Category averages
1053
+ if (aggregated.categoryAverages && Object.keys(aggregated.categoryAverages).length > 0) {
1054
+ md += `## Category Averages\n\n`;
1055
+ const sorted = Object.entries(aggregated.categoryAverages).sort((a, b) => b[1] - a[1]);
1056
+ for (const [cat, avg] of sorted) {
1057
+ const icon = CATEGORY_ICONS[cat] || "\u{1F4CB}";
1058
+ md += `- ${icon} **${cat}:** ${avg}% (${scoreToGrade(avg)})\n`;
1059
+ }
1060
+ md += `\n`;
1061
+ }
1062
+
1063
+ // Weakest pages
1064
+ const scored = pageResults
1065
+ .filter((p) => !p.error && p.analysis?.overallScore != null)
1066
+ .sort((a, b) => a.analysis.overallScore - b.analysis.overallScore);
1067
+
1068
+ if (scored.length > 2) {
1069
+ md += `## Weakest Pages\n\n`;
1070
+ for (const p of scored.slice(0, 5)) {
1071
+ const fails = (p.analysis.categories || [])
1072
+ .filter((c) => c.score < 50)
1073
+ .map((c) => c.category);
1074
+ md += `- **${p.url}:** ${p.analysis.overallScore}%`;
1075
+ if (fails.length > 0) md += ` \u2014 weak in: ${fails.join(", ")}`;
1076
+ md += `\n`;
1077
+ }
1078
+ md += `\n`;
1079
+ }
1080
+
1081
+ // Strongest pages
1082
+ if (scored.length > 2) {
1083
+ md += `## Strongest Pages\n\n`;
1084
+ const strongest = [...scored].reverse();
1085
+ for (const p of strongest.slice(0, 5)) {
1086
+ md += `- **${p.url}:** ${p.analysis.overallScore}% (${scoreToGrade(p.analysis.overallScore)})\n`;
1087
+ }
1088
+ md += `\n`;
1089
+ }
1090
+
1091
+ // Common issues across pages
1092
+ const issueCounts = new Map(); // "category: label" → count
1093
+ for (const page of pageResults) {
1094
+ if (page.error || !page.analysis?.categories) continue;
1095
+ for (const cat of page.analysis.categories) {
1096
+ for (const check of cat.checks || []) {
1097
+ if (check.status === "fail" || check.status === "warn") {
1098
+ const key = `${cat.category}: ${htmlToPlainText(check.label)}`;
1099
+ issueCounts.set(key, (issueCounts.get(key) || 0) + 1);
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ const commonIssues = [...issueCounts.entries()]
1105
+ .filter(([, count]) => count > 1)
1106
+ .sort((a, b) => b[1] - a[1])
1107
+ .slice(0, 10);
1108
+
1109
+ if (commonIssues.length > 0) {
1110
+ md += `## Most Common Issues\n\n`;
1111
+ for (const [issue, count] of commonIssues) {
1112
+ md += `- **${issue}** \u2014 affects ${count}/${aggregated.pagesAnalysed} pages\n`;
1113
+ }
1114
+ md += `\n`;
1115
+ }
1116
+
1117
+ md += `---\n\n`;
1118
+ md += `*Generated by [Glippy](https://www.glippy.dev) \u2014 GEO Agent-Readiness Checker*\n`;
1119
+ return md;
1120
+ }
1121
+
1122
+ /**
1123
+ * Generate a standalone HTML report for a multi-domain comparison.
1124
+ *
1125
+ * @param {DomainEntry[]} entries
1126
+ * @returns {string}
1127
+ */
1128
+ function generateComparisonHTMLReport(entries) {
1129
+ const scored = entries.filter((e) => !e.error && e.score !== null);
1130
+ const ranked = [...scored].sort((a, b) => b.score - a.score);
1131
+ const errored = entries.filter((e) => e.error);
1132
+ const avgScore = scored.length > 0
1133
+ ? Math.round(scored.reduce((s, e) => s + e.score, 0) / scored.length)
1134
+ : 0;
1135
+ const avgGrade = getGrade(avgScore);
1136
+ const timestamp = new Date().toISOString();
1137
+
1138
+ // Build ranking rows
1139
+ const rankingRows = ranked
1140
+ .map((e, i) => {
1141
+ const color = getScoreColor(e.score);
1142
+ const g = getGrade(e.score);
1143
+ return `<tr>
1144
+ <td class="rank">${i + 1}</td>
1145
+ <td class="domain">${escapeHTML(e.domain)}</td>
1146
+ <td><span class="score-badge" style="background: ${color}20; color: ${color};">${e.score}%</span></td>
1147
+ <td><span class="grade-badge" style="color: ${color};">${g.letter}</span></td>
1148
+ <td>${e.robotsTxt ? '<span class="badge yes">Yes</span>' : '<span class="badge no">No</span>'}</td>
1149
+ <td>${e.llmsTxt ? '<span class="badge yes">Yes</span>' : '<span class="badge no">No</span>'}</td>
1150
+ <td>${e.sitemap ? '<span class="badge yes">Yes</span>' : '<span class="badge no">No</span>'}</td>
1151
+ <td>${e.blockedCrawlers.length > 0 ? escapeHTML(e.blockedCrawlers.join(", ")) : '<span style="color:var(--status-pass)">None</span>'}</td>
1152
+ </tr>`;
1153
+ })
1154
+ .join("");
1155
+
1156
+ const errorRows = errored
1157
+ .map(
1158
+ (e) =>
1159
+ `<tr class="error-row"><td>\u2014</td><td class="domain">${escapeHTML(e.domain)}</td><td colspan="6" class="error-msg">Error: ${escapeHTML(e.error)}</td></tr>`
1160
+ )
1161
+ .join("");
1162
+
1163
+ // Category comparison table (if 2+ scored domains)
1164
+ let categoryHTML = "";
1165
+ if (scored.length >= 2) {
1166
+ const catNames = [
1167
+ ...new Set(scored.flatMap((e) => e.categories.map((c) => c.category))),
1168
+ ];
1169
+ const catHeaderCells = scored
1170
+ .map((e) => `<th>${escapeHTML(e.domain)}</th>`)
1171
+ .join("");
1172
+ const catRows = catNames
1173
+ .map((catName) => {
1174
+ const icon = CATEGORY_ICONS[catName] || "\u{1F4CB}";
1175
+ const cells = scored
1176
+ .map((e) => {
1177
+ const cat = e.categories.find((c) => c.category === catName);
1178
+ if (!cat) return `<td>\u2014</td>`;
1179
+ const c = getScoreColor(cat.score);
1180
+ return `<td><span class="score-badge" style="background:${c}20;color:${c};">${cat.score}%</span></td>`;
1181
+ })
1182
+ .join("");
1183
+ return `<tr><td>${icon} ${escapeHTML(catName)}</td>${cells}</tr>`;
1184
+ })
1185
+ .join("");
1186
+
1187
+ categoryHTML = `
1188
+ <div class="section">
1189
+ <h2>Category Comparison</h2>
1190
+ <div class="table-wrap">
1191
+ <table class="data-table">
1192
+ <thead><tr><th>Category</th>${catHeaderCells}</tr></thead>
1193
+ <tbody>${catRows}</tbody>
1194
+ </table>
1195
+ </div>
1196
+ </div>`;
1197
+ }
1198
+
1199
+ // Per-domain recommendations
1200
+ let recsHTML = "";
1201
+ for (const entry of scored) {
1202
+ const recs = [];
1203
+ for (const cat of entry.categories) {
1204
+ for (const check of cat.checks || []) {
1205
+ if (check.status === "fail" || check.status === "warn") {
1206
+ const isLow = LOW_PRIORITY_CATEGORIES.includes(cat.category);
1207
+ recs.push({
1208
+ priority: isLow ? "low" : check.status,
1209
+ priorityLabel: isLow ? "Low" : check.status === "fail" ? "High" : "Medium",
1210
+ category: cat.category,
1211
+ label: htmlToPlainText(check.label),
1212
+ detail: check.detail ? htmlToPlainText(check.detail) : null,
1213
+ });
1214
+ }
1215
+ }
1216
+ }
1217
+ const g = getGrade(entry.score);
1218
+ const color = getScoreColor(entry.score);
1219
+ let recRows = "";
1220
+ if (recs.length === 0) {
1221
+ recRows = `<tr><td colspan="4" class="no-issues">\u2713 No issues found</td></tr>`;
1222
+ } else {
1223
+ recRows = recs
1224
+ .map(
1225
+ (r) =>
1226
+ `<tr class="${r.priority}"><td><span class="priority-badge ${r.priority}">${r.priorityLabel}</span></td><td>${escapeHTML(r.category)}</td><td>${escapeHTML(r.label)}</td><td>${r.detail ? escapeHTML(r.detail) : "\u2014"}</td></tr>`
1227
+ )
1228
+ .join("");
1229
+ }
1230
+ recsHTML += `
1231
+ <div class="domain-recs">
1232
+ <h3><span class="score-badge" style="background:${color}20;color:${color};">${entry.score}% ${g.letter}</span> ${escapeHTML(entry.domain)}</h3>
1233
+ <table class="recommendations-table">
1234
+ <thead><tr><th>Priority</th><th>Category</th><th>Issue</th><th>Recommendation</th></tr></thead>
1235
+ <tbody>${recRows}</tbody>
1236
+ </table>
1237
+ </div>`;
1238
+ }
1239
+
1240
+ return `<!DOCTYPE html>
1241
+ <html lang="en" data-theme="dark">
1242
+ <head>
1243
+ <meta charset="UTF-8">
1244
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1245
+ <title>Glippy Bulk Comparison Report</title>
1246
+ <style>
1247
+ ${bulkHTMLStyles()}
1248
+ </style>
1249
+ </head>
1250
+ <body>
1251
+ <div class="header">
1252
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode">
1253
+ ${themeToggleSVG()}
1254
+ </button>
1255
+ <h1>Glippy GEO Bulk Comparison Report</h1>
1256
+ <div class="meta">${entries.length} domains analysed \u2022 Generated ${escapeHTML(timestamp)}</div>
1257
+ </div>
1258
+
1259
+ <div class="summary-cards">
1260
+ <div class="card">
1261
+ <div class="card-label">Domains</div>
1262
+ <div class="card-value">${entries.length}</div>
1263
+ </div>
1264
+ <div class="card">
1265
+ <div class="card-label">Average Score</div>
1266
+ <div class="card-value" style="color:${getScoreColor(avgScore)}">${avgScore}%</div>
1267
+ </div>
1268
+ <div class="card">
1269
+ <div class="card-label">Average Grade</div>
1270
+ <div class="card-value" style="color:${getScoreColor(avgScore)}">${avgGrade.letter}</div>
1271
+ </div>
1272
+ <div class="card">
1273
+ <div class="card-label">Failed</div>
1274
+ <div class="card-value">${errored.length}</div>
1275
+ </div>
1276
+ </div>
1277
+
1278
+ <div class="section">
1279
+ <h2>Rankings</h2>
1280
+ <div class="table-wrap">
1281
+ <table class="data-table">
1282
+ <thead>
1283
+ <tr><th>#</th><th>Domain</th><th>Score</th><th>Grade</th><th>robots.txt</th><th>llms.txt</th><th>Sitemap</th><th>AI Crawlers Blocked</th></tr>
1284
+ </thead>
1285
+ <tbody>${rankingRows}${errorRows}</tbody>
1286
+ </table>
1287
+ </div>
1288
+ </div>
1289
+
1290
+ ${categoryHTML}
1291
+
1292
+ <div class="section">
1293
+ <h2>Recommendations by Domain</h2>
1294
+ ${recsHTML}
1295
+ </div>
1296
+
1297
+ <div class="footer">
1298
+ Generated by <a href="https://www.glippy.dev" target="_blank" rel="noopener">Glippy</a> \u2014 GEO Agent-Readiness Checker
1299
+ </div>
1300
+ <script>
1301
+ ${bulkHTMLScript()}
1302
+ </script>
1303
+ </body>
1304
+ </html>`;
1305
+ }
1306
+
1307
+ /**
1308
+ * Generate a standalone HTML report for multi-URL / sitemap analysis.
1309
+ *
1310
+ * @param {string} title
1311
+ * @param {object[]} pageResults
1312
+ * @param {object} aggregated
1313
+ * @returns {string}
1314
+ */
1315
+ function generateUrlsHTMLReport(title, pageResults, aggregated) {
1316
+ const timestamp = new Date().toISOString();
1317
+ const avgScore = aggregated.averageScore ?? 0;
1318
+
1319
+ // Per-page result rows
1320
+ const scored = pageResults
1321
+ .filter((p) => !p.error && p.analysis?.overallScore != null)
1322
+ .sort((a, b) => b.analysis.overallScore - a.analysis.overallScore);
1323
+ const errored = pageResults.filter((p) => p.error);
1324
+
1325
+ const pageRows = pageResults
1326
+ .map((page) => {
1327
+ if (page.error) {
1328
+ return `<tr class="error-row"><td class="domain">${escapeHTML(page.url)}</td><td>\u2014</td><td>\u2014</td><td>\u2014</td><td class="error-msg">${escapeHTML(page.error)}</td></tr>`;
1329
+ }
1330
+ const s = page.analysis?.overallScore ?? 0;
1331
+ const c = getScoreColor(s);
1332
+ return `<tr>
1333
+ <td class="domain">${escapeHTML(page.url)}</td>
1334
+ <td><span class="score-badge" style="background:${c}20;color:${c};">${s}%</span></td>
1335
+ <td><span class="grade-badge" style="color:${c};">${scoreToGrade(s)}</span></td>
1336
+ <td>${escapeHTML(page.analysis?.pageType || "unknown")}</td>
1337
+ <td>\u2014</td>
1338
+ </tr>`;
1339
+ })
1340
+ .join("");
1341
+
1342
+ // Category averages
1343
+ let categoryAvgHTML = "";
1344
+ if (aggregated.categoryAverages && Object.keys(aggregated.categoryAverages).length > 0) {
1345
+ const sortedCats = Object.entries(aggregated.categoryAverages).sort((a, b) => b[1] - a[1]);
1346
+ const catBars = sortedCats
1347
+ .map(([cat, avg]) => {
1348
+ const icon = CATEGORY_ICONS[cat] || "\u{1F4CB}";
1349
+ const c = getScoreColor(avg);
1350
+ return `<div class="cat-avg-row">
1351
+ <span class="cat-avg-name">${icon} ${escapeHTML(cat)}</span>
1352
+ <div class="cat-avg-bar-wrap">
1353
+ <div class="cat-avg-bar" style="width:${avg}%;background:${c};"></div>
1354
+ </div>
1355
+ <span class="cat-avg-score" style="color:${c};">${avg}%</span>
1356
+ </div>`;
1357
+ })
1358
+ .join("");
1359
+ categoryAvgHTML = `
1360
+ <div class="section">
1361
+ <h2>Category Averages</h2>
1362
+ <div class="cat-avg-container">${catBars}</div>
1363
+ </div>`;
1364
+ }
1365
+
1366
+ // Common issues
1367
+ const issueCounts = new Map();
1368
+ for (const page of pageResults) {
1369
+ if (page.error || !page.analysis?.categories) continue;
1370
+ for (const cat of page.analysis.categories) {
1371
+ for (const check of cat.checks || []) {
1372
+ if (check.status === "fail" || check.status === "warn") {
1373
+ const key = `${cat.category}|||${htmlToPlainText(check.label)}`;
1374
+ issueCounts.set(key, (issueCounts.get(key) || 0) + 1);
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+ const commonIssues = [...issueCounts.entries()]
1380
+ .filter(([, count]) => count > 1)
1381
+ .sort((a, b) => b[1] - a[1])
1382
+ .slice(0, 10);
1383
+
1384
+ let commonIssuesHTML = "";
1385
+ if (commonIssues.length > 0) {
1386
+ const issueRows = commonIssues
1387
+ .map(([key, count]) => {
1388
+ const [category, label] = key.split("|||");
1389
+ const pct = Math.round((count / aggregated.pagesAnalysed) * 100);
1390
+ return `<tr>
1391
+ <td>${escapeHTML(category)}</td>
1392
+ <td>${escapeHTML(label)}</td>
1393
+ <td>${count}/${aggregated.pagesAnalysed}</td>
1394
+ <td><div class="issue-bar-wrap"><div class="issue-bar" style="width:${pct}%;"></div></div></td>
1395
+ </tr>`;
1396
+ })
1397
+ .join("");
1398
+ commonIssuesHTML = `
1399
+ <div class="section">
1400
+ <h2>Most Common Issues</h2>
1401
+ <div class="table-wrap">
1402
+ <table class="data-table">
1403
+ <thead><tr><th>Category</th><th>Issue</th><th>Affected</th><th></th></tr></thead>
1404
+ <tbody>${issueRows}</tbody>
1405
+ </table>
1406
+ </div>
1407
+ </div>`;
1408
+ }
1409
+
1410
+ return `<!DOCTYPE html>
1411
+ <html lang="en" data-theme="dark">
1412
+ <head>
1413
+ <meta charset="UTF-8">
1414
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1415
+ <title>Glippy Bulk URL Report</title>
1416
+ <style>
1417
+ ${bulkHTMLStyles()}
1418
+ .cat-avg-container { display: flex; flex-direction: column; gap: 10px; }
1419
+ .cat-avg-row { display: flex; align-items: center; gap: 12px; }
1420
+ .cat-avg-name { font-size: 13px; font-weight: 500; min-width: 220px; }
1421
+ .cat-avg-bar-wrap { flex: 1; height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; }
1422
+ .cat-avg-bar { height: 100%; border-radius: 4px; transition: width 0.3s ease; }
1423
+ .cat-avg-score { font-weight: 600; font-size: 13px; min-width: 45px; text-align: right; }
1424
+ .issue-bar-wrap { width: 80px; height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; }
1425
+ .issue-bar { height: 100%; background: var(--status-fail); border-radius: 3px; }
1426
+ </style>
1427
+ </head>
1428
+ <body>
1429
+ <div class="header">
1430
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode">
1431
+ ${themeToggleSVG()}
1432
+ </button>
1433
+ <h1>Glippy GEO Bulk URL Report</h1>
1434
+ <div class="url">${escapeHTML(title)}</div>
1435
+ <div class="meta">Generated ${escapeHTML(timestamp)}</div>
1436
+ </div>
1437
+
1438
+ <div class="summary-cards">
1439
+ <div class="card">
1440
+ <div class="card-label">Pages Analysed</div>
1441
+ <div class="card-value">${aggregated.pagesAnalysed}</div>
1442
+ </div>
1443
+ <div class="card">
1444
+ <div class="card-label">Average Score</div>
1445
+ <div class="card-value" style="color:${getScoreColor(avgScore)}">${avgScore}%</div>
1446
+ </div>
1447
+ <div class="card">
1448
+ <div class="card-label">Average Grade</div>
1449
+ <div class="card-value" style="color:${getScoreColor(avgScore)}">${scoreToGrade(avgScore)}</div>
1450
+ </div>
1451
+ <div class="card">
1452
+ <div class="card-label">Failed</div>
1453
+ <div class="card-value">${errored.length}</div>
1454
+ </div>
1455
+ </div>
1456
+
1457
+ <div class="section">
1458
+ <h2>Per-Page Results</h2>
1459
+ <div class="table-wrap">
1460
+ <table class="data-table">
1461
+ <thead><tr><th>URL</th><th>Score</th><th>Grade</th><th>Page Type</th><th></th></tr></thead>
1462
+ <tbody>${pageRows}</tbody>
1463
+ </table>
1464
+ </div>
1465
+ </div>
1466
+
1467
+ ${categoryAvgHTML}
1468
+ ${commonIssuesHTML}
1469
+
1470
+ <div class="footer">
1471
+ Generated by <a href="https://www.glippy.dev" target="_blank" rel="noopener">Glippy</a> \u2014 GEO Agent-Readiness Checker
1472
+ </div>
1473
+ <script>
1474
+ ${bulkHTMLScript()}
1475
+ </script>
1476
+ </body>
1477
+ </html>`;
1478
+ }
1479
+
1480
+ /** Shared CSS styles for bulk HTML reports. */
1481
+ function bulkHTMLStyles() {
1482
+ return `
1483
+ :root {
1484
+ --bg-primary: #181b24; --bg-secondary: #1e222d; --bg-tertiary: #252a37;
1485
+ --text-primary: #e4e6eb; --text-secondary: #b0b3b8; --text-muted: #6b7280;
1486
+ --accent-purple: #a78bfa; --accent-green: #34d399;
1487
+ --status-pass: #34d399; --status-warn: #fbbf24; --status-fail: #f87171; --status-info: #60a5fa;
1488
+ --border-color: #2d3341;
1489
+ }
1490
+ [data-theme="light"] {
1491
+ --bg-primary: #f8f9fc; --bg-secondary: #ffffff; --bg-tertiary: #f0f2f5;
1492
+ --text-primary: #1a1d26; --text-secondary: #5a5f70; --text-muted: #8a8f9f;
1493
+ --accent-purple: #7c3aed; --accent-green: #16a34a;
1494
+ --status-pass: #16a34a; --status-warn: #ca8a04; --status-fail: #dc2626; --status-info: #2563eb;
1495
+ --border-color: #dce0e8;
1496
+ }
1497
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1498
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); line-height: 1.5; padding: 24px; max-width: 1200px; margin: 0 auto; }
1499
+ .header { text-align: center; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border-color); position: relative; }
1500
+ .header h1 { font-size: 24px; font-weight: 600; background: linear-gradient(135deg, var(--accent-purple), var(--accent-green)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
1501
+ .header .meta { color: var(--text-muted); font-size: 13px; margin-top: 8px; }
1502
+ .header .url { color: var(--text-secondary); font-size: 14px; word-break: break-all; margin-top: 4px; }
1503
+ .theme-toggle { position: absolute; top: 0; right: 0; width: 36px; height: 36px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; }
1504
+ .theme-toggle:hover { background: var(--bg-tertiary); border-color: var(--accent-purple); color: var(--accent-purple); }
1505
+ .theme-toggle svg { width: 18px; height: 18px; }
1506
+ .theme-toggle .icon-sun { display: none; } .theme-toggle .icon-moon { display: none; }
1507
+ [data-theme="dark"] .theme-toggle .icon-sun { display: block; }
1508
+ [data-theme="light"] .theme-toggle .icon-moon { display: block; }
1509
+ .summary-cards { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
1510
+ .card { flex: 1; min-width: 120px; background: var(--bg-secondary); border-radius: 10px; padding: 16px 20px; text-align: center; }
1511
+ .card-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
1512
+ .card-value { font-size: 28px; font-weight: 700; }
1513
+ .section { background: var(--bg-secondary); border-radius: 12px; padding: 20px; margin-bottom: 20px; }
1514
+ .section h2 { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--text-primary); }
1515
+ .table-wrap { overflow-x: auto; }
1516
+ .data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
1517
+ .data-table th, .data-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border-color); }
1518
+ .data-table th { font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
1519
+ .data-table tr:hover { background: var(--bg-tertiary); }
1520
+ .data-table td.domain { font-weight: 500; color: var(--accent-purple); max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1521
+ .data-table td.rank { font-weight: 700; color: var(--text-muted); width: 40px; }
1522
+ .data-table td.error-msg { color: var(--status-fail); }
1523
+ .data-table tr.error-row { opacity: 0.7; }
1524
+ .score-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
1525
+ .grade-badge { font-weight: 700; font-size: 14px; }
1526
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
1527
+ .badge.yes { background: rgba(52,211,153,0.15); color: var(--status-pass); }
1528
+ .badge.no { background: rgba(248,113,113,0.15); color: var(--status-fail); }
1529
+ .domain-recs { margin-bottom: 20px; }
1530
+ .domain-recs h3 { font-size: 15px; font-weight: 600; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
1531
+ .recommendations-table { width: 100%; border-collapse: collapse; font-size: 13px; }
1532
+ .recommendations-table th, .recommendations-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border-color); }
1533
+ .recommendations-table th { font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase; }
1534
+ .recommendations-table tr.fail { background: rgba(248,113,113,0.05); }
1535
+ .recommendations-table tr.warn { background: rgba(251,191,36,0.03); }
1536
+ .recommendations-table tr.low { background: rgba(148,163,184,0.03); }
1537
+ .recommendations-table td.no-issues { text-align: center; color: var(--status-pass); font-weight: 500; padding: 16px; }
1538
+ .priority-badge { display: inline-block; padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
1539
+ .priority-badge.fail { background: rgba(248,113,113,0.15); color: var(--status-fail); }
1540
+ .priority-badge.warn { background: rgba(251,191,36,0.15); color: var(--status-warn); }
1541
+ .priority-badge.low { background: rgba(148,163,184,0.15); color: var(--text-muted); }
1542
+ .footer { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color); text-align: center; color: var(--text-muted); font-size: 12px; }
1543
+ .footer a { color: var(--accent-purple); text-decoration: none; }
1544
+ .footer a:hover { text-decoration: underline; }
1545
+ @media print {
1546
+ :root { --bg-primary:#fff;--bg-secondary:#fff;--bg-tertiary:#f5f5f5;--text-primary:#1a1a1a;--text-secondary:#4a4a4a;--text-muted:#6a6a6a;--border-color:#e0e0e0; }
1547
+ body { background: white; color: #1a1a1a; }
1548
+ .theme-toggle { display: none; }
1549
+ }`;
1550
+ }
1551
+
1552
+ /** Shared theme toggle SVG icons. */
1553
+ function themeToggleSVG() {
1554
+ return `<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1555
+ <circle cx="12" cy="12" r="5"/>
1556
+ <line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
1557
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
1558
+ <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
1559
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
1560
+ </svg>
1561
+ <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1562
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
1563
+ </svg>`;
1564
+ }
1565
+
1566
+ /** Shared JS for bulk HTML reports. */
1567
+ function bulkHTMLScript() {
1568
+ return `
1569
+ function toggleTheme() {
1570
+ var html = document.documentElement;
1571
+ var cur = html.getAttribute('data-theme');
1572
+ var next = cur === 'dark' ? 'light' : 'dark';
1573
+ html.setAttribute('data-theme', next);
1574
+ localStorage.setItem('glippy-report-theme', next);
1575
+ }
1576
+ (function() {
1577
+ var saved = localStorage.getItem('glippy-report-theme');
1578
+ if (saved) { document.documentElement.setAttribute('data-theme', saved); }
1579
+ else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
1580
+ document.documentElement.setAttribute('data-theme', 'light');
1581
+ }
1582
+ })();`;
1583
+ }
1584
+
1585
+ // ---------------------------------------------------------------------------
1586
+ // MCP Server
1587
+ // ---------------------------------------------------------------------------
1588
+
1589
+ const server = new McpServer({
1590
+ name: "glippy-geo",
1591
+ version: "0.1.0",
1592
+ });
1593
+
1594
+ // ---------------------------------------------------------------------------
1595
+ // Tool: analyze_domain
1596
+ // ---------------------------------------------------------------------------
1597
+ server.tool(
1598
+ "analyze_domain",
1599
+ "Run a comprehensive GEO (Generative Engine Optimization) readiness analysis on a domain. " +
1600
+ "Checks robots.txt, llms.txt (note: llms.txt is not currently supported by major AI models — having one cannot hurt but is not a meaningful optimization), " +
1601
+ "homepage HTML (10 scoring categories), sitemap.xml, and security headers. " +
1602
+ "Returns an overall weighted score (0-100) with per-category breakdowns and actionable recommendations. " +
1603
+ "Use output_format='json' to get raw results that can be passed to export_report.",
1604
+ {
1605
+ domain: z
1606
+ .string()
1607
+ .describe(
1608
+ 'The domain to analyse, e.g. "example.com". Do not include https:// prefix.'
1609
+ ),
1610
+ max_pages: z
1611
+ .number()
1612
+ .int()
1613
+ .min(1)
1614
+ .max(10)
1615
+ .optional()
1616
+ .describe(
1617
+ "Maximum pages to crawl (1 = homepage only, up to 10 for multi-page analysis). Defaults to 10."
1618
+ ),
1619
+ output_format: z
1620
+ .enum(["text", "json"])
1621
+ .optional()
1622
+ .describe(
1623
+ 'Output format. "text" (default) returns a human-readable report. ' +
1624
+ '"json" returns the raw analysis result object that can be passed to export_report\'s analysis_result parameter.'
1625
+ ),
1626
+ },
1627
+ withLicense(async ({ domain, max_pages, output_format }) => {
1628
+ try {
1629
+ const result = await checkGEO(domain, {
1630
+ maxPages: max_pages ?? 10,
1631
+ });
1632
+
1633
+ if (result.error) {
1634
+ return {
1635
+ content: [
1636
+ {
1637
+ type: "text",
1638
+ text: `Error analysing ${domain}: ${result.error}`,
1639
+ },
1640
+ ],
1641
+ isError: true,
1642
+ };
1643
+ }
1644
+
1645
+ // JSON output mode - return raw result for use with export_report
1646
+ if (output_format === "json") {
1647
+ return {
1648
+ content: [
1649
+ {
1650
+ type: "text",
1651
+ text: JSON.stringify(result, null, 2),
1652
+ },
1653
+ ],
1654
+ };
1655
+ }
1656
+
1657
+ // Build a structured text report
1658
+ const lines = [];
1659
+ lines.push(`# GEO Analysis: ${result.domain}`);
1660
+ lines.push(`Timestamp: ${result.timestamp}`);
1661
+ lines.push("");
1662
+
1663
+ // Overall score
1664
+ const analysis = result.homepage?.analysis;
1665
+ if (analysis) {
1666
+ const grade = scoreToGrade(analysis.overallScore);
1667
+ lines.push(
1668
+ `## Overall GEO Score: ${analysis.overallScore}% (${grade})`
1669
+ );
1670
+ lines.push(`Page type detected: ${analysis.pageType}`);
1671
+ lines.push("");
1672
+
1673
+ // Category breakdown
1674
+ lines.push("## Category Scores");
1675
+ if (analysis.categories) {
1676
+ for (const cat of analysis.categories) {
1677
+ lines.push(formatCategory(cat));
1678
+ }
1679
+ }
1680
+ lines.push("");
1681
+ }
1682
+
1683
+ // robots.txt
1684
+ lines.push("## robots.txt");
1685
+ lines.push(`Exists: ${result.robotsTxt.exists ? "Yes" : "No"}`);
1686
+ if (result.robotsTxt.exists) {
1687
+ lines.push(
1688
+ `Wildcard disallow: ${result.robotsTxt.hasWildcardDisallow ? "Yes" : "No"}`
1689
+ );
1690
+ lines.push("AI Crawler Access:");
1691
+ lines.push(formatCrawlerAccess(result.robotsTxt.blocksCrawlers));
1692
+ if (result.robotsTxt.sitemapReferences.length > 0) {
1693
+ lines.push(
1694
+ `Sitemaps referenced: ${result.robotsTxt.sitemapReferences.join(", ")}`
1695
+ );
1696
+ }
1697
+ }
1698
+ lines.push("");
1699
+
1700
+ // llms.txt
1701
+ lines.push("## llms.txt");
1702
+ lines.push(`Exists: ${result.llmsTxt.exists ? "Yes" : "No"}`);
1703
+ lines.push(
1704
+ "(Note: llms.txt is not currently supported by major AI models or crawlers — having one cannot hurt but is not a meaningful optimization.)"
1705
+ );
1706
+ if (result.llmsTxt.exists && result.llmsTxt.content) {
1707
+ const preview = result.llmsTxt.content.substring(0, 500);
1708
+ lines.push(`Content preview:\n${preview}`);
1709
+ }
1710
+ lines.push("");
1711
+
1712
+ // Sitemap
1713
+ lines.push("## Sitemap");
1714
+ lines.push(`Exists: ${result.sitemap.exists ? "Yes" : "No"}`);
1715
+ lines.push(
1716
+ `Referenced in robots.txt: ${result.sitemap.referencedInRobotsTxt ? "Yes" : "No"}`
1717
+ );
1718
+ if (result.sitemap.urlsDiscovered > 0) {
1719
+ lines.push(`URLs discovered: ${result.sitemap.urlsDiscovered}`);
1720
+ }
1721
+ lines.push("");
1722
+
1723
+ // GEO score breakdown
1724
+ if (result.geoScore) {
1725
+ lines.push("## GEO Score Breakdown");
1726
+ lines.push(
1727
+ `Total: ${result.geoScore.total}/${result.geoScore.maxPossible}`
1728
+ );
1729
+ if (result.geoScore.breakdown) {
1730
+ for (const [key, val] of Object.entries(result.geoScore.breakdown)) {
1731
+ lines.push(` ${key}: ${val.score}/${val.max} - ${val.detail}`);
1732
+ }
1733
+ }
1734
+ }
1735
+
1736
+ // Multi-page results
1737
+ if (result.multiPageCrawl?.pagesAnalysed > 1) {
1738
+ lines.push("");
1739
+ lines.push("## Multi-Page Analysis");
1740
+ lines.push(
1741
+ `Pages analysed: ${result.multiPageCrawl.pagesAnalysed}`
1742
+ );
1743
+ lines.push(
1744
+ `Average score: ${result.multiPageCrawl.averageScore}%`
1745
+ );
1746
+ if (result.multiPageCrawl.categoryAverages) {
1747
+ lines.push("Category averages:");
1748
+ for (const [cat, avg] of Object.entries(
1749
+ result.multiPageCrawl.categoryAverages
1750
+ )) {
1751
+ lines.push(` ${cat}: ${avg}%`);
1752
+ }
1753
+ }
1754
+ }
1755
+
1756
+ return {
1757
+ content: [{ type: "text", text: lines.join("\n") }],
1758
+ };
1759
+ } catch (err) {
1760
+ return {
1761
+ content: [
1762
+ {
1763
+ type: "text",
1764
+ text: `Failed to analyse ${domain}: ${err.message}`,
1765
+ },
1766
+ ],
1767
+ isError: true,
1768
+ };
1769
+ }
1770
+ })
1771
+ );
1772
+
1773
+ // ---------------------------------------------------------------------------
1774
+ // Tool: check_robots_txt
1775
+ // ---------------------------------------------------------------------------
1776
+ server.tool(
1777
+ "check_robots_txt",
1778
+ "Check a domain's robots.txt file specifically for AI crawler access rules. " +
1779
+ "Reports which AI crawlers (GPTBot, ClaudeBot, etc.) are blocked or allowed.",
1780
+ {
1781
+ domain: z
1782
+ .string()
1783
+ .describe(
1784
+ 'The domain to check, e.g. "example.com". Do not include https:// prefix.'
1785
+ ),
1786
+ },
1787
+ withLicense(async ({ domain }) => {
1788
+ try {
1789
+ const result = await checkGEO(domain, { maxPages: 1 });
1790
+
1791
+ if (result.error) {
1792
+ return {
1793
+ content: [
1794
+ {
1795
+ type: "text",
1796
+ text: `Error checking ${domain}: ${result.error}`,
1797
+ },
1798
+ ],
1799
+ isError: true,
1800
+ };
1801
+ }
1802
+
1803
+ const lines = [];
1804
+ lines.push(`# robots.txt Analysis: ${result.domain}`);
1805
+ lines.push("");
1806
+ lines.push(`URL: ${result.robotsTxt.url}`);
1807
+ lines.push(`Exists: ${result.robotsTxt.exists ? "Yes" : "No"}`);
1808
+
1809
+ if (!result.robotsTxt.exists) {
1810
+ lines.push("");
1811
+ lines.push(
1812
+ "No robots.txt found. This means all crawlers have unrestricted access."
1813
+ );
1814
+ lines.push(
1815
+ "Consider adding a robots.txt to explicitly manage crawler access."
1816
+ );
1817
+ } else {
1818
+ lines.push(
1819
+ `Wildcard disallow (Disallow: /): ${result.robotsTxt.hasWildcardDisallow ? "Yes - blocks all bots by default" : "No"}`
1820
+ );
1821
+ lines.push("");
1822
+ lines.push("## AI Crawler Access");
1823
+ lines.push(formatCrawlerAccess(result.robotsTxt.blocksCrawlers));
1824
+
1825
+ const blockedCount = Object.values(
1826
+ result.robotsTxt.blocksCrawlers
1827
+ ).filter(Boolean).length;
1828
+ const totalCrawlers = Object.keys(
1829
+ result.robotsTxt.blocksCrawlers
1830
+ ).length;
1831
+ lines.push("");
1832
+ lines.push(
1833
+ `Summary: ${blockedCount}/${totalCrawlers} AI crawlers are blocked.`
1834
+ );
1835
+
1836
+ if (blockedCount > 0) {
1837
+ lines.push(
1838
+ "Blocked crawlers cannot index this site's content for AI features."
1839
+ );
1840
+ }
1841
+
1842
+ if (result.robotsTxt.sitemapReferences.length > 0) {
1843
+ lines.push("");
1844
+ lines.push("## Sitemaps Referenced");
1845
+ for (const url of result.robotsTxt.sitemapReferences) {
1846
+ lines.push(` - ${url}`);
1847
+ }
1848
+ }
1849
+ }
1850
+
1851
+ return {
1852
+ content: [{ type: "text", text: lines.join("\n") }],
1853
+ };
1854
+ } catch (err) {
1855
+ return {
1856
+ content: [
1857
+ {
1858
+ type: "text",
1859
+ text: `Failed to check robots.txt for ${domain}: ${err.message}`,
1860
+ },
1861
+ ],
1862
+ isError: true,
1863
+ };
1864
+ }
1865
+ })
1866
+ );
1867
+
1868
+ // ---------------------------------------------------------------------------
1869
+ // Tool: check_llms_txt
1870
+ // ---------------------------------------------------------------------------
1871
+ server.tool(
1872
+ "check_llms_txt",
1873
+ "Check if a domain has an llms.txt file. " +
1874
+ "Note: llms.txt is an emerging proposal but is NOT currently supported or consumed by any major AI model, crawler, or MCP client. " +
1875
+ "It should not be treated as a relevant optimization for GEO readiness — it will not meaningfully improve AI discoverability today. " +
1876
+ "That said, having one cannot hurt. Returns the file contents if found.",
1877
+ {
1878
+ domain: z
1879
+ .string()
1880
+ .describe(
1881
+ 'The domain to check, e.g. "example.com". Do not include https:// prefix.'
1882
+ ),
1883
+ },
1884
+ withLicense(async ({ domain }) => {
1885
+ try {
1886
+ const result = await checkGEO(domain, { maxPages: 1 });
1887
+
1888
+ if (result.error) {
1889
+ return {
1890
+ content: [
1891
+ {
1892
+ type: "text",
1893
+ text: `Error checking ${domain}: ${result.error}`,
1894
+ },
1895
+ ],
1896
+ isError: true,
1897
+ };
1898
+ }
1899
+
1900
+ const lines = [];
1901
+ lines.push(`# llms.txt Check: ${result.domain}`);
1902
+ lines.push("");
1903
+ lines.push(`URL: ${result.llmsTxt.url}`);
1904
+ lines.push(`Exists: ${result.llmsTxt.exists ? "Yes" : "No"}`);
1905
+
1906
+ if (!result.llmsTxt.exists) {
1907
+ lines.push("");
1908
+ lines.push(
1909
+ "No llms.txt found. Note: llms.txt is an emerging proposal but is not currently supported or consumed by any major AI model or crawler. " +
1910
+ "Adding one is not a meaningful optimization for GEO readiness today, but it cannot hurt to have one."
1911
+ );
1912
+ lines.push(
1913
+ "See https://llmstxt.org for the specification."
1914
+ );
1915
+ } else {
1916
+ lines.push("");
1917
+ lines.push("## Contents");
1918
+ lines.push(result.llmsTxt.content);
1919
+ }
1920
+
1921
+ return {
1922
+ content: [{ type: "text", text: lines.join("\n") }],
1923
+ };
1924
+ } catch (err) {
1925
+ return {
1926
+ content: [
1927
+ {
1928
+ type: "text",
1929
+ text: `Failed to check llms.txt for ${domain}: ${err.message}`,
1930
+ },
1931
+ ],
1932
+ isError: true,
1933
+ };
1934
+ }
1935
+ })
1936
+ );
1937
+
1938
+ // ---------------------------------------------------------------------------
1939
+ // Tool: get_geo_summary
1940
+ // ---------------------------------------------------------------------------
1941
+ server.tool(
1942
+ "get_geo_summary",
1943
+ "Get a concise GEO readiness summary for a domain: overall score, grade, top 3 strengths, and top 3 issues to fix. " +
1944
+ "Use this for a quick overview; use analyze_domain for full details.",
1945
+ {
1946
+ domain: z
1947
+ .string()
1948
+ .describe(
1949
+ 'The domain to check, e.g. "example.com". Do not include https:// prefix.'
1950
+ ),
1951
+ },
1952
+ withLicense(async ({ domain }) => {
1953
+ try {
1954
+ const result = await checkGEO(domain, { maxPages: 1 });
1955
+
1956
+ if (result.error) {
1957
+ return {
1958
+ content: [
1959
+ {
1960
+ type: "text",
1961
+ text: `Error analysing ${domain}: ${result.error}`,
1962
+ },
1963
+ ],
1964
+ isError: true,
1965
+ };
1966
+ }
1967
+
1968
+ const analysis = result.homepage?.analysis;
1969
+ if (!analysis) {
1970
+ const statusCode = result.homepage?.statusCode;
1971
+ const statusMsg = statusCode === null
1972
+ ? "connection failed (no response)"
1973
+ : `HTTP status: ${statusCode}`;
1974
+ return {
1975
+ content: [
1976
+ {
1977
+ type: "text",
1978
+ text: `Could not analyse homepage for ${domain}. ${statusMsg}`,
1979
+ },
1980
+ ],
1981
+ isError: true,
1982
+ };
1983
+ }
1984
+
1985
+ const grade = scoreToGrade(analysis.overallScore);
1986
+ const lines = [];
1987
+ lines.push(`# GEO Summary: ${result.domain}`);
1988
+ lines.push(`Overall Score: ${analysis.overallScore}% (${grade})`);
1989
+ lines.push(`Page Type: ${analysis.pageType}`);
1990
+ lines.push("");
1991
+
1992
+ // Sort categories by score
1993
+ const cats = [...(analysis.categories || [])];
1994
+ const sorted = cats.sort((a, b) => b.score - a.score);
1995
+
1996
+ // Top 3 strengths
1997
+ lines.push("## Top Strengths");
1998
+ for (const cat of sorted.slice(0, 3)) {
1999
+ lines.push(` + ${cat.category}: ${cat.score}%`);
2000
+ }
2001
+
2002
+ // Top 3 areas to improve
2003
+ lines.push("");
2004
+ lines.push("## Top Areas to Improve");
2005
+ const weakest = sorted.slice(-3).reverse();
2006
+ for (const cat of weakest) {
2007
+ const fails = (cat.checks || []).filter((c) => c.status === "fail");
2008
+ const topIssue = fails[0]?.label || "Review this category";
2009
+ lines.push(` - ${cat.category}: ${cat.score}% — ${topIssue}`);
2010
+ }
2011
+
2012
+ // Quick facts
2013
+ lines.push("");
2014
+ lines.push("## Quick Facts");
2015
+ lines.push(` robots.txt: ${result.robotsTxt.exists ? "Present" : "Missing"}`);
2016
+ lines.push(` llms.txt: ${result.llmsTxt.exists ? "Present" : "Missing"}`);
2017
+ lines.push(` Sitemap: ${result.sitemap.exists ? "Present" : "Missing"}`);
2018
+
2019
+ const blockedCrawlers = Object.entries(
2020
+ result.robotsTxt.blocksCrawlers || {}
2021
+ ).filter(([, blocked]) => blocked);
2022
+ if (blockedCrawlers.length > 0) {
2023
+ lines.push(
2024
+ ` AI crawlers blocked: ${blockedCrawlers.map(([name]) => name).join(", ")}`
2025
+ );
2026
+ } else {
2027
+ lines.push(" AI crawlers blocked: None");
2028
+ }
2029
+
2030
+ return {
2031
+ content: [{ type: "text", text: lines.join("\n") }],
2032
+ };
2033
+ } catch (err) {
2034
+ return {
2035
+ content: [
2036
+ {
2037
+ type: "text",
2038
+ text: `Failed to get summary for ${domain}: ${err.message}`,
2039
+ },
2040
+ ],
2041
+ isError: true,
2042
+ };
2043
+ }
2044
+ })
2045
+ );
2046
+
2047
+ // ---------------------------------------------------------------------------
2048
+ // Tool: compare_domains
2049
+ // ---------------------------------------------------------------------------
2050
+ server.tool(
2051
+ "compare_domains",
2052
+ "Analyse multiple domains in parallel and compare their GEO scores side by side. " +
2053
+ "Returns a comparison table with overall scores, per-category breakdowns, and a ranked summary. " +
2054
+ "Useful for competitive analysis or auditing a portfolio of sites. " +
2055
+ "Requires Pro or Agency tier. " +
2056
+ "Use output_format='json' to get raw results that can be passed to export_bulk_report.",
2057
+ {
2058
+ domains: z
2059
+ .array(z.string())
2060
+ .min(2)
2061
+ .max(10)
2062
+ .describe(
2063
+ 'List of domains to compare, e.g. ["example.com", "competitor.com"]. Do not include https:// prefix.'
2064
+ ),
2065
+ max_pages: z
2066
+ .number()
2067
+ .int()
2068
+ .min(1)
2069
+ .max(10)
2070
+ .optional()
2071
+ .describe(
2072
+ "Maximum pages to crawl per domain (1 = homepage only). Defaults to 10."
2073
+ ),
2074
+ output_format: z
2075
+ .enum(["text", "json"])
2076
+ .optional()
2077
+ .describe(
2078
+ 'Output format. "text" (default) returns a human-readable comparison table. ' +
2079
+ '"json" returns the raw analysis results that can be passed to export_bulk_report.'
2080
+ ),
2081
+ },
2082
+ withTierFeature(
2083
+ "compareDomains",
2084
+ "Domain comparison requires a Pro or Agency license.",
2085
+ async ({ domains, max_pages, output_format }) => {
2086
+ const maxPages = max_pages ?? 10;
2087
+
2088
+ // Run all analyses in parallel
2089
+ const results = await Promise.allSettled(
2090
+ domains.map((domain) =>
2091
+ checkGEO(domain, { maxPages }).then((result) => ({
2092
+ domain,
2093
+ result,
2094
+ }))
2095
+ )
2096
+ );
2097
+
2098
+ // JSON output mode - return raw results for use with export_bulk_report
2099
+ if (output_format === "json") {
2100
+ const rawResults = results.map((r) => {
2101
+ if (r.status === "rejected") {
2102
+ return { domain: "unknown", error: r.reason?.message || "Analysis failed" };
2103
+ }
2104
+ return r.value.result;
2105
+ });
2106
+ return {
2107
+ content: [
2108
+ {
2109
+ type: "text",
2110
+ text: JSON.stringify(rawResults, null, 2),
2111
+ },
2112
+ ],
2113
+ };
2114
+ }
2115
+
2116
+ const lines = [];
2117
+ lines.push(`# GEO Comparison: ${domains.length} domains`);
2118
+ lines.push("");
2119
+
2120
+ // Build comparison data
2121
+ const entries = [];
2122
+ for (const r of results) {
2123
+ if (r.status === "rejected") {
2124
+ entries.push({ domain: "unknown", error: r.reason?.message || "Analysis failed" });
2125
+ continue;
2126
+ }
2127
+ const { domain, result } = r.value;
2128
+ if (result.error) {
2129
+ entries.push({ domain, error: result.error });
2130
+ continue;
2131
+ }
2132
+ const analysis = result.homepage?.analysis;
2133
+ entries.push({
2134
+ domain: result.domain,
2135
+ score: analysis?.overallScore ?? null,
2136
+ grade: analysis ? scoreToGrade(analysis.overallScore) : "N/A",
2137
+ categories: analysis?.categories || [],
2138
+ robotsTxt: result.robotsTxt?.exists ?? false,
2139
+ llmsTxt: result.llmsTxt?.exists ?? false,
2140
+ sitemap: result.sitemap?.exists ?? false,
2141
+ blockedCrawlers: Object.entries(result.robotsTxt?.blocksCrawlers || {})
2142
+ .filter(([, blocked]) => blocked)
2143
+ .map(([name]) => name),
2144
+ error: null,
2145
+ });
2146
+ }
2147
+
2148
+ // Ranking table
2149
+ const scored = entries.filter((e) => !e.error && e.score !== null);
2150
+ const ranked = [...scored].sort((a, b) => b.score - a.score);
2151
+
2152
+ lines.push("## Rankings");
2153
+ lines.push("");
2154
+ for (let i = 0; i < ranked.length; i++) {
2155
+ const e = ranked[i];
2156
+ lines.push(`${i + 1}. **${e.domain}**: ${e.score}% (${e.grade})`);
2157
+ }
2158
+
2159
+ // Errors
2160
+ const errored = entries.filter((e) => e.error);
2161
+ if (errored.length > 0) {
2162
+ lines.push("");
2163
+ for (const e of errored) {
2164
+ lines.push(`- **${e.domain}**: Error — ${e.error}`);
2165
+ }
2166
+ }
2167
+
2168
+ // Category comparison table
2169
+ if (scored.length >= 2) {
2170
+ // Collect all unique category names
2171
+ const catNames = [
2172
+ ...new Set(scored.flatMap((e) => e.categories.map((c) => c.category))),
2173
+ ];
2174
+
2175
+ lines.push("");
2176
+ lines.push("## Category Comparison");
2177
+ lines.push("");
2178
+
2179
+ // Header
2180
+ const header = `| Category | ${scored.map((e) => e.domain).join(" | ")} |`;
2181
+ const separator = `|---|${scored.map(() => "---").join("|")}|`;
2182
+ lines.push(header);
2183
+ lines.push(separator);
2184
+
2185
+ for (const catName of catNames) {
2186
+ const scores = scored.map((e) => {
2187
+ const cat = e.categories.find((c) => c.category === catName);
2188
+ return cat ? `${cat.score}%` : "—";
2189
+ });
2190
+ lines.push(`| ${catName} | ${scores.join(" | ")} |`);
2191
+ }
2192
+
2193
+ // Overall row
2194
+ lines.push(
2195
+ `| **Overall** | ${scored.map((e) => `**${e.score}%**`).join(" | ")} |`
2196
+ );
2197
+ }
2198
+
2199
+ // Quick facts comparison
2200
+ lines.push("");
2201
+ lines.push("## Quick Facts");
2202
+ lines.push("");
2203
+ const factsHeader = `| | ${scored.map((e) => e.domain).join(" | ")} |`;
2204
+ const factsSep = `|---|${scored.map(() => "---").join("|")}|`;
2205
+ lines.push(factsHeader);
2206
+ lines.push(factsSep);
2207
+ lines.push(
2208
+ `| robots.txt | ${scored.map((e) => (e.robotsTxt ? "Yes" : "No")).join(" | ")} |`
2209
+ );
2210
+ lines.push(
2211
+ `| llms.txt | ${scored.map((e) => (e.llmsTxt ? "Yes" : "No")).join(" | ")} |`
2212
+ );
2213
+ lines.push(
2214
+ `| Sitemap | ${scored.map((e) => (e.sitemap ? "Yes" : "No")).join(" | ")} |`
2215
+ );
2216
+ lines.push(
2217
+ `| AI crawlers blocked | ${scored.map((e) => e.blockedCrawlers.length > 0 ? e.blockedCrawlers.join(", ") : "None").join(" | ")} |`
2218
+ );
2219
+
2220
+ return {
2221
+ content: [{ type: "text", text: lines.join("\n") }],
2222
+ };
2223
+ })
2224
+ );
2225
+
2226
+ // ---------------------------------------------------------------------------
2227
+ // Shared: per-URL analysis helper
2228
+ // ---------------------------------------------------------------------------
2229
+
2230
+ /** Default per-domain requests/second (overridable via GLIPPY_RATE_LIMIT env). */
2231
+ const DEFAULT_RATE_LIMIT = parseInt(process.env.GLIPPY_RATE_LIMIT, 10) || 5;
2232
+
2233
+ /**
2234
+ * Analyse a batch of full URLs. Groups by domain so robots.txt / llms.txt
2235
+ * is fetched once per domain. Enforces per-domain rate limiting to avoid
2236
+ * overwhelming target servers.
2237
+ *
2238
+ * @param {string[]} urls - Full URLs (https://example.com/page)
2239
+ * @param {number} concurrency - Max pages fetched at a time across all domains
2240
+ * @param {number} domainRateLimit - Max requests/second per domain (0 = unlimited)
2241
+ * @returns {Promise<{pageResults: object[], domainMeta: Map}>}
2242
+ */
2243
+ async function analyseUrls(urls, concurrency = 3, domainRateLimit = DEFAULT_RATE_LIMIT) {
2244
+ // Group URLs by domain
2245
+ const domainMap = new Map(); // domain → [urls]
2246
+ for (const url of urls) {
2247
+ try {
2248
+ const parsed = new URL(url);
2249
+ const domain = parsed.hostname;
2250
+ if (!domainMap.has(domain)) domainMap.set(domain, []);
2251
+ domainMap.get(domain).push(url);
2252
+ } catch {
2253
+ // skip invalid URLs
2254
+ }
2255
+ }
2256
+
2257
+ // Fetch robots.txt + llms.txt once per domain
2258
+ const domainMeta = new Map(); // domain → { robotsTxtData, llmsTxtData }
2259
+ await Promise.all(
2260
+ [...domainMap.keys()].map(async (domain) => {
2261
+ const [robotsRes, llmsRes] = await Promise.all([
2262
+ throttledFetchUrl(`https://${domain}/robots.txt`, 10000, 512 * 1024).catch(() => ({
2263
+ body: null,
2264
+ })),
2265
+ throttledFetchUrl(`https://${domain}/llms.txt`, 10000, 512 * 1024).catch(() => ({
2266
+ body: null,
2267
+ })),
2268
+ ]);
2269
+
2270
+ const robotsTxtData =
2271
+ robotsRes.statusCode === 200 && robotsRes.body
2272
+ ? analyseRobotsTxt(robotsRes.body)
2273
+ : { blocksCrawlers: {}, hasWildcardDisallow: false, sitemapUrls: [] };
2274
+
2275
+ const llmsTxtData = {
2276
+ exists: llmsRes.statusCode === 200 && !!llmsRes.body,
2277
+ content: llmsRes.body || null,
2278
+ };
2279
+
2280
+ domainMeta.set(domain, { robotsTxtData, llmsTxtData });
2281
+ })
2282
+ );
2283
+
2284
+ // Per-domain rate limiter: serialises requests to each domain with a
2285
+ // minimum interval between them, while still allowing cross-domain
2286
+ // concurrency within the batch window.
2287
+ const minInterval = domainRateLimit > 0 ? 1000 / domainRateLimit : 0;
2288
+ const domainQueues = new Map(); // domain → Promise (chain)
2289
+
2290
+ function withDomainRate(domain, fn) {
2291
+ const prev = domainQueues.get(domain) || Promise.resolve();
2292
+ const next = prev.then(async () => {
2293
+ const result = await fn();
2294
+ if (minInterval > 0) {
2295
+ await new Promise((r) => setTimeout(r, minInterval));
2296
+ }
2297
+ return result;
2298
+ });
2299
+ // Prevent a single failure from breaking the chain for subsequent URLs
2300
+ domainQueues.set(domain, next.catch(() => {}));
2301
+ return next;
2302
+ }
2303
+
2304
+ // Analyse each URL in batches, with per-domain rate limiting
2305
+ const pageResults = [];
2306
+ for (let i = 0; i < urls.length; i += concurrency) {
2307
+ const batch = urls.slice(i, i + concurrency);
2308
+ const batchResults = await Promise.all(
2309
+ batch.map(async (url) => {
2310
+ let domain;
2311
+ try {
2312
+ domain = new URL(url).hostname;
2313
+ } catch {
2314
+ return { url, analysis: null, error: "Invalid URL" };
2315
+ }
2316
+
2317
+ return withDomainRate(domain, async () => {
2318
+ try {
2319
+ const pathname = new URL(url).pathname;
2320
+ const meta = domainMeta.get(domain);
2321
+ const res = await throttledFetchUrl(url, 15000);
2322
+
2323
+ if (res.statusCode !== 200 || !res.body) {
2324
+ return {
2325
+ url,
2326
+ analysis: null,
2327
+ error: res.statusCode ? `HTTP ${res.statusCode}` : "Failed to fetch",
2328
+ };
2329
+ }
2330
+
2331
+ const analysis = analyseHTML(
2332
+ res.body,
2333
+ domain,
2334
+ meta.robotsTxtData,
2335
+ meta.llmsTxtData,
2336
+ res.headers || {},
2337
+ pathname
2338
+ );
2339
+
2340
+ return { url, analysis, error: null };
2341
+ } catch (err) {
2342
+ return { url, analysis: null, error: err.message };
2343
+ }
2344
+ });
2345
+ })
2346
+ );
2347
+ pageResults.push(...batchResults);
2348
+ }
2349
+
2350
+ return { pageResults, domainMeta };
2351
+ }
2352
+
2353
+ /** Format per-page results into a readable report. */
2354
+ function formatUrlResults(pageResults, domainMeta) {
2355
+ const lines = [];
2356
+
2357
+ // Aggregate
2358
+ const aggregated = aggregatePageScores(pageResults);
2359
+
2360
+ if (aggregated.pagesAnalysed > 0) {
2361
+ lines.push(
2362
+ `**${aggregated.pagesAnalysed} pages analysed** | Average score: ${aggregated.averageScore}% (${scoreToGrade(aggregated.averageScore)})`
2363
+ );
2364
+ if (aggregated.pagesFailed > 0) {
2365
+ lines.push(`${aggregated.pagesFailed} pages failed`);
2366
+ }
2367
+ lines.push("");
2368
+ }
2369
+
2370
+ // Per-page results
2371
+ lines.push("## Per-Page Results");
2372
+ lines.push("");
2373
+
2374
+ // Table header
2375
+ lines.push("| URL | Score | Grade | Page Type |");
2376
+ lines.push("|-----|-------|-------|-----------|");
2377
+
2378
+ for (const page of pageResults) {
2379
+ if (page.error) {
2380
+ lines.push(`| ${page.url} | — | — | Error: ${page.error} |`);
2381
+ } else {
2382
+ const score = page.analysis?.overallScore ?? 0;
2383
+ lines.push(
2384
+ `| ${page.url} | ${score}% | ${scoreToGrade(score)} | ${page.analysis?.pageType || "unknown"} |`
2385
+ );
2386
+ }
2387
+ }
2388
+
2389
+ // Category averages
2390
+ if (aggregated.categoryAverages && Object.keys(aggregated.categoryAverages).length > 0) {
2391
+ lines.push("");
2392
+ lines.push("## Category Averages");
2393
+ lines.push("");
2394
+ for (const [cat, avg] of Object.entries(aggregated.categoryAverages)) {
2395
+ lines.push(`- ${cat}: ${avg}% (${scoreToGrade(avg)})`);
2396
+ }
2397
+ }
2398
+
2399
+ // Weakest pages
2400
+ const scored = pageResults
2401
+ .filter((p) => !p.error && p.analysis?.overallScore != null)
2402
+ .sort((a, b) => a.analysis.overallScore - b.analysis.overallScore);
2403
+
2404
+ if (scored.length > 2) {
2405
+ lines.push("");
2406
+ lines.push("## Weakest Pages");
2407
+ for (const p of scored.slice(0, 3)) {
2408
+ const fails = (p.analysis.categories || [])
2409
+ .filter((c) => c.score < 50)
2410
+ .map((c) => c.category);
2411
+ lines.push(
2412
+ `- ${p.url}: ${p.analysis.overallScore}%${fails.length > 0 ? ` — weak in: ${fails.join(", ")}` : ""}`
2413
+ );
2414
+ }
2415
+ }
2416
+
2417
+ return lines.join("\n");
2418
+ }
2419
+
2420
+ // ---------------------------------------------------------------------------
2421
+ // Tool: analyze_sitemap
2422
+ // ---------------------------------------------------------------------------
2423
+ server.tool(
2424
+ "analyze_sitemap",
2425
+ "Fetch a sitemap XML (or sitemap index), extract page URLs, and run GEO analysis on each page. " +
2426
+ "Returns per-page scores, category averages, and weakest pages. " +
2427
+ "Supports both regular sitemaps and sitemap index files. " +
2428
+ "Requires Pro or Agency tier. " +
2429
+ "Use output_format='json' to get raw results that can be passed to export_bulk_report. " +
2430
+ "For large sitemaps, use 'summary' format or pagination (offset/limit) to avoid exceeding output limits.",
2431
+ {
2432
+ sitemap_url: z
2433
+ .string()
2434
+ .describe(
2435
+ 'Full URL to a sitemap.xml file, e.g. "https://example.com/sitemap.xml"'
2436
+ ),
2437
+ max_urls: z
2438
+ .number()
2439
+ .int()
2440
+ .min(1)
2441
+ .max(50000)
2442
+ .optional()
2443
+ .describe(
2444
+ "Maximum number of URLs to analyse from the sitemap. Defaults to all URLs found (up to 50,000). Set a limit for large sitemaps."
2445
+ ),
2446
+ rate_limit: z
2447
+ .number()
2448
+ .min(0.1)
2449
+ .max(100)
2450
+ .optional()
2451
+ .describe(
2452
+ "Maximum requests per second per domain. Prevents overwhelming target servers. " +
2453
+ "Defaults to 5 req/s (or GLIPPY_RATE_LIMIT env var). Set lower for polite crawling, higher if you control the target server. " +
2454
+ "Use 0.5 for 1 request every 2 seconds, 10 for aggressive crawling."
2455
+ ),
2456
+ output_format: z
2457
+ .enum(["text", "json", "summary"])
2458
+ .optional()
2459
+ .describe(
2460
+ 'Output format. "text" (default) returns a human-readable report. ' +
2461
+ '"json" returns the raw page results and aggregated scores that can be passed to export_bulk_report. ' +
2462
+ '"summary" returns a compact JSON with only aggregated stats and minimal page info (url, score, error) - ideal for large sitemaps.'
2463
+ ),
2464
+ offset: z
2465
+ .number()
2466
+ .int()
2467
+ .min(0)
2468
+ .optional()
2469
+ .describe(
2470
+ "For JSON output: skip the first N page results. Use with limit for pagination of large result sets."
2471
+ ),
2472
+ limit: z
2473
+ .number()
2474
+ .int()
2475
+ .min(1)
2476
+ .max(100)
2477
+ .optional()
2478
+ .describe(
2479
+ "For JSON output: return at most N page results (default: all). Use with offset for pagination. " +
2480
+ "Recommended: 10-20 for detailed results to stay within output limits."
2481
+ ),
2482
+ },
2483
+ withLicense(async ({ sitemap_url, max_urls, rate_limit, output_format, offset, limit }) => {
2484
+ const features = getFeatures();
2485
+
2486
+ // Check if sitemap analysis is available for this tier
2487
+ if (features.maxSitemapUrls === 0) {
2488
+ return {
2489
+ content: [
2490
+ {
2491
+ type: "text",
2492
+ text: "Sitemap analysis requires a Pro or Agency license.\n\nUpgrade at https://www.glippy.dev/mcp",
2493
+ },
2494
+ ],
2495
+ isError: true,
2496
+ };
2497
+ }
2498
+
2499
+ // Enforce tier URL limit
2500
+ const tierMaxUrls = features.maxSitemapUrls || 50000;
2501
+ const maxUrls = Math.min(max_urls ?? tierMaxUrls, tierMaxUrls);
2502
+
2503
+ try {
2504
+ // Fetch the sitemap
2505
+ const sitemapRes = await throttledFetchUrl(sitemap_url, 15000, 512 * 1024);
2506
+
2507
+ if (sitemapRes.statusCode !== 200 || !sitemapRes.body) {
2508
+ return {
2509
+ content: [
2510
+ {
2511
+ type: "text",
2512
+ text: `Failed to fetch sitemap: ${sitemap_url} (HTTP ${sitemapRes.statusCode || "error"})`,
2513
+ },
2514
+ ],
2515
+ isError: true,
2516
+ };
2517
+ }
2518
+
2519
+ // Extract domain from sitemap URL for parsing
2520
+ let domain;
2521
+ try {
2522
+ domain = new URL(sitemap_url).hostname;
2523
+ } catch {
2524
+ return {
2525
+ content: [
2526
+ { type: "text", text: `Invalid sitemap URL: ${sitemap_url}` },
2527
+ ],
2528
+ isError: true,
2529
+ };
2530
+ }
2531
+
2532
+ // Parse URLs from the sitemap
2533
+ const allUrls = await parseSitemapUrls(sitemapRes.body, domain, 50000);
2534
+
2535
+ if (allUrls.length === 0) {
2536
+ return {
2537
+ content: [
2538
+ {
2539
+ type: "text",
2540
+ text: `No URLs found in sitemap: ${sitemap_url}. Ensure it contains <loc> entries.`,
2541
+ },
2542
+ ],
2543
+ isError: true,
2544
+ };
2545
+ }
2546
+
2547
+ const urlsToAnalyse = allUrls.slice(0, maxUrls);
2548
+
2549
+ const lines = [];
2550
+ lines.push(`# Sitemap Analysis: ${sitemap_url}`);
2551
+ lines.push(
2552
+ `Found ${allUrls.length} URLs in sitemap, analysing ${urlsToAnalyse.length}`
2553
+ );
2554
+ lines.push("");
2555
+
2556
+ // Analyse all URLs with rate limiting
2557
+ const rateLimit = rate_limit ?? DEFAULT_RATE_LIMIT;
2558
+ const { pageResults } = await analyseUrls(urlsToAnalyse, 3, rateLimit);
2559
+ const aggregated = aggregatePageScores(pageResults);
2560
+
2561
+ // Summary output mode - compact JSON with minimal page info (ideal for large sitemaps)
2562
+ if (output_format === "summary") {
2563
+ const compactPages = pageResults.map(p => ({
2564
+ url: p.url,
2565
+ score: p.analysis?.overallScore ?? null,
2566
+ pageType: p.analysis?.pageType ?? null,
2567
+ error: p.error || null,
2568
+ }));
2569
+ return {
2570
+ content: [
2571
+ {
2572
+ type: "text",
2573
+ text: JSON.stringify({
2574
+ sitemap_url,
2575
+ urls_found: allUrls.length,
2576
+ urls_analysed: urlsToAnalyse.length,
2577
+ aggregated,
2578
+ pages: compactPages,
2579
+ }, null, 2),
2580
+ },
2581
+ ],
2582
+ };
2583
+ }
2584
+
2585
+ // JSON output mode - return raw results for use with export_bulk_report
2586
+ if (output_format === "json") {
2587
+ // Apply pagination if specified
2588
+ const paginationOffset = offset ?? 0;
2589
+ const paginationLimit = limit ?? pageResults.length;
2590
+ const paginatedResults = pageResults.slice(paginationOffset, paginationOffset + paginationLimit);
2591
+ const hasMore = paginationOffset + paginationLimit < pageResults.length;
2592
+
2593
+ return {
2594
+ content: [
2595
+ {
2596
+ type: "text",
2597
+ text: JSON.stringify({
2598
+ sitemap_url,
2599
+ urls_found: allUrls.length,
2600
+ urls_analysed: urlsToAnalyse.length,
2601
+ // Pagination metadata
2602
+ pagination: {
2603
+ total: pageResults.length,
2604
+ offset: paginationOffset,
2605
+ limit: paginationLimit,
2606
+ returned: paginatedResults.length,
2607
+ hasMore,
2608
+ nextOffset: hasMore ? paginationOffset + paginationLimit : null,
2609
+ },
2610
+ // Always include aggregated stats (computed from ALL results)
2611
+ aggregated,
2612
+ // Paginated page results
2613
+ pageResults: paginatedResults,
2614
+ }, null, 2),
2615
+ },
2616
+ ],
2617
+ };
2618
+ }
2619
+
2620
+ lines.push(formatUrlResults(pageResults));
2621
+
2622
+ return {
2623
+ content: [{ type: "text", text: lines.join("\n") }],
2624
+ };
2625
+ } catch (err) {
2626
+ return {
2627
+ content: [
2628
+ {
2629
+ type: "text",
2630
+ text: `Failed to analyse sitemap ${sitemap_url}: ${err.message}`,
2631
+ },
2632
+ ],
2633
+ isError: true,
2634
+ };
2635
+ }
2636
+ })
2637
+ );
2638
+
2639
+ // ---------------------------------------------------------------------------
2640
+ // Tool: analyze_urls
2641
+ // ---------------------------------------------------------------------------
2642
+ server.tool(
2643
+ "analyze_urls",
2644
+ "Run GEO analysis on a list of specific URLs. " +
2645
+ "Fetches each page, scores it across 10 categories, and returns per-page results with aggregated averages. " +
2646
+ "URLs can span multiple domains. " +
2647
+ "Batch analysis (multiple URLs) requires Pro or Agency tier. " +
2648
+ "Use output_format='json' to get raw results that can be passed to export_bulk_report. " +
2649
+ "For large batches, use 'summary' format or pagination (offset/limit) to avoid exceeding output limits.",
2650
+ {
2651
+ urls: z
2652
+ .array(z.string())
2653
+ .min(1)
2654
+ .max(50000)
2655
+ .describe(
2656
+ 'List of full URLs to analyse, e.g. ["https://example.com/about", "https://example.com/pricing"]. Include https:// prefix.'
2657
+ ),
2658
+ rate_limit: z
2659
+ .number()
2660
+ .min(0.1)
2661
+ .max(100)
2662
+ .optional()
2663
+ .describe(
2664
+ "Maximum requests per second per domain. Prevents overwhelming target servers. " +
2665
+ "Defaults to 5 req/s (or GLIPPY_RATE_LIMIT env var). Set lower for polite crawling, higher if you control the target server. " +
2666
+ "Use 0.5 for 1 request every 2 seconds, 10 for aggressive crawling."
2667
+ ),
2668
+ output_format: z
2669
+ .enum(["text", "json", "summary"])
2670
+ .optional()
2671
+ .describe(
2672
+ 'Output format. "text" (default) returns a human-readable report. ' +
2673
+ '"json" returns the raw page results and aggregated scores that can be passed to export_bulk_report. ' +
2674
+ '"summary" returns a compact JSON with only aggregated stats and minimal page info (url, score, error) - ideal for large batches.'
2675
+ ),
2676
+ offset: z
2677
+ .number()
2678
+ .int()
2679
+ .min(0)
2680
+ .optional()
2681
+ .describe(
2682
+ "For JSON output: skip the first N page results. Use with limit for pagination of large result sets."
2683
+ ),
2684
+ limit: z
2685
+ .number()
2686
+ .int()
2687
+ .min(1)
2688
+ .max(100)
2689
+ .optional()
2690
+ .describe(
2691
+ "For JSON output: return at most N page results (default: all). Use with offset for pagination. " +
2692
+ "Recommended: 10-20 for detailed results to stay within output limits."
2693
+ ),
2694
+ },
2695
+ withLicense(async ({ urls, rate_limit, output_format, offset, limit }) => {
2696
+ const features = getFeatures();
2697
+
2698
+ // Check if batch analysis is available for this tier
2699
+ if (urls.length > features.maxBatchUrls) {
2700
+ if (features.maxBatchUrls === 1) {
2701
+ return {
2702
+ content: [
2703
+ {
2704
+ type: "text",
2705
+ text: `Batch URL analysis requires a Pro or Agency license. Your Personal license allows 1 URL at a time.\n\nUpgrade at https://www.glippy.dev/mcp`,
2706
+ },
2707
+ ],
2708
+ isError: true,
2709
+ };
2710
+ }
2711
+ return {
2712
+ content: [
2713
+ {
2714
+ type: "text",
2715
+ text: `Your license allows up to ${features.maxBatchUrls} URLs per batch. You requested ${urls.length}.`,
2716
+ },
2717
+ ],
2718
+ isError: true,
2719
+ };
2720
+ }
2721
+
2722
+ try {
2723
+ const rateLimit = rate_limit ?? DEFAULT_RATE_LIMIT;
2724
+ const { pageResults } = await analyseUrls(urls, 3, rateLimit);
2725
+ const aggregated = aggregatePageScores(pageResults);
2726
+
2727
+ // Summary output mode - compact JSON with minimal page info (ideal for large batches)
2728
+ if (output_format === "summary") {
2729
+ const compactPages = pageResults.map(p => ({
2730
+ url: p.url,
2731
+ score: p.analysis?.overallScore ?? null,
2732
+ pageType: p.analysis?.pageType ?? null,
2733
+ error: p.error || null,
2734
+ }));
2735
+ return {
2736
+ content: [
2737
+ {
2738
+ type: "text",
2739
+ text: JSON.stringify({
2740
+ urls_analysed: urls.length,
2741
+ aggregated,
2742
+ pages: compactPages,
2743
+ }, null, 2),
2744
+ },
2745
+ ],
2746
+ };
2747
+ }
2748
+
2749
+ // JSON output mode - return raw results for use with export_bulk_report
2750
+ if (output_format === "json") {
2751
+ // Apply pagination if specified
2752
+ const paginationOffset = offset ?? 0;
2753
+ const paginationLimit = limit ?? pageResults.length;
2754
+ const paginatedResults = pageResults.slice(paginationOffset, paginationOffset + paginationLimit);
2755
+ const hasMore = paginationOffset + paginationLimit < pageResults.length;
2756
+
2757
+ return {
2758
+ content: [
2759
+ {
2760
+ type: "text",
2761
+ text: JSON.stringify({
2762
+ urls_analysed: urls.length,
2763
+ // Pagination metadata
2764
+ pagination: {
2765
+ total: pageResults.length,
2766
+ offset: paginationOffset,
2767
+ limit: paginationLimit,
2768
+ returned: paginatedResults.length,
2769
+ hasMore,
2770
+ nextOffset: hasMore ? paginationOffset + paginationLimit : null,
2771
+ },
2772
+ // Always include aggregated stats (computed from ALL results)
2773
+ aggregated,
2774
+ // Paginated page results
2775
+ pageResults: paginatedResults,
2776
+ }, null, 2),
2777
+ },
2778
+ ],
2779
+ };
2780
+ }
2781
+
2782
+ const lines = [];
2783
+ lines.push(`# URL Analysis: ${urls.length} pages`);
2784
+ lines.push("");
2785
+ lines.push(formatUrlResults(pageResults));
2786
+
2787
+ return {
2788
+ content: [{ type: "text", text: lines.join("\n") }],
2789
+ };
2790
+ } catch (err) {
2791
+ return {
2792
+ content: [
2793
+ {
2794
+ type: "text",
2795
+ text: `Failed to analyse URLs: ${err.message}`,
2796
+ },
2797
+ ],
2798
+ isError: true,
2799
+ };
2800
+ }
2801
+ })
2802
+ );
2803
+
2804
+ // ---------------------------------------------------------------------------
2805
+ // Tool: export_report
2806
+ // ---------------------------------------------------------------------------
2807
+ server.tool(
2808
+ "export_report",
2809
+ "Run a GEO analysis on a domain and return the results as a styled, self-contained report " +
2810
+ "in Markdown or HTML format — matching the Glippy browser extension's export output. " +
2811
+ "Use this when the user wants a shareable or saveable report file rather than inline text. " +
2812
+ "You can optionally pass pre-computed analysis results from analyze_domain (with output_format='json') " +
2813
+ "to avoid re-crawling the domain.",
2814
+ {
2815
+ domain: z
2816
+ .string()
2817
+ .optional()
2818
+ .describe(
2819
+ 'The domain to analyse, e.g. "example.com". Do not include https:// prefix. ' +
2820
+ 'Required unless analysis_result is provided.'
2821
+ ),
2822
+ format: z
2823
+ .enum(["markdown", "markdown_full", "html"])
2824
+ .describe(
2825
+ 'Report format. "markdown" = recommendations only, "markdown_full" = full report with all categories and checks, "html" = standalone styled HTML page.'
2826
+ ),
2827
+ max_pages: z
2828
+ .number()
2829
+ .int()
2830
+ .min(1)
2831
+ .max(10)
2832
+ .optional()
2833
+ .describe(
2834
+ "Maximum pages to crawl (1 = homepage only, up to 10 for multi-page analysis). Defaults to 10. " +
2835
+ "Ignored if analysis_result is provided."
2836
+ ),
2837
+ analysis_result: z
2838
+ .object({})
2839
+ .passthrough()
2840
+ .optional()
2841
+ .describe(
2842
+ "Pre-computed analysis result from analyze_domain (with output_format='json'). " +
2843
+ "If provided, the domain will not be re-crawled. This allows you to run analysis once " +
2844
+ "and export in multiple formats without redundant crawling."
2845
+ ),
2846
+ },
2847
+ withLicense(async ({ domain, format, max_pages, analysis_result }) => {
2848
+ try {
2849
+ let result;
2850
+
2851
+ if (analysis_result) {
2852
+ // Use pre-computed results
2853
+ result = analysis_result;
2854
+ if (!result.domain) {
2855
+ return {
2856
+ content: [
2857
+ {
2858
+ type: "text",
2859
+ text: "Invalid analysis_result: missing 'domain' field. Please provide a valid result from analyze_domain.",
2860
+ },
2861
+ ],
2862
+ isError: true,
2863
+ };
2864
+ }
2865
+ } else if (domain) {
2866
+ // Run fresh analysis (may use cache automatically)
2867
+ result = await checkGEO(domain, {
2868
+ maxPages: max_pages ?? 10,
2869
+ });
2870
+ } else {
2871
+ return {
2872
+ content: [
2873
+ {
2874
+ type: "text",
2875
+ text: "Either 'domain' or 'analysis_result' must be provided.",
2876
+ },
2877
+ ],
2878
+ isError: true,
2879
+ };
2880
+ }
2881
+
2882
+ if (result.error) {
2883
+ return {
2884
+ content: [
2885
+ {
2886
+ type: "text",
2887
+ text: `Error analysing ${result.domain || domain}: ${result.error}`,
2888
+ },
2889
+ ],
2890
+ isError: true,
2891
+ };
2892
+ }
2893
+
2894
+ let reportContent;
2895
+ let mimeDescription;
2896
+
2897
+ if (format === "html") {
2898
+ reportContent = generateHTMLReport(result);
2899
+ mimeDescription = "HTML";
2900
+ } else {
2901
+ const fullExport = format === "markdown_full";
2902
+ reportContent = generateMarkdownReport(result, fullExport);
2903
+ mimeDescription = fullExport ? "Markdown (full)" : "Markdown (recommendations)";
2904
+ }
2905
+
2906
+ return {
2907
+ content: [
2908
+ {
2909
+ type: "text",
2910
+ text:
2911
+ `<!-- ${mimeDescription} report for ${result.domain} -->\n` +
2912
+ `<!-- Save this content to a .${format === "html" ? "html" : "md"} file -->\n\n` +
2913
+ reportContent,
2914
+ },
2915
+ ],
2916
+ };
2917
+ } catch (err) {
2918
+ return {
2919
+ content: [
2920
+ {
2921
+ type: "text",
2922
+ text: `Failed to generate report for ${domain}: ${err.message}`,
2923
+ },
2924
+ ],
2925
+ isError: true,
2926
+ };
2927
+ }
2928
+ })
2929
+ );
2930
+
2931
+ // ---------------------------------------------------------------------------
2932
+ // Tool: export_bulk_report
2933
+ // ---------------------------------------------------------------------------
2934
+ server.tool(
2935
+ "export_bulk_report",
2936
+ "Generate a styled Markdown or HTML report for bulk analysis: compare multiple domains, " +
2937
+ "analyse a list of URLs, or crawl a sitemap. Returns a self-contained report with rankings, " +
2938
+ "category breakdowns, and per-domain/page recommendations. " +
2939
+ "Provide exactly one of: domains, urls, sitemap_url, or analysis_results. " +
2940
+ "You can pass pre-computed results from compare_domains, analyze_urls, or analyze_sitemap " +
2941
+ "(with output_format='json') to avoid re-crawling. " +
2942
+ "Requires Pro or Agency tier.",
2943
+ {
2944
+ format: z
2945
+ .enum(["markdown", "html"])
2946
+ .describe(
2947
+ 'Report format. "markdown" = Markdown report, "html" = standalone styled HTML page.'
2948
+ ),
2949
+ domains: z
2950
+ .array(z.string())
2951
+ .min(2)
2952
+ .max(10)
2953
+ .optional()
2954
+ .describe(
2955
+ 'Compare multiple domains. E.g. ["example.com", "competitor.com"]. Do not include https://.'
2956
+ ),
2957
+ urls: z
2958
+ .array(z.string())
2959
+ .min(1)
2960
+ .max(50000)
2961
+ .optional()
2962
+ .describe(
2963
+ 'Analyse specific URLs. E.g. ["https://example.com/about", "https://example.com/pricing"]. Include https://.'
2964
+ ),
2965
+ sitemap_url: z
2966
+ .string()
2967
+ .optional()
2968
+ .describe(
2969
+ 'Sitemap URL to crawl. E.g. "https://example.com/sitemap.xml".'
2970
+ ),
2971
+ analysis_results: z
2972
+ .object({})
2973
+ .passthrough()
2974
+ .optional()
2975
+ .describe(
2976
+ "Pre-computed analysis results from compare_domains, analyze_urls, or analyze_sitemap " +
2977
+ "(with output_format='json'). If provided, no crawling will be performed. " +
2978
+ "The type of report generated depends on the structure of the results."
2979
+ ),
2980
+ max_pages: z
2981
+ .number()
2982
+ .int()
2983
+ .min(1)
2984
+ .max(10)
2985
+ .optional()
2986
+ .describe(
2987
+ "For domain comparison: max pages to crawl per domain (default 10). Ignored for urls/sitemap/analysis_results."
2988
+ ),
2989
+ max_urls: z
2990
+ .number()
2991
+ .int()
2992
+ .min(1)
2993
+ .max(50000)
2994
+ .optional()
2995
+ .describe(
2996
+ "For sitemap mode: max URLs to analyse from the sitemap. Defaults to all. Ignored if analysis_results provided."
2997
+ ),
2998
+ rate_limit: z
2999
+ .number()
3000
+ .min(0.1)
3001
+ .max(100)
3002
+ .optional()
3003
+ .describe(
3004
+ "Max requests/second per domain for URL/sitemap modes. Defaults to 5. Ignored if analysis_results provided."
3005
+ ),
3006
+ },
3007
+ withTierFeature(
3008
+ "bulkExport",
3009
+ "Bulk report exports require a Pro or Agency license.",
3010
+ async ({ format, domains, urls, sitemap_url, analysis_results, max_pages, max_urls, rate_limit }) => {
3011
+ // Validate: exactly one input mode
3012
+ const modes = [domains, urls, sitemap_url, analysis_results].filter(Boolean).length;
3013
+ if (modes !== 1) {
3014
+ return {
3015
+ content: [
3016
+ {
3017
+ type: "text",
3018
+ text: "Provide exactly one of: domains, urls, sitemap_url, or analysis_results.",
3019
+ },
3020
+ ],
3021
+ isError: true,
3022
+ };
3023
+ }
3024
+
3025
+ try {
3026
+ // ------------------------------------------------------------------
3027
+ // Mode 0: Pre-computed analysis results
3028
+ // ------------------------------------------------------------------
3029
+ if (analysis_results) {
3030
+ // Detect the type of results based on structure
3031
+ // Domain comparison: array of checkGEO results
3032
+ // URL/Sitemap analysis: { pageResults, aggregated } or { sitemap_url, pageResults, aggregated }
3033
+
3034
+ if (Array.isArray(analysis_results)) {
3035
+ // Domain comparison results (array of checkGEO results)
3036
+ const entries = [];
3037
+ for (const result of analysis_results) {
3038
+ if (result.error && !result.homepage) {
3039
+ entries.push({ domain: result.domain || "unknown", error: result.error });
3040
+ continue;
3041
+ }
3042
+ const analysis = result.homepage?.analysis;
3043
+ entries.push({
3044
+ domain: result.domain,
3045
+ score: analysis?.overallScore ?? null,
3046
+ grade: analysis ? scoreToGrade(analysis.overallScore) : "N/A",
3047
+ categories: analysis?.categories || [],
3048
+ robotsTxt: result.robotsTxt?.exists ?? false,
3049
+ llmsTxt: result.llmsTxt?.exists ?? false,
3050
+ sitemap: result.sitemap?.exists ?? false,
3051
+ blockedCrawlers: Object.entries(result.robotsTxt?.blocksCrawlers || {})
3052
+ .filter(([, blocked]) => blocked)
3053
+ .map(([name]) => name),
3054
+ error: null,
3055
+ });
3056
+ }
3057
+
3058
+ const reportContent =
3059
+ format === "html"
3060
+ ? generateComparisonHTMLReport(entries)
3061
+ : generateComparisonMarkdownReport(entries);
3062
+
3063
+ return {
3064
+ content: [
3065
+ {
3066
+ type: "text",
3067
+ text:
3068
+ `<!-- Bulk comparison report (${format}) from pre-computed results -->\n` +
3069
+ `<!-- Save this content to a .${format === "html" ? "html" : "md"} file -->\n\n` +
3070
+ reportContent,
3071
+ },
3072
+ ],
3073
+ };
3074
+ } else if (analysis_results.pageResults && analysis_results.aggregated) {
3075
+ // URL or sitemap analysis results
3076
+ const { pageResults, aggregated } = analysis_results;
3077
+ const title = analysis_results.sitemap_url
3078
+ ? `Sitemap: ${analysis_results.sitemap_url}`
3079
+ : `${analysis_results.urls_analysed || pageResults.length} URLs`;
3080
+
3081
+ const reportContent =
3082
+ format === "html"
3083
+ ? generateUrlsHTMLReport(title, pageResults, aggregated)
3084
+ : generateUrlsMarkdownReport(title, pageResults, aggregated);
3085
+
3086
+ return {
3087
+ content: [
3088
+ {
3089
+ type: "text",
3090
+ text:
3091
+ `<!-- Bulk URL report (${format}) from pre-computed results -->\n` +
3092
+ `<!-- Save this content to a .${format === "html" ? "html" : "md"} file -->\n\n` +
3093
+ reportContent,
3094
+ },
3095
+ ],
3096
+ };
3097
+ } else {
3098
+ return {
3099
+ content: [
3100
+ {
3101
+ type: "text",
3102
+ text: "Invalid analysis_results structure. Expected either:\n" +
3103
+ "- Array of checkGEO results (from compare_domains with output_format='json')\n" +
3104
+ "- Object with { pageResults, aggregated } (from analyze_urls or analyze_sitemap with output_format='json')",
3105
+ },
3106
+ ],
3107
+ isError: true,
3108
+ };
3109
+ }
3110
+ }
3111
+
3112
+ // ------------------------------------------------------------------
3113
+ // Mode 1: Multi-domain comparison
3114
+ // ------------------------------------------------------------------
3115
+ if (domains) {
3116
+ const maxPages = max_pages ?? 10;
3117
+ const results = await Promise.allSettled(
3118
+ domains.map((domain) =>
3119
+ checkGEO(domain, { maxPages }).then((result) => ({
3120
+ domain,
3121
+ result,
3122
+ }))
3123
+ )
3124
+ );
3125
+
3126
+ const entries = [];
3127
+ for (const r of results) {
3128
+ if (r.status === "rejected") {
3129
+ entries.push({ domain: "unknown", error: r.reason?.message || "Analysis failed" });
3130
+ continue;
3131
+ }
3132
+ const { domain, result } = r.value;
3133
+ if (result.error) {
3134
+ entries.push({ domain, error: result.error });
3135
+ continue;
3136
+ }
3137
+ const analysis = result.homepage?.analysis;
3138
+ entries.push({
3139
+ domain: result.domain,
3140
+ score: analysis?.overallScore ?? null,
3141
+ grade: analysis ? scoreToGrade(analysis.overallScore) : "N/A",
3142
+ categories: analysis?.categories || [],
3143
+ robotsTxt: result.robotsTxt?.exists ?? false,
3144
+ llmsTxt: result.llmsTxt?.exists ?? false,
3145
+ sitemap: result.sitemap?.exists ?? false,
3146
+ blockedCrawlers: Object.entries(result.robotsTxt?.blocksCrawlers || {})
3147
+ .filter(([, blocked]) => blocked)
3148
+ .map(([name]) => name),
3149
+ error: null,
3150
+ });
3151
+ }
3152
+
3153
+ const reportContent =
3154
+ format === "html"
3155
+ ? generateComparisonHTMLReport(entries)
3156
+ : generateComparisonMarkdownReport(entries);
3157
+
3158
+ return {
3159
+ content: [
3160
+ {
3161
+ type: "text",
3162
+ text:
3163
+ `<!-- Bulk comparison report (${format}) for ${domains.length} domains -->\n` +
3164
+ `<!-- Save this content to a .${format === "html" ? "html" : "md"} file -->\n\n` +
3165
+ reportContent,
3166
+ },
3167
+ ],
3168
+ };
3169
+ }
3170
+
3171
+ // ------------------------------------------------------------------
3172
+ // Mode 2: URL list analysis
3173
+ // ------------------------------------------------------------------
3174
+ if (urls) {
3175
+ const rateLimit = rate_limit ?? DEFAULT_RATE_LIMIT;
3176
+ const { pageResults } = await analyseUrls(urls, 3, rateLimit);
3177
+ const aggregated = aggregatePageScores(pageResults);
3178
+ const title = `${urls.length} URLs`;
3179
+
3180
+ const reportContent =
3181
+ format === "html"
3182
+ ? generateUrlsHTMLReport(title, pageResults, aggregated)
3183
+ : generateUrlsMarkdownReport(title, pageResults, aggregated);
3184
+
3185
+ return {
3186
+ content: [
3187
+ {
3188
+ type: "text",
3189
+ text:
3190
+ `<!-- Bulk URL report (${format}) for ${urls.length} URLs -->\n` +
3191
+ `<!-- Save this content to a .${format === "html" ? "html" : "md"} file -->\n\n` +
3192
+ reportContent,
3193
+ },
3194
+ ],
3195
+ };
3196
+ }
3197
+
3198
+ // ------------------------------------------------------------------
3199
+ // Mode 3: Sitemap analysis
3200
+ // ------------------------------------------------------------------
3201
+ if (sitemap_url) {
3202
+ const sitemapRes = await throttledFetchUrl(sitemap_url, 15000, 512 * 1024);
3203
+ if (sitemapRes.statusCode !== 200 || !sitemapRes.body) {
3204
+ return {
3205
+ content: [
3206
+ {
3207
+ type: "text",
3208
+ text: `Failed to fetch sitemap: ${sitemap_url} (HTTP ${sitemapRes.statusCode || "error"})`,
3209
+ },
3210
+ ],
3211
+ isError: true,
3212
+ };
3213
+ }
3214
+
3215
+ let domain;
3216
+ try {
3217
+ domain = new URL(sitemap_url).hostname;
3218
+ } catch {
3219
+ return {
3220
+ content: [
3221
+ { type: "text", text: `Invalid sitemap URL: ${sitemap_url}` },
3222
+ ],
3223
+ isError: true,
3224
+ };
3225
+ }
3226
+
3227
+ const allUrls = await parseSitemapUrls(sitemapRes.body, domain, 50000);
3228
+ if (allUrls.length === 0) {
3229
+ return {
3230
+ content: [
3231
+ {
3232
+ type: "text",
3233
+ text: `No URLs found in sitemap: ${sitemap_url}`,
3234
+ },
3235
+ ],
3236
+ isError: true,
3237
+ };
3238
+ }
3239
+
3240
+ const urlsToAnalyse = allUrls.slice(0, max_urls ?? 50000);
3241
+ const rateLimit = rate_limit ?? DEFAULT_RATE_LIMIT;
3242
+ const { pageResults } = await analyseUrls(urlsToAnalyse, 3, rateLimit);
3243
+ const aggregated = aggregatePageScores(pageResults);
3244
+ const title = `Sitemap: ${sitemap_url} (${urlsToAnalyse.length} of ${allUrls.length} URLs)`;
3245
+
3246
+ const reportContent =
3247
+ format === "html"
3248
+ ? generateUrlsHTMLReport(title, pageResults, aggregated)
3249
+ : generateUrlsMarkdownReport(title, pageResults, aggregated);
3250
+
3251
+ return {
3252
+ content: [
3253
+ {
3254
+ type: "text",
3255
+ text:
3256
+ `<!-- Sitemap report (${format}) for ${sitemap_url} -->\n` +
3257
+ `<!-- Save this content to a .${format === "html" ? "html" : "md"} file -->\n\n` +
3258
+ reportContent,
3259
+ },
3260
+ ],
3261
+ };
3262
+ }
3263
+ } catch (err) {
3264
+ return {
3265
+ content: [
3266
+ {
3267
+ type: "text",
3268
+ text: `Failed to generate bulk report: ${err.message}`,
3269
+ },
3270
+ ],
3271
+ isError: true,
3272
+ };
3273
+ }
3274
+ })
3275
+ );
3276
+
3277
+ // ---------------------------------------------------------------------------
3278
+ // Graceful deactivation on shutdown
3279
+ // ---------------------------------------------------------------------------
3280
+
3281
+ async function deactivateInstance() {
3282
+ const licenseKey = process.env.GLIPPY_LICENSE_KEY;
3283
+ if (!licenseKey) return;
3284
+ try {
3285
+ await fetch(`${API_BASE}/api/mcp/deactivate-instance`, {
3286
+ method: "POST",
3287
+ headers: { "Content-Type": "application/json" },
3288
+ body: JSON.stringify({ licenseKey, instanceId: INSTANCE_ID }),
3289
+ });
3290
+ } catch {
3291
+ // Best-effort — stale pruning will clean up if this fails
3292
+ }
3293
+ }
3294
+
3295
+ // ---------------------------------------------------------------------------
3296
+ // Start the server
3297
+ // ---------------------------------------------------------------------------
3298
+ async function main() {
3299
+ const transport = new StdioServerTransport();
3300
+ await server.connect(transport);
3301
+ // Server is now running on stdio — log to stderr only
3302
+ console.error("Glippy GEO MCP server running on stdio");
3303
+
3304
+ // Register graceful shutdown handlers
3305
+ process.on("SIGTERM", async () => {
3306
+ await deactivateInstance();
3307
+ process.exit(0);
3308
+ });
3309
+ process.on("SIGINT", async () => {
3310
+ await deactivateInstance();
3311
+ process.exit(0);
3312
+ });
3313
+ }
3314
+
3315
+ main().catch((err) => {
3316
+ console.error("Fatal error:", err);
3317
+ process.exit(1);
3318
+ });