|
| 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()) |
0 commit comments