Files
blog/src/lib/projects.ts
T
wait4xandGLM-5.3 3e377adc7f feat(opensource): 开源页改原生档案卡,个人叙述迁往关于页(5h T5/T6)
用户原话:开源页开篇是从 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
2026-11-02 20:35:56 +08:00

213 lines
8.4 KiB
TypeScript
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.
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;
}
}