|
| 1 | +#!/usr/bin/env node |
| 2 | +// Refresh stars / forks / last-updated for every package in packages/packages.json. |
| 3 | +// |
| 4 | +// packages.json is emitted by System.Text.Json with \uXXXX escapes for all non-ASCII |
| 5 | +// (500+ of them, em-dashes and emoji included). Round-tripping it through JSON.stringify |
| 6 | +// would rewrite every one of those escapes and bury the real change in a giant diff — so |
| 7 | +// instead we parse it only to read ids and fetch fresh numbers, then surgically rewrite |
| 8 | +// just the three value lines per package. Every other byte is left exactly as-is. |
| 9 | +// |
| 10 | +// Zero dependencies — uses the built-in fetch (Node 18+). Set GITHUB_TOKEN to lift the |
| 11 | +// GitHub API rate limit to 5000/hr (the Actions default token is plenty); without it |
| 12 | +// GitHub allows 60/hr, which still covers a single run of this catalog. |
| 13 | +// |
| 14 | +// Usage: node scripts/update-package-stats.mjs [--dry] |
| 15 | +// --dry fetch and report what would change, but don't write the file. |
| 16 | + |
| 17 | +import { readFile, writeFile } from 'node:fs/promises'; |
| 18 | + |
| 19 | +const FILE = new URL('../packages/packages.json', import.meta.url); |
| 20 | +const TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; |
| 21 | +const DRY = process.argv.includes('--dry'); |
| 22 | +const CONCURRENCY = 8; |
| 23 | + |
| 24 | +// owner/repo (GitHub) or group/…/repo path (GitLab) from a repo or git URL, |
| 25 | +// stripping a trailing .git, a ?path= subfolder and a #ref. |
| 26 | +function parseRepo(u){ |
| 27 | + if(!u) return null; |
| 28 | + const base = String(u).split(/[?#]/)[0].replace(/\.git$/i,'').replace(/\/+$/,''); |
| 29 | + let m; |
| 30 | + if((m = base.match(/github\.com[/:]+([^/]+)\/([^/]+)/i))) return { host:'github', owner:m[1], repo:m[2] }; |
| 31 | + if((m = base.match(/gitlab\.com[/:]+(.+)$/i))) return { host:'gitlab', path:m[1] }; |
| 32 | + return null; |
| 33 | +} |
| 34 | +const isoDate = s => (typeof s === 'string' && s.length >= 10) ? s.slice(0,10) : null; // YYYY-MM-DD |
| 35 | + |
| 36 | +async function fetchStats(r){ |
| 37 | + if(r.host === 'github'){ |
| 38 | + const headers = { 'Accept':'application/vnd.github+json', 'User-Agent':'basisvr-stats-bot', 'X-GitHub-Api-Version':'2022-11-28' }; |
| 39 | + if(TOKEN) headers.Authorization = `Bearer ${TOKEN}`; |
| 40 | + const res = await fetch(`https://api.github.com/repos/${r.owner}/${r.repo}`, { headers }); |
| 41 | + if(!res.ok) throw new Error(`GitHub ${res.status}`); |
| 42 | + const j = await res.json(); |
| 43 | + return { stars:j.stargazers_count, forks:j.forks_count, updated:isoDate(j.pushed_at) }; |
| 44 | + } |
| 45 | + const res = await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(r.path)}`, { headers:{ 'User-Agent':'basisvr-stats-bot' } }); |
| 46 | + if(!res.ok) throw new Error(`GitLab ${res.status}`); |
| 47 | + const j = await res.json(); |
| 48 | + return { stars:j.star_count, forks:j.forks_count, updated:isoDate(j.last_activity_at) }; |
| 49 | +} |
| 50 | + |
| 51 | +// ---- read + fetch (fresh: id -> {stars?,forks?,updated?}, only fields the API returned) ---- |
| 52 | +const raw = await readFile(FILE, 'utf8'); |
| 53 | +const pkgs = JSON.parse(raw); |
| 54 | +const fresh = new Map(); |
| 55 | +let failed = 0; |
| 56 | + |
| 57 | +async function worker(list){ |
| 58 | + for(const p of list){ |
| 59 | + const r = parseRepo(p.repoUrl || p.gitUrl); |
| 60 | + if(!r) continue; |
| 61 | + try{ |
| 62 | + const s = await fetchStats(r); |
| 63 | + const clean = {}; |
| 64 | + for(const k of ['stars','forks','updated']) if(s[k] != null) clean[k] = s[k]; |
| 65 | + fresh.set(p.id, clean); |
| 66 | + }catch(e){ failed++; console.error(`! ${p.id}: ${e.message}`); } |
| 67 | + } |
| 68 | +} |
| 69 | +const lanes = Array.from({ length: CONCURRENCY }, () => []); |
| 70 | +pkgs.forEach((p,i) => lanes[i % CONCURRENCY].push(p)); // round-robin so one slow lane doesn't stall |
| 71 | +await Promise.all(lanes.map(worker)); |
| 72 | + |
| 73 | +// ---- report what changed (old -> new), independent of the surgical rewrite ---- |
| 74 | +const report = []; |
| 75 | +for(const p of pkgs){ |
| 76 | + const s = fresh.get(p.id); if(!s) continue; |
| 77 | + const diffs = []; |
| 78 | + for(const k of ['stars','forks','updated']) |
| 79 | + if(s[k] != null && p[k] !== s[k]) diffs.push(`${k} ${JSON.stringify(p[k])}→${JSON.stringify(s[k])}`); |
| 80 | + if(diffs.length) report.push(` ${p.id}: ${diffs.join(', ')}`); |
| 81 | +} |
| 82 | + |
| 83 | +// ---- surgical rewrite: only the stars/forks/updated value line of the current package ---- |
| 84 | +let currentId = null; |
| 85 | +const lines = raw.split('\n').map(line => { |
| 86 | + const idm = line.match(/^\s*"id":\s*"([^"]+)"/); |
| 87 | + if(idm){ currentId = idm[1]; return line; } |
| 88 | + const s = currentId && fresh.get(currentId); |
| 89 | + if(!s) return line; |
| 90 | + let m; |
| 91 | + if(s.stars != null && (m = line.match(/^(\s*)"stars":\s*\d+(,?)\s*$/))) return `${m[1]}"stars": ${s.stars}${m[2]}`; |
| 92 | + if(s.forks != null && (m = line.match(/^(\s*)"forks":\s*\d+(,?)\s*$/))) return `${m[1]}"forks": ${s.forks}${m[2]}`; |
| 93 | + if(s.updated != null && (m = line.match(/^(\s*)"updated":\s*"[^"]*"(,?)\s*$/))) return `${m[1]}"updated": "${s.updated}"${m[2]}`; |
| 94 | + return line; |
| 95 | +}); |
| 96 | +const out = lines.join('\n'); |
| 97 | + |
| 98 | +// ---- write / report ---- |
| 99 | +if(report.length){ console.log(`${report.length} package(s) changed:`); console.log(report.join('\n')); } |
| 100 | +if(out !== raw && !DRY){ |
| 101 | + await writeFile(FILE, out); |
| 102 | + console.log(`Wrote packages.json (${fresh.size} looked up${failed ? `, ${failed} failed` : ''}).`); |
| 103 | +}else{ |
| 104 | + console.log(DRY ? '[dry run] not writing.' : `No changes${failed ? ` (${failed} lookup failure(s))` : ''}.`); |
| 105 | +} |
| 106 | + |
| 107 | +// Surface a systemic failure (bad token / rate limit) instead of committing a no-op. |
| 108 | +if(failed && fresh.size === 0){ console.error('Every lookup failed — aborting.'); process.exit(1); } |
0 commit comments