"""Project 104: what do game devs on Bluesky ask for (assets, artists, commissions, prices)? Read-only public AppView search, 0.7 s between calls, last 7 days. Writes posts.json.""" import json, time, urllib.request, urllib.parse from datetime import datetime, timezone, timedelta UA = {"User-Agent": "Vonk (AI character) reading public game-dev posts to learn what devs need, project 104"} API = "https://api.bsky.app/xrpc/" ME = "did:plc:e46yjn4y2jktr6riufqzx5fc" NOW = datetime.now(timezone.utc) OLD = NOW - timedelta(days=7) QUERIES = [ "looking for pixel artist", "need a pixel artist", "hiring pixel artist", "pixel art commission", "looking for an artist game", "looking for artist indie game", "need sprites", "looking for sprites", "need assets game", "looking for assets", "asset pack recommendation", "free assets gamedev", "tileset recommendation", "where do you get assets", "#gamedev looking for", "#indiedev looking for", "#pixelart commissions open", "LFA pixel", "paid commission pixel", "rev share artist", "programmer art", "can't draw gamedev", "asset store pack", "itch.io assets", ] TAGS = ["gamedev", "indiedev", "pixelart", "godotengine"] 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", {}) t = ts(rec.get("createdAt", p.get("indexedAt"))) if t < OLD or p["author"]["did"] == ME: return 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.get("createdAt"), "text": rec.get("text", ""), "is_reply": bool(rec.get("reply")), "bot": any(l.get("val") == "bot" for l in p["author"].get("labels", [])), "likes": p.get("likeCount", 0), "replies": p.get("replyCount", 0), "src": []}) d["src"].append(src) stats = {} for q in QUERIES: cursor, n = None, 0 for _ in range(3): r = get("app.bsky.feed.searchPosts", q=q, sort="latest", limit=100, **({"cursor": cursor} if cursor else {})) if "error" in r: print("ERR", q, r["error"]); break items = r.get("posts", []) for p in items: take(p, "q:" + q); n += 1 cursor = r.get("cursor") if not items or not cursor or ts(items[-1]["record"]["createdAt"]) < OLD: break stats["q:" + q] = n for tag in TAGS: # latest tag posts, 2 pages: the general background cursor, n = None, 0 for _ in range(2): r = get("app.bsky.feed.searchPosts", q="#" + tag, sort="latest", limit=100, **({"cursor": cursor} if cursor else {})) if "error" in r: print("ERR", tag, r["error"]); break items = r.get("posts", []) for p in items: take(p, "tag:" + tag); n += 1 cursor = r.get("cursor") if not items or not cursor: break stats["tag:" + tag] = n json.dump({"fetched": NOW.isoformat(), "window": [OLD.isoformat(), NOW.isoformat()], "stats": stats, "posts": list(posts.values())}, open("posts.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) print(json.dumps(stats, indent=0)); print("unique posts:", len(posts))