substack-saved-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Substack Saved Posts MCP & Sync Application."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,413 @@
1
+ """Command Line Interface (CLI) for Substack Saved Posts MCP & Sync tool."""
2
+
3
+ import sys
4
+
5
+ import click
6
+
7
+ from substack_saved_mcp.content_utils import format_post_for_llm, html_to_llm_text
8
+ from substack_saved_mcp.database import (
9
+ get_post,
10
+ get_status,
11
+ init_db,
12
+ soft_delete_post,
13
+ upsert_post,
14
+ )
15
+ from substack_saved_mcp.database import (
16
+ list_audiences as db_list_audiences,
17
+ )
18
+ from substack_saved_mcp.database import (
19
+ list_posts as db_list_posts,
20
+ )
21
+ from substack_saved_mcp.database import (
22
+ list_publications as db_list_publications,
23
+ )
24
+ from substack_saved_mcp.database import (
25
+ search_posts as db_search_posts,
26
+ )
27
+ from substack_saved_mcp.mcp_server import run_server
28
+ from substack_saved_mcp.substack_client import (
29
+ AuthRequiredError,
30
+ SubstackSavedPostsClient,
31
+ perform_interactive_login,
32
+ )
33
+ from substack_saved_mcp.sync import sync_saved_posts as run_sync
34
+
35
+
36
+ @click.group()
37
+ @click.version_option(version="0.1.0")
38
+ def cli() -> None:
39
+ """Substack Saved Posts MCP & Sync Engine."""
40
+ pass
41
+
42
+
43
+ @cli.command()
44
+ def init() -> None:
45
+ """Initialize local SQLite database schema and FTS5 search index."""
46
+ init_db()
47
+ status = get_status()
48
+ click.echo(f"Initialized database at: {status.database_path}")
49
+ click.echo(f"Active saved posts: {status.total_saved_posts}")
50
+
51
+
52
+ @cli.command()
53
+ def login() -> None:
54
+ """Launch interactive browser window to log in to your Substack account."""
55
+ try:
56
+ state_file = perform_interactive_login()
57
+ click.echo(f"Session saved to: {state_file}")
58
+ except Exception as e:
59
+ click.secho(f"Error during login: {e}", fg="red")
60
+ sys.exit(1)
61
+
62
+
63
+ @cli.command()
64
+ @click.option(
65
+ "--force", is_flag=True, help="Force full resync instead of incremental stop."
66
+ )
67
+ def sync(force: bool) -> None:
68
+ """Sync saved posts from Substack account into local SQLite cache."""
69
+ click.echo("Starting Substack saved posts sync...")
70
+ result = run_sync(force=force)
71
+
72
+ if result.status == "success":
73
+ msg = f"Sync complete! Fetched {result.fetched_count} posts, upserted {result.upserted_count} posts."
74
+ if result.reconciled_count:
75
+ msg += f" Unsaved {result.reconciled_count} post(s) no longer on Substack's saved list."
76
+ click.secho(msg, fg="green")
77
+ elif result.status == "auth_required":
78
+ click.secho(f"Authentication required: {result.error_message}", fg="yellow")
79
+ else:
80
+ click.secho(f"Sync failed: {result.error_message}", fg="red")
81
+
82
+
83
+ @cli.command()
84
+ def serve() -> None:
85
+ """Run FastMCP stdio server for desktop clients (Claude Desktop, Goose, etc.)."""
86
+ init_db()
87
+ run_server()
88
+
89
+
90
+ @cli.command()
91
+ @click.argument("url")
92
+ def save(url: str) -> None:
93
+ """Save/bookmark a Substack post by URL."""
94
+ init_db()
95
+ click.echo(f"Saving post: {url}...")
96
+ client = SubstackSavedPostsClient()
97
+ try:
98
+ post, confirmation = client.save_post(url)
99
+ saved_db_post = upsert_post(post)
100
+ click.secho(
101
+ f"Successfully saved '{saved_db_post.title}' to local cache!", fg="green"
102
+ )
103
+ click.echo(f"Published at: {saved_db_post.published_at or 'N/A'}")
104
+ click.echo(f"Saved at: {saved_db_post.saved_at}")
105
+ if confirmation != "confirmed":
106
+ click.secho(
107
+ f"Warning: could not confirm the bookmark was saved on Substack's own page "
108
+ f"(status: {confirmation}). Cached locally regardless; a future 'sync --force' "
109
+ "will correct it if the remote save didn't actually happen.",
110
+ fg="yellow",
111
+ )
112
+ except AuthRequiredError as e:
113
+ click.secho(f"Authentication required: {e}", fg="yellow")
114
+ except Exception as e:
115
+ click.secho(f"Error saving post: {e}", fg="red")
116
+
117
+
118
+ @cli.command()
119
+ @click.argument("url_or_id")
120
+ def unsave(url_or_id: str) -> None:
121
+ """Unsave/unbookmark a Substack post by URL or local ID."""
122
+ init_db()
123
+ post = get_post(url_or_id)
124
+ if not post:
125
+ click.secho(f"Post '{url_or_id}' not found in local cache.", fg="yellow")
126
+ return
127
+
128
+ click.echo(f"Unsaving post '{post.title}'...")
129
+ client = SubstackSavedPostsClient()
130
+ confirmation = "click_failed"
131
+ try:
132
+ post_id = int(post.substack_post_id) if post.substack_post_id else None
133
+ confirmation = client.unsave_post(post.url, post_id=post_id)
134
+ except Exception as e:
135
+ click.echo(f"Remote unsave notice: {e}")
136
+
137
+ updated = soft_delete_post(post.url)
138
+ if updated:
139
+ click.secho(
140
+ f"Successfully unsaved '{post.title}' from local cache.", fg="green"
141
+ )
142
+ if confirmation != "confirmed":
143
+ click.secho(
144
+ f"Warning: could not confirm the bookmark was removed on Substack's own page (status: {confirmation}).",
145
+ fg="yellow",
146
+ )
147
+
148
+
149
+ @cli.command()
150
+ @click.argument("query")
151
+ @click.option("--publication", help="Filter by publication name.")
152
+ @click.option(
153
+ "--audience",
154
+ help="Filter by audience tier (e.g. everyone, only_paid). See 'audiences' command for cached values.",
155
+ )
156
+ @click.option(
157
+ "--published-after",
158
+ help="Only posts published on/after this ISO-8601 date (e.g. 2026-01-01).",
159
+ )
160
+ @click.option(
161
+ "--published-before", help="Only posts published on/before this ISO-8601 date."
162
+ )
163
+ @click.option(
164
+ "--saved-after", help="Only posts bookmarked on/after this ISO-8601 date."
165
+ )
166
+ @click.option(
167
+ "--saved-before", help="Only posts bookmarked on/before this ISO-8601 date."
168
+ )
169
+ @click.option("--limit", default=10, help="Maximum search results.")
170
+ def search(
171
+ query: str,
172
+ publication: str | None,
173
+ audience: str | None,
174
+ published_after: str | None,
175
+ published_before: str | None,
176
+ saved_after: str | None,
177
+ saved_before: str | None,
178
+ limit: int,
179
+ ) -> None:
180
+ """Perform full-text search across cached saved posts."""
181
+ init_db()
182
+ results = db_search_posts(
183
+ query=query,
184
+ publication=publication,
185
+ audience=audience,
186
+ published_after=published_after,
187
+ published_before=published_before,
188
+ saved_after=saved_after,
189
+ saved_before=saved_before,
190
+ limit=limit,
191
+ )
192
+ if not results:
193
+ click.echo(f"No saved posts matched query '{query}'.")
194
+ return
195
+
196
+ click.echo(f"Found {len(results)} matching post(s):\n")
197
+ for idx, p in enumerate(results, 1):
198
+ click.secho(f"{idx}. {p.title}", fg="cyan", bold=True)
199
+ click.echo(f" Publication : {p.publication_name}")
200
+ click.echo(
201
+ f" Published : {p.published_at or 'N/A'} | Saved: {p.saved_at or 'N/A'}"
202
+ )
203
+ click.echo(f" Audience : {p.audience or 'N/A'}")
204
+ if p.reading_time_minutes or p.word_count:
205
+ click.echo(
206
+ f" Reading time: {p.reading_time_minutes or '?'} min ({p.word_count or '?'} words)"
207
+ )
208
+ click.echo(f" URL : {p.url}")
209
+ if p.excerpt:
210
+ click.echo(f" Excerpt : {p.excerpt[:120]}...")
211
+ if p.image_url:
212
+ click.echo(f" Image : {p.image_url}")
213
+ click.echo("")
214
+
215
+
216
+ @cli.command(name="list")
217
+ @click.option("--limit", default=10, help="Number of posts to display.")
218
+ @click.option("--offset", default=0, help="Pagination offset.")
219
+ @click.option("--publication", help="Filter by publication name.")
220
+ @click.option(
221
+ "--audience",
222
+ help="Filter by audience tier (e.g. everyone, only_paid). See 'audiences' command for cached values.",
223
+ )
224
+ @click.option(
225
+ "--sort-by", type=click.Choice(["saved_at", "published_at"]), default="saved_at"
226
+ )
227
+ def list_cmd(
228
+ limit: int, offset: int, publication: str | None, audience: str | None, sort_by: str
229
+ ) -> None:
230
+ """List saved posts ordered by saved date or publication date."""
231
+ init_db()
232
+ posts = db_list_posts(
233
+ limit=limit,
234
+ offset=offset,
235
+ publication=publication,
236
+ audience=audience,
237
+ sort_by=sort_by,
238
+ )
239
+ if not posts:
240
+ click.echo("No saved posts found.")
241
+ return
242
+
243
+ click.echo(f"Saved Posts ({len(posts)} displayed):\n")
244
+ for idx, p in enumerate(posts, offset + 1):
245
+ reading = (
246
+ f" | {p.reading_time_minutes} min ({p.word_count} words)"
247
+ if p.reading_time_minutes
248
+ else ""
249
+ )
250
+ click.secho(f"{idx}. {p.title}", fg="cyan")
251
+ click.echo(
252
+ f" Pub: {p.publication_name} | Saved: {p.saved_at or 'N/A'} | Published: {p.published_at or 'N/A'} | Audience: {p.audience or 'N/A'}{reading}"
253
+ )
254
+ click.echo(f" URL: {p.url}\n")
255
+
256
+
257
+ @cli.command()
258
+ def publications() -> None:
259
+ """List all publications present in the cache."""
260
+ init_db()
261
+ pubs = db_list_publications()
262
+ if not pubs:
263
+ click.echo("No publications in cache.")
264
+ return
265
+
266
+ click.echo(f"Cached Publications ({len(pubs)} total):\n")
267
+ for p in pubs:
268
+ click.echo(
269
+ f"- {p.publication_name} ({p.post_count} saved post{'s' if p.post_count != 1 else ''})"
270
+ )
271
+
272
+
273
+ @cli.command()
274
+ def audiences() -> None:
275
+ """List all audience tiers present in the cache (e.g. everyone, only_paid)."""
276
+ init_db()
277
+ tiers = db_list_audiences()
278
+ if not tiers:
279
+ click.echo("No posts in cache.")
280
+ return
281
+
282
+ click.echo(f"Cached Audience Tiers ({len(tiers)} total):\n")
283
+ for t in tiers:
284
+ click.echo(
285
+ f"- {t.audience or 'unknown'} ({t.post_count} saved post{'s' if t.post_count != 1 else ''})"
286
+ )
287
+
288
+
289
+ @cli.command()
290
+ def status() -> None:
291
+ """Show cache statistics and last sync run info."""
292
+ init_db()
293
+ st = get_status()
294
+ click.echo(f"Database Path : {st.database_path}")
295
+ click.echo(f"Active Saved Posts : {st.total_saved_posts}")
296
+ click.echo(f"Unsaved Posts : {st.total_unsaved_posts}")
297
+ click.echo(f"Total Publications : {st.total_publications}")
298
+ click.echo(f"Last Successful Sync: {st.last_successful_sync or 'Never'}")
299
+ click.echo(f"Last Sync Status : {st.last_sync_status or 'N/A'}")
300
+
301
+
302
+ @cli.command(name="get-content")
303
+ @click.argument("url_or_id")
304
+ @click.option(
305
+ "--no-cache",
306
+ is_flag=True,
307
+ help="Don't store the fetched content in the local cache.",
308
+ )
309
+ def get_content(url_or_id: str, no_cache: bool) -> None:
310
+ """Fetch a saved post's full content and print it formatted for an LLM.
311
+
312
+ Uses the cached content_text if a previous fetch already stored it;
313
+ otherwise fetches the post's page and caches the result unless --no-cache
314
+ is given.
315
+ """
316
+ init_db()
317
+ post = get_post(url_or_id)
318
+ if not post:
319
+ click.secho(f"Post '{url_or_id}' not found in local cache.", fg="yellow")
320
+ return
321
+
322
+ if post.content_text:
323
+ click.echo(
324
+ format_post_for_llm(
325
+ title=post.title,
326
+ publication_name=post.publication_name,
327
+ url=post.url,
328
+ body_text=post.content_text,
329
+ author_name=post.author_name,
330
+ published_at=post.published_at,
331
+ )
332
+ )
333
+ return
334
+
335
+ click.echo(f"Fetching full content for '{post.title}'...", err=True)
336
+ client = SubstackSavedPostsClient()
337
+ try:
338
+ result = client.fetch_post_content(post.url)
339
+ except AuthRequiredError as e:
340
+ click.secho(f"Authentication required: {e}", fg="yellow")
341
+ return
342
+ except Exception as e:
343
+ click.secho(f"Error fetching content: {e}", fg="red")
344
+ return
345
+
346
+ body_html = result.get("body_html")
347
+ if not body_html:
348
+ click.secho(
349
+ "Could not find this post's full content on its page (Substack may have "
350
+ "changed how it embeds it, or this post is paywalled beyond your account's "
351
+ "access). Run 'substack-saved-mcp inspect-network' while opening this "
352
+ f"post ({post.url}) in the browser so we can capture the real content "
353
+ "source, then this command can be updated.",
354
+ fg="yellow",
355
+ )
356
+ return
357
+
358
+ body_text = html_to_llm_text(body_html)
359
+ if not no_cache:
360
+ post.content_text = body_text
361
+ post = upsert_post(post)
362
+
363
+ click.echo(
364
+ format_post_for_llm(
365
+ title=post.title,
366
+ publication_name=post.publication_name,
367
+ url=post.url,
368
+ body_text=body_text,
369
+ author_name=post.author_name,
370
+ published_at=post.published_at,
371
+ )
372
+ )
373
+
374
+
375
+ @cli.command()
376
+ def inspect_network() -> None:
377
+ """Inspect and capture Substack saved posts network endpoints structure safely."""
378
+ click.echo("Launching Playwright inspector context...")
379
+ try:
380
+ from playwright.sync_api import sync_playwright
381
+ except ImportError:
382
+ click.secho("Playwright not installed.", fg="red")
383
+ return
384
+
385
+ with sync_playwright() as p:
386
+ browser = p.chromium.launch(headless=False)
387
+ context = browser.new_context()
388
+ page = context.new_page()
389
+
390
+ def handle_response(response):
391
+ if (
392
+ "api/v1" in response.url
393
+ or "bookmark" in response.url
394
+ or "saved" in response.url
395
+ ):
396
+ click.echo(
397
+ f"[Network Intercept] {response.request.method} {response.url} (Status: {response.status})"
398
+ )
399
+ post_data = response.request.post_data
400
+ if post_data:
401
+ click.echo(f" Body: {post_data}")
402
+
403
+ page.on("response", handle_response)
404
+ page.goto("https://substack.com/saved")
405
+ click.echo(
406
+ "Navigate around your saved posts page. Press ENTER in terminal when finished."
407
+ )
408
+ input("--> Press ENTER to finish network inspection: ")
409
+ browser.close()
410
+
411
+
412
+ if __name__ == "__main__":
413
+ cli()
@@ -0,0 +1,55 @@
1
+ """Configuration settings and filesystem path management."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ APP_NAME = "substack-saved-mcp"
7
+
8
+
9
+ def get_default_data_dir() -> Path:
10
+ """Return OS-appropriate application data directory."""
11
+ if env_dir := os.getenv("SUBSTACK_SAVED_DATA_DIR"):
12
+ return Path(env_dir).expanduser().resolve()
13
+
14
+ xdg_data = os.getenv("XDG_DATA_HOME")
15
+ if xdg_data:
16
+ return (Path(xdg_data) / APP_NAME).resolve()
17
+
18
+ # Default to user home local share or fallback
19
+ return (Path.home() / ".local" / "share" / APP_NAME).resolve()
20
+
21
+
22
+ def get_db_path() -> Path:
23
+ """Return path to SQLite database."""
24
+ if env_db := os.getenv("SUBSTACK_SAVED_DB_PATH"):
25
+ return Path(env_db).expanduser().resolve()
26
+ return get_default_data_dir() / "saved_posts.sqlite"
27
+
28
+
29
+ def get_browser_dir() -> Path:
30
+ """Return path to Playwright browser context / storage state directory."""
31
+ if env_browser := os.getenv("SUBSTACK_SAVED_BROWSER_DIR"):
32
+ return Path(env_browser).expanduser().resolve()
33
+ return get_default_data_dir() / "browser_state"
34
+
35
+
36
+ def get_storage_state_path() -> Path:
37
+ """Return path to storage_state.json."""
38
+ return get_browser_dir() / "storage_state.json"
39
+
40
+
41
+ def ensure_app_dirs() -> None:
42
+ """Ensure data and browser state directories exist with restrictive permissions (0o700)."""
43
+ data_dir = get_default_data_dir()
44
+ browser_dir = get_browser_dir()
45
+
46
+ data_dir.mkdir(parents=True, exist_ok=True)
47
+ browser_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ # Restrict permissions to user-only (read/write/execute) on POSIX systems
50
+ if os.name == "posix":
51
+ try:
52
+ data_dir.chmod(0o700)
53
+ browser_dir.chmod(0o700)
54
+ except Exception:
55
+ pass
@@ -0,0 +1,151 @@
1
+ """Convert Substack post HTML content into clean text suitable for feeding to an LLM."""
2
+
3
+ from html.parser import HTMLParser
4
+
5
+ _BLOCK_TAGS = {
6
+ "p",
7
+ "div",
8
+ "section",
9
+ "article",
10
+ "blockquote",
11
+ "pre",
12
+ "h1",
13
+ "h2",
14
+ "h3",
15
+ "h4",
16
+ "h5",
17
+ "h6",
18
+ "ul",
19
+ "ol",
20
+ "li",
21
+ "tr",
22
+ "table",
23
+ "figure",
24
+ "figcaption",
25
+ "hr",
26
+ }
27
+ _SKIP_CONTENT_TAGS = {"script", "style", "noscript", "iframe"}
28
+ _HEADING_PREFIX = {
29
+ "h1": "# ",
30
+ "h2": "## ",
31
+ "h3": "### ",
32
+ "h4": "#### ",
33
+ "h5": "##### ",
34
+ "h6": "###### ",
35
+ }
36
+
37
+
38
+ class _PostBodyToTextParser(HTMLParser):
39
+ """Minimal HTML-to-markdown-ish-text converter for Substack post body HTML."""
40
+
41
+ def __init__(self) -> None:
42
+ super().__init__(convert_charrefs=True)
43
+ self._out: list[str] = []
44
+ self._skip_depth = 0
45
+ self._tag_stack: list[str] = []
46
+ self._link_href_stack: list[str | None] = []
47
+ self._list_item_open = False
48
+
49
+ def _write(self, text: str) -> None:
50
+ if text:
51
+ self._out.append(text)
52
+
53
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
54
+ if tag in _SKIP_CONTENT_TAGS:
55
+ self._skip_depth += 1
56
+ return
57
+ if self._skip_depth:
58
+ return
59
+
60
+ self._tag_stack.append(tag)
61
+
62
+ if tag == "br":
63
+ self._write("\n")
64
+ elif tag in _HEADING_PREFIX:
65
+ self._write("\n\n" + _HEADING_PREFIX[tag])
66
+ elif tag == "li":
67
+ self._write("\n- ")
68
+ self._list_item_open = True
69
+ elif tag in _BLOCK_TAGS:
70
+ self._write("\n\n")
71
+ elif tag in ("strong", "b"):
72
+ self._write("**")
73
+ elif tag in ("em", "i"):
74
+ self._write("*")
75
+ elif tag == "a":
76
+ href = dict(attrs).get("href")
77
+ self._link_href_stack.append(href)
78
+
79
+ def handle_endtag(self, tag: str) -> None:
80
+ if tag in _SKIP_CONTENT_TAGS:
81
+ if self._skip_depth:
82
+ self._skip_depth -= 1
83
+ return
84
+ if self._skip_depth:
85
+ return
86
+
87
+ if self._tag_stack and self._tag_stack[-1] == tag:
88
+ self._tag_stack.pop()
89
+
90
+ if tag in ("strong", "b"):
91
+ self._write("**")
92
+ elif tag in ("em", "i"):
93
+ self._write("*")
94
+ elif tag == "a" and self._link_href_stack:
95
+ href = self._link_href_stack.pop()
96
+ if href and not href.startswith("#"):
97
+ self._write(f" ({href})")
98
+ elif tag in _BLOCK_TAGS:
99
+ self._write("\n\n")
100
+
101
+ def handle_data(self, data: str) -> None:
102
+ if self._skip_depth:
103
+ return
104
+ self._write(data)
105
+
106
+ def get_text(self) -> str:
107
+ raw = "".join(self._out)
108
+ lines = [line.strip() for line in raw.splitlines()]
109
+ collapsed: list[str] = []
110
+ blank_run = 0
111
+ for line in lines:
112
+ if not line:
113
+ blank_run += 1
114
+ if blank_run <= 1:
115
+ collapsed.append("")
116
+ else:
117
+ blank_run = 0
118
+ collapsed.append(line)
119
+ return "\n".join(collapsed).strip()
120
+
121
+
122
+ def html_to_llm_text(html: str) -> str:
123
+ """Strip a Substack post's ``body_html`` down to clean text for LLM consumption.
124
+
125
+ Converts headings to markdown-style ``#`` prefixes, list items to ``- ``
126
+ bullets, links to ``text (url)``, and collapses excess whitespace, while
127
+ dropping script/style/iframe content entirely.
128
+ """
129
+ parser = _PostBodyToTextParser()
130
+ parser.feed(html or "")
131
+ parser.close()
132
+ return parser.get_text()
133
+
134
+
135
+ def format_post_for_llm(
136
+ title: str,
137
+ publication_name: str,
138
+ url: str,
139
+ body_text: str,
140
+ author_name: str | None = None,
141
+ published_at: str | None = None,
142
+ ) -> str:
143
+ """Assemble a post's metadata and cleaned body text into one LLM-ready document."""
144
+ header_lines = [f"Title: {title}", f"Publication: {publication_name}"]
145
+ if author_name:
146
+ header_lines.append(f"Author: {author_name}")
147
+ if published_at:
148
+ header_lines.append(f"Published: {published_at}")
149
+ header_lines.append(f"URL: {url}")
150
+
151
+ return "\n".join(header_lines) + "\n\n" + body_text