substack-saved-mcp 0.2.0__tar.gz → 0.3.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_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/CLAUDE.md +7 -3
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/PKG-INFO +24 -1
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/README.md +23 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/pyproject.toml +1 -1
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/__init__.py +1 -1
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/cli.py +240 -20
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/config.py +11 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/substack_client.py +232 -10
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/sync.py +71 -13
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_substack_client.py +199 -2
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_sync.py +426 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/uv.lock +1 -1
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/.github/workflows/ci.yml +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/.github/workflows/pypi.yml +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/.gitignore +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/.pre-commit-config.yaml +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/LICENSE +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/PLAN.md +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/content_utils.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/database.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/mcp_server.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/models.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/src/substack_saved_mcp/url_utils.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_cli.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_content_utils.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_database.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_mcp_server.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_normalization.py +0 -0
- {substack_saved_mcp-0.2.0 → substack_saved_mcp-0.3.0}/tests/test_url_utils.py +0 -0
|
@@ -21,16 +21,20 @@ uv sync --extra dev
|
|
|
21
21
|
|
|
22
22
|
`ruff` is configured in `pyproject.toml` (rules: `E4`, `E7`, `E9`, `F`, `I`, `UP`, `B`, `RUF`, line length 88, target py311). Lint with `uvx ruff check .` or `uv run ruff check .`.
|
|
23
23
|
|
|
24
|
+
After any change to dependencies or tracked files, run `uv lock` to keep `uv.lock` current, then `prek run --all-files` (using the repo's `.pre-commit-config.yaml`) before considering the work done.
|
|
25
|
+
|
|
24
26
|
## Architecture
|
|
25
27
|
|
|
26
28
|
- `cli.py` is the Click entry point (`substack-saved-mcp`). Its commands initialize the database and then delegate to the repository, sync engine, Playwright client, or MCP server.
|
|
27
29
|
- `mcp_server.py` exposes the same application operations as FastMCP stdio tools and resources. Read tools query the local cache; sync and save/unsave operations use the authenticated browser session.
|
|
28
|
-
- `substack_client.py` owns Playwright authentication and remote Substack interaction. `login` is the only intended headful workflow; normal sync and write paths use `storage_state.json` headlessly. Synchronous Playwright API calls are routed through `_run_playwright_sync()` to safely execute in a worker thread if an `asyncio` event loop is active (e.g. under FastMCP). Saved-post fetching prefers the reader inbox API (`GET /api/v1/reader/posts?inboxType=saved`), which exposes the real bookmark timestamp (`saved_at`) and an ISO publication date (`post_date`) per post. `_fetch_all_saved_via_reader_api()` cursor-paginates that endpoint (each page's oldest `saved_at` becomes the next `after=` cursor, until `more` is false), dedupes by canonical URL, and enriches each flat post with its `publication` object (from the response's `publications` array, matched by `publication_id`) and `author_name` (from `publishedBylines`). The full result is cached in `_api_cache` and sliced by offset; `_fetch_saved_posts_page_impl()` accepts an optional `playwright_instance` (same pattern as the notes fetcher and `_fetch_via_dom`) so this caching/slicing/fallback branch is directly testable with a Playwright double instead of only reachable through a real browser. Each page request goes through `_reader_api_get()`, which retries transient failures (HTTP 429 and 5xx) up to `max_retries` (default 3) with `Retry-After`-aware backoff: `_retry_after_seconds()` honors an integer `Retry-After` header (clamped to a 30s cap so a hostile value can't hang the sync) and otherwise uses capped exponential backoff (0.5s, 1s, 2s, ...); 401/403 and other 4xx are returned unretried so the existing auth/fallback handling applies. A 429 that survives all retries is deliberately treated as "unavailable" (partial list, or `None` → DOM fallback) rather than as a silent empty-success, so rate-limiting never masquerades as "you have no saved posts." Both `_reader_api_get()` and `_fetch_all_saved_via_reader_api()` accept an injectable `sleep_func` (default `time.sleep`) so tests exercise the backoff without real delays. If that endpoint is unavailable, it falls back to headless DOM extraction on `https://substack.com/saved`, which scrapes `div.reader2-post-container` cards, caches scrolling results in `_dom_cache`, and marks each dict with `_dom: True`; DOM cards only expose a localized relative publish string and no bookmark time, so `saved_at` stays unknown on that path. Both `save_post()` and `unsave_post()` were originally guesswork (an unverified DOM button selector plus an unverified `POST /api/v1/bookmark` call) and were fixed once real endpoints were captured via `inspect-network`. Every post page server-renders a `window._preloads` blob containing that post's own numeric ID and rich metadata (`preloads.post.id`, `.title`, `.audience`, `.description`/`.subtitle`, `.post_date`, plus `preloads.pub.name`); `_save_post_impl` reads this via `page.evaluate("() => window._preloads")` right after page load and, when an ID is found, calls the real `POST https://substack.com/api/v1/posts/saved` endpoint (body `{"post_id": ...}`) directly via the Playwright API request context — this also lets it populate the returned `SavedPost` with accurate title/publication/audience/excerpt instead of parsing `page.title()`. `_unsave_post_impl` does the mirror image: when the post's `substack_post_id` is already known (true for any post that has been through a normal `sync`, since the reader API's `id` field populates it), it calls `DELETE https://substack.com/api/v1/posts/saved` with the same body shape, no DOM interaction at all. Both treat an `ok` response as `"confirmed"` and only fall back to the old best-effort DOM click (`_click_bookmark_toggle()`, used when the numeric ID can't be obtained or the direct call doesn't confirm) — note its selector is English-only (`aria-label*='save'/'bookmark'`) and can silently fail on non-English Substack UIs, which is part of why the direct API path is preferred whenever possible. `_click_bookmark_toggle()` fingerprints the button's `aria-label`/`aria-pressed`/`class` before and after the click and returns `"confirmed"` only if that fingerprint changed, else `"unconfirmed"`, `"not_found"`, or `"click_failed"` — `save_post()` returns `(SavedPost, confirmation)` and `unsave_post()` returns just the confirmation string; both accept an optional `playwright_instance` for test injection (same pattern as `_fetch_via_dom`). `fetch_post_content()` reuses the same `window._preloads` mechanism to retrieve a saved post's full content: `_fetch_post_content_impl` navigates to the post's page and reads `preloads.post.body_html` (Substack's field name for full content — `parse_remote_post()` already expects this key from the reader API, though the saved-list payload never actually populates it, only individual post pages do), returning `None` for `body_html` if the page's embed format doesn't expose it (frontend change) or the account lacks paywall access; this is the case where the caller should be told to run `inspect-network` against an open post page to re-discover the real content source. `cli.py`'s `inspect-network` command (which logs any `api/v1`/`bookmark`/`saved`/`notes?`/`comment`/`reader`/`feed`/`restack` request's method, URL, status, and
|
|
30
|
+
- `substack_client.py` owns Playwright authentication and remote Substack interaction. `login` is the only intended headful workflow; normal sync and write paths use `storage_state.json` headlessly. Synchronous Playwright API calls are routed through `_run_playwright_sync()` to safely execute in a worker thread if an `asyncio` event loop is active (e.g. under FastMCP). Saved-post fetching prefers the reader inbox API (`GET /api/v1/reader/posts?inboxType=saved`), which exposes the real bookmark timestamp (`saved_at`) and an ISO publication date (`post_date`) per post. `_fetch_all_saved_via_reader_api()` cursor-paginates that endpoint (each page's oldest `saved_at` becomes the next `after=` cursor, until `more` is false), dedupes by canonical URL, and enriches each flat post with its `publication` object (from the response's `publications` array, matched by `publication_id`) and `author_name` (from `publishedBylines`). The full result is cached in `_api_cache` and sliced by offset; `_fetch_saved_posts_page_impl()` accepts an optional `playwright_instance` (same pattern as the notes fetcher and `_fetch_via_dom`) so this caching/slicing/fallback branch is directly testable with a Playwright double instead of only reachable through a real browser. Each page request goes through `_reader_api_get()`, which retries transient failures (HTTP 429 and 5xx) up to `max_retries` (default 3) with `Retry-After`-aware backoff: `_retry_after_seconds()` honors an integer `Retry-After` header (clamped to a 30s cap so a hostile value can't hang the sync) and otherwise uses capped exponential backoff (0.5s, 1s, 2s, ...); 401/403 and other 4xx are returned unretried so the existing auth/fallback handling applies. A 429 that survives all retries is deliberately treated as "unavailable" (partial list, or `None` → DOM fallback) rather than as a silent empty-success, so rate-limiting never masquerades as "you have no saved posts." Both `_reader_api_get()` and `_fetch_all_saved_via_reader_api()` accept an injectable `sleep_func` (default `time.sleep`) so tests exercise the backoff without real delays. If that endpoint is unavailable, it falls back to headless DOM extraction on `https://substack.com/saved`, which scrapes `div.reader2-post-container` cards, caches scrolling results in `_dom_cache`, and marks each dict with `_dom: True`; DOM cards only expose a localized relative publish string and no bookmark time, so `saved_at` stays unknown on that path. Both `save_post()` and `unsave_post()` were originally guesswork (an unverified DOM button selector plus an unverified `POST /api/v1/bookmark` call) and were fixed once real endpoints were captured via `inspect-network`. Every post page server-renders a `window._preloads` blob containing that post's own numeric ID and rich metadata (`preloads.post.id`, `.title`, `.audience`, `.description`/`.subtitle`, `.post_date`, plus `preloads.pub.name`); `_save_post_impl` reads this via `page.evaluate("() => window._preloads")` right after page load and, when an ID is found, calls the real `POST https://substack.com/api/v1/posts/saved` endpoint (body `{"post_id": ...}`) directly via the Playwright API request context — this also lets it populate the returned `SavedPost` with accurate title/publication/audience/excerpt instead of parsing `page.title()`. `_unsave_post_impl` does the mirror image: when the post's `substack_post_id` is already known (true for any post that has been through a normal `sync`, since the reader API's `id` field populates it), it calls `DELETE https://substack.com/api/v1/posts/saved` with the same body shape, no DOM interaction at all. Both treat an `ok` response as `"confirmed"` and only fall back to the old best-effort DOM click (`_click_bookmark_toggle()`, used when the numeric ID can't be obtained or the direct call doesn't confirm) — note its selector is English-only (`aria-label*='save'/'bookmark'`) and can silently fail on non-English Substack UIs, which is part of why the direct API path is preferred whenever possible. `_click_bookmark_toggle()` fingerprints the button's `aria-label`/`aria-pressed`/`class` before and after the click and returns `"confirmed"` only if that fingerprint changed, else `"unconfirmed"`, `"not_found"`, or `"click_failed"` — `save_post()` returns `(SavedPost, confirmation)` and `unsave_post()` returns just the confirmation string; both accept an optional `playwright_instance` for test injection (same pattern as `_fetch_via_dom`). `fetch_post_content()` reuses the same `window._preloads` mechanism to retrieve a saved post's full content: `_fetch_post_content_impl` navigates to the post's page and reads `preloads.post.body_html` (Substack's field name for full content — `parse_remote_post()` already expects this key from the reader API, though the saved-list payload never actually populates it, only individual post pages do), returning `None` for `body_html` if the page's embed format doesn't expose it (frontend change) or the account lacks paywall access; this is the case where the caller should be told to run `inspect-network` against an open post page to re-discover the real content source. `cli.py`'s `inspect-network` command (which logs any `api/v1`/`bookmark`/`saved`/`notes?`/`comment`/`reader`/`feed`/`restack` request's method, URL, status, and JSON response body) is the tool for discovering/re-verifying these endpoints when Substack's frontend changes. It supports `--authenticated/--anonymous` (reuses `storage_state.json` via `get_storage_state_path()` so `/saved` renders the real logged-in page instead of the marketing page), `--url` to target any page, `--filter` to override the response-matching regex, `--max-body` to cap logged response bytes, and `--out PATH` to append each exchange as JSON Lines for later grepping. Response bodies are captured via `context.route("**/*", handle_route)` + `route.fetch()`/`route.fulfill()`, not the more obvious `page.on("response", ...)` + `response.text()`: the latter can deadlock calling `response.text()` inside a sync-API response event handler on the same driver thread, and in practice silently produced zero captured bodies across an entire 108-request session before this fix (every `response_body` came back `null` with no error surfaced) — `route.fetch()` reads the body outside that handler and `route.fulfill(response=...)` re-serves the exact response so page behavior is unaffected; a body that still can't be read now logs a visible yellow warning instead of writing `null` silently.
|
|
31
|
+
|
|
32
|
+
Saved-posts fetching has a three-source chain, controlled by `get_saved_posts_source()`/`SUBSTACK_SAVED_POSTS_SOURCE` (`auto` default, or a forced `legacy`/`unified`/`dom`): the unified `GET /api/v1/reader/saved?filter=posts` endpoint (the same one notes uses with `filter=notes`) is now tried first, then the legacy reader-posts API above as an automatic fallback, then the DOM fallback. This was a migration decision, not a default guess — see `PLAN-newapi.md` for the full evidence-gathering and decision-gate process. Two tools were built to make that decision on evidence rather than guesswork: `probe-api URL [--out PATH]` headlessly GETs any known API URL with the saved session (no browser page) and dumps the raw JSON — a lighter complement to `inspect-network`, which is for *discovering* unknown endpoints by watching a live browsing session; and `compare-saved-apis [--out PATH]` fetches the full saved list from both sources in one session and reports counts, URL-set differences, per-field presence on the unified payload, and whether `post.saved_at` is present and ordered. A live run against a real ~1000-post account (after a first attempt was invalidated by rate limiting immediately following a large legacy fetch — `compare-saved-apis` now pauses and retries harder around the second fetch for exactly this reason) found the unified endpoint a **strict superset**: 1079 posts vs. legacy's 985, with only 1 post legacy had that unified lacked, `saved_at`/`id`/`canonical_url`/`title`/`post_date`/`audience`/`wordcount` all on 100% of items, correctly ordered newest-first. A follow-up shadow sync (via the real `sync --force` code path, into throwaway databases, same live session) showed *why*: legacy's own count is unstable run-to-run (985, then 1080 across two consecutive syncs), consistent with a real latent bug — `_fetch_all_saved_via_reader_api()` returns `all_posts if all_posts else None` when a mid-pagination 429 survives every retry, silently returning a partial list with no "this is incomplete" signal, rather than surfacing the truncation. The unified endpoint held steady at 1079 across both the comparison and the shadow sync. `_fetch_all_saved_posts_via_unified_api()` is a structural sibling of `_fetch_all_saved_notes_via_api()` (opaque `nextCursor` pagination, not the legacy `after=<ISO saved_at>` scheme), cached separately in `_unified_api_cache`/`_unified_api_failed` so one source's failure can't poison another's mid-run cache, same as the posts/notes cache split. `parse_remote_post()`'s `saved_at` lookup checks both the item level (legacy) and `post_obj` (unified: `post.saved_at`), since the two APIs nest it differently. The mid-pagination silent-truncation bug is now fixed, and turned out to be shared by all three cursor-paginated fetchers (legacy posts, unified posts, and — pre-existing, since before this session — notes): each still returns `all_posts`/`all_notes` `if ... else None` when a page request fails after every retry (so a persistent 429 doesn't throw away progress already made), but now also sets a per-source instance flag (`_api_truncated` / `_unified_api_truncated` / `_notes_api_truncated`, reset at the top of each fetch and in `reset_cache()`) whenever that partial-but-nonempty return fires. `is_posts_fetch_truncated()`/`is_notes_fetch_truncated()` read whichever cache is currently populated and report its flag (using `getattr()` defaults so a test double that skips `__init__`, the established pattern in this repo's sync tests, safely reports "not truncated" instead of raising). `sync_saved_posts()`/`sync_saved_notes()` check this after the fetch loop: on a `--force` sync, a truncated fetch **skips reconciliation entirely** rather than soft-deleting posts/notes that are merely absent from an incomplete list (they may still be saved remotely — the fetch just couldn't reach them this run), and the returned `SyncRun` gets the previously-unused `status = "partial"` (already documented in the model's comment, never actually emitted before this fix) with an `error_message` explaining what happened. The CLI's `sync` command treats `"partial"` as a qualified success (yellow, not red) — `posts_ok`/`notes_ok` still count it as having run — and prints the warning; `status` needs no change since it already displays whatever `last_sync_status` string is stored, and `get_status()`'s `status = 'success'` filter for "last successful sync" already correctly excludes a `partial` run.
|
|
29
33
|
- **Saved notes** (Substack's short-form posts, distinct from long-form posts) are modeled and synced separately from posts, in their own `notes` table/`SavedNote`/`NoteSummary` models, discovered and confirmed live against a real account rather than guessed. The saved-notes list is `GET https://substack.com/api/v1/reader/saved?filter=notes` (also `filter=all`/`filter=posts` — a newer, unified endpoint Substack has added alongside the legacy posts-only `/api/v1/reader/posts?inboxType=saved` that `_fetch_all_saved_via_reader_api()` still uses; posts intentionally were not migrated to it). Its response is `{"items": [...], "nextCursor": <opaque base64 JSON token> | null}`; pagination resubmits the server's own `nextCursor` value as a `cursor=` query param rather than the `after=<ISO timestamp>` scheme posts uses, and the `limit=` query param has no effect (the server returns a fixed page size), so `_fetch_all_saved_notes_via_api()` is a standalone paginator, not a generalization of `_fetch_all_saved_via_reader_api()`. Each item is `{"entity_key": "c-<id>", "publication": null|{...}, "post": null|{...}, "comment": {...}}`; a restack is represented by the top-level `publication`/`post` fields being populated instead of null (unconfirmed by a live example in the account used to discover this, but structurally solid since that item shape is shared with `filter=posts`). Critically, **the endpoint never exposes a bookmark timestamp** — only `comment.is_saved: bool` — so `SavedNote.saved_at` is always `None`, and `sync_saved_notes()`'s incremental early-stop check compares "already saved locally" (`existing.is_saved == 1`) rather than a `saved_at` match the way `sync_saved_posts()` does. A note's identity is its numeric `comment.id`, which is also embedded in its permalink (`https://substack.com/@<handle>/note/c-<id>`, confirmed live via click-tracking) — `_extract_note_id()` parses it back out of a URL via regex, and `upsert_note()`/`reconcile_unsaved_notes()`/`get_note_by_substack_id()` key on that id (not URL, unlike posts) since a note may lack a stable permalink. `comment.body` is already Substack-flattened plain text; `comment.body_json` is a ProseMirror doc (`{type:"doc", content:[{type:"paragraph"|"orderedList"|"listItem",...}]}`, text nodes carrying `marks:[{type:"bold"|"italic"|"link"}]`) that `content_utils.note_body_to_text()`/`prosemirror_to_llm_text()` convert to the same `**bold**`/`*italic*`/`text (url)` conventions `html_to_llm_text()` uses for posts; `body_raw` stores the source verbatim as a hedge against the body-format guess needing correction later. The individual-note-fetch endpoint `GET /api/v1/reader/comment/{id}` → `{"item": {"comment": {...}}}` and the bookmark endpoints `POST`/`DELETE https://substack.com/api/v1/note/c-{id}/save` (id in the URL path, no request body) are all plain authenticated calls through Playwright's `p.request` API context — **no browser page or `window._preloads` lookup is needed for notes at all**, unlike posts, so `save_note()`/`unsave_note()`/`fetch_note_content()` in `substack_client.py` never launch `p.chromium`. Notes have no DOM fallback and no `_click_bookmark_toggle`-style guesswork: the notes card markup is uncaptured, and since the note id is the identity, a DOM-scraped row without one could never reconcile, so an unavailable notes API raises `SubstackClientError` naming `inspect-network` rather than degrading silently. `sync_runs.entity` (`'post'` | `'note'`, default `'post'` for pre-existing rows) scopes `get_status()`'s "last sync" queries per entity so a notes sync can't be reported as the posts' last sync result.
|
|
30
34
|
- `content_utils.py` converts a post's raw `body_html` into clean text for LLM consumption. `html_to_llm_text()` is a small `html.parser.HTMLParser` subclass (no external HTML library dependency) that renders headings as markdown `#` prefixes, list items as `- ` bullets, links as `text (url)`, keeps minimal `**bold**`/`*italic*` markers, drops `script`/`style`/`iframe` content entirely, and collapses excess blank lines. `format_post_for_llm()` prepends a plain metadata header (title, publication, author, published date, URL) to the cleaned body text — this combined string is what both the CLI's `get-content` command and the MCP `get_post_content` tool return. `note_body_to_text()` handles the same problem for notes' `body_json`/`body`, dispatching a dict (or JSON string) to `prosemirror_to_llm_text()` (a small recursive walker over Substack's ProseMirror doc shape — `paragraph`/`heading`/`bulletList`/`orderedList`/`listItem`/`hard_break` nodes, `bold`/`italic`/`link` marks — producing the same `**bold**`/`*italic*`/`text (url)` conventions as `html_to_llm_text()`) and a plain string to either the HTML converter (if it looks like HTML) or straight through; `format_note_for_llm()` builds the note-specific header (author/handle/posted date/URL, plus a `Restacked: <title> (<url>)` line when applicable) rather than reusing `format_post_for_llm()`, since `publication_name` is required there but meaningless for most notes.
|
|
31
|
-
- `sync.py` converts API/DOM payloads to `SavedPost` models and coordinates paginated incremental or full syncs. `parse_remote_post()` routes DOM dicts (identified by the `_dom` marker) through a minimal mapping and everything else through the full reader-API/legacy mapping; it never fabricates a `saved_at` from the sync moment (unknown save times stay `None`). `word_count` is mapped
|
|
35
|
+
- `sync.py` converts API/DOM payloads to `SavedPost` models and coordinates paginated incremental or full syncs. `parse_remote_post()` routes DOM dicts (identified by the `_dom` marker) through a minimal mapping and everything else through the full reader-API/legacy mapping; it never fabricates a `saved_at` from the sync moment (unknown save times stay `None`). `word_count` is mapped from `wordcount`/`word_count`/`words` via `_first_positive_int()` (which ignores non-positive/uncoercible values); the `compare-saved-apis` parity run confirmed the real field is `wordcount` (100% populated on the unified endpoint's payload, 0% on `word_count`/`words`) — the defensive multi-key mapping is kept since `_first_positive_int()` already checks `wordcount` first and the other two cost nothing to keep as a hedge. `reading_time_minutes` is intentionally *derived* (ceil of `word_count / WORDS_PER_MINUTE`, ~200 wpm) rather than mapped from a field, because a wrong guess about that field's unit (seconds vs minutes) would persist a badly wrong value; deriving is unit-unambiguous. `image_url` is mapped from `cover_image`/`image_url` on the reader-API payload and is populated for the large majority of synced posts (confirmed live: ~98%); it was previously stored but invisible through every read path except `get_saved_post`/the full `SavedPost` — `PostSummary` and the `list_posts()`/`search_posts()` SELECTs now include it too; the CLI `search` command prints it when present, but `list` deliberately omits it since it's a long, uninformative CDN URL in that terser view. `content_text` remains unpopulated by sync (only `get-content`/`get_post_content` populate it) because the saved-list payload doesn't carry post body HTML, only individual post pages do. There is no `metadata_json` column: it was a never-populated "raw source JSON" placeholder from the original PLAN and has been removed from the model and schema. Existing databases created before its removal may still have an inert `metadata_json` column; reads tolerate it because `SavedPost` (Pydantic) ignores unknown columns from `SELECT *`, and `upsert_post` no longer references it. A force/full sync (`--force`) collects every fetched post's URL and, after the fetch loop completes, calls `reconcile_unsaved_posts()` to soft-delete any locally `is_saved = 1` post absent from that complete remote set — this is how posts unsaved directly on Substack (outside this tool) get reflected locally. An incremental sync never reconciles, since its early-stop-on-matches optimization means it only sees a partial remote list. It records each run in SQLite (including `reconciled_count`) and returns a `SyncRun` with `success`, `auth_required`, or `failed` status instead of propagating expected authentication failures. `sync_saved_notes()` is a sibling of `sync_saved_posts()`, not a generalization of it (notes reconcile by Substack note id via `reconcile_unsaved_notes()`, not URL, and the incremental early-stop check compares `existing.is_saved == 1` rather than a `saved_at` match, since notes carry none); both write `sync_runs.entity` (`'post'`/`'note'`) and share a `_build_sync_run()` helper instead of each hand-constructing `SyncRun` three times; `_build_sync_run()` takes the real `started_at` (captured once at the top of each sync function, right before `start_sync_run()`) rather than stamping it at finish time as all three constructions originally did, which had made every returned `SyncRun.started_at` equal to `completed_at` regardless of how long the sync actually took. `parse_remote_note()` unwraps a saved-notes list item (or a single-note-fetch `{"item": {...}}` wrapper) into a `SavedNote`, returning `None` (so the sync loop skips it) rather than inventing an id when `comment.id` is missing; it delegates body conversion to `content_utils.note_body_to_text()` and derives `word_count` from the resulting text rather than any source field.
|
|
32
36
|
- `database.py` is the SQLite repository and schema owner. The `posts` table is the cache; `sync_runs` records sync history; `posts_fts` is an external-content FTS5 index maintained by database triggers. `reconcile_unsaved_posts()` bulk soft-deletes posts missing from a given complete remote URL set and is a no-op on an empty list (an empty list is more likely a fetch problem than genuine mass-unsaving). `init_db()` runs additive column migrations (e.g. `posts.audience`, `sync_runs.reconciled_count`) via `ALTER TABLE` guarded by an existence check or `try`/`except sqlite3.OperationalError`, since `CREATE TABLE IF NOT EXISTS` never adds columns to an already-existing table; the `posts.audience` check runs before the `executescript` block because the `idx_posts_audience` index creation inside it would otherwise fail on a pre-migration table. `list_posts()`/`search_posts()` accept an `audience` filter (exact match, case-insensitive), and `list_audiences()` returns the distinct audience values actually present in the cache with post counts, rather than hardcoding Substack's (undocumented, possibly-growing) audience enum. `PostSummary` (returned by `list_posts()`/`search_posts()` and the MCP list/search tools) carries `reading_time_minutes` and `word_count`, and the CLI `list`/`search` commands display them — without this the values were populated in the `posts` table but invisible through every read path except `get_saved_post`/the full `SavedPost`. A parallel `notes` table (with its own `notes_fts` external-content index and `notes_ai`/`notes_ad`/`notes_au` triggers) holds saved notes; unlike `posts`, its unique identity column is `substack_note_id`, not `url` (a note may have no stable permalink), so `upsert_note()` does an explicit lookup-then-branch UPDATE/INSERT on `substack_note_id` rather than `INSERT ... ON CONFLICT(url)`. `upsert_post()` was originally the opposite (looked up by `substack_post_id` *or* `url` but written via `INSERT ... ON CONFLICT(url)`), which meant a post found by id whose canonical URL had since changed (slug rename, custom-domain migration) couldn't conflict on the new URL and tripped the `substack_post_id UNIQUE` constraint on the fresh INSERT instead; it now uses the same explicit lookup-then-branch UPDATE/INSERT pattern as `upsert_note()`, closing that crash path. `search_posts()`'s LIKE fallback (used when an FTS5 query string is malformed) shares its WHERE-clause construction with the main FTS branch via `_post_search_filters()` — mirroring `_note_search_filters()` — so a malformed query never silently drops the `publication`/`audience`/date-range filters or an `is_saved_only=False` caller the way the fallback originally did. `get_note_by_substack_id()` exists because `get_note()`'s polymorphic `url_or_id` dispatch treats any all-digit string as a local row id — and `substack_note_id` values are always digits — so callers that only have the Substack note id (the sync loop's incremental check) must use it instead of risking a collision with an unrelated local row. `sync_runs.entity` (`'post'`/`'note'`, additive column defaulting existing rows to `'post'`) scopes `get_status()`'s "last successful sync"/"last sync status" queries per entity, which is the one existing posts read path notes support required changing (it previously read the last `sync_runs` row unscoped, which was harmless with one entity and would otherwise let a notes sync be reported as the posts' last sync).
|
|
33
|
-
- `config.py` centralizes application paths. By default, data lives under `~/.local/share/substack-saved-mcp`; `SUBSTACK_SAVED_DB_PATH`, `SUBSTACK_SAVED_DATA_DIR`, and `SUBSTACK_SAVED_BROWSER_DIR` override those paths.
|
|
37
|
+
- `config.py` centralizes application paths. By default, data lives under `~/.local/share/substack-saved-mcp`; `SUBSTACK_SAVED_DB_PATH`, `SUBSTACK_SAVED_DATA_DIR`, and `SUBSTACK_SAVED_BROWSER_DIR` override those paths. `get_saved_posts_source()`/`SUBSTACK_SAVED_POSTS_SOURCE` (`auto`/`legacy`/`unified`/`dom`, falling back to `auto` on an unrecognized value) selects which of the three saved-posts fetch sources `_fetch_saved_posts_page_impl()` tries, so a source-specific regression is one env var away from a revert rather than a code rollback.
|
|
34
38
|
|
|
35
39
|
## Repository Conventions
|
|
36
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: substack-saved-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local stdio-based MCP server and sync engine for Substack saved posts
|
|
5
5
|
Author-email: Toni Hermoso Pulido <toniher@cau.cat>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -202,6 +202,29 @@ export SUBSTACK_SAVED_DB_PATH="/path/to/my/custom_database.sqlite"
|
|
|
202
202
|
export SUBSTACK_SAVED_DATA_DIR="/path/to/my/data_dir"
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
+
### Which Substack API does syncing saved posts use?
|
|
206
|
+
|
|
207
|
+
Saved posts are fetched from Substack's newer unified reader API by default, with
|
|
208
|
+
automatic fallback to an older, posts-only API and finally to headless browser
|
|
209
|
+
scraping if needed — you shouldn't normally need to think about this. If you ever
|
|
210
|
+
want to force a specific source (e.g. while troubleshooting), set:
|
|
211
|
+
```bash
|
|
212
|
+
export SUBSTACK_SAVED_POSTS_SOURCE="unified" # or "legacy" or "dom"
|
|
213
|
+
```
|
|
214
|
+
Leave it unset (or `"auto"`) for the default, self-healing behavior.
|
|
215
|
+
|
|
216
|
+
### What does a "partial" sync status mean?
|
|
217
|
+
|
|
218
|
+
If Substack rate-limits (HTTP 429) a sync so heavily that a page of results
|
|
219
|
+
can't be fetched even after retrying, the sync keeps whatever it already
|
|
220
|
+
fetched rather than failing outright, and reports `status: partial` (instead
|
|
221
|
+
of `success`) in `substack-saved-mcp status` or the sync tool's response.
|
|
222
|
+
When this happens on a `sync --force`, reconciliation (soft-deleting posts/notes
|
|
223
|
+
no longer in the remote list) is automatically skipped for that run, so a post
|
|
224
|
+
or note that merely couldn't be fetched is never mistaken for one you actually
|
|
225
|
+
unsaved on Substack. Just run `sync` again later — a subsequent successful run
|
|
226
|
+
picks up anything that was missed.
|
|
227
|
+
|
|
205
228
|
### Will a browser window pop up when running as an MCP server?
|
|
206
229
|
|
|
207
230
|
**No, a visible browser window will not open during normal MCP operations.**
|
|
@@ -184,6 +184,29 @@ export SUBSTACK_SAVED_DB_PATH="/path/to/my/custom_database.sqlite"
|
|
|
184
184
|
export SUBSTACK_SAVED_DATA_DIR="/path/to/my/data_dir"
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
+
### Which Substack API does syncing saved posts use?
|
|
188
|
+
|
|
189
|
+
Saved posts are fetched from Substack's newer unified reader API by default, with
|
|
190
|
+
automatic fallback to an older, posts-only API and finally to headless browser
|
|
191
|
+
scraping if needed — you shouldn't normally need to think about this. If you ever
|
|
192
|
+
want to force a specific source (e.g. while troubleshooting), set:
|
|
193
|
+
```bash
|
|
194
|
+
export SUBSTACK_SAVED_POSTS_SOURCE="unified" # or "legacy" or "dom"
|
|
195
|
+
```
|
|
196
|
+
Leave it unset (or `"auto"`) for the default, self-healing behavior.
|
|
197
|
+
|
|
198
|
+
### What does a "partial" sync status mean?
|
|
199
|
+
|
|
200
|
+
If Substack rate-limits (HTTP 429) a sync so heavily that a page of results
|
|
201
|
+
can't be fetched even after retrying, the sync keeps whatever it already
|
|
202
|
+
fetched rather than failing outright, and reports `status: partial` (instead
|
|
203
|
+
of `success`) in `substack-saved-mcp status` or the sync tool's response.
|
|
204
|
+
When this happens on a `sync --force`, reconciliation (soft-deleting posts/notes
|
|
205
|
+
no longer in the remote list) is automatically skipped for that run, so a post
|
|
206
|
+
or note that merely couldn't be fetched is never mistaken for one you actually
|
|
207
|
+
unsaved on Substack. Just run `sync` again later — a subsequent successful run
|
|
208
|
+
picks up anything that was missed.
|
|
209
|
+
|
|
187
210
|
### Will a browser window pop up when running as an MCP server?
|
|
188
211
|
|
|
189
212
|
**No, a visible browser window will not open during normal MCP operations.**
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
"""Command Line Interface (CLI) for Substack Saved Posts MCP & Sync tool."""
|
|
2
2
|
|
|
3
|
+
import itertools
|
|
3
4
|
import json
|
|
5
|
+
import logging
|
|
4
6
|
import re
|
|
5
7
|
import sys
|
|
8
|
+
import time
|
|
6
9
|
from datetime import UTC, datetime
|
|
7
10
|
|
|
8
11
|
import click
|
|
9
12
|
|
|
13
|
+
from substack_saved_mcp import __version__
|
|
10
14
|
from substack_saved_mcp.config import get_storage_state_path
|
|
11
15
|
from substack_saved_mcp.content_utils import (
|
|
12
16
|
format_note_for_llm,
|
|
@@ -52,10 +56,13 @@ from substack_saved_mcp.substack_client import (
|
|
|
52
56
|
)
|
|
53
57
|
from substack_saved_mcp.sync import sync_saved_notes as run_sync_notes
|
|
54
58
|
from substack_saved_mcp.sync import sync_saved_posts as run_sync
|
|
59
|
+
from substack_saved_mcp.url_utils import canonicalize_url
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger(__name__)
|
|
55
62
|
|
|
56
63
|
|
|
57
64
|
@click.group()
|
|
58
|
-
@click.version_option(version=
|
|
65
|
+
@click.version_option(version=__version__)
|
|
59
66
|
def cli() -> None:
|
|
60
67
|
"""Substack Saved Posts MCP & Sync Engine."""
|
|
61
68
|
pass
|
|
@@ -98,12 +105,14 @@ def sync(force: bool, only: str | None) -> None:
|
|
|
98
105
|
if only in (None, "posts"):
|
|
99
106
|
click.echo("Starting Substack saved posts sync...")
|
|
100
107
|
result = run_sync(force=force)
|
|
101
|
-
if result.status
|
|
108
|
+
if result.status in ("success", "partial"):
|
|
102
109
|
posts_ok = True
|
|
103
110
|
msg = f"Sync complete! Fetched {result.fetched_count} posts, upserted {result.upserted_count} posts."
|
|
104
111
|
if result.reconciled_count:
|
|
105
112
|
msg += f" Unsaved {result.reconciled_count} post(s) no longer on Substack's saved list."
|
|
106
|
-
click.secho(msg, fg="green")
|
|
113
|
+
click.secho(msg, fg="green" if result.status == "success" else "yellow")
|
|
114
|
+
if result.status == "partial":
|
|
115
|
+
click.secho(f"Warning: {result.error_message}", fg="yellow")
|
|
107
116
|
elif result.status == "auth_required":
|
|
108
117
|
click.secho(f"Authentication required: {result.error_message}", fg="yellow")
|
|
109
118
|
else:
|
|
@@ -112,12 +121,16 @@ def sync(force: bool, only: str | None) -> None:
|
|
|
112
121
|
if only in (None, "notes"):
|
|
113
122
|
click.echo("Starting Substack saved notes sync...")
|
|
114
123
|
note_result = run_sync_notes(force=force)
|
|
115
|
-
if note_result.status
|
|
124
|
+
if note_result.status in ("success", "partial"):
|
|
116
125
|
notes_ok = True
|
|
117
126
|
msg = f"Sync complete! Fetched {note_result.fetched_count} notes, upserted {note_result.upserted_count} notes."
|
|
118
127
|
if note_result.reconciled_count:
|
|
119
128
|
msg += f" Unsaved {note_result.reconciled_count} note(s) no longer on Substack's saved list."
|
|
120
|
-
click.secho(
|
|
129
|
+
click.secho(
|
|
130
|
+
msg, fg="green" if note_result.status == "success" else "yellow"
|
|
131
|
+
)
|
|
132
|
+
if note_result.status == "partial":
|
|
133
|
+
click.secho(f"Warning: {note_result.error_message}", fg="yellow")
|
|
121
134
|
elif note_result.status == "auth_required":
|
|
122
135
|
click.secho(
|
|
123
136
|
f"Authentication required: {note_result.error_message}", fg="yellow"
|
|
@@ -748,38 +761,56 @@ def inspect_network(
|
|
|
748
761
|
context = browser.new_context(storage_state=storage_state)
|
|
749
762
|
page = context.new_page()
|
|
750
763
|
|
|
751
|
-
def
|
|
752
|
-
|
|
764
|
+
def handle_route(route):
|
|
765
|
+
request = route.request
|
|
766
|
+
req_url = request.url
|
|
767
|
+
if req_url.lower().split("?")[0].endswith(
|
|
768
|
+
_INSPECT_ASSET_SUFFIXES
|
|
769
|
+
) or not pattern.search(req_url):
|
|
770
|
+
route.continue_()
|
|
753
771
|
return
|
|
754
|
-
|
|
772
|
+
|
|
773
|
+
# A sync-API page.on("response") handler can deadlock calling
|
|
774
|
+
# response.text() on the same driver thread that produced it. Routing
|
|
775
|
+
# the request through route.fetch() reads the body safely outside that
|
|
776
|
+
# handler, and route.fulfill() re-serves the exact response to the page
|
|
777
|
+
# so navigation/rendering behaves identically to an unrouted request.
|
|
778
|
+
try:
|
|
779
|
+
response = route.fetch()
|
|
780
|
+
except Exception as e:
|
|
781
|
+
logger.warning(f"Could not fetch intercepted request {req_url}: {e}")
|
|
782
|
+
route.continue_()
|
|
755
783
|
return
|
|
756
784
|
|
|
757
|
-
request_body =
|
|
785
|
+
request_body = request.post_data
|
|
786
|
+
content_type = response.headers.get("content-type") or ""
|
|
758
787
|
response_body = None
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
788
|
+
body_read_failed = False
|
|
789
|
+
if max_body and "json" in content_type:
|
|
790
|
+
try:
|
|
791
|
+
response_body = response.text()[:max_body]
|
|
792
|
+
except Exception as e:
|
|
793
|
+
body_read_failed = True
|
|
794
|
+
logger.warning(f"Could not read response body for {req_url}: {e}")
|
|
766
795
|
|
|
767
796
|
click.echo(
|
|
768
|
-
f"[Network Intercept] {
|
|
797
|
+
f"[Network Intercept] {request.method} {req_url} "
|
|
769
798
|
f"(Status: {response.status})"
|
|
770
799
|
)
|
|
771
800
|
if request_body:
|
|
772
801
|
click.echo(f" Request Body : {request_body}")
|
|
773
802
|
if response_body:
|
|
774
803
|
click.echo(f" Response Body: {response_body}")
|
|
804
|
+
elif body_read_failed:
|
|
805
|
+
click.secho(" Response Body: <could not be read>", fg="yellow")
|
|
775
806
|
|
|
776
807
|
if out_file:
|
|
777
808
|
out_file.write(
|
|
778
809
|
json.dumps(
|
|
779
810
|
{
|
|
780
811
|
"ts": datetime.now(UTC).isoformat(),
|
|
781
|
-
"method":
|
|
782
|
-
"url":
|
|
812
|
+
"method": request.method,
|
|
813
|
+
"url": req_url,
|
|
783
814
|
"status": response.status,
|
|
784
815
|
"request_body": request_body,
|
|
785
816
|
"response_body": response_body,
|
|
@@ -789,7 +820,9 @@ def inspect_network(
|
|
|
789
820
|
)
|
|
790
821
|
out_file.flush()
|
|
791
822
|
|
|
792
|
-
|
|
823
|
+
route.fulfill(response=response)
|
|
824
|
+
|
|
825
|
+
context.route("**/*", handle_route)
|
|
793
826
|
page.goto(url)
|
|
794
827
|
click.echo(
|
|
795
828
|
"Navigate around the page (try the Notes toggle, save/unsave, open an "
|
|
@@ -803,5 +836,192 @@ def inspect_network(
|
|
|
803
836
|
click.echo(f"Capture written to {out_path}")
|
|
804
837
|
|
|
805
838
|
|
|
839
|
+
@cli.command(name="probe-api")
|
|
840
|
+
@click.argument("url")
|
|
841
|
+
@click.option(
|
|
842
|
+
"--out",
|
|
843
|
+
"out_path",
|
|
844
|
+
type=click.Path(dir_okay=False),
|
|
845
|
+
help="Write the raw JSON response to this file instead of stdout.",
|
|
846
|
+
)
|
|
847
|
+
def probe_api(url: str, out_path: str | None) -> None:
|
|
848
|
+
"""Headlessly GET a Substack API URL with the saved session (no browser page).
|
|
849
|
+
|
|
850
|
+
Complements 'inspect-network' (which discovers unknown endpoints by watching
|
|
851
|
+
a live browsing session): use this to re-verify a URL already known, or to
|
|
852
|
+
inspect a specific reader-API response while assessing a migration.
|
|
853
|
+
"""
|
|
854
|
+
client = SubstackSavedPostsClient()
|
|
855
|
+
try:
|
|
856
|
+
data = client.probe_api_get(url)
|
|
857
|
+
except AuthRequiredError as e:
|
|
858
|
+
click.secho(f"Authentication required: {e}", fg="yellow")
|
|
859
|
+
return
|
|
860
|
+
except Exception as e:
|
|
861
|
+
click.secho(f"Error probing {url}: {e}", fg="red")
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
text = json.dumps(data, indent=2)
|
|
865
|
+
if out_path:
|
|
866
|
+
with open(out_path, "w") as f:
|
|
867
|
+
f.write(text)
|
|
868
|
+
click.echo(f"Response written to {out_path}")
|
|
869
|
+
else:
|
|
870
|
+
click.echo(text)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# Fields parse_remote_post() reads off a unified-endpoint post object, used by
|
|
874
|
+
# compare-saved-apis to report per-field presence without printing post content.
|
|
875
|
+
_UNIFIED_POST_FIELDS = (
|
|
876
|
+
"id",
|
|
877
|
+
"canonical_url",
|
|
878
|
+
"title",
|
|
879
|
+
"post_date",
|
|
880
|
+
"saved_at",
|
|
881
|
+
"audience",
|
|
882
|
+
"description",
|
|
883
|
+
"subtitle",
|
|
884
|
+
"cover_image",
|
|
885
|
+
"wordcount",
|
|
886
|
+
"word_count",
|
|
887
|
+
"words",
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@cli.command(name="compare-saved-apis")
|
|
892
|
+
@click.option(
|
|
893
|
+
"--out",
|
|
894
|
+
"out_path",
|
|
895
|
+
type=click.Path(dir_okay=False),
|
|
896
|
+
help=(
|
|
897
|
+
"Write the full report (including mismatched canonical URLs) as JSON to "
|
|
898
|
+
"this file. Without it, only aggregate counts/percentages are printed — "
|
|
899
|
+
"no post URLs or content."
|
|
900
|
+
),
|
|
901
|
+
)
|
|
902
|
+
def compare_saved_apis(out_path: str | None) -> None:
|
|
903
|
+
"""Fetch the full saved-posts list from both the legacy and unified reader
|
|
904
|
+
APIs and report parity: counts, set differences, per-field presence on the
|
|
905
|
+
unified payload, and bookmark-timestamp/ordering — the evidence needed to
|
|
906
|
+
decide whether posts can migrate off the legacy reader-posts API.
|
|
907
|
+
"""
|
|
908
|
+
try:
|
|
909
|
+
from playwright.sync_api import sync_playwright
|
|
910
|
+
except ImportError:
|
|
911
|
+
click.secho("Playwright not installed.", fg="red")
|
|
912
|
+
return
|
|
913
|
+
|
|
914
|
+
client = SubstackSavedPostsClient()
|
|
915
|
+
try:
|
|
916
|
+
client._ensure_authenticated()
|
|
917
|
+
except AuthRequiredError as e:
|
|
918
|
+
click.secho(f"Authentication required: {e}", fg="yellow")
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
with sync_playwright() as p:
|
|
922
|
+
api_context = p.request.new_context(storage_state=str(client.state_path))
|
|
923
|
+
try:
|
|
924
|
+
click.echo("Fetching via legacy reader-posts API...")
|
|
925
|
+
legacy_posts = client._fetch_all_saved_via_reader_api(api_context) or []
|
|
926
|
+
|
|
927
|
+
# This tool runs once, interactively, rather than in a background
|
|
928
|
+
# sync loop, so it can afford to be far more patient than the
|
|
929
|
+
# default sync retry policy: pause briefly before the second full
|
|
930
|
+
# paginated fetch (fetching legacy's full list can itself trigger
|
|
931
|
+
# rate limiting that would otherwise make the very next request
|
|
932
|
+
# 429 through all retries and be misread as "unified has no
|
|
933
|
+
# posts"), and retry the unified fetch harder (max_retries=6,
|
|
934
|
+
# exponential backoff up to ~30s per attempt) before giving up.
|
|
935
|
+
click.echo("Pausing briefly before the unified reader/saved API...")
|
|
936
|
+
time.sleep(5)
|
|
937
|
+
click.echo("Fetching via unified reader/saved API...")
|
|
938
|
+
unified_items = (
|
|
939
|
+
client._fetch_all_saved_posts_via_unified_api(
|
|
940
|
+
api_context, max_retries=6
|
|
941
|
+
)
|
|
942
|
+
or []
|
|
943
|
+
)
|
|
944
|
+
except AuthRequiredError as e:
|
|
945
|
+
click.secho(f"Authentication required: {e}", fg="yellow")
|
|
946
|
+
return
|
|
947
|
+
|
|
948
|
+
legacy_urls = {
|
|
949
|
+
canonicalize_url(item.get("canonical_url") or "") for item in legacy_posts
|
|
950
|
+
}
|
|
951
|
+
legacy_urls.discard("")
|
|
952
|
+
unified_posts = [item.get("post") or {} for item in unified_items]
|
|
953
|
+
unified_urls = {
|
|
954
|
+
canonicalize_url(post.get("canonical_url") or post.get("url") or "")
|
|
955
|
+
for post in unified_posts
|
|
956
|
+
}
|
|
957
|
+
unified_urls.discard("")
|
|
958
|
+
|
|
959
|
+
missing_from_unified = legacy_urls - unified_urls
|
|
960
|
+
missing_from_legacy = unified_urls - legacy_urls
|
|
961
|
+
|
|
962
|
+
n_unified = len(unified_posts) or 1
|
|
963
|
+
field_presence = {
|
|
964
|
+
field: sum(1 for post in unified_posts if post.get(field))
|
|
965
|
+
for field in _UNIFIED_POST_FIELDS
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
saved_ats = [post.get("saved_at") for post in unified_posts if post.get("saved_at")]
|
|
969
|
+
ordered = all(a >= b for a, b in itertools.pairwise(saved_ats))
|
|
970
|
+
|
|
971
|
+
if legacy_posts and not unified_items:
|
|
972
|
+
click.echo("")
|
|
973
|
+
click.secho(
|
|
974
|
+
"Unified API returned 0 posts while legacy returned "
|
|
975
|
+
f"{len(legacy_posts)} — before reading this as 'the endpoint lacks "
|
|
976
|
+
"posts data', re-run compare-saved-apis on its own (not right "
|
|
977
|
+
"after another full sync/fetch): a 429 that survives every retry "
|
|
978
|
+
"reads identically to a genuinely empty/unavailable endpoint, and "
|
|
979
|
+
"the large legacy fetch just before it can trigger exactly that.",
|
|
980
|
+
fg="yellow",
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
click.echo("")
|
|
984
|
+
click.echo(f"Legacy reader-posts API : {len(legacy_posts)} posts")
|
|
985
|
+
click.echo(f"Unified reader/saved API: {len(unified_items)} posts")
|
|
986
|
+
click.echo(f"In both : {len(legacy_urls & unified_urls)}")
|
|
987
|
+
click.echo(f"Only in legacy : {len(missing_from_unified)}")
|
|
988
|
+
click.echo(f"Only in unified : {len(missing_from_legacy)}")
|
|
989
|
+
click.echo("")
|
|
990
|
+
click.echo("Unified payload field presence:")
|
|
991
|
+
for field in _UNIFIED_POST_FIELDS:
|
|
992
|
+
pct = 100 * field_presence[field] / n_unified
|
|
993
|
+
click.echo(
|
|
994
|
+
f" {field:<16} {field_presence[field]:>5}/{len(unified_posts):<5} ({pct:5.1f}%)"
|
|
995
|
+
)
|
|
996
|
+
click.echo("")
|
|
997
|
+
if saved_ats:
|
|
998
|
+
click.echo(
|
|
999
|
+
f"post.saved_at present on {len(saved_ats)}/{len(unified_posts)} items; "
|
|
1000
|
+
f"{'monotonically newest-first' if ordered else 'NOT monotonically ordered'}."
|
|
1001
|
+
)
|
|
1002
|
+
else:
|
|
1003
|
+
click.secho(
|
|
1004
|
+
"No post.saved_at found anywhere in the unified payload — a real "
|
|
1005
|
+
"bookmark timestamp for posts may be absent from this endpoint.",
|
|
1006
|
+
fg="yellow",
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
if out_path:
|
|
1010
|
+
report = {
|
|
1011
|
+
"legacy_count": len(legacy_posts),
|
|
1012
|
+
"unified_count": len(unified_items),
|
|
1013
|
+
"overlap_count": len(legacy_urls & unified_urls),
|
|
1014
|
+
"only_in_legacy": sorted(missing_from_unified),
|
|
1015
|
+
"only_in_unified": sorted(missing_from_legacy),
|
|
1016
|
+
"unified_field_presence": field_presence,
|
|
1017
|
+
"unified_field_presence_denominator": len(unified_posts),
|
|
1018
|
+
"saved_at_present_count": len(saved_ats),
|
|
1019
|
+
"saved_at_ordered_newest_first": ordered if saved_ats else None,
|
|
1020
|
+
}
|
|
1021
|
+
with open(out_path, "w") as f:
|
|
1022
|
+
json.dump(report, f, indent=2)
|
|
1023
|
+
click.echo(f"\nFull report (including mismatched URLs) written to {out_path}")
|
|
1024
|
+
|
|
1025
|
+
|
|
806
1026
|
if __name__ == "__main__":
|
|
807
1027
|
cli()
|
|
@@ -5,6 +5,17 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
APP_NAME = "substack-saved-mcp"
|
|
7
7
|
|
|
8
|
+
_SAVED_POSTS_SOURCES = frozenset({"auto", "legacy", "unified", "dom"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_saved_posts_source() -> str:
|
|
12
|
+
"""Return which saved-posts fetch source to use: 'auto' (default, tries legacy
|
|
13
|
+
then the newer unified reader API then DOM), or a forced 'legacy'/'unified'/'dom'
|
|
14
|
+
for testing and rollback. Falls back to 'auto' on an unrecognized value rather
|
|
15
|
+
than raising, since a typo here shouldn't break every sync."""
|
|
16
|
+
value = (os.getenv("SUBSTACK_SAVED_POSTS_SOURCE") or "auto").strip().lower()
|
|
17
|
+
return value if value in _SAVED_POSTS_SOURCES else "auto"
|
|
18
|
+
|
|
8
19
|
|
|
9
20
|
def get_default_data_dir() -> Path:
|
|
10
21
|
"""Return OS-appropriate application data directory."""
|