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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "note-connector",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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.7"
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,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 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
@@ -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