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
watson/api_keys.py ADDED
@@ -0,0 +1,176 @@
1
+ """
2
+ Unified API key store for Watson tools.
3
+
4
+ Loads keys from ~/.watson/api_keys.json, falling back to environment variables.
5
+ Provides save/load/list for the dashboard settings UI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ STORE_PATH = Path.home() / ".watson" / "api_keys.json"
15
+
16
+ # ── Tool registry — all tools that accept API keys ─────────────
17
+
18
+ TOOLS_NEEDING_KEYS = {
19
+ # ── LLM Providers (required for intelligence generation) ──
20
+ "deepseek": {
21
+ "label": "DeepSeek",
22
+ "env_var": "DEEPSEEK_API_KEY",
23
+ "get_key_url": "https://platform.deepseek.com/api_keys",
24
+ "description": "LLM provider — powers intelligence synthesis. Free tier available.",
25
+ "tier": "free",
26
+ "category": "llm",
27
+ },
28
+ "openai": {
29
+ "label": "OpenAI",
30
+ "env_var": "OPENAI_API_KEY",
31
+ "get_key_url": "https://platform.openai.com/api-keys",
32
+ "description": "LLM provider — GPT-4o for intelligence synthesis. Pay-as-you-go.",
33
+ "tier": "paid",
34
+ "category": "llm",
35
+ },
36
+ "anthropic": {
37
+ "label": "Anthropic",
38
+ "env_var": "ANTHROPIC_API_KEY",
39
+ "get_key_url": "https://console.anthropic.com/keys",
40
+ "description": "LLM provider — Claude for intelligence synthesis.",
41
+ "tier": "paid",
42
+ "category": "llm",
43
+ },
44
+ "openrouter": {
45
+ "label": "OpenRouter",
46
+ "env_var": "OPENROUTER_API_KEY",
47
+ "get_key_url": "https://openrouter.ai/keys",
48
+ "description": "LLM provider — multi-model gateway. Pay-as-you-go.",
49
+ "tier": "paid",
50
+ "category": "llm",
51
+ },
52
+ # ── OSINT Tool Providers ──
53
+ "opencorporates": {
54
+ "label": "OpenCorporates",
55
+ "env_var": "OPENCORPORATES_API_KEY",
56
+ "get_key_url": "https://opencorporates.com/api_accounts/new",
57
+ "description": "Company registry — verifies corporate structures, directors, filings.",
58
+ "tier": "free", # free tier available, key unlocks higher rate limits
59
+ },
60
+ "opensanctions": {
61
+ "label": "OpenSanctions",
62
+ "env_var": "OPENSANCTIONS_API_KEY",
63
+ "get_key_url": "https://www.opensanctions.org/api/",
64
+ "description": "Sanctions & PEP database — checks individuals and entities against global sanctions lists.",
65
+ "tier": "free",
66
+ },
67
+ "hibp": {
68
+ "label": "Have I Been Pwned",
69
+ "env_var": "HIBP_API_KEY",
70
+ "get_key_url": "https://haveibeenpwned.com/API/Key",
71
+ "description": "Breach database — checks emails against known data breaches.",
72
+ "tier": "paid",
73
+ },
74
+ "shodan": {
75
+ "label": "Shodan",
76
+ "env_var": "SHODAN_API_KEY",
77
+ "get_key_url": "https://account.shodan.io/",
78
+ "description": "Internet scanner — discovers exposed services, industrial control systems, vulnerable infrastructure.",
79
+ "tier": "paid",
80
+ },
81
+ "marinetraffic": {
82
+ "label": "MarineTraffic AIS",
83
+ "env_var": "MARINETRAFFIC_API_KEY",
84
+ "get_key_url": "https://www.marinetraffic.com/en/ais-api-services",
85
+ "description": "Ship tracking — monitors vessel movements for sanctions evasion, smuggling, illegal fishing.",
86
+ "tier": "paid",
87
+ },
88
+ "chainalysis": {
89
+ "label": "Chainalysis / Crypto",
90
+ "env_var": "CHAINALYSIS_API_KEY",
91
+ "get_key_url": "https://www.chainalysis.com/",
92
+ "description": "Blockchain intelligence — traces crypto transactions, identifies wallets, sanctions screening.",
93
+ "tier": "paid",
94
+ },
95
+ "pacer": {
96
+ "label": "PACER (US Courts)",
97
+ "env_var": "PACER_API_KEY",
98
+ "get_key_url": "https://pacer.uscourts.gov/",
99
+ "description": "US federal court records — dockets, filings, judgments, bankruptcy records.",
100
+ "tier": "paid",
101
+ },
102
+ }
103
+
104
+
105
+ # ── Load / Save ───────────────────────────────────────────────
106
+
107
+ def _load_store() -> dict:
108
+ """Read the JSON key store. Returns empty dict if missing or corrupt."""
109
+ if not STORE_PATH.exists():
110
+ return {}
111
+ try:
112
+ return json.loads(STORE_PATH.read_text())
113
+ except (json.JSONDecodeError, OSError):
114
+ return {}
115
+
116
+
117
+ def _save_store(data: dict) -> None:
118
+ """Write the JSON key store atomically."""
119
+ STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
120
+ tmp = STORE_PATH.with_suffix(".tmp")
121
+ tmp.write_text(json.dumps(data, indent=2))
122
+ tmp.replace(STORE_PATH)
123
+
124
+
125
+ def get_key(slug: str) -> str:
126
+ """Get an API key by tool slug.
127
+
128
+ Priority: JSON store > environment variable.
129
+ """
130
+ store = _load_store()
131
+ if slug in store and store[slug]:
132
+ return store[slug]
133
+ tool = TOOLS_NEEDING_KEYS.get(slug, {})
134
+ env_var = tool.get("env_var", "")
135
+ return os.environ.get(env_var, "")
136
+
137
+
138
+ def set_key(slug: str, value: str) -> None:
139
+ """Save an API key to the JSON store."""
140
+ store = _load_store()
141
+ store[slug] = value.strip()
142
+ _save_store(store)
143
+
144
+
145
+ def delete_key(slug: str) -> None:
146
+ """Remove an API key from the JSON store."""
147
+ store = _load_store()
148
+ store.pop(slug, None)
149
+ _save_store(store)
150
+
151
+
152
+ def list_keys() -> list[dict]:
153
+ """Return all configured tools with their key status (masked)."""
154
+ store = _load_store()
155
+ result = []
156
+ for slug, tool in TOOLS_NEEDING_KEYS.items():
157
+ key = store.get(slug, "") or os.environ.get(tool["env_var"], "")
158
+ result.append({
159
+ "slug": slug,
160
+ "label": tool["label"],
161
+ "category": tool.get("category", "tool"),
162
+ "description": tool["description"],
163
+ "get_key_url": tool["get_key_url"],
164
+ "env_var": tool["env_var"],
165
+ "tier": tool.get("tier", "free"),
166
+ "configured": bool(key),
167
+ "preview": _mask(key) if key else "",
168
+ })
169
+ return result
170
+
171
+
172
+ def _mask(key: str) -> str:
173
+ """Show first 4 + last 4 chars, mask the rest."""
174
+ if len(key) <= 8:
175
+ return "*" * len(key)
176
+ return key[:4] + "*" * (len(key) - 8) + key[-4:]
@@ -0,0 +1,481 @@
1
+ """
2
+ Browser Scraper — headless Playwright automation for Bellingcat OSINT tools.
3
+
4
+ Visits tool search URLs, waits for results to render, extracts structured data.
5
+ Provides per-tool CSS selectors for known tools, plus generic extraction for any URL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+ from urllib.parse import quote
17
+
18
+ # ── Per-tool extraction patterns ────────────────────────────────────
19
+ # Each entry: CSS selectors + extraction logic for a specific tool
20
+
21
+ @dataclass
22
+ class ToolExtractor:
23
+ """Defines how to extract results from a tool's search page."""
24
+ name: str
25
+ # URL template with {query} placeholder
26
+ url_template: str | None = None
27
+ # Wait for this selector to appear before extracting
28
+ wait_for: str = "body"
29
+ # Time to wait for dynamic content (seconds)
30
+ wait_ms: int = 3000
31
+ # CSS selectors
32
+ result_items: str = "" # container for each result
33
+ title_selector: str = "" # title/name within each result
34
+ link_selector: str = "" # link within each result
35
+ desc_selector: str = "" # description within each result
36
+ # Whether to use generic text extraction fallback
37
+ generic_fallback: bool = True
38
+ # Max results to extract
39
+ max_results: int = 10
40
+
41
+
42
+ # ── Extraction patterns for the most valuable Bellingcat tools ──────
43
+
44
+ EXTRACTORS: dict[str, ToolExtractor] = {
45
+ "Shodan": ToolExtractor(
46
+ name="Shodan",
47
+ url_template="https://www.shodan.io/search?query={query}",
48
+ wait_for=".search-results, .no-results, .heading",
49
+ wait_ms=5000,
50
+ result_items=".result",
51
+ title_selector=".result h2, .result .title, .result a.title",
52
+ desc_selector=".result p, .result .description",
53
+ ),
54
+ "BuiltWith": ToolExtractor(
55
+ name="BuiltWith",
56
+ url_template="https://builtwith.com/{query}",
57
+ wait_for=".tech-item, .card-body, h1",
58
+ wait_ms=5000,
59
+ result_items=".tech-item, .card",
60
+ title_selector="h2, .card-title, .tech-name",
61
+ desc_selector="p, .card-text",
62
+ ),
63
+ "OpenCorporates": ToolExtractor(
64
+ name="OpenCorporates",
65
+ url_template="https://opencorporates.com/companies?q={query}",
66
+ wait_for=".companies, .search-results, #results",
67
+ wait_ms=5000,
68
+ result_items=".company, .result, .search-result",
69
+ title_selector=".company-name, .name a, h3 a",
70
+ desc_selector=".company-details, .jurisdiction, .status",
71
+ ),
72
+ "Namechk": ToolExtractor(
73
+ name="Namechk",
74
+ url_template="https://namechk.com/",
75
+ wait_for=".service-card, input[type=text]",
76
+ wait_ms=4000,
77
+ result_items=".service-card",
78
+ title_selector=".service-name",
79
+ desc_selector=".service-status",
80
+ ),
81
+ "Instant Username Search": ToolExtractor(
82
+ name="Instant Username Search",
83
+ url_template="https://instantusername.com/?q={query}",
84
+ wait_for=".result, .results, #results",
85
+ wait_ms=4000,
86
+ result_items=".result, .results li, .site-result",
87
+ title_selector=".site-name, .name, a",
88
+ desc_selector=".status, .available",
89
+ ),
90
+ "SEC EDGAR": ToolExtractor(
91
+ name="SEC EDGAR",
92
+ url_template="https://www.sec.gov/cgi-bin/browse-edgar?company={query}&action=getcompany",
93
+ wait_for=".tableFile, .companySearch, #seriesDiv",
94
+ wait_ms=5000,
95
+ result_items=".tableFile tr, table tr",
96
+ title_selector="td:first-child a, td a",
97
+ desc_selector="td:nth-child(2), td:nth-child(3)",
98
+ ),
99
+ "Google Maps": ToolExtractor(
100
+ name="Google Maps",
101
+ url_template="https://www.google.com/maps/search/{query}",
102
+ wait_for="h1, .section-hero-header-title, [role=main]",
103
+ wait_ms=6000,
104
+ result_items="[role=article], .section-result",
105
+ title_selector="h1, .section-result-title, [aria-label]",
106
+ desc_selector=".section-result-details, .section-result-location",
107
+ ),
108
+ "OpenStreetMap": ToolExtractor(
109
+ name="OpenStreetMap",
110
+ url_template="https://www.openstreetmap.org/search?query={query}",
111
+ wait_for=".search-results, #content",
112
+ wait_ms=3000,
113
+ result_items=".search-result, .search_results_entry",
114
+ title_selector="a, .name",
115
+ desc_selector=".type, .description",
116
+ ),
117
+ "FlightRadar24": ToolExtractor(
118
+ name="FlightRadar24",
119
+ url_template="https://www.flightradar24.com/data/search?q={query}",
120
+ wait_for="#search-results, .search-results, table",
121
+ wait_ms=5000,
122
+ result_items=".search-result, table tbody tr",
123
+ title_selector="a, .flight, td:first-child",
124
+ desc_selector=".details, td:nth-child(2)",
125
+ ),
126
+ "Wayback Machine": ToolExtractor(
127
+ name="Wayback Machine",
128
+ url_template="https://web.archive.org/web/*/https://{query}",
129
+ wait_for="#resultsUrl, .calendar-grid, .captures",
130
+ wait_ms=5000,
131
+ generic_fallback=True, # Wayback uses calendar — use text extraction
132
+ ),
133
+ }
134
+
135
+ # Tools where we can extract data by visiting the search page
136
+ BROWSER_TOOLS: set[str] = set(EXTRACTORS.keys())
137
+
138
+
139
+ # ── Browser Scraper Engine ──────────────────────────────────────────
140
+
141
+ class BrowserScraper:
142
+ """Headless browser automation for Bellingcat tool data extraction."""
143
+
144
+ def __init__(self):
145
+ self._browser = None
146
+ self._context = None
147
+ self._page = None
148
+ self._lock = asyncio.Lock()
149
+
150
+ async def start(self):
151
+ """Launch headless browser."""
152
+ from playwright.async_api import async_playwright
153
+
154
+ self._playwright = await async_playwright().start()
155
+ self._browser = await self._playwright.chromium.launch(
156
+ headless=True,
157
+ args=[
158
+ "--no-sandbox",
159
+ "--disable-dev-shm-usage",
160
+ "--disable-gpu",
161
+ "--disable-web-security",
162
+ "--disable-features=VizDisplayCompositor",
163
+ ],
164
+ )
165
+ self._context = await self._browser.new_context(
166
+ user_agent=(
167
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
168
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
169
+ ),
170
+ viewport={"width": 1280, "height": 800},
171
+ locale="en-US",
172
+ )
173
+ self._page = await self._context.new_page()
174
+
175
+ async def stop(self):
176
+ """Close browser."""
177
+ if self._browser:
178
+ await self._browser.close()
179
+ if self._playwright:
180
+ await self._playwright.stop()
181
+
182
+ async def scrape_tool(self, tool_name: str, query: str) -> list[dict]:
183
+ """Visit a tool's search URL and extract results."""
184
+ extractor = EXTRACTORS.get(tool_name)
185
+ if not extractor or not extractor.url_template:
186
+ return []
187
+
188
+ url = extractor.url_template.format(query=quote(query, safe=""))
189
+ async with self._lock:
190
+ return await self._scrape_url(url, extractor)
191
+
192
+ async def scrape_url(self, url: str, wait_ms: int = 3000) -> list[dict]:
193
+ """Scrape any URL with generic extraction."""
194
+ fallback = ToolExtractor(name="generic", wait_ms=wait_ms)
195
+ return await self._scrape_url(url, fallback)
196
+
197
+ async def _scrape_url(self, url: str, ex: ToolExtractor) -> list[dict]:
198
+ """Core scraping: navigate, wait, extract."""
199
+ try:
200
+ await self._page.goto(url, timeout=15000, wait_until="domcontentloaded")
201
+ except Exception as e:
202
+ return [{"title": f"{ex.name}: Page load failed", "url": url, "error": True,
203
+ "description": str(e)[:200]}]
204
+
205
+ # Wait for results to render
206
+ try:
207
+ await self._page.wait_for_selector(ex.wait_for, timeout=ex.wait_ms)
208
+ except Exception:
209
+ pass # Page loaded but selector not found — try generic
210
+
211
+ # Additional wait for JS rendering
212
+ await asyncio.sleep(min(ex.wait_ms / 1000 * 0.5, 2))
213
+
214
+ # Try structured extraction first
215
+ results = []
216
+ if ex.result_items:
217
+ results = await self._extract_structured(ex)
218
+
219
+ # Generic fallback
220
+ if (not results and ex.generic_fallback) or (not ex.result_items):
221
+ results = await self._extract_generic(ex.name, url)
222
+
223
+ return results[:ex.max_results]
224
+
225
+ async def _extract_structured(self, ex: ToolExtractor) -> list[dict]:
226
+ """Extract results using CSS selectors."""
227
+ try:
228
+ items = await self._page.query_selector_all(ex.result_items)
229
+ if not items:
230
+ return []
231
+
232
+ results = []
233
+ for item in items[:ex.max_results]:
234
+ result = {"source_tool": ex.name}
235
+
236
+ if ex.title_selector:
237
+ el = await item.query_selector(ex.title_selector)
238
+ if el:
239
+ result["title"] = (await el.inner_text()).strip()[:200]
240
+
241
+ if ex.link_selector:
242
+ el = await item.query_selector(ex.link_selector)
243
+ if el:
244
+ href = await el.get_attribute("href")
245
+ if href:
246
+ result["url"] = href
247
+
248
+ if ex.desc_selector:
249
+ el = await item.query_selector(ex.desc_selector)
250
+ if el:
251
+ result["description"] = (await el.inner_text()).strip()[:300]
252
+
253
+ if result.get("title") or result.get("description"):
254
+ results.append(result)
255
+
256
+ return results
257
+ except Exception:
258
+ return []
259
+
260
+ async def _extract_generic(self, tool_name: str, url: str) -> list[dict]:
261
+ """Generic extraction: page title + visible text + all links."""
262
+ try:
263
+ title = await self._page.title()
264
+ text = await self._page.inner_text("body")
265
+
266
+ # Clean and truncate visible text
267
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
268
+ clean_text = " | ".join(lines[:30])[:800]
269
+
270
+ # Extract all links
271
+ links = await self._page.evaluate("""() => {
272
+ return Array.from(document.querySelectorAll('a[href]'))
273
+ .slice(0, 20)
274
+ .map(a => ({
275
+ text: a.innerText.trim().substring(0, 100),
276
+ href: a.href
277
+ }))
278
+ .filter(l => l.href.startsWith('http'));
279
+ }""")
280
+
281
+ result = {
282
+ "source_tool": tool_name,
283
+ "title": title[:200] if title else url[:100],
284
+ "url": url,
285
+ "description": clean_text,
286
+ }
287
+
288
+ if links:
289
+ result["links"] = json.dumps(links[:10])
290
+
291
+ return [result]
292
+ except Exception:
293
+ return [{"source_tool": tool_name, "title": f"{tool_name}: Extraction failed", "url": url, "error": True}]
294
+
295
+ async def scrape_all(self, queries: list[tuple[str, str]]) -> dict:
296
+ """Scrape multiple tools in sequence. Each query is (tool_name, search_query)."""
297
+ results = {}
298
+ for tool_name, query in queries:
299
+ try:
300
+ data = await self.scrape_tool(tool_name, query)
301
+ results[tool_name] = data
302
+ except Exception as e:
303
+ results[tool_name] = [{"error": str(e), "source_tool": tool_name}]
304
+ return results
305
+
306
+ async def extract_article_text(self, url: str, timeout_ms: int = 15000) -> str:
307
+ """Navigate to a URL and extract the main article text content.
308
+
309
+ Uses generic extraction: finds the largest text block on the page,
310
+ removes navigation/ads/sidebars/scripts.
311
+
312
+ Returns up to 4000 chars of extracted article text, or empty string on failure.
313
+ """
314
+ if not self._browser:
315
+ await self.start()
316
+
317
+ try:
318
+ page = await self._context.new_page()
319
+ await page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
320
+
321
+ # Wait for content to settle
322
+ await asyncio.sleep(1.5)
323
+
324
+ # Extract text content: try article area, then <p> aggregation, fall back to body
325
+ text = await page.evaluate("""() => {
326
+ // ── Strategy 1: Find main content area via CSS selectors ──
327
+ const selectors = [
328
+ 'article', '[role="main"]', 'main',
329
+ '.article-body', '.article-content', '.post-content',
330
+ '.story-body', '.content-body', '#article-body',
331
+ '.article__body', '.article__content',
332
+ // Government/CMS templates
333
+ '.govuk-main-wrapper', '#main-content', '#content',
334
+ '.entry-content', '.post-body', '.single-post',
335
+ '.blog-post', '.news-article', '.press-release',
336
+ '[itemprop="articleBody"]', '.c-article-body',
337
+ ];
338
+ for (const sel of selectors) {
339
+ const el = document.querySelector(sel);
340
+ if (el && el.innerText && el.innerText.length > 200) {
341
+ return el.innerText;
342
+ }
343
+ }
344
+
345
+ // ── Strategy 2: Collect substantive <p> tags ──
346
+ // This naturally skips nav menus, cookie banners, and sidebars
347
+ // since those use <ul>/<div>/<button>, not <p> with real content
348
+ const paragraphs = document.querySelectorAll('p');
349
+ const substantial = [];
350
+ for (const p of paragraphs) {
351
+ const txt = p.innerText.trim();
352
+ // Skip short fragments, cookie boilerplate, nav-like text
353
+ if (txt.length < 40) continue;
354
+ if (/^(Accept|Reject|Decline|Allow|Deny|Manage|Customise|Customize|Settings|Save|Close)\\s/i.test(txt)) continue;
355
+ if (/cookies? (policy|settings|preferences)/i.test(txt)) continue;
356
+ substantial.push(txt);
357
+ }
358
+ if (substantial.length >= 3) {
359
+ return substantial.join('\\n\\n');
360
+ }
361
+
362
+ // ── Strategy 3: Body fallback with heavy noise stripping ──
363
+ if (!document.body) return '';
364
+
365
+ const clone = document.body.cloneNode(true);
366
+
367
+ const noiseSelectors = [
368
+ 'nav', 'header', 'footer', 'aside',
369
+ '.nav', '.navbar', '.navigation', '.menu',
370
+ '.sidebar', '.footer', '.header',
371
+ '.advertisement', '.ad', '.ads',
372
+ '.cookie-banner', '.cookie-consent', '.cookie-notice',
373
+ '.cookie-bar', '.cc-banner', '#cookie-banner',
374
+ '[aria-label="cookieconsent"]', '.consent-banner',
375
+ '.related-articles', '.related-posts',
376
+ '.recommended', '.trending', '.popular',
377
+ '.comments', '.comment-section',
378
+ '.social-share', '.share-buttons',
379
+ '.newsletter', '.subscribe', '.email-signup',
380
+ 'script', 'style', 'noscript', 'iframe',
381
+ // Common mobile nav
382
+ '.mobile-nav', '.mobile-menu', '.hamburger-menu',
383
+ // Breadcrumbs
384
+ '.breadcrumb', '.breadcrumbs',
385
+ // Author/social/meta bars
386
+ '.author-bio', '.byline', '.meta-info',
387
+ ];
388
+ noiseSelectors.forEach(sel => {
389
+ try {
390
+ clone.querySelectorAll(sel).forEach(el => el.remove());
391
+ } catch(e) {}
392
+ });
393
+
394
+ let text = clone.innerText || '';
395
+
396
+ // ── Aggressive boilerplate stripping ──
397
+ // Cookie consent line patterns (single-line removal)
398
+ const cookieLinePatterns = [
399
+ /^.*(?:uses? cookies?|cookie (?:policy|settings|preferences|notice)|This site (?:uses|employs|requires) cookies?).*$/gmi,
400
+ /^.*(?:Accept|Reject|Decline|Allow|Deny|Manage|Customi[sz]e|Save) (?:All )?Cookies?.*$/gmi,
401
+ /^.*(?:I (?:Accept|Do Not Accept|Reject|Decline) Cookies?).*$/gmi,
402
+ /^.*(?:Necessary|Essential|Analytical|Analytics|Marketing|Advertising|Functional|Performance|Targeting|Preference|Statistics) Cookies?.*$/gmi,
403
+ /^.*(?:On\\s*Off|On / Off).*$/gmi,
404
+ /^.*(?:About this tool).*$/gmi,
405
+ /^.*(?:Opens in a new window).*$/gmi,
406
+ /^.*(?:Cookie declaration|Cookie consent|Cookie notice|Cookie settings|Cookie preferences).*$/gmi,
407
+ /^.*(?:Powered by|Hosted by).*$/gmi,
408
+ ];
409
+ cookieLinePatterns.forEach(pattern => {
410
+ text = text.replace(pattern, '');
411
+ });
412
+
413
+ // Multi-line boilerplate block removal
414
+ const blockPatterns = [
415
+ /Today's Stocks[\\s\\S]*?(?=\\n\\n|$)/gi,
416
+ /Stock quotes[\\s\\S]{0,500}?(?=\\n\\n)/gi,
417
+ /Subscribe( to our newsletter)?[\\s\\S]{0,200}?(?=\\n\\n)/gi,
418
+ /Follow us on[\\s\\S]{0,200}?(?=\\n\\n)/gi,
419
+ /We use (essential )?cookies[\\s\\S]{0,300}?(?=\\n\\n)/gi,
420
+ /Accept (all )?cookies[\\s\\S]{0,200}?(?=\\n\\n)/gi,
421
+ /Sign (in|up)[\\s\\S]{0,200}?(?=\\n\\n)/gi,
422
+ /Log (in|out)[\\s\\S]{0,200}?(?=\\n\\n)/gi,
423
+ /Privacy Policy[\\s\\S]{0,200}?(?=\\n\\n)/gi,
424
+ /Terms of (Service|Use)[\\s\\S]{0,200}?(?=\\n\\n)/gi,
425
+ // Nav menu junk: repeated short capitalised tokens
426
+ /(?:PRODUCT TOURS?|Platform|Solutions?|Products?|Services?|Resources?|Company|About Us|Contact Us?|Blog|Support|Pricing|Integrations?|Documentation|Login|Register|Get Started|Request Demo|Free Trial)(?:\\n(?:PRODUCT TOURS?|Platform|Solutions?|Products?|Services?|Resources?|Company|About Us|Contact Us?|Blog|Support|Pricing|Integrations?|Documentation|Seleziona lingua|Login|Register|Get Started|Request Demo|Free Trial|Analysis & Investigations?|Security Orchestration|Threat Intelligence|Vulnerability Intelligence|National Security Intelligence|Managed Intelligence|Managed Attribution|Fraud Intelligence|Brand Intelligence|External Attack Surface|Threat Response|Threat Actor|Professional Services|Product Integrations?|Curated Alerting|Proactive Acquisitions|Tailored Reporting|Request for Information)){2,}/gi,
427
+ ];
428
+ blockPatterns.forEach(pattern => {
429
+ text = text.replace(pattern, '');
430
+ });
431
+
432
+ // Remove lines that are clearly UI boilerplate
433
+ const lines = text.split('\\n');
434
+ const cleaned = [];
435
+ for (const line of lines) {
436
+ const trimmed = line.trim();
437
+ if (!trimmed) { cleaned.push(''); continue; }
438
+ // Skip pure navigation/menu lines
439
+ if (/^(Facebook|Twitter|X|LinkedIn|Reddit|WhatsApp|Instagram|YouTube|Email|Share|Save|Print|Copy Link|Subscribe|Follow)$/i.test(trimmed)) continue;
440
+ // Skip isolated cookie controls
441
+ if (/^(On|Off)$/i.test(trimmed)) continue;
442
+ // Skip single-word lines that look like nav
443
+ if (/^(PLAY|GAMING|EUR|USD|CAD|GBP|RUB|CNY|INR|BRL|TRY|Platform)$/i.test(trimmed)) continue;
444
+ cleaned.push(trimmed);
445
+ }
446
+ text = cleaned.join('\\n');
447
+
448
+ // Remove repeated newlines and whitespace
449
+ text = text.replace(/\\n{3,}/g, '\\n\\n');
450
+ text = text.replace(/^\\s+|\\s+$/g, '');
451
+
452
+ return text;
453
+ }""")
454
+
455
+ await page.close()
456
+ return text[:4000] if text else ""
457
+
458
+ except Exception as e:
459
+ return ""
460
+
461
+
462
+ # ── Singleton ───────────────────────────────────────────────────────
463
+ _scraper: BrowserScraper | None = None
464
+ _NO_BROWSER = os.environ.get("WATSON_NO_BROWSER", "").lower() in ("1", "true", "yes")
465
+
466
+
467
+ async def get_scraper() -> BrowserScraper | None:
468
+ global _scraper
469
+ if _NO_BROWSER:
470
+ return None
471
+ if _scraper is None:
472
+ _scraper = BrowserScraper()
473
+ await _scraper.start()
474
+ return _scraper
475
+
476
+
477
+ async def close_scraper():
478
+ global _scraper
479
+ if _scraper:
480
+ await _scraper.stop()
481
+ _scraper = None