Skip to content

Commit 0e4bd60

Browse files
committed
Add npm download trend chart
Generate a static weekly npm downloads SVG from the npm downloads API and show it in the README. Add a scheduled workflow so the chart can refresh without requiring a manual webpage check.
1 parent 47b8b03 commit 0e4bd60

4 files changed

Lines changed: 328 additions & 2 deletions

File tree

.github/npm-weekly-downloads.svg

Lines changed: 49 additions & 0 deletions
Loading
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#!/usr/bin/env python3
2+
"""Generate a static weekly npm downloads SVG for the README."""
3+
4+
import argparse
5+
import json
6+
import math
7+
import subprocess
8+
import sys
9+
import urllib.parse
10+
import urllib.request
11+
from datetime import date
12+
from datetime import datetime
13+
from datetime import timedelta
14+
from xml.sax.saxutils import escape
15+
16+
17+
def parse_args():
18+
parser = argparse.ArgumentParser()
19+
parser.add_argument("--package", default="@leonw24/open-codex")
20+
parser.add_argument("--weeks", type=int, default=12)
21+
parser.add_argument("--output", default=".github/npm-weekly-downloads.svg")
22+
return parser.parse_args()
23+
24+
25+
def latest_complete_week_end():
26+
today = date.today()
27+
return today - timedelta(days=today.weekday() + 1)
28+
29+
30+
def fetch_daily_downloads(package_name, weeks):
31+
end = latest_complete_week_end()
32+
start = end - timedelta(days=(weeks * 7) - 1)
33+
encoded = urllib.parse.quote(package_name, safe="")
34+
url = "https://api.npmjs.org/downloads/range/{start}:{end}/{package}".format(
35+
start=start.isoformat(),
36+
end=end.isoformat(),
37+
package=encoded,
38+
)
39+
try:
40+
with urllib.request.urlopen(url, timeout=20) as response:
41+
payload = json.loads(response.read().decode("utf-8"))
42+
except Exception:
43+
output = subprocess.check_output(["curl", "-fsSL", url])
44+
payload = json.loads(output.decode("utf-8"))
45+
return payload.get("downloads", [])
46+
47+
48+
def week_start(day):
49+
return day - timedelta(days=day.weekday())
50+
51+
52+
def aggregate_weeks(daily_rows, weeks):
53+
end = latest_complete_week_end()
54+
first_week = week_start(end) - timedelta(days=(weeks - 1) * 7)
55+
buckets = []
56+
totals = {}
57+
for i in range(weeks):
58+
start = first_week + timedelta(days=i * 7)
59+
buckets.append(start)
60+
totals[start] = 0
61+
62+
for row in daily_rows:
63+
day = datetime.strptime(row["day"], "%Y-%m-%d").date()
64+
start = week_start(day)
65+
if start in totals:
66+
totals[start] += int(row.get("downloads", 0))
67+
68+
return [(start, totals[start]) for start in buckets]
69+
70+
71+
def nice_upper_bound(value):
72+
if value <= 0:
73+
return 10
74+
magnitude = 10 ** int(math.floor(math.log10(value)))
75+
normalized = float(value) / magnitude
76+
if normalized <= 2:
77+
nice = 2
78+
elif normalized <= 5:
79+
nice = 5
80+
else:
81+
nice = 10
82+
return nice * magnitude
83+
84+
85+
def fmt_count(value):
86+
if value >= 1000000:
87+
return "{:.1f}M".format(value / 1000000.0).rstrip("0").rstrip(".")
88+
if value >= 1000:
89+
return "{:.1f}k".format(value / 1000.0).rstrip("0").rstrip(".")
90+
return str(value)
91+
92+
93+
def svg_text(x, y, text, extra=""):
94+
return '<text x="{x}" y="{y}" {extra}>{text}</text>'.format(
95+
x=x,
96+
y=y,
97+
extra=extra,
98+
text=escape(text),
99+
)
100+
101+
102+
def generate_svg(package_name, weekly_rows):
103+
width = 960
104+
height = 300
105+
left = 70
106+
right = 32
107+
top = 58
108+
bottom = 56
109+
chart_w = width - left - right
110+
chart_h = height - top - bottom
111+
max_downloads = max([downloads for _, downloads in weekly_rows] or [0])
112+
upper = nice_upper_bound(max_downloads)
113+
114+
def x_for(index):
115+
if len(weekly_rows) == 1:
116+
return left + chart_w / 2.0
117+
return left + (chart_w * index / float(len(weekly_rows) - 1))
118+
119+
def y_for(value):
120+
return top + chart_h - (chart_h * value / float(upper))
121+
122+
points = []
123+
for index, (_, downloads) in enumerate(weekly_rows):
124+
points.append((x_for(index), y_for(downloads), downloads))
125+
126+
if points:
127+
path = "M " + " L ".join("{:.1f} {:.1f}".format(x, y) for x, y, _ in points)
128+
else:
129+
path = ""
130+
131+
grid_lines = []
132+
for step in range(5):
133+
value = int(upper * step / 4)
134+
y = y_for(value)
135+
grid_lines.append(
136+
'<line x1="{left}" y1="{y:.1f}" x2="{right_x}" y2="{y:.1f}" stroke="#243047" stroke-width="1" />'.format(
137+
left=left,
138+
y=y,
139+
right_x=width - right,
140+
)
141+
)
142+
grid_lines.append(
143+
svg_text(
144+
18,
145+
y + 4,
146+
fmt_count(value),
147+
'font-size="12" fill="#8b9ab8"',
148+
)
149+
)
150+
151+
labels = []
152+
if weekly_rows:
153+
label_indexes = sorted(set([0, len(weekly_rows) // 2, len(weekly_rows) - 1]))
154+
for index in label_indexes:
155+
start, _ = weekly_rows[index]
156+
labels.append(
157+
svg_text(
158+
x_for(index),
159+
height - 22,
160+
"{} {}".format(start.strftime("%b"), start.day),
161+
'font-size="12" fill="#8b9ab8" text-anchor="middle"',
162+
)
163+
)
164+
165+
dots = []
166+
for x, y, downloads in points:
167+
dots.append(
168+
'<circle cx="{x:.1f}" cy="{y:.1f}" r="4" fill="#8b5cf6" stroke="#dbe7ff" stroke-width="2"><title>{downloads} downloads</title></circle>'.format(
169+
x=x,
170+
y=y,
171+
downloads=downloads,
172+
)
173+
)
174+
175+
latest = weekly_rows[-1][1] if weekly_rows else 0
176+
total_window = sum(downloads for _, downloads in weekly_rows)
177+
through = (weekly_rows[-1][0] + timedelta(days=6)).isoformat() if weekly_rows else ""
178+
179+
return """<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">
180+
<title id="title">{package_name} weekly npm downloads</title>
181+
<desc id="desc">Weekly npm downloads over the last {weeks} full weeks. Latest full week: {latest} downloads.</desc>
182+
<defs>
183+
<linearGradient id="line" x1="0" x2="1" y1="0" y2="0">
184+
<stop offset="0%" stop-color="#22d3ee" />
185+
<stop offset="55%" stop-color="#8b5cf6" />
186+
<stop offset="100%" stop-color="#f472b6" />
187+
</linearGradient>
188+
<linearGradient id="fill" x1="0" x2="0" y1="0" y2="1">
189+
<stop offset="0%" stop-color="#8b5cf6" stop-opacity="0.22" />
190+
<stop offset="100%" stop-color="#8b5cf6" stop-opacity="0" />
191+
</linearGradient>
192+
</defs>
193+
<rect width="{width}" height="{height}" rx="16" fill="#0b1020" />
194+
<rect x="1" y="1" width="{inner_w}" height="{inner_h}" rx="15" fill="none" stroke="#26324a" />
195+
<text x="{left}" y="30" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="18" font-weight="700" fill="#f8fafc">npm weekly downloads</text>
196+
<text x="{left}" y="50" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-size="12" fill="#8b9ab8">{package_name} · latest full week {latest_fmt} · {total_fmt} over {weeks} weeks · through {through}</text>
197+
<g font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">
198+
{grid}
199+
<line x1="{left}" y1="{baseline:.1f}" x2="{right_x}" y2="{baseline:.1f}" stroke="#3a4867" stroke-width="1.2" />
200+
<path d="{area_path} L {last_x:.1f} {baseline:.1f} L {left:.1f} {baseline:.1f} Z" fill="url(#fill)" />
201+
<path d="{path}" fill="none" stroke="url(#line)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
202+
{dots}
203+
{labels}
204+
</g>
205+
</svg>
206+
""".format(
207+
width=width,
208+
height=height,
209+
inner_w=width - 2,
210+
inner_h=height - 2,
211+
package_name=escape(package_name),
212+
weeks=len(weekly_rows),
213+
latest=latest,
214+
latest_fmt=fmt_count(latest),
215+
total_fmt=fmt_count(total_window),
216+
through=through,
217+
left=left,
218+
baseline=top + chart_h,
219+
right_x=width - right,
220+
grid="\n ".join(grid_lines),
221+
path=path,
222+
area_path=path,
223+
last_x=points[-1][0] if points else left,
224+
dots="\n ".join(dots),
225+
labels="\n ".join(labels),
226+
)
227+
228+
229+
def main():
230+
args = parse_args()
231+
daily_rows = fetch_daily_downloads(args.package, args.weeks)
232+
weekly_rows = aggregate_weeks(daily_rows, args.weeks)
233+
svg = generate_svg(args.package, weekly_rows)
234+
with open(args.output, "w") as handle:
235+
handle.write(svg)
236+
return 0
237+
238+
239+
if __name__ == "__main__":
240+
sys.exit(main())
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: update-npm-download-chart
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "17 3 * * *"
7+
8+
permissions:
9+
contents: write
10+
11+
concurrency:
12+
group: update-npm-download-chart
13+
cancel-in-progress: false
14+
15+
jobs:
16+
update-chart:
17+
runs-on: ubuntu-24.04
18+
steps:
19+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
20+
- name: Generate npm downloads chart
21+
run: python3 .github/scripts/update_npm_download_chart.py
22+
- name: Commit chart update
23+
run: |
24+
set -euo pipefail
25+
if git diff --quiet -- .github/npm-weekly-downloads.svg; then
26+
echo "No chart changes."
27+
exit 0
28+
fi
29+
git config user.name "github-actions[bot]"
30+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
31+
git add .github/npm-weekly-downloads.svg
32+
git commit -m "Update npm download chart"
33+
git push

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="https://raw.githubusercontent.com/LEON-gittech/codex/main/.github/open-codexcli-icon.png" alt="Open Codex CLI icon" width="180" />
2+
<img src="https://raw.githubusercontent.com/LEON-gittech/Open-Codex-CLI/main/.github/open-codexcli-icon.png" alt="Open Codex CLI icon" width="180" />
33
</p>
44

55
<h1 align="center">Open Codex CLI</h1>
@@ -17,13 +17,17 @@
1717
<a href="https://www.npmjs.com/package/@leonw24/open-codex"><img alt="npm total downloads" src="https://img.shields.io/npm/dt/%40leonw24%2Fopen-codex?label=total%20downloads" /></a>
1818
</p>
1919

20+
<p align="center">
21+
<a href="https://www.npmjs.com/package/@leonw24/open-codex"><img src="https://raw.githubusercontent.com/LEON-gittech/Open-Codex-CLI/main/.github/npm-weekly-downloads.svg" alt="Open Codex CLI weekly npm downloads chart" width="960" /></a>
22+
</p>
23+
2024
<p align="center">
2125
<a href="#english"><img alt="English" src="https://img.shields.io/badge/English-default-111111?style=for-the-badge" /></a>
2226
<a href="#简体中文"><img alt="简体中文" src="https://img.shields.io/badge/简体中文-switch-444444?style=for-the-badge" /></a>
2327
</p>
2428

2529
<p align="center">
26-
<img src="https://raw.githubusercontent.com/LEON-gittech/codex/main/.github/open-codex-cli-unleashed.png" alt="Open Codex CLI unleashed banner" width="960" />
30+
<img src="https://raw.githubusercontent.com/LEON-gittech/Open-Codex-CLI/main/.github/open-codex-cli-unleashed.png" alt="Open Codex CLI unleashed banner" width="960" />
2731
</p>
2832

2933
<p align="center">

0 commit comments

Comments
 (0)