luckiest-co 1.0.47 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -0,0 +1,2166 @@
|
|
|
1
|
+
"""Unified `doctor` health surface: aggregate, tier rollup, render (U4).
|
|
2
|
+
|
|
3
|
+
One command answers "what's broken, what's serving, and what do I run to
|
|
4
|
+
fix it" by composing the existing health layers instead of replacing them:
|
|
5
|
+
|
|
6
|
+
- U1 ``lib/health.py`` dependency probes (ok/missing/broken/timeout)
|
|
7
|
+
- U2 ``lib/backends.py`` chain descriptors + "will use" prediction
|
|
8
|
+
- U3 ``lib/prescriptions.py`` the single remediation vocabulary
|
|
9
|
+
- ``lib/pipeline.diagnose`` + ``lib/permission_preflight`` for the
|
|
10
|
+
engine-level availability and permission summary
|
|
11
|
+
|
|
12
|
+
The legacy ``--diagnose`` / ``--preflight`` flags keep their frozen JSON
|
|
13
|
+
shapes (see ``tests/test_diagnose_compat.py``); anything new appears ONLY
|
|
14
|
+
in ``doctor --json``.
|
|
15
|
+
|
|
16
|
+
Tier rollup (the R1 machine contract). Per source, ``tier`` is the
|
|
17
|
+
four-value rollup and ``status`` preserves the most specific state:
|
|
18
|
+
|
|
19
|
+
| condition | status | tier |
|
|
20
|
+
|------------------------------------------------------|---------------|-------|
|
|
21
|
+
| probes pass, credentials (if any) present | ok | ok |
|
|
22
|
+
| usable but degraded (fallback serving, partial) | degraded | warn |
|
|
23
|
+
| opt-in not enabled / key-gated unconfigured | opt-in / | off |
|
|
24
|
+
| | unconfigured | |
|
|
25
|
+
| configured but missing / broken / timeout / error | that status | error |
|
|
26
|
+
|
|
27
|
+
Semantics and guarantees:
|
|
28
|
+
|
|
29
|
+
- ``active_backend`` is a PREDICTION ("will use"), never an observation
|
|
30
|
+
(KTD 4). Reddit is conditional mode: honest wording, no single winner.
|
|
31
|
+
- On a native-search host with no web keys, engine-side web search is
|
|
32
|
+
intentionally off — doctor reports tier ``off`` with a host-native note,
|
|
33
|
+
never a false-alarm error. Web search has NO env pin, only the
|
|
34
|
+
``--web-backend`` flag; the record says so.
|
|
35
|
+
- No cookie reads (plan-only, like ``--diagnose``); no secret values
|
|
36
|
+
anywhere — key presence is booleans only.
|
|
37
|
+
- Per-source exception isolation: one failing probe becomes that source's
|
|
38
|
+
``error`` record; it can never blank the report.
|
|
39
|
+
- Reporting problems is a successful run: the exit code is always 0.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import concurrent.futures
|
|
45
|
+
import datetime
|
|
46
|
+
import hashlib
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import shutil
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
import urllib.error
|
|
53
|
+
import urllib.request
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
56
|
+
|
|
57
|
+
from . import backends, brightdata, env, health, http, prescriptions, reddit_search, x_api
|
|
58
|
+
from .backends import TIER_ERROR, TIER_OK, TIER_WARN
|
|
59
|
+
|
|
60
|
+
# Rollup tiers (R1). ok/warn/error are U2's; only "off" is doctor's own.
|
|
61
|
+
TIER_OFF = "off"
|
|
62
|
+
|
|
63
|
+
# Specific statuses and the rollup row each maps to. The doctor-local
|
|
64
|
+
# statuses "opt-in" and "unconfigured" have no health constant.
|
|
65
|
+
TIER_BY_STATUS: Dict[str, str] = {
|
|
66
|
+
health.OK: TIER_OK,
|
|
67
|
+
health.DEGRADED: TIER_WARN,
|
|
68
|
+
"opt-in": TIER_OFF,
|
|
69
|
+
"unconfigured": TIER_OFF,
|
|
70
|
+
health.MISSING: TIER_ERROR,
|
|
71
|
+
health.BROKEN: TIER_ERROR,
|
|
72
|
+
health.TIMEOUT: TIER_ERROR,
|
|
73
|
+
health.ERROR: TIER_ERROR,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Tier -> glyph, still used by the cached-report shape validator to confirm a
|
|
77
|
+
# record carries a known tier. The user-facing render uses AUDIT_GLYPHS below.
|
|
78
|
+
GLYPHS = {TIER_OK: "✓", TIER_WARN: "!", TIER_OFF: "○", TIER_ERROR: "✗"}
|
|
79
|
+
|
|
80
|
+
# Four-state audit (R1). A presentation layer derived from the tier rollup +
|
|
81
|
+
# last-run evidence + optional live probe - it augments the per-source records,
|
|
82
|
+
# it does not replace them (the tier/status fields stay in the record and JSON).
|
|
83
|
+
AUDIT_WORKING = "working"
|
|
84
|
+
AUDIT_UNVERIFIED = "unverified"
|
|
85
|
+
AUDIT_NOT_WORKING = "not-working"
|
|
86
|
+
AUDIT_COULD_BE_ON = "could-be-on"
|
|
87
|
+
|
|
88
|
+
AUDIT_GLYPHS = {
|
|
89
|
+
AUDIT_WORKING: "●", # ●
|
|
90
|
+
AUDIT_UNVERIFIED: "◐", # ◐
|
|
91
|
+
AUDIT_NOT_WORKING: "✕", # ✕
|
|
92
|
+
AUDIT_COULD_BE_ON: "○", # ○
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
AUDIT_GROUPS = (
|
|
96
|
+
(AUDIT_WORKING, "WORKING"),
|
|
97
|
+
(AUDIT_UNVERIFIED, "TURNED ON - UNVERIFIED"),
|
|
98
|
+
(AUDIT_NOT_WORKING, "NOT WORKING"),
|
|
99
|
+
(AUDIT_COULD_BE_ON, "COULD BE ON"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Sources that need neither credentials nor a CLI: they always serve, so with
|
|
103
|
+
# no run evidence and no probe they are WORKING, not UNVERIFIED.
|
|
104
|
+
KEYLESS_ALWAYS_ON = frozenset(
|
|
105
|
+
{"reddit", "hackernews", "polymarket", "github", "library"}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Fresh-run outcome states -> audit bucket for a tier-ok source. Anything not
|
|
109
|
+
# listed here (error / timeout / rate-limited / auth-failed / unreachable /
|
|
110
|
+
# schema-drift) means the source ran and failed -> NOT WORKING.
|
|
111
|
+
_RUN_WORKING_STATES = frozenset({health.OK, health.NO_RESULTS})
|
|
112
|
+
_RUN_UNVERIFIED_STATES = frozenset({health.PARTIAL, health.SKIPPED_UNCONFIGURED})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def audit_state(
|
|
116
|
+
name: str,
|
|
117
|
+
record: Dict[str, Any],
|
|
118
|
+
run_outcome: Optional[Dict[str, Any]] = None,
|
|
119
|
+
probe_result: Optional[Dict[str, Any]] = None,
|
|
120
|
+
) -> str:
|
|
121
|
+
"""Map a source's (tier, run evidence, probe) to one of four audit states.
|
|
122
|
+
|
|
123
|
+
Precedence: a failing/degraded tier is NOT WORKING regardless of history;
|
|
124
|
+
an off tier (opt-in / unconfigured) is COULD BE ON. For a tier-ok source,
|
|
125
|
+
fresh run evidence wins (ok/no-results -> WORKING; partial/skipped ->
|
|
126
|
+
UNVERIFIED; any error/timeout/rate-limit/etc -> NOT WORKING), then a live
|
|
127
|
+
probe, then the keyless-always-on fallback, else UNVERIFIED.
|
|
128
|
+
"""
|
|
129
|
+
tier = record.get("tier")
|
|
130
|
+
if tier in (TIER_ERROR, TIER_WARN):
|
|
131
|
+
return AUDIT_NOT_WORKING
|
|
132
|
+
if tier == TIER_OFF:
|
|
133
|
+
return AUDIT_COULD_BE_ON
|
|
134
|
+
# tier ok
|
|
135
|
+
if run_outcome:
|
|
136
|
+
state = run_outcome.get("state")
|
|
137
|
+
if state in _RUN_WORKING_STATES:
|
|
138
|
+
return AUDIT_WORKING
|
|
139
|
+
if state in _RUN_UNVERIFIED_STATES:
|
|
140
|
+
return AUDIT_UNVERIFIED
|
|
141
|
+
return AUDIT_NOT_WORKING
|
|
142
|
+
if probe_result is not None:
|
|
143
|
+
if probe_result.get("ok"):
|
|
144
|
+
return AUDIT_WORKING
|
|
145
|
+
# A transient refusal (host rate-limited THIS probe) is not evidence the
|
|
146
|
+
# source is broken: the lane retries with backoff and serves fine. Report
|
|
147
|
+
# it as unverified instead of claiming a working source is down.
|
|
148
|
+
if probe_result.get("transient"):
|
|
149
|
+
return AUDIT_UNVERIFIED
|
|
150
|
+
return AUDIT_NOT_WORKING
|
|
151
|
+
if name in KEYLESS_ALWAYS_ON:
|
|
152
|
+
return AUDIT_WORKING
|
|
153
|
+
return AUDIT_UNVERIFIED
|
|
154
|
+
|
|
155
|
+
# Report order: chained sources first, then free, then key-gated/opt-in.
|
|
156
|
+
SOURCE_ORDER = (
|
|
157
|
+
"reddit",
|
|
158
|
+
"x",
|
|
159
|
+
"youtube",
|
|
160
|
+
"web",
|
|
161
|
+
"hackernews",
|
|
162
|
+
"polymarket",
|
|
163
|
+
"github",
|
|
164
|
+
"devto",
|
|
165
|
+
"digg",
|
|
166
|
+
"techmeme",
|
|
167
|
+
"arxiv",
|
|
168
|
+
"trustpilot",
|
|
169
|
+
"amazon",
|
|
170
|
+
"meta_ads",
|
|
171
|
+
"tiktok",
|
|
172
|
+
"instagram",
|
|
173
|
+
"threads",
|
|
174
|
+
"telegram",
|
|
175
|
+
"bluesky",
|
|
176
|
+
"truthsocial",
|
|
177
|
+
"perplexity",
|
|
178
|
+
"linkedin",
|
|
179
|
+
"pinterest",
|
|
180
|
+
"xiaohongshu",
|
|
181
|
+
"jobs",
|
|
182
|
+
"library",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Sources whose availability depends on a downloaded CLI binary. doctor probes
|
|
186
|
+
# each (installed AND functional, via health.probe_dependency) and surfaces a
|
|
187
|
+
# per-source marker plus a dedicated CLI-health block (R2). Everything not
|
|
188
|
+
# listed is keyless - it needs no CLI. gh is OPTIONAL for GitHub (the REST tier
|
|
189
|
+
# works without it), so its absence is a note, never a failure.
|
|
190
|
+
CLI_DEPENDENCIES = {
|
|
191
|
+
"youtube": "yt-dlp",
|
|
192
|
+
"digg": "digg-pp-cli",
|
|
193
|
+
"techmeme": "techmeme-pp-cli",
|
|
194
|
+
"arxiv": "arxiv-pp-cli",
|
|
195
|
+
"trustpilot": "trustpilot-pp-cli",
|
|
196
|
+
# The only entry that also needs auth; _amazon_record reports the
|
|
197
|
+
# installed-but-unauthenticated state the shared CLI helper cannot.
|
|
198
|
+
"amazon": "brightdata",
|
|
199
|
+
"github": "gh",
|
|
200
|
+
}
|
|
201
|
+
_OPTIONAL_CLI_SOURCES = frozenset({"github"})
|
|
202
|
+
|
|
203
|
+
# Key-presence booleans for the setup block. NEVER values.
|
|
204
|
+
KEY_PRESENCE_VARS = (
|
|
205
|
+
"SCRAPECREATORS_API_KEY",
|
|
206
|
+
"XAI_API_KEY",
|
|
207
|
+
"XQUIK_API_KEY",
|
|
208
|
+
"X_BEARER_TOKEN",
|
|
209
|
+
"BRAVE_API_KEY",
|
|
210
|
+
"EXA_API_KEY",
|
|
211
|
+
"SERPER_API_KEY",
|
|
212
|
+
"PARALLEL_API_KEY",
|
|
213
|
+
"GROQ_API_KEY",
|
|
214
|
+
"OPENAI_API_KEY",
|
|
215
|
+
"GOOGLE_API_KEY",
|
|
216
|
+
"GEMINI_API_KEY",
|
|
217
|
+
"OPENROUTER_API_KEY",
|
|
218
|
+
"PERPLEXITY_API_KEY",
|
|
219
|
+
"GITHUB_TOKEN",
|
|
220
|
+
"TRUTHSOCIAL_TOKEN",
|
|
221
|
+
"BSKY_APP_PASSWORD",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Failing statuses ranked most-specific-first for chained rollups: a broken
|
|
225
|
+
# shim outranks a timeout outranks a generic error when naming the source's
|
|
226
|
+
# status (all three roll up to tier error regardless).
|
|
227
|
+
_SPECIFIC_FAILURES = (health.BROKEN, health.TIMEOUT, health.ERROR)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _fix_text(entry: prescriptions.Prescription) -> str:
|
|
231
|
+
"""Render a registry entry as one actionable fix line (NL + CLI forms)."""
|
|
232
|
+
if entry.fix_cli and entry.fix_cli not in entry.fix_nl:
|
|
233
|
+
return f"{entry.fix_nl} (cli: {entry.fix_cli})"
|
|
234
|
+
return entry.fix_nl
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _record(
|
|
238
|
+
*,
|
|
239
|
+
status: str,
|
|
240
|
+
mode: str = "single",
|
|
241
|
+
backends_list: Optional[List[Dict[str, Any]]] = None,
|
|
242
|
+
active_backend: Optional[str] = None,
|
|
243
|
+
fix: str = "",
|
|
244
|
+
requires: str = "",
|
|
245
|
+
note: str = "",
|
|
246
|
+
detail: str = "",
|
|
247
|
+
pin_var: Optional[str] = None,
|
|
248
|
+
pin_flag: Optional[str] = None,
|
|
249
|
+
pinned: bool = False,
|
|
250
|
+
) -> Dict[str, Any]:
|
|
251
|
+
return {
|
|
252
|
+
"tier": TIER_BY_STATUS[status],
|
|
253
|
+
"status": status,
|
|
254
|
+
"mode": mode,
|
|
255
|
+
"backends": backends_list,
|
|
256
|
+
"active_backend": active_backend,
|
|
257
|
+
"fix": fix,
|
|
258
|
+
"requires": requires,
|
|
259
|
+
"note": note,
|
|
260
|
+
"detail": detail,
|
|
261
|
+
"pin_var": pin_var,
|
|
262
|
+
"pin_flag": pin_flag,
|
|
263
|
+
"pinned": pinned,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Chained sources (via U2 descriptors)
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def _finding_json(finding: backends.BackendFinding) -> Dict[str, Any]:
|
|
272
|
+
return {
|
|
273
|
+
"name": finding.name,
|
|
274
|
+
"status": finding.status,
|
|
275
|
+
"detail": finding.detail,
|
|
276
|
+
"requires": finding.requires,
|
|
277
|
+
"fix": finding.prescription,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _host_native_web_note(config: Dict[str, Any]) -> str:
|
|
282
|
+
"""Doctor-local note when the host's own web search serves this run.
|
|
283
|
+
|
|
284
|
+
Keys on LAST30DAYS_NATIVE_SEARCH (via env.is_native_search) AND on
|
|
285
|
+
CLAUDECODE as a host signal - Claude Code always exposes a web-search tool,
|
|
286
|
+
but `doctor` run in a plain shell never sees the LAST30DAYS_NATIVE_SEARCH
|
|
287
|
+
the engine exports only for its own run, so without the CLAUDECODE signal it
|
|
288
|
+
would mislabel a fine setup as "degraded/keyless". Messaging only: it does
|
|
289
|
+
not change env.is_native_search or the engine's keyless-floor behavior. The
|
|
290
|
+
note names the signal actually detected so it never cites an env var the
|
|
291
|
+
user did not set.
|
|
292
|
+
"""
|
|
293
|
+
if env.is_native_search(config):
|
|
294
|
+
return (
|
|
295
|
+
"host-native search active (LAST30DAYS_NATIVE_SEARCH): the host's "
|
|
296
|
+
"own web search serves this run; set a web key only if you want "
|
|
297
|
+
"engine-side web search"
|
|
298
|
+
)
|
|
299
|
+
if config.get("CLAUDECODE") or os.environ.get("CLAUDECODE"):
|
|
300
|
+
return (
|
|
301
|
+
"host-native web search active (Claude Code): the host's own web "
|
|
302
|
+
"search serves this run; set a web key only if you want "
|
|
303
|
+
"engine-side web search"
|
|
304
|
+
)
|
|
305
|
+
return ""
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _chained_record(source: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
309
|
+
descriptor = backends.get_descriptor(source)
|
|
310
|
+
res = backends.resolve(source, config)
|
|
311
|
+
findings_json = [_finding_json(f) for f in res.findings]
|
|
312
|
+
common = dict(
|
|
313
|
+
mode=res.mode,
|
|
314
|
+
backends_list=findings_json,
|
|
315
|
+
active_backend=res.active_backend,
|
|
316
|
+
pin_var=descriptor.pin_var,
|
|
317
|
+
pin_flag=descriptor.pin_flag,
|
|
318
|
+
pinned=res.pinned,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if res.mode == backends.MODE_CONDITIONAL:
|
|
322
|
+
# Reddit: honest conditional wording (U2, verbatim), never a winner.
|
|
323
|
+
return _record(status=health.OK, note=res.conditional,
|
|
324
|
+
requires=res.findings[0].requires if res.findings else "",
|
|
325
|
+
**common)
|
|
326
|
+
|
|
327
|
+
by_name = {f.name: f for f in res.findings}
|
|
328
|
+
if res.tier == backends.TIER_OK:
|
|
329
|
+
active = by_name.get(res.active_backend)
|
|
330
|
+
return _record(status=health.OK, note=res.summary,
|
|
331
|
+
requires=active.requires if active else "", **common)
|
|
332
|
+
|
|
333
|
+
# Doctor-local (KTD-3): on a host that brings its own web search, the
|
|
334
|
+
# engine's web lanes (keyless floor or nothing configured) are intentionally
|
|
335
|
+
# dormant - report that, not an alarming "degraded/keyless". This must run
|
|
336
|
+
# before the WARN branch, because the keyless floor resolves to WARN and
|
|
337
|
+
# would otherwise return first. Messaging only; it never touches
|
|
338
|
+
# env.is_native_search or the engine's keyless-floor runtime behavior.
|
|
339
|
+
if source == "web":
|
|
340
|
+
host_note = _host_native_web_note(config)
|
|
341
|
+
if host_note:
|
|
342
|
+
return _record(
|
|
343
|
+
status="unconfigured",
|
|
344
|
+
note=host_note,
|
|
345
|
+
requires=res.findings[0].requires if res.findings else "",
|
|
346
|
+
**common,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if res.tier == backends.TIER_WARN:
|
|
350
|
+
active = by_name.get(res.active_backend)
|
|
351
|
+
return _record(status=health.DEGRADED, note=res.summary,
|
|
352
|
+
detail=active.detail if active else "",
|
|
353
|
+
fix=res.prescription,
|
|
354
|
+
requires=active.requires if active else "", **common)
|
|
355
|
+
|
|
356
|
+
# res.tier == error: separate "nothing configured" (tier off) from
|
|
357
|
+
# "configured but broken" (tier error).
|
|
358
|
+
if res.findings and all(f.status == health.MISSING for f in res.findings):
|
|
359
|
+
return _record(
|
|
360
|
+
status="unconfigured",
|
|
361
|
+
fix=res.prescription,
|
|
362
|
+
requires=res.findings[0].requires if res.findings else "",
|
|
363
|
+
note=f"no backend configured (chain: {' -> '.join(res.chain)})",
|
|
364
|
+
**common,
|
|
365
|
+
)
|
|
366
|
+
# Something IS configured/installed but won't serve: name the most
|
|
367
|
+
# specific failure in chain order.
|
|
368
|
+
status = health.ERROR
|
|
369
|
+
fix = res.prescription
|
|
370
|
+
detail = ""
|
|
371
|
+
failed: Optional[backends.BackendFinding] = None
|
|
372
|
+
for wanted in _SPECIFIC_FAILURES:
|
|
373
|
+
failed = next((f for f in res.findings if f.status == wanted), None)
|
|
374
|
+
if failed is not None:
|
|
375
|
+
status = wanted
|
|
376
|
+
fix = failed.prescription or res.prescription
|
|
377
|
+
detail = failed.detail
|
|
378
|
+
break
|
|
379
|
+
# Mirror the OK/WARN branches: the requirement named is the FAILED
|
|
380
|
+
# backend's, not chain[0]'s (which may be a different, merely-missing
|
|
381
|
+
# backend when the failure came from later in the chain).
|
|
382
|
+
return _record(status=status, fix=fix, detail=detail,
|
|
383
|
+
requires=failed.requires if failed
|
|
384
|
+
else (res.findings[0].requires if res.findings else ""),
|
|
385
|
+
**common)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# Single-backend sources
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def _sc_fix() -> str:
|
|
393
|
+
return _fix_text(prescriptions.get("scrapecreators", "key_missing"))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _sc_gated_record(config: Dict[str, Any], purpose: str) -> Dict[str, Any]:
|
|
397
|
+
if config.get("SCRAPECREATORS_API_KEY"):
|
|
398
|
+
return _record(status=health.OK, requires="SCRAPECREATORS_API_KEY",
|
|
399
|
+
detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
|
|
400
|
+
return _record(status="unconfigured", requires="SCRAPECREATORS_API_KEY",
|
|
401
|
+
fix=_sc_fix())
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _sc_optin_record(config: Dict[str, Any], source: str, purpose: str) -> Dict[str, Any]:
|
|
405
|
+
"""SC-gated source that ALSO requires an INCLUDE_SOURCES opt-in to run.
|
|
406
|
+
|
|
407
|
+
Unlike ``_sc_gated_record`` (used by the on-by-default TikTok/Instagram),
|
|
408
|
+
a key alone is not enough here: the pipeline only fires this source when it
|
|
409
|
+
is in INCLUDE_SOURCES. Reporting a bare key as Ready is the Threads
|
|
410
|
+
false-Ready bug - this mirrors ``_linkedin_record``'s correct gating so
|
|
411
|
+
doctor and the pipeline cannot disagree.
|
|
412
|
+
"""
|
|
413
|
+
requires = f"SCRAPECREATORS_API_KEY + INCLUDE_SOURCES={source}"
|
|
414
|
+
if not config.get("SCRAPECREATORS_API_KEY"):
|
|
415
|
+
return _record(status="unconfigured", requires=requires, fix=_sc_fix())
|
|
416
|
+
if source in env.include_sources(config):
|
|
417
|
+
return _record(status=health.OK, requires=requires,
|
|
418
|
+
detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
|
|
419
|
+
return _record(
|
|
420
|
+
status="opt-in", requires=requires,
|
|
421
|
+
fix=f"add {source} to INCLUDE_SOURCES (or request it via --search {source})",
|
|
422
|
+
note="key present; opt-in, never auto-activates",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _reddit_record(config):
|
|
427
|
+
return _chained_record("reddit", config)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# Official-path wording. The bearer path is never described as
|
|
431
|
+
# parity with the connector lane.
|
|
432
|
+
X_BEARER_CAVEAT = x_api.BEARER_COVERAGE_NOTE
|
|
433
|
+
X_CONNECTOR_NOTE = "will use: built-in X tools or X connector (host-fetched at run time)"
|
|
434
|
+
X_CONNECTOR_ARMED = "host X lane armed (built-in X tools or X connector)"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _x_will_use_note(record: Dict[str, Any], policy: env.XPolicy) -> str:
|
|
438
|
+
"""The will-use line for a predicted X backend, shaped by the policy.
|
|
439
|
+
|
|
440
|
+
On an official-only host a pinned backend renders as ``will use: <name>
|
|
441
|
+
(pinned)`` and nothing else names it (the pin variable is not
|
|
442
|
+
advertised). Off it the backends summary is kept verbatim.
|
|
443
|
+
The bearer prediction carries the about-a-week caveat on every host.
|
|
444
|
+
"""
|
|
445
|
+
name = record["active_backend"]
|
|
446
|
+
if policy.official_only:
|
|
447
|
+
qualifiers: List[str] = []
|
|
448
|
+
if record.get("pinned"):
|
|
449
|
+
qualifiers.append("pinned")
|
|
450
|
+
if name == "xapi":
|
|
451
|
+
qualifiers.append(X_BEARER_CAVEAT)
|
|
452
|
+
return f"will use: {name}" + (f" ({'; '.join(qualifiers)})" if qualifiers else "")
|
|
453
|
+
note = record.get("note") or f"will use: {name}"
|
|
454
|
+
if name == "xapi":
|
|
455
|
+
if note.endswith(")"):
|
|
456
|
+
return f"{note[:-1]}; {X_BEARER_CAVEAT})"
|
|
457
|
+
return f"{note} ({X_BEARER_CAVEAT})"
|
|
458
|
+
return note
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _x_record(config):
|
|
462
|
+
record = _chained_record("x", config)
|
|
463
|
+
policy = env.x_policy(config)
|
|
464
|
+
if policy.official_only and not record.get("pinned"):
|
|
465
|
+
# The pin is documented in CONFIGURATION.md only; doctor never
|
|
466
|
+
# advertises the knob on an official-only host.
|
|
467
|
+
record["pin_var"] = None
|
|
468
|
+
if record.get("active_backend"):
|
|
469
|
+
record["note"] = _x_will_use_note(record, policy)
|
|
470
|
+
return record
|
|
471
|
+
# The declared X connector lane serves X when no engine backend is
|
|
472
|
+
# predicted (the same precedence as diagnose.x_backend). Host-independent:
|
|
473
|
+
# the envelope is accepted anywhere.
|
|
474
|
+
if env.x_host_lane_declared(config):
|
|
475
|
+
record["status"] = health.OK
|
|
476
|
+
record["tier"] = TIER_BY_STATUS[health.OK]
|
|
477
|
+
record["active_backend"] = "connector"
|
|
478
|
+
record["note"] = X_CONNECTOR_NOTE
|
|
479
|
+
record["detail"] = ""
|
|
480
|
+
record["fix"] = ""
|
|
481
|
+
return record
|
|
482
|
+
# Diagnose/doctor load config in plan_only mode, so browser cookies are not
|
|
483
|
+
# extracted and every X backend reads as statically missing -> unconfigured.
|
|
484
|
+
# But if bird is installed and FROM_BROWSER will authenticate X at run time,
|
|
485
|
+
# a normal run serves X fine (this is how the reporting user pulled 29 posts
|
|
486
|
+
# while doctor said "Off"). Reuse the existing shared predicate so doctor and
|
|
487
|
+
# diagnose cannot drift. It reads no cookie *values*, so it confirms a run
|
|
488
|
+
# will *attempt* browser auth, not that the session is currently valid -
|
|
489
|
+
# keep the note honest and point at the verified key-backed path.
|
|
490
|
+
#
|
|
491
|
+
# Policy-gated: on an official-only host no run-time cookie source
|
|
492
|
+
# exists unless bird is pinned, and then the note names only the pin.
|
|
493
|
+
#
|
|
494
|
+
# This check MUST come before grok normalization: a pending bird path or
|
|
495
|
+
# a recorded setup denial takes precedence over an unused grok store.
|
|
496
|
+
# Handle both "unconfigured" (all backends missing) and "error" (grok present
|
|
497
|
+
# but opt-in, no auto-chain backend usable) when pending bird applies.
|
|
498
|
+
#
|
|
499
|
+
# HOWEVER: pending bird must NOT replace a record that has a configured
|
|
500
|
+
# auto-chain backend in ERROR/DEGRADED/BROKEN/TIMEOUT. Same rule as the
|
|
501
|
+
# grok normalizer: only upgrade when no auto backend is configured-but-broken.
|
|
502
|
+
auto_chain_names = set(env.x_auto_chain(config))
|
|
503
|
+
pending_bird = policy.cookie_discovery and env.x_pending_browser_auth(
|
|
504
|
+
config, local_only=True
|
|
505
|
+
)
|
|
506
|
+
reported_denials = set((config.get("LAST30DAYS_X_COOKIE_ACCESS_DENIED") or "").split(","))
|
|
507
|
+
known_browsers = set(env.COOKIE_BROWSER_NAMES)
|
|
508
|
+
if (
|
|
509
|
+
policy.cookie_discovery
|
|
510
|
+
and str(config.get("BROWSER_CONSENT") or "").lower() in {"1", "true", "yes", "on"}
|
|
511
|
+
and str(config.get("FROM_BROWSER") or "").strip().lower() != "off"
|
|
512
|
+
):
|
|
513
|
+
denied_browsers = sorted(reported_denials & known_browsers)
|
|
514
|
+
else:
|
|
515
|
+
denied_browsers = []
|
|
516
|
+
if denied_browsers and record.get("pinned") and env.x_backend_pin(config) != "bird":
|
|
517
|
+
observation = (
|
|
518
|
+
"Last setup: permission denied reading X cookies from "
|
|
519
|
+
f"{', '.join(denied_browsers)}; current access not checked"
|
|
520
|
+
)
|
|
521
|
+
record["note"] = f"{record['note']}; {observation}" if record["note"] else observation
|
|
522
|
+
return record
|
|
523
|
+
if (pending_bird or denied_browsers) and record["status"] in ("unconfigured", health.ERROR):
|
|
524
|
+
backends_list = record.get("backends", [])
|
|
525
|
+
auto_backends = [b for b in backends_list if b.get("name") in auto_chain_names]
|
|
526
|
+
# Only apply pending-bird upgrade if ALL auto-chain backends are MISSING.
|
|
527
|
+
# If any auto backend is configured but broken, keep that error.
|
|
528
|
+
all_auto_missing = all(
|
|
529
|
+
b.get("status") == health.MISSING for b in auto_backends
|
|
530
|
+
)
|
|
531
|
+
if all_auto_missing:
|
|
532
|
+
if denied_browsers:
|
|
533
|
+
record["status"] = health.ERROR
|
|
534
|
+
record["tier"] = TIER_BY_STATUS[health.ERROR]
|
|
535
|
+
record["note"] = (
|
|
536
|
+
"Last setup: permission denied reading X cookies from "
|
|
537
|
+
f"{', '.join(denied_browsers)}; current access not checked"
|
|
538
|
+
)
|
|
539
|
+
record["fix"] = env.X_COOKIE_ACCESS_FIX
|
|
540
|
+
return record
|
|
541
|
+
record["status"] = health.OK
|
|
542
|
+
record["tier"] = TIER_BY_STATUS[health.OK]
|
|
543
|
+
if policy.official_only:
|
|
544
|
+
record["note"] = "will use: bird (pinned)"
|
|
545
|
+
else:
|
|
546
|
+
record["note"] = (
|
|
547
|
+
"will use: bird (browser cookies; session not verified until a run "
|
|
548
|
+
"- add XAI_API_KEY for a verified, cookie-free path)"
|
|
549
|
+
)
|
|
550
|
+
record["fix"] = ""
|
|
551
|
+
return record
|
|
552
|
+
#
|
|
553
|
+
# Grok is opt-in only: a leftover ~/.grok/auth.json must never steal the X
|
|
554
|
+
# lane. The grok backend appears in the chain findings (for visibility) but
|
|
555
|
+
# is never auto-selected. Doctor reports it as "available, unused - pin
|
|
556
|
+
# LAST30DAYS_X_BACKEND=grok to enable" rather than "will use: grok".
|
|
557
|
+
#
|
|
558
|
+
# Policy-gated: on an official-only host the CLI is not probed
|
|
559
|
+
# unless pinned and is never offered, so this branch does not run there
|
|
560
|
+
# (no "pin LAST30DAYS_X_BACKEND=grok" note on a Grok Bot host).
|
|
561
|
+
#
|
|
562
|
+
# R3/R8: When no auto-chain backend is CONFIGURED (all MISSING) but grok has
|
|
563
|
+
# any non-MISSING status, X is unconfigured/skipped - NOT broken/auth-failed.
|
|
564
|
+
# The tier must be "off" (unconfigured), not "error" (NOT WORKING).
|
|
565
|
+
#
|
|
566
|
+
# HOWEVER: if an auto-chain backend IS configured but broken (ERROR/DEGRADED),
|
|
567
|
+
# do NOT normalize to unconfigured. Keep that backend's error and repair
|
|
568
|
+
# guidance. Unused grok must not swallow a genuine auto-chain failure.
|
|
569
|
+
#
|
|
570
|
+
# Do NOT apply this normalization when pending browser auth would make bird
|
|
571
|
+
# usable — check pending_bird first (handled above via early return).
|
|
572
|
+
if (
|
|
573
|
+
not policy.official_only
|
|
574
|
+
and record["tier"] == TIER_ERROR
|
|
575
|
+
and not record.get("pinned")
|
|
576
|
+
and record.get("active_backend") is None
|
|
577
|
+
and not pending_bird
|
|
578
|
+
):
|
|
579
|
+
backends_list = record.get("backends", [])
|
|
580
|
+
auto_backends = [b for b in backends_list if b.get("name") in auto_chain_names]
|
|
581
|
+
# Only normalize if ALL auto-chain backends are MISSING (not configured).
|
|
582
|
+
# If any auto backend is ERROR/DEGRADED/BROKEN/TIMEOUT, keep that error.
|
|
583
|
+
all_auto_missing = all(
|
|
584
|
+
b.get("status") == health.MISSING for b in auto_backends
|
|
585
|
+
)
|
|
586
|
+
if not all_auto_missing:
|
|
587
|
+
# An auto-chain backend is configured but broken — do NOT normalize.
|
|
588
|
+
# Keep the original error and its repair guidance.
|
|
589
|
+
return record
|
|
590
|
+
grok_finding = next(
|
|
591
|
+
(b for b in backends_list if b.get("name") == "grok"),
|
|
592
|
+
None,
|
|
593
|
+
)
|
|
594
|
+
if grok_finding and grok_finding.get("status") in (
|
|
595
|
+
health.OK,
|
|
596
|
+
health.DEGRADED,
|
|
597
|
+
health.ERROR,
|
|
598
|
+
):
|
|
599
|
+
record["status"] = "unconfigured"
|
|
600
|
+
record["tier"] = TIER_OFF
|
|
601
|
+
if grok_finding.get("status") == health.ERROR:
|
|
602
|
+
record["note"] = (
|
|
603
|
+
"X unconfigured; grok CLI store is broken but unused (opt-in only) — "
|
|
604
|
+
"pin LAST30DAYS_X_BACKEND=grok to enable, then fix the store"
|
|
605
|
+
)
|
|
606
|
+
else:
|
|
607
|
+
record["note"] = (
|
|
608
|
+
"X unconfigured; grok CLI available but opt-in only — "
|
|
609
|
+
"pin LAST30DAYS_X_BACKEND=grok to enable"
|
|
610
|
+
)
|
|
611
|
+
record["fix"] = ""
|
|
612
|
+
return record
|
|
613
|
+
xapi_finding = next(
|
|
614
|
+
(b for b in backends_list if b.get("name") == "xapi"),
|
|
615
|
+
None,
|
|
616
|
+
)
|
|
617
|
+
if xapi_finding and xapi_finding.get("status") in (health.OK, health.DEGRADED):
|
|
618
|
+
# Same shape as the grok note: a configured opt-in backend is not
|
|
619
|
+
# a broken X, it is an unconfigured X with a one-line enable.
|
|
620
|
+
record["status"] = "unconfigured"
|
|
621
|
+
record["tier"] = TIER_OFF
|
|
622
|
+
record["note"] = (
|
|
623
|
+
"X unconfigured; X_BEARER_TOKEN is set but the X API backend is opt-in "
|
|
624
|
+
"on this host — pin LAST30DAYS_X_BACKEND=xapi to enable"
|
|
625
|
+
)
|
|
626
|
+
record["fix"] = ""
|
|
627
|
+
return record
|
|
628
|
+
return record
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _youtube_record(config):
|
|
632
|
+
record = _chained_record("youtube", config)
|
|
633
|
+
if record["status"] != health.OK:
|
|
634
|
+
return record
|
|
635
|
+
notes: List[str] = []
|
|
636
|
+
# yt-dlp already provides search + transcripts. A transcription key only
|
|
637
|
+
# backfills captions for the occasional caption-free video - an enhancement,
|
|
638
|
+
# not a sign YouTube is broken.
|
|
639
|
+
if not env.transcription_providers(config):
|
|
640
|
+
entry = prescriptions.get("youtube", "transcription_key_missing")
|
|
641
|
+
notes.append(
|
|
642
|
+
"search + transcripts work; a transcription key only adds "
|
|
643
|
+
"captions for caption-free videos"
|
|
644
|
+
)
|
|
645
|
+
record["fix"] = _fix_text(entry)
|
|
646
|
+
# Comment *text* is free via yt-dlp, so this caveat only fires when yt-dlp
|
|
647
|
+
# is absent and the legacy ScrapeCreators path is the only one left. Never
|
|
648
|
+
# prescribe a paid key for something the installed toolchain already does.
|
|
649
|
+
if not env.is_youtube_comments_available(config):
|
|
650
|
+
notes.append(
|
|
651
|
+
"comment text needs yt-dlp (free) or a ScrapeCreators key "
|
|
652
|
+
"+ youtube_comments opt-in"
|
|
653
|
+
)
|
|
654
|
+
# Actionable fix, matching the transcription branch. The transcription
|
|
655
|
+
# fix takes precedence when both caveats fire (one fix line per record).
|
|
656
|
+
if not record["fix"]:
|
|
657
|
+
if not config.get("SCRAPECREATORS_API_KEY"):
|
|
658
|
+
record["fix"] = _sc_fix()
|
|
659
|
+
else:
|
|
660
|
+
record["fix"] = (
|
|
661
|
+
"add youtube_comments to INCLUDE_SOURCES in "
|
|
662
|
+
"~/.config/last30days/.env to enable YouTube comment text"
|
|
663
|
+
)
|
|
664
|
+
if notes:
|
|
665
|
+
joined = "; ".join(notes)
|
|
666
|
+
record["note"] = (record["note"] + "; " + joined) if record["note"] else joined
|
|
667
|
+
return record
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _web_record(config):
|
|
671
|
+
return _chained_record("web", config)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _hackernews_record(config):
|
|
675
|
+
return _record(status=health.OK, requires="none (free Algolia API)")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _devto_record(config):
|
|
679
|
+
requires = "none (public Forem API)"
|
|
680
|
+
if "devto" in env.include_sources(config):
|
|
681
|
+
return _record(status=health.OK, requires=requires)
|
|
682
|
+
return _record(
|
|
683
|
+
status="opt-in", requires=requires,
|
|
684
|
+
fix="add devto to INCLUDE_SOURCES (or request it via --search devto)",
|
|
685
|
+
note="off by default; --devto-tags activates it for a run",
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _polymarket_record(config):
|
|
690
|
+
return _record(status=health.OK, requires="none (public API)")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _github_record(config):
|
|
694
|
+
authed = bool(config.get("GITHUB_TOKEN") or env.read_secret_env("GITHUB_TOKEN") or shutil.which("gh"))
|
|
695
|
+
detail = (
|
|
696
|
+
"authenticated tier (GITHUB_TOKEN or gh CLI)"
|
|
697
|
+
if authed
|
|
698
|
+
else "unauthenticated REST tier (lower rate limits; GITHUB_TOKEN or gh raises them)"
|
|
699
|
+
)
|
|
700
|
+
return _record(status=health.OK, detail=detail,
|
|
701
|
+
requires="none (GITHUB_TOKEN or gh CLI optional)")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _digg_record(config):
|
|
705
|
+
probe = health.probe_dependency("digg-pp-cli")
|
|
706
|
+
requires = "digg-pp-cli on the agent-subprocess PATH"
|
|
707
|
+
if probe.ok:
|
|
708
|
+
return _record(status=health.OK, detail=probe.detail, requires=requires)
|
|
709
|
+
entry = prescriptions.for_dependency_probe(probe)
|
|
710
|
+
fix = _fix_text(entry) if entry else probe.prescription
|
|
711
|
+
if probe.status == health.MISSING and not probe.off_path:
|
|
712
|
+
# Never installed: an optional source that simply isn't enabled.
|
|
713
|
+
return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
|
|
714
|
+
# Installed but off-PATH, broken, or timing out: configured-but-broken.
|
|
715
|
+
return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _cli_gated_record(config, cli_name: str, purpose: str):
|
|
719
|
+
"""A source gated purely on a keyless downloaded CLI (mirrors _digg_record).
|
|
720
|
+
|
|
721
|
+
ok -> installed and functional; opt-in -> never installed (an optional
|
|
722
|
+
source simply not enabled); its failing status -> installed off-PATH,
|
|
723
|
+
broken, or timing out (configured-but-broken).
|
|
724
|
+
"""
|
|
725
|
+
probe = health.probe_dependency(cli_name)
|
|
726
|
+
requires = f"{cli_name} on the agent-subprocess PATH"
|
|
727
|
+
if probe.ok:
|
|
728
|
+
return _record(status=health.OK, detail=probe.detail, requires=requires)
|
|
729
|
+
entry = prescriptions.for_dependency_probe(probe)
|
|
730
|
+
fix = _fix_text(entry) if entry else probe.prescription
|
|
731
|
+
if probe.status == health.MISSING and not probe.off_path:
|
|
732
|
+
return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
|
|
733
|
+
return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _techmeme_record(config):
|
|
737
|
+
return _cli_gated_record(config, "techmeme-pp-cli", "techmeme")
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _arxiv_record(config):
|
|
741
|
+
return _cli_gated_record(config, "arxiv-pp-cli", "arxiv")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _trustpilot_record(config):
|
|
745
|
+
return _cli_gated_record(config, "trustpilot-pp-cli", "trustpilot")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _amazon_record(config):
|
|
749
|
+
"""Amazon buyer signals: CLI-gated *and* auth-gated.
|
|
750
|
+
|
|
751
|
+
Unlike the other CLI-gated sources, a present binary is not enough --
|
|
752
|
+
the Bright Data CLI owns its own login, so a user can have `brightdata`
|
|
753
|
+
on PATH and still get nothing. Report those states separately: an
|
|
754
|
+
unauthenticated install is configured-but-broken (a real fix exists and
|
|
755
|
+
the user wants to hear it), while a missing binary is just an optional
|
|
756
|
+
source nobody opted into.
|
|
757
|
+
"""
|
|
758
|
+
probe = health.probe_dependency(brightdata.CLI_BIN)
|
|
759
|
+
requires = f"{brightdata.CLI_BIN} on the agent-subprocess PATH, logged in"
|
|
760
|
+
if probe.ok:
|
|
761
|
+
if brightdata.has_credentials(config):
|
|
762
|
+
return _record(status=health.OK, detail=probe.detail, requires=requires)
|
|
763
|
+
return _record(
|
|
764
|
+
status="unconfigured",
|
|
765
|
+
fix="run `brightdata login` to activate the amazon source",
|
|
766
|
+
detail="brightdata is installed but has no credentials",
|
|
767
|
+
requires=requires,
|
|
768
|
+
)
|
|
769
|
+
entry = prescriptions.for_dependency_probe(probe)
|
|
770
|
+
fix = _fix_text(entry) if entry else probe.prescription
|
|
771
|
+
if probe.status == health.MISSING and not probe.off_path:
|
|
772
|
+
return _record(
|
|
773
|
+
status="opt-in",
|
|
774
|
+
fix="npm i -g @brightdata/cli && brightdata login",
|
|
775
|
+
detail=probe.detail,
|
|
776
|
+
requires=requires,
|
|
777
|
+
)
|
|
778
|
+
return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _tiktok_record(config):
|
|
782
|
+
return _sc_gated_record(config, "tiktok")
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _instagram_record(config):
|
|
786
|
+
return _sc_gated_record(config, "instagram")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _threads_record(config):
|
|
790
|
+
# Threads needs the key AND an INCLUDE_SOURCES=threads opt-in to run, so it
|
|
791
|
+
# is opt-in-gated (not on-by-default like TikTok/Instagram).
|
|
792
|
+
return _sc_optin_record(config, "threads", "threads")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _telegram_record(config):
|
|
796
|
+
# Telegram needs the key AND an INCLUDE_SOURCES=telegram opt-in AND a
|
|
797
|
+
# channel list (TELEGRAM_SOURCES). Without named channels there is no
|
|
798
|
+
# discovery endpoint to call.
|
|
799
|
+
requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=telegram + TELEGRAM_SOURCES"
|
|
800
|
+
if not config.get("SCRAPECREATORS_API_KEY"):
|
|
801
|
+
return _record(status="unconfigured", requires=requires, fix=_sc_fix())
|
|
802
|
+
from . import telegram
|
|
803
|
+
channels = telegram._get_channel_sources(config)
|
|
804
|
+
if "telegram" in env.include_sources(config):
|
|
805
|
+
if channels:
|
|
806
|
+
return _record(
|
|
807
|
+
status=health.OK,
|
|
808
|
+
requires=requires,
|
|
809
|
+
detail=f"SCRAPECREATORS_API_KEY present, {len(channels)} channel(s) configured",
|
|
810
|
+
)
|
|
811
|
+
return _record(
|
|
812
|
+
status="unconfigured",
|
|
813
|
+
requires=requires,
|
|
814
|
+
fix="set TELEGRAM_SOURCES to a comma-separated list of public channel handles",
|
|
815
|
+
note="key present and opt-in active, but no channels configured",
|
|
816
|
+
)
|
|
817
|
+
return _record(
|
|
818
|
+
status="opt-in",
|
|
819
|
+
requires=requires,
|
|
820
|
+
fix="add telegram to INCLUDE_SOURCES and set TELEGRAM_SOURCES to channel handles",
|
|
821
|
+
note="key present; opt-in only, channels required",
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _bluesky_record(config):
|
|
826
|
+
if env.is_bluesky_available(config):
|
|
827
|
+
return _record(status=health.OK, requires="BSKY_HANDLE + BSKY_APP_PASSWORD")
|
|
828
|
+
return _record(
|
|
829
|
+
status="unconfigured",
|
|
830
|
+
requires="BSKY_HANDLE + BSKY_APP_PASSWORD",
|
|
831
|
+
fix=_fix_text(prescriptions.get("bluesky", "app_password_missing")),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _truthsocial_record(config):
|
|
836
|
+
if env.is_truthsocial_available(config):
|
|
837
|
+
return _record(status=health.OK, requires="TRUTHSOCIAL_TOKEN")
|
|
838
|
+
return _record(
|
|
839
|
+
status="unconfigured",
|
|
840
|
+
requires="TRUTHSOCIAL_TOKEN",
|
|
841
|
+
fix=_fix_text(prescriptions.get("truthsocial", "token_missing")),
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _perplexity_record(config):
|
|
846
|
+
requires = (
|
|
847
|
+
"PERPLEXITY_API_KEY or OPENROUTER_API_KEY + "
|
|
848
|
+
"INCLUDE_SOURCES=perplexity"
|
|
849
|
+
)
|
|
850
|
+
has_direct_key = bool(config.get("PERPLEXITY_API_KEY"))
|
|
851
|
+
has_openrouter_key = bool(config.get("OPENROUTER_API_KEY"))
|
|
852
|
+
has_key = has_direct_key or has_openrouter_key
|
|
853
|
+
include = env.include_sources(config)
|
|
854
|
+
if not has_key:
|
|
855
|
+
return _record(
|
|
856
|
+
status="unconfigured", requires=requires,
|
|
857
|
+
fix=(
|
|
858
|
+
"set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in "
|
|
859
|
+
"~/.config/last30days/.env, then add perplexity to INCLUDE_SOURCES"
|
|
860
|
+
),
|
|
861
|
+
)
|
|
862
|
+
if "perplexity" in include:
|
|
863
|
+
return _record(
|
|
864
|
+
status=health.OK,
|
|
865
|
+
requires=requires,
|
|
866
|
+
note=(
|
|
867
|
+
"direct Agent/Search APIs"
|
|
868
|
+
if has_direct_key
|
|
869
|
+
else "OpenRouter Sonar compatibility fallback"
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
return _record(
|
|
873
|
+
status="opt-in", requires=requires,
|
|
874
|
+
fix="add perplexity to INCLUDE_SOURCES (or request it via --search perplexity)",
|
|
875
|
+
note="key present; source runs only when opted in",
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _meta_ads_record(config):
|
|
880
|
+
return _sc_optin_record(config, "meta_ads", "Meta Ad Library")
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _linkedin_record(config):
|
|
884
|
+
requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=linkedin"
|
|
885
|
+
if not config.get("SCRAPECREATORS_API_KEY"):
|
|
886
|
+
return _record(status="unconfigured", requires=requires, fix=_sc_fix())
|
|
887
|
+
if "linkedin" in env.include_sources(config):
|
|
888
|
+
return _record(status=health.OK, requires=requires)
|
|
889
|
+
return _record(
|
|
890
|
+
status="opt-in", requires=requires,
|
|
891
|
+
fix="add linkedin to INCLUDE_SOURCES (or request it via --search linkedin)",
|
|
892
|
+
note="key present; power-user opt-in, never auto-activates",
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _pinterest_record(config):
|
|
897
|
+
requires = "SCRAPECREATORS_API_KEY; requested-only (--search pinterest)"
|
|
898
|
+
if not config.get("SCRAPECREATORS_API_KEY"):
|
|
899
|
+
return _record(status="unconfigured", requires=requires, fix=_sc_fix())
|
|
900
|
+
return _record(
|
|
901
|
+
status="opt-in", requires=requires,
|
|
902
|
+
fix="request it explicitly via --search pinterest (or INCLUDE_SOURCES)",
|
|
903
|
+
note="key present; runs only when requested",
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _xiaohongshu_record(config):
|
|
908
|
+
requires = (
|
|
909
|
+
"logged-in Xiaohongshu browser-session service; requested-only "
|
|
910
|
+
"(--search xhs)"
|
|
911
|
+
)
|
|
912
|
+
entry = prescriptions.get("xiaohongshu", "service_unreachable")
|
|
913
|
+
if config.get("XIAOHONGSHU_API_BASE"):
|
|
914
|
+
return _record(
|
|
915
|
+
status=health.OK, requires=requires,
|
|
916
|
+
note=(
|
|
917
|
+
"XIAOHONGSHU_API_BASE configured; service reachability is not "
|
|
918
|
+
"probed (doctor makes no network calls)"
|
|
919
|
+
),
|
|
920
|
+
)
|
|
921
|
+
return _record(
|
|
922
|
+
status="opt-in",
|
|
923
|
+
requires=requires,
|
|
924
|
+
fix=_fix_text(entry),
|
|
925
|
+
note=(
|
|
926
|
+
"auto-probes http://localhost:18060 first, then "
|
|
927
|
+
"http://host.docker.internal:18060"
|
|
928
|
+
),
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _jobs_record(config):
|
|
933
|
+
return _record(
|
|
934
|
+
status="opt-in",
|
|
935
|
+
requires="none; activates for company topics or --hiring-signals",
|
|
936
|
+
note="on-demand source: no configuration needed",
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _count_saved_briefs(memory_dir) -> int:
|
|
941
|
+
"""Cheap count of saved research briefs (directory listing, no file parse).
|
|
942
|
+
|
|
943
|
+
Globs the ``*-raw*.md`` artifacts the engine writes, deliberately avoiding
|
|
944
|
+
library.scan_library's read_text+parse of every file - a count does not
|
|
945
|
+
need the parsed content, and the full scan adds real latency to every
|
|
946
|
+
`doctor` run on a large library.
|
|
947
|
+
"""
|
|
948
|
+
path = Path(memory_dir).expanduser()
|
|
949
|
+
return sum(1 for _ in path.glob("*-raw*.md"))
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _library_record(config):
|
|
953
|
+
"""Local research library that feeds the report's 'From your library' block.
|
|
954
|
+
|
|
955
|
+
This is not a network source - it reports how many saved briefs are indexed
|
|
956
|
+
so the 'From your library' block's presence is explained on the health
|
|
957
|
+
surface. Read-only and never fails the run: an empty store, a missing store,
|
|
958
|
+
or a SQLite build without FTS5 all resolve to an informational OK line.
|
|
959
|
+
"""
|
|
960
|
+
from . import library, library_index
|
|
961
|
+
|
|
962
|
+
if not library_index.fts5_available():
|
|
963
|
+
return _record(
|
|
964
|
+
status=health.OK,
|
|
965
|
+
requires="none (local SQLite)",
|
|
966
|
+
note=(
|
|
967
|
+
"search index unavailable (this SQLite build lacks FTS5); "
|
|
968
|
+
"saved briefs still render, `library search` is disabled"
|
|
969
|
+
),
|
|
970
|
+
)
|
|
971
|
+
try:
|
|
972
|
+
count = _count_saved_briefs(
|
|
973
|
+
config.get("LAST30DAYS_MEMORY_DIR") or library.DEFAULT_MEMORY_DIR
|
|
974
|
+
)
|
|
975
|
+
except Exception:
|
|
976
|
+
return _record(
|
|
977
|
+
status=health.OK,
|
|
978
|
+
requires="none (local SQLite)",
|
|
979
|
+
note="local research library (powers the 'From your library' block)",
|
|
980
|
+
)
|
|
981
|
+
if count == 0:
|
|
982
|
+
note = "no saved briefs yet - runs you save build this over time"
|
|
983
|
+
else:
|
|
984
|
+
plural = "brief" if count == 1 else "briefs"
|
|
985
|
+
note = (
|
|
986
|
+
f"{count} saved {plural}; powers the 'From your library' block "
|
|
987
|
+
"(LAST30DAYS_LIBRARY_CONTEXT=off to hide)"
|
|
988
|
+
)
|
|
989
|
+
return _record(status=health.OK, requires="none (local SQLite)", note=note)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
_SOURCE_BUILDERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
|
993
|
+
"reddit": _reddit_record,
|
|
994
|
+
"x": _x_record,
|
|
995
|
+
"youtube": _youtube_record,
|
|
996
|
+
"web": _web_record,
|
|
997
|
+
"hackernews": _hackernews_record,
|
|
998
|
+
"polymarket": _polymarket_record,
|
|
999
|
+
"github": _github_record,
|
|
1000
|
+
"devto": _devto_record,
|
|
1001
|
+
"digg": _digg_record,
|
|
1002
|
+
"techmeme": _techmeme_record,
|
|
1003
|
+
"arxiv": _arxiv_record,
|
|
1004
|
+
"trustpilot": _trustpilot_record,
|
|
1005
|
+
"amazon": _amazon_record,
|
|
1006
|
+
"meta_ads": _meta_ads_record,
|
|
1007
|
+
"tiktok": _tiktok_record,
|
|
1008
|
+
"instagram": _instagram_record,
|
|
1009
|
+
"threads": _threads_record,
|
|
1010
|
+
"telegram": _telegram_record,
|
|
1011
|
+
"bluesky": _bluesky_record,
|
|
1012
|
+
"truthsocial": _truthsocial_record,
|
|
1013
|
+
"perplexity": _perplexity_record,
|
|
1014
|
+
"linkedin": _linkedin_record,
|
|
1015
|
+
"pinterest": _pinterest_record,
|
|
1016
|
+
"xiaohongshu": _xiaohongshu_record,
|
|
1017
|
+
"jobs": _jobs_record,
|
|
1018
|
+
"library": _library_record,
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# ---------------------------------------------------------------------------
|
|
1023
|
+
# Run-evidence overlay (U1): read the engine's last-report.json
|
|
1024
|
+
#
|
|
1025
|
+
# doctor predicts config health; a research run records what ACTUALLY happened
|
|
1026
|
+
# per source in Report.source_status. Reading the last run lets doctor tell
|
|
1027
|
+
# "configured" from "working" (the four-state audit) and powers --postmortem.
|
|
1028
|
+
# This is a read-only reuse of the engine's existing report cache - no new
|
|
1029
|
+
# writer. The schema stamp + filename mirror REPORT_CACHE_VERSION /
|
|
1030
|
+
# _last_report_cache_path() in last30days.py (the same mirror pattern the
|
|
1031
|
+
# doctor-cache block below already uses for its own schema stamp).
|
|
1032
|
+
# ---------------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
REPORT_CACHE_SCHEMA_VERSION = "last30days-report-cache/v1"
|
|
1035
|
+
REPORT_CACHE_FILENAME = "last-report.json"
|
|
1036
|
+
DEFAULT_REPORT_CACHE_TTL_SECONDS = 3600
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _last_report_path() -> Optional[Path]:
|
|
1040
|
+
"""The engine's last-report.json, beside the doctor cache (None in clean mode)."""
|
|
1041
|
+
if env.CONFIG_DIR is None:
|
|
1042
|
+
return None
|
|
1043
|
+
return env.CONFIG_DIR / REPORT_CACHE_FILENAME
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def load_run_evidence(
|
|
1047
|
+
config: Dict[str, Any], ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS
|
|
1048
|
+
) -> Dict[str, Any]:
|
|
1049
|
+
"""Return the last research run's per-source outcomes, read-only.
|
|
1050
|
+
|
|
1051
|
+
Shape: ``{"outcomes": {source: {state, items_returned, detail, fix_hint,
|
|
1052
|
+
at}}, "topic": str|None, "at": str|None, "fresh": bool, "present": bool}``.
|
|
1053
|
+
|
|
1054
|
+
Any failure mode - absent file, unreadable, invalid JSON, schema mismatch,
|
|
1055
|
+
wrong shape - yields the empty, not-present result and never raises
|
|
1056
|
+
(doctor's exit-0 contract is absolute). ``fresh`` reflects the report TTL:
|
|
1057
|
+
``--postmortem`` reads regardless of freshness (labeling the age), while the
|
|
1058
|
+
plain-``doctor`` overlay consumes only fresh evidence so a week-old run
|
|
1059
|
+
cannot mislabel a source as WORKING today.
|
|
1060
|
+
"""
|
|
1061
|
+
empty = {"outcomes": {}, "topic": None, "at": None, "fresh": False, "present": False}
|
|
1062
|
+
path = _last_report_path()
|
|
1063
|
+
if path is None or not path.exists():
|
|
1064
|
+
return empty
|
|
1065
|
+
try:
|
|
1066
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1067
|
+
except Exception:
|
|
1068
|
+
return empty
|
|
1069
|
+
if not isinstance(payload, dict):
|
|
1070
|
+
return empty
|
|
1071
|
+
if payload.get("schema") != REPORT_CACHE_SCHEMA_VERSION:
|
|
1072
|
+
return empty
|
|
1073
|
+
reports = payload.get("reports") or []
|
|
1074
|
+
if not reports or not isinstance(reports[0], dict):
|
|
1075
|
+
return empty
|
|
1076
|
+
report = reports[0].get("report")
|
|
1077
|
+
if not isinstance(report, dict):
|
|
1078
|
+
return empty
|
|
1079
|
+
raw_status = report.get("source_status") or {}
|
|
1080
|
+
outcomes: Dict[str, Any] = {}
|
|
1081
|
+
if isinstance(raw_status, dict):
|
|
1082
|
+
for source, outcome in raw_status.items():
|
|
1083
|
+
if not isinstance(outcome, dict):
|
|
1084
|
+
continue
|
|
1085
|
+
state = outcome.get("state")
|
|
1086
|
+
if not isinstance(state, str):
|
|
1087
|
+
continue
|
|
1088
|
+
outcomes[source] = {
|
|
1089
|
+
"state": state,
|
|
1090
|
+
"items_returned": int(outcome.get("items_returned") or 0),
|
|
1091
|
+
"detail": outcome.get("detail"),
|
|
1092
|
+
"fix_hint": outcome.get("fix_hint"),
|
|
1093
|
+
"at": outcome.get("at"),
|
|
1094
|
+
}
|
|
1095
|
+
timestamp = payload.get("timestamp")
|
|
1096
|
+
return {
|
|
1097
|
+
"outcomes": outcomes,
|
|
1098
|
+
"topic": payload.get("topic"),
|
|
1099
|
+
"at": timestamp or report.get("generated_at"),
|
|
1100
|
+
"fresh": bool(env.is_timestamp_fresh(timestamp, ttl_seconds)),
|
|
1101
|
+
"present": True,
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# ---------------------------------------------------------------------------
|
|
1106
|
+
# Backup + comment sub-lanes (U7 / R8, R9)
|
|
1107
|
+
#
|
|
1108
|
+
# Backups (Reddit's SC backfill, YouTube's SC transcript/search backstop, X's
|
|
1109
|
+
# cookie-vs-key dual path) and comment lanes (youtube/tiktok/instagram) are not
|
|
1110
|
+
# independent sources - they are capabilities of their parent. doctor surfaces
|
|
1111
|
+
# them as indented sub-lines so "is a backup armed when yt-dlp is rate-limited?"
|
|
1112
|
+
# is answerable at a glance without inventing fake sources.
|
|
1113
|
+
# ---------------------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
def _x_auth_path(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1116
|
+
"""The "X auth path" sub-lane, routed through the X policy.
|
|
1117
|
+
|
|
1118
|
+
Off an official-only host the wording is unchanged (key-backed, cookie
|
|
1119
|
+
path, or nothing armed). On one it names only the official path: the
|
|
1120
|
+
connector lane when declared, else the bearer (with the about-a-week
|
|
1121
|
+
caveat), the xAI key, or xurl; a pinned non-official backend is named
|
|
1122
|
+
once, on the will-use line, never here.
|
|
1123
|
+
"""
|
|
1124
|
+
policy = env.x_policy(config)
|
|
1125
|
+
if not policy.official_only:
|
|
1126
|
+
has_key = bool(config.get("XAI_API_KEY") or config.get("XQUIK_API_KEY"))
|
|
1127
|
+
cookie = bool(env.x_pending_browser_auth(config, local_only=True))
|
|
1128
|
+
if has_key:
|
|
1129
|
+
note = "XAI_API_KEY key-backed path (verified, cookie-free)"
|
|
1130
|
+
elif cookie:
|
|
1131
|
+
note = (
|
|
1132
|
+
"browser-cookie path primary; add XAI_API_KEY for a verified "
|
|
1133
|
+
"cookie-free backup"
|
|
1134
|
+
)
|
|
1135
|
+
else:
|
|
1136
|
+
note = "no auth path armed"
|
|
1137
|
+
return {"name": "X auth path", "armed": has_key or cookie, "note": note}
|
|
1138
|
+
if env.x_host_lane_declared(config):
|
|
1139
|
+
return {"name": "X auth path", "armed": True, "note": X_CONNECTOR_ARMED}
|
|
1140
|
+
source = env.get_x_source(config, local_only=True)
|
|
1141
|
+
if source == "xapi":
|
|
1142
|
+
note = f"X_BEARER_TOKEN path ({X_BEARER_CAVEAT})"
|
|
1143
|
+
elif source == "xai":
|
|
1144
|
+
note = "XAI_API_KEY licensed path (console.x.ai)"
|
|
1145
|
+
elif source == "xurl":
|
|
1146
|
+
note = "X API through the xurl CLI (stored OAuth2 login)"
|
|
1147
|
+
elif source:
|
|
1148
|
+
note = "explicit backend pin"
|
|
1149
|
+
else:
|
|
1150
|
+
note = (
|
|
1151
|
+
"no official X path armed (use Grok Bot's built-in X tools or add the X for Grok Bot plugin, or set "
|
|
1152
|
+
"X_BEARER_TOKEN or XAI_API_KEY)"
|
|
1153
|
+
)
|
|
1154
|
+
return {"name": "X auth path", "armed": bool(source), "note": note}
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _sub_lanes_for(source: str, config: Dict[str, Any]):
|
|
1158
|
+
"""Return (backups, comments) metadata for a source, or ([], None)."""
|
|
1159
|
+
backups: List[Dict[str, Any]] = []
|
|
1160
|
+
comments: Optional[Dict[str, Any]] = None
|
|
1161
|
+
has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
|
|
1162
|
+
if source == "reddit":
|
|
1163
|
+
floor = env.reddit_sc_min_items(config)
|
|
1164
|
+
if floor > 0:
|
|
1165
|
+
note = f"fills in when results fall below the {floor}-item floor"
|
|
1166
|
+
else:
|
|
1167
|
+
note = "fills in when the free public path returns nothing"
|
|
1168
|
+
backups.append({"name": "ScrapeCreators backfill", "armed": has_sc, "note": note})
|
|
1169
|
+
elif source == "youtube":
|
|
1170
|
+
backups.append({
|
|
1171
|
+
"name": "ScrapeCreators transcript/search backstop", "armed": has_sc,
|
|
1172
|
+
"note": "used when yt-dlp is rate-limited or bot-gated",
|
|
1173
|
+
})
|
|
1174
|
+
comments = {"enabled": bool(env.is_youtube_comments_available(config))}
|
|
1175
|
+
elif source == "x":
|
|
1176
|
+
backups.append(_x_auth_path(config))
|
|
1177
|
+
elif source == "tiktok":
|
|
1178
|
+
comments = {"enabled": bool(env.is_tiktok_comments_available(config))}
|
|
1179
|
+
elif source == "instagram":
|
|
1180
|
+
comments = {"enabled": bool(env.is_instagram_comments_available(config))}
|
|
1181
|
+
return backups, comments
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
# ---------------------------------------------------------------------------
|
|
1185
|
+
# Aggregation
|
|
1186
|
+
# ---------------------------------------------------------------------------
|
|
1187
|
+
|
|
1188
|
+
def _engine_version() -> str:
|
|
1189
|
+
try:
|
|
1190
|
+
from . import render
|
|
1191
|
+
|
|
1192
|
+
version = render._skill_version()
|
|
1193
|
+
except Exception:
|
|
1194
|
+
version = None
|
|
1195
|
+
# render's parent-walking helper falls back to "?"; doctor says "unknown".
|
|
1196
|
+
if not version or version == "?":
|
|
1197
|
+
return "unknown"
|
|
1198
|
+
return version
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _setup_block(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1202
|
+
keys_present = {var: bool(config.get(var)) for var in KEY_PRESENCE_VARS}
|
|
1203
|
+
keys_present["x_browser_cookies"] = bool(
|
|
1204
|
+
config.get("AUTH_TOKEN") and config.get("CT0")
|
|
1205
|
+
)
|
|
1206
|
+
keys_present["bluesky_app_password"] = bool(
|
|
1207
|
+
config.get("BSKY_HANDLE") and config.get("BSKY_APP_PASSWORD")
|
|
1208
|
+
)
|
|
1209
|
+
return {
|
|
1210
|
+
"setup_complete": env.is_setup_complete(config),
|
|
1211
|
+
"keys_present": keys_present,
|
|
1212
|
+
# get_config() rejected these before any presence check ran, so
|
|
1213
|
+
# keys_present already reads them as absent. Carrying the names here is
|
|
1214
|
+
# what lets the text renderer say *why* they are absent instead of
|
|
1215
|
+
# leaving the user to read "credentials present: none". Only keys left
|
|
1216
|
+
# unset are listed: one whose placeholder fell through to a real
|
|
1217
|
+
# lower-priority credential is configured and does not appear.
|
|
1218
|
+
"unsubstituted_templates": env.templated_config_keys(config),
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _permissions_block(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1223
|
+
"""Secret-free permission summary via the existing preflight provider."""
|
|
1224
|
+
from . import pipeline
|
|
1225
|
+
|
|
1226
|
+
diag = pipeline.diagnose(config, None, safe=True)
|
|
1227
|
+
return diag["permission_preflight"]
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def build_report(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1231
|
+
"""Aggregate every health provider into one report dict.
|
|
1232
|
+
|
|
1233
|
+
Per-source exceptions are isolated: a failing builder yields an
|
|
1234
|
+
``error`` record for that source and the rest of the report survives.
|
|
1235
|
+
"""
|
|
1236
|
+
def _build_one(name: str) -> Dict[str, Any]:
|
|
1237
|
+
try:
|
|
1238
|
+
return _SOURCE_BUILDERS[name](config)
|
|
1239
|
+
except Exception as exc: # one bad probe must not blank the report
|
|
1240
|
+
return _record(
|
|
1241
|
+
status=health.ERROR,
|
|
1242
|
+
detail=f"probe failed: {type(exc).__name__}: {exc}",
|
|
1243
|
+
fix=_fix_text(prescriptions.get(name, "probe_error")),
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
# Builders are independent probes (subprocess/filesystem bound), so run
|
|
1247
|
+
# them concurrently. ``pool.map`` preserves SOURCE_ORDER, keeping the
|
|
1248
|
+
# sources dict insertion order — and render grouping — deterministic.
|
|
1249
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
1250
|
+
max_workers=min(8, len(SOURCE_ORDER))
|
|
1251
|
+
) as pool:
|
|
1252
|
+
sources: Dict[str, Dict[str, Any]] = dict(
|
|
1253
|
+
zip(SOURCE_ORDER, pool.map(_build_one, SOURCE_ORDER))
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
# U1: overlay the last research run's per-source outcome onto each record
|
|
1257
|
+
# so the audit layer can tell "configured" from "actually working".
|
|
1258
|
+
# U2: derive each record's four-state audit bucket (probe evidence, when a
|
|
1259
|
+
# live probe runs, is layered on in run() before render).
|
|
1260
|
+
evidence = load_run_evidence(config)
|
|
1261
|
+
for source, record in sources.items():
|
|
1262
|
+
record["run_outcome"] = evidence["outcomes"].get(source) if evidence["fresh"] else None
|
|
1263
|
+
record["audit_state"] = audit_state(source, record, record["run_outcome"])
|
|
1264
|
+
|
|
1265
|
+
# U3: annotate each CLI-dependent source with its binary's health so the
|
|
1266
|
+
# per-source marker and the dedicated CLI-health block can render (R2).
|
|
1267
|
+
for source, cli_name in CLI_DEPENDENCIES.items():
|
|
1268
|
+
record = sources.get(source)
|
|
1269
|
+
if record is None:
|
|
1270
|
+
continue
|
|
1271
|
+
try:
|
|
1272
|
+
probe = health.probe_dependency(cli_name)
|
|
1273
|
+
except Exception:
|
|
1274
|
+
continue
|
|
1275
|
+
record["cli"] = {
|
|
1276
|
+
"name": cli_name,
|
|
1277
|
+
"status": probe.status,
|
|
1278
|
+
"off_path": bool(getattr(probe, "off_path", False)),
|
|
1279
|
+
"detail": probe.detail,
|
|
1280
|
+
"optional": source in _OPTIONAL_CLI_SOURCES,
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
# U7: attach backup + comment sub-lanes to their parent source.
|
|
1284
|
+
for source, record in sources.items():
|
|
1285
|
+
backups, comments = _sub_lanes_for(source, config)
|
|
1286
|
+
if backups:
|
|
1287
|
+
record["backups"] = backups
|
|
1288
|
+
if comments is not None:
|
|
1289
|
+
record["comments"] = comments
|
|
1290
|
+
|
|
1291
|
+
# Sequential on purpose: the permission preflight composes pipeline
|
|
1292
|
+
# diagnostics and must not race the source builders.
|
|
1293
|
+
try:
|
|
1294
|
+
permissions = _permissions_block(config)
|
|
1295
|
+
except Exception as exc:
|
|
1296
|
+
permissions = {"status": "unavailable", "error": f"{type(exc).__name__}: {exc}"}
|
|
1297
|
+
|
|
1298
|
+
return {
|
|
1299
|
+
"engine_version": _engine_version(),
|
|
1300
|
+
"config": {
|
|
1301
|
+
"global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
|
|
1302
|
+
"config_source": config.get("_CONFIG_SOURCE"),
|
|
1303
|
+
# The resolved host value, so a missing export is visible.
|
|
1304
|
+
"host": env.x_policy(config).host or None,
|
|
1305
|
+
# A .env line cannot declare the X connector lane.
|
|
1306
|
+
"host_lane_file_ignored": bool(config.get("_X_HOST_LANE_FILE_IGNORED")),
|
|
1307
|
+
},
|
|
1308
|
+
"setup": _setup_block(config),
|
|
1309
|
+
"permissions": permissions,
|
|
1310
|
+
"sources": sources,
|
|
1311
|
+
"mode": "config",
|
|
1312
|
+
"run_evidence": {
|
|
1313
|
+
"present": evidence["present"],
|
|
1314
|
+
"fresh": evidence["fresh"],
|
|
1315
|
+
"topic": evidence["topic"],
|
|
1316
|
+
"at": evidence["at"],
|
|
1317
|
+
},
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# ---------------------------------------------------------------------------
|
|
1322
|
+
# Renderers
|
|
1323
|
+
# ---------------------------------------------------------------------------
|
|
1324
|
+
|
|
1325
|
+
def render_json(report: Dict[str, Any]) -> str:
|
|
1326
|
+
return json.dumps(report, indent=2, sort_keys=True)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _cli_marker(record: Dict[str, Any]) -> str:
|
|
1330
|
+
"""Inline `[CLI: name ✓]` / `[keyless]` marker (populated by U3)."""
|
|
1331
|
+
cli = record.get("cli")
|
|
1332
|
+
if not cli:
|
|
1333
|
+
return ""
|
|
1334
|
+
name = cli.get("name")
|
|
1335
|
+
if cli.get("status") == health.OK:
|
|
1336
|
+
return f" [CLI: {name} ✓]"
|
|
1337
|
+
if cli.get("off_path"):
|
|
1338
|
+
return f" [CLI: {name} ✗ off-PATH]"
|
|
1339
|
+
return f" [CLI: {name} ✗ {cli.get('status')}]"
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _run_evidence_suffix(record: Dict[str, Any], state: str) -> str:
|
|
1343
|
+
"""Last-run outcome tail for a source line (R4)."""
|
|
1344
|
+
ro = record.get("run_outcome")
|
|
1345
|
+
if not ro:
|
|
1346
|
+
if state == AUDIT_UNVERIFIED:
|
|
1347
|
+
return " [no recent run]"
|
|
1348
|
+
return ""
|
|
1349
|
+
st = ro.get("state")
|
|
1350
|
+
count = ro.get("items_returned") or 0
|
|
1351
|
+
detail = ro.get("detail")
|
|
1352
|
+
if st in _RUN_WORKING_STATES:
|
|
1353
|
+
if count:
|
|
1354
|
+
return f" [✓ {count} items last run]"
|
|
1355
|
+
return " [✓ ran clean, 0 matches last run]"
|
|
1356
|
+
if st == health.PARTIAL:
|
|
1357
|
+
tail = f" ({detail})" if detail else ""
|
|
1358
|
+
return f" [⚠ partial last run{tail}]"
|
|
1359
|
+
tail = detail or st
|
|
1360
|
+
return f" [✕ {tail} last run]"
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _audit_source_line(name: str, record: Dict[str, Any], state: str) -> str:
|
|
1364
|
+
glyph = AUDIT_GLYPHS.get(state, "?")
|
|
1365
|
+
parts = [f" {glyph} {name}{_cli_marker(record)}"]
|
|
1366
|
+
descriptors: List[str] = []
|
|
1367
|
+
if record.get("status") not in (health.OK,):
|
|
1368
|
+
descriptors.append(record["status"])
|
|
1369
|
+
if record.get("note"):
|
|
1370
|
+
descriptors.append(record["note"])
|
|
1371
|
+
elif record.get("detail") and record.get("tier") != TIER_OK:
|
|
1372
|
+
descriptors.append(record["detail"])
|
|
1373
|
+
if descriptors:
|
|
1374
|
+
parts.append(" — " + "; ".join(descriptors))
|
|
1375
|
+
evidence = _run_evidence_suffix(record, state)
|
|
1376
|
+
if evidence:
|
|
1377
|
+
parts.append(evidence)
|
|
1378
|
+
# fix is only ever populated when there is something actionable, so
|
|
1379
|
+
# render it whenever present — an ok-tier record can carry one (the
|
|
1380
|
+
# youtube transcription-key note) and must not lose it in text mode.
|
|
1381
|
+
if record.get("fix"):
|
|
1382
|
+
parts.append(f"; fix: {record['fix']}")
|
|
1383
|
+
# Backup / comment sub-lanes render on their own indented lines (U7),
|
|
1384
|
+
# after the primary line (with its fix) is complete.
|
|
1385
|
+
for sub in _sub_lane_lines(record):
|
|
1386
|
+
parts.append("\n" + sub)
|
|
1387
|
+
return "".join(parts)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _sub_lane_lines(record: Dict[str, Any]) -> List[str]:
|
|
1391
|
+
"""Indented backup/comment sub-lane lines under a source (R8, R9)."""
|
|
1392
|
+
lines: List[str] = []
|
|
1393
|
+
for backup in record.get("backups") or []:
|
|
1394
|
+
state = "armed" if backup.get("armed") else "off"
|
|
1395
|
+
note = f" - {backup['note']}" if backup.get("note") else ""
|
|
1396
|
+
lines.append(f" backup: {backup['name']} — {state}{note}")
|
|
1397
|
+
comments = record.get("comments")
|
|
1398
|
+
if comments is not None:
|
|
1399
|
+
state = "on" if comments.get("enabled") else "off"
|
|
1400
|
+
lines.append(f" comments: {state}")
|
|
1401
|
+
return lines
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _cli_health_lines(report: Dict[str, Any]) -> List[str]:
|
|
1405
|
+
"""Dedicated CLI-health block (R2): one row per CLI-dependent source,
|
|
1406
|
+
plus a note naming the keyless sources that need no CLI at all.
|
|
1407
|
+
"""
|
|
1408
|
+
sources = report.get("sources") or {}
|
|
1409
|
+
rows: List[str] = []
|
|
1410
|
+
for source in SOURCE_ORDER:
|
|
1411
|
+
cli = (sources.get(source) or {}).get("cli")
|
|
1412
|
+
if not cli:
|
|
1413
|
+
continue
|
|
1414
|
+
ok = cli.get("status") == health.OK
|
|
1415
|
+
glyph = "✓" if ok else "✗"
|
|
1416
|
+
detail = cli.get("detail") or cli.get("status")
|
|
1417
|
+
tail = ""
|
|
1418
|
+
if not ok:
|
|
1419
|
+
if cli.get("off_path"):
|
|
1420
|
+
tail = " (installed off-PATH)"
|
|
1421
|
+
elif cli.get("optional"):
|
|
1422
|
+
tail = " (optional)"
|
|
1423
|
+
rows.append(f" {glyph} {cli['name']} — {source}{tail}: {detail}")
|
|
1424
|
+
if not rows:
|
|
1425
|
+
return []
|
|
1426
|
+
return (
|
|
1427
|
+
["CLI health (downloaded binaries):"]
|
|
1428
|
+
+ rows
|
|
1429
|
+
+ [" · Reddit, Hacker News, Polymarket need no CLI (keyless)"]
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def render_text(report: Dict[str, Any]) -> str:
|
|
1434
|
+
lines: List[str] = [f"last30days doctor — engine v{report['engine_version']}"]
|
|
1435
|
+
config_block = report.get("config") or {}
|
|
1436
|
+
if config_block.get("global_env"):
|
|
1437
|
+
line = f"config: {config_block['global_env']}"
|
|
1438
|
+
if config_block.get("config_source"):
|
|
1439
|
+
line += f" (source: {config_block['config_source']})"
|
|
1440
|
+
lines.append(line)
|
|
1441
|
+
host = config_block.get("host")
|
|
1442
|
+
if host:
|
|
1443
|
+
lines.append(f"host: {host} ({env.X_HOST_VAR})")
|
|
1444
|
+
else:
|
|
1445
|
+
lines.append(f"host: not set ({env.X_HOST_VAR} unset; default X policy)")
|
|
1446
|
+
if config_block.get("host_lane_file_ignored"):
|
|
1447
|
+
lines.append(
|
|
1448
|
+
f"note: a {env.X_HOST_LANE_VAR} line in .env cannot declare the X "
|
|
1449
|
+
"connector lane (ignored); export it in the process environment "
|
|
1450
|
+
"for the session instead"
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
setup = report.get("setup") or {}
|
|
1454
|
+
present = sorted(
|
|
1455
|
+
name for name, is_set in (setup.get("keys_present") or {}).items() if is_set
|
|
1456
|
+
)
|
|
1457
|
+
setup_state = "complete" if setup.get("setup_complete") else "not recorded"
|
|
1458
|
+
lines.append(
|
|
1459
|
+
f"setup: {setup_state}; credentials present: "
|
|
1460
|
+
+ (", ".join(present) if present else "none")
|
|
1461
|
+
+ " (values never shown)"
|
|
1462
|
+
)
|
|
1463
|
+
templated = setup.get("unsubstituted_templates") or []
|
|
1464
|
+
if templated:
|
|
1465
|
+
lines.append(
|
|
1466
|
+
"note: unsubstituted config template(s), counted as unset: "
|
|
1467
|
+
+ ", ".join(templated)
|
|
1468
|
+
+ " (set a real value or remove each)"
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
permissions = report.get("permissions") or {}
|
|
1472
|
+
if permissions.get("status"):
|
|
1473
|
+
browser = ((permissions.get("local_reads") or {}).get("browser_cookies") or {})
|
|
1474
|
+
lines.append(
|
|
1475
|
+
f"permissions: {permissions['status']}"
|
|
1476
|
+
+ (f"; browser cookies: {browser.get('status')}" if browser else "")
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
run_ev = report.get("run_evidence") or {}
|
|
1480
|
+
if run_ev.get("present") and run_ev.get("fresh"):
|
|
1481
|
+
topic = run_ev.get("topic") or "last run"
|
|
1482
|
+
lines.append(
|
|
1483
|
+
f"last run: {topic} - overlaying actual source outcomes below"
|
|
1484
|
+
)
|
|
1485
|
+
elif run_ev.get("present"):
|
|
1486
|
+
lines.append(
|
|
1487
|
+
"last run: found but stale - run `doctor --postmortem` to inspect it"
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
grouped: Dict[str, List[str]] = {state: [] for state, _ in AUDIT_GROUPS}
|
|
1491
|
+
for name, record in (report.get("sources") or {}).items():
|
|
1492
|
+
state = record.get("audit_state") or audit_state(
|
|
1493
|
+
name, record, record.get("run_outcome")
|
|
1494
|
+
)
|
|
1495
|
+
grouped.setdefault(state, []).append(_audit_source_line(name, record, state))
|
|
1496
|
+
|
|
1497
|
+
for state, header in AUDIT_GROUPS:
|
|
1498
|
+
entries = grouped.get(state) or []
|
|
1499
|
+
lines.append("")
|
|
1500
|
+
lines.append(f"{AUDIT_GLYPHS.get(state, '')} {header}:")
|
|
1501
|
+
if entries:
|
|
1502
|
+
lines.extend(entries)
|
|
1503
|
+
else:
|
|
1504
|
+
lines.append(" (none)")
|
|
1505
|
+
|
|
1506
|
+
cli_block = _cli_health_lines(report)
|
|
1507
|
+
if cli_block:
|
|
1508
|
+
lines.append("")
|
|
1509
|
+
lines.extend(cli_block)
|
|
1510
|
+
|
|
1511
|
+
lines.append("")
|
|
1512
|
+
lines.append(
|
|
1513
|
+
"doctor reports problems without failing; run the printed fixes, "
|
|
1514
|
+
"then re-run doctor"
|
|
1515
|
+
)
|
|
1516
|
+
return "\n".join(lines) + "\n"
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
# ---------------------------------------------------------------------------
|
|
1520
|
+
# Post-mortem (U4 / R5): what actually happened on the last run
|
|
1521
|
+
#
|
|
1522
|
+
# Unlike plain doctor (config prediction), --postmortem reads the last run's
|
|
1523
|
+
# per-source SourceOutcome and reports what broke, at any age (labeled). It is
|
|
1524
|
+
# a reader of the same last-report.json the overlay uses - no new persistence.
|
|
1525
|
+
# ---------------------------------------------------------------------------
|
|
1526
|
+
|
|
1527
|
+
def _age_label(iso: Any) -> str:
|
|
1528
|
+
if not iso:
|
|
1529
|
+
return ""
|
|
1530
|
+
try:
|
|
1531
|
+
ts = datetime.datetime.fromisoformat(iso)
|
|
1532
|
+
except (TypeError, ValueError):
|
|
1533
|
+
return ""
|
|
1534
|
+
if ts.tzinfo is None:
|
|
1535
|
+
ts = ts.replace(tzinfo=datetime.timezone.utc)
|
|
1536
|
+
secs = int(
|
|
1537
|
+
(datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds()
|
|
1538
|
+
)
|
|
1539
|
+
if secs < 0:
|
|
1540
|
+
return ""
|
|
1541
|
+
if secs < 3600:
|
|
1542
|
+
return f"{secs // 60}m ago"
|
|
1543
|
+
if secs < 86400:
|
|
1544
|
+
return f"{secs // 3600}h ago"
|
|
1545
|
+
return f"{secs // 86400}d ago"
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def build_postmortem(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1549
|
+
"""Assemble the last run's per-source outcomes (any age) for --postmortem."""
|
|
1550
|
+
evidence = load_run_evidence(config)
|
|
1551
|
+
return {
|
|
1552
|
+
"engine_version": _engine_version(),
|
|
1553
|
+
"mode": "postmortem",
|
|
1554
|
+
"present": evidence["present"],
|
|
1555
|
+
"topic": evidence["topic"],
|
|
1556
|
+
"at": evidence["at"],
|
|
1557
|
+
"outcomes": evidence["outcomes"],
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def _postmortem_state_label(source: str, outcome: Dict[str, Any]) -> str:
|
|
1562
|
+
"""State label for a failed post-mortem line.
|
|
1563
|
+
|
|
1564
|
+
``payment-required`` reads as "credits exhausted" ("X API credits
|
|
1565
|
+
exhausted" for X) so the fix is obvious at a glance: top up, not re-login.
|
|
1566
|
+
Every other state prints as its raw name.
|
|
1567
|
+
"""
|
|
1568
|
+
state = str(outcome.get("state") or "")
|
|
1569
|
+
if state == health.PAYMENT_REQUIRED:
|
|
1570
|
+
return health.credits_exhausted_label(source)
|
|
1571
|
+
return state
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def render_postmortem_text(pm: Dict[str, Any]) -> str:
|
|
1575
|
+
lines = [f"last30days post-mortem — engine v{pm['engine_version']}"]
|
|
1576
|
+
if not pm.get("present"):
|
|
1577
|
+
lines.append("")
|
|
1578
|
+
lines.append(
|
|
1579
|
+
"No saved run found - run `/last30days <topic>` first, or "
|
|
1580
|
+
"`doctor --probe` for a live check."
|
|
1581
|
+
)
|
|
1582
|
+
return "\n".join(lines) + "\n"
|
|
1583
|
+
topic = pm.get("topic") or "last run"
|
|
1584
|
+
age = _age_label(pm.get("at"))
|
|
1585
|
+
lines.append(f"last run: {topic}" + (f" ({age})" if age else ""))
|
|
1586
|
+
|
|
1587
|
+
failed, partial, succeeded, skipped = [], [], [], []
|
|
1588
|
+
for source, outcome in (pm.get("outcomes") or {}).items():
|
|
1589
|
+
state = outcome.get("state")
|
|
1590
|
+
if state in _RUN_WORKING_STATES:
|
|
1591
|
+
succeeded.append((source, outcome))
|
|
1592
|
+
elif state == health.PARTIAL:
|
|
1593
|
+
partial.append((source, outcome))
|
|
1594
|
+
elif state == health.SKIPPED_UNCONFIGURED:
|
|
1595
|
+
skipped.append((source, outcome))
|
|
1596
|
+
else:
|
|
1597
|
+
failed.append((source, outcome))
|
|
1598
|
+
|
|
1599
|
+
if failed:
|
|
1600
|
+
lines.append("")
|
|
1601
|
+
lines.append("Failed:")
|
|
1602
|
+
for source, outcome in failed:
|
|
1603
|
+
detail = outcome.get("detail") or outcome.get("state")
|
|
1604
|
+
lines.append(f" ✕ {source} — {_postmortem_state_label(source, outcome)}: {detail}")
|
|
1605
|
+
if outcome.get("fix_hint"):
|
|
1606
|
+
lines.append(f" fix: {outcome['fix_hint']}")
|
|
1607
|
+
if partial:
|
|
1608
|
+
lines.append("")
|
|
1609
|
+
lines.append("Partial:")
|
|
1610
|
+
for source, outcome in partial:
|
|
1611
|
+
count = outcome.get("items_returned") or 0
|
|
1612
|
+
detail = outcome.get("detail")
|
|
1613
|
+
tail = f" — {detail}" if detail else ""
|
|
1614
|
+
lines.append(f" ⚠ {source} ({count} items){tail}")
|
|
1615
|
+
if outcome.get("fix_hint"):
|
|
1616
|
+
lines.append(f" fix: {outcome['fix_hint']}")
|
|
1617
|
+
if succeeded:
|
|
1618
|
+
lines.append("")
|
|
1619
|
+
def _succeeded_label(source: str, outcome: Dict[str, Any]) -> str:
|
|
1620
|
+
count = outcome.get("items_returned") or 0
|
|
1621
|
+
detail = outcome.get("detail")
|
|
1622
|
+
if detail:
|
|
1623
|
+
noun = "item" if count == 1 else "items"
|
|
1624
|
+
return f"{source} ({count} {noun}; {detail})"
|
|
1625
|
+
return f"{source} ({count})"
|
|
1626
|
+
|
|
1627
|
+
names = ", ".join(_succeeded_label(s, o) for s, o in succeeded)
|
|
1628
|
+
lines.append(f"Succeeded: {names}")
|
|
1629
|
+
if skipped:
|
|
1630
|
+
lines.append("")
|
|
1631
|
+
lines.append(
|
|
1632
|
+
"Skipped (not configured): " + ", ".join(s for s, _ in skipped)
|
|
1633
|
+
)
|
|
1634
|
+
if not failed and not partial:
|
|
1635
|
+
lines.append("")
|
|
1636
|
+
lines.append("No failures on the last run.")
|
|
1637
|
+
return "\n".join(lines) + "\n"
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
# ---------------------------------------------------------------------------
|
|
1641
|
+
# Cross-invocation cache (U5 / R5, KTD 8)
|
|
1642
|
+
#
|
|
1643
|
+
# Doctor writes its JSON result beside the existing ``last-run.json``
|
|
1644
|
+
# convention (env.CONFIG_DIR) so the SKILL.md standing rule's pre-research
|
|
1645
|
+
# check costs one file read on the healthy path instead of a dozen probe
|
|
1646
|
+
# subprocesses. ``--cached`` serves the stored report within the TTL and
|
|
1647
|
+
# falls through to a live run (rewriting the cache) when the file is stale,
|
|
1648
|
+
# absent, or corrupt — corruption is treated as absence, never a crash.
|
|
1649
|
+
# An explicit ``doctor`` (no ``--cached``) always runs live and refreshes.
|
|
1650
|
+
#
|
|
1651
|
+
# The payload carries a schema stamp (mirrors REPORT_CACHE_VERSION in
|
|
1652
|
+
# last30days.py) and a config fingerprint — a sha256 over the same
|
|
1653
|
+
# non-secret signals doctor already reports (key-presence booleans, backend
|
|
1654
|
+
# pin values, INCLUDE_SOURCES). A schema or fingerprint mismatch is treated
|
|
1655
|
+
# as stale, so a credential or pin change can never serve yesterday's
|
|
1656
|
+
# conclusions. Served reports carry ``from_cache`` + ``generated_at`` so
|
|
1657
|
+
# consumers can see staleness instead of inferring it.
|
|
1658
|
+
# ---------------------------------------------------------------------------
|
|
1659
|
+
|
|
1660
|
+
CACHE_FILENAME = "doctor-cache.json"
|
|
1661
|
+
|
|
1662
|
+
# Bump when the cached payload/report shape changes incompatibly; a
|
|
1663
|
+
# mismatched (or absent) stamp is treated as an absent cache.
|
|
1664
|
+
DOCTOR_CACHE_SCHEMA_VERSION = "last30days-doctor-cache/v1"
|
|
1665
|
+
|
|
1666
|
+
# TTL in SECONDS (env-tunable via LAST30DAYS_DOCTOR_TTL; registered in
|
|
1667
|
+
# lib/env.py's get_config key list so a .env-set value is not swallowed).
|
|
1668
|
+
DEFAULT_CACHE_TTL_SECONDS = 900
|
|
1669
|
+
|
|
1670
|
+
# Config vars whose values must never land in the cache file. Doctor output
|
|
1671
|
+
# carries no secrets by design (key presence is booleans only); this belt-and-
|
|
1672
|
+
# suspenders check refuses to persist the cache if a seeded value ever leaks.
|
|
1673
|
+
_SECRET_CONFIG_VARS = KEY_PRESENCE_VARS + (
|
|
1674
|
+
"AUTH_TOKEN", "CT0", "APIFY_API_TOKEN", "GOOGLE_GENAI_API_KEY",
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
# Backend pin vars folded into the config fingerprint. Pin values are
|
|
1678
|
+
# backend names (e.g. "bird"), never secrets. The host key and the X
|
|
1679
|
+
# connector lane signal join them: both are non-secret switches that
|
|
1680
|
+
# change every X conclusion, so a cached report must not outlive them.
|
|
1681
|
+
_FINGERPRINT_PIN_VARS = (
|
|
1682
|
+
env.X_BACKEND_PIN_VAR,
|
|
1683
|
+
env.REDDIT_BACKEND_PIN_VAR,
|
|
1684
|
+
env.X_HOST_VAR,
|
|
1685
|
+
env.X_HOST_LANE_VAR,
|
|
1686
|
+
)
|
|
1687
|
+
|
|
1688
|
+
# Top-level report keys the renderers read unguarded; a cached report
|
|
1689
|
+
# missing any of them is treated as corrupt (absent), never rendered.
|
|
1690
|
+
_REQUIRED_REPORT_KEYS = ("engine_version", "config", "setup", "permissions", "sources")
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def cache_path() -> Optional[Path]:
|
|
1694
|
+
"""The doctor cache file, beside last-run.json (None in clean mode)."""
|
|
1695
|
+
if env.CONFIG_DIR is None:
|
|
1696
|
+
return None
|
|
1697
|
+
return env.CONFIG_DIR / CACHE_FILENAME
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def cache_ttl_seconds(config: Dict[str, Any]) -> int:
|
|
1701
|
+
"""LAST30DAYS_DOCTOR_TTL in seconds; process env > config; default 900."""
|
|
1702
|
+
raw: Any = os.environ.get("LAST30DAYS_DOCTOR_TTL")
|
|
1703
|
+
if raw is None:
|
|
1704
|
+
raw = (config or {}).get("LAST30DAYS_DOCTOR_TTL")
|
|
1705
|
+
if raw is None or raw == "":
|
|
1706
|
+
return DEFAULT_CACHE_TTL_SECONDS
|
|
1707
|
+
try:
|
|
1708
|
+
return max(0, int(raw))
|
|
1709
|
+
except (TypeError, ValueError):
|
|
1710
|
+
return DEFAULT_CACHE_TTL_SECONDS
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def _is_fresh(timestamp: Any, ttl_seconds: int) -> bool:
|
|
1714
|
+
return env.is_timestamp_fresh(timestamp, ttl_seconds)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _config_fingerprint(config: Dict[str, Any]) -> str:
|
|
1718
|
+
"""sha256 over the non-secret config signals doctor already reports.
|
|
1719
|
+
|
|
1720
|
+
Inputs are key-presence BOOLEANS (never credential values — the same
|
|
1721
|
+
``keys_present`` set the setup block renders), backend pin values
|
|
1722
|
+
(backend names, not secrets), and INCLUDE_SOURCES (not a secret).
|
|
1723
|
+
Adding or removing a credential, changing a pin, or toggling an opt-in
|
|
1724
|
+
source yields a new fingerprint, so ``read_cached_report`` treats the
|
|
1725
|
+
old cache as stale instead of serving pre-change conclusions.
|
|
1726
|
+
"""
|
|
1727
|
+
config = config or {}
|
|
1728
|
+
signals = {
|
|
1729
|
+
"keys_present": _setup_block(config)["keys_present"],
|
|
1730
|
+
"pins": {var: str(config.get(var) or "") for var in _FINGERPRINT_PIN_VARS},
|
|
1731
|
+
"include_sources": str(config.get("INCLUDE_SOURCES") or ""),
|
|
1732
|
+
"x_cookie_access_denied": str(config.get("LAST30DAYS_X_COOKIE_ACCESS_DENIED") or ""),
|
|
1733
|
+
"browser_consent": str(config.get("BROWSER_CONSENT") or ""),
|
|
1734
|
+
"from_browser": str(config.get("FROM_BROWSER") or ""),
|
|
1735
|
+
}
|
|
1736
|
+
canonical = json.dumps(signals, sort_keys=True, separators=(",", ":"))
|
|
1737
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def _report_shape_ok(report: Any) -> bool:
|
|
1741
|
+
"""True when a cached report satisfies the render contract.
|
|
1742
|
+
|
|
1743
|
+
Validates everything the renderers read unguarded: the required
|
|
1744
|
+
top-level keys exist (dict-valued where render calls ``.get`` on them),
|
|
1745
|
+
and every sources record is a dict carrying a known tier and a str
|
|
1746
|
+
status. Anything else is corrupt — treated as absent, never rendered.
|
|
1747
|
+
"""
|
|
1748
|
+
if not isinstance(report, dict):
|
|
1749
|
+
return False
|
|
1750
|
+
if any(key not in report for key in _REQUIRED_REPORT_KEYS):
|
|
1751
|
+
return False
|
|
1752
|
+
if any(
|
|
1753
|
+
not isinstance(report[key], dict)
|
|
1754
|
+
for key in ("config", "setup", "permissions", "sources")
|
|
1755
|
+
):
|
|
1756
|
+
return False
|
|
1757
|
+
sources = report["sources"]
|
|
1758
|
+
if not sources:
|
|
1759
|
+
return False
|
|
1760
|
+
for record in sources.values():
|
|
1761
|
+
if not isinstance(record, dict):
|
|
1762
|
+
return False
|
|
1763
|
+
if record.get("tier") not in GLYPHS:
|
|
1764
|
+
return False
|
|
1765
|
+
if not isinstance(record.get("status"), str):
|
|
1766
|
+
return False
|
|
1767
|
+
return True
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def read_cached_report(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
1771
|
+
"""Return the cached report when present, well-formed, and within TTL.
|
|
1772
|
+
|
|
1773
|
+
Any failure mode — unreadable file, invalid JSON, schema mismatch,
|
|
1774
|
+
config-fingerprint mismatch, wrong shape, bad or stale timestamp —
|
|
1775
|
+
returns None (cache treated as absent, never a crash).
|
|
1776
|
+
|
|
1777
|
+
A served report is stamped with ``from_cache: True`` and
|
|
1778
|
+
``generated_at`` (the cache write time) so consumers see staleness.
|
|
1779
|
+
"""
|
|
1780
|
+
path = cache_path()
|
|
1781
|
+
if path is None:
|
|
1782
|
+
return None
|
|
1783
|
+
try:
|
|
1784
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
1785
|
+
except Exception:
|
|
1786
|
+
return None
|
|
1787
|
+
if not isinstance(payload, dict):
|
|
1788
|
+
return None
|
|
1789
|
+
if payload.get("schema") != DOCTOR_CACHE_SCHEMA_VERSION:
|
|
1790
|
+
return None # absent or mismatched schema stamp: treat as absent
|
|
1791
|
+
if payload.get("fingerprint") != _config_fingerprint(config):
|
|
1792
|
+
return None # credentials/pins/opt-ins changed: cache is stale
|
|
1793
|
+
report = payload.get("report")
|
|
1794
|
+
if not _report_shape_ok(report):
|
|
1795
|
+
return None
|
|
1796
|
+
if not _is_fresh(payload.get("timestamp"), cache_ttl_seconds(config)):
|
|
1797
|
+
return None
|
|
1798
|
+
report["generated_at"] = payload.get("timestamp")
|
|
1799
|
+
report["from_cache"] = True
|
|
1800
|
+
return report
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _write_cache(report: Dict[str, Any], config: Dict[str, Any]) -> bool:
|
|
1804
|
+
"""Best-effort cache write; refuses to persist any secret value.
|
|
1805
|
+
|
|
1806
|
+
Never fatal: any failure returns False after a one-line stderr warning
|
|
1807
|
+
(doctor's exit-0 contract is unaffected; only ``--cached`` reuse is).
|
|
1808
|
+
"""
|
|
1809
|
+
try:
|
|
1810
|
+
path = cache_path()
|
|
1811
|
+
if path is None:
|
|
1812
|
+
return False
|
|
1813
|
+
payload = {
|
|
1814
|
+
"schema": DOCTOR_CACHE_SCHEMA_VERSION,
|
|
1815
|
+
"fingerprint": _config_fingerprint(config),
|
|
1816
|
+
"timestamp": report.get("generated_at")
|
|
1817
|
+
or datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
1818
|
+
"report": report,
|
|
1819
|
+
}
|
|
1820
|
+
raw = json.dumps(payload, indent=2, sort_keys=True)
|
|
1821
|
+
for var in _SECRET_CONFIG_VARS:
|
|
1822
|
+
value = (config or {}).get(var)
|
|
1823
|
+
if isinstance(value, str) and value and value in raw:
|
|
1824
|
+
return False # never write a cache containing a secret
|
|
1825
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1826
|
+
path.write_text(raw, encoding="utf-8")
|
|
1827
|
+
return True
|
|
1828
|
+
except Exception as exc:
|
|
1829
|
+
sys.stderr.write(
|
|
1830
|
+
f"[last30days] WARNING: could not write doctor cache: "
|
|
1831
|
+
f"{type(exc).__name__}: {exc}\n"
|
|
1832
|
+
)
|
|
1833
|
+
sys.stderr.flush()
|
|
1834
|
+
return False
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
# ---------------------------------------------------------------------------
|
|
1838
|
+
# Live probe (U5 / R6)
|
|
1839
|
+
#
|
|
1840
|
+
# When there is no fresh run to learn from (or on explicit --probe), doctor
|
|
1841
|
+
# runs a BOUNDED live test so WORKING is verified, not guessed. Scope is
|
|
1842
|
+
# deliberate: free HTTP endpoints + keyless CLIs only. Credit-gated /
|
|
1843
|
+
# session-gated sources (x, tiktok, instagram, threads, ...) are NOT
|
|
1844
|
+
# live-probed - a health check must never spend ScrapeCreators credits or trip
|
|
1845
|
+
# auth rate limits; they stay UNVERIFIED with that noted. Every probe is capped
|
|
1846
|
+
# by a per-source deadline so a single slow source (YouTube's 120s search) can
|
|
1847
|
+
# never hang doctor.
|
|
1848
|
+
# ---------------------------------------------------------------------------
|
|
1849
|
+
|
|
1850
|
+
# Free, keyless liveness endpoints (reachability check, tiny payload).
|
|
1851
|
+
_HTTP_PROBE_URLS = {
|
|
1852
|
+
# The keyless engine's real discovery endpoint, built by the lane itself so
|
|
1853
|
+
# the probe cannot drift from what the engine requests (hand-copied URLs
|
|
1854
|
+
# kept certifying endpoints the engine had abandoned).
|
|
1855
|
+
"reddit": reddit_search.search_url("test"),
|
|
1856
|
+
"hackernews": "https://hn.algolia.com/api/v1/search?query=test&hitsPerPage=1",
|
|
1857
|
+
"polymarket": "https://gamma-api.polymarket.com/events?limit=1",
|
|
1858
|
+
"github": "https://api.github.com/rate_limit",
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
# Per-source exception to "a 4xx still means the endpoint responded". The
|
|
1862
|
+
# keyless Reddit lanes send no credentials, so a 403/429 there is the host
|
|
1863
|
+
# refusing this client — the exact failure the engine hits — not reachability.
|
|
1864
|
+
_PROBE_BLOCKED_STATUSES = {"reddit": frozenset({403, 429})}
|
|
1865
|
+
|
|
1866
|
+
# Blocked statuses that mean "refused this probe right now", not "the source is
|
|
1867
|
+
# down". Reddit answers a burst of keyless probes with 429 while the research
|
|
1868
|
+
# lane — which retries with backoff across several endpoints — serves the same
|
|
1869
|
+
# query fine. A single unretried 429 was reporting a healthy Reddit as NOT
|
|
1870
|
+
# WORKING, so these get one retry and, if still refused, downgrade to unverified
|
|
1871
|
+
# rather than a false outage. 403 stays hard: that is a real keyless block.
|
|
1872
|
+
_PROBE_TRANSIENT_STATUSES = {"reddit": frozenset({429})}
|
|
1873
|
+
|
|
1874
|
+
# One retry only: the probe budget is the per-source deadline, and a source that
|
|
1875
|
+
# rate-limits twice in a row is worth surfacing as unverified.
|
|
1876
|
+
_PROBE_RETRY_DELAY_SECONDS = 2.0
|
|
1877
|
+
|
|
1878
|
+
# Probe with the identity the lane sends, or the probe measures the User-Agent
|
|
1879
|
+
# rather than the endpoint (get_text sends http.BROWSER_USER_AGENT).
|
|
1880
|
+
_PROBE_HEADERS = {
|
|
1881
|
+
"reddit": {
|
|
1882
|
+
"User-Agent": http.BROWSER_USER_AGENT,
|
|
1883
|
+
"Accept": "text/html",
|
|
1884
|
+
},
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
# Per-source body validators, run on a 2xx: None means the body is the page the
|
|
1888
|
+
# lane parses, anything else is the reason it is not. Reddit answers a blocked
|
|
1889
|
+
# keyless client with a 200 challenge page, so status alone reads it as working.
|
|
1890
|
+
_PROBE_BODY_CHECKS: Dict[str, Callable[[str], Optional[str]]] = {
|
|
1891
|
+
"reddit": reddit_search.unrecognized_body,
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
DEFAULT_PROBE_TIMEOUT_SECONDS = 10
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def probe_timeout_seconds(config: Dict[str, Any]) -> int:
|
|
1898
|
+
"""Per-source probe deadline; process env > config > default 10s."""
|
|
1899
|
+
raw: Any = os.environ.get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
|
|
1900
|
+
if raw is None:
|
|
1901
|
+
raw = (config or {}).get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
|
|
1902
|
+
if raw is None or raw == "":
|
|
1903
|
+
return DEFAULT_PROBE_TIMEOUT_SECONDS
|
|
1904
|
+
try:
|
|
1905
|
+
return max(1, int(raw))
|
|
1906
|
+
except (TypeError, ValueError):
|
|
1907
|
+
return DEFAULT_PROBE_TIMEOUT_SECONDS
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def _probeable_sources() -> tuple:
|
|
1911
|
+
"""Sources doctor will live-probe: free HTTP endpoints + keyless CLIs.
|
|
1912
|
+
|
|
1913
|
+
github is HTTP-probed (its REST tier works without gh), so it is excluded
|
|
1914
|
+
from the CLI-probe path even though gh is in CLI_DEPENDENCIES.
|
|
1915
|
+
"""
|
|
1916
|
+
cli_only = [s for s in CLI_DEPENDENCIES if s not in _HTTP_PROBE_URLS]
|
|
1917
|
+
return tuple(dict.fromkeys(list(_HTTP_PROBE_URLS) + cli_only))
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _http_ok(
|
|
1921
|
+
url: str,
|
|
1922
|
+
timeout: float,
|
|
1923
|
+
*,
|
|
1924
|
+
blocked_statuses: frozenset = frozenset(),
|
|
1925
|
+
headers: Optional[Dict[str, str]] = None,
|
|
1926
|
+
body_check: Optional[Callable[[str], Optional[str]]] = None,
|
|
1927
|
+
deadline_monotonic: Optional[float] = None,
|
|
1928
|
+
) -> tuple:
|
|
1929
|
+
"""Reachability check: a 4xx still means the endpoint responded; 5xx or a
|
|
1930
|
+
connection/timeout error means it did not.
|
|
1931
|
+
|
|
1932
|
+
``blocked_statuses`` names the per-source codes that mean "responded, but
|
|
1933
|
+
refused us" (Reddit's keyless 403/429) — those are a failure, not
|
|
1934
|
+
reachability. ``headers`` overrides the probe identity so a source can be
|
|
1935
|
+
probed with the same User-Agent its lane sends. ``body_check`` validates
|
|
1936
|
+
a 2xx body; a non-None reason fails the probe as blocked.
|
|
1937
|
+
"""
|
|
1938
|
+
def _verdict(code: int) -> tuple:
|
|
1939
|
+
return code < 500 and code not in blocked_statuses, f"HTTP {code}"
|
|
1940
|
+
|
|
1941
|
+
try:
|
|
1942
|
+
req = urllib.request.Request(
|
|
1943
|
+
url, headers=headers or {"User-Agent": "last30days-doctor"}
|
|
1944
|
+
)
|
|
1945
|
+
if deadline_monotonic is not None:
|
|
1946
|
+
from . import bounded_get
|
|
1947
|
+
|
|
1948
|
+
code, body, error = bounded_get.get(
|
|
1949
|
+
req, timeout=timeout, deadline_monotonic=deadline_monotonic,
|
|
1950
|
+
read_body=body_check is not None, read_error_body=False,
|
|
1951
|
+
)
|
|
1952
|
+
if error is not None:
|
|
1953
|
+
return _verdict(error.code)
|
|
1954
|
+
if body_check is not None and code < 300:
|
|
1955
|
+
reason = body_check((body or b"").decode("utf-8", errors="replace"))
|
|
1956
|
+
if reason:
|
|
1957
|
+
return False, f"HTTP {code} but blocked: {reason}"
|
|
1958
|
+
return _verdict(code)
|
|
1959
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
1960
|
+
code = getattr(resp, "status", 200) or 200
|
|
1961
|
+
if body_check is not None and code < 300:
|
|
1962
|
+
text = resp.read().decode("utf-8", errors="replace")
|
|
1963
|
+
reason = body_check(text)
|
|
1964
|
+
if reason:
|
|
1965
|
+
return False, f"HTTP {code} but blocked: {reason}"
|
|
1966
|
+
return _verdict(code)
|
|
1967
|
+
except urllib.error.HTTPError as exc:
|
|
1968
|
+
return _verdict(exc.code)
|
|
1969
|
+
except Exception as exc:
|
|
1970
|
+
from . import bounded_get
|
|
1971
|
+
|
|
1972
|
+
if isinstance(exc, bounded_get.GetTimeout):
|
|
1973
|
+
return False, "probe exceeded deadline"
|
|
1974
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def _transient_probe_detail(name: str, detail: str) -> bool:
|
|
1978
|
+
"""True when ``detail`` is a status this source may refuse us transiently.
|
|
1979
|
+
|
|
1980
|
+
``_http_ok`` renders its verdict as ``HTTP {code}``; both live in this module,
|
|
1981
|
+
so matching that shape here keeps the retry rule next to the codes it covers.
|
|
1982
|
+
"""
|
|
1983
|
+
transient = _PROBE_TRANSIENT_STATUSES.get(name)
|
|
1984
|
+
if not transient:
|
|
1985
|
+
return False
|
|
1986
|
+
return any(detail == f"HTTP {code}" for code in transient)
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def _probe_source(name: str, config: Dict[str, Any], timeout: float) -> Optional[Dict[str, Any]]:
|
|
1990
|
+
deadline = time.monotonic() + timeout
|
|
1991
|
+
expired = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
|
|
1992
|
+
if timeout <= 0:
|
|
1993
|
+
return expired
|
|
1994
|
+
url = _HTTP_PROBE_URLS.get(name)
|
|
1995
|
+
if url:
|
|
1996
|
+
blocked = _PROBE_BLOCKED_STATUSES.get(name, frozenset())
|
|
1997
|
+
headers = _PROBE_HEADERS.get(name)
|
|
1998
|
+
probe_kwargs = {
|
|
1999
|
+
"blocked_statuses": blocked,
|
|
2000
|
+
"headers": headers,
|
|
2001
|
+
"body_check": _PROBE_BODY_CHECKS.get(name),
|
|
2002
|
+
"deadline_monotonic": deadline,
|
|
2003
|
+
}
|
|
2004
|
+
ok, detail = _http_ok(url, timeout, **probe_kwargs)
|
|
2005
|
+
if time.monotonic() >= deadline or detail == "probe exceeded deadline":
|
|
2006
|
+
return expired
|
|
2007
|
+
if not ok and _transient_probe_detail(name, detail):
|
|
2008
|
+
remaining = deadline - time.monotonic()
|
|
2009
|
+
if remaining <= 0:
|
|
2010
|
+
return expired
|
|
2011
|
+
if remaining <= _PROBE_RETRY_DELAY_SECONDS:
|
|
2012
|
+
return {
|
|
2013
|
+
"ok": False,
|
|
2014
|
+
"transient": True,
|
|
2015
|
+
"detail": f"{detail} (retry skipped: insufficient probe budget)",
|
|
2016
|
+
"probed": True,
|
|
2017
|
+
}
|
|
2018
|
+
time.sleep(_PROBE_RETRY_DELAY_SECONDS)
|
|
2019
|
+
remaining = deadline - time.monotonic()
|
|
2020
|
+
if remaining <= 0:
|
|
2021
|
+
return expired
|
|
2022
|
+
ok, detail = _http_ok(url, remaining, **probe_kwargs)
|
|
2023
|
+
if time.monotonic() >= deadline or detail == "probe exceeded deadline":
|
|
2024
|
+
return expired
|
|
2025
|
+
if not ok and _transient_probe_detail(name, detail):
|
|
2026
|
+
return {
|
|
2027
|
+
"ok": False,
|
|
2028
|
+
"transient": True,
|
|
2029
|
+
"detail": f"{detail} (rate-limited twice; the lane retries with backoff)",
|
|
2030
|
+
"probed": True,
|
|
2031
|
+
}
|
|
2032
|
+
return {"ok": ok, "detail": detail, "probed": True}
|
|
2033
|
+
cli = CLI_DEPENDENCIES.get(name)
|
|
2034
|
+
if cli:
|
|
2035
|
+
try:
|
|
2036
|
+
probe = health.probe_dependency(cli, timeout=max(0, deadline - time.monotonic()))
|
|
2037
|
+
if time.monotonic() >= deadline:
|
|
2038
|
+
return expired
|
|
2039
|
+
except Exception as exc:
|
|
2040
|
+
return {"ok": False, "detail": f"{type(exc).__name__}: {exc}", "probed": True}
|
|
2041
|
+
return {"ok": bool(probe.ok), "detail": probe.detail, "probed": True}
|
|
2042
|
+
return None
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def _probe_sources(config: Dict[str, Any], timeout: int) -> Dict[str, Dict[str, Any]]:
|
|
2046
|
+
"""Probe the probeable sources concurrently, each capped at ``timeout``.
|
|
2047
|
+
|
|
2048
|
+
A source that blows its deadline resolves to a probe-failure for that
|
|
2049
|
+
source only (never a hung command); other probes are unaffected.
|
|
2050
|
+
"""
|
|
2051
|
+
names = _probeable_sources()
|
|
2052
|
+
results: Dict[str, Dict[str, Any]] = {}
|
|
2053
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
2054
|
+
max_workers=min(8, len(names) or 1)
|
|
2055
|
+
) as pool:
|
|
2056
|
+
futures = {
|
|
2057
|
+
name: pool.submit(_probe_source, name, config, timeout) for name in names
|
|
2058
|
+
}
|
|
2059
|
+
for name, fut in futures.items():
|
|
2060
|
+
try:
|
|
2061
|
+
res = fut.result()
|
|
2062
|
+
except concurrent.futures.TimeoutError:
|
|
2063
|
+
res = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
|
|
2064
|
+
except Exception as exc:
|
|
2065
|
+
res = {
|
|
2066
|
+
"ok": False,
|
|
2067
|
+
"detail": f"{type(exc).__name__}: {exc}",
|
|
2068
|
+
"probed": True,
|
|
2069
|
+
}
|
|
2070
|
+
if res is not None:
|
|
2071
|
+
results[name] = res
|
|
2072
|
+
return results
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _apply_probe(report: Dict[str, Any], probe_results: Dict[str, Dict[str, Any]]) -> None:
|
|
2076
|
+
"""Attach probe results and re-derive audit_state for probed sources."""
|
|
2077
|
+
for name, res in probe_results.items():
|
|
2078
|
+
record = (report.get("sources") or {}).get(name)
|
|
2079
|
+
if record is None:
|
|
2080
|
+
continue
|
|
2081
|
+
record["probe"] = res
|
|
2082
|
+
record["audit_state"] = audit_state(
|
|
2083
|
+
name, record, record.get("run_outcome"), res
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
def run(
|
|
2088
|
+
config: Dict[str, Any],
|
|
2089
|
+
*,
|
|
2090
|
+
emit_json: bool = False,
|
|
2091
|
+
cached: bool = False,
|
|
2092
|
+
postmortem: bool = False,
|
|
2093
|
+
probe: bool = False,
|
|
2094
|
+
) -> int:
|
|
2095
|
+
"""Build (or serve the cached) doctor report and print it. Always exits 0
|
|
2096
|
+
(reporting problems is a successful run).
|
|
2097
|
+
|
|
2098
|
+
``postmortem=True`` reads the last run's per-source outcomes (any age) and
|
|
2099
|
+
reports what broke, instead of predicting config health.
|
|
2100
|
+
``probe=True`` (or no fresh run) runs a bounded live probe (U5) so WORKING
|
|
2101
|
+
is verified, not guessed.
|
|
2102
|
+
``cached=True`` serves the stored report within the TTL; stale, absent,
|
|
2103
|
+
corrupt, schema-mismatched, or fingerprint-mismatched caches fall
|
|
2104
|
+
through to a live run that rewrites the cache — as does ANY exception
|
|
2105
|
+
raised while serving the cache (never-crash contract, KTD 8).
|
|
2106
|
+
``cached=False`` (explicit ``doctor``) always runs live and refreshes.
|
|
2107
|
+
"""
|
|
2108
|
+
if postmortem:
|
|
2109
|
+
pm = build_postmortem(config)
|
|
2110
|
+
if emit_json:
|
|
2111
|
+
print(json.dumps(pm, indent=2, sort_keys=True))
|
|
2112
|
+
else:
|
|
2113
|
+
print(render_postmortem_text(pm), end="")
|
|
2114
|
+
return 0
|
|
2115
|
+
|
|
2116
|
+
def _emit(report: Dict[str, Any]) -> None:
|
|
2117
|
+
if emit_json:
|
|
2118
|
+
# generated_at/from_cache ride the report dict, so they appear
|
|
2119
|
+
# at the JSON top level for free.
|
|
2120
|
+
print(render_json(report))
|
|
2121
|
+
else:
|
|
2122
|
+
# The cache-status line is printed here (run() owns this print)
|
|
2123
|
+
# because render_text's header belongs to the render layer, not
|
|
2124
|
+
# the cache layer.
|
|
2125
|
+
origin = "cached" if report.get("from_cache") else "live"
|
|
2126
|
+
print(render_text(report), end="")
|
|
2127
|
+
print(f"generated: {report.get('generated_at')} ({origin})")
|
|
2128
|
+
|
|
2129
|
+
if cached:
|
|
2130
|
+
try:
|
|
2131
|
+
cached_report = read_cached_report(config)
|
|
2132
|
+
if cached_report is not None:
|
|
2133
|
+
_emit(cached_report)
|
|
2134
|
+
return 0
|
|
2135
|
+
except Exception:
|
|
2136
|
+
# Belt-and-suspenders for shapes the validator misses: any
|
|
2137
|
+
# failure serving the cache falls through to a live run.
|
|
2138
|
+
pass
|
|
2139
|
+
report = build_report(config)
|
|
2140
|
+
report["generated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
2141
|
+
report["from_cache"] = False
|
|
2142
|
+
|
|
2143
|
+
# U5: verify WORKING with a bounded live probe when asked (--probe) or when
|
|
2144
|
+
# there is no fresh run to learn from ("if no recent runs, run a live
|
|
2145
|
+
# test"). Scoped to free/CLI sources; credit-gated sources stay UNVERIFIED.
|
|
2146
|
+
fresh_run = bool((report.get("run_evidence") or {}).get("fresh"))
|
|
2147
|
+
if probe or not fresh_run:
|
|
2148
|
+
timeout = probe_timeout_seconds(config)
|
|
2149
|
+
probeable = _probeable_sources()
|
|
2150
|
+
sys.stderr.write(
|
|
2151
|
+
f"[last30days] doctor live probe: checking {len(probeable)} free/CLI "
|
|
2152
|
+
f"sources ({timeout}s each; no credit-gated sources - x/tiktok/"
|
|
2153
|
+
f"instagram/threads stay unverified)\n"
|
|
2154
|
+
)
|
|
2155
|
+
sys.stderr.flush()
|
|
2156
|
+
try:
|
|
2157
|
+
probe_results = _probe_sources(config, timeout)
|
|
2158
|
+
except Exception:
|
|
2159
|
+
probe_results = {}
|
|
2160
|
+
_apply_probe(report, probe_results)
|
|
2161
|
+
report["mode"] = "probe"
|
|
2162
|
+
report["probe"] = {"ran": True, "timeout": timeout, "sources": list(probeable)}
|
|
2163
|
+
|
|
2164
|
+
_write_cache(report, config)
|
|
2165
|
+
_emit(report)
|
|
2166
|
+
return 0
|