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.ts、src/i18n/languages/zh_CN.ts 和 src/i18n/languages/en.ts 三个文件中新增:
// 十年之约 i18nKey.tsdecennialTitle = "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.ts 和 en.ts 文件中的对应字段。
本站十年之约页面见此
友链朋友圈
本站的友链朋友圈实现方案可能涉及部署方式的改变(例如 GitHub Pages -> Vercel),请慎重选择!
先下载 node-cache 包,在项目根目录运行:
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,那么在根目录执行包安装:
pnpm add @astrojs/vercel按照如下方式编辑 astro.config.mjs 文件:
// 开头引入包import vercel from "@astrojs/vercel";
// ……
// https://astro.build/configexport default defineConfig({ // …… adapter: vercel(), // ……});在 src/content/spec/ 下新建空文件 circle.md/circle.mdx。将博客源码上传至你的仓库。
此时在 Vercel Dashboard 新建项目,从存储本站源码的仓库里导入,默认设置不要动(会自动检测为 Astro 框架),等待初次构建完毕后,绑定域名。
本站友链朋友圈页面见此
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!



















































































































































