Scrape Bluesky posts by keyword or hashtag, user profiles, author feeds, followers, follows and full reply threads. No login, no browser — fast, cheap, clean JSON.
All notable changes to this Actor are documented here. The format follows Keep a Changelog .
[1.0.0] — 2026-09-26
Added
Six modes: search, profile, authorFeed, followers, follows, thread.
Flat post output: text, engagement counts, languages, hashtags, mentions, links, images (full size + alt), video, link card, flattened quoted post, reply / repost / thread context.
Flat profile output: counts, bio, avatar, banner, join date, pinned post, website parsed from the bio, and follower/follows relation context.
Pay-per-event charging: actor-start, post-scraped, profile-scraped (see .actor/pay_per_event.json).
Optional Bluesky login (handle + app password) that unlocks paginated search with server-side date filters.
STATS record with categorized error counters (blocked / rateLimited / proxy / network / parse / notFound / other), per-input status and charges, logged every 30 s.
Decisions (and why)
Plain XRPC over HTTPS instead of @atproto/api or a browser. Only 8 read-only GET endpoints are needed, so a ~300-line client keeps the image small and gives exact control over retries, rate limits and host fallback. There's no browser: everything is available as JSON.
Cursor loop instead of Crawlee HttpCrawler. Each page depends on the previous page's cursor, so a request queue adds nothing. A loop enforces maxItems, the budget and ratelimit-reset precisely. got-scraping and Apify Proxy session rotation are still used.
Search goes to api.bsky.app, everything else to public.api.bsky.app. The public AppView answers searchPosts with a CDN 403. Any other method that gets a 403 on the primary host automatically switches to the fallback host.
Logged-out search returns one page (~100 posts) per term. As of 2026-09, Bluesky refuses unauthenticated searchPosts calls that use a cursor (403 "forbidden by administrative rules") or since/until (400). We don't work around this. Date filters are applied locally, and deep search needs the user's own login (owner decision, 2026-09-26).
Login is optional, search-only, and app-password-only. The password must match the app-password format, so main passwords are rejected. Authenticated calls go direct rather than through rotating proxies, so the account keeps one stable IP. Tokens live in memory only. The service URL must be a public https:// host (SSRF guard).
Accounts with the !no-unauthenticated self-label are skipped (posts, profiles, followers, threads, quoted posts), matching Bluesky's logged-out web app. They're counted in STATS.optOutSkipped (owner decision, 2026-09-26).
maxItems is per input (per search term, handle or thread), matching the product brief's field name, instead of the template's maxResults. In Profile mode it caps the number of handles.
Results are deduplicated across the whole run by post AT-URI (reposts by reposter + URI; graph entries by relation + subject + DID). A post matched by two search terms is saved and charged once.
Followers / follows are enriched with getProfiles (1 extra request per 25 profiles), so follower counts are never missing. If enrichment fails, the basic profile is saved with null counts rather than dropped.
Charging happens after pushData succeeds. The chargeable count is checked first, so the platform's overcharge-by-one safeguard is never triggered. Dedupe keys are persisted on persistState, so a migrated run never re-pushes or re-charges.
since / until and the login fields have no default or prefill. A default would silently filter or authenticate every run. Every other input field has a default and a prefill, and the default input returns results.
Vitest 3 rather than 5. Vitest 5 requires Node ≥ 22, while the Actor image is Node 20.
Known advisories (accepted)
npm audit reports moderate advisories in vitest / @vitest/mocker (dev-only; the fix needs Node 22) and in stream-json via @crawlee/core (a transitive dependency of apify; the vulnerable JSON filter functions aren't used by this Actor). CI fails on high or critical production advisories.