Detailed commands and recipes for each phase. Read this when you're implementing a specific step. Parent SKILL.md has the workflow, gotchas, and output structure.
Fetch the index, then any child sitemaps it references, then concatenate page URLs into urls.txt. Adjust grep on the <loc> extraction if the source uses non-Yoast naming.
mkdir -p output
ROOT=https://example.com # source site origin
curl -s -A "Mozilla/5.0" "$ROOT/sitemap_index.xml" \
| tee output/sitemap_index.xml \
| grep -oE '<loc>[^<]+</loc>' \
| sed -E 's|</?loc>||g' \
| grep -v 'image-sitemap' \
> child-sitemaps.txt
while read -r child; do
name=$(basename "$child")
curl -s -A "Mozilla/5.0" "$child" -o "output/$name"
grep -oE '<loc>[^<]+</loc>' "output/$name" \
| sed -E 's|</?loc>||g'
done < child-sitemaps.txt | sort -u > urls.txt
# Yoast XSL (purely so sitemaps render in browser; search engines parse XML directly)
mkdir -p output/wp-content/plugins/wordpress-seo/css
curl -s -A "Mozilla/5.0" \
"$ROOT/wp-content/plugins/wordpress-seo/css/main-sitemap.xsl" \
-o output/wp-content/plugins/wordpress-seo/css/main-sitemap.xslSingle invocation over urls.txt. The browser UA + Accept / Accept-Language headers are mandatory — Cloudflare's bot protection 403s the default Wget/1.x UA.
wget -p -k -E -nH \
--restrict-file-names=unix \
-e robots=off \
-U "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" \
--header="Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
--header="Accept-Language: en,*;q=0.5" \
--wait=0.3 --random-wait \
-i urls.txt -P output/Flags:
-ppage requisites (CSS / JS / images / fonts referenced from HTML)-kconvert links to local-Eadd.htmlextension;/foo/→foo/index.html-nHskip the host directory wrapper--restrict-file-names=unixkeep filenames clean on macOS / Linux-e robots=offignore robots.txt--wait=0.3 --random-waitpaces requests so Cloudflare doesn't escalate to 403 partway through
If a non-English source has localised content negotiation, swap Accept-Language accordingly. Language doesn't matter to anything else in the pipeline (the runtime-strip and reply-link regexes match by class, not text).
wget -p follows <script src>, <link rel="stylesheet">, <img src> / srcset, <source>, etc. It does not follow og:image, apple-touch-icon, JSON-LD image / logo, msapplication-TileImage, or <link rel="modulepreload">. Audit and fetch the long tail across all three asset roots:
SRC=example.com
grep -rhoE "https?://$SRC/wp-content/(uploads|themes|plugins)/[^\"<'> ]*" output/ \
| sort -u > missing.txt
wget -x -nH -e robots=off \
-U "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ..." \
-i missing.txt -P output/-x (force directories) is important so files land at their original paths inside wp-content/....
import pathlib
p = pathlib.Path('output/wp-content/plugins/wordpress-seo/css/main-sitemap.xsl')
old = ('Generated by <a href="https://yoa.st/1y5" target="_blank" '
'rel="noopener">Yoast SEO</a>, this is an XML Sitemap, meant '
'for consumption by search engines.')
new = ('Generated statically and deployed via a static host, this is an XML '
'Sitemap, meant for consumption by search engines.')
p.write_text(p.read_text(encoding='utf-8').replace(old, new), encoding='utf-8')Leave the <a href="https://sitemaps.org"> link further down — it's still factually correct.
WordPress's Plausible plugin proxies the script through /wp-content/uploads/<hash>/pa-XXX.js and posts events back to /wp-json/.../v1/.... Replace the entire two-script block with the standard tracker:
import pathlib, re
PATTERN = re.compile(
r'<script[^>]*id="plausible-analytics-js"></script>\s*'
r'<script id="plausible-analytics-js-after">.*?</script>',
re.DOTALL,
)
REPLACEMENT = (
'<script defer data-domain="example.com" '
'src="https://plausible.io/js/script.js"></script>'
)
for path in pathlib.Path('output').rglob('*.html'):
text = path.read_text(encoding='utf-8')
new = PATTERN.sub(REPLACEMENT, text)
if new != text:
path.write_text(new, encoding='utf-8')Then delete the now-unused proxy file at output/wp-content/uploads/<hash>/pa-*.js.
After scripts/strip-wp-runtime.py and scripts/selfhost-gravatars.py, audit for any source-domain URLs left:
SRC=example.com
grep -hE "https?://$SRC/" --include="*.html" -r output/ \
| grep -vE 'rel="canonical"|og:url|"@id"|"url"|"item"|wp-json/oembed' \
| sort -uAnything left after the canonical / JSON-LD allow-list is something to triage. Common offenders:
- Author archive links (
<a class="wp-block-post-author-name__link" href=".../author/<slug>/">). On single-author sites WordPress often 301s these to the homepage. Probe withcurl -sI -o /dev/null -w "%{redirect_url}\n" <author-url>. If they redirect to/, strip the<a>wrapper and keep the inner text — don't waste a directory on a duplicate of the home page. modulepreloadabsolute URLs (<link rel="modulepreload" href="https://<domain>/wp-includes/...">). Convert to root-relative and fetch the underlying file (wget-pdoesn't followmodulepreload).- Server-rendered iframes pointing at custom PHP endpoints (e.g.
<iframe src="/<custom>/<script>.php">). These won't render statically. Drop the wrapping<p>. - Gravity Forms script blocks — wrappers have inline
<script>tags (noid, just bare<script>tags referencinggform.*) above and below the form, plus an in-<head>polyfill. Strip them all — match<script>...</script>whose body mentionsgform. Otherwise the page errors at load whengformis undefined. gform_not_foundparagraphs — pre-existing brokenness on the live site (failed shortcode render). Strip the paragraph and any orphaned lead-in heading.- Page archive pagination anchors (
<a href=".../page/N/">). The/page/N/URLs aren't in the sitemap, so the targets don't exist. Strip the link, keep the anchor text only.
ROOT=https://example.com
curl -s -A "Mozilla/5.0" "$ROOT/robots.txt" -o output/robots.txt
# If you flatten output/* into the repo root, the sitemap reference is fine.
# If you keep things under output/ and configure the host accordingly, leave alone.
# If the deployed sitemap path differs, edit:
# sed -i.bak -E 's|^Sitemap: .*$|Sitemap: https://new.example.com/sitemap_index.xml|' output/robots.txt
# (Delete the .bak after — `-i.bak` is the portable form that works on both BSD and GNU sed.)cd output && python3 -m http.server 8765 &
PORT=8765
# 1. Every URL in urls.txt resolves
while read -r url; do
path=$(echo "$url" | sed -E 's|^https?://[^/]+||; s|/?$|/|')
code=$(curl -sI -o /dev/null -w "%{http_code}" "http://localhost:$PORT$path")
[ "$code" = "200" ] || echo "MISS $code $path"
done < ../urls.txt
# 2. No remaining absolute source-domain URLs outside the allow-list
grep -hE "https?://example\.com/" --include="*.html" -r . \
| grep -vE 'rel="canonical"|og:url|"@id"|"url"|"item"|wp-json/oembed' \
| head
# 3. Spider-crawl for broken internal links
wget --spider -r -nd --no-parent -l 5 "http://localhost:$PORT/" 2>&1 \
| grep -E '404|broken'Then open the homepage and one deep page in a browser. Watch srcset images, sidebar widgets, the header banner — those break silently if missed.
Pick the recipe matching the target host from Phase 0.
Defaults: no build command, no build output directory, no root directory. Pages serves the repo root.
If the site has any URL changes (slug changes, dropped /page/N/ archive pagination, redirect from /category/<term>/ to a flat /blog/, etc.), add _redirects at the repo root:
/old-path/ /new-path/ 301
/category/* /blog/ 301
Long-cache the hashed assets via _headers:
/wp-content/*
Cache-Control: public, max-age=31536000, immutable
/wp-includes/*
Cache-Control: public, max-age=31536000, immutable
Push to GitHub and Pages auto-deploys. gh repo create <user>/<repo> --private --source=. --push works.
Same _redirects and _headers syntax as Cloudflare Pages. Optional netlify.toml:
[build]
publish = "."Connect the repo via the Netlify dashboard.
vercel.json at the repo root:
{
"redirects": [
{ "source": "/old-path/", "destination": "/new-path/", "permanent": true }
],
"headers": [
{
"source": "/wp-content/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
],
"cleanUrls": false,
"trailingSlash": true
}trailingSlash: true matches WordPress's default URL shape (and what wget -E produces). If the source uses no trailing slashes, set to false.
server {
listen 80;
server_name example.com;
root /var/www/wp-static-clone;
index index.html;
# Trailing-slash URLs map to <slug>/index.html
location / {
try_files $uri $uri/ $uri/index.html =404;
}
location ~ ^/wp-content/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}.htaccess at the document root:
Options +MultiViews
DirectoryIndex index.html
<FilesMatch "^.+\.(jpg|jpeg|png|webp|svg|css|js|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
ErrorDocument 404 /404.html