"""pbz-listing-audit.py - re-check what the storefronts and three ratings boards say about Phantom Blade Zero before its October 2026 launch. The article that ships with this file quotes a launch date that differs by one day between storefronts, prices in five currencies on two storefronts, the age labels those storefronts display, and three ratings-board searches that came back empty, all read on 6 October 2026. Pre-order data moves, so this script re-reads the same public pages and prints the same tables, and converts the PlayStation Store's launch timestamp into local clock times. What it reads, all public and none of it needing a key: Steam store.steampowered.com/api/appdetails for app 4115450, one call per storefront (cc=us, cc=gb, cc=de); the two editions are the two "subs" of the purchase package group, and the "ratings" block carries each label plus a rating_generated flag PlayStation store.playstation.com concept page 10017620 (the id the official site links to) in five locales; release date and prices sit in the embedded Apollo cache and the displayed age label is the alt text of the rating image, read with regular expressions ESRB esrb.org search endpoint (JSON; "total" is the match count) PEGI pegi.info search page ("Found N results from your query") USK usk.de search page ("N titles and N articles found") 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 two of the three ratings boards: esrb.org's robots.txt closes its rating pages and search results to a group of named crawlers, and pegi.info closed the connection without sending its robots.txt. For a board it may not read, the script prints the result the article read on 6 October, marked as such; the USK search and the age labels the two stores display are still read live. 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. The Epic Games Store page answered an automated request with HTTP 403 on the day of writing, so it is not read. Xbox is not read because the developer has named no Xbox version. Standard library only (urllib, json, re, time, datetime, zoneinfo). Python 3.9 or newer; on Windows the local-time table needs the tzdata package, and the script skips that table if it is missing. python pbz-listing-audit.py Exit code is 0 when every page the robots rules allowed answered, 1 when any of them did not. """ from __future__ import annotations import datetime import html import json import re import sys import time import urllib.error import urllib.parse import urllib.request UA = {"User-Agent": "pbz-listing-audit/1.1 (+https://blastproc.com/code/pbz-listing-audit.py; reader re-check script)", "Accept-Language": "en-US,en;q=0.9"} STEAM_APP = 4115450 STEAM_MARKETS = ("us", "gb", "de") PS_CONCEPT = 10017620 PS_LOCALES = ("en-us", "en-gb", "de-de", "ja-jp", "zh-hant-tw") CITIES = (("Los Angeles", "America/Los_Angeles"), ("New York", "America/New_York"), ("London", "Europe/London"), ("Berlin", "Europe/Berlin"), ("Beijing", "Asia/Shanghai"), ("Tokyo", "Asia/Tokyo")) # What the article read on 6 October 2026, printed for a board the robots rules keep this script from re-reading. BOARDS_ON_6_OCTOBER = {"ESRB": "0 ratings", "PEGI": "0 results", "USK": "0 titles"} 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 steam() -> dict: """{market: {"date": str, "Standard": price, "Deluxe": price, "ratings": {...}}}.""" 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 data = next(iter(json.loads(body).values())).get("data", {}) row = {"date": data.get("release_date", {}).get("date"), "ratings": data.get("ratings") or {}} 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.get("option_text", "").rsplit(" - ", 1)[-1] out[cc] = row 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)), key=lambda p: float(re.sub(r"[^0-9.]", "", p.replace(",", ".")) or 0)), "label": sorted(set(re.findall(r'data-qa="mfe-content-rating#ratingImage#preview" alt="([^"]+)"', page))), } return out def plain(page: str | None) -> str: """Visible text with tags removed and runs of whitespace collapsed (both search pages wrap the count in markup).""" return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", page or ""))) def boards() -> dict: """{board: (what the search says now, or None; the url asked for)}.""" out = {} url = ("https://www.esrb.org/wp-admin/admin-ajax.php?action=search_rating&args[searchKeyword]=" + urllib.parse.quote("Phantom Blade") + "&args[searchType]=All&args[pg]=1") body = get(url) out["ESRB"] = (f"{json.loads(body).get('total')} ratings" if body else None, url) url = "https://pegi.info/search-pegi?q=" + urllib.parse.quote("Phantom Blade") m = re.search(r"Found (\d+) results? from your query", plain(get(url))) out["PEGI"] = (f"{m.group(1)} results" if m else None, url) url = "https://www.usk.de/en/?s=" + urllib.parse.quote_plus("Phantom Blade") m = re.search(r"(\d+) titles? and (\d+) articles? found", plain(get(url))) out["USK"] = (f"{m.group(1)} titles" if m else None, url) return out def local_times(stamp: str) -> list: try: from zoneinfo import ZoneInfo t = datetime.datetime.fromisoformat(stamp.replace("Z", "+00:00")) return [(city, t.astimezone(ZoneInfo(zone)).strftime("%a %d %b %H:%M %Z")) for city, zone in CITIES] except Exception as e: # noqa: BLE001 - zoneinfo data missing on some Windows installs return [("(local times skipped)", str(e))] if __name__ == "__main__": if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # pound, euro and yen signs on a Windows console s, ps, b = steam(), playstation(), boards() print("LAUNCH DATE, BY STOREFRONT") for cc, row in s.items(): print(f" Steam {cc.upper():10} {row.get('date')}") for loc, row in ps.items(): print(f" PS {loc:10} {', '.join(row['releaseDate'])}") stamps = sorted({d for row in ps.values() for d in row["releaseDate"]}) if stamps: print(f"\nTHE PLAYSTATION TIMESTAMP {stamps[0]} IN LOCAL TIME") for city, when in local_times(stamps[0]): print(f" {city:12} {when}") print("\nPRICES, STANDARD / DIGITAL DELUXE") for cc, row in s.items(): print(f" Steam {cc.upper():10} {row.get('Standard')} / {row.get('Deluxe')}") for loc, row in ps.items(): print(f" PS {loc:10} {' / '.join(row['prices'])}") print("\nAGE LABELS THE STORES DISPLAY") for loc, row in ps.items(): print(f" PS {loc:10} {', '.join(row['label']) or '-'}") for system, r in (s.get("us", {}).get("ratings") or {}).items(): flag = "generated by Steam" if r.get("rating_generated") == "1" else "not marked as generated" print(f" Steam {system:10} {r.get('rating')} ({flag})") print("\nRATINGS-BOARD DATABASES, SEARCH FOR 'Phantom Blade'") for board, (result, url) in b.items(): if result is not None: print(f" {board:5} {result}") elif url in SKIPPED: print(f" {board:5} not re-read ({SKIPPED[url]});") print(f" the article read it on 6 October 2026: {BOARDS_ON_6_OCTOBER[board]}") else: print(f" {board:5} could not be read") 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)