python-substack 0.1.16__tar.gz → 0.1.18__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.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:
@@ -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.18"
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,665 @@
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
+ # 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
+
106
+ class Post:
107
+ """
108
+
109
+ Post utility class
110
+
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ title: str,
116
+ subtitle: str,
117
+ user_id,
118
+ audience: str = None,
119
+ write_comment_permissions: str = None,
120
+ ):
121
+ """
122
+
123
+ Args:
124
+ title:
125
+ subtitle:
126
+ user_id:
127
+ audience: possible values: everyone, only_paid, founding, only_free
128
+ write_comment_permissions: none, only_paid, everyone (this field is a mess)
129
+ """
130
+ self.draft_title = title
131
+ self.draft_subtitle = subtitle
132
+ self.draft_body = {"type": "doc", "content": []}
133
+ self.draft_bylines = [{"id": int(user_id), "is_guest": False}]
134
+ self.audience = audience if audience is not None else "everyone"
135
+ self.draft_section_id = None
136
+ self.section_chosen = True
137
+
138
+ # TODO better understand the possible values and combinations with audience
139
+ if write_comment_permissions is not None:
140
+ self.write_comment_permissions = write_comment_permissions
141
+ else:
142
+ self.write_comment_permissions = self.audience
143
+
144
+ def set_section(self, name: str, sections: list):
145
+ """
146
+
147
+ Args:
148
+ name:
149
+ sections:
150
+
151
+ Returns:
152
+
153
+ """
154
+ section = [s for s in sections if s.get("name") == name]
155
+ if len(section) != 1:
156
+ raise SectionNotExistsException(name)
157
+ section = section[0]
158
+ self.draft_section_id = section.get("id")
159
+
160
+ def add(self, item: Dict):
161
+ """
162
+
163
+ Add item to draft body.
164
+
165
+ Args:
166
+ item:
167
+
168
+ Returns:
169
+
170
+ """
171
+
172
+ self.draft_body["content"] = self.draft_body.get("content", []) + [
173
+ {"type": item.get("type")}
174
+ ]
175
+ content = item.get("content")
176
+ if item.get("type") == "captionedImage":
177
+ self.captioned_image(**item)
178
+ elif item.get("type") == "embeddedPublication":
179
+ self.draft_body["content"][-1]["attrs"] = item.get("url")
180
+ elif item.get("type") == "youtube2":
181
+ self.youtube(item.get("src"))
182
+ elif item.get("type") == "subscribeWidget":
183
+ self.subscribe_with_caption(item.get("message"))
184
+ elif item.get("type") == "codeBlock":
185
+ self.code_block(item.get("content"), item.get("attrs", {}))
186
+ else:
187
+ if content is not None:
188
+ self.add_complex_text(content)
189
+
190
+ if item.get("type") == "heading":
191
+ self.attrs(item.get("level", 1))
192
+
193
+ marks = item.get("marks")
194
+ if marks is not None:
195
+ self.marks(marks)
196
+
197
+ return self
198
+
199
+ def paragraph(self, content=None):
200
+ """
201
+
202
+ Args:
203
+ content:
204
+
205
+ Returns:
206
+
207
+ """
208
+ item = {"type": "paragraph"}
209
+ if content is not None:
210
+ item["content"] = content
211
+ return self.add(item)
212
+
213
+ def heading(self, content=None, level: int = 1):
214
+ """
215
+
216
+ Args:
217
+ content:
218
+ level:
219
+
220
+ Returns:
221
+
222
+ """
223
+
224
+ item = {"type": "heading"}
225
+ if content is not None:
226
+ item["content"] = content
227
+ item["level"] = level
228
+ return self.add(item)
229
+
230
+ def horizontal_rule(self):
231
+ """
232
+
233
+ Returns:
234
+
235
+ """
236
+ return self.add({"type": "horizontal_rule"})
237
+
238
+ def attrs(self, level):
239
+ """
240
+
241
+ Args:
242
+ level:
243
+
244
+ Returns:
245
+
246
+ """
247
+ content_attrs = self.draft_body["content"][-1].get("attrs", {})
248
+ content_attrs.update({"level": level})
249
+ self.draft_body["content"][-1]["attrs"] = content_attrs
250
+ return self
251
+
252
+ def captioned_image(
253
+ self,
254
+ src: str,
255
+ fullscreen: bool = False,
256
+ imageSize: str = "normal",
257
+ height: int = 819,
258
+ width: int = 1456,
259
+ resizeWidth: int = 728,
260
+ bytes: str = None,
261
+ alt: str = None,
262
+ title: str = None,
263
+ type: str = None,
264
+ href: str = None,
265
+ belowTheFold: bool = False,
266
+ internalRedirect: str = None,
267
+ ):
268
+ """
269
+
270
+ Add image to body.
271
+
272
+ Args:
273
+ bytes:
274
+ alt:
275
+ title:
276
+ type:
277
+ href:
278
+ belowTheFold:
279
+ internalRedirect:
280
+ src:
281
+ fullscreen:
282
+ imageSize:
283
+ height:
284
+ width:
285
+ resizeWidth:
286
+ """
287
+
288
+ content = self.draft_body["content"][-1].get("content", [])
289
+ content += [
290
+ {
291
+ "type": "image2",
292
+ "attrs": {
293
+ "src": src,
294
+ "fullscreen": fullscreen,
295
+ "imageSize": imageSize,
296
+ "height": height,
297
+ "width": width,
298
+ "resizeWidth": resizeWidth,
299
+ "bytes": bytes,
300
+ "alt": alt,
301
+ "title": title,
302
+ "type": type,
303
+ "href": href,
304
+ "belowTheFold": belowTheFold,
305
+ "internalRedirect": internalRedirect,
306
+ },
307
+ }
308
+ ]
309
+ self.draft_body["content"][-1]["content"] = content
310
+ return self
311
+
312
+ def text(self, value: str):
313
+ """
314
+
315
+ Add text to the last paragraph.
316
+
317
+ Args:
318
+ value: Text to add to paragraph.
319
+
320
+ Returns:
321
+
322
+ """
323
+ content = self.draft_body["content"][-1].get("content", [])
324
+ content += [{"type": "text", "text": value}]
325
+ self.draft_body["content"][-1]["content"] = content
326
+ return self
327
+
328
+ def add_complex_text(self, text):
329
+ """
330
+
331
+ Args:
332
+ text:
333
+ """
334
+ if isinstance(text, str):
335
+ self.text(text)
336
+ else:
337
+ for chunk in text:
338
+ if chunk:
339
+ self.text(chunk.get("content")).marks(chunk.get("marks", []))
340
+
341
+ def marks(self, marks):
342
+ """
343
+
344
+ Args:
345
+ marks:
346
+
347
+ Returns:
348
+
349
+ """
350
+ content = self.draft_body["content"][-1].get("content", [])[-1]
351
+ content_marks = content.get("marks", [])
352
+ for mark in marks:
353
+ new_mark = {"type": mark.get("type")}
354
+ if mark.get("type") == "link":
355
+ href = mark.get("href") or mark.get("attrs", {}).get("href")
356
+ new_mark.update({"attrs": {"href": href}})
357
+ content_marks.append(new_mark)
358
+ content["marks"] = content_marks
359
+ return self
360
+
361
+ def remove_last_paragraph(self):
362
+ """Remove last paragraph"""
363
+ del self.draft_body.get("content")[-1]
364
+
365
+ def get_draft(self):
366
+ """
367
+
368
+ Returns:
369
+
370
+ """
371
+ out = vars(self)
372
+ out["draft_body"] = json.dumps(out["draft_body"])
373
+ return out
374
+
375
+ def subscribe_with_caption(self, message: str = None):
376
+ """
377
+
378
+ Add subscribe widget with caption
379
+
380
+ Args:
381
+ message:
382
+
383
+ Returns:
384
+
385
+ """
386
+
387
+ if message is None:
388
+ message = """Thanks for reading this newsletter!
389
+ Subscribe for free to receive new posts and support my work."""
390
+
391
+ subscribe = self.draft_body["content"][-1]
392
+ subscribe["attrs"] = {
393
+ "url": "%%checkout_url%%",
394
+ "text": "Subscribe",
395
+ "language": "en",
396
+ }
397
+ subscribe["content"] = [
398
+ {
399
+ "type": "ctaCaption",
400
+ "content": [
401
+ {
402
+ "type": "text",
403
+ "text": message,
404
+ }
405
+ ],
406
+ }
407
+ ]
408
+ return self
409
+
410
+ def youtube(self, value: str):
411
+ """
412
+
413
+ Add youtube video to post.
414
+
415
+ Args:
416
+ value: youtube url
417
+
418
+ Returns:
419
+
420
+ """
421
+ content_attrs = self.draft_body["content"][-1].get("attrs", {})
422
+ content_attrs.update({"videoId": value})
423
+ self.draft_body["content"][-1]["attrs"] = content_attrs
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,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