python-substack 0.1.16__tar.gz → 0.1.17__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.
@@ -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:
@@ -3,7 +3,6 @@
3
3
  This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
4
4
  I am in no way affiliated with Substack.
5
5
 
6
- [![Python](https://img.shields.io/pypi/pyversions/fastapi.svg?color=%2334D058)](https://www.python.org/downloads/)
7
6
  [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
8
7
  ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
9
8
  ---
@@ -45,18 +44,49 @@ The .env file will be ignored by git but always be careful.
45
44
 
46
45
  Check out the examples folder for some examples 😃 🚀
47
46
 
47
+ ## Basic Authentication
48
+
48
49
  ```python
49
50
  import os
51
+ from dotenv import load_dotenv
50
52
 
51
53
  from substack import Api
52
54
  from substack.post import Post
53
55
 
56
+ load_dotenv()
57
+
58
+ # Authenticate with email and password
54
59
  api = Api(
55
60
  email=os.getenv("EMAIL"),
56
61
  password=os.getenv("PASSWORD"),
57
62
  publication_url=os.getenv("PUBLICATION_URL"),
58
63
  )
64
+ ```
65
+
66
+ ## Cookie-based Authentication
67
+
68
+ You can also authenticate using cookies instead of email/password:
69
+
70
+ ```python
71
+ import os
72
+ from dotenv import load_dotenv
73
+
74
+ from substack import Api
75
+
76
+ load_dotenv()
59
77
 
78
+ # Authenticate with cookies (alternative to email/password)
79
+ api = Api(
80
+ cookies_path=os.getenv("COOKIES_PATH"), # Path to cookies JSON file
81
+ # OR
82
+ cookies_string=os.getenv("COOKIES_STRING"), # Cookie string
83
+ publication_url=os.getenv("PUBLICATION_URL"),
84
+ )
85
+ ```
86
+
87
+ ## Creating and Publishing Posts
88
+
89
+ ```python
60
90
  user_id = api.get_user_id()
61
91
 
62
92
  # 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.
@@ -69,12 +99,22 @@ user_publications = api.get_user_publications()
69
99
  # This step is only necessary if you are not using your primary publication
70
100
  # api.change_publication(user_publication)
71
101
 
102
+ # Create a post with basic settings
72
103
  post = Post(
73
104
  title="How to publish a Substack post using the Python API",
74
105
  subtitle="This post was published using the Python API",
75
106
  user_id=user_id
76
107
  )
77
108
 
109
+ # Create a post with audience and comment permissions
110
+ post = Post(
111
+ title="My Post Title",
112
+ subtitle="My Post Subtitle",
113
+ user_id=user_id,
114
+ audience="everyone", # Options: "everyone", "only_paid", "founding", "only_free"
115
+ write_comment_permissions="everyone" # Options: "none", "only_paid", "everyone"
116
+ )
117
+
78
118
  post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
79
119
 
80
120
  # bolden text
@@ -100,6 +140,16 @@ post.add({"type": "captionedImage", "src": image.get("url")})
100
140
  embedded = api.publication_embed("https://jackio.substack.com/")
101
141
  post.add({"type": "embeddedPublication", "url": embedded})
102
142
 
143
+ # create post from Markdown
144
+ markdown_content = """
145
+ # My Heading
146
+
147
+ This is a paragraph with **bold** and *italic* text.
148
+
149
+ ![Image Alt](https://example.com/image.jpg)
150
+ """
151
+ post.from_markdown(markdown_content, api=api)
152
+
103
153
  draft = api.post_draft(post.get_draft())
104
154
 
105
155
  # set section (can only be done after first posting the draft)
@@ -111,6 +161,85 @@ api.prepublish_draft(draft.get("id"))
111
161
  api.publish_draft(draft.get("id"))
112
162
  ```
113
163
 
164
+ ## Loading Posts from YAML Files
165
+
166
+ You can define your posts in YAML files for easier management:
167
+
168
+ ```python
169
+ import yaml
170
+ import os
171
+ from dotenv import load_dotenv
172
+
173
+ from substack import Api
174
+ from substack.post import Post
175
+
176
+ load_dotenv()
177
+
178
+ # Load post data from YAML file
179
+ with open("draft.yaml", "r") as fp:
180
+ post_data = yaml.safe_load(fp)
181
+
182
+ # Authenticate (using cookies or email/password)
183
+ cookies_path = os.getenv("COOKIES_PATH")
184
+ cookies_string = os.getenv("COOKIES_STRING")
185
+
186
+ api = Api(
187
+ email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
188
+ password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
189
+ cookies_path=cookies_path,
190
+ cookies_string=cookies_string,
191
+ publication_url=os.getenv("PUBLICATION_URL"),
192
+ )
193
+
194
+ user_id = api.get_user_id()
195
+
196
+ # Create post from YAML data
197
+ post = Post(
198
+ post_data.get("title"),
199
+ post_data.get("subtitle", ""),
200
+ user_id,
201
+ audience=post_data.get("audience", "everyone"),
202
+ write_comment_permissions=post_data.get("write_comment_permissions", "everyone"),
203
+ )
204
+
205
+ # Add body content from YAML
206
+ body = post_data.get("body", {})
207
+ for _, item in body.items():
208
+ # Handle local images - upload them first
209
+ if item.get("type") == "captionedImage" and not item.get("src").startswith("http"):
210
+ image = api.get_image(item.get("src"))
211
+ item.update({"src": image.get("url")})
212
+ post.add(item)
213
+
214
+ draft = api.post_draft(post.get_draft())
215
+ api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
216
+
217
+ # Publish the draft
218
+ api.prepublish_draft(draft.get("id"))
219
+ api.publish_draft(draft.get("id"))
220
+ ```
221
+
222
+ Example YAML structure:
223
+
224
+ ```yaml
225
+ title: "My Post Title"
226
+ subtitle: "My Post Subtitle"
227
+ audience: "everyone" # everyone, only_paid, founding, only_free
228
+ write_comment_permissions: "everyone" # none, only_paid, everyone
229
+ section: "my-section"
230
+ body:
231
+ 0:
232
+ type: "heading"
233
+ level: 1
234
+ content: "Introduction"
235
+ 1:
236
+ type: "paragraph"
237
+ content: "This is a paragraph."
238
+ 2:
239
+ type: "captionedImage"
240
+ src: "local_image.jpg" # Local images will be uploaded automatically
241
+ ```
242
+
114
243
  # Contributing
115
244
 
116
245
  Install pre-commit:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.16"
3
+ version = "0.1.17"
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 = "^3.7"
19
+ python = "^3.9"
20
20
 
21
21
  requests = "^2.31.0"
22
22
  python-dotenv = "^0.21.0"
@@ -0,0 +1,637 @@
1
+ """
2
+
3
+ Post Utilities
4
+
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from typing import Dict, List
10
+
11
+ __all__ = ["Post", "parse_inline"]
12
+
13
+ from substack.exceptions import SectionNotExistsException
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
+
105
+ class Post:
106
+ """
107
+
108
+ Post utility class
109
+
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ title: str,
115
+ subtitle: str,
116
+ user_id,
117
+ audience: str = None,
118
+ write_comment_permissions: str = None,
119
+ ):
120
+ """
121
+
122
+ Args:
123
+ title:
124
+ subtitle:
125
+ user_id:
126
+ audience: possible values: everyone, only_paid, founding, only_free
127
+ write_comment_permissions: none, only_paid, everyone (this field is a mess)
128
+ """
129
+ self.draft_title = title
130
+ self.draft_subtitle = subtitle
131
+ self.draft_body = {"type": "doc", "content": []}
132
+ self.draft_bylines = [{"id": int(user_id), "is_guest": False}]
133
+ self.audience = audience if audience is not None else "everyone"
134
+ self.draft_section_id = None
135
+ self.section_chosen = True
136
+
137
+ # TODO better understand the possible values and combinations with audience
138
+ if write_comment_permissions is not None:
139
+ self.write_comment_permissions = write_comment_permissions
140
+ else:
141
+ self.write_comment_permissions = self.audience
142
+
143
+ def set_section(self, name: str, sections: list):
144
+ """
145
+
146
+ Args:
147
+ name:
148
+ sections:
149
+
150
+ Returns:
151
+
152
+ """
153
+ section = [s for s in sections if s.get("name") == name]
154
+ if len(section) != 1:
155
+ raise SectionNotExistsException(name)
156
+ section = section[0]
157
+ self.draft_section_id = section.get("id")
158
+
159
+ def add(self, item: Dict):
160
+ """
161
+
162
+ Add item to draft body.
163
+
164
+ Args:
165
+ item:
166
+
167
+ Returns:
168
+
169
+ """
170
+
171
+ self.draft_body["content"] = self.draft_body.get("content", []) + [
172
+ {"type": item.get("type")}
173
+ ]
174
+ content = item.get("content")
175
+ if item.get("type") == "captionedImage":
176
+ self.captioned_image(**item)
177
+ elif item.get("type") == "embeddedPublication":
178
+ self.draft_body["content"][-1]["attrs"] = item.get("url")
179
+ elif item.get("type") == "youtube2":
180
+ self.youtube(item.get("src"))
181
+ elif item.get("type") == "subscribeWidget":
182
+ self.subscribe_with_caption(item.get("message"))
183
+ elif item.get("type") == "codeBlock":
184
+ self.code_block(item.get("content"), item.get("attrs", {}))
185
+ else:
186
+ if content is not None:
187
+ self.add_complex_text(content)
188
+
189
+ if item.get("type") == "heading":
190
+ self.attrs(item.get("level", 1))
191
+
192
+ marks = item.get("marks")
193
+ if marks is not None:
194
+ self.marks(marks)
195
+
196
+ return self
197
+
198
+ def paragraph(self, content=None):
199
+ """
200
+
201
+ Args:
202
+ content:
203
+
204
+ Returns:
205
+
206
+ """
207
+ item = {"type": "paragraph"}
208
+ if content is not None:
209
+ item["content"] = content
210
+ return self.add(item)
211
+
212
+ def heading(self, content=None, level: int = 1):
213
+ """
214
+
215
+ Args:
216
+ content:
217
+ level:
218
+
219
+ Returns:
220
+
221
+ """
222
+
223
+ item = {"type": "heading"}
224
+ if content is not None:
225
+ item["content"] = content
226
+ item["level"] = level
227
+ return self.add(item)
228
+
229
+ def horizontal_rule(self):
230
+ """
231
+
232
+ Returns:
233
+
234
+ """
235
+ return self.add({"type": "horizontal_rule"})
236
+
237
+ def attrs(self, level):
238
+ """
239
+
240
+ Args:
241
+ level:
242
+
243
+ Returns:
244
+
245
+ """
246
+ content_attrs = self.draft_body["content"][-1].get("attrs", {})
247
+ content_attrs.update({"level": level})
248
+ self.draft_body["content"][-1]["attrs"] = content_attrs
249
+ return self
250
+
251
+ def captioned_image(
252
+ self,
253
+ src: str,
254
+ fullscreen: bool = False,
255
+ imageSize: str = "normal",
256
+ height: int = 819,
257
+ width: int = 1456,
258
+ resizeWidth: int = 728,
259
+ bytes: str = None,
260
+ alt: str = None,
261
+ title: str = None,
262
+ type: str = None,
263
+ href: str = None,
264
+ belowTheFold: bool = False,
265
+ internalRedirect: str = None,
266
+ ):
267
+ """
268
+
269
+ Add image to body.
270
+
271
+ Args:
272
+ bytes:
273
+ alt:
274
+ title:
275
+ type:
276
+ href:
277
+ belowTheFold:
278
+ internalRedirect:
279
+ src:
280
+ fullscreen:
281
+ imageSize:
282
+ height:
283
+ width:
284
+ resizeWidth:
285
+ """
286
+
287
+ content = self.draft_body["content"][-1].get("content", [])
288
+ content += [
289
+ {
290
+ "type": "image2",
291
+ "attrs": {
292
+ "src": src,
293
+ "fullscreen": fullscreen,
294
+ "imageSize": imageSize,
295
+ "height": height,
296
+ "width": width,
297
+ "resizeWidth": resizeWidth,
298
+ "bytes": bytes,
299
+ "alt": alt,
300
+ "title": title,
301
+ "type": type,
302
+ "href": href,
303
+ "belowTheFold": belowTheFold,
304
+ "internalRedirect": internalRedirect,
305
+ },
306
+ }
307
+ ]
308
+ self.draft_body["content"][-1]["content"] = content
309
+ return self
310
+
311
+ def text(self, value: str):
312
+ """
313
+
314
+ Add text to the last paragraph.
315
+
316
+ Args:
317
+ value: Text to add to paragraph.
318
+
319
+ Returns:
320
+
321
+ """
322
+ content = self.draft_body["content"][-1].get("content", [])
323
+ content += [{"type": "text", "text": value}]
324
+ self.draft_body["content"][-1]["content"] = content
325
+ return self
326
+
327
+ def add_complex_text(self, text):
328
+ """
329
+
330
+ Args:
331
+ text:
332
+ """
333
+ if isinstance(text, str):
334
+ self.text(text)
335
+ else:
336
+ for chunk in text:
337
+ if chunk:
338
+ self.text(chunk.get("content")).marks(chunk.get("marks", []))
339
+
340
+ def marks(self, marks):
341
+ """
342
+
343
+ Args:
344
+ marks:
345
+
346
+ Returns:
347
+
348
+ """
349
+ content = self.draft_body["content"][-1].get("content", [])[-1]
350
+ content_marks = content.get("marks", [])
351
+ for mark in marks:
352
+ new_mark = {"type": mark.get("type")}
353
+ if mark.get("type") == "link":
354
+ href = mark.get("href")
355
+ new_mark.update({"attrs": {"href": href}})
356
+ content_marks.append(new_mark)
357
+ content["marks"] = content_marks
358
+ return self
359
+
360
+ def remove_last_paragraph(self):
361
+ """Remove last paragraph"""
362
+ del self.draft_body.get("content")[-1]
363
+
364
+ def get_draft(self):
365
+ """
366
+
367
+ Returns:
368
+
369
+ """
370
+ out = vars(self)
371
+ out["draft_body"] = json.dumps(out["draft_body"])
372
+ return out
373
+
374
+ def subscribe_with_caption(self, message: str = None):
375
+ """
376
+
377
+ Add subscribe widget with caption
378
+
379
+ Args:
380
+ message:
381
+
382
+ Returns:
383
+
384
+ """
385
+
386
+ if message is None:
387
+ message = """Thanks for reading this newsletter!
388
+ Subscribe for free to receive new posts and support my work."""
389
+
390
+ subscribe = self.draft_body["content"][-1]
391
+ subscribe["attrs"] = {
392
+ "url": "%%checkout_url%%",
393
+ "text": "Subscribe",
394
+ "language": "en",
395
+ }
396
+ subscribe["content"] = [
397
+ {
398
+ "type": "ctaCaption",
399
+ "content": [
400
+ {
401
+ "type": "text",
402
+ "text": message,
403
+ }
404
+ ],
405
+ }
406
+ ]
407
+ return self
408
+
409
+ def youtube(self, value: str):
410
+ """
411
+
412
+ Add youtube video to post.
413
+
414
+ Args:
415
+ value: youtube url
416
+
417
+ Returns:
418
+
419
+ """
420
+ content_attrs = self.draft_body["content"][-1].get("attrs", {})
421
+ content_attrs.update({"videoId": value})
422
+ self.draft_body["content"][-1]["attrs"] = content_attrs
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,331 +0,0 @@
1
- """
2
-
3
- Post Utilities
4
-
5
- """
6
-
7
- import json
8
- from typing import Dict
9
-
10
- __all__ = ["Post"]
11
-
12
- from substack.exceptions import SectionNotExistsException
13
-
14
-
15
- class Post:
16
- """
17
-
18
- Post utility class
19
-
20
- """
21
-
22
- def __init__(
23
- self,
24
- title: str,
25
- subtitle: str,
26
- user_id,
27
- audience: str = None,
28
- write_comment_permissions: str = None,
29
- ):
30
- """
31
-
32
- Args:
33
- title:
34
- subtitle:
35
- user_id:
36
- audience: possible values: everyone, only_paid, founding, only_free
37
- write_comment_permissions: none, only_paid, everyone (this field is a mess)
38
- """
39
- self.draft_title = title
40
- self.draft_subtitle = subtitle
41
- self.draft_body = {"type": "doc", "content": []}
42
- self.draft_bylines = [{"id": int(user_id), "is_guest": False}]
43
- self.audience = audience if audience is not None else "everyone"
44
- self.draft_section_id = None
45
- self.section_chosen = True
46
-
47
- # TODO better understand the possible values and combinations with audience
48
- if write_comment_permissions is not None:
49
- self.write_comment_permissions = write_comment_permissions
50
- else:
51
- self.write_comment_permissions = self.audience
52
-
53
- def set_section(self, name: str, sections: list):
54
- """
55
-
56
- Args:
57
- name:
58
- sections:
59
-
60
- Returns:
61
-
62
- """
63
- section = [s for s in sections if s.get("name") == name]
64
- if len(section) != 1:
65
- raise SectionNotExistsException(name)
66
- section = section[0]
67
- self.draft_section_id = section.get("id")
68
-
69
- def add(self, item: Dict):
70
- """
71
-
72
- Add item to draft body.
73
-
74
- Args:
75
- item:
76
-
77
- Returns:
78
-
79
- """
80
-
81
- self.draft_body["content"] = self.draft_body.get("content", []) + [
82
- {"type": item.get("type")}
83
- ]
84
- content = item.get("content")
85
- if item.get("type") == "captionedImage":
86
- self.captioned_image(**item)
87
- elif item.get("type") == "embeddedPublication":
88
- self.draft_body["content"][-1]["attrs"] = item.get("url")
89
- elif item.get("type") == "youtube2":
90
- self.youtube(item.get("src"))
91
- elif item.get("type") == "subscribeWidget":
92
- self.subscribe_with_caption(item.get("message"))
93
- else:
94
- if content is not None:
95
- self.add_complex_text(content)
96
-
97
- if item.get("type") == "heading":
98
- self.attrs(item.get("level", 1))
99
-
100
- marks = item.get("marks")
101
- if marks is not None:
102
- self.marks(marks)
103
-
104
- return self
105
-
106
- def paragraph(self, content=None):
107
- """
108
-
109
- Args:
110
- content:
111
-
112
- Returns:
113
-
114
- """
115
- item = {"type": "paragraph"}
116
- if content is not None:
117
- item["content"] = content
118
- return self.add(item)
119
-
120
- def heading(self, content=None, level: int = 1):
121
- """
122
-
123
- Args:
124
- content:
125
- level:
126
-
127
- Returns:
128
-
129
- """
130
-
131
- item = {"type": "heading"}
132
- if content is not None:
133
- item["content"] = content
134
- item["level"] = level
135
- return self.add(item)
136
-
137
- def horizontal_rule(self):
138
- """
139
-
140
- Returns:
141
-
142
- """
143
- return self.add({"type": "horizontal_rule"})
144
-
145
- def attrs(self, level):
146
- """
147
-
148
- Args:
149
- level:
150
-
151
- Returns:
152
-
153
- """
154
- content_attrs = self.draft_body["content"][-1].get("attrs", {})
155
- content_attrs.update({"level": level})
156
- self.draft_body["content"][-1]["attrs"] = content_attrs
157
- return self
158
-
159
- def captioned_image(
160
- self,
161
- src: str,
162
- fullscreen: bool = False,
163
- imageSize: str = "normal",
164
- height: int = 819,
165
- width: int = 1456,
166
- resizeWidth: int = 728,
167
- bytes: str = None,
168
- alt: str = None,
169
- title: str = None,
170
- type: str = None,
171
- href: str = None,
172
- belowTheFold: bool = False,
173
- internalRedirect: str = None,
174
- ):
175
- """
176
-
177
- Add image to body.
178
-
179
- Args:
180
- bytes:
181
- alt:
182
- title:
183
- type:
184
- href:
185
- belowTheFold:
186
- internalRedirect:
187
- src:
188
- fullscreen:
189
- imageSize:
190
- height:
191
- width:
192
- resizeWidth:
193
- """
194
-
195
- content = self.draft_body["content"][-1].get("content", [])
196
- content += [
197
- {
198
- "type": "image2",
199
- "attrs": {
200
- "src": src,
201
- "fullscreen": fullscreen,
202
- "imageSize": imageSize,
203
- "height": height,
204
- "width": width,
205
- "resizeWidth": resizeWidth,
206
- "bytes": bytes,
207
- "alt": alt,
208
- "title": title,
209
- "type": type,
210
- "href": href,
211
- "belowTheFold": belowTheFold,
212
- "internalRedirect": internalRedirect,
213
- },
214
- }
215
- ]
216
- self.draft_body["content"][-1]["content"] = content
217
- return self
218
-
219
- def text(self, value: str):
220
- """
221
-
222
- Add text to the last paragraph.
223
-
224
- Args:
225
- value: Text to add to paragraph.
226
-
227
- Returns:
228
-
229
- """
230
- content = self.draft_body["content"][-1].get("content", [])
231
- content += [{"type": "text", "text": value}]
232
- self.draft_body["content"][-1]["content"] = content
233
- return self
234
-
235
- def add_complex_text(self, text):
236
- """
237
-
238
- Args:
239
- text:
240
- """
241
- if isinstance(text, str):
242
- self.text(text)
243
- else:
244
- for chunk in text:
245
- if chunk:
246
- self.text(chunk.get("content")).marks(chunk.get("marks", []))
247
-
248
- def marks(self, marks):
249
- """
250
-
251
- Args:
252
- marks:
253
-
254
- Returns:
255
-
256
- """
257
- content = self.draft_body["content"][-1].get("content", [])[-1]
258
- content_marks = content.get("marks", [])
259
- for mark in marks:
260
- new_mark = {"type": mark.get("type")}
261
- if mark.get("type") == "link":
262
- href = mark.get("href")
263
- new_mark.update({"attrs": {"href": href}})
264
- content_marks.append(new_mark)
265
- content["marks"] = content_marks
266
- return self
267
-
268
- def remove_last_paragraph(self):
269
- """Remove last paragraph"""
270
- del self.draft_body.get("content")[-1]
271
-
272
- def get_draft(self):
273
- """
274
-
275
- Returns:
276
-
277
- """
278
- out = vars(self)
279
- out["draft_body"] = json.dumps(out["draft_body"])
280
- return out
281
-
282
- def subscribe_with_caption(self, message: str = None):
283
- """
284
-
285
- Add subscribe widget with caption
286
-
287
- Args:
288
- message:
289
-
290
- Returns:
291
-
292
- """
293
-
294
- if message is None:
295
- message = """Thanks for reading this newsletter!
296
- Subscribe for free to receive new posts and support my work."""
297
-
298
- subscribe = self.draft_body["content"][-1]
299
- subscribe["attrs"] = {
300
- "url": "%%checkout_url%%",
301
- "text": "Subscribe",
302
- "language": "en",
303
- }
304
- subscribe["content"] = [
305
- {
306
- "type": "ctaCaption",
307
- "content": [
308
- {
309
- "type": "text",
310
- "text": message,
311
- }
312
- ],
313
- }
314
- ]
315
- return self
316
-
317
- def youtube(self, value: str):
318
- """
319
-
320
- Add youtube video to post.
321
-
322
- Args:
323
- value: youtube url
324
-
325
- Returns:
326
-
327
- """
328
- content_attrs = self.draft_body["content"][-1].get("attrs", {})
329
- content_attrs.update({"videoId": value})
330
- self.draft_body["content"][-1]["attrs"] = content_attrs
331
- return self