From 503f7356d14609faf3f36b8db26a1f51a274d399 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:51:58 +0000 Subject: [PATCH 01/10] Add blog card (link preview) feature Implemented a blog card shortcode that displays rich link previews with: - Automatic OGP metadata fetching (title, description, image) - Manual parameter specification support - Responsive design with dark theme styling - Hover animations and effects - CORS-friendly fetching via proxy service Usage: {{< blogcard url="https://example.com" >}} --- BLOGCARD_USAGE.md | 90 +++++++++++++++++++ layouts/partials/extended_footer.html | 3 + layouts/shortcodes/blogcard.html | 38 ++++++++ static/blogcard.js | 123 ++++++++++++++++++++++++++ static/style.css | 108 ++++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 BLOGCARD_USAGE.md create mode 100644 layouts/shortcodes/blogcard.html create mode 100644 static/blogcard.js diff --git a/BLOGCARD_USAGE.md b/BLOGCARD_USAGE.md new file mode 100644 index 00000000..edf1820f --- /dev/null +++ b/BLOGCARD_USAGE.md @@ -0,0 +1,90 @@ +# ブログカード機能の使い方 + +## 概要 + +ブログカード(リンクプレビュー)機能を使用すると、外部サイトへのリンクを視覚的に魅力的なカード形式で表示できます。 + +## 基本的な使い方 + +### 1. URLのみを指定(自動OGP取得) + +```markdown +{{< blogcard url="https://example.com" >}} +``` + +URLのみを指定すると、JavaScriptが自動的にOGP(Open Graph Protocol)メタデータを取得し、タイトル、説明、画像を表示します。 + +### 2. 手動で情報を指定 + +```markdown +{{< blogcard url="https://example.com" title="サイトのタイトル" description="サイトの説明文" image="https://example.com/image.png" >}} +``` + +OGP情報を手動で指定することもできます。この場合、自動取得は行われません。 + +### 3. 一部の情報のみ指定 + +```markdown +{{< blogcard url="https://example.com" title="カスタムタイトル" >}} +``` + +一部の情報のみを指定した場合でも、他の情報は自動取得されます。 + +## パラメータ + +| パラメータ | 必須 | 説明 | +|-----------|------|------| +| `url` | ✓ | リンク先のURL | +| `title` | | カードに表示するタイトル(省略時は自動取得) | +| `description` | | カードに表示する説明文(省略時は自動取得) | +| `image` | | カードに表示する画像URL(省略時は自動取得) | +| `auto-fetch` | | `"true"`または`"false"`。自動取得の有効/無効を明示的に指定(デフォルト: `"true"`) | + +## 使用例 + +### 例1: シンプルなブログカード + +```markdown +記事の本文... + +{{< blogcard url="https://github.com" >}} + +続きの本文... +``` + +### 例2: カスタム情報を指定 + +```markdown +{{< blogcard + url="https://blog.example.com/article" + title="おすすめの記事" + description="この記事では〇〇について解説しています。" + image="https://blog.example.com/images/thumbnail.png" +>}} +``` + +### 例3: 自動取得を無効化 + +```markdown +{{< blogcard url="https://example.com" auto-fetch="false" >}} +``` + +## 注意事項 + +- OGP情報の自動取得にはインターネット接続が必要です +- CORS制限を回避するため、プロキシサービス(allOrigins)を使用しています +- 一部のサイトではOGP情報が正しく取得できない場合があります。その場合は手動で情報を指定してください +- 自動取得はページ読み込み時に非同期で行われるため、表示されるまでに若干の時間がかかります + +## デザイン + +ブログカードのデザインは以下の特徴があります: + +- ダークテーマに対応 +- レスポンシブデザイン(モバイルでは縦型レイアウトに切り替わる) +- ホバー時にアニメーション効果 +- サイトのアクセントカラー(オレンジ)を使用 + +## カスタマイズ + +CSSをカスタマイズする場合は、`static/style.css`の`.blogcard`セクションを編集してください。 diff --git a/layouts/partials/extended_footer.html b/layouts/partials/extended_footer.html index 1f37fd05..18cca56a 100644 --- a/layouts/partials/extended_footer.html +++ b/layouts/partials/extended_footer.html @@ -5,3 +5,6 @@ Cookies can be disabled in your browser. + + + diff --git a/layouts/shortcodes/blogcard.html b/layouts/shortcodes/blogcard.html new file mode 100644 index 00000000..442e0ac3 --- /dev/null +++ b/layouts/shortcodes/blogcard.html @@ -0,0 +1,38 @@ +{{- $url := .Get "url" -}} +{{- $title := .Get "title" -}} +{{- $description := .Get "description" -}} +{{- $image := .Get "image" -}} +{{- $autoFetch := .Get "auto-fetch" | default "true" -}} + +{{- if not $url -}} + {{- errorf "blogcard shortcode requires 'url' parameter" -}} +{{- end -}} + +{{- /* title/description/imageが指定されていない場合は自動取得を有効化 */ -}} +{{- if and (not $title) (not $description) (not $image) -}} + {{- $autoFetch = "true" -}} +{{- end -}} + +
+ + {{- if $image -}} +
+ {{ $title | default $url }} +
+ {{- else -}} +
+ + + + +
+ {{- end -}} +
+
{{ $title | default $url }}
+ {{- if $description -}} +
{{ $description }}
+ {{- end -}} +
{{ $url }}
+
+
+
diff --git a/static/blogcard.js b/static/blogcard.js new file mode 100644 index 00000000..5b0987bd --- /dev/null +++ b/static/blogcard.js @@ -0,0 +1,123 @@ +// ブログカード - OGP情報の自動取得機能 +// data-auto-fetch="true" 属性を持つブログカードに対してOGP情報を自動取得します + +(function() { + 'use strict'; + + // OGP情報を取得する関数 + async function fetchOGPData(url) { + try { + // CORS制限を回避するため、alloriginsを使用 + const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; + const response = await fetch(proxyUrl); + + if (!response.ok) { + throw new Error('Failed to fetch OGP data'); + } + + const data = await response.json(); + const html = data.contents; + + // HTMLパーサーを使用してOGPメタタグを抽出 + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + // OGPメタタグから情報を取得 + const getMetaContent = (property) => { + const element = doc.querySelector(`meta[property="${property}"]`) || + doc.querySelector(`meta[name="${property}"]`); + return element ? element.getAttribute('content') : null; + }; + + // タイトルを取得(OGP > title要素の順) + const title = getMetaContent('og:title') || + doc.querySelector('title')?.textContent || + url; + + // 説明を取得 + const description = getMetaContent('og:description') || + getMetaContent('description') || + ''; + + // 画像を取得 + let image = getMetaContent('og:image') || ''; + + // 相対URLを絶対URLに変換 + if (image && !image.startsWith('http')) { + const urlObj = new URL(url); + if (image.startsWith('//')) { + image = urlObj.protocol + image; + } else if (image.startsWith('/')) { + image = urlObj.origin + image; + } else { + image = urlObj.origin + '/' + image; + } + } + + return { + title: title.trim(), + description: description.trim(), + image: image + }; + } catch (error) { + console.error('Error fetching OGP data:', error); + return null; + } + } + + // ブログカードを更新する関数 + function updateBlogCard(card, ogpData) { + if (!ogpData) return; + + const link = card.querySelector('.blogcard-link'); + const thumbnail = card.querySelector('.blogcard-thumbnail'); + const title = card.querySelector('.blogcard-title'); + const description = card.querySelector('.blogcard-description'); + + // タイトルを更新 + if (ogpData.title && title) { + title.textContent = ogpData.title; + } + + // 説明を更新(存在しない場合は作成) + if (ogpData.description) { + if (description) { + description.textContent = ogpData.description; + } else { + const descElement = document.createElement('div'); + descElement.className = 'blogcard-description'; + descElement.textContent = ogpData.description; + title.parentNode.insertBefore(descElement, title.nextSibling); + } + } + + // 画像を更新 + if (ogpData.image && thumbnail) { + thumbnail.classList.remove('blogcard-thumbnail-placeholder'); + thumbnail.innerHTML = `${ogpData.title}`; + } + } + + // ページ読み込み時に自動取得設定のあるブログカードを処理 + function initBlogCards() { + const cards = document.querySelectorAll('.blogcard[data-auto-fetch="true"]'); + + cards.forEach(async (card) => { + const url = card.getAttribute('data-url'); + if (!url) return; + + // OGP情報を取得して更新 + const ogpData = await fetchOGPData(url); + if (ogpData) { + updateBlogCard(card, ogpData); + } + }); + } + + // DOMContentLoaded後に初期化 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initBlogCards); + } else { + initBlogCards(); + } +})(); diff --git a/static/style.css b/static/style.css index 8e559dcd..1ef636d2 100644 --- a/static/style.css +++ b/static/style.css @@ -170,3 +170,111 @@ blockquote { blockquote::before { display: none; } + +/* ブログカード */ +.blogcard { + margin: 24px 0; + border-radius: 8px; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.blogcard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 168, 106, 0.2); +} + +.blogcard-link { + display: flex; + border: 2px solid var(--accent); + border-radius: 8px; + overflow: hidden; + text-decoration: none; + color: inherit; + background-color: rgba(99, 110, 123, 0.2); + transition: background-color 0.2s ease; +} + +.blogcard-link:hover { + background-color: rgba(99, 110, 123, 0.3); +} + +.blogcard-thumbnail { + flex-shrink: 0; + width: 200px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(99, 110, 123, 0.4); + overflow: hidden; +} + +.blogcard-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + border: none; + margin: 0; +} + +.blogcard-thumbnail-placeholder { + color: var(--color-gray); +} + +.blogcard-thumbnail-placeholder svg { + width: 48px; + height: 48px; +} + +.blogcard-content { + flex: 1; + padding: 16px; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} + +.blogcard-title { + font-size: 16px; + font-weight: bold; + color: var(--foreground); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.blogcard-description { + font-size: 14px; + color: var(--color-gray); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.blogcard-url { + font-size: 12px; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* レスポンシブ対応 */ +@media (max-width: 600px) { + .blogcard-link { + flex-direction: column; + } + + .blogcard-thumbnail { + width: 100%; + height: 180px; + } +} From b4ceac28759206d0581d3c105a45dc73a607f9e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 17:09:25 +0000 Subject: [PATCH 02/10] Fix blogcard: remove border and improve OGP fetching Changes: - Remove border from blogcard for cleaner look - Add debug logging to blogcard.js for troubleshooting - Update documentation to recommend manual parameter specification - Add working examples for immediate testing - Improve error handling in OGP fetch logic The manual specification method is now recommended as the most reliable approach, with auto-fetch as a bonus feature. --- BLOGCARD_USAGE.md | 66 +++++++++++++++++++++++++++++++++++++--------- static/blogcard.js | 49 +++++++++++++++++++++++++++++----- static/style.css | 1 - 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/BLOGCARD_USAGE.md b/BLOGCARD_USAGE.md index edf1820f..ebc83212 100644 --- a/BLOGCARD_USAGE.md +++ b/BLOGCARD_USAGE.md @@ -6,21 +6,28 @@ ## 基本的な使い方 -### 1. URLのみを指定(自動OGP取得) +### 1. 手動で情報を指定(推奨) ```markdown -{{< blogcard url="https://example.com" >}} +{{< blogcard + url="https://example.com" + title="サイトのタイトル" + description="サイトの説明文" + image="https://example.com/image.png" +>}} ``` -URLのみを指定すると、JavaScriptが自動的にOGP(Open Graph Protocol)メタデータを取得し、タイトル、説明、画像を表示します。 +**手動指定が最も確実で安定しています。** タイトル、説明、画像を指定することで、即座に表示され、外部APIに依存しません。 -### 2. 手動で情報を指定 +### 2. URLのみを指定(自動OGP取得) ```markdown -{{< blogcard url="https://example.com" title="サイトのタイトル" description="サイトの説明文" image="https://example.com/image.png" >}} +{{< blogcard url="https://example.com" >}} ``` -OGP情報を手動で指定することもできます。この場合、自動取得は行われません。 +URLのみを指定すると、JavaScriptが自動的にOGP(Open Graph Protocol)メタデータを取得し、タイトル、説明、画像を表示します。 + +**注意**: 自動取得はプロキシ経由で行うため、不安定になることがあります。確実に表示したい場合は手動指定を推奨します。 ### 3. 一部の情報のみ指定 @@ -42,7 +49,18 @@ OGP情報を手動で指定することもできます。この場合、自動 ## 使用例 -### 例1: シンプルなブログカード +### 例1: 手動指定(推奨) + +```markdown +{{< blogcard + url="https://github.com" + title="GitHub: Where the world builds software" + description="GitHub is where over 100 million developers shape the future of software, together." + image="https://github.githubassets.com/images/modules/site/social-cards/github-social.png" +>}} +``` + +### 例2: シンプルなブログカード(自動取得) ```markdown 記事の本文... @@ -52,14 +70,25 @@ OGP情報を手動で指定することもできます。この場合、自動 続きの本文... ``` -### 例2: カスタム情報を指定 +**ヒント**: サイトのOGP画像URLを確認するには、そのサイトのHTMLソースを表示して `` を探してください。 + +### すぐに試せる例 + +以下は実際に動作する例です。記事にコピー&ペーストして試してみてください: ```markdown {{< blogcard - url="https://blog.example.com/article" - title="おすすめの記事" - description="この記事では〇〇について解説しています。" - image="https://blog.example.com/images/thumbnail.png" + url="https://github.com" + title="GitHub" + description="GitHub is where over 100 million developers shape the future of software, together." + image="https://github.githubassets.com/images/modules/site/social-cards/github-social.png" +>}} + +{{< blogcard + url="https://www.rust-lang.org/" + title="Rust Programming Language" + description="A language empowering everyone to build reliable and efficient software." + image="https://www.rust-lang.org/static/images/rust-social-wide.jpg" >}} ``` @@ -71,11 +100,22 @@ OGP情報を手動で指定することもできます。この場合、自動 ## 注意事項 +- **手動指定を推奨**: 最も確実で安定した表示方法です - OGP情報の自動取得にはインターネット接続が必要です -- CORS制限を回避するため、プロキシサービス(allOrigins)を使用しています +- CORS制限を回避するため、プロキシサービス(allOrigins)を使用していますが、不安定になることがあります - 一部のサイトではOGP情報が正しく取得できない場合があります。その場合は手動で情報を指定してください - 自動取得はページ読み込み時に非同期で行われるため、表示されるまでに若干の時間がかかります +## デバッグ方法 + +自動取得がうまくいかない場合、ブラウザの開発者ツールのコンソールを確認してください。`[BlogCard]`というプレフィックスでデバッグログが表示されます。 + +デバッグモードを無効にするには、`static/blogcard.js`の7行目を以下のように変更してください: + +```javascript +const DEBUG = false; // デバッグモード +``` + ## デザイン ブログカードのデザインは以下の特徴があります: diff --git a/static/blogcard.js b/static/blogcard.js index 5b0987bd..61a07f35 100644 --- a/static/blogcard.js +++ b/static/blogcard.js @@ -4,19 +4,32 @@ (function() { 'use strict'; + const DEBUG = true; // デバッグモード + + function log(...args) { + if (DEBUG) { + console.log('[BlogCard]', ...args); + } + } + // OGP情報を取得する関数 async function fetchOGPData(url) { + log('Fetching OGP data for:', url); + try { // CORS制限を回避するため、alloriginsを使用 const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; + log('Proxy URL:', proxyUrl); + const response = await fetch(proxyUrl); if (!response.ok) { - throw new Error('Failed to fetch OGP data'); + throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); const html = data.contents; + log('HTML fetched, length:', html.length); // HTMLパーサーを使用してOGPメタタグを抽出 const parser = new DOMParser(); @@ -41,6 +54,7 @@ // 画像を取得 let image = getMetaContent('og:image') || ''; + log('Raw image URL:', image); // 相対URLを絶対URLに変換 if (image && !image.startsWith('http')) { @@ -52,22 +66,31 @@ } else { image = urlObj.origin + '/' + image; } + log('Converted image URL:', image); } - return { + const result = { title: title.trim(), description: description.trim(), image: image }; + + log('OGP data fetched:', result); + return result; } catch (error) { - console.error('Error fetching OGP data:', error); + console.error('[BlogCard] Error fetching OGP data:', error); return null; } } // ブログカードを更新する関数 function updateBlogCard(card, ogpData) { - if (!ogpData) return; + if (!ogpData) { + log('No OGP data to update'); + return; + } + + log('Updating blog card with:', ogpData); const link = card.querySelector('.blogcard-link'); const thumbnail = card.querySelector('.blogcard-thumbnail'); @@ -77,6 +100,7 @@ // タイトルを更新 if (ogpData.title && title) { title.textContent = ogpData.title; + log('Title updated:', ogpData.title); } // 説明を更新(存在しない場合は作成) @@ -89,22 +113,33 @@ descElement.textContent = ogpData.description; title.parentNode.insertBefore(descElement, title.nextSibling); } + log('Description updated:', ogpData.description); } // 画像を更新 if (ogpData.image && thumbnail) { thumbnail.classList.remove('blogcard-thumbnail-placeholder'); - thumbnail.innerHTML = `${ogpData.title}`; + thumbnail.innerHTML = `${ogpData.title || ''}`; + log('Image updated:', ogpData.image); + } else { + log('No image to update, ogpData.image:', ogpData.image); } } // ページ読み込み時に自動取得設定のあるブログカードを処理 function initBlogCards() { + log('Initializing blog cards...'); const cards = document.querySelectorAll('.blogcard[data-auto-fetch="true"]'); + log('Found', cards.length, 'blog cards with auto-fetch'); cards.forEach(async (card) => { const url = card.getAttribute('data-url'); - if (!url) return; + log('Processing card for URL:', url); + + if (!url) { + log('No URL found, skipping'); + return; + } // OGP情報を取得して更新 const ogpData = await fetchOGPData(url); @@ -116,8 +151,10 @@ // DOMContentLoaded後に初期化 if (document.readyState === 'loading') { + log('Waiting for DOMContentLoaded...'); document.addEventListener('DOMContentLoaded', initBlogCards); } else { + log('DOM already loaded, initializing immediately'); initBlogCards(); } })(); diff --git a/static/style.css b/static/style.css index 1ef636d2..87d90a6e 100644 --- a/static/style.css +++ b/static/style.css @@ -186,7 +186,6 @@ blockquote::before { .blogcard-link { display: flex; - border: 2px solid var(--accent); border-radius: 8px; overflow: hidden; text-decoration: none; From b13efda9af940c9464c5109401ce63792636a557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 17:11:58 +0000 Subject: [PATCH 03/10] Improve blogcard performance with faster proxies and timeout Performance improvements: - Add multiple proxy fallback (corsproxy.io as primary, allOrigins as backup) - Implement 5-second timeout per proxy (max 10 seconds total) - Add loading animation while fetching OGP data - Log elapsed time for debugging This should significantly reduce the wait time from 10+ seconds to under 5 seconds in most cases. --- BLOGCARD_USAGE.md | 5 +- static/blogcard.js | 164 +++++++++++++++++++++++++++++---------------- static/style.css | 13 ++++ 3 files changed, 122 insertions(+), 60 deletions(-) diff --git a/BLOGCARD_USAGE.md b/BLOGCARD_USAGE.md index ebc83212..8b2bebc3 100644 --- a/BLOGCARD_USAGE.md +++ b/BLOGCARD_USAGE.md @@ -102,9 +102,10 @@ URLのみを指定すると、JavaScriptが自動的にOGP(Open Graph Protocol - **手動指定を推奨**: 最も確実で安定した表示方法です - OGP情報の自動取得にはインターネット接続が必要です -- CORS制限を回避するため、プロキシサービス(allOrigins)を使用していますが、不安定になることがあります +- 自動取得は複数のプロキシサービス(corsproxy.io → allOrigins)を順番に試行します +- 各プロキシは5秒でタイムアウトします(最大10秒で諦めます) +- 自動取得中はローディングアニメーションが表示されます - 一部のサイトではOGP情報が正しく取得できない場合があります。その場合は手動で情報を指定してください -- 自動取得はページ読み込み時に非同期で行われるため、表示されるまでに若干の時間がかかります ## デバッグ方法 diff --git a/static/blogcard.js b/static/blogcard.js index 61a07f35..048d9b45 100644 --- a/static/blogcard.js +++ b/static/blogcard.js @@ -12,75 +12,113 @@ } } + // タイムアウト付きfetch + async function fetchWithTimeout(url, timeout = 5000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } + + // 複数のプロキシを試す + const PROXY_SERVICES = [ + // corsproxy.io - 高速で信頼性が高い + (url) => `https://corsproxy.io/?${encodeURIComponent(url)}`, + // allOrigins - バックアップ + (url) => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`, + ]; + // OGP情報を取得する関数 async function fetchOGPData(url) { log('Fetching OGP data for:', url); - try { - // CORS制限を回避するため、alloriginsを使用 - const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; - log('Proxy URL:', proxyUrl); + for (let i = 0; i < PROXY_SERVICES.length; i++) { + const proxyUrl = PROXY_SERVICES[i](url); + log(`Trying proxy ${i + 1}/${PROXY_SERVICES.length}:`, proxyUrl); - const response = await fetch(proxyUrl); + try { + const response = await fetchWithTimeout(proxyUrl, 5000); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + if (!response.ok) { + log(`Proxy ${i + 1} failed with status:`, response.status); + continue; + } - const data = await response.json(); - const html = data.contents; - log('HTML fetched, length:', html.length); - - // HTMLパーサーを使用してOGPメタタグを抽出 - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - - // OGPメタタグから情報を取得 - const getMetaContent = (property) => { - const element = doc.querySelector(`meta[property="${property}"]`) || - doc.querySelector(`meta[name="${property}"]`); - return element ? element.getAttribute('content') : null; - }; - - // タイトルを取得(OGP > title要素の順) - const title = getMetaContent('og:title') || - doc.querySelector('title')?.textContent || - url; - - // 説明を取得 - const description = getMetaContent('og:description') || - getMetaContent('description') || - ''; - - // 画像を取得 - let image = getMetaContent('og:image') || ''; - log('Raw image URL:', image); - - // 相対URLを絶対URLに変換 - if (image && !image.startsWith('http')) { - const urlObj = new URL(url); - if (image.startsWith('//')) { - image = urlObj.protocol + image; - } else if (image.startsWith('/')) { - image = urlObj.origin + image; - } else { - image = urlObj.origin + '/' + image; + let html; + if (i === 0) { + // corsproxy.io returns HTML directly + html = await response.text(); + } else if (i === 1) { + // allOrigins returns JSON + const data = await response.json(); + html = data.contents; } - log('Converted image URL:', image); - } - const result = { - title: title.trim(), - description: description.trim(), - image: image - }; + log('HTML fetched, length:', html.length); + + // HTMLパーサーを使用してOGPメタタグを抽出 + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + // OGPメタタグから情報を取得 + const getMetaContent = (property) => { + const element = doc.querySelector(`meta[property="${property}"]`) || + doc.querySelector(`meta[name="${property}"]`); + return element ? element.getAttribute('content') : null; + }; + + // タイトルを取得(OGP > title要素の順) + const title = getMetaContent('og:title') || + doc.querySelector('title')?.textContent || + url; + + // 説明を取得 + const description = getMetaContent('og:description') || + getMetaContent('description') || + ''; + + // 画像を取得 + let image = getMetaContent('og:image') || ''; + log('Raw image URL:', image); + + // 相対URLを絶対URLに変換 + if (image && !image.startsWith('http')) { + const urlObj = new URL(url); + if (image.startsWith('//')) { + image = urlObj.protocol + image; + } else if (image.startsWith('/')) { + image = urlObj.origin + image; + } else { + image = urlObj.origin + '/' + image; + } + log('Converted image URL:', image); + } - log('OGP data fetched:', result); - return result; - } catch (error) { - console.error('[BlogCard] Error fetching OGP data:', error); - return null; + const result = { + title: title.trim(), + description: description.trim(), + image: image + }; + + log('OGP data fetched successfully:', result); + return result; + } catch (error) { + log(`Proxy ${i + 1} error:`, error.message); + // Try next proxy + continue; + } } + + // All proxies failed + console.error('[BlogCard] All proxies failed to fetch OGP data for:', url); + return null; } // ブログカードを更新する関数 @@ -141,8 +179,18 @@ return; } + // ローディング状態を追加 + card.classList.add('loading'); + // OGP情報を取得して更新 + const startTime = Date.now(); const ogpData = await fetchOGPData(url); + const elapsedTime = Date.now() - startTime; + log(`Fetch completed in ${elapsedTime}ms`); + + // ローディング状態を解除 + card.classList.remove('loading'); + if (ogpData) { updateBlogCard(card, ogpData); } diff --git a/static/style.css b/static/style.css index 87d90a6e..b87f5d8a 100644 --- a/static/style.css +++ b/static/style.css @@ -179,6 +179,19 @@ blockquote::before { transition: transform 0.2s ease, box-shadow 0.2s ease; } +.blogcard.loading .blogcard-thumbnail-placeholder { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + .blogcard:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(255, 168, 106, 0.2); From 1f0e547d9fb66fd67736bd3858ded6cb2d1f6521 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:19:34 +0900 Subject: [PATCH 04/10] update --- static/style.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/style.css b/static/style.css index b87f5d8a..7cef4c67 100644 --- a/static/style.css +++ b/static/style.css @@ -213,8 +213,8 @@ blockquote::before { .blogcard-thumbnail { flex-shrink: 0; - width: 200px; - height: 120px; + width: 240px; + height: 144px; display: flex; align-items: center; justify-content: center; @@ -241,7 +241,7 @@ blockquote::before { .blogcard-content { flex: 1; - padding: 16px; + padding: 16px 16px 8px; display: flex; flex-direction: column; justify-content: center; @@ -263,7 +263,7 @@ blockquote::before { .blogcard-description { font-size: 14px; color: var(--color-gray); - margin-bottom: 8px; + margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; @@ -287,6 +287,6 @@ blockquote::before { .blogcard-thumbnail { width: 100%; - height: 180px; + height: 200px; } } From b79a35f9bf37c311a660c3137eef6ed26712ecc7 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:19:55 +0900 Subject: [PATCH 05/10] test --- content/posts/tidy-first/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/content/posts/tidy-first/index.md b/content/posts/tidy-first/index.md index f564228a..6b8598f4 100644 --- a/content/posts/tidy-first/index.md +++ b/content/posts/tidy-first/index.md @@ -15,6 +15,9 @@ color: '' cover: cover.png --- +{{< blogcard url="https://blog.kyu08.com/posts/tidy-first/" >}} +{{< blogcard url="https://github.com/kyu08/blog/pull/244" >}} + 『[Tidy First?](https://www.oreilly.co.jp/books/9784814400911/)』を読んだ。 勉強になったところをまとめる。 From 1bdd2d5f8cbd0aa011ca106632248f737e0f9ed0 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:28:00 +0900 Subject: [PATCH 06/10] delete --- BLOGCARD_USAGE.md | 131 ---------------------------------------------- 1 file changed, 131 deletions(-) delete mode 100644 BLOGCARD_USAGE.md diff --git a/BLOGCARD_USAGE.md b/BLOGCARD_USAGE.md deleted file mode 100644 index 8b2bebc3..00000000 --- a/BLOGCARD_USAGE.md +++ /dev/null @@ -1,131 +0,0 @@ -# ブログカード機能の使い方 - -## 概要 - -ブログカード(リンクプレビュー)機能を使用すると、外部サイトへのリンクを視覚的に魅力的なカード形式で表示できます。 - -## 基本的な使い方 - -### 1. 手動で情報を指定(推奨) - -```markdown -{{< blogcard - url="https://example.com" - title="サイトのタイトル" - description="サイトの説明文" - image="https://example.com/image.png" ->}} -``` - -**手動指定が最も確実で安定しています。** タイトル、説明、画像を指定することで、即座に表示され、外部APIに依存しません。 - -### 2. URLのみを指定(自動OGP取得) - -```markdown -{{< blogcard url="https://example.com" >}} -``` - -URLのみを指定すると、JavaScriptが自動的にOGP(Open Graph Protocol)メタデータを取得し、タイトル、説明、画像を表示します。 - -**注意**: 自動取得はプロキシ経由で行うため、不安定になることがあります。確実に表示したい場合は手動指定を推奨します。 - -### 3. 一部の情報のみ指定 - -```markdown -{{< blogcard url="https://example.com" title="カスタムタイトル" >}} -``` - -一部の情報のみを指定した場合でも、他の情報は自動取得されます。 - -## パラメータ - -| パラメータ | 必須 | 説明 | -|-----------|------|------| -| `url` | ✓ | リンク先のURL | -| `title` | | カードに表示するタイトル(省略時は自動取得) | -| `description` | | カードに表示する説明文(省略時は自動取得) | -| `image` | | カードに表示する画像URL(省略時は自動取得) | -| `auto-fetch` | | `"true"`または`"false"`。自動取得の有効/無効を明示的に指定(デフォルト: `"true"`) | - -## 使用例 - -### 例1: 手動指定(推奨) - -```markdown -{{< blogcard - url="https://github.com" - title="GitHub: Where the world builds software" - description="GitHub is where over 100 million developers shape the future of software, together." - image="https://github.githubassets.com/images/modules/site/social-cards/github-social.png" ->}} -``` - -### 例2: シンプルなブログカード(自動取得) - -```markdown -記事の本文... - -{{< blogcard url="https://github.com" >}} - -続きの本文... -``` - -**ヒント**: サイトのOGP画像URLを確認するには、そのサイトのHTMLソースを表示して `` を探してください。 - -### すぐに試せる例 - -以下は実際に動作する例です。記事にコピー&ペーストして試してみてください: - -```markdown -{{< blogcard - url="https://github.com" - title="GitHub" - description="GitHub is where over 100 million developers shape the future of software, together." - image="https://github.githubassets.com/images/modules/site/social-cards/github-social.png" ->}} - -{{< blogcard - url="https://www.rust-lang.org/" - title="Rust Programming Language" - description="A language empowering everyone to build reliable and efficient software." - image="https://www.rust-lang.org/static/images/rust-social-wide.jpg" ->}} -``` - -### 例3: 自動取得を無効化 - -```markdown -{{< blogcard url="https://example.com" auto-fetch="false" >}} -``` - -## 注意事項 - -- **手動指定を推奨**: 最も確実で安定した表示方法です -- OGP情報の自動取得にはインターネット接続が必要です -- 自動取得は複数のプロキシサービス(corsproxy.io → allOrigins)を順番に試行します -- 各プロキシは5秒でタイムアウトします(最大10秒で諦めます) -- 自動取得中はローディングアニメーションが表示されます -- 一部のサイトではOGP情報が正しく取得できない場合があります。その場合は手動で情報を指定してください - -## デバッグ方法 - -自動取得がうまくいかない場合、ブラウザの開発者ツールのコンソールを確認してください。`[BlogCard]`というプレフィックスでデバッグログが表示されます。 - -デバッグモードを無効にするには、`static/blogcard.js`の7行目を以下のように変更してください: - -```javascript -const DEBUG = false; // デバッグモード -``` - -## デザイン - -ブログカードのデザインは以下の特徴があります: - -- ダークテーマに対応 -- レスポンシブデザイン(モバイルでは縦型レイアウトに切り替わる) -- ホバー時にアニメーション効果 -- サイトのアクセントカラー(オレンジ)を使用 - -## カスタマイズ - -CSSをカスタマイズする場合は、`static/style.css`の`.blogcard`セクションを編集してください。 From a83b384ec00e41502edb2860fe24c0f2c9436a23 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:28:10 +0900 Subject: [PATCH 07/10] Revert "test" This reverts commit b79a35f9bf37c311a660c3137eef6ed26712ecc7. --- content/posts/tidy-first/index.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/content/posts/tidy-first/index.md b/content/posts/tidy-first/index.md index 6b8598f4..f564228a 100644 --- a/content/posts/tidy-first/index.md +++ b/content/posts/tidy-first/index.md @@ -15,9 +15,6 @@ color: '' cover: cover.png --- -{{< blogcard url="https://blog.kyu08.com/posts/tidy-first/" >}} -{{< blogcard url="https://github.com/kyu08/blog/pull/244" >}} - 『[Tidy First?](https://www.oreilly.co.jp/books/9784814400911/)』を読んだ。 勉強になったところをまとめる。 From 16f8c4add468e21d10501dbbb3bb8df4ec58bf59 Mon Sep 17 00:00:00 2001 From: Tatsuya Kyushima <49891479+kyu08@users.noreply.github.com> Date: Sun, 9 Nov 2025 02:34:31 +0900 Subject: [PATCH 08/10] Update static/blogcard.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- static/blogcard.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/static/blogcard.js b/static/blogcard.js index 048d9b45..e0b3cd85 100644 --- a/static/blogcard.js +++ b/static/blogcard.js @@ -157,7 +157,16 @@ // 画像を更新 if (ogpData.image && thumbnail) { thumbnail.classList.remove('blogcard-thumbnail-placeholder'); - thumbnail.innerHTML = `${ogpData.title || ''}`; + // 既存の画像を削除 + while (thumbnail.firstChild) { + thumbnail.removeChild(thumbnail.firstChild); + } + // 新しいimg要素を安全に作成 + const img = document.createElement('img'); + img.setAttribute('src', ogpData.image); + img.setAttribute('alt', ogpData.title || ''); + img.setAttribute('loading', 'lazy'); + thumbnail.appendChild(img); log('Image updated:', ogpData.image); } else { log('No image to update, ogpData.image:', ogpData.image); From 166d7a99076a143feb0dfc3362732d828958b098 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 10:02:56 +0900 Subject: [PATCH 09/10] Fix async forEach pattern in blog card OGP fetching (#247) * Initial plan * Fix async forEach issue - use Promise.all with map pattern Co-authored-by: kyu08 <49891479+kyu08@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kyu08 <49891479+kyu08@users.noreply.github.com> --- static/blogcard.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/blogcard.js b/static/blogcard.js index e0b3cd85..5ffb9ef5 100644 --- a/static/blogcard.js +++ b/static/blogcard.js @@ -174,12 +174,12 @@ } // ページ読み込み時に自動取得設定のあるブログカードを処理 - function initBlogCards() { + async function initBlogCards() { log('Initializing blog cards...'); const cards = document.querySelectorAll('.blogcard[data-auto-fetch="true"]'); log('Found', cards.length, 'blog cards with auto-fetch'); - cards.forEach(async (card) => { + await Promise.all(Array.from(cards).map(async (card) => { const url = card.getAttribute('data-url'); log('Processing card for URL:', url); @@ -203,7 +203,7 @@ if (ogpData) { updateBlogCard(card, ogpData); } - }); + })); } // DOMContentLoaded後に初期化 From 5e73af7a833f63121aff4b6d6ca7ee13eec9951d Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Sun, 9 Nov 2025 13:13:46 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=AF=E3=81=AF=E3=83=96=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=AB=E3=83=BC=E3=83=89=E3=81=A8=E3=81=97=E3=81=A6=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- layouts/_default/_markup/render-link.html | 16 ++++++++++- layouts/partials/blogcard.html | 34 +++++++++++++++++++++++ layouts/shortcodes/blogcard.html | 29 +------------------ 3 files changed, 50 insertions(+), 29 deletions(-) create mode 100644 layouts/partials/blogcard.html diff --git a/layouts/_default/_markup/render-link.html b/layouts/_default/_markup/render-link.html index eb8714db..3f43e657 100644 --- a/layouts/_default/_markup/render-link.html +++ b/layouts/_default/_markup/render-link.html @@ -1 +1,15 @@ -{{ .Text | safeHTML }} +{{- /* リンクテキストとURLが同じ場合はblogcardとして表示 */ -}} +{{- $text := .Text -}} +{{- $dest := .Destination -}} + +{{- /* URLの正規化(プロトコルとトレイリングスラッシュを除去して比較) */ -}} +{{- $normalizedText := $text | replaceRE "^https?://" "" | strings.TrimSuffix "/" -}} +{{- $normalizedDest := $dest | replaceRE "^https?://" "" | strings.TrimSuffix "/" -}} + +{{- if eq $normalizedText $normalizedDest -}} + {{- /* blogcardとして表示 */ -}} + {{- partial "blogcard.html" (dict "url" $dest "autoFetch" "true") -}} +{{- else -}} + {{- /* 通常のリンクとして表示 */ -}} + {{ .Text | safeHTML }} +{{- end -}} diff --git a/layouts/partials/blogcard.html b/layouts/partials/blogcard.html new file mode 100644 index 00000000..b7021d6d --- /dev/null +++ b/layouts/partials/blogcard.html @@ -0,0 +1,34 @@ +{{- $url := .url -}} +{{- $title := .title -}} +{{- $description := .description -}} +{{- $image := .image -}} +{{- $autoFetch := .autoFetch | default "true" -}} + +{{- /* title/description/imageが指定されていない場合は自動取得を有効化 */ -}} +{{- if and (not $title) (not $description) (not $image) -}} + {{- $autoFetch = "true" -}} +{{- end -}} + + diff --git a/layouts/shortcodes/blogcard.html b/layouts/shortcodes/blogcard.html index 442e0ac3..d26762f9 100644 --- a/layouts/shortcodes/blogcard.html +++ b/layouts/shortcodes/blogcard.html @@ -8,31 +8,4 @@ {{- errorf "blogcard shortcode requires 'url' parameter" -}} {{- end -}} -{{- /* title/description/imageが指定されていない場合は自動取得を有効化 */ -}} -{{- if and (not $title) (not $description) (not $image) -}} - {{- $autoFetch = "true" -}} -{{- end -}} - - +{{- partial "blogcard.html" (dict "url" $url "title" $title "description" $description "image" $image "autoFetch" $autoFetch) -}}