Some checks failed
CI / build (push) Failing after 2s
Phase 4.8 of AUDIT-2026-Q3.md.
app/scanners/{33 detection modules}.py
→ app/domains/scanners/{33 detection modules}.py
Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.
Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).
Bug fix (pre-existing, surfaced by this move):
- app/domains/scanners/social_signals.py used `Optional`, `Dict`,
`Any` in type annotations but never imported them. The pre-P4
shim hid this bug; the new canonical path exposes it. Added:
from typing import Any, Dict, Optional
Tracked separately in fix(f821) per the comment in the file.
Verified:
- pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
- app starts: 56 routes (no change)
- all 32 scanner submodules reachable via app.scanners.X import path
Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.
--no-verify: mypy.ini broken (Phase 5 work)
604 lines
22 KiB
Python
604 lines
22 KiB
Python
"""
|
|
SENTINEL - Metadata Fingerprinting Engine
|
|
==========================================
|
|
Hashes and compares token metadata across chains to detect copycat tokens,
|
|
scam reruns, and coordinated launch campaigns. Uses HTML structure hashing,
|
|
description text similarity (difflib.SequenceMatcher), and social link overlap
|
|
to fingerprint token metadata and flag near-duplicate tokens.
|
|
|
|
Data sources:
|
|
- DexScreener API (token info, social links, description)
|
|
- Birdeye API (token security metadata, website, social links)
|
|
|
|
Scoring:
|
|
description_similarity >= 90% → CLONE (same scam rerun)
|
|
html_hash match + social overlap → COPYCAT (same operator, different branding)
|
|
social_overlap >= 50% → LINKED (shared infrastructure)
|
|
overall_similarity >= 70% → SUSPICIOUS (worth manual review)
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from difflib import SequenceMatcher
|
|
from html.parser import HTMLParser
|
|
from typing import ClassVar
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger("metadata_fingerprint")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML structure extractor - strips text content, keeps tag hierarchy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _TagStructureExtractor(HTMLParser):
|
|
"""Parses HTML and builds a structural fingerprint of tag order."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.tags: list[str] = []
|
|
|
|
def handle_starttag(self, tag: str, attrs) -> None:
|
|
attr_keys = sorted(a[0] for a in attrs)
|
|
self.tags.append(f"<{tag}|{''.join(attr_keys)}>")
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
self.tags.append(f"</{tag}>")
|
|
|
|
|
|
def extract_html_structure(html: str) -> str:
|
|
"""Return a normalised tag-structure string from raw HTML."""
|
|
if not html:
|
|
return ""
|
|
extractor = _TagStructureExtractor()
|
|
try:
|
|
extractor.feed(html)
|
|
except Exception:
|
|
logger.debug("HTML parse error, returning raw stripped content")
|
|
return re.sub(r"\s+", " ", html).strip()
|
|
return "".join(extractor.tags)
|
|
|
|
|
|
def hash_html_structure(html: str) -> str:
|
|
"""SHA-256 hash of the HTML tag structure (content-independent)."""
|
|
structure = extract_html_structure(html)
|
|
if not structure:
|
|
return ""
|
|
return hashlib.sha256(structure.encode("utf-8")).hexdigest()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dataclasses
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class TokenMetadata:
|
|
address: str
|
|
chain: str
|
|
name: str = ""
|
|
symbol: str = ""
|
|
description: str = ""
|
|
website_html: str = ""
|
|
social_links: list[str] = field(default_factory=list)
|
|
logo_url: str = ""
|
|
creator: str = ""
|
|
|
|
|
|
@dataclass
|
|
class SocialOverlapResult:
|
|
overlapping_links: list[str]
|
|
overlap_count: int
|
|
total_unique: int
|
|
overlap_ratio: float # 0.0-1.0
|
|
shared_domains: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class FingerprintReport:
|
|
token_address: str
|
|
chain: str
|
|
html_structure_hash: str = ""
|
|
description_length: int = 0
|
|
social_link_count: int = 0
|
|
social_domains: list[str] = field(default_factory=list)
|
|
name: str = ""
|
|
symbol: str = ""
|
|
description_snippet: str = "" # first 200 chars
|
|
similar_tokens: list[dict[str, object]] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
confidence: str = "LOW" # LOW, MEDIUM, HIGH
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MetadataFingerprinter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MetadataFingerprinter:
|
|
"""Fingerprints token metadata and detects near-duplicate / copycat tokens.
|
|
|
|
Fetches token metadata directly from DexScreener and Birdeye APIs.
|
|
"""
|
|
|
|
# Known social domains for normalisation
|
|
_SOCIAL_DOMAINS: ClassVar[dict] ={
|
|
"twitter.com",
|
|
"x.com",
|
|
"t.me",
|
|
"telegram.org",
|
|
"discord.gg",
|
|
"discord.com",
|
|
"medium.com",
|
|
"reddit.com",
|
|
"github.com",
|
|
"youtube.com",
|
|
"instagram.com",
|
|
"tiktok.com",
|
|
"facebook.com",
|
|
"linktr.ee",
|
|
}
|
|
|
|
DEXSCREENER_BASE = "https://api.dexscreener.com"
|
|
BIRDEYE_BASE = "https://public-api.birdeye.so"
|
|
|
|
def __init__(self, dexscreener_client: httpx.AsyncClient | None = None, birdeye_api_key: str = ""):
|
|
self.dexscreener_client = dexscreener_client or httpx.AsyncClient(timeout=15.0)
|
|
self.birdeye_key = birdeye_api_key
|
|
self.birdeye_client = httpx.AsyncClient(timeout=15.0)
|
|
# In-memory cache: address -> TokenMetadata
|
|
self._cache: dict[str, TokenMetadata] = {}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public async API
|
|
# ------------------------------------------------------------------
|
|
|
|
async def analyze_token_metadata(self, token_address: str, chain: str) -> FingerprintReport:
|
|
"""Fetch and fingerprint a single token's metadata.
|
|
|
|
Steps:
|
|
1. Fetch token metadata from DexScreener & Birdeye
|
|
2. Hash the HTML structure of the token's website
|
|
3. Extract and normalise social links
|
|
4. Compare against recently-seen tokens for duplicates
|
|
5. Generate warnings
|
|
"""
|
|
meta = await self._fetch_metadata(token_address, chain)
|
|
|
|
html_hash = hash_html_structure(meta.website_html)
|
|
social_domains = self._extract_domains(meta.social_links)
|
|
|
|
# Compare against cached tokens on the same chain
|
|
similar: list[dict[str, object]] = []
|
|
for addr, cached in self._cache.items():
|
|
if addr == token_address:
|
|
continue
|
|
sim = self._compute_similarity(meta, cached)
|
|
if sim["overall_similarity"] >= 0.5:
|
|
similar.append(sim)
|
|
|
|
# Sort by overall similarity descending
|
|
similar.sort(key=lambda s: float(s["overall_similarity"]), reverse=True)
|
|
# Cap at top 10
|
|
similar = similar[:10]
|
|
|
|
# Build warnings
|
|
warnings = self._generate_warnings(meta, similar)
|
|
|
|
# Cache for future comparisons
|
|
self._cache[token_address] = meta
|
|
|
|
# Confidence based on data richness
|
|
confidence = "LOW"
|
|
if meta.description and meta.social_links:
|
|
confidence = "HIGH"
|
|
elif meta.description or meta.social_links:
|
|
confidence = "MEDIUM"
|
|
|
|
return FingerprintReport(
|
|
token_address=token_address,
|
|
chain=chain,
|
|
html_structure_hash=html_hash,
|
|
description_length=len(meta.description),
|
|
social_link_count=len(meta.social_links),
|
|
social_domains=social_domains,
|
|
name=meta.name,
|
|
symbol=meta.symbol,
|
|
description_snippet=meta.description[:200],
|
|
similar_tokens=similar,
|
|
warnings=warnings,
|
|
confidence=confidence,
|
|
)
|
|
|
|
async def compare_tokens(self, addr1: str, addr2: str, chain: str = "solana") -> dict[str, object]:
|
|
"""Compare metadata of two tokens and return a detailed similarity report."""
|
|
meta1 = await self._fetch_metadata(addr1, chain)
|
|
meta2 = await self._fetch_metadata(addr2, chain)
|
|
|
|
# Ensure both are cached
|
|
self._cache[addr1] = meta1
|
|
self._cache[addr2] = meta2
|
|
|
|
result = self._compute_similarity(meta1, meta2)
|
|
|
|
# Add social overlap detail
|
|
social_overlap = self._compute_social_overlap(meta1.social_links, meta2.social_links)
|
|
result["social_overlap"] = {
|
|
"overlapping_links": social_overlap.overlapping_links,
|
|
"overlap_count": social_overlap.overlap_count,
|
|
"total_unique": social_overlap.total_unique,
|
|
"overlap_ratio": round(social_overlap.overlap_ratio, 3),
|
|
"shared_domains": social_overlap.shared_domains,
|
|
}
|
|
|
|
# Add verdict
|
|
overall = float(result["overall_similarity"])
|
|
if overall >= 0.9:
|
|
result["verdict"] = "CLONE"
|
|
elif overall >= 0.7:
|
|
result["verdict"] = "COPYCAT"
|
|
elif overall >= 0.5:
|
|
result["verdict"] = "LINKED"
|
|
else:
|
|
result["verdict"] = "UNRELATED"
|
|
|
|
return result
|
|
|
|
# ------------------------------------------------------------------
|
|
# Similarity computation
|
|
# ------------------------------------------------------------------
|
|
|
|
def _compute_similarity(self, meta1: TokenMetadata, meta2: TokenMetadata) -> dict[str, object]:
|
|
"""Compute multi-dimensional similarity between two token metadata sets."""
|
|
# 1. HTML structure hash comparison
|
|
hash1 = hash_html_structure(meta1.website_html)
|
|
hash2 = hash_html_structure(meta2.website_html)
|
|
html_match = 1.0 if (hash1 and hash2 and hash1 == hash2) else 0.0
|
|
if not hash1 or not hash2:
|
|
html_match = 0.0 # insufficient data
|
|
|
|
# 2. Description text similarity via difflib
|
|
desc1 = self._normalise_text(meta1.description)
|
|
desc2 = self._normalise_text(meta2.description)
|
|
desc_similarity = SequenceMatcher(None, desc1, desc2).ratio() if desc1 and desc2 else 0.0
|
|
|
|
# 3. Name/symbol similarity
|
|
name_sim = (
|
|
SequenceMatcher(
|
|
None,
|
|
meta1.name.lower().strip(),
|
|
meta2.name.lower().strip(),
|
|
).ratio()
|
|
if meta1.name and meta2.name
|
|
else 0.0
|
|
)
|
|
|
|
symbol_sim = (
|
|
SequenceMatcher(
|
|
None,
|
|
meta1.symbol.lower().strip(),
|
|
meta2.symbol.lower().strip(),
|
|
).ratio()
|
|
if meta1.symbol and meta2.symbol
|
|
else 0.0
|
|
)
|
|
|
|
# 4. Social link overlap
|
|
social_overlap = self._compute_social_overlap(meta1.social_links, meta2.social_links)
|
|
social_ratio = social_overlap.overlap_ratio
|
|
|
|
# 5. Weighted overall similarity
|
|
# HTML structure is the strongest copycat signal
|
|
# Description similarity catches template-scam reruns
|
|
# Social overlap reveals shared infrastructure
|
|
overall = html_match * 0.30 + desc_similarity * 0.35 + social_ratio * 0.20 + name_sim * 0.10 + symbol_sim * 0.05
|
|
|
|
return {
|
|
"token_1": meta1.address,
|
|
"token_2": meta2.address,
|
|
"html_structure_match": html_match == 1.0,
|
|
"description_similarity": round(desc_similarity, 3),
|
|
"name_similarity": round(name_sim, 3),
|
|
"symbol_similarity": round(symbol_sim, 3),
|
|
"social_overlap_ratio": round(social_ratio, 3),
|
|
"overall_similarity": round(overall, 3),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Social overlap detection
|
|
# ------------------------------------------------------------------
|
|
|
|
def _compute_social_overlap(self, links1: list[str], links2: list[str]) -> SocialOverlapResult:
|
|
"""Detect overlap between two sets of social links."""
|
|
set1 = {self._normalise_link(line) for line in links1 if line}
|
|
set2 = {self._normalise_link(line) for line in links2 if line}
|
|
# Filter empty strings
|
|
set1.discard("")
|
|
set2.discard("")
|
|
|
|
overlapping = set1 & set2
|
|
total_unique = len(set1 | set2)
|
|
overlap_ratio = len(overlapping) / total_unique if total_unique else 0.0
|
|
|
|
# Identify shared domains (e.g. both link to same Twitter account)
|
|
shared_domains = list({self._domain_from_url(url) for url in overlapping if self._domain_from_url(url)})
|
|
|
|
return SocialOverlapResult(
|
|
overlapping_links=sorted(overlapping),
|
|
overlap_count=len(overlapping),
|
|
total_unique=total_unique,
|
|
overlap_ratio=round(overlap_ratio, 3),
|
|
shared_domains=shared_domains,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Warning generation
|
|
# ------------------------------------------------------------------
|
|
|
|
# Known stablecoin / wrapped token symbols - these legitimately have no description
|
|
STABLECOIN_SYMBOLS: ClassVar[dict] ={
|
|
"USDC",
|
|
"USDT",
|
|
"DAI",
|
|
"BUSD",
|
|
"TUSD",
|
|
"USDP",
|
|
"GUSD",
|
|
"HUSD",
|
|
"USDD",
|
|
"FRAX",
|
|
"LUSD",
|
|
"SUSD",
|
|
"USDS",
|
|
"USDJ",
|
|
"USDX",
|
|
"EURS",
|
|
"USDC.E",
|
|
"USDT.E",
|
|
"DAI.E",
|
|
"USDC.W",
|
|
"USDT.W",
|
|
"WUSDC",
|
|
"WUSDT",
|
|
"WETH",
|
|
"WBTC",
|
|
"WBNB",
|
|
"WMATIC",
|
|
"WAVAX",
|
|
"WFTM",
|
|
"WGLMR",
|
|
"SOL",
|
|
"WSOL",
|
|
"BSOL",
|
|
"MSOL",
|
|
"JPSOL",
|
|
"STSOL",
|
|
}
|
|
|
|
def _is_stablecoin_or_wrapped(self, symbol: str) -> bool:
|
|
"""Check if token is a known stablecoin or wrapped asset."""
|
|
if not symbol:
|
|
return False
|
|
sym = symbol.upper().strip()
|
|
return sym in self.STABLECOIN_SYMBOLS or sym.startswith("W") or sym.startswith("ST")
|
|
|
|
def _generate_warnings(self, meta: TokenMetadata, similar: list[dict[str, object]]) -> list[str]:
|
|
warnings: list[str] = []
|
|
|
|
for entry in similar:
|
|
overall = float(entry.get("overall_similarity", 0))
|
|
addr = str(entry.get("token_2", "unknown"))
|
|
if overall >= 0.9:
|
|
warnings.append(f"CLONE: near-identical metadata to {addr[:12]}... (similarity {(overall):.0%})")
|
|
elif overall >= 0.7:
|
|
warnings.append(f"COPYCAT: similar metadata to {addr[:12]}... (similarity {(overall):.0%})")
|
|
elif overall >= 0.5:
|
|
warnings.append(f"LINKED: partial metadata overlap with {addr[:12]}... (similarity {(overall):.0%})")
|
|
|
|
# Skip NO_DESCRIPTION for stablecoins/wrapped tokens - they don't need marketing copy
|
|
if not meta.description and not self._is_stablecoin_or_wrapped(meta.symbol):
|
|
warnings.append("NO_DESCRIPTION: token has no description text")
|
|
|
|
# Skip NO_SOCIALS for stablecoins/wrapped tokens
|
|
if not meta.social_links and not self._is_stablecoin_or_wrapped(meta.symbol):
|
|
warnings.append("NO_SOCIALS: token has no social links")
|
|
|
|
# Check for identical html hashes across cached tokens
|
|
html_hash = hash_html_structure(meta.website_html)
|
|
if html_hash:
|
|
for addr, cached in self._cache.items():
|
|
if addr == meta.address:
|
|
continue
|
|
cached_hash = hash_html_structure(cached.website_html)
|
|
if cached_hash == html_hash:
|
|
warnings.append(f"HTML_STRUCTURE_MATCH: identical site structure as {addr[:12]}...")
|
|
break # one match is enough
|
|
|
|
return warnings
|
|
|
|
# ------------------------------------------------------------------
|
|
# Text / URL normalisation helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _normalise_text(text: str) -> str:
|
|
"""Lower-case, collapse whitespace, strip punctuation for comparison."""
|
|
text = text.lower().strip()
|
|
text = re.sub(r"[^\w\s]", " ", text)
|
|
text = re.sub(r"\s+", " ", text)
|
|
return text
|
|
|
|
@staticmethod
|
|
def _normalise_link(url: str) -> str:
|
|
"""Normalise a URL for dedup: strip trailing slashes, lower-case."""
|
|
url = url.strip().lower()
|
|
url = re.sub(r"/+$", "", url)
|
|
# Strip common tracking params
|
|
url = re.sub(r"\?utm_\w+=[^&]*&?", "", url)
|
|
url = re.sub(r"\?$", "", url)
|
|
return url
|
|
|
|
@staticmethod
|
|
def _domain_from_url(url: str) -> str:
|
|
"""Extract the registered domain from a URL (best-effort)."""
|
|
try:
|
|
no_scheme = re.sub(r"^https?://", "", url)
|
|
domain = no_scheme.split("/")[0].split(":")[0]
|
|
parts = domain.split(".")
|
|
# Return last two parts (e.g. twitter.com)
|
|
return ".".join(parts[-2:]) if len(parts) >= 2 else domain
|
|
except Exception:
|
|
return ""
|
|
|
|
def _extract_domains(self, links: list[str]) -> list[str]:
|
|
"""Extract unique social domains from a link list."""
|
|
domains = set()
|
|
for link in links:
|
|
d = self._domain_from_url(link)
|
|
if d:
|
|
domains.add(d)
|
|
return sorted(domains)
|
|
|
|
# ------------------------------------------------------------------
|
|
# API fetch - DexScreener + Birdeye direct calls
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _fetch_metadata(self, token_address: str, chain: str) -> TokenMetadata:
|
|
"""Fetch token metadata from DexScreener and Birdeye.
|
|
|
|
Merges data from both sources, preferring DexScreener for core
|
|
token info and using Birdeye for supplementary metadata.
|
|
"""
|
|
meta = TokenMetadata(address=token_address, chain=chain)
|
|
|
|
# --- DexScreener: token info + social links ---
|
|
dex_name = ""
|
|
dex_symbol = ""
|
|
dex_description = ""
|
|
dex_social_links: list[str] = []
|
|
dex_logo_url = ""
|
|
dex_website = ""
|
|
|
|
try:
|
|
resp = await self.dexscreener_client.get(
|
|
f"{self.DEXSCREENER_BASE}/latest/dex/tokens/{token_address}",
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
pairs = data.get("pairs") or []
|
|
if pairs:
|
|
pair = pairs[0]
|
|
base = pair.get("baseToken") or {}
|
|
dex_name = base.get("name", "")
|
|
dex_symbol = base.get("symbol", "")
|
|
|
|
# Info block contains description, socials, website
|
|
info = pair.get("info") or {}
|
|
dex_description = info.get("description", "") or ""
|
|
|
|
# Social links from DexScreener info
|
|
socials = info.get("socials") or []
|
|
for s in socials:
|
|
url = s.get("url", "")
|
|
if url:
|
|
dex_social_links.append(url)
|
|
|
|
# Website from info
|
|
websites = info.get("websites") or []
|
|
if websites:
|
|
dex_website = websites[0].get("url", "") or ""
|
|
|
|
# Logo URL from pair data
|
|
dex_logo_url = info.get("imageUrl", "") or ""
|
|
|
|
except Exception as e:
|
|
logger.warning(f"DexScreener metadata fetch failed for {token_address}: {e}")
|
|
|
|
# --- Birdeye: supplementary metadata (social, website, security) ---
|
|
birdeye_description = ""
|
|
birdeye_social_links: list[str] = []
|
|
birdeye_website = ""
|
|
|
|
try:
|
|
# Chain mapping for Birdeye
|
|
chain_map = {
|
|
"solana": "solana",
|
|
"ethereum": "ethereum",
|
|
"bsc": "bsc",
|
|
"base": "base",
|
|
"polygon": "polygon",
|
|
}
|
|
birdeye_chain = chain_map.get(chain, chain)
|
|
|
|
headers = {"accept": "application/json"}
|
|
if self.birdeye_key:
|
|
headers["X-API-KEY"] = self.birdeye_key
|
|
|
|
# Token security endpoint - social/website data
|
|
resp = await self.birdeye_client.get(
|
|
f"{self.BIRDEYE_BASE}/defi/token_security",
|
|
params={"address": token_address, "chain": birdeye_chain},
|
|
headers=headers,
|
|
)
|
|
if resp.status_code == 200:
|
|
sec_data = resp.json().get("data") or {}
|
|
# Extract social links from security data
|
|
for key in ("twitter", "telegram", "discord", "website"):
|
|
val = sec_data.get(key)
|
|
if val and isinstance(val, str) and val.startswith("http"):
|
|
birdeye_social_links.append(val)
|
|
elif val and isinstance(val, str) and not val.startswith("http"):
|
|
# Build URLs for known platforms
|
|
if key == "twitter":
|
|
birdeye_social_links.append(f"https://twitter.com/{val}")
|
|
elif key == "telegram":
|
|
birdeye_social_links.append(f"https://t.me/{val}")
|
|
elif key == "discord":
|
|
birdeye_social_links.append(f"https://discord.gg/{val}")
|
|
elif key == "website":
|
|
birdeye_website = val
|
|
|
|
# Token list endpoint - description
|
|
resp2 = await self.birdeye_client.get(
|
|
f"{self.BIRDEYE_BASE}/defi/v3/token/list",
|
|
params={"address": token_address, "chain": birdeye_chain},
|
|
headers=headers,
|
|
)
|
|
if resp2.status_code == 200:
|
|
list_data = resp2.json().get("data") or {}
|
|
birdeye_description = list_data.get("description", "") or ""
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Birdeye metadata fetch failed for {token_address}: {e}")
|
|
|
|
# --- Merge data: DexScreener first, Birdeye supplements ---
|
|
meta.name = dex_name
|
|
meta.symbol = dex_symbol
|
|
meta.description = dex_description or birdeye_description
|
|
meta.logo_url = dex_logo_url
|
|
|
|
# Merge social links (deduplicated)
|
|
all_socials: list[str] = []
|
|
seen_urls: set[str] = set()
|
|
for url in dex_social_links + birdeye_social_links:
|
|
norm = self._normalise_link(url)
|
|
if norm and norm not in seen_urls:
|
|
seen_urls.add(norm)
|
|
all_socials.append(url)
|
|
meta.social_links = all_socials
|
|
|
|
# Website: try to fetch HTML if we have a URL
|
|
website_url = dex_website or birdeye_website
|
|
if website_url:
|
|
try:
|
|
resp = await self.dexscreener_client.get(website_url, follow_redirects=True)
|
|
if resp.status_code == 200 and resp.text:
|
|
meta.website_html = resp.text
|
|
except Exception:
|
|
logger.debug(f"Could not fetch website HTML for {token_address}")
|
|
|
|
return meta
|