From 4ed60ff3c86fe81c4f7c3aa5e40880345af7e695 Mon Sep 17 00:00:00 2001 From: driller Date: Sun, 1 Feb 2026 13:31:27 +0900 Subject: [PATCH 1/2] feat(#254): add connpass.com embed support Add support for embedding connpass.com event pages in note.com articles. Connpass URLs use subdomain format ({group}.connpass.com/event/{id}/) and are handled via the 'external-article' service type. - Add CONNPASS_PATTERN regex for URL detection (excludes www subdomain) - Register pattern in EMBED_PATTERNS with 'external-article' service - Add comprehensive unit tests (13 test cases across 8 test classes) - Add E2E test for API conversion verification - Update documentation and sample embeds file Co-Authored-By: Claude Opus 4.5 --- docs/features/embed.md | 1 + examples/sample_embeds.md | 6 + src/note_mcp/api/embeds.py | 11 +- tests/e2e/test_embed_api.py | 45 ++++++ tests/unit/test_embeds.py | 286 ++++++++++++++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 2 deletions(-) diff --git a/docs/features/embed.md b/docs/features/embed.md index 53020ab..e61b249 100644 --- a/docs/features/embed.md +++ b/docs/features/embed.md @@ -16,6 +16,7 @@ note.comは以下のサービスの埋め込みに対応しています: | noteマネー(株価チャート) | ✅ | 株価チャートとして埋め込み | | Zenn.dev | ✅ | 記事カードとして埋め込み | | Qiita | ✅ | 記事カードとして埋め込み | +| connpass | ✅ | イベントカードとして埋め込み | | Google Slides | ✅ | プレゼンテーションとして埋め込み | | SpeakerDeck | ✅ | プレゼンテーションとして埋め込み | | その他の外部URL | ❌ | 通常のリンクとして表示 | diff --git a/examples/sample_embeds.md b/examples/sample_embeds.md index cf0d8b1..750f54d 100644 --- a/examples/sample_embeds.md +++ b/examples/sample_embeds.md @@ -36,6 +36,12 @@ https://zenn.dev/driller/articles/0669cf98e07ffa https://qiita.com/driller/items/31c1ff4d0bf5813f624f +## イベント + +### connpassイベント埋め込み + +https://fin-py.connpass.com/event/381982/ + ## コード ### GitHub Gist埋め込み diff --git a/src/note_mcp/api/embeds.py b/src/note_mcp/api/embeds.py index 3826a63..2e5efaf 100644 --- a/src/note_mcp/api/embeds.py +++ b/src/note_mcp/api/embeds.py @@ -1,8 +1,8 @@ """Embed URL detection and HTML generation for note.com. This module provides functions for detecting embed URLs (YouTube, Twitter, note.com, -GitHub Gist, GitHub Repository, noteマネー, Zenn.dev, Google Slides, SpeakerDeck, Qiita) -and generating the required HTML structure for note.com embeds. +GitHub Gist, GitHub Repository, noteマネー, Zenn.dev, Google Slides, SpeakerDeck, Qiita, +connpass) and generating the required HTML structure for note.com embeds. This is the single source of truth for embed URL patterns (DRY principle). @@ -67,6 +67,12 @@ # Example: https://qiita.com/driller/items/31c1ff4d0bf5813f624f (Issue #244) QIITA_PATTERN = re.compile(r"^https?://qiita\.com/[\w-]+/items/[\w]+$") +# connpass: {group}.connpass.com/event/{event_id}/ +# Example: https://fin-py.connpass.com/event/381982/ (Issue #254) +# Note: connpass uses subdomain format for group names +# Note: www subdomain is excluded (connpass canonical URLs use group subdomain) +CONNPASS_PATTERN = re.compile(r"^https?://(?!www\.)([\w-]+)\.connpass\.com/event/\d+/?$") + # GitHub Repository: github.com/owner/repo (with optional trailing slash) # Example: https://github.com/anthropics/claude-code (Issue #226) # Note: This pattern must NOT match gist.github.com (handled by GIST_PATTERN) @@ -99,6 +105,7 @@ (MONEY_PATTERN, "oembed"), (ZENN_PATTERN, "external-article"), (QIITA_PATTERN, "external-article"), # Qiita also uses external-article (Issue #244) + (CONNPASS_PATTERN, "external-article"), # connpass.com events (Issue #254) ] diff --git a/tests/e2e/test_embed_api.py b/tests/e2e/test_embed_api.py index d1b029a..2a30b1c 100644 --- a/tests/e2e/test_embed_api.py +++ b/tests/e2e/test_embed_api.py @@ -654,3 +654,48 @@ async def test_stock_notation_in_code_block_not_converted( finally: # Clean up created article await delete_draft_with_retry(real_session, article_key) + + +class TestConnpassEmbedApiConversion: + """Test connpass event embed conversion via API (Issue #254).""" + + async def test_connpass_embed_via_api( + self, + real_session: Session, + ) -> None: + """connpassイベントURLがAPIでfigure要素に変換される. + + - ブラウザを起動せずにAPIのみで下書き作成 + - connpassイベントURLがfigure要素に変換される + - embedded-service="external-article"属性が設定される + """ + # Arrange - Use real connpass event URL (fin-py勉強会) + connpass_url = "https://fin-py.connpass.com/event/381982/" + body = f"""connpassイベント埋め込みテスト + +{connpass_url} + +上記にconnpassイベントカードが表示されます。""" + + # Act + result = await note_create_draft.fn( + title="[E2E-TEST] connpass Embed via API", + body=body, + tags=["e2e-test", "embed-api"], + ) + + # Assert - API response + assert "下書きを作成しました" in result + + # Verify embed figure is present in raw HTML + article_key = extract_article_key(result) + try: + article_html = await get_article_html(article_key) + + # Verify embed figure is present + assert 'embedded-service="external-article"' in article_html + assert f'data-src="{connpass_url}"' in article_html + assert "embedded-content-key=" in article_html + finally: + # Clean up created article + await delete_draft_with_retry(real_session, article_key) diff --git a/tests/unit/test_embeds.py b/tests/unit/test_embeds.py index 67a6d09..6be1f2f 100644 --- a/tests/unit/test_embeds.py +++ b/tests/unit/test_embeds.py @@ -2819,3 +2819,289 @@ async def test_resolve_qiita_embed(self) -> None: "https://qiita.com/driller/items/31c1ff4d0bf5813f624f", "n1234567890ab", ) + + +# ============================================================================ +# Connpass embed tests (Issue #254) +# ============================================================================ + + +class TestConnpassPattern: + """Tests for connpass event URL pattern (Issue #254).""" + + def test_connpass_event_url(self) -> None: + """Test connpass event URL detection.""" + from note_mcp.api.embeds import CONNPASS_PATTERN + + # Valid connpass event URLs (subdomain format) + assert CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982/") + assert CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982") + assert CONNPASS_PATTERN.match("https://pycon-jp.connpass.com/event/123456/") + assert CONNPASS_PATTERN.match("http://example-group.connpass.com/event/12345/") + + def test_connpass_pattern_rejects_invalid_urls(self) -> None: + """Test that invalid URLs are rejected.""" + from note_mcp.api.embeds import CONNPASS_PATTERN + + # Wrong domain + assert not CONNPASS_PATTERN.match("https://example.com/event/123/") + # No subdomain (connpass requires subdomain) + assert not CONNPASS_PATTERN.match("https://connpass.com/event/123/") + # www subdomain (not valid for connpass) + assert not CONNPASS_PATTERN.match("https://www.connpass.com/event/123/") + # Wrong path structure + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/user/123/") + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/") + # Missing event id + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/") + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/event") + + +class TestConnpassPatternEdgeCases: + """Tests for connpass URL pattern edge cases (Issue #254).""" + + def test_connpass_pattern_accepts_trailing_slash(self) -> None: + """Test that connpass URLs with trailing slashes are accepted. + + Connpass canonical URLs typically include trailing slashes. + """ + from note_mcp.api.embeds import CONNPASS_PATTERN + + # Trailing slash should be accepted + assert CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982/") + # Without trailing slash should also work + assert CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982") + + def test_connpass_pattern_rejects_query_parameters(self) -> None: + """Test that connpass URLs with query parameters are rejected. + + Query parameters are not part of valid connpass event URLs. + """ + from note_mcp.api.embeds import CONNPASS_PATTERN + + # Query parameters should be rejected + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982/?ref=series") + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982?utm_source=share") + + def test_connpass_pattern_rejects_non_event_paths(self) -> None: + """Test that non-event paths are rejected.""" + from note_mcp.api.embeds import CONNPASS_PATTERN + + # Various non-event paths + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/user/drillan/") + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982/participation/") + assert not CONNPASS_PATTERN.match("https://fin-py.connpass.com/event/381982/waitlist/") + + +class TestGetEmbedServiceConnpass: + """Tests for get_embed_service function with connpass URLs (Issue #254).""" + + def test_get_embed_service_returns_external_article_for_connpass(self) -> None: + """Test that get_embed_service returns 'external-article' for connpass URLs.""" + from note_mcp.api.embeds import get_embed_service + + assert get_embed_service("https://fin-py.connpass.com/event/381982/") == "external-article" + assert get_embed_service("https://pycon-jp.connpass.com/event/123456") == "external-article" + + +class TestGenerateEmbedHtmlConnpass: + """Tests for generate_embed_html function with connpass URLs (Issue #254).""" + + def test_connpass_embed_structure(self) -> None: + """Test connpass embed HTML structure.""" + from note_mcp.api.embeds import generate_embed_html + + url = "https://fin-py.connpass.com/event/381982/" + html = generate_embed_html(url) + + # Verify required attributes + assert "" in html + + +class TestIsEmbedUrlConnpass: + """Tests for is_embed_url function with connpass URLs (Issue #254).""" + + def test_connpass_urls_are_embed_urls(self) -> None: + """Test that connpass URLs are recognized as embed URLs.""" + from note_mcp.api.embeds import is_embed_url + + assert is_embed_url("https://fin-py.connpass.com/event/381982/") is True + assert is_embed_url("https://pycon-jp.connpass.com/event/123456") is True + + +class TestFetchEmbedKeyConnpass: + """Tests for fetch_embed_key function with connpass URLs (Issue #254).""" + + @pytest.mark.asyncio + async def test_fetch_connpass_embed_key_uses_v2_endpoint(self) -> None: + """Test that connpass URL uses GET /v2/embed_by_external_api endpoint.""" + import time + from unittest.mock import AsyncMock, patch + + from note_mcp.api.embeds import fetch_embed_key + from note_mcp.models import Session + + session = Session( + cookies={"note_gql_session_id": "test", "XSRF-TOKEN": "test"}, + user_id="123456", + username="testuser", + created_at=int(time.time()), + ) + + mock_response = { + "data": { + "key": "embconnpass1234567890", + "html_for_embed": '
connpass event
', + } + } + + with patch("note_mcp.api.embeds.NoteAPIClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + embed_key, html_for_embed = await fetch_embed_key( + session, + "https://fin-py.connpass.com/event/381982/", + "n1234567890ab", + ) + + # Verify the API was called with correct params + mock_client.get.assert_called_once_with( + "/v2/embed_by_external_api", + params={ + "url": "https://fin-py.connpass.com/event/381982/", + "service": "external-article", + "embeddable_key": "n1234567890ab", + "embeddable_type": "Note", + }, + ) + # Verify POST was NOT called + mock_client.post.assert_not_called() + # Verify returned values + assert embed_key == "embconnpass1234567890" + assert "external-article-widget" in html_for_embed + + @pytest.mark.asyncio + async def test_fetch_connpass_embed_key_sends_correct_service(self) -> None: + """Test that connpass embed sends 'external-article' as service type.""" + import time + from unittest.mock import AsyncMock, patch + + from note_mcp.api.embeds import fetch_embed_key + from note_mcp.models import Session + + session = Session( + cookies={"note_gql_session_id": "test", "XSRF-TOKEN": "test"}, + user_id="123456", + username="testuser", + created_at=int(time.time()), + ) + + mock_response = { + "data": { + "key": "embconnpass123", + "html_for_embed": "
connpass preview
", + } + } + + with patch("note_mcp.api.embeds.NoteAPIClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + await fetch_embed_key( + session, + "https://pycon-jp.connpass.com/event/123456/", + "narticlekey", + ) + + # Verify the params include service="external-article" + call_kwargs = mock_client.get.call_args[1] + params = call_kwargs.get("params", {}) + assert params.get("service") == "external-article" + + +class TestGenerateEmbedHtmlWithKeyConnpass: + """Tests for generate_embed_html_with_key function with connpass URLs (Issue #254).""" + + def test_connpass_embed_with_server_key(self) -> None: + """Test generating connpass embed HTML with server-registered key.""" + from note_mcp.api.embeds import generate_embed_html_with_key + + url = "https://fin-py.connpass.com/event/381982/" + embed_key = "embconnpass1234567890" + html = generate_embed_html_with_key(url, embed_key) + + assert "" in html + + def test_connpass_embed_auto_detect_service(self) -> None: + """Test that service is auto-detected as 'external-article' for connpass URLs.""" + from note_mcp.api.embeds import generate_embed_html_with_key + + url = "https://pycon-jp.connpass.com/event/123456/" + html = generate_embed_html_with_key(url, "embtest123") + + assert 'embedded-service="external-article"' in html + + +class TestResolveEmbedKeysConnpass: + """Tests for resolve_embed_keys function with connpass URLs (Issue #254).""" + + @pytest.mark.asyncio + async def test_resolve_connpass_embed(self) -> None: + """Test resolving a connpass embed key.""" + import time + from unittest.mock import patch + + from note_mcp.api.embeds import resolve_embed_keys + from note_mcp.models import Session + + session = Session( + cookies={"note_gql_session_id": "test", "XSRF-TOKEN": "test"}, + user_id="123456", + username="testuser", + created_at=int(time.time()), + ) + + # HTML with random embed key for connpass + html_body = ( + '

Check out this event:

' + '
' + ) + + # Mock fetch_embed_key to return a server key + with patch("note_mcp.api.embeds.fetch_embed_key") as mock_fetch: + mock_fetch.return_value = ( + "embconnpassserver123", + "
connpass preview
", + ) + + result = await resolve_embed_keys(session, html_body, "n1234567890ab") + + # Verify the key was replaced + assert 'embedded-content-key="embconnpassserver123"' in result + assert 'embedded-content-key="embrandomconnpass"' not in result + mock_fetch.assert_called_once_with( + session, + "https://fin-py.connpass.com/event/381982/", + "n1234567890ab", + ) From bbb21cd9205db2624c4ae10b0991b34d167909a7 Mon Sep 17 00:00:00 2001 From: driller Date: Sun, 1 Feb 2026 13:38:52 +0900 Subject: [PATCH 2/2] docs(#254): add Issue #254 section to embeds.py docstring Add missing docstring section for connpass embed support following the established pattern for other embed services. Co-Authored-By: Claude Opus 4.5 --- src/note_mcp/api/embeds.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/note_mcp/api/embeds.py b/src/note_mcp/api/embeds.py index 2e5efaf..4607f1a 100644 --- a/src/note_mcp/api/embeds.py +++ b/src/note_mcp/api/embeds.py @@ -24,6 +24,9 @@ Issue #244: Qiita article embed support added. Qiita URLs use 'external-article' service type (same as Zenn.dev) via the same /v2/embed_by_external_api endpoint. + +Issue #254: connpass event embed support added. connpass URLs use 'external-article' +service type (same as Zenn.dev and Qiita) via the same /v2/embed_by_external_api endpoint. """ from __future__ import annotations