osintengine 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
@@ -0,0 +1,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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
606
+ text = text.replace("&quot;", '"').replace("&#39;", "'").replace("&nbsp;", " ")
607
+ text = text.replace("&#160;", " ").replace("&ndash;", "–").replace("&mdash;", "—")
608
+ # Decode numeric entities like &#91; → [ or &#93; → ]
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)