A GEO audit asks one thing of a web page: can ChatGPT search, Google AI Overviews, Copilot and Perplexity reach it, understand it and quote it? Most of the answer needs a human. Whether the opening two sentences actually answer the question is a judgment call.
About a third of it can be checked by a script, though, and that third is where the silent failures live: a robots.txt group that catches Claude-SearchBot by accident, a firewall that answers OAI-SearchBot with a 403, FAQ answers that exist only inside JSON-LD.
We build LovedByAI, a generative engine optimization platform for small-business WordPress sites that runs most of these checks automatically. This post is the do-it-yourself version: one standard-library Python script that audits a single URL against the machine-checkable items of our 30-point GEO checklist. We ran it before publishing; the real output is below.
What can a GEO audit script check, and what can't it?
Here's what the script checks, with the checklist number each one maps to:
- AI search crawler access in robots.txt for OAI-SearchBot, Claude-SearchBot, PerplexityBot, Googlebot and Bingbot (check 1). The training crawlers, GPTBot, ClaudeBot and Google-Extended, are reported but never fail the audit: blocking them is a separate decision and doesn't remove a page from search answers.
- A status-code probe with each crawler's user-agent token, as a hint for firewall and CDN blocks (check 2).
- Server response time, flagged above one second (check 5).
- noindex, nosnippet and the canonical (checks 3 and 6).
- Exactly one H1, a title that agrees with it, and a meta description of 160 characters or less (checks 8 to 10).
- JSON-LD types, including an Organization or LocalBusiness node and its sameAs links (checks 26 and 27).
- FAQPage answers present in the HTML, not only in the schema (check 23).
- The URL listed in the XML sitemap, with a lastmod (check 7).
What it can't check is everything that needs reading: whether the page answers its question in the first two sentences, whether the headings are the questions customers ask, whether the proof is real. Those stay with a person.
How does robots.txt matching work for OAI-SearchBot and Claude-SearchBot?
RFC 9309 is short, and it's worth implementing properly. A crawler obeys the group that names its product token (case-insensitive), and falls back to the * group only if no group names it. Inside the group, the longest matching path wins, and on a tie, Allow beats Disallow. * matches anything and a trailing $ anchors the end.
We didn't use Python's built-in urllib.robotparser, because it reads rules differently. In Python 3.9 (the version we ran), it applies rules in file order and doesn't expand wildcards. Given the classic WordPress pair under User-agent: *, Disallow: /wp-admin/ followed by Allow: /wp-admin/admin-ajax.php, it disagrees with the longest-match rule:
>>> import urllib.robotparser
>>> rp = urllib.robotparser.RobotFileParser()
>>> rp.parse(open("robots.txt").read().splitlines())
>>> rp.can_fetch("OAI-SearchBot", "http://localhost:8765/wp-admin/admin-ajax.php")
False
>>> rp2 = urllib.robotparser.RobotFileParser()
>>> rp2.parse(["User-agent: *", "Disallow: /*.pdf$"])
>>> rp2.can_fetch("OAI-SearchBot", "http://localhost:8765/price-list.pdf")
True
The first result is wrong under longest-match: Allow: /wp-admin/admin-ajax.php is longer than Disallow: /wp-admin/, so the file allows it. The second ignores Disallow: /*.pdf$ entirely. The script's robots_verdict() gets both right, and it reports which line decided, so you can see why a crawler is blocked.
A mistake worth checking for is a pasted "block the AI bots" list that puts the search crawler in the same group as the training crawlers:
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: Claude-SearchBot
Disallow: /
That third line stops Claude-SearchBot from indexing the site for Claude's search, along with the training crawl. Anthropic's crawler documentation describes the three bots separately: ClaudeBot collects training data, Claude-SearchBot indexes for search, and Claude-User fetches pages when a person asks.
Why can a page return 200 to your browser and 403 to ChatGPT's crawler?
Because robots.txt isn't the only gate. Firewalls, CDNs and WordPress security plugins block crawlers by user-agent, and robots.txt never hears about it. In LovedByAI's study of 314 live websites in August 2026, 43% refused at least one AI crawler, and 112 of those refused it at the server while robots.txt said nothing.
So the script fetches the page once with a browser user-agent, then once per search crawler with Mozilla/5.0 (compatible; <Bot>/1.0), and warns when the status codes differ.
Read that probe as a hint, not proof. Real crawlers come from published IP ranges, and some firewalls check that, so a fake Googlebot from your laptop can get a 403 because the CDN is doing its job. Rules keyed on IP addresses don't show up in the probe at all. If the probe and the browser disagree for OAI-SearchBot or Claude-SearchBot, go and read the bot settings in your firewall or CDN; for the definitive answer, look for the crawler's requests and their status codes in your server logs.
How do you check noindex, nosnippet and the canonical?
The script reads <meta name="robots">, <meta name="googlebot"> and the X-Robots-Tag header, and fails on noindex, nosnippet, none or max-snippet:0. Google only uses pages that are indexed and eligible to show with a snippet for AI Overviews and AI Mode, and OpenAI uses noindex to keep pages out of ChatGPT search.
It also warns when the canonical points somewhere else. The canonical, the sitemap and the robots directives should all agree on which URL is the real one.
How do you check that the title, H1 and meta description agree?
Microsoft's guidance for Copilot and Bing asks for a title, H1 and description that agree. The script counts H1s (exactly one passes), then checks how many of the H1's significant words appear in the <title>, warning below half. It's a blunt measure on purpose: "Emergency plumber in Leeds, open 24/7" against "Emergency Plumber in Leeds, 24/7 | Joe's Plumbing" shares 5 of 6 words, while a homepage whose H1 is a slogan shares none.
How do you check FAQ answers are in the HTML, not only in the schema?
This check catches a failure that's easy to miss. Google's rule is that structured data has to match the visible text, and Microsoft notes that AI systems "may not render hidden content". An FAQ whose answers load by JavaScript when someone clicks a question will have complete FAQPage JSON-LD and an empty page.
The script walks every JSON-LD block (including @graph), collects each Question and its acceptedAnswer, normalizes the answer text and looks for its first 80 characters in the page's static text. Text inside a closed <details> element counts as present, because it is in the HTML. Text that arrives later doesn't.
How do you check the page is in your XML sitemap?
AI crawlers lean on sitemaps: across 82 million crawler requests on about 1,600 WordPress sites, they requested sitemap XML 177,353 times in May 2026 alone. The script reads the Sitemap: lines from robots.txt (or tries /sitemap.xml, /sitemap_index.xml and WordPress core's /wp-sitemap.xml), walks sitemap indexes up to 60 files, and checks the URL is listed with a lastmod date.
The GEO audit script
Standard library only, so it runs anywhere Python 3.9 or later does. Save it as geo_audit.py and run python3 geo_audit.py https://example.com/page/. It exits with code 1 if any check fails, so it can sit in CI or a cron job.
#!/usr/bin/env python3
"""geo_audit.py: check one URL against the machine-checkable items of an on-page GEO checklist.
Usage:
python3 geo_audit.py https://example.com/some-page/
Checks robots.txt rules for the AI search crawlers (and what a plain user-agent
probe gets back), noindex/nosnippet, canonical, title/H1 agreement, one H1,
meta description, JSON-LD types, whether FAQPage answers are really in the
HTML, and whether the URL is listed in the XML sitemap.
Standard library only. Python 3.9+. Exit code 1 if any check FAILs.
"""
import gzip
import json
import re
import sys
import time
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlsplit
from urllib.request import Request, urlopen
BROWSER_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/129.0 Safari/537.36")
# Crawlers that put a page into search answers. Blocking any of these is a FAIL.
SEARCH_BOTS = ["OAI-SearchBot", "Claude-SearchBot", "PerplexityBot", "Googlebot", "Bingbot"]
# Training crawlers: a separate decision, reported for information only.
TRAINING_BOTS = ["GPTBot", "ClaudeBot", "Google-Extended"]
ORG_TYPES = {"organization", "localbusiness", "corporation", "professionalservice",
"store", "onlinestore", "restaurant", "dentist", "physician", "plumber",
"electrician", "hvacbusiness", "roofingcontractor", "locksmith",
"legalservice", "attorney", "autorepair", "medicalbusiness",
"homeandconstructionbusiness", "generalcontractor", "realestateagent"}
STOPWORDS = set("a an and the of in on for to with by at from your our you we is are be "
"how what why when who which do does it its this that or vs".split())
results = [] # (status, section, message)
def report(status, section, message):
results.append((status, section, message))
def fetch(url, ua=BROWSER_UA, timeout=15):
"""Return (status, headers, body_text, seconds). Never raises on HTTP errors."""
start = time.time()
req = Request(url, headers={"User-Agent": ua, "Accept": "*/*"})
try:
with urlopen(req, timeout=timeout) as resp:
raw, status, headers = resp.read(), resp.status, resp.headers
except HTTPError as e:
raw, status, headers = e.read() or b"", e.code, e.headers
except URLError as e:
return None, {}, str(e.reason), time.time() - start
if raw[:2] == b"\x1f\x8b":
raw = gzip.decompress(raw)
charset = headers.get_content_charset() if hasattr(headers, "get_content_charset") else None
return status, headers, raw.decode(charset or "utf-8", errors="replace"), time.time() - start
# ---------------------------------------------------------------- robots.txt (RFC 9309)
def parse_robots(text):
"""Return (groups, sitemaps). A group is (list_of_agents, list_of_(allow, pattern))."""
groups, sitemaps = [], []
agents, rules, in_agent_lines = [], [], False
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if ":" not in line:
continue
key, value = (part.strip() for part in line.split(":", 1))
key = key.lower()
if key == "user-agent":
if not in_agent_lines and (agents or rules):
groups.append((agents, rules))
agents, rules = [], []
agents.append(value.lower())
in_agent_lines = True
elif key in ("allow", "disallow"):
if agents:
rules.append((key == "allow", value))
in_agent_lines = False
elif key == "sitemap":
sitemaps.append(value)
if agents:
groups.append((agents, rules))
return groups, sitemaps
def pattern_matches(pattern, path):
if not pattern:
return False # "Disallow:" with no value blocks nothing
regex = re.escape(pattern).replace(r"\*", ".*")
if regex.endswith(r"\$"):
regex = regex[:-2] + "$"
return re.match(regex, path) is not None
def robots_verdict(groups, bot, path):
"""Longest matching rule wins; on a tie, Allow wins. Returns (allowed, reason)."""
token = bot.lower()
chosen = [g for g in groups if token in g[0]]
agent_label = token
if not chosen:
chosen = [g for g in groups if "*" in g[0]]
agent_label = "*"
rules = [r for g in chosen for r in g[1]]
best = None
for allow, pattern in rules:
if pattern_matches(pattern, path):
if (best is None or len(pattern) > len(best[1])
or (len(pattern) == len(best[1]) and allow)):
best = (allow, pattern)
if best is None:
return True, f'no rule matches (group "{agent_label}")' if chosen else "no group applies"
verb = "Allow" if best[0] else "Disallow"
return best[0], f'"{verb}: {best[1]}" in group "{agent_label}"'
def check_crawlers(url):
parts = urlsplit(url)
robots_url = f"{parts.scheme}://{parts.netloc}/robots.txt"
path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
status, _, body, _ = fetch(robots_url)
sitemaps = []
if status == 200:
groups, sitemaps = parse_robots(body)
note = f"robots.txt {status}"
elif status is not None and 400 <= status < 500:
groups, note = [], f"robots.txt {status}: crawlers treat this as allow-all"
else:
got = "could not be fetched" if status is None else f"returned {status}"
report("FAIL", "Crawler access", f"robots.txt {got}: crawlers may treat the "
"whole site as blocked until it answers")
return []
report("INFO", "Crawler access", note)
browser_status = fetch(url)[0]
for bot in SEARCH_BOTS:
allowed, reason = robots_verdict(groups, bot, path)
probe = fetch(url, ua=f"Mozilla/5.0 (compatible; {bot}/1.0)")[0]
line = f"{bot:<17} {'allowed' if allowed else 'BLOCKED'}: {reason}; UA probe {probe}"
if not allowed:
report("FAIL", "Crawler access", line)
elif probe != browser_status:
report("WARN", "Crawler access", line + f" (a browser gets {browser_status}: "
"check firewall/CDN bot rules)")
else:
report("PASS", "Crawler access", line)
for bot in TRAINING_BOTS:
allowed, reason = robots_verdict(groups, bot, path)
report("INFO", "Crawler access", f"{bot:<17} {'allowed' if allowed else 'blocked'}: "
f"{reason} (training crawler, your call)")
return sitemaps
# ---------------------------------------------------------------- the page itself
class PageParser(HTMLParser):
SKIP = {"script", "style", "noscript", "template", "svg"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.title, self.h1s, self.metas, self.canonical = "", [], {}, None
self.jsonld, self.text, self.question_headings = [], [], 0
self._stack, self._buf, self._skip_depth, self._in_jsonld = [], None, 0, False
def handle_starttag(self, tag, attrs):
a = {k.lower(): (v or "") for k, v in attrs}
if tag == "meta" and a.get("name"):
self.metas.setdefault(a["name"].lower(), []).append(a.get("content", ""))
elif tag == "link" and "canonical" in a.get("rel", "").lower().split():
self.canonical = a.get("href")
elif tag == "script" and a.get("type", "").lower() == "application/ld+json":
self._in_jsonld, self._buf = True, []
if tag in ("title", "h1", "h2", "h3") and not self._skip_depth:
self._stack.append((tag, []))
if tag in self.SKIP:
self._skip_depth += 1
def handle_endtag(self, tag):
if tag == "script" and self._in_jsonld:
self.jsonld.append("".join(self._buf))
self._in_jsonld, self._buf = False, None
if tag in self.SKIP and self._skip_depth:
self._skip_depth -= 1
if self._stack and self._stack[-1][0] == tag:
_, chunks = self._stack.pop()
text = " ".join("".join(chunks).split())
if tag == "title":
self.title = self.title or text
elif tag == "h1":
self.h1s.append(text)
if tag in ("h2", "h3") and text.endswith("?"):
self.question_headings += 1
def handle_data(self, data):
if self._in_jsonld:
self._buf.append(data)
return
if self._skip_depth:
return
for _, chunks in self._stack:
chunks.append(data)
self.text.append(data)
def words(s):
return [w for w in re.findall(r"[a-z0-9]+", s.lower()) if w not in STOPWORDS]
def norm(s):
return " ".join(re.findall(r"[a-z0-9]+", re.sub(r"<[^>]+>", " ", s).lower()))
def walk_jsonld(node, found):
if isinstance(node, list):
for item in node:
walk_jsonld(item, found)
elif isinstance(node, dict):
types = node.get("@type", [])
for t in ([types] if isinstance(types, str) else types):
found.append((t, node))
for key, value in node.items():
if key != "@type":
walk_jsonld(value, found)
def check_page(url, status, headers, html):
p = PageParser()
p.feed(html)
page_text = norm(" ".join(p.text))
# Indexing and snippets
header_values = headers.get_all("X-Robots-Tag", []) if hasattr(headers, "get_all") else []
directives = " ".join(p.metas.get("robots", []) + p.metas.get("googlebot", []) +
header_values).lower()
bad = [d for d in ("noindex", "nosnippet", "none", "max-snippet:0") if d in directives]
if bad:
report("FAIL", "Indexing", f"found {', '.join(bad)} in meta robots / X-Robots-Tag")
else:
report("PASS", "Indexing", "no noindex or nosnippet in meta robots or X-Robots-Tag")
if p.canonical:
canon = urljoin(url, p.canonical)
if canon.rstrip("/") == url.rstrip("/"):
report("PASS", "Indexing", "canonical points to this URL")
else:
report("WARN", "Indexing", f"canonical points elsewhere: {canon}")
else:
report("WARN", "Indexing", "no canonical tag")
# Title, description, H1
if len(p.h1s) == 1:
report("PASS", "Title and H1", f'one H1: "{p.h1s[0][:70]}"')
else:
report("FAIL", "Title and H1", f"{len(p.h1s)} H1s found (want exactly one)")
title = p.title
report("PASS" if 30 <= len(title) <= 65 else "WARN", "Title and H1",
f'title ({len(title)} chars): "{title[:80]}"')
if p.h1s:
h1_words, title_words = set(words(p.h1s[0])), set(words(title))
shared = h1_words & title_words
share = len(shared) / max(1, len(h1_words))
report("PASS" if share >= 0.5 else "WARN", "Title and H1",
f"title and H1 share {len(shared)} of {len(h1_words)} H1 words")
desc = (p.metas.get("description") or [""])[0]
if not desc:
report("WARN", "Title and H1", "no meta description")
else:
report("PASS" if len(desc) <= 160 else "WARN", "Title and H1",
f"meta description {len(desc)} chars")
# Structured data
nodes, broken = [], 0
for block in p.jsonld:
try:
walk_jsonld(json.loads(block), nodes)
except json.JSONDecodeError:
broken += 1
if broken:
report("FAIL", "Structured data", f"{broken} JSON-LD block(s) do not parse")
types = sorted({t for t, _ in nodes})
report("PASS" if types else "WARN", "Structured data",
"JSON-LD types: " + (", ".join(types) if types else "none"))
orgs = [n for t, n in nodes if t.lower() in ORG_TYPES or t.lower().endswith("business")]
if orgs:
same_as = orgs[0].get("sameAs", [])
same_as = [same_as] if isinstance(same_as, str) else same_as
report("PASS" if same_as else "WARN", "Structured data",
f"organization node ({orgs[0].get('@type')}) with {len(same_as)} sameAs link(s)")
else:
report("WARN", "Structured data", "no Organization or LocalBusiness node")
# FAQ answers really in the HTML
faqs = []
for t, node in nodes:
if t == "Question":
answer = node.get("acceptedAnswer") or {}
answer = answer[0] if isinstance(answer, list) and answer else answer
faqs.append((node.get("name", ""), answer.get("text", "") if isinstance(answer, dict) else ""))
if faqs:
missing = [q for q, a in faqs if norm(a)[:80] not in page_text]
status_ = "PASS" if not missing else "WARN"
msg = f"{len(faqs) - len(missing)} of {len(faqs)} FAQPage answers are in the page HTML"
if missing:
msg += "; not in the HTML: " + "; ".join(f'"{q}"' for q in missing)
report(status_, "FAQ", msg)
else:
report("INFO", "FAQ", f"no FAQPage schema; {p.question_headings} question-shaped H2/H3s")
return page_text
# ---------------------------------------------------------------- sitemap
def check_sitemap(url, sitemaps):
parts = urlsplit(url)
root = f"{parts.scheme}://{parts.netloc}"
queue = sitemaps or [root + "/sitemap.xml", root + "/sitemap_index.xml", root + "/wp-sitemap.xml"]
target, seen = url.rstrip("/"), 0
while queue and seen < 60:
sm = queue.pop(0)
seen += 1
status, _, body, _ = fetch(sm)
if status != 200:
continue
try:
tree = ET.fromstring(body.encode("utf-8"))
except ET.ParseError:
continue
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
if tree.tag.endswith("sitemapindex"):
queue += [e.text.strip() for e in tree.findall("s:sitemap/s:loc", ns) if e.text]
continue
for u in tree.findall("s:url", ns):
loc = (u.findtext("s:loc", default="", namespaces=ns) or "").strip()
if loc.rstrip("/") == target:
lastmod = u.findtext("s:lastmod", default="", namespaces=ns)
if lastmod:
report("PASS", "Sitemap", f"listed in {sm} (lastmod {lastmod.strip()})")
else:
report("WARN", "Sitemap", f"listed in {sm} but without a lastmod date")
return
report("FAIL", "Sitemap", f"not found in {seen} sitemap file(s) checked")
def main():
if len(sys.argv) != 2:
sys.exit("usage: python3 geo_audit.py <url>")
url = sys.argv[1]
status, headers, html, secs = fetch(url)
print(f"GEO audit: {url}")
if status != 200:
print(f" page returned {status}: {html[:200]}")
sys.exit(1)
print(f" fetched {status} in {secs:.2f}s, {len(html) / 1024:.1f} KB of HTML")
report("PASS" if secs < 1 else "WARN", "Indexing", f"server answered in {secs:.2f}s")
sitemaps = check_crawlers(url)
check_page(url, status, headers, html)
check_sitemap(url, sitemaps)
for sec in ("Crawler access", "Indexing", "Title and H1", "Structured data", "FAQ", "Sitemap"):
print(f"\n{sec}")
for st, s, msg in results:
if s == sec:
print(f" {st:<4} {msg}")
counts = {s: sum(1 for r in results if r[0] == s) for s in ("PASS", "WARN", "FAIL")}
print(f"\n{counts['PASS']} pass, {counts['WARN']} warn, {counts['FAIL']} fail")
sys.exit(1 if counts["FAIL"] else 0)
if __name__ == "__main__":
main()
GEO audit output on a demo page and a live page
First, a demo. We built a page for a fictional business, Joe's Plumbing, served it locally with python3 -m http.server, and planted two mistakes: the robots.txt group above, which blocks Claude-SearchBot along with the training crawlers, and an FAQ answer ("Do you fix boilers on Sundays?") that exists only in the JSON-LD.
$ python3 geo_audit.py http://localhost:8765/emergency-plumber-leeds/
GEO audit: http://localhost:8765/emergency-plumber-leeds/
fetched 200 in 0.01s, 2.5 KB of HTML
Crawler access
INFO robots.txt 200
PASS OAI-SearchBot allowed: no rule matches (group "*"); UA probe 200
FAIL Claude-SearchBot BLOCKED: "Disallow: /" in group "claude-searchbot"; UA probe 200
PASS PerplexityBot allowed: no rule matches (group "*"); UA probe 200
PASS Googlebot allowed: no rule matches (group "*"); UA probe 200
PASS Bingbot allowed: no rule matches (group "*"); UA probe 200
INFO GPTBot blocked: "Disallow: /" in group "gptbot" (training crawler, your call)
INFO ClaudeBot blocked: "Disallow: /" in group "claudebot" (training crawler, your call)
INFO Google-Extended allowed: no rule matches (group "*") (training crawler, your call)
Indexing
PASS server answered in 0.01s
PASS no noindex or nosnippet in meta robots or X-Robots-Tag
PASS canonical points to this URL
Title and H1
PASS one H1: "Emergency plumber in Leeds, open 24/7"
PASS title (49 chars): "Emergency Plumber in Leeds, 24/7 | Joe's Plumbing"
PASS title and H1 share 5 of 6 H1 words
PASS meta description 145 chars
Structured data
PASS JSON-LD types: Answer, FAQPage, Plumber, PostalAddress, Question
PASS organization node (Plumber) with 2 sameAs link(s)
FAQ
WARN 2 of 3 FAQPage answers are in the page HTML; not in the HTML: "Do you fix boilers on Sundays?"
Sitemap
PASS listed in http://localhost:8765/sitemap.xml (lastmod 2026-10-14)
14 pass, 1 warn, 1 fail
Both planted mistakes show up, and the training-crawler blocks are reported without failing the audit. Note the UA probe 200 next to the blocked Claude-SearchBot: http.server ignores robots.txt, which is the point. robots.txt is a request to crawlers, and the probe shows what the server itself does.
Then we pointed it at the GEO checklist page itself, live on 20 October 2026:
$ python3 geo_audit.py https://www.lovedby.ai/blog/geo-checklist
GEO audit: https://www.lovedby.ai/blog/geo-checklist
fetched 200 in 0.19s, 182.8 KB of HTML
Crawler access
INFO robots.txt 200
PASS OAI-SearchBot allowed: "Allow: /" in group "oai-searchbot"; UA probe 200
PASS Claude-SearchBot allowed: "Allow: /" in group "*"; UA probe 200
PASS PerplexityBot allowed: "Allow: /" in group "perplexitybot"; UA probe 200
PASS Googlebot allowed: "Allow: /" in group "*"; UA probe 200
PASS Bingbot allowed: "Allow: /" in group "*"; UA probe 200
INFO GPTBot allowed: "Allow: /" in group "gptbot" (training crawler, your call)
INFO ClaudeBot allowed: "Allow: /" in group "claudebot" (training crawler, your call)
INFO Google-Extended allowed: "Allow: /" in group "google-extended" (training crawler, your call)
Indexing
PASS server answered in 0.19s
PASS no noindex or nosnippet in meta robots or X-Robots-Tag
PASS canonical points to this URL
Title and H1
PASS one H1: "GEO Checklist: 30 On-Page Checks for AI Search"
PASS title (58 chars): "GEO Checklist: 30 On-Page Checks for AI Search | LovedByAI"
PASS title and H1 share 7 of 7 H1 words
PASS meta description 152 chars
Structured data
PASS JSON-LD types: Answer, BlogPosting, Brand, BreadcrumbList, CollegeOrUniversity, ContactPoint, EntryPoint, FAQPage, ImageObject, ListItem, Organization, Person, Place, PostalAddress, QuantitativeValue, Question, SearchAction, WebPage, WebSite
PASS organization node (Organization) with 10 sameAs link(s)
FAQ
PASS 6 of 6 FAQPage answers are in the page HTML
Sitemap
PASS listed in https://www.lovedby.ai/sitemap/blog.xml (lastmod 2026-10-20T00:00:00.000Z)
16 pass, 0 warn, 0 fail
What can't a GEO audit script tell you?
The part that decides most citations. A script can confirm a page has one H1; it can't tell you whether the first two sentences name the business, the service and the place in the customer's own words. Wording matters here: in our test of 50 local questions on 13 October 2026, 117 of the 135 businesses ChatGPT recommended for a specific need were described with exactly that need.
It also can't see off-page signals. In the same test, 283 of the 284 local businesses ChatGPT recommended came with a listing card or a Google Maps link, so an up-to-date Google Business Profile matters as much as anything in the HTML.
[[VIDEO: planned youtube video | How to rank in ChatGPT: the human half of a GEO audit, the 14 parts of a small business page that a script can't judge, shown on one example page in under seven minutes.]]
And it can't tell you whether any of it worked. For that, measure AI referral visits per page: ChatGPT adds utm_source=chatgpt.com to its links, so Google Analytics 4 shows those visits by landing page, and Google counts AI Overviews and AI Mode traffic in Search Console's Performance report under the Web search type.
LovedByAI runs these checks across a whole WordPress site rather than one URL at a time, automates the on-page fixes (question headings, answer-first sections, FAQs with FAQPage schema, Organization schema), and reports AI mentions and AI referral traffic back. This script is the one-URL version you can read end to end.
For the business owner's side of this, without the code: SEO for AI search: what to change on a small business page, top to bottom.
The full 30-point checklist with sources: https://www.lovedby.ai/blog/geo-checklist
Top comments (1)
We must produce a short comment, one sentence or max two, casual, start with lowercase, specific reaction or question about this video. Something like "any chance it works on sites with Cloudflare protection?" or "did you test it on a non‑English page?" Keep it short, no punctuation errors. No quotes, no markdown. No URLs. Ensure no em-dash. Use straight quotes if any, but likely none. No double hyphen. Should be casual. Output only comment text.