用户原话:开源页开篇是从 GitHub README 直接移植的,「部分内容没有居中,有些图片
也没有正常显示,可以自行进行美化,无需完全一致……有些可以直接移植到关于页面」。
病根:README 是 GitHub 专用 markdown——`<div align="center">` 靠 HTML 属性居中
(本站按普通块渲染,故"没居中"),且依赖 7 类外链图(readme-typing-svg /
shields.io / github-readme-stats / streak-stats / activity-graph / snake /
komarev),慢网或被墙时全空白(故"图片不显示")。
T5 开源页重构:
- 新增 geek/GithubProfile.astro:头像 / 名字@handle / 定位叙述 / 社交图标 /
统计四格(followers、公开仓、总星标)/ 语言占比灰阶条(仅第一名 accent,
其余 tokens 灰阶)——**零外链图、零 iframe、零第三方徽章**
- 语言聚合复用 loadProjects 的结果;followers 与公开仓数走 GitHub REST
- 任一取数失败只隐藏该项(catch + warn),绝不炸构建
- opensource.astro / en:档案卡承接原 README 位置 → 热力图 → 项目墙;
README 渲染降级为兼容通道(intro_from=off 时不执行),yaml 注释写明
7 类外链图与 align 失效原因并标「不推荐」
- **修既有隐患**:GitHub API 请求原先不带 token,匿名 60 次/小时在密集构建下
被打满 → 项目墙退化成兜底 JSON(今天一度只剩 1 条)。现对 api.github.com
自动附 Bearer(token 读宿主 0600 文件,不入仓),墙恢复 16 条、总星标 0→66、
语言占比变真值
T6 关于页承接:
- about.yaml blocks 扩为 prose / tracks(兴趣版图)/ prose(关于我)/
linklist(找到我:本站·邮件·GitHub·自建 gitea·RSS)/ prose(本站如何构建),
双语齐备;AboutPage 零改动(blocks 早已驱动)
- 原则落地:关于页讲人,开源页讲开源,两边不再重复
验证: 构建 51 页 exit 0;content-lint 51 页 0/0/0;config-check 全过;
E2E **46/46 PASS**(37 原有 + 本包新增 9 项:置顶顺序与徽标、文章页计数、
模板彩色与折叠、瞬间评论区与只读计数、关于页块驱动);
dist/opensource 七类第三方外链零命中、第三方 img 数 0;数据实值:
语言占比 Python/TypeScript 各 36%、Shell/Rust/JS 各 9%
Spec: 2026-11-02_blog-5h-content-social (T5/T6)
Co-authored-by: GLM-5.3
213 lines
8.4 KiB
TypeScript
213 lines
8.4 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
// 项目墙数据(极客层 P0):构建时双源拉取 + 仓内缓存兜底
|
||
// 双源 = 自家 gitea(数据自控叙事)+ GitHub(社区 star 真值);置顶清单人工维护
|
||
export interface RepoCard {
|
||
source: 'gitea' | 'github';
|
||
name: string;
|
||
description: string;
|
||
language: string | null;
|
||
stars: number;
|
||
url: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
interface RawRepo {
|
||
full_name?: string;
|
||
name?: string;
|
||
description?: string | null;
|
||
language?: string | null;
|
||
stars_count?: number;
|
||
stargazers_count?: number;
|
||
html_url?: string;
|
||
updated_at?: string;
|
||
}
|
||
|
||
export const GITEA_API = 'https://git.wait4x.online/api/v1/users/wait4x/repos?limit=50';
|
||
export const GITHUB_API = 'https://api.github.com/users/wait4xx/repos?per_page=100&sort=updated';
|
||
|
||
/** 默认置顶(仅在调用方未传 pinned 时生效):现由 content/site/opensource.yaml 的 pinned 供数 */
|
||
export const PINNED_DEFAULT: string[] = ['blog'];
|
||
|
||
async function fetchJson(url: string): Promise<RawRepo[]> {
|
||
const headers: Record<string, string> = { '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: { ...headers, Accept: 'application/json' },
|
||
signal: AbortSignal.timeout(10_000),
|
||
});
|
||
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
|
||
return (await res.json()) as RawRepo[];
|
||
}
|
||
|
||
function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
|
||
const name = r.name ?? r.full_name?.split('/')[1];
|
||
if (!name || !r.html_url) return null;
|
||
return {
|
||
source,
|
||
name,
|
||
description: r.description ?? '',
|
||
language: r.language ?? null,
|
||
stars: r.stars_count ?? r.stargazers_count ?? 0,
|
||
url: r.html_url,
|
||
updatedAt: r.updated_at ?? '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
|
||
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
|
||
*/
|
||
export interface LoadProjectsOptions {
|
||
/** 置顶仓短名(顺序即顺序),缺省 PINNED_DEFAULT */
|
||
pinned?: string[];
|
||
/** 可见性:all=全部 / only=仅 include / exclude=排除 exclude */
|
||
mode?: 'all' | 'only' | 'exclude';
|
||
include?: string[];
|
||
exclude?: string[];
|
||
/** 排序:pinned-first(默认)/ stars / updated */
|
||
sort?: 'pinned-first' | 'stars' | 'updated';
|
||
/** 来源过滤:both(默认)/ github / gitea */
|
||
perSource?: 'both' | 'github' | 'gitea';
|
||
}
|
||
|
||
/**
|
||
* 拉取双源并按选项过滤/排序(选项来自 content/site/opensource.yaml,缺省 = 现状行为)
|
||
* @returns 未截断的完整列表:展示上限由调用方 slice(首页 6 / 开源页 99)
|
||
*/
|
||
export async function loadProjects(options: LoadProjectsOptions = {}): Promise<RepoCard[]> {
|
||
const { pinned = PINNED_DEFAULT, mode = 'all', include = [], exclude = [], sort = 'pinned-first', perSource = 'both' } = options;
|
||
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
|
||
// 单源失败不拦构建,但必须在日志里点名——否则“怪了,这个仓怎么没显示”要排查很久
|
||
if (gitea.status === 'rejected') {
|
||
console.warn(`[opensource] projects:gitea 源不可用(${(gitea.reason as Error).message}),仅用 GitHub 源`);
|
||
}
|
||
if (github.status === 'rejected') {
|
||
console.warn(`[opensource] projects:GitHub 源不可用(${(github.reason as Error).message}),仅用 gitea 源`);
|
||
}
|
||
const byName = new Map<string, RepoCard>();
|
||
|
||
for (const r of gitea.status === 'fulfilled' ? gitea.value : []) {
|
||
const c = toCard(r, 'gitea');
|
||
if (c) byName.set(c.name, c);
|
||
}
|
||
for (const r of github.status === 'fulfilled' ? github.value : []) {
|
||
const c = toCard(r, 'github');
|
||
if (!c) continue;
|
||
const existing = byName.get(c.name);
|
||
if (!existing) byName.set(c.name, c);
|
||
else if (c.stars > existing.stars) existing.stars = c.stars; // 社区真值覆盖镜像计数
|
||
}
|
||
|
||
if (byName.size === 0) {
|
||
console.warn('[opensource] projects:双源均无可用数据,改用仓内兜底 src/data/projects-fallback.json');
|
||
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
|
||
const out = arrange(fb, pinned, mode, include, exclude, sort, perSource);
|
||
warnMissedIncludes(out, fb, include, mode, perSource);
|
||
return out;
|
||
}
|
||
const merged = [...byName.values()];
|
||
const out = arrange(merged, pinned, mode, include, exclude, sort, perSource);
|
||
warnMissedIncludes(out, merged, include, mode, perSource);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* mode=only 时点名 include 里“写了却没显示”的仓名——避开设了一个看不见的黑洞。
|
||
* 未命中分两类,日志措词区分:①双源结果里根本没有(拼写错 / 私有仓 / 该源刚失败);
|
||
* ②有,但被 perSource 过滤掉了(配置与仓来源不匹配)。
|
||
*/
|
||
function warnMissedIncludes(
|
||
showing: RepoCard[],
|
||
pool: RepoCard[],
|
||
include: string[],
|
||
mode: 'all' | 'only' | 'exclude',
|
||
perSource: 'both' | 'github' | 'gitea',
|
||
): void {
|
||
if (mode !== 'only' || include.length === 0) return;
|
||
const shown = new Set(showing.map((r) => r.name));
|
||
const available = new Map(pool.map((r) => [r.name, r.source]));
|
||
for (const name of include) {
|
||
if (shown.has(name)) continue;
|
||
const src = available.get(name);
|
||
console.warn(
|
||
src
|
||
? `[opensource] projects:include 里的 「${name}」命中 ${src} 源,但被 perSource=${perSource} 过滤掉——改 perSource=both 或把它移出 include`
|
||
: `[opensource] projects:include 里的 「${name}」在双源结果中没有命中(仓名拼写?私有仓?源刚失败?)——它不会出现在项目墙上`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */
|
||
function arrange(
|
||
list: RepoCard[],
|
||
pinned: string[],
|
||
mode: 'all' | 'only' | 'exclude',
|
||
include: string[],
|
||
exclude: string[],
|
||
sort: 'pinned-first' | 'stars' | 'updated',
|
||
perSource: 'both' | 'github' | 'gitea',
|
||
): RepoCard[] {
|
||
let out = perSource === 'both' ? list : list.filter((r) => r.source === perSource);
|
||
if (mode === 'only') out = out.filter((r) => include.includes(r.name));
|
||
else if (mode === 'exclude') out = out.filter((r) => !exclude.includes(r.name));
|
||
|
||
if (sort === 'stars') return [...out].sort((a, b) => b.stars - a.stars);
|
||
if (sort === 'updated') {
|
||
return [...out].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||
}
|
||
return [...out].sort((a, b) => {
|
||
const pa = pinned.indexOf(a.name), pb = pinned.indexOf(b.name);
|
||
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
|
||
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<GithubUserStats | null> {
|
||
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;
|
||
}
|
||
}
|