python-substack 0.1.13__tar.gz → 0.1.15__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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-substack
3
- Version: 0.1.13
3
+ Version: 0.1.15
4
4
  Summary: A Python wrapper around the Substack API.
5
5
  Home-page: https://github.com/ma2za/python-substack
6
6
  License: MIT
@@ -44,15 +44,19 @@ You can install python-substack using:
44
44
 
45
45
  Set the following environment variables by creating a **.env** file:
46
46
 
47
- PUBLICATION_URL=https://ma2za.substack.com
48
47
  EMAIL=
49
48
  PASSWORD=
50
- USER_ID=
51
49
 
52
- To discover the USER_ID go to your public profile page,
53
- in the URL bar of the browser you find the substack address
54
- followed by your USER_ID and your username:
55
- https://substack.com/profile/[USER_ID]-[username]
50
+ ## If you don't have a password
51
+
52
+ Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses
53
+ your email address with a "magic" link.
54
+
55
+ Set a password:
56
+
57
+ - Sign-out of Substack
58
+ - At the sign-in page click, "Sign in with password" under the `Email` text box
59
+ - Then choose, "Set a new password"
56
60
 
57
61
  The .env file will be ignored by git but always be careful.
58
62
 
@@ -71,13 +75,24 @@ from substack.post import Post
71
75
  api = Api(
72
76
  email=os.getenv("EMAIL"),
73
77
  password=os.getenv("PASSWORD"),
74
- publication_url=os.getenv("PUBLICATION_URL"),
75
78
  )
76
79
 
80
+ user_id = api.get_user_id()
81
+
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.
83
+
84
+ # primary publication
85
+ user_publication = api.get_user_primary_publication()
86
+ # all publications
87
+ user_publications = api.get_user_publications()
88
+
89
+ # This step is only necessary if you are not using your primary publication
90
+ # api.change_publication(user_publication)
91
+
77
92
  post = Post(
78
93
  title="How to publish a Substack post using the Python API",
79
94
  subtitle="This post was published using the Python API",
80
- user_id=os.getenv("USER_ID")
95
+ user_id=user_id
81
96
  )
82
97
 
83
98
  post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
@@ -91,6 +106,9 @@ post.add({'type': "paragraph",
91
106
  post.add({'type': 'paragraph', 'content': [
92
107
  {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
93
108
 
109
+ # set paywall boundary
110
+ post.add({'type': 'paywall'})
111
+
94
112
  # add image
95
113
  post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
96
114
 
@@ -98,6 +116,10 @@ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsA
98
116
  image = api.get_image('image.png')
99
117
  post.add({"type": "captionedImage", "src": image.get("url")})
100
118
 
119
+ # embed publication
120
+ embedded = api.publication_embed("https://jackio.substack.com/")
121
+ post.add({"type": "embeddedPublication", "url": embedded})
122
+
101
123
  draft = api.post_draft(post.get_draft())
102
124
 
103
125
  # set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
@@ -109,4 +131,17 @@ api.prepublish_draft(draft.get("id"))
109
131
  api.publish_draft(draft.get("id"))
110
132
  ```
111
133
 
134
+ # Contributing
135
+
136
+ Install pre-commit:
137
+
138
+ ```shell
139
+ pip install pre-commit
140
+ ```
141
+
142
+ Set up pre-commit
143
+
144
+ ```shell
145
+ pre-commit install
146
+ ```
112
147
 
@@ -20,15 +20,19 @@ You can install python-substack using:
20
20
 
21
21
  Set the following environment variables by creating a **.env** file:
22
22
 
23
- PUBLICATION_URL=https://ma2za.substack.com
24
23
  EMAIL=
25
24
  PASSWORD=
26
- USER_ID=
27
25
 
28
- To discover the USER_ID go to your public profile page,
29
- in the URL bar of the browser you find the substack address
30
- followed by your USER_ID and your username:
31
- https://substack.com/profile/[USER_ID]-[username]
26
+ ## If you don't have a password
27
+
28
+ Recently Substack has been setting up new accounts without a password. If you sign-out and sign back in it just uses
29
+ your email address with a "magic" link.
30
+
31
+ Set a password:
32
+
33
+ - Sign-out of Substack
34
+ - At the sign-in page click, "Sign in with password" under the `Email` text box
35
+ - Then choose, "Set a new password"
32
36
 
33
37
  The .env file will be ignored by git but always be careful.
34
38
 
@@ -47,13 +51,24 @@ from substack.post import Post
47
51
  api = Api(
48
52
  email=os.getenv("EMAIL"),
49
53
  password=os.getenv("PASSWORD"),
50
- publication_url=os.getenv("PUBLICATION_URL"),
51
54
  )
52
55
 
56
+ user_id = api.get_user_id()
57
+
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.
59
+
60
+ # primary publication
61
+ user_publication = api.get_user_primary_publication()
62
+ # all publications
63
+ user_publications = api.get_user_publications()
64
+
65
+ # This step is only necessary if you are not using your primary publication
66
+ # api.change_publication(user_publication)
67
+
53
68
  post = Post(
54
69
  title="How to publish a Substack post using the Python API",
55
70
  subtitle="This post was published using the Python API",
56
- user_id=os.getenv("USER_ID")
71
+ user_id=user_id
57
72
  )
58
73
 
59
74
  post.add({'type': 'paragraph', 'content': 'This is how you add a new paragraph to your post!'})
@@ -67,6 +82,9 @@ post.add({'type': "paragraph",
67
82
  post.add({'type': 'paragraph', 'content': [
68
83
  {'content': "View Link", 'marks': [{'type': "link", 'href': 'https://whoraised.substack.com/'}]}]})
69
84
 
85
+ # set paywall boundary
86
+ post.add({'type': 'paywall'})
87
+
70
88
  # add image
71
89
  post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsAAAAC/i-am-batman.gif"})
72
90
 
@@ -74,6 +92,10 @@ post.add({'type': 'captionedImage', 'src': "https://media.tenor.com/7B4jMa-a7bsA
74
92
  image = api.get_image('image.png')
75
93
  post.add({"type": "captionedImage", "src": image.get("url")})
76
94
 
95
+ # embed publication
96
+ embedded = api.publication_embed("https://jackio.substack.com/")
97
+ post.add({"type": "embeddedPublication", "url": embedded})
98
+
77
99
  draft = api.post_draft(post.get_draft())
78
100
 
79
101
  # set section (THIS CAN BE DONE ONLY AFTER HAVING FIRST POSTED THE DRAFT)
@@ -85,3 +107,16 @@ api.prepublish_draft(draft.get("id"))
85
107
  api.publish_draft(draft.get("id"))
86
108
  ```
87
109
 
110
+ # Contributing
111
+
112
+ Install pre-commit:
113
+
114
+ ```shell
115
+ pip install pre-commit
116
+ ```
117
+
118
+ Set up pre-commit
119
+
120
+ ```shell
121
+ pre-commit install
122
+ ```
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.13"
3
+ version = "0.1.15"
4
4
  description = "A Python wrapper around the Substack API."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -28,4 +28,4 @@ PyYAML = "^6.0"
28
28
 
29
29
  [build-system]
30
30
  requires = ["poetry-core>=1.0.0"]
31
- build-backend = "poetry.core.masonry.api"
31
+ build-backend = "poetry.core.masonry.api"
@@ -1,4 +1,11 @@
1
+ """
2
+
3
+ API Wrapper
4
+
5
+ """
6
+
1
7
  import base64
8
+ import json
2
9
  import logging
3
10
  import os
4
11
  from datetime import datetime
@@ -21,12 +28,13 @@ class Api:
21
28
  """
22
29
 
23
30
  def __init__(
24
- self,
25
- email=None,
26
- password=None,
27
- base_url=None,
28
- publication_url=None,
29
- debug=False,
31
+ self,
32
+ email=None,
33
+ password=None,
34
+ cookies_path=None,
35
+ base_url=None,
36
+ publication_url=None,
37
+ debug=False,
30
38
  ):
31
39
  """
32
40
 
@@ -37,12 +45,15 @@ class Api:
37
45
  Args:
38
46
  email:
39
47
  password:
48
+ cookies_path
49
+ To re-use your session without logging in each time, you can save your cookies to a json file and
50
+ then load them in the next session.
51
+ Make sure to re-save your cookies, as they do update over time.
40
52
  base_url:
41
53
  The base URL to use to contact the Substack API.
42
54
  Defaults to https://substack.com/api/v1.
43
55
  """
44
56
  self.base_url = base_url or "https://substack.com/api/v1"
45
- self.publication_url = urljoin(publication_url, "api/v1")
46
57
 
47
58
  if debug:
48
59
  logging.basicConfig()
@@ -50,8 +61,42 @@ class Api:
50
61
 
51
62
  self._session = requests.Session()
52
63
 
53
- if email is not None and password is not None:
64
+ # Load cookies from file if provided
65
+ # Helps with Captcha errors by reusing cookies from "local" auth, then switching to running code in the cloud
66
+ if cookies_path is not None:
67
+ with open(cookies_path) as f:
68
+ cookies = json.load(f)
69
+ self._session.cookies.update(cookies)
70
+
71
+ elif email is not None and password is not None:
54
72
  self.login(email, password)
73
+ else:
74
+ raise ValueError(
75
+ "Must provide email and password or cookies_path to authenticate."
76
+ )
77
+
78
+ user_publication = None
79
+ # if the user provided a publication url, then use that
80
+ if publication_url:
81
+ import re
82
+
83
+ # Regular expression to extract subdomain name
84
+ match = re.search(r"https://(.*).substack.com", publication_url.lower())
85
+ subdomain = match.group(1) if match else None
86
+
87
+ user_publications = self.get_user_publications()
88
+ # search through publications to find the publication with the matching subdomain
89
+ for publication in user_publications:
90
+ if publication["subdomain"] == subdomain:
91
+ # set the current publication to the users publication
92
+ user_publication = publication
93
+ break
94
+ else:
95
+ # get the users primary publication
96
+ user_publication = self.get_user_primary_publication()
97
+
98
+ # set the current publication to the users primary publication
99
+ self.change_publication(user_publication)
55
100
 
56
101
  def login(self, email, password) -> dict:
57
102
  """
@@ -73,8 +118,41 @@ class Api:
73
118
  "redirect": "/",
74
119
  },
75
120
  )
121
+
76
122
  return Api._handle_response(response=response)
77
123
 
124
+ def signin_for_pub(self, publication):
125
+ """
126
+ Complete the signin process
127
+ """
128
+ response = self._session.get(
129
+ f"https://substack.com/sign-in?redirect=%2F&for_pub={publication['subdomain']}",
130
+ )
131
+ try:
132
+ output = Api._handle_response(response=response)
133
+ except SubstackRequestException as ex:
134
+ output = {}
135
+ return output
136
+
137
+ def change_publication(self, publication):
138
+ """
139
+ Change the publication URL
140
+ """
141
+ self.publication_url = urljoin(publication["publication_url"], "api/v1")
142
+
143
+ # sign-in to the publication
144
+ self.signin_for_pub(publication)
145
+
146
+ def export_cookies(self, path: str = "cookies.json"):
147
+ """
148
+ Export cookies to a json file.
149
+ Args:
150
+ path: path to the json file
151
+ """
152
+ cookies = self._session.cookies.get_dict()
153
+ with open(path, "w") as f:
154
+ json.dump(cookies, f)
155
+
78
156
  @staticmethod
79
157
  def _handle_response(response: requests.Response):
80
158
  """
@@ -92,6 +170,82 @@ class Api:
92
170
  except ValueError:
93
171
  raise SubstackRequestException("Invalid Response: %s" % response.text)
94
172
 
173
+ def get_user_id(self):
174
+ """
175
+
176
+ Returns:
177
+
178
+ """
179
+ profile = self.get_user_profile()
180
+ user_id = profile["id"]
181
+
182
+ return user_id
183
+
184
+ @staticmethod
185
+ def get_publication_url(publication: dict) -> str:
186
+ """
187
+ Gets the publication url
188
+
189
+ Args:
190
+ publication:
191
+ """
192
+ custom_domain = publication["custom_domain"]
193
+ if not custom_domain:
194
+ publication_url = f"https://{publication['subdomain']}.substack.com"
195
+ else:
196
+ publication_url = f"https://{custom_domain}"
197
+
198
+ return publication_url
199
+
200
+ def get_user_primary_publication(self):
201
+ """
202
+ Gets the users primary publication
203
+ """
204
+
205
+ profile = self.get_user_profile()
206
+ primary_publication = profile["primaryPublication"]
207
+ primary_publication["publication_url"] = self.get_publication_url(
208
+ primary_publication
209
+ )
210
+
211
+ return primary_publication
212
+
213
+ def get_user_publications(self):
214
+ """
215
+ Gets the users publications
216
+ """
217
+
218
+ profile = self.get_user_profile()
219
+
220
+ # Loop through users "publicationUsers" list, and return a list
221
+ # of dictionaries of "name", and "subdomain", and "id"
222
+ 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)
227
+
228
+ return user_publications
229
+
230
+ def get_user_profile(self):
231
+ """
232
+ Gets the users profile
233
+ """
234
+ response = self._session.get(f"{self.base_url}/user/profile/self")
235
+
236
+ return Api._handle_response(response=response)
237
+
238
+ def get_user_settings(self):
239
+ """
240
+ Get list of users.
241
+
242
+ Returns:
243
+
244
+ """
245
+ response = self._session.get(f"{self.base_url}/settings")
246
+
247
+ return Api._handle_response(response=response)
248
+
95
249
  def get_publication_users(self):
96
250
  """
97
251
  Get list of users.
@@ -111,9 +265,29 @@ class Api:
111
265
  Returns:
112
266
 
113
267
  """
114
- response = self._session.get(f"{self.publication_url}/publication_launch_checklist")
268
+ response = self._session.get(
269
+ f"{self.publication_url}/publication_launch_checklist"
270
+ )
271
+
272
+ return Api._handle_response(response=response)["subscriberCount"]
273
+
274
+ def get_published_posts(
275
+ self, offset=0, limit=25, order_by="post_date", order_direction="desc"
276
+ ):
277
+ """
278
+ Get list of published posts for the publication.
279
+ """
280
+ response = self._session.get(
281
+ f"{self.publication_url}/post_management/published",
282
+ params={
283
+ "offset": offset,
284
+ "limit": limit,
285
+ "order_by": order_by,
286
+ "order_direction": order_direction,
287
+ },
288
+ )
115
289
 
116
- return Api._handle_response(response=response)['subscriberCount']
290
+ return Api._handle_response(response=response)
117
291
 
118
292
  def get_posts(self) -> dict:
119
293
  """
@@ -142,6 +316,14 @@ class Api:
142
316
  )
143
317
  return Api._handle_response(response=response)
144
318
 
319
+ def get_draft(self, draft_id):
320
+ """
321
+ Gets a draft given it's id.
322
+
323
+ """
324
+ response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
325
+ return Api._handle_response(response=response)
326
+
145
327
  def delete_draft(self, draft_id):
146
328
  """
147
329
 
@@ -166,11 +348,7 @@ class Api:
166
348
  response = self._session.post(f"{self.publication_url}/drafts", json=body)
167
349
  return Api._handle_response(response=response)
168
350
 
169
- def put_draft(
170
- self,
171
- draft,
172
- **kwargs
173
- ) -> dict:
351
+ def put_draft(self, draft, **kwargs) -> dict:
174
352
  """
175
353
 
176
354
  Args:
@@ -202,7 +380,7 @@ class Api:
202
380
  return Api._handle_response(response=response)
203
381
 
204
382
  def publish_draft(
205
- self, draft, send: bool = True, share_automatically: bool = False
383
+ self, draft, send: bool = True, share_automatically: bool = False
206
384
  ) -> dict:
207
385
  """
208
386
 
@@ -320,7 +498,7 @@ class Api:
320
498
  page_output = self.get_category(category_id, category_type, page)
321
499
  publications.extend(page_output.get("publications", []))
322
500
  if (
323
- limit is not None and limit <= len(publications)
501
+ limit is not None and limit <= len(publications)
324
502
  ) or not page_output.get("more", False):
325
503
  publications = publications[:limit]
326
504
  break
@@ -358,5 +536,38 @@ class Api:
358
536
  f"{self.publication_url}/subscriptions",
359
537
  )
360
538
  content = Api._handle_response(response=response)
361
- sections = [p.get("sections") for p in content.get("publications") if p.get("hostname") in self.publication_url]
539
+ sections = [
540
+ p.get("sections")
541
+ for p in content.get("publications")
542
+ if p.get("hostname") in self.publication_url
543
+ ]
362
544
  return sections[0]
545
+
546
+ def publication_embed(self, url):
547
+ """
548
+
549
+ Args:
550
+ url:
551
+
552
+ Returns:
553
+
554
+ """
555
+ return self.call("/publication/embed", "GET", url=url)
556
+
557
+ def call(self, endpoint, method, **params):
558
+ """
559
+
560
+ Args:
561
+ endpoint:
562
+ method:
563
+ **params:
564
+
565
+ Returns:
566
+
567
+ """
568
+ response = self._session.request(
569
+ method=method,
570
+ url=f"{self.publication_url}/{endpoint}",
571
+ params=params,
572
+ )
573
+ return Api._handle_response(response=response)
@@ -1,3 +1,9 @@
1
+ """
2
+
3
+ Post Utilities
4
+
5
+ """
6
+
1
7
  import json
2
8
  from typing import Dict
3
9
 
@@ -7,13 +13,19 @@ from substack.exceptions import SectionNotExistsException
7
13
 
8
14
 
9
15
  class Post:
16
+ """
17
+
18
+ Post utility class
19
+
20
+ """
21
+
10
22
  def __init__(
11
- self,
12
- title: str,
13
- subtitle: str,
14
- user_id,
15
- audience: str = None,
16
- write_comment_permissions: str = None,
23
+ self,
24
+ title: str,
25
+ subtitle: str,
26
+ user_id,
27
+ audience: str = None,
28
+ write_comment_permissions: str = None,
17
29
  ):
18
30
  """
19
31
 
@@ -72,6 +84,8 @@ class Post:
72
84
  content = item.get("content")
73
85
  if item.get("type") == "captionedImage":
74
86
  self.captioned_image(**item)
87
+ elif item.get("type") == "embeddedPublication":
88
+ self.draft_body["content"][-1]["attrs"] = item.get("url")
75
89
  elif item.get("type") == "youtube2":
76
90
  self.youtube(item.get("src"))
77
91
  elif item.get("type") == "subscribeWidget":
@@ -143,20 +157,20 @@ class Post:
143
157
  return self
144
158
 
145
159
  def captioned_image(
146
- self,
147
- src: str,
148
- fullscreen: bool = False,
149
- imageSize: str = "normal",
150
- height: int = 819,
151
- width: int = 1456,
152
- resizeWidth: int = 728,
153
- bytes: str = None,
154
- alt: str = None,
155
- title: str = None,
156
- type: str = None,
157
- href: str = None,
158
- belowTheFold: bool = False,
159
- internalRedirect: str = None,
160
+ self,
161
+ src: str,
162
+ fullscreen: bool = False,
163
+ imageSize: str = "normal",
164
+ height: int = 819,
165
+ width: int = 1456,
166
+ resizeWidth: int = 728,
167
+ bytes: str = None,
168
+ alt: str = None,
169
+ title: str = None,
170
+ type: str = None,
171
+ href: str = None,
172
+ belowTheFold: bool = False,
173
+ internalRedirect: str = None,
160
174
  ):
161
175
  """
162
176
 
@@ -252,7 +266,7 @@ class Post:
252
266
  return self
253
267
 
254
268
  def remove_last_paragraph(self):
255
- """ """
269
+ """Remove last paragraph"""
256
270
  del self.draft_body.get("content")[-1]
257
271
 
258
272
  def get_draft(self):
@@ -282,7 +296,11 @@ class Post:
282
296
  Subscribe for free to receive new posts and support my work."""
283
297
 
284
298
  subscribe = self.draft_body["content"][-1]
285
- subscribe["attrs"] = {"url": "%%checkout_url%%", "text": "Subscribe", "language": "en"}
299
+ subscribe["attrs"] = {
300
+ "url": "%%checkout_url%%",
301
+ "text": "Subscribe",
302
+ "language": "en",
303
+ }
286
304
  subscribe["content"] = [
287
305
  {
288
306
  "type": "ctaCaption",