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
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"""Scraper engine — extracts real data from Wikipedia, OpenSanctions, and OSINT sources.
|
|
2
|
+
|
|
3
|
+
When APIs fail (rate-limited, blocked), this falls back to HTML scraping with
|
|
4
|
+
browser-grade headers. Uses plain http.client for maximum reliability.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import http.client
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import ssl
|
|
14
|
+
import urllib.parse
|
|
15
|
+
from html.parser import HTMLParser
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .base import OSINTTool
|
|
19
|
+
from .registry import registry
|
|
20
|
+
from ..core.models import Finding, FindingSource
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("watson.scraper")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TextExtractor(HTMLParser):
|
|
26
|
+
"""Extracts clean text from HTML, stripping tags and scripts."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.text_parts: list[str] = []
|
|
31
|
+
self.skip = False
|
|
32
|
+
self._skip_tags = {"script", "style", "noscript", "svg", "math"}
|
|
33
|
+
|
|
34
|
+
def handle_starttag(self, tag, attrs):
|
|
35
|
+
if tag in self._skip_tags:
|
|
36
|
+
self.skip = True
|
|
37
|
+
|
|
38
|
+
def handle_endtag(self, tag):
|
|
39
|
+
if tag in self._skip_tags:
|
|
40
|
+
self.skip = False
|
|
41
|
+
if tag in ("p", "br", "li", "tr", "h1", "h2", "h3", "h4", "td", "th", "div"):
|
|
42
|
+
self.text_parts.append("\n")
|
|
43
|
+
|
|
44
|
+
def handle_data(self, data):
|
|
45
|
+
if not self.skip:
|
|
46
|
+
text = data.strip()
|
|
47
|
+
if text:
|
|
48
|
+
self.text_parts.append(text + " ")
|
|
49
|
+
|
|
50
|
+
def get_text(self) -> str:
|
|
51
|
+
return "".join(self.text_parts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _http_get(url: str, timeout: int = 8) -> Optional[str]:
|
|
55
|
+
"""Fetch a URL with browser-grade headers. Returns HTML text or None."""
|
|
56
|
+
parsed = urllib.parse.urlparse(url)
|
|
57
|
+
host = parsed.netloc
|
|
58
|
+
path = parsed.path + ("?" + parsed.query if parsed.query else "")
|
|
59
|
+
|
|
60
|
+
headers = {
|
|
61
|
+
"User-Agent": (
|
|
62
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
63
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
|
64
|
+
),
|
|
65
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
66
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
67
|
+
"Accept-Encoding": "gzip, deflate",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
ctx = ssl.create_default_context()
|
|
72
|
+
conn = http.client.HTTPSConnection(host, timeout=timeout, context=ctx)
|
|
73
|
+
conn.request("GET", path, headers=headers)
|
|
74
|
+
resp = conn.getresponse()
|
|
75
|
+
|
|
76
|
+
if resp.status in (301, 302):
|
|
77
|
+
location = resp.getheader("Location", "")
|
|
78
|
+
conn.close()
|
|
79
|
+
if location:
|
|
80
|
+
return _http_get(location, timeout)
|
|
81
|
+
|
|
82
|
+
if resp.status != 200:
|
|
83
|
+
conn.close()
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Handle gzip
|
|
87
|
+
body = resp.read()
|
|
88
|
+
conn.close()
|
|
89
|
+
|
|
90
|
+
if resp.getheader("Content-Encoding") == "gzip":
|
|
91
|
+
import gzip
|
|
92
|
+
|
|
93
|
+
body = gzip.decompress(body)
|
|
94
|
+
|
|
95
|
+
return body.decode("utf-8", errors="replace")
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ScraperTool(OSINTTool):
|
|
101
|
+
"""Autonomous web scraper — extracts structured data from OSINT sources."""
|
|
102
|
+
|
|
103
|
+
category = FindingSource.PEOPLE
|
|
104
|
+
name = "scraper"
|
|
105
|
+
description = "Autonomous web scraper — extracts real data from Wikipedia, OpenSanctions, and OSINT sources"
|
|
106
|
+
free_tier_available = True
|
|
107
|
+
rate_limit_rps = 1.0
|
|
108
|
+
|
|
109
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
110
|
+
findings: list[Finding] = []
|
|
111
|
+
|
|
112
|
+
# Extract entity name
|
|
113
|
+
name = self._extract_entity_name(query)
|
|
114
|
+
if not name:
|
|
115
|
+
return findings
|
|
116
|
+
|
|
117
|
+
# 1. Wikipedia
|
|
118
|
+
wiki_findings = await self._scrape_wikipedia(name)
|
|
119
|
+
findings.extend(wiki_findings)
|
|
120
|
+
|
|
121
|
+
# 2. OpenSanctions
|
|
122
|
+
sanctions_findings = await self._scrape_opensanctions(name)
|
|
123
|
+
findings.extend(sanctions_findings)
|
|
124
|
+
|
|
125
|
+
return findings
|
|
126
|
+
|
|
127
|
+
async def _scrape_wikipedia(self, name: str) -> list[Finding]:
|
|
128
|
+
"""Scrape Wikipedia for person/entity data."""
|
|
129
|
+
findings: list[Finding] = []
|
|
130
|
+
|
|
131
|
+
# Try exact name first, then just first+last name
|
|
132
|
+
names_to_try = [name]
|
|
133
|
+
parts = name.split()
|
|
134
|
+
if len(parts) >= 2:
|
|
135
|
+
# Try first+last name
|
|
136
|
+
names_to_try.append(f"{parts[0]}_{parts[-1]}")
|
|
137
|
+
# Also try just the last name (common for criminals, celebrities)
|
|
138
|
+
names_to_try.append(parts[-1])
|
|
139
|
+
|
|
140
|
+
html = None
|
|
141
|
+
scraped_url = ""
|
|
142
|
+
for try_name in names_to_try:
|
|
143
|
+
encoded = urllib.parse.quote(try_name.replace(" ", "_"))
|
|
144
|
+
scraped_url = f"https://en.wikipedia.org/wiki/{encoded}"
|
|
145
|
+
html = _http_get(scraped_url)
|
|
146
|
+
|
|
147
|
+
if html and "Wikipedia does not have an article" not in html:
|
|
148
|
+
# ── Guard against Wikipedia "did you mean?" redirects ──
|
|
149
|
+
# Wikipedia redirects /wiki/Gačanin → /wiki/Edin (name etymology).
|
|
150
|
+
# Check the page title contains the LAST name part (most distinctive).
|
|
151
|
+
# "Edin Gačanin" → page title must contain "gačanin", not just "edin".
|
|
152
|
+
title_match = re.search(r'<title>([^<]+)</title>', html, re.IGNORECASE)
|
|
153
|
+
page_title = self._strip_html(title_match.group(1)) if title_match else ""
|
|
154
|
+
page_title = re.sub(r'\s*[-–—]\s*Wikipedia\s*$', '', page_title, flags=re.IGNORECASE).strip()
|
|
155
|
+
title_lower = page_title.lower()
|
|
156
|
+
try_parts = [p.lower() for p in try_name.split() if len(p) >= 3]
|
|
157
|
+
if try_parts:
|
|
158
|
+
# Must match the LAST name part (most distinctive).
|
|
159
|
+
# ASCII-fold both sides — "gačanin" ↔ "gacanin" diacritic mismatch
|
|
160
|
+
import unicodedata as _ucd
|
|
161
|
+
_fold = lambda s: _ucd.normalize("NFKD", s).encode("ascii", "ignore").decode()
|
|
162
|
+
last_folded = _fold(try_parts[-1])
|
|
163
|
+
title_folded = _fold(title_lower)
|
|
164
|
+
if last_folded not in title_folded:
|
|
165
|
+
html = None
|
|
166
|
+
continue
|
|
167
|
+
# Secondary: reject Wikipedia search result pages
|
|
168
|
+
is_search = (
|
|
169
|
+
"mw-search-results" in html or
|
|
170
|
+
"searchdidyoumean" in html.lower() or
|
|
171
|
+
'id="mw-search-top-table"' in html or
|
|
172
|
+
"may refer to:" in html[:2000].lower()
|
|
173
|
+
)
|
|
174
|
+
if is_search:
|
|
175
|
+
html = None
|
|
176
|
+
continue
|
|
177
|
+
break
|
|
178
|
+
html = None
|
|
179
|
+
|
|
180
|
+
if not html:
|
|
181
|
+
# Search fallback — use Wikipedia API for intelligent disambiguation.
|
|
182
|
+
# Avoids Wikipedia "did you mean?" redirects (e.g. /wiki/Gačanin → /wiki/Edin).
|
|
183
|
+
best_article = await self._wiki_api_search(name, parts)
|
|
184
|
+
if best_article:
|
|
185
|
+
scraped_url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(best_article.replace(' ', '_'))}"
|
|
186
|
+
html = _http_get(scraped_url)
|
|
187
|
+
|
|
188
|
+
if not html:
|
|
189
|
+
# Last resort: raw search page HTML (keeps existing fallback)
|
|
190
|
+
search_url = f"https://en.wikipedia.org/wiki/Special:Search?search={urllib.parse.quote(name)}"
|
|
191
|
+
html = _http_get(search_url)
|
|
192
|
+
|
|
193
|
+
if not html:
|
|
194
|
+
return findings
|
|
195
|
+
|
|
196
|
+
# ── Detect redirect: page title doesn't match search target ──
|
|
197
|
+
title_match = re.search(r'<title>([^<]+)</title>', html, re.IGNORECASE)
|
|
198
|
+
page_title = self._strip_html(title_match.group(1)) if title_match else ""
|
|
199
|
+
# Clean " - Wikipedia" suffix
|
|
200
|
+
page_title = re.sub(r'\s*[-–—]\s*Wikipedia\s*$', '', page_title, flags=re.IGNORECASE).strip()
|
|
201
|
+
# Check if we were redirected — page title doesn't contain our target name
|
|
202
|
+
name_parts = [p.lower() for p in name.split() if len(p) > 2]
|
|
203
|
+
title_lower = page_title.lower()
|
|
204
|
+
is_redirect = len(name_parts) >= 2 and not any(
|
|
205
|
+
part in title_lower for part in name_parts[-2:]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if is_redirect:
|
|
209
|
+
# ── Redirect case: page is about something else ──
|
|
210
|
+
# Skip infobox (belongs to redirect target, not our subject)
|
|
211
|
+
# Extract lead paragraph for context + paragraphs mentioning our target
|
|
212
|
+
lead = self._extract_lead_paragraph(html)
|
|
213
|
+
target_paras = self._extract_target_paragraphs(html, name)
|
|
214
|
+
|
|
215
|
+
if target_paras or lead:
|
|
216
|
+
desc_parts = []
|
|
217
|
+
if lead:
|
|
218
|
+
desc_parts.append(f"[Article: {page_title}] {lead[:300]}")
|
|
219
|
+
if target_paras:
|
|
220
|
+
desc_parts.append("")
|
|
221
|
+
desc_parts.append(f"--- Mentions of {name} ---")
|
|
222
|
+
for i, para in enumerate(target_paras[:5], 1):
|
|
223
|
+
desc_parts.append(f"{i}. {para[:600]}")
|
|
224
|
+
|
|
225
|
+
findings.append(
|
|
226
|
+
self._make_finding(
|
|
227
|
+
title=f"📖 Wikipedia: {name}",
|
|
228
|
+
description="\n".join(desc_parts),
|
|
229
|
+
evidence=[scraped_url],
|
|
230
|
+
confidence=0.75 if target_paras else 0.50,
|
|
231
|
+
source_url=scraped_url,
|
|
232
|
+
infobox={"page_title": page_title, "redirected": True},
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# ── Direct page: extract infobox normally ──
|
|
237
|
+
infobox = self._parse_infobox(html)
|
|
238
|
+
lead = self._extract_lead_paragraph(html)
|
|
239
|
+
|
|
240
|
+
if infobox or lead:
|
|
241
|
+
desc_parts = []
|
|
242
|
+
if lead:
|
|
243
|
+
desc_parts.append(lead[:300])
|
|
244
|
+
if infobox:
|
|
245
|
+
desc_parts.append("")
|
|
246
|
+
for key, value in list(infobox.items())[:10]:
|
|
247
|
+
desc_parts.append(f"**{key}:** {value[:120]}")
|
|
248
|
+
|
|
249
|
+
findings.append(
|
|
250
|
+
self._make_finding(
|
|
251
|
+
title=f"📖 Wikipedia: {name}",
|
|
252
|
+
description="\n".join(desc_parts),
|
|
253
|
+
evidence=[scraped_url],
|
|
254
|
+
confidence=0.9 if infobox else 0.6,
|
|
255
|
+
source_url=scraped_url,
|
|
256
|
+
infobox=infobox,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return findings
|
|
261
|
+
|
|
262
|
+
# ── Wikipedia API search with disambiguation ──────────────────
|
|
263
|
+
|
|
264
|
+
# Pages that are about words/names, not people — skip these.
|
|
265
|
+
_SKIP_TITLE_PATTERNS = [
|
|
266
|
+
r" \(name\)$", r" \(surname\)$", r" \(given name\)$",
|
|
267
|
+
r" \(disambiguation\)$", r" \(word\)$", r" \(term\)$",
|
|
268
|
+
]
|
|
269
|
+
# Keywords that suggest the article is about crime/investigations —
|
|
270
|
+
# prefer these when ambiguous.
|
|
271
|
+
_CRIME_KEYWORDS = [
|
|
272
|
+
"organised crime", "organized crime", "cartel", "drug traffick",
|
|
273
|
+
"cocaine", "mafia", "gang", "sanction", "indict", "arrest",
|
|
274
|
+
"convict", "criminal", "crime", "trafficking", "smuggl",
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
async def _wiki_api_search(self, name: str, parts: list[str]) -> str | None:
|
|
278
|
+
"""Search Wikipedia API for the best article about this subject.
|
|
279
|
+
|
|
280
|
+
Tries both original and ASCII-folded versions of the name.
|
|
281
|
+
Skips disambiguation pages and name-etymology articles.
|
|
282
|
+
Prefers articles with crime/investigation keywords.
|
|
283
|
+
"""
|
|
284
|
+
import unicodedata
|
|
285
|
+
import httpx
|
|
286
|
+
|
|
287
|
+
# Build search queries — original + ASCII-folded (no diacritics)
|
|
288
|
+
ascii_name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
|
289
|
+
queries = [name]
|
|
290
|
+
if ascii_name != name and len(ascii_name) >= 3:
|
|
291
|
+
queries.append(ascii_name)
|
|
292
|
+
|
|
293
|
+
for query in queries:
|
|
294
|
+
try:
|
|
295
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
296
|
+
resp = await client.get(
|
|
297
|
+
"https://en.wikipedia.org/w/api.php",
|
|
298
|
+
params={
|
|
299
|
+
"action": "query",
|
|
300
|
+
"list": "search",
|
|
301
|
+
"srsearch": query,
|
|
302
|
+
"srlimit": 10,
|
|
303
|
+
"format": "json",
|
|
304
|
+
},
|
|
305
|
+
headers={"User-Agent": "WatsonOSINT/1.0"},
|
|
306
|
+
)
|
|
307
|
+
if resp.status_code != 200:
|
|
308
|
+
continue
|
|
309
|
+
data = resp.json()
|
|
310
|
+
except Exception:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
results = data.get("query", {}).get("search", [])
|
|
314
|
+
if not results:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# Score and filter results
|
|
318
|
+
candidates: list[tuple[int, str]] = []
|
|
319
|
+
for r in results:
|
|
320
|
+
title = r.get("title", "")
|
|
321
|
+
snippet = r.get("snippet", "")
|
|
322
|
+
combined = (title + " " + snippet).lower()
|
|
323
|
+
|
|
324
|
+
# Skip disambiguation / name-etymology pages
|
|
325
|
+
skip = False
|
|
326
|
+
for pat in self._SKIP_TITLE_PATTERNS:
|
|
327
|
+
if re.search(pat, title, re.IGNORECASE):
|
|
328
|
+
skip = True
|
|
329
|
+
break
|
|
330
|
+
if skip:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Check that at least one name part appears in the article
|
|
334
|
+
if parts and not any(p.lower() in combined for p in parts if len(p) > 2):
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
# Score: crime keywords → higher priority
|
|
338
|
+
score = 0
|
|
339
|
+
for kw in self._CRIME_KEYWORDS:
|
|
340
|
+
if kw in combined:
|
|
341
|
+
score += 10
|
|
342
|
+
# Penalize very short titles (likely generic)
|
|
343
|
+
if len(title) < 15:
|
|
344
|
+
score -= 5
|
|
345
|
+
|
|
346
|
+
candidates.append((score, title))
|
|
347
|
+
|
|
348
|
+
if candidates:
|
|
349
|
+
# Sort by score descending, then pick best
|
|
350
|
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
|
351
|
+
return candidates[0][1]
|
|
352
|
+
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def _parse_infobox(self, html: str) -> dict[str, str]:
|
|
356
|
+
"""Parse Wikipedia infobox into key-value pairs."""
|
|
357
|
+
result: dict[str, str] = {}
|
|
358
|
+
|
|
359
|
+
# Find infobox table
|
|
360
|
+
infobox_match = re.search(
|
|
361
|
+
r'<table[^>]*class="[^"]*infobox[^"]*"[^>]*>(.*?)</table>',
|
|
362
|
+
html, re.DOTALL | re.IGNORECASE
|
|
363
|
+
)
|
|
364
|
+
if not infobox_match:
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
infobox_html = infobox_match.group(1)
|
|
368
|
+
|
|
369
|
+
# Extract rows
|
|
370
|
+
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', infobox_html, re.DOTALL | re.IGNORECASE)
|
|
371
|
+
|
|
372
|
+
for row in rows:
|
|
373
|
+
# th = key, td = value
|
|
374
|
+
th_match = re.search(r'<th[^>]*>(.*?)</th>', row, re.DOTALL | re.IGNORECASE)
|
|
375
|
+
td_match = re.search(r'<td[^>]*>(.*?)</td>', row, re.DOTALL | re.IGNORECASE)
|
|
376
|
+
|
|
377
|
+
if th_match and td_match:
|
|
378
|
+
key = self._strip_html(th_match.group(1)).strip()
|
|
379
|
+
value = self._strip_html(td_match.group(1)).strip()
|
|
380
|
+
if key and value and len(key) < 50:
|
|
381
|
+
result[key] = value
|
|
382
|
+
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
def _extract_lead_paragraph(self, html: str) -> str:
|
|
386
|
+
"""Extract the first substantive paragraph from Wikipedia."""
|
|
387
|
+
for p_match in re.finditer(r'<p[^>]*>(.*?)</p>', html, re.DOTALL | re.IGNORECASE):
|
|
388
|
+
text = self._strip_html(p_match.group(1)).strip()
|
|
389
|
+
if len(text) < 80:
|
|
390
|
+
continue
|
|
391
|
+
if "From Wikipedia" in text or "may refer to:" in text:
|
|
392
|
+
continue
|
|
393
|
+
return text[:500]
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
def _extract_target_paragraphs(self, html: str, target_name: str) -> list[str]:
|
|
397
|
+
"""Extract paragraphs that mention the target by name (for redirect pages).
|
|
398
|
+
|
|
399
|
+
When a Wikipedia page redirects (e.g., Massimo Bossetti → Murder of Yara
|
|
400
|
+
Gambirasio), the infobox belongs to the redirect target. This method
|
|
401
|
+
finds the paragraphs that actually discuss our search target.
|
|
402
|
+
"""
|
|
403
|
+
paras: list[str] = []
|
|
404
|
+
name_lower = target_name.lower()
|
|
405
|
+
parts = [p.lower() for p in target_name.split() if len(p) > 2]
|
|
406
|
+
first = parts[0] if parts else ""
|
|
407
|
+
last = parts[-1] if len(parts) >= 2 else ""
|
|
408
|
+
|
|
409
|
+
for p_match in re.finditer(r'<p[^>]*>(.*?)</p>', html, re.DOTALL | re.IGNORECASE):
|
|
410
|
+
text = self._strip_html(p_match.group(1)).strip()
|
|
411
|
+
if len(text) < 60:
|
|
412
|
+
continue
|
|
413
|
+
text_lower = text.lower()
|
|
414
|
+
|
|
415
|
+
# Full name match — strongest signal
|
|
416
|
+
if name_lower in text_lower:
|
|
417
|
+
paras.append(text[:600])
|
|
418
|
+
continue
|
|
419
|
+
# First+last both appear (name may be split across sentence)
|
|
420
|
+
if first and last and first in text_lower and last in text_lower:
|
|
421
|
+
paras.append(text[:600])
|
|
422
|
+
|
|
423
|
+
return paras
|
|
424
|
+
|
|
425
|
+
async def _scrape_opensanctions(self, name: str) -> list[Finding]:
|
|
426
|
+
"""Check OpenSanctions via API (authenticated) with graceful fallback."""
|
|
427
|
+
findings: list[Finding] = []
|
|
428
|
+
import os
|
|
429
|
+
|
|
430
|
+
api_key = os.environ.get("OPENSANCTIONS_API_KEY", "")
|
|
431
|
+
|
|
432
|
+
# Try API first (authenticated)
|
|
433
|
+
if api_key:
|
|
434
|
+
try:
|
|
435
|
+
import http.client, ssl, json, urllib.parse
|
|
436
|
+
|
|
437
|
+
params = urllib.parse.urlencode({"q": name, "limit": 10})
|
|
438
|
+
url = f"/search/default?{params}"
|
|
439
|
+
|
|
440
|
+
ctx = ssl.create_default_context()
|
|
441
|
+
conn = http.client.HTTPSConnection("api.opensanctions.org", timeout=15, context=ctx)
|
|
442
|
+
conn.request("GET", url, headers={
|
|
443
|
+
"Authorization": f"ApiKey {api_key}",
|
|
444
|
+
"Accept": "application/json",
|
|
445
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
446
|
+
})
|
|
447
|
+
resp = conn.getresponse()
|
|
448
|
+
body = resp.read().decode("utf-8", errors="replace")
|
|
449
|
+
conn.close()
|
|
450
|
+
|
|
451
|
+
if resp.status == 200:
|
|
452
|
+
data = json.loads(body)
|
|
453
|
+
results = data.get("results", [])
|
|
454
|
+
if results:
|
|
455
|
+
# ── RELEVANCE FILTER: score each result against the query ──
|
|
456
|
+
sanctioned = []
|
|
457
|
+
filtered_out = 0
|
|
458
|
+
for r in results[:10]: # Check all results, not just first 5
|
|
459
|
+
caption = r.get("caption", r.get("name", "Unknown"))
|
|
460
|
+
schema = r.get("schema", "")
|
|
461
|
+
countries = ", ".join(r.get("countries", []))
|
|
462
|
+
datasets = r.get("datasets", [])
|
|
463
|
+
topics = r.get("topics", [])
|
|
464
|
+
|
|
465
|
+
# Build a description from all available fields for scoring
|
|
466
|
+
desc_parts = [caption, schema] + countries.split(", ") + datasets + topics
|
|
467
|
+
full_desc = " ".join(desc_parts)
|
|
468
|
+
|
|
469
|
+
score = self._score_entity_relevance(caption, full_desc, name)
|
|
470
|
+
if score < 0.35:
|
|
471
|
+
filtered_out += 1
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
lines = [f"- **{caption}** [{schema}] (score: {score:.0%})"]
|
|
475
|
+
if countries:
|
|
476
|
+
lines.append(f" Countries: {countries}")
|
|
477
|
+
if datasets:
|
|
478
|
+
lines.append(f" Sanction lists: {', '.join(datasets)}")
|
|
479
|
+
if topics:
|
|
480
|
+
lines.append(f" Topics: {', '.join(topics)}")
|
|
481
|
+
sanctioned.append("\n".join(lines))
|
|
482
|
+
|
|
483
|
+
if sanctioned:
|
|
484
|
+
findings.append(
|
|
485
|
+
self._make_finding(
|
|
486
|
+
title=f"🚨 SANCTIONS MATCH: {len(sanctioned)} relevant entries for '{name}'",
|
|
487
|
+
description="\n".join(sanctioned),
|
|
488
|
+
evidence=[f"https://opensanctions.org/search/?q={urllib.parse.quote(name)}"],
|
|
489
|
+
confidence=0.95,
|
|
490
|
+
sanction_match=True,
|
|
491
|
+
result_count=len(sanctioned),
|
|
492
|
+
filtered_out=filtered_out,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
elif filtered_out:
|
|
496
|
+
findings.append(
|
|
497
|
+
self._make_finding(
|
|
498
|
+
title=f"🔍 OpenSanctions: {filtered_out} results filtered — none relevant to '{name}'",
|
|
499
|
+
description="All API results were filtered out by relevance scoring. No named entity matched the target.",
|
|
500
|
+
confidence=0.6,
|
|
501
|
+
sanction_match=False,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
return findings
|
|
505
|
+
else:
|
|
506
|
+
findings.append(
|
|
507
|
+
self._make_finding(
|
|
508
|
+
title=f"✅ No sanctions: '{name}' (OpenSanctions API)",
|
|
509
|
+
description="No matches found via authenticated OpenSanctions API search.",
|
|
510
|
+
confidence=0.7,
|
|
511
|
+
sanction_match=False,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
return findings
|
|
515
|
+
elif resp.status == 429:
|
|
516
|
+
logger.warning("opensanctions_rate_limited: API key exceeded monthly limit")
|
|
517
|
+
# Fall through to web search fallback
|
|
518
|
+
else:
|
|
519
|
+
logger.warning("opensanctions_api_error: HTTP %d", resp.status)
|
|
520
|
+
# Fall through to web search fallback
|
|
521
|
+
except Exception as e:
|
|
522
|
+
logger.warning("opensanctions_api_failed: %s", e)
|
|
523
|
+
# Fall through to web search fallback
|
|
524
|
+
|
|
525
|
+
# Fallback: search via DuckDuckGo for opensanctions.org entity pages
|
|
526
|
+
try:
|
|
527
|
+
from ddgs import DDGS
|
|
528
|
+
search_query = f'"{name}" site:opensanctions.org'
|
|
529
|
+
|
|
530
|
+
results = []
|
|
531
|
+
with DDGS() as ddgs:
|
|
532
|
+
results = list(ddgs.text(search_query, max_results=5))
|
|
533
|
+
|
|
534
|
+
if results:
|
|
535
|
+
entity_lines = []
|
|
536
|
+
filtered_out = 0
|
|
537
|
+
for r in results[:5]:
|
|
538
|
+
title = r.get("title", "")
|
|
539
|
+
url = r.get("href", "")
|
|
540
|
+
body = r.get("body", "")
|
|
541
|
+
|
|
542
|
+
# ── Filter out search pages, not entity pages ──
|
|
543
|
+
# Search redirects like /search/?q=X return garbage tracking URLs
|
|
544
|
+
# Startpage tracking redirects (/clev?event=StartpageResultClick...) are also noise
|
|
545
|
+
if "/search/" in url or "/search?" in url or "StartpageResultClick" in url:
|
|
546
|
+
filtered_out += 1
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
# DDG snippets all contain the bare search term, so body-only
|
|
550
|
+
# matches are noise. But the body ALSO contains entity descriptions.
|
|
551
|
+
# Check if the body mentions entity-specific aliases beyond the
|
|
552
|
+
# bare search term (e.g. "Norilsk Nickel" for "Nornickel").
|
|
553
|
+
# This catches Vladimir Potanin (body: "President of Norilsk Nickel")
|
|
554
|
+
# while filtering Mohammad Ali Jafari (body: generic sanctions info).
|
|
555
|
+
aliases = self._derive_aliases(name)
|
|
556
|
+
body_has_alias = any(alias.lower() in body.lower() for alias in aliases)
|
|
557
|
+
|
|
558
|
+
# Feed the body text into the scorer so it can check description tokens.
|
|
559
|
+
# Previously passed "" which made desc-based token overlap useless.
|
|
560
|
+
score = self._score_entity_relevance(title, body, name)
|
|
561
|
+
if score < 0.35 and not body_has_alias:
|
|
562
|
+
filtered_out += 1
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
entity_lines.append(f"- **{title}**\n {body[:200]}\n {url}")
|
|
566
|
+
|
|
567
|
+
if entity_lines:
|
|
568
|
+
findings.append(
|
|
569
|
+
self._make_finding(
|
|
570
|
+
title=f"🔍 OpenSanctions search results: {len(entity_lines)} for '{name}'",
|
|
571
|
+
description="\n".join(entity_lines),
|
|
572
|
+
evidence=[f"https://opensanctions.org/search/?q={urllib.parse.quote(name)}"],
|
|
573
|
+
confidence=0.7,
|
|
574
|
+
sanction_match=False,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
elif filtered_out:
|
|
578
|
+
findings.append(
|
|
579
|
+
self._make_finding(
|
|
580
|
+
title=f"🔍 OpenSanctions: {filtered_out} results filtered — none relevant to '{name}'",
|
|
581
|
+
description="All DDG results were filtered out by relevance scoring.",
|
|
582
|
+
confidence=0.5,
|
|
583
|
+
sanction_match=False,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
else:
|
|
587
|
+
findings.append(
|
|
588
|
+
self._make_finding(
|
|
589
|
+
title=f"✅ No OpenSanctions results: '{name}'",
|
|
590
|
+
description="No matches found via DuckDuckGo search of opensanctions.org.",
|
|
591
|
+
confidence=0.5,
|
|
592
|
+
sanction_match=False,
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
except Exception as e:
|
|
596
|
+
logger.warning("opensanctions_fallback_failed: %s", e)
|
|
597
|
+
|
|
598
|
+
return findings
|
|
599
|
+
|
|
600
|
+
def _strip_html(self, text: str) -> str:
|
|
601
|
+
"""Remove HTML tags and decode entities from text."""
|
|
602
|
+
# Remove tags
|
|
603
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
604
|
+
# Decode named entities
|
|
605
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
606
|
+
text = text.replace(""", '"').replace("'", "'").replace(" ", " ")
|
|
607
|
+
text = text.replace(" ", " ").replace("–", "–").replace("—", "—")
|
|
608
|
+
# Decode numeric entities like [ → [ or ] → ]
|
|
609
|
+
text = re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), text)
|
|
610
|
+
text = re.sub(r'&#[xX]([0-9a-fA-F]+);', lambda m: chr(int(m.group(1), 16)), text)
|
|
611
|
+
# Clean up whitespace
|
|
612
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
613
|
+
# Remove citation brackets [1][2] etc
|
|
614
|
+
text = re.sub(r'\[\d+\]', '', text)
|
|
615
|
+
text = re.sub(r'\[[a-z]\]', '', text)
|
|
616
|
+
return text
|
|
617
|
+
|
|
618
|
+
_RELEVANCE_STOP_WORDS: set[str] = {
|
|
619
|
+
"the", "and", "for", "with", "that", "this", "from", "have", "been",
|
|
620
|
+
"was", "are", "were", "not", "but", "its", "his", "her", "their",
|
|
621
|
+
"has", "had", "will", "would", "could", "should", "may", "also",
|
|
622
|
+
"inc", "corp", "ltd", "llc", "limited", "corporation", "company",
|
|
623
|
+
"group", "international", "global", "world", "organization",
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
def _score_entity_relevance(self, entity_name: str, entity_desc: str, search_query: str) -> float:
|
|
627
|
+
"""Score how relevant an OpenSanctions result is to the search query.
|
|
628
|
+
|
|
629
|
+
Returns 0.0 (completely unrelated) to 1.0 (exact match).
|
|
630
|
+
Threshold of 0.35 keeps entities with at least partial name overlap.
|
|
631
|
+
"""
|
|
632
|
+
query_lower = search_query.lower().strip()
|
|
633
|
+
name_lower = entity_name.lower().strip()
|
|
634
|
+
desc_lower = entity_desc.lower().strip()
|
|
635
|
+
|
|
636
|
+
# Direct substring match (strongest signal)
|
|
637
|
+
if query_lower in name_lower:
|
|
638
|
+
return 1.0
|
|
639
|
+
if name_lower in query_lower:
|
|
640
|
+
return 0.95
|
|
641
|
+
|
|
642
|
+
# Acronym matching: "MMC Norilsk Nickel" vs "Nornickel"
|
|
643
|
+
query_chars = set(query_lower.replace(" ", ""))
|
|
644
|
+
name_clean = name_lower.replace(" ", "").replace(".", "").replace(",", "")
|
|
645
|
+
overlap_ratio = len(query_chars & set(name_clean)) / max(len(query_chars), 1)
|
|
646
|
+
if overlap_ratio > 0.8 and len(query_chars) >= 4:
|
|
647
|
+
return 0.85
|
|
648
|
+
|
|
649
|
+
# Token overlap scoring
|
|
650
|
+
query_tokens = set(
|
|
651
|
+
t for t in re.findall(r'[a-z0-9]+', query_lower)
|
|
652
|
+
if len(t) > 2 and t not in self._RELEVANCE_STOP_WORDS
|
|
653
|
+
)
|
|
654
|
+
name_tokens = set(
|
|
655
|
+
t for t in re.findall(r'[a-z0-9]+', name_lower)
|
|
656
|
+
if len(t) > 2 and t not in self._RELEVANCE_STOP_WORDS
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if not query_tokens:
|
|
660
|
+
return 1.0 # Can't judge, include
|
|
661
|
+
|
|
662
|
+
# Token overlap
|
|
663
|
+
overlap = query_tokens & name_tokens
|
|
664
|
+
if overlap:
|
|
665
|
+
score = len(overlap) / len(query_tokens)
|
|
666
|
+
if len(name_tokens) <= 3:
|
|
667
|
+
score = min(1.0, score + 0.15)
|
|
668
|
+
return score
|
|
669
|
+
|
|
670
|
+
# Check description for query token mentions
|
|
671
|
+
desc_tokens = set(re.findall(r'[a-z0-9]+', desc_lower))
|
|
672
|
+
desc_hits = query_tokens & desc_tokens
|
|
673
|
+
if desc_hits:
|
|
674
|
+
hit_ratio = len(desc_hits) / len(query_tokens)
|
|
675
|
+
if hit_ratio >= 0.5:
|
|
676
|
+
return 0.55 + hit_ratio * 0.3
|
|
677
|
+
return 0.35 + hit_ratio * 0.2
|
|
678
|
+
|
|
679
|
+
# Last resort: any query token as substring in name
|
|
680
|
+
for token in query_tokens:
|
|
681
|
+
if token in name_clean:
|
|
682
|
+
return 0.40
|
|
683
|
+
|
|
684
|
+
return 0.0
|
|
685
|
+
|
|
686
|
+
@staticmethod
|
|
687
|
+
def _derive_aliases(name: str) -> list[str]:
|
|
688
|
+
"""Generate DERIVED aliases for an entity name — NOT the original.
|
|
689
|
+
|
|
690
|
+
Used in DDG fallback to check if a body snippet references the entity
|
|
691
|
+
by a VARIANT name. The original search term is excluded because every
|
|
692
|
+
DDG snippet already contains it (it's in the search query).
|
|
693
|
+
|
|
694
|
+
E.g., for "Nornickel" → ["norilsk nickel", "mmc norilsk nickel"]
|
|
695
|
+
"""
|
|
696
|
+
name_lower = name.lower().strip()
|
|
697
|
+
|
|
698
|
+
# Known company aliases (original → variants)
|
|
699
|
+
KNOWN_ALIASES = {
|
|
700
|
+
"nornickel": ["norilsk nickel", "mmc norilsk nickel"],
|
|
701
|
+
"norilsk nickel": ["nornickel", "mmc norilsk"],
|
|
702
|
+
"mmc norilsk nickel": ["nornickel", "norilsk nickel"],
|
|
703
|
+
"interros": ["interros holding"],
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
aliases = KNOWN_ALIASES.get(name_lower, [])
|
|
707
|
+
|
|
708
|
+
# Space-separated component swaps
|
|
709
|
+
if " " in name_lower:
|
|
710
|
+
parts = name_lower.split()
|
|
711
|
+
if len(parts) >= 2:
|
|
712
|
+
swapped = " ".join(parts[-1:] + parts[:-1])
|
|
713
|
+
if swapped != name_lower and swapped not in aliases:
|
|
714
|
+
aliases.append(swapped)
|
|
715
|
+
|
|
716
|
+
return list(set(aliases))
|
|
717
|
+
|
|
718
|
+
def _extract_entity_name(self, text: str) -> Optional[str]:
|
|
719
|
+
"""Extract entity name from query text."""
|
|
720
|
+
# Strip quotes and common keywords
|
|
721
|
+
clean = re.sub(r'["\']', '', text)
|
|
722
|
+
clean = re.sub(
|
|
723
|
+
r'\b(?:investigate|research|search|find|check|look\s+up|company|sanctions?)\b',
|
|
724
|
+
'', clean, flags=re.IGNORECASE
|
|
725
|
+
).strip()
|
|
726
|
+
|
|
727
|
+
# Find capitalized name sequence (1-4 words)
|
|
728
|
+
match = re.search(r'\b([A-Z][a-z]+(?:\s+(?:"[^"]*"\s+)?[A-Z][a-z]+){0,3})\b', clean)
|
|
729
|
+
if match:
|
|
730
|
+
return match.group(1)
|
|
731
|
+
|
|
732
|
+
# Fallback: any CamelCase or single capitalized word (e.g. "OpenAI", "DeepSeek")
|
|
733
|
+
match = re.search(r'\b([A-Za-z][A-Za-z0-9]{2,}(?:\s+[A-Za-z][A-Za-z0-9]{1,}){0,2})\b', clean)
|
|
734
|
+
if match:
|
|
735
|
+
name = match.group(1)
|
|
736
|
+
if name.lower() not in ("who", "what", "where", "when", "why", "how",
|
|
737
|
+
"the", "and", "for", "with", "this", "that"):
|
|
738
|
+
return name
|
|
739
|
+
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# Register
|
|
744
|
+
scraper_tool = ScraperTool()
|
|
745
|
+
registry.register(scraper_tool)
|