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();