Skip to content

Commit

Permalink
Merge branch 'main' into release/v4.0.0
Browse files Browse the repository at this point in the history
  • Loading branch information
surapuramakhil authored Nov 25, 2024
2 parents 6c42400 + 892a6fb commit 76350f4
Show file tree
Hide file tree
Showing 9 changed files with 1,176 additions and 123 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Mark and Close Stale Issues

on:
# Schedule the workflow to run periodically (e.g., daily at 1:30 AM UTC)
schedule:
- cron: "30 1 * * *"
workflow_dispatch:

jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write

steps:
- name: Run Stale Action
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 10 # Days of inactivity before marking an issue as stale
days-before-issue-close: 5 # Days after being marked stale before closing the issue
stale-issue-label: "stale" # Label to apply to stale issues
exempt-issue-labels: "pinned,important" # Labels to exclude from being marked as stale
exempt-issue-assignees: true # Exempt issues with assignees from being marked as stale
stale-issue-message: "This issue has been marked as stale due to inactivity. Please comment or update if this is still relevant."
close-issue-message: "This issue was closed due to prolonged inactivity."
days-before-pr-stale: 10 # Days of inactivity before marking a PR as stale
days-before-pr-close: 2 # Days after being marked stale before closing the PR
stale-pr-label: "stale" # Label to apply to stale PRs
exempt-pr-labels: "pinned,important" # Labels to exclude from being marked as stale
stale-pr-message: >
"This pull request has been marked as stale due to inactivity.
To keep it open, you can:
- Show progress by updating the PR with new commits.
- Continue the conversation by adding comments or requesting clarification on any blockers.
- Resolve pending feedback by replying to unresolved comments or implementing suggested changes.
- Indicate readiness for review by explicitly requesting a review from maintainers or reviewers.
If no action is taken within 7 days, this pull request will be closed."
close-pr-message: "This PR was closed due to prolonged inactivity."
remove-stale-when-updated: true # Remove the stale label if there is new activity
operations-per-run: 20 # Number of issues to process per run (default is 30)
681 changes: 649 additions & 32 deletions LICENSE

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Join our community: [Telegram](https://t.me/AIhawkCommunity) (for Normal user) |

</div>

**Creator** [feder-cr](https://github.com/feder-cr), Co-Founder of Ai Hawk </br>
As AI Hawk is focusing on their proprietary product - solving problems in hiring for companies, currently this project is led, managed, and maintained by a group of open-source contributors, with a focus on building tools to help job seekers land the jobs they deserve.
**Creator** [feder-cr](https://github.com/feder-cr), Co-Founder of AIHawk </br>
As AIHawk is focusing on their proprietary product - solving problems in hiring for companies, currently this project is led, managed, and maintained by a group of open-source contributors, with a focus on building tools to help job seekers land the jobs they deserve.

**Project Maintainers / Leads**: [surapuramakhil](https://github.com/surapuramakhil), [sarob](https://github.com/sarob), [cjbbb](https://github.com/cjbbb)

Expand Down Expand Up @@ -755,7 +755,12 @@ Made with [contrib.rocks](https://contrib.rocks).

## License

This project is licensed under the MIT + Commons Clause License - see the [LICENSE](LICENSE) file for details.
This project is licensed under the AGPL License. Documentation is licensed under CC BY - see the [AGPL LICENSE](LICENSE) and [CC BY LICENSE](docs/LICENSE) files for details.

The AGPL License requires that any derivative work must also be open source and distributed under the same license.

The CC BY License permits others to distribute, remix, adapt, and build upon your work, even for commercial purposes, as long as they credit you for the original creation.


## Disclaimer

Expand Down
397 changes: 397 additions & 0 deletions docs/LICENSE

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ langchain==0.2.11
langchain-anthropic
langchain-huggingface
langchain-community==0.2.10
langchain-core===0.2.36
langchain-core==0.2.36
langchain-google-genai==1.0.10
langchain-ollama==0.1.3
langchain-openai==0.1.17
Expand Down
92 changes: 41 additions & 51 deletions src/ai_hawk/job_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import time
from itertools import product
from pathlib import Path
import traceback
from turtle import color

from inputimeout import inputimeout, TimeoutOccurred
Expand All @@ -13,6 +14,7 @@

from ai_hawk.linkedIn_easy_applier import AIHawkEasyApplier
from config import JOB_MAX_APPLICATIONS, JOB_MIN_APPLICATIONS, MINIMUM_WAIT_TIME_IN_SECONDS

from src.job import Job
from src.logging import logger

Expand Down Expand Up @@ -155,7 +157,7 @@ def start_applying(self):
logger.debug("Starting the application process for this page...")

try:
jobs = self.get_jobs_from_page()
jobs = self.get_jobs_from_page(scroll=True)
if not jobs:
logger.debug("No more jobs found on this page. Exiting loop.")
break
Expand All @@ -166,7 +168,7 @@ def start_applying(self):
try:
self.apply_jobs()
except Exception as e:
logger.error(f"Error during job application: {e}")
logger.error(f"Error during job application: {e} {traceback.format_exc()}")
continue

logger.debug("Applying to jobs on this page has been completed!")
Expand Down Expand Up @@ -239,10 +241,9 @@ def start_applying(self):
time.sleep(sleep_time)
page_sleep += 1

def get_jobs_from_page(self):
def get_jobs_from_page(self, scroll=False):

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.")
Expand All @@ -252,41 +253,37 @@ def get_jobs_from_page(self):
pass

try:
job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
browser_utils.scroll_slow(self.driver, job_results)
browser_utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
# XPath query to find the ul tag with class scaffold-layout__list-container
jobs_xpath_query = "//ul[contains(@class, 'scaffold-layout__list-container')]"
jobs_container = self.driver.find_element(By.XPATH, jobs_xpath_query)

if scroll:
jobs_container_scrolableElement = jobs_container.find_element(By.XPATH,"..")
logger.warning(f'is scrollable: {browser_utils.is_scrollable(jobs_container_scrolableElement)}')

browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement)
browser_utils.scroll_slow(self.driver, jobs_container_scrolableElement, 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:
job_element_list = jobs_container.find_elements(By.XPATH, ".//li[contains(@class, 'jobs-search-results__list-item') and contains(@class, 'ember-view')]")

if not job_element_list:
logger.debug("No job class elements found on page, skipping.")
return []

return job_list_elements
return job_element_list

except NoSuchElementException:
logger.debug("No job results found on the page.")
except NoSuchElementException as e:
logger.warning(f'No job results found on the page. \n expection: {traceback.format_exc()}')
return []

except Exception as e:
logger.error(f"Error while fetching job elements: {e}")
logger.error(f"Error while fetching job elements: {e} {traceback.format_exc()}")
return []

def read_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")
except NoSuchElementException:
pass

job_results = self.driver.find_element(By.CLASS_NAME, "jobs-search-results-list")
browser_utils.scroll_slow(self.driver, job_results)
browser_utils.scroll_slow(self.driver, job_results, step=300, reverse=True)
job_list_elements = self.driver.find_elements(By.CLASS_NAME, 'scaffold-layout__list-container')[0].find_elements(By.CLASS_NAME, 'jobs-search-results__list-item')
if not job_list_elements:
raise Exception("No job class elements found on page")
job_list = [self.job_tile_to_job(job_element) for job_element in job_list_elements]

job_element_list = self.get_jobs_from_page()
job_list = [self.job_tile_to_job(job_element) for job_element in job_element_list]
for job in job_list:
if self.is_blacklisted(job.title, job.company, job.link, job.location):
logger.info(f"Blacklisted {job.title} at {job.company} in {job.location}, skipping...")
Expand All @@ -299,22 +296,9 @@ def read_jobs(self):
continue

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():
logger.debug("No matching jobs found on this page, skipping")
return
except NoSuchElementException:
pass

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
job_element_list = self.get_jobs_from_page()

job_list = [self.job_tile_to_job(job_element) for job_element in job_list_elements]
job_list = [self.job_tile_to_job(job_element) for job_element in job_element_list]

for job in job_list:

Expand Down Expand Up @@ -487,12 +471,12 @@ def job_tile_to_job(self, job_tile) -> Job:
logger.debug(f"Job link extracted: {job.link}")
except NoSuchElementException:
logger.warning("Job link is missing.")

try:
job.company = job_tile.find_element(By.CLASS_NAME, 'job-card-container__primary-description').text
job.company = job_tile.find_element(By.XPATH, ".//div[contains(@class, 'artdeco-entity-lockup__subtitle')]//span").text
logger.debug(f"Job company extracted: {job.company}")
except NoSuchElementException:
logger.warning("Job company is missing.")
except NoSuchElementException as e:
logger.warning(f'Job company is missing. {e} {traceback.format_exc()}')

# Extract job ID from job url
try:
Expand All @@ -510,11 +494,17 @@ def job_tile_to_job(self, job_tile) -> Job:
except NoSuchElementException:
logger.warning("Job location is missing.")


try:
job.apply_method = job_tile.find_element(By.CLASS_NAME, 'job-card-container__apply-method').text
except NoSuchElementException:
job.apply_method = "Applied"
logger.warning("Apply method not found, assuming 'Applied'.")
job_state = job_tile.find_element(By.XPATH, ".//ul[contains(@class, 'job-card-list__footer-wrapper')]//li[contains(@class, 'job-card-container__apply-method')]").text
except NoSuchElementException as e:
try:
# Fetching state when apply method is not found
job_state = job_tile.find_element(By.XPATH, ".//ul[contains(@class, 'job-card-list__footer-wrapper')]//li[contains(@class, 'job-card-container__footer-job-state')]").text
job.apply_method = "Applied"
logger.warning(f'Apply method not found, state {job_state}. {e} {traceback.format_exc()}')
except NoSuchElementException as e:
logger.warning(f'Apply method and state not found. {e} {traceback.format_exc()}')

return job

Expand Down
4 changes: 2 additions & 2 deletions src/ai_hawk/linkedIn_easy_applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,8 @@ def fill_up(self, job_context : JobContext) -> None:
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:
input_elements = easy_apply_content.find_elements(By.CLASS_NAME, 'jobs-easy-apply-form-section__grouping')
for element in input_elements:
self._process_form_element(element, job_context)
except Exception as e:
logger.error(f"Failed to find form elements: {e}")
Expand Down
10 changes: 8 additions & 2 deletions src/ai_hawk/llm/llm_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,8 +695,14 @@ def is_job_suitable(self):
)
output = self._clean_llm_output(raw_output)
logger.debug(f"Job suitability output: {output}")
score = re.search(r"Score: (\d+)", output).group(1)
reasoning = re.search(r"Reasoning: (.+)", output, re.DOTALL).group(1)

try:
score = re.search(r"Score:\s*(\d+)", output, re.IGNORECASE).group(1)
reasoning = re.search(r"Reasoning:\s*(.+)", output, re.IGNORECASE | re.DOTALL).group(1)
except AttributeError:
logger.warning("Failed to extract score or reasoning from LLM. Proceeding with application, but job may or may not be suitable.")
return True

logger.info(f"Job suitability score: {score}")
if int(score) < JOB_SUITABILITY_SCORE:
logger.debug(f"Job is not suitable: {reasoning}")
Expand Down
60 changes: 28 additions & 32 deletions tests/test_aihawk_job_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,33 @@ def test_get_jobs_from_page_no_jobs(mocker, job_manager):

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"
# Mock no_jobs_element to simulate the absence of "No matching jobs found" banner
no_jobs_element_mock = mocker.Mock()
no_jobs_element_mock.text = "" # Empty text means "No matching jobs found" is not present

# 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 driver to simulate the page source
mocker.patch.object(job_manager.driver, 'page_source', return_value="")

# Mock the page_source
mocker.patch.object(job_manager.driver, 'page_source',
return_value="some page content")
# Mock the outer find_element
container_mock = mocker.Mock()

# 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"
# 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_element', side_effect=[
no_jobs_element_mock,
container_mock
])

job_manager.get_jobs_from_page()

assert job_manager.driver.find_element.call_count == 2
assert container_mock.find_elements.call_count == 1



def test_apply_jobs_with_no_jobs(mocker, job_manager):
Expand All @@ -94,9 +106,6 @@ def test_apply_jobs_with_no_jobs(mocker, job_manager):
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)
Expand All @@ -111,28 +120,15 @@ def test_apply_jobs_with_no_jobs(mocker, job_manager):
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
# Simulating two job elements
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])

mocker.patch.object(job_manager, 'get_jobs_from_page', return_value=job_elements_list)

job = Job(
title="Title",
Expand Down Expand Up @@ -181,7 +177,7 @@ def test_apply_jobs_with_jobs(mocker, job_manager):
job_manager.apply_jobs()

# Assertions
assert job_manager.driver.find_elements.call_count == 1
assert job_manager.get_jobs_from_page.call_count == 1
# Called for each job element
assert job_manager.job_tile_to_job.call_count == 2
# Called for each job element
Expand Down

0 comments on commit 76350f4

Please sign in to comment.