-
Notifications
You must be signed in to change notification settings - Fork 0
Implement blog card link preview feature #246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kyu08
merged 10 commits into
main
from
claude/implement-blog-card-011CUvm6CxVowkigoux8VGUV
Nov 9, 2025
+400
−1
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
503f735
Add blog card (link preview) feature
claude b4ceac2
Fix blogcard: remove border and improve OGP fetching
claude b13efda
Improve blogcard performance with faster proxies and timeout
claude 1f0e547
update
kyu08 b79a35f
test
kyu08 1bdd2d5
delete
kyu08 a83b384
Revert "test"
kyu08 16f8c4a
Update static/blogcard.js
kyu08 166d7a9
Fix async forEach pattern in blog card OGP fetching (#247)
Copilot 5e73af7
独立したリンクはブログカードとして表示する
kyu08 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,15 @@ | ||
| <a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}{{ if or (strings.HasPrefix .Destination "http") (strings.HasPrefix .Destination "https") }} target="_blank"{{ end }} >{{ .Text | safeHTML }}</a> | ||
| {{- /* リンクテキストと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 -}} | ||
| {{- /* 通常のリンクとして表示 */ -}} | ||
| <a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}{{ if or (strings.HasPrefix .Destination "http") (strings.HasPrefix .Destination "https") }} target="_blank"{{ end }} >{{ .Text | safeHTML }}</a> | ||
| {{- end -}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 -}} | ||
|
|
||
| <div class="blogcard" data-url="{{ $url }}" data-auto-fetch="{{ $autoFetch }}"> | ||
| <a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="blogcard-link"> | ||
| {{- if $image -}} | ||
| <div class="blogcard-thumbnail"> | ||
| <img src="{{ $image }}" alt="{{ $title | default $url }}" loading="lazy"> | ||
| </div> | ||
| {{- else -}} | ||
| <div class="blogcard-thumbnail blogcard-thumbnail-placeholder"> | ||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> | ||
| <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> | ||
| </svg> | ||
| </div> | ||
| {{- end -}} | ||
| <div class="blogcard-content"> | ||
| <div class="blogcard-title">{{ $title | default $url }}</div> | ||
| {{- if $description -}} | ||
| <div class="blogcard-description">{{ $description }}</div> | ||
| {{- end -}} | ||
| <div class="blogcard-url">{{ $url }}</div> | ||
| </div> | ||
| </a> | ||
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| {{- $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 -}} | ||
|
|
||
| {{- partial "blogcard.html" (dict "url" $url "title" $title "description" $description "image" $image "autoFetch" $autoFetch) -}} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| // ブログカード - OGP情報の自動取得機能 | ||
| // data-auto-fetch="true" 属性を持つブログカードに対してOGP情報を自動取得します | ||
|
|
||
| (function() { | ||
| 'use strict'; | ||
|
|
||
| const DEBUG = true; // デバッグモード | ||
|
|
||
| function log(...args) { | ||
| if (DEBUG) { | ||
| console.log('[BlogCard]', ...args); | ||
| } | ||
| } | ||
|
|
||
| // タイムアウト付き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); | ||
|
|
||
| for (let i = 0; i < PROXY_SERVICES.length; i++) { | ||
| const proxyUrl = PROXY_SERVICES[i](url); | ||
| log(`Trying proxy ${i + 1}/${PROXY_SERVICES.length}:`, proxyUrl); | ||
|
|
||
| try { | ||
| const response = await fetchWithTimeout(proxyUrl, 5000); | ||
|
|
||
| if (!response.ok) { | ||
| log(`Proxy ${i + 1} failed with status:`, response.status); | ||
| continue; | ||
| } | ||
|
|
||
| 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('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); | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| // ブログカードを更新する関数 | ||
| function updateBlogCard(card, ogpData) { | ||
| if (!ogpData) { | ||
| log('No OGP data to update'); | ||
| return; | ||
| } | ||
|
|
||
| log('Updating blog card with:', ogpData); | ||
|
|
||
| const link = card.querySelector('.blogcard-link'); | ||
kyu08 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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; | ||
| log('Title updated:', 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); | ||
| } | ||
| log('Description updated:', ogpData.description); | ||
| } | ||
|
|
||
| // 画像を更新 | ||
| if (ogpData.image && thumbnail) { | ||
| thumbnail.classList.remove('blogcard-thumbnail-placeholder'); | ||
| // 既存の画像を削除 | ||
| 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); | ||
| } | ||
| } | ||
|
|
||
| // ページ読み込み時に自動取得設定のあるブログカードを処理 | ||
| 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'); | ||
|
|
||
| await Promise.all(Array.from(cards).map(async (card) => { | ||
| const url = card.getAttribute('data-url'); | ||
| log('Processing card for URL:', url); | ||
|
|
||
| if (!url) { | ||
| log('No URL found, skipping'); | ||
| 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); | ||
| } | ||
| })); | ||
| } | ||
|
|
||
| // DOMContentLoaded後に初期化 | ||
| if (document.readyState === 'loading') { | ||
| log('Waiting for DOMContentLoaded...'); | ||
| document.addEventListener('DOMContentLoaded', initBlogCards); | ||
| } else { | ||
| log('DOM already loaded, initializing immediately'); | ||
| initBlogCards(); | ||
| } | ||
| })(); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.