|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const API_KEYS = {
|
|
|
ETHERSCAN: 'SZHYFZK2RR8H9TIMJBVW54V4H81K2Z2KR2',
|
|
|
ETHERSCAN_BACKUP: 'T6IR8VJHX2NE6ZJW2S3FDVN1TYG4PYYI45',
|
|
|
BSCSCAN: 'K62RKHGXTDCG53RU4MCG6XABIMJKTN19IT',
|
|
|
TRONSCAN: '7ae72726-bffe-4e74-9c33-97b761eeea21',
|
|
|
CMC_PRIMARY: 'b54bcf4d-1bca-4e8e-9a24-22ff2c3d462c',
|
|
|
CMC_BACKUP: '04cf4b5b-9868-465c-8ba0-9f2e78c92eb1',
|
|
|
NEWSAPI: 'pub_346789abc123def456789ghi012345jkl',
|
|
|
CRYPTOCOMPARE: 'e79c8e6d4c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f',
|
|
|
|
|
|
HUGGINGFACE: null
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const CORS_PROXIES = [
|
|
|
'https://api.allorigins.win/get?url=',
|
|
|
'https://proxy.cors.sh/',
|
|
|
'https://api.codetabs.com/v1/proxy?quest='
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|