substack-api 1.1.3__tar.gz → 1.2.0__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.
Files changed (48) hide show
  1. {substack_api-1.1.3 → substack_api-1.2.0}/PKG-INFO +31 -4
  2. {substack_api-1.1.3 → substack_api-1.2.0}/README.md +30 -3
  3. substack_api-1.2.0/docs/cli.md +176 -0
  4. {substack_api-1.1.3 → substack_api-1.2.0}/docs/index.md +2 -1
  5. {substack_api-1.1.3 → substack_api-1.2.0}/docs/installation.md +12 -2
  6. {substack_api-1.1.3 → substack_api-1.2.0}/mkdocs.yml +1 -0
  7. {substack_api-1.1.3 → substack_api-1.2.0}/pyproject.toml +3 -0
  8. substack_api-1.2.0/substack_api/__main__.py +3 -0
  9. substack_api-1.2.0/substack_api/cli.py +301 -0
  10. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/PKG-INFO +31 -4
  11. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/SOURCES.txt +5 -0
  12. substack_api-1.2.0/substack_api.egg-info/entry_points.txt +2 -0
  13. substack_api-1.2.0/tests/test_cli.py +453 -0
  14. {substack_api-1.1.3 → substack_api-1.2.0}/.github/workflows/docs.yml +0 -0
  15. {substack_api-1.1.3 → substack_api-1.2.0}/.github/workflows/pull_request.yml +0 -0
  16. {substack_api-1.1.3 → substack_api-1.2.0}/.github/workflows/release.yml +0 -0
  17. {substack_api-1.1.3 → substack_api-1.2.0}/.gitignore +0 -0
  18. {substack_api-1.1.3 → substack_api-1.2.0}/.python-version +0 -0
  19. {substack_api-1.1.3 → substack_api-1.2.0}/LICENSE +0 -0
  20. {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/auth.md +0 -0
  21. {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/category.md +0 -0
  22. {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/index.md +0 -0
  23. {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/newsletter.md +0 -0
  24. {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/post.md +0 -0
  25. {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/user.md +0 -0
  26. {substack_api-1.1.3 → substack_api-1.2.0}/docs/authentication.md +0 -0
  27. {substack_api-1.1.3 → substack_api-1.2.0}/docs/css/extra.css +0 -0
  28. {substack_api-1.1.3 → substack_api-1.2.0}/docs/user-guide.md +0 -0
  29. {substack_api-1.1.3 → substack_api-1.2.0}/examples/usage_walkthrough.ipynb +0 -0
  30. {substack_api-1.1.3 → substack_api-1.2.0}/setup.cfg +0 -0
  31. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/__init__.py +0 -0
  32. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/auth.py +0 -0
  33. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/category.py +0 -0
  34. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/newsletter.py +0 -0
  35. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/post.py +0 -0
  36. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/user.py +0 -0
  37. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/dependency_links.txt +0 -0
  38. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/requires.txt +0 -0
  39. {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/top_level.txt +0 -0
  40. {substack_api-1.1.3 → substack_api-1.2.0}/tests/__init__.py +0 -0
  41. {substack_api-1.1.3 → substack_api-1.2.0}/tests/conftest.py +0 -0
  42. {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_auth.py +0 -0
  43. {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_category.py +0 -0
  44. {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_newsletter.py +0 -0
  45. {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_post.py +0 -0
  46. {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_user.py +0 -0
  47. {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_user_redirects.py +0 -0
  48. {substack_api-1.1.3 → substack_api-1.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substack-api
3
- Version: 1.1.3
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
 
@@ -18,11 +18,38 @@ This library provides Python interfaces for interacting with Substack's unoffici
18
18
  # Using pip
19
19
  pip install substack-api
20
20
 
21
- # Using poetry
22
- poetry add substack-api
21
+ # Using uv
22
+ uv add substack-api
23
23
  ```
24
24
 
25
- ## Usage Examples
25
+ ## Command-Line Interface
26
+
27
+ The library includes a CLI for quick access from the terminal. All commands output JSON by default.
28
+
29
+ ```bash
30
+ # Get the 5 newest posts from a newsletter
31
+ substack newsletter posts https://example.substack.com --limit 5
32
+
33
+ # Search for posts
34
+ substack newsletter search https://example.substack.com "machine learning"
35
+
36
+ # Get metadata for a specific post
37
+ substack post metadata https://example.substack.com/p/my-post --pretty
38
+
39
+ # Look up a user's subscriptions
40
+ substack user subscriptions username
41
+
42
+ # Browse categories
43
+ substack categories
44
+ substack category newsletters --name Technology
45
+
46
+ # Run the quickstart guide for a full command reference
47
+ substack quickstart
48
+ ```
49
+
50
+ Use `--pretty` for human-readable output and `--cookies <path>` for authenticated access to paywalled content.
51
+
52
+ ## Python Usage Examples
26
53
 
27
54
  ### Working with Newsletters
28
55
 
@@ -0,0 +1,176 @@
1
+ # Command-Line Interface
2
+
3
+ The `substack` CLI provides terminal access to Substack newsletters, posts, users, and categories. All commands output JSON by default.
4
+
5
+ ## Global Options
6
+
7
+ | Option | Description |
8
+ |--------|-------------|
9
+ | `--pretty` | Pretty-print JSON output with indentation |
10
+ | `--cookies PATH` | Path to a cookies JSON file for authenticated access to paywalled content |
11
+
12
+ ## Newsletter Commands
13
+
14
+ ### Get posts
15
+
16
+ ```bash
17
+ substack newsletter posts <url> [--sort new|top|pinned|community] [--limit N]
18
+ ```
19
+
20
+ Returns a list of post URLs from the newsletter.
21
+
22
+ ```bash
23
+ # Get the 5 newest posts
24
+ substack newsletter posts https://example.substack.com --limit 5
25
+
26
+ # Get top posts
27
+ substack newsletter posts https://example.substack.com --sort top --limit 10
28
+ ```
29
+
30
+ ### Search posts
31
+
32
+ ```bash
33
+ substack newsletter search <url> <query> [--limit N]
34
+ ```
35
+
36
+ ```bash
37
+ substack newsletter search https://example.substack.com "machine learning" --limit 3
38
+ ```
39
+
40
+ ### Get podcasts
41
+
42
+ ```bash
43
+ substack newsletter podcasts <url> [--limit N]
44
+ ```
45
+
46
+ ### Get recommendations
47
+
48
+ ```bash
49
+ substack newsletter recs <url>
50
+ ```
51
+
52
+ Returns URLs of newsletters recommended by the given newsletter.
53
+
54
+ ### Get authors
55
+
56
+ ```bash
57
+ substack newsletter authors <url>
58
+ ```
59
+
60
+ Returns usernames of the newsletter's authors.
61
+
62
+ ## Post Commands
63
+
64
+ ### Get metadata
65
+
66
+ ```bash
67
+ substack post metadata <url>
68
+ ```
69
+
70
+ Returns the full metadata dictionary for a post.
71
+
72
+ ```bash
73
+ substack post metadata https://example.substack.com/p/my-post --pretty
74
+ ```
75
+
76
+ ### Get content
77
+
78
+ ```bash
79
+ substack post content <url>
80
+ ```
81
+
82
+ Returns the post URL and its HTML body content.
83
+
84
+ ### Check paywall status
85
+
86
+ ```bash
87
+ substack post paywalled <url>
88
+ ```
89
+
90
+ Returns whether the post is paywalled.
91
+
92
+ ## User Commands
93
+
94
+ ### Get user info
95
+
96
+ ```bash
97
+ substack user info <username>
98
+ ```
99
+
100
+ Returns the full user profile data.
101
+
102
+ ### Get subscriptions
103
+
104
+ ```bash
105
+ substack user subscriptions <username>
106
+ ```
107
+
108
+ Returns the list of newsletters the user is subscribed to.
109
+
110
+ ## Category Commands
111
+
112
+ ### List all categories
113
+
114
+ ```bash
115
+ substack categories
116
+ ```
117
+
118
+ Returns all available newsletter categories with their names and IDs.
119
+
120
+ ### Get newsletters in a category
121
+
122
+ ```bash
123
+ substack category newsletters --name <name> [--metadata]
124
+ substack category newsletters --id <id> [--metadata]
125
+ ```
126
+
127
+ Returns newsletter URLs in the category. Add `--metadata` to get full newsletter metadata instead.
128
+
129
+ ```bash
130
+ # By name
131
+ substack category newsletters --name Technology
132
+
133
+ # By ID with full metadata
134
+ substack category newsletters --id 42 --metadata --pretty
135
+ ```
136
+
137
+ ## Other Commands
138
+
139
+ ### Resolve a renamed handle
140
+
141
+ ```bash
142
+ substack resolve-handle <handle>
143
+ ```
144
+
145
+ Checks if a Substack user handle has been renamed and returns the new handle.
146
+
147
+ ### Quickstart
148
+
149
+ ```bash
150
+ substack quickstart
151
+ ```
152
+
153
+ Prints a concise reference guide covering all CLI commands and options.
154
+
155
+ ### Version
156
+
157
+ ```bash
158
+ substack version
159
+ ```
160
+
161
+ ## Authentication
162
+
163
+ To access paywalled content from the CLI, pass `--cookies` before the command:
164
+
165
+ ```bash
166
+ substack --cookies cookies.json post content https://example.substack.com/p/paid-post
167
+ substack --cookies cookies.json newsletter posts https://example.substack.com
168
+ ```
169
+
170
+ See the [Authentication Guide](authentication.md) for details on obtaining cookies.
171
+
172
+ ## Notes
173
+
174
+ - API calls include a 2-second delay to be respectful to Substack's servers. Use `--limit` to avoid long waits on large newsletters.
175
+ - All output is JSON. Pipe to `jq` for further processing, or use `--pretty` for readability.
176
+ - The CLI can also be invoked as `python -m substack_api`.
@@ -41,7 +41,8 @@ paywalled_content = authenticated_post.get_content()
41
41
 
42
42
  ## Features
43
43
 
44
- - Simple, intuitive API
44
+ - Simple, intuitive Python API
45
+ - Command-line interface for quick terminal access
45
46
  - Comprehensive access to Substack data
46
47
  - Pagination support for large collections
47
48
  - Automatic caching to minimize API calls
@@ -2,8 +2,8 @@
2
2
 
3
3
  ## Requirements
4
4
 
5
- - Python 3.7 or higher
6
- - pip (Python package installer)
5
+ - Python 3.12 or higher
6
+ - pip or uv (Python package installer)
7
7
 
8
8
  ## Install from PyPI
9
9
 
@@ -30,3 +30,13 @@ The library has minimal dependencies:
30
30
 
31
31
  These dependencies will be automatically installed when you install the package.
32
32
 
33
+ ## CLI
34
+
35
+ Installing the package also installs the `substack` command-line tool:
36
+
37
+ ```bash
38
+ substack --help
39
+ substack quickstart
40
+ ```
41
+
42
+ See the [CLI Guide](cli.md) for full documentation.
@@ -52,6 +52,7 @@ plugins:
52
52
  nav:
53
53
  - Home: index.md
54
54
  - Installation: installation.md
55
+ - CLI: cli.md
55
56
  - User Guide: user-guide.md
56
57
  - Authentication: authentication.md
57
58
  - API Reference:
@@ -22,6 +22,9 @@ dev = [
22
22
  "ruff>=0.9.9",
23
23
  ]
24
24
 
25
+ [project.scripts]
26
+ substack = "substack_api.cli:main"
27
+
25
28
  [project.urls]
26
29
  "Homepage" = "https://github.com/nhagar/substack_api"
27
30
  "Bug Tracker" = "https://github.com/nhagar/substack_api/issues"
@@ -0,0 +1,3 @@
1
+ from substack_api.cli import main
2
+
3
+ main()
@@ -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.3
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
 
@@ -9,6 +9,7 @@ uv.lock
9
9
  .github/workflows/pull_request.yml
10
10
  .github/workflows/release.yml
11
11
  docs/authentication.md
12
+ docs/cli.md
12
13
  docs/index.md
13
14
  docs/installation.md
14
15
  docs/user-guide.md
@@ -21,20 +22,24 @@ docs/api-reference/user.md
21
22
  docs/css/extra.css
22
23
  examples/usage_walkthrough.ipynb
23
24
  substack_api/__init__.py
25
+ substack_api/__main__.py
24
26
  substack_api/auth.py
25
27
  substack_api/category.py
28
+ substack_api/cli.py
26
29
  substack_api/newsletter.py
27
30
  substack_api/post.py
28
31
  substack_api/user.py
29
32
  substack_api.egg-info/PKG-INFO
30
33
  substack_api.egg-info/SOURCES.txt
31
34
  substack_api.egg-info/dependency_links.txt
35
+ substack_api.egg-info/entry_points.txt
32
36
  substack_api.egg-info/requires.txt
33
37
  substack_api.egg-info/top_level.txt
34
38
  tests/__init__.py
35
39
  tests/conftest.py
36
40
  tests/test_auth.py
37
41
  tests/test_category.py
42
+ tests/test_cli.py
38
43
  tests/test_newsletter.py
39
44
  tests/test_post.py
40
45
  tests/test_user.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ substack = substack_api.cli:main
@@ -0,0 +1,453 @@
1
+ import json
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+
6
+ from substack_api.cli import QUICKSTART_TEXT, _build_parser, main
7
+
8
+
9
+ class TestBuildParser:
10
+ def test_newsletter_posts_args(self):
11
+ parser = _build_parser()
12
+ args = parser.parse_args(
13
+ ["newsletter", "posts", "https://example.substack.com", "--limit", "5"]
14
+ )
15
+ assert args.command == "newsletter"
16
+ assert args.subcommand == "posts"
17
+ assert args.url == "https://example.substack.com"
18
+ assert args.limit == 5
19
+
20
+ def test_newsletter_posts_sort(self):
21
+ parser = _build_parser()
22
+ args = parser.parse_args(
23
+ ["newsletter", "posts", "https://example.substack.com", "--sort", "top"]
24
+ )
25
+ assert args.sort == "top"
26
+
27
+ def test_newsletter_posts_default_sort(self):
28
+ parser = _build_parser()
29
+ args = parser.parse_args(
30
+ ["newsletter", "posts", "https://example.substack.com"]
31
+ )
32
+ assert args.sort == "new"
33
+
34
+ def test_newsletter_search_args(self):
35
+ parser = _build_parser()
36
+ args = parser.parse_args(
37
+ ["newsletter", "search", "https://example.substack.com", "machine learning"]
38
+ )
39
+ assert args.subcommand == "search"
40
+ assert args.query == "machine learning"
41
+
42
+ def test_newsletter_podcasts_args(self):
43
+ parser = _build_parser()
44
+ args = parser.parse_args(
45
+ ["newsletter", "podcasts", "https://example.substack.com", "--limit", "3"]
46
+ )
47
+ assert args.subcommand == "podcasts"
48
+ assert args.limit == 3
49
+
50
+ def test_newsletter_recs_args(self):
51
+ parser = _build_parser()
52
+ args = parser.parse_args(
53
+ ["newsletter", "recs", "https://example.substack.com"]
54
+ )
55
+ assert args.subcommand == "recs"
56
+
57
+ def test_newsletter_authors_args(self):
58
+ parser = _build_parser()
59
+ args = parser.parse_args(
60
+ ["newsletter", "authors", "https://example.substack.com"]
61
+ )
62
+ assert args.subcommand == "authors"
63
+
64
+ def test_post_metadata_args(self):
65
+ parser = _build_parser()
66
+ args = parser.parse_args(
67
+ ["post", "metadata", "https://example.substack.com/p/test"]
68
+ )
69
+ assert args.command == "post"
70
+ assert args.subcommand == "metadata"
71
+ assert args.url == "https://example.substack.com/p/test"
72
+
73
+ def test_post_content_args(self):
74
+ parser = _build_parser()
75
+ args = parser.parse_args(
76
+ ["post", "content", "https://example.substack.com/p/test"]
77
+ )
78
+ assert args.subcommand == "content"
79
+
80
+ def test_post_paywalled_args(self):
81
+ parser = _build_parser()
82
+ args = parser.parse_args(
83
+ ["post", "paywalled", "https://example.substack.com/p/test"]
84
+ )
85
+ assert args.subcommand == "paywalled"
86
+
87
+ def test_user_info_args(self):
88
+ parser = _build_parser()
89
+ args = parser.parse_args(["user", "info", "testuser"])
90
+ assert args.command == "user"
91
+ assert args.subcommand == "info"
92
+ assert args.username == "testuser"
93
+
94
+ def test_user_subscriptions_args(self):
95
+ parser = _build_parser()
96
+ args = parser.parse_args(["user", "subscriptions", "testuser"])
97
+ assert args.subcommand == "subscriptions"
98
+
99
+ def test_categories_args(self):
100
+ parser = _build_parser()
101
+ args = parser.parse_args(["categories"])
102
+ assert args.command == "categories"
103
+
104
+ def test_category_newsletters_by_name(self):
105
+ parser = _build_parser()
106
+ args = parser.parse_args(
107
+ ["category", "newsletters", "--name", "Technology"]
108
+ )
109
+ assert args.command == "category"
110
+ assert args.subcommand == "newsletters"
111
+ assert args.name == "Technology"
112
+ assert args.id is None
113
+
114
+ def test_category_newsletters_by_id(self):
115
+ parser = _build_parser()
116
+ args = parser.parse_args(["category", "newsletters", "--id", "42"])
117
+ assert args.id == 42
118
+ assert args.name is None
119
+
120
+ def test_category_newsletters_metadata_flag(self):
121
+ parser = _build_parser()
122
+ args = parser.parse_args(
123
+ ["category", "newsletters", "--name", "Tech", "--metadata"]
124
+ )
125
+ assert args.metadata is True
126
+
127
+ def test_resolve_handle_args(self):
128
+ parser = _build_parser()
129
+ args = parser.parse_args(["resolve-handle", "olduser"])
130
+ assert args.command == "resolve-handle"
131
+ assert args.handle == "olduser"
132
+
133
+ def test_global_cookies_option(self):
134
+ parser = _build_parser()
135
+ args = parser.parse_args(
136
+ ["--cookies", "my_cookies.json", "newsletter", "posts", "https://x.substack.com"]
137
+ )
138
+ assert args.cookies == "my_cookies.json"
139
+
140
+ def test_global_pretty_option(self):
141
+ parser = _build_parser()
142
+ args = parser.parse_args(
143
+ ["--pretty", "categories"]
144
+ )
145
+ assert args.pretty is True
146
+
147
+ def test_quickstart_args(self):
148
+ parser = _build_parser()
149
+ args = parser.parse_args(["quickstart"])
150
+ assert args.command == "quickstart"
151
+
152
+ def test_version_args(self):
153
+ parser = _build_parser()
154
+ args = parser.parse_args(["version"])
155
+ assert args.command == "version"
156
+
157
+
158
+ class TestQuickstart:
159
+ def test_quickstart_output(self, capsys):
160
+ with patch("sys.argv", ["substack", "quickstart"]):
161
+ main()
162
+ out = capsys.readouterr().out
163
+ assert "substack newsletter posts" in out
164
+ assert "substack post metadata" in out
165
+ assert "substack user info" in out
166
+ assert "substack categories" in out
167
+ assert "--cookies" in out
168
+ assert "--pretty" in out
169
+ assert "EXAMPLES" in out
170
+
171
+ def test_quickstart_text_contains_examples(self):
172
+ assert "substack newsletter search" in QUICKSTART_TEXT
173
+ assert "substack resolve-handle" in QUICKSTART_TEXT
174
+
175
+
176
+ class TestVersion:
177
+ def test_version_output(self, capsys):
178
+ with patch("sys.argv", ["substack", "version"]):
179
+ main()
180
+ out = capsys.readouterr().out.strip()
181
+ assert len(out) > 0
182
+
183
+
184
+ class TestNewsletterCommands:
185
+ @patch("substack_api.cli.Newsletter")
186
+ def test_posts_output(self, MockNewsletter, capsys):
187
+ mock_post = MagicMock()
188
+ mock_post.url = "https://example.substack.com/p/test"
189
+ MockNewsletter.return_value.get_posts.return_value = [mock_post]
190
+
191
+ with patch("sys.argv", ["substack", "newsletter", "posts", "https://example.substack.com"]):
192
+ main()
193
+
194
+ data = json.loads(capsys.readouterr().out)
195
+ assert data == [{"url": "https://example.substack.com/p/test"}]
196
+ MockNewsletter.return_value.get_posts.assert_called_once_with(
197
+ sorting="new", limit=None
198
+ )
199
+
200
+ @patch("substack_api.cli.Newsletter")
201
+ def test_posts_with_sort_and_limit(self, MockNewsletter, capsys):
202
+ MockNewsletter.return_value.get_posts.return_value = []
203
+
204
+ with patch("sys.argv", ["substack", "newsletter", "posts", "https://x.substack.com", "--sort", "top", "--limit", "3"]):
205
+ main()
206
+
207
+ MockNewsletter.return_value.get_posts.assert_called_once_with(
208
+ sorting="top", limit=3
209
+ )
210
+
211
+ @patch("substack_api.cli.Newsletter")
212
+ def test_search_output(self, MockNewsletter, capsys):
213
+ mock_post = MagicMock()
214
+ mock_post.url = "https://example.substack.com/p/result"
215
+ MockNewsletter.return_value.search_posts.return_value = [mock_post]
216
+
217
+ with patch("sys.argv", ["substack", "newsletter", "search", "https://example.substack.com", "test query"]):
218
+ main()
219
+
220
+ data = json.loads(capsys.readouterr().out)
221
+ assert data == [{"url": "https://example.substack.com/p/result"}]
222
+ MockNewsletter.return_value.search_posts.assert_called_once_with(
223
+ query="test query", limit=None
224
+ )
225
+
226
+ @patch("substack_api.cli.Newsletter")
227
+ def test_podcasts_output(self, MockNewsletter, capsys):
228
+ MockNewsletter.return_value.get_podcasts.return_value = []
229
+
230
+ with patch("sys.argv", ["substack", "newsletter", "podcasts", "https://x.substack.com"]):
231
+ main()
232
+
233
+ data = json.loads(capsys.readouterr().out)
234
+ assert data == []
235
+
236
+ @patch("substack_api.cli.Newsletter")
237
+ def test_recs_output(self, MockNewsletter, capsys):
238
+ mock_nl = MagicMock()
239
+ mock_nl.url = "https://rec.substack.com"
240
+ MockNewsletter.return_value.get_recommendations.return_value = [mock_nl]
241
+
242
+ with patch("sys.argv", ["substack", "newsletter", "recs", "https://x.substack.com"]):
243
+ main()
244
+
245
+ data = json.loads(capsys.readouterr().out)
246
+ assert data == [{"url": "https://rec.substack.com"}]
247
+
248
+ @patch("substack_api.cli.Newsletter")
249
+ def test_authors_output(self, MockNewsletter, capsys):
250
+ mock_user = MagicMock()
251
+ mock_user.username = "author1"
252
+ MockNewsletter.return_value.get_authors.return_value = [mock_user]
253
+
254
+ with patch("sys.argv", ["substack", "newsletter", "authors", "https://x.substack.com"]):
255
+ main()
256
+
257
+ data = json.loads(capsys.readouterr().out)
258
+ assert data == [{"username": "author1"}]
259
+
260
+
261
+ class TestPostCommands:
262
+ @patch("substack_api.cli.Post")
263
+ def test_metadata_output(self, MockPost, capsys):
264
+ MockPost.return_value.get_metadata.return_value = {
265
+ "title": "Test", "id": 123
266
+ }
267
+
268
+ with patch("sys.argv", ["substack", "post", "metadata", "https://x.substack.com/p/test"]):
269
+ main()
270
+
271
+ data = json.loads(capsys.readouterr().out)
272
+ assert data == {"title": "Test", "id": 123}
273
+
274
+ @patch("substack_api.cli.Post")
275
+ def test_content_output(self, MockPost, capsys):
276
+ MockPost.return_value.url = "https://x.substack.com/p/test"
277
+ MockPost.return_value.get_content.return_value = "<p>Hello</p>"
278
+
279
+ with patch("sys.argv", ["substack", "post", "content", "https://x.substack.com/p/test"]):
280
+ main()
281
+
282
+ data = json.loads(capsys.readouterr().out)
283
+ assert data == {"url": "https://x.substack.com/p/test", "html": "<p>Hello</p>"}
284
+
285
+ @patch("substack_api.cli.Post")
286
+ def test_paywalled_output(self, MockPost, capsys):
287
+ MockPost.return_value.url = "https://x.substack.com/p/test"
288
+ MockPost.return_value.is_paywalled.return_value = True
289
+
290
+ with patch("sys.argv", ["substack", "post", "paywalled", "https://x.substack.com/p/test"]):
291
+ main()
292
+
293
+ data = json.loads(capsys.readouterr().out)
294
+ assert data == {"url": "https://x.substack.com/p/test", "paywalled": True}
295
+
296
+
297
+ class TestUserCommands:
298
+ @patch("substack_api.cli.User")
299
+ def test_info_output(self, MockUser, capsys):
300
+ MockUser.return_value.get_raw_data.return_value = {
301
+ "id": 1, "name": "Test User"
302
+ }
303
+
304
+ with patch("sys.argv", ["substack", "user", "info", "testuser"]):
305
+ main()
306
+
307
+ data = json.loads(capsys.readouterr().out)
308
+ assert data == {"id": 1, "name": "Test User"}
309
+
310
+ @patch("substack_api.cli.User")
311
+ def test_subscriptions_output(self, MockUser, capsys):
312
+ MockUser.return_value.get_subscriptions.return_value = [
313
+ {"publication_name": "Test", "domain": "test.substack.com"}
314
+ ]
315
+
316
+ with patch("sys.argv", ["substack", "user", "subscriptions", "testuser"]):
317
+ main()
318
+
319
+ data = json.loads(capsys.readouterr().out)
320
+ assert len(data) == 1
321
+ assert data[0]["publication_name"] == "Test"
322
+
323
+
324
+ class TestCategoryCommands:
325
+ @patch("substack_api.cli.list_all_categories")
326
+ def test_categories_output(self, mock_list, capsys):
327
+ mock_list.return_value = [("Technology", 1), ("Culture", 2)]
328
+
329
+ with patch("sys.argv", ["substack", "categories"]):
330
+ main()
331
+
332
+ data = json.loads(capsys.readouterr().out)
333
+ assert data == [{"name": "Technology", "id": 1}, {"name": "Culture", "id": 2}]
334
+
335
+ @patch("substack_api.cli.Category")
336
+ def test_category_newsletters_urls(self, MockCategory, capsys):
337
+ MockCategory.return_value.get_newsletter_urls.return_value = [
338
+ "https://a.substack.com", "https://b.substack.com"
339
+ ]
340
+
341
+ with patch("sys.argv", ["substack", "category", "newsletters", "--name", "Tech"]):
342
+ main()
343
+
344
+ data = json.loads(capsys.readouterr().out)
345
+ assert data == ["https://a.substack.com", "https://b.substack.com"]
346
+
347
+ @patch("substack_api.cli.Category")
348
+ def test_category_newsletters_metadata(self, MockCategory, capsys):
349
+ MockCategory.return_value.get_newsletter_metadata.return_value = [
350
+ {"name": "A", "url": "https://a.substack.com"}
351
+ ]
352
+
353
+ with patch("sys.argv", ["substack", "category", "newsletters", "--name", "Tech", "--metadata"]):
354
+ main()
355
+
356
+ data = json.loads(capsys.readouterr().out)
357
+ assert data == [{"name": "A", "url": "https://a.substack.com"}]
358
+
359
+ @patch("substack_api.cli.Category")
360
+ def test_category_newsletters_by_id(self, MockCategory, capsys):
361
+ MockCategory.return_value.get_newsletter_urls.return_value = []
362
+
363
+ with patch("sys.argv", ["substack", "category", "newsletters", "--id", "42"]):
364
+ main()
365
+
366
+ MockCategory.assert_called_once_with(name=None, id=42)
367
+
368
+
369
+ class TestResolveHandle:
370
+ @patch("substack_api.cli.resolve_handle_redirect")
371
+ def test_resolve_found(self, mock_resolve, capsys):
372
+ mock_resolve.return_value = "newuser"
373
+
374
+ with patch("sys.argv", ["substack", "resolve-handle", "olduser"]):
375
+ main()
376
+
377
+ data = json.loads(capsys.readouterr().out)
378
+ assert data == {"old_handle": "olduser", "new_handle": "newuser"}
379
+
380
+ @patch("substack_api.cli.resolve_handle_redirect")
381
+ def test_resolve_not_found(self, mock_resolve, capsys):
382
+ mock_resolve.return_value = None
383
+
384
+ with patch("sys.argv", ["substack", "resolve-handle", "sameuser"]):
385
+ main()
386
+
387
+ data = json.loads(capsys.readouterr().out)
388
+ assert data == {"old_handle": "sameuser", "new_handle": None}
389
+
390
+
391
+ class TestPrettyOutput:
392
+ @patch("substack_api.cli.list_all_categories")
393
+ def test_pretty_flag(self, mock_list, capsys):
394
+ mock_list.return_value = [("Tech", 1)]
395
+
396
+ with patch("sys.argv", ["substack", "--pretty", "categories"]):
397
+ main()
398
+
399
+ out = capsys.readouterr().out
400
+ # Pretty output has indentation
401
+ assert " " in out
402
+
403
+
404
+ class TestErrorHandling:
405
+ def test_no_command_shows_help(self, capsys):
406
+ with patch("sys.argv", ["substack"]):
407
+ with pytest.raises(SystemExit) as exc_info:
408
+ main()
409
+ assert exc_info.value.code == 1
410
+
411
+ def test_newsletter_no_subcommand(self, capsys):
412
+ with patch("sys.argv", ["substack", "newsletter"]):
413
+ with pytest.raises(SystemExit) as exc_info:
414
+ main()
415
+ assert exc_info.value.code == 1
416
+
417
+ def test_post_no_subcommand(self, capsys):
418
+ with patch("sys.argv", ["substack", "post"]):
419
+ with pytest.raises(SystemExit) as exc_info:
420
+ main()
421
+ assert exc_info.value.code == 1
422
+
423
+ def test_user_no_subcommand(self, capsys):
424
+ with patch("sys.argv", ["substack", "user"]):
425
+ with pytest.raises(SystemExit) as exc_info:
426
+ main()
427
+ assert exc_info.value.code == 1
428
+
429
+ @patch("substack_api.cli.Newsletter")
430
+ def test_api_error_exits_with_1(self, MockNewsletter, capsys):
431
+ MockNewsletter.return_value.get_posts.side_effect = Exception("API Error")
432
+
433
+ with patch("sys.argv", ["substack", "newsletter", "posts", "https://x.substack.com"]):
434
+ with pytest.raises(SystemExit) as exc_info:
435
+ main()
436
+ assert exc_info.value.code == 1
437
+
438
+ assert "Error" in capsys.readouterr().err
439
+
440
+
441
+ class TestAuthIntegration:
442
+ @patch("substack_api.cli.Post")
443
+ @patch("substack_api.cli.SubstackAuth")
444
+ def test_cookies_passed_to_post(self, MockAuth, MockPost, capsys):
445
+ mock_auth = MagicMock()
446
+ MockAuth.return_value = mock_auth
447
+ MockPost.return_value.get_metadata.return_value = {"title": "Test"}
448
+
449
+ with patch("sys.argv", ["substack", "--cookies", "cookies.json", "post", "metadata", "https://x.substack.com/p/test"]):
450
+ main()
451
+
452
+ MockAuth.assert_called_once_with("cookies.json")
453
+ MockPost.assert_called_once_with("https://x.substack.com/p/test", auth=mock_auth)
File without changes
File without changes
File without changes
File without changes