HomeMAL' // MANUFACTORUM
code & tools
Darktide Build Extractor / gameslantern_extract.py
gameslantern_extract.py45.2 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 html import unescape
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"}

# Class display order, matching Darktide's in-game archetype order (base
# classes, then DLC classes in release order). Builds are grouped by class in
# this order; unknown classes fall to the end.
DARKTIDE_CLASS_ORDER = [
    "Veteran", "Zealot", "Psyker", "Ogryn", "Arbitrator", "Hive Scum", "Skitarius",
]

# Rename a gameslantern class label to the preferred display name. Applied to
# the manifest class and the visible class label (not the emblem lookup, which
# still keys off the raw name).
CLASS_RENAME = {"Skitarii": "Skitarius", "Arbites": "Arbitrator"}


def class_rank(name):
    return DARKTIDE_CLASS_ORDER.index(name) if name in DARKTIDE_CLASS_ORDER \
        else len(DARKTIDE_CLASS_ORDER)


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")


# Passive themes for the Build Summary — first matching keyword wins, order = display order.
SUMMARY_THEMES = [
    ("Critical Hit", ["Critical", "Weakspot"]),
    ("Peril", ["Peril"]),
    ("Quelling", ["Quell"]),
    ("Cleave", ["Cleave"]),
    ("Grenade", ["Grenade"]),
    ("Stims", ["Stimm"]),
    ("Toughness", ["Toughness"]),
    ("Dodge", ["Dodge", "Evasion"]),
    ("Movement", ["Movement", "Sprint"]),
    ("Ability / Cooldown", ["Cooldown", "Ability"]),
    ("Stamina", ["Stamina"]),
    ("Damage", ["Damage"]),
    ("Other", []),
]

# ---- flat stat-boost extraction ---------------------------------------------
# Aggregate always-on, generic character-stat buffs wherever they appear (curio/stat
# nodes, passives, even the flat part of an aura like "Gain +25 Toughness"). Only
# whitelisted generic stats count; ability-/weapon-specific numbers ("+75% Damage" on
# a grenade, an ability's own buff) and situational/conditional buffs are excluded.
# "+N Blitz/Grenade/Ability Charges" are routed onto their ability slot instead.
GENERIC_STATS = {
    "toughness", "toughness damage reduction", "damage resistance", "damage reduction",
    "melee damage", "ranged damage", "heavy melee damage", "weakspot damage",
    "critical hit chance", "melee critical hit chance", "critical hit damage",
    "finesse", "melee finesse bonus", "attack speed", "melee attack speed",
    "reload speed", "weapon swap speed", "sprint speed", "movement speed", "revive speed",
    "stamina", "stamina cost", "ammo", "ammo reserve", "ammo capacity", "health", "wounds",
    "cleave", "ranged cleave", "rending", "melee rending", "toxin strength",
    "effective dodges", "dodge duration", "sprint efficiency",
}
STAT_SYNONYMS = {"weak spot damage": "weakspot damage", "critical chance": "critical hit chance"}
CHARGE_STATS = {"blitz charges", "max grenades", "max grenade", "max ability charges",
                "ability charges", "max missiles"}
STAT_CATS = {"Passive", "Aura", "Operative Modifier"}  # categories where generic stats live
_SIT = re.compile(r"\b(for \d|Stacks?\b|per |each |when |while |after |whenever |upon "
                  r"|based on|up to|every \d|restore|replenish|regenerat|consume|generat"
                  r"|Coheren|Allies|Ally\b|decay|reduce|becoming|scaling|lasts \d"
                  r"|if |increased|above |below |against "  # conditions / thresholds / enemy-specific
                  r"|on (?:a |an |your |kill|hit|elite|special|weakspot|critical|ranged|"
                  r"melee|toughness|successful|being|taking|dealing|avoiding|staggered|"
                  r"slain|hitting))", re.I)
_GRANT = re.compile(r"([+-]?\d+)(%?)\s+([A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+){0,3})")


def _statname(s):
    s = s.strip().lower()
    return STAT_SYNONYMS.get(s, s)


def stat_sign(n, u):
    return f"{'+' if n >= 0 else '-'}{abs(n)}{u}"


def flat_grants(node):
    """(generic grants, charge grants) as [(num, unit, canonical-stat)] from a talent's
    non-situational clauses; only for stat-bearing categories."""
    if node["cat"] not in STAT_CATS:
        return [], []
    gens, charges = [], []
    for cl in re.split(r"(?<=[.;])\s+", node["efftxt"]):
        if _SIT.search(cl):
            continue
        for m in _GRANT.finditer(cl):
            stat, n = _statname(m.group(3)), int(m.group(1))
            if stat in GENERIC_STATS:
                gens.append((n, m.group(2), stat))
            elif stat in CHARGE_STATS and n > 0:
                charges.append((n, m.group(2), stat))
    return gens, charges


def is_pure_stat(node):
    """True if the talent's whole effect is just flat stat/charge grants (fully shown in
    Stat boosts, so it shouldn't also appear as a themed passive)."""
    gens, charges = flat_grants(node)
    if not (gens or charges):
        return False
    left = _GRANT.sub(" ", node["efftxt"])
    left = re.sub(r"\b(Gain|You|have|gain|Grants?|additional|extra|and|the|a|an|of|to"
                  r"|Improve|your|chosen|also)\b", " ", left, flags=re.I)
    return re.sub(r"[^A-Za-z]", " ", left).strip() == ""


def render_build_summary(inner, templates):
    """Build the in-game-style 'Build Summary' digest from the rendered build content
    (talent tree) + tooltip templates, and insert it before the weapons section.

    Each selected talent's ability/aura/keystone/blitz is shown with its modifiers
    folded in; flat always-on buffs are aggregated into 'Stat boosts'; the remaining
    passives are grouped by theme. Ability-modifier and passive hovers point at their
    own tt-<key>-sum tooltip copy (talent icon added) so the shared tooltips used by
    the talent tree are left untouched. Returns `inner` unchanged if the expected
    structure isn't present (e.g. non-build pages).
    """
    anchor = re.search(r'<div\b[^>]*\bid="weapons"', inner)
    if anchor is None:
        return inner
    tpl = {tid: body for tid, body in
           re.findall(r'<template id="(tt-[^"]+)">(.*?)</template>', templates, re.S)}

    def info(key):
        b = tpl.get("tt-" + key, "")
        cat = re.search(r"text-\[#707d67\][^>]*>\s*<div>([^<]+)</div>", b)
        name = re.search(r'text-\[#d7e4ce\] text-lg">([^<]+)</div>', b)
        eff = re.search(r'text-\[#a7be97\][^"]*font-normal leading-5">(.*?)</div>', b, re.S)
        return dict(
            cat=cat.group(1).strip() if cat else "Passive",
            name=name.group(1).strip() if name else "?",
            eff=(eff.group(1).strip() if eff else ""),  # raw html (keeps coloured numbers)
            # plain text for analysis: strip tags AND decode entities so "&amp;" doesn't
            # split a sentence on the ';' of the entity (which would sever "+N Stat" from
            # a trailing "while …" condition and mis-read it as an always-on stat).
            efftxt=re.sub(r"\s+", " ", unescape(re.sub(r"<[^>]+>", "", eff.group(1))) if eff else "").strip(),
        )

    # the tooltip namespace isn't always the slug (some imports kept their original slug)
    nsm = re.search(r'<g class="[^"]*" data-tooltip="(.+?)-\d+">', inner)
    if nsm is None:
        return inner
    ns = nsm.group(1)

    active = []
    for g in re.finditer(rf'<g class="[^"]*" data-tooltip="({re.escape(ns)}-\d+)">(.*?)</g>', inner, re.S):
        key, body = g.group(1), g.group(2)
        if "ability-active" in body:
            icon = re.search(r'href="(assets/[^"]*exporter_talents[^"]*)"', body)
            d = info(key)
            d["key"] = key
            d["icon"] = icon.group(1) if icon else None
            active.append(d)
    if not active:
        return inner

    def by(cat):
        return [a for a in active if a["cat"] == cat]

    charge_mods = {}  # ability label -> [talent, ...]; rendered inside headline()

    # summary hovers want the talent icon, but the shared tt-<key> templates are also used
    # by the talent tree — so give each a private tt-<key>-sum copy with the icon added.
    sum_tts, seen_sum = [], set()

    def sum_tooltip(node):
        key = node["key"]
        if not (node.get("icon") and ("tt-" + key) in tpl):
            return key  # no icon/template — fall back to the shared tooltip
        if key not in seen_sum:
            seen_sum.add(key)
            body = re.sub(
                r'(<div class="text-\[#d7e4ce\] text-lg">[^<]*</div>)',
                lambda n: (
                    '<div class="flex items-center gap-2">'
                    f'<img class="w-10 h-10 object-contain shrink-0" src="{node["icon"]}" alt="">'
                    f"{n.group(1)}</div>"
                ),
                tpl["tt-" + key], count=1,
            )
            sum_tts.append(f'<template id="tt-{key}-sum">{body}</template>')
        return f"{key}-sum"

    def headline(label, cat, modcat):
        items = by(cat)
        if not items:
            return ""
        out = []
        for a in items:
            ic = (f'<img src="{a["icon"]}" alt="" class="w-12 h-12 object-contain shrink-0">'
                  if a["icon"] else "")
            mods = ""
            for m in by(modcat):
                # each modification folded in (body font); hover reveals its source (icon + name)
                mods += (
                    '<div class="text-[#a7be97] text-sm leading-snug mt-2 pl-3 pr-2 py-1 rounded" '
                    'style="border-left:2px solid rgba(137,167,131,.45);background:rgba(137,167,131,.10)" '
                    f'data-tooltip="{sum_tooltip(m)}">'
                    f'<span class="text-[#d7e4ce] font-semibold">+ </span>{m["eff"]}</div>'
                )
            for m in charge_mods.get(label, []):
                # a flat "+N Charges" buff routed onto this ability (its effect already reads "+N …")
                mods += (
                    '<div class="text-[#a7be97] text-sm leading-snug mt-2 pl-3 pr-2 py-1 rounded" '
                    'style="border-left:2px solid rgba(137,167,131,.45);background:rgba(137,167,131,.10)" '
                    f'data-tooltip="{sum_tooltip(m)}">{m["eff"]}</div>'
                )
            out.append(
                f'<div class="flex items-start gap-3">{ic}<div class="min-w-0">'
                f'<div class="text-[#707d67] text-xs uppercase tracking-wide">{label}</div>'
                f'<div class="text-[#d7e4ce] font-teko text-xl leading-none">{a["name"]}</div>'
                f'<div class="text-[#a7be97] text-sm leading-snug mt-1">{a["eff"]}</div>'
                f"{mods}</div></div>"
            )
        return "".join(out)

    # aggregate generic stat boosts across every stat-bearing talent, tracking sources;
    # route charge boosts onto their ability slot (rendered by headline()).
    totals, order, sources = {}, [], {}
    for a in active:
        gens, charges = flat_grants(a)
        for num, unit, stat in gens:
            k = (stat, unit)
            if k not in totals:
                order.append(k)
                totals[k] = 0
                sources[k] = []
            totals[k] += num
            sources[k].append((a, num, unit))
        for num, unit, stat in charges:
            label = "Ability" if "ability" in stat else "Blitz"
            charge_mods.setdefault(label, [])
            if a not in charge_mods[label]:
                charge_mods[label].append(a)

    # each aggregated stat chip hovers to reveal its contributing source talent(s)
    card = ('<div class="shadow-md border border-[#3c4e39] px-4 py-3 font-bold w-screen '
            'bg-gradient-to-b from-[rgba(16,21,17,1)] to-[rgba(57,71,54,1)] max-w-sm">')

    def stat_tooltip(idx, stat, unit, total, srcs):
        rows = "".join(
            '<div class="flex items-center gap-2 py-0.5">'
            + (f'<img class="w-7 h-7 object-contain shrink-0" src="{a["icon"]}" alt="">' if a["icon"] else "")
            + f'<span class="text-[#d7e4ce]">{a["name"]}</span>'
            f'<span class="ml-auto pl-3 text-[rgb(226,199,126)]">{stat_sign(num, u)}</span></div>'
            for a, num, u in srcs
        )
        key = f"{ns}-stat{idx}"
        sum_tts.append(
            f'<template id="tt-{key}-sum">{card}'
            '<div class="text-[#707d67] text-sm"><div>Stat boost</div></div>'
            f'<div class="text-[#d7e4ce] text-lg">{stat_sign(total, unit)} {stat.title()}</div>'
            f'<div class="text-[#a7be97] py-2 font-normal leading-5">{rows}</div></div></template>'
        )
        return f"{key}-sum"

    statline = ""
    if order:
        chips = []
        for i, (s, u) in enumerate(order):
            tt = stat_tooltip(i, s, u, totals[(s, u)], sources[(s, u)])
            chips.append(
                f'<span class="cursor-help" data-tooltip="{tt}">'
                f'<span class="text-[#d7e4ce] font-semibold">{stat_sign(totals[(s, u)], u)}</span> '
                f"{s.title()}</span>"
            )
        statline = (
            '<div class="mt-4 pt-3 border-t border-[#89A783]/25">'
            '<div class="text-[#707d67] text-xs uppercase tracking-wide mb-1.5">Stat boosts</div>'
            '<div class="text-[#a7be97] text-sm flex flex-wrap gap-x-4 gap-y-1">'
            f'{"".join(chips)}</div></div>'
        )

    def theme(txt):
        t = txt.lower().replace("critical peril", "peril")  # peril threshold, not a crit
        for name, kws in SUMMARY_THEMES:
            if any(k.lower() in t for k in kws):
                return name
        return "Other"

    pv = [p for p in by("Passive") if not is_pure_stat(p)]
    seen = set()
    pv = [p for p in pv if not (p["name"] in seen or seen.add(p["name"]))]  # dedup by name
    groups = {}
    for p in pv:
        groups.setdefault(theme(p["efftxt"]), []).append(p)

    passives = ""
    if pv:
        blocks = []
        for tname, _ in SUMMARY_THEMES:
            if tname not in groups:
                continue
            rows = []
            for a in groups[tname]:
                # same box/font as the modifiers: no inline name, whole box hoverable
                rows.append(
                    '<div class="text-[#a7be97] text-sm leading-snug pl-3 pr-2 py-1 rounded cursor-help" '
                    'style="border-left:2px solid rgba(137,167,131,.45);background:rgba(137,167,131,.10)" '
                    f'data-tooltip="{sum_tooltip(a)}">{a["eff"]}</div>'
                )
            blocks.append(
                '<div class="mt-3"><div class="flex items-center gap-2 mb-1.5">'
                f'<span class="text-[#707d67] text-[11px] uppercase tracking-widest">{tname}</span>'
                '<span class="flex-1 h-px bg-[#89A783]/15"></span></div>'
                f'<div class="grid gap-1.5">{"".join(rows)}</div></div>'
            )
        passives = (
            '<div class="mt-4 pt-3 border-t border-[#89A783]/25">'
            '<div class="text-[#707d67] text-xs uppercase tracking-wide mb-1">Passives</div>'
            + "".join(blocks) + "</div>"
        )

    # render everything before joining sum_tts (every sum_tooltip() must have appended first)
    headlines = (
        headline("Ability", "Ability", "Ability Modifier")
        + headline("Aura", "Aura", "Aura Modifier")
        + headline("Keystone", "Keystone", "Keystone Modifier")
        + headline("Blitz", "Blitz", "Blitz Modifier")
    )
    summary = (
        '<details class="build-notes" id="build-summary" style="max-width:48rem;margin:1.5rem auto">'
        "<summary>Build Summary</summary>\n"
        '            <div class="darktide-description bg-gradient-to-b from-[#263225] to-[#3D503C] '
        'border border-neutral-800 rounded text-[#89A783] p-4 build-guide">\n'
        '                <div class="grid sm:grid-cols-2 gap-x-6 gap-y-4">' + headlines + "</div>\n"
        "                " + statline + "\n"
        "                " + passives + "\n"
        "            </div>\n"
        "            " + "".join(sum_tts) + "\n"
        "            </details>\n            "
    )
    i = anchor.start()
    return inner[:i] + summary + inner[i:]


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}"

    # apply the preferred display name to the visible class label + emblem alt
    class_display = CLASS_RENAME.get(class_name, class_name)
    if class_display != class_name:
        if emblem is not None:
            emblem["alt"] = class_display
            label = emblem.find_next("p")
            if label is not None and label.get_text(strip=True) == class_name:
                label.string = class_display

    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)

    # in-game-style digest, inserted into the build content before the weapons section
    inner = render_build_summary(inner, templates)

    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; }}
        .cursor-help {{ cursor: default; }}
        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_display,
            "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.")

    # within a class, keep the order builds were added to the manifest (the
    # order the user archived them) rather than sorting by title
    manifest_index = {e["slug"]: i for i, e in enumerate(entries)}
    entries.sort(
        key=lambda e: (version_key(e["game_version"]),
                       class_rank(e["class"]), manifest_index[e["slug"]])
    )
    # 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; }}
        .cursor-help {{ cursor: default; }}
        .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 {{
            display: inline-block; min-width: 8rem;
            color: #89a783; font-weight: 700;
        }}
        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;
                }});
                // scroll the opened build to the top of the view (not the bottom).
                // instant (not smooth) so the ResizeObserver reflow can't cancel it.
                setTimeout(() => d.scrollIntoView({{block: "start"}}), 80);
            }}
            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)")


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()