# Substack Recommendations (`maximedupre/substack-recommendations`) Actor

Map public Substack recommendations from publication URLs. Get directed relationships with publication names, URLs, public context, and source pages in a dataset. Optionally assess recommended newsletters with public signals.

- **URL**: https://apify.com/maximedupre/substack-recommendations.md
- **Developed by:** [Maxime Dupré](https://apify.com/maximedupre) (community)
- **Categories:** Social media, Marketing, News
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.01 / 1,000 recommendations

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

### 🕸️ Map Substack newsletter recommendations

Newsletter researchers, media buyers, partnership teams, and creator agencies can use this Actor to map public Substack recommendations from the publication URLs they provide. It returns one directed relationship per saved recommendation with publication names, URLs, public context, source-page provenance, and collection time. This gives you a useful starting point for comparing newsletter networks and reviewing adjacent publications.

Request a separate public qualification pass when you need topics, headline patterns, posting timing, paywall posture, audience signals, sponsor-fit signals, evidence quality, and a recommended next action.

**Use cases**

- Compare public links around leading newsletters with **[Most Popular Substack Newsletters](https://apify.com/maximedupre/substack-recommendations/examples/most-popular-substack-newsletters)**.
- Review public recommendation pages through **[Substack Examples](https://apify.com/maximedupre/substack-recommendations/examples/substack-examples)** before you map a network.
- Build a seed list with **[Find Substack Newsletters](https://apify.com/maximedupre/substack-recommendations/examples/find-substack-newsletters)** for a newsletter research project.
- Follow adjacent publications with **[Substack Newsletter Recommendations](https://apify.com/maximedupre/substack-recommendations/examples/substack-newsletter-recommendations)** from a known seed.
- Map public newsletter links with **[Substack Recommendations](https://apify.com/maximedupre/substack-recommendations/examples/substack-recommendations)** from submitted pages.

#### 🧾 See relationship rows and public newsletter signals

Each dataset row represents one public recommendation from a source publication to a recommended publication. Rows can include names, URLs, author and description context, public recommendation text, source-page provenance, collection time, and the discovery hop.

When `qualifyTargets` is enabled, a row can also include public topics, headline patterns, posting timing, paywall posture, audience signals, sponsor-fit signals, evidence quality, missing data, and a recommended next action. Optional public fields stay absent when the source does not show them.

#### 🚦 Follow public recommendation paths at a chosen depth

1. Add one or more public publication homepages or direct `/recommendations` URLs.
2. Set `maxHops` to `0` for the submitted pages only, or to `1`, `2`, or `3` for bounded breadth-first follow-on levels.
3. Leave `maxItems` empty to return all available results until the source is exhausted within the selected depth. Set it when you want to stop after a chosen number of saved relationships.
4. Set `qualifyTargets` to `true` when you also want a separate public assessment for recommendation targets.
5. Start the run and review the relationships in the Apify dataset.

The Actor reads reachable public pages without a Substack account or API key. It does not collect private, paid, subscriber-only, or inbound recommendation data. It also does not discover publications by keyword, so your submitted URLs define the starting set.

#### ⚙️ Input

Use public Substack publication homepages, direct recommendation pages, or Substack-powered custom publication domains.

**Input fields**

| Field | Type | What it does |
|---|---|---|
| `startUrls` | array of objects | The public publication pages used as mapping seeds. At least one is required. |
| `startUrls[].url` | string | A public publication homepage or direct recommendation-page URL. |
| `maxHops` | integer | The number of recommendation levels to follow after the submitted pages. Use `0` for the submitted pages only. Values from `0` to `3` are allowed. |
| `maxItems` | integer | An optional stop for saved relationships. Leave it empty to return all available results until the source is exhausted within the selected depth. This field has no schema upper bound. |
| `qualifyTargets` | boolean | Adds a separate public qualification pass for each recommendation target when set to `true`. It can return public topics, headline patterns, cadence, audience signals, paywall posture, and sponsor-fit signals. |

**Default input**

This is the complete public input from a successful current-beta default-input run:

```json
{
  "startUrls": [
    {
      "url": "https://downtime.substack.com"
    }
  ],
  "maxHops": 0,
  "maxItems": 10,
  "qualifyTargets": false
}
```

#### 🧾 Output

The dataset contains relationship rows. A qualified run uses the same relationship fields and adds a `qualification` object when a public assessment is requested and returned.

**Relationship rows**

| Field | Type | What it does |
|---|---|---|
| `relationshipId` | string | A stable ID for the directed recommendation relationship. |
| `sourcePublication` | object | Groups the public publication that made the recommendation. |
| `sourcePublication.name` | string | The public name of the source publication. |
| `sourcePublication.url` | string | The public URL of the source publication. |
| `recommendedPublication` | object | Groups the public publication named by the recommendation. |
| `recommendedPublication.name` | string | The public name of the recommended publication. |
| `recommendedPublication.url` | string | The public URL of the recommended publication. |
| `recommendedPublication.author` | string | The public author name, when shown. |
| `recommendedPublication.description` | string | The public description of the recommended publication, when shown. |
| `recommendation` | object | Groups the public recommendation text and attribution, when shown. |
| `recommendation.blurb` | string | The public text that describes the recommendation. |
| `recommendation.attribution` | string | The public name or text that attributes the recommendation. |
| `provenance` | object | Groups the public recommendation page and collection time. |
| `provenance.recommendationPageUrl` | string | The public page where this recommendation was found. |
| `provenance.collectedAt` | string | The time when this relationship was collected. |
| `discovery` | object | Groups the submitted seed and follow-on depth that led to the relationship. |
| `discovery.seedUrl` | string | The submitted public URL that led to this relationship. |
| `discovery.hop` | integer | The number of follow-on levels from the submitted seed. |

**Example relationship row**

This complete row is from a successful current-beta run:

```json
{
  "relationshipId": "c2dca93a4ca8ce69ddd64065",
  "sourcePublication": {
    "name": "The Common Reader",
    "url": "https://www.commonreader.co.uk/"
  },
  "recommendedPublication": {
    "name": "Life and Letters",
    "url": "https://lifeandletters.substack.com/",
    "author": "Julianne Werlin",
    "description": "Reflections on Literature in Demographic History"
  },
  "recommendation": {
    "blurb": "One of the best literary Substacks. Erudite humanism informed by social science. I am a fan.",
    "attribution": "Henry Oliver"
  },
  "provenance": {
    "recommendationPageUrl": "https://www.commonreader.co.uk/recommendations",
    "collectedAt": "2026-08-22T14:21:30.558Z"
  },
  "discovery": {
    "seedUrl": "https://www.henrikkarlsson.xyz/",
    "hop": 2
  }
}
```

**Qualified relationship rows**

| Field | Type | What it does |
|---|---|---|
| `relationshipId` | string | A stable ID for the directed recommendation relationship. |
| `sourcePublication` | object | Groups the public publication that made the recommendation. |
| `sourcePublication.name` | string | The public name of the source publication. |
| `sourcePublication.url` | string | The public URL of the source publication. |
| `recommendedPublication` | object | Groups the public publication named by the recommendation. |
| `recommendedPublication.name` | string | The public name of the recommended publication. |
| `recommendedPublication.url` | string | The public URL of the recommended publication. |
| `recommendedPublication.author` | string | The public author name, when shown. |
| `recommendedPublication.description` | string | The public description of the recommended publication, when shown. |
| `recommendation` | object | Groups the public recommendation text and attribution, when shown. |
| `recommendation.blurb` | string | The public text that describes the recommendation. |
| `recommendation.attribution` | string | The public name or text that attributes the recommendation. |
| `provenance` | object | Groups the public recommendation page and collection time. |
| `provenance.recommendationPageUrl` | string | The public page where this recommendation was found. |
| `provenance.collectedAt` | string | The time when this relationship was collected. |
| `discovery` | object | Groups the submitted seed and follow-on depth that led to the relationship. |
| `discovery.seedUrl` | string | The submitted public URL that led to this relationship. |
| `discovery.hop` | integer | The number of follow-on levels from the submitted seed. |
| `qualification` | object | Groups public signals about the recommended publication when qualification is requested. |
| `qualification.topics` | array of strings | Public topics covered by the publication. |
| `qualification.headlinePatterns` | array of strings | Patterns seen in recent public post headlines. |
| `qualification.audienceLane` | string | The public audience group the publication appears to serve. |
| `qualification.editorialPromise` | string | The main promise the publication makes to readers. |
| `qualification.recentPublicPosts` | object | Groups coverage and timing for recent public posts. |
| `qualification.recentPublicPosts.postCount` | integer | The number of public posts reviewed. |
| `qualification.recentPublicPosts.coverageWindowDays` | integer | The number of days covered by the public post review. |
| `qualification.recentPublicPosts.estimatedCadence` | string | The estimated public posting cadence. |
| `qualification.recentPublicPosts.lastPublicPostAt` | string | The time of the latest public post reviewed. |
| `qualification.paywallPosture` | string | How much of the recent publication content is public. |
| `qualification.audienceSizeSignal` | string | The public audience size signal, when shown. |
| `qualification.sponsorFit` | object | Groups public signals about sponsor fit and available inventory. |
| `qualification.sponsorFit.fitSignals` | array of strings | Public facts that support or limit sponsor fit. |
| `qualification.sponsorFit.inventorySignals` | array of strings | Public signs of sponsor or advertising inventory. |
| `qualification.sponsorFit.sponsorLanguage` | array of strings | Public words or phrases about sponsors or advertising. |
| `qualification.sponsorFit.sponsorNames` | array of strings | Public sponsor or advertiser names that were found. |
| `qualification.sponsorFit.fitCategories` | array of strings | Categories that match the publication audience or topic. |
| `qualification.sponsorFit.commercialReadiness` | string | The public evidence level for commercial readiness. |
| `qualification.evidenceQuality` | string | The quality of the public evidence used for the assessment. |
| `qualification.confidence` | number | Confidence in the public qualification assessment, from `0` to `1`. |
| `qualification.disqualifiers` | array of strings | Public facts that may rule out a sponsor fit. |
| `qualification.missingData` | array of strings | Public data that was not available for the assessment. |
| `qualification.nextAction` | string | The next step suggested by the public evidence. |

**Example qualified relationship row**

This complete qualified row is from a successful current-beta run:

```json
{
  "relationshipId": "d8e96d536a41cb961e708633",
  "sourcePublication": {
    "name": "On Substack",
    "url": "https://on.substack.com/"
  },
  "recommendedPublication": {
    "name": "The Substack Post",
    "url": "https://post.substack.com/",
    "author": "Substack",
    "description": "A guide to the creators and stories shaping culture"
  },
  "provenance": {
    "recommendationPageUrl": "https://on.substack.com/recommendations",
    "collectedAt": "2026-08-22T14:02:22.810Z"
  },
  "discovery": {
    "seedUrl": "https://on.substack.com/",
    "hop": 0
  },
  "qualification": {
    "topics": [
      "The Weekender",
      "Unstacked",
      "Someone on Film"
    ],
    "headlinePatterns": [
      "question-led headlines",
      "headlines with a descriptive qualifier",
      "product and technology topic framing"
    ],
    "audienceLane": "A guide to the creators and stories shaping culture",
    "editorialPromise": "A guide to the creators and stories shaping culture",
    "recentPublicPosts": {
      "postCount": 12,
      "coverageWindowDays": 49,
      "estimatedCadence": "about weekly",
      "lastPublicPostAt": "2026-08-22T13:03:05.947Z"
    },
    "paywallPosture": "mostly_public",
    "sponsorFit": {
      "fitSignals": [
        "A guide to the creators and stories shaping culture",
        "Recent public archive tag: The Weekender.",
        "Recent public archive tag: Unstacked."
      ],
      "inventorySignals": [],
      "sponsorLanguage": [
        "partner",
        "advertising"
      ],
      "sponsorNames": [],
      "fitCategories": [
        "The Weekender",
        "Unstacked",
        "Someone on Film"
      ],
      "commercialReadiness": "promising"
    },
    "evidenceQuality": "high",
    "confidence": 0.89,
    "disqualifiers": [],
    "missingData": [
      "No public audience size signal was shown.",
      "No public sponsorship rate card or inventory terms were observed in the sampled pages."
    ],
    "nextAction": "Review the missing public data before outreach."
  }
}
```

#### 💳 Pricing

Pricing is pay per event. `Recommendation` costs $0.00001 for each saved public recommendation relationship. When `qualifyTargets` is enabled, `Newsletter assessment` costs $0.009 for each public assessment saved for a requested recommendation target. The total depends on the number of saved events.

#### 🔌 Integrations

The output is an Apify dataset. Use the dataset view or its API link to review the rows, export them, or pass them to your workflow.

**Video guide**

https://www.youtube.com/watch?v=bNACk1\_S\_6w\&list=PLObrtcm1Kw6MUrlLNDbK9QRg8VDJg0gOW\&index=4

#### ❓ FAQ

##### What happens when a public page has no visible recommendations?

The dataset has no relationship row for that page. The Actor does not invent a target or fill missing public fields.

##### Can I use a custom publication domain?

Yes. Use a Substack-powered custom publication homepage or its recommendation page as a seed.

##### Does the Actor read private or subscriber-only recommendations?

No. It uses publicly visible recommendation pages and cards. Private, paid, subscriber-only, and inbound recommendation data are outside the scope of this Actor.

##### Can I follow recommendations beyond the seed pages?

Yes. Set `maxHops` to `1`, `2`, or `3` for bounded breadth-first follow-on levels. Use `0` for seed pages only.

##### What does leaving `maxItems` empty do?

The Actor returns all available results until the source is exhausted within the selected depth. Set `maxItems` when you want to stop after a chosen number of saved relationships.

##### Why is a field missing from a relationship row?

Author, description, recommendation text, and attribution appear only when the public source displays them. Qualification fields appear when you request qualification and the public evidence supports them.

##### How can I compare two runs?

Use `relationshipId` to compare rows across snapshots. `provenance` and `discovery` also show the source page, collection time, submitted seed, and follow-on hop.

##### Can I discover newsletters by keyword?

No. Submit a list of public publication URLs. This Actor maps recommendations from those seeds, but it is not a keyword discovery tool.

##### Do I need a Substack account or API key?

No. The Actor reads reachable public pages without a Substack account or API key.

### 📝 Changelog

**0.0: Initial release**

### 🆘 Support

For issues, questions, or feature requests, [file a ticket](https://console.apify.com/actors/maximedupre~substack-recommendations/issues) and I'll fix or implement it in less than 24h 🫡

### 🔗 Related Actors

- **[Substack Recommendations Network Scraper](https://apify.com/automation-lab/substack-recommendations-network-scraper)**: compare a separate public recommendation graph for newsletter research.
- **[Substack Newsletter Sponsor Lead Analyzer](https://apify.com/cbmcl/newsletter-sponsor-positioning-brief)**: qualify recommended newsletters for public sponsor-fit and commercial signals.
- **[Substack Newsletter Scraper](https://apify.com/straightforward_hydra/substack-newsletter-scraper)**: add publication and recommendation graph context to broader newsletter research.
- **[Substack Scraper: Posts, Comments & Authors](https://apify.com/doggo/substack-scraper-posts-comments-authors)**: collect post, comment, and author context around a recommendation network.
- **[Substack Leaderboard Scraper 📊](https://apify.com/easyapi/substack-leaderboard-scraper)**: find ranked publications that can supply starting URLs for network mapping.

**Made with ❤️ by Maxime Dupré**

# Actor input Schema

## `startUrls` (type: `array`):

Add one or more public Substack publication homepages or direct recommendation-page URLs. Substack-powered custom domains are supported.

## `maxHops` (type: `integer`):

Choose how many recommendation levels to follow after the submitted pages. Use 0 for the submitted pages only. Values from 0 to 3 are allowed.

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

Optional stop for the number of relationships to save. Leave this empty to return all available results until the source is exhausted within the selected depth.

## `qualifyTargets` (type: `boolean`):

Turn this on to run a separate public qualification pass for each recommendation target. It returns public topics, headline patterns, cadence, audience signals, paywall posture, and sponsor-fit signals, and uses a separate charge.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://downtime.substack.com"
    }
  ],
  "maxHops": 0,
  "maxItems": 10,
  "qualifyTargets": false
}
```

# Actor output Schema

## `dataset` (type: `string`):

Public recommendation relationships collected from submitted pages, with public newsletter qualification signals when requested.

# 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 = {
    "startUrls": [
        {
            "url": "https://downtime.substack.com"
        }
    ],
    "maxItems": 10
};

// Run the Actor and wait for it to finish
const run = await client.actor("maximedupre/substack-recommendations").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 = {
    "startUrls": [{ "url": "https://downtime.substack.com" }],
    "maxItems": 10,
}

# Run the Actor and wait for it to finish
run = client.actor("maximedupre/substack-recommendations").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 '{
  "startUrls": [
    {
      "url": "https://downtime.substack.com"
    }
  ],
  "maxItems": 10
}' |
apify call maximedupre/substack-recommendations --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,maximedupre/substack-recommendations"
        }
    }
}

```

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/dD4INDi54XNT16d7D/builds/4YpgpftjsY4C5Ph6a/openapi.json
