substack-api 1.1.2__py3-none-any.whl → 1.1.4.dev0__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/__init__.py +9 -0
- substack_api/newsletter.py +92 -29
- {substack_api-1.1.2.dist-info → substack_api-1.1.4.dev0.dist-info}/METADATA +1 -1
- substack_api-1.1.4.dev0.dist-info/RECORD +11 -0
- substack_api-1.1.2.dist-info/RECORD +0 -11
- {substack_api-1.1.2.dist-info → substack_api-1.1.4.dev0.dist-info}/WHEEL +0 -0
- {substack_api-1.1.2.dist-info → substack_api-1.1.4.dev0.dist-info}/licenses/LICENSE +0 -0
- {substack_api-1.1.2.dist-info → substack_api-1.1.4.dev0.dist-info}/top_level.txt +0 -0
substack_api/__init__.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
from importlib import metadata as _metadata
|
|
2
|
+
|
|
1
3
|
from .auth import SubstackAuth
|
|
2
4
|
from .category import Category, list_all_categories
|
|
3
5
|
from .newsletter import Newsletter
|
|
4
6
|
from .post import Post
|
|
5
7
|
from .user import User, resolve_handle_redirect
|
|
6
8
|
|
|
9
|
+
try:
|
|
10
|
+
# Use distribution metadata so tag-based builds report the correct version.
|
|
11
|
+
__version__ = _metadata.version("substack-api")
|
|
12
|
+
except _metadata.PackageNotFoundError: # pragma: no cover - occurs for editable installs
|
|
13
|
+
__version__ = "0.0.0"
|
|
14
|
+
|
|
7
15
|
__all__ = [
|
|
8
16
|
"User",
|
|
9
17
|
"Post",
|
|
@@ -12,4 +20,5 @@ __all__ = [
|
|
|
12
20
|
"SubstackAuth",
|
|
13
21
|
"resolve_handle_redirect",
|
|
14
22
|
"list_all_categories",
|
|
23
|
+
"__version__",
|
|
15
24
|
]
|
substack_api/newsletter.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import urllib.parse
|
|
1
3
|
from time import sleep
|
|
2
4
|
from typing import Any, Dict, List, Optional
|
|
3
5
|
|
|
@@ -10,6 +12,43 @@ HEADERS = {
|
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
SEARCH_URL = "https://substack.com/api/v1/publication/search"
|
|
16
|
+
|
|
17
|
+
DISCOVERY_HEADERS = {
|
|
18
|
+
"User-Agent": HEADERS["User-Agent"],
|
|
19
|
+
"Accept": "application/json",
|
|
20
|
+
"Origin": "https://substack.com",
|
|
21
|
+
"Referer": "https://substack.com/discover",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _host_from_url(url: str) -> str:
|
|
26
|
+
host = urllib.parse.urlparse(
|
|
27
|
+
url if "://" in url else f"https://{url}"
|
|
28
|
+
).netloc.lower()
|
|
29
|
+
return host
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _match_publication(search_results: dict, host: str) -> Optional[dict]:
|
|
33
|
+
# Try exact custom domain, then subdomain match
|
|
34
|
+
for item in search_results.get("publications", []):
|
|
35
|
+
if (
|
|
36
|
+
item.get("custom_domain") and _host_from_url(item["custom_domain"]) == host
|
|
37
|
+
) or (
|
|
38
|
+
item.get("subdomain")
|
|
39
|
+
and f"{item['subdomain'].lower()}.substack.com" == host
|
|
40
|
+
):
|
|
41
|
+
return item
|
|
42
|
+
# Fallback: loose match on subdomain token
|
|
43
|
+
m = re.match(r"^([a-z0-9-]+)\.substack\.com$", host)
|
|
44
|
+
if m:
|
|
45
|
+
sub = m.group(1)
|
|
46
|
+
for item in search_results.get("publications", []):
|
|
47
|
+
if item.get("subdomain", "").lower() == sub:
|
|
48
|
+
return item
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
13
52
|
class Newsletter:
|
|
14
53
|
"""
|
|
15
54
|
Newsletter class for interacting with Substack newsletters
|
|
@@ -183,49 +222,73 @@ class Newsletter:
|
|
|
183
222
|
post_data = self._fetch_paginated_posts(params, limit)
|
|
184
223
|
return [Post(item["canonical_url"], auth=self.auth) for item in post_data]
|
|
185
224
|
|
|
186
|
-
def
|
|
225
|
+
def _resolve_publication_id(self) -> Optional[int]:
|
|
187
226
|
"""
|
|
188
|
-
|
|
227
|
+
Resolve publication_id via Substack discovery search—no posts needed.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
None
|
|
189
232
|
|
|
190
233
|
Returns
|
|
191
234
|
-------
|
|
192
|
-
|
|
193
|
-
|
|
235
|
+
Optional[int]
|
|
236
|
+
The publication ID if found, otherwise None.
|
|
237
|
+
|
|
238
|
+
Raises
|
|
239
|
+
------
|
|
240
|
+
requests.HTTPError
|
|
241
|
+
If the HTTP request to Substack fails.
|
|
194
242
|
"""
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
243
|
+
host = _host_from_url(self.url)
|
|
244
|
+
q = host.split(":")[0] # strip port if present
|
|
245
|
+
params = {
|
|
246
|
+
"query": q,
|
|
247
|
+
"page": 0,
|
|
248
|
+
"limit": 25,
|
|
249
|
+
"skipExplanation": "true",
|
|
250
|
+
"sort": "relevance",
|
|
251
|
+
}
|
|
252
|
+
r = requests.get(
|
|
253
|
+
SEARCH_URL, headers=DISCOVERY_HEADERS, params=params, timeout=30
|
|
254
|
+
)
|
|
255
|
+
r.raise_for_status()
|
|
256
|
+
match = _match_publication(r.json(), host)
|
|
257
|
+
return match.get("id") if match else None
|
|
199
258
|
|
|
200
|
-
|
|
259
|
+
def get_recommendations(self) -> List["Newsletter"]:
|
|
260
|
+
"""
|
|
261
|
+
Get recommended publications without relying on the latest post.
|
|
262
|
+
"""
|
|
263
|
+
publication_id = self._resolve_publication_id()
|
|
264
|
+
if not publication_id:
|
|
265
|
+
# graceful fallback to your existing (post-derived) path
|
|
266
|
+
try:
|
|
267
|
+
posts = self.get_posts(limit=1)
|
|
268
|
+
publication_id = (
|
|
269
|
+
posts[0].get_metadata()["publication_id"] if posts else None
|
|
270
|
+
)
|
|
271
|
+
except Exception:
|
|
272
|
+
publication_id = None
|
|
273
|
+
if not publication_id:
|
|
274
|
+
return []
|
|
201
275
|
|
|
202
|
-
# Now get the recommendations
|
|
203
276
|
endpoint = f"{self.url}/api/v1/recommendations/from/{publication_id}"
|
|
204
277
|
response = self._make_request(endpoint, timeout=30)
|
|
205
278
|
response.raise_for_status()
|
|
279
|
+
recommendations = response.json() or []
|
|
206
280
|
|
|
207
|
-
|
|
208
|
-
if not recommendations:
|
|
209
|
-
return []
|
|
210
|
-
|
|
211
|
-
recommended_newsletter_urls = []
|
|
281
|
+
urls = []
|
|
212
282
|
for rec in recommendations:
|
|
213
|
-
|
|
214
|
-
if "custom_domain"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
f"{recpub['subdomain']}.substack.com"
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
# Avoid circular import
|
|
222
|
-
from .newsletter import Newsletter
|
|
283
|
+
pub = rec.get("recommendedPublication", {})
|
|
284
|
+
if pub.get("custom_domain"):
|
|
285
|
+
urls.append(pub["custom_domain"])
|
|
286
|
+
elif pub.get("subdomain"):
|
|
287
|
+
urls.append(f"{pub['subdomain']}.substack.com")
|
|
223
288
|
|
|
224
|
-
|
|
225
|
-
Newsletter(url, auth=self.auth) for url in recommended_newsletter_urls
|
|
226
|
-
]
|
|
289
|
+
from .newsletter import Newsletter # avoid circular import
|
|
227
290
|
|
|
228
|
-
return
|
|
291
|
+
return [Newsletter(u, auth=self.auth) for u in urls]
|
|
229
292
|
|
|
230
293
|
def get_authors(self) -> List:
|
|
231
294
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: substack-api
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.4.dev0
|
|
4
4
|
Summary: Unofficial wrapper for the Substack API
|
|
5
5
|
Project-URL: Homepage, https://github.com/nhagar/substack_api
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/nhagar/substack_api/issues
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
substack_api/__init__.py,sha256=PIVnDjujILU1uVJoSr7XckDNHYHaGYkX7InO7dZZcdw,658
|
|
2
|
+
substack_api/auth.py,sha256=V5pU1jKZdEdsEcaL02wGO3frdxmtj-zJbafNXzH1AKI,2775
|
|
3
|
+
substack_api/category.py,sha256=Xzc8KOIHg_eqloiWy44Lfm-T2EWC1nbt8sd4OII_eAQ,5167
|
|
4
|
+
substack_api/newsletter.py,sha256=XmpX8-QIPD38-akUHzB0LMWj0-9FoYs75npeSWbvOzM,9676
|
|
5
|
+
substack_api/post.py,sha256=k75iXnxAECKFvIzyAPT3fjbel6dM2YJysss82ZsLsLo,3633
|
|
6
|
+
substack_api/user.py,sha256=aCQPdTVz0CYwCjIBkVeGi-6gz5o2pD1SbiyXD_Cj73w,8211
|
|
7
|
+
substack_api-1.1.4.dev0.dist-info/licenses/LICENSE,sha256=yBOIxrO0jO_AaAkiYbdydb7kg5ZO_qy4mBEYzjxIQn8,1066
|
|
8
|
+
substack_api-1.1.4.dev0.dist-info/METADATA,sha256=_RhPL7q0sZbJyGFkuEmBjU8v3pAFBJEE5ONpLNHjKds,5977
|
|
9
|
+
substack_api-1.1.4.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
substack_api-1.1.4.dev0.dist-info/top_level.txt,sha256=0CQgkJ3y5JH19TzaDyg_rjPkU6zKxEffCGksxw7qyIg,13
|
|
11
|
+
substack_api-1.1.4.dev0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
substack_api/__init__.py,sha256=zlsma-aBbXqRp6hoZ1e2ZZU4H5xZjJpr91EpJfAGd28,340
|
|
2
|
-
substack_api/auth.py,sha256=V5pU1jKZdEdsEcaL02wGO3frdxmtj-zJbafNXzH1AKI,2775
|
|
3
|
-
substack_api/category.py,sha256=Xzc8KOIHg_eqloiWy44Lfm-T2EWC1nbt8sd4OII_eAQ,5167
|
|
4
|
-
substack_api/newsletter.py,sha256=ynvkH5iwI45pgar4lJxwAsaG_6FSlRpq0CyM85F8kIc,7664
|
|
5
|
-
substack_api/post.py,sha256=k75iXnxAECKFvIzyAPT3fjbel6dM2YJysss82ZsLsLo,3633
|
|
6
|
-
substack_api/user.py,sha256=aCQPdTVz0CYwCjIBkVeGi-6gz5o2pD1SbiyXD_Cj73w,8211
|
|
7
|
-
substack_api-1.1.2.dist-info/licenses/LICENSE,sha256=yBOIxrO0jO_AaAkiYbdydb7kg5ZO_qy4mBEYzjxIQn8,1066
|
|
8
|
-
substack_api-1.1.2.dist-info/METADATA,sha256=o8RRePSGLjiYZrS8OwEO4IB6jpT4Noor00p30JXkIrg,5972
|
|
9
|
-
substack_api-1.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
-
substack_api-1.1.2.dist-info/top_level.txt,sha256=0CQgkJ3y5JH19TzaDyg_rjPkU6zKxEffCGksxw7qyIg,13
|
|
11
|
-
substack_api-1.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|