Firefly 魔改指南

3557 字
18 分钟
Firefly 魔改指南

“十年之约”页面#

src/pages/ 下新建文件 decennial.astro 并写入:

---
import MainGridLayout from "@/layouts/MainGridLayout.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { Icon } from "astro-icon";
import { getCollection } from "astro:content";
import { siteConfig } from "@/config";
const allBlogPosts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
let words = 0;
for (let post of allBlogPosts) {
if (post.body) {
let text = post.body
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]*`/g, "")
.replace(/\s+/g, " ")
.trim();
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
const englishChars = text.match(/[a-zA-Z]/g) || [];
words += chineseChars.length + englishChars.length;
}
}
const sorted = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.data.published);
const dateB = new Date(b.data.published);
return dateA > dateB ? -1 : 1;
});
const title = sorted[0].data.title;
const startDate = siteConfig.siteStartDate || '2025-01-01';
const begin = new Date(startDate);
let end = new Date(startDate);
end.setDate(begin.getDate() + 3650);
const latestDate = new Date(sorted[0].data.published);
const total = Math.ceil((end - begin) / (1000 * 60 * 60 * 24));
const dayU = i18n(I18nKey.decennialDayUnit);
const dayAgoU = i18n(I18nKey.decennialDaysAgo);
const daysTogoU = i18n(I18nKey.decennialDaysToGo);
---
<style>
.container {
width: 100%;
background: var(--card-bg);
border-radius: 20px;
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.1);
padding: 40px;
text-align: center;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 8px;
background: linear-gradient(90deg, #ff7e5f, #feb47b);
}
h1 {
font-size: 2.8rem;
margin-bottom: 30px;
letter-spacing: 2px;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);
}
.avatar-container {
margin: 20px auto 30px;
width: 150px;
height: 150px;
border-radius: 50%;
overflow: hidden;
border: 5px solid #f8f9fa;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
position: relative;
user-select: none;
}
.avatar {
width: 100%;
height: 100%;
background: linear-gradient(45deg, #3498db, #9b59b6);
display: flex;
justify-content: center;
align-items: center;
font-size: 3rem;
}
.decennial-title {
margin: 0 0 30px 0;
}
#daysCounter {
margin: 20px 0;
}
#days-label {
font-size: 1.2rem;
margin-bottom: 30px;
}
.date-info {
display: flex;
justify-content: space-between;
margin: 30px 0;
flex-wrap: wrap;
}
.date-box {
flex: 1;
min-width: 200px;
padding: 20px;
margin: 10px;
background: var(--page-bg);
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease;
}
.date-box:hover {
transform: translateY(-5px);
}
.date-title {
margin-bottom: 10px;
}
.label-container {
display: flex;
flex-direction: column;
text-align: left;
line-height: 2.5;
}
.horizontal-items {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.progress-container {
margin: 40px 0 20px;
text-align: left;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 1.1rem;
}
.progress-bar {
height: 20px;
background: var(--page-bg);
border-radius: 10px;
overflow: hidden;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.1);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3498db, #2ecc71);
border-radius: 10px;
width: 0;
transition: width 1.5s ease-in-out;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.2) 75%,
transparent 75%,
transparent
);
background-size: 20px 20px;
animation: move 1s linear infinite;
}
@keyframes move {
0% {
background-position: 0 0;
}
100% {
background-position: 20px 0;
}
}
.quote {
margin-top: 30px;
font-style: italic;
color: #7f8c8d;
font-size: 1.1rem;
line-height: 1.6;
border-left: 4px solid #3498db;
padding-left: 15px;
text-align: left;
}
img {
max-width: 100%;
}
@media (max-width: 600px) {
.container {
padding: 20px;
}
h1 {
font-size: 2rem;
}
.days-counter {
font-size: 3rem;
}
.date-info {
flex-direction: column;
}
.date-box {
min-width: 100%;
}
}
</style>
<MainGridLayout title={i18n(I18nKey.decennialTitle)} description={i18n(I18nKey.decennialDesc)}>
<div class="container">
<div class="decennial-title text-4xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors truncate pr-1">{i18n(I18nKey.decennialSubtitle)}</div>
<div class="avatar-container">
<div class="avatar">
<img src="https://justpureh2o.cn/avatar.jpg">
</div>
</div>
<div class="text-7xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors truncate pr-1" id="daysCounter"></div>
<div class="days-label font-bold text-base text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors truncate pr-4">{i18n(I18nKey.decennialDaysCounter)}</div>
<div class="date-info">
<div class="date-box">
<div class="date-title text-base text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialCountFrom)}</div>
<div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="startDate">2025-01-01</div>
</div>
<div class="date-box">
<div class="date-title text-base text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialCountTo)}</div>
<div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="endDate">2034-12-29</div>
</div>
</div>
<div class="label-container">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">📄 {i18n(I18nKey.decennialPromiseTitle)}</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry1Title">{i18n(I18nKey.decennialPromiseEntry1)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry1Text"></div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${sorted.length >= 10 ? "oklch(0.6 0.2 170)" : "oklch(0.7 0.2 25)"}`>{i18n(I18nKey.decennialPromiseEntry2)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{sorted.length} {i18n(I18nKey.decennialPostUnit)}</div>
</div>
</div>
<div class="label-container">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">🎉 {i18n(I18nKey.decennialAchievementTitle)}</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${sorted.length >= 100 ? "oklch(0.6 0.2 170)" : ""}`>{i18n(I18nKey.decennialAchievementEntry1)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{sorted.length} {i18n(I18nKey.decennialPostUnit)}</div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${words >= 100000 ? "oklch(0.6 0.2 170)" : ""}`>{i18n(I18nKey.decennialAchievementEntry2)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{words} {i18n(I18nKey.decennialCharacterUnit)}</div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${words >= 1000000 ? "oklch(0.6 0.2 170)" : ""}`>{i18n(I18nKey.decennialAchievementEntry3)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{words} {i18n(I18nKey.decennialCharacterUnit)}</div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry4Title">{i18n(I18nKey.decennialAchievementEntry4)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry4Text"></div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry5Title">{i18n(I18nKey.decennialAchievementEntry5)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry5Text"></div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry6Title">{i18n(I18nKey.decennialAchievementEntry6)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry6Text"></div>
</div>
</div>
<div class="progress-container">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialAchievementsTitle)}</div>
<div class="progress-label">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialProgressTitle)}</div>
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="progressPercent"></div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div class="quote text-base font-bold text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialQuote)}</div>
</div>
</MainGridLayout>
<script is:inline define:vars={{ startDate, latestDate, title, dayU, dayAgoU, daysTogoU }}>
const today = new Date();
const begin = new Date(startDate);
const end = new Date(startDate);
const latest = new Date(latestDate);
end.setDate(end.getDate() + 3650);
const elapsed = Math.ceil((today - latest) / (1000 * 60 * 60 * 24));
const counter = Math.ceil((today - begin) / (1000 * 60 * 60 * 24));
const daysTogo = Math.max(Math.ceil((end - today) / (1000 * 60 * 60 * 24)), 0);
// 计算进度百分比
const progress = (counter / 3650 * 100).toFixed(3);
// 格式化日期为 YYYY-MM-DD
function formatDate(date) {
date = new Date(date);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 更新页面内容
document.getElementById('startDate').textContent = formatDate(begin);
document.getElementById('endDate').textContent = formatDate(end);
document.getElementById('progressPercent').textContent = `${progress}%`;
document.getElementById('entry1Title').style.color = elapsed <= 180 ? "oklch(0.6 0.2 170)" : "oklch(0.7 0.2 25)";
document.getElementById('entry1Text').textContent = `${elapsed} ${dayAgoU}: ${title}`;
document.getElementById('entry4Title').style.color = counter >= 100 ? "oklch(0.6 0.2 170)" : "";
document.getElementById('entry4Text').textContent = `${counter} ${dayU}`;
document.getElementById('entry5Title').style.color = counter >= 1000 ? "oklch(0.6 0.2 170)" : "";
document.getElementById('entry5Text').textContent = `${counter} ${dayU}`;
document.getElementById('entry6Title').style.color = daysTogo === 0 ? "oklch(0.6 0.2 170)" : "";
document.getElementById('entry6Text').textContent = `${daysTogo} ${daysTogoU}`;
// 动画显示进度条
setTimeout(() => {
document.getElementById('progressFill').style.width = `${progress}%`;
}, 500);
// 更新运行天数(动态效果)
let currentDays = 0;
const daysCounter = document.getElementById('daysCounter');
const increment = Math.ceil(counter / 100);
const counterInterval = setInterval(() => {
currentDays += increment;
if (currentDays >= counter) {
currentDays = counter;
clearInterval(counterInterval);
}
daysCounter.textContent = currentDays;
}, 20);
</script>

请在 siteConfig 中写入正确的博客建站日期。若对应字段为空,则默认设置为 2025 年 1 月 1 日建站。

同时,分别在 src/i18n/i18nKey.tssrc/i18n/languages/zh_CN.tssrc/i18n/languages/en.ts 三个文件中新增:

// 十年之约 i18nKey.ts
decennialTitle = "decennialTitle",
decennialDesc = "decennialDesc",
decennialSubtitle = "decennialSubtitle",
decennialDaysCounter = "decennialDaysCounter",
decennialCountFrom = "decennialCountFrom",
decennialCountTo = "decennialCountTo",
decennialPromiseTitle = "decennialPromiseTitle",
decennialAchievementTitle = "decennialAchievementTitle",
decennialProgressTitle = "decennialProgressTitle",
decennialQuote = "decennialQuote",
decennialDaysAgo = "decennialDaysAgo",
decennialDaysToGo = "decennialDaysToGo",
decennialCharacterUnit = "decennialCharacterUnit",
decennialDayUnit = "decennialDayUnit",
decennialPostUnit = "decennialPostUnit",
decennialAchievementEntry1 = "decennialAchievementEntry1",
decennialAchievementEntry2 = "decennialAchievementEntry2",
decennialAchievementEntry3 = "decennialAchievementEntry3",
decennialAchievementEntry4 = "decennialAchievementEntry4",
decennialAchievementEntry5 = "decennialAchievementEntry5",
decennialAchievementEntry6 = "decennialAchievementEntry6",
decennialPromiseEntry1 = "decennialPromiseEntry1",
decennialPromiseEntry2 = "decennialPromiseEntry2",
// 十年之约 zh_CN.ts
[Key.decennialTitle]: "十年之约",
[Key.decennialDesc]: "",
[Key.decennialSubtitle]: "我的博客 · 十年之约",
[Key.decennialDaysCounter]: "已运行天数",
[Key.decennialCountFrom]: "建站日期",
[Key.decennialCountTo]: "十年之约到期日",
[Key.decennialPromiseTitle]: "履约事项:",
[Key.decennialAchievementTitle]: "履约成就:",
[Key.decennialProgressTitle]: "履约进度:",
[Key.decennialQuote]: "十年之约,是坚持,是承诺,是对未来的期许。无论风雨,无论晴好,我会在这里记录、分享、成长。",
[Key.decennialDaysAgo]: "天前发布",
[Key.decennialDaysToGo]: "天后",
[Key.decennialCharacterUnit]: "",
[Key.decennialDayUnit]: "",
[Key.decennialPostUnit]: "",
[Key.decennialAchievementEntry1]: "发布100篇原创文章",
[Key.decennialAchievementEntry2]: "总字数达10万字",
[Key.decennialAchievementEntry3]: "总字数达100万字",
[Key.decennialAchievementEntry4]: "正常运行100天",
[Key.decennialAchievementEntry5]: "正常运行1000天",
[Key.decennialAchievementEntry6]: "完成十年之约",
[Key.decennialPromiseEntry1]: "六个月内有新文章发布",
[Key.decennialPromiseEntry2]: "至少有十篇原创文章",
// Decennial Countdown en.ts
[Key.decennialTitle]: "Decennial Countdown",
[Key.decennialDesc]: "",
[Key.decennialSubtitle]: "My Blog - Decennial Countdown",
[Key.decennialDaysCounter]: "Days Since Online",
[Key.decennialCountFrom]: "Starts on",
[Key.decennialCountTo]: "Ends on",
[Key.decennialPromiseTitle]: "Promise TODOs",
[Key.decennialAchievementTitle]: "Achievements",
[Key.decennialProgressTitle]: "Countdown progress",
[Key.decennialQuote]: "A decennial miracle will soon bloom on this site.",
[Key.decennialDaysAgo]: "days after post",
[Key.decennialDaysToGo]: "days to go",
[Key.decennialCharacterUnit]: "characters",
[Key.decennialDayUnit]: "days",
[Key.decennialPostUnit]: "posts",
[Key.decennialAchievementEntry1]: "Post 100 original articles",
[Key.decennialAchievementEntry2]: "Write 100k words",
[Key.decennialAchievementEntry3]: "Write 1m words",
[Key.decennialAchievementEntry4]: "Run normally for 100 days",
[Key.decennialAchievementEntry5]: "Run normally for 1000 days",
[Key.decennialAchievementEntry6]: "Accomplish the Decennial Countdown",
[Key.decennialPromiseEntry1]: "Post at least one article in six months",
[Key.decennialPromiseEntry2]: "Post at least ten original posts",

请根据博客的自身情况,定义 zh_CN.tsen.ts 文件中的对应字段。

本站十年之约页面见此

友链朋友圈#

Warning

本站的友链朋友圈实现方案可能涉及部署方式的改变(例如 GitHub Pages -> Vercel),请慎重选择!

先下载 node-cache 包,在项目根目录运行:

Terminal window
pnpm add node-cache

src/pages/api/ 下新建文件 feed.json.ts 并写入:

import type { APIRoute } from 'astro';
import { analyzeFeed } from '@/utils/feed-utils';
import NodeCache from 'node-cache';
export const prerender = false;
const cache = new NodeCache({ stdTTL: 1800, checkperiod: 120 });
export const POST: APIRoute = async ({ request }) => {
try {
const { friends } = await request.json();
const cacheKey = JSON.stringify(
friends.map(f => ({ baseid: f.baseid, feed: f.feed, feedtype: f.feedtype }))
);
let cachedLinks = cache.get(cacheKey);
if (cachedLinks) {
return new Response(JSON.stringify(cachedLinks), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const promises = friends.map(async (friend: any) => {
const type = friend.feedtype || 'rss';
const feed = await analyzeFeed(type, friend.feed, 3);
return { baseid: friend.baseid, feed };
});
const links = await Promise.all(promises);
cache.set(cacheKey, links);
return new Response(JSON.stringify(links), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch feeds' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

src/pages/ 下新建文件 circle.astro 并写入:

---
import { Icon } from "astro-icon/components";
import {
friendsPageConfig,
getEnabledFriends,
siteConfig,
} from "@/config";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
if (!siteConfig.pages.circle) {
return Astro.redirect("/404/");
}
const title = "友链朋友圈";
const description = "友链里发生的那些新鲜事";
let friends = [];
getEnabledFriends()
.filter(friend => friend.feed?.length > 0)
.map(friend => {
friends.push({
...friend,
baseid: Buffer.from(friend.siteurl, 'utf-8').toString('base64'),
});
});
---
<MainGridLayout title={title} description={description}>
<div
class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32"
>
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题和描述 -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-3">
<div
class="h-8 w-8 rounded-lg bg-(--primary) flex items-center justify-center text-white dark:text-black/70"
>
<Icon is:inline name="material-symbols:rss-feed-rounded" class="text-[1.5rem]" />
</div>
<div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{title}
</div>
</div>
{
description && (
<p class="text-base text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{description}
</p>
)
}
</div>
<div class="mb-4">
{
friends.map((item) => (
<div class="mb-2">
<div
data-tags={item.tags?.join(",")}
class="friend-card group flex items-center gap-3 p-2.5 rounded-xl border border-(--line-divider) transition-all duration-300 hover:shadow-lg relative overflow-hidden"
>
<!-- 头像 -->
<div class="relative w-16 h-16 shrink-0 rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-800 border border-black/5 dark:border-white/5 transition-transform duration-300">
<img
src={item.imgurl}
alt={item.title}
class="w-full h-full object-cover"
onerror="this.onerror=null;this.src='/defavatar.svg';"
/>
</div>
<!-- 内容 -->
<div class="grow min-h-0 flex flex-col gap-1">
<div class="flex items-center justify-between">
<div class="font-bold text-base text-neutral-900 dark:text-neutral-100 transition-colors truncate pr-4">
{item.title}
</div>
</div>
<div
class="text-sm text-neutral-500 dark:text-neutral-400 line-clamp-1"
title={item.desc}
>
{item.desc}
</div>
</div>
<div class="flex flex-col gap-0.5" id={item.baseid}>
<div class="feed-loading text-sm text-neutral-400 dark:text-neutral-500 animate-pulse">
加载 RSS/Atom 订阅中...
</div>
</div>
</div>
</div>
))
}
</div>
</div>
</div>
</MainGridLayout>
<script define:vars={{ friends }}>
const friendParams = friends.map(f => ({
baseid: f.baseid,
feedtype: f.feedtype,
feed: f.feed,
}));
async function loadFeeds() {
try {
const response = await fetch('/api/feed.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friends: friendParams }),
});
if (!response.ok) throw new Error('Network response was not ok');
const links = await response.json();
links.forEach((item) => {
const container = document.getElementById(item.baseid);
if (!container) return;
container.replaceChildren();
item.feed.entries.forEach((entry) => {
container.insertAdjacentHTML("beforeend", `
<a
href="${entry.url}"
target="_blank"
rel="noopener noreferrer"
data-tags="${item.tags?.join(",") || ''}"
class="friend-card group flex items-center gap-3 p-2.5 rounded-xl border border-(--line-divider) hover:border-(--primary) hover:bg-(--card-bg) transition-all duration-300 hover:shadow-lg relative overflow-hidden w-100"
>
<div class="flex flex-col">
<div class="font-bold text-base text-neutral-900 dark:text-neutral-100 transition-colors pr-4 line-clamp-1 w-100 truncate">
${entry.title}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400 line-clamp-1 indent-1">
${new Date(entry.date).toLocaleDateString()}
</div>
</div>
</a>
`);
});
});
} catch (error) {
console.error('Failed to load feeds:', error);
}
}
loadFeeds();
</script>

siteConfig 中,在如下位置添加字段并设置为 true 来启用友链朋友圈页面:

// 页面开关配置 - 控制特定页面的访问权限,设为false会返回404并自动隐藏对应的导航栏菜单项
pages: {
// 友链页面开关
friends: true,
// 友链朋友圈页面开关
circle: true,
// 其他字段……
},

src/utils/ 下新建文件 feed-utils.ts 并写入:

class SimpleXMLParser {
constructor(xmlString) {
this.xml = xmlString;
this.root = null;
this.parse();
}
parse() {
const stack = [];
let currentParent = null;
let i = 0;
const len = this.xml.length;
while (i < len) {
// 跳过空白字符(仅对标签外有效,但为了简化,我们统一跳过)
if (this.xml[i] === ' ' || this.xml[i] === '\n' || this.xml[i] === '\r' || this.xml[i] === '\t') {
i++;
continue;
}
if (this.xml.startsWith('<?', i)) {
const end = this.xml.indexOf('?>', i);
if (end === -1) throw new Error('处理指令未闭合');
i = end + 2;
continue;
}
// 跳过注释(如 <!-- comment -->)
if (this.xml.startsWith('<!--', i)) {
const end = this.xml.indexOf('-->', i);
if (end === -1) throw new Error('注释未闭合');
i = end + 3;
continue;
}
// 处理标签或 CDATA
if (this.xml[i] === '<') {
// --- 检测 CDATA 开始 ---
if (this.xml.startsWith('<![CDATA[', i)) {
const cdataStart = i + 9; // '<![CDATA[' 的长度
const cdataEnd = this.xml.indexOf(']]>', cdataStart);
if (cdataEnd === -1) {
throw new Error('CDATA 未闭合');
}
const cdataContent = this.xml.substring(cdataStart, cdataEnd);
// 创建 CDATA 节点(作为文本节点的一种)
const cdataNode = {
nodeType: 'cdata',
nodeValue: cdataContent,
parent: currentParent
};
if (currentParent) {
currentParent.childNodes.push(cdataNode);
} else {
// CDATA 不能作为根节点,但为了容错,仍添加
// 实际上根元素应该是标签,此处防止错误使用
throw new Error('CDATA 必须在元素内部');
}
i = cdataEnd + 3; // 跳过 ']]>'
continue;
}
// --- 结束标签 ---
if (this.xml[i + 1] === '/') {
const endTagEnd = this.xml.indexOf('>', i);
if (endTagEnd === -1) throw new Error('标签未闭合');
const tagName = this.xml.substring(i + 2, endTagEnd).trim();
if (stack.length === 0 || stack[stack.length - 1].tagName !== tagName) {
throw new Error(`标签不匹配: 期望 ${stack[stack.length - 1]?.tagName}, 得到 ${tagName}`);
}
stack.pop();
currentParent = stack.length > 0 ? stack[stack.length - 1] : null;
i = endTagEnd + 1;
continue;
}
// --- 自闭合标签(如 <book />)---
const selfClose = this.xml.indexOf('/>', i);
const closeBracket = this.xml.indexOf('>', i);
if (selfClose !== -1 && selfClose < closeBracket) {
const tagContent = this.xml.substring(i + 1, selfClose).trim();
const tagName = tagContent.split(/\s/)[0];
const newNode = {
tagName: tagName,
attributes: {},
childNodes: [],
parent: currentParent
};
if (currentParent) {
currentParent.childNodes.push(newNode);
} else {
if (this.root) throw new Error('文档只能有一个根元素');
this.root = newNode;
}
i = selfClose + 2;
continue;
}
// --- 普通开始标签 ---
const tagEnd = this.xml.indexOf('>', i);
if (tagEnd === -1) throw new Error('标签未闭合');
const tagContent = this.xml.substring(i + 1, tagEnd).trim();
const tagName = tagContent.split(/\s/)[0];
const newNode = {
tagName: tagName,
attributes: {},
childNodes: [],
parent: currentParent
};
if (currentParent) {
currentParent.childNodes.push(newNode);
} else {
if (this.root) throw new Error('文档只能有一个根元素');
this.root = newNode;
}
stack.push(newNode);
currentParent = newNode;
i = tagEnd + 1;
continue;
}
// --- 普通文本内容(非 CDATA) ---
const nextLt = this.xml.indexOf('<', i);
let text = '';
if (nextLt === -1) {
text = this.xml.substring(i).trim();
i = len;
} else {
text = this.xml.substring(i, nextLt).trim();
i = nextLt;
}
if (text && currentParent) {
currentParent.childNodes.push({
nodeType: 'text',
nodeValue: text,
parent: currentParent
});
}
}
if (stack.length > 0) {
throw new Error(`标签未闭合: ${stack[stack.length - 1].tagName}`);
}
}
getElementsByTagName(tagName) {
const result = [];
const traverse = (node) => {
if (node.tagName === tagName) {
result.push(node);
}
if (node.childNodes) {
for (const child of node.childNodes) {
traverse(child);
}
}
};
if (this.root) traverse(this.root);
return result;
}
hasChild() {
return this.childNodes?.length > 0;
}
getRoot() {
return this.root;
}
}
export async function analyzeFeed(type: string, url: string, count: int = -1) : Object {
let ret = {url: url, entries: []};
if (type === 'atom') {
ret.entries = await readAtom(url, count);
} else if (type === 'rss') {
ret.entries = await readRss(url, count);
}
return ret;
}
async function readAtom(url: string, count: int = -1) : Object[] {
let atoms = [];
await fetch(url).then(res => res.text()).then(data => {
const parser = new SimpleXMLParser(data);
const entries = parser.getElementsByTagName('entry').sort((a, b) => {
const at = new Date(b.childNodes.find(n => n.tagName === 'published').childNodes[0].nodeValue);
const bt = new Date(a.childNodes.find(n => n.tagName === 'published').childNodes[0].nodeValue);
return at.getTime() - bt.getTime();
});
if (count < 0) count = entries.length;
for (let i = 0; i < count; i++) {
if (i >= entries.length) break;
const entry = entries[i];
const title = entry.childNodes.find(n => n.tagName === 'title').childNodes[0].nodeValue;
const date = entry.childNodes.find(n => n.tagName === 'published').childNodes[0].nodeValue;
const url = entry.childNodes.find(n => n.tagName === 'id').childNodes[0].nodeValue;
atoms.push({
title: title,
date: date,
url: url,
});
}
}).catch(e => {
console.log(e);
atoms.push({title: null, date: null, url: null})
});
return atoms;
}
async function readRss(url: string, count: int = -1) : Object[] {
let rss = [];
await fetch(url).then(res => res.text()).then(data => {
const parser = new SimpleXMLParser(data);
const items = parser.getElementsByTagName('item').sort((a, b) => {
const at = new Date(b.childNodes.find(n => n.tagName === 'pubDate').childNodes[0].nodeValue);
const bt = new Date(a.childNodes.find(n => n.tagName === 'pubDate').childNodes[0].nodeValue)
return at.getTime() - bt.getTime()
});
if (count < 0) count = items.length;
for (let i = 0; i < count; i++) {
if (i >= items.length) break;
const item = items[i];
const title = item.childNodes.find(n => n.tagName === 'title').childNodes[0].nodeValue;
const date = item.childNodes.find(n => n.tagName === 'pubDate').childNodes[0].nodeValue;
const url = item.childNodes.find(n => n.tagName === 'link').childNodes[0].nodeValue;
rss.push({
title: title,
date: date,
url: url,
});
}
}).catch(e => {
console.log(e);
rss.push({title: null, date: null, url: null})
});
return rss;
}

此时便可以通过编辑友链配置 friendsConfig 来抓取对应博客的订阅文章。本站示例如下:

{
title: "JustPureH2O",
imgurl: "https://justpureh2o.cn/avatar.jpg",
desc: "穷方圆平直之情,尽规矩准绳之用",
siteurl: "https://justpureh2o.cn/",
tags: [],
weight: 10000,
enabled: true,
feed: 'https://justpureh2o.cn/rss.xml', // RSS/Atom 文件地址
feedtype: 'rss' // 支持的值:rss(默认)、atom,用来指定订阅格式,暂不支持其他格式
},

如果需要部署到网页,需要下载对应平台的 Adapter 用来处理我们新建的服务端 API(即 feed.json.ts)。本站选用 Vercel,那么在根目录执行包安装:

Terminal window
pnpm add @astrojs/vercel

按照如下方式编辑 astro.config.mjs 文件:

// 开头引入包
import vercel from "@astrojs/vercel";
// ……
// https://astro.build/config
export default defineConfig({
// ……
adapter: vercel(),
// ……
});

src/content/spec/ 下新建空文件 circle.md/circle.mdx。将博客源码上传至你的仓库。

此时在 Vercel Dashboard 新建项目,从存储本站源码的仓库里导入,默认设置不要动(会自动检测为 Astro 框架),等待初次构建完毕后,绑定域名。

本站友链朋友圈页面见此

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Firefly 魔改指南
https://justpureh2o.cn/articles/20018/
作者
JustPureH2O
发布于
2026-07-07
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
JustPureH2O
穷方圆平直之情,尽规矩准绳之用
公告
JustPureH2O 的博客现已正式迁移至 Astro!原 Hexo 网站将移至 https://hexo.justpureh2o.cn/
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
105
分类
16
标签
59
总字数
387,546
运行时长
0
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly vunknown
文章许可
CC BY-NC-SA 4.0

文章目录