note-connector 0.2.6 → 0.2.8
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 +264 -12
- package/py/src/note_mcp/models.py +5 -0
- package/py/src/note_mcp/server.py +45 -2
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,63 @@ 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
|
+
|
|
119
|
+
|
|
120
|
+
def _extract_image_url_from_response(response: dict[str, Any]) -> str | None:
|
|
121
|
+
"""Extract image URL from API response, trying multiple possible field names.
|
|
122
|
+
|
|
123
|
+
note.com API may return the URL under different field names.
|
|
124
|
+
This function tries known field names in order of preference.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
response: Full API response dictionary
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Image URL string if found, None otherwise
|
|
131
|
+
"""
|
|
132
|
+
data = response.get("data", {})
|
|
133
|
+
|
|
134
|
+
# Try known field names for image URL
|
|
135
|
+
candidate_keys = [
|
|
136
|
+
"url",
|
|
137
|
+
"image_url",
|
|
138
|
+
"src",
|
|
139
|
+
"download_url",
|
|
140
|
+
"note_image_url",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
for key in candidate_keys:
|
|
144
|
+
value = data.get(key)
|
|
145
|
+
if value and isinstance(value, str) and value.strip():
|
|
146
|
+
return str(value)
|
|
147
|
+
|
|
148
|
+
# Check if the top-level response itself is a string URL
|
|
149
|
+
for key in candidate_keys:
|
|
150
|
+
value = response.get(key)
|
|
151
|
+
if value and isinstance(value, str) and value.strip():
|
|
152
|
+
return str(value)
|
|
153
|
+
|
|
154
|
+
return None
|
|
155
|
+
|
|
93
156
|
|
|
94
157
|
def validate_image_file(file_path: str) -> None:
|
|
95
158
|
"""Validate image file before upload.
|
|
@@ -181,25 +244,36 @@ async def _upload_image_internal(
|
|
|
181
244
|
async with NoteAPIClient(session) as client:
|
|
182
245
|
response = await client.post(endpoint, files=files, data=data)
|
|
183
246
|
|
|
184
|
-
#
|
|
185
|
-
|
|
247
|
+
# Debug: log full API response for investigation
|
|
248
|
+
logger.debug(
|
|
249
|
+
"Image upload response for note_id=%s, endpoint=%s: %s",
|
|
250
|
+
numeric_note_id,
|
|
251
|
+
endpoint,
|
|
252
|
+
{k: v for k, v in response.items() if k != "data"},
|
|
253
|
+
)
|
|
254
|
+
if "data" in response:
|
|
255
|
+
logger.debug("Image upload response data keys: %s", list(response["data"].keys()))
|
|
256
|
+
logger.debug("Image upload response data: %s", response["data"])
|
|
186
257
|
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
258
|
+
# Extract image URL from response (tries multiple field names)
|
|
259
|
+
image_url = _extract_image_url_from_response(response)
|
|
260
|
+
|
|
261
|
+
image_data = response.get("data", {})
|
|
190
262
|
image_key = image_data.get("key")
|
|
191
263
|
|
|
192
|
-
|
|
264
|
+
# URL is optional - eyecatch API may not always return it
|
|
265
|
+
# The eyecatch is set server-side even without a URL in the response
|
|
193
266
|
if not image_url:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
267
|
+
logger.warning(
|
|
268
|
+
"Image upload response missing image URL for note_id=%s. Response keys: top=%s, data=%s",
|
|
269
|
+
numeric_note_id,
|
|
270
|
+
list(response.keys()),
|
|
271
|
+
list(image_data.keys()) if image_data else "none",
|
|
198
272
|
)
|
|
199
273
|
|
|
200
274
|
return Image(
|
|
201
275
|
key=str(image_key) if image_key else None,
|
|
202
|
-
url=str(image_url),
|
|
276
|
+
url=str(image_url) if image_url else "",
|
|
203
277
|
original_path=file_path,
|
|
204
278
|
size_bytes=file_size,
|
|
205
279
|
uploaded_at=int(time.time()),
|
|
@@ -454,3 +528,181 @@ async def insert_image_via_api(
|
|
|
454
528
|
"caption": caption,
|
|
455
529
|
"fallback_used": False, # No fallback in API-only mode
|
|
456
530
|
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# =============================================================================
|
|
534
|
+
# Base64 Image Upload
|
|
535
|
+
# =============================================================================
|
|
536
|
+
|
|
537
|
+
_DATA_URL_PREFIX_RE = re.compile(r"^data:.*?;base64,")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _strip_data_url_prefix(image_base64: str) -> str:
|
|
541
|
+
"""Strip data URL prefix from a base64 string if present.
|
|
542
|
+
|
|
543
|
+
Handles inputs like:
|
|
544
|
+
data:image/png;base64,iVBORw0KGgo...
|
|
545
|
+
iVBORw0KGgo... (plain base64)
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
image_base64: Raw or data-URL-prefixed base64 string
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Clean base64 string without the prefix
|
|
552
|
+
"""
|
|
553
|
+
match = _DATA_URL_PREFIX_RE.match(image_base64)
|
|
554
|
+
if match:
|
|
555
|
+
return image_base64[match.end() :]
|
|
556
|
+
return image_base64
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _decode_base64_image(image_base64: str) -> bytes:
|
|
560
|
+
"""Decode a base64 string to image bytes.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
image_base64: Base64-encoded image data (with or without data URL prefix)
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Decoded image bytes
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
NoteAPIError: If base64 decoding fails
|
|
570
|
+
"""
|
|
571
|
+
clean = _strip_data_url_prefix(image_base64)
|
|
572
|
+
|
|
573
|
+
if not clean.strip():
|
|
574
|
+
raise NoteAPIError(
|
|
575
|
+
code=ErrorCode.INVALID_BASE64,
|
|
576
|
+
message="image_base64 が空です。",
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
return base64.b64decode(clean, validate=True)
|
|
581
|
+
except (binascii.Error, ValueError) as e:
|
|
582
|
+
raise NoteAPIError(
|
|
583
|
+
code=ErrorCode.INVALID_BASE64,
|
|
584
|
+
message="image_base64 のデコードに失敗しました。",
|
|
585
|
+
details={"error": str(e)},
|
|
586
|
+
) from e
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _validate_mime_type(mime_type: str) -> None:
|
|
590
|
+
"""Validate that the MIME type is supported.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
mime_type: MIME type string (e.g., "image/png")
|
|
594
|
+
|
|
595
|
+
Raises:
|
|
596
|
+
NoteAPIError: If the MIME type is not supported
|
|
597
|
+
"""
|
|
598
|
+
if mime_type not in SUPPORTED_MIME_TYPES:
|
|
599
|
+
raise NoteAPIError(
|
|
600
|
+
code=ErrorCode.UNSUPPORTED_MIME_TYPE,
|
|
601
|
+
message=(f"未対応のMIME typeです: {mime_type}。対応形式: {', '.join(sorted(SUPPORTED_MIME_TYPES))}"),
|
|
602
|
+
details={"mime_type": mime_type},
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _validate_image_bytes(data: bytes, mime_type: str) -> None:
|
|
607
|
+
"""Validate that decoded bytes represent a valid image.
|
|
608
|
+
|
|
609
|
+
Checks:
|
|
610
|
+
- Data is not empty
|
|
611
|
+
- Magic bytes match the declared MIME type
|
|
612
|
+
- Size is within limits
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
data: Raw image bytes
|
|
616
|
+
mime_type: Declared MIME type
|
|
617
|
+
|
|
618
|
+
Raises:
|
|
619
|
+
NoteAPIError: If validation fails
|
|
620
|
+
"""
|
|
621
|
+
if not data:
|
|
622
|
+
raise NoteAPIError(
|
|
623
|
+
code=ErrorCode.INVALID_IMAGE,
|
|
624
|
+
message="デコードされた画像データが空です。",
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Size check
|
|
628
|
+
if len(data) > MAX_FILE_SIZE:
|
|
629
|
+
raise NoteAPIError(
|
|
630
|
+
code=ErrorCode.IMAGE_TOO_LARGE,
|
|
631
|
+
message=(f"画像サイズ ({len(data)} bytes) が上限 ({MAX_FILE_SIZE} bytes) を超えています。"),
|
|
632
|
+
details={"size": len(data), "max_size": MAX_FILE_SIZE},
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# Magic byte check
|
|
636
|
+
magic_info = _MAGIC_BYTES.get(mime_type)
|
|
637
|
+
if magic_info is not None:
|
|
638
|
+
signature, offset = magic_info
|
|
639
|
+
if len(data) < offset + len(signature):
|
|
640
|
+
raise NoteAPIError(
|
|
641
|
+
code=ErrorCode.INVALID_IMAGE,
|
|
642
|
+
message="画像データが短すぎて形式を判別できません。",
|
|
643
|
+
details={"mime_type": mime_type, "size": len(data)},
|
|
644
|
+
)
|
|
645
|
+
if data[offset : offset + len(signature)] != signature:
|
|
646
|
+
raise NoteAPIError(
|
|
647
|
+
code=ErrorCode.INVALID_IMAGE,
|
|
648
|
+
message=f"宣言されたMIME type ({mime_type}) と実画像形式が一致しません。",
|
|
649
|
+
details={"mime_type": mime_type},
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
async def upload_eyecatch_base64(
|
|
654
|
+
session: Session,
|
|
655
|
+
note_id: str,
|
|
656
|
+
mime_type: str,
|
|
657
|
+
image_base64: str,
|
|
658
|
+
) -> Image:
|
|
659
|
+
"""Upload an eyecatch image from base64-encoded data.
|
|
660
|
+
|
|
661
|
+
Decodes base64 image data, validates it, writes to a temporary file,
|
|
662
|
+
and uploads via the standard image upload flow. The temporary file is
|
|
663
|
+
cleaned up after upload (success or failure).
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
session: Authenticated session
|
|
667
|
+
note_id: The note ID to associate the image with (numeric or key format)
|
|
668
|
+
mime_type: MIME type of the image (e.g., "image/png")
|
|
669
|
+
image_base64: Base64-encoded image data (with or without data URL prefix)
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Image object with upload result
|
|
673
|
+
|
|
674
|
+
Raises:
|
|
675
|
+
NoteAPIError: If validation fails or API request fails
|
|
676
|
+
"""
|
|
677
|
+
# Step 1: Validate MIME type
|
|
678
|
+
_validate_mime_type(mime_type)
|
|
679
|
+
|
|
680
|
+
# Step 2: Decode base64
|
|
681
|
+
image_bytes = _decode_base64_image(image_base64)
|
|
682
|
+
|
|
683
|
+
# Step 3: Validate image bytes
|
|
684
|
+
_validate_image_bytes(image_bytes, mime_type)
|
|
685
|
+
|
|
686
|
+
# Step 4: Determine file extension
|
|
687
|
+
extension = MIME_TO_EXTENSION.get(mime_type, ".bin")
|
|
688
|
+
|
|
689
|
+
# Step 5: Write to temp file and upload
|
|
690
|
+
tmp_path: str | None = None
|
|
691
|
+
try:
|
|
692
|
+
fd, tmp_path = tempfile.mkstemp(suffix=extension)
|
|
693
|
+
os.close(fd)
|
|
694
|
+
|
|
695
|
+
with open(tmp_path, "wb") as f:
|
|
696
|
+
f.write(image_bytes)
|
|
697
|
+
|
|
698
|
+
image = await _upload_image_internal(
|
|
699
|
+
session=session,
|
|
700
|
+
file_path=tmp_path,
|
|
701
|
+
note_id=note_id,
|
|
702
|
+
image_type=ImageType.EYECATCH,
|
|
703
|
+
)
|
|
704
|
+
return image
|
|
705
|
+
finally:
|
|
706
|
+
if tmp_path is not None:
|
|
707
|
+
with contextlib.suppress(OSError):
|
|
708
|
+
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
|
|
@@ -280,7 +285,45 @@ async def note_upload_eyecatch(
|
|
|
280
285
|
アップロード結果(画像URLを含む)
|
|
281
286
|
"""
|
|
282
287
|
image = await upload_eyecatch_image(session, file_path, note_id=note_id)
|
|
283
|
-
|
|
288
|
+
if image.url:
|
|
289
|
+
return f"アイキャッチ画像をアップロードしました。URL: {image.url}"
|
|
290
|
+
return "アイキャッチ画像をアップロードしました。"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@mcp.tool()
|
|
294
|
+
@require_session
|
|
295
|
+
@handle_api_error
|
|
296
|
+
async def note_set_eyecatch_base64(
|
|
297
|
+
session: Session,
|
|
298
|
+
note_id: Annotated[str, "アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)"],
|
|
299
|
+
mime_type: Annotated[str, "画像のMIMEタイプ(image/png, image/jpeg, image/webp など)"],
|
|
300
|
+
image_base64: Annotated[str, "base64エンコードされた画像データ(data:image/...;base64, 形式も可)"],
|
|
301
|
+
) -> str:
|
|
302
|
+
"""base64画像データから記事のアイキャッチ画像を設定します。
|
|
303
|
+
|
|
304
|
+
ChatGPTなどで生成した画像をbase64形式で直接渡すことで、
|
|
305
|
+
ファイルパスを必要とせずにnote記事のサムネイル/見出し画像を設定できます。
|
|
306
|
+
|
|
307
|
+
対応形式: PNG, JPEG, WebP, GIF
|
|
308
|
+
最大サイズ: 10MB
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
note_id: アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)
|
|
312
|
+
mime_type: 画像のMIMEタイプ
|
|
313
|
+
image_base64: base64エンコードされた画像データ
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
設定結果(画像URLを含む)
|
|
317
|
+
"""
|
|
318
|
+
image = await upload_eyecatch_base64(
|
|
319
|
+
session=session,
|
|
320
|
+
note_id=note_id,
|
|
321
|
+
mime_type=mime_type,
|
|
322
|
+
image_base64=image_base64,
|
|
323
|
+
)
|
|
324
|
+
if image.url:
|
|
325
|
+
return f"アイキャッチ画像を設定しました。URL: {image.url}"
|
|
326
|
+
return "アイキャッチ画像を設定しました。"
|
|
284
327
|
|
|
285
328
|
|
|
286
329
|
@mcp.tool()
|