"""zoo-listing-audit.py - re-check what the storefronts and ratings boards say about Planet Zoo 2 and Planet Zoo. The article that ships with this file quotes prices, release timestamps and age ratings read on 22 September 2026. Storefront data moves and ratings boards publish late, 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, one call per storefront (cc=us, cc=uk, cc=de) for each app id Xbox displaycatalog.mp.microsoft.com/v7.0/products/, the same catalogue the store pages render from PlayStation store.playstation.com product pages; price, release date and the call-to-action sit in the embedded Apollo cache, which this reads with a regular expression rather than a browser ESRB esrb.org search endpoint, which returns the certificate number, rating, descriptors and interactive elements USK and PEGI are not read here: both serve their databases as HTML search pages with no documented endpoint, so their figures in the article were read by hand. The USK certificate numbers are printed below for anyone re-checking them. 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. Version 1.0 re-read four endpoints; this one re-reads three, prints the two ESRB certificates as the article recorded them on 22 September, marked as such, and beside them the ESRB label that Steam, the Microsoft Store and the PlayStation 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 zoo-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": "zoo-listing-audit/1.1 (+https://blastproc.com/code/zoo-listing-audit.py; reader re-check script)", "Accept-Language": "en-US,en;q=0.9"} # Steam app ids, and the three storefronts the article quotes. STEAM = {"Planet Zoo 2": 3219030, "Planet Zoo": 703080} MARKETS = (("us", "english"), ("uk", "english"), ("de", "german")) # Xbox catalogue ids. The first two take money; three of the rest do not, and # the article explains why. XBOX = ( ("Planet Zoo 2", "9NB4JF6B6VSF"), ("Planet Zoo 2: Deluxe Edition", "9NKKL93XSF77"), ("Planet Zoo 2 (second entry)", "9P2578Z09ZH5"), ("Planet Zoo 2: Bonus Content Pack", "9PB3QZ5K3PFQ"), ("Planet Zoo 2: Deluxe Upgrade Pack", "9P420N5D1B0X"), ("Planet Zoo: Console Edition", "9NZ038KZ68B7"), ("Planet Zoo: Deluxe Edition", "9NM6ZK43RXWB"), ("Planet Zoo: Ultimate Edition", "9N5S3422QD6N"), ) # PlayStation product ids. Note the EP prefix: this is a European product id # that the United States storefront also serves. The UP-prefixed variant a US # listing would normally carry does not exist. PLAYSTATION = ( ("Planet Zoo 2", "en-us", "EP2377-PPSA29934_00-PLANETZOO2000000"), ("Planet Zoo 2", "en-gb", "EP2377-PPSA29934_00-PLANETZOO2000000"), ("Planet Zoo 2: Deluxe Edition", "en-us", "EP2377-PPSA29934_00-PZ2DELUXEEDITION"), ) # Certificate numbers printed for hand re-checking at usk.de. USK_CERTIFICATES = ( ("Planet Zoo (PC)", "47697/19", "05.12.2019", "ab 6 Jahren", "Frontier Developments PLC"), ("Planet Zoo: Console Edition (Xbox Series)", "55641/24", "18.06.2024", "ab 6 Jahren", "Sold Out Sales and Marketing"), ("Planet Zoo: Console Edition (PlayStation 5)", "55642/24", "18.06.2024", "ab 6 Jahren", "Sold Out Sales and Marketing"), ("Planet Zoo 2 (PC)", "59049/26", "01.07.2026", "ohne Altersbeschraenkung", "Frontier Developments PLC"), ("Planet Zoo 2 (Xbox Series)", "59050/26", "01.07.2026", "ohne Altersbeschraenkung", "Frontier Developments PLC"), ("Planet Zoo 2 (PlayStation 5)", "59051/26", "01.07.2026", "ohne Altersbeschraenkung", "Frontier Developments PLC"), ) # The ESRB search as the article recorded it on 22 September 2026 (certificate, # title, rating, descriptors, interactive elements; both for Windows PC, # PlayStation 5 and Xbox Series, publisher Frontier Developments), printed when # the robots rules keep this script from re-reading it. ESRB_ON_22_SEPTEMBER = ( ("41461", "Planet Zoo 2", "E10+", "Crude Humor, Mild Blood, Mild Violence", "Users Interact, In-Game Purchases"), ("36682", "Planet Zoo", "E10+", "Crude Humor, Mild Blood, Mild Violence", "Users Interact"), ) FAILED = [] SKIPPED = {} # url -> why the robots rules ruled it out STORE_ESRB = [] # (where, the ESRB label a store displays), gathered by the tables # ------------------------------------------------------------------ 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): 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): """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): """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) best: best = (len(pattern), allow) return not best[1] def robots_block(url): """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 fetch(url): """The response body, or None when the robots rules rule the url out.""" why = robots_block(url) if why: SKIPPED[url] = why return None _wait(urllib.parse.urlsplit(url).hostname) request = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(request, timeout=45) as response: return response.read() # ------------------------------------------------------------------ the tables def money(cents, currency): if cents is None: return "-" return f"{cents / 100:.2f} {currency or ''}".strip() def steam_table(): print("STEAM") print(f" {'title':<14} {'market':<7} {'release':<16} {'base':>12} {'deluxe':>12}") for title, app in STEAM.items(): for market, language in MARKETS: url = ("https://store.steampowered.com/api/appdetails" f"?appids={app}&cc={market}&l={language}") try: body = fetch(url) if body is None: continue payload = json.loads(body)[str(app)] except (OSError, ValueError, KeyError) as exc: FAILED.append(f"steam {app} {market}: {exc}") continue if not payload.get("success"): FAILED.append(f"steam {app} {market}: no data") continue data = payload["data"] price = data.get("price_overview") or {} subs = [s for group in data.get("package_groups", []) for s in group.get("subs", [])] deluxe = next((s["price_in_cents_with_discount"] for s in subs if "deluxe" in s.get("option_text", "").lower()), None) print(f" {title:<14} {market:<7} {data['release_date']['date']:<16} " f"{money(price.get('final'), price.get('currency')):>12} " f"{money(deluxe, price.get('currency')):>12}") esrb = (data.get("ratings") or {}).get("esrb") or {} if market == "us": STORE_ESRB.append((f"Steam, {title}", esrb.get("rating", "").upper() or None)) print() def xbox_table(): print("XBOX DISPLAY CATALOGUE") print(f" {'id':<14} {'title':<34} {'release (UTC)':<21} {'price':>10} actions") for title, product in XBOX: url = (f"https://displaycatalog.mp.microsoft.com/v7.0/products/{product}" "?market=US&languages=en-us&fieldsTemplate=details") try: raw = fetch(url) if raw is None: continue body = json.loads(raw) except (OSError, ValueError) as exc: FAILED.append(f"xbox {product}: {exc}") continue entry = body.get("Product") or body["Products"][0] release = entry["MarketProperties"][0].get("OriginalReleaseDate") or "-" buyable = None every_action = set() for sku in entry.get("DisplaySkuAvailabilities", []): for availability in sku.get("Availabilities", []): actions = availability.get("Actions") or [] every_action.update(actions) if "Purchase" in actions: price = availability["OrderManagementData"]["Price"] buyable = f"{price['ListPrice']:.2f} {price['CurrencyCode']}" other = sorted(every_action & {"Redeem", "License", "Fulfill"}) print(f" {product:<14} {title:<34} {release[:19]:<21} " f"{buyable or 'no price':>10} " f"{'Purchase' if buyable else ','.join(other)}") if product in ("9NB4JF6B6VSF", "9NZ038KZ68B7"): STORE_ESRB.append((f"Xbox, {title}", next((r.get("RatingId") for r in entry["MarketProperties"][0].get("ContentRatings", []) if r.get("RatingSystem") == "ESRB"), None))) print() def playstation_table(): print("PLAYSTATION STORE") print(f" {'store':<8} {'title':<30} {'release (UTC)':<22} {'price':>9} cta") for title, locale, product in PLAYSTATION: url = f"https://store.playstation.com/{locale}/product/{product}" try: raw = fetch(url) if raw is None: continue page = raw.decode("utf-8", "replace") except OSError as exc: FAILED.append(f"playstation {locale} {product}: {exc}") continue release = re.search(r'"releaseDate":"([^"]+)"', page) # Several products appear in one page's cache, so take the price that # follows this product's own sku id rather than the first one found. # The sku suffix is regional: -U001 on the US store, -E001 on the UK one. own = re.search(r'"value":"' + re.escape(product) + r'-\w{4}".{0,600}?"basePrice":"([^"]+)"', page, re.S) cta = sorted(set(re.findall(r'"GameCTA:(\w+):\w+:' + re.escape(product), page))) print(f" {locale:<8} {title:<30} " f"{(release.group(1) if release else '-'):<22} " f"{(own.group(1) if own else '-'):>9} {','.join(cta) or '-'}") if locale == "en-us" and product.endswith("PLANETZOO2000000"): labels = sorted(set(re.findall(r'"authority":"ESRB","description":"([^"]+)"', page))) STORE_ESRB.append((f"PlayStation, {title}", ", ".join(labels) or None)) print() def esrb_table(): print("ESRB") url = ("https://www.esrb.org/wp-admin/admin-ajax.php" "?action=search_rating&args[searchKeyword]=Planet%20Zoo") try: raw = fetch(url) body = json.loads(raw) if raw is not None else None except (OSError, ValueError) as exc: FAILED.append(f"esrb: {exc}") return print(f" {'cert':<7} {'title':<16} {'rating':<7} " f"{'descriptors':<40} interactive elements") if body is None: print(f" not re-read ({SKIPPED.get(url)});") print(" as the article recorded them on 22 September 2026:") for cert, title, rating, descriptors, elements in ESRB_ON_22_SEPTEMBER: print(f" {cert:<7} {title:<16} {rating:<7} {descriptors:<40} {elements}") print(f" ({len(ESRB_ON_22_SEPTEMBER)} certificates found on 22 September)") else: for game in body.get("games", []): elements = ", ".join(e["name"] for e in game.get("interactiveElements", [])) print(f" {game['certificateId']:<7} {game['submissionTitle']:<16} " f"{game['rating']:<7} {game['descriptors']:<40} {elements}") print(f" ({body.get('found')} certificates found)") want = ESRB_ON_22_SEPTEMBER[0][2] print(f"\n The ESRB label each store displays today (both certificates say {want}):") for where, label in STORE_ESRB: letters = re.sub(r"^ESRB[: ]*", "", (label or "").upper()).replace("EVERYONE 10+", "E10+").replace("EVERYONE", "E") letters = "E10+" if letters == "E10" else letters verdict = "not shown" if not label else "agrees" if letters == want else "DIFFERS" print(f" {where:<40} {label or '-':<20} {verdict}") print() def usk_table(): print("USK CERTIFICATES (read by hand at usk.de, printed here for re-checking)") print(f" {'number':<10} {'classified':<12} {'rating':<26} " f"{'publisher':<30} title") for title, number, date, rating, publisher in USK_CERTIFICATES: print(f" {number:<10} {date:<12} {rating:<26} {publisher:<30} {title}") print() def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") steam_table() xbox_table() playstation_table() esrb_table() usk_table() if SKIPPED: print("not requested, under the hosts' robots.txt rules:") for why in dict.fromkeys(SKIPPED.values()): print(" -", why) if FAILED: print("endpoints that did not answer:") for line in FAILED: print(" -", line) sys.exit(1) print("every endpoint the robots rules allowed answered") if __name__ == "__main__": main()