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