python-substack 0.1.15__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.
@@ -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
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
32
+ ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
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
+ ![Image Alt](https://example.com/image.jpg)
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,259 @@
1
+ # Python Substack
2
+
3
+ This is an unofficial library providing a Python interface for [Substack](https://substack.com/).
4
+ I am in no way affiliated with Substack.
5
+
6
+ [![Downloads](https://static.pepy.tech/badge/python-substack/month)](https://pepy.tech/project/python-substack)
7
+ ![Release Build](https://github.com/ma2za/python-substack/actions/workflows/ci_publish.yml/badge.svg)
8
+ ---
9
+
10
+ # Installation
11
+
12
+ You can install python-substack using:
13
+
14
+ $ pip install python-substack
15
+
16
+ ---
17
+
18
+ # Setup
19
+
20
+ Set the following environment variables by creating a **.env** file:
21
+
22
+ EMAIL=
23
+ PASSWORD=
24
+ PUBLICATION_URL= # Optional: your publication URL
25
+ COOKIES_PATH= # Optional: path to cookies JSON file
26
+ COOKIES_STRING= # Optional: cookie string for authentication
27
+
28
+ ## If you don't have a password
29
+
30
+ Recently Substack has been setting up new accounts without a password. If you sign out and sign back in, it just uses
31
+ your email address with a "magic" link.
32
+
33
+ Set a password:
34
+
35
+ - Sign out of Substack
36
+ - At the sign-in page, click "Sign in with password" under the `Email` text box
37
+ - Then choose, "Set a new password"
38
+
39
+ The .env file will be ignored by git but always be careful.
40
+
41
+ ---
42
+
43
+ # Usage
44
+
45
+ Check out the examples folder for some examples 😃 🚀
46
+
47
+ ## Basic Authentication
48
+
49
+ ```python
50
+ import os
51
+ from dotenv import load_dotenv
52
+
53
+ from substack import Api
54
+ from substack.post import Post
55
+
56
+ load_dotenv()
57
+
58
+ # Authenticate with email and password
59
+ api = Api(
60
+ email=os.getenv("EMAIL"),
61
+ password=os.getenv("PASSWORD"),
62
+ publication_url=os.getenv("PUBLICATION_URL"),
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()
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
90
+ user_id = api.get_user_id()
91
+
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.
93
+
94
+ # primary publication
95
+ user_publication = api.get_user_primary_publication()
96
+ # all publications
97
+ user_publications = api.get_user_publications()
98
+
99
+ # This step is only necessary if you are not using your primary publication
100
+ # api.change_publication(user_publication)
101
+
102
+ # Create a post with basic settings
103
+ post = Post(
104
+ title="How to publish a Substack post using the Python API",
105
+ subtitle="This post was published using the Python API",
106
+ user_id=user_id
107
+ )
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
+
118
+ post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
119
+
120
+ # bolden text
121
+ post.add({'type': "paragraph",
122
+ 'content': [{'content': "This is how you "}, {'content': "bolden ", 'marks': [{'type': "strong"}]},
123
+ {'content': "a word."}]})
124
+
125
+ # add hyperlink to text
126
+ post.add({'type': 'paragraph', 'content': [
127
+ {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
128
+
129
+ # set paywall boundary
130
+ post.add({'type': 'paywall'})
131
+
132
+ # add image
133
+ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
134
+
135
+ # add local image
136
+ image = api.get_image('image.png')
137
+ post.add({"type": "captionedImage", "src": image.get("url")})
138
+
139
+ # embed publication
140
+ embedded = api.publication_embed("https://jackio.substack.com/")
141
+ post.add({"type": "embeddedPublication", "url": embedded})
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
+ ![Image Alt](https://example.com/image.jpg)
150
+ """
151
+ post.from_markdown(markdown_content, api=api)
152
+
153
+ draft = api.post_draft(post.get_draft())
154
+
155
+ # set section (can only be done after first posting the draft)
156
+ # post.set_section("rick rolling", api.get_sections())
157
+ # api.put_draft(draft.get("id"), draft_section_id=post.draft_section_id)
158
+
159
+ api.prepublish_draft(draft.get("id"))
160
+
161
+ api.publish_draft(draft.get("id"))
162
+ ```
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
+
243
+ # Contributing
244
+
245
+ Install pre-commit:
246
+
247
+ ```shell
248
+ pip install pre-commit
249
+ ```
250
+
251
+ Set up pre-commit
252
+
253
+ ```shell
254
+ pre-commit install
255
+ ```
256
+
257
+ ## Cookie Help
258
+
259
+ 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.15"
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,14 +16,14 @@ homepage = "https://github.com/ma2za/python-substack"
16
16
  keywords = ["substack"]
17
17
 
18
18
  [tool.poetry.dependencies]
19
- python = "^3.7"
19
+ python = "^3.9"
20
20
 
21
21
  requests = "^2.31.0"
22
22
  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
 
@@ -189,8 +223,8 @@ class Api:
189
223
  Args:
190
224
  publication:
191
225
  """
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):
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 = 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
+
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
- 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)
227
293
 
228
294
  return user_publications
229
295