@mcptoolshop/registry-stats 0.3.0
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/LICENSE +21 -0
- package/README.es.md +206 -0
- package/README.fr.md +206 -0
- package/README.hi.md +206 -0
- package/README.it.md +206 -0
- package/README.ja.md +206 -0
- package/README.md +239 -0
- package/README.pt-BR.md +206 -0
- package/README.zh.md +206 -0
- package/assets/logo.png +0 -0
- package/dist/cli.js +880 -0
- package/dist/index.cjs +669 -0
- package/dist/index.d.cts +130 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +633 -0
- package/package.json +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { writeFileSync, existsSync as existsSync2 } from "fs";
|
|
5
|
+
import { resolve as resolve2 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/types.ts
|
|
8
|
+
var RegistryError = class extends Error {
|
|
9
|
+
constructor(registry, statusCode, message, retryAfter) {
|
|
10
|
+
super(`[${registry}] ${message}`);
|
|
11
|
+
this.registry = registry;
|
|
12
|
+
this.statusCode = statusCode;
|
|
13
|
+
this.retryAfter = retryAfter;
|
|
14
|
+
this.name = "RegistryError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/fetch.ts
|
|
19
|
+
var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
20
|
+
var MAX_RETRIES = 3;
|
|
21
|
+
var BASE_DELAY = 1e3;
|
|
22
|
+
async function fetchWithRetry(url, registry, init) {
|
|
23
|
+
let lastError;
|
|
24
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
25
|
+
const res = await fetch(url, init);
|
|
26
|
+
if (res.status === 404) return null;
|
|
27
|
+
if (res.ok) return res.json();
|
|
28
|
+
const retryAfter = res.headers.get("retry-after");
|
|
29
|
+
const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : void 0;
|
|
30
|
+
lastError = new RegistryError(
|
|
31
|
+
registry,
|
|
32
|
+
res.status,
|
|
33
|
+
`${res.statusText}: ${url}`,
|
|
34
|
+
retryAfter ? parseInt(retryAfter, 10) : void 0
|
|
35
|
+
);
|
|
36
|
+
if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
|
|
37
|
+
const delay = retryAfterMs ?? BASE_DELAY * Math.pow(2, attempt);
|
|
38
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
39
|
+
}
|
|
40
|
+
throw lastError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/providers/npm.ts
|
|
44
|
+
var API = "https://api.npmjs.org/downloads";
|
|
45
|
+
var npm = {
|
|
46
|
+
name: "npm",
|
|
47
|
+
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;
|
|
54
|
+
return {
|
|
55
|
+
registry: "npm",
|
|
56
|
+
package: pkg,
|
|
57
|
+
downloads: {
|
|
58
|
+
lastDay: day?.downloads,
|
|
59
|
+
lastWeek: week?.downloads,
|
|
60
|
+
lastMonth: month?.downloads
|
|
61
|
+
},
|
|
62
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
async getRange(pkg, start, end) {
|
|
66
|
+
const startDate = new Date(start);
|
|
67
|
+
const endDate = new Date(end);
|
|
68
|
+
const maxDays = 549;
|
|
69
|
+
const chunks = [];
|
|
70
|
+
let cursor = startDate;
|
|
71
|
+
while (cursor < endDate) {
|
|
72
|
+
const chunkEnd = new Date(cursor);
|
|
73
|
+
chunkEnd.setDate(chunkEnd.getDate() + maxDays - 1);
|
|
74
|
+
const actualEnd = chunkEnd > endDate ? endDate : chunkEnd;
|
|
75
|
+
const s = fmt(cursor);
|
|
76
|
+
const e = fmt(actualEnd);
|
|
77
|
+
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${pkg}`, "npm");
|
|
78
|
+
if (data) {
|
|
79
|
+
for (const d of data.downloads) {
|
|
80
|
+
chunks.push({ date: d.day, downloads: d.downloads });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
cursor = new Date(actualEnd);
|
|
84
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
85
|
+
}
|
|
86
|
+
return chunks;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
function fmt(d) {
|
|
90
|
+
return d.toISOString().slice(0, 10);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/providers/pypi.ts
|
|
94
|
+
var API2 = "https://pypistats.org/api";
|
|
95
|
+
var pypi = {
|
|
96
|
+
name: "pypi",
|
|
97
|
+
rateLimit: { maxRequests: 30, windowSeconds: 60, authRaisesLimit: false },
|
|
98
|
+
async getStats(pkg) {
|
|
99
|
+
const [recent, overall] = await Promise.all([
|
|
100
|
+
fetchWithRetry(`${API2}/packages/${pkg}/recent`, "pypi"),
|
|
101
|
+
fetchWithRetry(`${API2}/packages/${pkg}/overall?mirrors=false`, "pypi")
|
|
102
|
+
]);
|
|
103
|
+
if (!recent && !overall) return null;
|
|
104
|
+
const total = overall?.data?.filter((d) => d.category === "without_mirrors")?.reduce((sum, d) => sum + d.downloads, 0);
|
|
105
|
+
return {
|
|
106
|
+
registry: "pypi",
|
|
107
|
+
package: pkg,
|
|
108
|
+
downloads: {
|
|
109
|
+
total: total || void 0,
|
|
110
|
+
lastDay: recent?.data.last_day,
|
|
111
|
+
lastWeek: recent?.data.last_week,
|
|
112
|
+
lastMonth: recent?.data.last_month
|
|
113
|
+
},
|
|
114
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
async getRange(pkg, start, end) {
|
|
118
|
+
const data = await fetchWithRetry(
|
|
119
|
+
`${API2}/packages/${pkg}/overall?mirrors=false`,
|
|
120
|
+
"pypi"
|
|
121
|
+
);
|
|
122
|
+
if (!data) return [];
|
|
123
|
+
const startDate = new Date(start);
|
|
124
|
+
const endDate = new Date(end);
|
|
125
|
+
return data.data.filter((d) => {
|
|
126
|
+
if (!d.date || d.category !== "without_mirrors") return false;
|
|
127
|
+
const date = new Date(d.date);
|
|
128
|
+
return date >= startDate && date <= endDate;
|
|
129
|
+
}).map((d) => ({ date: d.date, downloads: d.downloads })).sort((a, b) => a.date.localeCompare(b.date));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/providers/nuget.ts
|
|
134
|
+
var SEARCH_API = "https://azuresearch-usnc.nuget.org/query";
|
|
135
|
+
var nuget = {
|
|
136
|
+
name: "nuget",
|
|
137
|
+
async getStats(pkg) {
|
|
138
|
+
const url = `${SEARCH_API}?q=packageid:${encodeURIComponent(pkg)}&take=1`;
|
|
139
|
+
const json2 = await fetchWithRetry(url, "nuget");
|
|
140
|
+
if (!json2) return null;
|
|
141
|
+
const match = json2.data.find(
|
|
142
|
+
(d) => d.id.toLowerCase() === pkg.toLowerCase()
|
|
143
|
+
);
|
|
144
|
+
if (!match) return null;
|
|
145
|
+
return {
|
|
146
|
+
registry: "nuget",
|
|
147
|
+
package: match.id,
|
|
148
|
+
downloads: {
|
|
149
|
+
total: match.totalDownloads
|
|
150
|
+
},
|
|
151
|
+
extra: {
|
|
152
|
+
version: match.version
|
|
153
|
+
},
|
|
154
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/providers/vscode.ts
|
|
160
|
+
var API3 = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery";
|
|
161
|
+
function getStat(stats2, name) {
|
|
162
|
+
return stats2.find((s) => s.statisticName === name)?.value;
|
|
163
|
+
}
|
|
164
|
+
var vscode = {
|
|
165
|
+
name: "vscode",
|
|
166
|
+
async getStats(pkg) {
|
|
167
|
+
let json2;
|
|
168
|
+
try {
|
|
169
|
+
json2 = await fetchWithRetry(API3, "vscode", {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: {
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
Accept: "application/json;api-version=3.0-preview.1"
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
filters: [
|
|
177
|
+
{
|
|
178
|
+
criteria: [{ filterType: 7, value: pkg }]
|
|
179
|
+
}
|
|
180
|
+
],
|
|
181
|
+
flags: 256
|
|
182
|
+
// IncludeStatistics
|
|
183
|
+
})
|
|
184
|
+
});
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (e.statusCode === 400) return null;
|
|
187
|
+
throw e;
|
|
188
|
+
}
|
|
189
|
+
if (!json2) return null;
|
|
190
|
+
const ext = json2.results?.[0]?.extensions?.[0];
|
|
191
|
+
if (!ext) return null;
|
|
192
|
+
const stats2 = ext.statistics || [];
|
|
193
|
+
return {
|
|
194
|
+
registry: "vscode",
|
|
195
|
+
package: `${ext.publisher.publisherName}.${ext.extensionName}`,
|
|
196
|
+
downloads: {
|
|
197
|
+
total: getStat(stats2, "install")
|
|
198
|
+
},
|
|
199
|
+
extra: {
|
|
200
|
+
displayName: ext.displayName,
|
|
201
|
+
rating: getStat(stats2, "averagerating"),
|
|
202
|
+
ratingCount: getStat(stats2, "ratingcount"),
|
|
203
|
+
trendingDaily: getStat(stats2, "trendingdaily"),
|
|
204
|
+
trendingWeekly: getStat(stats2, "trendingweekly"),
|
|
205
|
+
trendingMonthly: getStat(stats2, "trendingmonthly")
|
|
206
|
+
},
|
|
207
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// src/providers/docker.ts
|
|
213
|
+
var API4 = "https://hub.docker.com/v2/repositories";
|
|
214
|
+
var docker = {
|
|
215
|
+
name: "docker",
|
|
216
|
+
rateLimit: { maxRequests: 10, windowSeconds: 3600, authRaisesLimit: true },
|
|
217
|
+
async getStats(pkg, options) {
|
|
218
|
+
const headers = {};
|
|
219
|
+
if (options?.dockerToken) {
|
|
220
|
+
headers["Authorization"] = `Bearer ${options.dockerToken}`;
|
|
221
|
+
}
|
|
222
|
+
const json2 = await fetchWithRetry(`${API4}/${pkg}`, "docker", { headers });
|
|
223
|
+
if (!json2 || !json2.name || !json2.namespace) return null;
|
|
224
|
+
return {
|
|
225
|
+
registry: "docker",
|
|
226
|
+
package: `${json2.namespace}/${json2.name}`,
|
|
227
|
+
downloads: {
|
|
228
|
+
total: json2.pull_count
|
|
229
|
+
},
|
|
230
|
+
extra: {
|
|
231
|
+
stars: json2.star_count,
|
|
232
|
+
lastUpdated: json2.last_updated
|
|
233
|
+
},
|
|
234
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// src/calc.ts
|
|
240
|
+
var calc = {
|
|
241
|
+
total(records) {
|
|
242
|
+
return records.reduce((sum, r) => sum + r.downloads, 0);
|
|
243
|
+
},
|
|
244
|
+
avg(records) {
|
|
245
|
+
if (records.length === 0) return 0;
|
|
246
|
+
return calc.total(records) / records.length;
|
|
247
|
+
},
|
|
248
|
+
group(records, fn) {
|
|
249
|
+
const groups = {};
|
|
250
|
+
for (const r of records) {
|
|
251
|
+
const key = fn(r);
|
|
252
|
+
(groups[key] ??= []).push(r);
|
|
253
|
+
}
|
|
254
|
+
return groups;
|
|
255
|
+
},
|
|
256
|
+
monthly(records) {
|
|
257
|
+
return calc.group(records, (r) => r.date.slice(0, 7));
|
|
258
|
+
},
|
|
259
|
+
yearly(records) {
|
|
260
|
+
return calc.group(records, (r) => r.date.slice(0, 4));
|
|
261
|
+
},
|
|
262
|
+
groupTotals(grouped) {
|
|
263
|
+
const result = {};
|
|
264
|
+
for (const [key, records] of Object.entries(grouped)) {
|
|
265
|
+
result[key] = calc.total(records);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
},
|
|
269
|
+
groupAvgs(grouped) {
|
|
270
|
+
const result = {};
|
|
271
|
+
for (const [key, records] of Object.entries(grouped)) {
|
|
272
|
+
result[key] = calc.avg(records);
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
},
|
|
276
|
+
trend(records, windowDays = 7) {
|
|
277
|
+
if (records.length < windowDays * 2) {
|
|
278
|
+
return { slope: 0, direction: "flat", changePercent: 0 };
|
|
279
|
+
}
|
|
280
|
+
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
281
|
+
const recent = sorted.slice(-windowDays);
|
|
282
|
+
const previous = sorted.slice(-windowDays * 2, -windowDays);
|
|
283
|
+
const recentAvg = calc.avg(recent);
|
|
284
|
+
const previousAvg = calc.avg(previous);
|
|
285
|
+
const slope = recentAvg - previousAvg;
|
|
286
|
+
const changePercent = previousAvg === 0 ? 0 : (recentAvg - previousAvg) / previousAvg * 100;
|
|
287
|
+
const threshold = previousAvg * 0.05;
|
|
288
|
+
const direction = slope > threshold ? "up" : slope < -threshold ? "down" : "flat";
|
|
289
|
+
return { slope: Math.round(slope * 100) / 100, direction, changePercent: Math.round(changePercent * 100) / 100 };
|
|
290
|
+
},
|
|
291
|
+
movingAvg(records, windowDays = 7) {
|
|
292
|
+
if (records.length < windowDays) return [];
|
|
293
|
+
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
294
|
+
const result = [];
|
|
295
|
+
for (let i = windowDays - 1; i < sorted.length; i++) {
|
|
296
|
+
let sum = 0;
|
|
297
|
+
for (let j = i - windowDays + 1; j <= i; j++) {
|
|
298
|
+
sum += sorted[j].downloads;
|
|
299
|
+
}
|
|
300
|
+
result.push({
|
|
301
|
+
date: sorted[i].date,
|
|
302
|
+
downloads: Math.round(sum / windowDays * 100) / 100
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
},
|
|
307
|
+
popularity(records) {
|
|
308
|
+
if (records.length === 0) return 0;
|
|
309
|
+
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
310
|
+
const recentDays = Math.min(30, sorted.length);
|
|
311
|
+
const recent = sorted.slice(-recentDays);
|
|
312
|
+
const avgDaily = calc.avg(recent);
|
|
313
|
+
const score = Math.max(0, Math.min(100, Math.log10(Math.max(1, avgDaily)) / 6 * 100));
|
|
314
|
+
return Math.round(score * 10) / 10;
|
|
315
|
+
},
|
|
316
|
+
toCSV(records) {
|
|
317
|
+
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
318
|
+
const lines = ["date,downloads"];
|
|
319
|
+
for (const r of sorted) {
|
|
320
|
+
lines.push(`${r.date},${r.downloads}`);
|
|
321
|
+
}
|
|
322
|
+
return lines.join("\n");
|
|
323
|
+
},
|
|
324
|
+
toChartData(records, label = "downloads") {
|
|
325
|
+
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
326
|
+
return {
|
|
327
|
+
labels: sorted.map((r) => r.date),
|
|
328
|
+
datasets: [{ label, data: sorted.map((r) => r.downloads) }]
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// src/config.ts
|
|
334
|
+
import { readFileSync, existsSync } from "fs";
|
|
335
|
+
import { resolve, dirname } from "path";
|
|
336
|
+
var CONFIG_NAME = "registry-stats.config.json";
|
|
337
|
+
function loadConfig(startDir) {
|
|
338
|
+
let dir = startDir ?? process.cwd();
|
|
339
|
+
while (true) {
|
|
340
|
+
const configPath = resolve(dir, CONFIG_NAME);
|
|
341
|
+
if (existsSync(configPath)) {
|
|
342
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
343
|
+
return JSON.parse(raw);
|
|
344
|
+
}
|
|
345
|
+
const parent = dirname(dir);
|
|
346
|
+
if (parent === dir) break;
|
|
347
|
+
dir = parent;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
var STARTER = `{
|
|
352
|
+
"registries": ["npm", "pypi", "nuget", "vscode", "docker"],
|
|
353
|
+
"packages": {
|
|
354
|
+
"my-package": {
|
|
355
|
+
"npm": "my-package",
|
|
356
|
+
"pypi": "my-package"
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
"cache": true,
|
|
360
|
+
"cacheTtlMs": 300000,
|
|
361
|
+
"concurrency": 5
|
|
362
|
+
}
|
|
363
|
+
`;
|
|
364
|
+
function starterConfig() {
|
|
365
|
+
return STARTER;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/server.ts
|
|
369
|
+
import { createServer as httpCreateServer } from "http";
|
|
370
|
+
function json(res, data, status = 200) {
|
|
371
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
372
|
+
res.end(JSON.stringify(data));
|
|
373
|
+
}
|
|
374
|
+
function error(res, message, status = 400) {
|
|
375
|
+
json(res, { error: message }, status);
|
|
376
|
+
}
|
|
377
|
+
function parseUrl(url) {
|
|
378
|
+
const [pathname, search] = url.split("?");
|
|
379
|
+
const path = pathname.replace(/^\/api\//, "/").split("/").filter(Boolean);
|
|
380
|
+
const query = {};
|
|
381
|
+
if (search) {
|
|
382
|
+
for (const pair of search.split("&")) {
|
|
383
|
+
const [k, v] = pair.split("=");
|
|
384
|
+
if (k) query[decodeURIComponent(k)] = decodeURIComponent(v ?? "");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { path, query };
|
|
388
|
+
}
|
|
389
|
+
function createHandler(opts) {
|
|
390
|
+
const options = { ...opts };
|
|
391
|
+
if (!options.cache) {
|
|
392
|
+
options.cache = createCache();
|
|
393
|
+
}
|
|
394
|
+
return async (req, res) => {
|
|
395
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
396
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
397
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
398
|
+
if (req.method === "OPTIONS") {
|
|
399
|
+
res.writeHead(204);
|
|
400
|
+
res.end();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (req.method !== "GET") {
|
|
404
|
+
error(res, "Method not allowed", 405);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const { path, query } = parseUrl(req.url ?? "/");
|
|
408
|
+
try {
|
|
409
|
+
if (path[0] === "stats") {
|
|
410
|
+
if (path.length === 2) {
|
|
411
|
+
const pkg = decodeURIComponent(path[1]);
|
|
412
|
+
const results = await stats.all(pkg, options);
|
|
413
|
+
json(res, results);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (path.length >= 3) {
|
|
417
|
+
const registry = path[1];
|
|
418
|
+
const pkg = path.slice(2).join("/");
|
|
419
|
+
const result = await stats(registry, pkg, options);
|
|
420
|
+
if (!result) {
|
|
421
|
+
error(res, `Package "${pkg}" not found on ${registry}`, 404);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
json(res, result);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (path[0] === "compare" && path.length >= 2) {
|
|
429
|
+
const pkg = decodeURIComponent(path[1]);
|
|
430
|
+
const registries = query.registries ? query.registries.split(",") : void 0;
|
|
431
|
+
const result = await stats.compare(pkg, registries, options);
|
|
432
|
+
json(res, result);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (path[0] === "range" && path.length >= 3) {
|
|
436
|
+
const registry = path[1];
|
|
437
|
+
const pkg = path.slice(2).join("/");
|
|
438
|
+
const { start, end, format } = query;
|
|
439
|
+
if (!start || !end) {
|
|
440
|
+
error(res, "Missing start and end query parameters");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const data = await stats.range(registry, pkg, start, end, options);
|
|
444
|
+
if (format === "csv") {
|
|
445
|
+
res.writeHead(200, {
|
|
446
|
+
"Content-Type": "text/csv",
|
|
447
|
+
"Content-Disposition": `attachment; filename="${pkg}-${start}-${end}.csv"`
|
|
448
|
+
});
|
|
449
|
+
res.end(calc.toCSV(data));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (format === "chart") {
|
|
453
|
+
json(res, calc.toChartData(data, `${pkg} (${registry})`));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
json(res, data);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (path.length === 0) {
|
|
460
|
+
json(res, {
|
|
461
|
+
name: "@mcptoolshop/registry-stats",
|
|
462
|
+
endpoints: [
|
|
463
|
+
"GET /stats/:package",
|
|
464
|
+
"GET /stats/:registry/:package",
|
|
465
|
+
"GET /compare/:package?registries=npm,pypi",
|
|
466
|
+
"GET /range/:registry/:package?start=YYYY-MM-DD&end=YYYY-MM-DD&format=json|csv|chart"
|
|
467
|
+
]
|
|
468
|
+
});
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
error(res, "Not found", 404);
|
|
472
|
+
} catch (e) {
|
|
473
|
+
error(res, e.message, 500);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function serve(opts) {
|
|
478
|
+
const port = opts?.port ?? 3e3;
|
|
479
|
+
const handler = createHandler({
|
|
480
|
+
cache: opts?.cache !== false ? createCache() : void 0
|
|
481
|
+
});
|
|
482
|
+
const server = httpCreateServer(handler);
|
|
483
|
+
server.listen(port, () => {
|
|
484
|
+
console.log(`registry-stats server listening on http://localhost:${port}`);
|
|
485
|
+
console.log(`
|
|
486
|
+
Endpoints:`);
|
|
487
|
+
console.log(` GET /stats/:package`);
|
|
488
|
+
console.log(` GET /stats/:registry/:package`);
|
|
489
|
+
console.log(` GET /compare/:package?registries=npm,pypi`);
|
|
490
|
+
console.log(` GET /range/:registry/:package?start=...&end=...&format=json|csv|chart`);
|
|
491
|
+
});
|
|
492
|
+
return server;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/index.ts
|
|
496
|
+
function createCache() {
|
|
497
|
+
const store = /* @__PURE__ */ new Map();
|
|
498
|
+
return {
|
|
499
|
+
get(key) {
|
|
500
|
+
const entry = store.get(key);
|
|
501
|
+
if (!entry) return void 0;
|
|
502
|
+
if (Date.now() > entry.expiresAt) {
|
|
503
|
+
store.delete(key);
|
|
504
|
+
return void 0;
|
|
505
|
+
}
|
|
506
|
+
return entry.value;
|
|
507
|
+
},
|
|
508
|
+
set(key, value, ttlMs) {
|
|
509
|
+
store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function pLimit(concurrency) {
|
|
514
|
+
let active = 0;
|
|
515
|
+
const queue = [];
|
|
516
|
+
function next() {
|
|
517
|
+
if (queue.length > 0 && active < concurrency) {
|
|
518
|
+
active++;
|
|
519
|
+
queue.shift()();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return (fn) => new Promise((resolve3, reject) => {
|
|
523
|
+
queue.push(() => {
|
|
524
|
+
fn().then(resolve3, reject).finally(() => {
|
|
525
|
+
active--;
|
|
526
|
+
next();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
next();
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
var providers = {
|
|
533
|
+
npm,
|
|
534
|
+
pypi,
|
|
535
|
+
nuget,
|
|
536
|
+
vscode,
|
|
537
|
+
docker
|
|
538
|
+
};
|
|
539
|
+
var DEFAULT_TTL = 3e5;
|
|
540
|
+
async function stats(registry, pkg, options) {
|
|
541
|
+
const provider = providers[registry];
|
|
542
|
+
if (!provider) {
|
|
543
|
+
throw new RegistryError(registry, 0, `Unknown registry "${registry}". Use registerProvider() to add custom registries.`);
|
|
544
|
+
}
|
|
545
|
+
const cache = options?.cache;
|
|
546
|
+
if (cache) {
|
|
547
|
+
const key = `stats:${registry}:${pkg}`;
|
|
548
|
+
const cached = cache.get(key);
|
|
549
|
+
if (cached) return cached;
|
|
550
|
+
const result = await provider.getStats(pkg, options);
|
|
551
|
+
if (result) cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
return provider.getStats(pkg, options);
|
|
555
|
+
}
|
|
556
|
+
stats.all = async function all(pkg, options) {
|
|
557
|
+
const results = await Promise.allSettled(
|
|
558
|
+
Object.values(providers).map((p) => p.getStats(pkg, options))
|
|
559
|
+
);
|
|
560
|
+
return results.filter(
|
|
561
|
+
(r) => r.status === "fulfilled" && r.value !== null
|
|
562
|
+
).map((r) => r.value);
|
|
563
|
+
};
|
|
564
|
+
stats.bulk = async function bulk(registry, packages, options) {
|
|
565
|
+
const provider = providers[registry];
|
|
566
|
+
if (!provider) {
|
|
567
|
+
throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
|
|
568
|
+
}
|
|
569
|
+
const concurrency = options?.concurrency ?? 5;
|
|
570
|
+
const limit = pLimit(concurrency);
|
|
571
|
+
return Promise.all(packages.map((pkg) => limit(() => stats(registry, pkg, options))));
|
|
572
|
+
};
|
|
573
|
+
stats.range = async function range(registry, pkg, start, end, options) {
|
|
574
|
+
const provider = providers[registry];
|
|
575
|
+
if (!provider) {
|
|
576
|
+
throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
|
|
577
|
+
}
|
|
578
|
+
if (!provider.getRange) {
|
|
579
|
+
throw new RegistryError(
|
|
580
|
+
registry,
|
|
581
|
+
0,
|
|
582
|
+
`${registry} does not support time-series data. Only npm and pypi support getRange().`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const cache = options?.cache;
|
|
586
|
+
if (cache) {
|
|
587
|
+
const key = `range:${registry}:${pkg}:${start}:${end}`;
|
|
588
|
+
const cached = cache.get(key);
|
|
589
|
+
if (cached) return cached;
|
|
590
|
+
const result = await provider.getRange(pkg, start, end);
|
|
591
|
+
cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
return provider.getRange(pkg, start, end);
|
|
595
|
+
};
|
|
596
|
+
stats.compare = async function compare(pkg, registries, options) {
|
|
597
|
+
const regs = registries ?? Object.keys(providers);
|
|
598
|
+
const results = await Promise.allSettled(
|
|
599
|
+
regs.map(async (reg) => {
|
|
600
|
+
const result = await stats(reg, pkg, options);
|
|
601
|
+
return result ? { reg, result } : null;
|
|
602
|
+
})
|
|
603
|
+
);
|
|
604
|
+
const registryMap = {};
|
|
605
|
+
for (const r of results) {
|
|
606
|
+
if (r.status === "fulfilled" && r.value) {
|
|
607
|
+
registryMap[r.value.reg] = r.value.result;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
package: pkg,
|
|
612
|
+
registries: registryMap,
|
|
613
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// src/cli.ts
|
|
618
|
+
function usage() {
|
|
619
|
+
console.log(`
|
|
620
|
+
Usage: registry-stats [package] [options]
|
|
621
|
+
registry-stats serve [--port 3000]
|
|
622
|
+
|
|
623
|
+
If no package is given, reads from registry-stats.config.json.
|
|
624
|
+
|
|
625
|
+
Options:
|
|
626
|
+
--registry, -r Registry to query (npm, pypi, nuget, vscode, docker)
|
|
627
|
+
Omit to query all registries
|
|
628
|
+
--range Date range for time series (e.g. 2025-01-01:2025-06-30)
|
|
629
|
+
Only npm and pypi support this
|
|
630
|
+
--compare Compare package across registries side-by-side
|
|
631
|
+
--format Output format: table (default), json, csv, chart
|
|
632
|
+
--init Create a starter registry-stats.config.json
|
|
633
|
+
--help, -h Show this help
|
|
634
|
+
|
|
635
|
+
Subcommands:
|
|
636
|
+
serve Start a REST API server
|
|
637
|
+
--port Port to listen on (default: 3000)
|
|
638
|
+
|
|
639
|
+
Examples:
|
|
640
|
+
registry-stats express
|
|
641
|
+
registry-stats express -r npm
|
|
642
|
+
registry-stats express --compare
|
|
643
|
+
registry-stats express -r npm --range 2025-01-01:2025-06-30 --format csv
|
|
644
|
+
registry-stats serve --port 8080
|
|
645
|
+
registry-stats --init
|
|
646
|
+
registry-stats # fetches all packages from config
|
|
647
|
+
`);
|
|
648
|
+
}
|
|
649
|
+
function formatNumber(n) {
|
|
650
|
+
if (n === void 0) return "-";
|
|
651
|
+
return n.toLocaleString("en-US");
|
|
652
|
+
}
|
|
653
|
+
function printStats(s) {
|
|
654
|
+
const d = s.downloads;
|
|
655
|
+
const parts = [
|
|
656
|
+
` ${s.registry.padEnd(7)} | ${s.package}`
|
|
657
|
+
];
|
|
658
|
+
const metrics = [];
|
|
659
|
+
if (d.total !== void 0) metrics.push(`total: ${formatNumber(d.total)}`);
|
|
660
|
+
if (d.lastMonth !== void 0) metrics.push(`month: ${formatNumber(d.lastMonth)}`);
|
|
661
|
+
if (d.lastWeek !== void 0) metrics.push(`week: ${formatNumber(d.lastWeek)}`);
|
|
662
|
+
if (d.lastDay !== void 0) metrics.push(`day: ${formatNumber(d.lastDay)}`);
|
|
663
|
+
if (metrics.length > 0) {
|
|
664
|
+
parts.push(` ${metrics.join(" ")}`);
|
|
665
|
+
}
|
|
666
|
+
if (s.extra) {
|
|
667
|
+
const extras = [];
|
|
668
|
+
if (s.extra.stars !== void 0) extras.push(`stars: ${formatNumber(s.extra.stars)}`);
|
|
669
|
+
if (s.extra.rating !== void 0) extras.push(`rating: ${s.extra.rating.toFixed(1)}`);
|
|
670
|
+
if (s.extra.version !== void 0) extras.push(`v${s.extra.version}`);
|
|
671
|
+
if (extras.length > 0) {
|
|
672
|
+
parts.push(` ${extras.join(" ")}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
console.log(parts.join("\n"));
|
|
676
|
+
}
|
|
677
|
+
function printComparison(result) {
|
|
678
|
+
const regs = Object.entries(result.registries);
|
|
679
|
+
if (regs.length === 0) {
|
|
680
|
+
console.error(`Package "${result.package}" not found on any registry`);
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
console.log(`
|
|
684
|
+
${result.package} \u2014 comparison
|
|
685
|
+
`);
|
|
686
|
+
const cols = regs.map(([r]) => r.padEnd(12));
|
|
687
|
+
console.log(` ${"Metric".padEnd(14)}${cols.join("")}`);
|
|
688
|
+
console.log(` ${"\u2500".repeat(14 + cols.length * 12)}`);
|
|
689
|
+
const metrics = ["total", "lastMonth", "lastWeek", "lastDay"];
|
|
690
|
+
const labels = {
|
|
691
|
+
total: "Total",
|
|
692
|
+
lastMonth: "Month",
|
|
693
|
+
lastWeek: "Week",
|
|
694
|
+
lastDay: "Day"
|
|
695
|
+
};
|
|
696
|
+
for (const m of metrics) {
|
|
697
|
+
const values = regs.map(([, s]) => {
|
|
698
|
+
const v = s.downloads[m];
|
|
699
|
+
return (v !== void 0 ? formatNumber(v) : "-").padEnd(12);
|
|
700
|
+
});
|
|
701
|
+
console.log(` ${labels[m].padEnd(14)}${values.join("")}`);
|
|
702
|
+
}
|
|
703
|
+
console.log();
|
|
704
|
+
}
|
|
705
|
+
function buildOptions(config) {
|
|
706
|
+
const opts = {};
|
|
707
|
+
if (!config) return opts;
|
|
708
|
+
if (config.cache !== false) {
|
|
709
|
+
opts.cache = createCache();
|
|
710
|
+
opts.cacheTtlMs = config.cacheTtlMs;
|
|
711
|
+
}
|
|
712
|
+
if (config.concurrency) opts.concurrency = config.concurrency;
|
|
713
|
+
if (config.dockerToken) opts.dockerToken = config.dockerToken;
|
|
714
|
+
return opts;
|
|
715
|
+
}
|
|
716
|
+
async function runConfigPackages(config, format) {
|
|
717
|
+
const packages = config.packages;
|
|
718
|
+
if (!packages || Object.keys(packages).length === 0) {
|
|
719
|
+
console.error("No packages defined in config. Add packages to registry-stats.config.json.");
|
|
720
|
+
process.exit(1);
|
|
721
|
+
}
|
|
722
|
+
const opts = buildOptions(config);
|
|
723
|
+
const allResults = {};
|
|
724
|
+
for (const [displayName, registryMap] of Object.entries(packages)) {
|
|
725
|
+
const results = [];
|
|
726
|
+
const fetches = Object.entries(registryMap).map(async ([registry, pkgId]) => {
|
|
727
|
+
try {
|
|
728
|
+
const result = await stats(registry, pkgId, opts);
|
|
729
|
+
if (result) results.push(result);
|
|
730
|
+
} catch {
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
await Promise.all(fetches);
|
|
734
|
+
if (results.length > 0) allResults[displayName] = results;
|
|
735
|
+
}
|
|
736
|
+
if (format === "json") {
|
|
737
|
+
console.log(JSON.stringify(allResults, null, 2));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (Object.keys(allResults).length === 0) {
|
|
741
|
+
console.error("No results found for any configured packages.");
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
for (const [displayName, results] of Object.entries(allResults)) {
|
|
745
|
+
console.log(`
|
|
746
|
+
${displayName}`);
|
|
747
|
+
console.log(` ${"\u2500".repeat(displayName.length)}`);
|
|
748
|
+
for (const r of results) {
|
|
749
|
+
printStats(r);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
console.log();
|
|
753
|
+
}
|
|
754
|
+
async function main() {
|
|
755
|
+
const args = process.argv.slice(2);
|
|
756
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
757
|
+
usage();
|
|
758
|
+
process.exit(0);
|
|
759
|
+
}
|
|
760
|
+
if (args.includes("--init")) {
|
|
761
|
+
const configPath = resolve2(process.cwd(), "registry-stats.config.json");
|
|
762
|
+
if (existsSync2(configPath)) {
|
|
763
|
+
console.error("registry-stats.config.json already exists.");
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
writeFileSync(configPath, starterConfig(), "utf-8");
|
|
767
|
+
console.log("Created registry-stats.config.json");
|
|
768
|
+
process.exit(0);
|
|
769
|
+
}
|
|
770
|
+
if (args[0] === "serve") {
|
|
771
|
+
let port = 3e3;
|
|
772
|
+
for (let i = 1; i < args.length; i++) {
|
|
773
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
774
|
+
port = parseInt(args[++i], 10);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
serve({ port });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
let pkg;
|
|
781
|
+
let registry;
|
|
782
|
+
let range2;
|
|
783
|
+
let format = "table";
|
|
784
|
+
let compare2 = false;
|
|
785
|
+
for (let i = 0; i < args.length; i++) {
|
|
786
|
+
if ((args[i] === "--registry" || args[i] === "-r") && args[i + 1]) {
|
|
787
|
+
registry = args[++i];
|
|
788
|
+
} else if (args[i] === "--range" && args[i + 1]) {
|
|
789
|
+
range2 = args[++i];
|
|
790
|
+
} else if (args[i] === "--format" && args[i + 1]) {
|
|
791
|
+
format = args[++i];
|
|
792
|
+
} else if (args[i] === "--json") {
|
|
793
|
+
format = "json";
|
|
794
|
+
} else if (args[i] === "--compare") {
|
|
795
|
+
compare2 = true;
|
|
796
|
+
} else if (!args[i].startsWith("-") && !pkg) {
|
|
797
|
+
pkg = args[i];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const config = loadConfig();
|
|
801
|
+
if (!pkg) {
|
|
802
|
+
if (!config) {
|
|
803
|
+
usage();
|
|
804
|
+
process.exit(0);
|
|
805
|
+
}
|
|
806
|
+
await runConfigPackages(config, format);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const opts = buildOptions(config);
|
|
810
|
+
try {
|
|
811
|
+
if (compare2) {
|
|
812
|
+
const registries = registry ? [registry] : void 0;
|
|
813
|
+
const result = await stats.compare(pkg, registries, opts);
|
|
814
|
+
if (format === "json") {
|
|
815
|
+
console.log(JSON.stringify(result, null, 2));
|
|
816
|
+
} else {
|
|
817
|
+
printComparison(result);
|
|
818
|
+
}
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (range2) {
|
|
822
|
+
const reg = registry ?? "npm";
|
|
823
|
+
const [start, end] = range2.split(":");
|
|
824
|
+
if (!start || !end) {
|
|
825
|
+
console.error("Error: --range must be start:end (e.g. 2025-01-01:2025-06-30)");
|
|
826
|
+
process.exit(1);
|
|
827
|
+
}
|
|
828
|
+
const data = await stats.range(reg, pkg, start, end, opts);
|
|
829
|
+
if (format === "json") {
|
|
830
|
+
console.log(JSON.stringify(data, null, 2));
|
|
831
|
+
} else if (format === "csv") {
|
|
832
|
+
console.log(calc.toCSV(data));
|
|
833
|
+
} else if (format === "chart") {
|
|
834
|
+
console.log(JSON.stringify(calc.toChartData(data, `${pkg} (${reg})`), null, 2));
|
|
835
|
+
} else {
|
|
836
|
+
const monthly = calc.groupTotals(calc.monthly(data));
|
|
837
|
+
const t = calc.trend(data);
|
|
838
|
+
console.log(`
|
|
839
|
+
${pkg} (${reg}) \u2014 ${start} to ${end}
|
|
840
|
+
`);
|
|
841
|
+
for (const [month, total] of Object.entries(monthly)) {
|
|
842
|
+
console.log(` ${month} ${formatNumber(total)}`);
|
|
843
|
+
}
|
|
844
|
+
console.log(`
|
|
845
|
+
Total: ${formatNumber(calc.total(data))} Avg/day: ${formatNumber(Math.round(calc.avg(data)))} Trend: ${t.direction} (${t.changePercent > 0 ? "+" : ""}${t.changePercent}%)`);
|
|
846
|
+
}
|
|
847
|
+
} else if (registry) {
|
|
848
|
+
const result = await stats(registry, pkg, opts);
|
|
849
|
+
if (!result) {
|
|
850
|
+
console.error(`Package "${pkg}" not found on ${registry}`);
|
|
851
|
+
process.exit(1);
|
|
852
|
+
}
|
|
853
|
+
if (format === "json") {
|
|
854
|
+
console.log(JSON.stringify(result, null, 2));
|
|
855
|
+
} else {
|
|
856
|
+
console.log();
|
|
857
|
+
printStats(result);
|
|
858
|
+
}
|
|
859
|
+
} else {
|
|
860
|
+
const results = await stats.all(pkg, opts);
|
|
861
|
+
if (results.length === 0) {
|
|
862
|
+
console.error(`Package "${pkg}" not found on any registry`);
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}
|
|
865
|
+
if (format === "json") {
|
|
866
|
+
console.log(JSON.stringify(results, null, 2));
|
|
867
|
+
} else {
|
|
868
|
+
console.log();
|
|
869
|
+
for (const r of results) {
|
|
870
|
+
printStats(r);
|
|
871
|
+
console.log();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
} catch (e) {
|
|
876
|
+
console.error(`Error: ${e.message}`);
|
|
877
|
+
process.exit(1);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
main();
|