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,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