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/LICENSE +64 -0
- package/README.md +734 -0
- package/package.json +43 -0
- package/src/geo-checker.js +4870 -0
- package/src/index.js +3318 -0
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, "<")
|
|
289
|
+
.replace(/>/g, ">")
|
|
290
|
+
.replace(/"/g, """)
|
|
291
|
+
.replace(/'/g, "'");
|
|
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, """)}">
|
|
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
|
+
});
|