| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { mkdir, writeFile } from 'node:fs/promises'; |
| 4 | import { pathToFileURL } from 'node:url'; |
| 5 | |
| 6 | const API_VERSION = '2026-03-10'; |
| 7 | const DEFAULT_REPOSITORY = 'esengine/DeepSeek-Reasonix'; |
| 8 | |
| 9 | export function parseLinkHeader(value) { |
| 10 | const links = {}; |
| 11 | for (const part of (value || '').split(',')) { |
| 12 | const match = part.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"$/); |
| 13 | if (match) links[match[2]] = match[1]; |
| 14 | } |
| 15 | return links; |
| 16 | } |
| 17 | |
| 18 | async function requestPage(url, token, fetchImpl) { |
| 19 | const retryable = new Set([429, 500, 502, 503, 504]); |
| 20 | let lastError; |
| 21 | |
| 22 | for (let attempt = 0; attempt < 4; attempt += 1) { |
| 23 | let response; |
| 24 | try { |
| 25 | response = await fetchImpl(url, { |
| 26 | headers: { |
| 27 | Accept: 'application/vnd.github.star+json', |
| 28 | Authorization: `Bearer ${token}`, |
| 29 | 'User-Agent': 'DeepSeek-Reasonix-star-history-updater', |
| 30 | 'X-GitHub-Api-Version': API_VERSION, |
| 31 | }, |
| 32 | signal: AbortSignal.timeout(30_000), |
| 33 | }); |
| 34 | } catch (error) { |
| 35 | lastError = error; |
| 36 | if (attempt < 3) { |
| 37 | await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1_000)); |
| 38 | continue; |
| 39 | } |
| 40 | break; |
| 41 | } |
| 42 | |
| 43 | if (response.ok) return response; |
| 44 | |
| 45 | const detail = (await response.text()).trim().slice(0, 300); |
| 46 | lastError = new Error(`GitHub API returned ${response.status}: ${detail}`); |
| 47 | if (!retryable.has(response.status) || attempt === 3) break; |
| 48 | await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1_000)); |
| 49 | } |
| 50 | |
| 51 | throw lastError; |
| 52 | } |
| 53 | |
| 54 | async function readStargazerPage(response, page) { |
| 55 | const entries = await response.json(); |
| 56 | if (!Array.isArray(entries)) { |
| 57 | throw new Error(`GitHub stargazers page ${page} was not an array`); |
| 58 | } |
| 59 | |
| 60 | return entries.map((entry, index) => { |
| 61 | if (typeof entry?.starred_at !== 'string') { |
| 62 | throw new Error( |
| 63 | `GitHub stargazers page ${page} item ${index + 1} did not include starred_at`, |
| 64 | ); |
| 65 | } |
| 66 | return entry.starred_at; |
| 67 | }); |
| 68 | } |
| 69 | |
| 70 | export async function fetchAllStargazers({ |
| 71 | repository = DEFAULT_REPOSITORY, |
| 72 | token, |
| 73 | fetchImpl = fetch, |
| 74 | }) { |
| 75 | if (!token) throw new Error('STAR_HISTORY_GITHUB_TOKEN is required'); |
| 76 | |
| 77 | const endpoint = new URL(`https://api.github.com/repos/${repository}/stargazers`); |
| 78 | endpoint.searchParams.set('per_page', '100'); |
| 79 | endpoint.searchParams.set('page', '1'); |
| 80 | |
| 81 | const firstResponse = await requestPage(endpoint, token, fetchImpl); |
| 82 | const links = parseLinkHeader(firstResponse.headers.get('link')); |
| 83 | const lastPage = links.last |
| 84 | ? Number.parseInt(new URL(links.last).searchParams.get('page') || '1', 10) |
| 85 | : 1; |
| 86 | |
| 87 | if (!Number.isInteger(lastPage) || lastPage < 1) { |
| 88 | throw new Error(`GitHub returned an invalid last page: ${lastPage}`); |
| 89 | } |
| 90 | |
| 91 | const timestamps = await readStargazerPage(firstResponse, 1); |
| 92 | for (let page = 2; page <= lastPage; page += 1) { |
| 93 | endpoint.searchParams.set('page', String(page)); |
| 94 | const response = await requestPage(endpoint, token, fetchImpl); |
| 95 | timestamps.push(...(await readStargazerPage(response, page))); |
| 96 | |
| 97 | if (page % 50 === 0 || page === lastPage) { |
| 98 | console.log(`Fetched stargazer page ${page}/${lastPage}`); |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | return timestamps; |
| 103 | } |
| 104 | |
| 105 | export function buildDailySeries(timestamps, asOf = new Date()) { |
| 106 | if (timestamps.length === 0) throw new Error('The repository has no stargazers'); |
| 107 | |
| 108 | const byDay = new Map(); |
| 109 | for (const timestamp of timestamps) { |
| 110 | const instant = new Date(timestamp); |
| 111 | if (Number.isNaN(instant.getTime())) { |
| 112 | throw new Error(`Invalid starred_at timestamp: ${timestamp}`); |
| 113 | } |
| 114 | const day = instant.toISOString().slice(0, 10); |
| 115 | byDay.set(day, (byDay.get(day) || 0) + 1); |
| 116 | } |
| 117 | |
| 118 | let total = 0; |
| 119 | const series = [...byDay.entries()] |
| 120 | .sort(([left], [right]) => left.localeCompare(right)) |
| 121 | .map(([date, count]) => { |
| 122 | total += count; |
| 123 | return { date, count: total }; |
| 124 | }); |
| 125 | |
| 126 | const today = asOf.toISOString().slice(0, 10); |
| 127 | if (today > series.at(-1).date) series.push({ date: today, count: total }); |
| 128 | return series; |
| 129 | } |
| 130 | |
| 131 | function escapeXml(value) { |
| 132 | return String(value) |
| 133 | .replaceAll('&', '&') |
| 134 | .replaceAll('<', '<') |
| 135 | .replaceAll('>', '>') |
| 136 | .replaceAll('"', '"') |
| 137 | .replaceAll("'", '''); |
| 138 | } |
| 139 | |
| 140 | function niceMaximum(value) { |
| 141 | const magnitude = 10 ** Math.floor(Math.log10(Math.max(1, value))); |
| 142 | return Math.ceil(value / magnitude) * magnitude; |
| 143 | } |
| 144 | |
| 145 | function formatDateTick(timestamp, spanDays) { |
| 146 | const date = new Date(timestamp); |
| 147 | return new Intl.DateTimeFormat('en-US', { |
| 148 | month: 'short', |
| 149 | ...(spanDays > 365 ? { year: 'numeric' } : { day: 'numeric' }), |
| 150 | timeZone: 'UTC', |
| 151 | }).format(date); |
| 152 | } |
| 153 | |
| 154 | export function renderStarHistorySvg( |
| 155 | series, |
| 156 | { repository = DEFAULT_REPOSITORY, theme = 'light' } = {}, |
| 157 | ) { |
| 158 | if (series.length === 0) throw new Error('Cannot render an empty star history series'); |
| 159 | |
| 160 | const palette = |
| 161 | theme === 'dark' |
| 162 | ? { |
| 163 | background: '#0d1117', |
| 164 | border: '#30363d', |
| 165 | grid: '#21262d', |
| 166 | line: '#58a6ff', |
| 167 | muted: '#8b949e', |
| 168 | text: '#f0f6fc', |
| 169 | } |
| 170 | : { |
| 171 | background: '#ffffff', |
| 172 | border: '#d0d7de', |
| 173 | grid: '#d8dee4', |
| 174 | line: '#0969da', |
| 175 | muted: '#57606a', |
| 176 | text: '#24292f', |
| 177 | }; |
| 178 | |
| 179 | const width = 960; |
| 180 | const height = 480; |
| 181 | const margin = { top: 78, right: 34, bottom: 62, left: 76 }; |
| 182 | const plotWidth = width - margin.left - margin.right; |
| 183 | const plotHeight = height - margin.top - margin.bottom; |
| 184 | const firstTime = Date.parse(`${series[0].date}T00:00:00Z`); |
| 185 | const lastTime = Date.parse(`${series.at(-1).date}T00:00:00Z`); |
| 186 | const timeSpan = Math.max(86_400_000, lastTime - firstTime); |
| 187 | const spanDays = timeSpan / 86_400_000; |
| 188 | const total = series.at(-1).count; |
| 189 | const yMaximum = niceMaximum(total); |
| 190 | const xFor = (timestamp) => margin.left + ((timestamp - firstTime) / timeSpan) * plotWidth; |
| 191 | const yFor = (count) => margin.top + plotHeight - (count / yMaximum) * plotHeight; |
| 192 | |
| 193 | const points = series.map(({ date, count }) => ({ |
| 194 | x: xFor(Date.parse(`${date}T00:00:00Z`)), |
| 195 | y: yFor(count), |
| 196 | })); |
| 197 | const linePath = points |
| 198 | .map(({ x, y }, index) => `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`) |
| 199 | .join(' '); |
| 200 | const areaPath = `${linePath} L${points.at(-1).x.toFixed(2)},${( |
| 201 | margin.top + plotHeight |
| 202 | ).toFixed(2)} L${points[0].x.toFixed(2)},${(margin.top + plotHeight).toFixed(2)} Z`; |
| 203 | |
| 204 | const yTicks = Array.from({ length: 6 }, (_, index) => { |
| 205 | const value = (yMaximum / 5) * index; |
| 206 | const y = yFor(value); |
| 207 | return ` |
| 208 | <line x1="${margin.left}" y1="${y.toFixed(2)}" x2="${width - margin.right}" y2="${y.toFixed(2)}" stroke="${palette.grid}" stroke-width="1" /> |
| 209 | <text x="${margin.left - 12}" y="${(y + 4).toFixed(2)}" text-anchor="end" fill="${palette.muted}" font-size="12">${Math.round(value).toLocaleString('en-US')}</text>`; |
| 210 | }).join(''); |
| 211 | |
| 212 | const xTicks = Array.from({ length: 6 }, (_, index) => { |
| 213 | const timestamp = firstTime + (timeSpan * index) / 5; |
| 214 | const x = xFor(timestamp); |
| 215 | return ` |
| 216 | <line x1="${x.toFixed(2)}" y1="${margin.top}" x2="${x.toFixed(2)}" y2="${margin.top + plotHeight}" stroke="${palette.grid}" stroke-width="1" /> |
| 217 | <text x="${x.toFixed(2)}" y="${height - 28}" text-anchor="middle" fill="${palette.muted}" font-size="12">${escapeXml(formatDateTick(timestamp, spanDays))}</text>`; |
| 218 | }).join(''); |
| 219 | |
| 220 | const lastPoint = points.at(-1); |
| 221 | const title = `${repository} Star History`; |
| 222 | return `<?xml version="1.0" encoding="UTF-8"?> |
| 223 | <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description"> |
| 224 | <title id="title">${escapeXml(title)}</title> |
| 225 | <desc id="description">${total.toLocaleString('en-US')} current GitHub stargazers through ${escapeXml(series.at(-1).date)}.</desc> |
| 226 | <rect width="${width}" height="${height}" rx="12" fill="${palette.background}" stroke="${palette.border}" /> |
| 227 | <text x="${margin.left}" y="37" fill="${palette.text}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="22" font-weight="600">Star History</text> |
| 228 | <text x="${margin.left}" y="60" fill="${palette.muted}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="13">${escapeXml(repository)}</text> |
| 229 | <g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"> |
| 230 | ${yTicks} |
| 231 | ${xTicks} |
| 232 | <path d="${areaPath}" fill="${palette.line}" opacity="0.12" /> |
| 233 | <path d="${linePath}" fill="none" stroke="${palette.line}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" /> |
| 234 | <circle cx="${lastPoint.x.toFixed(2)}" cy="${lastPoint.y.toFixed(2)}" r="5" fill="${palette.line}" stroke="${palette.background}" stroke-width="2" /> |
| 235 | <g transform="translate(${width - margin.right - 280}, 25)"> |
| 236 | <circle cx="0" cy="0" r="5" fill="${palette.line}" /> |
| 237 | <text x="12" y="5" fill="${palette.text}" font-size="14" font-weight="600">${escapeXml(repository)}</text> |
| 238 | <text x="12" y="24" fill="${palette.muted}" font-size="12">${total.toLocaleString('en-US')} stars</text> |
| 239 | </g> |
| 240 | <text x="${width - margin.right}" y="${height - 14}" text-anchor="end" fill="${palette.muted}" font-size="11">Updated automatically from GitHub stargazer history · ${escapeXml(series.at(-1).date)} UTC</text> |
| 241 | </g> |
| 242 | </svg> |
| 243 | `; |
| 244 | } |
| 245 | |
| 246 | export async function main() { |
| 247 | const repository = process.env.STAR_HISTORY_REPOSITORY || DEFAULT_REPOSITORY; |
| 248 | const outputDirectory = process.env.STAR_HISTORY_OUTPUT_DIR || 'assets/star-history'; |
| 249 | const token = process.env.STAR_HISTORY_GITHUB_TOKEN; |
| 250 | |
| 251 | console.log(`Fetching GitHub stargazer history for ${repository}`); |
| 252 | const timestamps = await fetchAllStargazers({ repository, token }); |
| 253 | const series = buildDailySeries(timestamps); |
| 254 | console.log(`Rendering ${timestamps.length.toLocaleString('en-US')} current stargazers`); |
| 255 | |
| 256 | await mkdir(outputDirectory, { recursive: true }); |
| 257 | await Promise.all([ |
| 258 | writeFile( |
| 259 | `${outputDirectory}/star-history-light.svg`, |
| 260 | renderStarHistorySvg(series, { repository, theme: 'light' }), |
| 261 | 'utf8', |
| 262 | ), |
| 263 | writeFile( |
| 264 | `${outputDirectory}/star-history-dark.svg`, |
| 265 | renderStarHistorySvg(series, { repository, theme: 'dark' }), |
| 266 | 'utf8', |
| 267 | ), |
| 268 | ]); |
| 269 | } |
| 270 | |
| 271 | const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; |
| 272 | if (isMain) { |
| 273 | main().catch((error) => { |
| 274 | console.error(error instanceof Error ? error.message : error); |
| 275 | process.exitCode = 1; |
| 276 | }); |
| 277 | } |
| 278 |