mirror of
https://github.com/jacob-bd/openclaw-newsroom.git
synced 2026-08-14 08:52:23 +00:00
251 lines
7.8 KiB
Python
251 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Fetch AI tweets via an API supplement to bird CLI.
|
|
|
|
Uses a provider search endpoint for keyword-based Twitter searches. This
|
|
catches breaking news that might be missed by the bird CLI's account-based
|
|
approach.
|
|
|
|
Output: pipe-delimited TITLE|URL|SOURCE format.
|
|
Sources tagged with "(tweet)" when no external article URL exists.
|
|
|
|
Usage:
|
|
python3 fetch_twitter_api.py [--max-queries 3] [--provider auto]
|
|
|
|
Environment:
|
|
TWITTERAPI_IO_KEY - used by the default twitterapi.io path
|
|
XQUIK_API_KEY - used when --provider xquik is selected
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import ssl
|
|
import argparse
|
|
from urllib.request import Request, urlopen
|
|
from urllib.error import HTTPError
|
|
|
|
_SSL_CTX = ssl.create_default_context()
|
|
|
|
TIMEOUT = 15
|
|
TWITTERAPI_BASE = "https://api.twitterapi.io/twitter"
|
|
XQUIK_API_BASE = "https://xquik.com/api/v1"
|
|
XQUIK_CONTRACT_DATE = "2026-04-29"
|
|
PROVIDER_AUTO = "auto"
|
|
PROVIDER_TWITTERAPI = "twitterapi"
|
|
PROVIDER_XQUIK = "xquik"
|
|
PROVIDER_CHOICES = (PROVIDER_AUTO, PROVIDER_TWITTERAPI, PROVIDER_XQUIK)
|
|
|
|
# Search queries - focused on breaking AI news
|
|
SEARCH_QUERIES = [
|
|
'"breaking" (AI OR "artificial intelligence" OR LLM) -is:retweet lang:en',
|
|
'(Anthropic OR Claude OR OpenAI) (announce OR launch OR release) -is:retweet lang:en',
|
|
'(AI OR "artificial intelligence") (acquisition OR merger OR billion OR deal) -is:retweet lang:en',
|
|
]
|
|
|
|
# Minimum engagement to filter noise
|
|
MIN_LIKES = 50
|
|
MIN_FOLLOWERS = 5000
|
|
|
|
|
|
def resolve_provider(provider_choice, env=os.environ):
|
|
"""Resolve an explicit or automatic provider choice to a provider and key."""
|
|
if provider_choice == PROVIDER_XQUIK:
|
|
return PROVIDER_XQUIK, env.get("XQUIK_API_KEY", "")
|
|
if provider_choice == PROVIDER_TWITTERAPI:
|
|
return PROVIDER_TWITTERAPI, env.get("TWITTERAPI_IO_KEY", "")
|
|
|
|
twitterapi_key = env.get("TWITTERAPI_IO_KEY", "")
|
|
if twitterapi_key:
|
|
return PROVIDER_TWITTERAPI, twitterapi_key
|
|
return PROVIDER_XQUIK, env.get("XQUIK_API_KEY", "")
|
|
|
|
|
|
def search_twitter(query, api_key, provider=PROVIDER_TWITTERAPI, max_results=10):
|
|
"""Search tweets via the selected provider."""
|
|
if provider == PROVIDER_XQUIK:
|
|
return search_xquik(query, api_key, max_results=max_results)
|
|
return search_twitterapi(query, api_key, max_results=max_results)
|
|
|
|
|
|
def search_twitterapi(query, api_key, max_results=10):
|
|
"""Search tweets via twitterapi.io."""
|
|
from urllib.parse import urlencode
|
|
|
|
params = urlencode({
|
|
"query": query,
|
|
"queryType": "Latest",
|
|
})
|
|
url = f"{TWITTERAPI_BASE}/tweet/advanced_search?{params}"
|
|
|
|
req = Request(url, headers={
|
|
"X-API-Key": api_key,
|
|
"User-Agent": "NewsScanner/1.0",
|
|
})
|
|
|
|
try:
|
|
with urlopen(req, timeout=TIMEOUT, context=_SSL_CTX) as resp:
|
|
data = json.loads(resp.read().decode('utf-8'))
|
|
return data.get("tweets", [])
|
|
except HTTPError as e:
|
|
if e.code == 401:
|
|
print(" Error: Invalid TWITTERAPI_IO_KEY", file=sys.stderr)
|
|
elif e.code == 429:
|
|
print(" Warning: twitterapi.io rate limit", file=sys.stderr)
|
|
else:
|
|
print(f" Warning: twitterapi.io HTTP {e.code}", file=sys.stderr)
|
|
return []
|
|
except Exception as e:
|
|
print(f" Warning: twitterapi.io error: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
|
|
def search_xquik(query, api_key, max_results=10):
|
|
"""Search tweets via Xquik."""
|
|
from urllib.parse import urlencode
|
|
|
|
params = urlencode({
|
|
"q": query,
|
|
"queryType": "Latest",
|
|
"limit": max_results,
|
|
})
|
|
url = f"{XQUIK_API_BASE}/x/tweets/search?{params}"
|
|
|
|
req = Request(url, headers={
|
|
"x-api-key": api_key,
|
|
"xquik-api-contract": XQUIK_CONTRACT_DATE,
|
|
"User-Agent": "NewsScanner/1.0",
|
|
})
|
|
|
|
try:
|
|
with urlopen(req, timeout=TIMEOUT, context=_SSL_CTX) as resp:
|
|
data = json.loads(resp.read().decode('utf-8'))
|
|
return data.get("tweets", [])
|
|
except HTTPError as e:
|
|
if e.code == 401:
|
|
print(" Error: Invalid XQUIK_API_KEY", file=sys.stderr)
|
|
elif e.code == 402:
|
|
print(" Warning: Xquik API access required", file=sys.stderr)
|
|
elif e.code == 429:
|
|
print(" Warning: Xquik rate limit", file=sys.stderr)
|
|
else:
|
|
print(f" Warning: Xquik HTTP {e.code}", file=sys.stderr)
|
|
return []
|
|
except Exception as e:
|
|
print(f" Warning: Xquik error: {e}", file=sys.stderr)
|
|
return []
|
|
|
|
|
|
def _external_url_from_entity(url_entity):
|
|
expanded = (
|
|
url_entity.get("expanded_url")
|
|
or url_entity.get("expandedUrl")
|
|
or url_entity.get("url", "")
|
|
)
|
|
if (
|
|
expanded
|
|
and "twitter.com" not in expanded
|
|
and "t.co" not in expanded
|
|
and "x.com" not in expanded
|
|
):
|
|
return expanded
|
|
return ""
|
|
|
|
|
|
def _author_screen_name(author):
|
|
return (
|
|
author.get("userName")
|
|
or author.get("username")
|
|
or author.get("screenName")
|
|
or ""
|
|
)
|
|
|
|
|
|
def extract_url_from_tweet(tweet):
|
|
"""
|
|
Extract the first external URL from a tweet.
|
|
Returns (url, is_tweet_only):
|
|
- (external_url, False) if an article URL was found in entities
|
|
- (tweet_url, True) if only the tweet's own URL is available
|
|
- ("", True) if no URL could be constructed
|
|
"""
|
|
entities = tweet.get("entities", {})
|
|
urls = entities.get("urls", [])
|
|
for u in urls:
|
|
expanded = _external_url_from_entity(u)
|
|
if expanded:
|
|
return expanded, False
|
|
|
|
author = tweet.get("author", {})
|
|
screen_name = _author_screen_name(author)
|
|
tweet_id = tweet.get("id", "")
|
|
if screen_name and tweet_id:
|
|
return f"https://x.com/{screen_name}/status/{tweet_id}", True
|
|
return "", True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Fetch AI tweets via an API provider")
|
|
parser.add_argument('--max-queries', type=int, default=3,
|
|
help='Max queries to run (default: 3)')
|
|
parser.add_argument('--provider', choices=PROVIDER_CHOICES,
|
|
default=PROVIDER_AUTO,
|
|
help='Tweet search provider (default: auto)')
|
|
args = parser.parse_args()
|
|
|
|
provider, api_key = resolve_provider(args.provider)
|
|
if not api_key:
|
|
print(" Warning: TWITTERAPI_IO_KEY or XQUIK_API_KEY not set, skipping",
|
|
file=sys.stderr)
|
|
return 0
|
|
|
|
seen_urls = set()
|
|
all_results = []
|
|
|
|
queries = SEARCH_QUERIES[:args.max_queries]
|
|
|
|
for query in queries:
|
|
tweets = search_twitter(query, api_key, provider=provider)
|
|
for tweet in tweets:
|
|
likes = tweet.get("likeCount", 0)
|
|
author = tweet.get("author", {})
|
|
followers = author.get("followers", 0)
|
|
|
|
if likes < MIN_LIKES and followers < MIN_FOLLOWERS:
|
|
continue
|
|
|
|
text = tweet.get("text", "").strip()
|
|
if not text:
|
|
continue
|
|
|
|
title = re.sub(r'https?://\S+', '', text).strip()
|
|
title = title.replace('\n', ' ').replace('|', ' -')
|
|
title = re.sub(r'\s+', ' ', title)
|
|
|
|
if len(title) > 200:
|
|
title = title[:197] + "..."
|
|
if len(title) < 15:
|
|
continue
|
|
|
|
url, is_tweet_only = extract_url_from_tweet(tweet)
|
|
if not url or url in seen_urls:
|
|
continue
|
|
seen_urls.add(url)
|
|
|
|
screen_name = _author_screen_name(author) or "unknown"
|
|
source_tag = f"X/@{screen_name} (tweet)" if is_tweet_only else f"X/@{screen_name}"
|
|
all_results.append(f"{title}|{url}|{source_tag}")
|
|
|
|
for line in all_results:
|
|
print(line)
|
|
|
|
print(
|
|
f" Done: {len(all_results)} tweets from {len(queries)} queries via {provider}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|