| """ |
| X/Twitter Social Intelligence via Web Scraping |
| ================================================ |
| Uses web_search + web_extract for tweet discovery and content. |
| No API credits needed. Runs as a cron job every 6 hours. |
| |
| Cache strategy: |
| - Tweet text: cached 24h (doesn't change) |
| - Engagement metrics: cached 1h (changes frequently) |
| - Profile data: cached 24h |
| - Sentiment/analysis: cached 6h |
| |
| Cron: Every 6 hours, discover new tweets, extract content, update metrics. |
| """ |
|
|
| import logging |
| from datetime import UTC, datetime |
|
|
| from app.databus.cache import CacheLayer, get_cache |
|
|
| logger = logging.getLogger("databus.social_scraper") |
|
|
| |
| OUR_HANDLE = "CryptoRugMunch" |
| OUR_USER_ID = "1771377421117169668" |
|
|
| |
| CACHE_TTL_TWEET = 86400 |
| CACHE_TTL_METRICS = 3600 |
| CACHE_TTL_PROFILE = 86400 |
| CACHE_TTL_DISCOVERY = 21600 |
|
|
|
|
| class XWebScraper: |
| """ |
| X/Twitter data via web search + extract. No API needed. |
| |
| Uses: |
| - web_search: discover tweets by keyword/from:handle |
| - web_extract: pull full tweet content from URLs |
| - DataBus cache: dedup and TTL management |
| |
| Designed to run as a cron job every 6 hours. |
| """ |
|
|
| def __init__(self, cache: CacheLayer = None): |
| self.cache = cache or get_cache() |
|
|
| async def discover_tweets( |
| self, handle: str = OUR_HANDLE, since_date: str | None = None, limit: int = 50 |
| ) -> list[dict]: |
| """ |
| Discover tweets from @handle using web_search. |
| Returns list of {id, url, text_snippet, date, source}. |
| """ |
| cache_key = f"social:x:discovery:{handle}:{since_date or 'latest'}" |
|
|
| |
| cached = await self.cache.get(cache_key) |
| if cached: |
| return cached |
|
|
| |
| from hermes_tools import web_extract, web_search |
|
|
| all_tweets = {} |
| queries = [ |
| f"from:{handle}", |
| f"site:x.com/{handle} 2026", |
| f"site:x.com/{handle} status", |
| ] |
|
|
| if since_date: |
| queries.append(f"from:{handle} since:{since_date}") |
|
|
| for q in queries: |
| try: |
| result = web_search(q, limit=10) |
| for item in result.get("data", {}).get("web", []): |
| url = item.get("url", "") |
| desc = item.get("description", "") |
| title = item.get("title", "") |
|
|
| |
| tweet_id = url.split("/")[-1] if "/" in url else "" |
| if not tweet_id.isdigit(): |
| continue |
|
|
| if tweet_id not in all_tweets: |
| all_tweets[tweet_id] = { |
| "id": tweet_id, |
| "url": url, |
| "title": title, |
| "description": desc, |
| "discovered_at": datetime.now(UTC).isoformat(), |
| } |
| except Exception as e: |
| logger.warning(f"Search error for '{q}': {e}") |
| continue |
|
|
| |
| tweet_urls = [ |
| t["url"] |
| for t in all_tweets.values() |
| if "CryptoRugMunch/status/" in t["url"] or "twitter.com/CryptoRugMunch/status/" in t["url"] |
| ] |
|
|
| if tweet_urls: |
| for i in range(0, len(tweet_urls), 5): |
| batch = tweet_urls[i : i + 5] |
| try: |
| results = web_extract(batch) |
| for r in results.get("results", []): |
| if r.get("content"): |
| url = r.get("url", "") |
| tweet_id = url.split("/")[-1] if "/" in url else "" |
| if tweet_id in all_tweets: |
| all_tweets[tweet_id]["full_text"] = r["content"][:2000] |
| all_tweets[tweet_id]["extracted_at"] = datetime.now(UTC).isoformat() |
| except Exception as e: |
| logger.warning(f"Extract error: {e}") |
| continue |
|
|
| tweets = list(all_tweets.values()) |
|
|
| |
| await self.cache.set(cache_key, tweets, ttl=CACHE_TTL_DISCOVERY) |
|
|
| |
| for tweet in tweets: |
| await self.cache.set(f"social:x:tweet:{tweet['id']}", tweet, ttl=CACHE_TTL_TWEET) |
|
|
| logger.info(f"Discovered {len(tweets)} tweets for @{handle}") |
| return tweets |
|
|
| async def get_profile(self, handle: str = OUR_HANDLE) -> dict | None: |
| """ |
| Get profile data via web search. Returns cached if available. |
| """ |
| cache_key = f"social:x:profile:{handle}" |
| cached = await self.cache.get(cache_key) |
| if cached: |
| return cached |
|
|
| from hermes_tools import web_search |
|
|
| try: |
| result = web_search(f"@{handle} twitter profile followers", limit=5) |
| for item in result.get("data", {}).get("web", []): |
| desc = item.get("description", "") |
| if handle.lower() in desc.lower() and "follower" in desc.lower(): |
| |
| import re |
|
|
| match = re.search(r"(\d[\d,]+)\s+follower", desc) |
| followers = int(match.group(1).replace(",", "")) if match else None |
|
|
| profile = { |
| "handle": handle, |
| "followers": followers, |
| "source_url": item.get("url", ""), |
| "description": desc, |
| "updated_at": datetime.now(UTC).isoformat(), |
| } |
| await self.cache.set(cache_key, profile, ttl=CACHE_TTL_PROFILE) |
| return profile |
| except Exception as e: |
| logger.warning(f"Profile search error: {e}") |
|
|
| return None |
|
|
| async def get_mentions(self, handle: str = OUR_HANDLE, limit: int = 20) -> list[dict]: |
| """ |
| Find tweets mentioning @handle. |
| """ |
| cache_key = f"social:x:mentions:{handle}" |
| cached = await self.cache.get(cache_key) |
| if cached: |
| return cached |
|
|
| from hermes_tools import web_search |
|
|
| mentions = [] |
| try: |
| result = web_search(f"@{handle} -from:{handle}", limit=limit) |
| for item in result.get("data", {}).get("web", []): |
| url = item.get("url", "") |
| if "/status/" in url and handle.lower() not in url.lower().split("/status/")[0]: |
| mentions.append( |
| { |
| "url": url, |
| "title": item.get("title", ""), |
| "description": item.get("description", ""), |
| "discovered_at": datetime.now(UTC).isoformat(), |
| } |
| ) |
| except Exception as e: |
| logger.warning(f"Mentions search error: {e}") |
|
|
| await self.cache.set(cache_key, mentions, ttl=CACHE_TTL_METRICS) |
| return mentions |
|
|
| async def get_trending_topics(self) -> list[dict]: |
| """Get current crypto trending topics via web search.""" |
| cache_key = "social:x:trending:crypto" |
| cached = await self.cache.get(cache_key) |
| if cached: |
| return cached |
|
|
| from hermes_tools import web_search |
|
|
| topics = [] |
| searches = [ |
| "crypto rug pull trending today", |
| "crypto scam alert today 2026", |
| "cryptocurrency security news", |
| ] |
|
|
| for q in searches: |
| try: |
| result = web_search(q, limit=5) |
| for item in result.get("data", {}).get("web", []): |
| topics.append( |
| { |
| "query": q, |
| "title": item.get("title", ""), |
| "url": item.get("url", ""), |
| "description": item.get("description", "")[:200], |
| "discovered_at": datetime.now(UTC).isoformat(), |
| } |
| ) |
| except Exception: |
| continue |
|
|
| await self.cache.set(cache_key, topics, ttl=CACHE_TTL_METRICS) |
| return topics |
|
|
| async def get_engagement_report(self, handle: str = OUR_HANDLE) -> dict: |
| """ |
| Generate an engagement report based on discovered tweets. |
| Computes avg likes, best performing tweets, posting frequency. |
| """ |
| tweets = await self.discover_tweets(handle) |
| if not tweets: |
| return {"error": "No tweets discovered"} |
|
|
| |
| import re |
|
|
| total_likes = 0 |
| total_replies = 0 |
| tweets_with_metrics = 0 |
|
|
| for t in tweets: |
| desc = (t or {}).get("description", "") |
| likes_match = re.search(r"(\d+)\s+likes?", desc) |
| replies_match = re.search(r"(\d+)\s+repl(?:ies|y)", desc) |
|
|
| if likes_match: |
| total_likes += int(likes_match.group(1)) |
| tweets_with_metrics += 1 |
| if replies_match: |
| total_replies += int(replies_match.group(1)) |
|
|
| avg_likes = total_likes / max(1, tweets_with_metrics) |
|
|
| report = { |
| "handle": handle, |
| "total_tweets_discovered": len(tweets), |
| "tweets_with_metrics": tweets_with_metrics, |
| "total_likes": total_likes, |
| "total_replies": total_replies, |
| "avg_likes_per_tweet": round(avg_likes, 1), |
| "best_tweets": sorted( |
| [t for t in tweets if t and t.get("description")], |
| key=lambda t: int(re.search(r"(\d+)\s+likes?", t.get("description", "")).group(1)) |
| if re.search(r"(\d+)\s+likes?", t.get("description", "")) |
| else 0, |
| reverse=True, |
| )[:5], |
| "generated_at": datetime.now(UTC).isoformat(), |
| } |
|
|
| return report |
|
|
|
|
| |
| async def run_social_scan(): |
| """Run a full social scan — called by cron every 6 hours.""" |
| cache = get_cache() |
| scraper = XWebScraper(cache) |
|
|
| |
| tweets = await scraper.discover_tweets() |
| logger.info(f"Social scan: discovered {len(tweets)} tweets") |
|
|
| |
| profile = await scraper.get_profile() |
| logger.info(f"Social scan: profile updated - {profile}") |
|
|
| |
| mentions = await scraper.get_mentions() |
| logger.info(f"Social scan: found {len(mentions)} mentions") |
|
|
| |
| trends = await scraper.get_trending_topics() |
| logger.info(f"Social scan: found {len(trends)} trending items") |
|
|
| |
| report = await scraper.get_engagement_report() |
| logger.info(f"Social scan: engagement report - avg {report.get('avg_likes_per_tweet', 0)} likes/tweet") |
|
|
| return { |
| "tweets_found": len(tweets), |
| "mentions_found": len(mentions), |
| "trends_found": len(trends), |
| "report": report, |
| } |
|
|