Files
Ilia Alshanetsky 5b87cca886 fix(xurl): treat PermissionError from PATH lookup as unavailable (#322)
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.
2026-04-26 14:16:14 -07:00

173 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 10100 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