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.
Files changed (34) hide show
  1. {article-0.1.2 → article-0.1.4}/.env.example +9 -12
  2. {article-0.1.2 → article-0.1.4}/PKG-INFO +69 -14
  3. {article-0.1.2 → article-0.1.4}/README.md +61 -9
  4. article-0.1.4/article/__main__.py +182 -0
  5. article-0.1.4/article/adapters/medium.py +66 -0
  6. article-0.1.4/article/adapters/substack.py +495 -0
  7. {article-0.1.2 → article-0.1.4}/article/base.py +34 -2
  8. {article-0.1.2 → article-0.1.4}/article/config.py +49 -19
  9. article-0.1.4/article/md_source.py +135 -0
  10. article-0.1.4/article/misc/CHANGELOG.md +52 -0
  11. {article-0.1.2 → article-0.1.4}/examples/article.example.json +1 -1
  12. {article-0.1.2 → article-0.1.4}/pyproject.toml +13 -5
  13. article-0.1.4/tests/conftest.py +65 -0
  14. article-0.1.4/tests/test_cli.py +192 -0
  15. {article-0.1.2 → article-0.1.4}/tests/test_pipeline.py +64 -11
  16. article-0.1.4/tests/test_substack.py +364 -0
  17. article-0.1.4/tests/test_substack_live.py +52 -0
  18. article-0.1.2/article/__main__.py +0 -106
  19. article-0.1.2/article/adapters/medium.py +0 -126
  20. article-0.1.2/article/adapters/substack.py +0 -118
  21. article-0.1.2/article/misc/CHANGELOG.md +0 -26
  22. {article-0.1.2 → article-0.1.4}/.editorconfig +0 -0
  23. {article-0.1.2 → article-0.1.4}/.gitattributes +0 -0
  24. {article-0.1.2 → article-0.1.4}/.github/workflows/ci.yml +0 -0
  25. {article-0.1.2 → article-0.1.4}/.gitignore +0 -0
  26. {article-0.1.2 → article-0.1.4}/LICENSE +0 -0
  27. {article-0.1.2 → article-0.1.4}/article/__init__.py +0 -0
  28. {article-0.1.2 → article-0.1.4}/article/adapters/__init__.py +0 -0
  29. {article-0.1.2 → article-0.1.4}/article/adapters/dev_to.py +0 -0
  30. {article-0.1.2 → article-0.1.4}/article/adapters/hashnode.py +0 -0
  31. {article-0.1.2 → article-0.1.4}/article/engine.py +0 -0
  32. {article-0.1.2 → article-0.1.4}/article/registry.py +0 -0
  33. {article-0.1.2 → article-0.1.4}/article/state.py +0 -0
  34. {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
- # 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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: article
3
- Version: 0.1.2
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: argh
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: playwright; extra == 'substack'
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, `![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.
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
+ ![The tower of Babel](img/babel.jpg "Bruegel, The Tower of Babel (1563)")
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": { "publish_as_draft": false },
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` | `argh` dispatch of the two commands |
215
+ | CLI | `article/__main__.py` | `cw` dispatch of the two commands |
162
216
 
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`).
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, `![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; 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": { "publish_as_draft": false },
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` | `argh` dispatch of the two commands |
174
+ | CLI | `article/__main__.py` | `cw` dispatch of the two commands |
124
175
 
125
- > **Status:** the layers above are complete and tested; each adapter's actual
126
- > network / Playwright call is a clearly-marked `TODO` stub that returns a typed
127
- > placeholder result. Payload field names are verified against each platform's
128
- > live API (`canonicalUrl` / `canonical_url` / `originalArticleURL`).
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
+ )