note-connector 0.2.11 → 0.2.13

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.11",
3
+ "version": "0.2.13",
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.11"
3
+ version = "0.2.13"
4
4
  description = "note-connector: MCP server and ChatGPT connector for note.com"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -677,59 +677,166 @@ def _validate_image_bytes(data: bytes, mime_type: str) -> None:
677
677
  )
678
678
 
679
679
 
680
- async def upload_eyecatch_base64(
680
+ # =============================================================================
681
+ # Chunked Base64 Image Upload
682
+ # =============================================================================
683
+
684
+ # Max chunk size for safe MCP transport (64KB base64)
685
+ MAX_CHUNK_SIZE: int = 64 * 1024
686
+
687
+ # In-memory chunk buffer: upload_id -> chunk state
688
+ # Each entry: {"chunks": {index: bytes}, "total_chunks": int, "mime_type": str, "note_id": str}
689
+ _chunk_buffer: dict[str, dict[str, Any]] = {}
690
+
691
+
692
+ def _reset_chunk_state(upload_id: str) -> None:
693
+ """Clear chunk buffer state for an upload_id."""
694
+ _chunk_buffer.pop(upload_id, None)
695
+
696
+
697
+ def _get_chunk_state(upload_id: str) -> dict[str, Any] | None:
698
+ """Get chunk buffer state for an upload_id."""
699
+ return _chunk_buffer.get(upload_id)
700
+
701
+
702
+ async def upload_eyecatch_chunked(
681
703
  session: Session,
704
+ upload_id: str,
682
705
  note_id: str,
683
706
  mime_type: str,
684
- image_base64: str,
685
- ) -> Image:
686
- """Upload an eyecatch image from base64-encoded data.
707
+ chunk: str,
708
+ chunk_index: int,
709
+ total_chunks: int,
710
+ ) -> Image | None:
711
+ """Upload an eyecatch image in base64 chunks.
687
712
 
688
- Decodes base64 image data, validates it, writes to a temporary file,
689
- and uploads via the standard image upload flow. The temporary file is
690
- cleaned up after upload (success or failure).
713
+ Receives base64-encoded chunks of an image, buffers them, and on the
714
+ final chunk assembles the complete image, validates it, and uploads
715
+ to note.com as an eyecatch.
716
+
717
+ This is designed for ChatGPT where large base64 strings may be
718
+ truncated during MCP tool argument transmission.
691
719
 
692
720
  Args:
693
721
  session: Authenticated session
694
- note_id: The note ID to associate the image with (numeric or key format)
695
- mime_type: MIME type of the image (e.g., "image/png")
696
- image_base64: Base64-encoded image data (with or without data URL prefix)
722
+ upload_id: Unique ID for this upload session (generated by ChatGPT)
723
+ note_id: The note ID to associate the image with
724
+ mime_type: MIME type of the image
725
+ chunk: Base64-encoded chunk data
726
+ chunk_index: 0-based index of this chunk
727
+ total_chunks: Total number of chunks expected
697
728
 
698
729
  Returns:
699
- Image object with upload result
730
+ Image object on final chunk success, None for intermediate chunks
700
731
 
701
732
  Raises:
702
733
  NoteAPIError: If validation fails or API request fails
703
734
  """
704
- # Step 1: Validate MIME type
705
- _validate_mime_type(mime_type)
735
+ # Validate parameters
736
+ if not upload_id.strip():
737
+ raise NoteAPIError(
738
+ code=ErrorCode.INVALID_INPUT,
739
+ message="upload_id が空です。",
740
+ )
706
741
 
707
- # Step 2: Decode base64
708
- image_bytes = _decode_base64_image(image_base64)
742
+ if total_chunks < 1:
743
+ raise NoteAPIError(
744
+ code=ErrorCode.INVALID_INPUT,
745
+ message=f"total_chunks は1以上である必要があります: {total_chunks}",
746
+ )
709
747
 
710
- # Step 3: Validate image bytes
711
- _validate_image_bytes(image_bytes, mime_type)
748
+ if chunk_index < 0 or chunk_index >= total_chunks:
749
+ raise NoteAPIError(
750
+ code=ErrorCode.INVALID_INPUT,
751
+ message=(f"chunk_index が範囲外です: {chunk_index} (有効範囲: 0〜{total_chunks - 1})"),
752
+ )
753
+
754
+ if not chunk.strip():
755
+ raise NoteAPIError(
756
+ code=ErrorCode.INVALID_BASE64,
757
+ message=f"chunk {chunk_index + 1}/{total_chunks} が空です。",
758
+ )
759
+
760
+ # Validate MIME type (only on first chunk to avoid repeat checks)
761
+ if chunk_index == 0:
762
+ _validate_mime_type(mime_type)
763
+
764
+ # Initialize or get chunk state
765
+ state = _get_chunk_state(upload_id)
766
+ if state is None:
767
+ state = {
768
+ "chunks": {},
769
+ "total_chunks": total_chunks,
770
+ "mime_type": mime_type,
771
+ "note_id": note_id,
772
+ }
773
+ _chunk_buffer[upload_id] = state
774
+ else:
775
+ # Validate consistency
776
+ if state["total_chunks"] != total_chunks:
777
+ raise NoteAPIError(
778
+ code=ErrorCode.INVALID_INPUT,
779
+ message=(f"total_chunks が一致しません: (前回: {state['total_chunks']}, 今回: {total_chunks})"),
780
+ )
781
+ if state["mime_type"] != mime_type:
782
+ raise NoteAPIError(
783
+ code=ErrorCode.INVALID_INPUT,
784
+ message=(f"mime_type が一致しません: (前回: {state['mime_type']}, 今回: {mime_type})"),
785
+ )
712
786
 
713
- # Step 4: Determine file extension
714
- extension = MIME_TO_EXTENSION.get(mime_type, ".bin")
787
+ # Store raw base64 chunk (decode happens after assembly to avoid
788
+ # base64 alignment issues when splitting across 3-byte boundaries)
789
+ state["chunks"][chunk_index] = chunk.strip()
715
790
 
716
- # Step 5: Write to temp file and upload
717
- tmp_path: str | None = None
791
+ received = len(state["chunks"])
792
+ logger.debug(
793
+ "Chunk %d/%d for upload_id=%s, received=%d/%d",
794
+ chunk_index + 1,
795
+ total_chunks,
796
+ upload_id,
797
+ received,
798
+ total_chunks,
799
+ )
800
+
801
+ # Return early if not all chunks received
802
+ if received < total_chunks:
803
+ return None
804
+
805
+ # All chunks received - assemble base64 and decode
718
806
  try:
719
- fd, tmp_path = tempfile.mkstemp(suffix=extension)
720
- os.close(fd)
807
+ # Concatenate base64 chunks in order
808
+ chunks_list = state["chunks"]
809
+ assembled_b64 = "".join(chunks_list[i] for i in range(total_chunks))
721
810
 
722
- with open(tmp_path, "wb") as f:
723
- f.write(image_bytes)
811
+ # Decode the complete base64 string
812
+ image_bytes = _decode_base64_image(assembled_b64)
724
813
 
725
- image = await _upload_image_internal(
726
- session=session,
727
- file_path=tmp_path,
728
- note_id=note_id,
729
- image_type=ImageType.EYECATCH,
730
- )
731
- return image
814
+ # Validate the assembled image
815
+ _validate_image_bytes(image_bytes, mime_type)
816
+
817
+ # Determine file extension
818
+ extension = MIME_TO_EXTENSION.get(mime_type, ".bin")
819
+
820
+ # Write to temp file and upload
821
+ tmp_path: str | None = None
822
+ try:
823
+ fd, tmp_path = tempfile.mkstemp(suffix=extension)
824
+ os.close(fd)
825
+
826
+ with open(tmp_path, "wb") as f:
827
+ f.write(image_bytes)
828
+
829
+ image = await _upload_image_internal(
830
+ session=session,
831
+ file_path=tmp_path,
832
+ note_id=note_id,
833
+ image_type=ImageType.EYECATCH,
834
+ )
835
+ return image
836
+ finally:
837
+ if tmp_path is not None:
838
+ with contextlib.suppress(OSError):
839
+ os.unlink(tmp_path)
732
840
  finally:
733
- if tmp_path is not None:
734
- with contextlib.suppress(OSError):
735
- os.unlink(tmp_path)
841
+ # Always clean up state
842
+ _reset_chunk_state(upload_id)
@@ -25,7 +25,7 @@ from note_mcp.api.articles import (
25
25
  from note_mcp.api.images import (
26
26
  insert_image_via_api,
27
27
  upload_body_image,
28
- upload_eyecatch_base64,
28
+ upload_eyecatch_chunked,
29
29
  upload_eyecatch_image,
30
30
  )
31
31
  from note_mcp.api.preview import get_preview_html
@@ -293,34 +293,55 @@ async def note_upload_eyecatch(
293
293
  @mcp.tool()
294
294
  @require_session
295
295
  @handle_api_error
296
- async def note_set_eyecatch_base64(
296
+ async def note_set_eyecatch_base64_chunked(
297
297
  session: Session,
298
+ upload_id: Annotated[str, "アップロードセッションを識別する一意なID(UUID推奨)"],
298
299
  note_id: Annotated[str, "アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)"],
299
300
  mime_type: Annotated[str, "画像のMIMEタイプ(image/png, image/jpeg, image/webp など)"],
300
- image_base64: Annotated[str, "base64エンコードされた画像データ(data:image/...;base64, 形式も可)"],
301
+ chunk: Annotated[str, "base64エンコードされた画像データのチャンク"],
302
+ chunk_index: Annotated[int, "このチャンクの0ベースのインデックス"],
303
+ total_chunks: Annotated[int, "全チャンク数"],
301
304
  ) -> str:
302
- """base64画像データから記事のアイキャッチ画像を設定します。
305
+ """base64画像を分割(チャンク)で送信し、アイキャッチ画像を設定します。
303
306
 
304
- ChatGPTなどで生成した画像をbase64形式で直接渡すことで、
305
- ファイルパスを必要とせずにnote記事のサムネイル/見出し画像を設定できます。
307
+ 大きなbase64画像をChatGPT経由で安全に転送するためのツールです。
308
+ base64文字列を複数チャンクに分割して順次送信し、全チャンクが
309
+ 揃った時点で画像を組み立ててnote.comにアップロードします。
310
+
311
+ 使い方:
312
+ 1. ChatGPT側でbase64を分割(例: 50KBずつ)
313
+ 2. upload_id(UUID)を生成
314
+ 3. 各チャンクを note_set_eyecatch_base64_chunked で送信
315
+ 4. 全チャンク送信後、自動的に画像が組み立てられアップロードされる
306
316
 
307
317
  対応形式: PNG, JPEG, WebP, GIF
308
- 最大サイズ: 10MB
318
+ 最大合計サイズ: 10MB
319
+ 1チャンクあたり推奨サイズ: 64KB以下
309
320
 
310
321
  Args:
311
- note_id: アイキャッチ画像を設定する記事のID(数値IDまたは記事キー n... 形式)
322
+ upload_id: アップロードセッションの一意なID
323
+ note_id: アイキャッチ画像を設定する記事のID
312
324
  mime_type: 画像のMIMEタイプ
313
- image_base64: base64エンコードされた画像データ
325
+ chunk: base64エンコードされたチャンクデータ
326
+ chunk_index: このチャンクのインデックス(0始まり)
327
+ total_chunks: 全チャンク数
314
328
 
315
329
  Returns:
316
- 設定結果(画像URLを含む)
330
+ 中間チャンクでは受信状況、最終チャンクでは設定結果
317
331
  """
318
- image = await upload_eyecatch_base64(
332
+ image = await upload_eyecatch_chunked(
319
333
  session=session,
334
+ upload_id=upload_id,
320
335
  note_id=note_id,
321
336
  mime_type=mime_type,
322
- image_base64=image_base64,
337
+ chunk=chunk,
338
+ chunk_index=chunk_index,
339
+ total_chunks=total_chunks,
323
340
  )
341
+
342
+ if image is None:
343
+ return f"チャンク {chunk_index + 1}/{total_chunks} を受信しました。 upload_id: {upload_id}"
344
+
324
345
  if image.url:
325
346
  return f"アイキャッチ画像を設定しました。URL: {image.url}"
326
347
  return "アイキャッチ画像を設定しました。"