new func
This commit is contained in:
parent
35cc5d3bde
commit
b6ceeb44ac
8 changed files with 406 additions and 345 deletions
129
src/gpt.py
129
src/gpt.py
|
|
@ -3,89 +3,28 @@ import os
|
|||
import re
|
||||
import textwrap
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Union
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import httpx
|
||||
from Levenshtein import distance
|
||||
from dotenv import load_dotenv
|
||||
from httpx import HTTPStatusError
|
||||
from langchain_core.messages.ai import AIMessage
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
from langchain_core.prompt_values import StringPromptValue
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_openai import ChatOpenAI
|
||||
from Levenshtein import distance
|
||||
|
||||
import src.strings as strings
|
||||
from src.utils import logger
|
||||
|
||||
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:
|
||||
|
||||
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]):
|
||||
def __init__(self, llm: ChatOpenAI):
|
||||
logger.debug("Initializing LLMLogger with LLM: %s", llm)
|
||||
self.llm = llm
|
||||
logger.debug("LLMLogger successfully initialized with LLM: %s", llm)
|
||||
|
|
@ -108,11 +47,10 @@ class LLMLogger:
|
|||
prompts = prompts.text
|
||||
logger.debug("Prompts converted to text: %s", prompts)
|
||||
elif isinstance(prompts, Dict):
|
||||
# Convert prompts to a dictionary if they are not in the expected format
|
||||
logger.debug("Prompts are of type Dict")
|
||||
try:
|
||||
prompts = {
|
||||
f"prompt_{i+1}": prompt.content
|
||||
f"prompt_{i + 1}": prompt.content
|
||||
for i, prompt in enumerate(prompts.messages)
|
||||
}
|
||||
logger.debug("Prompts converted to dictionary: %s", prompts)
|
||||
|
|
@ -123,7 +61,7 @@ class LLMLogger:
|
|||
logger.debug("Prompts are of unknown type, attempting default conversion")
|
||||
try:
|
||||
prompts = {
|
||||
f"prompt_{i+1}": prompt.content
|
||||
f"prompt_{i + 1}": prompt.content
|
||||
for i, prompt in enumerate(prompts.messages)
|
||||
}
|
||||
logger.debug("Prompts converted to dictionary using default method: %s", prompts)
|
||||
|
|
@ -137,7 +75,7 @@ class LLMLogger:
|
|||
except Exception as e:
|
||||
logger.error("Error obtaining current time: %s", str(e))
|
||||
raise
|
||||
# Extract token usage details from the response
|
||||
|
||||
try:
|
||||
token_usage = parsed_reply["usage_metadata"]
|
||||
output_tokens = token_usage["output_tokens"]
|
||||
|
|
@ -147,14 +85,14 @@ class LLMLogger:
|
|||
except KeyError as e:
|
||||
logger.error("KeyError in parsed_reply structure: %s", str(e))
|
||||
raise
|
||||
# Extract model details from the response
|
||||
|
||||
try:
|
||||
model_name = parsed_reply["response_metadata"]["model_name"]
|
||||
logger.debug("Model name: %s", model_name)
|
||||
except KeyError as e:
|
||||
logger.error("KeyError in response_metadata: %s", str(e))
|
||||
raise
|
||||
# Calculate the total cost of the API call
|
||||
|
||||
try:
|
||||
prompt_price_per_token = 0.00000015
|
||||
completion_price_per_token = 0.0000006
|
||||
|
|
@ -169,7 +107,7 @@ class LLMLogger:
|
|||
"model": model_name,
|
||||
"time": current_time,
|
||||
"prompts": prompts,
|
||||
"replies": parsed_reply["content"], # Response content
|
||||
"replies": parsed_reply["content"],
|
||||
"total_tokens": total_tokens,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
|
|
@ -179,7 +117,7 @@ class LLMLogger:
|
|||
except KeyError as e:
|
||||
logger.error("Error creating log entry: missing key %s in parsed_reply", str(e))
|
||||
raise
|
||||
# Write the log entry to the log file in JSON format
|
||||
|
||||
try:
|
||||
with open(calls_log, "a", encoding="utf-8") as f:
|
||||
json_string = json.dumps(log_entry, ensure_ascii=False, indent=4)
|
||||
|
|
@ -191,13 +129,12 @@ class LLMLogger:
|
|||
|
||||
|
||||
class LoggerChatModel:
|
||||
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel]):
|
||||
def __init__(self, llm: ChatOpenAI):
|
||||
logger.debug("Initializing LoggerChatModel with LLM: %s", llm)
|
||||
self.llm = llm
|
||||
logger.debug("LoggerChatModel successfully initialized with LLM: %s", llm)
|
||||
|
||||
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)
|
||||
while True:
|
||||
try:
|
||||
|
|
@ -221,18 +158,25 @@ class LoggerChatModel:
|
|||
|
||||
if 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)
|
||||
elif retry_after_ms:
|
||||
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)
|
||||
else:
|
||||
wait_time = 30 # Время ожидания по умолчанию
|
||||
logger.warning("'retry-after' header not found. Waiting for %d seconds before retrying (default)...", wait_time)
|
||||
wait_time = 30
|
||||
logger.warning(
|
||||
"'retry-after' header not found. Waiting for %d seconds before retrying (default)...",
|
||||
wait_time)
|
||||
time.sleep(wait_time)
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -242,8 +186,6 @@ class LoggerChatModel:
|
|||
continue
|
||||
|
||||
def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]:
|
||||
# Parse the LLM result into a structured format.
|
||||
|
||||
logger.debug("Parsing LLM result: %s", llmresult)
|
||||
|
||||
try:
|
||||
|
|
@ -280,11 +222,11 @@ class LoggerChatModel:
|
|||
raise
|
||||
|
||||
|
||||
|
||||
class GPTAnswerer:
|
||||
def __init__(self, config, llm_api_key):
|
||||
self.ai_adapter = AIAdapter(config, llm_api_key)
|
||||
self.llm_cheap = LoggerChatModel(self.ai_adapter)
|
||||
def __init__(self, openai_api_key):
|
||||
self.llm_cheap = LoggerChatModel(
|
||||
ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4)
|
||||
)
|
||||
logger.debug("GPTAnswerer initialized with API key")
|
||||
|
||||
@property
|
||||
|
|
@ -309,7 +251,6 @@ class GPTAnswerer:
|
|||
|
||||
@staticmethod
|
||||
def _preprocess_template_string(template: str) -> str:
|
||||
# Preprocess a template string to remove unnecessary indentation.
|
||||
logger.debug("Preprocessing template string")
|
||||
return textwrap.dedent(template)
|
||||
|
||||
|
|
@ -343,7 +284,6 @@ class GPTAnswerer:
|
|||
return prompt | self.llm_cheap | StrOutputParser()
|
||||
|
||||
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)
|
||||
chains = {
|
||||
"personal_information": self._create_chain(strings.personal_information_template),
|
||||
|
|
@ -452,17 +392,14 @@ class GPTAnswerer:
|
|||
chain = prompt | self.llm_cheap | StrOutputParser()
|
||||
output = chain.invoke({"question": question})
|
||||
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)
|
||||
if not match:
|
||||
raise ValueError("Could not extract section name from the response.")
|
||||
|
||||
section_name = match.group(1).lower().replace(" ", "_")
|
||||
section_name = output.lower().replace(" ", "_")
|
||||
if section_name == "cover_letter":
|
||||
chain = chains.get(section_name)
|
||||
output = chain.invoke({"resume": self.resume, "job_description": self.job_description})
|
||||
logger.debug("Cover letter generated: %s", 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:
|
||||
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.")
|
||||
|
|
@ -479,7 +416,9 @@ class GPTAnswerer:
|
|||
func_template = self._preprocess_template_string(strings.numeric_question_template)
|
||||
prompt = ChatPromptTemplate.from_template(func_template)
|
||||
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)
|
||||
try:
|
||||
output = self.extract_number_from_string(output_str)
|
||||
|
|
@ -511,10 +450,12 @@ class GPTAnswerer:
|
|||
return best_option
|
||||
|
||||
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)
|
||||
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}
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
from src.utils import logger
|
||||
|
|
@ -13,6 +13,7 @@ class SelfIdentification:
|
|||
disability: str
|
||||
ethnicity: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegalAuthorization:
|
||||
eu_work_authorization: str
|
||||
|
|
@ -24,6 +25,7 @@ class LegalAuthorization:
|
|||
legally_allowed_to_work_in_eu: str
|
||||
requires_eu_sponsorship: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkPreferences:
|
||||
remote_work: str
|
||||
|
|
@ -33,14 +35,17 @@ class WorkPreferences:
|
|||
willing_to_undergo_drug_tests: str
|
||||
willing_to_undergo_background_checks: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Availability:
|
||||
notice_period: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SalaryExpectations:
|
||||
salary_range_usd: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobApplicationProfile:
|
||||
self_identification: SelfIdentification
|
||||
|
|
@ -159,6 +164,7 @@ class JobApplicationProfile:
|
|||
|
||||
def __str__(self):
|
||||
logger.debug("Generating string representation of JobApplicationProfile")
|
||||
|
||||
def format_dataclass(obj):
|
||||
return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import random
|
||||
import time
|
||||
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
||||
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.ui import WebDriverWait
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
|
|
@ -107,7 +108,6 @@ class LinkedInAuthenticator:
|
|||
buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger')
|
||||
logger.debug("Found %d 'Start a post' buttons", len(buttons))
|
||||
|
||||
# Выведем текст всех найденных кнопок в лог для диагностики
|
||||
for i, button in enumerate(buttons):
|
||||
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.")
|
||||
return True
|
||||
|
||||
# Альтернативная проверка авторизации по наличию изображения профиля
|
||||
profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]")
|
||||
if profile_img_elements:
|
||||
logger.info("Profile image found. Assuming user is logged in.")
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class LinkedInBotState:
|
|||
raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.")
|
||||
logger.debug("State validation passed")
|
||||
|
||||
|
||||
class LinkedInBotFacade:
|
||||
def __init__(self, login_component, apply_component):
|
||||
logger.debug("Initializing LinkedInBotFacade")
|
||||
|
|
|
|||
|
|
@ -3,26 +3,28 @@ import json
|
|||
import os
|
||||
import random
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from datetime import date
|
||||
from typing import List, Optional, Any, Tuple
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from openai import RateLimitError
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.pdfgen import canvas
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
||||
from selenium.webdriver import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import Select, WebDriverWait
|
||||
from selenium.webdriver import ActionChains
|
||||
|
||||
import src.utils as utils
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
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")
|
||||
if resume_dir is None or not os.path.exists(resume_dir):
|
||||
resume_dir = None
|
||||
|
|
@ -56,11 +58,28 @@ class LinkedInEasyApplier:
|
|||
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}")
|
||||
|
||||
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):
|
||||
logger.debug("Starting job application for job: %s", job)
|
||||
|
||||
# Открываем страницу с вакансией
|
||||
try:
|
||||
self.driver.get(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))
|
||||
raise
|
||||
|
||||
# Добавляем небольшую паузу для загрузки страницы
|
||||
time.sleep(random.uniform(3, 5))
|
||||
self.check_for_premium_redirect(job)
|
||||
|
||||
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")
|
||||
job_description = self._get_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")
|
||||
recruiter_link = self._get_job_recruiter()
|
||||
job.set_recruiter_link(recruiter_link)
|
||||
logger.debug("Recruiter link set: %s", recruiter_link)
|
||||
|
||||
# Действие: нажимаем на кнопку 'Easy Apply'
|
||||
logger.debug("Attempting to click 'Easy Apply' button")
|
||||
actions = ActionChains(self.driver)
|
||||
actions.move_to_element(easy_apply_button).click().perform()
|
||||
logger.debug("'Easy Apply' button clicked successfully")
|
||||
|
||||
# Передача информации о работе для дальнейшей обработки
|
||||
logger.debug("Passing job information to GPT Answerer")
|
||||
self.gpt_answerer.set_job(job)
|
||||
|
||||
# Заполнение формы подачи заявки
|
||||
logger.debug("Filling out application form")
|
||||
self._fill_application_form(job)
|
||||
logger.debug("Job application process completed successfully for job: %s", job)
|
||||
|
||||
except Exception as e:
|
||||
# Захват и логирование полного traceback в случае ошибки
|
||||
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str)
|
||||
|
||||
# Отмена заявки в случае ошибки
|
||||
logger.debug("Discarding application due to failure")
|
||||
self._discard_application()
|
||||
|
||||
# Поднятие исключения с оригинальной ошибкой
|
||||
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")
|
||||
attempt = 0
|
||||
|
||||
# Список методов поиска кнопки
|
||||
search_methods = [
|
||||
{
|
||||
'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")]'
|
||||
},
|
||||
{
|
||||
|
|
@ -137,20 +154,21 @@ class LinkedInEasyApplier:
|
|||
]
|
||||
|
||||
while attempt < 2:
|
||||
|
||||
self.check_for_premium_redirect(job)
|
||||
self._scroll_page()
|
||||
|
||||
for method in search_methods:
|
||||
try:
|
||||
logger.debug(f"Attempting search using {method['description']}")
|
||||
|
||||
# Если метод использует find_elements
|
||||
if method.get('find_elements'):
|
||||
# Поиск всех кнопок "Easy Apply"
|
||||
buttons = self.driver.find_elements(By.XPATH, method['xpath'])
|
||||
if buttons:
|
||||
for index, button in enumerate(buttons):
|
||||
try:
|
||||
# Проверка видимости и кликабельности каждой кнопки
|
||||
|
||||
WebDriverWait(self.driver, 10).until(EC.visibility_of(button))
|
||||
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button))
|
||||
logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click")
|
||||
|
|
@ -160,7 +178,7 @@ class LinkedInEasyApplier:
|
|||
else:
|
||||
raise TimeoutException("No 'Easy Apply' buttons found")
|
||||
else:
|
||||
# Стандартный метод с WebDriverWait для одного элемента
|
||||
|
||||
button = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, method['xpath']))
|
||||
)
|
||||
|
|
@ -172,7 +190,10 @@ class LinkedInEasyApplier:
|
|||
except TimeoutException:
|
||||
logger.warning(f"Timeout during search using {method['description']}")
|
||||
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:
|
||||
logger.debug("Refreshing page to retry finding 'Easy Apply' button")
|
||||
|
|
@ -180,7 +201,6 @@ class LinkedInEasyApplier:
|
|||
time.sleep(random.randint(3, 5))
|
||||
attempt += 1
|
||||
|
||||
# Если не удалось найти кнопку, выводим HTML для отладки
|
||||
page_source = self.driver.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")
|
||||
|
|
@ -189,7 +209,8 @@ class LinkedInEasyApplier:
|
|||
logger.debug("Getting job description")
|
||||
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.move_to_element(see_more_button).click().perform()
|
||||
time.sleep(2)
|
||||
|
|
@ -216,7 +237,8 @@ class LinkedInEasyApplier:
|
|||
)
|
||||
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:
|
||||
recruiter_element = recruiter_elements[0]
|
||||
|
|
@ -289,18 +311,17 @@ class LinkedInEasyApplier:
|
|||
def fill_up(self, job) -> None:
|
||||
logger.debug("Filling up form sections for job: %s", job)
|
||||
|
||||
# Используем WebDriverWait для ожидания элемента с классом 'jobs-easy-apply-content'
|
||||
try:
|
||||
easy_apply_content = WebDriverWait(self.driver, 10).until(
|
||||
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')
|
||||
for element in pb4_elements:
|
||||
self._process_form_element(element, job)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find form elements: {e}")
|
||||
|
||||
def _process_form_element(self, element: WebElement, job) -> None:
|
||||
logger.debug("Processing form element")
|
||||
if self._is_upload_field(element):
|
||||
|
|
@ -308,6 +329,47 @@ class LinkedInEasyApplier:
|
|||
else:
|
||||
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:
|
||||
is_upload = bool(element.find_elements(By.XPATH, ".//input[@type='file']"))
|
||||
logger.debug("Element is upload field: %s", is_upload)
|
||||
|
|
@ -317,7 +379,8 @@ class LinkedInEasyApplier:
|
|||
logger.debug("Handling upload fields")
|
||||
|
||||
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()
|
||||
logger.debug("Clicked 'Show more resumes' button")
|
||||
except NoSuchElementException:
|
||||
|
|
@ -339,105 +402,154 @@ class LinkedInEasyApplier:
|
|||
self._create_and_upload_resume(element, job)
|
||||
elif 'cover' in output:
|
||||
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")
|
||||
|
||||
def _create_and_upload_resume(self, element, job):
|
||||
logger.debug("Starting the process of creating and uploading resume.")
|
||||
folder_path = 'generated_cv'
|
||||
logger.debug("Starting the process of creating and uploading resume.")
|
||||
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:
|
||||
if not os.path.exists(folder_path):
|
||||
logger.debug(f"Creating directory at path: {folder_path}")
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
timestamp = int(time.time())
|
||||
file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf")
|
||||
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:
|
||||
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
|
||||
|
||||
while True:
|
||||
try:
|
||||
timestamp = int(time.time())
|
||||
file_path_pdf = os.path.join(folder_path, f"CV_{timestamp}.pdf")
|
||||
logger.debug(f"Generated file path for resume: {file_path_pdf}")
|
||||
file_size = os.path.getsize(file_path_pdf)
|
||||
max_file_size = 2 * 1024 * 1024 # 2 MB
|
||||
logger.debug(f"Cover letter file size: {file_size} bytes")
|
||||
if file_size > max_file_size:
|
||||
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}")
|
||||
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}")
|
||||
allowed_extensions = {'.pdf', '.doc', '.docx'}
|
||||
file_extension = os.path.splitext(file_path_pdf)[1].lower()
|
||||
logger.debug(f"Cover letter file extension: {file_extension}")
|
||||
if file_extension not in allowed_extensions:
|
||||
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
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
try:
|
||||
|
||||
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:
|
||||
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)
|
||||
logger.debug(f"Uploading cover letter from path: {file_path_pdf}")
|
||||
element.send_keys(os.path.abspath(file_path_pdf))
|
||||
job.cover_letter_path = os.path.abspath(file_path_pdf)
|
||||
time.sleep(2)
|
||||
logger.debug(f"Cover letter created and uploaded successfully: {file_path_pdf}")
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error(f"Cover letter upload failed: {tb_str}")
|
||||
raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
|
||||
|
||||
def _fill_additional_questions(self) -> None:
|
||||
logger.debug("Filling additional questions")
|
||||
|
|
@ -445,7 +557,6 @@ class LinkedInEasyApplier:
|
|||
for section in form_sections:
|
||||
self._process_form_section(section)
|
||||
|
||||
|
||||
def _process_form_section(self, section: WebElement) -> None:
|
||||
logger.debug("Processing form section")
|
||||
if self._handle_terms_of_service(section):
|
||||
|
|
@ -460,13 +571,15 @@ class LinkedInEasyApplier:
|
|||
if self._find_and_handle_date_question(section):
|
||||
logger.debug("Handled date question")
|
||||
return
|
||||
|
||||
if self._find_and_handle_dropdown_question(section):
|
||||
logger.debug("Handled dropdown question")
|
||||
return
|
||||
|
||||
def _handle_terms_of_service(self, element: WebElement) -> bool:
|
||||
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()
|
||||
logger.debug("Clicked terms of service checkbox")
|
||||
return True
|
||||
|
|
@ -502,31 +615,29 @@ class LinkedInEasyApplier:
|
|||
|
||||
if text_fields:
|
||||
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}")
|
||||
|
||||
is_numeric = self._is_numeric_field(text_field)
|
||||
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
|
||||
question_type = 'numeric' if is_numeric else 'textbox'
|
||||
|
||||
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
|
||||
logger.debug(f"Found existing answer in the data: {existing_answer['answer']}")
|
||||
break
|
||||
|
||||
if existing_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)
|
||||
text_field.send_keys(Keys.ARROW_DOWN)
|
||||
|
|
@ -534,9 +645,16 @@ class LinkedInEasyApplier:
|
|||
logger.debug("Selected first option from the dropdown.")
|
||||
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._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)
|
||||
text_field.send_keys(Keys.ARROW_DOWN)
|
||||
|
|
@ -555,7 +673,6 @@ class LinkedInEasyApplier:
|
|||
answer_date = self.gpt_answerer.answer_question_date()
|
||||
answer_text = answer_date.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
existing_answer = None
|
||||
for item in self.all_data:
|
||||
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:
|
||||
try:
|
||||
|
||||
question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element')
|
||||
question_text = question.find_element(By.TAG_NAME, 'label').text.lower()
|
||||
logger.debug(f"Processing dropdown or combobox question: {question_text}")
|
||||
|
||||
try:
|
||||
dropdown = question.find_element(By.TAG_NAME, 'select')
|
||||
dropdowns = question.find_elements(By.TAG_NAME, 'select')
|
||||
if dropdowns:
|
||||
dropdown = dropdowns[0]
|
||||
select = Select(dropdown)
|
||||
options = [option.text for option in select.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
|
||||
for item in self.all_data:
|
||||
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
|
||||
existing_answer = item
|
||||
existing_answer = item['answer']
|
||||
break
|
||||
|
||||
if existing_answer:
|
||||
self._select_dropdown_option(dropdown, existing_answer['answer'])
|
||||
logger.debug("Selected existing dropdown answer")
|
||||
logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}")
|
||||
if current_selection != existing_answer:
|
||||
logger.debug(f"Updating selection to: {existing_answer}")
|
||||
self._select_dropdown_option(dropdown, existing_answer)
|
||||
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)
|
||||
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer})
|
||||
self._select_dropdown_option(dropdown, answer)
|
||||
logger.debug("Selected new dropdown 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")
|
||||
logger.debug(f"Selected new dropdown answer: {answer}")
|
||||
return True
|
||||
|
||||
return False
|
||||
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
|
||||
|
||||
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)
|
||||
raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}")
|
||||
|
||||
|
||||
def _sanitize_text(self, text: str) -> str:
|
||||
sanitized_text = text.lower().strip().replace('"', '').replace('\\', '')
|
||||
sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',')
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
import src.utils as utils
|
||||
from src.job import Job
|
||||
from src.linkedIn_easy_applier import LinkedInEasyApplier
|
||||
import json
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ class EnvironmentKeys:
|
|||
logger.debug("Read environment key %s as bool: %s", key, value)
|
||||
return value
|
||||
|
||||
|
||||
class LinkedInJobManager:
|
||||
def __init__(self, driver):
|
||||
logger.debug("Initializing LinkedInJobManager")
|
||||
|
|
@ -47,7 +49,6 @@ class LinkedInJobManager:
|
|||
self.title_blacklist = parameters.get('titleBlacklist', []) or []
|
||||
self.positions = parameters.get('positions', [])
|
||||
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.seen_jobs = []
|
||||
resume_path = parameters.get('uploads', {}).get('resume', None)
|
||||
|
|
@ -66,7 +67,8 @@ class LinkedInJobManager:
|
|||
|
||||
def start_applying(self):
|
||||
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))
|
||||
random.shuffle(searches)
|
||||
page_sleep = 0
|
||||
|
|
@ -134,9 +136,13 @@ class LinkedInJobManager:
|
|||
time.sleep(sleep_time)
|
||||
page_sleep += 1
|
||||
|
||||
|
||||
def get_jobs_from_page(self):
|
||||
"""
|
||||
Функция для получения списка вакансий на текущей странице.
|
||||
Если вакансии не найдены, возвращает пустой список.
|
||||
"""
|
||||
try:
|
||||
|
||||
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():
|
||||
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, 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:
|
||||
utils.printyellow("No job class elements found on page.")
|
||||
logger.debug("No job class elements found on page, skipping.")
|
||||
|
|
@ -180,7 +187,8 @@ class LinkedInJobManager:
|
|||
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, 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:
|
||||
utils.printyellow("No job class elements found on page, moving to next page.")
|
||||
logger.debug("No job class elements found on page, skipping")
|
||||
|
|
@ -192,12 +200,6 @@ class LinkedInJobManager:
|
|||
logger.debug("Job blacklisted: %s at %s", job.title, job.company)
|
||||
self.write_to_file(job, "skipped")
|
||||
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:
|
||||
if job.apply_method not in {"Continue", "Applied", "Apply"}:
|
||||
self.easy_applier_component.job_apply(job)
|
||||
|
|
@ -244,7 +246,8 @@ class LinkedInJobManager:
|
|||
url_parts = []
|
||||
if parameters['remote']:
|
||||
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:
|
||||
url_parts.append(f"f_E={','.join(experience_levels)}")
|
||||
url_parts.append(f"distance={parameters['distance']}")
|
||||
|
|
@ -266,7 +269,8 @@ class LinkedInJobManager:
|
|||
|
||||
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)
|
||||
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):
|
||||
logger.debug("Extracting job information from tile")
|
||||
|
|
@ -287,7 +291,7 @@ class LinkedInJobManager:
|
|||
try:
|
||||
apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text
|
||||
except NoSuchElementException:
|
||||
apply_method = "Applied" # Подразумеваем, что вакансия уже подана
|
||||
apply_method = "Applied"
|
||||
utils.printyellow("Apply method not found, assuming 'Applied'.")
|
||||
logger.warning("Apply method not found, assuming 'Applied'.")
|
||||
|
||||
|
|
@ -299,33 +303,6 @@ class LinkedInJobManager:
|
|||
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)
|
||||
link_seen = link in self.seen_jobs
|
||||
|
||||
is_blacklisted = title_blacklisted or company_blacklisted or link_seen
|
||||
logger.debug("Job blacklisted status: %s", 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
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ Answer the following question based on the provided language skills.
|
|||
- 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 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
|
||||
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"""
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -371,7 +370,6 @@ Options: [1-2, 3-5, 6-10, 10+]
|
|||
|
||||
## """
|
||||
|
||||
|
||||
try_to_fix_template = """\
|
||||
The objective is to fix the text of a form input on a web page.
|
||||
|
||||
|
|
|
|||
89
src/utils.py
89
src/utils.py
|
|
@ -1,23 +1,32 @@
|
|||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
|
||||
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__)
|
||||
|
||||
# Отключаем логирование для selenium и urllib3
|
||||
logging.getLogger("selenium.webdriver.remote.remote_connection").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile")
|
||||
|
||||
|
||||
def ensure_chrome_profile():
|
||||
logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath)
|
||||
profile_dir = os.path.dirname(chromeProfilePath)
|
||||
|
|
@ -29,51 +38,74 @@ def ensure_chrome_profile():
|
|||
logger.debug("Created Chrome profile directory: %s", chromeProfilePath)
|
||||
return chromeProfilePath
|
||||
|
||||
|
||||
def is_scrollable(element):
|
||||
scroll_height = element.get_attribute("scrollHeight")
|
||||
client_height = element.get_attribute("clientHeight")
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if reverse:
|
||||
start, end = end, start
|
||||
step = -step
|
||||
|
||||
if step == 0:
|
||||
logger.error("Step value cannot be zero.")
|
||||
raise ValueError("Step cannot be zero.")
|
||||
|
||||
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("Current scroll position: %d", current_scroll_position)
|
||||
|
||||
if end > max_scroll_height:
|
||||
logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height)
|
||||
end = max_scroll_height
|
||||
if reverse:
|
||||
|
||||
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];"
|
||||
|
||||
try:
|
||||
if scrollable_element.is_displayed():
|
||||
if not is_scrollable(scrollable_element):
|
||||
logger.warning("The element is not scrollable.")
|
||||
print("The element is not scrollable.")
|
||||
return
|
||||
|
||||
if (step > 0 and start >= end) or (step < 0 and start <= end):
|
||||
logger.warning("No scrolling will occur due to incorrect start/end values.")
|
||||
print("No scrolling will occur due to incorrect start/end values.")
|
||||
return
|
||||
for position in range(start, end, step):
|
||||
|
||||
position = start
|
||||
while (step > 0 and position < end) or (step < 0 and position > end):
|
||||
try:
|
||||
driver.execute_script(script_scroll_to, scrollable_element, position)
|
||||
logger.debug("Scrolled to position: %d", position)
|
||||
except Exception as e:
|
||||
logger.error("Error during scrolling: %s", 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)
|
||||
logger.debug("Scrolled to final position: %d", end)
|
||||
time.sleep(1)
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
logger.warning("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)
|
||||
print(f"Exception occurred: {e}")
|
||||
|
||||
def chromeBrowserOptions():
|
||||
|
||||
def chrome_browser_options():
|
||||
logger.debug("Setting Chrome browser options")
|
||||
ensure_chrome_profile()
|
||||
options = webdriver.ChromeOptions()
|
||||
|
|
@ -112,10 +145,10 @@ def chromeBrowserOptions():
|
|||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
if len(chromeProfilePath) > 0:
|
||||
initialPath = os.path.dirname(chromeProfilePath)
|
||||
profileDir = os.path.basename(chromeProfilePath)
|
||||
options.add_argument('--user-data-dir=' + initialPath)
|
||||
options.add_argument("--profile-directory=" + profileDir)
|
||||
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("Using Chrome profile directory: %s", chromeProfilePath)
|
||||
else:
|
||||
options.add_argument("--incognito")
|
||||
|
|
@ -123,14 +156,16 @@ def chromeBrowserOptions():
|
|||
|
||||
return options
|
||||
|
||||
|
||||
def printred(text):
|
||||
RED = "\033[91m"
|
||||
RESET = "\033[0m"
|
||||
red = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
logger.debug("Printing text in red: %s", text)
|
||||
print(f"{RED}{text}{RESET}")
|
||||
print(f"{red}{text}{reset}")
|
||||
|
||||
|
||||
def printyellow(text):
|
||||
YELLOW = "\033[93m"
|
||||
RESET = "\033[0m"
|
||||
yellow = "\033[93m"
|
||||
reset = "\033[0m"
|
||||
logger.debug("Printing text in yellow: %s", text)
|
||||
print(f"{YELLOW}{text}{RESET}")
|
||||
print(f"{yellow}{text}{reset}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue