Merge pull request #366 from azjz92/yaml-updates-local
This commit is contained in:
commit
426c8d017f
34 changed files with 3067 additions and 453 deletions
65
.github/CONTRIBUTING.md
vendored
Normal file
65
.github/CONTRIBUTING.md
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Issues Reporting Guidelines
|
||||
|
||||
Welcome to the LinkedIn Auto Jobs Applier with AI issues page! To keep things organized and ensure issues are resolved quickly, please follow the guidelines below when submitting a bug report, feature request, or any other issue.
|
||||
|
||||
## Before You Submit an Issue
|
||||
|
||||
### 1. Search Existing Issues
|
||||
|
||||
Please search through the existing open issues and closed issues to ensure your issue hasn’t already been reported. This helps avoid duplicates and allows us to focus on unresolved problems.
|
||||
|
||||
### 2. Check Documentation
|
||||
|
||||
Review the README and any available documentation to see if your issue is covered.
|
||||
|
||||
### 3. Provide Detailed Information
|
||||
|
||||
If you are reporting a bug, make sure you include enough details to reproduce the issue. The more information you provide, the faster we can diagnose and fix the problem.
|
||||
|
||||
## Issue Types
|
||||
|
||||
### 1. Bug Reports
|
||||
|
||||
Please include the following information:
|
||||
|
||||
- **Description:** A clear and concise description of the problem.
|
||||
- **Steps to Reproduce:** Provide detailed steps to reproduce the bug.
|
||||
- **Expected Behavior:** What should have happened.
|
||||
- **Actual Behavior:** What actually happened.
|
||||
- **Environment Details:** Include your OS, browser version (if applicable), and any other relevant environment details.
|
||||
- **Logs/Screenshots:** If applicable, attach screenshots or log outputs.
|
||||
|
||||
### 2. Feature Requests
|
||||
|
||||
For new features or improvements:
|
||||
|
||||
- Clearly describe the feature you would like to see.
|
||||
- Explain the problem this feature would solve or the benefit it would bring.
|
||||
- If possible, provide examples or references to similar features in other tools or platforms.
|
||||
|
||||
### 3. Questions/Discussions
|
||||
|
||||
- If you’re unsure whether something is a bug or if you’re seeking clarification on functionality, you can ask a question. Please make sure to label your issue as a question.
|
||||
|
||||
## Issue Labeling and Response Time
|
||||
|
||||
We use the following labels to categorize issues:
|
||||
|
||||
- **bug:** An issue where something isn't functioning as expected.
|
||||
- **documentation:** Improvements or additions to project documentation.
|
||||
- **duplicate:** This issue or pull request already exists elsewhere.
|
||||
- **enhancement:** A request for a new feature or improvement.
|
||||
- **good first issue:** A simple issue suitable for newcomers.
|
||||
- **help wanted:** The issue needs extra attention or assistance.
|
||||
- **invalid:** The issue is not valid or doesn't seem correct.
|
||||
- **question:** Additional information or clarification is needed.
|
||||
- **wontfix:** The issue will not be fixed or addressed.
|
||||
- We aim to respond to issues as early as possible. Please be patient, as maintainers may have limited availability.
|
||||
|
||||
## Contributing Fixes
|
||||
|
||||
If you’re able to contribute a fix for an issue:
|
||||
|
||||
1. Fork the repository and create a new branch for your fix.
|
||||
2. Reference the issue number in your branch and pull request.
|
||||
3. Submit a pull request with a detailed description of the changes and how they resolve the issue.
|
||||
68
.github/ISSUE_TEMPLATE/bug-issue.yml
vendored
Normal file
68
.github/ISSUE_TEMPLATE/bug-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: Bug report
|
||||
description: Report a bug or an issue that isn't working as expected.
|
||||
title: "[BUG]: <Provide a clear, descriptive title>"
|
||||
labels: ["bug"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please fill out the following information to help us resolve the issue.
|
||||
|
||||
- type: input
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: "Describe the bug in detail..."
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
placeholder: "List the steps to reproduce the bug..."
|
||||
|
||||
- type: input
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What you expected to happen.
|
||||
placeholder: "What was the expected result?"
|
||||
|
||||
- type: input
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What actually happened instead.
|
||||
placeholder: "What happened instead?"
|
||||
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Specify the environment where the bug occurred.
|
||||
options:
|
||||
- Production
|
||||
- Development
|
||||
- Staging
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Version of the application where the bug occurred.
|
||||
placeholder: "e.g., 1.0.0"
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: "Any additional information..."
|
||||
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: t.me/AIhawkCommunity
|
||||
about: You can join the discussions on Telegram.
|
||||
- name: New issue
|
||||
url: >-
|
||||
https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/blob/v3/.github/CONTRIBUTING.md
|
||||
about: "Before opening a new issue, please make sure to read CONTRIBUTING.md"
|
||||
39
.github/ISSUE_TEMPLATE/documentation-issue.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/documentation-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Documentation request
|
||||
description: Suggest improvements or additions to the project's documentation.
|
||||
title: "[DOCS]: <Provide a short title>"
|
||||
labels: ["documentation"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping to improve the project's documentation! Please provide the following details to ensure your request is clear.
|
||||
|
||||
- type: input
|
||||
id: doc_section
|
||||
attributes:
|
||||
label: Affected documentation section
|
||||
description: Specify which part of the documentation needs improvement or addition.
|
||||
placeholder: "e.g., Installation Guide, API Reference..."
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Documentation improvement description
|
||||
description: Describe the specific improvements or additions you suggest.
|
||||
placeholder: "Explain what changes you propose and why..."
|
||||
|
||||
- type: input
|
||||
id: reason
|
||||
attributes:
|
||||
label: Why is this change necessary?
|
||||
description: Explain why the documentation needs to be updated or expanded.
|
||||
placeholder: "Describe the issue or gap in the documentation..."
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context, such as related documentation, external resources, or screenshots.
|
||||
placeholder: "Add any other supporting information..."
|
||||
32
.github/ISSUE_TEMPLATE/duplicate-issue.yml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/duplicate-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: Duplicate issue report
|
||||
description: Report an issue or pull request that already exists in the project.
|
||||
title: "[DUPLICATE]: <Provide a brief title>"
|
||||
labels: ["duplicate"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please provide information about the duplicate issue or pull request.
|
||||
|
||||
- type: input
|
||||
id: duplicate_link
|
||||
attributes:
|
||||
label: Link to the original issue/pull request
|
||||
description: Provide the URL of the original issue or pull request that duplicates this one.
|
||||
placeholder: "https://github.com/your-repo/issue/123"
|
||||
|
||||
- type: input
|
||||
id: reason
|
||||
attributes:
|
||||
label: Reason for marking as duplicate
|
||||
description: Explain why this issue is considered a duplicate.
|
||||
placeholder: "Briefly explain why this is a duplicate."
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any additional context or supporting information.
|
||||
placeholder: "Any additional information or comments..."
|
||||
46
.github/ISSUE_TEMPLATE/enhancement-issue.yml
vendored
Normal file
46
.github/ISSUE_TEMPLATE/enhancement-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Feature request
|
||||
description: Suggest a new feature or improvement for the project.
|
||||
title: "[FEATURE]: <Provide a descriptive title>"
|
||||
labels: ["enhancement"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for suggesting a feature! Please fill out the form below to help us understand your idea.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Feature summary
|
||||
description: Provide a short summary of the feature you're requesting.
|
||||
placeholder: "Summarize the feature in a few words..."
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Feature description
|
||||
description: A detailed description of the feature or improvement.
|
||||
placeholder: "Describe the feature in detail..."
|
||||
|
||||
- type: input
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: Explain why this feature would be beneficial and how it solves a problem.
|
||||
placeholder: "Why do you need this feature?"
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: List any alternative solutions or features you've considered.
|
||||
placeholder: "Are there any alternative features or solutions you’ve considered?"
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots to support your feature request.
|
||||
placeholder: "Any additional information..."
|
||||
46
.github/ISSUE_TEMPLATE/goodfirst-issue.yml
vendored
Normal file
46
.github/ISSUE_TEMPLATE/goodfirst-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Good first issue
|
||||
description: Suitable for newcomers or those new to the project.
|
||||
title: "[GOOD FIRST ISSUE]: <Provide a descriptive title>"
|
||||
labels: ["good first issue"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Welcome to contributing to our project! This issue is marked as a "Good First Issue," which means it is a great starting point for new contributors. Please provide the following information to help us understand your issue.
|
||||
|
||||
- type: input
|
||||
id: issue_summary
|
||||
attributes:
|
||||
label: Issue summary
|
||||
description: Provide a brief summary of the issue or task.
|
||||
placeholder: "Summarize the issue or task..."
|
||||
|
||||
- type: textarea
|
||||
id: detailed_description
|
||||
attributes:
|
||||
label: Detailed description
|
||||
description: Provide a detailed description of what needs to be done, including any relevant background information or steps.
|
||||
placeholder: "Describe the issue or task in detail, including any relevant information..."
|
||||
|
||||
- type: input
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce (if applicable)
|
||||
description: If this issue involves a bug, list the steps to reproduce the problem.
|
||||
placeholder: "List the steps to reproduce the issue (if applicable)..."
|
||||
|
||||
- type: input
|
||||
id: expected_outcome
|
||||
attributes:
|
||||
label: Expected outcome
|
||||
description: Describe what you expect to happen once the issue is resolved.
|
||||
placeholder: "Describe the expected outcome..."
|
||||
|
||||
- type: input
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or information that might be helpful for resolving the issue.
|
||||
placeholder: "Any additional information or comments..."
|
||||
39
.github/ISSUE_TEMPLATE/help-issue.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/help-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Help wanted
|
||||
description: Request additional help or attention for an issue that needs extra effort.
|
||||
title: "[HELP WANTED]: <Provide a descriptive title>"
|
||||
labels: ["help wanted"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
We need additional help with this issue. Please provide as much detail as possible to assist contributors.
|
||||
|
||||
- type: textarea
|
||||
id: issue_description
|
||||
attributes:
|
||||
label: Issue description
|
||||
description: Provide a detailed description of the issue and what kind of help is needed.
|
||||
placeholder: "Describe the issue and the type of help required..."
|
||||
|
||||
- type: input
|
||||
id: specific_tasks
|
||||
attributes:
|
||||
label: Specific tasks
|
||||
description: List any specific tasks or sub-tasks where help is needed.
|
||||
placeholder: "List specific tasks or areas where help is needed..."
|
||||
|
||||
- type: input
|
||||
id: additional_resources
|
||||
attributes:
|
||||
label: Additional resources
|
||||
description: Provide links to related documentation, resources, or references that might help contributors.
|
||||
placeholder: "Link to relevant resources or documentation..."
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any extra information or context that might help in addressing the issue.
|
||||
placeholder: "Any additional information or comments..."
|
||||
39
.github/ISSUE_TEMPLATE/invalid-issue.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/invalid-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Invalid issue report
|
||||
description: Report an issue that doesn't seem correct or is invalid.
|
||||
title: "[INVALID]: <Provide a brief title>"
|
||||
labels: ["invalid"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If you've identified an issue that seems incorrect or should not exist, please fill out the form below to provide more details.
|
||||
|
||||
- type: input
|
||||
id: reason
|
||||
attributes:
|
||||
label: Reason for invalidation
|
||||
description: Briefly explain why this issue is considered invalid or incorrect.
|
||||
placeholder: "Why do you think this issue is invalid?"
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to validate
|
||||
description: Provide steps or evidence that confirm the issue is invalid.
|
||||
placeholder: "Explain how you verified this issue is not valid..."
|
||||
|
||||
- type: input
|
||||
id: original_issue
|
||||
attributes:
|
||||
label: Related issue (if applicable)
|
||||
description: Provide a link to the original issue if this is related to an existing one.
|
||||
placeholder: "Link to the related issue (if applicable)"
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Any additional information you think is necessary.
|
||||
placeholder: "Add any other context here..."
|
||||
39
.github/ISSUE_TEMPLATE/question-issue.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/question-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Question or Information Request
|
||||
description: Ask a question or request more information related to the project.
|
||||
title: "[QUESTION]: <Provide a short title>"
|
||||
labels: ["question"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please fill out the form below to ask your question or request further information.
|
||||
|
||||
- type: input
|
||||
id: question_summary
|
||||
attributes:
|
||||
label: Summary of your question
|
||||
description: Provide a brief summary of your question or information request.
|
||||
placeholder: "Summarize your question in a few words..."
|
||||
|
||||
- type: textarea
|
||||
id: question_details
|
||||
attributes:
|
||||
label: Question details
|
||||
description: Provide a detailed explanation of your question or what information you're requesting.
|
||||
placeholder: "Describe your question or information request in detail..."
|
||||
|
||||
- type: input
|
||||
id: context
|
||||
attributes:
|
||||
label: Context for the question
|
||||
description: Provide any relevant context or background information that may help clarify your question.
|
||||
placeholder: "Add context for your question (e.g., where you encountered the issue, what you're trying to do)..."
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any additional information that may help answer your question.
|
||||
placeholder: "Any extra information or comments..."
|
||||
32
.github/ISSUE_TEMPLATE/wontfix-issue.yml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/wontfix-issue.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: Won't fix
|
||||
description: Mark an issue as won't fix if it will not be addressed or resolved.
|
||||
title: "[WONTFIX]: <Provide a brief title>"
|
||||
labels: ["wontfix"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This issue will not be fixed. Please provide reasons or context for why the issue is being closed as won't fix.
|
||||
|
||||
- type: textarea
|
||||
id: reason
|
||||
attributes:
|
||||
label: Reason for won't fix
|
||||
description: Explain why this issue will not be fixed or addressed.
|
||||
placeholder: "Describe the reason why this issue is being marked as won't fix..."
|
||||
|
||||
- type: input
|
||||
id: decision_maker
|
||||
attributes:
|
||||
label: Decision maker
|
||||
description: Specify who made the decision to mark the issue as won't fix.
|
||||
placeholder: "Name of the person or team responsible for this decision..."
|
||||
|
||||
- type: input
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or information relevant to the decision.
|
||||
placeholder: "Any additional information or comments..."
|
||||
168
.gitignore
vendored
168
.gitignore
vendored
|
|
@ -1,15 +1,155 @@
|
|||
*.csv
|
||||
__pycache__/**
|
||||
.idea/**
|
||||
open_ai_calls.log
|
||||
test*
|
||||
openaiSelenium*
|
||||
open_ai_calls.json
|
||||
_*
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv’s dependency resolution may lead to different
|
||||
# Pipfile.lock files generated on each colleague’s machine.
|
||||
# Thus, uncomment the following line if the pipenv environment is expected to be identical
|
||||
# across all environments.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
generated_cv*
|
||||
.vscode
|
||||
chrome_profile
|
||||
answers.json
|
||||
data*
|
||||
*virtual
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# PyCharm and all JetBrains IDEs
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
# Visual Studio 2015/2017/2019/2022
|
||||
.vs/
|
||||
*.opendb
|
||||
*.VC.db
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Mono Auto Generated Files
|
||||
mono_crash.*
|
||||
|
||||
# Project Specific
|
||||
data_folder/output/*
|
||||
generated_cv/*
|
||||
chrome_profile/*
|
||||
answers.json
|
||||
138
README.md
138
README.md
|
|
@ -133,6 +133,11 @@ LinkedIn_AIHawk steps in as a game-changing solution to these challenges. It's n
|
|||
source virtual/bin/activate
|
||||
```
|
||||
|
||||
or for Windows-based machines -
|
||||
```bash
|
||||
.\virtual\Scripts\activate
|
||||
```
|
||||
|
||||
5. **Install the required packages:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
|
@ -148,11 +153,16 @@ This file contains sensitive information. Never share or commit this file to ver
|
|||
- Replace with your LinkedIn account email address
|
||||
- `password: [Your LinkedIn password]`
|
||||
- Replace with your LinkedIn account password
|
||||
- `openai_api_key: [Your OpenAI API key]`
|
||||
- `llm_api_key: [Your OpenAI or Ollama API key or Gemini API key]`
|
||||
- Replace with your OpenAI API key for GPT integration
|
||||
- To obtain an API key, follow the tutorial at: https://medium.com/@lorenzozar/how-to-get-your-own-openai-api-key-f4d44e60c327
|
||||
- Note: You need to add credit to your OpenAI account to use the API. You can add credit by visiting the [OpenAI billing dashboard](https://platform.openai.com/account/billing).
|
||||
|
||||
- According to the [OpenAI community](https://community.openai.com/t/usage-tier-free-to-tier-1/919150) and our users' reports, right after setting up the OpenAI account and purchasing the required credits, users still have a `Free` account type. This prevents them from having unlimited access to OpenAI models and allows only 200 requests per day. This might cause runtime errors such as:
|
||||
`Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. ...}}`
|
||||
`{'error': {'message': 'Rate limit reached for gpt-4o-mini in organization <org> on requests per day (RPD): Limit 200, Used 200, Requested 1.}}`
|
||||
OpenAI will update your account automatically, but it might take some time, ranging from a couple of hours to a few days.
|
||||
You can find more about your organization limits on the [official page](https://platform.openai.com/settings/organization/limits).
|
||||
- For obtaining Gemini API key visit [Google AI for Devs](https://ai.google.dev/gemini-api/docs/api-key)
|
||||
|
||||
|
||||
### 2. config.yaml
|
||||
|
|
@ -189,6 +199,8 @@ This file defines your job search parameters and bot behavior. Each section cont
|
|||
- Italy
|
||||
- London
|
||||
```
|
||||
- `applyOnceAtCompany: [True/False]`
|
||||
- Set if you will apply in more than one opportunity per company
|
||||
|
||||
- `distance: [number]`
|
||||
- Set the radius for your job search in miles
|
||||
|
|
@ -211,7 +223,24 @@ This file defines your job search parameters and bot behavior. Each section cont
|
|||
- Sales
|
||||
- Marketing
|
||||
```
|
||||
#### 2.1 config.yaml - Customize LLM model endpoint
|
||||
|
||||
- `llm_model_type`:
|
||||
- Choose the model type, supported: openai / ollama / claude / gemini
|
||||
- `llm_model`:
|
||||
- Choose the LLM model, currently supported:
|
||||
- openai: gpt-4o
|
||||
- ollama: llama2, mistral:v0.3
|
||||
- claude: any model
|
||||
- gemini: any model
|
||||
- `llm_api_url`:
|
||||
- Link of the API endpoint for the LLM model
|
||||
- openai: https://api.pawan.krd/cosmosrp/v1
|
||||
- ollama: http://127.0.0.1:11434/
|
||||
- claude: https://api.anthropic.com/v1
|
||||
- gemini: no api_url
|
||||
- Note: To run local Ollama, follow the guidelines here: [Guide to Ollama deployment](https://github.com/ollama/ollama)
|
||||
|
||||
### 3. plain_text_resume.yaml
|
||||
|
||||
This file contains your resume information in a structured format. Fill it out with your personal details, education, work experience, and skills. This information is used to auto-fill application forms and generate customized resumes.
|
||||
|
|
@ -455,6 +484,27 @@ Each section has specific fields to fill out:
|
|||
willing_to_undergo_drug_tests: "No"
|
||||
willing_to_undergo_background_checks: "Yes"
|
||||
```
|
||||
### 4. Generating plain_text_resume.yaml from a PDF or Text Resume
|
||||
|
||||
To simplify the process of creating your `plain_text_resume.yaml` file, you can use the provided script to generate it from a pdf-based or text-based resume. Follow these steps:
|
||||
|
||||
1. Prepare your resume in a pdf (.pdf file) or plain text (.txt file) format.
|
||||
|
||||
2. Place your resume in the `data_folder` directory.
|
||||
|
||||
3. Run the following command:
|
||||
|
||||
```bash
|
||||
python generate_resume_yaml.py --input data_folder/your_resume.[pdf|txt] --output data_folder/plain_text_resume.yaml
|
||||
```
|
||||
|
||||
Replace `your_resume.[pdf|txt]` with the actual name of your pdf or text resume file.
|
||||
|
||||
4. The script will generate a `plain_text_resume.yaml` file in the `data_folder` directory.
|
||||
|
||||
5. Review the generated YAML file and make any necessary adjustments to ensure all information is correct and complete.
|
||||
|
||||
This automated process helps in creating a structured YAML file from your existing resume, saving time and reducing the chance of errors in manual data entry.
|
||||
|
||||
### PLUS. data_folder_example
|
||||
|
||||
|
|
@ -504,19 +554,83 @@ Using this folder as a guide can be particularly helpful for:
|
|||
python main.py --resume /path/to/your/resume.pdf
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
TODO ):
|
||||
### Troubleshooting Common Issues
|
||||
|
||||
#### 1. OpenAI API Rate Limit Errors
|
||||
|
||||
**Error Message:**
|
||||
|
||||
openai.RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}}
|
||||
|
||||
**Solution:**
|
||||
- Check your OpenAI API billing settings at https://platform.openai.com/account/billing
|
||||
- Ensure you have added a valid payment method to your OpenAI account
|
||||
- Note that ChatGPT Plus subscription is different from API access
|
||||
- If you've recently added funds or upgraded, wait 12-24 hours for changes to take effect
|
||||
- Free tier has a 3 RPM limit; spend at least $5 on API usage to increase
|
||||
|
||||
#### 2. LinkedIn Easy Apply Button Not Found
|
||||
|
||||
**Error Message:**
|
||||
|
||||
Exception: No clickable 'Easy Apply' button found
|
||||
|
||||
**Solution:**
|
||||
- Ensure that you're logged into LinkedIn properly
|
||||
- Check if the job listings you're targeting actually have the "Easy Apply" option
|
||||
- Verify that your search parameters in the `config.yaml` file are correct and returning jobs with the "Easy Apply" button
|
||||
- Try increasing the wait time for page loading in the script to ensure all elements are loaded before searching for the button
|
||||
|
||||
#### 3. Incorrect Information in Job Applications
|
||||
|
||||
**Issue:** Bot provides inaccurate data for experience, CTC, and notice period
|
||||
|
||||
**Solution:**
|
||||
- Update prompts for professional experience specificity
|
||||
- Add fields in `config.yaml` for current CTC, expected CTC, and notice period
|
||||
- Modify bot logic to use these new config fields
|
||||
|
||||
#### 4. YAML Configuration Errors
|
||||
|
||||
**Error Message:**
|
||||
|
||||
yaml.scanner.ScannerError: while scanning a simple key
|
||||
|
||||
**Solution:**
|
||||
- Copy example `config.yaml` and modify gradually
|
||||
- Ensure proper YAML indentation and spacing
|
||||
- Use a YAML validator tool
|
||||
- Avoid unnecessary special characters or quotes
|
||||
|
||||
#### 5. Bot Logs In But Doesn't Apply to Jobs
|
||||
|
||||
**Issue:** Bot searches for jobs but continues scrolling without applying
|
||||
|
||||
**Solution:**
|
||||
- Check for security checks or CAPTCHAs
|
||||
- Verify `config.yaml` job search parameters
|
||||
- Ensure your LinkedIn profile meets job requirements
|
||||
- Review console output for error messages
|
||||
|
||||
### General Troubleshooting Tips
|
||||
|
||||
- Use the latest version of the script
|
||||
- Verify all dependencies are installed and updated
|
||||
- Check internet connection stability
|
||||
- Use VPNs cautiously to avoid triggering LinkedIn security
|
||||
- Clear browser cache and cookies if issues persist
|
||||
|
||||
For further assistance, please create an issue on the [GitHub repository](https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/issues) with detailed information about your problem, including error messages and your configuration (with sensitive information removed).
|
||||
|
||||
### Additional Resources
|
||||
|
||||
- [Video Tutorial: How to set up LinkedIn_AIHawk](https://youtu.be/gdW9wogHEUM)
|
||||
- [OpenAI API Documentation](https://platform.openai.com/docs/)
|
||||
- [LinkedIn Developer Documentation](https://developer.linkedin.com/)
|
||||
- [Lang Chain Developer Documentation](https://python.langchain.com/v0.2/docs/integrations/components/)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Carefully read logs and output :** Most of the errors are verbosely reflected just watch the output and try to find the root couse.
|
||||
- **If nothing works by unknown reason:** Use tested OS. Reboot and/or update OS. Use new clean venv. Try update Python to the tested version.
|
||||
- **ChromeDriver Issues:** Ensure ChromeDriver is compatible with your installed Chrome version.
|
||||
- **Missing Files:** Verify that all necessary files are present in the data folder.
|
||||
- **Invalid YAML:** Check your YAML files for syntax errors . Try to use external YAML validators e.g. https://www.yamllint.com/
|
||||
- **OpenAI endpoint isues**: Try to check possible limits\blocking at their side
|
||||
|
||||
If you encounter any issues, you can open an issue on [GitHub](https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/issues).
|
||||
Please add valuable details to the subject and to the description. If you need new feature then please reflect this.
|
||||
I'll be more than happy to assist you!
|
||||
|
|
|
|||
132
assets/resume_schema.yaml
Normal file
132
assets/resume_schema.yaml
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# YAML Schema for plain_text_resume.yaml
|
||||
|
||||
personal_information:
|
||||
type: object
|
||||
properties:
|
||||
name: {type: string}
|
||||
surname: {type: string}
|
||||
date_of_birth: {type: string, format: date}
|
||||
country: {type: string}
|
||||
city: {type: string}
|
||||
address: {type: string}
|
||||
phone_prefix: {type: string, format: phone_prefix}
|
||||
phone: {type: string, format: phone}
|
||||
email: {type: string, format: email}
|
||||
github: {type: string, format: uri}
|
||||
linkedin: {type: string, format: uri}
|
||||
required: [name, surname, date_of_birth, country, city, address, phone_prefix, phone, email]
|
||||
|
||||
education_details:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
degree: {type: string}
|
||||
university: {type: string}
|
||||
gpa: {type: string}
|
||||
graduation_year: {type: string}
|
||||
field_of_study: {type: string}
|
||||
exam:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
required: [degree, university, gpa, graduation_year, field_of_study]
|
||||
|
||||
experience_details:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
position: {type: string}
|
||||
company: {type: string}
|
||||
employment_period: {type: string}
|
||||
location: {type: string}
|
||||
industry: {type: string}
|
||||
key_responsibilities:
|
||||
type: object
|
||||
additionalProperties: {type: string}
|
||||
skills_acquired:
|
||||
type: array
|
||||
items: {type: string}
|
||||
required: [position, company, employment_period, location, industry, key_responsibilities, skills_acquired]
|
||||
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: {type: string}
|
||||
description: {type: string}
|
||||
link: {type: string, format: uri}
|
||||
required: [name, description]
|
||||
|
||||
achievements:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: {type: string}
|
||||
description: {type: string}
|
||||
required: [name, description]
|
||||
|
||||
certifications:
|
||||
type: array
|
||||
items: {type: string}
|
||||
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
language: {type: string}
|
||||
proficiency: {type: string, enum: [Native, Fluent, Intermediate, Beginner]}
|
||||
required: [language, proficiency]
|
||||
|
||||
interests:
|
||||
type: array
|
||||
items: {type: string}
|
||||
|
||||
availability:
|
||||
type: object
|
||||
properties:
|
||||
notice_period: {type: string}
|
||||
required: [notice_period]
|
||||
|
||||
salary_expectations:
|
||||
type: object
|
||||
properties:
|
||||
salary_range_usd: {type: string}
|
||||
required: [salary_range_usd]
|
||||
|
||||
self_identification:
|
||||
type: object
|
||||
properties:
|
||||
gender: {type: string}
|
||||
pronouns: {type: string}
|
||||
veteran: {type: string, enum: [Yes, No]}
|
||||
disability: {type: string, enum: [Yes, No]}
|
||||
ethnicity: {type: string}
|
||||
required: [gender, pronouns, veteran, disability, ethnicity]
|
||||
|
||||
legal_authorization:
|
||||
type: object
|
||||
properties:
|
||||
eu_work_authorization: {type: string, enum: [Yes, No]}
|
||||
us_work_authorization: {type: string, enum: [Yes, No]}
|
||||
requires_us_visa: {type: string, enum: [Yes, No]}
|
||||
requires_us_sponsorship: {type: string, enum: [Yes, No]}
|
||||
requires_eu_visa: {type: string, enum: [Yes, No]}
|
||||
legally_allowed_to_work_in_eu: {type: string, enum: [Yes, No]}
|
||||
legally_allowed_to_work_in_us: {type: string, enum: [Yes, No]}
|
||||
requires_eu_sponsorship: {type: string, enum: [Yes, No]}
|
||||
required: [eu_work_authorization, us_work_authorization, requires_us_visa, requires_us_sponsorship, requires_eu_visa, legally_allowed_to_work_in_eu, legally_allowed_to_work_in_us, requires_eu_sponsorship]
|
||||
|
||||
work_preferences:
|
||||
type: object
|
||||
properties:
|
||||
remote_work: {type: string, enum: [Yes, No]}
|
||||
in_person_work: {type: string, enum: [Yes, No]}
|
||||
open_to_relocation: {type: string, enum: [Yes, No]}
|
||||
willing_to_complete_assessments: {type: string, enum: [Yes, No]}
|
||||
willing_to_undergo_drug_tests: {type: string, enum: [Yes, No]}
|
||||
willing_to_undergo_background_checks: {type: string, enum: [Yes, No]}
|
||||
required: [remote_work, in_person_work, open_to_relocation, willing_to_complete_assessments, willing_to_undergo_drug_tests, willing_to_undergo_background_checks]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
remote: [true/false]
|
||||
|
||||
experienceLevel:
|
||||
experience_level:
|
||||
internship: [true/false]
|
||||
entry: [true/false]
|
||||
associate: [true/false]
|
||||
|
|
@ -31,12 +31,22 @@ locations:
|
|||
- Country1
|
||||
- Country2
|
||||
|
||||
apply_once_at_company: [true/false]
|
||||
|
||||
distance: 100
|
||||
|
||||
companyBlacklist:
|
||||
company_blacklist:
|
||||
- Company1
|
||||
- Company2
|
||||
|
||||
titleBlacklist:
|
||||
title_blacklist:
|
||||
- word1
|
||||
- word2
|
||||
- word2
|
||||
|
||||
job_applicants_threshold:
|
||||
min_applicants: 0
|
||||
max_applicants: 100
|
||||
|
||||
llm_model_type: openai
|
||||
llm_model: gpt-4o
|
||||
llm_api_url: https://api.pawan.krd/cosmosrp/v1
|
||||
|
|
@ -116,4 +116,4 @@ work_preferences:
|
|||
open_to_relocation: "[Yes/No]"
|
||||
willing_to_complete_assessments: "[Yes/No]"
|
||||
willing_to_undergo_drug_tests: "[Yes/No]"
|
||||
willing_to_undergo_background_checks: "[Yes/No]"
|
||||
willing_to_undergo_background_checks: "[Yes/No]"
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
email: myemaillinkedin@gmail.com
|
||||
password: ImpossiblePassowrd10
|
||||
openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR
|
||||
llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR'
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
remote: true
|
||||
|
||||
experienceLevel:
|
||||
internship: true
|
||||
internship: false
|
||||
entry: true
|
||||
associate: true
|
||||
mid-senior level: true
|
||||
|
|
@ -19,21 +19,33 @@ jobTypes:
|
|||
|
||||
date:
|
||||
all time: false
|
||||
month: true
|
||||
month: false
|
||||
week: false
|
||||
24 hours: true
|
||||
|
||||
positions:
|
||||
- Software Tester
|
||||
|
||||
- Software engineer or "python"
|
||||
|
||||
locations:
|
||||
- USA
|
||||
- london
|
||||
- copenhagen
|
||||
|
||||
apply_once_at_company: true
|
||||
|
||||
distance: 100
|
||||
|
||||
companyBlacklist:
|
||||
- Noir
|
||||
company_blacklist:
|
||||
- wayfair
|
||||
- Crossover
|
||||
|
||||
titleBlacklist:
|
||||
title_blacklist:
|
||||
- word1
|
||||
- word2
|
||||
|
||||
job_applicants_threshold:
|
||||
min_applicants: 0
|
||||
max_applicants: 30
|
||||
|
||||
llm_model_type: openai
|
||||
llm_model: 'gpt-4o'
|
||||
llm_api_url: https://api.pawan.krd/cosmosrp/v1'
|
||||
|
|
@ -1,133 +1,123 @@
|
|||
personal_information:
|
||||
name: "Giovanni"
|
||||
surname: "Bianchi"
|
||||
date_of_birth: "12/02/1988"
|
||||
country: "Italy"
|
||||
city: "Rome"
|
||||
address: "Via Nazionale, 45"
|
||||
phone_prefix: "+39"
|
||||
phone: "3345678901"
|
||||
email: "giovanni.bianchi@example.com"
|
||||
github: "https://github.com/giovanni-bianchi"
|
||||
linkedin: "https://www.linkedin.com/in/giovanni-bianchi/"
|
||||
name: "solid"
|
||||
surname: "snake"
|
||||
date_of_birth: "12/01/1861"
|
||||
country: "Ireland"
|
||||
city: "Dublin"
|
||||
address: "12 Fox road"
|
||||
phone_prefix: "+1"
|
||||
phone: "7819117091"
|
||||
email: "hi@gmail.com"
|
||||
github: "https://github.com/lol"
|
||||
linkedin: "https://www.linkedin.com/in/thezucc/"
|
||||
|
||||
|
||||
education_details:
|
||||
- education_level: "Master's Degree"
|
||||
institution: "University of Rome"
|
||||
field_of_study: "Computer Engineering"
|
||||
final_evaluation_grade: "110/110"
|
||||
start_date: "2011"
|
||||
year_of_completion: "2013"
|
||||
exam:
|
||||
Computer Networks: "30/30"
|
||||
Advanced Algorithms: "30/30"
|
||||
Database Systems: "30/30"
|
||||
Embedded Systems: "30/30"
|
||||
Artificial Intelligence: "30/30"
|
||||
institution: "Bob academy"
|
||||
field_of_study: "Bobs Engineering"
|
||||
final_evaluation_grade: "4.0"
|
||||
year_of_completion: "2023"
|
||||
start_date: "2022"
|
||||
additional_info:
|
||||
exam:
|
||||
Algorithms: "A"
|
||||
Linear Algebra: "A"
|
||||
Database Systems: "A"
|
||||
Operating Systems: "A-"
|
||||
Web Development: "A"
|
||||
|
||||
experience_details:
|
||||
- position: "Senior Software Engineer"
|
||||
company: "TechSolutions"
|
||||
employment_period: "01/2018 - Present"
|
||||
location: "Rome, Italy"
|
||||
industry: "Software Development"
|
||||
- position: "X"
|
||||
company: "Y."
|
||||
employment_period: "06/2019 - Present"
|
||||
location: "San Francisco, CA"
|
||||
industry: "Technology"
|
||||
key_responsibilities:
|
||||
- responsibility_1: "Led a team of developers in designing and implementing enterprise software solutions"
|
||||
- responsibility_2: "Architected scalable systems to handle high-volume data processing"
|
||||
- responsibility_3: "Optimized application performance and reduced downtime by 20%"
|
||||
- responsibility: "Developed web applications using React and Node.js"
|
||||
- responsibility: "Collaborated with cross-functional teams to design and implement new features"
|
||||
- responsibility: "Troubleshot and resolved complex software issues"
|
||||
skills_acquired:
|
||||
- "Software architecture"
|
||||
- "Team leadership"
|
||||
- "Performance optimization"
|
||||
|
||||
- "React"
|
||||
- "Node.js"
|
||||
- "Software Troubleshooting"
|
||||
- position: "Software Developer"
|
||||
company: "Innovatech"
|
||||
employment_period: "06/2015 - 12/2017"
|
||||
location: "Milan, Italy"
|
||||
industry: "Technology"
|
||||
key_responsibilities:
|
||||
- responsibility_1: "Developed and maintained web applications using modern technologies"
|
||||
- responsibility_2: "Collaborated with UX/UI designers to enhance user experience"
|
||||
- responsibility_3: "Implemented automated testing procedures to ensure code quality"
|
||||
- responsibility: "Developed and maintained web applications using modern technologies"
|
||||
- responsibility: "Collaborated with UX/UI designers to enhance user experience"
|
||||
- responsibility: "Implemented automated testing procedures to ensure code quality"
|
||||
skills_acquired:
|
||||
- "Web development"
|
||||
- "User experience design"
|
||||
- "Automated testing"
|
||||
|
||||
- position: "Junior Developer"
|
||||
company: "StartUp Hub"
|
||||
employment_period: "01/2014 - 05/2015"
|
||||
location: "Florence, Italy"
|
||||
industry: "Startups"
|
||||
key_responsibilities:
|
||||
- responsibility_1: "Assisted in the development of mobile applications and web platforms"
|
||||
- responsibility_2: "Participated in code reviews and contributed to software design discussions"
|
||||
- responsibility_3: "Resolved bugs and implemented feature enhancements"
|
||||
- responsibility: "Assisted in the development of mobile applications and web platforms"
|
||||
- responsibility: "Participated in code reviews and contributed to software design discussions"
|
||||
- responsibility: "Resolved bugs and implemented feature enhancements"
|
||||
skills_acquired:
|
||||
- "Mobile app development"
|
||||
- "Code reviews"
|
||||
- "Bug fixing"
|
||||
|
||||
projects:
|
||||
- name: "E-Commerce Platform"
|
||||
description: "Developed a scalable e-commerce platform with advanced features like real-time inventory tracking and user analytics"
|
||||
link: "https://github.com/giovanni-bianchi/ecommerce-platform"
|
||||
- name: "Smart Home Automation"
|
||||
description: "Created a smart home automation system integrating various IoT devices for remote control and monitoring"
|
||||
link: "https://github.com/giovanni-bianchi/smart-home-automation"
|
||||
- name: "X"
|
||||
description: "Y blah blah blah "
|
||||
link: "https://github.com/haveagoodday"
|
||||
|
||||
|
||||
|
||||
achievements:
|
||||
- name: "Top Innovator Award"
|
||||
description: "Recognized for innovative solutions and contributions to high-impact projects at TechSolutions"
|
||||
- name: "Best Young Developer"
|
||||
description: "Awarded for outstanding performance and contributions during the first three years at Innovatech"
|
||||
- name: "Employee of the Month"
|
||||
description: "Recognized for exceptional performance and contributions to the team."
|
||||
- name: "Hackathon Winner"
|
||||
description: "Won first place in a national hackathon competition."
|
||||
|
||||
certifications:
|
||||
- name: "Certified Ethical Hacker (CEH)"
|
||||
description: "Certification for expertise in ethical hacking and cybersecurity practices"
|
||||
- name: "AWS Certified DevOps Engineer"
|
||||
description: "Certification for DevOps practices and using AWS for cloud services"
|
||||
- name: "Microsoft Certified: Azure Solutions Architect Expert"
|
||||
description: "Certification for designing and implementing Azure solutions"
|
||||
- name: "Certified Kubernetes Administrator (CKA)"
|
||||
description: "Certification for managing and orchestrating Kubernetes clusters"
|
||||
- name: "Certified Data Privacy Professional (CDPP)"
|
||||
description: "Certification for ensuring data privacy and compliance with regulations"
|
||||
#- "Certified Scrum Master"
|
||||
#- "AWS Certified Solutions Architect"
|
||||
|
||||
languages:
|
||||
- language: "Italian"
|
||||
proficiency: "Native"
|
||||
- language: "English"
|
||||
proficiency: "Fluent"
|
||||
- language: "Spanish"
|
||||
proficiency: "Intermediate"
|
||||
|
||||
interests:
|
||||
- "Cloud Computing"
|
||||
- "Machine Learning"
|
||||
- "Cybersecurity"
|
||||
- "IoT Development"
|
||||
- "Artificial Intelligence"
|
||||
- "Data Privacy"
|
||||
- "Open Source Projects"
|
||||
- "Digital Marketing"
|
||||
- "Entrepreneurship"
|
||||
|
||||
availability:
|
||||
notice_period: "2 months"
|
||||
notice_period: "2 weeks"
|
||||
|
||||
salary_expectations:
|
||||
salary_range_usd: "90000 - 110000"
|
||||
|
||||
self_identification:
|
||||
gender: "Male"
|
||||
pronouns: "He/Him"
|
||||
gender: "Female"
|
||||
pronouns: "She/Her"
|
||||
veteran: "No"
|
||||
disability: "No"
|
||||
ethnicity: "White"
|
||||
ethnicity: "Asian"
|
||||
|
||||
legal_authorization:
|
||||
eu_work_authorization: "Yes"
|
||||
us_work_authorization: "No"
|
||||
requires_us_visa: "Yes"
|
||||
us_work_authorization: "Yes"
|
||||
requires_us_visa: "No"
|
||||
requires_us_sponsorship: "Yes"
|
||||
requires_eu_visa: "No"
|
||||
legally_allowed_to_work_in_eu: "Yes"
|
||||
legally_allowed_to_work_in_us: "No"
|
||||
legally_allowed_to_work_in_us: "Yes"
|
||||
requires_eu_sponsorship: "No"
|
||||
|
||||
work_preferences:
|
||||
|
|
|
|||
55
data_folder_example/resume_liam_murphy.txt
Normal file
55
data_folder_example/resume_liam_murphy.txt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
Liam Murphy
|
||||
Galway, Ireland
|
||||
Email: liam.murphy@gmail.com | LinkedIn: liam-murphy
|
||||
GitHub: liam-murphy | Phone: +353 871234567
|
||||
|
||||
Education
|
||||
Bachelor's Degree in Computer Science
|
||||
National University of Ireland, Galway (GPA: 4/4)
|
||||
Graduation Year: 2020
|
||||
|
||||
Experience
|
||||
Co-Founder & Software Engineer
|
||||
CryptoWave Solutions (03/2021 - Present)
|
||||
Location: Ireland | Industry: Blockchain Technology
|
||||
|
||||
Co-founded and led a startup specializing in app and software development with a focus on blockchain technology
|
||||
Provided blockchain consultations for 10+ companies, enhancing their software capabilities with secure, decentralized solutions
|
||||
Developed blockchain applications, integrated cutting-edge technology to meet client needs and drive industry innovation
|
||||
Research Intern
|
||||
National University of Ireland, Galway (11/2022 - 03/2023)
|
||||
Location: Galway, Ireland | Industry: IoT Security Research
|
||||
|
||||
Conducted in-depth research on IoT security, focusing on binary instrumentation and runtime monitoring
|
||||
Performed in-depth study of the MQTT protocol and Falco
|
||||
Developed multiple software components including MQTT packet analysis library, Falco adapter, and RML monitor in Prolog
|
||||
Authored thesis "Binary Instrumentation for Runtime Monitoring of Internet of Things Systems Using Falco"
|
||||
Software Engineer
|
||||
University Hospital Galway (05/2022 - 11/2022)
|
||||
Location: Galway, Ireland | Industry: Healthcare IT
|
||||
|
||||
Integrated and enforced robust security protocols
|
||||
Developed and maintained a critical software tool for password validation used by over 1,600 employees
|
||||
Played an integral role in the hospital's cybersecurity team
|
||||
Projects
|
||||
JobBot
|
||||
AI-driven tool to automate and personalize job applications on LinkedIn, gained over 3000 stars on GitHub, improving efficiency and reducing application time
|
||||
Link: JobBot
|
||||
|
||||
mqtt-packet-parser
|
||||
Developed a Node.js module for parsing MQTT packets, improved parsing efficiency by 40%
|
||||
Link: mqtt-packet-parser
|
||||
|
||||
Achievements
|
||||
Winner of an Irish public competition - Won first place in a public competition with a perfect score of 70/70, securing a Software Developer position at University Hospital Galway
|
||||
Galway Merit Scholarship - Awarded annually from 2018 to 2020 in recognition of academic excellence and contribution
|
||||
GitHub Recognition - Gained over 3000 stars on GitHub with JobBot project
|
||||
Certifications
|
||||
C1
|
||||
|
||||
Languages
|
||||
English - Native
|
||||
Spanish - Professional
|
||||
Interests
|
||||
Full-Stack Development, Software Architecture, IoT system design and development, Artificial Intelligence, Cloud Technologies
|
||||
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
email: myemaillinkedin@gmail.com
|
||||
password: ImpossiblePassowrd10
|
||||
openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR
|
||||
llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR'
|
||||
21
main.py
21
main.py
|
|
@ -9,7 +9,7 @@ from selenium.webdriver.chrome.service import Service as ChromeService
|
|||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
from selenium.common.exceptions import WebDriverException, TimeoutException
|
||||
from lib_resume_builder_AIHawk import Resume,StyleManager,FacadeManager,ResumeGenerator
|
||||
from src.utils import chromeBrowserOptions
|
||||
from src.utils import chrome_browser_options
|
||||
from src.gpt import GPTAnswerer
|
||||
from src.linkedIn_authenticator import LinkedInAuthenticator
|
||||
from src.linkedIn_bot_facade import LinkedInBotFacade
|
||||
|
|
@ -101,7 +101,7 @@ class ConfigValidator:
|
|||
@staticmethod
|
||||
def validate_secrets(secrets_yaml_path: Path) -> tuple:
|
||||
secrets = ConfigValidator.validate_yaml_file(secrets_yaml_path)
|
||||
mandatory_secrets = ['email', 'password', 'openai_api_key']
|
||||
mandatory_secrets = ['email', 'password']
|
||||
|
||||
for secret in mandatory_secrets:
|
||||
if secret not in secrets:
|
||||
|
|
@ -111,10 +111,7 @@ class ConfigValidator:
|
|||
raise ConfigError(f"Invalid email format in secrets file {secrets_yaml_path}.")
|
||||
if not secrets['password']:
|
||||
raise ConfigError(f"Password cannot be empty in secrets file {secrets_yaml_path}.")
|
||||
if not secrets['openai_api_key']:
|
||||
raise ConfigError(f"OpenAI API key cannot be empty in secrets file {secrets_yaml_path}.")
|
||||
|
||||
return secrets['email'], str(secrets['password']), secrets['openai_api_key']
|
||||
return secrets['email'], str(secrets['password']), secrets['llm_api_key']
|
||||
|
||||
class FileManager:
|
||||
@staticmethod
|
||||
|
|
@ -152,20 +149,20 @@ class FileManager:
|
|||
|
||||
def init_browser() -> webdriver.Chrome:
|
||||
try:
|
||||
options = chromeBrowserOptions()
|
||||
options = chrome_browser_options()
|
||||
service = ChromeService(ChromeDriverManager().install())
|
||||
return webdriver.Chrome(service=service, options=options)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize browser: {str(e)}")
|
||||
|
||||
def create_and_run_bot(email: str, password: str, parameters: dict, openai_api_key: str):
|
||||
def create_and_run_bot(email, password, parameters, llm_api_key):
|
||||
try:
|
||||
style_manager = StyleManager()
|
||||
resume_generator = ResumeGenerator()
|
||||
with open(parameters['uploads']['plainTextResume'], "r", encoding='utf-8') as file:
|
||||
plain_text_resume = file.read()
|
||||
resume_object = Resume(plain_text_resume)
|
||||
resume_generator_manager = FacadeManager(openai_api_key, style_manager, resume_generator, resume_object, Path("data_folder/output"))
|
||||
resume_generator_manager = FacadeManager(llm_api_key, style_manager, resume_generator, resume_object, Path("data_folder/output"))
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
resume_generator_manager.choose_style()
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
|
@ -175,7 +172,7 @@ def create_and_run_bot(email: str, password: str, parameters: dict, openai_api_k
|
|||
browser = init_browser()
|
||||
login_component = LinkedInAuthenticator(browser)
|
||||
apply_component = LinkedInJobManager(browser)
|
||||
gpt_answerer_component = GPTAnswerer(openai_api_key)
|
||||
gpt_answerer_component = GPTAnswerer(parameters, llm_api_key)
|
||||
bot = LinkedInBotFacade(login_component, apply_component)
|
||||
bot.set_secrets(email, password)
|
||||
bot.set_job_application_profile_and_resume(job_application_profile_object, resume_object)
|
||||
|
|
@ -197,12 +194,12 @@ def main(resume: Path = None):
|
|||
secrets_file, config_file, plain_text_resume_file, output_folder = FileManager.validate_data_folder(data_folder)
|
||||
|
||||
parameters = ConfigValidator.validate_config(config_file)
|
||||
email, password, openai_api_key = ConfigValidator.validate_secrets(secrets_file)
|
||||
email, password, llm_api_key = ConfigValidator.validate_secrets(secrets_file)
|
||||
|
||||
parameters['uploads'] = FileManager.file_paths_to_dict(resume, plain_text_resume_file)
|
||||
parameters['outputFileDirectory'] = output_folder
|
||||
|
||||
create_and_run_bot(email, password, parameters, openai_api_key)
|
||||
create_and_run_bot(email, password, parameters, llm_api_key)
|
||||
except ConfigError as ce:
|
||||
print(f"Configuration error: {str(ce)}")
|
||||
print("Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
|
||||
|
|
|
|||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
160
resume_yaml_generator.py
Normal file
160
resume_yaml_generator.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import argparse
|
||||
import yaml
|
||||
from openai import OpenAI
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
import re
|
||||
from jsonschema import validate, ValidationError
|
||||
from pdfminer.high_level import extract_text
|
||||
|
||||
def load_yaml(file_path: str) -> Dict[str, Any]:
|
||||
with open(file_path, 'r') as file:
|
||||
return yaml.safe_load(file)
|
||||
|
||||
def load_resume_text(file_path: str) -> str:
|
||||
with open(file_path, 'r') as file:
|
||||
return file.read()
|
||||
|
||||
def get_api_key() -> str:
|
||||
secrets_path = os.path.join('data_folder', 'secrets.yaml')
|
||||
if not os.path.exists(secrets_path):
|
||||
raise FileNotFoundError(f"Secrets file not found at {secrets_path}")
|
||||
|
||||
secrets = load_yaml(secrets_path)
|
||||
|
||||
if not 'llm_api_key' in secrets:
|
||||
raise KeyError("No key as llm_api_key in the secret.yaml")
|
||||
|
||||
api_key = secrets.get('llm_api_key')
|
||||
if not api_key:
|
||||
raise ValueError("LLM API key not found in secrets.yaml")
|
||||
|
||||
return api_key
|
||||
|
||||
def generate_yaml_from_resume(resume_text: str, schema: Dict[str, Any], api_key: str) -> str:
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
prompt = f"""
|
||||
I'm sending you the content of a text-based resume. Your task is to interpret this content and generate a YAML file that conforms to the following schema structure.
|
||||
The generated YAML should include all required fields and follow the structure defined in the schema.
|
||||
|
||||
Pay special attention to the property attributes in the schema. These indicate the expected type and format for each field:
|
||||
- 'type': Specifies the data type (e.g., string, object, array)
|
||||
- 'format': Indicates a specific format for certain fields:
|
||||
- 'date' format should be a valid date (e.g., YYYY-MM-DD)
|
||||
- 'phone_prefix' format should be a valid country code with a '+' prefix (e.g., +1 for US)
|
||||
- 'phone' format should be a valid phone number
|
||||
- 'email' format should be a valid email address
|
||||
- 'uri' format should be a valid URL
|
||||
- 'enum': Provides a list of allowed values for a field
|
||||
|
||||
Important instructions:
|
||||
1. Ensure that the YAML structure matches exactly with the provided schema. Use a dictionary structure that mirrors the schema.
|
||||
2. For all sections, if information is not explicitly provided in the resume, make a best guess based on the context of the resume. This is CRUCIAL for the following fields:
|
||||
- languages: Infer from the resume content or make an educated guess. Use the 'enum' values for proficiency.
|
||||
- interests: Deduce from the overall resume or related experiences.
|
||||
- availability (notice_period): Provide a reasonable estimate (e.g., "2 weeks" or "1 month").
|
||||
- salary_expectations (salary_range_usd): Estimate based on experience level and industry standards.
|
||||
- self_identification: Make reasonable assumptions based on the resume context. Use 'enum' values where provided.
|
||||
- legal_authorization: Provide plausible values based on the resume information. Use 'Yes' or 'No' as per the 'enum' values.
|
||||
- work_preferences: Infer from job history, skills, and overall resume tone. Use 'Yes' or 'No' as per the 'enum' values.
|
||||
3. For the fields mentioned in point 2, always provide a value. Do not leave them blank or omit them.
|
||||
4. For the 'key_responsibilities' field in 'experience_details', format the responsibilities as follows:
|
||||
responsibility_1: "Description of first responsibility"
|
||||
responsibility_2: "Description of second responsibility"
|
||||
responsibility_3: "Description of third responsibility"
|
||||
responsibility_4: "Description of fourth responsibility"
|
||||
Continue this pattern for all responsibilities listed.
|
||||
5. In the 'experience_details' section, ensure that 'position' comes before 'company' in each entry.
|
||||
6. For the 'skills_acquired' field in 'experience_details', infer relevant skills based on the job responsibilities and industry. Do not leave this field empty.
|
||||
7. Make reasonable inferences for any missing dates, such as date_of_birth or employment dates, ensuring they follow the 'date' format.
|
||||
8. For array types (e.g., education_details, experience_details), ensure to include all required fields for each item as specified in the schema.
|
||||
|
||||
Resume Text Content:
|
||||
{resume_text}
|
||||
|
||||
YAML Schema:
|
||||
{yaml.dump(schema, default_flow_style=False)}
|
||||
|
||||
Generate the YAML content that matches this schema based on the resume content provided, ensuring all format hints are followed and making educated guesses where necessary. Be sure to include best guesses for ALL fields, even if not explicitly mentioned in the resume.
|
||||
Enclose your response in <resume_yaml> tags. Only include the YAML content within these tags, without any additional text or code block markers.
|
||||
"""
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant that generates structured YAML content from resume files, paying close attention to format requirements and schema structure."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.5,
|
||||
)
|
||||
|
||||
yaml_content = response.choices[0].message.content.strip()
|
||||
|
||||
# Extract YAML content from between the tags
|
||||
match = re.search(r'<resume_yaml>(.*?)</resume_yaml>', yaml_content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
else:
|
||||
raise ValueError("YAML content not found in the expected format")
|
||||
|
||||
def save_yaml(data: str, output_file: str):
|
||||
with open(output_file, 'w') as file:
|
||||
file.write(data)
|
||||
|
||||
def validate_yaml(yaml_content: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
yaml_dict = yaml.safe_load(yaml_content)
|
||||
validate(instance=yaml_dict, schema=schema)
|
||||
return {"valid": True, "errors": None}
|
||||
except ValidationError as e:
|
||||
return {"valid": False, "errors": str(e)}
|
||||
|
||||
def generate_report(validation_result: Dict[str, Any], output_file: str):
|
||||
report = f"Validation Report for {output_file}\n"
|
||||
report += "=" * 40 + "\n"
|
||||
if validation_result["valid"]:
|
||||
report += "YAML is valid and conforms to the schema.\n"
|
||||
else:
|
||||
report += "YAML is not valid. Errors:\n"
|
||||
report += validation_result["errors"] + "\n"
|
||||
|
||||
print(report)
|
||||
|
||||
def pdf_to_text(pdf_path: str) -> str:
|
||||
return extract_text(pdf_path)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a resume YAML file from a PDF or text resume using OpenAI API")
|
||||
parser.add_argument("--input", required=True, help="Path to the input resume file (PDF or TXT)")
|
||||
parser.add_argument("--output", default="data_folder/plain_text_resume.yaml", help="Path to the output YAML file")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
api_key = get_api_key()
|
||||
schema = load_yaml("assets/resume_schema.yaml")
|
||||
|
||||
# Check if input is PDF or TXT
|
||||
if args.input.lower().endswith('.pdf'):
|
||||
resume_text = pdf_to_text(args.input)
|
||||
print(f"PDF resume converted to text successfully.")
|
||||
else:
|
||||
resume_text = load_resume_text(args.input)
|
||||
|
||||
generated_yaml = generate_yaml_from_resume(resume_text, schema, api_key)
|
||||
save_yaml(generated_yaml, args.output)
|
||||
|
||||
print(f"Resume YAML generated and saved to {args.output}")
|
||||
|
||||
validation_result = validate_yaml(generated_yaml, schema)
|
||||
if validation_result["valid"]:
|
||||
print("YAML is valid and conforms to the schema.")
|
||||
else:
|
||||
print("YAML is not valid. Errors:")
|
||||
print(validation_result["errors"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
448
src/gpt.py
448
src/gpt.py
|
|
@ -2,168 +2,383 @@ import json
|
|||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from typing import Union
|
||||
|
||||
import httpx
|
||||
from Levenshtein import distance
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.messages.ai import AIMessage
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
from langchain_core.prompt_values import StringPromptValue
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_openai import ChatOpenAI
|
||||
from Levenshtein import distance
|
||||
|
||||
import src.strings as strings
|
||||
from src.utils import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class AIModel(ABC):
|
||||
@abstractmethod
|
||||
def invoke(self, prompt: str) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class OpenAIModel(AIModel):
|
||||
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
|
||||
from langchain_openai import ChatOpenAI
|
||||
self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key,
|
||||
temperature=0.4, base_url=llm_api_url)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
print("invoke in openai")
|
||||
response = self.model.invoke(prompt)
|
||||
return response
|
||||
|
||||
|
||||
class ClaudeModel(AIModel):
|
||||
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
self.model = ChatAnthropic(model=llm_model, api_key=api_key,
|
||||
temperature=0.4, base_url=llm_api_url)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
response = self.model.invoke(prompt)
|
||||
return response
|
||||
|
||||
|
||||
class OllamaModel(AIModel):
|
||||
def __init__(self, api_key: str, llm_model: str, llm_api_url: str):
|
||||
from langchain_ollama import ChatOllama
|
||||
self.model = ChatOllama(model=llm_model, base_url=llm_api_url)
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
response = self.model.invoke(prompt)
|
||||
return response
|
||||
|
||||
|
||||
class GeminiModel(AIModel):
|
||||
def __init__(self, api_key:str, llm_model: str, llm_api_url: str):
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key)
|
||||
|
||||
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)
|
||||
elif llm_model_type == "gemini":
|
||||
return GeminiModel(api_key, llm_model, llm_api_url)
|
||||
else:
|
||||
raise ValueError(f"Unsupported model type: {llm_model_type}")
|
||||
|
||||
def invoke(self, prompt: str) -> str:
|
||||
return self.model.invoke(prompt)
|
||||
|
||||
|
||||
class LLMLogger:
|
||||
|
||||
def __init__(self, llm: ChatOpenAI):
|
||||
|
||||
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]):
|
||||
|
||||
self.llm = llm
|
||||
logger.debug("LLMLogger successfully initialized with LLM: %s", llm)
|
||||
|
||||
@staticmethod
|
||||
def log_request(prompts, parsed_reply: Dict[str, Dict]):
|
||||
calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json")
|
||||
logger.debug("Starting log_request method")
|
||||
logger.debug("Prompts received: %s", prompts)
|
||||
logger.debug("Parsed reply received: %s", parsed_reply)
|
||||
|
||||
try:
|
||||
calls_log = os.path.join(
|
||||
Path("data_folder/output"), "open_ai_calls.json")
|
||||
logger.debug("Logging path determined: %s", calls_log)
|
||||
except Exception as e:
|
||||
logger.error("Error determining the log path: %s", str(e))
|
||||
raise
|
||||
|
||||
if isinstance(prompts, StringPromptValue):
|
||||
logger.debug("Prompts are of type StringPromptValue")
|
||||
prompts = prompts.text
|
||||
logger.debug("Prompts converted to text: %s", prompts)
|
||||
elif isinstance(prompts, Dict):
|
||||
# Convert prompts to a dictionary if they are not in the expected format
|
||||
prompts = {
|
||||
f"prompt_{i+1}": prompt.content
|
||||
for i, prompt in enumerate(prompts.messages)
|
||||
}
|
||||
logger.debug("Prompts are of type Dict")
|
||||
try:
|
||||
prompts = {
|
||||
f"prompt_{i + 1}": prompt.content
|
||||
for i, prompt in enumerate(prompts.messages)
|
||||
}
|
||||
logger.debug("Prompts converted to dictionary: %s", prompts)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error converting prompts to dictionary: %s", str(e))
|
||||
raise
|
||||
else:
|
||||
prompts = {
|
||||
f"prompt_{i+1}": prompt.content
|
||||
for i, prompt in enumerate(prompts.messages)
|
||||
logger.debug(
|
||||
"Prompts are of unknown type, attempting default conversion")
|
||||
try:
|
||||
prompts = {
|
||||
f"prompt_{i + 1}": prompt.content
|
||||
for i, prompt in enumerate(prompts.messages)
|
||||
}
|
||||
logger.debug(
|
||||
"Prompts converted to dictionary using default method: %s", prompts)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error converting prompts using default method: %s", str(e))
|
||||
raise
|
||||
|
||||
try:
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
logger.debug("Current time obtained: %s", current_time)
|
||||
except Exception as e:
|
||||
logger.error("Error obtaining current time: %s", str(e))
|
||||
raise
|
||||
|
||||
try:
|
||||
token_usage = parsed_reply["usage_metadata"]
|
||||
output_tokens = token_usage["output_tokens"]
|
||||
input_tokens = token_usage["input_tokens"]
|
||||
total_tokens = token_usage["total_tokens"]
|
||||
logger.debug("Token usage - Input: %d, Output: %d, Total: %d",
|
||||
input_tokens, output_tokens, total_tokens)
|
||||
except KeyError as e:
|
||||
logger.error("KeyError in parsed_reply structure: %s", str(e))
|
||||
raise
|
||||
|
||||
try:
|
||||
model_name = parsed_reply["response_metadata"]["model_name"]
|
||||
logger.debug("Model name: %s", model_name)
|
||||
except KeyError as e:
|
||||
logger.error("KeyError in response_metadata: %s", str(e))
|
||||
raise
|
||||
|
||||
try:
|
||||
prompt_price_per_token = 0.00000015
|
||||
completion_price_per_token = 0.0000006
|
||||
total_cost = (input_tokens * prompt_price_per_token) + \
|
||||
(output_tokens * completion_price_per_token)
|
||||
logger.debug("Total cost calculated: %f", total_cost)
|
||||
except Exception as e:
|
||||
logger.error("Error calculating total cost: %s", str(e))
|
||||
raise
|
||||
|
||||
try:
|
||||
log_entry = {
|
||||
"model": model_name,
|
||||
"time": current_time,
|
||||
"prompts": prompts,
|
||||
"replies": parsed_reply["content"],
|
||||
"total_tokens": total_tokens,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_cost": total_cost,
|
||||
}
|
||||
logger.debug("Log entry created: %s", log_entry)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
"Error creating log entry: missing key %s in parsed_reply", str(e))
|
||||
raise
|
||||
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Extract token usage details from the response
|
||||
token_usage = parsed_reply["usage_metadata"]
|
||||
output_tokens = token_usage["output_tokens"]
|
||||
input_tokens = token_usage["input_tokens"]
|
||||
total_tokens = token_usage["total_tokens"]
|
||||
|
||||
# Extract model details from the response
|
||||
model_name = parsed_reply["response_metadata"]["model_name"]
|
||||
prompt_price_per_token = 0.00000015
|
||||
completion_price_per_token = 0.0000006
|
||||
|
||||
# Calculate the total cost of the API call
|
||||
total_cost = (input_tokens * prompt_price_per_token) + (
|
||||
output_tokens * completion_price_per_token
|
||||
)
|
||||
|
||||
# Create a log entry with all relevant information
|
||||
log_entry = {
|
||||
"model": model_name,
|
||||
"time": current_time,
|
||||
"prompts": prompts,
|
||||
"replies": parsed_reply["content"], # Response content
|
||||
"total_tokens": total_tokens,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_cost": total_cost,
|
||||
}
|
||||
|
||||
# Write the log entry to the log file in JSON format
|
||||
with open(calls_log, "a", encoding="utf-8") as f:
|
||||
json_string = json.dumps(log_entry, ensure_ascii=False, indent=4)
|
||||
f.write(json_string + "\n")
|
||||
try:
|
||||
with open(calls_log, "a", encoding="utf-8") as f:
|
||||
json_string = json.dumps(
|
||||
log_entry, ensure_ascii=False, indent=4)
|
||||
f.write(json_string + "\n")
|
||||
logger.debug("Log entry written to file: %s", calls_log)
|
||||
except Exception as e:
|
||||
logger.error("Error writing log entry to file: %s", str(e))
|
||||
raise
|
||||
|
||||
|
||||
class LoggerChatModel:
|
||||
|
||||
def __init__(self, llm: ChatOpenAI):
|
||||
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]):
|
||||
self.llm = llm
|
||||
logger.debug(
|
||||
"LoggerChatModel successfully initialized with LLM: %s", llm)
|
||||
|
||||
def __call__(self, messages: List[Dict[str, str]]) -> str:
|
||||
# Call the LLM with the provided messages and log the response.
|
||||
reply = self.llm(messages)
|
||||
parsed_reply = self.parse_llmresult(reply)
|
||||
LLMLogger.log_request(prompts=messages, parsed_reply=parsed_reply)
|
||||
return reply
|
||||
logger.debug("Entering __call__ method with messages: %s", messages)
|
||||
while True:
|
||||
try:
|
||||
logger.debug("Attempting to call the LLM with messages")
|
||||
|
||||
reply = self.llm.invoke(messages)
|
||||
logger.debug("LLM response received: %s", reply)
|
||||
|
||||
parsed_reply = self.parse_llmresult(reply)
|
||||
logger.debug("Parsed LLM reply: %s", parsed_reply)
|
||||
|
||||
LLMLogger.log_request(
|
||||
prompts=messages, parsed_reply=parsed_reply)
|
||||
logger.debug("Request successfully logged")
|
||||
|
||||
return reply
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error("HTTPStatusError encountered: %s", str(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(
|
||||
"Rate limit exceeded. Waiting for %d seconds before retrying (extracted from 'retry-after' header)...",
|
||||
wait_time)
|
||||
time.sleep(wait_time)
|
||||
elif retry_after_ms:
|
||||
wait_time = int(retry_after_ms) / 1000.0
|
||||
logger.warning(
|
||||
"Rate limit exceeded. Waiting for %f seconds before retrying (extracted from 'retry-after-ms' header)...",
|
||||
wait_time)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
wait_time = 30
|
||||
logger.warning(
|
||||
"'retry-after' header not found. Waiting for %d seconds before retrying (default)...",
|
||||
wait_time)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
logger.error("HTTP error occurred with status code: %d, waiting 30 seconds before retrying",
|
||||
e.response.status_code)
|
||||
time.sleep(30)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error occurred: %s", str(e))
|
||||
logger.info(
|
||||
"Waiting for 30 seconds before retrying due to an unexpected error.")
|
||||
time.sleep(30)
|
||||
continue
|
||||
|
||||
def parse_llmresult(self, llmresult: AIMessage) -> Dict[str, Dict]:
|
||||
# Parse the LLM result into a structured format.
|
||||
content = llmresult.content
|
||||
response_metadata = llmresult.response_metadata
|
||||
id_ = llmresult.id
|
||||
usage_metadata = llmresult.usage_metadata
|
||||
parsed_result = {
|
||||
"content": content,
|
||||
"response_metadata": {
|
||||
"model_name": response_metadata.get("model_name", ""),
|
||||
"system_fingerprint": response_metadata.get("system_fingerprint", ""),
|
||||
"finish_reason": response_metadata.get("finish_reason", ""),
|
||||
"logprobs": response_metadata.get("logprobs", None),
|
||||
},
|
||||
"id": id_,
|
||||
"usage_metadata": {
|
||||
"input_tokens": usage_metadata.get("input_tokens", 0),
|
||||
"output_tokens": usage_metadata.get("output_tokens", 0),
|
||||
"total_tokens": usage_metadata.get("total_tokens", 0),
|
||||
},
|
||||
}
|
||||
return parsed_result
|
||||
logger.debug("Parsing LLM result: %s", llmresult)
|
||||
|
||||
try:
|
||||
content = llmresult.content
|
||||
response_metadata = llmresult.response_metadata
|
||||
id_ = llmresult.id
|
||||
usage_metadata = llmresult.usage_metadata
|
||||
|
||||
parsed_result = {
|
||||
"content": content,
|
||||
"response_metadata": {
|
||||
"model_name": response_metadata.get("model_name", ""),
|
||||
"system_fingerprint": response_metadata.get("system_fingerprint", ""),
|
||||
"finish_reason": response_metadata.get("finish_reason", ""),
|
||||
"logprobs": response_metadata.get("logprobs", None),
|
||||
},
|
||||
"id": id_,
|
||||
"usage_metadata": {
|
||||
"input_tokens": usage_metadata.get("input_tokens", 0),
|
||||
"output_tokens": usage_metadata.get("output_tokens", 0),
|
||||
"total_tokens": usage_metadata.get("total_tokens", 0),
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug("Parsed LLM result successfully: %s", parsed_result)
|
||||
return parsed_result
|
||||
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
"KeyError while parsing LLM result: missing key %s", str(e))
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error while parsing LLM result: %s", str(e))
|
||||
raise
|
||||
|
||||
|
||||
class GPTAnswerer:
|
||||
def __init__(self, openai_api_key):
|
||||
self.llm_cheap = LoggerChatModel(
|
||||
ChatOpenAI(model_name="gpt-4o-mini", openai_api_key=openai_api_key, temperature=0.4)
|
||||
)
|
||||
|
||||
def __init__(self, config, llm_api_key):
|
||||
self.ai_adapter = AIAdapter(config, llm_api_key)
|
||||
self.llm_cheap = LoggerChatModel(self.ai_adapter)
|
||||
|
||||
@property
|
||||
def job_description(self):
|
||||
return self.job.description
|
||||
|
||||
@staticmethod
|
||||
def find_best_match(text: str, options: list[str]) -> str:
|
||||
logger.debug(
|
||||
"Finding best match for text: '%s' in options: %s", text, options)
|
||||
distances = [
|
||||
(option, distance(text.lower(), option.lower())) for option in options
|
||||
]
|
||||
best_option = min(distances, key=lambda x: x[1])[0]
|
||||
logger.debug("Best match found: %s", best_option)
|
||||
return best_option
|
||||
|
||||
@staticmethod
|
||||
def _remove_placeholders(text: str) -> str:
|
||||
logger.debug("Removing placeholders from text: %s", text)
|
||||
text = text.replace("PLACEHOLDER", "")
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def _preprocess_template_string(template: str) -> str:
|
||||
# Preprocess a template string to remove unnecessary indentation.
|
||||
logger.debug("Preprocessing template string")
|
||||
return textwrap.dedent(template)
|
||||
|
||||
def set_resume(self, resume):
|
||||
logger.debug("Setting resume: %s", resume)
|
||||
self.resume = resume
|
||||
|
||||
def set_job(self, job):
|
||||
logger.debug("Setting job: %s", job)
|
||||
self.job = job
|
||||
self.job.set_summarize_job_description(self.summarize_job_description(self.job.description))
|
||||
self.job.set_summarize_job_description(
|
||||
self.summarize_job_description(self.job.description))
|
||||
|
||||
def set_job_application_profile(self, job_application_profile):
|
||||
logger.debug("Setting job application profile: %s",
|
||||
job_application_profile)
|
||||
self.job_application_profile = job_application_profile
|
||||
|
||||
|
||||
def summarize_job_description(self, text: str) -> str:
|
||||
logger.debug("Summarizing job description: %s", text)
|
||||
strings.summarize_prompt_template = self._preprocess_template_string(
|
||||
strings.summarize_prompt_template
|
||||
)
|
||||
prompt = ChatPromptTemplate.from_template(strings.summarize_prompt_template)
|
||||
prompt = ChatPromptTemplate.from_template(
|
||||
strings.summarize_prompt_template)
|
||||
chain = prompt | self.llm_cheap | StrOutputParser()
|
||||
output = chain.invoke({"text": text})
|
||||
logger.debug("Summary generated: %s", output)
|
||||
return output
|
||||
|
||||
|
||||
def _create_chain(self, template: str):
|
||||
logger.debug("Creating chain with template: %s", template)
|
||||
prompt = ChatPromptTemplate.from_template(template)
|
||||
return prompt | self.llm_cheap | StrOutputParser()
|
||||
|
||||
|
||||
def answer_question_textual_wide_range(self, question: str) -> str:
|
||||
# Define chains for each section of the resume
|
||||
logger.debug("Answering textual question: %s", question)
|
||||
chains = {
|
||||
"personal_information": self._create_chain(strings.personal_information_template),
|
||||
"self_identification": self._create_chain(strings.self_identification_template),
|
||||
|
|
@ -270,55 +485,94 @@ class GPTAnswerer:
|
|||
prompt = ChatPromptTemplate.from_template(section_prompt)
|
||||
chain = prompt | self.llm_cheap | StrOutputParser()
|
||||
output = chain.invoke({"question": question})
|
||||
section_name = output.lower().replace(" ", "_")
|
||||
|
||||
match = re.search(
|
||||
r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education Details|Experience Details|Projects|Availability|Salary Expectations|Certifications|Languages|Interests|Cover letter)",
|
||||
output, re.IGNORECASE)
|
||||
if not match:
|
||||
raise ValueError(
|
||||
"Could not extract section name from the response.")
|
||||
|
||||
section_name = match.group(1).lower().replace(" ", "_")
|
||||
|
||||
if section_name == "cover_letter":
|
||||
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)
|
||||
return output
|
||||
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, None)
|
||||
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name,
|
||||
None)
|
||||
if resume_section is None:
|
||||
logger.error(
|
||||
"Section '%s' not found in either resume or job_application_profile.", section_name)
|
||||
raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.")
|
||||
chain = chains.get(section_name)
|
||||
if chain is None:
|
||||
logger.error("Chain not defined for section '%s'", section_name)
|
||||
raise ValueError(f"Chain not defined for section '{section_name}'")
|
||||
return chain.invoke({"resume_section": resume_section, "question": question})
|
||||
output = chain.invoke(
|
||||
{"resume_section": resume_section, "question": question})
|
||||
logger.debug("Question answered: %s", output)
|
||||
return output
|
||||
|
||||
def answer_question_numeric(self, question: str, default_experience: int = 3) -> int:
|
||||
func_template = self._preprocess_template_string(strings.numeric_question_template)
|
||||
logger.debug("Answering numeric question: %s", question)
|
||||
func_template = self._preprocess_template_string(
|
||||
strings.numeric_question_template)
|
||||
prompt = ChatPromptTemplate.from_template(func_template)
|
||||
chain = prompt | self.llm_cheap | StrOutputParser()
|
||||
output_str = chain.invoke({"resume_educations": self.resume.education_details,"resume_jobs": self.resume.experience_details,"resume_projects": self.resume.projects , "question": question})
|
||||
output_str = chain.invoke(
|
||||
{"resume_educations": self.resume.education_details, "resume_jobs": self.resume.experience_details,
|
||||
"resume_projects": self.resume.projects, "question": question})
|
||||
logger.debug("Raw output for numeric question: %s", output_str)
|
||||
try:
|
||||
output = self.extract_number_from_string(output_str)
|
||||
logger.debug("Extracted number: %d", output)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Failed to extract number, using default experience: %d", default_experience)
|
||||
output = default_experience
|
||||
return output
|
||||
|
||||
def extract_number_from_string(self, output_str):
|
||||
logger.debug("Extracting number from string: %s", output_str)
|
||||
numbers = re.findall(r"\d+", output_str)
|
||||
if numbers:
|
||||
logger.debug("Numbers found: %s", numbers)
|
||||
return int(numbers[0])
|
||||
else:
|
||||
logger.error("No numbers found in the string")
|
||||
raise ValueError("No numbers found in the string")
|
||||
|
||||
def answer_question_from_options(self, question: str, options: list[str]) -> str:
|
||||
func_template = self._preprocess_template_string(strings.options_template)
|
||||
logger.debug("Answering question from options: %s", question)
|
||||
func_template = self._preprocess_template_string(
|
||||
strings.options_template)
|
||||
prompt = ChatPromptTemplate.from_template(func_template)
|
||||
chain = prompt | self.llm_cheap | StrOutputParser()
|
||||
output_str = chain.invoke({"resume": self.resume, "question": question, "options": options})
|
||||
output_str = chain.invoke(
|
||||
{"resume": self.resume, "question": question, "options": options})
|
||||
logger.debug("Raw output for options question: %s", output_str)
|
||||
best_option = self.find_best_match(output_str, options)
|
||||
logger.debug("Best option determined: %s", best_option)
|
||||
return best_option
|
||||
|
||||
|
||||
def resume_or_cover(self, phrase: str) -> str:
|
||||
# Define the prompt template
|
||||
logger.debug(
|
||||
"Determining if phrase refers to resume or cover letter: %s", phrase)
|
||||
prompt_template = """
|
||||
Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. 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}
|
||||
"""
|
||||
prompt = ChatPromptTemplate.from_template(prompt_template)
|
||||
chain = prompt | self.llm_cheap | StrOutputParser()
|
||||
response = chain.invoke({"phrase": phrase})
|
||||
logger.debug("Response for resume_or_cover: %s", response)
|
||||
if "resume" in response:
|
||||
return "resume"
|
||||
elif "cover" in response:
|
||||
|
|
|
|||
11
src/job.py
11
src/job.py
|
|
@ -1,5 +1,8 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
title: str
|
||||
|
|
@ -13,18 +16,22 @@ class Job:
|
|||
recruiter_link: str = ""
|
||||
|
||||
def set_summarize_job_description(self, summarize_job_description):
|
||||
logger.debug("Setting summarized job description: %s", summarize_job_description)
|
||||
self.summarize_job_description = summarize_job_description
|
||||
|
||||
def set_job_description(self, description):
|
||||
logger.debug("Setting job description: %s", description)
|
||||
self.description = description
|
||||
|
||||
def set_recruiter_link(self, recruiter_link):
|
||||
logger.debug("Setting recruiter link: %s", recruiter_link)
|
||||
self.recruiter_link = recruiter_link
|
||||
|
||||
def formatted_job_information(self):
|
||||
"""
|
||||
Formats the job information as a markdown string.
|
||||
"""
|
||||
logger.debug("Formatting job information for job: %s at %s", self.title, self.company)
|
||||
job_information = f"""
|
||||
# Job Description
|
||||
## Job Information
|
||||
|
|
@ -36,4 +43,6 @@ class Job:
|
|||
## Description
|
||||
{self.description or 'No description provided.'}
|
||||
"""
|
||||
return job_information.strip()
|
||||
formatted_information = job_information.strip()
|
||||
logger.debug("Formatted job information: %s", formatted_information)
|
||||
return formatted_information
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelfIdentification:
|
||||
gender: str
|
||||
|
|
@ -10,6 +13,7 @@ class SelfIdentification:
|
|||
disability: str
|
||||
ethnicity: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegalAuthorization:
|
||||
eu_work_authorization: str
|
||||
|
|
@ -21,6 +25,7 @@ class LegalAuthorization:
|
|||
legally_allowed_to_work_in_eu: str
|
||||
requires_eu_sponsorship: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkPreferences:
|
||||
remote_work: str
|
||||
|
|
@ -30,14 +35,17 @@ class WorkPreferences:
|
|||
willing_to_undergo_drug_tests: str
|
||||
willing_to_undergo_background_checks: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Availability:
|
||||
notice_period: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SalaryExpectations:
|
||||
salary_range_usd: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobApplicationProfile:
|
||||
self_identification: SelfIdentification
|
||||
|
|
@ -47,86 +55,123 @@ class JobApplicationProfile:
|
|||
salary_expectations: SalaryExpectations
|
||||
|
||||
def __init__(self, yaml_str: str):
|
||||
logger.debug("Initializing JobApplicationProfile with provided YAML string")
|
||||
try:
|
||||
data = yaml.safe_load(yaml_str)
|
||||
logger.debug("YAML data successfully parsed: %s", data)
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Error parsing YAML file: %s", e)
|
||||
raise ValueError("Error parsing YAML file.") from e
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error occurred while parsing the YAML file: %s", e)
|
||||
raise RuntimeError("An unexpected error occurred while parsing the YAML file.") from e
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.error("YAML data must be a dictionary, received: %s", type(data))
|
||||
raise TypeError("YAML data must be a dictionary.")
|
||||
|
||||
# Process self_identification
|
||||
try:
|
||||
logger.debug("Processing self_identification")
|
||||
self.self_identification = SelfIdentification(**data['self_identification'])
|
||||
logger.debug("self_identification processed: %s", self.self_identification)
|
||||
except KeyError as e:
|
||||
logger.error("Required field %s is missing in self_identification data.", e)
|
||||
raise KeyError(f"Required field {e} is missing in self_identification data.") from e
|
||||
except TypeError as e:
|
||||
logger.error("Error in self_identification data: %s", e)
|
||||
raise TypeError(f"Error in self_identification data: {e}") from e
|
||||
except AttributeError as e:
|
||||
logger.error("Attribute error in self_identification processing: %s", e)
|
||||
raise AttributeError("Attribute error in self_identification processing.") from e
|
||||
except Exception as e:
|
||||
logger.error("An unexpected error occurred while processing self_identification: %s", e)
|
||||
raise RuntimeError("An unexpected error occurred while processing self_identification.") from e
|
||||
|
||||
# Process legal_authorization
|
||||
try:
|
||||
logger.debug("Processing legal_authorization")
|
||||
self.legal_authorization = LegalAuthorization(**data['legal_authorization'])
|
||||
logger.debug("legal_authorization processed: %s", self.legal_authorization)
|
||||
except KeyError as e:
|
||||
logger.error("Required field %s is missing in legal_authorization data.", e)
|
||||
raise KeyError(f"Required field {e} is missing in legal_authorization data.") from e
|
||||
except TypeError as e:
|
||||
logger.error("Error in legal_authorization data: %s", e)
|
||||
raise TypeError(f"Error in legal_authorization data: {e}") from e
|
||||
except AttributeError as e:
|
||||
logger.error("Attribute error in legal_authorization processing: %s", e)
|
||||
raise AttributeError("Attribute error in legal_authorization processing.") from e
|
||||
except Exception as e:
|
||||
logger.error("An unexpected error occurred while processing legal_authorization: %s", e)
|
||||
raise RuntimeError("An unexpected error occurred while processing legal_authorization.") from e
|
||||
|
||||
# Process work_preferences
|
||||
try:
|
||||
logger.debug("Processing work_preferences")
|
||||
self.work_preferences = WorkPreferences(**data['work_preferences'])
|
||||
logger.debug("work_preferences processed: %s", self.work_preferences)
|
||||
except KeyError as e:
|
||||
logger.error("Required field %s is missing in work_preferences data.", e)
|
||||
raise KeyError(f"Required field {e} is missing in work_preferences data.") from e
|
||||
except TypeError as e:
|
||||
logger.error("Error in work_preferences data: %s", e)
|
||||
raise TypeError(f"Error in work_preferences data: {e}") from e
|
||||
except AttributeError as e:
|
||||
logger.error("Attribute error in work_preferences processing: %s", e)
|
||||
raise AttributeError("Attribute error in work_preferences processing.") from e
|
||||
except Exception as e:
|
||||
logger.error("An unexpected error occurred while processing work_preferences: %s", e)
|
||||
raise RuntimeError("An unexpected error occurred while processing work_preferences.") from e
|
||||
|
||||
# Process availability
|
||||
try:
|
||||
logger.debug("Processing availability")
|
||||
self.availability = Availability(**data['availability'])
|
||||
logger.debug("availability processed: %s", self.availability)
|
||||
except KeyError as e:
|
||||
logger.error("Required field %s is missing in availability data.", e)
|
||||
raise KeyError(f"Required field {e} is missing in availability data.") from e
|
||||
except TypeError as e:
|
||||
logger.error("Error in availability data: %s", e)
|
||||
raise TypeError(f"Error in availability data: {e}") from e
|
||||
except AttributeError as e:
|
||||
logger.error("Attribute error in availability processing: %s", e)
|
||||
raise AttributeError("Attribute error in availability processing.") from e
|
||||
except Exception as e:
|
||||
logger.error("An unexpected error occurred while processing availability: %s", e)
|
||||
raise RuntimeError("An unexpected error occurred while processing availability.") from e
|
||||
|
||||
# Process salary_expectations
|
||||
try:
|
||||
logger.debug("Processing salary_expectations")
|
||||
self.salary_expectations = SalaryExpectations(**data['salary_expectations'])
|
||||
logger.debug("salary_expectations processed: %s", self.salary_expectations)
|
||||
except KeyError as e:
|
||||
logger.error("Required field %s is missing in salary_expectations data.", e)
|
||||
raise KeyError(f"Required field {e} is missing in salary_expectations data.") from e
|
||||
except TypeError as e:
|
||||
logger.error("Error in salary_expectations data: %s", e)
|
||||
raise TypeError(f"Error in salary_expectations data: {e}") from e
|
||||
except AttributeError as e:
|
||||
logger.error("Attribute error in salary_expectations processing: %s", e)
|
||||
raise AttributeError("Attribute error in salary_expectations processing.") from e
|
||||
except Exception as e:
|
||||
logger.error("An unexpected error occurred while processing salary_expectations: %s", e)
|
||||
raise RuntimeError("An unexpected error occurred while processing salary_expectations.") from e
|
||||
|
||||
# Process additional fields
|
||||
|
||||
|
||||
logger.debug("JobApplicationProfile initialization completed successfully.")
|
||||
|
||||
def __str__(self):
|
||||
logger.debug("Generating string representation of JobApplicationProfile")
|
||||
|
||||
def format_dataclass(obj):
|
||||
return "\n".join(f"{field.name}: {getattr(obj, field.name)}" for field in obj.__dataclass_fields__.values())
|
||||
|
||||
return (f"Self Identification:\n{format_dataclass(self.self_identification)}\n\n"
|
||||
f"Legal Authorization:\n{format_dataclass(self.legal_authorization)}\n\n"
|
||||
f"Work Preferences:\n{format_dataclass(self.work_preferences)}\n\n"
|
||||
f"Availability: {self.availability.notice_period}\n\n"
|
||||
f"Salary Expectations: {self.salary_expectations.salary_range_usd}\n\n")
|
||||
formatted_str = (f"Self Identification:\n{format_dataclass(self.self_identification)}\n\n"
|
||||
f"Legal Authorization:\n{format_dataclass(self.legal_authorization)}\n\n"
|
||||
f"Work Preferences:\n{format_dataclass(self.work_preferences)}\n\n"
|
||||
f"Availability: {self.availability.notice_period}\n\n"
|
||||
f"Salary Expectations: {self.salary_expectations.salary_range_usd}\n\n")
|
||||
logger.debug("String representation generated: %s", formatted_str)
|
||||
return formatted_str
|
||||
|
|
|
|||
|
|
@ -1,29 +1,43 @@
|
|||
import random
|
||||
import time
|
||||
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
class LinkedInAuthenticator:
|
||||
|
||||
|
||||
def __init__(self, driver=None):
|
||||
self.driver = driver
|
||||
self.email = ""
|
||||
self.password = ""
|
||||
logger.debug("LinkedInAuthenticator initialized with driver: %s", driver)
|
||||
|
||||
def set_secrets(self, email, password):
|
||||
self.email = email
|
||||
self.password = password
|
||||
logger.debug("Secrets set with email: %s", email)
|
||||
|
||||
def start(self):
|
||||
print("Starting Chrome browser to log in to LinkedIn.")
|
||||
self.driver.get('https://www.linkedin.com')
|
||||
logger.info("Starting Chrome browser to log in to LinkedIn.")
|
||||
self.driver.get('https://www.linkedin.com/feed')
|
||||
self.wait_for_page_load()
|
||||
if not self.is_logged_in():
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
if self.is_logged_in():
|
||||
logger.info("User is already logged in. Skipping login process.")
|
||||
return
|
||||
else:
|
||||
logger.info("User is not logged in. Proceeding with login.")
|
||||
self.handle_login()
|
||||
|
||||
def handle_login(self):
|
||||
print("Navigating to the LinkedIn login page...")
|
||||
logger.info("Navigating to the LinkedIn login page...")
|
||||
self.driver.get("https://www.linkedin.com/login")
|
||||
if 'feed' in self.driver.current_url:
|
||||
print("User is already logged in.")
|
||||
|
|
@ -31,60 +45,98 @@ class LinkedInAuthenticator:
|
|||
try:
|
||||
self.enter_credentials()
|
||||
self.submit_login_form()
|
||||
except NoSuchElementException:
|
||||
print("Could not log in to LinkedIn. Please check your credentials.")
|
||||
time.sleep(35) #TODO fix better
|
||||
except NoSuchElementException as e:
|
||||
logger.error("Could not log in to LinkedIn. Element not found: %s", e)
|
||||
time.sleep(random.uniform(3, 5))
|
||||
self.handle_security_check()
|
||||
|
||||
def enter_credentials(self):
|
||||
try:
|
||||
logger.debug("Entering credentials...")
|
||||
email_field = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.ID, "username"))
|
||||
)
|
||||
email_field.send_keys(self.email)
|
||||
logger.debug("Email entered: %s", self.email)
|
||||
password_field = self.driver.find_element(By.ID, "password")
|
||||
password_field.send_keys(self.password)
|
||||
logger.debug("Password entered.")
|
||||
except TimeoutException:
|
||||
logger.error("Login form not found. Aborting login.")
|
||||
print("Login form not found. Aborting login.")
|
||||
|
||||
def submit_login_form(self):
|
||||
try:
|
||||
logger.debug("Submitting login form...")
|
||||
login_button = self.driver.find_element(By.XPATH, '//button[@type="submit"]')
|
||||
login_button.click()
|
||||
logger.debug("Login form submitted.")
|
||||
except NoSuchElementException:
|
||||
logger.error("Login button not found. Please verify the page structure.")
|
||||
print("Login button not found. Please verify the page structure.")
|
||||
|
||||
def handle_security_check(self):
|
||||
try:
|
||||
logger.debug("Handling security check...")
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.url_contains('https://www.linkedin.com/checkpoint/challengesV2/')
|
||||
)
|
||||
logger.warning("Security checkpoint detected. Please complete the challenge.")
|
||||
print("Security checkpoint detected. Please complete the challenge.")
|
||||
WebDriverWait(self.driver, 300).until(
|
||||
EC.url_contains('https://www.linkedin.com/feed/')
|
||||
)
|
||||
logger.info("Security check completed")
|
||||
print("Security check completed")
|
||||
except TimeoutException:
|
||||
logger.error("Security check not completed within the timeout.")
|
||||
print("Security check not completed. Please try again later.")
|
||||
|
||||
def is_logged_in(self):
|
||||
self.driver.get('https://www.linkedin.com/feed')
|
||||
# target_url = 'https://www.linkedin.com/feed'
|
||||
#
|
||||
# # Navigate to the target URL if not already there
|
||||
# if self.driver.current_url != target_url:
|
||||
# logger.debug("Navigating to target URL: %s", target_url)
|
||||
# self.driver.get(target_url)
|
||||
|
||||
try:
|
||||
# Increase the wait time for the page elements to load
|
||||
logger.debug("Checking if user is logged in...")
|
||||
WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, 'share-box-feed-entry__trigger'))
|
||||
)
|
||||
|
||||
# Check for the presence of the "Start a post" button
|
||||
buttons = self.driver.find_elements(By.CLASS_NAME, 'share-box-feed-entry__trigger')
|
||||
if any(button.text.strip() == 'Start a post' for button in buttons):
|
||||
print("User is already logged in.")
|
||||
logger.debug("Found %d 'Start a post' buttons", len(buttons))
|
||||
|
||||
for i, button in enumerate(buttons):
|
||||
logger.debug("Button %d text: %s", i + 1, button.text.strip())
|
||||
|
||||
if any(button.text.strip().lower() == 'start a post' for button in buttons):
|
||||
logger.info("Found 'Start a post' button indicating user is logged in.")
|
||||
return True
|
||||
|
||||
profile_img_elements = self.driver.find_elements(By.XPATH, "//img[contains(@alt, 'Photo of')]")
|
||||
if profile_img_elements:
|
||||
logger.info("Profile image found. Assuming user is logged in.")
|
||||
return True
|
||||
|
||||
logger.info("Did not find 'Start a post' button or profile image. User might not be logged in.")
|
||||
return False
|
||||
|
||||
except TimeoutException:
|
||||
pass
|
||||
return False
|
||||
logger.error("Page elements took too long to load or were not found.")
|
||||
return False
|
||||
|
||||
def wait_for_page_load(self, timeout=10):
|
||||
try:
|
||||
logger.debug("Waiting for page to load with timeout: %s seconds", timeout)
|
||||
WebDriverWait(self.driver, timeout).until(
|
||||
lambda d: d.execute_script('return document.readyState') == 'complete'
|
||||
)
|
||||
logger.debug("Page load completed.")
|
||||
except TimeoutException:
|
||||
logger.error("Page load timed out.")
|
||||
print("Page load timed out.")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
from src.utils import logger
|
||||
|
||||
|
||||
class LinkedInBotState:
|
||||
def __init__(self):
|
||||
logger.debug("Initializing LinkedInBotState")
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
logger.debug("Resetting LinkedInBotState")
|
||||
self.credentials_set = False
|
||||
self.api_key_set = False
|
||||
self.job_application_profile_set = False
|
||||
|
|
@ -11,12 +16,17 @@ class LinkedInBotState:
|
|||
self.logged_in = False
|
||||
|
||||
def validate_state(self, required_keys):
|
||||
logger.debug("Validating LinkedInBotState with required keys: %s", required_keys)
|
||||
for key in required_keys:
|
||||
if not getattr(self, key):
|
||||
logger.error("State validation failed: %s is not set", key)
|
||||
raise ValueError(f"{key.replace('_', ' ').capitalize()} must be set before proceeding.")
|
||||
logger.debug("State validation passed")
|
||||
|
||||
|
||||
class LinkedInBotFacade:
|
||||
def __init__(self, login_component, apply_component):
|
||||
logger.debug("Initializing LinkedInBotFacade")
|
||||
self.login_component = login_component
|
||||
self.apply_component = apply_component
|
||||
self.state = LinkedInBotState()
|
||||
|
|
@ -27,47 +37,65 @@ class LinkedInBotFacade:
|
|||
self.parameters = None
|
||||
|
||||
def set_job_application_profile_and_resume(self, job_application_profile, resume):
|
||||
logger.debug("Setting job application profile and resume")
|
||||
self._validate_non_empty(job_application_profile, "Job application profile")
|
||||
self._validate_non_empty(resume, "Resume")
|
||||
self.job_application_profile = job_application_profile
|
||||
self.resume = resume
|
||||
self.state.job_application_profile_set = True
|
||||
logger.debug("Job application profile and resume set successfully")
|
||||
|
||||
def set_secrets(self, email, password):
|
||||
logger.debug("Setting secrets: email and password")
|
||||
self._validate_non_empty(email, "Email")
|
||||
self._validate_non_empty(password, "Password")
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.state.credentials_set = True
|
||||
logger.debug("Secrets set successfully")
|
||||
|
||||
def set_gpt_answerer_and_resume_generator(self, gpt_answerer_component, resume_generator_manager):
|
||||
logger.debug("Setting GPT answerer and resume generator")
|
||||
self._ensure_job_profile_and_resume_set()
|
||||
gpt_answerer_component.set_job_application_profile(self.job_application_profile)
|
||||
gpt_answerer_component.set_resume(self.resume)
|
||||
self.apply_component.set_gpt_answerer(gpt_answerer_component)
|
||||
self.apply_component.set_resume_generator_manager(resume_generator_manager)
|
||||
self.state.gpt_answerer_set = True
|
||||
logger.debug("GPT answerer and resume generator set successfully")
|
||||
|
||||
def set_parameters(self, parameters):
|
||||
logger.debug("Setting parameters")
|
||||
self._validate_non_empty(parameters, "Parameters")
|
||||
self.parameters = parameters
|
||||
self.apply_component.set_parameters(parameters)
|
||||
self.state.parameters_set = True
|
||||
logger.debug("Parameters set successfully")
|
||||
|
||||
def start_login(self):
|
||||
logger.debug("Starting login process")
|
||||
self.state.validate_state(['credentials_set'])
|
||||
self.login_component.set_secrets(self.email, self.password)
|
||||
self.login_component.start()
|
||||
self.state.logged_in = True
|
||||
logger.debug("Login process completed successfully")
|
||||
|
||||
def start_apply(self):
|
||||
logger.debug("Starting apply process")
|
||||
self.state.validate_state(['logged_in', 'job_application_profile_set', 'gpt_answerer_set', 'parameters_set'])
|
||||
self.apply_component.start_applying()
|
||||
logger.debug("Apply process started successfully")
|
||||
|
||||
def _validate_non_empty(self, value, name):
|
||||
logger.debug("Validating that %s is not empty", name)
|
||||
if not value:
|
||||
logger.error("Validation failed: %s is empty", name)
|
||||
raise ValueError(f"{name} cannot be empty.")
|
||||
logger.debug("Validation passed for %s", name)
|
||||
|
||||
def _ensure_job_profile_and_resume_set(self):
|
||||
logger.debug("Ensuring job profile and resume are set")
|
||||
if not self.state.job_application_profile_set:
|
||||
logger.error("Job application profile and resume are not set")
|
||||
raise ValueError("Job application profile and resume must be set before proceeding.")
|
||||
logger.debug("Job profile and resume are set")
|
||||
|
|
|
|||
|
|
@ -3,24 +3,29 @@ import json
|
|||
import os
|
||||
import random
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from datetime import date
|
||||
from typing import List, Optional, Any, Tuple
|
||||
from reportlab.lib.pagesizes import letter
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
||||
from selenium.webdriver import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import Select, WebDriverWait
|
||||
from selenium.webdriver import ActionChains
|
||||
|
||||
import src.utils as utils
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
class LinkedInEasyApplier:
|
||||
def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]], gpt_answerer: Any, resume_generator_manager):
|
||||
def __init__(self, driver: Any, resume_dir: Optional[str], set_old_answers: List[Tuple[str, str, str]],
|
||||
gpt_answerer: Any, resume_generator_manager):
|
||||
logger.debug("Initializing LinkedInEasyApplier")
|
||||
if resume_dir is None or not os.path.exists(resume_dir):
|
||||
resume_dir = None
|
||||
self.driver = driver
|
||||
|
|
@ -30,106 +35,243 @@ class LinkedInEasyApplier:
|
|||
self.resume_generator_manager = resume_generator_manager
|
||||
self.all_data = self._load_questions_from_json()
|
||||
|
||||
logger.debug("LinkedInEasyApplier initialized successfully")
|
||||
|
||||
def _load_questions_from_json(self) -> List[dict]:
|
||||
output_file = 'answers.json'
|
||||
logger.debug("Loading questions from JSON file: %s", output_file)
|
||||
try:
|
||||
try:
|
||||
with open(output_file, 'r') as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("JSON file format is incorrect. Expected a list of questions.")
|
||||
except json.JSONDecodeError:
|
||||
data = []
|
||||
except FileNotFoundError:
|
||||
data = []
|
||||
with open(output_file, 'r') as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("JSON file format is incorrect. Expected a list of questions.")
|
||||
except json.JSONDecodeError:
|
||||
logger.error("JSON decoding failed")
|
||||
data = []
|
||||
logger.debug("Questions loaded successfully from JSON")
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
logger.warning("JSON file not found, returning empty list")
|
||||
return []
|
||||
except Exception:
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error("Error loading questions data from JSON file: %s", tb_str)
|
||||
raise Exception(f"Error loading questions data from JSON file: \nTraceback:\n{tb_str}")
|
||||
|
||||
def check_for_premium_redirect(self, job: Any, max_attempts=3):
|
||||
|
||||
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):
|
||||
self.driver.get(job.link)
|
||||
time.sleep(random.uniform(3, 5))
|
||||
logger.debug("Starting job application for job: %s", job)
|
||||
|
||||
try:
|
||||
easy_apply_button = self._find_easy_apply_button()
|
||||
job.set_job_description(self._get_job_description())
|
||||
job.set_recruiter_link(self._get_job_recruiter())
|
||||
self.driver.get(job.link)
|
||||
logger.debug("Navigated to job link: %s", job.link)
|
||||
except Exception as e:
|
||||
logger.error("Failed to navigate to job link: %s, error: %s", job.link, str(e))
|
||||
raise
|
||||
|
||||
time.sleep(random.uniform(3, 5))
|
||||
self.check_for_premium_redirect(job)
|
||||
|
||||
try:
|
||||
|
||||
self.driver.execute_script("document.activeElement.blur();")
|
||||
logger.debug("Focus removed from the active element")
|
||||
|
||||
self.check_for_premium_redirect(job)
|
||||
|
||||
easy_apply_button = self._find_easy_apply_button(job)
|
||||
|
||||
self.check_for_premium_redirect(job)
|
||||
|
||||
logger.debug("Retrieving job description")
|
||||
job_description = self._get_job_description()
|
||||
job.set_job_description(job_description)
|
||||
logger.debug("Job description set: %s", job_description[:100])
|
||||
|
||||
logger.debug("Retrieving recruiter link")
|
||||
recruiter_link = self._get_job_recruiter()
|
||||
job.set_recruiter_link(recruiter_link)
|
||||
logger.debug("Recruiter link set: %s", recruiter_link)
|
||||
|
||||
logger.debug("Attempting to click 'Easy Apply' button")
|
||||
actions = ActionChains(self.driver)
|
||||
actions.move_to_element(easy_apply_button).click().perform()
|
||||
self.gpt_answerer.set_job(job)
|
||||
self._fill_application_form(job)
|
||||
except Exception:
|
||||
tb_str = traceback.format_exc()
|
||||
self._discard_application()
|
||||
raise Exception(f"Failed to apply to job! Original exception: \nTraceback:\n{tb_str}")
|
||||
logger.debug("'Easy Apply' button clicked successfully")
|
||||
|
||||
def _find_easy_apply_button(self) -> WebElement:
|
||||
logger.debug("Passing job information to GPT Answerer")
|
||||
self.gpt_answerer.set_job(job)
|
||||
|
||||
logger.debug("Filling out application form")
|
||||
self._fill_application_form(job)
|
||||
logger.debug("Job application process completed successfully for job: %s", job)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error("Failed to apply to job: %s. Error traceback: %s", job, tb_str)
|
||||
|
||||
logger.debug("Discarding application due to failure")
|
||||
self._discard_application()
|
||||
|
||||
raise Exception(f"Failed to apply to job! Original exception:\nTraceback:\n{tb_str}")
|
||||
|
||||
def _find_easy_apply_button(self, job: Any) -> WebElement:
|
||||
logger.debug("Searching for 'Easy Apply' button")
|
||||
attempt = 0
|
||||
|
||||
search_methods = [
|
||||
{
|
||||
'description': "find all 'Easy Apply' buttons using find_elements",
|
||||
'find_elements': True,
|
||||
'xpath': '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]'
|
||||
},
|
||||
{
|
||||
'description': "'aria-label' containing 'Easy Apply to'",
|
||||
'xpath': '//button[contains(@aria-label, "Easy Apply to")]'
|
||||
},
|
||||
{
|
||||
'description': "button text search",
|
||||
'xpath': '//button[contains(text(), "Easy Apply") or contains(text(), "Apply now")]'
|
||||
}
|
||||
]
|
||||
|
||||
while attempt < 2:
|
||||
|
||||
self.check_for_premium_redirect(job)
|
||||
self._scroll_page()
|
||||
buttons = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_all_elements_located(
|
||||
(By.XPATH, '//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")]')
|
||||
)
|
||||
)
|
||||
for index, _ in enumerate(buttons):
|
||||
|
||||
for method in search_methods:
|
||||
try:
|
||||
button = WebDriverWait(self.driver, 10).until(
|
||||
EC.element_to_be_clickable(
|
||||
(By.XPATH, f'(//button[contains(@class, "jobs-apply-button") and contains(., "Easy Apply")])[{index + 1}]')
|
||||
logger.debug(f"Attempting search using {method['description']}")
|
||||
|
||||
if method.get('find_elements'):
|
||||
|
||||
buttons = self.driver.find_elements(By.XPATH, method['xpath'])
|
||||
if buttons:
|
||||
for index, button in enumerate(buttons):
|
||||
try:
|
||||
|
||||
WebDriverWait(self.driver, 10).until(EC.visibility_of(button))
|
||||
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button))
|
||||
logger.debug(f"Found 'Easy Apply' button {index + 1}, attempting to click")
|
||||
return button
|
||||
except Exception as e:
|
||||
logger.warning(f"Button {index + 1} found but not clickable: {e}")
|
||||
else:
|
||||
raise TimeoutException("No 'Easy Apply' buttons found")
|
||||
else:
|
||||
|
||||
button = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, method['xpath']))
|
||||
)
|
||||
)
|
||||
return button
|
||||
WebDriverWait(self.driver, 10).until(EC.visibility_of(button))
|
||||
WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable(button))
|
||||
logger.debug("Found 'Easy Apply' button, attempting to click")
|
||||
return button
|
||||
|
||||
except TimeoutException:
|
||||
logger.warning(f"Timeout during search using {method['description']}")
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.warning(
|
||||
f"Failed to click 'Easy Apply' button using {method['description']} on attempt {attempt + 1}: {e}")
|
||||
|
||||
self.check_for_premium_redirect(job)
|
||||
|
||||
if attempt == 0:
|
||||
logger.debug("Refreshing page to retry finding 'Easy Apply' button")
|
||||
self.driver.refresh()
|
||||
time.sleep(3)
|
||||
time.sleep(random.randint(3, 5))
|
||||
attempt += 1
|
||||
|
||||
page_source = self.driver.page_source
|
||||
logger.error("No clickable 'Easy Apply' button found after 2 attempts. Page source:\n%s", page_source)
|
||||
raise Exception("No clickable 'Easy Apply' button found")
|
||||
|
||||
|
||||
def _get_job_description(self) -> str:
|
||||
logger.debug("Getting job description")
|
||||
try:
|
||||
see_more_button = self.driver.find_element(By.XPATH, '//button[@aria-label="Click to see more description"]')
|
||||
actions = ActionChains(self.driver)
|
||||
actions.move_to_element(see_more_button).click().perform()
|
||||
time.sleep(2)
|
||||
try:
|
||||
see_more_button = self.driver.find_element(By.XPATH,
|
||||
'//button[@aria-label="Click to see more description"]')
|
||||
actions = ActionChains(self.driver)
|
||||
actions.move_to_element(see_more_button).click().perform()
|
||||
time.sleep(2)
|
||||
except NoSuchElementException:
|
||||
logger.debug("See more button not found, skipping")
|
||||
|
||||
description = self.driver.find_element(By.CLASS_NAME, 'jobs-description-content__text').text
|
||||
logger.debug("Job description retrieved successfully")
|
||||
return description
|
||||
except NoSuchElementException:
|
||||
tb_str = traceback.format_exc()
|
||||
raise Exception("Job description 'See more' button not found: \nTraceback:\n{tb_str}")
|
||||
logger.error("Job description not found: %s", tb_str)
|
||||
raise Exception(f"Job description not found: \nTraceback:\n{tb_str}")
|
||||
except Exception:
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error("Error getting Job description: %s", tb_str)
|
||||
raise Exception(f"Error getting Job description: \nTraceback:\n{tb_str}")
|
||||
|
||||
def _get_job_recruiter(self):
|
||||
logger.debug("Getting job recruiter information")
|
||||
try:
|
||||
hiring_team_section = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.XPATH, '//h2[text()="Meet the hiring team"]'))
|
||||
)
|
||||
recruiter_element = hiring_team_section.find_element(By.XPATH, './/following::a[contains(@href, "linkedin.com/in/")]')
|
||||
recruiter_link = recruiter_element.get_attribute('href')
|
||||
return recruiter_link
|
||||
logger.debug("Hiring team section found")
|
||||
|
||||
recruiter_elements = hiring_team_section.find_elements(By.XPATH,
|
||||
'.//following::a[contains(@href, "linkedin.com/in/")]')
|
||||
|
||||
if recruiter_elements:
|
||||
recruiter_element = recruiter_elements[0]
|
||||
recruiter_link = recruiter_element.get_attribute('href')
|
||||
logger.debug("Job recruiter link retrieved successfully: %s", recruiter_link)
|
||||
return recruiter_link
|
||||
else:
|
||||
logger.debug("No recruiter link found in the hiring team section")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning("Failed to retrieve recruiter information: %s", e)
|
||||
return ""
|
||||
|
||||
def _scroll_page(self) -> None:
|
||||
logger.debug("Scrolling the page")
|
||||
scrollable_element = self.driver.find_element(By.TAG_NAME, 'html')
|
||||
utils.scroll_slow(self.driver, scrollable_element, step=300, reverse=False)
|
||||
utils.scroll_slow(self.driver, scrollable_element, step=300, reverse=True)
|
||||
|
||||
def _fill_application_form(self, job):
|
||||
logger.debug("Filling out application form for job: %s", job)
|
||||
while True:
|
||||
self.fill_up(job)
|
||||
if self._next_or_submit():
|
||||
logger.debug("Application form submitted")
|
||||
break
|
||||
|
||||
def _next_or_submit(self):
|
||||
logger.debug("Clicking 'Next' or 'Submit' button")
|
||||
next_button = self.driver.find_element(By.CLASS_NAME, "artdeco-button--primary")
|
||||
button_text = next_button.text.lower()
|
||||
if 'submit application' in button_text:
|
||||
logger.debug("Submit button found, submitting application")
|
||||
self._unfollow_company()
|
||||
time.sleep(random.uniform(1.5, 2.5))
|
||||
next_button.click()
|
||||
|
|
@ -142,104 +284,340 @@ class LinkedInEasyApplier:
|
|||
|
||||
def _unfollow_company(self) -> None:
|
||||
try:
|
||||
logger.debug("Unfollowing company")
|
||||
follow_checkbox = self.driver.find_element(
|
||||
By.XPATH, "//label[contains(.,'to stay up to date with their page.')]")
|
||||
follow_checkbox.click()
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.warning("Failed to unfollow company: %s", e)
|
||||
|
||||
def _check_for_errors(self) -> None:
|
||||
logger.debug("Checking for form errors")
|
||||
error_elements = self.driver.find_elements(By.CLASS_NAME, 'artdeco-inline-feedback--error')
|
||||
if error_elements:
|
||||
logger.error("Form submission failed with errors: %s", [e.text for e in error_elements])
|
||||
raise Exception(f"Failed answering or file upload. {str([e.text for e in error_elements])}")
|
||||
|
||||
def _discard_application(self) -> None:
|
||||
logger.debug("Discarding application")
|
||||
try:
|
||||
self.driver.find_element(By.CLASS_NAME, 'artdeco-modal__dismiss').click()
|
||||
time.sleep(random.uniform(3, 5))
|
||||
self.driver.find_elements(By.CLASS_NAME, 'artdeco-modal__confirm-dialog-btn')[0].click()
|
||||
time.sleep(random.uniform(3, 5))
|
||||
except Exception as e:
|
||||
pass
|
||||
logger.warning("Failed to discard application: %s", e)
|
||||
|
||||
def fill_up(self, job) -> None:
|
||||
easy_apply_content = self.driver.find_element(By.CLASS_NAME, 'jobs-easy-apply-content')
|
||||
pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4')
|
||||
for element in pb4_elements:
|
||||
self._process_form_element(element, job)
|
||||
|
||||
logger.debug("Filling up form sections for job: %s", job)
|
||||
|
||||
try:
|
||||
easy_apply_content = WebDriverWait(self.driver, 10).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, 'jobs-easy-apply-content'))
|
||||
)
|
||||
|
||||
pb4_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'pb4')
|
||||
for element in pb4_elements:
|
||||
self._process_form_element(element, job)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find form elements: {e}")
|
||||
|
||||
def _process_form_element(self, element: WebElement, job) -> None:
|
||||
logger.debug("Processing form element")
|
||||
if self._is_upload_field(element):
|
||||
self._handle_upload_fields(element, job)
|
||||
else:
|
||||
self._fill_additional_questions()
|
||||
|
||||
def _handle_dropdown_fields(self, element: WebElement) -> None:
|
||||
logger.debug("Handling dropdown fields")
|
||||
|
||||
dropdown = element.find_element(By.TAG_NAME, 'select')
|
||||
select = Select(dropdown)
|
||||
|
||||
options = [option.text for option in select.options]
|
||||
logger.debug(f"Dropdown options found: {options}")
|
||||
|
||||
parent_element = dropdown.find_element(By.XPATH, '../..')
|
||||
|
||||
label_elements = parent_element.find_elements(By.TAG_NAME, 'label')
|
||||
if label_elements:
|
||||
question_text = label_elements[0].text.lower()
|
||||
else:
|
||||
question_text = "unknown"
|
||||
|
||||
logger.debug(f"Detected question text: {question_text}")
|
||||
|
||||
existing_answer = None
|
||||
for item in self.all_data:
|
||||
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
|
||||
existing_answer = item['answer']
|
||||
break
|
||||
|
||||
if existing_answer:
|
||||
logger.debug(f"Found existing answer for question '{question_text}': {existing_answer}")
|
||||
else:
|
||||
|
||||
logger.debug(f"No existing answer found, querying model for: {question_text}")
|
||||
existing_answer = self.gpt_answerer.answer_question_from_options(question_text, options)
|
||||
logger.debug(f"Model provided answer: {existing_answer}")
|
||||
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': existing_answer})
|
||||
|
||||
if existing_answer in options:
|
||||
select.select_by_visible_text(existing_answer)
|
||||
logger.debug(f"Selected option: {existing_answer}")
|
||||
else:
|
||||
logger.error(f"Answer '{existing_answer}' is not a valid option in the dropdown")
|
||||
raise Exception(f"Invalid option selected: {existing_answer}")
|
||||
|
||||
def _is_upload_field(self, element: WebElement) -> bool:
|
||||
return 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)
|
||||
return is_upload
|
||||
|
||||
def _handle_upload_fields(self, element: WebElement, job) -> None:
|
||||
logger.debug("Handling upload fields")
|
||||
|
||||
try:
|
||||
show_more_button = self.driver.find_element(By.XPATH,
|
||||
"//button[contains(@aria-label, 'Show more resumes')]")
|
||||
show_more_button.click()
|
||||
logger.debug("Clicked 'Show more resumes' button")
|
||||
except NoSuchElementException:
|
||||
logger.debug("'Show more resumes' button not found, continuing...")
|
||||
|
||||
file_upload_elements = self.driver.find_elements(By.XPATH, "//input[@type='file']")
|
||||
for element in file_upload_elements:
|
||||
parent = element.find_element(By.XPATH, "..")
|
||||
self.driver.execute_script("arguments[0].classList.remove('hidden')", element)
|
||||
|
||||
output = self.gpt_answerer.resume_or_cover(parent.text.lower())
|
||||
if 'resume' in output:
|
||||
logger.debug("Uploading resume")
|
||||
if self.resume_path is not None and self.resume_path.resolve().is_file():
|
||||
element.send_keys(str(self.resume_path.resolve()))
|
||||
logger.debug(f"Resume uploaded from path: {self.resume_path.resolve()}")
|
||||
else:
|
||||
logger.debug("Resume path not found or invalid, generating new resume")
|
||||
self._create_and_upload_resume(element, job)
|
||||
elif 'cover' in output:
|
||||
self._create_and_upload_cover_letter(element)
|
||||
logger.debug("Uploading cover letter")
|
||||
self._create_and_upload_cover_letter(element, job)
|
||||
|
||||
logger.debug("Finished handling upload fields")
|
||||
|
||||
def _create_and_upload_resume(self, element, job):
|
||||
logger.debug("Starting the process of creating and uploading resume.")
|
||||
folder_path = 'generated_cv'
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
|
||||
try:
|
||||
file_path_pdf = os.path.join(folder_path, f"CV_{random.randint(0, 9999)}.pdf")
|
||||
with open(file_path_pdf, "xb") as f:
|
||||
f.write(base64.b64decode(self.resume_generator_manager.pdf_base64(job_description_text=job.description)))
|
||||
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"CV_{timestamp}.pdf")
|
||||
logger.debug(f"Generated file path for resume: {file_path_pdf}")
|
||||
|
||||
logger.debug(f"Generating resume for job: {job.title} at {job.company}")
|
||||
resume_pdf_base64 = self.resume_generator_manager.pdf_base64(job_description_text=job.description)
|
||||
with open(file_path_pdf, "xb") as f:
|
||||
f.write(base64.b64decode(resume_pdf_base64))
|
||||
logger.debug(f"Resume successfully generated and saved to: {file_path_pdf}")
|
||||
|
||||
break
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
|
||||
retry_after = e.response.headers.get('retry-after')
|
||||
retry_after_ms = e.response.headers.get('retry-after-ms')
|
||||
|
||||
if retry_after:
|
||||
wait_time = int(retry_after)
|
||||
logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...")
|
||||
elif retry_after_ms:
|
||||
wait_time = int(retry_after_ms) / 1000.0
|
||||
logger.warning(f"Rate limit exceeded, waiting {wait_time} milliseconds before retrying...")
|
||||
else:
|
||||
wait_time = 20
|
||||
logger.warning(f"Rate limit exceeded, waiting {wait_time} seconds before retrying...")
|
||||
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
logger.error(f"HTTP error: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to 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)
|
||||
except Exception:
|
||||
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:
|
||||
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)
|
||||
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=A4)
|
||||
page_width, page_height = A4
|
||||
text_object = c.beginText(50, page_height - 50)
|
||||
text_object.setFont("Helvetica", 12)
|
||||
|
||||
max_width = page_width - 100
|
||||
bottom_margin = 50
|
||||
available_height = page_height - bottom_margin - 50
|
||||
|
||||
def split_text_by_width(text, font, font_size, max_width):
|
||||
wrapped_lines = []
|
||||
for line in text.splitlines():
|
||||
|
||||
if utils.stringWidth(line, font, font_size) > max_width:
|
||||
words = line.split()
|
||||
new_line = ""
|
||||
for word in words:
|
||||
if utils.stringWidth(new_line + word + " ", font, font_size) <= max_width:
|
||||
new_line += word + " "
|
||||
else:
|
||||
wrapped_lines.append(new_line.strip())
|
||||
new_line = word + " "
|
||||
wrapped_lines.append(new_line.strip())
|
||||
else:
|
||||
wrapped_lines.append(line)
|
||||
return wrapped_lines
|
||||
|
||||
lines = split_text_by_width(cover_letter_text, "Helvetica", 12, max_width)
|
||||
|
||||
for line in lines:
|
||||
text_height = text_object.getY()
|
||||
if text_height > bottom_margin:
|
||||
text_object.textLine(line)
|
||||
else:
|
||||
|
||||
c.drawText(text_object)
|
||||
c.showPage()
|
||||
text_object = c.beginText(50, page_height - 50)
|
||||
text_object.setFont("Helvetica", 12)
|
||||
text_object.textLine(line)
|
||||
|
||||
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
|
||||
|
||||
file_size = os.path.getsize(file_path_pdf)
|
||||
max_file_size = 2 * 1024 * 1024 # 2 MB
|
||||
logger.debug(f"Cover letter file size: {file_size} bytes")
|
||||
if file_size > max_file_size:
|
||||
logger.error(f"Cover letter file size exceeds 2 MB: {file_size} bytes")
|
||||
raise ValueError("Cover letter file size exceeds the maximum limit of 2 MB.")
|
||||
|
||||
allowed_extensions = {'.pdf', '.doc', '.docx'}
|
||||
file_extension = os.path.splitext(file_path_pdf)[1].lower()
|
||||
logger.debug(f"Cover letter file extension: {file_extension}")
|
||||
if file_extension not in allowed_extensions:
|
||||
logger.error(f"Invalid cover letter file format: {file_extension}")
|
||||
raise ValueError("Cover letter file format is not allowed. Only PDF, DOC, and DOCX formats are supported.")
|
||||
|
||||
try:
|
||||
|
||||
logger.debug(f"Uploading cover letter from path: {file_path_pdf}")
|
||||
element.send_keys(os.path.abspath(file_path_pdf))
|
||||
job.cover_letter_path = os.path.abspath(file_path_pdf)
|
||||
time.sleep(2)
|
||||
logger.debug(f"Cover letter created and uploaded successfully: {file_path_pdf}")
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error(f"Cover letter upload failed: {tb_str}")
|
||||
raise Exception(f"Upload failed: \nTraceback:\n{tb_str}")
|
||||
|
||||
def _fill_additional_questions(self) -> None:
|
||||
logger.debug("Filling additional questions")
|
||||
form_sections = self.driver.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping')
|
||||
for section in form_sections:
|
||||
self._process_form_section(section)
|
||||
|
||||
|
||||
def _process_form_section(self, section: WebElement) -> None:
|
||||
logger.debug("Processing form section")
|
||||
if self._handle_terms_of_service(section):
|
||||
logger.debug("Handled terms of service")
|
||||
return
|
||||
if self._find_and_handle_radio_question(section):
|
||||
logger.debug("Handled radio question")
|
||||
return
|
||||
if self._find_and_handle_textbox_question(section):
|
||||
logger.debug("Handled textbox question")
|
||||
return
|
||||
if self._find_and_handle_date_question(section):
|
||||
logger.debug("Handled date question")
|
||||
return
|
||||
|
||||
if self._find_and_handle_dropdown_question(section):
|
||||
logger.debug("Handled dropdown question")
|
||||
return
|
||||
|
||||
def _handle_terms_of_service(self, element: WebElement) -> bool:
|
||||
checkbox = element.find_elements(By.TAG_NAME, 'label')
|
||||
if checkbox and any(term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']):
|
||||
if checkbox and any(
|
||||
term in checkbox[0].text.lower() for term in ['terms of service', 'privacy policy', 'terms of use']):
|
||||
checkbox[0].click()
|
||||
logger.debug("Clicked terms of service checkbox")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -249,39 +627,79 @@ class LinkedInEasyApplier:
|
|||
if radios:
|
||||
question_text = section.text.lower()
|
||||
options = [radio.text.lower() for radio in radios]
|
||||
|
||||
existing_answer = None
|
||||
for item in self.all_data:
|
||||
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'radio':
|
||||
existing_answer = item
|
||||
self._select_radio(radios, existing_answer['answer'])
|
||||
return True
|
||||
|
||||
break
|
||||
if existing_answer:
|
||||
self._select_radio(radios, existing_answer['answer'])
|
||||
logger.debug("Selected existing radio answer")
|
||||
return True
|
||||
|
||||
answer = self.gpt_answerer.answer_question_from_options(question_text, options)
|
||||
self._save_questions_to_json({'type': 'radio', 'question': question_text, 'answer': answer})
|
||||
self._select_radio(radios, answer)
|
||||
logger.debug("Selected new radio answer")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _find_and_handle_textbox_question(self, section: WebElement) -> bool:
|
||||
logger.debug("Searching for text fields in the section.")
|
||||
text_fields = section.find_elements(By.TAG_NAME, 'input') + section.find_elements(By.TAG_NAME, 'textarea')
|
||||
|
||||
if text_fields:
|
||||
text_field = text_fields[0]
|
||||
question_text = section.find_element(By.TAG_NAME, 'label').text.lower()
|
||||
question_text = section.find_element(By.TAG_NAME, 'label').text.lower().strip()
|
||||
logger.debug(f"Found text field with label: {question_text}")
|
||||
|
||||
is_numeric = self._is_numeric_field(text_field)
|
||||
if is_numeric:
|
||||
question_type = 'numeric'
|
||||
answer = self.gpt_answerer.answer_question_numeric(question_text)
|
||||
else:
|
||||
question_type = 'textbox'
|
||||
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
|
||||
logger.debug(f"Is the field numeric? {'Yes' if is_numeric else 'No'}")
|
||||
|
||||
existing_answer = None
|
||||
question_type = 'numeric' if is_numeric else 'textbox'
|
||||
|
||||
for item in self.all_data:
|
||||
if 'cover' not in item['question'] and 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
|
||||
self._enter_text(text_field, existing_answer['answer'])
|
||||
return True
|
||||
logger.debug(f"Found existing answer in the data: {existing_answer['answer']}")
|
||||
break
|
||||
|
||||
if existing_answer:
|
||||
self._enter_text(text_field, existing_answer['answer'])
|
||||
logger.debug("Entered existing answer into the textbox.")
|
||||
|
||||
time.sleep(1)
|
||||
text_field.send_keys(Keys.ARROW_DOWN)
|
||||
text_field.send_keys(Keys.ENTER)
|
||||
logger.debug("Selected first option from the dropdown.")
|
||||
return True
|
||||
|
||||
if is_numeric:
|
||||
answer = self.gpt_answerer.answer_question_numeric(question_text)
|
||||
logger.debug(f"Generated numeric answer: {answer}")
|
||||
else:
|
||||
answer = self.gpt_answerer.answer_question_textual_wide_range(question_text)
|
||||
logger.debug(f"Generated textual answer: {answer}")
|
||||
|
||||
self._save_questions_to_json({'type': question_type, 'question': question_text, 'answer': answer})
|
||||
self._enter_text(text_field, answer)
|
||||
logger.debug("Entered new answer into the textbox and saved it to JSON.")
|
||||
|
||||
time.sleep(1)
|
||||
text_field.send_keys(Keys.ARROW_DOWN)
|
||||
text_field.send_keys(Keys.ENTER)
|
||||
logger.debug("Selected first option from the dropdown.")
|
||||
return True
|
||||
|
||||
logger.debug("No text fields found in the section.")
|
||||
return False
|
||||
|
||||
def _find_and_handle_date_question(self, section: WebElement) -> bool:
|
||||
|
|
@ -294,49 +712,88 @@ class LinkedInEasyApplier:
|
|||
|
||||
existing_answer = None
|
||||
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':
|
||||
existing_answer = item
|
||||
self._enter_text(date_field, existing_answer['answer'])
|
||||
return True
|
||||
|
||||
break
|
||||
if existing_answer:
|
||||
self._enter_text(date_field, existing_answer['answer'])
|
||||
logger.debug("Entered existing date answer")
|
||||
return True
|
||||
|
||||
self._save_questions_to_json({'type': 'date', 'question': question_text, 'answer': answer_text})
|
||||
self._enter_text(date_field, answer_text)
|
||||
logger.debug("Entered new date answer")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _find_and_handle_dropdown_question(self, section: WebElement) -> bool:
|
||||
try:
|
||||
question = section.find_element(By.CLASS_NAME, 'jobs-easy-apply-form-element')
|
||||
question_text = question.find_element(By.TAG_NAME, 'label').text.lower()
|
||||
dropdown = question.find_element(By.TAG_NAME, 'select')
|
||||
if dropdown:
|
||||
|
||||
dropdowns = question.find_elements(By.TAG_NAME, 'select')
|
||||
if not dropdowns:
|
||||
dropdowns = section.find_elements(By.CSS_SELECTOR, '[data-test-text-entity-list-form-select]')
|
||||
|
||||
if dropdowns:
|
||||
dropdown = dropdowns[0]
|
||||
select = Select(dropdown)
|
||||
options = [option.text for option in select.options]
|
||||
|
||||
logger.debug(f"Dropdown options found: {options}")
|
||||
|
||||
question_text = question.find_element(By.TAG_NAME, 'label').text.lower()
|
||||
logger.debug(f"Processing dropdown or combobox question: {question_text}")
|
||||
|
||||
current_selection = select.first_selected_option.text
|
||||
logger.debug(f"Current selection: {current_selection}")
|
||||
|
||||
existing_answer = None
|
||||
for item in self.all_data:
|
||||
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'dropdown':
|
||||
existing_answer = item
|
||||
self._select_dropdown_option(dropdown, existing_answer['answer'])
|
||||
return True
|
||||
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}")
|
||||
if current_selection != existing_answer:
|
||||
logger.debug(f"Updating selection to: {existing_answer}")
|
||||
self._select_dropdown_option(dropdown, existing_answer)
|
||||
return True
|
||||
|
||||
logger.debug(f"No existing answer found, querying model for: {question_text}")
|
||||
|
||||
answer = self.gpt_answerer.answer_question_from_options(question_text, options)
|
||||
self._save_questions_to_json({'type': 'dropdown', 'question': question_text, 'answer': answer})
|
||||
self._select_dropdown_option(dropdown, answer)
|
||||
logger.debug(f"Selected new dropdown answer: {answer}")
|
||||
return True
|
||||
except Exception:
|
||||
|
||||
else:
|
||||
|
||||
logger.debug(f"No dropdown found. Logging elements for debugging.")
|
||||
elements = section.find_elements(By.XPATH, ".//*")
|
||||
logger.debug(f"Elements found: {[element.tag_name for element in elements]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to handle dropdown or combobox question: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _is_numeric_field(self, field: WebElement) -> bool:
|
||||
field_type = field.get_attribute('type').lower()
|
||||
if 'numeric' in field_type:
|
||||
return True
|
||||
class_attribute = field.get_attribute("id")
|
||||
return class_attribute and 'numeric' in class_attribute
|
||||
field_id = field.get_attribute("id").lower()
|
||||
is_numeric = 'numeric' in field_id or field_type == 'number' or ('text' == field_type and 'numeric' in field_id)
|
||||
logger.debug("Field type: %s, Field ID: %s, Is numeric: %s", field_type, field_id, is_numeric)
|
||||
return is_numeric
|
||||
|
||||
def _enter_text(self, element: WebElement, text: str) -> None:
|
||||
logger.debug("Entering text: %s", text)
|
||||
element.clear()
|
||||
element.send_keys(text)
|
||||
|
||||
def _select_radio(self, radios: List[WebElement], answer: str) -> None:
|
||||
logger.debug("Selecting radio option: %s", answer)
|
||||
for radio in radios:
|
||||
if answer in radio.text.lower():
|
||||
radio.find_element(By.TAG_NAME, 'label').click()
|
||||
|
|
@ -344,12 +801,14 @@ class LinkedInEasyApplier:
|
|||
radios[-1].find_element(By.TAG_NAME, 'label').click()
|
||||
|
||||
def _select_dropdown_option(self, element: WebElement, text: str) -> None:
|
||||
logger.debug("Selecting dropdown option: %s", text)
|
||||
select = Select(element)
|
||||
select.select_by_visible_text(text)
|
||||
|
||||
def _save_questions_to_json(self, question_data: dict) -> None:
|
||||
output_file = 'answers.json'
|
||||
question_data['question'] = self._sanitize_text(question_data['question'])
|
||||
logger.debug("Saving question data to JSON: %s", question_data)
|
||||
try:
|
||||
try:
|
||||
with open(output_file, 'r') as f:
|
||||
|
|
@ -358,22 +817,22 @@ class LinkedInEasyApplier:
|
|||
if not isinstance(data, list):
|
||||
raise ValueError("JSON file format is incorrect. Expected a list of questions.")
|
||||
except json.JSONDecodeError:
|
||||
logger.error("JSON decoding failed")
|
||||
data = []
|
||||
except FileNotFoundError:
|
||||
logger.warning("JSON file not found, creating new file")
|
||||
data = []
|
||||
data.append(question_data)
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
logger.debug("Question data saved successfully to JSON")
|
||||
except Exception:
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error("Error saving questions data to JSON file: %s", tb_str)
|
||||
raise Exception(f"Error saving questions data to JSON file: \nTraceback:\n{tb_str}")
|
||||
|
||||
def _sanitize_text(self, text: str) -> str:
|
||||
sanitized_text = text.lower()
|
||||
sanitized_text = sanitized_text.strip()
|
||||
sanitized_text = sanitized_text.replace('"', '')
|
||||
sanitized_text = sanitized_text.replace('\\', '')
|
||||
sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text)
|
||||
sanitized_text = sanitized_text.replace('\n', ' ').replace('\r', '')
|
||||
sanitized_text = sanitized_text.rstrip(',')
|
||||
sanitized_text = text.lower().strip().replace('"', '').replace('\\', '')
|
||||
sanitized_text = re.sub(r'[\x00-\x1F\x7F]', '', sanitized_text).replace('\n', ' ').replace('\r', '').rstrip(',')
|
||||
logger.debug("Sanitized text: %s", sanitized_text)
|
||||
return sanitized_text
|
||||
|
|
|
|||
|
|
@ -1,60 +1,81 @@
|
|||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import traceback
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
|
||||
from inputimeout import inputimeout, TimeoutOccurred
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
import src.utils as utils
|
||||
from src.job import Job
|
||||
from src.linkedIn_easy_applier import LinkedInEasyApplier
|
||||
import json
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
class EnvironmentKeys:
|
||||
def __init__(self):
|
||||
logger.debug("Initializing EnvironmentKeys")
|
||||
self.skip_apply = self._read_env_key_bool("SKIP_APPLY")
|
||||
self.disable_description_filter = self._read_env_key_bool("DISABLE_DESCRIPTION_FILTER")
|
||||
logger.debug("EnvironmentKeys initialized: skip_apply=%s, disable_description_filter=%s",
|
||||
self.skip_apply, self.disable_description_filter)
|
||||
|
||||
@staticmethod
|
||||
def _read_env_key(key: str) -> str:
|
||||
return os.getenv(key, "")
|
||||
value = os.getenv(key, "")
|
||||
logger.debug("Read environment key %s: %s", key, value)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _read_env_key_bool(key: str) -> bool:
|
||||
return os.getenv(key) == "True"
|
||||
value = os.getenv(key) == "True"
|
||||
logger.debug("Read environment key %s as bool: %s", key, value)
|
||||
return value
|
||||
|
||||
|
||||
class LinkedInJobManager:
|
||||
def __init__(self, driver):
|
||||
logger.debug("Initializing LinkedInJobManager")
|
||||
self.driver = driver
|
||||
self.set_old_answers = set()
|
||||
self.easy_applier_component = None
|
||||
logger.debug("LinkedInJobManager initialized successfully")
|
||||
|
||||
def set_parameters(self, parameters):
|
||||
self.company_blacklist = parameters.get('companyBlacklist', []) or []
|
||||
self.title_blacklist = parameters.get('titleBlacklist', []) or []
|
||||
logger.debug("Setting parameters for LinkedInJobManager")
|
||||
self.company_blacklist = parameters.get('company_blacklist', []) or []
|
||||
self.title_blacklist = parameters.get('title_blacklist', []) or []
|
||||
self.positions = parameters.get('positions', [])
|
||||
self.locations = parameters.get('locations', [])
|
||||
self.apply_once_at_company = parameters.get('apply_once_at_company', False)
|
||||
self.base_search_url = self.get_base_search_url(parameters)
|
||||
self.seen_jobs = []
|
||||
|
||||
job_applicants_threshold = parameters.get('job_applicants_threshold', {})
|
||||
self.min_applicants = job_applicants_threshold.get('min_applicants', 0)
|
||||
self.max_applicants = job_applicants_threshold.get('max_applicants', float('inf'))
|
||||
|
||||
resume_path = parameters.get('uploads', {}).get('resume', None)
|
||||
if resume_path is not None and Path(resume_path).exists():
|
||||
self.resume_path = Path(resume_path)
|
||||
else:
|
||||
self.resume_path = None
|
||||
self.resume_path = Path(resume_path) if resume_path and Path(resume_path).exists() else None
|
||||
self.output_file_directory = Path(parameters['outputFileDirectory'])
|
||||
self.env_config = EnvironmentKeys()
|
||||
#self.old_question()
|
||||
logger.debug("Parameters set successfully")
|
||||
|
||||
def set_gpt_answerer(self, gpt_answerer):
|
||||
logger.debug("Setting GPT answerer")
|
||||
self.gpt_answerer = gpt_answerer
|
||||
|
||||
def set_resume_generator_manager(self, resume_generator_manager):
|
||||
logger.debug("Setting resume generator manager")
|
||||
self.resume_generator_manager = resume_generator_manager
|
||||
|
||||
def start_applying(self):
|
||||
self.easy_applier_component = LinkedInEasyApplier(self.driver, self.resume_path, self.set_old_answers, self.gpt_answerer, self.resume_generator_manager)
|
||||
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)
|
||||
searches = list(product(self.positions, self.locations))
|
||||
random.shuffle(searches)
|
||||
page_sleep = 0
|
||||
|
|
@ -74,63 +95,248 @@ class LinkedInJobManager:
|
|||
self.next_job_page(position, location_url, job_page_number)
|
||||
time.sleep(random.uniform(1.5, 3.5))
|
||||
utils.printyellow("Starting the application process for this page...")
|
||||
self.apply_jobs()
|
||||
|
||||
try:
|
||||
jobs = self.get_jobs_from_page()
|
||||
if not jobs:
|
||||
utils.printyellow("No more jobs found on this page. Exiting loop.")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve jobs: {e}")
|
||||
break
|
||||
|
||||
try:
|
||||
self.apply_jobs()
|
||||
except Exception as e:
|
||||
logger.error("Error during job application: %s", e)
|
||||
utils.printred(f"Error during job application: {e}")
|
||||
continue
|
||||
|
||||
utils.printyellow("Applying to jobs on this page has been completed!")
|
||||
|
||||
time_left = minimum_page_time - time.time()
|
||||
|
||||
# Ask user if they want to skip waiting, with timeout
|
||||
if time_left > 0:
|
||||
utils.printyellow(f"Sleeping for {time_left} seconds.")
|
||||
time.sleep(time_left)
|
||||
minimum_page_time = time.time() + minimum_time
|
||||
try:
|
||||
user_input = inputimeout(
|
||||
prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 60 seconds : ",
|
||||
timeout=60).strip().lower()
|
||||
except TimeoutOccurred:
|
||||
user_input = '' # No input after timeout
|
||||
if user_input == 'y':
|
||||
logger.debug("User chose to skip waiting.")
|
||||
utils.printyellow("User skipped waiting.")
|
||||
else:
|
||||
logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.")
|
||||
utils.printyellow(f"Sleeping for {time_left} seconds.")
|
||||
time.sleep(time_left)
|
||||
|
||||
minimum_page_time = time.time() + minimum_time
|
||||
|
||||
if page_sleep % 5 == 0:
|
||||
sleep_time = random.randint(5, 34)
|
||||
utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.")
|
||||
time.sleep(sleep_time)
|
||||
try:
|
||||
user_input = inputimeout(
|
||||
prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting. Timeout 60 seconds : ",
|
||||
timeout=60).strip().lower()
|
||||
except TimeoutOccurred:
|
||||
user_input = '' # No input after timeout
|
||||
if user_input == 'y':
|
||||
logger.debug("User chose to skip waiting.")
|
||||
utils.printyellow("User skipped waiting.")
|
||||
else:
|
||||
logger.debug(f"Sleeping for {sleep_time} seconds.")
|
||||
utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.")
|
||||
time.sleep(sleep_time)
|
||||
page_sleep += 1
|
||||
except Exception:
|
||||
traceback.format_exc()
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during job search: %s", e)
|
||||
utils.printred(f"Unexpected error: {e}")
|
||||
continue
|
||||
|
||||
time_left = minimum_page_time - time.time()
|
||||
|
||||
if time_left > 0:
|
||||
utils.printyellow(f"Sleeping for {time_left} seconds.")
|
||||
time.sleep(time_left)
|
||||
minimum_page_time = time.time() + minimum_time
|
||||
try:
|
||||
user_input = inputimeout(
|
||||
prompt=f"Sleeping for {time_left} seconds. Press 'y' to skip waiting. Timeout 60 seconds : ",
|
||||
timeout=60).strip().lower()
|
||||
except TimeoutOccurred:
|
||||
user_input = '' # No input after timeout
|
||||
if user_input == 'y':
|
||||
logger.debug("User chose to skip waiting.")
|
||||
utils.printyellow("User skipped waiting.")
|
||||
else:
|
||||
logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.")
|
||||
utils.printyellow(f"Sleeping for {time_left} seconds.")
|
||||
time.sleep(time_left)
|
||||
|
||||
minimum_page_time = time.time() + minimum_time
|
||||
|
||||
if page_sleep % 5 == 0:
|
||||
sleep_time = random.randint(50, 90)
|
||||
utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.")
|
||||
time.sleep(sleep_time)
|
||||
try:
|
||||
user_input = inputimeout(
|
||||
prompt=f"Sleeping for {sleep_time / 60} minutes. Press 'y' to skip waiting: ",
|
||||
timeout=60).strip().lower()
|
||||
except TimeoutOccurred:
|
||||
user_input = '' # No input after timeout
|
||||
if user_input == 'y':
|
||||
logger.debug("User chose to skip waiting.")
|
||||
utils.printyellow("User skipped waiting.")
|
||||
else:
|
||||
logger.debug(f"Sleeping for {sleep_time} seconds.")
|
||||
utils.printyellow(f"Sleeping for {sleep_time / 60} minutes.")
|
||||
time.sleep(sleep_time)
|
||||
page_sleep += 1
|
||||
|
||||
def get_jobs_from_page(self):
|
||||
|
||||
try:
|
||||
|
||||
no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand')
|
||||
if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower():
|
||||
utils.printyellow("No matching jobs found on this page.")
|
||||
logger.debug("No matching jobs found on this page, skipping.")
|
||||
return []
|
||||
|
||||
except NoSuchElementException:
|
||||
pass
|
||||
|
||||
try:
|
||||
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
|
||||
utils.scroll_slow(self.driver, job_results)
|
||||
utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
|
||||
|
||||
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[
|
||||
0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
|
||||
if not job_list_elements:
|
||||
utils.printyellow("No job class elements found on page.")
|
||||
logger.debug("No job class elements found on page, skipping.")
|
||||
return []
|
||||
|
||||
return job_list_elements
|
||||
|
||||
except NoSuchElementException:
|
||||
logger.debug("No job results found on the page.")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error while fetching job elements: {e}")
|
||||
return []
|
||||
|
||||
def apply_jobs(self):
|
||||
try:
|
||||
no_jobs_element = self.driver.find_element(By.CLASS_NAME, 'jobs-search-two-pane__no-results-banner--expand')
|
||||
if 'No matching jobs found' in no_jobs_element.text or 'unfortunately, things aren' in self.driver.page_source.lower():
|
||||
raise Exception("No more jobs on this page")
|
||||
utils.printyellow("No matching jobs found on this page, moving to next.")
|
||||
logger.debug("No matching jobs found on this page, skipping")
|
||||
return
|
||||
except NoSuchElementException:
|
||||
pass
|
||||
|
||||
|
||||
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
|
||||
utils.scroll_slow(self.driver, job_results)
|
||||
utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
|
||||
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
|
||||
# utils.scroll_slow(self.driver, job_results)
|
||||
# utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
|
||||
|
||||
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[
|
||||
0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
|
||||
|
||||
if not job_list_elements:
|
||||
raise Exception("No job class elements found on page")
|
||||
job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements]
|
||||
utils.printyellow("No job class elements found on page, moving to next page.")
|
||||
logger.debug("No job class elements found on page, skipping")
|
||||
return
|
||||
|
||||
job_list = [Job(*self.extract_job_information_from_tile(job_element)) for job_element in job_list_elements]
|
||||
|
||||
for job in job_list:
|
||||
|
||||
try:
|
||||
logger.debug(f"Starting applicant count search for job: {job.title} at {job.company}")
|
||||
|
||||
# Find all job insight elements
|
||||
job_insight_elements = self.driver.find_elements(By.CLASS_NAME,
|
||||
"job-details-jobs-unified-top-card__job-insight")
|
||||
logger.debug(f"Found {len(job_insight_elements)} job insight elements")
|
||||
|
||||
# Initialize applicants_count as None
|
||||
applicants_count = None
|
||||
|
||||
# Iterate over each job insight element to find the one containing the word "applicant"
|
||||
for element in job_insight_elements:
|
||||
logger.debug(f"Checking element text: {element.text}")
|
||||
if "applicant" in element.text.lower():
|
||||
# Found an element containing "applicant"
|
||||
applicants_text = element.text.strip()
|
||||
logger.debug(f"Applicants text found: {applicants_text}")
|
||||
|
||||
# Extract numeric digits from the text (e.g., "70 applicants" -> "70")
|
||||
applicants_count = ''.join(filter(str.isdigit, applicants_text))
|
||||
logger.debug(f"Extracted applicants count: {applicants_count}")
|
||||
|
||||
if applicants_count:
|
||||
if "over" in applicants_text.lower():
|
||||
applicants_count = int(applicants_count) + 1 # Handle "over X applicants"
|
||||
logger.debug(f"Applicants count adjusted for 'over': {applicants_count}")
|
||||
else:
|
||||
applicants_count = int(applicants_count) # Convert the extracted number to an integer
|
||||
break
|
||||
|
||||
# Check if applicants_count is valid (not None) before performing comparisons
|
||||
if applicants_count is not None:
|
||||
# Perform the threshold check for applicants count
|
||||
if applicants_count < self.min_applicants or applicants_count > self.max_applicants:
|
||||
utils.printyellow(
|
||||
f"Skipping {job.title} at {job.company} due to applicants count: {applicants_count}")
|
||||
logger.debug(f"Skipping {job.title} at {job.company}, applicants count: {applicants_count}")
|
||||
self.write_to_file(job, "skipped_due_to_applicants")
|
||||
continue # Skip this job if applicants count is outside the threshold
|
||||
else:
|
||||
logger.debug(f"Applicants count {applicants_count} is within the threshold")
|
||||
else:
|
||||
# If no applicants count was found, log a warning but continue the process
|
||||
logger.warning(
|
||||
f"Applicants count not found for {job.title} at {job.company}, continuing with application.")
|
||||
except NoSuchElementException:
|
||||
# Log a warning if the job insight elements are not found, but do not stop the job application process
|
||||
logger.warning(
|
||||
f"Applicants count elements not found for {job.title} at {job.company}, continuing with application.")
|
||||
except ValueError as e:
|
||||
# Handle errors when parsing the applicants count
|
||||
logger.error(f"Error parsing applicants count for {job.title} at {job.company}: {e}")
|
||||
except Exception as e:
|
||||
# Catch any other exceptions to ensure the process continues
|
||||
logger.error(
|
||||
f"Unexpected error during applicants count processing for {job.title} at {job.company}: {e}")
|
||||
|
||||
# Continue with the job application process regardless of the applicants count check
|
||||
logger.debug(f"Continuing with job application for {job.title} at {job.company}")
|
||||
|
||||
if self.is_blacklisted(job.title, job.company, job.link):
|
||||
utils.printyellow(f"Blacklisted {job.title} at {job.company}, skipping...")
|
||||
logger.debug("Job blacklisted: %s at %s", job.title, job.company)
|
||||
self.write_to_file(job, "skipped")
|
||||
continue
|
||||
if self.is_already_applied_to_job(job.title, job.company, job.link):
|
||||
self.write_to_file(job, "skipped")
|
||||
continue
|
||||
if self.is_already_applied_to_company(job.company):
|
||||
self.write_to_file(job, "skipped")
|
||||
continue
|
||||
try:
|
||||
if job.apply_method not in {"Continue", "Applied", "Apply"}:
|
||||
self.easy_applier_component.job_apply(job)
|
||||
self.write_to_file(job, "success")
|
||||
logger.debug("Applied to job: %s at %s", job.title, job.company)
|
||||
except Exception as e:
|
||||
utils.printred(traceback.format_exc())
|
||||
logger.error("Failed to apply for %s at %s: %s", job.title, job.company, e)
|
||||
utils.printred(f"Failed to apply for {job.title} at {job.company}: {e}")
|
||||
self.write_to_file(job, "failed")
|
||||
continue
|
||||
|
||||
|
||||
def write_to_file(self, job, file_name):
|
||||
logger.debug("Writing job application result to file: %s", file_name)
|
||||
pdf_path = Path(job.pdf_path).resolve()
|
||||
pdf_path = pdf_path.as_uri()
|
||||
data = {
|
||||
|
|
@ -145,22 +351,27 @@ class LinkedInJobManager:
|
|||
if not file_path.exists():
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump([data], f, indent=4)
|
||||
logger.debug("Job data written to new file: %s", file_path)
|
||||
else:
|
||||
with open(file_path, 'r+', encoding='utf-8') as f:
|
||||
try:
|
||||
existing_data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("JSON decode error in file: %s", file_path)
|
||||
existing_data = []
|
||||
existing_data.append(data)
|
||||
f.seek(0)
|
||||
json.dump(existing_data, f, indent=4)
|
||||
f.truncate()
|
||||
logger.debug("Job data appended to existing file: %s", file_path)
|
||||
|
||||
def get_base_search_url(self, parameters):
|
||||
logger.debug("Constructing base search URL")
|
||||
url_parts = []
|
||||
if parameters['remote']:
|
||||
url_parts.append("f_CF=f_WRA")
|
||||
experience_levels = [str(i+1) for i, (level, v) in enumerate(parameters.get('experienceLevel', {}).items()) if v]
|
||||
experience_levels = [str(i + 1) for i, (level, v) in enumerate(parameters.get('experience_level', {}).items()) if
|
||||
v]
|
||||
if experience_levels:
|
||||
url_parts.append(f"f_E={','.join(experience_levels)}")
|
||||
url_parts.append(f"distance={parameters['distance']}")
|
||||
|
|
@ -176,33 +387,73 @@ class LinkedInJobManager:
|
|||
date_param = next((v for k, v in date_mapping.items() if parameters.get('date', {}).get(k)), "")
|
||||
url_parts.append("f_LF=f_AL") # Easy Apply
|
||||
base_url = "&".join(url_parts)
|
||||
return f"?{base_url}{date_param}"
|
||||
|
||||
full_url = f"?{base_url}{date_param}"
|
||||
logger.debug("Base search URL constructed: %s", full_url)
|
||||
return full_url
|
||||
|
||||
def next_job_page(self, 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}")
|
||||
|
||||
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}")
|
||||
|
||||
def extract_job_information_from_tile(self, job_tile):
|
||||
logger.debug("Extracting job information from tile")
|
||||
job_title, company, job_location, apply_method, link = "", "", "", "", ""
|
||||
try:
|
||||
job_title = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').text
|
||||
link = job_tile.find_element(By.CLASS_NAME, 'job-card-list__title').get_attribute('href').split('?')[0]
|
||||
company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__primary-description').text
|
||||
except:
|
||||
pass
|
||||
logger.debug("Job information extracted: %s at %s", job_title, company)
|
||||
except NoSuchElementException:
|
||||
utils.printyellow("Some job information (title, link, or company) is missing.")
|
||||
logger.warning("Some job information (title, link, or company) is missing.")
|
||||
try:
|
||||
job_location = job_tile.find_element(By.CLASS_NAME, 'job-card-container__metadata-item').text
|
||||
except:
|
||||
pass
|
||||
except NoSuchElementException:
|
||||
utils.printyellow("Job location is missing.")
|
||||
logger.warning("Job location is missing.")
|
||||
try:
|
||||
apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text
|
||||
except:
|
||||
except NoSuchElementException:
|
||||
apply_method = "Applied"
|
||||
utils.printyellow("Apply method not found, assuming 'Applied'.")
|
||||
logger.warning("Apply method not found, assuming 'Applied'.")
|
||||
|
||||
return job_title, company, job_location, link, apply_method
|
||||
|
||||
|
||||
def is_blacklisted(self, job_title, company, link):
|
||||
logger.debug("Checking if job is blacklisted: %s at %s", job_title, company)
|
||||
job_title_words = job_title.lower().split(' ')
|
||||
title_blacklisted = any(word in job_title_words for word in self.title_blacklist)
|
||||
company_blacklisted = company.strip().lower() in (word.strip().lower() for word in self.company_blacklist)
|
||||
link_seen = link in self.seen_jobs
|
||||
is_blacklisted = title_blacklisted or company_blacklisted or link_seen
|
||||
logger.debug("Job blacklisted status: %s", is_blacklisted)
|
||||
|
||||
return title_blacklisted or company_blacklisted or link_seen
|
||||
|
||||
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
|
||||
|
|
|
|||
376
src/linkedin-api.py
Normal file
376
src/linkedin-api.py
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import logging
|
||||
from typing import Dict, List
|
||||
from typing import Optional, Union, Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from linkedin_api import Linkedin
|
||||
|
||||
# set log to all debug
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
class LinkedInEvolvedAPI(Linkedin):
|
||||
already_applied_jobs: List[str] = []
|
||||
|
||||
def __init__(self, username, password):
|
||||
super().__init__(username, password)
|
||||
|
||||
def search_jobs(
|
||||
self,
|
||||
keywords: Optional[str] = None,
|
||||
companies: Optional[List[str]] = None,
|
||||
experience: Optional[
|
||||
List[
|
||||
Union[
|
||||
Literal["1"],
|
||||
Literal["2"],
|
||||
Literal["3"],
|
||||
Literal["4"],
|
||||
Literal["5"],
|
||||
Literal["6"],
|
||||
]
|
||||
]
|
||||
] = None,
|
||||
job_type: Optional[
|
||||
List[
|
||||
Union[
|
||||
Literal["F"],
|
||||
Literal["C"],
|
||||
Literal["P"],
|
||||
Literal["T"],
|
||||
Literal["I"],
|
||||
Literal["V"],
|
||||
Literal["O"],
|
||||
]
|
||||
]
|
||||
] = None,
|
||||
job_title: Optional[List[str]] = None,
|
||||
industries: Optional[List[str]] = None,
|
||||
location_name: Optional[str] = None,
|
||||
remote: Optional[List[Union[Literal["1"], Literal["2"], Literal["3"]]]] = None,
|
||||
listed_at: None | int = None,
|
||||
distance: Optional[int] = None,
|
||||
easy_apply: Optional[bool] = True,
|
||||
limit=-1,
|
||||
offset=0,
|
||||
**kwargs,
|
||||
) -> List[Dict]:
|
||||
"""Perform a LinkedIn search for jobs.
|
||||
|
||||
:param keywords: Search keywords (str)
|
||||
:type keywords: str, optional
|
||||
:param companies: A list of company URN IDs (str)
|
||||
:type companies: list, optional
|
||||
:param experience: A list of experience levels, one or many of "1", "2", "3", "4", "5" and "6" (internship, entry level, associate, mid-senior level, director and executive, respectively)
|
||||
:type experience: list, optional
|
||||
:param job_type: A list of job types , one or many of "F", "C", "P", "T", "I", "V", "O" (full-time, contract, part-time, temporary, internship, volunteer and "other", respectively)
|
||||
:type job_type: list, optional
|
||||
:param job_title: A list of title URN IDs (str)
|
||||
:type job_title: list, optional
|
||||
:param industries: A list of industry URN IDs (str)
|
||||
:type industries: list, optional
|
||||
:param location_name: Name of the location to search within. Example: "Kyiv City, Ukraine"
|
||||
:type location_name: str, optional
|
||||
:param remote: Filter for remote jobs, onsite or hybrid. onsite:"1", remote:"2", hybrid:"3"
|
||||
:type remote: list, optional
|
||||
:param listed_at: maximum number of seconds passed since job posting. 86400 will filter job postings posted in last 24 hours, default is None
|
||||
:type listed_at: int or none, if none, no filter applied, otherwise, filter applied in seconds
|
||||
:param distance: maximum distance from location in miles
|
||||
:type distance: int/str, optional. If not specified, None or 0, the default value of 25 miles applied.
|
||||
:param easy_apply: filter for jobs that are easy to apply to
|
||||
:type easy_apply: bool, optional. Default value is True.
|
||||
:param limit: maximum number of results obtained from API queries. -1 means maximum which is defined by constants and is equal to 1000 now.
|
||||
:type limit: int, optional, default -1
|
||||
:param offset: indicates how many search results shall be skipped
|
||||
:type offset: int, optional
|
||||
:return: List of jobs
|
||||
:rtype: list
|
||||
"""
|
||||
count = Linkedin._MAX_SEARCH_COUNT
|
||||
if limit is None:
|
||||
limit = -1
|
||||
|
||||
query: Dict[str, Union[str, Dict[str, str]]] = {
|
||||
"origin": "JOB_SEARCH_PAGE_QUERY_EXPANSION"
|
||||
}
|
||||
if keywords:
|
||||
query["keywords"] = "KEYWORD_PLACEHOLDER"
|
||||
if location_name:
|
||||
query["locationFallback"] = "LOCATION_PLACEHOLDER"
|
||||
|
||||
query["selectedFilters"] = {}
|
||||
if companies:
|
||||
query["selectedFilters"]["company"] = f"List({','.join(companies)})"
|
||||
if experience:
|
||||
query["selectedFilters"]["experience"] = f"List({','.join(experience)})"
|
||||
if job_type:
|
||||
query["selectedFilters"]["jobType"] = f"List({','.join(job_type)})"
|
||||
if job_title:
|
||||
query["selectedFilters"]["title"] = f"List({','.join(job_title)})"
|
||||
if industries:
|
||||
query["selectedFilters"]["industry"] = f"List({','.join(industries)})"
|
||||
if distance:
|
||||
query["selectedFilters"]["distance"] = f"List({distance})"
|
||||
if remote:
|
||||
query["selectedFilters"]["workplaceType"] = f"List({','.join(remote)})"
|
||||
if easy_apply:
|
||||
query["selectedFilters"]["applyWithLinkedin"] = "List(true)"
|
||||
|
||||
if listed_at:
|
||||
query["selectedFilters"]["timePostedRange"] = f"List(r{listed_at})"
|
||||
query["spellCorrectionEnabled"] = "true"
|
||||
|
||||
query_string = (
|
||||
str(query)
|
||||
.replace(" ", "")
|
||||
.replace("'", "")
|
||||
.replace("KEYWORD_PLACEHOLDER", keywords or "")
|
||||
.replace("LOCATION_PLACEHOLDER", location_name or "")
|
||||
.replace("{", "(")
|
||||
.replace("}", ")")
|
||||
)
|
||||
results = []
|
||||
while True:
|
||||
if limit > -1 and limit - len(results) < count:
|
||||
count = limit - len(results)
|
||||
default_params = {
|
||||
"decorationId": "com.linkedin.voyager.dash.deco.jobs.search.JobSearchCardsCollection-174",
|
||||
"count": count,
|
||||
"q": "jobSearch",
|
||||
"query": query_string,
|
||||
"start": len(results) + offset,
|
||||
}
|
||||
|
||||
res = self._fetch(
|
||||
f"/voyagerJobsDashJobCards?{urlencode(default_params, safe='(),:')}",
|
||||
headers={"accept": "application/vnd.linkedin.normalized+json+2.1"},
|
||||
)
|
||||
data = res.json()
|
||||
elements = data.get("included", [])
|
||||
new_data = []
|
||||
for e in elements:
|
||||
trackingUrn = e.get("trackingUrn")
|
||||
if trackingUrn:
|
||||
trackingUrn = trackingUrn.split(":")[-1]
|
||||
e["job_id"] = trackingUrn
|
||||
if e.get("$type") == "com.linkedin.voyager.dash.jobs.JobPosting":
|
||||
new_data.append(e)
|
||||
|
||||
if not new_data:
|
||||
break
|
||||
results.extend(new_data)
|
||||
if (
|
||||
(-1 < limit <= len(results))
|
||||
or len(results) / count >= Linkedin._MAX_REPEATED_REQUESTS
|
||||
) or len(elements) == 0:
|
||||
break
|
||||
|
||||
self.logger.debug(f"results grew to {len(results)}")
|
||||
|
||||
return results
|
||||
|
||||
def get_fields_for_easy_apply(self, job_id: str) -> List[Dict]:
|
||||
"""Get fields needed for easy apply jobs.
|
||||
|
||||
:param job_id: Job ID
|
||||
:type job_id: str
|
||||
:return: Fields
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
cookies = self.client.session.cookies.get_dict()
|
||||
cookie_str = "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
||||
|
||||
headers: Dict[str, str] = self._headers()
|
||||
|
||||
headers["Accept"] = "application/vnd.linkedin.normalized+json+2.1"
|
||||
headers["csrf-token"] = cookies["JSESSIONID"].replace('"', "")
|
||||
headers["Cookie"] = cookie_str
|
||||
headers["Connection"] = "keep-alive"
|
||||
|
||||
default_params = {
|
||||
"decorationId": "com.linkedin.voyager.dash.deco.jobs.OnsiteApplyApplication-67",
|
||||
"jobPostingUrn": f"urn:li:fsd_jobPosting:{job_id}",
|
||||
"q": "jobPosting",
|
||||
}
|
||||
|
||||
default_params = urlencode(default_params)
|
||||
res = self._fetch(
|
||||
f"/voyagerJobsDashOnsiteApplyApplication?{default_params}",
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
)
|
||||
|
||||
match res.status_code:
|
||||
case 200:
|
||||
pass
|
||||
case 409:
|
||||
self.logger.error("Failed to fetch fields for easy apply job because already applied to this job!")
|
||||
return []
|
||||
case _:
|
||||
self.logger.error("Failed to fetch fields for easy apply job")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = res.json()
|
||||
except ValueError:
|
||||
self.logger.error("Failed to parse JSON response")
|
||||
return []
|
||||
|
||||
form_components = []
|
||||
|
||||
for item in data.get("included", []):
|
||||
if 'formComponent' in item:
|
||||
urn = item['urn']
|
||||
try:
|
||||
title = item['title']['text']
|
||||
except TypeError:
|
||||
title = urn
|
||||
|
||||
form_component_type = list(item['formComponent'].keys())[0]
|
||||
form_component_details = item['formComponent'][form_component_type]
|
||||
|
||||
component_info = {
|
||||
'title': title,
|
||||
'urn': urn,
|
||||
'formComponentType': form_component_type,
|
||||
}
|
||||
|
||||
if 'textSelectableOptions' in form_component_details:
|
||||
options = [
|
||||
opt['optionText']['text'] for opt in form_component_details['textSelectableOptions']
|
||||
]
|
||||
component_info['selectableOptions'] = options
|
||||
elif 'selectableOptions' in form_component_details:
|
||||
options = [
|
||||
opt['textSelectableOption']['optionText']['text']
|
||||
for opt in form_component_details['selectableOptions']
|
||||
]
|
||||
component_info['selectableOptions'] = options
|
||||
|
||||
form_components.append(component_info)
|
||||
|
||||
return form_components
|
||||
|
||||
def apply_to_job(self, job_id: str, fields: dict, followCompany: bool = True) -> bool:
|
||||
return False
|
||||
|
||||
# ToDo: Implement apply to job parser first
|
||||
# How need to be implemented:
|
||||
# 1. Get fields for easy apply job from the previous method (get_fields_for_easy_apply)
|
||||
# 2. Fill the fields with the data adding a response parameter in the specific field in the dict object, for example:
|
||||
# {'title': 'Quanti anni di esperienza di lavoro hai con Router?', 'urn': 'urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4013860791,9478711764,numeric)', 'formComponentType': 'singleLineTextFormComponent'}
|
||||
# Became:
|
||||
# {'title': 'Quanti anni di esperienza di lavoro hai con Router?', 'urn': 'urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4013860791,9478711764,numeric)', 'formComponentType': 'singleLineTextFormComponent', 'response': '5'}
|
||||
# To fill, you can temporary use input() function to get the data from the user manually for testing purposes (for the further implementation, the question will be asked to AI implementation and automatically filled)
|
||||
# Build a working payload.
|
||||
|
||||
# EXAMPLE OF WORKING PAYLOAD
|
||||
# 4005350454 is job_id, so need to be replaced with the job_id
|
||||
|
||||
# {
|
||||
# "followCompany": true,
|
||||
# "responses": [
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278561,multipleChoice)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "entityInputValue": {
|
||||
# "inputEntityName": "email@gmail.com"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278545,phoneNumber~country)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "entityInputValue": {
|
||||
# "inputEntityName": "Italy (+39)",
|
||||
# "inputEntityUrn": "urn:li:country:it"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278545,phoneNumber~nationalNumber)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "textInputValue": "3333333"
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278529,multipleChoice)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "entityInputValue": {
|
||||
# "inputEntityName": "Native or bilingual"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278537,numeric)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "textInputValue": "0"
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3498546713,multipleChoice)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "entityInputValue": {
|
||||
# "inputEntityName": "No"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# {
|
||||
# "formElementUrn": "urn:li:fsd_formElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278521,multipleChoice)",
|
||||
# "formElementInputValues": [
|
||||
# {
|
||||
# "entityInputValue": {
|
||||
# "inputEntityName": "No"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ],
|
||||
# "referenceId": "",
|
||||
# "trackingCode": "d_flagship3_search_srp_jobs",
|
||||
# "fileUploadResponses": [
|
||||
# {
|
||||
# "inputUrn": "urn:li:fsd_resume:/##todo##",
|
||||
# "formElementUrn": "urn:li:fsu_jobApplicationFileUploadFormElement:urn:li:jobs_applyformcommon_easyApplyFormElement:(4005350454,3497278553,document)"
|
||||
# }
|
||||
# ],
|
||||
# "trackingId": ""
|
||||
# }
|
||||
|
||||
# Push the commit to the repository and create a pull request to the v3 branch.
|
||||
|
||||
def set_job_as_applied(self, job_id: str) -> None:
|
||||
self.already_applied_jobs.append(job_id)
|
||||
|
||||
|
||||
## EXAMPLE USAGE
|
||||
if __name__ == "__main__":
|
||||
api: LinkedInEvolvedAPI = LinkedInEvolvedAPI(username="", password="")
|
||||
jobs = api.search_jobs(keywords="Frontend Developer", location_name="Italia", limit=100, easy_apply=True, offset=1,
|
||||
listed_at=None)
|
||||
for job in jobs:
|
||||
job_id: str = job["job_id"]
|
||||
print(f"Job ID: {job_id}")
|
||||
continue
|
||||
|
||||
if job_id in api.already_applied_jobs:
|
||||
logging.info(f"Already applied to job {job_id}, skipping it")
|
||||
continue
|
||||
|
||||
fields = api.get_fields_for_easy_apply(job_id)
|
||||
for field in fields:
|
||||
print(field)
|
||||
break
|
||||
|
|
@ -181,7 +181,7 @@ Answer the following question based on the provided language skills.
|
|||
- Answer questions directly.
|
||||
- If it seems likely that you have the experience, even if not explicitly defined, answer as if you have the experience.
|
||||
- If unsure, respond with "I have no experience with that, but I learn fast" or "Not yet, but willing to learn."
|
||||
- Keep the answer under 140 characters.
|
||||
- Keep the answer under 140 characters. Do not add any additional languages what is not in my experience
|
||||
|
||||
## Example
|
||||
My resume: Fluent in Italian and English.
|
||||
|
|
@ -238,7 +238,6 @@ This comprehensive overview will serve as a guideline for the recruitment proces
|
|||
|
||||
# Job Description Summary"""
|
||||
|
||||
|
||||
coverletter_template = """
|
||||
Compose a brief and impactful cover letter based on the provided job description and resume. The letter should be no longer than three paragraphs and should be written in a professional, yet conversational tone. Avoid using any placeholders, and ensure that the letter flows naturally and is tailored to the job.
|
||||
|
||||
|
|
@ -371,7 +370,6 @@ Options: [1-2, 3-5, 6-10, 10+]
|
|||
|
||||
## """
|
||||
|
||||
|
||||
try_to_fix_template = """\
|
||||
The objective is to fix the text of a form input on a web page.
|
||||
|
||||
|
|
|
|||
164
src/utils.py
164
src/utils.py
|
|
@ -1,103 +1,181 @@
|
|||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
|
||||
from selenium import webdriver
|
||||
|
||||
log_file = "app_log.log"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
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
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile")
|
||||
|
||||
|
||||
def ensure_chrome_profile():
|
||||
logger.debug("Ensuring Chrome profile exists at path: %s", chromeProfilePath)
|
||||
profile_dir = os.path.dirname(chromeProfilePath)
|
||||
if not os.path.exists(profile_dir):
|
||||
os.makedirs(profile_dir)
|
||||
logger.debug("Created directory for Chrome profile: %s", profile_dir)
|
||||
if not os.path.exists(chromeProfilePath):
|
||||
os.makedirs(chromeProfilePath)
|
||||
logger.debug("Created Chrome profile directory: %s", chromeProfilePath)
|
||||
return chromeProfilePath
|
||||
|
||||
|
||||
def is_scrollable(element):
|
||||
scroll_height = element.get_attribute("scrollHeight")
|
||||
client_height = element.get_attribute("clientHeight")
|
||||
return 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)
|
||||
return scrollable
|
||||
|
||||
|
||||
def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False):
|
||||
logger.debug("Starting slow scroll: start=%d, end=%d, step=%d, reverse=%s", start, end, step, reverse)
|
||||
|
||||
def scroll_slow(driver, scrollable_element, start=0, end=3600, step=100, reverse=False):
|
||||
if reverse:
|
||||
start, end = end, start
|
||||
step = -step
|
||||
|
||||
if step == 0:
|
||||
logger.error("Step value cannot be zero.")
|
||||
raise ValueError("Step cannot be zero.")
|
||||
|
||||
max_scroll_height = int(scrollable_element.get_attribute("scrollHeight"))
|
||||
current_scroll_position = int(scrollable_element.get_attribute("scrollTop"))
|
||||
logger.debug("Max scroll height of the element: %d", max_scroll_height)
|
||||
logger.debug("Current scroll position: %d", current_scroll_position)
|
||||
|
||||
if reverse:
|
||||
|
||||
if current_scroll_position < start:
|
||||
start = current_scroll_position
|
||||
logger.debug("Adjusted start position for upward scroll: %d", start)
|
||||
else:
|
||||
|
||||
if end > max_scroll_height:
|
||||
logger.warning("End value exceeds the scroll height. Adjusting end to %d", max_scroll_height)
|
||||
end = max_scroll_height
|
||||
|
||||
script_scroll_to = "arguments[0].scrollTop = arguments[1];"
|
||||
|
||||
try:
|
||||
if scrollable_element.is_displayed():
|
||||
if not is_scrollable(scrollable_element):
|
||||
logger.warning("The element is not scrollable.")
|
||||
print("The element is not scrollable.")
|
||||
return
|
||||
|
||||
if (step > 0 and start >= end) or (step < 0 and start <= end):
|
||||
logger.warning("No scrolling will occur due to incorrect start/end values.")
|
||||
print("No scrolling will occur due to incorrect start/end values.")
|
||||
return
|
||||
for position in range(start, end, step):
|
||||
return
|
||||
|
||||
position = start
|
||||
previous_position = None # Tracking the previous position to avoid duplicate scrolls
|
||||
while (step > 0 and position < end) or (step < 0 and position > end):
|
||||
if position == previous_position:
|
||||
# Avoid re-scrolling to the same position
|
||||
logger.debug("Stopping scroll as position hasn't changed: %d", position)
|
||||
break
|
||||
|
||||
try:
|
||||
driver.execute_script(script_scroll_to, scrollable_element, position)
|
||||
logger.debug("Scrolled to position: %d", position)
|
||||
except Exception as e:
|
||||
logger.error("Error during scrolling: %s", e)
|
||||
print(f"Error during scrolling: {e}")
|
||||
time.sleep(random.uniform(1.0, 2.6))
|
||||
|
||||
previous_position = position
|
||||
position += step
|
||||
|
||||
# Decrease the step but ensure it doesn't reverse direction
|
||||
step = max(10, abs(step) - 10) * (-1 if reverse else 1)
|
||||
|
||||
time.sleep(random.uniform(0.6, 1.5))
|
||||
|
||||
# Ensure the final scroll position is correct
|
||||
driver.execute_script(script_scroll_to, scrollable_element, end)
|
||||
time.sleep(1)
|
||||
logger.debug("Scrolled to final position: %d", end)
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
logger.warning("The element is not visible.")
|
||||
print("The element is not visible.")
|
||||
except Exception as e:
|
||||
logger.error("Exception occurred during scrolling: %s", e)
|
||||
print(f"Exception occurred: {e}")
|
||||
|
||||
def chromeBrowserOptions():
|
||||
|
||||
def chrome_browser_options():
|
||||
logger.debug("Setting Chrome browser options")
|
||||
ensure_chrome_profile()
|
||||
options = webdriver.ChromeOptions()
|
||||
options.add_argument("--start-maximized") # Avvia il browser a schermo intero
|
||||
options.add_argument("--no-sandbox") # Disabilita la sandboxing per migliorare le prestazioni
|
||||
options.add_argument("--disable-dev-shm-usage") # Utilizza una directory temporanea per la memoria condivisa
|
||||
options.add_argument("--ignore-certificate-errors") # Ignora gli errori dei certificati SSL
|
||||
options.add_argument("--disable-extensions") # Disabilita le estensioni del browser
|
||||
options.add_argument("--disable-gpu") # Disabilita l'accelerazione GPU
|
||||
options.add_argument("window-size=1200x800") # Imposta la dimensione della finestra del browser
|
||||
options.add_argument("--disable-background-timer-throttling") # Disabilita il throttling dei timer in background
|
||||
options.add_argument("--disable-backgrounding-occluded-windows") # Disabilita la sospensione delle finestre occluse
|
||||
options.add_argument("--disable-translate") # Disabilita il traduttore automatico
|
||||
options.add_argument("--disable-popup-blocking") # Disabilita il blocco dei popup
|
||||
options.add_argument("--no-first-run") # Disabilita la configurazione iniziale del browser
|
||||
options.add_argument("--no-default-browser-check") # Disabilita il controllo del browser predefinito
|
||||
options.add_argument("--disable-logging") # Disabilita il logging
|
||||
options.add_argument("--disable-autofill") # Disabilita l'autocompletamento dei moduli
|
||||
options.add_argument("--disable-plugins") # Disabilita i plugin del browser
|
||||
options.add_argument("--disable-animations") # Disabilita le animazioni
|
||||
options.add_argument("--disable-cache") # Disabilita la cache
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"]) # Esclude switch della modalità automatica e logging
|
||||
options.add_argument("--start-maximized")
|
||||
options.add_argument("--no-sandbox")
|
||||
options.add_argument("--disable-dev-shm-usage")
|
||||
options.add_argument("--ignore-certificate-errors")
|
||||
options.add_argument("--disable-extensions")
|
||||
options.add_argument("--disable-gpu")
|
||||
options.add_argument("window-size=1200x800")
|
||||
options.add_argument("--disable-background-timer-throttling")
|
||||
options.add_argument("--disable-backgrounding-occluded-windows")
|
||||
options.add_argument("--disable-translate")
|
||||
options.add_argument("--disable-popup-blocking")
|
||||
options.add_argument("--no-first-run")
|
||||
options.add_argument("--no-default-browser-check")
|
||||
options.add_argument("--disable-logging")
|
||||
options.add_argument("--disable-autofill")
|
||||
options.add_argument("--disable-plugins")
|
||||
options.add_argument("--disable-animations")
|
||||
options.add_argument("--disable-cache")
|
||||
options.add_experimental_option("excludeSwitches", ["enable-automation", "enable-logging"])
|
||||
|
||||
# Preferenze per contenuti
|
||||
prefs = {
|
||||
"profile.default_content_setting_values.images": 2, # Disabilita il caricamento delle immagini
|
||||
"profile.managed_default_content_settings.stylesheets": 2, # Disabilita il caricamento dei fogli di stile
|
||||
"profile.default_content_setting_values.images": 2,
|
||||
"profile.managed_default_content_settings.stylesheets": 2,
|
||||
}
|
||||
options.add_experimental_option("prefs", prefs)
|
||||
|
||||
if len(chromeProfilePath) > 0:
|
||||
initialPath = os.path.dirname(chromeProfilePath)
|
||||
profileDir = os.path.basename(chromeProfilePath)
|
||||
options.add_argument('--user-data-dir=' + initialPath)
|
||||
options.add_argument("--profile-directory=" + profileDir)
|
||||
initial_path = os.path.dirname(chromeProfilePath)
|
||||
profile_dir = os.path.basename(chromeProfilePath)
|
||||
options.add_argument('--user-data-dir=' + initial_path)
|
||||
options.add_argument("--profile-directory=" + profile_dir)
|
||||
logger.debug("Using Chrome profile directory: %s", chromeProfilePath)
|
||||
else:
|
||||
options.add_argument("--incognito")
|
||||
logger.debug("Using Chrome in incognito mode")
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def printred(text):
|
||||
# Codice colore ANSI per il rosso
|
||||
RED = "\033[91m"
|
||||
RESET = "\033[0m"
|
||||
# Stampa il testo in rosso
|
||||
print(f"{RED}{text}{RESET}")
|
||||
red = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
logger.debug("Printing text in red: %s", text)
|
||||
print(f"{red}{text}{reset}")
|
||||
|
||||
|
||||
def printyellow(text):
|
||||
# Codice colore ANSI per il giallo
|
||||
YELLOW = "\033[93m"
|
||||
RESET = "\033[0m"
|
||||
# Stampa il testo in giallo
|
||||
print(f"{YELLOW}{text}{RESET}")
|
||||
yellow = "\033[93m"
|
||||
reset = "\033[0m"
|
||||
logger.debug("Printing text in yellow: %s", text)
|
||||
print(f"{yellow}{text}{reset}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue