"""subnautica-2-check.py - re-check the public figures behind the Subnautica 2 article (published 8 October 2026), and see whether the game has left Early Access yet. The article quotes Steam prices in three currencies, a review count, a concurrent-player count, the dated updates Unknown Worlds has posted since Early Access began on 14 May 2026, the Microsoft Store listing, and Microsoft's own lists of the games included with Game Pass. Prices, counts and catalogues move, so this script re-reads the same public figures and prints them. Nothing here needs a key or an account. What it reads: Steam appdetails store.steampowered.com/api/appdetails for app 1962700, once per storefront (cc=us, cc=gb, cc=de) Steam reviews store.steampowered.com/appreviews/1962700 (all languages) Steam players api.steampowered.com ISteamUserStats/GetNumberOfCurrentPlayers, only while that host's robots.txt allows it (see below) Steam events store.steampowered.com/events/ajaxgetpartnereventspageable, the store's own list of the developer's posts Microsoft displaycatalog.mp.microsoft.com, product 9PJPCB188SVG (US) Game Pass catalog.gamepass.com, the "All PC Games" and "All Console Games" lists The Epic Games Store, the rating boards and the developer's websites are not read here: they serve HTML pages whose layout changes (Epic refused our script outright), and their facts in the article were read from saved pages by hand. Robots rules (version 1.1, 10 October 2026). Before its first call 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 api.steampowered.com's robots.txt closed every path to every crawler, so the script cannot re-read the player count: it prints the article's figure, 1,303 at 12:19 UTC on 8 October 2026, marked as such. Version 1.0 also read the update list from that host's news feed; this one reads the same posts from the store's event list. Every call names this script in its User-Agent header, 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, datetime, sys). Python 3.9 or newer. python subnautica-2-check.py """ import datetime import json import re import sys import time import urllib.error import urllib.parse import urllib.request APP = 1962700 XBOX_ID = "9PJPCB188SVG" UA = "subnautica-2-check/1.1 (+https://blastproc.com/code/subnautica-2-check.py; reader re-check script)" HEADERS = {"User-Agent": UA} ARTICLE_DAY = datetime.date(2026, 10, 8) # Microsoft's public Game Pass collection lists; each list names itself in its # first element, and the script prints that name rather than trusting this label. GAME_PASS_LISTS = { "fdd9e2a7-0fee-49f6-ad69-4354098401ff": "PC", "f6f1f99f-9b49-4ccd-b3bf-4d9767a77f5e": "console", } # What the article printed on 8 October 2026, for comparison. PRINTED = {"us": "$29.99", "gb": "£25.99", "de": "29,99€", "reviews": 128446, "players": 1303} 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): 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=HEADERS) 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 get(url): """The parsed answer, or None when the robots rules rule the url out (the reason goes to SKIPPED).""" why = robots_block(url) if why: SKIPPED[url] = why return None _wait(urllib.parse.urlsplit(url).hostname) req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode("utf-8")) # ------------------------------------------------------------------ the figures def steam_store(): print("Steam store, app", APP) for cc in ("us", "gb", "de"): url = f"https://store.steampowered.com/api/appdetails?appids={APP}&cc={cc}&l=en" answer = get(url) if answer is None: print(f" {cc}: not read ({SKIPPED[url]})") continue d = answer[str(APP)] if not d.get("success"): print(f" {cc}: no answer") continue data = d["data"] po = data.get("price_overview") or {} price = po.get("final_formatted", "no price") off = po.get("discount_percent", 0) print(f" {cc}: {price}" + (f" ({off}% off)" if off else "") + f" printed {PRINTED[cc]}") if cc == "us": genres = [g["description"] for g in data.get("genres", [])] still_ea = "Early Access" in genres print(f" release date field: {data['release_date']['date']}") print(f" still in Early Access on Steam: {'yes' if still_ea else 'NO - the Early Access genre is gone'}") def steam_counts(): url = f"https://store.steampowered.com/appreviews/{APP}?json=1&language=all&purchase_type=all&num_per_page=0" answer = get(url) if answer is None: print(f"Reviews (all languages): not read ({SKIPPED[url]}) printed {PRINTED['reviews']:,}") else: q = answer["query_summary"] share = 100 * q["total_positive"] / q["total_reviews"] if q["total_reviews"] else 0 print(f"Reviews (all languages): {q['total_reviews']:,}, {q['total_positive']:,} positive ({share:.1f}%), " f"\"{q['review_score_desc']}\" printed {PRINTED['reviews']:,}") url = f"https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid={APP}" answer = get(url) if answer is None: print(f"Playing on Steam now: not read ({SKIPPED[url]});") print(f" printed {PRINTED['players']:,} at 12:19 UTC on 8 October") else: now = datetime.datetime.now(datetime.timezone.utc).strftime("%H:%M UTC") print(f"Playing on Steam now ({now}): {answer['response']['player_count']:,} printed {PRINTED['players']:,} at 12:19 UTC on 8 October") def updates(): url = ("https://store.steampowered.com/events/ajaxgetpartnereventspageable/" f"?clan_accountid=0&appid={APP}&offset=0&count=100&l=english") answer = get(url) if answer is None: print(f"Developer posts on Steam: not read ({SKIPPED[url]})") return posts = [e["announcement_body"] for e in answer.get("events") or [] if e.get("announcement_body")] print(f"Developer posts on Steam: {len(posts)} in the store's event list") for post in sorted(posts, key=lambda p: p["posttime"]): day = datetime.datetime.fromtimestamp(post["posttime"], datetime.timezone.utc).date() title = post["headline"] released = any(w in title for w in ("Update", "Hotfix", "Branch", "Early Access Released")) if day >= datetime.date(2026, 5, 14) and released and "Vlog" not in title: new = " NEW since the article" if day > ARTICLE_DAY else "" print(f" {day.isoformat()} {title}{new}") def microsoft(): answer = get(f"https://displaycatalog.mp.microsoft.com/v7.0/products/{XBOX_ID}?market=US&languages=en-us" "&fieldsTemplate=details") if answer is None: print("Microsoft Store: not read (" + "; ".join(dict.fromkeys(SKIPPED.values())) + ")") return p = answer["Product"] title = p["LocalizedProperties"][0]["ProductTitle"] prices = set() for dsa in p["DisplaySkuAvailabilities"]: for av in dsa["Availabilities"]: if "Purchase" in av.get("Actions", []): pr = av["OrderManagementData"]["Price"]["ListPrice"] if pr: prices.add(pr) print(f"Microsoft Store {XBOX_ID}: \"{title}\", purchase price(s) USD {sorted(prices)}" + (" (still a Game Preview)" if "Game Preview" in title else " (the Game Preview label is gone)")) for sigl, label in GAME_PASS_LISTS.items(): url = f"https://catalog.gamepass.com/sigls/v2?id={sigl}&language=en-us&market=US" lst = get(url) if lst is None: print(f" Game Pass list {sigl}: not read ({SKIPPED[url]})") continue ids = {x.get("id") for x in lst[1:]} print(f" Game Pass list \"{lst[0].get('title')}\" ({label}, {len(ids)} games): " f"{'includes' if XBOX_ID in ids else 'does NOT include'} Subnautica 2") if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") for step in (steam_store, steam_counts, updates, microsoft): try: step() except Exception as exc: # noqa: BLE001 (one dead endpoint should not hide the others) print(f"{step.__name__}: could not read ({exc})") print() if SKIPPED: print("not read, under the hosts' robots.txt rules:\n " + "\n ".join(dict.fromkeys(SKIPPED.values())))