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,621 @@
|
|
|
1
|
+
"""Universal pre-synthesis pipeline for Watson OSINT.
|
|
2
|
+
|
|
3
|
+
Handles any target type and any language/script. Conservatively drops only
|
|
4
|
+
findings with zero relevance to the target — never risks losing real intel.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
1. TargetCanonicalizer → extracts canonical tokens from any target type
|
|
8
|
+
2. RelevanceGrader → multilingual token + bigram scoring
|
|
9
|
+
3. relevance_filter → safe drop-only gate (zero overlap + low confidence)
|
|
10
|
+
|
|
11
|
+
Target types supported:
|
|
12
|
+
person, organization, domain, email, wallet, phone, ip, username, unknown
|
|
13
|
+
|
|
14
|
+
Scripts supported:
|
|
15
|
+
Latin (all diacritic variants), Cyrillic, CJK, Arabic, Devanagari, Thai, etc.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import unicodedata
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Script → language mapping (for language boost)
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
# Unicode script blocks for common writing systems
|
|
30
|
+
_SCRIPT_RANGES: list[tuple[int, int, str]] = [
|
|
31
|
+
# Latin blocks (various extensions)
|
|
32
|
+
(0x0041, 0x007A, "en"), # Basic Latin A-Z a-z
|
|
33
|
+
(0x00C0, 0x024F, "en"), # Latin-1 Supplement + Extended-A
|
|
34
|
+
# Specific diacritic patterns for language detection
|
|
35
|
+
(0x0150, 0x0151, "hu"), # Ő ő — Hungarian
|
|
36
|
+
(0x0170, 0x0171, "hu"), # Ű ű — Hungarian
|
|
37
|
+
(0x010C, 0x010D, "cs"), # Č č — Czech/Slovak/Slovene/Croatian
|
|
38
|
+
(0x0160, 0x0161, "cs"), # Š š
|
|
39
|
+
(0x017D, 0x017E, "cs"), # Ž ž
|
|
40
|
+
(0x0141, 0x0142, "pl"), # Ł ł — Polish
|
|
41
|
+
(0x0104, 0x0105, "pl"), # Ą ą
|
|
42
|
+
(0x0118, 0x0119, "pl"), # Ę ę
|
|
43
|
+
(0x0143, 0x0144, "pl"), # Ń ń
|
|
44
|
+
(0x015A, 0x015B, "pl"), # Ś ś
|
|
45
|
+
(0x0179, 0x017A, "pl"), # Ź ź
|
|
46
|
+
(0x017B, 0x017C, "pl"), # Ż ż
|
|
47
|
+
(0x00DF, 0x00DF, "de"), # ß — German
|
|
48
|
+
(0x00E4, 0x00E4, "de"), # ä
|
|
49
|
+
(0x00F6, 0x00F6, "de"), # ö
|
|
50
|
+
(0x00FC, 0x00FC, "de"), # ü
|
|
51
|
+
(0x00E0, 0x00E0, "it"), # à — Italian
|
|
52
|
+
(0x00E8, 0x00E8, "it"), # è
|
|
53
|
+
(0x00EC, 0x00EC, "it"), # ì
|
|
54
|
+
(0x00F2, 0x00F2, "it"), # ò
|
|
55
|
+
(0x00F9, 0x00F9, "it"), # ù
|
|
56
|
+
(0x00E7, 0x00E7, "pt"), # ç — Portuguese/French
|
|
57
|
+
(0x00F1, 0x00F1, "es"), # ñ — Spanish
|
|
58
|
+
# Cyrillic
|
|
59
|
+
(0x0400, 0x04FF, "ru"), # Cyrillic → default Russian
|
|
60
|
+
(0x0500, 0x052F, "ru"), # Cyrillic Supplement
|
|
61
|
+
# CJK
|
|
62
|
+
(0x4E00, 0x9FFF, "zh"), # CJK Unified → default Chinese
|
|
63
|
+
(0x3400, 0x4DBF, "zh"), # CJK Extension A
|
|
64
|
+
(0x3040, 0x309F, "ja"), # Hiragana → Japanese
|
|
65
|
+
(0x30A0, 0x30FF, "ja"), # Katakana
|
|
66
|
+
(0xAC00, 0xD7AF, "ko"), # Hangul → Korean
|
|
67
|
+
# Arabic
|
|
68
|
+
(0x0600, 0x06FF, "ar"), # Arabic
|
|
69
|
+
(0x0750, 0x077F, "ar"), # Arabic Supplement
|
|
70
|
+
(0xFB50, 0xFDFF, "ar"), # Arabic Presentation A
|
|
71
|
+
(0xFE70, 0xFEFF, "ar"), # Arabic Presentation B
|
|
72
|
+
# Devanagari (Hindi, Sanskrit, Marathi, Nepali)
|
|
73
|
+
(0x0900, 0x097F, "hi"), # Devanagari
|
|
74
|
+
# Thai
|
|
75
|
+
(0x0E00, 0x0E7F, "th"), # Thai
|
|
76
|
+
# Greek
|
|
77
|
+
(0x0370, 0x03FF, "el"), # Greek
|
|
78
|
+
# Hebrew
|
|
79
|
+
(0x0590, 0x05FF, "he"), # Hebrew
|
|
80
|
+
# Georgian
|
|
81
|
+
(0x10A0, 0x10FF, "ka"), # Georgian
|
|
82
|
+
# Armenian
|
|
83
|
+
(0x0530, 0x058F, "hy"), # Armenian
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detect_scripts(text: str) -> dict[str, float]:
|
|
88
|
+
"""Count character frequencies per script/language.
|
|
89
|
+
|
|
90
|
+
Returns dict mapping language codes to proportion (0.0–1.0).
|
|
91
|
+
"""
|
|
92
|
+
counts: dict[str, int] = {}
|
|
93
|
+
total = 0
|
|
94
|
+
for ch in text:
|
|
95
|
+
cp = ord(ch)
|
|
96
|
+
for lo, hi, lang in _SCRIPT_RANGES:
|
|
97
|
+
if lo <= cp <= hi:
|
|
98
|
+
counts[lang] = counts.get(lang, 0) + 1
|
|
99
|
+
total += 1
|
|
100
|
+
break
|
|
101
|
+
if total == 0:
|
|
102
|
+
return {}
|
|
103
|
+
return {lang: cnt / total for lang, cnt in counts.items()}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Target canonicalization
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
# Known email provider domains — tokens to strip when canonicalizing
|
|
111
|
+
_EMAIL_PROVIDERS = {
|
|
112
|
+
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com",
|
|
113
|
+
"outlook.com", "hotmail.com", "live.com", "msn.com",
|
|
114
|
+
"protonmail.com", "proton.me", "pm.me",
|
|
115
|
+
"icloud.com", "me.com", "mac.com",
|
|
116
|
+
"aol.com", "mail.com", "gmx.com", "gmx.de",
|
|
117
|
+
"zoho.com", "fastmail.com", "tutanota.com",
|
|
118
|
+
"yandex.com", "yandex.ru", "qq.com", "163.com",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Common TLDs — not useful as canonical tokens for domain targets
|
|
122
|
+
_NOISE_TLDS = {"com", "org", "net", "io", "co", "ai", "dev", "app", "info", "biz"}
|
|
123
|
+
|
|
124
|
+
# Known crypto prefixes
|
|
125
|
+
_WALLET_PREFIXES = {"0x": "eth", "bc1": "btc", "1": "btc", "3": "btc",
|
|
126
|
+
"T": "trx", "L": "ltc", "M": "ltc", "X": "xmr"}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class TargetProfile:
|
|
131
|
+
"""Canonical representation of an investigation target — any type, any language."""
|
|
132
|
+
raw: str # Original query string
|
|
133
|
+
target_type: str # person, organization, domain, email, wallet, phone, ip, username, unknown
|
|
134
|
+
canonical_tokens: set[str] # Meaningful, normalized tokens
|
|
135
|
+
name_parts: list[str] # For person/org targets (split by spaces, normalized)
|
|
136
|
+
likely_languages: set[str] # ISO 639-1 codes inferred from script
|
|
137
|
+
tld: str = "" # For domain/email targets
|
|
138
|
+
wallet_chain: str = "" # For wallet targets
|
|
139
|
+
metadata: dict = field(default_factory=dict)
|
|
140
|
+
name_part_tokens: list[set[str]] = field(default_factory=list) # Per-part ASCII-normalized tokens (for person targets)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def canonicalize_target(query: str) -> TargetProfile:
|
|
144
|
+
"""Extract canonical tokens and metadata from ANY target type.
|
|
145
|
+
|
|
146
|
+
Handles: person names, organization names, domains, emails,
|
|
147
|
+
cryptocurrency wallets, phone numbers, IP addresses, usernames.
|
|
148
|
+
|
|
149
|
+
For multilingual targets: preserves diacritics in name_parts
|
|
150
|
+
but normalizes canonical_tokens to ASCII for matching.
|
|
151
|
+
"""
|
|
152
|
+
if not query or not query.strip():
|
|
153
|
+
return TargetProfile(
|
|
154
|
+
raw=query, target_type="unknown",
|
|
155
|
+
canonical_tokens=set(), name_parts=[],
|
|
156
|
+
likely_languages={"en"}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
query = query.strip()
|
|
160
|
+
|
|
161
|
+
# ── Type detection ──
|
|
162
|
+
# Email
|
|
163
|
+
if "@" in query and "." in query.split("@")[-1]:
|
|
164
|
+
return _canonicalize_email(query)
|
|
165
|
+
|
|
166
|
+
# Wallet
|
|
167
|
+
if re.match(r'^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-zA-HJ-NP-Z0-9]{25,62}|T[a-zA-Z0-9]{33}|L[a-zA-Z0-9]{33}|4[0-9AB][1-9A-HJ-NP-Za-km-z]{93})$', query):
|
|
168
|
+
return _canonicalize_wallet(query)
|
|
169
|
+
|
|
170
|
+
# IP address
|
|
171
|
+
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query):
|
|
172
|
+
return _canonicalize_ip(query)
|
|
173
|
+
|
|
174
|
+
# Phone number (international format)
|
|
175
|
+
if re.match(r'^\+?\d{7,15}$', query.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")):
|
|
176
|
+
return _canonicalize_phone(query)
|
|
177
|
+
|
|
178
|
+
# Domain
|
|
179
|
+
if "." in query and "/" not in query and " " not in query and not query.startswith("@"):
|
|
180
|
+
# Must have a valid TLD
|
|
181
|
+
parts = query.lower().rstrip(".").split(".")
|
|
182
|
+
if len(parts) >= 2 and parts[-1].isalpha() and 2 <= len(parts[-1]) <= 6:
|
|
183
|
+
return _canonicalize_domain(query)
|
|
184
|
+
|
|
185
|
+
# Username (no spaces, alphanumeric + common separators)
|
|
186
|
+
if re.match(r'^@?[a-zA-Z0-9][a-zA-Z0-9._-]{2,30}$', query.lstrip("@")):
|
|
187
|
+
return _canonicalize_username(query)
|
|
188
|
+
|
|
189
|
+
# Person vs Organization heuristic
|
|
190
|
+
# Person: usually 2-3 words, capitalized, no common org indicators
|
|
191
|
+
# Organization: often has Inc, Corp, Ltd, LLC, or is a single word
|
|
192
|
+
words = query.split()
|
|
193
|
+
org_indicators = {"inc", "corp", "ltd", "llc", "gmbh", "sa", "spa", "bv", "nv",
|
|
194
|
+
"ag", "kg", "plc", "lp", "llp", "co", "group", "holdings",
|
|
195
|
+
"corporation", "limited", "company", "bank", "university",
|
|
196
|
+
"institute", "foundation", "association", "ministry", "department"}
|
|
197
|
+
|
|
198
|
+
is_org = any(w.lower().rstrip(".,") in org_indicators for w in words)
|
|
199
|
+
|
|
200
|
+
if len(words) >= 2 and not is_org:
|
|
201
|
+
return _canonicalize_person(query)
|
|
202
|
+
else:
|
|
203
|
+
return _canonicalize_organization(query)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Stop words: universal function words in major languages ──
|
|
207
|
+
# These generate false bigram/token matches and dilute relevance.
|
|
208
|
+
_STOP_WORDS: set[str] = {
|
|
209
|
+
# English
|
|
210
|
+
"the", "and", "for", "that", "this", "with", "from", "have", "are",
|
|
211
|
+
"was", "not", "but", "you", "all", "can", "had", "her", "his", "its",
|
|
212
|
+
"one", "our", "out", "she", "some", "than", "them", "then", "were",
|
|
213
|
+
"will", "would", "been", "being", "does", "did", "has", "just", "like",
|
|
214
|
+
"make", "more", "much", "must", "now", "only", "over", "said", "such",
|
|
215
|
+
"also", "any", "after", "about", "into", "other", "their", "there",
|
|
216
|
+
"which", "when", "what", "who", "how", "where", "may", "get", "got",
|
|
217
|
+
"new", "see", "use", "way", "well", "back", "come", "down", "each",
|
|
218
|
+
"even", "first", "good", "know", "last", "life", "long", "look", "many",
|
|
219
|
+
"most", "own", "part", "same", "still", "take", "tell", "these", "those",
|
|
220
|
+
"very", "year", "here", "every", "through", "before", "between",
|
|
221
|
+
# Hungarian
|
|
222
|
+
"az", "ez", "egy", "nem", "hogy", "van", "volt", "lesz", "mint",
|
|
223
|
+
"meg", "mert", "már", "még", "itt", "ott", "aki", "ami", "csak",
|
|
224
|
+
"ha", "is", "de", "el", "ki", "be", "ki", "fel", "le", "át",
|
|
225
|
+
# French
|
|
226
|
+
"le", "la", "les", "un", "une", "des", "est", "pas", "dans",
|
|
227
|
+
"pour", "avec", "sur", "par", "que", "qui", "ce", "se", "au",
|
|
228
|
+
"du", "en", "il", "elle", "nous", "vous", "ils", "elles", "mais",
|
|
229
|
+
# Spanish
|
|
230
|
+
"el", "los", "las", "del", "por", "con", "sin", "para", "como",
|
|
231
|
+
"más", "pero", "entre", "hasta", "desde", "porque", "cuando",
|
|
232
|
+
# German
|
|
233
|
+
"der", "die", "das", "den", "dem", "ein", "eine", "einen",
|
|
234
|
+
"nicht", "sich", "auch", "auf", "bei", "nach", "noch", "schon",
|
|
235
|
+
"um", "zu", "zur", "zum", "von", "vor", "wir", "sie", "er",
|
|
236
|
+
# Italian
|
|
237
|
+
"di", "che", "in", "lo", "gli", "sono", "per", "con", "su",
|
|
238
|
+
# Russian (transliterated)
|
|
239
|
+
"i", "v", "na", "ne", "chto", "kak", "eto", "ot", "po",
|
|
240
|
+
# Portuguese
|
|
241
|
+
"da", "das", "dos", "em", "na", "no", "nas", "nos", "ao", "aos",
|
|
242
|
+
# Dutch
|
|
243
|
+
"de", "het", "een", "op", "te", "zijn", "dat", "met", "van",
|
|
244
|
+
# Arabic (transliterated)
|
|
245
|
+
"al", "fi", "min", "ma", "la", "wa", "ya",
|
|
246
|
+
# Universal
|
|
247
|
+
"or", "if", "an", "as", "at", "be", "by", "do", "go", "he", "in",
|
|
248
|
+
"is", "it", "me", "my", "no", "of", "on", "so", "to", "up", "us",
|
|
249
|
+
"we", "oh", "ok", "hi", "am", "pm", "dr", "mr", "ms", "rs",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def _tokenize(text: str) -> set[str]:
|
|
253
|
+
"""Normalize text to canonical ASCII tokens (min 2 chars). Filters stop words."""
|
|
254
|
+
# NFKD decomposes diacritics: ó → o + combining acute
|
|
255
|
+
s = unicodedata.normalize('NFKD', text.lower())
|
|
256
|
+
# Strip combining characters (diacritics)
|
|
257
|
+
s = ''.join(c for c in s if not unicodedata.combining(c))
|
|
258
|
+
# Extract alphanumeric tokens, filter stop words
|
|
259
|
+
tokens = set()
|
|
260
|
+
for token in s.split():
|
|
261
|
+
token = ''.join(c for c in token if c.isalnum())
|
|
262
|
+
if len(token) >= 2 and token not in _STOP_WORDS:
|
|
263
|
+
tokens.add(token)
|
|
264
|
+
return tokens
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _detect_languages(text: str) -> set[str]:
|
|
268
|
+
"""Infer likely languages from script detection."""
|
|
269
|
+
scripts = _detect_scripts(text)
|
|
270
|
+
langs = set(scripts.keys())
|
|
271
|
+
# Always include English as fallback
|
|
272
|
+
langs.add("en")
|
|
273
|
+
return langs
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _canonicalize_person(query: str) -> TargetProfile:
|
|
277
|
+
"""Canonicalize a person name."""
|
|
278
|
+
words = [w.strip(".,;:'\"") for w in query.split() if len(w.strip(".,;:'\"")) >= 2]
|
|
279
|
+
name_parts = [w.lower() for w in words]
|
|
280
|
+
tokens = _tokenize(query)
|
|
281
|
+
# Also add individual name parts as tokens (e.g., "bodi", "ildiko")
|
|
282
|
+
# And track per-part tokens for surname-aware relevance scoring
|
|
283
|
+
name_part_tokens: list[set[str]] = []
|
|
284
|
+
for part in name_parts:
|
|
285
|
+
tokenized = _tokenize(part)
|
|
286
|
+
tokens.update(tokenized)
|
|
287
|
+
name_part_tokens.append(tokenized)
|
|
288
|
+
langs = _detect_languages(query)
|
|
289
|
+
return TargetProfile(
|
|
290
|
+
raw=query, target_type="person",
|
|
291
|
+
canonical_tokens=tokens, name_parts=name_parts,
|
|
292
|
+
name_part_tokens=name_part_tokens,
|
|
293
|
+
likely_languages=langs, metadata={"word_count": len(words)}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _canonicalize_organization(query: str) -> TargetProfile:
|
|
298
|
+
"""Canonicalize an organization name."""
|
|
299
|
+
# Strip org indicators for cleaner tokens
|
|
300
|
+
stripped = re.sub(r'\b(Inc|Corp|Ltd|LLC|GmbH|SA|SpA|BV|NV|AG|KG|PLC|LP|LLP|Co)\b\.?',
|
|
301
|
+
'', query, flags=re.IGNORECASE)
|
|
302
|
+
tokens = _tokenize(stripped)
|
|
303
|
+
words = [w for w in stripped.split() if len(w) >= 2]
|
|
304
|
+
langs = _detect_languages(query)
|
|
305
|
+
return TargetProfile(
|
|
306
|
+
raw=query, target_type="organization",
|
|
307
|
+
canonical_tokens=tokens, name_parts=[w.lower() for w in words],
|
|
308
|
+
likely_languages=langs, metadata={"word_count": len(words)}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _canonicalize_domain(query: str) -> TargetProfile:
|
|
313
|
+
"""Canonicalize a domain name."""
|
|
314
|
+
parts = query.lower().rstrip(".").split(".")
|
|
315
|
+
tld = parts[-1] if len(parts) >= 2 else ""
|
|
316
|
+
# The meaningful part is the SLD (second-level domain)
|
|
317
|
+
sld = parts[-2] if len(parts) >= 2 else parts[0]
|
|
318
|
+
tokens = _tokenize(sld)
|
|
319
|
+
# Also add the full domain without TLD
|
|
320
|
+
if len(parts) >= 2:
|
|
321
|
+
tokens.add('.'.join(parts[:-1]))
|
|
322
|
+
langs = {"en"}
|
|
323
|
+
# Country-code TLD hints at language
|
|
324
|
+
_CC_LANG = {"hu": "hu", "de": "de", "fr": "fr", "es": "es", "it": "it",
|
|
325
|
+
"pt": "pt", "ru": "ru", "cn": "zh", "jp": "ja", "kr": "ko",
|
|
326
|
+
"pl": "pl", "cz": "cs", "sk": "cs", "nl": "nl", "se": "sv",
|
|
327
|
+
"no": "no", "dk": "da", "fi": "fi", "gr": "el", "il": "he",
|
|
328
|
+
"ar": "ar", "th": "th", "vn": "vi", "tr": "tr", "ua": "uk"}
|
|
329
|
+
if tld in _CC_LANG:
|
|
330
|
+
langs.add(_CC_LANG[tld])
|
|
331
|
+
return TargetProfile(
|
|
332
|
+
raw=query, target_type="domain",
|
|
333
|
+
canonical_tokens=tokens, name_parts=[sld],
|
|
334
|
+
likely_languages=langs, tld=tld
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _canonicalize_email(query: str) -> TargetProfile:
|
|
339
|
+
"""Canonicalize an email address."""
|
|
340
|
+
local, domain = query.split("@", 1)
|
|
341
|
+
# Extract meaningful tokens from local part
|
|
342
|
+
# "baron.lorenzo99" → ["baron", "lorenzo"]
|
|
343
|
+
name_parts = re.split(r'[\d._\-+\s]+', local)
|
|
344
|
+
name_parts = [p for p in name_parts if len(p) >= 2]
|
|
345
|
+
tokens = set()
|
|
346
|
+
for part in name_parts:
|
|
347
|
+
tokens.update(_tokenize(part))
|
|
348
|
+
# Also add the local part as a whole for username matching
|
|
349
|
+
tokens.add(local.lower())
|
|
350
|
+
# Don't add domain tokens if it's a known provider
|
|
351
|
+
domain_parts = domain.lower().split(".")
|
|
352
|
+
if domain.lower() not in _EMAIL_PROVIDERS:
|
|
353
|
+
sld = domain_parts[0] if domain_parts else domain
|
|
354
|
+
tokens.update(_tokenize(sld))
|
|
355
|
+
tokens.add(domain.lower())
|
|
356
|
+
langs = _detect_languages(' '.join(name_parts))
|
|
357
|
+
langs.add("en")
|
|
358
|
+
return TargetProfile(
|
|
359
|
+
raw=query, target_type="email",
|
|
360
|
+
canonical_tokens=tokens, name_parts=[p.lower() for p in name_parts],
|
|
361
|
+
likely_languages=langs, metadata={
|
|
362
|
+
"local": local, "domain": domain,
|
|
363
|
+
"is_provider": domain.lower() in _EMAIL_PROVIDERS
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _canonicalize_wallet(query: str) -> TargetProfile:
|
|
369
|
+
"""Canonicalize a cryptocurrency wallet address."""
|
|
370
|
+
clean = query.strip()
|
|
371
|
+
chain = "unknown"
|
|
372
|
+
for prefix, c in _WALLET_PREFIXES.items():
|
|
373
|
+
if clean.lower().startswith(prefix.lower()):
|
|
374
|
+
chain = c
|
|
375
|
+
break
|
|
376
|
+
# Use the full address as a single token
|
|
377
|
+
tokens = {clean.lower()}
|
|
378
|
+
# Also add first 8 and last 8 chars for partial matches
|
|
379
|
+
if len(clean) >= 16:
|
|
380
|
+
tokens.add(clean[:8].lower())
|
|
381
|
+
tokens.add(clean[-8:].lower())
|
|
382
|
+
return TargetProfile(
|
|
383
|
+
raw=query, target_type="wallet",
|
|
384
|
+
canonical_tokens=tokens, name_parts=[clean[:12]],
|
|
385
|
+
likely_languages={"en"}, wallet_chain=chain
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _canonicalize_phone(query: str) -> TargetProfile:
|
|
390
|
+
"""Canonicalize a phone number."""
|
|
391
|
+
digits = ''.join(c for c in query if c.isdigit())
|
|
392
|
+
tokens = {digits}
|
|
393
|
+
# Last 6-10 digits as partial match
|
|
394
|
+
if len(digits) >= 6:
|
|
395
|
+
tokens.add(digits[-6:])
|
|
396
|
+
if len(digits) >= 10:
|
|
397
|
+
tokens.add(digits[-10:])
|
|
398
|
+
langs = {"en"}
|
|
399
|
+
# Country code detection (very rough)
|
|
400
|
+
if digits.startswith("36"):
|
|
401
|
+
langs.add("hu")
|
|
402
|
+
elif digits.startswith("33"):
|
|
403
|
+
langs.add("fr")
|
|
404
|
+
elif digits.startswith("39"):
|
|
405
|
+
langs.add("it")
|
|
406
|
+
return TargetProfile(
|
|
407
|
+
raw=query, target_type="phone",
|
|
408
|
+
canonical_tokens=tokens, name_parts=[digits],
|
|
409
|
+
likely_languages=langs
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _canonicalize_ip(query: str) -> TargetProfile:
|
|
414
|
+
"""Canonicalize an IP address."""
|
|
415
|
+
tokens = {query.strip()}
|
|
416
|
+
# Individual octets
|
|
417
|
+
for octet in query.split("."):
|
|
418
|
+
if octet.isdigit():
|
|
419
|
+
tokens.add(octet)
|
|
420
|
+
return TargetProfile(
|
|
421
|
+
raw=query, target_type="ip",
|
|
422
|
+
canonical_tokens=tokens, name_parts=[query.strip()],
|
|
423
|
+
likely_languages={"en"}
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _canonicalize_username(query: str) -> TargetProfile:
|
|
428
|
+
"""Canonicalize a username/handle."""
|
|
429
|
+
clean = query.lstrip("@").lower()
|
|
430
|
+
tokens = {clean}
|
|
431
|
+
# Split on common separators for sub-tokens
|
|
432
|
+
sub_tokens = re.split(r'[._\-]', clean)
|
|
433
|
+
for st in sub_tokens:
|
|
434
|
+
if len(st) >= 2:
|
|
435
|
+
tokens.update(_tokenize(st))
|
|
436
|
+
langs = _detect_languages(clean)
|
|
437
|
+
langs.add("en")
|
|
438
|
+
return TargetProfile(
|
|
439
|
+
raw=query, target_type="username",
|
|
440
|
+
canonical_tokens=tokens, name_parts=sub_tokens,
|
|
441
|
+
likely_languages=langs
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Multilingual Relevance Grading
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def relevance_score(profile: TargetProfile, text: str) -> float:
|
|
450
|
+
"""Multilingual relevance: 0.0 = no relation, 1.0 = exact match.
|
|
451
|
+
|
|
452
|
+
Strategies (applied cumulatively):
|
|
453
|
+
1. Jaccard token overlap (diacritic-normalized) — primary
|
|
454
|
+
2. Bigram overlap — catches partial name matches
|
|
455
|
+
3. Script/language boost — reward matching scripts
|
|
456
|
+
4. Target-type-specific heuristics
|
|
457
|
+
"""
|
|
458
|
+
if not text or not profile.canonical_tokens:
|
|
459
|
+
return 0.0 if profile.canonical_tokens else 0.5
|
|
460
|
+
|
|
461
|
+
text_tokens = _tokenize(text)
|
|
462
|
+
if not text_tokens:
|
|
463
|
+
return 0.0
|
|
464
|
+
|
|
465
|
+
# ── Strategy 1: Jaccard token overlap ──
|
|
466
|
+
target_tokens = profile.canonical_tokens
|
|
467
|
+
intersection = target_tokens & text_tokens
|
|
468
|
+
union = target_tokens | text_tokens
|
|
469
|
+
jaccard = len(intersection) / len(union) if union else 0.0
|
|
470
|
+
|
|
471
|
+
# ── Strategy 2: Trigram overlap ──
|
|
472
|
+
# 3-character sequences are much less likely to randomly match across
|
|
473
|
+
# unrelated text than bigrams (e.g., "di" appears in ~30% of English words,
|
|
474
|
+
# but "bodi" → "bod" or "odi" appears in almost none).
|
|
475
|
+
# Catches "ildiko" matching "ildikó" (NFKD → "ildiko") and
|
|
476
|
+
# "Ildiko Bodi" matching "Bódi Ildikó" via shared trigrams in the name parts.
|
|
477
|
+
def _trigrams(tokens: set[str]) -> set[str]:
|
|
478
|
+
trigrams = set()
|
|
479
|
+
for token in tokens:
|
|
480
|
+
for i in range(len(token) - 2):
|
|
481
|
+
trigrams.add(token[i:i+3])
|
|
482
|
+
return trigrams
|
|
483
|
+
|
|
484
|
+
target_trigrams = _trigrams(target_tokens)
|
|
485
|
+
text_trigrams = _trigrams(text_tokens)
|
|
486
|
+
if target_trigrams:
|
|
487
|
+
trigram_overlap = len(target_trigrams & text_trigrams) / len(target_trigrams)
|
|
488
|
+
else:
|
|
489
|
+
trigram_overlap = 0.0
|
|
490
|
+
|
|
491
|
+
# ── Strategy 3: Language/script boost ──
|
|
492
|
+
# If the text uses scripts matching the target's likely languages,
|
|
493
|
+
# apply a small boost (helps Hungarian articles about Hungarian people)
|
|
494
|
+
text_scripts = _detect_scripts(text)
|
|
495
|
+
script_boost = 0.0
|
|
496
|
+
if text_scripts and profile.likely_languages:
|
|
497
|
+
matching_scripts = profile.likely_languages & set(text_scripts.keys())
|
|
498
|
+
if matching_scripts:
|
|
499
|
+
# Up to 0.10 boost based on script match ratio
|
|
500
|
+
script_boost = 0.10 * sum(text_scripts[lang] for lang in matching_scripts)
|
|
501
|
+
|
|
502
|
+
# ── Strategy 2.5: Surname-aware penalty (person targets only) ──
|
|
503
|
+
# For multi-word person names, a finding that matches only ONE name part
|
|
504
|
+
# (e.g., "Ildiko Szabo" matching only "Ildiko" from "BÓDI Ildikó") is a
|
|
505
|
+
# completely different person. Given names are too common; the surname is
|
|
506
|
+
# the primary disambiguator. This penalty prevents the trigram/Jaccard
|
|
507
|
+
# overlap from giving high scores to clearly irrelevant findings.
|
|
508
|
+
name_penalty = 1.0
|
|
509
|
+
if profile.target_type == "person" and profile.name_part_tokens and len(profile.name_part_tokens) >= 2:
|
|
510
|
+
parts_matched = 0
|
|
511
|
+
for part_tokens in profile.name_part_tokens:
|
|
512
|
+
if part_tokens & text_tokens:
|
|
513
|
+
parts_matched += 1
|
|
514
|
+
|
|
515
|
+
match_ratio = parts_matched / len(profile.name_part_tokens)
|
|
516
|
+
|
|
517
|
+
if match_ratio >= 1.0:
|
|
518
|
+
# Full name match — all parts present → slight bonus
|
|
519
|
+
name_penalty = 1.15
|
|
520
|
+
elif match_ratio <= 0.5:
|
|
521
|
+
# Only half or fewer name parts matched → heavy penalty
|
|
522
|
+
# 1/2 → 0.25x, 1/3 → 0.15x, 2/4 → 0.35x
|
|
523
|
+
name_penalty = max(0.10, match_ratio * 0.50)
|
|
524
|
+
else:
|
|
525
|
+
# Most parts matched (e.g., 2/3) → moderate penalty
|
|
526
|
+
name_penalty = match_ratio
|
|
527
|
+
|
|
528
|
+
# ── Composite score ──
|
|
529
|
+
# Jaccard is the primary signal (most reliable). Trigram overlap is a
|
|
530
|
+
# secondary fallback — weighted low, and 3-char sequences avoid the
|
|
531
|
+
# false-match problem of bigrams. Script boost is tiny.
|
|
532
|
+
composite = (0.70 * jaccard) + (0.12 * trigram_overlap) + (0.05 * script_boost)
|
|
533
|
+
|
|
534
|
+
# Apply surname-aware penalty / bonus
|
|
535
|
+
composite *= name_penalty
|
|
536
|
+
|
|
537
|
+
# ── Target-type-specific boosts ──
|
|
538
|
+
if profile.target_type == "wallet":
|
|
539
|
+
# Wallet addresses are unique — exact match is very strong
|
|
540
|
+
if profile.raw.lower() in text.lower():
|
|
541
|
+
composite = max(composite, 0.95)
|
|
542
|
+
elif profile.target_type == "ip":
|
|
543
|
+
if profile.raw in text:
|
|
544
|
+
composite = max(composite, 0.95)
|
|
545
|
+
elif profile.target_type == "phone":
|
|
546
|
+
digits = ''.join(c for c in profile.raw if c.isdigit())
|
|
547
|
+
if digits and digits in ''.join(c for c in text if c.isdigit()):
|
|
548
|
+
composite = max(composite, 0.90)
|
|
549
|
+
|
|
550
|
+
return min(composite, 1.0)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# ---------------------------------------------------------------------------
|
|
554
|
+
# Safe Relevance Filter
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
def relevance_filter(findings: list, query: str, min_score: float = 0.03) -> tuple[list, list]:
|
|
558
|
+
"""Filter out clearly irrelevant findings.
|
|
559
|
+
|
|
560
|
+
SAFETY RULES (never drops legitimate intelligence):
|
|
561
|
+
1. Findings WITH source URLs are always kept (assume scraper validated)
|
|
562
|
+
EXCEPT: person targets with 2+ name parts → if only 1 name part matches
|
|
563
|
+
and confidence < 0.80, drop it (given-name-only matches are noise).
|
|
564
|
+
2. Findings with high confidence (≥0.65) are always kept
|
|
565
|
+
3. Only drops findings with ZERO composite relevance score
|
|
566
|
+
4. Only drops if confidence < 0.65
|
|
567
|
+
|
|
568
|
+
Returns: (kept, dropped) — dropped list is for audit logging.
|
|
569
|
+
"""
|
|
570
|
+
if not query or not findings:
|
|
571
|
+
return (list(findings), [])
|
|
572
|
+
|
|
573
|
+
profile = canonicalize_target(query)
|
|
574
|
+
if not profile.canonical_tokens:
|
|
575
|
+
return (list(findings), []) # Can't assess, keep all
|
|
576
|
+
|
|
577
|
+
kept = []
|
|
578
|
+
dropped = []
|
|
579
|
+
for f in findings:
|
|
580
|
+
confidence = f.confidence or 0.5
|
|
581
|
+
|
|
582
|
+
# Rule 1: Has source URL → keep UNLESS it's a person target
|
|
583
|
+
# where ≤1 of 2+ name parts matches (given-name-only noise).
|
|
584
|
+
# Given names (like "Ildikó") are too common; a finding that
|
|
585
|
+
# matches ONLY the given name and not the surname is a different
|
|
586
|
+
# person. No confidence threshold — scrapers assign uniform
|
|
587
|
+
# high confidence, so we gate on name-part overlap instead.
|
|
588
|
+
has_source = f.source_url and f.source_url.startswith("http")
|
|
589
|
+
if has_source:
|
|
590
|
+
if profile.target_type == "person" and profile.name_part_tokens and len(profile.name_part_tokens) >= 2:
|
|
591
|
+
# Surname check: count how many name parts have token overlap
|
|
592
|
+
combined_text = f"{f.title or ''} {f.description or ''}"
|
|
593
|
+
text_tokens = _tokenize(combined_text)
|
|
594
|
+
parts_matched = sum(
|
|
595
|
+
1 for pt in profile.name_part_tokens if pt & text_tokens
|
|
596
|
+
)
|
|
597
|
+
# If ≤1 name part matches out of 2+ → it's a different person
|
|
598
|
+
# (unless ALL parts matched, which means full name overlap)
|
|
599
|
+
if parts_matched <= 1:
|
|
600
|
+
dropped.append(f)
|
|
601
|
+
continue
|
|
602
|
+
kept.append(f)
|
|
603
|
+
continue
|
|
604
|
+
|
|
605
|
+
# Rule 2: High confidence → always keep (don't risk false positives)
|
|
606
|
+
if confidence >= 0.65:
|
|
607
|
+
kept.append(f)
|
|
608
|
+
continue
|
|
609
|
+
|
|
610
|
+
# Rule 3: Compute relevance
|
|
611
|
+
combined_text = f"{f.title or ''} {f.description or ''}"
|
|
612
|
+
score = relevance_score(profile, combined_text)
|
|
613
|
+
|
|
614
|
+
# Rule 4: Only drop if ZERO relevance + low confidence
|
|
615
|
+
if score <= min_score:
|
|
616
|
+
dropped.append(f)
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
kept.append(f)
|
|
620
|
+
|
|
621
|
+
return (kept, dropped)
|