# Bluesky Scraper — search posts, profiles, followers as JSON (`westerly_breaker/bluesky-scraper`) Actor

Search Bluesky posts by keyword or fetch profiles, followers, follows and author feeds via the open AT Protocol API. Engagement counts, embeds, language and direct links in clean JSON — no login, no API key, no proxies. Built for social analytics, research, brand monitoring and AI agents.

- **URL**: https://apify.com/westerly\_breaker/bluesky-scraper.md
- **Developed by:** [Daniel Posztos](https://apify.com/westerly_breaker) (community)
- **Categories:** AI, Social media
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 item results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Bluesky Scraper — search posts, profiles, followers as JSON

**SEO title:** Bluesky Scraper — search posts, profiles, followers as JSON
**SEO description:** Search Bluesky posts by keyword or fetch profiles, followers, follows and author feeds via the open AT Protocol API. Engagement counts, embeds, language and direct links in clean JSON — no login, no API key, no proxies. Built for social analytics, research, brand monitoring and AI agents.

One actor, five data types, zero credentials. Bluesky runs on the open AT Protocol: its AppView API is public and documented, so this actor talks to the same endpoints the official app uses — no scraping fragility, no anti-bot arms race, no proxy costs.

### What it does

Pick a `type` and get clean, deduplicated JSON items:

| `type` | What you get | Needs |
|---|---|---|
| `posts` (default) | Keyword search across all Bluesky posts, newest first | `search_query` |
| `profiles` | Full profile per account: bio, follower/post counts | `handles` |
| `followers` | Who follows the given account(s) | `handles` |
| `follows` | Who the given account(s) follow | `handles` |
| `author_feed` | Recent posts/reposts from the given account(s)' own timelines | `handles` |

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `type` | string | `"posts"` | One of `posts`, `profiles`, `followers`, `follows`, `author_feed`. |
| `search_query` | string | — | Keyword(s) for post search. Required for (and only used by) `type: "posts"`. |
| `handles` | array of strings | `[]` | Bluesky handles (`"bsky.app"`) and/or DIDs (`"did:plc:..."`), mixable. Required for every type except `posts`. |
| `max_items` | integer (1–5000) | `100` | Total result cap. For `followers`/`follows`/`author_feed` with multiple handles the budget is split evenly across handles. |
| `date_from` | string | — | ISO date (`"2026-06-01"`) or datetime (`"2026-06-01T00:00:00Z"`). Only used by `type: "posts"`: restricts results to posts on/after this moment. |

Type-specific required fields are validated up front with an error message that says exactly what to add (e.g. `type: "posts"` without `search_query` → *"Post search needs a keyword: set e.g. 'search\_query': "bitcoin""*).

#### Worked example — input

```json
{
  "type": "posts",
  "search_query": "bitcoin",
  "max_items": 25
}
```

#### Worked example — output (1 of 25 items, real data)

```json
{
  "post_uri": "at://did:plc:22mtqdiuelequw4xh4hirr4w/app.bsky.feed.post/3mpwduk2qgk2u",
  "cid": "bafyreidyobz5lit5f4k46yeyil3i26b3jvkdv35xuhhmztxuufdjwaqry4",
  "author": {
    "handle": "bufbvr.bsky.social",
    "did": "did:plc:22mtqdiuelequw4xh4hirr4w",
    "display_name": "Buffcoat and Beaver"
  },
  "text": "Just walked into the luncheonery store... Oh, macro-flavored Bitcoin.",
  "created_at": "2026-07-05T19:49:34.521858+00:00",
  "likes": 0,
  "reposts": 0,
  "replies": 0,
  "quotes": 0,
  "embeds": [],
  "lang": "en",
  "url": "https://bsky.app/profile/bufbvr.bsky.social/post/3mpwduk2qgk2u",
  "scraped_at": "2026-07-05T19:51:39.872045+00:00"
}
```

A profile lookup (`{"type": "profiles", "handles": ["bsky.app"]}`) returns:

```json
{
  "did": "did:plc:z72i7hdynmk6r22z27h6tvur",
  "handle": "bsky.app",
  "display_name": "Bluesky",
  "description": "official Bluesky account (check username👆)...",
  "followers_count": 33981092,
  "follows_count": 10,
  "posts_count": 800,
  "created_at": "2023-04-12T04:53:57.057Z",
  "url": "https://bsky.app/profile/bsky.app",
  "scraped_at": "2026-07-05T19:45:01.163306+00:00"
}
```

### Output schema

#### `type: "posts"` and `type: "author_feed"` — post item

| Field | Type | Notes |
|---|---|---|
| `post_uri` | string | AT Protocol URI (`at://did/app.bsky.feed.post/rkey`) — the post's stable ID |
| `cid` | string | Content hash of the post record |
| `author.handle` | string | Author's handle (may read `handle.invalid` while an account's domain verification is broken) |
| `author.did` | string | Author's stable decentralized ID |
| `author.display_name` | string or `null` | |
| `text` | string | Post text |
| `created_at` | string (ISO 8601) | Author-declared creation time |
| `likes` / `reposts` / `replies` / `quotes` | integer | Engagement counts at scrape time |
| `embeds` | array | Simplified: `{type, url, alt}` with `type` one of `image`, `link`, `quote`, `video`, `other`. `alt` is image alt-text / link title. |
| `lang` | string or `null` | Post's self-declared primary language (`"en"`, …) |
| `url` | string | `https://bsky.app/profile/{handle}/post/{rkey}` — opens in a browser |
| `is_repost` | boolean | **author\_feed only:** the requested account reposted someone else's post (whose `author` is then the original author) |
| `feed_of` | string | **author\_feed only:** the requested handle/DID this item came from |
| `scraped_at` | string (ISO 8601) | When this actor fetched the data |

#### `type: "profiles"` — profile item

| Field | Type | Notes |
|---|---|---|
| `did` | string | Stable decentralized ID |
| `handle` | string | e.g. `"bsky.app"` |
| `display_name` | string or `null` | |
| `description` | string or `null` | Profile bio |
| `followers_count` / `follows_count` / `posts_count` | integer | |
| `created_at` | string (ISO 8601) | Account creation time |
| `url` | string | `https://bsky.app/profile/{handle}` |
| `scraped_at` | string (ISO 8601) | |

#### `type: "followers"` and `type: "follows"` — connection item

| Field | Type | Notes |
|---|---|---|
| `subject_handle` | string | The requested account this connection belongs to |
| `subject_did` | string | The requested account's DID |
| `did`, `handle`, `display_name`, `description`, `created_at`, `url` | | The connected profile (the follower, or the followed account) — same meaning as the profile item |
| `followers_count` / `follows_count` / `posts_count` | `null` | Always `null` here: Bluesky's follower/follow list API returns compact profiles without counts. Run a second `type: "profiles"` pass on the handles you care about to get counts. |
| `scraped_at` | string (ISO 8601) | |

### Pricing (pay-per-event)

| Event | Price | When it's charged |
|---|---|---|
| `item-result` | $0.002 | Once per item returned, regardless of `type` |

Plus Apify's own `apify-actor-start` synthetic event (first 5 seconds of compute free, platform-managed).

**Examples:** 1000 posts = 1000 × $0.002 = **$2.00**. 25-post smoke run = $0.05. 10 profile lookups = $0.02. 5000 followers = $10.00.

If a run's cost would exceed the `Max total charge USD` you set for it, the actor stops producing further results at exactly that point — it never crashes and never produces unbilled/"free" results past the limit. Delivered count always equals charged count.

### Error messages

- **`type: "posts"` without `search_query`:** *"Post search needs a keyword: set e.g. 'search\_query': "bitcoin"..."* — add the field (the message also tells you which type to use if you meant account lookups).
- **`profiles`/`followers`/`follows`/`author_feed` without `handles`:** *"This type needs at least one Bluesky handle or DID to look up..."*
- **Unknown `type`:** the message lists all five valid values and what each does.
- **`max_items` out of range / wrong types / bad `date_from`:** the message states the expected format and a corrected example.
- **A handle that doesn't exist:** that handle is skipped with a log warning naming it (other handles in the list still return data); if every input handle fails, the run ends with the explicit 0-results warning below.
- **0 results with otherwise valid input:** not an error — the run succeeds, but the log carries an explicit `WARNING: 0 results produced despite valid input (...)` line and the run's status message says so. Never a silent "nothing happened."

### Known limitations (documented, not hidden)

- **Public data only.** The actor reads Bluesky's open, unauthenticated AppView. No private accounts, no logged-in-only data, no DMs — by design.
- **Post search depth:** Bluesky's API rejects unauthenticated cursor pagination on post search, so this actor paginates search results by time-windowing (`until` keyset on the post timestamp) instead. Results stay newest-first and are deduplicated; pathological corner cases (hundreds of posts sharing one millisecond) could theoretically end pagination early. Followers/follows/author feeds use normal cursor pagination (not affected).
- **Rate limits:** the AppView rate-limits per IP; bursts get HTTP 429, which the actor retries with exponential backoff automatically. Very large runs simply take a bit longer.
- **Follower/follow counts are `null` in list items** (see the connection-item table) — that's what the upstream list API provides.
- **`author_feed` includes reposts** (marked `is_repost: true`); pinned-post duplication is avoided by URI-level dedup.

### Data sources (public, no API key, no scraping)

Bluesky AppView (AT Protocol XRPC over HTTPS): `app.bsky.feed.searchPosts`, `app.bsky.actor.getProfile`, `app.bsky.feed.getAuthorFeed`, `app.bsky.graph.getFollowers`, `app.bsky.graph.getFollows` — the same documented, unauthenticated endpoints the official Bluesky app calls.

# Actor input Schema

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

What kind of data to fetch. "posts" searches Bluesky posts by keyword (requires 'search\_query'). "profiles" looks up full profile info for specific accounts (requires 'handles'). "followers" / "follows" list who follows / who is followed by specific accounts (requires 'handles'). "author\_feed" fetches recent posts from specific accounts' own timelines (requires 'handles'). Defaults to "posts".

## `search_query` (type: `string`):

Keyword(s) or phrase to search for across Bluesky posts, e.g. "bitcoin", "apify". Required (and only used) when 'type' is "posts" — leave empty for every other 'type'.

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

Bluesky handle(s) (e.g. "bsky.app") or DID(s) (e.g. "did:plc:z72i7hdynmk6r22z27h6tvur") to look up. Required when 'type' is "profiles", "followers", "follows", or "author\_feed" — not used for "posts". You can mix handles and DIDs in the same list.

## `max_items` (type: `integer`):

Maximum number of results to return in total. For 'followers'/'follows'/'author\_feed' with multiple 'handles', this budget is split evenly across the requested handles. Each item returned bills one 'item-result' event (see README pricing), so lowering this caps cost.

## `date_from` (type: `string`):

Optional ISO date or datetime, e.g. "2026-06-01" or "2026-06-01T00:00:00Z". Only used when 'type' is "posts": restricts results to posts indexed on/after this date. Ignored for every other 'type'.

## Actor input object example

```json
{
  "type": "posts",
  "search_query": "bitcoin",
  "handles": [
    "bsky.app"
  ],
  "max_items": 100,
  "date_from": "2026-06-01"
}
```

# Actor output Schema

## `items` (type: `string`):

Bluesky items (posts, profiles, or follower/follow/author-feed entries depending on the 'type' input), stored in the run's default dataset.

# 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 = {
    "search_query": "bitcoin"
};

// Run the Actor and wait for it to finish
const run = await client.actor("westerly_breaker/bluesky-scraper").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 = { "search_query": "bitcoin" }

# Run the Actor and wait for it to finish
run = client.actor("westerly_breaker/bluesky-scraper").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "search_query": "bitcoin"
}' |
apify call westerly_breaker/bluesky-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=westerly_breaker/bluesky-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/8aLFzfjZebzoign2A/builds/4MDGeAxsXUSikc8Vd/openapi.json
