Compare commits
No commits in common. "be07b543aa41bf2b1a579d14e8f8e3b3e03d86d7" and "98d23e7c94e8e2708f2044a5d2345288adcc5d74" have entirely different histories.
be07b543aa
...
98d23e7c94
5 changed files with 110 additions and 153 deletions
|
@ -1,6 +1,4 @@
|
||||||
---
|
---
|
||||||
import { getMetadata, getWaybackMetadata } from '../../plugins/get-metadata';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
showArchive?: boolean;
|
showArchive?: boolean;
|
||||||
|
@ -31,12 +29,71 @@ function formatDateToNumber(date: Date | string | undefined): string {
|
||||||
return `${year}${month}${day}`;
|
return `${year}${month}${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which date to use (prefer updatedDate if available, or fallback to the build time)
|
// Get metadata from the URL
|
||||||
const timestamp = (updatedDate ? formatDateToNumber(updatedDate) : formatDateToNumber(pubDate)) || formatDateToNumber(new Date());
|
async function fetchMetadata(url: string) {
|
||||||
|
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)}×tamp=${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 getMetadata(url);
|
const metadata = Astro.props.title ? siteMetadata : await fetchMetadata(url);
|
||||||
const archiveUrl = showArchive ? await getWaybackMetadata(url, timestamp) : null;
|
const archiveUrl = showArchive ? await checkArchive(url) : null;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="link-card">
|
<div class="link-card">
|
||||||
|
|
|
@ -50,29 +50,33 @@ 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 class="container">
|
<body>
|
||||||
{noscript && <div id="top" style="visibility: hidden">Back To Top</div>}
|
<main>
|
||||||
<header>
|
{noscript && <div id="top" style="visibility: hidden">Back To Top</div>}
|
||||||
<div class="terminal-path">
|
<div class="container">
|
||||||
{path}
|
<div class="terminal-path">
|
||||||
</div>
|
{path}
|
||||||
<Navbar />
|
</div>
|
||||||
<Search />
|
<Navbar />
|
||||||
</header>
|
<Search />
|
||||||
<main class="content-box">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer">
|
<div class="content-box">
|
||||||
<div class="floating">
|
<slot />
|
||||||
<BackToTop/>
|
|
||||||
{noscript ? <ThemeSwitcher_CSSOnly/> : <ThemeSwitcher/>}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="container">
|
</div>
|
||||||
<Fragment set:html={customFooter} />
|
</main>
|
||||||
<p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p>
|
|
||||||
</div>
|
<footer class="footer">
|
||||||
</footer>
|
<div class="floating">
|
||||||
|
<BackToTop/>
|
||||||
|
|
||||||
|
{noscript ? <ThemeSwitcher_CSSOnly/> : <ThemeSwitcher/>}
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<Fragment set:html={customFooter} />
|
||||||
|
<p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
{statisticsEnabled && <Statistics/>}
|
{statisticsEnabled && <Statistics/>}
|
||||||
{ (siteConfig.neko.enabled && !noscript) &&
|
{ (siteConfig.neko.enabled && !noscript) &&
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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,27 +49,26 @@ 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>
|
{headings.length !== 0 && <TableOfContents headings={headings} />}
|
||||||
{headings.length !== 0 && <TableOfContents headings={headings} />}
|
{entry.data.summary && <p class="summary">{entry.data.summary}</p> }
|
||||||
{entry.data.summary && <p class="summary">{entry.data.summary}</p> }
|
<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>
|
||||||
<a href="/blog">← Back to posts</a>
|
<a href="/blog">← Back to posts</a>
|
||||||
{!noscript && <h2>Comments</h2> <Comments />}
|
{!noscript && <h2>Comments</h2> <Comments />}
|
||||||
{!noscript &&
|
{!noscript &&
|
||||||
<script>
|
<script>
|
||||||
import "katex/dist/contrib/copy-tex.js"
|
import "katex/dist/contrib/copy-tex.js"
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
<style>
|
<style>
|
||||||
p.summary {
|
p.summary {
|
||||||
|
|
|
@ -1,103 +0,0 @@
|
||||||
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)}×tamp=${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;
|
|
||||||
}
|
|
|
@ -70,7 +70,7 @@ a:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
main {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue