python-substack 0.1.17__tar.gz → 0.1.19__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.17 → python_substack-0.1.19}/PKG-INFO +2 -2
- {python_substack-0.1.17 → python_substack-0.1.19}/pyproject.toml +2 -2
- {python_substack-0.1.17 → python_substack-0.1.19}/substack/api.py +61 -0
- {python_substack-0.1.17 → python_substack-0.1.19}/substack/post.py +133 -25
- {python_substack-0.1.17 → python_substack-0.1.19}/LICENSE +0 -0
- {python_substack-0.1.17 → python_substack-0.1.19}/README.md +0 -0
- {python_substack-0.1.17 → python_substack-0.1.19}/substack/__init__.py +0 -0
- {python_substack-0.1.17 → python_substack-0.1.19}/substack/exceptions.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.19
|
|
4
4
|
Summary: A Python wrapper around the Substack API.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Keywords: substack
|
|
8
8
|
Author: Paolo Mazza
|
|
9
9
|
Author-email: mazzapaolo2019@gmail.com
|
|
10
|
-
Requires-Python: >=3.9
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.9
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-substack"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.19"
|
|
4
4
|
description = "A Python wrapper around the Substack API."
|
|
5
5
|
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -16,7 +16,7 @@ homepage = "https://github.com/ma2za/python-substack"
|
|
|
16
16
|
keywords = ["substack"]
|
|
17
17
|
|
|
18
18
|
[tool.poetry.dependencies]
|
|
19
|
-
python = "
|
|
19
|
+
python = ">=3.9"
|
|
20
20
|
|
|
21
21
|
requests = "^2.31.0"
|
|
22
22
|
python-dotenv = "^0.21.0"
|
|
@@ -514,6 +514,67 @@ class Api:
|
|
|
514
514
|
data={"image": image},
|
|
515
515
|
)
|
|
516
516
|
return Api._handle_response(response=response)
|
|
517
|
+
|
|
518
|
+
def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
|
|
519
|
+
"""
|
|
520
|
+
Add multiple tags to a post.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
post_id: The ID of the post to tag.
|
|
524
|
+
tag_names: A list of tag names to add.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
A dictionary with the results of applying all tags.
|
|
528
|
+
"""
|
|
529
|
+
results = []
|
|
530
|
+
for tag_name in tag_names:
|
|
531
|
+
result = self.add_tag_to_post(post_id, tag_name)
|
|
532
|
+
results.append(result)
|
|
533
|
+
return {"tags_added": results}
|
|
534
|
+
|
|
535
|
+
def get_publication_post_tags(self) -> list:
|
|
536
|
+
"""
|
|
537
|
+
Retrieve all post tags for the current publication.
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
List of tag dicts as returned by Substack API.
|
|
541
|
+
"""
|
|
542
|
+
response = self._session.get(f"{self.publication_url}/publication/post-tag")
|
|
543
|
+
return Api._handle_response(response=response)
|
|
544
|
+
|
|
545
|
+
def add_tag_to_post(self, post_id: int, tag_name: str) -> dict:
|
|
546
|
+
"""
|
|
547
|
+
Add a tag to a post by first checking published tags and creating only if needed.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
post_id: The ID of the post to tag.
|
|
551
|
+
tag_name: The name of the tag to add.
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
The response from applying the tag to the post.
|
|
555
|
+
"""
|
|
556
|
+
# Fetch existing publication tags first (avoid re-creating an already existing tag)
|
|
557
|
+
existing_tags = self.get_publication_post_tags() or []
|
|
558
|
+
existing_tag = next(
|
|
559
|
+
(tag for tag in existing_tags if tag.get("name") == tag_name),
|
|
560
|
+
None,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if existing_tag is not None:
|
|
564
|
+
tag_id = existing_tag["id"]
|
|
565
|
+
else:
|
|
566
|
+
create_tag_response = self._session.post(
|
|
567
|
+
f"{self.publication_url}/publication/post-tag",
|
|
568
|
+
json={"name": tag_name},
|
|
569
|
+
)
|
|
570
|
+
tag_data = Api._handle_response(create_tag_response)
|
|
571
|
+
tag_id = tag_data["id"]
|
|
572
|
+
|
|
573
|
+
apply_tag_response = self._session.post(
|
|
574
|
+
f"{self.publication_url}/post/{post_id}/tag/{tag_id}",
|
|
575
|
+
)
|
|
576
|
+
return Api._handle_response(apply_tag_response)
|
|
577
|
+
|
|
517
578
|
|
|
518
579
|
def get_categories(self):
|
|
519
580
|
"""
|
|
@@ -39,40 +39,41 @@ def parse_inline(text: str) -> List[Dict]:
|
|
|
39
39
|
tokens = []
|
|
40
40
|
# Process text character by character to handle nested formatting
|
|
41
41
|
# We'll use regex to find all markdown patterns, then process them in order
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
# Find all markdown patterns: links, bold, italic
|
|
44
44
|
# Pattern order: links first (to avoid conflicts), then bold, then italic
|
|
45
45
|
link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
|
|
46
46
|
bold_pattern = r'\*\*([^*]+)\*\*'
|
|
47
47
|
italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
# Find all matches with their positions
|
|
50
50
|
matches = []
|
|
51
51
|
for match in re.finditer(link_pattern, text):
|
|
52
52
|
# Skip if it's an image link (starts with ![)
|
|
53
|
-
|
|
53
|
+
# But do NOT skip normal links at position 0.
|
|
54
|
+
if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
|
|
54
55
|
matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
|
|
55
|
-
|
|
56
|
+
|
|
56
57
|
for match in re.finditer(bold_pattern, text):
|
|
57
58
|
# Check if this range is already covered by a link
|
|
58
59
|
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
59
60
|
matches.append((match.start(), match.end(), "bold", match.group(1), None))
|
|
60
|
-
|
|
61
|
+
|
|
61
62
|
for match in re.finditer(italic_pattern, text):
|
|
62
63
|
# Check if this range is already covered by a link or bold
|
|
63
64
|
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
64
65
|
matches.append((match.start(), match.end(), "italic", match.group(1), None))
|
|
65
|
-
|
|
66
|
+
|
|
66
67
|
# Sort matches by position
|
|
67
68
|
matches.sort(key=lambda x: x[0])
|
|
68
|
-
|
|
69
|
+
|
|
69
70
|
# Build tokens
|
|
70
71
|
last_pos = 0
|
|
71
72
|
for start, end, match_type, content, url in matches:
|
|
72
73
|
# Add text before this match
|
|
73
74
|
if start > last_pos:
|
|
74
75
|
tokens.append({"content": text[last_pos:start]})
|
|
75
|
-
|
|
76
|
+
|
|
76
77
|
# Add the formatted content
|
|
77
78
|
if match_type == "link":
|
|
78
79
|
tokens.append({
|
|
@@ -89,16 +90,16 @@ def parse_inline(text: str) -> List[Dict]:
|
|
|
89
90
|
"content": content,
|
|
90
91
|
"marks": [{"type": "em"}]
|
|
91
92
|
})
|
|
92
|
-
|
|
93
|
+
|
|
93
94
|
last_pos = end
|
|
94
|
-
|
|
95
|
+
|
|
95
96
|
# Add remaining text
|
|
96
97
|
if last_pos < len(text):
|
|
97
98
|
tokens.append({"content": text[last_pos:]})
|
|
98
|
-
|
|
99
|
+
|
|
99
100
|
# Filter out empty tokens
|
|
100
101
|
tokens = [t for t in tokens if t.get("content")]
|
|
101
|
-
|
|
102
|
+
|
|
102
103
|
return tokens
|
|
103
104
|
|
|
104
105
|
|
|
@@ -226,6 +227,42 @@ class Post:
|
|
|
226
227
|
item["level"] = level
|
|
227
228
|
return self.add(item)
|
|
228
229
|
|
|
230
|
+
def blockquote(self, content=None):
|
|
231
|
+
"""
|
|
232
|
+
Add a blockquote to the post.
|
|
233
|
+
|
|
234
|
+
The blockquote wraps one or more paragraph nodes.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
content: Text string or list of inline token dicts. When a plain
|
|
238
|
+
string is provided it is wrapped in a single paragraph node.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Self for method chaining.
|
|
242
|
+
"""
|
|
243
|
+
paragraphs: List[Dict] = []
|
|
244
|
+
if content is not None:
|
|
245
|
+
if isinstance(content, str):
|
|
246
|
+
tokens = parse_inline(content)
|
|
247
|
+
text_nodes = [
|
|
248
|
+
{"type": "text", "text": t["content"]} for t in tokens if t
|
|
249
|
+
]
|
|
250
|
+
if text_nodes:
|
|
251
|
+
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
252
|
+
elif isinstance(content, list):
|
|
253
|
+
for item in content:
|
|
254
|
+
if isinstance(item, dict) and item.get("type") == "paragraph":
|
|
255
|
+
paragraphs.append(item)
|
|
256
|
+
elif isinstance(item, dict):
|
|
257
|
+
text_nodes = [{"type": "text", "text": item.get("content", "")}]
|
|
258
|
+
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
259
|
+
|
|
260
|
+
node: Dict = {"type": "blockquote"}
|
|
261
|
+
if paragraphs:
|
|
262
|
+
node["content"] = paragraphs
|
|
263
|
+
self.draft_body["content"] = self.draft_body.get("content", []) + [node]
|
|
264
|
+
return self
|
|
265
|
+
|
|
229
266
|
def horizontal_rule(self):
|
|
230
267
|
"""
|
|
231
268
|
|
|
@@ -351,7 +388,7 @@ class Post:
|
|
|
351
388
|
for mark in marks:
|
|
352
389
|
new_mark = {"type": mark.get("type")}
|
|
353
390
|
if mark.get("type") == "link":
|
|
354
|
-
href = mark.get("href")
|
|
391
|
+
href = mark.get("href") or mark.get("attrs", {}).get("href")
|
|
355
392
|
new_mark.update({"attrs": {"href": href}})
|
|
356
393
|
content_marks.append(new_mark)
|
|
357
394
|
content["marks"] = content_marks
|
|
@@ -463,6 +500,7 @@ class Post:
|
|
|
463
500
|
- Linked images: [](link_url) - images that are also links
|
|
464
501
|
- Links: [text](url) - inline links in paragraphs
|
|
465
502
|
- Code blocks: Fenced code blocks with ```language or ```
|
|
503
|
+
- Blockquotes: Lines starting with '>' (consecutive lines grouped)
|
|
466
504
|
- Paragraphs: Regular text blocks
|
|
467
505
|
- Bullet lists: Lines starting with '*' or '-'
|
|
468
506
|
- Inline formatting: **bold** and *italic* within paragraphs
|
|
@@ -572,7 +610,7 @@ class Post:
|
|
|
572
610
|
alt_text = linked_image_match.group(1)
|
|
573
611
|
image_url = linked_image_match.group(2)
|
|
574
612
|
link_url = linked_image_match.group(3)
|
|
575
|
-
|
|
613
|
+
|
|
576
614
|
# Adjust image URL if it starts with a slash
|
|
577
615
|
image_url = image_url[1:] if image_url.startswith("/") else image_url
|
|
578
616
|
|
|
@@ -610,28 +648,98 @@ class Post:
|
|
|
610
648
|
|
|
611
649
|
self.add({"type": "captionedImage", "src": image_url})
|
|
612
650
|
|
|
613
|
-
# Process paragraphs
|
|
651
|
+
# Process paragraphs, bullet lists, or blockquotes
|
|
614
652
|
else:
|
|
615
653
|
if "\n" in text_content:
|
|
616
|
-
# Process each line
|
|
654
|
+
# Process each line, grouping consecutive bullets
|
|
655
|
+
# into a single bullet_list node and consecutive
|
|
656
|
+
# blockquote lines into a single blockquote node.
|
|
657
|
+
pending_bullets: List[List[Dict]] = []
|
|
658
|
+
pending_quotes: List[str] = []
|
|
659
|
+
|
|
660
|
+
def flush_bullets():
|
|
661
|
+
if not pending_bullets:
|
|
662
|
+
return
|
|
663
|
+
list_items = []
|
|
664
|
+
for bullet_nodes in pending_bullets:
|
|
665
|
+
list_items.append({
|
|
666
|
+
"type": "list_item",
|
|
667
|
+
"content": [{"type": "paragraph", "content": bullet_nodes}],
|
|
668
|
+
})
|
|
669
|
+
self.draft_body["content"].append(
|
|
670
|
+
{"type": "bullet_list", "content": list_items}
|
|
671
|
+
)
|
|
672
|
+
pending_bullets.clear()
|
|
673
|
+
|
|
674
|
+
def flush_quotes():
|
|
675
|
+
if not pending_quotes:
|
|
676
|
+
return
|
|
677
|
+
paragraphs: List[Dict] = []
|
|
678
|
+
for quote_line in pending_quotes:
|
|
679
|
+
tokens = parse_inline(quote_line)
|
|
680
|
+
text_nodes = [
|
|
681
|
+
{"type": "text", "text": t["content"]}
|
|
682
|
+
for t in tokens if t
|
|
683
|
+
]
|
|
684
|
+
if text_nodes:
|
|
685
|
+
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
686
|
+
node: Dict = {"type": "blockquote"}
|
|
687
|
+
if paragraphs:
|
|
688
|
+
node["content"] = paragraphs
|
|
689
|
+
self.draft_body["content"].append(node)
|
|
690
|
+
pending_quotes.clear()
|
|
691
|
+
|
|
617
692
|
for line in text_content.split("\n"):
|
|
618
693
|
line = line.strip()
|
|
619
694
|
if not line:
|
|
695
|
+
flush_bullets()
|
|
696
|
+
flush_quotes()
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
# Check for blockquote marker
|
|
700
|
+
if line.startswith("> ") or line == ">":
|
|
701
|
+
flush_bullets()
|
|
702
|
+
quote_text = line[2:] if line.startswith("> ") else ""
|
|
703
|
+
pending_quotes.append(quote_text)
|
|
620
704
|
continue
|
|
621
|
-
|
|
705
|
+
|
|
706
|
+
# Check for bullet marker
|
|
707
|
+
bullet_text = None
|
|
622
708
|
if line.startswith("* "):
|
|
623
|
-
|
|
709
|
+
bullet_text = line[2:].strip()
|
|
624
710
|
elif line.startswith("- "):
|
|
625
|
-
|
|
711
|
+
bullet_text = line[2:].strip()
|
|
626
712
|
elif line.startswith("*") and not line.startswith("**"):
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if
|
|
713
|
+
bullet_text = line[1:].strip()
|
|
714
|
+
|
|
715
|
+
if bullet_text is not None:
|
|
716
|
+
flush_quotes()
|
|
717
|
+
tokens = parse_inline(bullet_text)
|
|
718
|
+
if tokens:
|
|
719
|
+
pending_bullets.append(tokens)
|
|
720
|
+
else:
|
|
721
|
+
flush_bullets()
|
|
722
|
+
flush_quotes()
|
|
630
723
|
tokens = parse_inline(line)
|
|
631
724
|
self.add({"type": "paragraph", "content": tokens})
|
|
725
|
+
|
|
726
|
+
flush_bullets()
|
|
727
|
+
flush_quotes()
|
|
632
728
|
else:
|
|
633
|
-
# Single paragraph
|
|
634
|
-
|
|
635
|
-
|
|
729
|
+
# Single line — could be a blockquote or paragraph
|
|
730
|
+
if text_content.startswith("> ") or text_content == ">":
|
|
731
|
+
quote_text = text_content[2:] if text_content.startswith("> ") else ""
|
|
732
|
+
tokens = parse_inline(quote_text)
|
|
733
|
+
text_nodes = [
|
|
734
|
+
{"type": "text", "text": t["content"]}
|
|
735
|
+
for t in tokens if t
|
|
736
|
+
]
|
|
737
|
+
para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
|
|
738
|
+
self.draft_body["content"] = self.draft_body.get("content", []) + [
|
|
739
|
+
{"type": "blockquote", "content": [para]}
|
|
740
|
+
]
|
|
741
|
+
else:
|
|
742
|
+
tokens = parse_inline(text_content)
|
|
743
|
+
self.add({"type": "paragraph", "content": tokens})
|
|
636
744
|
|
|
637
745
|
return self
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|