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.
- {python_substack-0.4.0.dist-info → python_substack-0.6.0.dist-info}/METADATA +12 -2
- python_substack-0.6.0.dist-info/RECORD +14 -0
- substack/__init__.py +13 -13
- substack/api.py +857 -792
- substack/cli.py +41 -1
- substack/exceptions.py +44 -32
- substack/mdexport.py +381 -0
- python_substack-0.4.0.dist-info/RECORD +0 -13
- {python_substack-0.4.0.dist-info → python_substack-0.6.0.dist-info}/WHEEL +0 -0
- {python_substack-0.4.0.dist-info → python_substack-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_substack-0.4.0.dist-info → python_substack-0.6.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
|
|
|
@@ -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
|
-
|
|
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/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"}"
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|