luckiest-co 1.0.47 → 1.0.49

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 (274) 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/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -15,12 +15,24 @@ Default-on safety (three gates):
15
15
  company/brand -- a domain-like token, or a short (<=2-word) capitalized
16
16
  proper noun. Generic phrases ("AI coding agents", "agent memory") and
17
17
  longer multi-word phrases never call the CLI, so Trustpilot stays quiet --
18
- and never harvests Chrome -- on non-company topics.
18
+ and never harvests Chrome -- on non-company topics. An explicit resolved
19
+ domain (``--trustpilot-domain`` or an auto-resolve hint) bypasses this
20
+ gate: an explicit domain is proof of brand intent.
19
21
  2. Browser opt-out. Automated contexts (cron, CI, the eval harness) can set
20
22
  ``LAST30DAYS_TRUSTPILOT_NO_BROWSER`` to disable the source entirely, so a
21
23
  headless run never spawns the cookie harvest.
22
24
  3. Graceful degradation. Any CLI failure (no Chrome, expired cookie that
23
25
  cannot re-harvest, timeout) degrades to empty results, never an error.
26
+
27
+ Domain resolution: Trustpilot review pages are keyed by domain
28
+ (``www.thriftbooks.com``), not company name -- ``info ThriftBooks`` 404s.
29
+ Priority chain: user flag (verbatim) > auto-resolve hint (retries via search
30
+ on a miss) > domain token in the topic > CLI ``search`` name->domain lookup
31
+ (cached per topic) > cleaned topic (legacy behavior).
32
+
33
+ Session pre-flight: ``ensure_session_ready`` performs one serialized
34
+ ``auth status`` / ``auth login`` before the parallel fan-out so concurrent
35
+ streams and vs-mode sub-runs never race their own Chrome harvests.
24
36
  """
25
37
 
26
38
  from __future__ import annotations
@@ -29,6 +41,8 @@ import json
29
41
  import os
30
42
  import re
31
43
  import shutil
44
+ import threading
45
+ import time
32
46
  from typing import Any, Dict, List, Optional
33
47
 
34
48
  from . import dates, log, subproc
@@ -39,8 +53,16 @@ CLI_BIN = "trustpilot-pp-cli"
39
53
 
40
54
  SEARCH_TIMEOUT = 75 # generous: a cold run may harvest a WAF cookie (~10s).
41
55
 
56
+ AUTH_STATUS_TIMEOUT = 20 # auth status is a local SQLite read; fast.
57
+
42
58
  NO_BROWSER_ENV = "LAST30DAYS_TRUSTPILOT_NO_BROWSER"
43
59
 
60
+ # Among name-matching search hits, the top hit must have this many times the
61
+ # runner-up's review volume to win automatically. Lookalike/squatter pages have
62
+ # tiny volume (ThriftBooks: 2.8M vs 130); comparable volume means genuine
63
+ # ambiguity, where falling back beats silently picking the wrong company.
64
+ DOMAIN_DOMINANCE_FACTOR = 50
65
+
44
66
  # Domain-like token, e.g. "chownow.com", "nothing.tech".
45
67
  _DOMAIN_RE = re.compile(r"\b[a-z0-9][a-z0-9-]*\.(com|io|co|net|org|app|ai|dev|gg|tech|shop|store)\b")
46
68
 
@@ -132,6 +154,192 @@ def _build_info_args(identifier: str) -> List[str]:
132
154
  return [CLI_BIN, "info", identifier, "--agent"]
133
155
 
134
156
 
157
+ def _normalize_name(text: str) -> str:
158
+ """Case/whitespace/punctuation-insensitive brand-name key."""
159
+ return re.sub(r"[^a-z0-9]", "", (text or "").lower())
160
+
161
+
162
+ # name->domain results, keyed by normalized topic. Per-topic (NOT a single
163
+ # process-wide slot): vs-mode resolves several entities in one process, and a
164
+ # single slot would serve entity A's domain to entity B.
165
+ _domain_cache: Dict[str, Optional[str]] = {}
166
+ _domain_cache_lock = threading.Lock()
167
+
168
+ _warmup_lock = threading.Lock()
169
+ _warmup_at: Optional[float] = None # time.monotonic() of the last warm-up
170
+
171
+ # Warm-up freshness window, matching the CLI's ~4-minute safe replay bound for
172
+ # WAF tokens. A boolean-forever flag would leave long-lived host processes
173
+ # running stale (and never retrying a failed login); the TTL re-checks cheaply
174
+ # via `auth status` once the window lapses.
175
+ WARMUP_TTL_SECONDS = 240
176
+
177
+
178
+ def _reset_state_for_tests() -> None:
179
+ """Clear module-level caches/flags (tests only)."""
180
+ global _warmup_at
181
+ with _domain_cache_lock:
182
+ _domain_cache.clear()
183
+ _warmup_at = None
184
+
185
+
186
+ def _warmup_fresh() -> bool:
187
+ return _warmup_at is not None and (time.monotonic() - _warmup_at) < WARMUP_TTL_SECONDS
188
+
189
+
190
+ def _select_search_hit(topic: str, hits: List[Any]) -> Optional[str]:
191
+ """Pick the canonical domain from search hits, or None when ambiguous.
192
+
193
+ Name-match is mandatory: review volume must never override a name
194
+ mismatch, or the engine attributes another company's reviews to the topic.
195
+ Among name-matching hits the winner must dominate on review volume
196
+ (DOMAIN_DOMINANCE_FACTOR); comparable volume is genuine ambiguity and
197
+ falls back to legacy behavior.
198
+ """
199
+ want = _normalize_name(topic)
200
+ if not want:
201
+ return None
202
+ matching: List[tuple[int, str]] = []
203
+ for hit in hits:
204
+ if not isinstance(hit, dict):
205
+ continue
206
+ domain = str(hit.get("domain") or hit.get("identifyingName") or "").strip()
207
+ name = str(hit.get("displayName") or hit.get("name") or "").strip()
208
+ if not domain or _normalize_name(name) != want:
209
+ continue
210
+ try:
211
+ count = int(hit.get("numberOfReviews") or 0)
212
+ except (TypeError, ValueError):
213
+ count = 0
214
+ matching.append((count, domain))
215
+ if not matching:
216
+ top = next(
217
+ (str(h.get("domain") or "").strip() for h in hits
218
+ if isinstance(h, dict) and h.get("domain")),
219
+ "",
220
+ )
221
+ if top:
222
+ _log(
223
+ f"no name-matching search hit; top candidate was '{top}' - "
224
+ f"pass --trustpilot-domain to target it"
225
+ )
226
+ return None
227
+ matching.sort(reverse=True)
228
+ if len(matching) == 1:
229
+ return matching[0][1]
230
+ top_count, top_domain = matching[0]
231
+ runner_count, runner_domain = matching[1]
232
+ if top_count >= max(1, runner_count) * DOMAIN_DOMINANCE_FACTOR:
233
+ return top_domain
234
+ _log(
235
+ f"ambiguous search hits ('{top_domain}' vs '{runner_domain}'); "
236
+ f"falling back - pass --trustpilot-domain to disambiguate"
237
+ )
238
+ return None
239
+
240
+
241
+ def _search_domain(topic: str) -> Optional[str]:
242
+ """Resolve a company name to its Trustpilot domain via the CLI's search.
243
+
244
+ Cached per normalized topic (thread-safe), so repeat lookups cost one
245
+ subprocess while vs-mode entities still resolve independently. Only
246
+ definitive results are cached: a transient CLI failure (timeout, spawn
247
+ error, malformed JSON) returns None WITHOUT caching, so one flaky search
248
+ does not suppress resolution for this topic for the rest of the process.
249
+ """
250
+ key = _normalize_name(topic)
251
+ if not key:
252
+ return None
253
+ with _domain_cache_lock:
254
+ if key in _domain_cache:
255
+ return _domain_cache[key]
256
+ data = _run_cli(
257
+ [CLI_BIN, "search", topic.strip(), "--limit", "5", "--agent"],
258
+ timeout=SEARCH_TIMEOUT,
259
+ )
260
+ if not isinstance(data, dict) or "error" in data:
261
+ return None # transient failure: retry on the next lookup
262
+ hits = data.get("hits")
263
+ if not isinstance(hits, list):
264
+ # Degenerate payload (e.g. empty stdout parses to {}): not a
265
+ # definitive no-match -- do not cache, retry on the next lookup.
266
+ return None
267
+ domain = _select_search_hit(topic, hits)
268
+ if domain:
269
+ _log(f"resolved '{topic}' -> '{domain}' via search")
270
+ with _domain_cache_lock:
271
+ _domain_cache[key] = domain
272
+ return domain
273
+
274
+
275
+ def _is_session_fresh(status: Dict[str, Any]) -> bool:
276
+ """Read the freshness signal from an ``auth status --agent`` payload."""
277
+ if not isinstance(status, dict) or "error" in status:
278
+ return False
279
+ containers: List[Dict[str, Any]] = [status]
280
+ session = status.get("session")
281
+ if isinstance(session, dict):
282
+ containers.append(session)
283
+ for container in containers:
284
+ for key in ("isFresh", "fresh"):
285
+ if key in container:
286
+ return bool(container[key])
287
+ return False
288
+
289
+
290
+ def ensure_session_ready(
291
+ topic: str,
292
+ config: Optional[Dict[str, Any]] = None,
293
+ has_domain: bool = False,
294
+ ) -> None:
295
+ """Warm the CLI's WAF session, serialized, at the first Trustpilot fetch.
296
+
297
+ Called from ``search_trustpilot`` (never from the pipeline's fan-out
298
+ setup), so it only ever delays the one capped Trustpilot stream, never
299
+ the other sources -- and never fires for runs whose plan fetches no
300
+ Trustpilot at all. The module lock serializes concurrent streams (vs-mode
301
+ fans out up to 6 entity sub-runs) so they never race their own Chrome
302
+ harvests. Freshness is a monotonic TTL (WARMUP_TTL_SECONDS, matching the
303
+ CLI's ~4-minute token bound), not a boolean-forever flag: long-lived host
304
+ processes re-check via ``auth status`` after the window lapses, which
305
+ also retries a previously failed login. ``auth login`` fires only when
306
+ ``auth status`` reports the session missing or stale -- login always
307
+ harvests (~10s Chrome), it has no freshness no-op. Logs only structured
308
+ status strings; the raw CLI payload carries live WAF-token prefixes and
309
+ must never be logged. Never raises.
310
+ """
311
+ global _warmup_at
312
+ if _warmup_fresh():
313
+ return
314
+ if not _is_available():
315
+ return
316
+ if not _harvest_allowed(config):
317
+ return
318
+ if not has_domain and not is_brand_shaped(topic):
319
+ return
320
+ with _warmup_lock:
321
+ if _warmup_fresh():
322
+ return
323
+ status = _run_cli(
324
+ [CLI_BIN, "auth", "status", "--agent"], timeout=AUTH_STATUS_TIMEOUT
325
+ )
326
+ if _is_session_fresh(status):
327
+ _log("warm-up: fresh")
328
+ _warmup_at = time.monotonic()
329
+ return
330
+ # Missing session exits non-zero (an error dict here): that is the
331
+ # "login needed" signal, not a warm-up failure.
332
+ login = _run_cli([CLI_BIN, "auth", "login", "--agent"], timeout=SEARCH_TIMEOUT)
333
+ if isinstance(login, dict) and "error" in login:
334
+ _log("warm-up failed: auth login did not complete")
335
+ else:
336
+ _log("warm-up: harvested")
337
+ # Stamp even on failure: a broken Chrome will not fix itself within
338
+ # the TTL, per-call CLI auto-harvest remains the fallback, and the
339
+ # TTL lapse retries the warm-up later.
340
+ _warmup_at = time.monotonic()
341
+
342
+
135
343
  def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
136
344
  """Invoke trustpilot-pp-cli and parse the JSON object. Never raises."""
137
345
  if not _is_available():
@@ -171,23 +379,66 @@ def search_trustpilot(
171
379
  to_date: str,
172
380
  depth: str = "default",
173
381
  config: Optional[Dict[str, Any]] = None,
382
+ explicit_domain: Optional[str] = None,
383
+ domain_is_hint: bool = False,
174
384
  ) -> Dict[str, Any]:
175
385
  """Look up a company's Trustpilot sentiment, gated on a brand-shaped topic.
176
386
 
387
+ ``explicit_domain`` is used verbatim as the CLI identifier and bypasses
388
+ the brand-shape gate (an explicit domain is proof of brand intent). A
389
+ user-set domain is verbatim-final; a resolved hint
390
+ (``domain_is_hint=True``) retries via the CLI search when the lookup
391
+ misses, since auto-resolve can guess a plausible-but-wrong domain (the
392
+ official site is not always Trustpilot's canonical identifyingName).
393
+
394
+ Without an explicit domain, a bare company name resolves via the CLI's
395
+ ``search`` (cached per topic) before falling back to the cleaned topic.
396
+
177
397
  Returns ``{"results": [info_dict]}`` for a resolved company, or
178
- ``{"results": []}`` when the topic is not brand-shaped, the browser opt-out
179
- is set, or the CLI fails.
398
+ ``{"results": []}`` when the topic is not brand-shaped, the browser
399
+ opt-out is set, or the CLI fails.
180
400
  """
181
- if not is_brand_shaped(topic):
401
+ explicit_domain = (explicit_domain or "").strip() or None
402
+ user_domain = bool(explicit_domain) and not domain_is_hint
403
+ # Only a USER-set domain proves brand intent and bypasses the brand-shape
404
+ # gate. An auto-resolved hint must not widen activation beyond
405
+ # brand-shaped topics, or a generic topic that happens to yield a hint
406
+ # would trigger a Chrome harvest -- violating the module's documented
407
+ # "never harvests on non-company topics" contract.
408
+ if not user_domain and not is_brand_shaped(topic):
182
409
  return {"results": []}
183
410
  if not _is_available():
184
411
  return {"results": [], "error": f"{CLI_BIN} not on PATH"}
185
412
  if not _harvest_allowed(config):
186
413
  _log("skipped: browser opt-out set")
187
414
  return {"results": []}
188
- identifier = _company_identifier(topic)
415
+ # Serialized session check at first source touch (all gates above have
416
+ # passed, so this never fires for topics the source would not fetch).
417
+ ensure_session_ready(topic, config=config, has_domain=bool(explicit_domain))
418
+ # Retry-budget timer starts AFTER the warm-up: a slow Chrome harvest must
419
+ # not consume the hint-retry budget when the info call itself was fast.
420
+ started = time.monotonic()
421
+ if explicit_domain:
422
+ identifier = explicit_domain
423
+ else:
424
+ identifier = _company_identifier(topic)
425
+ if not _DOMAIN_RE.search(topic.lower()):
426
+ # No domain token in the topic: Trustpilot pages are keyed by
427
+ # domain, so resolve name -> domain before the info lookup.
428
+ identifier = _search_domain(topic) or identifier
189
429
  _log(f"info '{identifier}'")
190
430
  data = _run_cli(_build_info_args(identifier), timeout=SEARCH_TIMEOUT)
431
+ if ("error" in data or not data) and explicit_domain and domain_is_hint:
432
+ # The auto-resolved hint missed. Only user-set flags are
433
+ # verbatim-final; a hint falls through to the search resolution.
434
+ # Skip the retry chain when the first lookup already consumed a full
435
+ # single-call budget (hung CLI) -- one stream must not chain three
436
+ # sequential SEARCH_TIMEOUT-bound subprocesses.
437
+ if time.monotonic() - started < SEARCH_TIMEOUT:
438
+ resolved = _search_domain(topic)
439
+ if resolved and resolved != identifier:
440
+ _log(f"hint '{identifier}' missed; retrying via search as '{resolved}'")
441
+ data = _run_cli(_build_info_args(resolved), timeout=SEARCH_TIMEOUT)
191
442
  if "error" in data or not data:
192
443
  return {"results": []}
193
444
  return {"results": [data]}
@@ -89,7 +89,15 @@ def search_truthsocial(
89
89
  try:
90
90
  response = http.request(
91
91
  "GET", url,
92
- headers={"Authorization": f"Bearer {token}"},
92
+ headers={
93
+ "Authorization": f"Bearer {token}",
94
+ # Cloudflare 403s the skill's default User-Agent regardless of token validity (#909).
95
+ # Reuse http.BROWSER_USER_AGENT, as the keyless Reddit path does.
96
+ "User-Agent": http.BROWSER_USER_AGENT,
97
+ "Accept": "application/json, text/plain, */*",
98
+ "Accept-Language": "en-US,en;q=0.9",
99
+ "Referer": "https://truthsocial.com/",
100
+ },
93
101
  timeout=30,
94
102
  )
95
103
  except http.HTTPError as e:
@@ -121,6 +121,7 @@ SOURCE_COMPLETION_ORDER = [
121
121
  "tiktok",
122
122
  "instagram",
123
123
  "hackernews",
124
+ "devto",
124
125
  "bluesky",
125
126
  "truthsocial",
126
127
  "polymarket",
@@ -130,6 +131,8 @@ SOURCE_COMPLETION_ORDER = [
130
131
  "arxiv",
131
132
  "techmeme",
132
133
  "trustpilot",
134
+ "amazon",
135
+ "meta_ads",
133
136
  ]
134
137
 
135
138
  SOURCE_COMPLETION_META = {
@@ -139,6 +142,7 @@ SOURCE_COMPLETION_META = {
139
142
  "tiktok": ("TikTok", "video", "videos", Colors.PURPLE),
140
143
  "instagram": ("Instagram", "reel", "reels", Colors.PURPLE),
141
144
  "hackernews": ("HN", "story", "stories", Colors.YELLOW),
145
+ "devto": ("dev.to", "article", "articles", Colors.GREEN),
142
146
  "bluesky": ("Bluesky", "post", "posts", Colors.BLUE),
143
147
  "truthsocial": ("Truth Social", "post", "posts", Colors.CYAN),
144
148
  "polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
@@ -148,6 +152,8 @@ SOURCE_COMPLETION_META = {
148
152
  "arxiv": ("arXiv", "paper", "papers", Colors.RED),
149
153
  "techmeme": ("Techmeme", "headline", "headlines", Colors.CYAN),
150
154
  "trustpilot": ("Trustpilot", "review", "reviews", Colors.GREEN),
155
+ "amazon": ("Amazon", "product", "products", Colors.YELLOW),
156
+ "meta_ads": ("Meta Ads", "creative", "creatives", Colors.BLUE),
151
157
  }
152
158
 
153
159
 
@@ -0,0 +1,132 @@
1
+ """Provider-reported charges for a scoped watchlist subprocess."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import sqlite3
8
+ import uuid
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ JOURNAL_ENV = "LAST30DAYS_USAGE_JOURNAL"
14
+
15
+ _PAID_HOSTS = {
16
+ "openrouter.ai": "openrouter",
17
+ "api.perplexity.ai": "perplexity",
18
+ "api.openai.com": "openai",
19
+ "api.x.ai": "xai",
20
+ "generativelanguage.googleapis.com": "gemini",
21
+ "api.scrapecreators.com": "scrapecreators",
22
+ "api.search.brave.com": "brave",
23
+ "api.tavily.com": "tavily",
24
+ "api.exa.ai": "exa",
25
+ "google.serper.dev": "serper",
26
+ "api.parallel.ai": "parallel",
27
+ "api.x.com": "x",
28
+ "api.twitter.com": "x",
29
+ "xquik.com": "xquik",
30
+ "api.groq.com": "groq",
31
+ }
32
+
33
+
34
+ @contextmanager
35
+ def _connection(path):
36
+ conn = sqlite3.connect(path)
37
+ try:
38
+ with conn:
39
+ yield conn
40
+ finally:
41
+ conn.close()
42
+
43
+
44
+ def create_journal(path: Path) -> None:
45
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
46
+ os.close(fd)
47
+ with _connection(path) as conn:
48
+ conn.execute(
49
+ "CREATE TABLE attempts (id TEXT PRIMARY KEY, provider TEXT NOT NULL, "
50
+ "cost REAL, unknown INTEGER NOT NULL DEFAULT 1, "
51
+ "prompt_tokens INTEGER DEFAULT 0, completion_tokens INTEGER DEFAULT 0)"
52
+ )
53
+
54
+
55
+ def begin(provider: str) -> tuple[str, str, str] | None:
56
+ path = os.environ.get(JOURNAL_ENV)
57
+ if not path:
58
+ return None
59
+ attempt_id = uuid.uuid4().hex
60
+ with _connection(path) as conn:
61
+ conn.execute("INSERT INTO attempts (id, provider) VALUES (?, ?)", (attempt_id, provider))
62
+ return path, attempt_id, provider
63
+
64
+
65
+ def begin_http(url: str, method: str) -> tuple[str, str, str] | None:
66
+ if not os.environ.get(JOURNAL_ENV):
67
+ return None
68
+ parts = urlsplit(url)
69
+ provider = _PAID_HOSTS.get(parts.hostname)
70
+ if provider == "perplexity" and method == "GET":
71
+ return None # Retrieving an existing background operation does not start another charge.
72
+ if provider is None:
73
+ for name in ("OPENAI", "XAI", "OPENROUTER"):
74
+ override = os.environ.get(f"{name}_BASE_URL")
75
+ if override and urlsplit(override).netloc == parts.netloc:
76
+ provider = name.lower()
77
+ break
78
+ return begin(provider) if provider else None
79
+
80
+
81
+ def cancel(attempt: tuple[str, str, str] | None) -> None:
82
+ """Remove an attempt when the transport provably never started."""
83
+ if attempt is not None:
84
+ path, attempt_id, _ = attempt
85
+ with _connection(path) as conn:
86
+ conn.execute("DELETE FROM attempts WHERE id = ?", (attempt_id,))
87
+
88
+
89
+ def finish(attempt: tuple[str, str, str] | None, payload: object) -> None:
90
+ if attempt is None or not isinstance(payload, dict):
91
+ return
92
+ path, attempt_id, provider = attempt
93
+ usage = payload.get("usage")
94
+ if not isinstance(usage, dict):
95
+ return
96
+ cost = usage.get("cost")
97
+ if provider == "perplexity":
98
+ if not isinstance(cost, dict) or cost.get("currency") != "USD":
99
+ return
100
+ cost = cost.get("total_cost")
101
+ elif provider != "openrouter":
102
+ return
103
+ if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost) or cost < 0:
104
+ return
105
+ # A background response may expose a running subtotal rather than its final bill.
106
+ unknown = int(payload.get("status") in {"queued", "in_progress"})
107
+ prompt = usage.get("prompt_tokens", usage.get("input_tokens", 0))
108
+ completion = usage.get("completion_tokens", usage.get("output_tokens", 0))
109
+ prompt = prompt if type(prompt) is int and prompt >= 0 else 0
110
+ completion = completion if type(completion) is int and completion >= 0 else 0
111
+ with _connection(path) as conn:
112
+ conn.execute(
113
+ "UPDATE attempts SET cost = ?, unknown = ?, prompt_tokens = ?, completion_tokens = ? WHERE id = ?",
114
+ (cost, unknown, prompt, completion, attempt_id),
115
+ )
116
+
117
+
118
+ def read_journal(path: Path) -> dict:
119
+ try:
120
+ with _connection(path) as conn:
121
+ row = conn.execute(
122
+ "SELECT COALESCE(SUM(cost), 0), COALESCE(SUM(unknown), 0), "
123
+ "COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM attempts"
124
+ ).fetchone()
125
+ except sqlite3.Error:
126
+ return {"token_cost": 0.0, "cost_unknown": 1, "prompt_tokens": 0, "completion_tokens": 0}
127
+ return {
128
+ "token_cost": row[0],
129
+ "cost_unknown": int(row[1] > 0),
130
+ "prompt_tokens": row[2],
131
+ "completion_tokens": row[3],
132
+ }
@@ -9,10 +9,12 @@ It must never run on a host that has native search (the model does it better
9
9
  there) or preempt a configured paid backend. The pipeline/grounding layer owns
10
10
  that gating; this module just performs the search when asked.
11
11
 
12
- Two vendor-neutral rungs, both stdlib-only via :mod:`http`:
12
+ Three vendor-neutral rungs, all stdlib-only via :mod:`http`:
13
13
  1. DuckDuckGo HTML endpoint (no key, no instance to maintain).
14
- 2. A configurable SearXNG instance returning JSON (``LAST30DAYS_SEARXNG_URL``),
15
- tried when the primary yields nothing.
14
+ 2. Startpage HTML results, tried when DuckDuckGo yields nothing — notably
15
+ when DuckDuckGo anomaly-blocks a datacenter IP with a 202 challenge page.
16
+ 3. A configurable SearXNG instance returning JSON (``LAST30DAYS_SEARXNG_URL``),
17
+ tried when the HTML rungs yield nothing.
16
18
 
17
19
  Never raises. Returns results in the same dict shape as the paid backends in
18
20
  :mod:`grounding` so they flow through normalize/score/dedupe unchanged. On total
@@ -37,6 +39,10 @@ _DDG_HTML_URL = "https://html.duckduckgo.com/html/"
37
39
  _KEYLESS_RELEVANCE = 0.6
38
40
 
39
41
  _TAG_RE = re.compile(r"<[^>]+>")
42
+ # Strip <style>/<script> blocks *including their contents* before dropping tags,
43
+ # so inline CSS/JS text (e.g. Startpage's emotion styles) never leaks into a
44
+ # title or snippet.
45
+ _STYLE_SCRIPT_RE = re.compile(r"<(style|script)\b[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
40
46
  _RESULT_A_RE = re.compile(
41
47
  r'class="result__a"[^>]*href="(?P<href>[^"]+)"[^>]*>(?P<title>.*?)</a>',
42
48
  re.IGNORECASE | re.DOTALL,
@@ -46,6 +52,21 @@ _SNIPPET_RE = re.compile(
46
52
  re.IGNORECASE | re.DOTALL,
47
53
  )
48
54
 
55
+ _STARTPAGE_HTML_URL = "https://www.startpage.com/sp/search"
56
+ # Startpage marks each organic hit with an <a class="result-title result-link …"
57
+ # href="<target>">…<h2 …>title</h2></a>, and the description in a following
58
+ # <p class="…description…">. Class names carry hashed emotion suffixes, so match
59
+ # on the stable "result-title" / "description" substrings.
60
+ _SP_RESULT_RE = re.compile(
61
+ r'<a\b[^>]*class="[^"]*result-title[^"]*"[^>]*href="(?P<href>https?://[^"]+)"[^>]*>(?P<inner>.*?)</a>',
62
+ re.IGNORECASE | re.DOTALL,
63
+ )
64
+ _SP_H2_RE = re.compile(r"<h2\b[^>]*>(?P<title>.*?)</h2>", re.IGNORECASE | re.DOTALL)
65
+ _SP_DESC_RE = re.compile(
66
+ r'<p\b[^>]*class="[^"]*description[^"]*"[^>]*>(?P<snippet>.*?)</p>',
67
+ re.IGNORECASE | re.DOTALL,
68
+ )
69
+
49
70
 
50
71
  def _domain(url: str) -> str:
51
72
  # Normalize identically to grounding._domain (strip + lowercase) so keyless
@@ -57,7 +78,8 @@ def _domain(url: str) -> str:
57
78
 
58
79
 
59
80
  def _strip_html(fragment: str) -> str:
60
- return html.unescape(_TAG_RE.sub("", fragment or "")).strip()
81
+ without_blocks = _STYLE_SCRIPT_RE.sub("", fragment or "")
82
+ return html.unescape(_TAG_RE.sub("", without_blocks)).strip()
61
83
 
62
84
 
63
85
  def _unwrap_ddg_redirect(href: str) -> str:
@@ -81,6 +103,11 @@ def keyless_search(
81
103
  """Run keyless web search; returns (items, artifact). Never raises."""
82
104
  items = _search_ddg(query, count)
83
105
  used = "ddg"
106
+ if not items:
107
+ # DuckDuckGo anomaly-blocks datacenter IPs (202 challenge page); fall
108
+ # back to Startpage, which still serves organic results there.
109
+ items = _search_startpage(query, count)
110
+ used = "startpage"
84
111
  if not items:
85
112
  searxng_url = (config.get("LAST30DAYS_SEARXNG_URL") or "").strip()
86
113
  if searxng_url:
@@ -123,6 +150,38 @@ def _search_ddg(query: str, count: int) -> list[dict]:
123
150
  return items
124
151
 
125
152
 
153
+ def _search_startpage(query: str, count: int) -> list[dict]:
154
+ """Keyless rung 2: Startpage's HTML results page. Unlike DuckDuckGo's HTML
155
+ endpoint (which anomaly-blocks datacenter IPs with a 202 challenge page),
156
+ Startpage returns organic results to a plain browser-UA GET, making it the
157
+ working floor on hosts DuckDuckGo refuses. Never raises."""
158
+ url = f"{_STARTPAGE_HTML_URL}?{urlencode({'query': query})}"
159
+ text = http.get_text(url, accept="text/html", retries=2)
160
+ if not text:
161
+ return []
162
+ items: list[dict] = []
163
+ result_matches = list(_SP_RESULT_RE.finditer(text))
164
+ desc_matches = list(_SP_DESC_RE.finditer(text))
165
+ for match in result_matches:
166
+ if len(items) >= count:
167
+ break
168
+ target = html.unescape(match.group("href"))
169
+ if not target.startswith("http"):
170
+ continue
171
+ h2 = _SP_H2_RE.search(match.group("inner"))
172
+ title = _strip_html(h2.group("title") if h2 else match.group("inner"))
173
+ if not title:
174
+ continue
175
+ # First description block that appears after this result's title anchor.
176
+ snippet = ""
177
+ for desc in desc_matches:
178
+ if desc.start() > match.end():
179
+ snippet = _strip_html(desc.group("snippet"))
180
+ break
181
+ items.append(_to_item(len(items), title, target, snippet))
182
+ return items
183
+
184
+
126
185
  def _search_searxng(query: str, count: int, instance_url: str) -> list[dict]:
127
186
  base = instance_url.rstrip("/")
128
187
  url = f"{base}/search?{urlencode({'q': query, 'format': 'json'})}"