@mcptoolshop/registry-stats 2.3.0 → 3.1.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 +137 -134
- package/README.fr.md +137 -134
- package/README.hi.md +137 -134
- package/README.it.md +137 -134
- package/README.ja.md +137 -134
- package/README.md +47 -5
- package/README.pt-BR.md +137 -134
- package/README.zh.md +137 -134
- package/dist/cli.js +4 -3
- package/dist/index.cjs +311 -3
- package/dist/index.d.cts +108 -1
- package/dist/index.d.ts +108 -1
- package/dist/index.js +304 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ async function fetchWithRetry(url, registry, init) {
|
|
|
34
34
|
let lastError;
|
|
35
35
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
36
36
|
await acquireSlot(registry);
|
|
37
|
-
const res = await fetch(url, init);
|
|
37
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
38
38
|
if (res.status === 404) return null;
|
|
39
39
|
if (res.ok) return res.json();
|
|
40
40
|
const retryAfter = res.headers.get("retry-after");
|
|
@@ -56,7 +56,7 @@ async function fetchWithRetry(url, registry, init) {
|
|
|
56
56
|
async function fetchDirect(url, registry, init) {
|
|
57
57
|
let lastError;
|
|
58
58
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
59
|
-
const res = await fetch(url, init);
|
|
59
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
60
60
|
if (res.status === 404) return null;
|
|
61
61
|
if (res.ok) return res.json();
|
|
62
62
|
const retryAfter = res.headers.get("retry-after");
|
|
@@ -278,7 +278,8 @@ var docker = {
|
|
|
278
278
|
if (options?.dockerToken) {
|
|
279
279
|
headers["Authorization"] = `Bearer ${options.dockerToken}`;
|
|
280
280
|
}
|
|
281
|
-
const
|
|
281
|
+
const safePkg = pkg.split("/").map((s) => encodeURIComponent(s)).join("/");
|
|
282
|
+
const json2 = await fetchWithRetry(`${API4}/${safePkg}`, "docker", { headers });
|
|
282
283
|
if (!json2 || !json2.name || !json2.namespace) return null;
|
|
283
284
|
return {
|
|
284
285
|
registry: "docker",
|
|
@@ -565,6 +566,299 @@ Endpoints:`);
|
|
|
565
566
|
return server;
|
|
566
567
|
}
|
|
567
568
|
|
|
569
|
+
// src/inference.ts
|
|
570
|
+
function mean(arr) {
|
|
571
|
+
if (arr.length === 0) return 0;
|
|
572
|
+
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
573
|
+
}
|
|
574
|
+
function stddev(arr) {
|
|
575
|
+
if (arr.length < 2) return 0;
|
|
576
|
+
const m = mean(arr);
|
|
577
|
+
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
|
|
578
|
+
return Math.sqrt(variance);
|
|
579
|
+
}
|
|
580
|
+
function linearRegression(ys) {
|
|
581
|
+
const n = ys.length;
|
|
582
|
+
if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, r2: 0 };
|
|
583
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
|
|
584
|
+
for (let i = 0; i < n; i++) {
|
|
585
|
+
sumX += i;
|
|
586
|
+
sumY += ys[i];
|
|
587
|
+
sumXY += i * ys[i];
|
|
588
|
+
sumX2 += i * i;
|
|
589
|
+
}
|
|
590
|
+
const denom = n * sumX2 - sumX * sumX;
|
|
591
|
+
if (denom === 0) return { slope: 0, intercept: sumY / n, r2: 0 };
|
|
592
|
+
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
593
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
594
|
+
const meanY = sumY / n;
|
|
595
|
+
let ssTot = 0, ssRes = 0;
|
|
596
|
+
for (let i = 0; i < n; i++) {
|
|
597
|
+
ssTot += (ys[i] - meanY) ** 2;
|
|
598
|
+
ssRes += (ys[i] - (intercept + slope * i)) ** 2;
|
|
599
|
+
}
|
|
600
|
+
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
|
601
|
+
return { slope, intercept, r2 };
|
|
602
|
+
}
|
|
603
|
+
var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
604
|
+
function forecast(series, days = 7) {
|
|
605
|
+
if (series.length < 7) return [];
|
|
606
|
+
const window = series.slice(-Math.min(14, series.length));
|
|
607
|
+
const n = window.length;
|
|
608
|
+
const weights = window.map((_, i) => Math.exp(0.1 * (i - n + 1)));
|
|
609
|
+
const totalW = weights.reduce((a, b) => a + b, 0);
|
|
610
|
+
let wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0;
|
|
611
|
+
for (let i = 0; i < n; i++) {
|
|
612
|
+
const w = weights[i];
|
|
613
|
+
wSumX += w * i;
|
|
614
|
+
wSumY += w * window[i];
|
|
615
|
+
wSumXY += w * i * window[i];
|
|
616
|
+
wSumX2 += w * i * i;
|
|
617
|
+
}
|
|
618
|
+
const denom = totalW * wSumX2 - wSumX * wSumX;
|
|
619
|
+
let slope, intercept;
|
|
620
|
+
if (Math.abs(denom) < 1e-10) {
|
|
621
|
+
slope = 0;
|
|
622
|
+
intercept = wSumY / totalW;
|
|
623
|
+
} else {
|
|
624
|
+
slope = (totalW * wSumXY - wSumX * wSumY) / denom;
|
|
625
|
+
intercept = (wSumY - slope * wSumX) / totalW;
|
|
626
|
+
}
|
|
627
|
+
let ssRes = 0;
|
|
628
|
+
for (let i = 0; i < n; i++) {
|
|
629
|
+
ssRes += weights[i] * (window[i] - (intercept + slope * i)) ** 2;
|
|
630
|
+
}
|
|
631
|
+
const rse = Math.sqrt(ssRes / Math.max(1, totalW - 2));
|
|
632
|
+
const z80 = 1.28;
|
|
633
|
+
const results = [];
|
|
634
|
+
for (let d = 1; d <= days; d++) {
|
|
635
|
+
const x = n - 1 + d;
|
|
636
|
+
const predicted = Math.max(0, Math.round(intercept + slope * x));
|
|
637
|
+
const margin = Math.round(z80 * rse * Math.sqrt(1 + 1 / n + (x - n / 2) ** 2 / (n * n / 12)));
|
|
638
|
+
results.push({
|
|
639
|
+
day: d,
|
|
640
|
+
predicted,
|
|
641
|
+
lower: Math.max(0, predicted - margin),
|
|
642
|
+
upper: predicted + margin
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return results;
|
|
646
|
+
}
|
|
647
|
+
function detectAnomalies(series, threshold = 2) {
|
|
648
|
+
if (series.length < 7) return [];
|
|
649
|
+
const anomalies = [];
|
|
650
|
+
const windowSize = Math.min(14, Math.floor(series.length * 0.7));
|
|
651
|
+
for (let i = windowSize; i < series.length; i++) {
|
|
652
|
+
const window = series.slice(i - windowSize, i);
|
|
653
|
+
const m = mean(window);
|
|
654
|
+
const s = stddev(window);
|
|
655
|
+
if (s < 1) continue;
|
|
656
|
+
const zscore = (series[i] - m) / s;
|
|
657
|
+
if (Math.abs(zscore) >= threshold) {
|
|
658
|
+
anomalies.push({
|
|
659
|
+
day: i,
|
|
660
|
+
value: series[i],
|
|
661
|
+
expected: Math.round(m),
|
|
662
|
+
zscore: Math.round(zscore * 10) / 10,
|
|
663
|
+
type: zscore > 0 ? "spike" : "drop"
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return anomalies;
|
|
668
|
+
}
|
|
669
|
+
function segmentTrends(series, minSegmentLength = 5) {
|
|
670
|
+
if (series.length < minSegmentLength) return [];
|
|
671
|
+
const segments = [];
|
|
672
|
+
let segStart = 0;
|
|
673
|
+
while (segStart < series.length - minSegmentLength + 1) {
|
|
674
|
+
let bestEnd = segStart + minSegmentLength - 1;
|
|
675
|
+
const initialSlope = linearRegression(series.slice(segStart, segStart + minSegmentLength)).slope;
|
|
676
|
+
const initialDir = initialSlope > 0.5 ? "up" : initialSlope < -0.5 ? "down" : "flat";
|
|
677
|
+
for (let end = segStart + minSegmentLength; end < series.length; end++) {
|
|
678
|
+
const seg2 = series.slice(segStart, end + 1);
|
|
679
|
+
const { slope: slope2 } = linearRegression(seg2);
|
|
680
|
+
const dir = slope2 > 0.5 ? "up" : slope2 < -0.5 ? "down" : "flat";
|
|
681
|
+
if (dir !== initialDir) break;
|
|
682
|
+
bestEnd = end;
|
|
683
|
+
}
|
|
684
|
+
const seg = series.slice(segStart, bestEnd + 1);
|
|
685
|
+
const { slope } = linearRegression(seg);
|
|
686
|
+
const direction = slope > 0.5 ? "up" : slope < -0.5 ? "down" : "flat";
|
|
687
|
+
segments.push({
|
|
688
|
+
start: segStart,
|
|
689
|
+
end: bestEnd,
|
|
690
|
+
direction,
|
|
691
|
+
slope: Math.round(slope * 10) / 10,
|
|
692
|
+
magnitude: Math.round(seg[seg.length - 1] - seg[0])
|
|
693
|
+
});
|
|
694
|
+
segStart = bestEnd + 1;
|
|
695
|
+
}
|
|
696
|
+
return segments;
|
|
697
|
+
}
|
|
698
|
+
function detectSeasonality(series, startDaysAgo) {
|
|
699
|
+
if (series.length < 14) return null;
|
|
700
|
+
const buckets = [[], [], [], [], [], [], []];
|
|
701
|
+
const today = /* @__PURE__ */ new Date();
|
|
702
|
+
for (let i = 0; i < series.length; i++) {
|
|
703
|
+
const date = new Date(today);
|
|
704
|
+
date.setDate(date.getDate() - (startDaysAgo - i));
|
|
705
|
+
const dow = date.getDay();
|
|
706
|
+
buckets[dow].push(series[i]);
|
|
707
|
+
}
|
|
708
|
+
const dayAvgs = buckets.map((b) => Math.round(mean(b)));
|
|
709
|
+
const overallMean = mean(dayAvgs);
|
|
710
|
+
const maxAvg = Math.max(...dayAvgs);
|
|
711
|
+
const minAvg = Math.min(...dayAvgs);
|
|
712
|
+
if (overallMean < 1 || (maxAvg - minAvg) / overallMean < 0.15) return null;
|
|
713
|
+
const peakIdx = dayAvgs.indexOf(maxAvg);
|
|
714
|
+
return {
|
|
715
|
+
dayOfWeek: dayAvgs,
|
|
716
|
+
peakDay: DAY_NAMES[peakIdx]
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
function computeMomentum(series) {
|
|
720
|
+
if (series.length < 14) return 0;
|
|
721
|
+
const last7 = series.slice(-7);
|
|
722
|
+
const prev7 = series.slice(-14, -7);
|
|
723
|
+
const last7Sum = last7.reduce((a, b) => a + b, 0);
|
|
724
|
+
const prev7Sum = prev7.reduce((a, b) => a + b, 0);
|
|
725
|
+
const dampK = 10;
|
|
726
|
+
const dirScore = prev7Sum > dampK ? Math.max(-40, Math.min(40, (last7Sum - prev7Sum) / Math.sqrt(prev7Sum + dampK) * 4)) : last7Sum > 0 ? 20 : 0;
|
|
727
|
+
const { slope: recentSlope } = linearRegression(last7);
|
|
728
|
+
const { slope: prevSlope } = linearRegression(prev7);
|
|
729
|
+
const accelScore = Math.max(-20, Math.min(20, (recentSlope - prevSlope) * 2));
|
|
730
|
+
const cv = last7Sum > 0 ? stddev(last7) / mean(last7) : 1;
|
|
731
|
+
const consistencyScore = Math.max(0, 20 - cv * 20);
|
|
732
|
+
const volumeScore = last7Sum > 0 ? Math.min(20, Math.log10(last7Sum + 1) * 5) : 0;
|
|
733
|
+
return Math.round(Math.max(-100, Math.min(100, dirScore + accelScore + consistencyScore + volumeScore)));
|
|
734
|
+
}
|
|
735
|
+
function generateRecommendations(packages, opts = {}) {
|
|
736
|
+
const recs = [];
|
|
737
|
+
const declining = packages.filter((p) => p.momentum < -30);
|
|
738
|
+
if (declining.length > 0) {
|
|
739
|
+
const names = declining.slice(0, 3).map((p) => p.name).join(", ");
|
|
740
|
+
recs.push({
|
|
741
|
+
type: "attention",
|
|
742
|
+
priority: declining.some((p) => p.momentum < -60) ? "high" : "medium",
|
|
743
|
+
title: `${declining.length} package${declining.length > 1 ? "s" : ""} losing momentum`,
|
|
744
|
+
detail: `${names}${declining.length > 3 ? ` and ${declining.length - 3} more` : ""} show sustained decline. Consider: release updates, fix open issues, or update documentation.`,
|
|
745
|
+
metric: `Worst momentum: ${Math.min(...declining.map((p) => p.momentum))}`
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
if (opts.gini !== void 0 && opts.gini > 0.7) {
|
|
749
|
+
recs.push({
|
|
750
|
+
type: "risk",
|
|
751
|
+
priority: opts.gini > 0.85 ? "high" : "medium",
|
|
752
|
+
title: "High portfolio concentration",
|
|
753
|
+
detail: `Gini coefficient ${opts.gini.toFixed(2)} indicates downloads are heavily concentrated in a few packages. Diversify promotion efforts across the portfolio.`,
|
|
754
|
+
metric: `Gini: ${opts.gini.toFixed(2)}`
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
if (opts.npmPct !== void 0 && opts.npmPct > 75) {
|
|
758
|
+
recs.push({
|
|
759
|
+
type: "risk",
|
|
760
|
+
priority: "medium",
|
|
761
|
+
title: "Heavy npm dependency",
|
|
762
|
+
detail: `${opts.npmPct}% of downloads come from npm. Consider cross-publishing to PyPI and NuGet to reduce single-registry risk.`,
|
|
763
|
+
metric: `npm share: ${opts.npmPct}%`
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const growing = packages.filter((p) => p.momentum > 40 && p.anomalies.some((a) => a.type === "spike"));
|
|
767
|
+
if (growing.length > 0) {
|
|
768
|
+
const names = growing.slice(0, 3).map((p) => p.name).join(", ");
|
|
769
|
+
recs.push({
|
|
770
|
+
type: "opportunity",
|
|
771
|
+
priority: "medium",
|
|
772
|
+
title: `${growing.length} package${growing.length > 1 ? "s" : ""} gaining traction`,
|
|
773
|
+
detail: `${names} show organic growth with download spikes. Capitalize with blog posts, social media, or conference talks.`,
|
|
774
|
+
metric: `Best momentum: ${Math.max(...growing.map((p) => p.momentum))}`
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const forecastGrowing = packages.filter((p) => {
|
|
778
|
+
if (p.forecast7.length < 7) return false;
|
|
779
|
+
const lastActual = p.forecast7[0]?.predicted ?? 0;
|
|
780
|
+
const lastForecast = p.forecast7[6]?.predicted ?? 0;
|
|
781
|
+
return lastForecast > lastActual * 1.2;
|
|
782
|
+
});
|
|
783
|
+
if (forecastGrowing.length > 0) {
|
|
784
|
+
recs.push({
|
|
785
|
+
type: "growth",
|
|
786
|
+
priority: "low",
|
|
787
|
+
title: `${forecastGrowing.length} package${forecastGrowing.length > 1 ? "s" : ""} predicted to grow`,
|
|
788
|
+
detail: `Statistical models predict >20% growth in the next 7 days for ${forecastGrowing.slice(0, 3).map((p) => p.name).join(", ")}.`
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const spiked = packages.filter((p) => p.anomalies.filter((a) => a.type === "spike").length >= 2);
|
|
792
|
+
if (spiked.length > 0) {
|
|
793
|
+
recs.push({
|
|
794
|
+
type: "attention",
|
|
795
|
+
priority: "low",
|
|
796
|
+
title: `${spiked.length} package${spiked.length > 1 ? "s" : ""} with repeated spikes`,
|
|
797
|
+
detail: `Multiple download spikes detected for ${spiked.slice(0, 3).map((p) => p.name).join(", ")}. Could indicate bot activity, viral posts, or dependency adoption.`
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
801
|
+
recs.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
|
802
|
+
return recs;
|
|
803
|
+
}
|
|
804
|
+
function inferPortfolio(leaderboard, opts = {}) {
|
|
805
|
+
const packages = [];
|
|
806
|
+
for (const row of leaderboard) {
|
|
807
|
+
const series = row.range30;
|
|
808
|
+
if (!series || series.length < 7) {
|
|
809
|
+
packages.push({
|
|
810
|
+
name: row.name,
|
|
811
|
+
registry: row.registry,
|
|
812
|
+
forecast7: [],
|
|
813
|
+
anomalies: [],
|
|
814
|
+
trendSegments: [],
|
|
815
|
+
seasonality: null,
|
|
816
|
+
momentum: 0
|
|
817
|
+
});
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
packages.push({
|
|
821
|
+
name: row.name,
|
|
822
|
+
registry: row.registry,
|
|
823
|
+
forecast7: forecast(series, 7),
|
|
824
|
+
anomalies: detectAnomalies(series),
|
|
825
|
+
trendSegments: segmentTrends(series),
|
|
826
|
+
seasonality: detectSeasonality(series, 30),
|
|
827
|
+
momentum: computeMomentum(series)
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
const forecastTotal7 = new Array(7).fill(0);
|
|
831
|
+
for (const pkg of packages) {
|
|
832
|
+
for (const pt of pkg.forecast7) {
|
|
833
|
+
forecastTotal7[pt.day - 1] += pt.predicted;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const totalWeek = leaderboard.reduce((s, r) => s + (r.week ?? 0), 0);
|
|
837
|
+
let weightedMomentum = 0;
|
|
838
|
+
for (const pkg of packages) {
|
|
839
|
+
const row = leaderboard.find((r) => r.name === pkg.name);
|
|
840
|
+
const weight = totalWeek > 0 ? (row?.week ?? 0) / totalWeek : 1 / packages.length;
|
|
841
|
+
weightedMomentum += pkg.momentum * weight;
|
|
842
|
+
}
|
|
843
|
+
const decliningPct = packages.filter((p) => p.momentum < -20).length / Math.max(1, packages.length);
|
|
844
|
+
const totalAnomalies = packages.reduce((s, p) => s + p.anomalies.length, 0);
|
|
845
|
+
const anomalyDensity = totalAnomalies / Math.max(1, packages.length);
|
|
846
|
+
const giniRisk = (opts.gini ?? 0) * 30;
|
|
847
|
+
const declineRisk = decliningPct * 40;
|
|
848
|
+
const anomalyRisk = Math.min(30, anomalyDensity * 10);
|
|
849
|
+
const riskScore = Math.round(Math.max(0, Math.min(100, giniRisk + declineRisk + anomalyRisk)));
|
|
850
|
+
const diversityTrend = "stable";
|
|
851
|
+
const recommendations = generateRecommendations(packages, opts);
|
|
852
|
+
return {
|
|
853
|
+
packages,
|
|
854
|
+
recommendations,
|
|
855
|
+
forecastTotal7,
|
|
856
|
+
riskScore,
|
|
857
|
+
diversityTrend,
|
|
858
|
+
portfolioMomentum: Math.round(weightedMomentum)
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
568
862
|
// src/index.ts
|
|
569
863
|
function createCache() {
|
|
570
864
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -761,11 +1055,18 @@ stats.mine = async function mine(maintainer, options) {
|
|
|
761
1055
|
export {
|
|
762
1056
|
RegistryError,
|
|
763
1057
|
calc,
|
|
1058
|
+
computeMomentum,
|
|
764
1059
|
createCache,
|
|
765
1060
|
createHandler,
|
|
766
1061
|
defaultConfig,
|
|
1062
|
+
detectAnomalies,
|
|
1063
|
+
detectSeasonality,
|
|
1064
|
+
forecast,
|
|
1065
|
+
generateRecommendations,
|
|
1066
|
+
inferPortfolio,
|
|
767
1067
|
loadConfig,
|
|
768
1068
|
registerProvider,
|
|
1069
|
+
segmentTrends,
|
|
769
1070
|
serve,
|
|
770
1071
|
starterConfig,
|
|
771
1072
|
stats
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcptoolshop/registry-stats",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Multi-registry download stats — engine, AI-powered dashboard, and desktop app for npm, PyPI, NuGet, VS Code Marketplace, and Docker Hub",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|