Darktide Build Extractor / gameslantern_extract.py
gameslantern_extract.py29.8 KB
#!/usr/bin/env python3
"""Archive a Darktide build from darktide.gameslantern.com into the blog.
Extracts a clean standalone page (class, weapons, curios, talent tree — no
nav, links, comments or votes), mirrors all images/CSS/fonts locally, embeds
the hover tooltips, and maintains a manifest + combined index page grouped
by game version and class.
Usage:
python3 tools/gameslantern_extract.py <build-url> --game-version 1.1.23
python3 tools/gameslantern_extract.py --reindex # rebuild index only
Output (all under site/tools/darktide/):
<slug>.html one standalone page per build
builds.json manifest (title, class, game version)
builds.html all builds + navigation menu (regenerate with --reindex
after hand-editing a build page — builds.html embeds a
COPY of each page, it does not read them live)
assets/... mirrored assets, shared across builds
(existing files are never overwritten,
so re-runs cannot break old builds)
"""
import argparse
import datetime
import json
import re
import sys
from pathlib import Path
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
SITE_ROOT = Path(__file__).resolve().parent.parent # script lives in site/tools/
OUT_DIR = SITE_ROOT / "tools" / "darktide"
ASSETS_DIR = OUT_DIR / "assets"
CSS_DIR = ASSETS_DIR / "css"
MANIFEST = OUT_DIR / "builds.json"
HEADERS = {"User-Agent": "Mozilla/5.0 (blog build archiver; personal use)"}
# Sections kept, in page order. Everything before "class" (breadcrumbs, TOC,
# cover, votes) and from "description" onward (description, share, author,
# comments, related builds) is dropped. Description is optional, so the
# share button and author blocks also act as stop markers.
FIRST_SECTION_ID = "class"
STOP_SECTION_IDS = {"description", "related"}
def fetch(url):
r = requests.get(url, headers=HEADERS, timeout=30)
r.raise_for_status()
return r
def asset_filename(url):
"""Stable local filename derived from the URL path."""
path = urlparse(url).path.lstrip("/")
name = re.sub(r"[^A-Za-z0-9._-]", "_", path.replace("/", "_"))
return name[-120:] # keep filenames sane
def download_asset(url, dest_dir, cache, prefix=""):
if url in cache:
return cache[url]
name = asset_filename(url)
dest = dest_dir / name
if not dest.exists():
try:
data = fetch(url).content
except Exception as e:
print(f" ! failed {url}: {e}", file=sys.stderr)
cache[url] = None
return None
dest.write_bytes(data)
print(f" + {name} ({len(data) // 1024} kB)")
cache[url] = name
return name
def localize_css(css_url, cache):
"""Download a stylesheet, download its url(...) refs, rewrite them."""
name = asset_filename(css_url)
dest = CSS_DIR / name
if dest.exists():
return name
css = fetch(css_url).text
def repl(m):
ref = m.group(1).strip("'\"")
if ref.startswith("data:") or ref.startswith("#"):
return m.group(0)
abs_url = urljoin(css_url, ref)
# only mirror darktide assets and fonts; other-site backgrounds in the
# shared tailwind bundle are unused here — point them at the CDN
path = urlparse(abs_url).path
if "darktide" not in path and "font" not in path.lower():
return f'url("{abs_url}")'
local = download_asset(abs_url, CSS_DIR, cache)
return f'url("{local}")' if local else m.group(0)
css = re.sub(r"url\(([^)]+)\)", repl, css)
dest.write_text(css)
print(f" + css/{name}")
return name
def extract_fonts(css_url, families, cache):
"""Pull only the @font-face rules for `families` out of the app css."""
dest = CSS_DIR / "fonts.css"
if dest.exists():
return "fonts.css"
css = fetch(css_url).text
kept = []
for m in re.finditer(r"@font-face\{[^}]*\}", css):
block = m.group(0)
if any(fam in block for fam in families):
def repl(u):
ref = u.group(1).strip("'\"")
local = download_asset(urljoin(css_url, ref), CSS_DIR, cache)
return f'url("{local}")' if local else u.group(0)
kept.append(re.sub(r"url\(([^)]+)\)", repl, block))
dest.write_text("\n".join(kept))
print(" + css/fonts.css")
return "fonts.css"
def localize_images(root, base_url, cache):
"""Rewrite src/srcset/xlink:href/href image refs to local copies."""
for tag in root.find_all(True):
for attr in ("src", "href", "xlink:href", "poster"):
val = tag.get(attr)
if not val or tag.name == "a":
continue
local = download_asset(urljoin(base_url, val), ASSETS_DIR, cache)
if local:
tag[attr] = f"assets/{local}"
srcset = tag.get("srcset")
if srcset:
parts = []
for entry in srcset.split(","):
bits = entry.strip().split()
if not bits:
continue
local = download_asset(urljoin(base_url, bits[0]), ASSETS_DIR, cache)
if local:
bits[0] = f"assets/{local}"
parts.append(" ".join(bits))
tag["srcset"] = ", ".join(parts)
def fetch_tooltip(url, tooltip_cache):
"""Fetch the hover-tooltip HTML served for a content URL, cleaned of
any site credit lines."""
if url in tooltip_cache:
return tooltip_cache[url]
html = None
try:
r = requests.get(
"https://gameslantern.com/api/tooltip",
params={"url": url},
headers={**HEADERS, "X-Requested-With": "XMLHttpRequest"},
timeout=30,
)
if r.status_code == 200 and r.text.strip():
frag = BeautifulSoup(r.text, "html5lib")
for el in frag.find_all(
string=lambda s: s and "gameslantern" in s.lower()
):
el.parent.decompose()
html = frag.body.decode_contents()
except Exception as e:
print(f" ! tooltip failed {url}: {e}", file=sys.stderr)
tooltip_cache[url] = html
return html
def clean_links(root, slug, tooltip_cache):
"""Drop 'View more ...' links, neutralize the rest (keep their styling).
Links to content pages get a data-tooltip key (namespaced by build slug
so several builds can share one page) referencing an embedded template.
"""
tooltips = {} # tooltip key -> html
seen = {} # url -> key
for a in root.find_all("a"):
if a.get_text(strip=True).lower().startswith("view more"):
parent = a.parent
a.decompose()
# remove the button wrapper if nothing visible is left in it
if parent and not parent.get_text(strip=True) and not parent.find("img"):
parent.decompose()
continue
href = a.get("href") or ""
# inside the talent-tree SVG links must become <g>; elsewhere <span>
# (display still comes from their classes, and spans stay valid
# inside description paragraphs)
in_svg = a.namespace == "http://www.w3.org/2000/svg"
a.name = "g" if in_svg else "span"
for attr in ("href", "target", "rel", "wire:navigate", "onclick"):
if a.has_attr(attr):
del a[attr]
if "gameslantern.com" in href and urlparse(href).path.count("/") >= 2:
if href not in seen:
html = fetch_tooltip(href, tooltip_cache)
if html is None:
seen[href] = None
else:
key = f"{slug}-{len(tooltips)}"
tooltips[key] = html
seen[href] = key
print(f" + tooltip {urlparse(href).path}")
if seen[href] is not None:
a["data-tooltip"] = seen[href]
a["class"] = (a.get("class") or []) + ["cursor-help"]
# drop Cloudflare rocket-loader leftovers
for tag in root.find_all(True):
for attr in list(tag.attrs):
if attr == "onclick" or attr.startswith("data-cf-modified"):
del tag[attr]
return tooltips
# Tooltip hover behavior: shows the embedded <template> matching an element's
# data-tooltip key, following the cursor and flipping at screen edges.
TOOLTIP_JS = """
(() => {
const place = (ev, tip) => {
if (window.screen.width < 500) {
tip.style.left = window.screen.width / 2 + "px";
tip.style.transform = "translate(-50%, 15px)";
tip.style.maxWidth = window.screen.width - 8 + "px";
} else {
const dx = ev.clientX > innerWidth / 2 ? "calc(-100% - 15px)" : "15px";
const dy = ev.clientY > innerHeight / 2 ? "calc(-100% - 15px)" : "15px";
tip.style.transform = `translate(${dx}, ${dy})`;
tip.style.left = ev.pageX + "px";
}
tip.style.position = "absolute";
tip.style.top = ev.pageY + "px";
tip.style.zIndex = "99999";
};
const hide = () => document.getElementById("build-tooltip")?.remove();
document.querySelectorAll("[data-tooltip]").forEach(el => {
const tpl = document.getElementById("tt-" + el.getAttribute("data-tooltip"));
if (!tpl) return;
el.addEventListener("mouseenter", ev => {
hide();
const tip = document.createElement("div");
tip.id = "build-tooltip";
tip.style.pointerEvents = "none";
tip.innerHTML = tpl.innerHTML;
place(ev, tip);
document.body.appendChild(tip);
});
el.addEventListener("mousemove", ev => {
const tip = document.getElementById("build-tooltip");
if (tip) place(ev, tip);
});
el.addEventListener("mouseleave", hide);
});
})();
"""
def normalize_build_url(url):
"""Accept build pages, bare /builds/<uuid> URLs, or build-editor links."""
p = urlparse(url)
if "build-editor" in p.path:
m = re.search(r"id=([0-9a-f-]+)", p.query)
if m:
return f"https://{p.hostname}/builds/{m.group(1)}"
return url
def extract_sections(soup):
header = soup.find(id=FIRST_SECTION_ID) or soup.find(id="weapons")
if header is None:
sys.exit("Could not find the build sections on this page.")
container = header.parent
kept, keeping = [], False
for child in container.children:
if getattr(child, "name", None) is None:
continue
cid = child.get("id") or (
child.find(id=True).get("id") if child.find(id=True) else None
)
if cid in (FIRST_SECTION_ID, "weapons") and not keeping:
keeping = True
if keeping and (
cid in STOP_SECTION_IDS
or child.find("share-page") is not None
or child.name == "share-page"
):
break
if keeping:
kept.append(child)
return container, kept
SECTION_IDS = ("class", "weapons", "curios", "talents-talent-tree")
def extract_description(soup):
"""Return the author's description/comment content div, or None."""
hdr = soup.find(id="description")
if hdr is None:
return None
for sib in hdr.next_siblings:
if getattr(sib, "name", None) is None:
continue
if "darktide-description" in (sib.get("class") or []):
return sib
return None
return None
def notes_panel(desc):
"""Wrap a description div in a collapsible Notes panel."""
# drop the trailing runs of empty paragraphs/headings authors leave
# behind, at any nesting level (they render as a large blank area)
def strip_trailing_empties(node):
while True:
last = None
for c in reversed(list(node.contents)):
if getattr(c, "name", None) is None:
if str(c).strip():
return
c.extract()
continue
last = c
break
if last is None:
return
if not last.get_text(strip=True) and not last.find("img"):
last.decompose()
continue
strip_trailing_empties(last)
return
strip_trailing_empties(desc)
dsoup = BeautifulSoup(
'<details class="build-notes"><summary>Notes</summary></details>',
"html5lib",
)
det = dsoup.find("details")
det.append(desc)
return det
def extract_groups(soup):
"""Split the kept children into named sections (class/weapons/curios/
talents) so sections can be swapped between builds."""
container, kept = extract_sections(soup)
groups, current = {}, None
for child in kept:
cid = child.get("id") or (
child.find(id=True).get("id") if child.find(id=True) else None
)
if cid in SECTION_IDS:
current = cid
groups[current] = []
if current:
groups[current].append(child)
return container, groups
def load_manifest():
if MANIFEST.exists():
return json.loads(MANIFEST.read_text())
return []
def save_manifest(entries):
MANIFEST.write_text(json.dumps(entries, indent=2) + "\n")
def build_page(url, slug, game_version, title_override=None,
weapons_from=None, curios_from=None, no_notes=False):
url = normalize_build_url(url)
print(f"Fetching {url}")
html = fetch(url).text
soup = BeautifulSoup(html, "html5lib")
og = soup.find("meta", property="og:title")
title = title_override or (og["content"].split(" - Warhammer")[0] if og else slug)
body_classes = " ".join(soup.body.get("class", []))
container, groups = extract_groups(soup)
container_classes = " ".join(container.get("class", []))
# section swaps: weapons from another build URL, curios from an
# already-archived build (re-fetched from its source URL)
if weapons_from:
wurl = normalize_build_url(weapons_from)
print(f"Fetching weapons from {wurl}")
_, wgroups = extract_groups(BeautifulSoup(fetch(wurl).text, "html5lib"))
if "weapons" not in wgroups:
sys.exit("weapons donor build has no weapons section")
groups["weapons"] = wgroups["weapons"]
if curios_from:
if curios_from.startswith("http"):
curios_url = normalize_build_url(curios_from)
else:
sys.exit("--curios-from takes a build URL (sources are not "
"kept in the manifest)")
print(f"Fetching curios from {curios_url}")
_, cgroups = extract_groups(
BeautifulSoup(fetch(curios_url).text, "html5lib")
)
if "curios" not in cgroups:
sys.exit("curios donor build has no curios section")
groups["curios"] = cgroups["curios"]
sections = [c for key in SECTION_IDS for c in groups.get(key, [])]
desc = None if no_notes else extract_description(soup)
if desc is not None:
sections.append(notes_panel(desc))
# class name from the model image in the Class section
class_name = "Unknown"
for sec in sections:
img = sec.find("img", alt=True)
if img and img.get("width") == "200":
class_name = img["alt"]
break
# show the class emblem (in-game achievement icon) instead of the
# character model; the archetype comes from the talent tree root icon
archetype = None
for sec in sections:
m = re.search(r"/talent_root/(\w+)\.webp", str(sec))
if m:
archetype = m.group(1)
break
# Only the tailwind utilities + darktide theme are needed; the app css
# bundles fonts/backgrounds for every site on the network (~11 MB).
# Fonts actually used by the build page are pulled separately below.
css_links, font_css_url = [], None
seen = set()
for link in soup.find_all("link", rel="stylesheet"):
href = urljoin(url, link["href"])
if href in seen:
continue
seen.add(href)
base = urlparse(href).path.rsplit("/", 1)[-1]
if base.startswith(("tailwind-", "darktide-")):
css_links.append(href)
elif base.startswith("app-") and font_css_url is None:
font_css_url = href
OUT_DIR.mkdir(parents=True, exist_ok=True)
ASSETS_DIR.mkdir(exist_ok=True)
CSS_DIR.mkdir(exist_ok=True)
cache = {}
print("Stylesheets:")
local_css = [localize_css(u, cache) for u in css_links]
if font_css_url:
local_css.append(extract_fonts(font_css_url, ("Teko", "Figtree"), cache))
print("Tooltips:")
wrapper = BeautifulSoup("<div></div>", "html5lib").div
for sec in sections:
wrapper.append(sec)
tooltips = clean_links(wrapper, slug, tooltip_cache={})
print("Images:")
localize_images(wrapper, url, cache)
# class image: swap the character model for the class emblem AFTER
# localizing (so the local assets/ path isn't treated as a remote URL).
# Uses a cleaned emblem (assets/emblem_<archetype>.png, prepared once per
# class) or falls back to the raw achievement icon with a warning.
emblem = wrapper.find("img", alt=class_name) if class_name != "Unknown" else None
if emblem is not None and archetype:
local = ASSETS_DIR / f"emblem_{archetype}.png"
if local.exists():
emblem["src"] = f"assets/emblem_{archetype}.png"
else:
print(f" ! no cleaned emblem for {archetype} — using raw icon; "
f"prepare assets/emblem_{archetype}.png", file=sys.stderr)
icon_url = (
"https://gameslantern.com/storage/sites/darktide/exporter/"
"content/ui/textures/icons/achievements/class_achievements/"
f"{archetype}/achievement_icon_{archetype}_0001.webp"
)
name = download_asset(icon_url, ASSETS_DIR, cache)
if name:
emblem["src"] = f"assets/{name}"
inner = wrapper.decode_contents()
tooltip_html = []
for key, tt in tooltips.items():
frag = BeautifulSoup(tt, "html5lib")
localize_images(frag.body, url, cache)
tooltip_html.append(
f'<template id="tt-{key}">{frag.body.decode_contents()}</template>'
)
templates = "\n ".join(tooltip_html)
css_tags = "\n ".join(
f'<link rel="stylesheet" href="assets/css/{n}">' for n in local_css if n
)
page = f"""<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<link rel="icon" type="image/png" href="/blog/logo.png">
{css_tags}
<style>
html {{ color-scheme: dark; }}
body {{ background: #171717; }}
details.build-notes {{
max-width: 56rem; margin: 1.5rem auto;
border: 1px solid #3c4e39; border-radius: 2px;
background: rgba(60, 78, 57, 0.12);
}}
details.build-notes > summary {{
cursor: pointer; list-style: none;
padding: 6px 14px; color: #89a783;
font-family: Teko, sans-serif; font-size: 1.3rem;
letter-spacing: 0.05em;
}}
details.build-notes > summary::before {{ content: "▸"; margin-right: 8px; }}
details.build-notes[open] > summary::before {{ content: "▾"; }}
details.build-notes > summary:hover {{ background: rgba(60, 78, 57, 0.35); }}
details.build-notes a {{ color: #d1ffc3; text-decoration: underline; }}
details.build-notes .darktide-description {{ min-height: 0; }}
.mention {{ font-weight: 500; font-family: Teko; color: #e0faff !important;
background: #d1ffc330; padding: 0 .25rem; }}
.darktide-description h3 {{ color: #d1ffc3; padding: 1rem 0 .5rem;
font-size: 1.8rem; font-weight: 700; font-family: Teko; line-height: 2rem; }}
.darktide-description ul {{ list-style: disc; padding: .5rem 0 .5rem 2rem; }}
.darktide-description ol {{ list-style: decimal; padding: .5rem 0 .5rem 2rem; }}
</style>
</head>
<body class="{body_classes}" style="background:#171717">
<!-- BUILD-CONTENT -->
<article id="build-{slug}" class="{container_classes} max-w-[1100px] mx-auto px-4 pb-12">
<h1 class="text-4xl text-center text-white font-teko tracking-wide mt-8 pt-4">{title}</h1>
{inner}
</article>
<!-- /BUILD-CONTENT -->
<!-- BUILD-TOOLTIPS -->
{templates}
<!-- /BUILD-TOOLTIPS -->
<script>{TOOLTIP_JS}</script>
</body>
</html>
"""
out = OUT_DIR / f"{slug}.html"
out.write_text(page)
print(f"Wrote {out.relative_to(SITE_ROOT.parent)}")
entries = [e for e in load_manifest() if e["slug"] != slug]
entries.append(
{
"slug": slug,
"title": title if title_override
else re.sub(rf"\s*-\s*{re.escape(class_name)} Build.*$", "", title),
"class": class_name,
"game_version": game_version,
"archived": datetime.date.today().isoformat(),
}
)
save_manifest(entries)
return out
def version_key(v):
parts = re.findall(r"\d+", v)
return tuple(int(p) for p in parts) if parts else (0,)
def write_index():
"""Combine all archived builds into builds.html (collapsible sections,
grouped by game version then class) and refresh the navigation menu in
the blog post."""
entries = load_manifest()
if not entries:
sys.exit("No builds in manifest yet.")
entries.sort(
key=lambda e: (version_key(e["game_version"]), e["class"], e["title"])
)
# newest game version first
groups = {}
for e in entries:
groups.setdefault(e["game_version"], []).append(e)
versions = sorted(groups, key=version_key, reverse=True)
body_parts, tooltip_parts = [], []
css_tags = None
for v in versions:
body_parts.append(f'<h2 class="version-divider">Patch {v}</h2>')
for e in groups[v]:
html = (OUT_DIR / f'{e["slug"]}.html').read_text()
content = re.search(
r"<!-- BUILD-CONTENT -->(.*?)<!-- /BUILD-CONTENT -->", html, re.S
)
tts = re.search(
r"<!-- BUILD-TOOLTIPS -->(.*?)<!-- /BUILD-TOOLTIPS -->", html, re.S
)
if not content:
sys.exit(f'{e["slug"]}.html has no BUILD-CONTENT markers — '
"re-run the extractor for it.")
body_parts.append(
f'<details class="build" id="build-{e["slug"]}">'
f'<summary><span class="cls">{e["class"]}</span>'
f'{e["title"]}</summary>'
f'{content.group(1)}</details>'
)
if tts:
tooltip_parts.append(tts.group(1))
if css_tags is None:
m = re.findall(r'<link[^>]*rel="stylesheet"[^>]*>', html)
css_tags = "\n ".join(m)
open_from_hash_js = """
const openFromHash = () => {
const el = document.querySelector(location.hash || "#none");
if (!el) return;
const d = el.closest("details") || el;
if (d.tagName === "DETAILS") d.open = true;
d.scrollIntoView({behavior: "smooth"});
};
window.addEventListener("hashchange", openFromHash);
if (location.hash) openFromHash();
"""
page = f"""<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Darktide Builds</title>
<link rel="icon" type="image/png" href="/blog/logo.png">
{css_tags}
<style>
html {{ color-scheme: dark; }}
body {{ background: #171717; }}
.version-divider {{
color: #89a783; font-family: Teko, sans-serif; text-align: center;
font-size: 2rem; letter-spacing: 0.08em; margin-top: 2rem;
border-bottom: 1px solid #3c4e39; max-width: 700px;
margin-left: auto; margin-right: auto;
}}
details.build {{
max-width: 1100px; margin: 12px auto;
border: 1px solid #3c4e39; border-radius: 2px;
background: rgba(60, 78, 57, 0.12);
}}
details.build > summary {{
cursor: pointer; list-style: none;
padding: 10px 16px;
color: #d7e4ce; font-family: Teko, sans-serif;
font-size: 1.5rem; letter-spacing: 0.04em;
}}
details.build > summary::before {{
content: "▸"; margin-right: 10px; color: #89a783;
}}
details.build[open] > summary::before {{ content: "▾"; }}
details.build > summary:hover {{ background: rgba(60, 78, 57, 0.35); }}
details.build > summary .cls {{
color: #89a783; font-weight: 700; margin-right: 12px;
}}
details.build-notes {{
max-width: 56rem; margin: 1.5rem auto;
border: 1px solid #3c4e39; border-radius: 2px;
background: rgba(60, 78, 57, 0.12);
}}
details.build-notes > summary {{
cursor: pointer; list-style: none;
padding: 6px 14px; color: #89a783;
font-family: Teko, sans-serif; font-size: 1.3rem;
letter-spacing: 0.05em;
}}
details.build-notes > summary::before {{ content: "▸"; margin-right: 8px; }}
details.build-notes[open] > summary::before {{ content: "▾"; }}
details.build-notes > summary:hover {{ background: rgba(60, 78, 57, 0.35); }}
details.build-notes a {{ color: #d1ffc3; text-decoration: underline; }}
details.build-notes .darktide-description {{ min-height: 0; }}
.mention {{ font-weight: 500; font-family: Teko; color: #e0faff !important;
background: #d1ffc330; padding: 0 .25rem; }}
.darktide-description h3 {{ color: #d1ffc3; padding: 1rem 0 .5rem;
font-size: 1.8rem; font-weight: 700; font-family: Teko; line-height: 2rem; }}
.darktide-description ul {{ list-style: disc; padding: .5rem 0 .5rem 2rem; }}
.darktide-description ol {{ list-style: decimal; padding: .5rem 0 .5rem 2rem; }}
</style>
</head>
<body class="w-full dark:bg-neutral-900 dark:text-zinc-300" style="background:#171717">
{"".join(body_parts)}
{"".join(tooltip_parts)}
<script>{TOOLTIP_JS}</script>
<script>{open_from_hash_js}</script>
<script>
// keep the embedding iframe as tall as the content (main.js listens).
// measure the body box, not scrollHeight — the latter is clamped to the
// viewport, so the iframe could grow but never shrink back.
const postHeight = () => parent.postMessage(
{{type: "divePlannerResize",
height: Math.ceil(document.body.getBoundingClientRect().height) + 20}}, "*");
window.__heightObserver = new ResizeObserver(postHeight);
window.__heightObserver.observe(document.body);
window.addEventListener("load", postHeight);
window.addEventListener("hashchange", () => setTimeout(postHeight, 50));
document.querySelectorAll("details").forEach(d =>
d.addEventListener("toggle", () => {{
// accordion between builds only; nested panels (notes) must not
// close their parent or siblings
if (d.open && d.classList.contains("build"))
document.querySelectorAll("details.build[open]").forEach(o => {{
if (o !== d) o.open = false;
}});
setTimeout(postHeight, 50);
}}));
</script>
</body>
</html>
"""
out = OUT_DIR / "builds.html"
out.write_text(page)
print(f"Wrote {out.relative_to(SITE_ROOT.parent)} ({len(entries)} builds)")
update_post_menu(versions, groups)
def update_post_menu(versions, groups):
"""Regenerate the left-side navigation menu inside the blog post,
between the BUILDS-MENU markers."""
post = SITE_ROOT / "blog" / "posts" / "darktide_builds.en.md"
if not post.exists():
print("! post darktide_builds.en.md not found, menu not updated",
file=sys.stderr)
return
lines = []
for v in versions:
lines.append(f'<div class="dt-menu-version">Patch {v}</div>')
for e in groups[v]:
lines.append(
f'<a href="/tools/darktide/builds.html#build-{e["slug"]}"'
f' target="darktideBuilds"><span class="cls">{e["class"]}</span>'
f'<span class="ttl">{e["title"]}</span></a>'
)
menu = "\n".join(lines)
md = post.read_text()
new = re.sub(
r"<!-- BUILDS-MENU -->.*?<!-- /BUILDS-MENU -->",
lambda m: f"<!-- BUILDS-MENU -->\n{menu}\n<!-- /BUILDS-MENU -->",
md,
flags=re.S,
)
if new != md:
post.write_text(new)
print(f"Updated menu in {post}")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("url", nargs="?", help="build URL to archive")
ap.add_argument("--slug", help="output file name (default: from URL)")
ap.add_argument("--title", help="override the build title")
ap.add_argument("--game-version", help="game patch this build belongs to")
ap.add_argument("--weapons-from", metavar="URL",
help="take the weapons section from this build URL")
ap.add_argument("--curios-from", metavar="SLUG_OR_URL",
help="take the curios section from an archived build "
"slug or any build URL")
ap.add_argument("--no-notes", action="store_true",
help="skip the author's description/notes panel")
ap.add_argument(
"--reindex", action="store_true", help="only rebuild builds.html"
)
args = ap.parse_args()
if args.reindex and not args.url:
write_index()
return
if not args.url:
ap.error("a build URL is required (or use --reindex)")
if not args.game_version:
ap.error("--game-version is required when archiving a build")
slug = args.slug or re.sub(
r"[^a-z0-9]+", "_", urlparse(args.url).path.rstrip("/").split("/")[-1].lower()
).strip("_")
build_page(args.url, slug, args.game_version, args.title,
args.weapons_from, args.curios_from, args.no_notes)
write_index()
if __name__ == "__main__":
main()
