@0xprotovox/deficlaw 0.1.1 → 0.2.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.md +60 -39
- package/dist/analysis/formatOutput.d.ts +113 -2
- package/dist/analysis/formatOutput.js +135 -100
- package/dist/analysis/formatOutput.js.map +1 -1
- package/dist/analysis/riskScorer.d.ts +24 -2
- package/dist/analysis/riskScorer.js +146 -48
- package/dist/analysis/riskScorer.js.map +1 -1
- package/dist/analysis/summaryGenerator.d.ts +3 -2
- package/dist/analysis/summaryGenerator.js +125 -42
- package/dist/analysis/summaryGenerator.js.map +1 -1
- package/dist/cache/memoryCache.d.ts +12 -3
- package/dist/cache/memoryCache.js +43 -2
- package/dist/cache/memoryCache.js.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.js +28 -16
- package/dist/server.js.map +1 -1
- package/dist/sources/dexscreener.d.ts +6 -17
- package/dist/sources/dexscreener.js +72 -26
- package/dist/sources/dexscreener.js.map +1 -1
- package/dist/sources/gmgn.d.ts +11 -4
- package/dist/sources/gmgn.js +179 -105
- package/dist/sources/gmgn.js.map +1 -1
- package/dist/sources/solanaRpc.d.ts +4 -4
- package/dist/sources/solanaRpc.js +60 -29
- package/dist/sources/solanaRpc.js.map +1 -1
- package/dist/tools/analyzeToken.d.ts +1 -2
- package/dist/tools/analyzeToken.js +114 -100
- package/dist/tools/analyzeToken.js.map +1 -1
- package/dist/tools/getPrice.d.ts +2 -12
- package/dist/tools/getPrice.js +23 -4
- package/dist/tools/getPrice.js.map +1 -1
- package/dist/tools/getTopTraders.d.ts +30 -24
- package/dist/tools/getTopTraders.js +36 -21
- package/dist/tools/getTopTraders.js.map +1 -1
- package/dist/tools/getTrending.d.ts +8 -1
- package/dist/tools/getTrending.js +12 -2
- package/dist/tools/getTrending.js.map +1 -1
- package/dist/types/index.d.ts +34 -0
- package/package.json +18 -2
|
@@ -1,49 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DexScreener API Client
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Free API, no auth needed. Rate limit: ~300 req/min (we cap at ~150).
|
|
4
|
+
* All fetches have timeouts, retries on transient errors, and proper error handling.
|
|
5
5
|
*/
|
|
6
6
|
import { MemoryCache } from '../cache/memoryCache.js';
|
|
7
7
|
const BASE_URL = 'https://api.dexscreener.com';
|
|
8
8
|
const PAIR_CACHE_TTL = 5 * 60 * 1000; // 5 min
|
|
9
9
|
const PRICE_CACHE_TTL = 30 * 1000; // 30s
|
|
10
10
|
const TRENDING_CACHE_TTL = 30 * 1000; // 30s
|
|
11
|
+
const MAX_RETRIES = 2;
|
|
11
12
|
// Simple rate limiter — sequential queue with min interval
|
|
12
13
|
let lastRequestMs = 0;
|
|
13
14
|
const MIN_INTERVAL = 200; // 200ms between requests
|
|
15
|
+
/**
|
|
16
|
+
* Fetch with rate limiting, timeout, and retry for transient failures.
|
|
17
|
+
* Returns parsed JSON on success; throws on permanent failures.
|
|
18
|
+
*/
|
|
14
19
|
async function throttledFetch(url, timeoutMs = 8000) {
|
|
15
20
|
const now = Date.now();
|
|
16
21
|
const wait = MIN_INTERVAL - (now - lastRequestMs);
|
|
17
22
|
if (wait > 0)
|
|
18
23
|
await new Promise(r => setTimeout(r, wait));
|
|
19
24
|
lastRequestMs = Date.now();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
let lastError = null;
|
|
26
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
29
|
+
if (res.status === 429) {
|
|
30
|
+
// Rate limited — wait and retry
|
|
31
|
+
const retryAfter = Math.min(parseInt(res.headers.get('retry-after') || '2', 10) * 1000, 10000);
|
|
32
|
+
await new Promise(r => setTimeout(r, retryAfter));
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (res.status >= 500 && attempt < MAX_RETRIES) {
|
|
36
|
+
// Server error — brief pause and retry
|
|
37
|
+
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
throw new Error(`DexScreener HTTP ${res.status}: ${res.statusText} (${url})`);
|
|
42
|
+
}
|
|
43
|
+
return await res.json();
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
47
|
+
if (err instanceof TypeError && attempt < MAX_RETRIES) {
|
|
48
|
+
// Network error (DNS, connection refused, etc.) — retry
|
|
49
|
+
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (lastError.name === 'TimeoutError' && attempt < MAX_RETRIES) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
throw lastError;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw lastError ?? new Error(`DexScreener request failed after ${MAX_RETRIES + 1} attempts`);
|
|
24
59
|
}
|
|
25
60
|
const pairCache = new MemoryCache(PAIR_CACHE_TTL);
|
|
26
61
|
const priceCache = new MemoryCache(PRICE_CACHE_TTL);
|
|
27
62
|
const trendingCache = new MemoryCache(TRENDING_CACHE_TTL);
|
|
28
|
-
/** Get best pair for a token address (highest liquidity) */
|
|
63
|
+
/** Get best pair for a token address (highest liquidity on the target chain) */
|
|
29
64
|
export async function getTokenPair(address, chain = 'solana') {
|
|
65
|
+
if (!address || address.trim().length === 0)
|
|
66
|
+
return null;
|
|
30
67
|
const cacheKey = `${chain}:${address}`;
|
|
31
68
|
const cached = pairCache.get(cacheKey);
|
|
32
69
|
if (cached)
|
|
33
70
|
return cached;
|
|
34
71
|
const data = await throttledFetch(`${BASE_URL}/latest/dex/tokens/${address}`);
|
|
35
|
-
const pairs = data.pairs
|
|
72
|
+
const pairs = data.pairs ?? [];
|
|
73
|
+
if (pairs.length === 0)
|
|
74
|
+
return null;
|
|
36
75
|
// Filter by chain and sort by liquidity
|
|
37
76
|
const chainPairs = pairs
|
|
38
77
|
.filter(p => p.chainId === chain)
|
|
39
|
-
.sort((a, b) => (b.liquidity?.usd
|
|
40
|
-
const best = chainPairs[0]
|
|
78
|
+
.sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0));
|
|
79
|
+
const best = chainPairs[0] ?? pairs[0] ?? null;
|
|
41
80
|
if (best)
|
|
42
81
|
pairCache.set(cacheKey, best);
|
|
43
82
|
return best;
|
|
44
83
|
}
|
|
45
|
-
/** Quick price lookup */
|
|
84
|
+
/** Quick price lookup — returns null if token not found */
|
|
46
85
|
export async function getPrice(address, chain = 'solana') {
|
|
86
|
+
if (!address || address.trim().length === 0)
|
|
87
|
+
return null;
|
|
47
88
|
const cacheKey = `price:${chain}:${address}`;
|
|
48
89
|
const cached = priceCache.get(cacheKey);
|
|
49
90
|
if (cached)
|
|
@@ -51,16 +92,17 @@ export async function getPrice(address, chain = 'solana') {
|
|
|
51
92
|
const pair = await getTokenPair(address, chain);
|
|
52
93
|
if (!pair)
|
|
53
94
|
return null;
|
|
95
|
+
const priceUsd = parseFloat(pair.priceUsd);
|
|
54
96
|
const result = {
|
|
55
97
|
address: pair.baseToken.address,
|
|
56
98
|
symbol: pair.baseToken.symbol,
|
|
57
99
|
name: pair.baseToken.name,
|
|
58
|
-
priceUsd:
|
|
59
|
-
priceChange1h: pair.priceChange?.h1
|
|
60
|
-
priceChange24h: pair.priceChange?.h24
|
|
61
|
-
volume24h: pair.volume?.h24
|
|
62
|
-
liquidity: pair.liquidity?.usd
|
|
63
|
-
marketCap: pair.marketCap
|
|
100
|
+
priceUsd: isNaN(priceUsd) ? 0 : priceUsd,
|
|
101
|
+
priceChange1h: pair.priceChange?.h1 ?? 0,
|
|
102
|
+
priceChange24h: pair.priceChange?.h24 ?? 0,
|
|
103
|
+
volume24h: pair.volume?.h24 ?? 0,
|
|
104
|
+
liquidity: pair.liquidity?.usd ?? 0,
|
|
105
|
+
marketCap: pair.marketCap ?? 0,
|
|
64
106
|
dex: pair.dexId,
|
|
65
107
|
};
|
|
66
108
|
priceCache.set(cacheKey, result);
|
|
@@ -68,22 +110,26 @@ export async function getPrice(address, chain = 'solana') {
|
|
|
68
110
|
}
|
|
69
111
|
/** Search tokens by name or symbol */
|
|
70
112
|
export async function searchTokens(query) {
|
|
113
|
+
if (!query || query.trim().length === 0)
|
|
114
|
+
return [];
|
|
71
115
|
const data = await throttledFetch(`${BASE_URL}/latest/dex/search?q=${encodeURIComponent(query)}`);
|
|
72
|
-
return (data.pairs
|
|
116
|
+
return (data.pairs ?? []).slice(0, 20);
|
|
73
117
|
}
|
|
74
|
-
/** Get trending/boosted tokens */
|
|
118
|
+
/** Get trending/boosted tokens. Enriches with price data in parallel. */
|
|
75
119
|
export async function getTrending(chain = 'solana', limit = 20) {
|
|
120
|
+
const clampedLimit = Math.min(Math.max(1, limit), 50);
|
|
76
121
|
const cacheKey = `trending:${chain}`;
|
|
77
122
|
const cached = trendingCache.get(cacheKey);
|
|
78
123
|
if (cached)
|
|
79
|
-
return cached.slice(0,
|
|
124
|
+
return cached.slice(0, clampedLimit);
|
|
125
|
+
// Fetch both boost endpoints in parallel — catch individually so one failure doesn't block the other
|
|
80
126
|
const [topData, latestData] = await Promise.all([
|
|
81
127
|
throttledFetch(`${BASE_URL}/token-boosts/top/v1`).catch(() => []),
|
|
82
128
|
throttledFetch(`${BASE_URL}/token-boosts/latest/v1`).catch(() => []),
|
|
83
129
|
]);
|
|
84
130
|
const top = Array.isArray(topData) ? topData : [];
|
|
85
131
|
const latest = Array.isArray(latestData) ? latestData : [];
|
|
86
|
-
// Merge and deduplicate
|
|
132
|
+
// Merge and deduplicate by address
|
|
87
133
|
const seen = new Set();
|
|
88
134
|
const merged = [];
|
|
89
135
|
for (const token of [...top, ...latest]) {
|
|
@@ -97,21 +143,21 @@ export async function getTrending(chain = 'solana', limit = 20) {
|
|
|
97
143
|
symbol: token.description?.split('/')[0] || token.tokenAddress.slice(0, 6),
|
|
98
144
|
name: token.description || '',
|
|
99
145
|
chainId: token.chainId,
|
|
100
|
-
url: token.url,
|
|
146
|
+
url: token.url || '',
|
|
101
147
|
boostAmount: token.totalAmount || 0,
|
|
102
148
|
});
|
|
103
149
|
}
|
|
104
|
-
// Enrich top items with price data
|
|
105
|
-
const enriched = await Promise.all(merged.slice(0,
|
|
150
|
+
// Enrich top items with price data — all in parallel
|
|
151
|
+
const enriched = await Promise.all(merged.slice(0, clampedLimit).map(async (t) => {
|
|
106
152
|
try {
|
|
107
153
|
const price = await getPrice(t.address, chain);
|
|
108
|
-
return { ...t, ...price };
|
|
154
|
+
return price ? { ...t, ...price } : t;
|
|
109
155
|
}
|
|
110
156
|
catch {
|
|
111
157
|
return t;
|
|
112
158
|
}
|
|
113
159
|
}));
|
|
114
160
|
trendingCache.set(cacheKey, enriched);
|
|
115
|
-
return enriched.slice(0,
|
|
161
|
+
return enriched.slice(0, clampedLimit);
|
|
116
162
|
}
|
|
117
163
|
//# sourceMappingURL=dexscreener.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dexscreener.js","sourceRoot":"","sources":["../../src/sources/dexscreener.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAGtD,MAAM,QAAQ,GAAG,6BAA6B,CAAC;AAC/C,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAG,QAAQ;AAChD,MAAM,eAAe,GAAG,EAAE,GAAG,IAAI,CAAC,CAAO,MAAM;AAC/C,MAAM,kBAAkB,GAAG,EAAE,GAAG,IAAI,CAAC,CAAI,MAAM;
|
|
1
|
+
{"version":3,"file":"dexscreener.js","sourceRoot":"","sources":["../../src/sources/dexscreener.ts"],"names":[],"mappings":"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"}
|
package/dist/sources/gmgn.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { Holder } from '../types/index.js';
|
|
2
|
-
|
|
3
|
-
/** Get holders — try curl first, then Playwright */
|
|
4
|
-
export declare function getHolders(address: string): Promise<{
|
|
2
|
+
interface HolderResult {
|
|
5
3
|
holders: Holder[];
|
|
6
4
|
kolHolders: Holder[];
|
|
7
|
-
}
|
|
5
|
+
}
|
|
6
|
+
/** Returns true — curl fallback is always available, Playwright is optional */
|
|
7
|
+
export declare function isPlaywrightAvailable(): Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Get holders for a Solana token.
|
|
10
|
+
* Strategy: try curl first (fast, ~1s), fall back to Playwright (~11s).
|
|
11
|
+
*/
|
|
12
|
+
export declare function getHolders(address: string): Promise<HolderResult>;
|
|
13
|
+
/** Gracefully shut down the Playwright browser */
|
|
8
14
|
export declare function shutdown(): Promise<void>;
|
|
15
|
+
export {};
|
package/dist/sources/gmgn.js
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GMGN Data Source
|
|
3
|
-
* Strategy: try direct
|
|
4
|
-
* GMGN uses Cloudflare which blocks Node.js fetch but allows curl and
|
|
3
|
+
* Strategy: try direct API via curl first (fast), fall back to Playwright (slower but reliable).
|
|
4
|
+
* GMGN uses Cloudflare which blocks Node.js fetch but allows curl and real browsers.
|
|
5
5
|
*/
|
|
6
6
|
import { MemoryCache } from '../cache/memoryCache.js';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
8
|
const CACHE_TTL = 2 * 60 * 1000;
|
|
9
9
|
const BASE_URL = 'https://gmgn.ai/vas/api/v1';
|
|
10
10
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
11
|
-
const SCRAPE_TIMEOUT =
|
|
11
|
+
const SCRAPE_TIMEOUT = 40_000;
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
13
|
let browser = null;
|
|
13
14
|
let initializing = false;
|
|
14
15
|
const holderCache = new MemoryCache(CACHE_TTL);
|
|
15
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Try fetching via curl (bypasses Node.js TLS fingerprint detection).
|
|
18
|
+
* Returns parsed data on success, null on any failure.
|
|
19
|
+
*/
|
|
16
20
|
function curlFetch(path) {
|
|
17
21
|
try {
|
|
18
22
|
const url = `${BASE_URL}${path}`;
|
|
19
23
|
const cmd = `curl -s --max-time 10 "${url}" -H "User-Agent: ${UA}"`;
|
|
20
|
-
const out = execSync(cmd, { timeout:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const out = execSync(cmd, { timeout: 12_000 }).toString();
|
|
25
|
+
// Cloudflare sometimes returns HTML instead of JSON
|
|
26
|
+
if (!out || out.startsWith('<!') || out.startsWith('<html'))
|
|
27
|
+
return null;
|
|
28
|
+
let json;
|
|
29
|
+
try {
|
|
30
|
+
json = JSON.parse(out);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null; // Malformed JSON
|
|
34
|
+
}
|
|
35
|
+
if (json.code !== 0 || !json.data)
|
|
25
36
|
return null;
|
|
26
37
|
return json.data;
|
|
27
38
|
}
|
|
@@ -29,62 +40,91 @@ function curlFetch(path) {
|
|
|
29
40
|
return null;
|
|
30
41
|
}
|
|
31
42
|
}
|
|
32
|
-
/**
|
|
43
|
+
/** Safely extract a string array of tags from raw GMGN holder data */
|
|
44
|
+
function extractTags(h) {
|
|
45
|
+
const raw = [];
|
|
46
|
+
if (Array.isArray(h.tags))
|
|
47
|
+
raw.push(...h.tags);
|
|
48
|
+
if (h.wallet_tag_v2) {
|
|
49
|
+
const wt = Array.isArray(h.wallet_tag_v2) ? h.wallet_tag_v2 : [h.wallet_tag_v2];
|
|
50
|
+
raw.push(...wt);
|
|
51
|
+
}
|
|
52
|
+
if (h.maker_token_tags) {
|
|
53
|
+
const mt = Array.isArray(h.maker_token_tags) ? h.maker_token_tags : [h.maker_token_tags];
|
|
54
|
+
raw.push(...mt);
|
|
55
|
+
}
|
|
56
|
+
const normalized = raw.map(t => {
|
|
57
|
+
if (typeof t === 'string')
|
|
58
|
+
return t.trim();
|
|
59
|
+
if (typeof t === 'object' && t !== null) {
|
|
60
|
+
const obj = t;
|
|
61
|
+
return String(obj.name ?? obj.label ?? '').trim();
|
|
62
|
+
}
|
|
63
|
+
return '';
|
|
64
|
+
}).filter(Boolean);
|
|
65
|
+
return [...new Set(normalized)];
|
|
66
|
+
}
|
|
67
|
+
/** Normalize raw GMGN holder array into typed Holder objects */
|
|
33
68
|
function normalizeHolders(rawList) {
|
|
34
69
|
if (!Array.isArray(rawList))
|
|
35
70
|
return [];
|
|
36
|
-
return rawList
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return t.name || t.label || '';
|
|
51
|
-
return String(t).trim();
|
|
52
|
-
}).filter(Boolean))];
|
|
71
|
+
return rawList
|
|
72
|
+
.filter((item) => typeof item === 'object' && item !== null)
|
|
73
|
+
.map(h => {
|
|
74
|
+
const num = (key) => {
|
|
75
|
+
const v = h[key];
|
|
76
|
+
return typeof v === 'number' ? v : 0;
|
|
77
|
+
};
|
|
78
|
+
const str = (key) => {
|
|
79
|
+
const v = h[key];
|
|
80
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
81
|
+
};
|
|
82
|
+
const unrealizedPnl = num('unrealized_profit') || num('unrealized_pnl');
|
|
83
|
+
const realizedPnl = num('realized_profit') || num('realized_pnl');
|
|
84
|
+
const lastTs = h.last_active_timestamp;
|
|
53
85
|
return {
|
|
54
|
-
address: h.address
|
|
55
|
-
tags,
|
|
56
|
-
twitterHandle:
|
|
57
|
-
twitterName:
|
|
58
|
-
name:
|
|
59
|
-
balance:
|
|
60
|
-
supplyPercent:
|
|
61
|
-
valueUsd:
|
|
62
|
-
avgBuyPrice:
|
|
63
|
-
cost:
|
|
64
|
-
unrealizedPnl
|
|
65
|
-
realizedPnl
|
|
66
|
-
totalPnl:
|
|
67
|
-
profitMultiple:
|
|
68
|
-
buyAmount:
|
|
69
|
-
sellAmount:
|
|
70
|
-
buyTxCount:
|
|
71
|
-
sellTxCount:
|
|
72
|
-
isDeployer: h.is_deployer
|
|
73
|
-
isFreshWallet: h.is_new || h.is_fresh_wallet
|
|
74
|
-
lastActiveAt:
|
|
86
|
+
address: String(h.address ?? ''),
|
|
87
|
+
tags: extractTags(h),
|
|
88
|
+
twitterHandle: str('twitter_username'),
|
|
89
|
+
twitterName: str('twitter_name'),
|
|
90
|
+
name: str('name'),
|
|
91
|
+
balance: num('balance') || num('amount_cur'),
|
|
92
|
+
supplyPercent: num('amount_percentage'),
|
|
93
|
+
valueUsd: num('usd_value'),
|
|
94
|
+
avgBuyPrice: num('avg_cost'),
|
|
95
|
+
cost: num('cost_cur') || num('cost') || num('total_cost'),
|
|
96
|
+
unrealizedPnl,
|
|
97
|
+
realizedPnl,
|
|
98
|
+
totalPnl: unrealizedPnl + realizedPnl,
|
|
99
|
+
profitMultiple: num('profit_change'),
|
|
100
|
+
buyAmount: num('buy_amount_cur') || num('accu_amount'),
|
|
101
|
+
sellAmount: num('sell_amount_cur') || num('current_sell_amount'),
|
|
102
|
+
buyTxCount: num('buy_tx_count_cur'),
|
|
103
|
+
sellTxCount: num('sell_tx_count_cur'),
|
|
104
|
+
isDeployer: h.is_deployer === true,
|
|
105
|
+
isFreshWallet: h.is_new === true || h.is_fresh_wallet === true,
|
|
106
|
+
lastActiveAt: typeof lastTs === 'number' && lastTs > 0
|
|
107
|
+
? new Date(lastTs * 1000).toISOString()
|
|
108
|
+
: null,
|
|
75
109
|
};
|
|
76
|
-
})
|
|
110
|
+
})
|
|
111
|
+
.filter(h => h.address.length > 10);
|
|
77
112
|
}
|
|
78
|
-
/**
|
|
113
|
+
/** Launch or reconnect the Playwright browser instance */
|
|
79
114
|
async function ensureBrowser() {
|
|
80
115
|
if (browser?.isConnected?.())
|
|
81
116
|
return;
|
|
82
117
|
browser = null;
|
|
83
118
|
if (initializing) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
// Wait for another caller to finish initializing
|
|
120
|
+
await new Promise(resolve => {
|
|
121
|
+
const iv = setInterval(() => {
|
|
122
|
+
if (!initializing) {
|
|
123
|
+
clearInterval(iv);
|
|
124
|
+
resolve();
|
|
125
|
+
}
|
|
126
|
+
}, 200);
|
|
127
|
+
});
|
|
88
128
|
return;
|
|
89
129
|
}
|
|
90
130
|
initializing = true;
|
|
@@ -95,74 +135,99 @@ async function ensureBrowser() {
|
|
|
95
135
|
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
96
136
|
});
|
|
97
137
|
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
throw new Error(`Failed to launch Playwright browser: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
140
|
+
'Install with: npm install playwright && npx playwright install chromium');
|
|
141
|
+
}
|
|
98
142
|
finally {
|
|
99
143
|
initializing = false;
|
|
100
144
|
}
|
|
101
145
|
}
|
|
146
|
+
/** Playwright fallback: navigate to GMGN token page and intercept holder API responses */
|
|
102
147
|
async function playwrightFetch(address) {
|
|
103
148
|
await ensureBrowser();
|
|
104
|
-
|
|
149
|
+
if (!browser)
|
|
150
|
+
throw new Error('Playwright browser not available');
|
|
151
|
+
const ctx = await browser.newContext({
|
|
152
|
+
userAgent: UA,
|
|
153
|
+
viewport: { width: 1280, height: 720 },
|
|
154
|
+
});
|
|
105
155
|
const page = await ctx.newPage();
|
|
106
156
|
try {
|
|
107
|
-
return await new Promise(
|
|
108
|
-
const timeout = setTimeout(() => reject(new Error('GMGN scrape timeout')), SCRAPE_TIMEOUT);
|
|
109
|
-
|
|
157
|
+
return await new Promise((resolve, reject) => {
|
|
158
|
+
const timeout = setTimeout(() => reject(new Error('GMGN scrape timeout (40s)')), SCRAPE_TIMEOUT);
|
|
159
|
+
const collected = [];
|
|
110
160
|
let resolved = false;
|
|
161
|
+
const finish = (items) => {
|
|
162
|
+
if (resolved)
|
|
163
|
+
return;
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
resolved = true;
|
|
166
|
+
resolve(items);
|
|
167
|
+
};
|
|
111
168
|
page.on('response', async (response) => {
|
|
112
169
|
if (resolved)
|
|
113
170
|
return;
|
|
114
171
|
const url = response.url();
|
|
115
|
-
if (url.includes('/token_holders/sol/')
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
172
|
+
if (!url.includes('/token_holders/sol/') && !url.includes('/top_holders/sol/'))
|
|
173
|
+
return;
|
|
174
|
+
try {
|
|
175
|
+
const json = await response.json();
|
|
176
|
+
const data = json.data;
|
|
177
|
+
const items = Array.isArray(data) ? data
|
|
178
|
+
: (data && typeof data === 'object'
|
|
179
|
+
? (Array.isArray(data.holders) ? data.holders
|
|
180
|
+
: Array.isArray(data.list) ? data.list
|
|
181
|
+
: [])
|
|
182
|
+
: []);
|
|
183
|
+
if (Array.isArray(items) && items.length > 0) {
|
|
184
|
+
collected.push(...items);
|
|
185
|
+
if (collected.length >= 10)
|
|
186
|
+
finish(collected.slice(0, 150));
|
|
127
187
|
}
|
|
128
|
-
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Response wasn't valid JSON, skip
|
|
129
191
|
}
|
|
130
192
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
193
|
+
(async () => {
|
|
194
|
+
try {
|
|
195
|
+
await page.goto(`https://gmgn.ai/sol/token/${address}`, {
|
|
196
|
+
waitUntil: 'domcontentloaded',
|
|
197
|
+
timeout: 20_000,
|
|
198
|
+
});
|
|
199
|
+
await page.waitForTimeout(3000);
|
|
200
|
+
// Try clicking the Holders tab
|
|
201
|
+
for (const sel of ['button:has-text("Holder")', '[role="tab"]:has-text("Holder")', 'text=Holders']) {
|
|
202
|
+
try {
|
|
203
|
+
const tab = await page.$(sel);
|
|
204
|
+
if (tab) {
|
|
205
|
+
await tab.click();
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
continue;
|
|
140
211
|
}
|
|
141
212
|
}
|
|
142
|
-
|
|
143
|
-
|
|
213
|
+
await page.waitForTimeout(5000);
|
|
214
|
+
if (!resolved && collected.length > 0)
|
|
215
|
+
finish(collected.slice(0, 150));
|
|
216
|
+
if (!resolved) {
|
|
217
|
+
await page.waitForTimeout(5000);
|
|
218
|
+
if (collected.length > 0)
|
|
219
|
+
finish(collected.slice(0, 150));
|
|
144
220
|
}
|
|
221
|
+
if (!resolved)
|
|
222
|
+
reject(new Error('GMGN returned no holder data via Playwright'));
|
|
145
223
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
clearTimeout(timeout);
|
|
149
|
-
resolved = true;
|
|
150
|
-
resolve(collected.slice(0, 150));
|
|
151
|
-
}
|
|
152
|
-
if (!resolved) {
|
|
153
|
-
await page.waitForTimeout(5000);
|
|
154
|
-
if (collected.length > 0) {
|
|
224
|
+
catch (e) {
|
|
225
|
+
if (!resolved) {
|
|
155
226
|
clearTimeout(timeout);
|
|
156
|
-
|
|
227
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
157
228
|
}
|
|
158
229
|
}
|
|
159
|
-
}
|
|
160
|
-
catch (e) {
|
|
161
|
-
if (!resolved) {
|
|
162
|
-
clearTimeout(timeout);
|
|
163
|
-
reject(e);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
230
|
+
})();
|
|
166
231
|
});
|
|
167
232
|
}
|
|
168
233
|
finally {
|
|
@@ -170,20 +235,29 @@ async function playwrightFetch(address) {
|
|
|
170
235
|
await ctx.close().catch(() => { });
|
|
171
236
|
}
|
|
172
237
|
}
|
|
238
|
+
/** Returns true — curl fallback is always available, Playwright is optional */
|
|
173
239
|
export async function isPlaywrightAvailable() {
|
|
174
|
-
return true;
|
|
240
|
+
return true;
|
|
175
241
|
}
|
|
176
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* Get holders for a Solana token.
|
|
244
|
+
* Strategy: try curl first (fast, ~1s), fall back to Playwright (~11s).
|
|
245
|
+
*/
|
|
177
246
|
export async function getHolders(address) {
|
|
247
|
+
if (!address || address.trim().length === 0) {
|
|
248
|
+
throw new Error('Token address is required for holder analysis');
|
|
249
|
+
}
|
|
178
250
|
const cached = holderCache.get(address);
|
|
179
251
|
if (cached)
|
|
180
252
|
return cached;
|
|
181
253
|
// Strategy 1: curl (fast, ~1s)
|
|
182
254
|
const curlData = curlFetch(`/token_holders/sol/${address}?limit=100`);
|
|
183
|
-
if (curlData
|
|
255
|
+
if (curlData && Array.isArray(curlData.list) &&
|
|
256
|
+
curlData.list.length > 0) {
|
|
257
|
+
// Fetch KOL holders in parallel-ish (curl is sync but fast)
|
|
184
258
|
const kolData = curlFetch(`/token_holders/sol/${address}?tag=renowned&limit=50`);
|
|
185
259
|
const holders = normalizeHolders(curlData.list);
|
|
186
|
-
const kolHolders = normalizeHolders(kolData
|
|
260
|
+
const kolHolders = normalizeHolders(kolData ? kolData.list : []);
|
|
187
261
|
const result = { holders, kolHolders };
|
|
188
262
|
holderCache.set(address, result);
|
|
189
263
|
return result;
|
|
@@ -192,15 +266,15 @@ export async function getHolders(address) {
|
|
|
192
266
|
try {
|
|
193
267
|
const rawHolders = await playwrightFetch(address);
|
|
194
268
|
const holders = normalizeHolders(rawHolders);
|
|
195
|
-
// KOL fetch via page.evaluate inside Playwright context
|
|
196
269
|
const result = { holders, kolHolders: [] };
|
|
197
270
|
holderCache.set(address, result);
|
|
198
271
|
return result;
|
|
199
272
|
}
|
|
200
273
|
catch (err) {
|
|
201
|
-
throw new Error(`GMGN fetch failed: ${err.message}`);
|
|
274
|
+
throw new Error(`GMGN fetch failed for ${address}: ${err instanceof Error ? err.message : String(err)}`);
|
|
202
275
|
}
|
|
203
276
|
}
|
|
277
|
+
/** Gracefully shut down the Playwright browser */
|
|
204
278
|
export async function shutdown() {
|
|
205
279
|
if (browser) {
|
|
206
280
|
await browser.close().catch(() => { });
|