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