-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_worker.js
More file actions
286 lines (240 loc) · 10.1 KB
/
_worker.js
File metadata and controls
286 lines (240 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// _worker.js
// === CONFIGURATION ===
const HEADERS = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
// Tell browsers to cache API responses for 30s
"Cache-Control": "public, max-age=30",
};
const KV_KEY_PREFIX = "dex_cache_v1";
const IMG_CACHE_PREFIX = "img_";
const SOFT_REFRESH_MS = 4 * 60 * 1000; // 4 Minutes
const HARD_REFRESH_MS = 5 * 60 * 1000; // 5 Minutes
const NETWORK_MAP = {
'solana': 'solana',
'ethereum': 'eth',
'bnb': 'bsc',
'base': 'base'
};
// === STEALTH HEADERS ===
// Prevents 403 Forbidden errors from GeckoTerminal
function getStealthHeaders() {
const agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
];
return {
"User-Agent": agents[Math.floor(Math.random() * agents.length)],
"Accept": "application/json",
"Referer": "https://www.geckoterminal.com/",
"Origin": "https://www.geckoterminal.com"
};
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// === ROUTE 1: MARKET DATA ===
if (url.pathname === "/api/stats") {
const network = url.searchParams.get("network") || "solana";
// We pass 'ctx' so we can run background tasks (Image Caching)
return handleStatsWithCache(request, env, ctx, network);
}
// === ROUTE 2: IMAGE PROXY (THE SPEED ENGINE) ===
if (url.pathname === "/api/image-proxy") {
return handleImageProxy(request, env, url);
}
// Fallback: Serve website assets
return env.ASSETS.fetch(request);
}
};
// === IMAGE PROXY LOGIC ===
async function handleImageProxy(request, env, url) {
const targetUrl = url.searchParams.get("url");
if (!targetUrl) return new Response("Missing URL", { status: 400 });
// 1. Try to get from Main Database (KV)
// This is fast because it runs on Cloudflare's Edge
if (env.KV_STORE) {
const imgKey = IMG_CACHE_PREFIX + btoa(targetUrl);
// Get file AND metadata (to know if it's a GIF or PNG)
const { value, metadata } = await env.KV_STORE.getWithMetadata(imgKey, { type: "stream" });
if (value) {
return new Response(value, {
headers: {
"Content-Type": metadata?.contentType || "image/png",
"Access-Control-Allow-Origin": "*",
// BROWSER CACHE: Tell browser "Keep this for 7 days"
"Cache-Control": "public, max-age=604800",
"X-Source": "Worker-KV-Cache"
}
});
}
}
// 2. If not in DB, download it live (Slower, but necessary for first time)
try {
const imgRes = await fetch(decodeURIComponent(targetUrl), {
headers: { "User-Agent": "Mozilla/5.0" }
});
// Forward the image to the user
const newHeaders = new Headers(imgRes.headers);
newHeaders.set("Access-Control-Allow-Origin", "*");
newHeaders.set("Cache-Control", "public, max-age=86400"); // 7 Days
return new Response(imgRes.body, {
status: imgRes.status,
headers: newHeaders
});
} catch (e) {
return new Response("Proxy Error", { status: 500 });
}
}
// === DATA CACHING LOGIC ===
async function handleStatsWithCache(request, env, ctx, network) {
if (!env.KV_STORE) {
return handleDexStats(network); // Fallback if no database
}
const cacheKey = `${KV_KEY_PREFIX}${network}`;
const now = Date.now();
let cached = null;
let age = 0;
// Try to read cache
try {
const raw = await env.KV_STORE.get(cacheKey);
if (raw) {
cached = JSON.parse(raw);
age = now - (cached.timestamp || 0);
}
} catch (e) { console.error("KV Error", e); }
// Strategy A: Fresh Cache (0-4 mins) -> Return instantly
if (cached && age < SOFT_REFRESH_MS) {
return new Response(JSON.stringify(cached), { headers: { ...HEADERS, "X-Source": "Cache-Fresh" } });
}
// Strategy B: Stale Cache (4-5 mins) -> Return old data, update in background
if (cached && age < HARD_REFRESH_MS) {
ctx.waitUntil(refreshData(env, network, cacheKey, ctx));
return new Response(JSON.stringify(cached), { headers: { ...HEADERS, "X-Source": "Cache-Stale-Background" } });
}
// Strategy C: Expired (>5 mins) -> Blocking update
console.log(`Cache expired for ${network}. Fetching live...`);
try {
const freshData = await refreshData(env, network, cacheKey, ctx);
return new Response(JSON.stringify(freshData), { headers: { ...HEADERS, "X-Source": "Live-Fetch" } });
} catch (e) {
if (cached) return new Response(JSON.stringify(cached), { headers: { ...HEADERS, "X-Source": "Cache-Fallback" } });
return new Response(JSON.stringify({ error: true, message: e.message }), { status: 200, headers: HEADERS });
}
}
// === DEEP SCAN: DATA + IMAGE PRE-FETCHING ===
async function refreshData(env, network, key, ctx) {
const data = await handleDexStats(network);
if (data && !data.error && data.gainers) {
// 1. Save the JSON data
await env.KV_STORE.put(key, JSON.stringify(data), { expirationTtl: 1800 });
// 2. TRIGGER BACKGROUND IMAGE CACHING
// This is the "Deep Scan" optimization.
// We download the logos for the top 20 movers NOW, so they are ready
// in the KV database before the user even asks for them.
if (ctx && ctx.waitUntil) {
const topTokens = [
...data.gainers.slice(0, 20),
...data.losers.slice(0, 20)
];
ctx.waitUntil(backgroundCacheImages(env, topTokens));
}
}
return data;
}
// === BACKGROUND TASK: DOWNLOAD & SAVE IMAGES ===
async function backgroundCacheImages(env, tokens) {
if (!env.KV_STORE) return;
const fetches = tokens.map(async (token) => {
if (!token.image || token.image.includes("bullish.png")) return;
const imgUrl = token.image;
const imgKey = IMG_CACHE_PREFIX + btoa(imgUrl); // Unique Key
try {
// Check if we already have it? (Optional optimization)
// For now, we just overwrite to ensure freshness.
const res = await fetch(imgUrl, {
headers: { "User-Agent": "Mozilla/5.0" }
});
if (res.ok) {
const blob = await res.arrayBuffer();
// Get the real type (image/gif, image/png, etc.)
const type = res.headers.get("Content-Type") || "image/png";
// Save to KV (Database) with 7 Day Expiration
await env.KV_STORE.put(imgKey, blob, {
expirationTtl: 604800,
metadata: { contentType: type }
});
}
} catch (err) {
console.log(`Failed to cache image for ${token.symbol}`);
}
});
// Run all downloads in parallel
await Promise.all(fetches);
}
// === API LOGIC (RESTORING YOUR ORIGINAL LOGIC) ===
async function handleDexStats(networkKey) {
const apiSlug = NETWORK_MAP[networkKey];
if (!apiSlug) throw new Error("Invalid Network");
try {
// Fetch 5 pages to get deep data
const promises = [1, 2, 3, 4, 5].map(page =>
fetch(`https://api.geckoterminal.com/api/v2/networks/${apiSlug}/trending_pools?include=base_token&page=${page}`, {
headers: getStealthHeaders()
})
);
const responses = await Promise.all(promises);
let allPools = [];
let allIncluded = [];
for (const res of responses) {
if (res.ok) {
const data = await res.json();
if (data.data) allPools = allPools.concat(data.data);
if (data.included) allIncluded = allIncluded.concat(data.included);
}
}
if (allPools.length === 0) throw new Error("Gecko API: No Pools Found");
let formatted = allPools.map(pool => {
const baseTokenId = pool.relationships?.base_token?.data?.id;
const tokenData = allIncluded.find(i => i.id === baseTokenId && i.type === 'token');
const attr = pool.attributes;
const changes = attr.price_change_percentage || {};
return {
symbol: attr.name.split('/')[0].trim(),
name: tokenData?.attributes?.name || attr.name,
image: tokenData?.attributes?.image_url || "/images/bullish.png",
price: parseFloat(attr.base_token_price_usd) || 0,
change_30m: parseFloat(changes.m30) || 0,
change_1h: parseFloat(changes.h1) || 0,
change_6h: parseFloat(changes.h6) || 0,
change_24h: parseFloat(changes.h24) || 0,
volume_24h: parseFloat(attr.volume_usd?.h24) || 0,
fdv: parseFloat(attr.fdv_usd) || 0,
address: attr.address
};
});
// FILTER: Remove scams ($0 price or <$1000 volume)
formatted = formatted.filter(p => p.price > 0 && p.volume_24h > 1000);
// Deduplicate
const unique = [];
const seen = new Set();
for (const item of formatted) {
if (!seen.has(item.symbol)) {
seen.add(item.symbol);
unique.push(item);
}
}
const onlyGainers = unique.filter(x => x.change_24h > 0);
const onlyLosers = unique.filter(x => x.change_24h < 0);
const gainers = onlyGainers.sort((a, b) => b.change_24h - a.change_24h).slice(0, 50);
const losers = onlyLosers.sort((a, b) => a.change_24h - b.change_24h).slice(0, 50);
return {
timestamp: Date.now(),
network: networkKey,
gainers,
losers
};
} catch (e) {
throw new Error(`Worker Error: ${e.message}`);
}
}