From bc5f0bbadd2eeb5ca62be3e18d5864e5b20fae63 Mon Sep 17 00:00:00 2001 From: pppscn <35696959@qq.com> Date: Sun, 26 Jul 2026 10:54:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E8=87=AA=E6=89=98?= =?UTF-8?q?=E7=AE=A1`Star=20History`=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/star-history.mjs | 236 ++++++++++++++++++++++ .github/workflows/Update_Star_History.yml | 71 +++++++ .nvmrc | 2 + README.md | 17 +- 4 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/star-history.mjs create mode 100644 .github/workflows/Update_Star_History.yml create mode 100644 .nvmrc diff --git a/.github/scripts/star-history.mjs b/.github/scripts/star-history.mjs new file mode 100644 index 00000000..834d054d --- /dev/null +++ b/.github/scripts/star-history.mjs @@ -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) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[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 `${number.format(stars)}`; + }).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 `${date.format(time)}`; + }).join(""); + const latest = history.points.at(-1); + + return ` + + ${escapeXml(history.repository)} Star History + GitHub stars by date, last updated ${history.updated} + + + ${escapeXml(history.repository)} Star History + GitHub stars by date · updated ${history.updated} + ★ ${number.format(latest.stars)} + ${grid} + ${dates} + + + + +`; +} + +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; +}); diff --git a/.github/workflows/Update_Star_History.yml b/.github/workflows/Update_Star_History.yml new file mode 100644 index 00000000..8ff533da --- /dev/null +++ b/.github/workflows/Update_Star_History.yml @@ -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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..42126c05 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +22 + diff --git a/README.md b/README.md index 14eeb0de..aaf3bad1 100644 --- a/README.md +++ b/README.md @@ -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 @@ ## 如果您觉得本工具对您有帮助,不妨在右上角点亮一颗小星星,以示鼓励! - - - - - Star History Chart - - +

+ + Star History Chart + +

--------