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,347 @@
1
+ """Geolocation tool — pinpoint locations from visual clues and coordinates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import OSINTTool
6
+ from .registry import registry
7
+ from ..core.models import Finding, FindingSource
8
+ from ..utils.http import get_client
9
+
10
+ import logging
11
+
12
+ logger = logging.getLogger("watson.geolocation")
13
+
14
+
15
+ class GeolocationTool(OSINTTool):
16
+ """Investigate and verify locations — geocoding, reverse geocoding, and POI search."""
17
+
18
+ category = FindingSource.GEOLOCATION
19
+ name = "geolocation"
20
+ description = "Forward/reverse geocoding, POI search, address verification"
21
+ free_tier_available = True
22
+ rate_limit_rps = 1.0
23
+
24
+ NOMINATIM_SEARCH = "https://nominatim.openstreetmap.org/search"
25
+ NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"
26
+ OVERPASS_API = "https://overpass-api.de/api/interpreter"
27
+
28
+ async def _overpass_query(self, query: str) -> dict | None:
29
+ """Run an Overpass QL query via POST (required for larger queries)."""
30
+ import httpx
31
+ async with httpx.AsyncClient(timeout=30) as client:
32
+ r = await client.post(
33
+ self.OVERPASS_API,
34
+ data={"data": query},
35
+ headers={"Accept": "application/json", "User-Agent": "WatsonOSINT/0.3"},
36
+ )
37
+ r.raise_for_status()
38
+ return r.json()
39
+
40
+ async def investigate(self, query: str, context: str = "") -> list[Finding]:
41
+ findings: list[Finding] = []
42
+ client = get_client(rate_limit=self.rate_limit_rps)
43
+
44
+ # Check for coordinates in the query
45
+ coords = self._parse_coordinates(query)
46
+
47
+ if coords:
48
+ lat, lon = coords
49
+ try:
50
+ params = {"lat": lat, "lon": lon, "format": "json", "zoom": 18}
51
+ data = await client.get_json(self.NOMINATIM_REVERSE, params=params)
52
+
53
+ if isinstance(data, dict):
54
+ display = data.get("display_name", f"{lat}, {lon}")
55
+ address = data.get("address", {})
56
+
57
+ findings.append(
58
+ self._make_finding(
59
+ title=f"📍 Reverse geocode: {display[:80]}",
60
+ description=(
61
+ f"Coordinates {lat}, {lon} resolve to: "
62
+ f"{address.get('road', '')} {address.get('city', '')}, "
63
+ f"{address.get('country', '')}"
64
+ ),
65
+ evidence=[f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=18"],
66
+ confidence=0.95,
67
+ lat=lat,
68
+ lon=lon,
69
+ address=address,
70
+ )
71
+ )
72
+
73
+ # Find nearby POIs via Overpass
74
+ pois = await self._nearby_pois(lat, lon, client)
75
+ findings.extend(pois)
76
+
77
+ except Exception as e:
78
+ findings.append(
79
+ self._make_finding(
80
+ title="Geolocation lookup failed",
81
+ description=f"Error reverse-geocoding {lat}, {lon}: {str(e)}",
82
+ confidence=0.0,
83
+ )
84
+ )
85
+
86
+ else:
87
+ # Try forward geocoding — search for a place name
88
+ location = self._extract_place(query)
89
+ if location:
90
+ try:
91
+ params = {"q": location, "format": "json", "limit": 3}
92
+ data = await client.get_json(self.NOMINATIM_SEARCH, params=params)
93
+
94
+ if isinstance(data, list) and data:
95
+ places = []
96
+ for d in data[:3]:
97
+ places.append(f"{d.get('display_name', '')[:60]} ({d.get('lat')}, {d.get('lon')})")
98
+
99
+ findings.append(
100
+ self._make_finding(
101
+ title=f"📍 Places matching '{location}'",
102
+ description="\n".join(f"- {p}" for p in places),
103
+ confidence=0.85,
104
+ )
105
+ )
106
+ except Exception:
107
+ pass
108
+
109
+ return findings
110
+
111
+ def _parse_coordinates(self, text: str) -> tuple[float, float] | None:
112
+ """Extract lat/lon from text."""
113
+ import re
114
+
115
+ # Decimal degrees: 48.8566, 2.3522
116
+ match = re.search(r"(-?\d+\.\d+)\s*[,;\s]\s*(-?\d+\.\d+)", text)
117
+ if match:
118
+ return float(match.group(1)), float(match.group(2))
119
+
120
+ # DMS format: 48°51'24"N, 2°21'08"E — simplified
121
+ match = re.search(
122
+ r"(\d+)°\s*(\d+)'?\s*(\d+(?:\.\d+)?)\"?\s*([NS])[\s,;]+\s*(\d+)°\s*(\d+)'?\s*(\d+(?:\.\d+)?)\"?\s*([EW])",
123
+ text,
124
+ )
125
+ if match:
126
+ lat = int(match.group(1)) + int(match.group(2)) / 60 + float(match.group(3)) / 3600
127
+ if match.group(4) == "S":
128
+ lat = -lat
129
+ lon = int(match.group(5)) + int(match.group(6)) / 60 + float(match.group(7)) / 3600
130
+ if match.group(8) == "W":
131
+ lon = -lon
132
+ return lat, lon
133
+
134
+ return None
135
+
136
+ async def _nearby_pois(self, lat: float, lon: float, client) -> list[Finding]:
137
+ """Find points of interest near a location using Overpass API."""
138
+ findings: list[Finding] = []
139
+ radius = 500 # meters
140
+
141
+ query = f"""
142
+ [out:json];
143
+ (
144
+ node(around:{radius},{lat},{lon})["amenity"];
145
+ node(around:{radius},{lat},{lon})["tourism"];
146
+ node(around:{radius},{lat},{lon})["historic"];
147
+ node(around:{radius},{lat},{lon})["building"]["name"];
148
+ );
149
+ out 5;
150
+ """
151
+
152
+ try:
153
+ data = await self._overpass_query(query)
154
+
155
+ if isinstance(data, dict) and "elements" in data:
156
+ elements = data["elements"][:5]
157
+ if elements:
158
+ pois = []
159
+ for el in elements:
160
+ tags = el.get("tags", {})
161
+ name = tags.get("name", "Unnamed")
162
+ amenity = tags.get("amenity", tags.get("tourism", tags.get("historic", "")))
163
+ pois.append(f"{name} ({amenity})" if amenity else name)
164
+
165
+ findings.append(
166
+ self._make_finding(
167
+ title=f"🏛 Nearby landmarks ({len(elements)} found)",
168
+ description="\n".join(f"- {p}" for p in pois),
169
+ confidence=0.8,
170
+ lat=lat,
171
+ lon=lon,
172
+ radius=radius,
173
+ )
174
+ )
175
+ except Exception:
176
+ pass
177
+
178
+ return findings
179
+
180
+ def _extract_place(self, text: str) -> str | None:
181
+ """Extract a place name from query text."""
182
+ import re
183
+
184
+ patterns = [
185
+ r"(?:find|locate|search|show|look up|geolocate)\s+(.+?)(?:\s+(?:and|or|for|$|\.))",
186
+ r"(?:where is|what is at)\s+(.+?)(?:\?|$)",
187
+ ]
188
+ for pattern in patterns:
189
+ match = re.search(pattern, text, re.IGNORECASE)
190
+ if match:
191
+ return match.group(1).strip().rstrip(".,?")
192
+ return None
193
+
194
+ async def investigate_infrastructure(self, place_name: str) -> list[Finding]:
195
+ """Geocode a place then find nearby strategic infrastructure.
196
+
197
+ Queries ports, mines, refineries, military facilities — relevant for
198
+ sanctions evasion, conflict mineral smuggling, and commodity fraud investigations.
199
+ """
200
+ findings: list[Finding] = []
201
+ client = get_client(rate_limit=self.rate_limit_rps)
202
+
203
+ # Step 1: Geocode the place
204
+ try:
205
+ params = {"q": place_name, "format": "json", "limit": 1}
206
+ data = await client.get_json(self.NOMINATIM_SEARCH, params=params)
207
+ if not isinstance(data, list) or not data:
208
+ return findings
209
+
210
+ lat = float(data[0]["lat"])
211
+ lon = float(data[0]["lon"])
212
+ display = data[0].get("display_name", place_name)[:80]
213
+
214
+ findings.append(self._make_finding(
215
+ title=f"📍 Geocoded: {display}",
216
+ description=f"Coordinates: {lat}, {lon}\n[OpenStreetMap](https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=12)",
217
+ evidence=[f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=12"],
218
+ confidence=0.95,
219
+ lat=lat, lon=lon,
220
+ ))
221
+
222
+ # Step 2: Find infrastructure within 50km
223
+ infra = await self._nearby_infrastructure(lat, lon, client)
224
+ findings.extend(infra)
225
+
226
+ except Exception:
227
+ pass
228
+
229
+ return findings
230
+
231
+ async def _nearby_infrastructure(self, lat: float, lon: float, client) -> list[Finding]:
232
+ """Query Overpass for strategic infrastructure near a point.
233
+
234
+ Queries nodes, ways, AND relations for industrial/mining/military/port
235
+ features. Nornickel-type targets need ways (mine boundaries) and relations.
236
+ """
237
+ findings: list[Finding] = []
238
+ radius = 50000 # 50km radius
239
+
240
+ # ── Single combined query: nodes + ways + relations for all categories ──
241
+ # This avoids 4 sequential API calls (4×1.5s = 6s saved) and catches
242
+ # ways/relations that were invisible to node-only queries.
243
+ query = f"""[out:json][timeout:25];
244
+ (
245
+ // Mines & quarries — nodes, ways, relations
246
+ node(around:{radius},{lat},{lon})["landuse"="quarry"];
247
+ way(around:{radius},{lat},{lon})["landuse"="quarry"];
248
+ rel(around:{radius},{lat},{lon})["landuse"="quarry"];
249
+ node(around:{radius},{lat},{lon})["landuse"="mining"];
250
+ way(around:{radius},{lat},{lon})["landuse"="mining"];
251
+ rel(around:{radius},{lat},{lon})["landuse"="mining"];
252
+
253
+ // Industrial facilities — factories, refineries, smelters
254
+ node(around:{radius},{lat},{lon})["landuse"="industrial"];
255
+ way(around:{radius},{lat},{lon})["landuse"="industrial"];
256
+ rel(around:{radius},{lat},{lon})["landuse"="industrial"];
257
+ node(around:{radius},{lat},{lon})["man_made"="works"];
258
+ way(around:{radius},{lat},{lon})["man_made"="works"];
259
+ node(around:{radius},{lat},{lon})["industrial"];
260
+ way(around:{radius},{lat},{lon})["industrial"];
261
+
262
+ // Ports & harbours
263
+ node(around:{radius},{lat},{lon})["amenity"="ferry_terminal"];
264
+ node(around:{radius},{lat},{lon})["harbour"];
265
+ way(around:{radius},{lat},{lon})["harbour"];
266
+
267
+ // Military
268
+ node(around:{radius},{lat},{lon})["landuse"="military"];
269
+ way(around:{radius},{lat},{lon})["landuse"="military"];
270
+ node(around:{radius},{lat},{lon})["aeroway"="aerodrome"]["name"];
271
+ );
272
+ out center 20;
273
+ """
274
+
275
+ loc_label = display_name(lat, lon)
276
+
277
+ try:
278
+ data = await self._overpass_query(query)
279
+
280
+ if isinstance(data, dict) and "elements" in data:
281
+ elements = data["elements"]
282
+ # Group by category
283
+ mines: list[str] = []
284
+ industrial: list[str] = []
285
+ ports: list[str] = []
286
+ military: list[str] = []
287
+
288
+ for el in elements[:20]:
289
+ tags = el.get("tags", {})
290
+ name = tags.get("name", "")
291
+ # Get center for ways/relations
292
+ center = el.get("center", {})
293
+ el_lat = center.get("lat", el.get("lat", 0))
294
+ el_lon = center.get("lon", el.get("lon", 0))
295
+ elem_type = el.get("type", "node")
296
+
297
+ label = f"- **{name}**" if name else f"- Unnamed {elem_type}"
298
+ if el_lat and el_lon:
299
+ label += f" ({el_lat:.4f}, {el_lon:.4f})"
300
+
301
+ # Classify into category
302
+ landuse = tags.get("landuse", "")
303
+ man_made = tags.get("man_made", "")
304
+ industrial_tag = tags.get("industrial", "")
305
+ amenity = tags.get("amenity", "")
306
+ aeroway = tags.get("aeroway", "")
307
+ harbour_tag = tags.get("harbour", "")
308
+ has_name = tags.get("name", "")
309
+
310
+ if landuse in ("quarry", "mining"):
311
+ mines.append(label)
312
+ elif landuse == "industrial" or man_made == "works" or industrial_tag:
313
+ industrial.append(label)
314
+ elif amenity == "ferry_terminal" or harbour_tag:
315
+ ports.append(label)
316
+ elif landuse == "military" or (aeroway == "aerodrome" and has_name):
317
+ military.append(label)
318
+
319
+ titles = {
320
+ "mines": "⛏ Mines & quarries",
321
+ "industrial": "🏭 Industrial facilities",
322
+ "ports": "🚢 Ports & harbours",
323
+ "military": "🪖 Military installations",
324
+ }
325
+ for cat, entries in [("mines", mines), ("industrial", industrial),
326
+ ("ports", ports), ("military", military)]:
327
+ if entries:
328
+ findings.append(self._make_finding(
329
+ title=f"{titles[cat]} near {loc_label}",
330
+ description="\n".join(entries[:6]),
331
+ confidence=0.85,
332
+ infrastructure_type=cat,
333
+ ))
334
+
335
+ except Exception as e:
336
+ logger.warning("overpass_infra_failed: %s", e)
337
+
338
+ return findings
339
+
340
+
341
+ def display_name(lat: float, lon: float) -> str:
342
+ return f"({lat:.3f}, {lon:.3f})"
343
+
344
+
345
+ # Register
346
+ geolocation_tool = GeolocationTool()
347
+ registry.register(geolocation_tool)
@@ -0,0 +1,108 @@
1
+ """Image & Video verification tool — reverse image search, EXIF, verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from urllib.parse import quote
7
+
8
+ from .base import OSINTTool
9
+ from .registry import registry
10
+ from ..core.models import Finding, FindingSource
11
+ from ..utils.http import get_client, BaseHTTPClient
12
+ from ..utils.helpers import is_url
13
+
14
+
15
+ class ImageVideoTool(OSINTTool):
16
+ """Reverse image search, EXIF extraction, and visual verification."""
17
+
18
+ category = FindingSource.IMAGE_VIDEO
19
+ name = "image-video"
20
+ description = "Reverse image search (Google, Yandex, TinEye), EXIF metadata, verification"
21
+ free_tier_available = True
22
+ rate_limit_rps = 0.5
23
+
24
+ GOOGLE_IMAGE_SEARCH = "https://lens.google.com/uploadbyurl?url={image_url}"
25
+ YANDEX_IMAGE_SEARCH = "https://yandex.com/images/search?rpt=imageview&url={image_url}"
26
+ TINEYE_SEARCH = "https://tineye.com/search?url={image_url}"
27
+
28
+ async def investigate(self, query: str, context: str = "") -> list[Finding]:
29
+ findings: list[Finding] = []
30
+
31
+ # Extract image URLs from the query
32
+ image_urls = self._extract_image_urls(query)
33
+
34
+ if image_urls:
35
+ for url in image_urls[:3]: # Max 3 images
36
+ findings.append(
37
+ self._make_finding(
38
+ title=f"🖼 Reverse image search ready: {url[:60]}",
39
+ description=(
40
+ f"Submit this image to reverse image search engines:\n"
41
+ f"- [Google Lens]({self.GOOGLE_IMAGE_SEARCH.format(image_url=quote(url))})\n"
42
+ f"- [Yandex]({self.YANDEX_IMAGE_SEARCH.format(image_url=quote(url))})\n"
43
+ f"- [TinEye]({self.TINEYE_SEARCH.format(image_url=quote(url))})\n\n"
44
+ f"Tip: Yandex is often best for faces and Eastern European content. "
45
+ f"Google Lens excels at objects and locations."
46
+ ),
47
+ evidence=[
48
+ self.GOOGLE_IMAGE_SEARCH.format(image_url=quote(url)),
49
+ self.YANDEX_IMAGE_SEARCH.format(image_url=quote(url)),
50
+ self.TINEYE_SEARCH.format(image_url=quote(url)),
51
+ ],
52
+ confidence=0.7,
53
+ image_url=url,
54
+ )
55
+ )
56
+
57
+ # Check for image/video file references even without full URLs
58
+ if not image_urls:
59
+ image_refs = self._find_image_references(query)
60
+ if image_refs:
61
+ findings.append(
62
+ self._make_finding(
63
+ title="📸 Image/video reference detected",
64
+ description=(
65
+ f"Found references to: {', '.join(image_refs[:5])}. "
66
+ "To run reverse image search, provide the full image URL. "
67
+ "Use `watson investigate \"[image_url]\"` with the direct URL."
68
+ ),
69
+ confidence=0.4,
70
+ references=image_refs,
71
+ )
72
+ )
73
+
74
+ return findings
75
+
76
+ def _extract_image_urls(self, text: str) -> list[str]:
77
+ """Extract image URLs from text."""
78
+ urls = re.findall(
79
+ r"https?://[^\s]+?\.(?:jpg|jpeg|png|gif|webp|bmp|svg)(?:\?[^\s]*)?",
80
+ text,
81
+ re.IGNORECASE,
82
+ )
83
+ # Also catch URLs that might be images without extensions
84
+ if not urls:
85
+ for word in text.split():
86
+ if word.startswith("http") and any(
87
+ kw in word.lower() for kw in ["image", "photo", "img", "pic"]
88
+ ):
89
+ urls.append(word.strip(".,;:!?"))
90
+ return urls
91
+
92
+ def _find_image_references(self, text: str) -> list[str]:
93
+ """Find references to images/videos in text."""
94
+ keywords = ["photo", "image", "video", "screenshot", "picture", "footage"]
95
+ refs = []
96
+ for kw in keywords:
97
+ idx = text.lower().find(kw)
98
+ if idx >= 0:
99
+ # Get surrounding context
100
+ start = max(0, idx - 20)
101
+ end = min(len(text), idx + len(kw) + 30)
102
+ refs.append(text[start:end].strip())
103
+ return refs
104
+
105
+
106
+ # Register
107
+ image_video_tool = ImageVideoTool()
108
+ registry.register(image_video_tool)
@@ -0,0 +1,142 @@
1
+ """MarineTraffic AIS tool — ship tracking for sanctions evasion OSINT.
2
+
3
+ Monitors vessel movements, ownership, and port calls. Critical for
4
+ sanctions evasion, illegal fishing, and smuggling investigations.
5
+ Paid API required (free tier: 500 req/day).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from .base import OSINTTool
13
+ from .registry import registry
14
+ from ..core.models import Finding, FindingSource
15
+
16
+ logger = logging.getLogger("watson.marinetraffic")
17
+
18
+
19
+ class MarineTrafficTool(OSINTTool):
20
+ """Ship tracking via AIS — vessel movements, ownership, port calls."""
21
+
22
+ category = FindingSource.OSINT
23
+ name = "marinetraffic"
24
+ description = "MarineTraffic AIS — vessel tracking, ship ownership, port calls for sanctions evasion detection"
25
+ free_tier_available = False
26
+ rate_limit_rps = 1.0
27
+
28
+ VESSEL_SEARCH = "https://services.marinetraffic.com/api/exportvessels/v:8/"
29
+
30
+ async def investigate(self, query: str, context: str = "") -> list[Finding]:
31
+ """Search vessel movements related to a target."""
32
+ findings: list[Finding] = []
33
+
34
+ from watson.api_keys import get_key
35
+
36
+ api_key = get_key("marinetraffic")
37
+ if not api_key:
38
+ findings.append(self._make_finding(
39
+ title="🔒 MarineTraffic AIS API key not configured",
40
+ description=(
41
+ "MarineTraffic provides real-time ship tracking via AIS — vessel "
42
+ "movements, ownership, port calls. Critical for sanctions evasion "
43
+ "and smuggling investigations.\n\n"
44
+ "Install your API key in Settings → API Vault to enable.\n"
45
+ "Get a key: https://www.marinetraffic.com/en/ais-api-services"
46
+ ),
47
+ confidence=0.0,
48
+ source_url="https://www.marinetraffic.com/en/ais-api-services",
49
+ ))
50
+ return findings
51
+
52
+ # Extract searchable terms
53
+ vessel_name = self._extract_vessel_name(query)
54
+ if not vessel_name:
55
+ return findings
56
+
57
+ try:
58
+ import httpx
59
+
60
+ async with httpx.AsyncClient(timeout=20) as client:
61
+ resp = await client.get(
62
+ f"{self.VESSEL_SEARCH}{api_key}/v:2/name:{vessel_name}/protocol:jsono",
63
+ )
64
+ resp.raise_for_status()
65
+
66
+ # MarineTraffic returns JSONP-style — strip prefix
67
+ raw = resp.text
68
+ if raw.startswith("jsono("):
69
+ import json as _json
70
+ raw = raw[6:-1] # strip jsono( ... )
71
+ data = _json.loads(raw)
72
+ else:
73
+ data = resp.json()
74
+
75
+ vessels = data if isinstance(data, list) else data.get("data", {}).get("rows", [])
76
+
77
+ if vessels:
78
+ entries = []
79
+ for v in vessels[:5]:
80
+ name = v.get("SHIPNAME", v.get("NAME", "Unknown"))
81
+ imo = v.get("IMO", "?")
82
+ mmsi = v.get("MMSI", "?")
83
+ flag = v.get("FLAG", v.get("COUNTRY", "?"))
84
+ ship_type = v.get("SHIPTYPE", v.get("TYPE", "?"))
85
+ lat = v.get("LAT", "")
86
+ lon = v.get("LON", "")
87
+
88
+ entries.append(
89
+ f"- **{name}** (IMO: {imo}) — {flag} flag\n"
90
+ f" Type: {ship_type} | MMSI: {mmsi}"
91
+ + (f" | Position: {lat}, {lon}" if lat and lon else "")
92
+ )
93
+
94
+ findings.append(self._make_finding(
95
+ title=f"🚢 MarineTraffic: {len(vessels)} vessels matching '{vessel_name}'",
96
+ description="\n".join(entries[:5]),
97
+ confidence=0.85,
98
+ evidence=[f"https://www.marinetraffic.com/en/ais/details/ships/name:{vessel_name}"],
99
+ ))
100
+ else:
101
+ findings.append(self._make_finding(
102
+ title=f"🚢 MarineTraffic: No vessels found for '{vessel_name}'",
103
+ description="No vessels matching the query in the AIS database.",
104
+ confidence=0.6,
105
+ ))
106
+
107
+ except Exception as e:
108
+ logger.warning("marinetraffic_search_failed: %s", e)
109
+ findings.append(self._make_finding(
110
+ title=f"⚠ MarineTraffic search failed: {str(e)[:100]}",
111
+ description="API call failed. Check your key or try again later.",
112
+ confidence=0.0,
113
+ ))
114
+
115
+ return findings
116
+
117
+ @staticmethod
118
+ def _extract_vessel_name(query: str) -> str | None:
119
+ """Extract vessel name or IMO number from query."""
120
+ import re
121
+
122
+ # IMO number: 7 digits
123
+ imo = re.search(r'\b(\d{7})\b', query)
124
+ if imo:
125
+ return imo.group(1)
126
+
127
+ # Vessel name in quotes
128
+ quoted = re.search(r'"([^"]+)"', query)
129
+ if quoted:
130
+ return quoted.group(1)
131
+
132
+ # Vessel name keyword
133
+ vessel = re.search(r'(?:ship|vessel|tanker|vessel_name)\s*[:=]?\s*([A-Za-z0-9\s]{2,30})', query, re.IGNORECASE)
134
+ if vessel:
135
+ return vessel.group(1).strip()
136
+
137
+ return None
138
+
139
+
140
+ # Register
141
+ marinetraffic_tool = MarineTrafficTool()
142
+ registry.register(marinetraffic_tool)