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,555 @@
|
|
|
1
|
+
"""Entity resolution — deduplicate, merge aliases, propagate confidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import re
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("watson.resolution")
|
|
10
|
+
|
|
11
|
+
# ── Data model ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ResolvedEntity:
|
|
15
|
+
canonical: str # best display form
|
|
16
|
+
etype: str # person | email | handle | domain | company | other
|
|
17
|
+
core: str # identity fingerprint
|
|
18
|
+
aliases: set[str] = field(default_factory=set)
|
|
19
|
+
finding_ids: set[str] = field(default_factory=set)
|
|
20
|
+
agents: set[str] = field(default_factory=set)
|
|
21
|
+
confidence: float = 0.0 # resolved confidence after propagation
|
|
22
|
+
link_reasons: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"canonical": self.canonical,
|
|
27
|
+
"type": self.etype,
|
|
28
|
+
"aliases": sorted(self.aliases),
|
|
29
|
+
"source_count": len(self.agents),
|
|
30
|
+
"finding_count": len(self.finding_ids),
|
|
31
|
+
"agents": sorted(self.agents),
|
|
32
|
+
"confidence": round(self.confidence, 3),
|
|
33
|
+
"tier": _tier(self.confidence),
|
|
34
|
+
"link_reasons": self.link_reasons,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tier(c: float) -> str:
|
|
39
|
+
if c >= 0.95: return "CONFIRMED"
|
|
40
|
+
if c >= 0.70: return "PROBABLE"
|
|
41
|
+
if c >= 0.40: return "POSSIBLE"
|
|
42
|
+
if c >= 0.10: return "UNLIKELY"
|
|
43
|
+
return "UNSUBSTANTIATED"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Entity extraction ────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
_EXTRACT_EMAIL = re.compile(r"\b([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})\b", re.I)
|
|
49
|
+
_EXTRACT_PERSON = re.compile(r"\b([A-Z][a-z]+(?: [A-Z][a-z]+){1,2})\b")
|
|
50
|
+
_EXTRACT_DOMAIN = re.compile(r"\b([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:com|org|net|io|gov|ru|cn|de|uk|fr))\b", re.I)
|
|
51
|
+
|
|
52
|
+
# Organization indicators — suffixes that signal an organization, not a person
|
|
53
|
+
_ORG_INDICATORS = {
|
|
54
|
+
"Inc", "LLC", "Ltd", "Corp", "Corporation", "GmbH", "SA", "AG", "PLC",
|
|
55
|
+
"Group", "Holdings", "Limited", "Solutions", "Technologies", "Systems",
|
|
56
|
+
"Partners", "Capital", "Ventures", "Enterprises", "Industries", "Associates",
|
|
57
|
+
"International", "Global", "Bank", "Foundation", "Institute", "University",
|
|
58
|
+
"College", "School", "Academy", "Hospital", "Media", "Network", "Agency",
|
|
59
|
+
"Authority", "Department", "Ministry", "Bureau", "Office", "Commission",
|
|
60
|
+
"Council", "Committee", "Organization", "Association", "Federation",
|
|
61
|
+
"Union", "Alliance", "Coalition", "Party", "Movement",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Common first names — if first token is a first name, entity is probably a person
|
|
65
|
+
_PERSON_FIRST_NAMES = {
|
|
66
|
+
"Dmitry", "Dmitri", "Vladimir", "Sergey", "Alexei", "Mikhail", "Nikolai",
|
|
67
|
+
"Ivan", "Andrei", "Alexander", "Boris", "Yuri", "Viktor", "Pavel", "Anton",
|
|
68
|
+
"Roman", "Denis", "Oleg", "Igor", "Evgeny", "Konstantin", "Maxim", "Artem",
|
|
69
|
+
"Donald", "Jeffrey", "Elon", "Bill", "Steve", "Mark", "John", "David",
|
|
70
|
+
"Michael", "Robert", "James", "William", "Richard", "Joseph", "Thomas",
|
|
71
|
+
"Charles", "Christopher", "Daniel", "Matthew", "Anthony", "George",
|
|
72
|
+
"Lorenzo", "Giovanni", "Marco", "Andrea", "Francesco", "Alessandro",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_organization_name(name: str) -> bool:
|
|
77
|
+
"""Heuristic: does this name look like an organization?"""
|
|
78
|
+
tokens = name.split()
|
|
79
|
+
# If last token is an org indicator, it's an org
|
|
80
|
+
if tokens[-1] in _ORG_INDICATORS:
|
|
81
|
+
return True
|
|
82
|
+
# If it contains an org indicator anywhere
|
|
83
|
+
if any(t in _ORG_INDICATORS for t in tokens):
|
|
84
|
+
return True
|
|
85
|
+
# If name has 1 token and it's not a first name
|
|
86
|
+
if len(tokens) == 1 and tokens[0] not in _PERSON_FIRST_NAMES:
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _entity_has_digits(name: str) -> bool:
|
|
92
|
+
"""People don't have version numbers."""
|
|
93
|
+
return bool(re.search(r"\d", name))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Noise phrases — things that should never become entities
|
|
97
|
+
_NOISE_PHRASES = {
|
|
98
|
+
"Public Affairs", "Key Takeaways", "Not Found", "Contact Us",
|
|
99
|
+
"Terms of Service", "Privacy Policy", "All Rights Reserved",
|
|
100
|
+
"Learn More", "Read More", "Click Here", "Subscribe",
|
|
101
|
+
"Related Articles", "Recommended", "Trending", "Popular",
|
|
102
|
+
"Breaking News", "Latest News", "Top Stories", "Featured",
|
|
103
|
+
"Advertisement", "Sponsored", "Cookie Policy", "Accept Cookies",
|
|
104
|
+
"Log In", "Sign Up", "Join Facebook", "Instagram Lite",
|
|
105
|
+
"Update Substack", "Contact Uploading", "User Agreement",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Bad tokens — if any token in an entity matches these, it's NOT a person.
|
|
109
|
+
# Trade-off: rare surnames like "Hong" are lost to prevent "Hong Kong" as person.
|
|
110
|
+
_BAD_TOKENS = {
|
|
111
|
+
# Geographic
|
|
112
|
+
"hong", "kong", "holy", "land", "crusades",
|
|
113
|
+
# Web/app chrome
|
|
114
|
+
"app", "store", "chat", "developer", "timeline",
|
|
115
|
+
# News/org suffixes
|
|
116
|
+
"news", "emails", "email", "media", "network", "agency",
|
|
117
|
+
# Tech companies
|
|
118
|
+
"cloudflare", "microsoft", "bing", "google", "facebook",
|
|
119
|
+
"instagram", "twitter", "reddit", "substack",
|
|
120
|
+
# Political/geographic
|
|
121
|
+
"russian", "state", "supporters", "empire", "flippers",
|
|
122
|
+
# Tech terms
|
|
123
|
+
"deepfake", "video", "makers", "big", "role", "manage",
|
|
124
|
+
"preferences", "open", "mic", "crushai",
|
|
125
|
+
# Articles
|
|
126
|
+
"the", "this", "that", "these", "those",
|
|
127
|
+
# Web chrome words
|
|
128
|
+
"cookie", "policy", "uploading", "join", "lite", "update",
|
|
129
|
+
"contact", "agreement", "sign", "log",
|
|
130
|
+
# LLM-generated garbage (observed from dark/gap phases)
|
|
131
|
+
"arrested", "cartel", "super", "longman", "pronunciation",
|
|
132
|
+
"dictionary", "pearson", "ransomware", "breach", "jail",
|
|
133
|
+
"primary", "target", "final", "assessment", "sand", "dune",
|
|
134
|
+
"padel", "financial", "investigation", "geospatial", "analysis",
|
|
135
|
+
"language", "search", "results", "offshore", "company",
|
|
136
|
+
"infrastructure", "identified", "cybercrime", "indicators",
|
|
137
|
+
"digital", "footprint", "presence", "detected", "overall",
|
|
138
|
+
"confidence", "forum", "discussions", "mentions",
|
|
139
|
+
"marketplaces", "mixing", "services", "private", "channels",
|
|
140
|
+
"leak", "pastebin", "extradited", "wanted", "sanctioned",
|
|
141
|
+
"sanctions", "evasion", "relevance", "lieutenant", "active",
|
|
142
|
+
# Verdict/sentencing terms that shouldn't be entities
|
|
143
|
+
"charges", "conviction", "sentence", "sentencing", "remarks",
|
|
144
|
+
"pleaded", "guilty", "court", "criminal", "legal",
|
|
145
|
+
"victim", "murder", "organisation", "affiliation",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Article prefixes that disqualify person names
|
|
149
|
+
_ARTICLE_PREFIXES = {"The", "A", "An"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_entity_noise(text: str) -> bool:
|
|
153
|
+
"""Filter out garbage that looks like entities but isn't."""
|
|
154
|
+
t = text.strip()
|
|
155
|
+
if len(t) < 3 or len(t) > 80:
|
|
156
|
+
return True
|
|
157
|
+
if t in _NOISE_PHRASES:
|
|
158
|
+
return True
|
|
159
|
+
if t.startswith(("http://", "https://", "www.")):
|
|
160
|
+
return True
|
|
161
|
+
# Python module paths
|
|
162
|
+
if t.startswith(("watson.", "src.watson.", "watson/", "src/")):
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _has_bad_token(name: str) -> bool:
|
|
168
|
+
"""Check if any token in the name is in the bad tokens denylist."""
|
|
169
|
+
tokens = name.lower().replace(".", " ").split()
|
|
170
|
+
return any(tok in _BAD_TOKENS for tok in tokens)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _has_article_prefix(name: str) -> bool:
|
|
174
|
+
"""Check if name starts with an article (The, A, An) — disqualifies as person."""
|
|
175
|
+
tokens = name.split()
|
|
176
|
+
if tokens and tokens[0] in _ARTICLE_PREFIXES and len(tokens) > 1:
|
|
177
|
+
return True
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _extract_entities_from_text(text: str) -> list[tuple[str, str]]:
|
|
182
|
+
"""Pull (value, type) entity candidates from text."""
|
|
183
|
+
out: list[tuple[str, str]] = []
|
|
184
|
+
if not text:
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
for m in _EXTRACT_EMAIL.finditer(text):
|
|
188
|
+
val = m.group(1)
|
|
189
|
+
if not _is_entity_noise(val):
|
|
190
|
+
out.append((val, "email"))
|
|
191
|
+
|
|
192
|
+
for m in _EXTRACT_DOMAIN.finditer(text):
|
|
193
|
+
val = m.group(1)
|
|
194
|
+
if not _is_entity_noise(val):
|
|
195
|
+
out.append((val, "domain"))
|
|
196
|
+
|
|
197
|
+
for m in _EXTRACT_PERSON.finditer(text):
|
|
198
|
+
val = m.group(1)
|
|
199
|
+
if _is_entity_noise(val):
|
|
200
|
+
continue
|
|
201
|
+
if _has_article_prefix(val):
|
|
202
|
+
continue
|
|
203
|
+
if _has_bad_token(val):
|
|
204
|
+
continue
|
|
205
|
+
if _is_organization_name(val):
|
|
206
|
+
continue
|
|
207
|
+
out.append((val, "person"))
|
|
208
|
+
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── Public API (test-compatible) ──────────────────────────────
|
|
213
|
+
|
|
214
|
+
def resolve_entities(findings: list) -> list[ResolvedEntity]:
|
|
215
|
+
"""Resolve and deduplicate entities from findings.
|
|
216
|
+
|
|
217
|
+
Returns resolved entities (without cross-reference patterns).
|
|
218
|
+
"""
|
|
219
|
+
entities, _ = build_intelligence_picture(findings)
|
|
220
|
+
return entities
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def propagate_confidence(findings: list, entities: list[ResolvedEntity]) -> list[ResolvedEntity]:
|
|
224
|
+
"""Propagate and adjust confidence based on corroboration.
|
|
225
|
+
|
|
226
|
+
- Single-source entities are capped below CONFIRMED (0.85 max)
|
|
227
|
+
- Multi-source entities get boosted by corroboration
|
|
228
|
+
"""
|
|
229
|
+
for e in entities:
|
|
230
|
+
n_sources = len(e.agents) if e.agents else len(e.finding_ids)
|
|
231
|
+
if n_sources <= 1:
|
|
232
|
+
# Single source — cap below CONFIRMED
|
|
233
|
+
e.confidence = min(e.confidence, 0.85)
|
|
234
|
+
else:
|
|
235
|
+
# Multi-source — boost by corroboration
|
|
236
|
+
boost = min(0.15, (n_sources - 1) * 0.05)
|
|
237
|
+
e.confidence = min(0.99, e.confidence + boost)
|
|
238
|
+
return entities
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cross_reference_advanced(
|
|
242
|
+
findings: list,
|
|
243
|
+
blocked: list | None = None,
|
|
244
|
+
) -> list[dict]:
|
|
245
|
+
"""Cross-reference findings and blocked vectors.
|
|
246
|
+
|
|
247
|
+
Returns patterns list with typed dicts:
|
|
248
|
+
- entity_corroboration: same entity across multiple agents
|
|
249
|
+
- adversarial_posture: blocked tools indicating target countermeasures
|
|
250
|
+
- confidence_summary: aggregate confidence stats
|
|
251
|
+
"""
|
|
252
|
+
patterns = []
|
|
253
|
+
|
|
254
|
+
# Build intelligence picture from findings
|
|
255
|
+
if findings:
|
|
256
|
+
entities, entity_patterns = build_intelligence_picture(findings)
|
|
257
|
+
|
|
258
|
+
# Entity corroboration patterns
|
|
259
|
+
for e in entities:
|
|
260
|
+
if len(e.agents) >= 2 or len(e.finding_ids) >= 2:
|
|
261
|
+
patterns.append({
|
|
262
|
+
"type": "entity_corroboration",
|
|
263
|
+
"entity": e.canonical,
|
|
264
|
+
"entity_type": e.etype,
|
|
265
|
+
"sources": sorted(e.agents) if e.agents else [],
|
|
266
|
+
"confidence": e.confidence,
|
|
267
|
+
"count": len(e.finding_ids),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
# Add entity patterns from build_intelligence_picture
|
|
271
|
+
for p in entity_patterns:
|
|
272
|
+
if p.get("type") == "shared_source":
|
|
273
|
+
patterns.append({
|
|
274
|
+
"type": "entity_corroboration",
|
|
275
|
+
"entity": f"{p['source']} ↔ {p['target']}",
|
|
276
|
+
"connection": p.get("connection", ""),
|
|
277
|
+
"strength": p.get("strength", 0),
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
# Confidence summary
|
|
281
|
+
total = len(findings)
|
|
282
|
+
high = sum(1 for f in findings if getattr(f, "confidence", 0) >= 0.70)
|
|
283
|
+
mid = sum(1 for f in findings if 0.40 <= getattr(f, "confidence", 0) < 0.70)
|
|
284
|
+
low = sum(1 for f in findings if getattr(f, "confidence", 0) < 0.40)
|
|
285
|
+
patterns.append({
|
|
286
|
+
"type": "confidence_summary",
|
|
287
|
+
"total_findings": total,
|
|
288
|
+
"high_confidence_count": high,
|
|
289
|
+
"medium_confidence_count": mid,
|
|
290
|
+
"low_confidence_count": low,
|
|
291
|
+
})
|
|
292
|
+
else:
|
|
293
|
+
# Empty findings — still return empty confidence summary
|
|
294
|
+
patterns.append({
|
|
295
|
+
"type": "confidence_summary",
|
|
296
|
+
"total_findings": 0,
|
|
297
|
+
"high_confidence_count": 0,
|
|
298
|
+
"medium_confidence_count": 0,
|
|
299
|
+
"low_confidence_count": 0,
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
# Adversarial posture from blocked tools
|
|
303
|
+
if blocked:
|
|
304
|
+
for b in blocked:
|
|
305
|
+
if b.get("is_intelligence"):
|
|
306
|
+
patterns.append({
|
|
307
|
+
"type": "adversarial_posture",
|
|
308
|
+
"agent": b.get("agent", "unknown"),
|
|
309
|
+
"failure_reason": b.get("failure_reason", "unknown"),
|
|
310
|
+
"alternatives": b.get("alternatives", []),
|
|
311
|
+
"assessment": "Target may have active countermeasures",
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
return patterns
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ── Entity classification helpers (test-compatible) ──────────
|
|
318
|
+
|
|
319
|
+
def _classify_entity(text: str) -> str:
|
|
320
|
+
"""Classify an entity string by type."""
|
|
321
|
+
import re
|
|
322
|
+
text = text.strip()
|
|
323
|
+
|
|
324
|
+
# Email
|
|
325
|
+
if re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', text):
|
|
326
|
+
return "email"
|
|
327
|
+
|
|
328
|
+
# Domain
|
|
329
|
+
if re.match(r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$', text, re.I):
|
|
330
|
+
return "domain"
|
|
331
|
+
|
|
332
|
+
# Person (2+ capitalized words)
|
|
333
|
+
if re.match(r'^[A-Z][a-z]+ [A-Z][a-z]+', text):
|
|
334
|
+
return "person"
|
|
335
|
+
|
|
336
|
+
# Handle (contains underscore, digits, or @)
|
|
337
|
+
if '_' in text or '@' in text or any(c.isdigit() for c in text):
|
|
338
|
+
return "handle"
|
|
339
|
+
|
|
340
|
+
return "other"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _normalize(text: str, etype: str) -> str:
|
|
344
|
+
"""Normalize an entity for deduplication."""
|
|
345
|
+
text = text.strip().lower()
|
|
346
|
+
|
|
347
|
+
if etype == "email":
|
|
348
|
+
# Gmail: remove dots from local part
|
|
349
|
+
if "@" in text:
|
|
350
|
+
local, domain = text.split("@", 1)
|
|
351
|
+
if domain == "gmail.com":
|
|
352
|
+
local = local.replace(".", "")
|
|
353
|
+
# For cross-type matching, normalize to just the local part
|
|
354
|
+
# so email "baron.lorenzo99@gmail.com" → "baronlorenzo99"
|
|
355
|
+
# This allows email+handle merging
|
|
356
|
+
return local
|
|
357
|
+
return text
|
|
358
|
+
|
|
359
|
+
if etype == "handle":
|
|
360
|
+
# Normalize separators (., _ → nothing)
|
|
361
|
+
return text.replace(".", "").replace("_", "")
|
|
362
|
+
|
|
363
|
+
return text
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _person_token_overlap(name: str, handle: str) -> bool:
|
|
367
|
+
"""Check if a person name and handle share enough tokens to be the same entity."""
|
|
368
|
+
name_tokens = [t.lower() for t in name.replace(".", " ").split() if len(t) > 2]
|
|
369
|
+
handle_lower = handle.lower()
|
|
370
|
+
if not name_tokens:
|
|
371
|
+
return False
|
|
372
|
+
# Check if any name token appears as a substring in the handle
|
|
373
|
+
for token in name_tokens:
|
|
374
|
+
if token in handle_lower:
|
|
375
|
+
return True
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def _fingerprint(text: str) -> str:
|
|
379
|
+
"""Create a stable identity fingerprint."""
|
|
380
|
+
return hashlib.sha256(text.lower().strip().encode()).hexdigest()[:16]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def build_intelligence_picture(
|
|
384
|
+
findings: list,
|
|
385
|
+
) -> tuple[list[ResolvedEntity], list]:
|
|
386
|
+
"""Build resolved entity list from findings. Returns (entities, link_patterns)."""
|
|
387
|
+
|
|
388
|
+
# Step 1: Extract raw entities from all findings
|
|
389
|
+
# (name, type, finding_title, confidence, agent)
|
|
390
|
+
raw_entities: list[tuple[str, str, str, float, str]] = []
|
|
391
|
+
|
|
392
|
+
for f in findings:
|
|
393
|
+
fid = getattr(f, "title", str(f))[:60]
|
|
394
|
+
conf = getattr(f, "confidence", 0.5)
|
|
395
|
+
agent = getattr(f, "agent", None)
|
|
396
|
+
agent_name = agent.value if hasattr(agent, "value") else str(agent) if agent else ""
|
|
397
|
+
|
|
398
|
+
# Use structured entities field if available
|
|
399
|
+
ents = getattr(f, "entities", None)
|
|
400
|
+
if ents and isinstance(ents, list):
|
|
401
|
+
for e in ents:
|
|
402
|
+
if isinstance(e, dict):
|
|
403
|
+
name = e.get("value", e.get("name", ""))
|
|
404
|
+
etype = e.get("type", "other")
|
|
405
|
+
elif isinstance(e, (list, tuple)) and len(e) >= 2:
|
|
406
|
+
name, etype = e[0], e[1]
|
|
407
|
+
else:
|
|
408
|
+
name, etype = str(e), "other"
|
|
409
|
+
if name and not _is_entity_noise(name):
|
|
410
|
+
raw_entities.append((name, etype, fid, conf, agent_name))
|
|
411
|
+
else:
|
|
412
|
+
# Fallback: extract from text — but only for non-trash findings
|
|
413
|
+
conf = getattr(f, "confidence", 0.5)
|
|
414
|
+
tier = getattr(f, "tier", "")
|
|
415
|
+
if conf >= 0.35 or tier in ("PRIMARY", "SECONDARY", "PROBABLE", "CONFIRMED"):
|
|
416
|
+
text = f"{getattr(f, 'title', '')} {getattr(f, 'description', '')}"
|
|
417
|
+
for name, etype in _extract_entities_from_text(text):
|
|
418
|
+
raw_entities.append((name, etype, fid, conf, agent_name))
|
|
419
|
+
|
|
420
|
+
if not raw_entities:
|
|
421
|
+
return [], []
|
|
422
|
+
|
|
423
|
+
# Step 2: Classify and normalize each entity
|
|
424
|
+
classified = []
|
|
425
|
+
for name, etype, fid, conf, agent in raw_entities:
|
|
426
|
+
# If type is not given or "other", classify
|
|
427
|
+
if etype in ("other", ""):
|
|
428
|
+
etype = _classify_entity(name)
|
|
429
|
+
|
|
430
|
+
# Normalize for grouping
|
|
431
|
+
normalized = _normalize(name, etype)
|
|
432
|
+
|
|
433
|
+
# For handles, also check if they match any known person name
|
|
434
|
+
classified.append((name, etype, normalized, fid, conf, agent))
|
|
435
|
+
|
|
436
|
+
# Step 3: Group by normalized form
|
|
437
|
+
groups: dict[str, list[tuple[str, str, str, str, float, str]]] = {}
|
|
438
|
+
for name, etype, norm, fid, conf, agent in classified:
|
|
439
|
+
if norm not in groups:
|
|
440
|
+
groups[norm] = []
|
|
441
|
+
groups[norm].append((name, etype, norm, fid, conf, agent))
|
|
442
|
+
|
|
443
|
+
# Step 3b: Cross-type merging — persons linked to handles/emails
|
|
444
|
+
# If a person name has token overlap with a handle, merge them
|
|
445
|
+
merge_map: dict[str, str] = {} # norm → target_norm
|
|
446
|
+
person_norms = {n: entries for n, entries in groups.items()
|
|
447
|
+
if any(e[1] == "person" for e in entries)}
|
|
448
|
+
handle_norms = {n: entries for n, entries in groups.items()
|
|
449
|
+
if any(e[1] in ("handle", "email") for e in entries)}
|
|
450
|
+
|
|
451
|
+
for p_norm, p_entries in person_norms.items():
|
|
452
|
+
for h_norm, h_entries in handle_norms.items():
|
|
453
|
+
if p_norm == h_norm:
|
|
454
|
+
continue
|
|
455
|
+
# Check if any person name overlaps with any handle
|
|
456
|
+
for pe in p_entries:
|
|
457
|
+
p_name = pe[0]
|
|
458
|
+
for he in h_entries:
|
|
459
|
+
h_name = he[0]
|
|
460
|
+
if _person_token_overlap(p_name, h_name):
|
|
461
|
+
# Merge handle into person (or vice versa)
|
|
462
|
+
merge_map[h_norm] = p_norm
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
# Apply merges
|
|
466
|
+
if merge_map:
|
|
467
|
+
merged_groups: dict[str, list] = {}
|
|
468
|
+
for norm, entries in groups.items():
|
|
469
|
+
target = merge_map.get(norm, norm)
|
|
470
|
+
# Follow chain
|
|
471
|
+
while target in merge_map and target != merge_map[target]:
|
|
472
|
+
target = merge_map[target]
|
|
473
|
+
if target not in merged_groups:
|
|
474
|
+
merged_groups[target] = []
|
|
475
|
+
merged_groups[target].extend(entries)
|
|
476
|
+
groups = merged_groups
|
|
477
|
+
|
|
478
|
+
# Step 4: Resolve each group
|
|
479
|
+
resolved = []
|
|
480
|
+
for norm, entries in groups.items():
|
|
481
|
+
# Pick canonical name (longest, non-empty)
|
|
482
|
+
names = sorted(set(e[0] for e in entries), key=len, reverse=True)
|
|
483
|
+
canonical = names[0] if names else entries[0][0]
|
|
484
|
+
|
|
485
|
+
# Pick best type
|
|
486
|
+
types = [e[1] for e in entries if e[1] not in ("other", "")]
|
|
487
|
+
etype = types[0] if types else "other"
|
|
488
|
+
|
|
489
|
+
# If entity has digits, it's NOT a person
|
|
490
|
+
if etype == "person" and _entity_has_digits(canonical):
|
|
491
|
+
etype = "other"
|
|
492
|
+
# If it looks like an org, it's not a person
|
|
493
|
+
if etype == "person" and _is_organization_name(canonical):
|
|
494
|
+
etype = "company"
|
|
495
|
+
|
|
496
|
+
# Compute confidence — weighted by source count
|
|
497
|
+
n_sources = len(set(e[3] for e in entries))
|
|
498
|
+
avg_conf = sum(e[4] for e in entries) / len(entries)
|
|
499
|
+
# Boost: more sources = higher confidence
|
|
500
|
+
confidence = min(0.99, avg_conf + (n_sources - 1) * 0.05)
|
|
501
|
+
|
|
502
|
+
# Collect agents
|
|
503
|
+
agents = set(e[5] for e in entries if e[5])
|
|
504
|
+
|
|
505
|
+
resolved.append(ResolvedEntity(
|
|
506
|
+
canonical=canonical,
|
|
507
|
+
etype=etype,
|
|
508
|
+
core=norm,
|
|
509
|
+
aliases=set(n for n, _, _, _, _, _ in entries),
|
|
510
|
+
finding_ids=set(e[3] for e in entries),
|
|
511
|
+
agents=agents,
|
|
512
|
+
confidence=confidence,
|
|
513
|
+
link_reasons=[f"Resolved from {n_sources} sources"],
|
|
514
|
+
))
|
|
515
|
+
|
|
516
|
+
# Sort by confidence
|
|
517
|
+
resolved.sort(key=lambda e: e.confidence, reverse=True)
|
|
518
|
+
|
|
519
|
+
# Step 5: Cross-reference patterns
|
|
520
|
+
patterns = _build_cross_references(resolved, classified)
|
|
521
|
+
|
|
522
|
+
# Add entity_resolution patterns
|
|
523
|
+
for e in resolved:
|
|
524
|
+
if e.aliases:
|
|
525
|
+
patterns.append({
|
|
526
|
+
"type": "entity_resolution",
|
|
527
|
+
"entity": e.canonical,
|
|
528
|
+
"aliases": sorted(e.aliases)[:5],
|
|
529
|
+
"sources": len(e.finding_ids),
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
return resolved, patterns[:20]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _build_cross_references(
|
|
536
|
+
resolved: list[ResolvedEntity],
|
|
537
|
+
raw: list[tuple[str, str, str, float]],
|
|
538
|
+
) -> list[dict]:
|
|
539
|
+
"""Find connections between resolved entities."""
|
|
540
|
+
patterns = []
|
|
541
|
+
|
|
542
|
+
for i, e1 in enumerate(resolved):
|
|
543
|
+
for e2 in resolved[i + 1:]:
|
|
544
|
+
# Entities that share finding_ids are connected
|
|
545
|
+
shared = e1.finding_ids & e2.finding_ids
|
|
546
|
+
if shared:
|
|
547
|
+
patterns.append({
|
|
548
|
+
"source": e1.canonical,
|
|
549
|
+
"target": e2.canonical,
|
|
550
|
+
"connection": f"Shared {len(shared)} sources",
|
|
551
|
+
"type": "shared_source",
|
|
552
|
+
"strength": len(shared),
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
return patterns[:20]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Article scraper — extract real text from web pages, not cookie banners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("watson.scraper")
|
|
8
|
+
|
|
9
|
+
# ── 3-stage extraction ────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
_STRIP_PATTERNS = [
|
|
12
|
+
re.compile(r"I Accept Cookies|Accept All Cookies|Cookie Settings|Manage Cookies", re.I),
|
|
13
|
+
re.compile(r"We use cookies|This site uses cookies|By continuing", re.I),
|
|
14
|
+
re.compile(r"Subscribe to our newsletter|Sign up for our newsletter", re.I),
|
|
15
|
+
re.compile(r"Advertisement|Sponsored Content|Promoted", re.I),
|
|
16
|
+
re.compile(r"Share on (?:Facebook|Twitter|LinkedIn|Reddit)", re.I),
|
|
17
|
+
re.compile(r"All (?:rights reserved|products featured).*?(?:independently|selected).*?[.]", re.I),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def extract_content(html: str, url: str = "") -> str:
|
|
21
|
+
"""Extract readable text from HTML. 3-stage pipeline."""
|
|
22
|
+
if not html:
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
# Stage 1: Try content selectors (article, main, etc.)
|
|
26
|
+
content = _extract_by_selectors(html)
|
|
27
|
+
if content and len(content) > 200:
|
|
28
|
+
return _clean_text(content)
|
|
29
|
+
|
|
30
|
+
# Stage 2: Aggregate <p> tags
|
|
31
|
+
content = _extract_paragraphs(html)
|
|
32
|
+
if content and len(content) > 200:
|
|
33
|
+
return _clean_text(content)
|
|
34
|
+
|
|
35
|
+
# Stage 3: Extract body text with noise stripping
|
|
36
|
+
content = _extract_body(html)
|
|
37
|
+
return _clean_text(content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_by_selectors(html: str) -> str:
|
|
41
|
+
"""Try known content containers."""
|
|
42
|
+
selectors = [
|
|
43
|
+
r'<article[^>]*>(.*?)</article>',
|
|
44
|
+
r'<main[^>]*>(.*?)</main>',
|
|
45
|
+
r'<div[^>]*class="[^"]*content[^"]*"[^>]*>(.*?)</div>',
|
|
46
|
+
r'<div[^>]*class="[^"]*article[^"]*"[^>]*>(.*?)</div>',
|
|
47
|
+
r'<div[^>]*id="content"[^>]*>(.*?)</div>',
|
|
48
|
+
r'<div[^>]*class="[^"]*post[^"]*"[^>]*>(.*?)</div>',
|
|
49
|
+
r'<section[^>]*>(.*?)</section>',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
for pattern in selectors:
|
|
53
|
+
m = re.search(pattern, html, re.DOTALL | re.I)
|
|
54
|
+
if m:
|
|
55
|
+
text = re.sub(r'<[^>]+>', ' ', m.group(1))
|
|
56
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
57
|
+
if len(text) > 200:
|
|
58
|
+
return text
|
|
59
|
+
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extract_paragraphs(html: str) -> str:
|
|
64
|
+
"""Extract all <p> tag text."""
|
|
65
|
+
paragraphs = re.findall(r'<p[^>]*>(.*?)</p>', html, re.DOTALL | re.I)
|
|
66
|
+
texts = []
|
|
67
|
+
for p in paragraphs:
|
|
68
|
+
text = re.sub(r'<[^>]+>', ' ', p)
|
|
69
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
70
|
+
if len(text) > 20:
|
|
71
|
+
texts.append(text)
|
|
72
|
+
return ' '.join(texts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_body(html: str) -> str:
|
|
76
|
+
"""Extract body, strip scripts, styles, navigation."""
|
|
77
|
+
# Remove script and style blocks
|
|
78
|
+
html = re.sub(r'<(script|style|nav|header|footer)[^>]*>.*?</\1>', '', html, flags=re.DOTALL | re.I)
|
|
79
|
+
# Remove remaining tags
|
|
80
|
+
text = re.sub(r'<[^>]+>', ' ', html)
|
|
81
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _clean_text(text: str) -> str:
|
|
86
|
+
"""Strip noise patterns from extracted text."""
|
|
87
|
+
for pattern in _STRIP_PATTERNS:
|
|
88
|
+
text = pattern.sub('', text)
|
|
89
|
+
# Collapse whitespace
|
|
90
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
91
|
+
# Truncate at max chars
|
|
92
|
+
if len(text) > 4000:
|
|
93
|
+
text = text[:4000] + "..."
|
|
94
|
+
return text
|