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.
- {python_substack-0.1.16 → python_substack-0.1.17}/PKG-INFO +132 -5
- {python_substack-0.1.16 → python_substack-0.1.17}/README.md +130 -1
- {python_substack-0.1.16 → python_substack-0.1.17}/pyproject.toml +2 -2
- python_substack-0.1.17/substack/post.py +637 -0
- python_substack-0.1.16/substack/post.py +0 -331
- {python_substack-0.1.16 → python_substack-0.1.17}/LICENSE +0 -0
- {python_substack-0.1.16 → python_substack-0.1.17}/substack/__init__.py +0 -0
- {python_substack-0.1.16 → python_substack-0.1.17}/substack/api.py +0 -0
- {python_substack-0.1.16 → python_substack-0.1.17}/substack/exceptions.py +0 -0
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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
|
-
[](https://www.python.org/downloads/)
|
|
34
31
|
[](https://pepy.tech/project/python-substack)
|
|
35
32
|

|
|
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
|
+

|
|
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
|
-
[](https://www.python.org/downloads/)
|
|
7
6
|
[](https://pepy.tech/project/python-substack)
|
|
8
7
|

|
|
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
|
+

|
|
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.
|
|
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.
|
|
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 
|
|
463
|
+
- Linked images: [](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: 
|
|
566
|
+
# Also handle linked images: [](link_url)
|
|
567
|
+
elif text_content.startswith("!") or (text_content.startswith("[") and "](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: 
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|