geo-ai-search-optimization 2.0.0 → 2.2.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.
@@ -0,0 +1,306 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ const REQUIRED_FIELDS = {
6
+ Organization: ["name", "url"],
7
+ Corporation: ["name", "url"],
8
+ LocalBusiness: ["name", "url", "address"],
9
+ Person: ["name"],
10
+ Article: ["headline", "author", "datePublished"],
11
+ BlogPosting: ["headline", "author", "datePublished"],
12
+ NewsArticle: ["headline", "author", "datePublished", "dateModified"],
13
+ FAQPage: ["mainEntity"],
14
+ HowTo: ["name", "step"],
15
+ Product: ["name"],
16
+ SoftwareApplication: ["name"],
17
+ BreadcrumbList: ["itemListElement"],
18
+ WebSite: ["name", "url"],
19
+ WebPage: ["name"]
20
+ };
21
+
22
+ const AI_DISCOVERABILITY_FIELDS = {
23
+ Organization: ["sameAs", "logo", "description", "foundingDate", "numberOfEmployees"],
24
+ Person: ["sameAs", "jobTitle", "worksFor", "url"],
25
+ Article: ["dateModified", "publisher", "image", "description", "mainEntityOfPage"],
26
+ BlogPosting: ["dateModified", "publisher", "image", "description", "mainEntityOfPage"],
27
+ Product: ["brand", "offers", "description", "image", "aggregateRating"],
28
+ FAQPage: [],
29
+ BreadcrumbList: [],
30
+ WebSite: ["potentialAction", "description"],
31
+ HowTo: ["description", "totalTime", "image"]
32
+ };
33
+
34
+ const SPEAKABLE_ELIGIBLE = new Set(["Article", "BlogPosting", "NewsArticle", "WebPage"]);
35
+
36
+ function extractJsonLd(html) {
37
+ const blocks = [];
38
+ const regex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
39
+ let match;
40
+
41
+ while ((match = regex.exec(html)) !== null) {
42
+ try {
43
+ const parsed = JSON.parse(match[1].trim());
44
+ if (Array.isArray(parsed)) {
45
+ blocks.push(...parsed);
46
+ } else {
47
+ blocks.push(parsed);
48
+ }
49
+ } catch {
50
+ blocks.push({ _parseError: true, _raw: match[1].trim().slice(0, 200) });
51
+ }
52
+ }
53
+
54
+ return blocks;
55
+ }
56
+
57
+ function flattenGraph(blocks) {
58
+ const flat = [];
59
+ for (const block of blocks) {
60
+ if (block["@graph"] && Array.isArray(block["@graph"])) {
61
+ flat.push(...block["@graph"]);
62
+ } else {
63
+ flat.push(block);
64
+ }
65
+ }
66
+ return flat;
67
+ }
68
+
69
+ function getType(entity) {
70
+ const raw = entity["@type"];
71
+ if (Array.isArray(raw)) return raw[0];
72
+ return raw || "Unknown";
73
+ }
74
+
75
+ function validateEntity(entity) {
76
+ const type = getType(entity);
77
+ const issues = [];
78
+ const warnings = [];
79
+ const enhancements = [];
80
+
81
+ if (entity._parseError) {
82
+ return {
83
+ type: "ParseError",
84
+ issues: [{ field: "_raw", message: "Failed to parse JSON-LD block", severity: "error" }],
85
+ warnings: [],
86
+ enhancements: []
87
+ };
88
+ }
89
+
90
+ if (!entity["@context"] || !String(entity["@context"]).includes("schema.org")) {
91
+ issues.push({ field: "@context", message: "Missing or invalid @context (should be https://schema.org)", severity: "error" });
92
+ }
93
+
94
+ if (!entity["@type"]) {
95
+ issues.push({ field: "@type", message: "Missing @type", severity: "error" });
96
+ }
97
+
98
+ const required = REQUIRED_FIELDS[type] || [];
99
+ for (const field of required) {
100
+ if (!entity[field] && entity[field] !== 0) {
101
+ issues.push({ field, message: `Missing required field: ${field}`, severity: "error" });
102
+ }
103
+ }
104
+
105
+ const aiFields = AI_DISCOVERABILITY_FIELDS[type] || [];
106
+ for (const field of aiFields) {
107
+ if (!entity[field]) {
108
+ enhancements.push({ field, message: `Add ${field} for better AI discoverability`, severity: "enhancement" });
109
+ }
110
+ }
111
+
112
+ if (SPEAKABLE_ELIGIBLE.has(type) && !entity.speakable) {
113
+ enhancements.push({ field: "speakable", message: "Add speakable property for voice search and AI reading", severity: "enhancement" });
114
+ }
115
+
116
+ if (entity.sameAs) {
117
+ const sameAs = Array.isArray(entity.sameAs) ? entity.sameAs : [entity.sameAs];
118
+ for (const url of sameAs) {
119
+ try {
120
+ new URL(url);
121
+ } catch {
122
+ warnings.push({ field: "sameAs", message: `Invalid sameAs URL: ${url}`, severity: "warning" });
123
+ }
124
+ }
125
+ }
126
+
127
+ if (entity.datePublished && !/^\d{4}-\d{2}-\d{2}/.test(entity.datePublished)) {
128
+ warnings.push({ field: "datePublished", message: "datePublished should be in ISO 8601 format (YYYY-MM-DD)", severity: "warning" });
129
+ }
130
+
131
+ if (entity.dateModified && !/^\d{4}-\d{2}-\d{2}/.test(entity.dateModified)) {
132
+ warnings.push({ field: "dateModified", message: "dateModified should be in ISO 8601 format (YYYY-MM-DD)", severity: "warning" });
133
+ }
134
+
135
+ return { type, issues, warnings, enhancements };
136
+ }
137
+
138
+ function computeScore(validations) {
139
+ if (validations.length === 0) return 0;
140
+
141
+ let totalErrors = 0;
142
+ let totalWarnings = 0;
143
+ let totalEnhancements = 0;
144
+
145
+ for (const v of validations) {
146
+ totalErrors += v.issues.length;
147
+ totalWarnings += v.warnings.length;
148
+ totalEnhancements += v.enhancements.length;
149
+ }
150
+
151
+ let score = 100;
152
+ score -= totalErrors * 12;
153
+ score -= totalWarnings * 5;
154
+ score -= totalEnhancements * 2;
155
+
156
+ return Math.max(0, Math.min(100, score));
157
+ }
158
+
159
+ function getScoreLabel(score) {
160
+ if (score >= 90) return "Excellent";
161
+ if (score >= 70) return "Good";
162
+ if (score >= 50) return "Needs work";
163
+ return "Poor";
164
+ }
165
+
166
+ async function fetchContent(url) {
167
+ const response = await fetch(url, {
168
+ redirect: "follow",
169
+ headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
170
+ signal: AbortSignal.timeout(10_000)
171
+ });
172
+ if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
173
+ return response.text();
174
+ }
175
+
176
+ export async function validateSchema(input, options = {}) {
177
+ let content;
178
+ let source;
179
+
180
+ if (/^https?:\/\//i.test(input)) {
181
+ content = await fetchContent(input);
182
+ source = input;
183
+ } else {
184
+ const filePath = path.resolve(input);
185
+ content = await fs.readFile(filePath, "utf8");
186
+ source = filePath;
187
+
188
+ if (filePath.endsWith(".json")) {
189
+ try {
190
+ const parsed = JSON.parse(content);
191
+ const entities = Array.isArray(parsed) ? parsed : [parsed];
192
+ const flat = flattenGraph(entities);
193
+ const validations = flat.map(validateEntity);
194
+ const score = computeScore(validations);
195
+
196
+ return {
197
+ kind: "geo-validate-schema",
198
+ source,
199
+ entityCount: flat.length,
200
+ types: flat.map(getType),
201
+ validations,
202
+ score,
203
+ scoreLabel: getScoreLabel(score),
204
+ summary: buildSummary(validations, score)
205
+ };
206
+ } catch {
207
+ // Not valid JSON, treat as HTML
208
+ }
209
+ }
210
+ }
211
+
212
+ const blocks = extractJsonLd(content);
213
+ const flat = flattenGraph(blocks);
214
+
215
+ if (flat.length === 0) {
216
+ return {
217
+ kind: "geo-validate-schema",
218
+ source,
219
+ entityCount: 0,
220
+ types: [],
221
+ validations: [],
222
+ score: 0,
223
+ scoreLabel: "No schema found",
224
+ summary: "No JSON-LD structured data found. Add schema markup with: geo-ai-search-optimization init-schema <type>"
225
+ };
226
+ }
227
+
228
+ const validations = flat.map(validateEntity);
229
+ const score = computeScore(validations);
230
+
231
+ return {
232
+ kind: "geo-validate-schema",
233
+ source,
234
+ entityCount: flat.length,
235
+ types: flat.map(getType),
236
+ validations,
237
+ score,
238
+ scoreLabel: getScoreLabel(score),
239
+ summary: buildSummary(validations, score)
240
+ };
241
+ }
242
+
243
+ function buildSummary(validations, score) {
244
+ const totalErrors = validations.reduce((sum, v) => sum + v.issues.length, 0);
245
+ const totalWarnings = validations.reduce((sum, v) => sum + v.warnings.length, 0);
246
+ const totalEnhancements = validations.reduce((sum, v) => sum + v.enhancements.length, 0);
247
+
248
+ if (totalErrors === 0 && totalWarnings === 0) {
249
+ return `Schema validation: ${score}/100. All entities valid.${totalEnhancements > 0 ? ` ${totalEnhancements} enhancement(s) available for AI discoverability.` : ""}`;
250
+ }
251
+
252
+ return `Schema validation: ${score}/100. ${totalErrors} error(s), ${totalWarnings} warning(s), ${totalEnhancements} enhancement(s).`;
253
+ }
254
+
255
+ export function renderValidateSchemaMarkdown(report) {
256
+ const lines = [
257
+ "# Schema Validation Report",
258
+ "",
259
+ `- Source: \`${report.source}\``,
260
+ `- Entities found: \`${report.entityCount}\``,
261
+ `- Types: ${report.types.map((t) => `\`${t}\``).join(", ") || "none"}`,
262
+ `- Score: \`${report.score}/100\` (${report.scoreLabel})`,
263
+ `- Summary: ${report.summary}`,
264
+ ""
265
+ ];
266
+
267
+ if (report.entityCount === 0) {
268
+ lines.push("## Action Required", "", "- Add JSON-LD structured data to your pages.", "");
269
+ return lines.join("\n");
270
+ }
271
+
272
+ for (let i = 0; i < report.validations.length; i++) {
273
+ const v = report.validations[i];
274
+ lines.push(`## Entity ${i + 1}: ${v.type}`, "");
275
+
276
+ if (v.issues.length > 0) {
277
+ lines.push("### Errors", "");
278
+ for (const issue of v.issues) {
279
+ lines.push(`- ❌ \`${issue.field}\`: ${issue.message}`);
280
+ }
281
+ lines.push("");
282
+ }
283
+
284
+ if (v.warnings.length > 0) {
285
+ lines.push("### Warnings", "");
286
+ for (const warning of v.warnings) {
287
+ lines.push(`- ⚠️ \`${warning.field}\`: ${warning.message}`);
288
+ }
289
+ lines.push("");
290
+ }
291
+
292
+ if (v.enhancements.length > 0) {
293
+ lines.push("### AI Discoverability Enhancements", "");
294
+ for (const enh of v.enhancements) {
295
+ lines.push(`- 💡 \`${enh.field}\`: ${enh.message}`);
296
+ }
297
+ lines.push("");
298
+ }
299
+ }
300
+
301
+ return lines.join("\n");
302
+ }
303
+
304
+ export async function writeValidateSchemaOutput(outputPath, content) {
305
+ return writeScanOutput(outputPath, content);
306
+ }