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.
Files changed (46) hide show
  1. package/dist/paths.js +4 -0
  2. package/dist/setup-dependencies.js +56 -13
  3. package/package.json +3 -2
  4. package/py/pyproject.toml +86 -0
  5. package/py/src/note_mcp/__init__.py +7 -0
  6. package/py/src/note_mcp/__main__.py +65 -0
  7. package/py/src/note_mcp/api/__init__.py +31 -0
  8. package/py/src/note_mcp/api/articles.py +1395 -0
  9. package/py/src/note_mcp/api/client.py +318 -0
  10. package/py/src/note_mcp/api/embeds.py +482 -0
  11. package/py/src/note_mcp/api/images.py +660 -0
  12. package/py/src/note_mcp/api/preview.py +142 -0
  13. package/py/src/note_mcp/api/public_notes.py +150 -0
  14. package/py/src/note_mcp/auth/__init__.py +9 -0
  15. package/py/src/note_mcp/auth/browser.py +574 -0
  16. package/py/src/note_mcp/auth/file_session.py +145 -0
  17. package/py/src/note_mcp/auth/session.py +240 -0
  18. package/py/src/note_mcp/browser/__init__.py +10 -0
  19. package/py/src/note_mcp/browser/config.py +21 -0
  20. package/py/src/note_mcp/browser/manager.py +182 -0
  21. package/py/src/note_mcp/browser/preview.py +68 -0
  22. package/py/src/note_mcp/browser/url_helpers.py +18 -0
  23. package/py/src/note_mcp/chatgpt/__init__.py +1 -0
  24. package/py/src/note_mcp/chatgpt/__main__.py +63 -0
  25. package/py/src/note_mcp/chatgpt/access_log.py +25 -0
  26. package/py/src/note_mcp/chatgpt/auth.py +52 -0
  27. package/py/src/note_mcp/chatgpt/images.py +92 -0
  28. package/py/src/note_mcp/chatgpt/login_once.py +26 -0
  29. package/py/src/note_mcp/chatgpt/middleware.py +31 -0
  30. package/py/src/note_mcp/chatgpt/tools.py +255 -0
  31. package/py/src/note_mcp/chatgpt/widgets.py +121 -0
  32. package/py/src/note_mcp/decorators.py +113 -0
  33. package/py/src/note_mcp/investigator/__init__.py +33 -0
  34. package/py/src/note_mcp/investigator/__main__.py +11 -0
  35. package/py/src/note_mcp/investigator/cli.py +313 -0
  36. package/py/src/note_mcp/investigator/core.py +653 -0
  37. package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
  38. package/py/src/note_mcp/models.py +562 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +944 -0
  41. package/py/src/note_mcp/utils/__init__.py +7 -0
  42. package/py/src/note_mcp/utils/file_parser.py +314 -0
  43. package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
  44. package/py/src/note_mcp/utils/logging.py +119 -0
  45. package/py/src/note_mcp/utils/markdown.py +12 -0
  46. 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
+ ![alt text](returned_url)
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)