How to Diagnose a Backlink Profile with Codex (Full SKILL.md Included)

Key takeaways

A Codex skill that verifies a list of backlinks one by one, then scores the profile factors it can prove - and refuses to invent numbers for the ones it cannot.

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

verify fetches the page and looks for your <a> directly

"I built 20 links and nothing moved"

Dead URLs, wrong target page, or links from one domain only

verify counts live vs 404 vs blocked, and unique referring domains

"Tool A says 23 links, Tool B says 47"

Different sources, different freshness

profile labels every factor with its source

"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.

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:

bash
mkdir -p ~/.codex/skills/codex-seo-backlinks/scripts

Save this as ~/.codex/skills/codex-seo-backlinks/SKILL.md:

markdown
---
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:

python
#!/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

bash
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.csv

links.txt is one URL per line, comments start with #.

A real diagnosis: this series' own site

The backlink diagnostic road: a suspicion, live verification of the link, counting against the target, the 7-factor profile with a 4-factor data gate, and a fix list built from real evidence.

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:

text
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 links

Read 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:

text
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:

csv
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/guide

And the scored result:

text
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

verified

Page loaded and contains an <a> resolving to your target

Keep it in the list; check the anchor text

follow / nofollow

The matched link's rel attribute

Nofollow is not a smell by itself; a whole list of nofollow is

missing (404)

The citing page is gone or the URL was wrong

Remove it; find the page's new home or a different real link

loaded but no link

Page exists but does not link to you

The claim was false; drop it from the list, that is a finding too

dead or blocked

5xx, 403, or network error

Check once by hand: 403 can be bot blocking, not death

no data

No evidence for that factor

Never fill it with a guess; add GSC/Moz/Bing data that can score it

INSUFFICIENT DATA

<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's Errors table 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. follow in the output comes from the rel attribute 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

Backlink Health Score: INSUFFICIENT DATA

Data gate: under 4 factors had evidence

Provide a GSC export or Moz/Bing data, or start with verify

All anchors (no anchor text)

The matched links are image/navigation links

Re-check the file is real citing pages, not your own site's pages

gsc-export error:

The CSV's headers lack URL and anchor columns

Export again from GSC's External links, keep those columns

Several missing (404)

Stale link list or URL typos

Drop the entries; re-source the claiming sites

ERR: statuses

DNS or connection, not deindexing

Fix links.txt or the network; retry, few hours apart

Large CSV, low score

A real observation, not an error

The score reflects domains and diversity; velocity and toxic remain no data until you store periodic exports

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: first verify https://your-site.com against a file of suspected link URLs you create in this conversation, then profile 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.

Explore this topic

Keep following the same growth thread