返回 last30days-skill
xquik.py
根目录 / skills / last30days / scripts / lib / xquik.py
1 """Xquik X search source for the v3.0.0 last30days pipeline.
2
3 Uses the Xquik REST API (https://xquik.com/api/v1) to search X/Twitter
4 with full engagement metrics (likes, retweets, replies, quotes, views,
5 bookmarks). Requires an API key from xquik.com.
6 """
7
8 from __future__ import annotations
9
10 from datetime import datetime
11 from typing import Any, Dict, List, Optional
12
13 from . import http, log
14 from .relevance import token_overlap_relevance as _compute_relevance
15
16 # Per-process probe cache: (state, reason). state is "unset" until probed, then
17 # True (funded/working) | False (auth/payment failure) | None (inconclusive).
18 _probe_cache: tuple = ("unset", "")
19
20 # Depth configurations: number of results to request per query
21 DEPTH_CONFIG = {
22 "quick": {"limit": 10, "queries": 1},
23 "default": {"limit": 20, "queries": 2},
24 "deep": {"limit": 40, "queries": 3},
25 }
26
27 _BASE_URL = "https://xquik.com/api/v1"
28
29
30 def _log(msg: str):
31 log.source_log("Xquik", msg, tty_only=False)
32
33
34 def _extract_core_subject(topic: str) -> str:
35 """Extract core subject for X search queries."""
36 from .query import extract_core_subject
37 return extract_core_subject(topic, max_words=5, strip_suffixes=True)
38
39
40 def expand_xquik_queries(topic: str, depth: str) -> List[str]:
41 """Generate query variants based on depth.
42
43 Args:
44 topic: Research topic
45 depth: "quick", "default", or "deep"
46
47 Returns:
48 List of query strings (1 for quick, 2 for default, 3 for deep).
49 """
50 core = _extract_core_subject(topic)
51 # Anti-bare-generic guard (#607): never let the core collapse to a single
52 # bare token when the topic carries more — a lone generic word floods X with
53 # off-topic collisions. Fall back to the full multi-word topic as the anchor.
54 topic_clean = topic.strip()
55 if len(core.split()) <= 1 and len(topic_clean.split()) > 1 and core.lower() != topic_clean.lower():
56 core = topic_clean
57 queries = [core]
58
59 # Add original topic if meaningfully different
60 if topic.lower().strip() != core.lower().strip():
61 queries.append(topic.strip())
62
63 # Add compound term variant for deep searches
64 if len(queries) < 3:
65 from .query import extract_compound_terms
66 compounds = extract_compound_terms(topic)
67 if compounds:
68 or_parts = " OR ".join(f'"{t}"' for t in compounds[:3])
69 queries.append(f"({or_parts})")
70
71 cap = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])["queries"]
72 return queries[:cap]
73
74
75 def search_xquik(
76 topic: str,
77 from_date: str,
78 to_date: str,
79 depth: str = "default",
80 token: str = "",
81 ) -> Dict[str, Any]:
82 """Search X via Xquik REST API.
83
84 Args:
85 topic: Search topic
86 from_date: Start date (YYYY-MM-DD)
87 to_date: End date (YYYY-MM-DD)
88 depth: Research depth - "quick", "default", or "deep"
89 token: Xquik API key
90
91 Returns:
92 Dict with "items" list and optional "error" string.
93 """
94 if not token:
95 return {"items": [], "error": "No XQUIK_API_KEY configured"}
96
97 cfg = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
98 queries = expand_xquik_queries(topic, depth)
99 all_items: List[Dict[str, Any]] = []
100 seen_ids: set[str] = set()
101
102 for query_text in queries:
103 q = f"{query_text} since:{from_date} until:{to_date}"
104 items, auth_error = _execute_search(
105 q, cfg["limit"], token,
106 label=query_text, id_prefix="XQ",
107 seen_ids=seen_ids, relevance_query=query_text,
108 index_offset=len(all_items),
109 )
110 if auth_error:
111 # Auth/payment failure is fatal for the whole source (e.g. 401/403,
112 # and 402-unpaid surfaced via U5 diagnose) — return it so the caller
113 # settles honestly instead of silently empty.
114 return {"items": [], "error": auth_error}
115 all_items.extend(items)
116
117 return {"items": all_items}
118
119
120 def _execute_search(
121 q: str,
122 limit: int,
123 token: str,
124 *,
125 label: str,
126 id_prefix: str,
127 seen_ids: set[str],
128 relevance_query: str,
129 index_offset: int = 0,
130 ) -> tuple[List[Dict[str, Any]], str | None]:
131 """Run one Xquik search call and parse its tweets.
132
133 Returns ``(items, auth_error)``. ``auth_error`` is a non-empty string only
134 on a fatal auth/payment failure (401/403); transient/HTTP errors log and
135 return ``([], None)`` so one bad lane never discards another's results.
136 ``relevance_query`` (the topic) is what items are scored against — for the
137 handle lanes that differs from the search query (``from:handle``).
138 ``index_offset`` keeps item ids unique across multiple calls that share an
139 accumulator (multi-query topic search, per-handle lanes).
140 """
141 full_url = f"{_BASE_URL}/x/tweets/search?q={_url_encode(q)}&queryType=Top&limit={limit}"
142 _log(f"Searching: {label}")
143 try:
144 request_headers = {"X-Api-Key": token}
145 response = http.get(full_url, headers=request_headers, timeout=30, retries=2)
146 except http.HTTPError as exc:
147 status = getattr(exc, "status_code", None)
148 if status == 402:
149 # Unpaid key — fatal for the source, and surfaced on the real search
150 # path (not just --diagnose) so a live run reports it instead of
151 # settling silently empty.
152 return [], "Xquik key unpaid (402)"
153 if status in (401, 403):
154 return [], f"Xquik auth failed ({status})"
155 _log(f"HTTP error for '{label}': {exc}")
156 return [], None
157 except Exception as exc:
158 _log(f"Error for '{label}': {exc}")
159 return [], None
160
161 tweets = response.get("tweets", [])
162 if not isinstance(tweets, list):
163 return [], None
164 items: List[Dict[str, Any]] = []
165 for tweet in tweets:
166 if not isinstance(tweet, dict):
167 continue
168 tweet_id = str(tweet.get("id", ""))
169 if tweet_id in seen_ids:
170 continue
171 seen_ids.add(tweet_id)
172 item = _parse_tweet(tweet, index_offset + len(items), relevance_query, id_prefix=id_prefix)
173 if item:
174 items.append(item)
175 return items, None
176
177
178 def _is_own(url: str, handle: str) -> bool:
179 """True when a tweet URL is authored by ``handle`` (their own post).
180
181 Used by the ABOUT lane to drop the subject's own tweets so only mentions
182 *by others* remain. Handles both x.com and twitter.com permalinks.
183 """
184 u = (url or "").lower()
185 h = handle.lower().lstrip("@").strip()
186 return bool(h) and (f"x.com/{h}/status" in u or f"twitter.com/{h}/status" in u)
187
188
189 def search_handles(
190 handles: List[str],
191 topic: str,
192 from_date: str,
193 to_date: str,
194 *,
195 count_per: int = 8,
196 token: str = "",
197 ) -> List[Dict[str, Any]]:
198 """FROM lane: tweets authored BY each handle (their own timeline).
199
200 The topic is NOT AND'd into the query (that was the from:-AND bug, #610) —
201 we pull the raw timeline and use ``topic`` for relevance ranking only.
202 Returns a flat list of item dicts (mirrors ``bird_x.search_handles``).
203 """
204 if not token or not handles:
205 return []
206 items: List[Dict[str, Any]] = []
207 seen_ids: set[str] = set()
208 for raw in handles:
209 handle = str(raw).lstrip("@").strip()
210 if not handle:
211 continue
212 q = f"from:{handle} since:{from_date} until:{to_date}"
213 got, auth_error = _execute_search(
214 q, count_per, token,
215 label=f"from:{handle}", id_prefix="XF",
216 seen_ids=seen_ids, relevance_query=topic,
217 index_offset=len(items),
218 )
219 if auth_error:
220 break # fatal auth/payment failure — stop, keep what we have
221 items.extend(got)
222 return items
223
224
225 def search_mentions(
226 handles: List[str],
227 from_date: str,
228 to_date: str,
229 *,
230 topic: str = "",
231 count_per: int = 5,
232 token: str = "",
233 ) -> List[Dict[str, Any]]:
234 """ABOUT lane: tweets mentioning each handle, authored by OTHERS.
235
236 Queries ``@handle`` then drops the handle's own tweets (``_is_own``) so only
237 third-party mentions remain. Returns a flat list of item dicts.
238 """
239 if not token or not handles:
240 return []
241 items: List[Dict[str, Any]] = []
242 seen_ids: set[str] = set()
243 for raw in handles:
244 handle = str(raw).lstrip("@").strip()
245 if not handle:
246 continue
247 q = f"@{handle} since:{from_date} until:{to_date}"
248 got, auth_error = _execute_search(
249 q, count_per, token,
250 label=f"@{handle}", id_prefix="XA",
251 seen_ids=seen_ids, relevance_query=topic,
252 index_offset=len(items),
253 )
254 if auth_error:
255 break
256 items.extend(it for it in got if not _is_own(it.get("url", ""), handle))
257 return items
258
259
260 def probe_works(token: str, timeout: int = 8) -> Optional[bool]:
261 """Cheap runtime check that the xquik key actually returns data.
262
263 Mirrors ``bird_x.probe_works`` for the key-based X path so ``--diagnose``
264 reflects reality instead of static key presence. Returns True
265 (funded/working), False (a clear auth/payment failure — 401/403, or 402
266 when the key is configured but unpaid), or None (inconclusive: timeout /
267 transient HTTP) so callers fail open.
268 The human-readable reason is available via ``probe_reason()``. Cached per
269 process so repeated diagnose calls don't re-probe.
270 """
271 global _probe_cache
272 if _probe_cache[0] != "unset":
273 return _probe_cache[0]
274 if not token:
275 _probe_cache = (False, "no XQUIK_API_KEY configured")
276 return False
277 from datetime import timedelta, timezone
278 since = (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%d")
279 # @x (the platform's own account) posts frequently, so a no-error response
280 # means the key works even if this particular window is quiet.
281 q = f"from:x since:{since}"
282 full_url = f"{_BASE_URL}/x/tweets/search?q={_url_encode(q)}&queryType=Top&limit=1"
283 try:
284 request_headers = {"X-Api-Key": token}
285 http.get(full_url, headers=request_headers, timeout=timeout, retries=0)
286 except http.HTTPError as exc:
287 status = getattr(exc, "status_code", None)
288 if status == 402:
289 _probe_cache = (False, "xquik key unpaid (402)")
290 elif status in (401, 403):
291 _probe_cache = (False, f"xquik auth failed ({status})")
292 else:
293 # 5xx / unexpected status — inconclusive, don't report a false-down.
294 _probe_cache = (None, f"xquik probe inconclusive ({status})")
295 return _probe_cache[0]
296 except Exception as exc:
297 _probe_cache = (None, f"xquik probe inconclusive ({type(exc).__name__})")
298 return None
299 _probe_cache = (True, "ok")
300 return True
301
302
303 def probe_reason() -> str:
304 """Human-readable reason for the last ``probe_works`` result (or '')."""
305 return _probe_cache[1]
306
307
308 def search_and_enrich(
309 topic: str,
310 from_date: str,
311 to_date: str,
312 depth: str = "default",
313 token: str = "",
314 ) -> Dict[str, Any]:
315 """Search X via Xquik and return results.
316
317 Xquik API returns full engagement data by default, so no separate
318 enrichment step is needed.
319 """
320 return search_xquik(topic, from_date, to_date, depth=depth, token=token)
321
322
323 def parse_xquik_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
324 """Extract items from search response.
325
326 Args:
327 response: Response dict from search_xquik()
328
329 Returns:
330 List of normalized item dicts.
331 """
332 return response.get("items", [])
333
334
335 def _parse_tweet(
336 tweet: Dict[str, Any], index: int, query: str, id_prefix: str = "XQ"
337 ) -> Dict[str, Any] | None:
338 """Parse a single tweet from the API response into the standard item format."""
339 author = tweet.get("author") or {}
340 username = str(author.get("username", "")).lstrip("@")
341 tweet_id = str(tweet.get("id", ""))
342
343 # Build URL
344 url = ""
345 if username and tweet_id:
346 url = f"https://x.com/{username}/status/{tweet_id}"
347 if not url:
348 return None
349
350 # Parse date
351 date = None
352 created_at = tweet.get("createdAt") or ""
353 if created_at:
354 try:
355 if len(created_at) > 10 and created_at[10] == "T":
356 dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
357 else:
358 dt = datetime.strptime(created_at, "%a %b %d %H:%M:%S %z %Y")
359 date = dt.strftime("%Y-%m-%d")
360 except (ValueError, TypeError):
361 pass
362
363 text = str(tweet.get("text", "")).strip()[:500]
364
365 # Leading-run @mentions = who the post is directed at (reply target). Shared
366 # parser with bird so the first-party interaction signal fires for xquik too.
367 from .query import leading_mentions
368 mentioned_handles = leading_mentions(text)
369
370 # Build engagement dict with full metrics
371 engagement = {
372 "likes": _safe_int(tweet.get("likeCount")),
373 "reposts": _safe_int(tweet.get("retweetCount")),
374 "replies": _safe_int(tweet.get("replyCount")),
375 "quotes": _safe_int(tweet.get("quoteCount")),
376 "views": _safe_int(tweet.get("viewCount")),
377 "bookmarks": _safe_int(tweet.get("bookmarkCount")),
378 }
379
380 return {
381 "id": f"{id_prefix}{index + 1}",
382 "text": text,
383 "url": url,
384 "author_handle": username,
385 "date": date,
386 "engagement": engagement,
387 "mentioned_handles": mentioned_handles,
388 "relevance": _compute_relevance(query, text) if query else 0.7,
389 "why_relevant": "",
390 }
391
392
393 def _safe_int(value: Any) -> int | None:
394 """Convert value to int, returning None on failure."""
395 if value is None:
396 return None
397 try:
398 return int(value)
399 except (ValueError, TypeError):
400 return None
401
402
403 def _url_encode(text: str) -> str:
404 """URL-encode a string using stdlib."""
405 from urllib.parse import quote
406 return quote(text, safe="")
407
407 lines PYTHON