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
|
@@ -1,50 +1,82 @@
|
|
|
1
|
-
"""X (Twitter) search via xurl CLI — official X API v2
|
|
1
|
+
"""X (Twitter) search via xurl CLI — official X API v2.
|
|
2
2
|
|
|
3
|
-
xurl is
|
|
4
|
-
It
|
|
3
|
+
xurl is X's official CLI for the X API
|
|
4
|
+
(https://github.com/xdevplatform/xurl). It requires only a free
|
|
5
5
|
X Developer App. No xAI subscription or browser cookies needed.
|
|
6
6
|
|
|
7
|
-
Install: npm install -g xurl
|
|
8
|
-
Auth: xurl auth
|
|
7
|
+
Install: npm install -g @xdevplatform/xurl
|
|
8
|
+
Auth: xurl auth app-only <bearer-token> (search / availability)
|
|
9
|
+
xurl auth oauth1 ... (optional; not used for search)
|
|
9
10
|
|
|
10
11
|
Priority: xAI API > Bird/GraphQL > xurl > web-only fallback
|
|
11
12
|
"""
|
|
12
13
|
|
|
13
14
|
import json
|
|
14
15
|
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
15
18
|
import subprocess
|
|
16
|
-
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
22
|
|
|
18
|
-
from . import log
|
|
19
|
-
from .
|
|
23
|
+
from . import health, http, log, usage
|
|
24
|
+
from . import x_api
|
|
25
|
+
# One X API v2 depth table and one parser: xurl_x re-imports both.
|
|
26
|
+
from .x_api import DEPTH_CONFIG
|
|
27
|
+
|
|
28
|
+
# xurl auth status marks a configured app-only bearer as "bearer: ✓".
|
|
29
|
+
# Search uses --auth app, so availability must require this — oauth1 alone
|
|
30
|
+
# is not enough.
|
|
31
|
+
_BEARER_CONFIGURED_RE = re.compile(r"bearer:\s*✓")
|
|
20
32
|
|
|
21
33
|
|
|
22
34
|
def _log(msg: str) -> None:
|
|
23
35
|
log.source_log("xurl", msg, tty_only=False)
|
|
24
36
|
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
|
|
39
|
+
# Memoized availability, mirroring health.py's per-process dependency-probe
|
|
40
|
+
# cache: each uncached is_available() check spawns an `xurl auth status`
|
|
41
|
+
# subprocess (local credential status; no network). The doctor/safe-diagnose
|
|
42
|
+
# path never uses it — see stored_auth_status()/has_stored_auth() below —
|
|
43
|
+
# but research-time callers may consult it more than once per process.
|
|
44
|
+
# None means "not yet probed".
|
|
45
|
+
_availability_cache: Optional[bool] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def clear_availability_cache() -> None:
|
|
49
|
+
"""Reset the memoized is_available() result (tests, or a re-check after auth)."""
|
|
50
|
+
global _availability_cache
|
|
51
|
+
_availability_cache = None
|
|
32
52
|
|
|
33
53
|
|
|
34
54
|
def is_available() -> bool:
|
|
35
|
-
"""Check if xurl is installed and has
|
|
55
|
+
"""Check if xurl is installed and has app-only bearer auth.
|
|
36
56
|
|
|
37
|
-
Returns True only if xurl binary is found AND
|
|
38
|
-
|
|
57
|
+
Returns True only if xurl binary is found AND ``xurl auth status``
|
|
58
|
+
exits 0 with a configured app-only bearer (``bearer: ✓``). OAuth1
|
|
59
|
+
alone is insufficient — ``search_x`` pins ``--auth app``.
|
|
60
|
+
Memoized per process; ``clear_availability_cache()`` resets.
|
|
39
61
|
"""
|
|
62
|
+
global _availability_cache
|
|
63
|
+
if _availability_cache is None:
|
|
64
|
+
_availability_cache = _is_available_uncached()
|
|
65
|
+
return _availability_cache
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_available_uncached() -> bool:
|
|
40
69
|
try:
|
|
41
70
|
result = subprocess.run(
|
|
42
|
-
["xurl", "
|
|
71
|
+
["xurl", "auth", "status"],
|
|
43
72
|
capture_output=True,
|
|
44
73
|
text=True,
|
|
45
74
|
timeout=10,
|
|
46
75
|
)
|
|
47
|
-
return
|
|
76
|
+
return (
|
|
77
|
+
result.returncode == 0
|
|
78
|
+
and _BEARER_CONFIGURED_RE.search(result.stdout) is not None
|
|
79
|
+
)
|
|
48
80
|
except (OSError, subprocess.TimeoutExpired):
|
|
49
81
|
# OSError covers FileNotFoundError (no xurl on PATH) and
|
|
50
82
|
# PermissionError (a non-executable match on PATH, e.g. WSL's
|
|
@@ -52,56 +84,272 @@ def is_available() -> bool:
|
|
|
52
84
|
return False
|
|
53
85
|
|
|
54
86
|
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Local auth evidence (doctor / safe-diagnose path — no subprocess, no
|
|
89
|
+
# network).
|
|
90
|
+
#
|
|
91
|
+
# xurl persists OAuth credentials to an on-disk token store at ~/.xurl
|
|
92
|
+
# (YAML in current releases; legacy versions wrote JSON — see the upstream
|
|
93
|
+
# store package at github.com/xdevplatform/xurl). A populated store is the
|
|
94
|
+
# strongest LOCAL evidence of authentication obtainable without spending a
|
|
95
|
+
# network call, so doctor keys on it and reports "auth not live-verified"
|
|
96
|
+
# instead of running `xurl whoami` (a real, authenticated X API request
|
|
97
|
+
# that would violate doctor's no-network guarantee).
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
AUTH_OK = "ok" # token store present with stored credentials
|
|
101
|
+
AUTH_MISSING = "missing" # no token store, or no credentials stored in it
|
|
102
|
+
AUTH_ERROR = "error" # token store exists but could not be read
|
|
103
|
+
|
|
104
|
+
# Substrings a populated store carries in both the YAML and legacy JSON
|
|
105
|
+
# formats (per-user oauth2 token blocks, or an app-only bearer token).
|
|
106
|
+
_TOKEN_STORE_MARKERS = (
|
|
107
|
+
"access_token",
|
|
108
|
+
"bearer_token",
|
|
109
|
+
"oauth2_tokens",
|
|
110
|
+
"oauth1_tokens",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_file(path: Path) -> bool:
|
|
115
|
+
"""True when *path* is a regular file; raise on an unreadable stat.
|
|
116
|
+
|
|
117
|
+
``pathlib.Path.is_file()`` swallows ``OSError`` into ``False``, which
|
|
118
|
+
would silently misreport a permission-denied (EACCES) store as absent.
|
|
119
|
+
Probing via ``stat`` keeps the typed ``AUTH_ERROR`` path reachable for
|
|
120
|
+
real stat failures. A missing path (``FileNotFoundError``) returns False —
|
|
121
|
+
absence is a normal state (this also covers a dangling symlink whose
|
|
122
|
+
target is gone). Path stubs without ``stat``/``is_file`` support (test
|
|
123
|
+
doubles) fall back to their own behavior.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
return stat.S_ISREG(path.stat().st_mode)
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
return False
|
|
129
|
+
except (AttributeError, TypeError):
|
|
130
|
+
try:
|
|
131
|
+
return bool(path.is_file())
|
|
132
|
+
except AttributeError:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_dir(path: Path) -> bool:
|
|
137
|
+
"""True when *path* is a directory; raise on an unreadable stat.
|
|
138
|
+
|
|
139
|
+
Same rationale as :func:`_is_file` — pathlib's ``is_dir()`` hides
|
|
140
|
+
``OSError``, so a permission-denied parent would read as a missing store.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
return stat.S_ISDIR(path.stat().st_mode)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
return False
|
|
146
|
+
except (AttributeError, TypeError):
|
|
147
|
+
try:
|
|
148
|
+
return bool(path.is_dir())
|
|
149
|
+
except AttributeError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def token_store_path() -> Path:
|
|
154
|
+
"""xurl's on-disk OAuth token store (~/.xurl/auth.yml).
|
|
155
|
+
|
|
156
|
+
Current xurl (>=1.1) stores credentials in a YAML file at
|
|
157
|
+
``~/.xurl/auth.yml``; ``~/.xurl`` itself is a directory. Legacy
|
|
158
|
+
releases wrote a flat file at ``~/.xurl``. ``stored_auth_status()``
|
|
159
|
+
resolves both layouts; this returns the canonical current path.
|
|
160
|
+
"""
|
|
161
|
+
return Path.home() / ".xurl" / "auth.yml"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def stored_auth_status() -> Tuple[str, str]:
|
|
165
|
+
"""Local-only evidence of xurl authentication: ``(status, detail)``.
|
|
166
|
+
|
|
167
|
+
Reads only the on-disk token store — never spawns xurl, never touches
|
|
168
|
+
the network. ``status`` is AUTH_OK (store holds credentials),
|
|
169
|
+
AUTH_MISSING (no store / empty store / no credential markers), or
|
|
170
|
+
AUTH_ERROR (store exists but cannot be read — surfaced as a typed
|
|
171
|
+
error, not as "unconfigured").
|
|
172
|
+
"""
|
|
173
|
+
# Resolve the actual credential file across layouts. Current xurl keeps
|
|
174
|
+
# ~/.xurl/auth.yml inside the ~/.xurl directory; legacy installs wrote a
|
|
175
|
+
# flat ~/.xurl file. Honor whatever token_store_path() resolves to and
|
|
176
|
+
# check both shapes so a valid directory layout is never misread as a
|
|
177
|
+
# missing token store. Path stubs that cannot combine (e.g. an unreadable
|
|
178
|
+
# store stub without / support) still reach the typed error path below.
|
|
179
|
+
path = token_store_path()
|
|
180
|
+
try:
|
|
181
|
+
base = path.parent if path.name == "auth.yml" else path
|
|
182
|
+
except (AttributeError, TypeError):
|
|
183
|
+
# A path stub with no parent/name support: treat it as the file itself.
|
|
184
|
+
base = path
|
|
185
|
+
try:
|
|
186
|
+
# Use stat rather than is_file()/is_dir(): pathlib swallows OSError
|
|
187
|
+
# into False, which would silently turn a permission-denied store into
|
|
188
|
+
# "no token store" instead of the typed AUTH_ERROR.
|
|
189
|
+
candidates = [base / "auth.yml", base] if _is_dir(base) else [base, base / "auth.yml"]
|
|
190
|
+
except (TypeError, AttributeError):
|
|
191
|
+
# A path stub that cannot combine (no __truediv__): the path itself is
|
|
192
|
+
# the only candidate.
|
|
193
|
+
candidates = [path]
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
return (
|
|
196
|
+
AUTH_ERROR,
|
|
197
|
+
f"token store {base} unreadable: {type(exc).__name__}: {exc}",
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
path = next(c for c in candidates if _is_file(c))
|
|
201
|
+
except StopIteration:
|
|
202
|
+
return AUTH_MISSING, f"no token store at {token_store_path()}"
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
return (
|
|
205
|
+
AUTH_ERROR,
|
|
206
|
+
f"token store {base} unreadable: {type(exc).__name__}: {exc}",
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
return (
|
|
212
|
+
AUTH_ERROR,
|
|
213
|
+
f"token store {path} unreadable: {type(exc).__name__}: {exc}",
|
|
214
|
+
)
|
|
215
|
+
if any(marker in content for marker in _TOKEN_STORE_MARKERS):
|
|
216
|
+
return AUTH_OK, f"stored OAuth credentials found in {path}"
|
|
217
|
+
return AUTH_MISSING, f"token store {path} has no stored credentials"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def has_stored_auth() -> bool:
|
|
221
|
+
"""Local-only availability: xurl on PATH with stored credentials.
|
|
222
|
+
|
|
223
|
+
The doctor/safe-diagnose counterpart of ``is_available()`` — the same
|
|
224
|
+
"installed and authenticated" question answered from local evidence
|
|
225
|
+
only (PATH lookup + token store), never a live ``xurl whoami``. A
|
|
226
|
+
broken token store reads as unavailable here; the doctor probe layer
|
|
227
|
+
(``backends._probe_xurl``) reports that case as a typed error.
|
|
228
|
+
"""
|
|
229
|
+
return shutil.which("xurl") is not None and stored_auth_status()[0] == AUTH_OK
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Engine-authored fixed error strings (the same rule as x_api): xurl's
|
|
233
|
+
# stderr can echo the request, the bearer, or an account id, so it never
|
|
234
|
+
# reaches the outcome detail. Each string carries a marker that
|
|
235
|
+
# http.classify_failure recognizes.
|
|
236
|
+
ERR_PAYMENT_REQUIRED = "xurl: payment required (X API credits exhausted)"
|
|
237
|
+
ERR_UNAUTHORIZED = "xurl: unauthorized (bearer token rejected)"
|
|
238
|
+
ERR_FORBIDDEN = "xurl: forbidden (bearer token lacks access)"
|
|
239
|
+
ERR_RATE_LIMITED = "xurl: rate limit exceeded (X API)"
|
|
240
|
+
ERR_FAILED = "xurl: search failed"
|
|
241
|
+
# HTTP 400 from X's query grammar (for example a bare lowercase "and").
|
|
242
|
+
ERR_INVALID_REQUEST = "xurl: X rejected the query (invalid request)"
|
|
243
|
+
ERR_EMPTY_QUERY = "xurl: no searchable keywords left after removing X search operators"
|
|
244
|
+
ERR_INVALID_JSON = "xurl: invalid JSON from xurl"
|
|
245
|
+
ERR_NOT_FOUND = "xurl not found in PATH"
|
|
246
|
+
ERR_TIMED_OUT = "xurl search timed out (30s)"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _classify_cli_failure(output: str) -> str:
|
|
250
|
+
"""Map xurl's stderr/stdout to a fixed string via the shared classifier."""
|
|
251
|
+
text = output or ""
|
|
252
|
+
state = http.classify_failure(message=text)
|
|
253
|
+
if state == health.PAYMENT_REQUIRED:
|
|
254
|
+
return ERR_PAYMENT_REQUIRED
|
|
255
|
+
if state == health.AUTH_FAILED:
|
|
256
|
+
# The shared vocabulary has one auth state; split the wording only.
|
|
257
|
+
lowered = text.lower()
|
|
258
|
+
if "401" in lowered or "unauthorized" in lowered:
|
|
259
|
+
return ERR_UNAUTHORIZED
|
|
260
|
+
return ERR_FORBIDDEN
|
|
261
|
+
if state == health.RATE_LIMITED:
|
|
262
|
+
return ERR_RATE_LIMITED
|
|
263
|
+
# X's v2 problem type (".../2/problems/invalid-request") or its title
|
|
264
|
+
# ("Invalid Request"). Only the fixed string leaves; the body may echo
|
|
265
|
+
# the query or request headers.
|
|
266
|
+
lowered = text.lower()
|
|
267
|
+
if "invalid-request" in lowered or "invalid request" in lowered:
|
|
268
|
+
return ERR_INVALID_REQUEST
|
|
269
|
+
return ERR_FAILED
|
|
270
|
+
|
|
271
|
+
|
|
55
272
|
def search_x(
|
|
56
273
|
query: str,
|
|
57
274
|
depth: str = "default",
|
|
275
|
+
deadline: Optional[float] = None,
|
|
58
276
|
) -> Dict[str, Any]:
|
|
59
277
|
"""Search X via xurl CLI using X API v2 search/recent.
|
|
60
278
|
|
|
279
|
+
The topic is sanitized into space-joined keywords first
|
|
280
|
+
(``x_api.build_keyword_query``): X's v2 grammar rejects a bare lowercase
|
|
281
|
+
``and``/``or`` with HTTP 400 and treats ``from:``-style tokens, leading
|
|
282
|
+
``-``, parentheses and quotes as operators, and the pipeline always
|
|
283
|
+
passes the raw user topic here.
|
|
284
|
+
|
|
61
285
|
Args:
|
|
62
|
-
query: Search
|
|
286
|
+
query: Search topic (raw user text is fine; it is sanitized)
|
|
63
287
|
depth: "quick", "default", or "deep"
|
|
288
|
+
deadline: Optional shared wall-clock deadline (``time.monotonic()``
|
|
289
|
+
instant) from the X backend chain. The single subprocess timeout
|
|
290
|
+
is clamped to the time left; a call past the deadline never starts.
|
|
64
291
|
|
|
65
292
|
Returns:
|
|
66
293
|
Raw JSON response from X API v2 tweets/search/recent, or a dict
|
|
67
|
-
with an "error" key on failure.
|
|
294
|
+
with an "error" key holding a fixed string on failure. The CLI's
|
|
295
|
+
own output reaches only the debug log, never the error.
|
|
68
296
|
"""
|
|
297
|
+
search_query = x_api.build_keyword_query(query)
|
|
298
|
+
if not search_query:
|
|
299
|
+
return {"error": ERR_EMPTY_QUERY}
|
|
300
|
+
|
|
69
301
|
max_results = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
|
70
302
|
# X API v2 search/recent requires max_results in 10–100 range
|
|
71
303
|
max_results = max(10, min(100, max_results))
|
|
72
|
-
|
|
304
|
+
timeout = 30
|
|
305
|
+
if deadline is not None:
|
|
306
|
+
remaining = deadline - time.monotonic()
|
|
307
|
+
if remaining < 1:
|
|
308
|
+
return {"error": "xurl: chain budget exhausted"}
|
|
309
|
+
timeout = max(1, min(timeout, int(remaining)))
|
|
310
|
+
|
|
311
|
+
charge = None
|
|
73
312
|
try:
|
|
313
|
+
# --auth app (app-only bearer): xurl >=1.1 mis-signs OAuth1 requests
|
|
314
|
+
# whose query needs percent-encoding (spaces, parens, ...) -> 401.
|
|
315
|
+
# Bearer auth sends no signature, so multi-word queries work.
|
|
316
|
+
charge = usage.begin("x")
|
|
74
317
|
result = subprocess.run(
|
|
75
|
-
["xurl", "search",
|
|
318
|
+
["xurl", "search", search_query, "-n", str(max_results), "--auth", "app"],
|
|
76
319
|
capture_output=True,
|
|
77
320
|
text=True,
|
|
78
|
-
timeout=
|
|
321
|
+
timeout=timeout,
|
|
79
322
|
)
|
|
80
323
|
|
|
81
324
|
if result.returncode != 0:
|
|
82
325
|
error_text = result.stderr.strip() or result.stdout.strip()
|
|
83
|
-
|
|
326
|
+
classified = _classify_cli_failure(error_text)
|
|
327
|
+
# The raw CLI output can echo the bearer; only the fixed string
|
|
328
|
+
# and its size reach the debug log.
|
|
329
|
+
log.debug(f"xurl exit {result.returncode}: {classified} ({len(error_text)} chars)")
|
|
330
|
+
return {"error": classified}
|
|
84
331
|
|
|
85
332
|
return json.loads(result.stdout)
|
|
86
333
|
|
|
87
334
|
except FileNotFoundError:
|
|
88
|
-
|
|
335
|
+
usage.cancel(charge)
|
|
336
|
+
return {"error": ERR_NOT_FOUND}
|
|
89
337
|
except subprocess.TimeoutExpired:
|
|
90
|
-
return {"error":
|
|
91
|
-
except json.JSONDecodeError
|
|
92
|
-
return {"error":
|
|
338
|
+
return {"error": ERR_TIMED_OUT}
|
|
339
|
+
except json.JSONDecodeError:
|
|
340
|
+
return {"error": ERR_INVALID_JSON}
|
|
93
341
|
except Exception as exc:
|
|
94
|
-
return {"error": f"{type(exc).__name__}
|
|
342
|
+
return {"error": f"xurl: {type(exc).__name__}"}
|
|
95
343
|
|
|
96
344
|
|
|
97
345
|
def parse_x_response(
|
|
98
346
|
response: Dict[str, Any],
|
|
99
347
|
topic: str = "",
|
|
100
348
|
) -> List[Dict[str, Any]]:
|
|
101
|
-
"""Parse xurl search response into normalized item dicts.
|
|
349
|
+
"""Parse an xurl search response into normalized item dicts.
|
|
102
350
|
|
|
103
|
-
|
|
104
|
-
|
|
351
|
+
Delegates to the shared X API v2 parser (``x_api.parse_v2_response``)
|
|
352
|
+
so xurl and xapi never drift; only the id prefix differs.
|
|
105
353
|
|
|
106
354
|
Args:
|
|
107
355
|
response: Raw X API v2 response dict from search_x()
|
|
@@ -110,62 +358,7 @@ def parse_x_response(
|
|
|
110
358
|
Returns:
|
|
111
359
|
List of item dicts. Empty list on error or no results.
|
|
112
360
|
"""
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if "error" in response:
|
|
361
|
+
if isinstance(response, dict) and "error" in response:
|
|
116
362
|
_log(f"Error in response: {response['error']}")
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
data = response.get("data") or []
|
|
120
|
-
if not data:
|
|
121
|
-
return items
|
|
122
|
-
|
|
123
|
-
# Build author lookup from includes.users
|
|
124
|
-
authors: Dict[str, Dict[str, Any]] = {}
|
|
125
|
-
for user in (response.get("includes") or {}).get("users") or []:
|
|
126
|
-
authors[user["id"]] = user
|
|
127
|
-
|
|
128
|
-
for i, tweet in enumerate(data):
|
|
129
|
-
author_id = tweet.get("author_id", "")
|
|
130
|
-
author = authors.get(author_id, {})
|
|
131
|
-
username = author.get("username", "")
|
|
132
|
-
|
|
133
|
-
tweet_id = tweet.get("id", "")
|
|
134
|
-
url = f"https://x.com/{username}/status/{tweet_id}" if username else ""
|
|
135
|
-
|
|
136
|
-
# Parse public_metrics
|
|
137
|
-
engagement: Optional[Dict[str, Any]] = None
|
|
138
|
-
metrics = tweet.get("public_metrics") or {}
|
|
139
|
-
if metrics:
|
|
140
|
-
engagement = {
|
|
141
|
-
"likes": metrics.get("like_count", 0),
|
|
142
|
-
"reposts": metrics.get("retweet_count", 0),
|
|
143
|
-
"replies": metrics.get("reply_count", 0),
|
|
144
|
-
"quotes": metrics.get("quote_count", 0),
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
# Parse ISO 8601 date → YYYY-MM-DD
|
|
148
|
-
date: Optional[str] = None
|
|
149
|
-
created = tweet.get("created_at", "")
|
|
150
|
-
if created:
|
|
151
|
-
m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
|
|
152
|
-
if m:
|
|
153
|
-
date = m.group(1)
|
|
154
|
-
|
|
155
|
-
text = tweet.get("text", "").strip()
|
|
156
|
-
|
|
157
|
-
# Relevance score via shared token-overlap function
|
|
158
|
-
relevance = _compute_relevance(topic, text) if topic else 0.5
|
|
159
|
-
|
|
160
|
-
items.append({
|
|
161
|
-
"id": f"XURL{i + 1}",
|
|
162
|
-
"text": text[:500],
|
|
163
|
-
"url": url,
|
|
164
|
-
"author_handle": username,
|
|
165
|
-
"date": date,
|
|
166
|
-
"engagement": engagement,
|
|
167
|
-
"why_relevant": "",
|
|
168
|
-
"relevance": relevance,
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
return items
|
|
363
|
+
return []
|
|
364
|
+
return x_api.parse_v2_response(response, topic, None, id_prefix="XURL")
|