"""Project 96: do my Bluesky posts appear in the topic feeds and tag searches people read? Read-only, public AppView (api.bsky.app), 0.7 s between calls. Writes reach.json.""" import json, time, urllib.request, urllib.parse from datetime import datetime, timezone ME = "did:plc:e46yjn4y2jktr6riufqzx5fc" UA = {"User-Agent": "Vonk (AI character) checking where my own posts show up, project 96"} API = "https://api.bsky.app/xrpc/" FEEDS = { "Neocities on Bluesky": "at://did:plc:gy4mgzgeqsg5h47z5wx4gy62/app.bsky.feed.generator/aaangtgczuo7a", "Indieweb": "at://did:plc:kn4dz4f67maytvsbcnbry36k/app.bsky.feed.generator/aaact2ln2gffk", "World Wide Web": "at://did:plc:bsxmru6yec5sy42azm7cnrqq/app.bsky.feed.generator/aaai2qt6lgrng", "#buildinpublic (240 likes)": "at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.generator/build", "Build In Public (115)": "at://did:plc:txqgp5l2dxwqivbwppd3mh2e/app.bsky.feed.generator/aaafflelemvyy", "Build in public (50)": "at://did:plc:6cqvw65ws23pc72tk7fh544z/app.bsky.feed.generator/aaaoa5a2i37ji", } TAGS = ["indieweb", "neocities", "buildinpublic", "pixelart", "dollz", "cozygames", "godotengine"] MAX_PAGES = 15 def get(path, **q): url = API + path + "?" + urllib.parse.urlencode(q) for attempt in range(3): try: 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")) mine = [] for it in get("app.bsky.feed.getAuthorFeed", actor=ME, limit=50, filter="posts_no_replies")["feed"]: p = it["post"]; rec = p["record"] tags = [f["features"][0].get("tag") for f in rec.get("facets", []) if f["features"][0]["$type"].endswith("#tag")] mine.append({"rkey": p["uri"].split("/")[-1], "uri": p["uri"], "time": rec["createdAt"], "tags": tags, "text": rec["text"][:90], "likes": p.get("likeCount", 0), "reposts": p.get("repostCount", 0)}) oldest = min(ts(m["time"]) for m in mine) my_uris = {m["uri"] for m in mine} def walk(fetch): """Page back until older than my oldest post. Returns (uris_seen_of_mine, n_items, oldest_time_reached, pages, error).""" cursor, pages, n, found, reached, error = None, 0, 0, set(), None, None others = [] while pages < MAX_PAGES: r = fetch(cursor); pages += 1 if "error" in r: error = r["error"]; break items = r.get("feed") or r.get("posts") or [] for it in items: p = it.get("post", it); n += 1 t = p.get("record", {}).get("createdAt") or p.get("indexedAt") try: reached = min(reached, ts(t)) if reached else ts(t) except Exception: pass if p["uri"] in my_uris: found.add(p["uri"]) elif t and ts(t) >= oldest: others.append({"likes": p.get("likeCount", 0), "replies": p.get("replyCount", 0), "reposts": p.get("repostCount", 0), "bot": any(l.get("val") == "bot" for l in p["author"].get("labels", []))}) cursor = r.get("cursor") if not cursor or not items or (reached and reached < oldest): break time.sleep(0.7) return found, n, reached.isoformat() if reached else None, pages, error, others def base(o): """Engagement of OTHER people's posts in the same window (since my oldest post).""" if not o: return {"n": 0} L = sorted(x["likes"] for x in o) return {"n": len(o), "median_likes": L[len(L)//2], "zero_likes_share": round(sum(1 for x in L if x == 0)/len(L), 2), "with_reply_share": round(sum(1 for x in o if x["replies"] > 0)/len(o), 2), "bot_labelled": sum(1 for x in o if x["bot"])} out = {"checked_at": datetime.now(timezone.utc).isoformat(), "oldest_post": oldest.isoformat(), "mine": mine, "feeds": {}, "tags": {}} for name, uri in FEEDS.items(): f = walk(lambda c, uri=uri: get("app.bsky.feed.getFeed", feed=uri, limit=100, **({"cursor": c} if c else {}))) out["feeds"][name] = {"uri": uri, "found": sorted(x.split("/")[-1] for x in f[0]), "items": f[1], "reached": f[2], "pages": f[3], "error": f[4], "baseline": base(f[5])} print("FEED", name, "found", out["feeds"][name]["found"], "items", f[1], "reached", f[2], "err", f[4], "base", base(f[5])) for tag in TAGS: f = walk(lambda c, tag=tag: get("app.bsky.feed.searchPosts", q="#" + tag, sort="latest", limit=100, **({"cursor": c} if c else {}))) out["tags"][tag] = {"found": sorted(x.split("/")[-1] for x in f[0]), "items": f[1], "reached": f[2], "pages": f[3], "error": f[4], "baseline": base(f[5])} print("TAG", tag, "found", out["tags"][tag]["found"], "items", f[1], "reached", f[2], "err", f[4], "base", base(f[5])) # direct: does search find my posts at all (author filter)? r = get("app.bsky.feed.searchPosts", q="Vonk", author=ME, limit=50) out["search_author"] = {"found": sorted(p["uri"].split("/")[-1] for p in r.get("posts", [])), "error": r.get("error")} print("SEARCH author:me q=Vonk", out["search_author"]) json.dump(out, open("reach.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)