返回 last30days-skill
bird_x.py
根目录 / skills / last30days / scripts / lib / bird_x.py
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
758 lines PYTHON