This commit is contained in:
queukat 2024-09-07 14:15:32 +03:00
parent 35cc5d3bde
commit b6ceeb44ac
8 changed files with 406 additions and 345 deletions

View file

@ -3,89 +3,28 @@ import os
import re import re
import textwrap import textwrap
import time import time
from abc import ABC, abstractmethod
from typing import Dict, List, Union
from datetime import datetime from datetime import datetime
from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
import httpx import httpx
from Levenshtein import distance from Levenshtein import distance
from dotenv import load_dotenv from dotenv import load_dotenv
from httpx import HTTPStatusError
from langchain_core.messages.ai import AIMessage from langchain_core.messages.ai import AIMessage
from langchain_core.output_parsers import StrOutputParser from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompt_values import StringPromptValue from langchain_core.prompt_values import StringPromptValue
from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from Levenshtein import distance
import src.strings as strings import src.strings as strings
from src.utils import logger from src.utils import logger
load_dotenv() load_dotenv()
class AIModel(ABC):
@abstractmethod
def invoke(self, prompt: str) -> str:
pass
class OpenAIModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_openai import ChatOpenAI
self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key,
temperature=0.4, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
print("invoke in openai")
response = self.model.invoke(prompt)
return response
class ClaudeModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_anthropic import ChatAnthropic
self.model = ChatAnthropic(model=llm_model, api_key=api_key,
temperature=0.4, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
response = self.model.invoke(prompt)
return response
class OllamaModel(AIModel):
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
from langchain_ollama import ChatOllama
self.model = ChatOllama(model=llm_model, base_url=llm_api_url)
def invoke(self, prompt: str) -> str:
response = self.model.invoke(prompt)
return response
class AIAdapter:
def __init__(self, config: dict, api_key: str):
self.model = self._create_model(config, api_key)
def _create_model(self, config: dict, api_key: str) -> AIModel:
llm_model_type = config['llm_model_type']
llm_model = config['llm_model']
llm_api_url = config['llm_api_url']
print('Using {0} with {1} from {2}'.format(llm_model_type, llm_model, llm_api_url))
if llm_model_type == "openai":
return OpenAIModel(api_key, llm_model, llm_api_url)
elif llm_model_type == "claude":
return ClaudeModel(api_key, llm_model, llm_api_url)
elif llm_model_type == "ollama":
return OllamaModel(api_key, llm_model, llm_api_url)
else:
raise ValueError(f"Unsupported model type: {model_type}")
def invoke(self, prompt: str) -> str:
return self.model.invoke(prompt)
class LLMLogger: class LLMLogger:
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): def __init__(self, llm: ChatOpenAI):
logger.debug("Initializing LLMLogger with LLM: %s", llm) logger.debug("Initializing LLMLogger with LLM: %s", llm)
self.llm = llm self.llm = llm
logger.debug("LLMLogger successfully initialized with LLM: %s", llm) logger.debug("LLMLogger successfully initialized with LLM: %s", llm)
@ -108,11 +47,10 @@ class LLMLogger:
prompts = prompts.text prompts = prompts.text
logger.debug("Prompts converted to text: %s", prompts) logger.debug("Prompts converted to text: %s", prompts)
elif isinstance(prompts, Dict): elif isinstance(prompts, Dict):
# Convert prompts to a dictionary if they are not in the expected format
logger.debug("Prompts are of type Dict") logger.debug("Prompts are of type Dict")
try: try:
prompts = { prompts = {
f"prompt_{i+1}": prompt.content f"prompt_{i + 1}": prompt.content
for i, prompt in enumerate(prompts.messages) for i, prompt in enumerate(prompts.messages)
} }
logger.debug("Prompts converted to dictionary: %s", prompts) logger.debug("Prompts converted to dictionary: %s", prompts)
@ -123,7 +61,7 @@ class LLMLogger:
logger.debug("Prompts are of unknown type, attempting default conversion") logger.debug("Prompts are of unknown type, attempting default conversion")
try: try:
prompts = { prompts = {
f"prompt_{i+1}": prompt.content f"prompt_{i + 1}": prompt.content
for i, prompt in enumerate(prompts.messages) for i, prompt in enumerate(prompts.messages)
} }
logger.debug("Prompts converted to dictionary using default method: %s", prompts) logger.debug("Prompts converted to dictionary using default method: %s", prompts)
@ -137,7 +75,7 @@ class LLMLogger:
except Exception as e: except Exception as e:
logger.error("Error obtaining current time: %s", str(e)) logger.error("Error obtaining current time: %s", str(e))
raise raise
# Extract token usage details from the response
try: try:
token_usage = parsed_reply["usage_metadata"] token_usage = parsed_reply["usage_metadata"]
output_tokens = token_usage["output_tokens"] output_tokens = token_usage["output_tokens"]
@ -147,14 +85,14 @@ class LLMLogger:
except KeyError as e: except KeyError as e:
logger.error("KeyError in parsed_reply structure: %s", str(e)) logger.error("KeyError in parsed_reply structure: %s", str(e))
raise raise
# Extract model details from the response
try: try:
model_name = parsed_reply["response_metadata"]["model_name"] model_name = parsed_reply["response_metadata"]["model_name"]
logger.debug("Model name: %s", model_name) logger.debug("Model name: %s", model_name)
except KeyError as e: except KeyError as e:
logger.error("KeyError in response_metadata: %s", str(e)) logger.error("KeyError in response_metadata: %s", str(e))
raise raise
# Calculate the total cost of the API call
try: try:
prompt_price_per_token = 0.00000015 prompt_price_per_token = 0.00000015
completion_price_per_token = 0.0000006 completion_price_per_token = 0.0000006
@ -169,7 +107,7 @@ class LLMLogger:
"model": model_name, "model": model_name,
"time": current_time, "time": current_time,
"prompts": prompts, "prompts": prompts,
"replies": parsed_reply["content"], # Response content "replies": parsed_reply["content"],
"total_tokens": total_tokens, "total_tokens": total_tokens,
"input_tokens": input_tokens, "input_tokens": input_tokens,
"output_tokens": output_tokens, "output_tokens": output_tokens,
@ -179,7 +117,7 @@ class LLMLogger:
except KeyError as e: except KeyError as e:
logger.error("Error creating log entry: missing key %s in parsed_reply", str(e)) logger.error("Error creating log entry: missing key %s in parsed_reply", str(e))
raise raise
# Write the log entry to the log file in JSON format
try: try:
with open(calls_log, "a", encoding="utf-8") as f: with open(calls_log, "a", encoding="utf-8") as f:
json_string = json.dumps(log_entry, ensure_ascii=False, indent=4) json_string = json.dumps(log_entry, ensure_ascii=False, indent=4)
@ -191,13 +129,12 @@ class LLMLogger:
class LoggerChatModel: class LoggerChatModel:
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]): def __init__(self, llm: ChatOpenAI):
logger.debug("Initializing LoggerChatModel with LLM: %s", llm) logger.debug("Initializing LoggerChatModel with LLM: %s", llm)
self.llm = llm self.llm = llm
logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm) logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm)
def __call__(self, messages: List[Dict[str, str]]) -> str: def __call__(self, messages: List[Dict[str, str]]) -> str:
# Call the LLM with the provided messages and log the response.
logger.debug("Entering __call__ method with messages: %s", messages) logger.debug("Entering __call__ method with messages: %s", messages)
while True: while True:
try: try:
@ -221,18 +158,25 @@ class LoggerChatModel:
if retry_after: if retry_after:
wait_time = int(retry_after) wait_time = int(retry_after)
logger.warning("Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...", wait_time) logger.warning(
"Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...",
wait_time)
time.sleep(wait_time) time.sleep(wait_time)
elif retry_after_ms: elif retry_after_ms:
wait_time = int(retry_after_ms) / 1000.0 wait_time = int(retry_after_ms) / 1000.0
logger.warning("Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...", wait_time) logger.warning(
"Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...",
wait_time)
time.sleep(wait_time) time.sleep(wait_time)
else: else:
wait_time = 30 # Время ожидания по умолчанию wait_time = 30
logger.warning("'retry-after' header not found. Waiting for %d seconds before retrying (default)...", wait_time) logger.warning(
"'retry-after' header not found. Waiting for %d seconds before retrying (default)...",
wait_time)
time.sleep(wait_time) time.sleep(wait_time)
else: else:
logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying", e.response.status_code) logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying",
e.response.status_code)
time.sleep(30) time.sleep(30)
except Exception as e: except Exception as e:
@ -242,8 +186,6 @@ class LoggerChatModel:
continue continue
def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]: def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]:
# Parse the LLM result into a structured format.
logger.debug("Parsing LLM result: %s", llmresult) logger.debug("Parsing LLM result: %s", llmresult)
try: try:
@ -280,11 +222,11 @@ class LoggerChatModel:
raise raise
class GPTAnswerer: class GPTAnswerer:
def __init__(self, config, llm_api_key): def __init__(self, openai_api_key):
self.ai_adapter = AIAdapter(config, llm_api_key) self.llm_cheap = LoggerChatModel(
self.llm_cheap = LoggerChatModel(self.ai_adapter) ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4)
)
logger.debug("GPTAnswerer initialized with API key") logger.debug("GPTAnswerer initialized with API key")
@property @property
@ -309,7 +251,6 @@ class GPTAnswerer:
@staticmethod @staticmethod
def _preprocess_template_string(template: str) -> str: def _preprocess_template_string(template: str) -> str:
# Preprocess a template string to remove unnecessary indentation.
logger.debug("Preprocessing template string") logger.debug("Preprocessing template string")
return textwrap.dedent(template) return textwrap.dedent(template)
@ -336,14 +277,13 @@ class GPTAnswerer:
output = chain.invoke({"text": text}) output = chain.invoke({"text": text})
logger.debug("Summary generated: %s", output) logger.debug("Summary generated: %s", output)
return output return output
def _create_chain(self, template: str): def _create_chain(self, template: str):
logger.debug("Creating chain with template: %s", template) logger.debug("Creating chain with template: %s", template)
prompt = ChatPromptTemplate.from_template(template) prompt = ChatPromptTemplate.from_template(template)
return prompt | self.llm_cheap | StrOutputParser() return prompt | self.llm_cheap | StrOutputParser()
def answer_question_textual_wide_range(self, question: str) -> str: def answer_question_textual_wide_range(self, question: str) -> str:
# Define chains for each section of the resume
logger.debug("Answering textual question: %s", question) logger.debug("Answering textual question: %s", question)
chains = { chains = {
"personal_information": self._create_chain(strings.personal_information_template), "personal_information": self._create_chain(strings.personal_information_template),
@ -452,17 +392,14 @@ class GPTAnswerer:
chain = prompt | self.llm_cheap | StrOutputParser() chain = prompt | self.llm_cheap | StrOutputParser()
output = chain.invoke({"question": question}) output = chain.invoke({"question": question})
logger.debug("Section determined from question: %s", output) logger.debug("Section determined from question: %s", output)
match = re.search(r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education Details|Experience Details|Projects|Availability|Salary Expectations|Certifications|Languages|Interests|Cover letter)", output, re.IGNORECASE) section_name = output.lower().replace(" ", "_")
if not match:
raise ValueError("Could not extract section name from the response.")
section_name = match.group(1).lower().replace(" ", "_")
if section_name == "cover_letter": if section_name == "cover_letter":
chain = chains.get(section_name) chain = chains.get(section_name)
output = chain.invoke({"resume": self.resume, "job_description": self.job_description}) output = chain.invoke({"resume": self.resume, "job_description": self.job_description})
logger.debug("Cover letter generated: %s", output) logger.debug("Cover letter generated: %s", output)
return output return output
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, None) resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name,
None)
if resume_section is None: if resume_section is None:
logger.error("Section '%s' not found in either resume or job_application_profile.", section_name) logger.error("Section '%s' not found in either resume or job_application_profile.", section_name)
raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.") raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.")
@ -479,7 +416,9 @@ class GPTAnswerer:
func_template = self._preprocess_template_string(strings.numeric_question_template) func_template = self._preprocess_template_string(strings.numeric_question_template)
prompt = ChatPromptTemplate.from_template(func_template) prompt = ChatPromptTemplate.from_template(func_template)
chain = prompt | self.llm_cheap | StrOutputParser() chain = prompt | self.llm_cheap | StrOutputParser()
output_str = chain.invoke({"resume_educations": self.resume.education_details,"resume_jobs": self.resume.experience_details,"resume_projects": self.resume.projects , "question": question}) output_str = chain.invoke(
{"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details,
"resume_projects": self.resume.projects, "question": question})
logger.debug("Raw output for numeric question: %s", output_str) logger.debug("Raw output for numeric question: %s", output_str)
try: try:
output = self.extract_number_from_string(output_str) output = self.extract_number_from_string(output_str)
@ -511,10 +450,12 @@ class GPTAnswerer:
return best_option return best_option
def resume_or_cover(self, phrase: str) -> str: def resume_or_cover(self, phrase: str) -> str:
# Define the prompt template
logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase) logger.debug("Determining if phrase refers to resume or cover letter: %s", phrase)
prompt_template = """ prompt_template = """
Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. If the phrase contains only the word 'upload', consider it as 'cover'. Do not provide any additional information or explanations. Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter.
If the phrase contains only one word 'upload', consider it as 'cover'.
If the phrase contains 'upload resume', consider it as 'resume'.
Do not provide any additional information or explanations.
phrase: {phrase} phrase: {phrase}
""" """

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List
import yaml import yaml
from src.utils import logger from src.utils import logger
@ -13,6 +13,7 @@ class SelfIdentification:
disability: str disability: str
ethnicity: str ethnicity: str
@dataclass @dataclass
class LegalAuthorization: class LegalAuthorization:
eu_work_authorization: str eu_work_authorization: str
@ -24,6 +25,7 @@ class LegalAuthorization:
legally_allowed_to_work_in_eu: str legally_allowed_to_work_in_eu: str
requires_eu_sponsorship: str requires_eu_sponsorship: str
@dataclass @dataclass
class WorkPreferences: class WorkPreferences:
remote_work: str remote_work: str
@ -33,14 +35,17 @@ class WorkPreferences:
willing_to_undergo_drug_tests: str willing_to_undergo_drug_tests: str
willing_to_undergo_background_checks: str willing_to_undergo_background_checks: str
@dataclass @dataclass
class Availability: class Availability:
notice_period: str notice_period: str
@dataclass @dataclass
class SalaryExpectations: class SalaryExpectations:
salary_range_usd: str salary_range_usd: str
@dataclass @dataclass
class JobApplicationProfile: class JobApplicationProfile:
self_identification: SelfIdentification self_identification: SelfIdentification
@ -159,6 +164,7 @@ class JobApplicationProfile:
def __str__(self): def __str__(self):
logger.debug("Generating string representation of JobApplicationProfile") logger.debug("Generating string representation of JobApplicationProfile")
def format_dataclass(obj): def format_dataclass(obj):
return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values()) return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values())

View file

@ -1,15 +1,16 @@
import random import random
import time import time
from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from src.utils import logger from src.utils import logger
class LinkedInAuthenticator: class LinkedInAuthenticator:
def __init__(self, driver=None): def __init__(self, driver=None):
self.driver = driver self.driver = driver
self.email = "" self.email = ""
@ -107,7 +108,6 @@ class LinkedInAuthenticator:
buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger') buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger')
logger.debug("Found %d 'Start a post' buttons", len(buttons)) logger.debug("Found %d 'Start a post' buttons", len(buttons))
# Выведем текст всех найденных кнопок в лог для диагностики
for i, button in enumerate(buttons): for i, button in enumerate(buttons):
logger.debug("Button %d text: %s", i + 1, button.text.strip()) logger.debug("Button %d text: %s", i + 1, button.text.strip())
@ -115,7 +115,6 @@ class LinkedInAuthenticator:
logger.info("Found 'Start a post' button indicating user is logged in.") logger.info("Found 'Start a post' button indicating user is logged in.")
return True return True
# Альтернативная проверка авторизации по наличию изображения профиля
profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]") profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]")
if profile_img_elements: if profile_img_elements:
logger.info("Profile image found. Assuming user is logged in.") logger.info("Profile image found. Assuming user is logged in.")

View file

@ -23,6 +23,7 @@ class LinkedInBotState:
raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.") raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.")
logger.debug("State validation passed") logger.debug("State validation passed")
class LinkedInBotFacade: class LinkedInBotFacade:
def __init__(self, login_component, apply_component): def __init__(self, login_component, apply_component):
logger.debug("Initializing LinkedInBotFacade") logger.debug("Initializing LinkedInBotFacade")

View file

@ -3,26 +3,28 @@ import json
import os import os
import random import random
import re import re
import tempfile
import time import time
import traceback import traceback
from datetime import date
from typing import List, Optional, Any, Tuple from typing import List, Optional, Any, Tuple
from httpx import HTTPStatusError from httpx import HTTPStatusError
from openai import RateLimitError
from reportlab.lib.pagesizes import letter from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver import ActionChains
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait from selenium.webdriver.support.ui import Select, WebDriverWait
from selenium.webdriver import ActionChains
import src.utils as utils import src.utils as utils
from src.utils import logger from src.utils import logger
class LinkedInEasyApplier: class LinkedInEasyApplier:
def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], gpt_answerer: Any, resume_generator_manager): def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]],
gpt_answerer: Any, resume_generator_manager):
logger.debug("Initializing LinkedInEasyApplier") logger.debug("Initializing LinkedInEasyApplier")
if resume_dir is None or not os.path.exists(resume_dir): if resume_dir is None or not os.path.exists(resume_dir):
resume_dir = None resume_dir = None
@ -56,11 +58,28 @@ class LinkedInEasyApplier:
logger.error("Error loading questions data from JSON file: %s", tb_str) logger.error("Error loading questions data from JSON file: %s", tb_str)
raise Exception(f"Error loading questions data from JSON file: \nTraceback:\n{tb_str}") raise Exception(f"Error loading questions data from JSON file: \nTraceback:\n{tb_str}")
def check_for_premium_redirect(self, job: Any, max_attempts=3):
"""Проверяет, был ли выполнен редирект на страницу LinkedIn Premium.
В случае редиректа возвращает пользователя на исходную страницу вакансии."""
current_url = self.driver.current_url
attempts = 0
while "linkedin.com/premium" in current_url and attempts < max_attempts:
logger.warning("Redirected to LinkedIn Premium page. Attempting to return to job page.")
attempts += 1
self.driver.get(job.link)
time.sleep(2)
current_url = self.driver.current_url
if "linkedin.com/premium" in current_url:
logger.error("Failed to return to job page after %d attempts. Cannot apply for the job.", max_attempts)
raise Exception(
f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.")
def job_apply(self, job: Any): def job_apply(self, job: Any):
logger.debug("Starting job application for job: %s", job) logger.debug("Starting job application for job: %s", job)
# Открываем страницу с вакансией
try: try:
self.driver.get(job.link) self.driver.get(job.link)
logger.debug("Navigated to job link: %s", job.link) logger.debug("Navigated to job link: %s", job.link)
@ -68,62 +87,60 @@ class LinkedInEasyApplier:
logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e)) logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e))
raise raise
# Добавляем небольшую паузу для загрузки страницы
time.sleep(random.uniform(3, 5)) time.sleep(random.uniform(3, 5))
self.check_for_premium_redirect(job)
try: try:
# Поиск кнопки 'Easy Apply'
logger.debug("Searching for 'Easy Apply' button on job page")
easy_apply_button = self._find_easy_apply_button()
# Получаем описание вакансии self.driver.execute_script("document.activeElement.blur();")
logger.debug("Focus removed from the active element")
self.check_for_premium_redirect(job)
easy_apply_button = self._find_easy_apply_button(job)
self.check_for_premium_redirect(job)
logger.debug("Retrieving job description") logger.debug("Retrieving job description")
job_description = self._get_job_description() job_description = self._get_job_description()
job.set_job_description(job_description) job.set_job_description(job_description)
logger.debug("Job description set: %s", job_description[:100]) # Логируем только первые 100 символов logger.debug("Job description set: %s", job_description[:100])
# Получаем ссылку на рекрутера (если есть)
logger.debug("Retrieving recruiter link") logger.debug("Retrieving recruiter link")
recruiter_link = self._get_job_recruiter() recruiter_link = self._get_job_recruiter()
job.set_recruiter_link(recruiter_link) job.set_recruiter_link(recruiter_link)
logger.debug("Recruiter link set: %s", recruiter_link) logger.debug("Recruiter link set: %s", recruiter_link)
# Действие: нажимаем на кнопку 'Easy Apply'
logger.debug("Attempting to click 'Easy Apply' button") logger.debug("Attempting to click 'Easy Apply' button")
actions = ActionChains(self.driver) actions = ActionChains(self.driver)
actions.move_to_element(easy_apply_button).click().perform() actions.move_to_element(easy_apply_button).click().perform()
logger.debug("'Easy Apply' button clicked successfully") logger.debug("'Easy Apply' button clicked successfully")
# Передача информации о работе для дальнейшей обработки
logger.debug("Passing job information to GPT Answerer") logger.debug("Passing job information to GPT Answerer")
self.gpt_answerer.set_job(job) self.gpt_answerer.set_job(job)
# Заполнение формы подачи заявки
logger.debug("Filling out application form") logger.debug("Filling out application form")
self._fill_application_form(job) self._fill_application_form(job)
logger.debug("Job application process completed successfully for job: %s", job) logger.debug("Job application process completed successfully for job: %s", job)
except Exception as e: except Exception as e:
# Захват и логирование полного traceback в случае ошибки
tb_str = traceback.format_exc() tb_str = traceback.format_exc()
logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str) logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str)
# Отмена заявки в случае ошибки
logger.debug("Discarding application due to failure") logger.debug("Discarding application due to failure")
self._discard_application() self._discard_application()
# Поднятие исключения с оригинальной ошибкой
raise Exception(f"Failed to apply to job! Original exception:\nTraceback:\n{tb_str}") raise Exception(f"Failed to apply to job! Original exception:\nTraceback:\n{tb_str}")
def _find_easy_apply_button(self) -> WebElement: def _find_easy_apply_button(self, job: Any) -> WebElement:
logger.debug("Searching for 'Easy Apply' button") logger.debug("Searching for 'Easy Apply' button")
attempt = 0 attempt = 0
# Список методов поиска кнопки
search_methods = [ search_methods = [
{ {
'description': "find all 'Easy Apply' buttons using find_elements", 'description': "find all 'Easy Apply' buttons using find_elements",
'find_elements': True, # Используем find_elements для поиска всех кнопок 'find_elements': True,
'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]' 'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]'
}, },
{ {
@ -137,20 +154,21 @@ class LinkedInEasyApplier:
] ]
while attempt < 2: while attempt < 2:
self.check_for_premium_redirect(job)
self._scroll_page() self._scroll_page()
for method in search_methods: for method in search_methods:
try: try:
logger.debug(f"Attempting search using {method['description']}") logger.debug(f"Attempting search using {method['description']}")
# Если метод использует find_elements
if method.get('find_elements'): if method.get('find_elements'):
# Поиск всех кнопок "Easy Apply" # Поиск всех кнопок "Easy Apply"
buttons = self.driver.find_elements(By.XPATH, method['xpath']) buttons = self.driver.find_elements(By.XPATH, method['xpath'])
if buttons: if buttons:
for index, button in enumerate(buttons): for index, button in enumerate(buttons):
try: try:
# Проверка видимости и кликабельности каждой кнопки
WebDriverWait(self.driver, 10).until(EC.visibility_of(button)) WebDriverWait(self.driver, 10).until(EC.visibility_of(button))
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button)) WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button))
logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click") logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click")
@ -160,7 +178,7 @@ class LinkedInEasyApplier:
else: else:
raise TimeoutException("No 'Easy Apply' buttons found") raise TimeoutException("No 'Easy Apply' buttons found")
else: else:
# Стандартный метод с WebDriverWait для одного элемента
button = WebDriverWait(self.driver, 10).until( button = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, method['xpath'])) EC.presence_of_element_located((By.XPATH, method['xpath']))
) )
@ -172,7 +190,10 @@ class LinkedInEasyApplier:
except TimeoutException: except TimeoutException:
logger.warning(f"Timeout during search using {method['description']}") logger.warning(f"Timeout during search using {method['description']}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}") logger.warning(
f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}")
self.check_for_premium_redirect(job)
if attempt == 0: if attempt == 0:
logger.debug("Refreshing page to retry finding 'Easy Apply' button") logger.debug("Refreshing page to retry finding 'Easy Apply' button")
@ -180,7 +201,6 @@ class LinkedInEasyApplier:
time.sleep(random.randint(3, 5)) time.sleep(random.randint(3, 5))
attempt += 1 attempt += 1
# Если не удалось найти кнопку, выводим HTML для отладки
page_source = self.driver.page_source page_source = self.driver.page_source
logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source) logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source)
raise Exception("No clickable 'Easy Apply' button found") raise Exception("No clickable 'Easy Apply' button found")
@ -189,7 +209,8 @@ class LinkedInEasyApplier:
logger.debug("Getting job description") logger.debug("Getting job description")
try: try:
try: try:
see_more_button = self.driver.find_element(By.XPATH, '//button[@aria-label="Click to see more description"]') see_more_button = self.driver.find_element(By.XPATH,
'//button[@aria-label="Click to see more description"]')
actions = ActionChains(self.driver) actions = ActionChains(self.driver)
actions.move_to_element(see_more_button).click().perform() actions.move_to_element(see_more_button).click().perform()
time.sleep(2) time.sleep(2)
@ -216,7 +237,8 @@ class LinkedInEasyApplier:
) )
logger.debug("Hiring team section found") logger.debug("Hiring team section found")
recruiter_elements = hiring_team_section.find_elements(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]') recruiter_elements = hiring_team_section.find_elements(By.XPATH,
'.//following::a[contains(@href, "linkedin.com/in/")]')
if recruiter_elements: if recruiter_elements:
recruiter_element = recruiter_elements[0] recruiter_element = recruiter_elements[0]
@ -289,18 +311,17 @@ class LinkedInEasyApplier:
def fill_up(self, job) -> None: def fill_up(self, job) -> None:
logger.debug("Filling up form sections for job: %s", job) logger.debug("Filling up form sections for job: %s", job)
# Используем WebDriverWait для ожидания элемента с классом 'jobs-easy-apply-content'
try: try:
easy_apply_content = WebDriverWait(self.driver, 10).until( easy_apply_content = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content')) EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content'))
) )
# После нахождения 'jobs-easy-apply-content' ищем элементы с классом 'pb4'
pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4') pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4')
for element in pb4_elements: for element in pb4_elements:
self._process_form_element(element, job) self._process_form_element(element, job)
except Exception as e: except Exception as e:
logger.error(f"Failed to find form elements: {e}") logger.error(f"Failed to find form elements: {e}")
def _process_form_element(self, element: WebElement, job) -> None: def _process_form_element(self, element: WebElement, job) -> None:
logger.debug("Processing form element") logger.debug("Processing form element")
if self._is_upload_field(element): if self._is_upload_field(element):
@ -308,6 +329,47 @@ class LinkedInEasyApplier:
else: else:
self._fill_additional_questions() self._fill_additional_questions()
def _handle_dropdown_fields(self, element: WebElement) -> None:
logger.debug("Handling dropdown fields")
dropdown = element.find_element(By.TAG_NAME, 'select')
select = Select(dropdown)
options = [option.text for option in select.options]
logger.debug(f"Dropdown options found: {options}")
parent_element = dropdown.find_element(By.XPATH, '../..')
label_elements = parent_element.find_elements(By.TAG_NAME, 'label')
if label_elements:
question_text = label_elements[0].text.lower()
else:
question_text = "unknown"
logger.debug(f"Detected question text: {question_text}")
existing_answer = None
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
existing_answer = item['answer']
break
if existing_answer:
logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}")
else:
logger.debug(f"No existing answer found, querying model for: {question_text}")
existing_answer = self.gpt_answerer.answer_question_from_options(question_text, options)
logger.debug(f"Model provided answer: {existing_answer}")
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': existing_answer})
if existing_answer in options:
select.select_by_visible_text(existing_answer)
logger.debug(f"Selected option: {existing_answer}")
else:
logger.error(f"Answer '{existing_answer}' is not a valid option in the dropdown")
raise Exception(f"Invalid option selected: {existing_answer}")
def _is_upload_field(self, element: WebElement) -> bool: def _is_upload_field(self, element: WebElement) -> bool:
is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']")) is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']"))
logger.debug("Element is upload field: %s", is_upload) logger.debug("Element is upload field: %s", is_upload)
@ -317,7 +379,8 @@ class LinkedInEasyApplier:
logger.debug("Handling upload fields") logger.debug("Handling upload fields")
try: try:
show_more_button = self.driver.find_element(By.XPATH, "//button[contains(@aria-label, 'Show more resumes')]") show_more_button = self.driver.find_element(By.XPATH,
"//button[contains(@aria-label, 'Show more resumes')]")
show_more_button.click() show_more_button.click()
logger.debug("Clicked 'Show more resumes' button") logger.debug("Clicked 'Show more resumes' button")
except NoSuchElementException: except NoSuchElementException:
@ -339,112 +402,160 @@ class LinkedInEasyApplier:
self._create_and_upload_resume(element, job) self._create_and_upload_resume(element, job)
elif 'cover' in output: elif 'cover' in output:
logger.debug("Uploading cover letter") logger.debug("Uploading cover letter")
self._create_and_upload_cover_letter(element) self._create_and_upload_cover_letter(element, job)
logger.debug("Finished handling upload fields") logger.debug("Finished handling upload fields")
def _create_and_upload_resume(self, element, job): def _create_and_upload_resume(self, element, job):
logger.debug("Starting the process of creating and uploading resume.") logger.debug("Starting the process of creating and uploading resume.")
folder_path = 'generated_cv' folder_path = 'generated_cv'
try:
if not os.path.exists(folder_path):
logger.debug(f"Creating directory at path: {folder_path}")
os.makedirs(folder_path, exist_ok=True)
except Exception as e:
logger.error(f"Failed to create directory: {folder_path}. Error: {e}")
raise
while True:
try: try:
if not os.path.exists(folder_path): timestamp = int(time.time())
logger.debug(f"Creating directory at path: {folder_path}") file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf")
os.makedirs(folder_path, exist_ok=True) logger.debug(f"Generated file path for resume: {file_path_pdf}")
logger.debug(f"Generating resume for job: {job.title} at {job.company}")
resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description)
with open(file_path_pdf, "xb") as f:
f.write(base64.b64decode(resume_pdf_base64))
logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}")
break
except HTTPStatusError as e:
if e.response.status_code == 429:
retry_after = e.response.headers.get('retry-after')
retry_after_ms = e.response.headers.get('retry-after-ms')
if retry_after:
wait_time = int(retry_after)
logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...")
elif retry_after_ms:
wait_time = int(retry_after_ms) / 1000.0
logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...")
else:
wait_time = 20
logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...")
time.sleep(wait_time)
else:
logger.error(f"HTTP error: {e}")
raise
except Exception as e: except Exception as e:
logger.error(f"Failed to create directory: {folder_path}. Error: {e}") logger.error(f"Failed to generate resume: {e}")
tb_str = traceback.format_exc()
logger.error(f"Traceback: {tb_str}")
if "RateLimitError" in str(e):
logger.warning("Rate limit error encountered, retrying...")
time.sleep(20)
else:
raise
file_size = os.path.getsize(file_path_pdf)
max_file_size = 2 * 1024 * 1024 # 2 MB
logger.debug(f"Resume file size: {file_size} bytes")
if file_size > max_file_size:
logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes")
raise ValueError("Resume file size exceeds the maximum limit of 2 MB.")
allowed_extensions = {'.pdf', '.doc', '.docx'}
file_extension = os.path.splitext(file_path_pdf)[1].lower()
logger.debug(f"Resume file extension: {file_extension}")
if file_extension not in allowed_extensions:
logger.error(f"Invalid resume file format: {file_extension}")
raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.")
try:
logger.debug(f"Uploading resume from path: {file_path_pdf}")
element.send_keys(os.path.abspath(file_path_pdf))
job.pdf_path = os.path.abspath(file_path_pdf)
time.sleep(2)
logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}")
except Exception as e:
tb_str = traceback.format_exc()
logger.error(f"Resume upload failed: {tb_str}")
raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
def _create_and_upload_cover_letter(self, element: WebElement, job) -> None:
logger.debug("Starting the process of creating and uploading cover letter.")
cover_letter_text = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter")
folder_path = 'generated_cv'
try:
if not os.path.exists(folder_path):
logger.debug(f"Creating directory at path: {folder_path}")
os.makedirs(folder_path, exist_ok=True)
except Exception as e:
logger.error(f"Failed to create directory: {folder_path}. Error: {e}")
raise
while True:
try:
timestamp = int(time.time())
file_path_pdf = os.path.join(folder_path, f"Cover_Letter_{timestamp}.pdf")
logger.debug(f"Generated file path for cover letter: {file_path_pdf}")
c = canvas.Canvas(file_path_pdf, pagesize=letter)
_, height = letter
text_object = c.beginText(100, height - 100)
text_object.setFont("Helvetica", 12)
text_object.textLines(cover_letter_text)
c.drawText(text_object)
c.save()
logger.debug(f"Cover letter successfully generated and saved to: {file_path_pdf}")
break
except Exception as e:
logger.error(f"Failed to generate cover letter: {e}")
tb_str = traceback.format_exc()
logger.error(f"Traceback: {tb_str}")
raise raise
while True: file_size = os.path.getsize(file_path_pdf)
try: max_file_size = 2 * 1024 * 1024 # 2 MB
timestamp = int(time.time()) logger.debug(f"Cover letter file size: {file_size} bytes")
file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf") if file_size > max_file_size:
logger.debug(f"Generated file path for resume: {file_path_pdf}") logger.error(f"Cover letter file size exceeds 2 MB: {file_size} bytes")
raise ValueError("Cover letter file size exceeds the maximum limit of 2 MB.")
logger.debug(f"Generating resume for job: {job.title} at {job.company}") allowed_extensions = {'.pdf', '.doc', '.docx'}
resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description) file_extension = os.path.splitext(file_path_pdf)[1].lower()
with open(file_path_pdf, "xb") as f: logger.debug(f"Cover letter file extension: {file_extension}")
f.write(base64.b64decode(resume_pdf_base64)) if file_extension not in allowed_extensions:
logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}") logger.error(f"Invalid cover letter file format: {file_extension}")
raise ValueError("Cover letter file format is not allowed. Only PDF, DOC, and DOCX formats are supported.")
break try:
except HTTPStatusError as e:
if e.response.status_code == 429:
retry_after = e.response.headers.get('retry-after') logger.debug(f"Uploading cover letter from path: {file_path_pdf}")
retry_after_ms = e.response.headers.get('retry-after-ms') element.send_keys(os.path.abspath(file_path_pdf))
job.cover_letter_path = os.path.abspath(file_path_pdf)
if retry_after: time.sleep(2)
wait_time = int(retry_after) logger.debug(f"Cover letter created and uploaded successfully: {file_path_pdf}")
logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...") except Exception as e:
elif retry_after_ms: tb_str = traceback.format_exc()
wait_time = int(retry_after_ms) / 1000.0 logger.error(f"Cover letter upload failed: {tb_str}")
logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...") raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
else:
wait_time = 20
logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...")
time.sleep(wait_time)
else:
logger.error(f"HTTP error: {e}")
raise
except Exception as e:
logger.error(f"Failed to generate resume: {e}")
tb_str = traceback.format_exc()
logger.error(f"Traceback: {tb_str}")
if "RateLimitError" in str(e):
logger.warning("Rate limit error encountered, retrying...")
time.sleep(20)
else:
raise
file_size = os.path.getsize(file_path_pdf)
max_file_size = 2 * 1024 * 1024 # 2 MB
logger.debug(f"Resume file size: {file_size} bytes")
if file_size > max_file_size:
logger.error(f"Resume file size exceeds 2 MB: {file_size} bytes")
raise ValueError("Resume file size exceeds the maximum limit of 2 MB.")
allowed_extensions = {'.pdf', '.doc', '.docx'}
file_extension = os.path.splitext(file_path_pdf)[1].lower()
logger.debug(f"Resume file extension: {file_extension}")
if file_extension not in allowed_extensions:
logger.error(f"Invalid resume file format: {file_extension}")
raise ValueError("Resume file format is not allowed. Only PDF, DOC, and DOCX formats are supported.")
try:
logger.debug(f"Uploading resume from path: {file_path_pdf}")
element.send_keys(os.path.abspath(file_path_pdf))
job.pdf_path = os.path.abspath(file_path_pdf)
time.sleep(2)
logger.debug(f"Resume created and uploaded successfully: {file_path_pdf}")
except Exception as e:
tb_str = traceback.format_exc()
logger.error(f"Resume upload failed: {tb_str}")
raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
def _create_and_upload_cover_letter(self, element: WebElement) -> None:
logger.debug("Creating and uploading cover letter")
cover_letter = self.gpt_answerer.answer_question_textual_wide_range("Write a cover letter")
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as temp_pdf_file:
letter_path = temp_pdf_file.name
c = canvas.Canvas(letter_path, pagesize=letter)
_, height = letter
text_object = c.beginText(100, height - 100)
text_object.setFont("Helvetica", 12)
text_object.textLines(cover_letter)
c.drawText(text_object)
c.save()
element.send_keys(letter_path)
logger.debug("Cover letter created and uploaded successfully: %s", letter_path)
def _fill_additional_questions(self) -> None: def _fill_additional_questions(self) -> None:
logger.debug("Filling additional questions") logger.debug("Filling additional questions")
form_sections = self.driver.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping') form_sections = self.driver.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping')
for section in form_sections: for section in form_sections:
self._process_form_section(section) self._process_form_section(section)
def _process_form_section(self, section: WebElement) -> None: def _process_form_section(self, section: WebElement) -> None:
logger.debug("Processing form section") logger.debug("Processing form section")
@ -460,13 +571,15 @@ class LinkedInEasyApplier:
if self._find_and_handle_date_question(section): if self._find_and_handle_date_question(section):
logger.debug("Handled date question") logger.debug("Handled date question")
return return
if self._find_and_handle_dropdown_question(section): if self._find_and_handle_dropdown_question(section):
logger.debug("Handled dropdown question") logger.debug("Handled dropdown question")
return return
def _handle_terms_of_service(self, element: WebElement) -> bool: def _handle_terms_of_service(self, element: WebElement) -> bool:
checkbox = element.find_elements(By.TAG_NAME, 'label') checkbox = element.find_elements(By.TAG_NAME, 'label')
if checkbox and any(term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']): if checkbox and any(
term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']):
checkbox[0].click() checkbox[0].click()
logger.debug("Clicked terms of service checkbox") logger.debug("Clicked terms of service checkbox")
return True return True
@ -478,7 +591,7 @@ class LinkedInEasyApplier:
if radios: if radios:
question_text = section.text.lower() question_text = section.text.lower()
options = [radio.text.lower() for radio in radios] options = [radio.text.lower() for radio in radios]
existing_answer = None existing_answer = None
for item in self.all_data: for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'radio': if self._sanitize_text(question_text) in item['question'] and item['type'] == 'radio':
@ -502,31 +615,29 @@ class LinkedInEasyApplier:
if text_fields: if text_fields:
text_field = text_fields[0] text_field = text_fields[0]
question_text = section.find_element(By.TAG_NAME, 'label').text.lower() question_text = section.find_element(By.TAG_NAME, 'label').text.lower().strip()
logger.debug(f"Found text field with label: {question_text}") logger.debug(f"Found text field with label: {question_text}")
is_numeric = self._is_numeric_field(text_field) is_numeric = self._is_numeric_field(text_field)
logger.debug(f"Is the field numeric? {'Yes' if is_numeric else 'No'}") logger.debug(f"Is the field numeric? {'Yes' if is_numeric else 'No'}")
if is_numeric:
question_type = 'numeric'
answer = self.gpt_answerer.answer_question_numeric(question_text)
logger.debug(f"Generated numeric answer: {answer}")
else:
question_type = 'textbox'
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
logger.debug(f"Generated textual answer: {answer}")
existing_answer = None existing_answer = None
question_type = 'numeric' if is_numeric else 'textbox'
for item in self.all_data: for item in self.all_data:
if item['question'] == self._sanitize_text(question_text) and item['type'] == question_type:
logger.debug(
f"Comparing sanitized stored question: '{self._sanitize_text(item['question'])}' and type: '{item.get('type')}' with current question: '{self._sanitize_text(question_text)}' and type: '{question_type}'")
if self._sanitize_text(item['question']) == self._sanitize_text(question_text) and item.get(
'type') == question_type:
existing_answer = item existing_answer = item
logger.debug(f"Found existing answer in the data: {existing_answer['answer']}") logger.debug(f"Found existing answer in the data: {existing_answer['answer']}")
break break
if existing_answer: if existing_answer:
self._enter_text(text_field, existing_answer['answer']) self._enter_text(text_field, existing_answer['answer'])
logger.debug("Entered existing textbox answer.") logger.debug("Entered existing answer into the textbox.")
time.sleep(1) time.sleep(1)
text_field.send_keys(Keys.ARROW_DOWN) text_field.send_keys(Keys.ARROW_DOWN)
@ -534,9 +645,16 @@ class LinkedInEasyApplier:
logger.debug("Selected first option from the dropdown.") logger.debug("Selected first option from the dropdown.")
return True return True
if is_numeric:
answer = self.gpt_answerer.answer_question_numeric(question_text)
logger.debug(f"Generated numeric answer: {answer}")
else:
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
logger.debug(f"Generated textual answer: {answer}")
self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer}) self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer})
self._enter_text(text_field, answer) self._enter_text(text_field, answer)
logger.debug("Entered new textbox answer and saved it to JSON.") logger.debug("Entered new answer into the textbox and saved it to JSON.")
time.sleep(1) time.sleep(1)
text_field.send_keys(Keys.ARROW_DOWN) text_field.send_keys(Keys.ARROW_DOWN)
@ -555,7 +673,6 @@ class LinkedInEasyApplier:
answer_date = self.gpt_answerer.answer_question_date() answer_date = self.gpt_answerer.answer_question_date()
answer_text = answer_date.strftime("%Y-%m-%d") answer_text = answer_date.strftime("%Y-%m-%d")
existing_answer = None existing_answer = None
for item in self.all_data: for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date': if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date':
@ -574,56 +691,44 @@ class LinkedInEasyApplier:
def _find_and_handle_dropdown_question(self, section: WebElement) -> bool: def _find_and_handle_dropdown_question(self, section: WebElement) -> bool:
try: try:
question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element') question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element')
question_text = question.find_element(By.TAG_NAME, 'label').text.lower() question_text = question.find_element(By.TAG_NAME, 'label').text.lower()
logger.debug(f"Processing dropdown or combobox question: {question_text}") logger.debug(f"Processing dropdown or combobox question: {question_text}")
try: dropdowns = question.find_elements(By.TAG_NAME, 'select')
dropdown = question.find_element(By.TAG_NAME, 'select') if dropdowns:
dropdown = dropdowns[0]
select = Select(dropdown) select = Select(dropdown)
options = [option.text for option in select.options] options = [option.text for option in select.options]
logger.debug(f"Dropdown options found: {options}") logger.debug(f"Dropdown options found: {options}")
current_selection = select.first_selected_option.text
logger.debug(f"Current selection: {current_selection}")
existing_answer = None existing_answer = None
for item in self.all_data: for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown': if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
existing_answer = item existing_answer = item['answer']
break break
if existing_answer: if existing_answer:
self._select_dropdown_option(dropdown, existing_answer['answer']) logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}")
logger.debug("Selected existing dropdown answer") if current_selection != existing_answer:
logger.debug(f"Updating selection to: {existing_answer}")
self._select_dropdown_option(dropdown, existing_answer)
return True return True
logger.debug(f"No existing answer found, querying model for: {question_text}")
answer = self.gpt_answerer.answer_question_from_options(question_text, options) answer = self.gpt_answerer.answer_question_from_options(question_text, options)
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer}) self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer})
self._select_dropdown_option(dropdown, answer) self._select_dropdown_option(dropdown, answer)
logger.debug("Selected new dropdown answer") logger.debug(f"Selected new dropdown answer: {answer}")
return True
except NoSuchElementException:
combobox = question.find_element(By.TAG_NAME, 'input')
logger.debug(f"Found combobox with ID: {combobox.get_attribute('id')}")
existing_answer = None
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'combobox':
existing_answer = item
break
if existing_answer:
self._enter_text(combobox, existing_answer['answer'])
logger.debug("Entered existing combobox answer")
return True
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
self._save_questions_to_json({'type': 'combobox', 'question': question_text, 'answer': answer})
self._enter_text(combobox, answer)
logger.debug("Entered new combobox answer")
return True return True
return False
except Exception as e: except Exception as e:
logger.warning("Failed to handle dropdown or combobox question: %s", e) logger.warning(f"Failed to handle dropdown or combobox question: {e}")
return False return False
def _is_numeric_field(self, field: WebElement) -> bool: def _is_numeric_field(self, field: WebElement) -> bool:
@ -677,7 +782,6 @@ class LinkedInEasyApplier:
logger.error("Error saving questions data to JSON file: %s", tb_str) logger.error("Error saving questions data to JSON file: %s", tb_str)
raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}") raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}")
def _sanitize_text(self, text: str) -> str: def _sanitize_text(self, text: str) -> str:
sanitized_text = text.lower().strip().replace('"', '').replace('\\', '') sanitized_text = text.lower().strip().replace('"', '').replace('\\', '')
sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',') sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',')

View file

@ -1,15 +1,16 @@
import json
import os import os
import random import random
import time import time
import traceback
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
import src.utils as utils import src.utils as utils
from src.job import Job from src.job import Job
from src.linkedIn_easy_applier import LinkedInEasyApplier from src.linkedIn_easy_applier import LinkedInEasyApplier
import json
from src.utils import logger from src.utils import logger
@ -33,6 +34,7 @@ class EnvironmentKeys:
logger.debug("Read environment key %s as bool: %s", key, value) logger.debug("Read environment key %s as bool: %s", key, value)
return value return value
class LinkedInJobManager: class LinkedInJobManager:
def __init__(self, driver): def __init__(self, driver):
logger.debug("Initializing LinkedInJobManager") logger.debug("Initializing LinkedInJobManager")
@ -47,7 +49,6 @@ class LinkedInJobManager:
self.title_blacklist = parameters.get('titleBlacklist', []) or [] self.title_blacklist = parameters.get('titleBlacklist', []) or []
self.positions = parameters.get('positions', []) self.positions = parameters.get('positions', [])
self.locations = parameters.get('locations', []) self.locations = parameters.get('locations', [])
self.apply_once_at_company = parameters.get('applyOnceAtCompany', False)
self.base_search_url = self.get_base_search_url(parameters) self.base_search_url = self.get_base_search_url(parameters)
self.seen_jobs = [] self.seen_jobs = []
resume_path = parameters.get('uploads', {}).get('resume', None) resume_path = parameters.get('uploads', {}).get('resume', None)
@ -66,7 +67,8 @@ class LinkedInJobManager:
def start_applying(self): def start_applying(self):
logger.debug("Starting job application process") logger.debug("Starting job application process")
self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, self.gpt_answerer, self.resume_generator_manager) self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers,
self.gpt_answerer, self.resume_generator_manager)
searches = list(product(self.positions, self.locations)) searches = list(product(self.positions, self.locations))
random.shuffle(searches) random.shuffle(searches)
page_sleep = 0 page_sleep = 0
@ -134,9 +136,13 @@ class LinkedInJobManager:
time.sleep(sleep_time) time.sleep(sleep_time)
page_sleep += 1 page_sleep += 1
def get_jobs_from_page(self): def get_jobs_from_page(self):
"""
Функция для получения списка вакансий на текущей странице.
Если вакансии не найдены, возвращает пустой список.
"""
try: try:
no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand') no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand')
if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower(): if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower():
utils.printyellow("No matching jobs found on this page.") utils.printyellow("No matching jobs found on this page.")
@ -151,7 +157,8 @@ class LinkedInJobManager:
utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results)
utils.scroll_slow(self.driver, job_results, step=300, reverse=True) utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[
0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
if not job_list_elements: if not job_list_elements:
utils.printyellow("No job class elements found on page.") utils.printyellow("No job class elements found on page.")
logger.debug("No job class elements found on page, skipping.") logger.debug("No job class elements found on page, skipping.")
@ -180,24 +187,19 @@ class LinkedInJobManager:
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list") job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
utils.scroll_slow(self.driver, job_results) utils.scroll_slow(self.driver, job_results)
utils.scroll_slow(self.driver, job_results, step=300, reverse=True) utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item') job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[
0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
if not job_list_elements: if not job_list_elements:
utils.printyellow("No job class elements found on page, moving to next page.") utils.printyellow("No job class elements found on page, moving to next page.")
logger.debug("No job class elements found on page, skipping") logger.debug("No job class elements found on page, skipping")
return return
job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements] job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements]
for job in job_list: for job in job_list:
if self.is_blacklisted(job.title, job.company, job.link): if self.is_blacklisted(job.title, job.company, job.link):
utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...") utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...")
logger.debug("Job blacklisted: %s at %s", job.title, job.company) logger.debug("Job blacklisted: %s at %s", job.title, job.company)
self.write_to_file(job, "skipped") self.write_to_file(job, "skipped")
continue continue
if self.is_already_applied_to_job(job.title, job.company, job.link):
self.write_to_file(job, "skipped")
continue
if self.is_already_applied_to_company(job.company):
self.write_to_file(job, "skipped")
continue
try: try:
if job.apply_method not in {"Continue", "Applied", "Apply"}: if job.apply_method not in {"Continue", "Applied", "Apply"}:
self.easy_applier_component.job_apply(job) self.easy_applier_component.job_apply(job)
@ -208,7 +210,7 @@ class LinkedInJobManager:
utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}") utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}")
self.write_to_file(job, "failed") self.write_to_file(job, "failed")
continue continue
def write_to_file(self, job, file_name): def write_to_file(self, job, file_name):
logger.debug("Writing job application result to file: %s", file_name) logger.debug("Writing job application result to file: %s", file_name)
pdf_path = Path(job.pdf_path).resolve() pdf_path = Path(job.pdf_path).resolve()
@ -244,7 +246,8 @@ class LinkedInJobManager:
url_parts = [] url_parts = []
if parameters['remote']: if parameters['remote']:
url_parts.append("f_CF=f_WRA") url_parts.append("f_CF=f_WRA")
experience_levels = [str(i+1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if v] experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if
v]
if experience_levels: if experience_levels:
url_parts.append(f"f_E={','.join(experience_levels)}") url_parts.append(f"f_E={','.join(experience_levels)}")
url_parts.append(f"distance={parameters['distance']}") url_parts.append(f"distance={parameters['distance']}")
@ -263,11 +266,12 @@ class LinkedInJobManager:
full_url = f"?{base_url}{date_param}" full_url = f"?{base_url}{date_param}"
logger.debug("Base search URL constructed: %s", full_url) logger.debug("Base search URL constructed: %s", full_url)
return full_url return full_url
def next_job_page(self, position, location, job_page): def next_job_page(self, position, location, job_page):
logger.debug("Navigating to next job page: %s in %s, page %d", position, location, job_page) logger.debug("Navigating to next job page: %s in %s, page %d", position, location, job_page)
self.driver.get(f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}") self.driver.get(
f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}{location}&start={job_page * 25}")
def extract_job_information_from_tile(self, job_tile): def extract_job_information_from_tile(self, job_tile):
logger.debug("Extracting job information from tile") logger.debug("Extracting job information from tile")
job_title, company, job_location, apply_method, link = "", "", "", "", "" job_title, company, job_location, apply_method, link = "", "", "", "", ""
@ -287,45 +291,18 @@ class LinkedInJobManager:
try: try:
apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text
except NoSuchElementException: except NoSuchElementException:
apply_method = "Applied" # Подразумеваем, что вакансия уже подана apply_method = "Applied"
utils.printyellow("Apply method not found, assuming 'Applied'.") utils.printyellow("Apply method not found, assuming 'Applied'.")
logger.warning("Apply method not found, assuming 'Applied'.") logger.warning("Apply method not found, assuming 'Applied'.")
return job_title, company, job_location, link, apply_method return job_title, company, job_location, link, apply_method
def is_blacklisted(self, job_title, company, link): def is_blacklisted(self, job_title, company, link):
logger.debug("Checking if job is blacklisted: %s at %s", job_title, company) logger.debug("Checking if job is blacklisted: %s at %s", job_title, company)
job_title_words = job_title.lower().split(' ') job_title_words = job_title.lower().split(' ')
title_blacklisted = any(word in job_title_words for word in self.title_blacklist) title_blacklisted = any(word in job_title_words for word in self.title_blacklist)
company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist) company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist)
link_seen = link in self.seen_jobs link_seen = link in self.seen_jobs
is_blacklisted = title_blacklisted or company_blacklisted or link_seen is_blacklisted = title_blacklisted or company_blacklisted or link_seen
logger.debug("Job blacklisted status: %s", is_blacklisted) logger.debug("Job blacklisted status: %s", is_blacklisted)
return is_blacklisted return is_blacklisted
def is_already_applied_to_job(self, job_title, company, link):
link_seen = link in self.seen_jobs
if link_seen:
utils.printyellow(f"Already applied to job: {job_title} at {company}, skipping...")
return link_seen
def is_already_applied_to_company(self, company):
if not self.apply_once_at_company:
return False
output_files = ["success.json"]
for file_name in output_files:
file_path = self.output_file_directory / file_name
if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f:
try:
existing_data = json.load(f)
for applied_job in existing_data:
if applied_job['company'].strip().lower() == company.strip().lower():
utils.printyellow(f"Already applied at {company} (once per company policy), skipping...")
return True
except json.JSONDecodeError:
continue
return False

View file

@ -181,7 +181,7 @@ Answer the following question based on the provided language skills.
- Answer questions directly. - Answer questions directly.
- If it seems likely that you have the experience, even if not explicitly defined, answer as if you have the experience. - If it seems likely that you have the experience, even if not explicitly defined, answer as if you have the experience.
- If unsure, respond with "I have no experience with that, but I learn fast" or "Not yet, but willing to learn." - If unsure, respond with "I have no experience with that, but I learn fast" or "Not yet, but willing to learn."
- Keep the answer under 140 characters. - Keep the answer under 140 characters. Do not add any additional languages what is not in my experience
## Example ## Example
My resume: Fluent in Italian and English. My resume: Fluent in Italian and English.
@ -238,7 +238,6 @@ This comprehensive overview will serve as a guideline for the recruitment proces
# Job Description Summary""" # Job Description Summary"""
coverletter_template = """ coverletter_template = """
Compose a brief and impactful cover letter based on the provided job description and resume. The letter should be no longer than three paragraphs and should be written in a professional, yet conversational tone. Avoid using any placeholders, and ensure that the letter flows naturally and is tailored to the job. Compose a brief and impactful cover letter based on the provided job description and resume. The letter should be no longer than three paragraphs and should be written in a professional, yet conversational tone. Avoid using any placeholders, and ensure that the letter flows naturally and is tailored to the job.
@ -371,7 +370,6 @@ Options: [1-2, 3-5, 6-10, 10+]
## """ ## """
try_to_fix_template = """\ try_to_fix_template = """\
The objective is to fix the text of a form input on a web page. The objective is to fix the text of a form input on a web page.

View file

@ -1,23 +1,32 @@
import logging
import os import os
import random import random
import time import time
from selenium import webdriver from selenium import webdriver
import logging log_file = "app_log.log"
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, mode='a', encoding='utf-8'),
logging.StreamHandler()
],
force=True # This will reset the root logger's handlers and apply the new configuration
)
# Настройка логирования
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
# Отключаем логирование для selenium и urllib3 formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING) file_handler.setFormatter(formatter)
logging.getLogger("urllib3").setLevel(logging.WARNING) logger.addHandler(file_handler)
logging.getLogger("httpcore").setLevel(logging.WARNING) logger.setLevel(logging.DEBUG)
chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile") chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile")
def ensure_chrome_profile(): def ensure_chrome_profile():
logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath) logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath)
profile_dir = os.path.dirname(chromeProfilePath) profile_dir = os.path.dirname(chromeProfilePath)
@ -29,51 +38,74 @@ def ensure_chrome_profile():
logger.debug("Created Chrome profile directory: %s", chromeProfilePath) logger.debug("Created Chrome profile directory: %s", chromeProfilePath)
return chromeProfilePath return chromeProfilePath
def is_scrollable(element): def is_scrollable(element):
scroll_height = element.get_attribute("scrollHeight") scroll_height = element.get_attribute("scrollHeight")
client_height = element.get_attribute("clientHeight") client_height = element.get_attribute("clientHeight")
scrollable = int(scroll_height) > int(client_height) scrollable = int(scroll_height) > int(client_height)
logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height, client_height, scrollable) logger.debug("Element scrollable check: scrollHeight=%s, clientHeight=%s, scrollable=%s", scroll_height,
client_height, scrollable)
return scrollable return scrollable
def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False): def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False):
logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse) logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse)
if reverse: if reverse:
start, end = end, start start, end = end, start
step = -step step = -step
if step == 0: if step == 0:
logger.error("Step value cannot be zero.") logger.error("Step value cannot be zero.")
raise ValueError("Step cannot be zero.") raise ValueError("Step cannot be zero.")
max_scroll_height = int(scrollable_element.get_attribute("scrollHeight")) max_scroll_height = int(scrollable_element.get_attribute("scrollHeight"))
current_scroll_position = int(scrollable_element.get_attribute("scrollTop"))
logger.debug("Max scroll height of the element: %d", max_scroll_height) logger.debug("Max scroll height of the element: %d", max_scroll_height)
logger.debug("Current scroll position: %d", current_scroll_position)
if end > max_scroll_height: if reverse:
logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height)
end = max_scroll_height if current_scroll_position < start:
start = current_scroll_position
logger.debug("Adjusted start position for upward scroll: %d", start)
else:
if end > max_scroll_height:
logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height)
end = max_scroll_height
script_scroll_to = "arguments[0].scrollTop = arguments[1];" script_scroll_to = "arguments[0].scrollTop = arguments[1];"
try: try:
if scrollable_element.is_displayed(): if scrollable_element.is_displayed():
if not is_scrollable(scrollable_element): if not is_scrollable(scrollable_element):
logger.warning("The element is not scrollable.") logger.warning("The element is not scrollable.")
print("The element is not scrollable.") print("The element is not scrollable.")
return return
if (step > 0 and start >= end) or (step < 0 and start <= end): if (step > 0 and start >= end) or (step < 0 and start <= end):
logger.warning("No scrolling will occur due to incorrect start/end values.") logger.warning("No scrolling will occur due to incorrect start/end values.")
print("No scrolling will occur due to incorrect start/end values.") print("No scrolling will occur due to incorrect start/end values.")
return return
for position in range(start, end, step):
position = start
while (step > 0 and position < end) or (step < 0 and position > end):
try: try:
driver.execute_script(script_scroll_to, scrollable_element, position) driver.execute_script(script_scroll_to, scrollable_element, position)
logger.debug("Scrolled to position: %d", position) logger.debug("Scrolled to position: %d", position)
except Exception as e: except Exception as e:
logger.error("Error during scrolling: %s", e) logger.error("Error during scrolling: %s", e)
print(f"Error during scrolling: {e}") print(f"Error during scrolling: {e}")
time.sleep(random.uniform(1.0, 1.6))
position += step
step = max(10, abs(step) - 10) * (-1 if reverse else 1)
time.sleep(random.uniform(0.6, 1.5))
driver.execute_script(script_scroll_to, scrollable_element, end) driver.execute_script(script_scroll_to, scrollable_element, end)
logger.debug("Scrolled to final position: %d", end) logger.debug("Scrolled to final position: %d", end)
time.sleep(1) time.sleep(0.5)
else: else:
logger.warning("The element is not visible.") logger.warning("The element is not visible.")
print("The element is not visible.") print("The element is not visible.")
@ -81,7 +113,8 @@ def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse
logger.error("Exception occurred during scrolling: %s", e) logger.error("Exception occurred during scrolling: %s", e)
print(f"Exception occurred: {e}") print(f"Exception occurred: {e}")
def chromeBrowserOptions():
def chrome_browser_options():
logger.debug("Setting Chrome browser options") logger.debug("Setting Chrome browser options")
ensure_chrome_profile() ensure_chrome_profile()
options = webdriver.ChromeOptions() options = webdriver.ChromeOptions()
@ -112,10 +145,10 @@ def chromeBrowserOptions():
options.add_experimental_option("prefs", prefs) options.add_experimental_option("prefs", prefs)
if len(chromeProfilePath) > 0: if len(chromeProfilePath) > 0:
initialPath = os.path.dirname(chromeProfilePath) initial_path = os.path.dirname(chromeProfilePath)
profileDir = os.path.basename(chromeProfilePath) profile_dir = os.path.basename(chromeProfilePath)
options.add_argument('--user-data-dir=' + initialPath) options.add_argument('--user-data-dir=' + initial_path)
options.add_argument("--profile-directory=" + profileDir) options.add_argument("--profile-directory=" + profile_dir)
logger.debug("Using Chrome profile directory: %s", chromeProfilePath) logger.debug("Using Chrome profile directory: %s", chromeProfilePath)
else: else:
options.add_argument("--incognito") options.add_argument("--incognito")
@ -123,14 +156,16 @@ def chromeBrowserOptions():
return options return options
def printred(text): def printred(text):
RED = "\033[91m" red = "\033[91m"
RESET = "\033[0m" reset = "\033[0m"
logger.debug("Printing text in red: %s", text) logger.debug("Printing text in red: %s", text)
print(f"{RED}{text}{RESET}") print(f"{red}{text}{reset}")
def printyellow(text): def printyellow(text):
YELLOW = "\033[93m" yellow = "\033[93m"
RESET = "\033[0m" reset = "\033[0m"
logger.debug("Printing text in yellow: %s", text) logger.debug("Printing text in yellow: %s", text)
print(f"{YELLOW}{text}{RESET}") print(f"{yellow}{text}{reset}")