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.
- osintengine-1.0.2.dist-info/METADATA +311 -0
- osintengine-1.0.2.dist-info/RECORD +103 -0
- osintengine-1.0.2.dist-info/WHEEL +5 -0
- osintengine-1.0.2.dist-info/entry_points.txt +2 -0
- osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
- osintengine-1.0.2.dist-info/top_level.txt +2 -0
- src/watson/__init__.py +10 -0
- src/watson/agent/__init__.py +96 -0
- src/watson/agents/__init__.py +101 -0
- src/watson/agents/protocol.py +196 -0
- src/watson/auth/__init__.py +68 -0
- src/watson/auth/store.py +145 -0
- src/watson/conversation.py +68 -0
- src/watson/core/__init__.py +0 -0
- src/watson/core/models.py +96 -0
- src/watson/ethics.py +205 -0
- src/watson/exports.py +182 -0
- src/watson/graph/__init__.py +69 -0
- src/watson/graph/entities.py +207 -0
- src/watson/graph/graph.py +489 -0
- src/watson/graph/osint_framework.py +266 -0
- src/watson/graph/relationships.py +116 -0
- src/watson/graph/transforms.py +787 -0
- src/watson/infra/__init__.py +43 -0
- src/watson/infra/cache.py +171 -0
- src/watson/infra/ratelimit.py +125 -0
- src/watson/infra/resilience.py +239 -0
- src/watson/infra/retry.py +186 -0
- src/watson/metrics.py +128 -0
- src/watson/opsec/__init__.py +290 -0
- src/watson/orchestration/__init__.py +23 -0
- src/watson/orchestration/engine.py +5587 -0
- src/watson/orchestration/executor.py +96 -0
- src/watson/orchestration/intent.py +139 -0
- src/watson/orchestration/intent_classifier.py +45 -0
- src/watson/orchestration/llm_config.py +160 -0
- src/watson/orchestration/resolution.py +555 -0
- src/watson/orchestration/scraper.py +94 -0
- src/watson/orchestration/synthesis.py +726 -0
- src/watson/orchestration/target_profile.py +748 -0
- src/watson/persistence/__init__.py +18 -0
- src/watson/persistence/models.py +161 -0
- src/watson/persistence/store.py +230 -0
- src/watson/pipeline/__init__.py +16 -0
- src/watson/pipeline/pre_synthesis.py +621 -0
- src/watson/search.py +103 -0
- src/watson/serializers/stix.py +451 -0
- src/watson/tools/__init__.py +31 -0
- src/watson/tools/base.py +97 -0
- src/watson/tools/blockchain.py +359 -0
- src/watson/tools/captcha.py +276 -0
- src/watson/tools/conflict.py +107 -0
- src/watson/tools/corporate.py +287 -0
- src/watson/tools/darkweb.py +144 -0
- src/watson/tools/geolocation.py +347 -0
- src/watson/tools/image_video.py +108 -0
- src/watson/tools/marinetraffic.py +142 -0
- src/watson/tools/people.py +476 -0
- src/watson/tools/registry.py +56 -0
- src/watson/tools/satellite.py +118 -0
- src/watson/tools/scraper.py +745 -0
- src/watson/tools/shodan.py +129 -0
- src/watson/tools/social_media.py +160 -0
- src/watson/tools/websites.py +291 -0
- src/watson/tools/wikidata.py +398 -0
- src/watson/utils/__init__.py +1 -0
- src/watson/utils/helpers.py +49 -0
- src/watson/utils/http.py +119 -0
- src/watson/verification/__init__.py +245 -0
- watson/__init__.py +6 -0
- watson/agents/__init__.py +20 -0
- watson/agents/base.py +103 -0
- watson/agents/direct.py +225 -0
- watson/agents/hermes.py +275 -0
- watson/agents/hermes_mcp.py +292 -0
- watson/api_keys.py +176 -0
- watson/browser_scraper.py +481 -0
- watson/cli.py +836 -0
- watson/crossref.py +175 -0
- watson/ethics.py +20 -0
- watson/graph.py +406 -0
- watson/mcp_server.py +601 -0
- watson/memory.py +552 -0
- watson/neo4j_graph.py +124 -0
- watson/opsec/__init__.py +307 -0
- watson/reporter.py +537 -0
- watson/scheduler.py +486 -0
- watson/serializers/stix.py +451 -0
- watson/terminal.py +410 -0
- watson/toolkit.py +252 -0
- watson/toolkit_api.py +347 -0
- watson/toolkit_automation.py +95 -0
- watson/toolkit_registry.py +345 -0
- watson/verification/__init__.py +251 -0
- watson/web/__init__.py +1 -0
- watson/web/app.py +1650 -0
- watson/web/middleware.py +301 -0
- watson/web/static/assets/index-B7hPOc0z.js +278 -0
- watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
- watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
- watson/web/static/index.html +14 -0
- watson/web/templates/chat.html +2 -0
- 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
|