note-connector 0.2.5 → 0.2.7
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.
- package/dist/paths.js +4 -0
- package/dist/setup-dependencies.js +56 -13
- package/package.json +3 -2
- package/py/pyproject.toml +86 -0
- package/py/src/note_mcp/__init__.py +7 -0
- package/py/src/note_mcp/__main__.py +65 -0
- package/py/src/note_mcp/api/__init__.py +31 -0
- package/py/src/note_mcp/api/articles.py +1395 -0
- package/py/src/note_mcp/api/client.py +318 -0
- package/py/src/note_mcp/api/embeds.py +482 -0
- package/py/src/note_mcp/api/images.py +660 -0
- package/py/src/note_mcp/api/preview.py +142 -0
- package/py/src/note_mcp/api/public_notes.py +150 -0
- package/py/src/note_mcp/auth/__init__.py +9 -0
- package/py/src/note_mcp/auth/browser.py +574 -0
- package/py/src/note_mcp/auth/file_session.py +145 -0
- package/py/src/note_mcp/auth/session.py +240 -0
- package/py/src/note_mcp/browser/__init__.py +10 -0
- package/py/src/note_mcp/browser/config.py +21 -0
- package/py/src/note_mcp/browser/manager.py +182 -0
- package/py/src/note_mcp/browser/preview.py +68 -0
- package/py/src/note_mcp/browser/url_helpers.py +18 -0
- package/py/src/note_mcp/chatgpt/__init__.py +1 -0
- package/py/src/note_mcp/chatgpt/__main__.py +63 -0
- package/py/src/note_mcp/chatgpt/access_log.py +25 -0
- package/py/src/note_mcp/chatgpt/auth.py +52 -0
- package/py/src/note_mcp/chatgpt/images.py +92 -0
- package/py/src/note_mcp/chatgpt/login_once.py +26 -0
- package/py/src/note_mcp/chatgpt/middleware.py +31 -0
- package/py/src/note_mcp/chatgpt/tools.py +255 -0
- package/py/src/note_mcp/chatgpt/widgets.py +121 -0
- package/py/src/note_mcp/decorators.py +113 -0
- package/py/src/note_mcp/investigator/__init__.py +33 -0
- package/py/src/note_mcp/investigator/__main__.py +11 -0
- package/py/src/note_mcp/investigator/cli.py +313 -0
- package/py/src/note_mcp/investigator/core.py +653 -0
- package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
- package/py/src/note_mcp/models.py +562 -0
- package/py/src/note_mcp/py.typed +0 -0
- package/py/src/note_mcp/server.py +944 -0
- package/py/src/note_mcp/utils/__init__.py +7 -0
- package/py/src/note_mcp/utils/file_parser.py +314 -0
- package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
- package/py/src/note_mcp/utils/logging.py +119 -0
- package/py/src/note_mcp/utils/markdown.py +12 -0
- package/py/src/note_mcp/utils/markdown_to_html.py +826 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""File-based session management for Docker/headless environments.
|
|
2
|
+
|
|
3
|
+
Provides session storage using JSON files when keyring is not available.
|
|
4
|
+
This is a fallback mechanism for environments where system keyring
|
|
5
|
+
cannot be accessed (Docker containers, headless servers, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from note_mcp.models import Session
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Default session filename
|
|
22
|
+
SESSION_FILENAME = "session.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_default_data_dir() -> Path:
|
|
26
|
+
"""Get the default data directory for session storage.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Path to data directory. Priority:
|
|
30
|
+
1. NOTE_MCP_DATA_DIR environment variable
|
|
31
|
+
2. /app/data (Docker)
|
|
32
|
+
3. ~/.note-mcp (local)
|
|
33
|
+
"""
|
|
34
|
+
# Check for environment variable first (Important #4)
|
|
35
|
+
env_dir = os.environ.get("NOTE_MCP_DATA_DIR")
|
|
36
|
+
if env_dir:
|
|
37
|
+
return Path(env_dir)
|
|
38
|
+
|
|
39
|
+
# Check for Docker environment
|
|
40
|
+
if Path("/app/data").exists() and os.access("/app/data", os.W_OK):
|
|
41
|
+
return Path("/app/data")
|
|
42
|
+
|
|
43
|
+
# Fall back to home directory
|
|
44
|
+
home = Path.home()
|
|
45
|
+
return home / ".note-mcp"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FileBasedSessionManager:
|
|
49
|
+
"""File-based session management for Docker/headless environments.
|
|
50
|
+
|
|
51
|
+
Stores session data in a JSON file instead of system keyring.
|
|
52
|
+
Suitable for environments where keyring is not available.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
data_dir: Directory where session file is stored
|
|
56
|
+
session_file: Full path to the session JSON file
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, data_dir: Path | None = None) -> None:
|
|
60
|
+
"""Initialize FileBasedSessionManager.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
data_dir: Session file storage directory.
|
|
64
|
+
Default: /app/data (Docker) or ~/.note-mcp (local)
|
|
65
|
+
"""
|
|
66
|
+
self.data_dir = data_dir or _get_default_data_dir()
|
|
67
|
+
self.session_file = self.data_dir / SESSION_FILENAME
|
|
68
|
+
|
|
69
|
+
def save(self, session: Session) -> None:
|
|
70
|
+
"""Save session to JSON file.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
session: Session object to save
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
OSError: If file cannot be written
|
|
77
|
+
"""
|
|
78
|
+
# Ensure directory exists
|
|
79
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
# Write session data to file
|
|
82
|
+
session_data = session.model_dump()
|
|
83
|
+
with open(self.session_file, "w", encoding="utf-8") as f:
|
|
84
|
+
json.dump(session_data, f, ensure_ascii=False, indent=2)
|
|
85
|
+
|
|
86
|
+
logger.debug(f"Session saved to {self.session_file}")
|
|
87
|
+
|
|
88
|
+
def load(self) -> Session | None:
|
|
89
|
+
"""Load session from JSON file.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Session object if found and valid, None otherwise
|
|
93
|
+
"""
|
|
94
|
+
if not self.session_file.exists():
|
|
95
|
+
logger.debug("No session file found")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with open(self.session_file, encoding="utf-8") as f:
|
|
100
|
+
session_data = json.load(f)
|
|
101
|
+
|
|
102
|
+
# Import here to avoid circular imports
|
|
103
|
+
from note_mcp.models import Session
|
|
104
|
+
|
|
105
|
+
return Session(**session_data)
|
|
106
|
+
except json.JSONDecodeError as e:
|
|
107
|
+
# File is corrupted - distinct from "no session"
|
|
108
|
+
logger.error(f"Session file corrupted (invalid JSON): {e}")
|
|
109
|
+
logger.info(f"Consider deleting corrupted file: {self.session_file}")
|
|
110
|
+
return None
|
|
111
|
+
except (TypeError, ValueError) as e:
|
|
112
|
+
# Invalid data structure
|
|
113
|
+
logger.error(f"Session file has invalid data structure: {e}")
|
|
114
|
+
logger.info(f"Consider deleting invalid file: {self.session_file}")
|
|
115
|
+
return None
|
|
116
|
+
except OSError as e:
|
|
117
|
+
logger.warning(f"Failed to read session file: {e}")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def clear(self) -> bool:
|
|
121
|
+
"""Clear session by deleting the session file.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if session was cleared successfully, False if deletion failed.
|
|
125
|
+
Returns True if no session file exists (nothing to clear).
|
|
126
|
+
"""
|
|
127
|
+
if not self.session_file.exists():
|
|
128
|
+
logger.debug("No session file to clear")
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self.session_file.unlink()
|
|
133
|
+
logger.debug(f"Session file deleted: {self.session_file}")
|
|
134
|
+
return True
|
|
135
|
+
except OSError as e:
|
|
136
|
+
logger.error(f"Failed to delete session file (security concern): {e}")
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def has_session(self) -> bool:
|
|
140
|
+
"""Check if a session file exists.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if session file exists, False otherwise
|
|
144
|
+
"""
|
|
145
|
+
return self.session_file.exists()
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Session management with keyring storage.
|
|
2
|
+
|
|
3
|
+
Provides secure session storage using the system keyring.
|
|
4
|
+
Includes diagnostic information when keyring is not available.
|
|
5
|
+
|
|
6
|
+
For Docker/headless environments where keyring is not available,
|
|
7
|
+
set USE_FILE_SESSION=1 to use file-based session storage.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import keyring
|
|
18
|
+
from keyring.errors import PasswordDeleteError
|
|
19
|
+
|
|
20
|
+
from note_mcp.auth.file_session import FileBasedSessionManager
|
|
21
|
+
from note_mcp.models import Session
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KeyringError(Exception):
|
|
28
|
+
"""Exception raised when keyring operations fail.
|
|
29
|
+
|
|
30
|
+
Includes diagnostic information to help users troubleshoot
|
|
31
|
+
keyring configuration issues.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
message: Human-readable error message
|
|
35
|
+
os_info: Operating system information
|
|
36
|
+
backend_info: Keyring backend information
|
|
37
|
+
setup_instructions: Steps to configure keyring
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
message: str,
|
|
43
|
+
os_info: str,
|
|
44
|
+
backend_info: str | None = None,
|
|
45
|
+
setup_instructions: list[str] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Initialize KeyringError with diagnostic information.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
message: Human-readable error message
|
|
51
|
+
os_info: Operating system information
|
|
52
|
+
backend_info: Keyring backend information (if available)
|
|
53
|
+
setup_instructions: Steps to configure keyring
|
|
54
|
+
"""
|
|
55
|
+
self.message = message
|
|
56
|
+
self.os_info = os_info
|
|
57
|
+
self.backend_info = backend_info
|
|
58
|
+
self.setup_instructions = setup_instructions or []
|
|
59
|
+
super().__init__(self._format_message())
|
|
60
|
+
|
|
61
|
+
def _format_message(self) -> str:
|
|
62
|
+
"""Format the error message with diagnostic information."""
|
|
63
|
+
parts = [self.message]
|
|
64
|
+
parts.append(f"\nOS: {self.os_info}")
|
|
65
|
+
if self.backend_info:
|
|
66
|
+
parts.append(f"Backend: {self.backend_info}")
|
|
67
|
+
if self.setup_instructions:
|
|
68
|
+
parts.append("\nSetup instructions:")
|
|
69
|
+
for instruction in self.setup_instructions:
|
|
70
|
+
parts.append(f" - {instruction}")
|
|
71
|
+
return "\n".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_os_info() -> str:
|
|
75
|
+
"""Get operating system information for diagnostics."""
|
|
76
|
+
return f"{platform.system()} {platform.release()}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_backend_info() -> str | None:
|
|
80
|
+
"""Get keyring backend information for diagnostics."""
|
|
81
|
+
try:
|
|
82
|
+
backend = keyring.get_keyring()
|
|
83
|
+
return type(backend).__name__
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_setup_instructions() -> list[str]:
|
|
89
|
+
"""Get setup instructions based on the current platform."""
|
|
90
|
+
system = platform.system()
|
|
91
|
+
instructions: list[str] = []
|
|
92
|
+
|
|
93
|
+
if system == "Linux":
|
|
94
|
+
instructions = [
|
|
95
|
+
"Install a keyring backend: sudo apt-get install gnome-keyring",
|
|
96
|
+
"Or install libsecret: sudo apt-get install libsecret-1-0",
|
|
97
|
+
"For headless servers, consider using keyrings.alt: pip install keyrings.alt",
|
|
98
|
+
"Then configure with: python -c 'import keyring; print(keyring.get_keyring())'",
|
|
99
|
+
]
|
|
100
|
+
elif system == "Darwin":
|
|
101
|
+
instructions = [
|
|
102
|
+
"macOS should use Keychain automatically",
|
|
103
|
+
"If issues persist, try: security unlock-keychain",
|
|
104
|
+
"Check Keychain Access app for any locked keychains",
|
|
105
|
+
]
|
|
106
|
+
elif system == "Windows":
|
|
107
|
+
instructions = [
|
|
108
|
+
"Windows should use Windows Credential Locker automatically",
|
|
109
|
+
"If issues persist, check Windows Credential Manager",
|
|
110
|
+
"Try running as administrator if access is denied",
|
|
111
|
+
]
|
|
112
|
+
else:
|
|
113
|
+
instructions = [
|
|
114
|
+
"Install a compatible keyring backend",
|
|
115
|
+
"See: https://pypi.org/project/keyring/",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
return instructions
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SessionManager:
|
|
122
|
+
"""Manages session storage using keyring or file-based backend.
|
|
123
|
+
|
|
124
|
+
Provides secure storage of session data in the system keyring.
|
|
125
|
+
When keyring is not available, provides clear diagnostic information.
|
|
126
|
+
|
|
127
|
+
For Docker/headless environments, set USE_FILE_SESSION=1 to use
|
|
128
|
+
file-based session storage instead of keyring.
|
|
129
|
+
|
|
130
|
+
Attributes:
|
|
131
|
+
service_name: The keyring service name used for storage
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
DEFAULT_SERVICE_NAME = "note-mcp"
|
|
135
|
+
SESSION_KEY = "session"
|
|
136
|
+
|
|
137
|
+
def __init__(self, service_name: str | None = None) -> None:
|
|
138
|
+
"""Initialize SessionManager.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
service_name: Keyring service name (default: "note-mcp")
|
|
142
|
+
"""
|
|
143
|
+
self.service_name = service_name or self.DEFAULT_SERVICE_NAME
|
|
144
|
+
|
|
145
|
+
# Use file-based backend when USE_FILE_SESSION=1
|
|
146
|
+
if os.environ.get("USE_FILE_SESSION") == "1":
|
|
147
|
+
self._backend: FileBasedSessionManager | None = FileBasedSessionManager()
|
|
148
|
+
else:
|
|
149
|
+
self._backend = None # Use keyring directly
|
|
150
|
+
|
|
151
|
+
def save(self, session: Session) -> None:
|
|
152
|
+
"""Save session to keyring or file backend.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
session: Session object to save
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
KeyringError: If keyring operation fails (when using keyring)
|
|
159
|
+
OSError: If file operation fails (when using file backend)
|
|
160
|
+
"""
|
|
161
|
+
if self._backend:
|
|
162
|
+
self._backend.save(session)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
session_data = session.model_dump()
|
|
167
|
+
session_json = json.dumps(session_data)
|
|
168
|
+
keyring.set_password(self.service_name, self.SESSION_KEY, session_json)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise KeyringError(
|
|
171
|
+
message=f"Failed to save session to keyring: {e}",
|
|
172
|
+
os_info=_get_os_info(),
|
|
173
|
+
backend_info=_get_backend_info(),
|
|
174
|
+
setup_instructions=_get_setup_instructions(),
|
|
175
|
+
) from e
|
|
176
|
+
|
|
177
|
+
def load(self) -> Session | None:
|
|
178
|
+
"""Load session from keyring or file backend.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Session object if found and valid, None otherwise
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
KeyringError: If keyring operation fails (when using keyring)
|
|
185
|
+
"""
|
|
186
|
+
if self._backend:
|
|
187
|
+
return self._backend.load()
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
session_json = keyring.get_password(self.service_name, self.SESSION_KEY)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
raise KeyringError(
|
|
193
|
+
message=f"Failed to load session from keyring: {e}",
|
|
194
|
+
os_info=_get_os_info(),
|
|
195
|
+
backend_info=_get_backend_info(),
|
|
196
|
+
setup_instructions=_get_setup_instructions(),
|
|
197
|
+
) from e
|
|
198
|
+
|
|
199
|
+
if session_json is None:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
session_data = json.loads(session_json)
|
|
204
|
+
return Session(**session_data)
|
|
205
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
206
|
+
# Invalid JSON or invalid session data
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
def clear(self) -> None:
|
|
210
|
+
"""Clear session from keyring or file backend.
|
|
211
|
+
|
|
212
|
+
Does not raise an error if no session exists.
|
|
213
|
+
"""
|
|
214
|
+
if self._backend:
|
|
215
|
+
self._backend.clear()
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
keyring.delete_password(self.service_name, self.SESSION_KEY)
|
|
220
|
+
except PasswordDeleteError:
|
|
221
|
+
# Session didn't exist, which is fine
|
|
222
|
+
pass
|
|
223
|
+
except Exception:
|
|
224
|
+
# Silently ignore other errors on clear
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
def has_session(self) -> bool:
|
|
228
|
+
"""Check if a session exists in keyring or file backend.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if a valid session exists, False otherwise
|
|
232
|
+
"""
|
|
233
|
+
if self._backend:
|
|
234
|
+
return self._backend.has_session()
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
session_json = keyring.get_password(self.service_name, self.SESSION_KEY)
|
|
238
|
+
return session_json is not None
|
|
239
|
+
except Exception:
|
|
240
|
+
return False
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Browser module for note-mcp.
|
|
2
|
+
|
|
3
|
+
Provides Playwright-based browser automation for login and preview.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from note_mcp.browser.config import HEADLESS_ENV_VAR, get_headless_mode
|
|
7
|
+
from note_mcp.browser.manager import BrowserManager
|
|
8
|
+
from note_mcp.browser.preview import show_preview
|
|
9
|
+
|
|
10
|
+
__all__ = ["BrowserManager", "show_preview", "get_headless_mode", "HEADLESS_ENV_VAR"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Browser configuration utilities.
|
|
2
|
+
|
|
3
|
+
Provides configuration functions for browser automation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
# Environment variable name for headless mode configuration
|
|
9
|
+
HEADLESS_ENV_VAR = "NOTE_MCP_TEST_HEADLESS"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_headless_mode() -> bool:
|
|
13
|
+
"""Get headless mode from NOTE_MCP_TEST_HEADLESS environment variable.
|
|
14
|
+
|
|
15
|
+
Default: True (headless mode for CI/CD stability)
|
|
16
|
+
Set NOTE_MCP_TEST_HEADLESS=false to show browser window for debugging.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
True if headless mode is enabled (default)
|
|
20
|
+
"""
|
|
21
|
+
return os.environ.get(HEADLESS_ENV_VAR, "true").lower() != "false"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Browser manager for Playwright automation.
|
|
2
|
+
|
|
3
|
+
Provides singleton browser instance management with page reuse.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import atexit
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
12
|
+
|
|
13
|
+
from playwright.async_api import Page, async_playwright
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from playwright.async_api import Browser, BrowserContext, Playwright
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BrowserManager:
|
|
22
|
+
"""Manages Playwright browser instance as singleton.
|
|
23
|
+
|
|
24
|
+
Provides page reuse and proper cleanup on exit.
|
|
25
|
+
Uses asyncio.Lock for thread-safe access.
|
|
26
|
+
|
|
27
|
+
Class Attributes:
|
|
28
|
+
_instance: Singleton instance
|
|
29
|
+
_browser: Playwright Browser instance
|
|
30
|
+
_context: Browser context
|
|
31
|
+
_page: Reusable page instance
|
|
32
|
+
_lock: Async lock for thread-safe access
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_instance: ClassVar[BrowserManager | None] = None
|
|
36
|
+
_playwright: Playwright | None = None
|
|
37
|
+
_browser: Browser | None = None
|
|
38
|
+
_context: BrowserContext | None = None
|
|
39
|
+
_page: Page | None = None
|
|
40
|
+
_lock: asyncio.Lock | None = None
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
"""Initialize browser manager.
|
|
44
|
+
|
|
45
|
+
Should not be called directly. Use get_instance() instead.
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get_instance(cls) -> BrowserManager:
|
|
51
|
+
"""Get singleton instance of BrowserManager.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
BrowserManager singleton instance
|
|
55
|
+
"""
|
|
56
|
+
if cls._instance is None:
|
|
57
|
+
cls._instance = cls()
|
|
58
|
+
cls._lock = asyncio.Lock()
|
|
59
|
+
# Register cleanup on exit
|
|
60
|
+
atexit.register(cls._sync_cleanup)
|
|
61
|
+
return cls._instance
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _sync_cleanup(cls) -> None:
|
|
65
|
+
"""Synchronous cleanup for atexit hook."""
|
|
66
|
+
if cls._browser is not None:
|
|
67
|
+
try:
|
|
68
|
+
loop = asyncio.get_event_loop()
|
|
69
|
+
if loop.is_running():
|
|
70
|
+
# Schedule cleanup if loop is running
|
|
71
|
+
loop.create_task(cls._async_cleanup())
|
|
72
|
+
else:
|
|
73
|
+
# Run cleanup directly if loop is not running
|
|
74
|
+
loop.run_until_complete(cls._async_cleanup())
|
|
75
|
+
except RuntimeError as e:
|
|
76
|
+
# Only handle "no event loop" errors, re-raise others
|
|
77
|
+
error_msg = str(e).lower()
|
|
78
|
+
if "no running event loop" in error_msg or "no current event loop" in error_msg:
|
|
79
|
+
asyncio.run(cls._async_cleanup())
|
|
80
|
+
else:
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
async def _async_cleanup(cls) -> None:
|
|
85
|
+
"""Async cleanup for browser resources."""
|
|
86
|
+
if cls._browser is not None:
|
|
87
|
+
await cls._safe_close_browser()
|
|
88
|
+
cls._browser = None
|
|
89
|
+
if cls._playwright is not None:
|
|
90
|
+
await cls._safe_stop_playwright()
|
|
91
|
+
cls._playwright = None
|
|
92
|
+
cls._context = None
|
|
93
|
+
cls._page = None
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
async def _safe_close_browser(cls) -> None:
|
|
97
|
+
"""Safely close browser, logging errors at debug level."""
|
|
98
|
+
try:
|
|
99
|
+
if cls._browser is not None:
|
|
100
|
+
await cls._browser.close()
|
|
101
|
+
except Exception as e: # noqa: BLE001 - intentionally broad for cleanup
|
|
102
|
+
logger.debug("Browser cleanup error (non-fatal): %s", e, exc_info=True)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
async def _safe_stop_playwright(cls) -> None:
|
|
106
|
+
"""Safely stop playwright, logging errors at debug level."""
|
|
107
|
+
try:
|
|
108
|
+
if cls._playwright is not None:
|
|
109
|
+
await cls._playwright.stop()
|
|
110
|
+
except Exception as e: # noqa: BLE001 - intentionally broad for cleanup
|
|
111
|
+
logger.debug("Playwright cleanup error (non-fatal): %s", e, exc_info=True)
|
|
112
|
+
|
|
113
|
+
async def _ensure_browser(self, headless: bool | None = None) -> None:
|
|
114
|
+
"""Ensure browser is started.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
headless: If True, run in headless mode. If False, show browser window.
|
|
118
|
+
If None, use the default from config (NOTE_MCP_TEST_HEADLESS env var).
|
|
119
|
+
"""
|
|
120
|
+
if self._playwright is None:
|
|
121
|
+
self.__class__._playwright = await async_playwright().start()
|
|
122
|
+
playwright = self._playwright
|
|
123
|
+
assert playwright is not None
|
|
124
|
+
|
|
125
|
+
if self._browser is None:
|
|
126
|
+
if headless is None:
|
|
127
|
+
from note_mcp.browser.config import get_headless_mode
|
|
128
|
+
|
|
129
|
+
headless = get_headless_mode()
|
|
130
|
+
self.__class__._browser = await playwright.chromium.launch(headless=headless)
|
|
131
|
+
browser = self._browser
|
|
132
|
+
assert browser is not None
|
|
133
|
+
|
|
134
|
+
if self._context is None:
|
|
135
|
+
self.__class__._context = await browser.new_context()
|
|
136
|
+
context = self._context
|
|
137
|
+
assert context is not None
|
|
138
|
+
|
|
139
|
+
if self._page is None or self._page.is_closed():
|
|
140
|
+
self.__class__._page = await context.new_page()
|
|
141
|
+
|
|
142
|
+
async def get_page(self, headless: bool | None = None) -> Page:
|
|
143
|
+
"""Get a browser page, creating if necessary.
|
|
144
|
+
|
|
145
|
+
Reuses existing page if available and not closed.
|
|
146
|
+
Uses lock for thread-safe access.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
headless: If True, run in headless mode. If False, show browser window.
|
|
150
|
+
If None, use the default from config (NOTE_MCP_TEST_HEADLESS env var).
|
|
151
|
+
|
|
152
|
+
IMPORTANT: This parameter is only used when launching a NEW browser.
|
|
153
|
+
If a browser already exists, this parameter is SILENTLY IGNORED.
|
|
154
|
+
Call close() first if you need to change the headless mode.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Playwright Page instance
|
|
158
|
+
"""
|
|
159
|
+
if self._lock is None:
|
|
160
|
+
self.__class__._lock = asyncio.Lock()
|
|
161
|
+
lock = self._lock
|
|
162
|
+
assert lock is not None
|
|
163
|
+
|
|
164
|
+
async with lock:
|
|
165
|
+
# Check if existing page is still valid
|
|
166
|
+
if self._page is not None and not self._page.is_closed():
|
|
167
|
+
return self._page
|
|
168
|
+
|
|
169
|
+
# Create new browser/page if needed
|
|
170
|
+
await self._ensure_browser(headless=headless)
|
|
171
|
+
assert self._page is not None
|
|
172
|
+
return self._page
|
|
173
|
+
|
|
174
|
+
async def close(self) -> None:
|
|
175
|
+
"""Close browser and cleanup resources."""
|
|
176
|
+
if self._lock is None:
|
|
177
|
+
self.__class__._lock = asyncio.Lock()
|
|
178
|
+
lock = self._lock
|
|
179
|
+
assert lock is not None
|
|
180
|
+
|
|
181
|
+
async with lock:
|
|
182
|
+
await self._async_cleanup()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Browser-based article preview using API access token.
|
|
2
|
+
|
|
3
|
+
Provides functionality to show article preview in browser via API.
|
|
4
|
+
This approach is faster and more stable than the editor-based approach.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from playwright.async_api import Error as PlaywrightError
|
|
12
|
+
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
13
|
+
|
|
14
|
+
from note_mcp.api.articles import build_preview_url, get_preview_access_token
|
|
15
|
+
from note_mcp.browser.manager import BrowserManager
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from note_mcp.models import Session
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def show_preview(
|
|
22
|
+
session: Session,
|
|
23
|
+
article_key: str,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Show article preview in browser via API.
|
|
26
|
+
|
|
27
|
+
Gets preview access token via API and navigates directly
|
|
28
|
+
to preview URL. Faster and more stable than editor-based approach.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
session: Authenticated session with username
|
|
32
|
+
article_key: Article key (e.g., "n1234567890ab")
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
NoteAPIError: If token fetch fails
|
|
36
|
+
RuntimeError: If browser navigation fails
|
|
37
|
+
"""
|
|
38
|
+
# Get preview access token via API
|
|
39
|
+
access_token = await get_preview_access_token(session, article_key)
|
|
40
|
+
|
|
41
|
+
# Build preview URL
|
|
42
|
+
preview_url = build_preview_url(article_key, access_token)
|
|
43
|
+
|
|
44
|
+
# Get browser page
|
|
45
|
+
manager = BrowserManager.get_instance()
|
|
46
|
+
page = await manager.get_page()
|
|
47
|
+
|
|
48
|
+
# Inject session cookies into browser context
|
|
49
|
+
playwright_cookies: list[dict[str, Any]] = []
|
|
50
|
+
for name, value in session.cookies.items():
|
|
51
|
+
playwright_cookies.append(
|
|
52
|
+
{
|
|
53
|
+
"name": name,
|
|
54
|
+
"value": value,
|
|
55
|
+
"domain": ".note.com",
|
|
56
|
+
"path": "/",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
await page.context.add_cookies(playwright_cookies) # type: ignore[arg-type]
|
|
60
|
+
|
|
61
|
+
# Navigate directly to preview URL (no editor involved)
|
|
62
|
+
try:
|
|
63
|
+
await page.goto(preview_url, wait_until="domcontentloaded")
|
|
64
|
+
await page.wait_for_load_state("networkidle")
|
|
65
|
+
except PlaywrightTimeoutError as e:
|
|
66
|
+
raise RuntimeError(f"Preview page load timed out: {preview_url}") from e
|
|
67
|
+
except PlaywrightError as e:
|
|
68
|
+
raise RuntimeError(f"Browser navigation failed: {e}") from e
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""URL helper utilities for browser automation.
|
|
2
|
+
|
|
3
|
+
Provides common URL validation and manipulation functions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_article_edit_url(current_url: str, article_key: str) -> bool:
|
|
8
|
+
"""Validate that the current URL is a valid article edit page.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
current_url: The current browser URL
|
|
12
|
+
article_key: The expected article key (e.g., "n1234567890ab")
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
True if the URL is a valid article edit page, False otherwise
|
|
16
|
+
"""
|
|
17
|
+
valid_patterns = [f"/notes/{article_key}", f"/n/{article_key}", "editor.note.com"]
|
|
18
|
+
return any(pattern in current_url for pattern in valid_patterns)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ChatGPT Apps SDK connector layer for note-connector."""
|