Skip to main content

Map Image Update Script

I use the following .py script to periodically check my server's installed maps against the official TF2 wiki and download any missing map images.

#!/usr/bin/env python3
import os
import sys
import textwrap
import time
from urllib.parse import quote

import requests


# ---- CONFIG ----
MAPCYCLE_PATH    = "/home/tf2server/serverfiles/tf/cfg/mapcycle.txt"
WIKI_SOURCE_PATH = "/opt/tf2-live/wiki_list_of_maps.txt"
OUTPUT_DIR       = "/var/www/html/assets/map-img"
METADATA_OUT     = "/opt/tf2-live/map_metadata.json"
TF_MAPS_DIR      = "/home/tf2server/serverfiles/tf/maps"


# ---- FLAGS ----
#   --download       : fetch images
#   --missing-only   : only download images that don't exist already
#   --sleep <sec>    : delay between successful downloads (polite mode)
#   --all            : process ALL wiki maps (preload every image), but DO NOT overwrite metadata.json
#   --installed      : process maps installed on the server (tf/maps/*.bsp), but DO NOT overwrite metadata.json
DOWNLOAD_IMAGES  = "--download" in sys.argv
DOWNLOAD_ALL     = "--all" in sys.argv
MISSING_ONLY     = "--missing-only" in sys.argv
CHECK_INSTALLED  = "--installed" in sys.argv
UPDATE_WIKI      = "--update-wiki" in sys.argv


SLEEP_TIME = 0.2
if "--sleep" in sys.argv:
    try:
        SLEEP_TIME = float(sys.argv[sys.argv.index("--sleep") + 1])
    except (IndexError, ValueError):
        print("ERROR: --sleep requires a numeric value (seconds)")
        sys.exit(2)


# ---- UPDATE WIKI LIST ----
def update_wiki_list(path: str):
    url = "https://wiki.teamfortress.com/wiki/Template:List_of_maps?action=raw"
    print("Updating wiki map list from TF2 Wiki...")
    try:
        resp = requests.get(url, timeout=30)
        if resp.status_code != 200:
            print(f"ERROR: Failed to fetch wiki list (HTTP {resp.status_code})")
            return False
        with open(path, "w", encoding="utf-8") as f:
            f.write(resp.text)
        print("  Wiki map list updated successfully.")
        return True
    except Exception as e:
        print(f"ERROR: Failed to update wiki list: {e}")
        return False


# ---- PARSING WIKI TEMPLATE ----
def parse_wiki_list(path: str):
    """
    Parse the Template:List_of_maps source (the {{List of maps/row ...}} stuff)
    and return a dict: filename -> {image, map_name, map_type, mode, filename}
    """
    records = {}
    current = None

    def finalize(rec):
        if not rec:
            return
        filename = rec.get("filename")
        if not filename:
            return
        filename = filename.strip()
        if filename.startswith("`") and filename.endswith("`"):
            filename = filename[1:-1]
        rec["filename"] = filename
        records[filename] = rec

    with open(path, encoding="utf-8") as f:
        for raw in f:
            line = raw.rstrip("\n")

            if line.startswith("{{List of maps/row"):
                if current is not None:
                    finalize(current)
                current = {
                    "image": None,
                    "map_name": None,
                    "map_type": None,
                    "mode": None,
                    "filename": None,
                }
                continue

            if current is None:
                continue

            stripped = line.strip()

            if stripped == "}}":
                finalize(current)
                current = None
                continue

            if stripped.startswith("| "):
                parts = stripped[2:].split("=", 1)
                if len(parts) != 2:
                    continue
                key = parts[0].strip()
                value = parts[1].strip()

                if key == "image":
                    current["image"] = value
                elif key == "map":
                    current["map_name"] = value
                elif key == "map-type":
                    current["map_type"] = value
                elif key == "mode":
                    current["mode"] = value
                elif key == "filename":
                    current["filename"] = value

    if current is not None:
        finalize(current)

    return records


# ---- READ INSTALLED BSPs ----
def read_installed_maps(maps_dir: str):
    if not os.path.isdir(maps_dir):
        return []
    maps = []
    for name in os.listdir(maps_dir):
        if not name.endswith(".bsp"):
            continue
        m = name[:-4]  # strip .bsp
        # ignore some junk you might not want thumbnails for
        if m.startswith("background") or m.startswith("test_"):
            continue
        maps.append(m)
    return sorted(set(maps))


# ---- MAPCYCLE PARSING ----
def read_mapcycle(path: str):
    maps = []
    with open(path, encoding="utf-8") as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith("//") or line.startswith("#"):
                continue
            maps.append(line.split()[0])
    return maps


# ---- IMAGE DOWNLOAD ----
def build_image_url(image_name: str) -> str:
    quoted = quote(image_name)
    return f"https://wiki.teamfortress.com/wiki/Special:FilePath/{quoted}"


def download_image_for_map(map_id: str, rec: dict, output_dir: str):
    image_name = rec.get("image")
    if not image_name:
        print(f"  - {map_id}: no image name in wiki data, skipping download")
        return False

    # Normalize everything to .png for simplicity.
    out_name = f"{map_id}.png"
    out_path = os.path.join(output_dir, out_name)

    if os.path.exists(out_path):
        if MISSING_ONLY:
            return False
        print(f"  - {map_id}: image already exists ({out_name}), skipping")
        return False

    url = build_image_url(image_name)
    print(f"  - {map_id}: downloading {image_name} -> {out_name}")

    try:
        resp = requests.get(url, timeout=20)
        if resp.status_code != 200:
            print(f"    ! HTTP {resp.status_code} for {url}, skipping")
            return False

        os.makedirs(output_dir, exist_ok=True)
        with open(out_path, "wb") as f:
            f.write(resp.content)

        # Only sleep after a successful download/write
        time.sleep(SLEEP_TIME)
        return True

    except Exception as e:
        print(f"    ! Error downloading {url}: {e}")
        return False


# ---- MAIN ----
def main():
    # mapcycle is only required for mapcycle mode
    if (not DOWNLOAD_ALL) and (not CHECK_INSTALLED) and (not os.path.exists(MAPCYCLE_PATH)):
        print(f"ERROR: mapcycle not found at {MAPCYCLE_PATH}")
        sys.exit(1)

    if UPDATE_WIKI:
        ok = update_wiki_list(WIKI_SOURCE_PATH)
        if not ok:
            sys.exit(1)

    if not os.path.exists(WIKI_SOURCE_PATH):
        print(f"ERROR: wiki source file not found at {WIKI_SOURCE_PATH}")
        sys.exit(1)

    print("Parsing wiki template...")
    wiki_records = parse_wiki_list(WIKI_SOURCE_PATH)
    print(f"  Loaded {len(wiki_records)} wiki map entries")

    cycle_maps = []
    installed_maps = []

    if CHECK_INSTALLED:
        print(f"\nReading installed maps from: {TF_MAPS_DIR}")
        installed_maps = read_installed_maps(TF_MAPS_DIR)
        print(f"  Found {len(installed_maps)} .bsp maps installed\n")
        maps_to_process = installed_maps

    elif DOWNLOAD_ALL:
        print("\nMode: --all (processing all wiki maps)\n")
        maps_to_process = list(wiki_records.keys())

    else:
        print("\nReading mapcycle...")
        cycle_maps = read_mapcycle(MAPCYCLE_PATH)
        print(f"  Found {len(cycle_maps)} maps in mapcycle.txt\n")
        maps_to_process = cycle_maps

    missing = []
    found = []
    meta_out = {}

    for m in maps_to_process:
        rec = wiki_records.get(m)
        if rec is None:
            missing.append(m)
            continue

        found.append((m, rec))

        # Only write metadata for mapcycle mode
        # (avoid overwriting site metadata during --all or --installed runs)
        if (not DOWNLOAD_ALL) and (not CHECK_INSTALLED):
            map_name = (rec.get("map_name") or m).strip()
            map_type = (rec.get("map_type") or "").strip()
            mode     = (rec.get("mode") or "Unknown").strip()

            title = map_name.replace(" ", "_")
            wiki_url = f"https://wiki.teamfortress.com/wiki/{quote(title)}"

            meta_out[m] = {
                "name": map_name,
                "mode": mode,
                "origin": map_type,
                "wiki": wiki_url,
                "image": f"assets/map-img/{m}.png",
            }

    # ---- Summary ----
    print("=== SUMMARY ===")
    if CHECK_INSTALLED:
        print("Mode            : installed maps (--installed)")
    elif DOWNLOAD_ALL:
        print("Mode            : ALL wiki maps (--all)")
    else:
        print("Mode            : mapcycle.txt only")
    print(f"Total processed : {len(maps_to_process)}")
    print(f"Found in wiki   : {len(found)}")
    print(f"Missing in wiki : {len(missing)}\n")

    if missing and not DOWNLOAD_ALL:
        # For --installed, this is still useful (custom maps won't exist on wiki)
        print("Maps processed that DO NOT have a matching 'filename' in the wiki list:")
        for m in missing:
            print(f"  - {m}")
        print()

    # Group found by map-type (still helpful in all modes)
    valve = []
    community = []
    other = []

    for m, rec in found:
        t = (rec.get("map_type") or "").strip()
        if t.lower() == "valve":
            valve.append((m, rec))
        elif t.lower() == "community":
            community.append((m, rec))
        else:
            other.append((m, rec))

    def print_group(title, items):
        print(f"{title} ({len(items)}):")
        for m, rec in items:
            name = rec.get("map_name") or m
            mode = rec.get("mode") or "Unknown"
            print(f"  - {m:25s}  {name:25s}  [{mode}]")
        print()

    print_group("Valve maps", valve)
    print_group("Community maps (but shipped as official)", community)
    if other:
        print_group("Other / unknown-type maps", other)

    # ---- Write metadata JSON ----
    if (not DOWNLOAD_ALL) and (not CHECK_INSTALLED):
        try:
            os.makedirs(os.path.dirname(METADATA_OUT), exist_ok=True)
            with open(METADATA_OUT, "w", encoding="utf-8") as f:
                import json
                json.dump(meta_out, f, indent=2, sort_keys=True)
            print(f"\nWrote metadata for {len(meta_out)} maps to {METADATA_OUT}")
        except Exception as e:
            print(f"\nERROR writing metadata JSON: {e}")
    else:
        print("\nSkipping metadata write in --all/--installed mode (won't overwrite map_metadata.json).")

    # ---- Downloads ----
    if DOWNLOAD_IMAGES:
        print("=== DOWNLOAD PHASE ===")
        print(f"Output directory: {OUTPUT_DIR} (sleep={SLEEP_TIME}s, missing_only={MISSING_ONLY})")
        downloaded = 0
        for m, rec in found:
            if download_image_for_map(m, rec, OUTPUT_DIR):
                downloaded += 1
        print(f"\nDone. Downloaded {downloaded} new image(s).")
    else:
        if DOWNLOAD_ALL:
            print(textwrap.dedent(f"""
                No images were downloaded (run with --download to fetch them).

                Bulk preload mode enabled (--all):
                  Run:
                      python3 tf2_wiki_sync.py --all --download --missing-only --sleep 0.2
                  to fetch ALL TF2 Wiki map images into {OUTPUT_DIR}
                  (metadata.json will NOT be overwritten).
            """).strip())
        elif CHECK_INSTALLED:
            print(textwrap.dedent(f"""
                No images were downloaded (run with --download to fetch them).

                Installed-map mode enabled (--installed):
                  Run:
                      python3 tf2_wiki_sync.py --installed --download --missing-only --sleep 0.2
                  to fetch thumbnails for maps installed in {TF_MAPS_DIR}
                  (metadata.json will NOT be overwritten).
            """).strip())
        else:
            print(textwrap.dedent(f"""
                No images were downloaded (run with --download to fetch them).

                Next steps:
                  1) Review the 'missing in wiki' list above.
                  2) When satisfied, run:
                         python3 tf2_wiki_sync.py --download --missing-only --sleep 0.2
                     to fetch map images into {OUTPUT_DIR}
                  3) Ensure MAP_METADATA_FILE is set to:
                         {METADATA_OUT}
                     in your tf2-live .env so /api/maps can expose
                     name / origin / mode / wiki / image to the website.
            """).strip())


if __name__ == "__main__":
    main()