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 +1 -1
- package/py/pyproject.toml +1 -1
- package/py/src/note_mcp/api/images.py +142 -35
- package/py/src/note_mcp/server.py +33 -12
package/package.json
CHANGED
package/py/pyproject.toml
CHANGED
|
@@ -677,59 +677,166 @@ def _validate_image_bytes(data: bytes, mime_type: str) -> None:
|
|
|
677
677
|
)
|
|
678
678
|
|
|
679
679
|
|
|
680
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
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
|
-
#
|
|
705
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
#
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
723
|
-
|
|
811
|
+
# Decode the complete base64 string
|
|
812
|
+
image_bytes = _decode_base64_image(assembled_b64)
|
|
724
813
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
318
|
+
最大合計サイズ: 10MB
|
|
319
|
+
1チャンクあたり推奨サイズ: 64KB以下
|
|
309
320
|
|
|
310
321
|
Args:
|
|
311
|
-
|
|
322
|
+
upload_id: アップロードセッションの一意なID
|
|
323
|
+
note_id: アイキャッチ画像を設定する記事のID
|
|
312
324
|
mime_type: 画像のMIMEタイプ
|
|
313
|
-
|
|
325
|
+
chunk: base64エンコードされたチャンクデータ
|
|
326
|
+
chunk_index: このチャンクのインデックス(0始まり)
|
|
327
|
+
total_chunks: 全チャンク数
|
|
314
328
|
|
|
315
329
|
Returns:
|
|
316
|
-
|
|
330
|
+
中間チャンクでは受信状況、最終チャンクでは設定結果
|
|
317
331
|
"""
|
|
318
|
-
image = await
|
|
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
|
-
|
|
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 "アイキャッチ画像を設定しました。"
|