omnimancer-cli 0.1.0__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.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"""Web client for HTTP requests, API interactions, and web scraping with security features."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import pickle
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
|
14
|
+
from urllib.parse import urljoin, urlparse
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
import html2text
|
|
18
|
+
from bs4 import BeautifulSoup
|
|
19
|
+
from readability import Document
|
|
20
|
+
|
|
21
|
+
from ..security import SecurityManager
|
|
22
|
+
from ..security.permission_controller import PermissionOperation
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RequestMethod(Enum):
|
|
26
|
+
"""HTTP request methods."""
|
|
27
|
+
|
|
28
|
+
GET = "GET"
|
|
29
|
+
POST = "POST"
|
|
30
|
+
PUT = "PUT"
|
|
31
|
+
DELETE = "DELETE"
|
|
32
|
+
HEAD = "HEAD"
|
|
33
|
+
OPTIONS = "OPTIONS"
|
|
34
|
+
PATCH = "PATCH"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WebResponse:
|
|
39
|
+
"""Response from web request."""
|
|
40
|
+
|
|
41
|
+
url: str
|
|
42
|
+
status: int
|
|
43
|
+
headers: Dict[str, str]
|
|
44
|
+
content: Union[str, bytes]
|
|
45
|
+
content_type: str
|
|
46
|
+
encoding: str
|
|
47
|
+
cookies: Dict[str, str] = field(default_factory=dict)
|
|
48
|
+
history: List[str] = field(default_factory=list)
|
|
49
|
+
elapsed: float = 0.0
|
|
50
|
+
from_cache: bool = False
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_success(self) -> bool:
|
|
54
|
+
"""Check if response was successful."""
|
|
55
|
+
return 200 <= self.status < 300
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_text(self) -> bool:
|
|
59
|
+
"""Check if response is text content."""
|
|
60
|
+
return isinstance(self.content, str)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def text(self) -> str:
|
|
64
|
+
"""Get text content."""
|
|
65
|
+
if isinstance(self.content, str):
|
|
66
|
+
return self.content
|
|
67
|
+
|
|
68
|
+
# Handle binary encoding specially
|
|
69
|
+
if self.encoding == "binary":
|
|
70
|
+
return self.content.decode("utf-8", errors="ignore")
|
|
71
|
+
|
|
72
|
+
return self.content.decode(self.encoding or "utf-8", errors="ignore")
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def json(self) -> Any:
|
|
76
|
+
"""Parse JSON content."""
|
|
77
|
+
return json.loads(self.text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CacheEntry:
|
|
82
|
+
"""Cache entry for responses."""
|
|
83
|
+
|
|
84
|
+
response: WebResponse
|
|
85
|
+
timestamp: datetime
|
|
86
|
+
expires: datetime
|
|
87
|
+
etag: Optional[str] = None
|
|
88
|
+
last_modified: Optional[str] = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RateLimiter:
|
|
92
|
+
"""Rate limiter for HTTP requests."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
requests_per_second: float = 10.0,
|
|
97
|
+
requests_per_minute: float = 100.0,
|
|
98
|
+
requests_per_hour: float = 1000.0,
|
|
99
|
+
):
|
|
100
|
+
self.requests_per_second = requests_per_second
|
|
101
|
+
self.requests_per_minute = requests_per_minute
|
|
102
|
+
self.requests_per_hour = requests_per_hour
|
|
103
|
+
|
|
104
|
+
self.request_times: Dict[str, List[float]] = {}
|
|
105
|
+
self.lock = asyncio.Lock()
|
|
106
|
+
|
|
107
|
+
async def wait_if_needed(self, domain: str) -> None:
|
|
108
|
+
"""Wait if rate limit would be exceeded."""
|
|
109
|
+
async with self.lock:
|
|
110
|
+
now = time.time()
|
|
111
|
+
|
|
112
|
+
if domain not in self.request_times:
|
|
113
|
+
self.request_times[domain] = []
|
|
114
|
+
|
|
115
|
+
times = self.request_times[domain]
|
|
116
|
+
|
|
117
|
+
# Clean old entries
|
|
118
|
+
times[:] = [t for t in times if now - t < 3600] # Keep last hour
|
|
119
|
+
|
|
120
|
+
# Check limits
|
|
121
|
+
recent_second = [t for t in times if now - t < 1.0]
|
|
122
|
+
recent_minute = [t for t in times if now - t < 60.0]
|
|
123
|
+
recent_hour = [t for t in times if now - t < 3600.0]
|
|
124
|
+
|
|
125
|
+
wait_time = 0.0
|
|
126
|
+
|
|
127
|
+
# Check per-second limit
|
|
128
|
+
if len(recent_second) >= self.requests_per_second:
|
|
129
|
+
wait_time = max(wait_time, 1.0 - (now - recent_second[0]))
|
|
130
|
+
|
|
131
|
+
# Check per-minute limit
|
|
132
|
+
if len(recent_minute) >= self.requests_per_minute:
|
|
133
|
+
wait_time = max(wait_time, 60.0 - (now - recent_minute[0]))
|
|
134
|
+
|
|
135
|
+
# Check per-hour limit
|
|
136
|
+
if len(recent_hour) >= self.requests_per_hour:
|
|
137
|
+
wait_time = max(wait_time, 3600.0 - (now - recent_hour[0]))
|
|
138
|
+
|
|
139
|
+
if wait_time > 0:
|
|
140
|
+
await asyncio.sleep(wait_time)
|
|
141
|
+
|
|
142
|
+
# Record this request
|
|
143
|
+
times.append(time.time())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ResponseCache:
|
|
147
|
+
"""Cache for HTTP responses."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
cache_dir: Optional[Path] = None,
|
|
152
|
+
max_size_mb: int = 100,
|
|
153
|
+
default_ttl: int = 3600,
|
|
154
|
+
):
|
|
155
|
+
|
|
156
|
+
self.cache_dir = cache_dir or Path.home() / ".omnimancer" / "web_cache"
|
|
157
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
self.max_size_mb = max_size_mb
|
|
160
|
+
self.default_ttl = default_ttl
|
|
161
|
+
self.memory_cache: Dict[str, CacheEntry] = {}
|
|
162
|
+
|
|
163
|
+
def _get_cache_key(self, url: str, method: str, headers: Dict[str, str]) -> str:
|
|
164
|
+
"""Generate cache key for request."""
|
|
165
|
+
key_data = f"{method}:{url}:{json.dumps(sorted(headers.items()))}"
|
|
166
|
+
return hashlib.md5(key_data.encode()).hexdigest()
|
|
167
|
+
|
|
168
|
+
def _get_cache_path(self, cache_key: str) -> Path:
|
|
169
|
+
"""Get file path for cache entry."""
|
|
170
|
+
return self.cache_dir / f"{cache_key}.cache"
|
|
171
|
+
|
|
172
|
+
async def get(
|
|
173
|
+
self,
|
|
174
|
+
url: str,
|
|
175
|
+
method: str = "GET",
|
|
176
|
+
headers: Optional[Dict[str, str]] = None,
|
|
177
|
+
) -> Optional[WebResponse]:
|
|
178
|
+
"""Get cached response if available and valid."""
|
|
179
|
+
|
|
180
|
+
headers = headers or {}
|
|
181
|
+
cache_key = self._get_cache_key(url, method, headers)
|
|
182
|
+
|
|
183
|
+
# Check memory cache first
|
|
184
|
+
if cache_key in self.memory_cache:
|
|
185
|
+
entry = self.memory_cache[cache_key]
|
|
186
|
+
if datetime.now() < entry.expires:
|
|
187
|
+
entry.response.from_cache = True
|
|
188
|
+
return entry.response
|
|
189
|
+
else:
|
|
190
|
+
del self.memory_cache[cache_key]
|
|
191
|
+
|
|
192
|
+
# Check disk cache
|
|
193
|
+
cache_path = self._get_cache_path(cache_key)
|
|
194
|
+
if cache_path.exists():
|
|
195
|
+
try:
|
|
196
|
+
with open(cache_path, "rb") as f:
|
|
197
|
+
entry = pickle.load(f)
|
|
198
|
+
|
|
199
|
+
if datetime.now() < entry.expires:
|
|
200
|
+
# Load into memory cache
|
|
201
|
+
self.memory_cache[cache_key] = entry
|
|
202
|
+
entry.response.from_cache = True
|
|
203
|
+
return entry.response
|
|
204
|
+
else:
|
|
205
|
+
# Remove expired cache
|
|
206
|
+
cache_path.unlink()
|
|
207
|
+
|
|
208
|
+
except (pickle.PickleError, EOFError, FileNotFoundError):
|
|
209
|
+
# Remove corrupted cache
|
|
210
|
+
if cache_path.exists():
|
|
211
|
+
cache_path.unlink()
|
|
212
|
+
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
async def set(
|
|
216
|
+
self,
|
|
217
|
+
url: str,
|
|
218
|
+
method: str,
|
|
219
|
+
headers: Dict[str, str],
|
|
220
|
+
response: WebResponse,
|
|
221
|
+
ttl: Optional[int] = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Cache response."""
|
|
224
|
+
|
|
225
|
+
ttl = ttl if ttl is not None else self.default_ttl
|
|
226
|
+
cache_key = self._get_cache_key(url, method, headers)
|
|
227
|
+
|
|
228
|
+
expires = datetime.now() + timedelta(seconds=ttl)
|
|
229
|
+
entry = CacheEntry(
|
|
230
|
+
response=response,
|
|
231
|
+
timestamp=datetime.now(),
|
|
232
|
+
expires=expires,
|
|
233
|
+
etag=response.headers.get("ETag"),
|
|
234
|
+
last_modified=response.headers.get("Last-Modified"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Store in memory
|
|
238
|
+
self.memory_cache[cache_key] = entry
|
|
239
|
+
|
|
240
|
+
# Store on disk
|
|
241
|
+
cache_path = self._get_cache_path(cache_key)
|
|
242
|
+
try:
|
|
243
|
+
with open(cache_path, "wb") as f:
|
|
244
|
+
pickle.dump(entry, f)
|
|
245
|
+
except (pickle.PickleError, OSError):
|
|
246
|
+
pass # Continue without disk cache
|
|
247
|
+
|
|
248
|
+
self._cleanup_cache()
|
|
249
|
+
|
|
250
|
+
def _cleanup_cache(self) -> None:
|
|
251
|
+
"""Clean up cache to stay within size limits."""
|
|
252
|
+
# Remove expired entries from memory
|
|
253
|
+
now = datetime.now()
|
|
254
|
+
expired_keys = [k for k, v in self.memory_cache.items() if now >= v.expires]
|
|
255
|
+
for key in expired_keys:
|
|
256
|
+
del self.memory_cache[key]
|
|
257
|
+
|
|
258
|
+
# Check disk cache size
|
|
259
|
+
try:
|
|
260
|
+
total_size = sum(f.stat().st_size for f in self.cache_dir.glob("*.cache"))
|
|
261
|
+
max_size_bytes = self.max_size_mb * 1024 * 1024
|
|
262
|
+
|
|
263
|
+
if total_size > max_size_bytes:
|
|
264
|
+
# Remove oldest files
|
|
265
|
+
cache_files = sorted(
|
|
266
|
+
self.cache_dir.glob("*.cache"),
|
|
267
|
+
key=lambda f: f.stat().st_mtime,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
for cache_file in cache_files:
|
|
271
|
+
cache_file.unlink()
|
|
272
|
+
total_size -= cache_file.stat().st_size
|
|
273
|
+
if total_size <= max_size_bytes * 0.8: # Keep 20% buffer
|
|
274
|
+
break
|
|
275
|
+
except OSError:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class WebClient:
|
|
280
|
+
"""Advanced web client with rate limiting, caching, and content extraction."""
|
|
281
|
+
|
|
282
|
+
def __init__(
|
|
283
|
+
self,
|
|
284
|
+
security_manager: Optional[SecurityManager] = None,
|
|
285
|
+
user_agent: str = "Omnimancer-Agent/1.0",
|
|
286
|
+
timeout: int = 30,
|
|
287
|
+
max_redirects: int = 10,
|
|
288
|
+
enable_cache: bool = True,
|
|
289
|
+
enable_rate_limiting: bool = True,
|
|
290
|
+
):
|
|
291
|
+
|
|
292
|
+
self.security = security_manager or SecurityManager()
|
|
293
|
+
self.user_agent = user_agent
|
|
294
|
+
self.timeout = timeout
|
|
295
|
+
self.max_redirects = max_redirects
|
|
296
|
+
|
|
297
|
+
# Initialize components
|
|
298
|
+
self.rate_limiter = RateLimiter() if enable_rate_limiting else None
|
|
299
|
+
self.cache = ResponseCache() if enable_cache else None
|
|
300
|
+
|
|
301
|
+
# Domain blacklist (security)
|
|
302
|
+
self.blacklisted_domains = self._get_default_blacklist()
|
|
303
|
+
self.whitelisted_domains: Set[str] = set()
|
|
304
|
+
|
|
305
|
+
# Content extraction
|
|
306
|
+
self.html_converter = html2text.HTML2Text()
|
|
307
|
+
self.html_converter.ignore_links = False
|
|
308
|
+
self.html_converter.ignore_images = True
|
|
309
|
+
|
|
310
|
+
# Session will be created when needed
|
|
311
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
312
|
+
|
|
313
|
+
# Request statistics
|
|
314
|
+
self.stats = {
|
|
315
|
+
"requests_made": 0,
|
|
316
|
+
"cache_hits": 0,
|
|
317
|
+
"cache_misses": 0,
|
|
318
|
+
"rate_limited": 0,
|
|
319
|
+
"blocked_requests": 0,
|
|
320
|
+
"failed_requests": 0,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
def _get_default_blacklist(self) -> Set[str]:
|
|
324
|
+
"""Get default domain blacklist."""
|
|
325
|
+
return {
|
|
326
|
+
# Localhost and private networks
|
|
327
|
+
"localhost",
|
|
328
|
+
"127.0.0.1",
|
|
329
|
+
"0.0.0.0",
|
|
330
|
+
"::1",
|
|
331
|
+
# Private IP ranges (partial)
|
|
332
|
+
"192.168.",
|
|
333
|
+
"10.",
|
|
334
|
+
"172.16.",
|
|
335
|
+
"172.17.",
|
|
336
|
+
"172.18.",
|
|
337
|
+
"172.19.",
|
|
338
|
+
"172.20.",
|
|
339
|
+
"172.21.",
|
|
340
|
+
"172.22.",
|
|
341
|
+
"172.23.",
|
|
342
|
+
"172.24.",
|
|
343
|
+
"172.25.",
|
|
344
|
+
"172.26.",
|
|
345
|
+
"172.27.",
|
|
346
|
+
"172.28.",
|
|
347
|
+
"172.29.",
|
|
348
|
+
"172.30.",
|
|
349
|
+
"172.31.",
|
|
350
|
+
# Potentially harmful
|
|
351
|
+
"malware-test.com",
|
|
352
|
+
"eicar.org",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
356
|
+
"""Get or create aiohttp session."""
|
|
357
|
+
if self._session is None or self._session.closed:
|
|
358
|
+
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
359
|
+
headers = {"User-Agent": self.user_agent}
|
|
360
|
+
|
|
361
|
+
self._session = aiohttp.ClientSession(
|
|
362
|
+
timeout=timeout,
|
|
363
|
+
headers=headers,
|
|
364
|
+
connector=aiohttp.TCPConnector(
|
|
365
|
+
limit=100, limit_per_host=20, enable_cleanup_closed=True
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return self._session
|
|
370
|
+
|
|
371
|
+
def _is_url_allowed(self, url: str) -> bool:
|
|
372
|
+
"""Check if URL is allowed by security policy."""
|
|
373
|
+
try:
|
|
374
|
+
parsed = urlparse(url)
|
|
375
|
+
domain = parsed.hostname
|
|
376
|
+
|
|
377
|
+
if not domain:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
# Check whitelist first
|
|
381
|
+
if self.whitelisted_domains and domain in self.whitelisted_domains:
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
# Check blacklist
|
|
385
|
+
if domain in self.blacklisted_domains:
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
# Check for private IP patterns
|
|
389
|
+
for blocked_pattern in self.blacklisted_domains:
|
|
390
|
+
if blocked_pattern.endswith(".") and domain.startswith(blocked_pattern):
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
# Additional security checks
|
|
394
|
+
if parsed.scheme not in ["http", "https"]:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
except Exception:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
async def request(
|
|
403
|
+
self,
|
|
404
|
+
url: str,
|
|
405
|
+
method: RequestMethod = RequestMethod.GET,
|
|
406
|
+
headers: Optional[Dict[str, str]] = None,
|
|
407
|
+
params: Optional[Dict[str, Any]] = None,
|
|
408
|
+
data: Optional[Union[str, bytes, Dict[str, Any]]] = None,
|
|
409
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
410
|
+
cookies: Optional[Dict[str, str]] = None,
|
|
411
|
+
allow_redirects: bool = True,
|
|
412
|
+
timeout: Optional[int] = None,
|
|
413
|
+
cache_ttl: Optional[int] = None,
|
|
414
|
+
) -> WebResponse:
|
|
415
|
+
"""Make HTTP request with security, rate limiting, and caching."""
|
|
416
|
+
|
|
417
|
+
url = url.strip()
|
|
418
|
+
headers = headers or {}
|
|
419
|
+
method_str = (
|
|
420
|
+
method.value if isinstance(method, RequestMethod) else str(method).upper()
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Security validation
|
|
424
|
+
if not self._is_url_allowed(url):
|
|
425
|
+
self.stats["blocked_requests"] += 1
|
|
426
|
+
raise ValueError(f"URL blocked by security policy: {url}")
|
|
427
|
+
|
|
428
|
+
# Validate with security manager
|
|
429
|
+
operation = PermissionOperation(
|
|
430
|
+
operation_type="network_request",
|
|
431
|
+
url=url,
|
|
432
|
+
method=method_str,
|
|
433
|
+
headers=headers,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
validation = await self.security.validate_operation(operation)
|
|
437
|
+
if not validation["allowed"]:
|
|
438
|
+
self.stats["blocked_requests"] += 1
|
|
439
|
+
raise ValueError(f"Request blocked: {', '.join(validation['reasons'])}")
|
|
440
|
+
|
|
441
|
+
# Check cache for GET requests
|
|
442
|
+
cached_response = None
|
|
443
|
+
if method == RequestMethod.GET and self.cache:
|
|
444
|
+
cached_response = await self.cache.get(url, method_str, headers)
|
|
445
|
+
if cached_response:
|
|
446
|
+
self.stats["cache_hits"] += 1
|
|
447
|
+
return cached_response
|
|
448
|
+
self.stats["cache_misses"] += 1
|
|
449
|
+
|
|
450
|
+
# Rate limiting
|
|
451
|
+
if self.rate_limiter:
|
|
452
|
+
domain = urlparse(url).hostname
|
|
453
|
+
if domain:
|
|
454
|
+
start_time = time.time()
|
|
455
|
+
await self.rate_limiter.wait_if_needed(domain)
|
|
456
|
+
if time.time() - start_time > 0.1: # If we waited more than 100ms
|
|
457
|
+
self.stats["rate_limited"] += 1
|
|
458
|
+
|
|
459
|
+
# Make request with retries
|
|
460
|
+
response = await self._make_request_with_retry(
|
|
461
|
+
url,
|
|
462
|
+
method_str,
|
|
463
|
+
headers,
|
|
464
|
+
params,
|
|
465
|
+
data,
|
|
466
|
+
json_data,
|
|
467
|
+
cookies,
|
|
468
|
+
allow_redirects,
|
|
469
|
+
timeout or self.timeout,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Cache successful GET responses
|
|
473
|
+
if (
|
|
474
|
+
response.is_success
|
|
475
|
+
and method == RequestMethod.GET
|
|
476
|
+
and self.cache
|
|
477
|
+
and response.status != 204
|
|
478
|
+
):
|
|
479
|
+
await self.cache.set(url, method_str, headers, response, cache_ttl)
|
|
480
|
+
|
|
481
|
+
self.stats["requests_made"] += 1
|
|
482
|
+
return response
|
|
483
|
+
|
|
484
|
+
async def _make_request_with_retry(
|
|
485
|
+
self,
|
|
486
|
+
url: str,
|
|
487
|
+
method: str,
|
|
488
|
+
headers: Dict[str, str],
|
|
489
|
+
params: Optional[Dict[str, Any]],
|
|
490
|
+
data: Optional[Union[str, bytes, Dict[str, Any]]],
|
|
491
|
+
json_data: Optional[Dict[str, Any]],
|
|
492
|
+
cookies: Optional[Dict[str, str]],
|
|
493
|
+
allow_redirects: bool,
|
|
494
|
+
timeout: int,
|
|
495
|
+
max_retries: int = 3,
|
|
496
|
+
) -> WebResponse:
|
|
497
|
+
"""Make request with exponential backoff retry."""
|
|
498
|
+
|
|
499
|
+
session = await self._get_session()
|
|
500
|
+
last_exception = None
|
|
501
|
+
|
|
502
|
+
for attempt in range(max_retries + 1):
|
|
503
|
+
try:
|
|
504
|
+
start_time = time.time()
|
|
505
|
+
|
|
506
|
+
# Prepare request kwargs
|
|
507
|
+
kwargs = {}
|
|
508
|
+
if params:
|
|
509
|
+
kwargs["params"] = params
|
|
510
|
+
if data:
|
|
511
|
+
kwargs["data"] = data
|
|
512
|
+
if json_data:
|
|
513
|
+
kwargs["json"] = json_data
|
|
514
|
+
if cookies:
|
|
515
|
+
kwargs["cookies"] = cookies
|
|
516
|
+
if not allow_redirects:
|
|
517
|
+
kwargs["allow_redirects"] = False
|
|
518
|
+
|
|
519
|
+
async with session.request(
|
|
520
|
+
method, url, headers=headers, **kwargs
|
|
521
|
+
) as resp:
|
|
522
|
+
elapsed = time.time() - start_time
|
|
523
|
+
|
|
524
|
+
# Read content
|
|
525
|
+
content_bytes = await resp.read()
|
|
526
|
+
|
|
527
|
+
# Determine if content is text
|
|
528
|
+
content_type = resp.headers.get("content-type", "").lower()
|
|
529
|
+
is_text = any(
|
|
530
|
+
t in content_type
|
|
531
|
+
for t in [
|
|
532
|
+
"text/",
|
|
533
|
+
"application/json",
|
|
534
|
+
"application/xml",
|
|
535
|
+
]
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Convert to text if appropriate
|
|
539
|
+
if is_text:
|
|
540
|
+
encoding = resp.charset or "utf-8"
|
|
541
|
+
content = content_bytes.decode(encoding, errors="ignore")
|
|
542
|
+
else:
|
|
543
|
+
content = content_bytes
|
|
544
|
+
encoding = "binary"
|
|
545
|
+
|
|
546
|
+
# Build response
|
|
547
|
+
response = WebResponse(
|
|
548
|
+
url=str(resp.url),
|
|
549
|
+
status=resp.status,
|
|
550
|
+
headers=dict(resp.headers),
|
|
551
|
+
content=content,
|
|
552
|
+
content_type=content_type,
|
|
553
|
+
encoding=encoding,
|
|
554
|
+
cookies=dict(resp.cookies),
|
|
555
|
+
history=[str(h.url) for h in resp.history],
|
|
556
|
+
elapsed=elapsed,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return response
|
|
560
|
+
|
|
561
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
562
|
+
last_exception = e
|
|
563
|
+
|
|
564
|
+
# Don't retry on client errors (4xx) except specific cases
|
|
565
|
+
if hasattr(e, "status") and 400 <= e.status < 500:
|
|
566
|
+
if e.status not in [429, 408]: # Rate limit, timeout
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
if attempt < max_retries:
|
|
570
|
+
# Exponential backoff
|
|
571
|
+
wait_time = (2**attempt) + (0.1 * attempt)
|
|
572
|
+
await asyncio.sleep(wait_time)
|
|
573
|
+
|
|
574
|
+
# All retries failed
|
|
575
|
+
self.stats["failed_requests"] += 1
|
|
576
|
+
raise Exception(
|
|
577
|
+
f"Request failed after {max_retries + 1} attempts: {last_exception}"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
async def get(self, url: str, **kwargs) -> WebResponse:
|
|
581
|
+
"""Make GET request."""
|
|
582
|
+
return await self.request(url, RequestMethod.GET, **kwargs)
|
|
583
|
+
|
|
584
|
+
async def post(self, url: str, **kwargs) -> WebResponse:
|
|
585
|
+
"""Make POST request."""
|
|
586
|
+
return await self.request(url, RequestMethod.POST, **kwargs)
|
|
587
|
+
|
|
588
|
+
async def put(self, url: str, **kwargs) -> WebResponse:
|
|
589
|
+
"""Make PUT request."""
|
|
590
|
+
return await self.request(url, RequestMethod.PUT, **kwargs)
|
|
591
|
+
|
|
592
|
+
async def delete(self, url: str, **kwargs) -> WebResponse:
|
|
593
|
+
"""Make DELETE request."""
|
|
594
|
+
return await self.request(url, RequestMethod.DELETE, **kwargs)
|
|
595
|
+
|
|
596
|
+
async def head(self, url: str, **kwargs) -> WebResponse:
|
|
597
|
+
"""Make HEAD request."""
|
|
598
|
+
return await self.request(url, RequestMethod.HEAD, **kwargs)
|
|
599
|
+
|
|
600
|
+
async def scrape_content(
|
|
601
|
+
self,
|
|
602
|
+
url: str,
|
|
603
|
+
extract_main_content: bool = True,
|
|
604
|
+
convert_to_markdown: bool = True,
|
|
605
|
+
remove_scripts: bool = True,
|
|
606
|
+
) -> Dict[str, Any]:
|
|
607
|
+
"""Scrape and extract content from web page."""
|
|
608
|
+
|
|
609
|
+
response = await self.get(url)
|
|
610
|
+
|
|
611
|
+
if not response.is_success:
|
|
612
|
+
raise ValueError(f"Failed to fetch content: HTTP {response.status}")
|
|
613
|
+
|
|
614
|
+
if not response.is_text:
|
|
615
|
+
raise ValueError("Response is not text content")
|
|
616
|
+
|
|
617
|
+
html_content = response.text
|
|
618
|
+
soup = BeautifulSoup(html_content, "html.parser")
|
|
619
|
+
|
|
620
|
+
# Remove unwanted elements
|
|
621
|
+
if remove_scripts:
|
|
622
|
+
for element in soup(
|
|
623
|
+
["script", "style", "nav", "header", "footer", "aside"]
|
|
624
|
+
):
|
|
625
|
+
element.decompose()
|
|
626
|
+
|
|
627
|
+
# Extract metadata
|
|
628
|
+
title = soup.find("title")
|
|
629
|
+
title_text = title.get_text().strip() if title else ""
|
|
630
|
+
|
|
631
|
+
meta_description = soup.find("meta", attrs={"name": "description"})
|
|
632
|
+
description = (
|
|
633
|
+
meta_description.get("content", "").strip() if meta_description else ""
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Extract main content
|
|
637
|
+
if extract_main_content:
|
|
638
|
+
# Try readability first (use cleaned HTML)
|
|
639
|
+
try:
|
|
640
|
+
cleaned_html = str(soup)
|
|
641
|
+
doc = Document(cleaned_html)
|
|
642
|
+
main_content = doc.summary()
|
|
643
|
+
main_soup = BeautifulSoup(main_content, "html.parser")
|
|
644
|
+
except:
|
|
645
|
+
# Fallback to common content selectors
|
|
646
|
+
main_soup = (
|
|
647
|
+
soup.find("main")
|
|
648
|
+
or soup.find("article")
|
|
649
|
+
or soup.find("div", class_=re.compile(r"content|main|article"))
|
|
650
|
+
)
|
|
651
|
+
if not main_soup:
|
|
652
|
+
main_soup = soup
|
|
653
|
+
else:
|
|
654
|
+
main_soup = soup
|
|
655
|
+
|
|
656
|
+
# Convert to text
|
|
657
|
+
if convert_to_markdown:
|
|
658
|
+
# Convert to markdown
|
|
659
|
+
main_text = self.html_converter.handle(str(main_soup))
|
|
660
|
+
else:
|
|
661
|
+
# Plain text
|
|
662
|
+
main_text = main_soup.get_text(separator="\n", strip=True)
|
|
663
|
+
|
|
664
|
+
# Extract links
|
|
665
|
+
links = []
|
|
666
|
+
for link in soup.find_all("a", href=True):
|
|
667
|
+
href = link["href"]
|
|
668
|
+
text = link.get_text().strip()
|
|
669
|
+
if href and text:
|
|
670
|
+
absolute_url = urljoin(url, href)
|
|
671
|
+
links.append({"url": absolute_url, "text": text})
|
|
672
|
+
|
|
673
|
+
# Extract images
|
|
674
|
+
images = []
|
|
675
|
+
for img in soup.find_all("img", src=True):
|
|
676
|
+
src = img["src"]
|
|
677
|
+
alt = img.get("alt", "").strip()
|
|
678
|
+
absolute_url = urljoin(url, src)
|
|
679
|
+
images.append({"url": absolute_url, "alt": alt})
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
"url": response.url,
|
|
683
|
+
"title": title_text,
|
|
684
|
+
"description": description,
|
|
685
|
+
"content": main_text,
|
|
686
|
+
"links": links[:50], # Limit links
|
|
687
|
+
"images": images[:20], # Limit images
|
|
688
|
+
"status": response.status,
|
|
689
|
+
"content_type": response.content_type,
|
|
690
|
+
"word_count": len(main_text.split()),
|
|
691
|
+
"char_count": len(main_text),
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
def add_to_blacklist(self, domain: str) -> None:
|
|
695
|
+
"""Add domain to blacklist."""
|
|
696
|
+
self.blacklisted_domains.add(domain)
|
|
697
|
+
|
|
698
|
+
def add_to_whitelist(self, domain: str) -> None:
|
|
699
|
+
"""Add domain to whitelist."""
|
|
700
|
+
self.whitelisted_domains.add(domain)
|
|
701
|
+
|
|
702
|
+
def remove_from_blacklist(self, domain: str) -> None:
|
|
703
|
+
"""Remove domain from blacklist."""
|
|
704
|
+
self.blacklisted_domains.discard(domain)
|
|
705
|
+
|
|
706
|
+
def remove_from_whitelist(self, domain: str) -> None:
|
|
707
|
+
"""Remove domain from whitelist."""
|
|
708
|
+
self.whitelisted_domains.discard(domain)
|
|
709
|
+
|
|
710
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
711
|
+
"""Get client statistics."""
|
|
712
|
+
return {
|
|
713
|
+
**self.stats,
|
|
714
|
+
"cache_size": len(self.cache.memory_cache) if self.cache else 0,
|
|
715
|
+
"blacklisted_domains": len(self.blacklisted_domains),
|
|
716
|
+
"whitelisted_domains": len(self.whitelisted_domains),
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
def clear_cache(self) -> None:
|
|
720
|
+
"""Clear response cache."""
|
|
721
|
+
if self.cache:
|
|
722
|
+
self.cache.memory_cache.clear()
|
|
723
|
+
# Clear disk cache
|
|
724
|
+
for cache_file in self.cache.cache_dir.glob("*.cache"):
|
|
725
|
+
try:
|
|
726
|
+
cache_file.unlink()
|
|
727
|
+
except OSError:
|
|
728
|
+
pass
|
|
729
|
+
|
|
730
|
+
async def close(self) -> None:
|
|
731
|
+
"""Close the web client and cleanup resources."""
|
|
732
|
+
if self._session and not self._session.closed:
|
|
733
|
+
await self._session.close()
|
|
734
|
+
|
|
735
|
+
# Clear cache on close
|
|
736
|
+
if self.cache:
|
|
737
|
+
self.cache.memory_cache.clear()
|
|
738
|
+
|
|
739
|
+
async def __aenter__(self):
|
|
740
|
+
"""Async context manager entry."""
|
|
741
|
+
return self
|
|
742
|
+
|
|
743
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
744
|
+
"""Async context manager exit."""
|
|
745
|
+
await self.close()
|