// 项目墙数据(极客层 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 res = await fetch(url, { headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' }, 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(构建产物绝不被网络单点卡死)。 */ /** * 拉取双源并排序(置顶名单由 content/site/opensource.yaml 供给,缺省用 PINNED_DEFAULT) * @param pinned 置顶仓短名,顺序即展示顺序 */ export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise { const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]); 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) { const fb = (await import('../data/projects-fallback.json')).default as RepoCard[]; return orderPinned(fb, pinned); } return orderPinned([...byName.values()], pinned); } function orderPinned(list: RepoCard[], pinned: string[]): RepoCard[] { return [...list].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; }).slice(0, 6); }