From 000d4293bd935d1067f6e4c6afda75b46ea47ab6 Mon Sep 17 00:00:00 2001 From: Nick Hagar Date: Sun, 2 Nov 2025 18:52:36 -0600 Subject: [PATCH 1/3] updated recs approach --- substack_api/newsletter.py | 109 ++++++++++++++++++++++++++----------- uv.lock | 4 +- 2 files changed, 80 insertions(+), 33 deletions(-) diff --git a/substack_api/newsletter.py b/substack_api/newsletter.py index 1377a03..b14c6d0 100644 --- a/substack_api/newsletter.py +++ b/substack_api/newsletter.py @@ -1,3 +1,5 @@ +import re +import urllib.parse from time import sleep from typing import Any, Dict, List, Optional @@ -10,6 +12,43 @@ } +SEARCH_URL = "https://substack.com/api/v1/publication/search" + +DISCOVERY_HEADERS = { + "User-Agent": HEADERS["User-Agent"], + "Accept": "application/json", + "Origin": "https://substack.com", + "Referer": "https://substack.com/discover", +} + + +def _host_from_url(url: str) -> str: + host = urllib.parse.urlparse( + url if "://" in url else f"https://{url}" + ).netloc.lower() + return host + + +def _match_publication(search_results: dict, host: str) -> Optional[dict]: + # Try exact custom domain, then subdomain match + for item in search_results.get("publications", []): + if ( + item.get("custom_domain") and _host_from_url(item["custom_domain"]) == host + ) or ( + item.get("subdomain") + and f"{item['subdomain'].lower()}.substack.com" == host + ): + return item + # Fallback: loose match on subdomain token + m = re.match(r"^([a-z0-9-]+)\.substack\.com$", host) + if m: + sub = m.group(1) + for item in search_results.get("publications", []): + if item.get("subdomain", "").lower() == sub: + return item + return None + + class Newsletter: """ Newsletter class for interacting with Substack newsletters @@ -183,49 +222,57 @@ def get_podcasts(self, limit: Optional[int] = None) -> List: post_data = self._fetch_paginated_posts(params, limit) return [Post(item["canonical_url"], auth=self.auth) for item in post_data] + def _resolve_publication_id(self) -> Optional[int]: + """Resolve publication_id via Substack discovery search—no posts needed.""" + host = _host_from_url(self.url) + q = host.split(":")[0] # strip port if present + params = { + "query": q, + "page": 0, + "limit": 25, + "skipExplanation": "true", + "sort": "relevance", + } + r = requests.get( + SEARCH_URL, headers=DISCOVERY_HEADERS, params=params, timeout=30 + ) + r.raise_for_status() + match = _match_publication(r.json(), host) + return match.get("id") if match else None + def get_recommendations(self) -> List["Newsletter"]: """ - Get recommended publications for this newsletter - - Returns - ------- - List[Newsletter] - List of recommended Newsletter objects + Get recommended publications without relying on the latest post. """ - # First get any post to extract the publication ID - posts = self.get_posts(limit=1) - if not posts: + publication_id = self._resolve_publication_id() + if not publication_id: + # graceful fallback to your existing (post-derived) path + try: + posts = self.get_posts(limit=1) + publication_id = ( + posts[0].get_metadata()["publication_id"] if posts else None + ) + except Exception: + publication_id = None + if not publication_id: return [] - publication_id = posts[0].get_metadata()["publication_id"] - - # Now get the recommendations endpoint = f"{self.url}/api/v1/recommendations/from/{publication_id}" response = self._make_request(endpoint, timeout=30) response.raise_for_status() + recommendations = response.json() or [] - recommendations = response.json() - if not recommendations: - return [] - - recommended_newsletter_urls = [] + urls = [] for rec in recommendations: - recpub = rec["recommendedPublication"] - if "custom_domain" in recpub and recpub["custom_domain"]: - recommended_newsletter_urls.append(recpub["custom_domain"]) - else: - recommended_newsletter_urls.append( - f"{recpub['subdomain']}.substack.com" - ) - - # Avoid circular import - from .newsletter import Newsletter + pub = rec.get("recommendedPublication", {}) + if pub.get("custom_domain"): + urls.append(pub["custom_domain"]) + elif pub.get("subdomain"): + urls.append(f"{pub['subdomain']}.substack.com") - result = [ - Newsletter(url, auth=self.auth) for url in recommended_newsletter_urls - ] + from .newsletter import Newsletter # avoid circular import - return result + return [Newsletter(u, auth=self.auth) for u in urls] def get_authors(self) -> List: """ diff --git a/uv.lock b/uv.lock index d16a01e..124782b 100644 --- a/uv.lock +++ b/uv.lock @@ -909,8 +909,8 @@ wheels = [ [[package]] name = "substack-api" -version = "1.0.2" -source = { virtual = "." } +version = "1.1.3.dev1+g65e2d0a92.d20251103" +source = { editable = "." } dependencies = [ { name = "requests" }, ] From d40ffd788b832cdac9612ef9778bf26615448efa Mon Sep 17 00:00:00 2001 From: Nick Hagar Date: Sun, 2 Nov 2025 18:59:13 -0600 Subject: [PATCH 2/3] tests --- .gitignore | 4 +- pyproject.toml | 1 + tests/test_newsletter.py | 399 ++++++++++++++++++++++++++++++++++----- uv.lock | 92 ++++++++- 4 files changed, 450 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index b36d575..1fb63da 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ dist/ .env .vscode/ .DS_Store -*.json \ No newline at end of file +*.json +substack_api.egg-info/ +.coverage \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 63da7ec..21f4709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dev = [ "mkdocs-material>=9.6.6", "mkdocstrings-python>=1.16.2", "pytest>=8.3.4", + "pytest-cov>=7.0.0", "ruff>=0.9.9", ] diff --git a/tests/test_newsletter.py b/tests/test_newsletter.py index 070bce8..1762ca4 100644 --- a/tests/test_newsletter.py +++ b/tests/test_newsletter.py @@ -3,7 +3,13 @@ import pytest from substack_api import Newsletter, User -from substack_api.newsletter import HEADERS +from substack_api.newsletter import ( + DISCOVERY_HEADERS, + HEADERS, + SEARCH_URL, + _host_from_url, + _match_publication, +) @pytest.fixture @@ -54,6 +60,26 @@ def mock_authors(): ] +@pytest.fixture +def mock_search_result(): + return { + "publications": [ + { + "id": 123, + "subdomain": "testblog", + "custom_domain": None, + "name": "Test Blog", + }, + { + "id": 456, + "subdomain": "otherblog", + "custom_domain": "https://custom.example.com", + "name": "Other Blog", + }, + ] + } + + def test_newsletter_init(newsletter_url): newsletter = Newsletter(newsletter_url) assert newsletter.url == newsletter_url @@ -159,6 +185,24 @@ def test_fetch_paginated_posts_error_response(mock_get, newsletter_url): assert results == [] +@patch("substack_api.newsletter.requests.get") +def test_fetch_paginated_posts_empty_first_response(mock_get, newsletter_url): + # Set up mock to return empty list on first request + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_get.return_value = mock_response + + newsletter = Newsletter(newsletter_url) + params = {"sort": "new"} + results = newsletter._fetch_paginated_posts(params) + + # Should return empty list + assert results == [] + # Should only make one call before breaking + assert mock_get.call_count == 1 + + @patch("substack_api.newsletter.Newsletter._fetch_paginated_posts") def test_get_posts(mock_fetch, newsletter_url): newsletter = Newsletter(newsletter_url) @@ -201,67 +245,168 @@ def test_get_podcasts(mock_fetch, newsletter_url): mock_fetch.assert_called_once_with({"sort": "new", "type": "podcast"}, 3) +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") +@patch("substack_api.newsletter.requests.get") +def test_get_recommendations_success_via_resolve( + mock_get, mock_resolve, newsletter_url, mock_recommendations +): + # Mock the publication ID resolution + mock_resolve.return_value = 123 + + # Mock the recommendations API call + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = mock_recommendations + mock_get.return_value = mock_resp + + newsletter = Newsletter(newsletter_url) + recommendations = newsletter.get_recommendations() + + # Verify _resolve_publication_id was called + mock_resolve.assert_called_once() + + # Verify the API was called correctly + mock_get.assert_called_once_with( + f"{newsletter_url}/api/v1/recommendations/from/123", + headers=HEADERS, + timeout=30, + ) + + # Verify we got the expected recommendations + assert len(recommendations) == 3 + assert all(isinstance(rec, Newsletter) for rec in recommendations) + # Check URL formation with and without custom domains + assert recommendations[0].url == "newsletter1.substack.com" + assert recommendations[2].url == "https://custom.domain.com" + + +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") +@patch("substack_api.newsletter.Newsletter.get_posts") @patch("substack_api.newsletter.requests.get") -def test_get_recommendations_success(mock_get, newsletter_url, mock_recommendations): +def test_get_recommendations_fallback_to_posts( + mock_get, mock_get_posts, mock_resolve, newsletter_url, mock_recommendations +): + # Mock _resolve_publication_id to return None (search fails) + mock_resolve.return_value = None + # Mock the post fetch to return a publication ID post_mock = MagicMock() - post_mock.get_metadata.return_value = {"publication_id": 123} - - # First patch _fetch_paginated_posts to return our mocked post - with patch.object(Newsletter, "get_posts", return_value=[post_mock]): - # Then patch the recommendations API call - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = mock_recommendations - mock_get.return_value = mock_resp - - newsletter = Newsletter(newsletter_url) - recommendations = newsletter.get_recommendations() - - # Verify the API was called correctly - mock_get.assert_called_once_with( - f"{newsletter_url}/api/v1/recommendations/from/123", - headers=HEADERS, - timeout=30, - ) - - # Verify we got the expected recommendations - assert len(recommendations) == 3 - assert all(isinstance(rec, Newsletter) for rec in recommendations) - # Check URL formation with and without custom domains - assert recommendations[0].url == "newsletter1.substack.com" - assert recommendations[2].url == "https://custom.domain.com" + post_mock.get_metadata.return_value = {"publication_id": 456} + mock_get_posts.return_value = [post_mock] + # Mock the recommendations API call + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = mock_recommendations + mock_get.return_value = mock_resp + + newsletter = Newsletter(newsletter_url) + recommendations = newsletter.get_recommendations() + + # Verify fallback path was used + mock_resolve.assert_called_once() + mock_get_posts.assert_called_once_with(limit=1) + + # Verify the API was called with the publication ID from the post + mock_get.assert_called_once_with( + f"{newsletter_url}/api/v1/recommendations/from/456", + headers=HEADERS, + timeout=30, + ) + # Verify we got recommendations + assert len(recommendations) == 3 + + +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") @patch("substack_api.newsletter.Newsletter.get_posts") -def test_get_recommendations_no_posts(mock_get_posts, newsletter_url): - # Mock empty post list +def test_get_recommendations_no_publication_id( + mock_get_posts, mock_resolve, newsletter_url +): + # Mock both resolution paths to fail + mock_resolve.return_value = None mock_get_posts.return_value = [] newsletter = Newsletter(newsletter_url) recommendations = newsletter.get_recommendations() - # Should return empty list when no posts + # Should return empty list when publication_id cannot be resolved + assert recommendations == [] + + +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") +@patch("substack_api.newsletter.Newsletter.get_posts") +def test_get_recommendations_fallback_exception( + mock_get_posts, mock_resolve, newsletter_url +): + # Mock _resolve_publication_id to return None + mock_resolve.return_value = None + + # Mock get_posts to raise an exception + mock_get_posts.side_effect = Exception("Error fetching posts") + + newsletter = Newsletter(newsletter_url) + recommendations = newsletter.get_recommendations() + + # Should return empty list when exception occurs in fallback assert recommendations == [] +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") @patch("substack_api.newsletter.requests.get") -def test_get_recommendations_error(mock_get, newsletter_url): - # Mock the post fetch - post_mock = MagicMock() - post_mock.get_metadata.return_value = {"publication_id": 123} +def test_get_recommendations_api_error(mock_get, mock_resolve, newsletter_url): + # Mock successful publication ID resolution + mock_resolve.return_value = 123 - with patch.object(Newsletter, "get_posts", return_value=[post_mock]): - # Mock error response for recommendations - mock_resp = MagicMock() - mock_resp.status_code = 404 - mock_get.return_value = mock_resp + # Mock error response for recommendations + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.raise_for_status.side_effect = Exception("404 Not Found") + mock_get.return_value = mock_resp - newsletter = Newsletter(newsletter_url) - recommendations = newsletter.get_recommendations() + newsletter = Newsletter(newsletter_url) - # Should return empty list on error - assert recommendations == [] + # The implementation raises the error, so we expect an exception + with pytest.raises(Exception): + newsletter.get_recommendations() + + +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") +@patch("substack_api.newsletter.requests.get") +def test_get_recommendations_empty_response(mock_get, mock_resolve, newsletter_url): + # Mock successful publication ID resolution + mock_resolve.return_value = 123 + + # Mock empty recommendations response + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [] + mock_get.return_value = mock_resp + + newsletter = Newsletter(newsletter_url) + recommendations = newsletter.get_recommendations() + + # Should return empty list when no recommendations + assert recommendations == [] + + +@patch("substack_api.newsletter.Newsletter._resolve_publication_id") +@patch("substack_api.newsletter.requests.get") +def test_get_recommendations_null_response(mock_get, mock_resolve, newsletter_url): + # Mock successful publication ID resolution + mock_resolve.return_value = 123 + + # Mock None (null) recommendations response + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = None + mock_get.return_value = mock_resp + + newsletter = Newsletter(newsletter_url) + recommendations = newsletter.get_recommendations() + + # Should return empty list when response is None + assert recommendations == [] @patch("substack_api.newsletter.requests.get") @@ -298,3 +443,169 @@ def test_get_authors_empty_response(mock_get, newsletter_url): # Should return empty list assert authors == [] + + +# Tests for helper functions +def test_host_from_url(): + # Test with full URL + assert _host_from_url("https://testblog.substack.com") == "testblog.substack.com" + + # Test without protocol + assert _host_from_url("testblog.substack.com") == "testblog.substack.com" + + # Test with custom domain + assert _host_from_url("https://custom.example.com") == "custom.example.com" + + # Test with port + assert _host_from_url("https://testblog.substack.com:8080") == "testblog.substack.com:8080" + + # Test case insensitivity + assert _host_from_url("https://TestBlog.Substack.COM") == "testblog.substack.com" + + +def test_match_publication(): + search_results = { + "publications": [ + { + "id": 123, + "subdomain": "testblog", + "custom_domain": None, + }, + { + "id": 456, + "subdomain": "otherblog", + "custom_domain": "https://custom.example.com", + }, + { + "id": 789, + "subdomain": "thirdblog", + "custom_domain": "custom2.example.com", + }, + ] + } + + # Test exact subdomain match + match = _match_publication(search_results, "testblog.substack.com") + assert match is not None + assert match["id"] == 123 + + # Test custom domain match (with https) + match = _match_publication(search_results, "custom.example.com") + assert match is not None + assert match["id"] == 456 + + # Test custom domain match (without protocol) + match = _match_publication(search_results, "custom2.example.com") + assert match is not None + assert match["id"] == 789 + + # Test no match + match = _match_publication(search_results, "nonexistent.substack.com") + assert match is None + + # Test case insensitive subdomain match (via fallback regex path) + # The regex path converts to lowercase, so it should match + search_results_mixed_case = { + "publications": [ + { + "id": 999, + "subdomain": "TestBlog", + "custom_domain": None, + } + ] + } + match = _match_publication(search_results_mixed_case, "testblog.substack.com") + assert match is not None + assert match["id"] == 999 + + # Test fallback path when first pass doesn't match but regex does + # This tests the scenario where the exact match fails but regex succeeds + search_results_fallback = { + "publications": [ + { + "id": 888, + "subdomain": "TESTBLOG", # Won't match on first pass due to case + "custom_domain": None, + } + ] + } + match = _match_publication(search_results_fallback, "testblog.substack.com") + assert match is not None + assert match["id"] == 888 + + # Test empty publications list + match = _match_publication({"publications": []}, "testblog.substack.com") + assert match is None + + +@patch("substack_api.newsletter.requests.get") +def test_resolve_publication_id_success(mock_get, newsletter_url, mock_search_result): + # Set up mock + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_search_result + mock_get.return_value = mock_response + + newsletter = Newsletter(newsletter_url) + publication_id = newsletter._resolve_publication_id() + + # Check API call + mock_get.assert_called_once() + call_args = mock_get.call_args + assert call_args[0][0] == SEARCH_URL + assert call_args[1]["headers"] == DISCOVERY_HEADERS + assert call_args[1]["timeout"] == 30 + + # Check params + params = call_args[1]["params"] + assert params["query"] == "testblog.substack.com" + assert params["page"] == 0 + assert params["limit"] == 25 + assert params["skipExplanation"] == "true" + assert params["sort"] == "relevance" + + # Check result + assert publication_id == 123 + + +@patch("substack_api.newsletter.requests.get") +def test_resolve_publication_id_no_match(mock_get, newsletter_url): + # Set up mock with empty results + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"publications": []} + mock_get.return_value = mock_response + + newsletter = Newsletter(newsletter_url) + publication_id = newsletter._resolve_publication_id() + + # Should return None when no match + assert publication_id is None + + +@patch("substack_api.newsletter.requests.get") +def test_resolve_publication_id_with_custom_domain(mock_get): + # Set up mock + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "publications": [ + { + "id": 999, + "subdomain": "blog", + "custom_domain": "https://custom.example.com", + } + ] + } + mock_get.return_value = mock_response + + newsletter = Newsletter("https://custom.example.com") + publication_id = newsletter._resolve_publication_id() + + # Check that query is based on the custom domain + call_args = mock_get.call_args + params = call_args[1]["params"] + assert params["query"] == "custom.example.com" + + # Check result + assert publication_id == 999 diff --git a/uv.lock b/uv.lock index 124782b..0132dde 100644 --- a/uv.lock +++ b/uv.lock @@ -151,6 +151,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 }, ] +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098 }, + { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331 }, + { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825 }, + { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573 }, + { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706 }, + { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221 }, + { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624 }, + { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744 }, + { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325 }, + { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180 }, + { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479 }, + { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290 }, + { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924 }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129 }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380 }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375 }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978 }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253 }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591 }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411 }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303 }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157 }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921 }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526 }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317 }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948 }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837 }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061 }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398 }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574 }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797 }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361 }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349 }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114 }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723 }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238 }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180 }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241 }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510 }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110 }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395 }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433 }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970 }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445 }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324 }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261 }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092 }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755 }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793 }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587 }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168 }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850 }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071 }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570 }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738 }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994 }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282 }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430 }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190 }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658 }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342 }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568 }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687 }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711 }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761 }, +] + [[package]] name = "debugpy" version = "1.8.12" @@ -737,6 +811,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -909,7 +997,7 @@ wheels = [ [[package]] name = "substack-api" -version = "1.1.3.dev1+g65e2d0a92.d20251103" +version = "1.1.3.dev2+g000d4293b.d20251103" source = { editable = "." } dependencies = [ { name = "requests" }, @@ -923,6 +1011,7 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings-python" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -937,6 +1026,7 @@ dev = [ { name = "mkdocs-material", specifier = ">=9.6.6" }, { name = "mkdocstrings-python", specifier = ">=1.16.2" }, { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.9.9" }, ] From b4dcbddeea30a74cda16e1e00676bd5edffdde30 Mon Sep 17 00:00:00 2001 From: Nick Hagar Date: Sun, 2 Nov 2025 19:04:27 -0600 Subject: [PATCH 3/3] Update substack_api/newsletter.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- substack_api/newsletter.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/substack_api/newsletter.py b/substack_api/newsletter.py index b14c6d0..50965fe 100644 --- a/substack_api/newsletter.py +++ b/substack_api/newsletter.py @@ -223,7 +223,23 @@ def get_podcasts(self, limit: Optional[int] = None) -> List: return [Post(item["canonical_url"], auth=self.auth) for item in post_data] def _resolve_publication_id(self) -> Optional[int]: - """Resolve publication_id via Substack discovery search—no posts needed.""" + """ + Resolve publication_id via Substack discovery search—no posts needed. + + Parameters + ---------- + None + + Returns + ------- + Optional[int] + The publication ID if found, otherwise None. + + Raises + ------ + requests.HTTPError + If the HTTP request to Substack fails. + """ host = _host_from_url(self.url) q = host.split(":")[0] # strip port if present params = {