5b87cca886
is_available() only caught FileNotFoundError and TimeoutExpired. On WSL, a /mnt/c/.../WindowsApps entry on $PATH returns EACCES during exec, and Python raises PermissionError. That escaped is_available() and crashed pipeline.diagnose() before any source ran. Catch OSError instead. It covers FileNotFoundError, PermissionError, and any other spawn-time OS error, so a non-executable xurl on PATH falls through to the next backend instead of aborting the run.
173 lines
5.2 KiB
Python
173 lines
5.2 KiB
Python
"""X (Twitter) search via xurl CLI — official X API v2 with OAuth2.
|
||
|
||
xurl is an open-source CLI for the X API (https://github.com/openclaw/xurl).
|
||
It uses OAuth2 with PKCE and automatic token refresh, requiring only a free
|
||
X Developer App. No xAI subscription or browser cookies needed.
|
||
|
||
Install: npm install -g xurl
|
||
Auth: xurl auth oauth2 login
|
||
|
||
Priority: xAI API > Bird/GraphQL > xurl > web-only fallback
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from .relevance import token_overlap_relevance as _compute_relevance
|
||
|
||
|
||
def _log(msg: str) -> None:
|
||
sys.stderr.write(f"[xurl] {msg}\n")
|
||
sys.stderr.flush()
|
||
|
||
|
||
# Depth configurations: number of results to request
|
||
DEPTH_CONFIG = {
|
||
"quick": 10,
|
||
"default": 30,
|
||
"deep": 60,
|
||
}
|
||
|
||
|
||
def is_available() -> bool:
|
||
"""Check if xurl is installed and has valid authentication.
|
||
|
||
Returns True only if xurl binary is found AND the user is authenticated
|
||
(i.e. ``xurl whoami`` exits 0 and returns a username field).
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
["xurl", "whoami"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
)
|
||
return result.returncode == 0 and '"username"' in result.stdout
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
# OSError covers FileNotFoundError (no xurl on PATH) and
|
||
# PermissionError (a non-executable match on PATH, e.g. WSL's
|
||
# /mnt/c/.../WindowsApps shim returning EACCES on exec).
|
||
return False
|
||
|
||
|
||
def search_x(
|
||
query: str,
|
||
depth: str = "default",
|
||
) -> Dict[str, Any]:
|
||
"""Search X via xurl CLI using X API v2 search/recent.
|
||
|
||
Args:
|
||
query: Search query string
|
||
depth: "quick", "default", or "deep"
|
||
|
||
Returns:
|
||
Raw JSON response from X API v2 tweets/search/recent, or a dict
|
||
with an "error" key on failure.
|
||
"""
|
||
max_results = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||
# X API v2 search/recent requires max_results in 10–100 range
|
||
max_results = max(10, min(100, max_results))
|
||
|
||
try:
|
||
result = subprocess.run(
|
||
["xurl", "search", query, "-n", str(max_results)],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
|
||
if result.returncode != 0:
|
||
error_text = result.stderr.strip() or result.stdout.strip()
|
||
return {"error": f"xurl search failed: {error_text}"}
|
||
|
||
return json.loads(result.stdout)
|
||
|
||
except FileNotFoundError:
|
||
return {"error": "xurl not found in PATH"}
|
||
except subprocess.TimeoutExpired:
|
||
return {"error": "xurl search timed out (30s)"}
|
||
except json.JSONDecodeError as exc:
|
||
return {"error": f"Invalid JSON from xurl: {exc}"}
|
||
except Exception as exc:
|
||
return {"error": f"{type(exc).__name__}: {exc}"}
|
||
|
||
|
||
def parse_x_response(
|
||
response: Dict[str, Any],
|
||
topic: str = "",
|
||
) -> List[Dict[str, Any]]:
|
||
"""Parse xurl search response into normalized item dicts.
|
||
|
||
Output format matches the existing XItem schema used by xai_x and bird_x:
|
||
id, text, url, author_handle, date, engagement, why_relevant, relevance.
|
||
|
||
Args:
|
||
response: Raw X API v2 response dict from search_x()
|
||
topic: Original search topic (used for relevance scoring)
|
||
|
||
Returns:
|
||
List of item dicts. Empty list on error or no results.
|
||
"""
|
||
items: List[Dict[str, Any]] = []
|
||
|
||
if "error" in response:
|
||
_log(f"Error in response: {response['error']}")
|
||
return items
|
||
|
||
data = response.get("data") or []
|
||
if not data:
|
||
return items
|
||
|
||
# Build author lookup from includes.users
|
||
authors: Dict[str, Dict[str, Any]] = {}
|
||
for user in (response.get("includes") or {}).get("users") or []:
|
||
authors[user["id"]] = user
|
||
|
||
for i, tweet in enumerate(data):
|
||
author_id = tweet.get("author_id", "")
|
||
author = authors.get(author_id, {})
|
||
username = author.get("username", "")
|
||
|
||
tweet_id = tweet.get("id", "")
|
||
url = f"https://x.com/{username}/status/{tweet_id}" if username else ""
|
||
|
||
# Parse public_metrics
|
||
engagement: Optional[Dict[str, Any]] = None
|
||
metrics = tweet.get("public_metrics") or {}
|
||
if metrics:
|
||
engagement = {
|
||
"likes": metrics.get("like_count", 0),
|
||
"reposts": metrics.get("retweet_count", 0),
|
||
"replies": metrics.get("reply_count", 0),
|
||
"quotes": metrics.get("quote_count", 0),
|
||
}
|
||
|
||
# Parse ISO 8601 date → YYYY-MM-DD
|
||
date: Optional[str] = None
|
||
created = tweet.get("created_at", "")
|
||
if created:
|
||
m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
|
||
if m:
|
||
date = m.group(1)
|
||
|
||
text = tweet.get("text", "").strip()
|
||
|
||
# Relevance score via shared token-overlap function
|
||
relevance = _compute_relevance(topic, text) if topic else 0.5
|
||
|
||
items.append({
|
||
"id": f"XURL{i + 1}",
|
||
"text": text[:500],
|
||
"url": url,
|
||
"author_handle": username,
|
||
"date": date,
|
||
"engagement": engagement,
|
||
"why_relevant": "",
|
||
"relevance": relevance,
|
||
})
|
||
|
||
return items
|