substack-api 1.1.4.dev0__py3-none-any.whl → 1.2.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/__main__.py +3 -0
- substack_api/cli.py +301 -0
- {substack_api-1.1.4.dev0.dist-info → substack_api-1.2.0.dist-info}/METADATA +31 -4
- substack_api-1.2.0.dist-info/RECORD +14 -0
- {substack_api-1.1.4.dev0.dist-info → substack_api-1.2.0.dist-info}/WHEEL +1 -1
- substack_api-1.2.0.dist-info/entry_points.txt +2 -0
- substack_api-1.1.4.dev0.dist-info/RECORD +0 -11
- {substack_api-1.1.4.dev0.dist-info → substack_api-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {substack_api-1.1.4.dev0.dist-info → substack_api-1.2.0.dist-info}/top_level.txt +0 -0
substack_api/__main__.py
ADDED
substack_api/cli.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Command-line interface for substack_api."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from substack_api import (
|
|
9
|
+
Category,
|
|
10
|
+
Newsletter,
|
|
11
|
+
Post,
|
|
12
|
+
SubstackAuth,
|
|
13
|
+
User,
|
|
14
|
+
__version__,
|
|
15
|
+
list_all_categories,
|
|
16
|
+
resolve_handle_redirect,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
QUICKSTART_TEXT = """\
|
|
20
|
+
substack — CLI for reading Substack newsletters, posts, and user profiles.
|
|
21
|
+
|
|
22
|
+
All output is JSON by default. Add --pretty for human-readable formatting.
|
|
23
|
+
Use --cookies <path> to authenticate for paywalled content.
|
|
24
|
+
|
|
25
|
+
COMMANDS
|
|
26
|
+
========
|
|
27
|
+
|
|
28
|
+
Newsletter:
|
|
29
|
+
substack newsletter posts <url> [--sort new|top|pinned|community] [--limit N]
|
|
30
|
+
substack newsletter search <url> <query> [--limit N]
|
|
31
|
+
substack newsletter podcasts <url> [--limit N]
|
|
32
|
+
substack newsletter recs <url>
|
|
33
|
+
substack newsletter authors <url>
|
|
34
|
+
|
|
35
|
+
Post:
|
|
36
|
+
substack post metadata <url>
|
|
37
|
+
substack post content <url>
|
|
38
|
+
substack post paywalled <url>
|
|
39
|
+
|
|
40
|
+
User:
|
|
41
|
+
substack user info <username>
|
|
42
|
+
substack user subscriptions <username>
|
|
43
|
+
|
|
44
|
+
Categories:
|
|
45
|
+
substack categories
|
|
46
|
+
substack category newsletters --name <name> [--metadata]
|
|
47
|
+
substack category newsletters --id <id> [--metadata]
|
|
48
|
+
|
|
49
|
+
Other:
|
|
50
|
+
substack resolve-handle <handle>
|
|
51
|
+
substack version
|
|
52
|
+
|
|
53
|
+
EXAMPLES
|
|
54
|
+
========
|
|
55
|
+
|
|
56
|
+
# List the 5 newest posts from a newsletter
|
|
57
|
+
substack newsletter posts https://example.substack.com --limit 5
|
|
58
|
+
|
|
59
|
+
# Search for posts about a topic
|
|
60
|
+
substack newsletter search https://example.substack.com "machine learning"
|
|
61
|
+
|
|
62
|
+
# Get full metadata for a specific post
|
|
63
|
+
substack post metadata https://example.substack.com/p/my-post --pretty
|
|
64
|
+
|
|
65
|
+
# Check if a post is paywalled
|
|
66
|
+
substack post paywalled https://example.substack.com/p/my-post
|
|
67
|
+
|
|
68
|
+
# Get the HTML content of a post (with auth for paywalled content)
|
|
69
|
+
substack --cookies cookies.json post content https://example.substack.com/p/paid-post
|
|
70
|
+
|
|
71
|
+
# Look up a user's subscriptions
|
|
72
|
+
substack user subscriptions username
|
|
73
|
+
|
|
74
|
+
# Browse newsletter categories
|
|
75
|
+
substack categories
|
|
76
|
+
substack category newsletters --name Technology
|
|
77
|
+
|
|
78
|
+
# Resolve a renamed user handle
|
|
79
|
+
substack resolve-handle oldusername
|
|
80
|
+
|
|
81
|
+
NOTES
|
|
82
|
+
=====
|
|
83
|
+
- API calls include a 2-second delay to be respectful to Substack's servers.
|
|
84
|
+
- Use --limit to avoid long waits when fetching posts from large newsletters.
|
|
85
|
+
- The cookies file should be a JSON file containing Substack session cookies.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _json_out(data: Any, pretty: bool = False) -> None:
|
|
90
|
+
"""Print data as JSON to stdout."""
|
|
91
|
+
indent = 2 if pretty else None
|
|
92
|
+
json.dump(data, sys.stdout, indent=indent, default=str)
|
|
93
|
+
sys.stdout.write("\n")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_auth(cookies_path: str | None):
|
|
97
|
+
"""Build auth object if cookies path provided."""
|
|
98
|
+
if cookies_path:
|
|
99
|
+
return SubstackAuth(cookies_path)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="substack",
|
|
106
|
+
description="CLI for the substack_api library",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--cookies", metavar="PATH", help="Path to cookies JSON file for auth"
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--pretty", action="store_true", help="Pretty-print JSON output"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
116
|
+
|
|
117
|
+
# quickstart
|
|
118
|
+
subparsers.add_parser("quickstart", help="Print developer quickstart guide")
|
|
119
|
+
|
|
120
|
+
# version
|
|
121
|
+
subparsers.add_parser("version", help="Print version")
|
|
122
|
+
|
|
123
|
+
# --- newsletter ---
|
|
124
|
+
nl_parser = subparsers.add_parser("newsletter", help="Newsletter operations")
|
|
125
|
+
nl_sub = nl_parser.add_subparsers(dest="subcommand")
|
|
126
|
+
|
|
127
|
+
nl_posts = nl_sub.add_parser("posts", help="Get posts from a newsletter")
|
|
128
|
+
nl_posts.add_argument("url", help="Newsletter URL")
|
|
129
|
+
nl_posts.add_argument(
|
|
130
|
+
"--sort",
|
|
131
|
+
default="new",
|
|
132
|
+
choices=["new", "top", "pinned", "community"],
|
|
133
|
+
help="Sort order (default: new)",
|
|
134
|
+
)
|
|
135
|
+
nl_posts.add_argument("--limit", type=int, help="Max number of posts")
|
|
136
|
+
|
|
137
|
+
nl_search = nl_sub.add_parser("search", help="Search posts in a newsletter")
|
|
138
|
+
nl_search.add_argument("url", help="Newsletter URL")
|
|
139
|
+
nl_search.add_argument("query", help="Search query")
|
|
140
|
+
nl_search.add_argument("--limit", type=int, help="Max number of results")
|
|
141
|
+
|
|
142
|
+
nl_podcasts = nl_sub.add_parser("podcasts", help="Get podcasts from a newsletter")
|
|
143
|
+
nl_podcasts.add_argument("url", help="Newsletter URL")
|
|
144
|
+
nl_podcasts.add_argument("--limit", type=int, help="Max number of podcasts")
|
|
145
|
+
|
|
146
|
+
nl_recs = nl_sub.add_parser("recs", help="Get newsletter recommendations")
|
|
147
|
+
nl_recs.add_argument("url", help="Newsletter URL")
|
|
148
|
+
|
|
149
|
+
nl_authors = nl_sub.add_parser("authors", help="Get newsletter authors")
|
|
150
|
+
nl_authors.add_argument("url", help="Newsletter URL")
|
|
151
|
+
|
|
152
|
+
# --- post ---
|
|
153
|
+
post_parser = subparsers.add_parser("post", help="Post operations")
|
|
154
|
+
post_sub = post_parser.add_subparsers(dest="subcommand")
|
|
155
|
+
|
|
156
|
+
post_meta = post_sub.add_parser("metadata", help="Get post metadata")
|
|
157
|
+
post_meta.add_argument("url", help="Post URL")
|
|
158
|
+
|
|
159
|
+
post_content = post_sub.add_parser("content", help="Get post HTML content")
|
|
160
|
+
post_content.add_argument("url", help="Post URL")
|
|
161
|
+
|
|
162
|
+
post_pay = post_sub.add_parser("paywalled", help="Check if post is paywalled")
|
|
163
|
+
post_pay.add_argument("url", help="Post URL")
|
|
164
|
+
|
|
165
|
+
# --- user ---
|
|
166
|
+
user_parser = subparsers.add_parser("user", help="User operations")
|
|
167
|
+
user_sub = user_parser.add_subparsers(dest="subcommand")
|
|
168
|
+
|
|
169
|
+
user_info = user_sub.add_parser("info", help="Get user profile info")
|
|
170
|
+
user_info.add_argument("username", help="Substack username")
|
|
171
|
+
|
|
172
|
+
user_subs = user_sub.add_parser("subscriptions", help="Get user subscriptions")
|
|
173
|
+
user_subs.add_argument("username", help="Substack username")
|
|
174
|
+
|
|
175
|
+
# --- categories ---
|
|
176
|
+
subparsers.add_parser("categories", help="List all categories")
|
|
177
|
+
|
|
178
|
+
# --- category ---
|
|
179
|
+
cat_parser = subparsers.add_parser("category", help="Category operations")
|
|
180
|
+
cat_sub = cat_parser.add_subparsers(dest="subcommand")
|
|
181
|
+
|
|
182
|
+
cat_nl = cat_sub.add_parser("newsletters", help="Get newsletters in a category")
|
|
183
|
+
cat_nl_group = cat_nl.add_mutually_exclusive_group(required=True)
|
|
184
|
+
cat_nl_group.add_argument("--name", help="Category name")
|
|
185
|
+
cat_nl_group.add_argument("--id", type=int, help="Category ID")
|
|
186
|
+
cat_nl.add_argument(
|
|
187
|
+
"--metadata", action="store_true", help="Include full metadata"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# --- resolve-handle ---
|
|
191
|
+
rh = subparsers.add_parser("resolve-handle", help="Resolve a renamed handle")
|
|
192
|
+
rh.add_argument("handle", help="The handle to resolve")
|
|
193
|
+
|
|
194
|
+
return parser
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _dispatch(args: argparse.Namespace) -> None:
|
|
198
|
+
"""Dispatch to the appropriate handler based on parsed args."""
|
|
199
|
+
pretty = args.pretty
|
|
200
|
+
|
|
201
|
+
if args.command == "quickstart":
|
|
202
|
+
print(QUICKSTART_TEXT)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if args.command == "version":
|
|
206
|
+
print(__version__)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
if args.command == "newsletter":
|
|
210
|
+
if not args.subcommand:
|
|
211
|
+
print("Usage: substack newsletter {posts,search,podcasts,recs,authors}", file=sys.stderr)
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
|
|
214
|
+
auth = _get_auth(args.cookies)
|
|
215
|
+
nl = Newsletter(args.url, auth=auth)
|
|
216
|
+
|
|
217
|
+
if args.subcommand == "posts":
|
|
218
|
+
posts = nl.get_posts(sorting=args.sort, limit=args.limit)
|
|
219
|
+
_json_out([{"url": p.url} for p in posts], pretty)
|
|
220
|
+
elif args.subcommand == "search":
|
|
221
|
+
posts = nl.search_posts(query=args.query, limit=args.limit)
|
|
222
|
+
_json_out([{"url": p.url} for p in posts], pretty)
|
|
223
|
+
elif args.subcommand == "podcasts":
|
|
224
|
+
posts = nl.get_podcasts(limit=args.limit)
|
|
225
|
+
_json_out([{"url": p.url} for p in posts], pretty)
|
|
226
|
+
elif args.subcommand == "recs":
|
|
227
|
+
recs = nl.get_recommendations()
|
|
228
|
+
_json_out([{"url": r.url} for r in recs], pretty)
|
|
229
|
+
elif args.subcommand == "authors":
|
|
230
|
+
authors = nl.get_authors()
|
|
231
|
+
_json_out([{"username": a.username} for a in authors], pretty)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if args.command == "post":
|
|
235
|
+
if not args.subcommand:
|
|
236
|
+
print("Usage: substack post {metadata,content,paywalled}", file=sys.stderr)
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
|
|
239
|
+
auth = _get_auth(args.cookies)
|
|
240
|
+
post = Post(args.url, auth=auth)
|
|
241
|
+
|
|
242
|
+
if args.subcommand == "metadata":
|
|
243
|
+
_json_out(post.get_metadata(), pretty)
|
|
244
|
+
elif args.subcommand == "content":
|
|
245
|
+
_json_out({"url": post.url, "html": post.get_content()}, pretty)
|
|
246
|
+
elif args.subcommand == "paywalled":
|
|
247
|
+
_json_out({"url": post.url, "paywalled": post.is_paywalled()}, pretty)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if args.command == "user":
|
|
251
|
+
if not args.subcommand:
|
|
252
|
+
print("Usage: substack user {info,subscriptions}", file=sys.stderr)
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
user = User(args.username)
|
|
256
|
+
|
|
257
|
+
if args.subcommand == "info":
|
|
258
|
+
_json_out(user.get_raw_data(), pretty)
|
|
259
|
+
elif args.subcommand == "subscriptions":
|
|
260
|
+
_json_out(user.get_subscriptions(), pretty)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if args.command == "categories":
|
|
264
|
+
cats = list_all_categories()
|
|
265
|
+
_json_out([{"name": name, "id": id} for name, id in cats], pretty)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if args.command == "category":
|
|
269
|
+
if not args.subcommand:
|
|
270
|
+
print("Usage: substack category {newsletters}", file=sys.stderr)
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
cat = Category(name=args.name, id=args.id)
|
|
274
|
+
|
|
275
|
+
if args.subcommand == "newsletters":
|
|
276
|
+
if args.metadata:
|
|
277
|
+
_json_out(cat.get_newsletter_metadata(), pretty)
|
|
278
|
+
else:
|
|
279
|
+
_json_out(cat.get_newsletter_urls(), pretty)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
if args.command == "resolve-handle":
|
|
283
|
+
result = resolve_handle_redirect(args.handle)
|
|
284
|
+
_json_out({"old_handle": args.handle, "new_handle": result}, pretty)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main() -> None:
|
|
289
|
+
"""CLI entry point."""
|
|
290
|
+
parser = _build_parser()
|
|
291
|
+
args = parser.parse_args()
|
|
292
|
+
|
|
293
|
+
if not args.command:
|
|
294
|
+
parser.print_help()
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
_dispatch(args)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
301
|
+
sys.exit(1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: substack-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
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
|
|
@@ -31,11 +31,38 @@ This library provides Python interfaces for interacting with Substack's unoffici
|
|
|
31
31
|
# Using pip
|
|
32
32
|
pip install substack-api
|
|
33
33
|
|
|
34
|
-
# Using
|
|
35
|
-
|
|
34
|
+
# Using uv
|
|
35
|
+
uv add substack-api
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
##
|
|
38
|
+
## Command-Line Interface
|
|
39
|
+
|
|
40
|
+
The library includes a CLI for quick access from the terminal. All commands output JSON by default.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Get the 5 newest posts from a newsletter
|
|
44
|
+
substack newsletter posts https://example.substack.com --limit 5
|
|
45
|
+
|
|
46
|
+
# Search for posts
|
|
47
|
+
substack newsletter search https://example.substack.com "machine learning"
|
|
48
|
+
|
|
49
|
+
# Get metadata for a specific post
|
|
50
|
+
substack post metadata https://example.substack.com/p/my-post --pretty
|
|
51
|
+
|
|
52
|
+
# Look up a user's subscriptions
|
|
53
|
+
substack user subscriptions username
|
|
54
|
+
|
|
55
|
+
# Browse categories
|
|
56
|
+
substack categories
|
|
57
|
+
substack category newsletters --name Technology
|
|
58
|
+
|
|
59
|
+
# Run the quickstart guide for a full command reference
|
|
60
|
+
substack quickstart
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use `--pretty` for human-readable output and `--cookies <path>` for authenticated access to paywalled content.
|
|
64
|
+
|
|
65
|
+
## Python Usage Examples
|
|
39
66
|
|
|
40
67
|
### Working with Newsletters
|
|
41
68
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
substack_api/__init__.py,sha256=PIVnDjujILU1uVJoSr7XckDNHYHaGYkX7InO7dZZcdw,658
|
|
2
|
+
substack_api/__main__.py,sha256=q8uf3yzLPEl1Zh-g3CgORdlOF78YbBLG__GKbquB_Sw,42
|
|
3
|
+
substack_api/auth.py,sha256=V5pU1jKZdEdsEcaL02wGO3frdxmtj-zJbafNXzH1AKI,2775
|
|
4
|
+
substack_api/category.py,sha256=Xzc8KOIHg_eqloiWy44Lfm-T2EWC1nbt8sd4OII_eAQ,5167
|
|
5
|
+
substack_api/cli.py,sha256=OpC9v2kfiyy3LzE5r6_TfR03mYiWH69MvB7uUH4TUa4,10037
|
|
6
|
+
substack_api/newsletter.py,sha256=XmpX8-QIPD38-akUHzB0LMWj0-9FoYs75npeSWbvOzM,9676
|
|
7
|
+
substack_api/post.py,sha256=k75iXnxAECKFvIzyAPT3fjbel6dM2YJysss82ZsLsLo,3633
|
|
8
|
+
substack_api/user.py,sha256=aCQPdTVz0CYwCjIBkVeGi-6gz5o2pD1SbiyXD_Cj73w,8211
|
|
9
|
+
substack_api-1.2.0.dist-info/licenses/LICENSE,sha256=yBOIxrO0jO_AaAkiYbdydb7kg5ZO_qy4mBEYzjxIQn8,1066
|
|
10
|
+
substack_api-1.2.0.dist-info/METADATA,sha256=M4w8JYEsIEsjgY2-QsMwjijQEdH8zRuAyW1F5ullx14,6771
|
|
11
|
+
substack_api-1.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
substack_api-1.2.0.dist-info/entry_points.txt,sha256=Lksu4c6E-Mzeb-55MdizfOwSEnT1_35G-xjNWDlioGY,51
|
|
13
|
+
substack_api-1.2.0.dist-info/top_level.txt,sha256=0CQgkJ3y5JH19TzaDyg_rjPkU6zKxEffCGksxw7qyIg,13
|
|
14
|
+
substack_api-1.2.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|