python-substack 0.1.16__py3-none-any.whl → 0.1.18__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.
@@ -1,17 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.16
3
+ Version: 0.1.18
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.7,<4.0
10
+ Requires-Python: >=3.9,<4.0
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.7
14
- Classifier: Programming Language :: Python :: 3.8
15
13
  Classifier: Programming Language :: Python :: 3.9
16
14
  Classifier: Programming Language :: Python :: 3.10
17
15
  Classifier: Programming Language :: Python :: 3.11
@@ -30,7 +28,6 @@ Description-Content-Type: text/markdown
30
28
  This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
31
29
  I am in no way affiliated with Substack.
32
30
 
33
- [![Python](https://img.shields.io/pypi/pyversions/fastapi.svg?color=%2334D058)](https://www.python.org/downloads/)
34
31
  [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
35
32
  ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
36
33
  ---
@@ -72,18 +69,49 @@ The .env file will be ignored by git but always be careful.
72
69
 
73
70
  Check out the examples folder for some examples 😃 🚀
74
71
 
72
+ ## Basic Authentication
73
+
75
74
  ```python
76
75
  import os
76
+ from dotenv import load_dotenv
77
77
 
78
78
  from substack import Api
79
79
  from substack.post import Post
80
80
 
81
+ load_dotenv()
82
+
83
+ # Authenticate with email and password
81
84
  api = Api(
82
85
  email=os.getenv("EMAIL"),
83
86
  password=os.getenv("PASSWORD"),
84
87
  publication_url=os.getenv("PUBLICATION_URL"),
85
88
  )
89
+ ```
90
+
91
+ ## Cookie-based Authentication
92
+
93
+ You can also authenticate using cookies instead of email/password:
94
+
95
+ ```python
96
+ import os
97
+ from dotenv import load_dotenv
98
+
99
+ from substack import Api
100
+
101
+ load_dotenv()
86
102
 
103
+ # Authenticate with cookies (alternative to email/password)
104
+ api = Api(
105
+ cookies_path=os.getenv("COOKIES_PATH"), # Path to cookies JSON file
106
+ # OR
107
+ cookies_string=os.getenv("COOKIES_STRING"), # Cookie string
108
+ publication_url=os.getenv("PUBLICATION_URL"),
109
+ )
110
+ ```
111
+
112
+ ## Creating and Publishing Posts
113
+
114
+ ```python
87
115
  user_id = api.get_user_id()
88
116
 
89
117
  # Switch Publications - The library defaults to your user's primary publication. You can retrieve all your publications and change which one you want to use.
@@ -96,12 +124,22 @@ user_publications = api.get_user_publications()
96
124
  # This step is only necessary if you are not using your primary publication
97
125
  # api.change_publication(user_publication)
98
126
 
127
+ # Create a post with basic settings
99
128
  post = Post(
100
129
  title="How to publish a Substack post using the Python API",
101
130
  subtitle="This post was published using the Python API",
102
131
  user_id=user_id
103
132
  )
104
133
 
134
+ # Create a post with audience and comment permissions
135
+ post = Post(
136
+ title="My Post Title",
137
+ subtitle="My Post Subtitle",
138
+ user_id=user_id,
139
+ audience="everyone", # Options: "everyone", "only_paid", "founding", "only_free"
140
+ write_comment_permissions="everyone" # Options: "none", "only_paid", "everyone"
141
+ )
142
+
105
143
  post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
106
144
 
107
145
  # bolden text
@@ -127,6 +165,16 @@ post.add({"type": "captionedImage", "src": image.get("url")})
127
165
  embedded = api.publication_embed("https://jackio.substack.com/")
128
166
  post.add({"type": "embeddedPublication", "url": embedded})
129
167
 
168
+ # create post from Markdown
169
+ markdown_content = """
170
+ # My Heading
171
+
172
+ This is a paragraph with **bold** and *italic* text.
173
+
174
+ ![Image Alt](https://example.com/image.jpg)
175
+ """
176
+ post.from_markdown(markdown_content, api=api)
177
+
130
178
  draft = api.post_draft(post.get_draft())
131
179
 
132
180
  # set section (can only be done after first posting the draft)
@@ -138,6 +186,85 @@ api.prepublish_draft(draft.get("id"))
138
186
  api.publish_draft(draft.get("id"))
139
187
  ```
140
188
 
189
+ ## Loading Posts from YAML Files
190
+
191
+ You can define your posts in YAML files for easier management:
192
+
193
+ ```python
194
+ import yaml
195
+ import os
196
+ from dotenv import load_dotenv
197
+
198
+ from substack import Api
199
+ from substack.post import Post
200
+
201
+ load_dotenv()
202
+
203
+ # Load post data from YAML file
204
+ with open("draft.yaml", "r") as fp:
205
+ post_data = yaml.safe_load(fp)
206
+
207
+ # Authenticate (using cookies or email/password)
208
+ cookies_path = os.getenv("COOKIES_PATH")
209
+ cookies_string = os.getenv("COOKIES_STRING")
210
+
211
+ api = Api(
212
+ email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
213
+ password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
214
+ cookies_path=cookies_path,
215
+ cookies_string=cookies_string,
216
+ publication_url=os.getenv("PUBLICATION_URL"),
217
+ )
218
+
219
+ user_id = api.get_user_id()
220
+
221
+ # Create post from YAML data
222
+ post = Post(
223
+ post_data.get("title"),
224
+ post_data.get("subtitle", ""),
225
+ user_id,
226
+ audience=post_data.get("audience", "everyone"),
227
+ write_comment_permissions=post_data.get("write_comment_permissions", "everyone"),
228
+ )
229
+
230
+ # Add body content from YAML
231
+ body = post_data.get("body", {})
232
+ for _, item in body.items():
233
+ # Handle local images - upload them first
234
+ if item.get("type") == "captionedImage" and not item.get("src").startswith("http"):
235
+ image = api.get_image(item.get("src"))
236
+ item.update({"src": image.get("url")})
237
+ post.add(item)
238
+
239
+ draft = api.post_draft(post.get_draft())
240
+ api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
241
+
242
+ # Publish the draft
243
+ api.prepublish_draft(draft.get("id"))
244
+ api.publish_draft(draft.get("id"))
245
+ ```
246
+
247
+ Example YAML structure:
248
+
249
+ ```yaml
250
+ title: "My Post Title"
251
+ subtitle: "My Post Subtitle"
252
+ audience: "everyone" # everyone, only_paid, founding, only_free
253
+ write_comment_permissions: "everyone" # none, only_paid, everyone
254
+ section: "my-section"
255
+ body:
256
+ 0:
257
+ type: "heading"
258
+ level: 1
259
+ content: "Introduction"
260
+ 1:
261
+ type: "paragraph"
262
+ content: "This is a paragraph."
263
+ 2:
264
+ type: "captionedImage"
265
+ src: "local_image.jpg" # Local images will be uploaded automatically
266
+ ```
267
+
141
268
  # Contributing
142
269
 
143
270
  Install pre-commit:
@@ -0,0 +1,8 @@
1
+ substack/__init__.py,sha256=mkNj8jFW6wA4dIYyyM1UfKt5Q9MgR8xg3CaADl4IjlQ,387
2
+ substack/api.py,sha256=KFSeStlfmri1qyO_5rOT41dd7QWV-iHqyMbQyVSd5-k,18336
3
+ substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
4
+ substack/post.py,sha256=EYGMRa3__zgJToOuZJ37OJiJR8N8UNIjrDepDG9OTbg,22095
5
+ python_substack-0.1.18.dist-info/METADATA,sha256=oyQawtVvlwzEaZiNa6eoeDwYVd-YTmR_UDGmIFmpNrE,8007
6
+ python_substack-0.1.18.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
7
+ python_substack-0.1.18.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
8
+ python_substack-0.1.18.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
substack/post.py CHANGED
@@ -5,13 +5,104 @@ Post Utilities
5
5
  """
6
6
 
7
7
  import json
8
- from typing import Dict
8
+ import re
9
+ from typing import Dict, List
9
10
 
10
- __all__ = ["Post"]
11
+ __all__ = ["Post", "parse_inline"]
11
12
 
12
13
  from substack.exceptions import SectionNotExistsException
13
14
 
14
15
 
16
+ def parse_inline(text: str) -> List[Dict]:
17
+ """
18
+ Convert inline Markdown in a text string into a list of tokens
19
+ for use in the post content.
20
+
21
+ Supported formatting:
22
+ - **Bold**: Text wrapped in double asterisks.
23
+ - *Italic*: Text wrapped in single asterisks.
24
+ - [Links]: Text wrapped in square brackets followed by URL in parentheses.
25
+
26
+ Args:
27
+ text: Text string containing inline Markdown formatting.
28
+
29
+ Returns:
30
+ List of token dictionaries with content and marks.
31
+
32
+ Example:
33
+ >>> parse_inline("This is **bold** and this is [a link](https://example.com)")
34
+ [{'content': 'This is '}, {'content': 'bold', 'marks': [{'type': 'strong'}]}, {'content': ' and this is '}, {'content': 'a link', 'marks': [{'type': 'link', 'attrs': {'href': 'https://example.com'}}]}]
35
+ """
36
+ if not text:
37
+ return []
38
+
39
+ tokens = []
40
+ # Process text character by character to handle nested formatting
41
+ # We'll use regex to find all markdown patterns, then process them in order
42
+
43
+ # Find all markdown patterns: links, bold, italic
44
+ # Pattern order: links first (to avoid conflicts), then bold, then italic
45
+ link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
46
+ bold_pattern = r'\*\*([^*]+)\*\*'
47
+ italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
48
+
49
+ # Find all matches with their positions
50
+ matches = []
51
+ for match in re.finditer(link_pattern, text):
52
+ # Skip if it's an image link (starts with ![)
53
+ # But do NOT skip normal links at position 0.
54
+ if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
55
+ matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
56
+
57
+ for match in re.finditer(bold_pattern, text):
58
+ # Check if this range is already covered by a link
59
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
60
+ matches.append((match.start(), match.end(), "bold", match.group(1), None))
61
+
62
+ for match in re.finditer(italic_pattern, text):
63
+ # Check if this range is already covered by a link or bold
64
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
65
+ matches.append((match.start(), match.end(), "italic", match.group(1), None))
66
+
67
+ # Sort matches by position
68
+ matches.sort(key=lambda x: x[0])
69
+
70
+ # Build tokens
71
+ last_pos = 0
72
+ for start, end, match_type, content, url in matches:
73
+ # Add text before this match
74
+ if start > last_pos:
75
+ tokens.append({"content": text[last_pos:start]})
76
+
77
+ # Add the formatted content
78
+ if match_type == "link":
79
+ tokens.append({
80
+ "content": content,
81
+ "marks": [{"type": "link", "attrs": {"href": url}}]
82
+ })
83
+ elif match_type == "bold":
84
+ tokens.append({
85
+ "content": content,
86
+ "marks": [{"type": "strong"}]
87
+ })
88
+ elif match_type == "italic":
89
+ tokens.append({
90
+ "content": content,
91
+ "marks": [{"type": "em"}]
92
+ })
93
+
94
+ last_pos = end
95
+
96
+ # Add remaining text
97
+ if last_pos < len(text):
98
+ tokens.append({"content": text[last_pos:]})
99
+
100
+ # Filter out empty tokens
101
+ tokens = [t for t in tokens if t.get("content")]
102
+
103
+ return tokens
104
+
105
+
15
106
  class Post:
16
107
  """
17
108
 
@@ -90,6 +181,8 @@ class Post:
90
181
  self.youtube(item.get("src"))
91
182
  elif item.get("type") == "subscribeWidget":
92
183
  self.subscribe_with_caption(item.get("message"))
184
+ elif item.get("type") == "codeBlock":
185
+ self.code_block(item.get("content"), item.get("attrs", {}))
93
186
  else:
94
187
  if content is not None:
95
188
  self.add_complex_text(content)
@@ -259,7 +352,7 @@ class Post:
259
352
  for mark in marks:
260
353
  new_mark = {"type": mark.get("type")}
261
354
  if mark.get("type") == "link":
262
- href = mark.get("href")
355
+ href = mark.get("href") or mark.get("attrs", {}).get("href")
263
356
  new_mark.update({"attrs": {"href": href}})
264
357
  content_marks.append(new_mark)
265
358
  content["marks"] = content_marks
@@ -329,3 +422,244 @@ class Post:
329
422
  content_attrs.update({"videoId": value})
330
423
  self.draft_body["content"][-1]["attrs"] = content_attrs
331
424
  return self
425
+
426
+ def code_block(self, content, attrs=None):
427
+ """
428
+ Add code block to post.
429
+
430
+ Args:
431
+ content: String containing code or list of text nodes
432
+ attrs: Optional attributes like language
433
+
434
+ Returns:
435
+
436
+ """
437
+ if attrs is None:
438
+ attrs = {}
439
+
440
+ # Handle content - can be list of text nodes or a string
441
+ if isinstance(content, str):
442
+ # Convert string to list of text nodes
443
+ code_content = [{"type": "text", "text": content}]
444
+ elif isinstance(content, list):
445
+ code_content = content
446
+ else:
447
+ code_content = []
448
+
449
+ # Set up the code block structure
450
+ code_block = self.draft_body["content"][-1]
451
+ code_block["content"] = code_content
452
+ if attrs:
453
+ code_block["attrs"] = attrs
454
+
455
+ return self
456
+
457
+ def from_markdown(self, markdown_content: str, api=None):
458
+ """
459
+ Parse Markdown content and add it to the post.
460
+
461
+ Supported Markdown features:
462
+ - Headings: Lines starting with '#' characters (1-6 levels)
463
+ - Images: Markdown image syntax ![Alt](URL)
464
+ - Linked images: [![Alt](image_url)](link_url) - images that are also links
465
+ - Links: [text](url) - inline links in paragraphs
466
+ - Code blocks: Fenced code blocks with ```language or ```
467
+ - Paragraphs: Regular text blocks
468
+ - Bullet lists: Lines starting with '*' or '-'
469
+ - Inline formatting: **bold** and *italic* within paragraphs
470
+
471
+ Args:
472
+ markdown_content: Markdown string to parse and add to the post.
473
+ api: Optional Api instance for uploading local images. If provided,
474
+ local image paths will be uploaded via api.get_image().
475
+
476
+ Returns:
477
+ Self for method chaining.
478
+
479
+ Example:
480
+ >>> post = Post("Title", "Subtitle", user_id)
481
+ >>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
482
+ """
483
+ lines = markdown_content.split("\n")
484
+ blocks = []
485
+ current_block: List[str] = []
486
+ in_code_block = False
487
+ code_block_language = None
488
+
489
+ for line in lines:
490
+ # Check for fenced code block start/end
491
+ if line.strip().startswith("```"):
492
+ if in_code_block:
493
+ # End of code block
494
+ if current_block:
495
+ blocks.append({
496
+ "type": "code",
497
+ "language": code_block_language,
498
+ "content": "\n".join(current_block)
499
+ })
500
+ current_block = []
501
+ in_code_block = False
502
+ code_block_language = None
503
+ else:
504
+ # Start of code block
505
+ if current_block:
506
+ blocks.append({"type": "text", "content": "\n".join(current_block)})
507
+ current_block = []
508
+ # Extract language if specified
509
+ language = line.strip()[3:].strip()
510
+ code_block_language = language if language else None
511
+ in_code_block = True
512
+ continue
513
+
514
+ if in_code_block:
515
+ # Inside code block - collect lines as-is
516
+ current_block.append(line)
517
+ else:
518
+ # Regular content
519
+ if line.strip() == "":
520
+ # Empty line - end current block if it has content
521
+ if current_block:
522
+ blocks.append({"type": "text", "content": "\n".join(current_block)})
523
+ current_block = []
524
+ else:
525
+ current_block.append(line)
526
+
527
+ # Add any remaining content
528
+ if current_block:
529
+ if in_code_block:
530
+ blocks.append({
531
+ "type": "code",
532
+ "language": code_block_language,
533
+ "content": "\n".join(current_block)
534
+ })
535
+ else:
536
+ blocks.append({"type": "text", "content": "\n".join(current_block)})
537
+
538
+ # Process blocks
539
+ for block in blocks:
540
+ if block["type"] == "code":
541
+ # Add code block
542
+ code_content = block.get("content", "").strip()
543
+ if code_content:
544
+ # Substack uses "codeBlock" type
545
+ code_attrs = {}
546
+ if block.get("language"):
547
+ code_attrs["language"] = block["language"]
548
+ self.add({
549
+ "type": "codeBlock",
550
+ "content": code_content, # Pass as string, code_block method will handle it
551
+ "attrs": code_attrs
552
+ })
553
+ else:
554
+ # Process text block
555
+ text_content = block.get("content", "").strip()
556
+ if not text_content:
557
+ continue
558
+
559
+ # Process headings (lines starting with '#' characters)
560
+ if text_content.startswith("#"):
561
+ level = len(text_content) - len(text_content.lstrip("#"))
562
+ heading_text = text_content.lstrip("#").strip()
563
+ if heading_text: # Only add if there's actual text
564
+ self.heading(content=heading_text, level=min(level, 6))
565
+
566
+ # Process images using Markdown image syntax: ![Alt](URL)
567
+ # Also handle linked images: [![Alt](image_url)](link_url)
568
+ elif text_content.startswith("!") or (text_content.startswith("[") and "![" in text_content):
569
+ # Check for linked image first: [![alt](img)](link)
570
+ linked_image_match = re.match(r'\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)', text_content)
571
+ if linked_image_match:
572
+ # Linked image - create image with href
573
+ alt_text = linked_image_match.group(1)
574
+ image_url = linked_image_match.group(2)
575
+ link_url = linked_image_match.group(3)
576
+
577
+ # Adjust image URL if it starts with a slash
578
+ image_url = image_url[1:] if image_url.startswith("/") else image_url
579
+
580
+ # If api is provided and image_url is a local file, upload it
581
+ if api is not None:
582
+ try:
583
+ image = api.get_image(image_url)
584
+ image_url = image.get("url")
585
+ except Exception:
586
+ # If upload fails, use original URL
587
+ pass
588
+
589
+ self.add({
590
+ "type": "captionedImage",
591
+ "src": image_url,
592
+ "alt": alt_text,
593
+ "href": link_url
594
+ })
595
+ else:
596
+ # Regular image: ![Alt](URL)
597
+ match = re.match(r"!\[.*?\]\((.*?)\)", text_content)
598
+ if match:
599
+ image_url = match.group(1)
600
+ # Adjust image URL if it starts with a slash
601
+ image_url = image_url[1:] if image_url.startswith("/") else image_url
602
+
603
+ # If api is provided and image_url is a local file, upload it
604
+ if api is not None:
605
+ try:
606
+ image = api.get_image(image_url)
607
+ image_url = image.get("url")
608
+ except Exception:
609
+ # If upload fails, use original URL
610
+ pass
611
+
612
+ self.add({"type": "captionedImage", "src": image_url})
613
+
614
+ # Process paragraphs or bullet lists
615
+ else:
616
+ if "\n" in text_content:
617
+ # Process each line, grouping consecutive bullets
618
+ # into a single bullet_list node
619
+ pending_bullets: List[List[Dict]] = []
620
+
621
+ def flush_bullets():
622
+ if not pending_bullets:
623
+ return
624
+ list_items = []
625
+ for bullet_nodes in pending_bullets:
626
+ list_items.append({
627
+ "type": "list_item",
628
+ "content": [{"type": "paragraph", "content": bullet_nodes}],
629
+ })
630
+ self.draft_body["content"].append(
631
+ {"type": "bullet_list", "content": list_items}
632
+ )
633
+ pending_bullets.clear()
634
+
635
+ for line in text_content.split("\n"):
636
+ line = line.strip()
637
+ if not line:
638
+ flush_bullets()
639
+ continue
640
+
641
+ # Check for bullet marker
642
+ bullet_text = None
643
+ if line.startswith("* "):
644
+ bullet_text = line[2:].strip()
645
+ elif line.startswith("- "):
646
+ bullet_text = line[2:].strip()
647
+ elif line.startswith("*") and not line.startswith("**"):
648
+ bullet_text = line[1:].strip()
649
+
650
+ if bullet_text is not None:
651
+ tokens = parse_inline(bullet_text)
652
+ if tokens:
653
+ pending_bullets.append(tokens)
654
+ else:
655
+ flush_bullets()
656
+ tokens = parse_inline(line)
657
+ self.add({"type": "paragraph", "content": tokens})
658
+
659
+ flush_bullets()
660
+ else:
661
+ # Single paragraph
662
+ tokens = parse_inline(text_content)
663
+ self.add({"type": "paragraph", "content": tokens})
664
+
665
+ return self
@@ -1,8 +0,0 @@
1
- substack/__init__.py,sha256=mkNj8jFW6wA4dIYyyM1UfKt5Q9MgR8xg3CaADl4IjlQ,387
2
- substack/api.py,sha256=KFSeStlfmri1qyO_5rOT41dd7QWV-iHqyMbQyVSd5-k,18336
3
- substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
4
- substack/post.py,sha256=rcnTfWUqfuocvHN61UxidbAiX4Y7LtcvSAkLjjsxqxM,7997
5
- python_substack-0.1.16.dist-info/METADATA,sha256=GVC0syynaz3zkNQLr3GzjV-mgA6mOCXALwcqfQs5X5w,4925
6
- python_substack-0.1.16.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
7
- python_substack-0.1.16.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
8
- python_substack-0.1.16.dist-info/RECORD,,