"""among-us-check.py - re-check the Steam figures and the developer's dated posts behind the Among Us article (published 5 October 2026). The article quotes Steam prices in three currencies during a 40% sale, a review count, a concurrent-player count, and the dates of the maps, modes and roles Innersloth has added since 2018, each taken from the developer's own Steam announcements. Prices and counts move, so this script re-reads the same public figures and prints them, and it lists the dated announcements the timeline tables were built from. Nothing here needs a key or an account. What it reads: Steam appdetails store.steampowered.com/api/appdetails for app 945360, once per storefront (cc=us, cc=gb, cc=de) Steam reviews store.steampowered.com/appreviews/945360 (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, 100 at a time The console stores, the mobile stores and the ratings boards are not read here: they serve HTML pages whose layout changes, and their figures in the article were read from the 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, 3,620 at 11:10 UTC on 5 October 2026, marked as such. Version 1.0 also read the developer's posts from that host's news feed; this one reads them from the store's event list, which carries the same posts under different ids but with the same post times, so each cited post is found by its time (POSTED below, from the feed as read on 5 October). 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 among-us-check.py """ import datetime import json import re import sys import time import urllib.error import urllib.parse import urllib.request APP = 945360 UA = "among-us-check/1.1 (+https://blastproc.com/code/among-us-check.py; reader re-check script)" HEADERS = {"User-Agent": UA} PLAYERS_PRINTED = (3620, "11:10 UTC on 5 October 2026") # what the article printed # The announcements the article's tables cite, keyed by the post id at the end of # each post's URL (ids are stable; titles carry emoji and change case). MILESTONES = { "2423394805997953063": "PC price set at $5, mobile stays free with ads", "2412160539855033323": "MIRA HQ, the second map, at $4", "2451574645877358719": "Polus, the third map, announced for 12 November at $4", "3309530667977609566": "MIRA HQ and Polus made free", "4828375451935162408": "Among Us 2 confirmed", "3715990047692204837": "Among Us 2 cancelled", "4023377846272223681": "The Airship, the fourth map; accounts and chat rules", "5019805792180397584": "15-player lobbies", "4186653398789142175": "Scientist, Engineer, Guardian Angel, Shapeshifter", "4980448574112987753": "Hide n Seek mode", "5218041989061754845": "The Fungle, the fifth map", "6890025005367416897": "Tracker, Noisemaker, Phantom", "1791580426132716": "2025 roadmap: roles total 'up to 7'", "1794830911038908": "matchmaking filters and Beginner/Intermediate/Expert tags", "1809869180202783": "Detective, Viper", "1835871199313720": "2024 versions no longer supported", "1840944183789406": "Judge (version 18.0.0)", "1844751498234928": "Influencer (version 19.0.0)", "1845383656382937": "Impostor Month", } # The time Steam gives each of those posts (unix seconds, from the news feed as # read on 5 October 2026). The store's event list carries the same time. POSTED = { "2423394805997953063": 1541710451, "2412160539855033323": 1565293227, "2451574645877358719": 1572991339, "3309530667977609566": 1591920217, "4828375451935162408": 1597709348, "3715990047692204837": 1600898192, "4023377846272223681": 1617213928, "5019805792180397584": 1623789879, "4186653398789142175": 1636485305, "4980448574112987753": 1670608832, "5218041989061754845": 1698166856, "6890025005367416897": 1718722830, "1791580426132716": 1739901652, "1794830911038908": 1742922923, "1809869180202783": 1757437843, "1835871199313720": 1782152953, "1840944183789406": 1787072740, "1844751498234928": 1790701400, "1845383656382937": 1790874416, } 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_json(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=60) as r: return json.loads(r.read().decode("utf-8")) # ------------------------------------------------------------------ the figures def plain(title): """Titles carry curly quotes, dashes and emoji; print them as plain text.""" title = title.replace("’", "'").replace("–", "-").replace("—", "-") return " ".join(title.encode("ascii", "ignore").decode().split()) def day_of(posttime): return datetime.datetime.fromtimestamp(posttime, datetime.timezone.utc).strftime("%Y-%m-%d") def prices(): print("Steam price, by storefront") for cc in ("us", "gb", "de"): url = f"https://store.steampowered.com/api/appdetails?appids={APP}&cc={cc}&l=en" data = get_json(url) if data is None: print(f" {cc.upper()}: not read ({SKIPPED[url]})") continue p = data[str(APP)]["data"].get("price_overview") or {} was = p.get("initial_formatted") or p.get("final_formatted", "?") print(f" {cc.upper()}: {p.get('final_formatted', '?'):>8} (regular {was}, discount {p.get('discount_percent', 0)}%)") def reviews_and_players(): url = (f"https://store.steampowered.com/appreviews/{APP}?json=1&language=all" "&purchase_type=all&num_per_page=0&filter=all") data = get_json(url) if data is None: print(f"\nSteam reviews: not read ({SKIPPED[url]})") else: s = data["query_summary"] pos, neg = s["total_positive"], s["total_negative"] print(f"\nSteam reviews: {s['total_reviews']:,} ({pos:,} positive, {neg:,} negative, " f"{100 * pos / (pos + neg):.1f}% positive, '{s['review_score_desc']}')") url = f"https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid={APP}" n = get_json(url) if n is None: count, when = PLAYERS_PRINTED print(f"Steam players right now: not read ({SKIPPED[url]});") print(f" the article printed {count:,} at {when}") else: now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") print(f"Steam players right now: {n['response']['player_count']:,} at {now}") def store_posts(): """Every post in the store's event list for the app, as (post time, headline), or None if it may not be read.""" posts, offset = [], 0 while offset < 2000: page = get_json("https://store.steampowered.com/events/ajaxgetpartnereventspageable/" f"?clan_accountid=0&appid={APP}&offset={offset}&count=100&l=english") if page is None: return None events = page.get("events") or [] posts += [(e["announcement_body"]["posttime"], e["announcement_body"]["headline"]) for e in events if e.get("announcement_body")] if len(events) < 100: break offset += 100 return posts def milestones(): posts = store_posts() if posts is None: print("\nDeveloper announcements on Steam: not read (" + "; ".join(dict.fromkeys(SKIPPED.values())) + ")") return print(f"\nDeveloper announcements on Steam: {len(posts)} in the store's event list") by_time = dict(posts) rows = [] for gid, what in MILESTONES.items(): title = by_time.get(POSTED[gid]) if title is not None: rows.append((day_of(POSTED[gid]), what, plain(title))) for day, what, title in sorted(rows): print(f" {day} {what}") print(f" post: {title}") missing = len(MILESTONES) - len(rows) if missing: print(f" ({missing} of the cited posts were not in the list today)") newest = max(posts) print(f"\nNewest announcement: {day_of(newest[0])} {plain(newest[1])}") if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") prices() reviews_and_players() milestones() if SKIPPED: print("\nnot read, under the hosts' robots.txt rules:\n " + "\n ".join(dict.fromkeys(SKIPPED.values())))