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,398 @@
|
|
|
1
|
+
"""Wikidata SPARQL tool — corporate ownership, sanctions, key people via structured knowledge graph.
|
|
2
|
+
|
|
3
|
+
Wikidata is the structured-data backbone of Wikipedia. Every company, person,
|
|
4
|
+
and entity has a unique Q-ID with typed properties — we can query ownership
|
|
5
|
+
chains, sanctions designations, board members, and financial data without
|
|
6
|
+
ever hitting a paywall or Cloudflare block.
|
|
7
|
+
|
|
8
|
+
Free tier: always free. No API key needed. SPARQL endpoint is public.
|
|
9
|
+
Rate limit: ~5 req/s (generous — be respectful).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .base import OSINTTool
|
|
20
|
+
from .registry import registry
|
|
21
|
+
from ..core.models import Finding, FindingSeverity, FindingSource
|
|
22
|
+
|
|
23
|
+
# ── Shared httpx client (avoid BaseHTTPClient deadlock in parallel) ─
|
|
24
|
+
|
|
25
|
+
_client: Optional[httpx.AsyncClient] = None
|
|
26
|
+
_client_lock = asyncio.Lock()
|
|
27
|
+
_semaphore = asyncio.Semaphore(3) # Max 3 concurrent SPARQL queries
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _get_client() -> httpx.AsyncClient:
|
|
31
|
+
global _client
|
|
32
|
+
if _client is None:
|
|
33
|
+
async with _client_lock:
|
|
34
|
+
if _client is None:
|
|
35
|
+
_client = httpx.AsyncClient(
|
|
36
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
37
|
+
headers={
|
|
38
|
+
"User-Agent": "WatsonOSINT/0.3 (https://github.com/nousresearch/watson-osint)",
|
|
39
|
+
"Accept": "application/json",
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
return _client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── SPARQL query templates ────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
COMPANY_SEARCH = """
|
|
48
|
+
SELECT ?company ?companyLabel ?countryLabel ?industryLabel ?website ?revenue ?employees
|
|
49
|
+
WHERE {{
|
|
50
|
+
?company (rdfs:label|skos:altLabel) "{name}"@en.
|
|
51
|
+
?company wdt:P31/wdt:P279* wd:Q4830453.
|
|
52
|
+
OPTIONAL {{ ?company wdt:P17 ?country. }}
|
|
53
|
+
OPTIONAL {{ ?company wdt:P452 ?industry. }}
|
|
54
|
+
OPTIONAL {{ ?company wdt:P856 ?website. }}
|
|
55
|
+
OPTIONAL {{ ?company wdt:P2139 ?revenue. }}
|
|
56
|
+
OPTIONAL {{ ?company wdt:P1128 ?employees. }}
|
|
57
|
+
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
58
|
+
}}
|
|
59
|
+
LIMIT 5
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
OWNERSHIP_QUERY = """
|
|
63
|
+
SELECT ?owner ?ownerLabel ?ownerTypeLabel ?ownershipPercent
|
|
64
|
+
WHERE {{
|
|
65
|
+
?company (rdfs:label|skos:altLabel) "{name}"@en.
|
|
66
|
+
?company wdt:P127 ?owner.
|
|
67
|
+
OPTIONAL {{ ?owner wdt:P31 ?ownerType. }}
|
|
68
|
+
OPTIONAL {{ ?company p:P127 [pq:P1103 ?ownershipPercent]. }}
|
|
69
|
+
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
70
|
+
}}
|
|
71
|
+
LIMIT 5
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
SUBSIDIARIES_QUERY = """
|
|
75
|
+
SELECT ?sub ?subLabel ?subCountryLabel
|
|
76
|
+
WHERE {{
|
|
77
|
+
?company (rdfs:label|skos:altLabel) "{name}"@en.
|
|
78
|
+
?sub wdt:P749 ?company.
|
|
79
|
+
OPTIONAL {{ ?sub wdt:P17 ?subCountry. }}
|
|
80
|
+
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
81
|
+
}}
|
|
82
|
+
LIMIT 20
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
KEY_PEOPLE_QUERY = """
|
|
86
|
+
SELECT ?person ?personLabel ?roleLabel
|
|
87
|
+
WHERE {{
|
|
88
|
+
?company (rdfs:label|skos:altLabel) "{name}"@en.
|
|
89
|
+
{{ ?company wdt:P169 ?person. BIND("CEO" AS ?roleLabel) }}
|
|
90
|
+
UNION
|
|
91
|
+
{{ ?company wdt:P112 ?person. BIND("Founder" AS ?roleLabel) }}
|
|
92
|
+
UNION
|
|
93
|
+
{{ ?company wdt:P3320 ?person. BIND("Board member" AS ?roleLabel) }}
|
|
94
|
+
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
95
|
+
}}
|
|
96
|
+
LIMIT 10
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
SANCTIONS_QUERY = """
|
|
100
|
+
SELECT ?entity ?entityLabel ?sanctionType ?sanctionListLabel
|
|
101
|
+
WHERE {{
|
|
102
|
+
VALUES ?entityName {{ "{name}" }}
|
|
103
|
+
?entity (rdfs:label|skos:altLabel) ?entityName.
|
|
104
|
+
FILTER(LANG(?entityName) = "en")
|
|
105
|
+
?entity wdt:P31 ?sanctionType.
|
|
106
|
+
FILTER(?sanctionType IN (wd:Q85891580, wd:Q110761879, wd:Q28135121))
|
|
107
|
+
OPTIONAL {{ ?entity wdt:P793 ?sanctionList. }}
|
|
108
|
+
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
109
|
+
}}
|
|
110
|
+
LIMIT 5
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
PERSON_SEARCH = """
|
|
114
|
+
SELECT ?person ?personLabel ?description ?birthDate ?countryLabel
|
|
115
|
+
WHERE {{
|
|
116
|
+
?person (rdfs:label|skos:altLabel) "{name}"@en.
|
|
117
|
+
?person wdt:P31 wd:Q5.
|
|
118
|
+
OPTIONAL {{ ?person schema:description ?description. FILTER(LANG(?description) = "en") }}
|
|
119
|
+
OPTIONAL {{ ?person wdt:P569 ?birthDate. }}
|
|
120
|
+
OPTIONAL {{ ?person wdt:P27 ?country. }}
|
|
121
|
+
SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
|
|
122
|
+
}}
|
|
123
|
+
LIMIT 5
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class WikidataTool(OSINTTool):
|
|
128
|
+
"""Query Wikidata for structured corporate intelligence, ownership, and sanctions."""
|
|
129
|
+
|
|
130
|
+
category = FindingSource.CORPORATE
|
|
131
|
+
name = "wikidata"
|
|
132
|
+
description = "Wikidata SPARQL — corporate ownership, subsidiaries, key people, sanctions, structured entity data"
|
|
133
|
+
free_tier_available = True
|
|
134
|
+
rate_limit_rps = 3.0
|
|
135
|
+
|
|
136
|
+
SPARQL_ENDPOINT = "https://query.wikidata.org/sparql"
|
|
137
|
+
|
|
138
|
+
async def investigate(self, query: str, context: str = "") -> list[Finding]:
|
|
139
|
+
findings: list[Finding] = []
|
|
140
|
+
|
|
141
|
+
name = self._extract_entity(query)
|
|
142
|
+
if not name:
|
|
143
|
+
return findings
|
|
144
|
+
|
|
145
|
+
# Run all SPARQL queries in parallel for speed
|
|
146
|
+
import asyncio
|
|
147
|
+
|
|
148
|
+
tasks = [
|
|
149
|
+
self._run_query("company_search", COMPANY_SEARCH.format(name=self._escape_sparql(name)), name),
|
|
150
|
+
self._run_query("ownership", OWNERSHIP_QUERY.format(name=self._escape_sparql(name)), name),
|
|
151
|
+
self._run_query("subsidiaries", SUBSIDIARIES_QUERY.format(name=self._escape_sparql(name)), name),
|
|
152
|
+
self._run_query("key_people", KEY_PEOPLE_QUERY.format(name=self._escape_sparql(name)), name),
|
|
153
|
+
self._run_query("sanctions", SANCTIONS_QUERY.format(name=self._escape_sparql(name)), name),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
results = await asyncio.gather(*tasks)
|
|
157
|
+
for f in results:
|
|
158
|
+
if f:
|
|
159
|
+
findings.append(f)
|
|
160
|
+
|
|
161
|
+
return findings
|
|
162
|
+
|
|
163
|
+
async def _run_query(self, label: str, sparql: str, target: str) -> Optional[Finding]:
|
|
164
|
+
"""Execute a SPARQL query and format results."""
|
|
165
|
+
max_retries = 2
|
|
166
|
+
for attempt in range(max_retries + 1):
|
|
167
|
+
raw = None
|
|
168
|
+
try:
|
|
169
|
+
async with _semaphore:
|
|
170
|
+
client = await _get_client()
|
|
171
|
+
params = {"format": "json", "query": sparql}
|
|
172
|
+
r = await client.get(self.SPARQL_ENDPOINT, params=params)
|
|
173
|
+
if r.status_code == 429:
|
|
174
|
+
retry_after = int(r.headers.get("Retry-After", "5"))
|
|
175
|
+
if attempt < max_retries:
|
|
176
|
+
await asyncio.sleep(min(retry_after, 3))
|
|
177
|
+
continue
|
|
178
|
+
return None
|
|
179
|
+
r.raise_for_status()
|
|
180
|
+
raw = r.json()
|
|
181
|
+
except Exception:
|
|
182
|
+
if attempt < max_retries:
|
|
183
|
+
await asyncio.sleep(1.5)
|
|
184
|
+
continue
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
if not isinstance(raw, dict):
|
|
188
|
+
continue
|
|
189
|
+
data: dict = raw
|
|
190
|
+
|
|
191
|
+
bindings = data.get("results", {}).get("bindings", [])
|
|
192
|
+
if not bindings:
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
if label == "company_search":
|
|
196
|
+
return self._format_company(bindings, target)
|
|
197
|
+
elif label == "ownership":
|
|
198
|
+
return self._format_ownership(bindings, target)
|
|
199
|
+
elif label == "subsidiaries":
|
|
200
|
+
return self._format_subsidiaries(bindings, target)
|
|
201
|
+
elif label == "key_people":
|
|
202
|
+
return self._format_key_people(bindings, target)
|
|
203
|
+
elif label == "sanctions":
|
|
204
|
+
return self._format_sanctions(bindings, target)
|
|
205
|
+
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
# ── Formatters ────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def _format_company(self, bindings: list[dict], target: str) -> Optional[Finding]:
|
|
211
|
+
lines = []
|
|
212
|
+
evidence_urls = []
|
|
213
|
+
for b in bindings[:5]:
|
|
214
|
+
label = self._val(b, "companyLabel") or self._uri_id(b, "company")
|
|
215
|
+
country = self._val(b, "countryLabel")
|
|
216
|
+
industry = self._val(b, "industryLabel")
|
|
217
|
+
website = self._val(b, "website")
|
|
218
|
+
revenue = self._val(b, "revenue")
|
|
219
|
+
company_uri = b.get("company", {}).get("value", "")
|
|
220
|
+
qid = company_uri.split("/")[-1] if company_uri else ""
|
|
221
|
+
|
|
222
|
+
parts = [f"- **{label}**"]
|
|
223
|
+
if country:
|
|
224
|
+
parts.append(f" ({country})")
|
|
225
|
+
if industry:
|
|
226
|
+
parts.append(f" — {industry}")
|
|
227
|
+
if revenue:
|
|
228
|
+
parts.append(f" — Revenue: {revenue}")
|
|
229
|
+
lines.append(" ".join(parts))
|
|
230
|
+
|
|
231
|
+
if website:
|
|
232
|
+
lines.append(f" 🌐 {website}")
|
|
233
|
+
evidence_urls.append(website)
|
|
234
|
+
if qid:
|
|
235
|
+
lines.append(f" 📊 [Wikidata](https://www.wikidata.org/wiki/{qid})")
|
|
236
|
+
evidence_urls.append(f"https://www.wikidata.org/wiki/{qid}")
|
|
237
|
+
|
|
238
|
+
if not lines:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
return self._make_finding(
|
|
242
|
+
title=f"📊 Wikidata: Company data for '{target}'",
|
|
243
|
+
description="\n".join(lines),
|
|
244
|
+
evidence=evidence_urls[:5],
|
|
245
|
+
confidence=0.90,
|
|
246
|
+
query=target,
|
|
247
|
+
result_count=len(bindings),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _format_ownership(self, bindings: list[dict], target: str) -> Optional[Finding]:
|
|
251
|
+
lines = []
|
|
252
|
+
evidence_urls = []
|
|
253
|
+
for b in bindings[:5]:
|
|
254
|
+
owner = self._val(b, "ownerLabel") or self._uri_id(b, "owner")
|
|
255
|
+
owner_type = self._val(b, "ownerTypeLabel")
|
|
256
|
+
pct = self._val(b, "ownershipPercent")
|
|
257
|
+
owner_uri = b.get("owner", {}).get("value", "")
|
|
258
|
+
owner_qid = owner_uri.split("/")[-1] if owner_uri else ""
|
|
259
|
+
|
|
260
|
+
line = f"- **{owner}**"
|
|
261
|
+
if owner_type:
|
|
262
|
+
line += f" [{owner_type}]"
|
|
263
|
+
if pct:
|
|
264
|
+
line += f" — {pct}"
|
|
265
|
+
lines.append(line)
|
|
266
|
+
if owner_qid:
|
|
267
|
+
evidence_urls.append(f"https://www.wikidata.org/wiki/{owner_qid}")
|
|
268
|
+
|
|
269
|
+
if not lines:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
return self._make_finding(
|
|
273
|
+
title=f"🏛️ Wikidata: Ownership of '{target}'",
|
|
274
|
+
description="\n".join(lines),
|
|
275
|
+
evidence=evidence_urls[:5],
|
|
276
|
+
confidence=0.85,
|
|
277
|
+
query=target,
|
|
278
|
+
result_count=len(bindings),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _format_subsidiaries(self, bindings: list[dict], target: str) -> Optional[Finding]:
|
|
282
|
+
lines = []
|
|
283
|
+
evidence_urls = []
|
|
284
|
+
for b in bindings[:20]:
|
|
285
|
+
sub = self._val(b, "subLabel") or self._uri_id(b, "sub")
|
|
286
|
+
country = self._val(b, "subCountryLabel")
|
|
287
|
+
sub_uri = b.get("sub", {}).get("value", "")
|
|
288
|
+
sub_qid = sub_uri.split("/")[-1] if sub_uri else ""
|
|
289
|
+
|
|
290
|
+
line = f"- **{sub}**"
|
|
291
|
+
if country:
|
|
292
|
+
line += f" ({country})"
|
|
293
|
+
lines.append(line)
|
|
294
|
+
if sub_qid:
|
|
295
|
+
evidence_urls.append(f"https://www.wikidata.org/wiki/{sub_qid}")
|
|
296
|
+
|
|
297
|
+
if not lines:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
return self._make_finding(
|
|
301
|
+
title=f"🏢 Wikidata: {len(bindings)} subsidiaries of '{target}'",
|
|
302
|
+
description="\n".join(lines[:20]),
|
|
303
|
+
evidence=evidence_urls[:5],
|
|
304
|
+
confidence=0.85,
|
|
305
|
+
query=target,
|
|
306
|
+
result_count=len(bindings),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _format_key_people(self, bindings: list[dict], target: str) -> Optional[Finding]:
|
|
310
|
+
lines = []
|
|
311
|
+
evidence_urls = []
|
|
312
|
+
for b in bindings[:10]:
|
|
313
|
+
person = self._val(b, "personLabel") or self._uri_id(b, "person")
|
|
314
|
+
role = self._val(b, "roleLabel") or "Affiliated"
|
|
315
|
+
person_uri = b.get("person", {}).get("value", "")
|
|
316
|
+
person_qid = person_uri.split("/")[-1] if person_uri else ""
|
|
317
|
+
|
|
318
|
+
lines.append(f"- **{person}** — *{role}*")
|
|
319
|
+
if person_qid:
|
|
320
|
+
evidence_urls.append(f"https://www.wikidata.org/wiki/{person_qid}")
|
|
321
|
+
|
|
322
|
+
if not lines:
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
return self._make_finding(
|
|
326
|
+
title=f"👥 Wikidata: Key people — {target}",
|
|
327
|
+
description="\n".join(lines[:10]),
|
|
328
|
+
evidence=evidence_urls[:5],
|
|
329
|
+
confidence=0.90,
|
|
330
|
+
query=target,
|
|
331
|
+
result_count=len(bindings),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _format_sanctions(self, bindings: list[dict], target: str) -> Optional[Finding]:
|
|
335
|
+
lines = []
|
|
336
|
+
evidence_urls = []
|
|
337
|
+
for b in bindings[:5]:
|
|
338
|
+
entity = self._val(b, "entityLabel") or self._uri_id(b, "entity")
|
|
339
|
+
list_name = self._val(b, "sanctionListLabel")
|
|
340
|
+
entity_uri = b.get("entity", {}).get("value", "")
|
|
341
|
+
entity_qid = entity_uri.split("/")[-1] if entity_uri else ""
|
|
342
|
+
|
|
343
|
+
line = f"- 🚨 **{entity}**"
|
|
344
|
+
if list_name:
|
|
345
|
+
line += f" — on: {list_name}"
|
|
346
|
+
lines.append(line)
|
|
347
|
+
if entity_qid:
|
|
348
|
+
evidence_urls.append(f"https://www.wikidata.org/wiki/{entity_qid}")
|
|
349
|
+
|
|
350
|
+
if not lines:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
return self._make_finding(
|
|
354
|
+
title=f"🚨 Wikidata: Sanctions match — '{target}'",
|
|
355
|
+
description="\n".join(lines),
|
|
356
|
+
evidence=evidence_urls[:5],
|
|
357
|
+
confidence=0.95,
|
|
358
|
+
query=target,
|
|
359
|
+
result_count=len(bindings),
|
|
360
|
+
sanction_match=True,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# ── Helpers ───────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def _escape_sparql(self, s: str) -> str:
|
|
366
|
+
"""Escape a string for SPARQL literal (basic — handles quotes)."""
|
|
367
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
368
|
+
|
|
369
|
+
def _val(self, binding: dict, key: str) -> Optional[str]:
|
|
370
|
+
"""Extract a string value from a SPARQL binding."""
|
|
371
|
+
v = binding.get(key, {})
|
|
372
|
+
if isinstance(v, dict):
|
|
373
|
+
return v.get("value")
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
def _uri_id(self, binding: dict, key: str) -> Optional[str]:
|
|
377
|
+
"""Extract the Q-ID or last path segment from a URI."""
|
|
378
|
+
v = binding.get(key, {})
|
|
379
|
+
if isinstance(v, dict):
|
|
380
|
+
uri = v.get("value", "")
|
|
381
|
+
return uri.rsplit("/", 1)[-1].rsplit("#", 1)[-1] if uri else None
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
def _extract_entity(self, query: str) -> Optional[str]:
|
|
385
|
+
"""Extract a company or person name from the query."""
|
|
386
|
+
import re
|
|
387
|
+
# Grab the first capitalized phrase (company/person name)
|
|
388
|
+
name = query.strip().split("\n")[0].strip()
|
|
389
|
+
# Remove common prefixes
|
|
390
|
+
name = re.sub(r'^(investigate|research|look into|check|find|who is|what is)\s+', '', name, flags=re.IGNORECASE).strip()
|
|
391
|
+
if len(name) < 2:
|
|
392
|
+
return None
|
|
393
|
+
return name
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# ── Register ──────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
registry.register(WikidataTool())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utilities package."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Utility helpers — domain extraction, URL parsing, text cleaning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_domain(url: str) -> str:
|
|
10
|
+
"""Extract the domain from a URL, stripping www prefix."""
|
|
11
|
+
if not url.startswith(("http://", "https://")):
|
|
12
|
+
url = f"https://{url}"
|
|
13
|
+
parsed = urlparse(url)
|
|
14
|
+
domain = parsed.netloc or parsed.path.split("/")[0]
|
|
15
|
+
return domain.removeprefix("www.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract_domains(text: str) -> list[str]:
|
|
19
|
+
"""Extract all domains from arbitrary text."""
|
|
20
|
+
pattern = r"(?:https?://)?(?:www\.)?([a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?)"
|
|
21
|
+
matches = re.findall(pattern, text)
|
|
22
|
+
return list(dict.fromkeys(matches)) # Deduplicated, order preserved
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clean_username(text: str) -> str:
|
|
26
|
+
"""Clean and extract a username from text (strip @, URLs, etc.)."""
|
|
27
|
+
text = text.strip().removeprefix("@")
|
|
28
|
+
# If it's a URL, extract the handle
|
|
29
|
+
if "/" in text:
|
|
30
|
+
parts = text.rstrip("/").split("/")
|
|
31
|
+
text = parts[-1] if parts[-1] else parts[-2]
|
|
32
|
+
return text
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_email(text: str) -> bool:
|
|
36
|
+
"""Check if text looks like an email address."""
|
|
37
|
+
return bool(re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", text.strip()))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_url(text: str) -> bool:
|
|
41
|
+
"""Check if text looks like a URL."""
|
|
42
|
+
return bool(re.match(r"^https?://", text.strip()))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def truncate(text: str, max_len: int = 200) -> str:
|
|
46
|
+
"""Truncate text to max_len with ellipsis."""
|
|
47
|
+
if len(text) <= max_len:
|
|
48
|
+
return text
|
|
49
|
+
return text[: max_len - 3] + "..."
|
src/watson/utils/http.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Async HTTP client with retry, rate limiting, and rotating user agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
USER_AGENTS = [
|
|
13
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
14
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
15
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
16
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
|
|
17
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimiter:
|
|
22
|
+
"""Token bucket rate limiter."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, requests_per_second: float = 1.0):
|
|
25
|
+
self.rate = requests_per_second
|
|
26
|
+
self._tokens = requests_per_second
|
|
27
|
+
self._last_refill = time.monotonic()
|
|
28
|
+
|
|
29
|
+
async def acquire(self) -> None:
|
|
30
|
+
"""Wait until a token is available."""
|
|
31
|
+
while True:
|
|
32
|
+
now = time.monotonic()
|
|
33
|
+
elapsed = now - self._last_refill
|
|
34
|
+
self._tokens = min(self.rate, self._tokens + elapsed * self.rate)
|
|
35
|
+
self._last_refill = now
|
|
36
|
+
|
|
37
|
+
if self._tokens >= 1.0:
|
|
38
|
+
self._tokens -= 1.0
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
await asyncio.sleep(0.1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BaseHTTPClient:
|
|
45
|
+
"""Async HTTP client with built-in retry and rate limiting."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
rate_limit: float = 1.0,
|
|
50
|
+
max_retries: int = 2,
|
|
51
|
+
timeout: float = 20.0,
|
|
52
|
+
):
|
|
53
|
+
self.rate_limiter = RateLimiter(rate_limit)
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
57
|
+
self._last_error: str = "" # Exposed for tools to report failures
|
|
58
|
+
|
|
59
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
60
|
+
if self._client is None:
|
|
61
|
+
self._client = httpx.AsyncClient(
|
|
62
|
+
timeout=httpx.Timeout(self.timeout, connect=10.0),
|
|
63
|
+
follow_redirects=True,
|
|
64
|
+
headers={
|
|
65
|
+
"User-Agent": random.choice(USER_AGENTS),
|
|
66
|
+
"Accept": "application/json, text/plain, */*",
|
|
67
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
return self._client
|
|
71
|
+
|
|
72
|
+
async def get(self, url: str, **kwargs) -> httpx.Response:
|
|
73
|
+
"""GET with retry and rate limiting."""
|
|
74
|
+
await self.rate_limiter.acquire()
|
|
75
|
+
client = await self._get_client()
|
|
76
|
+
|
|
77
|
+
last_err = ""
|
|
78
|
+
for attempt in range(self.max_retries + 1):
|
|
79
|
+
try:
|
|
80
|
+
response = await client.get(url, **kwargs)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
return response
|
|
83
|
+
except httpx.HTTPStatusError as e:
|
|
84
|
+
last_err = f"HTTP {e.response.status_code} from {url}"
|
|
85
|
+
if e.response.status_code == 429:
|
|
86
|
+
retry_after = float(e.response.headers.get("Retry-After", 5))
|
|
87
|
+
await asyncio.sleep(retry_after)
|
|
88
|
+
continue
|
|
89
|
+
if attempt == self.max_retries:
|
|
90
|
+
self._last_error = last_err
|
|
91
|
+
raise
|
|
92
|
+
await asyncio.sleep(2 ** attempt)
|
|
93
|
+
except (httpx.RequestError, httpx.TimeoutException) as e:
|
|
94
|
+
last_err = f"Connection error for {url}: {e}"
|
|
95
|
+
if attempt == self.max_retries:
|
|
96
|
+
self._last_error = last_err
|
|
97
|
+
raise
|
|
98
|
+
await asyncio.sleep(2 ** attempt)
|
|
99
|
+
|
|
100
|
+
self._last_error = last_err
|
|
101
|
+
raise RuntimeError(f"Failed to fetch {url} after {self.max_retries + 1} attempts")
|
|
102
|
+
|
|
103
|
+
async def get_json(self, url: str, **kwargs) -> dict | list:
|
|
104
|
+
"""GET and parse JSON response."""
|
|
105
|
+
response = await self.get(url, **kwargs)
|
|
106
|
+
return response.json()
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
if self._client is not None:
|
|
110
|
+
await self._client.aclose()
|
|
111
|
+
self._client = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_client(rate_limit: float = 1.0) -> BaseHTTPClient:
|
|
115
|
+
"""Create a new HTTP client with the given rate limit.
|
|
116
|
+
|
|
117
|
+
Each tool gets its own client to avoid rate-limit contention across parallel tools.
|
|
118
|
+
"""
|
|
119
|
+
return BaseHTTPClient(rate_limit=rate_limit)
|