A useful Python SEO script finishes one small job: checking a URL list, comparing two exports or turning saved HTML into a review sheet. It should also tell you what it could not check. A timeout is not a 404, a missing export row is not zero traffic, and an empty image alt attribute is not automatically a mistake.
These nine scripts produce CSV files you can inspect before changing anything. They cover HTTP responses, sitemaps, page metadata, image descriptions, internal links, Search Console exports and proposed redirects. Each includes the complete code, required input and a command to run it.
| Task | Script | Input | Output |
|---|---|---|---|
| Check first-hop responses | 1 | A small URL list | Status, Location or explicit failure |
| Extract sitemap page URLs | 2 | One downloaded page sitemap | URL rows, preserving duplicates |
| Find sitemap additions/removals | 3 | Two URL lists | Exact set differences |
| Inventory metadata | 4 | HTML files and their URLs | Head tags and counts |
| Review repeated titles | 5 | Script 4's metadata CSV | Groups across distinct URLs |
| Review image alt attributes | 6 | HTML snapshots | Missing, empty and nonempty states |
| Export internal links | 7 | HTML snapshots | Source-to-destination edges |
| Compare page performance | 8 | Two matched Search Console exports | Click changes and recalculated CTR |
| Inspect a redirect proposal | 9 | Source/target mappings | Conflicts, chains and cycles |
The code is an original set of small examples, not a full crawler or an SEO scoring system. The sample domains and numbers are fictional. Only script 1 sends network requests; the others work on files you supply. None publishes content, changes redirects or guarantees indexing or rankings.
Prepare the files and shared CSV helper
Use a currently supported Python 3 release in a new working directory. Create an isolated environment using the Python Packaging guide:
python -m venv .venv
Use that environment's interpreter for installation and every command below. On Windows it is .venv\Scripts\python.exe; on macOS/Linux it is .venv/bin/python. For example, on Windows:
.venv\Scripts\python.exe -m pip install requests beautifulsoup4
The later commands use python as shorthand for that same interpreter. No paid API credentials are required. The file-processing examples still depend on the quality and coverage of your exports.
Save this shared plumbing as seo_io.py beside the nine script files. It uses Python's CSV reader and writer, checks required headers and creates new output files instead of overwriting existing ones. It is a dependency, not a tenth SEO task.
import csv
from pathlib import Path
from urllib.parse import urlsplit
def read_csv(path, required):
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
names = reader.fieldnames or []
if len(names) != len(set(names)) or not set(required) <= set(names):
raise ValueError(f"Required unique headers: {required}")
rows = []
for row in reader:
if None in row or any(row[k] is None for k in required):
raise ValueError("Malformed CSV row")
rows.append(row)
if len(rows) > 2000:
raise ValueError("Use a sample of at most 2,000 rows")
return rows
def write_csv(path, fields, rows):
with open(path, "x", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
def http_url(value):
parts = urlsplit(value)
parts.port # Reject malformed port values.
if (parts.scheme not in {"http", "https"} or not parts.hostname
or parts.username is not None or parts.password is not None
or any(c.isspace() for c in value)):
raise ValueError(f"Expected an absolute HTTP(S) URL: {value!r}")
return value
def html_pages():
from bs4 import BeautifulSoup
for row in read_csv("pages.csv", ["url", "html_file"]):
url = http_url(row["url"])
path = Path(row["html_file"])
if path.stat().st_size > 2_000_000:
raise ValueError("Use HTML snapshots below 2 MB")
yield url, BeautifulSoup(path.read_text(encoding="utf-8"), "html.parser")
Inputs are trusted local files, not uploads from strangers. Use comma-delimited UTF-8 CSVs with the exact headers shown. Treat URL strings literally: these examples do not declare /page, /page/ or different query strings equivalent. Import result columns as text when opening CSVs in a spreadsheet, so source values beginning with formula characters are not executed.
For scripts 4, 6 and 7, save known successful HTML responses as UTF-8 files. Create this manifest as pages.csv:
url,html_file
https://www.example.com/services/,services.html
https://www.example.com/guides/,guides.html
Use the final response URL for each snapshot. Do not label a saved login screen or error page as the intended article. These examples parse HTML with Beautiful Soup; they do not execute JavaScript. Keep the input files and capture dates so you can explain a result later. Start each rerun in a new output directory, or choose a new output filename in the script.
1. Check first-hop HTTP status codes
Use this for a short list of important URLs, such as pages changed during a release. Save urls.csv with a url header and at most 50 rows. Run only against destinations you are authorized to check, respecting their access and crawl rules.
Save as status_codes.py:
import time
import requests
from seo_io import read_csv, write_csv, http_url
rows = read_csv("urls.csv", ["url"])
if len(rows) > 50:
raise ValueError("This example accepts at most 50 URLs")
results = []
paused = False
for row in rows:
item = {"url": row["url"], "status": "", "location": "", "state": ""}
if paused:
item["state"] = "not_checked_after_rate_limit_or_unavailable"
else:
try:
url = http_url(row["url"])
with requests.get(url, allow_redirects=False, stream=True,
timeout=(5, 15),
headers={"User-Agent": "SmallSEOCheck/1.0"}) as r:
item.update(status=r.status_code,
location=r.headers.get("Location", ""),
state="observed_first_response")
paused = r.status_code in {429, 503}
except ValueError:
item["state"] = "invalid_url"
except requests.RequestException as exc:
item["state"] = type(exc).__name__
time.sleep(1)
results.append(item)
write_csv("status_results.csv", ["url", "status", "location", "state"], results)
python status_codes.py
A row with 301 and /new-page/ reports the first response's relative Location value. It does not follow the destination. This avoids hiding a redirect behind a final 200. A failed request retains an error state rather than becoming a fabricated status code.
Requests documents redirect handling and timeouts; the timeout here is not a deadline for the entire run. The script pauses between requests, performs no retries and stops making new requests after 429 or 503. Investigate access restrictions instead of changing identity to evade them.
Check one result against the actual response. A 403 does not prove Googlebot is blocked; a 200 does not prove the expected page exists, has an appropriate canonical or can be indexed. For a browser-based alternative to this small script, use Screpy's Bulk HTTP Status Checker and inspect its reported scope.
2. Extract page URLs from a downloaded sitemap
A sitemap's image and language extensions can contain additional URLs. Extracting every element named loc can therefore mix page URLs with other resources. This example selects only the direct page-location elements in the sitemap namespace.
Save one trusted, downloaded page sitemap as sitemap.xml, then save this as sitemap_urls.py:
from pathlib import Path
import xml.etree.ElementTree as ET
from seo_io import http_url, write_csv
path = Path("sitemap.xml")
if path.stat().st_size > 2_000_000:
raise ValueError("Use a small sitemap below 2 MB")
root = ET.fromstring(path.read_bytes())
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
if root.tag != "{" + ns["s"] + "}urlset":
raise ValueError("Expected a namespaced urlset, not a sitemap index")
entries = root.findall("s:url", ns)
if len(entries) > 2000:
raise ValueError("Use a sample with at most 2,000 page entries")
rows = []
for number, entry in enumerate(entries, 1):
loc = entry.findall("s:loc", ns)
if len(loc) != 1 or not (loc[0].text or "").strip():
raise ValueError(f"Entry {number} needs one nonempty page loc")
rows.append({"row": number, "url": http_url(loc[0].text.strip())})
write_csv("sitemap_urls.csv", ["row", "url"], rows)
print(f"Exported {len(rows)} page entries; duplicates are retained")
python sitemap_urls.py
The output is a page inventory. Duplicate entries remain visible, and XML entity escapes such as & are decoded by the parser. If the file is a sitemap index, download the relevant child page sitemap and run the script on that file. It deliberately does not recursively fetch children.
Python's ElementTree documentation explains namespace-aware parsing. This bounded extractor is not a full sitemap validator: it does not inspect every extension, date, protocol constraint or listed page. Google's sitemap guidance remains the reference for publishing a sitemap; inclusion does not confirm indexing.
3. Compare two sitemap URL lists
After an update, compare the previous and current inventories. Copy the relevant script 2 outputs to before.csv and after.csv, keeping their url headers. You can also use other inventories with that header when their coverage is comparable.
Save as sitemap_changes.py:
from seo_io import read_csv, write_csv, http_url
def url_set(path):
rows = read_csv(path, ["url"])
urls = [http_url(row["url"]) for row in rows]
print(f"{path}: {len(urls)} rows, {len(set(urls))} unique URLs")
return set(urls)
before = url_set("before.csv")
after = url_set("after.csv")
changes = ([{"change": "removed", "url": u} for u in sorted(before - after)]
+ [{"change": "added", "url": u} for u in sorted(after - before)])
write_csv("sitemap_changes.csv", ["change", "url"], changes)
python sitemap_changes.py
For a fictional before list containing /old/ and /keep/, and an after list containing /keep/ and /new/, expect one removal and one addition. The script compares exact absolute URLs; it does not collapse trailing slashes, remove parameters or declare two URL variants equivalent.
A removal means the URL disappeared from this inventory. It does not mean the page was deleted or deindexed. Before treating a large difference as a release problem, check whether both files cover the same child sitemaps, locale and publication states. This set comparison also does not report changes in duplicate counts; script 2 retains the rows you need for that separate inspection.
4. Extract metadata from saved HTML pages
Use the pages.csv manifest from the setup section. This creates a repeatable inventory without requesting each page again every time you change an extraction rule.
Save as metadata.py:
import json
from seo_io import html_pages, write_csv
rows = []
for url, soup in html_pages():
head = soup.find("head")
titles, descriptions, canonicals, robots = [], [], [], []
if head is not None:
titles = [t.get_text(" ", strip=True) for t in head.find_all("title")]
for tag in head.find_all("meta"):
name = (tag.get("name") or "").strip().lower()
if name == "description":
descriptions.append(tag.get("content", ""))
if name in {"robots", "googlebot"}:
robots.append({"name": name, "content": tag.get("content", "")})
for tag in head.find_all("link"):
rel = [str(x).lower() for x in tag.get("rel", [])]
if "canonical" in rel:
canonicals.append(tag.get("href", ""))
rows.append({"url": url, "state": "head_found" if head else "missing_head",
"title_count": len(titles), "titles": json.dumps(titles),
"descriptions": json.dumps(descriptions),
"canonicals": json.dumps(canonicals),
"robots": json.dumps(robots)})
write_csv("metadata.csv", ["url", "state", "title_count", "titles",
"descriptions", "canonicals", "robots"], rows)
python metadata.py
Lists are encoded as JSON inside CSV cells, so multiple tags remain visible rather than being reduced to whichever tag happened to appear first. An absent head is labelled missing_head. A canonical value is kept as written, making a relative value or missing href available for review.
This is HTML-head evidence, not an indexability verdict. It does not capture HTTP Link or X-Robots-Tag headers, interpret robots.txt, execute JavaScript or report Google's selected canonical. Compare important findings with current canonical guidance and robots-directive documentation.
Inspect one known page with two title tags to confirm both survive extraction. If your application inserts important tags only after JavaScript runs, obtain an appropriate rendered snapshot and identify it as such; do not silently treat raw HTML and rendered HTML as identical evidence. For a single live page, Screpy's Meta Tag Analyzer provides a separate inspection route.
5. Group duplicate page titles
The metadata inventory shows individual pages. This script answers a different question: which distinct URLs share the same title after whitespace and case normalization?
Save as duplicate_titles.py and use the metadata.csv produced by script 4:
import json
from collections import defaultdict
from seo_io import read_csv, write_csv
rows = read_csv("metadata.csv", ["url", "state", "titles"])
groups = defaultdict(set)
seen = set()
results = []
for row in rows:
if row["url"] in seen:
raise ValueError("Supply one metadata record per URL")
seen.add(row["url"])
titles = json.loads(row["titles"])
if not isinstance(titles, list) or not all(isinstance(t, str) for t in titles):
raise ValueError("Expected a JSON list of title strings")
valid = row["state"] == "head_found" and len(titles) == 1 and titles[0].strip()
title = titles[0] if valid else ""
key = " ".join(title.casefold().split()) if valid else ""
if valid:
groups[key].add(row["url"])
results.append({"url": row["url"], "title": title, "group_key": key,
"group_size": "", "state": ""})
for item in results:
if not item["group_key"]:
item["state"] = "not_assessed_missing_or_multiple_title"
else:
size = len(groups[item["group_key"]])
item.update(group_size=size, state="repeated_title" if size > 1 else "unique_in_sample")
write_csv("title_groups.csv", ["url", "title", "group_key", "group_size", "state"], results)
python duplicate_titles.py
Our Services and our services form one review group. The script does not strip brand suffixes, stem words or infer semantic duplication. Pages with missing or multiple titles remain explicit unassessed rows.
Google recommends descriptive, distinct titles, but this report cannot decide whether two pages should be merged. Read their purposes and content first. A repeated title is an editorial investigation, not proof of a ranking penalty. After changing the relevant template or content, make fresh snapshots and rerun the same check.
6. Separate missing and empty image alt attributes
A missing alt attribute and alt="" are different states. The latter can be appropriate for a decorative image; deciding that role requires context. This script inventories what the supplied HTML actually contains.
Save as image_alt.py and reuse pages.csv:
from seo_io import html_pages, write_csv
results = []
for url, soup in html_pages():
for number, image in enumerate(soup.find_all("img"), 1):
if not image.has_attr("alt"):
state = "missing_attribute"
alt = ""
else:
alt = image.get("alt") or ""
state = "empty_or_whitespace" if not alt.strip() else "nonempty"
results.append({"page": url, "image_number": number,
"src": image.get("src", ""),
"srcset": image.get("srcset", ""),
"alt": alt, "state": state})
write_csv("image_alt.csv", ["page", "image_number", "src", "srcset", "alt", "state"], results)
python image_alt.py
A fictional page containing <img src="chart.png">, <img src="border.png" alt=""> and <img src="shop.jpg" alt="Shop entrance"> should produce three rows with three different states. Repeated images remain separate occurrences so you can locate them in their page context.
The W3C decorative-image guidance explains why empty alt can be intentional. Inspect the actual image and its function before writing a description. A nonempty value is not automatically accurate or accessible, and a filename does not establish what the image shows.
The script does not inspect CSS backgrounds, select the browser's responsive-image resource, render lazy-loaded images or fetch the image assets. Use it to assemble a review list, then verify the important cases visually. Screpy's Image Alt Text Checker can help inspect a live page when that is the task you need.
7. Export internal link edges from HTML snapshots
This creates a source-to-destination file you can join to a page inventory. It records actual anchor links in the supplied snapshots, not recommendations generated from URL names.
Save as internal_links.py. Change ORIGIN to your exact approved site origin and reuse pages.csv:
from urllib.parse import urlsplit, urljoin, urldefrag
from seo_io import html_pages, http_url, write_csv
ORIGIN = "https://www.example.com"
def origin(url):
p = urlsplit(http_url(url))
return p.scheme, p.hostname, p.port or (443 if p.scheme == "https" else 80)
approved = origin(ORIGIN)
results = []
skipped = 0
for source, soup in html_pages():
if origin(source) != approved:
raise ValueError("Manifest contains a page outside the approved origin")
head = soup.find("head")
base_tag = head.find("base", href=True) if head else None
base = urljoin(source, base_tag["href"]) if base_tag else source
http_url(base)
for number, link in enumerate(soup.find_all("a", href=True), 1):
try:
target = urldefrag(urljoin(base, link["href"].strip()))[0]
internal = origin(target) == approved
except ValueError:
internal = False
if not internal:
skipped += 1
continue
results.append({"source": source, "target": target,
"link_number": number,
"anchor": link.get_text(" ", strip=True),
"rel": " ".join(link.get("rel", []))})
write_csv("internal_edges.csv", ["source", "target", "link_number", "anchor", "rel"], results)
print(f"Skipped {skipped} external, non-HTTP or invalid destinations")
python internal_links.py
With <base href="/docs/">, a link to setup/?mode=quick#install resolves to https://www.example.com/docs/setup/?mode=quick. The query remains; the fragment is removed. The file also retains repeated links, anchor text and rel values rather than declaring every observed link followed or useful.
Python's URL parsing documentation describes URL joining and its boundaries. This example permits only the configured origin in its output, including scheme and effective port. HTTP versus HTTPS and subdomains are different origins.
Google's link documentation explains the importance of anchor href markup. This extraction does not execute JavaScript, check target availability or prove site-wide incoming-link coverage. A URL with no incoming edge in these few snapshots is not a proven orphan page. To inspect one fetched page's outgoing links, Screpy's Internal Link Analyzer offers a related workflow.
8. Compare matched Search Console page exports
Prepare two equal-length periods with the same property, search type, country, device and other filters. Use one row per page, and rename the columns to url,clicks,impressions. Save the files as gsc_before.csv and gsc_after.csv. Supply unformatted nonnegative integer counts, not percentages or abbreviated values.
Save as gsc_changes.py:
from seo_io import read_csv, write_csv, http_url
def load(path):
pages = {}
for row in read_csv(path, ["url", "clicks", "impressions"]):
url = http_url(row["url"])
if url in pages:
raise ValueError("Expected one row per page, not duplicate keys")
values = []
for field in ["clicks", "impressions"]:
value = row[field].strip()
if not value.isascii() or not value.isdigit():
raise ValueError(f"{path}: {field} must be a raw integer count")
values.append(int(value))
clicks, impressions = values
if clicks > impressions:
raise ValueError("Review inconsistent clicks/impressions")
pages[url] = {"clicks": clicks, "impressions": impressions}
return pages
def ctr(row):
return round(100 * row["clicks"] / row["impressions"], 4) if row["impressions"] else ""
before, after = load("gsc_before.csv"), load("gsc_after.csv")
results = []
for url in sorted(before.keys() | after.keys()):
old, new = before.get(url), after.get(url)
matched = old is not None and new is not None
results.append({"url": url,
"before_clicks": old["clicks"] if old is not None else "",
"after_clicks": new["clicks"] if new is not None else "",
"click_delta": new["clicks"] - old["clicks"] if matched else "",
"before_ctr_percent": ctr(old) if old is not None else "",
"after_ctr_percent": ctr(new) if new is not None else "",
"state": "matched" if matched else "missing_from_one_export"})
write_csv("gsc_changes.csv", ["url", "before_clicks", "after_clicks", "click_delta",
"before_ctr_percent", "after_ctr_percent", "state"], results)
python gsc_changes.py
For fictional counts of 20 clicks from 400 impressions followed by 12 from 300, expect click_delta=-8, previous CTR 5.0 and current CTR 4.0, expressed as percentages. The script recalculates CTR from counts rather than averaging exported percentages.
An unmatched URL receives a missing-data state, not zero clicks. Duplicate page keys stop the run because they could indicate a query-by-page export or another incompatible grain. Do not combine those rows without first deciding what the dimensions mean.
There is a further source limitation: Google's Performance report documentation notes that some unavailable values display as zeros in downloaded data. This script cannot reconstruct that lost distinction. Confirm the report's completeness and original presentation before interpreting zeros.
Sort matched rows by negative click delta to choose investigations. The result does not explain causation: changed queries, positions, seasonality or a changed page can require separate evidence. Use the Search Console MCP guide if you need a connected analysis workflow rather than these local CSV comparisons.
9. Find conflicts and loops in a redirect map
Use this before implementing a proposed migration map. Create redirects.csv with source,target columns containing absolute HTTP(S) URLs without fragments. This checks the map's graph; it does not decide which new page best answers an old page's purpose.
Save as redirect_map.py:
import json
from collections import defaultdict
from urllib.parse import urlsplit
from seo_io import read_csv, write_csv, http_url
graph = defaultdict(list)
for row in read_csv("redirects.csv", ["source", "target"]):
source, target = http_url(row["source"]), http_url(row["target"])
if urlsplit(source).fragment or urlsplit(target).fragment:
raise ValueError("Remove fragments after reviewing the mapping")
graph[source].append(target)
results = []
for source, raw_targets in sorted(graph.items()):
flags = set()
if len(raw_targets) > 1:
flags.add("duplicate_source_rows")
current, steps, visited = source, 0, set()
while current in graph:
if current in visited:
flags.add("cycle")
break
visited.add(current)
choices = set(graph[current])
if len(choices) != 1:
flags.add("conflicting_targets_at_" + current)
break
target = next(iter(choices))
if target == current:
flags.add("self_redirect")
current = target
steps += 1
if steps > 1:
flags.add("multi_step_chain")
results.append({"source": source, "targets": json.dumps(sorted(set(raw_targets))),
"input_rows": len(raw_targets), "steps_followed": steps,
"stopped_at": current,
"findings": "; ".join(sorted(flags)) or "no_graph_issue_observed"})
write_csv("redirect_review.csv", ["source", "targets", "input_rows", "steps_followed",
"stopped_at", "findings"], results)
python redirect_map.py
A fictional /a/ → /b/ → /c/ sequence is a multi-step chain. /a/ → /b/ → /a/ is a cycle. Two rows mapping the same source to different targets produce a conflict, and traversal stops at that source rather than choosing one target silently.
Review the findings against the intended migration behavior. This file has no knowledge of server-rule precedence, wildcard redirects, locale logic, target content or deployed HTTP responses. An acyclic graph can still send readers to the wrong page.
Google's redirect documentation explains redirect behavior and selection. After an approved implementation, inspect actual responses with script 1 and separately verify the complete redirect path and final content. Keep the proposed map and the observed live behavior as different records.
Review one result before scheduling a script
The examples were exercised with small fictional files and a temporary local HTTP server. Those checks confirmed the following outputs; they are not measurements of a customer website or evidence of SEO improvement.
| Example input | Observed result |
|---|---|
A local 301 response with Location: /200 |
Script 1 retained 301 and the location without following it |
| A sitemap with two identical page entries and an image-extension URL | Script 2 exported two page rows and excluded the image URL |
| Two URLs with whitespace/case variants of one title | Script 5 produced one two-URL title group |
| 20/400 followed by 12/300 clicks/impressions | Script 8 returned -8 clicks and 5% → 4% CTR |
| A two-node redirect cycle | Script 9 reported a cycle instead of looping indefinitely |
Start your own run with a few records whose answers you know. Check both a normal case and a meaningful exception: a missing file, an incompatible export, a deliberately empty alt attribute or a conflicting redirect source. Reconcile output rows with inputs and inspect any skipped or unassessed state before expanding the sample.
If Python reports a missing module, confirm that installation and execution use the same interpreter. If a required-header error appears, inspect the CSV's actual column names and delimiter. If an output already exists, keep it as evidence and run in a fresh directory or change the output filename; do not overwrite the source to make the error disappear.
For repeated use, retain the script version, input capture dates, filters and outputs together. Choose the next action from a reviewed finding. A metadata inventory can support an editorial fix; it cannot authorize a site-wide rewrite on its own.
When the job needs scheduled triggers, stored issue states and notifications, the n8n SEO workflow guide covers that different layer. Keep the small Python check useful and understandable before turning it into an unattended process.