Compare commits

...
Sign in to create a new pull request.

143 commits
main ... v3

Author SHA1 Message Date
Federico
946f8cdd11
Update README.md 2024-09-14 13:48:10 +02:00
Federico
45eaef03d0
Delete resume_yaml_generator.py 2024-09-14 13:46:54 +02:00
Federico
bdbeceeb83
Merge pull request #380 from thomHayner/requirements-file 2024-09-13 23:57:39 +02:00
feder-cr
acfc990b87 check gemini 2024-09-13 23:44:34 +02:00
feder-cr
9e21417e02 gemini check 2024-09-13 23:43:53 +02:00
Thomas
35d0caec6b
Merge branch 'v3' into requirements-file 2024-09-13 15:05:26 -06:00
Thomas Hayner
c4240e110a reinclude last two packages 2024-09-13 13:44:34 -06:00
Thomas Hayner
8c0a4127a8 add new packages 2024-09-13 13:41:29 -06:00
Thomas Hayner
07891940ee organize and alphebetize 2024-09-13 11:56:36 -06:00
Federico
80a7b78b4d
Update config.yaml 2024-09-13 18:37:24 +02:00
Federico
90611d5895
Update config.yaml 2024-09-13 18:36:54 +02:00
Federico
1405246270
Merge pull request #378 from tapas-joshi/v3
Made "llm_api_url" Optional: Not needed for default configurations
2024-09-13 18:21:04 +02:00
feder-cr
b75055e076 little fix 2024-09-13 18:20:04 +02:00
tapas-joshi
0534297ccc Made llm_api_url optional
Tested with Ollama local and it works without providing any URL.
Also made MINIMUM_WAIT_TIME a config variable in app_config which can easily be tweaked
2024-09-13 12:12:42 -04:00
Federico
4554a8e4ec
Merge pull request #377 from feder-cr/test
v3
2024-09-13 17:01:27 +02:00
Federico
5fc087e209
Merge branch 'v3' into test 2024-09-13 17:01:20 +02:00
Federico
0c6f02ee37
Merge pull request #374 from thomHayner/main
update .gitignore
2024-09-13 16:56:44 +02:00
Thomas Hayner
09e41160f9 revert 2024-09-13 02:50:48 -06:00
Thomas Hayner
6d27b823ae cleanup: consistent variable names 2024-09-13 02:30:49 -06:00
Federico
8c7df4c2f9
Merge pull request #368 from tapas-joshi/v3 2024-09-13 10:01:52 +02:00
Thomas
28f4ac137b
Update .gitignore
" * " character after directoryName/ is not necessary
2024-09-13 01:02:10 -06:00
Thomas
75e18f7737
Delete virtual directory 2024-09-13 00:33:50 -06:00
Thomas Hayner
1e5c5cc6be Revert "Delete virtual directory"
This reverts commit 02f8f73c8d.
2024-09-13 00:27:06 -06:00
Thomas
ff1c99821d
Update .gitignore
add:
virtual/*
.DS_Store
2024-09-13 00:06:55 -06:00
Thomas
02f8f73c8d
Delete virtual directory 2024-09-13 00:04:19 -06:00
Thomas Hayner
671f460465 sync fork with origin/main 2024-09-12 21:41:36 -06:00
tapas-joshi
f77706d579 Loguru Integration: Better logs 2024-09-12 21:30:35 -04:00
Federico
c27bf2c715
Merge pull request #367 from queukat/v3 2024-09-13 01:47:48 +02:00
Federico
426c8d017f
Merge pull request #366 from azjz92/yaml-updates-local 2024-09-13 01:47:10 +02:00
queukat
eae841136f fixed easy apply button, make some refactoring and fixed saiving cover letters 2024-09-13 02:33:58 +03:00
queukat
82fc6bedde fixed easy apply button, make some refactoring and fixed saiving cover letters 2024-09-13 02:32:23 +03:00
azjz92
f62ee694c4 updated resume in data folder example to actually work, was out of date 2024-09-12 18:59:18 -04:00
Federico
5568931234
Merge pull request #355 from blackms/v3
Add Unit Tests for LinkedInJobManager
2024-09-11 19:38:24 +02:00
Federico
8787a0d916
Merge pull request #353 from shivamkrsarin1996/v3
Resolved dependency conflict
2024-09-11 19:38:11 +02:00
blackms
2c1322b270 pytest config missing 2024-09-11 19:01:29 +02:00
blackms
6eaf521db6 Adding tests folder 2024-09-11 18:52:17 +02:00
blackms
a4846a8c6f First version with one issue of test suite 2024-09-11 18:48:46 +02:00
Shivam Sareen
ff24e276c1 Update gitignore 2024-09-10 22:01:58 -07:00
Shivam Sareen
5420b6b80d Resolved dependency conflict 2024-09-10 20:20:44 -07:00
Federico
aa5f1dfd5b
Merge pull request #349 from spectreDeveloper/v3
Added three new method to upload resume automatically.
2024-09-10 21:31:33 +02:00
Manu Altieri
d9ffc7542c added resume upload 2024-09-10 21:25:19 +02:00
Manu Altieri
40cf3e4d34 Merge remote-tracking branch 'upstream/v3' into v3 2024-09-10 21:24:34 +02:00
Manu Altieri
c85a2025f6 added resume upload 2024-09-10 21:16:11 +02:00
Manu Altieri
08af24ea3b added resume upload 2024-09-10 21:16:00 +02:00
Federico
99b74fbb61
Merge pull request #341 from khalid-faiz/v3
Adding Google Gemini Implementation
2024-09-10 14:40:26 +02:00
Khalid F. Ahmed
67ad78ba7b updating README.md 2024-09-10 09:31:21 +03:00
Khalid F. Ahmed
0d036f6a6e Adding Google Gemini 2024-09-10 09:16:04 +03:00
Federico
2242b3e0b7
Merge pull request #333 from blackms/v3
Fix method invocation in LoggerChatModel
2024-09-10 00:04:38 +02:00
Federico
bed7c5e173
Merge branch 'v3' into v3 2024-09-10 00:04:07 +02:00
Federico
cfbd77648d
Merge pull request #338 from queukat/v3
fixed names and llm problems
2024-09-10 00:02:16 +02:00
queukat
74f0f13de4 fixed logs and requirements 2024-09-09 23:49:51 +03:00
queukat
3f13d9bf1a fixed names and llm problems 2024-09-09 23:44:16 +03:00
queukat
b554357be9 Revert "fixed some issues"
This reverts commit 6540bbbb40.
2024-09-09 23:39:12 +03:00
blackms
bffb561ad5 yaml files 2024-09-09 19:59:43 +02:00
blackms
f8e1572a44 Restored data_folder and optimized gitignore file 2024-09-09 19:57:25 +02:00
blackms
3a8627c20f Missing inputimeout in requirements.txt 2024-09-09 18:01:54 +02:00
blackms
7d7110253a Fix method invocation in LoggerChatModel
This commit resolves an issue where the LoggerChatModel class was incorrectly
    attempting to call instances of AIModel directly as if they were callable objects.

    Changes include:
    - Modified __call__ method to explicitly use the invoke method when calling AI models.
    - Updated constructor documentation to clarify the type of object expected.
    - Added additional debug logging for better traceability of method entry and exit points.

    These changes ensure that the LoggerChatModel class aligns with the intended design
    patterns and correctly utilizes the AIModel instances, improving the maintainability
    and robustness of the codebase.
2024-09-09 17:52:07 +02:00
queukat
6540bbbb40 fixed some issues 2024-09-09 18:04:42 +03:00
Federico
9ef928569b
Merge pull request #331 from feder-cr/main
v3
2024-09-09 13:14:37 +02:00
Federico
0201331ad4
Merge pull request #302 from shivamkrsarin1996/patch-1 2024-09-09 00:46:13 +02:00
Federico
02e0185d75
Merge pull request #318 from queukat/v3 2024-09-09 00:44:33 +02:00
queukat
5efe6c3048 reformat code 2024-09-08 17:46:00 +03:00
queukat
ad85b9587f Merge remote-tracking branch 'origin/v3' into v3 2024-09-08 17:45:24 +03:00
queukat
62133ef028 new func 2024-09-08 17:44:30 +03:00
queukat
3f2fdb6742
Update linkedIn_easy_applier.py 2024-09-08 16:40:06 +02:00
queukat
58584def99 new func 2024-09-08 17:32:07 +03:00
Federico
77f5b5ab9e
Merge pull request #304 from shivamkrsarin1996/patch-2
Update resume_yaml_generator.py
2024-09-08 00:09:11 +02:00
Federico
ad1b17c031
Merge pull request #295 from queukat/v3
add logs, fix finding easy apply button, fix prompts for achievements
2024-09-08 00:05:23 +02:00
queukat
dd97ec7df1
Update linkedIn_job_manager.py
del comment
2024-09-07 16:28:00 +02:00
queukat
0f48cb1e1e
Merge branch 'v3' into v3 2024-09-07 14:23:03 +03:00
queukat
b6ceeb44ac new func 2024-09-07 14:15:32 +03:00
Shivam Sareen
b45f3af360
Update resume_yaml_generator.py
Updated resume_yaml_generator.py with the correct api key name present in the secrets.yaml file
2024-09-06 14:00:42 -07:00
Shivam Sareen
dfd79dc4c7
Update README.md
Added command to create virtual environment for windows based machine
2024-09-06 12:34:25 -07:00
queukat
35cc5d3bde resolve issues 2024-09-06 20:06:30 +03:00
queukat
b1de845ec1 add logs and some bugs fixes 2024-09-06 02:02:55 +03:00
queukat
525e794f8b add logs and some bugs fixes 2024-09-06 01:53:11 +03:00
Federico
f118bb6bf3
Merge pull request #275 from viniciusnunest/main
Add configuration to apply once per job and once per company based on user settings
2024-09-05 19:35:58 +02:00
Federico
3ab71e82ce
Merge pull request #285 from karnoark/local-v3-02
Fix Section Name Parsing Issue in answer_question_textual_wide_range Method
2024-09-05 19:15:36 +02:00
Federico
2cedd2cf62
Merge pull request #288 from spectreDeveloper/v3
Fix for search method
2024-09-05 17:43:04 +02:00
Manu Altieri
104f99aa96 Fixed search filter to avoid fixed results 2024-09-05 17:27:04 +02:00
karnoark
5673308a3d stripped away additional text 2024-09-05 16:38:23 +05:30
Federico
74b0be8b16
Merge pull request #277 from spectreDeveloper/v3 2024-09-04 20:57:10 +02:00
Manu Altieri
d7fd400918 Added a little list to save jobs_id to avoid re-iterate applied already jobs 2024-09-04 19:51:08 +02:00
Vinicius Nunes
89a8c462f6
Update README.md 2024-09-04 12:51:52 -03:00
Vinicius Tavares
75468db0b9 add config to apply once at company 2024-09-04 10:30:18 -03:00
Federico
ffca48f6c3
Merge pull request #274 from feder-cr/main
v3
2024-09-04 13:22:24 +02:00
Federico
fff143d407
Merge pull request #273 from activatedbonkers/v3
Added templates for issues, and guidelines
2024-09-04 13:21:32 +02:00
Federico
4fffb64a81
Merge pull request #223 from anton6tak/open-ai-tier-upgrade-runtime-errors
add information about request limit errors and account type issues
2024-09-04 13:21:04 +02:00
Syed
5069a0b18c added contributing.md for issues 2024-09-04 07:20:38 +05:30
Syed
b780bae4a0 goodfirst template added 2024-09-04 06:55:35 +05:30
Syed
c77d891a76 wont fix and help templates added 2024-09-04 06:52:42 +05:30
Syed
6ef07a5f30 questions and duplicates template added 2024-09-04 06:49:10 +05:30
Syed
db69b0f1bf documentation issue template 2024-09-04 06:46:44 +05:30
Syed
512efda803 invalid issue template 2024-09-04 06:44:59 +05:30
Syed
cee47f7d70 enhancement template 2024-09-04 06:42:43 +05:30
Syed
08810adf05 fixed yaml indentations 2024-09-04 06:38:23 +05:30
Syed
059f5e9302 fixed erros for arrays 2024-09-04 06:28:26 +05:30
Syed
14fff0b051 prettified yml 2024-09-04 06:25:31 +05:30
Syed
9e88d3ba8d Add config for templates 2024-09-04 06:22:08 +05:30
Syed
f9b6f36357 Add bug issue template 2024-09-04 06:07:44 +05:30
Federico
e3bcc9d5e8
Merge pull request #266 from feder-cr/main
add
2024-09-03 19:08:44 +02:00
Federico
314f054fc0
Merge pull request #204 from mmcc007/main
plain_text_resume.yaml generator from text resume
2024-09-03 17:54:20 +02:00
Federico
1b6c388a0f
Merge pull request #247 from spectreDeveloper/v3
Instructions to apply_jobs function toDo
2024-09-03 17:53:56 +02:00
Federico
a17d3766d4
Merge pull request #260 from feder-cr/main
cover letter fixed
2024-09-03 17:14:38 +02:00
Manu Altieri
092f4e7032 Added instructions toDo; about apply method 2024-09-03 00:27:54 +02:00
Maurice McCabe
3511dbc88e
Merge branch 'v3' into main 2024-09-02 08:13:09 -07:00
Federico
6f383632a0
Merge pull request #230 from spectreDeveloper/v3
Added method to get fields required for fast apply via API, fixed easyApply filter for search.
2024-09-02 14:41:23 +02:00
Federico
15bbd61177
Merge pull request #199 from zaverichintan/v3
Added support for Ollama running locally or publicly hosted api
2024-09-02 14:27:48 +02:00
Federico
020d7a24dc
Merge branch 'v3' into v3 2024-09-02 14:27:29 +02:00
Manu Altieri
d2372523fa fixed the parsing of the required fields for apply 2024-09-02 11:10:18 +02:00
Maurice McCabe
685da0f9fc replace lib PYPDF2 with pdfminer.six 2024-09-02 01:44:54 -07:00
Maurice McCabe
23567ee7c4 updated for generating resume yaml from pdf 2024-09-02 01:25:38 -07:00
Maurice McCabe
ff087129b0 add support for converting a pdf to txt 2024-09-02 01:06:11 -07:00
Manu Altieri
d76a35fa7c Fixed easy apply search, implemented method to extract required fields from API with new endpoint, Todo; parse result json to return a dict that contains title, optionUrn and possible choices for the value if present 2024-09-01 22:53:05 +02:00
Manu Altieri
c0f186ba84 Fixed easy apply search, implemented method to extract required fields from API with new endpoint, Todo; parse result json to return a dict that contains title, optionUrn and possible choices for the value if present 2024-09-01 22:48:01 +02:00
user
d1d9e9f6a3 config yaml file with default openai gpt freely hosted 2024-09-01 21:16:02 +02:00
user
28b8fa3746 README updated 2024-09-01 21:15:03 +02:00
anton6tak
bb4e0ed8a6 add information about request limit errors and account type issues 2024-09-01 10:49:36 -07:00
user
777d1fa4a6 Claude support with langchain antrhopic 2024-09-01 19:33:42 +02:00
Federico
60b3c9950d
Merge pull request #217 from earl562/main
Documentation TODO: https://github.com/feder-cr/linkedIn_auto_jobs_applier_with_AI/issues/193
2024-09-01 16:08:47 +02:00
EP
28e4f29e21
Merge branch 'v3' into main 2024-09-01 09:19:24 -04:00
Earl Perry
9ed0c3ea63 Instead of adding a documentation section, improved trouble shooting guide, with current issues, solutions, and addtional resources 2024-09-01 09:01:52 -04:00
user
6b4ebe1bd0 config.yaml corrected 2024-09-01 12:01:39 +02:00
user
a9d9e13474 Adapter class added 2024-09-01 11:59:43 +02:00
Maurice McCabe
aa5f319494
Merge branch 'v3' into main 2024-08-31 21:55:01 -07:00
Maurice McCabe
369c23791d use gpt-4o-mini 2024-08-31 15:58:55 -07:00
Maurice McCabe
67543499ab update cmdline utility to match README 2024-08-31 15:41:33 -07:00
Maurice McCabe
93691913f9 update cmdline utility to match README 2024-08-31 15:41:08 -07:00
Maurice McCabe
fbb04b5f67
Merge branch 'feder-cr:main' into main 2024-08-31 14:57:15 -07:00
Maurice McCabe
be8ddb8241 resume generator cmdline utility 2024-08-31 14:56:37 -07:00
user
ca4f56833a Added support for ollama endpoint and defining the LLM model in the config 2024-08-31 22:58:40 +02:00
queukat
0c4ae18064 add logs and some bugs fixes 2024-08-31 23:45:10 +03:00
queukat
966e610fee add logs and some bugs fixes 2024-08-31 23:43:39 +03:00
Federico
3c0fbb4969
Merge pull request #200 from spectreDeveloper/v3
Added a new pyhon class linkedin-api.py, edited to allow to search only jobs with easyApply easly.
2024-08-31 21:07:56 +02:00
user
d964f599be Added support for Ollama running locally or publicly hosted api 2024-08-31 20:19:33 +02:00
Manu Altieri
bac05ad04c Adding linkedin-api.py to search jobs without use selenium (requirements) 2024-08-31 19:39:44 +02:00
Manu Altieri
9fc3274b9a Adding linkedin-api.py to search jobs without use selenium. 2024-08-31 19:31:31 +02:00
Federico
6f3b3252ba
Merge pull request #186 from air55555/v3
Readme upd to ease troubleshooting and fixing
2024-08-31 14:11:55 +02:00
1
761bb91e96 Readme upd to ease troubleshooting and fixing 2024-08-31 12:41:05 +03:00
feder-cr
0e90d4be7d now we use pydantic for yaml validator 2024-08-29 19:01:42 +02:00
Federico
45bc6ea95d
Merge pull request #135 from spectreDeveloper/v3
logged in functions optimized
2024-08-29 17:31:56 +02:00
feder-cr
e898df9880 v3 lib 2024-08-29 17:11:38 +02:00
Manu Altieri
f948443a39 Make the function better, because is not needed check selectors. when a user is logged on linkedin will be redirected already on /feed/ endpoint, otherwise url will remain https://linkedin.com, so is possible check the state of login just using this approach 2024-08-29 17:08:26 +02:00
44 changed files with 3982 additions and 717 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

65
.github/CONTRIBUTING.md vendored Normal file
View 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 hasnt 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 youre unsure whether something is a bug or if youre 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 youre 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
View 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
View 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"

View 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..."

View 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..."

View 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 youve 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..."

View 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
View 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..."

View 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..."

View 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..."

View 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..."

165
.gitignore vendored
View file

@ -1,15 +1,152 @@
*.csv
__pycache__/**
.idea/**
open_ai_calls.log
test*
openaiSelenium*
open_ai_calls.json
_*
.venv
generated_cv*
.vscode
chrome_profile
# 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
chrome_profile/*
data_folder/*
answers.json
data*
*virtual
# 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, pipenvs dependency resolution may lead to different
# Pipfile.lock files generated on each colleagues 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
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.*

115
README.md
View file

@ -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,6 +223,23 @@ 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
@ -504,18 +533,82 @@ 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
## Troubleshooting
#### 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/)
- **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.

4
app_config.py Normal file
View file

@ -0,0 +1,4 @@
# LOGGING
MINIMUM_LOG_LEVEL = "DEBUG"
MINIMUM_WAIT_TIME = 60 * 15

132
assets/resume_schema.yaml Normal file
View 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]

View file

@ -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
job_applicants_threshold:
min_applicants: 0
max_applicants: 100
llm_model_type: openai
llm_model: gpt-4o-mini
# llm_api_url: https://api.pawan.krd/cosmosrp/v1 this field is optional

View file

@ -1,3 +1,3 @@
email: myemaillinkedin@gmail.com
password: ImpossiblePassowrd10
openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR
llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR'

View file

@ -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'

View file

@ -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"
institution: "Bob academy"
field_of_study: "Bobs Engineering"
final_evaluation_grade: "4.0"
year_of_completion: "2023"
start_date: "2022"
additional_info:
exam:
Computer Networks: "30/30"
Advanced Algorithms: "30/30"
Database Systems: "30/30"
Embedded Systems: "30/30"
Artificial Intelligence: "30/30"
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:

View 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

View file

@ -1,3 +1,3 @@
email: myemaillinkedin@gmail.com
password: ImpossiblePassowrd10
openai_api_key: sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR
llm_api_key: 'sk-11KRr4uuTwpRGfeRTfj1T9BlbkFJjP8QTrswHU1yGruru2FR'

46
main.py
View file

@ -7,14 +7,15 @@ import click
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from webdriver_manager.chrome import ChromeDriverManager
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.common.exceptions import WebDriverException
from lib_resume_builder_AIHawk import Resume,StyleManager,FacadeManager,ResumeGenerator
from src.utils import chromeBrowserOptions
from src.gpt import GPTAnswerer
from src.utils import chrome_browser_options
from src.llm.llm_manager import GPTAnswerer
from src.linkedIn_authenticator import LinkedInAuthenticator
from src.linkedIn_bot_facade import LinkedInBotFacade
from src.linkedIn_job_manager import LinkedInJobManager
from src.job_application_profile import JobApplicationProfile
from loguru import logger
# Suppress stderr
sys.stderr = open(os.devnull, 'w')
@ -101,7 +102,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 +112,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 +150,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 +173,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)
@ -184,7 +182,7 @@ def create_and_run_bot(email: str, password: str, parameters: dict, openai_api_k
bot.start_login()
bot.start_apply()
except WebDriverException as e:
print(f"WebDriver error occurred: {e}")
logger.error(f"WebDriver error occurred: {e}")
except Exception as e:
raise RuntimeError(f"Error running the bot: {str(e)}")
@ -197,27 +195,27 @@ 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")
logger.error(f"Configuration error: {str(ce)}")
logger.error(f"Refer to the configuration guide for troubleshooting: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration {str(ce)}")
except FileNotFoundError as fnf:
print(f"File not found: {str(fnf)}")
print("Ensure all required files are present in the data folder.")
print("Refer to the file setup guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
logger.error(f"File not found: {str(fnf)}")
logger.error("Ensure all required files are present in the data folder.")
logger.error("Refer to the file setup guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
except RuntimeError as re:
print(f"Runtime error: {str(re)}")
logger.error(f"Runtime error: {str(re)}")
print("Refer to the configuration and troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
logger.error("Refer to the configuration and troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
except Exception as e:
print(f"An unexpected error occurred: {str(e)}")
print("Refer to the general troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
logger.error(f"An unexpected error occurred: {str(e)}")
logger.error("Refer to the general troubleshooting guide: https://github.com/feder-cr/LinkedIn_AIHawk_automatic_job_application/blob/main/readme.md#configuration")
if __name__ == "__main__":
main()

5
pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
minversion = 6.0
addopts = --strict-markers --tb=short --cov=src --cov-report=term-missing
testpaths =
tests

Binary file not shown.

View file

@ -1,327 +0,0 @@
import json
import os
import re
import textwrap
from datetime import datetime
from typing import Dict, List
from pathlib import Path
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
load_dotenv()
class LLMLogger:
def __init__(self, llm: ChatOpenAI):
self.llm = llm
@staticmethod
def log_request(prompts, parsed_reply: Dict[str, Dict]):
calls_log = os.path.join(Path("data_folder/output"), "open_ai_calls.json")
if isinstance(prompts, StringPromptValue):
prompts = prompts.text
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)
}
else:
prompts = {
f"prompt_{i+1}": prompt.content
for i, prompt in enumerate(prompts.messages)
}
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")
class LoggerChatModel:
def __init__(self, llm: ChatOpenAI):
self.llm = 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
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
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)
)
@property
def job_description(self):
return self.job.description
@staticmethod
def find_best_match(text: str, options: list[str]) -> str:
distances = [
(option, distance(text.lower(), option.lower())) for option in options
]
best_option = min(distances, key=lambda x: x[1])[0]
return best_option
@staticmethod
def _remove_placeholders(text: str) -> str:
text = text.replace("PLACEHOLDER", "")
return text.strip()
@staticmethod
def _preprocess_template_string(template: str) -> str:
# Preprocess a template string to remove unnecessary indentation.
return textwrap.dedent(template)
def set_resume(self, resume):
self.resume = resume
def set_job(self, job):
self.job = job
self.job.set_summarize_job_description(self.summarize_job_description(self.job.description))
def set_job_application_profile(self, job_application_profile):
self.job_application_profile = job_application_profile
def summarize_job_description(self, text: str) -> str:
strings.summarize_prompt_template = self._preprocess_template_string(
strings.summarize_prompt_template
)
prompt = ChatPromptTemplate.from_template(strings.summarize_prompt_template)
chain = prompt | self.llm_cheap | StrOutputParser()
output = chain.invoke({"text": text})
return output
def _create_chain(self, template: str):
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
chains = {
"personal_information": self._create_chain(strings.personal_information_template),
"self_identification": self._create_chain(strings.self_identification_template),
"legal_authorization": self._create_chain(strings.legal_authorization_template),
"work_preferences": self._create_chain(strings.work_preferences_template),
"education_details": self._create_chain(strings.education_details_template),
"experience_details": self._create_chain(strings.experience_details_template),
"projects": self._create_chain(strings.projects_template),
"availability": self._create_chain(strings.availability_template),
"salary_expectations": self._create_chain(strings.salary_expectations_template),
"certifications": self._create_chain(strings.certifications_template),
"languages": self._create_chain(strings.languages_template),
"interests": self._create_chain(strings.interests_template),
"cover_letter": self._create_chain(strings.coverletter_template),
}
section_prompt = """
You are assisting a bot designed to automatically apply for jobs on LinkedIn. The bot receives various questions about job applications and needs to determine the most relevant section of the resume to provide an accurate response.
For the following question: '{question}', determine which section of the resume is most relevant.
Respond with exactly one of the following options:
- Personal information
- Self Identification
- Legal Authorization
- Work Preferences
- Education Details
- Experience Details
- Projects
- Availability
- Salary Expectations
- Certifications
- Languages
- Interests
- Cover letter
Here are detailed guidelines to help you choose the correct section:
1. **Personal Information**:
- **Purpose**: Contains your basic contact details and online profiles.
- **Use When**: The question is about how to contact you or requests links to your professional online presence.
- **Examples**: Email address, phone number, LinkedIn profile, GitHub repository, personal website.
2. **Self Identification**:
- **Purpose**: Covers personal identifiers and demographic information.
- **Use When**: The question pertains to your gender, pronouns, veteran status, disability status, or ethnicity.
- **Examples**: Gender, pronouns, veteran status, disability status, ethnicity.
3. **Legal Authorization**:
- **Purpose**: Details your work authorization status and visa requirements.
- **Use When**: The question asks about your ability to work in specific countries or if you need sponsorship or visas.
- **Examples**: Work authorization in EU and US, visa requirements, legally allowed to work.
4. **Work Preferences**:
- **Purpose**: Specifies your preferences regarding work conditions and job roles.
- **Use When**: The question is about your preferences for remote work, in-person work, relocation, and willingness to undergo assessments or background checks.
- **Examples**: Remote work, in-person work, open to relocation, willingness to complete assessments.
5. **Education Details**:
- **Purpose**: Contains information about your academic qualifications.
- **Use When**: The question concerns your degrees, universities attended, GPA, and relevant coursework.
- **Examples**: Degree, university, GPA, field of study, exams.
6. **Experience Details**:
- **Purpose**: Details your professional work history and key responsibilities.
- **Use When**: The question pertains to your job roles, responsibilities, and achievements in previous positions.
- **Examples**: Job positions, company names, key responsibilities, skills acquired.
7. **Projects**:
- **Purpose**: Highlights specific projects you have worked on.
- **Use When**: The question asks about particular projects, their descriptions, or links to project repositories.
- **Examples**: Project names, descriptions, links to project repositories.
8. **Availability**:
- **Purpose**: Provides information on your availability for new roles.
- **Use When**: The question is about how soon you can start a new job or your notice period.
- **Examples**: Notice period, availability to start.
9. **Salary Expectations**:
- **Purpose**: Covers your expected salary range.
- **Use When**: The question pertains to your salary expectations or compensation requirements.
- **Examples**: Desired salary range.
10. **Certifications**:
- **Purpose**: Lists your professional certifications or licenses.
- **Use When**: The question involves your certifications or qualifications from recognized organizations.
- **Examples**: Certification names, issuing bodies, dates of validity.
11. **Languages**:
- **Purpose**: Describes the languages you can speak and your proficiency levels.
- **Use When**: The question asks about your language skills or proficiency in specific languages.
- **Examples**: Languages spoken, proficiency levels.
12. **Interests**:
- **Purpose**: Details your personal or professional interests.
- **Use When**: The question is about your hobbies, interests, or activities outside of work.
- **Examples**: Personal hobbies, professional interests.
13. **Cover Letter**:
- **Purpose**: Contains your personalized cover letter or statement.
- **Use When**: The question involves your cover letter or specific written content intended for the job application.
- **Examples**: Cover letter content, personalized statements.
Provide only the exact name of the section from the list above with no additional text.
"""
prompt = ChatPromptTemplate.from_template(section_prompt)
chain = prompt | self.llm_cheap | StrOutputParser()
output = chain.invoke({"question": question})
section_name = output.lower().replace(" ", "_")
if section_name == "cover_letter":
chain = chains.get(section_name)
output = chain.invoke({"resume": self.resume, "job_description": self.job_description})
return output
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name, None)
if resume_section is None:
raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.")
chain = chains.get(section_name)
if chain is None:
raise ValueError(f"Chain not defined for section '{section_name}'")
return chain.invoke({"resume_section": resume_section, "question": question})
def answer_question_numeric(self, question: str, default_experience: int = 3) -> int:
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})
try:
output = self.extract_number_from_string(output_str)
except ValueError:
output = default_experience
return output
def extract_number_from_string(self, output_str):
numbers = re.findall(r"\d+", output_str)
if numbers:
return int(numbers[0])
else:
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)
prompt = ChatPromptTemplate.from_template(func_template)
chain = prompt | self.llm_cheap | StrOutputParser()
output_str = chain.invoke({"resume": self.resume, "question": question, "options": options})
best_option = self.find_best_match(output_str, options)
return best_option
def resume_or_cover(self, phrase: str) -> str:
# Define the prompt template
prompt_template = """
Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter. 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})
if "resume" in response:
return "resume"
elif "cover" in response:
return "cover"
else:
return "resume"

View file

@ -1,5 +1,8 @@
from dataclasses import dataclass
from loguru 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(f"Setting summarized job description: {summarize_job_description}")
self.summarize_job_description = summarize_job_description
def set_job_description(self, description):
logger.debug(f"Setting job description: {description}")
self.description = description
def set_recruiter_link(self, recruiter_link):
logger.debug(f"Setting recruiter link: {recruiter_link}")
self.recruiter_link = recruiter_link
def formatted_job_information(self):
"""
Formats the job information as a markdown string.
"""
logger.debug(f"Formatting job information for job: {self.title} at {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(f"Formatted job information: {formatted_information}")
return formatted_information

View file

@ -1,7 +1,10 @@
from dataclasses import dataclass
from typing import Dict, List
import yaml
from loguru 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(f"YAML data successfully parsed: {data}")
except yaml.YAMLError as e:
logger.error(f"Error parsing YAML file: {e}")
raise ValueError("Error parsing YAML file.") from e
except Exception as e:
logger.error(f"Unexpected error occurred while parsing the YAML file: {e}")
raise RuntimeError("An unexpected error occurred while parsing the YAML file.") from e
if not isinstance(data, dict):
logger.error(f"YAML data must be a dictionary, received: {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(f"self_identification processed: {self.self_identification}")
except KeyError as e:
logger.error(f"Required field {e} is missing in self_identification data.")
raise KeyError(f"Required field {e} is missing in self_identification data.") from e
except TypeError as e:
logger.error(f"Error in self_identification data: {e}")
raise TypeError(f"Error in self_identification data: {e}") from e
except AttributeError as e:
logger.error(f"Attribute error in self_identification processing: {e}")
raise AttributeError("Attribute error in self_identification processing.") from e
except Exception as e:
logger.error(f"An unexpected error occurred while processing self_identification: {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(f"legal_authorization processed: {self.legal_authorization}")
except KeyError as e:
logger.error(f"Required field {e} is missing in legal_authorization data.")
raise KeyError(f"Required field {e} is missing in legal_authorization data.") from e
except TypeError as e:
logger.error(f"Error in legal_authorization data: {e}")
raise TypeError(f"Error in legal_authorization data: {e}") from e
except AttributeError as e:
logger.error(f"Attribute error in legal_authorization processing: {e}")
raise AttributeError("Attribute error in legal_authorization processing.") from e
except Exception as e:
logger.error(f"An unexpected error occurred while processing legal_authorization: {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(f"Work_preferences processed: {self.work_preferences}")
except KeyError as e:
logger.error(f"Required field {e} is missing in work_preferences data.")
raise KeyError(f"Required field {e} is missing in work_preferences data.") from e
except TypeError as e:
logger.error(f"Error in work_preferences data: {e}")
raise TypeError(f"Error in work_preferences data: {e}") from e
except AttributeError as e:
logger.error(f"Attribute error in work_preferences processing: {e}")
raise AttributeError("Attribute error in work_preferences processing.") from e
except Exception as e:
logger.error(f"An unexpected error occurred while processing work_preferences: {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(f"Availability processed: {self.availability}")
except KeyError as e:
logger.error(f"Required field {e} is missing in availability data.")
raise KeyError(f"Required field {e} is missing in availability data.") from e
except TypeError as e:
logger.error(f"Error in availability data: {e}")
raise TypeError(f"Error in availability data: {e}") from e
except AttributeError as e:
logger.error(f"Attribute error in availability processing: {e}")
raise AttributeError("Attribute error in availability processing.") from e
except Exception as e:
logger.error(f"An unexpected error occurred while processing availability: {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(f"salary_expectations processed: {self.salary_expectations}")
except KeyError as e:
logger.error(f"Required field {e} is missing in salary_expectations data.")
raise KeyError(f"Required field {e} is missing in salary_expectations data.") from e
except TypeError as e:
logger.error(f"Error in salary_expectations data: {e}")
raise TypeError(f"Error in salary_expectations data: {e}") from e
except AttributeError as e:
logger.error(f"Attribute error in salary_expectations processing: {e}")
raise AttributeError("Attribute error in salary_expectations processing.") from e
except Exception as e:
logger.error(f"An unexpected error occurred while processing salary_expectations: {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"
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(f"String representation generated: {formatted_str}")
return formatted_str

View file

@ -1,8 +1,13 @@
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 loguru import logger
class LinkedInAuthenticator:
@ -10,81 +15,123 @@ class LinkedInAuthenticator:
self.driver = driver
self.email = ""
self.password = ""
logger.debug(f"LinkedInAuthenticator initialized with driver: {driver}")
def set_secrets(self, email, password):
self.email = email
self.password = password
logger.debug(f"Secrets set with email: {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.")
logger.debug("User is already logged in.")
return
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(f"Could not log in to LinkedIn. Element not found: {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(f"Email entered: {self.email}")
password_field = self.driver.find_element(By.ID, "password")
password_field.send_keys(self.password)
logger.debug("Password entered.")
except TimeoutException:
print("Login form not found. Aborting login.")
logger.error("Login form not found. Aborting login.")
logger.error("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:
print("Login button not found. Please verify the page structure.")
logger.error("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/')
)
print("Security checkpoint detected. Please complete the challenge.")
logger.warning("Security checkpoint detected. Please complete the challenge.")
WebDriverWait(self.driver, 300).until(
EC.url_contains('https://www.linkedin.com/feed/')
)
print("Security check completed")
logger.info("Security check completed")
except TimeoutException:
print("Security check not completed. Please try again later.")
logger.error("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(f"Navigating to target URL: {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(f"Found {len(buttons)} 'Start a post' buttons")
for i, button in enumerate(buttons):
logger.debug(f"Button {i + 1} text: {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
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(f"Waiting for page to load with timeout: {timeout} seconds")
WebDriverWait(self.driver, timeout).until(
lambda d: d.execute_script('return document.readyState') == 'complete'
)
logger.debug("Page load completed.")
except TimeoutException:
print("Page load timed out.")
logger.error("Page load timed out.")

View file

@ -1,8 +1,13 @@
from loguru 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(f"Validating LinkedInBotState with required keys: {required_keys}")
for key in required_keys:
if not getattr(self, key):
logger.error(f"State validation failed: {key} is not set")
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(f"Validating that {name} is not empty")
if not value:
logger.error(f"Validation failed: {name} is empty")
raise ValueError(f"{name} cannot be empty.")
logger.debug(f"Validation passed for {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")

View file

@ -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 loguru 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,9 +35,11 @@ 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'
try:
logger.debug(f"Loading questions from JSON file: {output_file}")
try:
with open(output_file, 'r') as f:
try:
@ -40,96 +47,245 @@ 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:
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(f"Error loading questions data from JSON file: {tb_str}")
raise Exception(f"Error loading questions data from JSON file: \nTraceback:\n{tb_str}")
def job_apply(self, job: Any):
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(random.uniform(3, 5))
time.sleep(2)
current_url = self.driver.current_url
if "linkedin.com/premium" in current_url:
logger.error(f"Failed to return to job page after {max_attempts} attempts. Cannot apply for the job.")
raise Exception(
f"Redirected to LinkedIn Premium page and failed to return after {max_attempts} attempts. Job application aborted.")
def apply_to_job(self, job: Any) -> None:
"""
Starts the process of applying to a job.
:param job: A job object with the job details.
:return: None
"""
logger.debug(f"Applying to job: {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.job_apply(job)
logger.info(f"Successfully applied to job: {job.title}")
except Exception as e:
logger.error(f"Failed to apply to job: {job.title}, error: {str(e)}")
raise e
def job_apply(self, job: Any):
logger.debug(f"Starting job application for job: {job}")
try:
self.driver.get(job.link)
logger.debug(f"Navigated to job link: {job.link}")
except Exception as e:
logger.error(f"Failed to navigate to job link: {job.link}, error: {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(f"Job description set: {job_description[:100]}")
logger.debug("Retrieving recruiter link")
recruiter_link = self._get_job_recruiter()
job.set_recruiter_link(recruiter_link)
logger.debug(f"Recruiter link set: {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(f"Job application process completed successfully for job: {job}")
except Exception as e:
tb_str = traceback.format_exc()
logger.error(f"Failed to apply to job: {job}, error: {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:
pass
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']))
)
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:
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(f"No clickable 'Easy Apply' button found after 2 attempts. Page source:\n{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"]')
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(f"Job description not found: {tb_str}")
raise Exception(f"Job description not found: \nTraceback:\n{tb_str}")
except Exception:
tb_str = traceback.format_exc()
logger.error(f"Error getting Job description: {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/")]')
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(f"Job recruiter link retrieved successfully: {recruiter_link}")
return recruiter_link
else:
logger.debug("No recruiter link found in the hiring team section")
return ""
except Exception as e:
logger.warning(f"Failed to retrieve recruiter information: {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(f"Filling out application form for job: {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 +298,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.debug(f"Failed to unfollow company: {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(f"Form submission failed with errors: {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(f"Failed to discard application: {e}")
def fill_up(self, job) -> None:
easy_apply_content = self.driver.find_element(By.CLASS_NAME, 'jobs-easy-apply-content')
logger.debug(f"Filling up form sections for job: {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(f"Element is upload field: {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")
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(self.resume_generator_manager.pdf_base64(job_description_text=job.description)))
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)
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)
text_object.textLines(cover_letter)
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()
element.send_keys(letter_path)
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 +641,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
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
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:
@ -296,47 +728,86 @@ class LinkedInEasyApplier:
for item in self.all_data:
if self._sanitize_text(question_text) in item['question'] and item['type'] == 'date':
existing_answer = item
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'])
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(f"Field type: {field_type}, Field ID: {field_id}, Is numeric: {is_numeric}")
return is_numeric
def _enter_text(self, element: WebElement, text: str) -> None:
logger.debug(f"Entering text: {text}")
element.clear()
element.send_keys(text)
def _select_radio(self, radios: List[WebElement], answer: str) -> None:
logger.debug(f"Selecting radio option: {answer}")
for radio in radios:
if answer in radio.text.lower():
radio.find_element(By.TAG_NAME, 'label').click()
@ -344,12 +815,14 @@ class LinkedInEasyApplier:
radios[-1].find_element(By.TAG_NAME, 'label').click()
def _select_dropdown_option(self, element: WebElement, text: str) -> None:
logger.debug(f"Selecting dropdown option: {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(f"Saving question data to JSON: {question_data}")
try:
try:
with open(output_file, 'r') as f:
@ -358,22 +831,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(f"Error saving questions data to JSON file: {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(f"Sanitized text: {sanitized_text}")
return sanitized_text

View file

@ -1,136 +1,324 @@
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 app_config import MINIMUM_WAIT_TIME
from src.job import Job
from src.linkedIn_easy_applier import LinkedInEasyApplier
import json
from loguru 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(f"EnvironmentKeys initialized: skip_apply={self.skip_apply}, disable_description_filter={self.disable_description_filter}")
@staticmethod
def _read_env_key(key: str) -> str:
return os.getenv(key, "")
value = os.getenv(key, "")
logger.debug(f"Read environment key {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(f"Read environment key {key} as bool: {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
minimum_time = 60 * 15
minimum_time = MINIMUM_WAIT_TIME
minimum_page_time = time.time() + minimum_time
for position, location in searches:
location_url = "&location=" + location
job_page_number = -1
utils.printyellow(f"Starting the search for {position} in {location}.")
logger.debug(f"Starting the search for {position} in {location}.")
try:
while True:
page_sleep += 1
job_page_number += 1
utils.printyellow(f"Going to job page {job_page_number}")
logger.debug(f"Going to job page {job_page_number}")
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...")
logger.debug("Starting the application process for this page...")
try:
jobs = self.get_jobs_from_page()
if not jobs:
logger.debug("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()
utils.printyellow("Applying to jobs on this page has been completed!")
except Exception as e:
logger.error(f"Error during job application: {e}")
continue
logger.debug("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.")
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.")
else:
logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.")
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.")
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.")
else:
logger.debug(f"Sleeping for {sleep_time} seconds.")
time.sleep(sleep_time)
page_sleep += 1
except Exception:
traceback.format_exc()
pass
except Exception as e:
logger.error(f"Unexpected error during job search: {e}")
continue
time_left = minimum_page_time - time.time()
if time_left > 0:
utils.printyellow(f"Sleeping for {time_left} seconds.")
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.")
else:
logger.debug(f"Sleeping for {time_left} seconds as user chose not to skip.")
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.")
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.")
else:
logger.debug(f"Sleeping for {sleep_time} seconds.")
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():
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:
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")
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")
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:
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(f"Job blacklisted: {job.title} at {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(f"Applied to job: {job.title} at {job.company}")
except Exception as e:
utils.printred(traceback.format_exc())
logger.error(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(f"Writing job application result to file: {file_name}")
pdf_path = Path(job.pdf_path).resolve()
pdf_path = pdf_path.as_uri()
data = {
@ -145,22 +333,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(f"Job data written to new file: {file_name}")
else:
with open(file_path, 'r+', encoding='utf-8') as f:
try:
existing_data = json.load(f)
except json.JSONDecodeError:
logger.error(f"JSON decode error in file: {file_path}")
existing_data = []
existing_data.append(data)
f.seek(0)
json.dump(existing_data, f, indent=4)
f.truncate()
logger.debug(f"Job data appended to existing file: {file_name}")
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 +369,70 @@ 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(f"Base search URL constructed: {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(f"Navigating to next job page: {position} in {location}, page {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(f"Job information extracted: {job_title} at {company}")
except NoSuchElementException:
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:
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"
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(f"Checking if job is blacklisted: {job_title} at {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(f"Job blacklisted status: {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:
logger.debug(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():
logger.debug(
f"Already applied at {company} (once per company policy), skipping...")
return True
except json.JSONDecodeError:
continue
return False

521
src/linkedin-api.py Normal file
View file

@ -0,0 +1,521 @@
from typing import Dict, List
from linkedin_api import Linkedin
from typing import Optional, Union, Literal
from urllib.parse import quote, urlencode, parse_qs, urlparse
# import logging
import json
from loguru import logger
# 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 create_request_pdf(self, filename: str) -> str | None:
"""
Create a PDF file with the request data.
:param filename: Name of the file
:type filename: str | None
:return: URL of the file uploaded to the LinkedIn.
"""
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 = {
'action': 'requestUrl'
}
res = self._post(
f"/voyagerJobsDashAmbryUploadUrls",
headers=headers,
cookies=cookies,
json={"contentType":"PDF","filename":"200.pdf","maxSizeBytes":18810},
params=default_params
)
match res.status_code:
case 200:
parse_res = res.json()
url = parse_res['data']['value']
logger.info(url)
return url
case _:
self.logger.error("Failed to create a request PDF")
return None
def upload_resume_via_ambry(self, url: str, cv_path: str) -> bool | str:
"""
Upload resume via Ambry.
:param url: URL of the file uploaded to the LinkedIn.
:type url: str
:param raw_cv: Raw CV data
:type raw_cv: bytes
:return: PDF hash.pdf or false
"""
binary_cv: bytes = self.file_to_binary(cv_path)
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"
ambry_url = url.replace("https://www.linkedin.com", "")
res = self._post(
ambry_url,
base_request=True,
headers=headers,
cookies=cookies,
data=binary_cv,
)
match res.status_code:
case 201:
return res.headers['Location']
case _:
self.logger.error("Failed to upload resume via Ambry")
return False
def confirm_upload_resume(self, cv_hash: str) -> bool:
"""
Upload resume.
:param cv_hash: PDF hash
:type cv_hash: str
:return: True if success, False if failed
"""
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"
json_data = {'entityUrn': f'urn:li:fsd_resume:{cv_hash}'}
res = self._post(
"/voyagerJobsDashResumes",
headers=headers,
cookies=cookies,
json=json_data,
)
match res.status_code:
case 201:
return True
case _:
self.logger.error("Failed to upload resume")
return False
def file_to_binary(self, file_path):
with open(file_path, 'rb') as file:
binary_data = file.read()
return binary_data
def set_job_as_applied(self, job_id: str) -> None:
self.already_applied_jobs.append(job_id)
def upload_linkedin_resume(self, cv_path: str) -> str | bool:
url = self.create_request_pdf("resume.pdf")
if url:
cv_hash = self.upload_resume_via_ambry(url, cv_path)
if cv_hash:
self.confirm_upload_resume(cv_hash)
return cv_hash
return False
## 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"]
resume: str = api.upload_linkedin_resume("resume.pdf")
if isinstance(resume, bool):
logger.error("Failed to upload resume")
continue
elif isinstance(resume, str):
logger.info(f"Resume uploaded with hash {resume}")
else:
logger.error("Unknown error")
continue
if job_id in api.already_applied_jobs:
logger.info(f"Already applied to job {job_id}, skipping it")
continue
fields = api.get_fields_for_easy_apply(job_id)
for field in fields:
logger.info(field)
break

588
src/llm/llm_manager.py Normal file
View file

@ -0,0 +1,588 @@
import json
import os
import re
import textwrap
import time
from abc import ABC, abstractmethod
from datetime import datetime
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 import BaseMessage
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
import src.strings as strings
from loguru 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):
from langchain_openai import ChatOpenAI
self.model = ChatOpenAI(model_name=llm_model, openai_api_key=api_key,
temperature=0.4)
def invoke(self, prompt: str) -> BaseMessage:
logger.debug("Invoking OpenAI API")
response = self.model.invoke(prompt)
return response
class ClaudeModel(AIModel):
def __init__(self, api_key: str, llm_model: str):
from langchain_anthropic import ChatAnthropic
self.model = ChatAnthropic(model=llm_model, api_key=api_key,
temperature=0.4)
def invoke(self, prompt: str) -> BaseMessage:
response = self.model.invoke(prompt)
logger.debug("Invoking Claude API")
return response
class OllamaModel(AIModel):
def __init__(self, llm_model: str, llm_api_url: str):
from langchain_ollama import ChatOllama
if len(llm_api_url) > 0:
logger.debug(f"Using Ollama with API URL: {llm_api_url}")
self.model = ChatOllama(model=llm_model, base_url=llm_api_url)
else:
self.model = ChatOllama(model=llm_model)
def invoke(self, prompt: str) -> BaseMessage:
response = self.model.invoke(prompt)
return response
#gemini doesn't seem to work because API doesn't rstitute answers for questions that involve answers that are too short
class GeminiModel(AIModel):
def __init__(self, api_key:str, llm_model: str):
from langchain_google_genai import ChatGoogleGenerativeAI, HarmBlockThreshold, HarmCategory
self.model = ChatGoogleGenerativeAI(model=llm_model, google_api_key=api_key,safety_settings={
HarmCategory.HARM_CATEGORY_UNSPECIFIED: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_DEROGATORY: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_TOXICITY: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_VIOLENCE: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_SEXUAL: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_MEDICAL: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_DANGEROUS: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_NONE,
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE
})
def invoke(self, prompt: str) -> BaseMessage:
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.get('llm_api_url', "")
logger.debug(f"Using {llm_model_type} with {llm_model}")
if llm_model_type == "openai":
return OpenAIModel(api_key, llm_model)
elif llm_model_type == "claude":
return ClaudeModel(api_key, llm_model)
elif llm_model_type == "ollama":
return OllamaModel(llm_model, llm_api_url)
elif llm_model_type == "gemini":
return GeminiModel(api_key, llm_model)
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: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]):
self.llm = llm
logger.debug(f"LLMLogger successfully initialized with LLM: {llm}")
@staticmethod
def log_request(prompts, parsed_reply: Dict[str, Dict]):
logger.debug("Starting log_request method")
logger.debug(f"Prompts received: {prompts}")
logger.debug(f"Parsed reply received: {parsed_reply}")
try:
calls_log = os.path.join(
Path("data_folder/output"), "open_ai_calls.json")
logger.debug(f"Logging path determined: {calls_log}")
except Exception as e:
logger.error(f"Error determining the log path: {str(e)}")
raise
if isinstance(prompts, StringPromptValue):
logger.debug("Prompts are of type StringPromptValue")
prompts = prompts.text
logger.debug(f"Prompts converted to text: {prompts}")
elif isinstance(prompts, Dict):
logger.debug("Prompts are of type Dict")
try:
prompts = {
f"prompt_{i + 1}": prompt.content
for i, prompt in enumerate(prompts.messages)
}
logger.debug(f"Prompts converted to dictionary: {prompts}")
except Exception as e:
logger.error(f"Error converting prompts to dictionary: {str(e)}")
raise
else:
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(f"Prompts converted to dictionary using default method: {prompts}")
except Exception as e:
logger.error(f"Error converting prompts using default method: {str(e)}")
raise
try:
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
logger.debug(f"Current time obtained: {current_time}")
except Exception as e:
logger.error(f"Error obtaining current time: {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(f"Token usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}")
except KeyError as e:
logger.error(f"KeyError in parsed_reply structure: {str(e)}")
raise
try:
model_name = parsed_reply["response_metadata"]["model_name"]
logger.debug(f"Model name: {model_name}")
except KeyError as e:
logger.error(f"KeyError in response_metadata: {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(f"Total cost calculated: {total_cost}")
except Exception as e:
logger.error(f"Error calculating total cost: {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(f"Log entry created: {log_entry}")
except KeyError as e:
logger.error(f"Error creating log entry: missing key {str(e)} in parsed_reply")
raise
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(f"Log entry written to file: {calls_log}")
except Exception as e:
logger.error(f"Error writing log entry to file: {str(e)}")
raise
class LoggerChatModel:
def __init__(self, llm: Union[OpenAIModel, OllamaModel, ClaudeModel, GeminiModel]):
self.llm = llm
logger.debug(f"LoggerChatModel successfully initialized with LLM: {llm}")
def __call__(self, messages: List[Dict[str, str]]) -> str:
logger.debug(f"Entering __call__ method with messages: {messages}")
while True:
try:
logger.debug("Attempting to call the LLM with messages")
reply = self.llm.invoke(messages)
logger.debug(f"LLM response received: {reply}")
parsed_reply = self.parse_llmresult(reply)
logger.debug(f"Parsed LLM reply: {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(f"HTTPStatusError encountered: {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(
f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after' header)...")
time.sleep(wait_time)
elif retry_after_ms:
wait_time = int(retry_after_ms) / 1000.0
logger.warning(
f"Rate limit exceeded. Waiting for {wait_time} seconds before retrying (extracted from 'retry-after-ms' header)...")
time.sleep(wait_time)
else:
wait_time = 30
logger.warning(
f"'retry-after' header not found. Waiting for {wait_time} seconds before retrying (default)...")
time.sleep(wait_time)
else:
logger.error(f"HTTP error occurred with status code: {e.response.status_code}, waiting 30 seconds before retrying")
time.sleep(30)
except Exception as e:
logger.error(f"Unexpected error occurred: {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]:
logger.debug(f"Parsing LLM result: {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(f"Parsed LLM result successfully: {parsed_result}")
return parsed_result
except KeyError as e:
logger.error(
f"KeyError while parsing LLM result: missing key {str(e)}")
raise
except Exception as e:
logger.error(
f"Unexpected error while parsing LLM result: {str(e)}")
raise
class GPTAnswerer:
def __init__(self, config, llm_api_key):
self.ai_adapter = AIAdapter(config, llm_api_key)
self.llm_cheap = LoggerChatModel(self.ai_adapter)
@property
def job_description(self):
return self.job.description
@staticmethod
def find_best_match(text: str, options: list[str]) -> str:
logger.debug(f"Finding best match for text: '{text}' in options: {options}")
distances = [
(option, distance(text.lower(), option.lower())) for option in options
]
best_option = min(distances, key=lambda x: x[1])[0]
logger.debug(f"Best match found: {best_option}")
return best_option
@staticmethod
def _remove_placeholders(text: str) -> str:
logger.debug(f"Removing placeholders from text: {text}")
text = text.replace("PLACEHOLDER", "")
return text.strip()
@staticmethod
def _preprocess_template_string(template: str) -> str:
logger.debug("Preprocessing template string")
return textwrap.dedent(template)
def set_resume(self, resume):
logger.debug(f"Setting resume: {resume}")
self.resume = resume
def set_job(self, job):
logger.debug(f"Setting job: {job}")
self.job = job
self.job.set_summarize_job_description(
self.summarize_job_description(self.job.description))
def set_job_application_profile(self, job_application_profile):
logger.debug(f"Setting job application profile: {job_application_profile}")
self.job_application_profile = job_application_profile
def summarize_job_description(self, text: str) -> str:
logger.debug(f"Summarizing job description: {text}")
strings.summarize_prompt_template = self._preprocess_template_string(
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(f"Summary generated: {output}")
return output
def _create_chain(self, template: str):
logger.debug(f"Creating chain with template: {template}")
prompt = ChatPromptTemplate.from_template(template)
return prompt | self.llm_cheap | StrOutputParser()
def answer_question_textual_wide_range(self, question: str) -> str:
logger.debug(f"Answering textual question: {question}")
chains = {
"personal_information": self._create_chain(strings.personal_information_template),
"self_identification": self._create_chain(strings.self_identification_template),
"legal_authorization": self._create_chain(strings.legal_authorization_template),
"work_preferences": self._create_chain(strings.work_preferences_template),
"education_details": self._create_chain(strings.education_details_template),
"experience_details": self._create_chain(strings.experience_details_template),
"projects": self._create_chain(strings.projects_template),
"availability": self._create_chain(strings.availability_template),
"salary_expectations": self._create_chain(strings.salary_expectations_template),
"certifications": self._create_chain(strings.certifications_template),
"languages": self._create_chain(strings.languages_template),
"interests": self._create_chain(strings.interests_template),
"cover_letter": self._create_chain(strings.coverletter_template),
}
section_prompt = """You are assisting a bot designed to automatically apply for jobs on LinkedIn. The bot receives various questions about job applications and needs to determine the most relevant section of the resume to provide an accurate response.
For the following question: '{question}', determine which section of the resume is most relevant.
Respond with exactly one of the following options:
- Personal information
- Self Identification
- Legal Authorization
- Work Preferences
- Education Details
- Experience Details
- Projects
- Availability
- Salary Expectations
- Certifications
- Languages
- Interests
- Cover letter
Here are detailed guidelines to help you choose the correct section:
1. **Personal Information**:
- **Purpose**: Contains your basic contact details and online profiles.
- **Use When**: The question is about how to contact you or requests links to your professional online presence.
- **Examples**: Email address, phone number, LinkedIn profile, GitHub repository, personal website.
2. **Self Identification**:
- **Purpose**: Covers personal identifiers and demographic information.
- **Use When**: The question pertains to your gender, pronouns, veteran status, disability status, or ethnicity.
- **Examples**: Gender, pronouns, veteran status, disability status, ethnicity.
3. **Legal Authorization**:
- **Purpose**: Details your work authorization status and visa requirements.
- **Use When**: The question asks about your ability to work in specific countries or if you need sponsorship or visas.
- **Examples**: Work authorization in EU and US, visa requirements, legally allowed to work.
4. **Work Preferences**:
- **Purpose**: Specifies your preferences regarding work conditions and job roles.
- **Use When**: The question is about your preferences for remote work, in-person work, relocation, and willingness to undergo assessments or background checks.
- **Examples**: Remote work, in-person work, open to relocation, willingness to complete assessments.
5. **Education Details**:
- **Purpose**: Contains information about your academic qualifications.
- **Use When**: The question concerns your degrees, universities attended, GPA, and relevant coursework.
- **Examples**: Degree, university, GPA, field of study, exams.
6. **Experience Details**:
- **Purpose**: Details your professional work history and key responsibilities.
- **Use When**: The question pertains to your job roles, responsibilities, and achievements in previous positions.
- **Examples**: Job positions, company names, key responsibilities, skills acquired.
7. **Projects**:
- **Purpose**: Highlights specific projects you have worked on.
- **Use When**: The question asks about particular projects, their descriptions, or links to project repositories.
- **Examples**: Project names, descriptions, links to project repositories.
8. **Availability**:
- **Purpose**: Provides information on your availability for new roles.
- **Use When**: The question is about how soon you can start a new job or your notice period.
- **Examples**: Notice period, availability to start.
9. **Salary Expectations**:
- **Purpose**: Covers your expected salary range.
- **Use When**: The question pertains to your salary expectations or compensation requirements.
- **Examples**: Desired salary range.
10. **Certifications**:
- **Purpose**: Lists your professional certifications or licenses.
- **Use When**: The question involves your certifications or qualifications from recognized organizations.
- **Examples**: Certification names, issuing bodies, dates of validity.
11. **Languages**:
- **Purpose**: Describes the languages you can speak and your proficiency levels.
- **Use When**: The question asks about your language skills or proficiency in specific languages.
- **Examples**: Languages spoken, proficiency levels.
12. **Interests**:
- **Purpose**: Details your personal or professional interests.
- **Use When**: The question is about your hobbies, interests, or activities outside of work.
- **Examples**: Personal hobbies, professional interests.
13. **Cover Letter**:
- **Purpose**: Contains your personalized cover letter or statement.
- **Use When**: The question involves your cover letter or specific written content intended for the job application.
- **Examples**: Cover letter content, personalized statements.
Provide only the exact name of the section from the list above with no additional text.
"""
prompt = ChatPromptTemplate.from_template(section_prompt)
chain = prompt | self.llm_cheap | StrOutputParser()
output = chain.invoke({"question": question})
match = re.search(
r"(Personal information|Self Identification|Legal Authorization|Work Preferences|Education "
r"Details|Experience Details|Projects|Availability|Salary "
r"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})
logger.debug(f"Cover letter generated: {output}")
return output
resume_section = getattr(self.resume, section_name, None) or getattr(self.job_application_profile, section_name,
None)
if resume_section is None:
logger.error(
f"Section '{section_name}' not found in either resume or job_application_profile.")
raise ValueError(f"Section '{section_name}' not found in either resume or job_application_profile.")
chain = chains.get(section_name)
if chain is None:
logger.error(f"Chain not defined for section '{section_name}'")
raise ValueError(f"Chain not defined for section '{section_name}'")
output = chain.invoke(
{"resume_section": resume_section, "question": question})
logger.debug(f"Question answered: {output}")
return output
def answer_question_numeric(self, question: str, default_experience: int = 3) -> int:
logger.debug(f"Answering numeric question: {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})
logger.debug(f"Raw output for numeric question: {output_str}")
try:
output = self.extract_number_from_string(output_str)
logger.debug(f"Extracted number: {output}")
except ValueError:
logger.warning(
f"Failed to extract number, using default experience: {default_experience}")
output = default_experience
return output
def extract_number_from_string(self, output_str):
logger.debug(f"Extracting number from string: {output_str}")
numbers = re.findall(r"\d+", output_str)
if numbers:
logger.debug(f"Numbers found: {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:
logger.debug(f"Answering question from options: {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})
logger.debug(f"Raw output for options question: {output_str}")
best_option = self.find_best_match(output_str, options)
logger.debug(f"Best option determined: {best_option}")
return best_option
def resume_or_cover(self, phrase: str) -> str:
logger.debug(
f"Determining if phrase refers to resume or cover letter: {phrase}")
prompt_template = """
Given the following phrase, respond with only 'resume' if the phrase is about a resume, or 'cover' if it's about a cover letter.
If the phrase contains only 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(f"Response for resume_or_cover: {response}")
if "resume" in response:
return "resume"
elif "cover" in response:
return "cover"
else:
return "resume"

View file

@ -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.

View file

@ -1,103 +1,180 @@
import logging
import os
import random
import sys
import time
from selenium import webdriver
from loguru import logger
from app_config import MINIMUM_LOG_LEVEL
log_file = "app_log.log"
# TODO: REMOVE THE FOLLOWING BLOCK: No need as Loguru handles everything by default
# 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
# )
if MINIMUM_LOG_LEVEL in ["DEBUG", "TRACE", "INFO", "WARNING", "ERROR", "CRITICAL"]:
logger.remove()
logger.add(sys.stderr, level=MINIMUM_LOG_LEVEL)
else:
logger.warning(f"Invalid log level: {MINIMUM_LOG_LEVEL}. Defaulting to DEBUG.")
logger.remove()
logger.add(sys.stderr, level="DEBUG")
chromeProfilePath = os.path.join(os.getcwd(), "chrome_profile", "linkedin_profile")
def ensure_chrome_profile():
logger.debug(f"Ensuring Chrome profile exists at path: {chromeProfilePath}")
profile_dir = os.path.dirname(chromeProfilePath)
if not os.path.exists(profile_dir):
os.makedirs(profile_dir)
logger.debug(f"Created directory for Chrome profile: {profile_dir}")
if not os.path.exists(chromeProfilePath):
os.makedirs(chromeProfilePath)
logger.debug(f"Created Chrome profile directory: {chromeProfilePath}")
return chromeProfilePath
def 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(f"Element scrollable check: scrollHeight={scroll_height}, clientHeight={client_height}, scrollable={scrollable}")
return scrollable
def scroll_slow(driver, scrollable_element, start=0, end=3600, step=300, reverse=False):
logger.debug(f"Starting slow scroll: start={start}, end={end}, step={step}, reverse={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(float(scrollable_element.get_attribute("scrollTop")))
logger.debug(f"Max scroll height of the element: {max_scroll_height}")
logger.debug(f"Current scroll position: {current_scroll_position}")
if reverse:
if current_scroll_position < start:
start = current_scroll_position
logger.debug(f"Adjusted start position for upward scroll: {start}")
else:
if end > max_scroll_height:
logger.warning(f"End value exceeds the scroll height. Adjusting end to {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):
print("The element is not scrollable.")
logger.warning("The element is not scrollable.")
return
if (step > 0 and start >= end) or (step < 0 and start <= end):
print("No scrolling will occur due to incorrect start/end values.")
logger.warning("No scrolling will occur due to incorrect start/end values.")
return
for position in range(start, end, step):
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(f"Stopping scroll as position hasn't changed: {position}")
break
try:
driver.execute_script(script_scroll_to, scrollable_element, position)
logger.debug(f"Scrolled to position: {position}")
except Exception as e:
print(f"Error during scrolling: {e}")
time.sleep(random.uniform(1.0, 2.6))
driver.execute_script(script_scroll_to, scrollable_element, end)
time.sleep(1)
else:
print("The element is not visible.")
except Exception as e:
print(f"Exception occurred: {e}")
logger.error(f"Error during scrolling: {e}")
def chromeBrowserOptions():
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)
logger.debug(f"Scrolled to final position: {end}")
time.sleep(0.5)
else:
logger.warning("The element is not visible.")
except Exception as e:
logger.error(f"Exception occurred during scrolling: {e}")
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(f"Using Chrome profile directory: {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}")

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,153 @@
import pytest
from src.job_application_profile import JobApplicationProfile
@pytest.fixture
def valid_yaml():
"""Valid YAML string for initializing JobApplicationProfile."""
return """
self_identification:
gender: Male
pronouns: He/Him
veteran: No
disability: No
ethnicity: Asian
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: "Yes"
in_person_work: "No"
open_to_relocation: "Yes"
willing_to_complete_assessments: "Yes"
willing_to_undergo_drug_tests: "Yes"
willing_to_undergo_background_checks: "Yes"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
"""
@pytest.fixture
def missing_field_yaml():
"""YAML string missing a required field (self_identification)."""
return """
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: "Yes"
in_person_work: "No"
open_to_relocation: "Yes"
willing_to_complete_assessments: "Yes"
willing_to_undergo_drug_tests: "Yes"
willing_to_undergo_background_checks: "Yes"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
"""
@pytest.fixture
def invalid_type_yaml():
"""YAML string with an invalid type for a field."""
return """
self_identification:
gender: Male
pronouns: He/Him
veteran: No
disability: No
ethnicity: Asian
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: 12345 # Invalid type, expecting a string
in_person_work: "No"
open_to_relocation: "Yes"
willing_to_complete_assessments: "Yes"
willing_to_undergo_drug_tests: "Yes"
willing_to_undergo_background_checks: "Yes"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
"""
def test_initialize_with_valid_yaml(valid_yaml):
"""Test initializing JobApplicationProfile with valid YAML."""
profile = JobApplicationProfile(valid_yaml)
# Check that the profile fields are correctly initialized
assert profile.self_identification.gender == "Male"
assert profile.self_identification.pronouns == "He/Him"
assert profile.legal_authorization.eu_work_authorization == "Yes"
assert profile.work_preferences.remote_work == "Yes"
assert profile.availability.notice_period == "2 weeks"
assert profile.salary_expectations.salary_range_usd == "80000-120000"
def test_initialize_with_missing_field(missing_field_yaml):
"""Test initializing JobApplicationProfile with missing required fields."""
with pytest.raises(KeyError) as excinfo:
JobApplicationProfile(missing_field_yaml)
assert "self_identification" in str(excinfo.value)
def test_initialize_with_invalid_yaml():
"""Test initializing JobApplicationProfile with invalid YAML."""
invalid_yaml_str = """
self_identification:
gender: Male
pronouns: He/Him
veteran: No
disability: No
ethnicity: Asian
legal_authorization:
eu_work_authorization: "Yes"
us_work_authorization: "Yes"
requires_us_visa: "No"
legally_allowed_to_work_in_us: "Yes"
requires_us_sponsorship: "No"
requires_eu_visa: "No"
legally_allowed_to_work_in_eu: "Yes"
requires_eu_sponsorship: "No"
work_preferences:
remote_work: "Yes"
in_person_work: "No"
availability:
notice_period: "2 weeks"
salary_expectations:
salary_range_usd: "80000-120000"
""" # Missing fields in work_preferences
with pytest.raises(TypeError):
JobApplicationProfile(invalid_yaml_str)
def test_str_representation(valid_yaml):
"""Test the string representation of JobApplicationProfile."""
profile = JobApplicationProfile(valid_yaml)
profile_str = str(profile)
assert "Self Identification:" in profile_str
assert "Legal Authorization:" in profile_str
assert "Work Preferences:" in profile_str
assert "Availability:" in profile_str
assert "Salary Expectations:" in profile_str
assert "Male" in profile_str
assert "80000-120000" in profile_str

View file

@ -0,0 +1,158 @@
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from src.linkedIn_authenticator import LinkedInAuthenticator
from selenium.common.exceptions import NoSuchElementException, TimeoutException
@pytest.fixture
def mock_driver(mocker):
"""Fixture to mock the Selenium WebDriver."""
return mocker.Mock()
@pytest.fixture
def authenticator(mock_driver):
"""Fixture to initialize LinkedInAuthenticator with a mocked driver."""
return LinkedInAuthenticator(mock_driver)
def test_set_secrets(authenticator):
"""Test setting secrets (email, password)."""
authenticator.set_secrets("test@example.com", "password123")
assert authenticator.email == "test@example.com"
assert authenticator.password == "password123"
def test_start_logged_in(mocker, authenticator):
"""Test starting LinkedIn when already logged in."""
mocker.patch.object(authenticator, 'is_logged_in', return_value=True)
mocker.patch.object(authenticator.driver, 'get')
mocker.patch("time.sleep") # Avoid waiting during the test
authenticator.start()
authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed')
authenticator.is_logged_in.assert_called_once()
assert authenticator.driver.get.call_count == 1
def test_start_not_logged_in(mocker, authenticator):
"""Test starting LinkedIn when not logged in."""
mocker.patch.object(authenticator, 'is_logged_in', return_value=False)
mocker.patch.object(authenticator, 'handle_login')
mocker.patch.object(authenticator.driver, 'get')
mocker.patch("time.sleep")
authenticator.start()
authenticator.driver.get.assert_called_with('https://www.linkedin.com/feed')
authenticator.handle_login.assert_called_once()
def test_handle_login(mocker, authenticator):
"""Test handling the LinkedIn login process."""
mocker.patch.object(authenticator.driver, 'get')
mocker.patch.object(authenticator, 'enter_credentials')
mocker.patch.object(authenticator, 'submit_login_form')
mocker.patch.object(authenticator, 'handle_security_check')
# Mock current_url as a regular return value, not PropertyMock
mocker.patch.object(authenticator.driver, 'current_url', return_value='https://www.linkedin.com/login')
authenticator.handle_login()
authenticator.driver.get.assert_called_with('https://www.linkedin.com/login')
authenticator.enter_credentials.assert_called_once()
authenticator.submit_login_form.assert_called_once()
authenticator.handle_security_check.assert_called_once()
def test_enter_credentials_success(mocker, authenticator):
"""Test entering credentials."""
email_mock = mocker.Mock()
password_mock = mocker.Mock()
mocker.patch.object(WebDriverWait, 'until', return_value=email_mock)
mocker.patch.object(authenticator.driver, 'find_element', return_value=password_mock)
authenticator.set_secrets("test@example.com", "password123")
authenticator.enter_credentials()
email_mock.send_keys.assert_called_once_with("test@example.com")
password_mock.send_keys.assert_called_once_with("password123")
def test_enter_credentials_timeout(mocker, authenticator):
"""Test entering credentials with a TimeoutException."""
mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException)
authenticator.set_secrets("test@example.com", "password123")
authenticator.enter_credentials()
authenticator.driver.find_element.assert_not_called() # Password input should not be accessed if email fails
def test_submit_login_form_success(mocker, authenticator):
"""Test submitting the login form."""
login_button_mock = mocker.Mock()
mocker.patch.object(authenticator.driver, 'find_element', return_value=login_button_mock)
authenticator.submit_login_form()
login_button_mock.click.assert_called_once()
def test_submit_login_form_no_button(mocker, authenticator):
"""Test submitting the login form when the login button is not found."""
mocker.patch.object(authenticator.driver, 'find_element', side_effect=NoSuchElementException)
authenticator.submit_login_form()
authenticator.driver.find_element.assert_called_once_with(By.XPATH, '//button[@type="submit"]')
def test_is_logged_in_true(mocker, authenticator):
"""Test if the user is logged in."""
buttons_mock = mocker.Mock()
buttons_mock.text = "Start a post"
mocker.patch.object(WebDriverWait, 'until')
mocker.patch.object(authenticator.driver, 'find_elements', return_value=[buttons_mock])
assert authenticator.is_logged_in() is True
def test_is_logged_in_false(mocker, authenticator):
"""Test if the user is not logged in."""
mocker.patch.object(WebDriverWait, 'until')
mocker.patch.object(authenticator.driver, 'find_elements', return_value=[])
assert authenticator.is_logged_in() is False
def test_handle_security_check_success(mocker, authenticator):
"""Test handling security check successfully."""
mocker.patch.object(WebDriverWait, 'until', side_effect=[
mocker.Mock(), # Security checkpoint detection
mocker.Mock() # Security check completion
])
authenticator.handle_security_check()
# Verify WebDriverWait is called with EC.url_contains for both the challenge and feed
WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY)
WebDriverWait(authenticator.driver, 300).until.assert_any_call(mocker.ANY)
def test_handle_security_check_timeout(mocker, authenticator):
"""Test handling security check timeout."""
mocker.patch.object(WebDriverWait, 'until', side_effect=TimeoutException)
authenticator.handle_security_check()
# Verify WebDriverWait is called with EC.url_contains for the challenge
WebDriverWait(authenticator.driver, 10).until.assert_any_call(mocker.ANY)

View file

@ -0,0 +1,14 @@
import pytest
# from src.linkedIn_job_manager import JobManager
@pytest.fixture
def job_manager():
"""Fixture for JobManager."""
return None # Replace with valid instance or mock later
def test_bot_functionality(job_manager):
"""Test LinkedIn bot facade."""
# Example: test job manager interacts with the bot facade correctly
job = {"title": "Software Engineer"}
# job_manager.some_method_to_apply(job)
assert job is not None # Placeholder for actual test

View file

@ -0,0 +1,97 @@
import pytest
from unittest import mock
from src.linkedIn_easy_applier import LinkedInEasyApplier
@pytest.fixture
def mock_driver():
"""Fixture to mock Selenium WebDriver."""
return mock.Mock()
@pytest.fixture
def mock_gpt_answerer():
"""Fixture to mock GPT Answerer."""
return mock.Mock()
@pytest.fixture
def mock_resume_generator_manager():
"""Fixture to mock Resume Generator Manager."""
return mock.Mock()
@pytest.fixture
def easy_applier(mock_driver, mock_gpt_answerer, mock_resume_generator_manager):
"""Fixture to initialize LinkedInEasyApplier with mocks."""
return LinkedInEasyApplier(
driver=mock_driver,
resume_dir="/path/to/resume",
set_old_answers=[('Question 1', 'Answer 1', 'Type 1')],
gpt_answerer=mock_gpt_answerer,
resume_generator_manager=mock_resume_generator_manager
)
def test_initialization(mocker, easy_applier):
"""Test that LinkedInEasyApplier is initialized correctly."""
# Mock os.path.exists to return True
mocker.patch('os.path.exists', return_value=True)
easy_applier = LinkedInEasyApplier(
driver=mocker.Mock(),
resume_dir="/path/to/resume",
set_old_answers=[('Question 1', 'Answer 1', 'Type 1')],
gpt_answerer=mocker.Mock(),
resume_generator_manager=mocker.Mock()
)
assert easy_applier.resume_path == "/path/to/resume"
assert len(easy_applier.set_old_answers) == 1
assert easy_applier.gpt_answerer is not None
assert easy_applier.resume_generator_manager is not None
def test_apply_to_job_success(mocker, easy_applier):
"""Test successfully applying to a job."""
mock_job = mock.Mock()
# Mock job_apply so we don't actually try to apply
mocker.patch.object(easy_applier, 'job_apply')
easy_applier.apply_to_job(mock_job)
easy_applier.job_apply.assert_called_once_with(mock_job)
def test_apply_to_job_failure(mocker, easy_applier):
"""Test failure while applying to a job."""
mock_job = mock.Mock()
mocker.patch.object(easy_applier, 'job_apply',
side_effect=Exception("Test error"))
with pytest.raises(Exception, match="Test error"):
easy_applier.apply_to_job(mock_job)
easy_applier.job_apply.assert_called_once_with(mock_job)
def test_check_for_premium_redirect_no_redirect(mocker, easy_applier):
"""Test that check_for_premium_redirect works when there's no redirect."""
mock_job = mock.Mock()
easy_applier.driver.current_url = "https://www.linkedin.com/jobs/view/1234"
easy_applier.check_for_premium_redirect(mock_job)
easy_applier.driver.get.assert_not_called()
def test_check_for_premium_redirect_with_redirect(mocker, easy_applier):
"""Test that check_for_premium_redirect handles LinkedIn Premium redirects."""
mock_job = mock.Mock()
easy_applier.driver.current_url = "https://www.linkedin.com/premium"
mock_job.link = "https://www.linkedin.com/jobs/view/1234"
with pytest.raises(Exception, match="Redirected to LinkedIn Premium page and failed to return"):
easy_applier.check_for_premium_redirect(mock_job)
# Verify that it attempted to return to the job page 3 times
assert easy_applier.driver.get.call_count == 3

View file

@ -0,0 +1,168 @@
from src.job import Job
from unittest import mock
from pathlib import Path
import os
import pytest
from src.linkedIn_job_manager import LinkedInJobManager
from selenium.common.exceptions import NoSuchElementException
from loguru import logger
@pytest.fixture
def job_manager(mocker):
"""Fixture to create a LinkedInJobManager instance with mocked driver."""
mock_driver = mocker.Mock()
return LinkedInJobManager(mock_driver)
def test_initialization(job_manager):
"""Test LinkedInJobManager initialization."""
assert job_manager.driver is not None
assert job_manager.set_old_answers == set()
assert job_manager.easy_applier_component is None
def test_set_parameters(mocker, job_manager):
"""Test setting parameters for the LinkedInJobManager."""
# Mocking os.path.exists to return True for the resume path
mocker.patch('pathlib.Path.exists', return_value=True)
params = {
'company_blacklist': ['Company A', 'Company B'],
'title_blacklist': ['Intern', 'Junior'],
'positions': ['Software Engineer', 'Data Scientist'],
'locations': ['New York', 'San Francisco'],
'apply_once_at_company': True,
'uploads': {'resume': '/path/to/resume'}, # Resume path provided here
'outputFileDirectory': '/path/to/output',
'job_applicants_threshold': {
'min_applicants': 5,
'max_applicants': 50
},
'remote': False,
'distance': 50,
'date': {'all time': True}
}
job_manager.set_parameters(params)
# Normalize paths to handle platform differences (e.g., Windows vs Unix-like systems)
assert str(job_manager.resume_path) == os.path.normpath('/path/to/resume')
assert str(job_manager.output_file_directory) == os.path.normpath(
'/path/to/output')
def next_job_page(self, position, location, job_page):
logger.debug(f"Navigating to next job page: {position} in {location}, page {job_page}")
self.driver.get(
f"https://www.linkedin.com/jobs/search/{self.base_search_url}&keywords={position}&location={location}&start={job_page * 25}")
def test_get_jobs_from_page_no_jobs(mocker, job_manager):
"""Test get_jobs_from_page when no jobs are found."""
mocker.patch.object(job_manager.driver, 'find_element',
side_effect=NoSuchElementException)
jobs = job_manager.get_jobs_from_page()
assert jobs == []
def test_get_jobs_from_page_with_jobs(mocker, job_manager):
"""Test get_jobs_from_page when job elements are found."""
# Mock the no_jobs_element to behave correctly
mock_no_jobs_element = mocker.Mock()
mock_no_jobs_element.text = "No matching jobs found"
# Mocking the find_element to return the mock no_jobs_element
mocker.patch.object(job_manager.driver, 'find_element',
return_value=mock_no_jobs_element)
# Mock the page_source
mocker.patch.object(job_manager.driver, 'page_source',
return_value="some page content")
# Ensure jobs are returned as empty list due to "No matching jobs found"
jobs = job_manager.get_jobs_from_page()
assert jobs == [] # No jobs expected due to "No matching jobs found"
def test_apply_jobs_with_no_jobs(mocker, job_manager):
"""Test apply_jobs when no jobs are found."""
# Mocking find_element to return a mock element that simulates no jobs
mock_element = mocker.Mock()
mock_element.text = "No matching jobs found"
# Mock the driver to simulate the page source
mocker.patch.object(job_manager.driver, 'page_source', return_value="")
# Mock the driver to return the mock element when find_element is called
mocker.patch.object(job_manager.driver, 'find_element',
return_value=mock_element)
# Call apply_jobs and ensure no exceptions are raised
job_manager.apply_jobs()
# Ensure it attempted to find the job results list
assert job_manager.driver.find_element.call_count == 1
def test_apply_jobs_with_jobs(mocker, job_manager):
"""Test apply_jobs when jobs are present."""
# Mock no_jobs_element to simulate the absence of "No matching jobs found" banner
no_jobs_element = mocker.Mock()
no_jobs_element.text = "" # Empty text means "No matching jobs found" is not present
mocker.patch.object(job_manager.driver, 'find_element',
return_value=no_jobs_element)
# Mock the page_source to simulate what the page looks like when jobs are present
mocker.patch.object(job_manager.driver, 'page_source',
return_value="some job content")
# Mock the outer find_elements (scaffold-layout__list-container)
container_mock = mocker.Mock()
# Mock the inner find_elements to return job list items
job_element_mock = mocker.Mock()
# Simulating two job items
job_elements_list = [job_element_mock, job_element_mock]
# Return the container mock, which itself returns the job elements list
container_mock.find_elements.return_value = job_elements_list
mocker.patch.object(job_manager.driver, 'find_elements',
return_value=[container_mock])
# Mock the extract_job_information_from_tile method to return sample job info
mocker.patch.object(job_manager, 'extract_job_information_from_tile', return_value=(
"Title", "Company", "Location", "Apply", "Link"))
# Mock other methods like is_blacklisted, is_already_applied_to_job, and is_already_applied_to_company
mocker.patch.object(job_manager, 'is_blacklisted', return_value=False)
mocker.patch.object(
job_manager, 'is_already_applied_to_job', return_value=False)
mocker.patch.object(
job_manager, 'is_already_applied_to_company', return_value=False)
# Mock the LinkedInEasyApplier component
job_manager.easy_applier_component = mocker.Mock()
# Mock the output_file_directory as a valid Path object
job_manager.output_file_directory = Path("/mocked/path/to/output")
# Mock Path.exists() to always return True (so no actual file system interaction is needed)
mocker.patch.object(Path, 'exists', return_value=True)
# Mock the open function to prevent actual file writing
mock_open = mocker.mock_open()
mocker.patch('builtins.open', mock_open)
# Run the apply_jobs method
job_manager.apply_jobs()
# Assertions
assert job_manager.driver.find_elements.call_count == 1
# Called for each job element
assert job_manager.extract_job_information_from_tile.call_count == 2
# Called for each job element
assert job_manager.easy_applier_component.job_apply.call_count == 2
mock_open.assert_called() # Ensure that the open function was called

96
tests/test_utils.py Normal file
View file

@ -0,0 +1,96 @@
# tests/test_utils.py
import pytest
import os
import time
from unittest import mock
from selenium.webdriver.remote.webelement import WebElement
from src.utils import ensure_chrome_profile, is_scrollable, scroll_slow, chrome_browser_options, printred, printyellow
# Mocking logging to avoid actual file writing
@pytest.fixture(autouse=True)
def mock_logger(mocker):
mocker.patch("src.utils.logger")
# Test ensure_chrome_profile function
def test_ensure_chrome_profile(mocker):
mocker.patch("os.path.exists", return_value=False) # Pretend directory doesn't exist
mocker.patch("os.makedirs") # Mock making directories
# Call the function
profile_path = ensure_chrome_profile()
# Verify that os.makedirs was called twice to create the directory
assert profile_path.endswith("linkedin_profile")
assert os.path.exists.called
assert os.makedirs.called
# Test is_scrollable function
def test_is_scrollable(mocker):
mock_element = mocker.Mock(spec=WebElement)
mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "500"
# Call the function
scrollable = is_scrollable(mock_element)
# Check the expected outcome
assert scrollable is True
mock_element.get_attribute.assert_any_call("scrollHeight")
mock_element.get_attribute.assert_any_call("clientHeight")
# Test scroll_slow function
def test_scroll_slow(mocker):
mock_driver = mocker.Mock()
mock_element = mocker.Mock(spec=WebElement)
# Mock element's attributes for scrolling
mock_element.get_attribute.side_effect = lambda attr: "2000" if attr == "scrollHeight" else "0"
mock_element.is_displayed.return_value = True
mocker.patch("time.sleep") # Mock time.sleep to avoid waiting
# Call the function
scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100, reverse=False)
# Ensure that scrolling happened multiple times
assert mock_driver.execute_script.called
mock_element.is_displayed.assert_called_once()
def test_scroll_slow_element_not_scrollable(mocker):
mock_driver = mocker.Mock()
mock_element = mocker.Mock(spec=WebElement)
# Mock the attributes so the element is not scrollable
mock_element.get_attribute.side_effect = lambda attr: "1000" if attr == "scrollHeight" else "1000"
mock_element.is_displayed.return_value = True
scroll_slow(mock_driver, mock_element, start=0, end=1000, step=100)
# Ensure it detected non-scrollable element
mock_driver.execute_script.assert_not_called()
# Test chrome_browser_options function
def test_chrome_browser_options(mocker):
mocker.patch("src.utils.ensure_chrome_profile")
mocker.patch("os.path.dirname", return_value="/mocked/path")
mocker.patch("os.path.basename", return_value="profile_directory")
mock_options = mocker.Mock()
mocker.patch("selenium.webdriver.ChromeOptions", return_value=mock_options)
# Call the function
options = chrome_browser_options()
# Ensure options were set
assert mock_options.add_argument.called
assert options == mock_options
# Test printred and printyellow functions
def test_printred(mocker):
mocker.patch("builtins.print")
printred("Test")
print.assert_called_once_with("\033[91mTest\033[0m")
def test_printyellow(mocker):
mocker.patch("builtins.print")
printyellow("Test")
print.assert_called_once_with("\033[93mTest\033[0m")