python-substack 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
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.3.0.dist-info → python_substack-0.5.0.dist-info}/METADATA +3 -2
- python_substack-0.5.0.dist-info/RECORD +13 -0
- substack/__init__.py +13 -13
- substack/api.py +835 -792
- substack/cli.py +7 -1
- substack/exceptions.py +44 -32
- substack_mcp/mcp_server.py +137 -44
- python_substack-0.3.0.dist-info/RECORD +0 -13
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/WHEEL +0 -0
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.3.0.dist-info → python_substack-0.5.0.dist-info}/licenses/LICENSE +0 -0
substack/cli.py
CHANGED
|
@@ -18,7 +18,7 @@ class CLIUsageError(Exception):
|
|
|
18
18
|
pass
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def _api_from_env(cookies_path=None, publication_url=None):
|
|
21
|
+
def _api_from_env(cookies_path=None, publication_url=None, timeout=None):
|
|
22
22
|
load_dotenv()
|
|
23
23
|
|
|
24
24
|
cookies_path = cookies_path or os.getenv("COOKIES_PATH")
|
|
@@ -30,12 +30,14 @@ def _api_from_env(cookies_path=None, publication_url=None):
|
|
|
30
30
|
cookies_path=cookies_path,
|
|
31
31
|
cookies_string=cookies_string,
|
|
32
32
|
publication_url=publication_url,
|
|
33
|
+
timeout=timeout,
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
return Api(
|
|
36
37
|
email=os.getenv("EMAIL"),
|
|
37
38
|
password=os.getenv("PASSWORD"),
|
|
38
39
|
publication_url=publication_url,
|
|
40
|
+
timeout=timeout,
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
|
|
@@ -370,6 +372,9 @@ def _build_parser():
|
|
|
370
372
|
parser.add_argument(
|
|
371
373
|
"--publication-url", help="Override PUBLICATION_URL for this command."
|
|
372
374
|
)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--timeout", type=float, help="Timeout in seconds for API requests."
|
|
377
|
+
)
|
|
373
378
|
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
374
379
|
parser.add_argument("--version", action="version", version=__version__)
|
|
375
380
|
|
|
@@ -450,6 +455,7 @@ def main(argv=None):
|
|
|
450
455
|
api = _api_from_env(
|
|
451
456
|
cookies_path=args.cookies,
|
|
452
457
|
publication_url=args.publication_url,
|
|
458
|
+
timeout=args.timeout,
|
|
453
459
|
)
|
|
454
460
|
args.handler(api, args)
|
|
455
461
|
except CLIUsageError as exc:
|
substack/exceptions.py
CHANGED
|
@@ -1,32 +1,44 @@
|
|
|
1
|
-
import json
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _redact_message(text: str) -> str:
|
|
6
|
+
if not text:
|
|
7
|
+
return text
|
|
8
|
+
# Redact common cookie-like values and session tokens
|
|
9
|
+
# e.g., s%3A... or s:... which are typical for express session cookies
|
|
10
|
+
text = re.sub(r"s%3A[a-zA-Z0-9_\-\.\%]+", "[REDACTED_COOKIE]", text)
|
|
11
|
+
text = re.sub(r"s:[a-zA-Z0-9_\-\.\%]+", "[REDACTED_COOKIE]", text)
|
|
12
|
+
return text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubstackAPIException(Exception):
|
|
16
|
+
def __init__(self, status_code, text):
|
|
17
|
+
text = _redact_message(text)
|
|
18
|
+
try:
|
|
19
|
+
json_res = json.loads(text)
|
|
20
|
+
except ValueError:
|
|
21
|
+
self.message = f"Invalid JSON error message from Substack: {text}"
|
|
22
|
+
else:
|
|
23
|
+
self.message = ", ".join(
|
|
24
|
+
list(
|
|
25
|
+
map(lambda error: error.get("msg", ""), json_res.get("errors", []))
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
self.message = self.message or json_res.get("error", "")
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
|
|
31
|
+
def __str__(self):
|
|
32
|
+
return f"APIError(code={self.status_code}): {self.message}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SubstackRequestException(Exception):
|
|
36
|
+
def __init__(self, message):
|
|
37
|
+
self.message = _redact_message(message)
|
|
38
|
+
|
|
39
|
+
def __str__(self):
|
|
40
|
+
return f"SubstackRequestException: {self.message}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SectionNotExistsException(SubstackRequestException):
|
|
44
|
+
pass
|
substack_mcp/mcp_server.py
CHANGED
|
@@ -163,55 +163,24 @@ async def post_draft_from_markdown(
|
|
|
163
163
|
This docstring example is meant to mirror the YAML-driven workflow and show how to decompose the same operations into explicit tool calls.
|
|
164
164
|
"""
|
|
165
165
|
client = get_api()
|
|
166
|
-
user_id = client.get_user_id()
|
|
167
166
|
|
|
168
|
-
|
|
167
|
+
return client.create_draft_from_markdown(
|
|
169
168
|
title=title,
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
markdown=markdown,
|
|
170
|
+
subtitle=subtitle,
|
|
172
171
|
audience=audience,
|
|
173
172
|
write_comment_permissions=write_comment_permissions,
|
|
173
|
+
search_engine_title=search_engine_title,
|
|
174
|
+
search_engine_description=search_engine_description,
|
|
175
|
+
slug=slug,
|
|
176
|
+
draft_section_id=draft_section_id,
|
|
177
|
+
tags=tags,
|
|
178
|
+
prepublish=prepublish,
|
|
179
|
+
publish=publish,
|
|
180
|
+
send=send,
|
|
181
|
+
share_automatically=share_automatically,
|
|
174
182
|
)
|
|
175
183
|
|
|
176
|
-
post.from_markdown(markdown, api=client)
|
|
177
|
-
|
|
178
|
-
draft = client.post_draft(post.get_draft())
|
|
179
|
-
|
|
180
|
-
update_payload: Dict[str, Any] = {}
|
|
181
|
-
if search_engine_title:
|
|
182
|
-
update_payload["search_engine_title"] = search_engine_title
|
|
183
|
-
if search_engine_description:
|
|
184
|
-
update_payload["search_engine_description"] = search_engine_description
|
|
185
|
-
if slug:
|
|
186
|
-
update_payload["slug"] = slug
|
|
187
|
-
if draft_section_id is not None:
|
|
188
|
-
update_payload["draft_section_id"] = draft_section_id
|
|
189
|
-
|
|
190
|
-
if update_payload:
|
|
191
|
-
draft = client.put_draft(draft.get("id"), **update_payload)
|
|
192
|
-
|
|
193
|
-
tags_list = _normalize_tags(tags)
|
|
194
|
-
tags_result = None
|
|
195
|
-
if tags_list:
|
|
196
|
-
tags_result = client.add_tags_to_post(draft.get("id"), tags_list)
|
|
197
|
-
|
|
198
|
-
prepublish_result = None
|
|
199
|
-
if prepublish:
|
|
200
|
-
prepublish_result = client.prepublish_draft(draft.get("id"))
|
|
201
|
-
|
|
202
|
-
publish_result = None
|
|
203
|
-
if publish:
|
|
204
|
-
publish_result = client.publish_draft(
|
|
205
|
-
draft.get("id"), send=send, share_automatically=share_automatically
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
"draft": draft,
|
|
210
|
-
"tags": tags_result,
|
|
211
|
-
"prepublish": prepublish_result,
|
|
212
|
-
"publish": publish_result,
|
|
213
|
-
}
|
|
214
|
-
|
|
215
184
|
|
|
216
185
|
@mcp.tool()
|
|
217
186
|
async def put_draft(
|
|
@@ -269,7 +238,11 @@ async def publish_draft(
|
|
|
269
238
|
send: bool = True,
|
|
270
239
|
share_automatically: bool = False,
|
|
271
240
|
) -> Dict[str, Any]:
|
|
272
|
-
"""Publish a draft to live post state.
|
|
241
|
+
"""Publish a draft to live post state. (Legacy compatibility interface).
|
|
242
|
+
|
|
243
|
+
This tool remains for backward compatibility. It is recommended to use
|
|
244
|
+
`publish_draft_checked` instead, which provides a safer publishing path
|
|
245
|
+
with explicit confirmation and prepublish validation.
|
|
273
246
|
|
|
274
247
|
Args:
|
|
275
248
|
draft_id: target draft identifier.
|
|
@@ -285,6 +258,126 @@ async def publish_draft(
|
|
|
285
258
|
)
|
|
286
259
|
|
|
287
260
|
|
|
261
|
+
@mcp.tool()
|
|
262
|
+
async def publish_draft_checked(
|
|
263
|
+
draft_id: int,
|
|
264
|
+
confirm: bool = False,
|
|
265
|
+
send: bool = False,
|
|
266
|
+
share_automatically: bool = False,
|
|
267
|
+
) -> Dict[str, Any]:
|
|
268
|
+
"""A safer publishing path that requires confirmation and runs prepublish checks.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
draft_id: target draft identifier.
|
|
272
|
+
confirm: Must be True to proceed with publication.
|
|
273
|
+
send: if False then do not send email to subscribers. Defaults to False.
|
|
274
|
+
share_automatically: whether to auto-share.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Response from Substack `publish_draft`.
|
|
278
|
+
"""
|
|
279
|
+
if not confirm:
|
|
280
|
+
raise ValueError("Publishing rejected: confirm parameter must be True.")
|
|
281
|
+
|
|
282
|
+
client = get_api()
|
|
283
|
+
client.prepublish_draft(draft_id)
|
|
284
|
+
return client.publish_draft(
|
|
285
|
+
draft_id, send=send, share_automatically=share_automatically
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@mcp.tool()
|
|
290
|
+
async def get_status() -> Dict[str, Any]:
|
|
291
|
+
"""Get the authentication status and basic user information.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
A dictionary containing user profile and primary publication details.
|
|
295
|
+
"""
|
|
296
|
+
client = get_api()
|
|
297
|
+
profile = client.get_user_profile()
|
|
298
|
+
primary_pub = client.get_user_primary_publication()
|
|
299
|
+
return {"profile": profile, "primary_publication": primary_pub}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@mcp.tool()
|
|
303
|
+
async def list_publications() -> List[Dict[str, Any]]:
|
|
304
|
+
"""List all publications available to the authenticated user.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
A list of publications.
|
|
308
|
+
"""
|
|
309
|
+
client = get_api()
|
|
310
|
+
return client.get_user_publications()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@mcp.tool()
|
|
314
|
+
async def list_drafts(
|
|
315
|
+
filter: str = "draft", offset: int = 0, limit: int = 25
|
|
316
|
+
) -> List[Dict[str, Any]]:
|
|
317
|
+
"""List drafts for the current publication.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
filter: Filter string, defaults to "draft".
|
|
321
|
+
offset: Pagination offset.
|
|
322
|
+
limit: Max number of drafts to return.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
A list of drafts.
|
|
326
|
+
"""
|
|
327
|
+
client = get_api()
|
|
328
|
+
return client.get_drafts(filter=filter, offset=offset, limit=limit)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@mcp.tool()
|
|
332
|
+
async def get_draft(draft_id: int) -> Dict[str, Any]:
|
|
333
|
+
"""Get a specific draft by its ID.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
draft_id: The identifier of the draft.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
The draft details.
|
|
340
|
+
"""
|
|
341
|
+
client = get_api()
|
|
342
|
+
return client.get_draft(draft_id)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@mcp.tool()
|
|
346
|
+
async def schedule_draft(draft_id: int, at: str) -> Dict[str, Any]:
|
|
347
|
+
"""Schedule a draft for release.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
draft_id: target draft identifier.
|
|
351
|
+
at: ISO 8601 formatted datetime string (e.g., "2024-01-01T12:00:00Z").
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
API response dict for the scheduled draft.
|
|
355
|
+
"""
|
|
356
|
+
from datetime import datetime
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
draft_datetime = datetime.fromisoformat(at.replace("Z", "+00:00"))
|
|
360
|
+
except ValueError as e:
|
|
361
|
+
raise ValueError(f"Invalid ISO datetime string for 'at': {e}")
|
|
362
|
+
|
|
363
|
+
client = get_api()
|
|
364
|
+
return client.schedule_draft(draft_id, draft_datetime)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@mcp.tool()
|
|
368
|
+
async def unschedule_draft(draft_id: int) -> Dict[str, Any]:
|
|
369
|
+
"""Unschedule a previously scheduled draft.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
draft_id: target draft identifier.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
API response dict for unscheduling.
|
|
376
|
+
"""
|
|
377
|
+
client = get_api()
|
|
378
|
+
return client.unschedule_draft(draft_id)
|
|
379
|
+
|
|
380
|
+
|
|
288
381
|
def main() -> None:
|
|
289
382
|
mcp.run(transport="stdio")
|
|
290
383
|
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=wulIsJc0NOyRmhOrRSyATms453zZ1GoPCHCqk0Yef5k,437
|
|
2
|
-
substack/api.py,sha256=5dIijlGDZI5bPgX9skTzytDkY1IO6etvCv6PbNFUjyQ,23212
|
|
3
|
-
substack/cli.py,sha256=JBCsCdmthjzTeDpOYBLeVKOdNYjBrPuY1o_CIsAIekk,21712
|
|
4
|
-
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
5
|
-
substack/mdrender.py,sha256=QGJkdp1isFmhVyTRMogIabAK8OO2xQh9CrjnXZvFTY4,9308
|
|
6
|
-
substack/nodes.py,sha256=fTsGO0-lTztcXiloIjXrHVnJes9EcltGcrX-mEV2ceQ,5004
|
|
7
|
-
substack/post.py,sha256=qDXu-xzO3nRXtahc-yG2EhxKAXB2hG_Z1nLa55UMBGg,19697
|
|
8
|
-
substack_mcp/mcp_server.py,sha256=gmevdc59XTBXXMTvVMpYoq66Umlqku1O_uzmw5tMy7o,8405
|
|
9
|
-
python_substack-0.3.0.dist-info/METADATA,sha256=oj9XGFEZB62J8QCUyhO0n2VUrZFPXzu_TQsgkt2L_4s,8349
|
|
10
|
-
python_substack-0.3.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
11
|
-
python_substack-0.3.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
|
|
12
|
-
python_substack-0.3.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
13
|
-
python_substack-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|