Skip to content

Score Backlink Quality in Python: DataForSEO, Embeddings and Claude

By Lazarina Stoy · 11 min read
Score Backlink Quality in Python: DataForSEO, Embeddings and Claude: illustration of a code notebook connected to an API. An MLforSEO guide by Lazarina Stoy.

Most backlink audits I see end in one of two places: a scary spam score, or a spreadsheet of a few hundred referring domains nobody has time to read. I wanted a short list instead: which links are worth knowing about, which are spam you can leave alone, which need a human to look, and why, in one sentence per domain.

This post is part of my AI-powered backlink strategy guide, and it is the code route. It walks through a free Python notebook that works as a backlink quality checker: it pulls your profile from the DataForSEO Backlinks API, flags the clear cases with transparent rules, scores topical relevance with a small embedding model, and sends only the ambiguous middle to Claude. I ran it on mlforseo.com on 7 November 2026. One full audit cost $0.17, and every number below comes from that run.

Prefer no code? The Claude and DataForSEO MCP version covers the same ground in plain English. For the thinking behind the signals, read how to do an AI-powered backlink audit.

Free notebook

Backlink Quality Audit notebook: a free Colab notebook that sorts every referring domain into Valuable, Neutral, Likely spam or Investigate, with who decided and why, plus a link reclamation list. Code and sample data are in the GitHub folder.

On this page
  1. What the notebook does, and why rules come first
  2. Keys, spend caps and what it costs
  3. Step 1: the settings
  4. Step 2: five DataForSEO calls
  5. Step 3: rule flags and anchor types
  6. Step 4: relevance with embeddings, and why it’s a proxy
  7. Step 5: Claude reviews the ambiguous middle
  8. Step 6: four buckets, each with a reason
  9. Step 7: link reclamation with fuzzy matching
  10. Step 8: exports
  11. What the run on mlforseo.com taught me
  12. How to adapt it to your site
  13. Pitfalls and limits
  14. What to do next
  15. FAQ

What the notebook does, and why rules come first

The design rule: let cheap, explainable rules decide everything they can, and spend model calls only where they can’t. On my profile the rules settled 224 of 288 referring domains and Claude reviewed the other 64. That keeps the cost tiny, and every verdict has a reason you can read.

You can try it without keys. With USE_SAMPLE = True (the default) it replays my real run from sample_data/, Claude’s answers included. If Google Colab or Python are new to you, my Introduction to Machine Learning for SEO course starts from those basics, embeddings included.

Keys, spend caps and what it costs

For live data you need a DataForSEO login with Backlinks API access and an Anthropic API key, stored as Colab secrets (DATAFORSEO_LOGIN, DATAFORSEO_PASSWORD, ANTHROPIC_API_KEY) or environment variables. Never paste keys into a cell.

On 7 November 2026 the Backlinks pricing page showed $0.024 per request plus $0.000036 per row, and Claude Haiku 4.5 was $1 per million input tokens and $5 per million output tokens (Anthropic pricing). The notebook prints a worst-case estimate before spending anything:

CallRow limitWorst case (USD)Actual (USD)
summary10.024040.024036
referring_domains1,0000.060000.034368 (288 rows)
backlinks (one per domain)1,0000.060000.034368 (288 rows)
anchors1000.027600.027600 (100 rows)
broken_backlinks2000.031200.025188 (33 rows)
DataForSEO total2,3010.202840.145560

You pay per row returned, so smaller sites pay less; the actual column is DataForSEO’s own cost field. Claude reviewed 64 rows in 3 calls for $0.024102, so one audit came to $0.146 + $0.024 = $0.170.

MAX_SPEND_USD (0.30) and MAX_LLM_SPEND_USD (0.25) are hard caps checked before every paid call, responses are cached in output/raw/ so a re-run never pays twice, and a dropped connection on a paid call is never retried (it may already have been charged):

if self.spent + expected_cost > self.max_spend_usd + 1e-9:
    raise RuntimeError(f"Spend cap: ${self.spent:.4f} spent, the next call could cost up to "
                       f"${expected_cost:.4f} and the cap is ${self.max_spend_usd}.")

Step 1: the settings

  • TARGET: your bare domain (no https:// or www. prefix).
  • SITE_TOPIC: one or two specific lines about your site. Every link is compared with it.
  • BRAND_TERMS and KEYWORD_TERMS: words that make an anchor branded (mine: mlforseo, ml for seo, ml4seo, lazarina, stoy) or a topic keyword.
  • OWNED_DOMAINS: your other sites and newsletters (mine: my personal site, my newsletter host and a community site I run), kept in Neutral so they don’t inflate Valuable.
  • REVIEW_PATTERNS: words that send a link to review however good it looks: download, sale ?page, group ?buy, leak, torrent, course.
  • Row limits, the two spend caps, BATCH_SIZE (25) and MAX_LLM_ROWS (300).

Step 2: five DataForSEO calls

All five are POST /v3/backlinks/{endpoint}/live, one task each, live backlinks only. New to the DataForSEO API? My DataForSEO API in Python tutorial covers authentication and responses.

CallEndpoint and key parametersWhat it gives you
summarysummary/liveTotals and breakdowns across every live backlink
referring_domainsreferring_domains/live, order_by: ["rank,desc"]Domain-level metrics
backlinksbacklinks/live, mode: "one_per_domain"The strongest link per domain, with group_count
anchorsanchors/live, order_by: ["backlinks,desc"]Most-used anchors
broken_backlinksbacklinks/live, filters: ["is_broken", "=", true]Links to URLs that returned 4xx or 5xx

For mlforseo.com: DataForSEO rank 219 (0 to 1,000 scale), 2,611 backlinks, 611 referring domains and 288 referring main domains. The profile’s backlinks_spam_score was 34 while the site’s own spam score was 3, so the spam points at the site. 743 links came from .xyz domains.

Step 3: rule flags and anchor types

Each of the 288 domains gets one row of explainable features: spam score band, rank band, follow or nofollow, spam-prone TLD, position on the page, anchor type and link age. The spam score is DataForSEO’s 0 to 100 heuristic from on-page and domain signals and the rank is its PageRank-like score. Neither is Google’s. A spam-prone TLD is a signal, never a verdict.

Anchor type is checked in order: image or empty, link-selling phrases (telegram, backlinks, pbn, casino and so on), naked URL, branded, generic, keyword. Order matters: my first version called a link seller’s advert a keyword anchor because it contained “seo”. Per domain:

spam_phrasenaked_urlkeywordbrandedotherimage_or_emptygeneric
186 (64.6%)38 (13.2%)22 (7.6%)18 (6.2%)12 (4.2%)7 (2.4%)5 (1.7%)

Then the rules decide the clear cases, in this order:

if row.get("owned"):
    return "own_site", "one of your own sites (OWNED_DOMAINS)"
if spam >= rules["spam_score_spam"]:                              # 60
    return "likely_spam", f"spam score {int(spam)} >= {rules['spam_score_spam']}"
if row.get("spam_prone_tld") and spam >= rules["spam_score_tld"]:  # 30
    return "likely_spam", f".{row.get('tld')} TLD with spam score {int(spam)}"
if spam >= rules["spam_score_tld"] and rel < rules["relevance_floor"]:  # 0.10
    return "likely_spam", f"spam score {int(spam)} and off-topic (relevance {rel:.2f})"
if (0 <= spam < rules["spam_score_clean"] and rank >= rules["rank_valuable"]   # <15, 150+
        and rel >= rules["relevance_valuable"] and not row.get("review_hint")):  # 0.30+
    return "valuable", f"rank {int(rank)}, spam {int(spam)}, relevance {rel:.2f}"
return "ambiguous", ""

Result: 202 likely spam, 19 valuable, 3 own sites, 64 ambiguous. The thresholds live in one RULES dictionary.

Step 4: relevance with embeddings, and why it’s a proxy

Each link becomes page title | anchor | domain, turned into an embedding by all-MiniLM-L6-v2, a small Sentence Transformers model that runs on a laptop CPU. The score is its cosine similarity with SITE_TOPIC:

model = SentenceTransformer("all-MiniLM-L6-v2")
texts = [f"{title} | {anchor} | {domain}" for ...]       # relevance_text(row)
vecs = model.encode(texts, normalize_embeddings=True)
topic = model.encode([site_topic], normalize_embeddings=True)[0]
relevance = (vecs @ topic).clip(0, 1)                     # cosine, 0-1

It is a proxy: it only sees metadata, the model is English-first (a Japanese SEO publication’s expert roundup scored 0.217, below the median spam domain), and spam about SEO is on-topic. Link-selling pages had a median of 0.267 and reached 0.457, above the median Valuable link (0.430). So relevance never decides a link alone.

Step 5: Claude reviews the ambiguous middle

The 64 ambiguous rows go to claude-haiku-4-5, strongest first, 25 per call, one line of JSON each. One real row, title shortened:

{'id': 45, 'domain': 'about.me', 'title': '(profile page title)', 'anchor': 'MLforSEO',
 'dofollow': False, 'location': 'section', 'domain_rank': 625, 'spam_score': 0,
 'tld': 'me', 'first_seen': '2025-11-06', 'links_from_domain': 2, 'review_hint': None}

The call uses structured outputs, so every answer parses against a JSON schema: per id, a verdict (keep_valuable, keep_neutral, ignore_spam or investigate), a reason and a confidence (low, medium, high).

resp = self.client.messages.create(
    model="claude-haiku-4-5", max_tokens=4000, system=system,
    messages=[{"role": "user", "content": user}],
    output_config={"format": {"type": "json_schema", "schema": REVIEW_SCHEMA}},
)
reviews = json.loads(next(b.text for b in resp.content if b.type == "text"))["reviews"]

The system prompt rules that matter most:

  • Metadata only, no invention. “Do not invent facts about a site you cannot see”; if the metadata isn’t enough, choose investigate.
  • Any language counts. An on-topic article in German, Japanese or Spanish is as good as one in English.
  • Piracy looks on-topic. Sites listing someone else’s paid courses as downloads, sale pages or group buys go to investigate, with a reason.
  • Reasons of 20 words or fewer, based only on the metadata shown.

Claude returned 26 keep_valuable, 16 investigate, 11 ignore_spam and 11 keep_neutral: 45 high-confidence verdicts, 19 medium, none low.

Step 6: four buckets, each with a reason

BucketReferring domainsLinksBy rulesBy Claude
Likely spam213 (74.0%)1,45620211
Valuable45 (15.6%)1911926
Investigate16 (5.6%)109016
Neutral14 (4.9%)8403 (own sites)11
Total2882,59622464

Neutral holds 840 links because my own sites send most of them. Some of the strongest Valuable domains:

DomainDataForSEO rankSpam scoreRelevanceDecided by
wix.com69650.452rules
webtan.impress.co.jp58900.217Claude
moz.com54000.486rules
searchenginejournal.com52300.342rules
seroundtable.com47100.487rules
advancedwebranking.com38500.569Claude

For the Japanese publication Claude’s reason was “Japanese SEO/marketing publication; on-topic expert roundup article”. The relevance score alone would have missed it.

The notebook groups broken backlinks by dead URL, re-checks each URL today, and suggests the live sitemap URL with the closest path words, using RapidFuzz token_set_ratio (fuzzy matching, 0 to 100, blank under 60). It’s the approach from my 404 and redirect mapping notebook, pointed at backlinks:

cands = {k: v for k, v in live_paths.items() if k.rstrip("/") != u.rstrip("/")}  # never itself
hit = process.extractOne(url_path(u), cands, scorer=fuzz.token_set_ratio)      # (words, score, url)
if hit and hit[1] >= 60:
    suggested, score = hit[2], round(hit[1])

Step 8: exports

One CSV per table, a backlink_audit.xlsx workbook with a tab each for Google Sheets, and cost_log.csv with every paid call. In audit.csv, filter Investigate first, then skim Valuable for outreach ideas.

What the run on mlforseo.com taught me

Course piracy hides behind clean metrics

Pages offering my courses as downloads, reseller listings and a copy of a Moz article on a group-buy domain had spam scores of 0 to 15 and ranks up to 381. On metrics alone, the Valuable rule would have filed four piracy or reseller domains and two possible course clones as Valuable. REVIEW_PATTERNS routed them to Claude instead, and Claude put every one of them in Investigate, mostly with high confidence: “possible course piracy” for a page titled “Download” plus my course name, rank 381, spam score 0. If you sell courses, templates or software, keep those patterns.

Spam can look strong

Claude caught 11 spam domains the rules had left ambiguous, including “URL shared” and “domain report” stat pages at ranks 245 to 306 and a hacked-looking day-trading page on a real news organisation’s subdomain with rank 468 and spam score 0. 161 of the 213 spam domains belonged to three link-selling networks advertised on Telegram; one had every domain at rank 149.

Removing the embedding score changed 14 of 64 verdicts

In my first run, the rows sent to Claude included the relevance score. Claude leaned on it (“low topical relevance”) and put on-topic German, Japanese and Spanish entity SEO articles in Neutral because the English-first model scored them 0.06 to 0.22. Telling it to distrust the number changed only 5 verdicts. Removing the number changed 14 of 64 against the first run, 11 of them to Valuable. Two non-English pages still sit in Neutral with weak reasons. If you want a second opinion, don’t show the model the first one.

Self-consistency: 54 of 64, every spam verdict held

Running the final prompt twice on identical batches, 54 of 64 verdicts matched (84%), including all 11 spam verdicts and 14 of 16 Investigate verdicts. The 10 disagreements were between neighbouring labels, such as Neutral and Valuable. The installed SDK (anthropic 2.6.0) did not accept a temperature argument, so the notebook saves every response in output/raw/claude/ instead: act on one saved run, and re-runs reuse it.

Stale broken links, and a wrong 88

DataForSEO reported 33 broken backlinks pointing at 2 URLs. Both loaded with a 200 on 7 November (its crawls ran 29 October to 6 November), so the real reclamation list was empty, which is why the notebook re-checks before suggesting anything. For the dead clustering guide, the fuzzy matcher suggested my classification guide with a score of 88: different page, shared words. Always read a suggestion before adding a 301.

Two smaller misses: the rules sent a legitimate-looking page about my personal site to Likely spam (spam 30, relevance 0.079), and Claude put a probable scraped copy of a Search Engine Journal article in Valuable because the title looked real.

How to adapt it to your site

  • Set USE_SAMPLE = False, your TARGET, a specific SITE_TOPIC, your brand and keyword terms, and every site you own in OWNED_DOMAINS.
  • Edit REVIEW_PATTERNS and the spam-prone TLD list for your market. My default list lacks .online, .site, .website and .space; spam score caught those anyway.
  • Mostly non-English links? Switch EMBEDDING_MODEL to paraphrase-multilingual-MiniLM-L12-v2, the option given in the settings.
  • Tune RULES, for example RULES["rank_valuable"] = 200 in a competitive niche.
  • Each call returns up to 1,000 rows by rank and the notebook doesn’t page further, so bigger profiles are judged on their strongest 1,000 referring domains.

How to swap the model

Change CLAUDE_MODEL, then check three things. Add the model’s price to LLM_PRICES in backlink_audit.py: it lists Haiku 4.5 and Sonnet 5.5 and falls back to Haiku prices, which would make the cost log and spend cap too low for a pricier model. Models that reason before answering use more output tokens and can hit max_tokens; the helper stops with a clear error, and a smaller BATCH_SIZE fixes it. And compare the new verdicts with your saved run before trusting them. I chose Haiku because the rules and the prompt carry most of the judgement.

How to run it monthly

  • Rename last month’s output/ folder first. The cache is keyed on the request, so leaving it in place replays last month’s data.
  • Compare the new audit.csv with the old one and read new Investigate rows first. Spam keeps arriving: some of mine was first seen on the day of the run.
  • Re-check reclamation, since DataForSEO’s crawl can lag your fixes.
  • Expect roughly the same cost each month ($0.17 for a profile my size).

Pitfalls and limits

  • Third-party index. DataForSEO’s data is its own crawl: it misses links, lists links Google ignores, and lags.
  • One link per domain. A domain is judged by its strongest link; check group_count.
  • Every score is a proxy, and Claude only sees metadata. Open the linking page before acting.
  • Likely spam is not a disavow file. Google’s disavow guidance says most sites won’t need the tool, and its December 2022 link spam update describes SpamBrain neutralising unnatural links.

What to do next

The audit gives you lists. After the backlink audit covers what to do with each bucket: ignore, (rarely) disavow, reclaim or pursue. The AI-powered backlink strategy guide has the wider picture.

Free notebook

Backlink Quality Audit notebook: run it free on the saved mlforseo.com data, then on your own site for about 17 cents at my profile’s size. Code, sample data and README are in the GitHub folder.

FAQ

What is a backlink quality checker?

A tool or workflow that sorts the sites linking to you by how much they’re worth, from real editorial links to automated spam. This one combines DataForSEO metrics, rules, embedding relevance and a Claude review, with a reason for every verdict.

How much does it cost to run?

On 7 November 2026, one audit of mlforseo.com (288 referring main domains) cost $0.146 at DataForSEO and $0.024 at Anthropic: $0.17. Sample mode is free.

Do I need to know Python?

Not to try it: run the cells in sample mode in Colab. To adapt it you edit one settings cell. For more, the Introduction to Machine Learning for SEO course covers the basics.

Should I disavow the Likely spam bucket?

Usually not. Google says most sites never need to, and John Mueller has called toxic links a concept made up by SEO tools. My next-steps post goes through the exceptions.

MLforSEO Academy

Take your skills further — pick your path

Recommended with this article

Semantic ML-enabled Keyword Research

by Lazarina Stoy

Query understanding, search intent and semantic clustering for how people and AI really search.

View course →

AI Search Optimisation & Agentic SEO

by Beatrice Gamba

How agentic systems retrieve, evaluate and select — and how to become the source they cite.

View course →

AI Search, LLMs, Entity SEO & Knowledge Graph Strategies

by Beatrice Gamba

Build the entity authority and knowledge-graph presence that makes AI recognise and trust your brand.

View course →

Introduction to Machine Learning for SEO

by Lazarina Stoy

Ship practical ML workflows — classification, clustering, entity extraction — no CS degree required.

View course →
Bundle & save up to €200 →Already an Academy member? Use Community30 for 30% off your next course or bundle.