article 0.1.3__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {article-0.1.3 → article-0.1.5}/.env.example +9 -12
- {article-0.1.3 → article-0.1.5}/PKG-INFO +71 -11
- article-0.1.5/README.md +182 -0
- {article-0.1.3 → article-0.1.5}/article/__init__.py +2 -0
- article-0.1.5/article/__main__.py +220 -0
- article-0.1.5/article/adapters/medium.py +66 -0
- article-0.1.5/article/adapters/substack.py +659 -0
- {article-0.1.3 → article-0.1.5}/article/base.py +42 -2
- {article-0.1.3 → article-0.1.5}/article/config.py +56 -19
- {article-0.1.3 → article-0.1.5}/article/engine.py +37 -2
- article-0.1.5/article/md_source.py +135 -0
- {article-0.1.3 → article-0.1.5}/article/misc/CHANGELOG.md +9 -0
- {article-0.1.3 → article-0.1.5}/examples/article.example.json +1 -1
- {article-0.1.3 → article-0.1.5}/pyproject.toml +15 -4
- article-0.1.5/tests/conftest.py +65 -0
- {article-0.1.3 → article-0.1.5}/tests/test_cli.py +16 -10
- {article-0.1.3 → article-0.1.5}/tests/test_pipeline.py +86 -11
- article-0.1.5/tests/test_substack.py +434 -0
- article-0.1.5/tests/test_substack_live.py +52 -0
- article-0.1.3/README.md +0 -128
- article-0.1.3/article/__main__.py +0 -119
- article-0.1.3/article/adapters/medium.py +0 -126
- article-0.1.3/article/adapters/substack.py +0 -118
- {article-0.1.3 → article-0.1.5}/.editorconfig +0 -0
- {article-0.1.3 → article-0.1.5}/.gitattributes +0 -0
- {article-0.1.3 → article-0.1.5}/.github/workflows/ci.yml +0 -0
- {article-0.1.3 → article-0.1.5}/.gitignore +0 -0
- {article-0.1.3 → article-0.1.5}/LICENSE +0 -0
- {article-0.1.3 → article-0.1.5}/article/adapters/__init__.py +0 -0
- {article-0.1.3 → article-0.1.5}/article/adapters/dev_to.py +0 -0
- {article-0.1.3 → article-0.1.5}/article/adapters/hashnode.py +0 -0
- {article-0.1.3 → article-0.1.5}/article/registry.py +0 -0
- {article-0.1.3 → article-0.1.5}/article/state.py +0 -0
- {article-0.1.3 → article-0.1.5}/article/util.py +0 -0
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
# Loaded by article.config.Settings via pydantic-settings.
|
|
3
3
|
# Every platform is optional: omit a platform's secrets to skip it.
|
|
4
4
|
|
|
5
|
-
# --- Substack (Phase 1, primary)
|
|
5
|
+
# --- Substack (Phase 1, primary; drafts only) ---------------------------------
|
|
6
6
|
# Public URL of your publication; the canonical SSOT URL is derived from it.
|
|
7
7
|
SUBSTACK_PUBLICATION_URL=https://yourname.substack.com
|
|
8
|
-
#
|
|
9
|
-
|
|
8
|
+
# Auth, first one set wins. The cookie header copied from a logged-in browser:
|
|
9
|
+
SUBSTACK_COOKIES=
|
|
10
|
+
# ... or a JSON file of cookies:
|
|
11
|
+
SUBSTACK_COOKIES_PATH=
|
|
12
|
+
# ... or email + password (accounts that have a password):
|
|
13
|
+
SUBSTACK_EMAIL=
|
|
10
14
|
SUBSTACK_PASSWORD=
|
|
11
|
-
# Where the Playwright storage_state (cookies/session) is persisted/reused.
|
|
12
|
-
SUBSTACK_SESSION_PATH=.article/substack_session.json
|
|
13
15
|
|
|
14
16
|
# --- Medium (Phase 2) --------------------------------------------------------
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
MEDIUM_TOKEN=
|
|
18
|
-
MEDIUM_USER_ID=
|
|
17
|
+
# Nothing to set: Medium's API is closed to new integrations, so the adapter
|
|
18
|
+
# prints the "Import a story" step instead of posting.
|
|
19
19
|
|
|
20
20
|
# --- Dev.to / Forem (Phase 2) ------------------------------------------------
|
|
21
21
|
# Generate at https://dev.to/settings/extensions ("DEV API Keys").
|
|
@@ -30,6 +30,3 @@ HASHNODE_PUBLICATION_ID=
|
|
|
30
30
|
# --- Pipeline ----------------------------------------------------------------
|
|
31
31
|
# JSON state store; the canonical_url SSOT lives here, keyed by article slug.
|
|
32
32
|
STATE_PATH=pipeline_state.json
|
|
33
|
-
# Human-like pacing window (seconds) for browser automation.
|
|
34
|
-
MIN_ACTION_DELAY=0.6
|
|
35
|
-
MAX_ACTION_DELAY=2.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: article
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Tools to semi-automate the publication of articles
|
|
5
5
|
Project-URL: Homepage, https://github.com/thorwhalen/article
|
|
6
6
|
Project-URL: Repository, https://github.com/thorwhalen/article
|
|
@@ -22,18 +22,24 @@ Requires-Dist: httpx
|
|
|
22
22
|
Requires-Dist: pydantic-settings>=2
|
|
23
23
|
Requires-Dist: pydantic>=2
|
|
24
24
|
Requires-Dist: python-dotenv
|
|
25
|
+
Requires-Dist: pyyaml
|
|
25
26
|
Provides-Extra: completion
|
|
26
27
|
Requires-Dist: argcomplete; extra == 'completion'
|
|
27
28
|
Provides-Extra: dev
|
|
28
|
-
Requires-Dist:
|
|
29
|
+
Requires-Dist: pillow; extra == 'dev'
|
|
29
30
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
31
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: python-substack<0.11,>=0.10; extra == 'dev'
|
|
31
33
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
34
|
Provides-Extra: docs
|
|
33
35
|
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
34
36
|
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
37
|
+
Provides-Extra: images
|
|
38
|
+
Requires-Dist: pillow; extra == 'images'
|
|
35
39
|
Provides-Extra: substack
|
|
36
|
-
Requires-Dist:
|
|
40
|
+
Requires-Dist: markdown-it-py>=3; extra == 'substack'
|
|
41
|
+
Requires-Dist: mdit-py-plugins>=0.4; extra == 'substack'
|
|
42
|
+
Requires-Dist: python-substack<0.11,>=0.10; extra == 'substack'
|
|
37
43
|
Description-Content-Type: text/markdown
|
|
38
44
|
|
|
39
45
|
# article
|
|
@@ -46,9 +52,61 @@ Hashnode) — each pointing its canonical URL back to the primary, so you get
|
|
|
46
52
|
cross-platform reach without an SEO duplicate-content penalty.
|
|
47
53
|
|
|
48
54
|
```bash
|
|
49
|
-
pip install article
|
|
55
|
+
pip install 'article[substack]'
|
|
56
|
+
article draft-substack essay.md # an UNPUBLISHED Substack draft
|
|
50
57
|
```
|
|
51
58
|
|
|
59
|
+
`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.
|
|
60
|
+
|
|
61
|
+
## Markdown to a Substack draft
|
|
62
|
+
|
|
63
|
+
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`:
|
|
64
|
+
|
|
65
|
+
```markdown
|
|
66
|
+
---
|
|
67
|
+
subtitle: Why everyone agrees and nobody agrees
|
|
68
|
+
tags: [agile, ai]
|
|
69
|
+
---
|
|
70
|
+
# The Agile Religion
|
|
71
|
+
|
|
72
|
+
A claim.[^1]
|
|
73
|
+
|
|
74
|
+
")
|
|
75
|
+
|
|
76
|
+
[^1]: The footnote text.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
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:
|
|
80
|
+
|
|
81
|
+
| Construct | Why | What to do |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| 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 |
|
|
84
|
+
| 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 |
|
|
85
|
+
| A missing local image | nothing to upload | fix the path |
|
|
86
|
+
| A raw HTML block | the converter drops it | rewrite it in Markdown |
|
|
87
|
+
| A `data:` image | cannot be uploaded | save it as a file |
|
|
88
|
+
| 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 |
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
article draft-substack essay.md --dry-run # what survives, no credentials needed
|
|
92
|
+
article draft-substack essay.md # create the draft; prints its edit link
|
|
93
|
+
article draft-substack essay.md --tables code # keep tables as code blocks
|
|
94
|
+
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)
|
|
95
|
+
article syndicate-secondary essay.md # refused until the Substack post is live (--allow-unpublished to override)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
### Substack credentials
|
|
101
|
+
|
|
102
|
+
Substack has no official write API; this uses the unofficial [python-substack](https://github.com/ma2za/python-substack) client, which talks to undocumented endpoints that can change. Put one of these in `.env` (never commit it):
|
|
103
|
+
|
|
104
|
+
- `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.
|
|
105
|
+
- `SUBSTACK_COOKIES_PATH`: a JSON file of cookies (`{"substack.sid": "...", ...}`).
|
|
106
|
+
- `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD`, for accounts with a password.
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
52
110
|
## The single source of truth
|
|
53
111
|
|
|
54
112
|
The Substack public URL captured in phase 1 is the SSOT. It is stored
|
|
@@ -65,7 +123,8 @@ author.json ──► publish-primary ──► Substack ──► live URL ─
|
|
|
65
123
|
```
|
|
66
124
|
|
|
67
125
|
The two phases are deliberately separate commands so you can syndicate later
|
|
68
|
-
(e.g. `+3 days`) on your own schedule.
|
|
126
|
+
(e.g. `+3 days`) on your own schedule. Phase 1 creates a Substack **draft**;
|
|
127
|
+
publish it in the Substack editor before syndicating.
|
|
69
128
|
|
|
70
129
|
## Two-command flow
|
|
71
130
|
|
|
@@ -85,7 +144,7 @@ After `pip install`, the `article` console script is equivalent
|
|
|
85
144
|
|
|
86
145
|
## The article JSON
|
|
87
146
|
|
|
88
|
-
One standardized file is the single source of truth for content **and** which
|
|
147
|
+
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
|
|
89
148
|
platforms to publish to — a platform is published only if it appears under
|
|
90
149
|
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):
|
|
91
150
|
|
|
@@ -96,7 +155,7 @@ platforms to publish to — a platform is published only if it appears under
|
|
|
96
155
|
"content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
|
|
97
156
|
"tags": ["python", "automation", "seo"],
|
|
98
157
|
"platforms": {
|
|
99
|
-
"substack": { "
|
|
158
|
+
"substack": { "tables": "error" },
|
|
100
159
|
"medium": { "publish_as_draft": true },
|
|
101
160
|
"dev_to": { "publish_as_draft": true },
|
|
102
161
|
"hashnode": { "publish_as_draft": true }
|
|
@@ -160,7 +219,8 @@ async def publish(article, *, canonical_url, config, secrets):
|
|
|
160
219
|
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
161
220
|
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |
|
|
162
221
|
|
|
163
|
-
> **Status
|
|
164
|
-
>
|
|
165
|
-
>
|
|
166
|
-
>
|
|
222
|
+
> **Status, per platform:**
|
|
223
|
+
>
|
|
224
|
+
> - **Substack**: real. Creates unpublished drafts through python-substack; never publishes.
|
|
225
|
+
> - **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.
|
|
226
|
+
> - **Dev.to, Hashnode**: still stubs; they build the payload (with the canonical field) but make no network call.
|
article-0.1.5/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# article
|
|
2
|
+
|
|
3
|
+
Tools to semi-automate the publication of articles.
|
|
4
|
+
|
|
5
|
+
Write an article **once** as a JSON file. Publish it to a **primary** platform
|
|
6
|
+
(Substack), then **syndicate** it to secondary platforms (Medium, Dev.to,
|
|
7
|
+
Hashnode) — each pointing its canonical URL back to the primary, so you get
|
|
8
|
+
cross-platform reach without an SEO duplicate-content penalty.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install 'article[substack]'
|
|
12
|
+
article draft-substack essay.md # an UNPUBLISHED Substack draft
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`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.
|
|
16
|
+
|
|
17
|
+
## Markdown to a Substack draft
|
|
18
|
+
|
|
19
|
+
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`:
|
|
20
|
+
|
|
21
|
+
```markdown
|
|
22
|
+
---
|
|
23
|
+
subtitle: Why everyone agrees and nobody agrees
|
|
24
|
+
tags: [agile, ai]
|
|
25
|
+
---
|
|
26
|
+
# The Agile Religion
|
|
27
|
+
|
|
28
|
+
A claim.[^1]
|
|
29
|
+
|
|
30
|
+
")
|
|
31
|
+
|
|
32
|
+
[^1]: The footnote text.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
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:
|
|
36
|
+
|
|
37
|
+
| Construct | Why | What to do |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| 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 |
|
|
40
|
+
| 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 |
|
|
41
|
+
| A missing local image | nothing to upload | fix the path |
|
|
42
|
+
| A raw HTML block | the converter drops it | rewrite it in Markdown |
|
|
43
|
+
| A `data:` image | cannot be uploaded | save it as a file |
|
|
44
|
+
| 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 |
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
article draft-substack essay.md --dry-run # what survives, no credentials needed
|
|
48
|
+
article draft-substack essay.md # create the draft; prints its edit link
|
|
49
|
+
article draft-substack essay.md --tables code # keep tables as code blocks
|
|
50
|
+
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)
|
|
51
|
+
article syndicate-secondary essay.md # refused until the Substack post is live (--allow-unpublished to override)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
### Substack credentials
|
|
57
|
+
|
|
58
|
+
Substack has no official write API; this uses the unofficial [python-substack](https://github.com/ma2za/python-substack) client, which talks to undocumented endpoints that can change. Put one of these in `.env` (never commit it):
|
|
59
|
+
|
|
60
|
+
- `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.
|
|
61
|
+
- `SUBSTACK_COOKIES_PATH`: a JSON file of cookies (`{"substack.sid": "...", ...}`).
|
|
62
|
+
- `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD`, for accounts with a password.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
## The single source of truth
|
|
67
|
+
|
|
68
|
+
The Substack public URL captured in phase 1 is the SSOT. It is stored
|
|
69
|
+
(keyed by article slug) and injected as `canonical_url` into every secondary
|
|
70
|
+
platform in phase 2, so each emits `<link rel="canonical" href="…">` pointing
|
|
71
|
+
back at the original.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
author.json ──► publish-primary ──► Substack ──► live URL ─┐
|
|
75
|
+
│ stored as canonical_url
|
|
76
|
+
▼ (pipeline_state.json, by slug)
|
|
77
|
+
syndicate-secondary ──► Medium / Dev.to / Hashnode
|
|
78
|
+
each canonical → Substack URL
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The two phases are deliberately separate commands so you can syndicate later
|
|
82
|
+
(e.g. `+3 days`) on your own schedule. Phase 1 creates a Substack **draft**;
|
|
83
|
+
publish it in the Substack editor before syndicating.
|
|
84
|
+
|
|
85
|
+
## Two-command flow
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# 1. Author once, then publish to the primary (records the canonical URL):
|
|
89
|
+
python -m article publish-primary article.json
|
|
90
|
+
|
|
91
|
+
# 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
|
|
92
|
+
python -m article syndicate-secondary article.json
|
|
93
|
+
|
|
94
|
+
# Subset / machine-readable output:
|
|
95
|
+
python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
After `pip install`, the `article` console script is equivalent
|
|
99
|
+
(`article publish-primary article.json`).
|
|
100
|
+
|
|
101
|
+
## The article JSON
|
|
102
|
+
|
|
103
|
+
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
|
|
104
|
+
platforms to publish to — a platform is published only if it appears under
|
|
105
|
+
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"title": "Designing an SSOT Publishing Pipeline",
|
|
110
|
+
"slug": "ssot-publishing-pipeline",
|
|
111
|
+
"content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
|
|
112
|
+
"tags": ["python", "automation", "seo"],
|
|
113
|
+
"platforms": {
|
|
114
|
+
"substack": { "tables": "error" },
|
|
115
|
+
"medium": { "publish_as_draft": true },
|
|
116
|
+
"dev_to": { "publish_as_draft": true },
|
|
117
|
+
"hashnode": { "publish_as_draft": true }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
Secrets and settings load from `.env` (see
|
|
125
|
+
[`.env.example`](.env.example)) — copy it and fill in what you use:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cp .env.example .env
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Every platform is optional; omit a platform's secrets (or its `platforms`
|
|
132
|
+
entry) to skip it.
|
|
133
|
+
|
|
134
|
+
## Using it from Python
|
|
135
|
+
|
|
136
|
+
The CLI is a thin wrapper over the same functions, so everything is callable
|
|
137
|
+
programmatically:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import asyncio
|
|
141
|
+
from article import load_article, publish_primary, syndicate_secondary
|
|
142
|
+
|
|
143
|
+
art = load_article("article.json")
|
|
144
|
+
asyncio.run(publish_primary(art)) # phase 1 → records canonical_url
|
|
145
|
+
asyncio.run(syndicate_secondary(art)) # phase 2 → canonical SEO everywhere
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Adding a platform
|
|
149
|
+
|
|
150
|
+
Adapters are registered in an open-closed registry. To add a platform, write
|
|
151
|
+
one async function satisfying the `PublishAdapter` protocol and register it —
|
|
152
|
+
no core changes:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from article import register_adapter, PublishResult
|
|
156
|
+
from article.util import require_canonical_url
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@register_adapter("my_platform")
|
|
160
|
+
async def publish(article, *, canonical_url, config, secrets):
|
|
161
|
+
require_canonical_url("my_platform", canonical_url) # SSOT is never optional
|
|
162
|
+
# … call the platform's API, injecting canonical_url …
|
|
163
|
+
return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Architecture
|
|
167
|
+
|
|
168
|
+
| Layer | Module | Responsibility |
|
|
169
|
+
|------|--------|----------------|
|
|
170
|
+
| Schema + config | `article/config.py` | Pydantic `Article` model, `.env` `Settings`, `load_article` (friendly errors) |
|
|
171
|
+
| Vocabulary | `article/base.py` | `PublishResult`, `RunSummary`, platform names, exceptions |
|
|
172
|
+
| State store | `article/state.py` | `MutableMapping` facade over JSON, keyed by slug (the SSOT) |
|
|
173
|
+
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
174
|
+
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
175
|
+
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
176
|
+
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |
|
|
177
|
+
|
|
178
|
+
> **Status, per platform:**
|
|
179
|
+
>
|
|
180
|
+
> - **Substack**: real. Creates unpublished drafts through python-substack; never publishes.
|
|
181
|
+
> - **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.
|
|
182
|
+
> - **Dev.to, Hashnode**: still stubs; they build the payload (with the canonical field) but make no network call.
|
|
@@ -45,6 +45,7 @@ from .base import (
|
|
|
45
45
|
ArticleError,
|
|
46
46
|
ArticleValidationError,
|
|
47
47
|
CanonicalUrlMissing,
|
|
48
|
+
CanonicalUrlNotLive,
|
|
48
49
|
ConfigError,
|
|
49
50
|
Phase,
|
|
50
51
|
PlatformName,
|
|
@@ -122,6 +123,7 @@ __all__ = [
|
|
|
122
123
|
"AdapterError",
|
|
123
124
|
"UnknownPlatformError",
|
|
124
125
|
"CanonicalUrlMissing",
|
|
126
|
+
"CanonicalUrlNotLive",
|
|
125
127
|
# state
|
|
126
128
|
"JsonStateStore",
|
|
127
129
|
"default_state_store",
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""Command-line entry point for the ``article`` publishing pipeline.
|
|
3
|
+
|
|
4
|
+
Two commands mirror the two phases, and a third is the everyday shortcut for
|
|
5
|
+
phase 1 — all thin wrappers over the very same :mod:`article.engine` and
|
|
6
|
+
adapter functions the Python API exposes (dispatch-to-interface: one
|
|
7
|
+
implementation, many front-ends)::
|
|
8
|
+
|
|
9
|
+
python -m article publish-primary article.json
|
|
10
|
+
python -m article syndicate-secondary article.json
|
|
11
|
+
python -m article draft-substack essay.md # unpublished draft
|
|
12
|
+
python -m article draft-substack essay.md --dry-run # no credentials
|
|
13
|
+
|
|
14
|
+
Common options: ``--env-file`` (where to read secrets), ``--state-path``
|
|
15
|
+
(the SSOT state file), ``--json-out`` (machine-readable summary). Phase 2 also
|
|
16
|
+
takes ``--platforms medium,dev_to`` to syndicate a subset.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import cw
|
|
26
|
+
|
|
27
|
+
from .adapters import substack as _substack
|
|
28
|
+
from .base import SUBSTACK, ArticleError, RunSummary
|
|
29
|
+
from .config import SubstackConfig, load_article, load_settings
|
|
30
|
+
from .engine import publish_primary as _publish_primary
|
|
31
|
+
from .engine import syndicate_secondary as _syndicate_secondary
|
|
32
|
+
from .state import JsonStateStore
|
|
33
|
+
from .util import run_sync
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render(summary: RunSummary, *, json_out: bool) -> str:
|
|
37
|
+
return json.dumps(summary.as_record(), indent=2) if json_out else summary.render()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepare(article_path: str, *, env_file: Optional[str], state_path: Optional[str]):
|
|
41
|
+
"""Shared setup: load settings + article + state store (with friendly errors)."""
|
|
42
|
+
try:
|
|
43
|
+
overrides = {"state_path": state_path} if state_path else {}
|
|
44
|
+
settings = load_settings(env_file=env_file, **overrides)
|
|
45
|
+
article = load_article(article_path)
|
|
46
|
+
except ArticleError as e:
|
|
47
|
+
raise cw.CommandError(str(e)) from e
|
|
48
|
+
store = JsonStateStore(settings.state_path)
|
|
49
|
+
return settings, article, store
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def publish_primary(
|
|
53
|
+
article_path: str,
|
|
54
|
+
*,
|
|
55
|
+
env_file: Optional[str] = None,
|
|
56
|
+
state_path: Optional[str] = None,
|
|
57
|
+
json_out: bool = False,
|
|
58
|
+
):
|
|
59
|
+
"""Phase 1 — publish to the primary platform (Substack) and record canonical_url."""
|
|
60
|
+
settings, article, store = _prepare(
|
|
61
|
+
article_path, env_file=env_file, state_path=state_path
|
|
62
|
+
)
|
|
63
|
+
summary = run_sync(_publish_primary(article, settings=settings, store=store))
|
|
64
|
+
return _render(summary, json_out=json_out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def syndicate_secondary(
|
|
68
|
+
article_path: str,
|
|
69
|
+
*,
|
|
70
|
+
env_file: Optional[str] = None,
|
|
71
|
+
state_path: Optional[str] = None,
|
|
72
|
+
platforms: Optional[str] = None,
|
|
73
|
+
allow_unpublished: bool = False,
|
|
74
|
+
json_out: bool = False,
|
|
75
|
+
):
|
|
76
|
+
"""Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO.
|
|
77
|
+
|
|
78
|
+
Refused while the Substack post is still a draft (its URL would be a 404), unless
|
|
79
|
+
``allow_unpublished``.
|
|
80
|
+
"""
|
|
81
|
+
settings, article, store = _prepare(
|
|
82
|
+
article_path, env_file=env_file, state_path=state_path
|
|
83
|
+
)
|
|
84
|
+
selected = [p.strip() for p in platforms.split(",")] if platforms else None
|
|
85
|
+
try:
|
|
86
|
+
summary = run_sync(
|
|
87
|
+
_syndicate_secondary(
|
|
88
|
+
article,
|
|
89
|
+
settings=settings,
|
|
90
|
+
store=store,
|
|
91
|
+
platforms=selected,
|
|
92
|
+
allow_unpublished=allow_unpublished,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
except ArticleError as e:
|
|
96
|
+
raise cw.CommandError(str(e)) from e
|
|
97
|
+
return _render(summary, json_out=json_out)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _render_dry_run(report: dict) -> str:
|
|
101
|
+
counts = ", ".join(f"{n} {k}" for k, n in report["counts"].items()) or "empty"
|
|
102
|
+
lines = [
|
|
103
|
+
f"Dry run (nothing sent): {report['title']!r} -> slug {report['slug']}",
|
|
104
|
+
f" converts to: {counts}",
|
|
105
|
+
*(f" warning: {w}" for w in report["warnings"]),
|
|
106
|
+
]
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def draft_substack(
|
|
111
|
+
article_path: str,
|
|
112
|
+
*,
|
|
113
|
+
env_file: Optional[str] = None,
|
|
114
|
+
state_path: Optional[str] = None,
|
|
115
|
+
tables: Optional[str] = None,
|
|
116
|
+
dry_run: bool = False,
|
|
117
|
+
update: bool = False,
|
|
118
|
+
overwrite_edits: bool = False,
|
|
119
|
+
json_out: bool = False,
|
|
120
|
+
):
|
|
121
|
+
"""Create an UNPUBLISHED Substack draft from a Markdown file; never publishes.
|
|
122
|
+
|
|
123
|
+
With ``update``, rewrite the draft this article's earlier run created (from the
|
|
124
|
+
state file) instead of creating another one.
|
|
125
|
+
"""
|
|
126
|
+
settings, article, store = _prepare(
|
|
127
|
+
article_path, env_file=env_file, state_path=state_path
|
|
128
|
+
)
|
|
129
|
+
config = article.platforms.substack or SubstackConfig()
|
|
130
|
+
if tables is not None:
|
|
131
|
+
config = config.model_copy(update={"tables": tables})
|
|
132
|
+
earlier = store.get(article.slug, {}).get("primary", {}).get("detail", {})
|
|
133
|
+
if update:
|
|
134
|
+
if not earlier.get("draft_id"):
|
|
135
|
+
raise cw.CommandError(
|
|
136
|
+
f"--update: no earlier draft is recorded for {article.slug!r} in the "
|
|
137
|
+
f"state file; run once without --update first"
|
|
138
|
+
)
|
|
139
|
+
config = config.model_copy(
|
|
140
|
+
update={
|
|
141
|
+
"draft_id": earlier["draft_id"],
|
|
142
|
+
"expected_revision": earlier.get("revision"),
|
|
143
|
+
"overwrite_edits": overwrite_edits,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
if dry_run:
|
|
148
|
+
report = _substack.dry_run(article, config=config)
|
|
149
|
+
return json.dumps(report, indent=2) if json_out else _render_dry_run(report)
|
|
150
|
+
platforms = article.platforms.model_copy(update={SUBSTACK: config})
|
|
151
|
+
article = article.model_copy(update={"platforms": platforms})
|
|
152
|
+
summary = run_sync(_publish_primary(article, settings=settings, store=store))
|
|
153
|
+
except ArticleError as e:
|
|
154
|
+
raise cw.CommandError(str(e)) from e
|
|
155
|
+
if not summary.ok:
|
|
156
|
+
raise cw.CommandError(summary.failures[0].error)
|
|
157
|
+
if json_out:
|
|
158
|
+
return _render(summary, json_out=True)
|
|
159
|
+
detail = summary.by_platform[SUBSTACK].detail
|
|
160
|
+
lines = [summary.render(), f"Edit the draft: {detail.get('edit_url')}"]
|
|
161
|
+
lines += [f" warning: {w}" for w in detail.get("warnings", ())]
|
|
162
|
+
if earlier.get("edit_url") and not update:
|
|
163
|
+
lines.append(
|
|
164
|
+
f"Note: an earlier draft of this article is still on Substack "
|
|
165
|
+
f"({earlier['edit_url']}); delete it if this one replaces it."
|
|
166
|
+
)
|
|
167
|
+
return "\n".join(lines)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: SSOT list of dispatchable commands (``cw`` maps ``_`` in names to ``-``).
|
|
171
|
+
_dispatch_funcs = [publish_primary, syndicate_secondary, draft_substack]
|
|
172
|
+
|
|
173
|
+
#: Per-parameter ``add_argument`` particulars the signature cannot carry -- here, the
|
|
174
|
+
#: one ``help`` string that used to ride on an ``@argh.arg`` decorator. ``cw`` reads
|
|
175
|
+
#: ``config``, never decorator metadata, so this is where such declarations live now.
|
|
176
|
+
_dispatch_config = {
|
|
177
|
+
command: {"article_path": {"help": "Path to the article JSON file"}}
|
|
178
|
+
for command in ("publish-primary", "syndicate-secondary")
|
|
179
|
+
}
|
|
180
|
+
_dispatch_config["syndicate-secondary"]["allow_unpublished"] = {
|
|
181
|
+
"help": "Syndicate even though the Substack post is still a draft (its URL is a 404)"
|
|
182
|
+
}
|
|
183
|
+
_dispatch_config["draft-substack"] = {
|
|
184
|
+
"article_path": {"help": "Path to the essay Markdown file (or article JSON)"},
|
|
185
|
+
"tables": {
|
|
186
|
+
"choices": ["error", "code", "image"],
|
|
187
|
+
"help": "Markdown tables: refuse (error, the default), keep as code blocks, "
|
|
188
|
+
"or render to an image (needs article[images])",
|
|
189
|
+
},
|
|
190
|
+
"update": {
|
|
191
|
+
"help": "Rewrite the draft an earlier run created instead of making a new one"
|
|
192
|
+
},
|
|
193
|
+
"overwrite_edits": {
|
|
194
|
+
"help": "With --update: replace edits made in the Substack editor since the last run"
|
|
195
|
+
},
|
|
196
|
+
"dry_run": {"help": "Check and convert only; no credentials, nothing sent"},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def mk_parser() -> argparse.ArgumentParser:
|
|
201
|
+
"""Build the CLI parser -- a plain :class:`argparse.ArgumentParser`, no I/O.
|
|
202
|
+
|
|
203
|
+
The single place the command list and its per-parameter declarations meet, so a
|
|
204
|
+
test that inspects the grammar inspects the very parser :func:`main` dispatches.
|
|
205
|
+
"""
|
|
206
|
+
return cw.mk_parser(_dispatch_funcs, config=_dispatch_config)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main() -> int:
|
|
210
|
+
"""Dispatch a CLI command and return its exit code.
|
|
211
|
+
|
|
212
|
+
``cw.run`` offers the parser to ``argcomplete`` itself (the
|
|
213
|
+
``# PYTHON_ARGCOMPLETE_OK`` marker on line 1 is what the shell hook looks for),
|
|
214
|
+
so there is no hand-written completion block to keep in step.
|
|
215
|
+
"""
|
|
216
|
+
return cw.run(mk_parser())
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Medium adapter — Phase 2, as an honest manual step ("Import a story").
|
|
2
|
+
|
|
3
|
+
Medium's REST API is closed to new integrations (no new integration tokens
|
|
4
|
+
are issued, and the API docs repository is archived), so this adapter does
|
|
5
|
+
**not** post anything. Medium's own *Import a story* tool is the supported
|
|
6
|
+
route, and it sets the imported story's canonical link to the original URL,
|
|
7
|
+
which is exactly the SEO handoff this pipeline exists for.
|
|
8
|
+
|
|
9
|
+
The adapter returns a :meth:`~article.base.PublishResult.manual` result whose
|
|
10
|
+
``next_step`` names the URL to import and where to paste it. Nothing is ever
|
|
11
|
+
published to Medium.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
from ..base import MEDIUM, PublishResult
|
|
19
|
+
from ..config import Article, MediumConfig
|
|
20
|
+
from ..registry import register_adapter
|
|
21
|
+
from ..util import require_canonical_url
|
|
22
|
+
|
|
23
|
+
#: Medium's "Import a story" page (Profile menu > Stories > Import a story).
|
|
24
|
+
MEDIUM_IMPORT_URL = "https://medium.com/p/import"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def import_steps(canonical_url: str) -> str:
|
|
28
|
+
"""The one-line instruction for importing ``canonical_url`` into Medium.
|
|
29
|
+
|
|
30
|
+
>>> import_steps("https://me.substack.com/p/t") # doctest: +ELLIPSIS
|
|
31
|
+
'Once https://me.substack.com/p/t is live, open https://medium.com/p/import, ...'
|
|
32
|
+
"""
|
|
33
|
+
return (
|
|
34
|
+
f"Once {canonical_url} is live, open {MEDIUM_IMPORT_URL}, paste that URL "
|
|
35
|
+
f"and press Import; Medium sets the canonical link to it. Review the "
|
|
36
|
+
f"imported draft (footnotes, tables, captions) before publishing."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register_adapter(MEDIUM)
|
|
41
|
+
async def publish(
|
|
42
|
+
article: Article,
|
|
43
|
+
*,
|
|
44
|
+
canonical_url: Optional[str],
|
|
45
|
+
config: MediumConfig,
|
|
46
|
+
secrets: Mapping[str, Any],
|
|
47
|
+
) -> PublishResult:
|
|
48
|
+
"""Return the *Import a story* step for ``article``; sends nothing to Medium.
|
|
49
|
+
|
|
50
|
+
>>> import asyncio
|
|
51
|
+
>>> from article.config import Article, MediumConfig
|
|
52
|
+
>>> art = Article(title="T", slug="t", content_markdown="x")
|
|
53
|
+
>>> res = asyncio.run(publish(
|
|
54
|
+
... art, canonical_url="https://me.substack.com/p/t",
|
|
55
|
+
... config=MediumConfig(), secrets={},
|
|
56
|
+
... ))
|
|
57
|
+
>>> res.state.value, res.detail["import_url"]
|
|
58
|
+
('manual', 'https://me.substack.com/p/t')
|
|
59
|
+
"""
|
|
60
|
+
require_canonical_url(MEDIUM, canonical_url)
|
|
61
|
+
return PublishResult.manual(
|
|
62
|
+
MEDIUM,
|
|
63
|
+
next_step=import_steps(canonical_url),
|
|
64
|
+
canonical_url=canonical_url,
|
|
65
|
+
detail={"import_url": canonical_url, "import_page": MEDIUM_IMPORT_URL},
|
|
66
|
+
)
|