"""Bluesky audience audit (#107). Public appview only, no login, read-only. For every top-level post of mine (posts.json from list_posts.py): likes, reposts, quotes, replies (post_stats, no classify), plus my followers. Each unique account is classified ONCE with classify_actor (cached in cache.json). Writes audit.json. Pauses 0.5 s between calls.""" import importlib.util, json, time, urllib.parse, datetime as dt from pathlib import Path sp = importlib.util.spec_from_file_location("core", Path(__file__).with_name("bsky_public.py")) core = importlib.util.module_from_spec(sp); sp.loader.exec_module(core) H = Path(__file__).parent posts = json.loads((H / "posts.json").read_text(encoding="utf-8")) types = json.loads((H / "types.json").read_text(encoding="utf-8")) cache_f = H / "cache.json" cache = json.loads(cache_f.read_text(encoding="utf-8")) if cache_f.exists() else {} assert all(p["rkey"] in types for p in posts), "unlabelled post" accounts = {} # did -> {"handle", "acts": [[rkey, kind], ...], "follower": bool} per_post = [] for p in posts: s = core.post_stats(p["uri"], classify=False) time.sleep(0.5) dids = [] for w in s["responders"]: a = accounts.setdefault(w["did"], {"handle": w["handle"], "acts": [], "follower": False}) for k in w["did_what"]: a["acts"].append([p["rkey"], k]) dids.append(w["did"]) per_post.append({"rkey": p["rkey"], "type": types[p["rkey"]], "created": p["created"], "counts": s["counts"], "responders": dids, "reply_texts": s["reply_texts"]}) cur, followers = "", [] while True: j = core._pub("app.bsky.graph.getFollowers", f"actor={core.MY_DID}&limit=100" + (f"&cursor={urllib.parse.quote(cur)}" if cur else "")) followers += j.get("followers", []) cur = j.get("cursor") if not cur or not j.get("followers"): break for f in followers: accounts.setdefault(f["did"], {"handle": f["handle"], "acts": [], "follower": False})["follower"] = True for did, a in accounts.items(): if did not in cache: try: cache[did] = core.classify_actor(did) except Exception as e: # noqa: BLE001 cache[did] = {"verdict": "unclear", "error": str(e)[:120], "auto": [], "human": []} time.sleep(0.5) cache_f.write_text(json.dumps(cache, indent=1, ensure_ascii=False), encoding="utf-8") a["auto_verdict"] = cache[did]["verdict"] a["signals"] = {k: cache[did].get(k) for k in ("auto", "human", "followers", "follows", "posts", "age_days", "labels", "bio")} out = {"run_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "n_posts": len(posts), "posts": per_post, "accounts": accounts, "n_followers": len(followers)} (H / "audit.json").write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8") print("posts", len(posts), "accounts", len(accounts), "followers", len(followers)) for did, a in sorted(accounts.items(), key=lambda kv: kv[1]["auto_verdict"]): s = a["signals"] print(f"{a['auto_verdict']:12} @{a['handle']:40} acts={[f'{r[:6]}:{k}' for r, k in a['acts']]} fol={a['follower']} " f"| f{s['followers']}/{s['follows']} p{s['posts']} d{s['age_days']} | A:{'; '.join(s['auto'] or [])} | H:{'; '.join(s['human'] or [])} | {(s['bio'] or '')[:70]!r}")