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.
- package/action.yml +130 -0
- package/package.json +15 -3
- package/src/auto-fix.js +349 -0
- package/src/batch-full-page-audit.js +151 -0
- package/src/citability.js +311 -0
- package/src/citation-check.js +1 -1
- package/src/cli-site-ops-commands.js +391 -2
- package/src/compare.js +175 -0
- package/src/config.js +105 -0
- package/src/crawlers.js +286 -0
- package/src/diagnose.js +221 -0
- package/src/eeat.js +251 -0
- package/src/freshness.js +281 -0
- package/src/full-audit.js +269 -0
- package/src/full-page-audit.js +273 -0
- package/src/heading-structure.js +287 -0
- package/src/index.d.ts +492 -0
- package/src/index.js +24 -0
- package/src/internal-links.js +298 -0
- package/src/page-audit.js +1 -1
- package/src/page-snapshot.js +198 -0
- package/src/pdf-report.js +205 -0
- package/src/platform-ready.js +238 -0
- package/src/plugins.js +126 -0
- package/src/readability.js +252 -0
- package/src/security.js +249 -0
- package/src/sitemap.js +323 -0
- package/src/social-meta.js +293 -0
- package/src/topics.js +275 -0
- package/src/url-onboarding.js +1 -1
- package/src/validate-llms.js +307 -0
- package/src/validate-schema.js +306 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PDF-ready HTML report generator.
|
|
6
|
+
* Generates a self-contained HTML file designed for browser Print-to-PDF.
|
|
7
|
+
* Zero external dependencies — uses only inline CSS.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
return String(text)
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scoreColor(score) {
|
|
19
|
+
if (score >= 80) return "#22c55e";
|
|
20
|
+
if (score >= 60) return "#eab308";
|
|
21
|
+
if (score >= 40) return "#f97316";
|
|
22
|
+
return "#ef4444";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildScoreGauge(score, label, maxScore = 100) {
|
|
26
|
+
const color = scoreColor(score);
|
|
27
|
+
const pct = Math.round((score / maxScore) * 100);
|
|
28
|
+
return `
|
|
29
|
+
<div class="gauge">
|
|
30
|
+
<div class="gauge-ring" style="background: conic-gradient(${color} ${pct * 3.6}deg, #e5e7eb ${pct * 3.6}deg)">
|
|
31
|
+
<div class="gauge-center">
|
|
32
|
+
<span class="gauge-score">${score}</span>
|
|
33
|
+
<span class="gauge-max">/ ${maxScore}</span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="gauge-label">${escapeHtml(label)}</div>
|
|
37
|
+
</div>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildCheckTable(checks) {
|
|
41
|
+
if (!checks || checks.length === 0) return "";
|
|
42
|
+
const rows = checks.map((c) => `
|
|
43
|
+
<tr>
|
|
44
|
+
<td>${c.passed ? "✅" : "❌"}</td>
|
|
45
|
+
<td>${escapeHtml(c.label || c.key)}</td>
|
|
46
|
+
<td>${c.pointsAwarded ?? (c.passed ? "pass" : "fail")}${c.maxPoints ? ` / ${c.maxPoints}` : ""}</td>
|
|
47
|
+
</tr>`).join("");
|
|
48
|
+
|
|
49
|
+
return `
|
|
50
|
+
<table>
|
|
51
|
+
<thead><tr><th></th><th>Check</th><th>Score</th></tr></thead>
|
|
52
|
+
<tbody>${rows}</tbody>
|
|
53
|
+
</table>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildIssueList(issues) {
|
|
57
|
+
if (!issues || issues.length === 0) return "<p>No issues found.</p>";
|
|
58
|
+
return `<ul>${issues.map((i) => `<li><strong>${escapeHtml(typeof i === "string" ? i : i.message || i.action || JSON.stringify(i))}</strong></li>`).join("")}</ul>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildSectionFromAudit(audit) {
|
|
62
|
+
const sections = [];
|
|
63
|
+
|
|
64
|
+
sections.push(`<h2>Summary</h2><p>${escapeHtml(audit.summary || "")}</p>`);
|
|
65
|
+
|
|
66
|
+
if (audit.checks) {
|
|
67
|
+
sections.push(`<h2>Score Breakdown</h2>${buildCheckTable(audit.checks)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (audit.areas) {
|
|
71
|
+
sections.push("<h2>Issue Areas</h2>");
|
|
72
|
+
for (const area of audit.areas) {
|
|
73
|
+
const sev = (area.severity || "").toLowerCase();
|
|
74
|
+
const severity = (sev === "高" || sev === "high") ? "🔴" : (sev === "中" || sev === "medium") ? "🟡" : "🟢";
|
|
75
|
+
sections.push(`
|
|
76
|
+
<div class="area-card">
|
|
77
|
+
<h3>${severity} ${escapeHtml(area.title)}</h3>
|
|
78
|
+
<p><strong>Owner:</strong> ${escapeHtml(area.owner)} | <strong>Status:</strong> ${escapeHtml(area.status)}</p>
|
|
79
|
+
<p>${escapeHtml(area.summary)}</p>
|
|
80
|
+
</div>`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (audit.actionPlan) {
|
|
85
|
+
sections.push("<h2>Action Plan</h2>");
|
|
86
|
+
sections.push(`<table>
|
|
87
|
+
<thead><tr><th>Priority</th><th>Owner</th><th>Area</th><th>Action</th></tr></thead>
|
|
88
|
+
<tbody>${audit.actionPlan.map((t) => `
|
|
89
|
+
<tr>
|
|
90
|
+
<td><span class="badge badge-${(t.priority || "P2").toLowerCase()}">${escapeHtml(t.priority || "P2")}</span></td>
|
|
91
|
+
<td>${escapeHtml(t.owner || "")}</td>
|
|
92
|
+
<td>${escapeHtml(t.area || "")}</td>
|
|
93
|
+
<td>${escapeHtml(t.action || "")}</td>
|
|
94
|
+
</tr>`).join("")}
|
|
95
|
+
</tbody></table>`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (audit.groups) {
|
|
99
|
+
if (audit.groups.blockers?.length > 0) {
|
|
100
|
+
sections.push(`<h2>Blockers</h2>${buildIssueList(audit.groups.blockers)}`);
|
|
101
|
+
}
|
|
102
|
+
if (audit.groups.highImpactFixes?.length > 0) {
|
|
103
|
+
sections.push(`<h2>High Impact Fixes</h2>${buildIssueList(audit.groups.highImpactFixes)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (audit.recommendations) {
|
|
108
|
+
sections.push(`<h2>Recommendations</h2>${buildIssueList(audit.recommendations)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return sections.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const CSS = `
|
|
115
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
116
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1f2937; line-height: 1.6; padding: 40px; max-width: 900px; margin: 0 auto; }
|
|
117
|
+
h1 { font-size: 28px; margin-bottom: 8px; color: #111827; }
|
|
118
|
+
h2 { font-size: 20px; margin: 32px 0 12px; color: #374151; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
|
|
119
|
+
h3 { font-size: 16px; margin: 16px 0 8px; color: #4b5563; }
|
|
120
|
+
p { margin: 8px 0; }
|
|
121
|
+
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 14px; }
|
|
122
|
+
th, td { border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; }
|
|
123
|
+
th { background: #f9fafb; font-weight: 600; }
|
|
124
|
+
tr:nth-child(even) { background: #f9fafb; }
|
|
125
|
+
ul { margin: 8px 0 8px 24px; }
|
|
126
|
+
li { margin: 4px 0; }
|
|
127
|
+
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
|
|
128
|
+
.header-info { flex: 1; }
|
|
129
|
+
.meta { color: #6b7280; font-size: 14px; }
|
|
130
|
+
.gauges { display: flex; gap: 24px; flex-wrap: wrap; margin: 24px 0; }
|
|
131
|
+
.gauge { text-align: center; }
|
|
132
|
+
.gauge-ring { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
|
133
|
+
.gauge-center { width: 90px; height: 90px; border-radius: 50%; background: white; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
|
134
|
+
.gauge-score { font-size: 28px; font-weight: 700; }
|
|
135
|
+
.gauge-max { font-size: 12px; color: #9ca3af; }
|
|
136
|
+
.gauge-label { margin-top: 8px; font-size: 13px; font-weight: 500; color: #6b7280; }
|
|
137
|
+
.area-card { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin: 12px 0; }
|
|
138
|
+
.badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
|
139
|
+
.badge-p0 { background: #fef2f2; color: #dc2626; }
|
|
140
|
+
.badge-p1 { background: #fff7ed; color: #ea580c; }
|
|
141
|
+
.badge-p2 { background: #fefce8; color: #ca8a04; }
|
|
142
|
+
.footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
|
|
143
|
+
@media print { body { padding: 20px; } .area-card { break-inside: avoid; } table { break-inside: avoid; } }
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
export function generatePdfHtml(data, options = {}) {
|
|
147
|
+
const title = options.title || "GEO Audit Report";
|
|
148
|
+
const date = options.date || new Date().toISOString().split("T")[0];
|
|
149
|
+
const score = data.score ?? 0;
|
|
150
|
+
const maxScore = data.maxScore ?? 100;
|
|
151
|
+
const scoreLabel = data.scoreLabel || "";
|
|
152
|
+
|
|
153
|
+
const gauges = [buildScoreGauge(score, "GEO Score", maxScore)];
|
|
154
|
+
|
|
155
|
+
if (data.dimensions) {
|
|
156
|
+
for (const [key, dim] of Object.entries(data.dimensions)) {
|
|
157
|
+
if (dim.score !== undefined) {
|
|
158
|
+
gauges.push(buildScoreGauge(dim.score, key, 100));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const body = buildSectionFromAudit(data);
|
|
164
|
+
|
|
165
|
+
return `<!DOCTYPE html>
|
|
166
|
+
<html lang="en">
|
|
167
|
+
<head>
|
|
168
|
+
<meta charset="UTF-8">
|
|
169
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
170
|
+
<title>${escapeHtml(title)}</title>
|
|
171
|
+
<style>${CSS}</style>
|
|
172
|
+
</head>
|
|
173
|
+
<body>
|
|
174
|
+
<div class="header">
|
|
175
|
+
<div class="header-info">
|
|
176
|
+
<h1>${escapeHtml(title)}</h1>
|
|
177
|
+
<p class="meta">Generated: ${escapeHtml(date)} | Source: ${escapeHtml(data.source || data.scanSummary?.root || "—")}</p>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div class="gauges">${gauges.join("")}</div>
|
|
182
|
+
|
|
183
|
+
${body}
|
|
184
|
+
|
|
185
|
+
<div class="footer">
|
|
186
|
+
Generated by geo-ai-search-optimization v2.2.0 — Open-source GEO toolkit for AI search optimization.
|
|
187
|
+
<br>Print this page to PDF for a polished report.
|
|
188
|
+
</div>
|
|
189
|
+
</body>
|
|
190
|
+
</html>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function writePdfReport(inputPath, outputPath, options = {}) {
|
|
194
|
+
const resolvedInput = path.resolve(inputPath);
|
|
195
|
+
const content = await fs.readFile(resolvedInput, "utf8");
|
|
196
|
+
const data = JSON.parse(content);
|
|
197
|
+
|
|
198
|
+
const html = generatePdfHtml(data, options);
|
|
199
|
+
const resolvedOutput = path.resolve(outputPath || inputPath.replace(/\.json$/, ".html"));
|
|
200
|
+
|
|
201
|
+
await fs.mkdir(path.dirname(resolvedOutput), { recursive: true });
|
|
202
|
+
await fs.writeFile(resolvedOutput, html, "utf8");
|
|
203
|
+
|
|
204
|
+
return { outputPath: resolvedOutput, title: options.title || "GEO Audit Report" };
|
|
205
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeScanOutput } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
function stripHtml(text) {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
8
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
9
|
+
.replace(/<[^>]+>/g, " ")
|
|
10
|
+
.replace(/\s+/g, " ")
|
|
11
|
+
.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PLATFORMS = {
|
|
15
|
+
chatgpt: {
|
|
16
|
+
name: "ChatGPT",
|
|
17
|
+
crawlers: ["GPTBot", "ChatGPT-User", "OAI-SearchBot"],
|
|
18
|
+
checks: [
|
|
19
|
+
{ key: "llmsTxt", label: "llms.txt present", test: (ctx) => ctx.hasLlmsTxt },
|
|
20
|
+
{ key: "jsonLd", label: "JSON-LD structured data", test: (ctx) => ctx.hasJsonLd },
|
|
21
|
+
{ key: "answerFirst", label: "Answer-first opening paragraph", test: (ctx) => ctx.hasDirectAnswer },
|
|
22
|
+
{ key: "crawlerAccess", label: "GPTBot not blocked in robots.txt", test: (ctx) => !ctx.blockedCrawlers.includes("GPTBot") },
|
|
23
|
+
{ key: "metaDesc", label: "Meta description present", test: (ctx) => ctx.hasMetaDescription },
|
|
24
|
+
{ key: "cleanHtml", label: "Clean semantic HTML", test: (ctx) => ctx.hasSemanticHtml }
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
perplexity: {
|
|
28
|
+
name: "Perplexity",
|
|
29
|
+
crawlers: ["PerplexityBot"],
|
|
30
|
+
checks: [
|
|
31
|
+
{ key: "citations", label: "Source links / citations", test: (ctx) => ctx.hasCitations },
|
|
32
|
+
{ key: "factDensity", label: "High factual claim density", test: (ctx) => ctx.factDensity > 10 },
|
|
33
|
+
{ key: "crawlerAccess", label: "PerplexityBot not blocked", test: (ctx) => !ctx.blockedCrawlers.includes("PerplexityBot") },
|
|
34
|
+
{ key: "author", label: "Author attribution", test: (ctx) => ctx.hasAuthor },
|
|
35
|
+
{ key: "freshness", label: "Content freshness signals", test: (ctx) => ctx.hasFreshness },
|
|
36
|
+
{ key: "qaStructure", label: "Q&A structure", test: (ctx) => ctx.hasQaHeadings }
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
gemini: {
|
|
40
|
+
name: "Google Gemini",
|
|
41
|
+
crawlers: ["Google-Extended", "Googlebot"],
|
|
42
|
+
checks: [
|
|
43
|
+
{ key: "schemaOrg", label: "Schema.org markup", test: (ctx) => ctx.hasJsonLd },
|
|
44
|
+
{ key: "entityClarity", label: "Clear entity identification", test: (ctx) => ctx.entityCount > 3 },
|
|
45
|
+
{ key: "crawlerAccess", label: "Google-Extended not blocked", test: (ctx) => !ctx.blockedCrawlers.includes("Google-Extended") },
|
|
46
|
+
{ key: "canonical", label: "Canonical URL", test: (ctx) => ctx.hasCanonical },
|
|
47
|
+
{ key: "breadcrumb", label: "Breadcrumb markup", test: (ctx) => ctx.hasBreadcrumb },
|
|
48
|
+
{ key: "sameAs", label: "sameAs links for entity resolution", test: (ctx) => ctx.hasSameAs }
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
googleAio: {
|
|
52
|
+
name: "Google AI Overviews",
|
|
53
|
+
crawlers: ["Googlebot"],
|
|
54
|
+
checks: [
|
|
55
|
+
{ key: "snippetReady", label: "Featured snippet-ready format", test: (ctx) => ctx.hasDirectAnswer },
|
|
56
|
+
{ key: "listOrTable", label: "Lists or tables present", test: (ctx) => ctx.hasListOrTable },
|
|
57
|
+
{ key: "faqSchema", label: "FAQ schema", test: (ctx) => ctx.hasFaqSchema },
|
|
58
|
+
{ key: "howtoSchema", label: "HowTo schema", test: (ctx) => ctx.hasHowtoSchema },
|
|
59
|
+
{ key: "heading", label: "Question-style headings", test: (ctx) => ctx.hasQaHeadings },
|
|
60
|
+
{ key: "metaDesc", label: "Descriptive meta description", test: (ctx) => ctx.hasMetaDescription }
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
bingCopilot: {
|
|
64
|
+
name: "Bing Copilot",
|
|
65
|
+
crawlers: ["Bingbot"],
|
|
66
|
+
checks: [
|
|
67
|
+
{ key: "ogTags", label: "Open Graph meta tags", test: (ctx) => ctx.hasOgTags },
|
|
68
|
+
{ key: "jsonLd", label: "JSON-LD structured data", test: (ctx) => ctx.hasJsonLd },
|
|
69
|
+
{ key: "author", label: "Author and publisher info", test: (ctx) => ctx.hasAuthor },
|
|
70
|
+
{ key: "canonical", label: "Canonical URL", test: (ctx) => ctx.hasCanonical },
|
|
71
|
+
{ key: "freshness", label: "datePublished/dateModified", test: (ctx) => ctx.hasFreshness },
|
|
72
|
+
{ key: "citations", label: "External source links", test: (ctx) => ctx.hasCitations }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function buildContext(content) {
|
|
78
|
+
const plain = /<html|<body/i.test(content) ? stripHtml(content) : content;
|
|
79
|
+
const sentences = plain.split(/(?<=[.!?])\s+/).filter((s) => s.length > 10);
|
|
80
|
+
const factSentences = sentences.filter((s) => /\b\d+(\.\d+)?\s*(%|percent|million|billion|x|times)\b/i.test(s));
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
hasLlmsTxt: false, // Must be checked externally
|
|
84
|
+
hasJsonLd: /application\/ld\+json/i.test(content),
|
|
85
|
+
hasDirectAnswer: (() => {
|
|
86
|
+
const first = plain.split(/\n\s*\n/)[0] || "";
|
|
87
|
+
return first.length >= 50 && first.length <= 500;
|
|
88
|
+
})(),
|
|
89
|
+
hasMetaDescription: /name=["']description["']/i.test(content),
|
|
90
|
+
hasCanonical: /rel=["']canonical["']/i.test(content),
|
|
91
|
+
hasAuthor: /\bauthor\b/i.test(content) || /\bdatePublished\b/i.test(content),
|
|
92
|
+
hasFreshness: /\bdatePublished\b|\bdateModified\b|<time\b/i.test(content),
|
|
93
|
+
hasCitations: (content.match(/https?:\/\/[^\s"'<>)]+/g) || []).length > 2,
|
|
94
|
+
hasQaHeadings: /^#{1,6}\s+.+\?/m.test(content) || /<h[1-6][^>]*>.*\?.*<\/h[1-6]>/i.test(content),
|
|
95
|
+
hasBreadcrumb: /BreadcrumbList/i.test(content),
|
|
96
|
+
hasSameAs: /sameAs/i.test(content),
|
|
97
|
+
hasFaqSchema: /FAQPage/i.test(content),
|
|
98
|
+
hasHowtoSchema: /HowTo/i.test(content),
|
|
99
|
+
hasListOrTable: /<[ou]l\b/i.test(content) || /<table\b/i.test(content) || /^[-*]\s/m.test(content),
|
|
100
|
+
hasOgTags: /property=["']og:/i.test(content),
|
|
101
|
+
hasSemanticHtml: /<(article|section|nav|main|aside|header|footer)\b/i.test(content),
|
|
102
|
+
entityCount: (plain.match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/g) || []).length,
|
|
103
|
+
factDensity: sentences.length > 0 ? Math.round((factSentences.length / sentences.length) * 100) : 0,
|
|
104
|
+
blockedCrawlers: [] // Must be populated externally
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function evaluatePlatform(platform, ctx) {
|
|
109
|
+
const results = platform.checks.map((check) => ({
|
|
110
|
+
key: check.key,
|
|
111
|
+
label: check.label,
|
|
112
|
+
passed: check.test(ctx)
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
const passed = results.filter((r) => r.passed).length;
|
|
116
|
+
const total = results.length;
|
|
117
|
+
const score = Math.round((passed / total) * 100);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
platform: platform.name,
|
|
121
|
+
crawlers: platform.crawlers,
|
|
122
|
+
score,
|
|
123
|
+
passed,
|
|
124
|
+
total,
|
|
125
|
+
checks: results,
|
|
126
|
+
readiness: score >= 80 ? "Ready" : score >= 50 ? "Partial" : "Not ready"
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function fetchContent(url) {
|
|
131
|
+
const response = await fetch(url, {
|
|
132
|
+
redirect: "follow",
|
|
133
|
+
headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
|
|
134
|
+
signal: AbortSignal.timeout(10_000)
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
|
|
137
|
+
return response.text();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function analyzePlatformReadiness(input, options = {}) {
|
|
141
|
+
let content;
|
|
142
|
+
let source;
|
|
143
|
+
|
|
144
|
+
if (/^https?:\/\//i.test(input)) {
|
|
145
|
+
content = await fetchContent(input);
|
|
146
|
+
source = input;
|
|
147
|
+
} else {
|
|
148
|
+
const filePath = path.resolve(input);
|
|
149
|
+
content = await fs.readFile(filePath, "utf8");
|
|
150
|
+
source = filePath;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ctx = buildContext(content);
|
|
154
|
+
|
|
155
|
+
if (options.blockedCrawlers) {
|
|
156
|
+
ctx.blockedCrawlers = options.blockedCrawlers;
|
|
157
|
+
}
|
|
158
|
+
if (options.hasLlmsTxt !== undefined) {
|
|
159
|
+
ctx.hasLlmsTxt = options.hasLlmsTxt;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const platforms = Object.entries(PLATFORMS).map(([key, platform]) => ({
|
|
163
|
+
key,
|
|
164
|
+
...evaluatePlatform(platform, ctx)
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const overallScore = Math.round(platforms.reduce((sum, p) => sum + p.score, 0) / platforms.length);
|
|
168
|
+
const readyCount = platforms.filter((p) => p.readiness === "Ready").length;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
kind: "geo-platform-ready",
|
|
172
|
+
source,
|
|
173
|
+
overallScore,
|
|
174
|
+
overallLabel: overallScore >= 80 ? "Well optimized" : overallScore >= 50 ? "Partially optimized" : "Needs work",
|
|
175
|
+
readyPlatforms: readyCount,
|
|
176
|
+
totalPlatforms: platforms.length,
|
|
177
|
+
platforms,
|
|
178
|
+
summary: `Platform readiness: ${overallScore}/100. ${readyCount}/${platforms.length} platforms ready.`,
|
|
179
|
+
recommendations: buildPlatformRecommendations(platforms)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildPlatformRecommendations(platforms) {
|
|
184
|
+
const recs = [];
|
|
185
|
+
for (const p of platforms) {
|
|
186
|
+
const failed = p.checks.filter((c) => !c.passed);
|
|
187
|
+
if (failed.length > 0) {
|
|
188
|
+
recs.push(`**${p.platform}** (${p.score}/100): Fix ${failed.map((f) => f.label).join(", ")}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return recs;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function renderPlatformReadyMarkdown(report) {
|
|
195
|
+
const lines = [
|
|
196
|
+
"# Multi-Platform Readiness Report",
|
|
197
|
+
"",
|
|
198
|
+
`- Source: \`${report.source}\``,
|
|
199
|
+
`- Overall Score: \`${report.overallScore}/100\` (${report.overallLabel})`,
|
|
200
|
+
`- Platforms Ready: \`${report.readyPlatforms}/${report.totalPlatforms}\``,
|
|
201
|
+
`- Summary: ${report.summary}`,
|
|
202
|
+
"",
|
|
203
|
+
"## Platform Matrix",
|
|
204
|
+
"",
|
|
205
|
+
"| Platform | Score | Status | Passed | Failed |",
|
|
206
|
+
"|----------|-------|--------|--------|--------|"
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
for (const p of report.platforms) {
|
|
210
|
+
const icon = p.readiness === "Ready" ? "✅" : p.readiness === "Partial" ? "⚠️" : "❌";
|
|
211
|
+
const failedCount = p.total - p.passed;
|
|
212
|
+
lines.push(`| ${p.platform} | ${p.score}/100 | ${icon} ${p.readiness} | ${p.passed} | ${failedCount} |`);
|
|
213
|
+
}
|
|
214
|
+
lines.push("");
|
|
215
|
+
|
|
216
|
+
for (const p of report.platforms) {
|
|
217
|
+
lines.push(`## ${p.platform}`, "");
|
|
218
|
+
for (const check of p.checks) {
|
|
219
|
+
const icon = check.passed ? "✅" : "❌";
|
|
220
|
+
lines.push(`- ${icon} ${check.label}`);
|
|
221
|
+
}
|
|
222
|
+
lines.push("");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (report.recommendations.length > 0) {
|
|
226
|
+
lines.push("## Recommendations", "");
|
|
227
|
+
for (const rec of report.recommendations) {
|
|
228
|
+
lines.push(`- ${rec}`);
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return lines.join("\n");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function writePlatformReadyOutput(outputPath, content) {
|
|
237
|
+
return writeScanOutput(outputPath, content);
|
|
238
|
+
}
|
package/src/plugins.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin architecture for geo-ai-search-optimization.
|
|
6
|
+
*
|
|
7
|
+
* Plugins are JS modules that export a `register(api)` function.
|
|
8
|
+
* The API object exposes:
|
|
9
|
+
* - api.addSignal(name, { pattern, label, weight })
|
|
10
|
+
* - api.addCheck(name, { label, maxPoints, passed: (data) => boolean })
|
|
11
|
+
* - api.addCommand(name, { description, handler: async (args) => void })
|
|
12
|
+
* - api.addScoreRule(name, { label, maxPoints, passed: (scan) => boolean })
|
|
13
|
+
*
|
|
14
|
+
* Plugins can be loaded from:
|
|
15
|
+
* - .georc.json "plugins" array (module names or paths)
|
|
16
|
+
* - Programmatic API via loadPlugins()
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const registry = {
|
|
20
|
+
signals: {},
|
|
21
|
+
checks: {},
|
|
22
|
+
commands: {},
|
|
23
|
+
scoreRules: []
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const api = {
|
|
27
|
+
addSignal(name, config) {
|
|
28
|
+
if (!name || !config.pattern) throw new Error(`Plugin signal "${name}" requires a pattern`);
|
|
29
|
+
registry.signals[name] = {
|
|
30
|
+
pattern: config.pattern instanceof RegExp ? config.pattern : new RegExp(config.pattern, "i"),
|
|
31
|
+
label: config.label || name,
|
|
32
|
+
weight: config.weight || 0
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
addCheck(name, config) {
|
|
37
|
+
if (!name || !config.passed) throw new Error(`Plugin check "${name}" requires a passed function`);
|
|
38
|
+
registry.checks[name] = {
|
|
39
|
+
label: config.label || name,
|
|
40
|
+
maxPoints: config.maxPoints || 5,
|
|
41
|
+
passed: config.passed
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
addCommand(name, config) {
|
|
46
|
+
if (!name || !config.handler) throw new Error(`Plugin command "${name}" requires a handler`);
|
|
47
|
+
registry.commands[name] = {
|
|
48
|
+
description: config.description || name,
|
|
49
|
+
handler: config.handler
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
addScoreRule(name, config) {
|
|
54
|
+
if (!name || !config.passed) throw new Error(`Plugin score rule "${name}" requires a passed function`);
|
|
55
|
+
registry.scoreRules.push({
|
|
56
|
+
key: name,
|
|
57
|
+
label: config.label || name,
|
|
58
|
+
maxPoints: config.maxPoints || 5,
|
|
59
|
+
passed: config.passed
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export async function loadPlugins(pluginPaths = [], options = {}) {
|
|
65
|
+
const loaded = [];
|
|
66
|
+
const errors = [];
|
|
67
|
+
|
|
68
|
+
for (const pluginPath of pluginPaths) {
|
|
69
|
+
try {
|
|
70
|
+
let resolvedPath;
|
|
71
|
+
|
|
72
|
+
if (pluginPath.startsWith(".") || pluginPath.startsWith("/")) {
|
|
73
|
+
resolvedPath = path.resolve(options.baseDir || ".", pluginPath);
|
|
74
|
+
} else {
|
|
75
|
+
// Try as npm module name — resolve from project node_modules
|
|
76
|
+
const baseDirNodeModules = path.join(options.baseDir || ".", "node_modules", pluginPath);
|
|
77
|
+
try {
|
|
78
|
+
await fs.access(baseDirNodeModules);
|
|
79
|
+
resolvedPath = baseDirNodeModules;
|
|
80
|
+
} catch {
|
|
81
|
+
resolvedPath = pluginPath; // Let dynamic import handle it
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pluginModule = await import(resolvedPath);
|
|
86
|
+
if (typeof pluginModule.register !== "function") {
|
|
87
|
+
errors.push({ path: pluginPath, error: "Plugin must export a register(api) function" });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pluginModule.register(api);
|
|
92
|
+
loaded.push({ path: pluginPath, name: pluginModule.name || path.basename(pluginPath) });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
errors.push({ path: pluginPath, error: err.message });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { loaded, errors };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getPluginRegistry() {
|
|
102
|
+
return { ...registry };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getPluginSignals() {
|
|
106
|
+
return { ...registry.signals };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getPluginChecks() {
|
|
110
|
+
return { ...registry.checks };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getPluginCommands() {
|
|
114
|
+
return { ...registry.commands };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getPluginScoreRules() {
|
|
118
|
+
return [...registry.scoreRules];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resetPluginRegistry() {
|
|
122
|
+
registry.signals = {};
|
|
123
|
+
registry.checks = {};
|
|
124
|
+
registry.commands = {};
|
|
125
|
+
registry.scoreRules = [];
|
|
126
|
+
}
|