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.
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/PKG-INFO +1 -2
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/pyproject.toml +1 -2
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/__init__.py +1 -1
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/cli.py +4 -12
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/config.py +5 -2
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/database.py +52 -77
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/substack_client.py +67 -90
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_substack_client.py +16 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/uv.lock +1 -16
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.github/workflows/ci.yml +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.github/workflows/pypi.yml +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.gitignore +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/.pre-commit-config.yaml +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/CLAUDE.md +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/LICENSE +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/PLAN.md +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/README.md +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/content_utils.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/mcp_server.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/models.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/sync.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/url_utils.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_cli.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_content_utils.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_database.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_mcp_server.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_normalization.py +0 -0
- {substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/tests/test_sync.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
{substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/substack_client.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{substack_saved_mcp-0.3.1 → substack_saved_mcp-0.3.2}/src/substack_saved_mcp/content_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|