Ghost Blog Scraper: Posts, Tags & Authors avatar

Ghost Blog Scraper: Posts, Tags & Authors

Pricing

from $0.56 / 1,000 post scrapeds

Go to Apify Store
Ghost Blog Scraper: Posts, Tags & Authors

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

Arman Hossain

Maintained by Community

Actor stats

0

Bookmarked

1

Total users

0

Monthly active users

20 days ago

Last modified

Share

Ghost Blog Scraper: Posts from any Ghost-powered blog, full text, tags, authors, feature image and reading time

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 fieldMeaning
siteThe host you asked for, so multi-site runs group cleanly
postIdGhost's own post ID, a 24-character ObjectID, stable across edits
titlePost title, emoji and all
slugURL slug
excerptThe summary Ghost puts in the feed, as plain text
contentPlainFull post body as clean plain text, markup stripped, entities decoded
featureImageFeature image URL, from media:content or the first image in the body
tagsEvery tag on the post
authorsEvery credited author
publishedAtPublish time, normalised to ISO 8601 UTC
updatedAtLast edit time, Content API only, null over RSS
readingTimeMinutes to read, Ghost's own figure over the Content API, computed at 275 words/minute over RSS
urlCanonical post URL
scrapedAtRun 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

FieldTypeDefaultNotes
sitesarray-Required. Domains, site URLs or /rss/ URLs. Sub-path publications such as https://ghost.org/resources work.
contentApiKeystring""Optional. Switches to the Content API for the full archive plus updatedAt and Ghost's own readingTime. Keys are per-site.
tagsarray[]Substring match, case-insensitive, against tag names and slugs. Empty = all posts.
maxPostsPerSiteinteger0Cap per site, applied after tag filtering. 0 = no limit.
includeContentbooleantrueInclude 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 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.