新增:自托管Star History图表

This commit is contained in:
pppscn
2026-07-26 10:54:19 +08:00
parent 069bd13be3
commit bc5f0bbadd
4 changed files with 317 additions and 9 deletions

236
.github/scripts/star-history.mjs vendored Normal file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const DAY = 24 * 60 * 60 * 1000;
function mergePoint(points, point) {
return [...new Map([...points, point].map((item) => [item.date, item])).values()].sort(
(left, right) => left.date.localeCompare(right.date),
);
}
function pointsFromStargazers(stargazers) {
const perDay = new Map();
for (const stargazer of stargazers) {
const date = stargazer.starred_at?.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date ?? "")) {
throw new Error("GitHub returned a stargazer without starred_at");
}
perDay.set(date, (perDay.get(date) ?? 0) + 1);
}
let stars = 0;
return [...perDay.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([date, count]) => ({ date, stars: (stars += count) }));
}
function niceMaximum(value) {
if (value <= 0) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
return [1, 2, 5, 10].map((step) => step * magnitude).find((candidate) => candidate >= value);
}
function escapeXml(value) {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&apos;",
})[character]);
}
function renderSvg(history) {
const width = 960;
const height = 520;
const left = 76;
const right = 32;
const top = 92;
const bottom = 64;
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
const times = history.points.map(({ date }) => Date.parse(`${date}T00:00:00Z`));
const minimumTime = Math.min(...times);
const maximumTime = Math.max(...times);
const timeSpan = Math.max(maximumTime - minimumTime, DAY);
const maximumStars = niceMaximum(Math.max(...history.points.map(({ stars }) => stars)));
const x = (time) => history.points.length === 1
? width - right
: left + ((time - minimumTime) / timeSpan) * plotWidth;
const y = (stars) => top + plotHeight - (stars / maximumStars) * plotHeight;
const coordinates = history.points.map((point, index) => ({
x: x(times[index]),
y: y(point.stars),
}));
let line = `M ${coordinates[0].x.toFixed(1)} ${coordinates[0].y.toFixed(1)}`;
for (const point of coordinates.slice(1)) {
line += ` H ${point.x.toFixed(1)} V ${point.y.toFixed(1)}`;
}
const area = `${line} V ${height - bottom} H ${coordinates[0].x.toFixed(1)} Z`;
const number = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
const date = new Intl.DateTimeFormat("en", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: "UTC",
});
const grid = Array.from({ length: 6 }, (_, index) => {
const stars = (maximumStars * index) / 5;
const position = y(stars);
return `<path class="grid" d="M ${left} ${position.toFixed(1)} H ${width - right}"/><text class="label" x="${left - 12}" y="${(position + 4).toFixed(1)}" text-anchor="end">${number.format(stars)}</text>`;
}).join("");
const dates = Array.from({ length: 5 }, (_, index) => {
const time = minimumTime + (timeSpan * index) / 4;
const position = left + (plotWidth * index) / 4;
const anchor = index === 0 ? "start" : index === 4 ? "end" : "middle";
return `<path class="grid" d="M ${position.toFixed(1)} ${top} V ${height - bottom}"/><text class="label" x="${position.toFixed(1)}" y="${height - bottom + 28}" text-anchor="${anchor}">${date.format(time)}</text>`;
}).join("");
const latest = history.points.at(-1);
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">
<title id="title">${escapeXml(history.repository)} Star History</title>
<desc id="description">GitHub stars by date, last updated ${history.updated}</desc>
<style>
.background { fill: #ffffff; }
.grid { fill: none; stroke: #d8dee4; stroke-width: 1; }
.label, .subtitle { fill: #57606a; font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.heading, .value { fill: #24292f; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.heading { font-size: 20px; font-weight: 600; }
.value { font-size: 24px; font-weight: 600; }
.area { fill: #0969da; opacity: .12; }
.line { fill: none; stroke: #0969da; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
.point { fill: #0969da; stroke: #ffffff; stroke-width: 2; }
@media (prefers-color-scheme: dark) {
.background { fill: #0d1117; }
.grid { stroke: #30363d; }
.label, .subtitle { fill: #8b949e; }
.heading, .value { fill: #f0f6fc; }
.area { fill: #58a6ff; }
.line { stroke: #58a6ff; }
.point { fill: #58a6ff; stroke: #0d1117; }
}
</style>
<rect class="background" width="${width}" height="${height}" rx="8"/>
<text class="heading" x="${left}" y="38">${escapeXml(history.repository)} Star History</text>
<text class="subtitle" x="${left}" y="62">GitHub stars by date · updated ${history.updated}</text>
<text class="value" x="${width - right}" y="40" text-anchor="end">★ ${number.format(latest.stars)}</text>
${grid}
${dates}
<path class="area" d="${area}"/>
<path class="line" d="${line}"/>
<circle class="point" cx="${coordinates.at(-1).x.toFixed(1)}" cy="${coordinates.at(-1).y.toFixed(1)}" r="5"/>
</svg>
`;
}
async function github(pathname, token, accept = "application/vnd.github+json") {
const headers = {
Accept: accept,
"User-Agent": "knife4j-next-star-history",
"X-GitHub-Api-Version": "2022-11-28",
};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`https://api.github.com${pathname}`, { headers });
if (!response.ok) {
throw new Error(`GitHub API ${response.status}: ${(await response.text()).slice(0, 300)}`);
}
return response.json();
}
async function fetchStargazers(repository, token) {
const stargazers = [];
for (let page = 1; ; page += 1) {
const batch = await github(
`/repos/${repository}/stargazers?per_page=100&page=${page}`,
token,
"application/vnd.github.star+json",
);
stargazers.push(...batch);
if (batch.length < 100) return stargazers;
}
}
async function readHistory(file, repository) {
try {
const history = JSON.parse(await readFile(file, "utf8"));
if (history.repository !== repository || !Array.isArray(history.points)) {
throw new Error(`${file} does not contain history for ${repository}`);
}
for (const point of history.points) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(point.date) || !Number.isInteger(point.stars) || point.stars < 0) {
throw new Error(`${file} contains an invalid data point`);
}
}
return history;
} catch (error) {
if (error.code === "ENOENT") return { repository, points: [] };
throw error;
}
}
function selfTest() {
assert.deepEqual(
mergePoint([{ date: "2026-07-17", stars: 10 }], { date: "2026-07-17", stars: 11 }),
[{ date: "2026-07-17", stars: 11 }],
);
assert.deepEqual(pointsFromStargazers([
{ starred_at: "2026-07-17T01:00:00Z" },
{ starred_at: "2026-07-18T01:00:00Z" },
{ starred_at: "2026-07-18T02:00:00Z" },
]), [
{ date: "2026-07-17", stars: 1 },
{ date: "2026-07-18", stars: 3 },
]);
const svg = renderSvg({
repository: "owner/repo",
updated: "2026-07-18",
points: [{ date: "2026-07-18", stars: 3 }],
});
assert.match(svg, /owner\/repo Star History/);
assert.match(svg, /★ 3/);
console.log("star history self-test passed");
}
async function main() {
const [command = "snapshot", outputDirectory = "."] = process.argv.slice(2);
if (command === "self-test") return selfTest();
if (!new Set(["snapshot", "bootstrap"]).has(command)) {
throw new Error("usage: star-history.mjs [snapshot|bootstrap|self-test] [output-directory]");
}
const repository = process.env.GITHUB_REPOSITORY;
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? "")) {
throw new Error("GITHUB_REPOSITORY must be in owner/repository form");
}
const token = process.env.STAR_HISTORY_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
if (command === "bootstrap" && !token) {
throw new Error("bootstrap requires STAR_HISTORY_TOKEN, GITHUB_TOKEN, or GH_TOKEN");
}
const file = path.join(outputDirectory, "history.json");
const history = await readHistory(file, repository);
const repositoryData = await github(`/repos/${repository}`, token);
let points = command === "bootstrap"
? pointsFromStargazers(await fetchStargazers(repository, token))
: history.points;
const today = new Date().toISOString().slice(0, 10);
points = mergePoint(points, { date: today, stars: repositoryData.stargazers_count });
const updated = { repository, updated: today, points };
await mkdir(outputDirectory, { recursive: true });
await Promise.all([
writeFile(file, `${JSON.stringify(updated, null, 2)}\n`),
writeFile(path.join(outputDirectory, "star-history.svg"), renderSvg(updated)),
]);
console.log(`${command}: wrote ${points.length} points for ${repository}`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});

View File

@@ -0,0 +1,71 @@
name: Update Star History
on:
schedule:
- cron: "17 1 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: star-history
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
update:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Delete Workflow
uses: Mattraks/delete-workflow-runs@v2
with:
token: ${{ secrets.TOKEN }}
repository: ${{ github.repository }}
retain_days: 1
keep_minimum_runs: 2
delete_workflow_pattern: 'Update Star History'
- name: Prepare data branch
run: |
set -euo pipefail
target="$RUNNER_TEMP/star-history"
if git ls-remote --exit-code --heads origin refs/heads/star-history >/dev/null 2>&1; then
git fetch --no-tags origin refs/heads/star-history:refs/remotes/origin/star-history
git worktree add --detach "$target" refs/remotes/origin/star-history
git -C "$target" switch -c star-history
else
git worktree add --detach "$target"
git -C "$target" switch --orphan star-history
git -C "$target" rm -rf --ignore-unmatch .
fi
echo "STAR_HISTORY_DIR=$target" >> "$GITHUB_ENV"
- name: Generate snapshot
env:
GITHUB_TOKEN: ${{ github.token }}
run: node .github/scripts/star-history.mjs snapshot "$STAR_HISTORY_DIR"
- name: Publish snapshot
run: |
set -euo pipefail
git -C "$STAR_HISTORY_DIR" config user.name github-actions[bot]
git -C "$STAR_HISTORY_DIR" config user.email 41898282+github-actions[bot]@users.noreply.github.com
git -C "$STAR_HISTORY_DIR" add history.json star-history.svg
if git -C "$STAR_HISTORY_DIR" diff --cached --quiet; then
echo "Star history is already up to date"
exit 0
fi
git -C "$STAR_HISTORY_DIR" commit -m "更新 Star History 快照"
git -C "$STAR_HISTORY_DIR" push origin HEAD:refs/heads/star-history

2
.nvmrc Normal file
View File

@@ -0,0 +1,2 @@
22

View File

@@ -22,9 +22,10 @@
> `加入SmsF预览体验计划`(在线更新每周构建版,率先体验新版&修复BUG
**升级操作提示:**
**升级操作提示:**
- `加入SmsF预览体验计划`后在线更新(`关于软件`页面开启,`v3.3.0_240305+`适用)
- 手动下载https://github.com/pppscn/SmsForwarder/actions/workflows/Weekly_Build.yml
- 手动下载https://github.com/pppscn/SmsForwarder/actions/workflows/Weekly_Build.yml
--------
@@ -107,13 +108,11 @@
## 如果您觉得本工具对您有帮助,不妨在右上角点亮一颗小星星,以示鼓励!
<a href="https://star-history.com/#pppscn/SmsForwarder&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=pppscn/SmsForwarder&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=pppscn/SmsForwarder&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=pppscn/SmsForwarder&type=Date" />
</picture>
</a>
<p align="center">
<a href="https://github.com/pppscn/SmsForwarder/tree/star-history">
<img alt="Star History Chart" src="https://raw.githubusercontent.com/pppscn/SmsForwarder/refs/heads/star-history/star-history.svg" />
</a>
</p>
--------