Ghost Blog Scraper: Posts, Tags & Authors
Pricing
from $0.56 / 1,000 post scrapeds
Ghost Blog Scraper: Posts, Tags & Authors
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.
Pricing
from $0.56 / 1,000 post scrapeds
Rating
0.0
(0)
Developer
Arman Hossain
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
0
Monthly active users
20 days ago
Last modified
Categories
Share

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
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.
{"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.
{"sites": ["demo.ghost.io"],"contentApiKey": "22444f78447824223cefc48062","includeContent": true}
- Content research across indie media. One topic across many publications.
{"sites": ["blog.duolingo.com", "blog.codinghorror.com"],"tags": ["engineering"],"maxPostsPerSite": 20}
Quick start
Two blogs, everything their feeds carry:
{"sites": ["blog.codinghorror.com", "blog.duolingo.com"]}
Engineering posts only, capped, with full text:
{"sites": ["blog.duolingo.com"],"tags": ["engineering"],"maxPostsPerSite": 10,"includeContent": true}
Full archive of one site via its Content API key:
{"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:
{"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:
{"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
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
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 asfeedExhaustedAt. 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 inRUN_SUMMARY.failureswith 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 insrc/rss.jssteps 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
<script>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. readingTimeover 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.failuresand 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.