advertools: the Python toolkit that replaces a drawer of SEO scripts
An open-source Python package for crawling, sitemap and robots.txt analysis, SERP tooling and log-file parsing, all under one pip install.
The SEO Bench
A Python script that cross-references your ranking queries against a site crawl to find pages that mention a term but do not yet link to the page ranking for it.
The biggest internal-link wins on most sites are not new links you have to invent, they are links you already deserve. A page discussing a topic in passing, with no link to the page that actually ranks for it, is a missed opportunity sitting in plain text on your own site. Finding those by hand means reading every page against every ranking query. This script does the cross-reference for you and writes out a CSV.
page, query, impressions.pip install requests beautifulsoup4 pandas defusedxml. The sitemap is XML from an external source, so it is parsed with defusedxml rather than the standard library parser, which is vulnerable to entity-expansion attacks from a hostile or compromised sitemap.requests.get, no paid API and no headless browser, so it works on any site with a standard XML sitemap."""
Internal-Link Opportunity Finder
The SEM Dispatch Vault, semdispatch.com/vault
Cross-references a Search Console export against a crawl of the site's own
pages to find pages that mention a ranking query but do not yet link to the
page that ranks for it. Produces a CSV. Never edits a page.
"""
import re
import requests
import pandas as pd
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
from defusedxml import ElementTree # parses the sitemap safely; do not swap for xml.etree
# --- Config -------------------------------------------------------------
SITEMAP_URL = "https://example.com/sitemap.xml"
GSC_EXPORT_CSV = "gsc_export.csv" # columns: page, query, impressions
BASE_DOMAIN = "example.com"
MIN_IMPRESSIONS = 100
MAX_SUGGESTIONS_PER_TARGET = 10
OUTPUT_CSV = "link_opportunities.csv"
# --- Crawl ----------------------------------------------------------------
def get_sitemap_urls(sitemap_url):
resp = requests.get(sitemap_url, timeout=20)
root = ElementTree.fromstring(resp.content)
ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
return [loc.text for loc in root.findall(".//sm:loc", ns)]
def fetch_page(url):
resp = requests.get(url, timeout=20, headers={"User-Agent": "LinkFinder/1.0"})
soup = BeautifulSoup(resp.text, "html.parser")
body_text = soup.get_text(separator=" ").lower()
linked_targets = set()
for a in soup.find_all("a", href=True):
href = urljoin(url, a["href"])
if urlparse(href).netloc.endswith(BASE_DOMAIN):
linked_targets.add(href.split("#")[0].rstrip("/"))
return body_text, linked_targets
def crawl_site(urls):
pages = {}
for url in urls:
try:
text, links = fetch_page(url)
pages[url.rstrip("/")] = {"text": text, "links": links}
except requests.RequestException:
continue
return pages
# --- Matching ---------------------------------------------------------------
def top_queries_by_page(gsc_csv, min_impressions):
df = pd.read_csv(gsc_csv)
df = df[df["impressions"] >= min_impressions]
grouped = df.sort_values("impressions", ascending=False).groupby("page")
return {page: list(rows["query"]) for page, rows in grouped}
def find_opportunities(pages, queries_by_page, gsc_impressions):
opportunities = []
for target_page, queries in queries_by_page.items():
target_key = target_page.rstrip("/")
suggestions_for_target = 0
for query in queries:
if suggestions_for_target >= MAX_SUGGESTIONS_PER_TARGET:
break
phrase = query.lower().strip()
if not phrase:
continue
for source_page, data in pages.items():
if source_page == target_key:
continue
if target_key in data["links"]:
continue
if re.search(r"\b" + re.escape(phrase) + r"\b", data["text"]):
opportunities.append({
"source_page": source_page,
"target_page": target_key,
"anchor_phrase": query,
"target_query_impressions": gsc_impressions.get((target_key, query), 0),
})
suggestions_for_target += 1
break
return opportunities
# --- Entry point --------------------------------------------------------------
def main():
urls = get_sitemap_urls(SITEMAP_URL)
pages = crawl_site(urls)
df = pd.read_csv(GSC_EXPORT_CSV)
df = df[df["impressions"] >= MIN_IMPRESSIONS]
impressions_lookup = {
(row["page"].rstrip("/"), row["query"]): row["impressions"]
for _, row in df.iterrows()
}
queries_by_page = top_queries_by_page(GSC_EXPORT_CSV, MIN_IMPRESSIONS)
opportunities = find_opportunities(pages, queries_by_page, impressions_lookup)
result = pd.DataFrame(opportunities)
if not result.empty:
result = result.sort_values("target_query_impressions", ascending=False)
result.to_csv(OUTPUT_CSV, index=False)
print(f"Wrote {len(result)} link opportunities to {OUTPUT_CSV}")
if __name__ == "__main__":
main()
SITEMAP_URL, GSC_EXPORT_CSV and BASE_DOMAIN for your site.MIN_IMPRESSIONS to filter out queries too small to matter, and MAX_SUGGESTIONS_PER_TARGET to cap how many source pages get suggested per target, so one high-impression page does not swamp the whole report.python link_finder.py. A mid-sized site of a few hundred pages takes a few minutes, the crawl step is the slow part.The CSV is sorted by target_query_impressions, so the top rows are the highest-value links you are missing: a source page that already discusses the topic in its own text, and a target page that already ranks for the exact phrase, with no link between them yet. Open the source page, find the mention, and add a natural link using the query itself, or a close variant, as anchor text.
The whole-phrase match is a simple word-boundary regular expression, not semantic matching, so it will miss a page that discusses the same topic using different words, and it can occasionally flag a mention that reads awkwardly as a link in context, always check the sentence before adding one. The script never edits a page, it only reads the crawl and writes the CSV, so the linking itself stays a deliberate, reviewed step. Respect the site's own crawl etiquette: add a short delay between requests on a large site and check robots.txt before pointing this at anything you do not own.
More Like This
An open-source Python package for crawling, sitemap and robots.txt analysis, SERP tooling and log-file parsing, all under one pip install.
A community-maintained, continuously updated list of AI crawlers, shipped as drop-in robots.txt, htaccess, Nginx and Caddy files so you can block or allow AI bots in minutes.
A Cloudflare feature, free on every plan, that shows exactly which AI crawlers hit your site and lets you allow or block them per bot with one click.