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 `
+
+`;
+}
+
+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 @@
## 如果您觉得本工具对您有帮助,不妨在右上角点亮一颗小星星,以示鼓励!
-
-
-
-
-
-
-
+
+
+
+
+
--------