Generative Engine Optimization (GEO) is the practice of structuring web content so that large language models and AI-powered search engines can accurately discover, parse, and cite it. Unlike traditional SEO—which targets keyword density and backlink graphs—GEO focuses on semantic clarity, structured data markup, factual density, and content organization that LLMs can reliably extract and summarize. A GEO analysis tool implements this discipline as a command-line tool: given a URL, a local HTML file, or a batch list of targets, the CLI fetches and parses each page, runs a multi-pass analysis pipeline, and emits actionable suggestions for improving AI discoverability.
The CLI is built on Commander.js, the de facto standard for Node.js command-line programs. Commander handles argument parsing, subcommand dispatch, option validation, and --help generation, freeing the GEO toolkit’s core logic to focus entirely on content analysis. Two primary subsystems sit behind the CLI surface: a ContentAnalyzer that inspects fetched HTML for structural, semantic, and schema issues, and a SuggestionEngine that converts raw issues into prioritized, categorized, human-readable recommendations with concrete remediation examples.
The output layer supports two formats—plain text for human review in a terminal and JSON for machine consumption in CI pipelines or downstream tooling. Batch mode adds concurrency controls and aggregate scoring, making it practical to audit an entire site’s worth of pages in a single run. Together these layers form a coherent CLI application pattern that is broadly applicable beyond GEO: any tool that fetches external content, runs analysis, and reports findings will benefit from the same Commander/Analyzer/SuggestionEngine decomposition.
Commander.js models a CLI as a tree of Command objects. The root command declares global options (output format, verbosity, concurrency); subcommands like analyze, batch, and report declare their own options and a required action handler. Commander parses process.argv, validates types and required fields, and calls the appropriate handler—or prints a formatted help message if something is missing. This declarative approach means the GEO CLI’s entire argument contract is visible in one place rather than scattered through manual argv parsing.
The ContentAnalyzer accepts a URL or file path, fetches or reads the HTML, then runs a sequence of analysis passes: structural checks (heading hierarchy, landmark elements), metadata checks (title length, meta description, canonical URL), schema.org markup validation, internal link density, and factual-content heuristics. Each pass returns a typed array of Issue objects carrying a code, severity, affected element selector, and raw diagnostic data. Passes are independent, so they can run concurrently via Promise.all and their results are merged into a single AnalysisResult.
The SuggestionEngine takes an AnalysisResult and maps each Issue to a Suggestion. Priority is assigned by combining issue severity with estimated impact on AI discoverability: a missing FAQPage schema on a Q&A page scores higher than a sub-optimal heading depth. Each Suggestion carries a category (Structure, Metadata, Schema, Content, Performance), a priority level (critical, high, medium, low), a human-readable explanation, and one or more structured Example objects showing before/after HTML or JSON-LD snippets. This separation means the analysis logic and the recommendation logic evolve independently.
Batch mode reads a newline-delimited file of URLs or a glob pattern of local HTML files, then processes them with a configurable concurrency limit using a semaphore or a queue-based worker pool. Each page produces its own AnalysisResult; the batch runner aggregates results into a summary report that includes per-page scores, a site-wide score, and the top issues by frequency across all pages. Concurrency limits prevent overwhelming remote servers or exhausting local file descriptors, and failed pages are logged with their error reason rather than aborting the entire batch.
The report layer is a pure formatting concern, separated from analysis. Text format uses ANSI color codes and box-drawing characters to render a readable terminal report: a score badge at the top, suggestions grouped by priority with color-coded severity indicators, and code blocks for the remediation examples. JSON format emits the raw AnalysisResult and Suggestion[] arrays as structured data suitable for piping to jq, storing in a database, or feeding into a CI quality gate. The formatter is selected by a --format flag at the CLI level and injected into the runner at startup.
The entry point creates a root Command, attaches global options, then registers subcommands. Each subcommand declares its own options and calls a handler that wires together the analysis and formatting layers. Commander’s .parseAsync() handles the async action handlers cleanly.
import { Command } from 'commander';
import { runAnalyze } from './commands/analyze.js';
import { runBatch } from './commands/batch.js';
const program = new Command();
program
.name('geo')
.description('Generative Engine Optimization analyzer')
.version('1.0.0')
.option('-f, --format <type>', 'output format: text or json', 'text')
.option('-v, --verbose', 'enable verbose diagnostic output', false);
program
.command('analyze <target>')
.description('analyze a single URL or local HTML file')
.option('--timeout <ms>', 'HTTP fetch timeout in milliseconds', '10000')
.option('--no-schema', 'skip schema.org validation pass')
.action(async (target, options, cmd) => {
const globalOpts = cmd.parent.opts();
await runAnalyze(target, { ...options, ...globalOpts });
});
program
.command('batch <listFile>')
.description('analyze all URLs listed in a file, one per line')
.option('-c, --concurrency <n>', 'max parallel requests', '5')
.option('-o, --output <file>', 'write report to file instead of stdout')
.action(async (listFile, options, cmd) => {
const globalOpts = cmd.parent.opts();
await runBatch(listFile, { ...options, ...globalOpts });
});
await program.parseAsync(process.argv);
The ContentAnalyzer composes independent analysis passes. Each pass receives the parsed DOM (via Cheerio) and the raw HTML string, then returns an array of typed issues. Passes run concurrently and their results merge into a single flat array. The score is derived from the weighted sum of issue severities.
import * as cheerio from 'cheerio';
import type { Issue, AnalysisResult } from './types.js';
import { analyzeStructure } from './passes/structure.js';
import { analyzeMetadata } from './passes/metadata.js';
import { analyzeSchema } from './passes/schema.js';
import { analyzeContent } from './passes/content.js';
export class ContentAnalyzer {
async analyze(html: string, url: string): Promise<AnalysisResult> {
const $ = cheerio.load(html);
const [structureIssues, metadataIssues, schemaIssues, contentIssues] =
await Promise.all([
analyzeStructure($, html),
analyzeMetadata($, url),
analyzeSchema($, html),
analyzeContent($, html),
]);
const issues: Issue[] = [
...structureIssues,
...metadataIssues,
...schemaIssues,
...contentIssues,
];
const score = computeScore(issues);
return { url, html, issues, score, analyzedAt: new Date().toISOString() };
}
}
function computeScore(issues: Issue[]): number {
const weights = { critical: 25, high: 10, medium: 4, low: 1 };
const penalty = issues.reduce((acc, i) => acc + (weights[i.severity] ?? 0), 0);
return Math.max(0, 100 - penalty);
}
The SuggestionEngine maps issue codes to suggestion templates and then enriches each template with context from the issue’s diagnostic data. Priority is a function of severity and category impact weights. Each suggestion carries a structured examples array so the report formatter can render before/after code blocks.
import type { AnalysisResult, Issue, Suggestion } from './types.js';
import { SUGGESTION_TEMPLATES } from './templates.js';
const CATEGORY_IMPACT: Record<string, number> = {
Schema: 1.4,
Metadata: 1.2,
Structure: 1.1,
Content: 1.0,
Performance: 0.8,
};
export class SuggestionEngine {
generate(result: AnalysisResult): Suggestion[] {
return result.issues
.map((issue) => this.#issueToSuggestion(issue))
.filter(Boolean)
.sort((a, b) => b.priorityScore - a.priorityScore) as Suggestion[];
}
#issueToSuggestion(issue: Issue): Suggestion | null {
const template = SUGGESTION_TEMPLATES[issue.code];
if (!template) return null;
const severityBase = { critical: 100, high: 75, medium: 40, low: 15 };
const impact = CATEGORY_IMPACT[template.category] ?? 1.0;
const priorityScore = (severityBase[issue.severity] ?? 0) * impact;
const priority =
priorityScore >= 100 ? 'critical'
: priorityScore >= 70 ? 'high'
: priorityScore >= 30 ? 'medium'
: 'low';
return {
issueCode: issue.code,
category: template.category,
priority,
priorityScore,
title: template.title,
explanation: template.explain(issue),
examples: template.examples(issue),
};
}
}
Batch mode reads a URL list from disk, processes pages with a bounded concurrency pool, aggregates per-page results, and computes a site-wide summary. Failed pages are captured as error entries rather than crashing the run, ensuring a single unreachable URL doesn’t invalidate the entire batch.
import { readFile } from 'node:fs/promises';
import pLimit from 'p-limit';
import { ContentAnalyzer } from '../analyzer.js';
import { SuggestionEngine } from '../suggestions.js';
import type { BatchResult, PageResult } from '../types.js';
export async function runBatch(
listFile: string,
opts: { concurrency: number; format: string; output?: string }
): Promise<void> {
const raw = await readFile(listFile, 'utf-8');
const urls = raw.split('\n').map((l) => l.trim()).filter(Boolean);
const limit = pLimit(opts.concurrency);
const analyzer = new ContentAnalyzer();
const engine = new SuggestionEngine();
const pageResults: PageResult[] = await Promise.all(
urls.map((url) =>
limit(async () => {
try {
const html = await fetchHtml(url);
const result = await analyzer.analyze(html, url);
const suggestions = engine.generate(result);
return { url, result, suggestions, error: null };
} catch (err) {
return { url, result: null, suggestions: [], error: String(err) };
}
})
)
);
const batchResult: BatchResult = {
totalPages: urls.length,
successCount: pageResults.filter((p) => !p.error).length,
errorCount: pageResults.filter((p) => p.error).length,
averageScore: average(pageResults.filter((p) => p.result).map((p) => p.result!.score)),
pages: pageResults,
};
const report = formatBatchReport(batchResult, opts.format);
await writeOutput(report, opts.output);
}
The report formatter is selected by the --format flag. Text format renders ANSI-styled output for terminals; JSON format emits a machine-readable object. Both formatters receive the same Suggestion[] input and produce a string, keeping formatting completely decoupled from analysis.
import chalk from 'chalk';
import type { Suggestion, AnalysisResult } from './types.js';
export function formatReport(
result: AnalysisResult,
suggestions: Suggestion[],
format: 'text' | 'json'
): string {
if (format === 'json') {
return JSON.stringify({ result, suggestions }, null, 2);
}
return formatText(result, suggestions);
}
function formatText(result: AnalysisResult, suggestions: Suggestion[]): string {
const scoreColor =
result.score >= 80 ? chalk.green
: result.score >= 50 ? chalk.yellow
: chalk.red;
const lines: string[] = [
'',
chalk.bold(`GEO Analysis: ${result.url}`),
`Score: ${scoreColor(String(result.score) + '/100')} Issues: ${result.issues.length}`,
'',
];
for (const s of suggestions) {
const priorityBadge =
s.priority === 'critical' ? chalk.bgRed.white(' CRITICAL ')
: s.priority === 'high' ? chalk.bgYellow.black(' HIGH ')
: s.priority === 'medium' ? chalk.bgBlue.white(' MEDIUM ')
: chalk.gray(' LOW ');
lines.push(`${priorityBadge} ${chalk.bold(s.title)} [${s.category}]`);
lines.push(` ${s.explanation}`);
for (const ex of s.examples) {
if (ex.before) lines.push(chalk.dim(' Before:'), chalk.red(' ' + ex.before));
if (ex.after) lines.push(chalk.dim(' After:'), chalk.green(' ' + ex.after));
}
lines.push('');
}
return lines.join('\n');
}
The GEO toolkit uses this CLI architecture to audit web pages for AI discoverability issues before content is published or after it changes. In a typical workflow, a developer runs geo analyze https://example.com/article locally while authoring content to get immediate feedback on missing schema markup, weak heading structure, or overly dense prose that LLMs struggle to parse. The text output gives a fast, color-coded summary; the JSON output can be piped to a custom script that opens relevant documentation links automatically.
In a CI context, the batch command processes a crawl-generated URL list after each deployment. The JSON output feeds a quality gate script that fails the pipeline if the site-wide score drops below a threshold or if any page introduces a new “critical” severity issue. Because the SuggestionEngine emits structured examples alongside each suggestion, a future integration can render those examples directly in a GitHub PR comment, pointing authors to the exact HTML or JSON-LD fragment they need to add or change.
The ContentAnalyzer’s pass-based design also makes it straightforward to extend. Adding a new GEO signal—say, checking for speakable schema properties or validating Q&A page markup—means writing a new pass function that returns Issue[] and registering it in the Promise.all array. No other code needs to change, and the new pass’s issues automatically flow through the SuggestionEngine if a corresponding template entry is added to SUGGESTION_TEMPLATES.
Rather than hardcoding the list of passes inside ContentAnalyzer, treat each pass as a plugin conforming to a shared interface—($ : CheerioAPI, html: string, url: string) => Promise<Issue[]>. The analyzer accepts an array of pass functions at construction time. This lets consuming code enable or disable passes per invocation (the --no-schema flag simply omits the schema pass from the array) and makes unit testing trivial: each pass is a pure function from DOM to issues.
When many issues share the same severity level, secondary sort keys matter for report readability. Sort suggestions first by priorityScore (descending), then by category impact weight (Schema first, Performance last), then alphabetically by issue code for a stable order across runs. Stable ordering ensures that CI diff comparisons don’t show spurious reorderings when no real changes occurred.
Treat the JSON output schema as a versioned contract, not an implementation detail. Define TypeScript interfaces for AnalysisResult, Issue, Suggestion, and BatchResult, and export them from the package. Downstream tools that consume the JSON can import the same types, giving end-to-end type safety. Bump a schemaVersion field in the output when the shape changes so consumers can detect breaking changes.
process.argv directly alongside Commander leads to conflicts where options appear parsed in one place but not the other, causing subtle bugs when users pass flags in different orders.await instead of Promise.all multiplies the per-page latency by the number of passes. On a batch of 100 pages, this difference can mean minutes versus seconds.p-limit, batching hundreds of URLs launches hundreds of concurrent HTTP requests simultaneously, exhausting system sockets, triggering rate limiting on the target server, and producing unreliable results from timeouts.example(issue) factory function should interpolate issue diagnostic data into the before/after snippets wherever possible.