-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathurl_handler.py
More file actions
395 lines (320 loc) · 14.1 KB
/
Copy pathurl_handler.py
File metadata and controls
395 lines (320 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
import os
import re
import shutil
import hashlib
import logging
from pathlib import Path
from typing import Tuple, List, Dict, Set
from urllib.parse import urlparse, unquote, urljoin
import requests
from bs4 import BeautifulSoup
import json
from utils import get_sandbox_path, CONFIG_PATH, get_proxies, DEFAULT_CONFIG
logger = logging.getLogger(__name__)
def extract_urls(text: str) -> Dict[str, List[str]]:
"""
Extract file URLs and web URLs from text.
Supports paths with spaces if enclosed in brackets [path] or quotes "path".
Returns:
dict with keys 'web_urls' and 'file_urls'
"""
web_urls = []
file_urls = []
# Pattern for web URLs (http/https)
# Note: excluding \s < > " { } | ^ ` [ ] but KEEPING \ for UNC/Windows compatibility in file URLs
web_pattern = r'https?://[^\s<>"{}|^`\[\]]+'
web_matches = re.findall(web_pattern, text)
web_urls.extend(web_matches)
# Pattern for file:// URLs
# Support file:// URLs with spaces if in brackets/quotes, or without spaces otherwise
file_url_bracket_pattern = r'\[(file://[^\[\]]+)\]'
file_url_quote_pattern = r'["\'](file://[^"\']+)["\']'
file_url_basic_pattern = r'file://[^\s<>"{}|^`\[\]]+'
file_url_matches = re.findall(file_url_bracket_pattern, text)
file_url_matches.extend(re.findall(file_url_quote_pattern, text))
file_url_matches.extend(re.findall(file_url_basic_pattern, text))
# Convert file:// URLs to paths
for url in file_url_matches:
path = get_path_from_url(url)
file_urls.append(path)
# Pattern for absolute paths (Unix, Windows, and UNC)
# Brackets [path] - supports spaces
bracket_path_pattern = r'\[([^\[\]]{3,})\]' # Min 3 chars to avoid [a], [!]
# Quotes "path" - supports spaces
quote_path_pattern = r'["\']([^"\']{3,})["\']'
# Original patterns (no spaces)
unix_path_pattern = r'(?:^|\s)(/[^\s<>"{}|^`\[\]]+)'
windows_path_pattern = r'(?:^|\s)([A-Za-z]:[/\\][^\s<>"{}|^`\[\]]*)'
unc_path_pattern = r'(?:^|\s)(\\\\[^\s<>"{}|^`\[\]]+)'
bracket_matches = re.findall(bracket_path_pattern, text)
quote_matches = re.findall(quote_path_pattern, text)
unix_matches = re.findall(unix_path_pattern, text)
windows_matches = re.findall(windows_path_pattern, text)
unc_matches = re.findall(unc_path_pattern, text)
# Combine all matches
potential_paths = []
for p in bracket_matches + quote_matches + unix_matches + windows_matches + unc_matches:
p = p.strip()
# Basic validation: must look like a path
if (p.startswith('/') or
(len(p) > 2 and p[1:3] == ':\\') or
(len(p) > 2 and p[1:3] == ':/') or
p.startswith('\\\\')):
potential_paths.append(p)
# Filter duplicates and validate existence
potential_paths = list(set(potential_paths))
for path in potential_paths:
if os.path.exists(path):
file_urls.append(path)
return {
'web_urls': list(set(web_urls)), # Remove duplicates
'file_urls': list(set(file_urls))
}
def get_path_from_url(url: str) -> str:
"""
Convert a file:// URL to a local or network path.
Handles Windows drive letters and UNC paths.
"""
if not url.startswith('file://'):
return url
try:
parsed = urlparse(url)
# Handle netloc for UNC paths (file://server/share/path)
if parsed.netloc:
path_part = unquote(parsed.path)
if os.name == 'nt':
# On Windows, we want \\server\share\path
win_path = path_part.replace('/', '\\')
return f"\\\\{parsed.netloc}{win_path}"
else:
# On Unix-like, we keep it as //server/path
return f"//{parsed.netloc}{path_part}"
# Local paths (file:///C:/path or file:///path)
path = unquote(parsed.path)
if os.name == 'nt':
# Remove leading slash for drive letters: /C:/ -> C:/
# Note: parsed.path on Windows for file:///C:/ starts with /C:/
if path.startswith('/') and len(path) > 2 and path[2] == ':':
path = path[1:]
return path.replace('/', '\\')
return path
except Exception as e:
logger.error(f"Error parsing file URL {url}: {e}")
# Fallback to simple replacement if urlparse fails
return unquote(url.replace('file://', ''))
def extract_links_from_html(html_content: str, base_url: str) -> List[str]:
"""
Extract all links from HTML content.
Args:
html_content: HTML content as string
base_url: Base URL to resolve relative links
Returns:
List of absolute URLs found in the HTML
"""
try:
soup = BeautifulSoup(html_content, 'html.parser')
links = []
# Find all <a> tags with href
for link in soup.find_all('a', href=True):
href = link['href']
# Convert relative URLs to absolute
absolute_url = urljoin(base_url, href)
# Only include http/https URLs
if absolute_url.startswith(('http://', 'https://')):
links.append(absolute_url)
return links
except Exception as e:
logger.error(f"Error extracting links: {e}")
return []
def download_web_url(url: str, sandbox_path: str, recursive: bool = False, max_depth: int = 1,
_visited: Set[str] = None, _current_depth: int = 0) -> str:
"""
Download web content to sandbox with optional recursive downloading.
Args:
url: URL to download
sandbox_path: Path to sandbox directory
recursive: If True, download linked pages (default: False)
max_depth: Maximum recursion depth for recursive downloads (default: 1)
_visited: Internal parameter to track visited URLs
_current_depth: Internal parameter to track current depth
Returns:
Path to downloaded file relative to sandbox, or error message
"""
# Initialize visited set for top-level call
if _visited is None:
_visited = set()
# Skip if already visited or max depth reached
if url in _visited or _current_depth > max_depth:
return f"Skipped (already visited or max depth): {url}"
_visited.add(url)
try:
# Parse URL to get filename
parsed = urlparse(url)
domain = parsed.netloc
path_parts = parsed.path.strip('/').split('/')
# Create subdirectory for domain
download_dir = os.path.join(sandbox_path, 'downloads', domain)
os.makedirs(download_dir, exist_ok=True)
# Determine filename
if path_parts and path_parts[-1]:
filename = unquote(path_parts[-1])
# If no extension, add .html
if '.' not in filename:
filename += '.html'
else:
# Use hash of URL as filename
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
filename = f'page_{url_hash}.html'
# Handle duplicate filenames by appending depth
if _current_depth > 0:
name, ext = os.path.splitext(filename)
filename = f"{name}_depth{_current_depth}{ext}"
# Download with timeout and User-Agent header
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
# Load config to check for proxies
config = DEFAULT_CONFIG
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r') as f:
config = json.load(f)
except Exception as e:
logger.error(f"Error loading config for proxy check: {e}")
proxies = get_proxies(config, url)
response = requests.get(url, timeout=30, allow_redirects=True, headers=headers, proxies=proxies)
response.raise_for_status()
# Save to file
file_path = os.path.join(download_dir, filename)
# Handle binary vs text content
content_type = response.headers.get('content-type', '').lower()
is_html = 'text' in content_type or 'html' in content_type
if is_html or 'json' in content_type:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(response.text)
else:
with open(file_path, 'wb') as f:
f.write(response.content)
# Return relative path
rel_path = os.path.relpath(file_path, sandbox_path)
logger.info(f"Downloaded {url} to {rel_path}")
# Recursive download if enabled and this is HTML
if recursive and is_html and _current_depth < max_depth:
try:
links = extract_links_from_html(response.text, url)
# Filter to same domain only
same_domain_links = [link for link in links if urlparse(link).netloc == domain]
logger.info(f"Found {len(same_domain_links)} same-domain links at depth {_current_depth}")
# Download linked pages
for link in same_domain_links[:50]: # Limit to 50 links per page to avoid excessive downloads
try:
download_web_url(link, sandbox_path, recursive=True, max_depth=max_depth,
_visited=_visited, _current_depth=_current_depth + 1)
except Exception as e:
logger.error(f"Error downloading linked page {link}: {e}")
continue
except Exception as e:
logger.error(f"Error processing links from {url}: {e}")
return rel_path
except requests.exceptions.RequestException as e:
error_msg = f"Error downloading {url}: {str(e)}"
logger.error(error_msg)
return error_msg
except Exception as e:
error_msg = f"Error saving {url}: {str(e)}"
logger.error(error_msg)
return error_msg
def copy_file_url(file_path: str, sandbox_path: str) -> str:
"""
Copy local file or directory to sandbox (recursive for directories).
Returns:
Path to copied file/directory relative to sandbox, or error message
"""
try:
if not os.path.exists(file_path):
return f"Error: File not found: {file_path}"
# Normalize path to handle trailing slashes which cause empty basename
# and convert to absolute path just in case
normalized_path = os.path.abspath(file_path).rstrip(os.sep)
# Get the base name
base_name = os.path.basename(normalized_path)
# If base_name is still empty (e.g., root directory), use a fallback name
if not base_name:
# For "C:\" -> "C_"
base_name = normalized_path.replace(':', '_').replace('\\', '_').replace('/', '_').strip('_')
if not base_name:
base_name = "root_import"
# Create 'imported' subdirectory
import_dir = os.path.join(sandbox_path, 'imported')
os.makedirs(import_dir, exist_ok=True)
dest_path = os.path.join(import_dir, base_name)
# Handle name conflicts
if os.path.exists(dest_path):
name, ext = os.path.splitext(base_name)
counter = 1
while os.path.exists(dest_path):
dest_path = os.path.join(import_dir, f"{name}_{counter}{ext}")
counter += 1
# Copy file or directory
if os.path.isdir(file_path):
shutil.copytree(file_path, dest_path, ignore=shutil.ignore_patterns('.git', '__pycache__', '*.pyc'))
else:
shutil.copy2(file_path, dest_path)
# Return relative path
rel_path = os.path.relpath(dest_path, sandbox_path)
logger.info(f"Copied {file_path} to {rel_path}")
return rel_path
except Exception as e:
error_msg = f"Error copying {file_path}: {str(e)}"
logger.error(error_msg)
return error_msg
def process_urls_in_prompt(prompt: str, sandbox_id: str) -> Tuple[str, List[str]]:
"""
Process all URLs in a user prompt by downloading/copying them to sandbox.
Args:
prompt: User's message text
sandbox_id: ID of the sandbox
Returns:
Tuple of (updated_prompt, list of result messages)
"""
urls = extract_urls(prompt)
results = []
if not urls['web_urls'] and not urls['file_urls']:
return prompt, []
sandbox_path = get_sandbox_path(sandbox_id)
# Check if user requested recursive download
# Look for patterns like "recursive", "level 1", "depth 1", etc.
recursive = False
max_depth = 1
if re.search(r'\b(recursive|crawl|spider|level|depth)\b', prompt, re.IGNORECASE):
recursive = True
# Look for depth/level specification
depth_match = re.search(r'\b(?:level|depth)\s*(\d+)\b', prompt, re.IGNORECASE)
if depth_match:
max_depth = int(depth_match.group(1))
# Process web URLs
for url in urls['web_urls']:
if recursive:
result = download_web_url(url, sandbox_path, recursive=True, max_depth=max_depth)
if result.startswith('Error') or result.startswith('Skipped'):
results.append(f"❌ {result}")
else:
results.append(f"✓ Downloaded {url} recursively (depth {max_depth}) to {result}")
else:
result = download_web_url(url, sandbox_path, recursive=False)
if result.startswith('Error'):
results.append(f"❌ {result}")
else:
results.append(f"✓ Downloaded {url} to {result}")
# Process file URLs
for file_path in urls['file_urls']:
result = copy_file_url(file_path, sandbox_path)
if result.startswith('Error'):
results.append(f"❌ {result}")
else:
results.append(f"✓ Copied {file_path} to {result}")
# Append results to prompt if any
if results:
updated_prompt = prompt + "\n\n---\n**Auto-imported files:**\n" + "\n".join(results)
return updated_prompt, results
return prompt, []