# Ghost Blog Scraper: Posts, Tags & Authors (`arman-bd/ghost-blog-scraper`) Actor

Scrape Ghost-powered blogs: post titles, excerpts, full content, tags, authors, reading time and feature images. Works via public RSS or the Content API with a key.

- **URL**: https://apify.com/arman-bd/ghost-blog-scraper.md
- **Developed by:** [Arman Hossain](https://apify.com/arman-bd) (community)
- **Categories:** Social media, Marketing, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.56 / 1,000 post scrapeds

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

## Ghost Blog Scraper: Posts, Tags & Authors

![Ghost Blog Scraper: Posts from any Ghost-powered blog, full text, tags, authors, feature image and reading time](https://api.apify.com/v2/key-value-stores/ZQOcNAOHrIgTacAmy/records/ghost-blog-scraper.jpg)

**Ghost Blog Scraper** pulls posts from any **Ghost**-powered publication, title, excerpt, full body as clean plain text, tags, authors, feature image, publish date and reading time, with **no credentials required**.

Ghost powers a large slice of independent media, engineering blogs and paid newsletters, and every Ghost site ships a public RSS feed as standard. This Actor reads that feed directly: **no browser, no proxies, no login.** Add a Content API key and it switches to Ghost's own JSON API to reach the site's full archive instead of just the recent posts.

**Agent skill: [SKILL.md](https://api.apify.com/v2/key-value-stores/t7YoTxpZEJOWvw4Ug/records/ghost-blog-scraper.md)**

```
https://api.apify.com/v2/key-value-stores/t7YoTxpZEJOWvw4Ug/records/ghost-blog-scraper.md
```

### What you get

| Output field | Meaning |
|---|---|
| `site` | The host you asked for, so multi-site runs group cleanly |
| `postId` | Ghost's own post ID, a 24-character ObjectID, stable across edits |
| `title` | Post title, emoji and all |
| `slug` | URL slug |
| `excerpt` | The summary Ghost puts in the feed, as plain text |
| `contentPlain` | Full post body as clean plain text, markup stripped, entities decoded |
| `featureImage` | Feature image URL, from `media:content` or the first image in the body |
| `tags` | Every tag on the post |
| `authors` | Every credited author |
| `publishedAt` | Publish time, normalised to ISO 8601 UTC |
| `updatedAt` | Last edit time, **Content API only**, `null` over RSS |
| `readingTime` | Minutes to read, Ghost's own figure over the Content API, computed at 275 words/minute over RSS |
| `url` | Canonical post URL |
| `scrapedAt` | Run timestamp |

A `RUN_SUMMARY` record in the key-value store holds per-site counts, which path was used, the filters applied, and any site that failed.

### Common use cases

- **Track independent publishers.** A shortlist of blogs, titles and dates only, cheap enough to run hourly.

```json
{
"sites": ["blog.codinghorror.com", "blog.duolingo.com", "blog.ghost.org"],
"includeContent": false
}
```

- **Archive a Ghost publication.** Full text of everything, using a Content API key to reach past the feed window.

```json
{
"sites": ["demo.ghost.io"],
"contentApiKey": "22444f78447824223cefc48062",
"includeContent": true
}
```

- **Content research across indie media.** One topic across many publications.

```json
{
"sites": ["blog.duolingo.com", "blog.codinghorror.com"],
"tags": ["engineering"],
"maxPostsPerSite": 20
}
```

### Quick start

Two blogs, everything their feeds carry:

```json
{
 "sites": ["blog.codinghorror.com", "blog.duolingo.com"]
}
```

Engineering posts only, capped, with full text:

```json
{
 "sites": ["blog.duolingo.com"],
 "tags": ["engineering"],
 "maxPostsPerSite": 10,
 "includeContent": true
}
```

Full archive of one site via its Content API key:

```json
{
 "sites": ["demo.ghost.io"],
 "contentApiKey": "22444f78447824223cefc48062"
}
```

### Input

| Field | Type | Default | Notes |
|---|---|---|---|
| `sites` | array | - | **Required.** Domains, site URLs or `/rss/` URLs. Sub-path publications such as `https://ghost.org/resources` work. |
| `contentApiKey` | string | `""` | Optional. Switches to the Content API for the full archive plus `updatedAt` and Ghost's own `readingTime`. Keys are per-site. |
| `tags` | array | `[]` | Substring match, case-insensitive, against tag names and slugs. Empty = all posts. |
| `maxPostsPerSite` | integer | `0` | Cap per site, applied **after** tag filtering. `0` = no limit. |
| `includeContent` | boolean | `true` | Include `contentPlain`. Turn off for a much smaller dataset. |

**Which combinations make sense.** `sites` and `contentApiKey` are the one pairing to be careful with: a Content API key authenticates **one** site, so if you pass a key alongside several sites, only the site the key belongs to will succeed and the rest are recorded as failures. Run one site per job when you are using a key; batch freely when you are not.

`tags` and `maxPostsPerSite` combine in that order, filter first, then cap, so `maxPostsPerSite: 10` with a tag filter gives you the ten most recent *matching* posts, not ten posts of which some match. `includeContent: false` is the right setting for scheduled change detection: you still get `postId` and `publishedAt` to diff against, at a fraction of the size.

### Output example

A real record from `blog.duolingo.com`:

```json
{
 "site": "blog.duolingo.com",
 "postId": "6a7091b92083b20001810ddd",
 "title": "Making production-ready agents the default: building Duolingo's agent platform",
 "slug": "production-ready-ai-agent-platform",
 "excerpt": "See how we made AI agents easy to build, run, and improve in production.",
 "contentPlain": "TL;DR\nAt Duolingo, teams were repeatedly rebuilding the same infrastructure around AI agents. We solved this by…",
 "featureImage": "https://storage.ghost.io/c/7a/33/7a33d0f4-927d-4fe8-a6bf-96131b5e76d4/content/images/2026/08/cover_Production-ready-agent-AI.png",
 "tags": ["Life at Duolingo", "Engineering"],
 "authors": ["Guadalupe Aliseda-Canton"],
 "publishedAt": "2026-08-04T12:00:00.000Z",
 "updatedAt": null,
 "readingTime": 7,
 "url": "https://blog.duolingo.com/production-ready-ai-agent-platform/",
 "scrapedAt": "2026-08-06T11:52:06.331Z"
}
```

`updatedAt` is `null` because this run used RSS, which carries no edit timestamp. With a Content API key the same post returns a real value.

`RUN_SUMMARY` looks like this:

```json
{
 "sitesRequested": 3,
 "sitesFailed": 1,
 "failures": [
 { "site": "www.taniarascia.com", "error": "not a Ghost site, GatsbyJS" }
 ],
 "postsSaved": 30,
 "source": "rss",
 "perSite": [
 { "site": "blog.codinghorror.com", "source": "rss", "listed": 15, "matched": 15, "saved": 15, "feedExhaustedAt": 15 },
 { "site": "blog.duolingo.com", "source": "rss", "listed": 15, "matched": 15, "saved": 15, "feedExhaustedAt": 15 }
 ],
 "filters": { "tags": [], "maxPostsPerSite": 0, "includeContent": true },
 "finishedAt": "2026-08-06T11:52:06.331Z"
}
```

### Finding a Content API key

You need admin access to the Ghost site. In Ghost Admin: **Settings → Integrations → Add custom integration**, name it anything, and copy the **Content API key**, a 26-character hex string such as `22444f78447824223cefc48062`.

Content API keys are read-only and designed to be embedded in public front-ends, so they are safe to use here. Do **not** paste the *Admin* API key, which looks like `id:secret` and grants write access; this Actor does not accept it and does not need it.

Without a key the Actor still works. It just sees whatever the public feed carries.

### API example

```bash
curl -X POST "https://api.apify.com/v2/acts/arman-bd~ghost-blog-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
 -H "Content-Type: application/json" \
 -d '{
 "sites": ["blog.codinghorror.com", "blog.duolingo.com"],
 "tags": ["engineering"],
 "maxPostsPerSite": 10
 }'
```

### JavaScript example

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

const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('arman-bd/ghost-blog-scraper').call({
 sites: ['blog.codinghorror.com', 'blog.duolingo.com'],
 includeContent: false,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const post of items) console.log(`${post.publishedAt}, ${post.site}: ${post.title}`);
```

### Limits and behaviour

- **RSS shows recent posts only.** A Ghost feed carries the most recent posts, 15 on a default install, and current Ghost versions no longer serve `/rss/2/`. The Actor still probes for older-style feed pages and uses them where they exist, then logs exactly where the feed ran out and records it as `feedExhaustedAt`. If you need the full archive, use a Content API key; that path pages through everything.
- **Non-Ghost sites are reported, not guessed at.** Every feed is checked for Ghost's `<generator>` tag. A WordPress or Gatsby feed is recorded in `RUN_SUMMARY.failures` with the generator it actually reported. Sites that hide the generator via Ghost's branding-privacy setting get one cheap follow-up check against the Content API path before being rejected.
- **The XML is parsed properly, not regex-scraped.** Ghost wraps every field in CDATA, and a post that quotes RSS markup will contain a literal `</item>` in its body. The parser in `src/rss.js` steps over CDATA sections when locating element boundaries, so a post about feeds cannot truncate itself and shift every field after it.
- **Markup is stripped before entities are decoded.** That order matters: decoding first would turn an escaped `&lt;script&gt;` written in the prose into a real tag and then delete it. A second pass handles feeds that entity-encode their markup instead of using CDATA.
- **`readingTime` over RSS is computed, not reported.** RSS has no reading-time field, so it is derived from the word count at Ghost's own 275 words per minute. Over the Content API the value comes from Ghost directly.
- **One bad site never kills the run.** A dead domain, a 404 feed or a non-Ghost platform is recorded in `RUN_SUMMARY.failures` and the run continues. The Actor only errors out if *every* site fails.
- **Transient errors are retried.** 429 and 5xx get three attempts with linear backoff. 404 is not retried.
- **Public content only.** Members-only posts are not in the public feed and are not in the Content API's public scope either. This Actor does not attempt to reach paywalled content.

### FAQ

**Do I need a proxy?** No. Proxy configuration is not required to run this Actor.

**Do I need an account on the site?** No. You supply no credentials.

**How do I know a site is Ghost?** Open `https://thesite.com/rss/` and look for `<generator>Ghost 6.x</generator>`. The Actor performs exactly this check and tells you what it found when a site is rejected.

**Why did I only get 15 posts?** That is the Ghost RSS feed window. Use a Content API key for the full archive.

**Can I use one key for several sites?** No, Ghost keys are per-site. Pass one site per run when using a key.

**Why is `updatedAt` null?** RSS carries no edit timestamp. It is populated on the Content API path.

**Can I get members-only or paid posts?** No. They are not published in the feed or the public Content API.

**What happens if a site is unavailable?** It is logged, added to `RUN_SUMMARY.failures`, and the run continues with the rest.

**Can I schedule it?** Yes, it is designed for scheduled runs. Set `includeContent: false` and diff on `postId` plus `publishedAt` for cheap change detection.

**Can I integrate it with something else?** Yes, Apify API, client libraries, webhooks, scheduled runs, dataset exports (JSON/CSV/Excel) or MCP. Output is structured JSON.

# Actor input Schema

## `sites` (type: `array`):

Site domains or URLs, e.g. 'blog.codinghorror.com' or 'https://blog.duolingo.com'. Feed URLs ending in /rss/ are accepted and reduced to the site root. Publications hosted on a sub-path such as https://ghost.org/resources work too. Each site is checked for the Ghost generator tag and reported as a failure if it is running something else.

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

A Ghost Content API key unlocks the site's full post archive instead of the ~15 most recent posts the RSS feed carries, and adds real updatedAt and readingTime values. Find it in Ghost Admin under Settings → Integrations → Add custom integration; it is a 26-character hex string and is safe to share publicly. Only usable against one site at a time, since keys are per-site.

## `tags` (type: `array`):

Keep only posts carrying at least one of these tags, matched case-insensitively against both tag names and slugs as a substring. 'engineer' matches 'Engineering'. Leave empty to keep every post.

## `maxPostsPerSite` (type: `integer`):

Cap the posts saved per site after tag filtering. Note that the public RSS feed only carries the most recent posts, so a large number here has no effect without a Content API key. Set 0 for no limit.

## `includeContent` (type: `boolean`):

Include the full post body as clean plain text. Turn this off for a much smaller, faster dataset when you only need titles, tags and dates. useful for change detection.

## Actor input object example

```json
{
  "sites": [
    "blog.codinghorror.com",
    "https://blog.ghost.org"
  ],
  "contentApiKey": "22444f78447824223cefc48062",
  "tags": [
    "engineering",
    "product"
  ],
  "maxPostsPerSite": 0,
  "includeContent": true
}
```

# Actor output Schema

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

Every record the run produced.

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

The RUN_SUMMARY record from the run's key-value store.

# 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 = {
    "sites": [
        "blog.codinghorror.com",
        "blog.duolingo.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("arman-bd/ghost-blog-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 = { "sites": [
        "blog.codinghorror.com",
        "blog.duolingo.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("arman-bd/ghost-blog-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 '{
  "sites": [
    "blog.codinghorror.com",
    "blog.duolingo.com"
  ]
}' |
apify call arman-bd/ghost-blog-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,arman-bd/ghost-blog-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/DXkTc3nIhjD2h8Eke/builds/gErKUJcDSjePkbAZM/openapi.json
