Skip to content

Commit 81c7feb

Browse files
committed
🔧 Update dependencies and enhance cookie authentication support
1 parent d79f1ca commit 81c7feb

File tree

5 files changed

+121
-26
lines changed

5 files changed

+121
-26
lines changed

‎README.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,19 @@ Set the following environment variables by creating a **.env** file:
2222

2323
EMAIL=
2424
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
2528

2629
## If you don't have a password
2730

28-
Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses
31+
Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
2932
your email address with a "magic" link.
3033

3134
Set a password:
3235

33-
- Sign-out of Substack
34-
- At the sign-in page click, "Sign in with password" under the `Email` text box
36+
- Sign out of Substack
37+
- At the sign-in page, click "Sign in with password" under the `Email` text box
3538
- Then choose, "Set a new password"
3639

3740
The .env file will be ignored by git but always be careful.
@@ -51,11 +54,12 @@ from substack.post import Post
5154
api = Api(
5255
email=os.getenv("EMAIL"),
5356
password=os.getenv("PASSWORD"),
57+
publication_url=os.getenv("PUBLICATION_URL"),
5458
)
5559

5660
user_id = api.get_user_id()
5761

58-
# Switch Publications - The library defaults to your users primary publication. You can retrieve all your publications and change which one you want to use.
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.
5963

6064
# primary publication
6165
user_publication = api.get_user_primary_publication()
@@ -98,9 +102,9 @@ post.add({"type": "embeddedPublication", "url": embedded})
98102

99103
draft = api.post_draft(post.get_draft())
100104

101-
# set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
102-
post.set_section("rick rolling", api.get_sections())
103-
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)
104108

105109
api.prepublish_draft(draft.get("id"))
106110

@@ -120,3 +124,7 @@ Set up pre-commit
120124
```shell
121125
pre-commit install
122126
```
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!

‎examples/publish_post.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,34 @@
2222
parser.add_argument(
2323
"--publish", help="Publish the draft.", action="store_true", default=True
2424
)
25+
parser.add_argument(
26+
"--cookies",
27+
help="Path to cookies JSON file for authentication (optional, can also be set via COOKIES_PATH or COOKIES_STRING env vars).",
28+
type=str,
29+
default=None,
30+
)
2531
args = parser.parse_args()
2632

2733
with open(args.post, "r") as fp:
2834
post_data = yaml.safe_load(fp)
2935

36+
cookies_path = args.cookies or os.getenv("COOKIES_PATH")
37+
cookies_string = os.getenv("COOKIES_STRING")
38+
3039
api = Api(
31-
email=os.getenv("EMAIL"),
32-
password=os.getenv("PASSWORD"),
40+
email=os.getenv("EMAIL") if not cookies_path and not cookies_string else None,
41+
password=os.getenv("PASSWORD") if not cookies_path and not cookies_string else None,
42+
cookies_path=cookies_path,
43+
cookies_string=cookies_string,
3344
publication_url=os.getenv("PUBLICATION_URL"),
3445
)
3546

47+
user_id = api.get_user_id()
48+
3649
post = Post(
3750
post_data.get("title"),
3851
post_data.get("subtitle", ""),
39-
os.getenv("USER_ID"),
52+
user_id,
4053
audience=post_data.get("audience", "everyone"),
4154
write_comment_permissions=post_data.get(
4255
"write_comment_permissions", "everyone"
@@ -53,7 +66,7 @@
5366

5467
draft = api.post_draft(post.get_draft())
5568

56-
post.set_section(post_data.get("section"), api.get_sections())
69+
# post.set_section(post_data.get("section"), api.get_sections())
5770
api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
5871

5972
if args.publish:

‎poetry.lock‎

Lines changed: 12 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-substack"
3-
version = "0.1.15"
3+
version = "0.1.16"
44
description = "A Python wrapper around the Substack API."
55
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
66
license = "MIT"
@@ -23,7 +23,7 @@ python-dotenv = "^0.21.0"
2323
PyYAML = "^6.0"
2424

2525

26-
[tool.poetry.dev-dependencies]
26+
[tool.poetry.group.dev.dependencies]
2727

2828

2929
[build-system]

‎substack/api.py‎

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import logging
1010
import os
1111
from datetime import datetime
12-
from urllib.parse import urljoin
12+
from urllib.parse import urljoin, unquote
1313

1414
import requests
1515

@@ -35,6 +35,7 @@ def __init__(
3535
base_url=None,
3636
publication_url=None,
3737
debug=False,
38+
cookies_string=None,
3839
):
3940
"""
4041
@@ -49,6 +50,10 @@ def __init__(
4950
To re-use your session without logging in each time, you can save your cookies to a json file and
5051
then load them in the next session.
5152
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.
5257
base_url:
5358
The base URL to use to contact the Substack API.
5459
Defaults to https://substack.com/api/v1.
@@ -68,11 +73,15 @@ def __init__(
6873
cookies = json.load(f)
6974
self._session.cookies.update(cookies)
7075

76+
elif cookies_string is not None:
77+
cookies = self._parse_cookies_string(cookies_string)
78+
self._session.cookies.update(cookies)
79+
7180
elif email is not None and password is not None:
7281
self.login(email, password)
7382
else:
7483
raise ValueError(
75-
"Must provide email and password or cookies_path to authenticate."
84+
"Must provide email and password, cookies_path, or cookies_string to authenticate."
7685
)
7786

7887
user_publication = None
@@ -98,6 +107,31 @@ def __init__(
98107
# set the current publication to the users primary publication
99108
self.change_publication(user_publication)
100109

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+
101135
def login(self, email, password) -> dict:
102136
"""
103137
@@ -189,8 +223,8 @@ def get_publication_url(publication: dict) -> str:
189223
Args:
190224
publication:
191225
"""
192-
custom_domain = publication["custom_domain"]
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):
194228
publication_url = f"https://{publication['subdomain']}.substack.com"
195229
else:
196230
publication_url = f"https://{custom_domain}"
@@ -203,7 +237,31 @@ def get_user_primary_publication(self):
203237
"""
204238

205239
profile = self.get_user_profile()
206-
primary_publication = profile["primaryPublication"]
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+
207265
primary_publication["publication_url"] = self.get_publication_url(
208266
primary_publication
209267
)
@@ -220,10 +278,18 @@ def get_user_publications(self):
220278
# Loop through users "publicationUsers" list, and return a list
221279
# of dictionaries of "name", and "subdomain", and "id"
222280
user_publications = []
223-
for publication in profile["publicationUsers"]:
224-
pub = publication["publication"]
225-
pub["publication_url"] = self.get_publication_url(pub)
226-
user_publications.append(pub)
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)
227293

228294
return user_publications
229295

0 commit comments

Comments
 (0)