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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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,
|
|
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
|
|
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;
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|