python-substack 0.1.16__py3-none-any.whl → 0.1.17__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.17
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=KVwEBeQp32CtzUJPZFf3yACJaPzUiJ0zoZZfyWcqnlM,20820
5
+ python_substack-0.1.17.dist-info/METADATA,sha256=HZKwKT1VRGn7z37sYFn3u4XyIO7up2c7tv0ejuu0Ago,8007
6
+ python_substack-0.1.17.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
7
+ python_substack-0.1.17.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
8
+ python_substack-0.1.17.dist-info/RECORD,,
substack/post.py CHANGED
@@ -5,13 +5,103 @@ 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
+ if match.start() > 0 and text[match.start()-1:match.start()+1] != "![":
54
+ matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
55
+
56
+ for match in re.finditer(bold_pattern, text):
57
+ # Check if this range is already covered by a link
58
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
59
+ matches.append((match.start(), match.end(), "bold", match.group(1), None))
60
+
61
+ for match in re.finditer(italic_pattern, text):
62
+ # Check if this range is already covered by a link or bold
63
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
64
+ matches.append((match.start(), match.end(), "italic", match.group(1), None))
65
+
66
+ # Sort matches by position
67
+ matches.sort(key=lambda x: x[0])
68
+
69
+ # Build tokens
70
+ last_pos = 0
71
+ for start, end, match_type, content, url in matches:
72
+ # Add text before this match
73
+ if start > last_pos:
74
+ tokens.append({"content": text[last_pos:start]})
75
+
76
+ # Add the formatted content
77
+ if match_type == "link":
78
+ tokens.append({
79
+ "content": content,
80
+ "marks": [{"type": "link", "attrs": {"href": url}}]
81
+ })
82
+ elif match_type == "bold":
83
+ tokens.append({
84
+ "content": content,
85
+ "marks": [{"type": "strong"}]
86
+ })
87
+ elif match_type == "italic":
88
+ tokens.append({
89
+ "content": content,
90
+ "marks": [{"type": "em"}]
91
+ })
92
+
93
+ last_pos = end
94
+
95
+ # Add remaining text
96
+ if last_pos < len(text):
97
+ tokens.append({"content": text[last_pos:]})
98
+
99
+ # Filter out empty tokens
100
+ tokens = [t for t in tokens if t.get("content")]
101
+
102
+ return tokens
103
+
104
+
15
105
  class Post:
16
106
  """
17
107
 
@@ -90,6 +180,8 @@ class Post:
90
180
  self.youtube(item.get("src"))
91
181
  elif item.get("type") == "subscribeWidget":
92
182
  self.subscribe_with_caption(item.get("message"))
183
+ elif item.get("type") == "codeBlock":
184
+ self.code_block(item.get("content"), item.get("attrs", {}))
93
185
  else:
94
186
  if content is not None:
95
187
  self.add_complex_text(content)
@@ -329,3 +421,217 @@ class Post:
329
421
  content_attrs.update({"videoId": value})
330
422
  self.draft_body["content"][-1]["attrs"] = content_attrs
331
423
  return self
424
+
425
+ def code_block(self, content, attrs=None):
426
+ """
427
+ Add code block to post.
428
+
429
+ Args:
430
+ content: String containing code or list of text nodes
431
+ attrs: Optional attributes like language
432
+
433
+ Returns:
434
+
435
+ """
436
+ if attrs is None:
437
+ attrs = {}
438
+
439
+ # Handle content - can be list of text nodes or a string
440
+ if isinstance(content, str):
441
+ # Convert string to list of text nodes
442
+ code_content = [{"type": "text", "text": content}]
443
+ elif isinstance(content, list):
444
+ code_content = content
445
+ else:
446
+ code_content = []
447
+
448
+ # Set up the code block structure
449
+ code_block = self.draft_body["content"][-1]
450
+ code_block["content"] = code_content
451
+ if attrs:
452
+ code_block["attrs"] = attrs
453
+
454
+ return self
455
+
456
+ def from_markdown(self, markdown_content: str, api=None):
457
+ """
458
+ Parse Markdown content and add it to the post.
459
+
460
+ Supported Markdown features:
461
+ - Headings: Lines starting with '#' characters (1-6 levels)
462
+ - Images: Markdown image syntax ![Alt](URL)
463
+ - Linked images: [![Alt](image_url)](link_url) - images that are also links
464
+ - Links: [text](url) - inline links in paragraphs
465
+ - Code blocks: Fenced code blocks with ```language or ```
466
+ - Paragraphs: Regular text blocks
467
+ - Bullet lists: Lines starting with '*' or '-'
468
+ - Inline formatting: **bold** and *italic* within paragraphs
469
+
470
+ Args:
471
+ markdown_content: Markdown string to parse and add to the post.
472
+ api: Optional Api instance for uploading local images. If provided,
473
+ local image paths will be uploaded via api.get_image().
474
+
475
+ Returns:
476
+ Self for method chaining.
477
+
478
+ Example:
479
+ >>> post = Post("Title", "Subtitle", user_id)
480
+ >>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
481
+ """
482
+ lines = markdown_content.split("\n")
483
+ blocks = []
484
+ current_block: List[str] = []
485
+ in_code_block = False
486
+ code_block_language = None
487
+
488
+ for line in lines:
489
+ # Check for fenced code block start/end
490
+ if line.strip().startswith("```"):
491
+ if in_code_block:
492
+ # End of code block
493
+ if current_block:
494
+ blocks.append({
495
+ "type": "code",
496
+ "language": code_block_language,
497
+ "content": "\n".join(current_block)
498
+ })
499
+ current_block = []
500
+ in_code_block = False
501
+ code_block_language = None
502
+ else:
503
+ # Start of code block
504
+ if current_block:
505
+ blocks.append({"type": "text", "content": "\n".join(current_block)})
506
+ current_block = []
507
+ # Extract language if specified
508
+ language = line.strip()[3:].strip()
509
+ code_block_language = language if language else None
510
+ in_code_block = True
511
+ continue
512
+
513
+ if in_code_block:
514
+ # Inside code block - collect lines as-is
515
+ current_block.append(line)
516
+ else:
517
+ # Regular content
518
+ if line.strip() == "":
519
+ # Empty line - end current block if it has content
520
+ if current_block:
521
+ blocks.append({"type": "text", "content": "\n".join(current_block)})
522
+ current_block = []
523
+ else:
524
+ current_block.append(line)
525
+
526
+ # Add any remaining content
527
+ if current_block:
528
+ if in_code_block:
529
+ blocks.append({
530
+ "type": "code",
531
+ "language": code_block_language,
532
+ "content": "\n".join(current_block)
533
+ })
534
+ else:
535
+ blocks.append({"type": "text", "content": "\n".join(current_block)})
536
+
537
+ # Process blocks
538
+ for block in blocks:
539
+ if block["type"] == "code":
540
+ # Add code block
541
+ code_content = block.get("content", "").strip()
542
+ if code_content:
543
+ # Substack uses "codeBlock" type
544
+ code_attrs = {}
545
+ if block.get("language"):
546
+ code_attrs["language"] = block["language"]
547
+ self.add({
548
+ "type": "codeBlock",
549
+ "content": code_content, # Pass as string, code_block method will handle it
550
+ "attrs": code_attrs
551
+ })
552
+ else:
553
+ # Process text block
554
+ text_content = block.get("content", "").strip()
555
+ if not text_content:
556
+ continue
557
+
558
+ # Process headings (lines starting with '#' characters)
559
+ if text_content.startswith("#"):
560
+ level = len(text_content) - len(text_content.lstrip("#"))
561
+ heading_text = text_content.lstrip("#").strip()
562
+ if heading_text: # Only add if there's actual text
563
+ self.heading(content=heading_text, level=min(level, 6))
564
+
565
+ # Process images using Markdown image syntax: ![Alt](URL)
566
+ # Also handle linked images: [![Alt](image_url)](link_url)
567
+ elif text_content.startswith("!") or (text_content.startswith("[") and "![" in text_content):
568
+ # Check for linked image first: [![alt](img)](link)
569
+ linked_image_match = re.match(r'\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)', text_content)
570
+ if linked_image_match:
571
+ # Linked image - create image with href
572
+ alt_text = linked_image_match.group(1)
573
+ image_url = linked_image_match.group(2)
574
+ link_url = linked_image_match.group(3)
575
+
576
+ # Adjust image URL if it starts with a slash
577
+ image_url = image_url[1:] if image_url.startswith("/") else image_url
578
+
579
+ # If api is provided and image_url is a local file, upload it
580
+ if api is not None:
581
+ try:
582
+ image = api.get_image(image_url)
583
+ image_url = image.get("url")
584
+ except Exception:
585
+ # If upload fails, use original URL
586
+ pass
587
+
588
+ self.add({
589
+ "type": "captionedImage",
590
+ "src": image_url,
591
+ "alt": alt_text,
592
+ "href": link_url
593
+ })
594
+ else:
595
+ # Regular image: ![Alt](URL)
596
+ match = re.match(r"!\[.*?\]\((.*?)\)", text_content)
597
+ if match:
598
+ image_url = match.group(1)
599
+ # Adjust image URL if it starts with a slash
600
+ image_url = image_url[1:] if image_url.startswith("/") else image_url
601
+
602
+ # If api is provided and image_url is a local file, upload it
603
+ if api is not None:
604
+ try:
605
+ image = api.get_image(image_url)
606
+ image_url = image.get("url")
607
+ except Exception:
608
+ # If upload fails, use original URL
609
+ pass
610
+
611
+ self.add({"type": "captionedImage", "src": image_url})
612
+
613
+ # Process paragraphs or bullet lists
614
+ else:
615
+ if "\n" in text_content:
616
+ # Process each line separately (for bullet lists)
617
+ for line in text_content.split("\n"):
618
+ line = line.strip()
619
+ if not line:
620
+ continue
621
+ # Remove bullet marker if present
622
+ if line.startswith("* "):
623
+ line = line[2:].strip()
624
+ elif line.startswith("- "):
625
+ line = line[2:].strip()
626
+ elif line.startswith("*") and not line.startswith("**"):
627
+ line = line[1:].strip()
628
+
629
+ if line:
630
+ tokens = parse_inline(line)
631
+ self.add({"type": "paragraph", "content": tokens})
632
+ else:
633
+ # Single paragraph
634
+ tokens = parse_inline(text_content)
635
+ self.add({"type": "paragraph", "content": tokens})
636
+
637
+ 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,,