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

|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
session: Authenticated session
|
|
274
|
+
file_path: Path to the image file
|
|
275
|
+
note_id: The note ID to associate the image with (for metadata only)
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Image object with upload result
|
|
279
|
+
|
|
280
|
+
Raises:
|
|
281
|
+
NoteAPIError: If validation fails or API request fails
|
|
282
|
+
"""
|
|
283
|
+
import httpx
|
|
284
|
+
|
|
285
|
+
# Validate file before upload
|
|
286
|
+
validate_image_file(file_path)
|
|
287
|
+
|
|
288
|
+
path = Path(file_path)
|
|
289
|
+
file_size = path.stat().st_size
|
|
290
|
+
|
|
291
|
+
# Step 1: Get presigned POST URL from note.com
|
|
292
|
+
async with NoteAPIClient(session) as client:
|
|
293
|
+
response = await client.post(
|
|
294
|
+
"/v3/images/upload/presigned_post",
|
|
295
|
+
data={"filename": path.name},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
presigned_data = response.get("data", {})
|
|
299
|
+
s3_url = presigned_data.get("action")
|
|
300
|
+
image_url = presigned_data.get("url")
|
|
301
|
+
post_fields = presigned_data.get("post", {})
|
|
302
|
+
|
|
303
|
+
if not s3_url or not image_url or not post_fields:
|
|
304
|
+
raise NoteAPIError(
|
|
305
|
+
code=ErrorCode.API_ERROR,
|
|
306
|
+
message="Failed to get presigned URL for image upload",
|
|
307
|
+
details={"response": response},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Step 2: Upload file directly to S3
|
|
311
|
+
with open(file_path, "rb") as f:
|
|
312
|
+
file_content = f.read()
|
|
313
|
+
|
|
314
|
+
# Article 6: Validate required S3 presigned POST fields
|
|
315
|
+
required_s3_fields = [
|
|
316
|
+
"key",
|
|
317
|
+
"policy",
|
|
318
|
+
"x-amz-credential",
|
|
319
|
+
"x-amz-algorithm",
|
|
320
|
+
"x-amz-date",
|
|
321
|
+
"x-amz-signature",
|
|
322
|
+
]
|
|
323
|
+
for field in required_s3_fields:
|
|
324
|
+
if not post_fields.get(field):
|
|
325
|
+
raise NoteAPIError(
|
|
326
|
+
code=ErrorCode.API_ERROR,
|
|
327
|
+
message=f"Presigned POST missing required S3 field: {field}",
|
|
328
|
+
details={"response": response, "missing_field": field},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Build multipart form data with S3 required fields
|
|
332
|
+
# Order matters for S3 - policy fields first, then file
|
|
333
|
+
files_data: dict[str, tuple[None, str] | tuple[str, bytes, str]] = {
|
|
334
|
+
"key": (None, str(post_fields["key"])),
|
|
335
|
+
"acl": (None, str(post_fields.get("acl", ""))),
|
|
336
|
+
"Expires": (None, str(post_fields.get("Expires", ""))),
|
|
337
|
+
"policy": (None, str(post_fields["policy"])),
|
|
338
|
+
"x-amz-credential": (None, str(post_fields["x-amz-credential"])),
|
|
339
|
+
"x-amz-algorithm": (None, str(post_fields["x-amz-algorithm"])),
|
|
340
|
+
"x-amz-date": (None, str(post_fields["x-amz-date"])),
|
|
341
|
+
"x-amz-signature": (None, str(post_fields["x-amz-signature"])),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Determine content type
|
|
345
|
+
content_type = CONTENT_TYPE_MAP.get(path.suffix.lower(), "application/octet-stream")
|
|
346
|
+
|
|
347
|
+
# Add file last (S3 requirement)
|
|
348
|
+
files_data["file"] = (path.name, file_content, content_type)
|
|
349
|
+
|
|
350
|
+
async with httpx.AsyncClient() as http_client:
|
|
351
|
+
s3_response = await http_client.post(s3_url, files=files_data)
|
|
352
|
+
|
|
353
|
+
if not s3_response.is_success:
|
|
354
|
+
raise NoteAPIError(
|
|
355
|
+
code=ErrorCode.API_ERROR,
|
|
356
|
+
message=f"Failed to upload image to S3: {s3_response.status_code}",
|
|
357
|
+
details={"status": s3_response.status_code, "response": s3_response.text},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# key is validated above in required_s3_fields check
|
|
361
|
+
return Image(
|
|
362
|
+
key=str(post_fields["key"]),
|
|
363
|
+
url=image_url,
|
|
364
|
+
original_path=file_path,
|
|
365
|
+
size_bytes=file_size,
|
|
366
|
+
uploaded_at=int(time.time()),
|
|
367
|
+
image_type=ImageType.BODY,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def insert_image_via_api(
|
|
372
|
+
session: Session,
|
|
373
|
+
article_id: str,
|
|
374
|
+
file_path: str,
|
|
375
|
+
caption: str | None = None,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
"""Insert an image into an article via API.
|
|
378
|
+
|
|
379
|
+
Fully API-based implementation without Playwright dependency.
|
|
380
|
+
This is faster and more reliable than browser-based insertion.
|
|
381
|
+
|
|
382
|
+
Flow:
|
|
383
|
+
1. Validate image file
|
|
384
|
+
2. Get article with raw HTML body
|
|
385
|
+
3. Upload image to S3 via API
|
|
386
|
+
4. Generate figure HTML
|
|
387
|
+
5. Append to existing body
|
|
388
|
+
6. Update article via draft_save API
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
session: Authenticated session
|
|
392
|
+
article_id: Article key (e.g., "n1234567890ab").
|
|
393
|
+
Note: Key format is required due to note.com API limitations.
|
|
394
|
+
The /v3/notes/ endpoint does not support numeric IDs.
|
|
395
|
+
Use the article key returned from create_draft() or list_articles().
|
|
396
|
+
file_path: Path to the image file to insert
|
|
397
|
+
caption: Optional caption for the image
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Dictionary with the following keys:
|
|
401
|
+
- success: Always True on success (raises on failure)
|
|
402
|
+
- article_id: Numeric article ID
|
|
403
|
+
- article_key: Article key (e.g., "n1234567890ab")
|
|
404
|
+
- file_path: Path to the uploaded file
|
|
405
|
+
- image_url: URL of the uploaded image on note.com CDN
|
|
406
|
+
- caption: Caption text (if provided)
|
|
407
|
+
- fallback_used: Always False (no browser fallback in API-only mode)
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
NoteAPIError: If image insertion fails
|
|
411
|
+
"""
|
|
412
|
+
# Import here to avoid circular imports
|
|
413
|
+
from note_mcp.api.articles import (
|
|
414
|
+
append_image_to_body,
|
|
415
|
+
generate_image_html,
|
|
416
|
+
get_article_raw_html,
|
|
417
|
+
update_article_raw_html,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Step 1: Validate file (existence, extension, and size)
|
|
421
|
+
validate_image_file(file_path)
|
|
422
|
+
|
|
423
|
+
# Step 2: Validate article_id format
|
|
424
|
+
# Issue #147: /v3/notes/ endpoint does not support numeric IDs
|
|
425
|
+
if article_id.isdigit():
|
|
426
|
+
raise NoteAPIError(
|
|
427
|
+
code=ErrorCode.INVALID_INPUT,
|
|
428
|
+
message=(
|
|
429
|
+
f"Numeric article ID '{article_id}' is not supported. "
|
|
430
|
+
"Please use the article key format (e.g., 'n1234567890ab'). "
|
|
431
|
+
"You can get the article key from create_draft() or list_articles()."
|
|
432
|
+
),
|
|
433
|
+
details={"article_id": article_id},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Step 3: Get article with raw HTML body
|
|
437
|
+
try:
|
|
438
|
+
article = await get_article_raw_html(session, article_id)
|
|
439
|
+
except NoteAPIError as e:
|
|
440
|
+
raise NoteAPIError(
|
|
441
|
+
code=ErrorCode.INVALID_INPUT,
|
|
442
|
+
message=f"Invalid article ID: {article_id}. Please verify the article exists and you have access.",
|
|
443
|
+
details={"article_id": article_id, "original_error": str(e)},
|
|
444
|
+
) from e
|
|
445
|
+
|
|
446
|
+
article_key = article.key
|
|
447
|
+
numeric_id = article.id
|
|
448
|
+
logger.debug(f"Article validated: key={article_key}, numeric_id={numeric_id}")
|
|
449
|
+
|
|
450
|
+
# Step 3: Upload image via API
|
|
451
|
+
image = await upload_body_image(session, file_path, numeric_id)
|
|
452
|
+
logger.info(f"Image uploaded via API: {image.url[:50]}...")
|
|
453
|
+
|
|
454
|
+
# Step 4: Generate image HTML in note.com format
|
|
455
|
+
image_html = generate_image_html(
|
|
456
|
+
image_url=image.url,
|
|
457
|
+
caption=caption or "",
|
|
458
|
+
)
|
|
459
|
+
logger.debug(f"Generated image HTML: {image_html[:100]}...")
|
|
460
|
+
|
|
461
|
+
# Step 5: Append image to existing body
|
|
462
|
+
new_body_html = append_image_to_body(article.body or "", image_html)
|
|
463
|
+
logger.debug(f"New body length: {len(new_body_html)} chars")
|
|
464
|
+
|
|
465
|
+
# Step 6: Update article via API (draft_save)
|
|
466
|
+
await update_article_raw_html(
|
|
467
|
+
session=session,
|
|
468
|
+
article_id=numeric_id,
|
|
469
|
+
title=article.title,
|
|
470
|
+
html_body=new_body_html,
|
|
471
|
+
)
|
|
472
|
+
logger.info("Article updated via API")
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
"success": True,
|
|
476
|
+
"article_id": numeric_id,
|
|
477
|
+
"article_key": article_key,
|
|
478
|
+
"file_path": file_path,
|
|
479
|
+
"image_url": image.url,
|
|
480
|
+
"caption": caption,
|
|
481
|
+
"fallback_used": False, # No fallback in API-only mode
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# =============================================================================
|
|
486
|
+
# Base64 Image Upload
|
|
487
|
+
# =============================================================================
|
|
488
|
+
|
|
489
|
+
_DATA_URL_PREFIX_RE = re.compile(r"^data:.*?;base64,")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _strip_data_url_prefix(image_base64: str) -> str:
|
|
493
|
+
"""Strip data URL prefix from a base64 string if present.
|
|
494
|
+
|
|
495
|
+
Handles inputs like:
|
|
496
|
+
data:image/png;base64,iVBORw0KGgo...
|
|
497
|
+
iVBORw0KGgo... (plain base64)
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
image_base64: Raw or data-URL-prefixed base64 string
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
Clean base64 string without the prefix
|
|
504
|
+
"""
|
|
505
|
+
match = _DATA_URL_PREFIX_RE.match(image_base64)
|
|
506
|
+
if match:
|
|
507
|
+
return image_base64[match.end() :]
|
|
508
|
+
return image_base64
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _decode_base64_image(image_base64: str) -> bytes:
|
|
512
|
+
"""Decode a base64 string to image bytes.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
image_base64: Base64-encoded image data (with or without data URL prefix)
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Decoded image bytes
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
NoteAPIError: If base64 decoding fails
|
|
522
|
+
"""
|
|
523
|
+
clean = _strip_data_url_prefix(image_base64)
|
|
524
|
+
|
|
525
|
+
if not clean.strip():
|
|
526
|
+
raise NoteAPIError(
|
|
527
|
+
code=ErrorCode.INVALID_BASE64,
|
|
528
|
+
message="image_base64 が空です。",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
return base64.b64decode(clean, validate=True)
|
|
533
|
+
except (binascii.Error, ValueError) as e:
|
|
534
|
+
raise NoteAPIError(
|
|
535
|
+
code=ErrorCode.INVALID_BASE64,
|
|
536
|
+
message="image_base64 のデコードに失敗しました。",
|
|
537
|
+
details={"error": str(e)},
|
|
538
|
+
) from e
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _validate_mime_type(mime_type: str) -> None:
|
|
542
|
+
"""Validate that the MIME type is supported.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
mime_type: MIME type string (e.g., "image/png")
|
|
546
|
+
|
|
547
|
+
Raises:
|
|
548
|
+
NoteAPIError: If the MIME type is not supported
|
|
549
|
+
"""
|
|
550
|
+
if mime_type not in SUPPORTED_MIME_TYPES:
|
|
551
|
+
raise NoteAPIError(
|
|
552
|
+
code=ErrorCode.UNSUPPORTED_MIME_TYPE,
|
|
553
|
+
message=(f"未対応のMIME typeです: {mime_type}。対応形式: {', '.join(sorted(SUPPORTED_MIME_TYPES))}"),
|
|
554
|
+
details={"mime_type": mime_type},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _validate_image_bytes(data: bytes, mime_type: str) -> None:
|
|
559
|
+
"""Validate that decoded bytes represent a valid image.
|
|
560
|
+
|
|
561
|
+
Checks:
|
|
562
|
+
- Data is not empty
|
|
563
|
+
- Magic bytes match the declared MIME type
|
|
564
|
+
- Size is within limits
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
data: Raw image bytes
|
|
568
|
+
mime_type: Declared MIME type
|
|
569
|
+
|
|
570
|
+
Raises:
|
|
571
|
+
NoteAPIError: If validation fails
|
|
572
|
+
"""
|
|
573
|
+
if not data:
|
|
574
|
+
raise NoteAPIError(
|
|
575
|
+
code=ErrorCode.INVALID_IMAGE,
|
|
576
|
+
message="デコードされた画像データが空です。",
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Size check
|
|
580
|
+
if len(data) > MAX_FILE_SIZE:
|
|
581
|
+
raise NoteAPIError(
|
|
582
|
+
code=ErrorCode.IMAGE_TOO_LARGE,
|
|
583
|
+
message=(f"画像サイズ ({len(data)} bytes) が上限 ({MAX_FILE_SIZE} bytes) を超えています。"),
|
|
584
|
+
details={"size": len(data), "max_size": MAX_FILE_SIZE},
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Magic byte check
|
|
588
|
+
magic_info = _MAGIC_BYTES.get(mime_type)
|
|
589
|
+
if magic_info is not None:
|
|
590
|
+
signature, offset = magic_info
|
|
591
|
+
if len(data) < offset + len(signature):
|
|
592
|
+
raise NoteAPIError(
|
|
593
|
+
code=ErrorCode.INVALID_IMAGE,
|
|
594
|
+
message="画像データが短すぎて形式を判別できません。",
|
|
595
|
+
details={"mime_type": mime_type, "size": len(data)},
|
|
596
|
+
)
|
|
597
|
+
if data[offset : offset + len(signature)] != signature:
|
|
598
|
+
raise NoteAPIError(
|
|
599
|
+
code=ErrorCode.INVALID_IMAGE,
|
|
600
|
+
message=f"宣言されたMIME type ({mime_type}) と実画像形式が一致しません。",
|
|
601
|
+
details={"mime_type": mime_type},
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def upload_eyecatch_base64(
|
|
606
|
+
session: Session,
|
|
607
|
+
note_id: str,
|
|
608
|
+
mime_type: str,
|
|
609
|
+
image_base64: str,
|
|
610
|
+
) -> Image:
|
|
611
|
+
"""Upload an eyecatch image from base64-encoded data.
|
|
612
|
+
|
|
613
|
+
Decodes base64 image data, validates it, writes to a temporary file,
|
|
614
|
+
and uploads via the standard image upload flow. The temporary file is
|
|
615
|
+
cleaned up after upload (success or failure).
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
session: Authenticated session
|
|
619
|
+
note_id: The note ID to associate the image with (numeric or key format)
|
|
620
|
+
mime_type: MIME type of the image (e.g., "image/png")
|
|
621
|
+
image_base64: Base64-encoded image data (with or without data URL prefix)
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Image object with upload result
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
NoteAPIError: If validation fails or API request fails
|
|
628
|
+
"""
|
|
629
|
+
# Step 1: Validate MIME type
|
|
630
|
+
_validate_mime_type(mime_type)
|
|
631
|
+
|
|
632
|
+
# Step 2: Decode base64
|
|
633
|
+
image_bytes = _decode_base64_image(image_base64)
|
|
634
|
+
|
|
635
|
+
# Step 3: Validate image bytes
|
|
636
|
+
_validate_image_bytes(image_bytes, mime_type)
|
|
637
|
+
|
|
638
|
+
# Step 4: Determine file extension
|
|
639
|
+
extension = MIME_TO_EXTENSION.get(mime_type, ".bin")
|
|
640
|
+
|
|
641
|
+
# Step 5: Write to temp file and upload
|
|
642
|
+
tmp_path: str | None = None
|
|
643
|
+
try:
|
|
644
|
+
fd, tmp_path = tempfile.mkstemp(suffix=extension)
|
|
645
|
+
os.close(fd)
|
|
646
|
+
|
|
647
|
+
with open(tmp_path, "wb") as f:
|
|
648
|
+
f.write(image_bytes)
|
|
649
|
+
|
|
650
|
+
image = await _upload_image_internal(
|
|
651
|
+
session=session,
|
|
652
|
+
file_path=tmp_path,
|
|
653
|
+
note_id=note_id,
|
|
654
|
+
image_type=ImageType.EYECATCH,
|
|
655
|
+
)
|
|
656
|
+
return image
|
|
657
|
+
finally:
|
|
658
|
+
if tmp_path is not None:
|
|
659
|
+
with contextlib.suppress(OSError):
|
|
660
|
+
os.unlink(tmp_path)
|