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
@@ -23,6 +23,7 @@ from typing import Any, Dict, List, Optional
23
23
  from . import http, log
24
24
 
25
25
  BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"
26
+ BSKY_REFRESH_URL = "https://bsky.social/xrpc/com.atproto.server.refreshSession"
26
27
  _DEFAULT_BSKY_SEARCH_HOST = "api.bsky.app"
27
28
 
28
29
 
@@ -90,8 +91,10 @@ DEPTH_CONFIG = {
90
91
 
91
92
  # Module-level token cache (valid for the lifetime of a single research run)
92
93
  _cached_token: Optional[str] = None
94
+ _cached_refresh_token: Optional[str] = None
93
95
  _token_created_at: float = 0.0
94
96
  _session_error: Optional[str] = None
97
+ _refresh_error_status: Optional[int] = None
95
98
  _TOKEN_MAX_AGE_SECONDS = 5400 # 90 minutes (conservative, tokens last ~2 hours)
96
99
 
97
100
 
@@ -109,7 +112,7 @@ def _create_session(handle: str, app_password: str) -> Optional[str]:
109
112
  Returns:
110
113
  Access JWT string, or None on failure. Sets _session_error on failure.
111
114
  """
112
- global _cached_token, _token_created_at, _session_error
115
+ global _cached_token, _cached_refresh_token, _token_created_at, _session_error
113
116
  if _cached_token and (time.monotonic() - _token_created_at < _TOKEN_MAX_AGE_SECONDS):
114
117
  return _cached_token
115
118
  if _cached_token:
@@ -127,6 +130,7 @@ def _create_session(handle: str, app_password: str) -> Optional[str]:
127
130
  token = response.get("accessJwt")
128
131
  if token:
129
132
  _cached_token = token
133
+ _cached_refresh_token = response.get("refreshJwt")
130
134
  _token_created_at = time.monotonic()
131
135
  _session_error = None
132
136
  _log("Session created successfully")
@@ -149,11 +153,61 @@ def _create_session(handle: str, app_password: str) -> Optional[str]:
149
153
  return None
150
154
 
151
155
 
156
+ def _refresh_session(refresh_jwt: Optional[str]) -> Optional[str]:
157
+ """Refresh an access token using the cached AT Protocol refresh token.
158
+
159
+ Returns the new access token on success. ``_refresh_error_status`` records
160
+ an HTTP status so the caller can distinguish an invalid refresh token
161
+ (which should fall back to createSession) from a transient failure.
162
+ """
163
+ global _cached_token, _cached_refresh_token, _token_created_at
164
+ global _session_error, _refresh_error_status
165
+
166
+ _refresh_error_status = None
167
+ if not refresh_jwt:
168
+ _session_error = "No Bluesky refresh token is available"
169
+ return None
170
+
171
+ try:
172
+ response = http.request(
173
+ "POST",
174
+ BSKY_REFRESH_URL,
175
+ headers={"Authorization": f"Bearer {refresh_jwt}"},
176
+ timeout=15,
177
+ retries=0,
178
+ )
179
+ token = response.get("accessJwt")
180
+ if not token:
181
+ _session_error = "No accessJwt in refresh session response"
182
+ return None
183
+ _cached_token = token
184
+ _cached_refresh_token = response.get("refreshJwt") or refresh_jwt
185
+ _token_created_at = time.monotonic()
186
+ _session_error = None
187
+ return token
188
+ except http.HTTPError as e:
189
+ _refresh_error_status = e.status_code
190
+ _session_error = (
191
+ "Bluesky refresh token is invalid (401/400)"
192
+ if e.status_code in (400, 401)
193
+ else f"Bluesky session refresh failed (HTTP {e.status_code})"
194
+ )
195
+ _log(f"Session refresh failed: {_session_error}")
196
+ return None
197
+ except Exception as e:
198
+ _session_error = f"Bluesky session refresh failed: {type(e).__name__}"
199
+ _log(f"Session refresh failed: {_session_error}")
200
+ return None
201
+
202
+
152
203
  def _reset_session_cache() -> None:
153
- global _cached_token, _token_created_at, _session_error
204
+ global _cached_token, _cached_refresh_token, _token_created_at
205
+ global _session_error, _refresh_error_status
154
206
  _cached_token = None
207
+ _cached_refresh_token = None
155
208
  _token_created_at = 0.0
156
209
  _session_error = None
210
+ _refresh_error_status = None
157
211
 
158
212
 
159
213
  def _extract_core_subject(topic: str) -> str:
@@ -231,11 +285,7 @@ def search_bluesky(
231
285
  }
232
286
  url = f"{_resolve_search_url(config)}?{urlencode(params)}"
233
287
 
234
- def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]:
235
- token = _create_session(handle, app_password)
236
- if not token:
237
- error_msg = _session_error or "Bluesky session creation failed (unknown error)"
238
- return None, error_msg
288
+ def _search_with_token(token: str) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
239
289
  try:
240
290
  response = http.request(
241
291
  "GET", url,
@@ -246,7 +296,6 @@ def search_bluesky(
246
296
  except http.HTTPError as e:
247
297
  _log(f"Search failed: {e}")
248
298
  if e.status_code == 401:
249
- _reset_session_cache()
250
299
  return None, "refresh"
251
300
  if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
252
301
  return None, "Bluesky search blocked by Cloudflare (403). This is a network-level block - try a different network or VPN."
@@ -255,10 +304,35 @@ def search_bluesky(
255
304
  _log(f"Search failed: {e}")
256
305
  return None, f"Bluesky search failed: {type(e).__name__}: {e}"
257
306
 
307
+ def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]:
308
+ token = _create_session(handle, app_password)
309
+ if not token:
310
+ error_msg = _session_error or "Bluesky session creation failed (unknown error)"
311
+ return None, error_msg
312
+ return _search_with_token(token)
313
+
258
314
  response, error_msg = _auth_and_search()
259
315
  if error_msg == "refresh":
260
- _log("Session expired; recreating token and retrying once")
261
- response, error_msg = _auth_and_search()
316
+ _log("Session expired; refreshing access token and retrying once")
317
+ refreshed_token = _refresh_session(_cached_refresh_token)
318
+ if refreshed_token:
319
+ response, error_msg = _search_with_token(refreshed_token)
320
+ elif not _cached_refresh_token or _refresh_error_status in (400, 401):
321
+ _log("Refresh token missing or rejected; recreating session once")
322
+ _reset_session_cache()
323
+ response, error_msg = _auth_and_search()
324
+ if error_msg == "refresh":
325
+ error_msg = (
326
+ "Bluesky session remained unauthorized after refresh and re-authentication; "
327
+ "check credentials or app password"
328
+ )
329
+ else:
330
+ response, error_msg = None, _session_error
331
+ if error_msg == "refresh":
332
+ error_msg = (
333
+ "Bluesky session remained unauthorized after token refresh; "
334
+ "check credentials or app password"
335
+ )
262
336
  if error_msg:
263
337
  return {"posts": [], "error": error_msg}
264
338
  if response is None:
@@ -0,0 +1,156 @@
1
+ """Owned GET transport for operation deadlines, including DNS and body reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from email.message import Message
7
+ import io
8
+ import json
9
+ import socket
10
+ import ssl
11
+ import sys
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ if __package__ in (None, ""):
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
+
21
+ from lib import subproc
22
+
23
+ # Reserve TERM and KILL/reap time inside the caller's operation budget.
24
+ CLEANUP_GRACE_SECONDS = 0.1
25
+
26
+
27
+ class GetTimeout(TimeoutError):
28
+ def __init__(self, message, *, started):
29
+ super().__init__(message)
30
+ self.started = started
31
+
32
+
33
+ class GetLaunchError(OSError):
34
+ """GET worker launch failed before the worker received a request."""
35
+
36
+
37
+ def _error_record(exc):
38
+ if isinstance(exc, urllib.error.URLError):
39
+ reason = exc.reason
40
+ return {"kind": "URLError", "reason": _error_record(reason) if isinstance(reason, BaseException) else str(reason)}
41
+ return {"kind": type(exc).__name__, "message": str(exc), "errno": getattr(exc, "errno", None)}
42
+
43
+
44
+ def _error_from_record(record):
45
+ if record["kind"] == "URLError":
46
+ reason = record["reason"]
47
+ return urllib.error.URLError(_error_from_record(reason) if isinstance(reason, dict) else reason)
48
+ kinds = {
49
+ "gaierror": socket.gaierror,
50
+ "TimeoutError": TimeoutError,
51
+ "ConnectionResetError": ConnectionResetError,
52
+ "ConnectionRefusedError": ConnectionRefusedError,
53
+ "OSError": OSError,
54
+ "SSLError": ssl.SSLError,
55
+ "SSLCertVerificationError": ssl.SSLCertVerificationError,
56
+ }
57
+ kind = kinds.get(record["kind"], OSError)
58
+ if record.get("errno") is not None:
59
+ return kind(record["errno"], record["message"])
60
+ return kind(record["message"])
61
+
62
+
63
+ def get(
64
+ req: urllib.request.Request,
65
+ *,
66
+ timeout: float,
67
+ deadline_monotonic: float,
68
+ cancel: threading.Event | None = None,
69
+ read_body: bool = True,
70
+ read_error_body: bool = True,
71
+ ):
72
+ if req.get_method() != "GET":
73
+ raise ValueError("bounded transport supports GET only")
74
+ payload = {
75
+ "url": req.full_url,
76
+ "headers": req.headers,
77
+ "unredirected_headers": req.unredirected_hdrs,
78
+ "origin_req_host": req.origin_req_host,
79
+ "timeout": timeout,
80
+ "read_body": read_body,
81
+ "read_error_body": read_error_body,
82
+ }
83
+ transport_deadline = deadline_monotonic - 2 * CLEANUP_GRACE_SECONDS
84
+ try:
85
+ result = subproc.run_with_timeout(
86
+ [sys.executable, "-I", str(Path(__file__).resolve())],
87
+ timeout=max(0, transport_deadline - time.monotonic()),
88
+ deadline_monotonic=transport_deadline,
89
+ input_text=json.dumps(payload),
90
+ cancel=cancel,
91
+ cleanup_grace=CLEANUP_GRACE_SECONDS,
92
+ )
93
+ except subproc.SubprocTimeout as exc:
94
+ raise GetTimeout("GET exceeded operation deadline or was cancelled", started=exc.started) from exc
95
+ except OSError as exc:
96
+ if not getattr(exc, "_last30days_subproc_launch_failed", False):
97
+ raise
98
+ raise GetLaunchError(*exc.args) from exc
99
+ if result.returncode != 0:
100
+ raise OSError("bounded GET worker failed")
101
+ record = json.loads(result.stdout)
102
+ if "error" in record:
103
+ raise _error_from_record(record["error"])
104
+ body = base64.b64decode(record["body"]) if record["body"] is not None else None
105
+ error = None
106
+ if record.get("http_error") is not None:
107
+ headers = Message()
108
+ for key, value in record["headers"]:
109
+ headers[key] = value
110
+ error = urllib.error.HTTPError(
111
+ record["url"], record["status"], record["http_error"],
112
+ headers, io.BytesIO(body or b""),
113
+ )
114
+ return record["status"], body, error
115
+
116
+
117
+ def _main():
118
+ from lib import http
119
+
120
+ payload = json.load(sys.stdin)
121
+ req = urllib.request.Request(
122
+ payload["url"], headers=payload["headers"], method="GET",
123
+ origin_req_host=payload["origin_req_host"],
124
+ )
125
+ for key, value in payload["unredirected_headers"].items():
126
+ req.add_unredirected_header(key, value)
127
+ error_reason = None
128
+ try:
129
+ try:
130
+ response = http.open_request(req, payload["timeout"])
131
+ except urllib.error.HTTPError as exc:
132
+ response = exc
133
+ error_reason = str(exc.reason)
134
+ with response:
135
+ body = None
136
+ read_body = payload["read_error_body"] if error_reason is not None else payload["read_body"]
137
+ if read_body:
138
+ try:
139
+ body = response.read()
140
+ except OSError:
141
+ if error_reason is None:
142
+ raise
143
+ result = {
144
+ "status": getattr(response, "status", 200) or 200,
145
+ "body": base64.b64encode(body).decode("ascii") if body is not None else None,
146
+ "headers": list(response.headers.items()),
147
+ "url": response.geturl(),
148
+ "http_error": error_reason,
149
+ }
150
+ except Exception as exc:
151
+ result = {"error": _error_record(exc)}
152
+ json.dump(result, sys.stdout)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ _main()
@@ -0,0 +1,291 @@
1
+ """Bright Data CLI adapter for last30days.
2
+
3
+ Shells out to the ``brightdata`` CLI (``@brightdata/cli``) to run Bright
4
+ Data Pipelines. The CLI owns authentication end to end -- ``brightdata
5
+ login`` does a gh-style zero-click browser flow and stores credentials in
6
+ a platform config directory -- so this module never handles a login, and
7
+ never reads credential *contents*: the auth probe is presence-only.
8
+
9
+ Activation gate: two-way, mirroring the digg CLI-gated precedent but with
10
+ an auth dimension the digg source does not have.
11
+
12
+ 1. ``shutil.which("brightdata")`` must resolve on the **agent subprocess
13
+ PATH** (not merely exist on disk -- Hermes/OpenClaw gateways often drop
14
+ ``~/.local/bin``).
15
+ 2. A credential signal must be present: either ``BRIGHTDATA_API_KEY``
16
+ resolved through the normal config layering, or the CLI's own
17
+ credentials file in the platform config dir.
18
+
19
+ The second check is deliberately offline. A stale token passes it and
20
+ then 401s fast at call time; that path degrades to empty results with the
21
+ CLI's own error line preserved in the envelope, which is the AE2 contract.
22
+
23
+ Metering note (R13): no pricing logic lives here. One pipeline request
24
+ costs one credit against the account's monthly free tier regardless of how
25
+ many records come back, so caps in the calling adapter bound *records*
26
+ (paid-tier cost), not credits. Credit and auth warnings from the CLI are
27
+ passed through verbatim rather than interpreted.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import shutil
35
+ import sys
36
+ from pathlib import Path
37
+ from typing import Any, Dict, List, Optional, Sequence
38
+
39
+ from . import log, subproc, usage
40
+
41
+
42
+ CLI_BIN = "brightdata"
43
+
44
+ # Env var carrying an explicit API key. Registered in env.py so `.env` file
45
+ # and keychain users pass the gate the same way process-env users do; when
46
+ # it resolves from a non-process-env layer we pass it in the child environment.
47
+ API_KEY_ENV = "BRIGHTDATA_API_KEY"
48
+
49
+ # Credentials filename written by `brightdata login`. Probed for existence
50
+ # only -- never opened, parsed, or logged.
51
+ _CREDENTIALS_FILENAME = "credentials.json"
52
+ _CONFIG_DIRNAME = "brightdata-cli"
53
+
54
+ # The CLI's own polling timeout sits below our subprocess timeout so the CLI
55
+ # exits cleanly with its own error rather than being SIGTERM'd mid-poll. Its
56
+ # timeout path throws with zero records (verified in its polling module --
57
+ # never partial output), so a timed-out pull is a clean parseable failure.
58
+ _CLI_TIMEOUT_MARGIN = 10
59
+
60
+
61
+ def _log(msg: str) -> None:
62
+ log.source_log("BrightData", msg, tty_only=False)
63
+
64
+
65
+ def _config_dir() -> Path:
66
+ """Platform config directory the Bright Data CLI stores credentials in.
67
+
68
+ Mirrors the CLI's own credentials module: APPDATA on Windows, the
69
+ Application Support tree on macOS, XDG_CONFIG_HOME (or ~/.config) on
70
+ everything else.
71
+ """
72
+ if sys.platform == "win32":
73
+ base = os.environ.get("APPDATA")
74
+ root = Path(base) if base else Path.home() / "AppData" / "Roaming"
75
+ elif sys.platform == "darwin":
76
+ root = Path.home() / "Library" / "Application Support"
77
+ else:
78
+ base = os.environ.get("XDG_CONFIG_HOME")
79
+ root = Path(base) if base else Path.home() / ".config"
80
+ return root / _CONFIG_DIRNAME
81
+
82
+
83
+ def is_installed() -> bool:
84
+ """True when the brightdata binary resolves on the agent subprocess PATH."""
85
+ return shutil.which(CLI_BIN) is not None
86
+
87
+
88
+ def _api_key(config: Optional[Dict[str, Any]]) -> str:
89
+ if not config:
90
+ return ""
91
+ return str(config.get(API_KEY_ENV) or "").strip()
92
+
93
+
94
+ def has_credentials(config: Optional[Dict[str, Any]] = None) -> bool:
95
+ """True when some credential signal exists, without reading any secret.
96
+
97
+ Presence-only by design: an explicit API key resolved through config
98
+ layering, or the existence of the CLI's credentials file. The file is
99
+ never opened. This cannot distinguish a live token from an expired one
100
+ -- that is what the fast 401 at call time is for.
101
+ """
102
+ if _api_key(config):
103
+ return True
104
+ try:
105
+ return (_config_dir() / _CREDENTIALS_FILENAME).exists()
106
+ except OSError:
107
+ return False
108
+
109
+
110
+ def is_available(config: Optional[Dict[str, Any]] = None) -> bool:
111
+ """The full activation gate: binary on PATH *and* a credential signal."""
112
+ return is_installed() and has_credentials(config)
113
+
114
+
115
+ def gate_status(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
116
+ """Two-field probe for ``pipeline.diagnose`` (bird_installed precedent).
117
+
118
+ Network-free, so it is safe on the ``--diagnose`` / doctor path.
119
+ """
120
+ installed = is_installed()
121
+ return {
122
+ "brightdata_installed": installed,
123
+ "brightdata_authenticated": installed and has_credentials(config),
124
+ }
125
+
126
+
127
+ def _build_args(
128
+ pipeline_type: str,
129
+ params: Sequence[str],
130
+ *,
131
+ cli_timeout: int,
132
+ ) -> List[str]:
133
+ """Assemble the CLI invocation.
134
+
135
+ The API key is deliberately **absent** here -- it travels in the child's
136
+ environment instead (see ``_child_env``). Process arguments are not a
137
+ secret channel: ``/proc/<pid>/cmdline`` is world-readable under the
138
+ default ``hidepid=0``, and a review pull lives for up to 180s, so a key
139
+ on the command line is readable by any other local user and is captured
140
+ verbatim by execve auditing, process accounting, and any monitoring
141
+ agent that snapshots ``ps``. Mirrors the ``bird_x`` cookie-injection
142
+ precedent.
143
+
144
+ Positional params are fenced behind ``--`` so a keyword that happens to
145
+ begin with a dash is parsed as a search term rather than as an option.
146
+ """
147
+ return [
148
+ CLI_BIN,
149
+ "pipelines",
150
+ pipeline_type,
151
+ "--json",
152
+ "--timeout",
153
+ str(cli_timeout),
154
+ "--",
155
+ *(str(p) for p in params),
156
+ ]
157
+
158
+
159
+ def _child_env(api_key: str) -> Optional[Dict[str, str]]:
160
+ """Environment for the child process, carrying the key when we have one.
161
+
162
+ Returns None when there is nothing to inject, so the child simply
163
+ inherits the parent environment (the common case: the CLI owns its own
164
+ credentials file, or the key is already exported).
165
+ """
166
+ if not api_key:
167
+ return None
168
+ return {**os.environ, API_KEY_ENV: api_key}
169
+
170
+
171
+ def _scrub(text: str, secret: str) -> str:
172
+ """Remove a secret from text before it is logged or returned.
173
+
174
+ Defense in depth for the passthrough paths: the stderr lines this
175
+ module deliberately surfaces are auth and quota failures, which are
176
+ exactly the messages a CLI is most likely to echo the rejected
177
+ credential back in.
178
+ """
179
+ if not secret or not text:
180
+ return text
181
+ return text.replace(secret, "***")
182
+
183
+
184
+ def _extract_records(payload: Any) -> List[Dict[str, Any]]:
185
+ """Pull the record list out of a parsed CLI payload.
186
+
187
+ Verified live (2026-08-13): both amazon pipelines return a **bare JSON
188
+ array** of flat record dicts, not the ``{"results": [...]}`` envelope the
189
+ digg CLI uses. The dict branches below are defensive against CLI churn,
190
+ which is a live risk on a package this young.
191
+ """
192
+ if isinstance(payload, list):
193
+ return [r for r in payload if isinstance(r, dict)]
194
+ if isinstance(payload, dict):
195
+ for key in ("records", "results", "data"):
196
+ value = payload.get(key)
197
+ if isinstance(value, list):
198
+ return [r for r in value if isinstance(r, dict)]
199
+ return []
200
+
201
+
202
+ def run_pipeline(
203
+ pipeline_type: str,
204
+ params: Sequence[str],
205
+ *,
206
+ timeout: int,
207
+ config: Optional[Dict[str, Any]] = None,
208
+ ) -> Dict[str, Any]:
209
+ """Run one Bright Data pipeline and return ``{"records", "error"}``.
210
+
211
+ Never raises. Every failure mode -- missing binary, spawn failure,
212
+ subprocess timeout, non-zero exit, unparseable stdout -- returns empty
213
+ records plus a one-line ``error`` string, so callers can record the
214
+ failure in ``errors_by_source`` without branching on exception types.
215
+
216
+ The CLI's first stderr line is preserved verbatim as the error (auth
217
+ 401s and low-credit warnings are the cases that matter), and also
218
+ mirrored to ``source_log`` so the failure is visible in non-TTY hosts.
219
+
220
+ Args:
221
+ pipeline_type: pipeline name, e.g. ``amazon_product_search``.
222
+ params: positional pipeline params, passed through in order.
223
+ timeout: subprocess timeout in seconds. The CLI's own polling
224
+ timeout is set just below this so it can fail cleanly first.
225
+ config: resolved config dict, consulted only for the API key.
226
+
227
+ Returns:
228
+ ``{"records": [...]}`` on success, else ``{"records": [], "error": str}``.
229
+ """
230
+ if not is_installed():
231
+ return {"records": [], "error": f"{CLI_BIN} not on PATH"}
232
+
233
+ cli_timeout = max(5, int(timeout) - _CLI_TIMEOUT_MARGIN)
234
+ key = _api_key(config)
235
+ cmd = _build_args(pipeline_type, params, cli_timeout=cli_timeout)
236
+
237
+ try:
238
+ usage.begin("brightdata")
239
+ result = subproc.run_with_timeout(cmd, timeout=timeout, env=_child_env(key))
240
+ except subproc.SubprocTimeout as exc:
241
+ _log(f"Timeout: {exc}")
242
+ return {"records": [], "error": str(exc)}
243
+ except FileNotFoundError as exc:
244
+ _log(f"Binary missing: {exc}")
245
+ return {"records": [], "error": str(exc)}
246
+ except OSError as exc:
247
+ _log(f"Spawn failed: {exc}")
248
+ return {"records": [], "error": str(exc)}
249
+
250
+ stderr = _scrub(result.stderr or "", key)
251
+ _passthrough_warnings(stderr)
252
+
253
+ if result.returncode != 0:
254
+ lines = [ln.strip() for ln in stderr.strip().splitlines() if ln.strip()]
255
+ # The CLI narrates polling progress on stderr, so the *last* line is
256
+ # the actual failure; the first line is "Triggering pipeline...".
257
+ first = lines[-1] if lines else f"exit {result.returncode}"
258
+ _log(f"CLI exit {result.returncode}: {first}")
259
+ return {"records": [], "error": first}
260
+
261
+ stdout = result.stdout or ""
262
+ if not stdout.strip():
263
+ return {"records": []}
264
+ try:
265
+ payload = json.loads(stdout)
266
+ except json.JSONDecodeError as exc:
267
+ _log(f"JSON decode failed: {exc}")
268
+ return {"records": [], "error": f"json decode: {exc}"}
269
+
270
+ return {"records": _extract_records(payload)}
271
+
272
+
273
+ # Substrings that mark a stderr line worth surfacing even on a successful
274
+ # run -- credit exhaustion and auth trouble are the two the user must see.
275
+ # Matched case-insensitively against the CLI's own wording, and echoed
276
+ # verbatim rather than reworded (R13: no pricing logic, no interpretation).
277
+ _WARNING_MARKERS = ("credit", "quota", "balance", "unauthor", "401", "expired", "login")
278
+
279
+
280
+ def _passthrough_warnings(stderr: str) -> None:
281
+ """Echo credit/auth warning lines from the CLI verbatim.
282
+
283
+ Skips the routine polling narration so a normal run stays quiet.
284
+ """
285
+ for line in (stderr or "").splitlines():
286
+ text = line.strip()
287
+ if not text or text.lower().startswith(("status:", "triggering", "triggered", "data received")):
288
+ continue
289
+ lowered = text.lower()
290
+ if any(marker in lowered for marker in _WARNING_MARKERS):
291
+ _log(text)
@@ -23,6 +23,7 @@ before `ai_chat_model` so "gpt image 2" matches the image-gen category.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import re
26
27
  from typing import List, Optional, TypedDict
27
28
 
28
29
 
@@ -126,6 +127,8 @@ CATEGORY_PEERS: dict[str, _CategoryEntry] = {
126
127
  },
127
128
  "ai_agent_framework": {
128
129
  "patterns": [
130
+ "ai agent",
131
+ "ai agents",
129
132
  "agent framework",
130
133
  "agentic framework",
131
134
  "langchain",
@@ -264,7 +267,10 @@ def detect_category(topic: Optional[str]) -> Optional[str]:
264
267
  lowered = topic.lower()
265
268
  for category_id, entry in CATEGORY_PEERS.items():
266
269
  for pattern in entry["patterns"]:
267
- if pattern in lowered:
270
+ # Word-boundary match: "ai agent" must not fire on "Dubai agents"
271
+ # or "Thai agents". Substring matching classified those as
272
+ # ai_agent_framework and routed discovery to LangChain subreddits.
273
+ if re.search(rf"(?<![a-z0-9]){re.escape(pattern)}(?![a-z0-9])", lowered):
268
274
  return category_id
269
275
  return None
270
276