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.
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.14
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-out and sign back in it just uses
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-out of Substack
58
- - At the sign-in page click, "Sign in with password" under the `Email` text box
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 users primary publication. You can retrieve all your publications and change which one you want to use.
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 (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
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-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
29
32
  your email address with a "magic" link.
30
33
 
31
34
  Set a password:
32
35
 
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
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 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.
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 (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
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.14"
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-dependencies]
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 cookies_path to authenticate."
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
- return Api._handle_response(response=response)
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["custom_domain"]
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 = 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
+
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
- for publication in profile["publicationUsers"]:
220
- pub = publication["publication"]
221
- pub["publication_url"] = self.get_publication_url(pub)
222
- 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)
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":