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,456 @@
|
|
|
1
|
+
"""Image upload operations for note.com API.
|
|
2
|
+
|
|
3
|
+
Provides functionality for uploading images to note.com.
|
|
4
|
+
Supports both eyecatch (header) images and body (inline) images.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from note_mcp.api.client import NoteAPIClient
|
|
16
|
+
from note_mcp.models import ErrorCode, Image, ImageType, NoteAPIError, Session
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _resolve_numeric_note_id(session: Session, note_id: str) -> str:
|
|
25
|
+
"""Resolve note ID to numeric format.
|
|
26
|
+
|
|
27
|
+
The image upload API requires numeric note IDs.
|
|
28
|
+
This function converts key format IDs (e.g., "ne1c111d2073c") to numeric IDs.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
session: Authenticated session
|
|
32
|
+
note_id: Note ID in either numeric or key format
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Numeric note ID as string
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
NoteAPIError: If ID resolution fails
|
|
39
|
+
"""
|
|
40
|
+
# If already numeric, return as-is
|
|
41
|
+
if note_id.isdigit():
|
|
42
|
+
return note_id
|
|
43
|
+
|
|
44
|
+
# Key format IDs start with "n" followed by alphanumeric characters
|
|
45
|
+
if not re.match(r"^n[a-z0-9]+$", note_id):
|
|
46
|
+
raise NoteAPIError(
|
|
47
|
+
code=ErrorCode.INVALID_INPUT,
|
|
48
|
+
message=f"Invalid note ID format: {note_id}",
|
|
49
|
+
details={"note_id": note_id},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Fetch article details to get numeric ID
|
|
53
|
+
async with NoteAPIClient(session) as client:
|
|
54
|
+
response = await client.get(f"/v3/notes/{note_id}")
|
|
55
|
+
|
|
56
|
+
# Extract numeric ID from response
|
|
57
|
+
data = response.get("data", {})
|
|
58
|
+
numeric_id = data.get("id")
|
|
59
|
+
|
|
60
|
+
if not numeric_id:
|
|
61
|
+
raise NoteAPIError(
|
|
62
|
+
code=ErrorCode.API_ERROR,
|
|
63
|
+
message=f"Failed to resolve note ID: {note_id}",
|
|
64
|
+
details={"note_id": note_id, "response": response},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return str(numeric_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Allowed image file extensions
|
|
71
|
+
ALLOWED_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
72
|
+
|
|
73
|
+
# Maximum file size in bytes (10MB)
|
|
74
|
+
MAX_FILE_SIZE: int = 10 * 1024 * 1024
|
|
75
|
+
|
|
76
|
+
# Content-type mapping for image files (single source of truth - DRY)
|
|
77
|
+
CONTENT_TYPE_MAP: dict[str, str] = {
|
|
78
|
+
".jpg": "image/jpeg",
|
|
79
|
+
".jpeg": "image/jpeg",
|
|
80
|
+
".png": "image/png",
|
|
81
|
+
".gif": "image/gif",
|
|
82
|
+
".webp": "image/webp",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# API endpoints for different image types
|
|
86
|
+
# Note: Body images use the same endpoint as eyecatch images.
|
|
87
|
+
# The returned URL can be embedded in article body using Markdown syntax.
|
|
88
|
+
IMAGE_UPLOAD_ENDPOINTS: dict[ImageType, str] = {
|
|
89
|
+
ImageType.EYECATCH: "/v1/image_upload/note_eyecatch",
|
|
90
|
+
ImageType.BODY: "/v1/image_upload/note_eyecatch", # Same endpoint - URL works for body embedding
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_image_file(file_path: str) -> None:
|
|
95
|
+
"""Validate image file before upload.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
file_path: Path to the image file
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
NoteAPIError: If file is invalid (not found, wrong format, too large)
|
|
102
|
+
"""
|
|
103
|
+
path = Path(file_path)
|
|
104
|
+
|
|
105
|
+
# Check file exists
|
|
106
|
+
if not path.exists():
|
|
107
|
+
raise NoteAPIError(
|
|
108
|
+
code=ErrorCode.INVALID_INPUT,
|
|
109
|
+
message=f"File not found: {file_path}",
|
|
110
|
+
details={"file_path": file_path},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Check file extension
|
|
114
|
+
if path.suffix.lower() not in ALLOWED_EXTENSIONS:
|
|
115
|
+
raise NoteAPIError(
|
|
116
|
+
code=ErrorCode.INVALID_INPUT,
|
|
117
|
+
message=(f"Invalid file format: {path.suffix}. Allowed formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}"),
|
|
118
|
+
details={"file_path": file_path, "extension": path.suffix},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Check file size
|
|
122
|
+
file_size = path.stat().st_size
|
|
123
|
+
if file_size > MAX_FILE_SIZE:
|
|
124
|
+
raise NoteAPIError(
|
|
125
|
+
code=ErrorCode.INVALID_INPUT,
|
|
126
|
+
message=(f"File size ({file_size} bytes) exceeds maximum allowed size ({MAX_FILE_SIZE} bytes)"),
|
|
127
|
+
details={"file_path": file_path, "size": file_size, "max_size": MAX_FILE_SIZE},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _upload_image_internal(
|
|
132
|
+
session: Session,
|
|
133
|
+
file_path: str,
|
|
134
|
+
note_id: str,
|
|
135
|
+
image_type: ImageType,
|
|
136
|
+
) -> Image:
|
|
137
|
+
"""Internal function for uploading an image to note.com.
|
|
138
|
+
|
|
139
|
+
Validates the file format and size before uploading.
|
|
140
|
+
Uses multipart/form-data for the upload.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
session: Authenticated session
|
|
144
|
+
file_path: Path to the image file
|
|
145
|
+
note_id: The note ID to associate the image with (numeric or key format)
|
|
146
|
+
image_type: Type of image (eyecatch or body)
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Image object with upload result
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
NoteAPIError: If validation fails or API request fails
|
|
153
|
+
"""
|
|
154
|
+
# Validate file before upload
|
|
155
|
+
validate_image_file(file_path)
|
|
156
|
+
|
|
157
|
+
# Resolve note ID to numeric format (API requirement)
|
|
158
|
+
numeric_note_id = await _resolve_numeric_note_id(session, note_id)
|
|
159
|
+
|
|
160
|
+
path = Path(file_path)
|
|
161
|
+
file_size = path.stat().st_size
|
|
162
|
+
|
|
163
|
+
# Prepare file for multipart upload
|
|
164
|
+
with open(file_path, "rb") as f:
|
|
165
|
+
file_content = f.read()
|
|
166
|
+
|
|
167
|
+
# Determine content type based on extension
|
|
168
|
+
content_type = CONTENT_TYPE_MAP.get(path.suffix.lower(), "application/octet-stream")
|
|
169
|
+
|
|
170
|
+
# Prepare files for multipart request
|
|
171
|
+
files = {
|
|
172
|
+
"file": (path.name, file_content, content_type),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# note_id is required by the API (must be numeric)
|
|
176
|
+
data = {"note_id": numeric_note_id}
|
|
177
|
+
|
|
178
|
+
# Get endpoint for the image type
|
|
179
|
+
endpoint = IMAGE_UPLOAD_ENDPOINTS[image_type]
|
|
180
|
+
|
|
181
|
+
async with NoteAPIClient(session) as client:
|
|
182
|
+
response = await client.post(endpoint, files=files, data=data)
|
|
183
|
+
|
|
184
|
+
# Parse response - Article 6: validate required fields, no fallback
|
|
185
|
+
image_data = response.get("data", {})
|
|
186
|
+
|
|
187
|
+
# Note: The eyecatch upload endpoint (/v1/image_upload/note_eyecatch) returns
|
|
188
|
+
# only 'url' in the response, not 'key'. This is expected behavior based on
|
|
189
|
+
# API testing - body images (via presigned_post) return 'key', eyecatch does not.
|
|
190
|
+
image_key = image_data.get("key")
|
|
191
|
+
|
|
192
|
+
image_url = image_data.get("url")
|
|
193
|
+
if not image_url:
|
|
194
|
+
raise NoteAPIError(
|
|
195
|
+
code=ErrorCode.API_ERROR,
|
|
196
|
+
message="Image upload failed: API response missing required field 'url'",
|
|
197
|
+
details={"response": response},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return Image(
|
|
201
|
+
key=str(image_key) if image_key else None,
|
|
202
|
+
url=str(image_url),
|
|
203
|
+
original_path=file_path,
|
|
204
|
+
size_bytes=file_size,
|
|
205
|
+
uploaded_at=int(time.time()),
|
|
206
|
+
image_type=image_type,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def upload_eyecatch_image(
|
|
211
|
+
session: Session,
|
|
212
|
+
file_path: str,
|
|
213
|
+
note_id: str,
|
|
214
|
+
) -> Image:
|
|
215
|
+
"""Upload an eyecatch (header) image to note.com.
|
|
216
|
+
|
|
217
|
+
Validates the file format and size before uploading.
|
|
218
|
+
Uses multipart/form-data for the upload.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
session: Authenticated session
|
|
222
|
+
file_path: Path to the image file
|
|
223
|
+
note_id: The note ID to associate the image with (required by API)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Image object with upload result
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
NoteAPIError: If validation fails or API request fails
|
|
230
|
+
"""
|
|
231
|
+
return await _upload_image_internal(session, file_path, note_id, ImageType.EYECATCH)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def upload_body_image(
|
|
235
|
+
session: Session,
|
|
236
|
+
file_path: str,
|
|
237
|
+
note_id: str,
|
|
238
|
+
) -> Image:
|
|
239
|
+
"""Upload a body (inline) image to note.com.
|
|
240
|
+
|
|
241
|
+
Uses the presigned_post flow to upload directly to S3.
|
|
242
|
+
This does NOT update the eyecatch image (unlike the eyecatch endpoint).
|
|
243
|
+
The returned URL can be embedded in article body using Markdown syntax:
|
|
244
|
+

|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
session: Authenticated session
|
|
248
|
+
file_path: Path to the image file
|
|
249
|
+
note_id: The note ID to associate the image with (for metadata only)
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Image object with upload result
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
NoteAPIError: If validation fails or API request fails
|
|
256
|
+
"""
|
|
257
|
+
import httpx
|
|
258
|
+
|
|
259
|
+
# Validate file before upload
|
|
260
|
+
validate_image_file(file_path)
|
|
261
|
+
|
|
262
|
+
path = Path(file_path)
|
|
263
|
+
file_size = path.stat().st_size
|
|
264
|
+
|
|
265
|
+
# Step 1: Get presigned POST URL from note.com
|
|
266
|
+
async with NoteAPIClient(session) as client:
|
|
267
|
+
response = await client.post(
|
|
268
|
+
"/v3/images/upload/presigned_post",
|
|
269
|
+
data={"filename": path.name},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
presigned_data = response.get("data", {})
|
|
273
|
+
s3_url = presigned_data.get("action")
|
|
274
|
+
image_url = presigned_data.get("url")
|
|
275
|
+
post_fields = presigned_data.get("post", {})
|
|
276
|
+
|
|
277
|
+
if not s3_url or not image_url or not post_fields:
|
|
278
|
+
raise NoteAPIError(
|
|
279
|
+
code=ErrorCode.API_ERROR,
|
|
280
|
+
message="Failed to get presigned URL for image upload",
|
|
281
|
+
details={"response": response},
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Step 2: Upload file directly to S3
|
|
285
|
+
with open(file_path, "rb") as f:
|
|
286
|
+
file_content = f.read()
|
|
287
|
+
|
|
288
|
+
# Article 6: Validate required S3 presigned POST fields
|
|
289
|
+
required_s3_fields = [
|
|
290
|
+
"key",
|
|
291
|
+
"policy",
|
|
292
|
+
"x-amz-credential",
|
|
293
|
+
"x-amz-algorithm",
|
|
294
|
+
"x-amz-date",
|
|
295
|
+
"x-amz-signature",
|
|
296
|
+
]
|
|
297
|
+
for field in required_s3_fields:
|
|
298
|
+
if not post_fields.get(field):
|
|
299
|
+
raise NoteAPIError(
|
|
300
|
+
code=ErrorCode.API_ERROR,
|
|
301
|
+
message=f"Presigned POST missing required S3 field: {field}",
|
|
302
|
+
details={"response": response, "missing_field": field},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Build multipart form data with S3 required fields
|
|
306
|
+
# Order matters for S3 - policy fields first, then file
|
|
307
|
+
files_data: dict[str, tuple[None, str] | tuple[str, bytes, str]] = {
|
|
308
|
+
"key": (None, str(post_fields["key"])),
|
|
309
|
+
"acl": (None, str(post_fields.get("acl", ""))),
|
|
310
|
+
"Expires": (None, str(post_fields.get("Expires", ""))),
|
|
311
|
+
"policy": (None, str(post_fields["policy"])),
|
|
312
|
+
"x-amz-credential": (None, str(post_fields["x-amz-credential"])),
|
|
313
|
+
"x-amz-algorithm": (None, str(post_fields["x-amz-algorithm"])),
|
|
314
|
+
"x-amz-date": (None, str(post_fields["x-amz-date"])),
|
|
315
|
+
"x-amz-signature": (None, str(post_fields["x-amz-signature"])),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Determine content type
|
|
319
|
+
content_type = CONTENT_TYPE_MAP.get(path.suffix.lower(), "application/octet-stream")
|
|
320
|
+
|
|
321
|
+
# Add file last (S3 requirement)
|
|
322
|
+
files_data["file"] = (path.name, file_content, content_type)
|
|
323
|
+
|
|
324
|
+
async with httpx.AsyncClient() as http_client:
|
|
325
|
+
s3_response = await http_client.post(s3_url, files=files_data)
|
|
326
|
+
|
|
327
|
+
if not s3_response.is_success:
|
|
328
|
+
raise NoteAPIError(
|
|
329
|
+
code=ErrorCode.API_ERROR,
|
|
330
|
+
message=f"Failed to upload image to S3: {s3_response.status_code}",
|
|
331
|
+
details={"status": s3_response.status_code, "response": s3_response.text},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# key is validated above in required_s3_fields check
|
|
335
|
+
return Image(
|
|
336
|
+
key=str(post_fields["key"]),
|
|
337
|
+
url=image_url,
|
|
338
|
+
original_path=file_path,
|
|
339
|
+
size_bytes=file_size,
|
|
340
|
+
uploaded_at=int(time.time()),
|
|
341
|
+
image_type=ImageType.BODY,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def insert_image_via_api(
|
|
346
|
+
session: Session,
|
|
347
|
+
article_id: str,
|
|
348
|
+
file_path: str,
|
|
349
|
+
caption: str | None = None,
|
|
350
|
+
) -> dict[str, Any]:
|
|
351
|
+
"""Insert an image into an article via API.
|
|
352
|
+
|
|
353
|
+
Fully API-based implementation without Playwright dependency.
|
|
354
|
+
This is faster and more reliable than browser-based insertion.
|
|
355
|
+
|
|
356
|
+
Flow:
|
|
357
|
+
1. Validate image file
|
|
358
|
+
2. Get article with raw HTML body
|
|
359
|
+
3. Upload image to S3 via API
|
|
360
|
+
4. Generate figure HTML
|
|
361
|
+
5. Append to existing body
|
|
362
|
+
6. Update article via draft_save API
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
session: Authenticated session
|
|
366
|
+
article_id: Article key (e.g., "n1234567890ab").
|
|
367
|
+
Note: Key format is required due to note.com API limitations.
|
|
368
|
+
The /v3/notes/ endpoint does not support numeric IDs.
|
|
369
|
+
Use the article key returned from create_draft() or list_articles().
|
|
370
|
+
file_path: Path to the image file to insert
|
|
371
|
+
caption: Optional caption for the image
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Dictionary with the following keys:
|
|
375
|
+
- success: Always True on success (raises on failure)
|
|
376
|
+
- article_id: Numeric article ID
|
|
377
|
+
- article_key: Article key (e.g., "n1234567890ab")
|
|
378
|
+
- file_path: Path to the uploaded file
|
|
379
|
+
- image_url: URL of the uploaded image on note.com CDN
|
|
380
|
+
- caption: Caption text (if provided)
|
|
381
|
+
- fallback_used: Always False (no browser fallback in API-only mode)
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
NoteAPIError: If image insertion fails
|
|
385
|
+
"""
|
|
386
|
+
# Import here to avoid circular imports
|
|
387
|
+
from note_mcp.api.articles import (
|
|
388
|
+
append_image_to_body,
|
|
389
|
+
generate_image_html,
|
|
390
|
+
get_article_raw_html,
|
|
391
|
+
update_article_raw_html,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Step 1: Validate file (existence, extension, and size)
|
|
395
|
+
validate_image_file(file_path)
|
|
396
|
+
|
|
397
|
+
# Step 2: Validate article_id format
|
|
398
|
+
# Issue #147: /v3/notes/ endpoint does not support numeric IDs
|
|
399
|
+
if article_id.isdigit():
|
|
400
|
+
raise NoteAPIError(
|
|
401
|
+
code=ErrorCode.INVALID_INPUT,
|
|
402
|
+
message=(
|
|
403
|
+
f"Numeric article ID '{article_id}' is not supported. "
|
|
404
|
+
"Please use the article key format (e.g., 'n1234567890ab'). "
|
|
405
|
+
"You can get the article key from create_draft() or list_articles()."
|
|
406
|
+
),
|
|
407
|
+
details={"article_id": article_id},
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Step 3: Get article with raw HTML body
|
|
411
|
+
try:
|
|
412
|
+
article = await get_article_raw_html(session, article_id)
|
|
413
|
+
except NoteAPIError as e:
|
|
414
|
+
raise NoteAPIError(
|
|
415
|
+
code=ErrorCode.INVALID_INPUT,
|
|
416
|
+
message=f"Invalid article ID: {article_id}. Please verify the article exists and you have access.",
|
|
417
|
+
details={"article_id": article_id, "original_error": str(e)},
|
|
418
|
+
) from e
|
|
419
|
+
|
|
420
|
+
article_key = article.key
|
|
421
|
+
numeric_id = article.id
|
|
422
|
+
logger.debug(f"Article validated: key={article_key}, numeric_id={numeric_id}")
|
|
423
|
+
|
|
424
|
+
# Step 3: Upload image via API
|
|
425
|
+
image = await upload_body_image(session, file_path, numeric_id)
|
|
426
|
+
logger.info(f"Image uploaded via API: {image.url[:50]}...")
|
|
427
|
+
|
|
428
|
+
# Step 4: Generate image HTML in note.com format
|
|
429
|
+
image_html = generate_image_html(
|
|
430
|
+
image_url=image.url,
|
|
431
|
+
caption=caption or "",
|
|
432
|
+
)
|
|
433
|
+
logger.debug(f"Generated image HTML: {image_html[:100]}...")
|
|
434
|
+
|
|
435
|
+
# Step 5: Append image to existing body
|
|
436
|
+
new_body_html = append_image_to_body(article.body or "", image_html)
|
|
437
|
+
logger.debug(f"New body length: {len(new_body_html)} chars")
|
|
438
|
+
|
|
439
|
+
# Step 6: Update article via API (draft_save)
|
|
440
|
+
await update_article_raw_html(
|
|
441
|
+
session=session,
|
|
442
|
+
article_id=numeric_id,
|
|
443
|
+
title=article.title,
|
|
444
|
+
html_body=new_body_html,
|
|
445
|
+
)
|
|
446
|
+
logger.info("Article updated via API")
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
"success": True,
|
|
450
|
+
"article_id": numeric_id,
|
|
451
|
+
"article_key": article_key,
|
|
452
|
+
"file_path": file_path,
|
|
453
|
+
"image_url": image.url,
|
|
454
|
+
"caption": caption,
|
|
455
|
+
"fallback_used": False, # No fallback in API-only mode
|
|
456
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Preview API functions for note.com.
|
|
2
|
+
|
|
3
|
+
Provides functionality to get preview access tokens
|
|
4
|
+
and fetch preview page HTML.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from note_mcp.api.articles import build_preview_url, get_preview_access_token
|
|
16
|
+
from note_mcp.models import ErrorCode, NoteAPIError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from note_mcp.models import Session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Re-export for convenience
|
|
25
|
+
__all__ = ["get_preview_access_token", "build_preview_url", "get_preview_html"]
|
|
26
|
+
|
|
27
|
+
# Common User-Agent string for API requests
|
|
28
|
+
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
|
29
|
+
|
|
30
|
+
# Retry configuration for transient errors
|
|
31
|
+
MAX_TRANSIENT_RETRIES = 3 # Maximum retries for transient errors (502/503/504)
|
|
32
|
+
BASE_DELAY = 0.5 # Initial backoff delay in seconds
|
|
33
|
+
MAX_DELAY = 4.0 # Maximum backoff delay in seconds
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_preview_html(
|
|
37
|
+
session: Session,
|
|
38
|
+
article_key: str,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Fetch preview page HTML for an article.
|
|
41
|
+
|
|
42
|
+
Gets preview access token via API and fetches the preview page HTML.
|
|
43
|
+
Useful for E2E testing and content verification.
|
|
44
|
+
|
|
45
|
+
Retry behavior:
|
|
46
|
+
- Authentication errors (401/403): Retries once with a fresh token
|
|
47
|
+
- Transient server errors (502/503/504): Retries with exponential backoff
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
session: Authenticated session
|
|
51
|
+
article_key: Article key (e.g., "n1234567890ab")
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Preview page HTML as string
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
NoteAPIError: If token fetch or HTML fetch fails after all retries
|
|
58
|
+
"""
|
|
59
|
+
# Build cookie header
|
|
60
|
+
cookie_parts = [f"{k}={v}" for k, v in session.cookies.items()]
|
|
61
|
+
cookies_header = "; ".join(cookie_parts)
|
|
62
|
+
|
|
63
|
+
# HTTP headers for requests
|
|
64
|
+
headers = {
|
|
65
|
+
"Cookie": cookies_header,
|
|
66
|
+
"User-Agent": USER_AGENT,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Auth error status codes that trigger token refresh retry
|
|
70
|
+
auth_error_codes = {401, 403}
|
|
71
|
+
|
|
72
|
+
# Transient server error codes that trigger backoff retry
|
|
73
|
+
transient_error_codes = {502, 503, 504}
|
|
74
|
+
|
|
75
|
+
last_response: httpx.Response | None = None
|
|
76
|
+
auth_retry_used = False
|
|
77
|
+
transient_retry_count = 0
|
|
78
|
+
|
|
79
|
+
while True:
|
|
80
|
+
# Get preview access token via API
|
|
81
|
+
access_token = await get_preview_access_token(session, article_key)
|
|
82
|
+
|
|
83
|
+
# Build preview URL
|
|
84
|
+
preview_url = build_preview_url(article_key, access_token)
|
|
85
|
+
|
|
86
|
+
# Fetch HTML via httpx
|
|
87
|
+
async with httpx.AsyncClient() as client:
|
|
88
|
+
response = await client.get(
|
|
89
|
+
preview_url,
|
|
90
|
+
headers=headers,
|
|
91
|
+
follow_redirects=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if response.is_success:
|
|
95
|
+
return response.text
|
|
96
|
+
|
|
97
|
+
last_response = response
|
|
98
|
+
status_code = response.status_code
|
|
99
|
+
|
|
100
|
+
# Handle auth errors: retry once with fresh token
|
|
101
|
+
if status_code in auth_error_codes and not auth_retry_used:
|
|
102
|
+
logger.warning(
|
|
103
|
+
"Preview HTML fetch got auth error %d, retrying with fresh token",
|
|
104
|
+
status_code,
|
|
105
|
+
)
|
|
106
|
+
auth_retry_used = True
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Handle transient server errors: retry with exponential backoff
|
|
110
|
+
if status_code in transient_error_codes and transient_retry_count < MAX_TRANSIENT_RETRIES:
|
|
111
|
+
delay = min(BASE_DELAY * (2**transient_retry_count), MAX_DELAY)
|
|
112
|
+
logger.warning(
|
|
113
|
+
"Preview HTML fetch got transient error %d, retrying in %.1fs (%d/%d)",
|
|
114
|
+
status_code,
|
|
115
|
+
delay,
|
|
116
|
+
transient_retry_count + 1,
|
|
117
|
+
MAX_TRANSIENT_RETRIES,
|
|
118
|
+
)
|
|
119
|
+
await asyncio.sleep(delay)
|
|
120
|
+
transient_retry_count += 1
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# No more retries available
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# All attempts failed
|
|
127
|
+
assert last_response is not None
|
|
128
|
+
|
|
129
|
+
# Use NOT_AUTHENTICATED for 401 errors, API_ERROR for others
|
|
130
|
+
error_code = ErrorCode.NOT_AUTHENTICATED if last_response.status_code == 401 else ErrorCode.API_ERROR
|
|
131
|
+
|
|
132
|
+
raise NoteAPIError(
|
|
133
|
+
code=error_code,
|
|
134
|
+
message=f"Failed to fetch preview HTML. Status: {last_response.status_code}",
|
|
135
|
+
details={
|
|
136
|
+
"article_key": article_key,
|
|
137
|
+
"status_code": last_response.status_code,
|
|
138
|
+
"response_text": last_response.text[:500] if last_response.text else "(empty)",
|
|
139
|
+
"auth_retry_used": auth_retry_used,
|
|
140
|
+
"transient_retry_count": transient_retry_count,
|
|
141
|
+
},
|
|
142
|
+
)
|