"""oss-patch-tally.py - list Order of the Sinking Star's Steam posts and count what its patch notes fixed, and re-read the store figures behind the Order of the Sinking Star article (published 14 October 2026). Thekla, Inc. posts its patch notes as Steam announcements titled "Update notes for ...". Each one opens with a sentence or two about the headline fix and then gives a bulleted list. This script reads the game's Steam posts through the store's public events endpoint, prints every post with its date, and for each patch-notes post counts the bulleted items and how many of them mention an exploit or a "cheesy" solution, a checkpoint, a crash, a "jailbreak", or voice, subtitle or audio work. The counts are keyword matches on the bullet text, so an item can fall into more than one group or into none; the opening sentences are printed but not counted. It then prints the Steam price on the US, UK and German storefronts and the all-language review summary. What it reads (all on store.steampowered.com): events /events/ajaxgetpartnereventspageable/?appid=499170 (newest 30 posts) prices /api/appdetails?appids=499170&cc=us|gb|de reviews /appreviews/499170?json=1&language=all 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 14 October 2026 store.steampowered.com allowed all three paths. The script does not call api.steampowered.com, whose robots.txt closes every path to every crawler, so it prints no player count. 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 oss-patch-tally.py read the live posts and store figures python oss-patch-tally.py --events FILE.json count a saved copy of the events endpoint's answer instead (no network) """ import datetime import html import json import re import sys import time import urllib.error import urllib.parse import urllib.request UA = "oss-patch-tally/1.0 (+https://blastproc.com/code/oss-patch-tally.py; reader re-check script)" HEADERS = {"User-Agent": UA, "Accept": "text/html,application/json;q=0.9,*/*;q=0.5"} APP = 499170 EVENTS = f"https://store.steampowered.com/events/ajaxgetpartnereventspageable/?clan_accountid=0&appid={APP}&offset=0&count=30&l=english" GROUPS = { "exploit or cheese": r"exploit|chees", "checkpoint": r"checkpoint", "crash": r"crash", "jailbreak": r"jailbreak", "voice, subtitle or audio": r"voice|subtitle|audio|\bvo\b|sound|music", } # What the article printed on 14 October 2026, for comparison. PRINTED = { "us": "$29.99", "gb": "£26.75", "de": "30,49€", "reviews": "2,129 reviews, 2,069 positive", "patch_posts": 4, "bullets": 59, } SKIPPED = {} # url -> why the robots rules ruled it out # ------------------------------------------------------------------ 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, 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 fetch(url): """(final url, text) 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=60) as r: raw = r.read() final = r.geturl() if urllib.parse.urlsplit(final).hostname != urllib.parse.urlsplit(url).hostname and robots_block(final): SKIPPED[url] = robots_block(final) return None try: return final, raw.decode("utf-8") except UnicodeDecodeError: return final, raw.decode("cp1252", "replace") # ------------------------------------------------------------------ patch notes def bbcode_lines(body): """A Steam post body (BBCode) as (lead lines, bullet items).""" lead, bullets = [], [] in_list = False for raw in body.replace("\r", "").split("\n"): line = raw.strip() if re.match(r"(?i)^\[list\]", line): in_list = True line = line[6:].strip() if re.match(r"(?i)^\[/list\]", line): in_list = False continue if not line: continue if line.startswith("[*]"): bullets.append(html.unescape(line[3:].strip())) elif not in_list: lead.append(html.unescape(re.sub(r"\[/?[a-z0-9]+[^\]]*\]", "", line)).strip()) return [x for x in lead if x], bullets def tally(events): """Print every post, then the patch-notes counts. Returns the totals for the summary.""" rows = [] for e in events: body = e.get("announcement_body") or {} when = datetime.datetime.fromtimestamp(body.get("posttime", e["rtime32_start_time"]), datetime.timezone.utc) rows.append((when, e["event_name"], body.get("body", ""))) rows.sort() print(f"Steam posts for app {APP}: {len(rows)}") for when, name, _ in rows: print(f" {when:%Y-%m-%d %H:%M} UTC {name}") totals = {"posts": 0, "bullets": 0, **{g: 0 for g in GROUPS}} print("\nPatch notes (posts titled 'Update notes ...')") for when, name, body in rows: if not name.lower().startswith("update notes"): continue lead, bullets = bbcode_lines(body) counts = {g: sum(1 for b in bullets if re.search(rx, b, re.I)) for g, rx in GROUPS.items()} totals["posts"] += 1 totals["bullets"] += len(bullets) for g in GROUPS: totals[g] += counts[g] print(f"\n {when:%Y-%m-%d %H:%M} UTC {name}: {len(bullets)} bulleted items") for x in lead: print(f" lead: {x[:110]}") print(" " + ", ".join(f"{g} {n}" for g, n in counts.items())) print(f"\n total: {totals['posts']} patch-notes posts, {totals['bullets']} bulleted items" f" (printed {PRINTED['patch_posts']} posts, {PRINTED['bullets']} items)") print(" " + ", ".join(f"{g} {totals[g]}" for g in GROUPS)) return totals # ------------------------------------------------------------------ store figures def store(): print("\nSteam price") for cc in ("us", "gb", "de"): got = fetch(f"https://store.steampowered.com/api/appdetails?appids={APP}&cc={cc}&l=english") if got is None: print(f" {cc}: not read") continue d = json.loads(got[1])[str(APP)] po = (d.get("data") or {}).get("price_overview") or {} off = po.get("discount_percent", 0) print(f" {cc}: {po.get('final_formatted', 'no price')}" + (f" ({off}% off)" if off else "") + f" printed {PRINTED[cc]}") got = fetch(f"https://store.steampowered.com/appreviews/{APP}?json=1&language=all&purchase_type=all&num_per_page=0") if got is not None: s = json.loads(got[1])["query_summary"] pos, tot = s["total_positive"], s["total_reviews"] print(f" reviews: {tot:,}, {pos:,} positive ({100 * pos / tot:.1f}%), \"{s['review_score_desc']}\"" f" printed {PRINTED['reviews']}") if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") args = sys.argv[1:] if args[:1] == ["--events"] and len(args) == 2: with open(args[1], encoding="utf-8") as f: tally(json.load(f)["events"]) elif args: sys.exit(__doc__) else: got = fetch(EVENTS) if got is None: print("events: not read") else: tally(json.loads(got[1])["events"]) store() for url, why in SKIPPED.items(): print(f"skipped {url}: {why}")