| """ |
| RMI Mega News Aggregator — Largest Free Crypto News Pipeline |
| 50+ RSS feeds, automatic dedup, sentiment scoring, multi-DB storage |
| """ |
|
|
| import hashlib |
| import json |
| import logging |
| import re |
| import time |
| from xml.etree import ElementTree as ET |
|
|
| import httpx |
|
|
| logger = logging.getLogger("rmi.news") |
|
|
| |
| |
| |
| RSS_FEEDS = [ |
| |
| ("cointelegraph", "https://cointelegraph.com/rss"), |
| ("decrypt", "https://decrypt.co/feed"), |
| ("coindesk", "https://www.coindesk.com/arc/outboundfeeds/rss/"), |
| ("theblock", "https://www.theblock.co/rss"), |
| ("cryptoslate", "https://cryptoslate.com/feed/"), |
| ("beincrypto", "https://beincrypto.com/feed/"), |
| ("bitcoinmagazine", "https://bitcoinmagazine.com/.rss/full/"), |
| ("newsbtc", "https://www.newsbtc.com/feed/"), |
| ("cryptopotato", "https://cryptopotato.com/feed/"), |
| ("ambcrypto", "https://ambcrypto.com/feed/"), |
| ("cryptobriefing", "https://cryptobriefing.com/feed/"), |
| ("dailyhodl", "https://dailyhodl.com/feed/"), |
| ("zycrypto", "https://zycrypto.com/feed/"), |
| ("bitcoinist", "https://bitcoinist.com/feed/"), |
| ("cryptonews", "https://cryptonews.com/feed/"), |
| |
| ("ethereum-blog", "https://blog.ethereum.org/feed.xml"), |
| ("solana-blog", "https://solana.com/feed"), |
| ("polkadot-blog", "https://polkadot.network/blog/feed/"), |
| ("chainlink-blog", "https://blog.chain.link/feed/"), |
| ("a16z-crypto", "https://a16zcrypto.com/feed/"), |
| |
| ("messari", "https://messari.io/feed"), |
| ("glassnode", "https://insights.glassnode.com/feed/"), |
| ("kaiko", "https://blog.kaiko.com/feed"), |
| ("defillama", "https://defillama.com/feed"), |
| ("dune-analytics", "https://dune.com/blog/rss.xml"), |
| |
| ("defi-pulse", "https://defipulse.com/blog/feed/"), |
| ("bankless", "https://newsletter.banklesshq.com/feed"), |
| ("the-defiant", "https://thedefiant.io/feed"), |
| ("coingecko-buzz", "https://www.coingecko.com/en/blog/rss"), |
| ("coinmarketcap", "https://coinmarketcap.com/feed/"), |
| |
| ("sec-crypto", "https://www.sec.gov/cgi-bin/rss?crypto"), |
| ("cfpb", "https://www.consumerfinance.gov/feed/"), |
| |
| ("bitcoin-core", "https://bitcoincore.org/en/rss.xml"), |
| ("bitcoin-ops", "https://bitcoinops.org/en/feed.xml"), |
| ("lightning-blog", "https://lightning.engineering/rss/"), |
| ("unchained", "https://unchainedcrypto.com/feed/"), |
| ("blockworks", "https://blockworks.co/feed"), |
| ("protos", "https://protos.com/feed/"), |
| ("the-crypto-times", "https://thecryptotimes.com/feed/"), |
| ("crypto-daily", "https://cryptodaily.co.uk/feed/"), |
| ("coinjournal", "https://coinjournal.net/feed/"), |
| ("trustnodes", "https://www.trustnodes.com/feed"), |
| ("cryptopolitan", "https://www.cryptopolitan.com/feed/"), |
| ("crypto-news-flash", "https://www.crypto-news-flash.com/feed/"), |
| ("live-bitcoin-news", "https://www.livebitcoinnews.com/feed/"), |
| ("crypto-reporter", "https://www.crypto-reporter.com/feed/"), |
| ("coinpedia", "https://coinpedia.org/feed/"), |
| ("cryptoadventure", "https://cryptoadventure.org/feed/"), |
| ("coincodex", "https://coincodex.com/blog/feed/"), |
| ] |
|
|
| |
| POSITIVE_WORDS = { |
| "surge", |
| "soar", |
| "rally", |
| "bullish", |
| "buy", |
| "gain", |
| "record", |
| "boom", |
| "adopt", |
| "approve", |
| "launch", |
| "partner", |
| "growth", |
| "profit", |
| "higher", |
| "green", |
| "breakout", |
| "upgrade", |
| "win", |
| "success", |
| } |
| NEGATIVE_WORDS = { |
| "crash", |
| "hack", |
| "exploit", |
| "scam", |
| "fraud", |
| "ban", |
| "sue", |
| "fine", |
| "investigation", |
| "sanction", |
| "drop", |
| "plunge", |
| "bear", |
| "sell", |
| "loss", |
| "decline", |
| "lower", |
| "red", |
| "warning", |
| "risk", |
| "fud", |
| "fear", |
| } |
|
|
|
|
| def score_sentiment(text: str) -> float: |
| """Fast lexicon-based sentiment: -1 (bearish) to +1 (bullish)""" |
| words = set(text.lower().split()) |
| pos = len(words & POSITIVE_WORDS) |
| neg = len(words & NEGATIVE_WORDS) |
| total = pos + neg |
| return (pos - neg) / total if total > 0 else 0.0 |
|
|
|
|
| def extract_tickers(text: str) -> list[str]: |
| """Extract crypto tickers from text""" |
| tickers = set() |
| for match in re.finditer(r"\b[A-Z]{2,5}\b", text): |
| t = match.group() |
| if t not in ("THE", "AND", "FOR", "BTC", "ETH", "SOL"): |
| tickers.add(t) |
| return list(tickers)[:5] |
|
|
|
|
| def fetch_all_feeds(db=None) -> dict: |
| """Fetch ALL 50+ RSS feeds, deduplicate, score, store. Returns stats.""" |
| results = [] |
| sources_seen = set() |
| errors = [] |
|
|
| for source, url in RSS_FEEDS: |
| try: |
| resp = httpx.get(url, timeout=15, headers={"User-Agent": "RMI/3.0 NewsBot"}) |
| if resp.status_code != 200: |
| errors.append(f"{source}: HTTP {resp.status_code}") |
| continue |
|
|
| root = ET.fromstring(resp.content) |
| items = root.findall(".//item") |
| if not items: |
| items = root.findall(".//{http://www.w3.org/2005/Atom}entry") |
|
|
| count = 0 |
| for item in items: |
| title = item.findtext("title", "").strip() |
| description = ( |
| item.findtext("description", "") |
| or item.findtext("{http://www.w3.org/2005/Atom}summary", "") |
| ).strip() |
| link = ( |
| item.findtext("link", "") |
| or (item.find("link") is not None and item.find("link").get("href", "")) |
| ).strip() |
| pub_date = ( |
| item.findtext("pubDate", "") |
| or item.findtext("{http://www.w3.org/2005/Atom}updated", "") |
| or "" |
| ) |
|
|
| if not title or len(title) < 10: |
| continue |
|
|
| doc_id = hashlib.sha256((source + title).encode()).hexdigest()[:20] |
| if doc_id in sources_seen: |
| continue |
| sources_seen.add(doc_id) |
|
|
| sentiment = score_sentiment(title + " " + description) |
| tickers = extract_tickers(title + " " + description) |
|
|
| article = { |
| "id": doc_id, |
| "title": title, |
| "content": (description or title)[:5000], |
| "url": link, |
| "source": source, |
| "sentiment": sentiment, |
| "tickers": tickers, |
| "published": pub_date, |
| "ingested_at": time.time(), |
| } |
| results.append(article) |
| count += 1 |
|
|
| logger.info(f" {source}: {count} articles") |
| except Exception as e: |
| errors.append(f"{source}: {str(e)[:80]}") |
|
|
| |
| try: |
| from dotenv import load_dotenv |
|
|
| load_dotenv("/app/.env", override=True) |
| import os |
|
|
| import redis |
|
|
| r = redis.Redis( |
| host="rmi-redis", port=6379, password=os.getenv("REDIS_PASSWORD"), decode_responses=True |
| ) |
|
|
| |
| for a in results: |
| r.zadd("rmi:news:index", {a["id"]: a["ingested_at"]}) |
| r.set(f"rmi:news:article:{a['id']}", json.dumps(a)) |
|
|
| |
| stats = { |
| "total_fetched": len(results), |
| "sources_successful": len({a["source"] for a in results}), |
| "sources_failed": len(errors), |
| "errors": errors[:10], |
| "last_ingest": time.time(), |
| "sentiment_avg": sum(a["sentiment"] for a in results) / max(len(results), 1), |
| } |
| r.set("rmi:news:stats", json.dumps(stats)) |
|
|
| |
| try: |
| import psycopg2 |
|
|
| pg = psycopg2.connect( |
| host="rmi-postgres", |
| port=5432, |
| user="rmi", |
| password=os.getenv("POSTGRES_PASSWORD"), |
| dbname="rmi", |
| ) |
| cur = pg.cursor() |
| cur.execute(""" |
| CREATE TABLE IF NOT EXISTS crypto_news ( |
| id TEXT PRIMARY KEY, |
| title TEXT, content TEXT, url TEXT, source TEXT, |
| sentiment REAL, tickers TEXT[], |
| published TEXT, ingested_at DOUBLE PRECISION |
| ) |
| """) |
| for a in results: |
| cur.execute( |
| """ |
| INSERT INTO crypto_news (id, title, content, url, source, sentiment, tickers, published, ingested_at) |
| VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) |
| ON CONFLICT (id) DO UPDATE SET sentiment=EXCLUDED.sentiment, tickers=EXCLUDED.tickers |
| """, |
| ( |
| a["id"], |
| a["title"], |
| a["content"], |
| a["url"], |
| a["source"], |
| a["sentiment"], |
| a["tickers"], |
| a["published"], |
| a["ingested_at"], |
| ), |
| ) |
| pg.commit() |
| cur.close() |
| pg.close() |
| stats["in_postgres"] = len(results) |
| except Exception as e: |
| stats["postgres_error"] = str(e)[:100] |
|
|
| except Exception as e: |
| stats = {"total_fetched": len(results), "redis_error": str(e)[:100]} |
|
|
| return stats |
|
|
|
|
| if __name__ == "__main__": |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [news] %(message)s") |
| stats = fetch_all_feeds() |
| logger.info(json.dumps(stats, indent=2)) |
|
|