"""Project 109: what gets a #gamedev post seen on Bluesky? Read-only public AppView (searchPosts sort=latest, getProfiles), 0.7 s between calls. Keeps top-level posts created 24 h to 48 h before the fetch (likes have mostly settled). Writes posts.json. No reply bodies, no personal data beyond what the public post shows; raw posts stay in this folder.""" import json, time, urllib.request, urllib.parse, re from datetime import datetime, timezone, timedelta UA = {"User-Agent": "Vonk (AI character) counting public #gamedev posts to publish open reach statistics, project 109"} API = "https://api.bsky.app/xrpc/" ME = "" # your own did, to skip your posts NOW = datetime.now(timezone.utc) NEW, OLD = NOW - timedelta(hours=24), NOW - timedelta(hours=48) def get(path, **q): url = API + path + "?" + urllib.parse.urlencode(q, doseq=True) err = None for _ in range(3): try: time.sleep(0.7) return json.load(urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=25)) except Exception as e: err = e; time.sleep(2) return {"error": str(err)} def ts(s): return datetime.fromisoformat(s.replace("Z", "+00:00")) # The public AppView refuses search cursors (403), so search pages need a logged-in account. # Use your OWN account with an app password: set BSKY_HANDLE and BSKY_APP_PASSWORD. Still read-only, same pacing. import os def _post(url, body): req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **UA}) return json.load(urllib.request.urlopen(req, timeout=25)) class Session: def __init__(self): self.pds = os.environ.get("BSKY_PDS", "https://bsky.social") self.token = _post(self.pds + "/xrpc/com.atproto.server.createSession", {"identifier": os.environ["BSKY_HANDLE"], "password": os.environ["BSKY_APP_PASSWORD"]})["accessJwt"] def get(self, nsid, params, proxy=True): req = urllib.request.Request(f"{self.pds}/xrpc/{nsid}?{params}", headers={**UA, "Authorization": "Bearer " + self.token, "atproto-proxy": "did:web:api.bsky.app#bsky_appview"}) return json.load(urllib.request.urlopen(req, timeout=25)) S = Session() def search(**q): err = None for _ in range(3): try: time.sleep(0.7) return S.get("app.bsky.feed.searchPosts", urllib.parse.urlencode(q), proxy=True) except Exception as e: err = e; time.sleep(2) return {"error": str(err)} def media(e): t = e.get("$type", "") if "recordWithMedia" in t: e = e.get("media", {}); t = e.get("$type", "") if "video" in t: return "video" if "images" in t: return "images" if "external" in t: u = (e.get("external") or {}).get("uri", "").lower() if ".gif" in u or "tenor.com" in u or "giphy" in u: return "gif" return "link card" if "record" in t: return "quote only" return "text only" posts, seen_pages, oldest = {}, 0, None cursor = None for _ in range(80): q = {"q": "#gamedev", "sort": "latest", "limit": 100} if cursor: q["cursor"] = cursor r = search(**q) if "error" in r: print("search error", r["error"]); break items = r.get("posts", []); seen_pages += 1 for p in items: rec = p.get("record", {}) t = ts(rec.get("createdAt", p.get("indexedAt"))) oldest = t if oldest is None or t < oldest else oldest if rec.get("reply") or not (OLD <= t <= NEW) or p["author"]["did"] == ME: continue e = p.get("embed") or {} text = rec.get("text", "") tags = {f["features"][0].get("tag", "").lower() for f in rec.get("facets", []) if f["features"][0]["$type"].endswith("#tag")} tags |= {m.lower() for m in re.findall(r"#(\w+)", text)} n_img = len(e.get("images", []) or (e.get("media", {}) or {}).get("images", []) or []) links = [f["features"][0].get("uri", "") for f in rec.get("facets", []) if f["features"][0]["$type"].endswith("#link")] ext = ((e.get("external") or (e.get("media", {}) or {}).get("external") or {}).get("uri", "")) posts[p["uri"]] = { "uri": p["uri"], "url": f"https://bsky.app/profile/{p['author']['handle']}/post/{p['uri'].split('/')[-1]}", "did": p["author"]["did"], "handle": p["author"]["handle"], "time": rec.get("createdAt"), "text": text, "langs": rec.get("langs", []), "media": media(e), "n_img": n_img, "tags": sorted(tags), "links": links + ([ext] if ext else []), "bot": any(l.get("val") == "bot" for l in p["author"].get("labels", [])), "likes": p.get("likeCount", 0), "reposts": p.get("repostCount", 0), "replies": p.get("replyCount", 0), "quotes": p.get("quoteCount", 0)} cursor = r.get("cursor") if not items or not cursor or oldest < OLD: break print("pages", seen_pages, "oldest seen", oldest, "posts in window", len(posts)) dids = sorted({p["did"] for p in posts.values()}) prof = {} for i in range(0, len(dids), 25): r = get("app.bsky.actor.getProfiles", actors=dids[i:i+25]) for a in r.get("profiles", []): prof[a["did"]] = {"followers": a.get("followersCount", 0), "posts": a.get("postsCount", 0)} for p in posts.values(): p["followers"] = prof.get(p["did"], {}).get("followers", 0) p["author_posts"] = prof.get(p["did"], {}).get("posts", 0) json.dump({"fetched": NOW.isoformat(), "window": [OLD.isoformat(), NEW.isoformat()], "pages": seen_pages, "posts": list(posts.values())}, open("posts.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) print("authors", len(dids), "profiles", len(prof))