diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adc00831..403b19ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 8517010c..215d1045 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "1.1.27" description = "NLP2CMD - Transforms natural language into domain-specific commands (SQL, Shell, Docker, Kubernetes) using a multi-layered detection pipeline and thermodynamic optimization." readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "NLP2CMD Team", email = "tom@sapletta.com" } ] @@ -34,7 +34,6 @@ classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Code Generators", diff --git a/src/nlp2cmd/cli/commands/doctor.py b/src/nlp2cmd/cli/commands/doctor.py index b990c193..1dd5aa7b 100644 --- a/src/nlp2cmd/cli/commands/doctor.py +++ b/src/nlp2cmd/cli/commands/doctor.py @@ -11,17 +11,11 @@ from __future__ import annotations +import json import os import sys -import json -import socket -import subprocess -import shutil -import time from pathlib import Path from typing import Optional -from dataclasses import dataclass, field -from enum import Enum try: import requests @@ -39,37 +33,17 @@ HAS_RICH = False Console = None -# Import modular browser token retriever -try: - from nlp2cmd.browser_token import HFTokenRetriever, TokenConfig - _BROWSER_TOKEN_AVAILABLE = True -except ImportError: - _BROWSER_TOKEN_AVAILABLE = False - -# Import modular browser manager -try: - from nlp2cmd.browser_manager import ExistingBrowserManager, BrowserConfig - _BROWSER_MANAGER_AVAILABLE = True -except ImportError: - _BROWSER_MANAGER_AVAILABLE = False - - -class Status(Enum): - OK = "ok" - WARNING = "warning" - ERROR = "error" - INFO = "info" - FIXED = "fixed" +from nlp2cmd.cli.commands.doctor_browser import get_hf_token_via_browser +from nlp2cmd.cli.commands.doctor_types import CheckResult, Status - -@dataclass -class CheckResult: - name: str - status: Status - message: str - details: dict = field(default_factory=dict) - fix_applied: bool = False - fix_command: Optional[str] = None +__all__ = [ + "CheckResult", + "NP2CMDDoctor", + "Status", + "doctor_command", + "get_hf_token_via_browser", + "run_doctor", +] class NP2CMDDoctor: @@ -590,680 +564,6 @@ def run_doctor(auto_fix: bool = False, output_json: bool = False, fix_script: Op return not has_errors -def get_hf_token_via_browser(console: Optional[Console] = None) -> Optional[str]: - """Open browser to help user get HF_TOKEN from Hugging Face. - - Browser priority: - 1. Connect to existing browser (Firefox/Chrome via CDP) - 2. Open new system browser (firefox/chrome commands) - 3. Use Playwright (requires manual login) - - Returns the token if successfully retrieved. - """ - if console: - console.print("[cyan]π Opening Hugging Face in browser...[/cyan]") - else: - print("π Opening Hugging Face in browser...") - - # Try priority 1: Connect to existing browser - token = _try_existing_browser(console) - if token: - return token - - # Try priority 2: Open new system browser - token = _try_system_browser(console) - if token: - return token - - # Try priority 3: Use Playwright - token = _try_playwright_browser(console) - if token: - return token - - return None - - -def _try_existing_browser(console: Optional[Console] = None) -> Optional[str]: - """Try to connect to existing browser via CDP with detailed logging.""" - import socket - - if console: - console.print("[dim] [Stage 1/3] Checking for existing browser...[/dim]") - - # Check common CDP ports - cdp_ports = [9222, 9223, 9224, 9333] - found_port = None - - for port in cdp_ports: - if console: - console.print(f"[dim] Checking port {port}...[/dim]") - - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - result = sock.connect_ex(("localhost", port)) - sock.close() - - if result == 0: - found_port = port - if console: - console.print(f"[green] β Found browser on port {port}[/green]") - break - else: - if console: - console.print(f"[dim] Port {port}: not available[/dim]") - except Exception as e: - if console: - console.print(f"[dim] Port {port}: error - {e}[/dim]") - - if not found_port: - if console: - console.print("[dim] βΉ No existing browser with CDP found[/dim]") - console.print("[dim] Tip: Run 'firefox --remote-debugging-port=9222' first[/dim]") - return None - - # Try to connect via Playwright CDP - if console: - console.print(f"[cyan] β Connecting via Playwright to port {found_port}...[/cyan]") - - connection_success = False - browser = None - browser_type = None - - try: - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - # Try Chrome/Chromium first - try: - browser = p.chromium.connect_over_cdp(f"http://localhost:{found_port}") - browser_type = "Chrome/Chromium" - connection_success = True - if console: - console.print(f"[green] β Connected to {browser_type} via CDP[/green]") - except Exception as chrome_err: - if console: - console.print(f"[dim] Chromium CDP failed: {str(chrome_err)[:50]}...[/dim]") - - # Try Firefox - try: - browser = p.firefox.connect_over_cdp(f"http://localhost:{found_port}") - browser_type = "Firefox" - connection_success = True - if console: - console.print(f"[green] β Connected to Firefox via CDP[/green]") - except Exception as firefox_err: - if console: - console.print(f"[red] β CDP connection failed for both browsers[/red]") - console.print(f"[dim] Chrome error: {str(chrome_err)[:30]}...[/dim]") - console.print(f"[dim] Firefox error: {str(firefox_err)[:30]}...[/dim]") - return None - - if not connection_success or not browser: - if console: - console.print(f"[red] β Browser connection established but browser object is None[/red]") - return None - - # Create new context and page - if console: - console.print(f"[dim] Creating browser context...[/dim]") - - try: - context = browser.new_context() - page = context.new_page() - if console: - console.print(f"[green] β Browser context created[/green]") - except Exception as ctx_err: - if console: - console.print(f"[red] β Failed to create browser context: {ctx_err}[/red]") - return None - - # Navigate - if console: - console.print(f"[cyan] β Navigating to huggingface.co...[/cyan]") - - nav_success = False - expected_url_pattern = "huggingface.co/settings/tokens" - actual_url = None - - try: - page.goto("https://huggingface.co/settings/tokens", timeout=30000) - actual_url = page.url - - # Verify we reached the expected page (or at least HF domain) - if expected_url_pattern in actual_url: - nav_success = True - if console: - console.print(f"[green] β Page loaded at correct URL[/green]") - elif "huggingface.co/login" in actual_url: - # This is expected if not logged in, but we should warn - nav_success = True # Page loaded, just needs login - if console: - console.print(f"[yellow] β Page loaded but requires login first[/yellow]") - console.print(f"[dim] URL: {actual_url}[/dim]") - elif "huggingface.co" in actual_url: - nav_success = True - if console: - console.print(f"[yellow] β Page loaded on HF domain but different path[/yellow]") - console.print(f"[dim] URL: {actual_url}[/dim]") - else: - if console: - console.print(f"[red] β Page loaded but unexpected URL[/red]") - console.print(f"[dim] Expected: {expected_url_pattern}[/dim]") - console.print(f"[dim] Actual: {actual_url}[/dim]") - - except Exception as e: - if console: - console.print(f"[red] β Navigation failed: {e}[/red]") - if actual_url: - console.print(f"[dim] Last URL: {actual_url}[/dim]") - nav_success = False - - except ImportError: - if console: - console.print(f"[red] β Playwright not installed[/red]") - return None - except Exception as e: - if console: - console.print(f"[red] β CDP connection error: {e}[/red]") - return None - - -def _try_system_browser(console: Optional[Console] = None) -> Optional[str]: - """Try to open new system browser with detailed stage logging.""" - import subprocess - import time - import socket - - if console: - console.print("[dim] [Stage 2/3] Opening system browser...[/dim]") - - # Try to open Firefox or Chrome/Chromium - browsers_to_try = [ - ("firefox", ["firefox", "--new-window", "--remote-debugging-port=9222"]), - ("google-chrome", ["google-chrome", "--new-window", "--remote-debugging-port=9222"]), - ("chromium", ["chromium", "--new-window", "--remote-debugging-port=9222"]), - ("chromium-browser", ["chromium-browser", "--new-window", "--remote-debugging-port=9222"]), - ] - - for browser_name, cmd in browsers_to_try: - try: - # Stage 2.1: Check if browser binary exists - if console: - console.print(f"[dim] Checking {browser_name}...[/dim]") - - result = subprocess.run(["which", browser_name], capture_output=True, timeout=5) - if result.returncode != 0: - if console: - console.print(f"[dim] β {browser_name} not found[/dim]") - continue - - if console: - console.print(f"[cyan] β Launching {browser_name}...[/cyan]") - - # Stage 2.2: Launch browser with CDP enabled - try: - process = subprocess.Popen( - cmd + ["about:blank"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True - ) - if console: - console.print(f"[dim] PID: {process.pid}[/dim]") - except Exception as e: - if console: - console.print(f"[red] β Failed to launch: {e}[/red]") - continue - - # Stage 2.3: Wait for browser to initialize - if console: - console.print("[dim] Waiting for browser to start (3s)...[/dim]") - time.sleep(3) - - # Stage 2.4: Try to connect via CDP (with actual protocol verification) - if console: - console.print("[dim] Checking CDP port 9222 (with protocol verification)...[/dim]") - - cdp_available = False - for attempt in range(5): - try: - # First: basic TCP check - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - result = sock.connect_ex(("localhost", 9222)) - sock.close() - - if result == 0: - # Second: verify it's actually a CDP endpoint by making HTTP request - import urllib.request - try: - response = urllib.request.urlopen( - "http://localhost:9222/json/version", - timeout=3 - ) - cdp_info = response.read().decode('utf-8') - if 'Browser' in cdp_info or 'Protocol-Version' in cdp_info: - cdp_available = True - if console: - console.print(f"[green] β CDP port 9222 ready (verified protocol)[/green]") - break - else: - if console: - console.print(f"[dim] Attempt {attempt+1}/5: port open but not CDP protocol[/dim]") - except Exception as http_err: - if console: - console.print(f"[dim] Attempt {attempt+1}/5: port open but CDP check failed: {str(http_err)[:40]}[/dim]") - else: - if console: - console.print(f"[dim] Attempt {attempt+1}/5: port not ready (code: {result})[/dim]") - except Exception as e: - if console: - console.print(f"[dim] Attempt {attempt+1}/5: {str(e)[:40]}[/dim]") - time.sleep(1) - - if not cdp_available: - if console: - console.print(f"[yellow] β CDP not available after 5 attempts[/yellow]") - console.print(f"[dim] Browser launched but CDP protocol not responding[/dim]") - continue - - # Stage 2.5: Connect with Playwright - if console: - console.print(f"[cyan] β Connecting via Playwright CDP...[/cyan]") - - connection_success = False - browser = None - - try: - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - try: - browser = p.chromium.connect_over_cdp("http://localhost:9222") - connection_success = True - if console: - console.print(f"[green] β Connected to {browser_name} via CDP[/green]") - except Exception as e: - if console: - console.print(f"[yellow] β CDP connect failed: {str(e)[:50]}...[/yellow]") - console.print(f"[dim] Falling back to manual mode...[/dim]") - return _manual_browser_instructions(console, browser_name) - - if not connection_success or not browser: - if console: - console.print(f"[red] β Connection reported success but browser is None[/red]") - return _manual_browser_instructions(console, browser_name) - - try: - context = browser.new_context() - page = context.new_page() - if console: - console.print(f"[green] β Browser context created[/green]") - except Exception as ctx_err: - if console: - console.print(f"[red] β Failed to create context: {ctx_err}[/red]") - return _manual_browser_instructions(console, browser_name) - - # Stage 2.6: Navigate to HF - if console: - console.print(f"[cyan] β Navigating to huggingface.co...[/cyan]") - - nav_success = False - expected_url_pattern = "huggingface.co/settings/tokens" - actual_url = None - - try: - page.goto("https://huggingface.co/settings/tokens", timeout=30000) - actual_url = page.url - - # Verify we reached the expected page (or at least HF domain) - if expected_url_pattern in actual_url: - nav_success = True - if console: - console.print(f"[green] β Page loaded at correct URL[/green]") - elif "huggingface.co/login" in actual_url: - nav_success = True # Page loaded, just needs login - if console: - console.print(f"[yellow] β Page loaded but requires login first[/yellow]") - console.print(f"[dim] URL: {actual_url}[/dim]") - elif "huggingface.co" in actual_url: - nav_success = True - if console: - console.print(f"[yellow] β Page loaded on HF domain but different path[/yellow]") - console.print(f"[dim] URL: {actual_url}[/dim]") - else: - if console: - console.print(f"[red] β Page loaded but unexpected URL[/red]") - console.print(f"[dim] Expected: {expected_url_pattern}[/dim]") - console.print(f"[dim] Actual: {actual_url}[/dim]") - - except Exception as e: - if console: - console.print(f"[red] β Navigation failed: {e}[/red]") - if actual_url: - console.print(f"[dim] Last URL: {actual_url}[/dim]") - nav_success = False - - # Stage 2.7: Get token from user (even if nav had issues, let user try) - if nav_success or actual_url: # Only proceed if we at least loaded something - return _navigate_and_get_token(page, console, browser_name) - else: - if console: - console.print(f"[red] β Cannot proceed - page did not load[/red]") - return None - - except ImportError: - if console: - console.print(f"[red] β Playwright not installed[/red]") - return _manual_browser_instructions(console, browser_name) - except Exception as e: - if console: - console.print(f"[red] β Playwright error: {e}[/red]") - return _manual_browser_instructions(console, browser_name) - - except Exception as e: - if console: - console.print(f"[red] β Error with {browser_name}: {e}[/red]") - continue - - if console: - console.print("[red] β No system browser could be opened[/red]") - return None - - -def _try_playwright_browser(console: Optional[Console] = None) -> Optional[str]: - """Last resort: Use Playwright to launch browser with detailed logging.""" - try: - from playwright.sync_api import sync_playwright - - if console: - console.print("[dim] [Stage 3/3] Using Playwright (last resort)...[/dim]") - console.print("[yellow] β Note: You'll need to login manually[/yellow]") - else: - print(" [Stage 3/3] Using Playwright (you may need to login manually)...") - - with sync_playwright() as p: - # Try Firefox first (better privacy) - browser_type = "firefox" - try: - if console: - console.print("[dim] Launching Firefox...[/dim]") - browser = p.firefox.launch(headless=False) - if console: - console.print("[green] β Firefox launched[/green]") - except Exception as firefox_err: - if console: - console.print(f"[dim] Firefox failed: {firefox_err}[/dim]") - console.print("[dim] Trying Chromium...[/dim]") - - try: - browser = p.chromium.launch(headless=False) - browser_type = "chromium" - if console: - console.print("[green] β Chromium launched[/green]") - except Exception as chromium_err: - if console: - console.print(f"[red] β Both browsers failed[/red]") - return None - - # Create context and page - if console: - console.print("[dim] Creating browser context...[/dim]") - - context = browser.new_context() - page = context.new_page() - - # Navigate - if console: - console.print(f"[cyan] β Navigating to huggingface.co...[/cyan]") - - nav_success = False - expected_url_pattern = "huggingface.co/settings/tokens" - actual_url = None - - try: - page.goto("https://huggingface.co/settings/tokens", timeout=30000) - actual_url = page.url - - # Verify we reached the expected page (or at least HF domain) - if expected_url_pattern in actual_url: - nav_success = True - if console: - console.print(f"[green] β Page loaded at correct URL[/green]") - elif "huggingface.co/login" in actual_url: - nav_success = True - if console: - console.print(f"[yellow] β Page loaded but requires login first[/yellow]") - console.print(f"[dim] URL: {actual_url}[/dim]") - elif "huggingface.co" in actual_url: - nav_success = True - if console: - console.print(f"[yellow] β Page loaded on HF domain but different path[/yellow]") - console.print(f"[dim] URL: {actual_url}[/dim]") - else: - if console: - console.print(f"[red] β Page loaded but unexpected URL[/red]") - console.print(f"[dim] Expected: {expected_url_pattern}[/dim]") - console.print(f"[dim] Actual: {actual_url}[/dim]") - - except Exception as e: - if console: - console.print(f"[red] β Navigation failed: {e}[/red]") - if actual_url: - console.print(f"[dim] Last URL: {actual_url}[/dim]") - nav_success = False - - # Only proceed if page loaded - if nav_success or actual_url: - return _navigate_and_get_token(page, console, browser_type) - else: - if console: - console.print(f"[red] β Cannot proceed - page did not load[/red]") - return None - - except ImportError: - if console: - console.print("[red] β Playwright not installed[/red]") - else: - print(" β Playwright not installed") - return None - except Exception as e: - if console: - console.print(f"[red] β Playwright error: {e}[/red]") - else: - print(f" β Playwright error: {e}") - return None - - -def _navigate_and_get_token(page, console: Optional[Console], browser_type: str) -> Optional[str]: - """Navigate to HuggingFace and get token from user.""" - - if console: - console.print(f"[dim] [Token Step 1/4] Already navigated to HF tokens page[/dim]") - - # Verify page loaded by checking URL - try: - current_url = page.url - if console: - console.print(f"[dim] Current URL: {current_url}[/dim]") - except Exception as e: - if console: - console.print(f"[yellow] β Could not get URL: {e}[/yellow]") - - # Show instructions - if console: - console.print(f"[cyan] [Token Step 2/4] Showing instructions:[/cyan]") - console.print(" 1. Login to Hugging Face if needed") - console.print(" 2. Click 'New token' button") - console.print(" 3. Set name: 'nlp2cmd'") - console.print(" 4. Select 'Read' role") - console.print(" 5. Click 'Generate token'") - console.print(" 6. Copy the token and paste it here") - else: - print("\nπ Instructions:") - print(" 1. Login to Hugging Face if needed") - print(" 2. Click 'New token' button") - print(" 3. Set name: 'nlp2cmd'") - print(" 4. Select 'Read' role") - print(" 5. Click 'Generate token'") - print(" 6. Copy the token and paste it here") - - # Interactive prompt for token - if console: - console.print(f"[cyan] [Token Step 3/4] Waiting for user input...[/cyan]") - console.print(f"[bold yellow] β οΈ CHECK YOUR TERMINAL - waiting for token input![/bold yellow]") - console.print(f"[bold] The browser should be open.[/bold]") - console.print(f"[bold] After you create the token in the browser, come back here and paste it below.[/bold]") - - try: - # Print visible separator to catch attention - print("\n" + "="*60) - print("π ENTER YOUR HF_TOKEN BELOW π") - print("="*60) - - token = input("π Paste HF_TOKEN here: ").strip() - - print("="*60) - - if console: - console.print(f"[dim] Input received: {'Yes' if token else 'No'}[/dim]") - - if token: - if console: - console.print(f"[cyan] [Token Step 4/4] Closing browser page...[/cyan]") - - try: - page.close() - if console: - console.print(f"[green] β Page closed[/green]") - except Exception as e: - if console: - console.print(f"[dim] Note: Could not close page: {e}[/dim]") - - return token - else: - if console: - console.print(f"[yellow] β No token entered[/yellow]") - except EOFError: - if console: - console.print(f"[red] β EOFError (no input available)[/red]") - except KeyboardInterrupt: - if console: - console.print(f"[yellow] β User cancelled (KeyboardInterrupt)[/yellow]") - except Exception as e: - if console: - console.print(f"[red] β Error getting input: {e}[/red]") - - # Cleanup on failure - if console: - console.print(f"[dim] Cleaning up...[/dim]") - - try: - page.close() - except Exception: - pass - - return None - - -def _try_existing_browser_dispatch(console: Optional[Console] = None) -> Optional[str]: - """New existing browser token retrieval using modular ExistingBrowserManager. - - This is the refactored version that uses the browser_manager package. - Falls back to legacy _try_existing_browser if modular version unavailable. - """ - if not _BROWSER_MANAGER_AVAILABLE: - return _try_existing_browser(console) - - try: - if console: - console.print("[dim] [Stage 1/3] Using modular browser manager...[/dim]") - - manager = ExistingBrowserManager() - result = manager.connect_and_navigate(verbose=True, console=console) - - if not result.success: - if console and result.error: - console.print(f"[dim] Modular manager failed: {result.error}[/dim]") - return _try_existing_browser(console) - - if result.page: - token = manager.get_token_interactive(result, verbose=True, console=console) - return token - - return None - - except Exception as e: - # Fall back to legacy implementation - if console: - console.print(f"[dim] Modular manager failed: {e}[/dim]") - console.print("[dim] Falling back to legacy implementation...[/dim]") - return _try_existing_browser(console) - - -def _try_playwright_browser_dispatch(console: Optional[Console] = None) -> Optional[str]: - """New browser token retrieval using modular HFTokenRetriever. - - This is the refactored version that uses the browser_token package. - Falls back to legacy _try_playwright_browser if modular version unavailable. - """ - if not _BROWSER_TOKEN_AVAILABLE: - return _try_playwright_browser(console) - - try: - if console: - console.print("[dim] [Stage 3/3] Using modular browser token retriever...[/dim]") - console.print("[yellow] β Note: You'll need to login manually[/yellow]") - - retriever = HFTokenRetriever() - result = retriever.retrieve() - - if result.success: - if console: - console.print(f"[green] β Token retrieved via {result.browser_type}[/green]") - return result.token - else: - if console: - if result.error: - console.print(f"[red] β {result.error}[/red]") - else: - console.print(f"[yellow] β {result.message}[/yellow]") - return None - - except Exception as e: - # Fall back to legacy implementation - if console: - console.print(f"[dim] Modular retriever failed: {e}[/dim]") - console.print("[dim] Falling back to legacy implementation...[/dim]") - return _try_playwright_browser(console) - - -def _manual_browser_instructions(console: Optional[Console], browser_name: str) -> Optional[str]: - """Show manual instructions when browser automation fails.""" - if console: - console.print(f"\n[cyan]π {browser_name} opened. Please:[/cyan]") - console.print(" 1. Go to: https://huggingface.co/settings/tokens") - console.print(" 2. Login if not logged in") - console.print(" 3. Create new token (name: nlp2cmd, role: read)") - console.print(" 4. Copy the token") - else: - print(f"\nπ {browser_name} opened. Please:") - print(" 1. Go to: https://huggingface.co/settings/tokens") - print(" 2. Login if not logged in") - print(" 3. Create new token (name: nlp2cmd, role: read)") - print(" 4. Copy the token") - - try: - token = input("\nπ Paste HF_TOKEN here: ").strip() - if token: - return token - except (EOFError, KeyboardInterrupt): - pass - - return None - - if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="NLP2CMD System Doctor") diff --git a/src/nlp2cmd/cli/commands/doctor_browser.py b/src/nlp2cmd/cli/commands/doctor_browser.py new file mode 100644 index 00000000..c21bb67f --- /dev/null +++ b/src/nlp2cmd/cli/commands/doctor_browser.py @@ -0,0 +1,704 @@ +"""Browser automation helpers for HF token retrieval.""" +from __future__ import annotations + +import os +import socket +import subprocess +import shutil +import time +from pathlib import Path +from typing import Optional + +try: + from rich.console import Console + HAS_RICH = True +except ImportError: + HAS_RICH = False + Console = None + +try: + from nlp2cmd.browser_token import HFTokenRetriever, TokenConfig + _BROWSER_TOKEN_AVAILABLE = True +except ImportError: + _BROWSER_TOKEN_AVAILABLE = False + +try: + from nlp2cmd.browser_manager import ExistingBrowserManager, BrowserConfig + _BROWSER_MANAGER_AVAILABLE = True +except ImportError: + _BROWSER_MANAGER_AVAILABLE = False + + +def get_hf_token_via_browser(console: Optional[Console] = None) -> Optional[str]: + """Open browser to help user get HF_TOKEN from Hugging Face. + + Browser priority: + 1. Connect to existing browser (Firefox/Chrome via CDP) + 2. Open new system browser (firefox/chrome commands) + 3. Use Playwright (requires manual login) + + Returns the token if successfully retrieved. + """ + if console: + console.print("[cyan]π Opening Hugging Face in browser...[/cyan]") + else: + print("π Opening Hugging Face in browser...") + + # Try priority 1: Connect to existing browser + token = _try_existing_browser(console) + if token: + return token + + # Try priority 2: Open new system browser + token = _try_system_browser(console) + if token: + return token + + # Try priority 3: Use Playwright + token = _try_playwright_browser(console) + if token: + return token + + return None + + +def _try_existing_browser(console: Optional[Console] = None) -> Optional[str]: + """Try to connect to existing browser via CDP with detailed logging.""" + import socket + + if console: + console.print("[dim] [Stage 1/3] Checking for existing browser...[/dim]") + + # Check common CDP ports + cdp_ports = [9222, 9223, 9224, 9333] + found_port = None + + for port in cdp_ports: + if console: + console.print(f"[dim] Checking port {port}...[/dim]") + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1) + result = sock.connect_ex(("localhost", port)) + sock.close() + + if result == 0: + found_port = port + if console: + console.print(f"[green] β Found browser on port {port}[/green]") + break + else: + if console: + console.print(f"[dim] Port {port}: not available[/dim]") + except Exception as e: + if console: + console.print(f"[dim] Port {port}: error - {e}[/dim]") + + if not found_port: + if console: + console.print("[dim] βΉ No existing browser with CDP found[/dim]") + console.print("[dim] Tip: Run 'firefox --remote-debugging-port=9222' first[/dim]") + return None + + # Try to connect via Playwright CDP + if console: + console.print(f"[cyan] β Connecting via Playwright to port {found_port}...[/cyan]") + + connection_success = False + browser = None + browser_type = None + + try: + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + # Try Chrome/Chromium first + try: + browser = p.chromium.connect_over_cdp(f"http://localhost:{found_port}") + browser_type = "Chrome/Chromium" + connection_success = True + if console: + console.print(f"[green] β Connected to {browser_type} via CDP[/green]") + except Exception as chrome_err: + if console: + console.print(f"[dim] Chromium CDP failed: {str(chrome_err)[:50]}...[/dim]") + + # Try Firefox + try: + browser = p.firefox.connect_over_cdp(f"http://localhost:{found_port}") + browser_type = "Firefox" + connection_success = True + if console: + console.print(f"[green] β Connected to Firefox via CDP[/green]") + except Exception as firefox_err: + if console: + console.print(f"[red] β CDP connection failed for both browsers[/red]") + console.print(f"[dim] Chrome error: {str(chrome_err)[:30]}...[/dim]") + console.print(f"[dim] Firefox error: {str(firefox_err)[:30]}...[/dim]") + return None + + if not connection_success or not browser: + if console: + console.print(f"[red] β Browser connection established but browser object is None[/red]") + return None + + # Create new context and page + if console: + console.print(f"[dim] Creating browser context...[/dim]") + + try: + context = browser.new_context() + page = context.new_page() + if console: + console.print(f"[green] β Browser context created[/green]") + except Exception as ctx_err: + if console: + console.print(f"[red] β Failed to create browser context: {ctx_err}[/red]") + return None + + # Navigate + if console: + console.print(f"[cyan] β Navigating to huggingface.co...[/cyan]") + + nav_success = False + expected_url_pattern = "huggingface.co/settings/tokens" + actual_url = None + + try: + page.goto("https://huggingface.co/settings/tokens", timeout=30000) + actual_url = page.url + + # Verify we reached the expected page (or at least HF domain) + if expected_url_pattern in actual_url: + nav_success = True + if console: + console.print(f"[green] β Page loaded at correct URL[/green]") + elif "huggingface.co/login" in actual_url: + # This is expected if not logged in, but we should warn + nav_success = True # Page loaded, just needs login + if console: + console.print(f"[yellow] β Page loaded but requires login first[/yellow]") + console.print(f"[dim] URL: {actual_url}[/dim]") + elif "huggingface.co" in actual_url: + nav_success = True + if console: + console.print(f"[yellow] β Page loaded on HF domain but different path[/yellow]") + console.print(f"[dim] URL: {actual_url}[/dim]") + else: + if console: + console.print(f"[red] β Page loaded but unexpected URL[/red]") + console.print(f"[dim] Expected: {expected_url_pattern}[/dim]") + console.print(f"[dim] Actual: {actual_url}[/dim]") + + except Exception as e: + if console: + console.print(f"[red] β Navigation failed: {e}[/red]") + if actual_url: + console.print(f"[dim] Last URL: {actual_url}[/dim]") + nav_success = False + + except ImportError: + if console: + console.print(f"[red] β Playwright not installed[/red]") + return None + except Exception as e: + if console: + console.print(f"[red] β CDP connection error: {e}[/red]") + return None + + +def _try_system_browser(console: Optional[Console] = None) -> Optional[str]: + """Try to open new system browser with detailed stage logging.""" + import subprocess + import time + import socket + + if console: + console.print("[dim] [Stage 2/3] Opening system browser...[/dim]") + + # Try to open Firefox or Chrome/Chromium + browsers_to_try = [ + ("firefox", ["firefox", "--new-window", "--remote-debugging-port=9222"]), + ("google-chrome", ["google-chrome", "--new-window", "--remote-debugging-port=9222"]), + ("chromium", ["chromium", "--new-window", "--remote-debugging-port=9222"]), + ("chromium-browser", ["chromium-browser", "--new-window", "--remote-debugging-port=9222"]), + ] + + for browser_name, cmd in browsers_to_try: + try: + # Stage 2.1: Check if browser binary exists + if console: + console.print(f"[dim] Checking {browser_name}...[/dim]") + + result = subprocess.run(["which", browser_name], capture_output=True, timeout=5) + if result.returncode != 0: + if console: + console.print(f"[dim] β {browser_name} not found[/dim]") + continue + + if console: + console.print(f"[cyan] β Launching {browser_name}...[/cyan]") + + # Stage 2.2: Launch browser with CDP enabled + try: + process = subprocess.Popen( + cmd + ["about:blank"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + if console: + console.print(f"[dim] PID: {process.pid}[/dim]") + except Exception as e: + if console: + console.print(f"[red] β Failed to launch: {e}[/red]") + continue + + # Stage 2.3: Wait for browser to initialize + if console: + console.print("[dim] Waiting for browser to start (3s)...[/dim]") + time.sleep(3) + + # Stage 2.4: Try to connect via CDP (with actual protocol verification) + if console: + console.print("[dim] Checking CDP port 9222 (with protocol verification)...[/dim]") + + cdp_available = False + for attempt in range(5): + try: + # First: basic TCP check + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex(("localhost", 9222)) + sock.close() + + if result == 0: + # Second: verify it's actually a CDP endpoint by making HTTP request + import urllib.request + try: + response = urllib.request.urlopen( + "http://localhost:9222/json/version", + timeout=3 + ) + cdp_info = response.read().decode('utf-8') + if 'Browser' in cdp_info or 'Protocol-Version' in cdp_info: + cdp_available = True + if console: + console.print(f"[green] β CDP port 9222 ready (verified protocol)[/green]") + break + else: + if console: + console.print(f"[dim] Attempt {attempt+1}/5: port open but not CDP protocol[/dim]") + except Exception as http_err: + if console: + console.print(f"[dim] Attempt {attempt+1}/5: port open but CDP check failed: {str(http_err)[:40]}[/dim]") + else: + if console: + console.print(f"[dim] Attempt {attempt+1}/5: port not ready (code: {result})[/dim]") + except Exception as e: + if console: + console.print(f"[dim] Attempt {attempt+1}/5: {str(e)[:40]}[/dim]") + time.sleep(1) + + if not cdp_available: + if console: + console.print(f"[yellow] β CDP not available after 5 attempts[/yellow]") + console.print(f"[dim] Browser launched but CDP protocol not responding[/dim]") + continue + + # Stage 2.5: Connect with Playwright + if console: + console.print(f"[cyan] β Connecting via Playwright CDP...[/cyan]") + + connection_success = False + browser = None + + try: + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + try: + browser = p.chromium.connect_over_cdp("http://localhost:9222") + connection_success = True + if console: + console.print(f"[green] β Connected to {browser_name} via CDP[/green]") + except Exception as e: + if console: + console.print(f"[yellow] β CDP connect failed: {str(e)[:50]}...[/yellow]") + console.print(f"[dim] Falling back to manual mode...[/dim]") + return _manual_browser_instructions(console, browser_name) + + if not connection_success or not browser: + if console: + console.print(f"[red] β Connection reported success but browser is None[/red]") + return _manual_browser_instructions(console, browser_name) + + try: + context = browser.new_context() + page = context.new_page() + if console: + console.print(f"[green] β Browser context created[/green]") + except Exception as ctx_err: + if console: + console.print(f"[red] β Failed to create context: {ctx_err}[/red]") + return _manual_browser_instructions(console, browser_name) + + # Stage 2.6: Navigate to HF + if console: + console.print(f"[cyan] β Navigating to huggingface.co...[/cyan]") + + nav_success = False + expected_url_pattern = "huggingface.co/settings/tokens" + actual_url = None + + try: + page.goto("https://huggingface.co/settings/tokens", timeout=30000) + actual_url = page.url + + # Verify we reached the expected page (or at least HF domain) + if expected_url_pattern in actual_url: + nav_success = True + if console: + console.print(f"[green] β Page loaded at correct URL[/green]") + elif "huggingface.co/login" in actual_url: + nav_success = True # Page loaded, just needs login + if console: + console.print(f"[yellow] β Page loaded but requires login first[/yellow]") + console.print(f"[dim] URL: {actual_url}[/dim]") + elif "huggingface.co" in actual_url: + nav_success = True + if console: + console.print(f"[yellow] β Page loaded on HF domain but different path[/yellow]") + console.print(f"[dim] URL: {actual_url}[/dim]") + else: + if console: + console.print(f"[red] β Page loaded but unexpected URL[/red]") + console.print(f"[dim] Expected: {expected_url_pattern}[/dim]") + console.print(f"[dim] Actual: {actual_url}[/dim]") + + except Exception as e: + if console: + console.print(f"[red] β Navigation failed: {e}[/red]") + if actual_url: + console.print(f"[dim] Last URL: {actual_url}[/dim]") + nav_success = False + + # Stage 2.7: Get token from user (even if nav had issues, let user try) + if nav_success or actual_url: # Only proceed if we at least loaded something + return _navigate_and_get_token(page, console, browser_name) + else: + if console: + console.print(f"[red] β Cannot proceed - page did not load[/red]") + return None + + except ImportError: + if console: + console.print(f"[red] β Playwright not installed[/red]") + return _manual_browser_instructions(console, browser_name) + except Exception as e: + if console: + console.print(f"[red] β Playwright error: {e}[/red]") + return _manual_browser_instructions(console, browser_name) + + except Exception as e: + if console: + console.print(f"[red] β Error with {browser_name}: {e}[/red]") + continue + + if console: + console.print("[red] β No system browser could be opened[/red]") + return None + + +def _try_playwright_browser(console: Optional[Console] = None) -> Optional[str]: + """Last resort: Use Playwright to launch browser with detailed logging.""" + try: + from playwright.sync_api import sync_playwright + + if console: + console.print("[dim] [Stage 3/3] Using Playwright (last resort)...[/dim]") + console.print("[yellow] β Note: You'll need to login manually[/yellow]") + else: + print(" [Stage 3/3] Using Playwright (you may need to login manually)...") + + with sync_playwright() as p: + # Try Firefox first (better privacy) + browser_type = "firefox" + try: + if console: + console.print("[dim] Launching Firefox...[/dim]") + browser = p.firefox.launch(headless=False) + if console: + console.print("[green] β Firefox launched[/green]") + except Exception as firefox_err: + if console: + console.print(f"[dim] Firefox failed: {firefox_err}[/dim]") + console.print("[dim] Trying Chromium...[/dim]") + + try: + browser = p.chromium.launch(headless=False) + browser_type = "chromium" + if console: + console.print("[green] β Chromium launched[/green]") + except Exception as chromium_err: + if console: + console.print(f"[red] β Both browsers failed[/red]") + return None + + # Create context and page + if console: + console.print("[dim] Creating browser context...[/dim]") + + context = browser.new_context() + page = context.new_page() + + # Navigate + if console: + console.print(f"[cyan] β Navigating to huggingface.co...[/cyan]") + + nav_success = False + expected_url_pattern = "huggingface.co/settings/tokens" + actual_url = None + + try: + page.goto("https://huggingface.co/settings/tokens", timeout=30000) + actual_url = page.url + + # Verify we reached the expected page (or at least HF domain) + if expected_url_pattern in actual_url: + nav_success = True + if console: + console.print(f"[green] β Page loaded at correct URL[/green]") + elif "huggingface.co/login" in actual_url: + nav_success = True + if console: + console.print(f"[yellow] β Page loaded but requires login first[/yellow]") + console.print(f"[dim] URL: {actual_url}[/dim]") + elif "huggingface.co" in actual_url: + nav_success = True + if console: + console.print(f"[yellow] β Page loaded on HF domain but different path[/yellow]") + console.print(f"[dim] URL: {actual_url}[/dim]") + else: + if console: + console.print(f"[red] β Page loaded but unexpected URL[/red]") + console.print(f"[dim] Expected: {expected_url_pattern}[/dim]") + console.print(f"[dim] Actual: {actual_url}[/dim]") + + except Exception as e: + if console: + console.print(f"[red] β Navigation failed: {e}[/red]") + if actual_url: + console.print(f"[dim] Last URL: {actual_url}[/dim]") + nav_success = False + + # Only proceed if page loaded + if nav_success or actual_url: + return _navigate_and_get_token(page, console, browser_type) + else: + if console: + console.print(f"[red] β Cannot proceed - page did not load[/red]") + return None + + except ImportError: + if console: + console.print("[red] β Playwright not installed[/red]") + else: + print(" β Playwright not installed") + return None + except Exception as e: + if console: + console.print(f"[red] β Playwright error: {e}[/red]") + else: + print(f" β Playwright error: {e}") + return None + + +def _navigate_and_get_token(page, console: Optional[Console], browser_type: str) -> Optional[str]: + """Navigate to HuggingFace and get token from user.""" + + if console: + console.print(f"[dim] [Token Step 1/4] Already navigated to HF tokens page[/dim]") + + # Verify page loaded by checking URL + try: + current_url = page.url + if console: + console.print(f"[dim] Current URL: {current_url}[/dim]") + except Exception as e: + if console: + console.print(f"[yellow] β Could not get URL: {e}[/yellow]") + + # Show instructions + if console: + console.print(f"[cyan] [Token Step 2/4] Showing instructions:[/cyan]") + console.print(" 1. Login to Hugging Face if needed") + console.print(" 2. Click 'New token' button") + console.print(" 3. Set name: 'nlp2cmd'") + console.print(" 4. Select 'Read' role") + console.print(" 5. Click 'Generate token'") + console.print(" 6. Copy the token and paste it here") + else: + print("\nπ Instructions:") + print(" 1. Login to Hugging Face if needed") + print(" 2. Click 'New token' button") + print(" 3. Set name: 'nlp2cmd'") + print(" 4. Select 'Read' role") + print(" 5. Click 'Generate token'") + print(" 6. Copy the token and paste it here") + + # Interactive prompt for token + if console: + console.print(f"[cyan] [Token Step 3/4] Waiting for user input...[/cyan]") + console.print(f"[bold yellow] β οΈ CHECK YOUR TERMINAL - waiting for token input![/bold yellow]") + console.print(f"[bold] The browser should be open.[/bold]") + console.print(f"[bold] After you create the token in the browser, come back here and paste it below.[/bold]") + + try: + # Print visible separator to catch attention + print("\n" + "="*60) + print("π ENTER YOUR HF_TOKEN BELOW π") + print("="*60) + + token = input("π Paste HF_TOKEN here: ").strip() + + print("="*60) + + if console: + console.print(f"[dim] Input received: {'Yes' if token else 'No'}[/dim]") + + if token: + if console: + console.print(f"[cyan] [Token Step 4/4] Closing browser page...[/cyan]") + + try: + page.close() + if console: + console.print(f"[green] β Page closed[/green]") + except Exception as e: + if console: + console.print(f"[dim] Note: Could not close page: {e}[/dim]") + + return token + else: + if console: + console.print(f"[yellow] β No token entered[/yellow]") + except EOFError: + if console: + console.print(f"[red] β EOFError (no input available)[/red]") + except KeyboardInterrupt: + if console: + console.print(f"[yellow] β User cancelled (KeyboardInterrupt)[/yellow]") + except Exception as e: + if console: + console.print(f"[red] β Error getting input: {e}[/red]") + + # Cleanup on failure + if console: + console.print(f"[dim] Cleaning up...[/dim]") + + try: + page.close() + except Exception: + pass + + return None + + +def _try_existing_browser_dispatch(console: Optional[Console] = None) -> Optional[str]: + """New existing browser token retrieval using modular ExistingBrowserManager. + + This is the refactored version that uses the browser_manager package. + Falls back to legacy _try_existing_browser if modular version unavailable. + """ + if not _BROWSER_MANAGER_AVAILABLE: + return _try_existing_browser(console) + + try: + if console: + console.print("[dim] [Stage 1/3] Using modular browser manager...[/dim]") + + manager = ExistingBrowserManager() + result = manager.connect_and_navigate(verbose=True, console=console) + + if not result.success: + if console and result.error: + console.print(f"[dim] Modular manager failed: {result.error}[/dim]") + return _try_existing_browser(console) + + if result.page: + token = manager.get_token_interactive(result, verbose=True, console=console) + return token + + return None + + except Exception as e: + # Fall back to legacy implementation + if console: + console.print(f"[dim] Modular manager failed: {e}[/dim]") + console.print("[dim] Falling back to legacy implementation...[/dim]") + return _try_existing_browser(console) + + +def _try_playwright_browser_dispatch(console: Optional[Console] = None) -> Optional[str]: + """New browser token retrieval using modular HFTokenRetriever. + + This is the refactored version that uses the browser_token package. + Falls back to legacy _try_playwright_browser if modular version unavailable. + """ + if not _BROWSER_TOKEN_AVAILABLE: + return _try_playwright_browser(console) + + try: + if console: + console.print("[dim] [Stage 3/3] Using modular browser token retriever...[/dim]") + console.print("[yellow] β Note: You'll need to login manually[/yellow]") + + retriever = HFTokenRetriever() + result = retriever.retrieve() + + if result.success: + if console: + console.print(f"[green] β Token retrieved via {result.browser_type}[/green]") + return result.token + else: + if console: + if result.error: + console.print(f"[red] β {result.error}[/red]") + else: + console.print(f"[yellow] β {result.message}[/yellow]") + return None + + except Exception as e: + # Fall back to legacy implementation + if console: + console.print(f"[dim] Modular retriever failed: {e}[/dim]") + console.print("[dim] Falling back to legacy implementation...[/dim]") + return _try_playwright_browser(console) + + +def _manual_browser_instructions(console: Optional[Console], browser_name: str) -> Optional[str]: + """Show manual instructions when browser automation fails.""" + if console: + console.print(f"\n[cyan]π {browser_name} opened. Please:[/cyan]") + console.print(" 1. Go to: https://huggingface.co/settings/tokens") + console.print(" 2. Login if not logged in") + console.print(" 3. Create new token (name: nlp2cmd, role: read)") + console.print(" 4. Copy the token") + else: + print(f"\nπ {browser_name} opened. Please:") + print(" 1. Go to: https://huggingface.co/settings/tokens") + print(" 2. Login if not logged in") + print(" 3. Create new token (name: nlp2cmd, role: read)") + print(" 4. Copy the token") + + try: + token = input("\nπ Paste HF_TOKEN here: ").strip() + if token: + return token + except (EOFError, KeyboardInterrupt): + pass + + return None + diff --git a/src/nlp2cmd/cli/commands/doctor_types.py b/src/nlp2cmd/cli/commands/doctor_types.py new file mode 100644 index 00000000..76d175d9 --- /dev/null +++ b/src/nlp2cmd/cli/commands/doctor_types.py @@ -0,0 +1,25 @@ +"""Shared types for nlp2cmd doctor checks.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class Status(Enum): + OK = "ok" + WARNING = "warning" + ERROR = "error" + INFO = "info" + FIXED = "fixed" + + +@dataclass +class CheckResult: + name: str + status: Status + message: str + details: dict = field(default_factory=dict) + fix_applied: bool = False + fix_command: Optional[str] = None + diff --git a/src/nlp2cmd/web_schema/site_explorer.py b/src/nlp2cmd/web_schema/site_explorer.py index 2a7cc092..37351f2b 100644 --- a/src/nlp2cmd/web_schema/site_explorer.py +++ b/src/nlp2cmd/web_schema/site_explorer.py @@ -15,164 +15,54 @@ import re import sys import time -from dataclasses import dataclass, field from typing import Any, Optional from urllib.parse import urljoin, urlparse from urllib.request import urlopen, Request -from urllib.error import URLError -import xml.etree.ElementTree as ET -# Import modular page analysis -try: - from nlp2cmd.page_analysis import PageAnalyzer, PageAnalysisResult - _PAGE_ANALYSIS_AVAILABLE = True -except ImportError: - _PAGE_ANALYSIS_AVAILABLE = False - -_DEBUG = os.environ.get("NLP2CMD_DEBUG", "").lower() in ("1", "true", "yes") - - -def _debug(msg: str) -> None: - """Print debug message to stderr when NLP2CMD_DEBUG=1.""" - if _DEBUG: - print(f"DEBUG [SiteExplorer] {msg}", file=sys.stderr, flush=True) - - -# ββ Module-level helpers for platform URL resolution ββββββββββββββββββ -def _github_readme_url(url: str) -> str: - """Convert github.com/owner/repo to raw README URL.""" - parsed = urlparse(url) - parts = [p for p in parsed.path.strip("/").split("/") if p] - if len(parts) >= 2: - return f"https://github.com/{parts[0]}/{parts[1]}" - return url - - -def _github_docs_url(url: str) -> str: - """Try to resolve GitHub repo docs (wiki, /docs, or README).""" - parsed = urlparse(url) - parts = [p for p in parsed.path.strip("/").split("/") if p] - if len(parts) >= 2: - return f"https://github.com/{parts[0]}/{parts[1]}/tree/main/docs" - return url - - -def _pypi_to_docs_url(url: str) -> Optional[str]: - """Convert pypi.org/project/X to readthedocs or homepage.""" - parsed = urlparse(url) - parts = [p for p in parsed.path.strip("/").split("/") if p] - if len(parts) >= 2 and parts[0] == "project": - pkg = parts[1].lower().replace("-", "").replace("_", "") - return f"https://{pkg}.readthedocs.io/en/latest/" - return None - - -@dataclass -class PageInfo: - """Information about a discovered page.""" - url: str - title: str = "" - links: list[str] = field(default_factory=list) - has_form: bool = False - form_count: int = 0 - contact_field_count: int = 0 - junk_field_count: int = 0 - score: float = 0.0 # Relevance score for form/contact pages - load_time_ms: float = 0.0 # Page load + analysis time - - -@dataclass -class ExplorationResult: - """Result of site exploration.""" - success: bool - form_url: Optional[str] = None - form_page: Optional[PageInfo] = None - explored_pages: list[PageInfo] = field(default_factory=list) - error: Optional[str] = None - - -class SiteExplorer: +from nlp2cmd.web_schema.site_explorer_page import SiteExplorerPageMixin +from nlp2cmd.web_schema.site_explorer_types import ( + ARTICLE_KEYWORDS, + BLOCKED_RESOURCE_PATTERNS, + CONTACT_KEYWORDS, + DOCS_FRAMEWORKS, + DOCS_KEYWORDS, + FORM_FIELD_KEYWORDS, + PLATFORM_DOCS_URLS, + PRODUCT_KEYWORDS, + ExplorationResult, + PageInfo, + debug, +) + +# Backward-compatible re-exports +__all__ = [ + "SiteExplorer", + "quick_find_form", + "quick_find_content", + "ExplorationResult", + "PageInfo", +] + + +class SiteExplorer(SiteExplorerPageMixin): """ Explores website to find forms, contact pages, and other content. - + Usage: explorer = SiteExplorer() result = explorer.find_form(url="https://example.com", intent="contact") if result.success: print(f"Found form at: {result.form_url}") """ - - # Keywords that suggest a page might contain a contact form - CONTACT_KEYWORDS = [ - "kontakt", "contact", "napisz do nas", "write to us", - "formularz", "form", "wiadomoΕΔ", "message", - "pomoc", "help", "support", "serwis", - "zapytaj", "ask", "biuro", "office", "dane", "info", - "obsΕuga", "obsuga", "klienta", "customer", - ] - - # Keywords that suggest articles/content - ARTICLE_KEYWORDS = [ - "artykuΕ", "article", "blog", "news", "wiadomoΕci", "aktualnoΕci", - "publikacja", "publication", "post", "wpis", "treΕΔ", "content", - "poradnik", "guide", "tutorial", "instrukcja", "manual", - ] - - # Keywords that suggest products/services - PRODUCT_KEYWORDS = [ - "produkt", "product", "usΕuga", "service", "oferta", "offer", - "sklep", "shop", "store", "cennik", "price", "cena", "buy", - "katalog", "catalog", "portfolio", "galeria", "gallery", - ] - - # Keywords that suggest documentation/help - DOCS_KEYWORDS = [ - "dokumentacja", "documentation", "docs", "pomoc", "help", - "faq", "pytania", "questions", "support", "wsparcie", - "manual", "instrukcja", "guide", "tutorial", "readme", - "wiki", "api", "reference", "examples", "przykΕady", - "github", "gitlab", "bitbucket", "repository", "repo" - ] - - # Keywords that suggest form fields - FORM_FIELD_KEYWORDS = [ - "email", "e-mail", "telefon", "phone", "imiΔ", "name", - "nazwisko", "surname", "wiadomoΕΔ", "message", "temat", "subject", - ] - - # Resource types to block for faster loading - BLOCKED_RESOURCE_PATTERNS = ( - "**/*.png", "**/*.jpg", "**/*.jpeg", "**/*.gif", "**/*.svg", - "**/*.webp", "**/*.ico", "**/*.bmp", "**/*.tiff", - "**/*.woff", "**/*.woff2", "**/*.ttf", "**/*.eot", - "**/*.mp4", "**/*.webm", "**/*.ogg", "**/*.mp3", - ) - # Smart URL shortcuts for known platforms (Strategy 3) - PLATFORM_DOCS_URLS: dict[str, Any] = { - "github.com": { - "readme": lambda url: _github_readme_url(url), - "docs": lambda url: _github_docs_url(url), - }, - "readthedocs.io": { - "docs": lambda url: url if "/en/" in url else url.rstrip("/") + "/en/latest/", - }, - "docs.python.org": { - "docs": lambda _url: "https://docs.python.org/3/", - }, - "pypi.org": { - "docs": lambda url: _pypi_to_docs_url(url), - }, - } - - # Known documentation frameworks with predictable URL structures (Strategy 8) - DOCS_FRAMEWORKS: dict[str, list[str]] = { - "readthedocs": ["/en/latest/", "/en/stable/", "readthedocs.io"], - "mkdocs": ["/mkdocs.yml", "mkdocs-material", "/site/"], - "gitbook": ["gitbook.io", ".gitbook.io"], - "sphinx": ["/_static/sphinx", "searchindex.js", "genindex.html"], - "docusaurus": ["/docs/", "/blog/", "docusaurus"], - } + CONTACT_KEYWORDS = CONTACT_KEYWORDS + ARTICLE_KEYWORDS = ARTICLE_KEYWORDS + PRODUCT_KEYWORDS = PRODUCT_KEYWORDS + DOCS_KEYWORDS = DOCS_KEYWORDS + FORM_FIELD_KEYWORDS = FORM_FIELD_KEYWORDS + BLOCKED_RESOURCE_PATTERNS = BLOCKED_RESOURCE_PATTERNS + PLATFORM_DOCS_URLS = PLATFORM_DOCS_URLS + DOCS_FRAMEWORKS = DOCS_FRAMEWORKS def __init__( self, @@ -205,12 +95,12 @@ def _abort_heavy(route: Any) -> None: except Exception: pass - for pattern in SiteExplorer.BLOCKED_RESOURCE_PATTERNS: + for pattern in BLOCKED_RESOURCE_PATTERNS: try: context.route(pattern, _abort_heavy) except Exception: pass - _debug("Resource blocking enabled") + debug("Resource blocking enabled") # ββ Strategy 3: Smart URL Patterns βββββββββββββββββββββββββββββββββ def _resolve_platform_url(self, url: str, content_type: str) -> Optional[str]: @@ -218,14 +108,14 @@ def _resolve_platform_url(self, url: str, content_type: str) -> Optional[str]: parsed = urlparse(url) netloc = parsed.netloc.lower() - for platform, handlers in self.PLATFORM_DOCS_URLS.items(): + for platform, handlers in PLATFORM_DOCS_URLS.items(): if platform in netloc: handler = handlers.get(content_type) or handlers.get("docs") if handler: try: resolved = handler(url) if resolved: - _debug(f"Platform shortcut: {platform} -> {resolved}") + debug(f"Platform shortcut: {platform} -> {resolved}") return resolved except Exception: pass @@ -250,7 +140,7 @@ def _goto_with_retry(self, page: Any, url: str) -> None: if not is_retriable: raise wait_ms = min(1000 * (2 ** attempt), 8000) - _debug(f"Retry {attempt + 1}/{self.max_retries} for {url} after {wait_ms}ms: {e}") + debug(f"Retry {attempt + 1}/{self.max_retries} for {url} after {wait_ms}ms: {e}") page.wait_for_timeout(wait_ms) if last_exc: raise last_exc @@ -275,10 +165,10 @@ def _try_github_api(url: str) -> Optional[str]: with urlopen(req, timeout=5) as resp: content = resp.read().decode("utf-8", errors="replace") if len(content) > 50: - _debug(f"GitHub API: fetched README ({len(content)} chars) for {owner}/{repo}") + debug(f"GitHub API: fetched README ({len(content)} chars) for {owner}/{repo}") return content except Exception as e: - _debug(f"GitHub API failed for {owner}/{repo}: {e}") + debug(f"GitHub API failed for {owner}/{repo}: {e}") return None # ββ Strategy 8: Documentation Framework Detection ββββββββββββββββββ @@ -288,9 +178,9 @@ def _detect_docs_framework(self, url: str, page_html: str = "") -> Optional[str] html_lower = page_html.lower() if page_html else "" combined = url_lower + " " + html_lower - for framework, indicators in self.DOCS_FRAMEWORKS.items(): + for framework, indicators in DOCS_FRAMEWORKS.items(): if any(ind in combined for ind in indicators): - _debug(f"Detected docs framework: {framework} at {url}") + debug(f"Detected docs framework: {framework} at {url}") return framework return None @@ -300,51 +190,13 @@ def _record_timing(self, url: str, phase: str, duration_ms: float) -> None: entry = {"url": url, "phase": phase, "duration_ms": round(duration_ms, 1)} self._timing_stats.append(entry) if duration_ms > 5000: - _debug(f"β SLOW {phase}: {url} took {duration_ms:.0f}ms") + debug(f"β SLOW {phase}: {url} took {duration_ms:.0f}ms") elif _DEBUG: - _debug(f"Timing {phase}: {url} = {duration_ms:.0f}ms") + debug(f"Timing {phase}: {url} = {duration_ms:.0f}ms") def get_timing_stats(self) -> list[dict[str, Any]]: """Return collected timing stats.""" return list(self._timing_stats) - - # ββ Strategy 10: Graceful Degradation ββββββββββββββββββββββββββββββ - @staticmethod - def _fallback_static_scrape(url: str, timeout: int = 5) -> Optional[PageInfo]: - """Fallback: fetch page with urllib (no JS) when Playwright fails.""" - try: - req = Request(url, headers={"User-Agent": "nlp2cmd/1.0 (static fallback)"}) - with urlopen(req, timeout=timeout) as resp: - html = resp.read().decode("utf-8", errors="replace") - - info = PageInfo(url=url) - - # Extract title - m = re.search(r"