ghcr_inject.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. #!/usr/bin/env python3
  2. """Inject GHCR container-download stats into the jgehrcke/github-repo-stats report.
  3. GHCR exposes a container's total + 30-day daily-pull series only in the
  4. package page HTML. There is no REST or GraphQL API for it. This script
  5. scrapes that page once per workflow run, merges the rolling 30-day window
  6. into a sidecar CSV on gh-pages, and re-injects a Vega-Lite chart at the
  7. top of ``latest-report/report.html``.
  8. Why the merge: each run only sees the last 30 days, but the CSV grows
  9. forever — days that fall off GitHub's 30-day window stay in the CSV
  10. because they were captured while in-window. Overlapping dates are
  11. overwritten on each run, so GitHub's late-arriving revisions to recent
  12. days self-correct.
  13. Hard-fails if either scrape pattern stops matching. Silent fallbacks
  14. would let the chart freeze at last-known-good and nobody would notice.
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import csv
  19. import json
  20. import re
  21. import sys
  22. import urllib.request
  23. from datetime import datetime, timezone
  24. from pathlib import Path
  25. GHCR_URL = "https://github.com/{owner}/{pkg}/pkgs/container/{pkg}"
  26. USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) bambuddy-stats"
  27. TOTAL_RE = re.compile(
  28. r'Total downloads</span>\s*<h3 title="(\d+)">([^<]+)</h3>',
  29. re.DOTALL,
  30. )
  31. RECT_MERGE_FIRST_RE = re.compile(r'data-merge-count="(\d+)"[^>]*data-date="(\d{4}-\d{2}-\d{2})"')
  32. RECT_DATE_FIRST_RE = re.compile(r'data-date="(\d{4}-\d{2}-\d{2})"[^>]*data-merge-count="(\d+)"')
  33. def fetch_ghcr(owner: str, pkg: str) -> str:
  34. req = urllib.request.Request(
  35. GHCR_URL.format(owner=owner, pkg=pkg),
  36. headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
  37. )
  38. with urllib.request.urlopen(req, timeout=30) as resp:
  39. return resp.read().decode("utf-8")
  40. def parse_total(html: str) -> tuple[int, str]:
  41. m = TOTAL_RE.search(html)
  42. if not m:
  43. raise RuntimeError(
  44. "GHCR scrape: 'Total downloads' marker not found. GitHub markup likely changed — update TOTAL_RE."
  45. )
  46. return int(m.group(1)), m.group(2).strip()
  47. def parse_daily(html: str) -> dict[str, int]:
  48. daily: dict[str, int] = {}
  49. for m in RECT_MERGE_FIRST_RE.finditer(html):
  50. daily[m.group(2)] = int(m.group(1))
  51. for m in RECT_DATE_FIRST_RE.finditer(html):
  52. daily.setdefault(m.group(1), int(m.group(2)))
  53. if not daily:
  54. raise RuntimeError(
  55. "GHCR scrape: 30-day sparkline rects not found. GitHub markup likely changed — update RECT_*_RE."
  56. )
  57. return daily
  58. def merge_csv(csv_path: Path, fresh: dict[str, int]) -> dict[str, int]:
  59. merged: dict[str, int] = {}
  60. if csv_path.exists():
  61. with csv_path.open() as fp:
  62. for row in csv.DictReader(fp):
  63. merged[row["date"]] = int(row["daily_count"])
  64. merged.update(fresh)
  65. return dict(sorted(merged.items()))
  66. def write_csv(csv_path: Path, series: dict[str, int]) -> None:
  67. csv_path.parent.mkdir(parents=True, exist_ok=True)
  68. with csv_path.open("w", newline="") as fp:
  69. w = csv.writer(fp)
  70. w.writerow(["date", "daily_count"])
  71. for date, count in series.items():
  72. w.writerow([date, count])
  73. # Cloned verbatim from jgehrcke's "Total clones" chart so the new chart
  74. # inherits the report's theme (fonts, palette, axis colors).
  75. VEGA_CONFIG = {
  76. "arc": {"fill": "#1b1e23"},
  77. "area": {"fill": "#1b1e23"},
  78. "axisBottom": {
  79. "domainColor": "#a9b4c4",
  80. "gridColor": "#a9b4c4",
  81. "labelColor": "#1b1e23",
  82. "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  83. "tickColor": "#a9b4c4",
  84. "titleColor": "#1b1e23",
  85. "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  86. },
  87. "axisLeft": {
  88. "domainColor": "#a9b4c4",
  89. "gridColor": "#a9b4c4",
  90. "labelColor": "#1b1e23",
  91. "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  92. "tickColor": "#a9b4c4",
  93. "titleColor": "#1b1e23",
  94. "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  95. },
  96. "axisX": {"grid": False},
  97. "axisY": {"grid": False, "labelBound": True},
  98. "background": "#FFFFFF",
  99. "group": {"fill": "#FFFFFF"},
  100. "header": {
  101. "fontWeight": 400,
  102. "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  103. "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  104. },
  105. "legend": {
  106. "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  107. "symbolSize": 200,
  108. "symbolType": "circle",
  109. "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
  110. },
  111. "line": {"color": "#1b1e23", "stroke": "#1b1e23"},
  112. "path": {"stroke": "#1b1e23"},
  113. "point": {
  114. "color": "#1b1e23",
  115. "cursor": "pointer",
  116. "filled": True,
  117. "size": 20,
  118. },
  119. "range": {
  120. "category": ["#85a2f7", "#ea9755", "#7eb36a", "#f07071", "#bc85d9", "#e587b6", "#a9b4c4", "#d4c05e", "#64b9c4"],
  121. },
  122. "style": {
  123. "bar": {"fill": "#1b1e23"},
  124. "text": {
  125. "font": "relative-mono-11-pitch-pro, Menlo, monospace",
  126. "fontWeight": 400,
  127. },
  128. },
  129. "symbol": {"shape": "circle"},
  130. "title": {
  131. "anchor": "start",
  132. "font": "relative-mono-11-pitch-pro, Menlo, monospace",
  133. "fontWeight": 400,
  134. },
  135. "trail": {"color": "#1b1e23", "stroke": "#1b1e23"},
  136. "view": {"stroke": None},
  137. }
  138. def build_vega_spec(series: dict[str, int]) -> dict:
  139. rows = [{"time": f"{date}T00:00:00+00:00", "daily_count": count} for date, count in series.items()]
  140. counts = [r["daily_count"] for r in rows] or [1]
  141. y_max = max(counts)
  142. dates = sorted(series.keys())
  143. x_domain = [dates[0], dates[-1]] if dates else None
  144. return {
  145. "$schema": "https://vega.github.io/schema/vega-lite/v4.17.0.json",
  146. "config": VEGA_CONFIG,
  147. "data": {"name": "data-ghcr-pulls"},
  148. "datasets": {"data-ghcr-pulls": rows},
  149. "encoding": {
  150. "tooltip": [
  151. {"field": "daily_count", "format": ".0f", "title": "pulls", "type": "quantitative"},
  152. {"field": "time", "format": "%B %e, %Y", "title": "date", "type": "temporal"},
  153. ],
  154. "x": {
  155. "axis": {"labelAngle": 25},
  156. "field": "time",
  157. "scale": {"domain": x_domain} if x_domain else {},
  158. "timeUnit": "yearmonthdate",
  159. "title": "date",
  160. "type": "temporal",
  161. },
  162. "y": {
  163. # Linear, not symlog: pulls sit in a tight band far above zero, so a log-ish
  164. # axis squeezes the whole series into its top decade and flattens the line.
  165. "axis": {"format": "~s"},
  166. "field": "daily_count",
  167. "scale": {
  168. "domain": [0, y_max * 1.1 if y_max > 0 else 1],
  169. "type": "linear",
  170. "zero": True,
  171. },
  172. "title": "container pulls per day",
  173. "type": "quantitative",
  174. },
  175. },
  176. "height": 200,
  177. "mark": {"point": True, "type": "line"},
  178. "padding": 10,
  179. "width": "container",
  180. }
  181. TOC_START = "<!-- ghcr:toc-start -->"
  182. TOC_END = "<!-- ghcr:toc-end -->"
  183. SECTION_START = "<!-- ghcr:section-start -->"
  184. SECTION_END = "<!-- ghcr:section-end -->"
  185. SCRIPT_START = "<!-- ghcr:script-start -->"
  186. SCRIPT_END = "<!-- ghcr:script-end -->"
  187. def _strip_existing(html: str, start: str, end: str) -> str:
  188. pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
  189. return pattern.sub("", html)
  190. def patch_report(
  191. report_path: Path,
  192. spec: dict,
  193. cumulative: int,
  194. cumulative_display: str,
  195. fetched_at: str,
  196. owner: str,
  197. pkg: str,
  198. ) -> None:
  199. html = report_path.read_text(encoding="utf-8")
  200. # Idempotency: if a prior run left markers (shouldn't happen because
  201. # jgehrcke regenerates the file, but guard against partial re-runs),
  202. # strip them before re-injecting.
  203. for s, e in (
  204. (TOC_START, TOC_END),
  205. (SECTION_START, SECTION_END),
  206. (SCRIPT_START, SCRIPT_END),
  207. ):
  208. html = _strip_existing(html, s, e)
  209. toc_block = f'{TOC_START}\n<li><a href="#ghcr-pulls">Container pulls (ghcr.io)</a></li>\n{TOC_END}\n'
  210. section_block = (
  211. f"{SECTION_START}\n"
  212. f'<h2 id="ghcr-pulls">Container pulls (ghcr.io)</h2>\n'
  213. f"<p>Daily pulls of <code>ghcr.io/{owner}/{pkg}</code>. "
  214. f"Cumulative: <strong>{cumulative:,}</strong> "
  215. f"({cumulative_display}). Source refreshed {fetched_at}.</p>\n"
  216. f'<h4 id="ghcr-pulls-daily">Pulls per day</h4>\n'
  217. f'<div id="chart_ghcr_pulls_daily" class="full-width-chart">\n\n</div>\n'
  218. f'<div class="pagebreak-for-print">\n\n</div>\n'
  219. f"{SECTION_END}\n"
  220. )
  221. script_block = (
  222. f"{SCRIPT_START}\n"
  223. f'<script type="text/javascript">\n'
  224. f"vegaEmbed('#chart_ghcr_pulls_daily', "
  225. f"{json.dumps(spec, separators=(',', ':'))}, "
  226. f'{{"actions": false, "renderer": "svg"}}).catch(console.error);\n'
  227. f"</script>\n"
  228. f"{SCRIPT_END}\n"
  229. )
  230. toc_anchor = "<p>Table of contents:</p>\n<ul>\n"
  231. if toc_anchor not in html:
  232. raise RuntimeError("report.html: TOC anchor not found; layout drift?")
  233. html = html.replace(toc_anchor, toc_anchor + toc_block, 1)
  234. section_anchor = "</nav>\n"
  235. if section_anchor not in html:
  236. raise RuntimeError("report.html: section anchor (</nav>) not found; layout drift?")
  237. html = html.replace(section_anchor, section_anchor + section_block, 1)
  238. script_anchor = "</article>\n"
  239. if script_anchor not in html:
  240. raise RuntimeError("report.html: script anchor (</article>) not found; layout drift?")
  241. html = html.replace(script_anchor, script_block + script_anchor, 1)
  242. report_path.write_text(html, encoding="utf-8")
  243. def main() -> int:
  244. parser = argparse.ArgumentParser()
  245. parser.add_argument("--report", required=True, type=Path)
  246. parser.add_argument("--csv", required=True, type=Path)
  247. parser.add_argument("--owner", required=True)
  248. parser.add_argument("--pkg", required=True)
  249. parser.add_argument(
  250. "--ghcr-cache",
  251. type=Path,
  252. default=None,
  253. help="Read GHCR HTML from a local file instead of fetching. For local dry-runs only.",
  254. )
  255. args = parser.parse_args()
  256. if not args.report.exists():
  257. print(f"::error::report not found: {args.report}", file=sys.stderr)
  258. return 1
  259. if args.ghcr_cache:
  260. html = args.ghcr_cache.read_text(encoding="utf-8")
  261. print(f"Loaded cached GHCR HTML: {args.ghcr_cache} ({len(html):,} bytes)")
  262. else:
  263. html = fetch_ghcr(args.owner, args.pkg)
  264. print(f"Fetched GHCR page: {len(html):,} bytes")
  265. cumulative, cumulative_display = parse_total(html)
  266. fresh_daily = parse_daily(html)
  267. print(f"Cumulative pulls: {cumulative:,} ({cumulative_display})")
  268. print(f"Fresh days from sparkline: {len(fresh_daily)}")
  269. merged = merge_csv(args.csv, fresh_daily)
  270. write_csv(args.csv, merged)
  271. print(f"Merged CSV rows: {len(merged)} -> {args.csv}")
  272. spec = build_vega_spec(merged)
  273. fetched_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
  274. patch_report(
  275. args.report,
  276. spec,
  277. cumulative,
  278. cumulative_display,
  279. fetched_at,
  280. args.owner,
  281. args.pkg,
  282. )
  283. print(f"Patched: {args.report}")
  284. return 0
  285. if __name__ == "__main__":
  286. raise SystemExit(main())