python-substack 0.1.13__tar.gz → 0.1.14__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.14
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
 
@@ -109,4 +127,17 @@ api.prepublish_draft(draft.get("id"))
109
127
  api.publish_draft(draft.get("id"))
110
128
  ```
111
129
 
130
+ # Contributing
131
+
132
+ Install pre-commit:
133
+
134
+ ```shell
135
+ pip install pre-commit
136
+ ```
137
+
138
+ Set up pre-commit
139
+
140
+ ```shell
141
+ pre-commit install
142
+ ```
112
143
 
@@ -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
 
@@ -85,3 +103,16 @@ api.prepublish_draft(draft.get("id"))
85
103
  api.publish_draft(draft.get("id"))
86
104
  ```
87
105
 
106
+ # Contributing
107
+
108
+ Install pre-commit:
109
+
110
+ ```shell
111
+ pip install pre-commit
112
+ ```
113
+
114
+ Set up pre-commit
115
+
116
+ ```shell
117
+ pre-commit install
118
+ ```
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.13"
3
+ version = "0.1.14"
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,37 @@ 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
+ return Api._handle_response(response=response)
132
+
133
+ def change_publication(self, publication):
134
+ """
135
+ Change the publication URL
136
+ """
137
+ self.publication_url = urljoin(publication["publication_url"], "api/v1")
138
+
139
+ # sign-in to the publication
140
+ self.signin_for_pub(publication)
141
+
142
+ def export_cookies(self, path: str = "cookies.json"):
143
+ """
144
+ Export cookies to a json file.
145
+ Args:
146
+ path: path to the json file
147
+ """
148
+ cookies = self._session.cookies.get_dict()
149
+ with open(path, "w") as f:
150
+ json.dump(cookies, f)
151
+
78
152
  @staticmethod
79
153
  def _handle_response(response: requests.Response):
80
154
  """
@@ -92,6 +166,82 @@ class Api:
92
166
  except ValueError:
93
167
  raise SubstackRequestException("Invalid Response: %s" % response.text)
94
168
 
169
+ def get_user_id(self):
170
+ """
171
+
172
+ Returns:
173
+
174
+ """
175
+ profile = self.get_user_profile()
176
+ user_id = profile["id"]
177
+
178
+ return user_id
179
+
180
+ @staticmethod
181
+ def get_publication_url(publication: dict) -> str:
182
+ """
183
+ Gets the publication url
184
+
185
+ Args:
186
+ publication:
187
+ """
188
+ custom_domain = publication["custom_domain"]
189
+ if not custom_domain:
190
+ publication_url = f"https://{publication['subdomain']}.substack.com"
191
+ else:
192
+ publication_url = f"https://{custom_domain}"
193
+
194
+ return publication_url
195
+
196
+ def get_user_primary_publication(self):
197
+ """
198
+ Gets the users primary publication
199
+ """
200
+
201
+ profile = self.get_user_profile()
202
+ primary_publication = profile["primaryPublication"]
203
+ primary_publication["publication_url"] = self.get_publication_url(
204
+ primary_publication
205
+ )
206
+
207
+ return primary_publication
208
+
209
+ def get_user_publications(self):
210
+ """
211
+ Gets the users publications
212
+ """
213
+
214
+ profile = self.get_user_profile()
215
+
216
+ # Loop through users "publicationUsers" list, and return a list
217
+ # of dictionaries of "name", and "subdomain", and "id"
218
+ 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)
223
+
224
+ return user_publications
225
+
226
+ def get_user_profile(self):
227
+ """
228
+ Gets the users profile
229
+ """
230
+ response = self._session.get(f"{self.base_url}/user/profile/self")
231
+
232
+ return Api._handle_response(response=response)
233
+
234
+ def get_user_settings(self):
235
+ """
236
+ Get list of users.
237
+
238
+ Returns:
239
+
240
+ """
241
+ response = self._session.get(f"{self.base_url}/settings")
242
+
243
+ return Api._handle_response(response=response)
244
+
95
245
  def get_publication_users(self):
96
246
  """
97
247
  Get list of users.
@@ -111,9 +261,29 @@ class Api:
111
261
  Returns:
112
262
 
113
263
  """
114
- response = self._session.get(f"{self.publication_url}/publication_launch_checklist")
264
+ response = self._session.get(
265
+ f"{self.publication_url}/publication_launch_checklist"
266
+ )
267
+
268
+ return Api._handle_response(response=response)["subscriberCount"]
269
+
270
+ def get_published_posts(
271
+ self, offset=0, limit=25, order_by="post_date", order_direction="desc"
272
+ ):
273
+ """
274
+ Get list of published posts for the publication.
275
+ """
276
+ response = self._session.get(
277
+ f"{self.publication_url}/post_management/published",
278
+ params={
279
+ "offset": offset,
280
+ "limit": limit,
281
+ "order_by": order_by,
282
+ "order_direction": order_direction,
283
+ },
284
+ )
115
285
 
116
- return Api._handle_response(response=response)['subscriberCount']
286
+ return Api._handle_response(response=response)
117
287
 
118
288
  def get_posts(self) -> dict:
119
289
  """
@@ -142,6 +312,14 @@ class Api:
142
312
  )
143
313
  return Api._handle_response(response=response)
144
314
 
315
+ def get_draft(self, draft_id):
316
+ """
317
+ Gets a draft given it's id.
318
+
319
+ """
320
+ response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
321
+ return Api._handle_response(response=response)
322
+
145
323
  def delete_draft(self, draft_id):
146
324
  """
147
325
 
@@ -166,11 +344,7 @@ class Api:
166
344
  response = self._session.post(f"{self.publication_url}/drafts", json=body)
167
345
  return Api._handle_response(response=response)
168
346
 
169
- def put_draft(
170
- self,
171
- draft,
172
- **kwargs
173
- ) -> dict:
347
+ def put_draft(self, draft, **kwargs) -> dict:
174
348
  """
175
349
 
176
350
  Args:
@@ -202,7 +376,7 @@ class Api:
202
376
  return Api._handle_response(response=response)
203
377
 
204
378
  def publish_draft(
205
- self, draft, send: bool = True, share_automatically: bool = False
379
+ self, draft, send: bool = True, share_automatically: bool = False
206
380
  ) -> dict:
207
381
  """
208
382
 
@@ -320,7 +494,7 @@ class Api:
320
494
  page_output = self.get_category(category_id, category_type, page)
321
495
  publications.extend(page_output.get("publications", []))
322
496
  if (
323
- limit is not None and limit <= len(publications)
497
+ limit is not None and limit <= len(publications)
324
498
  ) or not page_output.get("more", False):
325
499
  publications = publications[:limit]
326
500
  break
@@ -358,5 +532,9 @@ class Api:
358
532
  f"{self.publication_url}/subscriptions",
359
533
  )
360
534
  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]
535
+ sections = [
536
+ p.get("sections")
537
+ for p in content.get("publications")
538
+ if p.get("hostname") in self.publication_url
539
+ ]
362
540
  return sections[0]
@@ -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
 
@@ -143,20 +155,20 @@ class Post:
143
155
  return self
144
156
 
145
157
  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,
158
+ self,
159
+ src: str,
160
+ fullscreen: bool = False,
161
+ imageSize: str = "normal",
162
+ height: int = 819,
163
+ width: int = 1456,
164
+ resizeWidth: int = 728,
165
+ bytes: str = None,
166
+ alt: str = None,
167
+ title: str = None,
168
+ type: str = None,
169
+ href: str = None,
170
+ belowTheFold: bool = False,
171
+ internalRedirect: str = None,
160
172
  ):
161
173
  """
162
174
 
@@ -252,7 +264,7 @@ class Post:
252
264
  return self
253
265
 
254
266
  def remove_last_paragraph(self):
255
- """ """
267
+ """Remove last paragraph"""
256
268
  del self.draft_body.get("content")[-1]
257
269
 
258
270
  def get_draft(self):
@@ -282,7 +294,11 @@ class Post:
282
294
  Subscribe for free to receive new posts and support my work."""
283
295
 
284
296
  subscribe = self.draft_body["content"][-1]
285
- subscribe["attrs"] = {"url": "%%checkout_url%%", "text": "Subscribe", "language": "en"}
297
+ subscribe["attrs"] = {
298
+ "url": "%%checkout_url%%",
299
+ "text": "Subscribe",
300
+ "language": "en",
301
+ }
286
302
  subscribe["content"] = [
287
303
  {
288
304
  "type": "ctaCaption",