# Substack Publication Post Exporter (`vexaloom-labs/substack-publication-post-exporter`) Actor

Export posts from any Substack newsletter: title, author, publish date, body, categories and cover image, filtered by category, date or keyword. No login or proxy needed.

- **URL**: https://apify.com/vexaloom-labs/substack-publication-post-exporter.md
- **Developed by:** [vexaloom-labs](https://apify.com/vexaloom-labs) (community)
- **Categories:** Automation, News, Other
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.49 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.
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

## Substack Publication Post Exporter

Turn a Substack publication's public archive into a clean dataset, or fetch individual posts you already know. Custom-domain newsletters work directly; publications that still live on `*.substack.com` are reported as a clear `blocked_egress` status row instead of a misleading empty result. Free posts arrive with full HTML, readable text and Markdown; paid posts arrive with metadata and a clearly flagged public teaser. No account, login or proxy setup.

### What this actor does

- **Two modes:** export a publication's archive (`publicationPosts`) or fetch specific post URLs (`postUrls`).
- **Full archive depth:** walk a custom-domain publication from its newest post back through its history, 50 posts at a time.
- **Free posts in full:** HTML, plain text and clean Markdown for every free post.
- **Paid posts, honestly:** title, subtitle, author, date, cover, word count, reactions, comments count and the public teaser. The public preview body is optional and flagged with `contentIsPreview`.
- **Comments:** nested public comment threads for free posts.
- **Publication details:** name, domain, language, payment status, subscription plans and sections.
- **Filters:** date range, keyword, tags, free/paid, post type and audio/video presence.
- **RSS fallback:** non-Substack sites such as Platformer are read through their public feed (up to 20 recent items).
- **Blocked inputs are visible:** `substack.com`, `*.substack.com` and share links produce a `blocked_egress` row that explains the limitation and the workaround.
- **Empty fields are omitted:** a post without a subtitle, tag or video simply has no such key.

### Output per post

- `id`, `slug`, `title`, `subtitle`, `url`, `guid` — identity and canonical link
- `publishedAt`, `publishedAtRaw`, `updatedAt` — ISO date plus the original source value
- `author`, `authors[]` — primary byline and every byline with name, handle, bio, photo and Twitter
- `publication`, `publicationUrl`, `publicationInfo` — newsletter name/link, plus plans and sections when enabled
- `audience`, `isPaid`, `contentAccess`, `contentIsPreview`, `shouldSendFreePreview`, `freeUnlockRequired` — access state
- `bodyHtml`, `bodyText`, `bodyMarkdown` — body in the requested formats; paid bodies are previews only
- `summary`, `truncatedBodyText`, `description` — plain excerpt, source teaser and source description
- `wordCount` (words in the delivered body), `articleWordCount` (full article count from the source), `readingTimeMinutes`
- `language`, `postType` — language code and `newsletter` / `podcast` / `thread`
- `coverImage`, `audioUrl`, `podcastUrl`, `podcastDurationSec` — image and audio
- `videoUrl`, `videoThumbnailUrl`, `videoDurationSec`, `videoWidth`, `videoHeight`, `videoIsPreview` — video details
- `tags`, `tagSlugs`, `categories`, `sectionId`, `sectionName` — taxonomy; `categories` is kept for older integrations
- `reactionCount`, `reactions`, `commentCount`, `childCommentCount`, `restacks` — engagement
- `comments[]` — nested public comments when enabled, with author, date, reactions and replies
- `dataSource`, `recordType` (`post`), `scrapedAt`

### Blocked input row

When an input is hosted on `substack.com` (a bare subdomain, a `substack.com/@handle`, or a share link), the actor emits one row with `recordType: "blocked_egress"`, the original `input`, `host`, a plain-language `message` and a `workaround`. This is a limitation of this actor's cloud network, not an empty publication. Use the publication's custom domain or an RSS-capable site instead.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `mode` | string | `publicationPosts` | `publicationPosts` or `postUrls` |
| `publications` | array | `["platformer.news"]` | Custom-domain publication URLs or RSS-capable sites (mode=publicationPosts) |
| `postUrls` | array | – | Custom-domain post URLs (mode=postUrls) |
| `sortBy` | string | `newest` | `newest` or `top` for publication posts |
| `publishedAfter` | string | – | Keep posts on or after `YYYY-MM-DD` |
| `publishedBefore` | string | – | Keep posts on or before `YYYY-MM-DD` |
| `containsKeyword` | string | – | Keep posts whose title, teaser or text contains this keyword |
| `tagsAnyOf` | array | – | Keep posts with at least one matching tag |
| `audienceFilter` | string | `all` | `all`, `free` or `paid` |
| `postType` | string | `all` | `all`, `newsletter`, `podcast` or `thread` |
| `mediaFilter` | string | `any` | `any`, `audio`, `video` or `text` |
| `bodyFormat` | string | `all` | `none`, `html`, `text`, `markdown` or `all` |
| `includePaidPreview` | boolean | `true` | Include the flagged public preview of paid posts |
| `includeComments` | boolean | `false` | Include public comment threads for free posts |
| `maxCommentsPerPost` | integer | `20` | Comment cap per post, replies included; `0` means no limit |
| `includePublicationInfo` | boolean | `true` | Add plans, sections and publication metadata |
| `maxItems` | integer | `50` | Hard cap on records (1–10,000) |
| `maxPostsPerPublication` | integer | `0` | Per-publication cap; `0` means no per-publication limit |

#### Example: a custom-domain publication with full bodies

```json
{
  "mode": "publicationPosts",
  "publications": ["https://www.slowboring.com"],
  "maxItems": 25,
  "bodyFormat": "all"
}
```

#### Example: paid and free posts in a date range

```json
{
  "mode": "publicationPosts",
  "publications": ["https://www.noahpinion.blog"],
  "publishedAfter": "2026-01-01",
  "audienceFilter": "all",
  "includePaidPreview": true,
  "maxItems": 100
}
```

#### Example: specific custom-domain post URLs

```json
{
  "mode": "postUrls",
  "postUrls": [
    "https://www.noahpinion.blog/p/the-internet-breaks",
    "https://www.slowboring.com/p/destroying-womens-sports-in-order"
  ],
  "bodyFormat": "markdown",
  "includeComments": true
}
```

#### Example: RSS fallback for a site that left Substack

```json
{
  "mode": "publicationPosts",
  "publications": ["platformer.news"],
  "maxItems": 10,
  "bodyFormat": "text"
}
```

### Use cases

- **Newsletter monitoring** — follow competitor or industry publications without checking each site.
- **Research and NLP** — export free-post text or Markdown for topic models, search indexes and RAG stores.
- **Content archives** — keep a searchable copy of a public archive and its free comments.
- **Media analysis** — track themes, posting cadence, reactions and discussion across newsletters.
- **Feed consolidation** — merge several custom-domain publications, or an RSS site, into one dataset.

### FAQ

**Do I need a Substack account, cookies or a proxy?**
No. The actor reads public custom-domain pages and posts. There is no login step and no proxy setup.

**Which publications work?**
Any publication served from its own custom domain, plus non-Substack sites with a public RSS feed. Bare `*.substack.com` addresses and `substack.com` share links do not work from this actor's cloud network.

**What is a `blocked_egress` row?**
It is a status record that tells you the input is hosted on `substack.com` and cannot be read from this actor's cloud network. It includes the original input, the host, a plain-language message and a workaround. It is not an empty publication.

**Can I download paid posts in full?**
No. Paid posts are protected. The actor returns their metadata, the public teaser and, when `includePaidPreview` is on, the public preview body with `contentIsPreview: true`. It never bypasses a paywall.

**How far back does the archive go?**
As far as a custom-domain publication's public archive goes — not just the latest page. The RSS fallback returns up to 20 recent items.

**Why are comments missing on some posts?**
Public comment threads are available for free posts. Comments on paid posts are members-only, so the actor returns the comment count but not the thread.

**Can I get Markdown for AI or RAG pipelines?**
Yes. Choose `markdown` or `all` in `bodyFormat`. Free posts get clean Markdown with subscribe buttons, share widgets and image wrappers removed. Paid posts get the flagged public preview only.

**Why is a field absent from some records?**
Empty values are omitted rather than written as `null`. A post without a subtitle, tag, video or public subscriber count simply does not include that key.

**Do custom domains work the same as a `.substack.com` address?**
Use the custom domain itself, such as `https://www.slowboring.com` or `https://www.noahpinion.blog`. The `.substack.com` address is reported as `blocked_egress`.

**Is this affiliated with Substack?**
No. This is an independent third-party actor that reads publicly available newsletter data. It is not affiliated with or endorsed by Substack.

# Actor input Schema

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

What to scrape. substack.com-hosted inputs are reported as blocked_egress.

## `publications` (type: `array`):

(mode=publicationPosts) Custom-domain publication URLs or RSS-capable sites, e.g. `https://www.slowboring.com` or `https://platformer.news`. Bare subdomains and substack.com links are blocked from this actor's cloud network.

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

(mode=postUrls) Custom-domain post URLs, e.g. `https://www.noahpinion.blog/p/the-internet-breaks`. substack.com share links are reported as blocked_egress.

## `sortBy` (type: `string`):

(mode=publicationPosts) Newest first or all-time most popular first.

## `publishedAfter` (type: `string`):

Keep posts published on or after this date (`YYYY-MM-DD`).

## `publishedBefore` (type: `string`):

Keep posts published on or before this date (`YYYY-MM-DD`).

## `containsKeyword` (type: `string`):

(mode=publicationPosts) Keep posts whose title, teaser or text contains this keyword. Uses Substack's publication search when possible.

## `tagsAnyOf` (type: `array`):

Keep posts carrying at least one of these tags (case-insensitive).

## `audienceFilter` (type: `string`):

Keep all posts, only free posts, or only paid posts.

## `postType` (type: `string`):

Keep all post types or a single type.

## `mediaFilter` (type: `string`):

Keep all posts, audio posts, video posts, or text-only posts.

## `bodyFormat` (type: `string`):

Body fields to include. `none` is fastest; `all` adds HTML, plain text and Markdown.

## `includePaidPreview` (type: `boolean`):

For paid posts, include the public preview body and flag it as a preview. Turn off for faster metadata-only runs.

## `includeComments` (type: `boolean`):

Include public comment threads for free posts. Paid-post comments are members-only.

## `maxCommentsPerPost` (type: `integer`):

Cap on comments per post, including replies. Set to 0 for no limit.

## `includePublicationInfo` (type: `boolean`):

Add publication details such as pricing plans, sections, language and payment status.

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

Hard cap on emitted records.

## `maxPostsPerPublication` (type: `integer`):

Optional per-publication cap. Set to 0 for no per-publication limit.

## Actor input object example

```json
{
  "mode": "publicationPosts",
  "publications": [
    "platformer.news"
  ],
  "postUrls": [],
  "sortBy": "newest",
  "tagsAnyOf": [],
  "audienceFilter": "all",
  "postType": "all",
  "mediaFilter": "any",
  "bodyFormat": "none",
  "includePaidPreview": true,
  "includeComments": false,
  "maxCommentsPerPost": 20,
  "includePublicationInfo": true,
  "maxItems": 3,
  "maxPostsPerPublication": 3
}
```

# Actor output Schema

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

Dataset containing scraped Substack posts and author profiles.

# 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": "publicationPosts",
    "publications": [
        "platformer.news"
    ],
    "postUrls": [],
    "sortBy": "newest",
    "tagsAnyOf": [],
    "audienceFilter": "all",
    "postType": "all",
    "mediaFilter": "any",
    "bodyFormat": "none",
    "includePaidPreview": false,
    "includeComments": false,
    "maxCommentsPerPost": 20,
    "includePublicationInfo": false,
    "maxItems": 3,
    "maxPostsPerPublication": 3
};

// Run the Actor and wait for it to finish
const run = await client.actor("vexaloom-labs/substack-publication-post-exporter").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": "publicationPosts",
    "publications": ["platformer.news"],
    "postUrls": [],
    "sortBy": "newest",
    "tagsAnyOf": [],
    "audienceFilter": "all",
    "postType": "all",
    "mediaFilter": "any",
    "bodyFormat": "none",
    "includePaidPreview": False,
    "includeComments": False,
    "maxCommentsPerPost": 20,
    "includePublicationInfo": False,
    "maxItems": 3,
    "maxPostsPerPublication": 3,
}

# Run the Actor and wait for it to finish
run = client.actor("vexaloom-labs/substack-publication-post-exporter").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 '{
  "mode": "publicationPosts",
  "publications": [
    "platformer.news"
  ],
  "postUrls": [],
  "sortBy": "newest",
  "tagsAnyOf": [],
  "audienceFilter": "all",
  "postType": "all",
  "mediaFilter": "any",
  "bodyFormat": "none",
  "includePaidPreview": false,
  "includeComments": false,
  "maxCommentsPerPost": 20,
  "includePublicationInfo": false,
  "maxItems": 3,
  "maxPostsPerPublication": 3
}' |
apify call vexaloom-labs/substack-publication-post-exporter --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,vexaloom-labs/substack-publication-post-exporter"
        }
    }
}
```

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/n7xofJAWyJUILJ0N1/builds/izi31evru0SmgKMn3/openapi.json
