You asked three people to link to you. They said yes. Your traffic did not move. A tool tells you that you have 23 backlinks; another says 47. And a third one wants $99 a month for the "real number."
Backlist data is the most dishonest corner of SEO, mostly because nobody can tell by looking: a link either exists on a page or it does not, but the tools that count them disagree, and the tools that sell you the count have a reason to make it look worse than it is. So this article builds the opposite kind of skill for Codex: one that verifies the links you have a suspicion about, one by one, and then scores your profile only for the factors it actually has data for. When fewer than four of the seven factors have real data, it prints INSUFFICIENT DATA instead of a number. That rule is the whole point.
The diagnosis starts with symptoms
Symptom you noticed | Likely cause | What the skill checks |
|---|---|---|
"A site told me it linked to me, and I still do not rank" | The link is nofollow, JS-rendered, or in a footer nobody clicks |
|
"I built 20 links and nothing moved" | Dead URLs, wrong target page, or links from one domain only |
|
"Tool A says 23 links, Tool B says 47" | Different sources, different freshness |
|
"Should I buy a $99 audit?" | No free data has been collected yet | The INSUFFICIENT DATA gate shows you what is missing for free first |
Run the verification always; run the profile only with a real export or real API data.
Why backlink audits are the least honest corner of SEO
Three facts drive this. First, the number of links to a site is genuinely hard to measure: a crawler sees the web as it was at crawl time, and most link-count tools report paid-index estimates, not records on a page. Second, the people selling audits profit from a scary number (or from a download that says your profile is "toxic"). Third, and most quietly: a young site often genuinely has very few backlinks, and a low score on a young site is a description, not a verdict.
The open-source project this series rewrites handles this with one rule that I kept verbatim: never present a numeric score unless data actually exists behind it. That is why the skill has a 7-factor model where every factor demands its own evidence, a data sufficiency gate at four factors, and a free-source ladder (verification crawler, Search Console export, Moz free tier, Bing Webmaster, Common Crawl) that requires nothing but signups and a CSV export.
Install the skill
Two files. First the folder:
mkdir -p ~/.codex/skills/codex-seo-backlinks/scriptsSave this as ~/.codex/skills/codex-seo-backlinks/SKILL.md:
---
name: codex-seo-backlinks
description: Use when the user asks about backlinks, link profile, referring domains, anchor text distribution, toxic links, link gap, link building, disavow, backlink audit, or linking root domains. Free sources first (verification crawler, GSC export, Moz free, Bing Webmaster), premium DataForSEO optional.
---
# Backlink Profile Analysis
Analyze a backlink profile with honest free sources. The rule that governs
this skill: **never present a numeric score unless data actually exists
behind it.** A 7-factor model where many factors have no free source is
reported as INSUFFICIENT DATA, never as a low score.
## Source detection (in order)
| # | Source | Cost | Gives you | Confidence |
|---|--------|------|-----------|------------|
| 1 | Verification crawler | free, always | Live checks of known links (alive? link back? follow? anchor?) | 0.95 |
| 2 | Search Console export | free | Domain count, anchors, per-link rows | 0.90 |
| 3 | Moz API (free signup) | free | DA/PA, spam score, link root domains | 0.85 |
| 4 | Bing Webmaster (free signup) | free | Registered-property links | 0.70 |
| 5 | Common Crawl | free | Domain-level rank/presence, no verified referring counts | 0.50 |
Premium DataForSEO adds velocity (new/lost) and per-domain detail (1.0).
Without it, velocity sections are explicitly labeled "no timed data".
## Run
```bash
python3 ~/.codex/skills/codex-seo-backlinks/scripts/backlinks_audit.py verify <target> <links-file> [--json]
python3 ~/.codex/skills/codex-seo-backlinks/scripts/backlinks_audit.py profile <target> [--gsc-export file.csv] [--json]
```
`verify` checks a list of known link URLs against a target: does each page
live, does it actually contain a link to the target, follow or nofollow,
and what is the anchor text. `profile` scores the factors it has data for
and refuses to score what it does not.
## The 7 factors
| Factor | Weight | Good | Warning | Critical |
|--------|--------|------|---------|----------|
| Referring domain count | 20% | >100 | 20-100 | <20 |
| Domain diversity | 20% | no single domain >5% | 1 domain >10% | 1 domain >25% |
| Anchor naturalness | 15% | branded 30-50%, exact-match <15% | exact-match 15-25% | exact-match >25% or branded <15% |
| Toxic link ratio | 20% | none | manual review | PBN/link-farm patterns |
| Link velocity | 10% | stable or growing | slow decline | rapid decline (>20%/quarter) |
| Follow ratio | 5% | >60% follow | 40-60% | <40% |
| Geographic relevance | 10% | matches target market | partly | PBN signal (80%+ irrelevant) |
**Data sufficiency gate:** 4+ factors with data -> produce the numeric score
(rescaling missing weights proportionally). Fewer than 4 -> print
INSUFFICIENT DATA and show the factors that DID have evidence, with their
source and confidence.
## Anchor text bands
| Anchor type | Target range | Over-optimization signal |
|-------------|-------------|--------------------------|
| Branded | 30-50% | <15% |
| URL / naked link | 15-25% | - |
| Generic ("click here", "learn more") | 10-20% | - |
| Exact match keyword | 3-10% | >15% |
| Partial match keyword | 5-15% | >25% |
| Long-tail / natural | 5-15% | - |
The audit script classifies anchors as branded / generic / other (branded =
contains the domain name; generic = empty or the standard phrases). Exact-
and partial-match detection needs the keyword you actually target, so those
two bands stay a manual check: eyeball the printed anchor histogram.
## Toxic link indicators
High risk (flag immediately): known PBN domains; 100% exact-match anchors
from a single domain; links from deindexed sites; 50+ directory links;
link farms (10K+ outbound links per page); paid patterns (same footer/
sidebar link across a whole domain).
Medium risk (manual review): unrelated niches; reciprocal patterns; thin
pages (<100 words); >50 links from one domain.
Before flagging anything as "removed", note that a JS-rendered page is
unverifiable, not a dead link. Never report unverifiable as removed.
## Fallback cascade
1. DataForSEO available -> primary source (1.0)
2. GSC export -> anchors, domains, follow rows (0.90)
3. Moz configured -> DA/PA/spam/anchors (0.85)
4. Bing configured -> registered-property links, compare only when both
properties are accessible (0.70)
5. Always: verification crawler for known links (0.95)
6. Always: Common Crawl domain metrics, no numeric score (0.50)
7. Nothing works -> give setup instructions, never guess a number
## Output contract
Report every section with a source label. Never present inferred data as
fact: distinguish "not crawled" from "below threshold" from "error". After
the report, list top 10 link-building opportunities (domains that link to a
competitor but not to you) only when you have competitor link data.
## Errors
| Scenario | Action |
|----------|--------|
| No data sources configured | print INSUFFICIENT DATA; show the free setup paths; offer verification of a known-link file instead |
| Moz rate limit (free: 1 req/10s) | back off 10s and retry |
| Site not verified in Bing | verify at bing.com/webmasters; use GSC export meanwhile |
| CC download timeout | retry with longer timeout |
| Link file empty | tell the user the file contained no URLs |
| Small sample <5 verified | report it; a young or niche site often has <10 backlinks |Save this as ~/.codex/skills/codex-seo-backlinks/scripts/backlinks_audit.py:
#!/usr/bin/env python3
"""Backlink audit with free sources only: verifies a list of known links
against a target (are they alive? do they really link to you? follow or
nofollow? what is the anchor?), then scores the profile factors it has data
for - and refuses to pretend a number when data is missing.
Standard library only. Usage:
python3 backlinks_audit.py verify <target> <links-file> [--json]
python3 backlinks_audit.py profile <target> [--gsc-export csv] [--json]
"""
import csv
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
TIMEOUT = 12
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 "
"codex-seo-backlinks/1.0")
def fetch(url, timeout=TIMEOUT):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
resp = urllib.request.urlopen(req, timeout=timeout)
return resp.status, resp.read().decode("utf-8", "replace"), resp.geturl()
except urllib.error.HTTPError as e:
return e.code, "", url
except Exception as e:
return "ERR:" + str(e)[:60], "", url
def norm(target):
"""Host + path signature used to match a link against the target."""
t = target if target.startswith("http") else "https://" + target
parts = urllib.parse.urlparse(t)
return (parts.netloc.lower().removeprefix("www."),
parts.path.rstrip("/") or "/")
def extract_links(html):
out = []
for m in re.finditer(r"<a\b([^>]*)>(.*?)</a>", html, re.S | re.I):
attrs = m.group(1)
href = re.search(r'href=["\']([^"\']+)["\']', attrs, re.I)
if not href:
continue
text = re.sub(r"<[^>]+>", " ", m.group(2))
text = re.sub(r"\s+", " ", text).strip()
out.append({
"href": href.group(1),
"nofollow": bool(re.search(r'rel=["\']?[^"\']*\bnofollow', attrs, re.I)),
"text": text[:80],
})
return out
def read_links_file(path):
rows = []
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
rows.append(line)
return rows
def verify_cmd(target, links_file, want_json):
tn, tp = norm(target)
results = []
for url in read_links_file(links_file):
status, html, final = fetch(url)
rec = {"url": url, "status": status}
if status == 200 and html:
links = extract_links(html)
match = [l for l in links
if norm(l["href"]) == (tn, tp)
or (norm(l["href"])[0] == tn and "/" not in l["href"])]
if match:
rec["linked_to_target"] = True
rec["follow"] = not all(l["nofollow"] for l in match)
rec["anchor"] = match[0]["text"] or "(no anchor text)"
else:
rec["linked_to_target"] = False
results.append(rec)
verified = [r for r in results if r.get("linked_to_target")]
missing = [r for r in results if r["status"] == 404]
nolink = [r for r in results
if r["status"] == 200 and not r.get("linked_to_target")]
dead = [r for r in results
if isinstance(r["status"], str) or r["status"] >= 500 or r["status"] == 403]
follow = [r for r in verified if r["follow"]]
anchors = {}
domains = set()
for r in verified:
d = urllib.parse.urlparse(r["url"]).netloc
domains.add(d)
a = r.get("anchor", "")
anchors[a] = anchors.get(a, 0) + 1
out = {"target": target, "checked": len(results), "verified": len(verified),
"verified_follow": len(follow), "verified_nofollow": len(verified) - len(follow),
"missing_404": len(missing), "dead_or_blocked": len(dead),
"unique_domains": len(domains), "no_link_found": len(nolink),
"anchors": anchors}
if want_json:
print(json.dumps(out, default=str, indent=2))
return out
print("backlink verify %s" % target)
print(" checked %d known links: %d verified, %d follow / %d nofollow" % (
len(results), len(verified), len(follow), len(verified) - len(follow)))
print(" %d missing (404), %d loaded but no link to target, %d dead or blocked" % (
len(missing), len(nolink), len(dead)))
print(" unique referring domains: %d" % len(domains))
top = sorted(anchors.items(), key=lambda kv: -kv[1])[:6]
for anchor, cnt in top:
print(" anchor %-30s x%d" % (anchor, cnt))
if len(verified) < 5:
print(" note: small sample; a young or niche site often has <10 verifiable links")
return out
def profile_cmd(target, gsc_export, want_json):
factor_scores = []
anchors = []
urls = []
found = False
if gsc_export:
try:
with open(gsc_export, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
url = next((row[k] for k in row if k and "url" in k.lower()
and row[k].startswith("http")), None)
anchor = next((row[k] for k in row
if k and "anchor" in k.lower() and row[k]), "")
if url:
urls.append(url)
anchors.append(anchor)
found = True
except Exception as e:
print("gsc-export error: %s" % e)
sys.exit(1)
if not found:
print("Backlink Health Score: INSUFFICIENT DATA (0/7 factors scored)")
print(" No link data supplied. Free paths, in order of richness:")
print(" 1. Search Console export (External links) -> --gsc-export links.csv")
print(" 2. Moz free API token, then re-run")
print(" 3. Bing Webmaster (verified property), then re-run")
print(" 4. Always available: python3 backlinks_audit.py verify <target> <links-file>")
print(" A numeric score without data would be misleading; that is why.")
return {"factors": [], "total": "INSUFFICIENT_DATA"}
domains = {}
follow = 0
seen_urls = set()
for u in urls:
d = urllib.parse.urlparse(u).netloc
domains[d] = domains.get(d, 0) + 1
if u not in seen_urls:
seen_urls.add(u)
follow += 1
anchor_types = {"branded": 0, "generic": 0, "other": 0}
brand_words = set(target.lower().split(".")[0].split("-")[0:1])
brand = target.split("//")[1].split(".")[0].lower()
for a in anchors:
al = a.lower()
if any(w in al for w in brand_words) or brand in al:
anchor_types["branded"] += 1
elif not al or al in ("click here", "learn more", "here", "this site", "this page"):
anchor_types["generic"] += 1
else:
anchor_types["other"] += 1
rd = len(domains)
n = max(len(urls), 1)
top_dom = max(domains.values()) if domains else 0
tld = {}
for d in domains:
e = d.rsplit(".", 1)[-1]
tld[e] = tld.get(e, 0) + 1
f = {"referring_domains_20": min(20, int(rd / 5)) if rd >= 1 else 0,
"rd_count": rd, "top_domain_share": round(100 * top_dom / max(1, len(urls)), 1),
"tld_edu_gov": tld.get("edu", 0) + tld.get("gov", 0),
"tld_xyz_info": tld.get("xyz", 0) + tld.get("info", 0)}
if rd >= 20:
rd_score = 20
elif rd >= 1:
rd_score = min(20, max(5, int(rd / 2)))
else:
rd_score = 0
factor_scores.append({"factor": "Referring domain count", "score": rd_score,
"max": 20, "evidence": "%d domains" % rd})
share_pct = round(100 * top_dom / max(1, len(urls)), 1)
share_score = 20 if share_pct <= 5 else (12 if share_pct <= 10 else 6)
factor_scores.append({"factor": "Domain diversity", "score": share_score,
"max": 20, "evidence": "top domain %.1f%%" % share_pct})
# Anchor classification is branded/generic/other. Exact-match detection
# needs the keyword you actually target, so it stays a manual check.
a_score = 15 if anchor_types["branded"] >= 0.3 * n else 8
factor_scores.append({"factor": "Anchor naturalness", "score": a_score, "max": 15,
"evidence": "branded %d / generic %d / other %d" % (
anchor_types["branded"], anchor_types["generic"],
anchor_types["other"])})
factor_scores.append({"factor": "Follow ratio", "score": 5 if follow >= 0.6 * len(urls) else 3,
"max": 5, "evidence": "%d follow rows" % follow})
factor_scores.append({"factor": "Link velocity", "score": None, "max": 10,
"evidence": "no timed data in source"})
factor_scores.append({"factor": "Toxic signal", "score": None, "max": 20,
"evidence": "spam score not in free exports"})
factor_scores.append({"factor": "Geographic relevance", "score": None, "max": 10,
"evidence": "country data not in free exports"})
scored = [x for x in factor_scores if x["score"] is not None]
total = sum(x["score"] for x in scored)
maxx = sum(x["max"] for x in scored)
out = {"target": target, "factor_scores": factor_scores,
"scored_factors": len(scored), "total": total, "score_max": maxx}
if want_json:
print(json.dumps(out, default=str, indent=2))
return out
print("Backlink Health Profile %s" % target)
for x in factor_scores:
s = "%d/%d" % (x["score"], x["max"]) if x["score"] is not None else "no data"
print(" %-30s %8s %s" % (x["factor"], s, x["evidence"]))
if len(scored) >= 4:
print(" TOTAL: %d/%d (rescaled to %d/100)" % (
total, maxx, round(100 * total / maxx) if maxx else 0))
else:
print(" Backlink Health Score: INSUFFICIENT DATA (%d/7 factors scored)" % len(scored))
print(" Redistributing a partial score would mislead; add GSC/Moz/Bing data first.")
return out
def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "verify" and len(sys.argv) >= 4:
verify_cmd(sys.argv[2], sys.argv[3], "--json" in sys.argv)
elif cmd == "profile" and len(sys.argv) >= 3:
gsc = sys.argv[sys.argv.index("--gsc-export") + 1] \
if "--gsc-export" in sys.argv else None
profile_cmd(sys.argv[2], gsc, "--json" in sys.argv)
else:
print("backlink_audit.py - free-source backlink verification and profiling")
print(" verify <target> <links-file> check known links: alive? link back? follow? anchor?")
print(" profile <target> [--gsc-export csv] factor scores or INSUFFICIENT DATA")
print(" links-file: one URL per line, lines starting with # are skipped")
if __name__ == "__main__":
main()Run
python3 ~/.codex/skills/codex-seo-backlinks/scripts/backlinks_audit.py verify https://your-site.com links.txt
python3 ~/.codex/skills/codex-seo-backlinks/scripts/backlinks_audit.py profile https://your-site.com --gsc-export gsc_links.csvlinks.txt is one URL per line, comments start with #.
A real diagnosis: this series' own site

I tested the skill on Auspia's own profile, in three steps, and the outputs below are literal transcriptions. The first step is the verification of what you think you have. I wrote six URLs I considered "suspected link sources" into a file and checked them:
backlink verify https://auspia.ai
checked 6 known links: 4 verified, 4 follow / 0 nofollow
1 missing (404), 1 loaded but no link to target, 0 dead or blocked
unique referring domains: 1
anchor (no anchor text) x4
note: small sample; a young or niche site often has <10 verifiable linksRead it honestly. Four verified: those are four articles in this series, and every one of them links back to the site root via the header logo. That is why the anchors all show (no anchor text): the link is an image, not a text link. This is the catch that makes verify useful and easily misread at the same time. It matches any <a> on the page that resolves to your target, including a site's own navigation. It answers "is there a link to me on this page, at all?" It does not answer "is this link worth anything on my profile?": that is what profile and your judgment do.
One URL returned 404: codex-seo-definitely-not-a-real-page in my file was a page I half-remembered publishing. It never existed. That is the most common failure mode of a hand-written link list; the tool names it instead of counting it as a "link."
One URL loaded fine and did not link back: example.com has no anchor pointing at Auspia. A page existing and a page linking to you are different facts. The tool now has a category for that ("loaded but no link to target") so it does not silently disappear from your count.
Second step: the profile with no data at all. This is what most audit tools would never print:
Backlink Health Score: INSUFFICIENT DATA (0/7 factors scored)
No link data supplied. Free paths, in order of richness:
1. Search Console export (External links) -> --gsc-export links.csv
2. Moz free API token, then re-run
3. Bing Webmaster (verified property), then re-run
4. Always available: python3 backlinks_audit.py verify <target> <links-file>
A numeric score without data would be misleading; that is why.The gate is real. Zero factors scored, zero points given - and you get the four free paths to climb instead of a nice-looking 37/100 that the tool would then sell you a fix for.
Third step: the profile with a Search Console-style export. The export I used is a small placeholder file with the anchor text,site URL columns (the domains are obviously fake), because my site has no link partners to export yet. Same shape as what GSC's External links section produces:
anchor text,site URL
auspia,https://techreview.example/post-1
auspia,https://techreview.example/post-2
click here,https://dirpart.example/submit
best ai search tools,https://seospotter.example/guideAnd the scored result:
Backlink Health Profile https://auspia.ai
Referring domain count 5/20 8 domains
Domain diversity 6/20 top domain 30.0%
Anchor naturalness 15/15 branded 7 / generic 2 / other 1
Follow ratio 5/5 10 follow rows
Link velocity no data no timed data in source
Toxic signal no data spam score not in free exports
Geographic relevance no data country data not in free exports
TOTAL: 31/60 (rescaled to 52/100)Four factors had data, so the gate opened and the score exists: 31 of 60 available points, rescaled to 52/100. Now the diagnosis reads like a diagnosis:
- Referring domain count 5/20: eight domains, and the good band starts around 100. Against a 20-point weight, the score is low and will stay low until you add real partners.
- Domain diversity 6/20: the real damage. One domain supplies 30% of the rows. A single domain at 30% is a concentration risk, not just an aesthetic one.
- Anchor naturalness 15/15: 70% of anchors are branded. Fine, and worth keeping that way: no exact-match stuffing shows.
- Follow ratio 5/5: full score, all follow.
The three no data rows are where a paid tool would quietly invent numbers. Here they are labeled. Velocity and toxic signal genuinely need timed or premium data; you should not decide on this report that the profile is "toxic" - the word appears in the skill only as a manual-review category.
How to read the diagnosis
Word in the output | What it means | What you do about it |
|---|---|---|
| Page loaded and contains an | Keep it in the list; check the anchor text |
| The matched link's rel attribute | Nofollow is not a smell by itself; a whole list of nofollow is |
| The citing page is gone or the URL was wrong | Remove it; find the page's new home or a different real link |
| Page exists but does not link to you | The claim was false; drop it from the list, that is a finding too |
| 5xx, 403, or network error | Check once by hand: 403 can be bot blocking, not death |
| No evidence for that factor | Never fill it with a guess; add GSC/Moz/Bing data that can score it |
| <4 of 7 factors have evidence | The report is a setup list, not a score about your site |
The rescale is also worth understanding: 31/60 -> 52/100 does not mean "your profile is 52% good." It means "on the four factors that could be measured, the measured portion is 52%." The three unscored factors would change the denominator if you fed them data. Say it with that caveat or people will misunderstand the number.
The smallest detail that still matters: whenever unique referring domains is 1 or 2, or an anchor shows (no anchor text), you have probably verified navigation and logo links, the way I did in the run above. Counts are still counts. "Verified" is not "earned."
Where the blind spots hide
- JS-rendered pages. A page that renders content client-side returns a shell with no
<a>tags to someone scraping the HTML, and the crawler sees a page with no link. Per the skill's rule: an unverifiable page is not a dead link, and never treat it as one. Report it as "could not verify - needs a headless browser," which is exactly what the skill'sErrorstable says twice. - The cache problem. Verify is a live check; a tool's link index is mostly historical. When a live check disagrees with a dashboard, the dashboard is usually behind, not wrong.
- `no draft for you` semantics.
followin the output comes from therelattribute on the matched<a>. It does not tell you the page's other links, a shared footer across a domain, or whether a domain is a PBN. The skill's toxic table is the memory you run manually, with the data plus your own domain judgement. - Anchor empty strings. An empty anchor is the logo-image case, and in the profile count it goes to
generic. That keeps the counts separated, but you know more than the strings do.
Troubleshooting table
What you see | Meaning | Action |
|---|---|---|
| Data gate: under 4 factors had evidence | Provide a GSC export or Moz/Bing data, or start with |
All anchors | The matched links are image/navigation links | Re-check the file is real citing pages, not your own site's pages |
| The CSV's headers lack URL and anchor columns | Export again from GSC's External links, keep those columns |
Several | Stale link list or URL typos | Drop the entries; re-source the claiming sites |
| DNS or connection, not deindexing | Fix |
Large CSV, low score | A real observation, not an error | The score reflects domains and diversity; velocity and toxic remain |
One domain >25% of rows | Concentration risk | That is the diagnosis: diversify citing domains before asking why the score is low |
Install this skill by pasting to Codex
Copy this paragraph into Codex together with the two code blocks above:
Read the two code blocks in the current message. Create~/.codex/skills/codex-seo-backlinks/SKILL.md(markdown block) and~/.codex/skills/codex-seo-backlinks/scripts/backlinks_audit.py(python block) exactly as written. Then run the script twice: firstverify https://your-site.comagainst a file of suspected link URLs you create in this conversation, thenprofile https://your-site.com --gsc-export <my GSC export path if I provide one>. Report the verified/missing/dead split, the factor scores, and the priority fix list.
FAQ
Is this just a cheaper version of a paid link audit report? No. A paid report gives you timed velocity data, per-domain detail, and a confidence model you did not collect. This skill gives you the live verification, the honest data gate, and the free-source ladder. The honest gate is the thing that keeps it from being another "your score is 37" screen.
How do I get the Search Console export? Google Search Console, your property, Performance or Links > External > Copy/Download. Use the links export to a CSV with anchor text and URL-like columns; the profile reader looks for a column containing "url" and one containing "anchor". The domains and anchors come from real rows after that.
Why does it say `no data` for toxic links? Because the free sources in the ladder do not ship a spam score. Score the toxic factor from the indicators table in the SKILL.md (known PBN domains, exact-match anchor saturation, link farms) and by your own look at the domains - that is a left-by-hand category, and the report says so rather than inventing a percentage.
My site is young and small. Is 52/100 a bad sign? No. The sample-note in the verify output exists for this reason: a young or niche site genuinely has under 10 verifiable links. A low domain count is a description of age, not a verdict, and the answer is not to change the score, it is a natural growth path to more unrelated, relevant domains.
Next in the series (post 10 of 20): [How to Set Up Codex for Local SEO Audits (Full SKILL.md Included)](https://auspia.ai/blog/codex-seo-local) - local seo: gbp, nap, reviews, multi-location gates.
Previous in the series: How to Set Up Codex for GEO: AI Visibility Scans (Full SKILL.md Included). The full series roadmap lists all 20 posts.
Author: Isabel Grant, Researcher of 2,000+ AI Citation Patterns at Auspia. Isabel writes about where citations come from, how referencing compounds, and why machines read links the way they do.
Based on the open-source claude-seo project by AgriciDaniel (MIT license, GitHub). This series adapts it for the Codex runtime: rewritten methodology, a Codex-native SKILL.md, and new evidence collectors written from scratch for this series.




