The SEO Bench
Redirect map builder: match old URLs to new ones before a migration
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.
Before you start
- Export two single-column CSVs:
old-urls.csv(the URLs that are going away) andnew-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. - Full URLs or paths both work. The script compares normalised paths, so
https://old.example.com/about/and/aboutmatch. - Decide your fuzzy threshold. The default of
0.65is deliberately cautious. Raise it towards0.8for a cleaner, smaller set of confident guesses; lower it only if you intend to review every row anyway.
The script
#!/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()
Set it up
- Save the script as
redirect-map.pyin a folder with your two CSVs. No packages to install: it uses only the Python 3 standard library. - Put
old-urls.csvandnew-urls.csvalongside it, or edit the paths inCONFIG. - Set
FUZZY_THRESHOLDto your taste. Start with the default and raise it if the fuzzy pile is too noisy. - Run
python3 redirect-map.pyand read the summary it prints. - Open
redirect-map.csv. Filter tomatch_type = fuzzyand check every row by hand before any of it becomes a redirect rule.
How to read the output
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.