geo-checker 1.0.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.
Potentially problematic release.
This version of geo-checker might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/SKILL.md +49 -0
- package/dist/ai-bots-DziNn7Mx.d.ts +86 -0
- package/dist/chunk-3RPMJ2W2.js +127 -0
- package/dist/chunk-3RPMJ2W2.js.map +1 -0
- package/dist/cli.js +1420 -0
- package/dist/cli.js.map +1 -0
- package/dist/data/ai-bots.d.ts +1 -0
- package/dist/data/ai-bots.js +7 -0
- package/dist/data/ai-bots.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1074 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
|
|
7
|
+
// src/data/ai-bots.ts
|
|
8
|
+
var aiBots = [
|
|
9
|
+
{
|
|
10
|
+
name: "GPTBot",
|
|
11
|
+
userAgent: "GPTBot",
|
|
12
|
+
company: "OpenAI",
|
|
13
|
+
purpose: "Both",
|
|
14
|
+
docsUrl: "https://platform.openai.com/docs/bots"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "OAI-SearchBot",
|
|
18
|
+
userAgent: "OAI-SearchBot",
|
|
19
|
+
company: "OpenAI",
|
|
20
|
+
purpose: "Search",
|
|
21
|
+
docsUrl: "https://platform.openai.com/docs/bots"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "ChatGPT-User",
|
|
25
|
+
userAgent: "ChatGPT-User",
|
|
26
|
+
company: "OpenAI",
|
|
27
|
+
purpose: "Browse",
|
|
28
|
+
docsUrl: "https://platform.openai.com/docs/bots"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "ClaudeBot",
|
|
32
|
+
userAgent: "ClaudeBot",
|
|
33
|
+
company: "Anthropic",
|
|
34
|
+
purpose: "Training",
|
|
35
|
+
docsUrl: "https://support.anthropic.com/en/articles/8896518"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "anthropic-ai",
|
|
39
|
+
userAgent: "anthropic-ai",
|
|
40
|
+
company: "Anthropic",
|
|
41
|
+
purpose: "Training",
|
|
42
|
+
docsUrl: "https://support.anthropic.com/en/articles/8896518"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "PerplexityBot",
|
|
46
|
+
userAgent: "PerplexityBot",
|
|
47
|
+
company: "Perplexity",
|
|
48
|
+
purpose: "Search",
|
|
49
|
+
docsUrl: "https://docs.perplexity.ai/docs/perplexitybot"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "Google-Extended",
|
|
53
|
+
userAgent: "Google-Extended",
|
|
54
|
+
company: "Google",
|
|
55
|
+
purpose: "Training",
|
|
56
|
+
docsUrl: "https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "Googlebot",
|
|
60
|
+
userAgent: "Googlebot",
|
|
61
|
+
company: "Google",
|
|
62
|
+
purpose: "Reference",
|
|
63
|
+
docsUrl: "https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "Bytespider",
|
|
67
|
+
userAgent: "Bytespider",
|
|
68
|
+
company: "ByteDance",
|
|
69
|
+
purpose: "Training",
|
|
70
|
+
docsUrl: "https://www.bytedance.com/en/robot"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "CCBot",
|
|
74
|
+
userAgent: "CCBot",
|
|
75
|
+
company: "Common Crawl",
|
|
76
|
+
purpose: "Training",
|
|
77
|
+
docsUrl: "https://commoncrawl.org/ccbot"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "FacebookBot",
|
|
81
|
+
userAgent: "FacebookBot",
|
|
82
|
+
company: "Meta",
|
|
83
|
+
purpose: "Training",
|
|
84
|
+
docsUrl: "https://developers.facebook.com/docs/sharing/webmasters/web-crawlers"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "Meta-ExternalAgent",
|
|
88
|
+
userAgent: "Meta-ExternalAgent",
|
|
89
|
+
company: "Meta",
|
|
90
|
+
purpose: "Training",
|
|
91
|
+
docsUrl: "https://developers.facebook.com/docs/sharing/webmasters/web-crawlers"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "Amazonbot",
|
|
95
|
+
userAgent: "Amazonbot",
|
|
96
|
+
company: "Amazon",
|
|
97
|
+
purpose: "Training",
|
|
98
|
+
docsUrl: "https://developer.amazon.com/support/amazonbot"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "Cohere-ai",
|
|
102
|
+
userAgent: "cohere-ai",
|
|
103
|
+
company: "Cohere",
|
|
104
|
+
purpose: "Training",
|
|
105
|
+
docsUrl: "https://docs.cohere.com/docs/crawlers"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "YouBot",
|
|
109
|
+
userAgent: "YouBot",
|
|
110
|
+
company: "You.com",
|
|
111
|
+
purpose: "Search",
|
|
112
|
+
docsUrl: "https://about.you.com/youbot/"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "Applebot-Extended",
|
|
116
|
+
userAgent: "Applebot-Extended",
|
|
117
|
+
company: "Apple",
|
|
118
|
+
purpose: "Training",
|
|
119
|
+
docsUrl: "https://support.apple.com/en-us/119829"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "Diffbot",
|
|
123
|
+
userAgent: "Diffbot",
|
|
124
|
+
company: "Diffbot",
|
|
125
|
+
purpose: "Training",
|
|
126
|
+
docsUrl: "https://docs.diffbot.com/docs/using-robots-txt"
|
|
127
|
+
}
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// src/analyzers/geo-score.ts
|
|
131
|
+
import { load } from "cheerio";
|
|
132
|
+
|
|
133
|
+
// src/analyzers/bot-access.ts
|
|
134
|
+
function calculateBotAccessPoints(robots) {
|
|
135
|
+
const { allowed, notMentioned, partiallyBlocked, totalBots } = robots.summary;
|
|
136
|
+
if (totalBots === 0) {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
const ratio = (allowed + notMentioned + partiallyBlocked * 0.5) / totalBots;
|
|
140
|
+
return Math.round(10 * ratio);
|
|
141
|
+
}
|
|
142
|
+
function buildBotAccessCheck(robots) {
|
|
143
|
+
const points = calculateBotAccessPoints(robots);
|
|
144
|
+
const allowedEquivalent = robots.summary.allowed + robots.summary.notMentioned + robots.summary.partiallyBlocked * 0.5;
|
|
145
|
+
return {
|
|
146
|
+
id: "ai.bots_access",
|
|
147
|
+
name: "AI bots allowed in robots.txt",
|
|
148
|
+
passed: points >= 7,
|
|
149
|
+
points,
|
|
150
|
+
maxPoints: 10,
|
|
151
|
+
detail: `${allowedEquivalent.toFixed(1)} of ${robots.summary.totalBots} bots counted as allowed-equivalent`,
|
|
152
|
+
fix: "Allow strategic AI bots in robots.txt while explicitly documenting bot policy."
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function buildSitemapCheck(robots) {
|
|
156
|
+
const hasSitemap = robots.sitemaps.length > 0;
|
|
157
|
+
return {
|
|
158
|
+
id: "ai.sitemap_reference",
|
|
159
|
+
name: "Sitemap.xml referenced in robots.txt",
|
|
160
|
+
passed: hasSitemap,
|
|
161
|
+
points: hasSitemap ? 3 : 0,
|
|
162
|
+
maxPoints: 3,
|
|
163
|
+
detail: hasSitemap ? `Found ${robots.sitemaps.length} sitemap reference(s)` : "No sitemap reference found in robots.txt",
|
|
164
|
+
fix: "Add a Sitemap directive in robots.txt (e.g., Sitemap: https://example.com/sitemap.xml)."
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function buildNotMentionedRecommendation(robots) {
|
|
168
|
+
if (robots.summary.notMentioned === 0) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
priority: "low",
|
|
173
|
+
category: "AI Accessibility",
|
|
174
|
+
message: `${robots.summary.notMentioned} AI bot(s) are not explicitly mentioned in robots.txt. Add explicit rules to remove ambiguity.`,
|
|
175
|
+
impact: "No score impact",
|
|
176
|
+
checkId: "ai.bots_explicit_policy"
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/analyzers/content-quality.ts
|
|
181
|
+
function countWords(text) {
|
|
182
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
183
|
+
if (!normalized) {
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
return normalized.split(" ").length;
|
|
187
|
+
}
|
|
188
|
+
function isHeadingHierarchyLogical($) {
|
|
189
|
+
const headings = $("h1,h2,h3,h4,h5,h6").toArray();
|
|
190
|
+
let lastLevel = 0;
|
|
191
|
+
for (const heading of headings) {
|
|
192
|
+
const tag = heading.tagName?.toLowerCase();
|
|
193
|
+
if (!tag || !/^h[1-6]$/.test(tag)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const currentLevel = Number(tag.slice(1));
|
|
197
|
+
if (lastLevel > 0 && currentLevel > lastLevel + 1) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
lastLevel = currentLevel;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
function analyzeLinks($, pageUrl) {
|
|
205
|
+
const currentHost = new URL(pageUrl).hostname;
|
|
206
|
+
let internal = 0;
|
|
207
|
+
let external = 0;
|
|
208
|
+
for (const anchor of $("a[href]").toArray()) {
|
|
209
|
+
const href = $(anchor).attr("href")?.trim();
|
|
210
|
+
if (!href) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const target = new URL(href, pageUrl);
|
|
218
|
+
if (target.hostname === currentHost) {
|
|
219
|
+
internal += 1;
|
|
220
|
+
} else {
|
|
221
|
+
external += 1;
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { internal, external };
|
|
228
|
+
}
|
|
229
|
+
function analyzeContentQuality($, pageUrl) {
|
|
230
|
+
const bodyText = $("body").text();
|
|
231
|
+
const wordCount = countWords(bodyText);
|
|
232
|
+
const paragraphWordCounts = $("p").toArray().map((paragraph) => countWords($(paragraph).text())).filter((words) => words > 0);
|
|
233
|
+
const avgParagraphWords = paragraphWordCounts.length === 0 ? 0 : paragraphWordCounts.reduce((sum, words) => sum + words, 0) / paragraphWordCounts.length;
|
|
234
|
+
const links = analyzeLinks($, pageUrl);
|
|
235
|
+
const hasAuthorSignal = $("[rel='author']").length > 0 || $("[itemprop='author']").length > 0 || $("meta[name='author']").length > 0 || $("[class*='author' i]").length > 0;
|
|
236
|
+
const hasTablesWithHeaders = $("table").toArray().some((table) => $(table).find("th").length > 0);
|
|
237
|
+
return {
|
|
238
|
+
h1Count: $("h1").length,
|
|
239
|
+
headingHierarchyLogical: isHeadingHierarchyLogical($),
|
|
240
|
+
wordCount,
|
|
241
|
+
avgParagraphWords,
|
|
242
|
+
hasLists: $("ul li,ol li").length > 0,
|
|
243
|
+
hasTablesWithHeaders,
|
|
244
|
+
internalLinks: links.internal,
|
|
245
|
+
externalLinks: links.external,
|
|
246
|
+
hasAuthorSignal,
|
|
247
|
+
hasMeaningfulHtmlContent: wordCount >= 120
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/analyzers/meta-tags.ts
|
|
252
|
+
var REQUIRED_OG_TAGS = ["og:title", "og:description", "og:image", "og:type"];
|
|
253
|
+
function getMetaContent($, selector) {
|
|
254
|
+
const value = $(selector).attr("content")?.trim();
|
|
255
|
+
return value || void 0;
|
|
256
|
+
}
|
|
257
|
+
function hasDirective(value, directive) {
|
|
258
|
+
if (!value) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return value.toLowerCase().split(",").map((entry) => entry.trim()).includes(directive);
|
|
262
|
+
}
|
|
263
|
+
function analyzeMetaTags($) {
|
|
264
|
+
const ogMissing = REQUIRED_OG_TAGS.filter((tag) => !getMetaContent($, `meta[property='${tag}']`));
|
|
265
|
+
const canonicalUrl = $("link[rel='canonical']").attr("href")?.trim();
|
|
266
|
+
const viewport = getMetaContent($, "meta[name='viewport']");
|
|
267
|
+
const robots = getMetaContent($, "meta[name='robots']");
|
|
268
|
+
const googleBot = getMetaContent($, "meta[name='googlebot']");
|
|
269
|
+
const noaiMeta = getMetaContent($, "meta[name='noai']");
|
|
270
|
+
const noImageAiMeta = getMetaContent($, "meta[name='noimageai']");
|
|
271
|
+
const publicationDate = getMetaContent($, "meta[property='article:published_time']") ?? getMetaContent($, "meta[name='datePublished']") ?? $("time[datetime]").first().attr("datetime")?.trim();
|
|
272
|
+
const modifiedDate = getMetaContent($, "meta[property='article:modified_time']") ?? getMetaContent($, "meta[name='last-modified']") ?? getMetaContent($, "meta[name='dateModified']");
|
|
273
|
+
const hasMetaAuthor = !!getMetaContent($, "meta[name='author']") || $("link[rel='author']").length > 0 || !!getMetaContent($, "meta[property='article:author']");
|
|
274
|
+
return {
|
|
275
|
+
ogTagsPresent: ogMissing.length === 0,
|
|
276
|
+
ogMissing,
|
|
277
|
+
hasCanonical: !!canonicalUrl,
|
|
278
|
+
canonicalUrl,
|
|
279
|
+
hasViewport: !!viewport,
|
|
280
|
+
hasNoAiDirectives: hasDirective(robots, "noai") || hasDirective(robots, "noimageai") || hasDirective(googleBot, "noai") || hasDirective(googleBot, "noimageai") || !!noaiMeta || !!noImageAiMeta,
|
|
281
|
+
publicationDate,
|
|
282
|
+
modifiedDate,
|
|
283
|
+
hasMetaAuthor
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/analyzers/structured-data.ts
|
|
288
|
+
function normalizeType(value) {
|
|
289
|
+
if (typeof value === "string") {
|
|
290
|
+
return [value];
|
|
291
|
+
}
|
|
292
|
+
if (Array.isArray(value)) {
|
|
293
|
+
return value.filter((item) => typeof item === "string");
|
|
294
|
+
}
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
function walkJsonLd(node, ctx) {
|
|
298
|
+
if (!node) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (Array.isArray(node)) {
|
|
302
|
+
for (const child of node) {
|
|
303
|
+
walkJsonLd(child, ctx);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (typeof node !== "object") {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const record = node;
|
|
311
|
+
for (const typeName of normalizeType(record["@type"])) {
|
|
312
|
+
ctx.types.add(typeName);
|
|
313
|
+
if (typeName.toLowerCase() === "organization") {
|
|
314
|
+
ctx.hasOrganization = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (record.author !== void 0) {
|
|
318
|
+
ctx.hasAuthor = true;
|
|
319
|
+
}
|
|
320
|
+
if (record.datePublished !== void 0) {
|
|
321
|
+
ctx.hasDatePublished = true;
|
|
322
|
+
}
|
|
323
|
+
if (record.dateModified !== void 0 || record.modifiedTime !== void 0) {
|
|
324
|
+
ctx.hasDateModified = true;
|
|
325
|
+
}
|
|
326
|
+
for (const value of Object.values(record)) {
|
|
327
|
+
walkJsonLd(value, ctx);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function analyzeStructuredData($) {
|
|
331
|
+
const scripts = $("script[type='application/ld+json']").toArray();
|
|
332
|
+
const types = /* @__PURE__ */ new Set();
|
|
333
|
+
let validCount = 0;
|
|
334
|
+
let invalidCount = 0;
|
|
335
|
+
const ctx = {
|
|
336
|
+
types,
|
|
337
|
+
hasOrganization: false,
|
|
338
|
+
hasAuthor: false,
|
|
339
|
+
hasDatePublished: false,
|
|
340
|
+
hasDateModified: false
|
|
341
|
+
};
|
|
342
|
+
for (const script of scripts) {
|
|
343
|
+
const payload = $(script).text().trim();
|
|
344
|
+
if (!payload) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(payload);
|
|
349
|
+
validCount += 1;
|
|
350
|
+
walkJsonLd(parsed, ctx);
|
|
351
|
+
} catch {
|
|
352
|
+
invalidCount += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
hasJsonLd: scripts.length > 0,
|
|
357
|
+
validJsonLd: scripts.length > 0 && validCount > 0 && invalidCount === 0,
|
|
358
|
+
schemaTypes: [...types].sort((a, b) => a.localeCompare(b)),
|
|
359
|
+
hasOrganizationSchema: ctx.hasOrganization,
|
|
360
|
+
hasAuthorProperty: ctx.hasAuthor,
|
|
361
|
+
hasDatePublished: ctx.hasDatePublished,
|
|
362
|
+
hasDateModified: ctx.hasDateModified
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/analyzers/technical.ts
|
|
367
|
+
function analyzeTechnical(input) {
|
|
368
|
+
return {
|
|
369
|
+
isHttps: input.finalUrl.startsWith("https://"),
|
|
370
|
+
loadUnder3Seconds: input.loadTimeMs < 3e3
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/scoring/calculator.ts
|
|
375
|
+
function clamp(value, min, max) {
|
|
376
|
+
return Math.max(min, Math.min(max, value));
|
|
377
|
+
}
|
|
378
|
+
function priorityFromMissing(missingPoints) {
|
|
379
|
+
if (missingPoints >= 5) {
|
|
380
|
+
return "high";
|
|
381
|
+
}
|
|
382
|
+
if (missingPoints >= 3) {
|
|
383
|
+
return "medium";
|
|
384
|
+
}
|
|
385
|
+
return "low";
|
|
386
|
+
}
|
|
387
|
+
function calculateCategoryScore(checks, maxScore) {
|
|
388
|
+
const score = clamp(
|
|
389
|
+
checks.reduce((sum, check2) => sum + check2.points, 0),
|
|
390
|
+
0,
|
|
391
|
+
maxScore
|
|
392
|
+
);
|
|
393
|
+
return {
|
|
394
|
+
score,
|
|
395
|
+
maxScore,
|
|
396
|
+
checks
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function calculateGrade(total) {
|
|
400
|
+
if (total >= 95) {
|
|
401
|
+
return "A+";
|
|
402
|
+
}
|
|
403
|
+
if (total >= 90) {
|
|
404
|
+
return "A";
|
|
405
|
+
}
|
|
406
|
+
if (total >= 80) {
|
|
407
|
+
return "B+";
|
|
408
|
+
}
|
|
409
|
+
if (total >= 70) {
|
|
410
|
+
return "B";
|
|
411
|
+
}
|
|
412
|
+
if (total >= 60) {
|
|
413
|
+
return "C";
|
|
414
|
+
}
|
|
415
|
+
if (total >= 40) {
|
|
416
|
+
return "D";
|
|
417
|
+
}
|
|
418
|
+
return "F";
|
|
419
|
+
}
|
|
420
|
+
function recommendationsFromChecks(checksByCategory) {
|
|
421
|
+
const recommendations = [];
|
|
422
|
+
for (const item of checksByCategory) {
|
|
423
|
+
for (const check2 of item.checks) {
|
|
424
|
+
const missingPoints = check2.maxPoints - check2.points;
|
|
425
|
+
if (missingPoints <= 0) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
recommendations.push({
|
|
429
|
+
priority: priorityFromMissing(missingPoints),
|
|
430
|
+
category: item.category,
|
|
431
|
+
message: check2.fix ?? `Improve ${check2.name}.`,
|
|
432
|
+
impact: `+${missingPoints} points`,
|
|
433
|
+
checkId: check2.id
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return recommendations.sort((left, right) => {
|
|
438
|
+
const leftImpact = Number.parseInt(left.impact.replace(/[^0-9]/g, ""), 10) || 0;
|
|
439
|
+
const rightImpact = Number.parseInt(right.impact.replace(/[^0-9]/g, ""), 10) || 0;
|
|
440
|
+
if (leftImpact !== rightImpact) {
|
|
441
|
+
return rightImpact - leftImpact;
|
|
442
|
+
}
|
|
443
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
444
|
+
return order[left.priority] - order[right.priority];
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/analyzers/geo-score.ts
|
|
449
|
+
var RELEVANT_SCHEMA_TYPES = ["Article", "FAQPage", "HowTo", "Product", "Organization"];
|
|
450
|
+
function hasType(types, target) {
|
|
451
|
+
return types.some((type) => type.toLowerCase() === target.toLowerCase());
|
|
452
|
+
}
|
|
453
|
+
function check(id, name, points, maxPoints, detail, fix) {
|
|
454
|
+
return {
|
|
455
|
+
id,
|
|
456
|
+
name,
|
|
457
|
+
passed: points >= maxPoints,
|
|
458
|
+
points,
|
|
459
|
+
maxPoints,
|
|
460
|
+
detail,
|
|
461
|
+
fix
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function analyzeGeoScore(input) {
|
|
465
|
+
const $ = load(input.html || "");
|
|
466
|
+
const structured = analyzeStructuredData($);
|
|
467
|
+
const meta = analyzeMetaTags($);
|
|
468
|
+
const content = analyzeContentQuality($, input.finalUrl);
|
|
469
|
+
const technical = analyzeTechnical({ finalUrl: input.finalUrl, loadTimeMs: input.loadTimeMs });
|
|
470
|
+
const matchedSchemaCount = RELEVANT_SCHEMA_TYPES.filter(
|
|
471
|
+
(schemaType) => hasType(structured.schemaTypes, schemaType)
|
|
472
|
+
).length;
|
|
473
|
+
const structuredChecks = [
|
|
474
|
+
check(
|
|
475
|
+
"structured.jsonld_present",
|
|
476
|
+
"JSON-LD present",
|
|
477
|
+
structured.hasJsonLd ? 8 : 0,
|
|
478
|
+
8,
|
|
479
|
+
structured.hasJsonLd ? "Found JSON-LD script blocks." : "No JSON-LD script detected.",
|
|
480
|
+
"Add JSON-LD structured data to key pages."
|
|
481
|
+
),
|
|
482
|
+
check(
|
|
483
|
+
"structured.schema_types",
|
|
484
|
+
"Relevant Schema.org types",
|
|
485
|
+
Math.min(10, matchedSchemaCount * 5),
|
|
486
|
+
10,
|
|
487
|
+
matchedSchemaCount > 0 ? `Found relevant schema types: ${RELEVANT_SCHEMA_TYPES.filter((schemaType) => hasType(structured.schemaTypes, schemaType)).join(", ")}` : "No relevant schema types found (Article, FAQPage, HowTo, Product, Organization).",
|
|
488
|
+
"Add relevant Schema.org types (Article, FAQPage, HowTo, Product, Organization)."
|
|
489
|
+
),
|
|
490
|
+
check(
|
|
491
|
+
"structured.jsonld_valid",
|
|
492
|
+
"Valid JSON-LD syntax",
|
|
493
|
+
structured.validJsonLd ? 2 : 0,
|
|
494
|
+
2,
|
|
495
|
+
structured.validJsonLd ? "All JSON-LD blocks parsed successfully." : "JSON-LD missing or contains parse errors.",
|
|
496
|
+
"Validate JSON-LD with a schema validator and fix parse errors."
|
|
497
|
+
),
|
|
498
|
+
check(
|
|
499
|
+
"structured.opengraph",
|
|
500
|
+
"OpenGraph tags present",
|
|
501
|
+
meta.ogTagsPresent ? 5 : 0,
|
|
502
|
+
5,
|
|
503
|
+
meta.ogTagsPresent ? "Found core OpenGraph tags (title, description, image, type)." : `Missing OpenGraph tags: ${meta.ogMissing.join(", ")}`,
|
|
504
|
+
"Add complete OpenGraph tags: og:title, og:description, og:image, og:type."
|
|
505
|
+
)
|
|
506
|
+
];
|
|
507
|
+
const contentChecks = [
|
|
508
|
+
check(
|
|
509
|
+
"content.h1_single",
|
|
510
|
+
"Single H1 present",
|
|
511
|
+
content.h1Count === 1 ? 5 : 0,
|
|
512
|
+
5,
|
|
513
|
+
content.h1Count === 1 ? "Exactly one H1 found." : `Found ${content.h1Count} H1 elements.`,
|
|
514
|
+
"Ensure each page has exactly one descriptive H1."
|
|
515
|
+
),
|
|
516
|
+
check(
|
|
517
|
+
"content.heading_hierarchy",
|
|
518
|
+
"Logical heading hierarchy",
|
|
519
|
+
content.headingHierarchyLogical ? 5 : 0,
|
|
520
|
+
5,
|
|
521
|
+
content.headingHierarchyLogical ? "Heading levels are sequential." : "Detected skipped heading levels (e.g., H2 to H4).",
|
|
522
|
+
"Use sequential heading levels without skipping (H1 \u2192 H2 \u2192 H3)."
|
|
523
|
+
),
|
|
524
|
+
check(
|
|
525
|
+
"content.length_300_words",
|
|
526
|
+
"Content length over 300 words",
|
|
527
|
+
content.wordCount > 300 ? 3 : 0,
|
|
528
|
+
3,
|
|
529
|
+
`Detected ${content.wordCount} words.`,
|
|
530
|
+
"Expand the page content to exceed 300 words of useful text."
|
|
531
|
+
),
|
|
532
|
+
check(
|
|
533
|
+
"content.short_paragraphs",
|
|
534
|
+
"Average paragraph length under 150 words",
|
|
535
|
+
content.avgParagraphWords > 0 && content.avgParagraphWords < 150 ? 3 : 0,
|
|
536
|
+
3,
|
|
537
|
+
`Average paragraph length is ${content.avgParagraphWords.toFixed(1)} words.`,
|
|
538
|
+
"Break long paragraphs into shorter blocks for readability."
|
|
539
|
+
),
|
|
540
|
+
check(
|
|
541
|
+
"content.lists_present",
|
|
542
|
+
"Bullet or numbered lists present",
|
|
543
|
+
content.hasLists ? 2 : 0,
|
|
544
|
+
2,
|
|
545
|
+
content.hasLists ? "Found list elements." : "No bullet/numbered lists found.",
|
|
546
|
+
"Use bullet or numbered lists to structure key points."
|
|
547
|
+
),
|
|
548
|
+
check(
|
|
549
|
+
"content.tables_with_headers",
|
|
550
|
+
"Tables with headers present",
|
|
551
|
+
content.hasTablesWithHeaders ? 2 : 0,
|
|
552
|
+
2,
|
|
553
|
+
content.hasTablesWithHeaders ? "Found table(s) with header cells." : "No data tables with headers detected.",
|
|
554
|
+
"Add tables with <th> headers when presenting structured comparisons."
|
|
555
|
+
),
|
|
556
|
+
check(
|
|
557
|
+
"content.internal_links",
|
|
558
|
+
"Internal links present",
|
|
559
|
+
content.internalLinks > 0 ? 3 : 0,
|
|
560
|
+
3,
|
|
561
|
+
`Found ${content.internalLinks} internal links.`,
|
|
562
|
+
"Add links to relevant internal pages to improve crawl pathways."
|
|
563
|
+
),
|
|
564
|
+
check(
|
|
565
|
+
"content.external_links",
|
|
566
|
+
"External authoritative links present",
|
|
567
|
+
content.externalLinks > 0 ? 2 : 0,
|
|
568
|
+
2,
|
|
569
|
+
`Found ${content.externalLinks} external links.`,
|
|
570
|
+
"Add links to authoritative external sources where relevant."
|
|
571
|
+
)
|
|
572
|
+
];
|
|
573
|
+
const authorityChecks = [
|
|
574
|
+
check(
|
|
575
|
+
"authority.author_signal",
|
|
576
|
+
"Author name or bio present",
|
|
577
|
+
content.hasAuthorSignal || meta.hasMetaAuthor || structured.hasAuthorProperty ? 6 : 0,
|
|
578
|
+
6,
|
|
579
|
+
content.hasAuthorSignal || meta.hasMetaAuthor || structured.hasAuthorProperty ? "Detected author signals in page markup." : "No author information detected.",
|
|
580
|
+
"Add an author name/bio and include structured author metadata."
|
|
581
|
+
),
|
|
582
|
+
check(
|
|
583
|
+
"authority.publication_date",
|
|
584
|
+
"Publication date present",
|
|
585
|
+
meta.publicationDate || structured.hasDatePublished ? 5 : 0,
|
|
586
|
+
5,
|
|
587
|
+
meta.publicationDate || structured.hasDatePublished ? `Publication date detected${meta.publicationDate ? ` (${meta.publicationDate})` : " in schema"}.` : "No publication date detected.",
|
|
588
|
+
"Add datePublished in schema or article meta tags."
|
|
589
|
+
),
|
|
590
|
+
check(
|
|
591
|
+
"authority.last_modified",
|
|
592
|
+
"Last modified date present",
|
|
593
|
+
meta.modifiedDate || structured.hasDateModified ? 4 : 0,
|
|
594
|
+
4,
|
|
595
|
+
meta.modifiedDate || structured.hasDateModified ? `Last modified date detected${meta.modifiedDate ? ` (${meta.modifiedDate})` : " in schema"}.` : "No last modified date detected.",
|
|
596
|
+
"Add dateModified / article:modified_time metadata."
|
|
597
|
+
),
|
|
598
|
+
check(
|
|
599
|
+
"authority.canonical",
|
|
600
|
+
"Canonical URL set",
|
|
601
|
+
meta.hasCanonical ? 4 : 0,
|
|
602
|
+
4,
|
|
603
|
+
meta.hasCanonical ? `Canonical URL: ${meta.canonicalUrl}` : "No canonical URL found.",
|
|
604
|
+
"Add a canonical link tag to avoid duplicate-content ambiguity."
|
|
605
|
+
),
|
|
606
|
+
check(
|
|
607
|
+
"authority.https",
|
|
608
|
+
"HTTPS enabled",
|
|
609
|
+
technical.isHttps ? 3 : 0,
|
|
610
|
+
3,
|
|
611
|
+
technical.isHttps ? "Page is served via HTTPS." : "Page is not served via HTTPS.",
|
|
612
|
+
"Serve pages over HTTPS."
|
|
613
|
+
),
|
|
614
|
+
check(
|
|
615
|
+
"authority.organization_schema",
|
|
616
|
+
"Organization schema present",
|
|
617
|
+
structured.hasOrganizationSchema ? 3 : 0,
|
|
618
|
+
3,
|
|
619
|
+
structured.hasOrganizationSchema ? "Organization schema detected." : "No Organization schema detected.",
|
|
620
|
+
"Add Organization schema to establish publisher identity."
|
|
621
|
+
)
|
|
622
|
+
];
|
|
623
|
+
const aiChecks = [
|
|
624
|
+
buildBotAccessCheck(input.robots),
|
|
625
|
+
check(
|
|
626
|
+
"ai.no_noai_tags",
|
|
627
|
+
"No noai/noimageai directives",
|
|
628
|
+
meta.hasNoAiDirectives ? 0 : 3,
|
|
629
|
+
3,
|
|
630
|
+
meta.hasNoAiDirectives ? "Detected noai/noimageai directives that may block AI usage." : "No restrictive noai/noimageai directives found.",
|
|
631
|
+
"Remove noai/noimageai directives if you want AI systems to cite your content."
|
|
632
|
+
),
|
|
633
|
+
buildSitemapCheck(input.robots),
|
|
634
|
+
check(
|
|
635
|
+
"ai.load_under_3s",
|
|
636
|
+
"Page loads under 3 seconds",
|
|
637
|
+
technical.loadUnder3Seconds ? 3 : 0,
|
|
638
|
+
3,
|
|
639
|
+
`Measured load time: ${input.loadTimeMs}ms.`,
|
|
640
|
+
"Improve server and asset performance to stay below 3 seconds."
|
|
641
|
+
),
|
|
642
|
+
check(
|
|
643
|
+
"ai.viewport_meta",
|
|
644
|
+
"Mobile viewport meta present",
|
|
645
|
+
meta.hasViewport ? 3 : 0,
|
|
646
|
+
3,
|
|
647
|
+
meta.hasViewport ? "Viewport meta tag present." : "Viewport meta tag missing.",
|
|
648
|
+
'Add <meta name="viewport" content="width=device-width, initial-scale=1">.'
|
|
649
|
+
),
|
|
650
|
+
check(
|
|
651
|
+
"ai.meaningful_html_content",
|
|
652
|
+
"Meaningful raw HTML content present",
|
|
653
|
+
content.hasMeaningfulHtmlContent ? 3 : 0,
|
|
654
|
+
3,
|
|
655
|
+
content.hasMeaningfulHtmlContent ? `Raw HTML includes ${content.wordCount} words of visible text.` : "Raw HTML has limited visible text and may rely on client-side rendering.",
|
|
656
|
+
"Ensure important content is rendered in raw HTML, not only via JavaScript."
|
|
657
|
+
)
|
|
658
|
+
];
|
|
659
|
+
const structuredData = calculateCategoryScore(structuredChecks, 25);
|
|
660
|
+
const contentStructure = calculateCategoryScore(contentChecks, 25);
|
|
661
|
+
const authoritySignals = calculateCategoryScore(authorityChecks, 25);
|
|
662
|
+
const aiAccessibility = calculateCategoryScore(aiChecks, 25);
|
|
663
|
+
const total = structuredData.score + contentStructure.score + authoritySignals.score + aiAccessibility.score;
|
|
664
|
+
const recommendations = recommendationsFromChecks([
|
|
665
|
+
{ category: "Structured Data", checks: structuredChecks },
|
|
666
|
+
{ category: "Content Structure", checks: contentChecks },
|
|
667
|
+
{ category: "Authority Signals", checks: authorityChecks },
|
|
668
|
+
{ category: "AI Accessibility", checks: aiChecks }
|
|
669
|
+
]);
|
|
670
|
+
const notMentionedWarning = buildNotMentionedRecommendation(input.robots);
|
|
671
|
+
if (notMentionedWarning) {
|
|
672
|
+
recommendations.push(notMentionedWarning);
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
total,
|
|
676
|
+
grade: calculateGrade(total),
|
|
677
|
+
categories: {
|
|
678
|
+
structuredData,
|
|
679
|
+
contentStructure,
|
|
680
|
+
authoritySignals,
|
|
681
|
+
aiAccessibility
|
|
682
|
+
},
|
|
683
|
+
recommendations: recommendations.sort((left, right) => {
|
|
684
|
+
const leftScore = Number.parseInt(left.impact.replace(/[^0-9]/g, ""), 10) || 0;
|
|
685
|
+
const rightScore = Number.parseInt(right.impact.replace(/[^0-9]/g, ""), 10) || 0;
|
|
686
|
+
if (leftScore !== rightScore) {
|
|
687
|
+
return rightScore - leftScore;
|
|
688
|
+
}
|
|
689
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
690
|
+
return order[left.priority] - order[right.priority];
|
|
691
|
+
})
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/scanner/http.ts
|
|
696
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
697
|
+
async function fetchWithRedirects(initialUrl, options) {
|
|
698
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
699
|
+
let currentUrl = initialUrl;
|
|
700
|
+
for (let redirects = 0; redirects <= options.maxRedirects; redirects += 1) {
|
|
701
|
+
const controller = new AbortController();
|
|
702
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
703
|
+
try {
|
|
704
|
+
const response = await fetchImpl(currentUrl, {
|
|
705
|
+
method: "GET",
|
|
706
|
+
redirect: "manual",
|
|
707
|
+
signal: controller.signal,
|
|
708
|
+
headers: {
|
|
709
|
+
"user-agent": "geo-check/1.0.0",
|
|
710
|
+
accept: "text/html,text/plain,*/*",
|
|
711
|
+
...options.headers
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
715
|
+
return { response, finalUrl: currentUrl };
|
|
716
|
+
}
|
|
717
|
+
const location = response.headers.get("location");
|
|
718
|
+
if (!location) {
|
|
719
|
+
return { response, finalUrl: currentUrl };
|
|
720
|
+
}
|
|
721
|
+
if (redirects === options.maxRedirects) {
|
|
722
|
+
throw new Error(`Too many redirects (>${options.maxRedirects})`);
|
|
723
|
+
}
|
|
724
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
725
|
+
} finally {
|
|
726
|
+
clearTimeout(timeoutId);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
throw new Error(`Too many redirects (>${options.maxRedirects})`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/scanner/robots.ts
|
|
733
|
+
function normalizeLine(line) {
|
|
734
|
+
const hashIndex = line.indexOf("#");
|
|
735
|
+
const withoutComment = hashIndex >= 0 ? line.slice(0, hashIndex) : line;
|
|
736
|
+
return withoutComment.trim();
|
|
737
|
+
}
|
|
738
|
+
function parseDirective(line) {
|
|
739
|
+
const colon = line.indexOf(":");
|
|
740
|
+
if (colon <= 0) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
744
|
+
const value = line.slice(colon + 1).trim();
|
|
745
|
+
if (!key) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
return { key, value };
|
|
749
|
+
}
|
|
750
|
+
function parseRobotsTxt(content) {
|
|
751
|
+
const groups = [];
|
|
752
|
+
const sitemaps = [];
|
|
753
|
+
let currentGroup = null;
|
|
754
|
+
let hasRuleInCurrentGroup = false;
|
|
755
|
+
const lines = content.split(/\r?\n/);
|
|
756
|
+
for (const rawLine of lines) {
|
|
757
|
+
const line = normalizeLine(rawLine);
|
|
758
|
+
if (!line) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const parsed = parseDirective(line);
|
|
762
|
+
if (!parsed) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (parsed.key === "user-agent") {
|
|
766
|
+
const userAgent = parsed.value.toLowerCase();
|
|
767
|
+
if (!userAgent) {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (!currentGroup || hasRuleInCurrentGroup) {
|
|
771
|
+
currentGroup = { userAgents: [], rules: [] };
|
|
772
|
+
groups.push(currentGroup);
|
|
773
|
+
hasRuleInCurrentGroup = false;
|
|
774
|
+
}
|
|
775
|
+
currentGroup.userAgents.push(userAgent);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (parsed.key === "allow" || parsed.key === "disallow") {
|
|
779
|
+
if (!currentGroup) {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
if (!parsed.value) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
currentGroup.rules.push({ directive: parsed.key, path: parsed.value });
|
|
786
|
+
hasRuleInCurrentGroup = true;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (parsed.key === "sitemap") {
|
|
790
|
+
if (parsed.value) {
|
|
791
|
+
sitemaps.push(parsed.value);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return { groups, sitemaps };
|
|
796
|
+
}
|
|
797
|
+
function escapeRegex(value) {
|
|
798
|
+
return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
799
|
+
}
|
|
800
|
+
function getMatchLength(targetPath, rulePath) {
|
|
801
|
+
if (!rulePath) {
|
|
802
|
+
return 0;
|
|
803
|
+
}
|
|
804
|
+
if (!rulePath.includes("*") && !rulePath.includes("$")) {
|
|
805
|
+
return targetPath.startsWith(rulePath) ? rulePath.length : -1;
|
|
806
|
+
}
|
|
807
|
+
const endsWithAnchor = rulePath.endsWith("$");
|
|
808
|
+
const patternBody = endsWithAnchor ? rulePath.slice(0, -1) : rulePath;
|
|
809
|
+
const escaped = escapeRegex(patternBody).replace(/\\\*/g, ".*");
|
|
810
|
+
const pattern = endsWithAnchor ? `^${escaped}$` : `^${escaped}`;
|
|
811
|
+
const regex = new RegExp(pattern);
|
|
812
|
+
const match = targetPath.match(regex);
|
|
813
|
+
if (!match || !match[0]) {
|
|
814
|
+
return -1;
|
|
815
|
+
}
|
|
816
|
+
return match[0].length;
|
|
817
|
+
}
|
|
818
|
+
function getDecisionForPath(path, rules) {
|
|
819
|
+
let bestDecision = "none";
|
|
820
|
+
let bestLength = -1;
|
|
821
|
+
for (const rule of rules) {
|
|
822
|
+
const matchLength = getMatchLength(path, rule.path);
|
|
823
|
+
if (matchLength < 0) {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (matchLength > bestLength) {
|
|
827
|
+
bestLength = matchLength;
|
|
828
|
+
bestDecision = rule.directive;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if (matchLength === bestLength && rule.directive === "allow") {
|
|
832
|
+
bestDecision = "allow";
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
decision: bestDecision,
|
|
837
|
+
matchedLength: Math.max(bestLength, 0)
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
function pathProbeFromRule(rulePath) {
|
|
841
|
+
const withoutAnchor = rulePath.endsWith("$") ? rulePath.slice(0, -1) : rulePath;
|
|
842
|
+
const withWildcardExpanded = withoutAnchor.replace(/\*/g, "sample");
|
|
843
|
+
if (!withWildcardExpanded.startsWith("/")) {
|
|
844
|
+
return `/${withWildcardExpanded}`;
|
|
845
|
+
}
|
|
846
|
+
return withWildcardExpanded || "/";
|
|
847
|
+
}
|
|
848
|
+
function classifyStatus(rules) {
|
|
849
|
+
if (rules.length === 0) {
|
|
850
|
+
return "allowed";
|
|
851
|
+
}
|
|
852
|
+
const hasDisallow = rules.some((rule) => rule.directive === "disallow");
|
|
853
|
+
if (!hasDisallow) {
|
|
854
|
+
return "allowed";
|
|
855
|
+
}
|
|
856
|
+
const rootDecision = getDecisionForPath("/", rules).decision;
|
|
857
|
+
if (rootDecision === "disallow") {
|
|
858
|
+
const hasAllowedIsland = rules.some((rule) => {
|
|
859
|
+
if (rule.directive !== "allow") {
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
const probe = pathProbeFromRule(rule.path);
|
|
863
|
+
return getDecisionForPath(probe, rules).decision === "allow";
|
|
864
|
+
});
|
|
865
|
+
return hasAllowedIsland ? "partially_blocked" : "blocked";
|
|
866
|
+
}
|
|
867
|
+
const blocksAnyPath = rules.some((rule) => {
|
|
868
|
+
if (rule.directive !== "disallow") {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
const probe = pathProbeFromRule(rule.path);
|
|
872
|
+
return getDecisionForPath(probe, rules).decision === "disallow";
|
|
873
|
+
});
|
|
874
|
+
return blocksAnyPath ? "partially_blocked" : "allowed";
|
|
875
|
+
}
|
|
876
|
+
function summarize(results) {
|
|
877
|
+
const summary = {
|
|
878
|
+
totalBots: results.length,
|
|
879
|
+
allowed: 0,
|
|
880
|
+
blocked: 0,
|
|
881
|
+
partiallyBlocked: 0,
|
|
882
|
+
notMentioned: 0
|
|
883
|
+
};
|
|
884
|
+
for (const result of results) {
|
|
885
|
+
if (result.status === "allowed") {
|
|
886
|
+
summary.allowed += 1;
|
|
887
|
+
} else if (result.status === "blocked") {
|
|
888
|
+
summary.blocked += 1;
|
|
889
|
+
} else if (result.status === "partially_blocked") {
|
|
890
|
+
summary.partiallyBlocked += 1;
|
|
891
|
+
} else {
|
|
892
|
+
summary.notMentioned += 1;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return summary;
|
|
896
|
+
}
|
|
897
|
+
function toRuleLines(rules) {
|
|
898
|
+
const seen = /* @__PURE__ */ new Set();
|
|
899
|
+
const lines = [];
|
|
900
|
+
for (const rule of rules) {
|
|
901
|
+
const line = `${rule.directive === "allow" ? "Allow" : "Disallow"}: ${rule.path}`;
|
|
902
|
+
if (!seen.has(line)) {
|
|
903
|
+
seen.add(line);
|
|
904
|
+
lines.push(line);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return lines;
|
|
908
|
+
}
|
|
909
|
+
function getApplicableRules(parsed, botUserAgent) {
|
|
910
|
+
const target = botUserAgent.toLowerCase();
|
|
911
|
+
const specificGroups = parsed.groups.filter(
|
|
912
|
+
(group) => group.userAgents.some((agent) => agent === target)
|
|
913
|
+
);
|
|
914
|
+
if (specificGroups.length > 0) {
|
|
915
|
+
return {
|
|
916
|
+
source: "specific",
|
|
917
|
+
rules: specificGroups.flatMap((group) => group.rules)
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const wildcardGroups = parsed.groups.filter(
|
|
921
|
+
(group) => group.userAgents.some((agent) => agent === "*")
|
|
922
|
+
);
|
|
923
|
+
if (wildcardGroups.length > 0) {
|
|
924
|
+
return {
|
|
925
|
+
source: "wildcard",
|
|
926
|
+
rules: wildcardGroups.flatMap((group) => group.rules)
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
return { source: "none", rules: [] };
|
|
930
|
+
}
|
|
931
|
+
function evaluateBotAccess(bot, parsed) {
|
|
932
|
+
const applicable = getApplicableRules(parsed, bot.userAgent);
|
|
933
|
+
if (applicable.source === "none") {
|
|
934
|
+
return {
|
|
935
|
+
bot,
|
|
936
|
+
status: "not_mentioned",
|
|
937
|
+
rules: []
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const status = classifyStatus(applicable.rules);
|
|
941
|
+
return {
|
|
942
|
+
bot,
|
|
943
|
+
status,
|
|
944
|
+
rules: toRuleLines(applicable.rules)
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function buildImplicitAllowAnalysis(robotsUrl, warning, bots) {
|
|
948
|
+
const botResults = bots.map((bot) => ({
|
|
949
|
+
bot,
|
|
950
|
+
status: "not_mentioned",
|
|
951
|
+
rules: []
|
|
952
|
+
}));
|
|
953
|
+
return {
|
|
954
|
+
url: robotsUrl,
|
|
955
|
+
found: false,
|
|
956
|
+
rawContent: "",
|
|
957
|
+
botResults,
|
|
958
|
+
summary: summarize(botResults),
|
|
959
|
+
sitemaps: [],
|
|
960
|
+
warnings: [warning]
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
async function analyzeRobots(siteUrl, options) {
|
|
964
|
+
const bots = options?.bots ?? aiBots;
|
|
965
|
+
const timeoutMs = options?.timeoutMs ?? 5e3;
|
|
966
|
+
const maxRedirects = options?.maxRedirects ?? 5;
|
|
967
|
+
const robotsUrl = new URL("/robots.txt", siteUrl).toString();
|
|
968
|
+
try {
|
|
969
|
+
const { response } = await fetchWithRedirects(robotsUrl, {
|
|
970
|
+
fetchImpl: options?.fetchImpl,
|
|
971
|
+
timeoutMs,
|
|
972
|
+
maxRedirects
|
|
973
|
+
});
|
|
974
|
+
if (response.status === 404) {
|
|
975
|
+
return buildImplicitAllowAnalysis(
|
|
976
|
+
robotsUrl,
|
|
977
|
+
"No robots.txt found (404). AI bots are implicitly allowed.",
|
|
978
|
+
bots
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
if (!response.ok) {
|
|
982
|
+
return buildImplicitAllowAnalysis(
|
|
983
|
+
robotsUrl,
|
|
984
|
+
`Could not read robots.txt (${response.status}). AI bots are treated as implicitly allowed.`,
|
|
985
|
+
bots
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
const rawContent = await response.text();
|
|
989
|
+
const parsed = parseRobotsTxt(rawContent);
|
|
990
|
+
const botResults = bots.map((bot) => evaluateBotAccess(bot, parsed));
|
|
991
|
+
return {
|
|
992
|
+
url: robotsUrl,
|
|
993
|
+
found: true,
|
|
994
|
+
rawContent,
|
|
995
|
+
botResults,
|
|
996
|
+
summary: summarize(botResults),
|
|
997
|
+
sitemaps: parsed.sitemaps,
|
|
998
|
+
warnings: []
|
|
999
|
+
};
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
return buildImplicitAllowAnalysis(
|
|
1002
|
+
robotsUrl,
|
|
1003
|
+
`Failed to fetch robots.txt: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
1004
|
+
bots
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/scanner/page.ts
|
|
1010
|
+
function headersToObject(headers) {
|
|
1011
|
+
const output = {};
|
|
1012
|
+
headers.forEach((value, key) => {
|
|
1013
|
+
output[key.toLowerCase()] = value;
|
|
1014
|
+
});
|
|
1015
|
+
return output;
|
|
1016
|
+
}
|
|
1017
|
+
async function fetchPage(url, options) {
|
|
1018
|
+
const timeoutMs = options?.timeoutMs ?? 8e3;
|
|
1019
|
+
const maxRedirects = options?.maxRedirects ?? 5;
|
|
1020
|
+
const start = Date.now();
|
|
1021
|
+
try {
|
|
1022
|
+
const { response, finalUrl } = await fetchWithRedirects(url, {
|
|
1023
|
+
fetchImpl: options?.fetchImpl,
|
|
1024
|
+
timeoutMs,
|
|
1025
|
+
maxRedirects
|
|
1026
|
+
});
|
|
1027
|
+
const html = await response.text();
|
|
1028
|
+
return {
|
|
1029
|
+
requestedUrl: url,
|
|
1030
|
+
finalUrl,
|
|
1031
|
+
statusCode: response.status,
|
|
1032
|
+
ok: response.ok,
|
|
1033
|
+
html,
|
|
1034
|
+
headers: headersToObject(response.headers),
|
|
1035
|
+
loadTimeMs: Date.now() - start,
|
|
1036
|
+
error: response.ok ? void 0 : `HTTP ${response.status}`
|
|
1037
|
+
};
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
return {
|
|
1040
|
+
requestedUrl: url,
|
|
1041
|
+
finalUrl: url,
|
|
1042
|
+
statusCode: null,
|
|
1043
|
+
ok: false,
|
|
1044
|
+
html: "",
|
|
1045
|
+
headers: {},
|
|
1046
|
+
loadTimeMs: Date.now() - start,
|
|
1047
|
+
error: error instanceof Error ? error.message : "Unknown fetch error"
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/index.ts
|
|
1053
|
+
var SCHEMA_VERSION = "1.0.0";
|
|
1054
|
+
var MONITOR_URL = "https://www.summalytics.ai";
|
|
1055
|
+
function normalizeUrl(input) {
|
|
1056
|
+
const trimmed = input.trim();
|
|
1057
|
+
if (!trimmed) {
|
|
1058
|
+
throw new Error("URL is empty.");
|
|
1059
|
+
}
|
|
1060
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
1061
|
+
const parsed = new URL(withScheme);
|
|
1062
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1063
|
+
throw new Error("Only http:// and https:// URLs are supported.");
|
|
1064
|
+
}
|
|
1065
|
+
return parsed.toString();
|
|
1066
|
+
}
|
|
1067
|
+
function notMentionedWarningRecommendation(count) {
|
|
1068
|
+
if (count <= 0) {
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
priority: "low",
|
|
1073
|
+
category: "AI Accessibility",
|
|
1074
|
+
message: `${count} AI bot(s) are not explicitly mentioned in robots.txt. Add explicit directives for policy clarity.`,
|
|
1075
|
+
impact: "No score impact",
|
|
1076
|
+
checkId: "ai.bots_explicit_policy"
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
async function geoCheck(url, options) {
|
|
1080
|
+
const startedAt = Date.now();
|
|
1081
|
+
const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1082
|
+
const warnings = [];
|
|
1083
|
+
const errors = [];
|
|
1084
|
+
let targetUrl;
|
|
1085
|
+
try {
|
|
1086
|
+
targetUrl = normalizeUrl(url);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
const message = error instanceof Error ? error.message : "Invalid URL";
|
|
1089
|
+
return {
|
|
1090
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1091
|
+
targetUrl: url,
|
|
1092
|
+
scannedAt,
|
|
1093
|
+
durationMs: Date.now() - startedAt,
|
|
1094
|
+
robots: null,
|
|
1095
|
+
geoScore: null,
|
|
1096
|
+
recommendations: [],
|
|
1097
|
+
monitor: MONITOR_URL,
|
|
1098
|
+
warnings,
|
|
1099
|
+
errors: [message],
|
|
1100
|
+
success: false
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
const shouldFetchPage = !options?.robotsOnly;
|
|
1104
|
+
const shouldFetchRobots = true;
|
|
1105
|
+
const [robots, page] = await Promise.all([
|
|
1106
|
+
shouldFetchRobots ? analyzeRobots(targetUrl, {
|
|
1107
|
+
fetchImpl: options?.fetchImpl,
|
|
1108
|
+
timeoutMs: 5e3,
|
|
1109
|
+
maxRedirects: 5,
|
|
1110
|
+
bots: aiBots
|
|
1111
|
+
}) : Promise.resolve(null),
|
|
1112
|
+
shouldFetchPage ? fetchPage(targetUrl, {
|
|
1113
|
+
fetchImpl: options?.fetchImpl,
|
|
1114
|
+
timeoutMs: 8e3,
|
|
1115
|
+
maxRedirects: 5
|
|
1116
|
+
}) : Promise.resolve(null)
|
|
1117
|
+
]);
|
|
1118
|
+
if (robots?.warnings.length) {
|
|
1119
|
+
warnings.push(...robots.warnings);
|
|
1120
|
+
}
|
|
1121
|
+
if (page?.error) {
|
|
1122
|
+
warnings.push(`Page fetch warning: ${page.error}`);
|
|
1123
|
+
}
|
|
1124
|
+
let geoScore = null;
|
|
1125
|
+
if (shouldFetchPage) {
|
|
1126
|
+
if (page && page.ok && page.html) {
|
|
1127
|
+
geoScore = analyzeGeoScore({
|
|
1128
|
+
html: page.html,
|
|
1129
|
+
finalUrl: page.finalUrl,
|
|
1130
|
+
loadTimeMs: page.loadTimeMs,
|
|
1131
|
+
robots: robots ?? {
|
|
1132
|
+
url: `${targetUrl.replace(/\/$/, "")}/robots.txt`,
|
|
1133
|
+
found: false,
|
|
1134
|
+
rawContent: "",
|
|
1135
|
+
botResults: [],
|
|
1136
|
+
summary: {
|
|
1137
|
+
totalBots: 0,
|
|
1138
|
+
allowed: 0,
|
|
1139
|
+
blocked: 0,
|
|
1140
|
+
partiallyBlocked: 0,
|
|
1141
|
+
notMentioned: 0
|
|
1142
|
+
},
|
|
1143
|
+
sitemaps: [],
|
|
1144
|
+
warnings: []
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
} else {
|
|
1148
|
+
errors.push("Could not fetch page HTML for GEO analysis.");
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const recommendations = geoScore ? [...geoScore.recommendations] : [];
|
|
1152
|
+
if (!geoScore && robots) {
|
|
1153
|
+
const warningRec = notMentionedWarningRecommendation(robots.summary.notMentioned);
|
|
1154
|
+
if (warningRec) {
|
|
1155
|
+
recommendations.push(warningRec);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const result = {
|
|
1159
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1160
|
+
targetUrl,
|
|
1161
|
+
scannedAt,
|
|
1162
|
+
durationMs: Date.now() - startedAt,
|
|
1163
|
+
robots: options?.geoOnly ? null : robots,
|
|
1164
|
+
geoScore: options?.robotsOnly ? null : geoScore,
|
|
1165
|
+
recommendations,
|
|
1166
|
+
monitor: MONITOR_URL,
|
|
1167
|
+
warnings,
|
|
1168
|
+
errors,
|
|
1169
|
+
success: errors.length === 0 || !!robots
|
|
1170
|
+
};
|
|
1171
|
+
return result;
|
|
1172
|
+
}
|
|
1173
|
+
async function mapLimit(items, concurrency, worker) {
|
|
1174
|
+
const output = new Array(items.length);
|
|
1175
|
+
let nextIndex = 0;
|
|
1176
|
+
async function runner() {
|
|
1177
|
+
while (true) {
|
|
1178
|
+
const current = nextIndex;
|
|
1179
|
+
nextIndex += 1;
|
|
1180
|
+
if (current >= items.length) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
output[current] = await worker(items[current], current);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
const safeConcurrency = Math.max(1, Math.min(concurrency, items.length || 1));
|
|
1187
|
+
await Promise.all(Array.from({ length: safeConcurrency }, () => runner()));
|
|
1188
|
+
return output;
|
|
1189
|
+
}
|
|
1190
|
+
async function geoCheckMany(urls, options) {
|
|
1191
|
+
const concurrency = options?.concurrency ?? 3;
|
|
1192
|
+
return mapLimit(urls, concurrency, (url) => geoCheck(url, options));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// src/reporters/json.ts
|
|
1196
|
+
function serializeResult(result) {
|
|
1197
|
+
return {
|
|
1198
|
+
schemaVersion: result.schemaVersion,
|
|
1199
|
+
targetUrl: result.targetUrl,
|
|
1200
|
+
scannedAt: result.scannedAt,
|
|
1201
|
+
durationMs: result.durationMs,
|
|
1202
|
+
robots: result.robots,
|
|
1203
|
+
geoScore: result.geoScore,
|
|
1204
|
+
recommendations: result.recommendations,
|
|
1205
|
+
monitor: result.monitor,
|
|
1206
|
+
warnings: result.warnings,
|
|
1207
|
+
errors: result.errors,
|
|
1208
|
+
success: result.success
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function formatJsonOutput(result) {
|
|
1212
|
+
const payload = Array.isArray(result) ? result.map((item) => serializeResult(item)) : serializeResult(result);
|
|
1213
|
+
return JSON.stringify(payload, null, 2);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// src/reporters/terminal.ts
|
|
1217
|
+
import chalk from "chalk";
|
|
1218
|
+
import Table from "cli-table3";
|
|
1219
|
+
var PRIORITY_ICON = {
|
|
1220
|
+
high: "\u{1F534}",
|
|
1221
|
+
medium: "\u{1F7E1}",
|
|
1222
|
+
low: "\u{1F7E2}"
|
|
1223
|
+
};
|
|
1224
|
+
function statusBadge(status) {
|
|
1225
|
+
if (status === "allowed") {
|
|
1226
|
+
return `${chalk.green("\u2705")} Allowed`;
|
|
1227
|
+
}
|
|
1228
|
+
if (status === "blocked") {
|
|
1229
|
+
return `${chalk.red("\u274C")} Blocked`;
|
|
1230
|
+
}
|
|
1231
|
+
if (status === "partially_blocked") {
|
|
1232
|
+
return `${chalk.yellow("\u26A0\uFE0F")} Partially blocked`;
|
|
1233
|
+
}
|
|
1234
|
+
return `${chalk.yellow("\u26A0\uFE0F")} Not mentioned (implicitly allowed)`;
|
|
1235
|
+
}
|
|
1236
|
+
function bar(score, max, width = 12) {
|
|
1237
|
+
const ratio = max === 0 ? 0 : score / max;
|
|
1238
|
+
const filled = Math.round(ratio * width);
|
|
1239
|
+
return `${"\u2588".repeat(filled)}${"\u2591".repeat(Math.max(0, width - filled))}`;
|
|
1240
|
+
}
|
|
1241
|
+
function headerBlock() {
|
|
1242
|
+
const lines = [
|
|
1243
|
+
"\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510",
|
|
1244
|
+
"\u2502 geo-check v1.0.0 \u2502",
|
|
1245
|
+
"\u2502 AI Visibility Scanner \u2502",
|
|
1246
|
+
"\u2502 https://www.summalytics.ai \u2502",
|
|
1247
|
+
"\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"
|
|
1248
|
+
];
|
|
1249
|
+
return chalk.cyan(lines.join("\n"));
|
|
1250
|
+
}
|
|
1251
|
+
function renderBots(results) {
|
|
1252
|
+
const table = new Table({
|
|
1253
|
+
head: ["Bot", "Company", "Status"],
|
|
1254
|
+
colWidths: [24, 16, 34],
|
|
1255
|
+
wordWrap: true
|
|
1256
|
+
});
|
|
1257
|
+
for (const result of results) {
|
|
1258
|
+
table.push([result.bot.name, result.bot.company, statusBadge(result.status)]);
|
|
1259
|
+
}
|
|
1260
|
+
return table.toString();
|
|
1261
|
+
}
|
|
1262
|
+
function renderRecommendations(recommendations, verbose) {
|
|
1263
|
+
const selected = verbose ? recommendations : recommendations.slice(0, 5);
|
|
1264
|
+
if (selected.length === 0) {
|
|
1265
|
+
return `${chalk.green("\u2705")} No recommendations. GEO profile looks strong.`;
|
|
1266
|
+
}
|
|
1267
|
+
return selected.map((recommendation) => {
|
|
1268
|
+
const icon = PRIORITY_ICON[recommendation.priority];
|
|
1269
|
+
return ` ${icon} ${recommendation.priority.toUpperCase()}: ${recommendation.message} (${recommendation.impact})`;
|
|
1270
|
+
}).join("\n");
|
|
1271
|
+
}
|
|
1272
|
+
function partialBlockExplanation(results) {
|
|
1273
|
+
const partialResults = results.filter((result) => result.status === "partially_blocked");
|
|
1274
|
+
if (partialResults.length === 0) {
|
|
1275
|
+
return [];
|
|
1276
|
+
}
|
|
1277
|
+
const ruleCounts = /* @__PURE__ */ new Map();
|
|
1278
|
+
for (const result of partialResults) {
|
|
1279
|
+
for (const rule of result.rules) {
|
|
1280
|
+
if (!rule.startsWith("Disallow:")) {
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
ruleCounts.set(rule, (ruleCounts.get(rule) ?? 0) + 1);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
const topRules = [...ruleCounts.entries()].sort((left, right) => right[1] - left[1]).slice(0, 3).map(([rule, count]) => `${rule} (${count})`);
|
|
1287
|
+
const lines = [
|
|
1288
|
+
`${chalk.yellow("\u2139\uFE0F")} Partially blocked means bots can crawl some paths but are blocked on others by robots.txt rules.`
|
|
1289
|
+
];
|
|
1290
|
+
if (topRules.length > 0) {
|
|
1291
|
+
lines.push(` Top blocking rules: ${topRules.join(" \xB7 ")}`);
|
|
1292
|
+
}
|
|
1293
|
+
return lines;
|
|
1294
|
+
}
|
|
1295
|
+
function renderSingleResult(result, options) {
|
|
1296
|
+
const lines = [];
|
|
1297
|
+
lines.push(headerBlock());
|
|
1298
|
+
lines.push("");
|
|
1299
|
+
lines.push(`Scanning ${chalk.bold(result.targetUrl)} ...`);
|
|
1300
|
+
if (!result.success) {
|
|
1301
|
+
lines.push(chalk.red("Scan failed."));
|
|
1302
|
+
if (result.errors.length > 0) {
|
|
1303
|
+
for (const error of result.errors) {
|
|
1304
|
+
lines.push(` ${chalk.red("\u274C")} ${error}`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return lines.join("\n");
|
|
1308
|
+
}
|
|
1309
|
+
if (result.geoScore) {
|
|
1310
|
+
lines.push("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1311
|
+
lines.push(
|
|
1312
|
+
`${chalk.bold("\u{1F4CA} GEO READINESS SCORE")}: ${chalk.bold(`${result.geoScore.total}/100`)} (${result.geoScore.grade})`
|
|
1313
|
+
);
|
|
1314
|
+
lines.push("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1315
|
+
const { categories } = result.geoScore;
|
|
1316
|
+
lines.push(
|
|
1317
|
+
` Structured Data ${bar(categories.structuredData.score, categories.structuredData.maxScore)} ${categories.structuredData.score}/${categories.structuredData.maxScore}`
|
|
1318
|
+
);
|
|
1319
|
+
lines.push(
|
|
1320
|
+
` Content Structure ${bar(categories.contentStructure.score, categories.contentStructure.maxScore)} ${categories.contentStructure.score}/${categories.contentStructure.maxScore}`
|
|
1321
|
+
);
|
|
1322
|
+
lines.push(
|
|
1323
|
+
` Authority Signals ${bar(categories.authoritySignals.score, categories.authoritySignals.maxScore)} ${categories.authoritySignals.score}/${categories.authoritySignals.maxScore}`
|
|
1324
|
+
);
|
|
1325
|
+
lines.push(
|
|
1326
|
+
` AI Accessibility ${bar(categories.aiAccessibility.score, categories.aiAccessibility.maxScore)} ${categories.aiAccessibility.score}/${categories.aiAccessibility.maxScore}`
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
if (result.robots) {
|
|
1330
|
+
lines.push("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1331
|
+
lines.push(chalk.bold("\u{1F916} AI BOT ACCESS"));
|
|
1332
|
+
lines.push("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1333
|
+
lines.push(renderBots(result.robots.botResults));
|
|
1334
|
+
lines.push(
|
|
1335
|
+
`Summary: ${result.robots.summary.allowed} allowed \xB7 ${result.robots.summary.blocked} blocked \xB7 ${result.robots.summary.partiallyBlocked} partial \xB7 ${result.robots.summary.notMentioned} not mentioned`
|
|
1336
|
+
);
|
|
1337
|
+
lines.push(...partialBlockExplanation(result.robots.botResults));
|
|
1338
|
+
}
|
|
1339
|
+
lines.push("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1340
|
+
lines.push(chalk.bold("\u{1F4A1} TOP RECOMMENDATIONS"));
|
|
1341
|
+
lines.push("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1342
|
+
lines.push(renderRecommendations(result.recommendations, options.verbose));
|
|
1343
|
+
if (result.warnings.length > 0) {
|
|
1344
|
+
lines.push("\nWarnings:");
|
|
1345
|
+
for (const warning of result.warnings) {
|
|
1346
|
+
lines.push(` ${chalk.yellow("\u26A0\uFE0F")} ${warning}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (options.verbose && result.geoScore) {
|
|
1350
|
+
lines.push("\nVerbose checks:");
|
|
1351
|
+
for (const [categoryName, category] of Object.entries(result.geoScore.categories)) {
|
|
1352
|
+
lines.push(` ${chalk.bold(categoryName)}: ${category.score}/${category.maxScore}`);
|
|
1353
|
+
for (const check2 of category.checks) {
|
|
1354
|
+
const marker = check2.passed ? chalk.green("\u2705") : chalk.red("\u274C");
|
|
1355
|
+
lines.push(` ${marker} ${check2.name} (${check2.points}/${check2.maxPoints}) - ${check2.detail}`);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
lines.push("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1360
|
+
lines.push("For ongoing monitoring and AI traffic analytics: https://www.summalytics.ai");
|
|
1361
|
+
lines.push("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
1362
|
+
return lines.join("\n");
|
|
1363
|
+
}
|
|
1364
|
+
function renderTerminalResults(results, options) {
|
|
1365
|
+
return results.map((result) => renderSingleResult(result, options)).join("\n\n");
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// src/cli.ts
|
|
1369
|
+
var defaultIO = {
|
|
1370
|
+
stdout: (message) => console.log(message),
|
|
1371
|
+
stderr: (message) => console.error(message)
|
|
1372
|
+
};
|
|
1373
|
+
async function runCli(argv = process.argv, io = defaultIO) {
|
|
1374
|
+
const program = new Command();
|
|
1375
|
+
program.name("geo-check").description("AI Visibility Scanner \u2014 robots.txt AI bot access + GEO readiness score").argument("[urls...]", "Target URLs to scan").option("--json", "Output machine-readable JSON").option("--robots-only", "Only run robots.txt checks").option("--geo-only", "Only output GEO score").option("--verbose", "Show all check details").showHelpAfterError();
|
|
1376
|
+
program.exitOverride();
|
|
1377
|
+
try {
|
|
1378
|
+
program.parse(argv);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
if (error instanceof CommanderError) {
|
|
1381
|
+
if (error.code !== "commander.helpDisplayed") {
|
|
1382
|
+
io.stderr(error.message);
|
|
1383
|
+
}
|
|
1384
|
+
return 2;
|
|
1385
|
+
}
|
|
1386
|
+
throw error;
|
|
1387
|
+
}
|
|
1388
|
+
const opts = program.opts();
|
|
1389
|
+
const urls = program.args;
|
|
1390
|
+
if (urls.length === 0) {
|
|
1391
|
+
io.stderr("Please provide at least one URL. Example: npx geo-check https://example.com");
|
|
1392
|
+
return 2;
|
|
1393
|
+
}
|
|
1394
|
+
if (opts.robotsOnly && opts.geoOnly) {
|
|
1395
|
+
io.stderr("--robots-only and --geo-only cannot be used together.");
|
|
1396
|
+
return 2;
|
|
1397
|
+
}
|
|
1398
|
+
const results = await geoCheckMany(urls, {
|
|
1399
|
+
robotsOnly: opts.robotsOnly,
|
|
1400
|
+
geoOnly: opts.geoOnly,
|
|
1401
|
+
verbose: opts.verbose,
|
|
1402
|
+
concurrency: 3
|
|
1403
|
+
});
|
|
1404
|
+
if (opts.json) {
|
|
1405
|
+
io.stdout(formatJsonOutput(results.length === 1 ? results[0] : results));
|
|
1406
|
+
} else {
|
|
1407
|
+
io.stdout(renderTerminalResults(results, { verbose: Boolean(opts.verbose) }));
|
|
1408
|
+
}
|
|
1409
|
+
return results.some((result) => result.success) ? 0 : 1;
|
|
1410
|
+
}
|
|
1411
|
+
var directInvocation = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
|
1412
|
+
if (directInvocation) {
|
|
1413
|
+
runCli().then((code) => {
|
|
1414
|
+
process.exit(code);
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
export {
|
|
1418
|
+
runCli
|
|
1419
|
+
};
|
|
1420
|
+
//# sourceMappingURL=cli.js.map
|