rmi-backend/app/caching_shield/social_feed.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

299 lines
16 KiB
Python
Raw Blame History

"""
RMI Social Feed - X/Twitter + Reddit crypto news with aggressive caching.
Top 50 crypto X accounts monitored for breaking news.
Falls back to Nitter when rate limited.
Reddit top posts from r/CryptoCurrency, r/ethfinance, r/solana.
"""
import asyncio
import hashlib
import logging
import time
from datetime import UTC, datetime
import httpx
logger = logging.getLogger("social_feed")
# ═══════════════════════════════════════════════════════════════════════════
# TOP 50 CRYPTO X ACCOUNTS - by influence/relevance
# ═══════════════════════════════════════════════════════════════════════════
TOP_CRYPTO_ACCOUNTS = [
{"handle": "cz_binance", "name": "CZ Binance", "followers": "9.5M", "pfp": "cz_binance"},
{"handle": "VitalikButerin", "name": "Vitalik Buterin", "followers": "5.5M", "pfp": "vitalik"},
{"handle": "SBF_FTX", "name": "SBF", "followers": "1.1M", "pfp": "sbf"},
{"handle": "aantonop", "name": "Andreas Antonopoulos", "followers": "750K", "pfp": "aantonop"},
{"handle": "balajis", "name": "Balaji Srinivasan", "followers": "1M", "pfp": "balajis"},
{"handle": "cdixon", "name": "Chris Dixon", "followers": "950K", "pfp": "cdixon"},
{"handle": "brian_armstrong", "name": "Brian Armstrong", "followers": "1.3M", "pfp": "brian"},
{"handle": "aeyakovenko", "name": "Anatoly Yakovenko", "followers": "400K", "pfp": "toly"},
{"handle": "saylor", "name": "Michael Saylor", "followers": "3.6M", "pfp": "saylor"},
{"handle": "mcuban", "name": "Mark Cuban", "followers": "8.8M", "pfp": "cuban"},
{"handle": "david_sacks", "name": "David Sacks", "followers": "900K", "pfp": "sacks"},
{"handle": "twobitidiot", "name": "Ryan Selkis", "followers": "400K", "pfp": "selkis"},
{"handle": "RamaswamyNik", "name": "Vivek Ramaswamy", "followers": "3.5M", "pfp": "vivek"},
{"handle": "elonmusk", "name": "Elon Musk", "followers": "210M", "pfp": "elon"},
{"handle": "novogratz", "name": "Mike Novogratz", "followers": "540K", "pfp": "novo"},
{"handle": "CryptoHayes", "name": "Arthur Hayes", "followers": "600K", "pfp": "hayes"},
{"handle": "zhusu", "name": "Zhu Su", "followers": "650K", "pfp": "zhu"},
{"handle": "cobie", "name": "Cobie", "followers": "800K", "pfp": "cobie"},
{"handle": "loomdart", "name": "Loomdart", "followers": "350K", "pfp": "loom"},
{"handle": "0xngmi", "name": "0xngmi", "followers": "250K", "pfp": "ngmi"},
{"handle": "gaut", "name": "Gaut", "followers": "200K", "pfp": "gaut"},
{"handle": "ThinkingUSD", "name": "ThinkingUSD", "followers": "180K", "pfp": "thinking"},
{"handle": "blknoiz06", "name": "Blknoiz06", "followers": "300K", "pfp": "blknoiz"},
{"handle": "Dynamo_Patrick", "name": "Patrick Dynamo", "followers": "350K", "pfp": "dynamo"},
{"handle": "TheCryptoLark", "name": "Lark Davis", "followers": "1.2M", "pfp": "lark"},
{"handle": "APompliano", "name": "Anthony Pompliano", "followers": "1.7M", "pfp": "pomp"},
{"handle": "RaoulGMI", "name": "Raoul Pal", "followers": "1.1M", "pfp": "raoul"},
{"handle": "Melt_Dem", "name": "Meltem Demirors", "followers": "300K", "pfp": "meltem"},
{"handle": "laurashin", "name": "Laura Shin", "followers": "350K", "pfp": "laura"},
{"handle": "nic__carter", "name": "Nic Carter", "followers": "400K", "pfp": "nic"},
{"handle": "ercwl", "name": "Eric Wall", "followers": "200K", "pfp": "ercwl"},
{"handle": "udiWertheimer", "name": "Udi Wertheimer", "followers": "250K", "pfp": "udi"},
{"handle": "PeterLBrandt", "name": "Peter Brandt", "followers": "700K", "pfp": "brandt"},
{"handle": "100trillionUSD", "name": "PlanB", "followers": "1.9M", "pfp": "planb"},
{"handle": "woonomic", "name": "Willy Woo", "followers": "1.1M", "pfp": "woo"},
{"handle": "CryptoCapo_", "name": "Crypto Capo", "followers": "900K", "pfp": "capo"},
{"handle": "CryptoCred", "name": "CryptoCred", "followers": "600K", "pfp": "cred"},
{"handle": "Pentosh1", "name": "Pentoshi", "followers": "850K", "pfp": "pentoshi"},
{"handle": "intocryptoverse", "name": "Benjamin Cowen", "followers": "900K", "pfp": "cowen"},
{"handle": "CryptoKaleo", "name": "Kaleo", "followers": "650K", "pfp": "kaleo"},
{"handle": "rektcapital", "name": "Rekt Capital", "followers": "500K", "pfp": "rektcap"},
{"handle": "CryptoMichNL", "name": "Micha<EFBFBD>l van de Poppe", "followers": "750K", "pfp": "mich"},
{"handle": "CryptoJack", "name": "CryptoJack", "followers": "400K", "pfp": "cryptojack"},
{"handle": "AltcoinGordon", "name": "Altcoin Gordon", "followers": "550K", "pfp": "gordon"},
{"handle": "Ashcryptoreal", "name": "Ash Crypto", "followers": "1.1M", "pfp": "ash"},
{"handle": "Trader_XO", "name": "TraderXO", "followers": "380K", "pfp": "xo"},
{"handle": "CryptoWizardd", "name": "CryptoWizard", "followers": "350K", "pfp": "wizard"},
{"handle": "CryptosBatman", "name": "Batman", "followers": "300K", "pfp": "batman"},
{"handle": "ColdBloodShill", "name": "Cold Blooded Shiller", "followers": "280K", "pfp": "cbs"},
{"handle": "MacroCRG", "name": "MacroCRG", "followers": "250K", "pfp": "crg"},
{"handle": "WatcherGuru", "name": "Watcher.Guru", "followers": "2.1M", "pfp": "watcher"},
{"handle": "unusual_whales", "name": "Unusual Whales", "followers": "1.5M", "pfp": "whales"},
{"handle": "dbnewstweets", "name": "DB News", "followers": "750K", "pfp": "db"},
{"handle": "ZeroHedge", "name": "ZeroHedge", "followers": "1.8M", "pfp": "zh"},
{
"handle": "SpectrumMarkets",
"name": "Spectrum Markets",
"followers": "120K",
"pfp": "spectrum",
},
{"handle": "TreeNews5", "name": "Tree News", "followers": "500K", "pfp": "tree"},
{
"handle": "Crypto_Twitter",
"name": "Crypto Twitter News",
"followers": "450K",
"pfp": "ctnews",
},
{"handle": "DegenerateNews", "name": "Degenerate News", "followers": "380K", "pfp": "degen"},
{"handle": "NFT_GOD", "name": "NFT God", "followers": "280K", "pfp": "nftgod"},
{"handle": "CryptoKoryo", "name": "Koryo", "followers": "180K", "pfp": "koryo"},
{"handle": "OnchainData", "name": "Onchain Data Nerd", "followers": "120K", "pfp": "onchain"},
{"handle": "spl_brah", "name": "SPL Brah", "followers": "90K", "pfp": "spl"},
{"handle": "solana_daily", "name": "Solana Daily", "followers": "250K", "pfp": "soldaily"},
{"handle": "SolanaLegend", "name": "Solana Legend", "followers": "180K", "pfp": "sollegend"},
{"handle": "SolanaConf", "name": "Solana News", "followers": "150K", "pfp": "solconf"},
{"handle": "0xMert_", "name": "Mert", "followers": "350K", "pfp": "mert"},
{"handle": "0xTanishq", "name": "Tanishq", "followers": "130K", "pfp": "tanishq"},
{"handle": "Deebs_DeFi", "name": "Deebs DeFi", "followers": "160K", "pfp": "deebs"},
{"handle": "DeFi_Dad", "name": "DeFi Dad", "followers": "200K", "pfp": "defidad"},
{"handle": "DeFi_Ignas", "name": "Ignas DeFi", "followers": "220K", "pfp": "ignas"},
{"handle": "Crypto_GodJohn", "name": "Crypto God John", "followers": "280K", "pfp": "godjohn"},
{"handle": "CryptoNTez", "name": "Crypto NTez", "followers": "90K", "pfp": "ntez"},
{"handle": "0xSisyphus", "name": "Sisyphus", "followers": "110K", "pfp": "sisyphus"},
{"handle": "vydamo_", "name": "Vydamo", "followers": "85K", "pfp": "vydamo"},
{"handle": "0xKillWolf", "name": "KillWolf", "followers": "75K", "pfp": "killwolf"},
{"handle": "MoonOverlord", "name": "Moon Overlord", "followers": "95K", "pfp": "moon"},
{"handle": "CryptoAmb", "name": "Crypto Amber", "followers": "70K", "pfp": "amber"},
{"handle": "satsdart", "name": "SatsDart", "followers": "65K", "pfp": "sats"},
{"handle": "DeFi_Kamikaze", "name": "Kamikaze DeFi", "followers": "55K", "pfp": "kami"},
{"handle": "Crypto_Ninja", "name": "Crypto Ninja", "followers": "80K", "pfp": "ninja"},
{"handle": "0xWave_", "name": "0xWave", "followers": "60K", "pfp": "wave"},
{"handle": "Crypto_Chase", "name": "Chase Crypto", "followers": "45K", "pfp": "chase"},
{"handle": "AltCryptoGems", "name": "Altcoin Gems", "followers": "140K", "pfp": "gems"},
{"handle": "DeFi_Warhol", "name": "DeFi Warhol", "followers": "50K", "pfp": "warhol"},
{"handle": "Crypto_Link", "name": "Crypto Link", "followers": "70K", "pfp": "link"},
{"handle": "SolanaAlpha_", "name": "Solana Alpha", "followers": "55K", "pfp": "solalpha"},
{"handle": "DeFi_Prime", "name": "DeFi Prime", "followers": "40K", "pfp": "prime"},
{"handle": "Crypto_Oracle", "name": "Crypto Oracle", "followers": "65K", "pfp": "oracle"},
{"handle": "chain_news", "name": "Chain News", "followers": "85K", "pfp": "chain"},
{"handle": "Crypto_Banter", "name": "Crypto Banter", "followers": "220K", "pfp": "banter"},
{"handle": "LunarCRUSH", "name": "LunarCrush", "followers": "250K", "pfp": "lunar"},
{"handle": "SantimentFeed", "name": "Santiment", "followers": "140K", "pfp": "santi"},
{"handle": "CoinMarketCap", "name": "CoinMarketCap", "followers": "3.5M", "pfp": "cmc"},
{"handle": "CoinGecko", "name": "CoinGecko", "followers": "1.8M", "pfp": "gecko"},
{"handle": "MessariCrypto", "name": "Messari", "followers": "450K", "pfp": "messari"},
{"handle": "ArkhamIntel", "name": "Arkham", "followers": "700K", "pfp": "arkham"},
{"handle": "DuneAnalytics", "name": "Dune", "followers": "350K", "pfp": "dune"},
{"handle": "Nansen_ai", "name": "Nansen", "followers": "280K", "pfp": "nansen"},
{"handle": "Glassnode", "name": "Glassnode", "followers": "320K", "pfp": "glass"},
]
# Reddit crypto subreddits
REDDIT_SUBS = [
"CryptoCurrency",
"ethfinance",
"solana",
"bitcoin",
"defi",
"CryptoTrading",
"CryptoMarkets",
"ethereum",
"CryptoTechnology",
]
# Cache TTLs
TTL_TWITTER = 300 # 5 min
TTL_REDDIT = 600 # 10 min
TTL_NITTER = 900 # 15 min (fallback, longer cache)
_cache: dict[str, tuple] = {}
def _cache_get(key: str) -> dict | None:
entry = _cache.get(key)
if entry:
expiry, data = entry
if time.monotonic() < expiry:
return data
del _cache[key]
return None
def _cache_set(key: str, data: dict, ttl: int):
_cache[key] = (time.monotonic() + ttl, data)
async def get_twitter_feed(limit: int = 30) -> dict:
"""Get top crypto tweets via Nitter (free, no auth, cached)."""
cache_key = f"twitter_feed_{limit}"
cached = _cache_get(cache_key)
if cached:
return cached
tweets = []
async with httpx.AsyncClient(timeout=15) as c:
for account in TOP_CRYPTO_ACCOUNTS[:30]: # Top 15 to minimize calls
try:
# Try Nitter (free, no auth)
nitter_url = f"https://nitter.net/{account['handle']}/rss"
r = await c.get(nitter_url)
if r.status_code == 200:
import xml.etree.ElementTree as ET
root = ET.fromstring(r.text)
for item in root.findall(".//item")[:3]:
title = item.find("title").text if item.find("title") is not None else ""
link = item.find("link").text if item.find("link") is not None else ""
item.find("description").text if item.find("description") is not None else ""
pubdate = item.find("pubDate").text if item.find("pubDate") is not None else ""
if title and "RT @" not in title:
tweets.append(
{
"id": hashlib.md5(link.encode()).hexdigest()[:12],
"text": title.replace(f"{account['name']}: ", ""),
"author": account["name"],
"handle": account["handle"],
"followers": account["followers"],
"pfp": f"https://unavatar.io/twitter/{account['handle']}",
"url": link,
"published": pubdate,
"source": "x",
"type": "tweet",
}
)
except Exception:
continue
result = {
"tweets": sorted(tweets, key=lambda t: t.get("published", ""), reverse=True)[:limit],
"accounts_monitored": len(TOP_CRYPTO_ACCOUNTS),
"new_accounts": "50 top news breakers + 49 underrated alpha accounts",
"updated": datetime.now(UTC).isoformat(),
}
_cache_set(cache_key, result, TTL_NITTER if tweets else 60)
return result
async def get_reddit_feed(limit: int = 20) -> dict:
"""Get top crypto Reddit posts (free, no auth, cached)."""
cache_key = f"reddit_feed_{limit}"
cached = _cache_get(cache_key)
if cached:
return cached
posts = []
async with httpx.AsyncClient(timeout=15) as c:
for sub in REDDIT_SUBS[:4]: # Top 4 to minimize calls
try:
r = await c.get(
f"https://www.reddit.com/r/{sub}/hot.json?limit=5",
headers={"User-Agent": "RMI/1.0"},
)
if r.status_code == 200:
data = r.json()
for child in data.get("data", {}).get("children", []):
d = child["data"]
posts.append(
{
"id": d["id"],
"title": d["title"],
"author": d["author"],
"subreddit": d["subreddit"],
"score": d["score"],
"num_comments": d["num_comments"],
"url": f"https://reddit.com{d['permalink']}",
"published": datetime.fromtimestamp(d["created_utc"], UTC).isoformat(),
"source": "reddit",
"type": "post",
}
)
except Exception:
continue
result = {
"posts": sorted(posts, key=lambda p: p.get("score", 0), reverse=True)[:limit],
"subreddits": REDDIT_SUBS,
"updated": datetime.now(UTC).isoformat(),
}
_cache_set(cache_key, result, TTL_REDDIT)
return result
async def get_social_feed(limit_twitter: int = 30, limit_reddit: int = 20) -> dict:
"""Get combined social feed - X + Reddit, sorted by recency."""
twitter, reddit = await asyncio.gather(
get_twitter_feed(limit_twitter),
get_reddit_feed(limit_reddit),
return_exceptions=True,
)
twitter_data = twitter if not isinstance(twitter, Exception) else {"tweets": []}
reddit_data = reddit if not isinstance(reddit, Exception) else {"posts": []}
return {
"x": twitter_data,
"reddit": reddit_data,
"total_social_sources": len(TOP_CRYPTO_ACCOUNTS) + len(REDDIT_SUBS),
"updated": datetime.now(UTC).isoformat(),
}
def get_top_accounts() -> list:
"""Get the list of monitored X accounts with profile pics."""
return [
{
"handle": a["handle"],
"name": a["name"],
"followers": a["followers"],
"pfp": f"https://unavatar.io/twitter/{a['handle']}",
}
for a in TOP_CRYPTO_ACCOUNTS
]