Off-Page SEO & Link BuildingSpam policies, penalties and disavow · Lesson 6 of 17

Hands-on: link audit spreadsheets, formulas and Python

Article · 14 min · 9 min lecture

Video lecture

Hands-on: link audit spreadsheets, formulas and Python

14 chapters · about 9 min · full transcript

Coming soon

Chapter 1 of 14

Hands-on: the link audit sheet

  • From raw exports to decisions
  • Formulas that flag patterns
  • The Python version

The narrated lecture is in production

Every chapter is scripted and ready. Browse the chapters and read the full transcript now — the video will appear here when it’s published.

Chapters

Why build your own audit sheet

"Toxic link" scores from tools are opinions from a vendor's model. A professional link audit is a documented, repeatable classification of every linking domain that a human has reviewed — the kind of evidence a reconsideration request or a client board meeting needs. This lesson gives you a working template in Google Sheets and the same workflow in Python for larger sites.

Step 1: Collect the exports

SourceWhereWhat you get
Google Search ConsoleLinks → External links → Export: "More sample links" and "Latest links" (the latter includes crawl dates)Google's own sample of links; free, not complete
Third-party indexAhrefs, Semrush, Moz or Majestic referring-domains and backlinks exportsLarger index, anchors, first seen/lost, estimated traffic
Your recordsPast vendor reports, invoices, outreach logsEvidence of links you (or a vendor) built

Use one third-party tool consistently; indexes differ and mixing them mid-audit creates noise.

Step 2: Normalise to root domains

Put all linking URLs in one tab (Raw, column A). In column B extract the hostname and strip www.:

B2: =LOWER(REGEXREPLACE(REGEXEXTRACT(A2, "^(?:https?://)?([^/:?#]+)"), "^www\.", ""))

Hostnames like blog.example.co.uk stay as they are — review subdomains of large platforms (blogspot, medium, wordpress.com) individually, because each is a separate publisher.

Step 3: One row per domain

In a Domains tab:

A2: =UNIQUE(FILTER(Raw!B2:B, Raw!B2:B<>""))
B2: =COUNTIF(Raw!B:B, A2)                         // links from this domain
C2: =IFERROR(VLOOKUP(A2, Tool!A:D, 2, FALSE), "")  // domain metric from your tool tab
D2: =IFERROR(VLOOKUP(A2, Tool!A:D, 3, FALSE), "")  // est. organic traffic
E2: =IF(COUNTIF(GSC!B:B, A2)>0, "yes", "no")      // does Google's sample include it?

Or summarise in one formula with QUERY:

=QUERY(Raw!A:C, "select B, count(A) where B is not null group by B order by count(A) desc label count(A) 'links'", 1)

Step 4: Automatic flags (they prompt review, they don't decide)

F  Sitewide?          =IF(B2>=50, "sitewide/template?", "")
G  Metric-no-audience =IF(AND(N(C2)>=40, N(D2)<100), "metric without traffic", "")
H  Commercial anchor  =IF(COUNTIFS(Anchors!A:A, A2, Anchors!C:C, "commercial")>0, "money anchor", "")
I  Risky TLD/topic    =IF(REGEXMATCH(A2, "(casino|loan|bet|pills|seo-links|guestpost)"), "topic flag", "")
J  Flag count         =COUNTA(F2:I2)-COUNTBLANK(F2:I2)

Classify anchors in an Anchors tab with a helper column: brand (contains your brand name), url, generic ("click here", "this guide"), topical, or commercial (exact-match money phrases). The share of commercial anchors is one of the clearest footprints of past link buying:

=COUNTIF(Anchors!C:C, "commercial") / COUNTA(Anchors!C2:C)

Step 5: Human classification

Add columns K: Classification (data validation: natural, questionable, unnatural), L: Evidence, M: Removal requested (date), N: Removal status, O: Disavow (Y/N). Rules of thumb:

  • Natural: editorial, relevant or at least plausibly organic; includes most scraper junk you did not build (Google generally ignores it).
  • Unnatural: you have evidence it was bought, swapped or placed (invoices, vendor lists, identical guest posts with money anchors, networks).
  • Questionable: needs a second look — do not disavow on suspicion alone.

Sort by J (flag count) descending and review the flagged rows first; then sample 10% of unflagged rows to check the flags aren't missing a pattern.

Step 6: The same workflow in Python (large sites)

import re
import pandas as pd

GSC_FILE = "gsc_latest_links.csv"      # first column = linking page URL
TOOL_FILE = "tool_backlinks.csv"        # adjust column names below to your export
TOOL_URL_COL, TOOL_ANCHOR_COL = "Referring page URL", "Anchor"
BRAND = re.compile(r"northgate", re.I)
MONEY = re.compile(r"(accountant|tax return|bookkeeping)\s+(manchester|uk|near me)", re.I)

def root(url: str) -> str:
    host = re.sub(r"^https?://", "", str(url)).split("/")[0].split(":")[0].lower()
    return host.removeprefix("www.")

gsc = pd.read_csv(GSC_FILE)
tool = pd.read_csv(TOOL_FILE)
gsc["domain"] = gsc.iloc[:, 0].map(root)
tool["domain"] = tool[TOOL_URL_COL].map(root)

def anchor_type(a: str) -> str:
    a = str(a)
    if BRAND.search(a): return "brand"
    if MONEY.search(a): return "commercial"
    if a.startswith("http"): return "url"
    return "other"

tool["anchor_type"] = tool[TOOL_ANCHOR_COL].map(anchor_type)
by_domain = (tool.groupby("domain")
                 .agg(links=("domain", "size"),
                      commercial=("anchor_type", lambda s: (s == "commercial").sum()))
                 .reset_index())
by_domain["in_gsc_sample"] = by_domain["domain"].isin(set(gsc["domain"]))
by_domain["flag_sitewide"] = by_domain["links"] >= 50
by_domain["flag_money"] = by_domain["commercial"] > 0
by_domain["classification"] = ""          # filled in by a human reviewer
by_domain.sort_values(["flag_money", "links"], ascending=False).to_csv("link_audit.csv", index=False)
print(by_domain.head(20))

The output feeds straight into the disavow script in the previous lesson once humans have filled in classification.

Worked example (illustrative)

Northgate Accountants (Manchester) inherits 1,240 linking domains. Flags surface 212 domains; manual review classifies 181 as unnatural (identical "guest posts" with anchors like accountant Manchester, all on sites selling posts), 19 as questionable and the rest as natural. Commercial anchors fall from 31% of all anchors to under 5% once those domains are excluded from the calculation — a clear before/after chart for the client. Removal requests go out; the rest are disavowed at domain level; the sheet is linked in the reconsideration request.

Common mistakes

  • Treating automatic flags as verdicts instead of prompts for review.
  • Auditing URLs instead of domains, which multiplies the workload by the number of sitewide links.
  • Mixing two third-party tools' numbers in one column.
  • Losing the evidence column — the "why" is what makes the audit credible.

Key takeaways

  • A link audit is a documented, human classification of every linking domain, not a vendor toxicity score.
  • Combine Search Console exports with one consistent third-party index and normalise to root domains.
  • Use formulas to flag sitewide links, money anchors, metric-without-traffic and risky topics, then review manually.
  • Record evidence and removal status per domain; the sheet feeds disavow files and reconsideration requests.

Check your understanding

Quick questions to lock in the lesson. They don’t count towards your certificate.

  1. Why normalise linking URLs to root domains before classifying?
  2. A domain is flagged 'metric without traffic' by your formula. What should happen next?

Put it into practice

Build the audit template for a real or demo site: import one GSC export and one tool export, add the normalisation and flag formulas, and classify the 20 most-flagged domains with evidence.

Enrol for free to save your progress

Reading is always free. Enrol to keep your place, take the final assessment and earn a verifiable certificate.