article 0.1.2__tar.gz → 0.1.4__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.2 → article-0.1.4}/.env.example +9 -12
- {article-0.1.2 → article-0.1.4}/PKG-INFO +69 -14
- {article-0.1.2 → article-0.1.4}/README.md +61 -9
- article-0.1.4/article/__main__.py +182 -0
- article-0.1.4/article/adapters/medium.py +66 -0
- article-0.1.4/article/adapters/substack.py +495 -0
- {article-0.1.2 → article-0.1.4}/article/base.py +34 -2
- {article-0.1.2 → article-0.1.4}/article/config.py +49 -19
- article-0.1.4/article/md_source.py +135 -0
- article-0.1.4/article/misc/CHANGELOG.md +52 -0
- {article-0.1.2 → article-0.1.4}/examples/article.example.json +1 -1
- {article-0.1.2 → article-0.1.4}/pyproject.toml +13 -5
- article-0.1.4/tests/conftest.py +65 -0
- article-0.1.4/tests/test_cli.py +192 -0
- {article-0.1.2 → article-0.1.4}/tests/test_pipeline.py +64 -11
- article-0.1.4/tests/test_substack.py +364 -0
- article-0.1.4/tests/test_substack_live.py +52 -0
- article-0.1.2/article/__main__.py +0 -106
- article-0.1.2/article/adapters/medium.py +0 -126
- article-0.1.2/article/adapters/substack.py +0 -118
- article-0.1.2/article/misc/CHANGELOG.md +0 -26
- {article-0.1.2 → article-0.1.4}/.editorconfig +0 -0
- {article-0.1.2 → article-0.1.4}/.gitattributes +0 -0
- {article-0.1.2 → article-0.1.4}/.github/workflows/ci.yml +0 -0
- {article-0.1.2 → article-0.1.4}/.gitignore +0 -0
- {article-0.1.2 → article-0.1.4}/LICENSE +0 -0
- {article-0.1.2 → article-0.1.4}/article/__init__.py +0 -0
- {article-0.1.2 → article-0.1.4}/article/adapters/__init__.py +0 -0
- {article-0.1.2 → article-0.1.4}/article/adapters/dev_to.py +0 -0
- {article-0.1.2 → article-0.1.4}/article/adapters/hashnode.py +0 -0
- {article-0.1.2 → article-0.1.4}/article/engine.py +0 -0
- {article-0.1.2 → article-0.1.4}/article/registry.py +0 -0
- {article-0.1.2 → article-0.1.4}/article/state.py +0 -0
- {article-0.1.2 → article-0.1.4}/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
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: article
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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
|
|
@@ -17,23 +17,26 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
18
|
Classifier: Topic :: Text Processing :: Markup
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: cw<0.2,>=0.1.1
|
|
21
21
|
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: playwright; extra == 'dev'
|
|
29
29
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
30
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: python-substack<0.11,>=0.10; extra == 'dev'
|
|
31
32
|
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
33
|
Provides-Extra: docs
|
|
33
34
|
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
34
35
|
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
35
36
|
Provides-Extra: substack
|
|
36
|
-
Requires-Dist:
|
|
37
|
+
Requires-Dist: markdown-it-py>=3; extra == 'substack'
|
|
38
|
+
Requires-Dist: mdit-py-plugins>=0.4; extra == 'substack'
|
|
39
|
+
Requires-Dist: python-substack<0.11,>=0.10; extra == 'substack'
|
|
37
40
|
Description-Content-Type: text/markdown
|
|
38
41
|
|
|
39
42
|
# article
|
|
@@ -46,9 +49,59 @@ Hashnode) — each pointing its canonical URL back to the primary, so you get
|
|
|
46
49
|
cross-platform reach without an SEO duplicate-content penalty.
|
|
47
50
|
|
|
48
51
|
```bash
|
|
49
|
-
pip install article
|
|
52
|
+
pip install 'article[substack]'
|
|
53
|
+
article draft-substack essay.md # an UNPUBLISHED Substack draft
|
|
50
54
|
```
|
|
51
55
|
|
|
56
|
+
`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.
|
|
57
|
+
|
|
58
|
+
## Markdown to a Substack draft
|
|
59
|
+
|
|
60
|
+
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`:
|
|
61
|
+
|
|
62
|
+
```markdown
|
|
63
|
+
---
|
|
64
|
+
subtitle: Why everyone agrees and nobody agrees
|
|
65
|
+
tags: [agile, ai]
|
|
66
|
+
---
|
|
67
|
+
# The Agile Religion
|
|
68
|
+
|
|
69
|
+
A claim.[^1]
|
|
70
|
+
|
|
71
|
+
")
|
|
72
|
+
|
|
73
|
+
[^1]: The footnote text.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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:
|
|
77
|
+
|
|
78
|
+
| Construct | Why | What to do |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| 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) |
|
|
81
|
+
| 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 |
|
|
82
|
+
| A missing local image | nothing to upload | fix the path |
|
|
83
|
+
| A raw HTML block | the converter drops it | rewrite it in Markdown |
|
|
84
|
+
| A `data:` image | cannot be uploaded | save it as a file |
|
|
85
|
+
| 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 |
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
article draft-substack essay.md --dry-run # what survives, no credentials needed
|
|
89
|
+
article draft-substack essay.md # create the draft; prints its edit link
|
|
90
|
+
article draft-substack essay.md --tables code # keep tables as code blocks
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
### Substack credentials
|
|
96
|
+
|
|
97
|
+
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):
|
|
98
|
+
|
|
99
|
+
- `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.
|
|
100
|
+
- `SUBSTACK_COOKIES_PATH`: a JSON file of cookies (`{"substack.sid": "...", ...}`).
|
|
101
|
+
- `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD`, for accounts with a password.
|
|
102
|
+
|
|
103
|
+
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.
|
|
104
|
+
|
|
52
105
|
## The single source of truth
|
|
53
106
|
|
|
54
107
|
The Substack public URL captured in phase 1 is the SSOT. It is stored
|
|
@@ -65,7 +118,8 @@ author.json ──► publish-primary ──► Substack ──► live URL ─
|
|
|
65
118
|
```
|
|
66
119
|
|
|
67
120
|
The two phases are deliberately separate commands so you can syndicate later
|
|
68
|
-
(e.g. `+3 days`) on your own schedule.
|
|
121
|
+
(e.g. `+3 days`) on your own schedule. Phase 1 creates a Substack **draft**;
|
|
122
|
+
publish it in the Substack editor before syndicating.
|
|
69
123
|
|
|
70
124
|
## Two-command flow
|
|
71
125
|
|
|
@@ -85,7 +139,7 @@ After `pip install`, the `article` console script is equivalent
|
|
|
85
139
|
|
|
86
140
|
## The article JSON
|
|
87
141
|
|
|
88
|
-
One standardized file is the single source of truth for content **and** which
|
|
142
|
+
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
143
|
platforms to publish to — a platform is published only if it appears under
|
|
90
144
|
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):
|
|
91
145
|
|
|
@@ -96,7 +150,7 @@ platforms to publish to — a platform is published only if it appears under
|
|
|
96
150
|
"content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
|
|
97
151
|
"tags": ["python", "automation", "seo"],
|
|
98
152
|
"platforms": {
|
|
99
|
-
"substack": { "
|
|
153
|
+
"substack": { "tables": "error" },
|
|
100
154
|
"medium": { "publish_as_draft": true },
|
|
101
155
|
"dev_to": { "publish_as_draft": true },
|
|
102
156
|
"hashnode": { "publish_as_draft": true }
|
|
@@ -158,9 +212,10 @@ async def publish(article, *, canonical_url, config, secrets):
|
|
|
158
212
|
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
159
213
|
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
160
214
|
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
161
|
-
| CLI | `article/__main__.py` | `
|
|
215
|
+
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |
|
|
162
216
|
|
|
163
|
-
> **Status
|
|
164
|
-
>
|
|
165
|
-
>
|
|
166
|
-
>
|
|
217
|
+
> **Status, per platform:**
|
|
218
|
+
>
|
|
219
|
+
> - **Substack**: real. Creates unpublished drafts through python-substack; never publishes.
|
|
220
|
+
> - **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.
|
|
221
|
+
> - **Dev.to, Hashnode**: still stubs; they build the payload (with the canonical field) but make no network call.
|
|
@@ -8,9 +8,59 @@ Hashnode) — each pointing its canonical URL back to the primary, so you get
|
|
|
8
8
|
cross-platform reach without an SEO duplicate-content penalty.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
pip install article
|
|
11
|
+
pip install 'article[substack]'
|
|
12
|
+
article draft-substack essay.md # an UNPUBLISHED Substack draft
|
|
12
13
|
```
|
|
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; or render it to an image (in Python, pass `tables=` 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
|
+
```
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
### Substack credentials
|
|
55
|
+
|
|
56
|
+
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):
|
|
57
|
+
|
|
58
|
+
- `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.
|
|
59
|
+
- `SUBSTACK_COOKIES_PATH`: a JSON file of cookies (`{"substack.sid": "...", ...}`).
|
|
60
|
+
- `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD`, for accounts with a password.
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
14
64
|
## The single source of truth
|
|
15
65
|
|
|
16
66
|
The Substack public URL captured in phase 1 is the SSOT. It is stored
|
|
@@ -27,7 +77,8 @@ author.json ──► publish-primary ──► Substack ──► live URL ─
|
|
|
27
77
|
```
|
|
28
78
|
|
|
29
79
|
The two phases are deliberately separate commands so you can syndicate later
|
|
30
|
-
(e.g. `+3 days`) on your own schedule.
|
|
80
|
+
(e.g. `+3 days`) on your own schedule. Phase 1 creates a Substack **draft**;
|
|
81
|
+
publish it in the Substack editor before syndicating.
|
|
31
82
|
|
|
32
83
|
## Two-command flow
|
|
33
84
|
|
|
@@ -47,7 +98,7 @@ After `pip install`, the `article` console script is equivalent
|
|
|
47
98
|
|
|
48
99
|
## The article JSON
|
|
49
100
|
|
|
50
|
-
One standardized file is the single source of truth for content **and** which
|
|
101
|
+
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
|
|
51
102
|
platforms to publish to — a platform is published only if it appears under
|
|
52
103
|
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):
|
|
53
104
|
|
|
@@ -58,7 +109,7 @@ platforms to publish to — a platform is published only if it appears under
|
|
|
58
109
|
"content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
|
|
59
110
|
"tags": ["python", "automation", "seo"],
|
|
60
111
|
"platforms": {
|
|
61
|
-
"substack": { "
|
|
112
|
+
"substack": { "tables": "error" },
|
|
62
113
|
"medium": { "publish_as_draft": true },
|
|
63
114
|
"dev_to": { "publish_as_draft": true },
|
|
64
115
|
"hashnode": { "publish_as_draft": true }
|
|
@@ -120,9 +171,10 @@ async def publish(article, *, canonical_url, config, secrets):
|
|
|
120
171
|
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
121
172
|
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
122
173
|
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
123
|
-
| CLI | `article/__main__.py` | `
|
|
174
|
+
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |
|
|
124
175
|
|
|
125
|
-
> **Status
|
|
126
|
-
>
|
|
127
|
-
>
|
|
128
|
-
>
|
|
176
|
+
> **Status, per platform:**
|
|
177
|
+
>
|
|
178
|
+
> - **Substack**: real. Creates unpublished drafts through python-substack; never publishes.
|
|
179
|
+
> - **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.
|
|
180
|
+
> - **Dev.to, Hashnode**: still stubs; they build the payload (with the canonical field) but make no network call.
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
json_out: bool = False,
|
|
74
|
+
):
|
|
75
|
+
"""Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO."""
|
|
76
|
+
settings, article, store = _prepare(
|
|
77
|
+
article_path, env_file=env_file, state_path=state_path
|
|
78
|
+
)
|
|
79
|
+
selected = [p.strip() for p in platforms.split(",")] if platforms else None
|
|
80
|
+
try:
|
|
81
|
+
summary = run_sync(
|
|
82
|
+
_syndicate_secondary(
|
|
83
|
+
article, settings=settings, store=store, platforms=selected
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
except ArticleError as e:
|
|
87
|
+
raise cw.CommandError(str(e)) from e
|
|
88
|
+
return _render(summary, json_out=json_out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render_dry_run(report: dict) -> str:
|
|
92
|
+
counts = ", ".join(f"{n} {k}" for k, n in report["counts"].items()) or "empty"
|
|
93
|
+
lines = [
|
|
94
|
+
f"Dry run (nothing sent): {report['title']!r} -> slug {report['slug']}",
|
|
95
|
+
f" converts to: {counts}",
|
|
96
|
+
*(f" warning: {w}" for w in report["warnings"]),
|
|
97
|
+
]
|
|
98
|
+
return "\n".join(lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def draft_substack(
|
|
102
|
+
article_path: str,
|
|
103
|
+
*,
|
|
104
|
+
env_file: Optional[str] = None,
|
|
105
|
+
state_path: Optional[str] = None,
|
|
106
|
+
tables: Optional[str] = None,
|
|
107
|
+
dry_run: bool = False,
|
|
108
|
+
json_out: bool = False,
|
|
109
|
+
):
|
|
110
|
+
"""Create an UNPUBLISHED Substack draft from a Markdown file; never publishes."""
|
|
111
|
+
settings, article, store = _prepare(
|
|
112
|
+
article_path, env_file=env_file, state_path=state_path
|
|
113
|
+
)
|
|
114
|
+
config = article.platforms.substack or SubstackConfig()
|
|
115
|
+
if tables is not None:
|
|
116
|
+
config = config.model_copy(update={"tables": tables})
|
|
117
|
+
try:
|
|
118
|
+
if dry_run:
|
|
119
|
+
report = _substack.dry_run(article, config=config)
|
|
120
|
+
return json.dumps(report, indent=2) if json_out else _render_dry_run(report)
|
|
121
|
+
platforms = article.platforms.model_copy(update={SUBSTACK: config})
|
|
122
|
+
article = article.model_copy(update={"platforms": platforms})
|
|
123
|
+
earlier = store.get(article.slug, {}).get("primary", {}).get("detail", {})
|
|
124
|
+
summary = run_sync(_publish_primary(article, settings=settings, store=store))
|
|
125
|
+
except ArticleError as e:
|
|
126
|
+
raise cw.CommandError(str(e)) from e
|
|
127
|
+
if not summary.ok:
|
|
128
|
+
raise cw.CommandError(summary.failures[0].error)
|
|
129
|
+
if json_out:
|
|
130
|
+
return _render(summary, json_out=True)
|
|
131
|
+
detail = summary.by_platform[SUBSTACK].detail
|
|
132
|
+
lines = [summary.render(), f"Edit the draft: {detail.get('edit_url')}"]
|
|
133
|
+
lines += [f" warning: {w}" for w in detail.get("warnings", ())]
|
|
134
|
+
if earlier.get("edit_url"):
|
|
135
|
+
lines.append(
|
|
136
|
+
f"Note: an earlier draft of this article is still on Substack "
|
|
137
|
+
f"({earlier['edit_url']}); delete it if this one replaces it."
|
|
138
|
+
)
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
#: SSOT list of dispatchable commands (``cw`` maps ``_`` in names to ``-``).
|
|
143
|
+
_dispatch_funcs = [publish_primary, syndicate_secondary, draft_substack]
|
|
144
|
+
|
|
145
|
+
#: Per-parameter ``add_argument`` particulars the signature cannot carry -- here, the
|
|
146
|
+
#: one ``help`` string that used to ride on an ``@argh.arg`` decorator. ``cw`` reads
|
|
147
|
+
#: ``config``, never decorator metadata, so this is where such declarations live now.
|
|
148
|
+
_dispatch_config = {
|
|
149
|
+
command: {"article_path": {"help": "Path to the article JSON file"}}
|
|
150
|
+
for command in ("publish-primary", "syndicate-secondary")
|
|
151
|
+
}
|
|
152
|
+
_dispatch_config["draft-substack"] = {
|
|
153
|
+
"article_path": {"help": "Path to the essay Markdown file (or article JSON)"},
|
|
154
|
+
"tables": {
|
|
155
|
+
"choices": ["error", "code"],
|
|
156
|
+
"help": "Markdown tables: refuse (error, the default) or keep as code blocks",
|
|
157
|
+
},
|
|
158
|
+
"dry_run": {"help": "Check and convert only; no credentials, nothing sent"},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def mk_parser() -> argparse.ArgumentParser:
|
|
163
|
+
"""Build the CLI parser -- a plain :class:`argparse.ArgumentParser`, no I/O.
|
|
164
|
+
|
|
165
|
+
The single place the command list and its per-parameter declarations meet, so a
|
|
166
|
+
test that inspects the grammar inspects the very parser :func:`main` dispatches.
|
|
167
|
+
"""
|
|
168
|
+
return cw.mk_parser(_dispatch_funcs, config=_dispatch_config)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main() -> int:
|
|
172
|
+
"""Dispatch a CLI command and return its exit code.
|
|
173
|
+
|
|
174
|
+
``cw.run`` offers the parser to ``argcomplete`` itself (the
|
|
175
|
+
``# PYTHON_ARGCOMPLETE_OK`` marker on line 1 is what the shell hook looks for),
|
|
176
|
+
so there is no hand-written completion block to keep in step.
|
|
177
|
+
"""
|
|
178
|
+
return cw.run(mk_parser())
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
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
|
+
)
|