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
@@ -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