diff --git a/.vscode/settings.json b/.vscode/settings.json index d4683ea..585aad7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,16 @@ "source.organizeImports.ruff": "explicit" } }, + "python.defaultInterpreterPath": ".venv/bin/python", + "python.terminal.activateEnvironment": true, "yaml.schemas": { "https://www.schemastore.org/github-issue-config.json": "file:///Users/daniel/Documents/development/python/linkedin-mcp-server/.github/ISSUE_TEMPLATE/config.yml" }, + "cursorpyright.analysis.autoImportCompletions": true, + "cursorpyright.analysis.diagnosticMode": "workspace", + "cursorpyright.analysis.extraPaths": [ + "./linkedin_mcp_server" + ], + "cursorpyright.analysis.stubPath": "./linkedin_mcp_server", + "cursorpyright.analysis.typeCheckingMode": "off" } diff --git a/README.md b/README.md index 7703b02..0b237b9 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,17 @@ Suggest improvements for my CV to target this job posting https://www.linkedin.c ## Features & Tool Status -**Working Tools:** +**Current Status: All Tools Working** > [!TIP] > - **Profile Scraping** (`get_person_profile`): Get detailed information from LinkedIn profiles including work history, education, skills, and connections > - **Company Analysis** (`get_company_profile`): Extract company information with comprehensive details > - **Job Details** (`get_job_details`): Retrieve specific job posting details using direct LinkedIn job URLs +> - **Job Search** (`search_jobs`): Search for jobs with filters like location, keywords, and experience level +> - **Recommended Jobs** (`get_recommended_jobs`): Get personalized job recommendations based on your profile > - **Session Management** (`close_session`): Properly close browser session and clean up resources -**Known Issues: (should be fixed after this [PR](https://github.com/joeyism/linkedin_scraper/pull/252) is merged)** -> [!WARNING] -> - **Job Search** (`search_jobs`): Compatibility issues with LinkedIn's search interface -> - **Recommended Jobs** (`get_recommended_jobs`): Selenium method compatibility issues -> - **Company Profiles** (`get_company_profile`): Some companies can't be accessed / may return empty results (need further investigation) +> [!NOTE] +> All tools are currently functional and actively maintained. If you encounter any issues, please report them in the [GitHub issues](https://github.com/stickerdaniel/linkedin-mcp-server/issues). --- @@ -57,7 +56,8 @@ Suggest improvements for my CV to target this job posting https://www.linkedin.c "run", "-i", "--rm", "-e", "LINKEDIN_EMAIL", "-e", "LINKEDIN_PASSWORD", - "stickerdaniel/linkedin-mcp-server" + "stickerdaniel/linkedin-mcp-server", + "--no-setup" ], "env": { "LINKEDIN_EMAIL": "your.email@example.com", @@ -76,6 +76,7 @@ Suggest improvements for my CV to target this job posting https://www.linkedin.c - **Streamable HTTP**: For a web-based MCP server **CLI Options:** +- `--no-setup` - Skip interactive prompts (required for Docker/non-interactive environments) - `--debug` - Enable detailed logging - `--no-lazy-init` - Login to LinkedIn immediately instead of waiting for the first tool call - `--transport {stdio,streamable-http}` - Set transport mode @@ -90,7 +91,7 @@ docker run -i --rm \ -e LINKEDIN_PASSWORD="your_password" \ -p 8080:8080 \ stickerdaniel/linkedin-mcp-server \ - --transport streamable-http --host 0.0.0.0 --port 8080 --path /mcp + --no-setup --transport streamable-http --host 0.0.0.0 --port 8080 --path /mcp ``` **Test with mcp inspector:** 1. Install and run mcp inspector ```bunx @modelcontextprotocol/inspector``` diff --git a/linkedin_mcp_server/cli.py b/linkedin_mcp_server/cli.py index 7c6457d..9852129 100644 --- a/linkedin_mcp_server/cli.py +++ b/linkedin_mcp_server/cli.py @@ -26,9 +26,7 @@ def print_claude_config() -> None: and copies it to the clipboard for easy pasting. """ config = get_config() - current_dir = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - ) + current_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) # Find the full path to uv executable try: diff --git a/linkedin_mcp_server/config/loaders.py b/linkedin_mcp_server/config/loaders.py index 0818363..ca1ea27 100644 --- a/linkedin_mcp_server/config/loaders.py +++ b/linkedin_mcp_server/config/loaders.py @@ -135,6 +135,9 @@ def load_from_args(config: AppConfig) -> AppConfig: if args.no_setup: config.server.setup = False + config.chrome.non_interactive = ( + True # Automatically set when --no-setup is used + ) if args.no_lazy_init: config.server.lazy_init = False diff --git a/linkedin_mcp_server/config/secrets.py b/linkedin_mcp_server/config/secrets.py index 0acc3d8..bafe01c 100644 --- a/linkedin_mcp_server/config/secrets.py +++ b/linkedin_mcp_server/config/secrets.py @@ -1,10 +1,11 @@ # src/linkedin_mcp_server/config/secrets.py import logging -from typing import Dict, Optional +from typing import Dict import inquirer # type: ignore from linkedin_mcp_server.config import get_config +from linkedin_mcp_server.exceptions import CredentialsNotFoundError from .providers import ( get_credentials_from_keyring, @@ -15,26 +16,28 @@ logger = logging.getLogger(__name__) -def get_credentials() -> Optional[Dict[str, str]]: +def get_credentials() -> Dict[str, str]: """Get LinkedIn credentials from config, keyring, or prompt.""" config = get_config() # First, try configuration (includes environment variables) if config.linkedin.email and config.linkedin.password: - print("Using LinkedIn credentials from configuration") + logger.info("Using LinkedIn credentials from configuration") return {"email": config.linkedin.email, "password": config.linkedin.password} # Second, try keyring if enabled if config.linkedin.use_keyring: credentials = get_credentials_from_keyring() if credentials["email"] and credentials["password"]: - print(f"Using LinkedIn credentials from {get_keyring_name()}") + logger.info(f"Using LinkedIn credentials from {get_keyring_name()}") return {"email": credentials["email"], "password": credentials["password"]} - # If in non-interactive mode and no credentials found, return None + # If in non-interactive mode and no credentials found, raise error if config.chrome.non_interactive: - print("No credentials found in non-interactive mode") - return None + raise CredentialsNotFoundError( + "No LinkedIn credentials found. Please provide credentials via " + "environment variables (LINKEDIN_EMAIL, LINKEDIN_PASSWORD) or keyring." + ) # Otherwise, prompt for credentials return prompt_for_credentials() @@ -54,9 +57,9 @@ def prompt_for_credentials() -> Dict[str, str]: # Store credentials securely in keyring if save_credentials_to_keyring(credentials["email"], credentials["password"]): - print(f"āœ… Credentials stored securely in {get_keyring_name()}") + logger.info(f"Credentials stored securely in {get_keyring_name()}") else: - print("āš ļø Warning: Could not store credentials in system keyring.") - print(" Your credentials will only be used for this session.") + logger.warning("Could not store credentials in system keyring.") + logger.info("Your credentials will only be used for this session.") return credentials diff --git a/linkedin_mcp_server/drivers/chrome.py b/linkedin_mcp_server/drivers/chrome.py index 63855e0..3c21db2 100644 --- a/linkedin_mcp_server/drivers/chrome.py +++ b/linkedin_mcp_server/drivers/chrome.py @@ -5,11 +5,20 @@ This module handles the creation and management of Chrome WebDriver instances. """ +import logging import os import sys from typing import Dict, Optional import inquirer # type: ignore +from linkedin_scraper.exceptions import ( + CaptchaRequiredError, + InvalidCredentialsError, + LoginTimeoutError, + RateLimitError, + SecurityChallengeError, + TwoFactorAuthError, +) from selenium import webdriver from selenium.common.exceptions import WebDriverException from selenium.webdriver.chrome.options import Options @@ -18,10 +27,16 @@ from linkedin_mcp_server.config import get_config from linkedin_mcp_server.config.providers import clear_credentials_from_keyring from linkedin_mcp_server.config.secrets import get_credentials +from linkedin_mcp_server.exceptions import ( + CredentialsNotFoundError, + DriverInitializationError, +) # Global driver storage to reuse sessions active_drivers: Dict[str, webdriver.Chrome] = {} +logger = logging.getLogger(__name__) + def get_or_create_driver() -> Optional[webdriver.Chrome]: """ @@ -43,8 +58,8 @@ def get_or_create_driver() -> Optional[webdriver.Chrome]: # Set up Chrome options chrome_options = Options() - print( - f"🌐 Running browser in {'headless' if config.chrome.headless else 'visible'} mode" + logger.info( + f"Running browser in {'headless' if config.chrome.headless else 'visible'} mode" ) if config.chrome.headless: chrome_options.add_argument("--headless=new") @@ -66,7 +81,7 @@ def get_or_create_driver() -> Optional[webdriver.Chrome]: # Initialize Chrome driver try: - print("🌐 Initializing Chrome WebDriver...") + logger.info("Initializing Chrome WebDriver...") # Use ChromeDriver path from environment or config chromedriver_path = ( @@ -74,37 +89,60 @@ def get_or_create_driver() -> Optional[webdriver.Chrome]: ) if chromedriver_path: - print(f"🌐 Using ChromeDriver at path: {chromedriver_path}") + logger.info(f"Using ChromeDriver at path: {chromedriver_path}") service = Service(executable_path=chromedriver_path) driver = webdriver.Chrome(service=service, options=chrome_options) else: - print("🌐 Using auto-detected ChromeDriver") + logger.info("Using auto-detected ChromeDriver") driver = webdriver.Chrome(options=chrome_options) - print("āœ… Chrome WebDriver initialized successfully") + logger.info("Chrome WebDriver initialized successfully") # Add a page load timeout for safety driver.set_page_load_timeout(60) - # Try to log in - if login_to_linkedin(driver): - print("Successfully logged in to LinkedIn") - elif config.chrome.non_interactive: - # In non-interactive mode, if login fails, return None - driver.quit() - return None - - active_drivers[session_id] = driver - return driver + # Try to log in with retry loop + max_retries = 3 + for attempt in range(max_retries): + try: + if login_to_linkedin(driver): + logger.info("Successfully logged in to LinkedIn") + active_drivers[session_id] = driver + return driver + except ( + CaptchaRequiredError, + InvalidCredentialsError, + SecurityChallengeError, + TwoFactorAuthError, + RateLimitError, + LoginTimeoutError, + CredentialsNotFoundError, + ) as e: + if config.chrome.non_interactive: + # In non-interactive mode, propagate the error + driver.quit() + raise e + else: + # In interactive mode, handle the error and potentially retry + should_retry = handle_login_error(e) + if should_retry and attempt < max_retries - 1: + logger.info(f"Retry attempt {attempt + 2}/{max_retries}") + continue + else: + # Clean up driver on final failure + driver.quit() + return None except Exception as e: - error_msg = f"šŸ›‘ Error creating web driver: {e}" - print(error_msg) + error_msg = f"Error creating web driver: {e}" + logger.error( + error_msg, + extra={"exception_type": type(e).__name__, "exception_message": str(e)}, + ) if config.chrome.non_interactive: - print("šŸ›‘ Failed to initialize driver in non-interactive mode") - return None - - raise WebDriverException(error_msg) + raise DriverInitializationError(error_msg) + else: + raise WebDriverException(error_msg) def login_to_linkedin(driver: webdriver.Chrome) -> bool: @@ -116,59 +154,115 @@ def login_to_linkedin(driver: webdriver.Chrome) -> bool: Returns: bool: True if login was successful, False otherwise + + Raises: + Various login-related errors from linkedin-scraper """ config = get_config() # Get LinkedIn credentials from config - credentials = get_credentials() + try: + credentials = get_credentials() + except CredentialsNotFoundError as e: + if config.chrome.non_interactive: + raise e + # Only prompt if not in non-interactive mode + from linkedin_mcp_server.config.secrets import prompt_for_credentials + + credentials = prompt_for_credentials() if not credentials: - print("āŒ No credentials available") - return False + raise CredentialsNotFoundError("No credentials available") - try: - # Login to LinkedIn - print("šŸ”‘ Logging in to LinkedIn...") + # Login to LinkedIn using enhanced linkedin-scraper + logger.info("Logging in to LinkedIn...") - from linkedin_scraper import actions # type: ignore + from linkedin_scraper import actions # type: ignore - actions.login(driver, credentials["email"], credentials["password"]) + # Use linkedin-scraper login but with simplified error handling + try: + actions.login( + driver, + credentials["email"], + credentials["password"], + interactive=not config.chrome.non_interactive, + ) - print("āœ… Successfully logged in to LinkedIn") + logger.info("Successfully logged in to LinkedIn") return True - except Exception as e: - error_msg = f"Failed to login: {str(e)}" - print(f"āŒ {error_msg}") - if not config.chrome.non_interactive: - print( - "āš ļø You might need to confirm the login in your LinkedIn mobile app. " - "Please try again and confirm the login." - ) + except Exception: + # Check current page to determine the real issue + current_url = driver.current_url - if config.chrome.headless: - print( - "šŸ” Try running with visible browser window to see what's happening: " - "uv run main.py --no-headless" + if "checkpoint/challenge" in current_url: + # We're on a challenge page - this is the real issue, not credentials + if "security check" in driver.page_source.lower(): + raise SecurityChallengeError( + challenge_url=current_url, + message="LinkedIn requires a security challenge. Please complete it manually and restart the application.", + ) + else: + raise CaptchaRequiredError( + captcha_url=current_url, ) - retry = inquirer.prompt( - [ - inquirer.Confirm( - "retry", - message="Would you like to try with different credentials?", - default=True, - ), - ] - ) + elif "feed" in current_url or "mynetwork" in current_url: + # Actually logged in successfully despite the exception + logger.info("Successfully logged in to LinkedIn") + return True - if retry and retry.get("retry", False): - # Clear credentials from keyring and try again - clear_credentials_from_keyring() - # Try again with new credentials - return login_to_linkedin(driver) + else: + # Check for actual credential issues + page_source = driver.page_source.lower() + if any( + pattern in page_source + for pattern in ["wrong email", "wrong password", "incorrect", "invalid"] + ): + raise InvalidCredentialsError("Invalid LinkedIn email or password.") + elif "too many" in page_source: + raise RateLimitError( + "Too many login attempts. Please wait and try again later." + ) + else: + raise LoginTimeoutError( + "Login failed. Please check your credentials and network connection." + ) - return False + +def handle_login_error(error: Exception) -> bool: + """Handle login errors in interactive mode. + + Returns: + bool: True if user wants to retry, False if they want to exit + """ + config = get_config() + + logger.error(f"\nāŒ {str(error)}") + + if config.chrome.headless: + logger.info( + "šŸ” Try running with visible browser window: uv run main.py --no-headless" + ) + + # Only allow retry for credential errors + if isinstance(error, InvalidCredentialsError): + retry = inquirer.prompt( + [ + inquirer.Confirm( + "retry", + message="Would you like to try with different credentials?", + default=True, + ), + ] + ) + if retry and retry.get("retry", False): + clear_credentials_from_keyring() + logger.info("āœ… Credentials cleared from keyring.") + logger.info("šŸ”„ Retrying with new credentials...") + return True + + return False def initialize_driver() -> None: @@ -178,23 +272,25 @@ def initialize_driver() -> None: config = get_config() if config.server.lazy_init: - print("Using lazy initialization - driver will be created on first tool call") + logger.info( + "Using lazy initialization - driver will be created on first tool call" + ) if config.linkedin.email and config.linkedin.password: - print("LinkedIn credentials found in configuration") + logger.info("LinkedIn credentials found in configuration") else: - print( + logger.info( "No LinkedIn credentials found - will look for stored credentials on first use" ) return # Validate chromedriver can be found if config.chrome.chromedriver_path: - print(f"āœ… ChromeDriver found at: {config.chrome.chromedriver_path}") + logger.info(f"āœ… ChromeDriver found at: {config.chrome.chromedriver_path}") os.environ["CHROMEDRIVER"] = config.chrome.chromedriver_path else: - print("āš ļø ChromeDriver not found in common locations.") - print("⚔ Continuing with automatic detection...") - print( + logger.info("āš ļø ChromeDriver not found in common locations.") + logger.info("⚔ Continuing with automatic detection...") + logger.info( "šŸ’” Tip: install ChromeDriver and set the CHROMEDRIVER environment variable" ) @@ -202,11 +298,27 @@ def initialize_driver() -> None: try: driver = get_or_create_driver() if driver: - print("āœ… Web driver initialized successfully") + logger.info("āœ… Web driver initialized successfully") else: - print("āŒ Failed to initialize web driver.") + # Driver creation failed - always raise an error + raise DriverInitializationError("Failed to initialize web driver") + except ( + CaptchaRequiredError, + InvalidCredentialsError, + SecurityChallengeError, + TwoFactorAuthError, + RateLimitError, + LoginTimeoutError, + CredentialsNotFoundError, + ) as e: + # Always re-raise login-related errors so main.py can handle them + raise e except WebDriverException as e: - print(f"āŒ Failed to initialize web driver: {str(e)}") + if config.chrome.non_interactive: + raise DriverInitializationError( + f"Failed to initialize web driver: {str(e)}" + ) + logger.error(f"āŒ Failed to initialize web driver: {str(e)}") handle_driver_error() @@ -216,6 +328,13 @@ def handle_driver_error() -> None: """ config = get_config() + # Skip interactive handling in non-interactive mode + if config.chrome.non_interactive: + logger.error( + "āŒ ChromeDriver is required for this application to work properly." + ) + sys.exit(1) + questions = [ inquirer.List( "chromedriver_action", @@ -238,22 +357,28 @@ def handle_driver_error() -> None: # Update config with the new path config.chrome.chromedriver_path = path os.environ["CHROMEDRIVER"] = path - print(f"āœ… ChromeDriver path set to: {path}") - # Try again with the new path - initialize_driver() + logger.info(f"āœ… ChromeDriver path set to: {path}") + logger.info( + "šŸ’” Please restart the application to use the new ChromeDriver path." + ) + logger.info(" Example: uv run main.py") + sys.exit(0) else: - print(f"āš ļø Warning: The specified path does not exist: {path}") - initialize_driver() + logger.warning(f"āš ļø Warning: The specified path does not exist: {path}") + logger.info("šŸ’” Please check the path and restart the application.") + sys.exit(1) elif answers["chromedriver_action"] == "help": - print("\nšŸ“‹ ChromeDriver Installation Guide:") - print("1. Find your Chrome version: Chrome menu > Help > About Google Chrome") - print( + logger.info("\nšŸ“‹ ChromeDriver Installation Guide:") + logger.info( + "1. Find your Chrome version: Chrome menu > Help > About Google Chrome" + ) + logger.info( "2. Download matching ChromeDriver: https://chromedriver.chromium.org/downloads" ) - print("3. Place ChromeDriver in a location on your PATH") - print(" - macOS/Linux: /usr/local/bin/ is recommended") - print( + logger.info("3. Place ChromeDriver in a location on your PATH") + logger.info(" - macOS/Linux: /usr/local/bin/ is recommended") + logger.info( " - Windows: Add to a directory in your PATH or specify the full path\n" ) @@ -262,5 +387,5 @@ def handle_driver_error() -> None: )["try_again"]: initialize_driver() - print("āŒ ChromeDriver is required for this application to work properly.") + logger.error("āŒ ChromeDriver is required for this application to work properly.") sys.exit(1) diff --git a/linkedin_mcp_server/error_handler.py b/linkedin_mcp_server/error_handler.py new file mode 100644 index 0000000..1e9d3d2 --- /dev/null +++ b/linkedin_mcp_server/error_handler.py @@ -0,0 +1,177 @@ +""" +Centralized error handling for LinkedIn MCP Server tools. + +This module provides a DRY approach to error handling across all tools, +eliminating code duplication and ensuring consistent error responses. +""" + +import logging +from typing import Any, Dict, List + +from linkedin_scraper.exceptions import ( + CaptchaRequiredError, + InvalidCredentialsError, + LoginTimeoutError, + RateLimitError, + SecurityChallengeError, + TwoFactorAuthError, +) + +from linkedin_mcp_server.exceptions import ( + CredentialsNotFoundError, + LinkedInMCPError, +) + + +def handle_tool_error(exception: Exception, context: str = "") -> Dict[str, Any]: + """ + Handle errors from tool functions and return structured responses. + + Args: + exception: The exception that occurred + context: Context about which tool failed + + Returns: + Structured error response dictionary + """ + return convert_exception_to_response(exception, context) + + +def handle_tool_error_list( + exception: Exception, context: str = "" +) -> List[Dict[str, Any]]: + """ + Handle errors from tool functions that return lists. + + Args: + exception: The exception that occurred + context: Context about which tool failed + + Returns: + List containing structured error response dictionary + """ + return convert_exception_to_list_response(exception, context) + + +def convert_exception_to_response( + exception: Exception, context: str = "" +) -> Dict[str, Any]: + """ + Convert an exception to a structured MCP response. + + Args: + exception: The exception to convert + context: Additional context about where the error occurred + + Returns: + Structured error response dictionary + """ + if isinstance(exception, CredentialsNotFoundError): + return { + "error": "credentials_not_found", + "message": str(exception), + "resolution": "Provide LinkedIn credentials via environment variables", + } + + elif isinstance(exception, InvalidCredentialsError): + return { + "error": "invalid_credentials", + "message": str(exception), + "resolution": "Check your LinkedIn email and password", + } + + elif isinstance(exception, CaptchaRequiredError): + return { + "error": "captcha_required", + "message": str(exception), + "captcha_url": exception.captcha_url, + "resolution": "Complete the captcha challenge manually", + } + + elif isinstance(exception, SecurityChallengeError): + return { + "error": "security_challenge_required", + "message": str(exception), + "challenge_url": getattr(exception, "challenge_url", None), + "resolution": "Complete the security challenge manually", + } + + elif isinstance(exception, TwoFactorAuthError): + return { + "error": "two_factor_auth_required", + "message": str(exception), + "resolution": "Complete 2FA verification", + } + + elif isinstance(exception, RateLimitError): + return { + "error": "rate_limit", + "message": str(exception), + "resolution": "Wait before attempting to login again", + } + + elif isinstance(exception, LoginTimeoutError): + return { + "error": "login_timeout", + "message": str(exception), + "resolution": "Check network connection and try again", + } + + elif isinstance(exception, LinkedInMCPError): + return {"error": "linkedin_error", "message": str(exception)} + + else: + # Generic error handling with structured logging + logger = logging.getLogger(__name__) + logger.error( + f"Error in {context}: {exception}", + extra={ + "context": context, + "exception_type": type(exception).__name__, + "exception_message": str(exception), + }, + ) + return { + "error": "unknown_error", + "message": f"Failed to execute {context}: {str(exception)}", + } + + +def convert_exception_to_list_response( + exception: Exception, context: str = "" +) -> List[Dict[str, Any]]: + """ + Convert an exception to a list-formatted structured MCP response. + + Some tools return lists, so this provides the same error handling + but wrapped in a list format. + + Args: + exception: The exception to convert + context: Additional context about where the error occurred + + Returns: + List containing single structured error response dictionary + """ + return [convert_exception_to_response(exception, context)] + + +def safe_get_driver(): + """ + Safely get or create a driver with proper error handling. + + Returns: + Driver instance or None if initialization fails + + Raises: + LinkedInMCPError: If driver initialization fails in non-interactive mode + """ + from linkedin_mcp_server.drivers.chrome import get_or_create_driver + + driver = get_or_create_driver() + if not driver: + from linkedin_mcp_server.exceptions import DriverInitializationError + + raise DriverInitializationError("Failed to initialize Chrome driver") + + return driver diff --git a/linkedin_mcp_server/exceptions.py b/linkedin_mcp_server/exceptions.py new file mode 100644 index 0000000..4f5799f --- /dev/null +++ b/linkedin_mcp_server/exceptions.py @@ -0,0 +1,24 @@ +""" +Custom exceptions for LinkedIn MCP Server. + +This module defines specific exception types for different error scenarios +to provide better error handling and reporting to MCP clients. +""" + + +class LinkedInMCPError(Exception): + """Base exception for LinkedIn MCP Server.""" + + pass + + +class CredentialsNotFoundError(LinkedInMCPError): + """No credentials available in non-interactive mode.""" + + pass + + +class DriverInitializationError(LinkedInMCPError): + """Failed to initialize Chrome WebDriver.""" + + pass diff --git a/linkedin_mcp_server/logging_config.py b/linkedin_mcp_server/logging_config.py new file mode 100644 index 0000000..98616ed --- /dev/null +++ b/linkedin_mcp_server/logging_config.py @@ -0,0 +1,109 @@ +""" +Logging configuration for LinkedIn MCP Server. + +This module provides structured JSON logging for better integration +with MCP clients and monitoring systems. +""" + +import json +import logging +from typing import Any, Dict + + +class MCPJSONFormatter(logging.Formatter): + """JSON formatter for MCP server logs.""" + + def format(self, record: logging.LogRecord) -> str: + """Format log record as JSON. + + Args: + record: The log record to format + + Returns: + JSON-formatted log string + """ + log_data: Dict[str, Any] = { + "timestamp": self.formatTime(record), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + + # Add error details if present + if hasattr(record, "error_type"): + log_data["error_type"] = record.error_type + if hasattr(record, "error_details"): + log_data["error_details"] = record.error_details + + # Add exception info if present + if record.exc_info: + log_data["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_data) + + +class CompactFormatter(logging.Formatter): + """Compact formatter that shortens logger names and uses shorter timestamps.""" + + def format(self, record: logging.LogRecord) -> str: + """Format log record with compact formatting. + + Args: + record: The log record to format + + Returns: + Compact-formatted log string + """ + # Create a copy of the record to avoid modifying the original + record_copy = logging.LogRecord( + name=record.name, + level=record.levelno, + pathname=record.pathname, + lineno=record.lineno, + msg=record.msg, + args=record.args, + exc_info=record.exc_info, + func=record.funcName, + ) + record_copy.stack_info = record.stack_info + + # Shorten the logger name by removing the linkedin_mcp_server prefix + if record_copy.name.startswith("linkedin_mcp_server."): + record_copy.name = record_copy.name[len("linkedin_mcp_server.") :] + + # Format the time as HH:MM:SS only + record_copy.asctime = self.formatTime(record_copy, datefmt="%H:%M:%S") + + return f"{record_copy.asctime} - {record_copy.name} - {record.levelname} - {record.getMessage()}" + + +def configure_logging(debug: bool = False, json_format: bool = False) -> None: + """Configure logging for the LinkedIn MCP Server. + + Args: + debug: Whether to enable debug logging + json_format: Whether to use JSON formatting for logs + """ + log_level = logging.DEBUG if debug else logging.INFO + + if json_format: + formatter = MCPJSONFormatter() + else: + formatter = CompactFormatter() + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + + # Remove existing handlers + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # Add console handler + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + # Set specific loggers + logging.getLogger("selenium").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) diff --git a/linkedin_mcp_server/server.py b/linkedin_mcp_server/server.py index 3e746cd..c99afdd 100644 --- a/linkedin_mcp_server/server.py +++ b/linkedin_mcp_server/server.py @@ -5,6 +5,7 @@ This module creates the MCP server and registers all the LinkedIn tools. """ +import logging from typing import Any, Dict from fastmcp import FastMCP @@ -14,6 +15,8 @@ from linkedin_mcp_server.tools.job import register_job_tools from linkedin_mcp_server.tools.person import register_person_tools +logger = logging.getLogger(__name__) + def create_mcp_server() -> FastMCP: """Create and configure the MCP server with all LinkedIn tools.""" @@ -59,4 +62,11 @@ def shutdown_handler() -> None: driver.quit() del active_drivers[session_id] except Exception as e: - print(f"āŒ Error closing driver during shutdown: {e}") + logger.error( + f"Error closing driver during shutdown: {e}", + extra={ + "session_id": session_id, + "exception_type": type(e).__name__, + "exception_message": str(e), + }, + ) diff --git a/linkedin_mcp_server/tools/company.py b/linkedin_mcp_server/tools/company.py index 4690a94..f64879b 100644 --- a/linkedin_mcp_server/tools/company.py +++ b/linkedin_mcp_server/tools/company.py @@ -5,12 +5,15 @@ This module provides tools for scraping LinkedIn company profiles. """ +import logging from typing import Any, Dict, List from fastmcp import FastMCP from linkedin_scraper import Company -from linkedin_mcp_server.drivers.chrome import get_or_create_driver +from linkedin_mcp_server.error_handler import handle_tool_error, safe_get_driver + +logger = logging.getLogger(__name__) def register_company_tools(mcp: FastMCP) -> None: @@ -35,12 +38,12 @@ async def get_company_profile( Returns: Dict[str, Any]: Structured data from the company's profile """ - driver = get_or_create_driver() - try: - print(f"šŸ¢ Scraping company: {linkedin_url}") + driver = safe_get_driver() + + logger.info(f"Scraping company: {linkedin_url}") if get_employees: - print("āš ļø Fetching employees may take a while...") + logger.info("Fetching employees may take a while...") company = Company( linkedin_url, @@ -92,5 +95,4 @@ async def get_company_profile( return result except Exception as e: - print(f"āŒ Error scraping company: {e}") - return {"error": f"Failed to scrape company profile: {str(e)}"} + return handle_tool_error(e, "get_company_profile") diff --git a/linkedin_mcp_server/tools/job.py b/linkedin_mcp_server/tools/job.py index 1af2a91..05a8f31 100644 --- a/linkedin_mcp_server/tools/job.py +++ b/linkedin_mcp_server/tools/job.py @@ -1,16 +1,23 @@ # src/linkedin_mcp_server/tools/job.py """ -Job-related tools for LinkedIn MCP server. +Job tools for LinkedIn MCP server. This module provides tools for scraping LinkedIn job postings and searches. """ +import logging from typing import Any, Dict, List from fastmcp import FastMCP from linkedin_scraper import Job, JobSearch -from linkedin_mcp_server.drivers.chrome import get_or_create_driver +from linkedin_mcp_server.error_handler import ( + handle_tool_error, + handle_tool_error_list, + safe_get_driver, +) + +logger = logging.getLogger(__name__) def register_job_tools(mcp: FastMCP) -> None: @@ -27,82 +34,74 @@ async def get_job_details(job_url: str) -> Dict[str, Any]: Scrape job details from a LinkedIn job posting. IMPORTANT: Only use direct LinkedIn job URLs in the format: - https://www.linkedin.com/jobs/view/[JOB_ID] - - DO NOT use collection URLs like: - - /collections/recommended/?currentJobId= - - /jobs/search/?keywords= + https://www.linkedin.com/jobs/view/XXXXXXXX/ where XXXXXXXX is the job ID. - If you have a collection URL, extract the job ID and convert it to the direct format. - Example: If you see currentJobId=1234567890, use https://www.linkedin.com/jobs/view/1234567890 + This tool extracts comprehensive job information including title, company, + location, posting date, application count, and full job description. Args: - job_url (str): The direct LinkedIn job URL (must be /jobs/view/[ID] format) + job_url (str): The LinkedIn job posting URL to scrape Returns: Dict[str, Any]: Structured job data including title, company, location, posting date, application count, and job description (may be empty if content is protected) """ - driver = get_or_create_driver() - try: - print(f"šŸ’¼ Scraping job: {job_url}") + driver = safe_get_driver() + + logger.info(f"Scraping job: {job_url}") job = Job(job_url, driver=driver, close_on_complete=False) # Convert job object to a dictionary return job.to_dict() except Exception as e: - print(f"āŒ Error scraping job: {e}") - return {"error": f"Failed to scrape job posting: {str(e)}"} + return handle_tool_error(e, "get_job_details") @mcp.tool() async def search_jobs(search_term: str) -> List[Dict[str, Any]]: """ - Search for jobs on LinkedIn with the given search term. + Search for jobs on LinkedIn (Note: This tool has compatibility issues). Args: - search_term (str): The job search query + search_term (str): The search term to use for job search Returns: List[Dict[str, Any]]: List of job search results """ - driver = get_or_create_driver() - try: - print(f"šŸ” Searching jobs: {search_term}") + driver = safe_get_driver() + + logger.info(f"Searching jobs: {search_term}") job_search = JobSearch(driver=driver, close_on_complete=False, scrape=False) jobs = job_search.search(search_term) # Convert job objects to dictionaries return [job.to_dict() for job in jobs] except Exception as e: - print(f"āŒ Error searching jobs: {e}") - return [{"error": f"Failed to search jobs: {str(e)}"}] + return handle_tool_error_list(e, "search_jobs") @mcp.tool() async def get_recommended_jobs() -> List[Dict[str, Any]]: """ - Get recommended jobs from your LinkedIn homepage. + Get recommended jobs from LinkedIn (Note: This tool has compatibility issues). Returns: List[Dict[str, Any]]: List of recommended jobs """ - driver = get_or_create_driver() - try: - print("šŸ“‹ Getting recommended jobs") + driver = safe_get_driver() + + logger.info("Getting recommended jobs") job_search = JobSearch( driver=driver, close_on_complete=False, - scrape=True, + scrape=True, # Enable scraping to get recommended jobs scrape_recommended_jobs=True, ) - # Get recommended jobs and convert to dictionaries if hasattr(job_search, "recommended_jobs") and job_search.recommended_jobs: return [job.to_dict() for job in job_search.recommended_jobs] else: return [] except Exception as e: - print(f"āŒ Error getting recommended jobs: {e}") - return [{"error": f"Failed to get recommended jobs: {str(e)}"}] + return handle_tool_error_list(e, "get_recommended_jobs") diff --git a/linkedin_mcp_server/tools/person.py b/linkedin_mcp_server/tools/person.py index 236fa7f..f1f6d70 100644 --- a/linkedin_mcp_server/tools/person.py +++ b/linkedin_mcp_server/tools/person.py @@ -5,12 +5,15 @@ This module provides tools for scraping LinkedIn person profiles. """ +import logging from typing import Any, Dict, List from fastmcp import FastMCP from linkedin_scraper import Person -from linkedin_mcp_server.drivers.chrome import get_or_create_driver +from linkedin_mcp_server.error_handler import handle_tool_error, safe_get_driver + +logger = logging.getLogger(__name__) def register_person_tools(mcp: FastMCP) -> None: @@ -32,10 +35,10 @@ async def get_person_profile(linkedin_url: str) -> Dict[str, Any]: Returns: Dict[str, Any]: Structured data from the person's profile """ - driver = get_or_create_driver() - try: - print(f"šŸ” Scraping profile: {linkedin_url}") + driver = safe_get_driver() + + logger.info(f"Scraping profile: {linkedin_url}") person = Person(linkedin_url, driver=driver, close_on_complete=False) # Convert experiences to structured dictionaries @@ -97,5 +100,4 @@ async def get_person_profile(linkedin_url: str) -> Dict[str, Any]: "open_to_work": getattr(person, "open_to_work", False), } except Exception as e: - print(f"āŒ Error scraping profile: {e}") - return {"error": f"Failed to scrape profile: {str(e)}"} + return handle_tool_error(e, "get_person_profile") diff --git a/main.py b/main.py index 8e23d5d..d070325 100644 --- a/main.py +++ b/main.py @@ -8,14 +8,26 @@ from typing import Literal import inquirer # type: ignore +from linkedin_scraper.exceptions import ( + CaptchaRequiredError, + InvalidCredentialsError, + LoginTimeoutError, + RateLimitError, + SecurityChallengeError, + TwoFactorAuthError, +) from linkedin_mcp_server.cli import print_claude_config # Import the new centralized configuration from linkedin_mcp_server.config import get_config from linkedin_mcp_server.drivers.chrome import initialize_driver +from linkedin_mcp_server.exceptions import LinkedInMCPError +from linkedin_mcp_server.logging_config import configure_logging from linkedin_mcp_server.server import create_mcp_server, shutdown_handler +logger = logging.getLogger(__name__) + def choose_transport_interactive() -> Literal["stdio", "streamable-http"]: """Prompt user for transport mode using inquirer.""" @@ -43,25 +55,50 @@ def main() -> None: config = get_config() # Configure logging - log_level = logging.DEBUG if config.server.debug else logging.ERROR - logging.basicConfig( - level=log_level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + configure_logging( + debug=config.server.debug, + json_format=config.chrome.non_interactive, # Use JSON format in non-interactive mode ) - logger = logging.getLogger("linkedin_mcp_server") logger.debug(f"Server configuration: {config}") # Initialize the driver with configuration (initialize driver checks for lazy init options) - initialize_driver() + try: + initialize_driver() + except ( + LinkedInMCPError, + CaptchaRequiredError, + InvalidCredentialsError, + SecurityChallengeError, + TwoFactorAuthError, + RateLimitError, + LoginTimeoutError, + ) as e: + logger.error( + f"Failed to initialize driver: {str(e)}", + extra={"error_type": type(e).__name__, "error_details": str(e)}, + ) + + # Always terminate if login fails and we're not using lazy initialization + if not config.server.lazy_init: + print(f"\nāŒ {str(e)}") + sys.exit(1) + + # In lazy init mode with non-interactive, still exit on error + if config.chrome.non_interactive: + sys.exit(1) + else: + print(f"\nāŒ Error: {str(e)}") + print("šŸ’” Tip: Check your credentials and try again.") + sys.exit(1) # Decide transport transport = config.server.transport if config.server.setup: transport = choose_transport_interactive() - # Print configuration for Claude if in setup mode - if config.server.setup: + # Print configuration for Claude if in setup mode and using stdio transport + if config.server.setup and transport == "stdio": print_claude_config() # Create and run the MCP server @@ -96,5 +133,9 @@ def exit_gracefully(exit_code: int = 0) -> None: except KeyboardInterrupt: exit_gracefully(0) except Exception as e: + logger.error( + f"Error running MCP server: {e}", + extra={"exception_type": type(e).__name__, "exception_message": str(e)}, + ) print(f"āŒ Error running MCP server: {e}") exit_gracefully(1) diff --git a/pyproject.toml b/pyproject.toml index 5284dcf..72572f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ linkedin_mcp_server = ["py.typed"] [tool.uv.sources] -linkedin-scraper = { git = "https://github.com/joeyism/linkedin_scraper.git" } +linkedin-scraper = { git = "https://github.com/stickerdaniel/linkedin_scraper.git" } [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index c8c796c..48ea821 100644 --- a/uv.lock +++ b/uv.lock @@ -684,7 +684,7 @@ requires-dist = [ { name = "fastmcp", specifier = ">=2.10.1" }, { name = "inquirer", specifier = ">=3.4.0" }, { name = "keyring", specifier = ">=25.6.0" }, - { name = "linkedin-scraper", git = "https://github.com/joeyism/linkedin_scraper.git" }, + { name = "linkedin-scraper", git = "https://github.com/stickerdaniel/linkedin_scraper.git" }, { name = "pyperclip", specifier = ">=1.9.0" }, ] @@ -702,53 +702,52 @@ dev = [ [[package]] name = "linkedin-scraper" version = "2.11.5" -source = { git = "https://github.com/joeyism/linkedin_scraper.git#44eafb893e691732474e37a20123c5cc9007e0ad" } +source = { git = "https://github.com/stickerdaniel/linkedin_scraper.git#1d6ff82f8b0950b060529b12102a674cfabad1bb" } dependencies = [ { name = "lxml" }, + { name = "python-dotenv" }, { name = "requests" }, { name = "selenium" }, ] [[package]] name = "lxml" -version = "5.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, - { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, - { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, - { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, - { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, - { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, - { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, - { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, - { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, - { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, - { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, - { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, - { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, - { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, - { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, - { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, - { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, - { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, - { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, - { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, - { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" }, + { url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" }, + { url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" }, + { url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" }, + { url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" }, + { url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" }, + { url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" }, + { url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" }, + { url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" }, + { url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" }, + { url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" }, + { url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" }, + { url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" }, + { url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" }, + { url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" }, + { url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" }, + { url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" }, ] [[package]] @@ -1148,11 +1147,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.1.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] [[package]] @@ -1224,7 +1223,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1232,9 +1231,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, ] [[package]] @@ -1378,7 +1377,7 @@ wheels = [ [[package]] name = "selenium" -version = "4.33.0" +version = "4.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1388,9 +1387,9 @@ dependencies = [ { name = "urllib3", extra = ["socks"] }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/7e/4145666dd275760b56d0123a9439915af167932dd6caa19b5f8b281ae297/selenium-4.33.0.tar.gz", hash = "sha256:d90974db95d2cdeb34d2fb1b13f03dc904f53e6c5d228745b0635ada10cd625d", size = 882387, upload-time = "2025-05-23T17:45:22.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/44/a6df7eae7fe929f18ffe08221fb05215ce991adc718bbe693a8d46ff09b7/selenium-4.34.0.tar.gz", hash = "sha256:8b7eb05a0ed22f9bb2187fd256c28630824ad01d8397b4e68bc0af7dabf26c80", size = 895790, upload-time = "2025-06-29T07:30:09.263Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/c0/092fde36918574e144613de73ba43c36ab8d31e7d36bb44c35261909452d/selenium-4.33.0-py3-none-any.whl", hash = "sha256:af9ea757813918bddfe05cc677bf63c8a0cd277ebf8474b3dd79caa5727fca85", size = 9370835, upload-time = "2025-05-23T17:45:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/11/b3/6a043a6968f263e90537b48870f7366f91a6d4c5cc67e5b656311c98d0f5/selenium-4.34.0-py3-none-any.whl", hash = "sha256:fc3535cfd99a073c21bf9091519b48ed31b34bf2cbd132f62e8c732b2e815b2d", size = 9403599, upload-time = "2025-06-29T07:30:07.012Z" }, ] [[package]] @@ -1527,11 +1526,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.2" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] [[package]]