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,287 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeScanOutput } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
function extractHeadings(content) {
|
|
6
|
+
const headings = [];
|
|
7
|
+
|
|
8
|
+
const htmlMatches = content.matchAll(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gim);
|
|
9
|
+
for (const match of htmlMatches) {
|
|
10
|
+
headings.push({
|
|
11
|
+
level: Number.parseInt(match[1], 10),
|
|
12
|
+
text: match[2].replace(/<[^>]+>/g, "").trim(),
|
|
13
|
+
source: "html"
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (headings.length === 0) {
|
|
18
|
+
const mdMatches = content.matchAll(/^(#{1,6})\s+(.+)$/gm);
|
|
19
|
+
for (const match of mdMatches) {
|
|
20
|
+
headings.push({
|
|
21
|
+
level: match[1].length,
|
|
22
|
+
text: match[2].trim(),
|
|
23
|
+
source: "markdown"
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return headings;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function analyzeHierarchy(headings) {
|
|
32
|
+
const issues = [];
|
|
33
|
+
const levels = headings.map((h) => h.level);
|
|
34
|
+
|
|
35
|
+
if (levels.length === 0) {
|
|
36
|
+
return { issues: [{ severity: "error", message: "No headings found" }], depth: 0, levelDistribution: {} };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const h1Count = levels.filter((l) => l === 1).length;
|
|
40
|
+
if (h1Count === 0) {
|
|
41
|
+
issues.push({ severity: "error", message: "Missing H1 heading" });
|
|
42
|
+
} else if (h1Count > 1) {
|
|
43
|
+
issues.push({ severity: "warning", message: `Multiple H1 headings found (${h1Count}). Best practice: use only one H1.` });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (levels[0] !== 1) {
|
|
47
|
+
issues.push({ severity: "warning", message: `First heading is H${levels[0]}, should be H1` });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (let i = 1; i < levels.length; i++) {
|
|
51
|
+
if (levels[i] > levels[i - 1] + 1) {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: `Skipped heading level: H${levels[i - 1]} → H${levels[i]} at "${headings[i].text.slice(0, 40)}"`
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const usedLevels = new Set(levels);
|
|
60
|
+
const maxLevel = Math.max(...levels);
|
|
61
|
+
for (let l = 1; l <= maxLevel; l++) {
|
|
62
|
+
if (!usedLevels.has(l)) {
|
|
63
|
+
issues.push({ severity: "info", message: `H${l} level not used (gap in hierarchy)` });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const depth = Math.max(...levels);
|
|
68
|
+
const levelDistribution = {};
|
|
69
|
+
for (let l = 1; l <= 6; l++) {
|
|
70
|
+
levelDistribution[`h${l}`] = levels.filter((lev) => lev === l).length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { issues, depth, levelDistribution };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function analyzeSemanticCoverage(headings) {
|
|
77
|
+
const texts = headings.map((h) => h.text.toLowerCase());
|
|
78
|
+
const fullText = texts.join(" ");
|
|
79
|
+
|
|
80
|
+
const categories = {
|
|
81
|
+
what: { pattern: /\b(what|definition|overview|introduction|about|summary)\b/i, found: false },
|
|
82
|
+
how: { pattern: /\b(how|guide|tutorial|steps?|instructions?|process|method)\b/i, found: false },
|
|
83
|
+
why: { pattern: /\b(why|benefits?|advantages?|reasons?|importance|value)\b/i, found: false },
|
|
84
|
+
who: { pattern: /\b(who|audience|for|users?|customers?|team)\b/i, found: false },
|
|
85
|
+
comparison: { pattern: /\b(vs\.?|versus|compare|comparison|alternatives?|difference)\b/i, found: false },
|
|
86
|
+
faq: { pattern: /\b(faq|questions?|q&a)\b|\?/i, found: false },
|
|
87
|
+
pricing: { pattern: /\b(pricing|cost|plans?|subscription|free|trial)\b/i, found: false },
|
|
88
|
+
technical: { pattern: /\b(api|sdk|integration|install|setup|configuration|requirements)\b/i, found: false }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const [key, category] of Object.entries(categories)) {
|
|
92
|
+
category.found = texts.some((t) => category.pattern.test(t));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const covered = Object.values(categories).filter((c) => c.found).length;
|
|
96
|
+
const total = Object.keys(categories).length;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
categories: Object.fromEntries(
|
|
100
|
+
Object.entries(categories).map(([key, cat]) => [key, cat.found])
|
|
101
|
+
),
|
|
102
|
+
coverageRatio: Math.round((covered / total) * 100),
|
|
103
|
+
covered,
|
|
104
|
+
total
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function analyzeQuestionHeadings(headings) {
|
|
109
|
+
const questions = headings.filter((h) =>
|
|
110
|
+
h.text.includes("?") || h.text.includes("?") ||
|
|
111
|
+
/^(what|how|why|when|where|who|which|can|do|does|is|are|should|will)\b/i.test(h.text)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
count: questions.length,
|
|
116
|
+
ratio: headings.length > 0 ? Math.round((questions.length / headings.length) * 100) : 0,
|
|
117
|
+
examples: questions.slice(0, 5).map((h) => h.text)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function computeScore(headings, hierarchy, semanticCoverage, questionHeadings) {
|
|
122
|
+
if (headings.length === 0) return 0;
|
|
123
|
+
|
|
124
|
+
let score = 0;
|
|
125
|
+
const errors = hierarchy.issues.filter((i) => i.severity === "error");
|
|
126
|
+
const warnings = hierarchy.issues.filter((i) => i.severity === "warning");
|
|
127
|
+
|
|
128
|
+
// Base: heading count
|
|
129
|
+
score += Math.min(headings.length * 5, 25);
|
|
130
|
+
|
|
131
|
+
// Hierarchy quality
|
|
132
|
+
score += errors.length === 0 ? 25 : (errors.length === 1 ? 10 : 0);
|
|
133
|
+
score -= warnings.length * 3;
|
|
134
|
+
|
|
135
|
+
// Semantic coverage
|
|
136
|
+
score += Math.round(semanticCoverage.coverageRatio * 0.3);
|
|
137
|
+
|
|
138
|
+
// Question headings
|
|
139
|
+
score += Math.min(questionHeadings.count * 5, 20);
|
|
140
|
+
|
|
141
|
+
return Math.max(0, Math.min(100, score));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildRecommendations(headings, hierarchy, semanticCoverage, questionHeadings) {
|
|
145
|
+
const recs = [];
|
|
146
|
+
|
|
147
|
+
if (headings.length === 0) {
|
|
148
|
+
recs.push("Add headings to structure your content. Start with one H1, then use H2s for main sections.");
|
|
149
|
+
return recs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const issue of hierarchy.issues.filter((i) => i.severity === "error")) {
|
|
153
|
+
recs.push(issue.message);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (headings.length < 3) {
|
|
157
|
+
recs.push("Add more headings. Content with 5+ headings is easier for AI to parse and cite.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (questionHeadings.count === 0) {
|
|
161
|
+
recs.push("Add question-style headings (e.g., 'What is X?', 'How does Y work?') for better AI answer extraction.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const uncovered = Object.entries(semanticCoverage.categories)
|
|
165
|
+
.filter(([, found]) => !found)
|
|
166
|
+
.map(([key]) => key);
|
|
167
|
+
|
|
168
|
+
if (uncovered.length > 4) {
|
|
169
|
+
recs.push(`Consider adding sections for: ${uncovered.slice(0, 4).join(", ")} to improve topic coverage.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return recs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function fetchContent(url) {
|
|
176
|
+
const response = await fetch(url, {
|
|
177
|
+
redirect: "follow",
|
|
178
|
+
headers: { "user-agent": "geo-ai-search-optimization/2.2.0" },
|
|
179
|
+
signal: AbortSignal.timeout(10_000)
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${url} (status ${response.status})`);
|
|
182
|
+
return response.text();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function analyzeHeadingStructure(input, options = {}) {
|
|
186
|
+
let content;
|
|
187
|
+
let source;
|
|
188
|
+
|
|
189
|
+
if (/^https?:\/\//i.test(input)) {
|
|
190
|
+
content = await fetchContent(input);
|
|
191
|
+
source = input;
|
|
192
|
+
} else {
|
|
193
|
+
const filePath = path.resolve(input);
|
|
194
|
+
content = await fs.readFile(filePath, "utf8");
|
|
195
|
+
source = filePath;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const headings = extractHeadings(content);
|
|
199
|
+
const hierarchy = analyzeHierarchy(headings);
|
|
200
|
+
const semanticCoverage = analyzeSemanticCoverage(headings);
|
|
201
|
+
const questionHeadings = analyzeQuestionHeadings(headings);
|
|
202
|
+
const score = computeScore(headings, hierarchy, semanticCoverage, questionHeadings);
|
|
203
|
+
const recommendations = buildRecommendations(headings, hierarchy, semanticCoverage, questionHeadings);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
kind: "geo-heading-structure",
|
|
207
|
+
source,
|
|
208
|
+
headingCount: headings.length,
|
|
209
|
+
headings: headings.map((h) => ({ level: h.level, text: h.text })),
|
|
210
|
+
hierarchy,
|
|
211
|
+
semanticCoverage,
|
|
212
|
+
questionHeadings,
|
|
213
|
+
score,
|
|
214
|
+
scoreLabel: score >= 70 ? "Good" : score >= 40 ? "Fair" : "Needs work",
|
|
215
|
+
recommendations,
|
|
216
|
+
summary: `${headings.length} headings, depth ${hierarchy.depth}. Semantic coverage: ${semanticCoverage.coverageRatio}%. Question headings: ${questionHeadings.count}.`
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function renderHeadingStructureMarkdown(report) {
|
|
221
|
+
const lines = [
|
|
222
|
+
"# Heading Structure Analysis",
|
|
223
|
+
"",
|
|
224
|
+
`- Source: \`${report.source}\``,
|
|
225
|
+
`- Score: \`${report.score}/100\` (${report.scoreLabel})`,
|
|
226
|
+
`- Total headings: \`${report.headingCount}\``,
|
|
227
|
+
`- Max depth: \`H${report.hierarchy.depth || 0}\``,
|
|
228
|
+
`- Summary: ${report.summary}`,
|
|
229
|
+
"",
|
|
230
|
+
"## Heading Outline",
|
|
231
|
+
""
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
if (report.headings.length === 0) {
|
|
235
|
+
lines.push("- No headings found.");
|
|
236
|
+
} else {
|
|
237
|
+
for (const h of report.headings) {
|
|
238
|
+
const indent = " ".repeat(h.level - 1);
|
|
239
|
+
lines.push(`${indent}- H${h.level}: ${h.text}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
lines.push("", "## Level Distribution", "");
|
|
244
|
+
for (const [level, count] of Object.entries(report.hierarchy.levelDistribution || {})) {
|
|
245
|
+
if (count > 0) {
|
|
246
|
+
lines.push(`- ${level.toUpperCase()}: \`${count}\``);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
lines.push("", "## Semantic Coverage", "");
|
|
251
|
+
for (const [category, found] of Object.entries(report.semanticCoverage.categories || {})) {
|
|
252
|
+
const icon = found ? "✅" : "❌";
|
|
253
|
+
lines.push(`- ${icon} ${category}`);
|
|
254
|
+
}
|
|
255
|
+
lines.push(``, `Coverage: \`${report.semanticCoverage.coverageRatio}%\``);
|
|
256
|
+
|
|
257
|
+
if (report.questionHeadings.count > 0) {
|
|
258
|
+
lines.push("", "## Question Headings", "");
|
|
259
|
+
for (const q of report.questionHeadings.examples) {
|
|
260
|
+
lines.push(`- ${q}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (report.hierarchy.issues.length > 0) {
|
|
265
|
+
lines.push("", "## Hierarchy Issues", "");
|
|
266
|
+
for (const issue of report.hierarchy.issues) {
|
|
267
|
+
const icon = issue.severity === "error" ? "❌" : issue.severity === "warning" ? "⚠️" : "ℹ️";
|
|
268
|
+
lines.push(`- ${icon} ${issue.message}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
lines.push("", "## Recommendations", "");
|
|
273
|
+
if (report.recommendations.length === 0) {
|
|
274
|
+
lines.push("- Heading structure is well-optimized.");
|
|
275
|
+
} else {
|
|
276
|
+
for (const rec of report.recommendations) {
|
|
277
|
+
lines.push(`- ${rec}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
lines.push("");
|
|
281
|
+
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function writeHeadingStructureOutput(outputPath, content) {
|
|
286
|
+
return writeScanOutput(outputPath, content);
|
|
287
|
+
}
|