Files
last30days-skill/scripts/lib/scrapecreators_x.py
Jeffrey Sperling 036bcd2ae3 Address review feedback: deduplicate query_type, clean unused imports, fix defaults
- Remove duplicate detect_query_type from query.py (divergent 5-type version);
  canonical 7-type version lives in query_type.py
- Fix reddit.py import to use query_type.detect_query_type
- Clean unused STOPWORDS/SYNONYMS/tokenize imports from youtube_yt, instagram,
  tiktok, scrapecreators_x, bird_x after relevance consolidation
- Fix _relevance_filter default from 0.7 to 0.0 (items without relevance
  should not silently pass the filter)
- Remove --dateafter from yt-dlp (returns 0 results for evergreen topics)
- Remove restrictSearchableAttributes from HN search (misses Ask/Show HN)
- Lower HN points filter from >5 to >2 (avoids filtering niche posts)
- Add error logging to select_openai_model HTTP failures
- Remove mise.toml and internal planning doc from repo
- Update module docstrings to describe current purpose, not migration history
- Update tests to import from canonical relevance module
2026-03-11 18:40:07 -07:00

183 lines
5.9 KiB
Python

"""X/Twitter search via ScrapeCreators API for /last30days.
Uses ScrapeCreators REST API to search Twitter/X by keyword.
Same API key as Reddit, TikTok, and Instagram - one key covers all social sources.
Requires SCRAPECREATORS_API_KEY in config.
API docs: https://scrapecreators.com/docs
"""
import sys
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import requests as _requests
except ImportError:
_requests = None
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/twitter"
DEPTH_CONFIG = {
"quick": {"results_per_page": 10},
"default": {"results_per_page": 20},
"deep": {"results_per_page": 40},
}
from .relevance import token_overlap_relevance as _compute_relevance
def _extract_core_subject(topic: str) -> str:
"""Extract core subject from verbose query for Twitter search."""
from .query import extract_core_subject
_SC_X_NOISE = frozenset({
'best', 'top', 'good', 'great', 'awesome',
'latest', 'new', 'news', 'update', 'updates',
'trending', 'hottest', 'popular', 'viral',
'practices', 'features', 'recommendations', 'advice',
})
return extract_core_subject(topic, noise=_SC_X_NOISE)
def _log(msg: str):
if sys.stderr.isatty():
sys.stderr.write(f"[X/SC] {msg}\n")
sys.stderr.flush()
def _sc_headers(token: str) -> Dict[str, str]:
return {
"x-api-key": token,
"Content-Type": "application/json",
}
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
"""Parse date from ScrapeCreators Twitter item to YYYY-MM-DD."""
# Try created_at string (e.g. "Wed Oct 10 20:19:24 +0000 2018")
created_at = item.get("created_at")
if created_at and isinstance(created_at, str):
try:
dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
# Try unix timestamp
ts = item.get("timestamp") or item.get("created_at_timestamp")
if ts:
try:
dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError, OSError):
pass
# Try ISO format
for key in ("created_at", "date"):
val = item.get(key)
if val and isinstance(val, str):
try:
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
return None
def search_x(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
token: str = None,
) -> Dict[str, Any]:
"""Search X/Twitter via ScrapeCreators API.
Returns:
Dict with 'items' list (in normalize_x_items format) and optional 'error'.
"""
if not token:
return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
if not _requests:
return {"items": [], "error": "requests library not installed"}
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
core_topic = _extract_core_subject(topic)
_log(f"Searching X for '{core_topic}' (depth={depth}, count={config['results_per_page']})")
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/search/tweets",
params={"query": core_topic, "sort_by": "relevance"},
headers=_sc_headers(token),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
raw_items = data.get("tweets") or data.get("data") or data.get("results") or []
raw_items = raw_items[:config["results_per_page"]]
items = []
for i, raw in enumerate(raw_items):
tweet_id = str(raw.get("id") or raw.get("tweet_id") or raw.get("id_str") or f"sc-x-{i}")
text = raw.get("full_text") or raw.get("text") or ""
user = raw.get("user") or raw.get("author") or {}
author_handle = user.get("screen_name") or user.get("username") or ""
# Engagement metrics
likes = raw.get("favorite_count") or raw.get("likes") or 0
retweets = raw.get("retweet_count") or raw.get("retweets") or 0
replies = raw.get("reply_count") or raw.get("replies") or 0
quotes = raw.get("quote_count") or raw.get("quotes") or 0
date_str = _parse_date(raw)
relevance = _compute_relevance(core_topic, text)
url = ""
if author_handle and tweet_id and not tweet_id.startswith("sc-x-"):
url = f"https://x.com/{author_handle}/status/{tweet_id}"
items.append({
"id": tweet_id,
"text": text,
"url": url,
"author_handle": author_handle,
"date": date_str,
"engagement": {
"likes": likes,
"reposts": retweets,
"replies": replies,
"quotes": quotes,
},
"relevance": relevance,
"why_relevant": f"X: @{author_handle}: {text[:60]}" if text else f"X: {core_topic}",
})
# Date filter
in_range = [i for i in items if i["date"] and from_date <= i["date"] <= to_date]
out_of_range = len(items) - len(in_range)
if in_range:
items = in_range
if out_of_range:
_log(f"Filtered {out_of_range} tweets outside date range")
else:
_log(f"No tweets within date range, keeping all {len(items)}")
# Sort by engagement (likes + retweets)
items.sort(key=lambda x: (x["engagement"]["likes"] + x["engagement"]["reposts"]), reverse=True)
_log(f"Found {len(items)} tweets")
return {"items": items}
def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse search response to normalized format."""
return response.get("items", [])