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,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] + "..."
@@ -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)