Files
last30days-skill/scripts/lib/bird_x.py
T
2026-02-03 10:59:39 -08:00

253 lines
7.4 KiB
Python

"""Bird CLI client for X (Twitter) search."""
import json
import shutil
import subprocess
import sys
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
# Depth configurations: number of results to request
DEPTH_CONFIG = {
"quick": 12,
"default": 30,
"deep": 60,
}
def _log(msg: str):
"""Log to stderr."""
sys.stderr.write(f"[Bird] {msg}\n")
sys.stderr.flush()
def is_bird_installed() -> bool:
"""Check if Bird CLI is installed.
Returns:
True if 'bird' command is available in PATH, False otherwise.
"""
return shutil.which("bird") is not None
def is_bird_authenticated() -> Optional[str]:
"""Check if Bird is authenticated by running 'bird whoami'.
Returns:
Username if authenticated, None otherwise.
"""
if not is_bird_installed():
return None
try:
result = subprocess.run(
["bird", "whoami"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
# Output is typically the username
return result.stdout.strip().split('\n')[0]
return None
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
return None
def check_npm_available() -> bool:
"""Check if npm is available for installation.
Returns:
True if 'npm' command is available in PATH, False otherwise.
"""
return shutil.which("npm") is not None
def install_bird() -> Tuple[bool, str]:
"""Install Bird CLI via npm.
Returns:
Tuple of (success, message).
"""
if not check_npm_available():
return False, "npm not found. Install Node.js first, or install Bird manually: https://github.com/steipete/bird"
try:
_log("Installing Bird CLI...")
result = subprocess.run(
["npm", "install", "-g", "@steipete/bird"],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode == 0:
return True, "Bird CLI installed successfully!"
else:
error = result.stderr.strip() or result.stdout.strip() or "Unknown error"
return False, f"Installation failed: {error}"
except subprocess.TimeoutExpired:
return False, "Installation timed out"
except Exception as e:
return False, f"Installation error: {e}"
def get_bird_status() -> Dict[str, Any]:
"""Get comprehensive Bird status.
Returns:
Dict with keys: installed, authenticated, username, can_install
"""
installed = is_bird_installed()
username = is_bird_authenticated() if installed else None
return {
"installed": installed,
"authenticated": username is not None,
"username": username,
"can_install": check_npm_available(),
}
def search_x(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
) -> Dict[str, Any]:
"""Search X using Bird CLI.
Args:
topic: Search topic
from_date: Start date (YYYY-MM-DD)
to_date: End date (YYYY-MM-DD) - unused but kept for API compatibility
depth: Research depth - "quick", "default", or "deep"
Returns:
Raw Bird JSON response or error dict.
"""
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
# Build command
cmd = [
"bird", "search",
topic,
"--since", from_date,
"-n", str(count),
"--json",
]
# Adjust timeout based on depth
timeout = 30 if depth == "quick" else 45 if depth == "default" else 60
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
error = result.stderr.strip() or "Bird search failed"
return {"error": error, "items": []}
# Parse JSON output
output = result.stdout.strip()
if not output:
return {"items": []}
return json.loads(output)
except subprocess.TimeoutExpired:
return {"error": "Search timed out", "items": []}
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON response: {e}", "items": []}
except Exception as e:
return {"error": str(e), "items": []}
def parse_bird_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Parse Bird response to match xai_x output format.
Args:
response: Raw Bird JSON response
Returns:
List of normalized item dicts matching xai_x.parse_x_response() format.
"""
items = []
# Check for errors
if "error" in response and response["error"]:
_log(f"Bird error: {response['error']}")
return items
# Bird returns a list of tweets directly or under a key
raw_items = response if isinstance(response, list) else response.get("items", response.get("tweets", []))
if not isinstance(raw_items, list):
return items
for i, tweet in enumerate(raw_items):
if not isinstance(tweet, dict):
continue
# Extract URL - Bird uses permanent_url or we construct from id
url = tweet.get("permanent_url") or tweet.get("url", "")
if not url and tweet.get("id"):
screen_name = tweet.get("user", {}).get("screen_name", "")
if screen_name:
url = f"https://x.com/{screen_name}/status/{tweet['id']}"
if not url:
continue
# Parse date from created_at (e.g., "Wed Jan 15 14:30:00 +0000 2026")
date = None
created_at = tweet.get("created_at", "")
if created_at:
try:
# Try ISO format first
if "T" in created_at:
dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
else:
# Twitter format: "Wed Jan 15 14:30:00 +0000 2026"
dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
date = dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
pass
# Extract user info
user = tweet.get("user", {})
author_handle = user.get("screen_name", "") or tweet.get("author_handle", "")
# Build engagement dict
engagement = {
"likes": tweet.get("like_count") or tweet.get("favorite_count"),
"reposts": tweet.get("retweet_count"),
"replies": tweet.get("reply_count"),
"quotes": tweet.get("quote_count"),
}
# Convert to int where possible
for key in engagement:
if engagement[key] is not None:
try:
engagement[key] = int(engagement[key])
except (ValueError, TypeError):
engagement[key] = None
# Build normalized item
item = {
"id": f"X{i+1}",
"text": str(tweet.get("text", tweet.get("full_text", ""))).strip()[:500],
"url": url,
"author_handle": author_handle.lstrip("@"),
"date": date,
"engagement": engagement if any(v is not None for v in engagement.values()) else None,
"why_relevant": "", # Bird doesn't provide relevance explanations
"relevance": 0.7, # Default relevance, let score.py re-rank
}
items.append(item)
return items