@mcptoolshop/registry-stats 0.3.0 → 0.4.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/README.es.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> &middot;
16
17
  <a href="#instalación">Instalación</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#archivo-de-configuración">Configuración</a> &middot;
package/README.fr.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> &middot;
16
17
  <a href="#installation">Installation</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#fichier-de-configuration">Configuration</a> &middot;
package/README.hi.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">डॉक्स</a> &middot;
16
17
  <a href="#इंस्टॉल">इंस्टॉल</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#कॉन्फ़िग-फ़ाइल">कॉन्फ़िग</a> &middot;
package/README.it.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> &middot;
16
17
  <a href="#installazione">Installazione</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#file-di-configurazione">Configurazione</a> &middot;
package/README.ja.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">ドキュメント</a> &middot;
16
17
  <a href="#インストール">インストール</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#設定ファイル">設定</a> &middot;
package/README.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> &middot;
16
17
  <a href="#install">Install</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#config-file">Config</a> &middot;
@@ -234,6 +235,14 @@ registerProvider(cargo);
234
235
  await stats('cargo', 'serde');
235
236
  ```
236
237
 
238
+ ## Website
239
+
240
+ Docs / landing page lives in `site/`.
241
+
242
+ - Dev: `npm run site:dev`
243
+ - Build: `npm run site:build`
244
+ - Preview: `npm run site:preview`
245
+
237
246
  ## License
238
247
 
239
248
  MIT
package/README.pt-BR.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">Docs</a> &middot;
16
17
  <a href="#instalação">Instalação</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#arquivo-de-configuração">Configuração</a> &middot;
package/README.zh.md CHANGED
@@ -13,6 +13,7 @@
13
13
  </p>
14
14
 
15
15
  <p align="center">
16
+ <a href="https://mcp-tool-shop-org.github.io/registry-stats/">文档</a> &middot;
16
17
  <a href="#安装">安装</a> &middot;
17
18
  <a href="#cli">CLI</a> &middot;
18
19
  <a href="#配置文件">配置</a> &middot;
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();
package/dist/index.cjs CHANGED
@@ -48,22 +48,64 @@ var RegistryError = class extends Error {
48
48
  var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
49
49
  var MAX_RETRIES = 3;
50
50
  var BASE_DELAY = 1e3;
51
+ var registryLocks = /* @__PURE__ */ new Map();
52
+ var REGISTRY_DELAYS = {
53
+ npm: 400,
54
+ // ~2.5 req/s — safe for 54+ scoped packages
55
+ pypi: 2200,
56
+ // 30 req/60s = 1 per 2s, with headroom
57
+ docker: 4e3
58
+ // 10 req/3600s — very tight
59
+ };
60
+ var DEFAULT_DELAY = 100;
61
+ function acquireSlot(registry) {
62
+ const minDelay = REGISTRY_DELAYS[registry] ?? DEFAULT_DELAY;
63
+ const prev = registryLocks.get(registry) ?? Promise.resolve();
64
+ const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
65
+ registryLocks.set(registry, slot);
66
+ return prev;
67
+ }
51
68
  async function fetchWithRetry(url, registry, init) {
69
+ let lastError;
70
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
71
+ await acquireSlot(registry);
72
+ const res = await fetch(url, init);
73
+ if (res.status === 404) return null;
74
+ if (res.ok) return res.json();
75
+ const retryAfter = res.headers.get("retry-after");
76
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
77
+ lastError = new RegistryError(
78
+ registry,
79
+ res.status,
80
+ `${res.statusText}: ${url}`,
81
+ retryAfterSeconds
82
+ );
83
+ if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
84
+ const backoff = BASE_DELAY * Math.pow(2, attempt);
85
+ const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
86
+ const delay = Math.max(backoff, retryAfterMs);
87
+ await new Promise((r) => setTimeout(r, delay));
88
+ }
89
+ throw lastError;
90
+ }
91
+ async function fetchDirect(url, registry, init) {
52
92
  let lastError;
53
93
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
54
94
  const res = await fetch(url, init);
55
95
  if (res.status === 404) return null;
56
96
  if (res.ok) return res.json();
57
97
  const retryAfter = res.headers.get("retry-after");
58
- const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : void 0;
98
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
59
99
  lastError = new RegistryError(
60
100
  registry,
61
101
  res.status,
62
102
  `${res.statusText}: ${url}`,
63
- retryAfter ? parseInt(retryAfter, 10) : void 0
103
+ retryAfterSeconds
64
104
  );
65
105
  if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
66
- const delay = retryAfterMs ?? BASE_DELAY * Math.pow(2, attempt);
106
+ const backoff = BASE_DELAY * Math.pow(2, attempt);
107
+ const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
108
+ const delay = Math.max(backoff, retryAfterMs);
67
109
  await new Promise((r) => setTimeout(r, delay));
68
110
  }
69
111
  throw lastError;
@@ -74,20 +116,22 @@ var API = "https://api.npmjs.org/downloads";
74
116
  var npm = {
75
117
  name: "npm",
76
118
  async getStats(pkg) {
77
- const [day, week, month] = await Promise.all([
78
- fetchWithRetry(`${API}/point/last-day/${pkg}`, "npm"),
79
- fetchWithRetry(`${API}/point/last-week/${pkg}`, "npm"),
80
- fetchWithRetry(`${API}/point/last-month/${pkg}`, "npm")
81
- ]);
82
- if (!day && !week && !month) return null;
119
+ const end = /* @__PURE__ */ new Date();
120
+ const start = new Date(end);
121
+ start.setDate(start.getDate() - 30);
122
+ const data = await fetchWithRetry(
123
+ `${API}/range/${fmt(start)}:${fmt(end)}/${pkg}`,
124
+ "npm"
125
+ );
126
+ if (!data || !data.downloads || data.downloads.length === 0) return null;
127
+ const days = data.downloads;
128
+ const lastDay = days[days.length - 1]?.downloads ?? 0;
129
+ const lastWeek = days.slice(-7).reduce((s, d) => s + d.downloads, 0);
130
+ const lastMonth = days.reduce((s, d) => s + d.downloads, 0);
83
131
  return {
84
132
  registry: "npm",
85
133
  package: pkg,
86
- downloads: {
87
- lastDay: day?.downloads,
88
- lastWeek: week?.downloads,
89
- lastMonth: month?.downloads
90
- },
134
+ downloads: { lastDay, lastWeek, lastMonth },
91
135
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
92
136
  };
93
137
  },
@@ -115,6 +159,27 @@ var npm = {
115
159
  return chunks;
116
160
  }
117
161
  };
162
+ async function npmBulkPoint(packages, period = "last-month") {
163
+ const result = /* @__PURE__ */ new Map();
164
+ if (packages.length === 0) return result;
165
+ const BATCH_SIZE = 128;
166
+ for (let i = 0; i < packages.length; i += BATCH_SIZE) {
167
+ const batch = packages.slice(i, i + BATCH_SIZE);
168
+ const joined = batch.join(",");
169
+ const data = await fetchDirect(
170
+ `${API}/point/${period}/${joined}`,
171
+ "npm"
172
+ );
173
+ if (data) {
174
+ for (const [name, entry] of Object.entries(data)) {
175
+ if (entry && typeof entry.downloads === "number") {
176
+ result.set(name, entry.downloads);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ return result;
182
+ }
118
183
  function fmt(d) {
119
184
  return d.toISOString().slice(0, 10);
120
185
  }
@@ -508,7 +573,12 @@ function createHandler(opts) {
508
573
  }
509
574
  error(res, "Not found", 404);
510
575
  } catch (e) {
511
- error(res, e.message, 500);
576
+ if (e instanceof RegistryError) {
577
+ const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
578
+ error(res, e.message, status);
579
+ } else {
580
+ error(res, e.message, 500);
581
+ }
512
582
  }
513
583
  };
514
584
  }
@@ -607,10 +677,54 @@ stats.bulk = async function bulk(registry, packages, options) {
607
677
  if (!provider) {
608
678
  throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
609
679
  }
680
+ if (registry === "npm" && packages.length > 1) {
681
+ return npmBulkStats(packages, options);
682
+ }
610
683
  const concurrency = options?.concurrency ?? 5;
611
684
  const limit = pLimit(concurrency);
612
685
  return Promise.all(packages.map((pkg) => limit(() => stats(registry, pkg, options))));
613
686
  };
687
+ async function npmBulkStats(packages, options) {
688
+ const scoped = [];
689
+ const unscoped = [];
690
+ for (const pkg of packages) {
691
+ if (pkg.startsWith("@")) {
692
+ scoped.push(pkg);
693
+ } else {
694
+ unscoped.push(pkg);
695
+ }
696
+ }
697
+ const bulkMonth = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-month") : /* @__PURE__ */ new Map();
698
+ const bulkWeek = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-week") : /* @__PURE__ */ new Map();
699
+ const bulkDay = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-day") : /* @__PURE__ */ new Map();
700
+ const unscopedResults = /* @__PURE__ */ new Map();
701
+ for (const pkg of unscoped) {
702
+ const month = bulkMonth.get(pkg);
703
+ const week = bulkWeek.get(pkg);
704
+ const day = bulkDay.get(pkg);
705
+ if (month === void 0 && week === void 0 && day === void 0) {
706
+ unscopedResults.set(pkg, null);
707
+ } else {
708
+ unscopedResults.set(pkg, {
709
+ registry: "npm",
710
+ package: pkg,
711
+ downloads: {
712
+ lastDay: day,
713
+ lastWeek: week,
714
+ lastMonth: month
715
+ },
716
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
717
+ });
718
+ }
719
+ }
720
+ const limit = pLimit(1);
721
+ const scopedResults = await Promise.all(
722
+ scoped.map((pkg) => limit(() => stats("npm", pkg, options)))
723
+ );
724
+ const scopedMap = /* @__PURE__ */ new Map();
725
+ scoped.forEach((pkg, i) => scopedMap.set(pkg, scopedResults[i]));
726
+ return packages.map((pkg) => unscopedResults.get(pkg) ?? scopedMap.get(pkg) ?? null);
727
+ }
614
728
  stats.range = async function range(registry, pkg, start, end, options) {
615
729
  const provider = providers[registry];
616
730
  if (!provider) {
@@ -654,6 +768,31 @@ stats.compare = async function compare(pkg, registries, options) {
654
768
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
655
769
  };
656
770
  };
771
+ stats.mine = async function mine(maintainer, options) {
772
+ const packages = [];
773
+ const PAGE_SIZE = 250;
774
+ let offset = 0;
775
+ while (true) {
776
+ const url = `https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(maintainer)}&size=${PAGE_SIZE}&from=${offset}`;
777
+ const data = await fetchDirect(url, "npm");
778
+ if (!data || data.objects.length === 0) break;
779
+ for (const obj of data.objects) {
780
+ packages.push(obj.package.name);
781
+ }
782
+ offset += data.objects.length;
783
+ if (offset >= data.total) break;
784
+ }
785
+ if (packages.length === 0) return [];
786
+ const results = [];
787
+ const bulkResults = await stats.bulk("npm", packages, options);
788
+ for (let i = 0; i < packages.length; i++) {
789
+ const r = bulkResults[i];
790
+ if (r) results.push(r);
791
+ options?.onProgress?.(i + 1, packages.length, packages[i]);
792
+ }
793
+ results.sort((a, b) => (b.downloads.lastMonth ?? 0) - (a.downloads.lastMonth ?? 0));
794
+ return results;
795
+ };
657
796
  // Annotate the CommonJS export names for ESM import in node:
658
797
  0 && (module.exports = {
659
798
  RegistryError,
package/dist/index.d.cts CHANGED
@@ -125,6 +125,9 @@ declare namespace stats {
125
125
  var bulk: (registry: string, packages: string[], options?: StatsOptions) => Promise<(PackageStats | null)[]>;
126
126
  var range: (registry: string, pkg: string, start: string, end: string, options?: StatsOptions) => Promise<DailyDownloads[]>;
127
127
  var compare: (pkg: string, registries?: string[], options?: StatsOptions) => Promise<ComparisonResult>;
128
+ var mine: (maintainer: string, options?: StatsOptions & {
129
+ onProgress?: (done: number, total: number, pkg: string) => void;
130
+ }) => Promise<PackageStats[]>;
128
131
  }
129
132
 
130
133
  export { type ChartData, type ComparisonResult, type Config, type DailyDownloads, type PackageConfig, type PackageStats, type RateLimitConfig, RegistryError, type RegistryName, type RegistryProvider, type ServerOptions, type StatsCache, type StatsOptions, calc, createCache, createHandler, defaultConfig, loadConfig, registerProvider, serve, starterConfig, stats };
package/dist/index.d.ts CHANGED
@@ -125,6 +125,9 @@ declare namespace stats {
125
125
  var bulk: (registry: string, packages: string[], options?: StatsOptions) => Promise<(PackageStats | null)[]>;
126
126
  var range: (registry: string, pkg: string, start: string, end: string, options?: StatsOptions) => Promise<DailyDownloads[]>;
127
127
  var compare: (pkg: string, registries?: string[], options?: StatsOptions) => Promise<ComparisonResult>;
128
+ var mine: (maintainer: string, options?: StatsOptions & {
129
+ onProgress?: (done: number, total: number, pkg: string) => void;
130
+ }) => Promise<PackageStats[]>;
128
131
  }
129
132
 
130
133
  export { type ChartData, type ComparisonResult, type Config, type DailyDownloads, type PackageConfig, type PackageStats, type RateLimitConfig, RegistryError, type RegistryName, type RegistryProvider, type ServerOptions, type StatsCache, type StatsOptions, calc, createCache, createHandler, defaultConfig, loadConfig, registerProvider, serve, starterConfig, stats };
package/dist/index.js CHANGED
@@ -13,22 +13,64 @@ var RegistryError = class extends Error {
13
13
  var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
14
14
  var MAX_RETRIES = 3;
15
15
  var BASE_DELAY = 1e3;
16
+ var registryLocks = /* @__PURE__ */ new Map();
17
+ var REGISTRY_DELAYS = {
18
+ npm: 400,
19
+ // ~2.5 req/s — safe for 54+ scoped packages
20
+ pypi: 2200,
21
+ // 30 req/60s = 1 per 2s, with headroom
22
+ docker: 4e3
23
+ // 10 req/3600s — very tight
24
+ };
25
+ var DEFAULT_DELAY = 100;
26
+ function acquireSlot(registry) {
27
+ const minDelay = REGISTRY_DELAYS[registry] ?? DEFAULT_DELAY;
28
+ const prev = registryLocks.get(registry) ?? Promise.resolve();
29
+ const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
30
+ registryLocks.set(registry, slot);
31
+ return prev;
32
+ }
16
33
  async function fetchWithRetry(url, registry, init) {
34
+ let lastError;
35
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
36
+ await acquireSlot(registry);
37
+ const res = await fetch(url, init);
38
+ if (res.status === 404) return null;
39
+ if (res.ok) return res.json();
40
+ const retryAfter = res.headers.get("retry-after");
41
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
42
+ lastError = new RegistryError(
43
+ registry,
44
+ res.status,
45
+ `${res.statusText}: ${url}`,
46
+ retryAfterSeconds
47
+ );
48
+ if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
49
+ const backoff = BASE_DELAY * Math.pow(2, attempt);
50
+ const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
51
+ const delay = Math.max(backoff, retryAfterMs);
52
+ await new Promise((r) => setTimeout(r, delay));
53
+ }
54
+ throw lastError;
55
+ }
56
+ async function fetchDirect(url, registry, init) {
17
57
  let lastError;
18
58
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
19
59
  const res = await fetch(url, init);
20
60
  if (res.status === 404) return null;
21
61
  if (res.ok) return res.json();
22
62
  const retryAfter = res.headers.get("retry-after");
23
- const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : void 0;
63
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
24
64
  lastError = new RegistryError(
25
65
  registry,
26
66
  res.status,
27
67
  `${res.statusText}: ${url}`,
28
- retryAfter ? parseInt(retryAfter, 10) : void 0
68
+ retryAfterSeconds
29
69
  );
30
70
  if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
31
- const delay = retryAfterMs ?? BASE_DELAY * Math.pow(2, attempt);
71
+ const backoff = BASE_DELAY * Math.pow(2, attempt);
72
+ const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
73
+ const delay = Math.max(backoff, retryAfterMs);
32
74
  await new Promise((r) => setTimeout(r, delay));
33
75
  }
34
76
  throw lastError;
@@ -39,20 +81,22 @@ var API = "https://api.npmjs.org/downloads";
39
81
  var npm = {
40
82
  name: "npm",
41
83
  async getStats(pkg) {
42
- const [day, week, month] = await Promise.all([
43
- fetchWithRetry(`${API}/point/last-day/${pkg}`, "npm"),
44
- fetchWithRetry(`${API}/point/last-week/${pkg}`, "npm"),
45
- fetchWithRetry(`${API}/point/last-month/${pkg}`, "npm")
46
- ]);
47
- if (!day && !week && !month) return null;
84
+ const end = /* @__PURE__ */ new Date();
85
+ const start = new Date(end);
86
+ start.setDate(start.getDate() - 30);
87
+ const data = await fetchWithRetry(
88
+ `${API}/range/${fmt(start)}:${fmt(end)}/${pkg}`,
89
+ "npm"
90
+ );
91
+ if (!data || !data.downloads || data.downloads.length === 0) return null;
92
+ const days = data.downloads;
93
+ const lastDay = days[days.length - 1]?.downloads ?? 0;
94
+ const lastWeek = days.slice(-7).reduce((s, d) => s + d.downloads, 0);
95
+ const lastMonth = days.reduce((s, d) => s + d.downloads, 0);
48
96
  return {
49
97
  registry: "npm",
50
98
  package: pkg,
51
- downloads: {
52
- lastDay: day?.downloads,
53
- lastWeek: week?.downloads,
54
- lastMonth: month?.downloads
55
- },
99
+ downloads: { lastDay, lastWeek, lastMonth },
56
100
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
57
101
  };
58
102
  },
@@ -80,6 +124,27 @@ var npm = {
80
124
  return chunks;
81
125
  }
82
126
  };
127
+ async function npmBulkPoint(packages, period = "last-month") {
128
+ const result = /* @__PURE__ */ new Map();
129
+ if (packages.length === 0) return result;
130
+ const BATCH_SIZE = 128;
131
+ for (let i = 0; i < packages.length; i += BATCH_SIZE) {
132
+ const batch = packages.slice(i, i + BATCH_SIZE);
133
+ const joined = batch.join(",");
134
+ const data = await fetchDirect(
135
+ `${API}/point/${period}/${joined}`,
136
+ "npm"
137
+ );
138
+ if (data) {
139
+ for (const [name, entry] of Object.entries(data)) {
140
+ if (entry && typeof entry.downloads === "number") {
141
+ result.set(name, entry.downloads);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return result;
147
+ }
83
148
  function fmt(d) {
84
149
  return d.toISOString().slice(0, 10);
85
150
  }
@@ -473,7 +538,12 @@ function createHandler(opts) {
473
538
  }
474
539
  error(res, "Not found", 404);
475
540
  } catch (e) {
476
- error(res, e.message, 500);
541
+ if (e instanceof RegistryError) {
542
+ const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
543
+ error(res, e.message, status);
544
+ } else {
545
+ error(res, e.message, 500);
546
+ }
477
547
  }
478
548
  };
479
549
  }
@@ -572,10 +642,54 @@ stats.bulk = async function bulk(registry, packages, options) {
572
642
  if (!provider) {
573
643
  throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
574
644
  }
645
+ if (registry === "npm" && packages.length > 1) {
646
+ return npmBulkStats(packages, options);
647
+ }
575
648
  const concurrency = options?.concurrency ?? 5;
576
649
  const limit = pLimit(concurrency);
577
650
  return Promise.all(packages.map((pkg) => limit(() => stats(registry, pkg, options))));
578
651
  };
652
+ async function npmBulkStats(packages, options) {
653
+ const scoped = [];
654
+ const unscoped = [];
655
+ for (const pkg of packages) {
656
+ if (pkg.startsWith("@")) {
657
+ scoped.push(pkg);
658
+ } else {
659
+ unscoped.push(pkg);
660
+ }
661
+ }
662
+ const bulkMonth = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-month") : /* @__PURE__ */ new Map();
663
+ const bulkWeek = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-week") : /* @__PURE__ */ new Map();
664
+ const bulkDay = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-day") : /* @__PURE__ */ new Map();
665
+ const unscopedResults = /* @__PURE__ */ new Map();
666
+ for (const pkg of unscoped) {
667
+ const month = bulkMonth.get(pkg);
668
+ const week = bulkWeek.get(pkg);
669
+ const day = bulkDay.get(pkg);
670
+ if (month === void 0 && week === void 0 && day === void 0) {
671
+ unscopedResults.set(pkg, null);
672
+ } else {
673
+ unscopedResults.set(pkg, {
674
+ registry: "npm",
675
+ package: pkg,
676
+ downloads: {
677
+ lastDay: day,
678
+ lastWeek: week,
679
+ lastMonth: month
680
+ },
681
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
682
+ });
683
+ }
684
+ }
685
+ const limit = pLimit(1);
686
+ const scopedResults = await Promise.all(
687
+ scoped.map((pkg) => limit(() => stats("npm", pkg, options)))
688
+ );
689
+ const scopedMap = /* @__PURE__ */ new Map();
690
+ scoped.forEach((pkg, i) => scopedMap.set(pkg, scopedResults[i]));
691
+ return packages.map((pkg) => unscopedResults.get(pkg) ?? scopedMap.get(pkg) ?? null);
692
+ }
579
693
  stats.range = async function range(registry, pkg, start, end, options) {
580
694
  const provider = providers[registry];
581
695
  if (!provider) {
@@ -619,6 +733,31 @@ stats.compare = async function compare(pkg, registries, options) {
619
733
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
620
734
  };
621
735
  };
736
+ stats.mine = async function mine(maintainer, options) {
737
+ const packages = [];
738
+ const PAGE_SIZE = 250;
739
+ let offset = 0;
740
+ while (true) {
741
+ const url = `https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(maintainer)}&size=${PAGE_SIZE}&from=${offset}`;
742
+ const data = await fetchDirect(url, "npm");
743
+ if (!data || data.objects.length === 0) break;
744
+ for (const obj of data.objects) {
745
+ packages.push(obj.package.name);
746
+ }
747
+ offset += data.objects.length;
748
+ if (offset >= data.total) break;
749
+ }
750
+ if (packages.length === 0) return [];
751
+ const results = [];
752
+ const bulkResults = await stats.bulk("npm", packages, options);
753
+ for (let i = 0; i < packages.length; i++) {
754
+ const r = bulkResults[i];
755
+ if (r) results.push(r);
756
+ options?.onProgress?.(i + 1, packages.length, packages[i]);
757
+ }
758
+ results.sort((a, b) => (b.downloads.lastMonth ?? 0) - (a.downloads.lastMonth ?? 0));
759
+ return results;
760
+ };
622
761
  export {
623
762
  RegistryError,
624
763
  calc,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/registry-stats",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-registry download stats for npm, PyPI, NuGet, VS Code Marketplace, and Docker Hub",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -31,7 +31,10 @@
31
31
  "build": "tsup",
32
32
  "test": "vitest run",
33
33
  "test:watch": "vitest",
34
- "prepublishOnly": "npm run build"
34
+ "prepublishOnly": "npm run build",
35
+ "site:dev": "npm --prefix site run dev",
36
+ "site:build": "npm --prefix site run build",
37
+ "site:preview": "npm --prefix site run preview"
35
38
  },
36
39
  "keywords": [
37
40
  "npm",
@@ -46,6 +49,7 @@
46
49
  ],
47
50
  "author": "mcp-tool-shop",
48
51
  "license": "MIT",
52
+ "homepage": "https://mcp-tool-shop-org.github.io/registry-stats/",
49
53
  "repository": {
50
54
  "type": "git",
51
55
  "url": "https://github.com/mcp-tool-shop-org/registry-stats.git"