Bot & Automation
Market AI
/root/hermes-projects/Market AI
packages/provider-adapters/src/dexscreener.ts
text
import {
filterCandidates,
logger,
mergeScannerCriteria,
scoreMemeCandidate,
type MemeCoinCandidate,
type ScannerCriteria,
} from "@market-ai/core";
import type { MemeMarketDataAdapter } from "./provider-types.js";
interface DexTokenProfile {
chainId?: string;
tokenAddress?: string;
url?: string;
description?: string;
}
interface DexPair {
chainId?: string;
dexId?: string;
url?: string;
pairAddress?: string;
baseToken?: { address?: string; name?: string; symbol?: string };
quoteToken?: { address?: string; name?: string; symbol?: string };
priceUsd?: string;
txns?: { h24?: { buys?: number; sells?: number } };
volume?: { h24?: number };
priceChange?: { m5?: number; h1?: number; h6?: number; h24?: number };
liquidity?: { usd?: number };
fdv?: number;
marketCap?: number;
pairCreatedAt?: number;
}
interface DexSearchResponse {
pairs?: DexPair[];
}
const DEFAULT_BASE_URL = "https://api.dexscreener.com";
async function fetchJson<T>(url: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Number(process.env.PROVIDER_DEFAULT_TIMEOUT_MS ?? 15000));
try {
const response = await fetch(url, {
headers: { accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`DexScreener request failed with ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
}
function isLikelyAddress(query: string): boolean {
return /^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/.test(query.trim());
}
function toCandidate(pair: DexPair): MemeCoinCandidate | undefined {
if (!pair.chainId || !pair.baseToken?.address || !pair.baseToken.symbol || !pair.baseToken.name) return undefined;
return scoreMemeCandidate({
id: `${pair.chainId}:${pair.baseToken.address}:${pair.pairAddress ?? "pair"}`,
token: {
chainId: pair.chainId,
tokenAddress: pair.baseToken.address,
symbol: pair.baseToken.symbol,
name: pair.baseToken.name,
pairAddress: pair.pairAddress,
dexId: pair.dexId,
url: pair.url,
},
metrics: {
priceUsd: Number(pair.priceUsd ?? 0),
liquidityUsd: Number(pair.liquidity?.usd ?? 0),
fdv: pair.fdv,
marketCap: pair.marketCap,
volumeH24: Number(pair.volume?.h24 ?? 0),
buysH24: Number(pair.txns?.h24?.buys ?? 0),
sellsH24: Number(pair.txns?.h24?.sells ?? 0),
priceChangeM5: pair.priceChange?.m5,
priceChangeH1: pair.priceChange?.h1,
priceChangeH6: pair.priceChange?.h6,
priceChangeH24: pair.priceChange?.h24,
pairCreatedAt: pair.pairCreatedAt ? new Date(pair.pairCreatedAt).toISOString() : undefined,
},
score: 0,
verdict: "RESEARCH",
riskLevel: "High",
labels: [],
warnings: [],
source: "dexscreener",
scannedAt: new Date().toISOString(),
});
}
function bestCandidateFromPairs(pairs: DexPair[]): MemeCoinCandidate | undefined {
return pairs
.map(toCandidate)
.filter((candidate): candidate is MemeCoinCandidate => Boolean(candidate))
.sort((a, b) => b.metrics.liquidityUsd - a.metrics.liquidityUsd)[0];
}
export class DexScreenerAdapter implements MemeMarketDataAdapter {
id = "dexscreener";
displayName = "DexScreener";
private readonly baseUrl = process.env.DEXSCREENER_BASE_URL ?? DEFAULT_BASE_URL;
async scanMemeCoins(criteria?: ScannerCriteria): Promise<MemeCoinCandidate[]> {
const merged = mergeScannerCriteria(criteria);
const profiles = await fetchJson<DexTokenProfile[]>(`${this.baseUrl}/token-profiles/latest/v1`);
const profileLimit = Number(process.env.DEXSCREENER_PROFILE_SCAN_LIMIT ?? 30);
const candidates: MemeCoinCandidate[] = [];
for (const profile of profiles.filter((item) => item.chainId === merged.chainId && item.tokenAddress).slice(0, profileLimit)) {
try {
const pairs = await fetchJson<DexPair[]>(`${this.baseUrl}/token-pairs/v1/${profile.chainId}/${profile.tokenAddress}`);
const candidate = bestCandidateFromPairs(pairs);
if (candidate) candidates.push(candidate);
} catch (error) {
logger.error("dexscreener_pair_fetch_failed", {
chainId: profile.chainId,
tokenAddress: profile.tokenAddress,
message: error instanceof Error ? error.message : "unknown",
});
}
}
const filtered = filterCandidates(candidates, criteria);
if (filtered.length > 0) return filtered;
const fallbackCandidates: MemeCoinCandidate[] = [];
for (const query of ["BONK", "WIF", "POPCAT", "PEPE"]) {
const found = await this.searchMemeCoins(query, {
...criteria,
chainId: merged.chainId,
limit: 3,
maxAgeHours: 3650,
minLiquidityUsd: criteria?.minLiquidityUsd ?? merged.minLiquidityUsd,
minVolumeH24: criteria?.minVolumeH24 ?? merged.minVolumeH24,
minScore: criteria?.minScore ?? merged.minScore,
});
fallbackCandidates.push(...found);
}
return filterCandidates(fallbackCandidates, { ...criteria, maxAgeHours: 3650 });
}
async searchMemeCoins(query: string, criteria?: ScannerCriteria): Promise<MemeCoinCandidate[]> {
const merged = mergeScannerCriteria(criteria);
const result = await fetchJson<DexSearchResponse>(`${this.baseUrl}/latest/dex/search?q=${encodeURIComponent(query)}`);
const candidates = (result.pairs ?? [])
.filter((pair) => !merged.chainId || pair.chainId === merged.chainId)
.map(toCandidate)
.filter((candidate): candidate is MemeCoinCandidate => Boolean(candidate));
return filterCandidates(candidates, { ...criteria, minLiquidityUsd: criteria?.minLiquidityUsd ?? 0, minVolumeH24: criteria?.minVolumeH24 ?? 0, minScore: criteria?.minScore ?? 0 });
}
async getMemeCandidate(query: string, chainId = "solana"): Promise<MemeCoinCandidate> {
if (isLikelyAddress(query)) {
const pairs = await fetchJson<DexPair[]>(`${this.baseUrl}/token-pairs/v1/${chainId}/${query}`);
const candidate = bestCandidateFromPairs(pairs);
if (candidate) return candidate;
}
const [candidate] = await this.searchMemeCoins(query, {
chainId,
limit: 1,
minLiquidityUsd: 0,
minVolumeH24: 0,
minScore: 0,
maxAgeHours: 3650,
});
if (!candidate) {
throw new Error("Token meme coin tidak ditemukan di DexScreener.");
}
return candidate;
}
}
export const dexScreenerAdapter = new DexScreenerAdapter();