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.
- 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 +456 -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 +557 -0
- package/py/src/note_mcp/py.typed +0 -0
- package/py/src/note_mcp/server.py +905 -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,482 @@
|
|
|
1
|
+
"""Embed URL detection and HTML generation for note.com.
|
|
2
|
+
|
|
3
|
+
This module provides functions for detecting embed URLs (YouTube, Twitter, note.com,
|
|
4
|
+
GitHub Gist, GitHub Repository, noteマネー, Zenn.dev, Google Slides, SpeakerDeck, Qiita,
|
|
5
|
+
connpass) and generating the required HTML structure for note.com embeds.
|
|
6
|
+
|
|
7
|
+
This is the single source of truth for embed URL patterns (DRY principle).
|
|
8
|
+
|
|
9
|
+
Issue #116: Server-registered embed keys are required for proper iframe rendering.
|
|
10
|
+
Random keys generated locally will not work - note.com frontend only renders embeds
|
|
11
|
+
with keys registered via the embed_by_external_api endpoint.
|
|
12
|
+
|
|
13
|
+
Issue #195: GitHub Gist embed support added. Gist URLs use the same
|
|
14
|
+
/v2/embed_by_external_api endpoint as YouTube and Twitter.
|
|
15
|
+
|
|
16
|
+
Issue #222: Zenn.dev article embed support added. Zenn URLs use
|
|
17
|
+
'external-article' service type via the same /v2/embed_by_external_api endpoint.
|
|
18
|
+
|
|
19
|
+
Issue #226: GitHub Repository embed support added. Repository URLs use
|
|
20
|
+
'githubRepository' service type via the same /v2/embed_by_external_api endpoint.
|
|
21
|
+
|
|
22
|
+
Issue #223: SpeakerDeck presentation embed support added. SpeakerDeck URLs use
|
|
23
|
+
'speakerdeck' service type via the same /v2/embed_by_external_api endpoint.
|
|
24
|
+
|
|
25
|
+
Issue #244: Qiita article embed support added. Qiita URLs use 'external-article'
|
|
26
|
+
service type (same as Zenn.dev) via the same /v2/embed_by_external_api endpoint.
|
|
27
|
+
|
|
28
|
+
Issue #254: connpass event embed support added. connpass URLs use 'external-article'
|
|
29
|
+
service type (same as Zenn.dev and Qiita) via the same /v2/embed_by_external_api endpoint.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import html
|
|
35
|
+
import logging
|
|
36
|
+
import re
|
|
37
|
+
import uuid
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
from note_mcp.api.client import NoteAPIClient
|
|
41
|
+
from note_mcp.models import ErrorCode, NoteAPIError
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from note_mcp.models import Session
|
|
47
|
+
|
|
48
|
+
# Embed URL patterns (single source of truth - DRY principle)
|
|
49
|
+
# YouTube: youtube.com/watch?v=xxx or youtu.be/xxx
|
|
50
|
+
YOUTUBE_PATTERN = re.compile(r"^https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)[\w-]+$")
|
|
51
|
+
|
|
52
|
+
# Twitter/X: twitter.com/user/status/xxx or x.com/user/status/xxx
|
|
53
|
+
TWITTER_PATTERN = re.compile(r"^https?://(?:www\.)?(?:twitter\.com|x\.com)/\w+/status/\d+$")
|
|
54
|
+
|
|
55
|
+
# note.com: note.com/user/n/xxx
|
|
56
|
+
NOTE_PATTERN = re.compile(r"^https?://note\.com/\w+/n/\w+$")
|
|
57
|
+
|
|
58
|
+
# GitHub Gist: gist.github.com/user/gist_id (with optional trailing slash and file fragment)
|
|
59
|
+
GIST_PATTERN = re.compile(r"^https?://gist\.github\.com/[\w-]+/[\w]+/?(?:#[\w-]+)?$")
|
|
60
|
+
|
|
61
|
+
# noteマネー (stock chart): money.note.com/companies|us-companies|indices|investments/xxx
|
|
62
|
+
# Supports Japanese stocks, US stocks, indices, and investment trusts
|
|
63
|
+
MONEY_PATTERN = re.compile(r"^https?://money\.note\.com/(companies|us-companies|indices|investments)/[\w-]+/?$")
|
|
64
|
+
|
|
65
|
+
# Zenn.dev: zenn.dev/username/articles/article-slug
|
|
66
|
+
# Example: https://zenn.dev/zenn/articles/markdown-guide (Issue #222)
|
|
67
|
+
ZENN_PATTERN = re.compile(r"^https?://zenn\.dev/[\w-]+/articles/[\w-]+$")
|
|
68
|
+
|
|
69
|
+
# Qiita: qiita.com/username/items/item_id
|
|
70
|
+
# Example: https://qiita.com/driller/items/31c1ff4d0bf5813f624f (Issue #244)
|
|
71
|
+
QIITA_PATTERN = re.compile(r"^https?://qiita\.com/[\w-]+/items/[\w]+$")
|
|
72
|
+
|
|
73
|
+
# connpass: {group}.connpass.com/event/{event_id}/
|
|
74
|
+
# Example: https://fin-py.connpass.com/event/381982/ (Issue #254)
|
|
75
|
+
# Note: connpass uses subdomain format for group names
|
|
76
|
+
# Note: www subdomain is excluded (connpass canonical URLs use group subdomain)
|
|
77
|
+
CONNPASS_PATTERN = re.compile(r"^https?://(?!www\.)([\w-]+)\.connpass\.com/event/\d+/?$")
|
|
78
|
+
|
|
79
|
+
# GitHub Repository: github.com/owner/repo (with optional trailing slash)
|
|
80
|
+
# Example: https://github.com/anthropics/claude-code (Issue #226)
|
|
81
|
+
# Note: This pattern must NOT match gist.github.com (handled by GIST_PATTERN)
|
|
82
|
+
# Note: This pattern must NOT match subpaths like /issues, /pull, /blob
|
|
83
|
+
GITHUB_REPO_PATTERN = re.compile(r"^https?://(?:www\.)?github\.com/[\w-]+/[\w.-]+/?$")
|
|
84
|
+
|
|
85
|
+
# Google Slides: docs.google.com/presentation/d/{id}/...
|
|
86
|
+
# Example: https://docs.google.com/presentation/d/1W543BSd-hHANrJOzCPyNf-r3x0s5s7ljc9xA7a7x960/edit (Issue #224)
|
|
87
|
+
# Supports: /edit, /pub, /view, /embed, or no suffix
|
|
88
|
+
# Supports: query parameters and fragment identifiers (#slide=id.xxx)
|
|
89
|
+
GOOGLE_SLIDES_PATTERN = re.compile(
|
|
90
|
+
r"^https?://docs\.google\.com/presentation/d/[\w-]+(?:/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# SpeakerDeck: speakerdeck.com/user/slide-name
|
|
94
|
+
# Example: https://speakerdeck.com/tomohisa/introducing-decider-pattern-with-event-sourcing (Issue #223)
|
|
95
|
+
SPEAKERDECK_PATTERN = re.compile(r"^https?://speakerdeck\.com/[\w-]+/[\w-]+$")
|
|
96
|
+
|
|
97
|
+
# Generic URL pattern: catch-all for any HTTP(S) URL
|
|
98
|
+
# Used as fallback for URL card / link card embeds (Open Graph preview)
|
|
99
|
+
# Matches any web URL not already handled by specific patterns above.
|
|
100
|
+
GENERIC_URL_PATTERN = re.compile(r"^https?://[^\s<>\"{}|\\^`\[\]]+$")
|
|
101
|
+
|
|
102
|
+
# Data-driven pattern to service mapping (Issue #235: DRY principle)
|
|
103
|
+
# Note: GIST_PATTERN and GITHUB_REPO_PATTERN are mutually exclusive by design
|
|
104
|
+
# (GIST_PATTERN matches gist.github.com, GITHUB_REPO_PATTERN matches github.com only).
|
|
105
|
+
# Note: GENERIC_URL_PATTERN must be LAST so specific services match first.
|
|
106
|
+
EMBED_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
107
|
+
(YOUTUBE_PATTERN, "youtube"),
|
|
108
|
+
(TWITTER_PATTERN, "twitter"),
|
|
109
|
+
(NOTE_PATTERN, "note"),
|
|
110
|
+
(GIST_PATTERN, "gist"), # gist.github.com (distinct from github.com)
|
|
111
|
+
(GITHUB_REPO_PATTERN, "githubRepository"),
|
|
112
|
+
(GOOGLE_SLIDES_PATTERN, "googlepresentation"),
|
|
113
|
+
(SPEAKERDECK_PATTERN, "speakerdeck"),
|
|
114
|
+
(MONEY_PATTERN, "oembed"),
|
|
115
|
+
(ZENN_PATTERN, "external-article"),
|
|
116
|
+
(QIITA_PATTERN, "external-article"), # Qiita also uses external-article (Issue #244)
|
|
117
|
+
(CONNPASS_PATTERN, "external-article"), # connpass.com events (Issue #254)
|
|
118
|
+
(GENERIC_URL_PATTERN, "external-article"), # URL cards (Open Graph preview) — MUST be last
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_embed_service(url: str) -> str | None:
|
|
123
|
+
"""Get embed service type from URL.
|
|
124
|
+
|
|
125
|
+
Uses data-driven pattern matching from EMBED_PATTERNS (Issue #235: DRY principle).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
url: The URL to check.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
|
|
132
|
+
'googlepresentation', 'speakerdeck', 'oembed', 'external-article') or None if unsupported.
|
|
133
|
+
"""
|
|
134
|
+
for pattern, service in EMBED_PATTERNS:
|
|
135
|
+
if pattern.match(url):
|
|
136
|
+
return service
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_embed_url(url: str) -> bool:
|
|
141
|
+
"""Check if URL is a supported embed URL.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
url: The URL to check.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if the URL is a supported embed URL, False otherwise.
|
|
148
|
+
"""
|
|
149
|
+
return get_embed_service(url) is not None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_embed_figure_html(
|
|
153
|
+
url: str,
|
|
154
|
+
embed_key: str,
|
|
155
|
+
service: str,
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Build the HTML figure element for an embed.
|
|
158
|
+
|
|
159
|
+
Internal helper to generate the figure HTML structure.
|
|
160
|
+
This is the single source of truth for embed figure HTML format (DRY).
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
url: Original URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
|
|
164
|
+
noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
|
|
165
|
+
embed_key: Embed key (random for placeholder, server-registered for final).
|
|
166
|
+
service: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
|
|
167
|
+
'googlepresentation', 'speakerdeck', 'oembed', 'external-article').
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
HTML figure element string.
|
|
171
|
+
"""
|
|
172
|
+
element_id = str(uuid.uuid4())
|
|
173
|
+
escaped_url = html.escape(url, quote=True)
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
f'<figure name="{element_id}" id="{element_id}" '
|
|
177
|
+
f'data-src="{escaped_url}" '
|
|
178
|
+
f'embedded-service="{service}" '
|
|
179
|
+
f'embedded-content-key="{embed_key}" '
|
|
180
|
+
f'contenteditable="false"></figure>'
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def generate_embed_html(
|
|
185
|
+
url: str,
|
|
186
|
+
service: str | None = None,
|
|
187
|
+
embed_key: str | None = None,
|
|
188
|
+
) -> str:
|
|
189
|
+
"""Generate embed HTML for note.com.
|
|
190
|
+
|
|
191
|
+
Creates a figure element with the required attributes for note.com
|
|
192
|
+
to render the embed (iframe is rendered client-side by note.com frontend).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
url: Original URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
|
|
196
|
+
noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
|
|
197
|
+
service: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
|
|
198
|
+
'googlepresentation', 'speakerdeck', 'oembed', 'external-article').
|
|
199
|
+
If None, auto-detected from URL.
|
|
200
|
+
embed_key: Server-registered embed key. If None, generates a random
|
|
201
|
+
placeholder key (for markdown-to-html conversion, replaced later via API).
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
HTML figure element string.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ValueError: If URL is not a supported embed URL.
|
|
208
|
+
"""
|
|
209
|
+
if service is None:
|
|
210
|
+
service = get_embed_service(url)
|
|
211
|
+
|
|
212
|
+
if service is None:
|
|
213
|
+
raise ValueError(f"Unsupported embed URL: {url}")
|
|
214
|
+
|
|
215
|
+
if embed_key is None:
|
|
216
|
+
embed_key = f"emb{uuid.uuid4().hex[:13]}"
|
|
217
|
+
|
|
218
|
+
return _build_embed_figure_html(url, embed_key, service)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _extract_and_validate_embed_response(
|
|
222
|
+
response: dict[str, Any],
|
|
223
|
+
path: list[str],
|
|
224
|
+
url: str,
|
|
225
|
+
article_key: str,
|
|
226
|
+
service: str,
|
|
227
|
+
) -> tuple[str, str]:
|
|
228
|
+
"""Extract and validate embed key and HTML from API response.
|
|
229
|
+
|
|
230
|
+
Internal helper to avoid duplicated response validation logic (DRY principle).
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
response: API response dictionary.
|
|
234
|
+
path: List of keys to navigate to the embed data
|
|
235
|
+
(e.g., ["data", "embedded_content"] for note.com,
|
|
236
|
+
["data"] for external services).
|
|
237
|
+
url: Original embed URL (for error context).
|
|
238
|
+
article_key: Article key (for error context).
|
|
239
|
+
service: Service name for error message (e.g., "note", "youtube").
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Tuple of (embed_key, html_for_embed).
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
NoteAPIError: If required fields are missing from response.
|
|
246
|
+
"""
|
|
247
|
+
# Navigate to the embed data following the path
|
|
248
|
+
data: dict[str, Any] = response
|
|
249
|
+
for key in path:
|
|
250
|
+
data = data.get(key, {})
|
|
251
|
+
|
|
252
|
+
embed_key = data.get("key")
|
|
253
|
+
html_for_embed = data.get("html_for_embed")
|
|
254
|
+
|
|
255
|
+
# Article 6: Validate required fields - no implicit fallbacks
|
|
256
|
+
if not embed_key or not html_for_embed:
|
|
257
|
+
raise NoteAPIError(
|
|
258
|
+
code=ErrorCode.API_ERROR,
|
|
259
|
+
message=(
|
|
260
|
+
f"Failed to fetch {service} embed key: API response missing required field(s) 'key' or 'html_for_embed'"
|
|
261
|
+
),
|
|
262
|
+
details={"url": url, "article_key": article_key, "response": response},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return str(embed_key), str(html_for_embed)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def _fetch_note_embed_key(
|
|
269
|
+
session: Session,
|
|
270
|
+
url: str,
|
|
271
|
+
article_key: str,
|
|
272
|
+
) -> tuple[str, str]:
|
|
273
|
+
"""Fetch embed key for note.com article via /v1/embed endpoint.
|
|
274
|
+
|
|
275
|
+
This internal function handles note.com article embeds which require
|
|
276
|
+
a different API endpoint than external services (YouTube, Twitter).
|
|
277
|
+
|
|
278
|
+
Issue #121: note.com articles must use POST /v1/embed instead of
|
|
279
|
+
GET /v2/embed_by_external_api which returns 500 error.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
session: Authenticated session with valid cookies.
|
|
283
|
+
url: note.com article URL (e.g., https://note.com/user/n/xxx).
|
|
284
|
+
article_key: Article key where embed will be inserted
|
|
285
|
+
(e.g., "n1234567890ab").
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Tuple of (embed_key, html_for_embed):
|
|
289
|
+
- embed_key: Server-registered key (e.g., "emb0076d44f4f7f")
|
|
290
|
+
- html_for_embed: HTML snippet for rendering the embed
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
NoteAPIError: If API request fails or returns empty response.
|
|
294
|
+
"""
|
|
295
|
+
payload = {
|
|
296
|
+
"url": url,
|
|
297
|
+
"embeddable_key": article_key,
|
|
298
|
+
"embeddable_type": "Note",
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async with NoteAPIClient(session) as client:
|
|
302
|
+
response = await client.post("/v1/embed", json=payload)
|
|
303
|
+
|
|
304
|
+
# Response structure: {"data": {"embedded_content": {"key": ..., "html_for_embed": ...}}}
|
|
305
|
+
return _extract_and_validate_embed_response(
|
|
306
|
+
response,
|
|
307
|
+
path=["data", "embedded_content"],
|
|
308
|
+
url=url,
|
|
309
|
+
article_key=article_key,
|
|
310
|
+
service="note",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def fetch_embed_key(
|
|
315
|
+
session: Session,
|
|
316
|
+
url: str,
|
|
317
|
+
article_key: str,
|
|
318
|
+
) -> tuple[str, str]:
|
|
319
|
+
"""Fetch server-registered embed key from note.com API.
|
|
320
|
+
|
|
321
|
+
This function calls the appropriate API endpoint to register
|
|
322
|
+
the embed URL with note.com's server and obtain a valid embed key.
|
|
323
|
+
|
|
324
|
+
The server-registered key is required for note.com's frontend to
|
|
325
|
+
render the iframe. Random keys generated locally will not work.
|
|
326
|
+
|
|
327
|
+
Issue #121: Different endpoints are used for different services:
|
|
328
|
+
- note.com articles: POST /v1/embed
|
|
329
|
+
- Other services (YouTube/Twitter/Gist/etc.): GET /v2/embed_by_external_api
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
session: Authenticated session with valid cookies.
|
|
333
|
+
url: Embed URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
|
|
334
|
+
noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
|
|
335
|
+
article_key: Article key where the embed will be inserted
|
|
336
|
+
(e.g., "n1234567890ab").
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Tuple of (embed_key, html_for_embed):
|
|
340
|
+
- embed_key: Server-registered key (e.g., "emb0076d44f4f7f")
|
|
341
|
+
- html_for_embed: HTML snippet for rendering the embed
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: If URL is not a supported embed URL.
|
|
345
|
+
NoteAPIError: If API request fails or returns empty response.
|
|
346
|
+
"""
|
|
347
|
+
service = get_embed_service(url)
|
|
348
|
+
if service is None:
|
|
349
|
+
raise ValueError(f"Unsupported embed URL: {url}")
|
|
350
|
+
|
|
351
|
+
# Issue #121: note.com articles use a different API endpoint
|
|
352
|
+
if service == "note":
|
|
353
|
+
return await _fetch_note_embed_key(session, url, article_key)
|
|
354
|
+
|
|
355
|
+
# External services: use /v2/embed_by_external_api endpoint
|
|
356
|
+
params = {
|
|
357
|
+
"url": url,
|
|
358
|
+
"service": service,
|
|
359
|
+
"embeddable_key": article_key,
|
|
360
|
+
"embeddable_type": "Note",
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async with NoteAPIClient(session) as client:
|
|
364
|
+
response = await client.get("/v2/embed_by_external_api", params=params)
|
|
365
|
+
|
|
366
|
+
# Response structure: {"data": {"key": ..., "html_for_embed": ...}}
|
|
367
|
+
return _extract_and_validate_embed_response(
|
|
368
|
+
response,
|
|
369
|
+
path=["data"],
|
|
370
|
+
url=url,
|
|
371
|
+
article_key=article_key,
|
|
372
|
+
service=service,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def generate_embed_html_with_key(
|
|
377
|
+
url: str,
|
|
378
|
+
embed_key: str,
|
|
379
|
+
service: str | None = None,
|
|
380
|
+
) -> str:
|
|
381
|
+
"""Generate embed HTML with a server-registered key.
|
|
382
|
+
|
|
383
|
+
.. deprecated::
|
|
384
|
+
Use ``generate_embed_html(url, service, embed_key)`` instead.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
url: Original URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
|
|
388
|
+
noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
|
|
389
|
+
embed_key: Server-registered embed key from fetch_embed_key().
|
|
390
|
+
service: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
|
|
391
|
+
'googlepresentation', 'speakerdeck', 'oembed', 'external-article').
|
|
392
|
+
If None, auto-detected from URL.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
HTML figure element string with server-registered key.
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
ValueError: If URL is not a supported embed URL.
|
|
399
|
+
"""
|
|
400
|
+
import warnings
|
|
401
|
+
|
|
402
|
+
warnings.warn(
|
|
403
|
+
"generate_embed_html_with_key is deprecated. Use generate_embed_html(url, service=..., embed_key=...) instead.",
|
|
404
|
+
DeprecationWarning,
|
|
405
|
+
stacklevel=2,
|
|
406
|
+
)
|
|
407
|
+
return generate_embed_html(url, service, embed_key)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# Pattern to find embed figure elements with their keys and URLs
|
|
411
|
+
# Uses non-greedy matching to handle any attribute order
|
|
412
|
+
_EMBED_FIGURE_PATTERN = re.compile(
|
|
413
|
+
r"<figure\s+"
|
|
414
|
+
r"(?=(?:[^>]*?data-src=\"([^\"]+)\"))" # Lookahead for data-src
|
|
415
|
+
r"(?=(?:[^>]*?embedded-content-key=\"([^\"]+)\"))" # Lookahead for embedded-content-key
|
|
416
|
+
r"[^>]*>",
|
|
417
|
+
re.IGNORECASE,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def resolve_embed_keys(
|
|
422
|
+
session: Session,
|
|
423
|
+
html_body: str,
|
|
424
|
+
article_key: str,
|
|
425
|
+
) -> str:
|
|
426
|
+
"""Replace random embed keys with server-registered keys.
|
|
427
|
+
|
|
428
|
+
Finds all <figure> elements with embedded-content-key attribute and
|
|
429
|
+
replaces their keys with server-registered keys obtained via API.
|
|
430
|
+
|
|
431
|
+
This function should be called after markdown_to_html() conversion
|
|
432
|
+
and before saving the article body to note.com.
|
|
433
|
+
|
|
434
|
+
Issue #121: API errors for individual embeds are logged and skipped,
|
|
435
|
+
allowing other embeds to be processed successfully.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
session: Authenticated session with valid cookies.
|
|
439
|
+
html_body: HTML body containing figure elements with random embed keys.
|
|
440
|
+
article_key: Article key where embeds will be inserted
|
|
441
|
+
(e.g., "n1234567890ab").
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
HTML body with embed keys replaced by server-registered keys.
|
|
445
|
+
Embeds that fail to resolve keep their original placeholder keys.
|
|
446
|
+
"""
|
|
447
|
+
# Find all embed figures in the HTML
|
|
448
|
+
matches = list(_EMBED_FIGURE_PATTERN.finditer(html_body))
|
|
449
|
+
|
|
450
|
+
if not matches:
|
|
451
|
+
# No embeds found, return unchanged
|
|
452
|
+
return html_body
|
|
453
|
+
|
|
454
|
+
result = html_body
|
|
455
|
+
|
|
456
|
+
# Process each embed figure
|
|
457
|
+
for match in matches:
|
|
458
|
+
data_src = match.group(1)
|
|
459
|
+
old_key = match.group(2)
|
|
460
|
+
|
|
461
|
+
# Unescape the URL (it was escaped when generating HTML)
|
|
462
|
+
url = html.unescape(data_src)
|
|
463
|
+
|
|
464
|
+
# Skip if URL is not a supported embed URL
|
|
465
|
+
if get_embed_service(url) is None:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Fetch server-registered key with error handling (Issue #121)
|
|
469
|
+
try:
|
|
470
|
+
server_key, _ = await fetch_embed_key(session, url, article_key)
|
|
471
|
+
|
|
472
|
+
# Replace the old key with the server key
|
|
473
|
+
result = result.replace(
|
|
474
|
+
f'embedded-content-key="{old_key}"',
|
|
475
|
+
f'embedded-content-key="{server_key}"',
|
|
476
|
+
)
|
|
477
|
+
except NoteAPIError as e:
|
|
478
|
+
# Log warning and continue processing other embeds
|
|
479
|
+
logger.warning("Embed key fetch failed for %s: %s", url, e.message)
|
|
480
|
+
# Original placeholder key is preserved
|
|
481
|
+
|
|
482
|
+
return result
|