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