From 6231ccafdfe490498f0f78d3aea3538df21ba568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=87=E6=B2=BC=E3=81=BB=E3=81=A8=E3=82=8A?= <206570885+katanumahotori@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:03:15 +0900 Subject: [PATCH] feat: embed any standalone http(s) URL as an external-article card Standalone URLs that match no specific embed pattern previously stayed as plain unlinked text in the published article. note.com's own editor turns any pasted URL into a link card, so mirror that behavior: - get_embed_service() falls back to 'external-article' for any other http(s) URL (specific patterns still take precedence). Non-http(s) strings keep returning None. - resolve_embed_keys() now degrades a figure to a visible link paragraph when key registration fails, instead of leaving an unregistered placeholder key that note.com's frontend renders as nothing (silent content loss). - _convert_standalone_embed_urls() unescapes HTML entities before matching, so query strings containing '&' (e.g. Amazon affiliate URLs) are not double-escaped. Update unit tests for the new semantics and add coverage for the fallback, the link degradation, and the '&' handling. --- src/note_mcp/api/embeds.py | 25 ++++++++++- src/note_mcp/utils/markdown_to_html.py | 6 ++- tests/unit/test_embeds.py | 59 +++++++++++++++++++------- tests/unit/test_markdown_to_html.py | 43 ++++++++++++------- 4 files changed, 99 insertions(+), 34 deletions(-) diff --git a/src/note_mcp/api/embeds.py b/src/note_mcp/api/embeds.py index 4607f1a..866674a 100644 --- a/src/note_mcp/api/embeds.py +++ b/src/note_mcp/api/embeds.py @@ -94,6 +94,14 @@ # Example: https://speakerdeck.com/tomohisa/introducing-decider-pattern-with-event-sourcing (Issue #223) SPEAKERDECK_PATTERN = re.compile(r"^https?://speakerdeck\.com/[\w-]+/[\w-]+$") +# Generic external URL fallback: any other http(s) URL that appears alone on a +# line is embedded as an 'external-article' card via the same +# /v2/embed_by_external_api endpoint already used for Zenn.dev / Qiita / +# connpass. This matches note.com's own editor behavior, where pasting any URL +# on its own line creates a link card. Specific patterns below take precedence +# (checked first in get_embed_service). +GENERIC_EXTERNAL_PATTERN = re.compile(r"^https?://\S+$") + # Data-driven pattern to service mapping (Issue #235: DRY principle) # Note: GIST_PATTERN and GITHUB_REPO_PATTERN are mutually exclusive by design # (GIST_PATTERN matches gist.github.com, GITHUB_REPO_PATTERN matches github.com only). @@ -123,10 +131,16 @@ def get_embed_service(url: str) -> str | None: Returns: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository', 'googlepresentation', 'speakerdeck', 'oembed', 'external-article') or None if unsupported. + Any http(s) URL that matches no specific pattern falls back to + 'external-article' (generic link card). Non-http(s) strings return None. """ for pattern, service in EMBED_PATTERNS: if pattern.match(url): return service + # Fallback: treat any other standalone http(s) URL as an external-article + # card, matching note.com's editor behavior on paste. + if GENERIC_EXTERNAL_PATTERN.match(url): + return "external-article" return None @@ -470,6 +484,15 @@ async def resolve_embed_keys( except NoteAPIError as e: # Log warning and continue processing other embeds logger.warning("Embed key fetch failed for %s: %s", url, e.message) - # Original placeholder key is preserved + # Degrade the figure to a plain link paragraph. A figure left with + # an unregistered placeholder key is not rendered by note.com's + # frontend at all (Issue #116), which would silently drop the URL + # from the article. A visible link is the safer fallback. + element_id = str(uuid.uuid4()) + link_html = ( + f'
' + f'{data_src}
' + ) + result = result.replace(match.group(0), link_html) return result diff --git a/src/note_mcp/utils/markdown_to_html.py b/src/note_mcp/utils/markdown_to_html.py index 13662f9..d876619 100644 --- a/src/note_mcp/utils/markdown_to_html.py +++ b/src/note_mcp/utils/markdown_to_html.py @@ -7,6 +7,7 @@ import uuid from collections.abc import Callable, Iterator from contextlib import contextmanager +from html import unescape as html_unescape from markdown_it import MarkdownIt @@ -268,7 +269,10 @@ def _convert_standalone_embed_urls(html: str) -> str: """ def replace_embed_url(match: re.Match[str]) -> str: - url = match.group(2).strip() + # Unescape HTML entities first: markdown-it escapes '&' in query + # strings to '&' (e.g. Amazon affiliate URLs), which would break + # pattern matching and double-escape in generate_embed_html. + url = html_unescape(match.group(2).strip()) # Check if this URL is a supported embed URL service = get_embed_service(url) diff --git a/tests/unit/test_embeds.py b/tests/unit/test_embeds.py index 6be1f2f..7d32da9 100644 --- a/tests/unit/test_embeds.py +++ b/tests/unit/test_embeds.py @@ -59,14 +59,26 @@ def test_gist_url(self) -> None: assert get_embed_service("https://gist.github.com/user-name/abc123def") == "gist" assert get_embed_service("http://gist.github.com/user/gist123") == "gist" - def test_unsupported_url_returns_none(self) -> None: - """Test that unsupported URLs return None.""" + def test_generic_http_url_falls_back_to_external_article(self) -> None: + """Generic http(s) URLs fall back to 'external-article' (link card).""" + from note_mcp.api.embeds import get_embed_service + + assert get_embed_service("https://example.com") == "external-article" + assert get_embed_service("https://google.com") == "external-article" + assert get_embed_service("https://vimeo.com/123456") == "external-article" + # Query strings (e.g. Amazon affiliate URLs) are accepted + assert ( + get_embed_service("https://www.amazon.co.jp/dp/4086315408?tag=abc-22&th=1") + == "external-article" + ) + + def test_non_http_url_returns_none(self) -> None: + """Non-http(s) strings still return None.""" from note_mcp.api.embeds import get_embed_service - assert get_embed_service("https://example.com") is None - assert get_embed_service("https://google.com") is None - assert get_embed_service("https://vimeo.com/123456") is None assert get_embed_service("not a url") is None + assert get_embed_service("ftp://example.com/file") is None + assert get_embed_service("mailto:user@example.com") is None class TestIsEmbedUrl: @@ -100,11 +112,15 @@ def test_gist_urls_are_embed_urls(self) -> None: assert is_embed_url("https://gist.github.com/user-name/abc123") is True def test_unsupported_urls_are_not_embed_urls(self) -> None: - """Test that unsupported URLs are not recognized as embed URLs.""" + """Non-http(s) strings are not recognized as embed URLs. + + Generic http(s) URLs ARE embed URLs since the external-article + fallback (they become link cards, like in note.com's editor). + """ from note_mcp.api.embeds import is_embed_url - assert is_embed_url("https://example.com") is False - assert is_embed_url("https://google.com") is False + assert is_embed_url("https://example.com") is True + assert is_embed_url("https://google.com") is True assert is_embed_url("not a url") is False @@ -223,12 +239,20 @@ def test_url_escaping(self) -> None: assert "&" in html or "feature=share" in html assert '"