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
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.
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
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": { "publish_as_draft": false },
"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: the layers above are complete and tested; each adapter's actual network / Playwright call is a clearly-marked
TODOstub that returns a typed placeholder result. Payload field names are verified against each platform's live API (canonicalUrl/canonical_url/originalArticleURL).
Metadata
Release files for article 0.1.3
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.3.tar.gz | 32.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| article-0.1.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 67.0 kB
Release files / article-0.1.3.tar.gz
| Download URL | article-0.1.3.tar.gz |
|---|---|
| Size | 32.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2f50fb20ada14b3d77914b2692b9975968af215626d61155d7e6171959759e14
|
|
BLAKE2b-256 checksum How to use checksums |
68f55a152d2273c00e24e35e013a2a657faab099794e4adae2f71937e3ae518c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","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.3-py3-none-any.whl
| Download URL | article-0.1.3-py3-none-any.whl |
|---|---|
| Size | 34.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
29355dc70eb2ea03ddc459aac85d53c306271b34febd8505d2ba885953751edf
|
|
BLAKE2b-256 checksum How to use checksums |
9fb80dcf64724056b39f819e1da3cc4e869f1a5663b52d0fe94a73f382390ee2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","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}
|