diff --git a/.gitignore b/.gitignore index 34cf849..3ffc5bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ __pycache__/ # C extensions *.so - +test-config.json # Distribution / packaging .Python build/ @@ -197,3 +197,4 @@ cython_debug/ # claude code settings .claude CLAUDE.md +compose/local/ngrok/ngrok.yml diff --git a/compose/local/ngrok/Dockerfile b/compose/local/ngrok/Dockerfile new file mode 100644 index 0000000..c9e47e2 --- /dev/null +++ b/compose/local/ngrok/Dockerfile @@ -0,0 +1,12 @@ +FROM ngrok/ngrok:latest + +RUN ngrok --version + +# Add config script (if you want to set up multiple tunnels or use diff config) +# COPY --chown=ngrok ngrok.yml /home/ngrok/.ngrok2/ +COPY entrypoint.sh / + +USER ngrok +ENV USER=ngrok + +CMD ["/entrypoint.sh"] diff --git a/compose/local/ngrok/entrypoint.sh b/compose/local/ngrok/entrypoint.sh new file mode 100644 index 0000000..9d6ae24 --- /dev/null +++ b/compose/local/ngrok/entrypoint.sh @@ -0,0 +1,89 @@ +#!/bin/sh -e + +if [ -n "$@" ]; then + exec "$@" +fi + +# Legacy compatible: +if [ -z "$NGROK_PORT" ]; then + if [ -n "$HTTPS_PORT" ]; then + NGROK_PORT="$HTTPS_PORT" + elif [ -n "$HTTP_PORT" ]; then + NGROK_PORT="$HTTP_PORT" + elif [ -n "$APP_PORT" ]; then + NGROK_PORT="$APP_PORT" + fi +fi + +ARGS="ngrok" + +# Set the protocol. +if [ "$NGROK_PROTOCOL" = "TCP" ]; then + ARGS="$ARGS tcp" +else + ARGS="$ARGS http" + NGROK_PORT="${NGROK_PORT:-80}" +fi + +# Set the TLS binding flag +if [ -n "$NGROK_BINDTLS" ]; then + ARGS="$ARGS --bind-tls=$NGROK_BINDTLS " +fi + +# Set the authorization token. +if [ -n "$NGROK_AUTH" ]; then + echo "authtoken: $NGROK_AUTH" >> ~/.ngrok2/ngrok.yml +fi + +# We use the forced NGROK_HOSTNAME here. +# This requires a valid Ngrok auth token in $NGROK_AUTH +if [ -n "$NGROK_HOSTNAME" ]; then + if [ -z "$NGROK_AUTH" ]; then + echo "You must set NGROK_AUTH (your Ngrok auth token) to use a custom domain." + exit 1 + fi + ARGS="$ARGS --url=$NGROK_HOSTNAME " +fi + +# Set the remote-addr if specified +if [ -n "$NGROK_REMOTE_ADDR" ]; then + if [ -z "$NGROK_AUTH" ]; then + echo "You must specify an authentication token to use reserved IP addresses." + exit 1 + fi + ARGS="$ARGS --remote-addr=$NGROK_REMOTE_ADDR " +fi + +# Set a custom region +if [ -n "$NGROK_REGION" ]; then + ARGS="$ARGS --region=$NGROK_REGION " +fi + +if [ -n "$NGROK_HEADER" ]; then + ARGS="$ARGS --host-header=$NGROK_HEADER " +fi + +# HTTP Auth config +if [ -n "$NGROK_USERNAME" ] && [ -n "$NGROK_PASSWORD" ] && [ -n "$NGROK_AUTH" ]; then + ARGS="$ARGS --auth=$NGROK_USERNAME:$NGROK_PASSWORD " +elif [ -n "$NGROK_USERNAME" ] || [ -n "$NGROK_PASSWORD" ]; then + if [ -z "$NGROK_AUTH" ]; then + echo "You must specify NGROK_USERNAME, NGROK_PASSWORD, and NGROK_AUTH for custom HTTP authentication." + exit 1 + fi +fi + +# Always log to stdout in debug mode +ARGS="$ARGS --log stdout --log-level=debug" + +# Set the port. +if [ -z "$NGROK_PORT" ]; then + echo "You must specify an NGROK_PORT to expose." + exit 1 +fi + +# Finally, add the port to the command +ARGS="$ARGS $(echo $NGROK_PORT | sed 's|^tcp://||')" + +set -x +exec $ARGS diff --git a/debug_fast_scraper.py b/debug_fast_scraper.py new file mode 100644 index 0000000..388b448 --- /dev/null +++ b/debug_fast_scraper.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Debug script to test fast-linkedin-scraper directly without our wrapper. +""" + +import threading + + +def test_fast_scraper_direct(): + """Test fast-linkedin-scraper directly.""" + print("Testing fast-linkedin-scraper directly...") + + try: + from fast_linkedin_scraper import LinkedInSession + + print("āœ… fast-linkedin-scraper imported successfully") + + # Test getting version info + try: + import fast_linkedin_scraper + + print( + f"šŸ“¦ fast-linkedin-scraper version: {getattr(fast_linkedin_scraper, '__version__', 'unknown')}" + ) + except Exception: + print("šŸ“¦ Version info not available") + except ImportError as e: + print(f"āŒ Cannot import fast-linkedin-scraper: {e}") + return False + + # Test with a dummy cookie to see if the library initializes properly + dummy_cookie = "li_at=dummy_cookie_for_testing" + + try: + print("šŸ” Testing LinkedInSession creation...") + with LinkedInSession.from_cookie(dummy_cookie) as session: + print(f"āœ… Session created successfully: {type(session)}") + print( + "This would normally fail with invalid cookie, but creation succeeded" + ) + + except Exception as e: + error_msg = str(e).lower() + print(f"āŒ Session creation failed: {e}") + + if ( + "'playwrightcontextmanager' object has no attribute '_connection'" + in error_msg + ): + print("šŸ› This is the _connection attribute error!") + return False + elif "invalid" in error_msg and "cookie" in error_msg: + print( + "āœ… Failed due to invalid cookie (expected), but no _connection error!" + ) + return True + else: + print(f"ā“ Unknown error: {error_msg}") + return False + + return True + + +def test_in_thread(): + """Test fast-linkedin-scraper in a separate thread.""" + print("\n" + "=" * 50) + print("Testing fast-linkedin-scraper in a separate thread...") + + result_container = {} + + def thread_target(): + try: + result_container["result"] = test_fast_scraper_direct() + except Exception as e: + result_container["error"] = e + + thread = threading.Thread(target=thread_target) + thread.start() + thread.join() + + if "error" in result_container: + print(f"āŒ Thread test failed: {result_container['error']}") + return False + + return result_container.get("result", False) + + +if __name__ == "__main__": + print("šŸš€ Fast-LinkedIn-Scraper Debug Test\n") + + # Test 1: Direct execution + print("=" * 50) + direct_result = test_fast_scraper_direct() + + # Test 2: In thread (simulates our async fix) + thread_result = test_in_thread() + + print("\n" + "=" * 50) + print("šŸ“Š RESULTS:") + print(f"Direct test: {'āœ… PASSED' if direct_result else 'āŒ FAILED'}") + print(f"Thread test: {'āœ… PASSED' if thread_result else 'āŒ FAILED'}") + + if direct_result and thread_result: + print("\nšŸŽ‰ fast-linkedin-scraper works correctly!") + else: + print("\nšŸ’„ fast-linkedin-scraper has compatibility issues!") + print("šŸ“‹ Recommendations:") + print(" 1. Check fast-linkedin-scraper installation") + print(" 2. Ensure playwright is installed: playwright install") + print( + " 3. Try upgrading: pip install --upgrade fast-linkedin-scraper playwright" + ) diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..3f05bda --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,45 @@ +name: 'linkedin-mcp-server' + +services: + linkedin-mcp-server: + build: + context: . + dockerfile: Dockerfile + container_name: linkedin-mcp-server + env_file: + - .env + ports: + - "8080:8080" + volumes: + - /tmp/chrome-profile-$(date +%s%N):/tmp/chrome-profile-$(date +%s%N) + - .:/app:z + + # streamable-http or stdio + command: + python linkedin-mcp-server --no-headless --user-agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + + restart: unless-stopped + networks: + - linkedin-mcp-server-network + + ngrok: + build: + context: ./compose/local/ngrok + dockerfile: ./Dockerfile + restart: unless-stopped + command: > + http + 127.0.0.1:8000 + # command: ["ngrok", "start", "--all"] + environment: + - NGROK_CONFIG_FILE=/home/ngrok/.ngrok2/ngrok.yml + ports: + - 4041:4040 + depends_on: + - linkedin-mcp-server + networks: + - linkedin-mcp-server-network + +networks: + linkedin-mcp-server-network: + driver: bridge diff --git a/linkedin_mcp_server/authentication.py b/linkedin_mcp_server/authentication.py index 0d94d1a..5cff370 100644 --- a/linkedin_mcp_server/authentication.py +++ b/linkedin_mcp_server/authentication.py @@ -87,7 +87,7 @@ def clear_authentication() -> bool: def ensure_authentication() -> str: """ - Ensure authentication is available with clear error messages. + Ensure authentication is available with backend-aware error messages. Returns: str: Valid LinkedIn session cookie @@ -100,6 +100,16 @@ def ensure_authentication() -> str: except CredentialsNotFoundError: config = get_config() - raise CredentialsNotFoundError( - ErrorMessages.no_cookie_found(config.is_interactive) - ) + # Provide backend-specific guidance + if config.linkedin.scraper_type == "fast-linkedin-scraper": + error_msg = ( + f"No LinkedIn cookie found for {config.linkedin.scraper_type}. " + "This scraper requires a valid LinkedIn session cookie. You can:\n" + " 1. Set LINKEDIN_COOKIE environment variable with a valid LinkedIn session cookie\n" + " 2. Use --cookie flag to provide the cookie directly\n" + " 3. Run with linkedin-scraper first using --get-cookie to extract a cookie" + ) + else: + error_msg = ErrorMessages.no_cookie_found(config.is_interactive) + + raise CredentialsNotFoundError(error_msg) diff --git a/linkedin_mcp_server/cli_main.py b/linkedin_mcp_server/cli_main.py index 4d34582..8790ab5 100644 --- a/linkedin_mcp_server/cli_main.py +++ b/linkedin_mcp_server/cli_main.py @@ -31,10 +31,16 @@ get_config, get_keyring_name, ) -from linkedin_mcp_server.drivers.chrome import close_all_drivers, get_or_create_driver + +# Chrome driver imports are now handled by the scraper factory from linkedin_mcp_server.exceptions import CredentialsNotFoundError, LinkedInMCPError from linkedin_mcp_server.logging_config import configure_logging from linkedin_mcp_server.server import create_mcp_server, shutdown_handler +from linkedin_mcp_server.scraper_factory import ( + cleanup_scraper_backend, + get_backend_capabilities, + initialize_scraper_backend, +) from linkedin_mcp_server.setup import run_cookie_extraction_setup, run_interactive_setup sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") @@ -242,33 +248,38 @@ def ensure_authentication_ready() -> str: return run_interactive_setup() -def initialize_driver_with_auth(authentication: str) -> None: +def initialize_backend_with_auth(authentication: str) -> None: """ - Phase 2: Initialize driver using existing authentication. + Phase 2: Initialize scraper backend using existing authentication. Args: - authentication: LinkedIn session cookie + authentication: LinkedIn session cookie (not used directly, backends get auth via ensure_authentication()) Raises: - Various exceptions if driver creation or login fails + Various exceptions if backend initialization fails """ config = get_config() if config.server.lazy_init: + backend_info = get_backend_capabilities() logger.info( - "Using lazy initialization - driver will be created on first tool call" + f"Using lazy initialization - {backend_info['backend']} will be created on first tool call" ) return - logger.info("Initializing Chrome WebDriver and logging in...") + backend_info = get_backend_capabilities() + logger.info(f"Initializing {backend_info['backend']} backend...") try: - # Create driver and login with provided authentication - get_or_create_driver(authentication) - logger.info("āœ… Web driver initialized and authenticated successfully") + # Initialize the appropriate backend (authentication is handled internally) + success = initialize_scraper_backend() + if success: + logger.info("āœ… Scraper backend initialized and authenticated successfully") + else: + raise Exception("Backend initialization returned False") except Exception as e: - logger.error(f"Failed to initialize driver: {e}") + logger.error(f"Failed to initialize scraper backend: {e}") raise e @@ -345,13 +356,13 @@ def main() -> None: print("\nāŒ Setup failed - please try again") sys.exit(1) - # Phase 2: Initialize Driver (if not lazy) + # Phase 2: Initialize Backend (if not lazy) try: - initialize_driver_with_auth(authentication) + initialize_backend_with_auth(authentication) except InvalidCredentialsError as e: - logger.error(f"Driver initialization failed with invalid credentials: {e}") + logger.error(f"Backend initialization failed with invalid credentials: {e}") - # Cookie was already cleared in driver layer + # Cookie was already cleared in authentication layer # In interactive mode, try setup again if config.is_interactive: print(f"\nāŒ {str(e)}") @@ -359,7 +370,7 @@ def main() -> None: try: new_authentication = run_interactive_setup() # Try again with new authentication - initialize_driver_with_auth(new_authentication) + initialize_backend_with_auth(new_authentication) logger.info("āœ… Successfully authenticated with new credentials") except Exception as setup_error: logger.error(f"Setup failed: {setup_error}") @@ -377,13 +388,13 @@ def main() -> None: RateLimitError, LoginTimeoutError, ) as e: - logger.error(f"Driver initialization failed: {e}") + logger.error(f"Backend initialization failed: {e}") print(f"\nāŒ {str(e)}") if not config.server.lazy_init: sys.exit(1) except Exception as e: - logger.error(f"Unexpected error during driver initialization: {e}") - print(f"\nāŒ Driver initialization failed: {e}") + logger.error(f"Unexpected error during backend initialization: {e}") + print(f"\nāŒ Backend initialization failed: {e}") if not config.server.lazy_init: sys.exit(1) @@ -437,8 +448,8 @@ def exit_gracefully(exit_code: int = 0) -> None: """Exit the application gracefully, cleaning up resources.""" print("šŸ‘‹ Shutting down LinkedIn MCP server...") - # Clean up drivers - close_all_drivers() + # Clean up scraper backend + cleanup_scraper_backend() # Clean up server shutdown_handler() diff --git a/linkedin_mcp_server/config/loaders.py b/linkedin_mcp_server/config/loaders.py index 0333539..2f5b7c3 100644 --- a/linkedin_mcp_server/config/loaders.py +++ b/linkedin_mcp_server/config/loaders.py @@ -42,6 +42,7 @@ class EnvironmentKeys: LINKEDIN_EMAIL = "LINKEDIN_EMAIL" LINKEDIN_PASSWORD = "LINKEDIN_PASSWORD" LINKEDIN_COOKIE = "LINKEDIN_COOKIE" + LINKEDIN_SCRAPER_TYPE = "LINKEDIN_SCRAPER_TYPE" # Chrome configuration CHROMEDRIVER = "CHROMEDRIVER" @@ -107,7 +108,7 @@ def load_from_keyring(config: AppConfig) -> AppConfig: def load_from_env(config: AppConfig) -> AppConfig: """Load configuration from environment variables.""" - # LinkedIn credentials + # LinkedIn credentials (always applicable) if email := os.environ.get(EnvironmentKeys.LINKEDIN_EMAIL): config.linkedin.email = email @@ -117,32 +118,31 @@ def load_from_env(config: AppConfig) -> AppConfig: if cookie := os.environ.get(EnvironmentKeys.LINKEDIN_COOKIE): config.linkedin.cookie = cookie - # ChromeDriver configuration - if chromedriver := os.environ.get(EnvironmentKeys.CHROMEDRIVER): - config.chrome.chromedriver_path = chromedriver + # Scraper type (affects Chrome config processing) + if scraper_type := os.environ.get(EnvironmentKeys.LINKEDIN_SCRAPER_TYPE): + if scraper_type in ["linkedin-scraper", "fast-linkedin-scraper"]: + config.linkedin.scraper_type = scraper_type # type: ignore - if user_agent := os.environ.get(EnvironmentKeys.USER_AGENT): - config.chrome.user_agent = user_agent - - # Log level + # Log level (always applicable) if log_level_env := os.environ.get(EnvironmentKeys.LOG_LEVEL): log_level_upper = log_level_env.upper() if log_level_upper in ("DEBUG", "INFO", "WARNING", "ERROR"): config.server.log_level = log_level_upper - # Headless mode - if os.environ.get(EnvironmentKeys.HEADLESS) in FALSY_VALUES: - config.chrome.headless = False - elif os.environ.get(EnvironmentKeys.HEADLESS) in TRUTHY_VALUES: - config.chrome.headless = True - - # Lazy initialization + # Lazy initialization (always applicable) if os.environ.get(EnvironmentKeys.LAZY_INIT) in TRUTHY_VALUES: config.server.lazy_init = True elif os.environ.get(EnvironmentKeys.LAZY_INIT) in FALSY_VALUES: config.server.lazy_init = False - # Transport mode + # Warn if LAZY_INIT=0/false is used with fast-linkedin-scraper (it's not meaningful) + if config.linkedin.scraper_type == "fast-linkedin-scraper": + logger.warning( + "LAZY_INIT=0/false has no effect with fast-linkedin-scraper. " + "This backend creates sessions on-demand and doesn't maintain persistent connections." + ) + + # Transport mode (always applicable) if transport_env := os.environ.get(EnvironmentKeys.TRANSPORT): config.server.transport_explicitly_set = True if transport_env == "stdio": @@ -150,6 +150,38 @@ def load_from_env(config: AppConfig) -> AppConfig: elif transport_env == "streamable-http": config.server.transport = "streamable-http" + # Chrome-specific configuration (only for linkedin-scraper) + chrome_env_relevant = config.linkedin.scraper_type == "linkedin-scraper" + + if chrome_env_relevant: + # ChromeDriver configuration + if chromedriver := os.environ.get(EnvironmentKeys.CHROMEDRIVER): + config.chrome.chromedriver_path = chromedriver + + if user_agent := os.environ.get(EnvironmentKeys.USER_AGENT): + config.chrome.user_agent = user_agent + + # Headless mode + if os.environ.get(EnvironmentKeys.HEADLESS) in FALSY_VALUES: + config.chrome.headless = False + elif os.environ.get(EnvironmentKeys.HEADLESS) in TRUTHY_VALUES: + config.chrome.headless = True + else: + # Warn if Chrome environment variables are set but not relevant + chrome_env_vars = [] + if os.environ.get(EnvironmentKeys.CHROMEDRIVER): + chrome_env_vars.append("CHROMEDRIVER") + if os.environ.get(EnvironmentKeys.USER_AGENT): + chrome_env_vars.append("USER_AGENT") + if os.environ.get(EnvironmentKeys.HEADLESS): + chrome_env_vars.append("HEADLESS") + + if chrome_env_vars: + logger.warning( + f"Chrome-specific environment variables ignored for {config.linkedin.scraper_type}: " + f"{', '.join(chrome_env_vars)}. These only apply to linkedin-scraper." + ) + return config @@ -159,10 +191,11 @@ def load_from_args(config: AppConfig) -> AppConfig: description="LinkedIn MCP Server - A Model Context Protocol server for LinkedIn integration" ) + # Always available arguments (common to all scrapers) parser.add_argument( - "--no-headless", - action="store_true", - help="Run Chrome with a visible browser window (useful for debugging)", + "--scraper-type", + choices=["linkedin-scraper", "fast-linkedin-scraper"], + help="Choose scraper library (default: linkedin-scraper)", ) parser.add_argument( @@ -174,7 +207,7 @@ def load_from_args(config: AppConfig) -> AppConfig: parser.add_argument( "--no-lazy-init", action="store_true", - help="Initialize Chrome driver and login immediately", + help="Initialize scraper backend immediately (applies to both scrapers)", ) parser.add_argument( @@ -205,12 +238,6 @@ def load_from_args(config: AppConfig) -> AppConfig: help="HTTP server path (default: /mcp)", ) - parser.add_argument( - "--chromedriver", - type=str, - help="Specify the path to the ChromeDriver executable", - ) - parser.add_argument( "--get-cookie", action="store_true", @@ -229,25 +256,52 @@ def load_from_args(config: AppConfig) -> AppConfig: help="Specify LinkedIn cookie directly", ) - parser.add_argument( + # Chrome-specific arguments (only for linkedin-scraper) + chrome_group = parser.add_argument_group( + "Chrome WebDriver Options (linkedin-scraper only)" + ) + + chrome_group.add_argument( + "--no-headless", + action="store_true", + help="Run Chrome with a visible browser window (only applies to linkedin-scraper)", + ) + + chrome_group.add_argument( + "--chromedriver", + type=str, + help="Specify the path to the ChromeDriver executable (only applies to linkedin-scraper)", + ) + + chrome_group.add_argument( "--user-agent", type=str, - help="Specify custom user agent string to prevent anti-scraping detection", + help="Specify custom user agent string (only applies to linkedin-scraper)", ) args = parser.parse_args() - # Update configuration with parsed arguments - if args.no_headless: - config.chrome.headless = False + # Apply scraper type first (affects other argument processing) + if getattr(args, "scraper_type", None): + config.linkedin.scraper_type = args.scraper_type - # Handle log level argument + # Determine if Chrome-specific arguments should be processed + chrome_args_relevant = config.linkedin.scraper_type == "linkedin-scraper" + + # Always apply common arguments if args.log_level: config.server.log_level = args.log_level if args.no_lazy_init: config.server.lazy_init = False + # Warn if --no-lazy-init is used with fast-linkedin-scraper (it's not meaningful) + if config.linkedin.scraper_type == "fast-linkedin-scraper": + logger.warning( + "--no-lazy-init has no effect with fast-linkedin-scraper. " + "This backend creates sessions on-demand and doesn't maintain persistent connections." + ) + if args.transport: config.server.transport = args.transport config.server.transport_explicitly_set = True @@ -261,9 +315,6 @@ def load_from_args(config: AppConfig) -> AppConfig: if args.path: config.server.path = args.path - if args.chromedriver: - config.chrome.chromedriver_path = args.chromedriver - if args.get_cookie: config.server.get_cookie = True if args.clear_keychain: @@ -271,8 +322,36 @@ def load_from_args(config: AppConfig) -> AppConfig: if args.cookie: config.linkedin.cookie = args.cookie - if args.user_agent: - config.chrome.user_agent = args.user_agent + # Only apply Chrome-specific arguments if using linkedin-scraper + if chrome_args_relevant: + if args.no_headless: + config.chrome.headless = False + logger.debug("Applied --no-headless for linkedin-scraper") + + if args.chromedriver: + config.chrome.chromedriver_path = args.chromedriver + logger.debug( + f"Applied --chromedriver for linkedin-scraper: {args.chromedriver}" + ) + + if args.user_agent: + config.chrome.user_agent = args.user_agent + logger.debug("Applied --user-agent for linkedin-scraper") + else: + # Warn if Chrome-specific arguments are provided but not relevant + chrome_args_provided = [] + if args.no_headless: + chrome_args_provided.append("--no-headless") + if args.chromedriver: + chrome_args_provided.append("--chromedriver") + if args.user_agent: + chrome_args_provided.append("--user-agent") + + if chrome_args_provided: + logger.warning( + f"Chrome-specific arguments ignored for {config.linkedin.scraper_type}: " + f"{', '.join(chrome_args_provided)}. These only apply to linkedin-scraper." + ) return config diff --git a/linkedin_mcp_server/config/schema.py b/linkedin_mcp_server/config/schema.py index 77f9463..b649203 100644 --- a/linkedin_mcp_server/config/schema.py +++ b/linkedin_mcp_server/config/schema.py @@ -41,6 +41,9 @@ class LinkedInConfig: email: Optional[str] = None password: Optional[str] = None cookie: Optional[str] = None + scraper_type: Literal["linkedin-scraper", "fast-linkedin-scraper"] = ( + "linkedin-scraper" + ) @dataclass @@ -73,6 +76,7 @@ def __post_init__(self) -> None: self._validate_transport_config() self._validate_port_range() self._validate_path_format() + self._validate_scraper_config() def _validate_transport_config(self) -> None: """Validate transport configuration is consistent.""" @@ -100,3 +104,32 @@ def _validate_path_format(self) -> None: raise ConfigurationError( f"HTTP path '{self.server.path}' must be at least 2 characters" ) + + def _validate_scraper_config(self) -> None: + """Validate scraper-specific configuration.""" + # Validate that fast-linkedin-scraper has appropriate authentication + if self.linkedin.scraper_type == "fast-linkedin-scraper": + if not self.linkedin.cookie and not self.is_interactive: + raise ConfigurationError( + "fast-linkedin-scraper requires a LinkedIn cookie in non-interactive mode. " + "Provide it via LINKEDIN_COOKIE environment variable or --cookie flag." + ) + + # Validate that Chrome config is only meaningful for linkedin-scraper + if self.linkedin.scraper_type == "fast-linkedin-scraper": + chrome_config_warnings = [] + if not self.chrome.headless: # Default is True, so False means it was set + chrome_config_warnings.append("headless=False") + if self.chrome.chromedriver_path: + chrome_config_warnings.append( + f"chromedriver_path={self.chrome.chromedriver_path}" + ) + if self.chrome.user_agent: + chrome_config_warnings.append(f"user_agent={self.chrome.user_agent}") + if self.chrome.browser_args: + chrome_config_warnings.append( + f"browser_args={self.chrome.browser_args}" + ) + + # Note: We don't raise an error here, just log a warning + # The actual warning is handled in the loaders module diff --git a/linkedin_mcp_server/drivers/__init__.py b/linkedin_mcp_server/drivers/__init__.py index 3d123ca..2085741 100644 --- a/linkedin_mcp_server/drivers/__init__.py +++ b/linkedin_mcp_server/drivers/__init__.py @@ -2,15 +2,82 @@ """ Driver management package for LinkedIn scraping. -This package provides Chrome WebDriver management and automation capabilities -for LinkedIn scraping. It implements a singleton pattern for driver instances -to ensure session persistence across multiple tool calls while handling -authentication, session management, and proper resource cleanup. +This package provides unified driver management for both legacy (Selenium) +and modern (Playwright) LinkedIn scraper backends. It handles scraper-specific +driver initialization, session management, and cleanup. Key Components: -- Chrome WebDriver initialization and configuration -- LinkedIn authentication and session management -- Singleton pattern for driver reuse across tools -- Automatic driver cleanup and resource management +- Chrome WebDriver management for legacy linkedin-scraper +- Playwright session management awareness for fast-linkedin-scraper +- Scraper-aware driver initialization and cleanup +- Singleton pattern for legacy driver reuse - Cross-platform Chrome driver detection and setup """ + +import logging +from typing import Any, Optional + +from linkedin_mcp_server.config import get_config + +logger = logging.getLogger(__name__) + + +def get_driver_for_scraper_type() -> Optional[Any]: + """ + Get appropriate driver based on configured scraper type. + + For legacy scraper: Returns Selenium WebDriver + For fast scraper: Returns None (manages its own Playwright sessions) + + Returns: + Optional[Any]: Driver instance for legacy scraper, None for fast scraper + """ + config = get_config() + + if config.linkedin.scraper_type == "linkedin-scraper": + from linkedin_mcp_server.authentication import ensure_authentication + from linkedin_mcp_server.drivers.chrome import get_or_create_driver + + logger.debug("Getting Chrome WebDriver for legacy scraper") + authentication = ensure_authentication() + return get_or_create_driver(authentication) + else: + logger.debug("Using fast-linkedin-scraper - no driver management needed") + return None + + +def close_all_sessions() -> None: + """ + Close all active sessions for the configured scraper type. + + This function is deprecated. Use scraper_factory.cleanup_scraper_backend() instead. + """ + config = get_config() + + if config.linkedin.scraper_type == "linkedin-scraper": + from linkedin_mcp_server.drivers.chrome import close_all_drivers + + logger.info("Closing Chrome WebDriver sessions") + close_all_drivers() + else: + # For fast-linkedin-scraper, cleanup Playwright resources + try: + from linkedin_mcp_server.playwright_wrapper import cleanup_playwright + + logger.info("Cleaning up Playwright resources for fast-linkedin-scraper") + cleanup_playwright() + except ImportError: + logger.debug("Playwright wrapper not available for cleanup") + except Exception as e: + logger.warning(f"Error cleaning up Playwright resources: {e}") + + +def is_driver_needed() -> bool: + """ + Check if the current scraper type needs driver management. + + Returns: + bool: True if driver management is needed, False otherwise + """ + config = get_config() + return config.linkedin.scraper_type == "linkedin-scraper" diff --git a/linkedin_mcp_server/error_handler.py b/linkedin_mcp_server/error_handler.py index e2f4cc0..0cb802f 100644 --- a/linkedin_mcp_server/error_handler.py +++ b/linkedin_mcp_server/error_handler.py @@ -10,14 +10,35 @@ import logging from typing import Any, Dict, List -from linkedin_scraper.exceptions import ( - CaptchaRequiredError, - InvalidCredentialsError, - LoginTimeoutError, - RateLimitError, - SecurityChallengeError, - TwoFactorAuthError, -) +try: + from linkedin_scraper.exceptions import ( + CaptchaRequiredError, + InvalidCredentialsError, + LoginTimeoutError, + RateLimitError, + SecurityChallengeError, + TwoFactorAuthError, + ) +except ImportError: + # Fallback if linkedin_scraper is not available + class CaptchaRequiredError(Exception): + pass + + class InvalidCredentialsError(Exception): + pass + + class LoginTimeoutError(Exception): + pass + + class RateLimitError(Exception): + pass + + class SecurityChallengeError(Exception): + pass + + class TwoFactorAuthError(Exception): + pass + from linkedin_mcp_server.exceptions import ( CredentialsNotFoundError, @@ -125,6 +146,37 @@ def convert_exception_to_response( else: # Generic error handling with structured logging logger = logging.getLogger(__name__) + + # Check for common fast-linkedin-scraper errors by error message + error_message = str(exception).lower() + + if "cookie" in error_message and ( + "invalid" in error_message or "expired" in error_message + ): + return { + "error": "invalid_cookie", + "message": str(exception), + "resolution": "LinkedIn cookie has expired or is invalid. Please obtain a fresh cookie.", + } + elif "authentication" in error_message or "login" in error_message: + return { + "error": "authentication_failed", + "message": str(exception), + "resolution": "Authentication failed. Check your LinkedIn credentials or cookie.", + } + elif "rate" in error_message or "limit" in error_message: + return { + "error": "rate_limited", + "message": str(exception), + "resolution": "Rate limited by LinkedIn. Wait before making more requests.", + } + elif "captcha" in error_message: + return { + "error": "captcha_required", + "message": str(exception), + "resolution": "Captcha required. Use --no-headless mode to solve manually.", + } + logger.error( f"Error in {context}: {exception}", extra={ @@ -161,6 +213,7 @@ def convert_exception_to_list_response( def safe_get_driver(): """ Safely get or create a driver with proper error handling. + Only used for legacy linkedin-scraper. Fast-linkedin-scraper handles its own browser management. Returns: Driver instance @@ -168,13 +221,24 @@ def safe_get_driver(): Raises: LinkedInMCPError: If driver initialization fails """ - from linkedin_mcp_server.authentication import ensure_authentication - from linkedin_mcp_server.drivers.chrome import get_or_create_driver + from linkedin_mcp_server.config import get_config + + config = get_config() - # Get authentication first - authentication = ensure_authentication() + # Only create Chrome driver for legacy scraper + if config.linkedin.scraper_type == "linkedin-scraper": + from linkedin_mcp_server.authentication import ensure_authentication + from linkedin_mcp_server.drivers.chrome import get_or_create_driver - # Create driver with authentication - driver = get_or_create_driver(authentication) + # Get authentication first + authentication = ensure_authentication() - return driver + # Create driver with authentication + driver = get_or_create_driver(authentication) + + return driver + else: + # For fast-linkedin-scraper, we don't use Chrome driver + raise LinkedInMCPError( + "safe_get_driver() should not be called when using fast-linkedin-scraper" + ) diff --git a/linkedin_mcp_server/playwright_wrapper.py b/linkedin_mcp_server/playwright_wrapper.py new file mode 100644 index 0000000..f8c05b6 --- /dev/null +++ b/linkedin_mcp_server/playwright_wrapper.py @@ -0,0 +1,158 @@ +# linkedin_mcp_server/playwright_wrapper.py +""" +Playwright initialization wrapper to fix fast-linkedin-scraper context manager issues. + +The fast-linkedin-scraper library has issues with Playwright context manager initialization, +causing '_connection' attribute errors. This wrapper ensures Playwright is properly +initialized before the library tries to use it. + +Updated to support both async and sync contexts properly. +""" + +import logging +from contextlib import contextmanager +from typing import Generator, Any +import threading + +logger = logging.getLogger(__name__) + +# Global Playwright instance management +_playwright_lock = threading.Lock() +_global_playwright = None +_reference_count = 0 +_is_async_instance = False + + +def ensure_playwright_started(): + """ + Ensure Playwright is properly started and return the instance. + + This fixes the '_connection' attribute error by properly initializing + Playwright before any library tries to use it. + + Automatically detects if we're in an async context and uses appropriate API. + """ + global _global_playwright, _reference_count, _is_async_instance + + with _playwright_lock: + if _global_playwright is None: + logger.debug("Starting global Playwright instance") + + # Always use sync API - fast-linkedin-scraper requires it + # The scraper adapter handles async context by running everything in a separate thread + logger.debug("Initializing Playwright with sync API") + from playwright.sync_api import sync_playwright + + playwright_cm = sync_playwright() + _global_playwright = playwright_cm.start() + _is_async_instance = False + + _reference_count = 0 + + _reference_count += 1 + logger.debug(f"Playwright reference count: {_reference_count}") + return _global_playwright + + +def release_playwright(): + """ + Release Playwright reference and stop it if no more references exist. + """ + global _global_playwright, _reference_count, _is_async_instance + + with _playwright_lock: + if _global_playwright is not None: + _reference_count -= 1 + logger.debug( + f"Playwright reference count after release: {_reference_count}" + ) + + if _reference_count <= 0: + logger.debug("Stopping global Playwright instance") + try: + if _is_async_instance: + # Handle async cleanup if needed + logger.debug("Stopping async Playwright instance") + else: + # Standard sync cleanup + if _global_playwright is not None: + _global_playwright.stop() + except Exception as e: + logger.warning(f"Error stopping Playwright: {e}") + finally: + _global_playwright = None + _reference_count = 0 + _is_async_instance = False + + +@contextmanager +def managed_playwright() -> Generator[Any, None, None]: + """ + Context manager that ensures Playwright is properly initialized. + + This can be used to wrap any code that needs Playwright to be running, + ensuring it's properly started and cleaned up. + """ + playwright = ensure_playwright_started() + try: + yield playwright + finally: + release_playwright() + + +def initialize_playwright_for_fast_scraper(): + """ + Pre-initialize Playwright for fast-linkedin-scraper to prevent context manager issues. + + Call this before using fast-linkedin-scraper to ensure Playwright is ready. + Works properly in both async and sync contexts. + """ + logger.info("Pre-initializing Playwright for fast-linkedin-scraper") + + try: + # Start Playwright properly (handles async context detection) + playwright = ensure_playwright_started() + + # Verify it's working by testing browser access + logger.debug("Testing Playwright browser access") + + # Run browser test (this should work fine since we're in the same thread as Playwright) + browser = playwright.chromium.launch(headless=True) + logger.debug("Playwright browser launched successfully") + browser.close() + logger.debug("Playwright test completed successfully") + return True + + except Exception as e: + logger.error(f"Playwright initialization test failed: {e}") + release_playwright() + return False + + +def cleanup_playwright(): + """ + Force cleanup of all Playwright resources. + + Call this on shutdown to ensure proper cleanup. + """ + global _global_playwright, _reference_count, _is_async_instance + + logger.info("Forcing Playwright cleanup") + + with _playwright_lock: + if _global_playwright is not None: + try: + if _is_async_instance: + logger.debug("Cleaning up async Playwright instance") + else: + logger.debug("Cleaning up sync Playwright instance") + + if _global_playwright is not None: + _global_playwright.stop() + logger.info("Playwright stopped successfully") + except Exception as e: + logger.warning(f"Error during forced Playwright cleanup: {e}") + finally: + _global_playwright = None + _reference_count = 0 + _is_async_instance = False diff --git a/linkedin_mcp_server/scraper_adapter.py b/linkedin_mcp_server/scraper_adapter.py new file mode 100644 index 0000000..de8ae43 --- /dev/null +++ b/linkedin_mcp_server/scraper_adapter.py @@ -0,0 +1,499 @@ +# linkedin_mcp_server/scraper_adapter.py +""" +Adapter to handle both linkedin-scraper and fast-linkedin-scraper libraries. + +Provides a unified interface for LinkedIn scraping that can switch between +different scraper implementations based on configuration. +""" + +import logging +from typing import Any, Dict, List, Protocol, runtime_checkable + +from linkedin_mcp_server.authentication import ensure_authentication +from linkedin_mcp_server.config import get_config + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class ScraperAdapter(Protocol): + """Protocol for LinkedIn scraper adapters.""" + + def get_person_profile(self, linkedin_username: str) -> Dict[str, Any]: + """Get a person's LinkedIn profile.""" + ... + + def get_company_profile( + self, company_name: str, get_employees: bool = False + ) -> Dict[str, Any]: + """Get a company's LinkedIn profile.""" + ... + + def get_job_details(self, job_id: str) -> Dict[str, Any]: + """Get job details for a specific job posting.""" + ... + + def search_jobs(self, search_term: str) -> List[Dict[str, Any]]: + """Search for jobs on LinkedIn.""" + ... + + def get_recommended_jobs(self) -> List[Dict[str, Any]]: + """Get personalized job recommendations.""" + ... + + +class FastLinkedInScraperAdapter: + """Adapter for fast-linkedin-scraper library.""" + + session: Any + + def _execute_with_session(self, func, *args): + """Helper method to execute functions with proper session management.""" + try: + # Import here to check availability + import fast_linkedin_scraper # noqa: F401 + except ImportError as e: + raise ImportError( + f"fast-linkedin-scraper is not installed. Please install it: pip install fast-linkedin-scraper. Error: {e}" + ) + + cookie = ensure_authentication() + + # Check if we're in an async context and need to run in thread pool + import asyncio + + try: + asyncio.get_running_loop() + # We're in an async context, run the entire session operation in a new thread + # This is necessary because fast-linkedin-scraper uses sync Playwright APIs + logger.debug( + "Async context detected, executing scraper session in dedicated thread" + ) + + def _sync_session_execution(): + # Don't pre-initialize Playwright - let fast-linkedin-scraper handle it completely + # This avoids the _connection attribute issues + logger.debug( + "Executing scraper session in sync thread (no pre-initialization)" + ) + return self._sync_execute_with_session(func, cookie, *args) + + import threading + + # Use a dedicated thread rather than a thread pool to avoid threading issues + result_container = {} + exception_container = {} + + def thread_target(): + try: + result_container["result"] = _sync_session_execution() + except Exception as e: + exception_container["exception"] = e + + thread = threading.Thread(target=thread_target) + thread.start() + thread.join(timeout=120) # 2 minute timeout + + if thread.is_alive(): + raise Exception("Scraping operation timed out after 2 minutes") + + if "exception" in exception_container: + raise exception_container["exception"] + + return result_container.get("result") + + except RuntimeError: + # No async context, execute directly + logger.debug( + "No async context, executing scraper session directly (no pre-initialization)" + ) + return self._sync_execute_with_session(func, cookie, *args) + + def _sync_execute_with_session(self, func, cookie, *args): + """Execute session operations synchronously.""" + from fast_linkedin_scraper import LinkedInSession + from fast_linkedin_scraper.exceptions import InvalidCredentialsError + + # Handle common fast-linkedin-scraper errors with retry logic + max_retries = 2 + for attempt in range(max_retries): + try: + logger.debug( + f"Attempt {attempt + 1}/{max_retries}: Creating LinkedInSession with context manager" + ) + + # Add a small delay for subsequent attempts + if attempt > 0: + import time + + time.sleep(1) + logger.debug("Retrying after delay...") + + with LinkedInSession.from_cookie(cookie) as session: + logger.debug(f"Session created successfully, type: {type(session)}") + return func(session, *args) + + except InvalidCredentialsError as cred_error: + logger.error(f"LinkedIn authentication failed: {cred_error}") + raise Exception( + f"LinkedIn authentication failed. Cookie may be invalid or expired: {cred_error}" + ) + + except AttributeError as attr_error: + attr_error_str = str(attr_error) + if ( + "'PlaywrightContextManager' object has no attribute '_connection'" + in attr_error_str + ): + logger.error( + f"Playwright context manager error: {attr_error}. " + f"This is a known issue with fast-linkedin-scraper library." + ) + raise Exception( + "fast-linkedin-scraper has Playwright context management issues. " + "This may be due to version compatibility or installation problems. " + "Try: pip install --upgrade fast-linkedin-scraper playwright && playwright install" + ) + else: + raise attr_error + + except Exception as e: + error_msg = str(e).lower() + + # Handle specific error patterns + if "net::err_too_many_redirects" in error_msg: + raise Exception( + "LinkedIn is redirecting too much - possible rate limiting or bot detection. Try again later or check cookie validity." + ) + elif "connection" in error_msg and "playwright" in error_msg: + if attempt < max_retries - 1: + logger.warning( + f"Playwright connection issue on attempt {attempt + 1}, retrying..." + ) + continue + else: + raise Exception( + "Playwright connection issue persisted after retries. Try running: playwright install && playwright install-deps" + ) + elif "timeout" in error_msg: + if attempt < max_retries - 1: + logger.warning(f"Timeout on attempt {attempt + 1}, retrying...") + continue + else: + raise Exception( + "LinkedIn page loading timeout persisted after retries. Network issues or LinkedIn is slow." + ) + else: + # Re-raise original error for non-retryable issues + logger.error(f"Non-retryable error in fast-linkedin-scraper: {e}") + raise + + def get_person_profile(self, linkedin_username: str) -> Dict[str, Any]: + """Get a person's LinkedIn profile using fast-linkedin-scraper.""" + try: + linkedin_url = f"https://www.linkedin.com/in/{linkedin_username}/" + logger.info(f"Scraping profile: {linkedin_url}") + + def _get_profile(session, url): + person = session.get_profile(url) + return person.model_dump() + + return self._execute_with_session(_get_profile, linkedin_url) + + except ImportError: + raise ImportError( + "fast-linkedin-scraper is not installed. Please install it: pip install fast-linkedin-scraper" + ) + except Exception as e: + logger.error(f"Error scraping profile {linkedin_url}: {str(e)}") + raise + + def get_company_profile( + self, company_name: str, get_employees: bool = False + ) -> Dict[str, Any]: + """Get a company's LinkedIn profile using fast-linkedin-scraper.""" + try: + linkedin_url = f"https://www.linkedin.com/company/{company_name}/" + logger.info(f"Scraping company: {linkedin_url}") + + def _get_company(session, url, get_emps): + company = session.get_company(url) + result = company.model_dump() + + # Add employee data if requested + if get_emps: + logger.info("Fetching employees may take a while...") + try: + employees = session.get_company_employees(url) + result["employees"] = [emp.model_dump() for emp in employees] + except Exception as e: + logger.warning(f"Could not fetch employees: {str(e)}") + result["employees"] = [] + + return result + + return self._execute_with_session(_get_company, linkedin_url, get_employees) + + except ImportError: + raise ImportError( + "fast-linkedin-scraper is not installed. Please install it: pip install fast-linkedin-scraper" + ) + except Exception as e: + logger.error(f"Error scraping company {linkedin_url}: {str(e)}") + raise + + def get_job_details(self, job_id: str) -> Dict[str, Any]: + """Get job details using fast-linkedin-scraper.""" + try: + job_url = f"https://www.linkedin.com/jobs/view/{job_id}/" + logger.info(f"Scraping job: {job_url}") + + def _get_job(session, url): + job = session.get_job(url) + return job.model_dump() + + return self._execute_with_session(_get_job, job_url) + + except ImportError: + raise ImportError( + "fast-linkedin-scraper is not installed. Please install it: pip install fast-linkedin-scraper" + ) + except Exception as e: + logger.error(f"Error scraping job {job_url}: {str(e)}") + raise + + def search_jobs(self, search_term: str) -> List[Dict[str, Any]]: + """Search for jobs using fast-linkedin-scraper.""" + try: + logger.info(f"Searching jobs: {search_term}") + + def _search_jobs(session, term): + jobs = session.search_jobs(term) + return [job.model_dump() for job in jobs] + + return self._execute_with_session(_search_jobs, search_term) + + except ImportError: + raise ImportError( + "fast-linkedin-scraper is not installed. Please install it: pip install fast-linkedin-scraper" + ) + except Exception as e: + logger.error(f"Error searching jobs: {str(e)}") + raise + + def get_recommended_jobs(self) -> List[Dict[str, Any]]: + """Get recommended jobs using fast-linkedin-scraper.""" + try: + logger.info("Getting recommended jobs") + + def _get_recommended_jobs(session): + jobs = session.get_recommended_jobs() + return [job.model_dump() for job in jobs] + + return self._execute_with_session(_get_recommended_jobs) + + except ImportError: + raise ImportError( + "fast-linkedin-scraper is not installed. Please install it: pip install fast-linkedin-scraper" + ) + except Exception as e: + logger.error(f"Error getting recommended jobs: {str(e)}") + raise + + +class LegacyLinkedInScraperAdapter: + """Adapter for legacy linkedin-scraper library.""" + + def get_person_profile(self, linkedin_username: str) -> Dict[str, Any]: + """Get a person's LinkedIn profile using legacy linkedin-scraper.""" + from linkedin_scraper import Person + from linkedin_mcp_server.drivers import get_driver_for_scraper_type + + linkedin_url = f"https://www.linkedin.com/in/{linkedin_username}/" + driver = get_driver_for_scraper_type() + + logger.info(f"Scraping profile: {linkedin_url}") + person = Person(linkedin_url, driver=driver, close_on_complete=False) + + # Convert experiences to structured dictionaries + experiences: List[Dict[str, Any]] = [ + { + "position_title": exp.position_title, + "company": exp.institution_name, + "from_date": exp.from_date, + "to_date": exp.to_date, + "duration": exp.duration, + "location": exp.location, + "description": exp.description, + } + for exp in person.experiences + ] + + # Convert educations to structured dictionaries + educations: List[Dict[str, Any]] = [ + { + "institution": edu.institution_name, + "degree": edu.degree, + "from_date": edu.from_date, + "to_date": edu.to_date, + "description": edu.description, + } + for edu in person.educations + ] + + # Convert interests to list of titles + interests: List[str] = [interest.title for interest in person.interests] + + # Convert accomplishments to structured dictionaries + accomplishments: List[Dict[str, str]] = [ + {"category": acc.category, "title": acc.title} + for acc in person.accomplishments + ] + + # Convert contacts to structured dictionaries + contacts: List[Dict[str, str]] = [ + { + "name": contact.name, + "occupation": contact.occupation, + "url": contact.url, + } + for contact in person.contacts + ] + + return { + "name": person.name, + "about": person.about, + "experiences": experiences, + "educations": educations, + "interests": interests, + "accomplishments": accomplishments, + "contacts": contacts, + "company": person.company, + "job_title": person.job_title, + "open_to_work": getattr(person, "open_to_work", False), + } + + def get_company_profile( + self, company_name: str, get_employees: bool = False + ) -> Dict[str, Any]: + """Get a company's LinkedIn profile using legacy linkedin-scraper.""" + from linkedin_scraper import Company + from linkedin_mcp_server.drivers import get_driver_for_scraper_type + + linkedin_url = f"https://www.linkedin.com/company/{company_name}/" + driver = get_driver_for_scraper_type() + + logger.info(f"Scraping company: {linkedin_url}") + if get_employees: + logger.info("Fetching employees may take a while...") + + company = Company( + linkedin_url, + driver=driver, + get_employees=get_employees, + close_on_complete=False, + ) + + # Convert showcase pages to structured dictionaries + showcase_pages: List[Dict[str, Any]] = [ + { + "name": page.name, + "linkedin_url": page.linkedin_url, + "followers": page.followers, + } + for page in company.showcase_pages + ] + + # Convert affiliated companies to structured dictionaries + affiliated_companies: List[Dict[str, Any]] = [ + { + "name": affiliated.name, + "linkedin_url": affiliated.linkedin_url, + "followers": affiliated.followers, + } + for affiliated in company.affiliated_companies + ] + + # Build the result dictionary + result: Dict[str, Any] = { + "name": company.name, + "about_us": company.about_us, + "website": company.website, + "phone": company.phone, + "headquarters": company.headquarters, + "founded": company.founded, + "industry": company.industry, + "company_type": company.company_type, + "company_size": company.company_size, + "specialties": company.specialties, + "showcase_pages": showcase_pages, + "affiliated_companies": affiliated_companies, + "headcount": company.headcount, + } + + # Add employees if requested and available + if get_employees and company.employees: + result["employees"] = company.employees + + return result + + def get_job_details(self, job_id: str) -> Dict[str, Any]: + """Get job details using legacy linkedin-scraper.""" + from linkedin_scraper import Job + from linkedin_mcp_server.drivers import get_driver_for_scraper_type + + job_url = f"https://www.linkedin.com/jobs/view/{job_id}/" + driver = get_driver_for_scraper_type() + + logger.info(f"Scraping job: {job_url}") + job = Job(job_url, driver=driver, close_on_complete=False) + + return job.to_dict() + + def search_jobs(self, search_term: str) -> List[Dict[str, Any]]: + """Search for jobs using legacy linkedin-scraper.""" + from linkedin_scraper import JobSearch + from linkedin_mcp_server.drivers import get_driver_for_scraper_type + + driver = get_driver_for_scraper_type() + + logger.info(f"Searching jobs: {search_term}") + job_search = JobSearch(driver=driver, close_on_complete=False, scrape=False) + jobs = job_search.search(search_term) + + return [job.to_dict() for job in jobs] + + def get_recommended_jobs(self) -> List[Dict[str, Any]]: + """Get recommended jobs using legacy linkedin-scraper.""" + from linkedin_scraper import JobSearch + from linkedin_mcp_server.drivers import get_driver_for_scraper_type + + driver = get_driver_for_scraper_type() + + logger.info("Getting recommended jobs") + job_search = JobSearch( + driver=driver, + close_on_complete=False, + scrape=True, # Enable scraping to get recommended jobs + scrape_recommended_jobs=True, + ) + + if hasattr(job_search, "recommended_jobs") and job_search.recommended_jobs: + return [job.to_dict() for job in job_search.recommended_jobs] + else: + return [] + + +def get_scraper_adapter() -> ScraperAdapter: + """Get the appropriate scraper adapter based on configuration.""" + config = get_config() + scraper_type = config.linkedin.scraper_type + + logger.info(f"Using scraper type: {scraper_type}") + + if scraper_type == "fast-linkedin-scraper": + return FastLinkedInScraperAdapter() + elif scraper_type == "linkedin-scraper": + return LegacyLinkedInScraperAdapter() + else: + raise ValueError(f"Unknown scraper type: {scraper_type}") diff --git a/linkedin_mcp_server/scraper_factory.py b/linkedin_mcp_server/scraper_factory.py new file mode 100644 index 0000000..862f288 --- /dev/null +++ b/linkedin_mcp_server/scraper_factory.py @@ -0,0 +1,199 @@ +# linkedin_mcp_server/scraper_factory.py +""" +Factory pattern for LinkedIn scraper backends. + +Provides clean separation between Chrome WebDriver (linkedin-scraper) and +Playwright (fast-linkedin-scraper) initialization flows. Ensures that each +backend only initializes its required dependencies. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict + +from linkedin_mcp_server.config import get_config +from linkedin_mcp_server.authentication import ensure_authentication + +logger = logging.getLogger(__name__) + + +class ScraperBackend(ABC): + """Abstract base class for scraper backends.""" + + @abstractmethod + def initialize(self) -> bool: + """Initialize the scraper backend.""" + pass + + @abstractmethod + def cleanup(self) -> None: + """Clean up backend resources.""" + pass + + @abstractmethod + def get_backend_info(self) -> Dict[str, Any]: + """Get information about this backend.""" + pass + + +class LinkedInScraperBackend(ScraperBackend): + """Chrome WebDriver backend for legacy linkedin-scraper.""" + + def initialize(self) -> bool: + """Initialize Chrome WebDriver for linkedin-scraper.""" + logger.info("Initializing Chrome WebDriver backend for linkedin-scraper") + + try: + # Only import Chrome driver components when actually using legacy scraper + from linkedin_mcp_server.drivers.chrome import get_or_create_driver + + # Get authentication and initialize driver + authentication = ensure_authentication() + get_or_create_driver(authentication) + + logger.info("Chrome WebDriver backend initialized successfully") + return True + + except Exception as e: + logger.error(f"Failed to initialize Chrome WebDriver backend: {e}") + return False + + def cleanup(self) -> None: + """Clean up Chrome WebDriver resources.""" + try: + from linkedin_mcp_server.drivers.chrome import close_all_drivers + + logger.info("Cleaning up Chrome WebDriver backend") + close_all_drivers() + except Exception as e: + logger.warning(f"Error cleaning up Chrome WebDriver backend: {e}") + + def get_backend_info(self) -> Dict[str, Any]: + """Get Chrome WebDriver backend information.""" + return { + "backend": "linkedin-scraper", + "type": "Chrome WebDriver (Selenium)", + "supports_headless": True, + "supports_no_lazy_init": True, + "requires_chromedriver": True, + } + + +class FastLinkedInScraperBackend(ScraperBackend): + """Playwright backend for fast-linkedin-scraper.""" + + def initialize(self) -> bool: + """Initialize Playwright for fast-linkedin-scraper.""" + logger.info("Initializing Playwright backend for fast-linkedin-scraper") + + # fast-linkedin-scraper doesn't need persistent initialization + # It creates sessions on-demand, so we just verify authentication is available + try: + ensure_authentication() + logger.info("Playwright backend initialized successfully (on-demand mode)") + return True + + except Exception as e: + logger.error(f"Failed to initialize Playwright backend: {e}") + return False + + def cleanup(self) -> None: + """Clean up Playwright resources.""" + # fast-linkedin-scraper manages its own Playwright sessions + # No persistent resources to clean up in on-demand mode + logger.info( + "Cleaning up Playwright backend (on-demand mode - no persistent resources)" + ) + + def get_backend_info(self) -> Dict[str, Any]: + """Get Playwright backend information.""" + return { + "backend": "fast-linkedin-scraper", + "type": "Playwright (on-demand sessions)", + "supports_headless": False, # Playwright manages its own headless mode internally + "supports_no_lazy_init": False, # Creates sessions on-demand, no persistent initialization + "requires_chromedriver": False, + "initialization_mode": "on-demand", + } + + +def get_scraper_backend() -> ScraperBackend: + """ + Get the appropriate scraper backend based on configuration. + + Returns: + ScraperBackend: The configured scraper backend instance + """ + config = get_config() + scraper_type = config.linkedin.scraper_type + + logger.info(f"Creating scraper backend for: {scraper_type}") + + if scraper_type == "linkedin-scraper": + return LinkedInScraperBackend() + elif scraper_type == "fast-linkedin-scraper": + return FastLinkedInScraperBackend() + else: + raise ValueError(f"Unknown scraper type: {scraper_type}") + + +# Track initialization state +_backend_initialized = False + + +def initialize_scraper_backend() -> bool: + """ + Initialize the configured scraper backend. + + Returns: + bool: True if initialization was successful, False otherwise + """ + global _backend_initialized + + try: + backend = get_scraper_backend() + result = backend.initialize() + _backend_initialized = result + return result + except Exception as e: + logger.error(f"Failed to initialize scraper backend: {e}") + _backend_initialized = False + return False + + +def cleanup_scraper_backend() -> None: + """Clean up the configured scraper backend.""" + global _backend_initialized + + try: + backend = get_scraper_backend() + backend.cleanup() + _backend_initialized = False + except Exception as e: + logger.warning(f"Error cleaning up scraper backend: {e}") + _backend_initialized = False + + +def is_scraper_backend_initialized() -> bool: + """Check if the scraper backend is initialized.""" + return _backend_initialized + + +def get_scraper_factory(): + """Get the scraper factory (for compatibility with existing code).""" + return get_scraper_backend() + + +def get_backend_capabilities() -> Dict[str, Any]: + """ + Get capabilities of the current scraper backend. + + Returns: + Dict[str, Any]: Backend information and capabilities + """ + try: + backend = get_scraper_backend() + return backend.get_backend_info() + except Exception as e: + logger.error(f"Error getting backend capabilities: {e}") + return {"backend": "unknown", "error": str(e)} diff --git a/linkedin_mcp_server/server.py b/linkedin_mcp_server/server.py index 7f7370a..6f5544f 100644 --- a/linkedin_mcp_server/server.py +++ b/linkedin_mcp_server/server.py @@ -32,13 +32,19 @@ def create_mcp_server() -> FastMCP: @mcp.tool() async def close_session() -> Dict[str, Any]: """Close the current browser session and clean up resources.""" - from linkedin_mcp_server.drivers.chrome import close_all_drivers + from linkedin_mcp_server.drivers import close_all_sessions, is_driver_needed try: - close_all_drivers() + close_all_sessions() + + if is_driver_needed(): + message = "Successfully closed Chrome WebDriver session and cleaned up resources" + else: + message = "No persistent browser session to close (using fast-linkedin-scraper)" + return { "status": "success", - "message": "Successfully closed the browser session and cleaned up resources", + "message": message, } except Exception as e: return { @@ -51,6 +57,6 @@ async def close_session() -> Dict[str, Any]: def shutdown_handler() -> None: """Clean up resources on shutdown.""" - from linkedin_mcp_server.drivers.chrome import close_all_drivers + from linkedin_mcp_server.drivers import close_all_sessions - close_all_drivers() + close_all_sessions() diff --git a/linkedin_mcp_server/setup.py b/linkedin_mcp_server/setup.py index 408478e..823cfbb 100644 --- a/linkedin_mcp_server/setup.py +++ b/linkedin_mcp_server/setup.py @@ -285,6 +285,10 @@ def run_cookie_extraction_setup() -> str: """ Run setup specifically for cookie extraction (--get-cookie mode). + Note: Cookie extraction always uses Chrome WebDriver regardless of scraper type, + as it requires credential-based login which is only supported by linkedin-scraper. + The extracted cookie can then be used with fast-linkedin-scraper. + Returns: str: Captured LinkedIn session cookie for display @@ -293,11 +297,12 @@ def run_cookie_extraction_setup() -> str: """ logger.info("šŸ”— LinkedIn MCP Server - Cookie Extraction mode started") print("šŸ”— LinkedIn MCP Server - Cookie Extraction") + print("ā„¹ļø Cookie extraction uses Chrome WebDriver for credential-based login") # Get credentials credentials: Dict[str, str] = get_credentials_for_setup() - # Capture cookie + # Capture cookie using Chrome WebDriver (regardless of configured scraper type) cookie: str = capture_cookie_from_credentials( credentials["email"], credentials["password"] ) diff --git a/linkedin_mcp_server/tools/company.py b/linkedin_mcp_server/tools/company.py index 5a50358..c2ffa2c 100644 --- a/linkedin_mcp_server/tools/company.py +++ b/linkedin_mcp_server/tools/company.py @@ -7,12 +7,12 @@ """ import logging -from typing import Any, Dict, List +from typing import Any, Dict from fastmcp import FastMCP -from linkedin_scraper import Company -from linkedin_mcp_server.error_handler import handle_tool_error, safe_get_driver +from linkedin_mcp_server.error_handler import handle_tool_error +from linkedin_mcp_server.scraper_adapter import get_scraper_adapter logger = logging.getLogger(__name__) @@ -40,63 +40,7 @@ async def get_company_profile( Dict[str, Any]: Structured data from the company's profile """ try: - # Construct clean LinkedIn URL from company name - linkedin_url = f"https://www.linkedin.com/company/{company_name}/" - - driver = safe_get_driver() - - logger.info(f"Scraping company: {linkedin_url}") - if get_employees: - logger.info("Fetching employees may take a while...") - - company = Company( - linkedin_url, - driver=driver, - get_employees=get_employees, - close_on_complete=False, - ) - - # Convert showcase pages to structured dictionaries - showcase_pages: List[Dict[str, Any]] = [ - { - "name": page.name, - "linkedin_url": page.linkedin_url, - "followers": page.followers, - } - for page in company.showcase_pages - ] - - # Convert affiliated companies to structured dictionaries - affiliated_companies: List[Dict[str, Any]] = [ - { - "name": affiliated.name, - "linkedin_url": affiliated.linkedin_url, - "followers": affiliated.followers, - } - for affiliated in company.affiliated_companies - ] - - # Build the result dictionary - result: Dict[str, Any] = { - "name": company.name, - "about_us": company.about_us, - "website": company.website, - "phone": company.phone, - "headquarters": company.headquarters, - "founded": company.founded, - "industry": company.industry, - "company_type": company.company_type, - "company_size": company.company_size, - "specialties": company.specialties, - "showcase_pages": showcase_pages, - "affiliated_companies": affiliated_companies, - "headcount": company.headcount, - } - - # Add employees if requested and available - if get_employees and company.employees: - result["employees"] = company.employees - - return result + scraper = get_scraper_adapter() + return scraper.get_company_profile(company_name, get_employees) except Exception as 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 d651352..4399378 100644 --- a/linkedin_mcp_server/tools/job.py +++ b/linkedin_mcp_server/tools/job.py @@ -10,13 +10,12 @@ from typing import Any, Dict, List from fastmcp import FastMCP -from linkedin_scraper import Job, JobSearch from linkedin_mcp_server.error_handler import ( handle_tool_error, handle_tool_error_list, - safe_get_driver, ) +from linkedin_mcp_server.scraper_adapter import get_scraper_adapter logger = logging.getLogger(__name__) @@ -42,16 +41,8 @@ async def get_job_details(job_id: str) -> Dict[str, Any]: application count, and job description (may be empty if content is protected) """ try: - # Construct clean LinkedIn URL from job ID - job_url = f"https://www.linkedin.com/jobs/view/{job_id}/" - - 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() + scraper = get_scraper_adapter() + return scraper.get_job_details(job_id) except Exception as e: return handle_tool_error(e, "get_job_details") @@ -67,14 +58,8 @@ async def search_jobs(search_term: str) -> List[Dict[str, Any]]: List[Dict[str, Any]]: List of job search results """ try: - 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] + scraper = get_scraper_adapter() + return scraper.search_jobs(search_term) except Exception as e: return handle_tool_error_list(e, "search_jobs") @@ -87,19 +72,7 @@ async def get_recommended_jobs() -> List[Dict[str, Any]]: List[Dict[str, Any]]: List of recommended jobs """ try: - driver = safe_get_driver() - - logger.info("Getting recommended jobs") - job_search = JobSearch( - driver=driver, - close_on_complete=False, - scrape=True, # Enable scraping to get recommended jobs - scrape_recommended_jobs=True, - ) - - if hasattr(job_search, "recommended_jobs") and job_search.recommended_jobs: - return [job.to_dict() for job in job_search.recommended_jobs] - else: - return [] + scraper = get_scraper_adapter() + return scraper.get_recommended_jobs() except Exception as 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 c9428a7..4cfa5c9 100644 --- a/linkedin_mcp_server/tools/person.py +++ b/linkedin_mcp_server/tools/person.py @@ -7,12 +7,12 @@ """ import logging -from typing import Any, Dict, List +from typing import Any, Dict from fastmcp import FastMCP -from linkedin_scraper import Person -from linkedin_mcp_server.error_handler import handle_tool_error, safe_get_driver +from linkedin_mcp_server.error_handler import handle_tool_error +from linkedin_mcp_server.scraper_adapter import get_scraper_adapter logger = logging.getLogger(__name__) @@ -37,71 +37,7 @@ async def get_person_profile(linkedin_username: str) -> Dict[str, Any]: Dict[str, Any]: Structured data from the person's profile """ try: - # Construct clean LinkedIn URL from username - linkedin_url = f"https://www.linkedin.com/in/{linkedin_username}/" - - 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 - experiences: List[Dict[str, Any]] = [ - { - "position_title": exp.position_title, - "company": exp.institution_name, - "from_date": exp.from_date, - "to_date": exp.to_date, - "duration": exp.duration, - "location": exp.location, - "description": exp.description, - } - for exp in person.experiences - ] - - # Convert educations to structured dictionaries - educations: List[Dict[str, Any]] = [ - { - "institution": edu.institution_name, - "degree": edu.degree, - "from_date": edu.from_date, - "to_date": edu.to_date, - "description": edu.description, - } - for edu in person.educations - ] - - # Convert interests to list of titles - interests: List[str] = [interest.title for interest in person.interests] - - # Convert accomplishments to structured dictionaries - accomplishments: List[Dict[str, str]] = [ - {"category": acc.category, "title": acc.title} - for acc in person.accomplishments - ] - - # Convert contacts to structured dictionaries - contacts: List[Dict[str, str]] = [ - { - "name": contact.name, - "occupation": contact.occupation, - "url": contact.url, - } - for contact in person.contacts - ] - - # Return the complete profile data - return { - "name": person.name, - "about": person.about, - "experiences": experiences, - "educations": educations, - "interests": interests, - "accomplishments": accomplishments, - "contacts": contacts, - "company": person.company, - "job_title": person.job_title, - "open_to_work": getattr(person, "open_to_work", False), - } + scraper = get_scraper_adapter() + return scraper.get_person_profile(linkedin_username) except Exception as e: return handle_tool_error(e, "get_person_profile") diff --git a/pyproject.toml b/pyproject.toml index 891072d..d8e370a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,9 @@ dependencies = [ "inquirer>=3.4.0", "keyring>=25.6.0", "linkedin-scraper", + "fast-linkedin-scraper", "pyperclip>=1.9.0", + "playwright>=1.54.0", ] [project.scripts] @@ -28,6 +30,7 @@ linkedin_mcp_server = ["py.typed"] [tool.uv.sources] linkedin-scraper = { git = "https://github.com/stickerdaniel/linkedin_scraper.git" } +fast-linkedin-scraper = { git = "https://github.com/stickerdaniel/fast-linkedin-scraper.git" } [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index 0956c41..58fa4f2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -382,6 +382,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "fast-linkedin-scraper" +version = "0.1.0" +source = { git = "https://github.com/stickerdaniel/fast-linkedin-scraper.git#662f0a3948e68f768ef393329ced28c4e115176b" } +dependencies = [ + { name = "playwright" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "rapidfuzz" }, + { name = "suffix-trees" }, +] + [[package]] name = "fastmcp" version = "2.10.1" @@ -471,6 +483,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + [[package]] name = "h11" version = "0.14.0" @@ -661,10 +706,12 @@ name = "linkedin-mcp-server" version = "1.4.0" source = { editable = "." } dependencies = [ + { name = "fast-linkedin-scraper" }, { name = "fastmcp" }, { name = "inquirer" }, { name = "keyring" }, { name = "linkedin-scraper" }, + { name = "playwright" }, { name = "pyperclip" }, ] @@ -681,10 +728,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "fast-linkedin-scraper", git = "https://github.com/stickerdaniel/fast-linkedin-scraper.git" }, { 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/stickerdaniel/linkedin_scraper.git" }, + { name = "playwright", specifier = ">=1.54.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, ] @@ -915,6 +964,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, ] +[[package]] +name = "playwright" +version = "1.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/09/33d5bfe393a582d8dac72165a9e88b274143c9df411b65ece1cc13f42988/playwright-1.54.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bf3b845af744370f1bd2286c2a9536f474cc8a88dc995b72ea9a5be714c9a77d", size = 40439034, upload-time = "2025-07-22T13:58:04.816Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7b/51882dc584f7aa59f446f2bb34e33c0e5f015de4e31949e5b7c2c10e54f0/playwright-1.54.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:780928b3ca2077aea90414b37e54edd0c4bbb57d1aafc42f7aa0b3fd2c2fac02", size = 38702308, upload-time = "2025-07-22T13:58:08.211Z" }, + { url = "https://files.pythonhosted.org/packages/73/a1/7aa8ae175b240c0ec8849fcf000e078f3c693f9aa2ffd992da6550ea0dff/playwright-1.54.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:81d0b6f28843b27f288cfe438af0a12a4851de57998009a519ea84cee6fbbfb9", size = 40439037, upload-time = "2025-07-22T13:58:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/a9/45084fd23b6206f954198296ce39b0acf50debfdf3ec83a593e4d73c9c8a/playwright-1.54.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:09919f45cc74c64afb5432646d7fef0d19fff50990c862cb8d9b0577093f40cc", size = 45920135, upload-time = "2025-07-22T13:58:14.494Z" }, + { url = "https://files.pythonhosted.org/packages/02/d4/6a692f4c6db223adc50a6e53af405b45308db39270957a6afebddaa80ea2/playwright-1.54.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ae206c55737e8e3eae51fb385d61c0312eeef31535643bb6232741b41b6fdc", size = 45302695, upload-time = "2025-07-22T13:58:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/4ee60a1c3714321db187bebbc40d52cea5b41a856925156325058b5fca5a/playwright-1.54.0-py3-none-win32.whl", hash = "sha256:0b108622ffb6906e28566f3f31721cd57dda637d7e41c430287804ac01911f56", size = 35469309, upload-time = "2025-07-22T13:58:21.917Z" }, + { url = "https://files.pythonhosted.org/packages/aa/77/8f8fae05a242ef639de963d7ae70a69d0da61d6d72f1207b8bbf74ffd3e7/playwright-1.54.0-py3-none-win_amd64.whl", hash = "sha256:9e5aee9ae5ab1fdd44cd64153313a2045b136fcbcfb2541cc0a3d909132671a2", size = 35469311, upload-time = "2025-07-22T13:58:24.707Z" }, + { url = "https://files.pythonhosted.org/packages/33/ff/99a6f4292a90504f2927d34032a4baf6adb498dc3f7cf0f3e0e22899e310/playwright-1.54.0-py3-none-win_arm64.whl", hash = "sha256:a975815971f7b8dca505c441a4c56de1aeb56a211290f8cc214eeef5524e8d75", size = 31239119, upload-time = "2025-07-22T13:58:27.56Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1081,6 +1149,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -1198,6 +1278,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "rapidfuzz" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226, upload-time = "2025-04-03T20:38:51.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/4b/a326f57a4efed8f5505b25102797a58e37ee11d94afd9d9422cb7c76117e/rapidfuzz-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a1a6a906ba62f2556372282b1ef37b26bca67e3d2ea957277cfcefc6275cca7", size = 1989501, upload-time = "2025-04-03T20:36:13.43Z" }, + { url = "https://files.pythonhosted.org/packages/b7/53/1f7eb7ee83a06c400089ec7cb841cbd581c2edd7a4b21eb2f31030b88daa/rapidfuzz-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fd0975e015b05c79a97f38883a11236f5a24cca83aa992bd2558ceaa5652b26", size = 1445379, upload-time = "2025-04-03T20:36:16.439Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/de8069a4599cc8e6d194e5fa1782c561151dea7d5e2741767137e2a8c1f0/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d4e13593d298c50c4f94ce453f757b4b398af3fa0fd2fde693c3e51195b7f69", size = 1405986, upload-time = "2025-04-03T20:36:18.447Z" }, + { url = "https://files.pythonhosted.org/packages/5d/77/d9a90b39c16eca20d70fec4ca377fbe9ea4c0d358c6e4736ab0e0e78aaf6/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed6f416bda1c9133000009d84d9409823eb2358df0950231cc936e4bf784eb97", size = 5310809, upload-time = "2025-04-03T20:36:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/14da291b0d0f22262d19522afaf63bccf39fc027c981233fb2137a57b71f/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dc82b6ed01acb536b94a43996a94471a218f4d89f3fdd9185ab496de4b2a981", size = 1629394, upload-time = "2025-04-03T20:36:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/79ed7e4fa58f37c0f8b7c0a62361f7089b221fe85738ae2dbcfb815e985a/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9d824de871daa6e443b39ff495a884931970d567eb0dfa213d234337343835f", size = 1600544, upload-time = "2025-04-03T20:36:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/4e/20/e62b4d13ba851b0f36370060025de50a264d625f6b4c32899085ed51f980/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d18228a2390375cf45726ce1af9d36ff3dc1f11dce9775eae1f1b13ac6ec50f", size = 3052796, upload-time = "2025-04-03T20:36:26.279Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/55fdf4387dec10aa177fe3df8dbb0d5022224d95f48664a21d6b62a5299d/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5fe634c9482ec5d4a6692afb8c45d370ae86755e5f57aa6c50bfe4ca2bdd87", size = 2464016, upload-time = "2025-04-03T20:36:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/0872f6a56c0f473165d3b47d4170fa75263dc5f46985755aa9bf2bbcdea1/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:694eb531889f71022b2be86f625a4209c4049e74be9ca836919b9e395d5e33b3", size = 7556725, upload-time = "2025-04-03T20:36:30.629Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f3/6c0750e484d885a14840c7a150926f425d524982aca989cdda0bb3bdfa57/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11b47b40650e06147dee5e51a9c9ad73bb7b86968b6f7d30e503b9f8dd1292db", size = 2859052, upload-time = "2025-04-03T20:36:32.836Z" }, + { url = "https://files.pythonhosted.org/packages/6f/98/5a3a14701b5eb330f444f7883c9840b43fb29c575e292e09c90a270a6e07/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:98b8107ff14f5af0243f27d236bcc6e1ef8e7e3b3c25df114e91e3a99572da73", size = 3390219, upload-time = "2025-04-03T20:36:35.062Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7d/f4642eaaeb474b19974332f2a58471803448be843033e5740965775760a5/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b836f486dba0aceb2551e838ff3f514a38ee72b015364f739e526d720fdb823a", size = 4377924, upload-time = "2025-04-03T20:36:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/8e/83/fa33f61796731891c3e045d0cbca4436a5c436a170e7f04d42c2423652c3/rapidfuzz-3.13.0-cp312-cp312-win32.whl", hash = "sha256:4671ee300d1818d7bdfd8fa0608580d7778ba701817216f0c17fb29e6b972514", size = 1823915, upload-time = "2025-04-03T20:36:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/03/25/5ee7ab6841ca668567d0897905eebc79c76f6297b73bf05957be887e9c74/rapidfuzz-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e2065f68fb1d0bf65adc289c1bdc45ba7e464e406b319d67bb54441a1b9da9e", size = 1616985, upload-time = "2025-04-03T20:36:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/5e/3f0fb88db396cb692aefd631e4805854e02120a2382723b90dcae720bcc6/rapidfuzz-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:65cc97c2fc2c2fe23586599686f3b1ceeedeca8e598cfcc1b7e56dc8ca7e2aa7", size = 860116, upload-time = "2025-04-03T20:36:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282, upload-time = "2025-04-03T20:36:46.149Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274, upload-time = "2025-04-03T20:36:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854, upload-time = "2025-04-03T20:36:50.294Z" }, + { url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962, upload-time = "2025-04-03T20:36:52.421Z" }, + { url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016, upload-time = "2025-04-03T20:36:54.639Z" }, + { url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414, upload-time = "2025-04-03T20:36:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179, upload-time = "2025-04-03T20:36:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856, upload-time = "2025-04-03T20:37:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107, upload-time = "2025-04-03T20:37:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192, upload-time = "2025-04-03T20:37:06.905Z" }, + { url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876, upload-time = "2025-04-03T20:37:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077, upload-time = "2025-04-03T20:37:11.929Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066, upload-time = "2025-04-03T20:37:14.425Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100, upload-time = "2025-04-03T20:37:16.611Z" }, + { url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976, upload-time = "2025-04-03T20:37:19.336Z" }, +] + [[package]] name = "readchar" version = "4.2.1" @@ -1453,6 +1571,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995, upload-time = "2025-03-08T10:55:32.662Z" }, ] +[[package]] +name = "suffix-trees" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/2505b47661e03a29485009c495f2ba2cc7920557a9eea3b8a38c0932cce1/suffix-trees-0.3.0.tar.gz", hash = "sha256:7a80200d085d537860c032e6a5b845ae6112df6fe118162ccd9554c63a9843e9", size = 4982, upload-time = "2020-04-10T16:54:20.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/18/f252d1af9ff13a5bc94a4691450794603d4b6dfe99b257e1fb672082afe7/suffix_trees-0.3.0-py3-none-any.whl", hash = "sha256:c87d6af38366531e111401e6cdb0196e31e81760f236d315b9cc20e0e1cf2a29", size = 5424, upload-time = "2020-04-10T16:54:19.304Z" }, +] + [[package]] name = "trio" version = "0.30.0"