note-connector 0.2.6 → 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/package.json +1 -1
- package/py/pyproject.toml +1 -1
- package/py/src/note_mcp/api/images.py +205 -1
- package/py/src/note_mcp/models.py +5 -0
- package/py/src/note_mcp/server.py +40 -1
package/package.json
CHANGED
package/py/pyproject.toml
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"""Image upload operations for note.com API.
|
|
2
2
|
|
|
3
3
|
Provides functionality for uploading images to note.com.
|
|
4
|
-
Supports both eyecatch (header) images and body (inline) images
|
|
4
|
+
Supports both eyecatch (header) images and body (inline) images,
|
|
5
|
+
as well as base64-encoded image uploads.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
from __future__ import annotations
|
|
8
9
|
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import contextlib
|
|
9
13
|
import logging
|
|
14
|
+
import os
|
|
10
15
|
import re
|
|
16
|
+
import tempfile
|
|
11
17
|
import time
|
|
12
18
|
from pathlib import Path
|
|
13
19
|
from typing import TYPE_CHECKING, Any
|
|
@@ -90,6 +96,26 @@ IMAGE_UPLOAD_ENDPOINTS: dict[ImageType, str] = {
|
|
|
90
96
|
ImageType.BODY: "/v1/image_upload/note_eyecatch", # Same endpoint - URL works for body embedding
|
|
91
97
|
}
|
|
92
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
|
+
|
|
93
119
|
|
|
94
120
|
def validate_image_file(file_path: str) -> None:
|
|
95
121
|
"""Validate image file before upload.
|
|
@@ -454,3 +480,181 @@ async def insert_image_via_api(
|
|
|
454
480
|
"caption": caption,
|
|
455
481
|
"fallback_used": False, # No fallback in API-only mode
|
|
456
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)
|
|
@@ -211,6 +211,11 @@ class ErrorCode(str, Enum):
|
|
|
211
211
|
API_ERROR = "api_error"
|
|
212
212
|
UPLOAD_FAILED = "upload_failed"
|
|
213
213
|
INVALID_INPUT = "invalid_input"
|
|
214
|
+
INVALID_BASE64 = "invalid_base64"
|
|
215
|
+
UNSUPPORTED_MIME_TYPE = "unsupported_mime_type"
|
|
216
|
+
INVALID_IMAGE = "invalid_image"
|
|
217
|
+
IMAGE_TOO_LARGE = "image_too_large"
|
|
218
|
+
EYECATCH_SET_FAILED = "eyecatch_set_failed"
|
|
214
219
|
|
|
215
220
|
|
|
216
221
|
class NoteAPIError(Exception):
|
|
@@ -22,7 +22,12 @@ from note_mcp.api.articles import (
|
|
|
22
22
|
unpublish_article,
|
|
23
23
|
update_article,
|
|
24
24
|
)
|
|
25
|
-
from note_mcp.api.images import
|
|
25
|
+
from note_mcp.api.images import (
|
|
26
|
+
insert_image_via_api,
|
|
27
|
+
upload_body_image,
|
|
28
|
+
upload_eyecatch_base64,
|
|
29
|
+
upload_eyecatch_image,
|
|
30
|
+
)
|
|
26
31
|
from note_mcp.api.preview import get_preview_html
|
|
27
32
|
from note_mcp.auth.browser import login_with_browser
|
|
28
33
|
from note_mcp.auth.session import SessionManager
|
|
@@ -283,6 +288,40 @@ async def note_upload_eyecatch(
|
|
|
283
288
|
return f"アイキャッチ画像をアップロードしました。URL: {image.url}"
|
|
284
289
|
|
|
285
290
|
|
|
291
|
+
@mcp.tool()
|
|
292
|
+
@require_session
|
|
293
|
+
@handle_api_error
|
|
294
|
+
async def note_set_eyecatch_base64(
|
|
295
|
+
session: Session,
|
|
296
|
+
note_id: Annotated[str, "アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)"],
|
|
297
|
+
mime_type: Annotated[str, "画像のMIMEタイプ(image/png, image/jpeg, image/webp など)"],
|
|
298
|
+
image_base64: Annotated[str, "base64エンコードされた画像データ(data:image/...;base64, 形式も可)"],
|
|
299
|
+
) -> str:
|
|
300
|
+
"""base64画像データから記事のアイキャッチ画像を設定します。
|
|
301
|
+
|
|
302
|
+
ChatGPTなどで生成した画像をbase64形式で直接渡すことで、
|
|
303
|
+
ファイルパスを必要とせずにnote記事のサムネイル/見出し画像を設定できます。
|
|
304
|
+
|
|
305
|
+
対応形式: PNG, JPEG, WebP, GIF
|
|
306
|
+
最大サイズ: 10MB
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
note_id: アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)
|
|
310
|
+
mime_type: 画像のMIMEタイプ
|
|
311
|
+
image_base64: base64エンコードされた画像データ
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
設定結果(画像URLを含む)
|
|
315
|
+
"""
|
|
316
|
+
image = await upload_eyecatch_base64(
|
|
317
|
+
session=session,
|
|
318
|
+
note_id=note_id,
|
|
319
|
+
mime_type=mime_type,
|
|
320
|
+
image_base64=image_base64,
|
|
321
|
+
)
|
|
322
|
+
return f"アイキャッチ画像を設定しました。URL: {image.url}"
|
|
323
|
+
|
|
324
|
+
|
|
286
325
|
@mcp.tool()
|
|
287
326
|
@require_session
|
|
288
327
|
@handle_api_error
|