python-substack 0.3.0__py3-none-any.whl → 0.5.0__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.
substack/api.py CHANGED
@@ -1,792 +1,835 @@
1
- """
2
-
3
- API Wrapper
4
-
5
- """
6
-
7
- import base64
8
- import json
9
- import logging
10
- import os
11
- from datetime import datetime
12
- from urllib.parse import unquote, urljoin
13
-
14
- import requests
15
- from requests.adapters import HTTPAdapter, Retry
16
-
17
- from substack.exceptions import SubstackAPIException, SubstackRequestException
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
- __all__ = ["Api"]
22
-
23
-
24
- class Api:
25
- """
26
-
27
- A python interface into the Substack API
28
-
29
- """
30
-
31
- def __init__(
32
- self,
33
- email=None,
34
- password=None,
35
- cookies_path=None,
36
- base_url=None,
37
- publication_url=None,
38
- debug=False,
39
- cookies_string=None,
40
- ):
41
- """
42
-
43
- To create an instance of the substack.Api class:
44
- >>> import substack
45
- >>> api = substack.Api(email="substack email", password="substack password")
46
-
47
- Args:
48
- email:
49
- password:
50
- cookies_path
51
- To re-use your session without logging in each time, you can save your cookies to a json file and
52
- then load them in the next session.
53
- Make sure to re-save your cookies, as they do update over time.
54
- cookies_string
55
- To re-use your session without logging in each time, you can provide cookies as a semicolon-separated
56
- string (e.g., "cookie1=value1; cookie2=value2"). This is useful when copying cookies from browser
57
- developer tools.
58
- base_url:
59
- The base URL to use to contact the Substack API.
60
- Defaults to https://substack.com/api/v1.
61
- """
62
- self.base_url = base_url or "https://substack.com/api/v1"
63
-
64
- if debug:
65
- logging.basicConfig()
66
- logging.getLogger().setLevel(logging.DEBUG)
67
-
68
- self._session = requests.Session()
69
- retry = Retry(
70
- total=4,
71
- status=4,
72
- backoff_factor=1,
73
- status_forcelist=(429,),
74
- allowed_methods=frozenset({"GET", "DELETE"}),
75
- respect_retry_after_header=True,
76
- raise_on_status=False,
77
- )
78
- adapter = HTTPAdapter(max_retries=retry)
79
- self._session.mount("http://", adapter)
80
- self._session.mount("https://", adapter)
81
-
82
- # Load cookies from file if provided
83
- # Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
84
- if cookies_path is not None:
85
- with open(cookies_path) as f:
86
- cookies = json.load(f)
87
- self._session.cookies.update(cookies)
88
-
89
- elif cookies_string is not None:
90
- cookies = self._parse_cookies_string(cookies_string)
91
- self._session.cookies.update(cookies)
92
-
93
- elif email is not None and password is not None:
94
- self.login(email, password)
95
- else:
96
- raise ValueError(
97
- "Must provide email and password, cookies_path, or cookies_string to authenticate."
98
- )
99
-
100
- user_publication = None
101
- # if the user provided a publication url, then use that
102
- if publication_url:
103
- import re
104
-
105
- # Regular expression to extract subdomain name
106
- match = re.search(r"https://(.*).substack.com", publication_url.lower())
107
- subdomain = match.group(1) if match else None
108
-
109
- user_publications = self.get_user_publications()
110
- # search through publications to find the publication with the matching subdomain
111
- for publication in user_publications:
112
- if publication["subdomain"] == subdomain:
113
- # set the current publication to the users publication
114
- user_publication = publication
115
- break
116
- else:
117
- # get the users primary publication
118
- user_publication = self.get_user_primary_publication()
119
-
120
- # set the current publication to the users primary publication
121
- self.change_publication(user_publication)
122
-
123
- @staticmethod
124
- def _parse_cookies_string(cookies_string: str) -> dict:
125
- """
126
- Parse a semicolon-separated cookie string into a dictionary.
127
-
128
- Args:
129
- cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
130
-
131
- Returns:
132
- A dictionary of cookie name-value pairs
133
- """
134
- cookies = {}
135
- for cookie_pair in cookies_string.split(";"):
136
- cookie_pair = cookie_pair.strip()
137
- if not cookie_pair:
138
- continue
139
- if "=" in cookie_pair:
140
- key, value = cookie_pair.split("=", 1)
141
- key = key.strip()
142
- value = value.strip()
143
- # URL decode the value (e.g., s%3A becomes s:)
144
- value = unquote(value)
145
- cookies[key] = value
146
- return cookies
147
-
148
- @staticmethod
149
- def _normalize_tags(tags):
150
- if tags is None:
151
- return []
152
- if isinstance(tags, str):
153
- return [tags]
154
- return [str(tag) for tag in tags]
155
-
156
- def login(self, email, password) -> dict:
157
- """
158
-
159
- Login to the substack account.
160
-
161
- Args:
162
- email: substack account email
163
- password: substack account password
164
- """
165
-
166
- response = self._session.post(
167
- f"{self.base_url}/login",
168
- json={
169
- "captcha_response": None,
170
- "email": email,
171
- "for_pub": "",
172
- "password": password,
173
- "redirect": "/",
174
- },
175
- )
176
-
177
- return Api._handle_response(response=response)
178
-
179
- def signin_for_pub(self, publication):
180
- """
181
- Complete the signin process
182
- """
183
- response = self._session.get(
184
- f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}",
185
- )
186
- try:
187
- output = Api._handle_response(response=response)
188
- except SubstackRequestException as ex:
189
- output = {}
190
- return output
191
-
192
- def change_publication(self, publication):
193
- """
194
- Change the publication URL
195
- """
196
- self.publication_url = urljoin(publication["publication_url"], "api/v1")
197
-
198
- # sign-in to the publication
199
- self.signin_for_pub(publication)
200
-
201
- def export_cookies(self, path: str = "cookies.json"):
202
- """
203
- Export cookies to a json file.
204
- Args:
205
- path: path to the json file
206
- """
207
- cookies = self._session.cookies.get_dict()
208
- with open(path, "w") as f:
209
- json.dump(cookies, f)
210
-
211
- @staticmethod
212
- def _handle_response(response: requests.Response):
213
- """
214
-
215
- Internal helper for handling API responses from the Substack server.
216
- Raises the appropriate exceptions when necessary; otherwise, returns the
217
- response.
218
-
219
- """
220
-
221
- if not (200 <= response.status_code < 300):
222
- raise SubstackAPIException(response.status_code, response.text)
223
- try:
224
- return response.json()
225
- except ValueError:
226
- raise SubstackRequestException("Invalid Response: %s" % response.text)
227
-
228
- def get_user_id(self):
229
- """
230
-
231
- Returns:
232
-
233
- """
234
- profile = self.get_user_profile()
235
- user_id = profile["id"]
236
-
237
- return user_id
238
-
239
- @staticmethod
240
- def get_publication_url(publication: dict) -> str:
241
- """
242
- Gets the publication url
243
-
244
- Args:
245
- publication:
246
- """
247
- custom_domain = publication.get("custom_domain", None)
248
- if not custom_domain and not publication.get("custom_domain_optional", None):
249
- publication_url = f"https://{publication['subdomain']}.substack.com"
250
- else:
251
- publication_url = f"https://{custom_domain}"
252
-
253
- return publication_url
254
-
255
- def get_user_primary_publication(self):
256
- """
257
- Gets the users primary publication
258
- """
259
-
260
- profile = self.get_user_profile()
261
- primary_publication = None
262
-
263
- # Try old API format first (backward compatibility)
264
- if (
265
- "primaryPublication" in profile
266
- and profile["primaryPublication"] is not None
267
- ):
268
- primary_publication = profile["primaryPublication"]
269
- else:
270
- # New API format: look for primary publication in publicationUsers
271
- publication_users = profile.get("publicationUsers")
272
- if publication_users is not None and len(publication_users) > 0:
273
- # Find the publication where is_primary is True
274
- for pub_user in publication_users:
275
- if pub_user.get("is_primary", False):
276
- primary_publication = pub_user.get("publication")
277
- if primary_publication:
278
- break
279
-
280
- # If no primary found, use the first publication
281
- if primary_publication is None:
282
- primary_publication = publication_users[0].get("publication")
283
-
284
- if primary_publication is None:
285
- raise SubstackRequestException(
286
- "Could not find primary publication in profile"
287
- )
288
-
289
- primary_publication["publication_url"] = self.get_publication_url(
290
- primary_publication
291
- )
292
-
293
- return primary_publication
294
-
295
- def get_user_publications(self):
296
- """
297
- Gets the users publications
298
- """
299
-
300
- profile = self.get_user_profile()
301
-
302
- # Loop through users "publicationUsers" list, and return a list
303
- # of dictionaries of "name", and "subdomain", and "id"
304
- user_publications = []
305
- publication_users = profile.get("publicationUsers")
306
-
307
- if publication_users is None:
308
- # If publicationUsers is None, return empty list or try to construct from other fields
309
- # This maintains backward compatibility while handling new API format
310
- return user_publications
311
-
312
- for publication in publication_users:
313
- pub = publication.get("publication")
314
- if pub is not None:
315
- pub["publication_url"] = self.get_publication_url(pub)
316
- user_publications.append(pub)
317
-
318
- return user_publications
319
-
320
- def get_user_profile(self):
321
- """
322
- Gets the users profile
323
- """
324
- response = self._session.get(f"{self.base_url}/user/profile/self")
325
-
326
- return Api._handle_response(response=response)
327
-
328
- def get_user_settings(self):
329
- """
330
- Get list of users.
331
-
332
- Returns:
333
-
334
- """
335
- response = self._session.get(f"{self.base_url}/settings")
336
-
337
- return Api._handle_response(response=response)
338
-
339
- def get_publication_users(self):
340
- """
341
- Get list of users.
342
-
343
- Returns:
344
-
345
- """
346
- response = self._session.get(f"{self.publication_url}/publication/users")
347
-
348
- return Api._handle_response(response=response)
349
-
350
- def get_publication_subscriber_count(self):
351
- """
352
- Get subscriber count.
353
-
354
- Returns:
355
-
356
- """
357
- response = self._session.get(
358
- f"{self.publication_url}/publication_launch_checklist"
359
- )
360
-
361
- data = Api._handle_response(response=response)
362
- if "subscriberCount" in data:
363
- return data["subscriberCount"]
364
- return len(data["subscribers"])
365
-
366
- def get_published_posts(
367
- self, offset=0, limit=25, order_by="post_date", order_direction="desc"
368
- ):
369
- """
370
- Get list of published posts for the publication.
371
- """
372
- response = self._session.get(
373
- f"{self.publication_url}/post_management/published",
374
- params={
375
- "offset": offset,
376
- "limit": limit,
377
- "order_by": order_by,
378
- "order_direction": order_direction,
379
- },
380
- )
381
-
382
- return Api._handle_response(response=response)
383
-
384
- def get_posts(self) -> dict:
385
- """
386
-
387
- Returns:
388
-
389
- """
390
- response = self._session.get(f"{self.base_url}/reader/posts")
391
-
392
- return Api._handle_response(response=response)
393
-
394
- def get_drafts(self, filter=None, offset=None, limit=None):
395
- """
396
-
397
- Args:
398
- filter:
399
- offset:
400
- limit:
401
-
402
- Returns:
403
-
404
- """
405
- response = self._session.get(
406
- f"{self.publication_url}/drafts",
407
- params={"filter": filter, "offset": offset, "limit": limit},
408
- )
409
- return Api._handle_response(response=response)
410
-
411
- def get_draft(self, draft_id):
412
- """
413
- Gets a draft given it's id.
414
-
415
- """
416
- response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
417
- return Api._handle_response(response=response)
418
-
419
- def delete_draft(self, draft_id):
420
- """
421
-
422
- Args:
423
- draft_id:
424
-
425
- Returns:
426
-
427
- """
428
- response = self._session.delete(f"{self.publication_url}/drafts/{draft_id}")
429
- return Api._handle_response(response=response)
430
-
431
- def post_draft(self, body) -> dict:
432
- """
433
-
434
- Args:
435
- body:
436
-
437
- Returns:
438
-
439
- """
440
- response = self._session.post(f"{self.publication_url}/drafts", json=body)
441
- return Api._handle_response(response=response)
442
-
443
- def create_draft_from_markdown(
444
- self,
445
- title: str,
446
- markdown: str,
447
- subtitle: str = "",
448
- audience: str = "everyone",
449
- write_comment_permissions: str = "everyone",
450
- search_engine_title: str = None,
451
- search_engine_description: str = None,
452
- slug: str = None,
453
- draft_section_id: int = None,
454
- tags=None,
455
- prepublish: bool = False,
456
- publish: bool = False,
457
- send: bool = True,
458
- share_automatically: bool = False,
459
- ) -> dict:
460
- from substack.post import Post
461
-
462
- post = Post(
463
- title=title,
464
- subtitle=subtitle or "",
465
- user_id=self.get_user_id(),
466
- audience=audience,
467
- write_comment_permissions=write_comment_permissions,
468
- )
469
- post.from_markdown(markdown, api=self)
470
-
471
- draft = self.post_draft(post.get_draft())
472
- draft_id = draft.get("id")
473
-
474
- update_payload = {
475
- "search_engine_title": search_engine_title,
476
- "search_engine_description": search_engine_description,
477
- "slug": slug,
478
- "draft_section_id": draft_section_id,
479
- }
480
- update_payload = {
481
- key: value for key, value in update_payload.items() if value is not None
482
- }
483
- if update_payload:
484
- draft = self.put_draft(draft_id, **update_payload)
485
-
486
- tags_result = None
487
- tags_list = self._normalize_tags(tags)
488
- if tags_list:
489
- tags_result = self.add_tags_to_post(draft_id, tags_list)
490
-
491
- prepublish_result = None
492
- if prepublish:
493
- prepublish_result = self.prepublish_draft(draft_id)
494
-
495
- publish_result = None
496
- if publish:
497
- publish_result = self.publish_draft(
498
- draft_id,
499
- send=send,
500
- share_automatically=share_automatically,
501
- )
502
-
503
- return {
504
- "draft": draft,
505
- "tags": tags_result,
506
- "prepublish": prepublish_result,
507
- "publish": publish_result,
508
- }
509
-
510
- def put_draft(self, draft, **kwargs) -> dict:
511
- """
512
-
513
- Args:
514
- draft:
515
- **kwargs:
516
-
517
- Returns:
518
-
519
- """
520
- response = self._session.put(
521
- f"{self.publication_url}/drafts/{draft}",
522
- json=kwargs,
523
- )
524
- return Api._handle_response(response=response)
525
-
526
- def prepublish_draft(self, draft) -> dict:
527
- """
528
-
529
- Args:
530
- draft: draft id
531
-
532
- Returns:
533
-
534
- """
535
-
536
- response = self._session.get(
537
- f"{self.publication_url}/drafts/{draft}/prepublish"
538
- )
539
- return Api._handle_response(response=response)
540
-
541
- def publish_draft(
542
- self, draft, send: bool = True, share_automatically: bool = False
543
- ) -> dict:
544
- """
545
-
546
- Args:
547
- draft: draft id
548
- send:
549
- share_automatically:
550
-
551
- Returns:
552
-
553
- """
554
- response = self._session.post(
555
- f"{self.publication_url}/drafts/{draft}/publish",
556
- json={"send": send, "share_automatically": share_automatically},
557
- )
558
- return Api._handle_response(response=response)
559
-
560
- def schedule_draft(self, draft, draft_datetime: datetime) -> dict:
561
- """
562
-
563
- Args:
564
- draft: draft id
565
- draft_datetime: datetime to schedule the draft
566
-
567
- Returns:
568
-
569
- """
570
- response = self._session.post(
571
- f"{self.publication_url}/drafts/{draft}/scheduled_release",
572
- json={"trigger_at": draft_datetime.isoformat()},
573
- )
574
- return Api._handle_response(response=response)
575
-
576
- def unschedule_draft(self, draft) -> dict:
577
- """
578
-
579
- Args:
580
- draft: draft id
581
-
582
- Returns:
583
-
584
- """
585
- response = self._session.delete(
586
- f"{self.publication_url}/drafts/{draft}/scheduled_release"
587
- )
588
- return Api._handle_response(response=response)
589
-
590
- def get_image(self, image: str):
591
- """
592
-
593
- This method generates a new substack link that contains the image.
594
-
595
- Args:
596
- image: filepath or original url of image.
597
-
598
- Returns:
599
-
600
- """
601
- if os.path.exists(image):
602
- with open(image, "rb") as file:
603
- image = b"data:image/jpeg;base64," + base64.b64encode(file.read())
604
-
605
- response = self._session.post(
606
- f"{self.publication_url}/image",
607
- data={"image": image},
608
- )
609
- return Api._handle_response(response=response)
610
-
611
- def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
612
- """
613
- Add multiple tags to a post.
614
-
615
- Args:
616
- post_id: The ID of the post to tag.
617
- tag_names: A list of tag names to add.
618
-
619
- Returns:
620
- A dictionary with the results of applying all tags.
621
- """
622
- results = []
623
- for tag_name in tag_names:
624
- result = self.add_tag_to_post(post_id, tag_name)
625
- results.append(result)
626
- return {"tags_added": results}
627
-
628
- def get_publication_post_tags(self) -> list:
629
- """
630
- Retrieve all post tags for the current publication.
631
-
632
- Returns:
633
- List of tag dicts as returned by Substack API.
634
- """
635
- response = self._session.get(f"{self.publication_url}/publication/post-tag")
636
- return Api._handle_response(response=response)
637
-
638
- def add_tag_to_post(self, post_id: int, tag_name: str) -> dict:
639
- """
640
- Add a tag to a post by first checking published tags and creating only if needed.
641
-
642
- Args:
643
- post_id: The ID of the post to tag.
644
- tag_name: The name of the tag to add.
645
-
646
- Returns:
647
- The response from applying the tag to the post.
648
- """
649
- # Fetch existing publication tags first (avoid re-creating an already existing tag)
650
- existing_tags = self.get_publication_post_tags() or []
651
- existing_tag = next(
652
- (tag for tag in existing_tags if tag.get("name") == tag_name),
653
- None,
654
- )
655
-
656
- if existing_tag is not None:
657
- tag_id = existing_tag["id"]
658
- else:
659
- create_tag_response = self._session.post(
660
- f"{self.publication_url}/publication/post-tag",
661
- json={"name": tag_name},
662
- )
663
- tag_data = Api._handle_response(create_tag_response)
664
- tag_id = tag_data["id"]
665
-
666
- apply_tag_response = self._session.post(
667
- f"{self.publication_url}/post/{post_id}/tag/{tag_id}",
668
- )
669
- return Api._handle_response(apply_tag_response)
670
-
671
- def get_categories(self):
672
- """
673
-
674
- Retrieve list of all available categories.
675
-
676
- Returns:
677
-
678
- """
679
- response = self._session.get(f"{self.base_url}/categories")
680
- return Api._handle_response(response=response)
681
-
682
- def get_category(self, category_id, category_type, page):
683
- """
684
-
685
- Args:
686
- category_id:
687
- category_type:
688
- page:
689
-
690
- Returns:
691
-
692
- """
693
- response = self._session.get(
694
- f"{self.base_url}/category/public/{category_id}/{category_type}",
695
- params={"page": page},
696
- )
697
- return Api._handle_response(response=response)
698
-
699
- def get_single_category(self, category_id, category_type, page=None, limit=None):
700
- """
701
-
702
- Args:
703
- category_id:
704
- category_type: paid or all
705
- page: by default substack retrieves only the first 25 publications in the category. If this is left None,
706
- then all pages will be retrieved. The page size is 25 publications.
707
- limit:
708
- Returns:
709
-
710
- """
711
- if page is not None:
712
- output = self.get_category(category_id, category_type, page)
713
- else:
714
- publications = []
715
- page = 0
716
- while True:
717
- page_output = self.get_category(category_id, category_type, page)
718
- publications.extend(page_output.get("publications", []))
719
- if (
720
- limit is not None and limit <= len(publications)
721
- ) or not page_output.get("more", False):
722
- publications = publications[:limit]
723
- break
724
- page += 1
725
- output = {
726
- "publications": publications,
727
- "more": page_output.get("more", False),
728
- }
729
- return output
730
-
731
- def delete_all_drafts(self):
732
- """
733
-
734
- Returns:
735
-
736
- """
737
- response = None
738
- while True:
739
- drafts = self.get_drafts(filter="draft", limit=10, offset=0)
740
- if len(drafts) == 0:
741
- break
742
- for draft in drafts:
743
- response = self.delete_draft(draft.get("id"))
744
- return response
745
-
746
- def get_sections(self):
747
- """
748
- Get a list of the sections of your publication.
749
-
750
- TODO: this is hacky but I cannot find another place where to get the sections.
751
- Returns:
752
-
753
- """
754
- response = self._session.get(
755
- f"{self.publication_url}/subscriptions",
756
- )
757
- content = Api._handle_response(response=response)
758
- sections = [
759
- p.get("sections")
760
- for p in content.get("publications")
761
- if p.get("hostname") in self.publication_url
762
- ]
763
- return sections[0]
764
-
765
- def publication_embed(self, url):
766
- """
767
-
768
- Args:
769
- url:
770
-
771
- Returns:
772
-
773
- """
774
- return self.call("/publication/embed", "GET", url=url)
775
-
776
- def call(self, endpoint, method, **params):
777
- """
778
-
779
- Args:
780
- endpoint:
781
- method:
782
- **params:
783
-
784
- Returns:
785
-
786
- """
787
- response = self._session.request(
788
- method=method,
789
- url=f"{self.publication_url}/{endpoint}",
790
- params=params,
791
- )
792
- return Api._handle_response(response=response)
1
+ """
2
+
3
+ API Wrapper
4
+
5
+ """
6
+
7
+ import base64
8
+ import json
9
+ import logging
10
+ import os
11
+ from datetime import datetime
12
+ from urllib.parse import unquote, urljoin
13
+
14
+ import requests
15
+ from requests.adapters import HTTPAdapter, Retry
16
+
17
+ from substack.exceptions import SubstackAPIException, SubstackRequestException
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ __all__ = ["Api"]
22
+
23
+
24
+ class Api:
25
+ """
26
+
27
+ A python interface into the Substack API
28
+
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ email=None,
34
+ password=None,
35
+ cookies_path=None,
36
+ base_url=None,
37
+ publication_url=None,
38
+ debug=False,
39
+ cookies_string=None,
40
+ timeout=None,
41
+ ):
42
+ """
43
+
44
+ To create an instance of the substack.Api class:
45
+ >>> import substack
46
+ >>> api = substack.Api(email="substack email", password="substack password")
47
+
48
+ Args:
49
+ email:
50
+ password:
51
+ cookies_path
52
+ To re-use your session without logging in each time, you can save your cookies to a json file and
53
+ then load them in the next session.
54
+ Make sure to re-save your cookies, as they do update over time.
55
+ cookies_string
56
+ To re-use your session without logging in each time, you can provide cookies as a semicolon-separated
57
+ string (e.g., "cookie1=value1; cookie2=value2"). This is useful when copying cookies from browser
58
+ developer tools.
59
+ base_url:
60
+ The base URL to use to contact the Substack API.
61
+ Defaults to https://substack.com/api/v1.
62
+ """
63
+ self.base_url = base_url or "https://substack.com/api/v1"
64
+
65
+ if debug:
66
+ logging.basicConfig()
67
+ logging.getLogger().setLevel(logging.DEBUG)
68
+
69
+ self.timeout = timeout
70
+
71
+ self._session = requests.Session()
72
+ original_request = self._session.request
73
+
74
+ def _request_with_timeout(*args, **kwargs):
75
+ if kwargs.get("timeout") is None and self.timeout is not None:
76
+ kwargs["timeout"] = self.timeout
77
+ return original_request(*args, **kwargs)
78
+
79
+ self._session.request = _request_with_timeout
80
+
81
+ retry = Retry(
82
+ total=4,
83
+ status=4,
84
+ backoff_factor=1,
85
+ status_forcelist=(429,),
86
+ allowed_methods=frozenset({"GET", "DELETE"}),
87
+ respect_retry_after_header=True,
88
+ raise_on_status=False,
89
+ )
90
+ adapter = HTTPAdapter(max_retries=retry)
91
+ self._session.mount("http://", adapter)
92
+ self._session.mount("https://", adapter)
93
+
94
+ # Load cookies from file if provided
95
+ # Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
96
+ if cookies_path is not None:
97
+ with open(cookies_path) as f:
98
+ cookies = json.load(f)
99
+ self._session.cookies.update(cookies)
100
+
101
+ elif cookies_string is not None:
102
+ cookies = self._parse_cookies_string(cookies_string)
103
+ self._session.cookies.update(cookies)
104
+
105
+ elif email is not None and password is not None:
106
+ self.login(email, password)
107
+ else:
108
+ raise ValueError(
109
+ "Must provide email and password, cookies_path, or cookies_string to authenticate."
110
+ )
111
+
112
+ user_publication = None
113
+ # if the user provided a publication url, then use that
114
+ if publication_url:
115
+ from urllib.parse import urlparse
116
+
117
+ # Normalize requested URL
118
+ parsed_req = urlparse(publication_url.lower())
119
+ req_hostname = parsed_req.hostname or parsed_req.path
120
+ # Strip trailing slashes and www.
121
+ req_hostname = req_hostname.strip("/").replace("www.", "", 1)
122
+
123
+ user_publications = self.get_user_publications()
124
+ for publication in user_publications:
125
+ pub_url = Api.get_publication_url(publication).lower()
126
+ parsed_pub = urlparse(pub_url)
127
+ pub_hostname = parsed_pub.hostname or parsed_pub.path
128
+ pub_hostname = pub_hostname.strip("/").replace("www.", "", 1)
129
+
130
+ if req_hostname == pub_hostname:
131
+ user_publication = publication
132
+ break
133
+
134
+ if user_publication is None:
135
+ raise SubstackRequestException(
136
+ f"Requested publication is unavailable: {publication_url}"
137
+ )
138
+ else:
139
+ # get the users primary publication
140
+ user_publication = self.get_user_primary_publication()
141
+ if user_publication is None:
142
+ raise SubstackRequestException(
143
+ "Could not find primary publication in profile"
144
+ )
145
+
146
+ # set the current publication to the users primary publication
147
+ self.change_publication(user_publication)
148
+
149
+ @staticmethod
150
+ def _parse_cookies_string(cookies_string: str) -> dict:
151
+ """
152
+ Parse a semicolon-separated cookie string into a dictionary.
153
+
154
+ Args:
155
+ cookies_string: A semicolon-separated string of cookies (e.g., "cookie1=value1; cookie2=value2")
156
+
157
+ Returns:
158
+ A dictionary of cookie name-value pairs
159
+ """
160
+ cookies = {}
161
+ for cookie_pair in cookies_string.split(";"):
162
+ cookie_pair = cookie_pair.strip()
163
+ if not cookie_pair:
164
+ continue
165
+ if "=" in cookie_pair:
166
+ key, value = cookie_pair.split("=", 1)
167
+ key = key.strip()
168
+ value = value.strip()
169
+ # URL decode the value (e.g., s%3A becomes s:)
170
+ value = unquote(value)
171
+ cookies[key] = value
172
+ return cookies
173
+
174
+ @staticmethod
175
+ def _normalize_tags(tags):
176
+ if tags is None:
177
+ return []
178
+ if isinstance(tags, str):
179
+ return [tags]
180
+ return [str(tag) for tag in tags]
181
+
182
+ def login(self, email, password) -> dict:
183
+ """
184
+
185
+ Login to the substack account.
186
+
187
+ Args:
188
+ email: substack account email
189
+ password: substack account password
190
+ """
191
+
192
+ response = self._session.post(
193
+ f"{self.base_url}/login",
194
+ json={
195
+ "captcha_response": None,
196
+ "email": email,
197
+ "for_pub": "",
198
+ "password": password,
199
+ "redirect": "/",
200
+ },
201
+ )
202
+
203
+ return Api._handle_response(response=response)
204
+
205
+ def signin_for_pub(self, publication):
206
+ """
207
+ Complete the signin process
208
+ """
209
+ response = self._session.get(
210
+ f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}",
211
+ )
212
+ try:
213
+ output = Api._handle_response(response=response)
214
+ except SubstackRequestException as ex:
215
+ output = {}
216
+ return output
217
+
218
+ def change_publication(self, publication):
219
+ """
220
+ Change the publication URL
221
+ """
222
+ self.publication_url = urljoin(publication["publication_url"], "api/v1")
223
+
224
+ # sign-in to the publication
225
+ self.signin_for_pub(publication)
226
+
227
+ def export_cookies(self, path: str = "cookies.json"):
228
+ """
229
+ Export cookies to a json file.
230
+ Args:
231
+ path: path to the json file
232
+ """
233
+ cookies = self._session.cookies.get_dict()
234
+ with open(path, "w") as f:
235
+ json.dump(cookies, f)
236
+
237
+ @staticmethod
238
+ def _handle_response(response: requests.Response):
239
+ """
240
+
241
+ Internal helper for handling API responses from the Substack server.
242
+ Raises the appropriate exceptions when necessary; otherwise, returns the
243
+ response.
244
+
245
+ """
246
+
247
+ if not (200 <= response.status_code < 300):
248
+ raise SubstackAPIException(response.status_code, response.text)
249
+ try:
250
+ return response.json()
251
+ except ValueError:
252
+ raise SubstackRequestException("Invalid Response: %s" % response.text)
253
+
254
+ def get_user_id(self):
255
+ """
256
+
257
+ Returns:
258
+
259
+ """
260
+ profile = self.get_user_profile()
261
+ user_id = profile["id"]
262
+
263
+ return user_id
264
+
265
+ @staticmethod
266
+ def get_publication_url(publication: dict) -> str:
267
+ """
268
+ Gets the publication url
269
+
270
+ Args:
271
+ publication:
272
+ """
273
+ custom_domain = publication.get("custom_domain", None)
274
+ if not custom_domain and not publication.get("custom_domain_optional", None):
275
+ publication_url = f"https://{publication['subdomain']}.substack.com"
276
+ else:
277
+ publication_url = f"https://{custom_domain}"
278
+
279
+ return publication_url
280
+
281
+ def get_user_primary_publication(self):
282
+ """
283
+ Gets the users primary publication
284
+ """
285
+
286
+ profile = self.get_user_profile()
287
+ primary_publication = None
288
+
289
+ # Try old API format first (backward compatibility)
290
+ if (
291
+ "primaryPublication" in profile
292
+ and profile["primaryPublication"] is not None
293
+ ):
294
+ primary_publication = profile["primaryPublication"]
295
+ else:
296
+ # New API format: look for primary publication in publicationUsers
297
+ publication_users = profile.get("publicationUsers")
298
+ if publication_users is not None and len(publication_users) > 0:
299
+ # Find the publication where is_primary is True
300
+ for pub_user in publication_users:
301
+ if pub_user.get("is_primary", False):
302
+ primary_publication = pub_user.get("publication")
303
+ if primary_publication:
304
+ break
305
+
306
+ # If no primary found, use the first publication
307
+ if primary_publication is None:
308
+ primary_publication = publication_users[0].get("publication")
309
+
310
+ if primary_publication is None:
311
+ raise SubstackRequestException(
312
+ "Could not find primary publication in profile"
313
+ )
314
+
315
+ primary_publication["publication_url"] = self.get_publication_url(
316
+ primary_publication
317
+ )
318
+
319
+ return primary_publication
320
+
321
+ def get_user_publications(self):
322
+ """
323
+ Gets the users publications
324
+ """
325
+
326
+ profile = self.get_user_profile()
327
+
328
+ # Loop through users "publicationUsers" list, and return a list
329
+ # of dictionaries of "name", and "subdomain", and "id"
330
+ user_publications = []
331
+ publication_users = profile.get("publicationUsers")
332
+
333
+ if publication_users is None:
334
+ # If publicationUsers is None, return empty list or try to construct from other fields
335
+ # This maintains backward compatibility while handling new API format
336
+ return user_publications
337
+
338
+ for publication in publication_users:
339
+ pub = publication.get("publication")
340
+ if pub is not None:
341
+ pub["publication_url"] = self.get_publication_url(pub)
342
+ user_publications.append(pub)
343
+
344
+ return user_publications
345
+
346
+ def get_user_profile(self):
347
+ """
348
+ Gets the users profile
349
+ """
350
+ response = self._session.get(f"{self.base_url}/user/profile/self")
351
+
352
+ return Api._handle_response(response=response)
353
+
354
+ def get_user_settings(self):
355
+ """
356
+ Get list of users.
357
+
358
+ Returns:
359
+
360
+ """
361
+ response = self._session.get(f"{self.base_url}/settings")
362
+
363
+ return Api._handle_response(response=response)
364
+
365
+ def get_publication_users(self):
366
+ """
367
+ Get list of users.
368
+
369
+ Returns:
370
+
371
+ """
372
+ response = self._session.get(f"{self.publication_url}/publication/users")
373
+
374
+ return Api._handle_response(response=response)
375
+
376
+ def get_publication_subscriber_count(self):
377
+ """
378
+ Get subscriber count.
379
+
380
+ Returns:
381
+
382
+ """
383
+ response = self._session.get(
384
+ f"{self.publication_url}/publication_launch_checklist"
385
+ )
386
+
387
+ data = Api._handle_response(response=response)
388
+ if "subscriberCount" in data:
389
+ return data["subscriberCount"]
390
+ return len(data["subscribers"])
391
+
392
+ def get_published_posts(
393
+ self, offset=0, limit=25, order_by="post_date", order_direction="desc"
394
+ ):
395
+ """
396
+ Get list of published posts for the publication.
397
+ """
398
+ response = self._session.get(
399
+ f"{self.publication_url}/post_management/published",
400
+ params={
401
+ "offset": offset,
402
+ "limit": limit,
403
+ "order_by": order_by,
404
+ "order_direction": order_direction,
405
+ },
406
+ )
407
+
408
+ return Api._handle_response(response=response)
409
+
410
+ def get_posts(self) -> dict:
411
+ """
412
+
413
+ Returns:
414
+
415
+ """
416
+ response = self._session.get(f"{self.base_url}/reader/posts")
417
+
418
+ return Api._handle_response(response=response)
419
+
420
+ def get_drafts(self, filter=None, offset=None, limit=None):
421
+ """
422
+
423
+ Args:
424
+ filter:
425
+ offset:
426
+ limit:
427
+
428
+ Returns:
429
+
430
+ """
431
+ response = self._session.get(
432
+ f"{self.publication_url}/drafts",
433
+ params={"filter": filter, "offset": offset, "limit": limit},
434
+ )
435
+ return Api._handle_response(response=response)
436
+
437
+ def get_draft(self, draft_id):
438
+ """
439
+ Gets a draft given it's id.
440
+
441
+ """
442
+ response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
443
+ return Api._handle_response(response=response)
444
+
445
+ def delete_draft(self, draft_id):
446
+ """
447
+
448
+ Args:
449
+ draft_id:
450
+
451
+ Returns:
452
+
453
+ """
454
+ response = self._session.delete(f"{self.publication_url}/drafts/{draft_id}")
455
+ return Api._handle_response(response=response)
456
+
457
+ def post_draft(self, body) -> dict:
458
+ """
459
+
460
+ Args:
461
+ body:
462
+
463
+ Returns:
464
+
465
+ """
466
+ response = self._session.post(f"{self.publication_url}/drafts", json=body)
467
+ return Api._handle_response(response=response)
468
+
469
+ def create_draft_from_markdown(
470
+ self,
471
+ title: str,
472
+ markdown: str,
473
+ subtitle: str = "",
474
+ audience: str = "everyone",
475
+ write_comment_permissions: str = "everyone",
476
+ search_engine_title: str = None,
477
+ search_engine_description: str = None,
478
+ slug: str = None,
479
+ draft_section_id: int = None,
480
+ tags=None,
481
+ prepublish: bool = False,
482
+ publish: bool = False,
483
+ send: bool = True,
484
+ share_automatically: bool = False,
485
+ ) -> dict:
486
+ from substack.post import Post
487
+
488
+ post = Post(
489
+ title=title,
490
+ subtitle=subtitle or "",
491
+ user_id=self.get_user_id(),
492
+ audience=audience,
493
+ write_comment_permissions=write_comment_permissions,
494
+ )
495
+ post.from_markdown(markdown, api=self)
496
+
497
+ draft = self.post_draft(post.get_draft())
498
+ draft_id = draft.get("id")
499
+
500
+ update_payload = {
501
+ "search_engine_title": search_engine_title,
502
+ "search_engine_description": search_engine_description,
503
+ "slug": slug,
504
+ "draft_section_id": draft_section_id,
505
+ }
506
+ update_payload = {
507
+ key: value for key, value in update_payload.items() if value is not None
508
+ }
509
+ if update_payload:
510
+ draft = self.put_draft(draft_id, **update_payload)
511
+
512
+ tags_result = None
513
+ tags_list = self._normalize_tags(tags)
514
+ if tags_list:
515
+ tags_result = self.add_tags_to_post(draft_id, tags_list)
516
+
517
+ prepublish_result = None
518
+ if prepublish:
519
+ prepublish_result = self.prepublish_draft(draft_id)
520
+
521
+ publish_result = None
522
+ if publish:
523
+ publish_result = self.publish_draft(
524
+ draft_id,
525
+ send=send,
526
+ share_automatically=share_automatically,
527
+ )
528
+
529
+ return {
530
+ "draft": draft,
531
+ "tags": tags_result,
532
+ "prepublish": prepublish_result,
533
+ "publish": publish_result,
534
+ }
535
+
536
+ def put_draft(self, draft, **kwargs) -> dict:
537
+ """
538
+
539
+ Args:
540
+ draft:
541
+ **kwargs:
542
+
543
+ Returns:
544
+
545
+ """
546
+ response = self._session.put(
547
+ f"{self.publication_url}/drafts/{draft}",
548
+ json=kwargs,
549
+ )
550
+ return Api._handle_response(response=response)
551
+
552
+ def prepublish_draft(self, draft) -> dict:
553
+ """
554
+
555
+ Args:
556
+ draft: draft id
557
+
558
+ Returns:
559
+
560
+ """
561
+
562
+ response = self._session.get(
563
+ f"{self.publication_url}/drafts/{draft}/prepublish"
564
+ )
565
+ return Api._handle_response(response=response)
566
+
567
+ def publish_draft(
568
+ self, draft, send: bool = True, share_automatically: bool = False
569
+ ) -> dict:
570
+ """
571
+
572
+ Args:
573
+ draft: draft id
574
+ send:
575
+ share_automatically:
576
+
577
+ Returns:
578
+
579
+ """
580
+ response = self._session.post(
581
+ f"{self.publication_url}/drafts/{draft}/publish",
582
+ json={"send": send, "share_automatically": share_automatically},
583
+ )
584
+ return Api._handle_response(response=response)
585
+
586
+ def schedule_draft(self, draft, draft_datetime: datetime) -> dict:
587
+ """
588
+
589
+ Args:
590
+ draft: draft id
591
+ draft_datetime: datetime to schedule the draft
592
+
593
+ Returns:
594
+
595
+ """
596
+ response = self._session.post(
597
+ f"{self.publication_url}/drafts/{draft}/scheduled_release",
598
+ json={"trigger_at": draft_datetime.isoformat()},
599
+ )
600
+ return Api._handle_response(response=response)
601
+
602
+ def unschedule_draft(self, draft) -> dict:
603
+ """
604
+
605
+ Args:
606
+ draft: draft id
607
+
608
+ Returns:
609
+
610
+ """
611
+ response = self._session.delete(
612
+ f"{self.publication_url}/drafts/{draft}/scheduled_release"
613
+ )
614
+ return Api._handle_response(response=response)
615
+
616
+ def get_image(self, image: str):
617
+ """
618
+
619
+ This method generates a new substack link that contains the image.
620
+
621
+ Args:
622
+ image: filepath or original url of image.
623
+
624
+ Returns:
625
+
626
+ """
627
+ if os.path.exists(image):
628
+ import mimetypes
629
+
630
+ mime_type, _ = mimetypes.guess_type(image)
631
+ if mime_type not in ["image/jpeg", "image/png", "image/gif", "image/webp"]:
632
+ ext = os.path.splitext(image)[1].lower()
633
+ if ext in [".jpg", ".jpeg"]:
634
+ mime_type = "image/jpeg"
635
+ elif ext == ".png":
636
+ mime_type = "image/png"
637
+ elif ext == ".gif":
638
+ mime_type = "image/gif"
639
+ elif ext == ".webp":
640
+ mime_type = "image/webp"
641
+ else:
642
+ mime_type = "application/octet-stream"
643
+
644
+ with open(image, "rb") as file:
645
+ image_bytes = base64.b64encode(file.read())
646
+ image = f"data:{mime_type};base64,".encode("ascii") + image_bytes
647
+
648
+ response = self._session.post(
649
+ f"{self.publication_url}/image",
650
+ data={"image": image},
651
+ )
652
+ return Api._handle_response(response=response)
653
+
654
+ def add_tags_to_post(self, post_id: int, tag_names: list) -> dict:
655
+ """
656
+ Add multiple tags to a post.
657
+
658
+ Args:
659
+ post_id: The ID of the post to tag.
660
+ tag_names: A list of tag names to add.
661
+
662
+ Returns:
663
+ A dictionary with the results of applying all tags.
664
+ """
665
+ results = []
666
+ for tag_name in tag_names:
667
+ result = self.add_tag_to_post(post_id, tag_name)
668
+ results.append(result)
669
+ return {"tags_added": results}
670
+
671
+ def get_publication_post_tags(self) -> list:
672
+ """
673
+ Retrieve all post tags for the current publication.
674
+
675
+ Returns:
676
+ List of tag dicts as returned by Substack API.
677
+ """
678
+ response = self._session.get(f"{self.publication_url}/publication/post-tag")
679
+ return Api._handle_response(response=response)
680
+
681
+ def add_tag_to_post(self, post_id: int, tag_name: str) -> dict:
682
+ """
683
+ Add a tag to a post by first checking published tags and creating only if needed.
684
+
685
+ Args:
686
+ post_id: The ID of the post to tag.
687
+ tag_name: The name of the tag to add.
688
+
689
+ Returns:
690
+ The response from applying the tag to the post.
691
+ """
692
+ # Fetch existing publication tags first (avoid re-creating an already existing tag)
693
+ existing_tags = self.get_publication_post_tags() or []
694
+ existing_tag = next(
695
+ (tag for tag in existing_tags if tag.get("name") == tag_name),
696
+ None,
697
+ )
698
+
699
+ if existing_tag is not None:
700
+ tag_id = existing_tag["id"]
701
+ else:
702
+ create_tag_response = self._session.post(
703
+ f"{self.publication_url}/publication/post-tag",
704
+ json={"name": tag_name},
705
+ )
706
+ tag_data = Api._handle_response(create_tag_response)
707
+ tag_id = tag_data["id"]
708
+
709
+ apply_tag_response = self._session.post(
710
+ f"{self.publication_url}/post/{post_id}/tag/{tag_id}",
711
+ )
712
+ return Api._handle_response(apply_tag_response)
713
+
714
+ def get_categories(self):
715
+ """
716
+
717
+ Retrieve list of all available categories.
718
+
719
+ Returns:
720
+
721
+ """
722
+ response = self._session.get(f"{self.base_url}/categories")
723
+ return Api._handle_response(response=response)
724
+
725
+ def get_category(self, category_id, category_type, page):
726
+ """
727
+
728
+ Args:
729
+ category_id:
730
+ category_type:
731
+ page:
732
+
733
+ Returns:
734
+
735
+ """
736
+ response = self._session.get(
737
+ f"{self.base_url}/category/public/{category_id}/{category_type}",
738
+ params={"page": page},
739
+ )
740
+ return Api._handle_response(response=response)
741
+
742
+ def get_single_category(self, category_id, category_type, page=None, limit=None):
743
+ """
744
+
745
+ Args:
746
+ category_id:
747
+ category_type: paid or all
748
+ page: by default substack retrieves only the first 25 publications in the category. If this is left None,
749
+ then all pages will be retrieved. The page size is 25 publications.
750
+ limit:
751
+ Returns:
752
+
753
+ """
754
+ if page is not None:
755
+ output = self.get_category(category_id, category_type, page)
756
+ else:
757
+ publications = []
758
+ page = 0
759
+ while True:
760
+ page_output = self.get_category(category_id, category_type, page)
761
+ publications.extend(page_output.get("publications", []))
762
+ if (
763
+ limit is not None and limit <= len(publications)
764
+ ) or not page_output.get("more", False):
765
+ publications = publications[:limit]
766
+ break
767
+ page += 1
768
+ output = {
769
+ "publications": publications,
770
+ "more": page_output.get("more", False),
771
+ }
772
+ return output
773
+
774
+ def delete_all_drafts(self):
775
+ """
776
+
777
+ Returns:
778
+
779
+ """
780
+ response = None
781
+ while True:
782
+ drafts = self.get_drafts(filter="draft", limit=10, offset=0)
783
+ if len(drafts) == 0:
784
+ break
785
+ for draft in drafts:
786
+ response = self.delete_draft(draft.get("id"))
787
+ return response
788
+
789
+ def get_sections(self):
790
+ """
791
+ Get a list of the sections of your publication.
792
+
793
+ TODO: this is hacky but I cannot find another place where to get the sections.
794
+ Returns:
795
+
796
+ """
797
+ response = self._session.get(
798
+ f"{self.publication_url}/subscriptions",
799
+ )
800
+ content = Api._handle_response(response=response)
801
+ sections = [
802
+ p.get("sections")
803
+ for p in content.get("publications")
804
+ if p.get("hostname") in self.publication_url
805
+ ]
806
+ return sections[0]
807
+
808
+ def publication_embed(self, url):
809
+ """
810
+
811
+ Args:
812
+ url:
813
+
814
+ Returns:
815
+
816
+ """
817
+ return self.call("/publication/embed", "GET", url=url)
818
+
819
+ def call(self, endpoint, method, **params):
820
+ """
821
+
822
+ Args:
823
+ endpoint:
824
+ method:
825
+ **params:
826
+
827
+ Returns:
828
+
829
+ """
830
+ response = self._session.request(
831
+ method=method,
832
+ url=f"{self.publication_url}/{endpoint}",
833
+ params=params,
834
+ )
835
+ return Api._handle_response(response=response)