|
| 1 | +#!/usr/bin/env bun |
| 2 | + |
| 3 | +interface Asset { |
| 4 | + name: string |
| 5 | + download_count: number |
| 6 | +} |
| 7 | + |
| 8 | +interface Release { |
| 9 | + tag_name: string |
| 10 | + name: string |
| 11 | + assets: Asset[] |
| 12 | +} |
| 13 | + |
| 14 | +interface NpmDownloadsRange { |
| 15 | + start: string |
| 16 | + end: string |
| 17 | + package: string |
| 18 | + downloads: Array<{ |
| 19 | + downloads: number |
| 20 | + day: string |
| 21 | + }> |
| 22 | +} |
| 23 | + |
| 24 | +async function fetchNpmDownloads(packageName: string): Promise<number> { |
| 25 | + try { |
| 26 | + // Use a range from 2020 to current year + 5 years to ensure it works forever |
| 27 | + const currentYear = new Date().getFullYear() |
| 28 | + const endYear = currentYear + 5 |
| 29 | + const response = await fetch( |
| 30 | + `https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`, |
| 31 | + ) |
| 32 | + if (!response.ok) { |
| 33 | + console.warn( |
| 34 | + `Failed to fetch npm downloads for ${packageName}: ${response.status}`, |
| 35 | + ) |
| 36 | + return 0 |
| 37 | + } |
| 38 | + const data: NpmDownloadsRange = await response.json() |
| 39 | + return data.downloads.reduce((total, day) => total + day.downloads, 0) |
| 40 | + } catch (error) { |
| 41 | + console.warn(`Error fetching npm downloads for ${packageName}:`, error) |
| 42 | + return 0 |
| 43 | + } |
| 44 | +} |
| 45 | + |
| 46 | +async function fetchReleases(): Promise<Release[]> { |
| 47 | + const releases: Release[] = [] |
| 48 | + let page = 1 |
| 49 | + const per = 100 |
| 50 | + |
| 51 | + while (true) { |
| 52 | + const url = `https://api.github.com/repos/sst/opencode/releases?page=${page}&per_page=${per}` |
| 53 | + |
| 54 | + const response = await fetch(url) |
| 55 | + if (!response.ok) { |
| 56 | + throw new Error( |
| 57 | + `GitHub API error: ${response.status} ${response.statusText}`, |
| 58 | + ) |
| 59 | + } |
| 60 | + |
| 61 | + const batch: Release[] = await response.json() |
| 62 | + if (batch.length === 0) break |
| 63 | + |
| 64 | + releases.push(...batch) |
| 65 | + console.log(`Fetched page ${page} with ${batch.length} releases`) |
| 66 | + |
| 67 | + if (batch.length < per) break |
| 68 | + page++ |
| 69 | + } |
| 70 | + |
| 71 | + return releases |
| 72 | +} |
| 73 | + |
| 74 | +function calculate(releases: Release[]) { |
| 75 | + let total = 0 |
| 76 | + const stats = [] |
| 77 | + |
| 78 | + for (const release of releases) { |
| 79 | + let downloads = 0 |
| 80 | + const assets = [] |
| 81 | + |
| 82 | + for (const asset of release.assets) { |
| 83 | + downloads += asset.download_count |
| 84 | + assets.push({ |
| 85 | + name: asset.name, |
| 86 | + downloads: asset.download_count, |
| 87 | + }) |
| 88 | + } |
| 89 | + |
| 90 | + total += downloads |
| 91 | + stats.push({ |
| 92 | + tag: release.tag_name, |
| 93 | + name: release.name, |
| 94 | + downloads, |
| 95 | + assets, |
| 96 | + }) |
| 97 | + } |
| 98 | + |
| 99 | + return { total, stats } |
| 100 | +} |
| 101 | + |
| 102 | +async function save(githubTotal: number, npmDownloads: number) { |
| 103 | + const file = "STATS.md" |
| 104 | + const date = new Date().toISOString().split("T")[0] |
| 105 | + const total = githubTotal + npmDownloads |
| 106 | + |
| 107 | + let previousGithub = 0 |
| 108 | + let previousNpm = 0 |
| 109 | + let previousTotal = 0 |
| 110 | + let content = "" |
| 111 | + |
| 112 | + try { |
| 113 | + content = await Bun.file(file).text() |
| 114 | + const lines = content.trim().split("\n") |
| 115 | + |
| 116 | + for (let i = lines.length - 1; i >= 0; i--) { |
| 117 | + const line = lines[i].trim() |
| 118 | + if ( |
| 119 | + line.startsWith("|") && |
| 120 | + !line.includes("Date") && |
| 121 | + !line.includes("---") |
| 122 | + ) { |
| 123 | + const match = line.match( |
| 124 | + /\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/, |
| 125 | + ) |
| 126 | + if (match) { |
| 127 | + previousGithub = parseInt(match[1].replace(/,/g, "")) |
| 128 | + previousNpm = parseInt(match[2].replace(/,/g, "")) |
| 129 | + previousTotal = parseInt(match[3].replace(/,/g, "")) |
| 130 | + break |
| 131 | + } |
| 132 | + } |
| 133 | + } |
| 134 | + } catch { |
| 135 | + content = |
| 136 | + "# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n" |
| 137 | + } |
| 138 | + |
| 139 | + const githubChange = githubTotal - previousGithub |
| 140 | + const npmChange = npmDownloads - previousNpm |
| 141 | + const totalChange = total - previousTotal |
| 142 | + |
| 143 | + const githubChangeStr = |
| 144 | + githubChange > 0 |
| 145 | + ? ` (+${githubChange.toLocaleString()})` |
| 146 | + : githubChange < 0 |
| 147 | + ? ` (${githubChange.toLocaleString()})` |
| 148 | + : " (+0)" |
| 149 | + const npmChangeStr = |
| 150 | + npmChange > 0 |
| 151 | + ? ` (+${npmChange.toLocaleString()})` |
| 152 | + : npmChange < 0 |
| 153 | + ? ` (${npmChange.toLocaleString()})` |
| 154 | + : " (+0)" |
| 155 | + const totalChangeStr = |
| 156 | + totalChange > 0 |
| 157 | + ? ` (+${totalChange.toLocaleString()})` |
| 158 | + : totalChange < 0 |
| 159 | + ? ` (${totalChange.toLocaleString()})` |
| 160 | + : " (+0)" |
| 161 | + const line = `| ${date} | ${githubTotal.toLocaleString()}${githubChangeStr} | ${npmDownloads.toLocaleString()}${npmChangeStr} | ${total.toLocaleString()}${totalChangeStr} |\n` |
| 162 | + |
| 163 | + if (!content.includes("# Download Stats")) { |
| 164 | + content = |
| 165 | + "# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n" |
| 166 | + } |
| 167 | + |
| 168 | + await Bun.write(file, content + line) |
| 169 | + await Bun.spawn(["bunx", "prettier", "--write", file]).exited |
| 170 | + |
| 171 | + console.log( |
| 172 | + `\nAppended stats to ${file}: GitHub ${githubTotal.toLocaleString()}${githubChangeStr}, npm ${npmDownloads.toLocaleString()}${npmChangeStr}, Total ${total.toLocaleString()}${totalChangeStr}`, |
| 173 | + ) |
| 174 | +} |
| 175 | + |
| 176 | +console.log("Fetching GitHub releases for sst/opencode...\n") |
| 177 | + |
| 178 | +const releases = await fetchReleases() |
| 179 | +console.log(`\nFetched ${releases.length} releases total\n`) |
| 180 | + |
| 181 | +const { total: githubTotal, stats } = calculate(releases) |
| 182 | + |
| 183 | +console.log("Fetching npm all-time downloads for opencode-ai...\n") |
| 184 | +const npmDownloads = await fetchNpmDownloads("opencode-ai") |
| 185 | +console.log( |
| 186 | + `Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`, |
| 187 | +) |
| 188 | + |
| 189 | +await save(githubTotal, npmDownloads) |
| 190 | + |
| 191 | +const totalDownloads = githubTotal + npmDownloads |
| 192 | + |
| 193 | +console.log("=".repeat(60)) |
| 194 | +console.log(`TOTAL DOWNLOADS: ${totalDownloads.toLocaleString()}`) |
| 195 | +console.log(` GitHub: ${githubTotal.toLocaleString()}`) |
| 196 | +console.log(` npm: ${npmDownloads.toLocaleString()}`) |
| 197 | +console.log("=".repeat(60)) |
| 198 | + |
| 199 | +console.log("\nDownloads by release:") |
| 200 | +console.log("-".repeat(60)) |
| 201 | + |
| 202 | +stats |
| 203 | + .sort((a, b) => b.downloads - a.downloads) |
| 204 | + .forEach((release) => { |
| 205 | + console.log( |
| 206 | + `${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`, |
| 207 | + ) |
| 208 | + |
| 209 | + if (release.assets.length > 1) { |
| 210 | + release.assets |
| 211 | + .sort((a, b) => b.downloads - a.downloads) |
| 212 | + .forEach((asset) => { |
| 213 | + console.log( |
| 214 | + ` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`, |
| 215 | + ) |
| 216 | + }) |
| 217 | + } |
| 218 | + }) |
| 219 | + |
| 220 | +console.log("-".repeat(60)) |
| 221 | +console.log( |
| 222 | + `GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`, |
| 223 | +) |
| 224 | +console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`) |
| 225 | +console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`) |
0 commit comments