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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""X corpus judging for retrieve-judge-retry.
|
|
2
|
+
|
|
3
|
+
No I/O: judges items already retrieved. Reuses relevance.token_overlap_relevance.
|
|
4
|
+
|
|
5
|
+
The judge detects off-topic floods and determines which extracted handles should
|
|
6
|
+
be promoted to the FROM lane based on on-topic post ratio, not frequency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
11
|
+
|
|
12
|
+
from . import relevance
|
|
13
|
+
|
|
14
|
+
# Minimum on-topic ratio for the overall corpus. Below this, the engine should
|
|
15
|
+
# retry with a wider keyword query. Rome measured ~0.2 (8/40 on-topic).
|
|
16
|
+
CORPUS_ON_TOPIC_FLOOR = 0.4
|
|
17
|
+
|
|
18
|
+
# Minimum on-topic ratio for a handle's posts to qualify for FROM promotion.
|
|
19
|
+
HANDLE_ON_TOPIC_FLOOR = 0.5
|
|
20
|
+
|
|
21
|
+
# Minimum on-topic keyword hits before a handle can be promoted to FROM lane.
|
|
22
|
+
# Prevents promoting handles that appeared in thin phrase hits with only 1 match.
|
|
23
|
+
MIN_ON_TOPIC_HITS = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Ambiguous short tokens that require case-sensitive matching to avoid
|
|
27
|
+
# pronoun/acronym collisions. E.g., "US" (country) vs "us" (pronoun).
|
|
28
|
+
# For these tokens, require the text to contain the uppercase form (acronym)
|
|
29
|
+
# rather than just the lowercase form (common word).
|
|
30
|
+
_CASE_SENSITIVE_ACRONYMS = frozenset({'us'})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _compute_relevance(query: str, text: str) -> float:
|
|
34
|
+
"""Compute relevance score for a post against the topic query.
|
|
35
|
+
|
|
36
|
+
Returns 0.0 for empty/stopword-only queries to avoid treating all items
|
|
37
|
+
as equally relevant (the shared relevance module returns 0.5 for empty
|
|
38
|
+
queries as a neutral fallback, but x_judge needs strict filtering).
|
|
39
|
+
|
|
40
|
+
Uses case-sensitive matching for ambiguous short tokens like 'us' to
|
|
41
|
+
distinguish country acronym 'US' from pronoun 'us'.
|
|
42
|
+
"""
|
|
43
|
+
import re
|
|
44
|
+
if not query or not text:
|
|
45
|
+
return 0.0
|
|
46
|
+
|
|
47
|
+
q_tokens = relevance.tokenize(query)
|
|
48
|
+
if not q_tokens:
|
|
49
|
+
return 0.0 # All query tokens were stopwords
|
|
50
|
+
|
|
51
|
+
# Check for ambiguous acronyms that need case-sensitive handling
|
|
52
|
+
t_tokens = relevance.tokenize(text)
|
|
53
|
+
filtered_q_tokens = set(q_tokens)
|
|
54
|
+
for acronym in _CASE_SENSITIVE_ACRONYMS:
|
|
55
|
+
if acronym in q_tokens and acronym in t_tokens:
|
|
56
|
+
# Text has the token, but we need to check if it's the acronym (US)
|
|
57
|
+
# or the common word (us). If text only has lowercase, don't count it.
|
|
58
|
+
has_uppercase = bool(re.search(rf'\b{acronym.upper()}\b', text))
|
|
59
|
+
has_lowercase = bool(re.search(rf'\b{acronym}\b', text))
|
|
60
|
+
if has_lowercase and not has_uppercase:
|
|
61
|
+
# Text only has lowercase version (pronoun) - don't count this
|
|
62
|
+
# token in query overlap. This effectively removes "us" from
|
|
63
|
+
# contributing to the score when text has only the pronoun.
|
|
64
|
+
t_tokens = t_tokens - {acronym}
|
|
65
|
+
# Also remove from query for this calculation to avoid
|
|
66
|
+
# penalizing the overall coverage ratio
|
|
67
|
+
filtered_q_tokens = filtered_q_tokens - {acronym}
|
|
68
|
+
|
|
69
|
+
# If filtering removed all query tokens, fall back to 0
|
|
70
|
+
if not filtered_q_tokens:
|
|
71
|
+
return 0.0
|
|
72
|
+
|
|
73
|
+
overlap_tokens = filtered_q_tokens & t_tokens
|
|
74
|
+
if not overlap_tokens:
|
|
75
|
+
return 0.0
|
|
76
|
+
|
|
77
|
+
# Compute simplified relevance: coverage ratio
|
|
78
|
+
# This is a simpler version of token_overlap_relevance that uses
|
|
79
|
+
# the filtered tokens rather than re-tokenizing the original text
|
|
80
|
+
coverage = len(overlap_tokens) / len(filtered_q_tokens)
|
|
81
|
+
return coverage
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def judge_x_corpus(
|
|
85
|
+
items: List[Dict[str, Any]],
|
|
86
|
+
topic: str,
|
|
87
|
+
*,
|
|
88
|
+
ranking_query: str = "",
|
|
89
|
+
) -> Dict[str, Any]:
|
|
90
|
+
"""Judge the retrieved X corpus for on-topic ratio.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
items: List of X items with 'author_handle' and 'text' fields
|
|
94
|
+
topic: The user topic (e.g., "Rome")
|
|
95
|
+
ranking_query: Optional ranking query for better relevance scoring
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Dict with:
|
|
99
|
+
- on_topic_ratio: float, fraction of posts that are on-topic
|
|
100
|
+
- is_off_topic_flood: bool, True if corpus fails the on-topic floor
|
|
101
|
+
- on_topic_items: list, items that passed relevance check
|
|
102
|
+
- off_topic_items: list, items that failed relevance check
|
|
103
|
+
- handle_stats: dict, per-handle on-topic counts and totals
|
|
104
|
+
"""
|
|
105
|
+
if not items:
|
|
106
|
+
return {
|
|
107
|
+
"on_topic_ratio": 1.0,
|
|
108
|
+
"is_off_topic_flood": False,
|
|
109
|
+
"on_topic_items": [],
|
|
110
|
+
"off_topic_items": [],
|
|
111
|
+
"handle_stats": {},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Use ranking_query if provided, otherwise topic
|
|
115
|
+
query = ranking_query or topic
|
|
116
|
+
|
|
117
|
+
on_topic_items = []
|
|
118
|
+
off_topic_items = []
|
|
119
|
+
handle_stats: Dict[str, Dict[str, int]] = {}
|
|
120
|
+
|
|
121
|
+
for item in items:
|
|
122
|
+
handle = (item.get("author_handle") or "").lower()
|
|
123
|
+
text = item.get("text") or ""
|
|
124
|
+
score = _compute_relevance(query, text)
|
|
125
|
+
|
|
126
|
+
# On-topic threshold: relevance.RELEVANCE_FLOOR is 0.1
|
|
127
|
+
is_on_topic = score >= relevance.RELEVANCE_FLOOR
|
|
128
|
+
|
|
129
|
+
if is_on_topic:
|
|
130
|
+
on_topic_items.append(item)
|
|
131
|
+
else:
|
|
132
|
+
off_topic_items.append(item)
|
|
133
|
+
|
|
134
|
+
if handle:
|
|
135
|
+
if handle not in handle_stats:
|
|
136
|
+
handle_stats[handle] = {"on_topic": 0, "total": 0}
|
|
137
|
+
handle_stats[handle]["total"] += 1
|
|
138
|
+
if is_on_topic:
|
|
139
|
+
handle_stats[handle]["on_topic"] += 1
|
|
140
|
+
|
|
141
|
+
on_topic_ratio = len(on_topic_items) / len(items) if items else 1.0
|
|
142
|
+
|
|
143
|
+
# Check if top-3 frequency authors have poor on-topic ratio
|
|
144
|
+
top_handles = sorted(
|
|
145
|
+
handle_stats.items(),
|
|
146
|
+
key=lambda x: x[1]["total"],
|
|
147
|
+
reverse=True,
|
|
148
|
+
)[:3]
|
|
149
|
+
top_authors_off_topic = all(
|
|
150
|
+
stats["on_topic"] / stats["total"] < HANDLE_ON_TOPIC_FLOOR
|
|
151
|
+
for _, stats in top_handles
|
|
152
|
+
if stats["total"] > 0
|
|
153
|
+
) if top_handles else False
|
|
154
|
+
|
|
155
|
+
is_off_topic_flood = (
|
|
156
|
+
on_topic_ratio < CORPUS_ON_TOPIC_FLOOR
|
|
157
|
+
or (top_authors_off_topic and len(on_topic_items) < MIN_ON_TOPIC_HITS)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"on_topic_ratio": on_topic_ratio,
|
|
162
|
+
"is_off_topic_flood": is_off_topic_flood,
|
|
163
|
+
"on_topic_items": on_topic_items,
|
|
164
|
+
"off_topic_items": off_topic_items,
|
|
165
|
+
"handle_stats": handle_stats,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def promotable_handles(
|
|
170
|
+
items: List[Dict[str, Any]],
|
|
171
|
+
topic: str,
|
|
172
|
+
extracted_handles: List[str],
|
|
173
|
+
*,
|
|
174
|
+
explicit_handles: Optional[List[str]] = None,
|
|
175
|
+
ranking_query: str = "",
|
|
176
|
+
) -> Tuple[List[str], List[str]]:
|
|
177
|
+
"""Determine which handles should be promoted to the FROM lane.
|
|
178
|
+
|
|
179
|
+
Split FROM promotion:
|
|
180
|
+
- Explicit handles (--x-handle/--x-related): always promoted, no AND topic
|
|
181
|
+
- Extracted handles: promoted only if:
|
|
182
|
+
- ≥MIN_ON_TOPIC_HITS on-topic keyword hits AND
|
|
183
|
+
- author on-topic ratio ≥ HANDLE_ON_TOPIC_FLOOR
|
|
184
|
+
- These pulls AND the topic (from:handle Rome)
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
items: List of X items with 'author_handle' and 'text' fields
|
|
188
|
+
topic: The user topic
|
|
189
|
+
extracted_handles: Handles extracted from entity_extract
|
|
190
|
+
explicit_handles: Explicit --x-handle/--x-related handles
|
|
191
|
+
ranking_query: Optional ranking query for relevance scoring
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Tuple of (explicit_promotable, extracted_promotable):
|
|
195
|
+
- explicit_promotable: handles that get FROM without AND topic
|
|
196
|
+
- extracted_promotable: handles that get FROM with AND topic
|
|
197
|
+
"""
|
|
198
|
+
explicit_set = {
|
|
199
|
+
h.lower().lstrip("@")
|
|
200
|
+
for h in (explicit_handles or [])
|
|
201
|
+
if h and h.strip()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Judge corpus to get handle stats
|
|
205
|
+
judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
|
|
206
|
+
handle_stats = judgment["handle_stats"]
|
|
207
|
+
|
|
208
|
+
explicit_promotable = []
|
|
209
|
+
extracted_promotable = []
|
|
210
|
+
|
|
211
|
+
for handle in extracted_handles:
|
|
212
|
+
handle_lower = handle.lower().lstrip("@")
|
|
213
|
+
|
|
214
|
+
# Explicit handles always promoted (no AND topic)
|
|
215
|
+
if handle_lower in explicit_set:
|
|
216
|
+
explicit_promotable.append(handle)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Check if handle qualifies for extracted promotion
|
|
220
|
+
stats = handle_stats.get(handle_lower, {"on_topic": 0, "total": 0})
|
|
221
|
+
|
|
222
|
+
# Need ≥MIN_ON_TOPIC_HITS on-topic posts
|
|
223
|
+
if stats["on_topic"] < MIN_ON_TOPIC_HITS:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Need ≥HANDLE_ON_TOPIC_FLOOR ratio
|
|
227
|
+
if stats["total"] > 0:
|
|
228
|
+
ratio = stats["on_topic"] / stats["total"]
|
|
229
|
+
if ratio >= HANDLE_ON_TOPIC_FLOOR:
|
|
230
|
+
extracted_promotable.append(handle)
|
|
231
|
+
|
|
232
|
+
# Also check explicit handles not in extracted list
|
|
233
|
+
for handle in (explicit_handles or []):
|
|
234
|
+
handle_lower = handle.lower().lstrip("@")
|
|
235
|
+
if handle_lower not in [h.lower() for h in explicit_promotable]:
|
|
236
|
+
if handle_lower not in [h.lower() for h in extracted_handles]:
|
|
237
|
+
explicit_promotable.append(handle)
|
|
238
|
+
|
|
239
|
+
return explicit_promotable, extracted_promotable
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def should_retry_x_search(
|
|
243
|
+
items: List[Dict[str, Any]],
|
|
244
|
+
topic: str,
|
|
245
|
+
*,
|
|
246
|
+
ranking_query: str = "",
|
|
247
|
+
depth: str = "default",
|
|
248
|
+
) -> bool:
|
|
249
|
+
"""Determine if X search should retry with wider keyword query.
|
|
250
|
+
|
|
251
|
+
Skip retry on quick/mock (same as Phase 2).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
items: Retrieved X items
|
|
255
|
+
topic: The user topic
|
|
256
|
+
ranking_query: Optional ranking query
|
|
257
|
+
depth: Search depth ("quick", "default", "deep")
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if retry is warranted
|
|
261
|
+
"""
|
|
262
|
+
if depth == "quick":
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
if not items:
|
|
266
|
+
return False # Nothing to judge, no retry
|
|
267
|
+
|
|
268
|
+
judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
|
|
269
|
+
return judgment["is_off_topic_flood"]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def prune_off_topic_items(
|
|
273
|
+
items: List[Dict[str, Any]],
|
|
274
|
+
topic: str,
|
|
275
|
+
*,
|
|
276
|
+
ranking_query: str = "",
|
|
277
|
+
) -> List[Dict[str, Any]]:
|
|
278
|
+
"""Prune off-topic items before the pool.
|
|
279
|
+
|
|
280
|
+
Eight on-topic items with 32 pruned → ok with 8.
|
|
281
|
+
Zero on-topic → no-results, not ok with 40 junk.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
items: Retrieved X items
|
|
285
|
+
topic: The user topic
|
|
286
|
+
ranking_query: Optional ranking query
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Only on-topic items
|
|
290
|
+
"""
|
|
291
|
+
judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
|
|
292
|
+
return judgment["on_topic_items"]
|
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
import sys
|
|
6
6
|
from typing import Any, Dict, List, Optional
|
|
7
7
|
|
|
8
|
-
from . import http, log
|
|
8
|
+
from . import http, log, providers
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _safe_text(val) -> str:
|
|
@@ -74,6 +74,8 @@ def search_x(
|
|
|
74
74
|
to_date: str,
|
|
75
75
|
depth: str = "default",
|
|
76
76
|
mock_response: Optional[Dict] = None,
|
|
77
|
+
deadline_monotonic: Optional[float] = None,
|
|
78
|
+
cancel: Any = None,
|
|
77
79
|
) -> Dict[str, Any]:
|
|
78
80
|
"""Search X for relevant posts using xAI API with live search.
|
|
79
81
|
|
|
@@ -85,6 +87,14 @@ def search_x(
|
|
|
85
87
|
to_date: End date (YYYY-MM-DD)
|
|
86
88
|
depth: Research depth - "quick", "default", or "deep"
|
|
87
89
|
mock_response: Mock response for testing
|
|
90
|
+
deadline_monotonic: Optional shared wall-clock deadline (``time.monotonic()``
|
|
91
|
+
instant) from the X backend chain. Bounds the whole call including
|
|
92
|
+
retries; without one this call owns its own timeout only.
|
|
93
|
+
|
|
94
|
+
The transport runs with ``retries=1`` (a global default change from the
|
|
95
|
+
inherited ``retries=5``): one 90-180s live-search call must not become a
|
|
96
|
+
10-15 minute stall. DNS-resolution failures are the exception: the
|
|
97
|
+
transport widens those to three attempts.
|
|
88
98
|
|
|
89
99
|
Returns:
|
|
90
100
|
Raw API response
|
|
@@ -122,7 +132,20 @@ def search_x(
|
|
|
122
132
|
],
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
|
|
135
|
+
# Honor XAI_BASE_URL like the planner/rerank client does (providers.py), so
|
|
136
|
+
# an override redirects every request that carries the xAI key, not only some.
|
|
137
|
+
url = providers.resolve_endpoint("XAI_BASE_URL", XAI_RESPONSES_URL)
|
|
138
|
+
# A single non-DNS attempt: the per-call timeout already spans the model's
|
|
139
|
+
# full live-search latency, and the inherited http default (retries=5)
|
|
140
|
+
# would turn one 90-180s call into a 10-15 minute stall. The shared chain
|
|
141
|
+
# deadline bounds the wait wall-clock; failover to the next X backend
|
|
142
|
+
# covers the miss.
|
|
143
|
+
return http.post(
|
|
144
|
+
url, payload, headers=headers, timeout=timeout,
|
|
145
|
+
retries=1, deadline_monotonic=deadline_monotonic,
|
|
146
|
+
cancel=cancel,
|
|
147
|
+
bypass_proxy=providers.is_loopback_http_endpoint(url),
|
|
148
|
+
)
|
|
126
149
|
|
|
127
150
|
|
|
128
151
|
def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
@@ -141,7 +164,7 @@ def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
141
164
|
error = response["error"]
|
|
142
165
|
err_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
|
|
143
166
|
_log_error(f"xAI API error: {err_msg}")
|
|
144
|
-
if
|
|
167
|
+
if log.is_debug():
|
|
145
168
|
_log_error(f"Full error response: {json.dumps(response, indent=2)[:1000]}")
|
|
146
169
|
return items
|
|
147
170
|
|
|
@@ -7,11 +7,13 @@ bookmarks). Requires an API key from xquik.com.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import time
|
|
10
11
|
from datetime import datetime
|
|
11
12
|
from typing import Any, Dict, List, Optional
|
|
12
13
|
|
|
13
14
|
from . import http, log
|
|
14
15
|
from .relevance import token_overlap_relevance as _compute_relevance
|
|
16
|
+
from .x_api import is_own_post
|
|
15
17
|
|
|
16
18
|
# Per-process probe cache: (state, reason). state is "unset" until probed, then
|
|
17
19
|
# True (funded/working) | False (auth/payment failure) | None (inconclusive).
|
|
@@ -78,6 +80,8 @@ def search_xquik(
|
|
|
78
80
|
to_date: str,
|
|
79
81
|
depth: str = "default",
|
|
80
82
|
token: str = "",
|
|
83
|
+
deadline: float | None = None,
|
|
84
|
+
cancel: Any = None,
|
|
81
85
|
) -> Dict[str, Any]:
|
|
82
86
|
"""Search X via Xquik REST API.
|
|
83
87
|
|
|
@@ -87,6 +91,10 @@ def search_xquik(
|
|
|
87
91
|
to_date: End date (YYYY-MM-DD)
|
|
88
92
|
depth: Research depth - "quick", "default", or "deep"
|
|
89
93
|
token: Xquik API key
|
|
94
|
+
deadline: Optional shared wall-clock deadline (``time.monotonic()``
|
|
95
|
+
instant) from the X backend chain. Queries past the deadline are
|
|
96
|
+
never started; the per-request timeout/retry pair still bounds
|
|
97
|
+
each call (mirrors ``x_api``'s TIMEOUT_SECONDS/RETRIES pattern).
|
|
90
98
|
|
|
91
99
|
Returns:
|
|
92
100
|
Dict with "items" list and optional "error" string.
|
|
@@ -100,12 +108,17 @@ def search_xquik(
|
|
|
100
108
|
seen_ids: set[str] = set()
|
|
101
109
|
|
|
102
110
|
for query_text in queries:
|
|
111
|
+
if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
|
|
112
|
+
_log("chain deadline reached; skipping remaining queries")
|
|
113
|
+
return {"items": all_items, "error": "Xquik research cancelled or timed out"}
|
|
103
114
|
q = f"{query_text} since:{from_date} until:{to_date}"
|
|
104
115
|
items, auth_error = _execute_search(
|
|
105
116
|
q, cfg["limit"], token,
|
|
106
117
|
label=query_text, id_prefix="XQ",
|
|
107
118
|
seen_ids=seen_ids, relevance_query=query_text,
|
|
108
119
|
index_offset=len(all_items),
|
|
120
|
+
deadline_monotonic=deadline,
|
|
121
|
+
cancel=cancel,
|
|
109
122
|
)
|
|
110
123
|
if auth_error:
|
|
111
124
|
# Auth/payment failure is fatal for the whole source (e.g. 401/403,
|
|
@@ -114,6 +127,8 @@ def search_xquik(
|
|
|
114
127
|
return {"items": [], "error": auth_error}
|
|
115
128
|
all_items.extend(items)
|
|
116
129
|
|
|
130
|
+
if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
|
|
131
|
+
return {"items": all_items, "error": "Xquik research cancelled or timed out"}
|
|
117
132
|
return {"items": all_items}
|
|
118
133
|
|
|
119
134
|
|
|
@@ -127,38 +142,64 @@ def _execute_search(
|
|
|
127
142
|
seen_ids: set[str],
|
|
128
143
|
relevance_query: str,
|
|
129
144
|
index_offset: int = 0,
|
|
145
|
+
deadline_monotonic: float | None = None,
|
|
146
|
+
failure_out: Optional[List[str]] = None,
|
|
147
|
+
cancel: Any = None,
|
|
130
148
|
) -> tuple[List[Dict[str, Any]], str | None]:
|
|
131
149
|
"""Run one Xquik search call and parse its tweets.
|
|
132
150
|
|
|
133
151
|
Returns ``(items, auth_error)``. ``auth_error`` is a non-empty string only
|
|
134
152
|
on a fatal auth/payment failure (401/403); transient/HTTP errors log and
|
|
135
153
|
return ``([], None)`` so one bad lane never discards another's results.
|
|
154
|
+
|
|
155
|
+
Non-fatal failures (429, 5xx, network error, unparseable payload) append a
|
|
156
|
+
reason to ``failure_out`` instead. They stay out of the return value
|
|
157
|
+
because the caller must keep going to the next handle, but a caller that
|
|
158
|
+
stopped there would report the lane as empty rather than as failed.
|
|
136
159
|
``relevance_query`` (the topic) is what items are scored against — for the
|
|
137
160
|
handle lanes that differs from the search query (``from:handle``).
|
|
138
161
|
``index_offset`` keeps item ids unique across multiple calls that share an
|
|
139
162
|
accumulator (multi-query topic search, per-handle lanes).
|
|
163
|
+
``deadline_monotonic`` bounds the request including retries at the
|
|
164
|
+
transport (mirrors ``x_api._get``).
|
|
140
165
|
"""
|
|
141
166
|
full_url = f"{_BASE_URL}/x/tweets/search?q={_url_encode(q)}&queryType=Top&limit={limit}"
|
|
142
167
|
_log(f"Searching: {label}")
|
|
143
168
|
try:
|
|
144
|
-
|
|
169
|
+
request_headers = {"X-Api-Key": token}
|
|
170
|
+
response = http.get(
|
|
171
|
+
full_url, headers=request_headers, timeout=30, retries=2,
|
|
172
|
+
deadline_monotonic=deadline_monotonic,
|
|
173
|
+
cancel=cancel,
|
|
174
|
+
)
|
|
145
175
|
except http.HTTPError as exc:
|
|
146
176
|
status = getattr(exc, "status_code", None)
|
|
147
177
|
if status == 402:
|
|
148
178
|
# Unpaid key — fatal for the source, and surfaced on the real search
|
|
149
179
|
# path (not just --diagnose) so a live run reports it instead of
|
|
150
|
-
# settling silently empty.
|
|
151
|
-
|
|
180
|
+
# settling silently empty. The X retrieval branch classifies by
|
|
181
|
+
# message text only, so the detail carries the "payment required"
|
|
182
|
+
# marker that http.classify_failure maps to PAYMENT_REQUIRED.
|
|
183
|
+
return [], "Xquik key unpaid: payment required (402)"
|
|
152
184
|
if status in (401, 403):
|
|
153
185
|
return [], f"Xquik auth failed ({status})"
|
|
154
186
|
_log(f"HTTP error for '{label}': {exc}")
|
|
187
|
+
if failure_out is not None:
|
|
188
|
+
failure_out.append(f"Xquik HTTP error for '{label}': {exc}")
|
|
155
189
|
return [], None
|
|
156
190
|
except Exception as exc:
|
|
157
191
|
_log(f"Error for '{label}': {exc}")
|
|
192
|
+
if failure_out is not None:
|
|
193
|
+
failure_out.append(f"Xquik request failed for '{label}': {exc}")
|
|
158
194
|
return [], None
|
|
159
195
|
|
|
160
196
|
tweets = response.get("tweets", [])
|
|
161
197
|
if not isinstance(tweets, list):
|
|
198
|
+
if failure_out is not None:
|
|
199
|
+
failure_out.append(
|
|
200
|
+
f"Xquik returned no tweets array for '{label}' "
|
|
201
|
+
f"(got {type(tweets).__name__})"
|
|
202
|
+
)
|
|
162
203
|
return [], None
|
|
163
204
|
items: List[Dict[str, Any]] = []
|
|
164
205
|
for tweet in tweets:
|
|
@@ -174,15 +215,10 @@ def _execute_search(
|
|
|
174
215
|
return items, None
|
|
175
216
|
|
|
176
217
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
*by others* remain. Handles both x.com and twitter.com permalinks.
|
|
182
|
-
"""
|
|
183
|
-
u = (url or "").lower()
|
|
184
|
-
h = handle.lower().lstrip("@").strip()
|
|
185
|
-
return bool(h) and (f"x.com/{h}/status" in u or f"twitter.com/{h}/status" in u)
|
|
218
|
+
# True when a tweet URL is authored by ``handle`` (their own post). Used by
|
|
219
|
+
# the ABOUT lane to drop the subject's own tweets so only mentions *by
|
|
220
|
+
# others* remain; the implementation is ``x_api.is_own_post``.
|
|
221
|
+
_is_own = is_own_post
|
|
186
222
|
|
|
187
223
|
|
|
188
224
|
def search_handles(
|
|
@@ -193,18 +229,30 @@ def search_handles(
|
|
|
193
229
|
*,
|
|
194
230
|
count_per: int = 8,
|
|
195
231
|
token: str = "",
|
|
232
|
+
failure_out: Optional[List[str]] = None,
|
|
233
|
+
deadline: float | None = None,
|
|
234
|
+
cancel: Any = None,
|
|
196
235
|
) -> List[Dict[str, Any]]:
|
|
197
236
|
"""FROM lane: tweets authored BY each handle (their own timeline).
|
|
198
237
|
|
|
199
238
|
The topic is NOT AND'd into the query (that was the from:-AND bug, #610) —
|
|
200
239
|
we pull the raw timeline and use ``topic`` for relevance ranking only.
|
|
201
240
|
Returns a flat list of item dicts (mirrors ``bird_x.search_handles``).
|
|
241
|
+
|
|
242
|
+
When ``failure_out`` is provided, any failure reason is appended — the
|
|
243
|
+
fatal auth/payment one that stops the lane, and the per-handle transient
|
|
244
|
+
ones (429, 5xx, network, unparseable payload) that do not — so the caller
|
|
245
|
+
can distinguish a failed request from a handle that posted nothing.
|
|
202
246
|
"""
|
|
203
247
|
if not token or not handles:
|
|
204
248
|
return []
|
|
205
249
|
items: List[Dict[str, Any]] = []
|
|
206
250
|
seen_ids: set[str] = set()
|
|
207
251
|
for raw in handles:
|
|
252
|
+
if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
|
|
253
|
+
if failure_out is not None:
|
|
254
|
+
failure_out.append("Xquik research cancelled or timed out")
|
|
255
|
+
break
|
|
208
256
|
handle = str(raw).lstrip("@").strip()
|
|
209
257
|
if not handle:
|
|
210
258
|
continue
|
|
@@ -214,9 +262,17 @@ def search_handles(
|
|
|
214
262
|
label=f"from:{handle}", id_prefix="XF",
|
|
215
263
|
seen_ids=seen_ids, relevance_query=topic,
|
|
216
264
|
index_offset=len(items),
|
|
265
|
+
failure_out=failure_out,
|
|
266
|
+
deadline_monotonic=deadline,
|
|
267
|
+
cancel=cancel,
|
|
217
268
|
)
|
|
218
269
|
if auth_error:
|
|
219
|
-
|
|
270
|
+
# Fatal auth/payment failure — stop, keep what we have. Surface the
|
|
271
|
+
# reason: an empty FROM lane is otherwise indistinguishable from a
|
|
272
|
+
# subject who simply did not post.
|
|
273
|
+
if failure_out is not None:
|
|
274
|
+
failure_out.append(auth_error)
|
|
275
|
+
break
|
|
220
276
|
items.extend(got)
|
|
221
277
|
return items
|
|
222
278
|
|
|
@@ -229,17 +285,29 @@ def search_mentions(
|
|
|
229
285
|
topic: str = "",
|
|
230
286
|
count_per: int = 5,
|
|
231
287
|
token: str = "",
|
|
288
|
+
failure_out: Optional[List[str]] = None,
|
|
289
|
+
deadline: float | None = None,
|
|
290
|
+
cancel: Any = None,
|
|
232
291
|
) -> List[Dict[str, Any]]:
|
|
233
292
|
"""ABOUT lane: tweets mentioning each handle, authored by OTHERS.
|
|
234
293
|
|
|
235
294
|
Queries ``@handle`` then drops the handle's own tweets (``_is_own``) so only
|
|
236
295
|
third-party mentions remain. Returns a flat list of item dicts.
|
|
296
|
+
|
|
297
|
+
When ``failure_out`` is provided, any failure reason is appended — the
|
|
298
|
+
fatal auth/payment one that stops the lane, and the per-handle transient
|
|
299
|
+
ones (429, 5xx, network, unparseable payload) that do not — so the caller
|
|
300
|
+
can distinguish a failed request from a handle nobody mentioned.
|
|
237
301
|
"""
|
|
238
302
|
if not token or not handles:
|
|
239
303
|
return []
|
|
240
304
|
items: List[Dict[str, Any]] = []
|
|
241
305
|
seen_ids: set[str] = set()
|
|
242
306
|
for raw in handles:
|
|
307
|
+
if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
|
|
308
|
+
if failure_out is not None:
|
|
309
|
+
failure_out.append("Xquik research cancelled or timed out")
|
|
310
|
+
break
|
|
243
311
|
handle = str(raw).lstrip("@").strip()
|
|
244
312
|
if not handle:
|
|
245
313
|
continue
|
|
@@ -249,8 +317,13 @@ def search_mentions(
|
|
|
249
317
|
label=f"@{handle}", id_prefix="XA",
|
|
250
318
|
seen_ids=seen_ids, relevance_query=topic,
|
|
251
319
|
index_offset=len(items),
|
|
320
|
+
failure_out=failure_out,
|
|
321
|
+
deadline_monotonic=deadline,
|
|
322
|
+
cancel=cancel,
|
|
252
323
|
)
|
|
253
324
|
if auth_error:
|
|
325
|
+
if failure_out is not None:
|
|
326
|
+
failure_out.append(auth_error)
|
|
254
327
|
break
|
|
255
328
|
items.extend(it for it in got if not _is_own(it.get("url", ""), handle))
|
|
256
329
|
return items
|
|
@@ -280,11 +353,12 @@ def probe_works(token: str, timeout: int = 8) -> Optional[bool]:
|
|
|
280
353
|
q = f"from:x since:{since}"
|
|
281
354
|
full_url = f"{_BASE_URL}/x/tweets/search?q={_url_encode(q)}&queryType=Top&limit=1"
|
|
282
355
|
try:
|
|
283
|
-
|
|
356
|
+
request_headers = {"X-Api-Key": token}
|
|
357
|
+
http.get(full_url, headers=request_headers, timeout=timeout, retries=0)
|
|
284
358
|
except http.HTTPError as exc:
|
|
285
359
|
status = getattr(exc, "status_code", None)
|
|
286
360
|
if status == 402:
|
|
287
|
-
_probe_cache = (False, "xquik key unpaid (402)")
|
|
361
|
+
_probe_cache = (False, "xquik key unpaid: payment required (402)")
|
|
288
362
|
elif status in (401, 403):
|
|
289
363
|
_probe_cache = (False, f"xquik auth failed ({status})")
|
|
290
364
|
else:
|