note-connector 0.2.5 → 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/dist/paths.js +4 -0
- package/dist/setup-dependencies.js +56 -13
- package/package.json +3 -2
- package/py/pyproject.toml +86 -0
- package/py/src/note_mcp/__init__.py +7 -0
- package/py/src/note_mcp/__main__.py +65 -0
- package/py/src/note_mcp/api/__init__.py +31 -0
- package/py/src/note_mcp/api/articles.py +1395 -0
- package/py/src/note_mcp/api/client.py +318 -0
- package/py/src/note_mcp/api/embeds.py +482 -0
- package/py/src/note_mcp/api/images.py +660 -0
- package/py/src/note_mcp/api/preview.py +142 -0
- package/py/src/note_mcp/api/public_notes.py +150 -0
- package/py/src/note_mcp/auth/__init__.py +9 -0
- package/py/src/note_mcp/auth/browser.py +574 -0
- package/py/src/note_mcp/auth/file_session.py +145 -0
- package/py/src/note_mcp/auth/session.py +240 -0
- package/py/src/note_mcp/browser/__init__.py +10 -0
- package/py/src/note_mcp/browser/config.py +21 -0
- package/py/src/note_mcp/browser/manager.py +182 -0
- package/py/src/note_mcp/browser/preview.py +68 -0
- package/py/src/note_mcp/browser/url_helpers.py +18 -0
- package/py/src/note_mcp/chatgpt/__init__.py +1 -0
- package/py/src/note_mcp/chatgpt/__main__.py +63 -0
- package/py/src/note_mcp/chatgpt/access_log.py +25 -0
- package/py/src/note_mcp/chatgpt/auth.py +52 -0
- package/py/src/note_mcp/chatgpt/images.py +92 -0
- package/py/src/note_mcp/chatgpt/login_once.py +26 -0
- package/py/src/note_mcp/chatgpt/middleware.py +31 -0
- package/py/src/note_mcp/chatgpt/tools.py +255 -0
- package/py/src/note_mcp/chatgpt/widgets.py +121 -0
- package/py/src/note_mcp/decorators.py +113 -0
- package/py/src/note_mcp/investigator/__init__.py +33 -0
- package/py/src/note_mcp/investigator/__main__.py +11 -0
- package/py/src/note_mcp/investigator/cli.py +313 -0
- package/py/src/note_mcp/investigator/core.py +653 -0
- package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
- package/py/src/note_mcp/models.py +562 -0
- package/py/src/note_mcp/py.typed +0 -0
- package/py/src/note_mcp/server.py +944 -0
- package/py/src/note_mcp/utils/__init__.py +7 -0
- package/py/src/note_mcp/utils/file_parser.py +314 -0
- package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
- package/py/src/note_mcp/utils/logging.py +119 -0
- package/py/src/note_mcp/utils/markdown.py +12 -0
- package/py/src/note_mcp/utils/markdown_to_html.py +826 -0
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
"""Article operations for note.com API.
|
|
2
|
+
|
|
3
|
+
Provides functions for creating, updating, and managing articles.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from note_mcp.api.client import NoteAPIClient
|
|
15
|
+
from note_mcp.api.embeds import resolve_embed_keys
|
|
16
|
+
from note_mcp.api.images import _resolve_numeric_note_id
|
|
17
|
+
from note_mcp.models import (
|
|
18
|
+
Article,
|
|
19
|
+
ArticleInput,
|
|
20
|
+
ArticleListResult,
|
|
21
|
+
ArticleStatus,
|
|
22
|
+
BulkDeletePreview,
|
|
23
|
+
BulkDeleteResult,
|
|
24
|
+
DeletePreview,
|
|
25
|
+
DeleteResult,
|
|
26
|
+
ErrorCode,
|
|
27
|
+
NoteAPIError,
|
|
28
|
+
Session,
|
|
29
|
+
from_api_response,
|
|
30
|
+
)
|
|
31
|
+
from note_mcp.utils import markdown_to_html
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Issue #174: Generic API Execution Helper Functions
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _execute_get[T](
|
|
44
|
+
session: Session,
|
|
45
|
+
endpoint: str,
|
|
46
|
+
response_parser: Callable[[dict[str, Any]], T],
|
|
47
|
+
*,
|
|
48
|
+
params: dict[str, Any] | None = None,
|
|
49
|
+
) -> T:
|
|
50
|
+
"""Execute GET request and parse response.
|
|
51
|
+
|
|
52
|
+
Common pattern for API operations that:
|
|
53
|
+
1. Open NoteAPIClient context
|
|
54
|
+
2. Execute GET request
|
|
55
|
+
3. Parse response with provided parser
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
session: Authenticated session
|
|
59
|
+
endpoint: API endpoint path
|
|
60
|
+
response_parser: Function to parse response dict into result type
|
|
61
|
+
params: Optional query parameters
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Parsed result of type T
|
|
65
|
+
"""
|
|
66
|
+
async with NoteAPIClient(session) as client:
|
|
67
|
+
response = await client.get(endpoint, params=params)
|
|
68
|
+
return response_parser(response)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _execute_post[T](
|
|
72
|
+
session: Session,
|
|
73
|
+
endpoint: str,
|
|
74
|
+
response_parser: Callable[[dict[str, Any]], T],
|
|
75
|
+
*,
|
|
76
|
+
payload: dict[str, Any] | None = None,
|
|
77
|
+
) -> T:
|
|
78
|
+
"""Execute POST request and parse response.
|
|
79
|
+
|
|
80
|
+
Common pattern for API operations that:
|
|
81
|
+
1. Open NoteAPIClient context
|
|
82
|
+
2. Execute POST request with payload
|
|
83
|
+
3. Parse response with provided parser
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
session: Authenticated session
|
|
87
|
+
endpoint: API endpoint path
|
|
88
|
+
response_parser: Function to parse response dict into result type
|
|
89
|
+
payload: JSON payload for request (optional, defaults to None)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Parsed result of type T
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
NoteAPIError: If API request fails (401, 403, 404, 429, 5xx)
|
|
96
|
+
"""
|
|
97
|
+
async with NoteAPIClient(session) as client:
|
|
98
|
+
response = await client.post(endpoint, json=payload)
|
|
99
|
+
return response_parser(response)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _execute_delete(
|
|
103
|
+
session: Session,
|
|
104
|
+
endpoint: str,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Execute DELETE request.
|
|
107
|
+
|
|
108
|
+
Common pattern for API delete operations that:
|
|
109
|
+
1. Open NoteAPIClient context
|
|
110
|
+
2. Execute DELETE request
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
session: Authenticated session
|
|
114
|
+
endpoint: API endpoint path
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
None
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
NoteAPIError: If API request fails (401, 403, 404, 429, 5xx)
|
|
121
|
+
"""
|
|
122
|
+
async with NoteAPIClient(session) as client:
|
|
123
|
+
await client.delete(endpoint)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# =============================================================================
|
|
127
|
+
# Issue #114: API-only Image Insertion Helper Functions
|
|
128
|
+
# =============================================================================
|
|
129
|
+
|
|
130
|
+
# Default image dimensions used by note.com's editor
|
|
131
|
+
NOTE_DEFAULT_IMAGE_WIDTH: int = 620
|
|
132
|
+
NOTE_DEFAULT_IMAGE_HEIGHT: int = 457
|
|
133
|
+
|
|
134
|
+
# =============================================================================
|
|
135
|
+
# Issue #141: Delete Draft Constants
|
|
136
|
+
# =============================================================================
|
|
137
|
+
|
|
138
|
+
# Maximum pages to fetch when listing all drafts (safety limit for pagination)
|
|
139
|
+
# 1 page = ~10 articles, so 100 pages = ~1000 articles
|
|
140
|
+
DELETE_ALL_DRAFTS_MAX_PAGES: int = 100
|
|
141
|
+
|
|
142
|
+
# Number of articles to show in preview when confirm=False
|
|
143
|
+
DELETE_ALL_DRAFTS_PREVIEW_LIMIT: int = 10
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def generate_image_html(
|
|
147
|
+
image_url: str,
|
|
148
|
+
caption: str = "",
|
|
149
|
+
width: int = NOTE_DEFAULT_IMAGE_WIDTH,
|
|
150
|
+
height: int = NOTE_DEFAULT_IMAGE_HEIGHT,
|
|
151
|
+
) -> str:
|
|
152
|
+
"""Generate note.com figure HTML for an image.
|
|
153
|
+
|
|
154
|
+
Creates HTML in the format expected by note.com's editor.
|
|
155
|
+
The default dimensions (620x457) match note.com's standard image size.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
image_url: CDN URL of the uploaded image
|
|
159
|
+
caption: Optional caption text (default: empty)
|
|
160
|
+
width: Image width in pixels (default: 620)
|
|
161
|
+
height: Image height in pixels (default: 457)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
HTML string: <figure name="..." id="..."><img ...><figcaption>...</figcaption></figure>
|
|
165
|
+
"""
|
|
166
|
+
element_id = str(uuid.uuid4())
|
|
167
|
+
# Escape caption and URL to prevent XSS attacks
|
|
168
|
+
escaped_caption = html.escape(caption)
|
|
169
|
+
escaped_url = html.escape(image_url)
|
|
170
|
+
return (
|
|
171
|
+
f'<figure name="{element_id}" id="{element_id}">'
|
|
172
|
+
f'<img src="{escaped_url}" alt="" width="{width}" height="{height}" '
|
|
173
|
+
f'contenteditable="false" draggable="false">'
|
|
174
|
+
f"<figcaption>{escaped_caption}</figcaption></figure>"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def append_image_to_body(existing_body: str, image_html: str) -> str:
|
|
179
|
+
"""Append image HTML to article body.
|
|
180
|
+
|
|
181
|
+
Simply appends the image HTML to the end of the existing body.
|
|
182
|
+
Use this when inserting images via API without browser automation.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
existing_body: Current HTML body of the article
|
|
186
|
+
image_html: Generated figure HTML to append
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Updated HTML body with image appended at the end
|
|
190
|
+
"""
|
|
191
|
+
return existing_body + image_html
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def get_article_raw_html(
|
|
195
|
+
session: Session,
|
|
196
|
+
article_id: str,
|
|
197
|
+
) -> Article:
|
|
198
|
+
"""Get article with raw HTML body (no conversion to Markdown).
|
|
199
|
+
|
|
200
|
+
Unlike get_article(), this returns the HTML body as-is without
|
|
201
|
+
converting to Markdown. Use this when you need to manipulate
|
|
202
|
+
the HTML content directly (e.g., appending image HTML).
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
session: Authenticated session
|
|
206
|
+
article_id: Article key (e.g., "n1234567890ab").
|
|
207
|
+
Note: Key format is required due to note.com API limitations.
|
|
208
|
+
The /v3/notes/ endpoint does not support numeric IDs.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Article object with raw HTML body
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
NoteAPIError: If API request fails or numeric ID is provided
|
|
215
|
+
"""
|
|
216
|
+
# Issue #154: /v3/notes/ endpoint does not support numeric IDs
|
|
217
|
+
if article_id.isdigit():
|
|
218
|
+
raise NoteAPIError(
|
|
219
|
+
code=ErrorCode.INVALID_INPUT,
|
|
220
|
+
message=(
|
|
221
|
+
f"Numeric article ID '{article_id}' is not supported. "
|
|
222
|
+
"Please use the article key format (e.g., 'n1234567890ab'). "
|
|
223
|
+
"You can get the article key from create_draft() or list_articles()."
|
|
224
|
+
),
|
|
225
|
+
details={"article_id": article_id},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
article = await _execute_get(
|
|
229
|
+
session,
|
|
230
|
+
f"/v3/notes/{article_id}",
|
|
231
|
+
_parse_article_response,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Issue #209: Check if article was deleted
|
|
235
|
+
# note.com API returns status='deleted' instead of 404 for deleted articles.
|
|
236
|
+
# We treat this as ARTICLE_NOT_FOUND because the content is no longer accessible.
|
|
237
|
+
if article.status == ArticleStatus.DELETED:
|
|
238
|
+
raise NoteAPIError(
|
|
239
|
+
code=ErrorCode.ARTICLE_NOT_FOUND,
|
|
240
|
+
message="Article has been deleted (status='deleted')",
|
|
241
|
+
details={"article_id": article_id},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return article
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def update_article_raw_html(
|
|
248
|
+
session: Session,
|
|
249
|
+
article_id: str,
|
|
250
|
+
title: str,
|
|
251
|
+
html_body: str,
|
|
252
|
+
tags: list[str] | None = None,
|
|
253
|
+
) -> Article:
|
|
254
|
+
"""Update article with raw HTML body (no Markdown conversion).
|
|
255
|
+
|
|
256
|
+
Unlike update_article(), this saves the HTML body directly without
|
|
257
|
+
converting from Markdown. Use this when the body is already in HTML
|
|
258
|
+
format (e.g., after appending image HTML).
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
session: Authenticated session
|
|
262
|
+
article_id: ID of the article to update
|
|
263
|
+
title: Article title
|
|
264
|
+
html_body: HTML body content (not Markdown)
|
|
265
|
+
tags: Optional list of tags
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Updated Article object
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
NoteAPIError: If API request fails
|
|
272
|
+
"""
|
|
273
|
+
# Resolve to numeric ID (API requirement)
|
|
274
|
+
numeric_id = await _resolve_numeric_note_id(session, article_id)
|
|
275
|
+
|
|
276
|
+
# Build payload with raw HTML body (no conversion)
|
|
277
|
+
payload: dict[str, Any] = {
|
|
278
|
+
"name": title,
|
|
279
|
+
"body": html_body,
|
|
280
|
+
"body_length": len(html_body),
|
|
281
|
+
"index": False,
|
|
282
|
+
"is_lead_form": False,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# Add tags if provided
|
|
286
|
+
hashtags = _normalize_tags(tags)
|
|
287
|
+
if hashtags:
|
|
288
|
+
payload["hashtags"] = hashtags
|
|
289
|
+
|
|
290
|
+
return await _execute_post(
|
|
291
|
+
session,
|
|
292
|
+
f"/v1/text_notes/draft_save?id={numeric_id}&is_temp_saved=true",
|
|
293
|
+
_create_draft_save_parser(article_id, numeric_id, title, html_body),
|
|
294
|
+
payload=payload,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _parse_article_response(response: dict[str, Any]) -> Article:
|
|
299
|
+
"""Parse API response and convert to Article.
|
|
300
|
+
|
|
301
|
+
Handles the common pattern of extracting article data from
|
|
302
|
+
API response and converting it to Article model.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
response: Raw API response dict with "data" key
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Article object parsed from response data
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
NoteAPIError: If "data" key is missing from response
|
|
312
|
+
"""
|
|
313
|
+
article_data = response.get("data")
|
|
314
|
+
if article_data is None:
|
|
315
|
+
raise NoteAPIError(
|
|
316
|
+
code=ErrorCode.API_ERROR,
|
|
317
|
+
message="Invalid API response: missing 'data' key",
|
|
318
|
+
details={"response": response},
|
|
319
|
+
)
|
|
320
|
+
return from_api_response(article_data)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _normalize_tags(tags: list[str] | None) -> list[dict[str, Any]] | None:
|
|
324
|
+
"""Normalize tags to API format for draft_save.
|
|
325
|
+
|
|
326
|
+
Removes leading '#' and converts to hashtag dict format.
|
|
327
|
+
This format is used by POST /v1/text_notes/draft_save.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
tags: List of tags (may include '#' prefix)
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
List of hashtag dicts for API, or None if no tags
|
|
334
|
+
"""
|
|
335
|
+
if not tags:
|
|
336
|
+
return None
|
|
337
|
+
normalized = [tag.lstrip("#") for tag in tags]
|
|
338
|
+
return [{"hashtag": {"name": tag}} for tag in normalized]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _normalize_tags_for_publish(tags: list[str] | None) -> list[str] | None:
|
|
342
|
+
"""Normalize tags to API format for publish.
|
|
343
|
+
|
|
344
|
+
Ensures tags have '#' prefix as required by PUT /v1/text_notes/{id}.
|
|
345
|
+
This format is used when publishing articles.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
tags: List of tags (may or may not include '#' prefix)
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
List of hashtag strings with '#' prefix, or None if no tags
|
|
352
|
+
"""
|
|
353
|
+
if not tags:
|
|
354
|
+
return None
|
|
355
|
+
return [f"#{tag.lstrip('#')}" for tag in tags]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _build_article_payload(
|
|
359
|
+
article_input: ArticleInput,
|
|
360
|
+
html_body: str | None = None,
|
|
361
|
+
include_body: bool = True,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""Build common article payload for API requests.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
article_input: Article content and metadata
|
|
367
|
+
html_body: Pre-converted HTML body (optional)
|
|
368
|
+
include_body: Whether to include body in payload
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Payload dict for note.com API
|
|
372
|
+
"""
|
|
373
|
+
payload: dict[str, Any] = {
|
|
374
|
+
"name": article_input.title,
|
|
375
|
+
"index": False,
|
|
376
|
+
"is_lead_form": False,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if include_body and html_body is not None:
|
|
380
|
+
payload["body"] = html_body
|
|
381
|
+
payload["body_length"] = len(html_body)
|
|
382
|
+
|
|
383
|
+
hashtags = _normalize_tags(article_input.tags)
|
|
384
|
+
if hashtags:
|
|
385
|
+
payload["hashtags"] = hashtags
|
|
386
|
+
|
|
387
|
+
return payload
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _is_article_key_format(article_id: str) -> bool:
|
|
391
|
+
"""Check if article_id is in key format (e.g., "n12345abcdef").
|
|
392
|
+
|
|
393
|
+
Key format starts with "n" followed by alphanumeric characters.
|
|
394
|
+
Pure numeric IDs (e.g., "12345") are NOT considered keys.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
article_id: Article identifier to check
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
True if article_id is in key format, False otherwise
|
|
401
|
+
"""
|
|
402
|
+
return article_id.startswith("n") and not article_id.isdigit()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _validate_draft_save_response(
|
|
406
|
+
response: dict[str, Any],
|
|
407
|
+
article_id: str,
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Validate draft_save API response.
|
|
410
|
+
|
|
411
|
+
Issue #155: draft_save returns {result, note_days_count, updated_at},
|
|
412
|
+
not full article data. We validate by checking for "result" field.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
response: Raw API response dict
|
|
416
|
+
article_id: Article ID for error context
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
NoteAPIError: If response is invalid or missing required fields
|
|
420
|
+
"""
|
|
421
|
+
article_data = response.get("data", {})
|
|
422
|
+
if not article_data or "result" not in article_data:
|
|
423
|
+
raise NoteAPIError(
|
|
424
|
+
code=ErrorCode.API_ERROR,
|
|
425
|
+
message="Article update failed: API returned empty response",
|
|
426
|
+
details={"article_id": article_id, "response": response},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _create_draft_save_parser(
|
|
431
|
+
article_id: str,
|
|
432
|
+
numeric_id: str,
|
|
433
|
+
title: str,
|
|
434
|
+
html_body: str,
|
|
435
|
+
article_key: str = "",
|
|
436
|
+
) -> Callable[[dict[str, Any]], Article]:
|
|
437
|
+
"""Create a parser for draft_save response.
|
|
438
|
+
|
|
439
|
+
Issue #174: Factory function to create response parser with context.
|
|
440
|
+
draft_save returns minimal response, so we construct Article from inputs.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
article_id: Original article ID (for error context)
|
|
444
|
+
numeric_id: Resolved numeric ID
|
|
445
|
+
title: Article title
|
|
446
|
+
html_body: HTML body content
|
|
447
|
+
article_key: Article key (optional, derived from article_id if not provided)
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Parser function that validates response and returns Article
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
def parser(response: dict[str, Any]) -> Article:
|
|
454
|
+
_validate_draft_save_response(response, article_id)
|
|
455
|
+
key = article_key if article_key else (article_id if _is_article_key_format(article_id) else "")
|
|
456
|
+
return Article(
|
|
457
|
+
id=numeric_id,
|
|
458
|
+
key=key,
|
|
459
|
+
title=title,
|
|
460
|
+
body=html_body,
|
|
461
|
+
status=ArticleStatus.DRAFT,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return parser
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _parse_create_response(response: dict[str, Any]) -> tuple[str, str, dict[str, Any]]:
|
|
468
|
+
"""Parse response from article creation endpoint.
|
|
469
|
+
|
|
470
|
+
Issue #174: Extract article_id and article_key from create response.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
response: Raw API response from /v1/text_notes
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Tuple of (article_id, article_key, article_data)
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
NoteAPIError: If required fields are missing
|
|
480
|
+
"""
|
|
481
|
+
article_data = response.get("data", {})
|
|
482
|
+
article_id = article_data.get("id")
|
|
483
|
+
article_key = article_data.get("key")
|
|
484
|
+
|
|
485
|
+
if not article_id:
|
|
486
|
+
raise NoteAPIError(
|
|
487
|
+
code=ErrorCode.API_ERROR,
|
|
488
|
+
message="Article creation failed: API returned no article ID",
|
|
489
|
+
details={"response": response},
|
|
490
|
+
)
|
|
491
|
+
if not article_key:
|
|
492
|
+
raise NoteAPIError(
|
|
493
|
+
code=ErrorCode.API_ERROR,
|
|
494
|
+
message="Article creation failed: API returned no article key",
|
|
495
|
+
details={"article_id": article_id, "response": response},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return str(article_id), str(article_key), article_data
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
async def create_draft(
|
|
502
|
+
session: Session,
|
|
503
|
+
article_input: ArticleInput,
|
|
504
|
+
) -> Article:
|
|
505
|
+
"""Create a new draft article.
|
|
506
|
+
|
|
507
|
+
Uses the note.com API to create the draft directly.
|
|
508
|
+
Converts Markdown body to HTML as required by the API.
|
|
509
|
+
|
|
510
|
+
Note: This function performs multiple API calls:
|
|
511
|
+
1. POST /v1/text_notes - Creates the article entry (without body)
|
|
512
|
+
2. GET /v2/embed_by_external_api - For each embed URL, fetches server key
|
|
513
|
+
3. POST /v1/text_notes/draft_save - Saves the body content with resolved keys
|
|
514
|
+
|
|
515
|
+
The body is sent only via draft_save to preserve HTML structure.
|
|
516
|
+
Embed URLs (YouTube, Twitter, note.com) are processed to obtain
|
|
517
|
+
server-registered keys required for iframe rendering.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
session: Authenticated session
|
|
521
|
+
article_input: Article content and metadata
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Created Article object
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
NoteAPIError: If API request fails
|
|
528
|
+
"""
|
|
529
|
+
# Convert Markdown to HTML for API (embeds get random keys initially)
|
|
530
|
+
html_body = markdown_to_html(article_input.body)
|
|
531
|
+
|
|
532
|
+
# Step 1 payload: without body to avoid sanitization
|
|
533
|
+
create_payload = _build_article_payload(article_input, include_body=False)
|
|
534
|
+
|
|
535
|
+
# Step 1: Create the article entry (without body)
|
|
536
|
+
# The body is saved separately via draft_save to preserve <br> tags
|
|
537
|
+
article_id, article_key, article_data = await _execute_post(
|
|
538
|
+
session,
|
|
539
|
+
"/v1/text_notes",
|
|
540
|
+
_parse_create_response,
|
|
541
|
+
payload=create_payload,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Step 2: Resolve embed keys via API
|
|
545
|
+
# Replace random keys with server-registered keys for iframe rendering
|
|
546
|
+
resolved_html = await resolve_embed_keys(session, html_body, article_key)
|
|
547
|
+
|
|
548
|
+
# Step 3: Save the body content with draft_save
|
|
549
|
+
# Use resolved HTML with server-registered embed keys
|
|
550
|
+
save_payload = _build_article_payload(article_input, resolved_html)
|
|
551
|
+
|
|
552
|
+
async with NoteAPIClient(session) as client:
|
|
553
|
+
await client.post(
|
|
554
|
+
f"/v1/text_notes/draft_save?id={article_id}&is_temp_saved=true",
|
|
555
|
+
json=save_payload,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Parse response
|
|
559
|
+
# Note: POST /v1/text_notes returns empty 'status' field for newly created articles.
|
|
560
|
+
# Since this function specifically creates drafts, we set status to 'draft' explicitly.
|
|
561
|
+
# This is Article 6 compliant: we know the expected state from the function's semantics.
|
|
562
|
+
status_str = article_data.get("status")
|
|
563
|
+
if not status_str:
|
|
564
|
+
logger.warning(
|
|
565
|
+
"create_draft API returned empty status, setting to 'draft'. Response: %s",
|
|
566
|
+
article_data,
|
|
567
|
+
)
|
|
568
|
+
article_data["status"] = ArticleStatus.DRAFT.value
|
|
569
|
+
|
|
570
|
+
return from_api_response(article_data)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def update_article(
|
|
574
|
+
session: Session,
|
|
575
|
+
article_id: str,
|
|
576
|
+
article_input: ArticleInput,
|
|
577
|
+
) -> Article:
|
|
578
|
+
"""Update an existing article.
|
|
579
|
+
|
|
580
|
+
Uses the note.com API to update the article.
|
|
581
|
+
Converts Markdown body to HTML as required by the API.
|
|
582
|
+
Embed URLs (YouTube, Twitter, note.com) are processed to obtain
|
|
583
|
+
server-registered keys required for iframe rendering.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
session: Authenticated session
|
|
587
|
+
article_id: ID of the article to update (numeric or key format)
|
|
588
|
+
article_input: New article content and metadata
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Updated Article object
|
|
592
|
+
|
|
593
|
+
Raises:
|
|
594
|
+
NoteAPIError: If API request fails
|
|
595
|
+
"""
|
|
596
|
+
from note_mcp.api.embeds import _EMBED_FIGURE_PATTERN
|
|
597
|
+
|
|
598
|
+
# Resolve to numeric ID (API requirement)
|
|
599
|
+
numeric_id = await _resolve_numeric_note_id(session, article_id)
|
|
600
|
+
|
|
601
|
+
# Convert Markdown to HTML for API (embeds get random keys initially)
|
|
602
|
+
html_body = markdown_to_html(article_input.body)
|
|
603
|
+
|
|
604
|
+
# Check if HTML contains embeds that need key resolution
|
|
605
|
+
# Issue #146: Only fetch article key when embeds are present
|
|
606
|
+
has_embeds = bool(_EMBED_FIGURE_PATTERN.search(html_body))
|
|
607
|
+
|
|
608
|
+
# Determine final HTML and article key for result construction
|
|
609
|
+
final_html = html_body
|
|
610
|
+
article_key_for_result = article_id if _is_article_key_format(article_id) else ""
|
|
611
|
+
|
|
612
|
+
if has_embeds:
|
|
613
|
+
# Resolve article key for embed resolution
|
|
614
|
+
article_key = article_id if _is_article_key_format(article_id) else ""
|
|
615
|
+
|
|
616
|
+
if not article_key:
|
|
617
|
+
# Numeric ID: fetch article to get key since draft_save doesn't return it
|
|
618
|
+
# Issue #155: draft_save returns {result, note_days_count, updated_at}, not article data
|
|
619
|
+
fetched_article = await get_article_via_api(session, str(numeric_id))
|
|
620
|
+
article_key = fetched_article.key
|
|
621
|
+
# Preserve fetched key in result (Issue #155 review feedback)
|
|
622
|
+
article_key_for_result = article_key
|
|
623
|
+
|
|
624
|
+
if article_key:
|
|
625
|
+
# Resolve embed keys via API
|
|
626
|
+
# Replace random keys with server-registered keys for iframe rendering
|
|
627
|
+
final_html = await resolve_embed_keys(session, html_body, str(article_key))
|
|
628
|
+
else:
|
|
629
|
+
# Fallback: proceed without embed resolution if key not available
|
|
630
|
+
logger.warning(
|
|
631
|
+
"Embed resolution skipped: article does not have a key. Embeds in article %s may not render correctly.",
|
|
632
|
+
article_id,
|
|
633
|
+
extra={"article_id": article_id},
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Build payload and save via draft_save endpoint
|
|
637
|
+
payload = _build_article_payload(article_input, final_html)
|
|
638
|
+
|
|
639
|
+
return await _execute_post(
|
|
640
|
+
session,
|
|
641
|
+
f"/v1/text_notes/draft_save?id={numeric_id}&is_temp_saved=true",
|
|
642
|
+
_create_draft_save_parser(
|
|
643
|
+
article_id,
|
|
644
|
+
numeric_id,
|
|
645
|
+
article_input.title,
|
|
646
|
+
final_html,
|
|
647
|
+
article_key_for_result,
|
|
648
|
+
),
|
|
649
|
+
payload=payload,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
async def get_article_via_api(
|
|
654
|
+
session: Session,
|
|
655
|
+
article_id: str,
|
|
656
|
+
) -> Article:
|
|
657
|
+
"""Get article content by ID via API.
|
|
658
|
+
|
|
659
|
+
Retrieves article content directly from the note.com API.
|
|
660
|
+
Faster and more reliable than browser-based retrieval.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
session: Authenticated session
|
|
664
|
+
article_id: Article key (e.g., "n1234567890ab").
|
|
665
|
+
Note: Key format is required due to note.com API limitations.
|
|
666
|
+
The /v3/notes/ endpoint does not support numeric IDs.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Article object with title, body (as Markdown), and status
|
|
670
|
+
|
|
671
|
+
Raises:
|
|
672
|
+
NoteAPIError: If API request fails or numeric ID is provided
|
|
673
|
+
"""
|
|
674
|
+
# Issue #154: /v3/notes/ endpoint does not support numeric IDs
|
|
675
|
+
if article_id.isdigit():
|
|
676
|
+
raise NoteAPIError(
|
|
677
|
+
code=ErrorCode.INVALID_INPUT,
|
|
678
|
+
message=(
|
|
679
|
+
f"Numeric article ID '{article_id}' is not supported. "
|
|
680
|
+
"Please use the article key format (e.g., 'n1234567890ab'). "
|
|
681
|
+
"You can get the article key from create_draft() or list_articles()."
|
|
682
|
+
),
|
|
683
|
+
details={"article_id": article_id},
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
from note_mcp.utils.html_to_markdown import html_to_markdown
|
|
687
|
+
|
|
688
|
+
article = await _execute_get(
|
|
689
|
+
session,
|
|
690
|
+
f"/v3/notes/{article_id}",
|
|
691
|
+
_parse_article_response,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# Issue #209: Check if article was deleted
|
|
695
|
+
# note.com API returns status='deleted' instead of 404 for deleted articles.
|
|
696
|
+
# We treat this as ARTICLE_NOT_FOUND because the content is no longer accessible.
|
|
697
|
+
if article.status == ArticleStatus.DELETED:
|
|
698
|
+
raise NoteAPIError(
|
|
699
|
+
code=ErrorCode.ARTICLE_NOT_FOUND,
|
|
700
|
+
message="Article has been deleted (status='deleted')",
|
|
701
|
+
details={"article_id": article_id},
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Convert HTML body to Markdown for consistent output
|
|
705
|
+
if article.body:
|
|
706
|
+
article = Article(
|
|
707
|
+
id=article.id,
|
|
708
|
+
key=article.key,
|
|
709
|
+
title=article.title,
|
|
710
|
+
body=html_to_markdown(article.body),
|
|
711
|
+
status=article.status,
|
|
712
|
+
tags=article.tags,
|
|
713
|
+
eyecatch_image_key=article.eyecatch_image_key,
|
|
714
|
+
prev_access_key=article.prev_access_key,
|
|
715
|
+
created_at=article.created_at,
|
|
716
|
+
updated_at=article.updated_at,
|
|
717
|
+
published_at=article.published_at,
|
|
718
|
+
url=article.url,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
return article
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
async def get_article(
|
|
725
|
+
session: Session,
|
|
726
|
+
article_id: str,
|
|
727
|
+
) -> Article:
|
|
728
|
+
"""Get article content by ID.
|
|
729
|
+
|
|
730
|
+
Retrieves article content via API.
|
|
731
|
+
Use this to retrieve existing content before editing.
|
|
732
|
+
|
|
733
|
+
Recommended workflow:
|
|
734
|
+
1. get_article(article_id) - retrieve current content
|
|
735
|
+
2. Edit content as needed
|
|
736
|
+
3. update_article(article_id, ...) - save changes
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
session: Authenticated session
|
|
740
|
+
article_id: ID of the article to retrieve
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
Article object with title, body (as Markdown), and status
|
|
744
|
+
|
|
745
|
+
Raises:
|
|
746
|
+
NoteAPIError: If API request fails
|
|
747
|
+
"""
|
|
748
|
+
return await get_article_via_api(session, article_id)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
async def list_articles(
|
|
752
|
+
session: Session,
|
|
753
|
+
status: ArticleStatus | None = None,
|
|
754
|
+
page: int = 1,
|
|
755
|
+
limit: int = 10,
|
|
756
|
+
) -> ArticleListResult:
|
|
757
|
+
"""List articles for the authenticated user.
|
|
758
|
+
|
|
759
|
+
Uses the note_list/contents endpoint which returns both drafts and
|
|
760
|
+
published articles for the authenticated user.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
session: Authenticated session
|
|
764
|
+
status: Filter by article status (draft, published, or None for all)
|
|
765
|
+
page: Page number (1-indexed)
|
|
766
|
+
limit: Number of articles per page (max 10)
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
ArticleListResult containing articles and pagination info
|
|
770
|
+
|
|
771
|
+
Raises:
|
|
772
|
+
NoteAPIError: If API request fails
|
|
773
|
+
"""
|
|
774
|
+
# Build query parameters for note_list endpoint
|
|
775
|
+
# This endpoint returns both drafts and published articles
|
|
776
|
+
params: dict[str, Any] = {
|
|
777
|
+
"page": page,
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
# Add status filter if specified
|
|
781
|
+
# Note: The note_list endpoint uses "publish_status" parameter
|
|
782
|
+
if status is not None:
|
|
783
|
+
params["publish_status"] = status.value
|
|
784
|
+
|
|
785
|
+
# Use note_list/contents endpoint for authenticated user's articles
|
|
786
|
+
# This endpoint requires authentication and returns both drafts and published
|
|
787
|
+
async with NoteAPIClient(session) as client:
|
|
788
|
+
response = await client.get("/v2/note_list/contents", params=params)
|
|
789
|
+
|
|
790
|
+
# Parse response
|
|
791
|
+
data = response.get("data", {})
|
|
792
|
+
|
|
793
|
+
# The endpoint returns notes (not contents) in data
|
|
794
|
+
contents = data.get("notes", [])
|
|
795
|
+
total_count = data.get("totalCount", len(contents))
|
|
796
|
+
is_last_page = data.get("isLastPage", True)
|
|
797
|
+
|
|
798
|
+
# Convert each article
|
|
799
|
+
articles: list[Article] = []
|
|
800
|
+
for item in contents:
|
|
801
|
+
article = from_api_response(item)
|
|
802
|
+
articles.append(article)
|
|
803
|
+
|
|
804
|
+
# Apply limit client-side if needed
|
|
805
|
+
articles = articles[:limit]
|
|
806
|
+
|
|
807
|
+
return ArticleListResult(
|
|
808
|
+
articles=articles,
|
|
809
|
+
total=total_count,
|
|
810
|
+
page=page,
|
|
811
|
+
has_more=not is_last_page,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
async def publish_article(
|
|
816
|
+
session: Session,
|
|
817
|
+
article_id: str | None = None,
|
|
818
|
+
article_input: ArticleInput | None = None,
|
|
819
|
+
tags: list[str] | None = None,
|
|
820
|
+
) -> Article:
|
|
821
|
+
"""Publish an article.
|
|
822
|
+
|
|
823
|
+
Either publishes an existing draft or creates and publishes a new article.
|
|
824
|
+
|
|
825
|
+
Args:
|
|
826
|
+
session: Authenticated session
|
|
827
|
+
article_id: ID of existing draft to publish (mutually exclusive with article_input)
|
|
828
|
+
article_input: New article content to create and publish (mutually exclusive with article_id)
|
|
829
|
+
tags: Tags to set on the article when publishing an existing draft (optional).
|
|
830
|
+
For new articles, use article_input.tags instead.
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
Published Article object
|
|
834
|
+
|
|
835
|
+
Raises:
|
|
836
|
+
ValueError: If neither or both article_id and article_input are provided
|
|
837
|
+
NoteAPIError: If API request fails
|
|
838
|
+
"""
|
|
839
|
+
if article_id is None and article_input is None:
|
|
840
|
+
raise ValueError("Either article_id or article_input must be provided")
|
|
841
|
+
|
|
842
|
+
if article_id is not None and article_input is not None:
|
|
843
|
+
raise ValueError("Cannot provide both article_id and article_input")
|
|
844
|
+
|
|
845
|
+
if article_id is not None:
|
|
846
|
+
# Publish existing draft
|
|
847
|
+
# Issue #250: Validate article_id format BEFORE making any API calls
|
|
848
|
+
# to prevent data inconsistency (article published but error returned)
|
|
849
|
+
if article_id.isdigit():
|
|
850
|
+
raise NoteAPIError(
|
|
851
|
+
code=ErrorCode.INVALID_INPUT,
|
|
852
|
+
message=(
|
|
853
|
+
f"Numeric article ID '{article_id}' is not supported. "
|
|
854
|
+
"Please use the article key format (e.g., 'n1234567890ab')."
|
|
855
|
+
),
|
|
856
|
+
details={"article_id": article_id},
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Issue #250: Use PUT /v1/text_notes/{numeric_id} instead of
|
|
860
|
+
# non-existent POST /v3/notes/{id}/publish endpoint
|
|
861
|
+
numeric_id = await _resolve_numeric_note_id(session, article_id)
|
|
862
|
+
|
|
863
|
+
async with NoteAPIClient(session) as client:
|
|
864
|
+
# Fetch article title (required for both draft_save and PUT)
|
|
865
|
+
article_response = await client.get(f"/v3/notes/{article_id}")
|
|
866
|
+
article_data = article_response.get("data", {})
|
|
867
|
+
# For drafts, title is in note_draft.name; for published, it's in name
|
|
868
|
+
article_title = article_data.get("name", "")
|
|
869
|
+
if not article_title:
|
|
870
|
+
note_draft = article_data.get("note_draft")
|
|
871
|
+
if isinstance(note_draft, dict):
|
|
872
|
+
article_title = note_draft.get("name", "")
|
|
873
|
+
# For drafts, prefer note_draft.body which has full HTML including headings
|
|
874
|
+
# data.body may be a stripped/sanitized version
|
|
875
|
+
# Use `or ""` to handle None values (key exists but value is None)
|
|
876
|
+
note_draft = article_data.get("note_draft")
|
|
877
|
+
if isinstance(note_draft, dict) and note_draft.get("body"):
|
|
878
|
+
article_body = note_draft.get("body") or ""
|
|
879
|
+
else:
|
|
880
|
+
article_body = article_data.get("body") or ""
|
|
881
|
+
|
|
882
|
+
# Publish the article
|
|
883
|
+
# Issue #252: PUT /v1/text_notes/{id} requires 'free_body' (not 'body')
|
|
884
|
+
# and hashtags in ["#tag1", "#tag2"] format (not dict format)
|
|
885
|
+
payload: dict[str, Any] = {
|
|
886
|
+
"name": article_title,
|
|
887
|
+
"free_body": article_body,
|
|
888
|
+
"body_length": len(article_body),
|
|
889
|
+
"status": "published",
|
|
890
|
+
"index": False,
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
# Add tags if provided (using publish format with # prefix)
|
|
894
|
+
if tags:
|
|
895
|
+
hashtags = _normalize_tags_for_publish(tags)
|
|
896
|
+
if hashtags:
|
|
897
|
+
payload["hashtags"] = hashtags
|
|
898
|
+
|
|
899
|
+
response = await client.put(f"/v1/text_notes/{numeric_id}", json=payload)
|
|
900
|
+
|
|
901
|
+
# Validate API response for logical failure
|
|
902
|
+
data = response.get("data", {})
|
|
903
|
+
if data.get("result") is False:
|
|
904
|
+
raise NoteAPIError(
|
|
905
|
+
code=ErrorCode.API_ERROR,
|
|
906
|
+
message="Failed to publish article: API returned failure",
|
|
907
|
+
details={"article_id": article_id, "response": response},
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return await get_article_via_api(session, article_id)
|
|
911
|
+
|
|
912
|
+
# Create and publish new article
|
|
913
|
+
assert article_input is not None # Type narrowing
|
|
914
|
+
html_body = markdown_to_html(article_input.body)
|
|
915
|
+
|
|
916
|
+
new_article_payload: dict[str, Any] = {
|
|
917
|
+
"name": article_input.title,
|
|
918
|
+
"body": html_body,
|
|
919
|
+
"status": "published",
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
# Add tags if present (using dict format for /v3/notes endpoint)
|
|
923
|
+
new_article_hashtags = _normalize_tags(article_input.tags)
|
|
924
|
+
if new_article_hashtags:
|
|
925
|
+
new_article_payload["hashtags"] = new_article_hashtags
|
|
926
|
+
|
|
927
|
+
return await _execute_post(
|
|
928
|
+
session,
|
|
929
|
+
"/v3/notes",
|
|
930
|
+
_parse_article_response,
|
|
931
|
+
payload=new_article_payload,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
# =============================================================================
|
|
936
|
+
# Issue #134: Preview Access Token Functions
|
|
937
|
+
# =============================================================================
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
async def get_preview_access_token(
|
|
941
|
+
session: Session,
|
|
942
|
+
article_key: str,
|
|
943
|
+
) -> str:
|
|
944
|
+
"""Get preview access token for a draft article.
|
|
945
|
+
|
|
946
|
+
Calls the note.com API to obtain a preview access token that allows
|
|
947
|
+
viewing draft articles without editor access.
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
session: Authenticated session
|
|
951
|
+
article_key: Article key (e.g., "n1234567890ab")
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
32-character hex preview access token
|
|
955
|
+
|
|
956
|
+
Raises:
|
|
957
|
+
NoteAPIError: If API request fails or token is missing from response
|
|
958
|
+
|
|
959
|
+
Example:
|
|
960
|
+
token = await get_preview_access_token(session, "n1234567890ab")
|
|
961
|
+
url = build_preview_url("n1234567890ab", token)
|
|
962
|
+
"""
|
|
963
|
+
async with NoteAPIClient(session) as client:
|
|
964
|
+
response = await client.post(
|
|
965
|
+
f"/v2/notes/{article_key}/access_tokens",
|
|
966
|
+
json={"key": article_key},
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
data = response.get("data", {})
|
|
970
|
+
token = data.get("preview_access_token")
|
|
971
|
+
|
|
972
|
+
if not token:
|
|
973
|
+
raise NoteAPIError(
|
|
974
|
+
code=ErrorCode.API_ERROR,
|
|
975
|
+
message=(
|
|
976
|
+
"Failed to get preview access token. "
|
|
977
|
+
"Possible causes: article does not exist, article is already published, "
|
|
978
|
+
"or insufficient permissions."
|
|
979
|
+
),
|
|
980
|
+
details={"article_key": article_key, "response": response},
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
return str(token)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def build_preview_url(article_key: str, preview_access_token: str) -> str:
|
|
987
|
+
"""Build direct preview URL from access token.
|
|
988
|
+
|
|
989
|
+
Constructs a URL that allows direct access to the draft article preview
|
|
990
|
+
without going through the editor UI.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
article_key: Article key (e.g., "n1234567890ab")
|
|
994
|
+
preview_access_token: 32-character hex token from API
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Direct preview URL
|
|
998
|
+
|
|
999
|
+
Example:
|
|
1000
|
+
url = build_preview_url("n123abc", "token123...")
|
|
1001
|
+
# url = "https://note.com/preview/n123abc?prev_access_key=token123..."
|
|
1002
|
+
"""
|
|
1003
|
+
return f"https://note.com/preview/{article_key}?prev_access_key={preview_access_token}"
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
# =============================================================================
|
|
1007
|
+
# Issue #141: Delete Draft Functions
|
|
1008
|
+
# =============================================================================
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
async def delete_draft(
|
|
1012
|
+
session: Session,
|
|
1013
|
+
article_key: str,
|
|
1014
|
+
*,
|
|
1015
|
+
confirm: bool = False,
|
|
1016
|
+
) -> DeleteResult | DeletePreview:
|
|
1017
|
+
"""Delete a draft article.
|
|
1018
|
+
|
|
1019
|
+
Deletes a draft article from note.com. Only draft articles can be deleted;
|
|
1020
|
+
published articles will raise an error.
|
|
1021
|
+
|
|
1022
|
+
This function implements a two-step confirmation flow:
|
|
1023
|
+
1. When confirm=False: Returns a DeletePreview with article info
|
|
1024
|
+
2. When confirm=True: Actually deletes the article
|
|
1025
|
+
|
|
1026
|
+
Args:
|
|
1027
|
+
session: Authenticated session
|
|
1028
|
+
article_key: Key of the article to delete (format: nXXXXXXXXXXXX)
|
|
1029
|
+
confirm: Confirmation flag (must be True to execute deletion)
|
|
1030
|
+
|
|
1031
|
+
Returns:
|
|
1032
|
+
DeletePreview when confirm=False (shows what will be deleted)
|
|
1033
|
+
DeleteResult when confirm=True (deletion result)
|
|
1034
|
+
|
|
1035
|
+
Raises:
|
|
1036
|
+
NoteAPIError: If article is published, not found, or API fails
|
|
1037
|
+
|
|
1038
|
+
Example:
|
|
1039
|
+
# Step 1: Preview what will be deleted
|
|
1040
|
+
preview = await delete_draft(session, "n1234567890ab", confirm=False)
|
|
1041
|
+
print(f"Will delete: {preview.article_title}")
|
|
1042
|
+
|
|
1043
|
+
# Step 2: Actually delete
|
|
1044
|
+
result = await delete_draft(session, "n1234567890ab", confirm=True)
|
|
1045
|
+
print(f"Deleted: {result.message}")
|
|
1046
|
+
"""
|
|
1047
|
+
return await delete_article(session, article_key, confirm=confirm, allow_published=False)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
async def delete_article(
|
|
1051
|
+
session: Session,
|
|
1052
|
+
article_key: str,
|
|
1053
|
+
*,
|
|
1054
|
+
confirm: bool = False,
|
|
1055
|
+
allow_published: bool = True,
|
|
1056
|
+
) -> DeleteResult | DeletePreview:
|
|
1057
|
+
"""Delete an article (draft or published).
|
|
1058
|
+
|
|
1059
|
+
Deletes an article from note.com. By default, both draft and published
|
|
1060
|
+
articles can be deleted. Set allow_published=False to restrict to drafts only.
|
|
1061
|
+
|
|
1062
|
+
This function implements a two-step confirmation flow:
|
|
1063
|
+
1. When confirm=False: Returns a DeletePreview with article info
|
|
1064
|
+
2. When confirm=True: Actually deletes the article
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
session: Authenticated session
|
|
1068
|
+
article_key: Key of the article to delete (format: nXXXXXXXXXXXX)
|
|
1069
|
+
confirm: Confirmation flag (must be True to execute deletion)
|
|
1070
|
+
allow_published: If False, raises error when article is published
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
DeletePreview when confirm=False (shows what will be deleted)
|
|
1074
|
+
DeleteResult when confirm=True (deletion result)
|
|
1075
|
+
|
|
1076
|
+
Raises:
|
|
1077
|
+
NoteAPIError: If article is published (and allow_published=False),
|
|
1078
|
+
not found, or API fails
|
|
1079
|
+
|
|
1080
|
+
Example:
|
|
1081
|
+
# Step 1: Preview what will be deleted
|
|
1082
|
+
preview = await delete_article(session, "n1234567890ab", confirm=False)
|
|
1083
|
+
print(f"Will delete: {preview.article_title}")
|
|
1084
|
+
|
|
1085
|
+
# Step 2: Actually delete
|
|
1086
|
+
result = await delete_article(session, "n1234567890ab", confirm=True)
|
|
1087
|
+
print(f"Deleted: {result.message}")
|
|
1088
|
+
"""
|
|
1089
|
+
# Import here to avoid circular imports
|
|
1090
|
+
from note_mcp.models import (
|
|
1091
|
+
DELETE_ERROR_PUBLISHED_ARTICLE,
|
|
1092
|
+
DeletePreview,
|
|
1093
|
+
DeleteResult,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
# Step 1: Fetch article info to validate and get details
|
|
1097
|
+
article = await _execute_get(
|
|
1098
|
+
session,
|
|
1099
|
+
f"/v3/notes/{article_key}",
|
|
1100
|
+
_parse_article_response,
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
# Check if article is published and not allowed
|
|
1104
|
+
if article.status == ArticleStatus.PUBLISHED and not allow_published:
|
|
1105
|
+
raise NoteAPIError(
|
|
1106
|
+
code=ErrorCode.API_ERROR,
|
|
1107
|
+
message=DELETE_ERROR_PUBLISHED_ARTICLE,
|
|
1108
|
+
details={"article_key": article_key, "status": article.status.value},
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
# Issue #209: Check if article was already deleted
|
|
1112
|
+
# note.com API returns status='deleted' instead of 404 for deleted articles.
|
|
1113
|
+
# We treat this as ARTICLE_NOT_FOUND because attempting to delete an
|
|
1114
|
+
# already deleted article is nonsensical.
|
|
1115
|
+
if article.status == ArticleStatus.DELETED:
|
|
1116
|
+
raise NoteAPIError(
|
|
1117
|
+
code=ErrorCode.ARTICLE_NOT_FOUND,
|
|
1118
|
+
message="Article has been deleted (status='deleted')",
|
|
1119
|
+
details={"article_key": article_key},
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
# If confirm=False, return preview without deleting
|
|
1123
|
+
if not confirm:
|
|
1124
|
+
status_label = "公開記事" if article.status == ArticleStatus.PUBLISHED else "下書き記事"
|
|
1125
|
+
return DeletePreview(
|
|
1126
|
+
article_id=article.id,
|
|
1127
|
+
article_key=article.key,
|
|
1128
|
+
article_title=article.title,
|
|
1129
|
+
status=article.status,
|
|
1130
|
+
message=(
|
|
1131
|
+
f"{status_label}「{article.title}」を削除しますか?confirm=True を指定して再度呼び出してください。"
|
|
1132
|
+
),
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
# Step 2: Execute deletion (confirm=True)
|
|
1136
|
+
# Note: The delete endpoint requires /n/ prefix before the article key
|
|
1137
|
+
await _execute_delete(session, f"/v1/notes/n/{article_key}")
|
|
1138
|
+
|
|
1139
|
+
status_label = "公開記事" if article.status == ArticleStatus.PUBLISHED else "下書き記事"
|
|
1140
|
+
return DeleteResult(
|
|
1141
|
+
success=True,
|
|
1142
|
+
article_id=article.id,
|
|
1143
|
+
article_key=article.key,
|
|
1144
|
+
article_title=article.title,
|
|
1145
|
+
message=f"{status_label}「{article.title}」({article.key})を削除しました。",
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
async def unpublish_article(
|
|
1150
|
+
session: Session,
|
|
1151
|
+
article_key: str,
|
|
1152
|
+
) -> Article:
|
|
1153
|
+
"""Unpublish an article (revert published article to draft).
|
|
1154
|
+
|
|
1155
|
+
Changes a published article's status back to draft. The article content
|
|
1156
|
+
is preserved. Only published articles can be unpublished.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
session: Authenticated session
|
|
1160
|
+
article_key: Key of the article to unpublish (format: nXXXXXXXXXXXX)
|
|
1161
|
+
|
|
1162
|
+
Returns:
|
|
1163
|
+
Article object with updated draft status
|
|
1164
|
+
|
|
1165
|
+
Raises:
|
|
1166
|
+
NoteAPIError: If article is already a draft, not found, or API fails
|
|
1167
|
+
|
|
1168
|
+
Example:
|
|
1169
|
+
article = await unpublish_article(session, "n1234567890ab")
|
|
1170
|
+
print(f"Reverted to draft: {article.title}")
|
|
1171
|
+
"""
|
|
1172
|
+
# Validate article key format
|
|
1173
|
+
if article_key.isdigit():
|
|
1174
|
+
raise NoteAPIError(
|
|
1175
|
+
code=ErrorCode.INVALID_INPUT,
|
|
1176
|
+
message=(
|
|
1177
|
+
f"Numeric article ID '{article_key}' is not supported. "
|
|
1178
|
+
"Please use the article key format (e.g., 'n1234567890ab')."
|
|
1179
|
+
),
|
|
1180
|
+
details={"article_key": article_key},
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
# Fetch article info
|
|
1184
|
+
article = await _execute_get(
|
|
1185
|
+
session,
|
|
1186
|
+
f"/v3/notes/{article_key}",
|
|
1187
|
+
_parse_article_response,
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
# Check status
|
|
1191
|
+
if article.status == ArticleStatus.DELETED:
|
|
1192
|
+
raise NoteAPIError(
|
|
1193
|
+
code=ErrorCode.ARTICLE_NOT_FOUND,
|
|
1194
|
+
message="Article has been deleted (status='deleted')",
|
|
1195
|
+
details={"article_key": article_key},
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
if article.status == ArticleStatus.DRAFT:
|
|
1199
|
+
raise NoteAPIError(
|
|
1200
|
+
code=ErrorCode.API_ERROR,
|
|
1201
|
+
message="Article is already a draft",
|
|
1202
|
+
details={"article_key": article_key, "status": article.status.value},
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
# Resolve numeric ID for PUT endpoint
|
|
1206
|
+
numeric_id = await _resolve_numeric_note_id(session, article_key)
|
|
1207
|
+
|
|
1208
|
+
# Get article body for PUT
|
|
1209
|
+
async with NoteAPIClient(session) as client:
|
|
1210
|
+
article_response = await client.get(f"/v3/notes/{article_key}")
|
|
1211
|
+
article_data = article_response.get("data", {})
|
|
1212
|
+
article_title = article_data.get("name", "")
|
|
1213
|
+
if not article_title:
|
|
1214
|
+
note_draft = article_data.get("note_draft")
|
|
1215
|
+
if isinstance(note_draft, dict):
|
|
1216
|
+
article_title = note_draft.get("name", "")
|
|
1217
|
+
article_body = article_data.get("body") or ""
|
|
1218
|
+
|
|
1219
|
+
# PUT with status=draft to unpublish
|
|
1220
|
+
payload: dict[str, Any] = {
|
|
1221
|
+
"name": article_title,
|
|
1222
|
+
"free_body": article_body,
|
|
1223
|
+
"body_length": len(article_body),
|
|
1224
|
+
"status": "draft",
|
|
1225
|
+
"index": False,
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
response = await client.put(f"/v1/text_notes/{numeric_id}", json=payload)
|
|
1229
|
+
|
|
1230
|
+
# Validate API response
|
|
1231
|
+
data = response.get("data", {})
|
|
1232
|
+
if data.get("result") is False:
|
|
1233
|
+
raise NoteAPIError(
|
|
1234
|
+
code=ErrorCode.API_ERROR,
|
|
1235
|
+
message="Failed to unpublish article: API returned failure",
|
|
1236
|
+
details={"article_key": article_key, "response": response},
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
return await get_article_via_api(session, article_key)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
async def delete_all_drafts(
|
|
1243
|
+
session: Session,
|
|
1244
|
+
*,
|
|
1245
|
+
confirm: bool = False,
|
|
1246
|
+
) -> BulkDeleteResult | BulkDeletePreview:
|
|
1247
|
+
"""Delete all draft articles.
|
|
1248
|
+
|
|
1249
|
+
Deletes all draft articles for the authenticated user.
|
|
1250
|
+
Implements a two-step confirmation flow for safety.
|
|
1251
|
+
|
|
1252
|
+
This function:
|
|
1253
|
+
1. Fetches all drafts using list_articles(status=DRAFT)
|
|
1254
|
+
2. When confirm=False: Returns a BulkDeletePreview listing all drafts
|
|
1255
|
+
3. When confirm=True: Sequentially deletes each draft
|
|
1256
|
+
|
|
1257
|
+
Args:
|
|
1258
|
+
session: Authenticated session
|
|
1259
|
+
confirm: Confirmation flag (must be True to execute deletion)
|
|
1260
|
+
|
|
1261
|
+
Returns:
|
|
1262
|
+
BulkDeletePreview when confirm=False (shows what will be deleted)
|
|
1263
|
+
BulkDeleteResult when confirm=True (deletion results with success/failure counts)
|
|
1264
|
+
|
|
1265
|
+
Example:
|
|
1266
|
+
# Step 1: Preview what will be deleted
|
|
1267
|
+
preview = await delete_all_drafts(session, confirm=False)
|
|
1268
|
+
print(f"Will delete {preview.total_count} drafts")
|
|
1269
|
+
|
|
1270
|
+
# Step 2: Actually delete all
|
|
1271
|
+
result = await delete_all_drafts(session, confirm=True)
|
|
1272
|
+
print(f"Deleted: {result.deleted_count}, Failed: {result.failed_count}")
|
|
1273
|
+
"""
|
|
1274
|
+
from note_mcp.models import (
|
|
1275
|
+
ArticleSummary,
|
|
1276
|
+
BulkDeletePreview,
|
|
1277
|
+
BulkDeleteResult,
|
|
1278
|
+
FailedArticle,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
# Step 1: Get all drafts (paginate through all pages)
|
|
1282
|
+
article_summaries: list[ArticleSummary] = []
|
|
1283
|
+
page = 1
|
|
1284
|
+
|
|
1285
|
+
async with NoteAPIClient(session) as client:
|
|
1286
|
+
while page <= DELETE_ALL_DRAFTS_MAX_PAGES:
|
|
1287
|
+
response = await client.get(
|
|
1288
|
+
"/v2/note_list/contents",
|
|
1289
|
+
params={"publish_status": "draft", "page": page},
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
data = response.get("data", {})
|
|
1293
|
+
notes = data.get("notes", [])
|
|
1294
|
+
|
|
1295
|
+
# No more notes, stop pagination
|
|
1296
|
+
if not notes:
|
|
1297
|
+
break
|
|
1298
|
+
|
|
1299
|
+
# Build article summaries for this page
|
|
1300
|
+
# Article 6: Required fields (id, key) must be present, skip invalid notes
|
|
1301
|
+
for note in notes:
|
|
1302
|
+
note_id = note.get("id")
|
|
1303
|
+
note_key = note.get("key")
|
|
1304
|
+
|
|
1305
|
+
# Skip notes with missing required fields (Article 6 compliance)
|
|
1306
|
+
if not note_id or not note_key:
|
|
1307
|
+
logger.warning(
|
|
1308
|
+
"Skipping note with missing required field(s)",
|
|
1309
|
+
extra={
|
|
1310
|
+
"note_id": note_id,
|
|
1311
|
+
"note_key": note_key,
|
|
1312
|
+
"note_name": note.get("name"),
|
|
1313
|
+
},
|
|
1314
|
+
)
|
|
1315
|
+
continue
|
|
1316
|
+
|
|
1317
|
+
article_summaries.append(
|
|
1318
|
+
ArticleSummary(
|
|
1319
|
+
article_id=str(note_id),
|
|
1320
|
+
article_key=str(note_key),
|
|
1321
|
+
# title is display-only, empty string is valid
|
|
1322
|
+
title=str(note.get("name") or ""),
|
|
1323
|
+
)
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
page += 1
|
|
1327
|
+
|
|
1328
|
+
total_count = len(article_summaries)
|
|
1329
|
+
|
|
1330
|
+
# If no drafts, return early
|
|
1331
|
+
if total_count == 0:
|
|
1332
|
+
if not confirm:
|
|
1333
|
+
return BulkDeletePreview(
|
|
1334
|
+
total_count=0,
|
|
1335
|
+
articles=[],
|
|
1336
|
+
message="削除対象の下書きがありません。",
|
|
1337
|
+
)
|
|
1338
|
+
return BulkDeleteResult(
|
|
1339
|
+
success=True,
|
|
1340
|
+
total_count=0,
|
|
1341
|
+
deleted_count=0,
|
|
1342
|
+
failed_count=0,
|
|
1343
|
+
deleted_articles=[],
|
|
1344
|
+
failed_articles=[],
|
|
1345
|
+
message="削除対象の下書きがありません。",
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
# If confirm=False, return preview
|
|
1349
|
+
if not confirm:
|
|
1350
|
+
return BulkDeletePreview(
|
|
1351
|
+
total_count=total_count,
|
|
1352
|
+
articles=article_summaries[:DELETE_ALL_DRAFTS_PREVIEW_LIMIT],
|
|
1353
|
+
message=f"{total_count}件の下書き記事を削除しますか?confirm=True を指定して再度呼び出してください。",
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
# Step 2: Execute deletion (confirm=True)
|
|
1357
|
+
deleted_articles: list[ArticleSummary] = []
|
|
1358
|
+
failed_articles: list[FailedArticle] = []
|
|
1359
|
+
|
|
1360
|
+
async with NoteAPIClient(session) as client:
|
|
1361
|
+
for summary in article_summaries:
|
|
1362
|
+
try:
|
|
1363
|
+
await client.delete(f"/v1/notes/n/{summary.article_key}")
|
|
1364
|
+
deleted_articles.append(summary)
|
|
1365
|
+
except NoteAPIError as e:
|
|
1366
|
+
failed_articles.append(
|
|
1367
|
+
FailedArticle(
|
|
1368
|
+
article_id=summary.article_id,
|
|
1369
|
+
article_key=summary.article_key,
|
|
1370
|
+
title=summary.title,
|
|
1371
|
+
error=e.message,
|
|
1372
|
+
)
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
deleted_count = len(deleted_articles)
|
|
1376
|
+
failed_count = len(failed_articles)
|
|
1377
|
+
success = failed_count == 0
|
|
1378
|
+
|
|
1379
|
+
# Build result message
|
|
1380
|
+
if failed_count == 0:
|
|
1381
|
+
message = f"{deleted_count}件の下書き記事を削除しました。"
|
|
1382
|
+
else:
|
|
1383
|
+
message = (
|
|
1384
|
+
f"{total_count}件中{deleted_count}件の下書き記事を削除しました。{failed_count}件の削除に失敗しました。"
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
return BulkDeleteResult(
|
|
1388
|
+
success=success,
|
|
1389
|
+
total_count=total_count,
|
|
1390
|
+
deleted_count=deleted_count,
|
|
1391
|
+
failed_count=failed_count,
|
|
1392
|
+
deleted_articles=deleted_articles,
|
|
1393
|
+
failed_articles=failed_articles,
|
|
1394
|
+
message=message,
|
|
1395
|
+
)
|