luckiest-co 1.0.47 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +4 -0
  257. package/skills/video-studio/SKILL.md +4 -4
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  261. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  262. package/skills/video-studio-motion/SKILL.md +8 -5
  263. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  264. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,824 @@
1
+ """Direct X API v2 backend (``xapi``) and the shared v2 response parser.
2
+
3
+ ``xapi`` searches X through ``api.x.com/2`` with an app-only bearer token
4
+ (``X_BEARER_TOKEN``). Full-archive search is tried first; when the developer
5
+ project is not enrolled for it (HTTP 403 with an enrollment marker), the
6
+ search retries once against recent search with the window clamped to the
7
+ last seven days and the truncation named in the result.
8
+
9
+ This module also owns the one X API v2 parser (``parse_v2_response``) that
10
+ ``xurl_x`` delegates to, plus the snowflake, handle-grammar, and
11
+ generated-sequence helpers that ``grok_x`` imports back.
12
+
13
+ Security contract: the bearer travels only in the Authorization
14
+ header; every failure becomes an engine-authored fixed string chosen by
15
+ status code plus a marker match on the body, and the response body, reason
16
+ phrase, and headers never enter an error string, a log line, or an
17
+ exception. Topic and handle text is sanitized before it enters a query, and
18
+ the research window is always carried by request parameters.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import time
25
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
26
+ from datetime import datetime, timedelta, timezone
27
+ from typing import Any, Callable, Dict, List, Optional
28
+
29
+ from . import health, http, log
30
+ from .relevance import token_overlap_relevance as _compute_relevance
31
+
32
+ _API_BASE = "https://api.x.com/2"
33
+ _SEARCH_ALL_URL = f"{_API_BASE}/tweets/search/all"
34
+ _SEARCH_RECENT_URL = f"{_API_BASE}/tweets/search/recent"
35
+
36
+ # Depth configurations: number of posts to collect per query (shared with
37
+ # xurl_x, which re-imports this table). The API minimum per page is 10.
38
+ DEPTH_CONFIG = {
39
+ "quick": 10,
40
+ "default": 30,
41
+ "deep": 60,
42
+ }
43
+
44
+ # X API v2 caps a search query at 512 characters (Basic) and pages at 10..100.
45
+ MAX_QUERY_CHARS = 512
46
+ MIN_PAGE_RESULTS = 10
47
+ MAX_PAGE_RESULTS = 100
48
+ # Recent search reaches back seven days; end_time must be at least ten
49
+ # seconds before now, so a "today" window keeps a small safety margin.
50
+ RECENT_WINDOW_DAYS = 7
51
+ END_TIME_SAFETY_SECONDS = 30
52
+ TIMEOUT_SECONDS = 30
53
+ RETRIES = 2
54
+ # Wall-clock budget for one search (all pages, including the recent-search
55
+ # fallback). A throttled or slow walk returns what it has instead of holding
56
+ # the whole run; the source's per-request timeout still bounds each page.
57
+ DEADLINE_SECONDS = 90
58
+ # One budget shared by every supplemental handle lane of a run (explicit
59
+ # from, extracted from, about, related), started once by the caller: the
60
+ # point is to bound the total, not each part.
61
+ LANE_BUDGET_SECONDS = 150.0
62
+
63
+ TRUNCATION_DETAIL = "window truncated to 7 days"
64
+ # Receipt when the lane budget stopped a search before ``count`` posts were
65
+ # collected: the posts kept are real, the coverage is not complete.
66
+ DEADLINE_DETAIL = "search stopped at the lane deadline; results may be incomplete"
67
+
68
+ # The one description of what an app-only bearer can reach. Doctor and the
69
+ # prescriptions quote it; the bearer path is never described as parity with
70
+ # the connector lane.
71
+ BEARER_COVERAGE_NOTE = (
72
+ "recent posts, about the last week, unless your X developer project has "
73
+ "full-archive access"
74
+ )
75
+
76
+ # Engine-authored fixed error strings. Each carries a marker that
77
+ # http.classify_failure recognizes, because the X retrieval branch classifies
78
+ # by message text only.
79
+ ERR_PAYMENT_REQUIRED = "xapi: payment required (X API credits exhausted)"
80
+ ERR_UNAUTHORIZED = "xapi: unauthorized (bearer token rejected)"
81
+ ERR_FORBIDDEN = "xapi: forbidden (bearer token lacks access)"
82
+ ERR_RATE_LIMITED = "xapi: rate limit exceeded (X API)"
83
+ ERR_TIMED_OUT = "xapi: timed out"
84
+ ERR_UNREACHABLE = "xapi: connection error (X API unreachable)"
85
+ ERR_NO_TOKEN = "No X_BEARER_TOKEN configured"
86
+ ERR_EMPTY_QUERY = "xapi: empty query after sanitizing"
87
+
88
+ # Failures that end every remaining lane for the run (the key itself is the
89
+ # problem, not this request).
90
+ _FATAL_ERRORS = frozenset({ERR_PAYMENT_REQUIRED, ERR_UNAUTHORIZED, ERR_FORBIDDEN})
91
+
92
+ # A 403 whose body carries one of these is "this project is not enrolled for
93
+ # full-archive search", which recent search can still serve.
94
+ _ENROLLMENT_MARKERS = (
95
+ "client-not-enrolled",
96
+ "not enrolled",
97
+ "enrolled",
98
+ "access level",
99
+ "subset of",
100
+ )
101
+ # X-specific body markers for credit exhaustion, matched on any status on
102
+ # top of http.classify_failure's shared payment-required markers: X reports
103
+ # a depleted pay-per-use balance under more than one code and title.
104
+ _X_CREDIT_MARKERS = ("creditsdepleted", "credits depleted")
105
+
106
+ # Twitter/X snowflake epoch (2010-11-04T01:42:54.657Z) in milliseconds.
107
+ _SNOWFLAKE_EPOCH_MS = 1288834974657
108
+
109
+ # X's real handle grammar. Handles are interpolated into post URLs and into
110
+ # search operators (from:, @), so anything outside this charset is rejected
111
+ # rather than passed through. entity_extract applies the same rule to
112
+ # @mentions.
113
+ _HANDLE_RE = re.compile(r"[A-Za-z0-9_]{1,15}")
114
+
115
+ # Grouping syntax that carries no lexical meaning in a topic, mirroring
116
+ # bird_x. Double quotes are handled separately: xapi strips them all and
117
+ # wraps the core in exactly one phrase pair, so a planner quote can never
118
+ # close the phrase early.
119
+ _GROUPING_CHARS = "“”()[]{}"
120
+ _QUOTE_CHARS = "\"„‟"
121
+ _APOSTROPHES = "'‘’"
122
+
123
+
124
+ def _log(msg: str) -> None:
125
+ log.source_log("xapi", msg, tty_only=False)
126
+
127
+
128
+ def _utcnow() -> datetime:
129
+ """Current UTC time; a seam tests patch to pin the window arithmetic."""
130
+ return datetime.now(timezone.utc)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Shared helpers (moved here from grok_x; grok_x imports them back)
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ def _decode_snowflake(post_id: str) -> Optional[datetime]:
139
+ """Recover a post's creation time from its id, with no network call."""
140
+ try:
141
+ value = int(str(post_id).strip())
142
+ except (TypeError, ValueError):
143
+ return None
144
+ if value <= 0:
145
+ return None
146
+ try:
147
+ return datetime.fromtimestamp(
148
+ ((value >> 22) + _SNOWFLAKE_EPOCH_MS) / 1000, tz=timezone.utc
149
+ )
150
+ except (OverflowError, OSError, ValueError):
151
+ return None
152
+
153
+
154
+ def _looks_generated(ids: List[str]) -> bool:
155
+ """True when ids form a near-uniform arithmetic run.
156
+
157
+ Real ranked results are not evenly spaced in time. A fabricated set often
158
+ is, because a model interpolates a plausible-looking id sequence. Four
159
+ ids is the minimum used here: three gaps are needed before a near-uniform
160
+ step reads as generated rather than coincidental.
161
+ """
162
+ numeric = []
163
+ for pid in ids:
164
+ try:
165
+ numeric.append(int(pid))
166
+ except (TypeError, ValueError):
167
+ return False
168
+ if len(numeric) < 4:
169
+ return False
170
+ numeric.sort()
171
+ gaps = [b - a for a, b in zip(numeric, numeric[1:])]
172
+ if any(g <= 0 for g in gaps):
173
+ return False
174
+ mean = sum(gaps) / len(gaps)
175
+ if mean <= 0:
176
+ return False
177
+ # Every gap within 5% of the mean is not something real timelines do.
178
+ return all(abs(g - mean) / mean < 0.05 for g in gaps)
179
+
180
+
181
+ def _clean_handle(value: str) -> str:
182
+ """Return a grammar-valid handle, or '' when the value is not one."""
183
+ candidate = str(value or "").strip().lstrip("@")
184
+ return candidate if _HANDLE_RE.fullmatch(candidate) else ""
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Parser
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ def _safe_int(value: Any) -> Optional[int]:
193
+ if value is None:
194
+ return None
195
+ try:
196
+ return int(value)
197
+ except (ValueError, TypeError):
198
+ return None
199
+
200
+
201
+ def parse_v2_response(
202
+ response: Dict[str, Any],
203
+ topic: str = "",
204
+ window: Optional[tuple[str, str]] = None,
205
+ *,
206
+ id_prefix: str = "XAPI",
207
+ index_offset: int = 0,
208
+ seen_ids: Optional[set[str]] = None,
209
+ ) -> List[Dict[str, Any]]:
210
+ """Parse an X API v2 search response into the engine's X item shape.
211
+
212
+ Output matches the XItem schema used by xai_x and bird_x (id, text, url,
213
+ author_handle, date, engagement, mentioned_handles, why_relevant,
214
+ relevance) plus ``post_id`` (the numeric snowflake as a string). The
215
+ citation URL is rebuilt from the id and username; URL fields in the
216
+ response are never used. A post whose author is not in
217
+ ``includes.users`` (or whose username fails the handle grammar) keeps
218
+ the ``https://x.com/i/status/<id>`` form with an empty handle.
219
+
220
+ ``window`` is an optional ``(from_date, to_date)`` pair of YYYY-MM-DD
221
+ strings; dated posts outside it are dropped. ``seen_ids`` and
222
+ ``index_offset`` let several calls share one accumulator with unique ids.
223
+ """
224
+ items: List[Dict[str, Any]] = []
225
+ if not isinstance(response, dict):
226
+ return items
227
+ if "error" in response:
228
+ _log("error in response; no items parsed")
229
+ return items
230
+
231
+ data = response.get("data") or []
232
+ if not isinstance(data, list) or not data:
233
+ return items
234
+
235
+ authors: Dict[str, str] = {}
236
+ for user in (response.get("includes") or {}).get("users") or []:
237
+ if isinstance(user, dict) and user.get("id") is not None:
238
+ authors[str(user["id"])] = _clean_handle(user.get("username", ""))
239
+
240
+ from .query import leading_mentions
241
+
242
+ window_from, window_to = (window or (None, None))
243
+ seen = seen_ids if seen_ids is not None else set()
244
+
245
+ for tweet in data:
246
+ if not isinstance(tweet, dict):
247
+ continue
248
+ tweet_id = str(tweet.get("id") or "").strip()
249
+ if not tweet_id.isdigit():
250
+ continue
251
+ if tweet_id in seen:
252
+ continue
253
+
254
+ username = authors.get(str(tweet.get("author_id") or ""), "")
255
+ if username:
256
+ url = f"https://x.com/{username}/status/{tweet_id}"
257
+ else:
258
+ url = f"https://x.com/i/status/{tweet_id}"
259
+
260
+ note = tweet.get("note_tweet")
261
+ text = ""
262
+ if isinstance(note, dict) and note.get("text"):
263
+ text = str(note["text"])
264
+ else:
265
+ text = str(tweet.get("text") or "")
266
+ text = text.strip()
267
+
268
+ engagement: Optional[Dict[str, Any]] = None
269
+ metrics = tweet.get("public_metrics") or {}
270
+ if isinstance(metrics, dict) and metrics:
271
+ engagement = {
272
+ "likes": metrics.get("like_count", 0),
273
+ "reposts": metrics.get("retweet_count", 0),
274
+ "replies": metrics.get("reply_count", 0),
275
+ "quotes": metrics.get("quote_count", 0),
276
+ }
277
+ bookmarks = _safe_int(metrics.get("bookmark_count"))
278
+ if bookmarks is not None:
279
+ engagement["bookmarks"] = bookmarks
280
+ views = _safe_int(metrics.get("impression_count"))
281
+ if views is not None:
282
+ engagement["views"] = views
283
+
284
+ date: Optional[str] = None
285
+ created = str(tweet.get("created_at") or "")
286
+ if created:
287
+ m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
288
+ if m:
289
+ date = m.group(1)
290
+ if date and window_from and date < window_from:
291
+ continue
292
+ if date and window_to and date > window_to:
293
+ continue
294
+
295
+ seen.add(tweet_id)
296
+ items.append({
297
+ "id": f"{id_prefix}{index_offset + len(items) + 1}",
298
+ "text": text[:500],
299
+ "url": url,
300
+ "author_handle": username,
301
+ "date": date,
302
+ "engagement": engagement,
303
+ "mentioned_handles": leading_mentions(text),
304
+ "why_relevant": "",
305
+ "relevance": _compute_relevance(topic, text) if topic else 0.5,
306
+ "post_id": tweet_id,
307
+ })
308
+
309
+ return items
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Query compilation
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ def _topic_tokens(topic: str, *, preserve_trailing_colon: bool = False) -> List[str]:
318
+ """Sanitized topic tokens: no quotes, grouping, operators, or negation."""
319
+ separators = str.maketrans({char: " " for char in _GROUPING_CHARS + _QUOTE_CHARS})
320
+ cleaned = str(topic or "").translate(separators)
321
+ tokens: List[str] = []
322
+ for token in cleaned.split():
323
+ clean = token.strip(_APOSTROPHES)
324
+ if preserve_trailing_colon and clean.endswith(":") and clean.count(":") == 1:
325
+ clean = clean[:-1]
326
+ if not clean:
327
+ continue
328
+ if ":" in clean or clean.startswith("-"):
329
+ continue
330
+ if clean.upper() in ("OR", "AND"):
331
+ continue
332
+ tokens.append(clean)
333
+ return tokens
334
+
335
+
336
+ def _compile(tokens: List[str]) -> str:
337
+ return f'"{" ".join(tokens)}" -is:retweet'
338
+
339
+
340
+ def _compile_keywords(tokens: List[str]) -> str:
341
+ return " ".join(tokens)
342
+
343
+
344
+ def _fit(tokens: List[str], compile_tokens: Callable[[List[str]], str]) -> str:
345
+ """Compile *tokens*, dropping trailing ones until under ``MAX_QUERY_CHARS``."""
346
+ if not tokens:
347
+ return ""
348
+ tokens = list(tokens)
349
+ compiled = compile_tokens(tokens)
350
+ while len(compiled) > MAX_QUERY_CHARS and len(tokens) > 1:
351
+ tokens.pop()
352
+ compiled = compile_tokens(tokens)
353
+ if len(compiled) > MAX_QUERY_CHARS:
354
+ # One token longer than the whole budget: keep as much of it as fits.
355
+ overhead = len(compile_tokens([""]))
356
+ tokens = [tokens[0][: MAX_QUERY_CHARS - overhead]]
357
+ compiled = compile_tokens(tokens)
358
+ return compiled
359
+
360
+
361
+ def build_query(topic: str) -> str:
362
+ """Compile a topic into one quoted phrase plus ``-is:retweet``.
363
+
364
+ Mirrors ``bird_x.build_topic_query`` (grouping characters become spaces,
365
+ quotes are balanced) and additionally drops every operator-shaped token
366
+ (anything with a colon, anything starting with ``-``, bare OR/AND) so a
367
+ topic can never smuggle ``from:``, ``since:``, or a phrase break into the
368
+ request. The window is never part of the query (see ``_window``). The
369
+ result stays under ``MAX_QUERY_CHARS``, cut at a token boundary. Returns
370
+ "" when nothing lexical survives.
371
+ """
372
+ return _fit(_topic_tokens(topic), _compile)
373
+
374
+
375
+ def build_keyword_query(topic: str) -> str:
376
+ """Compile a topic into space-joined keywords (implicit AND), no operators.
377
+
378
+ The keyword counterpart of :func:`build_query` for callers that keep
379
+ X's default any-order keyword matching instead of one exact phrase
380
+ (``xurl_x``). The same operator safeguards apply, while ordinary trailing
381
+ colons preserve subject words: bare ``and``/``or`` in any case, colon
382
+ operators, leading ``-`` negation, grouping and quote characters never
383
+ reach X, whose v2 grammar rejects a bare lowercase ``and``/``or`` with
384
+ HTTP 400. Capped at ``MAX_QUERY_CHARS`` on a token boundary. Returns ""
385
+ when nothing lexical survives.
386
+ """
387
+ return _fit(_topic_tokens(topic, preserve_trailing_colon=True), _compile_keywords)
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Window
392
+ # ---------------------------------------------------------------------------
393
+
394
+
395
+ def _parse_day(value: str) -> Optional[datetime]:
396
+ try:
397
+ return datetime.strptime(str(value)[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
398
+ except (TypeError, ValueError):
399
+ return None
400
+
401
+
402
+ def _iso(when: datetime) -> str:
403
+ return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
404
+
405
+
406
+ def _window(from_date: str, to_date: str) -> tuple[str, str]:
407
+ """``(start_time, end_time)`` for the request, ISO 8601 with Z.
408
+
409
+ ``start_time`` is midnight UTC of ``from_date``; ``end_time`` is the end
410
+ of ``to_date`` or, when that would be now or later ("today"), now minus a
411
+ safety margin.
412
+ """
413
+ now = _utcnow()
414
+ start = _parse_day(from_date) or (now - timedelta(days=30))
415
+ end_day = _parse_day(to_date)
416
+ latest = now - timedelta(seconds=END_TIME_SAFETY_SECONDS)
417
+ if end_day is None:
418
+ end = latest
419
+ else:
420
+ end = min(end_day + timedelta(hours=23, minutes=59, seconds=59), latest)
421
+ if start >= end:
422
+ start = end - timedelta(days=1)
423
+ return _iso(start), _iso(end)
424
+
425
+
426
+ def _recent_floor() -> str:
427
+ """The earliest ``start_time`` recent search accepts, with a margin."""
428
+ return _iso(
429
+ _utcnow() - timedelta(days=RECENT_WINDOW_DAYS) + timedelta(seconds=END_TIME_SAFETY_SECONDS)
430
+ )
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Transport
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ class _XApiFailure(Exception):
439
+ """A request failure already reduced to its fixed string.
440
+
441
+ ``enrollment`` marks the one 403 that recent search can still serve.
442
+ The message is always one of the ERR_* literals (or ``xapi: http <n>``),
443
+ never response text.
444
+ """
445
+
446
+ def __init__(self, message: str, *, enrollment: bool = False):
447
+ super().__init__(message)
448
+ self.enrollment = enrollment
449
+
450
+
451
+ def _failure_for(exc: http.HTTPError) -> _XApiFailure:
452
+ status = getattr(exc, "status_code", None)
453
+ body = str(getattr(exc, "body", "") or "").lower()
454
+ if (
455
+ status == 402
456
+ or http.classify_failure(message=body) == health.PAYMENT_REQUIRED
457
+ or any(marker in body for marker in _X_CREDIT_MARKERS)
458
+ ):
459
+ return _XApiFailure(ERR_PAYMENT_REQUIRED)
460
+ if status == 401:
461
+ return _XApiFailure(ERR_UNAUTHORIZED)
462
+ if status == 403:
463
+ enrolled = any(marker in body for marker in _ENROLLMENT_MARKERS)
464
+ return _XApiFailure(ERR_FORBIDDEN, enrollment=enrolled)
465
+ if status == 429:
466
+ return _XApiFailure(ERR_RATE_LIMITED)
467
+ state = getattr(exc, "outcome_state", None)
468
+ if status == 408 or state == health.TIMEOUT:
469
+ return _XApiFailure(ERR_TIMED_OUT)
470
+ if status:
471
+ return _XApiFailure(f"xapi: http {status}")
472
+ if state == health.UNREACHABLE:
473
+ return _XApiFailure(ERR_UNREACHABLE)
474
+ return _XApiFailure("xapi: request failed (HTTPError)")
475
+
476
+
477
+ def _get(
478
+ token: str, url: str, params: Dict[str, Any], deadline: Optional[float] = None,
479
+ cancel: Any = None,
480
+ ) -> Dict[str, Any]:
481
+ """One authenticated GET; every failure surfaces as ``_XApiFailure``.
482
+
483
+ ``deadline`` is handed to the transport as its wall deadline, so a
484
+ request started just before it cannot keep the full per-request timeout
485
+ and retry cycle: the socket wait and every backoff sleep stop at the
486
+ deadline (``http.DeadlineExceeded`` -> ``ERR_TIMED_OUT``).
487
+ """
488
+ try:
489
+ response = http.get(
490
+ url,
491
+ headers={"Authorization": f"Bearer {token}"},
492
+ params=params,
493
+ timeout=TIMEOUT_SECONDS,
494
+ retries=RETRIES,
495
+ deadline_monotonic=deadline,
496
+ **({"cancel": cancel} if cancel is not None else {}),
497
+ )
498
+ except http.HTTPError as exc:
499
+ raise _failure_for(exc) from None
500
+ except TimeoutError:
501
+ raise _XApiFailure(ERR_TIMED_OUT) from None
502
+ except Exception as exc: # noqa: BLE001 - the message must never leak
503
+ raise _XApiFailure(f"xapi: request failed ({type(exc).__name__})") from None
504
+ return response if isinstance(response, dict) else {}
505
+
506
+
507
+ def _page_size(count: int) -> int:
508
+ return max(MIN_PAGE_RESULTS, min(MAX_PAGE_RESULTS, int(count)))
509
+
510
+
511
+ def _search_pages(
512
+ token: str,
513
+ url: str,
514
+ params: Dict[str, Any],
515
+ count: int,
516
+ deadline: Optional[float] = None,
517
+ cancel: Any = None,
518
+ ) -> Dict[str, Any]:
519
+ """Follow ``next_token`` until ``count`` posts are collected.
520
+
521
+ ``deadline`` is a ``time.monotonic()`` instant: the first page always
522
+ runs; a later page is skipped once the deadline has passed and the
523
+ posts collected so far are returned with ``"truncated": True`` so the
524
+ caller can report the incomplete coverage.
525
+ """
526
+ data: List[Dict[str, Any]] = []
527
+ users: Dict[str, Dict[str, Any]] = {}
528
+ truncated = False
529
+ page_params = dict(params)
530
+ page_params["max_results"] = _page_size(count)
531
+ # Bound the walk even when every page carries a next_token.
532
+ max_pages = max(1, -(-count // MIN_PAGE_RESULTS))
533
+ for page_index in range(max_pages):
534
+ if cancel is not None and cancel.is_set():
535
+ if not data:
536
+ raise _XApiFailure(ERR_TIMED_OUT)
537
+ truncated = True
538
+ break
539
+ if page_index and deadline is not None and time.monotonic() >= deadline:
540
+ _log(f"search deadline ({DEADLINE_SECONDS}s) reached; keeping {len(data)} posts")
541
+ truncated = True
542
+ break
543
+ # A fresh dict per page: the transport must never see a later
544
+ # page's next_token on an earlier request.
545
+ try:
546
+ response = _get(token, url, dict(page_params), deadline, **({"cancel": cancel} if cancel is not None else {}))
547
+ except _XApiFailure as exc:
548
+ if data and str(exc) == ERR_TIMED_OUT:
549
+ # The budget ran out mid-walk: the pages already collected
550
+ # are the result, reported as truncated, not a failure.
551
+ _log(f"search deadline reached mid-walk; keeping {len(data)} posts")
552
+ truncated = True
553
+ break
554
+ raise
555
+ page = response.get("data") or []
556
+ if isinstance(page, list):
557
+ data.extend(t for t in page if isinstance(t, dict))
558
+ for user in (response.get("includes") or {}).get("users") or []:
559
+ if isinstance(user, dict) and user.get("id") is not None:
560
+ users[str(user["id"])] = user
561
+ next_token = (response.get("meta") or {}).get("next_token")
562
+ if not next_token or len(data) >= count:
563
+ break
564
+ page_params["next_token"] = next_token
565
+ return {
566
+ "data": data[:count],
567
+ "includes": {"users": list(users.values())},
568
+ "truncated": truncated,
569
+ }
570
+
571
+
572
+ def _run_search(
573
+ token: str,
574
+ query: str,
575
+ from_date: str,
576
+ to_date: str,
577
+ count: int,
578
+ *,
579
+ topic: str,
580
+ id_prefix: str,
581
+ label: str,
582
+ deadline: Optional[float] = None,
583
+ cancel: Any = None,
584
+ ) -> Dict[str, Any]:
585
+ """Full-archive search with the recent-search fallback.
586
+
587
+ ``deadline`` (a ``time.monotonic()`` instant) is shared with the caller's
588
+ other lanes; without one the search gets its own ``DEADLINE_SECONDS``.
589
+ A shared deadline that has already passed sends no request at all.
590
+
591
+ Returns ``{"items": [...]}`` (plus ``"warning"`` when the window was
592
+ truncated or the deadline stopped the walk early) or ``{"items": [],
593
+ "error": <fixed string>}``.
594
+ """
595
+ start, end = _window(from_date, to_date)
596
+ params = {
597
+ "query": query,
598
+ "start_time": start,
599
+ "end_time": end,
600
+ "sort_order": "recency",
601
+ "expansions": "author_id",
602
+ "tweet.fields": "created_at,public_metrics,note_tweet,entities",
603
+ "user.fields": "username",
604
+ }
605
+ if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
606
+ _log(f"{label}: lane budget ({LANE_BUDGET_SECONDS:.0f}s) exhausted before the search started")
607
+ return {"items": [], "warning": DEADLINE_DETAIL}
608
+ _log(f"Searching: {label}")
609
+ warnings: List[str] = []
610
+ if deadline is None:
611
+ deadline = time.monotonic() + DEADLINE_SECONDS
612
+ try:
613
+ response = _search_pages(token, _SEARCH_ALL_URL, params, count, deadline, **({"cancel": cancel} if cancel is not None else {}))
614
+ except _XApiFailure as exc:
615
+ if not exc.enrollment:
616
+ _log(f"{label}: {exc}")
617
+ return {"items": [], "error": str(exc)}
618
+ _log(f"{label}: full-archive search not enrolled; retrying recent search ({TRUNCATION_DETAIL})")
619
+ floor = _recent_floor()
620
+ if floor >= end:
621
+ # The whole window predates what recent search can reach: an
622
+ # empty, truncated result, never a start_time after end_time.
623
+ _log(f"{label}: window ends before the recent-search floor; nothing to fetch")
624
+ return {"items": [], "warning": TRUNCATION_DETAIL}
625
+ params["start_time"] = max(start, floor)
626
+ try:
627
+ response = _search_pages(token, _SEARCH_RECENT_URL, params, count, deadline, **({"cancel": cancel} if cancel is not None else {}))
628
+ except _XApiFailure as exc2:
629
+ _log(f"{label}: {exc2}")
630
+ return {"items": [], "error": str(exc2)}
631
+ warnings.append(TRUNCATION_DETAIL)
632
+ if response.get("truncated"):
633
+ warnings.append(DEADLINE_DETAIL)
634
+ items = parse_v2_response(response, topic, (from_date, to_date), id_prefix=id_prefix)
635
+ result: Dict[str, Any] = {"items": items}
636
+ if warnings:
637
+ result["warning"] = "; ".join(warnings)
638
+ return result
639
+
640
+
641
+ # ---------------------------------------------------------------------------
642
+ # Public search entry points
643
+ # ---------------------------------------------------------------------------
644
+
645
+
646
+ def search_x(
647
+ token: str,
648
+ query: str,
649
+ from_date: str,
650
+ to_date: str,
651
+ depth: str = "default",
652
+ deadline: Optional[float] = None,
653
+ cancel: Any = None,
654
+ ) -> Dict[str, Any]:
655
+ """Topic search via X API v2.
656
+
657
+ Returns ``{"items": [...]}`` or ``{"items": [], "error": "..."}`` (the
658
+ xquik shape); ``"warning"`` carries the truncation detail after the
659
+ recent-search fallback. ``deadline`` is the X chain's shared
660
+ ``time.monotonic()`` budget; without one the search gets its own
661
+ ``DEADLINE_SECONDS``.
662
+ """
663
+ if not token:
664
+ return {"items": [], "error": ERR_NO_TOKEN}
665
+ count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
666
+ compiled = build_query(query)
667
+ if not compiled:
668
+ _log("topic compiled to an empty query after sanitizing")
669
+ return {"items": [], "error": ERR_EMPTY_QUERY}
670
+ return _run_search(
671
+ token, compiled, from_date, to_date, count,
672
+ topic=query, id_prefix="XAPI", label="topic", deadline=deadline,
673
+ **({"cancel": cancel} if cancel is not None else {}),
674
+ )
675
+
676
+
677
+ def is_own_post(url: str, handle: str) -> bool:
678
+ """True when a post URL is authored by ``handle`` (their own post).
679
+
680
+ The ABOUT lanes (here, xquik, the host envelope) drop the subject's own
681
+ posts so only mentions *by others* remain. Handles both x.com and
682
+ twitter.com permalinks.
683
+ """
684
+ u = (url or "").lower()
685
+ h = handle.lower().lstrip("@").strip()
686
+ return bool(h) and (f"x.com/{h}/status" in u or f"twitter.com/{h}/status" in u)
687
+
688
+
689
+
690
+ def _lane_handle(raw: str, lane: str) -> str:
691
+ handle = _clean_handle(raw)
692
+ if not handle:
693
+ _log(f"skipping {lane} lane for a handle outside the X handle grammar")
694
+ return handle
695
+
696
+
697
+ _MAX_LANE_WORKERS = 5
698
+
699
+
700
+ def _run_handle_lanes(
701
+ handles: List[str],
702
+ search_one: Callable[[str], Dict[str, Any]],
703
+ *,
704
+ id_prefix: str,
705
+ keep: Optional[Callable[[Dict[str, Any], str], bool]] = None,
706
+ warnings: Optional[List[str]] = None,
707
+ ) -> List[Dict[str, Any]]:
708
+ """Run one search per handle on a bounded pool and merge in handle order.
709
+
710
+ Per-handle receipts (``"warning"`` on a result, e.g. a deadline stop)
711
+ are appended to ``warnings`` once each, so the caller can report
712
+ incomplete lane coverage instead of presenting it as complete.
713
+
714
+ Mirrors ``bird_x.search_handles``: at most five handles in flight. A
715
+ fatal auth/payment result stops further handles from being scheduled;
716
+ whatever was already fetched is kept. Results are merged on the calling
717
+ thread in ``handles`` order, deduped by post id across handles, and
718
+ renumbered ``<id_prefix><n>`` so the output is deterministic regardless
719
+ of completion order. ``keep(item, handle)`` filters a handle's items
720
+ after the post id is recorded, so a dropped post still dedupes later.
721
+ """
722
+ results: List[Optional[Dict[str, Any]]] = [None] * len(handles)
723
+ if handles:
724
+ max_workers = min(_MAX_LANE_WORKERS, len(handles))
725
+ fatal = False
726
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
727
+ pending: Dict[Any, int] = {}
728
+ next_index = 0
729
+ while pending or (next_index < len(handles) and not fatal):
730
+ while next_index < len(handles) and not fatal and len(pending) < max_workers:
731
+ pending[pool.submit(search_one, handles[next_index])] = next_index
732
+ next_index += 1
733
+ done, _ = wait(list(pending), return_when=FIRST_COMPLETED)
734
+ for future in done:
735
+ index = pending.pop(future)
736
+ result = future.result()
737
+ results[index] = result
738
+ if result.get("error") in _FATAL_ERRORS:
739
+ fatal = True # the key itself failed; keep what we have
740
+
741
+ items: List[Dict[str, Any]] = []
742
+ seen_ids: set[str] = set()
743
+ for handle, result in zip(handles, results):
744
+ note = (result or {}).get("warning")
745
+ if note and warnings is not None and note not in warnings:
746
+ warnings.append(note)
747
+ for item in (result or {}).get("items", []):
748
+ post_id = item.get("post_id", "")
749
+ if post_id in seen_ids:
750
+ continue
751
+ seen_ids.add(post_id)
752
+ if keep is not None and not keep(item, handle):
753
+ continue
754
+ item["id"] = f"{id_prefix}{len(items) + 1}"
755
+ items.append(item)
756
+ return items
757
+
758
+
759
+ def search_handles(
760
+ handles: List[str],
761
+ topic: str,
762
+ from_date: str,
763
+ to_date: str,
764
+ *,
765
+ count_per: int = 8,
766
+ token: str = "",
767
+ deadline: Optional[float] = None,
768
+ warnings: Optional[List[str]] = None,
769
+ cancel: Any = None,
770
+ ) -> List[Dict[str, Any]]:
771
+ """FROM lane: posts authored BY each handle (their own timeline).
772
+
773
+ The topic is NOT AND'd into the query; it ranks relevance only (mirrors
774
+ ``xquik.search_handles``). A handle outside the X grammar skips its lane
775
+ with a receipt line.
776
+ """
777
+ if not token or not handles:
778
+ return []
779
+ clean = [h for h in (_lane_handle(raw, "from:") for raw in handles) if h]
780
+
781
+ def _search_one(handle: str) -> Dict[str, Any]:
782
+ return _run_search(
783
+ token, f"from:{handle} -is:retweet", from_date, to_date, count_per,
784
+ topic=topic, id_prefix="XF", label=f"from:{handle}", deadline=deadline,
785
+ **({"cancel": cancel} if cancel is not None else {}),
786
+ )
787
+
788
+ return _run_handle_lanes(clean, _search_one, id_prefix="XF", warnings=warnings)
789
+
790
+
791
+ def search_mentions(
792
+ handles: List[str],
793
+ from_date: str,
794
+ to_date: str,
795
+ *,
796
+ topic: str = "",
797
+ count_per: int = 5,
798
+ token: str = "",
799
+ deadline: Optional[float] = None,
800
+ warnings: Optional[List[str]] = None,
801
+ cancel: Any = None,
802
+ ) -> List[Dict[str, Any]]:
803
+ """ABOUT lane: posts mentioning each handle, authored by OTHERS.
804
+
805
+ The query is ``@handle -from:handle`` and the handle's own posts are
806
+ dropped again client-side (``is_own_post``) so only third-party mentions
807
+ remain.
808
+ """
809
+ if not token or not handles:
810
+ return []
811
+ clean = [h for h in (_lane_handle(raw, "mention") for raw in handles) if h]
812
+
813
+ def _search_one(handle: str) -> Dict[str, Any]:
814
+ return _run_search(
815
+ token, f"@{handle} -from:{handle} -is:retweet", from_date, to_date, count_per,
816
+ topic=topic, id_prefix="XA", label=f"@{handle}", deadline=deadline,
817
+ **({"cancel": cancel} if cancel is not None else {}),
818
+ )
819
+
820
+ return _run_handle_lanes(
821
+ clean, _search_one, id_prefix="XA",
822
+ keep=lambda item, handle: not is_own_post(item.get("url", ""), handle),
823
+ warnings=warnings,
824
+ )