python-substack 0.1.15__py3-none-any.whl → 0.1.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_substack-0.1.17.dist-info/METADATA +285 -0
- python_substack-0.1.17.dist-info/RECORD +8 -0
- {python_substack-0.1.15.dist-info → python_substack-0.1.17.dist-info}/WHEEL +1 -1
- substack/api.py +75 -9
- substack/post.py +308 -2
- python_substack-0.1.15.dist-info/METADATA +0 -147
- python_substack-0.1.15.dist-info/RECORD +0 -8
- {python_substack-0.1.15.dist-info → python_substack-0.1.17.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-substack
|
|
3
|
+
Version: 0.1.17
|
|
4
|
+
Summary: A Python wrapper around the Substack API.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: substack
|
|
8
|
+
Author: Paolo Mazza
|
|
9
|
+
Author-email: mazzapaolo2019@gmail.com
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
20
|
+
Requires-Dist: python-dotenv (>=0.21.0,<0.22.0)
|
|
21
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
22
|
+
Project-URL: Homepage, https://github.com/ma2za/python-substack
|
|
23
|
+
Project-URL: Repository, https://github.com/ma2za/python-substack
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Python Substack
|
|
27
|
+
|
|
28
|
+
This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
|
|
29
|
+
I am in no way affiliated with Substack.
|
|
30
|
+
|
|
31
|
+
[](https://pepy.tech/project/python-substack)
|
|
32
|
+

|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
# Installation
|
|
36
|
+
|
|
37
|
+
You can install python-substack using:
|
|
38
|
+
|
|
39
|
+
$ pip install python-substack
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
# Setup
|
|
44
|
+
|
|
45
|
+
Set the following environment variables by creating a **.env** file:
|
|
46
|
+
|
|
47
|
+
EMAIL=
|
|
48
|
+
PASSWORD=
|
|
49
|
+
PUBLICATION_URL= # Optional: your publication URL
|
|
50
|
+
COOKIES_PATH= # Optional: path to cookies JSON file
|
|
51
|
+
COOKIES_STRING= # Optional: cookie string for authentication
|
|
52
|
+
|
|
53
|
+
## If you don't have a password
|
|
54
|
+
|
|
55
|
+
Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
|
|
56
|
+
your email address with a "magic" link.
|
|
57
|
+
|
|
58
|
+
Set a password:
|
|
59
|
+
|
|
60
|
+
- Sign out of Substack
|
|
61
|
+
- At the sign-in page, click "Sign in with password" under the `Email` text box
|
|
62
|
+
- Then choose, "Set a new password"
|
|
63
|
+
|
|
64
|
+
The .env file will be ignored by git but always be careful.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
# Usage
|
|
69
|
+
|
|
70
|
+
Check out the examples folder for some examples 😃 🚀
|
|
71
|
+
|
|
72
|
+
## Basic Authentication
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import os
|
|
76
|
+
from dotenv import load_dotenv
|
|
77
|
+
|
|
78
|
+
from substack import Api
|
|
79
|
+
from substack.post import Post
|
|
80
|
+
|
|
81
|
+
load_dotenv()
|
|
82
|
+
|
|
83
|
+
# Authenticate with email and password
|
|
84
|
+
api = Api(
|
|
85
|
+
email=os.getenv("EMAIL"),
|
|
86
|
+
password=os.getenv("PASSWORD"),
|
|
87
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Cookie-based Authentication
|
|
92
|
+
|
|
93
|
+
You can also authenticate using cookies instead of email/password:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import os
|
|
97
|
+
from dotenv import load_dotenv
|
|
98
|
+
|
|
99
|
+
from substack import Api
|
|
100
|
+
|
|
101
|
+
load_dotenv()
|
|
102
|
+
|
|
103
|
+
# Authenticate with cookies (alternative to email/password)
|
|
104
|
+
api = Api(
|
|
105
|
+
cookies_path=os.getenv("COOKIES_PATH"), # Path to cookies JSON file
|
|
106
|
+
# OR
|
|
107
|
+
cookies_string=os.getenv("COOKIES_STRING"), # Cookie string
|
|
108
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Creating and Publishing Posts
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
user_id = api.get_user_id()
|
|
116
|
+
|
|
117
|
+
# Switch Publications - The library defaults to your user's primary publication. You can retrieve all your publications and change which one you want to use.
|
|
118
|
+
|
|
119
|
+
# primary publication
|
|
120
|
+
user_publication = api.get_user_primary_publication()
|
|
121
|
+
# all publications
|
|
122
|
+
user_publications = api.get_user_publications()
|
|
123
|
+
|
|
124
|
+
# This step is only necessary if you are not using your primary publication
|
|
125
|
+
# api.change_publication(user_publication)
|
|
126
|
+
|
|
127
|
+
# Create a post with basic settings
|
|
128
|
+
post = Post(
|
|
129
|
+
title="How to publish a Substack post using the Python API",
|
|
130
|
+
subtitle="This post was published using the Python API",
|
|
131
|
+
user_id=user_id
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Create a post with audience and comment permissions
|
|
135
|
+
post = Post(
|
|
136
|
+
title="My Post Title",
|
|
137
|
+
subtitle="My Post Subtitle",
|
|
138
|
+
user_id=user_id,
|
|
139
|
+
audience="everyone", # Options: "everyone", "only_paid", "founding", "only_free"
|
|
140
|
+
write_comment_permissions="everyone" # Options: "none", "only_paid", "everyone"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
|
|
144
|
+
|
|
145
|
+
# bolden text
|
|
146
|
+
post.add({'type': "paragraph",
|
|
147
|
+
'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
|
|
148
|
+
{'content': "a word."}]})
|
|
149
|
+
|
|
150
|
+
# add hyperlink to text
|
|
151
|
+
post.add({'type': 'paragraph', 'content': [
|
|
152
|
+
{'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
|
|
153
|
+
|
|
154
|
+
# set paywall boundary
|
|
155
|
+
post.add({'type': 'paywall'})
|
|
156
|
+
|
|
157
|
+
# add image
|
|
158
|
+
post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
|
|
159
|
+
|
|
160
|
+
# add local image
|
|
161
|
+
image = api.get_image('image.png')
|
|
162
|
+
post.add({"type": "captionedImage", "src": image.get("url")})
|
|
163
|
+
|
|
164
|
+
# embed publication
|
|
165
|
+
embedded = api.publication_embed("https://jackio.substack.com/")
|
|
166
|
+
post.add({"type": "embeddedPublication", "url": embedded})
|
|
167
|
+
|
|
168
|
+
# create post from Markdown
|
|
169
|
+
markdown_content = """
|
|
170
|
+
# My Heading
|
|
171
|
+
|
|
172
|
+
This is a paragraph with **bold** and *italic* text.
|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
"""
|
|
176
|
+
post.from_markdown(markdown_content, api=api)
|
|
177
|
+
|
|
178
|
+
draft = api.post_draft(post.get_draft())
|
|
179
|
+
|
|
180
|
+
# set section (can only be done after first posting the draft)
|
|
181
|
+
# post.set_section("rick rolling", api.get_sections())
|
|
182
|
+
# api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
183
|
+
|
|
184
|
+
api.prepublish_draft(draft.get("id"))
|
|
185
|
+
|
|
186
|
+
api.publish_draft(draft.get("id"))
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Loading Posts from YAML Files
|
|
190
|
+
|
|
191
|
+
You can define your posts in YAML files for easier management:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import yaml
|
|
195
|
+
import os
|
|
196
|
+
from dotenv import load_dotenv
|
|
197
|
+
|
|
198
|
+
from substack import Api
|
|
199
|
+
from substack.post import Post
|
|
200
|
+
|
|
201
|
+
load_dotenv()
|
|
202
|
+
|
|
203
|
+
# Load post data from YAML file
|
|
204
|
+
with open("draft.yaml", "r") as fp:
|
|
205
|
+
post_data = yaml.safe_load(fp)
|
|
206
|
+
|
|
207
|
+
# Authenticate (using cookies or email/password)
|
|
208
|
+
cookies_path = os.getenv("COOKIES_PATH")
|
|
209
|
+
cookies_string = os.getenv("COOKIES_STRING")
|
|
210
|
+
|
|
211
|
+
api = Api(
|
|
212
|
+
email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
|
|
213
|
+
password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
|
|
214
|
+
cookies_path=cookies_path,
|
|
215
|
+
cookies_string=cookies_string,
|
|
216
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
user_id = api.get_user_id()
|
|
220
|
+
|
|
221
|
+
# Create post from YAML data
|
|
222
|
+
post = Post(
|
|
223
|
+
post_data.get("title"),
|
|
224
|
+
post_data.get("subtitle", ""),
|
|
225
|
+
user_id,
|
|
226
|
+
audience=post_data.get("audience", "everyone"),
|
|
227
|
+
write_comment_permissions=post_data.get("write_comment_permissions", "everyone"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Add body content from YAML
|
|
231
|
+
body = post_data.get("body", {})
|
|
232
|
+
for _, item in body.items():
|
|
233
|
+
# Handle local images - upload them first
|
|
234
|
+
if item.get("type") == "captionedImage" and not item.get("src").startswith("http"):
|
|
235
|
+
image = api.get_image(item.get("src"))
|
|
236
|
+
item.update({"src": image.get("url")})
|
|
237
|
+
post.add(item)
|
|
238
|
+
|
|
239
|
+
draft = api.post_draft(post.get_draft())
|
|
240
|
+
api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
241
|
+
|
|
242
|
+
# Publish the draft
|
|
243
|
+
api.prepublish_draft(draft.get("id"))
|
|
244
|
+
api.publish_draft(draft.get("id"))
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Example YAML structure:
|
|
248
|
+
|
|
249
|
+
```yaml
|
|
250
|
+
title: "My Post Title"
|
|
251
|
+
subtitle: "My Post Subtitle"
|
|
252
|
+
audience: "everyone" # everyone, only_paid, founding, only_free
|
|
253
|
+
write_comment_permissions: "everyone" # none, only_paid, everyone
|
|
254
|
+
section: "my-section"
|
|
255
|
+
body:
|
|
256
|
+
0:
|
|
257
|
+
type: "heading"
|
|
258
|
+
level: 1
|
|
259
|
+
content: "Introduction"
|
|
260
|
+
1:
|
|
261
|
+
type: "paragraph"
|
|
262
|
+
content: "This is a paragraph."
|
|
263
|
+
2:
|
|
264
|
+
type: "captionedImage"
|
|
265
|
+
src: "local_image.jpg" # Local images will be uploaded automatically
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
# Contributing
|
|
269
|
+
|
|
270
|
+
Install pre-commit:
|
|
271
|
+
|
|
272
|
+
```shell
|
|
273
|
+
pip install pre-commit
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Set up pre-commit
|
|
277
|
+
|
|
278
|
+
```shell
|
|
279
|
+
pre-commit install
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Cookie Help
|
|
283
|
+
|
|
284
|
+
To get a cookie string, after login, go to dev tools (F12), network tab, refresh and find one of the requests like subscription/unred/subscriptions, right click and copy as fetch (Node.js), paste somewhere and get the entire cookie string assigned to the cookie header and put it in the env variables as COOKIES_STRING, et voila!
|
|
285
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
substack/__init__.py,sha256=mkNj8jFW6wA4dIYyyM1UfKt5Q9MgR8xg3CaADl4IjlQ,387
|
|
2
|
+
substack/api.py,sha256=KFSeStlfmri1qyO_5rOT41dd7QWV-iHqyMbQyVSd5-k,18336
|
|
3
|
+
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
4
|
+
substack/post.py,sha256=KVwEBeQp32CtzUJPZFf3yACJaPzUiJ0zoZZfyWcqnlM,20820
|
|
5
|
+
python_substack-0.1.17.dist-info/METADATA,sha256=HZKwKT1VRGn7z37sYFn3u4XyIO7up2c7tv0ejuu0Ago,8007
|
|
6
|
+
python_substack-0.1.17.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
7
|
+
python_substack-0.1.17.dist-info/licenses/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
8
|
+
python_substack-0.1.17.dist-info/RECORD,,
|
substack/api.py
CHANGED
|
@@ -9,7 +9,7 @@ import json
|
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
11
|
from datetime import datetime
|
|
12
|
-
from urllib.parse import urljoin
|
|
12
|
+
from urllib.parse import urljoin, unquote
|
|
13
13
|
|
|
14
14
|
import requests
|
|
15
15
|
|
|
@@ -35,6 +35,7 @@ class Api:
|
|
|
35
35
|
base_url=None,
|
|
36
36
|
publication_url=None,
|
|
37
37
|
debug=False,
|
|
38
|
+
cookies_string=None,
|
|
38
39
|
):
|
|
39
40
|
"""
|
|
40
41
|
|
|
@@ -49,6 +50,10 @@ class Api:
|
|
|
49
50
|
To re-use your session without logging in each time, you can save your cookies to a json file and
|
|
50
51
|
then load them in the next session.
|
|
51
52
|
Make sure to re-save your cookies, as they do update over time.
|
|
53
|
+
cookies_string
|
|
54
|
+
To re-use your session without logging in each time, you can provide cookies as a semicolon-separated
|
|
55
|
+
string (e.g., "cookie1=value1; cookie2=value2"). This is useful when copying cookies from browser
|
|
56
|
+
developer tools.
|
|
52
57
|
base_url:
|
|
53
58
|
The base URL to use to contact the Substack API.
|
|
54
59
|
Defaults to https://substack.com/api/v1.
|
|
@@ -68,11 +73,15 @@ class Api:
|
|
|
68
73
|
cookies = json.load(f)
|
|
69
74
|
self._session.cookies.update(cookies)
|
|
70
75
|
|
|
76
|
+
elif cookies_string is not None:
|
|
77
|
+
cookies = self._parse_cookies_string(cookies_string)
|
|
78
|
+
self._session.cookies.update(cookies)
|
|
79
|
+
|
|
71
80
|
elif email is not None and password is not None:
|
|
72
81
|
self.login(email, password)
|
|
73
82
|
else:
|
|
74
83
|
raise ValueError(
|
|
75
|
-
"Must provide email and password or
|
|
84
|
+
"Must provide email and password, cookies_path, or cookies_string to authenticate."
|
|
76
85
|
)
|
|
77
86
|
|
|
78
87
|
user_publication = None
|
|
@@ -98,6 +107,31 @@ class Api:
|
|
|
98
107
|
# set the current publication to the users primary publication
|
|
99
108
|
self.change_publication(user_publication)
|
|
100
109
|
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _parse_cookies_string(cookies_string: str) -> dict:
|
|
112
|
+
"""
|
|
113
|
+
Parse a semicolon-separated cookie string into a dictionary.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A dictionary of cookie name-value pairs
|
|
120
|
+
"""
|
|
121
|
+
cookies = {}
|
|
122
|
+
for cookie_pair in cookies_string.split(';'):
|
|
123
|
+
cookie_pair = cookie_pair.strip()
|
|
124
|
+
if not cookie_pair:
|
|
125
|
+
continue
|
|
126
|
+
if '=' in cookie_pair:
|
|
127
|
+
key, value = cookie_pair.split('=', 1)
|
|
128
|
+
key = key.strip()
|
|
129
|
+
value = value.strip()
|
|
130
|
+
# URL decode the value (e.g., s%3A becomes s:)
|
|
131
|
+
value = unquote(value)
|
|
132
|
+
cookies[key] = value
|
|
133
|
+
return cookies
|
|
134
|
+
|
|
101
135
|
def login(self, email, password) -> dict:
|
|
102
136
|
"""
|
|
103
137
|
|
|
@@ -189,8 +223,8 @@ class Api:
|
|
|
189
223
|
Args:
|
|
190
224
|
publication:
|
|
191
225
|
"""
|
|
192
|
-
custom_domain = publication
|
|
193
|
-
if not custom_domain:
|
|
226
|
+
custom_domain = publication.get("custom_domain", None)
|
|
227
|
+
if not custom_domain and not publication.get('custom_domain_optional', None):
|
|
194
228
|
publication_url = f"https://{publication['subdomain']}.substack.com"
|
|
195
229
|
else:
|
|
196
230
|
publication_url = f"https://{custom_domain}"
|
|
@@ -203,7 +237,31 @@ class Api:
|
|
|
203
237
|
"""
|
|
204
238
|
|
|
205
239
|
profile = self.get_user_profile()
|
|
206
|
-
primary_publication =
|
|
240
|
+
primary_publication = None
|
|
241
|
+
|
|
242
|
+
# Try old API format first (backward compatibility)
|
|
243
|
+
if "primaryPublication" in profile and profile["primaryPublication"] is not None:
|
|
244
|
+
primary_publication = profile["primaryPublication"]
|
|
245
|
+
else:
|
|
246
|
+
# New API format: look for primary publication in publicationUsers
|
|
247
|
+
publication_users = profile.get("publicationUsers")
|
|
248
|
+
if publication_users is not None and len(publication_users) > 0:
|
|
249
|
+
# Find the publication where is_primary is True
|
|
250
|
+
for pub_user in publication_users:
|
|
251
|
+
if pub_user.get("is_primary", False):
|
|
252
|
+
primary_publication = pub_user.get("publication")
|
|
253
|
+
if primary_publication:
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
# If no primary found, use the first publication
|
|
257
|
+
if primary_publication is None:
|
|
258
|
+
primary_publication = publication_users[0].get("publication")
|
|
259
|
+
|
|
260
|
+
if primary_publication is None:
|
|
261
|
+
raise SubstackRequestException(
|
|
262
|
+
"Could not find primary publication in profile"
|
|
263
|
+
)
|
|
264
|
+
|
|
207
265
|
primary_publication["publication_url"] = self.get_publication_url(
|
|
208
266
|
primary_publication
|
|
209
267
|
)
|
|
@@ -220,10 +278,18 @@ class Api:
|
|
|
220
278
|
# Loop through users "publicationUsers" list, and return a list
|
|
221
279
|
# of dictionaries of "name", and "subdomain", and "id"
|
|
222
280
|
user_publications = []
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
281
|
+
publication_users = profile.get("publicationUsers")
|
|
282
|
+
|
|
283
|
+
if publication_users is None:
|
|
284
|
+
# If publicationUsers is None, return empty list or try to construct from other fields
|
|
285
|
+
# This maintains backward compatibility while handling new API format
|
|
286
|
+
return user_publications
|
|
287
|
+
|
|
288
|
+
for publication in publication_users:
|
|
289
|
+
pub = publication.get("publication")
|
|
290
|
+
if pub is not None:
|
|
291
|
+
pub["publication_url"] = self.get_publication_url(pub)
|
|
292
|
+
user_publications.append(pub)
|
|
227
293
|
|
|
228
294
|
return user_publications
|
|
229
295
|
|
substack/post.py
CHANGED
|
@@ -5,13 +5,103 @@ Post Utilities
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
-
|
|
8
|
+
import re
|
|
9
|
+
from typing import Dict, List
|
|
9
10
|
|
|
10
|
-
__all__ = ["Post"]
|
|
11
|
+
__all__ = ["Post", "parse_inline"]
|
|
11
12
|
|
|
12
13
|
from substack.exceptions import SectionNotExistsException
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def parse_inline(text: str) -> List[Dict]:
|
|
17
|
+
"""
|
|
18
|
+
Convert inline Markdown in a text string into a list of tokens
|
|
19
|
+
for use in the post content.
|
|
20
|
+
|
|
21
|
+
Supported formatting:
|
|
22
|
+
- **Bold**: Text wrapped in double asterisks.
|
|
23
|
+
- *Italic*: Text wrapped in single asterisks.
|
|
24
|
+
- [Links]: Text wrapped in square brackets followed by URL in parentheses.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
text: Text string containing inline Markdown formatting.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of token dictionaries with content and marks.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> parse_inline("This is **bold** and this is [a link](https://example.com)")
|
|
34
|
+
[{'content': 'This is '}, {'content': 'bold', 'marks': [{'type': 'strong'}]}, {'content': ' and this is '}, {'content': 'a link', 'marks': [{'type': 'link', 'attrs': {'href': 'https://example.com'}}]}]
|
|
35
|
+
"""
|
|
36
|
+
if not text:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
tokens = []
|
|
40
|
+
# Process text character by character to handle nested formatting
|
|
41
|
+
# We'll use regex to find all markdown patterns, then process them in order
|
|
42
|
+
|
|
43
|
+
# Find all markdown patterns: links, bold, italic
|
|
44
|
+
# Pattern order: links first (to avoid conflicts), then bold, then italic
|
|
45
|
+
link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
|
|
46
|
+
bold_pattern = r'\*\*([^*]+)\*\*'
|
|
47
|
+
italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
|
|
48
|
+
|
|
49
|
+
# Find all matches with their positions
|
|
50
|
+
matches = []
|
|
51
|
+
for match in re.finditer(link_pattern, text):
|
|
52
|
+
# Skip if it's an image link (starts with ![)
|
|
53
|
+
if match.start() > 0 and text[match.start()-1:match.start()+1] != "![":
|
|
54
|
+
matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
|
|
55
|
+
|
|
56
|
+
for match in re.finditer(bold_pattern, text):
|
|
57
|
+
# Check if this range is already covered by a link
|
|
58
|
+
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
59
|
+
matches.append((match.start(), match.end(), "bold", match.group(1), None))
|
|
60
|
+
|
|
61
|
+
for match in re.finditer(italic_pattern, text):
|
|
62
|
+
# Check if this range is already covered by a link or bold
|
|
63
|
+
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
64
|
+
matches.append((match.start(), match.end(), "italic", match.group(1), None))
|
|
65
|
+
|
|
66
|
+
# Sort matches by position
|
|
67
|
+
matches.sort(key=lambda x: x[0])
|
|
68
|
+
|
|
69
|
+
# Build tokens
|
|
70
|
+
last_pos = 0
|
|
71
|
+
for start, end, match_type, content, url in matches:
|
|
72
|
+
# Add text before this match
|
|
73
|
+
if start > last_pos:
|
|
74
|
+
tokens.append({"content": text[last_pos:start]})
|
|
75
|
+
|
|
76
|
+
# Add the formatted content
|
|
77
|
+
if match_type == "link":
|
|
78
|
+
tokens.append({
|
|
79
|
+
"content": content,
|
|
80
|
+
"marks": [{"type": "link", "attrs": {"href": url}}]
|
|
81
|
+
})
|
|
82
|
+
elif match_type == "bold":
|
|
83
|
+
tokens.append({
|
|
84
|
+
"content": content,
|
|
85
|
+
"marks": [{"type": "strong"}]
|
|
86
|
+
})
|
|
87
|
+
elif match_type == "italic":
|
|
88
|
+
tokens.append({
|
|
89
|
+
"content": content,
|
|
90
|
+
"marks": [{"type": "em"}]
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
last_pos = end
|
|
94
|
+
|
|
95
|
+
# Add remaining text
|
|
96
|
+
if last_pos < len(text):
|
|
97
|
+
tokens.append({"content": text[last_pos:]})
|
|
98
|
+
|
|
99
|
+
# Filter out empty tokens
|
|
100
|
+
tokens = [t for t in tokens if t.get("content")]
|
|
101
|
+
|
|
102
|
+
return tokens
|
|
103
|
+
|
|
104
|
+
|
|
15
105
|
class Post:
|
|
16
106
|
"""
|
|
17
107
|
|
|
@@ -90,6 +180,8 @@ class Post:
|
|
|
90
180
|
self.youtube(item.get("src"))
|
|
91
181
|
elif item.get("type") == "subscribeWidget":
|
|
92
182
|
self.subscribe_with_caption(item.get("message"))
|
|
183
|
+
elif item.get("type") == "codeBlock":
|
|
184
|
+
self.code_block(item.get("content"), item.get("attrs", {}))
|
|
93
185
|
else:
|
|
94
186
|
if content is not None:
|
|
95
187
|
self.add_complex_text(content)
|
|
@@ -329,3 +421,217 @@ class Post:
|
|
|
329
421
|
content_attrs.update({"videoId": value})
|
|
330
422
|
self.draft_body["content"][-1]["attrs"] = content_attrs
|
|
331
423
|
return self
|
|
424
|
+
|
|
425
|
+
def code_block(self, content, attrs=None):
|
|
426
|
+
"""
|
|
427
|
+
Add code block to post.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
content: String containing code or list of text nodes
|
|
431
|
+
attrs: Optional attributes like language
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
|
|
435
|
+
"""
|
|
436
|
+
if attrs is None:
|
|
437
|
+
attrs = {}
|
|
438
|
+
|
|
439
|
+
# Handle content - can be list of text nodes or a string
|
|
440
|
+
if isinstance(content, str):
|
|
441
|
+
# Convert string to list of text nodes
|
|
442
|
+
code_content = [{"type": "text", "text": content}]
|
|
443
|
+
elif isinstance(content, list):
|
|
444
|
+
code_content = content
|
|
445
|
+
else:
|
|
446
|
+
code_content = []
|
|
447
|
+
|
|
448
|
+
# Set up the code block structure
|
|
449
|
+
code_block = self.draft_body["content"][-1]
|
|
450
|
+
code_block["content"] = code_content
|
|
451
|
+
if attrs:
|
|
452
|
+
code_block["attrs"] = attrs
|
|
453
|
+
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
def from_markdown(self, markdown_content: str, api=None):
|
|
457
|
+
"""
|
|
458
|
+
Parse Markdown content and add it to the post.
|
|
459
|
+
|
|
460
|
+
Supported Markdown features:
|
|
461
|
+
- Headings: Lines starting with '#' characters (1-6 levels)
|
|
462
|
+
- Images: Markdown image syntax 
|
|
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,147 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: python-substack
|
|
3
|
-
Version: 0.1.15
|
|
4
|
-
Summary: A Python wrapper around the Substack API.
|
|
5
|
-
Home-page: https://github.com/ma2za/python-substack
|
|
6
|
-
License: MIT
|
|
7
|
-
Keywords: substack
|
|
8
|
-
Author: Paolo Mazza
|
|
9
|
-
Author-email: mazzapaolo2019@gmail.com
|
|
10
|
-
Requires-Python: >=3.7,<4.0
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.7
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
-
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
20
|
-
Requires-Dist: python-dotenv (>=0.21.0,<0.22.0)
|
|
21
|
-
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
22
|
-
Project-URL: Repository, https://github.com/ma2za/python-substack
|
|
23
|
-
Description-Content-Type: text/markdown
|
|
24
|
-
|
|
25
|
-
# Python Substack
|
|
26
|
-
|
|
27
|
-
This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
|
|
28
|
-
I am in no way affiliated with Substack.
|
|
29
|
-
|
|
30
|
-
[](https://www.python.org/downloads/)
|
|
31
|
-
[](https://pepy.tech/project/python-substack)
|
|
32
|
-

|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
# Installation
|
|
36
|
-
|
|
37
|
-
You can install python-substack using:
|
|
38
|
-
|
|
39
|
-
$ pip install python-substack
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
# Setup
|
|
44
|
-
|
|
45
|
-
Set the following environment variables by creating a **.env** file:
|
|
46
|
-
|
|
47
|
-
EMAIL=
|
|
48
|
-
PASSWORD=
|
|
49
|
-
|
|
50
|
-
## If you don't have a password
|
|
51
|
-
|
|
52
|
-
Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses
|
|
53
|
-
your email address with a "magic" link.
|
|
54
|
-
|
|
55
|
-
Set a password:
|
|
56
|
-
|
|
57
|
-
- Sign-out of Substack
|
|
58
|
-
- At the sign-in page click, "Sign in with password" under the `Email` text box
|
|
59
|
-
- Then choose, "Set a new password"
|
|
60
|
-
|
|
61
|
-
The .env file will be ignored by git but always be careful.
|
|
62
|
-
|
|
63
|
-
---
|
|
64
|
-
|
|
65
|
-
# Usage
|
|
66
|
-
|
|
67
|
-
Check out the examples folder for some examples 😃 🚀
|
|
68
|
-
|
|
69
|
-
```python
|
|
70
|
-
import os
|
|
71
|
-
|
|
72
|
-
from substack import Api
|
|
73
|
-
from substack.post import Post
|
|
74
|
-
|
|
75
|
-
api = Api(
|
|
76
|
-
email=os.getenv("EMAIL"),
|
|
77
|
-
password=os.getenv("PASSWORD"),
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
user_id = api.get_user_id()
|
|
81
|
-
|
|
82
|
-
# Switch Publications - The library defaults to your users primary publication. You can retrieve all your publications and change which one you want to use.
|
|
83
|
-
|
|
84
|
-
# primary publication
|
|
85
|
-
user_publication = api.get_user_primary_publication()
|
|
86
|
-
# all publications
|
|
87
|
-
user_publications = api.get_user_publications()
|
|
88
|
-
|
|
89
|
-
# This step is only necessary if you are not using your primary publication
|
|
90
|
-
# api.change_publication(user_publication)
|
|
91
|
-
|
|
92
|
-
post = Post(
|
|
93
|
-
title="How to publish a Substack post using the Python API",
|
|
94
|
-
subtitle="This post was published using the Python API",
|
|
95
|
-
user_id=user_id
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
|
|
99
|
-
|
|
100
|
-
# bolden text
|
|
101
|
-
post.add({'type': "paragraph",
|
|
102
|
-
'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
|
|
103
|
-
{'content': "a word."}]})
|
|
104
|
-
|
|
105
|
-
# add hyperlink to text
|
|
106
|
-
post.add({'type': 'paragraph', 'content': [
|
|
107
|
-
{'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
|
|
108
|
-
|
|
109
|
-
# set paywall boundary
|
|
110
|
-
post.add({'type': 'paywall'})
|
|
111
|
-
|
|
112
|
-
# add image
|
|
113
|
-
post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
|
|
114
|
-
|
|
115
|
-
# add local image
|
|
116
|
-
image = api.get_image('image.png')
|
|
117
|
-
post.add({"type": "captionedImage", "src": image.get("url")})
|
|
118
|
-
|
|
119
|
-
# embed publication
|
|
120
|
-
embedded = api.publication_embed("https://jackio.substack.com/")
|
|
121
|
-
post.add({"type": "embeddedPublication", "url": embedded})
|
|
122
|
-
|
|
123
|
-
draft = api.post_draft(post.get_draft())
|
|
124
|
-
|
|
125
|
-
# set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
|
|
126
|
-
post.set_section("rick rolling", api.get_sections())
|
|
127
|
-
api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
128
|
-
|
|
129
|
-
api.prepublish_draft(draft.get("id"))
|
|
130
|
-
|
|
131
|
-
api.publish_draft(draft.get("id"))
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
# Contributing
|
|
135
|
-
|
|
136
|
-
Install pre-commit:
|
|
137
|
-
|
|
138
|
-
```shell
|
|
139
|
-
pip install pre-commit
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
Set up pre-commit
|
|
143
|
-
|
|
144
|
-
```shell
|
|
145
|
-
pre-commit install
|
|
146
|
-
```
|
|
147
|
-
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
substack/__init__.py,sha256=mkNj8jFW6wA4dIYyyM1UfKt5Q9MgR8xg3CaADl4IjlQ,387
|
|
2
|
-
substack/api.py,sha256=VkAscD5Ks07wbIWIxTF-a8rqU2WDjdUwUtGX9mkGxtY,15367
|
|
3
|
-
substack/exceptions.py,sha256=BbP5W5UpzFcM5SYIxx6snWD_Rmj7F_YjYIYC_r03gZY,911
|
|
4
|
-
substack/post.py,sha256=rcnTfWUqfuocvHN61UxidbAiX4Y7LtcvSAkLjjsxqxM,7997
|
|
5
|
-
python_substack-0.1.15.dist-info/LICENSE,sha256=L6jk148I5HhhVbfUvkO3EO7eAoU5zToLio4-ApkCkxg,1062
|
|
6
|
-
python_substack-0.1.15.dist-info/METADATA,sha256=tyMO8CgEvWBmKB7hw4L__xW_gLVbX7LCKuuKTPIVcYM,4215
|
|
7
|
-
python_substack-0.1.15.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
8
|
-
python_substack-0.1.15.dist-info/RECORD,,
|
|
File without changes
|