Bluesky Scraper: Profile & Post Change Feed avatar

Bluesky Scraper: Profile & Post Change Feed

Pricing

Pay per event

Go to Apify Store
Bluesky Scraper: Profile & Post Change Feed

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

Changefeeds Tools

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

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_delta versus the previous run (null on the first run, which is marked is_baseline: true)
  • previous_checked_at, posts_found, new_posts_found
  • status: ok, not_found (deleted, suspended or misspelled handle) or error

And up to postsPerProfile post rows per profile, newest first:

  • uri, url (a bsky.app link), text, created_at
  • like_count, repost_count, reply_count, quote_count, bookmark_count (when the AppView reports it), and engagement = likes + reposts + replies + quotes
  • is_new: true when this post was not in the previous run's results
  • like_delta, repost_delta, reply_delta, quote_delta, engagement_delta for posts seen before (null for new posts)
  • embed_type (images, video, external, record, recordWithMedia), links, hashtags, mentions (DID and handle), language
  • is_reply with parent and root URIs, is_repost with reposted_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

FieldDefaultNotes
handlesrequiredHandles, @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.
postsPerProfile500 to 1,000. 0 returns profile rows only.
sincenoneISO date. Older posts are skipped and paging stops at that date.
includeRepliesfalseInclude the account's replies.
includeRepostsfalseInclude reposts (their counts belong to the original post).
snapshotKeyderivedWhich 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.
webhookUrlnoneReceives 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_new means "not returned by an earlier run of this snapshot key", so raising postsPerProfile also 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: true and 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.searchPosts returns 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-After and RateLimit-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 network
pnpm --filter @mmnm/skywatch build
node 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.