Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

Search engines rank you in a list of links. Answer engines quote you inside a synthesized reply. Those are different games, and a page can win the first while losing the second: to be quoted, an answer engine has to fetch your page with its own crawler, read it without executing your JavaScript, and find a passage worth lifting. willaicite scores all three, one verdict per engine, and tells you exactly what to fix.

Point it at a URL and get a 0 to 100 score across eight weighted dimensions, with evidence on every line: the exact robots.txt rule that blocks a bot, the HTTP status a crawler actually receives, the sentence an engine could quote. There are no LLM calls, so the same input always produces the same score.
Point it at a URL and get a 0 to 100 score across nine weighted dimensions, with evidence on every line: the exact robots.txt rule that blocks a bot, the HTTP status a crawler actually receives, the sentence an engine could quote. There are no LLM calls, so the same input always produces the same score.

Try it now at **[willaicite.com/audit](https://willaicite.com/audit)**, or run it locally.

Expand All @@ -35,9 +35,9 @@ SEO tooling audits the path to a ranking. willaicite audits the path to a citati
- **A verdict per engine, weighed by what it costs you.** "Is Googlebot allowed" is one question. willaicite asks it for a roster of AI crawler tokens and separates retrieval crawlers, where a block means that engine can never cite you, from training-only tokens, where blocking is a legitimate policy choice.
- **Is there a liftable answer.** Engines quote a chunk verbatim. willaicite checks whether one exists: a self-contained answer near the top, headings phrased as the questions people actually ask, an FAQ ready to be lifted whole. You can rank first for a query and still contain nothing an engine can use.

## The eight dimensions
## The nine dimensions

Every audit scores the same eight dimensions, each weighted by how often it decides whether a citation happens. The overall score is the weighted average of the dimensions that could be verified. The weights follow the 2025–2026 replication literature, not the 2024 headline numbers: v1.3 added topical focus (high), promoted freshness to high, and demoted evidence density to medium.
Every audit scores the same nine dimensions, each weighted by how often it decides whether a citation happens. The overall score is the weighted average of the dimensions that could be verified. The weights follow the 2025–2026 replication literature, not the 2024 headline numbers: v1.3 added topical focus (high), promoted freshness to high, and demoted evidence density to medium. v1.4 added SEO foundation (low): the narrow slice of classic SEO hygiene that also decides which URL an engine attributes, kept at low weight because it is a hygiene multiplier, not a primary citation driver.

| # | Dimension | Weight | What it measures |
|---|---|---|---|
Expand All @@ -49,6 +49,7 @@ Every audit scores the same eight dimensions, each weighted by how often it deci
| 06 | **Structured data** | medium | JSON-LD (Article, FAQPage, Organization, Person) that helps Google AI Overviews and entity trust |
| 07 | **Freshness** | high | Visible and machine-readable dates; a recent timestamp is one of the few content factors that consistently lifted citation odds in controlled 2026 testing |
| 08 | **Entity & E-E-A-T** | medium | A nameable author, organization, and provenance an engine can attribute |
| 09 | **SEO foundation** | low | Canonical URLs that reference the page they are on, and titles/descriptions unique across pages, so citations and retrieval land on the right URL |

Also checked, informational and unscored: `llms.txt` presence, well-formedness, and consistency with robots.txt.

Expand Down
6 changes: 3 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>audit · willaicite</title>
<meta name="description" content="Run a free, deterministic willaicite audit: will AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Claude) retrieve and cite your page? Eight dimensions, exact evidence, no fabricated numbers.">
<meta name="description" content="Run a free, deterministic willaicite audit: will AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Claude) retrieve and cite your page? Nine dimensions, exact evidence, no fabricated numbers.">
<link rel="canonical" href="https://willaicite.com/audit">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<meta property="og:site_name" content="willaicite">
Expand Down Expand Up @@ -224,7 +224,7 @@

<section class="prompt">
<h1>Will AI engines <em>cite</em> this site?</h1>
<p class="sub">A scored audit of retrieval-and-citation readiness for ChatGPT, Perplexity, Google AI Overviews and Claude: eight dimensions, exact evidence, no fabricated numbers.</p>
<p class="sub">A scored audit of retrieval-and-citation readiness for ChatGPT, Perplexity, Google AI Overviews and Claude: nine dimensions, exact evidence, no fabricated numbers.</p>
<form class="run" id="runform">
<input id="url" type="text" placeholder="example.com/article" autocomplete="off" spellcheck="false" autofocus>
<button id="runbtn" type="submit">Audit</button>
Expand All @@ -249,7 +249,7 @@ <h1>Will AI engines <em>cite</em> this site?</h1>
</div>
</div>

<div class="report-rule"><span>Eight dimensions</span></div>
<div class="report-rule"><span>Nine dimensions</span></div>
<table class="index"><tbody id="dims"></tbody></table>

<div class="report-rule"><span>Fix first, by impact per effort</span></div>
Expand Down
5 changes: 3 additions & 2 deletions site/src/pages/about.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const dims = [
{ name: 'Structured data', weight: 2, asks: 'Does JSON-LD tell engines who wrote what, and when' },
{ name: 'Freshness', weight: 3, asks: 'Is there a machine-readable date signal from the last 90 days' },
{ name: 'Entity & E-E-A-T', weight: 2, asks: 'Can the author and organization behind the page be identified' },
{ name: 'SEO foundation', weight: 1, asks: 'Does the canonical reference the page it is on, and are titles and descriptions unique across pages' },
];

const faqs = [
Expand All @@ -23,7 +24,7 @@ const faqs = [
},
{
q: 'Why does the audit stop at ten requests?',
a: 'Courtesy to the site being audited. Ten requests are enough to score all eight dimensions (the page twice, robots.txt, the sitemap, an about page, the favicon and llms.txt), and a small fixed cap means an audit can never hammer the site it is inspecting. The audit honors robots.txt while it works.',
a: 'Courtesy to the site being audited. Ten requests are enough to score all nine dimensions (the page twice, robots.txt, the sitemap, an about page, the favicon and llms.txt), and a small fixed cap means an audit can never hammer the site it is inspecting. The audit honors robots.txt while it works.',
},
];

Expand Down Expand Up @@ -118,7 +119,7 @@ const schema = {
retrieved and cited by AI answer engines&rdquo;.
</p>

<div class="report-rule"><h2 class="rulelabel">What do the eight dimensions measure?</h2></div>
<div class="report-rule"><h2 class="rulelabel">What do the nine dimensions measure?</h2></div>
<table class="dims">
<thead><tr><th>dimension</th><th>weight</th><th>question it answers</th></tr></thead>
<tbody>
Expand Down
7 changes: 4 additions & 3 deletions site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const dimensions = [
{ name: 'Structured data', weight: 'medium', note: 'Schema.org markup that helps Google AI Overviews and entity trust.' },
{ name: 'Freshness', weight: 'high', note: 'Visible and machine-readable dates; a recent timestamp is one of the few factors that consistently lifted citation odds in controlled 2026 testing.' },
{ name: 'Entity & E-E-A-T', weight: 'medium', note: 'A nameable author, organization and provenance an engine can attribute.' },
{ name: 'SEO foundation', weight: 'low', note: 'Self-referencing canonicals and unique titles/descriptions, so citations land on the right URL.' },
];

// FAQ is single-sourced: this array renders the visible section AND the
Expand Down Expand Up @@ -265,11 +266,11 @@ const schema = {
</ol>
</section>

<!-- ================= eight dimensions ================= -->
<!-- ================= nine dimensions ================= -->
<section aria-labelledby="dims-h">
<div class="report-rule"><h2 class="rulelabel" id="dims-h">The eight dimensions</h2></div>
<div class="report-rule"><h2 class="rulelabel" id="dims-h">The nine dimensions</h2></div>
<p class="section-intro">
Every audit scores the same eight dimensions, weighted by how often each one decides
Every audit scores the same nine dimensions, weighted by how often each one decides
whether a citation happens. The weights follow the 2025&ndash;2026 replication
literature, not the 2024 headline numbers.
</p>
Expand Down
4 changes: 3 additions & 1 deletion src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { checkTopicalFocus } from './checks/topicalFocus.js';
import { checkEvidenceDensity } from './checks/evidenceDensity.js';
import { checkFreshness } from './checks/freshness.js';
import { checkEntityEeat } from './checks/entityEeat.js';
import { checkSeoFoundation } from './checks/seoFoundation.js';
import { checkLlmsTxt } from './checks/llmsTxt.js';
import { overallScore, verdictFor, prioritize } from './score.js';

export const VERSION = '1.3.0';
export const VERSION = '1.4.0';
const MAX_PAGES = 10;
const OWN_BOT_TOKEN = 'geo-audit';

Expand Down Expand Up @@ -188,6 +189,7 @@ export function buildResult(inputUrl: string, ctx: AuditContext, now: Date = new
['evidenceDensity', () => checkEvidenceDensity(ctx)],
['freshness', () => checkFreshness(ctx, now)],
['entityEeat', () => checkEntityEeat(ctx)],
['seoFoundation', () => checkSeoFoundation(ctx)],
];

const dimensions: DimensionResult[] = checks.map(([key, run]) => {
Expand Down
19 changes: 19 additions & 0 deletions src/checks/answerReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,25 @@ export function checkAnswerReadiness(ctx: AuditContext): DimensionResult {
evidence.push({ status: 'warn', message: `${h1s.length} H1 headings found; topic signal is diluted` });
}

// 2b. Heading hierarchy (advisory, unscored): a skipped level (H2 → H4)
// breaks the outline chunkers use to attach body text to its section.
const skips: string[] = [];
for (let i = 1; i < headings.length; i++) {
if (headings[i].level > headings[i - 1].level + 1) {
skips.push(`H${headings[i - 1].level} → H${headings[i].level} at "${headings[i].text.slice(0, 50)}"`);
}
}
if (skips.length > 0) {
evidence.push({ status: 'warn', message: `heading hierarchy skips levels (${skips.slice(0, 3).join('; ')})` });
recommendations.push({
dimension: dim,
action: 'Fix skipped heading levels so the outline nests without gaps (H2 under H1, H3 under H2)',
why: 'Chunkers and outline parsers use heading levels to decide which section a passage belongs to; a skipped level detaches the section from its parent topic and weakens the retrieved chunk\'s context.',
impact: 1,
effort: 1,
});
}

// 3. Question-formatted headings (0-25)
const subHeadings = headings.filter((h) => h.level >= 2);
const questionHeadings = subHeadings.filter((h) => QUESTION_START.test(h.text) || h.text.trim().endsWith('?'));
Expand Down
152 changes: 152 additions & 0 deletions src/checks/seoFoundation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { AuditContext, PageData } from '../context.js';
import type { DimensionResult, Evidence, Recommendation } from '../types.js';
import { extractCanonical, extractTitle, findMeta } from '../html.js';

/**
* SEO foundation: the small subset of classic SEO hygiene that also decides
* which URL an AI engine attributes and cites. Deliberately narrow — presence
* of title/description/canonical is already scored in topical focus; this
* dimension checks the cross-cutting failure modes those single-page checks
* cannot see: a canonical that points AWAY from the audited URL (the citation
* lands on a different address), and titles/descriptions duplicated across
* pages (retrieval cannot tell the pages apart). Low weight: these are
* hygiene multipliers, not primary citation drivers in the 2025-2026
* literature.
*/

/** Comparable URL identity: scheme-insensitive, www-insensitive, trailing-slash-insensitive. */
function urlKey(url: string, base?: string): string | null {
try {
const u = base ? new URL(url, base) : new URL(url);
return u.hostname.replace(/^www\./, '') + (u.pathname.replace(/\/+$/, '') || '/') + u.search;
} catch {
return null;
}
}

export function checkSeoFoundation(ctx: AuditContext): DimensionResult {
const dim = 'SEO foundation';
const pages: PageData[] = [ctx.target, ctx.homepage, ctx.aboutPage, ...ctx.extraPages].filter(
(p): p is PageData => Boolean(p?.html),
);

if (pages.length === 0) {
return {
key: 'seoFoundation',
name: dim,
weight: 1,
score: null,
evidence: [{ status: 'unverified', message: 'could not verify: no page HTML available' }],
recommendations: [],
};
}

const evidence: Evidence[] = [];
const recommendations: Recommendation[] = [];
// Each assessable part contributes 0-100; the score renormalizes over the
// parts that could actually be checked (matches the overall-score policy).
const parts: number[] = [];

// 1. Canonical self-consistency on the audited page. Presence alone is
// scored in topical focus; here the href must resolve back to the page
// itself, else every consumer that honors canonicals attributes the content
// to a different URL than the one audited.
if (ctx.target?.html) {
const pageUrl = ctx.target.fetch.finalUrl ?? ctx.target.url;
const canonical = extractCanonical(ctx.target.html);
if (canonical === null) {
parts.push(30);
evidence.push({ status: 'warn', message: 'no canonical link on the audited page; parameter/www/slash variants can split its retrieval identity' });
recommendations.push({
dimension: dim,
action: 'Add a self-referencing <link rel="canonical"> to the audited page',
why: 'Engines and crawl pipelines consolidate duplicate URL variants onto the canonical; without one, citations and link equity can scatter across variants of the same page.',
impact: 1,
effort: 1,
});
} else {
const canonKey = urlKey(canonical, pageUrl);
const pageKey = urlKey(pageUrl);
if (canonKey !== null && pageKey !== null && canonKey === pageKey) {
parts.push(100);
evidence.push({ status: 'pass', message: `canonical is self-referencing (${canonical})` });
} else {
parts.push(0);
evidence.push({
status: 'fail',
message: `canonical points away from the audited page: canonical "${canonical}" vs page "${pageUrl}"`,
});
recommendations.push({
dimension: dim,
action: 'Fix the canonical URL so it references the page it is on (or audit the canonical target instead)',
why: 'A canonical pointing elsewhere tells every consumer that honors it — including AI crawl pipelines — that this page is a duplicate: the cited/indexed URL becomes the canonical target, not this page.',
impact: 2,
effort: 1,
});
}
}
} else {
evidence.push({ status: 'unverified', message: 'canonical consistency not assessable: audited page HTML unavailable' });
}

// 2 & 3. Title / meta-description uniqueness across the audited pages.
// Duplicated metadata makes distinct pages indistinguishable to a retriever
// matching a query against titles and snippets.
const assessUniqueness = (
label: 'title' | 'meta description',
valueOf: (html: string) => string | null,
action: string,
why: string,
) => {
const withValue = pages
.map((p) => ({ url: p.url, value: valueOf(p.html!)?.trim().toLowerCase() ?? null }))
.filter((p): p is { url: string; value: string } => Boolean(p.value));
if (withValue.length < 2) {
evidence.push({ status: 'info', message: `${label} uniqueness not assessable (fewer than 2 audited pages carry a ${label})` });
return;
}
const byValue = new Map<string, string[]>();
for (const p of withValue) byValue.set(p.value, [...(byValue.get(p.value) ?? []), p.url]);
const dupes = [...byValue.values()].filter((urls) => urls.length > 1);
if (dupes.length === 0) {
parts.push(100);
evidence.push({ status: 'pass', message: `${label}s are unique across the ${withValue.length} audited pages that have one` });
} else {
parts.push(0);
const example = dupes[0];
evidence.push({
status: 'fail',
message: `duplicated ${label} across pages (e.g. ${example.slice(0, 3).join(' and ')})`,
});
recommendations.push({ dimension: dim, action, why, impact: 2, effort: 1 });
}
};

assessUniqueness(
'title',
extractTitle,
'Give every page a unique <title> naming that page\'s specific topic',
'The title is the primary surface a retriever matches a query against; pages sharing one title compete as duplicates instead of each winning its own queries.',
);
assessUniqueness(
'meta description',
(html) => findMeta(html, 'description'),
'Write a distinct meta description per page',
'Engines use the description as a snippet/retrieval signal; identical descriptions across pages erase the differences a retriever could use to pick the right page.',
);

evidence.push({
status: 'info',
message: `scope note: only the ${pages.length} fetched page(s) were compared; presence/length of title, description and canonical are scored under topical focus & metadata`,
});

const score = parts.length > 0 ? Math.round(parts.reduce((a, b) => a + b, 0) / parts.length) : null;
return {
key: 'seoFoundation',
name: dim,
weight: 1,
score,
evidence,
recommendations,
};
}
13 changes: 13 additions & 0 deletions src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ export function jsonLdTypes(nodes: Record<string, unknown>[]): string[] {
return types;
}

/** href of the first `<link rel="canonical">`, or null. */
export function extractCanonical(html: string): string | null {
const tags = html.match(/<link\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const rel = getAttr(tag, 'rel');
if (rel && /(^|\s)canonical(\s|$)/i.test(rel)) {
const href = getAttr(tag, 'href');
if (href) return href;
}
}
return null;
}

export interface LinkTag {
href: string;
text: string;
Expand Down
Loading
Loading