geo-ai-search-optimization 2.0.0 → 2.2.1
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/README.md +166 -1205
- 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
package/action.yml
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
name: "GEO AI Search Optimization"
|
|
2
|
+
description: "Run GEO audit in CI/CD. Check your site's AI search readiness score and fail on regression."
|
|
3
|
+
author: "geo-ai-search-optimization"
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
project-path:
|
|
7
|
+
description: "Path to the project to audit"
|
|
8
|
+
required: true
|
|
9
|
+
default: "."
|
|
10
|
+
min-score:
|
|
11
|
+
description: "Minimum GEO score to pass (0-100)"
|
|
12
|
+
required: false
|
|
13
|
+
default: "40"
|
|
14
|
+
baseline:
|
|
15
|
+
description: "Path to baseline audit JSON for regression detection"
|
|
16
|
+
required: false
|
|
17
|
+
fail-on-regression:
|
|
18
|
+
description: "Fail if score drops compared to baseline"
|
|
19
|
+
required: false
|
|
20
|
+
default: "false"
|
|
21
|
+
output-format:
|
|
22
|
+
description: "Output format: json or markdown"
|
|
23
|
+
required: false
|
|
24
|
+
default: "markdown"
|
|
25
|
+
save-snapshot:
|
|
26
|
+
description: "Save audit snapshot for trend tracking"
|
|
27
|
+
required: false
|
|
28
|
+
default: "false"
|
|
29
|
+
|
|
30
|
+
outputs:
|
|
31
|
+
score:
|
|
32
|
+
description: "GEO audit score (0-100)"
|
|
33
|
+
value: ${{ steps.audit.outputs.score }}
|
|
34
|
+
score-label:
|
|
35
|
+
description: "Human-readable score label"
|
|
36
|
+
value: ${{ steps.audit.outputs.score-label }}
|
|
37
|
+
passed:
|
|
38
|
+
description: "Whether the audit passed (true/false)"
|
|
39
|
+
value: ${{ steps.audit.outputs.passed }}
|
|
40
|
+
report-path:
|
|
41
|
+
description: "Path to the generated report file"
|
|
42
|
+
value: ${{ steps.audit.outputs.report-path }}
|
|
43
|
+
|
|
44
|
+
runs:
|
|
45
|
+
using: "composite"
|
|
46
|
+
steps:
|
|
47
|
+
- name: Setup Node.js
|
|
48
|
+
uses: actions/setup-node@v4
|
|
49
|
+
with:
|
|
50
|
+
node-version: "18"
|
|
51
|
+
|
|
52
|
+
- name: Install geo-ai-search-optimization
|
|
53
|
+
shell: bash
|
|
54
|
+
run: npm install -g geo-ai-search-optimization
|
|
55
|
+
|
|
56
|
+
- name: Run GEO Audit
|
|
57
|
+
id: audit
|
|
58
|
+
shell: bash
|
|
59
|
+
run: |
|
|
60
|
+
PROJ_PATH="${{ inputs.project-path }}"
|
|
61
|
+
BASELINE="${{ inputs.baseline }}"
|
|
62
|
+
MIN_SCORE="${{ inputs.min-score }}"
|
|
63
|
+
|
|
64
|
+
ARGS=("$PROJ_PATH" "--json")
|
|
65
|
+
|
|
66
|
+
if [ -n "$BASELINE" ]; then
|
|
67
|
+
ARGS+=("--baseline" "$BASELINE")
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if [ "${{ inputs.fail-on-regression }}" = "true" ]; then
|
|
71
|
+
ARGS+=("--fail-on-regression")
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
ARGS+=("--min-score" "$MIN_SCORE")
|
|
75
|
+
|
|
76
|
+
RESULT=$(geo-ai-search-optimization ci "${ARGS[@]}" 2>geo-audit-stderr.log) || true
|
|
77
|
+
echo "$RESULT" > geo-audit-result.json
|
|
78
|
+
|
|
79
|
+
SCORE=$(echo "$RESULT" | node -e "
|
|
80
|
+
const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
|
|
81
|
+
console.log(data.audit?.score ?? data.score ?? 0);
|
|
82
|
+
" 2>/dev/null || echo "0")
|
|
83
|
+
|
|
84
|
+
LABEL=$(echo "$RESULT" | node -e "
|
|
85
|
+
const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
|
|
86
|
+
console.log(data.audit?.scoreLabel ?? data.scoreLabel ?? 'unknown');
|
|
87
|
+
" 2>/dev/null || echo "unknown")
|
|
88
|
+
|
|
89
|
+
PASSED=$(echo "$RESULT" | node -e "
|
|
90
|
+
const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
|
|
91
|
+
console.log(data.passed ?? (data.exitCode === 0));
|
|
92
|
+
" 2>/dev/null || echo "false")
|
|
93
|
+
|
|
94
|
+
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
|
95
|
+
echo "score-label=$LABEL" >> $GITHUB_OUTPUT
|
|
96
|
+
echo "passed=$PASSED" >> $GITHUB_OUTPUT
|
|
97
|
+
echo "report-path=geo-audit-result.json" >> $GITHUB_OUTPUT
|
|
98
|
+
|
|
99
|
+
- name: Save Snapshot
|
|
100
|
+
if: inputs.save-snapshot == 'true'
|
|
101
|
+
shell: bash
|
|
102
|
+
run: |
|
|
103
|
+
geo-ai-search-optimization audit "${{ inputs.project-path }}" --save --json > /dev/null 2>&1 || true
|
|
104
|
+
|
|
105
|
+
- name: Generate Markdown Report
|
|
106
|
+
if: inputs.output-format == 'markdown'
|
|
107
|
+
shell: bash
|
|
108
|
+
run: |
|
|
109
|
+
geo-ai-search-optimization audit "${{ inputs.project-path }}" --out geo-audit-report.md || true
|
|
110
|
+
|
|
111
|
+
- name: Post Summary
|
|
112
|
+
if: always()
|
|
113
|
+
shell: bash
|
|
114
|
+
run: |
|
|
115
|
+
echo "### GEO Audit Results" >> $GITHUB_STEP_SUMMARY
|
|
116
|
+
echo "" >> $GITHUB_STEP_SUMMARY
|
|
117
|
+
echo "- **Score:** ${{ steps.audit.outputs.score }}/100 (${{ steps.audit.outputs.score-label }})" >> $GITHUB_STEP_SUMMARY
|
|
118
|
+
echo "- **Passed:** ${{ steps.audit.outputs.passed }}" >> $GITHUB_STEP_SUMMARY
|
|
119
|
+
echo "- **Min Score:** ${{ inputs.min-score }}" >> $GITHUB_STEP_SUMMARY
|
|
120
|
+
|
|
121
|
+
- name: Check Result
|
|
122
|
+
if: steps.audit.outputs.passed == 'false'
|
|
123
|
+
shell: bash
|
|
124
|
+
run: |
|
|
125
|
+
echo "::error::GEO audit failed. Score: ${{ steps.audit.outputs.score }}/100 (minimum: ${{ inputs.min-score }})"
|
|
126
|
+
exit 1
|
|
127
|
+
|
|
128
|
+
branding:
|
|
129
|
+
icon: "search"
|
|
130
|
+
color: "blue"
|
package/package.json
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "geo-ai-search-optimization",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"geo-ai-search-optimization": "bin/geo-ai-search-optimization.js"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
|
-
".":
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
}
|
|
11
14
|
},
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
12
16
|
"files": [
|
|
13
17
|
"bin",
|
|
14
18
|
"src",
|
|
15
19
|
"resources",
|
|
16
20
|
"examples",
|
|
21
|
+
"action.yml",
|
|
17
22
|
"README.md",
|
|
18
23
|
"LICENSE"
|
|
19
24
|
],
|
|
@@ -34,7 +39,14 @@
|
|
|
34
39
|
"codex-skill",
|
|
35
40
|
"chatgpt",
|
|
36
41
|
"perplexity",
|
|
37
|
-
"gemini"
|
|
42
|
+
"gemini",
|
|
43
|
+
"llms-txt",
|
|
44
|
+
"schema-validation",
|
|
45
|
+
"eeat",
|
|
46
|
+
"citability",
|
|
47
|
+
"ai-crawler",
|
|
48
|
+
"aeo",
|
|
49
|
+
"ai-optimization"
|
|
38
50
|
],
|
|
39
51
|
"license": "MIT"
|
|
40
52
|
}
|
package/src/auto-fix.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeScanOutput } from "./scan.js";
|
|
4
|
+
import { auditPage } from "./page-audit.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auto-fix generator: produces ready-to-use code snippets
|
|
8
|
+
* based on page audit findings.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function generateMetaTags(metadata, signals) {
|
|
12
|
+
const tags = [];
|
|
13
|
+
|
|
14
|
+
if (!metadata.title) {
|
|
15
|
+
tags.push({ tag: "title", html: "<title>Your Page Title — Concise, Descriptive</title>", priority: "P0" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!metadata.metaDescription) {
|
|
19
|
+
tags.push({
|
|
20
|
+
tag: "meta-description",
|
|
21
|
+
html: '<meta name="description" content="A clear, 150-160 character description of what this page covers and who it helps.">',
|
|
22
|
+
priority: "P0"
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!metadata.canonical) {
|
|
27
|
+
tags.push({
|
|
28
|
+
tag: "canonical",
|
|
29
|
+
html: '<link rel="canonical" href="https://yoursite.com/this-page">',
|
|
30
|
+
priority: "P0"
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Viewport
|
|
35
|
+
tags.push({
|
|
36
|
+
tag: "viewport",
|
|
37
|
+
html: '<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
38
|
+
priority: "P1",
|
|
39
|
+
condition: "Add if missing"
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Charset
|
|
43
|
+
tags.push({
|
|
44
|
+
tag: "charset",
|
|
45
|
+
html: '<meta charset="UTF-8">',
|
|
46
|
+
priority: "P2",
|
|
47
|
+
condition: "Add if missing"
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return tags;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function generateOgTags(metadata) {
|
|
54
|
+
const title = metadata.title || "Your Page Title";
|
|
55
|
+
const description = metadata.metaDescription || "A concise description of your page.";
|
|
56
|
+
const url = metadata.canonical || "https://yoursite.com/this-page";
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
{ tag: "og:title", html: `<meta property="og:title" content="${title}">` },
|
|
60
|
+
{ tag: "og:description", html: `<meta property="og:description" content="${description}">` },
|
|
61
|
+
{ tag: "og:url", html: `<meta property="og:url" content="${url}">` },
|
|
62
|
+
{ tag: "og:type", html: '<meta property="og:type" content="article">' },
|
|
63
|
+
{ tag: "og:image", html: '<meta property="og:image" content="https://yoursite.com/images/page-image.png">' },
|
|
64
|
+
{ tag: "og:image:width", html: '<meta property="og:image:width" content="1200">' },
|
|
65
|
+
{ tag: "og:image:height", html: '<meta property="og:image:height" content="630">' },
|
|
66
|
+
{ tag: "og:image:alt", html: `<meta property="og:image:alt" content="${title}">` },
|
|
67
|
+
{ tag: "twitter:card", html: '<meta name="twitter:card" content="summary_large_image">' },
|
|
68
|
+
{ tag: "twitter:title", html: `<meta name="twitter:title" content="${title}">` },
|
|
69
|
+
{ tag: "twitter:description", html: `<meta name="twitter:description" content="${description}">` }
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function generateJsonLd(metadata, headings, signals) {
|
|
74
|
+
const title = metadata.title || "Page Title";
|
|
75
|
+
const description = metadata.metaDescription || "Page description.";
|
|
76
|
+
const url = metadata.canonical || "https://yoursite.com/page";
|
|
77
|
+
|
|
78
|
+
const schemas = [];
|
|
79
|
+
|
|
80
|
+
// Article schema (default for content pages)
|
|
81
|
+
schemas.push({
|
|
82
|
+
type: "Article",
|
|
83
|
+
json: JSON.stringify({
|
|
84
|
+
"@context": "https://schema.org",
|
|
85
|
+
"@type": "Article",
|
|
86
|
+
headline: title,
|
|
87
|
+
description,
|
|
88
|
+
author: { "@type": "Person", name: "Author Name" },
|
|
89
|
+
publisher: { "@type": "Organization", name: "Your Organization", url: "https://yoursite.com" },
|
|
90
|
+
datePublished: new Date().toISOString().slice(0, 10),
|
|
91
|
+
dateModified: new Date().toISOString().slice(0, 10),
|
|
92
|
+
mainEntityOfPage: url,
|
|
93
|
+
image: "https://yoursite.com/images/article-image.png"
|
|
94
|
+
}, null, 2)
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// FAQ schema from question headings
|
|
98
|
+
const questionHeadings = (headings || []).filter((h) =>
|
|
99
|
+
h.text.includes("?") || h.text.includes("?") ||
|
|
100
|
+
/^(what|how|why|when|where|who|which|can|do|does|is|are|should)\b/i.test(h.text)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (questionHeadings.length >= 2) {
|
|
104
|
+
schemas.push({
|
|
105
|
+
type: "FAQPage",
|
|
106
|
+
json: JSON.stringify({
|
|
107
|
+
"@context": "https://schema.org",
|
|
108
|
+
"@type": "FAQPage",
|
|
109
|
+
mainEntity: questionHeadings.slice(0, 5).map((h) => ({
|
|
110
|
+
"@type": "Question",
|
|
111
|
+
name: h.text,
|
|
112
|
+
acceptedAnswer: {
|
|
113
|
+
"@type": "Answer",
|
|
114
|
+
text: `[Replace with the answer visible on the page for: ${h.text}]`
|
|
115
|
+
}
|
|
116
|
+
}))
|
|
117
|
+
}, null, 2)
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Breadcrumb
|
|
122
|
+
schemas.push({
|
|
123
|
+
type: "BreadcrumbList",
|
|
124
|
+
json: JSON.stringify({
|
|
125
|
+
"@context": "https://schema.org",
|
|
126
|
+
"@type": "BreadcrumbList",
|
|
127
|
+
itemListElement: [
|
|
128
|
+
{ "@type": "ListItem", position: 1, name: "Home", item: "https://yoursite.com/" },
|
|
129
|
+
{ "@type": "ListItem", position: 2, name: "Section", item: "https://yoursite.com/section/" },
|
|
130
|
+
{ "@type": "ListItem", position: 3, name: title, item: url }
|
|
131
|
+
]
|
|
132
|
+
}, null, 2)
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return schemas;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function generateRobotsTxtAiSection() {
|
|
139
|
+
return {
|
|
140
|
+
label: "robots.txt AI Crawler Section",
|
|
141
|
+
content: [
|
|
142
|
+
"# AI Crawler Rules — REVIEW BEFORE ENABLING",
|
|
143
|
+
"# WARNING: Uncomment only the crawlers you want to explicitly allow.",
|
|
144
|
+
"# These rules override any existing User-agent: * Disallow: / rules.",
|
|
145
|
+
"# Make sure this does not expose private/admin paths unintentionally.",
|
|
146
|
+
"",
|
|
147
|
+
"# User-agent: GPTBot",
|
|
148
|
+
"# Allow: /",
|
|
149
|
+
"",
|
|
150
|
+
"# User-agent: ChatGPT-User",
|
|
151
|
+
"# Allow: /",
|
|
152
|
+
"",
|
|
153
|
+
"# User-agent: Google-Extended",
|
|
154
|
+
"# Allow: /",
|
|
155
|
+
"",
|
|
156
|
+
"# User-agent: PerplexityBot",
|
|
157
|
+
"# Allow: /",
|
|
158
|
+
"",
|
|
159
|
+
"# User-agent: ClaudeBot",
|
|
160
|
+
"# Allow: /",
|
|
161
|
+
"",
|
|
162
|
+
"# User-agent: anthropic-ai",
|
|
163
|
+
"# Allow: /",
|
|
164
|
+
"",
|
|
165
|
+
"# User-agent: Applebot-Extended",
|
|
166
|
+
"# Allow: /",
|
|
167
|
+
""
|
|
168
|
+
].join("\n")
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function generateLlmsTxt(metadata) {
|
|
173
|
+
const title = metadata.title || "Your Site Name";
|
|
174
|
+
const url = metadata.canonical || "https://yoursite.com";
|
|
175
|
+
const host = (() => { try { return new URL(url).origin; } catch { return "https://yoursite.com"; } })();
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
label: "llms.txt",
|
|
179
|
+
content: [
|
|
180
|
+
`# ${title}`,
|
|
181
|
+
"",
|
|
182
|
+
"> One-sentence description of what this site does and who it serves.",
|
|
183
|
+
"",
|
|
184
|
+
`Canonical: ${host}`,
|
|
185
|
+
"",
|
|
186
|
+
"## About",
|
|
187
|
+
"",
|
|
188
|
+
"- Describe what this site does in 1-2 sentences.",
|
|
189
|
+
"- Describe the primary audience.",
|
|
190
|
+
"- Mention what makes the content uniquely trustworthy.",
|
|
191
|
+
"",
|
|
192
|
+
"## Priority URLs",
|
|
193
|
+
"",
|
|
194
|
+
`- ${host}/`,
|
|
195
|
+
`- ${host}/pricing`,
|
|
196
|
+
`- ${host}/docs`,
|
|
197
|
+
`- ${host}/blog`,
|
|
198
|
+
"",
|
|
199
|
+
"## Recommended Sources",
|
|
200
|
+
"",
|
|
201
|
+
"- Link to pages with first-party research, methodology, or clear product facts.",
|
|
202
|
+
"",
|
|
203
|
+
"## AI Assistant Guidance",
|
|
204
|
+
"",
|
|
205
|
+
"- Use the latest canonical URLs when citing this site.",
|
|
206
|
+
"- Prefer pages with explicit dates, authors, or methodology notes.",
|
|
207
|
+
"",
|
|
208
|
+
"## Update Policy",
|
|
209
|
+
"",
|
|
210
|
+
"- Content is reviewed and updated [weekly/monthly/quarterly].",
|
|
211
|
+
""
|
|
212
|
+
].join("\n")
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function generateHeadingFix(headings) {
|
|
217
|
+
if (!headings || headings.length === 0) {
|
|
218
|
+
return {
|
|
219
|
+
label: "Suggested Heading Structure",
|
|
220
|
+
content: [
|
|
221
|
+
"<!-- Suggested answer-first heading structure -->",
|
|
222
|
+
"<h1>Page Topic — What This Is</h1>",
|
|
223
|
+
"<p>[2-4 sentences: direct answer, who it's for, key limitation]</p>",
|
|
224
|
+
"",
|
|
225
|
+
"<h2>What is [Topic]?</h2>",
|
|
226
|
+
"<p>[Definition and context]</p>",
|
|
227
|
+
"",
|
|
228
|
+
"<h2>How does [Topic] work?</h2>",
|
|
229
|
+
"<p>[Process or methodology]</p>",
|
|
230
|
+
"",
|
|
231
|
+
"<h2>Who should use [Topic]?</h2>",
|
|
232
|
+
"<p>[Target audience and use cases]</p>",
|
|
233
|
+
"",
|
|
234
|
+
"<h2>[Topic] vs Alternatives</h2>",
|
|
235
|
+
"<p>[Comparison with alternatives]</p>",
|
|
236
|
+
"",
|
|
237
|
+
"<h2>Frequently Asked Questions</h2>",
|
|
238
|
+
"<h3>Question 1?</h3>",
|
|
239
|
+
"<p>[Answer]</p>",
|
|
240
|
+
"<h3>Question 2?</h3>",
|
|
241
|
+
"<p>[Answer]</p>"
|
|
242
|
+
].join("\n")
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildFixSummary(fixes) {
|
|
250
|
+
const metaCount = fixes.metaTags.length;
|
|
251
|
+
const ogCount = fixes.ogTags.length;
|
|
252
|
+
const schemaCount = fixes.jsonLdSchemas.length;
|
|
253
|
+
const hasRobots = fixes.robotsTxt !== null;
|
|
254
|
+
const hasLlms = fixes.llmsTxt !== null;
|
|
255
|
+
const hasHeading = fixes.headingFix !== null;
|
|
256
|
+
|
|
257
|
+
const parts = [];
|
|
258
|
+
if (metaCount > 0) parts.push(`${metaCount} meta tags`);
|
|
259
|
+
if (ogCount > 0) parts.push(`${ogCount} OG/Twitter tags`);
|
|
260
|
+
if (schemaCount > 0) parts.push(`${schemaCount} JSON-LD schema(s)`);
|
|
261
|
+
if (hasRobots) parts.push("robots.txt AI section");
|
|
262
|
+
if (hasLlms) parts.push("llms.txt template");
|
|
263
|
+
if (hasHeading) parts.push("heading structure");
|
|
264
|
+
|
|
265
|
+
return `Generated ${parts.join(", ")}.`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function generateAutoFix(input, options = {}) {
|
|
269
|
+
const audit = await auditPage(input, options);
|
|
270
|
+
|
|
271
|
+
const metaTags = generateMetaTags(audit.metadata, audit.signals);
|
|
272
|
+
// Always generate OG/Twitter tags as templates — user applies what's missing
|
|
273
|
+
const ogTags = generateOgTags(audit.metadata);
|
|
274
|
+
const jsonLdSchemas = audit.signals.json_ld.count === 0
|
|
275
|
+
? generateJsonLd(audit.metadata, audit.headings, audit.signals)
|
|
276
|
+
: [];
|
|
277
|
+
const robotsTxt = generateRobotsTxtAiSection();
|
|
278
|
+
const llmsTxt = generateLlmsTxt(audit.metadata);
|
|
279
|
+
const headingFix = audit.headingStats.questionHeadingCount === 0
|
|
280
|
+
? generateHeadingFix(audit.headings)
|
|
281
|
+
: null;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
kind: "geo-auto-fix",
|
|
285
|
+
input,
|
|
286
|
+
source: audit.reference,
|
|
287
|
+
auditScore: audit.score.score,
|
|
288
|
+
metaTags,
|
|
289
|
+
ogTags,
|
|
290
|
+
jsonLdSchemas,
|
|
291
|
+
robotsTxt,
|
|
292
|
+
llmsTxt,
|
|
293
|
+
headingFix,
|
|
294
|
+
summary: buildFixSummary({ metaTags, ogTags, jsonLdSchemas, robotsTxt, llmsTxt, headingFix })
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function renderAutoFixMarkdown(report) {
|
|
299
|
+
const lines = [
|
|
300
|
+
"# GEO Auto-Fix: Generated Code",
|
|
301
|
+
"",
|
|
302
|
+
`- Source: \`${report.source}\``,
|
|
303
|
+
`- Audit Score: \`${report.auditScore}/100\``,
|
|
304
|
+
`- Summary: ${report.summary}`,
|
|
305
|
+
""
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
if (report.metaTags.length > 0) {
|
|
309
|
+
lines.push("## Meta Tags", "", "Add these to your `<head>`:", "", "```html");
|
|
310
|
+
for (const tag of report.metaTags) {
|
|
311
|
+
lines.push(`<!-- ${tag.priority}: ${tag.tag}${tag.condition ? ` (${tag.condition})` : ""} -->`);
|
|
312
|
+
lines.push(tag.html);
|
|
313
|
+
}
|
|
314
|
+
lines.push("```", "");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (report.ogTags.length > 0) {
|
|
318
|
+
lines.push("## Open Graph & Twitter Tags", "", "```html");
|
|
319
|
+
for (const tag of report.ogTags) {
|
|
320
|
+
lines.push(tag.html);
|
|
321
|
+
}
|
|
322
|
+
lines.push("```", "");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (report.jsonLdSchemas.length > 0) {
|
|
326
|
+
lines.push("## JSON-LD Structured Data", "");
|
|
327
|
+
for (const schema of report.jsonLdSchemas) {
|
|
328
|
+
lines.push(`### ${schema.type}`, "", "```html", `<script type="application/ld+json">`, schema.json, "</script>", "```", "");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (report.robotsTxt) {
|
|
333
|
+
lines.push("## robots.txt AI Section", "", "Append to your `robots.txt`:", "", "```", report.robotsTxt.content, "```", "");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (report.llmsTxt) {
|
|
337
|
+
lines.push("## llms.txt Template", "", "Save as `llms.txt` in your site root:", "", "```", report.llmsTxt.content, "```", "");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (report.headingFix) {
|
|
341
|
+
lines.push("## Suggested Heading Structure", "", "```html", report.headingFix.content, "```", "");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function writeAutoFixOutput(outputPath, content) {
|
|
348
|
+
return writeScanOutput(outputPath, content);
|
|
349
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { writeScanOutput } from "./scan.js";
|
|
2
|
+
import { fullPageAudit } from "./full-page-audit.js";
|
|
3
|
+
|
|
4
|
+
export async function batchFullPageAudit(urls, options = {}) {
|
|
5
|
+
if (!urls || urls.length === 0) {
|
|
6
|
+
throw new Error("batch-full-page-audit requires at least one URL or file path");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const concurrency = options.concurrency || 2;
|
|
10
|
+
const results = [];
|
|
11
|
+
const errors = [];
|
|
12
|
+
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for (let i = 0; i < urls.length; i += concurrency) {
|
|
15
|
+
chunks.push(urls.slice(i, i + concurrency));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const chunk of chunks) {
|
|
19
|
+
const chunkResults = await Promise.all(
|
|
20
|
+
chunk.map(async (url) => {
|
|
21
|
+
try {
|
|
22
|
+
const result = await fullPageAudit(url, options);
|
|
23
|
+
return { ok: true, url, data: result };
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { ok: false, url, error: err.message };
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
for (const result of chunkResults) {
|
|
31
|
+
if (result.ok) {
|
|
32
|
+
results.push(result.data);
|
|
33
|
+
} else {
|
|
34
|
+
errors.push({ url: result.url, error: result.error });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Compute aggregate stats
|
|
40
|
+
const scores = results.map((r) => r.compositeScore);
|
|
41
|
+
const avgScore = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
|
|
42
|
+
const minScore = scores.length > 0 ? Math.min(...scores) : 0;
|
|
43
|
+
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
|
|
44
|
+
|
|
45
|
+
// Dimension averages
|
|
46
|
+
const dimensionKeys = ["base", "citability", "eeat", "readability", "headingStructure", "internalLinks", "socialMeta", "platformReady", "schema", "freshness", "security", "topics"];
|
|
47
|
+
const dimensionAverages = {};
|
|
48
|
+
for (const key of dimensionKeys) {
|
|
49
|
+
const dimScores = results.map((r) => r.dimensions[key]?.score || 0);
|
|
50
|
+
dimensionAverages[key] = dimScores.length > 0 ? Math.round(dimScores.reduce((a, b) => a + b, 0) / dimScores.length) : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Weakest pages
|
|
54
|
+
const weakest = [...results]
|
|
55
|
+
.sort((a, b) => a.compositeScore - b.compositeScore)
|
|
56
|
+
.slice(0, 5);
|
|
57
|
+
|
|
58
|
+
// Most common issues
|
|
59
|
+
const issueCounts = {};
|
|
60
|
+
for (const result of results) {
|
|
61
|
+
for (const issue of result.topIssues || []) {
|
|
62
|
+
const key = issue.issue;
|
|
63
|
+
issueCounts[key] = (issueCounts[key] || 0) + 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const commonIssues = Object.entries(issueCounts)
|
|
67
|
+
.sort((a, b) => b[1] - a[1])
|
|
68
|
+
.slice(0, 10)
|
|
69
|
+
.map(([issue, count]) => ({ issue, count, percentage: Math.round((count / results.length) * 100) }));
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
kind: "geo-batch-full-page-audit",
|
|
73
|
+
totalUrls: urls.length,
|
|
74
|
+
successful: results.length,
|
|
75
|
+
failed: errors.length,
|
|
76
|
+
avgScore,
|
|
77
|
+
minScore,
|
|
78
|
+
maxScore,
|
|
79
|
+
dimensionAverages,
|
|
80
|
+
results,
|
|
81
|
+
weakest,
|
|
82
|
+
commonIssues,
|
|
83
|
+
errors,
|
|
84
|
+
summary: `Batch audit: ${results.length}/${urls.length} pages. Average composite: ${avgScore}/100. Range: ${minScore}-${maxScore}.`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderBatchFullPageAuditMarkdown(report) {
|
|
89
|
+
const lines = [
|
|
90
|
+
"# Batch Full Page Audit",
|
|
91
|
+
"",
|
|
92
|
+
`- Pages audited: \`${report.successful}/${report.totalUrls}\``,
|
|
93
|
+
`- Failures: \`${report.failed}\``,
|
|
94
|
+
`- **Average Composite Score: \`${report.avgScore}/100\`**`,
|
|
95
|
+
`- Range: \`${report.minScore}\` - \`${report.maxScore}\``,
|
|
96
|
+
`- Summary: ${report.summary}`,
|
|
97
|
+
"",
|
|
98
|
+
"## Dimension Averages",
|
|
99
|
+
"",
|
|
100
|
+
"| Dimension | Average Score |",
|
|
101
|
+
"|-----------|--------------|"
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const dimLabels = {
|
|
105
|
+
base: "Base Audit", citability: "Citability", eeat: "E-E-A-T",
|
|
106
|
+
readability: "Readability", headingStructure: "Heading Structure",
|
|
107
|
+
internalLinks: "Internal Links", socialMeta: "Social Meta",
|
|
108
|
+
platformReady: "Platform Readiness", schema: "Schema",
|
|
109
|
+
freshness: "Freshness", security: "Security", topics: "Topics"
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const [key, avg] of Object.entries(report.dimensionAverages)) {
|
|
113
|
+
const icon = avg >= 70 ? "🟢" : avg >= 40 ? "🟡" : "🔴";
|
|
114
|
+
lines.push(`| ${icon} ${dimLabels[key] || key} | ${avg}/100 |`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
lines.push("", "## Page Results", "", "| Page | Composite | Label |", "|------|-----------|-------|");
|
|
118
|
+
for (const result of report.results) {
|
|
119
|
+
const short = result.input.length > 50 ? `...${result.input.slice(-47)}` : result.input;
|
|
120
|
+
const icon = result.compositeScore >= 70 ? "🟢" : result.compositeScore >= 40 ? "🟡" : "🔴";
|
|
121
|
+
lines.push(`| ${short} | ${icon} ${result.compositeScore}/100 | ${result.compositeLabel} |`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (report.weakest.length > 0) {
|
|
125
|
+
lines.push("", "## Weakest Pages", "");
|
|
126
|
+
for (const page of report.weakest) {
|
|
127
|
+
lines.push(`- **${page.compositeScore}/100** — \`${page.input}\``);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (report.commonIssues.length > 0) {
|
|
132
|
+
lines.push("", "## Most Common Issues", "");
|
|
133
|
+
for (const issue of report.commonIssues) {
|
|
134
|
+
lines.push(`- (${issue.percentage}% of pages) ${issue.issue}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (report.errors.length > 0) {
|
|
139
|
+
lines.push("", "## Errors", "");
|
|
140
|
+
for (const err of report.errors) {
|
|
141
|
+
lines.push(`- ❌ \`${err.url}\`: ${err.error}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lines.push("");
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function writeBatchFullPageAuditOutput(outputPath, content) {
|
|
150
|
+
return writeScanOutput(outputPath, content);
|
|
151
|
+
}
|