note-connector 0.2.4 → 0.2.6
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 +61 -7
- 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 +456 -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 +557 -0
- package/py/src/note_mcp/py.typed +0 -0
- package/py/src/note_mcp/server.py +905 -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,318 @@
|
|
|
1
|
+
"""note.com API client using httpx.
|
|
2
|
+
|
|
3
|
+
Provides authenticated access to note.com API endpoints
|
|
4
|
+
with rate limiting and error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from note_mcp.models import ErrorCode, NoteAPIError, Session
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# API base URL
|
|
22
|
+
NOTE_API_BASE = "https://note.com/api"
|
|
23
|
+
|
|
24
|
+
# Editor origin and referer for mutating API requests
|
|
25
|
+
NOTE_EDITOR_ORIGIN = "https://editor.note.com"
|
|
26
|
+
NOTE_EDITOR_REFERER = "https://editor.note.com/"
|
|
27
|
+
|
|
28
|
+
# Common User-Agent string for API requests
|
|
29
|
+
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
|
30
|
+
|
|
31
|
+
# Rate limiting configuration
|
|
32
|
+
RATE_LIMIT_REQUESTS = 10 # Maximum requests per minute
|
|
33
|
+
RATE_LIMIT_WINDOW = 60 # Window size in seconds
|
|
34
|
+
|
|
35
|
+
# Default timeout for requests (seconds)
|
|
36
|
+
DEFAULT_TIMEOUT = 30
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoteAPIClient:
|
|
40
|
+
"""HTTP client for note.com API.
|
|
41
|
+
|
|
42
|
+
Provides authenticated requests with rate limiting and error handling.
|
|
43
|
+
Use as async context manager for proper resource management.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
session: User session with authentication cookies
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, session: Session | None = None) -> None:
|
|
50
|
+
"""Initialize API client.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
session: User session for authentication (optional for public endpoints)
|
|
54
|
+
"""
|
|
55
|
+
self.session = session
|
|
56
|
+
self._client: httpx.AsyncClient | None = None
|
|
57
|
+
self._request_times: list[float] = []
|
|
58
|
+
|
|
59
|
+
async def __aenter__(self) -> Self:
|
|
60
|
+
"""Enter async context manager."""
|
|
61
|
+
self._client = httpx.AsyncClient(
|
|
62
|
+
base_url=NOTE_API_BASE,
|
|
63
|
+
timeout=httpx.Timeout(DEFAULT_TIMEOUT),
|
|
64
|
+
)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(
|
|
68
|
+
self,
|
|
69
|
+
exc_type: type[BaseException] | None,
|
|
70
|
+
exc_val: BaseException | None,
|
|
71
|
+
exc_tb: TracebackType | None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Exit async context manager."""
|
|
74
|
+
if self._client is not None:
|
|
75
|
+
await self._client.aclose()
|
|
76
|
+
self._client = None
|
|
77
|
+
|
|
78
|
+
def _build_headers(self, include_xsrf: bool = False) -> dict[str, str]:
|
|
79
|
+
"""Build request headers with authentication.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
include_xsrf: Whether to include X-XSRF-TOKEN header (for POST/PUT/DELETE)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Headers dictionary with Accept and Cookie if session exists
|
|
86
|
+
"""
|
|
87
|
+
headers: dict[str, str] = {
|
|
88
|
+
"Accept": "*/*",
|
|
89
|
+
"User-Agent": USER_AGENT,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if self.session is not None:
|
|
93
|
+
cookie_parts = [f"{k}={v}" for k, v in self.session.cookies.items()]
|
|
94
|
+
headers["Cookie"] = "; ".join(cookie_parts)
|
|
95
|
+
|
|
96
|
+
# Add XSRF token and editor headers for mutating requests
|
|
97
|
+
if include_xsrf:
|
|
98
|
+
xsrf_token = self.session.cookies.get("XSRF-TOKEN")
|
|
99
|
+
if xsrf_token:
|
|
100
|
+
headers["X-XSRF-TOKEN"] = xsrf_token
|
|
101
|
+
# Required headers for editor API
|
|
102
|
+
headers["Origin"] = NOTE_EDITOR_ORIGIN
|
|
103
|
+
headers["Referer"] = NOTE_EDITOR_REFERER
|
|
104
|
+
headers["X-Requested-With"] = "XMLHttpRequest"
|
|
105
|
+
# Sec-Fetch headers (browser security headers)
|
|
106
|
+
headers["Sec-Fetch-Site"] = "same-site"
|
|
107
|
+
headers["Sec-Fetch-Mode"] = "cors"
|
|
108
|
+
headers["Sec-Fetch-Dest"] = "empty"
|
|
109
|
+
|
|
110
|
+
return headers
|
|
111
|
+
|
|
112
|
+
async def _check_rate_limit(self) -> None:
|
|
113
|
+
"""Check and enforce rate limiting.
|
|
114
|
+
|
|
115
|
+
Cleans up old timestamps and checks if we've exceeded the limit.
|
|
116
|
+
"""
|
|
117
|
+
now = time.time()
|
|
118
|
+
window_start = now - RATE_LIMIT_WINDOW
|
|
119
|
+
|
|
120
|
+
# Clean up old timestamps
|
|
121
|
+
self._request_times = [t for t in self._request_times if t > window_start]
|
|
122
|
+
|
|
123
|
+
# Note: We don't actively wait here, we just track
|
|
124
|
+
# The actual rate limit error will come from the server
|
|
125
|
+
# This is mainly for client-side tracking
|
|
126
|
+
|
|
127
|
+
def _track_request(self) -> None:
|
|
128
|
+
"""Track a request for rate limiting."""
|
|
129
|
+
self._request_times.append(time.time())
|
|
130
|
+
|
|
131
|
+
async def _request(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
path: str,
|
|
135
|
+
*,
|
|
136
|
+
params: dict[str, Any] | None = None,
|
|
137
|
+
json: dict[str, Any] | None = None,
|
|
138
|
+
data: dict[str, Any] | None = None,
|
|
139
|
+
files: dict[str, Any] | None = None,
|
|
140
|
+
include_xsrf: bool = False,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Make an HTTP request to the API.
|
|
143
|
+
|
|
144
|
+
Centralizes: init check, rate limit, tracking, headers, error handling.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
148
|
+
path: API endpoint path
|
|
149
|
+
params: Query parameters
|
|
150
|
+
json: JSON body
|
|
151
|
+
data: Form data
|
|
152
|
+
files: Files to upload
|
|
153
|
+
include_xsrf: Whether to include X-XSRF-TOKEN header
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
JSON response as dictionary
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
RuntimeError: If client not initialized
|
|
160
|
+
NoteAPIError: If request fails
|
|
161
|
+
"""
|
|
162
|
+
if self._client is None:
|
|
163
|
+
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
|
|
164
|
+
|
|
165
|
+
await self._check_rate_limit()
|
|
166
|
+
self._track_request()
|
|
167
|
+
|
|
168
|
+
headers = self._build_headers(include_xsrf=include_xsrf)
|
|
169
|
+
|
|
170
|
+
# Set Content-Type for JSON requests (not multipart)
|
|
171
|
+
if json is not None and files is None:
|
|
172
|
+
headers["Content-Type"] = "application/json"
|
|
173
|
+
|
|
174
|
+
request_method = getattr(self._client, method.lower())
|
|
175
|
+
|
|
176
|
+
# Build kwargs based on method - GET doesn't support json/data/files
|
|
177
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
178
|
+
if params is not None:
|
|
179
|
+
kwargs["params"] = params
|
|
180
|
+
if method.upper() != "GET":
|
|
181
|
+
if json is not None:
|
|
182
|
+
kwargs["json"] = json
|
|
183
|
+
if data is not None:
|
|
184
|
+
kwargs["data"] = data
|
|
185
|
+
if files is not None:
|
|
186
|
+
kwargs["files"] = files
|
|
187
|
+
|
|
188
|
+
response = await request_method(path, **kwargs)
|
|
189
|
+
|
|
190
|
+
if not response.is_success:
|
|
191
|
+
self._handle_error_response(response)
|
|
192
|
+
|
|
193
|
+
result: dict[str, Any] = response.json()
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
197
|
+
"""Handle error responses from the API.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
response: HTTP response object
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
NoteAPIError: With appropriate error code
|
|
204
|
+
"""
|
|
205
|
+
status = response.status_code
|
|
206
|
+
|
|
207
|
+
if status == 401:
|
|
208
|
+
raise NoteAPIError(
|
|
209
|
+
code=ErrorCode.NOT_AUTHENTICATED,
|
|
210
|
+
message="Authentication required. Please log in first.",
|
|
211
|
+
details={"status_code": status},
|
|
212
|
+
)
|
|
213
|
+
elif status == 403:
|
|
214
|
+
raise NoteAPIError(
|
|
215
|
+
code=ErrorCode.API_ERROR,
|
|
216
|
+
message="Access denied.",
|
|
217
|
+
details={"status_code": status, "response": response.text},
|
|
218
|
+
)
|
|
219
|
+
elif status == 404:
|
|
220
|
+
raise NoteAPIError(
|
|
221
|
+
code=ErrorCode.ARTICLE_NOT_FOUND,
|
|
222
|
+
message="Resource not found.",
|
|
223
|
+
details={"status_code": status, "response": response.text},
|
|
224
|
+
)
|
|
225
|
+
elif status == 429:
|
|
226
|
+
raise NoteAPIError(
|
|
227
|
+
code=ErrorCode.RATE_LIMITED,
|
|
228
|
+
message="Rate limit exceeded. Please wait before making more requests.",
|
|
229
|
+
details={"status_code": status},
|
|
230
|
+
)
|
|
231
|
+
elif status >= 500:
|
|
232
|
+
raise NoteAPIError(
|
|
233
|
+
code=ErrorCode.API_ERROR,
|
|
234
|
+
message="Server error. Please try again later.",
|
|
235
|
+
details={"status_code": status, "response": response.text},
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
# Include response text in error message for debugging
|
|
239
|
+
raise NoteAPIError(
|
|
240
|
+
code=ErrorCode.API_ERROR,
|
|
241
|
+
message=f"API request failed with status {status}. Response: {response.text[:500]}",
|
|
242
|
+
details={"status_code": status, "response": response.text},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def get(
|
|
246
|
+
self,
|
|
247
|
+
path: str,
|
|
248
|
+
params: dict[str, Any] | None = None,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
"""Make a GET request to the API.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
path: API endpoint path (e.g., "/v1/articles")
|
|
254
|
+
params: Query parameters
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
JSON response as dictionary
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
NoteAPIError: If request fails
|
|
261
|
+
"""
|
|
262
|
+
return await self._request("GET", path, params=params)
|
|
263
|
+
|
|
264
|
+
async def post(
|
|
265
|
+
self,
|
|
266
|
+
path: str,
|
|
267
|
+
json: dict[str, Any] | None = None,
|
|
268
|
+
data: dict[str, Any] | None = None,
|
|
269
|
+
files: dict[str, Any] | None = None,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
"""Make a POST request to the API.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
path: API endpoint path
|
|
275
|
+
json: JSON body
|
|
276
|
+
data: Form data
|
|
277
|
+
files: Files to upload
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
JSON response as dictionary
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
NoteAPIError: If request fails
|
|
284
|
+
"""
|
|
285
|
+
return await self._request("POST", path, json=json, data=data, files=files, include_xsrf=True)
|
|
286
|
+
|
|
287
|
+
async def put(
|
|
288
|
+
self,
|
|
289
|
+
path: str,
|
|
290
|
+
json: dict[str, Any] | None = None,
|
|
291
|
+
) -> dict[str, Any]:
|
|
292
|
+
"""Make a PUT request to the API.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
path: API endpoint path
|
|
296
|
+
json: JSON body
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
JSON response as dictionary
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
NoteAPIError: If request fails
|
|
303
|
+
"""
|
|
304
|
+
return await self._request("PUT", path, json=json, include_xsrf=True)
|
|
305
|
+
|
|
306
|
+
async def delete(self, path: str) -> dict[str, Any]:
|
|
307
|
+
"""Make a DELETE request to the API.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
path: API endpoint path
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
JSON response as dictionary
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
NoteAPIError: If request fails
|
|
317
|
+
"""
|
|
318
|
+
return await self._request("DELETE", path, include_xsrf=True)
|