|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const API_KEYS = { |
|
|
ETHERSCAN: '', |
|
|
ETHERSCAN_BACKUP: '', |
|
|
BSCSCAN: '', |
|
|
TRONSCAN: '', |
|
|
CMC_PRIMARY: '', |
|
|
CMC_BACKUP: '', |
|
|
NEWSAPI: '', |
|
|
CRYPTOCOMPARE: '', |
|
|
HUGGINGFACE: '' |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const CORS_PROXIES = []; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const MARKET_SOURCES = [ |
|
|
|
|
|
{ |
|
|
id: 'coingecko', |
|
|
name: 'CoinGecko', |
|
|
baseUrl: 'https://api.coingecko.com/api/v3', |
|
|
needsProxy: false, |
|
|
priority: 1, |
|
|
getPrice: (symbol) => `/simple/price?ids=${symbol}&vs_currencies=usd,eur&include_24hr_change=true&include_market_cap=true` |
|
|
}, |
|
|
{ |
|
|
id: 'coinpaprika', |
|
|
name: 'CoinPaprika', |
|
|
baseUrl: 'https://api.coinpaprika.com/v1', |
|
|
needsProxy: false, |
|
|
priority: 2, |
|
|
getPrice: (symbol) => `/tickers/${symbol}-${symbol}` |
|
|
}, |
|
|
{ |
|
|
id: 'coincap', |
|
|
name: 'CoinCap', |
|
|
baseUrl: 'https://api.coincap.io/v2', |
|
|
needsProxy: false, |
|
|
priority: 3, |
|
|
getPrice: (symbol) => `/assets/${symbol}` |
|
|
}, |
|
|
{ |
|
|
id: 'binance', |
|
|
name: 'Binance Public', |
|
|
baseUrl: 'https://api.binance.com/api/v3', |
|
|
needsProxy: false, |
|
|
priority: 4, |
|
|
getPrice: (symbol) => `/ticker/price?symbol=${symbol.toUpperCase()}USDT` |
|
|
}, |
|
|
{ |
|
|
id: 'coinlore', |
|
|
name: 'CoinLore', |
|
|
baseUrl: 'https://api.coinlore.net/api', |
|
|
needsProxy: false, |
|
|
priority: 5, |
|
|
getPrice: (symbol) => `/ticker/?id=${symbol}` |
|
|
}, |
|
|
{ |
|
|
id: 'defillama', |
|
|
name: 'DefiLlama', |
|
|
baseUrl: 'https://coins.llama.fi', |
|
|
needsProxy: false, |
|
|
priority: 6, |
|
|
getPrice: (symbol) => `/prices/current/coingecko:${symbol}` |
|
|
}, |
|
|
{ |
|
|
id: 'coinstats', |
|
|
name: 'CoinStats', |
|
|
baseUrl: 'https://api.coinstats.app/public/v1', |
|
|
needsProxy: false, |
|
|
priority: 7, |
|
|
getPrice: (symbol) => `/coins/${symbol}` |
|
|
}, |
|
|
{ |
|
|
id: 'messari', |
|
|
name: 'Messari', |
|
|
baseUrl: 'https://data.messari.io/api/v1', |
|
|
needsProxy: false, |
|
|
priority: 8, |
|
|
getPrice: (symbol) => `/assets/${symbol}/metrics` |
|
|
}, |
|
|
{ |
|
|
id: 'nomics', |
|
|
name: 'Nomics', |
|
|
baseUrl: 'https://api.nomics.com/v1', |
|
|
needsProxy: false, |
|
|
priority: 9, |
|
|
getPrice: (symbol) => `/currencies/ticker?ids=${symbol.toUpperCase()}&convert=USD` |
|
|
}, |
|
|
{ |
|
|
id: 'coindesk', |
|
|
name: 'CoinDesk', |
|
|
baseUrl: 'https://api.coindesk.com/v1', |
|
|
needsProxy: false, |
|
|
priority: 10, |
|
|
getPrice: () => `/bpi/currentprice.json` |
|
|
}, |
|
|
|
|
|
{ |
|
|
id: 'cmc_primary', |
|
|
name: 'CoinMarketCap', |
|
|
baseUrl: 'https://pro-api.coinmarketcap.com/v1', |
|
|
needsProxy: true, |
|
|
priority: 11, |
|
|
headers: () => ({ 'X-CMC_PRO_API_KEY': API_KEYS.CMC_PRIMARY }), |
|
|
getPrice: (symbol) => `/cryptocurrency/quotes/latest?symbol=${symbol.toUpperCase()}` |
|
|
}, |
|
|
{ |
|
|
id: 'cmc_backup', |
|
|
name: 'CoinMarketCap Backup', |
|
|
baseUrl: 'https://pro-api.coinmarketcap.com/v1', |
|
|
needsProxy: true, |
|
|
priority: 12, |
|
|
headers: () => ({ 'X-CMC_PRO_API_KEY': API_KEYS.CMC_BACKUP }), |
|
|
getPrice: (symbol) => `/cryptocurrency/quotes/latest?symbol=${symbol.toUpperCase()}` |
|
|
}, |
|
|
{ |
|
|
id: 'cryptocompare', |
|
|
name: 'CryptoCompare', |
|
|
baseUrl: 'https://min-api.cryptocompare.com/data', |
|
|
needsProxy: false, |
|
|
priority: 13, |
|
|
getPrice: (symbol) => `/price?fsym=${symbol.toUpperCase()}&tsyms=USD,EUR&api_key=${API_KEYS.CRYPTOCOMPARE}` |
|
|
}, |
|
|
{ |
|
|
id: 'kraken', |
|
|
name: 'Kraken Public', |
|
|
baseUrl: 'https://api.kraken.com/0/public', |
|
|
needsProxy: false, |
|
|
priority: 14, |
|
|
getPrice: (symbol) => `/Ticker?pair=${symbol.toUpperCase()}USD` |
|
|
}, |
|
|
{ |
|
|
id: 'bitfinex', |
|
|
name: 'Bitfinex Public', |
|
|
baseUrl: 'https://api-pub.bitfinex.com/v2', |
|
|
needsProxy: false, |
|
|
priority: 15, |
|
|
getPrice: (symbol) => `/ticker/t${symbol.toUpperCase()}USD` |
|
|
} |
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const NEWS_SOURCES = [ |
|
|
{ |
|
|
id: 'cryptopanic', |
|
|
name: 'CryptoPanic', |
|
|
baseUrl: 'https://cryptopanic.com/api/v1', |
|
|
needsProxy: false, |
|
|
priority: 1, |
|
|
getNews: () => `/posts/?public=true` |
|
|
}, |
|
|
{ |
|
|
id: 'coinstats_news', |
|
|
name: 'CoinStats News', |
|
|
baseUrl: 'https://api.coinstats.app/public/v1', |
|
|
needsProxy: false, |
|
|
priority: 2, |
|
|
getNews: () => `/news` |
|
|
}, |
|
|
{ |
|
|
id: 'cointelegraph_rss', |
|
|
name: 'Cointelegraph RSS', |
|
|
baseUrl: 'https://cointelegraph.com', |
|
|
needsProxy: false, |
|
|
priority: 3, |
|
|
getNews: () => `/rss`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'coindesk_rss', |
|
|
name: 'CoinDesk RSS', |
|
|
baseUrl: 'https://www.coindesk.com', |
|
|
needsProxy: false, |
|
|
priority: 4, |
|
|
getNews: () => `/arc/outboundfeeds/rss/?outputType=xml`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'decrypt_rss', |
|
|
name: 'Decrypt RSS', |
|
|
baseUrl: 'https://decrypt.co', |
|
|
needsProxy: false, |
|
|
priority: 5, |
|
|
getNews: () => `/feed`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'bitcoin_magazine_rss', |
|
|
name: 'Bitcoin Magazine RSS', |
|
|
baseUrl: 'https://bitcoinmagazine.com', |
|
|
needsProxy: false, |
|
|
priority: 6, |
|
|
getNews: () => `/.rss/full/`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'reddit_crypto', |
|
|
name: 'Reddit r/CryptoCurrency', |
|
|
baseUrl: 'https://www.reddit.com/r/CryptoCurrency', |
|
|
needsProxy: false, |
|
|
priority: 7, |
|
|
getNews: () => `/hot.json?limit=25` |
|
|
}, |
|
|
{ |
|
|
id: 'reddit_bitcoin', |
|
|
name: 'Reddit r/Bitcoin', |
|
|
baseUrl: 'https://www.reddit.com/r/Bitcoin', |
|
|
needsProxy: false, |
|
|
priority: 8, |
|
|
getNews: () => `/new.json?limit=25` |
|
|
}, |
|
|
{ |
|
|
id: 'blockworks', |
|
|
name: 'Blockworks RSS', |
|
|
baseUrl: 'https://blockworks.co', |
|
|
needsProxy: false, |
|
|
priority: 9, |
|
|
getNews: () => `/feed`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'theblock_rss', |
|
|
name: 'The Block RSS', |
|
|
baseUrl: 'https://www.theblock.co', |
|
|
needsProxy: false, |
|
|
priority: 10, |
|
|
getNews: () => `/rss.xml`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'coinjournal', |
|
|
name: 'CoinJournal RSS', |
|
|
baseUrl: 'https://coinjournal.net', |
|
|
needsProxy: false, |
|
|
priority: 11, |
|
|
getNews: () => `/feed/`, |
|
|
parseRSS: true |
|
|
}, |
|
|
{ |
|
|
id: 'cryptoslate_rss', |
|
|
name: 'CryptoSlate RSS', |
|
|
baseUrl: 'https://cryptoslate.com', |
|
|
needsProxy: false, |
|
|
priority: 12, |
|
|
getNews: () => `/feed/`, |
|
|
parseRSS: true |
|
|
} |
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const SENTIMENT_SOURCES = [ |
|
|
{ |
|
|
id: 'alternative_me', |
|
|
name: 'Alternative.me F&G', |
|
|
baseUrl: 'https://api.alternative.me', |
|
|
needsProxy: false, |
|
|
priority: 1, |
|
|
getSentiment: () => `/fng/?limit=1` |
|
|
}, |
|
|
{ |
|
|
id: 'cfgi_v1', |
|
|
name: 'CFGI API v1', |
|
|
baseUrl: 'https://api.cfgi.io/v1', |
|
|
needsProxy: false, |
|
|
priority: 2, |
|
|
getSentiment: () => `/fear-greed` |
|
|
}, |
|
|
{ |
|
|
id: 'cfgi_legacy', |
|
|
name: 'CFGI Legacy', |
|
|
baseUrl: 'https://cfgi.io', |
|
|
needsProxy: false, |
|
|
priority: 3, |
|
|
getSentiment: () => `/api` |
|
|
}, |
|
|
{ |
|
|
id: 'coinglass_fgi', |
|
|
name: 'CoinGlass F&G', |
|
|
baseUrl: 'https://open-api.coinglass.com/public/v2', |
|
|
needsProxy: false, |
|
|
priority: 4, |
|
|
getSentiment: () => `/indicator/fear_greed` |
|
|
}, |
|
|
{ |
|
|
id: 'lunarcrush', |
|
|
name: 'LunarCrush Social', |
|
|
baseUrl: 'https://api.lunarcrush.com/v2', |
|
|
needsProxy: false, |
|
|
priority: 5, |
|
|
getSentiment: () => `?data=global` |
|
|
}, |
|
|
{ |
|
|
id: 'santiment', |
|
|
name: 'Santiment Social Volume', |
|
|
baseUrl: 'https://api.santiment.net', |
|
|
needsProxy: false, |
|
|
priority: 6, |
|
|
getSentiment: () => `/graphql`, |
|
|
method: 'POST' |
|
|
}, |
|
|
{ |
|
|
id: 'thetie', |
|
|
name: 'TheTie.io Sentiment', |
|
|
baseUrl: 'https://api.thetie.io', |
|
|
needsProxy: false, |
|
|
priority: 7, |
|
|
getSentiment: () => `/v1/sentiment?symbol=BTC` |
|
|
}, |
|
|
{ |
|
|
id: 'augmento', |
|
|
name: 'Augmento AI Sentiment', |
|
|
baseUrl: 'https://api.augmento.ai/v1', |
|
|
needsProxy: false, |
|
|
priority: 8, |
|
|
getSentiment: () => `/signals/overview` |
|
|
}, |
|
|
{ |
|
|
id: 'cryptoquant_sentiment', |
|
|
name: 'CryptoQuant Sentiment', |
|
|
baseUrl: 'https://api.cryptoquant.com/v1', |
|
|
needsProxy: false, |
|
|
priority: 9, |
|
|
getSentiment: () => `/btc/indicator/fear-greed` |
|
|
}, |
|
|
{ |
|
|
id: 'glassnode_social', |
|
|
name: 'Glassnode Social Metrics', |
|
|
baseUrl: 'https://api.glassnode.com/v1', |
|
|
needsProxy: false, |
|
|
priority: 10, |
|
|
getSentiment: () => `/metrics/social/sentiment_positive` |
|
|
} |
|
|
]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchWithTimeout(url, options = {}, timeout = 10000) { |
|
|
const controller = new AbortController(); |
|
|
const id = setTimeout(() => controller.abort(), timeout); |
|
|
|
|
|
try { |
|
|
const response = await fetch(url, { |
|
|
...options, |
|
|
signal: controller.signal |
|
|
}); |
|
|
clearTimeout(id); |
|
|
return response; |
|
|
} catch (error) { |
|
|
clearTimeout(id); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
async function fetchDirect(url, options = {}) { |
|
|
try { |
|
|
const response = await fetchWithTimeout(url, options); |
|
|
if (!response.ok) { |
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`); |
|
|
} |
|
|
const contentType = response.headers.get('content-type'); |
|
|
if (contentType && contentType.includes('application/json')) { |
|
|
return await response.json(); |
|
|
} |
|
|
return await response.text(); |
|
|
} catch (error) { |
|
|
throw new Error(`Direct fetch failed: ${error.message}`); |
|
|
} |
|
|
} |
|
|
|
|
|
async function fetchWithProxy(url, options = {}, proxyIndex = 0) { |
|
|
if (proxyIndex >= CORS_PROXIES.length) { |
|
|
throw new Error('All CORS proxies exhausted'); |
|
|
} |
|
|
|
|
|
const proxy = CORS_PROXIES[proxyIndex]; |
|
|
const proxyUrl = proxy + encodeURIComponent(url); |
|
|
|
|
|
try { |
|
|
const response = await fetchWithTimeout(proxyUrl, { |
|
|
...options, |
|
|
headers: { |
|
|
...options.headers, |
|
|
'Origin': window.location.origin, |
|
|
'x-requested-with': 'XMLHttpRequest' |
|
|
} |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`Proxy returned ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
return data.contents ? JSON.parse(data.contents) : data; |
|
|
} catch (error) { |
|
|
console.warn(`Proxy ${proxyIndex + 1} failed:`, error.message); |
|
|
|
|
|
return fetchWithProxy(url, options, proxyIndex + 1); |
|
|
} |
|
|
} |
|
|
|
|
|
function parseRSS(xmlText, sourceName) { |
|
|
const parser = new DOMParser(); |
|
|
const doc = parser.parseFromString(xmlText, 'text/xml'); |
|
|
const items = doc.querySelectorAll('item'); |
|
|
|
|
|
const news = []; |
|
|
items.forEach((item, index) => { |
|
|
if (index >= 20) return; |
|
|
|
|
|
const title = item.querySelector('title')?.textContent || ''; |
|
|
const link = item.querySelector('link')?.textContent || ''; |
|
|
const pubDate = item.querySelector('pubDate')?.textContent || ''; |
|
|
const description = item.querySelector('description')?.textContent || ''; |
|
|
|
|
|
if (title && link) { |
|
|
news.push({ |
|
|
title, |
|
|
link, |
|
|
publishedAt: pubDate, |
|
|
description: description.substring(0, 200), |
|
|
source: sourceName |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
return news; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ComprehensiveAPIClient { |
|
|
constructor() { |
|
|
this.cache = new Map(); |
|
|
this.cacheTimeout = 60000; |
|
|
this.requestLog = []; |
|
|
} |
|
|
|
|
|
|
|
|
getCached(key) { |
|
|
const cached = this.cache.get(key); |
|
|
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) { |
|
|
console.log(`π¦ Cache hit: ${key}`); |
|
|
return cached.data; |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
setCache(key, data) { |
|
|
this.cache.set(key, { |
|
|
data, |
|
|
timestamp: Date.now() |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
logRequest(source, success, error = null) { |
|
|
this.requestLog.push({ |
|
|
source, |
|
|
success, |
|
|
error, |
|
|
timestamp: new Date().toISOString() |
|
|
}); |
|
|
|
|
|
|
|
|
if (this.requestLog.length > 100) { |
|
|
this.requestLog.shift(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getMarketPrice(symbol) { |
|
|
const cacheKey = `market_${symbol}`; |
|
|
const cached = this.getCached(cacheKey); |
|
|
if (cached) return cached; |
|
|
|
|
|
const normalizedSymbol = symbol.toLowerCase(); |
|
|
const sources = [...MARKET_SOURCES].sort((a, b) => a.priority - b.priority); |
|
|
|
|
|
for (const source of sources) { |
|
|
try { |
|
|
console.log(`π Trying ${source.name} for ${symbol}...`); |
|
|
|
|
|
const endpoint = source.getPrice(normalizedSymbol); |
|
|
const url = `${source.baseUrl}${endpoint}`; |
|
|
const options = source.headers ? { headers: source.headers() } : {}; |
|
|
|
|
|
let data; |
|
|
if (source.needsProxy) { |
|
|
data = await fetchWithProxy(url, options); |
|
|
} else { |
|
|
data = await fetchDirect(url, options); |
|
|
} |
|
|
|
|
|
|
|
|
const normalized = this.normalizeMarketData(data, source.id, symbol); |
|
|
if (normalized) { |
|
|
this.setCache(cacheKey, normalized); |
|
|
this.logRequest(source.name, true); |
|
|
console.log(`β
Success: ${source.name}`); |
|
|
return normalized; |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn(`β ${source.name} failed:`, error.message); |
|
|
this.logRequest(source.name, false, error.message); |
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
throw new Error(`All ${sources.length} market data sources failed for ${symbol}`); |
|
|
} |
|
|
|
|
|
normalizeMarketData(data, sourceId, symbol) { |
|
|
try { |
|
|
switch (sourceId) { |
|
|
case 'coingecko': |
|
|
const coinId = symbol.toLowerCase(); |
|
|
return { |
|
|
symbol: symbol.toUpperCase(), |
|
|
price: data[coinId]?.usd || null, |
|
|
change24h: data[coinId]?.usd_24h_change || null, |
|
|
marketCap: data[coinId]?.usd_market_cap || null, |
|
|
source: 'CoinGecko', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
case 'binance': |
|
|
return { |
|
|
symbol: symbol.toUpperCase(), |
|
|
price: parseFloat(data.price), |
|
|
source: 'Binance', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
case 'coincap': |
|
|
return { |
|
|
symbol: symbol.toUpperCase(), |
|
|
price: parseFloat(data.data?.priceUsd || 0), |
|
|
change24h: parseFloat(data.data?.changePercent24Hr || 0), |
|
|
marketCap: parseFloat(data.data?.marketCapUsd || 0), |
|
|
source: 'CoinCap', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
case 'cmc_primary': |
|
|
case 'cmc_backup': |
|
|
const cmcData = data.data?.[symbol.toUpperCase()]; |
|
|
return { |
|
|
symbol: symbol.toUpperCase(), |
|
|
price: cmcData?.quote?.USD?.price || null, |
|
|
change24h: cmcData?.quote?.USD?.percent_change_24h || null, |
|
|
marketCap: cmcData?.quote?.USD?.market_cap || null, |
|
|
source: 'CoinMarketCap', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
default: |
|
|
|
|
|
return { |
|
|
symbol: symbol.toUpperCase(), |
|
|
price: data.price || data.last || data.lastPrice || null, |
|
|
source: sourceId, |
|
|
timestamp: Date.now(), |
|
|
raw: data |
|
|
}; |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn(`Failed to normalize ${sourceId} data:`, error); |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getNews(limit = 20) { |
|
|
const cacheKey = 'news_latest'; |
|
|
const cached = this.getCached(cacheKey); |
|
|
if (cached) return cached; |
|
|
|
|
|
const allNews = []; |
|
|
const sources = [...NEWS_SOURCES].sort((a, b) => a.priority - b.priority); |
|
|
|
|
|
for (const source of sources) { |
|
|
try { |
|
|
console.log(`π Fetching news from ${source.name}...`); |
|
|
|
|
|
const endpoint = source.getNews(); |
|
|
const url = `${source.baseUrl}${endpoint}`; |
|
|
|
|
|
let data; |
|
|
if (source.needsProxy) { |
|
|
data = await fetchWithProxy(url); |
|
|
} else { |
|
|
data = await fetchDirect(url); |
|
|
} |
|
|
|
|
|
let news = []; |
|
|
if (source.parseRSS) { |
|
|
news = parseRSS(data, source.name); |
|
|
} else { |
|
|
news = this.normalizeNewsData(data, source.id, source.name); |
|
|
} |
|
|
|
|
|
if (news && news.length > 0) { |
|
|
allNews.push(...news); |
|
|
this.logRequest(source.name, true); |
|
|
console.log(`β
Got ${news.length} articles from ${source.name}`); |
|
|
} |
|
|
|
|
|
|
|
|
if (allNews.length >= limit * 2) break; |
|
|
} catch (error) { |
|
|
console.warn(`β ${source.name} failed:`, error.message); |
|
|
this.logRequest(source.name, false, error.message); |
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const uniqueNews = this.deduplicateNews(allNews); |
|
|
const sortedNews = uniqueNews.slice(0, limit); |
|
|
|
|
|
this.setCache(cacheKey, sortedNews); |
|
|
return sortedNews; |
|
|
} |
|
|
|
|
|
normalizeNewsData(data, sourceId, sourceName) { |
|
|
try { |
|
|
switch (sourceId) { |
|
|
case 'cryptopanic': |
|
|
return data.results?.map(item => ({ |
|
|
title: item.title, |
|
|
link: item.url, |
|
|
publishedAt: item.published_at, |
|
|
source: item.source?.title || sourceName, |
|
|
votes: item.votes?.positive || 0 |
|
|
})) || []; |
|
|
|
|
|
case 'coinstats_news': |
|
|
return data.news?.map(item => ({ |
|
|
title: item.title, |
|
|
link: item.link, |
|
|
publishedAt: item.feedDate, |
|
|
source: item.source || sourceName, |
|
|
imgURL: item.imgURL |
|
|
})) || []; |
|
|
|
|
|
case 'reddit_crypto': |
|
|
case 'reddit_bitcoin': |
|
|
return data.data?.children?.map(item => ({ |
|
|
title: item.data.title, |
|
|
link: `https://reddit.com${item.data.permalink}`, |
|
|
publishedAt: new Date(item.data.created_utc * 1000).toISOString(), |
|
|
source: sourceName, |
|
|
score: item.data.score |
|
|
})) || []; |
|
|
|
|
|
default: |
|
|
return []; |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn(`Failed to normalize ${sourceId} news:`, error); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
deduplicateNews(newsArray) { |
|
|
const seen = new Set(); |
|
|
return newsArray.filter(item => { |
|
|
const key = item.title.toLowerCase().trim(); |
|
|
if (seen.has(key)) return false; |
|
|
seen.add(key); |
|
|
return true; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getSentiment() { |
|
|
const cacheKey = 'sentiment_fng'; |
|
|
const cached = this.getCached(cacheKey); |
|
|
if (cached) return cached; |
|
|
|
|
|
const sources = [...SENTIMENT_SOURCES].sort((a, b) => a.priority - b.priority); |
|
|
|
|
|
for (const source of sources) { |
|
|
try { |
|
|
console.log(`π Trying ${source.name} for sentiment...`); |
|
|
|
|
|
const endpoint = source.getSentiment(); |
|
|
const url = `${source.baseUrl}${endpoint}`; |
|
|
const options = source.method === 'POST' ? { method: 'POST' } : {}; |
|
|
|
|
|
let data; |
|
|
if (source.needsProxy) { |
|
|
data = await fetchWithProxy(url, options); |
|
|
} else { |
|
|
data = await fetchDirect(url, options); |
|
|
} |
|
|
|
|
|
const normalized = this.normalizeSentimentData(data, source.id); |
|
|
if (normalized && normalized.value !== null) { |
|
|
this.setCache(cacheKey, normalized); |
|
|
this.logRequest(source.name, true); |
|
|
console.log(`β
Sentiment from ${source.name}: ${normalized.value}`); |
|
|
return normalized; |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn(`β ${source.name} failed:`, error.message); |
|
|
this.logRequest(source.name, false, error.message); |
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
throw new Error(`All ${sources.length} sentiment sources failed`); |
|
|
} |
|
|
|
|
|
normalizeSentimentData(data, sourceId) { |
|
|
try { |
|
|
switch (sourceId) { |
|
|
case 'alternative_me': |
|
|
const fngData = data.data?.[0]; |
|
|
return { |
|
|
value: parseInt(fngData?.value || 0), |
|
|
classification: fngData?.value_classification || 'Unknown', |
|
|
source: 'Alternative.me', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
case 'cfgi_v1': |
|
|
case 'cfgi_legacy': |
|
|
return { |
|
|
value: parseInt(data.value || data.fgi || 0), |
|
|
classification: data.classification || this.getClassification(data.value), |
|
|
source: 'CFGI', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
case 'coinglass_fgi': |
|
|
return { |
|
|
value: parseInt(data.data?.value || 0), |
|
|
classification: data.data?.value_classification || 'Unknown', |
|
|
source: 'CoinGlass', |
|
|
timestamp: Date.now() |
|
|
}; |
|
|
|
|
|
default: |
|
|
|
|
|
const value = parseInt(data.value || data.score || 50); |
|
|
return { |
|
|
value, |
|
|
classification: this.getClassification(value), |
|
|
source: sourceId, |
|
|
timestamp: Date.now(), |
|
|
raw: data |
|
|
}; |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn(`Failed to normalize ${sourceId} sentiment:`, error); |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
getClassification(value) { |
|
|
if (value <= 25) return 'Extreme Fear'; |
|
|
if (value <= 45) return 'Fear'; |
|
|
if (value <= 55) return 'Neutral'; |
|
|
if (value <= 75) return 'Greed'; |
|
|
return 'Extreme Greed'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getOHLCV(symbol, timeframe = '1d', limit = 100) { |
|
|
try { |
|
|
|
|
|
const { default: ohlcvClient } = await import('/static/shared/js/ohlcv-client.js'); |
|
|
return await ohlcvClient.getOHLCV(symbol, timeframe, limit); |
|
|
} catch (error) { |
|
|
console.error('Failed to load OHLCV client:', error); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getStats() { |
|
|
const total = this.requestLog.length; |
|
|
const successful = this.requestLog.filter(r => r.success).length; |
|
|
const failed = total - successful; |
|
|
const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : 0; |
|
|
|
|
|
return { |
|
|
total, |
|
|
successful, |
|
|
failed, |
|
|
successRate: `${successRate}%`, |
|
|
cacheSize: this.cache.size, |
|
|
recentRequests: this.requestLog.slice(-10) |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
clearCache() { |
|
|
this.cache.clear(); |
|
|
console.log('β
Cache cleared'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const apiClient = new ComprehensiveAPIClient(); |
|
|
export default apiClient; |
|
|
|
|
|
|