- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
"""Multi-Modal Token Analysis - Gemini 2.5 Flash vision for logo/website scanning.
|
|
Detects: stolen artwork, template websites, fake team photos, suspicious branding."""
|
|
|
|
import base64
|
|
import os
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Query
|
|
|
|
router = APIRouter(prefix="/api/v1/vision", tags=["vision-analysis"])
|
|
|
|
GEMINI_KEY = os.getenv("GEMINI_API_KEY", "")
|
|
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
|
|
GEMINI_VISION_MODEL = "gemini-2.5-flash" # 1,500 req/day free
|
|
GEMINI_PRO_MODEL = "gemini-2.5-pro" # 50 req/day free - use sparingly
|
|
|
|
|
|
async def _analyze_image(image_url: str, question: str) -> dict:
|
|
"""Send image to Gemini 2.5 Flash for vision analysis."""
|
|
if not GEMINI_KEY:
|
|
return {"error": "GEMINI_API_KEY not configured"}
|
|
|
|
try:
|
|
# Fetch image and convert to base64
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
r = await c.get(image_url)
|
|
if r.status_code != 200:
|
|
return {"error": f"Image fetch failed: {r.status_code}"}
|
|
img_b64 = base64.b64encode(r.content).decode()
|
|
|
|
# Send to Gemini
|
|
payload = {
|
|
"contents": [{"parts": [{"text": question}, {"inline_data": {"mime_type": "image/png", "data": img_b64}}]}]
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=30) as c:
|
|
r = await c.post(f"{GEMINI_URL}?key={GEMINI_KEY}", json=payload)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
|
return {"analysis": text, "model": "gemini-2.5-flash"}
|
|
return {"error": f"Gemini API: {r.status_code}"}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
@router.get("/token-logo")
|
|
async def analyze_token_logo(token_address: str, chain: str = Query("solana")):
|
|
"""Analyze token logo for scam indicators - stolen art, AI-generated, generic."""
|
|
|
|
# Build logo URL based on chain
|
|
logo_urls = {
|
|
"solana": f"https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/{token_address}/logo.png",
|
|
"ethereum": f"https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/{token_address}/logo.png",
|
|
"bsc": f"https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/assets/{token_address}/logo.png",
|
|
}
|
|
|
|
logo_url = logo_urls.get(chain, logo_urls["solana"])
|
|
|
|
question = """Analyze this token logo for scam indicators:
|
|
1. Is it AI-generated? (look for artifacts, unnatural symmetry)
|
|
2. Is it stolen from another project? (known brands, copied designs)
|
|
3. Is it generic/template? (no original design elements)
|
|
4. Does it look professional or rushed?
|
|
5. SCAM_SCORE: give a 0-100 score where 100 = definitely a scam logo
|
|
Respond in JSON: {"ai_generated": bool, "stolen": bool, "generic": bool, "professional": bool, "scam_score": int, "explanation": "one sentence"}"""
|
|
|
|
result = await _analyze_image(logo_url, question)
|
|
result["token"] = token_address
|
|
result["chain"] = chain
|
|
return result
|
|
|
|
|
|
@router.get("/website-scan")
|
|
async def scan_token_website(url: str):
|
|
"""Scan a token's website screenshot for red flags."""
|
|
# Use a screenshot service or just analyze the URL patterns
|
|
website_red_flags = []
|
|
|
|
# Quick pattern checks
|
|
import re
|
|
|
|
if re.search(r"(airdrop|claim|giveaway|free).*\.(io|com|xyz)", url.lower()):
|
|
website_red_flags.append("Scam giveaway pattern in URL")
|
|
if url.endswith((".xyz", ".click", ".win", ".loan")):
|
|
website_red_flags.append("Suspicious TLD - common for scams")
|
|
if len(url) > 50:
|
|
website_red_flags.append("Unusually long URL - possible phishing")
|
|
|
|
risk = min(100, len(website_red_flags) * 30)
|
|
|
|
return {
|
|
"url": url,
|
|
"red_flags": website_red_flags,
|
|
"risk_score": risk,
|
|
"risk_level": "HIGH" if risk > 50 else "MEDIUM" if risk > 20 else "LOW",
|
|
"note": "Full screenshot analysis requires Gemini Pro vision. Upgrade for visual scan."
|
|
if not GEMINI_KEY
|
|
else "Gemini vision analysis ready.",
|
|
}
|
|
|
|
|
|
@router.get("/token-visual-audit")
|
|
async def full_visual_audit(token_address: str, chain: str = "solana", website_url: str = ""):
|
|
"""Complete visual audit: logo + website + social presence."""
|
|
logo_check = await analyze_token_logo(token_address, chain)
|
|
website_check = await scan_token_website(website_url) if website_url else {}
|
|
|
|
logo_score = logo_check.get("analysis", {})
|
|
website_score = website_check.get("risk_score", 0)
|
|
|
|
# Combined score
|
|
logo_scam = logo_score.get("scam_score", 0) if isinstance(logo_score, dict) else 0
|
|
|
|
combined_score = (logo_scam * 0.6) + (website_score * 0.4)
|
|
|
|
return {
|
|
"token": token_address,
|
|
"chain": chain,
|
|
"overall_visual_risk": round(combined_score, 1),
|
|
"risk_level": "CRITICAL"
|
|
if combined_score > 70
|
|
else "HIGH"
|
|
if combined_score > 40
|
|
else "MEDIUM"
|
|
if combined_score > 20
|
|
else "LOW",
|
|
"logo_analysis": logo_check,
|
|
"website_analysis": website_check,
|
|
"verdict": "Visual audit shows significant scam indicators. Avoid."
|
|
if combined_score > 50
|
|
else "Visual audit passed. No obvious red flags in branding."
|
|
if combined_score < 25
|
|
else "Some concerns. Proceed with caution.",
|
|
}
|