返回 DeepSeek-Reasonix
update-star-history.mjs
根目录 / scripts / update-star-history.mjs
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('&', '&amp;')
134 .replaceAll('<', '&lt;')
135 .replaceAll('>', '&gt;')
136 .replaceAll('"', '&quot;')
137 .replaceAll("'", '&apos;');
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
278 lines Plain Text