substack-saved-mcp 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/PKG-INFO +1 -2
  2. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/pyproject.toml +1 -2
  3. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/__init__.py +1 -1
  4. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/cli.py +4 -12
  5. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/config.py +5 -2
  6. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/database.py +52 -77
  7. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/substack_client.py +67 -90
  8. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_substack_client.py +16 -0
  9. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/uv.lock +1 -16
  10. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.github/workflows/ci.yml +0 -0
  11. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.github/workflows/pypi.yml +0 -0
  12. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.gitignore +0 -0
  13. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.pre-commit-config.yaml +0 -0
  14. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/CLAUDE.md +0 -0
  15. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/LICENSE +0 -0
  16. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/PLAN.md +0 -0
  17. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/README.md +0 -0
  18. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/content_utils.py +0 -0
  19. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/mcp_server.py +0 -0
  20. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/models.py +0 -0
  21. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/sync.py +0 -0
  22. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/url_utils.py +0 -0
  23. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_cli.py +0 -0
  24. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_content_utils.py +0 -0
  25. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_database.py +0 -0
  26. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_mcp_server.py +0 -0
  27. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_normalization.py +0 -0
  28. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_sync.py +0 -0
  29. {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_url_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: substack-saved-mcp
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -11,7 +11,6 @@ Requires-Dist: fastmcp>=0.1.0
11
11
  Requires-Dist: playwright>=1.40.0
12
12
  Requires-Dist: pydantic>=2.0.0
13
13
  Provides-Extra: dev
14
- Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
15
14
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
15
  Requires-Dist: ruff==0.16.1; extra == 'dev'
17
16
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "substack-saved-mcp"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Local stdio-based MCP server and sync engine for Substack saved posts"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -20,7 +20,6 @@ dependencies = [
20
20
  [project.optional-dependencies]
21
21
  dev = [
22
22
  "pytest>=8.0.0",
23
- "pytest-asyncio>=0.23.0",
24
23
  "ruff==0.16.1",
25
24
  ]
26
25
 
@@ -1,3 +1,3 @@
1
1
  """Substack Saved Posts MCP & Sync Application."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
@@ -50,6 +50,8 @@ from substack_saved_mcp.database import (
50
50
  )
51
51
  from substack_saved_mcp.mcp_server import run_server
52
52
  from substack_saved_mcp.substack_client import (
53
+ STEALTH_CONTEXT_KWARGS,
54
+ STEALTH_LAUNCH_ARGS,
53
55
  AuthRequiredError,
54
56
  SubstackSavedPostsClient,
55
57
  perform_interactive_login,
@@ -806,18 +808,8 @@ def inspect_network(
806
808
  out_file = open(out_path, "a") if out_path else None
807
809
 
808
810
  with sync_playwright() as p:
809
- browser = p.chromium.launch(
810
- headless=False,
811
- args=["--disable-blink-features=AutomationControlled"],
812
- )
813
- context_kwargs = {
814
- "user_agent": (
815
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
816
- "AppleWebKit/537.36 (KHTML, like Gecko) "
817
- "Chrome/131.0.0.0 Safari/537.36"
818
- ),
819
- "viewport": {"width": 1280, "height": 800},
820
- }
811
+ browser = p.chromium.launch(headless=False, args=STEALTH_LAUNCH_ARGS)
812
+ context_kwargs = dict(STEALTH_CONTEXT_KWARGS)
821
813
  if storage_state:
822
814
  context_kwargs["storage_state"] = storage_state
823
815
 
@@ -1,8 +1,11 @@
1
1
  """Configuration settings and filesystem path management."""
2
2
 
3
+ import logging
3
4
  import os
4
5
  from pathlib import Path
5
6
 
7
+ logger = logging.getLogger(__name__)
8
+
6
9
  APP_NAME = "substack-saved-mcp"
7
10
 
8
11
  _SAVED_POSTS_SOURCES = frozenset({"auto", "legacy", "unified", "dom"})
@@ -80,5 +83,5 @@ def ensure_app_dirs() -> None:
80
83
  try:
81
84
  data_dir.chmod(0o700)
82
85
  browser_dir.chmod(0o700)
83
- except Exception:
84
- pass
86
+ except Exception as err:
87
+ logger.warning(f"Could not restrict permissions on {data_dir}: {err}")
@@ -66,6 +66,52 @@ def _read_state_clause(
66
66
  return clause, params
67
67
 
68
68
 
69
+ def _resolve_row(
70
+ cursor: sqlite3.Cursor, table: str, id_column: str, url_or_id: str | int
71
+ ) -> sqlite3.Row | None:
72
+ """Look up a posts/notes row by local numeric id, or by URL/Substack id
73
+ otherwise. Shared by get_post/soft_delete_post/get_note/soft_delete_note,
74
+ which all resolved this identically except for the table and id column name."""
75
+ if isinstance(url_or_id, int) or (
76
+ isinstance(url_or_id, str) and url_or_id.isdigit()
77
+ ):
78
+ cursor.execute(f"SELECT * FROM {table} WHERE id = ?", (int(url_or_id),))
79
+ else:
80
+ clean_url = canonicalize_url(str(url_or_id))
81
+ cursor.execute(
82
+ f"SELECT * FROM {table} WHERE url = ? OR {id_column} = ?",
83
+ (clean_url, str(url_or_id)),
84
+ )
85
+ return cursor.fetchone()
86
+
87
+
88
+ def _last_sync_info(
89
+ cursor: sqlite3.Cursor, entity: str
90
+ ) -> tuple[str | None, str | None]:
91
+ """Return (last_successful_completed_at, last_status) for a sync_runs
92
+ entity ('post' or 'note'). Shared by get_status()'s post/note blocks,
93
+ which were otherwise identical two-query lookups."""
94
+ cursor.execute(
95
+ """
96
+ SELECT completed_at FROM sync_runs
97
+ WHERE status = 'success' AND entity = ?
98
+ ORDER BY id DESC LIMIT 1
99
+ """,
100
+ (entity,),
101
+ )
102
+ success_row = cursor.fetchone()
103
+ last_success = success_row["completed_at"] if success_row else None
104
+
105
+ cursor.execute(
106
+ "SELECT status FROM sync_runs WHERE entity = ? ORDER BY id DESC LIMIT 1",
107
+ (entity,),
108
+ )
109
+ status_row = cursor.fetchone()
110
+ last_status = status_row["status"] if status_row else None
111
+
112
+ return last_success, last_status
113
+
114
+
69
115
  @contextmanager
70
116
  def get_db_connection(
71
117
  db_path: Path | None = None,
@@ -435,18 +481,7 @@ def soft_delete_post(
435
481
  with get_db_connection(db_path) as conn:
436
482
  cursor = conn.cursor()
437
483
 
438
- if isinstance(url_or_id, int) or (
439
- isinstance(url_or_id, str) and url_or_id.isdigit()
440
- ):
441
- cursor.execute("SELECT * FROM posts WHERE id = ?", (int(url_or_id),))
442
- else:
443
- clean_url = canonicalize_url(str(url_or_id))
444
- cursor.execute(
445
- "SELECT * FROM posts WHERE url = ? OR substack_post_id = ?",
446
- (clean_url, str(url_or_id)),
447
- )
448
-
449
- row = cursor.fetchone()
484
+ row = _resolve_row(cursor, "posts", "substack_post_id", url_or_id)
450
485
  if not row:
451
486
  return None
452
487
 
@@ -496,17 +531,7 @@ def get_post(url_or_id: str | int, db_path: Path | None = None) -> SavedPost | N
496
531
  """Retrieve full post record by local ID, Substack post ID, or URL."""
497
532
  with get_db_connection(db_path) as conn:
498
533
  cursor = conn.cursor()
499
- if isinstance(url_or_id, int) or (
500
- isinstance(url_or_id, str) and url_or_id.isdigit()
501
- ):
502
- cursor.execute("SELECT * FROM posts WHERE id = ?", (int(url_or_id),))
503
- else:
504
- clean_url = canonicalize_url(str(url_or_id))
505
- cursor.execute(
506
- "SELECT * FROM posts WHERE url = ? OR substack_post_id = ?",
507
- (clean_url, str(url_or_id)),
508
- )
509
- row = cursor.fetchone()
534
+ row = _resolve_row(cursor, "posts", "substack_post_id", url_or_id)
510
535
  return SavedPost(**dict(row)) if row else None
511
536
 
512
537
 
@@ -775,37 +800,8 @@ def get_status(db_path: Path | None = None) -> SavedPostsStatus:
775
800
  remaining_words = cursor.fetchone()[0] or 0
776
801
  minutes_remaining_total = math.ceil(remaining_words / WORDS_PER_MINUTE)
777
802
 
778
- cursor.execute("""
779
- SELECT completed_at, status FROM sync_runs
780
- WHERE status = 'success' AND entity = 'post'
781
- ORDER BY id DESC LIMIT 1
782
- """)
783
- last_success_row = cursor.fetchone()
784
- last_success = last_success_row["completed_at"] if last_success_row else None
785
-
786
- cursor.execute(
787
- "SELECT status FROM sync_runs WHERE entity = 'post' ORDER BY id DESC LIMIT 1"
788
- )
789
- last_status_row = cursor.fetchone()
790
- last_status = last_status_row["status"] if last_status_row else None
791
-
792
- cursor.execute("""
793
- SELECT completed_at, status FROM sync_runs
794
- WHERE status = 'success' AND entity = 'note'
795
- ORDER BY id DESC LIMIT 1
796
- """)
797
- last_note_success_row = cursor.fetchone()
798
- last_note_success = (
799
- last_note_success_row["completed_at"] if last_note_success_row else None
800
- )
801
-
802
- cursor.execute(
803
- "SELECT status FROM sync_runs WHERE entity = 'note' ORDER BY id DESC LIMIT 1"
804
- )
805
- last_note_status_row = cursor.fetchone()
806
- last_note_status = (
807
- last_note_status_row["status"] if last_note_status_row else None
808
- )
803
+ last_success, last_status = _last_sync_info(cursor, "post")
804
+ last_note_success, last_note_status = _last_sync_info(cursor, "note")
809
805
 
810
806
  return SavedPostsStatus(
811
807
  total_saved_posts=total_saved,
@@ -1003,17 +999,7 @@ def get_note(url_or_id: str | int, db_path: Path | None = None) -> SavedNote | N
1003
999
  """Retrieve full note record by local ID, Substack note ID, or URL."""
1004
1000
  with get_db_connection(db_path) as conn:
1005
1001
  cursor = conn.cursor()
1006
- if isinstance(url_or_id, int) or (
1007
- isinstance(url_or_id, str) and url_or_id.isdigit()
1008
- ):
1009
- cursor.execute("SELECT * FROM notes WHERE id = ?", (int(url_or_id),))
1010
- else:
1011
- clean_url = canonicalize_url(str(url_or_id))
1012
- cursor.execute(
1013
- "SELECT * FROM notes WHERE url = ? OR substack_note_id = ?",
1014
- (clean_url, str(url_or_id)),
1015
- )
1016
- row = cursor.fetchone()
1002
+ row = _resolve_row(cursor, "notes", "substack_note_id", url_or_id)
1017
1003
  return SavedNote(**dict(row)) if row else None
1018
1004
 
1019
1005
 
@@ -1044,18 +1030,7 @@ def soft_delete_note(
1044
1030
  with get_db_connection(db_path) as conn:
1045
1031
  cursor = conn.cursor()
1046
1032
 
1047
- if isinstance(url_or_id, int) or (
1048
- isinstance(url_or_id, str) and url_or_id.isdigit()
1049
- ):
1050
- cursor.execute("SELECT * FROM notes WHERE id = ?", (int(url_or_id),))
1051
- else:
1052
- clean_url = canonicalize_url(str(url_or_id))
1053
- cursor.execute(
1054
- "SELECT * FROM notes WHERE url = ? OR substack_note_id = ?",
1055
- (clean_url, str(url_or_id)),
1056
- )
1057
-
1058
- row = cursor.fetchone()
1033
+ row = _resolve_row(cursor, "notes", "substack_note_id", url_or_id)
1059
1034
  if not row:
1060
1035
  return None
1061
1036
 
@@ -3,6 +3,7 @@
3
3
  import concurrent.futures
4
4
  import json
5
5
  import logging
6
+ import os
6
7
  import re
7
8
  import time
8
9
  from pathlib import Path
@@ -24,6 +25,18 @@ logger = logging.getLogger(__name__)
24
25
  # https://substack.com/@handle/note/c-300984381 -> "300984381"
25
26
  _NOTE_URL_ID_PATTERN = re.compile(r"/note/c-(\d+)")
26
27
 
28
+ # Anti-bot evasion for the two headful browser flows (login, inspect-network):
29
+ # a real Chrome UA/viewport plus disabling the automation-controlled flag.
30
+ STEALTH_LAUNCH_ARGS = ["--disable-blink-features=AutomationControlled"]
31
+ STEALTH_CONTEXT_KWARGS: dict[str, Any] = {
32
+ "user_agent": (
33
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
34
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
35
+ "Chrome/131.0.0.0 Safari/537.36"
36
+ ),
37
+ "viewport": {"width": 1280, "height": 800},
38
+ }
39
+
27
40
 
28
41
  def _extract_note_id(url: str) -> str | None:
29
42
  match = _NOTE_URL_ID_PATTERN.search(url)
@@ -46,6 +59,20 @@ def _run_playwright_sync(func, *args, **kwargs):
46
59
  return func(*args, **kwargs)
47
60
 
48
61
 
62
+ def _with_playwright(playwright_instance: Any, fn):
63
+ """Run fn(p) using an injected Playwright instance (test double) if given,
64
+ else spin up a real one. Shared by every ``_*_impl`` method's playwright
65
+ dispatch instead of each repeating the same instance-or-launch branch."""
66
+ if playwright_instance is not None:
67
+ return fn(playwright_instance)
68
+ try:
69
+ from playwright.sync_api import sync_playwright
70
+ except ImportError:
71
+ raise SubstackClientError("Playwright is not installed.") from None
72
+ with sync_playwright() as p:
73
+ return fn(p)
74
+
75
+
49
76
  class SubstackClientError(Exception):
50
77
  """Base exception for Substack client operations."""
51
78
 
@@ -58,6 +85,20 @@ class AuthRequiredError(SubstackClientError):
58
85
  pass
59
86
 
60
87
 
88
+ def _require_https(url: str) -> None:
89
+ """Reject non-HTTPS URLs before an authenticated Playwright page navigates to
90
+ them, closing the file:// and internal-http(s) SSRF path a caller-supplied
91
+ URL (CLI arg or MCP tool input) could otherwise reach.
92
+
93
+ ponytail: scheme-only, not a domain allowlist — Substack publications can
94
+ live on custom domains, so a hard `*.substack.com` allowlist would reject
95
+ legitimate saves. Upgrade to a domain allowlist if custom-domain support
96
+ is ever dropped.
97
+ """
98
+ if urlparse(url).scheme != "https":
99
+ raise SubstackClientError(f"Refusing to navigate to non-HTTPS URL: {url}")
100
+
101
+
61
102
  def perform_interactive_login(browser_dir: Path | None = None) -> Path:
62
103
  """Launch a visible browser window for the user to log in to Substack.
63
104
 
@@ -90,18 +131,8 @@ def _perform_interactive_login_impl(browser_dir: Path | None = None) -> Path:
90
131
 
91
132
  with sync_playwright() as p:
92
133
  # Launch visible browser with anti-bot evasion flags
93
- browser = p.chromium.launch(
94
- headless=False,
95
- args=["--disable-blink-features=AutomationControlled"],
96
- )
97
- context_kwargs = {
98
- "user_agent": (
99
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
100
- "AppleWebKit/537.36 (KHTML, like Gecko) "
101
- "Chrome/131.0.0.0 Safari/537.36"
102
- ),
103
- "viewport": {"width": 1280, "height": 800},
104
- }
134
+ browser = p.chromium.launch(headless=False, args=STEALTH_LAUNCH_ARGS)
135
+ context_kwargs = dict(STEALTH_CONTEXT_KWARGS)
105
136
  if state_file.exists():
106
137
  context_kwargs["storage_state"] = str(state_file)
107
138
 
@@ -129,6 +160,13 @@ def _perform_interactive_login_impl(browser_dir: Path | None = None) -> Path:
129
160
  except Exception as err:
130
161
  logger.warning(f"Notice during login verification: {err}")
131
162
 
163
+ # Pre-create the credentials file with owner-only permissions so the
164
+ # write below never leaves a world/group-readable window (umask would
165
+ # otherwise apply to Playwright's own file creation). A no-op open on
166
+ # an already-existing file, since O_CREAT's mode is ignored then.
167
+ if os.name == "posix":
168
+ os.close(os.open(str(state_file), os.O_CREAT | os.O_WRONLY, 0o600))
169
+
132
170
  try:
133
171
  context.storage_state(path=str(state_file))
134
172
  except Exception as err:
@@ -139,14 +177,13 @@ def _perform_interactive_login_impl(browser_dir: Path | None = None) -> Path:
139
177
  except Exception:
140
178
  pass
141
179
 
142
- # Restrict permissions on session storage state file
143
- import os
144
-
180
+ # Belt-and-suspenders: re-assert restrictive permissions even though the
181
+ # file was pre-created 0o600 above.
145
182
  if os.name == "posix" and state_file.exists():
146
183
  try:
147
184
  state_file.chmod(0o600)
148
- except Exception:
149
- pass
185
+ except Exception as err:
186
+ logger.warning(f"Could not restrict permissions on {state_file}: {err}")
150
187
 
151
188
  print(f"--> Authentication state saved successfully to {state_file}")
152
189
  return state_file
@@ -309,16 +346,7 @@ class SubstackSavedPostsClient:
309
346
  except Exception:
310
347
  pass
311
348
 
312
- if playwright_instance is not None:
313
- return _do_fetch(playwright_instance)
314
-
315
- try:
316
- from playwright.sync_api import sync_playwright
317
- except ImportError:
318
- raise SubstackClientError("Playwright is not installed.") from None
319
-
320
- with sync_playwright() as p:
321
- return _do_fetch(p)
349
+ return _with_playwright(playwright_instance, _do_fetch)
322
350
 
323
351
  @staticmethod
324
352
  def _retry_after_seconds(res: Any, attempt: int, cap: float = 30.0) -> float:
@@ -574,16 +602,7 @@ class SubstackSavedPostsClient:
574
602
  except Exception:
575
603
  pass
576
604
 
577
- if playwright_instance is not None:
578
- return _do_fetch(playwright_instance)
579
-
580
- try:
581
- from playwright.sync_api import sync_playwright
582
- except ImportError:
583
- raise SubstackClientError("Playwright is not installed.") from None
584
-
585
- with sync_playwright() as p:
586
- return _do_fetch(p)
605
+ return _with_playwright(playwright_instance, _do_fetch)
587
606
 
588
607
  def fetch_saved_notes_page(
589
608
  self, limit: int = 50, offset: int = 0
@@ -626,16 +645,7 @@ class SubstackSavedPostsClient:
626
645
  "endpoint can be re-diagnosed."
627
646
  )
628
647
 
629
- if playwright_instance is not None:
630
- return _do_fetch(playwright_instance)
631
-
632
- try:
633
- from playwright.sync_api import sync_playwright
634
- except ImportError:
635
- raise SubstackClientError("Playwright is not installed.") from None
636
-
637
- with sync_playwright() as p:
638
- return _do_fetch(p)
648
+ return _with_playwright(playwright_instance, _do_fetch)
639
649
 
640
650
  def _fetch_all_saved_notes_via_api(
641
651
  self,
@@ -838,13 +848,7 @@ class SubstackSavedPostsClient:
838
848
  return results
839
849
 
840
850
  if self._dom_cache is None:
841
- if playwright_instance is not None:
842
- self._dom_cache = _do_fetch(playwright_instance)
843
- else:
844
- from playwright.sync_api import sync_playwright
845
-
846
- with sync_playwright() as p:
847
- self._dom_cache = _do_fetch(p)
851
+ self._dom_cache = _with_playwright(playwright_instance, _do_fetch)
848
852
 
849
853
  return self._dom_cache[offset : offset + limit]
850
854
 
@@ -913,6 +917,7 @@ class SubstackSavedPostsClient:
913
917
  ) -> tuple[SavedPost, str]:
914
918
  self._ensure_authenticated()
915
919
  clean_url = canonicalize_url(url)
920
+ _require_https(clean_url)
916
921
 
917
922
  def _do_save(p):
918
923
  browser = p.chromium.launch(headless=True)
@@ -989,12 +994,7 @@ class SubstackSavedPostsClient:
989
994
  )
990
995
  return saved_post, confirmation
991
996
 
992
- if playwright_instance is not None:
993
- return _do_save(playwright_instance)
994
- from playwright.sync_api import sync_playwright
995
-
996
- with sync_playwright() as p:
997
- return _do_save(p)
997
+ return _with_playwright(playwright_instance, _do_save)
998
998
 
999
999
  def fetch_post_content(self, url: str) -> dict[str, Any]:
1000
1000
  """Fetch a post's full body HTML by visiting its page.
@@ -1014,6 +1014,7 @@ class SubstackSavedPostsClient:
1014
1014
  ) -> dict[str, Any]:
1015
1015
  self._ensure_authenticated()
1016
1016
  clean_url = canonicalize_url(url)
1017
+ _require_https(clean_url)
1017
1018
 
1018
1019
  def _do_fetch(p):
1019
1020
  browser = p.chromium.launch(headless=True)
@@ -1043,12 +1044,7 @@ class SubstackSavedPostsClient:
1043
1044
  "audience": post_obj.get("audience"),
1044
1045
  }
1045
1046
 
1046
- if playwright_instance is not None:
1047
- return _do_fetch(playwright_instance)
1048
- from playwright.sync_api import sync_playwright
1049
-
1050
- with sync_playwright() as p:
1051
- return _do_fetch(p)
1047
+ return _with_playwright(playwright_instance, _do_fetch)
1052
1048
 
1053
1049
  def unsave_post(self, url: str, post_id: int | None = None) -> str:
1054
1050
  """Unbookmark a post on Substack remotely; returns a confirmation status.
@@ -1069,6 +1065,7 @@ class SubstackSavedPostsClient:
1069
1065
  ) -> str:
1070
1066
  self._ensure_authenticated()
1071
1067
  clean_url = canonicalize_url(url)
1068
+ _require_https(clean_url)
1072
1069
 
1073
1070
  def _do_unsave(p):
1074
1071
  if post_id is not None:
@@ -1105,12 +1102,7 @@ class SubstackSavedPostsClient:
1105
1102
  browser.close()
1106
1103
  return toggle_status
1107
1104
 
1108
- if playwright_instance is not None:
1109
- return _do_unsave(playwright_instance)
1110
- from playwright.sync_api import sync_playwright
1111
-
1112
- with sync_playwright() as p:
1113
- return _do_unsave(p)
1105
+ return _with_playwright(playwright_instance, _do_unsave)
1114
1106
 
1115
1107
  def save_note(self, url: str) -> tuple[SavedNote, str]:
1116
1108
  """Save/bookmark a Substack note by URL.
@@ -1175,12 +1167,7 @@ class SubstackSavedPostsClient:
1175
1167
  )
1176
1168
  return note, confirmation
1177
1169
 
1178
- if playwright_instance is not None:
1179
- return _do_save(playwright_instance)
1180
- from playwright.sync_api import sync_playwright
1181
-
1182
- with sync_playwright() as p:
1183
- return _do_save(p)
1170
+ return _with_playwright(playwright_instance, _do_save)
1184
1171
 
1185
1172
  def unsave_note(self, url: str, note_id: str | None = None) -> str:
1186
1173
  """Unsave/unbookmark a Substack note; returns a confirmation status.
@@ -1216,12 +1203,7 @@ class SubstackSavedPostsClient:
1216
1203
  pass
1217
1204
  return "unconfirmed"
1218
1205
 
1219
- if playwright_instance is not None:
1220
- return _do_unsave(playwright_instance)
1221
- from playwright.sync_api import sync_playwright
1222
-
1223
- with sync_playwright() as p:
1224
- return _do_unsave(p)
1206
+ return _with_playwright(playwright_instance, _do_unsave)
1225
1207
 
1226
1208
  def fetch_note_content(self, url: str) -> dict[str, Any]:
1227
1209
  """Fetch a saved note's full content directly, without a browser page.
@@ -1274,9 +1256,4 @@ class SubstackSavedPostsClient:
1274
1256
  "posted_at": comment.get("date"),
1275
1257
  }
1276
1258
 
1277
- if playwright_instance is not None:
1278
- return _do_fetch(playwright_instance)
1279
- from playwright.sync_api import sync_playwright
1280
-
1281
- with sync_playwright() as p:
1282
- return _do_fetch(p)
1259
+ return _with_playwright(playwright_instance, _do_fetch)
@@ -344,6 +344,22 @@ def test_unsave_post_impl_falls_back_to_dom_when_api_delete_fails(tmp_path: Path
344
344
  assert status == "confirmed" # confirmed via the DOM fallback, not the API
345
345
 
346
346
 
347
+ @pytest.mark.parametrize(
348
+ "method", ["_save_post_impl", "_unsave_post_impl", "_fetch_post_content_impl"]
349
+ )
350
+ @pytest.mark.parametrize(
351
+ "url",
352
+ ["file:///etc/shadow", "http://169.254.169.254/", "http://localhost:8000/admin"],
353
+ )
354
+ def test_impl_rejects_non_https_url(tmp_path: Path, method: str, url: str):
355
+ """SSRF guard: a non-HTTPS URL must be rejected before any Playwright
356
+ navigation happens. playwright_instance is left unset (None) so a page
357
+ double is never even needed if the guard fires first."""
358
+ client = _client(tmp_path)
359
+ with pytest.raises(SubstackClientError, match="non-HTTPS"):
360
+ getattr(client, method)(url=url)
361
+
362
+
347
363
  class MockNoteApiResponse:
348
364
  """API response double for notes: no browser page needed at all, so this
349
365
  is the only mock notes' save/unsave/fetch tests require."""
@@ -1073,19 +1073,6 @@ wheels = [
1073
1073
  { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
1074
1074
  ]
1075
1075
 
1076
- [[package]]
1077
- name = "pytest-asyncio"
1078
- version = "1.4.0"
1079
- source = { registry = "https://pypi.org/simple" }
1080
- dependencies = [
1081
- { name = "pytest" },
1082
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1083
- ]
1084
- sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
1085
- wheels = [
1086
- { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
1087
- ]
1088
-
1089
1076
  [[package]]
1090
1077
  name = "python-dotenv"
1091
1078
  version = "1.2.2"
@@ -1419,7 +1406,7 @@ wheels = [
1419
1406
 
1420
1407
  [[package]]
1421
1408
  name = "substack-saved-mcp"
1422
- version = "0.3.1"
1409
+ version = "0.3.2"
1423
1410
  source = { editable = "." }
1424
1411
  dependencies = [
1425
1412
  { name = "click" },
@@ -1431,7 +1418,6 @@ dependencies = [
1431
1418
  [package.optional-dependencies]
1432
1419
  dev = [
1433
1420
  { name = "pytest" },
1434
- { name = "pytest-asyncio" },
1435
1421
  { name = "ruff" },
1436
1422
  ]
1437
1423
 
@@ -1442,7 +1428,6 @@ requires-dist = [
1442
1428
  { name = "playwright", specifier = ">=1.40.0" },
1443
1429
  { name = "pydantic", specifier = ">=2.0.0" },
1444
1430
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
1445
- { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
1446
1431
  { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.1" },
1447
1432
  ]
1448
1433
  provides-extras = ["dev"]