luckiest-co 1.0.46 → 1.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +8 -0
- package/skills/video-studio/SKILL.md +6 -5
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio/scripts/qa.mjs +64 -2
- package/skills/video-studio/scripts/qa.test.mjs +29 -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/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -12,8 +12,8 @@ import sys
|
|
|
12
12
|
import time
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
from . import http, log, subproc
|
|
16
|
-
from datetime import datetime
|
|
15
|
+
from . import env, health, http, log, subproc
|
|
16
|
+
from datetime import date, datetime, timedelta
|
|
17
17
|
from typing import Any, Dict, List, Optional, Tuple
|
|
18
18
|
|
|
19
19
|
from .relevance import token_overlap_relevance as _compute_relevance
|
|
@@ -54,6 +54,29 @@ DEPTH_CONFIG = {
|
|
|
54
54
|
# Module-level credentials injected from .env config
|
|
55
55
|
_credentials: Dict[str, str] = {}
|
|
56
56
|
|
|
57
|
+
# The vendored bird-search client reads exactly this env surface, and the
|
|
58
|
+
# node subprocess it spawns needs the node-runtime env (platform, locale,
|
|
59
|
+
# TLS/proxy config) to run in every environment it runs in today. Ambient
|
|
60
|
+
# BIRD_* vars pass through as well. Everything else in os.environ - unrelated
|
|
61
|
+
# API keys, tokens, .env contents - must not reach the scan-excluded vendored
|
|
62
|
+
# client (issue #1063). Mirrors the platform-var surface grok_x keeps for its
|
|
63
|
+
# node child (grok_x._subprocess_env).
|
|
64
|
+
_SUBPROCESS_ENV_ALLOWLIST = (
|
|
65
|
+
# Runtime / platform vars the node subprocess needs (mirrors grok_x)
|
|
66
|
+
"PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SystemRoot",
|
|
67
|
+
"USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SystemDrive", "COMSPEC",
|
|
68
|
+
"PATHEXT", "TEMP", "TMP",
|
|
69
|
+
# Node TLS / proxy / CA config for custom-CA and proxied environments
|
|
70
|
+
"NODE_ENV", "NODE_OPTIONS", "NODE_EXTRA_CA_CERTS",
|
|
71
|
+
"NODE_TLS_REJECT_UNAUTHORIZED", "NODE_USE_ENV_PROXY",
|
|
72
|
+
"SSL_CERT_FILE", "SSL_CERT_DIR", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
73
|
+
"http_proxy", "https_proxy", "no_proxy",
|
|
74
|
+
# X session cookies the vendored client reads from the environment
|
|
75
|
+
"AUTH_TOKEN", "CT0", "TWITTER_AUTH_TOKEN", "TWITTER_CT0",
|
|
76
|
+
# Browser-cookie disable flag the client reads (cookies.js envFlagEnabled)
|
|
77
|
+
"LAST30DAYS_DISABLE_BROWSER_COOKIES",
|
|
78
|
+
)
|
|
79
|
+
|
|
57
80
|
|
|
58
81
|
def set_credentials(auth_token: Optional[str], ct0: Optional[str]):
|
|
59
82
|
"""Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them."""
|
|
@@ -70,12 +93,26 @@ def _has_injected_credentials() -> bool:
|
|
|
70
93
|
|
|
71
94
|
def _has_process_credentials() -> bool:
|
|
72
95
|
"""Return True when AUTH_TOKEN/CT0 are present in process env."""
|
|
73
|
-
return bool(
|
|
96
|
+
return bool(env.read_secret_env("AUTH_TOKEN") and env.read_secret_env("CT0"))
|
|
74
97
|
|
|
75
98
|
|
|
76
99
|
def _subprocess_env() -> Dict[str, str]:
|
|
77
|
-
"""Build env dict for Node subprocesses, merging injected credentials.
|
|
78
|
-
|
|
100
|
+
"""Build env dict for Node subprocesses, merging injected credentials.
|
|
101
|
+
|
|
102
|
+
The child env is limited to the vendored client's env surface (see
|
|
103
|
+
``_SUBPROCESS_ENV_ALLOWLIST``) plus injected credentials, so unrelated
|
|
104
|
+
ambient secrets never reach scan-excluded vendored code (issue #1063).
|
|
105
|
+
The ambient-credential lane behaves exactly as before.
|
|
106
|
+
"""
|
|
107
|
+
env = {
|
|
108
|
+
name: os.environ[name]
|
|
109
|
+
for name in _SUBPROCESS_ENV_ALLOWLIST
|
|
110
|
+
if name in os.environ
|
|
111
|
+
}
|
|
112
|
+
env.update({
|
|
113
|
+
key: value for key, value in os.environ.items()
|
|
114
|
+
if key.startswith("BIRD_")
|
|
115
|
+
})
|
|
79
116
|
env.update(_credentials)
|
|
80
117
|
# Hard-disable browser-cookie fallback so normal pipeline runs never hit
|
|
81
118
|
# Safari/Chrome Keychain prompts during source detection or search.
|
|
@@ -87,6 +124,38 @@ def _log(msg: str):
|
|
|
87
124
|
log.source_log("Bird", msg, tty_only=False)
|
|
88
125
|
|
|
89
126
|
|
|
127
|
+
def _scrub_credentials(text: str) -> str:
|
|
128
|
+
"""Redact X session cookie values from subprocess output.
|
|
129
|
+
|
|
130
|
+
A failure reason built from bird-search's stderr reaches the run's
|
|
131
|
+
``source_status`` detail, which is rendered in the report and returned by
|
|
132
|
+
``--emit=json``. The vendored client receives AUTH_TOKEN/CT0 in its
|
|
133
|
+
environment, so an error message that echoes a rejected cookie would
|
|
134
|
+
otherwise carry it into user-facing output. Log lines get the same
|
|
135
|
+
treatment because stderr is captured in agent harnesses.
|
|
136
|
+
"""
|
|
137
|
+
scrubbed = text
|
|
138
|
+
for name in ("AUTH_TOKEN", "CT0", "TWITTER_AUTH_TOKEN", "TWITTER_CT0"):
|
|
139
|
+
value = _credentials.get(name) or os.environ.get(name)
|
|
140
|
+
# Short values would match too much ordinary text to be worth it.
|
|
141
|
+
if value and len(value) >= 8:
|
|
142
|
+
scrubbed = scrubbed.replace(value, "<redacted>")
|
|
143
|
+
return scrubbed
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def classify_run_failure(detail: str) -> str:
|
|
147
|
+
"""Map Bird's subprocess-only failure shapes to run outcome states."""
|
|
148
|
+
text = detail.lower()
|
|
149
|
+
if any(marker in text for marker in ("interstitial", "non-json", "invalid json")):
|
|
150
|
+
return health.SCHEMA_DRIFT
|
|
151
|
+
if any(
|
|
152
|
+
marker in text
|
|
153
|
+
for marker in ("cookie expired", "expired cookie", "unauthorized", "forbidden", "login required")
|
|
154
|
+
):
|
|
155
|
+
return health.AUTH_FAILED
|
|
156
|
+
return http.classify_failure(message=detail)
|
|
157
|
+
|
|
158
|
+
|
|
90
159
|
def _extract_core_subject(topic: str) -> str:
|
|
91
160
|
"""Extract core subject from verbose query for X search.
|
|
92
161
|
|
|
@@ -98,6 +167,68 @@ def _extract_core_subject(topic: str) -> str:
|
|
|
98
167
|
return extract_core_subject(topic, max_words=5, strip_suffixes=True)
|
|
99
168
|
|
|
100
169
|
|
|
170
|
+
def _plain_query_tokens(text: str) -> list[str]:
|
|
171
|
+
"""Return lexical tokens without Bird query grouping syntax.
|
|
172
|
+
|
|
173
|
+
Strips phrase quotes as well as grouping characters. Used where a flat
|
|
174
|
+
token list is wanted; use ``build_topic_query`` for the provider query,
|
|
175
|
+
which preserves quoted phrases.
|
|
176
|
+
"""
|
|
177
|
+
separators = str.maketrans({char: " " for char in '\"“”()[]{}'})
|
|
178
|
+
return [
|
|
179
|
+
clean
|
|
180
|
+
for token in text.translate(separators).split()
|
|
181
|
+
if (clean := token.strip("'‘’"))
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Bird/X grouping syntax that carries no lexical meaning. Double quotes are
|
|
186
|
+
# deliberately absent: X advanced search treats "..." as a phrase match, which
|
|
187
|
+
# is exactly what the planner intended when it quoted a proper noun.
|
|
188
|
+
_GROUPING_CHARS = "“”()[]{}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _date_filters(from_date: str, to_date: Optional[str] = None) -> str:
|
|
192
|
+
filters = f"since:{from_date}"
|
|
193
|
+
if to_date is not None:
|
|
194
|
+
end = date.fromisoformat(to_date)
|
|
195
|
+
if end == date.max:
|
|
196
|
+
# No supported post date lies beyond this inclusive upper bound.
|
|
197
|
+
return filters
|
|
198
|
+
# The research window includes to_date; X's until bound is exclusive.
|
|
199
|
+
until = end + timedelta(days=1)
|
|
200
|
+
filters += f" until:{until.isoformat()}"
|
|
201
|
+
return filters
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_topic_query(
|
|
205
|
+
topic: str, from_date: str, to_date: Optional[str] = None
|
|
206
|
+
) -> str:
|
|
207
|
+
"""Build the X topic query, preserving quoted proper-noun phrases.
|
|
208
|
+
|
|
209
|
+
Previously the topic went through ``_plain_query_tokens``, which stripped
|
|
210
|
+
the quotes the planner had added, so an intended phrase match for
|
|
211
|
+
'"Peter Steinberger"' degraded into `peter AND steinberger` -- narrower and
|
|
212
|
+
noisier at once. X supports phrase queries natively, so the quotes are
|
|
213
|
+
passed through.
|
|
214
|
+
"""
|
|
215
|
+
separators = str.maketrans({char: " " for char in _GROUPING_CHARS})
|
|
216
|
+
cleaned = topic.translate(separators)
|
|
217
|
+
# An unbalanced quote is worse than no quote: X reads the orphan as an
|
|
218
|
+
# unterminated phrase and matches nothing. Upstream trimming (core-subject
|
|
219
|
+
# extraction, retry shortening) can cut a topic mid-phrase, so verify the
|
|
220
|
+
# quotes pair up and fall back to bare tokens when they do not.
|
|
221
|
+
if cleaned.count('"') % 2:
|
|
222
|
+
cleaned = cleaned.replace('"', " ")
|
|
223
|
+
tokens = [
|
|
224
|
+
clean
|
|
225
|
+
for token in cleaned.split()
|
|
226
|
+
if (clean := token.strip("'‘’"))
|
|
227
|
+
]
|
|
228
|
+
core = " ".join(tokens).strip()
|
|
229
|
+
return " ".join(part for part in (core, _date_filters(from_date, to_date)) if part)
|
|
230
|
+
|
|
231
|
+
|
|
101
232
|
def is_bird_installed() -> bool:
|
|
102
233
|
"""Check if vendored Bird search module is available.
|
|
103
234
|
|
|
@@ -214,39 +345,43 @@ def _invoke_bird_subprocess(query: str, count: int, timeout: int):
|
|
|
214
345
|
"--json",
|
|
215
346
|
]
|
|
216
347
|
|
|
217
|
-
pid_holder: list[int] = []
|
|
218
|
-
|
|
219
|
-
def _register(pid: int) -> None:
|
|
220
|
-
pid_holder.append(pid)
|
|
221
|
-
try:
|
|
222
|
-
from last30days import register_child_pid
|
|
223
|
-
register_child_pid(pid)
|
|
224
|
-
except ImportError:
|
|
225
|
-
pass
|
|
226
|
-
|
|
227
348
|
try:
|
|
228
349
|
result = subproc.run_with_timeout(
|
|
229
350
|
cmd,
|
|
230
351
|
timeout=timeout,
|
|
231
352
|
env=_subprocess_env(),
|
|
232
|
-
on_pid=_register,
|
|
233
353
|
)
|
|
234
354
|
except subproc.SubprocTimeout:
|
|
235
355
|
return None, {"error": f"Search timed out after {timeout}s", "items": []}
|
|
236
356
|
except Exception as e:
|
|
237
357
|
return None, {"error": str(e), "items": []}
|
|
238
|
-
finally:
|
|
239
|
-
if pid_holder:
|
|
240
|
-
try:
|
|
241
|
-
from last30days import unregister_child_pid
|
|
242
|
-
unregister_child_pid(pid_holder[0])
|
|
243
|
-
except Exception:
|
|
244
|
-
pass
|
|
245
358
|
|
|
246
359
|
return result, None
|
|
247
360
|
|
|
248
361
|
|
|
249
|
-
def
|
|
362
|
+
def _invalid_json_error(
|
|
363
|
+
attempts: int,
|
|
364
|
+
decode_error: Optional[str],
|
|
365
|
+
budget_exhausted: bool = False,
|
|
366
|
+
) -> Dict[str, Any]:
|
|
367
|
+
noun = "attempt" if attempts == 1 else "attempts"
|
|
368
|
+
skipped = ", retry skipped: chain budget exhausted" if budget_exhausted else ""
|
|
369
|
+
return {
|
|
370
|
+
"error": (
|
|
371
|
+
f"Invalid JSON response after {attempts} {noun}{skipped} "
|
|
372
|
+
f"(likely Twitter anti-bot interstitial): {decode_error}"
|
|
373
|
+
),
|
|
374
|
+
"items": [],
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _run_bird_search(
|
|
379
|
+
query: str,
|
|
380
|
+
count: int,
|
|
381
|
+
timeout: int,
|
|
382
|
+
deadline: Optional[float] = None,
|
|
383
|
+
cancel: Any = None,
|
|
384
|
+
) -> Dict[str, Any]:
|
|
250
385
|
"""Run a search using the vendored bird-search.mjs module.
|
|
251
386
|
|
|
252
387
|
Retries the subprocess on JSON-decode failure (typically a Twitter
|
|
@@ -259,22 +394,42 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
|
|
259
394
|
query: Full search query string (including since: filter)
|
|
260
395
|
count: Number of results to request
|
|
261
396
|
timeout: Timeout in seconds (per attempt)
|
|
397
|
+
deadline: Optional shared ``time.monotonic()`` deadline. A decode
|
|
398
|
+
retry runs only when the delay plus at least one second of
|
|
399
|
+
subprocess time still fits; its timeout is re-clamped after the
|
|
400
|
+
delay.
|
|
262
401
|
|
|
263
402
|
Returns:
|
|
264
403
|
Raw Bird JSON response or error dict.
|
|
265
404
|
"""
|
|
266
405
|
last_decode_error: Optional[str] = None
|
|
406
|
+
attempt_timeout: Optional[int] = timeout
|
|
267
407
|
|
|
268
408
|
for attempt in range(MAX_JSON_DECODE_RETRIES):
|
|
269
|
-
|
|
409
|
+
if cancel is not None and cancel.is_set():
|
|
410
|
+
return {"error": "bird: research cancelled", "items": []}
|
|
411
|
+
if attempt > 0:
|
|
412
|
+
attempt_timeout = _clamped_bird_timeout(timeout, deadline)
|
|
413
|
+
if attempt_timeout is None:
|
|
414
|
+
return _invalid_json_error(
|
|
415
|
+
attempt, last_decode_error, budget_exhausted=True,
|
|
416
|
+
)
|
|
417
|
+
result, terminal_error = _invoke_bird_subprocess(query, count, attempt_timeout)
|
|
270
418
|
if terminal_error is not None:
|
|
271
419
|
return terminal_error
|
|
272
420
|
|
|
421
|
+
output = result.stdout.strip()
|
|
273
422
|
if result.returncode != 0:
|
|
274
|
-
|
|
275
|
-
|
|
423
|
+
if not output:
|
|
424
|
+
error = result.stderr.strip() or "Bird search failed"
|
|
425
|
+
return {"error": error, "items": []}
|
|
426
|
+
# Windows/Node 24: the vendored Bird CLI uses native fetch (undici),
|
|
427
|
+
# and calling process.exit() while keep-alive sockets are still
|
|
428
|
+
# closing trips a libuv assertion -> non-zero exit code AFTER it has
|
|
429
|
+
# already written a complete, valid JSON result to stdout. Trust
|
|
430
|
+
# stdout when it has content; only treat a non-zero exit as a real
|
|
431
|
+
# failure when stdout is empty.
|
|
276
432
|
|
|
277
|
-
output = result.stdout.strip()
|
|
278
433
|
if not output:
|
|
279
434
|
return {"items": []}
|
|
280
435
|
|
|
@@ -293,21 +448,29 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
|
|
293
448
|
)
|
|
294
449
|
last_decode_error = str(e)
|
|
295
450
|
if attempt_num < MAX_JSON_DECODE_RETRIES:
|
|
451
|
+
if deadline is not None and (
|
|
452
|
+
deadline - time.monotonic() < JSON_DECODE_RETRY_DELAY + 1
|
|
453
|
+
):
|
|
454
|
+
log.source_log(
|
|
455
|
+
"X/bird",
|
|
456
|
+
f"{log_msg}; retry skipped, chain budget exhausted",
|
|
457
|
+
tty_only=False,
|
|
458
|
+
)
|
|
459
|
+
return _invalid_json_error(
|
|
460
|
+
attempt_num, last_decode_error, budget_exhausted=True,
|
|
461
|
+
)
|
|
296
462
|
log.source_log(
|
|
297
463
|
"X/bird",
|
|
298
464
|
f"{log_msg}; retrying in {JSON_DECODE_RETRY_DELAY:.0f}s",
|
|
299
465
|
tty_only=False,
|
|
300
466
|
)
|
|
301
|
-
|
|
467
|
+
if cancel is not None:
|
|
468
|
+
cancel.wait(JSON_DECODE_RETRY_DELAY)
|
|
469
|
+
else:
|
|
470
|
+
time.sleep(JSON_DECODE_RETRY_DELAY)
|
|
302
471
|
continue
|
|
303
472
|
log.source_log("X/bird", log_msg, tty_only=False)
|
|
304
|
-
return
|
|
305
|
-
"error": (
|
|
306
|
-
f"Invalid JSON response after {MAX_JSON_DECODE_RETRIES} attempts "
|
|
307
|
-
f"(likely Twitter anti-bot interstitial): {e}"
|
|
308
|
-
),
|
|
309
|
-
"items": [],
|
|
310
|
-
}
|
|
473
|
+
return _invalid_json_error(attempt_num, last_decode_error)
|
|
311
474
|
|
|
312
475
|
if isinstance(parsed, list):
|
|
313
476
|
return {"items": parsed}
|
|
@@ -320,38 +483,84 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
|
|
320
483
|
}
|
|
321
484
|
|
|
322
485
|
|
|
486
|
+
def _clamped_bird_timeout(base: int, deadline: Optional[float]) -> Optional[int]:
|
|
487
|
+
"""Per-attempt timeout clamped to the chain's remaining budget.
|
|
488
|
+
|
|
489
|
+
Returns None when the deadline already passed so the caller skips the
|
|
490
|
+
subprocess instead of starting a call guaranteed to overrun the chain.
|
|
491
|
+
"""
|
|
492
|
+
if deadline is None:
|
|
493
|
+
return base
|
|
494
|
+
remaining = deadline - time.monotonic()
|
|
495
|
+
if remaining < 1:
|
|
496
|
+
return None
|
|
497
|
+
return max(1, min(base, int(remaining)))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _budget_stop(
|
|
501
|
+
response: Dict[str, Any],
|
|
502
|
+
last_clean_response: Optional[Dict[str, Any]],
|
|
503
|
+
) -> Dict[str, Any]:
|
|
504
|
+
"""Outcome when an optional zero-result retry no longer fits the budget.
|
|
505
|
+
|
|
506
|
+
A clean empty response stays a no-results outcome with a coverage warning;
|
|
507
|
+
otherwise the earlier search's own error (interstitial, timeout) is more
|
|
508
|
+
diagnostic than a generic budget message.
|
|
509
|
+
"""
|
|
510
|
+
_log("chain budget exhausted; skipping remaining zero-result retries")
|
|
511
|
+
if last_clean_response is not None:
|
|
512
|
+
return {
|
|
513
|
+
**last_clean_response,
|
|
514
|
+
"warning": "Partial coverage: optional zero-result retries skipped because the search budget was exhausted.",
|
|
515
|
+
}
|
|
516
|
+
return response
|
|
517
|
+
|
|
518
|
+
|
|
323
519
|
def search_x(
|
|
324
520
|
topic: str,
|
|
325
521
|
from_date: str,
|
|
326
522
|
to_date: str,
|
|
327
523
|
depth: str = "default",
|
|
524
|
+
deadline: Optional[float] = None,
|
|
525
|
+
cancel: Any = None,
|
|
328
526
|
) -> Dict[str, Any]:
|
|
329
527
|
"""Search X using Bird CLI with automatic retry on 0 results.
|
|
330
528
|
|
|
331
529
|
Args:
|
|
332
530
|
topic: Search topic
|
|
333
531
|
from_date: Start date (YYYY-MM-DD)
|
|
334
|
-
to_date:
|
|
532
|
+
to_date: Inclusive end date (YYYY-MM-DD)
|
|
335
533
|
depth: Research depth - "quick", "default", or "deep"
|
|
534
|
+
deadline: Optional shared wall-clock deadline (``time.monotonic()``
|
|
535
|
+
instant) from the X backend chain. Each of the up-to-four
|
|
536
|
+
sequential searches clamps its per-attempt timeout to the time
|
|
537
|
+
left; searches past the deadline never start.
|
|
336
538
|
|
|
337
539
|
Returns:
|
|
338
540
|
Raw Bird JSON response or error dict.
|
|
339
541
|
"""
|
|
340
542
|
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
|
341
|
-
|
|
543
|
+
cancel_kwargs = {"cancel": cancel} if cancel is not None else {}
|
|
544
|
+
base_timeout = 30 if depth == "quick" else 45 if depth == "default" else 60
|
|
545
|
+
timeout = _clamped_bird_timeout(base_timeout, deadline)
|
|
546
|
+
if timeout is None:
|
|
547
|
+
return {"error": "bird: chain budget exhausted", "items": []}
|
|
342
548
|
|
|
343
549
|
# Extract core subject - X search is literal, not semantic
|
|
344
|
-
|
|
345
|
-
|
|
550
|
+
core_subject = _extract_core_subject(topic)
|
|
551
|
+
core_words = _plain_query_tokens(core_subject)
|
|
552
|
+
core_topic = " ".join(core_words)
|
|
553
|
+
query = build_topic_query(core_subject, from_date, to_date)
|
|
554
|
+
date_filters = _date_filters(from_date, to_date)
|
|
346
555
|
|
|
347
556
|
_log(f"Searching: {query}")
|
|
348
|
-
response = _run_bird_search(query, count, timeout)
|
|
557
|
+
response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
|
|
558
|
+
last_clean_response = response if not response.get("error") else None
|
|
349
559
|
|
|
350
560
|
# Check if we got results
|
|
351
561
|
items = parse_bird_response(response, query=core_topic)
|
|
352
562
|
|
|
353
563
|
# Retry with OR groups for multi-word queries (X supports OR operator)
|
|
354
|
-
core_words = core_topic.split()
|
|
355
564
|
if not items and len(core_words) >= 2:
|
|
356
565
|
from .query import extract_compound_terms
|
|
357
566
|
compounds = extract_compound_terms(topic)
|
|
@@ -359,16 +568,26 @@ def search_x(
|
|
|
359
568
|
# Build OR-group query: ("multi-agent" OR "agent simulation") since:DATE
|
|
360
569
|
or_parts = ' OR '.join(f'"{t}"' for t in compounds[:3])
|
|
361
570
|
_log(f"0 results for '{core_topic}', retrying with OR groups: {or_parts}")
|
|
362
|
-
query = f"({or_parts})
|
|
363
|
-
|
|
571
|
+
query = f"({or_parts}) {date_filters}"
|
|
572
|
+
timeout = _clamped_bird_timeout(base_timeout, deadline)
|
|
573
|
+
if timeout is None:
|
|
574
|
+
return _budget_stop(response, last_clean_response)
|
|
575
|
+
response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
|
|
576
|
+
if not response.get("error"):
|
|
577
|
+
last_clean_response = response
|
|
364
578
|
items = parse_bird_response(response, query=core_topic)
|
|
365
579
|
|
|
366
580
|
# Retry with fewer keywords if still 0 results and query has 3+ words
|
|
367
581
|
if not items and len(core_words) > 2:
|
|
368
582
|
shorter = ' '.join(core_words[:2])
|
|
369
583
|
_log(f"0 results for '{core_topic}', retrying with '{shorter}'")
|
|
370
|
-
query = f"{shorter}
|
|
371
|
-
|
|
584
|
+
query = f"{shorter} {date_filters}"
|
|
585
|
+
timeout = _clamped_bird_timeout(base_timeout, deadline)
|
|
586
|
+
if timeout is None:
|
|
587
|
+
return _budget_stop(response, last_clean_response)
|
|
588
|
+
response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
|
|
589
|
+
if not response.get("error"):
|
|
590
|
+
last_clean_response = response
|
|
372
591
|
items = parse_bird_response(response, query=core_topic)
|
|
373
592
|
|
|
374
593
|
# Last-chance retry: use strongest remaining token (often the product name)
|
|
@@ -390,9 +609,19 @@ def search_x(
|
|
|
390
609
|
strongest = max(candidates, key=len)
|
|
391
610
|
retry_terms = anchor if strongest == anchor else f"{anchor} {strongest}"
|
|
392
611
|
_log(f"0 results for '{core_topic}', retrying anchored on '{retry_terms}'")
|
|
393
|
-
query = f"{retry_terms}
|
|
394
|
-
|
|
395
|
-
|
|
612
|
+
query = f"{retry_terms} {date_filters}"
|
|
613
|
+
timeout = _clamped_bird_timeout(base_timeout, deadline)
|
|
614
|
+
if timeout is None:
|
|
615
|
+
return _budget_stop(response, last_clean_response)
|
|
616
|
+
response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
|
|
617
|
+
if not response.get("error"):
|
|
618
|
+
last_clean_response = response
|
|
619
|
+
|
|
620
|
+
if cancel is not None and cancel.is_set():
|
|
621
|
+
return {**response, "error": "bird: research cancelled"}
|
|
622
|
+
if response.get("error") and last_clean_response is not None:
|
|
623
|
+
_log("Optional retry failed after a clean empty response; preserving no-results outcome")
|
|
624
|
+
return last_clean_response
|
|
396
625
|
return response
|
|
397
626
|
|
|
398
627
|
|
|
@@ -401,6 +630,11 @@ def search_handles(
|
|
|
401
630
|
topic: Optional[str],
|
|
402
631
|
from_date: str,
|
|
403
632
|
count_per: int = 5,
|
|
633
|
+
failure_out: Optional[List[str]] = None,
|
|
634
|
+
*,
|
|
635
|
+
to_date: Optional[str] = None,
|
|
636
|
+
deadline: Optional[float] = None,
|
|
637
|
+
cancel: Any = None,
|
|
404
638
|
) -> List[Dict[str, Any]]:
|
|
405
639
|
"""Search specific X handles for topic-related content.
|
|
406
640
|
|
|
@@ -415,16 +649,29 @@ def search_handles(
|
|
|
415
649
|
topic: Search topic — used for relevance ranking only, not the query
|
|
416
650
|
from_date: Start date (YYYY-MM-DD)
|
|
417
651
|
count_per: Results to request per handle
|
|
652
|
+
failure_out: When provided, a short reason is appended for every
|
|
653
|
+
per-handle failure branch, so the caller can distinguish a
|
|
654
|
+
transport failure from a handle that genuinely posted nothing.
|
|
655
|
+
to_date: Inclusive end date (YYYY-MM-DD), when supplied
|
|
418
656
|
|
|
419
657
|
Returns:
|
|
420
658
|
List of raw item dicts (same format as parse_bird_response output).
|
|
421
659
|
"""
|
|
422
660
|
core_topic = _extract_core_subject(topic) if topic else None
|
|
661
|
+
date_filters = _date_filters(from_date, to_date)
|
|
662
|
+
|
|
663
|
+
def _note(msg: str) -> None:
|
|
664
|
+
if failure_out is not None:
|
|
665
|
+
failure_out.append(msg)
|
|
423
666
|
|
|
424
667
|
def _search_one_handle(handle: str) -> List[Dict[str, Any]]:
|
|
668
|
+
timeout = _clamped_bird_timeout(15, deadline)
|
|
669
|
+
if timeout is None or (cancel is not None and cancel.is_set()):
|
|
670
|
+
_note("bird handle research cancelled or timed out")
|
|
671
|
+
return []
|
|
425
672
|
handle = handle.lstrip("@")
|
|
426
673
|
# Always unfiltered: pull the timeline, rank by topic relevance below.
|
|
427
|
-
query = f"from:{handle}
|
|
674
|
+
query = f"from:{handle} {date_filters}"
|
|
428
675
|
|
|
429
676
|
cmd = [
|
|
430
677
|
"node", str(_BIRD_SEARCH_MJS),
|
|
@@ -434,19 +681,28 @@ def search_handles(
|
|
|
434
681
|
]
|
|
435
682
|
|
|
436
683
|
try:
|
|
437
|
-
result = subproc.run_with_timeout(cmd, timeout=
|
|
684
|
+
result = subproc.run_with_timeout(cmd, timeout=timeout, env=_subprocess_env())
|
|
438
685
|
except subproc.SubprocTimeout:
|
|
439
686
|
_log(f"Handle search timed out for @{handle}")
|
|
687
|
+
_note(f"@{handle}: bird-search timed out after 15s")
|
|
440
688
|
return []
|
|
441
689
|
except OSError as e:
|
|
442
690
|
_log(f"Handle search error for @{handle}: {e}")
|
|
691
|
+
_note(f"@{handle}: could not spawn bird-search ({e})")
|
|
443
692
|
return []
|
|
444
693
|
|
|
694
|
+
output = result.stdout.strip()
|
|
445
695
|
if result.returncode != 0:
|
|
446
|
-
|
|
447
|
-
|
|
696
|
+
if not output:
|
|
697
|
+
_log(f"Handle search failed for @{handle}: {_scrub_credentials(result.stderr.strip())}")
|
|
698
|
+
_note(
|
|
699
|
+
f"@{handle}: bird-search exited {result.returncode} "
|
|
700
|
+
f"({_scrub_credentials(result.stderr.strip())[:160] or 'no stderr'})"
|
|
701
|
+
)
|
|
702
|
+
return []
|
|
703
|
+
# Windows/Node 24: benign libuv assertion can cause non-zero exit
|
|
704
|
+
# AFTER valid JSON is written to stdout. Trust stdout content.
|
|
448
705
|
|
|
449
|
-
output = result.stdout.strip()
|
|
450
706
|
if not output:
|
|
451
707
|
return []
|
|
452
708
|
|
|
@@ -454,6 +710,7 @@ def search_handles(
|
|
|
454
710
|
response = json.loads(output)
|
|
455
711
|
except json.JSONDecodeError:
|
|
456
712
|
_log(f"Invalid JSON from handle search for @{handle}")
|
|
713
|
+
_note(f"@{handle}: bird-search returned invalid JSON")
|
|
457
714
|
return []
|
|
458
715
|
items = parse_bird_response(response, query=core_topic)
|
|
459
716
|
# Log on success/empty too (not only on failure): a silent handle search
|
|
@@ -476,6 +733,11 @@ def search_mentions(
|
|
|
476
733
|
handles: List[str],
|
|
477
734
|
from_date: str,
|
|
478
735
|
count_per: int = 5,
|
|
736
|
+
failure_out: Optional[List[str]] = None,
|
|
737
|
+
*,
|
|
738
|
+
to_date: Optional[str] = None,
|
|
739
|
+
deadline: Optional[float] = None,
|
|
740
|
+
cancel: Any = None,
|
|
479
741
|
) -> List[Dict[str, Any]]:
|
|
480
742
|
"""Search for tweets ABOUT/TO each handle — the mention lane.
|
|
481
743
|
|
|
@@ -488,13 +750,27 @@ def search_mentions(
|
|
|
488
750
|
handles: List of X handles (without @)
|
|
489
751
|
from_date: Start date (YYYY-MM-DD)
|
|
490
752
|
count_per: Results to request per handle
|
|
753
|
+
failure_out: When provided, a short reason is appended for every
|
|
754
|
+
per-handle failure branch, so the caller can distinguish a
|
|
755
|
+
transport failure from a handle nobody mentioned.
|
|
756
|
+
to_date: Inclusive end date (YYYY-MM-DD), when supplied
|
|
491
757
|
|
|
492
758
|
Returns:
|
|
493
759
|
List of raw item dicts (same format as parse_bird_response output).
|
|
494
760
|
"""
|
|
761
|
+
date_filters = _date_filters(from_date, to_date)
|
|
762
|
+
|
|
763
|
+
def _note(msg: str) -> None:
|
|
764
|
+
if failure_out is not None:
|
|
765
|
+
failure_out.append(msg)
|
|
766
|
+
|
|
495
767
|
def _search_one(handle: str) -> List[Dict[str, Any]]:
|
|
768
|
+
timeout = _clamped_bird_timeout(15, deadline)
|
|
769
|
+
if timeout is None or (cancel is not None and cancel.is_set()):
|
|
770
|
+
_note("bird mention research cancelled or timed out")
|
|
771
|
+
return []
|
|
496
772
|
handle = handle.lstrip("@")
|
|
497
|
-
query = f"@{handle}
|
|
773
|
+
query = f"@{handle} {date_filters}"
|
|
498
774
|
cmd = [
|
|
499
775
|
"node", str(_BIRD_SEARCH_MJS),
|
|
500
776
|
query,
|
|
@@ -502,15 +778,21 @@ def search_mentions(
|
|
|
502
778
|
"--json",
|
|
503
779
|
]
|
|
504
780
|
try:
|
|
505
|
-
result = subproc.run_with_timeout(cmd, timeout=
|
|
781
|
+
result = subproc.run_with_timeout(cmd, timeout=timeout, env=_subprocess_env())
|
|
506
782
|
except subproc.SubprocTimeout:
|
|
507
783
|
_log(f"Mention search timed out for @{handle}")
|
|
784
|
+
_note(f"@{handle}: bird-search timed out after 15s")
|
|
508
785
|
return []
|
|
509
786
|
except OSError as e:
|
|
510
787
|
_log(f"Mention search error for @{handle}: {e}")
|
|
788
|
+
_note(f"@{handle}: could not spawn bird-search ({e})")
|
|
511
789
|
return []
|
|
512
790
|
if result.returncode != 0:
|
|
513
|
-
_log(f"Mention search failed for @{handle}: {result.stderr.strip()}")
|
|
791
|
+
_log(f"Mention search failed for @{handle}: {_scrub_credentials(result.stderr.strip())}")
|
|
792
|
+
_note(
|
|
793
|
+
f"@{handle}: bird-search exited {result.returncode} "
|
|
794
|
+
f"({_scrub_credentials(result.stderr.strip())[:160] or 'no stderr'})"
|
|
795
|
+
)
|
|
514
796
|
return []
|
|
515
797
|
output = result.stdout.strip()
|
|
516
798
|
if not output:
|
|
@@ -519,6 +801,7 @@ def search_mentions(
|
|
|
519
801
|
response = json.loads(output)
|
|
520
802
|
except json.JSONDecodeError:
|
|
521
803
|
_log(f"Invalid JSON from mention search for @{handle}")
|
|
804
|
+
_note(f"@{handle}: bird-search returned invalid JSON")
|
|
522
805
|
return []
|
|
523
806
|
items = parse_bird_response(response, query=None)
|
|
524
807
|
# ABOUT lane = OTHERS mentioning the handle. Drop the handle's own tweets
|