new func
This commit is contained in:
parent
35cc5d3bde
commit
b6ceeb44ac
8 changed files with 406 additions and 345 deletions
133
src/gpt.py
133
src/gpt.py
|
|
@ -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}
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(',')
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
91
src/utils.py
91
src/utils.py
|
|
@ -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}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue