"""stou-launch-check.py - re-check what Playstack, Magic Fuel Games and Steam say about Star Trek: Outposts Unknown before and after its October 2026 launch. The article that ships with this file was written on 15 October 2026, five days before the game's stated release on 20 October, from the developer's and publisher's own Steam posts, Playstack's announcement page and the Steam store record. On that day the only price anywhere was a sentence in the developer's post of 15 October ("$32.99 USD"); the Steam record itself carried no price, listed Windows only, and offered no demo. Store records change at launch, so this script re-reads the same public records and marks each figure that no longer matches the one the article printed: 1. the Steam record for app 3469910 on the US, UK and German storefronts: release date, whether it is still "coming soon", the price if one is now listed, the platforms (Mac support is promised "in a future patch"), and whether a demo is attached; 2. the game's Steam posts: every post with its date and title, and in each one any price written as "$NN.NN USD" and any sentence about where the game "will only be available". What it reads (all on store.steampowered.com): events /events/ajaxgetpartnereventspageable/?appid=3469910 (newest 30 posts) store /api/appdetails?appids=3469910&cc=us|gb|de Robots rules. Before its first call to a host the script reads that host's robots.txt and 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 15 October 2026 store.steampowered.com allowed both paths. The script does not call api.steampowered.com, whose robots.txt closes every path to every crawler. Every call names this script in its User-Agent header, and calls are at least two seconds apart, or as far apart as the host's Crawl-delay asks. Standard library only (urllib, json, re, html, time, sys, datetime). Python 3.9 or newer. python stou-launch-check.py read the live posts and store record python stou-launch-check.py --events FILE.json read a saved copy of the events endpoint's answer instead of the live posts (the store is skipped) Exit code is 0 when every record answered, 1 when any of them did not. """ import datetime import html import json import re import sys import time import urllib.error import urllib.parse import urllib.request UA = "stou-launch-check/1.0 (+https://blastproc.com/code/stou-launch-check.py; reader re-check script)" HEADERS = {"User-Agent": UA, "Accept": "text/html,application/json;q=0.9,*/*;q=0.5"} APP = 3469910 ANNOUNCED = datetime.date(2026, 10, 20) # Steam post of 26 August 2026, "launches on Steam October 20" EVENTS = f"https://store.steampowered.com/events/ajaxgetpartnereventspageable/?clan_accountid=0&appid={APP}&offset=0&count=30&l=english" # What the article printed on 15 October 2026, for comparison. PRINTED = { "date": {"us": "Oct 20, 2026", "gb": "20 Oct, 2026", "de": "20 Oct, 2026"}, "coming_soon": True, "price": None, # no price in the store record on 15 October "platforms": ["windows"], "demo": None, # no demo attached to the record on 15 October "post_price": "$32.99 USD", # Captain's Devlog #4, 15 October 2026 "posts": 7, } SKIPPED = {} FAILED = [] # ------------------------------------------------------------------ robots.txt, read before the first call to a host MIN_GAP = 2.0 _ROBOTS = {} # host -> (groups or None, why the host is skipped, seconds between calls) _LAST = {} 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, a web page served in its place, 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() webpage = bool(re.search(rb"(?i) best: best = (len(pattern), allow) return not best[1] def robots_block(url): 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 page text, or None when the robots rules rule the url out (reason in SKIPPED) or the call fails.""" why = robots_block(url) if why: SKIPPED[url] = why return None _wait(urllib.parse.urlsplit(url).hostname) try: req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req, timeout=60) as r: return r.read().decode("utf-8", "replace") except Exception as e: # noqa: BLE001 FAILED.append(f"{url}: {e}") return None def mark(now, printed): return "" if now == printed else f" CHANGED (printed {printed})" # ------------------------------------------------------------------ Steam store record def store(): print("Steam store record (store.steampowered.com/api/appdetails)") for cc in ("us", "gb", "de"): text = get(f"https://store.steampowered.com/api/appdetails?appids={APP}&cc={cc}&l=english") if text is None: print(f" {cc}: not read") continue d = json.loads(text)[str(APP)].get("data") or {} rd = d.get("release_date", {}) price = (d.get("price_overview") or {}).get("final_formatted") plats = [k for k, v in (d.get("platforms") or {}).items() if v] demo = [x.get("appid") for x in d.get("demos") or []] or None print(f" {cc}: release {rd.get('date')!r}{mark(rd.get('date'), PRINTED['date'][cc])}") print(f" coming soon {rd.get('coming_soon')}{mark(rd.get('coming_soon'), PRINTED['coming_soon'])}; " f"price {price}{mark(price, PRINTED['price'])}") if cc == "us": print(f" developers {d.get('developers')}; publishers {d.get('publishers')}") print(f" platforms {plats}{mark(plats, PRINTED['platforms'])}; demo {demo}{mark(demo, PRINTED['demo'])}") # ------------------------------------------------------------------ the game's Steam posts def plain(bbcode): t = re.sub(r"\[/?(p|b|i|u|h\d|list|\*|carousel)\]", " ", bbcode) t = re.sub(r"\[[^\]]*\]", " ", t) return re.sub(r"\s+", " ", html.unescape(t)).strip() def posts(events_json): data = json.loads(events_json) evs = sorted(data.get("events", []), key=lambda e: e["rtime32_start_time"]) print(f"\nSteam posts for app {APP}: {len(evs)}{mark(len(evs), PRINTED['posts'])}") newest_price = None for e in evs: day = datetime.datetime.fromtimestamp(e["rtime32_start_time"], datetime.timezone.utc).date() body = plain(e["announcement_body"]["body"]) print(f" {day} {e['event_name'].strip()}") for p in re.findall(r"\$\d+\.\d\d USD", body): print(f" price in the post: {p}") newest_price = p for s in re.split(r"(?<=[.!?])\s+", body): if "will only be available" in s: print(f" where: {s.strip()}") print(f" newest price in a post: {newest_price}{mark(newest_price, PRINTED['post_price'])}") if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") today = datetime.date.today() print(f"Star Trek: Outposts Unknown launch check, {today.isoformat()}: " f"{(ANNOUNCED - today).days} days to the announced 20 October 2026\n") if "--events" in sys.argv: with open(sys.argv[sys.argv.index("--events") + 1], encoding="utf-8") as f: posts(f.read()) else: store() text = get(EVENTS) if text is not None: posts(text) for url, why in SKIPPED.items(): print(f"skipped {url}: {why}") for f in FAILED: print(f"failed {f}") sys.exit(1 if FAILED or SKIPPED else 0)