Skip to main content

A Python wrapper around the Substack API.

Project description

Python Substack

This is an unofficial library providing a Python interface for Substack. I am in no way affiliated with Substack.

Downloads Release Build

Installation

You can install python-substack using:

$ pip install python-substack

For the MCP server tools, install the extra dependency set:

$ poetry install --with mcp

NOTE: We had to upgrade the package requirements to support Python 3.10 because 3.9 is basically vintage now. If you still run 3.9, please join us in the future (or bring snacks).


Setup

Set the following environment variables by creating a .env file:

EMAIL=
PASSWORD=
PUBLICATION_URL=  # Optional: your publication URL
COOKIES_PATH=     # Optional: path to cookies JSON file
COOKIES_STRING=   # Optional: cookie string for authentication

If you don't have a password

Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses your email address with a "magic" link.

Set a password:

  • Sign out of Substack
  • At the sign-in page, click "Sign in with password" under the Email text box
  • Then choose, "Set a new password"

The .env file will be ignored by git but always be careful.


Usage

Check out the examples folder for some examples 😃 🚀

Basic Authentication

import os
from dotenv import load_dotenv

from substack import Api
from substack.post import Post

load_dotenv()

# Authenticate with email and password
api = Api(
    email=os.getenv("EMAIL"),
    password=os.getenv("PASSWORD"),
    publication_url=os.getenv("PUBLICATION_URL"),
)

Cookie-based Authentication

You can also authenticate using cookies instead of email/password:

import os
from dotenv import load_dotenv

from substack import Api

load_dotenv()

# Authenticate with cookies (alternative to email/password)
api = Api(
    cookies_path=os.getenv("COOKIES_PATH"),  # Path to cookies JSON file
    # OR
    cookies_string=os.getenv("COOKIES_STRING"),  # Cookie string
    publication_url=os.getenv("PUBLICATION_URL"),
)

Creating and Publishing Posts

user_id = api.get_user_id()

# Switch Publications - The library defaults to your user's primary publication. You can retrieve all your publications and change which one you want to use.

# primary publication
user_publication = api.get_user_primary_publication()
# all publications
user_publications = api.get_user_publications()

# This step is only necessary if you are not using your primary publication
# api.change_publication(user_publication)

# Create a post with basic settings
post = Post(
    title="How to publish a Substack post using the Python API",
    subtitle="This post was published using the Python API",
    user_id=user_id
)

# Create a post with audience and comment permissions
post = Post(
    title="My Post Title",
    subtitle="My Post Subtitle",
    user_id=user_id,
    audience="everyone",  # Options: "everyone", "only_paid", "founding", "only_free"
    write_comment_permissions="everyone"  # Options: "none", "only_paid", "everyone"
)

post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})

# bolden text
post.add({'type': "paragraph",
          'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
                      {'content': "a word."}]})

# add hyperlink to text
post.add({'type': 'paragraph', 'content': [
    {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})

# set paywall boundary
post.add({'type': 'paywall'})

# add image
post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})

# add local image
image = api.get_image('image.png')
post.add({"type": "captionedImage", "src": image.get("url")})

# embed publication
embedded = api.publication_embed("https://jackio.substack.com/")
post.add({"type": "embeddedPublication", "url": embedded})

# create post from Markdown
markdown_content = """
# My Heading

This is a paragraph with **bold** and *italic* text.

![Image Alt](https://example.com/image.jpg)
"""
post.from_markdown(markdown_content, api=api)

draft = api.post_draft(post.get_draft())

# set section (can only be done after first posting the draft)
# post.set_section("rick rolling", api.get_sections())
# api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)

api.prepublish_draft(draft.get("id"))

api.publish_draft(draft.get("id"))

Loading Posts from YAML Files

You can define your posts in YAML files for easier management:

import yaml
import os
from dotenv import load_dotenv

from substack import Api
from substack.post import Post

load_dotenv()

# Load post data from YAML file
with open("draft.yaml", "r") as fp:
    post_data = yaml.safe_load(fp)

# Authenticate (using cookies or email/password)
cookies_path = os.getenv("COOKIES_PATH")
cookies_string = os.getenv("COOKIES_STRING")

api = Api(
    email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
    password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
    cookies_path=cookies_path,
    cookies_string=cookies_string,
    publication_url=os.getenv("PUBLICATION_URL"),
)

user_id = api.get_user_id()

# Create post from YAML data
post = Post(
    post_data.get("title"),
    post_data.get("subtitle", ""),
    user_id,
    audience=post_data.get("audience", "everyone"),
    write_comment_permissions=post_data.get("write_comment_permissions", "everyone"),
)

# Add body content from YAML
body = post_data.get("body", {})
for _, item in body.items():
    # Handle local images - upload them first
    if item.get("type") == "captionedImage" and not item.get("src").startswith("http"):
        image = api.get_image(item.get("src"))
        item.update({"src": image.get("url")})
    post.add(item)

draft = api.post_draft(post.get_draft())
put_draft_kwargs = {
    "draft_section_id": post.draft_section_id,
    "search_engine_title": post_data.get("search_engine_title"),
    "search_engine_description": post_data.get("search_engine_description"),
    "slug": post_data.get("slug"),
}
put_draft_kwargs = {k: v for k, v in put_draft_kwargs.items() if v is not None}
api.put_draft(draft.get("id"), **put_draft_kwargs)

# Publish the draft
api.prepublish_draft(draft.get("id"))
api.publish_draft(draft.get("id"))

Example YAML structure:

title: "My Post Title"
subtitle: "My Post Subtitle"
audience: "everyone"  # everyone, only_paid, founding, only_free
write_comment_permissions: "everyone"  # none, only_paid, everyone
section: "my-section"
body:
  0:
    type: "heading"
    level: 1
    content: "Introduction"
  1:
    type: "paragraph"
    content: "This is a paragraph."
  2:
    type: "captionedImage"
    src: "local_image.jpg"  # Local images will be uploaded automatically

MCP FastMCP server

This package now includes a FastMCP server in substack/mcp_fastmcp.py with the following tools:

  • post_draft_from_markdown(...): create draft from markdown, optional tag/add/prepublish/publish, and control send/share_automatically.
  • put_draft(draft_id, update_payload): update draft fields.
  • add_tags(draft_id, tags): add tags to a draft/post.
  • prepublish_draft(draft_id): prepublish a draft.
  • publish_draft(draft_id, send=True, share_automatically=False): publish a draft.

Use via stdio transport:

python -c "from substack.mcp_fastmcp import main; main()"

Contributing

Install pre-commit:

pip install pre-commit

Set up pre-commit

pre-commit install

Cookie Help

To get a cookie string, after login, go to dev tools (F12), network tab, refresh and find one of the requests like subscription/unred/subscriptions, right click and copy as fetch (Node.js), paste somewhere and get the entire cookie string assigned to the cookie header and put it in the env variables as COOKIES_STRING, et voila!

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_substack-0.1.22.tar.gz (17.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_substack-0.1.22-py3-none-any.whl (17.0 kB view details)

Uploaded Python 3

File details

Details for the file python_substack-0.1.22.tar.gz.

File metadata

  • Download URL: python_substack-0.1.22.tar.gz
  • Upload date:
  • Size: 17.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.3.4 CPython/3.11.0 Linux/6.17.0-1010-azure

File hashes

Hashes for python_substack-0.1.22.tar.gz
Algorithm Hash digest
SHA256 28d56c151f5a9938e7f9db834a525cfdd2ba74cdf012398952ddc15c1249dae4
MD5 1cc3a4fe3637be53a627a67bb05b28b8
BLAKE2b-256 423f57c9ef0bda44a7cb8f46f24ff3fb8f68b6c21f3a3e9de7a03e72348b021f

See more details on using hashes here.

File details

Details for the file python_substack-0.1.22-py3-none-any.whl.

File metadata

  • Download URL: python_substack-0.1.22-py3-none-any.whl
  • Upload date:
  • Size: 17.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.3.4 CPython/3.11.0 Linux/6.17.0-1010-azure

File hashes

Hashes for python_substack-0.1.22-py3-none-any.whl
Algorithm Hash digest
SHA256 f827add94c4f015e9907bb5dcaefe1d54a03ea599011815d00faa9d26ac6d68a
MD5 491aacf5dc56ca8c54785fe7c7cc2648
BLAKE2b-256 cf85d0e7e5dcd63b6736df6cf5cfb3ed7fc6d7a7617e86b9232e341799af20b4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page