"""tf3-listing-audit.py - re-check what the storefronts and the ESRB say about Transport Fever 3 before its 29 September 2026 launch. The article that ships with this file quotes launch timestamps, prices in three currencies on four storefronts, and an ESRB certificate, all read on 25 September 2026. Pre-order data moves, so this script re-reads the same public endpoints and prints the same tables. Any number in the article can be checked against its source rather than taken on trust. What it reads, all public and none of it needing a key: Steam store.steampowered.com/api/appdetails for app 3493540, one call per storefront (cc=us, cc=gb, cc=de); the two editions are the two "subs" of the purchase package group GOG catalog.gog.com/v1/catalog, the search endpoint the store uses PlayStation store.playstation.com concept page 10015054 (the id the official site links to); release date and prices sit in the embedded Apollo cache, read with regular expressions, not a browser Xbox displaycatalog.mp.microsoft.com/v7.0/products/ for the standard (9NDRP902TJGZ) and Deluxe (9PMXR610D3S3) editions ESRB esrb.org search endpoint (certificate, rating, descriptors) PEGI and USK are not read here: both serve HTML search pages with no documented endpoint, and their results in the article were read by hand. The Epic Games Store page answered an automated request with HTTP 403 on the day of writing, so it is not read either. Robots rules (version 1.1, 10 October 2026). Before its first request to a host the script reads that host's robots.txt, and it skips the host, saying why, when the file cannot be read, when the server refuses the script, or when any group in the file closes the page, whichever crawler the group names. On 10 October that ruled out the ESRB: esrb.org's robots.txt closes its rating pages and search results to a group of named crawlers. The script then prints the two certificates as the article read them on 25 September, marked as such, and beside them the ESRB label that Steam, the PlayStation Store and the Microsoft Store display today. Every request names this script in its User-Agent header (version 1.0 sent a web browser's), and calls to one host are at least two seconds apart, or as far apart as the host's Crawl-delay asks. Standard library only (urllib, json, re, time, sys). Python 3.9 or newer. python tf3-listing-audit.py Exit code is 0 when every endpoint the robots rules allowed answered, 1 when any of them did not. """ from __future__ import annotations import json import re import sys import time import urllib.error import urllib.parse import urllib.request UA = {"User-Agent": "tf3-listing-audit/1.1 (+https://blastproc.com/code/tf3-listing-audit.py; reader re-check script)", "Accept-Language": "en-US,en;q=0.9"} STEAM_APP = 3493540 STEAM_MARKETS = ("us", "gb", "de") PS_CONCEPT = 10015054 PS_LOCALES = ("en-us", "en-gb", "de-de") XBOX = (("Standard", "9NDRP902TJGZ"), ("Deluxe", "9PMXR610D3S3")) XBOX_MARKETS = ("US", "GB", "DE") # The ESRB search as the article read it on 25 September 2026 (certificate, title, rating, descriptors, platforms, # publisher), printed when the robots rules keep this script from re-reading it. ESRB_ON_25_SEPTEMBER = ( (41536, "Transport Fever 3", "E", "Mild Language", "Windows PC, PlayStation 5, Xbox Series", "Paradox Interactive"), (39035, "Transport Fever 2: Console Edition", "E10+", "Alcohol and Tobacco Reference, Mild Language, Mild Suggestive Themes", "PlayStation 4, PlayStation 5, Xbox One, Xbox Series", "Nacon SA"), ) FAILED = [] SKIPPED = {} # url -> why the robots rules ruled it out # ------------------------------------------------------------------ robots.txt, read before the first call to a host MIN_GAP = 2.0 # seconds between two calls to one host, unless its robots.txt asks for more _ROBOTS = {} # host -> (groups or None, why the host is skipped, seconds between calls) _LAST = {} # host -> time of the last call def _wait(host: str) -> None: gap = _ROBOTS.get(host, (None, None, MIN_GAP))[2] if host in _LAST: time.sleep(max(0.0, _LAST[host] + gap - time.monotonic())) _LAST[host] = time.monotonic() def _parse(text: str): """robots.txt -> ([(agent names, [(allow?, pattern), ...]), ...], the longest Crawl-delay in the file).""" groups, gap, in_agents = [], 0.0, False for line in text.splitlines(): key, colon, value = line.split("#", 1)[0].partition(":") if not colon: continue key, value = key.strip().lower(), value.strip() if key == "user-agent": if not in_agents: groups.append(([], [])) groups[-1][0].append(value) in_agents = True continue in_agents = False if key in ("allow", "disallow") and groups and value: groups[-1][1].append((key == "allow", value)) elif key == "crawl-delay": try: gap = max(gap, float(value)) except ValueError: pass return groups, gap def _rules(host: str): """Read https:///robots.txt once per run. A file that cannot be read, or a server that refuses this script, rules the host out; a 404 or another 4xx means the host sets no rules.""" if host not in _ROBOTS: _wait(host) try: req = urllib.request.Request(f"https://{host}/robots.txt", headers=UA) with urllib.request.urlopen(req, timeout=60) as r: status, body = r.status, r.read() except urllib.error.HTTPError as e: status, body = e.code, b"" except Exception as e: # noqa: BLE001 (no answer or a dropped connection: the file cannot be read) status, body = None, str(e).encode() # a bot check or any other web page served in place of the file is not a robots.txt webpage = bool(re.search(rb"(?i) bool: """True when the longest pattern matching the path is a Disallow (an Allow of equal length wins).""" best = (0, True) for allow, pattern in rules: rx = re.escape(pattern.rstrip("$")).replace(r"\*", ".*") + ("$" if pattern.endswith("$") else "") if re.match(rx, path) and (len(pattern), allow) > best: best = (len(pattern), allow) return not best[1] def robots_block(url: str) -> str | None: """None when robots.txt lets this script read the url, else the reason it may not.""" parts = urllib.parse.urlsplit(url) groups, why, _ = _rules(parts.hostname) if groups is None: return f"{parts.hostname}: {why}" path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "") for agents, rules in groups: if _closed(rules, path): who = "every crawler" if "*" in agents else "the crawlers it names (" + ", ".join(agents) + ")" return f"{parts.hostname}: robots.txt closes {parts.path} to {who}" return None def get(url: str) -> str | None: why = robots_block(url) if why: SKIPPED[url] = why return None _wait(urllib.parse.urlsplit(url).hostname) try: req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=60) as r: return r.read().decode("utf-8", "replace") except OSError as e: # HTTP errors, timeouts and dropped connections FAILED.append(f"{url} ({e})") return None # ------------------------------------------------------------------ the listings def esrb_letter(label: str | None) -> str: """'e', 'ESRB Everyone', 'ESRB:E10' -> the letters an ESRB certificate uses ('E', 'E10+', 'T', 'M' ...).""" s = re.sub(r"^ESRB[: ]*", "", (label or "").strip().upper()) s = {"EVERYONE 10+": "E10+", "EVERYONE": "E", "TEEN": "T", "MATURE 17+": "M", "MATURE": "M", "ADULTS ONLY 18+": "AO", "RATING PENDING": "RP"}.get(s, s) return "E10+" if s == "E10" else s def steam() -> dict: """{market: {"date": str, "Standard": price, "Deluxe": price, "esrb": label}} from the package subs.""" out = {} for cc in STEAM_MARKETS: body = get(f"https://store.steampowered.com/api/appdetails?appids={STEAM_APP}&cc={cc}&l=en") if not body: continue # The response is keyed by an id that need not equal the requested app id, so take the one value. data = next(iter(json.loads(body).values())).get("data", {}) row = {"date": data.get("release_date", {}).get("date")} esrb = (data.get("ratings") or {}).get("esrb") or {} row["esrb"] = f"{esrb.get('rating', '').upper()} ({esrb.get('descriptors', '').strip()})" if esrb else None for group in data.get("package_groups", []): for sub in group.get("subs", []): edition = "Deluxe" if "Deluxe" in sub.get("option_text", "") else "Standard" row[edition] = sub["price_in_cents_with_discount"] / 100 out[cc] = row return out def gog() -> dict: body = get("https://catalog.gog.com/v1/catalog?query=" + urllib.parse.quote("Transport Fever 3") + "&limit=10&countryCode=US&locale=en-US¤cyCode=USD") out = {} for p in json.loads(body).get("products", []) if body else []: if p.get("title", "").startswith("Transport Fever 3"): edition = "Deluxe" if "Deluxe" in p["title"] else "Standard" out[edition] = (p.get("price") or {}).get("final"), p.get("releaseDate"), p.get("productState") return out def playstation() -> dict: out = {} for loc in PS_LOCALES: page = get(f"https://store.playstation.com/{loc}/concept/{PS_CONCEPT}") if not page: continue out[loc] = { "releaseDate": sorted(set(re.findall(r'"releaseDate":"([^"]+)"', page))), "prices": sorted(set(re.findall(r'"basePrice":"([^"]+)"', page))), "cta": sorted(set(re.findall(r'"GameCTA:(\w+):', page))), "label": sorted(set(re.findall(r'data-qa="mfe-content-rating#ratingImage#preview" alt="([^"]+)"', page))), } return out def xbox() -> dict: out = {} for edition, pid in XBOX: for mk in XBOX_MARKETS: body = get(f"https://displaycatalog.mp.microsoft.com/v7.0/products/{pid}?market={mk}" "&languages=en-us&fieldsTemplate=details") if not body: continue p = json.loads(body)["Product"] buyable = [av for d in p["DisplaySkuAvailabilities"] for av in d["Availabilities"] if "Purchase" in av.get("Actions", [])] out[(edition, mk)] = { "price": [(av["OrderManagementData"]["Price"]["ListPrice"], av["OrderManagementData"]["Price"]["CurrencyCode"]) for av in buyable], "window_end": [av.get("Conditions", {}).get("EndDate") for av in buyable], "release": p["MarketProperties"][0].get("OriginalReleaseDate"), "esrb": next((r.get("RatingId") for r in p["MarketProperties"][0].get("ContentRatings", []) if r.get("RatingSystem") == "ESRB"), None), } return out def esrb() -> tuple: """(certificates read now, or None when the robots rules rule the search out; the url asked for).""" q = ("https://www.esrb.org/wp-admin/admin-ajax.php?action=search_rating&args[searchKeyword]=" + urllib.parse.quote("Transport Fever") + "&args[searchType]=All&args[pg]=1") body = get(q) if body is None: return None, q return [(g["certificateId"], g["title"], g["rating"], g["descriptors"], g["platforms"], g["publisher"]) for g in json.loads(body).get("games", [])], q if __name__ == "__main__": if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") s, g, ps, xb = steam(), gog(), playstation(), xbox() print("LAUNCH DATE, BY STOREFRONT") for cc, row in s.items(): print(f" Steam {cc.upper():3} {row.get('date')}") for edition, (price, date, state) in g.items(): print(f" GOG {edition:8} {date} ({state})") for loc, row in ps.items(): print(f" PS {loc:6} {', '.join(row['releaseDate'])} call to action: {', '.join(row['cta'])}") for (edition, mk), row in xb.items(): print(f" Xbox {edition:8} {mk} release field {row['release']} pre-order window ends {', '.join(map(str, row['window_end']))}") print("\nPRICES, STANDARD / DELUXE") for cc, row in s.items(): print(f" Steam {cc.upper():3} {row.get('Standard')} / {row.get('Deluxe')}") print(f" GOG US {g.get('Standard', ('-',))[0]} / {g.get('Deluxe', ('-',))[0]}") for loc, row in ps.items(): print(f" PS {loc:6} {' / '.join(row['prices'])}") for (edition, mk), row in xb.items(): print(f" Xbox {edition:8} {mk} {row['price']}") certs, url = esrb() print("\nESRB CERTIFICATES") if certs is None: print(f" not re-read ({SKIPPED.get(url, 'the search could not be read')});") print(" as the article read them on 25 September 2026:") certs = ESRB_ON_25_SEPTEMBER for cert in certs: print(" ", " | ".join(map(str, cert))) want = ESRB_ON_25_SEPTEMBER[0][2] print(f"\nTHE ESRB LABEL EACH STORE DISPLAYS (certificate {ESRB_ON_25_SEPTEMBER[0][0]} says {want})") shown = [("Steam US", (s.get("us") or {}).get("esrb"))] shown += [(f"PS {loc}", ", ".join(row["label"])) for loc, row in ps.items() if loc == "en-us"] shown += [(f"Xbox {edition} US", row["esrb"]) for (edition, mk), row in xb.items() if mk == "US"] for where, label in shown: verdict = "not shown" if not label else "agrees" if esrb_letter(label.split(" (")[0]) == want else "DIFFERS" print(f" {where:20} {label or '-':28} {verdict}") if SKIPPED: print("\nnot requested, under the hosts' robots.txt rules:\n " + "\n ".join(dict.fromkeys(SKIPPED.values()))) if FAILED: print("\nendpoints that did not answer:\n " + "\n ".join(FAILED)) sys.exit(1 if FAILED else 0)