substack-api 1.1.2__tar.gz → 1.1.3__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 (45) hide show
  1. substack_api-1.1.3/.github/workflows/docs.yml +54 -0
  2. substack_api-1.1.3/.github/workflows/pull_request.yml +42 -0
  3. substack_api-1.1.3/.github/workflows/release.yml +51 -0
  4. substack_api-1.1.3/.gitignore +9 -0
  5. substack_api-1.1.3/.python-version +1 -0
  6. {substack_api-1.1.2/substack_api.egg-info → substack_api-1.1.3}/PKG-INFO +1 -1
  7. substack_api-1.1.3/docs/api-reference/auth.md +163 -0
  8. substack_api-1.1.3/docs/api-reference/category.md +118 -0
  9. substack_api-1.1.3/docs/api-reference/index.md +37 -0
  10. substack_api-1.1.3/docs/api-reference/newsletter.md +130 -0
  11. substack_api-1.1.3/docs/api-reference/post.md +93 -0
  12. substack_api-1.1.3/docs/api-reference/user.md +131 -0
  13. substack_api-1.1.3/docs/authentication.md +172 -0
  14. substack_api-1.1.3/docs/css/extra.css +16 -0
  15. substack_api-1.1.3/docs/index.md +52 -0
  16. substack_api-1.1.3/docs/installation.md +32 -0
  17. substack_api-1.1.3/docs/user-guide.md +191 -0
  18. substack_api-1.1.3/examples/usage_walkthrough.ipynb +4447 -0
  19. substack_api-1.1.3/mkdocs.yml +77 -0
  20. {substack_api-1.1.2 → substack_api-1.1.3}/pyproject.toml +10 -1
  21. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api/__init__.py +9 -0
  22. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api/newsletter.py +92 -29
  23. {substack_api-1.1.2 → substack_api-1.1.3/substack_api.egg-info}/PKG-INFO +1 -1
  24. substack_api-1.1.3/substack_api.egg-info/SOURCES.txt +41 -0
  25. substack_api-1.1.3/tests/__init__.py +0 -0
  26. substack_api-1.1.3/tests/conftest.py +55 -0
  27. substack_api-1.1.3/tests/test_newsletter.py +611 -0
  28. substack_api-1.1.3/uv.lock +1118 -0
  29. substack_api-1.1.2/substack_api.egg-info/SOURCES.txt +0 -20
  30. substack_api-1.1.2/tests/test_newsletter.py +0 -300
  31. {substack_api-1.1.2 → substack_api-1.1.3}/LICENSE +0 -0
  32. {substack_api-1.1.2 → substack_api-1.1.3}/README.md +0 -0
  33. {substack_api-1.1.2 → substack_api-1.1.3}/setup.cfg +0 -0
  34. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api/auth.py +0 -0
  35. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api/category.py +0 -0
  36. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api/post.py +0 -0
  37. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api/user.py +0 -0
  38. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api.egg-info/dependency_links.txt +0 -0
  39. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api.egg-info/requires.txt +0 -0
  40. {substack_api-1.1.2 → substack_api-1.1.3}/substack_api.egg-info/top_level.txt +0 -0
  41. {substack_api-1.1.2 → substack_api-1.1.3}/tests/test_auth.py +0 -0
  42. {substack_api-1.1.2 → substack_api-1.1.3}/tests/test_category.py +0 -0
  43. {substack_api-1.1.2 → substack_api-1.1.3}/tests/test_post.py +0 -0
  44. {substack_api-1.1.2 → substack_api-1.1.3}/tests/test_user.py +0 -0
  45. {substack_api-1.1.2 → substack_api-1.1.3}/tests/test_user_redirects.py +0 -0
@@ -0,0 +1,54 @@
1
+ name: Deploy Documentation
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+
11
+ jobs:
12
+ deploy:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Checkout repository
16
+ uses: actions/checkout@v3
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v4
22
+ with:
23
+ python-version: '3.12'
24
+
25
+ - name: Install uv
26
+ run: |
27
+ curl -LsSf https://astral.sh/uv/install.sh | sh
28
+ echo "$HOME/.cargo/bin" >> $GITHUB_PATH
29
+
30
+ - name: Setup uv cache
31
+ uses: actions/cache@v3
32
+ with:
33
+ path: ~/.cache/uv
34
+ key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }}
35
+ restore-keys: |
36
+ ${{ runner.os }}-uv-
37
+
38
+ - name: Install dependencies
39
+ run: |
40
+ uv sync
41
+
42
+ - name: Configure Git user
43
+ run: |
44
+ git config --global user.name "github-actions[bot]"
45
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
46
+
47
+ - name: Deploy documentation
48
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
49
+ run: |
50
+ uv run mkdocs gh-deploy --force
51
+
52
+ - name: Build documentation (PR)
53
+ if: github.event_name == 'pull_request'
54
+ run: uv run mkdocs build
@@ -0,0 +1,42 @@
1
+ name: Pull Request
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [ master ]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - uses: actions/checkout@v3
13
+
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v4
16
+ with:
17
+ python-version: '3.12'
18
+
19
+ - name: Install uv
20
+ run: |
21
+ curl -LsSf https://astral.sh/uv/install.sh | sh
22
+ echo "$HOME/.cargo/bin" >> $GITHUB_PATH
23
+
24
+ - name: Setup uv cache
25
+ uses: actions/cache@v3
26
+ with:
27
+ path: ~/.cache/uv
28
+ key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }}
29
+ restore-keys: |
30
+ ${{ runner.os }}-uv-
31
+
32
+ - name: Install dependencies
33
+ run: |
34
+ uv sync
35
+
36
+ - name: Lint with ruff
37
+ run: |
38
+ uv run ruff check .
39
+
40
+ - name: Test with pytest
41
+ run: |
42
+ uv run pytest
@@ -0,0 +1,51 @@
1
+ name: Python package
2
+ on:
3
+ push:
4
+ tags:
5
+ - "v*.*.*"
6
+ jobs:
7
+ build:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - name: Checkout repository
11
+ uses: actions/checkout@v3
12
+ with:
13
+ fetch-depth: 0
14
+
15
+ - name: Set up Python
16
+ uses: actions/setup-python@v4
17
+ with:
18
+ python-version: '3.12'
19
+
20
+ - name: Install uv
21
+ run: |
22
+ curl -LsSf https://astral.sh/uv/install.sh | sh
23
+ echo "$HOME/.cargo/bin" >> $GITHUB_PATH
24
+
25
+ - name: Setup uv cache
26
+ uses: actions/cache@v3
27
+ with:
28
+ path: ~/.cache/uv
29
+ key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }}
30
+ restore-keys: |
31
+ ${{ runner.os }}-uv-
32
+
33
+ - name: Install dependencies
34
+ run: |
35
+ uv sync
36
+ uv pip install build twine
37
+
38
+ - name: Extract release version
39
+ id: version
40
+ run: |
41
+ TAG="${GITHUB_REF_NAME}"
42
+ echo "value=${TAG#v}" >> "$GITHUB_OUTPUT"
43
+
44
+ - name: Build and publish
45
+ env:
46
+ TWINE_USERNAME: __token__
47
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_KEY }}
48
+ SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.version.outputs.value }}
49
+ run: |
50
+ uv run python -m build
51
+ uv run twine upload dist/*
@@ -0,0 +1,9 @@
1
+ .conda/
2
+ __pycache__/
3
+ dist/
4
+ .env
5
+ .vscode/
6
+ .DS_Store
7
+ *.json
8
+ substack_api.egg-info/
9
+ .coverage
@@ -0,0 +1 @@
1
+ 3.12
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substack-api
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Unofficial wrapper for the Substack API
5
5
  Project-URL: Homepage, https://github.com/nhagar/substack_api
6
6
  Project-URL: Bug Tracker, https://github.com/nhagar/substack_api/issues
@@ -0,0 +1,163 @@
1
+ # SubstackAuth
2
+
3
+ The `SubstackAuth` class handles authentication for accessing paywalled Substack content.
4
+
5
+ ## Class Definition
6
+
7
+ ```python
8
+ SubstackAuth(cookies_path: str)
9
+ ```
10
+
11
+ ### Parameters
12
+
13
+ - `cookies_path` (str): Path to the JSON file containing session cookies
14
+
15
+ ## Properties
16
+
17
+ ### `authenticated` (bool)
18
+ Whether the authentication was successful and cookies were loaded.
19
+
20
+ ### `cookies_path` (str)
21
+ Path to the cookies file.
22
+
23
+ ### `session` (requests.Session)
24
+ The authenticated requests session object.
25
+
26
+ ## Methods
27
+
28
+ ### `load_cookies() -> bool`
29
+
30
+ Load cookies from the specified file.
31
+
32
+ #### Returns
33
+
34
+ - `bool`: True if cookies were loaded successfully, False otherwise
35
+
36
+ ### `get(url: str, **kwargs) -> requests.Response`
37
+
38
+ Make an authenticated GET request.
39
+
40
+ #### Parameters
41
+
42
+ - `url` (str): The URL to request
43
+ - `**kwargs`: Additional arguments passed to requests.get
44
+
45
+ #### Returns
46
+
47
+ - `requests.Response`: The response object
48
+
49
+ ### `post(url: str, **kwargs) -> requests.Response`
50
+
51
+ Make an authenticated POST request.
52
+
53
+ #### Parameters
54
+
55
+ - `url` (str): The URL to request
56
+ - `**kwargs`: Additional arguments passed to requests.post
57
+
58
+ #### Returns
59
+
60
+ - `requests.Response`: The response object
61
+
62
+ ## Example Usage
63
+
64
+ ### Basic Authentication Setup
65
+
66
+ ```python
67
+ from substack_api import SubstackAuth
68
+
69
+ # Initialize with cookies file
70
+ auth = SubstackAuth(cookies_path="my_cookies.json")
71
+
72
+ # Check if authentication succeeded
73
+ if auth.authenticated:
74
+ print("Successfully authenticated!")
75
+ else:
76
+ print("Authentication failed")
77
+ ```
78
+
79
+ ### Using with Newsletter and Post Classes
80
+
81
+ ```python
82
+ from substack_api import Newsletter, Post, SubstackAuth
83
+
84
+ # Set up authentication
85
+ auth = SubstackAuth(cookies_path="cookies.json")
86
+
87
+ # Use with Newsletter
88
+ newsletter = Newsletter("https://example.substack.com", auth=auth)
89
+ posts = newsletter.get_posts(limit=5)
90
+
91
+ # Use with Post
92
+ post = Post("https://example.substack.com/p/paywalled-post", auth=auth)
93
+ content = post.get_content()
94
+ ```
95
+
96
+ ### Manual Authenticated Requests
97
+
98
+ ```python
99
+ from substack_api import SubstackAuth
100
+
101
+ auth = SubstackAuth(cookies_path="cookies.json")
102
+
103
+ # Make authenticated GET request
104
+ response = auth.get("https://example.substack.com/api/v1/posts/123")
105
+ data = response.json()
106
+
107
+ # Make authenticated POST request
108
+ response = auth.post(
109
+ "https://example.substack.com/api/v1/some-endpoint",
110
+ json={"key": "value"}
111
+ )
112
+ ```
113
+
114
+ ## Cookie File Format
115
+
116
+ The cookies file should be in JSON format with the following structure:
117
+
118
+ ```json
119
+ [
120
+ {
121
+ "name": "substack.sid",
122
+ "value": "your_session_id",
123
+ "domain": ".substack.com",
124
+ "path": "/",
125
+ "secure": true
126
+ },
127
+ {
128
+ "name": "substack.lli",
129
+ "value": "your_lli_value",
130
+ "domain": ".substack.com",
131
+ "path": "/",
132
+ "secure": true
133
+ },
134
+ ...
135
+ ]
136
+ ```
137
+
138
+ ## Error Handling
139
+
140
+ The `SubstackAuth` class handles several error conditions:
141
+
142
+ - **File not found**: If the cookies file doesn't exist, `authenticated` will be `False`
143
+ - **Invalid JSON**: If the cookies file contains invalid JSON, `load_cookies()` returns `False`
144
+ - **Missing cookies**: If required cookies are missing, authentication may fail silently
145
+
146
+ ```python
147
+ from substack_api import SubstackAuth
148
+
149
+ try:
150
+ auth = SubstackAuth(cookies_path="cookies.json")
151
+ if not auth.authenticated:
152
+ print("Authentication failed - check your cookies file")
153
+ except Exception as e:
154
+ print(f"Error setting up authentication: {e}")
155
+ ```
156
+
157
+ ## Security Notes
158
+
159
+ - Keep your cookies file secure and private
160
+ - Don't commit cookies files to version control
161
+ - Only use your own session cookies
162
+ - Cookies may expire and need to be refreshed periodically
163
+ - Respect Substack's Terms of Service when using authentication
@@ -0,0 +1,118 @@
1
+ # Category
2
+
3
+ The `Category` class provides access to Substack newsletter categories.
4
+
5
+ ## Module Functions
6
+
7
+ ### `list_all_categories() -> List[Tuple[str, int]]`
8
+
9
+ Get name/id representations of all newsletter categories.
10
+
11
+ #### Returns
12
+
13
+ - `List[Tuple[str, int]]`: List of tuples containing (category_name, category_id)
14
+
15
+ ## Class Definition
16
+
17
+ ```python
18
+ Category(name: Optional[str] = None, id: Optional[int] = None)
19
+ ```
20
+
21
+ ### Parameters
22
+
23
+ - `name` (Optional[str]): The name of the category
24
+ - `id` (Optional[int]): The ID of the category
25
+
26
+ ### Raises
27
+
28
+ - `ValueError`: If neither name nor id is provided, or if the provided name/id is not found
29
+
30
+ ## Methods
31
+
32
+ ### `_get_id_from_name() -> None`
33
+
34
+ Lookup category ID based on name.
35
+
36
+ #### Raises
37
+
38
+ - `ValueError`: If the category name is not found
39
+
40
+ ### `_get_name_from_id() -> None`
41
+
42
+ Lookup category name based on ID.
43
+
44
+ #### Raises
45
+
46
+ - `ValueError`: If the category ID is not found
47
+
48
+ ### `_fetch_newsletters_data(force_refresh: bool = False) -> List[Dict[str, Any]]`
49
+
50
+ Fetch the raw newsletter data from the API and cache it.
51
+
52
+ #### Parameters
53
+
54
+ - `force_refresh` (bool): Whether to force a refresh of the data, ignoring the cache
55
+
56
+ #### Returns
57
+
58
+ - `List[Dict[str, Any]]`: Full newsletter metadata
59
+
60
+ ### `get_newsletter_urls() -> List[str]`
61
+
62
+ Get only the URLs of newsletters in this category.
63
+
64
+ #### Returns
65
+
66
+ - `List[str]`: List of newsletter URLs
67
+
68
+ ### `get_newsletters() -> List[Newsletter]`
69
+
70
+ Get Newsletter objects for all newsletters in this category.
71
+
72
+ #### Returns
73
+
74
+ - `List[Newsletter]`: List of Newsletter objects
75
+
76
+ ### `get_newsletter_metadata() -> List[Dict[str, Any]]`
77
+
78
+ Get full metadata for all newsletters in this category.
79
+
80
+ #### Returns
81
+
82
+ - `List[Dict[str, Any]]`: List of newsletter metadata dictionaries
83
+
84
+ ### `refresh_data() -> None`
85
+
86
+ Force refresh of the newsletter data cache.
87
+
88
+ ## Example Usage
89
+
90
+ ```python
91
+ from substack_api import Category
92
+ from substack_api.category import list_all_categories
93
+
94
+ # List all available categories
95
+ categories = list_all_categories()
96
+ print("Available categories:")
97
+ for name, id in categories:
98
+ print(f"- {name} (ID: {id})")
99
+
100
+ # Create a category object by name
101
+ tech_category = Category(name="Technology")
102
+ print(f"Selected category: {tech_category}")
103
+
104
+ # Get newsletters in this category
105
+ newsletters = tech_category.get_newsletters()
106
+ print(f"Found {len(newsletters)} newsletters in {tech_category.name} category")
107
+
108
+ # Print the first 5 newsletters
109
+ for i, newsletter in enumerate(newsletters[:5]):
110
+ print(f"{i+1}. {newsletter.url}")
111
+
112
+ # Get detailed metadata
113
+ metadata = tech_category.get_newsletter_metadata()
114
+ for entry in metadata[:3]:
115
+ print(f"Newsletter ID: {entry['id']}")
116
+ print(f"Description: {entry.get('description', 'No description')[:100]}...")
117
+ print("-" * 40)
118
+ ```
@@ -0,0 +1,37 @@
1
+ # API Reference
2
+
3
+ This section provides detailed documentation for all modules and classes in the Substack API library.
4
+
5
+ ## Modules
6
+
7
+ - [User](user.md): Access to Substack user profiles and subscriptions
8
+ - [Newsletter](newsletter.md): Access to Substack publications, posts, and podcasts
9
+ - [Post](post.md): Access to individual Substack post content and metadata
10
+ - [Category](category.md): Discovery of newsletters by category
11
+ - [SubstackAuth](auth.md): Authentication for accessing paywalled content
12
+
13
+ Each module documentation includes:
14
+
15
+ - Class properties and methods
16
+ - Method parameters
17
+ - Return types
18
+ - Example usage
19
+
20
+ ## Common Patterns
21
+
22
+ Most classes in the library follow these patterns:
23
+
24
+ 1. **Initialization**: Create an object by providing an identifier (URL, username, etc.)
25
+ 2. **Data Retrieval**: Methods that fetch data from the Substack API
26
+ 3. **Caching**: Data is cached to avoid unnecessary API requests
27
+ 4. **Force Refresh**: Most methods accept a `force_refresh` parameter to bypass the cache
28
+
29
+ ## Error Handling
30
+
31
+ The library uses standard Python exceptions:
32
+
33
+ - `requests.exceptions.HTTPError`: Raised when an HTTP request fails
34
+ - `ValueError`: Raised when invalid parameters are provided
35
+ - `KeyError`: Raised when expected data is not found in the API response
36
+
37
+ You should wrap API calls in try/except blocks to handle these exceptions gracefully.
@@ -0,0 +1,130 @@
1
+ # Newsletter
2
+
3
+ The `Newsletter` class provides access to Substack publications.
4
+
5
+ ## Class Definition
6
+
7
+ ```python
8
+ Newsletter(url: str, auth: Optional[SubstackAuth] = None)
9
+ ```
10
+
11
+ ### Parameters
12
+
13
+ - `url` (str): The URL of the Substack newsletter
14
+ - `auth` (Optional[SubstackAuth]): Authentication handler for accessing paywalled content
15
+
16
+ ## Methods
17
+
18
+ ### `_fetch_paginated_posts(params: Dict[str, str], limit: Optional[int] = None, page_size: int = 15) -> List[Dict[str, Any]]`
19
+
20
+ Helper method to fetch paginated posts with different query parameters.
21
+
22
+ #### Parameters
23
+
24
+ - `params` (Dict[str, str]): Dictionary of query parameters to include in the API request
25
+ - `limit` (Optional[int]): Maximum number of posts to return
26
+ - `page_size` (int): Number of posts to retrieve per page request
27
+
28
+ #### Returns
29
+
30
+ - `List[Dict[str, Any]]`: List of post data dictionaries
31
+
32
+ ### `get_posts(sorting: str = "new", limit: Optional[int] = None) -> List[Post]`
33
+
34
+ Get posts from the newsletter with specified sorting.
35
+
36
+ #### Parameters
37
+
38
+ - `sorting` (str): Sorting order for the posts ("new", "top", "pinned", or "community")
39
+ - `limit` (Optional[int]): Maximum number of posts to return
40
+
41
+ #### Returns
42
+
43
+ - `List[Post]`: List of Post objects
44
+
45
+ ### `search_posts(query: str, limit: Optional[int] = None) -> List[Post]`
46
+
47
+ Search posts in the newsletter with the given query.
48
+
49
+ #### Parameters
50
+
51
+ - `query` (str): Search query string
52
+ - `limit` (Optional[int]): Maximum number of posts to return
53
+
54
+ #### Returns
55
+
56
+ - `List[Post]`: List of Post objects matching the search query
57
+
58
+ ### `get_podcasts(limit: Optional[int] = None) -> List[Post]`
59
+
60
+ Get podcast posts from the newsletter.
61
+
62
+ #### Parameters
63
+
64
+ - `limit` (Optional[int]): Maximum number of podcast posts to return
65
+
66
+ #### Returns
67
+
68
+ - `List[Post]`: List of Post objects representing podcast posts
69
+
70
+ ### `get_recommendations() -> List[Newsletter]`
71
+
72
+ Get recommended publications for this newsletter.
73
+
74
+ #### Returns
75
+
76
+ - `List[Newsletter]`: List of recommended Newsletter objects
77
+
78
+ ### `get_authors() -> List[User]`
79
+
80
+ Get authors of the newsletter.
81
+
82
+ #### Returns
83
+
84
+ - `List[User]`: List of User objects representing the authors
85
+
86
+ ## Example Usage
87
+
88
+ ```python
89
+ from substack_api import Newsletter, SubstackAuth
90
+
91
+ # Create a newsletter object
92
+ newsletter = Newsletter("https://example.substack.com")
93
+
94
+ # Get recent posts
95
+ recent_posts = newsletter.get_posts(limit=5)
96
+ for post in recent_posts:
97
+ metadata = post.get_metadata()
98
+ print(f"Post: {metadata['title']}")
99
+
100
+ # Search for posts on a specific topic
101
+ search_results = newsletter.search_posts("machine learning", limit=3)
102
+ for post in search_results:
103
+ metadata = post.get_metadata()
104
+ print(f"Found: {metadata['title']}")
105
+
106
+ # Get podcast episodes
107
+ podcasts = newsletter.get_podcasts(limit=2)
108
+ for podcast in podcasts:
109
+ metadata = podcast.get_metadata()
110
+ print(f"Podcast: {metadata['title']}")
111
+
112
+ # Get newsletter authors
113
+ authors = newsletter.get_authors()
114
+ for author in authors:
115
+ print(f"Author: {author.name}")
116
+
117
+ # Get recommended newsletters
118
+ recommendations = newsletter.get_recommendations()
119
+ for rec in recommendations:
120
+ print(f"Recommended: {rec.url}")
121
+
122
+ # Use with authentication for paywalled content
123
+ auth = SubstackAuth(cookies_path="cookies.json")
124
+ authenticated_newsletter = Newsletter("https://example.substack.com", auth=auth)
125
+ paywalled_posts = authenticated_newsletter.get_posts(limit=5)
126
+ for post in paywalled_posts:
127
+ if post.is_paywalled():
128
+ content = post.get_content() # Now accessible with auth
129
+ print(f"Paywalled content: {content[:100]}...")
130
+ ```