@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.
Files changed (39) hide show
  1. package/README.md +60 -39
  2. package/dist/analysis/formatOutput.d.ts +113 -2
  3. package/dist/analysis/formatOutput.js +135 -100
  4. package/dist/analysis/formatOutput.js.map +1 -1
  5. package/dist/analysis/riskScorer.d.ts +24 -2
  6. package/dist/analysis/riskScorer.js +146 -48
  7. package/dist/analysis/riskScorer.js.map +1 -1
  8. package/dist/analysis/summaryGenerator.d.ts +3 -2
  9. package/dist/analysis/summaryGenerator.js +125 -42
  10. package/dist/analysis/summaryGenerator.js.map +1 -1
  11. package/dist/cache/memoryCache.d.ts +12 -3
  12. package/dist/cache/memoryCache.js +43 -2
  13. package/dist/cache/memoryCache.js.map +1 -1
  14. package/dist/server.d.ts +3 -2
  15. package/dist/server.js +28 -16
  16. package/dist/server.js.map +1 -1
  17. package/dist/sources/dexscreener.d.ts +6 -17
  18. package/dist/sources/dexscreener.js +72 -26
  19. package/dist/sources/dexscreener.js.map +1 -1
  20. package/dist/sources/gmgn.d.ts +11 -4
  21. package/dist/sources/gmgn.js +179 -105
  22. package/dist/sources/gmgn.js.map +1 -1
  23. package/dist/sources/solanaRpc.d.ts +4 -4
  24. package/dist/sources/solanaRpc.js +60 -29
  25. package/dist/sources/solanaRpc.js.map +1 -1
  26. package/dist/tools/analyzeToken.d.ts +1 -2
  27. package/dist/tools/analyzeToken.js +114 -100
  28. package/dist/tools/analyzeToken.js.map +1 -1
  29. package/dist/tools/getPrice.d.ts +2 -12
  30. package/dist/tools/getPrice.js +23 -4
  31. package/dist/tools/getPrice.js.map +1 -1
  32. package/dist/tools/getTopTraders.d.ts +30 -24
  33. package/dist/tools/getTopTraders.js +36 -21
  34. package/dist/tools/getTopTraders.js.map +1 -1
  35. package/dist/tools/getTrending.d.ts +8 -1
  36. package/dist/tools/getTrending.js +12 -2
  37. package/dist/tools/getTrending.js.map +1 -1
  38. package/dist/types/index.d.ts +34 -0
  39. package/package.json +18 -2
@@ -1,49 +1,90 @@
1
1
  /**
2
2
  * DexScreener API Client
3
- * Pattern from TradingBox dexScreenerService.js
4
- * Free API, no auth needed. Rate limit: ~300 req/min (we cap at 150)
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
- const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
21
- if (!res.ok)
22
- throw new Error(`DexScreener ${res.status}: ${res.statusText}`);
23
- return res.json();
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 || 0) - (a.liquidity?.usd || 0));
40
- const best = chainPairs[0] || pairs[0] || null;
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: parseFloat(pair.priceUsd) || 0,
59
- priceChange1h: pair.priceChange?.h1 || 0,
60
- priceChange24h: pair.priceChange?.h24 || 0,
61
- volume24h: pair.volume?.h24 || 0,
62
- liquidity: pair.liquidity?.usd || 0,
63
- marketCap: pair.marketCap || 0,
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 || []).slice(0, 20);
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, limit);
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, limit).map(async (t) => {
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, limit);
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":"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"}
1
+ {"version":3,"file":"dexscreener.js","sourceRoot":"","sources":["../../src/sources/dexscreener.ts"],"names":[],"mappings":"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"}
@@ -1,8 +1,15 @@
1
1
  import type { Holder } from '../types/index.js';
2
- export declare function isPlaywrightAvailable(): Promise<boolean>;
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 {};
@@ -1,27 +1,38 @@
1
1
  /**
2
2
  * GMGN Data Source
3
- * Strategy: try direct VAS API first (fast), fall back to Playwright (slower but reliable)
4
- * GMGN uses Cloudflare which blocks Node.js fetch but allows curl and Playwright
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 = 40000;
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
- /** Try fetching via curl (bypasses Node.js TLS fingerprint detection) */
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: 12000 }).toString();
21
- if (out.startsWith('<!'))
22
- return null; // Cloudflare HTML
23
- const json = JSON.parse(out);
24
- if (json.code !== 0)
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
- /** Normalize GMGN holder data */
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.map(h => {
37
- let tags = [];
38
- if (Array.isArray(h.tags))
39
- tags.push(...h.tags);
40
- if (h.wallet_tag_v2) {
41
- const wt = Array.isArray(h.wallet_tag_v2) ? h.wallet_tag_v2 : [h.wallet_tag_v2];
42
- tags.push(...wt);
43
- }
44
- if (h.maker_token_tags) {
45
- const mt = Array.isArray(h.maker_token_tags) ? h.maker_token_tags : [h.maker_token_tags];
46
- tags.push(...mt);
47
- }
48
- tags = [...new Set(tags.map(t => {
49
- if (typeof t === 'object' && t !== null)
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: h.twitter_username || null,
57
- twitterName: h.twitter_name || null,
58
- name: h.name || null,
59
- balance: h.balance || h.amount_cur || 0,
60
- supplyPercent: h.amount_percentage || 0,
61
- valueUsd: h.usd_value || 0,
62
- avgBuyPrice: h.avg_cost || 0,
63
- cost: h.cost_cur || h.cost || h.total_cost || 0,
64
- unrealizedPnl: h.unrealized_profit || h.unrealized_pnl || 0,
65
- realizedPnl: h.realized_profit || h.realized_pnl || 0,
66
- totalPnl: (h.unrealized_profit || 0) + (h.realized_profit || 0),
67
- profitMultiple: h.profit_change || 0,
68
- buyAmount: h.buy_amount_cur || h.accu_amount || 0,
69
- sellAmount: h.sell_amount_cur || h.current_sell_amount || 0,
70
- buyTxCount: h.buy_tx_count_cur || 0,
71
- sellTxCount: h.sell_tx_count_cur || 0,
72
- isDeployer: h.is_deployer || false,
73
- isFreshWallet: h.is_new || h.is_fresh_wallet || false,
74
- lastActiveAt: h.last_active_timestamp ? new Date(h.last_active_timestamp * 1000).toISOString() : null,
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
- }).filter(h => h.address && h.address.length > 10);
110
+ })
111
+ .filter(h => h.address.length > 10);
77
112
  }
78
- /** Playwright fallback intercept GMGN API via browser */
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
- await new Promise(r => { const iv = setInterval(() => { if (!initializing) {
85
- clearInterval(iv);
86
- r();
87
- } }, 200); });
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
- const ctx = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 720 } });
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(async (resolve, reject) => {
108
- const timeout = setTimeout(() => reject(new Error('GMGN scrape timeout')), SCRAPE_TIMEOUT);
109
- let collected = [];
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/') || url.includes('/top_holders/sol/')) {
116
- try {
117
- const json = await response.json();
118
- const items = Array.isArray(json.data) ? json.data : (json.data?.holders || json.data?.list || []);
119
- if (items.length > 0) {
120
- collected.push(...items);
121
- if (collected.length >= 10) {
122
- clearTimeout(timeout);
123
- resolved = true;
124
- resolve(collected.slice(0, 150));
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
- catch { }
188
+ }
189
+ catch {
190
+ // Response wasn't valid JSON, skip
129
191
  }
130
192
  });
131
- try {
132
- await page.goto(`https://gmgn.ai/sol/token/${address}`, { waitUntil: 'domcontentloaded', timeout: 20000 });
133
- await page.waitForTimeout(3000);
134
- for (const sel of ['button:has-text("Holder")', '[role="tab"]:has-text("Holder")', 'text=Holders']) {
135
- try {
136
- const t = await page.$(sel);
137
- if (t) {
138
- await t.click();
139
- break;
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
- catch {
143
- continue;
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
- await page.waitForTimeout(5000);
147
- if (!resolved && collected.length > 0) {
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
- resolve(collected.slice(0, 150));
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; // curl fallback always available, Playwright optional
240
+ return true;
175
241
  }
176
- /** Get holders — try curl first, then Playwright */
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?.list?.length > 0) {
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?.list || []);
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(() => { });