返回 last30days-skill
env.py
1 """Environment and API key management for last30days skill."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import locale
8 import os
9 import re
10 import sys
11 from dataclasses import dataclass
12 from pathlib import Path
13 from typing import Any, Literal
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
28 # Allow override via environment variable for testing
29 # Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
30 # Set LAST30DAYS_CONFIG_DIR="/path/to/dir" for custom config location
31 _config_override = os.environ.get('LAST30DAYS_CONFIG_DIR')
32 if _config_override == "":
33 # Empty string = no config file (clean mode)
34 CONFIG_DIR = None
35 CONFIG_FILE = None
36 elif _config_override:
37 CONFIG_DIR = Path(_config_override)
38 CONFIG_FILE = CONFIG_DIR / ".env"
39 else:
40 CONFIG_DIR = Path.home() / ".config" / "last30days"
41 CONFIG_FILE = CONFIG_DIR / ".env"
42
43 # macOS Keychain integration: items stored with this service prefix are picked
44 # up automatically on Darwin as the lowest-priority credential source.
45 # Example: `security add-generic-password -a "$USER" -s last30days-XAI_API_KEY -w "xai-..."`.
46 KEYCHAIN_SERVICE_PREFIX = "last30days-"
47
48 # Optional non-secret aliases for users who already store API keys under a
49 # different Keychain naming convention. Configure as JSON in
50 # LAST30DAYS_KEYCHAIN_ALIASES, for example:
51 # {"XAI_API_KEY":{"account":"keychain-user","service":"existing-xai-api-key"}}
52 # A string value is shorthand for {"service": "..."} with the current user.
53 KEYCHAIN_ALIASES_ENV = "LAST30DAYS_KEYCHAIN_ALIASES"
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 # Single source of truth for which credentials the Keychain loader looks up.
63 # The setup-keychain.sh helper mirrors this list and is held in sync via
64 # tests/test_env_keychain.py::test_keychain_keys_match_setup_script.
65 KEYCHAIN_KEYS = (
66 "OPENAI_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
67 "GOOGLE_GENAI_API_KEY", "SCRAPECREATORS_API_KEY", "APIFY_API_TOKEN",
68 "AUTH_TOKEN", "CT0", "BSKY_HANDLE", "BSKY_APP_PASSWORD",
69 "TRUTHSOCIAL_TOKEN", "BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY",
70 "OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PARALLEL_API_KEY", "XQUIK_API_KEY",
71 "XIAOHONGSHU_API_BASE", "GITHUB_TOKEN", "BRIGHTDATA_API_KEY",
72 "X_BEARER_TOKEN",
73 )
74
75 # pass(1) integration: Linux/Unix analog of the Keychain source. Each key in
76 # KEYCHAIN_KEYS is looked up at pass path f"{prefix}{KEY}", the direct analog of
77 # Keychain's "last30days-<KEY>" service-name convention, so any user stores keys
78 # under one namespace without editing code. The prefix is resolved at call time
79 # (in get_config) from LAST30DAYS_PASS_PREFIX in the process env or a config
80 # file, falling back to this default; included verbatim, so keep the trailing
81 # separator. Honors PASSWORD_STORE_DIR.
82 DEFAULT_PASS_PATH_PREFIX = "last30days/"
83
84 AuthSource = Literal["api_key", "none"]
85 AuthStatus = Literal["ok", "missing"]
86
87 AUTH_SOURCE_API_KEY: AuthSource = "api_key"
88 AUTH_SOURCE_NONE: AuthSource = "none"
89
90 AUTH_STATUS_OK: AuthStatus = "ok"
91 AUTH_STATUS_MISSING: AuthStatus = "missing"
92
93 XIAOHONGSHU_DEFAULT_API_BASES = (
94 "http://localhost:18060",
95 "http://host.docker.internal:18060",
96 )
97 XIAOHONGSHU_RESOLVED_API_BASE_KEY = "_XIAOHONGSHU_API_BASE_RESOLVED"
98
99
100 @dataclass(frozen=True)
101 class OpenAIAuth:
102 token: str | None
103 source: AuthSource
104 status: AuthStatus
105
106
107 BrowserCookieMode = Literal["off", "read", "plan_only"]
108
109
110 @dataclass(frozen=True)
111 class ConfigLoadPolicy:
112 """Local-read gates for configuration loading.
113
114 Bare library calls use the safe default: no browser-cookie extraction and no
115 project-scoped config. CLI entry points can opt into narrower behavior after
116 parsing command intent.
117 """
118
119 browser_cookies: BrowserCookieMode = "off"
120 allow_project_config: bool = False
121 inspect_ignored_project_config: bool = False
122
123
124 def _truthy(value: Any) -> bool:
125 if value is None:
126 return False
127 return str(value).strip().lower() in {"1", "true", "yes", "on"}
128
129
130 # A Claude Desktop extension maps every unset field in its config modal to the
131 # literal string ``${user_config.<field>}`` in the engine's environment. The
132 # placeholder is non-empty, so a presence check reads it as a real credential:
133 # doctor reports the source healthy, preflight returns ready, and the backend
134 # sends the literal placeholder upstream and surfaces the vendor's 401 instead
135 # of falling back. Two constraints keep legitimate values out of scope. The
136 # match is anchored to the whole trimmed value, so a real credential containing
137 # ``$`` or braces is untouched. And the field name is restricted to the
138 # identifier charset the manifest uses, so shell-default syntax is not mistaken
139 # for a placeholder - both the generic form a user may paste into ``.env``
140 # (``${VAR:-default}``) and the namespaced form with a default
141 # (``${user_config.x:-default}``). Only the extension namespace, as issue
142 # #1081's own suggested fix names, is rejected.
143 _UNSUBSTITUTED_TEMPLATE = re.compile(r"^\$\{user_config\.[A-Za-z0-9_]+\}$")
144
145 # Config-record key holding the names of values rejected above, so diagnostics
146 # report the templated state instead of silently counting the key absent.
147 TEMPLATE_CONFIG_KEYS = "_TEMPLATE_CONFIG_KEYS"
148
149
150 def is_unsubstituted_template(value: Any) -> bool:
151 """True when ``value`` is a whole, unexpanded ``${user_config.*}`` placeholder."""
152 if not isinstance(value, str):
153 return False
154 return bool(_UNSUBSTITUTED_TEMPLATE.match(value.strip()))
155
156
157 def templated_config_keys(config: dict[str, Any]) -> list[str]:
158 """Public view of the keys ``get_config()`` rejected as unsubstituted templates.
159
160 Thin reader so diagnostics report the templated state without re-deriving
161 the record key, in the same spirit as ``include_sources`` and
162 ``is_setup_complete``. Sorted here too: these call sites also see hand-built
163 configs, and the order is user-visible in both diagnostics.
164 """
165 return sorted(config.get(TEMPLATE_CONFIG_KEYS) or [])
166
167
168 def _rotate_scrapecreators_key(config: dict[str, Any]) -> None:
169 """Round-robin a comma-separated SCRAPECREATORS_API_KEY to one key per run.
170
171 Extracted so the placeholder sweep can reapply it: the sweep may restore a
172 value from a lower-priority source after the ordinary rotation already ran,
173 and a comma-separated list handed to a backend whole fails authentication.
174 A second call on an already-rotated value is a no-op (no comma remains).
175 """
176 raw = config.get('SCRAPECREATORS_API_KEY') or ''
177 if ',' not in raw:
178 return
179 import random
180 sc_keys = [k.strip() for k in raw.split(',') if k.strip()]
181 config['SCRAPECREATORS_API_KEY'] = random.choice(sc_keys) if sc_keys else ''
182
183
184 def is_timestamp_fresh(timestamp_value: Any, ttl_seconds: int) -> bool:
185 """True when ``timestamp_value`` (ISO-8601 string) is within ``ttl_seconds``.
186
187 Shared freshness gate for the doctor cache and the report cache. The guard
188 order is load-bearing: a non-positive TTL disables caching entirely, a
189 non-string or empty timestamp is stale, a malformed timestamp is stale,
190 naive timestamps are treated as UTC, and a future timestamp (negative age)
191 counts as fresh.
192 """
193 if ttl_seconds <= 0:
194 return False
195 if not isinstance(timestamp_value, str) or not timestamp_value:
196 return False
197 try:
198 created_at = datetime.datetime.fromisoformat(timestamp_value)
199 except ValueError:
200 return False
201 if created_at.tzinfo is None:
202 created_at = created_at.replace(tzinfo=datetime.timezone.utc)
203 age = datetime.datetime.now(datetime.timezone.utc) - created_at.astimezone(
204 datetime.timezone.utc
205 )
206 return age.total_seconds() <= ttl_seconds
207
208
209 def _project_config_trusted(policy: ConfigLoadPolicy, file_env: dict[str, Any]) -> bool:
210 if policy.allow_project_config:
211 return True
212 process_value = os.environ.get("LAST30DAYS_TRUST_PROJECT_CONFIG")
213 if process_value is not None:
214 return _truthy(process_value)
215 return _truthy(file_env.get("LAST30DAYS_TRUST_PROJECT_CONFIG"))
216
217
218 def _check_file_permissions(path: Path) -> None:
219 """Warn to stderr if a secrets file has overly permissive permissions."""
220 if os.name == "nt":
221 # Windows reports synthesized POSIX mode bits that do not reflect NTFS ACLs.
222 return
223
224 try:
225 mode = path.stat().st_mode
226 # Check if group or other can read (bits 0o044)
227 if mode & 0o044:
228 sys.stderr.write(
229 f"[last30days] WARNING: {path} is readable by other users. "
230 f"Run: chmod 600 {path}\n"
231 )
232 sys.stderr.flush()
233 except OSError as exc:
234 sys.stderr.write(f"[last30days] WARNING: could not stat {path}: {exc}\n")
235 sys.stderr.flush()
236
237
238 def _strip_inline_comment(value: str) -> str:
239 """Drop a trailing ``# comment`` from the right-hand side of a KEY=value line.
240
241 Unquoted: ``#`` opens a comment only as the first non-blank character or
242 when preceded by whitespace, so ``value#nothash`` stays intact. Quoted:
243 everything up to the matching close quote is kept verbatim; only a
244 whitespace-separated ``#`` after the close quote is dropped. Anything that
245 does not match those shapes is returned unchanged for the existing quote
246 handling to deal with.
247 """
248 stripped = value.lstrip()
249 if stripped[:1] in ('"', "'"):
250 end = stripped.find(stripped[0], 1)
251 if end == -1:
252 return value
253 rest = stripped[end + 1:]
254 if rest[:1].isspace() and rest.lstrip().startswith('#'):
255 return stripped[:end + 1]
256 return value
257 match = re.search(r'(?:^|\s)#', stripped)
258 if match:
259 return stripped[:match.start()]
260 return value
261
262
263 def load_env_file(path: Path) -> dict[str, str]:
264 """Load environment variables from a file."""
265 env = {}
266 if not path or not path.exists():
267 return env
268 _check_file_permissions(path)
269
270 # Prefer UTF-8 (utf-8-sig transparently strips a BOM written by Windows
271 # editors like Notepad). Fall back to the locale decoder for a genuinely
272 # locale-encoded .env (e.g. cp1252) so an existing file that loaded before
273 # keeps loading. If it decodes as neither, let UnicodeDecodeError surface
274 # rather than corrupting keys/secrets with replacement characters.
275 try:
276 text = path.read_text(encoding='utf-8-sig')
277 except UnicodeDecodeError:
278 text = path.read_text(encoding=locale.getpreferredencoding(False))
279
280 for line in text.splitlines():
281 line = line.strip()
282 if not line or line.startswith('#'):
283 continue
284 if '=' in line:
285 key, _, value = line.partition('=')
286 key = key.strip()
287 value = _strip_inline_comment(value).strip()
288 # Remove quotes if present
289 if value and value[0] in ('"', "'") and value[-1] == value[0]:
290 value = value[1:-1]
291 # Empty LAST30DAYS_YT_PLAYER_CLIENT is a persisted disable; other
292 # keys still drop blanks so secrets cannot be set to "".
293 if key and (value or key == 'LAST30DAYS_YT_PLAYER_CLIENT'):
294 env.update({key: value})
295 return env
296
297
298 def _parse_keychain_aliases(raw: str | None) -> dict[str, list[dict[str, str]]]:
299 """Parse non-secret Keychain alias metadata from JSON.
300
301 Supported forms:
302 {"XAI_API_KEY": "existing-xai-api-key"}
303 {"XAI_API_KEY": {"service": "existing-xai-api-key", "account": "keychain-user"}}
304 {"XAI_API_KEY": [{"service": "primary"}, {"service": "fallback"}]}
305
306 Invalid entries are ignored so a typo never blocks canonical
307 `last30days-<KEY>` lookups; malformed JSON emits a warning.
308 """
309 if not raw:
310 return {}
311 try:
312 parsed = json.loads(raw)
313 except json.JSONDecodeError as exc:
314 sys.stderr.write(
315 f"[last30days] WARNING: {KEYCHAIN_ALIASES_ENV} is not valid JSON; "
316 f"ignoring Keychain aliases while keeping canonical lookups enabled: {exc}\n"
317 )
318 sys.stderr.flush()
319 return {}
320 if not isinstance(parsed, dict):
321 return {}
322
323 allowed = set(KEYCHAIN_KEYS)
324 aliases: dict[str, list[dict[str, str]]] = {}
325 for key, spec in parsed.items():
326 if key not in allowed:
327 continue
328 specs = spec if isinstance(spec, list) else [spec]
329 clean_specs: list[dict[str, str]] = []
330 for item in specs:
331 if isinstance(item, str):
332 service = item.strip()
333 account = ""
334 elif isinstance(item, dict):
335 service = str(item.get("service", "")).strip()
336 account = str(item.get("account", "")).strip()
337 else:
338 continue
339 if service:
340 clean_specs.append({"service": service, "account": account})
341 if clean_specs:
342 aliases[key] = clean_specs
343 return aliases
344
345
346 def _load_keychain(keys: list[str], aliases: dict[str, list[dict[str, str]]] | None = None) -> dict[str, str]:
347 """Load credentials from macOS Keychain (no-op on other platforms).
348
349 Each key is looked up as a generic password with service name
350 ``f"{KEYCHAIN_SERVICE_PREFIX}{key}"`` for the current user. Missing items
351 then fall back to optional alias metadata from
352 ``LAST30DAYS_KEYCHAIN_ALIASES``. Lookup failures are silent — Keychain is
353 the lowest-priority source and is meant to be additive over `.env` files
354 and process environment.
355
356 Set ``LAST30DAYS_SKIP_KEYCHAIN`` truthy to disable the source entirely. It
357 is read from the process environment only, never from a config file: it
358 gates a credential source that is consulted *while* the config is being
359 assembled, so a file-sourced value would be read too late to have any
360 effect.
361 """
362 if _truthy(os.environ.get(KEYCHAIN_DISABLE_ENV)):
363 return {}
364
365 import platform
366 if platform.system() != "Darwin":
367 return {}
368
369 import shutil
370 security = shutil.which("security")
371 if not security:
372 return {}
373
374 import subprocess
375 # USER can be unset under sudo, in Docker without --env USER, or in some CI
376 # runners; fall back to the OS user record so lookups still match items
377 # stored by setup-keychain.sh (which uses $USER).
378 user = os.environ.get("USER")
379 if not user:
380 try:
381 import pwd
382 except ImportError:
383 pwd = None
384
385 if pwd is not None:
386 try:
387 user = pwd.getpwuid(os.getuid()).pw_name
388 except AttributeError:
389 user = "unknown"
390 else:
391 user = "unknown"
392 env: dict[str, str] = {}
393
394 def lookup(account: str, service: str) -> str:
395 try:
396 result = subprocess.run(
397 [security, "find-generic-password",
398 "-a", account,
399 "-s", service,
400 "-w"],
401 capture_output=True, text=True, timeout=5,
402 )
403 except (subprocess.TimeoutExpired, OSError):
404 return ""
405 if result.returncode == 0 and result.stdout.strip():
406 return result.stdout.strip()
407 return ""
408
409 for key in keys:
410 value = lookup(user, f"{KEYCHAIN_SERVICE_PREFIX}{key}")
411 if not value and aliases:
412 for alias in aliases.get(key, []):
413 alias_account = alias.get("account") or user
414 value = lookup(alias_account, alias["service"])
415 if value:
416 break
417 if value:
418 env.update({key: value})
419 return env
420
421
422 def _load_pass(keys: list[str], prefix: str) -> dict[str, str]:
423 """Load credentials from a pass(1) store (no-op if `pass` is absent).
424
425 The Linux/Unix analog of the macOS Keychain source. Each env-var name is
426 looked up at pass path ``f"{prefix}{key}"`` — mirroring Keychain's
427 ``last30days-<key>`` service-name convention — so any user stores keys under
428 that namespace without editing code (prefix overridable via
429 ``LAST30DAYS_PASS_PREFIX``). The secret is decrypted in a subprocess and
430 read from stdout's first line (pass keeps the secret there; any metadata
431 follows) — never written to disk, never logged. Honors ``PASSWORD_STORE_DIR``.
432 Missing entries and failures are silent: pass is a lowest-priority, additive
433 source like Keychain, so an explicit .env or process-env value still wins.
434 """
435 import shutil
436 pass_bin = shutil.which("pass")
437 if not pass_bin:
438 return {}
439
440 import subprocess
441 env: dict[str, str] = {}
442 for key in keys:
443 try:
444 result = subprocess.run(
445 [pass_bin, "show", f"{prefix}{key}"],
446 capture_output=True, text=True, timeout=5,
447 encoding="utf-8", errors="replace",
448 )
449 except (subprocess.TimeoutExpired, OSError):
450 # A timeout (GPG/pinentry hanging) or exec failure isn't a per-key
451 # condition — it means the store is unusable right now. Stop instead
452 # of paying the timeout once per key; otherwise a locked store would
453 # stall every config load by 5s x len(keys). A genuinely missing key
454 # returns fast with a non-zero exit and is handled below.
455 break
456 if result.returncode == 0 and result.stdout.strip():
457 env.update({key: result.stdout.strip().splitlines()[0]})
458 return env
459
460
461 def get_openai_auth(file_env: dict[str, str]) -> OpenAIAuth:
462 """Resolve OpenAI API auth from explicit user-provided API keys."""
463 api_key = read_secret_env('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
464 if api_key:
465 return OpenAIAuth(
466 token=api_key,
467 source=AUTH_SOURCE_API_KEY,
468 status=AUTH_STATUS_OK,
469 )
470
471 return OpenAIAuth(
472 token=None,
473 source=AUTH_SOURCE_NONE,
474 status=AUTH_STATUS_MISSING,
475 )
476
477
478 def _find_project_env() -> Path | None:
479 """Find per-project .env by walking up from cwd.
480
481 Searches for .claude/last30days.env in each parent directory,
482 stopping at the git root, user's home directory, or filesystem root.
483 """
484 cwd = Path.cwd()
485 for parent in [cwd, *cwd.parents]:
486 candidate = parent / '.claude' / 'last30days.env'
487 if candidate.exists():
488 return candidate
489 if (parent / ".git").exists():
490 break
491 # Stop at filesystem root or home
492 if parent == Path.home() or parent == parent.parent:
493 break
494 return None
495
496
497 def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
498 """Load configuration from multiple sources.
499
500 Priority (highest wins):
501 1. Environment variables (os.environ)
502 2. Trusted .claude/last30days.env (per-project config)
503 3. ~/.config/last30days/.env (global config)
504 4. macOS Keychain items prefixed ``last30days-`` (Darwin only)
505 """
506 policy = policy or ConfigLoadPolicy()
507 # Load from global config file
508 file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
509
510 # Load per-project config only when trust comes from process env, global
511 # user config, or an explicit policy. A project file cannot grant trust to
512 # itself because it is not parsed until after this decision.
513 project_config_trusted = _project_config_trusted(policy, file_env)
514 project_env_path = _find_project_env() if project_config_trusted else None
515 project_env = load_env_file(project_env_path) if project_env_path else {}
516 ignored_project_env_path = None
517 ignored_project_keys: list[str] = []
518 if not project_config_trusted and policy.inspect_ignored_project_config:
519 ignored_project_env_path = _find_project_env()
520 if ignored_project_env_path:
521 ignored_project_keys = sorted(load_env_file(ignored_project_env_path).keys())
522
523 # Merge file sources: project > global
524 merged_env = {**file_env, **project_env}
525
526 # Keychain is the lowest-priority source (Darwin only; no-op elsewhere).
527 # Loaded before openai_auth so OPENAI_API_KEY can come from Keychain too.
528 keychain_aliases_raw = os.environ.get(KEYCHAIN_ALIASES_ENV) or merged_env.get(KEYCHAIN_ALIASES_ENV)
529 keychain_aliases = _parse_keychain_aliases(keychain_aliases_raw)
530 keychain_env = _load_keychain(list(KEYCHAIN_KEYS), keychain_aliases)
531 merged_env = {**keychain_env, **merged_env}
532 # pass(1) store: Linux/Unix analog of Keychain at convention path
533 # {prefix}<KEY>. Decrypts transiently so secrets stay encrypted at rest (no
534 # plaintext .env). Lowest priority: Keychain, the config files, and process
535 # env all win over it. Two efficiency guards so a user who merely has `pass`
536 # on PATH doesn't pay for it: resolve the prefix from the loaded config/env
537 # (not import time, so a .env-set LAST30DAYS_PASS_PREFIX is honored), and
538 # probe ONLY keys still unset after the higher-priority sources — an empty
539 # list short-circuits with no gpg/pinentry calls at all.
540 pass_prefix = (
541 os.environ.get("LAST30DAYS_PASS_PREFIX")
542 or merged_env.get("LAST30DAYS_PASS_PREFIX")
543 or DEFAULT_PASS_PATH_PREFIX
544 )
545 pass_missing = [k for k in KEYCHAIN_KEYS if k not in os.environ and not merged_env.get(k)]
546 pass_env = _load_pass(pass_missing, pass_prefix)
547 merged_env = {**pass_env, **merged_env}
548
549 openai_auth = get_openai_auth(merged_env)
550
551 # Build config: Codex/OpenAI auth + process.env > project .env > global .env
552 config = {
553 'OPENAI_API_KEY': openai_auth.token,
554 'OPENAI_AUTH_SOURCE': openai_auth.source,
555 'OPENAI_AUTH_STATUS': openai_auth.status,
556 }
557
558 keys = [
559 # Debug flag; also exported to os.environ below so log.py's lazy
560 # os.environ.get() picks up .env values after get_config() runs.
561 ('LAST30DAYS_DEBUG', None),
562 ('XAI_API_KEY', None),
563 ('GOOGLE_API_KEY', None),
564 ('GEMINI_API_KEY', None),
565 ('GOOGLE_GENAI_API_KEY', None),
566 ('XIAOHONGSHU_API_BASE', None),
567 ('LAST30DAYS_REASONING_PROVIDER', 'auto'),
568 ('LAST30DAYS_PLANNER_MODEL', None),
569 ('LAST30DAYS_RERANK_MODEL', None),
570 ('LAST30DAYS_X_MODEL', None),
571 ('LAST30DAYS_X_BACKEND', None),
572 ('LAST30DAYS_REDDIT_BACKEND', None),
573 # Keyless reddit.com token-bucket rate (req/sec). http.py reads it
574 # from os.environ on each acquire, so .env values are exported below.
575 ('LAST30DAYS_REDDIT_KEYLESS_RATE', None),
576 # Doctor cache freshness window in seconds (doctor --cached).
577 ('LAST30DAYS_DOCTOR_TTL', None),
578 # Per-source deadline (seconds) for doctor --probe live checks.
579 ('LAST30DAYS_DOCTOR_PROBE_TIMEOUT', None),
580 ('LAST30DAYS_REDDIT_SC_MIN_ITEMS', None),
581 ('LAST30DAYS_STORE', None),
582 # Discovery topic queue (podcast/X-article pipeline memory). Default
583 # ON; the literal value "off" disables queue writes and annotations.
584 ('LAST30DAYS_DISCOVERY_QUEUE', None),
585 # Wall-clock budget (seconds) for the deep-tier enrichment batch on
586 # the discovery resume leg (--discover --judgments). Read from the
587 # resolved config only (pipeline._resume_enrich_budget_seconds);
588 # unset/invalid falls back to 450s. The one-shot --discover path
589 # keeps its fixed 240s quick budget regardless.
590 ('LAST30DAYS_ENRICH_BUDGET_SECONDS', None),
591 # Opt-in strict exit: truthy -> CLI exits 3 when any source outcome is
592 # degraded (neither ok, no-results, nor skipped-unconfigured). #384.
593 ('LAST30DAYS_STRICT_EXIT', None),
594 ('LAST30DAYS_MEMORY_DIR', None),
595 # Optional local-only evidence source. Paths are separated with the
596 # platform path separator (":" on macOS/Linux, ";" on Windows).
597 ('LAST30DAYS_CORPUS_DIRS', None),
598 # Corpus evidence is omitted from the stable agent JSON export unless
599 # this explicit privacy opt-in is truthy.
600 ('LAST30DAYS_CORPUS_IN_EXPORT', None),
601 ('LAST30DAYS_LIBRARY_OWNER', None),
602 ('LAST30DAYS_LIBRARY_CONTEXT', 'on'),
603 ('LAST30DAYS_PUBLISH_PASSWORD', None),
604 ('OPENAI_MODEL_PIN', None),
605 ('XAI_MODEL_PIN', None),
606 ('OPENAI_BASE_URL', None),
607 ('XAI_BASE_URL', None),
608 ('OPENROUTER_BASE_URL', None),
609 ('SCRAPECREATORS_API_KEY', None),
610 ('APIFY_API_TOKEN', None),
611 ('AUTH_TOKEN', None),
612 ('CT0', None),
613 ('BSKY_HANDLE', None),
614 ('BSKY_APP_PASSWORD', None),
615 ('BSKY_SEARCH_HOST', None),
616 ('TRUTHSOCIAL_TOKEN', None),
617 ('BRAVE_API_KEY', None),
618 ('EXA_API_KEY', None),
619 ('SERPER_API_KEY', None),
620 ('OPENROUTER_API_KEY', None),
621 ('PERPLEXITY_API_KEY', None),
622 ('LAST30DAYS_PERPLEXITY_MODE', 'agent'),
623 # Legacy Sonar setting. Retain it during migration so existing env
624 # files load, but the Agent adapter does not map it to a dynamic preset.
625 ('LAST30DAYS_PERPLEXITY_MODEL', None),
626 ('LAST30DAYS_PERPLEXITY_AGENT_MODEL', None),
627 ('LAST30DAYS_PERPLEXITY_AGENT_PRESET', None),
628 ('LAST30DAYS_PERPLEXITY_AGENT_MAX_STEPS', None),
629 ('LAST30DAYS_PERPLEXITY_AGENT_MAX_OUTPUT_TOKENS', None),
630 ('LAST30DAYS_PERPLEXITY_AGENT_TIMEOUT_SECONDS', '120'),
631 ('LAST30DAYS_PERPLEXITY_MAX_RESULTS', None),
632 ('LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE', None),
633 ('LAST30DAYS_PERPLEXITY_SEARCH_MODE', None),
634 ('LAST30DAYS_PERPLEXITY_DOMAIN_FILTER', None),
635 ('LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER', None),
636 ('LAST30DAYS_PERPLEXITY_COUNTRY', None),
637 ('LAST30DAYS_PERPLEXITY_RECENCY_FILTER', None),
638 ('LAST30DAYS_PERPLEXITY_REASONING_EFFORT', None),
639 ('LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS', '600'),
640 ('PARALLEL_API_KEY', None),
641 ('XQUIK_API_KEY', None),
642 # Bright Data CLI. Optional: the CLI normally owns its own auth via
643 # `brightdata login`, so this only matters for users who prefer an
644 # explicit key in a `.env` file or the keychain. Registered here so
645 # those layers reach the gate and the subprocess (-k) alike.
646 ('BRIGHTDATA_API_KEY', None),
647 # Amazon marketplace the amazon source searches. Non-US users point
648 # this at their own storefront (e.g. https://www.amazon.co.uk).
649 ('LAST30DAYS_AMAZON_DOMAIN', 'https://www.amazon.com'),
650 # Ad Library country for the meta_ads source, as a two-letter code. The
651 # endpoint takes exactly one country per call. There is deliberately no
652 # durable env form of the advertiser-page override: a page id is
653 # per-topic state, and env keys ride through the competitor runner's
654 # config copy, which would attach one brand's ads to every peer.
655 ('LAST30DAYS_META_ADS_COUNTRY', 'US'),
656 # Host-native search signal: set by the SKILL.md agent-host path when the
657 # invoking runtime has its own (better) web-search tool, so the engine's
658 # keyless search floor stays off there. Defaults unset -> floor allowed.
659 ('LAST30DAYS_NATIVE_SEARCH', None),
660 # Optional SearXNG instance for the keyless-search fallback rung.
661 ('LAST30DAYS_SEARXNG_URL', None),
662 # Truthy -> disable Trustpilot's headless-Chrome WAF-cookie harvest in
663 # automated contexts (cron/CI/eval). Read by trustpilot._harvest_allowed.
664 ('LAST30DAYS_TRUSTPILOT_NO_BROWSER', None),
665 ('FROM_BROWSER', None),
666 # agentcookie sidecar: soft-dep X cookie source (lib/agentcookie.py),
667 # active only on extra hosts (Linux / Mac mini / Darwin sink) or when
668 # set to "on". "off" disables the sidecar reader.
669 ('AGENTCOOKIE', None),
670 # Explicit Chrome DevTools endpoint for the extra-host CDP cookie
671 # lookup (lib/chrome_cdp.py), e.g. http://127.0.0.1:18800. Preferred
672 # over the 18800 / 9222+$DISPLAY defaults when set.
673 ('BROWSER_CDP_URL', None),
674 ('LAST30DAYS_TRUST_PROJECT_CONFIG', None),
675 ('SETUP_COMPLETE', None),
676 ('INCLUDE_SOURCES', ''),
677 ('EXCLUDE_SOURCES', ''),
678 ('LAST30DAYS_DEFAULT_SEARCH', ''),
679 # Resolve the user-facing default in last30days.py so an absent value
680 # stays distinguishable from an explicit `default`. That distinction
681 # lets the new key override legacy ELI5_MODE=true configurations.
682 ('LAST30DAYS_REGISTER', None),
683 ('FUN_LEVEL', 'medium'),
684 # Backward compatibility for configs written by the original `eli5 on`
685 # follow-up command. New writes use LAST30DAYS_REGISTER=eli5.
686 ('ELI5_MODE', None),
687 ('LAST30DAYS_YOUTUBE_SSH_HOST', None),
688 ('LAST30DAYS_REPORT_CACHE_TTL_SECONDS', None),
689 ('LAST30DAYS_VERIFY_FRESHNESS', None),
690 ('LAST30DAYS_TRANSCRIPT_TIMEOUT', None),
691 ('DEGRADED_TRANSCRIPT_THRESHOLD', None),
692 (KEYCHAIN_ALIASES_ENV, None),
693 # Whisper transcription provider for caption-free audio/video. Groq's
694 # free tier is preferred; OPENAI_API_KEY is the paid backstop (already
695 # resolved above via openai_auth).
696 ('GROQ_API_KEY', None),
697 ('LAST30DAYS_YT_SUB_LANGS', 'en,es,pt'),
698 # youtube_yt reads this lazily from os.environ; default android is
699 # applied there when the key is absent. Empty disables.
700 ('LAST30DAYS_YT_PLAYER_CLIENT', None),
701 ('LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT', None),
702 ('LAST30DAYS_YT_SEARCH_TIMEOUT', None),
703 ('GITHUB_TOKEN', None),
704 # Host self-identification. `grok-bot` switches the X policy
705 # to official-only (see x_policy); the engine never sniffs the host
706 # any other way. Persisted by first-run setup and exported per
707 # invocation by the SKILL.md rule.
708 (X_HOST_VAR, None),
709 # App-only bearer token for the direct X API v2 backend (`xapi`).
710 ('X_BEARER_TOKEN', None),
711 # Per-session X connector lane signal. Read from the process
712 # environment ONLY: a .env line is deliberately ignored (a removed
713 # connector must never leave a stale declaration), so it is handled
714 # in the loop below rather than via merged_env.
715 (X_HOST_LANE_VAR, None),
716 ]
717
718 for key, default in keys:
719 if key == X_HOST_LANE_VAR:
720 # Process env only; the .env value never reaches config.
721 config[key] = os.environ.get(key) or default
722 continue
723 if key == 'LAST30DAYS_YT_PLAYER_CLIENT':
724 # Empty string is a valid disable; `or` would treat it as unset.
725 if key in os.environ:
726 config[key] = os.environ.get(key)
727 elif key in merged_env:
728 # Mapping lookup via .get; bracket form trips a CRITICAL
729 # scanner false positive on this identifier.
730 config[key] = merged_env.get(key)
731 else:
732 config[key] = default
733 else:
734 config[key] = os.environ.get(key) or merged_env.get(key, default)
735
736 # Export debug flag to os.environ so log.py's lazy os.environ.get()
737 # picks up .env values. setdefault ensures a shell-exported value is
738 # never overwritten by the (lower-priority) .env value.
739 if config.get('LAST30DAYS_DEBUG'):
740 os.environ.setdefault('LAST30DAYS_DEBUG', config['LAST30DAYS_DEBUG'])
741
742 # youtube_yt reads these tuning knobs lazily from os.environ, so values
743 # loaded from .env must be exported into the current engine process.
744 for key in (
745 'LAST30DAYS_YT_SUB_LANGS',
746 'LAST30DAYS_YT_TRANSCRIPT_FAST_TIMEOUT',
747 'LAST30DAYS_YT_SEARCH_TIMEOUT',
748 'LAST30DAYS_REDDIT_KEYLESS_RATE',
749 'LAST30DAYS_YT_PLAYER_CLIENT',
750 ):
751 value = config.get(key)
752 # Empty LAST30DAYS_YT_PLAYER_CLIENT is a valid disable; other knobs
753 # treat empty as unset and keep their code defaults.
754 if key == 'LAST30DAYS_YT_PLAYER_CLIENT':
755 if value is not None:
756 os.environ.setdefault(key, value)
757 elif value:
758 os.environ.setdefault(key, value)
759
760 # Backward-compat: ScrapeCreators' own examples and tutorials use the
761 # SCRAPE_CREATORS_API_KEY spelling (with underscore between SCRAPE and
762 # CREATORS). Accept that form too so users who follow the vendor's docs
763 # don't silently end up with has_scrapecreators=False. Canonical name
764 # wins when both are set.
765 if not config.get('SCRAPECREATORS_API_KEY'):
766 legacy = read_secret_env('SCRAPE_CREATORS_API_KEY') or merged_env.get('SCRAPE_CREATORS_API_KEY')
767 if legacy:
768 config['SCRAPECREATORS_API_KEY'] = legacy
769
770 # Multi-key rotation: comma-separated SCRAPECREATORS_API_KEY round-robins
771 # via random.choice per run. Originally added in #268, accidentally dropped
772 # in v3.0.6, restored here.
773 _rotate_scrapecreators_key(config)
774
775 # Track which config source was used (highest-priority file source wins
776 # the label; keychain is only reported when nothing else is configured).
777 if project_env_path:
778 config['_CONFIG_SOURCE'] = f'project:{project_env_path}'
779 elif CONFIG_FILE and CONFIG_FILE.exists():
780 config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}'
781 elif keychain_env:
782 config['_CONFIG_SOURCE'] = 'keychain'
783 elif pass_env:
784 config['_CONFIG_SOURCE'] = 'pass'
785 else:
786 config['_CONFIG_SOURCE'] = 'env_only'
787 if ignored_project_env_path:
788 config['_IGNORED_PROJECT_CONFIG'] = str(ignored_project_env_path)
789 config['_IGNORED_PROJECT_CONFIG_KEYS'] = ignored_project_keys
790 config['_BROWSER_COOKIE_MODE'] = policy.browser_cookies
791 # A LAST30DAYS_X_HOST_LANE line in a config file is ignored;
792 # remember that it was there so doctor can say so.
793 config['_X_HOST_LANE_FILE_IGNORED'] = bool(merged_env.get(X_HOST_LANE_VAR))
794 # Evaluated after the host and pin keys are merged: on an official-only
795 # host the browser list is empty unless bird is pinned (x_policy).
796 config['_BROWSER_COOKIE_BROWSERS'] = cookie_extraction_browsers(config)
797
798 # Reject unsubstituted extension placeholders last among the value-producing
799 # steps, so the legacy ScrapeCreators spelling, the multi-key rotation, and
800 # the OpenAI auth fields assembled above are all covered by one sweep rather
801 # than by a predicate repeated at each presence check. Rejection means
802 # "absent", not "empty": the placeholder is removed from the process
803 # environment and the key is then re-resolved from the lower-priority
804 # sources exactly as it would be had the host never written it, so a real
805 # .env, Keychain, or pass credential it was shadowing is not discarded.
806 # Every consumer of a rejected config key therefore agrees the credential is
807 # unset, and the keys left genuinely unset are published for the diagnostics
808 # to report. The sweep is bounded by the keys get_config registers: a
809 # credential read straight from the environment under a name it does not
810 # register - LAST30DAYS_API_KEY, or a bare SCRAPE_CREATORS_API_KEY spelling
811 # left behind after the canonical key resolved - keeps its placeholder.
812 declared_defaults = {key: default for key, default in keys}
813 templated_keys = sorted(
814 key
815 for key, value in config.items()
816 if not key.startswith('_') and is_unsubstituted_template(value)
817 )
818 for key in templated_keys:
819 os.environ.pop(key, None)
820 fallback = merged_env.get(key)
821 # A lower-priority value that is itself a placeholder is not a credential.
822 if is_unsubstituted_template(fallback):
823 fallback = None
824 resolved = fallback if fallback is not None else declared_defaults.get(key)
825 config[key] = resolved if resolved is not None else ''
826 # The rotation ran before this sweep, so a fallback restored from a
827 # comma-separated list would otherwise reach a backend whole. Reapply it,
828 # then reject the picked key if it is itself a placeholder.
829 _rotate_scrapecreators_key(config)
830 if is_unsubstituted_template(config.get('SCRAPECREATORS_API_KEY')):
831 config['SCRAPECREATORS_API_KEY'] = ''
832 # Report only the keys still leaving the credential unset. A placeholder that
833 # fell through to a real lower-priority credential (or to a usable default)
834 # is handled, and reporting it would nag about a setup that works.
835 config[TEMPLATE_CONFIG_KEYS] = [
836 key for key in templated_keys if not config.get(key)
837 ]
838 if 'OPENAI_API_KEY' in templated_keys and not config.get('OPENAI_API_KEY'):
839 # Keep the derived auth record consistent with the token it describes.
840 config['OPENAI_AUTH_SOURCE'] = AUTH_SOURCE_NONE
841 config['OPENAI_AUTH_STATUS'] = AUTH_STATUS_MISSING
842
843 if policy.browser_cookies == "read":
844 _discover_and_apply_x_credentials(config)
845
846 # Fixture recording (--record-fixtures) must redact a credential that
847 # came from a file, Keychain, or pass, not only one exported in the
848 # shell. No-op outside a recording session.
849 from . import http as _http
850 _http.add_fixture_redactions(_http.config_secret_values(config))
851
852 return config
853
854
855 # ---------------------------------------------------------------------------
856 # Extra-host X cookie discovery (Linux, Mac mini, Darwin agentcookie sink)
857 # ---------------------------------------------------------------------------
858
859
860 def _mac_model() -> str:
861 """Darwin hardware model via ``sysctl -n hw.model``, or "" otherwise.
862
863 Returns "" on non-Darwin and on any sysctl failure (missing binary,
864 non-zero exit, timeout) — the caller treats "" as "not a Mac mini", i.e. a
865 MacBook, which is the conservative default (no extra cookie lookups).
866 """
867 import platform
868 if platform.system() != "Darwin":
869 return ""
870 import subprocess
871 try:
872 out = subprocess.run(
873 ["sysctl", "-n", "hw.model"],
874 capture_output=True, text=True, timeout=3,
875 )
876 except (OSError, subprocess.SubprocessError):
877 return ""
878 if out.returncode != 0:
879 return ""
880 return (out.stdout or "").strip()
881
882
883 def _is_mac_mini() -> bool:
884 """True on a Darwin Mac mini (``hw.model`` prefix ``Macmini``).
885
886 sysctl failure yields "" -> False, so an unreadable model is treated as a
887 MacBook (no extras), per the plan.
888 """
889 return _mac_model().startswith("Macmini")
890
891
892 def x_extras_enabled(config: dict[str, Any]) -> bool:
893 """Whether the two EXTRA bird cookie lookups (agentcookie sidecar, live
894 Chrome CDP) apply on this host.
895
896 Extras apply when ANY of:
897 * ``AGENTCOOKIE=on`` — explicit per-host opt-in (works on a MacBook too);
898 * platform is Linux;
899 * a Darwin Mac mini (``hw.model`` prefix ``Macmini``);
900 * a Darwin agentcookie **sink** role (parse failure = not sink).
901
902 A plain MacBook (Darwin, source/unknown role, no opt-in) stays on the
903 mainline path — no agentcookie subprocess, no CDP socket. The host is NEVER
904 inferred from the home directory, PATH, or ``HERMES_AGENT``/``OPENCLAW_CLI``
905 env: only the signals above.
906 """
907 import platform
908 raw = (config.get("AGENTCOOKIE") or read_secret_env("AGENTCOOKIE") or "").strip().lower()
909 if raw == "on":
910 return True
911 system = platform.system()
912 if system == "Linux":
913 return True
914 if system == "Darwin":
915 if _is_mac_mini():
916 return True
917 from . import agentcookie
918 return agentcookie.role_is_sink(config)
919 return False
920
921
922 def _apply_x_pair(config: dict[str, Any], auth_token: str, ct0: str, source: str) -> None:
923 """Apply a COMPLETE X cookie pair from one source, labeling its origin.
924
925 Atomic on purpose (both keys from the same source) so a half-pair from one
926 source is never merged with a half-pair from another. Never written to the
927 ``.env``; values are never logged.
928 """
929 config["AUTH_TOKEN"] = auth_token
930 config["CT0"] = ct0
931 config["_AUTH_TOKEN_SOURCE"] = source
932 config["_CT0_SOURCE"] = source
933
934
935 def _apply_browser_extract(config: dict[str, Any]) -> None:
936 """Run the mainline in-process browser cookie extractor (unchanged from
937 main): fills X (when a browser is opted in via FROM_BROWSER) and non-X
938 cookie domains like truthsocial. Missing keys only; source label ``browser``."""
939 browser_creds = extract_browser_credentials(config)
940 for key, value in browser_creds.items():
941 if not config.get(key):
942 config[key] = value
943 config[f"_{key}_SOURCE"] = "browser"
944
945
946 def _discover_and_apply_x_credentials(config: dict[str, Any]) -> None:
947 """Fill AUTH_TOKEN/CT0 for the bird backend, first COMPLETE pair wins.
948
949 Mainline (every host): the in-process browser extractor, gated by
950 FROM_BROWSER exactly as on ``main``. EXTRA lookups (agentcookie sidecar,
951 then live Chrome CDP) run ONLY on extra hosts (``x_extras_enabled``), so a
952 MacBook with FROM_BROWSER unset/off does no agentcookie spawn and no CDP
953 socket. Probe order:
954
955 1. an explicit env AUTH_TOKEN+CT0 already present — never overwritten;
956 2. agentcookie sidecar (extras only);
957 3. live Chrome CDP (extras only);
958 4. the mainline browser extract (all hosts; X only when FROM_BROWSER
959 lists a browser).
960
961 On a Mac mini that has already opted into browser reads (FROM_BROWSER set),
962 the native extract runs BEFORE CDP (R19) — a local Keychain read beats a
963 debug-port scrape. Never persists cookies; values are never logged.
964
965 On an official-only host (``x_policy``: ``LAST30DAYS_HOST=grok-bot``)
966 this returns before ANY leg, for every cookie domain, unless the pin is
967 ``bird`` (the one path that re-enables discovery for that run).
968 """
969 if not x_policy(config).cookie_discovery:
970 return
971
972 from . import agentcookie, chrome_cdp
973
974 def have_pair() -> bool:
975 return bool(config.get("AUTH_TOKEN") and config.get("CT0"))
976
977 extras = x_extras_enabled(config)
978
979 # (2) agentcookie sidecar — extras only, complete pair only.
980 if extras and not have_pair():
981 pair = agentcookie.read_x_cookies(config)
982 if pair:
983 _apply_x_pair(config, pair["auth_token"], pair["ct0"], "agentcookie")
984
985 # Mac mini + browser opted in: native extract before CDP (R19).
986 mini_extract_first = (
987 extras and _is_mac_mini() and bool(cookie_extraction_browsers(config))
988 )
989 if mini_extract_first and not have_pair():
990 _apply_browser_extract(config)
991
992 # (3) live Chrome CDP — extras only, complete pair only.
993 if extras and not have_pair():
994 pair = chrome_cdp.read_x_cookies(config)
995 if pair:
996 _apply_x_pair(config, pair["auth_token"], pair["ct0"], "chrome cdp")
997
998 # (4) mainline browser extract (unless already run above for the mini case).
999 if not mini_extract_first:
1000 _apply_browser_extract(config)
1001
1002
1003 # ---------------------------------------------------------------------------
1004 # Browser cookie extraction
1005 # ---------------------------------------------------------------------------
1006
1007 COOKIE_DOMAINS: dict[str, dict[str, Any]] = {
1008 "x": {
1009 "domain": ".x.com",
1010 "cookies": ["auth_token", "ct0"],
1011 "mapping": {"auth_token": "AUTH_TOKEN", "ct0": "CT0"},
1012 },
1013 "truthsocial": {
1014 "domain": ".truthsocial.com",
1015 "cookies": ["_session_id"],
1016 "mapping": {"_session_id": "TRUTHSOCIAL_TOKEN"},
1017 },
1018 }
1019
1020
1021 def cookie_extraction_browsers(config: dict[str, Any]) -> list[str]:
1022 """Browsers to try for cookie extraction, honoring FROM_BROWSER.
1023
1024 Default (FROM_BROWSER unset): no browser-cookie reads. The Chromium family
1025 (Chrome, Brave, Edge, Vivaldi, Opera, Arc, Chromium) is available only when
1026 explicitly selected because reading their cookies on macOS requires the
1027 browser's Safe Storage Keychain key, which triggers a system password prompt
1028 that cannot be reliably suppressed. On Windows only Firefox cookie
1029 extraction is supported; Chrome and Edge use DPAPI-encrypted cookie stores
1030 that are not yet supported.
1031
1032 - ``FROM_BROWSER=<name>`` - a single browser (e.g. ``firefox``, ``brave``,
1033 ``edge``, ``arc``).
1034 - ``FROM_BROWSER=firefox,safari`` - a comma-separated explicit browser list.
1035 - ``FROM_BROWSER=auto`` - also try every Chromium browser (user accepts the
1036 Keychain dialog when needed).
1037 - ``FROM_BROWSER=off`` - returns [] (extraction disabled).
1038
1039 Returning the browser list from one place keeps the setup wizard and the
1040 steady-state path on the same policy, so neither surprises the user with an
1041 unrequested Keychain prompt. On an official-only host (``x_policy``) the
1042 list is empty regardless of ``FROM_BROWSER`` unless ``bird`` is pinned.
1043 """
1044 if not x_policy(config).cookie_discovery:
1045 return []
1046 silent_browsers = ["firefox", "safari"]
1047 chromium_browsers = ["chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium"]
1048 known_browsers = silent_browsers + chromium_browsers
1049 from_browser = (config.get("FROM_BROWSER") or "").strip().lower()
1050 if not from_browser:
1051 return []
1052 if from_browser == "off":
1053 return []
1054 if from_browser == "auto":
1055 return silent_browsers + chromium_browsers
1056 if "," in from_browser:
1057 requested = [b.strip() for b in from_browser.split(",") if b.strip()]
1058 resolved = [b for b in requested if b in known_browsers]
1059 unknown = [b for b in requested if b not in known_browsers]
1060 if unknown:
1061 sys.stderr.write(
1062 "[last30days] WARNING: FROM_BROWSER ignored unrecognized browser(s): "
1063 f"{', '.join(unknown)} (known: {', '.join(known_browsers)})\n"
1064 )
1065 sys.stderr.flush()
1066 return resolved
1067 if from_browser in known_browsers:
1068 return [from_browser]
1069 # Non-empty, not off/auto, not a known browser, not a list: unrecognized.
1070 # Warn rather than fail silently so a typo (FROM_BROWSER=chrme) is visible
1071 # instead of looking like "no cookies found".
1072 sys.stderr.write(
1073 f"[last30days] WARNING: FROM_BROWSER='{from_browser}' is not a recognized "
1074 f"browser; no cookies will be read (known: {', '.join(known_browsers)}, "
1075 "or 'auto'/'off')\n"
1076 )
1077 sys.stderr.flush()
1078 return []
1079
1080
1081
1082 def extract_browser_credentials(config: dict[str, Any]) -> dict[str, str]:
1083 """Extract auth cookies from local browsers.
1084
1085 Browser selection (and the Chrome-prompt caveat) is handled by
1086 ``cookie_extraction_browsers``; this function just runs the extraction for
1087 each configured cookie domain.
1088 """
1089 browsers = cookie_extraction_browsers(config)
1090 if not browsers:
1091 return {}
1092 try:
1093 from . import cookie_extract
1094 except ImportError:
1095 return {}
1096 extracted: dict[str, str] = {}
1097 for _service, spec in COOKIE_DOMAINS.items():
1098 if all(config.get(env_key) for env_key in spec["mapping"].values()):
1099 continue
1100 for browser in browsers:
1101 try:
1102 cookies = cookie_extract.extract_cookies(browser, spec["domain"], spec["cookies"])
1103 except Exception:
1104 continue
1105 if cookies:
1106 for cookie_name, env_key in spec["mapping"].items():
1107 if cookie_name in cookies and not config.get(env_key):
1108 extracted[env_key] = cookies[cookie_name]
1109 break # Found cookies for this service, stop trying browsers
1110 return extracted
1111
1112
1113 # Auth-origin label per X backend for ``get_x_source_with_method`` (bird's
1114 # label is the cookie source recorded in ``_AUTH_TOKEN_SOURCE``).
1115 _X_METHOD_LABELS = {
1116 "xai": "xai",
1117 "xurl": "oauth2", # xurl CLI (official X API v2, OAuth2, free developer app)
1118 "xapi": "bearer",
1119 "xquik": "api_key",
1120 }
1121
1122
1123 def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]:
1124 """Return (source, method) for X search, where method describes the auth origin.
1125
1126 Walks the policy's unpinned auto chain (``x_auto_chain``): on a default
1127 host bird first (cookies beat XAI_API_KEY when both are present), then
1128 xai, xurl, xquik; on an official-only host xapi, xai, xurl. Opt-in
1129 backends (grok, and xapi off Grok Bot) are never auto-selected here.
1130 """
1131 has_bird_creds = bool(config.get("AUTH_TOKEN") and config.get("CT0"))
1132 for backend in x_auto_chain(config):
1133 if backend == "bird":
1134 # Cookie presence only: the scraper install is not consulted
1135 # here (unlike ``x_backend_chain``), so a fresh cookie-bearing
1136 # config reports bird before the binary is checked.
1137 if not has_bird_creds:
1138 continue
1139 elif not _x_backend_available(backend, config, has_bird_creds):
1140 continue
1141 if backend == "bird":
1142 return "bird", config.get("_AUTH_TOKEN_SOURCE", "env")
1143 return backend, _X_METHOD_LABELS.get(backend, "none")
1144 return None, "none"
1145
1146
1147 def config_exists(policy: ConfigLoadPolicy | None = None) -> bool:
1148 """Check if any configuration source exists."""
1149 policy = policy or ConfigLoadPolicy()
1150 file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE and CONFIG_FILE.exists() else {}
1151 if _project_config_trusted(policy, file_env) and _find_project_env():
1152 return True
1153 if CONFIG_FILE:
1154 return CONFIG_FILE.exists()
1155 return False
1156
1157
1158 def get_reddit_source(config: dict[str, Any]) -> str | None:
1159 """Determine which Reddit backend to use.
1160
1161 Returns: 'scrapecreators' or None
1162 """
1163 if config.get('SCRAPECREATORS_API_KEY'):
1164 return 'scrapecreators'
1165 return None
1166
1167
1168 # Default X backend priority. The first available backend is the primary X
1169 # source; the rest are ordered failover backups, tried only if the one before
1170 # returns nothing or errors. There is one X source ("x"); these are its
1171 # interchangeable backends, never run in parallel.
1172 # bird — X GraphQL scrape via the user's browser cookies (AUTH_TOKEN/CT0)
1173 # xai — xAI/Grok live search (XAI_API_KEY)
1174 # xurl — official X API v2 (xurl CLI, OAuth2)
1175 # xquik — key-based REST X search (XQUIK_API_KEY)
1176 _X_BACKEND_ORDER = ("bird", "xai", "xurl", "xquik")
1177
1178 # Opt-in backends: never in the default unpinned auto chain; require an
1179 # explicit pin. grok is here because a leftover ~/.grok/auth.json must never
1180 # steal the X lane. xapi (direct X API v2 with X_BEARER_TOKEN) is here so an
1181 # ambient bearer exported for some other tool never spends X API credits
1182 # every time the cookie scraper comes back empty; on an official-only
1183 # host it is the first rung of the auto chain instead (see _X_OFFICIAL).
1184 _X_BACKEND_OPT_IN = ("grok", "xapi")
1185
1186 # All known backends (auto chain + opt-in): valid values for the pin var.
1187 _X_BACKEND_KNOWN = _X_BACKEND_ORDER + _X_BACKEND_OPT_IN
1188
1189 # Licensed / official backends: the unpinned auto chain on an official-only
1190 # host. xapi = X API v2 with an app-only bearer, xai = xAI's licensed
1191 # X search, xurl = the X API through X's own CLI.
1192 _X_OFFICIAL = ("xapi", "xai", "xurl")
1193
1194 # Host self-identification key and the one value that switches the X
1195 # policy. The engine trusts this key alone: it never infers the host from
1196 # the home directory, PATH, platform, or agent env vars.
1197 X_HOST_VAR = 'LAST30DAYS_HOST'
1198 GROK_BOT_HOST = 'grok-bot'
1199 # Per-session X connector lane signal: process env only.
1200 X_HOST_LANE_VAR = 'LAST30DAYS_X_HOST_LANE'
1201
1202 # Public routing definitions for the doctor/backend-descriptor layer
1203 # (lib/backends.py). These are aliases for knowledge this module already
1204 # owns — the declared X chain order and the pin/floor env var names — so
1205 # descriptors import one source of truth instead of restating it.
1206 X_BACKEND_ORDER = _X_BACKEND_ORDER
1207 X_BACKEND_OPT_IN = _X_BACKEND_OPT_IN
1208 X_BACKEND_KNOWN = _X_BACKEND_KNOWN
1209 X_OFFICIAL = _X_OFFICIAL
1210 X_BACKEND_PIN_VAR = 'LAST30DAYS_X_BACKEND'
1211 REDDIT_BACKEND_PIN_VAR = 'LAST30DAYS_REDDIT_BACKEND'
1212 REDDIT_SC_MIN_ITEMS_VAR = 'LAST30DAYS_REDDIT_SC_MIN_ITEMS'
1213
1214
1215 @dataclass(frozen=True)
1216 class XPolicy:
1217 """The host-conditional X routing rule, resolved once per config.
1218
1219 ``host`` is the normalized ``LAST30DAYS_HOST`` value; ``official_only``
1220 is true on a Grok Bot host; ``auto_chain`` is the unpinned chain
1221 (``_X_OFFICIAL`` when official-only, else ``_X_BACKEND_ORDER``);
1222 ``cookie_discovery`` is false when official-only unless the pin is
1223 ``bird``; ``hint_namespace`` (``official`` or ``default``) is derived
1224 from ``official_only`` as a plain string so this module never imports
1225 ``prescriptions``.
1226 """
1227
1228 host: str
1229 official_only: bool
1230 auto_chain: tuple[str, ...]
1231 cookie_discovery: bool
1232
1233 @property
1234 def hint_namespace(self) -> str:
1235 return 'official' if self.official_only else 'default'
1236
1237
1238 def x_backend_pin(config: dict[str, Any]) -> str:
1239 """The normalized ``LAST30DAYS_X_BACKEND`` pin value ("" when unset)."""
1240 return (config.get(X_BACKEND_PIN_VAR) or '').strip().lower()
1241
1242
1243 def x_policy(config: dict[str, Any]) -> XPolicy:
1244 """Resolve the X policy from ``LAST30DAYS_HOST`` and the pin.
1245
1246 This is the ONLY place the Grok Bot host string is compared. It reads
1247 just the host key, the pin, and the config dict: no platform, PATH, home
1248 directory, or agent env-var inspection (the same rule ``x_extras_enabled``
1249 follows), and nothing imported from ``lib``. The pin keeps its exclusive
1250 semantics on every host and may name any known backend; a ``bird`` pin
1251 is the one path that re-enables cookie discovery on an official-only host.
1252 """
1253 host = str(config.get(X_HOST_VAR) or '').strip().lower()
1254 official_only = host == GROK_BOT_HOST
1255 pin = x_backend_pin(config)
1256 return XPolicy(
1257 host=host,
1258 official_only=official_only,
1259 auto_chain=_X_OFFICIAL if official_only else _X_BACKEND_ORDER,
1260 cookie_discovery=(not official_only) or pin == 'bird',
1261 )
1262
1263
1264 def x_auto_chain(config: dict[str, Any]) -> list[str]:
1265 """The unpinned X auto chain for this host, in failover order."""
1266 return list(x_policy(config).auto_chain)
1267
1268
1269 def x_host_lane_declared(config: dict[str, Any]) -> bool:
1270 """True when the hosting model declared the X connector lane.
1271
1272 ``get_config`` fills ``LAST30DAYS_X_HOST_LANE`` from the process
1273 environment only, so a ``.env`` line never declares the lane.
1274 Deliberately NOT ``x_pending_browser_auth``: that predicate is false in
1275 cookie-read mode by contract, which would leave the envelope path dead at
1276 research time. Host-independent: the envelope is accepted anywhere.
1277 """
1278 return _truthy(config.get(X_HOST_LANE_VAR))
1279
1280
1281 def _x_backend_available(
1282 backend: str,
1283 config: dict[str, Any],
1284 has_bird_creds: bool,
1285 local_only: bool = False,
1286 ) -> bool:
1287 if backend == 'xai':
1288 return bool(config.get('XAI_API_KEY'))
1289 if backend == 'grok':
1290 # Keyless relative to X: needs only an installed, signed-in grok CLI.
1291 # Both surfaces are filesystem-only (PATH lookup + credential store),
1292 # so local_only needs no separate branch.
1293 from . import grok_x
1294 return grok_x.has_stored_auth()
1295 if backend == 'bird':
1296 from . import bird_x
1297 return has_bird_creds and bird_x.is_bird_installed()
1298 if backend == 'xurl':
1299 from . import xurl_x
1300 if local_only:
1301 # Doctor/safe-diagnose path: local evidence only (PATH lookup +
1302 # token store) — never the live `xurl whoami` network call.
1303 return xurl_x.has_stored_auth()
1304 return xurl_x.is_available()
1305 if backend == 'xquik':
1306 return is_xquik_available(config)
1307 if backend == 'xapi':
1308 # Key presence only (no network); local_only needs no branch.
1309 return bool(config.get('X_BEARER_TOKEN'))
1310 return False
1311
1312
1313 def x_backend_chain(config: dict[str, Any], local_only: bool = False) -> list[str]:
1314 """Ordered list of available X backends.
1315
1316 ``chain[0]`` is the default X source; the remaining entries are failover
1317 backups, used only when the one before yields no items or errors. There is
1318 exactly one X source — these are its backends, never fetched in parallel.
1319
1320 A ``LAST30DAYS_X_BACKEND`` pin forces a single backend (no failover): the
1321 user explicitly chose it. Valid pin values are in ``_X_BACKEND_KNOWN``
1322 (the auto chain plus opt-in backends like grok). Browser-cookie probing
1323 is intentionally avoided (automatic Keychain access causes popups); bird
1324 counts as available only when AUTH_TOKEN and CT0 are present explicitly.
1325
1326 Unpinned runs walk only ``_X_BACKEND_ORDER``: opt-in backends like grok
1327 are never auto-selected. A leftover ~/.grok/auth.json must not steal the
1328 X lane; pin ``LAST30DAYS_X_BACKEND=grok`` to enable it explicitly.
1329
1330 ``local_only=True`` is the doctor/safe-diagnose flavor: availability is
1331 answered from local evidence only (no subprocess spawns that reach the
1332 network — xurl's live `whoami` check is replaced by its on-disk token
1333 store). Research-time callers keep the default live semantics.
1334
1335 The unpinned walk is ``x_policy(config).auto_chain``: the default order
1336 above on every host, or ``_X_OFFICIAL`` (xapi -> xai -> xurl) on an
1337 official-only host. The scraper is primed with cookies only when bird
1338 ends up in the resulting chain (never on an official-only host unless
1339 bird is pinned).
1340 """
1341 has_bird_creds = bool(config.get('AUTH_TOKEN') and config.get('CT0'))
1342
1343 preferred = x_backend_pin(config)
1344 # Pin accepted from _X_BACKEND_KNOWN (auto chain + opt-in like grok).
1345 if preferred in _X_BACKEND_KNOWN:
1346 if _x_backend_available(preferred, config, has_bird_creds, local_only):
1347 chain = [preferred]
1348 else:
1349 chain = []
1350 else:
1351 # Unpinned: walk the policy's auto chain. Opt-in backends (grok, and
1352 # xapi off an official-only host) are never auto-selected.
1353 chain = [
1354 b for b in x_policy(config).auto_chain
1355 if _x_backend_available(b, config, has_bird_creds, local_only)
1356 ]
1357
1358 if 'bird' in chain:
1359 from . import bird_x
1360 bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
1361 return chain
1362
1363
1364 def get_x_source(config: dict[str, Any], local_only: bool = False) -> str | None:
1365 """The default (primary) X backend, or None if no X source is available.
1366
1367 Thin wrapper over ``x_backend_chain`` returning the first/primary backend;
1368 callers that want failover should use ``x_backend_chain`` directly.
1369 ``local_only`` is forwarded (see ``x_backend_chain``).
1370 """
1371 chain = x_backend_chain(config, local_only=local_only)
1372 return chain[0] if chain else None
1373
1374
1375 def x_pending_browser_auth(config: dict[str, Any], local_only: bool = False) -> bool:
1376 """True when X is not available now but ``FROM_BROWSER`` will authenticate it at run time.
1377
1378 ``--diagnose`` / ``--preflight`` load config in ``plan_only`` mode, which
1379 deliberately skips browser-cookie extraction (no Keychain popup,
1380 ``reads_values: false``). As a result ``get_x_source`` returns None and X is
1381 dropped from ``available_sources`` even though a normal run would extract the
1382 same cookies and authenticate X fine. This predicate reports that
1383 "available pending browser auth" state without reading a single cookie — it
1384 keys only on the resolved browser list (``cookie_extraction_browsers``
1385 derives it from ``FROM_BROWSER`` alone, no secrets) OR — on extra hosts
1386 only (``x_extras_enabled``) — the agentcookie sidecar being on PATH (a plain
1387 ``which`` lookup), bird being installed, and X having a cookie-domain
1388 mapping. A plain MacBook must NOT predict bird from an agentcookie binary on
1389 PATH (R18), so the sidecar leg is gated behind ``x_extras_enabled``.
1390 Side-effect free, so the safe-inspection contract of diagnose/preflight is
1391 preserved.
1392
1393 Returns False whenever X is already available outright (static AUTH_TOKEN/CT0,
1394 or xAI/xurl/xquik backend), and in ``read`` mode (a real run has already
1395 extracted creds, so its status must be unchanged — never "pending").
1396 """
1397 # Already available via a static backend (bird creds, xAI, xurl, xquik).
1398 # local_only (doctor/safe-diagnose) answers the xurl leg from the token
1399 # store instead of the live `xurl whoami` network call.
1400 if get_x_source(config, local_only=local_only):
1401 return False
1402 # Only meaningful in inspection modes that skip extraction; a real ``read``
1403 # run has already attempted extraction and must report its true state.
1404 if config.get('_BROWSER_COOKIE_MODE') == 'read':
1405 return False
1406 # Cookie-only predicate: on an official-only host no run-time cookie
1407 # source exists unless bird is pinned (x_policy), so nothing is pending.
1408 if not x_policy(config).cookie_discovery:
1409 return False
1410 if 'x' not in COOKIE_DOMAINS:
1411 return False
1412 from . import bird_x
1413 if not bird_x.is_bird_installed():
1414 return False
1415 # A FROM_BROWSER browser is a run-time cookie source on any host.
1416 if cookie_extraction_browsers(config):
1417 return True
1418 # The agentcookie sidecar is a run-time cookie source ONLY on extra hosts
1419 # (Linux / Mac mini / Darwin sink / AGENTCOOKIE=on). Gating this keeps a
1420 # plain MacBook from predicting bird off a stray agentcookie binary (R18).
1421 if x_extras_enabled(config):
1422 from . import agentcookie
1423 if agentcookie.is_available(config):
1424 return True
1425 return False
1426
1427
1428 def is_ytdlp_available() -> bool:
1429 """Check if yt-dlp is installed for YouTube search."""
1430 from . import youtube_yt
1431 return youtube_yt.is_ytdlp_installed()
1432
1433
1434 def is_youtube_comments_available(config: dict[str, Any]) -> bool:
1435 """Check if YouTube comment enrichment is available.
1436
1437 yt-dlp fetches YouTube comments free and keyless, so when it is installed
1438 comments need no credential and no ``INCLUDE_SOURCES`` opt-in — the opt-in
1439 only ever existed to gate ScrapeCreators credit spend, and there is none to
1440 gate. ``EXCLUDE_SOURCES=youtube_comments`` remains the off-switch.
1441
1442 Without yt-dlp, the legacy ScrapeCreators path still applies: it requires
1443 SCRAPECREATORS_API_KEY AND ``youtube_comments`` in ``INCLUDE_SOURCES``
1444 (mirroring ``is_tiktok_comments_available``), bounded by
1445 ``enrich_with_comments(max_videos=3)`` at ~3 credits per run.
1446 """
1447 if 'youtube_comments' in _parse_exclude_sources(config):
1448 return False
1449 if is_ytdlp_available():
1450 return True
1451 if not config.get('SCRAPECREATORS_API_KEY'):
1452 return False
1453 return 'youtube_comments' in _parse_include_sources(config)
1454
1455
1456 def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
1457 """Check if TikTok comment enrichment is available.
1458
1459 Requires SCRAPECREATORS_API_KEY AND tiktok_comments in INCLUDE_SOURCES.
1460 Mirrors the youtube_comments opt-in pattern.
1461 """
1462 if not config.get('SCRAPECREATORS_API_KEY'):
1463 return False
1464 include = _parse_include_sources(config)
1465 return 'tiktok_comments' in include
1466
1467
1468 def is_instagram_comments_available(config: dict[str, Any]) -> bool:
1469 """Check if Instagram comment enrichment is available.
1470
1471 Requires SCRAPECREATORS_API_KEY AND instagram_comments in INCLUDE_SOURCES.
1472 Mirrors the youtube_comments / tiktok_comments opt-in pattern. Comments are
1473 fetched via ScrapeCreators (GET /v2/instagram/post/comments) with each
1474 comment's ``comment_like_count`` used as its vote for ranking. Part of the
1475 default onboarding tier (posts on -> comments on for TikTok/Instagram/YouTube).
1476 """
1477 if not config.get('SCRAPECREATORS_API_KEY'):
1478 return False
1479 return 'instagram_comments' in _parse_include_sources(config)
1480
1481
1482 def is_youtube_sc_available(config: dict[str, Any]) -> bool:
1483 """Check if ScrapeCreators YouTube search fallback is available.
1484
1485 Used when yt-dlp is not installed or fails.
1486 """
1487 return bool(config.get('SCRAPECREATORS_API_KEY'))
1488
1489
1490 def is_hackernews_available() -> bool:
1491 """Check if Hacker News source is available.
1492
1493 Always returns True - HN uses free Algolia API, no key needed.
1494 """
1495 return True
1496
1497
1498 def is_native_search(config: dict[str, Any]) -> bool:
1499 """Whether the invoking host has its own (better) native web search.
1500
1501 Defined by capability, not host identity: the SKILL.md agent-host path sets
1502 ``LAST30DAYS_NATIVE_SEARCH`` when the runtime actually has a native web-search
1503 tool (e.g. Claude Code's WebSearch). When true, the engine's keyless search
1504 floor is suppressed so a worse free search never preempts the model's own.
1505 Defaults False (unset), so headless/cron and hosts without native search fall
1506 to the keyless floor.
1507 """
1508 raw = config.get('LAST30DAYS_NATIVE_SEARCH')
1509 if raw is None:
1510 return False
1511 return str(raw).strip().lower() in ('1', 'true', 'yes', 'on')
1512
1513
1514 def keyless_web_allowed(config: dict[str, Any]) -> bool:
1515 """Whether the engine may use its keyless web-search floor for this run.
1516
1517 Allowed only when the host does NOT have native search. Independent of
1518 whether a paid key is set (the grounding dispatcher prefers paid first and
1519 falls to keyless on empty/error for non-native runs).
1520 """
1521 return not is_native_search(config)
1522
1523
1524 def transcription_providers(config: dict[str, Any]) -> list[tuple[str, str]]:
1525 """Ordered (name, api_key) Whisper providers for caption-free transcription.
1526
1527 Groq (free tier) first, OpenAI (paid) as the backstop. Empty when neither
1528 key is set, in which case transcription degrades rather than runs.
1529 """
1530 providers: list[tuple[str, str]] = []
1531 if config.get('GROQ_API_KEY'):
1532 providers.append(('groq', config['GROQ_API_KEY']))
1533 if config.get('OPENAI_API_KEY'):
1534 providers.append(('openai', config['OPENAI_API_KEY']))
1535 return providers
1536
1537
1538 def is_bluesky_available(config: dict[str, Any]) -> bool:
1539 """Check if Bluesky source is available.
1540
1541 Requires BSKY_HANDLE and BSKY_APP_PASSWORD (app password from bsky.app/settings).
1542 """
1543 return bool(config.get('BSKY_HANDLE') and config.get('BSKY_APP_PASSWORD'))
1544
1545
1546 def is_truthsocial_available(config: dict[str, Any]) -> bool:
1547 """Check if Truth Social source is available.
1548
1549 Requires TRUTHSOCIAL_TOKEN (bearer token from browser dev tools).
1550 """
1551 return bool(config.get('TRUTHSOCIAL_TOKEN'))
1552
1553
1554 def is_polymarket_available() -> bool:
1555 """Check if Polymarket source is available.
1556
1557 Always returns True - Gamma API is free, no key needed.
1558 """
1559 return True
1560
1561
1562 def is_tiktok_available(config: dict[str, Any]) -> bool:
1563 """Check if TikTok source is available (ScrapeCreators or legacy Apify).
1564
1565 Returns True if SCRAPECREATORS_API_KEY or APIFY_API_TOKEN is set.
1566 """
1567 return bool(config.get('SCRAPECREATORS_API_KEY') or config.get('APIFY_API_TOKEN'))
1568
1569
1570 def get_tiktok_token(config: dict[str, Any]) -> str:
1571 """Get TikTok API token, preferring ScrapeCreators over legacy Apify."""
1572 return config.get('SCRAPECREATORS_API_KEY') or config.get('APIFY_API_TOKEN') or ''
1573
1574
1575 def _parse_include_sources(config: dict[str, Any]) -> set[str]:
1576 """Parse INCLUDE_SOURCES config value into a set of lowercase source names."""
1577 raw = config.get('INCLUDE_SOURCES') or ''
1578 return {s.strip().lower() for s in raw.split(',') if s.strip()}
1579
1580
1581 def _parse_exclude_sources(config: dict[str, Any]) -> set[str]:
1582 """Parse EXCLUDE_SOURCES config value into a set of lowercase source names."""
1583 raw = config.get('EXCLUDE_SOURCES') or ''
1584 return {s.strip().lower() for s in raw.split(',') if s.strip()}
1585
1586
1587 def include_sources(config: dict[str, Any]) -> set[str]:
1588 """Public view of the parsed INCLUDE_SOURCES set.
1589
1590 Thin wrapper over ``_parse_include_sources`` so other modules (doctor,
1591 etc.) don't reach into env's privates.
1592 """
1593 return _parse_include_sources(config)
1594
1595
1596 def is_setup_complete(config: dict[str, Any]) -> bool:
1597 """Whether guided setup marked this config complete (SETUP_COMPLETE truthy).
1598
1599 Thin wrapper over ``_truthy`` so other modules don't reach into env's
1600 privates.
1601 """
1602 return _truthy(config.get('SETUP_COMPLETE'))
1603
1604
1605 def is_threads_available(config: dict[str, Any]) -> bool:
1606 """Check if the Threads credential is available.
1607
1608 Returns True when SCRAPECREATORS_API_KEY is set. This is an availability
1609 predicate only: whether Threads is actually *scheduled* is gated in the
1610 pipeline's ``available_sources`` by an ``INCLUDE_SOURCES=threads`` opt-in
1611 (the onboarding "Everything" tier), so a key alone no longer runs Threads.
1612 """
1613 return bool(config.get('SCRAPECREATORS_API_KEY'))
1614
1615
1616 def is_instagram_available(config: dict[str, Any]) -> bool:
1617 """Check if Instagram source is available (ScrapeCreators).
1618
1619 Returns True if SCRAPECREATORS_API_KEY is set.
1620 Instagram uses the same key as TikTok.
1621 """
1622 return bool(config.get('SCRAPECREATORS_API_KEY'))
1623
1624
1625 def get_instagram_token(config: dict[str, Any]) -> str:
1626 """Get Instagram API token (same ScrapeCreators key as TikTok)."""
1627 return config.get('SCRAPECREATORS_API_KEY') or ''
1628
1629
1630 def get_xiaohongshu_api_base(config: dict[str, Any]) -> str:
1631 """Get Xiaohongshu HTTP API base URL.
1632
1633 The availability probe caches the first logged-in local service it finds so
1634 the later search request uses the same browser-backed session endpoint.
1635 """
1636 cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY)
1637 if cached:
1638 return str(cached).rstrip("/")
1639
1640 explicit = config.get("XIAOHONGSHU_API_BASE")
1641 if explicit:
1642 return str(explicit).rstrip("/")
1643
1644 return XIAOHONGSHU_DEFAULT_API_BASES[0]
1645
1646
1647 def _xiaohongshu_api_base_candidates(config: dict[str, Any]) -> list[str]:
1648 explicit = config.get("XIAOHONGSHU_API_BASE")
1649 if explicit:
1650 return [str(explicit).rstrip("/")]
1651
1652 candidates: list[str] = []
1653 cached = config.get(XIAOHONGSHU_RESOLVED_API_BASE_KEY)
1654 if cached:
1655 candidates.append(str(cached).rstrip("/"))
1656
1657 for base in XIAOHONGSHU_DEFAULT_API_BASES:
1658 if base not in candidates:
1659 candidates.append(base)
1660 return candidates
1661
1662
1663 def _xiaohongshu_base_logged_in(base: str, http_module: Any) -> bool:
1664 # Keep the health probe snappy, but allow one retry for transient hiccups.
1665 health = http_module.get(f"{base}/health", timeout=3, retries=2)
1666 if not isinstance(health, dict):
1667 return False
1668 if not health.get("success"):
1669 return False
1670
1671 # Login checks can be slower because some services consult the browser
1672 # profile/session, so use a slightly longer timeout than the health probe.
1673 login = http_module.get(f"{base}/api/v1/login/status", timeout=8, retries=2)
1674 is_logged_in = (
1675 login.get("data", {}).get("is_logged_in")
1676 if isinstance(login, dict) else False
1677 )
1678 return bool(is_logged_in)
1679
1680
1681 def is_xiaohongshu_available(config: dict[str, Any]) -> bool:
1682 """Check whether Xiaohongshu HTTP API is reachable and logged in."""
1683 # Import here to avoid heavy imports at module load.
1684 from . import http
1685
1686 for base in _xiaohongshu_api_base_candidates(config):
1687 try:
1688 if _xiaohongshu_base_logged_in(base, http):
1689 config[XIAOHONGSHU_RESOLVED_API_BASE_KEY] = base
1690 return True
1691 except (OSError, http.HTTPError):
1692 continue
1693 except Exception as exc:
1694 sys.stderr.write(
1695 f"[last30days] WARNING: unexpected error checking Xiaohongshu "
1696 f"at {base}: {type(exc).__name__}: {exc}\n"
1697 )
1698 sys.stderr.flush()
1699 return False
1700
1701
1702 # Backward compat alias
1703 is_apify_available = is_tiktok_available
1704
1705
1706 def get_x_source_status(config: dict[str, Any], probe: bool = False) -> dict[str, Any]:
1707 """Get detailed X source status for UI decisions.
1708
1709 Args:
1710 probe: when True, run a cheap 1-tweet bird probe and downgrade
1711 ``bird_authenticated`` to False when X clearly returns nothing,
1712 so ``--diagnose`` reflects runtime reality instead of static
1713 credential presence. A transient timeout leaves the status
1714 unchanged (fail open). When False (the safe/diagnose path that
1715 doctor uses), NO network is touched: xurl availability comes
1716 from local evidence (``xurl_x.has_stored_auth``), never the
1717 live ``xurl whoami`` call.
1718
1719 Returns:
1720 Dict with keys: source, bird_installed, bird_authenticated,
1721 bird_username, xai_available, can_install_bird
1722 """
1723 from . import bird_x
1724
1725 # Backends this host may run: the policy's auto chain plus a known pin.
1726 # Bird is primed/probed and xquik is probed only when they are in that
1727 # set, so an official-only host never touches the scraper or the
1728 # third-party API unless the backend is pinned.
1729 policy = x_policy(config)
1730 pin = x_backend_pin(config)
1731 considered = set(policy.auto_chain)
1732 if pin in _X_BACKEND_KNOWN:
1733 considered.add(pin)
1734
1735 if 'bird' in considered and config.get('AUTH_TOKEN') and config.get('CT0'):
1736 bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
1737 bird_status = dict(bird_x.get_bird_status())
1738 if 'bird' not in considered:
1739 # Never report the scraper as usable where the policy forbids it.
1740 bird_status["authenticated"] = False
1741 xai_available = bool(config.get('XAI_API_KEY'))
1742 xapi_available = bool(config.get('X_BEARER_TOKEN'))
1743
1744 # Report the TRUE auth lane (browser / env / keychain) rather than the static
1745 # "env AUTH_TOKEN" label — tokens usually come from live browser cookies, and
1746 # mislabeling the lane sent past debugging down a 30-minute wrong path.
1747 if bird_status["authenticated"]:
1748 lane = config.get('_AUTH_TOKEN_SOURCE') or 'env'
1749 bird_status["username"] = f"{lane} AUTH_TOKEN"
1750
1751 # Optional runtime probe: don't show X green when it's effectively dead.
1752 if probe and bird_status["authenticated"]:
1753 if bird_x.probe_works() is False:
1754 bird_status["authenticated"] = False
1755 bird_status["username"] = "probe failed (no working X auth)"
1756
1757 # Xquik: the key-based X source used when bird's cookie auth isn't available.
1758 # Probe so --diagnose reports the true state — funded, or configured-but-
1759 # unpaid (402) — instead of false-green on mere key presence.
1760 xquik_available = is_xquik_available(config)
1761 xquik_working: bool | None = None
1762 xquik_status = ""
1763 if xquik_available and 'xquik' in considered:
1764 if probe:
1765 from . import xquik
1766 xquik_working = xquik.probe_works(get_xquik_token(config))
1767 xquik_status = xquik.probe_reason()
1768 else:
1769 xquik_status = "configured (not probed)"
1770
1771 # Xurl availability, computed ONCE. probe=True (a live diagnose) may run
1772 # the real `xurl whoami`; probe=False is the safe path (doctor,
1773 # --diagnose, --preflight) and must stay local-only — the live check is
1774 # an authenticated X API network call.
1775 from . import xurl_x as _xurl_x
1776 xurl_available = _xurl_x.is_available() if probe else _xurl_x.has_stored_auth()
1777
1778 # Grok availability is filesystem-only on both paths (PATH lookup plus the
1779 # credential store), so it is safe to compute here regardless of `probe`.
1780 # Grok is opt-in only: it appears in grok_available but never wins the
1781 # unpinned source selection.
1782 from . import grok_x as _grok_x
1783 grok_available = _grok_x.has_stored_auth()
1784
1785 # Determine active source. A pin forces a single backend (R4): ANY known
1786 # pin is exclusive, mirroring x_backend_chain's [] semantics. Pinned
1787 # backend available -> that source. Pinned backend unavailable -> None.
1788 # Otherwise walk the policy's auto chain (default: bird first, cookies
1789 # beat XAI_API_KEY when both are present, then xai, xurl, xquik;
1790 # official-only: xapi, xai, xurl). Opt-in backends are never
1791 # auto-selected; a leftover ~/.grok/auth.json must not steal the X lane.
1792 usable = {
1793 'bird': bird_status["authenticated"],
1794 'xai': xai_available,
1795 'xurl': xurl_available,
1796 'xquik': xquik_available and xquik_working is not False,
1797 'grok': grok_available,
1798 'xapi': xapi_available,
1799 }
1800 if pin in _X_BACKEND_KNOWN:
1801 # Pin is exclusive: pinned backend if available, else None (no fallback).
1802 source = pin if usable.get(pin) else None
1803 else:
1804 source = next((b for b in policy.auto_chain if usable.get(b)), None)
1805
1806 return {
1807 "source": source,
1808 "bird_installed": bird_status["installed"],
1809 "bird_authenticated": bird_status["authenticated"],
1810 "bird_username": bird_status["username"],
1811 "xai_available": xai_available,
1812 "xapi_available": xapi_available,
1813 "grok_available": grok_available,
1814 "xurl_available": xurl_available,
1815 "xquik_available": xquik_available,
1816 "xquik_working": xquik_working,
1817 "xquik_status": xquik_status,
1818 "can_install_bird": bird_status["can_install"],
1819 }
1820
1821
1822 # Pinterest
1823 def is_pinterest_available(config: dict[str, Any]) -> bool:
1824 """Check if Pinterest source is available.
1825
1826 Returns True when SCRAPECREATORS_API_KEY is set AND 'pinterest' is in
1827 INCLUDE_SOURCES (or requested_sources at the pipeline level). Pinterest
1828 is opt-in because not every topic benefits from visual pin results.
1829 """
1830 return bool(config.get('SCRAPECREATORS_API_KEY'))
1831
1832
1833 def get_pinterest_token(config: dict[str, Any]) -> str:
1834 """Get Pinterest API token (same ScrapeCreators key as TikTok/Instagram)."""
1835 return config.get('SCRAPECREATORS_API_KEY') or ''
1836
1837
1838 # Xquik
1839 def is_xquik_available(config: dict[str, Any]) -> bool:
1840 """Check if Xquik X search source is available.
1841
1842 Requires XQUIK_API_KEY (API key from xquik.com).
1843 """
1844 return bool(config.get('XQUIK_API_KEY'))
1845
1846
1847 def get_xquik_token(config: dict[str, Any]) -> str:
1848 """Get Xquik API key."""
1849 return config.get('XQUIK_API_KEY') or ''
1850
1850 lines PYTHON