Automating Screaming Frog with Python Scripts
Running Screaming Frog manually through its GUI is incompatible with any repeatable audit pipeline: there is no scheduled execution, no structured output contract, and no way to react programmatically to rate-limit signals or schema drift. This page shows how to replace that manual step with a Python subprocess controller that generates per-domain configurations, drives the headless Screaming Frog CLI from a locked-down container, streams crawl output for real-time anomaly detection, and pushes normalised Parquet artifacts to cloud storage — all wired into the broader automated crawling pipeline.
Pipeline overview
The diagram below shows the four stages the controller orchestrates, from container boot to artifact upload.
Environment isolation and dependency declaration
Pin every dependency so the crawl is reproducible across CI runners, local machines, and scheduled jobs. Build a minimal Docker image from the Eclipse Temurin JRE (OpenJDK 17) — do not use a JDK image in production; the JRE is ~200 MB lighter. Download the Screaming Frog Linux CLI binary at image build time and inject SF_LICENSE_KEY at runtime via an environment variable, never baked into the image layer.
# Dockerfile — pin both the JRE tag and the Screaming Frog version
FROM eclipse-temurin:17.0.11_9-jre-alpine
RUN apk add --no-cache curl unzip bash
ARG SF_VERSION="21.4"
RUN curl -fsSL \
https://download.screamingfrog.co.uk/products/seo-spider/screamingfrogseospider-${SF_VERSION}.zip \
-o /tmp/sf.zip && \
unzip /tmp/sf.zip -d /opt/screamingfrog && \
chmod +x /opt/screamingfrog/ScreamingFrogSEOSpider && \
rm /tmp/sf.zip
# Licence key injected at runtime — never embed in the image
ENV SF_LICENSE_KEY=""
RUN adduser -D -s /bin/sh crawler
USER crawler
WORKDIR /data
ENTRYPOINT ["/opt/screamingfrog/ScreamingFrogSEOSpider"]
Python-side requirements (requirements.txt, pinned):
pandas==2.2.2
pyarrow==16.0.0
pydantic==2.7.1
boto3==1.34.101
Export these shell variables before running the controller locally or in CI:
export SF_LICENSE_KEY="your-licence-key"
export CRAWL_OUTPUT_DIR="/data/crawls/$(date +%Y%m%d)"
export S3_BUCKET="your-audit-artifacts-bucket"
export S3_PREFIX="screaming-frog"
Implementation
Stage 1 — Headless subprocess controller
The controller wraps the Screaming Frog CLI in subprocess.run. The key flags are --headless (suppresses the AWT display requirement) and --config (points to the per-domain config generated in stage 2). Pass check=True so any non-zero exit code surfaces immediately as a CalledProcessError rather than silently producing an empty artifact.
# crawler/controller.py
import subprocess
import os
from pathlib import Path
def run_headless_crawl(
url: str,
config_path: str,
output_dir: str,
timeout_seconds: int = 3600,
) -> subprocess.CompletedProcess:
"""
Launch ScreamingFrogSEOSpider in headless mode.
Raises subprocess.CalledProcessError on non-zero exit.
Raises subprocess.TimeoutExpired if the crawl exceeds timeout_seconds.
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
# Guard: abort if licence key is absent rather than silently running in demo mode
if not env.get("SF_LICENSE_KEY"):
raise EnvironmentError("SF_LICENSE_KEY is not set — crawl aborted.")
cmd = [
"ScreamingFrogSEOSpider",
"--headless",
"--url", url,
"--config", os.path.abspath(config_path), # absolute path required in CI
"--save-crawl",
"--output-folder", os.path.abspath(output_dir),
"--export-tabs", "Internal:All",
]
return subprocess.run(
cmd,
env=env,
timeout=timeout_seconds,
check=True,
capture_output=True,
text=True,
)
Stage 2 — Dynamic config generation
Static .seospider profiles cannot adapt to per-domain rate limits, JavaScript rendering requirements, or crawl scope changes. Instead, generate a fresh config before each crawl run. When the target is a JavaScript-rendered SPA, set RenderMode = JavaScript — the same rendering decision described in the headless browser configuration guide. For rate-sensitive domains, keep MaxThreads at 5 and lower RateLimit before adding threads; reversing that order triggers WAF blocks before the crawl reaches a meaningful URL sample.
# crawler/config_gen.py
import configparser
import os
from pathlib import Path
def generate_crawl_config(
target_domain: str,
output_dir: str,
rate_limit_per_second: int = 2,
max_threads: int = 5,
js_render: bool = False,
) -> str:
"""
Write a .seospider config to a temp path and return the absolute path.
Overwrites any existing config for this domain to avoid stale settings.
"""
config = configparser.ConfigParser()
config["Crawl"] = {
"StartUrl": f"https://{target_domain}",
"MaxThreads": str(max_threads),
"RateLimit": str(rate_limit_per_second),
"MaxUrls": "0", # 0 = unlimited
"FollowInternalNofollow": "false",
}
config["Spider"] = {
"CheckSitemapLinks": "true",
"CrawlLinkedXMLSitemaps": "true",
}
if js_render:
config["Rendering"] = {
"RenderMode": "JavaScript",
"WaitForNetworkRequests": "true",
"NetworkRequestTimeout": "5000",
}
# Write to a domain-scoped temp path, not /tmp/dynamic.seospider,
# so parallel crawl jobs do not overwrite each other's configs
config_path = os.path.join(output_dir, f"{target_domain}.seospider")
Path(output_dir).mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as fh:
config.write(fh)
return os.path.abspath(config_path)
Stage 3 — Real-time WAF detection via streaming
When managing crawl budget and rate limiting matters, polling stdout line-by-line lets you terminate the crawl the moment a WAF throttle signal appears rather than waiting for the full timeout to expire.
# crawler/stream_runner.py
import subprocess
import signal
import sys
import os
from pathlib import Path
WAF_SIGNALS = ["Rate limit exceeded", "429 Too Many Requests", "503 Service Unavailable"]
def run_with_streaming(
url: str,
config_path: str,
output_dir: str,
timeout_seconds: int = 3600,
) -> int:
"""
Run the crawl with real-time stdout monitoring.
Returns the process exit code.
Sends SIGTERM and returns 1 on WAF detection.
"""
Path(output_dir).mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
cmd = [
"ScreamingFrogSEOSpider",
"--headless",
"--url", url,
"--config", os.path.abspath(config_path),
"--save-crawl",
"--output-folder", os.path.abspath(output_dir),
"--export-tabs", "Internal:All",
]
with subprocess.Popen(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
) as proc:
try:
for line in proc.stdout:
sys.stdout.write(line)
if any(signal_str in line for signal_str in WAF_SIGNALS):
sys.stderr.write(f"[ALERT] WAF signal detected — terminating crawl: {line.strip()}\n")
proc.send_signal(signal.SIGTERM)
return 1
proc.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
proc.kill()
raise
return proc.returncode
def _graceful_exit(signum, frame):
print("[INFO] Received termination signal — flushing and exiting.", flush=True)
sys.exit(0)
signal.signal(signal.SIGINT, _graceful_exit)
signal.signal(signal.SIGTERM, _graceful_exit)
Stage 4 — CSV normalisation and Parquet upload
Raw Screaming Frog exports carry two reliable pitfalls: UTF-8 BOM on the first byte (corrupting the first column name) and column ordering that changes between Spider versions. The normalisation step strips the BOM with encoding='utf-8-sig', validates each row against a Pydantic model, serialises to Parquet for efficient downstream querying, and pushes the artifact to S3. This is the handoff point for storing and versioning crawl artifacts.
# crawler/normalise.py
import pandas as pd
import boto3
import io
import os
from pydantic import BaseModel, Field, ValidationError
from typing import Optional
from botocore.exceptions import ClientError
class CrawlRecord(BaseModel, extra="forbid"):
address: str = Field(alias="Address")
status_code: int = Field(alias="Status Code")
title: Optional[str] = Field(alias="Title 1", default=None)
indexability: str = Field(alias="Indexability")
content_type: Optional[str] = Field(alias="Content Type", default=None)
word_count: Optional[int] = Field(alias="Word Count", default=None)
def normalise_export(csv_path: str) -> pd.DataFrame:
"""
Read a Screaming Frog 'Internal:All' CSV, strip BOM, enforce types,
validate with Pydantic, and return a clean DataFrame.
Raises ValueError listing the first invalid row if schema validation fails.
"""
df = pd.read_csv(
csv_path,
encoding="utf-8-sig", # strips UTF-8 BOM — use 'utf-8' and you get a corrupted header
dtype={"Status Code": "Int64", "Word Count": "Int64"},
)
df.columns = df.columns.str.strip()
records = []
errors = []
for idx, row in df.iterrows():
try:
records.append(CrawlRecord(**row.to_dict()).model_dump())
except ValidationError as exc:
errors.append(f"Row {idx}: {exc.error_count()} validation error(s)")
if errors:
raise ValueError(f"Schema validation failed on {len(errors)} rows:\n" + "\n".join(errors[:5]))
return pd.DataFrame(records)
def upload_parquet(df: pd.DataFrame, bucket: str, key: str) -> None:
"""Serialise df to Parquet in memory and push to S3."""
buf = io.BytesIO()
df.to_parquet(buf, engine="pyarrow", index=False, compression="snappy")
buf.seek(0)
client = boto3.client("s3")
try:
client.put_object(Bucket=bucket, Key=key, Body=buf.getvalue())
except ClientError as exc:
raise RuntimeError(
f"S3 upload to s3://{bucket}/{key} failed: "
f"{exc.response['Error']['Message']}"
) from exc
Verification and smoke test
After a first run, confirm the pipeline succeeded at each stage:
# 1 — container health: licence validated, no X11 errors
docker run --rm \
-e SF_LICENSE_KEY="${SF_LICENSE_KEY}" \
your-sf-image:21.4 --version
# Expected: ScreamingFrogSEOSpider 21.4 ... License: Valid (CLI Mode)
# 2 — config present and non-empty
test -s "${CRAWL_OUTPUT_DIR}/example.com.seospider" && echo "Config OK"
# 3 — crawl artifacts exist
ls -lh "${CRAWL_OUTPUT_DIR}/"
# Expected: internal_all.csv crawl.seospider cli.log
# 4 — row count sanity check (replace 500 with your expected minimum)
python3 - <<'EOF'
import pandas as pd, sys
df = pd.read_csv("/data/crawls/$(date +%Y%m%d)/internal_all.csv", encoding="utf-8-sig")
assert len(df) >= 500, f"Only {len(df)} rows — crawl likely incomplete"
assert df["Address"].notna().all(), "Null addresses detected"
assert df["Status Code"].notna().all(), "Missing status codes"
print(f"OK — {len(df)} URLs with clean schema")
EOF
# 5 — Parquet artifact in S3
aws s3 ls "s3://${S3_BUCKET}/${S3_PREFIX}/" | grep ".parquet"
Expected output from step 3 after a complete crawl:
-rw-r--r-- 1 crawler crawler 2.4M Jun 21 09:42 internal_all.csv
-rw-r--r-- 1 crawler crawler 12K Jun 21 09:42 crawl.seospider
-rw-r--r-- 1 crawler crawler 88K Jun 21 09:43 cli.log
Failure signal: If cli.log contains java.awt.HeadlessException, the --headless flag was dropped or overridden. If it contains License: Demo Mode, SF_LICENSE_KEY was not injected into the container environment.
Failure modes
java.awt.HeadlessException at startup
Cause: The --headless flag is absent, or a wrapper script is stripping it before calling the binary.
Diagnostic:
grep -n "headless" your_launch_script.py
Fix: Add --headless as the first positional argument after the binary name. Verify it appears in the cmd list logged by the controller.
KeyError: 'Address' when reading the CSV
Cause: The export was read with encoding='utf-8' instead of encoding='utf-8-sig'. The BOM prefix () is prepended to the first column name, producing 'Address'.
Diagnostic:
import pandas as pd
df = pd.read_csv("internal_all.csv", encoding="utf-8", nrows=0)
print(repr(df.columns[0])) # expect 'Address' if BOM is present
Fix: Change the read_csv call to encoding='utf-8-sig'.
Parallel crawl jobs overwrite each other's configs
Cause: Both jobs write to the same /tmp/dynamic.seospider path, so the second job's config overwrites the first mid-crawl.
Diagnostic: Check the container's /tmp directory for a single .seospider file when two crawls should be running concurrently.
Fix: Use domain-scoped config paths as shown in stage 2: os.path.join(output_dir, f"{target_domain}.seospider"). Each crawl job should write to its own output directory.
FAQ
Why does Screaming Frog silently crawl in demo mode (500 URL cap) even with a valid licence?
The CLI only reads SF_LICENSE_KEY from the process environment, not from a config file. Confirm the variable is actually present inside the container:
docker run --rm -e SF_LICENSE_KEY="${SF_LICENSE_KEY}" your-sf-image:21.4 \
bash -c 'echo "Key length: ${#SF_LICENSE_KEY}"'
If the length is 0, the --env flag was applied to the wrong command or the variable is unset in the calling shell.
How do I prevent a second scheduled crawl from starting while one is already running?
Write the subprocess PID to a lockfile and check for it before launch. When setting up a cron job for weekly site crawls this pattern prevents overlapping crawl runs from exhausting memory:
import os, sys
from pathlib import Path
LOCKFILE = Path("/data/crawls/.crawl.lock")
if LOCKFILE.exists():
pid = LOCKFILE.read_text().strip()
sys.exit(f"Crawl already running (PID {pid}) — lockfile at {LOCKFILE}")
LOCKFILE.write_text(str(os.getpid()))
try:
# ... run crawl ...
pass
finally:
LOCKFILE.unlink(missing_ok=True)What is the safest way to pass the licence key inside a GitHub Actions workflow?
Store the key as an Actions secret (SF_LICENSE_KEY) and inject it via the env: block on the crawl step only — not at the job level — so it is not exposed to unrelated steps:
- name: Run Screaming Frog crawl
env:
SF_LICENSE_KEY: ${{ secrets.SF_LICENSE_KEY }}
run: python3 -m crawler.controllerHow do I retain only the last N crawl artifacts without a manual cleanup step?
Use an S3 Lifecycle policy to expire objects older than your retention window, or tag each upload with the crawl date and run a prefixed list-and-delete step. See storing and versioning crawl artifacts in cloud storage for a complete retention and versioning pattern including S3 object versioning and Glacier transition rules.
Related
- Configuring Headless Browsers for JS-Heavy Sites — parent: headless rendering, SPA crawl configuration, and Puppeteer/Playwright setup
- Managing Crawl Budget and Rate Limiting — rate-limit controls and crawl budget strategies that complement the per-domain config generated here
- Storing and Versioning Crawl Artifacts in Cloud Storage — artifact retention, S3 versioning, and downstream pipeline ingestion
- Setting Up a Cron Job for Weekly Site Crawls — scheduling the Python controller on a recurring cadence with concurrency guards