"""ffr-listing-audit.py - re-check what Square Enix and the stores say about FINAL FANTASY RESONANCE before its October 2026 launch. The article that ships with this file was written on 12 October 2026, ten days before the game's stated release, from Square Enix's own press release and press-hub page and from the Steam, PlayStation Store and Microsoft Store listings. Pre-order listings change, and one of the Microsoft records already disagrees with the others, so this script re-reads the same public records, prints every release timestamp it finds next to the 22 October date Square Enix announced, prints the edition prices in three Steam currencies, lists the platforms in Square Enix's own retailer feed, and marks each figure that no longer matches the one the article printed. What it reads, all public and none of it needing a key: Square Enix etailers.square-enix-games.com/etailers/deracxsiaofe8er, the retailer list behind the official site's Buy Now panel (one entry per region, platform, edition and store) Steam store.steampowered.com/api/appdetails for the game (app 3259780, cc=us/gb/de) and its demo (app 4474710) PlayStation store.playstation.com/en-us/product/JP0082-PPSA31544_00-FFRSSTDWW0000000; the release timestamp and prices sit in the page's embedded data Microsoft displaycatalog.mp.microsoft.com, the catalogue behind the Microsoft Store: 9NNCPRH9VWG4 (the game as sold), 9NXJZGKPFMKK (Digital Deluxe Edition), 9N8JS091F5G0 (the record that carries the console and PC packages) and 9NC4PGLSZ341 (the demo) 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. The retailer host has no robots.txt of its own (the request is redirected to the www.square-enix-games.com home page), so that site's file, "User-agent: * / Allow: /", is the one applied. On 12 October 2026 every host allowed these pages. It does not call api.steampowered.com, whose robots.txt closes every path to every crawler, or any age-rating board's website. 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, datetime, time, sys). Python 3.9 or newer. python ffr-listing-audit.py Exit code is 0 when every record answered, 1 when any of them did not. """ import datetime import json import re import sys import time import urllib.error import urllib.parse import urllib.request UA = "ffr-listing-audit/1.0 (+https://blastproc.com/code/ffr-listing-audit.py; reader re-check script)" HEADERS = {"User-Agent": UA, "Accept": "text/html,application/json;q=0.9,*/*;q=0.5"} ANNOUNCED = datetime.date(2026, 10, 22) # Square Enix press release, 3 September 2026 APP, DEMO = 3259780, 4474710 PS_URL = "https://store.playstation.com/en-us/product/JP0082-PPSA31544_00-FFRSSTDWW0000000" ETAILERS = "https://etailers.square-enix-games.com/etailers/deracxsiaofe8er" MS = { "9NNCPRH9VWG4": "the game as sold", "9NXJZGKPFMKK": "Digital Deluxe Edition", "9N8JS091F5G0": "record carrying the console and PC packages", "9NC4PGLSZ341": "demo", } # What the article printed on 12 October 2026, for comparison. PRINTED = { "steam": {"us": ("$49.99", "$59.99"), "gb": ("£39.99", "£49.99"), "de": ("49,99€", "59,99€")}, "steam_date": "Oct 22, 2026", "ps": {"release": "2026-10-22T04:00:00Z", "prices": ["$49.99", "$59.99"], "label": "ESRB Everyone 10+"}, "ms_release": {"9NNCPRH9VWG4": "2026-10-22", "9NXJZGKPFMKK": "2026-10-22", "9N8JS091F5G0": "2026-11-06", "9NC4PGLSZ341": "2026-09-03"}, "ms_price": {"9NNCPRH9VWG4": 49.99, "9NXJZGKPFMKK": 59.99}, "platforms_us": ["Nintendo Switch", "Nintendo Switch 2", "PS5", "PlayStation", "Steam", "Xbox Series X|S"], } SKIPPED = {} # url -> why the robots rules ruled it out 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, or a server that refuses this script, rules the host out; a 404 or another 4xx means the host sets no rules. When the request is redirected to an ordinary web page on another host (etailers.square-enix-games.com sends it to the home page of www.square-enix-games.com), the host has no file of its own, and the rules of the host it redirected to apply.""" if host not in _ROBOTS: _wait(host) try: req = urllib.request.Request(f"https://{host}/robots.txt", headers=HEADERS) final = None with urllib.request.urlopen(req, timeout=60) as r: status, body, final = r.status, r.read(), r.geturl() 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") def get(url): """fetch() that records a failure instead of stopping the run.""" try: return fetch(url) 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})" # ------------------------------------------------------------------ Square Enix's retailer feed def retailers(): print("Square Enix retailer feed: platforms and editions per region") got = get(ETAILERS) if got is None: print(" not read") return feed = json.loads(got[1]) for region in ("US", "GB", "DE"): entries = feed.get(region, []) plats = sorted({e["meta"]["platform"] for e in entries}) eds = sorted({e["meta"]["edition"] for e in entries}) line = f" {region}: {len(entries)} links; platforms {', '.join(plats)}; editions {', '.join(eds)}" print(line + (mark(plats, PRINTED["platforms_us"]) if region == "US" else "")) # ------------------------------------------------------------------ Steam def steam(): print("\nSteam (store.steampowered.com/api/appdetails)") for cc in ("us", "gb", "de"): got = get(f"https://store.steampowered.com/api/appdetails?appids={APP}&cc={cc}&l=en") if got is None: print(f" {cc}: not read") continue d = json.loads(got[1])[str(APP)].get("data") or {} prices = [] for group in d.get("package_groups", []): for sub in group.get("subs", []): prices.append(re.sub(r"<[^>]+>", "", sub["option_text"]).rsplit(" - ", 1)[-1].strip()) rd = d.get("release_date", {}) print(f" {cc}: release {rd.get('date')!r} (coming soon: {rd.get('coming_soon')}); editions {prices}" + mark(tuple(prices), PRINTED["steam"][cc])) if cc == "us": print(f" developers {d.get('developers')}; publishers {d.get('publishers')}; platforms " f"{[k for k, v in (d.get('platforms') or {}).items() if v]}" + mark(rd.get("date"), PRINTED["steam_date"])) for board, r in sorted((d.get("ratings") or {}).items()): generated = " (generated by Steam, not a board)" if r.get("rating_generated") == "1" else "" desc = " / ".join(x.strip() for x in re.split(r"[\r\n]+", r.get("descriptors", "")) if x.strip() and "[img]" not in x) print(f" rating {board}: {r.get('rating')}{generated} {desc}") got = get(f"https://store.steampowered.com/api/appdetails?appids={DEMO}&cc=us&l=en") if got is not None: d = json.loads(got[1])[str(DEMO)].get("data") or {} print(f" demo {DEMO}: {d.get('name')!r}, released {d.get('release_date', {}).get('date')!r}, free: {d.get('is_free')}") # ------------------------------------------------------------------ PlayStation Store def playstation(): print("\nPlayStation Store, US") got = get(PS_URL) if got is None: print(" not read") return page = got[1] release = sorted(set(re.findall(r'"releaseDate":"([^"]+)"', page))) prices = sorted(set(re.findall(r'"basePrice":"(\$[0-9.]+)"', page))) label = re.findall(r'mfe-content-rating#ratingImage#preview"[^>]*alt="([^"]+)"', page) print(f" release {release}" + mark(release, [PRINTED["ps"]["release"]])) print(f" prices {prices}" + mark(prices, PRINTED["ps"]["prices"])) print(f" age label {label[:1]}" + mark(label[:1], [PRINTED["ps"]["label"]])) # ------------------------------------------------------------------ Microsoft Store catalogue def microsoft(): print("\nMicrosoft Store catalogue (US market)") for pid, what in MS.items(): got = get(f"https://displaycatalog.mp.microsoft.com/v7.0/products/{pid}?market=US&languages=en-us&fieldsTemplate=details") if got is None: print(f" {pid}: not read") continue p = json.loads(got[1])["Product"] stamp = p["MarketProperties"][0].get("OriginalReleaseDate", "")[:19] day = stamp[:10] try: gap = (datetime.date.fromisoformat(day) - ANNOUNCED).days except ValueError: gap = None price = None for dsa in p.get("DisplaySkuAvailabilities", []): for av in dsa.get("Availabilities", []): if "Purchase" in av.get("Actions", []): price = av.get("OrderManagementData", {}).get("Price", {}).get("ListPrice") break if price is not None: break note = "" if gap in (0, None) else f", {gap:+d} days from the announced 22 October" print(f" {pid} ({what}): OriginalReleaseDate {stamp}Z{note}; purchase price {price}" + mark(day, PRINTED["ms_release"][pid]) + (mark(price, PRINTED["ms_price"][pid]) if pid in PRINTED["ms_price"] else "")) if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8") today = datetime.date.today() print(f"FINAL FANTASY RESONANCE listing audit, {today.isoformat()}: " f"{(ANNOUNCED - today).days} days to the announced 22 October 2026\n") retailers() steam() playstation() microsoft() 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)