Compare commits

..

4 commits

5 changed files with 153 additions and 110 deletions

View file

@ -1,4 +1,6 @@
--- ---
import { getMetadata, getWaybackMetadata } from '../../plugins/get-metadata';
interface Props { interface Props {
url: string; url: string;
showArchive?: boolean; showArchive?: boolean;
@ -29,71 +31,12 @@ function formatDateToNumber(date: Date | string | undefined): string {
return `${year}${month}${day}`; return `${year}${month}${day}`;
} }
// Get metadata from the URL // Determine which date to use (prefer updatedDate if available, or fallback to the build time)
async function fetchMetadata(url: string) { const timestamp = (updatedDate ? formatDateToNumber(updatedDate) : formatDateToNumber(pubDate)) || formatDateToNumber(new Date());
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; LinkCard/1.0)'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
// 提取元数据
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
const descriptionMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ||
html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i);
const imageMatch = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ||
html.match(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i);
const siteNameMatch = html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
return {
title: titleMatch?.[1]?.trim() || new URL(url).hostname,
description: descriptionMatch?.[1]?.trim() || '',
image: imageMatch?.[1]?.trim() || '',
siteName: siteNameMatch?.[1]?.trim() || new URL(url).hostname,
domain: new URL(url).hostname
};
} catch (error) {
console.warn(`Failed to fetch metadata for ${url}:`, error);
const domain = new URL(url).hostname;
return {
title: domain,
description: '',
image: '',
siteName: domain,
domain
};
}
}
// Check if the URL is archived on the Wayback Machine at the updated/build time
// TODO: bringing user's own archive service link
async function checkArchive(url: string) {
try {
// Determine which date to use (prefer updatedDate if available, or fallback to the build time)
const timestamp = (updatedDate ? formatDateToNumber(updatedDate) : formatDateToNumber(pubDate)) || formatDateToNumber(new Date());
const archiveUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(url)}&timestamp=${timestamp}`;
const response = await fetch(archiveUrl);
const data = await response.json();
if (data.archived_snapshots?.closest?.available) {
return data.archived_snapshots.closest.url;
}
} catch (error) {
console.warn(`Failed to check archive for ${url}:`, error);
}
return null;
}
// extract metadata and archive URL // extract metadata and archive URL
const metadata = Astro.props.title ? siteMetadata : await fetchMetadata(url); const metadata = Astro.props.title ? siteMetadata : await getMetadata(url);
const archiveUrl = showArchive ? await checkArchive(url) : null; const archiveUrl = showArchive ? await getWaybackMetadata(url, timestamp) : null;
--- ---
<div class="link-card"> <div class="link-card">

View file

@ -50,26 +50,22 @@ const { title = pageTitle, author = siteConfig.defaultAuthor.name,description =
{spaEnabled && <ClientRouter fallback="animate" />} {spaEnabled && <ClientRouter fallback="animate" />}
<!--transitional animation is broken in firefox though--> <!--transitional animation is broken in firefox though-->
</head> </head>
<body> <body class="container">
<main>
{noscript && <div id="top" style="visibility: hidden">Back To Top</div>} {noscript && <div id="top" style="visibility: hidden">Back To Top</div>}
<div class="container"> <header>
<div class="terminal-path"> <div class="terminal-path">
{path} {path}
</div> </div>
<Navbar /> <Navbar />
<Search /> <Search />
</header>
<div class="content-box"> <main class="content-box">
<slot /> <slot />
</div>
</div>
</main> </main>
<footer class="footer"> <footer class="footer">
<div class="floating"> <div class="floating">
<BackToTop/> <BackToTop/>
{noscript ? <ThemeSwitcher_CSSOnly/> : <ThemeSwitcher/>} {noscript ? <ThemeSwitcher_CSSOnly/> : <ThemeSwitcher/>}
</div> </div>
<div class="container"> <div class="container">

View file

@ -26,7 +26,7 @@ const slug = Astro.params.slug;
const author = Array.isArray(entry.data.author) ? entry.data.author : (entry.data.author !== undefined ? [entry.data.author] : [{collection: 'authors', id: siteConfig.defaultAuthor.id}]); const author = Array.isArray(entry.data.author) ? entry.data.author : (entry.data.author !== undefined ? [entry.data.author] : [{collection: 'authors', id: siteConfig.defaultAuthor.id}]);
// Get author data // Get author data
const authorData = await Promise.all((author).map((singleAuthor) => getEntry(singleAuthor).then(authorEntry => authorEntry.data))) const authorData = await Promise.all((author).map((singleAuthor) => getEntry(singleAuthor).then(authorEntry => authorEntry?.data)))
const authorInfo = authorData.includes(undefined) ? [{data: siteConfig.defaultAuthor}] : authorData; const authorInfo = authorData.includes(undefined) ? [{data: siteConfig.defaultAuthor}] : authorData;
// get featured image and use it as og:image // get featured image and use it as og:image
@ -49,6 +49,7 @@ const cover = customFeaturedImage || matchedImage_src?.src || firstImageURL || `
ogImage={cover} ogImage={cover}
author={authorInfo.map((a: any) => a.name).join(', ')} author={authorInfo.map((a: any) => a.name).join(', ')}
> >
<article>
<h1 class="title">{entry.data.title}</h1> <h1 class="title">{entry.data.title}</h1>
{authorInfo.map((a: any) => <AuthorInfo data={a} />)} {authorInfo.map((a: any) => <AuthorInfo data={a} />)}
<span class="date">{new Date(entry.data.pubDate).toISOString().split('T')[0]}</span> <span class="date">{new Date(entry.data.pubDate).toISOString().split('T')[0]}</span>
@ -57,7 +58,7 @@ const cover = customFeaturedImage || matchedImage_src?.src || firstImageURL || `
<div class="content"> <div class="content">
<Content /> <Content />
</div> </div>
</article>
<div class="extra-post" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1rem;"> <div class="extra-post" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
<ReplyViaEmail title={entry.data.title} email={authorInfo[0].email} /> <ReplyViaEmail title={entry.data.title} email={authorInfo[0].email} />
<br> <br>

103
src/plugins/get-metadata.js Normal file
View file

@ -0,0 +1,103 @@
import { parse } from "ultrahtml";
import "ultrahtml/selector";
import {querySelector} from "ultrahtml/selector";
// Simple in-memory cache
const metadataCache = new Map();
export async function getMetadata(url) {
if (metadataCache.has(url)) {
const cached = metadataCache.get(url);
return cached.data;
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; LinkCard/1.1)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9',
}
});
if (!response.ok) {
throw new Error(`Request not succeed: HTTP ${response.status}`);
}
const html = await response.text();
const document = parse(html);
const metadata = {
title: '',
description: '',
image: '',
siteName: '',
domain: new URL(url).hostname
};
// Extract title
const titleElement = querySelector(document,'title');
if (titleElement) {
metadata.title = titleElement.children[0].value.trim();
}
// Extract other metadata
const descriptionElement = querySelector(document, 'meta[name="description"]');
if (descriptionElement) {
metadata.description = descriptionElement.attributes.content || '';
}
const imageElement = querySelector(document,'meta[property="og:image"]') || querySelector(document,'meta[name="twitter:image"]');
if (imageElement) {
metadata.image = imageElement.attributes.content || '';
}
const siteNameElement = querySelector(document,'meta[property="og:site_name"]')
if (siteNameElement) {
metadata.siteName = siteNameElement.attributes.content || '';
} else {
metadata.siteName = metadata.domain; // Fallback to domain if no site name found
}
// Store in cache
metadataCache.set(url, {
data: metadata
});
return metadata;
} catch (error) {
console.warn(`Failed to fetch metadata for ${url}:`, error);
const domain = new URL(url).hostname;
return {
title: domain,
description: '',
image: '',
siteName: domain,
domain
};
}
}
export async function getWaybackMetadata(url, timestamp){
try {
const archiveUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(url)}&timestamp=${timestamp}`;
if (metadataCache.has(archiveUrl)) {
const cached = metadataCache.get(archiveUrl);
return cached.data;
}
const response = await fetch(archiveUrl);
const data = await response.json();
if (data.archived_snapshots?.closest?.available) {
// Store in cache
metadataCache.set(archiveUrl, {
data: data.archived_snapshots.closest.url
});
return data.archived_snapshots.closest.url;
}
} catch (error) {
console.warn(`Failed to check archive for ${url}:`, error);
}
return null;
}

View file

@ -70,7 +70,7 @@ a:hover {
opacity: 0.8; opacity: 0.8;
} }
main { body {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem; padding: 2rem 1rem;