"""Public, login-free Bluesky helpers (CC0), extracted unchanged from Vonk's own client. Only GET calls to the public appview are used by the audit scripts. Set MY_DID to your own DID to audit your own account.""" from __future__ import annotations import json, urllib.error, urllib.parse, urllib.request APPVIEW = "https://public.api.bsky.app" UA = "audit-script (+https://bsky.app/profile/vonk.cavu.social)" MY_DID = "did:plc:e46yjn4y2jktr6riufqzx5fc" # change to your own def _req(method: str, url: str, body=None, token: str | None = None, ctype="application/json", headers=None): data = None h = {"User-Agent": UA} if headers: h.update(headers) if body is not None: data = body if isinstance(body, (bytes, bytearray)) else json.dumps(body).encode() h["Content-Type"] = ctype if token: h["Authorization"] = f"Bearer {token}" r = urllib.request.Request(url, data=data, method=method, headers=h) try: with urllib.request.urlopen(r, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: raise RuntimeError(f"{method} {url.split('?')[0]} -> {e.code}: {e.read()[:300].decode('utf-8', 'replace')}") from None def _pub(nsid: str, params: str): return _req("GET", f"{APPVIEW}/xrpc/{nsid}?{params}") def _uri(post: str) -> str: """Accept an at:// uri, a bsky.app post URL or a bare rkey of one of my posts.""" if post.startswith("at://"): return post if "bsky.app/profile/" in post: prof, rkey = post.split("/profile/")[1].split("/post/") # getProfile uses the appview index; resolveHandle fails intermittently for small PDS handles (cycle 94) did = prof if prof.startswith("did:") else _pub("app.bsky.actor.getProfile", f"actor={prof}")["did"] return f"at://{did}/app.bsky.feed.post/{rkey.strip('/')}" return f"at://{MY_DID}/app.bsky.feed.post/{post}" def classify_actor(actor: str) -> dict: """Heuristic verdict for one account: 'automated', 'likely human' or 'unclear', with the reasons. Signals: bot self-label, posts per day, follows 0 with a big audience, repeated identical texts, and whether the account talks to other people (replies). A guess from public facts, never proof.""" import datetime as dt p = _pub("app.bsky.actor.getProfile", f"actor={urllib.parse.quote(actor)}") labels = [l.get("val") for l in p.get("labels", [])] posts, fol, fing = p.get("postsCount", 0), p.get("followersCount", 0), p.get("followsCount", 0) created = p.get("createdAt") or p.get("indexedAt") or "2023-01-01T00:00:00Z" days = max((dt.datetime.now(dt.timezone.utc) - dt.datetime.fromisoformat(created.replace("Z", "+00:00"))).days, 1) rate = posts / days feed = _pub("app.bsky.feed.getAuthorFeed", f"actor={p['did']}&limit=30&filter=posts_with_replies").get("feed", []) own = [it["post"]["record"] for it in feed if not it.get("reason") and it["post"]["author"]["did"] == p["did"]] texts = [r.get("text", "").strip() for r in own if r.get("text", "").strip()] dup = max((texts.count(t) for t in set(texts)), default=0) replies = sum(1 for r in own if r.get("reply")) auto, human = [], [] if "bot" in labels: auto.append("self-labelled bot") if rate > 50: auto.append(f"{rate:.0f} posts/day") if fing == 0 and fol > 1000: auto.append(f"follows nobody but has {fol} followers (broadcast/brand)") if dup >= 3: auto.append(f"same text {dup}x in last {len(texts)} posts") if not own and posts == 0: auto.append("no posts at all (like/follow-only account)") if rate <= 30 and own: human.append(f"{rate:.1f} posts/day") if replies >= 2 and dup < 3: human.append(f"talks to people ({replies} replies in last {len(own)})") if p.get("description") and len(set(texts)) >= 5: human.append("bio + varied posts") verdict = "automated" if auto else ("likely human" if len(human) >= 3 else "unclear") return {"handle": p["handle"], "did": p["did"], "verdict": verdict, "auto": auto, "human": human, "followers": fol, "follows": fing, "posts": posts, "age_days": days, "labels": labels, "bio": (p.get("description") or "")[:120]} def post_stats(post: str, classify: bool = True) -> dict: """Likes, reposts, quotes and replies on a post, each responder classified.""" uri = _uri(post) th = _pub("app.bsky.feed.getPostThread", f"uri={urllib.parse.quote(uri)}&depth=6")["thread"] pv = th["post"] def paged(nsid, key, cap=1000): # follow the cursor: getLikes returns at most 100 per page (bug found cycle 94) out, cur = [], "" while len(out) < cap: j = _pub(nsid, f"uri={urllib.parse.quote(uri)}&limit=100" + (f"&cursor={urllib.parse.quote(cur)}" if cur else "")) out += j.get(key, []) cur = j.get("cursor") if not cur or not j.get(key): break return out likes = [x["actor"] for x in paged("app.bsky.feed.getLikes", "likes")] reps = paged("app.bsky.feed.getRepostedBy", "repostedBy") quotes = [q["author"] for q in _pub("app.bsky.feed.getQuotes", f"uri={urllib.parse.quote(uri)}&limit=100").get("posts", [])] rep_authors, stack = [], list(th.get("replies") or []) while stack: r = stack.pop() if "post" in r: rep_authors.append((r["post"]["author"], r["post"]["record"].get("text", ""))) stack.extend(r.get("replies") or []) who = {} for kind, actors in (("like", likes), ("repost", reps), ("quote", quotes), ("reply", [a for a, _ in rep_authors])): for a in actors: if a["did"] == MY_DID: continue who.setdefault(a["did"], {"handle": a["handle"], "did": a["did"], "did_what": []})["did_what"].append(kind) if classify: for d, w in who.items(): try: w.update({k: v for k, v in classify_actor(d).items() if k not in ("handle", "did")}) except Exception as e: # noqa: BLE001 w["verdict"], w["error"] = "unclear", str(e)[:120] counts = {"likes": pv.get("likeCount", 0), "reposts": pv.get("repostCount", 0), "quotes": pv.get("quoteCount", 0), "replies": pv.get("replyCount", 0), "thread_replies_all_depths": len(rep_authors)} # replyCount = direct replies only tally = {} for w in who.values(): tally[w.get("verdict", "unclassified")] = tally.get(w.get("verdict", "unclassified"), 0) + 1 return {"uri": uri, "web": f"https://bsky.app/profile/{uri.split('/')[2]}/post/{uri.rsplit('/', 1)[1]}", "created": pv["record"].get("createdAt"), "counts": counts, "responders": list(who.values()), "tally": tally, "reply_texts": [(a["handle"], t[:200]) for a, t in rep_authors if a["did"] != MY_DID]}