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,  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]
")
[^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, anysubstack.comrequest, thecookierequest 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_EMAILandSUBSTACK_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)
| File | Size | Uploaded | |
|---|---|---|---|
| article-0.1.4.tar.gz | 46.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|