note-connector 0.2.5 → 0.2.6

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.
Files changed (46) hide show
  1. package/dist/paths.js +4 -0
  2. package/dist/setup-dependencies.js +56 -13
  3. package/package.json +3 -2
  4. package/py/pyproject.toml +86 -0
  5. package/py/src/note_mcp/__init__.py +7 -0
  6. package/py/src/note_mcp/__main__.py +65 -0
  7. package/py/src/note_mcp/api/__init__.py +31 -0
  8. package/py/src/note_mcp/api/articles.py +1395 -0
  9. package/py/src/note_mcp/api/client.py +318 -0
  10. package/py/src/note_mcp/api/embeds.py +482 -0
  11. package/py/src/note_mcp/api/images.py +456 -0
  12. package/py/src/note_mcp/api/preview.py +142 -0
  13. package/py/src/note_mcp/api/public_notes.py +150 -0
  14. package/py/src/note_mcp/auth/__init__.py +9 -0
  15. package/py/src/note_mcp/auth/browser.py +574 -0
  16. package/py/src/note_mcp/auth/file_session.py +145 -0
  17. package/py/src/note_mcp/auth/session.py +240 -0
  18. package/py/src/note_mcp/browser/__init__.py +10 -0
  19. package/py/src/note_mcp/browser/config.py +21 -0
  20. package/py/src/note_mcp/browser/manager.py +182 -0
  21. package/py/src/note_mcp/browser/preview.py +68 -0
  22. package/py/src/note_mcp/browser/url_helpers.py +18 -0
  23. package/py/src/note_mcp/chatgpt/__init__.py +1 -0
  24. package/py/src/note_mcp/chatgpt/__main__.py +63 -0
  25. package/py/src/note_mcp/chatgpt/access_log.py +25 -0
  26. package/py/src/note_mcp/chatgpt/auth.py +52 -0
  27. package/py/src/note_mcp/chatgpt/images.py +92 -0
  28. package/py/src/note_mcp/chatgpt/login_once.py +26 -0
  29. package/py/src/note_mcp/chatgpt/middleware.py +31 -0
  30. package/py/src/note_mcp/chatgpt/tools.py +255 -0
  31. package/py/src/note_mcp/chatgpt/widgets.py +121 -0
  32. package/py/src/note_mcp/decorators.py +113 -0
  33. package/py/src/note_mcp/investigator/__init__.py +33 -0
  34. package/py/src/note_mcp/investigator/__main__.py +11 -0
  35. package/py/src/note_mcp/investigator/cli.py +313 -0
  36. package/py/src/note_mcp/investigator/core.py +653 -0
  37. package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
  38. package/py/src/note_mcp/models.py +557 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +905 -0
  41. package/py/src/note_mcp/utils/__init__.py +7 -0
  42. package/py/src/note_mcp/utils/file_parser.py +314 -0
  43. package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
  44. package/py/src/note_mcp/utils/logging.py +119 -0
  45. package/py/src/note_mcp/utils/markdown.py +12 -0
  46. package/py/src/note_mcp/utils/markdown_to_html.py +826 -0
@@ -0,0 +1,905 @@
1
+ """FastMCP server for note.com article management.
2
+
3
+ Provides MCP tools for creating, updating, and managing note.com articles.
4
+ Supports investigator mode for API investigation via INVESTIGATOR_MODE=1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Annotated
11
+
12
+ from fastmcp import FastMCP
13
+
14
+ from note_mcp.api.articles import (
15
+ create_draft,
16
+ delete_all_drafts,
17
+ delete_article,
18
+ delete_draft,
19
+ get_article,
20
+ list_articles,
21
+ publish_article,
22
+ unpublish_article,
23
+ update_article,
24
+ )
25
+ from note_mcp.api.images import insert_image_via_api, upload_body_image, upload_eyecatch_image
26
+ from note_mcp.api.preview import get_preview_html
27
+ from note_mcp.auth.browser import login_with_browser
28
+ from note_mcp.auth.session import SessionManager
29
+ from note_mcp.browser.preview import show_preview
30
+ from note_mcp.decorators import handle_api_error, require_session
31
+ from note_mcp.investigator import register_investigator_tools
32
+ from note_mcp.models import ArticleInput, ArticleStatus, NoteAPIError, Session
33
+ from note_mcp.utils.file_parser import parse_markdown_file
34
+
35
+ # Create MCP server instance
36
+ mcp = FastMCP("note-mcp")
37
+
38
+
39
+ # Session manager instance
40
+ _session_manager = SessionManager()
41
+
42
+
43
+ @mcp.tool()
44
+ async def note_login(
45
+ timeout: Annotated[int, "ログインのタイムアウト時間(秒)。デフォルトは300秒。"] = 300,
46
+ ) -> str:
47
+ """note.comにログインします。
48
+
49
+ ブラウザウィンドウが開き、手動でログインを行います。
50
+ ログイン完了後、セッション情報が安全に保存されます。
51
+
52
+ Args:
53
+ timeout: ログインのタイムアウト時間(秒)
54
+
55
+ Returns:
56
+ ログイン結果のメッセージ
57
+ """
58
+ session = await login_with_browser(timeout=timeout)
59
+ return f"ログインに成功しました。ユーザー名: {session.username}"
60
+
61
+
62
+ @mcp.tool()
63
+ async def note_check_auth() -> str:
64
+ """現在の認証状態を確認します。
65
+
66
+ 保存されているセッション情報を確認し、有効かどうかを返します。
67
+
68
+ Returns:
69
+ 認証状態のメッセージ
70
+ """
71
+ if not _session_manager.has_session():
72
+ return "未認証です。note_loginを使用してログインしてください。"
73
+
74
+ session = _session_manager.load()
75
+ if session is None:
76
+ return "セッションの読み込みに失敗しました。note_loginで再ログインしてください。"
77
+
78
+ if session.is_expired():
79
+ return "セッションの有効期限が切れています。note_loginで再ログインしてください。"
80
+
81
+ return f"認証済みです。ユーザー名: {session.username}"
82
+
83
+
84
+ @mcp.tool()
85
+ async def note_logout() -> str:
86
+ """note.comからログアウトします。
87
+
88
+ 保存されているセッション情報を削除します。
89
+
90
+ Returns:
91
+ ログアウト結果のメッセージ
92
+ """
93
+ _session_manager.clear()
94
+ return "ログアウトしました。"
95
+
96
+
97
+ @mcp.tool()
98
+ async def note_set_username(
99
+ username: Annotated[str, "note.comのユーザー名(URLに表示される名前、例: your_username)"],
100
+ ) -> str:
101
+ """ユーザー名を手動で設定します。
102
+
103
+ ログイン時にユーザー名の自動取得に失敗した場合に使用します。
104
+ ユーザー名はnote.comのプロフィールURLから確認できます。
105
+ 例: https://note.com/your_username → your_username
106
+
107
+ Args:
108
+ username: note.comのユーザー名
109
+
110
+ Returns:
111
+ 設定結果のメッセージ
112
+ """
113
+ from note_mcp.models import Session
114
+
115
+ if not _session_manager.has_session():
116
+ return "セッションが存在しません。先にnote_loginを実行してください。"
117
+
118
+ session = _session_manager.load()
119
+ if session is None:
120
+ return "セッションの読み込みに失敗しました。note_loginで再ログインしてください。"
121
+
122
+ # Validate username format
123
+ import re
124
+
125
+ if not re.match(r"^[a-zA-Z0-9_-]+$", username):
126
+ return "無効なユーザー名です。英数字、アンダースコア、ハイフンのみ使用できます。"
127
+
128
+ # Create updated session with new username
129
+ updated_session = Session(
130
+ cookies=session.cookies,
131
+ user_id=username, # Use username as user_id
132
+ username=username,
133
+ expires_at=session.expires_at,
134
+ created_at=session.created_at,
135
+ )
136
+
137
+ _session_manager.save(updated_session)
138
+ return f"ユーザー名を '{username}' に設定しました。"
139
+
140
+
141
+ @mcp.tool()
142
+ async def note_create_draft(
143
+ title: Annotated[str, "記事のタイトル"],
144
+ body: Annotated[str, "記事の本文(Markdown形式)"],
145
+ tags: Annotated[list[str] | None, "記事のタグ(#なしでも可)"] = None,
146
+ ) -> str:
147
+ """note.comに下書き記事を作成します。
148
+
149
+ Markdown形式の本文をHTMLに変換してnote.comに送信します。
150
+ blockquote内の引用(— 出典名)はfigcaptionに自動入力されます。
151
+
152
+ Args:
153
+ title: 記事のタイトル
154
+ body: 記事の本文(Markdown形式)
155
+ tags: 記事のタグ(オプション)
156
+
157
+ Returns:
158
+ 作成結果のメッセージ(記事IDを含む)
159
+ """
160
+ session = _session_manager.load()
161
+ if session is None or session.is_expired():
162
+ return "セッションが無効です。note_loginでログインしてください。"
163
+
164
+ article_input = ArticleInput(
165
+ title=title,
166
+ body=body,
167
+ tags=tags or [],
168
+ )
169
+
170
+ try:
171
+ article = await create_draft(session, article_input)
172
+ except NoteAPIError as e:
173
+ return f"記事作成に失敗しました: {e}"
174
+
175
+ tag_info = f"、タグ: {', '.join(article.tags)}" if article.tags else ""
176
+ return f"下書きを作成しました。ID: {article.id}、キー: {article.key}{tag_info}"
177
+
178
+
179
+ @mcp.tool()
180
+ async def note_get_article(
181
+ article_id: Annotated[str, "取得する記事のID"],
182
+ ) -> str:
183
+ """記事の内容を取得します。
184
+
185
+ 指定したIDの記事のタイトル、本文、ステータスを取得します。
186
+ 記事を編集する前に既存内容を確認する際に使用します。
187
+
188
+ 推奨ワークフロー:
189
+ 1. note_get_article で既存内容を取得
190
+ 2. 取得した内容を元に編集を決定
191
+ 3. note_update_article で更新を保存
192
+
193
+ Args:
194
+ article_id: 取得する記事のID
195
+
196
+ Returns:
197
+ 記事の内容(タイトル、本文、ステータス)
198
+ """
199
+ session = _session_manager.load()
200
+ if session is None or session.is_expired():
201
+ return "セッションが無効です。note_loginでログインしてください。"
202
+
203
+ try:
204
+ article = await get_article(session, article_id)
205
+ except NoteAPIError as e:
206
+ return f"記事の取得に失敗しました: {e}"
207
+
208
+ tag_info = f"\nタグ: {', '.join(article.tags)}" if article.tags else ""
209
+
210
+ return f"""記事を取得しました。
211
+
212
+ タイトル: {article.title}
213
+ ステータス: {article.status.value}{tag_info}
214
+
215
+ 本文:
216
+ {article.body}"""
217
+
218
+
219
+ @mcp.tool()
220
+ async def note_update_article(
221
+ article_id: Annotated[str, "更新する記事のID"],
222
+ title: Annotated[str, "新しいタイトル"],
223
+ body: Annotated[str, "新しい本文(Markdown形式)"],
224
+ tags: Annotated[list[str] | None, "新しいタグ(#なしでも可)"] = None,
225
+ ) -> str:
226
+ """既存の記事を更新します。
227
+
228
+ 編集前にnote_get_articleで既存内容を取得することを推奨します。
229
+ Markdown形式の本文をHTMLに変換してnote.comに送信します。
230
+
231
+ Args:
232
+ article_id: 更新する記事のID
233
+ title: 新しいタイトル
234
+ body: 新しい本文(Markdown形式)
235
+ tags: 新しいタグ(オプション)
236
+
237
+ Returns:
238
+ 更新結果のメッセージ
239
+ """
240
+ session = _session_manager.load()
241
+ if session is None or session.is_expired():
242
+ return "セッションが無効です。note_loginでログインしてください。"
243
+
244
+ article_input = ArticleInput(
245
+ title=title,
246
+ body=body,
247
+ tags=tags or [],
248
+ )
249
+
250
+ try:
251
+ article = await update_article(session, article_id, article_input)
252
+ except NoteAPIError as e:
253
+ return f"記事更新に失敗しました: {e}"
254
+
255
+ tag_info = f"、タグ: {', '.join(article.tags)}" if article.tags else ""
256
+ return f"記事を更新しました。ID: {article.id}{tag_info}"
257
+
258
+
259
+ @mcp.tool()
260
+ @require_session
261
+ @handle_api_error
262
+ async def note_upload_eyecatch(
263
+ session: Session,
264
+ file_path: Annotated[str, "アップロードする画像ファイルのパス"],
265
+ note_id: Annotated[str, "画像を関連付ける記事のID(数字のみ)"],
266
+ ) -> str:
267
+ """記事のアイキャッチ(見出し)画像をアップロードします。
268
+
269
+ JPEG、PNG、GIF、WebP形式の画像をアップロードできます。
270
+ 最大ファイルサイズは10MBです。
271
+ アップロードした画像は記事の見出し画像として設定されます。
272
+
273
+ note_list_articlesで記事一覧を取得し、IDを確認できます。
274
+
275
+ Args:
276
+ file_path: アップロードする画像ファイルのパス
277
+ note_id: 画像を関連付ける記事のID
278
+
279
+ Returns:
280
+ アップロード結果(画像URLを含む)
281
+ """
282
+ image = await upload_eyecatch_image(session, file_path, note_id=note_id)
283
+ return f"アイキャッチ画像をアップロードしました。URL: {image.url}"
284
+
285
+
286
+ @mcp.tool()
287
+ @require_session
288
+ @handle_api_error
289
+ async def note_upload_body_image(
290
+ session: Session,
291
+ file_path: Annotated[str, "アップロードする画像ファイルのパス"],
292
+ note_id: Annotated[str, "画像を関連付ける記事のID(数字のみ)"],
293
+ ) -> str:
294
+ """記事本文内に埋め込む画像をアップロードします。
295
+
296
+ JPEG、PNG、GIF、WebP形式の画像をアップロードできます。
297
+ 最大ファイルサイズは10MBです。
298
+
299
+ **重要**: このツールは画像をアップロードしてURLを返すだけです。
300
+ 画像を記事に直接挿入するには note_insert_body_image を使用してください。
301
+
302
+ note_list_articlesで記事一覧を取得し、IDを確認できます。
303
+
304
+ Args:
305
+ file_path: アップロードする画像ファイルのパス
306
+ note_id: 画像を関連付ける記事のID
307
+
308
+ Returns:
309
+ アップロード結果(画像URLを含む)
310
+ """
311
+ image = await upload_body_image(session, file_path, note_id=note_id)
312
+ return (
313
+ f"本文用画像をアップロードしました。URL: {image.url}\n\n"
314
+ f"※画像を記事に直接挿入するには note_insert_body_image を使用してください。"
315
+ )
316
+
317
+
318
+ @mcp.tool()
319
+ async def note_insert_body_image(
320
+ file_path: Annotated[str, "挿入する画像ファイルのパス"],
321
+ article_id: Annotated[str, "画像を挿入する記事のID(数値またはキー形式)"],
322
+ caption: Annotated[str | None, "画像のキャプション(オプション)"] = None,
323
+ ) -> str:
324
+ """記事本文内に画像を直接挿入します。
325
+
326
+ API経由で画像をアップロードし、ProseMirrorで直接挿入します。
327
+ JPEG、PNG、GIF、WebP形式の画像を挿入できます。
328
+ 最大ファイルサイズは10MBです。
329
+
330
+ note_list_articlesで記事一覧を取得し、IDを確認できます。
331
+
332
+ Args:
333
+ file_path: 挿入する画像ファイルのパス
334
+ article_id: 画像を挿入する記事のID(数値またはキー形式)
335
+ caption: 画像のキャプション(オプション)
336
+
337
+ Returns:
338
+ 挿入結果のメッセージ
339
+ """
340
+ session = _session_manager.load()
341
+ if session is None or session.is_expired():
342
+ return "セッションが無効です。note_loginでログインしてください。"
343
+
344
+ try:
345
+ result = await insert_image_via_api(
346
+ session=session,
347
+ article_id=article_id,
348
+ file_path=file_path,
349
+ caption=caption,
350
+ )
351
+
352
+ # insert_image_via_api always returns {"success": True} on success
353
+ # or raises NoteAPIError on failure, so we can assume success here
354
+ caption_info = f"、キャプション: {result['caption']}" if result.get("caption") else ""
355
+ fallback_info = "(フォールバック使用)" if result.get("fallback_used") else ""
356
+ return (
357
+ f"画像を挿入しました。{fallback_info}\n"
358
+ f"記事ID: {result['article_id']}、キー: {result['article_key']}{caption_info}\n"
359
+ f"画像URL: {result['image_url']}"
360
+ )
361
+ except NoteAPIError as e:
362
+ return f"エラー: {e}"
363
+
364
+
365
+ @mcp.tool()
366
+ @require_session
367
+ @handle_api_error
368
+ async def note_show_preview(
369
+ session: Session,
370
+ article_key: Annotated[str, "プレビューする記事のキー(例: n1234567890ab)"],
371
+ ) -> str:
372
+ """記事のプレビューをブラウザで表示します。
373
+
374
+ 指定した記事のプレビューページをブラウザで開きます。
375
+ API経由でプレビューアクセストークンを取得し、直接プレビューURLにアクセスします。
376
+ エディターページを経由しないため、高速かつ安定しています。
377
+
378
+ Args:
379
+ article_key: プレビューする記事のキー
380
+
381
+ Returns:
382
+ プレビュー結果のメッセージ
383
+ """
384
+ await show_preview(session, article_key)
385
+ return f"プレビューを表示しました。記事キー: {article_key}"
386
+
387
+
388
+ @mcp.tool()
389
+ @require_session
390
+ @handle_api_error
391
+ async def note_get_preview_html(
392
+ session: Session,
393
+ article_key: Annotated[str, "取得する記事のキー(例: n1234567890ab)"],
394
+ ) -> str:
395
+ """プレビューページのHTMLを取得します。
396
+
397
+ 指定した記事のプレビューページのHTMLを文字列として取得します。
398
+ E2Eテストやコンテンツ検証のために使用します。
399
+ ブラウザを起動せず、API経由で高速に取得します。
400
+
401
+ Args:
402
+ article_key: 取得する記事のキー
403
+
404
+ Returns:
405
+ プレビューページのHTML
406
+ """
407
+ return await get_preview_html(session, article_key)
408
+
409
+
410
+ @mcp.tool()
411
+ async def note_publish_article(
412
+ article_id: Annotated[str | None, "公開する下書き記事のID(新規作成時は省略)"] = None,
413
+ file_path: Annotated[str | None, "タグを取得するMarkdownファイルのパス"] = None,
414
+ title: Annotated[str | None, "記事タイトル(新規作成時は必須)"] = None,
415
+ body: Annotated[str | None, "記事本文(Markdown形式、新規作成時は必須)"] = None,
416
+ tags: Annotated[list[str] | None, "記事のタグ(#なしでも可)"] = None,
417
+ ) -> str:
418
+ """記事を公開します。
419
+
420
+ 既存の下書きを公開するか、新規記事を作成して即公開できます。
421
+ article_idを指定すると既存の下書きを公開します。
422
+ title/bodyを指定すると新規記事を作成して公開します。
423
+
424
+ 既存の下書きを公開する際、tagsが未指定でfile_pathが指定されている場合、
425
+ Markdownファイルのフロントマターからタグを取得します。
426
+
427
+ Args:
428
+ article_id: 公開する下書き記事のID(新規作成時は省略)
429
+ file_path: タグを取得するMarkdownファイルのパス(既存下書き公開時のみ有効)
430
+ title: 記事タイトル(新規作成時は必須)
431
+ body: 記事本文(Markdown形式、新規作成時は必須)
432
+ tags: 記事のタグ(オプション、file_pathより優先)
433
+
434
+ Returns:
435
+ 公開結果のメッセージ(記事URLを含む)
436
+ """
437
+ from pathlib import Path
438
+
439
+ session = _session_manager.load()
440
+ if session is None or session.is_expired():
441
+ return "セッションが無効です。note_loginでログインしてください。"
442
+
443
+ # Determine whether to publish existing or create new
444
+ try:
445
+ if article_id is not None:
446
+ # Publish existing draft
447
+ publish_tags = tags
448
+
449
+ # Issue #258: If tags not specified but file_path is, get tags from file
450
+ if publish_tags is None and file_path is not None:
451
+ try:
452
+ parsed = parse_markdown_file(Path(file_path))
453
+ publish_tags = parsed.tags if parsed.tags else []
454
+ except FileNotFoundError:
455
+ return f"ファイルが見つかりません: {file_path}"
456
+ except ValueError as e:
457
+ return f"ファイル解析エラー: {e}"
458
+
459
+ article = await publish_article(session, article_id=article_id, tags=publish_tags)
460
+ elif title is not None and body is not None:
461
+ # Create and publish new article (file_path is ignored for new articles)
462
+ article_input = ArticleInput(
463
+ title=title,
464
+ body=body,
465
+ tags=tags or [],
466
+ )
467
+ article = await publish_article(session, article_input=article_input)
468
+ else:
469
+ return "article_idまたは(titleとbody)のいずれかを指定してください。"
470
+ except NoteAPIError as e:
471
+ return f"記事公開に失敗しました: {e}"
472
+
473
+ url_info = f"、URL: {article.url}" if article.url else ""
474
+ return f"記事を公開しました。ID: {article.id}{url_info}"
475
+
476
+
477
+ @mcp.tool()
478
+ async def note_list_articles(
479
+ status: Annotated[str | None, "フィルタするステータス(draft/published/all)"] = None,
480
+ page: Annotated[int, "ページ番号(1から開始)"] = 1,
481
+ limit: Annotated[int, "1ページあたりの記事数(最大10)"] = 10,
482
+ ) -> str:
483
+ """自分の記事一覧を取得します。
484
+
485
+ ステータスでフィルタリングできます。
486
+
487
+ Args:
488
+ status: フィルタするステータス(draft/published/all、省略時はall)
489
+ page: ページ番号(1から開始)
490
+ limit: 1ページあたりの記事数(最大10)
491
+
492
+ Returns:
493
+ 記事一覧の情報
494
+ """
495
+ session = _session_manager.load()
496
+ if session is None or session.is_expired():
497
+ return "セッションが無効です。note_loginでログインしてください。"
498
+
499
+ # Convert status string to ArticleStatus enum
500
+ status_filter: ArticleStatus | None = None
501
+ if status is not None and status != "all":
502
+ try:
503
+ status_filter = ArticleStatus(status)
504
+ except ValueError:
505
+ return f"無効なステータスです: {status}。draft/published/allのいずれかを指定してください。"
506
+
507
+ try:
508
+ result = await list_articles(session, status=status_filter, page=page, limit=limit)
509
+ except NoteAPIError as e:
510
+ return f"記事一覧の取得に失敗しました: {e}"
511
+
512
+ if not result.articles:
513
+ return "記事が見つかりませんでした。"
514
+
515
+ # Format article list
516
+ lines = [f"記事一覧({result.total}件中{len(result.articles)}件、ページ{result.page}):"]
517
+ for article in result.articles:
518
+ status_label = "下書き" if article.status == ArticleStatus.DRAFT else "公開済み"
519
+ lines.append(f" - [{status_label}] {article.title} (ID: {article.id}、キー: {article.key})")
520
+
521
+ if result.has_more:
522
+ lines.append(f" (続きはpage={result.page + 1}で取得できます)")
523
+
524
+ return "\n".join(lines)
525
+
526
+
527
+ @mcp.tool()
528
+ async def note_create_from_file(
529
+ file_path: Annotated[str, "Markdownファイルのパス"],
530
+ upload_images: Annotated[bool, "ローカル画像をアップロードするかどうか"] = True,
531
+ ) -> str:
532
+ """Markdownファイルから下書き記事を作成します。
533
+
534
+ ファイルからタイトル、本文、タグ、ローカル画像、アイキャッチ画像を抽出し、
535
+ note.comに下書きを作成します。
536
+
537
+ YAMLフロントマターがある場合:
538
+ - titleフィールドからタイトルを取得
539
+ - tagsフィールドからタグを取得
540
+ - eyecatchフィールドからアイキャッチ画像パスを取得
541
+
542
+ フロントマターがない場合:
543
+ - 最初のH1見出しをタイトルとして使用(本文から削除)
544
+ - H1がなければH2を使用
545
+
546
+ ローカル画像(./images/example.pngなど)は自動的にアップロードされ、
547
+ 本文内のパスがnote.comのURLに置換されます。
548
+
549
+ アイキャッチ画像が指定されている場合、自動的にアップロードされ、
550
+ 記事のアイキャッチとして設定されます。
551
+
552
+ Args:
553
+ file_path: Markdownファイルのパス
554
+ upload_images: ローカル画像をアップロードするかどうか(デフォルト: True)
555
+ Falseの場合、ローカルパスがそのまま残り、プレビューで画像が表示されません。
556
+
557
+ Returns:
558
+ 作成結果のメッセージ(記事IDを含む)
559
+ """
560
+ session = _session_manager.load()
561
+ if session is None:
562
+ return "ログインが必要です。note_loginを実行してください。"
563
+
564
+ from pathlib import Path
565
+
566
+ try:
567
+ parsed = parse_markdown_file(Path(file_path))
568
+ except FileNotFoundError:
569
+ return f"ファイルが見つかりません: {file_path}"
570
+ except ValueError as e:
571
+ return f"ファイル解析エラー: {e}"
572
+
573
+ article_input = ArticleInput(
574
+ title=parsed.title,
575
+ body=parsed.body,
576
+ tags=parsed.tags,
577
+ )
578
+
579
+ try:
580
+ article = await create_draft(session, article_input)
581
+
582
+ uploaded_count = 0
583
+ failed_images: list[str] = []
584
+
585
+ # Upload images via API and replace local paths with URLs
586
+ updated_body = parsed.body
587
+ if upload_images and parsed.local_images:
588
+ for img in parsed.local_images:
589
+ if img.absolute_path.exists():
590
+ try:
591
+ upload_result = await upload_body_image(
592
+ session,
593
+ str(img.absolute_path),
594
+ article.id,
595
+ )
596
+ updated_body = updated_body.replace(
597
+ f"({img.markdown_path})",
598
+ f"({upload_result.url})",
599
+ )
600
+ uploaded_count += 1
601
+ except NoteAPIError as e:
602
+ failed_images.append(f"{img.markdown_path}: {e}")
603
+ else:
604
+ failed_images.append(f"{img.markdown_path}: ファイルが見つかりません")
605
+
606
+ # Update article with image URLs
607
+ if uploaded_count > 0:
608
+ updated_input = ArticleInput(
609
+ title=parsed.title,
610
+ body=updated_body,
611
+ tags=parsed.tags,
612
+ )
613
+ await update_article(session, article.key, updated_input)
614
+
615
+ # Upload eyecatch image if specified
616
+ eyecatch_uploaded = False
617
+ eyecatch_error: str | None = None
618
+ if upload_images and parsed.eyecatch:
619
+ if parsed.eyecatch.exists():
620
+ try:
621
+ await upload_eyecatch_image(
622
+ session,
623
+ str(parsed.eyecatch),
624
+ article.id,
625
+ )
626
+ eyecatch_uploaded = True
627
+ except NoteAPIError as e:
628
+ eyecatch_error = f"{parsed.eyecatch.name}: {e}"
629
+ else:
630
+ eyecatch_error = f"ファイルが見つかりません: {parsed.eyecatch}"
631
+
632
+ result_lines = [
633
+ "✅ 下書きを作成しました",
634
+ f" タイトル: {article.title}",
635
+ f" 記事ID: {article.id}",
636
+ f" 記事キー: {article.key}",
637
+ ]
638
+
639
+ if uploaded_count > 0:
640
+ result_lines.append(f" アップロードした画像: {uploaded_count}件")
641
+
642
+ if eyecatch_uploaded:
643
+ result_lines.append(" アイキャッチ画像: アップロード完了")
644
+
645
+ if failed_images:
646
+ result_lines.append(f" ⚠️ 画像アップロード失敗: {len(failed_images)}件")
647
+ for msg in failed_images:
648
+ result_lines.append(f" - {msg}")
649
+
650
+ if eyecatch_error:
651
+ result_lines.append(f" ⚠️ アイキャッチ画像アップロード失敗: {eyecatch_error}")
652
+
653
+ return "\n".join(result_lines)
654
+
655
+ except NoteAPIError as e:
656
+ return f"記事作成エラー: {e}"
657
+
658
+
659
+ @mcp.tool()
660
+ async def note_delete_draft(
661
+ article_key: Annotated[str, "削除する記事のキー(例: n1234567890ab)"],
662
+ confirm: Annotated[bool, "削除を実行する場合はTrue、確認のみの場合はFalse"] = False,
663
+ ) -> str:
664
+ """下書き記事を削除します。
665
+
666
+ 指定した下書き記事を削除します。公開済み記事は削除できません。
667
+
668
+ 2段階確認フロー:
669
+ 1. confirm=False: 削除対象の記事情報を表示(実際の削除は行わない)
670
+ 2. confirm=True: 実際に削除を実行
671
+
672
+ **注意**: 削除は取り消しできません。
673
+
674
+ Args:
675
+ article_key: 削除する記事のキー
676
+ confirm: 削除を実行する場合はTrue(デフォルトはFalse)
677
+
678
+ Returns:
679
+ 削除結果または確認メッセージ
680
+ """
681
+ session = _session_manager.load()
682
+ if session is None or session.is_expired():
683
+ return "セッションが無効です。note_loginでログインしてください。"
684
+
685
+ try:
686
+ result = await delete_draft(session, article_key, confirm=confirm)
687
+
688
+ # Check result type and format response
689
+ from note_mcp.models import DeletePreview, DeleteResult
690
+
691
+ if isinstance(result, DeletePreview):
692
+ return (
693
+ f"削除対象の記事:\n"
694
+ f" タイトル: {result.article_title}\n"
695
+ f" キー: {result.article_key}\n"
696
+ f" ステータス: {result.status.value}\n\n"
697
+ f"{result.message}"
698
+ )
699
+ elif isinstance(result, DeleteResult):
700
+ return result.message
701
+
702
+ return str(result)
703
+
704
+ except NoteAPIError as e:
705
+ return f"削除に失敗しました: {e.message}"
706
+
707
+
708
+ @mcp.tool()
709
+ async def note_delete_all_drafts(
710
+ confirm: Annotated[bool, "削除を実行する場合はTrue、確認のみの場合はFalse"] = False,
711
+ ) -> str:
712
+ """すべての下書き記事を一括削除します。
713
+
714
+ 認証ユーザーのすべての下書き記事を削除します。
715
+ 公開済み記事は削除されません。
716
+
717
+ 2段階確認フロー:
718
+ 1. confirm=False: 削除対象の記事一覧を表示(実際の削除は行わない)
719
+ 2. confirm=True: 実際に削除を実行
720
+
721
+ **注意**: 削除は取り消しできません。
722
+
723
+ Args:
724
+ confirm: 削除を実行する場合はTrue(デフォルトはFalse)
725
+
726
+ Returns:
727
+ 削除結果または確認メッセージ
728
+ """
729
+ session = _session_manager.load()
730
+ if session is None or session.is_expired():
731
+ return "セッションが無効です。note_loginでログインしてください。"
732
+
733
+ try:
734
+ result = await delete_all_drafts(session, confirm=confirm)
735
+
736
+ # Check result type and format response
737
+ from note_mcp.models import BulkDeletePreview, BulkDeleteResult
738
+
739
+ if isinstance(result, BulkDeletePreview):
740
+ if result.total_count == 0:
741
+ return result.message
742
+
743
+ lines = [f"削除対象の下書き記事({result.total_count}件):"]
744
+ for article in result.articles:
745
+ lines.append(f" - {article.title} (キー: {article.article_key})")
746
+ # Show remaining count if there are more articles than displayed
747
+ displayed_count = len(result.articles)
748
+ remaining_count = result.total_count - displayed_count
749
+ if remaining_count > 0:
750
+ lines.append(f" ... 他 {remaining_count}件")
751
+ lines.append("")
752
+ lines.append(result.message)
753
+ return "\n".join(lines)
754
+
755
+ elif isinstance(result, BulkDeleteResult):
756
+ if result.total_count == 0:
757
+ return result.message
758
+
759
+ lines = [result.message]
760
+ if result.deleted_articles:
761
+ lines.append("")
762
+ lines.append("削除成功:")
763
+ for article in result.deleted_articles:
764
+ lines.append(f" - {article.title}")
765
+
766
+ if result.failed_articles:
767
+ lines.append("")
768
+ lines.append("削除失敗:")
769
+ for failed in result.failed_articles:
770
+ lines.append(f" - {failed.title}: {failed.error}")
771
+
772
+ return "\n".join(lines)
773
+
774
+ return str(result)
775
+
776
+ except NoteAPIError as e:
777
+ return f"一括削除に失敗しました: {e.message}"
778
+
779
+
780
+ @mcp.tool()
781
+ async def note_delete_article(
782
+ article_key: Annotated[str, "削除する記事のキー(例: n1234567890ab)"],
783
+ confirm: Annotated[bool, "削除を実行する場合はTrue、確認のみの場合はFalse"] = False,
784
+ ) -> str:
785
+ """公開記事を含む任意の記事を削除します。
786
+
787
+ note_delete_draft と異なり、公開済みの記事も削除できます。
788
+ **削除は取り消せません。**
789
+
790
+ 2段階確認フロー:
791
+ 1. confirm=False: 削除対象の記事情報を表示(実際の削除は行わない)
792
+ 2. confirm=True: 実際に削除を実行
793
+
794
+ Args:
795
+ article_key: 削除する記事のキー
796
+ confirm: 削除を実行する場合はTrue(デフォルトはFalse)
797
+
798
+ Returns:
799
+ 削除結果または確認メッセージ
800
+ """
801
+ session = _session_manager.load()
802
+ if session is None or session.is_expired():
803
+ return "セッションが無効です。note_loginでログインしてください。"
804
+
805
+ try:
806
+ result = await delete_article(session, article_key, confirm=confirm)
807
+
808
+ from note_mcp.models import DeletePreview, DeleteResult
809
+
810
+ if isinstance(result, DeletePreview):
811
+ return (
812
+ f"削除対象の記事:\n"
813
+ f" タイトル: {result.article_title}\n"
814
+ f" キー: {result.article_key}\n"
815
+ f" ステータス: {result.status.value}\n\n"
816
+ f"{result.message}"
817
+ )
818
+ elif isinstance(result, DeleteResult):
819
+ return result.message
820
+
821
+ return str(result)
822
+
823
+ except NoteAPIError as e:
824
+ return f"削除に失敗しました: {e.message}"
825
+
826
+
827
+ @mcp.tool()
828
+ async def note_unpublish_article(
829
+ article_key: Annotated[str, "下書きに戻す公開記事のキー(例: n1234567890ab)"],
830
+ ) -> str:
831
+ """公開記事を下書きに戻します。
832
+
833
+ 公開済みの記事を下書き状態に戻します。記事の内容は保持されます。
834
+ すでに下書きの記事に対して実行するとエラーになります。
835
+
836
+ Args:
837
+ article_key: 下書きに戻す公開記事のキー
838
+
839
+ Returns:
840
+ 下書きに戻した記事情報
841
+ """
842
+ session = _session_manager.load()
843
+ if session is None or session.is_expired():
844
+ return "セッションが無効です。note_loginでログインしてください。"
845
+
846
+ try:
847
+ article = await unpublish_article(session, article_key)
848
+ return (
849
+ f"記事を下書きに戻しました:\n"
850
+ f" タイトル: {article.title}\n"
851
+ f" キー: {article.key}\n"
852
+ f" ステータス: draft\n"
853
+ f" URL: {article.url}"
854
+ )
855
+
856
+ except NoteAPIError as e:
857
+ return f"下書き戻しに失敗しました: {e.message}"
858
+
859
+
860
+ @mcp.tool(annotations={"readOnlyHint": True})
861
+ async def note_search_public_articles(
862
+ query: Annotated[str, "検索キーワード"],
863
+ size: Annotated[int, "件数(1〜20)"] = 10,
864
+ ) -> str:
865
+ """note.com の公開記事をキーワード検索します(他人の記事・ログイン不要)。"""
866
+ from note_mcp.api.public_notes import search_public_notes
867
+
868
+ try:
869
+ result = await search_public_notes(query, size=size)
870
+ except NoteAPIError as e:
871
+ return f"検索に失敗しました: {e}"
872
+ if not result.items:
873
+ return f"「{query}」に一致する公開記事は見つかりませんでした。"
874
+ lines = [f"検索結果({len(result.items)}件)クエリ: {query}"]
875
+ for item in result.items:
876
+ lines.append(f" - {item.title} ({item.url}) 著者: {item.author_username}")
877
+ return "\n".join(lines)
878
+
879
+
880
+ @mcp.tool(annotations={"readOnlyHint": True})
881
+ async def note_fetch_public_article(
882
+ note_key_or_url: Annotated[str, "記事キー(n...)または https://note.com/.../n/... URL"],
883
+ ) -> str:
884
+ """公開記事の本文を取得します(他人の記事・ログイン不要)。"""
885
+ from note_mcp.api.public_notes import fetch_public_article
886
+
887
+ try:
888
+ article = await fetch_public_article(note_key_or_url)
889
+ except NoteAPIError as e:
890
+ return f"取得に失敗しました: {e}"
891
+ preview = article.body_markdown[:2000]
892
+ if len(article.body_markdown) > 2000:
893
+ preview += "\n...(省略)"
894
+ return (
895
+ f"タイトル: {article.title}\n"
896
+ f"著者: {article.author_username}\n"
897
+ f"URL: {article.url}\n"
898
+ f"ステータス: {article.status}\n\n"
899
+ f"{preview}"
900
+ )
901
+
902
+
903
+ # Register investigator tools if in investigator mode
904
+ if os.environ.get("INVESTIGATOR_MODE") == "1":
905
+ register_investigator_tools(mcp)