#!/usr/bin/env node // README 动效资产抓取器(5i T8):把只能靠外部生成的 SVG 数据卡抓成本地静态文件 // 设计取舍: // · 产物入仓(public/github-assets/)→ 构建确定性与离线可构建优先,不在 CI 里跑本脚本 // · 单资产失败保留旧文件;无旧文件则跳过(组件侧按存在性决定是否渲染该块) // · 配色按站点 tokens 近似:透明底 + 文字 #33343a(亮)/#c9ccd4(暗) + 线条 #3454d1 // · manifest.json 记录来源 URL 与抓取时间(可追溯,不含任何凭据) // 用法:node scripts/build-perf/fetch-readme-assets.mjs [--force] import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const OUT_DIR = 'public/github-assets'; const LOGIN = 'wait4xx'; const TIMEOUT_MS = 10_000; const UA = 'wait4x-blog-assets/1.0 (+https://wait4x.online)'; // 站点 tokens 近似值(与灰阶纪律一致:底透明,线条单一强调色) const ACCENT = '3454d1'; const INK_LIGHT = '33343a'; const INK_DARK = 'c9ccd4'; // 次级文字(Total Contributions / dates)也用 ink 档:旧的 #8b8f98/#9aa0aa 缩小后对比不足 4.5:1 const MUTED_LIGHT = '3f424a'; const MUTED_DARK = 'b9bec8'; /** 同一张卡按明暗两套参数各存一份(组件内用 CSS 切换,零运行时请求) */ const ASSETS = [ { name: 'stats', url: (theme) => `https://github-readme-stats.vercel.app/api?username=${LOGIN}` + '&show_icons=true&hide_border=true&include_all_commits=true&count_private=true&rank_icon=github' + `&bg_color=00000000&title_color=${ACCENT}&icon_color=${ACCENT}` + `&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`, label: 'GitHub 统计', }, { name: 'top-langs', url: (theme) => `https://github-readme-stats.vercel.app/api/top-langs/?username=${LOGIN}` + '&layout=compact&hide_border=true&langs_count=6' + `&bg_color=00000000&title_color=${ACCENT}` + `&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`, label: '语言分布', }, { name: 'streak', url: (theme) => `https://streak-stats.demolab.com/?user=${LOGIN}&hide_border=true` + '&background=00000000' + `&ring=${ACCENT}&fire=${ACCENT}&currStreakLabel=${ACCENT}` + `&currStreakNum=${theme === 'dark' ? INK_DARK : INK_LIGHT}` + `&sideNums=${theme === 'dark' ? INK_DARK : INK_LIGHT}` + `&sideLabels=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}` + `&dates=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}`, label: '连续贡献', }, { // 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份 name: 'snake', url: () => `https://raw.githubusercontent.com/${LOGIN}/${LOGIN}/output/github-contribution-grid-snake.svg`, label: '贡献蛇', single: true, }, ]; /** * 冻结进场动画:streak-stats 的 SVG 里元素带 `style='opacity: 0; animation: fadein … forwards'`, * loading=lazy 时用户会先看到 0.5–1.5s 的半成品。这里把动画声明剥掉、opacity 归 1, * 静态呈现最终态(同时删掉 @keyframes 块,减小体积)。 */ function freezeAnimations(svg) { let out = svg.replace(/style='([^']*)'/g, (_, css) => { const cleaned = css .split(';') .map((decl) => decl.trim()) .filter((decl) => decl && !decl.startsWith('animation')) .map((decl) => (/^opacity\s*:\s*0\b/.test(decl) ? 'opacity: 1' : decl)) .join('; '); return cleaned ? `style='${cleaned}'` : ''; }); out = out.replace(/animation\s*:\s*[^;'"]*;?/g, ''); out = out.replace(/@keyframes[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, ''); return out; } /** 10s 超时抓取;返回 SVG 文本或 null */ async function grab(url) { try { const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'image/svg+xml,image/*;q=0.8,*/*;q=0.5' }, signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!res.ok) { console.warn(`[assets] HTTP ${res.status}: ${url.slice(0, 90)}…`); return null; } const text = await res.text(); if (!text.includes('