python-substack 0.1.14__tar.gz → 0.1.16__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.14 → python_substack-0.1.16}/PKG-INFO +25 -10
- {python_substack-0.1.14 → python_substack-0.1.16}/README.md +19 -7
- {python_substack-0.1.14 → python_substack-0.1.16}/pyproject.toml +2 -2
- {python_substack-0.1.14 → python_substack-0.1.16}/substack/api.py +109 -10
- {python_substack-0.1.14 → python_substack-0.1.16}/substack/post.py +2 -0
- {python_substack-0.1.14 → python_substack-0.1.16}/LICENSE +0 -0
- {python_substack-0.1.14 → python_substack-0.1.16}/substack/__init__.py +0 -0
- {python_substack-0.1.14 → python_substack-0.1.16}/substack/exceptions.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.16
|
|
4
4
|
Summary: A Python wrapper around the Substack API.
|
|
5
|
-
Home-page: https://github.com/ma2za/python-substack
|
|
6
5
|
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
7
|
Keywords: substack
|
|
8
8
|
Author: Paolo Mazza
|
|
9
9
|
Author-email: mazzapaolo2019@gmail.com
|
|
@@ -16,9 +16,12 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
21
|
Requires-Dist: PyYAML (>=6.0,<7.0)
|
|
20
22
|
Requires-Dist: python-dotenv (>=0.21.0,<0.22.0)
|
|
21
23
|
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
24
|
+
Project-URL: Homepage, https://github.com/ma2za/python-substack
|
|
22
25
|
Project-URL: Repository, https://github.com/ma2za/python-substack
|
|
23
26
|
Description-Content-Type: text/markdown
|
|
24
27
|
|
|
@@ -46,16 +49,19 @@ Set the following environment variables by creating a **.env** file:
|
|
|
46
49
|
|
|
47
50
|
EMAIL=
|
|
48
51
|
PASSWORD=
|
|
52
|
+
PUBLICATION_URL= # Optional: your publication URL
|
|
53
|
+
COOKIES_PATH= # Optional: path to cookies JSON file
|
|
54
|
+
COOKIES_STRING= # Optional: cookie string for authentication
|
|
49
55
|
|
|
50
56
|
## If you don't have a password
|
|
51
57
|
|
|
52
|
-
Recently Substack has been setting up new accounts without a password. If you sign
|
|
58
|
+
Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
|
|
53
59
|
your email address with a "magic" link.
|
|
54
60
|
|
|
55
61
|
Set a password:
|
|
56
62
|
|
|
57
|
-
- Sign
|
|
58
|
-
- At the sign-in page click
|
|
63
|
+
- Sign out of Substack
|
|
64
|
+
- At the sign-in page, click "Sign in with password" under the `Email` text box
|
|
59
65
|
- Then choose, "Set a new password"
|
|
60
66
|
|
|
61
67
|
The .env file will be ignored by git but always be careful.
|
|
@@ -75,11 +81,12 @@ from substack.post import Post
|
|
|
75
81
|
api = Api(
|
|
76
82
|
email=os.getenv("EMAIL"),
|
|
77
83
|
password=os.getenv("PASSWORD"),
|
|
84
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
78
85
|
)
|
|
79
86
|
|
|
80
87
|
user_id = api.get_user_id()
|
|
81
88
|
|
|
82
|
-
# Switch Publications - The library defaults to your
|
|
89
|
+
# 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.
|
|
83
90
|
|
|
84
91
|
# primary publication
|
|
85
92
|
user_publication = api.get_user_primary_publication()
|
|
@@ -116,11 +123,15 @@ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsA
|
|
|
116
123
|
image = api.get_image('image.png')
|
|
117
124
|
post.add({"type": "captionedImage", "src": image.get("url")})
|
|
118
125
|
|
|
126
|
+
# embed publication
|
|
127
|
+
embedded = api.publication_embed("https://jackio.substack.com/")
|
|
128
|
+
post.add({"type": "embeddedPublication", "url": embedded})
|
|
129
|
+
|
|
119
130
|
draft = api.post_draft(post.get_draft())
|
|
120
131
|
|
|
121
|
-
# set section (
|
|
122
|
-
post.set_section("rick rolling", api.get_sections())
|
|
123
|
-
api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
132
|
+
# set section (can only be done after first posting the draft)
|
|
133
|
+
# post.set_section("rick rolling", api.get_sections())
|
|
134
|
+
# api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
124
135
|
|
|
125
136
|
api.prepublish_draft(draft.get("id"))
|
|
126
137
|
|
|
@@ -141,3 +152,7 @@ Set up pre-commit
|
|
|
141
152
|
pre-commit install
|
|
142
153
|
```
|
|
143
154
|
|
|
155
|
+
## Cookie Help
|
|
156
|
+
|
|
157
|
+
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!
|
|
158
|
+
|
|
@@ -22,16 +22,19 @@ Set the following environment variables by creating a **.env** file:
|
|
|
22
22
|
|
|
23
23
|
EMAIL=
|
|
24
24
|
PASSWORD=
|
|
25
|
+
PUBLICATION_URL= # Optional: your publication URL
|
|
26
|
+
COOKIES_PATH= # Optional: path to cookies JSON file
|
|
27
|
+
COOKIES_STRING= # Optional: cookie string for authentication
|
|
25
28
|
|
|
26
29
|
## If you don't have a password
|
|
27
30
|
|
|
28
|
-
Recently Substack has been setting up new accounts without a password. If you sign
|
|
31
|
+
Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
|
|
29
32
|
your email address with a "magic" link.
|
|
30
33
|
|
|
31
34
|
Set a password:
|
|
32
35
|
|
|
33
|
-
- Sign
|
|
34
|
-
- At the sign-in page click
|
|
36
|
+
- Sign out of Substack
|
|
37
|
+
- At the sign-in page, click "Sign in with password" under the `Email` text box
|
|
35
38
|
- Then choose, "Set a new password"
|
|
36
39
|
|
|
37
40
|
The .env file will be ignored by git but always be careful.
|
|
@@ -51,11 +54,12 @@ from substack.post import Post
|
|
|
51
54
|
api = Api(
|
|
52
55
|
email=os.getenv("EMAIL"),
|
|
53
56
|
password=os.getenv("PASSWORD"),
|
|
57
|
+
publication_url=os.getenv("PUBLICATION_URL"),
|
|
54
58
|
)
|
|
55
59
|
|
|
56
60
|
user_id = api.get_user_id()
|
|
57
61
|
|
|
58
|
-
# Switch Publications - The library defaults to your
|
|
62
|
+
# 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.
|
|
59
63
|
|
|
60
64
|
# primary publication
|
|
61
65
|
user_publication = api.get_user_primary_publication()
|
|
@@ -92,11 +96,15 @@ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsA
|
|
|
92
96
|
image = api.get_image('image.png')
|
|
93
97
|
post.add({"type": "captionedImage", "src": image.get("url")})
|
|
94
98
|
|
|
99
|
+
# embed publication
|
|
100
|
+
embedded = api.publication_embed("https://jackio.substack.com/")
|
|
101
|
+
post.add({"type": "embeddedPublication", "url": embedded})
|
|
102
|
+
|
|
95
103
|
draft = api.post_draft(post.get_draft())
|
|
96
104
|
|
|
97
|
-
# set section (
|
|
98
|
-
post.set_section("rick rolling", api.get_sections())
|
|
99
|
-
api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
105
|
+
# set section (can only be done after first posting the draft)
|
|
106
|
+
# post.set_section("rick rolling", api.get_sections())
|
|
107
|
+
# api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
|
|
100
108
|
|
|
101
109
|
api.prepublish_draft(draft.get("id"))
|
|
102
110
|
|
|
@@ -116,3 +124,7 @@ Set up pre-commit
|
|
|
116
124
|
```shell
|
|
117
125
|
pre-commit install
|
|
118
126
|
```
|
|
127
|
+
|
|
128
|
+
## Cookie Help
|
|
129
|
+
|
|
130
|
+
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!
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-substack"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.16"
|
|
4
4
|
description = "A Python wrapper around the Substack API."
|
|
5
5
|
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -23,7 +23,7 @@ python-dotenv = "^0.21.0"
|
|
|
23
23
|
PyYAML = "^6.0"
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
[tool.poetry.dev
|
|
26
|
+
[tool.poetry.group.dev.dependencies]
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
[build-system]
|
|
@@ -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
|
|
|
@@ -128,7 +162,11 @@ class Api:
|
|
|
128
162
|
response = self._session.get(
|
|
129
163
|
f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}",
|
|
130
164
|
)
|
|
131
|
-
|
|
165
|
+
try:
|
|
166
|
+
output = Api._handle_response(response=response)
|
|
167
|
+
except SubstackRequestException as ex:
|
|
168
|
+
output = {}
|
|
169
|
+
return output
|
|
132
170
|
|
|
133
171
|
def change_publication(self, publication):
|
|
134
172
|
"""
|
|
@@ -185,8 +223,8 @@ class Api:
|
|
|
185
223
|
Args:
|
|
186
224
|
publication:
|
|
187
225
|
"""
|
|
188
|
-
custom_domain = publication
|
|
189
|
-
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):
|
|
190
228
|
publication_url = f"https://{publication['subdomain']}.substack.com"
|
|
191
229
|
else:
|
|
192
230
|
publication_url = f"https://{custom_domain}"
|
|
@@ -199,7 +237,31 @@ class Api:
|
|
|
199
237
|
"""
|
|
200
238
|
|
|
201
239
|
profile = self.get_user_profile()
|
|
202
|
-
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
|
+
|
|
203
265
|
primary_publication["publication_url"] = self.get_publication_url(
|
|
204
266
|
primary_publication
|
|
205
267
|
)
|
|
@@ -216,10 +278,18 @@ class Api:
|
|
|
216
278
|
# Loop through users "publicationUsers" list, and return a list
|
|
217
279
|
# of dictionaries of "name", and "subdomain", and "id"
|
|
218
280
|
user_publications = []
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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)
|
|
223
293
|
|
|
224
294
|
return user_publications
|
|
225
295
|
|
|
@@ -538,3 +608,32 @@ class Api:
|
|
|
538
608
|
if p.get("hostname") in self.publication_url
|
|
539
609
|
]
|
|
540
610
|
return sections[0]
|
|
611
|
+
|
|
612
|
+
def publication_embed(self, url):
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
url:
|
|
617
|
+
|
|
618
|
+
Returns:
|
|
619
|
+
|
|
620
|
+
"""
|
|
621
|
+
return self.call("/publication/embed", "GET", url=url)
|
|
622
|
+
|
|
623
|
+
def call(self, endpoint, method, **params):
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
endpoint:
|
|
628
|
+
method:
|
|
629
|
+
**params:
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
|
|
633
|
+
"""
|
|
634
|
+
response = self._session.request(
|
|
635
|
+
method=method,
|
|
636
|
+
url=f"{self.publication_url}/{endpoint}",
|
|
637
|
+
params=params,
|
|
638
|
+
)
|
|
639
|
+
return Api._handle_response(response=response)
|
|
@@ -84,6 +84,8 @@ class Post:
|
|
|
84
84
|
content = item.get("content")
|
|
85
85
|
if item.get("type") == "captionedImage":
|
|
86
86
|
self.captioned_image(**item)
|
|
87
|
+
elif item.get("type") == "embeddedPublication":
|
|
88
|
+
self.draft_body["content"][-1]["attrs"] = item.get("url")
|
|
87
89
|
elif item.get("type") == "youtube2":
|
|
88
90
|
self.youtube(item.get("src"))
|
|
89
91
|
elif item.get("type") == "subscribeWidget":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|