diff --git a/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md b/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md
index d31a50c..08fc7d2 100644
--- a/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md
+++ b/.spec/specs/2026-11-02_blog-5h-content-social/tasks.md
@@ -28,12 +28,12 @@
- boundary: src/components/geek/ViewMeta.astro(**契约组件,由 orchestrator 在 T1 提交时一并落盘**,车道只消费不改语义)、src/components/ArtalkComments.astro(pv 小字行改为 ViewMeta,评论数取 `comments?limit=1` 的 count)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro(挂 ViewMeta)
- verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
-- [ ] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
+- [x] T5 开源页重构(原生 GitHub 档案卡,去外链图依赖)
- claim: op-gitea
- boundary: 新增 src/components/geek/GithubProfile.astro(头像/名字/简介/关注者/总星标/语言占比,数据来自 projects.ts 与 GraphQL;零第三方图片与徽章)、src/lib/projects.ts(补统计聚合:followers/stars/language 汇总,失败降级不炸构建)、src/pages/opensource.astro、src/pages/en/opensource.astro(用 GithubProfile 替换 readme 渲染段;README 通道保留开关但默认 off 且标注不推荐)、content/site/opensource.yaml(注释说明 intro_from 现状)
- verify: `grep -q "GithubProfile" src/pages/opensource.astro && python3 -c "import re,pathlib;p=pathlib.Path('dist/opensource/index.html').read_text();bad=[u for u in ['img.shields.io','readme-typing-svg','github-readme-stats','streak-stats','komarev','github-readme-activity-graph'] if u in p];assert not bad,bad"`
-- [ ] T6 关于页承接 README 的个人叙述
+- [x] T6 关于页承接 README 的个人叙述
- claim: op-gitea
- boundary: content/site/about.yaml(新增 blocks:关于/兴趣 prose、站点与项目 linklist、Find Me 社交块;双语 zh/en;测试值不得留在文件里)、src/pages/about.astro 与 en(仅当块渲染需要时改,优先零改动)
- verify: `python3 -c "import yaml;d=yaml.safe_load(open('content/site/about.yaml'));types=[b['type'] for b in d.get('blocks') or []];assert 'prose' in types and ('linklist' in types or 'cards' in types),types" && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
diff --git a/content/site/about.yaml b/content/site/about.yaml
index 0e07f45..0be8622 100644
--- a/content/site/about.yaml
+++ b/content/site/about.yaml
@@ -62,6 +62,69 @@ blocks:
zh: 街拍与山野,随身一台无反,在等光的间隙按快门。
en: Street and landscapes with a mirrorless camera, pressing the shutter while waiting for the light.
+ - type: prose
+ title:
+ zh: 关于我
+ en: About me
+ body:
+ zh: |
+ 气象是我起点的专业——一个想做预报的人,依旧把云图当日常读物。
+
+ 💻 写代码是第二条主线:Linux、容器、自托管,把日常工具一件件磨顺手。
+
+ 🌐 作品大多在 [wait4x.online](https://wait4x.online/)。
+
+ ⚡ 信条:**拥抱开源,热爱开源**。
+ en: |
+ Meteorology is where I started — an aspiring forecaster who still reads weather charts for fun.
+
+ 💻 Coding is the second thread: Linux, containers, self-hosting — polishing everyday tools one by one.
+
+ 🌐 Most of my work lives at [wait4x.online](https://wait4x.online/).
+
+ ⚡ Motto: **embrace open source, love open source**.
+
+ - type: linklist
+ title:
+ zh: 找到我
+ en: Find me
+ items:
+ - label:
+ zh: 本站
+ en: Website
+ href: 'https://wait4x.online/'
+ note:
+ zh: 文章、图库与瞬间
+ en: Posts, gallery and moments
+ - label:
+ zh: 邮件
+ en: Email
+ href: 'mailto:admin@wait4x.online'
+ note:
+ zh: 有话直说
+ en: Say hello directly
+ - label:
+ zh: GitHub
+ en: GitHub
+ href: 'https://github.com/wait4xx'
+ note:
+ zh: 开源项目与提交
+ en: Open-source projects and commits
+ - label:
+ zh: 自托管 gitea
+ en: Self-hosted gitea
+ href: 'https://git.wait4x.online/wait4x/blog'
+ note:
+ zh: 本站源码
+ en: Source of this site
+ - label:
+ zh: RSS
+ en: RSS
+ href: /rss.xml
+ note:
+ zh: 订阅更新
+ en: Subscribe to updates
+
- type: prose
title:
zh: 本站如何构建
diff --git a/content/site/opensource.yaml b/content/site/opensource.yaml
index b0dff4e..da5eb7f 100644
--- a/content/site/opensource.yaml
+++ b/content/site/opensource.yaml
@@ -18,5 +18,9 @@ exclude: []
sort: pinned-first
# 来源过滤:both=双源合并(默认) | github | gitea
perSource: both
-# 开篇文案来源:off=用本文件 blurb(默认) | github-readme=构建期取 wait4xx/wait4xx 的 README 渲染
+# 开篇来源(compat,**不推荐开启**):off=用本文件 blurb + 原生档案卡(默认)
+# github-readme = 构建期取 wait4xx/wait4xx 的 README 原样渲染在档案卡下方
+# ⚠ 开启会引入 7 类第三方外链图(readme-typing-svg / shields.io 徽章 / github-readme-stats /
+# streak-stats / activity-graph / 贡献蛇 / komarev 访客徽章),慢网或被墙时整片空白;
+# 且 README 用
居中,本站渲染不认该属性 → 居中失效。内容已由档案卡承接。
intro_from: "off"
diff --git a/src/components/geek/GithubProfile.astro b/src/components/geek/GithubProfile.astro
new file mode 100644
index 0000000..ce4372f
--- /dev/null
+++ b/src/components/geek/GithubProfile.astro
@@ -0,0 +1,257 @@
+---
+// 开源页原生档案卡(5h T5):零第三方图片、零 iframe
+// 数据来源:①content/site/home.yaml 的头像/社交(单一事实源)②content/site/opensource.yaml 的
+// 定位叙述(blurb/blurb_after,双语可后台改)③GitHub REST 的 followers/公开仓数(token 在宿主
+// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
+// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
+import { Image } from 'astro:assets';
+import { homeAvatar, homeSocials, openSourceCopy } from '../../lib/site-content';
+import { githubUserStats, loadProjects } from '../../lib/projects';
+import { iconPath } from '../../lib/icons';
+import type { Locale } from '../../lib/locale';
+
+interface Props {
+ locale?: Locale;
+}
+const { locale = 'zh' } = Astro.props;
+
+const avatar = await homeAvatar();
+const socials = await homeSocials();
+const copy = await openSourceCopy(locale);
+
+// 仓库数据:档案卡只关心"语言占比 + 星标汇总",取全量(不受项目墙可见性影响)
+let langs: { name: string; count: number; pct: number }[] = [];
+let totalStars: number | null = null;
+try {
+ const repos = await loadProjects({ mode: 'all', sort: 'stars' });
+ if (repos.length) {
+ totalStars = repos.reduce((sum, r) => sum + (r.stars ?? 0), 0);
+ const tally = new Map
();
+ for (const r of repos) {
+ if (!r.language) continue;
+ tally.set(r.language, (tally.get(r.language) ?? 0) + 1);
+ }
+ const total = [...tally.values()].reduce((a, b) => a + b, 0);
+ langs = [...tally.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+ .map(([name, count]) => ({
+ name,
+ count,
+ pct: total ? Math.round((count / total) * 100) : 0,
+ }));
+ }
+} catch {
+ console.warn('[profile] 仓库数据不可用,语言占比与星标汇总已隐藏');
+}
+
+const stats = await githubUserStats();
+const L = {
+ zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
+ en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
+}[locale];
+---
+
+
+
+ {avatar ? (
+
+ ) : (
+
w4
+ )}
+
+
+ wait4x@wait4xx
+
+
+ {copy.blurb}{copy.repoLabel}{copy.blurbAfter}
+
+
+
+
+
+ {(stats || totalStars !== null) && (
+
+ {stats && (
+ <>
+ - {L.followers}
- {stats.followers}
+ - {L.repos}
- {stats.publicRepos}
+ >
+ )}
+ {totalStars !== null && (
+ - {L.stars}
- {totalStars}
+ )}
+
+ )}
+
+ {langs.length > 0 && (
+
+
{L.langs}
+
+ {langs.map((l, i) => (
+ -
+ {l.name}
+
+
+
+ {l.pct}%
+
+ ))}
+
+
+ )}
+
+
+
diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index 4057e3b..799e49d 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -1,3 +1,5 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
export interface RepoCard {
@@ -28,8 +30,18 @@ export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=1
export const PINNED_DEFAULT: string[] = ['blog'];
async function fetchJson(url: string): Promise {
+ const headers: Record = { 'User-Agent': 'wait4x-blog-build' };
+ // GitHub 无鉴权仅 60 次/小时(多次构建就耗尽)→ 有 token 就带上(5000/小时)
+ if (url.includes('api.github.com')) {
+ try {
+ const token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim();
+ if (token) headers.Authorization = `Bearer ${token}`;
+ } catch {
+ /* token 缺位则匿名(可能被限流,走兜底) */
+ }
+ }
const res = await fetch(url, {
- headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
+ headers: { ...headers, Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
@@ -158,3 +170,43 @@ function arrange(
return b.stars - a.stars;
});
}
+
+/** GitHub 账号统计(followers / public repos):token 缺位或请求失败一律返回 null(调用方隐藏数字) */
+export interface GithubUserStats {
+ followers: number;
+ publicRepos: number;
+}
+
+export async function githubUserStats(
+ login = 'wait4xx',
+): Promise {
+ const { readFileSync } = await import('node:fs');
+ const { join } = await import('node:path');
+ let token: string | null = null;
+ try {
+ token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
+ } catch {
+ token = null;
+ }
+ try {
+ const res = await fetch(`https://api.github.com/users/${login}`, {
+ headers: {
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'wait4x-blog-build',
+ },
+ });
+ if (!res.ok) {
+ console.warn(`[profile] GitHub 用户统计获取失败 HTTP ${res.status},相关数字已隐藏`);
+ return null;
+ }
+ const data = (await res.json()) as { followers?: number; public_repos?: number };
+ return {
+ followers: Number(data.followers ?? 0),
+ publicRepos: Number(data.public_repos ?? 0),
+ };
+ } catch (err) {
+ console.warn(`[profile] GitHub 用户统计请求异常(${(err as Error).message}),相关数字已隐藏`);
+ return null;
+ }
+}
diff --git a/src/pages/en/opensource.astro b/src/pages/en/opensource.astro
index 27d7f2a..6289e1d 100644
--- a/src/pages/en/opensource.astro
+++ b/src/pages/en/opensource.astro
@@ -2,25 +2,23 @@
import ProjectWall from '../../components/geek/ProjectWall.astro';
import Heatmap from '../../components/geek/Heatmap.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
+import GithubProfile from '../../components/geek/GithubProfile.astro';
import { t } from '../../i18n/ui';
-import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown, renderRich } from '../../lib/site-content';
+import { githubReadmeMarkdown, openSourceCopy, openSourceOptions, pageTypography, renderMarkdown } from '../../lib/site-content';
const copy = await openSourceCopy('en');
const options = await openSourceOptions();
const readmeMd = options.introFrom === 'github-readme' ? await githubReadmeMarkdown() : null;
const readmeHtml = readmeMd ? await renderMarkdown(readmeMd) : null;
-const blurbHtml = await renderRich(copy.blurb, { inline: true });
-const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource');
---
Open Source
- {readmeHtml ? (
-
- ) : (
-
${copy.repoLabel}${blurbAfterHtml}`} />
- )}
+
+ {/* compat path (not recommended): renders the profile README below the card when
+ intro_from=github-readme. It pulls 7 kinds of third-party images and loses align centering. */}
+ {readmeHtml &&
}
)
-const blurbHtml = await renderRich(copy.blurb, { inline: true });
-const blurbAfterHtml = await renderRich(copy.blurbAfter, { inline: true });
const typo = await pageTypography('siteOpenSource', 'opensource');
---
开源Open Source
- {readmeHtml ? (
-
- ) : (
-
${copy.repoLabel}${blurbAfterHtml}`} />
- )}
+
+ {/* 兼容通道(不推荐):intro_from=github-readme 时把 profile README 原样渲染在档案卡下方。
+ 它会引入 7 类第三方外链图(shields.io / readme-typing-svg / stats / snake / 访客徽章)并带失效的
+ align 居中;慢网或被墙时整片空白。默认 off,正常内容由上方原生档案卡 + opensource.yaml 叙述承载。 */}
+ {readmeHtml &&
}
0);
ok('项目墙渲染', await page.locator('.project-wall, .repo').count() > 0);
+// 0) Phase 5h:置顶 / 计数徽标 / 瞬间富文本与逐条评论 / 块式关于页
+await page.goto(BASE + '/archives/', { waitUntil: 'networkidle' });
+const firstArchive = await page.locator('.post-list a.row').first().getAttribute('href');
+ok('5h·置顶排在归档首位', (firstArchive ?? '').includes('/archives/writing-template/'), firstArchive ?? 'none');
+ok('5h·置顶徽标可见', (await page.locator('.pin').count()) > 0);
+
+await page.goto(BASE + '/archives/writing-template/', { waitUntil: 'networkidle' });
+ok('5h·文章页计数组件挂载', (await page.locator('[data-viewmeta]').count()) >= 2, `count=${await page.locator('[data-viewmeta]').count()}`);
+ok('5h·模板彩色强调生效', (await page.locator('.prose-tech .c-blue').count()) > 0);
+ok('5h·模板折叠块渲染', (await page.locator('.prose-tech details').count()) > 0);
+
+await page.goto(BASE + '/moments/', { waitUntil: 'networkidle' });
+ok('5h·瞬间带逐条评论区', (await page.locator('details[data-mom-comments]').count()) > 0);
+ok('5h·瞬间卡片带只读计数', (await page.locator('.mom-card [data-viewmeta]').count()) > 0);
+await page.locator('details[data-mom-comments]').first().locator('summary').click();
+await page.waitForTimeout(400);
+// 计数徽标是 details 的兄弟(ViewMeta 根节点为 ,塞进 属非法嵌套)
+ok('5h·瞬间计数为只读模式', (await page.locator('.mom-cm-meta [data-viewmeta]').first().getAttribute('data-mode')) === 'readonly');
+
+await page.goto(BASE + '/about/', { waitUntil: 'networkidle' });
+ok('5h·关于页由块驱动', (await page.locator('[data-block]').count()) > 0, `blocks=${await page.locator('[data-block]').count()}`);
+
// 1) 明暗切换:按钮 + 状态持久 + t 键
const before = await page.getAttribute('html', 'data-theme');
await page.click('[data-theme-toggle]');