stats.ts 6.5 KB

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