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 { 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: { ...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 { 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(); 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 { 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; } }