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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "note-connector",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/py/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "note-connector"
3
- version = "0.2.6"
3
+ version = "0.2.8"
4
4
  description = "note-connector: MCP server and ChatGPT connector for note.com"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -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
- # Parse response - Article 6: validate required fields, no fallback
185
- image_data = response.get("data", {})
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
- # 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.
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
- image_url = image_data.get("url")
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
- raise NoteAPIError(
195
- code=ErrorCode.API_ERROR,
196
- message="Image upload failed: API response missing required field 'url'",
197
- details={"response": response},
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 insert_image_via_api, upload_body_image, upload_eyecatch_image
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
- return f"アイキャッチ画像をアップロードしました。URL: {image.url}"
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()