HomeMAL' // MANUFACTORUM
datacrypt
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()