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/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.0.0",
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
- ".": "./src/index.js"
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
  }
@@ -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
+ }