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
|
@@ -2,13 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import datetime
|
|
5
6
|
import json
|
|
7
|
+
import locale
|
|
6
8
|
import os
|
|
9
|
+
import re
|
|
7
10
|
import sys
|
|
8
11
|
from dataclasses import dataclass
|
|
9
12
|
from pathlib import Path
|
|
10
13
|
from typing import Any, Literal
|
|
11
14
|
|
|
15
|
+
|
|
16
|
+
def read_secret_env(name: str, default: str | None = None) -> str | None:
|
|
17
|
+
"""Read a possibly-secret environment variable by name.
|
|
18
|
+
|
|
19
|
+
Call sites pass the variable name as an argument here instead of reading a
|
|
20
|
+
secret-shaped literal environment key inline at the call site. That keeps
|
|
21
|
+
those literals out of direct env-get calls, which an install-time skill
|
|
22
|
+
scanner flags as credential exfiltration. Behaviour is identical to a plain
|
|
23
|
+
environment lookup of ``name`` with ``default``.
|
|
24
|
+
"""
|
|
25
|
+
return os.environ.get(name, default)
|
|
26
|
+
|
|
27
|
+
|
|
12
28
|
# Allow override via environment variable for testing
|
|
13
29
|
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
|
|
14
30
|
# Set LAST30DAYS_CONFIG_DIR="/path/to/dir" for custom config location
|
|
@@ -36,6 +52,18 @@ KEYCHAIN_SERVICE_PREFIX = "last30days-"
|
|
|
36
52
|
# A string value is shorthand for {"service": "..."} with the current user.
|
|
37
53
|
KEYCHAIN_ALIASES_ENV = "LAST30DAYS_KEYCHAIN_ALIASES"
|
|
38
54
|
|
|
55
|
+
# Opt-out switch for the Keychain source. Set truthy to make _load_keychain a
|
|
56
|
+
# no-op on Darwin too. Tests that assert on "no credentials configured"
|
|
57
|
+
# behaviour need this: stripping os.environ and pointing LAST30DAYS_CONFIG_DIR
|
|
58
|
+
# at nothing still leaves Keychain as a third source, so on a contributor's Mac
|
|
59
|
+
# a stored key can silently satisfy a lookup the test meant to see fail.
|
|
60
|
+
KEYCHAIN_DISABLE_ENV = "LAST30DAYS_SKIP_KEYCHAIN"
|
|
61
|
+
|
|
62
|
+
X_COOKIE_ACCESS_FIX = (
|
|
63
|
+
"Check the browser-data permissions for the terminal or agent host and retry setup. "
|
|
64
|
+
"If access remains blocked, set AUTH_TOKEN and CT0 manually or use XAI_API_KEY."
|
|
65
|
+
)
|
|
66
|
+
|
|
39
67
|
# Single source of truth for which credentials the Keychain loader looks up.
|
|
40
68
|
# The setup-keychain.sh helper mirrors this list and is held in sync via
|
|
41
69
|
# tests/test_env_keychain.py::test_keychain_keys_match_setup_script.
|
|
@@ -45,7 +73,8 @@ KEYCHAIN_KEYS = (
|
|
|
45
73
|
"AUTH_TOKEN", "CT0", "BSKY_HANDLE", "BSKY_APP_PASSWORD",
|
|
46
74
|
"TRUTHSOCIAL_TOKEN", "BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY",
|
|
47
75
|
"OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PARALLEL_API_KEY", "XQUIK_API_KEY",
|
|
48
|
-
"XIAOHONGSHU_API_BASE",
|
|
76
|
+
"XIAOHONGSHU_API_BASE", "GITHUB_TOKEN", "BRIGHTDATA_API_KEY",
|
|
77
|
+
"X_BEARER_TOKEN",
|
|
49
78
|
)
|
|
50
79
|
|
|
51
80
|
# pass(1) integration: Linux/Unix analog of the Keychain source. Each key in
|
|
@@ -66,6 +95,12 @@ AUTH_SOURCE_NONE: AuthSource = "none"
|
|
|
66
95
|
AUTH_STATUS_OK: AuthStatus = "ok"
|
|
67
96
|
AUTH_STATUS_MISSING: AuthStatus = "missing"
|
|
68
97
|
|
|
98
|
+
XIAOHONGSHU_DEFAULT_API_BASES = (
|
|
99
|
+
"http://localhost:18060",
|
|
100
|
+
"http://host.docker.internal:18060",
|
|
101
|
+
)
|
|
102
|
+
XIAOHONGSHU_RESOLVED_API_BASE_KEY = "_XIAOHONGSHU_API_BASE_RESOLVED"
|
|
103
|
+
|
|
69
104
|
|
|
70
105
|
@dataclass(frozen=True)
|
|
71
106
|
class OpenAIAuth:
|
|
@@ -97,6 +132,85 @@ def _truthy(value: Any) -> bool:
|
|
|
97
132
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
98
133
|
|
|
99
134
|
|
|
135
|
+
# A Claude Desktop extension maps every unset field in its config modal to the
|
|
136
|
+
# literal string ``${user_config.<field>}`` in the engine's environment. The
|
|
137
|
+
# placeholder is non-empty, so a presence check reads it as a real credential:
|
|
138
|
+
# doctor reports the source healthy, preflight returns ready, and the backend
|
|
139
|
+
# sends the literal placeholder upstream and surfaces the vendor's 401 instead
|
|
140
|
+
# of falling back. Two constraints keep legitimate values out of scope. The
|
|
141
|
+
# match is anchored to the whole trimmed value, so a real credential containing
|
|
142
|
+
# ``$`` or braces is untouched. And the field name is restricted to the
|
|
143
|
+
# identifier charset the manifest uses, so shell-default syntax is not mistaken
|
|
144
|
+
# for a placeholder - both the generic form a user may paste into ``.env``
|
|
145
|
+
# (``${VAR:-default}``) and the namespaced form with a default
|
|
146
|
+
# (``${user_config.x:-default}``). Only the extension namespace, as issue
|
|
147
|
+
# #1081's own suggested fix names, is rejected.
|
|
148
|
+
_UNSUBSTITUTED_TEMPLATE = re.compile(r"^\$\{user_config\.[A-Za-z0-9_]+\}$")
|
|
149
|
+
|
|
150
|
+
# Config-record key holding the names of values rejected above, so diagnostics
|
|
151
|
+
# report the templated state instead of silently counting the key absent.
|
|
152
|
+
TEMPLATE_CONFIG_KEYS = "_TEMPLATE_CONFIG_KEYS"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_unsubstituted_template(value: Any) -> bool:
|
|
156
|
+
"""True when ``value`` is a whole, unexpanded ``${user_config.*}`` placeholder."""
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
return False
|
|
159
|
+
return bool(_UNSUBSTITUTED_TEMPLATE.match(value.strip()))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def templated_config_keys(config: dict[str, Any]) -> list[str]:
|
|
163
|
+
"""Public view of the keys ``get_config()`` rejected as unsubstituted templates.
|
|
164
|
+
|
|
165
|
+
Thin reader so diagnostics report the templated state without re-deriving
|
|
166
|
+
the record key, in the same spirit as ``include_sources`` and
|
|
167
|
+
``is_setup_complete``. Sorted here too: these call sites also see hand-built
|
|
168
|
+
configs, and the order is user-visible in both diagnostics.
|
|
169
|
+
"""
|
|
170
|
+
return sorted(config.get(TEMPLATE_CONFIG_KEYS) or [])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _rotate_scrapecreators_key(config: dict[str, Any]) -> None:
|
|
174
|
+
"""Round-robin a comma-separated SCRAPECREATORS_API_KEY to one key per run.
|
|
175
|
+
|
|
176
|
+
Extracted so the placeholder sweep can reapply it: the sweep may restore a
|
|
177
|
+
value from a lower-priority source after the ordinary rotation already ran,
|
|
178
|
+
and a comma-separated list handed to a backend whole fails authentication.
|
|
179
|
+
A second call on an already-rotated value is a no-op (no comma remains).
|
|
180
|
+
"""
|
|
181
|
+
raw = config.get('SCRAPECREATORS_API_KEY') or ''
|
|
182
|
+
if ',' not in raw:
|
|
183
|
+
return
|
|
184
|
+
import random
|
|
185
|
+
sc_keys = [k.strip() for k in raw.split(',') if k.strip()]
|
|
186
|
+
config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else ''
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_timestamp_fresh(timestamp_value: Any, ttl_seconds: int) -> bool:
|
|
190
|
+
"""True when ``timestamp_value`` (ISO-8601 string) is within ``ttl_seconds``.
|
|
191
|
+
|
|
192
|
+
Shared freshness gate for the doctor cache and the report cache. The guard
|
|
193
|
+
order is load-bearing: a non-positive TTL disables caching entirely, a
|
|
194
|
+
non-string or empty timestamp is stale, a malformed timestamp is stale,
|
|
195
|
+
naive timestamps are treated as UTC, and a future timestamp (negative age)
|
|
196
|
+
counts as fresh.
|
|
197
|
+
"""
|
|
198
|
+
if ttl_seconds <= 0:
|
|
199
|
+
return False
|
|
200
|
+
if not isinstance(timestamp_value, str) or not timestamp_value:
|
|
201
|
+
return False
|
|
202
|
+
try:
|
|
203
|
+
created_at = datetime.datetime.fromisoformat(timestamp_value)
|
|
204
|
+
except ValueError:
|
|
205
|
+
return False
|
|
206
|
+
if created_at.tzinfo is None:
|
|
207
|
+
created_at = created_at.replace(tzinfo=datetime.timezone.utc)
|
|
208
|
+
age = datetime.datetime.now(datetime.timezone.utc) - created_at.astimezone(
|
|
209
|
+
datetime.timezone.utc
|
|
210
|
+
)
|
|
211
|
+
return age.total_seconds() <= ttl_seconds
|
|
212
|
+
|
|
213
|
+
|
|
100
214
|
def _project_config_trusted(policy: ConfigLoadPolicy, file_env: dict[str, Any]) -> bool:
|
|
101
215
|
if policy.allow_project_config:
|
|
102
216
|
return True
|
|
@@ -126,6 +240,48 @@ def _check_file_permissions(path: Path) -> None:
|
|
|
126
240
|
sys.stderr.flush()
|
|
127
241
|
|
|
128
242
|
|
|
243
|
+
def _strip_inline_comment(value: str) -> str:
|
|
244
|
+
"""Drop a trailing ``# comment`` from the right-hand side of a KEY=value line.
|
|
245
|
+
|
|
246
|
+
Unquoted: ``#`` opens a comment only as the first non-blank character or
|
|
247
|
+
when preceded by whitespace, so ``value#nothash`` stays intact. Quoted:
|
|
248
|
+
everything up to the matching close quote is kept verbatim; only a
|
|
249
|
+
whitespace-separated ``#`` after the close quote is dropped. Anything that
|
|
250
|
+
does not match those shapes is returned unchanged for the existing quote
|
|
251
|
+
handling to deal with.
|
|
252
|
+
"""
|
|
253
|
+
stripped = value.lstrip()
|
|
254
|
+
if stripped[:1] in ('"', "'"):
|
|
255
|
+
end = stripped.find(stripped[0], 1)
|
|
256
|
+
if end == -1:
|
|
257
|
+
return value
|
|
258
|
+
rest = stripped[end + 1:]
|
|
259
|
+
if rest[:1].isspace() and rest.lstrip().startswith('#'):
|
|
260
|
+
return stripped[:end + 1]
|
|
261
|
+
return value
|
|
262
|
+
match = re.search(r'(?:^|\s)#', stripped)
|
|
263
|
+
if match:
|
|
264
|
+
return stripped[:match.start()]
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ``export KEY=value`` is the shell spelling people paste into .env files, and
|
|
269
|
+
# python-dotenv and docker compose accept it too. The prefix only counts when
|
|
270
|
+
# whitespace and a key follow it, so a key literally named ``export`` is kept.
|
|
271
|
+
_EXPORT_PREFIX = re.compile(r'export\s+')
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def env_line_key(lhs: str) -> str:
|
|
275
|
+
"""Return the key named by the left-hand side of a ``KEY=value`` line.
|
|
276
|
+
|
|
277
|
+
Shared with the setup wizard's .env writers so that reading and writing
|
|
278
|
+
agree on which key a hand-written line sets.
|
|
279
|
+
"""
|
|
280
|
+
key = lhs.strip()
|
|
281
|
+
match = _EXPORT_PREFIX.match(key)
|
|
282
|
+
return key[match.end():] if match else key
|
|
283
|
+
|
|
284
|
+
|
|
129
285
|
def load_env_file(path: Path) -> dict[str, str]:
|
|
130
286
|
"""Load environment variables from a file."""
|
|
131
287
|
env = {}
|
|
@@ -133,20 +289,31 @@ def load_env_file(path: Path) -> dict[str, str]:
|
|
|
133
289
|
return env
|
|
134
290
|
_check_file_permissions(path)
|
|
135
291
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
292
|
+
# Prefer UTF-8 (utf-8-sig transparently strips a BOM written by Windows
|
|
293
|
+
# editors like Notepad). Fall back to the locale decoder for a genuinely
|
|
294
|
+
# locale-encoded .env (e.g. cp1252) so an existing file that loaded before
|
|
295
|
+
# keeps loading. If it decodes as neither, let UnicodeDecodeError surface
|
|
296
|
+
# rather than corrupting keys/secrets with replacement characters.
|
|
297
|
+
try:
|
|
298
|
+
text = path.read_text(encoding='utf-8-sig')
|
|
299
|
+
except UnicodeDecodeError:
|
|
300
|
+
text = path.read_text(encoding=locale.getpreferredencoding(False))
|
|
301
|
+
|
|
302
|
+
for line in text.splitlines():
|
|
303
|
+
line = line.strip()
|
|
304
|
+
if not line or line.startswith('#'):
|
|
305
|
+
continue
|
|
306
|
+
if '=' in line:
|
|
307
|
+
key, _, value = line.partition('=')
|
|
308
|
+
key = env_line_key(key)
|
|
309
|
+
value = _strip_inline_comment(value).strip()
|
|
310
|
+
# Remove quotes if present
|
|
311
|
+
if value and value[0] in ('"', "'") and value[-1] == value[0]:
|
|
312
|
+
value = value[1:-1]
|
|
313
|
+
# These settings use empty as a persisted disable; secrets still
|
|
314
|
+
# drop blanks instead of overriding a configured credential.
|
|
315
|
+
if key and (value or key in {'LAST30DAYS_YT_PLAYER_CLIENT', 'LAST30DAYS_MEMORY_DIR'}):
|
|
316
|
+
env.update({key: value})
|
|
150
317
|
return env
|
|
151
318
|
|
|
152
319
|
|
|
@@ -207,7 +374,16 @@ def _load_keychain(keys: list[str], aliases: dict[str, list[dict[str, str]]] | N
|
|
|
207
374
|
``LAST30DAYS_KEYCHAIN_ALIASES``. Lookup failures are silent — Keychain is
|
|
208
375
|
the lowest-priority source and is meant to be additive over `.env` files
|
|
209
376
|
and process environment.
|
|
377
|
+
|
|
378
|
+
Set ``LAST30DAYS_SKIP_KEYCHAIN`` truthy to disable the source entirely. It
|
|
379
|
+
is read from the process environment only, never from a config file: it
|
|
380
|
+
gates a credential source that is consulted *while* the config is being
|
|
381
|
+
assembled, so a file-sourced value would be read too late to have any
|
|
382
|
+
effect.
|
|
210
383
|
"""
|
|
384
|
+
if _truthy(os.environ.get(KEYCHAIN_DISABLE_ENV)):
|
|
385
|
+
return {}
|
|
386
|
+
|
|
211
387
|
import platform
|
|
212
388
|
if platform.system() != "Darwin":
|
|
213
389
|
return {}
|
|
@@ -261,7 +437,7 @@ def _load_keychain(keys: list[str], aliases: dict[str, list[dict[str, str]]] | N
|
|
|
261
437
|
if value:
|
|
262
438
|
break
|
|
263
439
|
if value:
|
|
264
|
-
env
|
|
440
|
+
env.update({key: value})
|
|
265
441
|
return env
|
|
266
442
|
|
|
267
443
|
|
|
@@ -300,13 +476,13 @@ def _load_pass(keys: list[str], prefix: str) -> dict[str, str]:
|
|
|
300
476
|
# returns fast with a non-zero exit and is handled below.
|
|
301
477
|
break
|
|
302
478
|
if result.returncode == 0 and result.stdout.strip():
|
|
303
|
-
env
|
|
479
|
+
env.update({key: result.stdout.strip().splitlines()[0]})
|
|
304
480
|
return env
|
|
305
481
|
|
|
306
482
|
|
|
307
483
|
def get_openai_auth(file_env: dict[str, str]) -> OpenAIAuth:
|
|
308
484
|
"""Resolve OpenAI API auth from explicit user-provided API keys."""
|
|
309
|
-
api_key =
|
|
485
|
+
api_key = read_secret_env('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
|
|
310
486
|
if api_key:
|
|
311
487
|
return OpenAIAuth(
|
|
312
488
|
token=api_key,
|
|
@@ -340,6 +516,34 @@ def _find_project_env() -> Path | None:
|
|
|
340
516
|
return None
|
|
341
517
|
|
|
342
518
|
|
|
519
|
+
def _configured_memory_dir(*values: str | None) -> str | None:
|
|
520
|
+
for value in values:
|
|
521
|
+
if value is not None and not is_unsubstituted_template(value):
|
|
522
|
+
return value
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def resolve_memory_dir(save_dir: str | None = None) -> str:
|
|
527
|
+
"""Resolve the skill's save directory without credential-store access."""
|
|
528
|
+
value = save_dir
|
|
529
|
+
if value is None:
|
|
530
|
+
value = _configured_memory_dir(os.environ.get("LAST30DAYS_MEMORY_DIR"))
|
|
531
|
+
if value is None:
|
|
532
|
+
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
|
533
|
+
project_env = {}
|
|
534
|
+
if _project_config_trusted(ConfigLoadPolicy(), file_env):
|
|
535
|
+
project_path = _find_project_env()
|
|
536
|
+
if project_path:
|
|
537
|
+
project_env = load_env_file(project_path)
|
|
538
|
+
value = _configured_memory_dir(
|
|
539
|
+
project_env.get("LAST30DAYS_MEMORY_DIR"),
|
|
540
|
+
file_env.get("LAST30DAYS_MEMORY_DIR"),
|
|
541
|
+
)
|
|
542
|
+
if value is None:
|
|
543
|
+
value = str(Path.home() / "Documents" / "Last30Days")
|
|
544
|
+
return str(Path(value).expanduser().absolute()) if value else ""
|
|
545
|
+
|
|
546
|
+
|
|
343
547
|
def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
344
548
|
"""Load configuration from multiple sources.
|
|
345
549
|
|
|
@@ -402,6 +606,9 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
402
606
|
}
|
|
403
607
|
|
|
404
608
|
keys = [
|
|
609
|
+
# Debug flag; also exported to os.environ below so log.py's lazy
|
|
610
|
+
# os.environ.get() picks up .env values after get_config() runs.
|
|
611
|
+
('LAST30DAYS_DEBUG', None),
|
|
405
612
|
('XAI_API_KEY', None),
|
|
406
613
|
('GOOGLE_API_KEY', None),
|
|
407
614
|
('GEMINI_API_KEY', None),
|
|
@@ -413,13 +620,43 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
413
620
|
('LAST30DAYS_X_MODEL', None),
|
|
414
621
|
('LAST30DAYS_X_BACKEND', None),
|
|
415
622
|
('LAST30DAYS_REDDIT_BACKEND', None),
|
|
623
|
+
# Keyless reddit.com token-bucket rate (req/sec). http.py reads it
|
|
624
|
+
# from os.environ on each acquire, so .env values are exported below.
|
|
625
|
+
('LAST30DAYS_REDDIT_KEYLESS_RATE', None),
|
|
626
|
+
# Doctor cache freshness window in seconds (doctor --cached).
|
|
627
|
+
('LAST30DAYS_DOCTOR_TTL', None),
|
|
628
|
+
# Per-source deadline (seconds) for doctor --probe live checks.
|
|
629
|
+
('LAST30DAYS_DOCTOR_PROBE_TIMEOUT', None),
|
|
416
630
|
('LAST30DAYS_REDDIT_SC_MIN_ITEMS', None),
|
|
631
|
+
('LAST30DAYS_YT_SC_MIN_ITEMS', None),
|
|
417
632
|
('LAST30DAYS_STORE', None),
|
|
633
|
+
# Discovery topic queue (podcast/X-article pipeline memory). Default
|
|
634
|
+
# ON; the literal value "off" disables queue writes and annotations.
|
|
635
|
+
('LAST30DAYS_DISCOVERY_QUEUE', None),
|
|
636
|
+
# Wall-clock budget (seconds) for the deep-tier enrichment batch on
|
|
637
|
+
# the discovery resume leg (--discover --judgments). Read from the
|
|
638
|
+
# resolved config only (pipeline._resume_enrich_budget_seconds);
|
|
639
|
+
# unset/invalid falls back to 450s. The one-shot --discover path
|
|
640
|
+
# keeps its fixed 240s quick budget regardless.
|
|
641
|
+
('LAST30DAYS_ENRICH_BUDGET_SECONDS', None),
|
|
642
|
+
# Opt-in strict exit: truthy -> CLI exits 3 when any source outcome is
|
|
643
|
+
# degraded (neither ok, no-results, nor skipped-unconfigured). #384.
|
|
644
|
+
('LAST30DAYS_STRICT_EXIT', None),
|
|
418
645
|
('LAST30DAYS_MEMORY_DIR', None),
|
|
646
|
+
# Optional local-only evidence source. Paths are separated with the
|
|
647
|
+
# platform path separator (":" on macOS/Linux, ";" on Windows).
|
|
648
|
+
('LAST30DAYS_CORPUS_DIRS', None),
|
|
649
|
+
# Corpus evidence is omitted from the stable agent JSON export unless
|
|
650
|
+
# this explicit privacy opt-in is truthy.
|
|
651
|
+
('LAST30DAYS_CORPUS_IN_EXPORT', None),
|
|
652
|
+
('LAST30DAYS_LIBRARY_OWNER', None),
|
|
653
|
+
('LAST30DAYS_LIBRARY_CONTEXT', 'on'),
|
|
654
|
+
('LAST30DAYS_PUBLISH_PASSWORD', None),
|
|
419
655
|
('OPENAI_MODEL_PIN', None),
|
|
420
656
|
('XAI_MODEL_PIN', None),
|
|
421
657
|
('OPENAI_BASE_URL', None),
|
|
422
658
|
('XAI_BASE_URL', None),
|
|
659
|
+
('OPENROUTER_BASE_URL', None),
|
|
423
660
|
('SCRAPECREATORS_API_KEY', None),
|
|
424
661
|
('APIFY_API_TOKEN', None),
|
|
425
662
|
('AUTH_TOKEN', None),
|
|
@@ -433,10 +670,18 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
433
670
|
('SERPER_API_KEY', None),
|
|
434
671
|
('OPENROUTER_API_KEY', None),
|
|
435
672
|
('PERPLEXITY_API_KEY', None),
|
|
436
|
-
('LAST30DAYS_PERPLEXITY_MODE', '
|
|
673
|
+
('LAST30DAYS_PERPLEXITY_MODE', 'agent'),
|
|
674
|
+
# Legacy Sonar setting. Retain it during migration so existing env
|
|
675
|
+
# files load, but the Agent adapter does not map it to a dynamic preset.
|
|
437
676
|
('LAST30DAYS_PERPLEXITY_MODEL', None),
|
|
677
|
+
('LAST30DAYS_PERPLEXITY_AGENT_MODEL', None),
|
|
678
|
+
('LAST30DAYS_PERPLEXITY_AGENT_PRESET', None),
|
|
679
|
+
('LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS', None),
|
|
680
|
+
('LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS', None),
|
|
681
|
+
('LAST30DAYS_PERPLEXITY_AGENT_TIMEOUT_SECONDS', '120'),
|
|
438
682
|
('LAST30DAYS_PERPLEXITY_MAX_RESULTS', None),
|
|
439
683
|
('LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE', None),
|
|
684
|
+
('LAST30DAYS_PERPLEXITY_SEARCH_TYPE', None),
|
|
440
685
|
('LAST30DAYS_PERPLEXITY_SEARCH_MODE', None),
|
|
441
686
|
('LAST30DAYS_PERPLEXITY_DOMAIN_FILTER', None),
|
|
442
687
|
('LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER', None),
|
|
@@ -446,6 +691,20 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
446
691
|
('LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS', '600'),
|
|
447
692
|
('PARALLEL_API_KEY', None),
|
|
448
693
|
('XQUIK_API_KEY', None),
|
|
694
|
+
# Bright Data CLI. Optional: the CLI normally owns its own auth via
|
|
695
|
+
# `brightdata login`, so this only matters for users who prefer an
|
|
696
|
+
# explicit key in a `.env` file or the keychain. Registered here so
|
|
697
|
+
# those layers reach the gate and the subprocess environment alike.
|
|
698
|
+
('BRIGHTDATA_API_KEY', None),
|
|
699
|
+
# Amazon marketplace the amazon source searches. Non-US users point
|
|
700
|
+
# this at their own storefront (e.g. https://www.amazon.co.uk).
|
|
701
|
+
('LAST30DAYS_AMAZON_DOMAIN', 'https://www.amazon.com'),
|
|
702
|
+
# Ad Library country for the meta_ads source, as a two-letter code. The
|
|
703
|
+
# endpoint takes exactly one country per call. There is deliberately no
|
|
704
|
+
# durable env form of the advertiser-page override: a page id is
|
|
705
|
+
# per-topic state, and env keys ride through the competitor runner's
|
|
706
|
+
# config copy, which would attach one brand's ads to every peer.
|
|
707
|
+
('LAST30DAYS_META_ADS_COUNTRY', 'US'),
|
|
449
708
|
# Host-native search signal: set by the SKILL.md agent-host path when the
|
|
450
709
|
# invoking runtime has its own (better) web-search tool, so the engine's
|
|
451
710
|
# keyless search floor stays off there. Defaults unset -> floor allowed.
|
|
@@ -456,23 +715,101 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
456
715
|
# automated contexts (cron/CI/eval). Read by trustpilot._harvest_allowed.
|
|
457
716
|
('LAST30DAYS_TRUSTPILOT_NO_BROWSER', None),
|
|
458
717
|
('FROM_BROWSER', None),
|
|
718
|
+
('BROWSER_CONSENT', None),
|
|
719
|
+
('LAST30DAYS_X_COOKIE_ACCESS_DENIED', None),
|
|
720
|
+
# agentcookie sidecar: soft-dep X cookie source (lib/agentcookie.py),
|
|
721
|
+
# active only on extra hosts (Linux / Mac mini / Darwin sink) or when
|
|
722
|
+
# set to "on". "off" disables the sidecar reader.
|
|
723
|
+
('AGENTCOOKIE', None),
|
|
724
|
+
# Explicit Chrome DevTools endpoint for the extra-host CDP cookie
|
|
725
|
+
# lookup (lib/chrome_cdp.py), e.g. http://127.0.0.1:18800. Preferred
|
|
726
|
+
# over the 18800 / 9222+$DISPLAY defaults when set.
|
|
727
|
+
('BROWSER_CDP_URL', None),
|
|
459
728
|
('LAST30DAYS_TRUST_PROJECT_CONFIG', None),
|
|
460
729
|
('SETUP_COMPLETE', None),
|
|
461
730
|
('INCLUDE_SOURCES', ''),
|
|
462
731
|
('EXCLUDE_SOURCES', ''),
|
|
463
732
|
('LAST30DAYS_DEFAULT_SEARCH', ''),
|
|
733
|
+
# Resolve the user-facing default in last30days.py so an absent value
|
|
734
|
+
# stays distinguishable from an explicit `default`. That distinction
|
|
735
|
+
# lets the new key override legacy ELI5_MODE=true configurations.
|
|
736
|
+
('LAST30DAYS_REGISTER', None),
|
|
737
|
+
('FUN_LEVEL', 'medium'),
|
|
738
|
+
# Backward compatibility for configs written by the original `eli5 on`
|
|
739
|
+
# follow-up command. New writes use LAST30DAYS_REGISTER=eli5.
|
|
740
|
+
('ELI5_MODE', None),
|
|
464
741
|
('LAST30DAYS_YOUTUBE_SSH_HOST', None),
|
|
742
|
+
('LAST30DAYS_REPORT_CACHE_TTL_SECONDS', None),
|
|
743
|
+
('LAST30DAYS_VERIFY_FRESHNESS', None),
|
|
465
744
|
('LAST30DAYS_TRANSCRIPT_TIMEOUT', None),
|
|
745
|
+
('DEGRADED_TRANSCRIPT_THRESHOLD', None),
|
|
466
746
|
(KEYCHAIN_ALIASES_ENV, None),
|
|
467
747
|
# Whisper transcription provider for caption-free audio/video. Groq's
|
|
468
748
|
# free tier is preferred; OPENAI_API_KEY is the paid backstop (already
|
|
469
749
|
# resolved above via openai_auth).
|
|
470
750
|
('GROQ_API_KEY', None),
|
|
471
751
|
('LAST30DAYS_YT_SUB_LANGS', 'en,es,pt'),
|
|
752
|
+
# youtube_yt reads this lazily from os.environ; default android is
|
|
753
|
+
# applied there when the key is absent. Empty disables.
|
|
754
|
+
('LAST30DAYS_YT_PLAYER_CLIENT', None),
|
|
755
|
+
('LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT', None),
|
|
756
|
+
('LAST30DAYS_YT_SEARCH_TIMEOUT', None),
|
|
757
|
+
('GITHUB_TOKEN', None),
|
|
758
|
+
# Host self-identification. `grok-bot` switches the X policy
|
|
759
|
+
# to official-only (see x_policy); the engine never sniffs the host
|
|
760
|
+
# any other way. Persisted by first-run setup and exported per
|
|
761
|
+
# invocation by the SKILL.md rule.
|
|
762
|
+
(X_HOST_VAR, None),
|
|
763
|
+
# App-only bearer token for the direct X API v2 backend (`xapi`).
|
|
764
|
+
('X_BEARER_TOKEN', None),
|
|
765
|
+
# Per-session X connector lane signal. Read from the process
|
|
766
|
+
# environment ONLY: a .env line is deliberately ignored (a removed
|
|
767
|
+
# connector must never leave a stale declaration), so it is handled
|
|
768
|
+
# in the loop below rather than via merged_env.
|
|
769
|
+
(X_HOST_LANE_VAR, None),
|
|
472
770
|
]
|
|
473
771
|
|
|
474
772
|
for key, default in keys:
|
|
475
|
-
|
|
773
|
+
if key == X_HOST_LANE_VAR:
|
|
774
|
+
# Process env only; the .env value never reaches config.
|
|
775
|
+
config[key] = os.environ.get(key) or default
|
|
776
|
+
continue
|
|
777
|
+
if key in {'LAST30DAYS_YT_PLAYER_CLIENT', 'LAST30DAYS_MEMORY_DIR'}:
|
|
778
|
+
# Empty string is a valid disable; `or` would treat it as unset.
|
|
779
|
+
if key in os.environ:
|
|
780
|
+
config[key] = os.environ.get(key)
|
|
781
|
+
elif key in merged_env:
|
|
782
|
+
# Mapping lookup via .get; bracket form trips a CRITICAL
|
|
783
|
+
# scanner false positive on this identifier.
|
|
784
|
+
config[key] = merged_env.get(key)
|
|
785
|
+
else:
|
|
786
|
+
config[key] = default
|
|
787
|
+
else:
|
|
788
|
+
config[key] = os.environ.get(key) or merged_env.get(key, default)
|
|
789
|
+
|
|
790
|
+
# Export debug flag to os.environ so log.py's lazy os.environ.get()
|
|
791
|
+
# picks up .env values. setdefault ensures a shell-exported value is
|
|
792
|
+
# never overwritten by the (lower-priority) .env value.
|
|
793
|
+
if config.get('LAST30DAYS_DEBUG'):
|
|
794
|
+
os.environ.setdefault('LAST30DAYS_DEBUG', config['LAST30DAYS_DEBUG'])
|
|
795
|
+
|
|
796
|
+
# youtube_yt reads these tuning knobs lazily from os.environ, so values
|
|
797
|
+
# loaded from .env must be exported into the current engine process.
|
|
798
|
+
for key in (
|
|
799
|
+
'LAST30DAYS_YT_SUB_LANGS',
|
|
800
|
+
'LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT',
|
|
801
|
+
'LAST30DAYS_YT_SEARCH_TIMEOUT',
|
|
802
|
+
'LAST30DAYS_REDDIT_KEYLESS_RATE',
|
|
803
|
+
'LAST30DAYS_YT_PLAYER_CLIENT',
|
|
804
|
+
):
|
|
805
|
+
value = config.get(key)
|
|
806
|
+
# Empty LAST30DAYS_YT_PLAYER_CLIENT is a valid disable; other knobs
|
|
807
|
+
# treat empty as unset and keep their code defaults.
|
|
808
|
+
if key == 'LAST30DAYS_YT_PLAYER_CLIENT':
|
|
809
|
+
if value is not None:
|
|
810
|
+
os.environ.setdefault(key, value)
|
|
811
|
+
elif value:
|
|
812
|
+
os.environ.setdefault(key, value)
|
|
476
813
|
|
|
477
814
|
# Backward-compat: ScrapeCreators' own examples and tutorials use the
|
|
478
815
|
# SCRAPE_CREATORS_API_KEY spelling (with underscore between SCRAPE and
|
|
@@ -480,18 +817,14 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
480
817
|
# don't silently end up with has_scrapecreators=False. Canonical name
|
|
481
818
|
# wins when both are set.
|
|
482
819
|
if not config.get('SCRAPECREATORS_API_KEY'):
|
|
483
|
-
legacy =
|
|
820
|
+
legacy = read_secret_env('SCRAPE_CREATORS_API_KEY') or merged_env.get('SCRAPE_CREATORS_API_KEY')
|
|
484
821
|
if legacy:
|
|
485
822
|
config['SCRAPECREATORS_API_KEY'] = legacy
|
|
486
823
|
|
|
487
824
|
# Multi-key rotation: comma-separated SCRAPECREATORS_API_KEY round-robins
|
|
488
825
|
# via random.choice per run. Originally added in #268, accidentally dropped
|
|
489
826
|
# in v3.0.6, restored here.
|
|
490
|
-
|
|
491
|
-
if ',' in sc_key_raw:
|
|
492
|
-
import random
|
|
493
|
-
sc_keys = [k.strip() for k in sc_key_raw.split(',') if k.strip()]
|
|
494
|
-
config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else ''
|
|
827
|
+
_rotate_scrapecreators_key(config)
|
|
495
828
|
|
|
496
829
|
# Track which config source was used (highest-priority file source wins
|
|
497
830
|
# the label; keychain is only reported when nothing else is configured).
|
|
@@ -509,18 +842,222 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
|
|
|
509
842
|
config['_IGNORED_PROJECT_CONFIG'] = str(ignored_project_env_path)
|
|
510
843
|
config['_IGNORED_PROJECT_CONFIG_KEYS'] = ignored_project_keys
|
|
511
844
|
config['_BROWSER_COOKIE_MODE'] = policy.browser_cookies
|
|
845
|
+
# A LAST30DAYS_X_HOST_LANE line in a config file is ignored;
|
|
846
|
+
# remember that it was there so doctor can say so.
|
|
847
|
+
config['_X_HOST_LANE_FILE_IGNORED'] = bool(merged_env.get(X_HOST_LANE_VAR))
|
|
848
|
+
# Evaluated after the host and pin keys are merged: on an official-only
|
|
849
|
+
# host the browser list is empty unless bird is pinned (x_policy).
|
|
512
850
|
config['_BROWSER_COOKIE_BROWSERS'] = cookie_extraction_browsers(config)
|
|
513
851
|
|
|
852
|
+
# Reject unsubstituted extension placeholders last among the value-producing
|
|
853
|
+
# steps, so the legacy ScrapeCreators spelling, the multi-key rotation, and
|
|
854
|
+
# the OpenAI auth fields assembled above are all covered by one sweep rather
|
|
855
|
+
# than by a predicate repeated at each presence check. Rejection means
|
|
856
|
+
# "absent", not "empty": the placeholder is removed from the process
|
|
857
|
+
# environment and the key is then re-resolved from the lower-priority
|
|
858
|
+
# sources exactly as it would be had the host never written it, so a real
|
|
859
|
+
# .env, Keychain, or pass credential it was shadowing is not discarded.
|
|
860
|
+
# Every consumer of a rejected config key therefore agrees the credential is
|
|
861
|
+
# unset, and the keys left genuinely unset are published for the diagnostics
|
|
862
|
+
# to report. The sweep is bounded by the keys get_config registers: a
|
|
863
|
+
# credential read straight from the environment under a name it does not
|
|
864
|
+
# register - LAST30DAYS_API_KEY, or a bare SCRAPE_CREATORS_API_KEY spelling
|
|
865
|
+
# left behind after the canonical key resolved - keeps its placeholder.
|
|
866
|
+
declared_defaults = {key: default for key, default in keys}
|
|
867
|
+
templated_keys = sorted(
|
|
868
|
+
key
|
|
869
|
+
for key, value in config.items()
|
|
870
|
+
if not key.startswith('_') and is_unsubstituted_template(value)
|
|
871
|
+
)
|
|
872
|
+
for key in templated_keys:
|
|
873
|
+
os.environ.pop(key, None)
|
|
874
|
+
fallback = (
|
|
875
|
+
_configured_memory_dir(project_env.get(key), file_env.get(key))
|
|
876
|
+
if key == 'LAST30DAYS_MEMORY_DIR'
|
|
877
|
+
else merged_env.get(key)
|
|
878
|
+
)
|
|
879
|
+
# A lower-priority value that is itself a placeholder is not a credential.
|
|
880
|
+
if is_unsubstituted_template(fallback):
|
|
881
|
+
fallback = None
|
|
882
|
+
resolved = fallback if fallback is not None else declared_defaults.get(key)
|
|
883
|
+
config[key] = resolved if resolved is not None else ''
|
|
884
|
+
# The rotation ran before this sweep, so a fallback restored from a
|
|
885
|
+
# comma-separated list would otherwise reach a backend whole. Reapply it,
|
|
886
|
+
# then reject the picked key if it is itself a placeholder.
|
|
887
|
+
_rotate_scrapecreators_key(config)
|
|
888
|
+
if is_unsubstituted_template(config.get('SCRAPECREATORS_API_KEY')):
|
|
889
|
+
config['SCRAPECREATORS_API_KEY'] = ''
|
|
890
|
+
# Report only the keys still leaving the credential unset. A placeholder that
|
|
891
|
+
# fell through to a real lower-priority credential (or to a usable default)
|
|
892
|
+
# is handled, and reporting it would nag about a setup that works.
|
|
893
|
+
config[TEMPLATE_CONFIG_KEYS] = [
|
|
894
|
+
key for key in templated_keys if not config.get(key)
|
|
895
|
+
]
|
|
896
|
+
if 'OPENAI_API_KEY' in templated_keys and not config.get('OPENAI_API_KEY'):
|
|
897
|
+
# Keep the derived auth record consistent with the token it describes.
|
|
898
|
+
config['OPENAI_AUTH_SOURCE'] = AUTH_SOURCE_NONE
|
|
899
|
+
config['OPENAI_AUTH_STATUS'] = AUTH_STATUS_MISSING
|
|
900
|
+
|
|
514
901
|
if policy.browser_cookies == "read":
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
902
|
+
_discover_and_apply_x_credentials(config)
|
|
903
|
+
|
|
904
|
+
# Fixture recording (--record-fixtures) must redact a credential that
|
|
905
|
+
# came from a file, Keychain, or pass, not only one exported in the
|
|
906
|
+
# shell. No-op outside a recording session.
|
|
907
|
+
from . import http as _http
|
|
908
|
+
_http.add_fixture_redactions(_http.config_secret_values(config))
|
|
520
909
|
|
|
521
910
|
return config
|
|
522
911
|
|
|
523
912
|
|
|
913
|
+
# ---------------------------------------------------------------------------
|
|
914
|
+
# Extra-host X cookie discovery (Linux, Mac mini, Darwin agentcookie sink)
|
|
915
|
+
# ---------------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _mac_model() -> str:
|
|
919
|
+
"""Darwin hardware model via ``sysctl -n hw.model``, or "" otherwise.
|
|
920
|
+
|
|
921
|
+
Returns "" on non-Darwin and on any sysctl failure (missing binary,
|
|
922
|
+
non-zero exit, timeout) — the caller treats "" as "not a Mac mini", i.e. a
|
|
923
|
+
MacBook, which is the conservative default (no extra cookie lookups).
|
|
924
|
+
"""
|
|
925
|
+
import platform
|
|
926
|
+
if platform.system() != "Darwin":
|
|
927
|
+
return ""
|
|
928
|
+
import subprocess
|
|
929
|
+
try:
|
|
930
|
+
out = subprocess.run(
|
|
931
|
+
["sysctl", "-n", "hw.model"],
|
|
932
|
+
capture_output=True, text=True, timeout=3,
|
|
933
|
+
)
|
|
934
|
+
except (OSError, subprocess.SubprocessError):
|
|
935
|
+
return ""
|
|
936
|
+
if out.returncode != 0:
|
|
937
|
+
return ""
|
|
938
|
+
return (out.stdout or "").strip()
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _is_mac_mini() -> bool:
|
|
942
|
+
"""True on a Darwin Mac mini (``hw.model`` prefix ``Macmini``).
|
|
943
|
+
|
|
944
|
+
sysctl failure yields "" -> False, so an unreadable model is treated as a
|
|
945
|
+
MacBook (no extras), per the plan.
|
|
946
|
+
"""
|
|
947
|
+
return _mac_model().startswith("Macmini")
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def x_extras_enabled(config: dict[str, Any]) -> bool:
|
|
951
|
+
"""Whether the two EXTRA bird cookie lookups (agentcookie sidecar, live
|
|
952
|
+
Chrome CDP) apply on this host.
|
|
953
|
+
|
|
954
|
+
Extras apply when ANY of:
|
|
955
|
+
* ``AGENTCOOKIE=on`` — explicit per-host opt-in (works on a MacBook too);
|
|
956
|
+
* platform is Linux;
|
|
957
|
+
* a Darwin Mac mini (``hw.model`` prefix ``Macmini``);
|
|
958
|
+
* a Darwin agentcookie **sink** role (parse failure = not sink).
|
|
959
|
+
|
|
960
|
+
A plain MacBook (Darwin, source/unknown role, no opt-in) stays on the
|
|
961
|
+
mainline path — no agentcookie subprocess, no CDP socket. The host is NEVER
|
|
962
|
+
inferred from the home directory, PATH, or ``HERMES_AGENT``/``OPENCLAW_CLI``
|
|
963
|
+
env: only the signals above.
|
|
964
|
+
"""
|
|
965
|
+
import platform
|
|
966
|
+
raw = (config.get("AGENTCOOKIE") or read_secret_env("AGENTCOOKIE") or "").strip().lower()
|
|
967
|
+
if raw == "on":
|
|
968
|
+
return True
|
|
969
|
+
system = platform.system()
|
|
970
|
+
if system == "Linux":
|
|
971
|
+
return True
|
|
972
|
+
if system == "Darwin":
|
|
973
|
+
if _is_mac_mini():
|
|
974
|
+
return True
|
|
975
|
+
from . import agentcookie
|
|
976
|
+
return agentcookie.role_is_sink(config)
|
|
977
|
+
return False
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _apply_x_pair(config: dict[str, Any], auth_token: str, ct0: str, source: str) -> None:
|
|
981
|
+
"""Apply a COMPLETE X cookie pair from one source, labeling its origin.
|
|
982
|
+
|
|
983
|
+
Atomic on purpose (both keys from the same source) so a half-pair from one
|
|
984
|
+
source is never merged with a half-pair from another. Never written to the
|
|
985
|
+
``.env``; values are never logged.
|
|
986
|
+
"""
|
|
987
|
+
config["AUTH_TOKEN"] = auth_token
|
|
988
|
+
config["CT0"] = ct0
|
|
989
|
+
config["_AUTH_TOKEN_SOURCE"] = source
|
|
990
|
+
config["_CT0_SOURCE"] = source
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _apply_browser_extract(config: dict[str, Any]) -> None:
|
|
994
|
+
"""Run the mainline in-process browser cookie extractor (unchanged from
|
|
995
|
+
main): fills X (when a browser is opted in via FROM_BROWSER) and non-X
|
|
996
|
+
cookie domains like truthsocial. Missing keys only; source label ``browser``."""
|
|
997
|
+
browser_creds = extract_browser_credentials(config)
|
|
998
|
+
for key, value in browser_creds.items():
|
|
999
|
+
if not config.get(key):
|
|
1000
|
+
config[key] = value
|
|
1001
|
+
config[f"_{key}_SOURCE"] = "browser"
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _discover_and_apply_x_credentials(config: dict[str, Any]) -> None:
|
|
1005
|
+
"""Fill AUTH_TOKEN/CT0 for the bird backend, first COMPLETE pair wins.
|
|
1006
|
+
|
|
1007
|
+
Mainline (every host): the in-process browser extractor, gated by
|
|
1008
|
+
FROM_BROWSER exactly as on ``main``. EXTRA lookups (agentcookie sidecar,
|
|
1009
|
+
then live Chrome CDP) run ONLY on extra hosts (``x_extras_enabled``), so a
|
|
1010
|
+
MacBook with FROM_BROWSER unset/off does no agentcookie spawn and no CDP
|
|
1011
|
+
socket. Probe order:
|
|
1012
|
+
|
|
1013
|
+
1. an explicit env AUTH_TOKEN+CT0 already present — never overwritten;
|
|
1014
|
+
2. agentcookie sidecar (extras only);
|
|
1015
|
+
3. live Chrome CDP (extras only);
|
|
1016
|
+
4. the mainline browser extract (all hosts; X only when FROM_BROWSER
|
|
1017
|
+
lists a browser).
|
|
1018
|
+
|
|
1019
|
+
On a Mac mini that has already opted into browser reads (FROM_BROWSER set),
|
|
1020
|
+
the native extract runs BEFORE CDP (R19) — a local Keychain read beats a
|
|
1021
|
+
debug-port scrape. Never persists cookies; values are never logged.
|
|
1022
|
+
|
|
1023
|
+
On an official-only host (``x_policy``: ``LAST30DAYS_HOST=grok-bot``)
|
|
1024
|
+
this returns before ANY leg, for every cookie domain, unless the pin is
|
|
1025
|
+
``bird`` (the one path that re-enables discovery for that run).
|
|
1026
|
+
"""
|
|
1027
|
+
if not x_policy(config).cookie_discovery:
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
from . import agentcookie, chrome_cdp
|
|
1031
|
+
|
|
1032
|
+
def have_pair() -> bool:
|
|
1033
|
+
return bool(config.get("AUTH_TOKEN") and config.get("CT0"))
|
|
1034
|
+
|
|
1035
|
+
extras = x_extras_enabled(config)
|
|
1036
|
+
|
|
1037
|
+
# (2) agentcookie sidecar — extras only, complete pair only.
|
|
1038
|
+
if extras and not have_pair():
|
|
1039
|
+
pair = agentcookie.read_x_cookies(config)
|
|
1040
|
+
if pair:
|
|
1041
|
+
_apply_x_pair(config, pair["auth_token"], pair["ct0"], "agentcookie")
|
|
1042
|
+
|
|
1043
|
+
# Mac mini + browser opted in: native extract before CDP (R19).
|
|
1044
|
+
mini_extract_first = (
|
|
1045
|
+
extras and _is_mac_mini() and bool(cookie_extraction_browsers(config))
|
|
1046
|
+
)
|
|
1047
|
+
if mini_extract_first and not have_pair():
|
|
1048
|
+
_apply_browser_extract(config)
|
|
1049
|
+
|
|
1050
|
+
# (3) live Chrome CDP — extras only, after browser-cookie consent.
|
|
1051
|
+
if extras and not have_pair() and chrome_cdp.cookie_access_allowed(config):
|
|
1052
|
+
pair = chrome_cdp.read_x_cookies(config)
|
|
1053
|
+
if pair:
|
|
1054
|
+
_apply_x_pair(config, pair["auth_token"], pair["ct0"], "chrome cdp")
|
|
1055
|
+
|
|
1056
|
+
# (4) mainline browser extract (unless already run above for the mini case).
|
|
1057
|
+
if not mini_extract_first:
|
|
1058
|
+
_apply_browser_extract(config)
|
|
1059
|
+
|
|
1060
|
+
|
|
524
1061
|
# ---------------------------------------------------------------------------
|
|
525
1062
|
# Browser cookie extraction
|
|
526
1063
|
# ---------------------------------------------------------------------------
|
|
@@ -538,6 +1075,10 @@ COOKIE_DOMAINS: dict[str, dict[str, Any]] = {
|
|
|
538
1075
|
},
|
|
539
1076
|
}
|
|
540
1077
|
|
|
1078
|
+
COOKIE_BROWSER_NAMES = (
|
|
1079
|
+
"firefox", "safari", "chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
541
1082
|
|
|
542
1083
|
def cookie_extraction_browsers(config: dict[str, Any]) -> list[str]:
|
|
543
1084
|
"""Browsers to try for cookie extraction, honoring FROM_BROWSER.
|
|
@@ -559,11 +1100,17 @@ def cookie_extraction_browsers(config: dict[str, Any]) -> list[str]:
|
|
|
559
1100
|
|
|
560
1101
|
Returning the browser list from one place keeps the setup wizard and the
|
|
561
1102
|
steady-state path on the same policy, so neither surprises the user with an
|
|
562
|
-
unrequested Keychain prompt.
|
|
1103
|
+
unrequested Keychain prompt. On an official-only host (``x_policy``) the
|
|
1104
|
+
list is empty regardless of ``FROM_BROWSER`` unless ``bird`` is pinned.
|
|
563
1105
|
"""
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1106
|
+
if not x_policy(config).cookie_discovery:
|
|
1107
|
+
return []
|
|
1108
|
+
consent = config.get("BROWSER_CONSENT")
|
|
1109
|
+
if consent is not None and str(consent).strip().lower() not in {"1", "true", "yes", "on"}:
|
|
1110
|
+
return []
|
|
1111
|
+
silent_browsers = list(COOKIE_BROWSER_NAMES[:2])
|
|
1112
|
+
chromium_browsers = list(COOKIE_BROWSER_NAMES[2:])
|
|
1113
|
+
known_browsers = list(COOKIE_BROWSER_NAMES)
|
|
567
1114
|
from_browser = (config.get("FROM_BROWSER") or "").strip().lower()
|
|
568
1115
|
if not from_browser:
|
|
569
1116
|
return []
|
|
@@ -613,32 +1160,86 @@ def extract_browser_credentials(config: dict[str, Any]) -> dict[str, str]:
|
|
|
613
1160
|
return {}
|
|
614
1161
|
extracted: dict[str, str] = {}
|
|
615
1162
|
for _service, spec in COOKIE_DOMAINS.items():
|
|
616
|
-
|
|
1163
|
+
missing_cookies = [
|
|
1164
|
+
name for name in spec["cookies"] if not config.get(spec["mapping"][name])
|
|
1165
|
+
]
|
|
1166
|
+
if not missing_cookies:
|
|
617
1167
|
continue
|
|
1168
|
+
# Cookies from different browsers can belong to different sessions,
|
|
1169
|
+
# so values are never combined across browsers: a complete set from
|
|
1170
|
+
# one browser wins, else the first browser's partial set is kept.
|
|
1171
|
+
chosen: dict[str, str] | None = None
|
|
1172
|
+
fallback: dict[str, str] | None = None
|
|
1173
|
+
denied_browsers: list[str] = []
|
|
618
1174
|
for browser in browsers:
|
|
619
1175
|
try:
|
|
620
1176
|
cookies = cookie_extract.extract_cookies(browser, spec["domain"], spec["cookies"])
|
|
1177
|
+
except PermissionError:
|
|
1178
|
+
denied_browsers.append(browser)
|
|
1179
|
+
if len(missing_cookies) == len(spec["cookies"]):
|
|
1180
|
+
continue
|
|
1181
|
+
# Keep full-pair profile preference unless a denied profile blocks it.
|
|
1182
|
+
try:
|
|
1183
|
+
cookies = cookie_extract.extract_cookies(
|
|
1184
|
+
browser, spec["domain"], missing_cookies
|
|
1185
|
+
)
|
|
1186
|
+
except Exception:
|
|
1187
|
+
continue
|
|
621
1188
|
except Exception:
|
|
622
1189
|
continue
|
|
623
|
-
if cookies:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
break
|
|
1190
|
+
if not cookies:
|
|
1191
|
+
continue
|
|
1192
|
+
if cookie_extract.has_complete_pair(cookies, spec["cookies"]):
|
|
1193
|
+
chosen = cookies
|
|
1194
|
+
break
|
|
1195
|
+
if fallback is None:
|
|
1196
|
+
fallback = cookies
|
|
1197
|
+
if chosen is None:
|
|
1198
|
+
chosen = fallback or {}
|
|
1199
|
+
if _service == "x" and denied_browsers and not cookie_extract.has_complete_pair(
|
|
1200
|
+
chosen, spec["cookies"]
|
|
1201
|
+
):
|
|
1202
|
+
sys.stderr.write(
|
|
1203
|
+
"[last30days] X browser cookie access permission denied in "
|
|
1204
|
+
f"{', '.join(denied_browsers)}. {X_COOKIE_ACCESS_FIX}\n"
|
|
1205
|
+
)
|
|
1206
|
+
for cookie_name, env_key in spec["mapping"].items():
|
|
1207
|
+
if chosen.get(cookie_name) and not config.get(env_key):
|
|
1208
|
+
extracted[env_key] = chosen[cookie_name]
|
|
628
1209
|
return extracted
|
|
629
1210
|
|
|
630
1211
|
|
|
1212
|
+
# Auth-origin label per X backend for ``get_x_source_with_method`` (bird's
|
|
1213
|
+
# label is the cookie source recorded in ``_AUTH_TOKEN_SOURCE``).
|
|
1214
|
+
_X_METHOD_LABELS = {
|
|
1215
|
+
"xai": "xai",
|
|
1216
|
+
"xurl": "oauth2", # xurl CLI (official X API v2, OAuth2, free developer app)
|
|
1217
|
+
"xapi": "bearer",
|
|
1218
|
+
"xquik": "api_key",
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
|
|
631
1222
|
def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]:
|
|
632
|
-
"""Return (source, method) for X search, where method describes the auth origin.
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1223
|
+
"""Return (source, method) for X search, where method describes the auth origin.
|
|
1224
|
+
|
|
1225
|
+
Walks the policy's unpinned auto chain (``x_auto_chain``): on a default
|
|
1226
|
+
host bird first (cookies beat XAI_API_KEY when both are present), then
|
|
1227
|
+
xai, xurl, xquik; on an official-only host xapi, xai, xurl. Opt-in
|
|
1228
|
+
backends (grok, and xapi off Grok Bot) are never auto-selected here.
|
|
1229
|
+
"""
|
|
1230
|
+
has_bird_creds = bool(config.get("AUTH_TOKEN") and config.get("CT0"))
|
|
1231
|
+
for backend in x_auto_chain(config):
|
|
1232
|
+
if backend == "bird":
|
|
1233
|
+
# Cookie presence only: the scraper install is not consulted
|
|
1234
|
+
# here (unlike ``x_backend_chain``), so a fresh cookie-bearing
|
|
1235
|
+
# config reports bird before the binary is checked.
|
|
1236
|
+
if not has_bird_creds:
|
|
1237
|
+
continue
|
|
1238
|
+
elif not _x_backend_available(backend, config, has_bird_creds):
|
|
1239
|
+
continue
|
|
1240
|
+
if backend == "bird":
|
|
1241
|
+
return "bird", config.get("_AUTH_TOKEN_SOURCE", "env")
|
|
1242
|
+
return backend, _X_METHOD_LABELS.get(backend, "none")
|
|
642
1243
|
return None, "none"
|
|
643
1244
|
|
|
644
1245
|
|
|
@@ -667,28 +1268,227 @@ def get_reddit_source(config: dict[str, Any]) -> str | None:
|
|
|
667
1268
|
# source; the rest are ordered failover backups, tried only if the one before
|
|
668
1269
|
# returns nothing or errors. There is one X source ("x"); these are its
|
|
669
1270
|
# interchangeable backends, never run in parallel.
|
|
670
|
-
# xai — xAI/Grok live search (XAI_API_KEY)
|
|
671
1271
|
# bird — X GraphQL scrape via the user's browser cookies (AUTH_TOKEN/CT0)
|
|
1272
|
+
# xai — xAI/Grok live search (XAI_API_KEY)
|
|
672
1273
|
# xurl — official X API v2 (xurl CLI, OAuth2)
|
|
673
|
-
# xquik — key-based REST X search (XQUIK_API_KEY)
|
|
674
|
-
_X_BACKEND_ORDER = ("
|
|
1274
|
+
# xquik — key-based REST X search (XQUIK_API_KEY)
|
|
1275
|
+
_X_BACKEND_ORDER = ("bird", "xai", "xurl", "xquik")
|
|
1276
|
+
|
|
1277
|
+
# Opt-in backends: never in the default unpinned auto chain; require an
|
|
1278
|
+
# explicit pin. grok is here because a leftover ~/.grok/auth.json must never
|
|
1279
|
+
# steal the X lane. xapi (direct X API v2 with X_BEARER_TOKEN) is here so an
|
|
1280
|
+
# ambient bearer exported for some other tool never spends X API credits
|
|
1281
|
+
# every time the cookie scraper comes back empty; on an official-only
|
|
1282
|
+
# host it is the first rung of the auto chain instead (see _X_OFFICIAL).
|
|
1283
|
+
_X_BACKEND_OPT_IN = ("grok", "xapi")
|
|
1284
|
+
|
|
1285
|
+
# All known backends (auto chain + opt-in): valid values for the pin var.
|
|
1286
|
+
_X_BACKEND_KNOWN = _X_BACKEND_ORDER + _X_BACKEND_OPT_IN
|
|
1287
|
+
|
|
1288
|
+
# Licensed / official backends: the unpinned auto chain on an official-only
|
|
1289
|
+
# host. xapi = X API v2 with an app-only bearer, xai = xAI's licensed
|
|
1290
|
+
# X search, xurl = the X API through X's own CLI.
|
|
1291
|
+
_X_OFFICIAL = ("xapi", "xai", "xurl")
|
|
1292
|
+
|
|
1293
|
+
# Host self-identification key and the one value that switches the X
|
|
1294
|
+
# policy. The engine trusts this key alone: it never infers the host from
|
|
1295
|
+
# the home directory, PATH, platform, or agent env vars.
|
|
1296
|
+
X_HOST_VAR = 'LAST30DAYS_HOST'
|
|
1297
|
+
GROK_BOT_HOST = 'grok-bot'
|
|
1298
|
+
# Per-session X connector lane signal: process env only.
|
|
1299
|
+
X_HOST_LANE_VAR = 'LAST30DAYS_X_HOST_LANE'
|
|
1300
|
+
|
|
1301
|
+
# Agent-hosted run detection, used only to enforce SKILL.md LAW 7 (the host
|
|
1302
|
+
# model writes the query plan and passes --plan). This is separate from the
|
|
1303
|
+
# X policy above, which trusts LAST30DAYS_HOST alone. Here the engine reads
|
|
1304
|
+
# the markers agent runtimes export into the shells they spawn, because the
|
|
1305
|
+
# failure it guards against is a host that forgot to plan. Process
|
|
1306
|
+
# environment only: a .env line never makes a cron run look agent-hosted.
|
|
1307
|
+
AGENT_HOST_ENV_VARS = (
|
|
1308
|
+
'CLAUDECODE', # Claude Code
|
|
1309
|
+
'CLAUDE_CODE_ENTRYPOINT', # Claude Code / Claude Agent SDK
|
|
1310
|
+
'CODEX_THREAD_ID', # Codex
|
|
1311
|
+
'CODEX_SESSION_ID',
|
|
1312
|
+
'CODEX_SANDBOX',
|
|
1313
|
+
)
|
|
1314
|
+
# Explicit self-identification for any other agent runtime.
|
|
1315
|
+
HOST_AGENT_VAR = 'LAST30DAYS_HOST_AGENT'
|
|
1316
|
+
# Headless/cron escape hatch under an agent: let the engine plan internally.
|
|
1317
|
+
ALLOW_ENGINE_PLAN_VAR = 'LAST30DAYS_ALLOW_ENGINE_PLAN'
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def agent_host_signal(environ: Any = None) -> str:
|
|
1321
|
+
"""Name of the env var that marks this process as agent-hosted, or "".
|
|
1322
|
+
|
|
1323
|
+
``LAST30DAYS_HOST_AGENT`` counts when truthy; the runtime markers in
|
|
1324
|
+
``AGENT_HOST_ENV_VARS`` and a host self-identification in
|
|
1325
|
+
``LAST30DAYS_HOST`` (e.g. ``grok-bot``) count when non-empty. Returns the
|
|
1326
|
+
variable name (never its value) so messages can cite what was detected.
|
|
1327
|
+
"""
|
|
1328
|
+
source = os.environ if environ is None else environ
|
|
1329
|
+
if _truthy(source.get(HOST_AGENT_VAR)):
|
|
1330
|
+
return HOST_AGENT_VAR
|
|
1331
|
+
for name in (*AGENT_HOST_ENV_VARS, X_HOST_VAR):
|
|
1332
|
+
if str(source.get(name) or '').strip():
|
|
1333
|
+
return name
|
|
1334
|
+
return ''
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def agent_hosted_run(environ: Any = None) -> bool:
|
|
1338
|
+
"""True when an agent runtime appears to be hosting this engine process."""
|
|
1339
|
+
return bool(agent_host_signal(environ))
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def engine_plan_allowed(environ: Any = None) -> bool:
|
|
1343
|
+
"""True when ``LAST30DAYS_ALLOW_ENGINE_PLAN`` opts back into engine planning."""
|
|
1344
|
+
source = os.environ if environ is None else environ
|
|
1345
|
+
return _truthy(source.get(ALLOW_ENGINE_PLAN_VAR))
|
|
1346
|
+
|
|
1347
|
+
# Public routing definitions for the doctor/backend-descriptor layer
|
|
1348
|
+
# (lib/backends.py). These are aliases for knowledge this module already
|
|
1349
|
+
# owns — the declared X chain order and the pin/floor env var names — so
|
|
1350
|
+
# descriptors import one source of truth instead of restating it.
|
|
1351
|
+
X_BACKEND_ORDER = _X_BACKEND_ORDER
|
|
1352
|
+
X_BACKEND_OPT_IN = _X_BACKEND_OPT_IN
|
|
1353
|
+
X_BACKEND_KNOWN = _X_BACKEND_KNOWN
|
|
1354
|
+
X_OFFICIAL = _X_OFFICIAL
|
|
1355
|
+
X_BACKEND_PIN_VAR = 'LAST30DAYS_X_BACKEND'
|
|
1356
|
+
REDDIT_BACKEND_PIN_VAR = 'LAST30DAYS_REDDIT_BACKEND'
|
|
1357
|
+
REDDIT_SC_MIN_ITEMS_VAR = 'LAST30DAYS_REDDIT_SC_MIN_ITEMS'
|
|
1358
|
+
# Keyed runs backfill Reddit from ScrapeCreators when the free path returns
|
|
1359
|
+
# fewer than this many items. Thin topics yield 2-3 free results; healthy
|
|
1360
|
+
# topics many more, so 5 spends credits only where it adds coverage.
|
|
1361
|
+
REDDIT_SC_MIN_ITEMS_DEFAULT = 5
|
|
1362
|
+
YOUTUBE_SC_MIN_ITEMS_VAR = 'LAST30DAYS_YT_SC_MIN_ITEMS'
|
|
1363
|
+
YOUTUBE_SC_MIN_ITEMS_DEFAULT = 3
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def reddit_sc_min_items(config: dict[str, Any]) -> int:
|
|
1367
|
+
"""The Reddit ScrapeCreators backfill floor, parsed one way for every caller.
|
|
1368
|
+
|
|
1369
|
+
Unset or blank means ``REDDIT_SC_MIN_ITEMS_DEFAULT``; an explicit ``0``
|
|
1370
|
+
means backfill only when the free path is empty; a malformed value means
|
|
1371
|
+
``0`` so a typo never spends extra credits. Negative values clamp to 0.
|
|
1372
|
+
"""
|
|
1373
|
+
raw = config.get(REDDIT_SC_MIN_ITEMS_VAR)
|
|
1374
|
+
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
1375
|
+
return REDDIT_SC_MIN_ITEMS_DEFAULT
|
|
1376
|
+
try:
|
|
1377
|
+
return max(int(raw), 0)
|
|
1378
|
+
except (TypeError, ValueError):
|
|
1379
|
+
return 0
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def youtube_sc_min_items(config: dict[str, Any]) -> int:
|
|
1383
|
+
"""Minimum yt-dlp result count before keyed YouTube search backfill is skipped."""
|
|
1384
|
+
raw = config.get(YOUTUBE_SC_MIN_ITEMS_VAR)
|
|
1385
|
+
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
1386
|
+
return YOUTUBE_SC_MIN_ITEMS_DEFAULT
|
|
1387
|
+
try:
|
|
1388
|
+
return max(int(raw), 0)
|
|
1389
|
+
except (TypeError, ValueError):
|
|
1390
|
+
return 0
|
|
675
1391
|
|
|
676
1392
|
|
|
677
|
-
|
|
1393
|
+
@dataclass(frozen=True)
|
|
1394
|
+
class XPolicy:
|
|
1395
|
+
"""The host-conditional X routing rule, resolved once per config.
|
|
1396
|
+
|
|
1397
|
+
``host`` is the normalized ``LAST30DAYS_HOST`` value; ``official_only``
|
|
1398
|
+
is true on a Grok Bot host; ``auto_chain`` is the unpinned chain
|
|
1399
|
+
(``_X_OFFICIAL`` when official-only, else ``_X_BACKEND_ORDER``);
|
|
1400
|
+
``cookie_discovery`` is false when official-only unless the pin is
|
|
1401
|
+
``bird``; ``hint_namespace`` (``official`` or ``default``) is derived
|
|
1402
|
+
from ``official_only`` as a plain string so this module never imports
|
|
1403
|
+
``prescriptions``.
|
|
1404
|
+
"""
|
|
1405
|
+
|
|
1406
|
+
host: str
|
|
1407
|
+
official_only: bool
|
|
1408
|
+
auto_chain: tuple[str, ...]
|
|
1409
|
+
cookie_discovery: bool
|
|
1410
|
+
|
|
1411
|
+
@property
|
|
1412
|
+
def hint_namespace(self) -> str:
|
|
1413
|
+
return 'official' if self.official_only else 'default'
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def x_backend_pin(config: dict[str, Any]) -> str:
|
|
1417
|
+
"""The normalized ``LAST30DAYS_X_BACKEND`` pin value ("" when unset)."""
|
|
1418
|
+
return (config.get(X_BACKEND_PIN_VAR) or '').strip().lower()
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def x_policy(config: dict[str, Any]) -> XPolicy:
|
|
1422
|
+
"""Resolve the X policy from ``LAST30DAYS_HOST`` and the pin.
|
|
1423
|
+
|
|
1424
|
+
This is the ONLY place the Grok Bot host string is compared. It reads
|
|
1425
|
+
just the host key, the pin, and the config dict: no platform, PATH, home
|
|
1426
|
+
directory, or agent env-var inspection (the same rule ``x_extras_enabled``
|
|
1427
|
+
follows), and nothing imported from ``lib``. The pin keeps its exclusive
|
|
1428
|
+
semantics on every host and may name any known backend; a ``bird`` pin
|
|
1429
|
+
is the one path that re-enables cookie discovery on an official-only host.
|
|
1430
|
+
"""
|
|
1431
|
+
host = str(config.get(X_HOST_VAR) or '').strip().lower()
|
|
1432
|
+
official_only = host == GROK_BOT_HOST
|
|
1433
|
+
pin = x_backend_pin(config)
|
|
1434
|
+
return XPolicy(
|
|
1435
|
+
host=host,
|
|
1436
|
+
official_only=official_only,
|
|
1437
|
+
auto_chain=_X_OFFICIAL if official_only else _X_BACKEND_ORDER,
|
|
1438
|
+
cookie_discovery=(not official_only) or pin == 'bird',
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def x_auto_chain(config: dict[str, Any]) -> list[str]:
|
|
1443
|
+
"""The unpinned X auto chain for this host, in failover order."""
|
|
1444
|
+
return list(x_policy(config).auto_chain)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def x_host_lane_declared(config: dict[str, Any]) -> bool:
|
|
1448
|
+
"""True when the hosting model declared the X connector lane.
|
|
1449
|
+
|
|
1450
|
+
``get_config`` fills ``LAST30DAYS_X_HOST_LANE`` from the process
|
|
1451
|
+
environment only, so a ``.env`` line never declares the lane.
|
|
1452
|
+
Deliberately NOT ``x_pending_browser_auth``: that predicate is false in
|
|
1453
|
+
cookie-read mode by contract, which would leave the envelope path dead at
|
|
1454
|
+
research time. Host-independent: the envelope is accepted anywhere.
|
|
1455
|
+
"""
|
|
1456
|
+
return _truthy(config.get(X_HOST_LANE_VAR))
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _x_backend_available(
|
|
1460
|
+
backend: str,
|
|
1461
|
+
config: dict[str, Any],
|
|
1462
|
+
has_bird_creds: bool,
|
|
1463
|
+
local_only: bool = False,
|
|
1464
|
+
) -> bool:
|
|
678
1465
|
if backend == 'xai':
|
|
679
1466
|
return bool(config.get('XAI_API_KEY'))
|
|
1467
|
+
if backend == 'grok':
|
|
1468
|
+
# Keyless relative to X: needs only an installed, signed-in grok CLI.
|
|
1469
|
+
# Both surfaces are filesystem-only (PATH lookup + credential store),
|
|
1470
|
+
# so local_only needs no separate branch.
|
|
1471
|
+
from . import grok_x
|
|
1472
|
+
return grok_x.has_stored_auth()
|
|
680
1473
|
if backend == 'bird':
|
|
681
1474
|
from . import bird_x
|
|
682
1475
|
return has_bird_creds and bird_x.is_bird_installed()
|
|
683
1476
|
if backend == 'xurl':
|
|
684
1477
|
from . import xurl_x
|
|
1478
|
+
if local_only:
|
|
1479
|
+
# Doctor/safe-diagnose path: local evidence only (PATH lookup +
|
|
1480
|
+
# token store) — never the live `xurl whoami` network call.
|
|
1481
|
+
return xurl_x.has_stored_auth()
|
|
685
1482
|
return xurl_x.is_available()
|
|
686
1483
|
if backend == 'xquik':
|
|
687
1484
|
return is_xquik_available(config)
|
|
1485
|
+
if backend == 'xapi':
|
|
1486
|
+
# Key presence only (no network); local_only needs no branch.
|
|
1487
|
+
return bool(config.get('X_BEARER_TOKEN'))
|
|
688
1488
|
return False
|
|
689
1489
|
|
|
690
1490
|
|
|
691
|
-
def x_backend_chain(config: dict[str, Any]) -> list[str]:
|
|
1491
|
+
def x_backend_chain(config: dict[str, Any], local_only: bool = False) -> list[str]:
|
|
692
1492
|
"""Ordered list of available X backends.
|
|
693
1493
|
|
|
694
1494
|
``chain[0]`` is the default X source; the remaining entries are failover
|
|
@@ -696,33 +1496,61 @@ def x_backend_chain(config: dict[str, Any]) -> list[str]:
|
|
|
696
1496
|
exactly one X source — these are its backends, never fetched in parallel.
|
|
697
1497
|
|
|
698
1498
|
A ``LAST30DAYS_X_BACKEND`` pin forces a single backend (no failover): the
|
|
699
|
-
user explicitly chose it.
|
|
700
|
-
(
|
|
701
|
-
|
|
1499
|
+
user explicitly chose it. Valid pin values are in ``_X_BACKEND_KNOWN``
|
|
1500
|
+
(the auto chain plus opt-in backends like grok). Browser-cookie probing
|
|
1501
|
+
is intentionally avoided (automatic Keychain access causes popups); bird
|
|
1502
|
+
counts as available only when AUTH_TOKEN and CT0 are present explicitly.
|
|
1503
|
+
|
|
1504
|
+
Unpinned runs walk only ``_X_BACKEND_ORDER``: opt-in backends like grok
|
|
1505
|
+
are never auto-selected. A leftover ~/.grok/auth.json must not steal the
|
|
1506
|
+
X lane; pin ``LAST30DAYS_X_BACKEND=grok`` to enable it explicitly.
|
|
1507
|
+
|
|
1508
|
+
``local_only=True`` is the doctor/safe-diagnose flavor: availability is
|
|
1509
|
+
answered from local evidence only (no subprocess spawns that reach the
|
|
1510
|
+
network — xurl's live `whoami` check is replaced by its on-disk token
|
|
1511
|
+
store). Research-time callers keep the default live semantics.
|
|
1512
|
+
|
|
1513
|
+
The unpinned walk is ``x_policy(config).auto_chain``: the default order
|
|
1514
|
+
above on every host, or ``_X_OFFICIAL`` (xapi -> xai -> xurl) on an
|
|
1515
|
+
official-only host. The scraper is primed with cookies only when bird
|
|
1516
|
+
ends up in the resulting chain (never on an official-only host unless
|
|
1517
|
+
bird is pinned).
|
|
702
1518
|
"""
|
|
703
|
-
from . import bird_x
|
|
704
1519
|
has_bird_creds = bool(config.get('AUTH_TOKEN') and config.get('CT0'))
|
|
705
|
-
if has_bird_creds:
|
|
706
|
-
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
|
|
707
|
-
|
|
708
|
-
preferred = (config.get('LAST30DAYS_X_BACKEND') or '').lower()
|
|
709
|
-
if preferred in _X_BACKEND_ORDER:
|
|
710
|
-
return [preferred] if _x_backend_available(preferred, config, has_bird_creds) else []
|
|
711
1520
|
|
|
712
|
-
|
|
1521
|
+
preferred = x_backend_pin(config)
|
|
1522
|
+
# Pin accepted from _X_BACKEND_KNOWN (auto chain + opt-in like grok).
|
|
1523
|
+
if preferred in _X_BACKEND_KNOWN:
|
|
1524
|
+
if _x_backend_available(preferred, config, has_bird_creds, local_only):
|
|
1525
|
+
chain = [preferred]
|
|
1526
|
+
else:
|
|
1527
|
+
chain = []
|
|
1528
|
+
else:
|
|
1529
|
+
# Unpinned: walk the policy's auto chain. Opt-in backends (grok, and
|
|
1530
|
+
# xapi off an official-only host) are never auto-selected.
|
|
1531
|
+
chain = [
|
|
1532
|
+
b for b in x_policy(config).auto_chain
|
|
1533
|
+
if _x_backend_available(b, config, has_bird_creds, local_only)
|
|
1534
|
+
]
|
|
1535
|
+
|
|
1536
|
+
if 'bird' in chain:
|
|
1537
|
+
from . import bird_x
|
|
1538
|
+
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
|
|
1539
|
+
return chain
|
|
713
1540
|
|
|
714
1541
|
|
|
715
|
-
def get_x_source(config: dict[str, Any]) -> str | None:
|
|
1542
|
+
def get_x_source(config: dict[str, Any], local_only: bool = False) -> str | None:
|
|
716
1543
|
"""The default (primary) X backend, or None if no X source is available.
|
|
717
1544
|
|
|
718
1545
|
Thin wrapper over ``x_backend_chain`` returning the first/primary backend;
|
|
719
1546
|
callers that want failover should use ``x_backend_chain`` directly.
|
|
1547
|
+
``local_only`` is forwarded (see ``x_backend_chain``).
|
|
720
1548
|
"""
|
|
721
|
-
chain = x_backend_chain(config)
|
|
1549
|
+
chain = x_backend_chain(config, local_only=local_only)
|
|
722
1550
|
return chain[0] if chain else None
|
|
723
1551
|
|
|
724
1552
|
|
|
725
|
-
def x_pending_browser_auth(config: dict[str, Any]) -> bool:
|
|
1553
|
+
def x_pending_browser_auth(config: dict[str, Any], local_only: bool = False) -> bool:
|
|
726
1554
|
"""True when X is not available now but ``FROM_BROWSER`` will authenticate it at run time.
|
|
727
1555
|
|
|
728
1556
|
``--diagnose`` / ``--preflight`` load config in ``plan_only`` mode, which
|
|
@@ -731,28 +1559,48 @@ def x_pending_browser_auth(config: dict[str, Any]) -> bool:
|
|
|
731
1559
|
dropped from ``available_sources`` even though a normal run would extract the
|
|
732
1560
|
same cookies and authenticate X fine. This predicate reports that
|
|
733
1561
|
"available pending browser auth" state without reading a single cookie — it
|
|
734
|
-
keys only on the
|
|
735
|
-
derives it from ``FROM_BROWSER`` alone, no secrets)
|
|
736
|
-
|
|
737
|
-
|
|
1562
|
+
keys only on the resolved browser list (``cookie_extraction_browsers``
|
|
1563
|
+
derives it from ``FROM_BROWSER`` alone, no secrets) OR — on extra hosts
|
|
1564
|
+
only (``x_extras_enabled``) — the agentcookie sidecar being on PATH (a plain
|
|
1565
|
+
``which`` lookup), bird being installed, and X having a cookie-domain
|
|
1566
|
+
mapping. A plain MacBook must NOT predict bird from an agentcookie binary on
|
|
1567
|
+
PATH (R18), so the sidecar leg is gated behind ``x_extras_enabled``.
|
|
1568
|
+
Side-effect free, so the safe-inspection contract of diagnose/preflight is
|
|
1569
|
+
preserved.
|
|
738
1570
|
|
|
739
1571
|
Returns False whenever X is already available outright (static AUTH_TOKEN/CT0,
|
|
740
1572
|
or xAI/xurl/xquik backend), and in ``read`` mode (a real run has already
|
|
741
1573
|
extracted creds, so its status must be unchanged — never "pending").
|
|
742
1574
|
"""
|
|
743
1575
|
# Already available via a static backend (bird creds, xAI, xurl, xquik).
|
|
744
|
-
|
|
1576
|
+
# local_only (doctor/safe-diagnose) answers the xurl leg from the token
|
|
1577
|
+
# store instead of the live `xurl whoami` network call.
|
|
1578
|
+
if get_x_source(config, local_only=local_only):
|
|
745
1579
|
return False
|
|
746
1580
|
# Only meaningful in inspection modes that skip extraction; a real ``read``
|
|
747
1581
|
# run has already attempted extraction and must report its true state.
|
|
748
1582
|
if config.get('_BROWSER_COOKIE_MODE') == 'read':
|
|
749
1583
|
return False
|
|
750
|
-
|
|
1584
|
+
# Cookie-only predicate: on an official-only host no run-time cookie
|
|
1585
|
+
# source exists unless bird is pinned (x_policy), so nothing is pending.
|
|
1586
|
+
if not x_policy(config).cookie_discovery:
|
|
751
1587
|
return False
|
|
752
|
-
if not
|
|
1588
|
+
if 'x' not in COOKIE_DOMAINS:
|
|
753
1589
|
return False
|
|
754
1590
|
from . import bird_x
|
|
755
|
-
|
|
1591
|
+
if not bird_x.is_bird_installed():
|
|
1592
|
+
return False
|
|
1593
|
+
# A FROM_BROWSER browser is a run-time cookie source on any host.
|
|
1594
|
+
if cookie_extraction_browsers(config):
|
|
1595
|
+
return True
|
|
1596
|
+
# The agentcookie sidecar is a run-time cookie source ONLY on extra hosts
|
|
1597
|
+
# (Linux / Mac mini / Darwin sink / AGENTCOOKIE=on). Gating this keeps a
|
|
1598
|
+
# plain MacBook from predicting bird off a stray agentcookie binary (R18).
|
|
1599
|
+
if x_extras_enabled(config):
|
|
1600
|
+
from . import agentcookie
|
|
1601
|
+
if agentcookie.is_available(config):
|
|
1602
|
+
return True
|
|
1603
|
+
return False
|
|
756
1604
|
|
|
757
1605
|
|
|
758
1606
|
def is_ytdlp_available() -> bool:
|
|
@@ -764,17 +1612,23 @@ def is_ytdlp_available() -> bool:
|
|
|
764
1612
|
def is_youtube_comments_available(config: dict[str, Any]) -> bool:
|
|
765
1613
|
"""Check if YouTube comment enrichment is available.
|
|
766
1614
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1615
|
+
yt-dlp fetches YouTube comments free and keyless, so when it is installed
|
|
1616
|
+
comments need no credential and no ``INCLUDE_SOURCES`` opt-in — the opt-in
|
|
1617
|
+
only ever existed to gate ScrapeCreators credit spend, and there is none to
|
|
1618
|
+
gate. ``EXCLUDE_SOURCES=youtube_comments`` remains the off-switch.
|
|
771
1619
|
|
|
772
|
-
|
|
773
|
-
|
|
1620
|
+
Without yt-dlp, the legacy ScrapeCreators path still applies: it requires
|
|
1621
|
+
SCRAPECREATORS_API_KEY AND ``youtube_comments`` in ``INCLUDE_SOURCES``
|
|
1622
|
+
(mirroring ``is_tiktok_comments_available``), bounded by
|
|
1623
|
+
``enrich_with_comments(max_videos=3)`` at ~3 credits per run.
|
|
774
1624
|
"""
|
|
1625
|
+
if 'youtube_comments' in _parse_exclude_sources(config):
|
|
1626
|
+
return False
|
|
1627
|
+
if is_ytdlp_available():
|
|
1628
|
+
return True
|
|
775
1629
|
if not config.get('SCRAPECREATORS_API_KEY'):
|
|
776
1630
|
return False
|
|
777
|
-
return 'youtube_comments'
|
|
1631
|
+
return 'youtube_comments' in _parse_include_sources(config)
|
|
778
1632
|
|
|
779
1633
|
|
|
780
1634
|
def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
|
|
@@ -789,6 +1643,20 @@ def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
|
|
|
789
1643
|
return 'tiktok_comments' in include
|
|
790
1644
|
|
|
791
1645
|
|
|
1646
|
+
def is_instagram_comments_available(config: dict[str, Any]) -> bool:
|
|
1647
|
+
"""Check if Instagram comment enrichment is available.
|
|
1648
|
+
|
|
1649
|
+
Requires SCRAPECREATORS_API_KEY AND instagram_comments in INCLUDE_SOURCES.
|
|
1650
|
+
Mirrors the youtube_comments / tiktok_comments opt-in pattern. Comments are
|
|
1651
|
+
fetched via ScrapeCreators (GET /v2/instagram/post/comments) with each
|
|
1652
|
+
comment's ``comment_like_count`` used as its vote for ranking. Part of the
|
|
1653
|
+
default onboarding tier (posts on -> comments on for TikTok/Instagram/YouTube).
|
|
1654
|
+
"""
|
|
1655
|
+
if not config.get('SCRAPECREATORS_API_KEY'):
|
|
1656
|
+
return False
|
|
1657
|
+
return 'instagram_comments' in _parse_include_sources(config)
|
|
1658
|
+
|
|
1659
|
+
|
|
792
1660
|
def is_youtube_sc_available(config: dict[str, Any]) -> bool:
|
|
793
1661
|
"""Check if ScrapeCreators YouTube search fallback is available.
|
|
794
1662
|
|
|
@@ -894,13 +1762,31 @@ def _parse_exclude_sources(config: dict[str, Any]) -> set[str]:
|
|
|
894
1762
|
return {s.strip().lower() for s in raw.split(',') if s.strip()}
|
|
895
1763
|
|
|
896
1764
|
|
|
1765
|
+
def include_sources(config: dict[str, Any]) -> set[str]:
|
|
1766
|
+
"""Public view of the parsed INCLUDE_SOURCES set.
|
|
1767
|
+
|
|
1768
|
+
Thin wrapper over ``_parse_include_sources`` so other modules (doctor,
|
|
1769
|
+
etc.) don't reach into env's privates.
|
|
1770
|
+
"""
|
|
1771
|
+
return _parse_include_sources(config)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def is_setup_complete(config: dict[str, Any]) -> bool:
|
|
1775
|
+
"""Whether guided setup marked this config complete (SETUP_COMPLETE truthy).
|
|
1776
|
+
|
|
1777
|
+
Thin wrapper over ``_truthy`` so other modules don't reach into env's
|
|
1778
|
+
privates.
|
|
1779
|
+
"""
|
|
1780
|
+
return _truthy(config.get('SETUP_COMPLETE'))
|
|
1781
|
+
|
|
1782
|
+
|
|
897
1783
|
def is_threads_available(config: dict[str, Any]) -> bool:
|
|
898
|
-
"""Check if Threads
|
|
1784
|
+
"""Check if the Threads credential is available.
|
|
899
1785
|
|
|
900
|
-
Returns True when SCRAPECREATORS_API_KEY is set.
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1786
|
+
Returns True when SCRAPECREATORS_API_KEY is set. This is an availability
|
|
1787
|
+
predicate only: whether Threads is actually *scheduled* is gated in the
|
|
1788
|
+
pipeline's ``available_sources`` by an ``INCLUDE_SOURCES=threads`` opt-in
|
|
1789
|
+
(the onboarding "Everything" tier), so a key alone no longer runs Threads.
|
|
904
1790
|
"""
|
|
905
1791
|
return bool(config.get('SCRAPECREATORS_API_KEY'))
|
|
906
1792
|
|
|
@@ -922,9 +1808,52 @@ def get_instagram_token(config: dict[str, Any]) -> str:
|
|
|
922
1808
|
def get_xiaohongshu_api_base(config: dict[str, Any]) -> str:
|
|
923
1809
|
"""Get Xiaohongshu HTTP API base URL.
|
|
924
1810
|
|
|
925
|
-
|
|
1811
|
+
The availability probe caches the first logged-in local service it finds so
|
|
1812
|
+
the later search request uses the same browser-backed session endpoint.
|
|
926
1813
|
"""
|
|
927
|
-
|
|
1814
|
+
cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY)
|
|
1815
|
+
if cached:
|
|
1816
|
+
return str(cached).rstrip("/")
|
|
1817
|
+
|
|
1818
|
+
explicit = config.get("XIAOHONGSHU_API_BASE")
|
|
1819
|
+
if explicit:
|
|
1820
|
+
return str(explicit).rstrip("/")
|
|
1821
|
+
|
|
1822
|
+
return XIAOHONGSHU_DEFAULT_API_BASES[0]
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _xiaohongshu_api_base_candidates(config: dict[str, Any]) -> list[str]:
|
|
1826
|
+
explicit = config.get("XIAOHONGSHU_API_BASE")
|
|
1827
|
+
if explicit:
|
|
1828
|
+
return [str(explicit).rstrip("/")]
|
|
1829
|
+
|
|
1830
|
+
candidates: list[str] = []
|
|
1831
|
+
cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY)
|
|
1832
|
+
if cached:
|
|
1833
|
+
candidates.append(str(cached).rstrip("/"))
|
|
1834
|
+
|
|
1835
|
+
for base in XIAOHONGSHU_DEFAULT_API_BASES:
|
|
1836
|
+
if base not in candidates:
|
|
1837
|
+
candidates.append(base)
|
|
1838
|
+
return candidates
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _xiaohongshu_base_logged_in(base: str, http_module: Any) -> bool:
|
|
1842
|
+
# Keep the health probe snappy, but allow one retry for transient hiccups.
|
|
1843
|
+
health = http_module.get(f"{base}/health", timeout=3, retries=2)
|
|
1844
|
+
if not isinstance(health, dict):
|
|
1845
|
+
return False
|
|
1846
|
+
if not health.get("success"):
|
|
1847
|
+
return False
|
|
1848
|
+
|
|
1849
|
+
# Login checks can be slower because some services consult the browser
|
|
1850
|
+
# profile/session, so use a slightly longer timeout than the health probe.
|
|
1851
|
+
login = http_module.get(f"{base}/api/v1/login/status", timeout=8, retries=2)
|
|
1852
|
+
is_logged_in = (
|
|
1853
|
+
login.get("data", {}).get("is_logged_in")
|
|
1854
|
+
if isinstance(login, dict) else False
|
|
1855
|
+
)
|
|
1856
|
+
return bool(is_logged_in)
|
|
928
1857
|
|
|
929
1858
|
|
|
930
1859
|
def is_xiaohongshu_available(config: dict[str, Any]) -> bool:
|
|
@@ -932,32 +1861,20 @@ def is_xiaohongshu_available(config: dict[str, Any]) -> bool:
|
|
|
932
1861
|
# Import here to avoid heavy imports at module load.
|
|
933
1862
|
from . import http
|
|
934
1863
|
|
|
935
|
-
base
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
if isinstance(login, dict) else False
|
|
950
|
-
)
|
|
951
|
-
return bool(is_logged_in)
|
|
952
|
-
except (OSError, http.HTTPError):
|
|
953
|
-
return False
|
|
954
|
-
except Exception as exc:
|
|
955
|
-
sys.stderr.write(
|
|
956
|
-
f"[last30days] WARNING: unexpected error checking Xiaohongshu: "
|
|
957
|
-
f"{type(exc).__name__}: {exc}\n"
|
|
958
|
-
)
|
|
959
|
-
sys.stderr.flush()
|
|
960
|
-
return False
|
|
1864
|
+
for base in _xiaohongshu_api_base_candidates(config):
|
|
1865
|
+
try:
|
|
1866
|
+
if _xiaohongshu_base_logged_in(base, http):
|
|
1867
|
+
config[XIAOHONGSHU_RESOLVED_API_BASE_KEY] = base
|
|
1868
|
+
return True
|
|
1869
|
+
except (OSError, http.HTTPError):
|
|
1870
|
+
continue
|
|
1871
|
+
except Exception as exc:
|
|
1872
|
+
sys.stderr.write(
|
|
1873
|
+
f"[last30days] WARNING: unexpected error checking Xiaohongshu "
|
|
1874
|
+
f"at {base}: {type(exc).__name__}: {exc}\n"
|
|
1875
|
+
)
|
|
1876
|
+
sys.stderr.flush()
|
|
1877
|
+
return False
|
|
961
1878
|
|
|
962
1879
|
|
|
963
1880
|
# Backward compat alias
|
|
@@ -972,7 +1889,10 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
|
|
|
972
1889
|
``bird_authenticated`` to False when X clearly returns nothing,
|
|
973
1890
|
so ``--diagnose`` reflects runtime reality instead of static
|
|
974
1891
|
credential presence. A transient timeout leaves the status
|
|
975
|
-
unchanged (fail open).
|
|
1892
|
+
unchanged (fail open). When False (the safe/diagnose path that
|
|
1893
|
+
doctor uses), NO network is touched: xurl availability comes
|
|
1894
|
+
from local evidence (``xurl_x.has_stored_auth``), never the
|
|
1895
|
+
live ``xurl whoami`` call.
|
|
976
1896
|
|
|
977
1897
|
Returns:
|
|
978
1898
|
Dict with keys: source, bird_installed, bird_authenticated,
|
|
@@ -980,10 +1900,24 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
|
|
|
980
1900
|
"""
|
|
981
1901
|
from . import bird_x
|
|
982
1902
|
|
|
983
|
-
|
|
1903
|
+
# Backends this host may run: the policy's auto chain plus a known pin.
|
|
1904
|
+
# Bird is primed/probed and xquik is probed only when they are in that
|
|
1905
|
+
# set, so an official-only host never touches the scraper or the
|
|
1906
|
+
# third-party API unless the backend is pinned.
|
|
1907
|
+
policy = x_policy(config)
|
|
1908
|
+
pin = x_backend_pin(config)
|
|
1909
|
+
considered = set(policy.auto_chain)
|
|
1910
|
+
if pin in _X_BACKEND_KNOWN:
|
|
1911
|
+
considered.add(pin)
|
|
1912
|
+
|
|
1913
|
+
if 'bird' in considered and config.get('AUTH_TOKEN') and config.get('CT0'):
|
|
984
1914
|
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
|
|
985
|
-
bird_status = bird_x.get_bird_status()
|
|
1915
|
+
bird_status = dict(bird_x.get_bird_status())
|
|
1916
|
+
if 'bird' not in considered:
|
|
1917
|
+
# Never report the scraper as usable where the policy forbids it.
|
|
1918
|
+
bird_status["authenticated"] = False
|
|
986
1919
|
xai_available = bool(config.get('XAI_API_KEY'))
|
|
1920
|
+
xapi_available = bool(config.get('X_BEARER_TOKEN'))
|
|
987
1921
|
|
|
988
1922
|
# Report the TRUE auth lane (browser / env / keychain) rather than the static
|
|
989
1923
|
# "env AUTH_TOKEN" label — tokens usually come from live browser cookies, and
|
|
@@ -1004,7 +1938,7 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
|
|
|
1004
1938
|
xquik_available = is_xquik_available(config)
|
|
1005
1939
|
xquik_working: bool | None = None
|
|
1006
1940
|
xquik_status = ""
|
|
1007
|
-
if xquik_available:
|
|
1941
|
+
if xquik_available and 'xquik' in considered:
|
|
1008
1942
|
if probe:
|
|
1009
1943
|
from . import xquik
|
|
1010
1944
|
xquik_working = xquik.probe_works(get_xquik_token(config))
|
|
@@ -1012,30 +1946,50 @@ def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str
|
|
|
1012
1946
|
else:
|
|
1013
1947
|
xquik_status = "configured (not probed)"
|
|
1014
1948
|
|
|
1015
|
-
#
|
|
1016
|
-
#
|
|
1017
|
-
#
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1949
|
+
# Xurl availability, computed ONCE. probe=True (a live diagnose) may run
|
|
1950
|
+
# the real `xurl whoami`; probe=False is the safe path (doctor,
|
|
1951
|
+
# --diagnose, --preflight) and must stay local-only — the live check is
|
|
1952
|
+
# an authenticated X API network call.
|
|
1953
|
+
from . import xurl_x as _xurl_x
|
|
1954
|
+
xurl_available = _xurl_x.is_available() if probe else _xurl_x.has_stored_auth()
|
|
1955
|
+
|
|
1956
|
+
# Grok availability is filesystem-only on both paths (PATH lookup plus the
|
|
1957
|
+
# credential store), so it is safe to compute here regardless of `probe`.
|
|
1958
|
+
# Grok is opt-in only: it appears in grok_available but never wins the
|
|
1959
|
+
# unpinned source selection.
|
|
1960
|
+
from . import grok_x as _grok_x
|
|
1961
|
+
grok_available = _grok_x.has_stored_auth()
|
|
1962
|
+
|
|
1963
|
+
# Determine active source. A pin forces a single backend (R4): ANY known
|
|
1964
|
+
# pin is exclusive, mirroring x_backend_chain's [] semantics. Pinned
|
|
1965
|
+
# backend available -> that source. Pinned backend unavailable -> None.
|
|
1966
|
+
# Otherwise walk the policy's auto chain (default: bird first, cookies
|
|
1967
|
+
# beat XAI_API_KEY when both are present, then xai, xurl, xquik;
|
|
1968
|
+
# official-only: xapi, xai, xurl). Opt-in backends are never
|
|
1969
|
+
# auto-selected; a leftover ~/.grok/auth.json must not steal the X lane.
|
|
1970
|
+
usable = {
|
|
1971
|
+
'bird': bird_status["authenticated"],
|
|
1972
|
+
'xai': xai_available,
|
|
1973
|
+
'xurl': xurl_available,
|
|
1974
|
+
'xquik': xquik_available and xquik_working is not False,
|
|
1975
|
+
'grok': grok_available,
|
|
1976
|
+
'xapi': xapi_available,
|
|
1977
|
+
}
|
|
1978
|
+
if pin in _X_BACKEND_KNOWN:
|
|
1979
|
+
# Pin is exclusive: pinned backend if available, else None (no fallback).
|
|
1980
|
+
source = pin if usable.get(pin) else None
|
|
1022
1981
|
else:
|
|
1023
|
-
|
|
1024
|
-
if _xurl_check.is_available():
|
|
1025
|
-
source = 'xurl'
|
|
1026
|
-
elif xquik_available and xquik_working is not False:
|
|
1027
|
-
source = 'xquik'
|
|
1028
|
-
else:
|
|
1029
|
-
source = None
|
|
1982
|
+
source = next((b for b in policy.auto_chain if usable.get(b)), None)
|
|
1030
1983
|
|
|
1031
|
-
from . import xurl_x as _xurl_x
|
|
1032
1984
|
return {
|
|
1033
1985
|
"source": source,
|
|
1034
1986
|
"bird_installed": bird_status["installed"],
|
|
1035
1987
|
"bird_authenticated": bird_status["authenticated"],
|
|
1036
1988
|
"bird_username": bird_status["username"],
|
|
1037
1989
|
"xai_available": xai_available,
|
|
1038
|
-
"
|
|
1990
|
+
"xapi_available": xapi_available,
|
|
1991
|
+
"grok_available": grok_available,
|
|
1992
|
+
"xurl_available": xurl_available,
|
|
1039
1993
|
"xquik_available": xquik_available,
|
|
1040
1994
|
"xquik_working": xquik_working,
|
|
1041
1995
|
"xquik_status": xquik_status,
|