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.
Files changed (34) hide show
  1. {article-0.1.3 → article-0.1.5}/.env.example +9 -12
  2. {article-0.1.3 → article-0.1.5}/PKG-INFO +71 -11
  3. article-0.1.5/README.md +182 -0
  4. {article-0.1.3 → article-0.1.5}/article/__init__.py +2 -0
  5. article-0.1.5/article/__main__.py +220 -0
  6. article-0.1.5/article/adapters/medium.py +66 -0
  7. article-0.1.5/article/adapters/substack.py +659 -0
  8. {article-0.1.3 → article-0.1.5}/article/base.py +42 -2
  9. {article-0.1.3 → article-0.1.5}/article/config.py +56 -19
  10. {article-0.1.3 → article-0.1.5}/article/engine.py +37 -2
  11. article-0.1.5/article/md_source.py +135 -0
  12. {article-0.1.3 → article-0.1.5}/article/misc/CHANGELOG.md +9 -0
  13. {article-0.1.3 → article-0.1.5}/examples/article.example.json +1 -1
  14. {article-0.1.3 → article-0.1.5}/pyproject.toml +15 -4
  15. article-0.1.5/tests/conftest.py +65 -0
  16. {article-0.1.3 → article-0.1.5}/tests/test_cli.py +16 -10
  17. {article-0.1.3 → article-0.1.5}/tests/test_pipeline.py +86 -11
  18. article-0.1.5/tests/test_substack.py +434 -0
  19. article-0.1.5/tests/test_substack_live.py +52 -0
  20. article-0.1.3/README.md +0 -128
  21. article-0.1.3/article/__main__.py +0 -119
  22. article-0.1.3/article/adapters/medium.py +0 -126
  23. article-0.1.3/article/adapters/substack.py +0 -118
  24. {article-0.1.3 → article-0.1.5}/.editorconfig +0 -0
  25. {article-0.1.3 → article-0.1.5}/.gitattributes +0 -0
  26. {article-0.1.3 → article-0.1.5}/.github/workflows/ci.yml +0 -0
  27. {article-0.1.3 → article-0.1.5}/.gitignore +0 -0
  28. {article-0.1.3 → article-0.1.5}/LICENSE +0 -0
  29. {article-0.1.3 → article-0.1.5}/article/adapters/__init__.py +0 -0
  30. {article-0.1.3 → article-0.1.5}/article/adapters/dev_to.py +0 -0
  31. {article-0.1.3 → article-0.1.5}/article/adapters/hashnode.py +0 -0
  32. {article-0.1.3 → article-0.1.5}/article/registry.py +0 -0
  33. {article-0.1.3 → article-0.1.5}/article/state.py +0 -0
  34. {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
- # Credentials are only needed when no saved browser session exists yet.
9
- SUBSTACK_EMAIL=you@example.com
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
- # Self-issued integration token: Settings > Security and apps > Integration tokens.
16
- # NOTE: Medium's API is deprecated but still works with a self-issued token.
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
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: playwright; extra == 'dev'
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: playwright; extra == 'substack'
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, `![alt](src "caption")` 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
+ ![The tower of Babel](img/babel.jpg "Bruegel, The Tower of Babel (1563)")
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": { "publish_as_draft": false },
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:** the layers above are complete and tested; each adapter's actual
164
- > network / Playwright call is a clearly-marked `TODO` stub that returns a typed
165
- > placeholder result. Payload field names are verified against each platform's
166
- > live API (`canonicalUrl` / `canonical_url` / `originalArticleURL`).
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.
@@ -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, `![alt](src "caption")` 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
+ ![The tower of Babel](img/babel.jpg "Bruegel, The Tower of Babel (1563)")
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
+ )