python-substack 0.1.25__tar.gz → 0.1.26__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.
- {python_substack-0.1.25 → python_substack-0.1.26}/PKG-INFO +58 -6
- {python_substack-0.1.25 → python_substack-0.1.26}/README.md +55 -3
- {python_substack-0.1.25 → python_substack-0.1.26}/pyproject.toml +5 -3
- {python_substack-0.1.25 → python_substack-0.1.26}/substack/__init__.py +2 -2
- python_substack-0.1.26/substack/cli.py +583 -0
- python_substack-0.1.25/substack/cli.py +0 -224
- {python_substack-0.1.25 → python_substack-0.1.26}/LICENSE +0 -0
- {python_substack-0.1.25 → python_substack-0.1.26}/substack/api.py +0 -0
- {python_substack-0.1.25 → python_substack-0.1.26}/substack/exceptions.py +0 -0
- {python_substack-0.1.25 → python_substack-0.1.26}/substack/mdrender.py +0 -0
- {python_substack-0.1.25 → python_substack-0.1.26}/substack/nodes.py +0 -0
- {python_substack-0.1.25 → python_substack-0.1.26}/substack/post.py +0 -0
- {python_substack-0.1.25 → python_substack-0.1.26}/substack_mcp/mcp_server.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary: A Python
|
|
3
|
+
Version: 0.1.26
|
|
4
|
+
Summary: A Python SDK and CLI for managing Substack publications and drafts.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Keywords: substack
|
|
7
|
+
Keywords: substack,substack-api,cli,newsletter,publishing,automation,mcp
|
|
8
8
|
Author: Paolo Mazza
|
|
9
9
|
Author-email: mazzapaolo2019@gmail.com
|
|
10
10
|
Requires-Python: >=3.10,<4.0
|
|
@@ -35,13 +35,20 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
|
|
36
36
|
# Python Substack
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
An unofficial Python SDK and CLI for managing [Substack](https://substack.com/) publications and drafts.
|
|
39
39
|
|
|
40
|
+
[](https://pypi.org/project/python-substack/)
|
|
41
|
+
[](https://pypi.org/project/python-substack/)
|
|
42
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
|
|
43
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
|
|
44
|
+
[](LICENSE)
|
|
40
45
|
[](https://pepy.tech/project/python-substack)
|
|
41
|
-

|
|
42
46
|
|
|
43
47
|
## Features
|
|
44
48
|
|
|
49
|
+
- Inspect authentication and publication status from the terminal.
|
|
50
|
+
- List publications and inspect, schedule, publish, or delete drafts.
|
|
51
|
+
- Use stable JSON output in scripts and automation.
|
|
45
52
|
- Create drafts and publish posts from Python.
|
|
46
53
|
- Convert Markdown into Substack's editor document format.
|
|
47
54
|
- Upload local images while rendering Markdown.
|
|
@@ -78,6 +85,49 @@ Use either `EMAIL` and `PASSWORD`, or cookie-based authentication with `COOKIES_
|
|
|
78
85
|
|
|
79
86
|
Newer Substack accounts may only have magic-link sign-in enabled. To set a password, sign out of Substack, choose "Sign in with password", then choose "Set a new password".
|
|
80
87
|
|
|
88
|
+
## CLI Operations
|
|
89
|
+
|
|
90
|
+
Check authentication, the selected publication, and subscriber count:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
substack status
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
List available publications or target one without changing `.env`:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
substack publications list
|
|
100
|
+
substack --publication-url https://example.substack.com drafts list
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
List and inspect drafts:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
substack drafts list --limit 10
|
|
107
|
+
substack drafts get 12345
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
|
|
114
|
+
substack drafts unschedule 12345
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
substack drafts publish 12345 --no-send
|
|
121
|
+
substack drafts delete 12345 --yes
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
substack --json drafts list
|
|
128
|
+
substack --cookies cookies.json --json status
|
|
129
|
+
```
|
|
130
|
+
|
|
81
131
|
## Quickstart
|
|
82
132
|
|
|
83
133
|
```python
|
|
@@ -113,7 +163,7 @@ print(result["draft"]["id"])
|
|
|
113
163
|
|
|
114
164
|
`create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
|
|
115
165
|
|
|
116
|
-
## CLI
|
|
166
|
+
## Content Publishing CLI
|
|
117
167
|
|
|
118
168
|
Check authentication without creating a draft:
|
|
119
169
|
|
|
@@ -373,6 +423,8 @@ pytest
|
|
|
373
423
|
|
|
374
424
|
Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
|
|
375
425
|
|
|
426
|
+
The CLI operations smoke test is separately opt-in. Set `RUN_SUBSTACK_CLI_E2E=1` to create, schedule, unschedule, inspect, and delete a disposable draft. It never publishes the draft.
|
|
427
|
+
|
|
376
428
|
Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
|
377
429
|
|
|
378
430
|
## Disclaimer
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
# Python Substack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An unofficial Python SDK and CLI for managing [Substack](https://substack.com/) publications and drafts.
|
|
4
4
|
|
|
5
|
+
[](https://pypi.org/project/python-substack/)
|
|
6
|
+
[](https://pypi.org/project/python-substack/)
|
|
7
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml)
|
|
9
|
+
[](LICENSE)
|
|
5
10
|
[](https://pepy.tech/project/python-substack)
|
|
6
|
-

|
|
7
11
|
|
|
8
12
|
## Features
|
|
9
13
|
|
|
14
|
+
- Inspect authentication and publication status from the terminal.
|
|
15
|
+
- List publications and inspect, schedule, publish, or delete drafts.
|
|
16
|
+
- Use stable JSON output in scripts and automation.
|
|
10
17
|
- Create drafts and publish posts from Python.
|
|
11
18
|
- Convert Markdown into Substack's editor document format.
|
|
12
19
|
- Upload local images while rendering Markdown.
|
|
@@ -43,6 +50,49 @@ Use either `EMAIL` and `PASSWORD`, or cookie-based authentication with `COOKIES_
|
|
|
43
50
|
|
|
44
51
|
Newer Substack accounts may only have magic-link sign-in enabled. To set a password, sign out of Substack, choose "Sign in with password", then choose "Set a new password".
|
|
45
52
|
|
|
53
|
+
## CLI Operations
|
|
54
|
+
|
|
55
|
+
Check authentication, the selected publication, and subscriber count:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
substack status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
List available publications or target one without changing `.env`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
substack publications list
|
|
65
|
+
substack --publication-url https://example.substack.com drafts list
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
List and inspect drafts:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
substack drafts list --limit 10
|
|
72
|
+
substack drafts get 12345
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Schedule with a timezone-aware ISO 8601 timestamp, or remove a schedule:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
substack drafts schedule 12345 --at 2026-08-01T09:00:00+03:00
|
|
79
|
+
substack drafts unschedule 12345
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Publishing and deletion prompt for confirmation. Use `--yes` for intentional non-interactive execution:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
substack drafts publish 12345 --no-send
|
|
86
|
+
substack drafts delete 12345 --yes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Global options must appear before the command. `--json` returns stable envelopes containing the raw Substack responses:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
substack --json drafts list
|
|
93
|
+
substack --cookies cookies.json --json status
|
|
94
|
+
```
|
|
95
|
+
|
|
46
96
|
## Quickstart
|
|
47
97
|
|
|
48
98
|
```python
|
|
@@ -78,7 +128,7 @@ print(result["draft"]["id"])
|
|
|
78
128
|
|
|
79
129
|
`create_draft_from_markdown` creates a draft by default. It only publishes when `publish=True` is passed.
|
|
80
130
|
|
|
81
|
-
## CLI
|
|
131
|
+
## Content Publishing CLI
|
|
82
132
|
|
|
83
133
|
Check authentication without creating a draft:
|
|
84
134
|
|
|
@@ -338,6 +388,8 @@ pytest
|
|
|
338
388
|
|
|
339
389
|
Live Substack tests are opt-in. Set `RUN_SUBSTACK_E2E=1` and configure credentials before running them.
|
|
340
390
|
|
|
391
|
+
The CLI operations smoke test is separately opt-in. Set `RUN_SUBSTACK_CLI_E2E=1` to create, schedule, unschedule, inspect, and delete a disposable draft. It never publishes the draft.
|
|
392
|
+
|
|
341
393
|
Release changes are tracked in [CHANGELOG.md](CHANGELOG.md).
|
|
342
394
|
|
|
343
395
|
## Disclaimer
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-substack"
|
|
3
|
-
version = "0.1.
|
|
4
|
-
description = "A Python
|
|
3
|
+
version = "0.1.26"
|
|
4
|
+
description = "A Python SDK and CLI for managing Substack publications and drafts."
|
|
5
5
|
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
packages = [
|
|
@@ -14,7 +14,7 @@ readme = "README.md"
|
|
|
14
14
|
repository = "https://github.com/ma2za/python-substack"
|
|
15
15
|
homepage = "https://github.com/ma2za/python-substack"
|
|
16
16
|
|
|
17
|
-
keywords = ["substack"]
|
|
17
|
+
keywords = ["substack", "substack-api", "cli", "newsletter", "publishing", "automation", "mcp"]
|
|
18
18
|
classifiers = [
|
|
19
19
|
"Development Status :: 3 - Alpha",
|
|
20
20
|
"Intended Audience :: Developers",
|
|
@@ -46,8 +46,10 @@ fastmcp = { version = "^3.1.1", optional = true }
|
|
|
46
46
|
mcp = ["fastmcp"]
|
|
47
47
|
|
|
48
48
|
[tool.poetry.group.dev.dependencies]
|
|
49
|
+
pytest = "^9.1.1"
|
|
49
50
|
|
|
50
51
|
[tool.poetry.scripts]
|
|
52
|
+
substack = "substack.cli:main"
|
|
51
53
|
substack-auth-check = "substack.cli:auth_check"
|
|
52
54
|
substack-publish-markdown = "substack.cli:publish_markdown"
|
|
53
55
|
substack-publish-yaml = "substack.cli:publish_yaml"
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.26"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
|
-
__description__ = "A Python
|
|
9
|
+
__description__ = "A Python SDK and CLI for managing Substack publications and drafts"
|
|
10
10
|
|
|
11
11
|
from .api import Api
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
from substack import Api, __version__
|
|
13
|
+
from substack.exceptions import SubstackAPIException, SubstackRequestException
|
|
14
|
+
from substack.post import Post
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CLIUsageError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _api_from_env(cookies_path=None, publication_url=None):
|
|
22
|
+
load_dotenv()
|
|
23
|
+
|
|
24
|
+
cookies_path = cookies_path or os.getenv("COOKIES_PATH")
|
|
25
|
+
cookies_string = os.getenv("COOKIES_STRING")
|
|
26
|
+
publication_url = publication_url or os.getenv("PUBLICATION_URL")
|
|
27
|
+
|
|
28
|
+
if cookies_path or cookies_string:
|
|
29
|
+
return Api(
|
|
30
|
+
cookies_path=cookies_path,
|
|
31
|
+
cookies_string=cookies_string,
|
|
32
|
+
publication_url=publication_url,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return Api(
|
|
36
|
+
email=os.getenv("EMAIL"),
|
|
37
|
+
password=os.getenv("PASSWORD"),
|
|
38
|
+
publication_url=publication_url,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _auth_method(cookies_path=None):
|
|
43
|
+
load_dotenv()
|
|
44
|
+
|
|
45
|
+
if cookies_path:
|
|
46
|
+
return "cookies_path"
|
|
47
|
+
if os.getenv("COOKIES_PATH"):
|
|
48
|
+
return "cookies_path"
|
|
49
|
+
if os.getenv("COOKIES_STRING"):
|
|
50
|
+
return "cookies_string"
|
|
51
|
+
if os.getenv("EMAIL") and os.getenv("PASSWORD"):
|
|
52
|
+
return "email_password"
|
|
53
|
+
return "unknown"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _title_from_markdown(markdown, fallback):
|
|
57
|
+
for line in markdown.splitlines():
|
|
58
|
+
if line.startswith("# "):
|
|
59
|
+
return line[2:].strip()
|
|
60
|
+
return fallback
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_result(result):
|
|
64
|
+
draft = result["draft"]
|
|
65
|
+
print(json.dumps({"draft_id": draft.get("id"), "draft": draft}, indent=2))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _print_json(value, stream=None):
|
|
69
|
+
print(json.dumps(value, indent=2, default=str), file=stream)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _display(value):
|
|
73
|
+
if value is None or value == "":
|
|
74
|
+
return "-"
|
|
75
|
+
return str(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_rows(headers, rows):
|
|
79
|
+
rows = [[_display(value) for value in row] for row in rows]
|
|
80
|
+
widths = [len(header) for header in headers]
|
|
81
|
+
for row in rows:
|
|
82
|
+
for index, value in enumerate(row):
|
|
83
|
+
widths[index] = max(widths[index], len(value))
|
|
84
|
+
|
|
85
|
+
print(
|
|
86
|
+
" ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
|
|
87
|
+
)
|
|
88
|
+
print(" ".join("-" * width for width in widths))
|
|
89
|
+
for row in rows:
|
|
90
|
+
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _identity(profile):
|
|
94
|
+
return (
|
|
95
|
+
profile.get("email")
|
|
96
|
+
or profile.get("name")
|
|
97
|
+
or profile.get("handle")
|
|
98
|
+
or str(profile.get("id", "unknown"))
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _selected_publication(api, publications):
|
|
103
|
+
for publication in publications:
|
|
104
|
+
publication_url = publication.get("publication_url")
|
|
105
|
+
if (
|
|
106
|
+
publication_url
|
|
107
|
+
and urljoin(publication_url, "api/v1") == api.publication_url
|
|
108
|
+
):
|
|
109
|
+
return publication
|
|
110
|
+
return {"publication_url": api.publication_url.removesuffix("/api/v1")}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_schedule(value):
|
|
114
|
+
normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
|
|
115
|
+
try:
|
|
116
|
+
scheduled_at = datetime.fromisoformat(normalized)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
raise CLIUsageError("--at must be a valid ISO 8601 timestamp") from exc
|
|
119
|
+
if scheduled_at.utcoffset() is None:
|
|
120
|
+
raise CLIUsageError("--at must include a timezone offset or Z")
|
|
121
|
+
return scheduled_at
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _confirm(action, target, yes, json_output):
|
|
125
|
+
if yes:
|
|
126
|
+
return
|
|
127
|
+
if json_output or not sys.stdin.isatty():
|
|
128
|
+
raise CLIUsageError(f"{action} requires --yes in non-interactive or JSON mode")
|
|
129
|
+
try:
|
|
130
|
+
confirmed = input(f"{action} draft {target}? [y/N] ").strip().lower()
|
|
131
|
+
except EOFError as exc:
|
|
132
|
+
raise CLIUsageError(f"{action} requires confirmation or --yes") from exc
|
|
133
|
+
if confirmed not in {"y", "yes"}:
|
|
134
|
+
raise CLIUsageError("Cancelled")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _redact(message):
|
|
138
|
+
redacted = str(message)
|
|
139
|
+
for name in ("EMAIL", "PASSWORD", "COOKIES_STRING"):
|
|
140
|
+
secret = os.getenv(name)
|
|
141
|
+
if secret:
|
|
142
|
+
redacted = redacted.replace(secret, "[redacted]")
|
|
143
|
+
return redacted
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _error_payload(exc):
|
|
147
|
+
if isinstance(exc, SubstackAPIException):
|
|
148
|
+
return {
|
|
149
|
+
"error": {
|
|
150
|
+
"type": "api_error",
|
|
151
|
+
"message": _redact(exc.message),
|
|
152
|
+
"status_code": exc.status_code,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if isinstance(exc, SubstackRequestException):
|
|
156
|
+
return {
|
|
157
|
+
"error": {
|
|
158
|
+
"type": "request_error",
|
|
159
|
+
"message": _redact(exc.message),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if isinstance(exc, OSError):
|
|
163
|
+
return {"error": {"type": "io_error", "message": _redact(exc)}}
|
|
164
|
+
return {"error": {"type": "configuration_error", "message": _redact(exc)}}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _print_error(exc, json_output):
|
|
168
|
+
payload = _error_payload(exc)
|
|
169
|
+
if json_output:
|
|
170
|
+
_print_json(payload, stream=sys.stderr)
|
|
171
|
+
else:
|
|
172
|
+
print(f"Error: {payload['error']['message']}", file=sys.stderr)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _print_usage_error(exc, json_output):
|
|
176
|
+
if json_output:
|
|
177
|
+
_print_json(
|
|
178
|
+
{"error": {"type": "usage_error", "message": str(exc)}},
|
|
179
|
+
stream=sys.stderr,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _status(api, args):
|
|
186
|
+
profile = api.get_user_profile()
|
|
187
|
+
publications = api.get_user_publications()
|
|
188
|
+
selected = _selected_publication(api, publications)
|
|
189
|
+
subscriber_count = api.get_publication_subscriber_count()
|
|
190
|
+
result = {
|
|
191
|
+
"status": {
|
|
192
|
+
"identity": _identity(profile),
|
|
193
|
+
"auth_method": _auth_method(args.cookies),
|
|
194
|
+
"subscriber_count": subscriber_count,
|
|
195
|
+
},
|
|
196
|
+
"profile": profile,
|
|
197
|
+
"publication": selected,
|
|
198
|
+
"publications": publications,
|
|
199
|
+
}
|
|
200
|
+
if args.json_output:
|
|
201
|
+
_print_json(result)
|
|
202
|
+
else:
|
|
203
|
+
print(f"Authenticated as: {result['status']['identity']}")
|
|
204
|
+
print(f"Auth method: {result['status']['auth_method']}")
|
|
205
|
+
print(f"Publication: {_display(selected.get('name'))}")
|
|
206
|
+
print(f"Publication URL: {_display(selected.get('publication_url'))}")
|
|
207
|
+
print(f"Subscribers: {subscriber_count}")
|
|
208
|
+
print(f"Available publications: {len(publications)}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _publications_list(api, args):
|
|
212
|
+
publications = api.get_user_publications()
|
|
213
|
+
if args.json_output:
|
|
214
|
+
_print_json({"publications": publications, "count": len(publications)})
|
|
215
|
+
else:
|
|
216
|
+
_print_rows(
|
|
217
|
+
["ID", "NAME", "SUBDOMAIN", "URL"],
|
|
218
|
+
[
|
|
219
|
+
[
|
|
220
|
+
publication.get("id"),
|
|
221
|
+
publication.get("name"),
|
|
222
|
+
publication.get("subdomain"),
|
|
223
|
+
publication.get("publication_url"),
|
|
224
|
+
]
|
|
225
|
+
for publication in publications
|
|
226
|
+
],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _drafts_list(api, args):
|
|
231
|
+
if args.offset < 0:
|
|
232
|
+
raise CLIUsageError("--offset must be zero or greater")
|
|
233
|
+
if args.limit < 1:
|
|
234
|
+
raise CLIUsageError("--limit must be greater than zero")
|
|
235
|
+
drafts = api.get_drafts(filter=args.filter, offset=args.offset, limit=args.limit)
|
|
236
|
+
if args.json_output:
|
|
237
|
+
_print_json(
|
|
238
|
+
{
|
|
239
|
+
"drafts": drafts,
|
|
240
|
+
"count": len(drafts),
|
|
241
|
+
"filter": args.filter,
|
|
242
|
+
"offset": args.offset,
|
|
243
|
+
"limit": args.limit,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
_print_rows(
|
|
248
|
+
["ID", "TITLE", "STATUS", "SCHEDULED"],
|
|
249
|
+
[
|
|
250
|
+
[
|
|
251
|
+
draft.get("id"),
|
|
252
|
+
draft.get("draft_title") or draft.get("title"),
|
|
253
|
+
draft.get("type") or draft.get("status") or "draft",
|
|
254
|
+
draft.get("post_date"),
|
|
255
|
+
]
|
|
256
|
+
for draft in drafts
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _drafts_get(api, args):
|
|
262
|
+
draft = api.get_draft(args.draft_id)
|
|
263
|
+
if args.json_output:
|
|
264
|
+
_print_json({"draft": draft})
|
|
265
|
+
else:
|
|
266
|
+
fields = [
|
|
267
|
+
("ID", draft.get("id")),
|
|
268
|
+
("Title", draft.get("draft_title") or draft.get("title")),
|
|
269
|
+
("Subtitle", draft.get("draft_subtitle") or draft.get("subtitle")),
|
|
270
|
+
("Status", draft.get("type") or draft.get("status") or "draft"),
|
|
271
|
+
("Slug", draft.get("slug")),
|
|
272
|
+
("Scheduled", draft.get("post_date")),
|
|
273
|
+
("Audience", draft.get("audience")),
|
|
274
|
+
]
|
|
275
|
+
for label, value in fields:
|
|
276
|
+
print(f"{label}: {_display(value)}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _drafts_schedule(api, args):
|
|
280
|
+
scheduled_at = _parse_schedule(args.at)
|
|
281
|
+
result = api.schedule_draft(args.draft_id, scheduled_at)
|
|
282
|
+
payload = {
|
|
283
|
+
"action": "schedule",
|
|
284
|
+
"draft_id": args.draft_id,
|
|
285
|
+
"scheduled_at": scheduled_at.isoformat(),
|
|
286
|
+
"result": result,
|
|
287
|
+
}
|
|
288
|
+
if args.json_output:
|
|
289
|
+
_print_json(payload)
|
|
290
|
+
else:
|
|
291
|
+
print(f"Scheduled draft {args.draft_id} for {scheduled_at.isoformat()}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _drafts_unschedule(api, args):
|
|
295
|
+
result = api.unschedule_draft(args.draft_id)
|
|
296
|
+
payload = {"action": "unschedule", "draft_id": args.draft_id, "result": result}
|
|
297
|
+
if args.json_output:
|
|
298
|
+
_print_json(payload)
|
|
299
|
+
else:
|
|
300
|
+
print(f"Unscheduled draft {args.draft_id}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _drafts_publish(api, args):
|
|
304
|
+
_confirm("Publish", args.draft_id, args.yes, args.json_output)
|
|
305
|
+
prepublish = api.prepublish_draft(args.draft_id)
|
|
306
|
+
result = api.publish_draft(
|
|
307
|
+
args.draft_id,
|
|
308
|
+
send=args.send,
|
|
309
|
+
share_automatically=args.share_automatically,
|
|
310
|
+
)
|
|
311
|
+
payload = {
|
|
312
|
+
"action": "publish",
|
|
313
|
+
"draft_id": args.draft_id,
|
|
314
|
+
"prepublish": prepublish,
|
|
315
|
+
"result": result,
|
|
316
|
+
}
|
|
317
|
+
if args.json_output:
|
|
318
|
+
_print_json(payload)
|
|
319
|
+
else:
|
|
320
|
+
delivery = "without email" if not args.send else "with email"
|
|
321
|
+
print(f"Published draft {args.draft_id} {delivery}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _drafts_delete(api, args):
|
|
325
|
+
_confirm("Delete", args.draft_id, args.yes, args.json_output)
|
|
326
|
+
result = api.delete_draft(args.draft_id)
|
|
327
|
+
payload = {"action": "delete", "draft_id": args.draft_id, "result": result}
|
|
328
|
+
if args.json_output:
|
|
329
|
+
_print_json(payload)
|
|
330
|
+
else:
|
|
331
|
+
print(f"Deleted draft {args.draft_id}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_parser():
|
|
335
|
+
parser = argparse.ArgumentParser(
|
|
336
|
+
prog="substack", description="Manage Substack publications and drafts."
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument("--cookies", help="Path to a cookies JSON file.")
|
|
339
|
+
parser.add_argument(
|
|
340
|
+
"--publication-url", help="Override PUBLICATION_URL for this command."
|
|
341
|
+
)
|
|
342
|
+
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
343
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
344
|
+
|
|
345
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
346
|
+
status = commands.add_parser(
|
|
347
|
+
"status", help="Show authentication and publication status."
|
|
348
|
+
)
|
|
349
|
+
status.set_defaults(handler=_status)
|
|
350
|
+
|
|
351
|
+
publications = commands.add_parser("publications", help="Manage publications.")
|
|
352
|
+
publication_commands = publications.add_subparsers(
|
|
353
|
+
dest="publication_command", required=True
|
|
354
|
+
)
|
|
355
|
+
publications_list = publication_commands.add_parser(
|
|
356
|
+
"list", help="List available publications."
|
|
357
|
+
)
|
|
358
|
+
publications_list.set_defaults(handler=_publications_list)
|
|
359
|
+
|
|
360
|
+
drafts = commands.add_parser("drafts", help="Manage drafts.")
|
|
361
|
+
draft_commands = drafts.add_subparsers(dest="draft_command", required=True)
|
|
362
|
+
|
|
363
|
+
drafts_list = draft_commands.add_parser("list", help="List drafts.")
|
|
364
|
+
drafts_list.add_argument("--filter", default="draft")
|
|
365
|
+
drafts_list.add_argument("--offset", type=int, default=0)
|
|
366
|
+
drafts_list.add_argument("--limit", type=int, default=25)
|
|
367
|
+
drafts_list.set_defaults(handler=_drafts_list)
|
|
368
|
+
|
|
369
|
+
drafts_get = draft_commands.add_parser("get", help="Inspect a draft.")
|
|
370
|
+
drafts_get.add_argument("draft_id", type=int)
|
|
371
|
+
drafts_get.set_defaults(handler=_drafts_get)
|
|
372
|
+
|
|
373
|
+
drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
|
|
374
|
+
drafts_schedule.add_argument("draft_id", type=int)
|
|
375
|
+
drafts_schedule.add_argument("--at", required=True)
|
|
376
|
+
drafts_schedule.set_defaults(handler=_drafts_schedule)
|
|
377
|
+
|
|
378
|
+
drafts_unschedule = draft_commands.add_parser(
|
|
379
|
+
"unschedule", help="Remove a draft schedule."
|
|
380
|
+
)
|
|
381
|
+
drafts_unschedule.add_argument("draft_id", type=int)
|
|
382
|
+
drafts_unschedule.set_defaults(handler=_drafts_unschedule)
|
|
383
|
+
|
|
384
|
+
drafts_publish = draft_commands.add_parser("publish", help="Publish a draft.")
|
|
385
|
+
drafts_publish.add_argument("draft_id", type=int)
|
|
386
|
+
drafts_publish.add_argument(
|
|
387
|
+
"--no-send", action="store_false", dest="send", default=True
|
|
388
|
+
)
|
|
389
|
+
drafts_publish.add_argument("--share-automatically", action="store_true")
|
|
390
|
+
drafts_publish.add_argument("--yes", action="store_true")
|
|
391
|
+
drafts_publish.set_defaults(handler=_drafts_publish)
|
|
392
|
+
|
|
393
|
+
drafts_delete = draft_commands.add_parser("delete", help="Delete a draft.")
|
|
394
|
+
drafts_delete.add_argument("draft_id", type=int)
|
|
395
|
+
drafts_delete.add_argument("--yes", action="store_true")
|
|
396
|
+
drafts_delete.set_defaults(handler=_drafts_delete)
|
|
397
|
+
return parser
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main(argv=None):
|
|
401
|
+
parser = _build_parser()
|
|
402
|
+
args = parser.parse_args(argv)
|
|
403
|
+
try:
|
|
404
|
+
api = _api_from_env(
|
|
405
|
+
cookies_path=args.cookies,
|
|
406
|
+
publication_url=args.publication_url,
|
|
407
|
+
)
|
|
408
|
+
args.handler(api, args)
|
|
409
|
+
except CLIUsageError as exc:
|
|
410
|
+
_print_usage_error(exc, args.json_output)
|
|
411
|
+
return 2
|
|
412
|
+
except (SubstackAPIException, SubstackRequestException, OSError, ValueError) as exc:
|
|
413
|
+
_print_error(exc, args.json_output)
|
|
414
|
+
return 1
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def publish_markdown(argv=None):
|
|
419
|
+
parser = argparse.ArgumentParser()
|
|
420
|
+
parser.add_argument("markdown", nargs="?", default="README.md")
|
|
421
|
+
parser.add_argument("-m", "--markdown-file", dest="markdown_file")
|
|
422
|
+
parser.add_argument("--title")
|
|
423
|
+
parser.add_argument("--subtitle", default="")
|
|
424
|
+
parser.add_argument("--audience", default="everyone")
|
|
425
|
+
parser.add_argument("--write-comment-permissions", default="everyone")
|
|
426
|
+
parser.add_argument("--search-engine-title")
|
|
427
|
+
parser.add_argument("--search-engine-description")
|
|
428
|
+
parser.add_argument("--slug")
|
|
429
|
+
parser.add_argument("--draft-section-id", type=int)
|
|
430
|
+
parser.add_argument("--tag", action="append", dest="tags")
|
|
431
|
+
parser.add_argument("--cookies")
|
|
432
|
+
parser.add_argument("--prepublish", action="store_true")
|
|
433
|
+
parser.add_argument("--publish", action="store_true")
|
|
434
|
+
parser.add_argument("--no-send", action="store_false", dest="send", default=True)
|
|
435
|
+
parser.add_argument("--share-automatically", action="store_true")
|
|
436
|
+
args = parser.parse_args(argv)
|
|
437
|
+
|
|
438
|
+
markdown_path = Path(args.markdown_file or args.markdown)
|
|
439
|
+
markdown = markdown_path.read_text(encoding="utf-8")
|
|
440
|
+
|
|
441
|
+
api = _api_from_env(args.cookies)
|
|
442
|
+
result = api.create_draft_from_markdown(
|
|
443
|
+
title=args.title or _title_from_markdown(markdown, markdown_path.stem),
|
|
444
|
+
markdown=markdown,
|
|
445
|
+
subtitle=args.subtitle,
|
|
446
|
+
audience=args.audience,
|
|
447
|
+
write_comment_permissions=args.write_comment_permissions,
|
|
448
|
+
search_engine_title=args.search_engine_title,
|
|
449
|
+
search_engine_description=args.search_engine_description,
|
|
450
|
+
slug=args.slug,
|
|
451
|
+
draft_section_id=args.draft_section_id,
|
|
452
|
+
tags=args.tags,
|
|
453
|
+
prepublish=args.prepublish or args.publish,
|
|
454
|
+
publish=args.publish,
|
|
455
|
+
send=args.send,
|
|
456
|
+
share_automatically=args.share_automatically,
|
|
457
|
+
)
|
|
458
|
+
_print_result(result)
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def publish_yaml(argv=None):
|
|
463
|
+
parser = argparse.ArgumentParser()
|
|
464
|
+
parser.add_argument("post", nargs="?", default="draft.yaml")
|
|
465
|
+
parser.add_argument("-p", "--post-file", dest="post_file")
|
|
466
|
+
parser.add_argument("--cookies")
|
|
467
|
+
parser.add_argument("--prepublish", action="store_true")
|
|
468
|
+
parser.add_argument("--publish", action="store_true")
|
|
469
|
+
parser.add_argument("--no-send", action="store_false", dest="send", default=True)
|
|
470
|
+
parser.add_argument("--share-automatically", action="store_true")
|
|
471
|
+
args = parser.parse_args(argv)
|
|
472
|
+
|
|
473
|
+
post_path = Path(args.post_file or args.post)
|
|
474
|
+
post_data = yaml.safe_load(post_path.read_text(encoding="utf-8"))
|
|
475
|
+
api = _api_from_env(args.cookies)
|
|
476
|
+
|
|
477
|
+
markdown = post_data.get("markdown")
|
|
478
|
+
markdown_file = post_data.get("markdown_file")
|
|
479
|
+
if markdown_file:
|
|
480
|
+
markdown_path = Path(markdown_file)
|
|
481
|
+
if not markdown_path.is_absolute():
|
|
482
|
+
markdown_path = post_path.parent / markdown_path
|
|
483
|
+
markdown = markdown_path.read_text(encoding="utf-8")
|
|
484
|
+
|
|
485
|
+
if markdown is not None:
|
|
486
|
+
result = api.create_draft_from_markdown(
|
|
487
|
+
title=post_data.get("title"),
|
|
488
|
+
markdown=markdown,
|
|
489
|
+
subtitle=post_data.get("subtitle", ""),
|
|
490
|
+
audience=post_data.get("audience", "everyone"),
|
|
491
|
+
write_comment_permissions=post_data.get(
|
|
492
|
+
"write_comment_permissions", "everyone"
|
|
493
|
+
),
|
|
494
|
+
search_engine_title=post_data.get("search_engine_title"),
|
|
495
|
+
search_engine_description=post_data.get("search_engine_description"),
|
|
496
|
+
slug=post_data.get("slug"),
|
|
497
|
+
draft_section_id=post_data.get("draft_section_id"),
|
|
498
|
+
tags=post_data.get("tags"),
|
|
499
|
+
prepublish=args.prepublish or args.publish,
|
|
500
|
+
publish=args.publish,
|
|
501
|
+
send=args.send,
|
|
502
|
+
share_automatically=args.share_automatically,
|
|
503
|
+
)
|
|
504
|
+
_print_result(result)
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
post = Post(
|
|
508
|
+
post_data.get("title"),
|
|
509
|
+
post_data.get("subtitle", ""),
|
|
510
|
+
api.get_user_id(),
|
|
511
|
+
audience=post_data.get("audience", "everyone"),
|
|
512
|
+
write_comment_permissions=post_data.get(
|
|
513
|
+
"write_comment_permissions", "everyone"
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
section = post_data.get("section")
|
|
518
|
+
if section:
|
|
519
|
+
post.set_section(section, api.get_sections())
|
|
520
|
+
|
|
521
|
+
for item in post_data.get("body", {}).values():
|
|
522
|
+
if item.get("type") == "captionedImage":
|
|
523
|
+
src = item.get("src", "")
|
|
524
|
+
if not src.startswith("http"):
|
|
525
|
+
image = api.get_image(src)
|
|
526
|
+
item.update({"src": image.get("url")})
|
|
527
|
+
post.add(item)
|
|
528
|
+
|
|
529
|
+
draft = api.post_draft(post.get_draft())
|
|
530
|
+
draft_id = draft.get("id")
|
|
531
|
+
|
|
532
|
+
update_payload = {
|
|
533
|
+
"draft_section_id": post.draft_section_id,
|
|
534
|
+
"search_engine_title": post_data.get("search_engine_title"),
|
|
535
|
+
"search_engine_description": post_data.get("search_engine_description"),
|
|
536
|
+
"slug": post_data.get("slug"),
|
|
537
|
+
}
|
|
538
|
+
update_payload = {
|
|
539
|
+
key: value for key, value in update_payload.items() if value is not None
|
|
540
|
+
}
|
|
541
|
+
if update_payload:
|
|
542
|
+
draft = api.put_draft(draft_id, **update_payload)
|
|
543
|
+
|
|
544
|
+
tags = Api._normalize_tags(post_data.get("tags"))
|
|
545
|
+
if tags:
|
|
546
|
+
api.add_tags_to_post(draft_id, tags)
|
|
547
|
+
|
|
548
|
+
if args.prepublish or args.publish:
|
|
549
|
+
api.prepublish_draft(draft_id)
|
|
550
|
+
if args.publish:
|
|
551
|
+
api.publish_draft(
|
|
552
|
+
draft_id,
|
|
553
|
+
send=args.send,
|
|
554
|
+
share_automatically=args.share_automatically,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
print(json.dumps({"draft_id": draft_id, "draft": draft}, indent=2))
|
|
558
|
+
return 0
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def auth_check(argv=None):
|
|
562
|
+
parser = argparse.ArgumentParser()
|
|
563
|
+
parser.add_argument("--cookies")
|
|
564
|
+
args = parser.parse_args(argv)
|
|
565
|
+
|
|
566
|
+
api = _api_from_env(args.cookies)
|
|
567
|
+
profile = api.get_user_profile()
|
|
568
|
+
primary_publication = api.get_user_primary_publication()
|
|
569
|
+
publications = api.get_user_publications()
|
|
570
|
+
|
|
571
|
+
identity = (
|
|
572
|
+
profile.get("email")
|
|
573
|
+
or profile.get("name")
|
|
574
|
+
or profile.get("handle")
|
|
575
|
+
or str(profile.get("id", "unknown"))
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
print(f"Authenticated as: {identity}")
|
|
579
|
+
print(f"Auth method: {_auth_method(args.cookies)}")
|
|
580
|
+
print(f"Primary publication: {primary_publication.get('name', 'unknown')}")
|
|
581
|
+
print(f"Publication URL: {primary_publication.get('publication_url', 'unknown')}")
|
|
582
|
+
print(f"Available publications: {len(publications)}")
|
|
583
|
+
return 0
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import argparse
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import yaml
|
|
7
|
-
from dotenv import load_dotenv
|
|
8
|
-
|
|
9
|
-
from substack import Api
|
|
10
|
-
from substack.post import Post
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _api_from_env(cookies_path=None):
|
|
14
|
-
load_dotenv()
|
|
15
|
-
|
|
16
|
-
cookies_path = cookies_path or os.getenv("COOKIES_PATH")
|
|
17
|
-
cookies_string = os.getenv("COOKIES_STRING")
|
|
18
|
-
|
|
19
|
-
if cookies_path or cookies_string:
|
|
20
|
-
return Api(
|
|
21
|
-
cookies_path=cookies_path,
|
|
22
|
-
cookies_string=cookies_string,
|
|
23
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
return Api(
|
|
27
|
-
email=os.getenv("EMAIL"),
|
|
28
|
-
password=os.getenv("PASSWORD"),
|
|
29
|
-
publication_url=os.getenv("PUBLICATION_URL"),
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _auth_method(cookies_path=None):
|
|
34
|
-
load_dotenv()
|
|
35
|
-
|
|
36
|
-
if cookies_path:
|
|
37
|
-
return "cookies_path"
|
|
38
|
-
if os.getenv("COOKIES_PATH"):
|
|
39
|
-
return "cookies_path"
|
|
40
|
-
if os.getenv("COOKIES_STRING"):
|
|
41
|
-
return "cookies_string"
|
|
42
|
-
if os.getenv("EMAIL") and os.getenv("PASSWORD"):
|
|
43
|
-
return "email_password"
|
|
44
|
-
return "unknown"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _title_from_markdown(markdown, fallback):
|
|
48
|
-
for line in markdown.splitlines():
|
|
49
|
-
if line.startswith("# "):
|
|
50
|
-
return line[2:].strip()
|
|
51
|
-
return fallback
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _print_result(result):
|
|
55
|
-
draft = result["draft"]
|
|
56
|
-
print(json.dumps({"draft_id": draft.get("id"), "draft": draft}, indent=2))
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def publish_markdown(argv=None):
|
|
60
|
-
parser = argparse.ArgumentParser()
|
|
61
|
-
parser.add_argument("markdown", nargs="?", default="README.md")
|
|
62
|
-
parser.add_argument("-m", "--markdown-file", dest="markdown_file")
|
|
63
|
-
parser.add_argument("--title")
|
|
64
|
-
parser.add_argument("--subtitle", default="")
|
|
65
|
-
parser.add_argument("--audience", default="everyone")
|
|
66
|
-
parser.add_argument("--write-comment-permissions", default="everyone")
|
|
67
|
-
parser.add_argument("--search-engine-title")
|
|
68
|
-
parser.add_argument("--search-engine-description")
|
|
69
|
-
parser.add_argument("--slug")
|
|
70
|
-
parser.add_argument("--draft-section-id", type=int)
|
|
71
|
-
parser.add_argument("--tag", action="append", dest="tags")
|
|
72
|
-
parser.add_argument("--cookies")
|
|
73
|
-
parser.add_argument("--prepublish", action="store_true")
|
|
74
|
-
parser.add_argument("--publish", action="store_true")
|
|
75
|
-
parser.add_argument("--no-send", action="store_false", dest="send", default=True)
|
|
76
|
-
parser.add_argument("--share-automatically", action="store_true")
|
|
77
|
-
args = parser.parse_args(argv)
|
|
78
|
-
|
|
79
|
-
markdown_path = Path(args.markdown_file or args.markdown)
|
|
80
|
-
markdown = markdown_path.read_text(encoding="utf-8")
|
|
81
|
-
|
|
82
|
-
api = _api_from_env(args.cookies)
|
|
83
|
-
result = api.create_draft_from_markdown(
|
|
84
|
-
title=args.title or _title_from_markdown(markdown, markdown_path.stem),
|
|
85
|
-
markdown=markdown,
|
|
86
|
-
subtitle=args.subtitle,
|
|
87
|
-
audience=args.audience,
|
|
88
|
-
write_comment_permissions=args.write_comment_permissions,
|
|
89
|
-
search_engine_title=args.search_engine_title,
|
|
90
|
-
search_engine_description=args.search_engine_description,
|
|
91
|
-
slug=args.slug,
|
|
92
|
-
draft_section_id=args.draft_section_id,
|
|
93
|
-
tags=args.tags,
|
|
94
|
-
prepublish=args.prepublish or args.publish,
|
|
95
|
-
publish=args.publish,
|
|
96
|
-
send=args.send,
|
|
97
|
-
share_automatically=args.share_automatically,
|
|
98
|
-
)
|
|
99
|
-
_print_result(result)
|
|
100
|
-
return 0
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def publish_yaml(argv=None):
|
|
104
|
-
parser = argparse.ArgumentParser()
|
|
105
|
-
parser.add_argument("post", nargs="?", default="draft.yaml")
|
|
106
|
-
parser.add_argument("-p", "--post-file", dest="post_file")
|
|
107
|
-
parser.add_argument("--cookies")
|
|
108
|
-
parser.add_argument("--prepublish", action="store_true")
|
|
109
|
-
parser.add_argument("--publish", action="store_true")
|
|
110
|
-
parser.add_argument("--no-send", action="store_false", dest="send", default=True)
|
|
111
|
-
parser.add_argument("--share-automatically", action="store_true")
|
|
112
|
-
args = parser.parse_args(argv)
|
|
113
|
-
|
|
114
|
-
post_path = Path(args.post_file or args.post)
|
|
115
|
-
post_data = yaml.safe_load(post_path.read_text(encoding="utf-8"))
|
|
116
|
-
api = _api_from_env(args.cookies)
|
|
117
|
-
|
|
118
|
-
markdown = post_data.get("markdown")
|
|
119
|
-
markdown_file = post_data.get("markdown_file")
|
|
120
|
-
if markdown_file:
|
|
121
|
-
markdown_path = Path(markdown_file)
|
|
122
|
-
if not markdown_path.is_absolute():
|
|
123
|
-
markdown_path = post_path.parent / markdown_path
|
|
124
|
-
markdown = markdown_path.read_text(encoding="utf-8")
|
|
125
|
-
|
|
126
|
-
if markdown is not None:
|
|
127
|
-
result = api.create_draft_from_markdown(
|
|
128
|
-
title=post_data.get("title"),
|
|
129
|
-
markdown=markdown,
|
|
130
|
-
subtitle=post_data.get("subtitle", ""),
|
|
131
|
-
audience=post_data.get("audience", "everyone"),
|
|
132
|
-
write_comment_permissions=post_data.get(
|
|
133
|
-
"write_comment_permissions", "everyone"
|
|
134
|
-
),
|
|
135
|
-
search_engine_title=post_data.get("search_engine_title"),
|
|
136
|
-
search_engine_description=post_data.get("search_engine_description"),
|
|
137
|
-
slug=post_data.get("slug"),
|
|
138
|
-
draft_section_id=post_data.get("draft_section_id"),
|
|
139
|
-
tags=post_data.get("tags"),
|
|
140
|
-
prepublish=args.prepublish or args.publish,
|
|
141
|
-
publish=args.publish,
|
|
142
|
-
send=args.send,
|
|
143
|
-
share_automatically=args.share_automatically,
|
|
144
|
-
)
|
|
145
|
-
_print_result(result)
|
|
146
|
-
return 0
|
|
147
|
-
|
|
148
|
-
post = Post(
|
|
149
|
-
post_data.get("title"),
|
|
150
|
-
post_data.get("subtitle", ""),
|
|
151
|
-
api.get_user_id(),
|
|
152
|
-
audience=post_data.get("audience", "everyone"),
|
|
153
|
-
write_comment_permissions=post_data.get(
|
|
154
|
-
"write_comment_permissions", "everyone"
|
|
155
|
-
),
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
section = post_data.get("section")
|
|
159
|
-
if section:
|
|
160
|
-
post.set_section(section, api.get_sections())
|
|
161
|
-
|
|
162
|
-
for item in post_data.get("body", {}).values():
|
|
163
|
-
if item.get("type") == "captionedImage":
|
|
164
|
-
src = item.get("src", "")
|
|
165
|
-
if not src.startswith("http"):
|
|
166
|
-
image = api.get_image(src)
|
|
167
|
-
item.update({"src": image.get("url")})
|
|
168
|
-
post.add(item)
|
|
169
|
-
|
|
170
|
-
draft = api.post_draft(post.get_draft())
|
|
171
|
-
draft_id = draft.get("id")
|
|
172
|
-
|
|
173
|
-
update_payload = {
|
|
174
|
-
"draft_section_id": post.draft_section_id,
|
|
175
|
-
"search_engine_title": post_data.get("search_engine_title"),
|
|
176
|
-
"search_engine_description": post_data.get("search_engine_description"),
|
|
177
|
-
"slug": post_data.get("slug"),
|
|
178
|
-
}
|
|
179
|
-
update_payload = {
|
|
180
|
-
key: value for key, value in update_payload.items() if value is not None
|
|
181
|
-
}
|
|
182
|
-
if update_payload:
|
|
183
|
-
draft = api.put_draft(draft_id, **update_payload)
|
|
184
|
-
|
|
185
|
-
tags = Api._normalize_tags(post_data.get("tags"))
|
|
186
|
-
if tags:
|
|
187
|
-
api.add_tags_to_post(draft_id, tags)
|
|
188
|
-
|
|
189
|
-
if args.prepublish or args.publish:
|
|
190
|
-
api.prepublish_draft(draft_id)
|
|
191
|
-
if args.publish:
|
|
192
|
-
api.publish_draft(
|
|
193
|
-
draft_id,
|
|
194
|
-
send=args.send,
|
|
195
|
-
share_automatically=args.share_automatically,
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
print(json.dumps({"draft_id": draft_id, "draft": draft}, indent=2))
|
|
199
|
-
return 0
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def auth_check(argv=None):
|
|
203
|
-
parser = argparse.ArgumentParser()
|
|
204
|
-
parser.add_argument("--cookies")
|
|
205
|
-
args = parser.parse_args(argv)
|
|
206
|
-
|
|
207
|
-
api = _api_from_env(args.cookies)
|
|
208
|
-
profile = api.get_user_profile()
|
|
209
|
-
primary_publication = api.get_user_primary_publication()
|
|
210
|
-
publications = api.get_user_publications()
|
|
211
|
-
|
|
212
|
-
identity = (
|
|
213
|
-
profile.get("email")
|
|
214
|
-
or profile.get("name")
|
|
215
|
-
or profile.get("handle")
|
|
216
|
-
or str(profile.get("id", "unknown"))
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
print(f"Authenticated as: {identity}")
|
|
220
|
-
print(f"Auth method: {_auth_method(args.cookies)}")
|
|
221
|
-
print(f"Primary publication: {primary_publication.get('name', 'unknown')}")
|
|
222
|
-
print(f"Publication URL: {primary_publication.get('publication_url', 'unknown')}")
|
|
223
|
-
print(f"Available publications: {len(publications)}")
|
|
224
|
-
return 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|