Files
blog/scripts/build-perf/fetch-readme-assets.mjs
T
wait4xandGLM-5.3 0ff4ede91f feat(opensource): 项目墙恢复居中,README 动效以自托管方式回归(5i T6/T8)
用户两项要求:开源页 Projects「依旧采取居中即可,不用左对齐」;README 迁移后
「很多图片元素还有一些 svg 基本上都摒弃了,可以在 readme 的基础上进行再创新,
保留那些动效的图,以及创新更多一些显示方式」。

T6 项目墙居中:`.repo` 从 align-items:stretch/text-align:left 改回 center/center,
`.repo-desc` 加 max-width:34em 防居中后长描述散架;语言点保持灰阶(与对齐无关)。

T8 动效回归(零运行时外链):
- 新增 geek/TypingLines.astro:自绘打字机(SSR 先出首行防闪白,JS 逐字打出/删除/
  轮播,光标 CSS 闪烁,prefers-reduced-motion 与单条文案时保持静态,min-height
  防抖,astro:page-load 重挂兼容软导航)——替代 readme-typing-svg 外链,且跟随主题
- 新增 scripts/build-perf/fetch-readme-assets.mjs:抓 stats/top-langs/streak/snake
  到 public/github-assets/(10s 超时 + UA、透明底、亮暗两套站点色),产物入仓保证
  离线可构建;单资产失败保留旧文件、无旧文件则该卡不渲染
- GithubProfile 新增「数据板」:灰阶 hairline 卡 + mono 小标题 + 2 列网格(移动单列),
  与原生统计四格、语言占比、热力图组合成展示区;贡献蛇在暗色加 grayscale+invert
  滤镜(SVG 内部是自带绿阶,直接贴暗底刺眼)
- build.sh 只做存在性守卫 + 缺资产 WARN,**不挂抓取**(发布链不被外网抖动拖累)
- 后台开源页新增 typing 字段(list,zh/en 双语,≤6 条,summary 取 zh)+ schema 声明

验证: 构建 51 页 exit 0;config-check 211 字段/66 hint 全过;E2E 46/46;
      产物断言第三方外链零命中、本地引用 streak-light/streak-dark/snake;
      Playwright 实测打字机文本 900ms 内变化(动效在跑)、streak naturalWidth=495、
      snake=880、暗色自动切 streak-dark、项目墙 center/center、1280 与 390 均零横向溢出

备注: github-readme-stats 今日全局 503,stats/top-langs 两卡按契约跳过(原生四格与
      语言条已覆盖同一信息);服务恢复后跑一次 fetch-readme-assets.mjs 即自动补上。

Spec: 2026-11-02_blog-5i-fonts-polish (T6/T8)

Co-authored-by: GLM-5.3
2026-11-03 11:24:28 +08:00

122 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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';
const MUTED_LIGHT = '8b8f98';
const MUTED_DARK = '9aa0aa';
/** 同一张卡按明暗两套参数各存一份(组件内用 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,
},
];
/** 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('<svg')) {
console.warn(`[assets] 非 SVG 响应,已跳过: ${url.slice(0, 90)}…`);
return null;
}
return text;
} catch (err) {
console.warn(`[assets] 抓取失败(${err.name === 'TimeoutError' ? '超时' : err.message}): ${url.slice(0, 90)}…`);
return null;
}
}
const force = process.argv.includes('--force');
mkdirSync(OUT_DIR, { recursive: true });
const manifestPath = join(OUT_DIR, 'manifest.json');
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : { assets: {} };
const now = new Date().toISOString();
let ok = 0;
let kept = 0;
let skipped = 0;
for (const asset of ASSETS) {
const themes = asset.single ? [null] : ['light', 'dark'];
for (const theme of themes) {
const fileName = theme ? `${asset.name}-${theme}.svg` : `${asset.name}.svg`;
const filePath = join(OUT_DIR, fileName);
const url = asset.url(theme);
const svg = await grab(url);
if (svg) {
writeFileSync(filePath, svg, 'utf8');
manifest.assets[fileName] = { source: url, fetchedAt: now, label: asset.label, theme: theme ?? 'any' };
console.log(`[assets] ✓ ${fileName} (${Math.round(svg.length / 1024)}KB)`);
ok += 1;
} else if (existsSync(filePath)) {
console.log(`[assets] ↺ ${fileName} 抓取失败,保留旧文件`);
kept += 1;
} else {
console.warn(`[assets] ⊘ ${fileName} 抓取失败且无旧文件 → 构建时将跳过该块`);
skipped += 1;
}
}
}
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
console.log(`[assets] 完成:新增/更新 ${ok},保留旧文件 ${kept},跳过 ${skipped};manifest → ${manifestPath}`);
process.exit(0);