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 dependency-light Python script that matches an old URL list to a new one, exact first then fuzzy, and writes a redirect map plus a list of everything it could not place.
The worst part of a site migration is not the new templates, it is the redirect map: the line-by-line job of pointing every old URL at its new home so the equity built up over years does not evaporate on launch day. Done by hand it is tedious and error-prone; done entirely by a tool it is fast and quietly wrong. This script takes the middle path. It does the mechanical matching and hands you a clearly separated pile of guesses to check.
It reads two CSVs, normalises the paths, matches exact paths first, then runs the leftovers through Python's own difflib.SequenceMatcher to find the closest surviving URL above a similarity threshold you set. It writes redirect-map.csv for the matches and unmatched.csv for everything it could not place, and prints a summary. It reads and writes local files only: it does not crawl, fetch, or change anything on either site.
old-urls.csv (the URLs that are going away) and new-urls.csv (the URLs that will exist after launch). A header row is fine; the script skips a first row that is not a URL.https://old.example.com/about/ and /about match.0.65 is deliberately cautious. Raise it towards 0.8 for a cleaner, smaller set of confident guesses; lower it only if you intend to review every row anyway.#!/usr/bin/env python3
"""
Redirect map builder
The SEM Dispatch Vault, semdispatch.com/vault
Matches an old URL list to a new one: exact path first, then a fuzzy
fallback with difflib. Writes redirect-map.csv and unmatched.csv.
Reads and writes local files only; it never touches either site.
"""
import csv
import sys
from difflib import SequenceMatcher
from urllib.parse import urlparse
CONFIG = {
# Input files (one URL per row; a header row is skipped if it is not a URL).
"OLD_URLS": "old-urls.csv",
"NEW_URLS": "new-urls.csv",
# Output files.
"REDIRECT_MAP": "redirect-map.csv",
"UNMATCHED": "unmatched.csv",
# Fuzzy similarity floor, 0.0 to 1.0. Below this, a URL is left unmatched
# rather than pointed at a weak guess. 0.65 is cautious on purpose.
"FUZZY_THRESHOLD": 0.65,
}
def normalise(url):
"""Reduce a URL to a comparable path: lowercase, no trailing slash."""
parsed = urlparse(url.strip())
path = parsed.path if parsed.scheme else url.strip()
path = path.split("?", 1)[0].split("#", 1)[0]
path = path.rstrip("/").lower()
return path or "/"
def read_urls(filename):
urls = []
with open(filename, newline="", encoding="utf-8") as handle:
for row in csv.reader(handle):
if not row:
continue
value = row[0].strip()
if not value:
continue
# Skip an obvious header cell.
if value.lower() in ("url", "urls", "address", "old", "new"):
continue
urls.append(value)
return urls
def main():
old_urls = read_urls(CONFIG["OLD_URLS"])
new_urls = read_urls(CONFIG["NEW_URLS"])
if not old_urls or not new_urls:
sys.exit("Both CSVs need at least one URL. Nothing to do.")
# Index the new URLs by normalised path for exact matching.
new_by_path = {}
for url in new_urls:
new_by_path.setdefault(normalise(url), url)
new_paths = list(new_by_path.keys())
matches = []
unmatched = []
exact_count = 0
fuzzy_count = 0
for old in old_urls:
old_path = normalise(old)
if old_path in new_by_path:
matches.append((old, new_by_path[old_path], "exact", "1.00"))
exact_count += 1
continue
best_path = None
best_score = 0.0
for candidate in new_paths:
score = SequenceMatcher(None, old_path, candidate).ratio()
if score > best_score:
best_score = score
best_path = candidate
if best_path is not None and best_score >= CONFIG["FUZZY_THRESHOLD"]:
matches.append(
(old, new_by_path[best_path], "fuzzy", "%.2f" % best_score)
)
fuzzy_count += 1
else:
unmatched.append((old, "%.2f" % best_score))
with open(CONFIG["REDIRECT_MAP"], "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["old_url", "new_url", "match_type", "confidence"])
writer.writerows(matches)
with open(CONFIG["UNMATCHED"], "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["old_url", "best_score"])
writer.writerows(unmatched)
print("Old URLs read: %d" % len(old_urls))
print("Exact matches: %d" % exact_count)
print("Fuzzy matches: %d (review every one before shipping)" % fuzzy_count)
print("Left unmatched: %d" % len(unmatched))
print("Wrote %s and %s" % (CONFIG["REDIRECT_MAP"], CONFIG["UNMATCHED"]))
if __name__ == "__main__":
main()
redirect-map.py in a folder with your two CSVs. No packages to install: it uses only the Python 3 standard library.old-urls.csv and new-urls.csv alongside it, or edit the paths in CONFIG.FUZZY_THRESHOLD to your taste. Start with the default and raise it if the fuzzy pile is too noisy.python3 redirect-map.py and read the summary it prints.redirect-map.csv. Filter to match_type = fuzzy and check every row by hand before any of it becomes a redirect rule.redirect-map.csv has a match_type column for a reason. The exact rows are safe: the same path exists on both sides and the mapping is mechanical. The fuzzy rows are suggestions, nothing more. A high confidence score means the two URLs are similar as strings, which is not the same as similar in meaning: /services/plumbing and /services/planning are one character apart and will score near the top, and shipping that redirect blind sends the wrong readers to the wrong page. Never push the fuzzy rows without a human reading each one.
unmatched.csv is the honest residue: old URLs with no confident new home, each with the best score the script could find so you can see how close it got. These are the ones that need a decision, a hand-picked target, a redirect to a sensible parent, or a deliberate 410 if the content is genuinely gone. The script's job is to shrink that list to the URLs that actually need judgement, not to pretend judgement is not needed.
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.