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; --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, 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.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| article-0.1.6.tar.gz | 52.5 kB | Without provenance | Details for article-0.1.6.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| article-0.1.6-py3-none-any.whl | Python 3 | none | any | Details for article-0.1.6-py3-none-any.whl |
Total release size: 100.7 kB
Release files / article-0.1.6.tar.gz
| Download URL | article-0.1.6.tar.gz |
|---|---|
| Size | 52.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
228986cd7260ff659ad1feb07b97e42699e37237e38a878500b84b2c477cc004
|
|
BLAKE2b-256 checksum How to use checksums |
14307a4e62cc21a802b0cd4e875c3edb21c6ac45adc0c67568e6ed0f759353cc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","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.6-py3-none-any.whl
| Download URL | article-0.1.6-py3-none-any.whl |
|---|---|
| Size | 48.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
75c22b8b4a6a74474993316c6dbc172e5244809ed04cc261eec6aec42edefc58
|
|
BLAKE2b-256 checksum How to use checksums |
b261bdeab6646de91b1a41c6695e96625566793cdcc1942c343824a59b382c4a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","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.6
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.