|
| 1 | +// ブログカード - OGP情報の自動取得機能 |
| 2 | +// data-auto-fetch="true" 属性を持つブログカードに対してOGP情報を自動取得します |
| 3 | + |
| 4 | +(function() { |
| 5 | + 'use strict'; |
| 6 | + |
| 7 | + const DEBUG = true; // デバッグモード |
| 8 | + |
| 9 | + function log(...args) { |
| 10 | + if (DEBUG) { |
| 11 | + console.log('[BlogCard]', ...args); |
| 12 | + } |
| 13 | + } |
| 14 | + |
| 15 | + // タイムアウト付きfetch |
| 16 | + async function fetchWithTimeout(url, timeout = 5000) { |
| 17 | + const controller = new AbortController(); |
| 18 | + const timeoutId = setTimeout(() => controller.abort(), timeout); |
| 19 | + |
| 20 | + try { |
| 21 | + const response = await fetch(url, { signal: controller.signal }); |
| 22 | + clearTimeout(timeoutId); |
| 23 | + return response; |
| 24 | + } catch (error) { |
| 25 | + clearTimeout(timeoutId); |
| 26 | + throw error; |
| 27 | + } |
| 28 | + } |
| 29 | + |
| 30 | + // 複数のプロキシを試す |
| 31 | + const PROXY_SERVICES = [ |
| 32 | + // corsproxy.io - 高速で信頼性が高い |
| 33 | + (url) => `https://corsproxy.io/?${encodeURIComponent(url)}`, |
| 34 | + // allOrigins - バックアップ |
| 35 | + (url) => `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`, |
| 36 | + ]; |
| 37 | + |
| 38 | + // OGP情報を取得する関数 |
| 39 | + async function fetchOGPData(url) { |
| 40 | + log('Fetching OGP data for:', url); |
| 41 | + |
| 42 | + for (let i = 0; i < PROXY_SERVICES.length; i++) { |
| 43 | + const proxyUrl = PROXY_SERVICES[i](url); |
| 44 | + log(`Trying proxy ${i + 1}/${PROXY_SERVICES.length}:`, proxyUrl); |
| 45 | + |
| 46 | + try { |
| 47 | + const response = await fetchWithTimeout(proxyUrl, 5000); |
| 48 | + |
| 49 | + if (!response.ok) { |
| 50 | + log(`Proxy ${i + 1} failed with status:`, response.status); |
| 51 | + continue; |
| 52 | + } |
| 53 | + |
| 54 | + let html; |
| 55 | + if (i === 0) { |
| 56 | + // corsproxy.io returns HTML directly |
| 57 | + html = await response.text(); |
| 58 | + } else if (i === 1) { |
| 59 | + // allOrigins returns JSON |
| 60 | + const data = await response.json(); |
| 61 | + html = data.contents; |
| 62 | + } |
| 63 | + |
| 64 | + log('HTML fetched, length:', html.length); |
| 65 | + |
| 66 | + // HTMLパーサーを使用してOGPメタタグを抽出 |
| 67 | + const parser = new DOMParser(); |
| 68 | + const doc = parser.parseFromString(html, 'text/html'); |
| 69 | + |
| 70 | + // OGPメタタグから情報を取得 |
| 71 | + const getMetaContent = (property) => { |
| 72 | + const element = doc.querySelector(`meta[property="${property}"]`) || |
| 73 | + doc.querySelector(`meta[name="${property}"]`); |
| 74 | + return element ? element.getAttribute('content') : null; |
| 75 | + }; |
| 76 | + |
| 77 | + // タイトルを取得(OGP > title要素の順) |
| 78 | + const title = getMetaContent('og:title') || |
| 79 | + doc.querySelector('title')?.textContent || |
| 80 | + url; |
| 81 | + |
| 82 | + // 説明を取得 |
| 83 | + const description = getMetaContent('og:description') || |
| 84 | + getMetaContent('description') || |
| 85 | + ''; |
| 86 | + |
| 87 | + // 画像を取得 |
| 88 | + let image = getMetaContent('og:image') || ''; |
| 89 | + log('Raw image URL:', image); |
| 90 | + |
| 91 | + // 相対URLを絶対URLに変換 |
| 92 | + if (image && !image.startsWith('http')) { |
| 93 | + const urlObj = new URL(url); |
| 94 | + if (image.startsWith('//')) { |
| 95 | + image = urlObj.protocol + image; |
| 96 | + } else if (image.startsWith('/')) { |
| 97 | + image = urlObj.origin + image; |
| 98 | + } else { |
| 99 | + image = urlObj.origin + '/' + image; |
| 100 | + } |
| 101 | + log('Converted image URL:', image); |
| 102 | + } |
| 103 | + |
| 104 | + const result = { |
| 105 | + title: title.trim(), |
| 106 | + description: description.trim(), |
| 107 | + image: image |
| 108 | + }; |
| 109 | + |
| 110 | + log('OGP data fetched successfully:', result); |
| 111 | + return result; |
| 112 | + } catch (error) { |
| 113 | + log(`Proxy ${i + 1} error:`, error.message); |
| 114 | + // Try next proxy |
| 115 | + continue; |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + // All proxies failed |
| 120 | + console.error('[BlogCard] All proxies failed to fetch OGP data for:', url); |
| 121 | + return null; |
| 122 | + } |
| 123 | + |
| 124 | + // ブログカードを更新する関数 |
| 125 | + function updateBlogCard(card, ogpData) { |
| 126 | + if (!ogpData) { |
| 127 | + log('No OGP data to update'); |
| 128 | + return; |
| 129 | + } |
| 130 | + |
| 131 | + log('Updating blog card with:', ogpData); |
| 132 | + |
| 133 | + const link = card.querySelector('.blogcard-link'); |
| 134 | + const thumbnail = card.querySelector('.blogcard-thumbnail'); |
| 135 | + const title = card.querySelector('.blogcard-title'); |
| 136 | + const description = card.querySelector('.blogcard-description'); |
| 137 | + |
| 138 | + // タイトルを更新 |
| 139 | + if (ogpData.title && title) { |
| 140 | + title.textContent = ogpData.title; |
| 141 | + log('Title updated:', ogpData.title); |
| 142 | + } |
| 143 | + |
| 144 | + // 説明を更新(存在しない場合は作成) |
| 145 | + if (ogpData.description) { |
| 146 | + if (description) { |
| 147 | + description.textContent = ogpData.description; |
| 148 | + } else { |
| 149 | + const descElement = document.createElement('div'); |
| 150 | + descElement.className = 'blogcard-description'; |
| 151 | + descElement.textContent = ogpData.description; |
| 152 | + title.parentNode.insertBefore(descElement, title.nextSibling); |
| 153 | + } |
| 154 | + log('Description updated:', ogpData.description); |
| 155 | + } |
| 156 | + |
| 157 | + // 画像を更新 |
| 158 | + if (ogpData.image && thumbnail) { |
| 159 | + thumbnail.classList.remove('blogcard-thumbnail-placeholder'); |
| 160 | + // 既存の画像を削除 |
| 161 | + while (thumbnail.firstChild) { |
| 162 | + thumbnail.removeChild(thumbnail.firstChild); |
| 163 | + } |
| 164 | + // 新しいimg要素を安全に作成 |
| 165 | + const img = document.createElement('img'); |
| 166 | + img.setAttribute('src', ogpData.image); |
| 167 | + img.setAttribute('alt', ogpData.title || ''); |
| 168 | + img.setAttribute('loading', 'lazy'); |
| 169 | + thumbnail.appendChild(img); |
| 170 | + log('Image updated:', ogpData.image); |
| 171 | + } else { |
| 172 | + log('No image to update, ogpData.image:', ogpData.image); |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + // ページ読み込み時に自動取得設定のあるブログカードを処理 |
| 177 | + async function initBlogCards() { |
| 178 | + log('Initializing blog cards...'); |
| 179 | + const cards = document.querySelectorAll('.blogcard[data-auto-fetch="true"]'); |
| 180 | + log('Found', cards.length, 'blog cards with auto-fetch'); |
| 181 | + |
| 182 | + await Promise.all(Array.from(cards).map(async (card) => { |
| 183 | + const url = card.getAttribute('data-url'); |
| 184 | + log('Processing card for URL:', url); |
| 185 | + |
| 186 | + if (!url) { |
| 187 | + log('No URL found, skipping'); |
| 188 | + return; |
| 189 | + } |
| 190 | + |
| 191 | + // ローディング状態を追加 |
| 192 | + card.classList.add('loading'); |
| 193 | + |
| 194 | + // OGP情報を取得して更新 |
| 195 | + const startTime = Date.now(); |
| 196 | + const ogpData = await fetchOGPData(url); |
| 197 | + const elapsedTime = Date.now() - startTime; |
| 198 | + log(`Fetch completed in ${elapsedTime}ms`); |
| 199 | + |
| 200 | + // ローディング状態を解除 |
| 201 | + card.classList.remove('loading'); |
| 202 | + |
| 203 | + if (ogpData) { |
| 204 | + updateBlogCard(card, ogpData); |
| 205 | + } |
| 206 | + })); |
| 207 | + } |
| 208 | + |
| 209 | + // DOMContentLoaded後に初期化 |
| 210 | + if (document.readyState === 'loading') { |
| 211 | + log('Waiting for DOMContentLoaded...'); |
| 212 | + document.addEventListener('DOMContentLoaded', initBlogCards); |
| 213 | + } else { |
| 214 | + log('DOM already loaded, initializing immediately'); |
| 215 | + initBlogCards(); |
| 216 | + } |
| 217 | +})(); |
0 commit comments