feat(content): 链接卡片模式——独立成段裸链接转 OG 预览卡
- rehype-link-cards.ts 构建期插件:段落仅含一个链接才转卡(内联链接 保持普通样式);fetch OG/twitter 元信息(UA 伪装、4s 超时、只读前 200KB)+ favicon 兜底;任何失败回落普通链接,构建绝不因外部站点挂 - content/.link-cache.json 入仓缓存(30 天刷新,构建确定性与零重复 请求);单篇上限 10 卡、并发 4,超出回落 - LinkCardStyles.astro 灰阶卡片样式:hairline+圆角、favicon 16px、 标题一行/描述两行截断、域名 mono、OG 图右侧 88px(无图不占位)、 hover 1px 微浮(reduced-motion 豁免)、纯 CSS 零 JS - 安全:抓取内容全 escaping、图片地址仅 http(s) - @astrojs/markdown-remark 依赖(Astro 7 换默认 markdown 处理器后 自定义插件必须经此包生效) - 样张 content/posts/link-cards-test.md(tags 含 hidden,活样例不列出) 修复说明:264148f 提交时误带入 archives 页面对 LinkCardStyles 的 import(两车道共改同一文件),本笔补全组件与插件,恢复构建仓可用。 教训:跨车道共改文件,staged 前必须看 diff 归属。 车道:op-artalk;收编复验:本地构建 45 页 + 卡片渲染(后续专项复验) 验证: pnpm astro build 45 页 exit 0;link-cards 样张页渲染正常 Spec: none (链接卡片,车道级交付) Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
264148f0a6
commit
f5232b7c03
7 files changed
+1701
-14
No files matched your search
+6
-1
@@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config';
|
|||||||
import pagefind from 'astro-pagefind';
|
import pagefind from 'astro-pagefind';
|
||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
|
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
|
||||||
|
|
||||||
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
|
// 构建透明(T7):commit/时间经 vite.define 注入,页脚与 /about 引用。
|
||||||
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
|
// 四重回退:CI 环境变量优先(GITEA_ACTIONS/GITHUB_SHA)→ 本地 git → dev 占位。
|
||||||
@@ -17,7 +18,11 @@ const date =
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://wait4x.online',
|
site: 'https://wait4x.online',
|
||||||
integrations: [pagefind(), sitemap()],
|
integrations: [pagefind(), sitemap(), linkCardsReport()],
|
||||||
|
markdown: {
|
||||||
|
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
|
||||||
|
rehypePlugins: [rehypeLinkCards],
|
||||||
|
},
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'zh',
|
defaultLocale: 'zh',
|
||||||
locales: ['zh', 'en'],
|
locales: ['zh', 'en'],
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"_readme": "链接卡片抓取缓存(构建确定性需求,入仓)。键=URL,值={title,desc,image,favicon,ts};ts 超 30 天刷新。手工改动安全:删条目即下次构建重抓。",
|
||||||
|
"https://www.cnblogs.com/Lenbrother/p/18673403": {
|
||||||
|
"title": "Debian 安装 Docker - 神奇小宝 - 博客园",
|
||||||
|
"desc": "卸载已有 Docker 如果你之前安装过 Docker Engine 之前,你需要卸载旧版本,避免冲突: for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get rem",
|
||||||
|
"image": "https://assets.cnblogs.com/images/wechat-share.jpg",
|
||||||
|
"favicon": "https://assets.cnblogs.com/favicon_v3_3.ico",
|
||||||
|
"ts": 1793593758751
|
||||||
|
},
|
||||||
|
"https://wait4x.online/": {
|
||||||
|
"title": "wait4x",
|
||||||
|
"desc": "wait4x 的博客:阅读 · 技术 · 摄影",
|
||||||
|
"image": "",
|
||||||
|
"favicon": "https://wait4x.online/favicon.svg",
|
||||||
|
"ts": 1793593759184
|
||||||
|
},
|
||||||
|
"https://blog.csdn.net/qq_40605167/article/details/122888580": {
|
||||||
|
"title": "Docker版 E5续订的E5调用API续订服务:Microsoft 365 E5 Renew X_e5 docker",
|
||||||
|
"desc": "文章浏览阅读2.7w次,点赞10次,收藏43次。本文介绍了如何使用Docker轻松部署Microsoft365E5续订服务,包括提供小巧的镜像、一键部署命令及邮箱通知功能。用户需要具备Docker基础,并按照步骤修改邮箱和密码等参数。此外,还提供了不同CPU架构的支持。注意,已有部分账号出现被关停的情况。文章附带了Docker环境搭建教程和相关资源链接。",
|
||||||
|
"image": "https://i-blog.csdnimg.cn/blog_migrate/cover/dc9f6540d4d4330e797f1ebd0261f736.png",
|
||||||
|
"favicon": "https://g.csdnimg.cn/static/logo/favicon32.ico",
|
||||||
|
"ts": 1793593759739
|
||||||
|
},
|
||||||
|
"https://astro.build/": {
|
||||||
|
"title": "Astro",
|
||||||
|
"desc": "Astro builds fast content sites, powerful web applications, dynamic server APIs, and everything in-between.",
|
||||||
|
"image": "https://astro.build/og/astro.jpg",
|
||||||
|
"favicon": "https://astro.build/favicon.svg",
|
||||||
|
"ts": 1793593759816
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: "链接卡片样例"
|
||||||
|
pubDate: 2026-11-02
|
||||||
|
category: tech
|
||||||
|
tags: [test, hidden]
|
||||||
|
# 链接卡片(OG 预览)测试样张:hidden 不列出,构建验证用;保留为活样例
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
本篇验证链接卡片:独立成段的裸链接转 OG 预览卡,内联链接保持普通样式。
|
||||||
|
|
||||||
|
## 正例:有 OG 的站点
|
||||||
|
|
||||||
|
https://wait4x.online/
|
||||||
|
|
||||||
|
https://astro.build/
|
||||||
|
|
||||||
|
## 反例:404 与无 OG 站点(应回落普通链接,构建不报错)
|
||||||
|
|
||||||
|
https://wait4x.online/definitely-not-a-real-page-404/
|
||||||
|
|
||||||
|
https://example.com/
|
||||||
|
|
||||||
|
## 内联链接保持普通样式(不应变卡)
|
||||||
|
|
||||||
|
这里有一个内联链接 [Astro 官网](https://astro.build/) 夹在句子中间,它必须保持普通链接样式,不能整篇变卡片墙。
|
||||||
|
|
||||||
|
## 卡片首行文字(markdown 链接形态同样触发)
|
||||||
|
|
||||||
|
[wait4x.online 首页](https://wait4x.online/)
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"sharp": "^0.35.5"
|
"sharp": "^0.35.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/markdown-remark": "^7.3.5",
|
||||||
"@fontsource/noto-serif-sc": "^5.3.1",
|
"@fontsource/noto-serif-sc": "^5.3.1",
|
||||||
"playwright": "^1.64.1"
|
"playwright": "^1.64.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1272
-13
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
// 链接卡片样式(构建期由 rehype 插件产出 .link-card 节点,元素无 astro cid):
|
||||||
|
// 样式必须全局。灰阶克制 + 纯 CSS(hover 微浮、reduced-motion 豁免)。
|
||||||
|
---
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
.link-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-img, 10px);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
.link-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.link-card, .link-card:hover { transition: none; transform: none; }
|
||||||
|
}
|
||||||
|
.lc-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.lc-title {
|
||||||
|
color: var(--ink-1);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lc-desc {
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lc-host {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink-4);
|
||||||
|
}
|
||||||
|
.lc-favicon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
/* 缩略图:右侧 88px 方形,无图不占位(元素不存在即无空间) */
|
||||||
|
.lc-thumb {
|
||||||
|
flex: none;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.lc-thumb { width: 64px; height: 64px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* rehype 链接卡片:独立成段的裸链接 → OG 预览卡(构建期抓取 + 仓内缓存)。
|
||||||
|
*
|
||||||
|
* 触发:段落 children 去掉空白后恰为一个 <a> 时转卡(内联链接保持普通样式)。
|
||||||
|
* 抓取:UA 伪装、4s 超时、仅读 HTML 前 200KB、解析 og/twitter 系列的标题描述图 + favicon;
|
||||||
|
* 任何失败回落普通链接——构建绝不因外部站点失败。
|
||||||
|
* 缓存:content/.link-cache.json(入仓,保证构建确定性);30 天 TTL 后再抓。
|
||||||
|
* 安全:标题/描述一律走 hast text 节点(序列化时自动转义,杜绝注入);
|
||||||
|
* og:image / favicon 仅接受 http(s) URL。
|
||||||
|
* 护栏:每篇最多 MAX_CARDS 张卡,超出回落;全局并发 4。
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const CACHE_PATH = path.join(process.cwd(), 'content', '.link-cache.json');
|
||||||
|
const CACHE_TTL = 30 * 24 * 3600 * 1000;
|
||||||
|
const MAX_CARDS = 10;
|
||||||
|
const CONCURRENCY = 4;
|
||||||
|
const TIMEOUT_MS = 4000;
|
||||||
|
const MAX_BYTES = 200 * 1024;
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
|
||||||
|
|
||||||
|
interface CardMeta {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
image: string;
|
||||||
|
favicon: string;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache = Record<string, CardMeta>;
|
||||||
|
|
||||||
|
const stats = { cards: 0, fallback: 0 };
|
||||||
|
|
||||||
|
function readCache(): Cache {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')) as Record<string, unknown>;
|
||||||
|
const out: Cache = {};
|
||||||
|
for (const [url, v] of Object.entries(raw)) {
|
||||||
|
if (url.startsWith('_')) continue; // 文件头说明字段
|
||||||
|
if (v && typeof v === 'object') out[url] = v as CardMeta;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache: Cache = readCache();
|
||||||
|
let cacheDirty = false;
|
||||||
|
|
||||||
|
function writeCache(): void {
|
||||||
|
if (!cacheDirty) return;
|
||||||
|
const payload = {
|
||||||
|
_readme:
|
||||||
|
'链接卡片抓取缓存(构建确定性需求,入仓)。键=URL,值={title,desc,image,favicon,ts};ts 超 30 天刷新。手工改动安全:删条目即下次构建重抓。',
|
||||||
|
...cache,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(CACHE_PATH, JSON.stringify(payload, null, 2) + '\n');
|
||||||
|
cacheDirty = false;
|
||||||
|
} catch {
|
||||||
|
/* 缓存写失败不阻断构建 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发闸:全局最多 4 个在途抓取
|
||||||
|
let active = 0;
|
||||||
|
const queue: (() => void)[] = [];
|
||||||
|
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
if (active >= CONCURRENCY) await new Promise<void>((r) => queue.push(r));
|
||||||
|
active++;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
active--;
|
||||||
|
queue.shift()?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只允许 http(s):og:image/favicon 来自外部站点,协议白名单挡住 data:/javascript: 等 */
|
||||||
|
function safeUrl(raw: string, base: string): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const u = new URL(raw, base);
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读流并截断到 MAX_BYTES:外部页面可能很大,只看头部足够拿到 meta */
|
||||||
|
async function readHead(res: Response): Promise<string> {
|
||||||
|
const body = res.body;
|
||||||
|
if (!body) return (await res.text()).slice(0, MAX_BYTES);
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (total < MAX_BYTES) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done || !value) break;
|
||||||
|
chunks.push(value);
|
||||||
|
total += value.byteLength;
|
||||||
|
}
|
||||||
|
try { await reader.cancel(); } catch { /* 已结束 */ }
|
||||||
|
return new TextDecoder('utf-8', { fatal: false }).decode(
|
||||||
|
Buffer.concat(chunks).subarray(0, MAX_BYTES),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMeta(html: string, base: string): Omit<CardMeta, 'ts'> | null {
|
||||||
|
const metas = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||||
|
const pick: Record<string, string> = {};
|
||||||
|
for (const tag of metas) {
|
||||||
|
const key = /(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]?.toLowerCase();
|
||||||
|
const val = /content\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1];
|
||||||
|
if (key && val && !(key in pick)) pick[key] = val;
|
||||||
|
}
|
||||||
|
// 契约门槛:仅当站点显式提供 OG/Twitter 标题才建卡;只有 <title> 的无 OG 站回落普通链接
|
||||||
|
const title = pick['og:title'] || pick['twitter:title'] || '';
|
||||||
|
const desc = pick['og:description'] || pick['twitter:description'] || pick['description'] || '';
|
||||||
|
const image = safeUrl(pick['og:image'] || pick['twitter:image'] || '', base);
|
||||||
|
// favicon:<link rel~="icon"> 优先,/favicon.ico 兜底(不依赖第三方 s2 服务)
|
||||||
|
const iconHref =
|
||||||
|
(/<link\b[^>]*rel\s*=\s*["'][^"']*icon[^"']*["'][^>]*>/i.exec(html) ?? [])
|
||||||
|
.map((t) => /href\s*=\s*["']([^"']+)["']/i.exec(t)?.[1] ?? '')[0] ?? '';
|
||||||
|
const favicon = safeUrl(iconHref || '/favicon.ico', base);
|
||||||
|
if (!title) return null; // 无 OG/Twitter 标题=无预览语义,回落普通链接(契约反例)
|
||||||
|
return {
|
||||||
|
title: decodeEntities(title).slice(0, 200),
|
||||||
|
desc: decodeEntities(desc).slice(0, 300),
|
||||||
|
image,
|
||||||
|
favicon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 常见 HTML 实体还原(og 字段常有 & 等;最终仍走 text 节点再转义一次) */
|
||||||
|
function decodeEntities(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMeta(url: string): Promise<CardMeta | null> {
|
||||||
|
const hit = cache[url];
|
||||||
|
if (hit && Date.now() - hit.ts < CACHE_TTL) return hit;
|
||||||
|
try {
|
||||||
|
const meta = await withSlot(async () => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const html = await readHead(res);
|
||||||
|
const m = extractMeta(html, url);
|
||||||
|
return m ? { ...m, ts: Date.now() } : null;
|
||||||
|
});
|
||||||
|
if (meta) {
|
||||||
|
cache[url] = meta;
|
||||||
|
cacheDirty = true;
|
||||||
|
writeCache();
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
} catch {
|
||||||
|
return null; // 超时/网络/证书失败:一律回落
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hast 构造(text 节点天然转义) ──
|
||||||
|
const el = (tagName: string, properties: Record<string, unknown>, children: unknown[] = []) =>
|
||||||
|
({ type: 'element', tagName, properties, children }) as const;
|
||||||
|
const txt = (value: string) => ({ type: 'text', value }) as const;
|
||||||
|
|
||||||
|
function buildCard(url: string, meta: CardMeta) {
|
||||||
|
const href = safeUrl(url, url);
|
||||||
|
const host = (() => {
|
||||||
|
try { return new URL(href).hostname.replace(/^www\./, ''); } catch { return ''; }
|
||||||
|
})();
|
||||||
|
const body = el('div', { className: ['lc-body'] }, [
|
||||||
|
el('div', { className: ['lc-title'] }, [txt(meta.title)]),
|
||||||
|
...(meta.desc ? [el('div', { className: ['lc-desc'] }, [txt(meta.desc)])] : []),
|
||||||
|
el('div', { className: ['lc-host'] }, [
|
||||||
|
...(meta.favicon
|
||||||
|
? [el('img', { className: ['lc-favicon'], src: meta.favicon, alt: '', loading: 'lazy', decoding: 'async', width: 16, height: 16 })]
|
||||||
|
: []),
|
||||||
|
txt(host),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return el(
|
||||||
|
'a',
|
||||||
|
{
|
||||||
|
className: ['link-card'],
|
||||||
|
href,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
},
|
||||||
|
[...(meta.image
|
||||||
|
? [el('img', { className: ['lc-thumb'], src: meta.image, alt: '', loading: 'lazy', decoding: 'async' })]
|
||||||
|
: []),
|
||||||
|
body],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 段落内「只有一个链接」判定:忽略空白 text */
|
||||||
|
function loneLink(node: any): any | null {
|
||||||
|
const kids = (node.children ?? []).filter(
|
||||||
|
(c: any) => !(c.type === 'text' && !c.value.trim()),
|
||||||
|
);
|
||||||
|
if (kids.length !== 1) return null;
|
||||||
|
const only = kids[0];
|
||||||
|
if (only.type !== 'element' || only.tagName !== 'a') return null;
|
||||||
|
return only;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function rehypeLinkCards() {
|
||||||
|
return async (tree: any) => {
|
||||||
|
const jobs: { parent: any; index: number; url: string }[] = [];
|
||||||
|
let count = 0;
|
||||||
|
const walk = (node: any) => {
|
||||||
|
const kids: any[] = node.children ?? [];
|
||||||
|
for (let i = 0; i < kids.length; i++) {
|
||||||
|
const child = kids[i];
|
||||||
|
if (child.type === 'element' && child.tagName === 'p') {
|
||||||
|
const link = loneLink(child);
|
||||||
|
const rawHref = link?.properties?.href;
|
||||||
|
if (link && typeof rawHref === 'string' && /^https?:\/\//.test(rawHref)) {
|
||||||
|
if (count < MAX_CARDS) {
|
||||||
|
jobs.push({ parent: node, index: i, url: rawHref });
|
||||||
|
count++;
|
||||||
|
} else {
|
||||||
|
stats.fallback++; // 超护栏:保留普通链接
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (child.children) walk(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree);
|
||||||
|
if (jobs.length === 0) return;
|
||||||
|
|
||||||
|
const results = await Promise.all(jobs.map((j) => fetchMeta(j.url)));
|
||||||
|
// 从后往前替换,避免下标偏移(同父内多个卡片)
|
||||||
|
const byParent = new Map<any, { index: number; card: any }[]>();
|
||||||
|
results.forEach((meta, k) => {
|
||||||
|
const job = jobs[k]!;
|
||||||
|
if (!meta) { stats.fallback++; return; }
|
||||||
|
if (!byParent.has(job.parent)) byParent.set(job.parent, []);
|
||||||
|
byParent.get(job.parent)!.push({ index: job.index, card: buildCard(job.url, meta) });
|
||||||
|
});
|
||||||
|
for (const [parent, list] of byParent) {
|
||||||
|
list.sort((a, b) => b.index - a.index).forEach(({ index, card }) => {
|
||||||
|
parent.children[index] = card;
|
||||||
|
stats.cards++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
writeCache();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建收尾汇总(单进程 build):N 卡片 / M 回落
|
||||||
|
/**
|
||||||
|
* 汇总日志集成:rehype 插件无法感知"最后一个文件",Astro CLI 末尾 `process.exit` 又会跳过
|
||||||
|
* beforeExit/exit 钩子——用 build:done 阶段汇报(契约要求「N 卡片 / M 回落」)。
|
||||||
|
*/
|
||||||
|
export function linkCardsReport() {
|
||||||
|
return {
|
||||||
|
name: 'link-cards-report',
|
||||||
|
hooks: {
|
||||||
|
'astro:build:done': () => {
|
||||||
|
if (stats.cards || stats.fallback) {
|
||||||
|
console.log(`[link-cards] ${stats.cards} 卡片 / ${stats.fallback} 回落`);
|
||||||
|
}
|
||||||
|
writeCache();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user