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,824 @@
|
|
|
1
|
+
"""Direct X API v2 backend (``xapi``) and the shared v2 response parser.
|
|
2
|
+
|
|
3
|
+
``xapi`` searches X through ``api.x.com/2`` with an app-only bearer token
|
|
4
|
+
(``X_BEARER_TOKEN``). Full-archive search is tried first; when the developer
|
|
5
|
+
project is not enrolled for it (HTTP 403 with an enrollment marker), the
|
|
6
|
+
search retries once against recent search with the window clamped to the
|
|
7
|
+
last seven days and the truncation named in the result.
|
|
8
|
+
|
|
9
|
+
This module also owns the one X API v2 parser (``parse_v2_response``) that
|
|
10
|
+
``xurl_x`` delegates to, plus the snowflake, handle-grammar, and
|
|
11
|
+
generated-sequence helpers that ``grok_x`` imports back.
|
|
12
|
+
|
|
13
|
+
Security contract: the bearer travels only in the Authorization
|
|
14
|
+
header; every failure becomes an engine-authored fixed string chosen by
|
|
15
|
+
status code plus a marker match on the body, and the response body, reason
|
|
16
|
+
phrase, and headers never enter an error string, a log line, or an
|
|
17
|
+
exception. Topic and handle text is sanitized before it enters a query, and
|
|
18
|
+
the research window is always carried by request parameters.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from . import health, http, log
|
|
30
|
+
from .relevance import token_overlap_relevance as _compute_relevance
|
|
31
|
+
|
|
32
|
+
_API_BASE = "https://api.x.com/2"
|
|
33
|
+
_SEARCH_ALL_URL = f"{_API_BASE}/tweets/search/all"
|
|
34
|
+
_SEARCH_RECENT_URL = f"{_API_BASE}/tweets/search/recent"
|
|
35
|
+
|
|
36
|
+
# Depth configurations: number of posts to collect per query (shared with
|
|
37
|
+
# xurl_x, which re-imports this table). The API minimum per page is 10.
|
|
38
|
+
DEPTH_CONFIG = {
|
|
39
|
+
"quick": 10,
|
|
40
|
+
"default": 30,
|
|
41
|
+
"deep": 60,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# X API v2 caps a search query at 512 characters (Basic) and pages at 10..100.
|
|
45
|
+
MAX_QUERY_CHARS = 512
|
|
46
|
+
MIN_PAGE_RESULTS = 10
|
|
47
|
+
MAX_PAGE_RESULTS = 100
|
|
48
|
+
# Recent search reaches back seven days; end_time must be at least ten
|
|
49
|
+
# seconds before now, so a "today" window keeps a small safety margin.
|
|
50
|
+
RECENT_WINDOW_DAYS = 7
|
|
51
|
+
END_TIME_SAFETY_SECONDS = 30
|
|
52
|
+
TIMEOUT_SECONDS = 30
|
|
53
|
+
RETRIES = 2
|
|
54
|
+
# Wall-clock budget for one search (all pages, including the recent-search
|
|
55
|
+
# fallback). A throttled or slow walk returns what it has instead of holding
|
|
56
|
+
# the whole run; the source's per-request timeout still bounds each page.
|
|
57
|
+
DEADLINE_SECONDS = 90
|
|
58
|
+
# One budget shared by every supplemental handle lane of a run (explicit
|
|
59
|
+
# from, extracted from, about, related), started once by the caller: the
|
|
60
|
+
# point is to bound the total, not each part.
|
|
61
|
+
LANE_BUDGET_SECONDS = 150.0
|
|
62
|
+
|
|
63
|
+
TRUNCATION_DETAIL = "window truncated to 7 days"
|
|
64
|
+
# Receipt when the lane budget stopped a search before ``count`` posts were
|
|
65
|
+
# collected: the posts kept are real, the coverage is not complete.
|
|
66
|
+
DEADLINE_DETAIL = "search stopped at the lane deadline; results may be incomplete"
|
|
67
|
+
|
|
68
|
+
# The one description of what an app-only bearer can reach. Doctor and the
|
|
69
|
+
# prescriptions quote it; the bearer path is never described as parity with
|
|
70
|
+
# the connector lane.
|
|
71
|
+
BEARER_COVERAGE_NOTE = (
|
|
72
|
+
"recent posts, about the last week, unless your X developer project has "
|
|
73
|
+
"full-archive access"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Engine-authored fixed error strings. Each carries a marker that
|
|
77
|
+
# http.classify_failure recognizes, because the X retrieval branch classifies
|
|
78
|
+
# by message text only.
|
|
79
|
+
ERR_PAYMENT_REQUIRED = "xapi: payment required (X API credits exhausted)"
|
|
80
|
+
ERR_UNAUTHORIZED = "xapi: unauthorized (bearer token rejected)"
|
|
81
|
+
ERR_FORBIDDEN = "xapi: forbidden (bearer token lacks access)"
|
|
82
|
+
ERR_RATE_LIMITED = "xapi: rate limit exceeded (X API)"
|
|
83
|
+
ERR_TIMED_OUT = "xapi: timed out"
|
|
84
|
+
ERR_UNREACHABLE = "xapi: connection error (X API unreachable)"
|
|
85
|
+
ERR_NO_TOKEN = "No X_BEARER_TOKEN configured"
|
|
86
|
+
ERR_EMPTY_QUERY = "xapi: empty query after sanitizing"
|
|
87
|
+
|
|
88
|
+
# Failures that end every remaining lane for the run (the key itself is the
|
|
89
|
+
# problem, not this request).
|
|
90
|
+
_FATAL_ERRORS = frozenset({ERR_PAYMENT_REQUIRED, ERR_UNAUTHORIZED, ERR_FORBIDDEN})
|
|
91
|
+
|
|
92
|
+
# A 403 whose body carries one of these is "this project is not enrolled for
|
|
93
|
+
# full-archive search", which recent search can still serve.
|
|
94
|
+
_ENROLLMENT_MARKERS = (
|
|
95
|
+
"client-not-enrolled",
|
|
96
|
+
"not enrolled",
|
|
97
|
+
"enrolled",
|
|
98
|
+
"access level",
|
|
99
|
+
"subset of",
|
|
100
|
+
)
|
|
101
|
+
# X-specific body markers for credit exhaustion, matched on any status on
|
|
102
|
+
# top of http.classify_failure's shared payment-required markers: X reports
|
|
103
|
+
# a depleted pay-per-use balance under more than one code and title.
|
|
104
|
+
_X_CREDIT_MARKERS = ("creditsdepleted", "credits depleted")
|
|
105
|
+
|
|
106
|
+
# Twitter/X snowflake epoch (2010-11-04T01:42:54.657Z) in milliseconds.
|
|
107
|
+
_SNOWFLAKE_EPOCH_MS = 1288834974657
|
|
108
|
+
|
|
109
|
+
# X's real handle grammar. Handles are interpolated into post URLs and into
|
|
110
|
+
# search operators (from:, @), so anything outside this charset is rejected
|
|
111
|
+
# rather than passed through. entity_extract applies the same rule to
|
|
112
|
+
# @mentions.
|
|
113
|
+
_HANDLE_RE = re.compile(r"[A-Za-z0-9_]{1,15}")
|
|
114
|
+
|
|
115
|
+
# Grouping syntax that carries no lexical meaning in a topic, mirroring
|
|
116
|
+
# bird_x. Double quotes are handled separately: xapi strips them all and
|
|
117
|
+
# wraps the core in exactly one phrase pair, so a planner quote can never
|
|
118
|
+
# close the phrase early.
|
|
119
|
+
_GROUPING_CHARS = "“”()[]{}"
|
|
120
|
+
_QUOTE_CHARS = "\"„‟"
|
|
121
|
+
_APOSTROPHES = "'‘’"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _log(msg: str) -> None:
|
|
125
|
+
log.source_log("xapi", msg, tty_only=False)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _utcnow() -> datetime:
|
|
129
|
+
"""Current UTC time; a seam tests patch to pin the window arithmetic."""
|
|
130
|
+
return datetime.now(timezone.utc)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Shared helpers (moved here from grok_x; grok_x imports them back)
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _decode_snowflake(post_id: str) -> Optional[datetime]:
|
|
139
|
+
"""Recover a post's creation time from its id, with no network call."""
|
|
140
|
+
try:
|
|
141
|
+
value = int(str(post_id).strip())
|
|
142
|
+
except (TypeError, ValueError):
|
|
143
|
+
return None
|
|
144
|
+
if value <= 0:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return datetime.fromtimestamp(
|
|
148
|
+
((value >> 22) + _SNOWFLAKE_EPOCH_MS) / 1000, tz=timezone.utc
|
|
149
|
+
)
|
|
150
|
+
except (OverflowError, OSError, ValueError):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _looks_generated(ids: List[str]) -> bool:
|
|
155
|
+
"""True when ids form a near-uniform arithmetic run.
|
|
156
|
+
|
|
157
|
+
Real ranked results are not evenly spaced in time. A fabricated set often
|
|
158
|
+
is, because a model interpolates a plausible-looking id sequence. Four
|
|
159
|
+
ids is the minimum used here: three gaps are needed before a near-uniform
|
|
160
|
+
step reads as generated rather than coincidental.
|
|
161
|
+
"""
|
|
162
|
+
numeric = []
|
|
163
|
+
for pid in ids:
|
|
164
|
+
try:
|
|
165
|
+
numeric.append(int(pid))
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
return False
|
|
168
|
+
if len(numeric) < 4:
|
|
169
|
+
return False
|
|
170
|
+
numeric.sort()
|
|
171
|
+
gaps = [b - a for a, b in zip(numeric, numeric[1:])]
|
|
172
|
+
if any(g <= 0 for g in gaps):
|
|
173
|
+
return False
|
|
174
|
+
mean = sum(gaps) / len(gaps)
|
|
175
|
+
if mean <= 0:
|
|
176
|
+
return False
|
|
177
|
+
# Every gap within 5% of the mean is not something real timelines do.
|
|
178
|
+
return all(abs(g - mean) / mean < 0.05 for g in gaps)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _clean_handle(value: str) -> str:
|
|
182
|
+
"""Return a grammar-valid handle, or '' when the value is not one."""
|
|
183
|
+
candidate = str(value or "").strip().lstrip("@")
|
|
184
|
+
return candidate if _HANDLE_RE.fullmatch(candidate) else ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Parser
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _safe_int(value: Any) -> Optional[int]:
|
|
193
|
+
if value is None:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return int(value)
|
|
197
|
+
except (ValueError, TypeError):
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_v2_response(
|
|
202
|
+
response: Dict[str, Any],
|
|
203
|
+
topic: str = "",
|
|
204
|
+
window: Optional[tuple[str, str]] = None,
|
|
205
|
+
*,
|
|
206
|
+
id_prefix: str = "XAPI",
|
|
207
|
+
index_offset: int = 0,
|
|
208
|
+
seen_ids: Optional[set[str]] = None,
|
|
209
|
+
) -> List[Dict[str, Any]]:
|
|
210
|
+
"""Parse an X API v2 search response into the engine's X item shape.
|
|
211
|
+
|
|
212
|
+
Output matches the XItem schema used by xai_x and bird_x (id, text, url,
|
|
213
|
+
author_handle, date, engagement, mentioned_handles, why_relevant,
|
|
214
|
+
relevance) plus ``post_id`` (the numeric snowflake as a string). The
|
|
215
|
+
citation URL is rebuilt from the id and username; URL fields in the
|
|
216
|
+
response are never used. A post whose author is not in
|
|
217
|
+
``includes.users`` (or whose username fails the handle grammar) keeps
|
|
218
|
+
the ``https://x.com/i/status/<id>`` form with an empty handle.
|
|
219
|
+
|
|
220
|
+
``window`` is an optional ``(from_date, to_date)`` pair of YYYY-MM-DD
|
|
221
|
+
strings; dated posts outside it are dropped. ``seen_ids`` and
|
|
222
|
+
``index_offset`` let several calls share one accumulator with unique ids.
|
|
223
|
+
"""
|
|
224
|
+
items: List[Dict[str, Any]] = []
|
|
225
|
+
if not isinstance(response, dict):
|
|
226
|
+
return items
|
|
227
|
+
if "error" in response:
|
|
228
|
+
_log("error in response; no items parsed")
|
|
229
|
+
return items
|
|
230
|
+
|
|
231
|
+
data = response.get("data") or []
|
|
232
|
+
if not isinstance(data, list) or not data:
|
|
233
|
+
return items
|
|
234
|
+
|
|
235
|
+
authors: Dict[str, str] = {}
|
|
236
|
+
for user in (response.get("includes") or {}).get("users") or []:
|
|
237
|
+
if isinstance(user, dict) and user.get("id") is not None:
|
|
238
|
+
authors[str(user["id"])] = _clean_handle(user.get("username", ""))
|
|
239
|
+
|
|
240
|
+
from .query import leading_mentions
|
|
241
|
+
|
|
242
|
+
window_from, window_to = (window or (None, None))
|
|
243
|
+
seen = seen_ids if seen_ids is not None else set()
|
|
244
|
+
|
|
245
|
+
for tweet in data:
|
|
246
|
+
if not isinstance(tweet, dict):
|
|
247
|
+
continue
|
|
248
|
+
tweet_id = str(tweet.get("id") or "").strip()
|
|
249
|
+
if not tweet_id.isdigit():
|
|
250
|
+
continue
|
|
251
|
+
if tweet_id in seen:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
username = authors.get(str(tweet.get("author_id") or ""), "")
|
|
255
|
+
if username:
|
|
256
|
+
url = f"https://x.com/{username}/status/{tweet_id}"
|
|
257
|
+
else:
|
|
258
|
+
url = f"https://x.com/i/status/{tweet_id}"
|
|
259
|
+
|
|
260
|
+
note = tweet.get("note_tweet")
|
|
261
|
+
text = ""
|
|
262
|
+
if isinstance(note, dict) and note.get("text"):
|
|
263
|
+
text = str(note["text"])
|
|
264
|
+
else:
|
|
265
|
+
text = str(tweet.get("text") or "")
|
|
266
|
+
text = text.strip()
|
|
267
|
+
|
|
268
|
+
engagement: Optional[Dict[str, Any]] = None
|
|
269
|
+
metrics = tweet.get("public_metrics") or {}
|
|
270
|
+
if isinstance(metrics, dict) and metrics:
|
|
271
|
+
engagement = {
|
|
272
|
+
"likes": metrics.get("like_count", 0),
|
|
273
|
+
"reposts": metrics.get("retweet_count", 0),
|
|
274
|
+
"replies": metrics.get("reply_count", 0),
|
|
275
|
+
"quotes": metrics.get("quote_count", 0),
|
|
276
|
+
}
|
|
277
|
+
bookmarks = _safe_int(metrics.get("bookmark_count"))
|
|
278
|
+
if bookmarks is not None:
|
|
279
|
+
engagement["bookmarks"] = bookmarks
|
|
280
|
+
views = _safe_int(metrics.get("impression_count"))
|
|
281
|
+
if views is not None:
|
|
282
|
+
engagement["views"] = views
|
|
283
|
+
|
|
284
|
+
date: Optional[str] = None
|
|
285
|
+
created = str(tweet.get("created_at") or "")
|
|
286
|
+
if created:
|
|
287
|
+
m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
|
|
288
|
+
if m:
|
|
289
|
+
date = m.group(1)
|
|
290
|
+
if date and window_from and date < window_from:
|
|
291
|
+
continue
|
|
292
|
+
if date and window_to and date > window_to:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
seen.add(tweet_id)
|
|
296
|
+
items.append({
|
|
297
|
+
"id": f"{id_prefix}{index_offset + len(items) + 1}",
|
|
298
|
+
"text": text[:500],
|
|
299
|
+
"url": url,
|
|
300
|
+
"author_handle": username,
|
|
301
|
+
"date": date,
|
|
302
|
+
"engagement": engagement,
|
|
303
|
+
"mentioned_handles": leading_mentions(text),
|
|
304
|
+
"why_relevant": "",
|
|
305
|
+
"relevance": _compute_relevance(topic, text) if topic else 0.5,
|
|
306
|
+
"post_id": tweet_id,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
return items
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Query compilation
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _topic_tokens(topic: str, *, preserve_trailing_colon: bool = False) -> List[str]:
|
|
318
|
+
"""Sanitized topic tokens: no quotes, grouping, operators, or negation."""
|
|
319
|
+
separators = str.maketrans({char: " " for char in _GROUPING_CHARS + _QUOTE_CHARS})
|
|
320
|
+
cleaned = str(topic or "").translate(separators)
|
|
321
|
+
tokens: List[str] = []
|
|
322
|
+
for token in cleaned.split():
|
|
323
|
+
clean = token.strip(_APOSTROPHES)
|
|
324
|
+
if preserve_trailing_colon and clean.endswith(":") and clean.count(":") == 1:
|
|
325
|
+
clean = clean[:-1]
|
|
326
|
+
if not clean:
|
|
327
|
+
continue
|
|
328
|
+
if ":" in clean or clean.startswith("-"):
|
|
329
|
+
continue
|
|
330
|
+
if clean.upper() in ("OR", "AND"):
|
|
331
|
+
continue
|
|
332
|
+
tokens.append(clean)
|
|
333
|
+
return tokens
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _compile(tokens: List[str]) -> str:
|
|
337
|
+
return f'"{" ".join(tokens)}" -is:retweet'
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _compile_keywords(tokens: List[str]) -> str:
|
|
341
|
+
return " ".join(tokens)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _fit(tokens: List[str], compile_tokens: Callable[[List[str]], str]) -> str:
|
|
345
|
+
"""Compile *tokens*, dropping trailing ones until under ``MAX_QUERY_CHARS``."""
|
|
346
|
+
if not tokens:
|
|
347
|
+
return ""
|
|
348
|
+
tokens = list(tokens)
|
|
349
|
+
compiled = compile_tokens(tokens)
|
|
350
|
+
while len(compiled) > MAX_QUERY_CHARS and len(tokens) > 1:
|
|
351
|
+
tokens.pop()
|
|
352
|
+
compiled = compile_tokens(tokens)
|
|
353
|
+
if len(compiled) > MAX_QUERY_CHARS:
|
|
354
|
+
# One token longer than the whole budget: keep as much of it as fits.
|
|
355
|
+
overhead = len(compile_tokens([""]))
|
|
356
|
+
tokens = [tokens[0][: MAX_QUERY_CHARS - overhead]]
|
|
357
|
+
compiled = compile_tokens(tokens)
|
|
358
|
+
return compiled
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def build_query(topic: str) -> str:
|
|
362
|
+
"""Compile a topic into one quoted phrase plus ``-is:retweet``.
|
|
363
|
+
|
|
364
|
+
Mirrors ``bird_x.build_topic_query`` (grouping characters become spaces,
|
|
365
|
+
quotes are balanced) and additionally drops every operator-shaped token
|
|
366
|
+
(anything with a colon, anything starting with ``-``, bare OR/AND) so a
|
|
367
|
+
topic can never smuggle ``from:``, ``since:``, or a phrase break into the
|
|
368
|
+
request. The window is never part of the query (see ``_window``). The
|
|
369
|
+
result stays under ``MAX_QUERY_CHARS``, cut at a token boundary. Returns
|
|
370
|
+
"" when nothing lexical survives.
|
|
371
|
+
"""
|
|
372
|
+
return _fit(_topic_tokens(topic), _compile)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_keyword_query(topic: str) -> str:
|
|
376
|
+
"""Compile a topic into space-joined keywords (implicit AND), no operators.
|
|
377
|
+
|
|
378
|
+
The keyword counterpart of :func:`build_query` for callers that keep
|
|
379
|
+
X's default any-order keyword matching instead of one exact phrase
|
|
380
|
+
(``xurl_x``). The same operator safeguards apply, while ordinary trailing
|
|
381
|
+
colons preserve subject words: bare ``and``/``or`` in any case, colon
|
|
382
|
+
operators, leading ``-`` negation, grouping and quote characters never
|
|
383
|
+
reach X, whose v2 grammar rejects a bare lowercase ``and``/``or`` with
|
|
384
|
+
HTTP 400. Capped at ``MAX_QUERY_CHARS`` on a token boundary. Returns ""
|
|
385
|
+
when nothing lexical survives.
|
|
386
|
+
"""
|
|
387
|
+
return _fit(_topic_tokens(topic, preserve_trailing_colon=True), _compile_keywords)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
# Window
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _parse_day(value: str) -> Optional[datetime]:
|
|
396
|
+
try:
|
|
397
|
+
return datetime.strptime(str(value)[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
398
|
+
except (TypeError, ValueError):
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _iso(when: datetime) -> str:
|
|
403
|
+
return when.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _window(from_date: str, to_date: str) -> tuple[str, str]:
|
|
407
|
+
"""``(start_time, end_time)`` for the request, ISO 8601 with Z.
|
|
408
|
+
|
|
409
|
+
``start_time`` is midnight UTC of ``from_date``; ``end_time`` is the end
|
|
410
|
+
of ``to_date`` or, when that would be now or later ("today"), now minus a
|
|
411
|
+
safety margin.
|
|
412
|
+
"""
|
|
413
|
+
now = _utcnow()
|
|
414
|
+
start = _parse_day(from_date) or (now - timedelta(days=30))
|
|
415
|
+
end_day = _parse_day(to_date)
|
|
416
|
+
latest = now - timedelta(seconds=END_TIME_SAFETY_SECONDS)
|
|
417
|
+
if end_day is None:
|
|
418
|
+
end = latest
|
|
419
|
+
else:
|
|
420
|
+
end = min(end_day + timedelta(hours=23, minutes=59, seconds=59), latest)
|
|
421
|
+
if start >= end:
|
|
422
|
+
start = end - timedelta(days=1)
|
|
423
|
+
return _iso(start), _iso(end)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _recent_floor() -> str:
|
|
427
|
+
"""The earliest ``start_time`` recent search accepts, with a margin."""
|
|
428
|
+
return _iso(
|
|
429
|
+
_utcnow() - timedelta(days=RECENT_WINDOW_DAYS) + timedelta(seconds=END_TIME_SAFETY_SECONDS)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Transport
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class _XApiFailure(Exception):
|
|
439
|
+
"""A request failure already reduced to its fixed string.
|
|
440
|
+
|
|
441
|
+
``enrollment`` marks the one 403 that recent search can still serve.
|
|
442
|
+
The message is always one of the ERR_* literals (or ``xapi: http <n>``),
|
|
443
|
+
never response text.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
def __init__(self, message: str, *, enrollment: bool = False):
|
|
447
|
+
super().__init__(message)
|
|
448
|
+
self.enrollment = enrollment
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _failure_for(exc: http.HTTPError) -> _XApiFailure:
|
|
452
|
+
status = getattr(exc, "status_code", None)
|
|
453
|
+
body = str(getattr(exc, "body", "") or "").lower()
|
|
454
|
+
if (
|
|
455
|
+
status == 402
|
|
456
|
+
or http.classify_failure(message=body) == health.PAYMENT_REQUIRED
|
|
457
|
+
or any(marker in body for marker in _X_CREDIT_MARKERS)
|
|
458
|
+
):
|
|
459
|
+
return _XApiFailure(ERR_PAYMENT_REQUIRED)
|
|
460
|
+
if status == 401:
|
|
461
|
+
return _XApiFailure(ERR_UNAUTHORIZED)
|
|
462
|
+
if status == 403:
|
|
463
|
+
enrolled = any(marker in body for marker in _ENROLLMENT_MARKERS)
|
|
464
|
+
return _XApiFailure(ERR_FORBIDDEN, enrollment=enrolled)
|
|
465
|
+
if status == 429:
|
|
466
|
+
return _XApiFailure(ERR_RATE_LIMITED)
|
|
467
|
+
state = getattr(exc, "outcome_state", None)
|
|
468
|
+
if status == 408 or state == health.TIMEOUT:
|
|
469
|
+
return _XApiFailure(ERR_TIMED_OUT)
|
|
470
|
+
if status:
|
|
471
|
+
return _XApiFailure(f"xapi: http {status}")
|
|
472
|
+
if state == health.UNREACHABLE:
|
|
473
|
+
return _XApiFailure(ERR_UNREACHABLE)
|
|
474
|
+
return _XApiFailure("xapi: request failed (HTTPError)")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _get(
|
|
478
|
+
token: str, url: str, params: Dict[str, Any], deadline: Optional[float] = None,
|
|
479
|
+
cancel: Any = None,
|
|
480
|
+
) -> Dict[str, Any]:
|
|
481
|
+
"""One authenticated GET; every failure surfaces as ``_XApiFailure``.
|
|
482
|
+
|
|
483
|
+
``deadline`` is handed to the transport as its wall deadline, so a
|
|
484
|
+
request started just before it cannot keep the full per-request timeout
|
|
485
|
+
and retry cycle: the socket wait and every backoff sleep stop at the
|
|
486
|
+
deadline (``http.DeadlineExceeded`` -> ``ERR_TIMED_OUT``).
|
|
487
|
+
"""
|
|
488
|
+
try:
|
|
489
|
+
response = http.get(
|
|
490
|
+
url,
|
|
491
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
492
|
+
params=params,
|
|
493
|
+
timeout=TIMEOUT_SECONDS,
|
|
494
|
+
retries=RETRIES,
|
|
495
|
+
deadline_monotonic=deadline,
|
|
496
|
+
**({"cancel": cancel} if cancel is not None else {}),
|
|
497
|
+
)
|
|
498
|
+
except http.HTTPError as exc:
|
|
499
|
+
raise _failure_for(exc) from None
|
|
500
|
+
except TimeoutError:
|
|
501
|
+
raise _XApiFailure(ERR_TIMED_OUT) from None
|
|
502
|
+
except Exception as exc: # noqa: BLE001 - the message must never leak
|
|
503
|
+
raise _XApiFailure(f"xapi: request failed ({type(exc).__name__})") from None
|
|
504
|
+
return response if isinstance(response, dict) else {}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _page_size(count: int) -> int:
|
|
508
|
+
return max(MIN_PAGE_RESULTS, min(MAX_PAGE_RESULTS, int(count)))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _search_pages(
|
|
512
|
+
token: str,
|
|
513
|
+
url: str,
|
|
514
|
+
params: Dict[str, Any],
|
|
515
|
+
count: int,
|
|
516
|
+
deadline: Optional[float] = None,
|
|
517
|
+
cancel: Any = None,
|
|
518
|
+
) -> Dict[str, Any]:
|
|
519
|
+
"""Follow ``next_token`` until ``count`` posts are collected.
|
|
520
|
+
|
|
521
|
+
``deadline`` is a ``time.monotonic()`` instant: the first page always
|
|
522
|
+
runs; a later page is skipped once the deadline has passed and the
|
|
523
|
+
posts collected so far are returned with ``"truncated": True`` so the
|
|
524
|
+
caller can report the incomplete coverage.
|
|
525
|
+
"""
|
|
526
|
+
data: List[Dict[str, Any]] = []
|
|
527
|
+
users: Dict[str, Dict[str, Any]] = {}
|
|
528
|
+
truncated = False
|
|
529
|
+
page_params = dict(params)
|
|
530
|
+
page_params["max_results"] = _page_size(count)
|
|
531
|
+
# Bound the walk even when every page carries a next_token.
|
|
532
|
+
max_pages = max(1, -(-count // MIN_PAGE_RESULTS))
|
|
533
|
+
for page_index in range(max_pages):
|
|
534
|
+
if cancel is not None and cancel.is_set():
|
|
535
|
+
if not data:
|
|
536
|
+
raise _XApiFailure(ERR_TIMED_OUT)
|
|
537
|
+
truncated = True
|
|
538
|
+
break
|
|
539
|
+
if page_index and deadline is not None and time.monotonic() >= deadline:
|
|
540
|
+
_log(f"search deadline ({DEADLINE_SECONDS}s) reached; keeping {len(data)} posts")
|
|
541
|
+
truncated = True
|
|
542
|
+
break
|
|
543
|
+
# A fresh dict per page: the transport must never see a later
|
|
544
|
+
# page's next_token on an earlier request.
|
|
545
|
+
try:
|
|
546
|
+
response = _get(token, url, dict(page_params), deadline, **({"cancel": cancel} if cancel is not None else {}))
|
|
547
|
+
except _XApiFailure as exc:
|
|
548
|
+
if data and str(exc) == ERR_TIMED_OUT:
|
|
549
|
+
# The budget ran out mid-walk: the pages already collected
|
|
550
|
+
# are the result, reported as truncated, not a failure.
|
|
551
|
+
_log(f"search deadline reached mid-walk; keeping {len(data)} posts")
|
|
552
|
+
truncated = True
|
|
553
|
+
break
|
|
554
|
+
raise
|
|
555
|
+
page = response.get("data") or []
|
|
556
|
+
if isinstance(page, list):
|
|
557
|
+
data.extend(t for t in page if isinstance(t, dict))
|
|
558
|
+
for user in (response.get("includes") or {}).get("users") or []:
|
|
559
|
+
if isinstance(user, dict) and user.get("id") is not None:
|
|
560
|
+
users[str(user["id"])] = user
|
|
561
|
+
next_token = (response.get("meta") or {}).get("next_token")
|
|
562
|
+
if not next_token or len(data) >= count:
|
|
563
|
+
break
|
|
564
|
+
page_params["next_token"] = next_token
|
|
565
|
+
return {
|
|
566
|
+
"data": data[:count],
|
|
567
|
+
"includes": {"users": list(users.values())},
|
|
568
|
+
"truncated": truncated,
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _run_search(
|
|
573
|
+
token: str,
|
|
574
|
+
query: str,
|
|
575
|
+
from_date: str,
|
|
576
|
+
to_date: str,
|
|
577
|
+
count: int,
|
|
578
|
+
*,
|
|
579
|
+
topic: str,
|
|
580
|
+
id_prefix: str,
|
|
581
|
+
label: str,
|
|
582
|
+
deadline: Optional[float] = None,
|
|
583
|
+
cancel: Any = None,
|
|
584
|
+
) -> Dict[str, Any]:
|
|
585
|
+
"""Full-archive search with the recent-search fallback.
|
|
586
|
+
|
|
587
|
+
``deadline`` (a ``time.monotonic()`` instant) is shared with the caller's
|
|
588
|
+
other lanes; without one the search gets its own ``DEADLINE_SECONDS``.
|
|
589
|
+
A shared deadline that has already passed sends no request at all.
|
|
590
|
+
|
|
591
|
+
Returns ``{"items": [...]}`` (plus ``"warning"`` when the window was
|
|
592
|
+
truncated or the deadline stopped the walk early) or ``{"items": [],
|
|
593
|
+
"error": <fixed string>}``.
|
|
594
|
+
"""
|
|
595
|
+
start, end = _window(from_date, to_date)
|
|
596
|
+
params = {
|
|
597
|
+
"query": query,
|
|
598
|
+
"start_time": start,
|
|
599
|
+
"end_time": end,
|
|
600
|
+
"sort_order": "recency",
|
|
601
|
+
"expansions": "author_id",
|
|
602
|
+
"tweet.fields": "created_at,public_metrics,note_tweet,entities",
|
|
603
|
+
"user.fields": "username",
|
|
604
|
+
}
|
|
605
|
+
if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
|
|
606
|
+
_log(f"{label}: lane budget ({LANE_BUDGET_SECONDS:.0f}s) exhausted before the search started")
|
|
607
|
+
return {"items": [], "warning": DEADLINE_DETAIL}
|
|
608
|
+
_log(f"Searching: {label}")
|
|
609
|
+
warnings: List[str] = []
|
|
610
|
+
if deadline is None:
|
|
611
|
+
deadline = time.monotonic() + DEADLINE_SECONDS
|
|
612
|
+
try:
|
|
613
|
+
response = _search_pages(token, _SEARCH_ALL_URL, params, count, deadline, **({"cancel": cancel} if cancel is not None else {}))
|
|
614
|
+
except _XApiFailure as exc:
|
|
615
|
+
if not exc.enrollment:
|
|
616
|
+
_log(f"{label}: {exc}")
|
|
617
|
+
return {"items": [], "error": str(exc)}
|
|
618
|
+
_log(f"{label}: full-archive search not enrolled; retrying recent search ({TRUNCATION_DETAIL})")
|
|
619
|
+
floor = _recent_floor()
|
|
620
|
+
if floor >= end:
|
|
621
|
+
# The whole window predates what recent search can reach: an
|
|
622
|
+
# empty, truncated result, never a start_time after end_time.
|
|
623
|
+
_log(f"{label}: window ends before the recent-search floor; nothing to fetch")
|
|
624
|
+
return {"items": [], "warning": TRUNCATION_DETAIL}
|
|
625
|
+
params["start_time"] = max(start, floor)
|
|
626
|
+
try:
|
|
627
|
+
response = _search_pages(token, _SEARCH_RECENT_URL, params, count, deadline, **({"cancel": cancel} if cancel is not None else {}))
|
|
628
|
+
except _XApiFailure as exc2:
|
|
629
|
+
_log(f"{label}: {exc2}")
|
|
630
|
+
return {"items": [], "error": str(exc2)}
|
|
631
|
+
warnings.append(TRUNCATION_DETAIL)
|
|
632
|
+
if response.get("truncated"):
|
|
633
|
+
warnings.append(DEADLINE_DETAIL)
|
|
634
|
+
items = parse_v2_response(response, topic, (from_date, to_date), id_prefix=id_prefix)
|
|
635
|
+
result: Dict[str, Any] = {"items": items}
|
|
636
|
+
if warnings:
|
|
637
|
+
result["warning"] = "; ".join(warnings)
|
|
638
|
+
return result
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
# Public search entry points
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def search_x(
|
|
647
|
+
token: str,
|
|
648
|
+
query: str,
|
|
649
|
+
from_date: str,
|
|
650
|
+
to_date: str,
|
|
651
|
+
depth: str = "default",
|
|
652
|
+
deadline: Optional[float] = None,
|
|
653
|
+
cancel: Any = None,
|
|
654
|
+
) -> Dict[str, Any]:
|
|
655
|
+
"""Topic search via X API v2.
|
|
656
|
+
|
|
657
|
+
Returns ``{"items": [...]}`` or ``{"items": [], "error": "..."}`` (the
|
|
658
|
+
xquik shape); ``"warning"`` carries the truncation detail after the
|
|
659
|
+
recent-search fallback. ``deadline`` is the X chain's shared
|
|
660
|
+
``time.monotonic()`` budget; without one the search gets its own
|
|
661
|
+
``DEADLINE_SECONDS``.
|
|
662
|
+
"""
|
|
663
|
+
if not token:
|
|
664
|
+
return {"items": [], "error": ERR_NO_TOKEN}
|
|
665
|
+
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
|
666
|
+
compiled = build_query(query)
|
|
667
|
+
if not compiled:
|
|
668
|
+
_log("topic compiled to an empty query after sanitizing")
|
|
669
|
+
return {"items": [], "error": ERR_EMPTY_QUERY}
|
|
670
|
+
return _run_search(
|
|
671
|
+
token, compiled, from_date, to_date, count,
|
|
672
|
+
topic=query, id_prefix="XAPI", label="topic", deadline=deadline,
|
|
673
|
+
**({"cancel": cancel} if cancel is not None else {}),
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def is_own_post(url: str, handle: str) -> bool:
|
|
678
|
+
"""True when a post URL is authored by ``handle`` (their own post).
|
|
679
|
+
|
|
680
|
+
The ABOUT lanes (here, xquik, the host envelope) drop the subject's own
|
|
681
|
+
posts so only mentions *by others* remain. Handles both x.com and
|
|
682
|
+
twitter.com permalinks.
|
|
683
|
+
"""
|
|
684
|
+
u = (url or "").lower()
|
|
685
|
+
h = handle.lower().lstrip("@").strip()
|
|
686
|
+
return bool(h) and (f"x.com/{h}/status" in u or f"twitter.com/{h}/status" in u)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _lane_handle(raw: str, lane: str) -> str:
|
|
691
|
+
handle = _clean_handle(raw)
|
|
692
|
+
if not handle:
|
|
693
|
+
_log(f"skipping {lane} lane for a handle outside the X handle grammar")
|
|
694
|
+
return handle
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
_MAX_LANE_WORKERS = 5
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _run_handle_lanes(
|
|
701
|
+
handles: List[str],
|
|
702
|
+
search_one: Callable[[str], Dict[str, Any]],
|
|
703
|
+
*,
|
|
704
|
+
id_prefix: str,
|
|
705
|
+
keep: Optional[Callable[[Dict[str, Any], str], bool]] = None,
|
|
706
|
+
warnings: Optional[List[str]] = None,
|
|
707
|
+
) -> List[Dict[str, Any]]:
|
|
708
|
+
"""Run one search per handle on a bounded pool and merge in handle order.
|
|
709
|
+
|
|
710
|
+
Per-handle receipts (``"warning"`` on a result, e.g. a deadline stop)
|
|
711
|
+
are appended to ``warnings`` once each, so the caller can report
|
|
712
|
+
incomplete lane coverage instead of presenting it as complete.
|
|
713
|
+
|
|
714
|
+
Mirrors ``bird_x.search_handles``: at most five handles in flight. A
|
|
715
|
+
fatal auth/payment result stops further handles from being scheduled;
|
|
716
|
+
whatever was already fetched is kept. Results are merged on the calling
|
|
717
|
+
thread in ``handles`` order, deduped by post id across handles, and
|
|
718
|
+
renumbered ``<id_prefix><n>`` so the output is deterministic regardless
|
|
719
|
+
of completion order. ``keep(item, handle)`` filters a handle's items
|
|
720
|
+
after the post id is recorded, so a dropped post still dedupes later.
|
|
721
|
+
"""
|
|
722
|
+
results: List[Optional[Dict[str, Any]]] = [None] * len(handles)
|
|
723
|
+
if handles:
|
|
724
|
+
max_workers = min(_MAX_LANE_WORKERS, len(handles))
|
|
725
|
+
fatal = False
|
|
726
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
727
|
+
pending: Dict[Any, int] = {}
|
|
728
|
+
next_index = 0
|
|
729
|
+
while pending or (next_index < len(handles) and not fatal):
|
|
730
|
+
while next_index < len(handles) and not fatal and len(pending) < max_workers:
|
|
731
|
+
pending[pool.submit(search_one, handles[next_index])] = next_index
|
|
732
|
+
next_index += 1
|
|
733
|
+
done, _ = wait(list(pending), return_when=FIRST_COMPLETED)
|
|
734
|
+
for future in done:
|
|
735
|
+
index = pending.pop(future)
|
|
736
|
+
result = future.result()
|
|
737
|
+
results[index] = result
|
|
738
|
+
if result.get("error") in _FATAL_ERRORS:
|
|
739
|
+
fatal = True # the key itself failed; keep what we have
|
|
740
|
+
|
|
741
|
+
items: List[Dict[str, Any]] = []
|
|
742
|
+
seen_ids: set[str] = set()
|
|
743
|
+
for handle, result in zip(handles, results):
|
|
744
|
+
note = (result or {}).get("warning")
|
|
745
|
+
if note and warnings is not None and note not in warnings:
|
|
746
|
+
warnings.append(note)
|
|
747
|
+
for item in (result or {}).get("items", []):
|
|
748
|
+
post_id = item.get("post_id", "")
|
|
749
|
+
if post_id in seen_ids:
|
|
750
|
+
continue
|
|
751
|
+
seen_ids.add(post_id)
|
|
752
|
+
if keep is not None and not keep(item, handle):
|
|
753
|
+
continue
|
|
754
|
+
item["id"] = f"{id_prefix}{len(items) + 1}"
|
|
755
|
+
items.append(item)
|
|
756
|
+
return items
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def search_handles(
|
|
760
|
+
handles: List[str],
|
|
761
|
+
topic: str,
|
|
762
|
+
from_date: str,
|
|
763
|
+
to_date: str,
|
|
764
|
+
*,
|
|
765
|
+
count_per: int = 8,
|
|
766
|
+
token: str = "",
|
|
767
|
+
deadline: Optional[float] = None,
|
|
768
|
+
warnings: Optional[List[str]] = None,
|
|
769
|
+
cancel: Any = None,
|
|
770
|
+
) -> List[Dict[str, Any]]:
|
|
771
|
+
"""FROM lane: posts authored BY each handle (their own timeline).
|
|
772
|
+
|
|
773
|
+
The topic is NOT AND'd into the query; it ranks relevance only (mirrors
|
|
774
|
+
``xquik.search_handles``). A handle outside the X grammar skips its lane
|
|
775
|
+
with a receipt line.
|
|
776
|
+
"""
|
|
777
|
+
if not token or not handles:
|
|
778
|
+
return []
|
|
779
|
+
clean = [h for h in (_lane_handle(raw, "from:") for raw in handles) if h]
|
|
780
|
+
|
|
781
|
+
def _search_one(handle: str) -> Dict[str, Any]:
|
|
782
|
+
return _run_search(
|
|
783
|
+
token, f"from:{handle} -is:retweet", from_date, to_date, count_per,
|
|
784
|
+
topic=topic, id_prefix="XF", label=f"from:{handle}", deadline=deadline,
|
|
785
|
+
**({"cancel": cancel} if cancel is not None else {}),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
return _run_handle_lanes(clean, _search_one, id_prefix="XF", warnings=warnings)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def search_mentions(
|
|
792
|
+
handles: List[str],
|
|
793
|
+
from_date: str,
|
|
794
|
+
to_date: str,
|
|
795
|
+
*,
|
|
796
|
+
topic: str = "",
|
|
797
|
+
count_per: int = 5,
|
|
798
|
+
token: str = "",
|
|
799
|
+
deadline: Optional[float] = None,
|
|
800
|
+
warnings: Optional[List[str]] = None,
|
|
801
|
+
cancel: Any = None,
|
|
802
|
+
) -> List[Dict[str, Any]]:
|
|
803
|
+
"""ABOUT lane: posts mentioning each handle, authored by OTHERS.
|
|
804
|
+
|
|
805
|
+
The query is ``@handle -from:handle`` and the handle's own posts are
|
|
806
|
+
dropped again client-side (``is_own_post``) so only third-party mentions
|
|
807
|
+
remain.
|
|
808
|
+
"""
|
|
809
|
+
if not token or not handles:
|
|
810
|
+
return []
|
|
811
|
+
clean = [h for h in (_lane_handle(raw, "mention") for raw in handles) if h]
|
|
812
|
+
|
|
813
|
+
def _search_one(handle: str) -> Dict[str, Any]:
|
|
814
|
+
return _run_search(
|
|
815
|
+
token, f"@{handle} -from:{handle} -is:retweet", from_date, to_date, count_per,
|
|
816
|
+
topic=topic, id_prefix="XA", label=f"@{handle}", deadline=deadline,
|
|
817
|
+
**({"cancel": cancel} if cancel is not None else {}),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
return _run_handle_lanes(
|
|
821
|
+
clean, _search_one, id_prefix="XA",
|
|
822
|
+
keep=lambda item, handle: not is_own_post(item.get("url", ""), handle),
|
|
823
|
+
warnings=warnings,
|
|
824
|
+
)
|