python-substack 0.1.15__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.
@@ -0,0 +1,285 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-substack
3
+ Version: 0.1.17
4
+ Summary: A Python wrapper around the Substack API.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: substack
8
+ Author: Paolo Mazza
9
+ Author-email: mazzapaolo2019@gmail.com
10
+ Requires-Python: >=3.9,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Dist: PyYAML (>=6.0,<7.0)
20
+ Requires-Dist: python-dotenv (>=0.21.0,<0.22.0)
21
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
22
+ Project-URL: Homepage, https://github.com/ma2za/python-substack
23
+ Project-URL: Repository, https://github.com/ma2za/python-substack
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Python Substack
27
+
28
+ This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
29
+ I am in no way affiliated with Substack.
30
+
31
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
32
+ ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
33
+ ---
34
+
35
+ # Installation
36
+
37
+ You can install python-substack using:
38
+
39
+ $ pip install python-substack
40
+
41
+ ---
42
+
43
+ # Setup
44
+
45
+ Set the following environment variables by creating a **.env** file:
46
+
47
+ EMAIL=
48
+ PASSWORD=
49
+ PUBLICATION_URL= # Optional: your publication URL
50
+ COOKIES_PATH= # Optional: path to cookies JSON file
51
+ COOKIES_STRING= # Optional: cookie string for authentication
52
+
53
+ ## If you don't have a password
54
+
55
+ Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
56
+ your email address with a "magic" link.
57
+
58
+ Set a password:
59
+
60
+ - Sign out of Substack
61
+ - At the sign-in page, click "Sign in with password" under the `Email` text box
62
+ - Then choose, "Set a new password"
63
+
64
+ The .env file will be ignored by git but always be careful.
65
+
66
+ ---
67
+
68
+ # Usage
69
+
70
+ Check out the examples folder for some examples 😃 🚀
71
+
72
+ ## Basic Authentication
73
+
74
+ ```python
75
+ import os
76
+ from dotenv import load_dotenv
77
+
78
+ from substack import Api
79
+ from substack.post import Post
80
+
81
+ load_dotenv()
82
+
83
+ # Authenticate with email and password
84
+ api = Api(
85
+ email=os.getenv("EMAIL"),
86
+ password=os.getenv("PASSWORD"),
87
+ publication_url=os.getenv("PUBLICATION_URL"),
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()
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
115
+ user_id = api.get_user_id()
116
+
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.
118
+
119
+ # primary publication
120
+ user_publication = api.get_user_primary_publication()
121
+ # all publications
122
+ user_publications = api.get_user_publications()
123
+
124
+ # This step is only necessary if you are not using your primary publication
125
+ # api.change_publication(user_publication)
126
+
127
+ # Create a post with basic settings
128
+ post = Post(
129
+ title="How to publish a Substack post using the Python API",
130
+ subtitle="This post was published using the Python API",
131
+ user_id=user_id
132
+ )
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
+
143
+ post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
144
+
145
+ # bolden text
146
+ post.add({'type': "paragraph",
147
+ 'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
148
+ {'content': "a word."}]})
149
+
150
+ # add hyperlink to text
151
+ post.add({'type': 'paragraph', 'content': [
152
+ {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
153
+
154
+ # set paywall boundary
155
+ post.add({'type': 'paywall'})
156
+
157
+ # add image
158
+ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
159
+
160
+ # add local image
161
+ image = api.get_image('image.png')
162
+ post.add({"type": "captionedImage", "src": image.get("url")})
163
+
164
+ # embed publication
165
+ embedded = api.publication_embed("https://jackio.substack.com/")
166
+ post.add({"type": "embeddedPublication", "url": embedded})
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
+
178
+ draft = api.post_draft(post.get_draft())
179
+
180
+ # set section (can only be done after first posting the draft)
181
+ # post.set_section("rick rolling", api.get_sections())
182
+ # api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
183
+
184
+ api.prepublish_draft(draft.get("id"))
185
+
186
+ api.publish_draft(draft.get("id"))
187
+ ```
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
+
268
+ # Contributing
269
+
270
+ Install pre-commit:
271
+
272
+ ```shell
273
+ pip install pre-commit
274
+ ```
275
+
276
+ Set up pre-commit
277
+
278
+ ```shell
279
+ pre-commit install
280
+ ```
281
+
282
+ ## Cookie Help
283
+
284
+ To get a cookie string, after login, go to dev tools (F12), network tab, refresh and find one of the requests like subscription/unred/subscriptions, right click and copy as fetch (Node.js), paste somewhere and get the entire cookie string assigned to the cookie header and put it in the env variables as COOKIES_STRING, et voila!
285
+
@@ -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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.8.1
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
substack/api.py CHANGED
@@ -9,7 +9,7 @@ import json
9
9
  import logging
10
10
  import os
11
11
  from datetime import datetime
12
- from urllib.parse import urljoin
12
+ from urllib.parse import urljoin, unquote
13
13
 
14
14
  import requests
15
15
 
@@ -35,6 +35,7 @@ class Api:
35
35
  base_url=None,
36
36
  publication_url=None,
37
37
  debug=False,
38
+ cookies_string=None,
38
39
  ):
39
40
  """
40
41
 
@@ -49,6 +50,10 @@ class Api:
49
50
  To re-use your session without logging in each time, you can save your cookies to a json file and
50
51
  then load them in the next session.
51
52
  Make sure to re-save your cookies, as they do update over time.
53
+ cookies_string
54
+ To re-use your session without logging in each time, you can provide cookies as a semicolon-separated
55
+ string (e.g., "cookie1=value1; cookie2=value2"). This is useful when copying cookies from browser
56
+ developer tools.
52
57
  base_url:
53
58
  The base URL to use to contact the Substack API.
54
59
  Defaults to https://substack.com/api/v1.
@@ -68,11 +73,15 @@ class Api:
68
73
  cookies = json.load(f)
69
74
  self._session.cookies.update(cookies)
70
75
 
76
+ elif cookies_string is not None:
77
+ cookies = self._parse_cookies_string(cookies_string)
78
+ self._session.cookies.update(cookies)
79
+
71
80
  elif email is not None and password is not None:
72
81
  self.login(email, password)
73
82
  else:
74
83
  raise ValueError(
75
- "Must provide email and password or cookies_path to authenticate."
84
+ "Must provide email and password, cookies_path, or cookies_string to authenticate."
76
85
  )
77
86
 
78
87
  user_publication = None
@@ -98,6 +107,31 @@ class Api:
98
107
  # set the current publication to the users primary publication
99
108
  self.change_publication(user_publication)
100
109
 
110
+ @staticmethod
111
+ def _parse_cookies_string(cookies_string: str) -> dict:
112
+ """
113
+ Parse a semicolon-separated cookie string into a dictionary.
114
+
115
+ Args:
116
+ cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
117
+
118
+ Returns:
119
+ A dictionary of cookie name-value pairs
120
+ """
121
+ cookies = {}
122
+ for cookie_pair in cookies_string.split(';'):
123
+ cookie_pair = cookie_pair.strip()
124
+ if not cookie_pair:
125
+ continue
126
+ if '=' in cookie_pair:
127
+ key, value = cookie_pair.split('=', 1)
128
+ key = key.strip()
129
+ value = value.strip()
130
+ # URL decode the value (e.g., s%3A becomes s:)
131
+ value = unquote(value)
132
+ cookies[key] = value
133
+ return cookies
134
+
101
135
  def login(self, email, password) -> dict:
102
136
  """
103
137
 
@@ -189,8 +223,8 @@ class Api:
189
223
  Args:
190
224
  publication:
191
225
  """
192
- custom_domain = publication["custom_domain"]
193
- if not custom_domain:
226
+ custom_domain = publication.get("custom_domain", None)
227
+ if not custom_domain and not publication.get('custom_domain_optional', None):
194
228
  publication_url = f"https://{publication['subdomain']}.substack.com"
195
229
  else:
196
230
  publication_url = f"https://{custom_domain}"
@@ -203,7 +237,31 @@ class Api:
203
237
  """
204
238
 
205
239
  profile = self.get_user_profile()
206
- primary_publication = profile["primaryPublication"]
240
+ primary_publication = None
241
+
242
+ # Try old API format first (backward compatibility)
243
+ if "primaryPublication" in profile and profile["primaryPublication"] is not None:
244
+ primary_publication = profile["primaryPublication"]
245
+ else:
246
+ # New API format: look for primary publication in publicationUsers
247
+ publication_users = profile.get("publicationUsers")
248
+ if publication_users is not None and len(publication_users) > 0:
249
+ # Find the publication where is_primary is True
250
+ for pub_user in publication_users:
251
+ if pub_user.get("is_primary", False):
252
+ primary_publication = pub_user.get("publication")
253
+ if primary_publication:
254
+ break
255
+
256
+ # If no primary found, use the first publication
257
+ if primary_publication is None:
258
+ primary_publication = publication_users[0].get("publication")
259
+
260
+ if primary_publication is None:
261
+ raise SubstackRequestException(
262
+ "Could not find primary publication in profile"
263
+ )
264
+
207
265
  primary_publication["publication_url"] = self.get_publication_url(
208
266
  primary_publication
209
267
  )
@@ -220,10 +278,18 @@ class Api:
220
278
  # Loop through users "publicationUsers" list, and return a list
221
279
  # of dictionaries of "name", and "subdomain", and "id"
222
280
  user_publications = []
223
- for publication in profile["publicationUsers"]:
224
- pub = publication["publication"]
225
- pub["publication_url"] = self.get_publication_url(pub)
226
- user_publications.append(pub)
281
+ publication_users = profile.get("publicationUsers")
282
+
283
+ if publication_users is None:
284
+ # If publicationUsers is None, return empty list or try to construct from other fields
285
+ # This maintains backward compatibility while handling new API format
286
+ return user_publications
287
+
288
+ for publication in publication_users:
289
+ pub = publication.get("publication")
290
+ if pub is not None:
291
+ pub["publication_url"] = self.get_publication_url(pub)
292
+ user_publications.append(pub)
227
293
 
228
294
  return user_publications
229
295
 
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,147 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: python-substack
3
- Version: 0.1.15
4
- Summary: A Python wrapper around the Substack API.
5
- Home-page: https://github.com/ma2za/python-substack
6
- License: MIT
7
- Keywords: substack
8
- Author: Paolo Mazza
9
- Author-email: mazzapaolo2019@gmail.com
10
- Requires-Python: >=3.7,<4.0
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.7
14
- Classifier: Programming Language :: Python :: 3.8
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
19
- Requires-Dist: PyYAML (>=6.0,<7.0)
20
- Requires-Dist: python-dotenv (>=0.21.0,<0.22.0)
21
- Requires-Dist: requests (>=2.31.0,<3.0.0)
22
- Project-URL: Repository, https://github.com/ma2za/python-substack
23
- Description-Content-Type: text/markdown
24
-
25
- # Python Substack
26
-
27
- This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
28
- I am in no way affiliated with Substack.
29
-
30
- [![Python](https://img.shields.io/pypi/pyversions/fastapi.svg?color=%2334D058)](https://www.python.org/downloads/)
31
- [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
32
- ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
33
- ---
34
-
35
- # Installation
36
-
37
- You can install python-substack using:
38
-
39
- $ pip install python-substack
40
-
41
- ---
42
-
43
- # Setup
44
-
45
- Set the following environment variables by creating a **.env** file:
46
-
47
- EMAIL=
48
- PASSWORD=
49
-
50
- ## If you don't have a password
51
-
52
- Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses
53
- your email address with a "magic" link.
54
-
55
- Set a password:
56
-
57
- - Sign-out of Substack
58
- - At the sign-in page click, "Sign in with password" under the `Email` text box
59
- - Then choose, "Set a new password"
60
-
61
- The .env file will be ignored by git but always be careful.
62
-
63
- ---
64
-
65
- # Usage
66
-
67
- Check out the examples folder for some examples 😃 🚀
68
-
69
- ```python
70
- import os
71
-
72
- from substack import Api
73
- from substack.post import Post
74
-
75
- api = Api(
76
- email=os.getenv("EMAIL"),
77
- password=os.getenv("PASSWORD"),
78
- )
79
-
80
- user_id = api.get_user_id()
81
-
82
- # Switch Publications - The library defaults to your users primary publication. You can retrieve all your publications and change which one you want to use.
83
-
84
- # primary publication
85
- user_publication = api.get_user_primary_publication()
86
- # all publications
87
- user_publications = api.get_user_publications()
88
-
89
- # This step is only necessary if you are not using your primary publication
90
- # api.change_publication(user_publication)
91
-
92
- post = Post(
93
- title="How to publish a Substack post using the Python API",
94
- subtitle="This post was published using the Python API",
95
- user_id=user_id
96
- )
97
-
98
- post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
99
-
100
- # bolden text
101
- post.add({'type': "paragraph",
102
- 'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
103
- {'content': "a word."}]})
104
-
105
- # add hyperlink to text
106
- post.add({'type': 'paragraph', 'content': [
107
- {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
108
-
109
- # set paywall boundary
110
- post.add({'type': 'paywall'})
111
-
112
- # add image
113
- post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
114
-
115
- # add local image
116
- image = api.get_image('image.png')
117
- post.add({"type": "captionedImage", "src": image.get("url")})
118
-
119
- # embed publication
120
- embedded = api.publication_embed("https://jackio.substack.com/")
121
- post.add({"type": "embeddedPublication", "url": embedded})
122
-
123
- draft = api.post_draft(post.get_draft())
124
-
125
- # set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
126
- post.set_section("rick rolling", api.get_sections())
127
- api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
128
-
129
- api.prepublish_draft(draft.get("id"))
130
-
131
- api.publish_draft(draft.get("id"))
132
- ```
133
-
134
- # Contributing
135
-
136
- Install pre-commit:
137
-
138
- ```shell
139
- pip install pre-commit
140
- ```
141
-
142
- Set up pre-commit
143
-
144
- ```shell
145
- pre-commit install
146
- ```
147
-
@@ -1,8 +0,0 @@
1
- substack/__init__.py,sha256=mkNj8jFW6wA4dIYyyM1UfKt5Q9MgR8xg3CaADl4IjlQ,387
2
- substack/api.py,sha256=VkAscD5Ks07wbIWIxTF-a8rqU2WDjdUwUtGX9mkGxtY,15367
3
- substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
4
- substack/post.py,sha256=rcnTfWUqfuocvHN61UxidbAiX4Y7LtcvSAkLjjsxqxM,7997
5
- python_substack-0.1.15.dist-info/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
6
- python_substack-0.1.15.dist-info/METADATA,sha256=tyMO8CgEvWBmKB7hw4L__xW_gLVbX7LCKuuKTPIVcYM,4215
7
- python_substack-0.1.15.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
8
- python_substack-0.1.15.dist-info/RECORD,,