@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.es.md +110 -60
- package/README.fr.md +151 -63
- package/README.hi.md +113 -63
- package/README.it.md +111 -61
- package/README.ja.md +109 -59
- package/README.md +12 -3
- package/README.pt-BR.md +107 -57
- package/README.zh.md +109 -59
- package/assets/logo.png +0 -0
- package/dist/cli.js +216 -15
- package/dist/index.cjs +154 -15
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +154 -15
- package/package.json +1 -1
package/README.zh.md
CHANGED
|
@@ -3,30 +3,35 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<img src="
|
|
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
|
-
<
|
|
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/"
|
|
17
|
-
<a href="
|
|
21
|
+
<a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> ·
|
|
22
|
+
<a href="#install">Install</a> ·
|
|
18
23
|
<a href="#cli">CLI</a> ·
|
|
19
|
-
<a href="
|
|
20
|
-
<a href="
|
|
21
|
-
<a href="#rest-api
|
|
22
|
-
<a href="
|
|
24
|
+
<a href="#config-file">Config</a> ·
|
|
25
|
+
<a href="#programmatic-api">API</a> ·
|
|
26
|
+
<a href="#rest-api-server">REST Server</a> ·
|
|
27
|
+
<a href="#license">License</a>
|
|
23
28
|
</p>
|
|
24
29
|
|
|
25
30
|
---
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
如果您将软件包发布到 npm、PyPI、NuGet、VS Code Marketplace 或 Docker Hub,目前您需要使用五个不同的 API 来回答“我这个月下载了多少次?”。这个库为您提供一个统一的接口,用于访问所有这些平台,可以通过命令行界面 (CLI) 或编程 API 使用。
|
|
28
33
|
|
|
29
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
90
|
+
# Start a REST API server
|
|
72
91
|
registry-stats serve --port 3000
|
|
73
92
|
```
|
|
74
93
|
|
|
75
94
|
## 配置文件
|
|
76
95
|
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
141
|
+
// All registries at once (uses Promise.allSettled — never throws)
|
|
110
142
|
const all = await stats.all('express');
|
|
111
143
|
|
|
112
|
-
//
|
|
144
|
+
// Bulk — multiple packages, concurrency-limited (default: 5)
|
|
113
145
|
const bulk = await stats.bulk('npm', ['express', 'koa', 'fastify']);
|
|
114
146
|
|
|
115
|
-
//
|
|
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); //
|
|
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
|
+
// Comparison — same package across registries
|
|
130
163
|
const comparison = await stats.compare('express');
|
|
131
|
-
|
|
164
|
+
// → { package: 'express', registries: { npm: {...}, pypi: {...} }, fetchedAt: '...' }
|
|
165
|
+
await stats.compare('express', ['npm', 'pypi']); // specific registries only
|
|
132
166
|
|
|
133
|
-
//
|
|
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` |
|
|
143
|
-
| `pypi` | `requests` |
|
|
144
|
-
| `nuget` | `Newtonsoft.Json` |
|
|
145
|
-
| `vscode` | `publisher.extension` |
|
|
146
|
-
| `docker` | `namespace/repo` |
|
|
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
|
-
-
|
|
185
|
+
- 自动重试,并在遇到 429/5xx 错误时采用指数退避策略
|
|
186
|
+
- 尊重 `Retry-After` 头部信息
|
|
152
187
|
- 批量请求的并发限制
|
|
153
|
-
- 可选的 TTL
|
|
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
|
|
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
|
-
|
|
74
|
+
retryAfterSeconds
|
|
35
75
|
);
|
|
36
76
|
if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
|
|
37
|
-
const
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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();
|