article 0.0.1__tar.gz → 0.1.2__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/.editorconfig +17 -0
- article-0.1.2/.env.example +35 -0
- article-0.1.2/PKG-INFO +166 -0
- article-0.1.2/README.md +128 -0
- article-0.1.2/article/__init__.py +141 -0
- article-0.1.2/article/__main__.py +106 -0
- article-0.1.2/article/adapters/__init__.py +17 -0
- article-0.1.2/article/adapters/dev_to.py +122 -0
- article-0.1.2/article/adapters/hashnode.py +152 -0
- article-0.1.2/article/adapters/medium.py +126 -0
- article-0.1.2/article/adapters/substack.py +118 -0
- article-0.1.2/article/base.py +256 -0
- article-0.1.2/article/config.py +326 -0
- article-0.1.2/article/engine.py +264 -0
- article-0.1.2/article/misc/CHANGELOG.md +26 -0
- article-0.1.2/article/registry.py +86 -0
- article-0.1.2/article/state.py +122 -0
- article-0.1.2/article/util.py +166 -0
- article-0.1.2/examples/article.example.json +16 -0
- {article-0.0.1 → article-0.1.2}/pyproject.toml +41 -5
- article-0.1.2/tests/test_pipeline.py +205 -0
- article-0.0.1/PKG-INFO +0 -27
- article-0.0.1/README.md +0 -7
- article-0.0.1/article/__init__.py +0 -1
- {article-0.0.1 → article-0.1.2}/.gitattributes +0 -0
- {article-0.0.1 → article-0.1.2}/.github/workflows/ci.yml +0 -0
- {article-0.0.1 → article-0.1.2}/.gitignore +0 -0
- {article-0.0.1 → article-0.1.2}/LICENSE +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
root = true
|
|
2
|
+
|
|
3
|
+
[*]
|
|
4
|
+
charset = utf-8
|
|
5
|
+
end_of_line = lf
|
|
6
|
+
insert_final_newline = true
|
|
7
|
+
trim_trailing_whitespace = true
|
|
8
|
+
|
|
9
|
+
[*.{py,toml,yml,yaml}]
|
|
10
|
+
indent_style = space
|
|
11
|
+
indent_size = 4
|
|
12
|
+
|
|
13
|
+
[*.md]
|
|
14
|
+
trim_trailing_whitespace = false
|
|
15
|
+
|
|
16
|
+
[Makefile]
|
|
17
|
+
indent_style = tab
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# article — configuration & secrets (copy to `.env`; never commit the real .env)
|
|
2
|
+
# Loaded by article.config.Settings via pydantic-settings.
|
|
3
|
+
# Every platform is optional: omit a platform's secrets to skip it.
|
|
4
|
+
|
|
5
|
+
# --- Substack (Phase 1, primary) ---------------------------------------------
|
|
6
|
+
# Public URL of your publication; the canonical SSOT URL is derived from it.
|
|
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
|
|
10
|
+
SUBSTACK_PASSWORD=
|
|
11
|
+
# Where the Playwright storage_state (cookies/session) is persisted/reused.
|
|
12
|
+
SUBSTACK_SESSION_PATH=.article/substack_session.json
|
|
13
|
+
|
|
14
|
+
# --- 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=
|
|
19
|
+
|
|
20
|
+
# --- Dev.to / Forem (Phase 2) ------------------------------------------------
|
|
21
|
+
# Generate at https://dev.to/settings/extensions ("DEV API Keys").
|
|
22
|
+
DEVTO_API_KEY=
|
|
23
|
+
|
|
24
|
+
# --- Hashnode (Phase 2) ------------------------------------------------------
|
|
25
|
+
# Personal Access Token from https://hashnode.com/settings/developer
|
|
26
|
+
HASHNODE_TOKEN=
|
|
27
|
+
# Publication ObjectId (query `publication(host: "...") { id }`).
|
|
28
|
+
HASHNODE_PUBLICATION_ID=
|
|
29
|
+
|
|
30
|
+
# --- Pipeline ----------------------------------------------------------------
|
|
31
|
+
# JSON state store; the canonical_url SSOT lives here, keyed by article slug.
|
|
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
|
article-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: article
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Tools to semi-automate the publication of articles
|
|
5
|
+
Project-URL: Homepage, https://github.com/thorwhalen/article
|
|
6
|
+
Project-URL: Repository, https://github.com/thorwhalen/article
|
|
7
|
+
Project-URL: Documentation, https://thorwhalen.github.io/article
|
|
8
|
+
Author: Thor Whalen
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,canonical-url,dev.to,hashnode,medium,publishing,seo,substack,syndication
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: argh
|
|
21
|
+
Requires-Dist: httpx
|
|
22
|
+
Requires-Dist: pydantic-settings>=2
|
|
23
|
+
Requires-Dist: pydantic>=2
|
|
24
|
+
Requires-Dist: python-dotenv
|
|
25
|
+
Provides-Extra: completion
|
|
26
|
+
Requires-Dist: argcomplete; extra == 'completion'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: playwright; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: docs
|
|
33
|
+
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
34
|
+
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
35
|
+
Provides-Extra: substack
|
|
36
|
+
Requires-Dist: playwright; extra == 'substack'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# article
|
|
40
|
+
|
|
41
|
+
Tools to semi-automate the publication of articles.
|
|
42
|
+
|
|
43
|
+
Write an article **once** as a JSON file. Publish it to a **primary** platform
|
|
44
|
+
(Substack), then **syndicate** it to secondary platforms (Medium, Dev.to,
|
|
45
|
+
Hashnode) — each pointing its canonical URL back to the primary, so you get
|
|
46
|
+
cross-platform reach without an SEO duplicate-content penalty.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install article
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## The single source of truth
|
|
53
|
+
|
|
54
|
+
The Substack public URL captured in phase 1 is the SSOT. It is stored
|
|
55
|
+
(keyed by article slug) and injected as `canonical_url` into every secondary
|
|
56
|
+
platform in phase 2, so each emits `<link rel="canonical" href="…">` pointing
|
|
57
|
+
back at the original.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
author.json ──► publish-primary ──► Substack ──► live URL ─┐
|
|
61
|
+
│ stored as canonical_url
|
|
62
|
+
▼ (pipeline_state.json, by slug)
|
|
63
|
+
syndicate-secondary ──► Medium / Dev.to / Hashnode
|
|
64
|
+
each canonical → Substack URL
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The two phases are deliberately separate commands so you can syndicate later
|
|
68
|
+
(e.g. `+3 days`) on your own schedule.
|
|
69
|
+
|
|
70
|
+
## Two-command flow
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# 1. Author once, then publish to the primary (records the canonical URL):
|
|
74
|
+
python -m article publish-primary article.json
|
|
75
|
+
|
|
76
|
+
# 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
|
|
77
|
+
python -m article syndicate-secondary article.json
|
|
78
|
+
|
|
79
|
+
# Subset / machine-readable output:
|
|
80
|
+
python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
After `pip install`, the `article` console script is equivalent
|
|
84
|
+
(`article publish-primary article.json`).
|
|
85
|
+
|
|
86
|
+
## The article JSON
|
|
87
|
+
|
|
88
|
+
One standardized file is the single source of truth for content **and** which
|
|
89
|
+
platforms to publish to — a platform is published only if it appears under
|
|
90
|
+
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"title": "Designing an SSOT Publishing Pipeline",
|
|
95
|
+
"slug": "ssot-publishing-pipeline",
|
|
96
|
+
"content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
|
|
97
|
+
"tags": ["python", "automation", "seo"],
|
|
98
|
+
"platforms": {
|
|
99
|
+
"substack": { "publish_as_draft": false },
|
|
100
|
+
"medium": { "publish_as_draft": true },
|
|
101
|
+
"dev_to": { "publish_as_draft": true },
|
|
102
|
+
"hashnode": { "publish_as_draft": true }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
Secrets and settings load from `.env` (see
|
|
110
|
+
[`.env.example`](.env.example)) — copy it and fill in what you use:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cp .env.example .env
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Every platform is optional; omit a platform's secrets (or its `platforms`
|
|
117
|
+
entry) to skip it.
|
|
118
|
+
|
|
119
|
+
## Using it from Python
|
|
120
|
+
|
|
121
|
+
The CLI is a thin wrapper over the same functions, so everything is callable
|
|
122
|
+
programmatically:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import asyncio
|
|
126
|
+
from article import load_article, publish_primary, syndicate_secondary
|
|
127
|
+
|
|
128
|
+
art = load_article("article.json")
|
|
129
|
+
asyncio.run(publish_primary(art)) # phase 1 → records canonical_url
|
|
130
|
+
asyncio.run(syndicate_secondary(art)) # phase 2 → canonical SEO everywhere
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Adding a platform
|
|
134
|
+
|
|
135
|
+
Adapters are registered in an open-closed registry. To add a platform, write
|
|
136
|
+
one async function satisfying the `PublishAdapter` protocol and register it —
|
|
137
|
+
no core changes:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from article import register_adapter, PublishResult
|
|
141
|
+
from article.util import require_canonical_url
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@register_adapter("my_platform")
|
|
145
|
+
async def publish(article, *, canonical_url, config, secrets):
|
|
146
|
+
require_canonical_url("my_platform", canonical_url) # SSOT is never optional
|
|
147
|
+
# … call the platform's API, injecting canonical_url …
|
|
148
|
+
return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Architecture
|
|
152
|
+
|
|
153
|
+
| Layer | Module | Responsibility |
|
|
154
|
+
|------|--------|----------------|
|
|
155
|
+
| Schema + config | `article/config.py` | Pydantic `Article` model, `.env` `Settings`, `load_article` (friendly errors) |
|
|
156
|
+
| Vocabulary | `article/base.py` | `PublishResult`, `RunSummary`, platform names, exceptions |
|
|
157
|
+
| State store | `article/state.py` | `MutableMapping` facade over JSON, keyed by slug (the SSOT) |
|
|
158
|
+
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
159
|
+
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
160
|
+
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
161
|
+
| CLI | `article/__main__.py` | `argh` dispatch of the two commands |
|
|
162
|
+
|
|
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`).
|
article-0.1.2/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The single source of truth
|
|
15
|
+
|
|
16
|
+
The Substack public URL captured in phase 1 is the SSOT. It is stored
|
|
17
|
+
(keyed by article slug) and injected as `canonical_url` into every secondary
|
|
18
|
+
platform in phase 2, so each emits `<link rel="canonical" href="…">` pointing
|
|
19
|
+
back at the original.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
author.json ──► publish-primary ──► Substack ──► live URL ─┐
|
|
23
|
+
│ stored as canonical_url
|
|
24
|
+
▼ (pipeline_state.json, by slug)
|
|
25
|
+
syndicate-secondary ──► Medium / Dev.to / Hashnode
|
|
26
|
+
each canonical → Substack URL
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The two phases are deliberately separate commands so you can syndicate later
|
|
30
|
+
(e.g. `+3 days`) on your own schedule.
|
|
31
|
+
|
|
32
|
+
## Two-command flow
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 1. Author once, then publish to the primary (records the canonical URL):
|
|
36
|
+
python -m article publish-primary article.json
|
|
37
|
+
|
|
38
|
+
# 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
|
|
39
|
+
python -m article syndicate-secondary article.json
|
|
40
|
+
|
|
41
|
+
# Subset / machine-readable output:
|
|
42
|
+
python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
After `pip install`, the `article` console script is equivalent
|
|
46
|
+
(`article publish-primary article.json`).
|
|
47
|
+
|
|
48
|
+
## The article JSON
|
|
49
|
+
|
|
50
|
+
One standardized file is the single source of truth for content **and** which
|
|
51
|
+
platforms to publish to — a platform is published only if it appears under
|
|
52
|
+
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"title": "Designing an SSOT Publishing Pipeline",
|
|
57
|
+
"slug": "ssot-publishing-pipeline",
|
|
58
|
+
"content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
|
|
59
|
+
"tags": ["python", "automation", "seo"],
|
|
60
|
+
"platforms": {
|
|
61
|
+
"substack": { "publish_as_draft": false },
|
|
62
|
+
"medium": { "publish_as_draft": true },
|
|
63
|
+
"dev_to": { "publish_as_draft": true },
|
|
64
|
+
"hashnode": { "publish_as_draft": true }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Secrets and settings load from `.env` (see
|
|
72
|
+
[`.env.example`](.env.example)) — copy it and fill in what you use:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cp .env.example .env
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every platform is optional; omit a platform's secrets (or its `platforms`
|
|
79
|
+
entry) to skip it.
|
|
80
|
+
|
|
81
|
+
## Using it from Python
|
|
82
|
+
|
|
83
|
+
The CLI is a thin wrapper over the same functions, so everything is callable
|
|
84
|
+
programmatically:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import asyncio
|
|
88
|
+
from article import load_article, publish_primary, syndicate_secondary
|
|
89
|
+
|
|
90
|
+
art = load_article("article.json")
|
|
91
|
+
asyncio.run(publish_primary(art)) # phase 1 → records canonical_url
|
|
92
|
+
asyncio.run(syndicate_secondary(art)) # phase 2 → canonical SEO everywhere
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Adding a platform
|
|
96
|
+
|
|
97
|
+
Adapters are registered in an open-closed registry. To add a platform, write
|
|
98
|
+
one async function satisfying the `PublishAdapter` protocol and register it —
|
|
99
|
+
no core changes:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from article import register_adapter, PublishResult
|
|
103
|
+
from article.util import require_canonical_url
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@register_adapter("my_platform")
|
|
107
|
+
async def publish(article, *, canonical_url, config, secrets):
|
|
108
|
+
require_canonical_url("my_platform", canonical_url) # SSOT is never optional
|
|
109
|
+
# … call the platform's API, injecting canonical_url …
|
|
110
|
+
return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Architecture
|
|
114
|
+
|
|
115
|
+
| Layer | Module | Responsibility |
|
|
116
|
+
|------|--------|----------------|
|
|
117
|
+
| Schema + config | `article/config.py` | Pydantic `Article` model, `.env` `Settings`, `load_article` (friendly errors) |
|
|
118
|
+
| Vocabulary | `article/base.py` | `PublishResult`, `RunSummary`, platform names, exceptions |
|
|
119
|
+
| State store | `article/state.py` | `MutableMapping` facade over JSON, keyed by slug (the SSOT) |
|
|
120
|
+
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
|
|
121
|
+
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
|
|
122
|
+
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
|
|
123
|
+
| CLI | `article/__main__.py` | `argh` dispatch of the two commands |
|
|
124
|
+
|
|
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`).
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""article — semi-automate single-source-of-truth article publishing.
|
|
2
|
+
|
|
3
|
+
Author an article once as a JSON file, publish it to a **primary** platform
|
|
4
|
+
(Substack), then **syndicate** it to secondaries (Medium, Dev.to, Hashnode)
|
|
5
|
+
with correct canonical-URL SEO. The Substack public URL captured in phase 1 is
|
|
6
|
+
the single source of truth (SSOT) injected as ``canonical_url`` into every
|
|
7
|
+
secondary in phase 2.
|
|
8
|
+
|
|
9
|
+
Quick start (Python)::
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from article import load_article, publish_primary, syndicate_secondary
|
|
13
|
+
|
|
14
|
+
art = load_article("article.json")
|
|
15
|
+
asyncio.run(publish_primary(art)) # phase 1 -> records canonical_url
|
|
16
|
+
asyncio.run(syndicate_secondary(art)) # phase 2 -> canonical SEO everywhere
|
|
17
|
+
|
|
18
|
+
Or via the CLI::
|
|
19
|
+
|
|
20
|
+
python -m article publish-primary article.json
|
|
21
|
+
python -m article syndicate-secondary article.json
|
|
22
|
+
|
|
23
|
+
The public surface below is curated; everything else is an implementation
|
|
24
|
+
detail. Importing this package registers the built-in platform adapters.
|
|
25
|
+
|
|
26
|
+
>>> from article import load_article, available_adapters
|
|
27
|
+
>>> load_article({"title": "T", "slug": "t", "content_markdown": "x"}).slug
|
|
28
|
+
't'
|
|
29
|
+
>>> available_adapters()
|
|
30
|
+
('dev_to', 'hashnode', 'medium', 'substack')
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
# Vocabulary / result types / exceptions
|
|
36
|
+
from .base import (
|
|
37
|
+
ALL_PLATFORMS,
|
|
38
|
+
DEV_TO,
|
|
39
|
+
HASHNODE,
|
|
40
|
+
MEDIUM,
|
|
41
|
+
PRIMARY_PLATFORM,
|
|
42
|
+
SECONDARY_PLATFORMS,
|
|
43
|
+
SUBSTACK,
|
|
44
|
+
AdapterError,
|
|
45
|
+
ArticleError,
|
|
46
|
+
ArticleValidationError,
|
|
47
|
+
CanonicalUrlMissing,
|
|
48
|
+
ConfigError,
|
|
49
|
+
Phase,
|
|
50
|
+
PlatformName,
|
|
51
|
+
PublishResult,
|
|
52
|
+
PublishState,
|
|
53
|
+
RunSummary,
|
|
54
|
+
UnknownPlatformError,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Domain schema + config facade
|
|
58
|
+
from .config import (
|
|
59
|
+
Article,
|
|
60
|
+
DevToConfig,
|
|
61
|
+
HashnodeConfig,
|
|
62
|
+
MediumConfig,
|
|
63
|
+
PlatformConfigs,
|
|
64
|
+
Settings,
|
|
65
|
+
SubstackConfig,
|
|
66
|
+
load_article,
|
|
67
|
+
load_settings,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# State store (MutableMapping facade)
|
|
71
|
+
from .state import JsonStateStore, default_state_store
|
|
72
|
+
|
|
73
|
+
# Adapter Protocol + open-closed registry
|
|
74
|
+
from .registry import (
|
|
75
|
+
PublishAdapter,
|
|
76
|
+
available_adapters,
|
|
77
|
+
get_adapter,
|
|
78
|
+
is_registered,
|
|
79
|
+
register_adapter,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Orchestration engine + module-level facade
|
|
83
|
+
from .engine import (
|
|
84
|
+
PipelineEngine,
|
|
85
|
+
build_engine,
|
|
86
|
+
publish_primary,
|
|
87
|
+
syndicate_secondary,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Importing the adapters package registers all built-in adapters.
|
|
91
|
+
from . import adapters # noqa: F401 (side-effect: populate the registry)
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
# domain
|
|
95
|
+
"Article",
|
|
96
|
+
"PlatformConfigs",
|
|
97
|
+
"SubstackConfig",
|
|
98
|
+
"MediumConfig",
|
|
99
|
+
"DevToConfig",
|
|
100
|
+
"HashnodeConfig",
|
|
101
|
+
"load_article",
|
|
102
|
+
# config / settings
|
|
103
|
+
"Settings",
|
|
104
|
+
"load_settings",
|
|
105
|
+
# results / vocabulary
|
|
106
|
+
"PublishResult",
|
|
107
|
+
"PublishState",
|
|
108
|
+
"RunSummary",
|
|
109
|
+
"Phase",
|
|
110
|
+
"PlatformName",
|
|
111
|
+
"PRIMARY_PLATFORM",
|
|
112
|
+
"SECONDARY_PLATFORMS",
|
|
113
|
+
"ALL_PLATFORMS",
|
|
114
|
+
"SUBSTACK",
|
|
115
|
+
"MEDIUM",
|
|
116
|
+
"DEV_TO",
|
|
117
|
+
"HASHNODE",
|
|
118
|
+
# errors
|
|
119
|
+
"ArticleError",
|
|
120
|
+
"ArticleValidationError",
|
|
121
|
+
"ConfigError",
|
|
122
|
+
"AdapterError",
|
|
123
|
+
"UnknownPlatformError",
|
|
124
|
+
"CanonicalUrlMissing",
|
|
125
|
+
# state
|
|
126
|
+
"JsonStateStore",
|
|
127
|
+
"default_state_store",
|
|
128
|
+
# registry
|
|
129
|
+
"PublishAdapter",
|
|
130
|
+
"register_adapter",
|
|
131
|
+
"get_adapter",
|
|
132
|
+
"available_adapters",
|
|
133
|
+
"is_registered",
|
|
134
|
+
# engine
|
|
135
|
+
"PipelineEngine",
|
|
136
|
+
"build_engine",
|
|
137
|
+
"publish_primary",
|
|
138
|
+
"syndicate_secondary",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""Command-line entry point for the ``article`` publishing pipeline.
|
|
3
|
+
|
|
4
|
+
Two commands, mirroring the two phases — both thin wrappers over the very same
|
|
5
|
+
:mod:`article.engine` functions the Python API exposes (dispatch-to-interface:
|
|
6
|
+
one implementation, many front-ends)::
|
|
7
|
+
|
|
8
|
+
python -m article publish-primary article.json
|
|
9
|
+
python -m article syndicate-secondary article.json
|
|
10
|
+
|
|
11
|
+
Common options: ``--env-file`` (where to read secrets), ``--state-path``
|
|
12
|
+
(the SSOT state file), ``--json-out`` (machine-readable summary). Phase 2 also
|
|
13
|
+
takes ``--platforms medium,dev_to`` to syndicate a subset.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import argh
|
|
22
|
+
|
|
23
|
+
from .base import ArticleError, RunSummary
|
|
24
|
+
from .config import load_article, load_settings
|
|
25
|
+
from .engine import publish_primary as _publish_primary
|
|
26
|
+
from .engine import syndicate_secondary as _syndicate_secondary
|
|
27
|
+
from .state import JsonStateStore
|
|
28
|
+
from .util import run_sync
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _render(summary: RunSummary, *, json_out: bool) -> str:
|
|
32
|
+
return json.dumps(summary.as_record(), indent=2) if json_out else summary.render()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _prepare(article_path: str, *, env_file: Optional[str], state_path: Optional[str]):
|
|
36
|
+
"""Shared setup: load settings + article + state store (with friendly errors)."""
|
|
37
|
+
try:
|
|
38
|
+
overrides = {"state_path": state_path} if state_path else {}
|
|
39
|
+
settings = load_settings(env_file=env_file, **overrides)
|
|
40
|
+
article = load_article(article_path)
|
|
41
|
+
except ArticleError as e:
|
|
42
|
+
raise argh.CommandError(str(e)) from e
|
|
43
|
+
store = JsonStateStore(settings.state_path)
|
|
44
|
+
return settings, article, store
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@argh.arg("article_path", help="Path to the article JSON file")
|
|
48
|
+
def publish_primary(
|
|
49
|
+
article_path: str,
|
|
50
|
+
*,
|
|
51
|
+
env_file: Optional[str] = None,
|
|
52
|
+
state_path: Optional[str] = None,
|
|
53
|
+
json_out: bool = False,
|
|
54
|
+
):
|
|
55
|
+
"""Phase 1 — publish to the primary platform (Substack) and record canonical_url."""
|
|
56
|
+
settings, article, store = _prepare(
|
|
57
|
+
article_path, env_file=env_file, state_path=state_path
|
|
58
|
+
)
|
|
59
|
+
summary = run_sync(_publish_primary(article, settings=settings, store=store))
|
|
60
|
+
return _render(summary, json_out=json_out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@argh.arg("article_path", help="Path to the article JSON file")
|
|
64
|
+
def syndicate_secondary(
|
|
65
|
+
article_path: str,
|
|
66
|
+
*,
|
|
67
|
+
env_file: Optional[str] = None,
|
|
68
|
+
state_path: Optional[str] = None,
|
|
69
|
+
platforms: Optional[str] = None,
|
|
70
|
+
json_out: bool = False,
|
|
71
|
+
):
|
|
72
|
+
"""Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO."""
|
|
73
|
+
settings, article, store = _prepare(
|
|
74
|
+
article_path, env_file=env_file, state_path=state_path
|
|
75
|
+
)
|
|
76
|
+
selected = [p.strip() for p in platforms.split(",")] if platforms else None
|
|
77
|
+
try:
|
|
78
|
+
summary = run_sync(
|
|
79
|
+
_syndicate_secondary(
|
|
80
|
+
article, settings=settings, store=store, platforms=selected
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
except ArticleError as e:
|
|
84
|
+
raise argh.CommandError(str(e)) from e
|
|
85
|
+
return _render(summary, json_out=json_out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: SSOT list of dispatchable commands (argh maps ``_`` in names to ``-``).
|
|
89
|
+
_dispatch_funcs = [publish_primary, syndicate_secondary]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
"""Assemble the argh parser and dispatch (with optional tab completion)."""
|
|
94
|
+
parser = argh.ArghParser()
|
|
95
|
+
parser.add_commands(_dispatch_funcs)
|
|
96
|
+
try:
|
|
97
|
+
import argcomplete
|
|
98
|
+
|
|
99
|
+
argcomplete.autocomplete(parser)
|
|
100
|
+
except ImportError:
|
|
101
|
+
pass
|
|
102
|
+
parser.dispatch()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Built-in platform adapters.
|
|
2
|
+
|
|
3
|
+
Importing this package has the side effect of registering every built-in
|
|
4
|
+
adapter in :mod:`article.registry`. The top-level :mod:`article` package
|
|
5
|
+
imports it, so the registry is populated as soon as ``article`` is imported.
|
|
6
|
+
|
|
7
|
+
To add a platform: drop a new module here that decorates an async function with
|
|
8
|
+
``@register_adapter("<name>")``, then import it below. No core code changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# Side-effect imports: each module registers its adapter on import.
|
|
14
|
+
from . import substack # noqa: F401
|
|
15
|
+
from . import medium # noqa: F401
|
|
16
|
+
from . import dev_to # noqa: F401
|
|
17
|
+
from . import hashnode # noqa: F401
|