Bluesky Scraper: Profile & Post Change Feed
Pricing
Pay per event
Bluesky Scraper: Profile & Post Change Feed
Bluesky scraper for profile and post monitoring: follower/following changes, new posts, and like/repost/reply/quote deltas versus your last run. Built for brand monitoring, competitor tracking, and account growth tracking. Public AppView API only, no login required.
Pricing
Pay per event
Rating
0.0
(0)
Developer
Changefeeds Tools
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
A Bluesky scraper for anyone who needs to monitor a list of accounts over time rather than take a single snapshot: competitor and creator trackers, brand-mention watchers checking known accounts, and founders tracking their own growth. Give it a list of Bluesky accounts; each run returns every profile's current numbers and its recent posts, compared with the previous run of the same list: follower and following changes, which posts are new, and how many likes, reposts, replies and quotes each earlier post gained since you last looked.
Schedule it daily or weekly and the dataset becomes a change feed for competitor tracking, creator or brand monitoring, or your own account's growth, without logging in to Bluesky.
Use cases
Daily competitor/brand digest in Slack. Add a Schedule for this actor
(e.g. daily at 08:00) with your list of competitor or brand handles. On the
Actor's Integrations tab, add the built-in Slack integration (or a Zapier /
Make / Google Sheets integration, or a plain webhook) triggered on
"Actor run succeeded", and map the run's OUTPUT key-value record (or the
dataset) into the message. You get a daily post: who gained/lost followers,
which of their posts are new, and which post moved the most since yesterday
— without opening Bluesky.
What you get
For each handle, one profile row:
- DID, handle, display name, description, avatar, account creation date
- followers, following, posts count
followers_delta,follows_delta,posts_count_deltaversus the previous run (null on the first run, which is markedis_baseline: true)previous_checked_at,posts_found,new_posts_foundstatus:ok,not_found(deleted, suspended or misspelled handle) orerror
And up to postsPerProfile post rows per profile, newest first:
uri,url(a bsky.app link),text,created_atlike_count,repost_count,reply_count,quote_count,bookmark_count(when the AppView reports it), andengagement= likes + reposts + replies + quotesis_new: true when this post was not in the previous run's resultslike_delta,repost_delta,reply_delta,quote_delta,engagement_deltafor posts seen before (null for new posts)embed_type(images,video,external,record,recordWithMedia),links,hashtags,mentions(DID and handle),languageis_replywith parent and root URIs,is_repostwithreposted_at
The key-value store record OUTPUT holds a summary per profile: new posts,
posts returned, average engagement per post, the top post by engagement, and
follower change. If you set webhookUrl, the same summary is POSTed there as
JSON when the run ends.
Input
| Field | Default | Notes |
|---|---|---|
handles | required | Handles, @handles, custom-domain handles, DIDs, or bsky.app profile URLs. Up to 1,000. A bare name with no dot is read as name.bsky.social. |
postsPerProfile | 50 | 0 to 1,000. 0 returns profile rows only. |
since | none | ISO date. Older posts are skipped and paging stops at that date. |
includeReplies | false | Include the account's replies. |
includeReposts | false | Include reposts (their counts belong to the original post). |
snapshotKey | derived | Which saved state to compare against. By default it is derived from the sorted handle list, so the same list always compares with itself. Set it yourself if you plan to add or remove handles and want to keep the history. |
webhookUrl | none | Receives the run summary as a JSON POST. |
{"handles": ["bsky.app", "jay.bsky.team", "pfrazee.com"],"postsPerProfile": 20}
If a run finds nothing new, the dataset holds one no_changes row (not charged), so a quiet run is never mistaken for a broken one.
Sample output
These rows come from a real run on 2026-09-29 (UTC) against the account
bsky.app. It was the second run of the same list, a few seconds after the
first, so every delta is 0. Long fields are shortened here.
{"type": "profile","input": "bsky.app","status": "ok","did": "did:plc:z72i7hdynmk6r22z27h6tvur","handle": "bsky.app","url": "https://bsky.app/profile/bsky.app","display_name": "Bluesky","followers": 35081051,"follows": 15,"posts_count": 864,"created_at": "2023-04-12T04:53:57.057Z","logged_out_visible": true,"is_baseline": false,"previous_checked_at": "2026-09-29T03:57:48.078Z","followers_delta": 0,"follows_delta": 0,"posts_count_delta": 0,"posts_found": 20,"new_posts_found": 0,"checked_at": "2026-09-29T03:57:49.572Z"}
{"type": "post","profile_handle": "bsky.app","uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mw2cdr44fc2a","url": "https://bsky.app/profile/bsky.app/post/3mw2cdr44fc2a","text": "If you're in line to vote for @bsky38.com, please stay in line! Polls close in just under 5 hours.","created_at": "2026-09-21T18:04:06.128Z","like_count": 749,"repost_count": 93,"reply_count": 100,"quote_count": 47,"bookmark_count": 40,"engagement": 989,"embed_type": "record","links": [],"hashtags": [],"mentions": [{ "did": "did:web:bsky38.com", "handle": "bsky38.com" }],"language": "en","is_reply": false,"is_repost": false,"is_new": false,"previous_seen_at": "2026-09-29T03:57:48.078Z","like_delta": 0,"repost_delta": 0,"reply_delta": 0,"quote_delta": 0,"engagement_delta": 0,"checked_at": "2026-09-29T03:57:49.572Z"}
Pricing
Pay per event, nothing else:
- $0.002 per profile checked (a profile that was found and compared).
- $0.0005 per post returned ($0.50 per 1,000 posts).
Handles that are not found or fail to load are listed in the dataset with an error status and are not charged.
If you set a maximum total charge for the run, the actor returns only as many
rows as fit, saves state for what it returned, stops, and says so in OUTPUT
(stopped_reason: "max_total_charge_reached").
What it costs
Every run charges the same way whether it's your first (baseline) run or a
later comparison run: postsPerProfile posts are returned and charged each
time, not just the new ones — the previous-run comparison only changes what
each row says (is_new, deltas), not how many rows or events you pay for.
Baseline run — small watchlist. 5 handles, postsPerProfile: 20:
5 x $0.002 (profiles) + 100 x $0.0005 (posts) = $0.01 + $0.05 = $0.06.
Steady state — same watchlist, scheduled daily. Same 5 handles and
postsPerProfile: 20, run once a day by a Schedule: each run still costs
$0.06, so 30 daily runs = $1.80/month.
Larger watchlist. 50 handles, postsPerProfile: 50:
50 x $0.002 + 2,500 x $0.0005 = $0.10 + $1.25 = $1.35/run; scheduled
daily, that's ~$40.50/month.
postsPerProfile: 0 (profile rows only, no posts) drops the per-run cost to
just the profile-checked charges, e.g. $0.02/run for 10 handles.
How the comparison works
- State lives in the named key-value store
skywatch-snapshots, one record per profile and snapshot key, keyed by DID, so a handle change does not reset history. - A profile whose lookup fails keeps its previous state untouched. If its post feed fails part-way, the profile numbers are updated and every previously remembered post is kept, so nothing is lost for the next comparison.
- Only posts actually returned to you are remembered.
is_newmeans "not returned by an earlier run of this snapshot key", so raisingpostsPerProfilealso marks older posts that are now included for the first time as new. - The first run of a list is the baseline: all posts are
is_new: trueand profile deltas are null. - Do not run two runs with the same snapshot key at the same time: the one that
finishes last wins. If the same account appears twice in
handles(say as a handle and as its DID), it is checked and charged once. - Each profile remembers its newest 3,000 posts; older ones are dropped from the saved state (they are far outside the 1,000-post window a run can read).
Limits, stated plainly
- Public data only. It reads Bluesky's public AppView
(
public.api.bsky.app) without an account. Private data, DMs, blocked content, and accounts that asked not to be shown to logged-out viewers are not returned. For those accounts (logged_out_visible: false) you get the profile row but no posts. - Counts are the AppView's. Followers, likes and other numbers are what the AppView reports at the time of the run; the public API is cached for about 30 seconds, and counts on other Bluesky clients or AppViews can differ.
- No keyword search.
app.bsky.feed.searchPostsreturns 403 to unauthenticated requests on the public AppView (checked 2026-09-28), so this actor does not offer post search. - Follower lists are not included, only follower counts.
- Reposted posts carry the original post's counts, not engagement on the repost.
- It stays polite: at most 5 requests per second for the whole run, waits out
HTTP 429 responses (honouring
Retry-AfterandRateLimit-Reset), and identifies itself with a clear User-Agent. Large lists therefore take time: one request per 25 profiles, plus at least one feed request per profile when posts are requested (a feed page holds up to 100 posts). In our live check, 3 profiles with 20 posts each took 4 requests and 1 to 2 seconds.
Local development
pnpm --filter @mmnm/skywatch test # unit tests, no networkpnpm --filter @mmnm/skywatch buildnode apps/skywatch/scripts/live-check.ts # two real passes over 3 public accounts
node src/main.ts runs the actor locally with Apify's local storage
(./storage); ACTOR_TEST_PAY_PER_EVENT=true ACTOR_MAX_TOTAL_CHARGE_USD=4
exercises the charging path with the SDK's $1 local test price.