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; --tables image renders it to a PNG (needs pip install 'article[images]'); in Python, tables= also takes 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
article draft-substack essay.md --update           # rewrite the draft the last run created (refuses if you edited it in Substack; --overwrite-edits to replace those edits)
article syndicate-secondary essay.md               # refused until the Substack post is live (--allow-unpublished to override)

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.5

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.5
File Size Uploaded Provenance Actions
article-0.1.5.tar.gz 51.2 kB Without provenance Details for article-0.1.5.tar.gz

Built distribution (wheel)

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

Total release size: 98.5 kB

Release files / article-0.1.5.tar.gz

Download URL article-0.1.5.tar.gz
Size 51.2 kB
Tags Source
SHA-256 checksum
How to use checksums
e473594427c0039725712c5ae4dc6c05d740180fd2236e24fc4edbf3e335990c
BLAKE2b-256 checksum
How to use checksums
52cd59c08f403d67679747c4a974026cf51b3663f53cc86738a68e3befd4da19
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.3 {"installer":{"name":"uv","version":"0.13.3","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.5-py3-none-any.whl

Download URL article-0.1.5-py3-none-any.whl
Size 47.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9d58643341df2d3a58358ac965e4176c5c792e5d9d63750ccdff19e18d1616a5
BLAKE2b-256 checksum
How to use checksums
7e44d7f13ea58ace74b7e3147d7a159138986147c00fddaa7730fe871938c8dc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.3 {"installer":{"name":"uv","version":"0.13.3","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}

Security for article 0.1.5

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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