Skip to main content

article

Tools to semi-automate the publication of articles.

Write an article once as a JSON file. Publish it to a primary platform (Substack), then syndicate it to secondary platforms (Medium, Dev.to, Hashnode) — each pointing its canonical URL back to the primary, so you get cross-platform reach without an SEO duplicate-content penalty.

pip install 'article[substack]'
article draft-substack essay.md            # an UNPUBLISHED Substack draft

draft-substack turns a Markdown file into a Substack draft: [^n] footnotes, ![alt](src "caption") captions, $...$ LaTeX and local images (uploaded to Substack) all survive. It never publishes: you review the draft in the Substack editor and press Publish there. Add --dry-run to check and convert without credentials or network.

Markdown to a Substack draft

The essay is a plain .md file. The title is a title: in front matter, or else the # Heading (removed from the body, since Substack shows the title itself) when there is exactly one, or when the first of several opens the file; the slug is derived from the title unless front matter sets slug:. Front matter can also set subtitle, tags and description:

---
subtitle: Why everyone agrees and nobody agrees
tags: [agile, ai]
---
# The Agile Religion

A claim.[^1]

![The tower of Babel](img/babel.jpg "Bruegel, The Tower of Babel (1563)")

[^1]: The footnote text.

Image paths resolve against the Markdown file's directory. Before anything is uploaded, the command refuses (listing every case, with the line) whatever Substack would silently lose:

Construct Why What to do
A table Substack has no table node --tables code keeps it as a monospace code block; or render it to an image (in Python, pass tables= a function returning the image Markdown)
An image in a paragraph with text the converter keeps only the alt text give the image its own paragraph; caption goes in the title slot
A missing local image nothing to upload fix the path
A raw HTML block the converter drops it rewrite it in Markdown
A data: image cannot be uploaded save it as a file
A footnote that is never referenced, a [^x] with no definition, or a reference inside a footnote dropped, left as text, or misnumbered fix the reference
article draft-substack essay.md --dry-run          # what survives, no credentials needed
article draft-substack essay.md                    # create the draft; prints its edit link
article draft-substack essay.md --tables code      # keep tables as code blocks

The draft's future public URL (<publication>/p/<slug>) is recorded as the canonical URL, so syndicate-secondary can point the other platforms at it.

Substack credentials

Substack has no official write API; this uses the unofficial python-substack client, which talks to undocumented endpoints that can change. Put one of these in .env (never commit it):

  • SUBSTACK_COOKIES: the cookie header from a logged-in browser session (developer tools, Network tab, any substack.com request, the cookie request header). The most reliable route, since sign-in by email link or captcha cannot be scripted.
  • SUBSTACK_COOKIES_PATH: a JSON file of cookies ({"substack.sid": "...", ...}).
  • SUBSTACK_EMAIL and SUBSTACK_PASSWORD, for accounts with a password.

Set SUBSTACK_PUBLICATION_URL (e.g. https://you.substack.com) if your account has more than one publication. The live smoke test (tests/test_substack_live.py) runs only when these are set; it creates one draft and deletes it.

The single source of truth

The Substack public URL captured in phase 1 is the SSOT. It is stored (keyed by article slug) and injected as canonical_url into every secondary platform in phase 2, so each emits <link rel="canonical" href="…"> pointing back at the original.

author.json ──► publish-primary ──► Substack ──► live URL ─┐
                                                           │ stored as canonical_url
                                                           ▼  (pipeline_state.json, by slug)
                          syndicate-secondary ──► Medium / Dev.to / Hashnode
                                                  each canonical → Substack URL

The two phases are deliberately separate commands so you can syndicate later (e.g. +3 days) on your own schedule. Phase 1 creates a Substack draft; publish it in the Substack editor before syndicating.

Two-command flow

# 1. Author once, then publish to the primary (records the canonical URL):
python -m article publish-primary article.json

# 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
python -m article syndicate-secondary article.json

# Subset / machine-readable output:
python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out

After pip install, the article console script is equivalent (article publish-primary article.json).

The article JSON

Instead of a Markdown file, an article can be one JSON file. One standardized file is the single source of truth for content and which platforms to publish to — a platform is published only if it appears under platforms (see examples/article.example.json):

{
  "title": "Designing an SSOT Publishing Pipeline",
  "slug": "ssot-publishing-pipeline",
  "content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
  "tags": ["python", "automation", "seo"],
  "platforms": {
    "substack": { "tables": "error" },
    "medium":   { "publish_as_draft": true },
    "dev_to":   { "publish_as_draft": true },
    "hashnode": { "publish_as_draft": true }
  }
}

Configuration

Secrets and settings load from .env (see .env.example) — copy it and fill in what you use:

cp .env.example .env

Every platform is optional; omit a platform's secrets (or its platforms entry) to skip it.

Using it from Python

The CLI is a thin wrapper over the same functions, so everything is callable programmatically:

import asyncio
from article import load_article, publish_primary, syndicate_secondary

art = load_article("article.json")
asyncio.run(publish_primary(art))  # phase 1 → records canonical_url
asyncio.run(syndicate_secondary(art))  # phase 2 → canonical SEO everywhere

Adding a platform

Adapters are registered in an open-closed registry. To add a platform, write one async function satisfying the PublishAdapter protocol and register it — no core changes:

from article import register_adapter, PublishResult
from article.util import require_canonical_url


@register_adapter("my_platform")
async def publish(article, *, canonical_url, config, secrets):
    require_canonical_url("my_platform", canonical_url)  # SSOT is never optional
    # … call the platform's API, injecting canonical_url …
    return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)

Architecture

Layer Module Responsibility
Schema + config article/config.py Pydantic Article model, .env Settings, load_article (friendly errors)
Vocabulary article/base.py PublishResult, RunSummary, platform names, exceptions
State store article/state.py MutableMapping facade over JSON, keyed by slug (the SSOT)
Registry article/registry.py PublishAdapter protocol + open-closed registry
Engine article/engine.py Orchestrates phase 1 → phase 2; canonical handoff; graceful errors
Adapters article/adapters/* One module per platform (config + secrets injected)
CLI article/__main__.py cw dispatch of the two commands

Status, per platform:

  • Substack: real. Creates unpublished drafts through python-substack; never publishes.
  • Medium: honest manual step. Its API is closed to new integrations, so the adapter sends nothing and reports a [todo] result: open Medium's Import a story page (https://medium.com/p/import) and paste the published Substack URL, which sets the canonical link.
  • Dev.to, Hashnode: still stubs; they build the payload (with the canonical field) but make no network call.

Metadata

Release files for article 0.1.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for article 0.1.4
File Size Uploaded
article-0.1.4.tar.gz 46.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for article 0.1.4
File Interpreter ABI Platform
article-0.1.4-py3-none-any.whl Python 3 none any Details

Total release size: 90.6 kB

Release files / article-0.1.4.tar.gz

Download URL article-0.1.4.tar.gz
Size 46.8 kB
Tags Source
SHA-256 checksum
How to use checksums
b6dd0faf4a73854afe228b8a64f96df0543b1e7c6eee91015abf52b6ce55faf6
BLAKE2b-256 checksum
How to use checksums
24988abfc222966545d39925fcf53e566369c56159f961a5bd8332dfa7818416
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / article-0.1.4-py3-none-any.whl

Download URL article-0.1.4-py3-none-any.whl
Size 43.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
43b7c6efdc96b604aa26e0a7f8decdb7f756d5401abf7ddc361b0203ab7f641e
BLAKE2b-256 checksum
How to use checksums
3db398329911dbeec6e48ef674d17ec47ddc485171f2e5c495f006529888bbab
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.4 This release

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

1 release file

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page