osintengine 1.0.2__py3-none-any.whl

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.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
@@ -0,0 +1,43 @@
1
+ """Infrastructure utilities — caching, retry, circuit breakers, rate limiting, resilience."""
2
+
3
+ from .retry import (
4
+ retry,
5
+ CircuitBreaker,
6
+ CircuitOpenError,
7
+ get_circuit,
8
+ _is_retryable,
9
+ )
10
+ from .ratelimit import (
11
+ TokenBucket,
12
+ get_bucket,
13
+ RateLimitExceeded,
14
+ )
15
+ from .cache import (
16
+ TTLCache,
17
+ get_cache,
18
+ )
19
+ from .resilience import (
20
+ FailureReason,
21
+ ToolResult,
22
+ safe_execute,
23
+ classify_failure,
24
+ suggest_alternatives,
25
+ )
26
+
27
+ __all__ = [
28
+ "retry",
29
+ "CircuitBreaker",
30
+ "CircuitOpenError",
31
+ "get_circuit",
32
+ "_is_retryable",
33
+ "TokenBucket",
34
+ "get_bucket",
35
+ "RateLimitExceeded",
36
+ "TTLCache",
37
+ "get_cache",
38
+ "FailureReason",
39
+ "ToolResult",
40
+ "safe_execute",
41
+ "classify_failure",
42
+ "suggest_alternatives",
43
+ ]
@@ -0,0 +1,171 @@
1
+ """TTL cache with size-bounded LRU eviction and stats.
2
+
3
+ Also exposes a small registry/helpers for aggregating cache statistics.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ import time
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ class _Entry:
14
+ __slots__ = ("value", "expiry", "hits", "inserted_at")
15
+
16
+ def __init__(self, value: Any, expiry: float, now: float):
17
+ self.value = value
18
+ self.expiry = expiry
19
+ self.hits = 0
20
+ self.inserted_at = now
21
+
22
+
23
+ class TTLCache:
24
+ """In-memory TTL cache with max-size eviction.
25
+
26
+ Note: :meth:`set` takes ``(value, key)`` — value first, key second —
27
+ matching the project's OSINT-collection ergonomics.
28
+ """
29
+
30
+ def __init__(self, name: str, ttl: float = 60.0, max_size: int = 1000):
31
+ self.name = name
32
+ self.ttl = float(ttl)
33
+ self.max_size = int(max_size)
34
+ self._data: Dict[str, _Entry] = {}
35
+ self._lock = threading.Lock()
36
+ self.stats: Dict[str, Any] = {
37
+ "hits": 0,
38
+ "misses": 0,
39
+ "evictions": 0,
40
+ "size": 0,
41
+ }
42
+
43
+ # -- core operations ------------------------------------------
44
+ def set(self, value: Any, key: str) -> None:
45
+ """Store *value* under *key* with the configured TTL."""
46
+ now = time.monotonic()
47
+ with self._lock:
48
+ if key in self._data:
49
+ # Update in place, keep hit count
50
+ entry = self._data[key]
51
+ entry.value = value
52
+ entry.expiry = now + self.ttl
53
+ else:
54
+ self._data[key] = _Entry(value, now + self.ttl, now)
55
+ if len(self._data) > self.max_size:
56
+ self._evict()
57
+ self.stats["size"] = len(self._data)
58
+
59
+ def get(self, key: str) -> Any:
60
+ """Return the value for *key* or ``None`` on miss/expiry."""
61
+ now = time.monotonic()
62
+ with self._lock:
63
+ entry = self._data.get(key)
64
+ if entry is None:
65
+ self.stats["misses"] += 1
66
+ return None
67
+ if now >= entry.expiry:
68
+ # Expired — remove and count as miss
69
+ del self._data[key]
70
+ self.stats["misses"] += 1
71
+ self.stats["size"] = len(self._data)
72
+ return None
73
+ entry.hits += 1
74
+ self.stats["hits"] += 1
75
+ return entry.value
76
+
77
+ def invalidate(self, key: str) -> None:
78
+ """Remove *key* from the cache if present."""
79
+ with self._lock:
80
+ if key in self._data:
81
+ del self._data[key]
82
+ self.stats["size"] = len(self._data)
83
+
84
+ def clear(self) -> None:
85
+ with self._lock:
86
+ self._data.clear()
87
+ self.stats["size"] = 0
88
+
89
+ # -- eviction --------------------------------------------------
90
+ def _evict(self) -> None:
91
+ """Evict the least-hit (oldest on tie) entry. Caller holds lock."""
92
+ if not self._data:
93
+ return
94
+ # Find entry with fewest hits; ties broken by insertion order (oldest first)
95
+ victim_key = None
96
+ victim_hits = None
97
+ for k, entry in self._data.items():
98
+ if victim_key is None or entry.hits < victim_hits:
99
+ victim_key = k
100
+ victim_hits = entry.hits
101
+ if victim_key is not None:
102
+ del self._data[victim_key]
103
+ self.stats["evictions"] += 1
104
+
105
+ # -- derived metrics ------------------------------------------
106
+ @property
107
+ def hit_rate(self) -> float:
108
+ total = self.stats["hits"] + self.stats["misses"]
109
+ if total == 0:
110
+ return 0.0
111
+ return self.stats["hits"] / total
112
+
113
+
114
+ # ─────────────────────────────────────────────────────────────────
115
+ # Pre-configured caches
116
+ # ─────────────────────────────────────────────────────────────────
117
+
118
+ _PRECONFIGURED: Dict[str, Dict[str, Any]] = {
119
+ "dns": {"ttl": 300, "max_size": 500},
120
+ "whois": {"ttl": 3600, "max_size": 500},
121
+ "cert": {"ttl": 600, "max_size": 1000},
122
+ "default": {"ttl": 60, "max_size": 1000},
123
+ }
124
+
125
+ _caches: Dict[str, TTLCache] = {}
126
+ _caches_lock = threading.Lock()
127
+
128
+
129
+ def get_cache(name: str) -> TTLCache:
130
+ """Get-or-create a singleton :class:`TTLCache` for *name*."""
131
+ with _caches_lock:
132
+ c = _caches.get(name)
133
+ if c is None:
134
+ cfg = _PRECONFIGURED.get(name, _PRECONFIGURED["default"])
135
+ c = TTLCache(
136
+ name=name,
137
+ ttl=cfg.get("ttl", 60),
138
+ max_size=cfg.get("max_size", 1000),
139
+ )
140
+ _caches[name] = c
141
+ return c
142
+
143
+
144
+ # ─────────────────────────────────────────────────────────────────
145
+ # Stats aggregation (legacy helpers)
146
+ # ─────────────────────────────────────────────────────────────────
147
+
148
+ _cache_registry: Dict[str, Any] = {}
149
+
150
+
151
+ def register_cache(name: str, cache_obj: Any) -> None:
152
+ """Register a cache for stats collection."""
153
+ _cache_registry[name] = cache_obj
154
+
155
+
156
+ def all_cache_stats() -> Dict[str, Dict[str, Any]]:
157
+ """Return stats for all registered caches."""
158
+ stats: Dict[str, Dict[str, Any]] = {}
159
+ for name, cache in _cache_registry.items():
160
+ s = getattr(cache, "stats", None)
161
+ if isinstance(s, dict):
162
+ stats[name] = dict(s)
163
+ elif callable(s):
164
+ stats[name] = s()
165
+ elif hasattr(cache, "get_stats"):
166
+ stats[name] = cache.get_stats()
167
+ else:
168
+ stats[name] = {"size": 0, "hits": 0, "misses": 0}
169
+ if not stats:
170
+ stats["default"] = {"size": 0, "hits": 0, "misses": 0}
171
+ return stats
@@ -0,0 +1,125 @@
1
+ """Token-bucket rate limiting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ import time
8
+ from typing import Any, Dict, Optional
9
+
10
+
11
+ class RateLimitExceeded(Exception):
12
+ """Raised when a rate-limited operation cannot acquire a token in time."""
13
+
14
+ def __init__(self, name: str = "", message: str = ""):
15
+ self.name = name
16
+ super().__init__(message or f"rate limit exceeded for '{name}'")
17
+
18
+
19
+ class TokenBucket:
20
+ """Classic token-bucket rate limiter.
21
+
22
+ Tokens accumulate at *refill_rate* per second up to *capacity*. Each
23
+ :meth:`acquire` consumes one token. ``acquire`` blocks up to *timeout*
24
+ seconds waiting for a token to become available.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ name: str = "",
30
+ capacity: float = 5,
31
+ refill_rate: float = 1.0,
32
+ ):
33
+ self.name = name
34
+ self.capacity = float(capacity)
35
+ self.refill_rate = float(refill_rate)
36
+ self._tokens = float(capacity)
37
+ self._last_refill = time.monotonic()
38
+ self._lock = threading.Lock()
39
+
40
+ # -- internals -------------------------------------------------
41
+ def _refill(self) -> None:
42
+ """Add tokens based on elapsed wall-clock time. Caller holds lock."""
43
+ now = time.monotonic()
44
+ elapsed = now - self._last_refill
45
+ if elapsed > 0 and self.refill_rate > 0:
46
+ self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_rate)
47
+ self._last_refill = now
48
+
49
+ def _try_consume(self) -> bool:
50
+ """Non-blocking consume. Caller must hold lock."""
51
+ self._refill()
52
+ if self._tokens >= 1.0:
53
+ self._tokens -= 1.0
54
+ return True
55
+ return False
56
+
57
+ # -- public API ------------------------------------------------
58
+ def acquire(self, timeout: float = 0.0) -> bool:
59
+ """Block up to *timeout* seconds for a token. Return True if acquired.
60
+
61
+ A *timeout* of 0 means non-blocking: try once and return immediately.
62
+ """
63
+ deadline = time.monotonic() + timeout
64
+ while True:
65
+ with self._lock:
66
+ if self._try_consume():
67
+ return True
68
+ if time.monotonic() >= deadline:
69
+ return False
70
+ # Sleep a short slice before retrying so we don't busy-spin hard.
71
+ remaining = deadline - time.monotonic()
72
+ time.sleep(max(0.0, min(0.005, remaining)))
73
+
74
+ async def async_acquire(self, timeout: float = 0.0) -> bool:
75
+ """Async variant of :meth:`acquire`."""
76
+ deadline = time.monotonic() + timeout
77
+ while True:
78
+ with self._lock:
79
+ if self._try_consume():
80
+ return True
81
+ if time.monotonic() >= deadline:
82
+ return False
83
+ remaining = deadline - time.monotonic()
84
+ await asyncio.sleep(max(0.0, min(0.005, remaining)))
85
+
86
+ # -- context manager ------------------------------------------
87
+ def __enter__(self) -> "TokenBucket":
88
+ if not self.acquire(timeout=0.0):
89
+ raise RateLimitExceeded(self.name)
90
+ return self
91
+
92
+ def __exit__(self, exc_type, exc, tb) -> bool:
93
+ return False # never suppress
94
+
95
+
96
+ # ─────────────────────────────────────────────────────────────────
97
+ # Pre-configured buckets
98
+ # ─────────────────────────────────────────────────────────────────
99
+
100
+ _PRECONFIGURED: Dict[str, Dict[str, Any]] = {
101
+ "crt.sh": {"capacity": 3, "refill_rate": 1.0},
102
+ "virustotal": {"capacity": 4, "refill_rate": 1.0},
103
+ "shodan": {"capacity": 1, "refill_rate": 1.0},
104
+ }
105
+
106
+ _DEFAULT_CAPACITY = 5
107
+ _DEFAULT_REFILL_RATE = 1.0
108
+
109
+ _buckets: Dict[str, TokenBucket] = {}
110
+ _buckets_lock = threading.Lock()
111
+
112
+
113
+ def get_bucket(name: str) -> TokenBucket:
114
+ """Get-or-create a singleton :class:`TokenBucket` for *name*."""
115
+ with _buckets_lock:
116
+ b = _buckets.get(name)
117
+ if b is None:
118
+ cfg = _PRECONFIGURED.get(name, {})
119
+ b = TokenBucket(
120
+ name=name,
121
+ capacity=cfg.get("capacity", _DEFAULT_CAPACITY),
122
+ refill_rate=cfg.get("refill_rate", _DEFAULT_REFILL_RATE),
123
+ )
124
+ _buckets[name] = b
125
+ return b
@@ -0,0 +1,239 @@
1
+ """Resilience layer — failure classification, safe execution, alternatives.
2
+
3
+ Wraps tool invocations so the orchestrator gets a uniform :class:`ToolResult`
4
+ regardless of whether a tool succeeded, returned nothing, or raised.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ import time
11
+ from typing import Any, Callable, List, Optional
12
+
13
+
14
+ class FailureReason(enum.Enum):
15
+ """Why a tool invocation failed (or yielded no usable data)."""
16
+
17
+ NO_RESULTS = "no_results"
18
+ TIMEOUT = "timeout"
19
+ CLOUDFLARE_PROTECTED = "cloudflare_protected"
20
+ RATE_LIMITED = "rate_limited"
21
+ WHOIS_REDACTED = "whois_redacted"
22
+ BLOCKED = "blocked"
23
+ UNKNOWN = "unknown"
24
+
25
+
26
+ # Reasons that indicate the target is actively blocking us — these are
27
+ # intelligence signals, not mere failures.
28
+ _BLOCK_REASONS = {
29
+ FailureReason.CLOUDFLARE_PROTECTED,
30
+ FailureReason.RATE_LIMITED,
31
+ FailureReason.WHOIS_REDACTED,
32
+ FailureReason.BLOCKED,
33
+ }
34
+
35
+
36
+ class ToolResult:
37
+ """Uniform result wrapper for tool invocations."""
38
+
39
+ __slots__ = (
40
+ "tool",
41
+ "success",
42
+ "data",
43
+ "failure_reason",
44
+ "latency_ms",
45
+ "retry_count",
46
+ "context",
47
+ )
48
+
49
+ def __init__(
50
+ self,
51
+ success: bool,
52
+ data: Any = None,
53
+ failure_reason: Optional[FailureReason] = None,
54
+ latency_ms: float = 0.0,
55
+ retry_count: int = 0,
56
+ tool: str = "",
57
+ context: Any = None,
58
+ ):
59
+ self.tool = tool
60
+ self.success = success
61
+ self.data = data
62
+ self.failure_reason = failure_reason
63
+ self.latency_ms = latency_ms
64
+ self.retry_count = retry_count
65
+ self.context = context
66
+
67
+ # -- derived properties ---------------------------------------
68
+ @property
69
+ def blocked(self) -> bool:
70
+ """True when the failure reason indicates active blocking."""
71
+ return self.failure_reason in _BLOCK_REASONS
72
+
73
+ @property
74
+ def is_intelligence(self) -> bool:
75
+ """True when the outcome itself is an intelligence signal."""
76
+ return self.failure_reason in _BLOCK_REASONS
77
+
78
+ # -- serialization --------------------------------------------
79
+ def to_dict(self) -> dict:
80
+ return {
81
+ "tool": self.tool,
82
+ "success": self.success,
83
+ "data": self.data,
84
+ "failure_reason": self.failure_reason.value if self.failure_reason else None,
85
+ "latency_ms": self.latency_ms,
86
+ "retry_count": self.retry_count,
87
+ "context": self.context,
88
+ }
89
+
90
+ def __repr__(self) -> str: # pragma: no cover
91
+ return (
92
+ f"ToolResult(success={self.success}, tool={self.tool!r}, "
93
+ f"failure_reason={self.failure_reason}, latency_ms={self.latency_ms})"
94
+ )
95
+
96
+
97
+ # ─────────────────────────────────────────────────────────────────
98
+ # Failure classification
99
+ # ─────────────────────────────────────────────────────────────────
100
+
101
+
102
+ def classify_failure(exc: BaseException) -> FailureReason:
103
+ """Classify an exception into a :class:`FailureReason`."""
104
+ # 1. Timeout
105
+ if isinstance(exc, TimeoutError):
106
+ return FailureReason.TIMEOUT
107
+
108
+ # 2. HTTP-style rate limiting (objects exposing a status code)
109
+ code = getattr(exc, "code", None)
110
+ if code is None:
111
+ code = getattr(exc, "status_code", None)
112
+ if code == 429:
113
+ return FailureReason.RATE_LIMITED
114
+
115
+ msg = str(exc).lower()
116
+
117
+ # 3. Cloudflare / WAF protection
118
+ if "cloudflare" in msg or "cf-ray" in msg or "attention required" in msg:
119
+ return FailureReason.CLOUDFLARE_PROTECTED
120
+
121
+ # 4. WHOIS privacy / GDPR redaction
122
+ if "redacted" in msg or "gdpr" in msg or "private registration" in msg:
123
+ return FailureReason.WHOIS_REDACTED
124
+
125
+ # 5. Generic network errors → could be transient; classify as unknown
126
+ return FailureReason.UNKNOWN
127
+
128
+
129
+ # ─────────────────────────────────────────────────────────────────
130
+ # Safe execution
131
+ # ─────────────────────────────────────────────────────────────────
132
+
133
+
134
+ def safe_execute(
135
+ tool: str,
136
+ func: Callable[[], Any],
137
+ context: Any = None,
138
+ retry_count: int = 0,
139
+ ) -> ToolResult:
140
+ """Run *func* and return a :class:`ToolResult` capturing the outcome.
141
+
142
+ A return value of ``None`` or an empty collection is treated as
143
+ ``NO_RESULTS`` rather than success.
144
+ """
145
+ start = time.monotonic()
146
+ try:
147
+ data = func()
148
+ except BaseException as exc: # noqa: BLE001
149
+ latency_ms = (time.monotonic() - start) * 1000.0
150
+ reason = classify_failure(exc)
151
+ return ToolResult(
152
+ success=False,
153
+ failure_reason=reason,
154
+ latency_ms=latency_ms,
155
+ retry_count=retry_count,
156
+ tool=tool,
157
+ context=context,
158
+ )
159
+
160
+ latency_ms = (time.monotonic() - start) * 1000.0
161
+
162
+ # Treat None / empty containers as "no results"
163
+ if data is None:
164
+ return ToolResult(
165
+ success=False,
166
+ failure_reason=FailureReason.NO_RESULTS,
167
+ latency_ms=latency_ms,
168
+ retry_count=retry_count,
169
+ tool=tool,
170
+ context=context,
171
+ )
172
+ if isinstance(data, (list, tuple, set, dict, str)) and len(data) == 0:
173
+ return ToolResult(
174
+ success=False,
175
+ failure_reason=FailureReason.NO_RESULTS,
176
+ latency_ms=latency_ms,
177
+ retry_count=retry_count,
178
+ tool=tool,
179
+ context=context,
180
+ )
181
+
182
+ return ToolResult(
183
+ success=True,
184
+ data=data,
185
+ latency_ms=latency_ms,
186
+ retry_count=retry_count,
187
+ tool=tool,
188
+ context=context,
189
+ )
190
+
191
+
192
+ # ─────────────────────────────────────────────────────────────────
193
+ # Alternative-source suggestions
194
+ # ─────────────────────────────────────────────────────────────────
195
+
196
+ _ALTERNATIVES: dict = {
197
+ FailureReason.WHOIS_REDACTED: [
198
+ "crt.sh certificate transparency search",
199
+ "DNS historical records (SecurityTrails)",
200
+ "Search engine cached WHOIS",
201
+ "Passive DNS replication",
202
+ ],
203
+ FailureReason.CLOUDFLARE_PROTECTED: [
204
+ "Historical DNS records (SecurityTrails)",
205
+ "crt.sh certificate transparency search",
206
+ "Search engine cache / archive.org",
207
+ "Shodan / Censys internet scan",
208
+ ],
209
+ FailureReason.RATE_LIMITED: [
210
+ "Wait and retry with backoff",
211
+ "Use an alternate data source",
212
+ "Reduce request frequency",
213
+ ],
214
+ FailureReason.TIMEOUT: [
215
+ "Retry with longer timeout",
216
+ "Use an alternate data source",
217
+ "crt.sh certificate transparency search",
218
+ ],
219
+ FailureReason.NO_RESULTS: [
220
+ "Try alternate query / domain variant",
221
+ "Use an alternate data source",
222
+ "crt.sh certificate transparency search",
223
+ ],
224
+ FailureReason.BLOCKED: [
225
+ "Historical DNS records",
226
+ "crt.sh certificate transparency search",
227
+ "archive.org",
228
+ ],
229
+ FailureReason.UNKNOWN: [
230
+ "Retry the operation",
231
+ "Use an alternate data source",
232
+ "crt.sh certificate transparency search",
233
+ ],
234
+ }
235
+
236
+
237
+ def suggest_alternatives(reason: FailureReason) -> List[str]:
238
+ """Return a list of suggested alternative approaches for *reason*."""
239
+ return list(_ALTERNATIVES.get(reason, _ALTERNATIVES[FailureReason.UNKNOWN]))