python-substack 0.4.0__py3-none-any.whl → 0.6.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.
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
 
@@ -307,6 +309,32 @@ def _drafts_create(api, args):
307
309
  print(f"Created draft {draft.get('id')}: {title}")
308
310
 
309
311
 
312
+ def _drafts_export(api, args):
313
+ output_path = Path(args.output) if args.output else None
314
+ if output_path is not None and output_path.exists() and not args.force:
315
+ raise CLIUsageError(
316
+ f"Output file already exists: {output_path}; use --force to overwrite"
317
+ )
318
+
319
+ result = api.export_draft_to_markdown(args.draft_id)
320
+ markdown = result["markdown"]
321
+ if output_path is not None:
322
+ output_path.write_text(markdown, encoding="utf-8")
323
+
324
+ payload = {
325
+ "action": "export",
326
+ "draft_id": args.draft_id,
327
+ "markdown": markdown,
328
+ "unsupported_nodes": result["unsupported_nodes"],
329
+ }
330
+ if args.json_output:
331
+ _print_json(payload)
332
+ elif output_path is not None:
333
+ print(f"Exported draft {args.draft_id} to {output_path}")
334
+ else:
335
+ print(markdown, end="")
336
+
337
+
310
338
  def _drafts_schedule(api, args):
311
339
  scheduled_at = _parse_schedule(args.at)
312
340
  result = api.schedule_draft(args.draft_id, scheduled_at)
@@ -370,6 +398,9 @@ def _build_parser():
370
398
  parser.add_argument(
371
399
  "--publication-url", help="Override PUBLICATION_URL for this command."
372
400
  )
401
+ parser.add_argument(
402
+ "--timeout", type=float, help="Timeout in seconds for API requests."
403
+ )
373
404
  parser.add_argument("--json", action="store_true", dest="json_output")
374
405
  parser.add_argument("--version", action="version", version=__version__)
375
406
 
@@ -416,6 +447,14 @@ def _build_parser():
416
447
  drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
417
448
  drafts_create.set_defaults(handler=_drafts_create)
418
449
 
450
+ drafts_export = draft_commands.add_parser(
451
+ "export", help="Export a draft to Markdown without modifying it."
452
+ )
453
+ drafts_export.add_argument("draft_id", type=int)
454
+ drafts_export.add_argument("--output", metavar="PATH")
455
+ drafts_export.add_argument("--force", action="store_true")
456
+ drafts_export.set_defaults(handler=_drafts_export)
457
+
419
458
  drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
420
459
  drafts_schedule.add_argument("draft_id", type=int)
421
460
  drafts_schedule.add_argument("--at", required=True)
@@ -450,6 +489,7 @@ def main(argv=None):
450
489
  api = _api_from_env(
451
490
  cookies_path=args.cookies,
452
491
  publication_url=args.publication_url,
492
+ timeout=args.timeout,
453
493
  )
454
494
  args.handler(api, args)
455
495
  except CLIUsageError as exc:
substack/exceptions.py CHANGED
@@ -1,32 +1,44 @@
1
- import json
2
-
3
-
4
- class SubstackAPIException(Exception):
5
- def __init__(self, status_code, text):
6
- try:
7
- json_res = json.loads(text)
8
- except ValueError:
9
- self.message = f"Invalid JSON error message from Substack: {text}"
10
- else:
11
- self.message = ", ".join(
12
- list(
13
- map(lambda error: error.get("msg", ""), json_res.get("errors", []))
14
- )
15
- )
16
- self.message = self.message or json_res.get("error", "")
17
- self.status_code = status_code
18
-
19
- def __str__(self):
20
- return f"APIError(code={self.status_code}): {self.message}"
21
-
22
-
23
- class SubstackRequestException(Exception):
24
- def __init__(self, message):
25
- self.message = message
26
-
27
- def __str__(self):
28
- return f"SubstackRequestException: {self.message}"
29
-
30
-
31
- class SectionNotExistsException(SubstackRequestException):
32
- pass
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/mdexport.py ADDED
@@ -0,0 +1,381 @@
1
+ import base64
2
+ import copy
3
+ import json
4
+ import re
5
+
6
+ _BLOCK_TYPES = {
7
+ "paragraph",
8
+ "heading",
9
+ "blockquote",
10
+ "codeBlock",
11
+ "horizontal_rule",
12
+ "bullet_list",
13
+ "ordered_list",
14
+ "captionedImage",
15
+ "footnote",
16
+ "latex_block",
17
+ "pullquote",
18
+ "calloutBlock",
19
+ }
20
+ _MARK_TYPES = {
21
+ "strong",
22
+ "em",
23
+ "code",
24
+ "strikethrough",
25
+ "superscript",
26
+ "subscript",
27
+ "link",
28
+ }
29
+ _IMAGE_ATTRS = {
30
+ "src",
31
+ "srcNoWatermark",
32
+ "fullscreen",
33
+ "imageSize",
34
+ "height",
35
+ "width",
36
+ "resizeWidth",
37
+ "bytes",
38
+ "alt",
39
+ "title",
40
+ "type",
41
+ "href",
42
+ "belowTheFold",
43
+ "topImage",
44
+ "internalRedirect",
45
+ "isProcessing",
46
+ "align",
47
+ "offset",
48
+ }
49
+
50
+
51
+ def _marker(node, unsupported):
52
+ unsupported.append(copy.deepcopy(node))
53
+ payload = json.dumps(
54
+ node, ensure_ascii=False, separators=(",", ":"), sort_keys=True
55
+ ).encode("utf-8")
56
+ encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
57
+ return f"<!-- python-substack-node:v1 {encoded} -->"
58
+
59
+
60
+ def _require_dict(node, context):
61
+ if not isinstance(node, dict):
62
+ raise ValueError(f"Malformed draft body: {context} must be an object")
63
+ if not isinstance(node.get("type"), str) or not node["type"]:
64
+ raise ValueError(f"Malformed draft body: {context} has no node type")
65
+
66
+
67
+ def _content(node, context):
68
+ content = node.get("content", [])
69
+ if not isinstance(content, list):
70
+ raise ValueError(f"Malformed draft body: {context} content must be a list")
71
+ return content
72
+
73
+
74
+ def _attrs(node, context):
75
+ attrs = node.get("attrs", {})
76
+ if not isinstance(attrs, dict):
77
+ raise ValueError(f"Malformed draft body: {context} attrs must be an object")
78
+ return attrs
79
+
80
+
81
+ def _has_unknown_keys(value, allowed):
82
+ return bool(set(value) - set(allowed))
83
+
84
+
85
+ def _escape_text(value):
86
+ return re.sub(r"([\\`*_{}\[\]()<>#+\-.!|])", r"\\\1", value)
87
+
88
+
89
+ def _escape_destination(value):
90
+ return str(value).replace("\\", "\\\\").replace(")", "\\)")
91
+
92
+
93
+ def _escape_title(value):
94
+ return value.replace("\\", "\\\\").replace('"', '\\"')
95
+
96
+
97
+ def _code_span(value):
98
+ runs = [len(match.group(0)) for match in re.finditer(r"`+", value)]
99
+ delimiter = "`" * max(1, (max(runs) + 1) if runs else 1)
100
+ padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else ""
101
+ return f"{delimiter}{padding}{value}{padding}{delimiter}"
102
+
103
+
104
+ def _render_marked_text(value, marks, node, unsupported):
105
+ if not isinstance(value, str) or not isinstance(marks, list):
106
+ raise ValueError("Malformed draft body: text and marks have invalid types")
107
+ for mark in marks:
108
+ _require_dict(mark, "mark")
109
+ if mark["type"] not in _MARK_TYPES:
110
+ return _marker(node, unsupported)
111
+ allowed = {"type", "attrs"} if mark["type"] == "link" else {"type"}
112
+ if _has_unknown_keys(mark, allowed):
113
+ return _marker(node, unsupported)
114
+
115
+ rendered = (
116
+ _code_span(value)
117
+ if any(mark["type"] == "code" for mark in marks)
118
+ else _escape_text(value)
119
+ )
120
+ for mark in marks:
121
+ mark_type = mark["type"]
122
+ if mark_type == "code":
123
+ continue
124
+ if mark_type == "strong":
125
+ rendered = f"**{rendered}**"
126
+ elif mark_type == "em":
127
+ rendered = f"*{rendered}*"
128
+ elif mark_type == "strikethrough":
129
+ rendered = f"~~{rendered}~~"
130
+ elif mark_type == "superscript":
131
+ rendered = f"^{rendered}^"
132
+ elif mark_type == "subscript":
133
+ rendered = f"~{rendered}~"
134
+ elif mark_type == "link":
135
+ attrs = _attrs(mark, "link mark")
136
+ if _has_unknown_keys(attrs, {"href"}) or not isinstance(
137
+ attrs.get("href"), str
138
+ ):
139
+ return _marker(node, unsupported)
140
+ rendered = f"[{rendered}]({_escape_destination(attrs['href'])})"
141
+ return rendered
142
+
143
+
144
+ def _render_inline(nodes, unsupported):
145
+ if not isinstance(nodes, list):
146
+ raise ValueError("Malformed draft body: inline content must be a list")
147
+ rendered = []
148
+ for node in nodes:
149
+ _require_dict(node, "inline node")
150
+ node_type = node["type"]
151
+ if node_type == "text":
152
+ if _has_unknown_keys(node, {"type", "text", "marks"}):
153
+ rendered.append(_marker(node, unsupported))
154
+ continue
155
+ rendered.append(
156
+ _render_marked_text(
157
+ node.get("text"), node.get("marks", []), node, unsupported
158
+ )
159
+ )
160
+ elif node_type == "footnoteAnchor":
161
+ attrs = _attrs(node, "footnote anchor")
162
+ number = attrs.get("number")
163
+ if (
164
+ _has_unknown_keys(node, {"type", "attrs"})
165
+ or _has_unknown_keys(attrs, {"number"})
166
+ or not isinstance(number, int)
167
+ ):
168
+ rendered.append(_marker(node, unsupported))
169
+ else:
170
+ rendered.append(f"[^{number}]")
171
+ elif node_type == "latex":
172
+ attrs = _attrs(node, "inline math")
173
+ expression = attrs.get("expression", attrs.get("persistentExpression"))
174
+ if (
175
+ _has_unknown_keys(node, {"type", "attrs"})
176
+ or _has_unknown_keys(attrs, {"expression", "persistentExpression"})
177
+ or not isinstance(expression, str)
178
+ ):
179
+ rendered.append(_marker(node, unsupported))
180
+ else:
181
+ rendered.append(f"${expression}$")
182
+ else:
183
+ rendered.append(_marker(node, unsupported))
184
+ return "".join(rendered)
185
+
186
+
187
+ def _render_list(node, unsupported, ordered=False):
188
+ lines = []
189
+ for index, item in enumerate(_content(node, "list"), start=1):
190
+ _require_dict(item, "list item")
191
+ if item["type"] != "list_item" or _has_unknown_keys(item, {"type", "content"}):
192
+ item_text = _marker(item, unsupported)
193
+ else:
194
+ item_blocks = [
195
+ _render_block(child, unsupported)
196
+ for child in _content(item, "list item")
197
+ ]
198
+ item_text = "\n\n".join(item_blocks)
199
+ prefix = f"{index}. " if ordered else "- "
200
+ item_lines = item_text.splitlines() or [""]
201
+ lines.append(prefix + item_lines[0])
202
+ lines.extend(" " + line if line else "" for line in item_lines[1:])
203
+ return "\n".join(lines)
204
+
205
+
206
+ def _render_image(node, unsupported):
207
+ content = _content(node, "captioned image")
208
+ if not content:
209
+ raise ValueError("Malformed draft body: captioned image has no image")
210
+ image = content[0]
211
+ _require_dict(image, "image")
212
+ attrs = _attrs(image, "image")
213
+ if (
214
+ image["type"] != "image2"
215
+ or _has_unknown_keys(node, {"type", "content"})
216
+ or _has_unknown_keys(image, {"type", "attrs"})
217
+ or _has_unknown_keys(attrs, _IMAGE_ATTRS)
218
+ or not isinstance(attrs.get("src"), str)
219
+ ):
220
+ return _marker(node, unsupported)
221
+
222
+ alt = str(attrs.get("alt") or "").replace("\\", "\\\\").replace("]", "\\]")
223
+ image_markdown = f"![{alt}]({_escape_destination(attrs['src'])}"
224
+ if len(content) > 2:
225
+ return _marker(node, unsupported)
226
+ if len(content) == 2:
227
+ caption = content[1]
228
+ _require_dict(caption, "image caption")
229
+ caption_nodes = _content(caption, "image caption")
230
+ if (
231
+ caption["type"] != "caption"
232
+ or _has_unknown_keys(caption, {"type", "content"})
233
+ or any(
234
+ not isinstance(child, dict)
235
+ or child.get("type") != "text"
236
+ or child.get("marks")
237
+ or not isinstance(child.get("text"), str)
238
+ for child in caption_nodes
239
+ )
240
+ ):
241
+ return _marker(node, unsupported)
242
+ caption_text = "".join(child.get("text", "") for child in caption_nodes)
243
+ image_markdown += f' "{_escape_title(caption_text)}"'
244
+ image_markdown += ")"
245
+ href = attrs.get("href")
246
+ if href:
247
+ if not isinstance(href, str):
248
+ return _marker(node, unsupported)
249
+ image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
250
+ return image_markdown
251
+
252
+
253
+ def _render_footnote(node, unsupported):
254
+ attrs = _attrs(node, "footnote")
255
+ number = attrs.get("number")
256
+ if (
257
+ _has_unknown_keys(node, {"type", "attrs", "content"})
258
+ or _has_unknown_keys(attrs, {"number"})
259
+ or not isinstance(number, int)
260
+ ):
261
+ return _marker(node, unsupported)
262
+ body = "\n\n".join(
263
+ _render_block(child, unsupported) for child in _content(node, "footnote")
264
+ )
265
+ lines = body.splitlines() or [""]
266
+ return f"[^{number}]: {lines[0]}" + "".join(
267
+ f"\n {line}" if line else "\n" for line in lines[1:]
268
+ )
269
+
270
+
271
+ def _render_block(node, unsupported):
272
+ _require_dict(node, "block node")
273
+ node_type = node["type"]
274
+ if node_type not in _BLOCK_TYPES:
275
+ return _marker(node, unsupported)
276
+
277
+ if node_type == "paragraph":
278
+ if _has_unknown_keys(node, {"type", "content"}):
279
+ return _marker(node, unsupported)
280
+ return _render_inline(_content(node, "paragraph"), unsupported)
281
+ if node_type == "heading":
282
+ attrs = _attrs(node, "heading")
283
+ level = attrs.get("level")
284
+ if (
285
+ _has_unknown_keys(node, {"type", "content", "attrs"})
286
+ or _has_unknown_keys(attrs, {"level"})
287
+ or not isinstance(level, int)
288
+ or not 1 <= level <= 6
289
+ ):
290
+ return _marker(node, unsupported)
291
+ return f"{'#' * level} {_render_inline(_content(node, 'heading'), unsupported)}"
292
+ if node_type == "horizontal_rule":
293
+ return (
294
+ "---"
295
+ if not _has_unknown_keys(node, {"type"})
296
+ else _marker(node, unsupported)
297
+ )
298
+ if node_type == "codeBlock":
299
+ attrs = _attrs(node, "code block")
300
+ if _has_unknown_keys(node, {"type", "content", "attrs"}) or _has_unknown_keys(
301
+ attrs, {"language"}
302
+ ):
303
+ return _marker(node, unsupported)
304
+ code_nodes = _content(node, "code block")
305
+ if any(
306
+ not isinstance(child, dict)
307
+ or child.get("type") != "text"
308
+ or _has_unknown_keys(child, {"type", "text"})
309
+ or not isinstance(child.get("text"), str)
310
+ for child in code_nodes
311
+ ):
312
+ raise ValueError("Malformed draft body: invalid code block content")
313
+ code = "".join(child["text"] for child in code_nodes)
314
+ runs = [len(match.group(0)) for match in re.finditer(r"`+", code)]
315
+ fence = "`" * max(3, (max(runs) + 1) if runs else 3)
316
+ language = attrs.get("language") or ""
317
+ if not isinstance(language, str) or "\n" in language:
318
+ return _marker(node, unsupported)
319
+ return f"{fence}{language}\n{code}\n{fence}"
320
+ if node_type == "blockquote":
321
+ if _has_unknown_keys(node, {"type", "content"}):
322
+ return _marker(node, unsupported)
323
+ body = "\n\n".join(
324
+ _render_block(child, unsupported) for child in _content(node, "blockquote")
325
+ )
326
+ return "\n".join(">" if not line else f"> {line}" for line in body.splitlines())
327
+ if node_type in {"bullet_list", "ordered_list"}:
328
+ if _has_unknown_keys(node, {"type", "content"}):
329
+ return _marker(node, unsupported)
330
+ return _render_list(node, unsupported, ordered=node_type == "ordered_list")
331
+ if node_type == "captionedImage":
332
+ return _render_image(node, unsupported)
333
+ if node_type == "footnote":
334
+ return _render_footnote(node, unsupported)
335
+ if node_type == "latex_block":
336
+ attrs = _attrs(node, "math block")
337
+ expression = attrs.get("persistentExpression", attrs.get("expression"))
338
+ if (
339
+ _has_unknown_keys(node, {"type", "attrs"})
340
+ or _has_unknown_keys(attrs, {"persistentExpression", "expression", "dirty"})
341
+ or not isinstance(expression, str)
342
+ ):
343
+ return _marker(node, unsupported)
344
+ return f"$$\n{expression}\n$$"
345
+ if node_type in {"pullquote", "calloutBlock"}:
346
+ allowed = (
347
+ {"type", "content", "attrs"}
348
+ if node_type == "pullquote"
349
+ else {"type", "content"}
350
+ )
351
+ if _has_unknown_keys(node, allowed):
352
+ return _marker(node, unsupported)
353
+ if node_type == "pullquote":
354
+ attrs = _attrs(node, "pull quote")
355
+ if _has_unknown_keys(attrs, {"align", "color"}) or any(
356
+ attrs.get(key) is not None for key in ("align", "color")
357
+ ):
358
+ return _marker(node, unsupported)
359
+ body = "\n\n".join(
360
+ _render_block(child, unsupported) for child in _content(node, node_type)
361
+ )
362
+ name = "pullquote" if node_type == "pullquote" else "callout"
363
+ return f"::: {name}\n{body}\n:::"
364
+ raise AssertionError(node_type)
365
+
366
+
367
+ def document_to_markdown(document):
368
+ _require_dict(document, "document")
369
+ if document["type"] != "doc":
370
+ raise ValueError("Malformed draft body: root node must have type 'doc'")
371
+ if _has_unknown_keys(document, {"type", "content"}):
372
+ raise ValueError("Malformed draft body: document has unsupported root fields")
373
+
374
+ unsupported = []
375
+ blocks = [
376
+ _render_block(node, unsupported) for node in _content(document, "document")
377
+ ]
378
+ markdown = "\n\n".join(blocks)
379
+ if markdown:
380
+ markdown += "\n"
381
+ return markdown, unsupported
@@ -1,13 +0,0 @@
1
- substack/__init__.py,sha256=jF2QV8aP2zgNMtGcOY7FlX1cZdnUDHoON4qzOiQpqrA,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=3VTSSsiAmr4btImWpfoEl7irpTgqI-FH4q5r9r3u3w8,10949
9
- python_substack-0.4.0.dist-info/METADATA,sha256=CYRO_5NDtuzFfyyjI8CNZCLogy2NyMGGbyUBIdSgS5A,8405
10
- python_substack-0.4.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
11
- python_substack-0.4.0.dist-info/entry_points.txt,sha256=MKPjaBUd-0PtvxsBviStsVq1c0h8JZ_qUoYEsBK1xJc,236
12
- python_substack-0.4.0.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
13
- python_substack-0.4.0.dist-info/RECORD,,