diff --git a/config.py b/config.py index 40c004b31..155b76c18 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ # In this file, you can set the configurations of the app. from constants import DEBUG, LLM_MODEL, OPENAI +from src.webdrivers.browser_type import BrowserType #config related to logging must have prefix LOG_ LOG_LEVEL = DEBUG @@ -12,6 +13,8 @@ JOB_APPLICATIONS_DIR = "job_applications" JOB_SUITABILITY_SCORE = 7 +BROWSER_TYPE_CONFIG = BrowserType.CHROME + JOB_MAX_APPLICATIONS = 5 JOB_MIN_APPLICATIONS = 1 diff --git a/main.py b/main.py index 6c0d98e3d..045ab5726 100644 --- a/main.py +++ b/main.py @@ -4,15 +4,12 @@ from pathlib import Path import yaml import click -from selenium import webdriver -from selenium.webdriver.chrome.service import Service as ChromeService -from webdriver_manager.chrome import ChromeDriverManager from selenium.common.exceptions import WebDriverException from lib_resume_builder_AIHawk import Resume, FacadeManager, ResumeGenerator, StyleManager from typing import Optional from constants import PLAIN_TEXT_RESUME_YAML, SECRETS_YAML, WORK_PREFERENCES_YAML -from src.utils.chrome_utils import chrome_browser_options +from src.webdrivers.browser_factory import BrowserFactory from src.job_application_profile import JobApplicationProfile from src.logging import logger @@ -155,14 +152,6 @@ def file_paths_to_dict(resume_file: Path | None, plain_text_resume_file: Path) - return result -def init_browser() -> webdriver.Chrome: - try: - options = chrome_browser_options() - service = ChromeService(ChromeDriverManager().install()) - return webdriver.Chrome(service=service, options=options) - except Exception as e: - raise RuntimeError(f"Failed to initialize browser: {str(e)}") - def create_and_run_bot(parameters, llm_api_key): try: style_manager = StyleManager() @@ -178,7 +167,7 @@ def create_and_run_bot(parameters, llm_api_key): job_application_profile_object = JobApplicationProfile(plain_text_resume) - browser = init_browser() + browser = BrowserFactory.get_browser() login_component = get_authenticator(driver=browser, platform='linkedin') apply_component = AIHawkJobManager(browser) gpt_answerer_component = GPTAnswerer(parameters, llm_api_key) @@ -213,7 +202,7 @@ def main(collect: bool = False, resume: Optional[Path] = None): parameters['uploads'] = FileManager.file_paths_to_dict(resume, plain_text_resume_file) parameters['outputFileDirectory'] = output_folder parameters['collectMode'] = collect - + create_and_run_bot(parameters, llm_api_key) except ConfigError as ce: logger.error(f"Configuration error: {str(ce)}") diff --git a/src/ai_hawk/job_manager.py b/src/ai_hawk/job_manager.py index c9a93eb6a..13d68c1ee 100644 --- a/src/ai_hawk/job_manager.py +++ b/src/ai_hawk/job_manager.py @@ -6,6 +6,7 @@ from pathlib import Path from datetime import datetime + from inputimeout import inputimeout, TimeoutOccurred from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.common.by import By diff --git a/src/utils/chrome_utils.py b/src/utils/chrome_utils.py deleted file mode 100644 index 3d3a84ac3..000000000 --- a/src/utils/chrome_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -from selenium import webdriver -from src.logging import logger - -chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") - -def ensure_chrome_profile(): - logger.debug(f"Ensuring Chrome profile exists at path: {chromeProfilePath}") - profile_dir = os.path.dirname(chromeProfilePath) - if not os.path.exists(profile_dir): - os.makedirs(profile_dir) - logger.debug(f"Created directory for Chrome profile: {profile_dir}") - if not os.path.exists(chromeProfilePath): - os.makedirs(chromeProfilePath) - logger.debug(f"Created Chrome profile directory: {chromeProfilePath}") - return chromeProfilePath - -def chrome_browser_options(): - logger.debug("Setting Chrome browser options") - ensure_chrome_profile() - options = webdriver.ChromeOptions() - options.add_argument("--start-maximized") - options.add_argument("--no-sandbox") - options.add_argument("--disable-dev-shm-usage") - options.add_argument("--ignore-certificate-errors") - options.add_argument("--disable-extensions") - options.add_argument("--disable-gpu") - options.add_argument("window-size=1200x800") - options.add_argument("--disable-background-timer-throttling") - options.add_argument("--disable-backgrounding-occluded-windows") - options.add_argument("--disable-translate") - options.add_argument("--disable-popup-blocking") - options.add_argument("--no-first-run") - options.add_argument("--no-default-browser-check") - options.add_argument("--disable-logging") - options.add_argument("--disable-autofill") - options.add_argument("--disable-plugins") - options.add_argument("--disable-animations") - options.add_argument("--disable-cache") - options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) - - prefs = { - "profile.default_content_setting_values.images": 2, - "profile.managed_default_content_settings.stylesheets": 2, - } - options.add_experimental_option("prefs", prefs) - - if len(chromeProfilePath) > 0: - initial_path = os.path.dirname(chromeProfilePath) - profile_dir = os.path.basename(chromeProfilePath) - options.add_argument('--user-data-dir=' + initial_path) - options.add_argument("--profile-directory=" + profile_dir) - logger.debug(f"Using Chrome profile directory: {chromeProfilePath}") - else: - options.add_argument("--incognito") - logger.debug("Using Chrome in incognito mode") - - return options - - diff --git a/src/webdrivers/base_browser.py b/src/webdrivers/base_browser.py new file mode 100644 index 000000000..fd9589bc9 --- /dev/null +++ b/src/webdrivers/base_browser.py @@ -0,0 +1,67 @@ +import os + +from abc import ABC, abstractmethod +from loguru import logger + + +class BrowserProfile: + """Manages browser profile creation and configuration""" + def __init__(self, browser_type: str): + self.browser_type: str = browser_type.lower() + self.profile_path = os.path.join( + os.getcwd(), + f"{self.browser_type}_profile", + "linkedin_profile" + ) + + def ensure_profile_exists(self) -> str: + """ + Ensures the browser profile directory exists + Returns: Path to the profile directory + """ + logger.debug(f"Ensuring {self.browser_type} profile exists at path: {self.profile_path}") + profile_dir = os.path.dirname(self.profile_path) + + if not os.path.exists(profile_dir): + os.makedirs(profile_dir) + logger.debug(f"Created directory for {self.browser_type} profile: {profile_dir}") + + if not os.path.exists(self.profile_path): + os.makedirs(self.profile_path) + logger.debug(f"Created {self.browser_type} profile directory: {self.profile_path}") + + return self.profile_path + +class Browser(ABC): + """Abstract base class for browser implementations""" + def __init__(self): + self.profile = BrowserProfile(self.browser_type) + + @property + def browser_type(self) -> str: + """Return the browser type identifier""" + return self.__class__.browser_type + + @abstractmethod + def create_options(self): + """Create and return browser-specific options""" + + @abstractmethod + def create_service(self): + """Create and return browser-specific service""" + + def create_driver(self): + """Create and return browser-specific WebDriver instance""" + try: + options = self.create_options() + service = self.create_service() + driver = self._create_driver_instance(service, options) + logger.debug(f"{self.browser_type} WebDriver instance created successfully") + return driver + except Exception as e: + logger.error(f"Failed to create {self.browser_type} WebDriver: {e}") + raise RuntimeError(f"Failed to initialize {self.browser_type} browser: {str(e)}") + + @abstractmethod + def _create_driver_instance(self, service, options): + """Create the specific driver instance""" diff --git a/src/webdrivers/browser_factory.py b/src/webdrivers/browser_factory.py new file mode 100644 index 000000000..2e1c8e801 --- /dev/null +++ b/src/webdrivers/browser_factory.py @@ -0,0 +1,43 @@ +from typing import Union + +from selenium import webdriver +from loguru import logger + +from config import BROWSER_TYPE_CONFIG +from src.webdrivers.browser_type import BrowserType + + +class BrowserFactory: + """Factory class for creating browser instances""" + _browser_type: BrowserType = BROWSER_TYPE_CONFIG + @classmethod + def get_browser_type(cls) -> BrowserType: + """Get current browser type""" + return cls._browser_type + + @classmethod + def set_browser_type(cls, browser_type: BrowserType) -> None: + """Set browser type""" + # safety check additional to type check. + if browser_type not in BrowserType: + raise ValueError(f"Unsupported browser type: {browser_type}") + cls._browser_type = browser_type + logger.debug(f"Browser type set to: {browser_type}") + + @classmethod + def get_browser(cls) -> Union[webdriver.Chrome, webdriver.Firefox]: + """ + Create and return a WebDriver instance for the specified browser type + Args: + browser_type: BrowserType enum value + Returns: + WebDriver instance + Raises: + RuntimeError: If browser initialization fails + """ + if cls._browser_type not in BrowserType: + raise ValueError("Unsupported browser type: {cls._browser_type}") + + browser = cls._browser_type.value() + + return browser.create_driver() diff --git a/src/webdrivers/browser_type.py b/src/webdrivers/browser_type.py new file mode 100644 index 000000000..20b85b87b --- /dev/null +++ b/src/webdrivers/browser_type.py @@ -0,0 +1,10 @@ +from enum import Enum + +from src.webdrivers.chrome import Chrome +from src.webdrivers.firefox import Firefox + + +class BrowserType(Enum): + """Enum for supported browser types""" + CHROME = Chrome + FIREFOX = Firefox diff --git a/src/webdrivers/chrome.py b/src/webdrivers/chrome.py new file mode 100644 index 000000000..29f72b374 --- /dev/null +++ b/src/webdrivers/chrome.py @@ -0,0 +1,59 @@ +import os + +from loguru import logger +from selenium import webdriver +from selenium.webdriver.chrome.service import Service as ChromeService +from webdriver_manager.chrome import ChromeDriverManager + +from src.webdrivers.base_browser import Browser + + +class Chrome(Browser): + """Chrome browser implementation""" + browser_type: str = "chrome" + + def create_options(self) -> webdriver.ChromeOptions: + """Create Chrome-specific options""" + self.profile.ensure_profile_exists() + options = webdriver.ChromeOptions() + + chrome_arguments = [ + "--start-maximized", "--no-sandbox", "--disable-dev-shm-usage", + "--ignore-certificate-errors", "--disable-extensions", "--disable-gpu", + "window-size=1200x800", "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", "--disable-translate", + "--disable-popup-blocking", "--no-first-run", "--no-default-browser-check", + "--disable-logging", "--disable-autofill", "--disable-plugins", + "--disable-animations", "--disable-cache" + ] + + for arg in chrome_arguments: + options.add_argument(arg) + + options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) + + prefs = { + "profile.default_content_setting_values.images": 2, + "profile.managed_default_content_settings.stylesheets": 2, + } + options.add_experimental_option("prefs", prefs) + + if self.profile.profile_path: + initial_path = os.path.dirname(self.profile.profile_path) + profile_dir = os.path.basename(self.profile.profile_path) + options.add_argument('--user-data-dir=' + initial_path) + options.add_argument("--profile-directory=" + profile_dir) + logger.debug(f"Using Chrome profile directory: {self.profile.profile_path}") + else: + options.add_argument("--incognito") + logger.debug("Using Chrome in incognito mode") + + return options + + def create_service(self) -> ChromeService: + """Create Chrome-specific service""" + return ChromeService(ChromeDriverManager().install()) + + def _create_driver_instance(self, service, options): + """Create Chrome WebDriver instance""" + return webdriver.Chrome(service=service, options=options) diff --git a/src/webdrivers/firefox.py b/src/webdrivers/firefox.py new file mode 100644 index 000000000..6e3cb60e7 --- /dev/null +++ b/src/webdrivers/firefox.py @@ -0,0 +1,53 @@ +from loguru import logger +from selenium import webdriver +from selenium.webdriver.firefox.service import Service as FirefoxService +from webdriver_manager.firefox import GeckoDriverManager + +from src.webdrivers.base_browser import Browser + + +class Firefox(Browser): + """Firefox browser implementation""" + browser_type: str = "firefox" + + def create_options(self) -> webdriver.FirefoxOptions: + """Create Firefox-specific options""" + self.profile.ensure_profile_exists() + options = webdriver.FirefoxOptions() + + firefox_arguments = [ + "--start-maximized", "--no-sandbox", "--disable-dev-shm-usage", + "--ignore-certificate-errors", "--disable-extensions", "--disable-gpu", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", "--disable-translate", + "--disable-popup-blocking", "--no-first-run", "--no-default-browser-check", + "--disable-logging", "--disable-autofill", "--disable-plugins", + "--disable-animations", "--disable-cache" + ] + + for arg in firefox_arguments: + options.add_argument(arg) + + prefs = { + "permissions.default.image": 2, + "permissions.default.stylesheet": 2, + } + for key, value in prefs.items(): + options.set_preference(key, value) + + if self.profile.profile_path: + options.set_preference("profile", self.profile.profile_path) + logger.debug(f"Using Firefox profile directory: {self.profile.profile_path}") + else: + options.set_preference("browser.privatebrowsing.autostart", True) + logger.debug("Using Firefox in private browsing mode") + + return options + + def create_service(self) -> FirefoxService: + """Create Firefox-specific service""" + return FirefoxService(GeckoDriverManager().install()) + + def _create_driver_instance(self, service, options): + """Create Firefox WebDriver instance""" + return webdriver.Firefox(service=service, options=options) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2ca828b44..1cb09e6c6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,10 @@ from unittest import mock from selenium.webdriver.remote.webelement import WebElement from src.utils.browser_utils import is_scrollable, scroll_slow -from src.utils.chrome_utils import chrome_browser_options, ensure_chrome_profile +from src.webdrivers.base_browser import BrowserProfile +from src.webdrivers.browser_type import BrowserType +from src.webdrivers.chrome import Chrome +from src.webdrivers.firefox import Firefox # Mocking logging to avoid actual file writing @pytest.fixture(autouse=True) @@ -13,15 +16,17 @@ def mock_logger(mocker): mocker.patch("src.logging.logger") # Test ensure_chrome_profile function -def test_ensure_chrome_profile(mocker): +def test_ensure_browser_profiles(mocker): mocker.patch("os.path.exists", return_value=False) # Pretend directory doesn't exist mocker.patch("os.makedirs") # Mock making directories # Call the function - profile_path = ensure_chrome_profile() + chrome_profile_path = BrowserProfile(BrowserType.CHROME.name).ensure_profile_exists() + firefox_profile_path = BrowserProfile(BrowserType.FIREFOX.name).ensure_profile_exists() # Verify that os.makedirs was called twice to create the directory - assert profile_path.endswith("linkedin_profile") + assert chrome_profile_path.endswith("linkedin_profile") + assert firefox_profile_path.endswith("linkedin_profile") assert os.path.exists.called assert os.makedirs.called @@ -70,8 +75,7 @@ def test_scroll_slow_element_not_scrollable(mocker): # Test chrome_browser_options function def test_chrome_browser_options(mocker): - mocker.patch("src.utils.chrome_utils.ensure_chrome_profile") - mocker.patch("os.path.dirname", return_value="/mocked/path") + mocker.patch("os.path.dirname", return_value="mocked/path") mocker.patch("os.path.basename", return_value="profile_directory") mock_options = mocker.Mock() @@ -79,8 +83,24 @@ def test_chrome_browser_options(mocker): mocker.patch("selenium.webdriver.ChromeOptions", return_value=mock_options) # Call the function - options = chrome_browser_options() + options = Chrome().create_options() # Ensure options were set assert mock_options.add_argument.called assert options == mock_options + +# Test firefox_browser_options function +def test_firefox_browser_options(mocker): + mock_options = mocker.Mock() + mock_profile = mocker.Mock(spec=BrowserProfile) + mock_profile.profile_path = "/mocked/path" + + mocker.patch("selenium.webdriver.FirefoxOptions", return_value=mock_options) + + # Call the function + options = Firefox().create_options() + + # Ensure options were set + assert mock_options.add_argument.called + assert mock_options.set_preference.called + assert options == mock_options