"""Project 101: what kind of #buildinpublic post gets answered by OTHER people? Read-only, public AppView, 0.7 s between calls. Writes posts.json. Window: posts created 6 h to 54 h ago (so replies had time to arrive).""" import json, time, urllib.request, urllib.parse from datetime import datetime, timezone, timedelta UA = {"User-Agent": "Vonk (AI character) reading the #buildinpublic feed to learn how people talk, project 101"} API = "https://api.bsky.app/xrpc/" FEEDS = {"bip240": "at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.generator/build", "bip115": "at://did:plc:txqgp5l2dxwqivbwppd3mh2e/app.bsky.feed.generator/aaafflelemvyy"} ME = "did:plc:e46yjn4y2jktr6riufqzx5fc" NOW = datetime.now(timezone.utc) NEW, OLD = NOW - timedelta(hours=6), NOW - timedelta(hours=54) 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")) posts = {} def take(p, src): rec = p.get("record", {}) if rec.get("reply"): # only top-level posts return t = ts(rec.get("createdAt", p.get("indexedAt"))) if not (OLD <= t <= NEW) or p["author"]["did"] == ME: return e = p.get("embed") or {} et = e.get("$type", "") facets = rec.get("facets", []) links = any(f["features"][0]["$type"].endswith("#link") for f in facets) d = posts.setdefault(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["createdAt"], "text": rec.get("text", ""), "image": "images" in et or "video" in et or "images" in json.dumps(e.get("media", {}))[:200], "link": links or "external" in et, "quote": "record" in et, "bot": any(l.get("val") == "bot" for l in p["author"].get("labels", [])), "likes": p.get("likeCount", 0), "replies": p.get("replyCount", 0), "reposts": p.get("repostCount", 0), "src": []}) d["src"].append(src) for name, uri in FEEDS.items(): cursor = None for _ in range(20): q = {"feed": uri, "limit": 100} if cursor: q["cursor"] = cursor r = get("app.bsky.feed.getFeed", **q) if "error" in r: print(name, r["error"]); break items = r.get("feed", []) for it in items: take(it["post"], name) cursor = r.get("cursor") if not items or not cursor or ts(items[-1]["post"]["record"].get("createdAt", items[-1]["post"]["indexedAt"])) < OLD: break cursor = None for _ in range(15): q = {"q": "#buildinpublic", "tag": "buildinpublic", "sort": "latest", "limit": 100} if cursor: q["cursor"] = cursor r = get("app.bsky.feed.searchPosts", **q) if "error" in r: print("search", r["error"]); break items = r.get("posts", []) for p in items: take(p, "tag") cursor = r.get("cursor") if not items or not cursor or ts(items[-1]["record"]["createdAt"]) < OLD: break print("posts in window:", len(posts)) # follower counts 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"]] = a.get("followersCount", 0) for p in posts.values(): p["followers"] = prof.get(p["did"], 0) # who replied: others vs the author themself (threads count as replies too) n = 0 for p in sorted(posts.values(), key=lambda x: -x["replies"]): if p["replies"] == 0 or n >= 260: p["others"] = 0 if p["replies"] == 0 else None; continue r = get("app.bsky.feed.getPostThread", uri=p["uri"], depth=1, parentHeight=0); n += 1 reps = (r.get("thread") or {}).get("replies", []) or [] o = [x["post"] for x in reps if "post" in x and x["post"]["author"]["did"] != p["did"]] p["others"] = len(o) p["other_replies"] = [{"handle": x["author"]["handle"], "bot": any(l.get("val") == "bot" for l in x["author"].get("labels", [])), "text": x["record"].get("text", "")[:200]} for x in o[:6]] json.dump({"fetched": NOW.isoformat(), "window": [OLD.isoformat(), NEW.isoformat()], "posts": list(posts.values())}, open("posts.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) print("threads read:", n, "authors:", len(dids))