--- name: bluesky-profile-scraper description: Read public Bluesky accounts as structured records via the Apify Actor arman-bd/bluesky-profile-scraper. Returns the DID, handle, display name, bio, avatar and banner URLs, follower, following and post counts, account creation date, moderation labels, and optionally the account's recent posts with engagement counts and up to 500 of its followers. Use when a task needs Bluesky profile metadata, follower-growth time series, social listening over a watchlist or creator discovery from a seed account's follower list. Not for search, feeds, threads, the accounts a profile follows, or anything behind a Bluesky login. --- # Bluesky Scraper: Profiles, Posts & Followers Apify Actor `arman-bd/bluesky-profile-scraper`. Give it a list of handles or DIDs, get one dataset record per account. Posts and followers are opt-in arrays on the same record. You supply no credentials. ## When to use it - A watchlist of accounts has to become rows: followers, following, post count, bio. - You are tracking follower growth on a schedule and want a diffable time series. - Social listening: recent posts with likes, reposts, replies and quotes per account. - Creator discovery: pull a seed account's followers, then feed those handles back in. - You have DIDs from somewhere else and need them resolved to current handles. ## When not to use it - Search. There is no query input, only an explicit list of accounts. - Threads, replies to a post, quote posts, or any feed other than an account's own. - The accounts a profile *follows*. `followsCount` is returned, the follows list is not. - Anything requiring a Bluesky session: DMs, private lists, blocked-account views. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/bluesky-profile-scraper').call({ handles: ['bsky.app', '@jay.bsky.team', 'https://bsky.app/profile/pfrazee.com'], includePosts: true, maxPostsPerProfile: 25, includeFollowers: false, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~bluesky-profile-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"handles":["bsky.app","jay.bsky.team"],"includePosts":true,"maxPostsPerProfile":25}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/bluesky-profile-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `handles` | string[] | yes | | Bare handle (`bsky.app`), `@handle`, a `https://bsky.app/profile/` URL, or a raw `did:plc:…`. Mixed forms are fine and are normalised. Handles are lower-cased; DIDs are passed through untouched. | | `includePosts` | boolean | no | `false` | Attach the account's recent posts with engagement counts. Adds one request per 100 posts per profile. | | `maxPostsPerProfile` | integer | no | `50` | Cap on posts collected per profile, minimum `1`. Only read when `includePosts` is on. A value of zero or less falls back to 50. | | `includeFollowers` | boolean | no | `false` | Attach the account's followers, newest first. Hard cap of 500 per profile, not configurable. | **The two booleans are the whole cost model.** Profiles alone are resolved 25 per request, so a 100-handle run costs 4 requests and everything in the profile block (counts, bio, avatar, banner, labels, `createdAt`) rides along on it. `includePosts` adds one request per 100 posts per profile. `includeFollowers` is the expensive one: five extra requests per profile at the 500 cap, and it does not scale to a bulk sweep. Leave both off for scheduled tracking of a long list; turn one on for a short, deliberate list. ## Output One record per account that resolved, de-duplicated by DID. | Field | Type | Notes | |---|---|---| | `did` | string | Permanent decentralised identifier. The only stable key: handles change, DIDs do not. Join your own tables on this. | | `handle` | string | Current handle at scrape time. | | `displayName` | string \| null | Chosen display name, `null` when unset. | | `description` | string \| null | Profile bio, newlines and emoji intact. | | `avatar` | string \| null | Image URL, `null` when unset. | | `banner` | string \| null | Image URL, `null` when unset. | | `followersCount` | number | Followers, `0` when the source omits it. | | `followsCount` | number | Accounts this one follows. The list itself is not returned. | | `postsCount` | number | Lifetime post count as indexed. Not the length of `posts`. | | `createdAt` | string \| null | Account creation time, ISO 8601. Account age for influence scoring. | | `posts` | object[] \| null | `null` when `includePosts` is off. Keys: `uri`, `cid`, `text`, `createdAt`, `indexedAt`, `langs`, `replyCount`, `repostCount`, `likeCount`, `quoteCount`, `isRepost`, `isReply`. | | `followers` | object[] \| null | `null` when `includeFollowers` is off. Keys: `did`, `handle`, `displayName`, `avatar`, `createdAt`. | | `labels` | string[] | Moderation label values applied to the account, flattened to strings, e.g. `!no-unauthenticated`. Empty array when none. | | `indexedAt` | string \| null | When the profile was last reindexed upstream. Not the scrape time. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, `posts` abridged to one entry and long strings trimmed: ```json { "did": "did:plc:oky5czdrnfjpqslsw2a5iclo", "handle": "jay.bsky.team", "displayName": "Jay 🦋", "description": "Founder & Chief Innovation Officer @ Bluesky\n\n🌱 🪴 🌳", "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkrei….jpeg", "banner": "https://cdn.bsky.app/img/banner/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkrei….jpeg", "followersCount": 595228, "followsCount": 3974, "postsCount": 4110, "createdAt": "2022-11-17T06:31:40.296Z", "posts": [ { "uri": "at://did:plc:oky5czdrnfjpqslsw2a5iclo/app.bsky.feed.post/3ms7bligcf22z", "cid": "bafyreie46c5unflqnrqi7xw3uzotpxzbobsmqul3iolbgnrth6f352jtym", "text": "New corner of the internet, zero idea who to follow? …", "createdAt": "2026-08-03T19:53:03.901Z", "indexedAt": "2026-08-03T19:53:05.435Z", "langs": ["en"], "replyCount": 9, "repostCount": 9, "likeCount": 72, "quoteCount": 1, "isRepost": true, "isReply": false } ], "followers": null, "labels": [], "indexedAt": "2026-03-29T21:16:33.460Z", "scrapedAt": "2026-08-06T11:35:03.807Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** It is where a partial run admits that it was partial. ```json { "handlesRequested": 5, "handlesFailed": 1, "failures": [ { "handle": "definitely-not-real-xyz123.bsky.social", "error": "profile not found" } ], "profilesSaved": 3, "filters": { "handles": ["bsky.app", "jay.bsky.team", "pfrazee.com", "did:plc:z72i7hdynmk6r22z27h6tvur", "definitely-not-real-xyz123.bsky.social"], "includePosts": true, "maxPostsPerProfile": 5, "includeFollowers": true, "maxFollowersPerProfile": 500 }, "finishedAt": "2026-08-06T11:35:40.714Z" } ``` `profilesSaved` below `handlesRequested` is normal and has two innocent causes, so check both before treating it as a failure: entries in `failures`, and collapsed duplicates. Above, five handles gave three records because one handle was bad and `did:plc:z72i7hdynmk6r22z27h6tvur` is `bsky.app` again. `filters.handles` is the normalised list the run actually used, so diff it against your input to see what the normaliser did. A `failures` entry whose `error` starts with `posts:` or `followers:` means the profile *was* saved, with that one array empty. ## Behaviour to plan around - **Key on `did`, never on `handle`.** Handles are user-changeable domain names and do get changed. Two records will never share a DID in one run. - **Duplicate inputs collapse silently.** A handle, its `@` form, its profile URL and its DID are four inputs and one record. Do not expect `profilesSaved` to equal the length of your list. - **An unknown handle does not error.** The batch response simply omits it, so the Actor diffs requested against returned and records each miss in `failures`. The run only throws when *no* profile resolved at all. - **`null` and `[]` mean different things for `posts` and `followers`.** `null` means the flag was off. `[]` means the flag was on and the account genuinely has none, or that sub-fetch failed and the error is in `failures`. Do not read `[]` as "no posts". - **`posts.length` will not match `postsCount`.** `postsCount` is the lifetime total; `posts` is capped by `maxPostsPerProfile` and drawn from the author feed, which mixes in the account's own reposts and replies. They are flagged with `isRepost` and `isReply` rather than dropped. Filter client-side for originals only. - **Followers stop at 500 per profile, newest first.** There is no input to raise it. Follower lists are unbounded and an uncapped fetch on a popular account would run until the Actor timed out. For a large graph, walk it across runs using the returned follower `did`s. - **Transient failures are retried** three times with linear backoff. An invalid or deactivated account fails fast and is not retried, because retrying never helps. - **Accounts carrying `!no-unauthenticated` publish less.** That label appears in `labels`, and thinner fields on such an account are expected, not a scrape fault. - **`indexedAt` is upstream reindex time, not freshness of the counts.** It can be months old on an active account. Use `scrapedAt` for your own recency logic. ## Recipes **Track a watchlist weekly.** The cheapest mode: no follow-up calls, 25 profiles per request. ```json { "handles": ["bsky.app", "jay.bsky.team", "pfrazee.com"], "includePosts": false, "includeFollowers": false } ``` Diff `followersCount`, `followsCount` and `postsCount` against the previous run, keyed on `did`, bucketed by `scrapedAt`. **Social listening over an open network.** Recent posts with engagement, enough depth to rank. ```json { "handles": ["bsky.app", "jay.bsky.team"], "includePosts": true, "maxPostsPerProfile": 100 } ``` Rank by `likeCount + repostCount + quoteCount`, and drop entries where `isRepost` is true to separate original output from amplification. **Creator discovery from a seed account.** One handle, followers on. Expect roughly five extra requests for it. ```json { "handles": ["pfrazee.com"], "includeFollowers": true } ``` Take `followers[].handle` from the record and run those back through this Actor with both flags off, then score the results on `followersCount`, `postsCount` and `createdAt`.