@mcptoolshop/registry-stats 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh.md CHANGED
@@ -3,30 +3,35 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <img src="assets/logo.png" alt="registry-stats 标志" width="280" />
6
+ <img src="https://raw.githubusercontent.com/mcp-tool-shop-org/brand/main/logos/registry-stats/readme.png" alt="registry-stats logo" width="400" />
7
7
  </p>
8
8
 
9
- <h1 align="center">@mcptoolshop/registry-stats</h1>
9
+ <p align="center">
10
+ One command. Five registries. All your download stats.
11
+ </p>
10
12
 
11
13
  <p align="center">
12
- 一个命令。五个注册表。所有下载统计。
14
+ <a href="https://github.com/mcp-tool-shop-org/registry-stats/actions/workflows/pages.yml"><img src="https://github.com/mcp-tool-shop-org/registry-stats/actions/workflows/pages.yml/badge.svg" alt="CI"></a>
15
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT License"></a>
16
+ <a href="https://www.npmjs.com/package/@mcptoolshop/registry-stats"><img src="https://img.shields.io/npm/v/@mcptoolshop/registry-stats" alt="npm version"></a>
17
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/"><img src="https://img.shields.io/badge/Landing_Page-live-blue" alt="Landing Page"></a>
13
18
  </p>
14
19
 
15
20
  <p align="center">
16
- <a href="https://mcp-tool-shop-org.github.io/registry-stats/">文档</a> &middot;
17
- <a href="#安装">安装</a> &middot;
21
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> &middot;
22
+ <a href="#install">Install</a> &middot;
18
23
  <a href="#cli">CLI</a> &middot;
19
- <a href="#配置文件">配置</a> &middot;
20
- <a href="#编程接口">API</a> &middot;
21
- <a href="#rest-api-服务器">REST 服务器</a> &middot;
22
- <a href="#许可证">许可证</a>
24
+ <a href="#config-file">Config</a> &middot;
25
+ <a href="#programmatic-api">API</a> &middot;
26
+ <a href="#rest-api-server">REST Server</a> &middot;
27
+ <a href="#license">License</a>
23
28
  </p>
24
29
 
25
30
  ---
26
31
 
27
- 如果你在 npm、PyPI、NuGet、VS Code Marketplace 或 Docker Hub 上发布包,你目前需要五个不同的 API 来回答"这个月我有多少下载量?"这个库提供统一接口 支持 CLI 和编程方式。
32
+ 如果您将软件包发布到 npm、PyPI、NuGet、VS Code Marketplace 或 Docker Hub,目前您需要使用五个不同的 API 来回答“我这个月下载了多少次?”。这个库为您提供一个统一的接口,用于访问所有这些平台,可以通过命令行界面 (CLI) 或编程 API 使用。
28
33
 
29
- 零依赖。使用原生 `fetch()`。Node 18+。
34
+ 无任何依赖。使用原生的 `fetch()` 方法。Node 18 及以上版本。
30
35
 
31
36
  ## 安装
32
37
 
@@ -34,47 +39,61 @@
34
39
  npm install @mcptoolshop/registry-stats
35
40
  ```
36
41
 
37
- ## CLI
42
+ ## 命令行界面 (CLI)
38
43
 
39
44
  ```bash
40
- # 查询单个注册表
45
+ # Query a single registry
41
46
  registry-stats express -r npm
47
+ # npm | express
48
+ # month: 283,472,710 week: 67,367,773 day: 11,566,113
42
49
 
43
- # 一次查询所有注册表
50
+ # Query all registries at once
44
51
  registry-stats express
45
52
 
46
- # 带月度细分和趋势的时间序列
53
+ # Time series with monthly breakdown + trend
47
54
  registry-stats express -r npm --range 2025-01-01:2025-06-30
55
+ # 2025-01 142,359,021
56
+ # 2025-02 147,522,528
57
+ # ...
58
+ # Total: 448,383,383 Avg/day: 4,982,038 Trend: flat (-0.46%)
48
59
 
49
- # JSON 输出
60
+ # Raw JSON output
50
61
  registry-stats express -r npm --json
51
62
 
52
- # 其他注册表
63
+ # Other registries
53
64
  registry-stats requests -r pypi
54
65
  registry-stats Newtonsoft.Json -r nuget
55
66
  registry-stats esbenp.prettier-vscode -r vscode
56
67
  registry-stats library/node -r docker
57
68
 
58
- # 创建配置文件
69
+ # Create a config file
59
70
  registry-stats --init
60
71
 
61
- # 从配置运行获取所有跟踪包的统计
72
+ # Run with config fetches all tracked packages
62
73
  registry-stats
63
74
 
64
- # 跨注册表比较
75
+ # Compare across registries
65
76
  registry-stats express --compare
66
-
67
- # 导出为 CSV 或图表友好的 JSON
77
+ # express — comparison
78
+ #
79
+ # Metric npm pypi
80
+ # ─────────────────────────────────
81
+ # Total - -
82
+ # Month 283,472 47,201
83
+ # Week 67,367 11,800
84
+ # Day 11,566 1,686
85
+
86
+ # Export as CSV or chart-friendly JSON
68
87
  registry-stats express -r npm --range 2025-01-01:2025-06-30 --format csv
69
88
  registry-stats express -r npm --range 2025-01-01:2025-06-30 --format chart
70
89
 
71
- # 启动 REST API 服务器
90
+ # Start a REST API server
72
91
  registry-stats serve --port 3000
73
92
  ```
74
93
 
75
94
  ## 配置文件
76
95
 
77
- 在项目根目录创建 `registry-stats.config.json`(或运行 `registry-stats --init`):
96
+ 在您的项目根目录下创建一个 `registry-stats.config.json` 文件(或者运行 `registry-stats --init` 命令):
78
97
 
79
98
  ```json
80
99
  {
@@ -95,91 +114,110 @@ registry-stats serve --port 3000
95
114
  }
96
115
  ```
97
116
 
98
- 不带参数运行 `registry-stats` 即可获取所有配置包的统计。CLI 会从当前目录向上查找配置文件。
117
+ 运行 `registry-stats` 命令,不带任何参数,即可获取所有配置软件包的统计数据。命令行界面会从当前工作目录向上查找最近的配置文件。
118
+
119
+ 配置文件也可以通过编程方式访问:
120
+
121
+ ```typescript
122
+ import { loadConfig, defaultConfig, starterConfig } from '@mcptoolshop/registry-stats';
123
+
124
+ const config = loadConfig(); // finds nearest config file, or null
125
+ const defaults = defaultConfig(); // returns default Config object
126
+ const template = starterConfig(); // returns starter JSON string
127
+ ```
99
128
 
100
- ## 编程接口
129
+ ## 编程 API
101
130
 
102
131
  ```typescript
103
132
  import { stats, calc, createCache } from '@mcptoolshop/registry-stats';
104
133
 
105
- // 单个注册表
134
+ // Single registry
106
135
  const npm = await stats('npm', 'express');
107
136
  const pypi = await stats('pypi', 'requests');
137
+ const nuget = await stats('nuget', 'Newtonsoft.Json');
138
+ const vscode = await stats('vscode', 'esbenp.prettier-vscode');
139
+ const docker = await stats('docker', 'library/node');
108
140
 
109
- // 所有注册表(使用 Promise.allSettled — 永不抛出)
141
+ // All registries at once (uses Promise.allSettled — never throws)
110
142
  const all = await stats.all('express');
111
143
 
112
- // 批量多个包,并发限制(默认:5
144
+ // Bulkmultiple packages, concurrency-limited (default: 5)
113
145
  const bulk = await stats.bulk('npm', ['express', 'koa', 'fastify']);
114
146
 
115
- // 时间序列(仅 npm + pypi
147
+ // Time series (npm + pypi only)
116
148
  const daily = await stats.range('npm', 'express', '2025-01-01', '2025-06-30');
117
149
 
118
- // 计算
119
- calc.total(daily); // 下载总量
120
- calc.avg(daily); // 日均量
150
+ // Calculations
151
+ calc.total(daily); // sum of all downloads
152
+ calc.avg(daily); // daily average
153
+ calc.groupTotals(calc.monthly(daily)); // { '2025-01': 134982, ... }
121
154
  calc.trend(daily); // { direction: 'up', changePercent: 8.3 }
122
- calc.movingAvg(daily, 7); // 7天移动平均
123
- calc.popularity(daily); // 0-100 对数刻度评分
155
+ calc.movingAvg(daily, 7); // 7-day moving average
156
+ calc.popularity(daily); // 0-100 log-scale score
124
157
 
125
- // 导出格式
126
- calc.toCSV(daily); // CSV 字符串
127
- calc.toChartData(daily, 'express'); // { labels: [...], datasets: [...] }
158
+ // Export formats
159
+ calc.toCSV(daily); // "date,downloads\n2025-01-01,1234\n..."
160
+ calc.toChartData(daily, 'express'); // { labels: [...], datasets: [{ label, data }] }
128
161
 
129
- // 比较跨注册表的同一包
162
+ // Comparisonsame package across registries
130
163
  const comparison = await stats.compare('express');
131
- await stats.compare('express', ['npm', 'pypi']);
164
+ // → { package: 'express', registries: { npm: {...}, pypi: {...} }, fetchedAt: '...' }
165
+ await stats.compare('express', ['npm', 'pypi']); // specific registries only
132
166
 
133
- // 缓存(5分钟 TTL,内存中)
167
+ // Caching (5 min TTL, in-memory)
134
168
  const cache = createCache();
135
- await stats('npm', 'express', { cache });
169
+ await stats('npm', 'express', { cache }); // fetches
170
+ await stats('npm', 'express', { cache }); // cache hit
136
171
  ```
137
172
 
138
- ## 注册表支持
173
+ ## 仓库支持
139
174
 
140
- | 注册表 | 包格式 | 时间序列 | 可用数据 |
141
- |--------|--------|----------|----------|
142
- | `npm` | `express`, `@scope/pkg` | 支持(549天) | lastDay, lastWeek, lastMonth |
143
- | `pypi` | `requests` | 支持(180天) | lastDay, lastWeek, lastMonth, total |
144
- | `nuget` | `Newtonsoft.Json` | 不支持 | total |
145
- | `vscode` | `publisher.extension` | 不支持 | total(安装数)、rating、trends |
146
- | `docker` | `namespace/repo` | 不支持 | total(拉取数)、stars |
175
+ | 仓库 | 软件包格式 | 时间序列 | 可用数据 |
176
+ | ---------- | --------------- | ------------- | ---------------- |
177
+ | `npm` | `express`, `@scope/pkg` | 是 (549 天) | 最近一天、最近一周、最近一个月 |
178
+ | `pypi` | `requests` | 是 (180 天) | 最近一天、最近一周、最近一个月、总数 |
179
+ | `nuget` | `Newtonsoft.Json` | No | 总数 |
180
+ | `vscode` | `publisher.extension` | No | 总数(安装量)、评分、趋势 |
181
+ | `docker` | `namespace/repo` | No | 总数(拉取次数)、星级 |
147
182
 
148
183
  ## 内置可靠性
149
184
 
150
- - 429/5xx 错误的指数退避自动重试
151
- - 遵守 `Retry-After` 头部
185
+ - 自动重试,并在遇到 429/5xx 错误时采用指数退避策略
186
+ - 尊重 `Retry-After` 头部信息
152
187
  - 批量请求的并发限制
153
- - 可选的 TTL 缓存(可插拔 — 通过 `StatsCache` 接口使用 Redis/文件后端)
188
+ - 可选的 TTL 缓存(可插拔,通过 `StatsCache` 接口,您可以自定义 Redis 或文件后端)
154
189
 
155
190
  ## REST API 服务器
156
191
 
157
- 作为微服务运行,或嵌入到自己的服务器中:
192
+ 可以作为微服务运行,也可以嵌入到您自己的服务器中:
158
193
 
159
194
  ```bash
195
+ # CLI
160
196
  registry-stats serve --port 3000
161
197
  ```
162
198
 
163
199
  ```
164
- GET /stats/:package # 所有注册表
165
- GET /stats/:registry/:package # 单个注册表
200
+ GET /stats/:package # all registries
201
+ GET /stats/:registry/:package # single registry
166
202
  GET /compare/:package?registries=npm,pypi
167
203
  GET /range/:registry/:package?start=YYYY-MM-DD&end=YYYY-MM-DD&format=json|csv|chart
168
204
  ```
169
205
 
206
+ 用于自定义服务器或无服务器环境的编程用法:
207
+
170
208
  ```typescript
171
209
  import { createHandler, serve } from '@mcptoolshop/registry-stats';
172
210
 
173
- // 快速启动
211
+ // Option 1: Quick start
174
212
  serve({ port: 3000 });
175
213
 
176
- // 自定义服务器
214
+ // Option 2: Bring your own server
177
215
  import { createServer } from 'node:http';
178
216
  const handler = createHandler();
179
217
  createServer(handler).listen(3000);
180
218
  ```
181
219
 
182
- ## 自定义注册表
220
+ ## 自定义仓库
183
221
 
184
222
  ```typescript
185
223
  import { registerProvider, type RegistryProvider } from '@mcptoolshop/registry-stats';
@@ -202,6 +240,18 @@ registerProvider(cargo);
202
240
  await stats('cargo', 'serde');
203
241
  ```
204
242
 
243
+ ## 网站
244
+
245
+ 网站和主页位于 `site/` 目录下。
246
+
247
+ - 开发:`npm run site:dev`
248
+ - 构建:`npm run site:build`
249
+ - 预览:`npm run site:preview`
250
+
205
251
  ## 许可证
206
252
 
207
253
  MIT
254
+
255
+ ---
256
+
257
+ 由 <a href="https://mcp-tool-shop.github.io/">MCP Tool Shop</a> 构建。
package/assets/logo.png CHANGED
Binary file
package/dist/cli.js CHANGED
@@ -19,22 +19,64 @@ var RegistryError = class extends Error {
19
19
  var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
20
20
  var MAX_RETRIES = 3;
21
21
  var BASE_DELAY = 1e3;
22
+ var registryLocks = /* @__PURE__ */ new Map();
23
+ var REGISTRY_DELAYS = {
24
+ npm: 400,
25
+ // ~2.5 req/s — safe for 54+ scoped packages
26
+ pypi: 2200,
27
+ // 30 req/60s = 1 per 2s, with headroom
28
+ docker: 4e3
29
+ // 10 req/3600s — very tight
30
+ };
31
+ var DEFAULT_DELAY = 100;
32
+ function acquireSlot(registry) {
33
+ const minDelay = REGISTRY_DELAYS[registry] ?? DEFAULT_DELAY;
34
+ const prev = registryLocks.get(registry) ?? Promise.resolve();
35
+ const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
36
+ registryLocks.set(registry, slot);
37
+ return prev;
38
+ }
22
39
  async function fetchWithRetry(url, registry, init) {
40
+ let lastError;
41
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
42
+ await acquireSlot(registry);
43
+ const res = await fetch(url, init);
44
+ if (res.status === 404) return null;
45
+ if (res.ok) return res.json();
46
+ const retryAfter = res.headers.get("retry-after");
47
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
48
+ lastError = new RegistryError(
49
+ registry,
50
+ res.status,
51
+ `${res.statusText}: ${url}`,
52
+ retryAfterSeconds
53
+ );
54
+ if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
55
+ const backoff = BASE_DELAY * Math.pow(2, attempt);
56
+ const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
57
+ const delay = Math.max(backoff, retryAfterMs);
58
+ await new Promise((r) => setTimeout(r, delay));
59
+ }
60
+ throw lastError;
61
+ }
62
+ async function fetchDirect(url, registry, init) {
23
63
  let lastError;
24
64
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
25
65
  const res = await fetch(url, init);
26
66
  if (res.status === 404) return null;
27
67
  if (res.ok) return res.json();
28
68
  const retryAfter = res.headers.get("retry-after");
29
- const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : void 0;
69
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
30
70
  lastError = new RegistryError(
31
71
  registry,
32
72
  res.status,
33
73
  `${res.statusText}: ${url}`,
34
- retryAfter ? parseInt(retryAfter, 10) : void 0
74
+ retryAfterSeconds
35
75
  );
36
76
  if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
37
- const delay = retryAfterMs ?? BASE_DELAY * Math.pow(2, attempt);
77
+ const backoff = BASE_DELAY * Math.pow(2, attempt);
78
+ const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
79
+ const delay = Math.max(backoff, retryAfterMs);
38
80
  await new Promise((r) => setTimeout(r, delay));
39
81
  }
40
82
  throw lastError;
@@ -45,20 +87,22 @@ var API = "https://api.npmjs.org/downloads";
45
87
  var npm = {
46
88
  name: "npm",
47
89
  async getStats(pkg) {
48
- const [day, week, month] = await Promise.all([
49
- fetchWithRetry(`${API}/point/last-day/${pkg}`, "npm"),
50
- fetchWithRetry(`${API}/point/last-week/${pkg}`, "npm"),
51
- fetchWithRetry(`${API}/point/last-month/${pkg}`, "npm")
52
- ]);
53
- if (!day && !week && !month) return null;
90
+ const end = /* @__PURE__ */ new Date();
91
+ const start = new Date(end);
92
+ start.setDate(start.getDate() - 30);
93
+ const data = await fetchWithRetry(
94
+ `${API}/range/${fmt(start)}:${fmt(end)}/${pkg}`,
95
+ "npm"
96
+ );
97
+ if (!data || !data.downloads || data.downloads.length === 0) return null;
98
+ const days = data.downloads;
99
+ const lastDay = days[days.length - 1]?.downloads ?? 0;
100
+ const lastWeek = days.slice(-7).reduce((s, d) => s + d.downloads, 0);
101
+ const lastMonth = days.reduce((s, d) => s + d.downloads, 0);
54
102
  return {
55
103
  registry: "npm",
56
104
  package: pkg,
57
- downloads: {
58
- lastDay: day?.downloads,
59
- lastWeek: week?.downloads,
60
- lastMonth: month?.downloads
61
- },
105
+ downloads: { lastDay, lastWeek, lastMonth },
62
106
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
63
107
  };
64
108
  },
@@ -86,6 +130,27 @@ var npm = {
86
130
  return chunks;
87
131
  }
88
132
  };
133
+ async function npmBulkPoint(packages, period = "last-month") {
134
+ const result = /* @__PURE__ */ new Map();
135
+ if (packages.length === 0) return result;
136
+ const BATCH_SIZE = 128;
137
+ for (let i = 0; i < packages.length; i += BATCH_SIZE) {
138
+ const batch = packages.slice(i, i + BATCH_SIZE);
139
+ const joined = batch.join(",");
140
+ const data = await fetchDirect(
141
+ `${API}/point/${period}/${joined}`,
142
+ "npm"
143
+ );
144
+ if (data) {
145
+ for (const [name, entry] of Object.entries(data)) {
146
+ if (entry && typeof entry.downloads === "number") {
147
+ result.set(name, entry.downloads);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return result;
153
+ }
89
154
  function fmt(d) {
90
155
  return d.toISOString().slice(0, 10);
91
156
  }
@@ -470,7 +535,12 @@ function createHandler(opts) {
470
535
  }
471
536
  error(res, "Not found", 404);
472
537
  } catch (e) {
473
- error(res, e.message, 500);
538
+ if (e instanceof RegistryError) {
539
+ const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
540
+ error(res, e.message, status);
541
+ } else {
542
+ error(res, e.message, 500);
543
+ }
474
544
  }
475
545
  };
476
546
  }
@@ -566,10 +636,54 @@ stats.bulk = async function bulk(registry, packages, options) {
566
636
  if (!provider) {
567
637
  throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
568
638
  }
639
+ if (registry === "npm" && packages.length > 1) {
640
+ return npmBulkStats(packages, options);
641
+ }
569
642
  const concurrency = options?.concurrency ?? 5;
570
643
  const limit = pLimit(concurrency);
571
644
  return Promise.all(packages.map((pkg) => limit(() => stats(registry, pkg, options))));
572
645
  };
646
+ async function npmBulkStats(packages, options) {
647
+ const scoped = [];
648
+ const unscoped = [];
649
+ for (const pkg of packages) {
650
+ if (pkg.startsWith("@")) {
651
+ scoped.push(pkg);
652
+ } else {
653
+ unscoped.push(pkg);
654
+ }
655
+ }
656
+ const bulkMonth = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-month") : /* @__PURE__ */ new Map();
657
+ const bulkWeek = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-week") : /* @__PURE__ */ new Map();
658
+ const bulkDay = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-day") : /* @__PURE__ */ new Map();
659
+ const unscopedResults = /* @__PURE__ */ new Map();
660
+ for (const pkg of unscoped) {
661
+ const month = bulkMonth.get(pkg);
662
+ const week = bulkWeek.get(pkg);
663
+ const day = bulkDay.get(pkg);
664
+ if (month === void 0 && week === void 0 && day === void 0) {
665
+ unscopedResults.set(pkg, null);
666
+ } else {
667
+ unscopedResults.set(pkg, {
668
+ registry: "npm",
669
+ package: pkg,
670
+ downloads: {
671
+ lastDay: day,
672
+ lastWeek: week,
673
+ lastMonth: month
674
+ },
675
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
676
+ });
677
+ }
678
+ }
679
+ const limit = pLimit(1);
680
+ const scopedResults = await Promise.all(
681
+ scoped.map((pkg) => limit(() => stats("npm", pkg, options)))
682
+ );
683
+ const scopedMap = /* @__PURE__ */ new Map();
684
+ scoped.forEach((pkg, i) => scopedMap.set(pkg, scopedResults[i]));
685
+ return packages.map((pkg) => unscopedResults.get(pkg) ?? scopedMap.get(pkg) ?? null);
686
+ }
573
687
  stats.range = async function range(registry, pkg, start, end, options) {
574
688
  const provider = providers[registry];
575
689
  if (!provider) {
@@ -613,6 +727,31 @@ stats.compare = async function compare(pkg, registries, options) {
613
727
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
614
728
  };
615
729
  };
730
+ stats.mine = async function mine(maintainer, options) {
731
+ const packages = [];
732
+ const PAGE_SIZE = 250;
733
+ let offset = 0;
734
+ while (true) {
735
+ const url = `https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(maintainer)}&size=${PAGE_SIZE}&from=${offset}`;
736
+ const data = await fetchDirect(url, "npm");
737
+ if (!data || data.objects.length === 0) break;
738
+ for (const obj of data.objects) {
739
+ packages.push(obj.package.name);
740
+ }
741
+ offset += data.objects.length;
742
+ if (offset >= data.total) break;
743
+ }
744
+ if (packages.length === 0) return [];
745
+ const results = [];
746
+ const bulkResults = await stats.bulk("npm", packages, options);
747
+ for (let i = 0; i < packages.length; i++) {
748
+ const r = bulkResults[i];
749
+ if (r) results.push(r);
750
+ options?.onProgress?.(i + 1, packages.length, packages[i]);
751
+ }
752
+ results.sort((a, b) => (b.downloads.lastMonth ?? 0) - (a.downloads.lastMonth ?? 0));
753
+ return results;
754
+ };
616
755
 
617
756
  // src/cli.ts
618
757
  function usage() {
@@ -625,6 +764,8 @@ Usage: registry-stats [package] [options]
625
764
  Options:
626
765
  --registry, -r Registry to query (npm, pypi, nuget, vscode, docker)
627
766
  Omit to query all registries
767
+ --mine Discover and show stats for all npm packages by a maintainer
768
+ e.g. registry-stats --mine mikefrilot
628
769
  --range Date range for time series (e.g. 2025-01-01:2025-06-30)
629
770
  Only npm and pypi support this
630
771
  --compare Compare package across registries side-by-side
@@ -640,6 +781,8 @@ Examples:
640
781
  registry-stats express
641
782
  registry-stats express -r npm
642
783
  registry-stats express --compare
784
+ registry-stats --mine mikefrilot
785
+ registry-stats --mine mikefrilot --format json
643
786
  registry-stats express -r npm --range 2025-01-01:2025-06-30 --format csv
644
787
  registry-stats serve --port 8080
645
788
  registry-stats --init
@@ -702,6 +845,37 @@ function printComparison(result) {
702
845
  }
703
846
  console.log();
704
847
  }
848
+ function printMineTable(results, maintainer) {
849
+ const withDownloads = results.filter((r) => (r.downloads.lastMonth ?? 0) > 0);
850
+ const noData = results.filter((r) => (r.downloads.lastMonth ?? 0) === 0);
851
+ const totalMonth = results.reduce((s, r) => s + (r.downloads.lastMonth ?? 0), 0);
852
+ const totalWeek = results.reduce((s, r) => s + (r.downloads.lastWeek ?? 0), 0);
853
+ const totalDay = results.reduce((s, r) => s + (r.downloads.lastDay ?? 0), 0);
854
+ const nameWidth = Math.max(7, ...results.map((r) => r.package.length)) + 2;
855
+ const numWidth = 10;
856
+ console.log(`
857
+ ${maintainer} \u2014 ${results.length} npm packages
858
+ `);
859
+ console.log(
860
+ ` ${"Package".padEnd(nameWidth)}${"Month".padStart(numWidth)}${"Week".padStart(numWidth)}${"Day".padStart(numWidth)}`
861
+ );
862
+ console.log(` ${"\u2500".repeat(nameWidth + numWidth * 3)}`);
863
+ for (const r of withDownloads) {
864
+ console.log(
865
+ ` ${r.package.padEnd(nameWidth)}${formatNumber(r.downloads.lastMonth).padStart(numWidth)}${formatNumber(r.downloads.lastWeek).padStart(numWidth)}${formatNumber(r.downloads.lastDay).padStart(numWidth)}`
866
+ );
867
+ }
868
+ console.log(` ${"\u2500".repeat(nameWidth + numWidth * 3)}`);
869
+ console.log(
870
+ ` ${"TOTAL".padEnd(nameWidth)}${formatNumber(totalMonth).padStart(numWidth)}${formatNumber(totalWeek).padStart(numWidth)}${formatNumber(totalDay).padStart(numWidth)}`
871
+ );
872
+ if (noData.length > 0) {
873
+ console.log(`
874
+ ${noData.length} package(s) with no download data yet:`);
875
+ console.log(` ${noData.map((r) => r.package).join(", ")}`);
876
+ }
877
+ console.log();
878
+ }
705
879
  function buildOptions(config) {
706
880
  const opts = {};
707
881
  if (!config) return opts;
@@ -751,6 +925,26 @@ async function runConfigPackages(config, format) {
751
925
  }
752
926
  console.log();
753
927
  }
928
+ async function runMine(maintainer, format, config) {
929
+ const opts = buildOptions(config);
930
+ process.stderr.write(` Discovering packages for ${maintainer}...`);
931
+ const results = await stats.mine(maintainer, {
932
+ ...opts,
933
+ onProgress(done, total, pkg) {
934
+ process.stderr.write(`\r Fetching stats... ${done}/${total} (${pkg})${"".padEnd(20)}`);
935
+ }
936
+ });
937
+ process.stderr.write("\r" + " ".repeat(80) + "\r");
938
+ if (results.length === 0) {
939
+ console.error(`No packages found for maintainer "${maintainer}".`);
940
+ process.exit(1);
941
+ }
942
+ if (format === "json") {
943
+ console.log(JSON.stringify(results, null, 2));
944
+ } else {
945
+ printMineTable(results, maintainer);
946
+ }
947
+ }
754
948
  async function main() {
755
949
  const args = process.argv.slice(2);
756
950
  if (args.includes("--help") || args.includes("-h")) {
@@ -782,6 +976,7 @@ async function main() {
782
976
  let range2;
783
977
  let format = "table";
784
978
  let compare2 = false;
979
+ let mineUser;
785
980
  for (let i = 0; i < args.length; i++) {
786
981
  if ((args[i] === "--registry" || args[i] === "-r") && args[i + 1]) {
787
982
  registry = args[++i];
@@ -793,11 +988,17 @@ async function main() {
793
988
  format = "json";
794
989
  } else if (args[i] === "--compare") {
795
990
  compare2 = true;
991
+ } else if (args[i] === "--mine" && args[i + 1]) {
992
+ mineUser = args[++i];
796
993
  } else if (!args[i].startsWith("-") && !pkg) {
797
994
  pkg = args[i];
798
995
  }
799
996
  }
800
997
  const config = loadConfig();
998
+ if (mineUser) {
999
+ await runMine(mineUser, format, config);
1000
+ return;
1001
+ }
801
1002
  if (!pkg) {
802
1003
  if (!config) {
803
1004
  usage();