# Bluesky Posts Scraper & Keyword Monitor - Likes & Reposts (`neverempty/bluesky-posts-scraper`) Actor

For brand monitoring, social listening and research: Bluesky posts by keyword with text, author, likes, reposts, replies, links and hashtags, up to 2,000 per query. Monitoring returns only posts not returned before. A refused search is marked as refused, never as empty. No login or API key.

- **URL**: https://apify.com/neverempty/bluesky-posts-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Social media, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.19 / 1,000 post returneds

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/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 Posts Scraper & Keyword Monitor - Likes & Reposts

Search **Bluesky posts by keyword** and get one row per post: the text, the author (handle, DID, display name, avatar), **likes, reposts, replies, quotes and bookmarks**, the post and reply links, images, video, link card, hashtags, mentions and links. No Bluesky login, app password or API key is needed.

Turn on **monitoring mode**, schedule the Actor, and each run returns **only the posts it has not returned before** for each keyword - a feed of new mentions of your brand, product or topic. Queries Bluesky refuses and empty results come back as free rows that say why.

### What you can use it for

- **Brand and product monitoring**: schedule your brand names in monitoring mode and receive only new posts that mention them.
- **Topic and trend research**: collect the newest posts for a keyword with their engagement counts for analysis in a spreadsheet or BI tool.
- **Social listening for launches and campaigns**: track a hashtag or a phrase and see who is posting and how much the posts are liked and reposted.
- **Lead and community discovery**: find accounts that post about a subject (`authorHandle`, `authorDisplayName`, `postUrl`).

### How it works

1. Enter one or more search queries. Each one is sent to Bluesky's public search API (`https://api.bsky.app/xrpc/app.bsky.feed.searchPosts`, newest posts first, 100 per page).
2. Bluesky's search can only be paged back **by time**: its page cursor was refused (HTTP 403) on every try on 2026-09-17, so the Actor asks for the next page with `until` = the time of the last post read, and drops the posts it already has.
3. Bluesky refuses a share of anonymous search requests with `403 Forbidden`, including from cloud servers (on 2026-09-17, 0 of 12 direct requests from Apify were answered; through US residential proxies 26 of 56 were). The Actor therefore sends every request through an Apify residential proxy with a new session and tries each request up to 10 times in total, with a pause between tries. A query that is still refused gets a free `blocked` row - it is never reported as having no posts.
4. Up to 3 queries are searched at the same time, as Bluesky's `robots.txt` allows "a handful" of concurrent requests to its public API.

### Input

| Field | Default | What it does |
| --- | --- | --- |
| `queries` | example query | Search terms, one query per line. Bluesky's search syntax works: `"exact phrase"`, `#hashtag`, `from:handle`, `mentions:handle`, `domain:example.com`. Repeated queries (ignoring letter case and spaces) are searched once. Up to 100 per run. Left out with monitoring off, the query `web scraping` is searched. |
| `maxPostsPerQuery` | `100` | Monitoring off: rows returned per query, up to 2,000. Monitoring on: limits only a query's first run. |
| `language` | empty | Bluesky's language filter, e.g. `en`, `ja`, `pt-BR`. Bluesky decides which posts match; each row keeps the post's own tags in `langs`. |
| `postedAfter` | empty | Only posts created on or after this UTC date or time (`2026-09-01`, `2026-09-01T12:00:00Z`). Checked before sending, because Bluesky silently ignores a date it cannot read. |
| `monitoringMode` | `false` | Return only posts not returned before for each query (see below). |
| `resetMonitoringState` | `false` | Forget what was returned for the queries in this input, once. |

Example:

```json
{
  "queries": ["apify", "\"web scraping\"", "#opensource"],
  "maxPostsPerQuery": 200,
  "language": "en",
  "monitoringMode": true
}
```

#### Queries made only of hashtags or operators

A query with no plain word - for example `#bookclub`, `from:bsky.app` or `domain:github.com` - is answered by Bluesky only **without** a time range: with `since` or `until` it returned `400 Invalid request` on 2026-09-17 (adding a word, as in `books #bookclub`, works). For such queries the Actor reads **the newest 100 posts only**, applies `postedAfter` itself, and adds a free `incomplete-query` row when older posts may exist. Add a plain word to the query to read further back.

### Output

One row per post:

```json
{
  "source": "bluesky-search",
  "status": "ok",
  "query": "climate",
  "searchLanguage": null,
  "postUrl": "https://bsky.app/profile/reuters.com/post/3mvohc6ksst2q",
  "postUri": "at://did:plc:jbvnehrrdqoulco4rf5gxg5r/app.bsky.feed.post/3mvohc6ksst2q",
  "postCid": "bafyreicnwfzygeqfo5byd4vz3wgwudyur3lu5dqvlkfuqjcpxdzrohgsk4",
  "text": "Climate change likely to have helped trigger Nepal glacier collapse, scientists say reut.rs/4AchDBX",
  "textContainsQuery": true,
  "createdAt": "2026-09-17T01:00:44Z",
  "indexedAt": "2026-09-17T01:00:45.261Z",
  "langs": [],
  "authorHandle": "reuters.com",
  "authorDid": "did:plc:jbvnehrrdqoulco4rf5gxg5r",
  "authorDisplayName": "Reuters",
  "authorAvatarUrl": "https://cdn.bsky.app/img/avatar/plain/did:plc:jbvnehrrdqoulco4rf5gxg5r/bafkreibl5cibo6e7epl3pomyqeu7pkvb6yo7zg6q3wf2o4syej5yycrepy",
  "likeCount": 2,
  "repostCount": 0,
  "replyCount": 0,
  "quoteCount": 0,
  "bookmarkCount": 0,
  "isReply": false,
  "replyParentUri": null,
  "replyRootUri": null,
  "embedType": "external",
  "imageUrls": [],
  "imageAltTexts": [],
  "videoPlaylistUrl": null,
  "videoThumbnailUrl": null,
  "linkCardUrl": "https://reut.rs/4AchDBX",
  "linkCardTitle": "Climate change likely to have helped trigger Nepal glacier collapse, scientists say",
  "quotedPostUri": null,
  "hashtags": [],
  "mentionedDids": [],
  "linkUrls": ["https://reut.rs/4AchDBX"],
  "labels": [],
  "isNewSinceLastRun": null,
  "scrapedAt": "2026-09-17T01:02:11.000Z"
}
```

- **Counts** (`likeCount`, `repostCount`, `replyCount`, `quoteCount`, `bookmarkCount`) are the numbers Bluesky's API returned at `scrapedAt`. A count Bluesky did not return is `null`, not `0`.
- **`textContainsQuery`** says whether every word of the query appears in the post text (a word made of letters and digits must not be part of a longer word, so `AI` does not match "said"; a `#hashtag` is checked as its word). Bluesky also matches words outside the text (for example in link cards or image descriptions), so it can be `false` for a correct match: on 2026-09-17, the share of posts whose text contained all the words was 81 of 100 for `climate change`, 64 of 100 for `apify`, 70 of 99 for `machine learning`, 55 of 100 for `Taylor Swift` and 99 of 100 for `#bookclub`. It is `null` for a query made only of operators (such as `from:bsky.app`).
- **`createdAt`** is the time the post says it was written (set by the posting app); **`indexedAt`** is when Bluesky indexed it. Bluesky orders "latest" results by `createdAt`.
- **`embedType`** is `images`, `video`, `external` (link card), `record` (quote), `recordWithMedia` (quote with media), `gallery`, or `null`. `imageUrls` are the full-size images, with their descriptions in `imageAltTexts` in the same order.
- **`hashtags`, `mentionedDids`, `linkUrls`** come from the post's rich-text data, not from guessing in the text.
- **`postUrl`** uses the author's handle; if Bluesky marks the handle as invalid, it uses the DID instead.
- Inside the post text, email addresses are replaced with `[email removed]`, and numbers written with a `+` country code or as 10 digits starting with 6-9 (the mobile number shape in India) are replaced with `[phone removed]`. Other phone number formats are not removed.
- Rows from different queries can be interleaved, because queries run in parallel. Sort by `query` and `createdAt` if you need them grouped.
- A post found by two queries in the same run is returned and charged once, under the first query that found it.

### Rows that are never charged

Every row has a `status`. Only `ok` rows are charged. The others explain what happened in `note`:

| `status` | What it means |
| --- | --- |
| `ok` | A post. Charged. |
| `no-results` | Bluesky returned an empty result for the query. |
| `blocked` | Bluesky answered with 403, 429, a server error or nothing on all 10 attempts. The query is not reported as empty. Run it again. |
| `rejected-by-bluesky` | Bluesky rejected the search with HTTP 400; its message is in `note` (for example an unknown language code). |
| `unreadable` | Bluesky's answer was not a search result in the shape this Actor reads. |
| `invalid-input` | The input could not be used (empty query list, a date or language code that cannot be read, monitoring without queries). Nothing was requested. |
| `duplicate-query` | The same query appeared earlier in the list. |
| `incomplete-query` | Not every post for the query could be read in this run: only the newest 100 posts can be read for hashtag- or operator-only queries; more than 100 posts shared the same second; or, in monitoring mode, more posts were created since the last run than one run reads. The note gives the time range that was not read. |
| `no-new-posts` | Monitoring mode: no post that was not returned before. Only the check fee applies. |
| `budget-reached` | The run's maximum total charge was reached; the note lists the queries that were not searched. |
| `not-checked` | Queries beyond the first 100 in the list. |

### Pricing

- **$3.00 per 1,000 posts returned** (`post-returned`), with lower prices on higher Apify plans.
- **Monitoring mode only: $0.30 per 1,000 query checks** (`query-checked`) - one check each time a query is searched, whether or not it has new posts. Queries Bluesky refuses are not charged. Example: 20 queries checked every hour = 14,400 checks a month = $4.32, plus $3.00 per 1,000 new posts.
- Free rows (the table above) are never charged.
- The Actor stops before it goes over the maximum total charge you set for a run. In monitoring mode it starts a query only if the remaining charge can pay for a check and one post, so a low limit does not keep paying check fees without returning posts.

### Monitoring mode

With `monitoringMode` on, the Actor stores, for each query and language, which posts it returned and up to which post time it has read everything (in a named key-value store of your Apify account, so it is kept between runs).

- **First run of a query**: returns the newest posts, up to `maxPostsPerQuery`, as a starting point. `isNewSinceLastRun` is `false` on these rows.
- **Later runs**: search from 15 minutes before the last completed read (to catch posts Bluesky indexes late) and return only posts not returned before, with `isNewSinceLastRun: true`. `maxPostsPerQuery` does not cut them; up to 1,000 posts (10 pages) are read per query per run, and if more were created an `incomplete-query` row names the time range that was skipped. Run the monitor more often for very busy queries.
- A post written with an older `createdAt` than the last completed read is returned only if Bluesky indexed it after that time. Posts indexed more than 15 minutes late can be missed.
- If a run stops early (maximum total charge, a refused request), posts that were read but not returned are not remembered and the read position is not moved. A later run returns them if they are within what it searches: a query that has never completed a run is searched again for its newest `maxPostsPerQuery` posts, and a query that has is searched from its last completed read (up to 1,000 posts).
- Letter case and extra spaces do not make a query different; a different `language` does.
- `resetMonitoringState` forgets only the queries in the current input. Turn it off again after one run.
- Do not put the same query in two schedules that can run at the same time: Apify's key-value store has no atomic update, so two runs finishing together can overwrite each other's record and return a post twice.

### Limits

- Public posts only, as Bluesky's public search API returns them. No login is used, so posts that Bluesky shows only to logged-in users may be missing.
- Up to 100 queries and 2,000 posts per query per run (1,000 new posts per query per run in monitoring mode).
- Bluesky does not publish an exact result count for a search (it returned `hitsTotal` 10,000 for every busy query on 2026-09-17), so the Actor does not report one.
- Requests go through Apify residential proxies; if the proxy is not available to the run, requests go out directly and most are refused (`blocked` rows).

# Actor input Schema

## `queries` (type: `array`):

Keywords to search Bluesky posts for, one query per line. Each query is searched separately (newest posts first) and every row says which query found it. Bluesky's own search syntax works: quotes for a phrase, #hashtag, from:handle, mentions:handle, domain:example.com. A query made only of #hashtags or such operators can only be read for its newest 100 posts (Bluesky rejects a time range for it); add a plain word to read further back. Letter case and extra spaces are ignored when two queries are compared, and a repeated query gets a free 'duplicate-query' row. If you leave this field out with monitoring off, the example query "web scraping" is searched; with monitoring on it is required. Up to 100 queries per run.

## `maxPostsPerQuery` (type: `integer`):

With monitoring off, the newest posts are read 100 at a time until this many rows have been returned for a query (or Bluesky has no older posts). With monitoring on, it limits only the first run of a query; later runs return every post not returned before, up to 1,000 per query per run.

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

Sent to Bluesky's search as its language filter, as a code such as en, ja, de or pt-BR; Bluesky decides which posts match. Each row keeps the language tags the post itself carries in langs. Leave empty for all languages.

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

Only posts created on or after this date (UTC), for example 2026-09-01 or 2026-09-01T12:00:00Z. Bluesky ignores dates it cannot read, so the Actor checks the value first and rejects it with a free row instead.

## `monitoringMode` (type: `boolean`):

Off = every run returns the newest posts for each query. On = the Actor remembers, per query and language, which posts it has returned and up to which time it has read. The first run returns the newest posts (up to maxPostsPerQuery) as a starting point; later runs return only posts not returned before, up to 1,000 per query per run. **In monitoring mode every query that is searched costs $0.30 per 1,000 checks, with or without new posts** (queries Bluesky refuses are free), plus the row price for each post returned. Example: 20 queries every hour = 14,400 checks a month = $4.32. A query is read only if the run's maximum total charge can pay for a check and one post. Do not put the same query in two schedules that can run at the same time.

## `resetMonitoringState` (type: `boolean`):

Clears what was remembered for the queries (and language) in this input, so the next monitoring run treats them as a first run again. Turn it off again after one run: left on in a schedule, every run starts over and returns the newest posts again.

## Actor input object example

```json
{
  "queries": [
    "web scraping"
  ],
  "maxPostsPerQuery": 100,
  "monitoringMode": false,
  "resetMonitoringState": false
}
```

# Actor output Schema

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

One row per Bluesky post found by a query: the post URL and AT URI, text, creation and index time, languages, author handle, DID, display name and avatar, like, repost, reply, quote and bookmark counts, reply and quote links, images, video, link card, hashtags, mentions, links and labels, and whether the text contains the query words; in monitoring mode only posts not returned before. Queries Bluesky refuses, empty results and invalid input come back as free rows that say why.

# 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 = {
    "queries": [
        "web scraping"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/bluesky-posts-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 = { "queries": ["web scraping"] }

# Run the Actor and wait for it to finish
run = client.actor("neverempty/bluesky-posts-scraper").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 '{
  "queries": [
    "web scraping"
  ]
}' |
apify call neverempty/bluesky-posts-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/bluesky-posts-scraper"
        }
    }
}
```

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/FVOdNBTlakyaPAJZD/builds/0flEBlVoTP6FwZ4ck/openapi.json
