"""Turn offline-test.cjs results into verdicts. python3 classify.py out/ entities.json results.csv Standard library only. Verdicts, decided in this order: fails the offline reload showed Chrome's own error page (or did not load) page only the page came back but the game did not: a frame that held the game online is an error offline, or the game's canvas / text is gone works offline the page and the game came back with the network off A game "can be installed" when it has a web app manifest with a name, a start URL and at least one icon, and a registered service worker. Outside requests are those made while online to domains the page's owner does not control (see analyze.py in the free-game-trackers study); ad/tracking uses the same public list. """ import csv, json, os, sys, urllib.parse _here = os.path.dirname(os.path.abspath(__file__)) for _d in (_here, os.path.join(_here, "..", "free-game-trackers"), os.path.join(_here, "..", "game-weight-study")): sys.path.insert(1, _d) # analyze.py comes from the free-game-trackers study from analyze import registrable, AD_TRACKING # noqa: E402 def game_signal(view, own): """Canvas and text in frames the game's own site serves. Ad and other outside frames are ignored: an empty ad slot offline does not stop the game.""" mine = [f for f in view["frames"] if not f.get("error") and own(f["url"])] return {"canvases": sum(f.get("canvases") or 0 for f in mine), "text": sum(f.get("text") or 0 for f in mine), "own_frames": len(mine)} def verdict(r, own, is_ad_url=lambda u: False): off = r["offline_view"] # A game can live in a frame from another domain. Any non-ad frame that showed a # game canvas online must show one offline too. # A frame holds the game if it drew a canvas or showed real content, and is not an ad. holds = lambda f: (f.get("canvases") or 0) > 0 or (f.get("text") or 0) > 200 game_frames = [f["url"] for f in r["online_view"]["frames"] if f["url"].startswith("http") and holds(f) and not f.get("error") and not is_ad_url(f["url"])] offline_ok = {f["url"] for f in off["frames"] if not f.get("error") and holds(f)} if off.get("error_page") or (r.get("offline_error") and "ERR_INTERNET_DISCONNECTED" in r["offline_error"]): return "fails", "offline reload showed a browser error page" on_s, off_s = game_signal(r["online_view"], own), game_signal(off, own) ads_gone = sum(1 for f in off["frames"] if f.get("error")) > 0 note = "ad slots empty offline" if ads_gone else "" if any(u not in offline_ok for u in game_frames) and not off.get("error_page"): return "page only", "the frame holding the game did not come back" if off_s["own_frames"] < on_s["own_frames"]: return "page only", "a frame from the game's own site failed offline" if on_s["canvases"] and off_s["canvases"] < on_s["canvases"]: return "page only", "the game canvas did not come back" if not on_s["canvases"] and off_s["text"] < 0.5 * on_s["text"]: return "page only", "most of the page content did not come back" return "works offline", note def installable(r): inst = r.get("install") or {} m = inst.get("manifest") if not isinstance(m, dict): return False return bool((m.get("name") or m.get("short_name")) and m.get("start_url") is not None and m.get("icons")) \ and inst.get("service_workers", 0) > 0 _ov = os.path.join(os.path.dirname(os.path.abspath(__file__)), "overrides.csv") OVERRIDES = {r["label"]: r for r in csv.DictReader(open(_ov))} if os.path.exists(_ov) else {} def main(out_dir, entities_path, out_csv): E = json.load(open(entities_path)) owner = lambda g: (E.get(g) or {}).get("owner") or g rows = [] for name in sorted(os.listdir(out_dir)): if not name.endswith(".json"): continue r = json.load(open(os.path.join(out_dir, name))) po = owner(registrable(urllib.parse.urlsplit(r["url"]).hostname)) third = {registrable(urllib.parse.urlsplit(q["url"]).hostname) for q in r["online_requests"] if not q["url"].startswith(("data:", "blob:"))} third = {g for g in third if owner(g) != po} ad = {g for g in third if {c.lower() for c in (E.get(g) or {}).get("categories", [])} & AD_TRACKING} # Only real pages from the game's own site count: blank frames are ad slots. own = lambda u, po=po: u.startswith("http") and owner(registrable(urllib.parse.urlsplit(u).hostname or "")) == po is_ad_url = lambda u: bool({c.lower() for c in (E.get(registrable(urllib.parse.urlsplit(u).hostname or "")) or {}).get("categories", [])} & AD_TRACKING) v, why = verdict(r, own, is_ad_url) if r["label"] in OVERRIDES: # decided by eye from the screenshot; see overrides.csv v, why = OVERRIDES[r["label"]]["verdict"], "by eye: " + OVERRIDES[r["label"]]["reason"] rows.append({"label": r["label"], "url": r["url"], "tested_at": r["tested_at"], "verdict": v, "why": why, "installable": installable(r), "service_worker": (r.get("install") or {}).get("service_workers", 0), "manifest": isinstance((r.get("install") or {}).get("manifest"), dict), "outside_domains": len(third), "ad_tracking_domains": len(ad), "ad_tracking_companies": len({owner(g) for g in ad})}) with open(out_csv, "w", newline="") as fh: w = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) w.writeheader() w.writerows(rows) for r in rows: print(f"{r['label']:32} {r['verdict']:14} install={r['installable']!s:5} outside={r['outside_domains']:>3} ad={r['ad_tracking_companies']:>3} {r['why']}") if __name__ == "__main__": main(*sys.argv[1:])