# Bluesky Scraper: Profile & Post Change Feed (`changefeeds/bluesky-profile-analytics-change-feed`) Actor

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.

- **URL**: https://apify.com/changefeeds/bluesky-profile-analytics-change-feed.md
- **Developed by:** [Changefeeds Tools](https://apify.com/changefeeds) (community)
- **Categories:** Social media, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Bluesky Scraper: Profile & Post Change Feed

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

| 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. |

```json
{
  "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.

```json
{
  "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"
}
```

```json
{
  "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

```bash
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.

# Actor input Schema

## `handles` (type: `array`):

Bluesky accounts to check: handles (alice.bsky.social, @alice.bsky.social), custom-domain handles (jay.bsky.team), DIDs (did:plc:...), or bsky.app profile URLs. A bare name without a dot is read as name.bsky.social. Up to 1,000 per run.

## `postsPerProfile` (type: `integer`):

Most recent posts to return per profile (0 = profile rows only). Each returned post is a billed event.

## `since` (type: `string`):

Optional ISO date or date-time (e.g. 2026-09-01). Older posts are skipped and paging stops once the feed passes this date.

## `includeReplies` (type: `boolean`):

Also return the account's replies to other posts.

## `includeReposts` (type: `boolean`):

Also return posts the account reposted (their counts belong to the original post).

## `snapshotKey` (type: `string`):

Name of the saved state used to compute changes between runs. Leave empty to derive it from the sorted handle list, so the same list always compares against its own last run. Set it explicitly to keep history when you edit the list.

## `webhookUrl` (type: `string`):

Optional. When the run finishes, the OUTPUT summary is POSTed here as JSON.

## Actor input object example

```json
{
  "handles": [
    "bsky.app",
    "jay.bsky.team"
  ],
  "postsPerProfile": 50,
  "includeReplies": false,
  "includeReposts": false
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "handles": [
        "bsky.app",
        "jay.bsky.team"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("changefeeds/bluesky-profile-analytics-change-feed").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "handles": [
        "bsky.app",
        "jay.bsky.team",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("changefeeds/bluesky-profile-analytics-change-feed").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "handles": [
    "bsky.app",
    "jay.bsky.team"
  ]
}' |
apify call changefeeds/bluesky-profile-analytics-change-feed --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,changefeeds/bluesky-profile-analytics-change-feed"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/2km6EAppeVOoXTn8E/builds/goZh6RkB3gzCZbXom/openapi.json
