Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions layouts/partials/extended_footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
Cookies can be disabled in your browser.
</span>
</font>

<!-- ブログカード -->
<script src="{{ "blogcard.js" | absURL }}"></script>
38 changes: 38 additions & 0 deletions layouts/shortcodes/blogcard.html
Original file line number Diff line number Diff line change
@@ -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 -}}

<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>
208 changes: 208 additions & 0 deletions static/blogcard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// ブログカード - 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');
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');
thumbnail.innerHTML = `<img src="${ogpData.image}" alt="${ogpData.title || ''}" loading="lazy">`;
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');
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();
}
})();
120 changes: 120 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,123 @@ 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.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);
}

.blogcard-link {
display: flex;
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: 240px;
height: 144px;
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 16px 8px;
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: 4px;
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: 200px;
}
}
Loading