# Bluesky Scraper — Posts, Profiles & Search (`eszetael_lab/bluesky-scraper`) Actor

Scrape Bluesky without a login: posts, profiles, threads, hashtags and full-text search over the decentralized AT Protocol. Built for social listening and research. Multiple relays with deduplication, incremental mode returns only new posts. Empty runs cost nothing.

- **URL**: https://apify.com/eszetael\_lab/bluesky-scraper.md
- **Developed by:** [Radosław Szal](https://apify.com/eszetael_lab) (community)
- **Categories:** Social media, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.70 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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 — Posts, Profiles, Hashtags & Search (AT Protocol)

> 🔗 Part of the **[Apify actors collection](https://github.com/Eszetael/apify-actors)** — actors that chain: scrape → clean → use.

Scrape **Bluesky** without a login: posts, profiles, threads, hashtags, follower graphs and
full-text search, exported as clean JSON, CSV or Excel. Built directly on the public
**AT Protocol** — the decentralized protocol Bluesky itself runs on — so there is no browser,
no proxy and no fragile HTML parsing to break when the site changes.

Built for **social listening**, brand monitoring and research.
**$0.001 per record — $1.00 per 1 000**, and an empty run costs nothing.

***

### What you get back, and how fast

Every record is **flat and typed** — engagement counts, hashtags, mentions, links and media pulled
up to top-level fields, with a real clickable `https://bsky.app/...` URL. Post records carry
`uri, url, cid, text, createdAt, indexedAt, langs, author{did,handle,displayName,avatar},
likeCount, repostCount, replyCount, quoteCount, hashtags, mentions, links, media[{url,alt,type}],
quotedPost, isReply, isRepost, labels`. Profile records carry `did, handle, url, displayName,
description, avatar, banner, followersCount, followsCount, postsCount, createdAt, labels`.

**A typical run finishes in about 6 seconds** (25 records, measured), against Apify's 5-minute
limit for automated checks. There is no browser and no proxy in the path — this reads the AT
Protocol directly — which is why it is both fast and cheap to run.

Full samples are in [Output](#%EF%B8%8F-output--what-you-get-back) below.

### What can this Bluesky scraper do?

Seven jobs in one Actor. Pick a `mode`:

| Mode | What it does | Login? |
|------|--------------|--------|
| **`author_posts`** | Every post from one or more accounts, with reply and media filters | No |
| **`search_posts`** | Full-text keyword search across Bluesky — date, language, author and hashtag filters | App password |
| **`profiles`** | Full profile details for given accounts | No |
| **`search_profiles`** | Find accounts by keyword | No |
| **`post_thread`** | The complete reply tree under a post | No |
| **`followers`** | An account's followers | No |
| **`follows`** | Who an account follows | No |

Only **keyword post search** needs an app password — Bluesky does not serve that one endpoint
anonymously. Everything else runs on the public AppView out of the box.

### Why use this one rather than another Bluesky scraper?

- **Image alt-text.** `media[].alt` carries the author's own description of each image. Most
  scrapers drop it; it is the single most useful field for AI, search and accessibility work.
- **The quoted post, in full.** When a post quotes another, you get the quoted text, author and
  URL — not just an opaque reference.
- **`handleResolved`.** You can tell a verified handle from an unresolved one (`handle.invalid`),
  and you get a working profile URL either way.
- **A flat, stable schema** — engagement counts, hashtags, mentions, links and media pulled up to
  top-level fields, and a real clickable `https://bsky.app/...` URL on every post. Not a raw API dump.
- **Incremental monitoring built in.** Set `onlyNew` and a scheduled run returns only what it has
  not seen before — so a daily brand-alert costs you the new posts, not the same ones again.
- **It fails loudly.** A run that fetches data and delivers nothing does not report success. See
  [What happens when something fails?](#what-happens-when-something-fails)

### How do I use it?

**Get an account's latest posts** (no login):

```json
{ "mode": "author_posts", "handles": ["nytimes.com", "bsky.app"], "maxItems": 100 }
```

**Search posts by keyword** (needs an app password):

```json
{
  "mode": "search_posts",
  "searchQuery": "electric vehicles",
  "lang": "en",
  "since": "2026-07-01",
  "sort": "latest",
  "maxItems": 500,
  "blueskyIdentifier": "you.bsky.social",
  "blueskyAppPassword": "<your-app-password>"
}
```

**Monitor mentions of your brand** — schedule it hourly and pay only for new posts:

```json
{
  "mode": "search_posts",
  "searchQuery": "\"YourBrand\"",
  "onlyNew": true,
  "stateLabel": "yourbrand-mentions",
  "blueskyIdentifier": "you.bsky.social",
  "blueskyAppPassword": "<your-app-password>"
}
```

**Get the full thread under a post** (no login):

```json
{ "mode": "post_thread", "postUrls": ["https://bsky.app/profile/bsky.app/post/3l..."], "threadDepth": 6 }
```

### How do I scrape a Bluesky hashtag?

Two ways, depending on what you want:

```json
{ "mode": "search_posts", "hashtags": ["climate"], "since": "2026-07-01",
  "blueskyIdentifier": "you.bsky.social", "blueskyAppPassword": "<your-app-password>" }
```

That searches all of Bluesky for the tag. If instead you want a specific account's tagged posts,
use `author_posts` and filter on the `hashtags` field in the output — every post record carries
its hashtags as a top-level array.

### How do I narrow what I get back — and pay less?

Billing is per delivered record, so every filter below is also a discount.

| Input | What it does |
|---|---|
| **`maxItems`** | Hard stop on records delivered. The cheapest way to try the Actor. |
| **`authorFeedFilter`** | Which slice of an author's feed: `posts_and_author_threads` keeps their posts and own threads, `posts_no_replies` drops replies to other people, `posts_with_media` keeps only posts with images or video. |
| **`since` / `until`** | ISO date bounds. Use both to pull a fixed window — one calendar month — rather than "everything newer than X". |
| **`fromAuthor`** | In `search_posts`, restricts the search to one author. "What did this account say about Y" in a single run instead of two. |
| **`lang`** | Restricts search to one language. |
| **`onlyNew` + `stateLabel`** | Returns only records not seen in previous runs under the same key. Turns any scrape into a monitor. |

### How do I get a Bluesky app password?

1. Open Bluesky → **Settings → Privacy and Security → App Passwords**.
2. **Add App Password**, name it (e.g. "Apify"), copy the value (format `xxxx-xxxx-xxxx-xxxx`).
3. Paste your handle into **Bluesky handle** and the value into **Bluesky app password**.

An app password is revocable and scoped — it is **not** your main password. It is sent only to
Bluesky's own `createSession` endpoint to sign in, and is never written to the dataset or the logs.

### ⬆️ Output — what you get back

#### 📝 Posts (`author_posts`, `search_posts`, `post_thread`)

```json
{
  "uri": "at://did:plc:.../app.bsky.feed.post/3l...",
  "url": "https://bsky.app/profile/handle/post/3l...",
  "cid": "bafy...",
  "text": "the post text",
  "createdAt": "2026-07-20T10:00:00.000Z",
  "indexedAt": "2026-07-20T10:00:01.000Z",
  "langs": ["en"],
  "author": {
    "did": "did:plc:...", "handle": "handle", "handleResolved": true,
    "displayName": "Name", "avatar": "https://..."
  },
  "likeCount": 6512, "repostCount": 539, "replyCount": 88, "quoteCount": 12, "bookmarkCount": 4,
  "hashtags": ["climate"], "mentions": ["did:plc:..."], "links": ["https://..."],
  "embedType": "images",
  "media": [{ "url": "https://...", "alt": "a solar farm at sunset", "type": "image" }],
  "quotedPost": {
    "uri": "at://...", "url": "https://bsky.app/profile/.../post/...",
    "author": "quoted.handle", "text": "the quoted post"
  },
  "external": null,
  "isReply": false, "isRepost": false, "replyParent": null, "replyRoot": null,
  "labels": []
}
```

#### 👤 Profiles (`profiles`, `search_profiles`, `followers`, `follows`)

```json
{
  "did": "did:plc:...", "handle": "handle", "handleResolved": true,
  "url": "https://bsky.app/profile/handle",
  "displayName": "Name", "description": "bio text",
  "avatar": "https://...", "banner": "https://...",
  "followersCount": 12043, "followsCount": 311, "postsCount": 2894,
  "createdAt": "2023-05-01T09:12:00.000Z", "indexedAt": "2026-07-20T10:00:01.000Z",
  "labels": []
}
```

Download from the run's **Dataset** tab as JSON, CSV or Excel, or pull it through the Apify API.

### What happens when something fails?

Failures are never written into your dataset as records, and you are never charged for one.

| Situation | What the Actor does |
|---|---|
| Bad or missing input for the chosen mode | Fails immediately with a message naming the problem. |
| App password rejected | Fails with `Authentication failed`, and **still bills only what was already delivered** — a mid-run auth failure never re-delivers or double-charges next run. |
| One target account errors, others work | Continues, delivers what worked, and counts the failure. |
| **Data fetched but zero records delivered** | The run **fails** with the reason — every record dropped in normalisation, every target errored, or pagination hit a safety backstop. A broken run never reports success. |
| `onlyNew` finds nothing new | **Succeeds** with zero records. That is a legitimate quiet poll, not a failure, and it costs nothing. |
| Pagination stops at a safety backstop | Reported as truncated — a partial result is never passed off as a complete one. |
| Billing calls keep failing | Delivery **stops** rather than giving data away unpaid, and the run says how many records it delivered first. |

The two rows that matter most are the fourth and the fifth. Distinguishing "nothing broke, there is
simply nothing new" from "everything broke and you got nothing" is the difference between a monitor
you can trust on a schedule and one you have to check by hand.

### How much does it cost?

- **$0.001 per record** delivered (`result-item`), flat — $1.00 per 1 000.
- **An empty run costs nothing.** No record written, no charge.
- Nothing is charged for compute time, pagination, retries or bandwidth. A slow run costs the same
  as a fast one.
- To try it cheaply, set `maxItems` to 10. That is three cents.

### What can I do with the data?

- **Social listening and brand monitoring** on the fastest-growing X alternative — schedule
  `search_posts` with `onlyNew` and get only what is new since the last check.
- **Research datasets** for AI and academia: public data, open protocol, alt-text included.
- **Topic and hashtag tracking** as Bluesky adoption grows.
- **Community and lead research** — find accounts around a topic, then profile them.
- **Clean it first**: pipe the dataset straight into
  [Dataset Deduplicator & Cleaner](https://apify.com/eszetael_lab/dataset-deduplicator-cleaner)
  to merge duplicates across runs before analysis.

### FAQ

#### Can I use it with the Apify API?

Yes. `POST /v2/acts/eszetael_lab~bluesky-scraper/runs` with your input as the JSON body, then read
the run's dataset. Standard API, CLI and client libraries all work.

#### Can I use it through an MCP server?

Yes. It is exposed through Apify's Actors MCP server like any public Actor, so an AI agent can call
it as a tool. It is also enabled for **agentic payments** — an agent can run and pay for it without
a human in the loop.

#### Can I schedule it to run automatically?

Yes — use Apify **Schedules**, and set `onlyNew: true` with a `stateLabel`. Each run then returns only
posts it has not seen before, so a daily monitor costs you the new posts rather than the whole feed
again.

#### Do I need a proxy?

No. The AT Protocol AppView serves public data directly. There is no anti-bot layer to defeat, which
is also why this Actor is cheap to run and does not break when a page layout changes.

#### Does it scrape private posts or DMs?

No. It reads only public data through the official public AppView. No private posts, no
logged-in-only content, no fake accounts.

#### Is it legal, and what about GDPR?

Reading public posts from an open protocol is broadly lawful, but profile and follower data is
personal data under GDPR. You need your own lawful basis for processing it, and you are responsible
for how you store and use what you export. This Actor gives you the data; it does not give you the
basis.

#### Your feedback

Found a bug, or a case where a mode returns something odd? Open an issue on the Actor's **Issues**
tab. Real failure reports are worth more to us than feature requests.

### Related actors

Three tools built to chain into each other — scrape, then clean, then use.

- **[Dataset Deduplicator & Cleaner](https://apify.com/eszetael_lab/dataset-deduplicator-cleaner)** —
  pass this Actor's dataset ID straight in to merge duplicates across runs before analysis.
  Six times cheaper than a scraper, because it processes data you already paid to collect.
- **[Python Web Scraper](https://apify.com/eszetael_lab/reliable-playwright-scraper)** — when your
  target is an ordinary website rather than Bluesky, write your own Python page function and get
  exactly the fields you want, with no template to fight.

All three are on pay-per-result pricing, and an empty run costs nothing in every one of them.

***

*Built on the AT Protocol (`app.bsky.*`). Keywords: bluesky scraper, bluesky api, at protocol
scraper, scrape bluesky posts, bluesky profile scraper, bluesky hashtag, bluesky search, social
listening, decentralized social, social media scraper, bluesky data export.*

# Actor input Schema

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

Pick the capability. Everything except 'search\_posts' works with no login.

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

e.g. nytimes.com, bsky.app, or did:plc:... . Used by author\_posts, profiles, followers, follows.

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

Keyword(s) for 'search\_posts' or 'search\_profiles'. Supports Bluesky search syntax. Example: climate policy — or with Bluesky syntax: "climate policy" -greenwashing.

## `postUrls` (type: `array`):

bsky.app post URLs (or at:// URIs) for 'post\_thread'. Example: https://bsky.app/profile/bsky.app/post/3l6oveex3ii2l (at:// URIs work too).

## `maxItems` (type: `integer`):

Hard cap on delivered records. You are billed per delivered record ($0.001), so this is also your cost ceiling: the default of 50 caps a run at $0.05. Raise it when you know how much you need; the safety limit is 50000.

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

For 'author\_posts': which posts to include.

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

Sort order for search results: 'latest' (newest first) or 'top' (most relevant).

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

Only posts on/after this time, e.g. 2026-07-01 or 2026-07-01T00:00:00Z.

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

Only posts before this date (YYYY-MM-DD). Search modes only.

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

Two-letter code, e.g. en, pl.

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

Restrict search to one account's posts. Handle with or without the @, e.g. bsky.app or @bsky.app.

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

Posts mentioning this account. Handle with or without the @, e.g. bsky.app.

## `hashtags` (type: `array`):

Posts carrying these tags, without the # sign — e.g. climate, opensource.

## `threadDepth` (type: `integer`):

For 'post\_thread': how many reply levels to walk.

## `onlyNew` (type: `boolean`):

Skip posts already delivered in previous runs of this saved task — turns any scrape into a change monitor / alert feed.

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

Optional label that groups the 'Only new' memory. Leave blank to derive it from the query or accounts. Example: daily-climate-watch. Two schedules sharing a label share one memory. This is a plain label, not a credential — it appears in run logs.

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

Only needed for 'search\_posts'. Your Bluesky handle, e.g. you.bsky.social.

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

Create at Bluesky → Settings → App Passwords. NOT your main password. Only used to sign in for post search; never stored.

## Actor input object example

```json
{
  "mode": "author_posts",
  "handles": [
    "bsky.app"
  ],
  "maxItems": 25,
  "authorFeedFilter": "posts_no_replies",
  "sort": "latest",
  "threadDepth": 6,
  "onlyNew": false
}
```

# Actor output Schema

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

Every post the run collected. Fields: url, text, author, createdAt, likeCount, repostCount, replyCount, quoteCount, hashtags, langs, quotedPost, media.

# 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 = {
    "mode": "author_posts",
    "handles": [
        "bsky.app"
    ],
    "maxItems": 25,
    "authorFeedFilter": "posts_no_replies",
    "onlyNew": false
};

// Run the Actor and wait for it to finish
const run = await client.actor("eszetael_lab/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 = {
    "mode": "author_posts",
    "handles": ["bsky.app"],
    "maxItems": 25,
    "authorFeedFilter": "posts_no_replies",
    "onlyNew": False,
}

# Run the Actor and wait for it to finish
run = client.actor("eszetael_lab/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 '{
  "mode": "author_posts",
  "handles": [
    "bsky.app"
  ],
  "maxItems": 25,
  "authorFeedFilter": "posts_no_replies",
  "onlyNew": false
}' |
apify call eszetael_lab/bluesky-scraper --silent --output-dataset

```

## MCP server setup

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

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/7pkGUaJT2aMtfrsQ1/builds/QmWgrgYlDlNejhhHC/openapi.json
