|
| 1 | +/** |
| 2 | + * @description 通过 GitHub REST API 为开发者页面补全「主要贡献项目」列表。 |
| 3 | + * - 扫描 source/ 下正文为空(或正文无实质内容且无仓库链接)的开发者页面; |
| 4 | + * - 合并「本人非 fork 仓库」与「公开事件(Push/PullRequest)涉及的他人仓库」两类候选, |
| 5 | + * 按 stargazers_count 排序取前 10(star 相同按名称字典序); |
| 6 | + * - 对 NEW_LOGINS 中尚未建页的组织成员,先创建 front-matter 再补全正文。 |
| 7 | + * 已有实质正文(含仓库链接或富文本介绍)的页面绝不修改。 |
| 8 | + * |
| 9 | + * 运行:GITHUB_TOKEN=$(gh auth token) node script/enrich-contributions.js |
| 10 | + * @param GITHUB_TOKEN github token,未配置时匿名限流 60 次/小时,无法跑完全量 |
| 11 | + */ |
| 12 | + |
| 13 | +const fs = require('fs').promises; |
| 14 | +const path = require('path'); |
| 15 | + |
| 16 | +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; |
| 17 | +// 防止速度过快被 github 限制,每次请求间隔 500~800ms |
| 18 | +const DELAY_MIN = 500; |
| 19 | +const DELAY_MAX = 800; |
| 20 | +// 每个开发者最多为事件涉及的仓库查询 star 的次数上限(按事件频次排序) |
| 21 | +const MAX_EVENT_REPO_LOOKUPS = 30; |
| 22 | +// 每个开发者最终写入的仓库数上限 |
| 23 | +const TOP_N = 10; |
| 24 | +// 组织成员中尚无页面、需要新建的用户 |
| 25 | +const NEW_LOGINS = ['joyqi', 'shuashuai', 'sunshineg']; |
| 26 | +// 非开发者目录,扫描时排除 |
| 27 | +const EXCLUDE_DIRS = new Set(['_data', '_posts', 'opensource-ranking']); |
| 28 | + |
| 29 | +const SOURCE_DIR = path.join(__dirname, '../source'); |
| 30 | +const API_BASE = 'https://api.github.com'; |
| 31 | + |
| 32 | +if (!GITHUB_TOKEN) { |
| 33 | + console.warn('[警告] 未配置 GITHUB_TOKEN,GitHub 匿名请求限流 60 次/小时,无法跑完全量!'); |
| 34 | + console.warn(' 请先执行:export GITHUB_TOKEN=$(gh auth token)'); |
| 35 | +} |
| 36 | + |
| 37 | +// 延迟函数 |
| 38 | +async function delay(ms) { |
| 39 | + return new Promise(resolve => setTimeout(resolve, ms)); |
| 40 | +} |
| 41 | + |
| 42 | +// 请求间隔 500~800ms 随机 |
| 43 | +async function throttle() { |
| 44 | + await delay(DELAY_MIN + Math.floor(Math.random() * (DELAY_MAX - DELAY_MIN + 1))); |
| 45 | +} |
| 46 | + |
| 47 | +// 带限流退避重试的 GitHub API 请求 |
| 48 | +async function githubFetch(apiPath, retries = 0) { |
| 49 | + await throttle(); |
| 50 | + const headers = { |
| 51 | + 'Accept': 'application/vnd.github+json', |
| 52 | + 'User-Agent': 'opensourcewin-enrich-contributions', |
| 53 | + }; |
| 54 | + if (GITHUB_TOKEN) { |
| 55 | + headers.Authorization = `token ${GITHUB_TOKEN}`; |
| 56 | + } |
| 57 | + |
| 58 | + let response; |
| 59 | + try { |
| 60 | + response = await fetch(`${API_BASE}${apiPath}`, { headers }); |
| 61 | + } catch (error) { |
| 62 | + if (retries < 5) { |
| 63 | + const wait = 2000 * Math.pow(2, retries); |
| 64 | + console.warn(`网络错误,${wait}ms 后重试 (${apiPath}):`, error.message); |
| 65 | + await delay(wait); |
| 66 | + return githubFetch(apiPath, retries + 1); |
| 67 | + } |
| 68 | + throw error; |
| 69 | + } |
| 70 | + |
| 71 | + if (response.status === 403 || response.status === 429) { |
| 72 | + if (retries < 5) { |
| 73 | + const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10); |
| 74 | + const wait = Math.max(retryAfter * 1000, 2000 * Math.pow(2, retries)); |
| 75 | + console.warn(`触发限流 (${response.status}),${wait}ms 后重试: ${apiPath}`); |
| 76 | + await delay(wait); |
| 77 | + return githubFetch(apiPath, retries + 1); |
| 78 | + } |
| 79 | + throw new Error(`限流重试次数用尽: ${apiPath}`); |
| 80 | + } |
| 81 | + |
| 82 | + if (response.status === 404) { |
| 83 | + return null; |
| 84 | + } |
| 85 | + |
| 86 | + if (!response.ok) { |
| 87 | + throw new Error(`请求失败 ${response.status}: ${apiPath}`); |
| 88 | + } |
| 89 | + |
| 90 | + return response.json(); |
| 91 | +} |
| 92 | + |
| 93 | +// 提取 front-matter,返回 { frontMatter, body };frontMatter 含首尾 --- 分隔符原文 |
| 94 | +function splitFrontMatter(content) { |
| 95 | + const match = content.match(/^---\n[\s\S]*?\n---\n?/); |
| 96 | + if (!match) { |
| 97 | + return { frontMatter: null, body: content }; |
| 98 | + } |
| 99 | + return { frontMatter: match[0], body: content.slice(match[0].length) }; |
| 100 | +} |
| 101 | + |
| 102 | +// 判断页面状态:'has-repos'(已有仓库链接)、'rich'(富文本,禁止修改)、'empty'(需要补全) |
| 103 | +// 注意:正文非空但无仓库链接(如 AlexV525 的纯介绍页)按 'rich' 保守跳过—— |
| 104 | +// 「已有实质内容的页面绝不修改」优先级高于补全,避免误伤人工维护的介绍文字。 |
| 105 | +function classifyBody(body) { |
| 106 | + const trimmed = body.trim(); |
| 107 | + if (!trimmed) return 'empty'; |
| 108 | + if (/github\.com\/[\w.-]+\/[\w.-]+/.test(trimmed)) return 'has-repos'; |
| 109 | + return 'rich'; |
| 110 | +} |
| 111 | + |
| 112 | +// 收集某 login 的候选仓库,返回按 star 排序后的 [{ fullName, stars }] |
| 113 | +async function collectCandidateRepos(login, repoStarsCache) { |
| 114 | + const candidates = new Map(); // fullName -> stars |
| 115 | + |
| 116 | + // 来源一:本人非 fork 仓库,按 star 排序 |
| 117 | + const ownRepos = await githubFetch(`/users/${login}/repos?per_page=100&type=owner`); |
| 118 | + if (Array.isArray(ownRepos)) { |
| 119 | + for (const repo of ownRepos) { |
| 120 | + if (repo.fork) continue; |
| 121 | + candidates.set(repo.full_name, repo.stargazers_count || 0); |
| 122 | + repoStarsCache.set(repo.full_name, repo.stargazers_count || 0); |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // 来源二:公开事件中涉及的「他人仓库」(Push / PullRequest),按出现频次排序后取前 N 查 star。 |
| 127 | + // 本人名下的仓库(含 fork)不从此来源取:非 fork 仓库已在来源一覆盖,fork 不算主要贡献。 |
| 128 | + const events = await githubFetch(`/users/${login}/events/public?per_page=100`); |
| 129 | + if (Array.isArray(events)) { |
| 130 | + const freq = new Map(); // fullName -> count |
| 131 | + for (const event of events) { |
| 132 | + if (event.type !== 'PushEvent' && event.type !== 'PullRequestEvent') continue; |
| 133 | + const name = event.repo && event.repo.name; |
| 134 | + if (!name) continue; |
| 135 | + if (name.split('/')[0].toLowerCase() === login.toLowerCase()) continue; // 排除本人名下仓库 |
| 136 | + freq.set(name, (freq.get(name) || 0) + 1); |
| 137 | + } |
| 138 | + const eventRepos = [...freq.entries()] |
| 139 | + .sort((a, b) => b[1] - a[1]) |
| 140 | + .slice(0, MAX_EVENT_REPO_LOOKUPS) |
| 141 | + .map(([name]) => name); |
| 142 | + |
| 143 | + for (const fullName of eventRepos) { |
| 144 | + if (candidates.has(fullName)) continue; // 已有 star 数据,去重 |
| 145 | + let stars = repoStarsCache.get(fullName); |
| 146 | + if (stars === undefined) { |
| 147 | + const repo = await githubFetch(`/repos/${fullName}`); |
| 148 | + if (!repo) continue; // 仓库不存在或不可见 |
| 149 | + stars = repo.stargazers_count || 0; |
| 150 | + repoStarsCache.set(fullName, stars); |
| 151 | + } |
| 152 | + candidates.set(fullName, stars); |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + return [...candidates.entries()] |
| 157 | + .map(([fullName, stars]) => ({ fullName, stars })) |
| 158 | + .sort((a, b) => b.stars - a.stars || a.fullName.toLowerCase().localeCompare(b.fullName.toLowerCase())) |
| 159 | + .slice(0, TOP_N); |
| 160 | +} |
| 161 | + |
| 162 | +// 渲染「主要贡献项目」正文,格式对齐 Goooler 页面 |
| 163 | +function renderBody(repos) { |
| 164 | + const lines = repos.map(r => `* <https://github.com/${r.fullName}>`); |
| 165 | + return `### 主要贡献项目\n\n${lines.join('\n')}\n`; |
| 166 | +} |
| 167 | + |
| 168 | +// 为组织新成员创建 index.md(front-matter + 正文) |
| 169 | +async function createNewPage(login, repos) { |
| 170 | + const user = await githubFetch(`/users/${login}`); |
| 171 | + if (!user) { |
| 172 | + console.log(`[${login}] 获取用户信息失败(404),跳过新建页面`); |
| 173 | + return false; |
| 174 | + } |
| 175 | + const dirPath = path.join(SOURCE_DIR, login); |
| 176 | + const indexPath = path.join(dirPath, 'index.md'); |
| 177 | + |
| 178 | + let frontMatter = `---\nslug: ${login}\n`; |
| 179 | + if (user.name) frontMatter += `name: ${user.name}\n`; |
| 180 | + if (user.location) frontMatter += `description: "${user.location}"\n`; |
| 181 | + frontMatter += `github_id: ${user.id}\ngithub_avatar: ${user.avatar_url}\n---\n\n`; |
| 182 | + |
| 183 | + await fs.mkdir(dirPath, { recursive: true }); |
| 184 | + await fs.writeFile(indexPath, frontMatter + renderBody(repos), 'utf-8'); |
| 185 | + console.log(`[${login}] 新建页面,补全 ${repos.length} 个仓库`); |
| 186 | + return true; |
| 187 | +} |
| 188 | + |
| 189 | +async function processLogin(login, repoStarsCache) { |
| 190 | + const indexPath = path.join(SOURCE_DIR, login, 'index.md'); |
| 191 | + const content = await fs.readFile(indexPath, 'utf-8'); |
| 192 | + const { frontMatter, body } = splitFrontMatter(content); |
| 193 | + |
| 194 | + const status = classifyBody(body); |
| 195 | + if (status === 'has-repos') { |
| 196 | + console.log(`[${login}] 跳过:正文已包含仓库链接`); |
| 197 | + return; |
| 198 | + } |
| 199 | + if (status === 'rich') { |
| 200 | + console.log(`[${login}] 跳过:正文为富文本介绍,不做修改`); |
| 201 | + return; |
| 202 | + } |
| 203 | + |
| 204 | + const repos = await collectCandidateRepos(login, repoStarsCache); |
| 205 | + if (repos.length === 0) { |
| 206 | + console.log(`[${login}] 跳过:未获取到任何候选仓库(可能无公开活动)`); |
| 207 | + return; |
| 208 | + } |
| 209 | + |
| 210 | + // front-matter 原样保留,仅替换正文 |
| 211 | + const newContent = `${frontMatter || `---\nslug: ${login}\n---\n`}\n${renderBody(repos)}`; |
| 212 | + await fs.writeFile(indexPath, newContent, 'utf-8'); |
| 213 | + console.log(`[${login}] 补全 ${repos.length} 个仓库`); |
| 214 | +} |
| 215 | + |
| 216 | +async function main() { |
| 217 | + const repoStarsCache = new Map(); // 跨用户去重缓存:fullName -> stars |
| 218 | + |
| 219 | + // 任务二:先处理需要新建页面的组织成员 |
| 220 | + for (const login of NEW_LOGINS) { |
| 221 | + const indexPath = path.join(SOURCE_DIR, login, 'index.md'); |
| 222 | + try { |
| 223 | + await fs.access(indexPath); |
| 224 | + console.log(`[${login}] 页面已存在,转入常规补全流程`); |
| 225 | + } catch { |
| 226 | + const repos = await collectCandidateRepos(login, repoStarsCache); |
| 227 | + if (repos.length === 0) { |
| 228 | + console.log(`[${login}] 跳过:未获取到任何候选仓库,不创建空页面`); |
| 229 | + continue; |
| 230 | + } |
| 231 | + await createNewPage(login, repos); |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + // 任务一:扫描既有开发者目录 |
| 236 | + const entries = await fs.readdir(SOURCE_DIR, { withFileTypes: true }); |
| 237 | + const logins = entries |
| 238 | + .filter(e => e.isDirectory() && !EXCLUDE_DIRS.has(e.name) && !e.name.startsWith('.')) |
| 239 | + .map(e => e.name) |
| 240 | + .sort(); |
| 241 | + |
| 242 | + console.log(`共扫描到 ${logins.length} 个开发者目录`); |
| 243 | + |
| 244 | + for (const login of logins) { |
| 245 | + try { |
| 246 | + await processLogin(login, repoStarsCache); |
| 247 | + } catch (error) { |
| 248 | + console.error(`[${login}] 处理失败:`, error.message); |
| 249 | + } |
| 250 | + } |
| 251 | + |
| 252 | + console.log('全部处理完成。'); |
| 253 | +} |
| 254 | + |
| 255 | +main().catch(error => { |
| 256 | + console.error('Error:', error); |
| 257 | + process.exit(1); |
| 258 | +}); |
0 commit comments