data-aggregator-mcp 1.0.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.
Potentially problematic release.
This version of data-aggregator-mcp might be problematic. Click here for more details.
- package/README.md +336 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +333 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/exchange.d.ts +15 -0
- package/dist/tools/exchange.d.ts.map +1 -0
- package/dist/tools/exchange.js +195 -0
- package/dist/tools/exchange.js.map +1 -0
- package/dist/tools/news.d.ts +20 -0
- package/dist/tools/news.d.ts.map +1 -0
- package/dist/tools/news.js +175 -0
- package/dist/tools/news.js.map +1 -0
- package/dist/tools/public-data.d.ts +24 -0
- package/dist/tools/public-data.d.ts.map +1 -0
- package/dist/tools/public-data.js +262 -0
- package/dist/tools/public-data.js.map +1 -0
- package/dist/tools/scraper.d.ts +19 -0
- package/dist/tools/scraper.d.ts.map +1 -0
- package/dist/tools/scraper.js +185 -0
- package/dist/tools/scraper.js.map +1 -0
- package/dist/tools/stocks.d.ts +14 -0
- package/dist/tools/stocks.d.ts.map +1 -0
- package/dist/tools/stocks.js +172 -0
- package/dist/tools/stocks.js.map +1 -0
- package/dist/tools/weather.d.ts +15 -0
- package/dist/tools/weather.d.ts.map +1 -0
- package/dist/tools/weather.js +172 -0
- package/dist/tools/weather.js.map +1 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/cache.d.ts +45 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +88 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/http.d.ts +39 -0
- package/dist/utils/http.d.ts.map +1 -0
- package/dist/utils/http.js +103 -0
- package/dist/utils/http.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +34 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +95 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/package.json +42 -0
- package/src/index.ts +461 -0
- package/src/tools/exchange.ts +241 -0
- package/src/tools/news.ts +238 -0
- package/src/tools/public-data.ts +325 -0
- package/src/tools/scraper.ts +217 -0
- package/src/tools/stocks.ts +205 -0
- package/src/tools/weather.ts +216 -0
- package/src/types.ts +184 -0
- package/src/utils/cache.ts +103 -0
- package/src/utils/http.ts +156 -0
- package/src/utils/rate-limiter.ts +114 -0
- package/tsconfig.json +19 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ─── Shared Types ──────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** Generic API response wrapper */
|
|
4
|
+
export interface ApiResponse<T> {
|
|
5
|
+
success: boolean;
|
|
6
|
+
data?: T;
|
|
7
|
+
error?: string;
|
|
8
|
+
cached?: boolean;
|
|
9
|
+
timestamp: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Stock / Crypto Types ──────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface StockQuote {
|
|
15
|
+
symbol: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
price: number;
|
|
18
|
+
currency: string;
|
|
19
|
+
change: number;
|
|
20
|
+
changePercent: number;
|
|
21
|
+
volume?: number;
|
|
22
|
+
marketCap?: number;
|
|
23
|
+
high?: number;
|
|
24
|
+
low?: number;
|
|
25
|
+
open?: number;
|
|
26
|
+
previousClose?: number;
|
|
27
|
+
timestamp: string;
|
|
28
|
+
source: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CryptoQuote {
|
|
32
|
+
symbol: string;
|
|
33
|
+
name: string;
|
|
34
|
+
price: number;
|
|
35
|
+
currency: string;
|
|
36
|
+
change24h: number;
|
|
37
|
+
changePercent24h: number;
|
|
38
|
+
volume24h: number;
|
|
39
|
+
marketCap: number;
|
|
40
|
+
high24h?: number;
|
|
41
|
+
low24h?: number;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
source: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Weather Types ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface CurrentWeather {
|
|
49
|
+
temperature: number;
|
|
50
|
+
feelsLike: number;
|
|
51
|
+
humidity: number;
|
|
52
|
+
windSpeed: number;
|
|
53
|
+
windDirection: number;
|
|
54
|
+
precipitation: number;
|
|
55
|
+
weatherCode: number;
|
|
56
|
+
weatherDescription: string;
|
|
57
|
+
isDay: boolean;
|
|
58
|
+
timestamp: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DailyForecast {
|
|
62
|
+
date: string;
|
|
63
|
+
temperatureMax: number;
|
|
64
|
+
temperatureMin: number;
|
|
65
|
+
precipitationSum: number;
|
|
66
|
+
precipitationProbability: number;
|
|
67
|
+
windSpeedMax: number;
|
|
68
|
+
weatherCode: number;
|
|
69
|
+
weatherDescription: string;
|
|
70
|
+
sunrise: string;
|
|
71
|
+
sunset: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WeatherData {
|
|
75
|
+
location: string;
|
|
76
|
+
latitude: number;
|
|
77
|
+
longitude: number;
|
|
78
|
+
timezone: string;
|
|
79
|
+
current: CurrentWeather;
|
|
80
|
+
forecast: DailyForecast[];
|
|
81
|
+
source: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── News Types ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export interface NewsArticle {
|
|
87
|
+
title: string;
|
|
88
|
+
description: string;
|
|
89
|
+
url: string;
|
|
90
|
+
source: string;
|
|
91
|
+
publishedAt: string;
|
|
92
|
+
author?: string;
|
|
93
|
+
imageUrl?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Exchange Rate Types ───────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface ExchangeRate {
|
|
99
|
+
base: string;
|
|
100
|
+
target: string;
|
|
101
|
+
rate: number;
|
|
102
|
+
timestamp: string;
|
|
103
|
+
source: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CurrencyConversion {
|
|
107
|
+
from: string;
|
|
108
|
+
to: string;
|
|
109
|
+
amount: number;
|
|
110
|
+
convertedAmount: number;
|
|
111
|
+
rate: number;
|
|
112
|
+
timestamp: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Web Scraping Types ────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export interface ScrapedPage {
|
|
118
|
+
url: string;
|
|
119
|
+
title: string;
|
|
120
|
+
metaDescription: string;
|
|
121
|
+
headings: { level: number; text: string }[];
|
|
122
|
+
mainContent: string;
|
|
123
|
+
links: { text: string; href: string }[];
|
|
124
|
+
selectedContent?: string;
|
|
125
|
+
timestamp: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Public Data Types ─────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export interface WikipediaSummary {
|
|
131
|
+
title: string;
|
|
132
|
+
extract: string;
|
|
133
|
+
url: string;
|
|
134
|
+
thumbnail?: string;
|
|
135
|
+
timestamp: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface IpGeolocation {
|
|
139
|
+
ip: string;
|
|
140
|
+
country: string;
|
|
141
|
+
countryCode: string;
|
|
142
|
+
region: string;
|
|
143
|
+
city: string;
|
|
144
|
+
latitude: number;
|
|
145
|
+
longitude: number;
|
|
146
|
+
timezone: string;
|
|
147
|
+
isp: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface DnsRecord {
|
|
151
|
+
domain: string;
|
|
152
|
+
type: string;
|
|
153
|
+
records: string[];
|
|
154
|
+
ttl?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface UrlInfo {
|
|
158
|
+
originalUrl: string;
|
|
159
|
+
expandedUrl: string;
|
|
160
|
+
statusCode: number;
|
|
161
|
+
contentType?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Cache & Rate Limiting ─────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export interface CacheEntry<T> {
|
|
167
|
+
data: T;
|
|
168
|
+
expiresAt: number;
|
|
169
|
+
createdAt: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RateLimitConfig {
|
|
173
|
+
maxRequests: number;
|
|
174
|
+
windowMs: number;
|
|
175
|
+
source: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Tool Result Helper ────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export interface ToolResult {
|
|
181
|
+
[key: string]: unknown;
|
|
182
|
+
content: Array<{ type: "text"; text: string }>;
|
|
183
|
+
isError?: boolean;
|
|
184
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { CacheEntry } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory cache with per-key TTL support.
|
|
5
|
+
*
|
|
6
|
+
* TTL defaults (in milliseconds):
|
|
7
|
+
* - Market data: 5 minutes (300_000)
|
|
8
|
+
* - Exchange rates: 30 minutes (1_800_000)
|
|
9
|
+
* - Weather: 1 hour (3_600_000)
|
|
10
|
+
* - News: 15 minutes (900_000)
|
|
11
|
+
* - Web scraping: 10 minutes (600_000)
|
|
12
|
+
* - Public data: 1 hour (3_600_000)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const TTL = {
|
|
16
|
+
MARKET: 5 * 60 * 1000,
|
|
17
|
+
EXCHANGE: 30 * 60 * 1000,
|
|
18
|
+
WEATHER: 60 * 60 * 1000,
|
|
19
|
+
NEWS: 15 * 60 * 1000,
|
|
20
|
+
SCRAPE: 10 * 60 * 1000,
|
|
21
|
+
PUBLIC: 60 * 60 * 1000,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export class Cache {
|
|
25
|
+
private store = new Map<string, CacheEntry<unknown>>();
|
|
26
|
+
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
27
|
+
|
|
28
|
+
constructor(cleanupIntervalMs = 60_000) {
|
|
29
|
+
// Periodic sweep to evict expired entries
|
|
30
|
+
this.cleanupInterval = setInterval(() => this.evictExpired(), cleanupIntervalMs);
|
|
31
|
+
// Allow the process to exit even if the interval is still running
|
|
32
|
+
if (this.cleanupInterval.unref) {
|
|
33
|
+
this.cleanupInterval.unref();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Retrieve a cached value, or undefined if missing / expired. */
|
|
38
|
+
get<T>(key: string): T | undefined {
|
|
39
|
+
const entry = this.store.get(key);
|
|
40
|
+
if (!entry) return undefined;
|
|
41
|
+
|
|
42
|
+
if (Date.now() > entry.expiresAt) {
|
|
43
|
+
this.store.delete(key);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return entry.data as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Store a value with a TTL (milliseconds). */
|
|
51
|
+
set<T>(key: string, data: T, ttlMs: number): void {
|
|
52
|
+
this.store.set(key, {
|
|
53
|
+
data,
|
|
54
|
+
expiresAt: Date.now() + ttlMs,
|
|
55
|
+
createdAt: Date.now(),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Check whether a non-expired entry exists. */
|
|
60
|
+
has(key: string): boolean {
|
|
61
|
+
return this.get(key) !== undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove a single key. */
|
|
65
|
+
delete(key: string): void {
|
|
66
|
+
this.store.delete(key);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Remove all entries. */
|
|
70
|
+
clear(): void {
|
|
71
|
+
this.store.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Number of entries (including possibly-expired ones until next sweep). */
|
|
75
|
+
get size(): number {
|
|
76
|
+
return this.store.size;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build a deterministic cache key from parts. */
|
|
80
|
+
static key(...parts: (string | number | boolean | undefined | null)[]): string {
|
|
81
|
+
return parts
|
|
82
|
+
.map((p) => (p === undefined || p === null ? "_" : String(p).toLowerCase()))
|
|
83
|
+
.join(":");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Remove all expired entries. */
|
|
87
|
+
private evictExpired(): void {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
for (const [key, entry] of this.store) {
|
|
90
|
+
if (now > entry.expiresAt) {
|
|
91
|
+
this.store.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Tear down the cleanup interval (for graceful shutdown). */
|
|
97
|
+
destroy(): void {
|
|
98
|
+
clearInterval(this.cleanupInterval);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Singleton cache instance shared across the server. */
|
|
103
|
+
export const cache = new Cache();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP client with:
|
|
3
|
+
* - Automatic retries with exponential back-off
|
|
4
|
+
* - User-Agent rotation (for scraping)
|
|
5
|
+
* - Timeout support
|
|
6
|
+
* - JSON convenience helpers
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const USER_AGENTS = [
|
|
10
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
11
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
|
12
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
13
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
|
|
14
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export interface HttpOptions {
|
|
18
|
+
/** Request timeout in milliseconds (default: 15 000). */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Maximum number of attempts (default: 3). */
|
|
21
|
+
maxRetries?: number;
|
|
22
|
+
/** Additional headers to merge in. */
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
/** Use a rotating browser-like User-Agent (default: false). */
|
|
25
|
+
rotateUserAgent?: boolean;
|
|
26
|
+
/** HTTP method (default: GET). */
|
|
27
|
+
method?: string;
|
|
28
|
+
/** Request body (for POST/PUT). */
|
|
29
|
+
body?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pickUserAgent(): string {
|
|
33
|
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fetch with retries, timeouts, and optional UA rotation.
|
|
38
|
+
* Returns the raw `Response` object on success.
|
|
39
|
+
*/
|
|
40
|
+
export async function httpFetch(
|
|
41
|
+
url: string,
|
|
42
|
+
opts: HttpOptions = {}
|
|
43
|
+
): Promise<Response> {
|
|
44
|
+
const {
|
|
45
|
+
timeoutMs = 15_000,
|
|
46
|
+
maxRetries = 3,
|
|
47
|
+
headers = {},
|
|
48
|
+
rotateUserAgent = false,
|
|
49
|
+
method = "GET",
|
|
50
|
+
body,
|
|
51
|
+
} = opts;
|
|
52
|
+
|
|
53
|
+
let lastError: Error | undefined;
|
|
54
|
+
|
|
55
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const finalHeaders: Record<string, string> = {
|
|
61
|
+
Accept: "application/json, text/html, */*",
|
|
62
|
+
...headers,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (rotateUserAgent) {
|
|
66
|
+
finalHeaders["User-Agent"] = pickUserAgent();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const response = await fetch(url, {
|
|
70
|
+
method,
|
|
71
|
+
headers: finalHeaders,
|
|
72
|
+
body,
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
|
|
78
|
+
// Retry on server errors (5xx), not on client errors (4xx)
|
|
79
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
80
|
+
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
81
|
+
await backoff(attempt);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return response;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
89
|
+
|
|
90
|
+
if (attempt < maxRetries) {
|
|
91
|
+
await backoff(attempt);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw lastError ?? new Error(`Failed to fetch ${url}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convenience: fetch and parse JSON.
|
|
102
|
+
*/
|
|
103
|
+
export async function httpGetJson<T = unknown>(
|
|
104
|
+
url: string,
|
|
105
|
+
opts: HttpOptions = {}
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
const resp = await httpFetch(url, {
|
|
108
|
+
...opts,
|
|
109
|
+
headers: { Accept: "application/json", ...opts.headers },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!resp.ok) {
|
|
113
|
+
const body = await resp.text().catch(() => "");
|
|
114
|
+
throw new Error(
|
|
115
|
+
`HTTP ${resp.status} from ${url}: ${body.slice(0, 300)}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return resp.json() as Promise<T>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Convenience: fetch and return text.
|
|
124
|
+
*/
|
|
125
|
+
export async function httpGetText(
|
|
126
|
+
url: string,
|
|
127
|
+
opts: HttpOptions = {}
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
const resp = await httpFetch(url, opts);
|
|
130
|
+
|
|
131
|
+
if (!resp.ok) {
|
|
132
|
+
const body = await resp.text().catch(() => "");
|
|
133
|
+
throw new Error(
|
|
134
|
+
`HTTP ${resp.status} from ${url}: ${body.slice(0, 300)}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return resp.text();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Exponential back-off: 500ms, 1s, 2s, ... */
|
|
142
|
+
async function backoff(attempt: number): Promise<void> {
|
|
143
|
+
const ms = Math.min(500 * 2 ** (attempt - 1), 5_000);
|
|
144
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Format an error into a user-friendly string for tool results.
|
|
149
|
+
*/
|
|
150
|
+
export function formatError(err: unknown): string {
|
|
151
|
+
if (err instanceof Error) {
|
|
152
|
+
if (err.name === "AbortError") return "Request timed out. The API may be slow or unreachable.";
|
|
153
|
+
return err.message;
|
|
154
|
+
}
|
|
155
|
+
return String(err);
|
|
156
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding-window rate limiter keyed by source name.
|
|
3
|
+
*
|
|
4
|
+
* Default limits per source (requests / window):
|
|
5
|
+
* - CoinGecko: 30 req / 60 s
|
|
6
|
+
* - Open-Meteo: 60 req / 60 s
|
|
7
|
+
* - ExchangeRate: 60 req / 60 s
|
|
8
|
+
* - NewsAPI: 50 req / 60 s (free tier is tight)
|
|
9
|
+
* - Google News RSS: 60 req / 60 s
|
|
10
|
+
* - Wikipedia: 100 req / 60 s
|
|
11
|
+
* - Generic HTTP: 120 req / 60 s
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface Window {
|
|
15
|
+
timestamps: number[];
|
|
16
|
+
maxRequests: number;
|
|
17
|
+
windowMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMITS: Record<string, { maxRequests: number; windowMs: number }> = {
|
|
21
|
+
coingecko: { maxRequests: 30, windowMs: 60_000 },
|
|
22
|
+
"open-meteo": { maxRequests: 60, windowMs: 60_000 },
|
|
23
|
+
exchangerate: { maxRequests: 60, windowMs: 60_000 },
|
|
24
|
+
newsapi: { maxRequests: 50, windowMs: 60_000 },
|
|
25
|
+
googlenews: { maxRequests: 60, windowMs: 60_000 },
|
|
26
|
+
wikipedia: { maxRequests: 100, windowMs: 60_000 },
|
|
27
|
+
generic: { maxRequests: 120, windowMs: 60_000 },
|
|
28
|
+
dns: { maxRequests: 120, windowMs: 60_000 },
|
|
29
|
+
ipgeo: { maxRequests: 45, windowMs: 60_000 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class RateLimiter {
|
|
33
|
+
private windows = new Map<string, Window>();
|
|
34
|
+
|
|
35
|
+
/** Register or update limits for a source. */
|
|
36
|
+
configure(source: string, maxRequests: number, windowMs: number): void {
|
|
37
|
+
const existing = this.windows.get(source);
|
|
38
|
+
if (existing) {
|
|
39
|
+
existing.maxRequests = maxRequests;
|
|
40
|
+
existing.windowMs = windowMs;
|
|
41
|
+
} else {
|
|
42
|
+
this.windows.set(source, { timestamps: [], maxRequests, windowMs });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Try to acquire a permit for `source`.
|
|
48
|
+
* Returns `true` if the request is allowed, `false` if rate-limited.
|
|
49
|
+
*/
|
|
50
|
+
tryAcquire(source: string): boolean {
|
|
51
|
+
const win = this.getOrCreate(source);
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
|
|
54
|
+
// Slide the window: drop timestamps older than the window
|
|
55
|
+
win.timestamps = win.timestamps.filter((t) => now - t < win.windowMs);
|
|
56
|
+
|
|
57
|
+
if (win.timestamps.length >= win.maxRequests) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
win.timestamps.push(now);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Acquire a permit, waiting if necessary. Throws after `timeoutMs`.
|
|
67
|
+
*/
|
|
68
|
+
async acquire(source: string, timeoutMs = 10_000): Promise<void> {
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
|
|
71
|
+
while (!this.tryAcquire(source)) {
|
|
72
|
+
if (Date.now() >= deadline) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Rate limit exceeded for "${source}". Try again in a few seconds.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
// Wait a short interval before retrying
|
|
78
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How many requests remain in the current window. */
|
|
83
|
+
remaining(source: string): number {
|
|
84
|
+
const win = this.getOrCreate(source);
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const active = win.timestamps.filter((t) => now - t < win.windowMs).length;
|
|
87
|
+
return Math.max(0, win.maxRequests - active);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Milliseconds until the next permit becomes available (0 if available now). */
|
|
91
|
+
retryAfterMs(source: string): number {
|
|
92
|
+
const win = this.getOrCreate(source);
|
|
93
|
+
if (win.timestamps.length < win.maxRequests) return 0;
|
|
94
|
+
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const oldest = win.timestamps.find((t) => now - t < win.windowMs);
|
|
97
|
+
if (!oldest) return 0;
|
|
98
|
+
|
|
99
|
+
return Math.max(0, oldest + win.windowMs - now);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private getOrCreate(source: string): Window {
|
|
103
|
+
let win = this.windows.get(source);
|
|
104
|
+
if (!win) {
|
|
105
|
+
const defaults = DEFAULT_LIMITS[source] ?? DEFAULT_LIMITS.generic!;
|
|
106
|
+
win = { timestamps: [], ...defaults };
|
|
107
|
+
this.windows.set(source, win);
|
|
108
|
+
}
|
|
109
|
+
return win;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Singleton rate limiter shared across the server. */
|
|
114
|
+
export const rateLimiter = new RateLimiter();
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|