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.
- {substack_api-1.1.3 → substack_api-1.2.0}/PKG-INFO +31 -4
- {substack_api-1.1.3 → substack_api-1.2.0}/README.md +30 -3
- substack_api-1.2.0/docs/cli.md +176 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/index.md +2 -1
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/installation.md +12 -2
- {substack_api-1.1.3 → substack_api-1.2.0}/mkdocs.yml +1 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/pyproject.toml +3 -0
- substack_api-1.2.0/substack_api/__main__.py +3 -0
- substack_api-1.2.0/substack_api/cli.py +301 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/PKG-INFO +31 -4
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/SOURCES.txt +5 -0
- substack_api-1.2.0/substack_api.egg-info/entry_points.txt +2 -0
- substack_api-1.2.0/tests/test_cli.py +453 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/.github/workflows/docs.yml +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/.github/workflows/pull_request.yml +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/.github/workflows/release.yml +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/.gitignore +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/.python-version +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/LICENSE +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/auth.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/category.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/index.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/newsletter.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/post.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/api-reference/user.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/authentication.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/css/extra.css +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/docs/user-guide.md +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/examples/usage_walkthrough.ipynb +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/setup.cfg +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/__init__.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/auth.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/category.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/newsletter.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/post.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api/user.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/dependency_links.txt +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/requires.txt +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/substack_api.egg-info/top_level.txt +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/__init__.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/conftest.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_auth.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_category.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_newsletter.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_post.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_user.py +0 -0
- {substack_api-1.1.3 → substack_api-1.2.0}/tests/test_user_redirects.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
|
22
|
-
|
|
21
|
+
# Using uv
|
|
22
|
+
uv add substack-api
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
##
|
|
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.
|
|
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.
|
|
@@ -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
|
|
|
@@ -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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|