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,456 @@
1
+ """Image upload operations for note.com API.
2
+
3
+ Provides functionality for uploading images to note.com.
4
+ Supports both eyecatch (header) images and body (inline) images.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+ import time
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from note_mcp.api.client import NoteAPIClient
16
+ from note_mcp.models import ErrorCode, Image, ImageType, NoteAPIError, Session
17
+
18
+ if TYPE_CHECKING:
19
+ pass
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ async def _resolve_numeric_note_id(session: Session, note_id: str) -> str:
25
+ """Resolve note ID to numeric format.
26
+
27
+ The image upload API requires numeric note IDs.
28
+ This function converts key format IDs (e.g., "ne1c111d2073c") to numeric IDs.
29
+
30
+ Args:
31
+ session: Authenticated session
32
+ note_id: Note ID in either numeric or key format
33
+
34
+ Returns:
35
+ Numeric note ID as string
36
+
37
+ Raises:
38
+ NoteAPIError: If ID resolution fails
39
+ """
40
+ # If already numeric, return as-is
41
+ if note_id.isdigit():
42
+ return note_id
43
+
44
+ # Key format IDs start with "n" followed by alphanumeric characters
45
+ if not re.match(r"^n[a-z0-9]+$", note_id):
46
+ raise NoteAPIError(
47
+ code=ErrorCode.INVALID_INPUT,
48
+ message=f"Invalid note ID format: {note_id}",
49
+ details={"note_id": note_id},
50
+ )
51
+
52
+ # Fetch article details to get numeric ID
53
+ async with NoteAPIClient(session) as client:
54
+ response = await client.get(f"/v3/notes/{note_id}")
55
+
56
+ # Extract numeric ID from response
57
+ data = response.get("data", {})
58
+ numeric_id = data.get("id")
59
+
60
+ if not numeric_id:
61
+ raise NoteAPIError(
62
+ code=ErrorCode.API_ERROR,
63
+ message=f"Failed to resolve note ID: {note_id}",
64
+ details={"note_id": note_id, "response": response},
65
+ )
66
+
67
+ return str(numeric_id)
68
+
69
+
70
+ # Allowed image file extensions
71
+ ALLOWED_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
72
+
73
+ # Maximum file size in bytes (10MB)
74
+ MAX_FILE_SIZE: int = 10 * 1024 * 1024
75
+
76
+ # Content-type mapping for image files (single source of truth - DRY)
77
+ CONTENT_TYPE_MAP: dict[str, str] = {
78
+ ".jpg": "image/jpeg",
79
+ ".jpeg": "image/jpeg",
80
+ ".png": "image/png",
81
+ ".gif": "image/gif",
82
+ ".webp": "image/webp",
83
+ }
84
+
85
+ # API endpoints for different image types
86
+ # Note: Body images use the same endpoint as eyecatch images.
87
+ # The returned URL can be embedded in article body using Markdown syntax.
88
+ IMAGE_UPLOAD_ENDPOINTS: dict[ImageType, str] = {
89
+ ImageType.EYECATCH: "/v1/image_upload/note_eyecatch",
90
+ ImageType.BODY: "/v1/image_upload/note_eyecatch", # Same endpoint - URL works for body embedding
91
+ }
92
+
93
+
94
+ def validate_image_file(file_path: str) -> None:
95
+ """Validate image file before upload.
96
+
97
+ Args:
98
+ file_path: Path to the image file
99
+
100
+ Raises:
101
+ NoteAPIError: If file is invalid (not found, wrong format, too large)
102
+ """
103
+ path = Path(file_path)
104
+
105
+ # Check file exists
106
+ if not path.exists():
107
+ raise NoteAPIError(
108
+ code=ErrorCode.INVALID_INPUT,
109
+ message=f"File not found: {file_path}",
110
+ details={"file_path": file_path},
111
+ )
112
+
113
+ # Check file extension
114
+ if path.suffix.lower() not in ALLOWED_EXTENSIONS:
115
+ raise NoteAPIError(
116
+ code=ErrorCode.INVALID_INPUT,
117
+ message=(f"Invalid file format: {path.suffix}. Allowed formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}"),
118
+ details={"file_path": file_path, "extension": path.suffix},
119
+ )
120
+
121
+ # Check file size
122
+ file_size = path.stat().st_size
123
+ if file_size > MAX_FILE_SIZE:
124
+ raise NoteAPIError(
125
+ code=ErrorCode.INVALID_INPUT,
126
+ message=(f"File size ({file_size} bytes) exceeds maximum allowed size ({MAX_FILE_SIZE} bytes)"),
127
+ details={"file_path": file_path, "size": file_size, "max_size": MAX_FILE_SIZE},
128
+ )
129
+
130
+
131
+ async def _upload_image_internal(
132
+ session: Session,
133
+ file_path: str,
134
+ note_id: str,
135
+ image_type: ImageType,
136
+ ) -> Image:
137
+ """Internal function for uploading an image to note.com.
138
+
139
+ Validates the file format and size before uploading.
140
+ Uses multipart/form-data for the upload.
141
+
142
+ Args:
143
+ session: Authenticated session
144
+ file_path: Path to the image file
145
+ note_id: The note ID to associate the image with (numeric or key format)
146
+ image_type: Type of image (eyecatch or body)
147
+
148
+ Returns:
149
+ Image object with upload result
150
+
151
+ Raises:
152
+ NoteAPIError: If validation fails or API request fails
153
+ """
154
+ # Validate file before upload
155
+ validate_image_file(file_path)
156
+
157
+ # Resolve note ID to numeric format (API requirement)
158
+ numeric_note_id = await _resolve_numeric_note_id(session, note_id)
159
+
160
+ path = Path(file_path)
161
+ file_size = path.stat().st_size
162
+
163
+ # Prepare file for multipart upload
164
+ with open(file_path, "rb") as f:
165
+ file_content = f.read()
166
+
167
+ # Determine content type based on extension
168
+ content_type = CONTENT_TYPE_MAP.get(path.suffix.lower(), "application/octet-stream")
169
+
170
+ # Prepare files for multipart request
171
+ files = {
172
+ "file": (path.name, file_content, content_type),
173
+ }
174
+
175
+ # note_id is required by the API (must be numeric)
176
+ data = {"note_id": numeric_note_id}
177
+
178
+ # Get endpoint for the image type
179
+ endpoint = IMAGE_UPLOAD_ENDPOINTS[image_type]
180
+
181
+ async with NoteAPIClient(session) as client:
182
+ response = await client.post(endpoint, files=files, data=data)
183
+
184
+ # Parse response - Article 6: validate required fields, no fallback
185
+ image_data = response.get("data", {})
186
+
187
+ # Note: The eyecatch upload endpoint (/v1/image_upload/note_eyecatch) returns
188
+ # only 'url' in the response, not 'key'. This is expected behavior based on
189
+ # API testing - body images (via presigned_post) return 'key', eyecatch does not.
190
+ image_key = image_data.get("key")
191
+
192
+ image_url = image_data.get("url")
193
+ if not image_url:
194
+ raise NoteAPIError(
195
+ code=ErrorCode.API_ERROR,
196
+ message="Image upload failed: API response missing required field 'url'",
197
+ details={"response": response},
198
+ )
199
+
200
+ return Image(
201
+ key=str(image_key) if image_key else None,
202
+ url=str(image_url),
203
+ original_path=file_path,
204
+ size_bytes=file_size,
205
+ uploaded_at=int(time.time()),
206
+ image_type=image_type,
207
+ )
208
+
209
+
210
+ async def upload_eyecatch_image(
211
+ session: Session,
212
+ file_path: str,
213
+ note_id: str,
214
+ ) -> Image:
215
+ """Upload an eyecatch (header) image to note.com.
216
+
217
+ Validates the file format and size before uploading.
218
+ Uses multipart/form-data for the upload.
219
+
220
+ Args:
221
+ session: Authenticated session
222
+ file_path: Path to the image file
223
+ note_id: The note ID to associate the image with (required by API)
224
+
225
+ Returns:
226
+ Image object with upload result
227
+
228
+ Raises:
229
+ NoteAPIError: If validation fails or API request fails
230
+ """
231
+ return await _upload_image_internal(session, file_path, note_id, ImageType.EYECATCH)
232
+
233
+
234
+ async def upload_body_image(
235
+ session: Session,
236
+ file_path: str,
237
+ note_id: str,
238
+ ) -> Image:
239
+ """Upload a body (inline) image to note.com.
240
+
241
+ Uses the presigned_post flow to upload directly to S3.
242
+ This does NOT update the eyecatch image (unlike the eyecatch endpoint).
243
+ The returned URL can be embedded in article body using Markdown syntax:
244
+ ![alt text](returned_url)
245
+
246
+ Args:
247
+ session: Authenticated session
248
+ file_path: Path to the image file
249
+ note_id: The note ID to associate the image with (for metadata only)
250
+
251
+ Returns:
252
+ Image object with upload result
253
+
254
+ Raises:
255
+ NoteAPIError: If validation fails or API request fails
256
+ """
257
+ import httpx
258
+
259
+ # Validate file before upload
260
+ validate_image_file(file_path)
261
+
262
+ path = Path(file_path)
263
+ file_size = path.stat().st_size
264
+
265
+ # Step 1: Get presigned POST URL from note.com
266
+ async with NoteAPIClient(session) as client:
267
+ response = await client.post(
268
+ "/v3/images/upload/presigned_post",
269
+ data={"filename": path.name},
270
+ )
271
+
272
+ presigned_data = response.get("data", {})
273
+ s3_url = presigned_data.get("action")
274
+ image_url = presigned_data.get("url")
275
+ post_fields = presigned_data.get("post", {})
276
+
277
+ if not s3_url or not image_url or not post_fields:
278
+ raise NoteAPIError(
279
+ code=ErrorCode.API_ERROR,
280
+ message="Failed to get presigned URL for image upload",
281
+ details={"response": response},
282
+ )
283
+
284
+ # Step 2: Upload file directly to S3
285
+ with open(file_path, "rb") as f:
286
+ file_content = f.read()
287
+
288
+ # Article 6: Validate required S3 presigned POST fields
289
+ required_s3_fields = [
290
+ "key",
291
+ "policy",
292
+ "x-amz-credential",
293
+ "x-amz-algorithm",
294
+ "x-amz-date",
295
+ "x-amz-signature",
296
+ ]
297
+ for field in required_s3_fields:
298
+ if not post_fields.get(field):
299
+ raise NoteAPIError(
300
+ code=ErrorCode.API_ERROR,
301
+ message=f"Presigned POST missing required S3 field: {field}",
302
+ details={"response": response, "missing_field": field},
303
+ )
304
+
305
+ # Build multipart form data with S3 required fields
306
+ # Order matters for S3 - policy fields first, then file
307
+ files_data: dict[str, tuple[None, str] | tuple[str, bytes, str]] = {
308
+ "key": (None, str(post_fields["key"])),
309
+ "acl": (None, str(post_fields.get("acl", ""))),
310
+ "Expires": (None, str(post_fields.get("Expires", ""))),
311
+ "policy": (None, str(post_fields["policy"])),
312
+ "x-amz-credential": (None, str(post_fields["x-amz-credential"])),
313
+ "x-amz-algorithm": (None, str(post_fields["x-amz-algorithm"])),
314
+ "x-amz-date": (None, str(post_fields["x-amz-date"])),
315
+ "x-amz-signature": (None, str(post_fields["x-amz-signature"])),
316
+ }
317
+
318
+ # Determine content type
319
+ content_type = CONTENT_TYPE_MAP.get(path.suffix.lower(), "application/octet-stream")
320
+
321
+ # Add file last (S3 requirement)
322
+ files_data["file"] = (path.name, file_content, content_type)
323
+
324
+ async with httpx.AsyncClient() as http_client:
325
+ s3_response = await http_client.post(s3_url, files=files_data)
326
+
327
+ if not s3_response.is_success:
328
+ raise NoteAPIError(
329
+ code=ErrorCode.API_ERROR,
330
+ message=f"Failed to upload image to S3: {s3_response.status_code}",
331
+ details={"status": s3_response.status_code, "response": s3_response.text},
332
+ )
333
+
334
+ # key is validated above in required_s3_fields check
335
+ return Image(
336
+ key=str(post_fields["key"]),
337
+ url=image_url,
338
+ original_path=file_path,
339
+ size_bytes=file_size,
340
+ uploaded_at=int(time.time()),
341
+ image_type=ImageType.BODY,
342
+ )
343
+
344
+
345
+ async def insert_image_via_api(
346
+ session: Session,
347
+ article_id: str,
348
+ file_path: str,
349
+ caption: str | None = None,
350
+ ) -> dict[str, Any]:
351
+ """Insert an image into an article via API.
352
+
353
+ Fully API-based implementation without Playwright dependency.
354
+ This is faster and more reliable than browser-based insertion.
355
+
356
+ Flow:
357
+ 1. Validate image file
358
+ 2. Get article with raw HTML body
359
+ 3. Upload image to S3 via API
360
+ 4. Generate figure HTML
361
+ 5. Append to existing body
362
+ 6. Update article via draft_save API
363
+
364
+ Args:
365
+ session: Authenticated session
366
+ article_id: Article key (e.g., "n1234567890ab").
367
+ Note: Key format is required due to note.com API limitations.
368
+ The /v3/notes/ endpoint does not support numeric IDs.
369
+ Use the article key returned from create_draft() or list_articles().
370
+ file_path: Path to the image file to insert
371
+ caption: Optional caption for the image
372
+
373
+ Returns:
374
+ Dictionary with the following keys:
375
+ - success: Always True on success (raises on failure)
376
+ - article_id: Numeric article ID
377
+ - article_key: Article key (e.g., "n1234567890ab")
378
+ - file_path: Path to the uploaded file
379
+ - image_url: URL of the uploaded image on note.com CDN
380
+ - caption: Caption text (if provided)
381
+ - fallback_used: Always False (no browser fallback in API-only mode)
382
+
383
+ Raises:
384
+ NoteAPIError: If image insertion fails
385
+ """
386
+ # Import here to avoid circular imports
387
+ from note_mcp.api.articles import (
388
+ append_image_to_body,
389
+ generate_image_html,
390
+ get_article_raw_html,
391
+ update_article_raw_html,
392
+ )
393
+
394
+ # Step 1: Validate file (existence, extension, and size)
395
+ validate_image_file(file_path)
396
+
397
+ # Step 2: Validate article_id format
398
+ # Issue #147: /v3/notes/ endpoint does not support numeric IDs
399
+ if article_id.isdigit():
400
+ raise NoteAPIError(
401
+ code=ErrorCode.INVALID_INPUT,
402
+ message=(
403
+ f"Numeric article ID '{article_id}' is not supported. "
404
+ "Please use the article key format (e.g., 'n1234567890ab'). "
405
+ "You can get the article key from create_draft() or list_articles()."
406
+ ),
407
+ details={"article_id": article_id},
408
+ )
409
+
410
+ # Step 3: Get article with raw HTML body
411
+ try:
412
+ article = await get_article_raw_html(session, article_id)
413
+ except NoteAPIError as e:
414
+ raise NoteAPIError(
415
+ code=ErrorCode.INVALID_INPUT,
416
+ message=f"Invalid article ID: {article_id}. Please verify the article exists and you have access.",
417
+ details={"article_id": article_id, "original_error": str(e)},
418
+ ) from e
419
+
420
+ article_key = article.key
421
+ numeric_id = article.id
422
+ logger.debug(f"Article validated: key={article_key}, numeric_id={numeric_id}")
423
+
424
+ # Step 3: Upload image via API
425
+ image = await upload_body_image(session, file_path, numeric_id)
426
+ logger.info(f"Image uploaded via API: {image.url[:50]}...")
427
+
428
+ # Step 4: Generate image HTML in note.com format
429
+ image_html = generate_image_html(
430
+ image_url=image.url,
431
+ caption=caption or "",
432
+ )
433
+ logger.debug(f"Generated image HTML: {image_html[:100]}...")
434
+
435
+ # Step 5: Append image to existing body
436
+ new_body_html = append_image_to_body(article.body or "", image_html)
437
+ logger.debug(f"New body length: {len(new_body_html)} chars")
438
+
439
+ # Step 6: Update article via API (draft_save)
440
+ await update_article_raw_html(
441
+ session=session,
442
+ article_id=numeric_id,
443
+ title=article.title,
444
+ html_body=new_body_html,
445
+ )
446
+ logger.info("Article updated via API")
447
+
448
+ return {
449
+ "success": True,
450
+ "article_id": numeric_id,
451
+ "article_key": article_key,
452
+ "file_path": file_path,
453
+ "image_url": image.url,
454
+ "caption": caption,
455
+ "fallback_used": False, # No fallback in API-only mode
456
+ }
@@ -0,0 +1,142 @@
1
+ """Preview API functions for note.com.
2
+
3
+ Provides functionality to get preview access tokens
4
+ and fetch preview page HTML.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import TYPE_CHECKING
12
+
13
+ import httpx
14
+
15
+ from note_mcp.api.articles import build_preview_url, get_preview_access_token
16
+ from note_mcp.models import ErrorCode, NoteAPIError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ if TYPE_CHECKING:
21
+ from note_mcp.models import Session
22
+
23
+
24
+ # Re-export for convenience
25
+ __all__ = ["get_preview_access_token", "build_preview_url", "get_preview_html"]
26
+
27
+ # Common User-Agent string for API requests
28
+ USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
29
+
30
+ # Retry configuration for transient errors
31
+ MAX_TRANSIENT_RETRIES = 3 # Maximum retries for transient errors (502/503/504)
32
+ BASE_DELAY = 0.5 # Initial backoff delay in seconds
33
+ MAX_DELAY = 4.0 # Maximum backoff delay in seconds
34
+
35
+
36
+ async def get_preview_html(
37
+ session: Session,
38
+ article_key: str,
39
+ ) -> str:
40
+ """Fetch preview page HTML for an article.
41
+
42
+ Gets preview access token via API and fetches the preview page HTML.
43
+ Useful for E2E testing and content verification.
44
+
45
+ Retry behavior:
46
+ - Authentication errors (401/403): Retries once with a fresh token
47
+ - Transient server errors (502/503/504): Retries with exponential backoff
48
+
49
+ Args:
50
+ session: Authenticated session
51
+ article_key: Article key (e.g., "n1234567890ab")
52
+
53
+ Returns:
54
+ Preview page HTML as string
55
+
56
+ Raises:
57
+ NoteAPIError: If token fetch or HTML fetch fails after all retries
58
+ """
59
+ # Build cookie header
60
+ cookie_parts = [f"{k}={v}" for k, v in session.cookies.items()]
61
+ cookies_header = "; ".join(cookie_parts)
62
+
63
+ # HTTP headers for requests
64
+ headers = {
65
+ "Cookie": cookies_header,
66
+ "User-Agent": USER_AGENT,
67
+ }
68
+
69
+ # Auth error status codes that trigger token refresh retry
70
+ auth_error_codes = {401, 403}
71
+
72
+ # Transient server error codes that trigger backoff retry
73
+ transient_error_codes = {502, 503, 504}
74
+
75
+ last_response: httpx.Response | None = None
76
+ auth_retry_used = False
77
+ transient_retry_count = 0
78
+
79
+ while True:
80
+ # Get preview access token via API
81
+ access_token = await get_preview_access_token(session, article_key)
82
+
83
+ # Build preview URL
84
+ preview_url = build_preview_url(article_key, access_token)
85
+
86
+ # Fetch HTML via httpx
87
+ async with httpx.AsyncClient() as client:
88
+ response = await client.get(
89
+ preview_url,
90
+ headers=headers,
91
+ follow_redirects=True,
92
+ )
93
+
94
+ if response.is_success:
95
+ return response.text
96
+
97
+ last_response = response
98
+ status_code = response.status_code
99
+
100
+ # Handle auth errors: retry once with fresh token
101
+ if status_code in auth_error_codes and not auth_retry_used:
102
+ logger.warning(
103
+ "Preview HTML fetch got auth error %d, retrying with fresh token",
104
+ status_code,
105
+ )
106
+ auth_retry_used = True
107
+ continue
108
+
109
+ # Handle transient server errors: retry with exponential backoff
110
+ if status_code in transient_error_codes and transient_retry_count < MAX_TRANSIENT_RETRIES:
111
+ delay = min(BASE_DELAY * (2**transient_retry_count), MAX_DELAY)
112
+ logger.warning(
113
+ "Preview HTML fetch got transient error %d, retrying in %.1fs (%d/%d)",
114
+ status_code,
115
+ delay,
116
+ transient_retry_count + 1,
117
+ MAX_TRANSIENT_RETRIES,
118
+ )
119
+ await asyncio.sleep(delay)
120
+ transient_retry_count += 1
121
+ continue
122
+
123
+ # No more retries available
124
+ break
125
+
126
+ # All attempts failed
127
+ assert last_response is not None
128
+
129
+ # Use NOT_AUTHENTICATED for 401 errors, API_ERROR for others
130
+ error_code = ErrorCode.NOT_AUTHENTICATED if last_response.status_code == 401 else ErrorCode.API_ERROR
131
+
132
+ raise NoteAPIError(
133
+ code=error_code,
134
+ message=f"Failed to fetch preview HTML. Status: {last_response.status_code}",
135
+ details={
136
+ "article_key": article_key,
137
+ "status_code": last_response.status_code,
138
+ "response_text": last_response.text[:500] if last_response.text else "(empty)",
139
+ "auth_retry_used": auth_retry_used,
140
+ "transient_retry_count": transient_retry_count,
141
+ },
142
+ )