| 1 | """Bird X search client for the v3.0.0 last30days pipeline. |
| 2 | |
| 3 | Uses a vendored subset of @steipete/bird v0.8.0 (MIT License) to search X |
| 4 | via Twitter's GraphQL API. No external `bird` CLI binary needed - just Node.js. |
| 5 | See scripts/lib/vendor/bird-search/package.json for authoritative version. |
| 6 | """ |
| 7 | |
| 8 | import json |
| 9 | import os |
| 10 | import shutil |
| 11 | import sys |
| 12 | import time |
| 13 | from pathlib import Path |
| 14 | |
| 15 | from . import env, health, http, log, subproc |
| 16 | from datetime import datetime |
| 17 | from typing import Any, Dict, List, Optional, Tuple |
| 18 | |
| 19 | from .relevance import token_overlap_relevance as _compute_relevance |
| 20 | |
| 21 | # How many times to retry the bird-search subprocess when stdout is non-JSON |
| 22 | # (typically an HTML anti-bot interstitial from Twitter's edge). |
| 23 | MAX_JSON_DECODE_RETRIES = 2 |
| 24 | JSON_DECODE_RETRY_DELAY = 5.0 # seconds between retry attempts |
| 25 | |
| 26 | |
| 27 | def _leading_mentions(text: str) -> list: |
| 28 | """Leading-run @mention parse, shared with other X-shaped sources (xquik). |
| 29 | |
| 30 | Thin wrapper over ``query.leading_mentions`` so bird and xquik share one |
| 31 | implementation; kept here for existing call sites and tests. |
| 32 | """ |
| 33 | from .query import leading_mentions |
| 34 | return leading_mentions(text) |
| 35 | |
| 36 | |
| 37 | def _first_of(*values): |
| 38 | """Return first value that is not None.""" |
| 39 | for v in values: |
| 40 | if v is not None: |
| 41 | return v |
| 42 | return None |
| 43 | |
| 44 | # Path to the vendored bird-search wrapper |
| 45 | _BIRD_SEARCH_MJS = Path(__file__).parent / "vendor" / "bird-search" / "bird-search.mjs" |
| 46 | |
| 47 | # Depth configurations: number of results to request |
| 48 | DEPTH_CONFIG = { |
| 49 | "quick": 12, |
| 50 | "default": 30, |
| 51 | "deep": 60, |
| 52 | } |
| 53 | |
| 54 | # Module-level credentials injected from .env config |
| 55 | _credentials: Dict[str, str] = {} |
| 56 | |
| 57 | # The vendored bird-search client reads exactly this env surface, and the |
| 58 | # node subprocess it spawns needs the node-runtime env (platform, locale, |
| 59 | # TLS/proxy config) to run in every environment it runs in today. Ambient |
| 60 | # BIRD_* vars pass through as well. Everything else in os.environ - unrelated |
| 61 | # API keys, tokens, .env contents - must not reach the scan-excluded vendored |
| 62 | # client (issue #1063). Mirrors the platform-var surface grok_x keeps for its |
| 63 | # node child (grok_x._subprocess_env). |
| 64 | _SUBPROCESS_ENV_ALLOWLIST = ( |
| 65 | # Runtime / platform vars the node subprocess needs (mirrors grok_x) |
| 66 | "PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SystemRoot", |
| 67 | "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SystemDrive", "COMSPEC", |
| 68 | "PATHEXT", "TEMP", "TMP", |
| 69 | # Node TLS / proxy / CA config for custom-CA and proxied environments |
| 70 | "NODE_ENV", "NODE_OPTIONS", "NODE_EXTRA_CA_CERTS", |
| 71 | "NODE_TLS_REJECT_UNAUTHORIZED", "NODE_USE_ENV_PROXY", |
| 72 | "SSL_CERT_FILE", "SSL_CERT_DIR", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", |
| 73 | "http_proxy", "https_proxy", "no_proxy", |
| 74 | # X session cookies the vendored client reads from the environment |
| 75 | "AUTH_TOKEN", "CT0", "TWITTER_AUTH_TOKEN", "TWITTER_CT0", |
| 76 | # Browser-cookie disable flag the client reads (cookies.js envFlagEnabled) |
| 77 | "LAST30DAYS_DISABLE_BROWSER_COOKIES", |
| 78 | ) |
| 79 | |
| 80 | |
| 81 | def set_credentials(auth_token: Optional[str], ct0: Optional[str]): |
| 82 | """Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them.""" |
| 83 | if auth_token: |
| 84 | _credentials['AUTH_TOKEN'] = auth_token |
| 85 | if ct0: |
| 86 | _credentials['CT0'] = ct0 |
| 87 | |
| 88 | |
| 89 | def _has_injected_credentials() -> bool: |
| 90 | """Return True when both X session cookies were injected from config.""" |
| 91 | return bool(_credentials.get('AUTH_TOKEN') and _credentials.get('CT0')) |
| 92 | |
| 93 | |
| 94 | def _has_process_credentials() -> bool: |
| 95 | """Return True when AUTH_TOKEN/CT0 are present in process env.""" |
| 96 | return bool(env.read_secret_env("AUTH_TOKEN") and env.read_secret_env("CT0")) |
| 97 | |
| 98 | |
| 99 | def _subprocess_env() -> Dict[str, str]: |
| 100 | """Build env dict for Node subprocesses, merging injected credentials. |
| 101 | |
| 102 | The child env is limited to the vendored client's env surface (see |
| 103 | ``_SUBPROCESS_ENV_ALLOWLIST``) plus injected credentials, so unrelated |
| 104 | ambient secrets never reach scan-excluded vendored code (issue #1063). |
| 105 | The ambient-credential lane behaves exactly as before. |
| 106 | """ |
| 107 | env = { |
| 108 | name: os.environ[name] |
| 109 | for name in _SUBPROCESS_ENV_ALLOWLIST |
| 110 | if name in os.environ |
| 111 | } |
| 112 | env.update({ |
| 113 | key: value for key, value in os.environ.items() |
| 114 | if key.startswith("BIRD_") |
| 115 | }) |
| 116 | env.update(_credentials) |
| 117 | # Hard-disable browser-cookie fallback so normal pipeline runs never hit |
| 118 | # Safari/Chrome Keychain prompts during source detection or search. |
| 119 | env["BIRD_DISABLE_BROWSER_COOKIES"] = "1" |
| 120 | return env |
| 121 | |
| 122 | |
| 123 | def _log(msg: str): |
| 124 | log.source_log("Bird", msg, tty_only=False) |
| 125 | |
| 126 | |
| 127 | def classify_run_failure(detail: str) -> str: |
| 128 | """Map Bird's subprocess-only failure shapes to run outcome states.""" |
| 129 | text = detail.lower() |
| 130 | if any(marker in text for marker in ("interstitial", "non-json", "invalid json")): |
| 131 | return health.SCHEMA_DRIFT |
| 132 | if any( |
| 133 | marker in text |
| 134 | for marker in ("cookie expired", "expired cookie", "unauthorized", "forbidden", "login required") |
| 135 | ): |
| 136 | return health.AUTH_FAILED |
| 137 | return http.classify_failure(message=detail) |
| 138 | |
| 139 | |
| 140 | def _extract_core_subject(topic: str) -> str: |
| 141 | """Extract core subject from verbose query for X search. |
| 142 | |
| 143 | X search is literal keyword AND matching — all words must appear. |
| 144 | Aggressively strip question/meta/research words to keep only the |
| 145 | core product/concept name (max 5 words). |
| 146 | """ |
| 147 | from .query import extract_core_subject |
| 148 | return extract_core_subject(topic, max_words=5, strip_suffixes=True) |
| 149 | |
| 150 | |
| 151 | def _plain_query_tokens(text: str) -> list[str]: |
| 152 | """Return lexical tokens without Bird query grouping syntax. |
| 153 | |
| 154 | Strips phrase quotes as well as grouping characters. Used where a flat |
| 155 | token list is wanted; use ``build_topic_query`` for the provider query, |
| 156 | which preserves quoted phrases. |
| 157 | """ |
| 158 | separators = str.maketrans({char: " " for char in '\"“”()[]{}'}) |
| 159 | return [ |
| 160 | clean |
| 161 | for token in text.translate(separators).split() |
| 162 | if (clean := token.strip("'‘’")) |
| 163 | ] |
| 164 | |
| 165 | |
| 166 | # Bird/X grouping syntax that carries no lexical meaning. Double quotes are |
| 167 | # deliberately absent: X advanced search treats "..." as a phrase match, which |
| 168 | # is exactly what the planner intended when it quoted a proper noun. |
| 169 | _GROUPING_CHARS = "“”()[]{}" |
| 170 | |
| 171 | |
| 172 | def build_topic_query(topic: str, from_date: str) -> str: |
| 173 | """Build the X topic query, preserving quoted proper-noun phrases. |
| 174 | |
| 175 | Previously the topic went through ``_plain_query_tokens``, which stripped |
| 176 | the quotes the planner had added, so an intended phrase match for |
| 177 | '"Peter Steinberger"' degraded into `peter AND steinberger` -- narrower and |
| 178 | noisier at once. X supports phrase queries natively, so the quotes are |
| 179 | passed through. |
| 180 | """ |
| 181 | separators = str.maketrans({char: " " for char in _GROUPING_CHARS}) |
| 182 | cleaned = topic.translate(separators) |
| 183 | # An unbalanced quote is worse than no quote: X reads the orphan as an |
| 184 | # unterminated phrase and matches nothing. Upstream trimming (core-subject |
| 185 | # extraction, retry shortening) can cut a topic mid-phrase, so verify the |
| 186 | # quotes pair up and fall back to bare tokens when they do not. |
| 187 | if cleaned.count('"') % 2: |
| 188 | cleaned = cleaned.replace('"', " ") |
| 189 | tokens = [ |
| 190 | clean |
| 191 | for token in cleaned.split() |
| 192 | if (clean := token.strip("'‘’")) |
| 193 | ] |
| 194 | core = " ".join(tokens).strip() |
| 195 | return f"{core} since:{from_date}" if core else f"since:{from_date}" |
| 196 | |
| 197 | |
| 198 | def is_bird_installed() -> bool: |
| 199 | """Check if vendored Bird search module is available. |
| 200 | |
| 201 | Returns: |
| 202 | True if bird-search.mjs exists and Node.js is in PATH. |
| 203 | """ |
| 204 | if not _BIRD_SEARCH_MJS.exists(): |
| 205 | return False |
| 206 | return shutil.which("node") is not None |
| 207 | |
| 208 | |
| 209 | def is_bird_authenticated() -> Optional[str]: |
| 210 | """Check if explicit X credentials are available. |
| 211 | |
| 212 | Returns: |
| 213 | Auth source string if authenticated, None otherwise. |
| 214 | """ |
| 215 | if not is_bird_installed(): |
| 216 | return None |
| 217 | |
| 218 | if _has_injected_credentials(): |
| 219 | return "env AUTH_TOKEN" |
| 220 | if _has_process_credentials(): |
| 221 | return "env AUTH_TOKEN" |
| 222 | return None |
| 223 | |
| 224 | |
| 225 | _probe_cache: Optional[Optional[bool]] = "unset" # "unset" | True | False | None |
| 226 | |
| 227 | |
| 228 | def probe_works(timeout: int = 8) -> Optional[bool]: |
| 229 | """Cheap runtime check that X auth actually returns data. |
| 230 | |
| 231 | Returns True when a 1-result probe comes back without an error, False on a |
| 232 | clear failure (auth error / generic search failure), and None when the |
| 233 | result is inconclusive (network timeout) so callers can fail open and keep |
| 234 | the static credential-presence status rather than reporting a false-down. |
| 235 | Cached per process so repeated diagnose calls don't re-probe. |
| 236 | """ |
| 237 | global _probe_cache |
| 238 | if _probe_cache != "unset": |
| 239 | return _probe_cache # type: ignore[return-value] |
| 240 | if not (_has_injected_credentials() or _has_process_credentials()): |
| 241 | _probe_cache = False |
| 242 | return False |
| 243 | from datetime import datetime, timedelta, timezone |
| 244 | since = (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%d") |
| 245 | # @x (the platform's own account) posts frequently, so a no-error response |
| 246 | # means auth works even if this particular window is quiet. |
| 247 | resp = _run_bird_search(f"from:x since:{since}", count=1, timeout=timeout) |
| 248 | if isinstance(resp, dict) and resp.get("error"): |
| 249 | err = str(resp.get("error")).lower() |
| 250 | if "timed out" in err or "timeout" in err: |
| 251 | _probe_cache = None # inconclusive — don't downgrade on a transient timeout |
| 252 | return None |
| 253 | _probe_cache = False |
| 254 | return False |
| 255 | _probe_cache = True |
| 256 | return True |
| 257 | |
| 258 | |
| 259 | def check_npm_available() -> bool: |
| 260 | """Check if npm is available (kept for API compatibility). |
| 261 | |
| 262 | Returns: |
| 263 | True if 'npm' command is available in PATH, False otherwise. |
| 264 | """ |
| 265 | return shutil.which("npm") is not None |
| 266 | |
| 267 | |
| 268 | def install_bird() -> Tuple[bool, str]: |
| 269 | """No-op. Bird search is vendored in v3.0.0, no installation needed. |
| 270 | |
| 271 | Returns: |
| 272 | Tuple of (success, message). |
| 273 | """ |
| 274 | if is_bird_installed(): |
| 275 | return True, "Bird search is bundled with /last30days v3.0.0 - no installation needed." |
| 276 | if not shutil.which("node"): |
| 277 | return False, "Node.js 22+ is required for X search. Install Node.js first." |
| 278 | return False, f"Vendored bird-search.mjs not found at {_BIRD_SEARCH_MJS}" |
| 279 | |
| 280 | |
| 281 | def get_bird_status() -> Dict[str, Any]: |
| 282 | """Get comprehensive Bird search status. |
| 283 | |
| 284 | Returns: |
| 285 | Dict with keys: installed, authenticated, username, can_install |
| 286 | """ |
| 287 | installed = is_bird_installed() |
| 288 | auth_source = is_bird_authenticated() if installed else None |
| 289 | |
| 290 | return { |
| 291 | "installed": installed, |
| 292 | "authenticated": auth_source is not None, |
| 293 | "username": auth_source, # Now returns auth source (e.g., "Safari", "env AUTH_TOKEN") |
| 294 | "can_install": True, # Always vendored in v3.0.0 |
| 295 | } |
| 296 | |
| 297 | |
| 298 | def _invoke_bird_subprocess(query: str, count: int, timeout: int): |
| 299 | """Invoke the vendored bird-search.mjs subprocess once. |
| 300 | |
| 301 | Returns (result, error_dict). If error_dict is non-None, treat it as the |
| 302 | final result and do not retry — those errors are terminal (timeout, |
| 303 | spawn failure). If error_dict is None, the subprocess ran to completion |
| 304 | and `result` is the SubprocResult; the caller decides whether to retry |
| 305 | based on the result.stdout content. |
| 306 | """ |
| 307 | cmd = [ |
| 308 | "node", str(_BIRD_SEARCH_MJS), |
| 309 | query, |
| 310 | "--count", str(count), |
| 311 | "--json", |
| 312 | ] |
| 313 | |
| 314 | pid_holder: list[int] = [] |
| 315 | |
| 316 | def _register(pid: int) -> None: |
| 317 | pid_holder.append(pid) |
| 318 | try: |
| 319 | from last30days import register_child_pid |
| 320 | register_child_pid(pid) |
| 321 | except ImportError: |
| 322 | pass |
| 323 | |
| 324 | try: |
| 325 | result = subproc.run_with_timeout( |
| 326 | cmd, |
| 327 | timeout=timeout, |
| 328 | env=_subprocess_env(), |
| 329 | on_pid=_register, |
| 330 | ) |
| 331 | except subproc.SubprocTimeout: |
| 332 | return None, {"error": f"Search timed out after {timeout}s", "items": []} |
| 333 | except Exception as e: |
| 334 | return None, {"error": str(e), "items": []} |
| 335 | finally: |
| 336 | if pid_holder: |
| 337 | try: |
| 338 | from last30days import unregister_child_pid |
| 339 | unregister_child_pid(pid_holder[0]) |
| 340 | except Exception: |
| 341 | pass |
| 342 | |
| 343 | return result, None |
| 344 | |
| 345 | |
| 346 | def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]: |
| 347 | """Run a search using the vendored bird-search.mjs module. |
| 348 | |
| 349 | Retries the subprocess on JSON-decode failure (typically a Twitter |
| 350 | anti-bot HTML interstitial in stdout) up to MAX_JSON_DECODE_RETRIES |
| 351 | times with JSON_DECODE_RETRY_DELAY seconds between attempts. Terminal |
| 352 | errors (subprocess timeout, non-zero return code) are returned |
| 353 | immediately without retry. |
| 354 | |
| 355 | Args: |
| 356 | query: Full search query string (including since: filter) |
| 357 | count: Number of results to request |
| 358 | timeout: Timeout in seconds (per attempt) |
| 359 | |
| 360 | Returns: |
| 361 | Raw Bird JSON response or error dict. |
| 362 | """ |
| 363 | last_decode_error: Optional[str] = None |
| 364 | |
| 365 | for attempt in range(MAX_JSON_DECODE_RETRIES): |
| 366 | result, terminal_error = _invoke_bird_subprocess(query, count, timeout) |
| 367 | if terminal_error is not None: |
| 368 | return terminal_error |
| 369 | |
| 370 | output = result.stdout.strip() |
| 371 | if result.returncode != 0: |
| 372 | if not output: |
| 373 | error = result.stderr.strip() or "Bird search failed" |
| 374 | return {"error": error, "items": []} |
| 375 | # Windows/Node 24: the vendored Bird CLI uses native fetch (undici), |
| 376 | # and calling process.exit() while keep-alive sockets are still |
| 377 | # closing trips a libuv assertion -> non-zero exit code AFTER it has |
| 378 | # already written a complete, valid JSON result to stdout. Trust |
| 379 | # stdout when it has content; only treat a non-zero exit as a real |
| 380 | # failure when stdout is empty. |
| 381 | |
| 382 | if not output: |
| 383 | return {"items": []} |
| 384 | |
| 385 | try: |
| 386 | parsed = json.loads(output) |
| 387 | except json.JSONDecodeError as e: |
| 388 | # Twitter's edge sometimes serves an HTML anti-bot interstitial |
| 389 | # in place of JSON. Tag the failure shape so it's distinguishable |
| 390 | # from "no results" in logs, then retry the subprocess. |
| 391 | looks_html = output.lstrip().lower().startswith(("<!doctype", "<html", "<")) |
| 392 | attempt_num = attempt + 1 |
| 393 | log_msg = ( |
| 394 | f"Bird search returned non-JSON stdout " |
| 395 | f"(looks_html={looks_html}, attempt {attempt_num}/{MAX_JSON_DECODE_RETRIES}, " |
| 396 | f"first 80 chars: {output[:80]!r})" |
| 397 | ) |
| 398 | last_decode_error = str(e) |
| 399 | if attempt_num < MAX_JSON_DECODE_RETRIES: |
| 400 | log.source_log( |
| 401 | "X/bird", |
| 402 | f"{log_msg}; retrying in {JSON_DECODE_RETRY_DELAY:.0f}s", |
| 403 | tty_only=False, |
| 404 | ) |
| 405 | time.sleep(JSON_DECODE_RETRY_DELAY) |
| 406 | continue |
| 407 | log.source_log("X/bird", log_msg, tty_only=False) |
| 408 | return { |
| 409 | "error": ( |
| 410 | f"Invalid JSON response after {MAX_JSON_DECODE_RETRIES} attempts " |
| 411 | f"(likely Twitter anti-bot interstitial): {e}" |
| 412 | ), |
| 413 | "items": [], |
| 414 | } |
| 415 | |
| 416 | if isinstance(parsed, list): |
| 417 | return {"items": parsed} |
| 418 | return parsed |
| 419 | |
| 420 | # Defensive fallthrough — loop should always return above. |
| 421 | return { |
| 422 | "error": f"Bird search exhausted retries: {last_decode_error}", |
| 423 | "items": [], |
| 424 | } |
| 425 | |
| 426 | |
| 427 | def search_x( |
| 428 | topic: str, |
| 429 | from_date: str, |
| 430 | to_date: str, |
| 431 | depth: str = "default", |
| 432 | ) -> Dict[str, Any]: |
| 433 | """Search X using Bird CLI with automatic retry on 0 results. |
| 434 | |
| 435 | Args: |
| 436 | topic: Search topic |
| 437 | from_date: Start date (YYYY-MM-DD) |
| 438 | to_date: End date (YYYY-MM-DD) - unused but kept for API compatibility |
| 439 | depth: Research depth - "quick", "default", or "deep" |
| 440 | |
| 441 | Returns: |
| 442 | Raw Bird JSON response or error dict. |
| 443 | """ |
| 444 | count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) |
| 445 | timeout = 30 if depth == "quick" else 45 if depth == "default" else 60 |
| 446 | |
| 447 | # Extract core subject - X search is literal, not semantic |
| 448 | core_subject = _extract_core_subject(topic) |
| 449 | core_words = _plain_query_tokens(core_subject) |
| 450 | core_topic = " ".join(core_words) |
| 451 | query = build_topic_query(core_subject, from_date) |
| 452 | |
| 453 | _log(f"Searching: {query}") |
| 454 | response = _run_bird_search(query, count, timeout) |
| 455 | last_clean_response = response if not response.get("error") else None |
| 456 | |
| 457 | # Check if we got results |
| 458 | items = parse_bird_response(response, query=core_topic) |
| 459 | |
| 460 | # Retry with OR groups for multi-word queries (X supports OR operator) |
| 461 | if not items and len(core_words) >= 2: |
| 462 | from .query import extract_compound_terms |
| 463 | compounds = extract_compound_terms(topic) |
| 464 | if compounds: |
| 465 | # Build OR-group query: ("multi-agent" OR "agent simulation") since:DATE |
| 466 | or_parts = ' OR '.join(f'"{t}"' for t in compounds[:3]) |
| 467 | _log(f"0 results for '{core_topic}', retrying with OR groups: {or_parts}") |
| 468 | query = f"({or_parts}) since:{from_date}" |
| 469 | response = _run_bird_search(query, count, timeout) |
| 470 | if not response.get("error"): |
| 471 | last_clean_response = response |
| 472 | items = parse_bird_response(response, query=core_topic) |
| 473 | |
| 474 | # Retry with fewer keywords if still 0 results and query has 3+ words |
| 475 | if not items and len(core_words) > 2: |
| 476 | shorter = ' '.join(core_words[:2]) |
| 477 | _log(f"0 results for '{core_topic}', retrying with '{shorter}'") |
| 478 | query = f"{shorter} since:{from_date}" |
| 479 | response = _run_bird_search(query, count, timeout) |
| 480 | if not response.get("error"): |
| 481 | last_clean_response = response |
| 482 | items = parse_bird_response(response, query=core_topic) |
| 483 | |
| 484 | # Last-chance retry: use strongest remaining token (often the product name) |
| 485 | if not items and core_words: |
| 486 | low_signal = { |
| 487 | 'trendiest', 'trending', 'hottest', 'hot', 'popular', 'viral', |
| 488 | 'best', 'top', 'latest', 'new', 'plugin', 'plugins', |
| 489 | 'skill', 'skills', 'tool', 'tools', |
| 490 | } |
| 491 | candidates = [w for w in core_words if w not in low_signal] |
| 492 | if candidates: |
| 493 | # Keep an entity anchor (the first distinctive topic token) in the |
| 494 | # retry so it can't collapse to a bare generic token like "compound" |
| 495 | # and flood the X pool with off-topic noise. Add the strongest |
| 496 | # (longest) distinctive token when it differs from the anchor; |
| 497 | # otherwise query the anchor alone. Better to return 0 than to |
| 498 | # over-broaden to an unanchored generic term. |
| 499 | anchor = candidates[0] |
| 500 | strongest = max(candidates, key=len) |
| 501 | retry_terms = anchor if strongest == anchor else f"{anchor} {strongest}" |
| 502 | _log(f"0 results for '{core_topic}', retrying anchored on '{retry_terms}'") |
| 503 | query = f"{retry_terms} since:{from_date}" |
| 504 | response = _run_bird_search(query, count, timeout) |
| 505 | if not response.get("error"): |
| 506 | last_clean_response = response |
| 507 | |
| 508 | if response.get("error") and last_clean_response is not None: |
| 509 | _log("Optional retry failed after a clean empty response; preserving no-results outcome") |
| 510 | return last_clean_response |
| 511 | return response |
| 512 | |
| 513 | |
| 514 | def search_handles( |
| 515 | handles: List[str], |
| 516 | topic: Optional[str], |
| 517 | from_date: str, |
| 518 | count_per: int = 5, |
| 519 | ) -> List[Dict[str, Any]]: |
| 520 | """Search specific X handles for topic-related content. |
| 521 | |
| 522 | Pulls each handle's actual timeline via `from:handle since:` — the FROM |
| 523 | lane (tweets BY the person), engagement-weighted downstream. The topic is |
| 524 | used for relevance RANKING, never AND'd into the query: X search is literal, |
| 525 | so `from:handle <their name>` only matched tweets where they wrote their own |
| 526 | name and returned ~0. Used in Phase 2 after entity extraction. |
| 527 | |
| 528 | Args: |
| 529 | handles: List of X handles to search (without @) |
| 530 | topic: Search topic — used for relevance ranking only, not the query |
| 531 | from_date: Start date (YYYY-MM-DD) |
| 532 | count_per: Results to request per handle |
| 533 | |
| 534 | Returns: |
| 535 | List of raw item dicts (same format as parse_bird_response output). |
| 536 | """ |
| 537 | core_topic = _extract_core_subject(topic) if topic else None |
| 538 | |
| 539 | def _search_one_handle(handle: str) -> List[Dict[str, Any]]: |
| 540 | handle = handle.lstrip("@") |
| 541 | # Always unfiltered: pull the timeline, rank by topic relevance below. |
| 542 | query = f"from:{handle} since:{from_date}" |
| 543 | |
| 544 | cmd = [ |
| 545 | "node", str(_BIRD_SEARCH_MJS), |
| 546 | query, |
| 547 | "--count", str(count_per), |
| 548 | "--json", |
| 549 | ] |
| 550 | |
| 551 | try: |
| 552 | result = subproc.run_with_timeout(cmd, timeout=15, env=_subprocess_env()) |
| 553 | except subproc.SubprocTimeout: |
| 554 | _log(f"Handle search timed out for @{handle}") |
| 555 | return [] |
| 556 | except OSError as e: |
| 557 | _log(f"Handle search error for @{handle}: {e}") |
| 558 | return [] |
| 559 | |
| 560 | output = result.stdout.strip() |
| 561 | if result.returncode != 0: |
| 562 | if not output: |
| 563 | _log(f"Handle search failed for @{handle}: {result.stderr.strip()}") |
| 564 | return [] |
| 565 | # Windows/Node 24: benign libuv assertion can cause non-zero exit |
| 566 | # AFTER valid JSON is written to stdout. Trust stdout content. |
| 567 | |
| 568 | if not output: |
| 569 | return [] |
| 570 | |
| 571 | try: |
| 572 | response = json.loads(output) |
| 573 | except json.JSONDecodeError: |
| 574 | _log(f"Invalid JSON from handle search for @{handle}") |
| 575 | return [] |
| 576 | items = parse_bird_response(response, query=core_topic) |
| 577 | # Log on success/empty too (not only on failure): a silent handle search |
| 578 | # made the from: query look like it never ran and caused wrong diagnoses. |
| 579 | _log(f"Searching: {query} -> {len(items)} results") |
| 580 | return items |
| 581 | |
| 582 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 583 | |
| 584 | all_items: List[Dict[str, Any]] = [] |
| 585 | with ThreadPoolExecutor(max_workers=min(5, len(handles))) as executor: |
| 586 | futures = {executor.submit(_search_one_handle, h): h for h in handles} |
| 587 | for future in as_completed(futures): |
| 588 | all_items.extend(future.result()) |
| 589 | |
| 590 | return all_items |
| 591 | |
| 592 | |
| 593 | def search_mentions( |
| 594 | handles: List[str], |
| 595 | from_date: str, |
| 596 | count_per: int = 5, |
| 597 | ) -> List[Dict[str, Any]]: |
| 598 | """Search for tweets ABOUT/TO each handle — the mention lane. |
| 599 | |
| 600 | Queries `@handle since:` (tweets that mention the account) and excludes the |
| 601 | handle's OWN tweets (those belong to the FROM lane via search_handles), so |
| 602 | this surfaces what OTHERS are saying about the person. Engagement-weighted |
| 603 | downstream; deduped against the FROM lane by URL at normalize time. |
| 604 | |
| 605 | Args: |
| 606 | handles: List of X handles (without @) |
| 607 | from_date: Start date (YYYY-MM-DD) |
| 608 | count_per: Results to request per handle |
| 609 | |
| 610 | Returns: |
| 611 | List of raw item dicts (same format as parse_bird_response output). |
| 612 | """ |
| 613 | def _search_one(handle: str) -> List[Dict[str, Any]]: |
| 614 | handle = handle.lstrip("@") |
| 615 | query = f"@{handle} since:{from_date}" |
| 616 | cmd = [ |
| 617 | "node", str(_BIRD_SEARCH_MJS), |
| 618 | query, |
| 619 | "--count", str(count_per), |
| 620 | "--json", |
| 621 | ] |
| 622 | try: |
| 623 | result = subproc.run_with_timeout(cmd, timeout=15, env=_subprocess_env()) |
| 624 | except subproc.SubprocTimeout: |
| 625 | _log(f"Mention search timed out for @{handle}") |
| 626 | return [] |
| 627 | except OSError as e: |
| 628 | _log(f"Mention search error for @{handle}: {e}") |
| 629 | return [] |
| 630 | if result.returncode != 0: |
| 631 | _log(f"Mention search failed for @{handle}: {result.stderr.strip()}") |
| 632 | return [] |
| 633 | output = result.stdout.strip() |
| 634 | if not output: |
| 635 | return [] |
| 636 | try: |
| 637 | response = json.loads(output) |
| 638 | except json.JSONDecodeError: |
| 639 | _log(f"Invalid JSON from mention search for @{handle}") |
| 640 | return [] |
| 641 | items = parse_bird_response(response, query=None) |
| 642 | # ABOUT lane = OTHERS mentioning the handle. Drop the handle's own tweets |
| 643 | # (the FROM lane already covers those); identify by the status URL author. |
| 644 | hl = handle.lower() |
| 645 | # The Bird API may return either x.com or twitter.com permalinks, so |
| 646 | # match both when excluding the handle's own tweets. |
| 647 | def _is_own(url): |
| 648 | u = (url or "").lower() |
| 649 | return f"x.com/{hl}/status" in u or f"twitter.com/{hl}/status" in u |
| 650 | about = [it for it in items if not _is_own(it.get("url"))] |
| 651 | _log(f"Searching: {query} -> {len(about)} mentions") |
| 652 | return about |
| 653 | |
| 654 | from concurrent.futures import ThreadPoolExecutor, as_completed |
| 655 | |
| 656 | all_items: List[Dict[str, Any]] = [] |
| 657 | with ThreadPoolExecutor(max_workers=min(5, len(handles))) as executor: |
| 658 | futures = {executor.submit(_search_one, h): h for h in handles} |
| 659 | for future in as_completed(futures): |
| 660 | all_items.extend(future.result()) |
| 661 | return all_items |
| 662 | |
| 663 | |
| 664 | def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[str, Any]]: |
| 665 | """Parse Bird response to match xai_x output format. |
| 666 | |
| 667 | Args: |
| 668 | response: Raw Bird JSON response |
| 669 | query: Original search query for relevance scoring |
| 670 | |
| 671 | Returns: |
| 672 | List of normalized item dicts matching xai_x.parse_x_response() format. |
| 673 | """ |
| 674 | items = [] |
| 675 | |
| 676 | # Check for errors |
| 677 | if "error" in response and response["error"]: |
| 678 | _log(f"Bird error: {response['error']}") |
| 679 | return items |
| 680 | |
| 681 | # Bird returns a list of tweets directly or under a key |
| 682 | raw_items = response if isinstance(response, list) else response.get("items", response.get("tweets", [])) |
| 683 | |
| 684 | if not isinstance(raw_items, list): |
| 685 | return items |
| 686 | |
| 687 | for i, tweet in enumerate(raw_items): |
| 688 | if not isinstance(tweet, dict): |
| 689 | continue |
| 690 | |
| 691 | # Extract URL - Bird uses permanent_url or we construct from id |
| 692 | url = tweet.get("permanent_url") or tweet.get("url", "") |
| 693 | if not url and tweet.get("id"): |
| 694 | # Try different field structures Bird might use |
| 695 | author = tweet.get("author", {}) or tweet.get("user", {}) |
| 696 | screen_name = author.get("username") or author.get("screen_name", "") |
| 697 | if screen_name: |
| 698 | url = f"https://x.com/{screen_name}/status/{tweet['id']}" |
| 699 | |
| 700 | if not url: |
| 701 | continue |
| 702 | |
| 703 | # Parse date from created_at/createdAt (e.g., "Wed Jan 15 14:30:00 +0000 2026") |
| 704 | date = None |
| 705 | created_at = tweet.get("createdAt") or tweet.get("created_at", "") |
| 706 | if created_at: |
| 707 | try: |
| 708 | # Try ISO format first (e.g., "2026-02-03T22:33:32Z") |
| 709 | # Check for ISO date separator, not just "T" (which appears in "Tue") |
| 710 | if len(created_at) > 10 and created_at[10] == "T": |
| 711 | dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) |
| 712 | else: |
| 713 | # Twitter format: "Wed Jan 15 14:30:00 +0000 2026" |
| 714 | dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y") |
| 715 | date = dt.strftime("%Y-%m-%d") |
| 716 | except (ValueError, TypeError): |
| 717 | pass |
| 718 | |
| 719 | # Extract user info (Bird uses author.username, older format uses user.screen_name) |
| 720 | author = tweet.get("author", {}) or tweet.get("user", {}) |
| 721 | author_handle = author.get("username") or author.get("screen_name", "") or tweet.get("author_handle", "") |
| 722 | |
| 723 | # Build engagement dict (Bird uses camelCase: likeCount, retweetCount, etc.) |
| 724 | engagement = { |
| 725 | "likes": _first_of(tweet.get("likeCount"), tweet.get("like_count"), tweet.get("favorite_count")), |
| 726 | "reposts": _first_of(tweet.get("retweetCount"), tweet.get("retweet_count")), |
| 727 | "replies": _first_of(tweet.get("replyCount"), tweet.get("reply_count")), |
| 728 | "quotes": _first_of(tweet.get("quoteCount"), tweet.get("quote_count")), |
| 729 | } |
| 730 | # Convert to int where possible |
| 731 | for key in engagement: |
| 732 | if engagement[key] is not None: |
| 733 | try: |
| 734 | engagement[key] = int(engagement[key]) |
| 735 | except (ValueError, TypeError): |
| 736 | engagement[key] = None |
| 737 | |
| 738 | # Build normalized item |
| 739 | text = str(tweet.get("text", tweet.get("full_text", ""))).strip()[:500] |
| 740 | item = { |
| 741 | "id": f"X{i+1}", |
| 742 | "text": text, |
| 743 | "url": url, |
| 744 | "author_handle": author_handle.lstrip("@"), |
| 745 | # Leading @mentions parsed from the post text identify who a reply is |
| 746 | # directed at (X replies open with the target handle(s)). Used by the |
| 747 | # interaction-signal classifier in rerank. |
| 748 | "mentioned_handles": _leading_mentions(text), |
| 749 | "date": date, |
| 750 | "engagement": engagement if any(v is not None for v in engagement.values()) else None, |
| 751 | "why_relevant": "", # Bird doesn't provide relevance explanations |
| 752 | "relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7, |
| 753 | } |
| 754 | |
| 755 | items.append(item) |
| 756 | |
| 757 | return items |
| 758 |