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.
@@ -0,0 +1,3 @@
1
+ from substack_api.cli import main
2
+
3
+ main()
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.1.4.dev0
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 poetry
35
- poetry add substack-api
34
+ # Using uv
35
+ uv add substack-api
36
36
  ```
37
37
 
38
- ## Usage Examples
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (82.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ substack = substack_api.cli:main
@@ -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,,