diff --git a/gimme_job/adapters/bfrench.py b/gimme_job/adapters/bfrench.py new file mode 100644 index 0000000..56ea969 --- /dev/null +++ b/gimme_job/adapters/bfrench.py @@ -0,0 +1,58 @@ +"""Consulting BFrench Jobs adapter — JazzHR single-page table, keyword filter.""" +from __future__ import annotations + +from loguru import logger + +from gimme_job.adapters.base import ManifestDrivenAdapter +from gimme_job.adapters.registry import register +from gimme_job.models.dto import JobPostingCandidate, RawJobCard + +_KEYWORDS = ["orthodontist", "orthodontic", "dentist"] + + +@register("bfrench") +class BFrenchAdapter(ManifestDrivenAdapter): + + def collect_cards(self, page, manifest) -> list[RawJobCard]: + """Extract all job rows from the table and filter by keywords.""" + raw_data = page.evaluate("""() => { + const links = document.querySelectorAll('a[href*="/apply/jobs/details/"]'); + return Array.from(links).map(a => { + const row = a.closest('tr'); + const cells = row ? Array.from(row.querySelectorAll('td')) : []; + return { + title: a.innerText.trim(), + url: a.href, + location: cells[1]?.innerText?.trim() || null + }; + }).filter(j => j.title); + }""") + + cards = [] + for item in raw_data: + if not self._matches_keywords(item["title"]): + continue + cards.append(RawJobCard( + title=item["title"], + company="Consulting BFrench", + location=item["location"], + url=item["url"], + )) + + logger.info( + f"[bfrench] Keyword filter: {len(cards)}/{len(raw_data)} cards matched" + ) + return cards + + def paginate(self, page, page_index: int, config) -> bool: + return False + + def normalize(self, raw: RawJobCard) -> JobPostingCandidate: + return super().normalize(raw) + + @staticmethod + def _matches_keywords(title: str | None) -> bool: + if not title: + return False + title_lower = title.lower() + return any(kw in title_lower for kw in _KEYWORDS) diff --git a/gimme_job/adapters/gilariver.py b/gimme_job/adapters/gilariver.py new file mode 100644 index 0000000..ecc08ef --- /dev/null +++ b/gimme_job/adapters/gilariver.py @@ -0,0 +1,104 @@ +"""Gila River Health Care adapter — Infor CloudSuite, keyword search + click pagination.""" +from __future__ import annotations + +from loguru import logger + +from gimme_job.adapters.base import ManifestDrivenAdapter +from gimme_job.adapters.registry import register +from gimme_job.models.dto import JobPostingCandidate, RawJobCard + +_SEARCH_KEYWORD = "dent" # broad match: captures Dentist, Dental, etc. +_KEYWORDS = ["orthodontist", "orthodontic", "dentist"] + + +@register("gilariver") +class GilaRiverAdapter(ManifestDrivenAdapter): + + def apply_search(self, page, query) -> None: + """Type keyword into search form and submit; wait for filtered results.""" + try: + page.wait_for_selector( + ".JobPostingSearchRel_col_KeywordSearch", + timeout=15000, + state="visible", + ) + kw_input = page.query_selector(".JobPostingSearchRel_col_KeywordSearch") + if not kw_input: + logger.warning("[gilariver] Keyword input not found") + return + + kw_input.click(click_count=3) + kw_input.type(_SEARCH_KEYWORD, delay=50) + kw_input.press("Enter") + page.wait_for_load_state("networkidle", timeout=15000) + logger.info(f"[gilariver] Searched for '{_SEARCH_KEYWORD}'") + except Exception as e: + logger.warning(f"[gilariver] apply_search failed: {e}") + + def collect_cards(self, page, manifest) -> list[RawJobCard]: + """Extract cards via JS and filter by keywords in-memory.""" + raw_data = page.evaluate("""() => { + const cards = document.querySelectorAll('.lm-card-content'); + return Array.from(cards).map(card => { + const titleA = card.querySelector('span.lm-card-field.text-strong a'); + const subtitle = card.querySelector( + 'span.lm-card-field:not(.text-strong):not(.text-emphasis)' + ); + const posted = card.querySelector('span.lm-card-field.text-emphasis'); + return { + title: (titleA?.textContent || '').trim(), + url: titleA?.href || '', + subtitle: (subtitle?.textContent || '').trim(), + posted: (posted?.textContent || '').trim() + }; + }).filter(r => r.title); + }""") + + cards = [] + for item in raw_data: + title = item["title"] + if not self._matches_keywords(title): + continue + + # Subtitle: "Location | Category | Status - Employment Type" + parts = [p.strip() for p in item["subtitle"].split("|")] + location = parts[0] if parts else None + + cards.append(RawJobCard( + title=title, + company="Gila River Health Care", + location=location or None, + posted_text=item["posted"] or None, + url=item["url"] or None, + )) + + logger.info( + f"[gilariver] Keyword filter: {len(cards)}/{len(raw_data)} cards matched" + ) + return cards + + def paginate(self, page, page_index: int, config) -> bool: + """Click Next Page button; return False when disabled or missing.""" + try: + buttons = page.query_selector_all("button") + next_btn = next( + (b for b in buttons if b.inner_text().strip() == "Next Page"), None + ) + if not next_btn or not next_btn.is_enabled(): + return False + next_btn.click() + page.wait_for_load_state("networkidle", timeout=15000) + return True + except Exception as e: + logger.debug(f"[gilariver] paginate failed: {e}") + return False + + def normalize(self, raw: RawJobCard) -> JobPostingCandidate: + return super().normalize(raw) + + @staticmethod + def _matches_keywords(title: str | None) -> bool: + if not title: + return False + title_lower = title.lower() + return any(kw in title_lower for kw in _KEYWORDS) diff --git a/gimme_job/adapters/govtjobs.py b/gimme_job/adapters/govtjobs.py new file mode 100644 index 0000000..06c0aac --- /dev/null +++ b/gimme_job/adapters/govtjobs.py @@ -0,0 +1,85 @@ +"""GovernmentJobs.com main search adapter — URL keyword search, URL-based pagination.""" +from __future__ import annotations + +import re + +from loguru import logger + +from gimme_job.adapters.base import ManifestDrivenAdapter +from gimme_job.adapters.registry import register +from gimme_job.models.dto import JobPostingCandidate, RawJobCard + + +@register("govtjobs") +class GovtJobsAdapter(ManifestDrivenAdapter): + + def collect_cards(self, page, manifest) -> list[RawJobCard]: + """Extract job cards from the search results page.""" + raw_data = page.evaluate("""() => { + const cards = document.querySelectorAll('h3 a[href*="/jobs/"]'); + return Array.from(cards).map(a => { + const cardDiv = a.closest('h3')?.parentElement; + const generics = cardDiv + ? Array.from(cardDiv.children).filter(el => el.tagName !== 'H3') + : []; + return { + title: a.innerText.trim(), + url: a.href, + company: generics[0]?.innerText?.trim() || null, + location: generics[1]?.innerText?.trim() || null, + salary: generics[2]?.innerText?.trim() || null + }; + }).filter(j => j.title && j.url.includes('/jobs/')); + }""") + + cards = [] + for item in raw_data: + cards.append(RawJobCard( + title=item["title"], + company=item["company"], + location=item["location"], + salary_text=item["salary"], + url=item["url"], + )) + + logger.info(f"[govtjobs] Extracted {len(cards)} cards") + return cards + + def paginate(self, page, page_index: int, config) -> bool: + """Navigate to next page using URL ?page=N parameter.""" + has_next = page.evaluate("""() => { + const links = Array.from(document.querySelectorAll('a')); + const next = links.find(a => + a.textContent.trim() === 'Next' || + (a.getAttribute('aria-label') || '').toLowerCase().includes('next page') + ); + return !!(next && !next.hasAttribute('disabled') && + next.getAttribute('aria-disabled') !== 'true'); + }""") + + if not has_next: + return False + + next_page_num = page_index + 2 # page_index 0 → go to page 2 + current_url = page.url + + if re.search(r'[?&]page=\d+', current_url): + next_url = re.sub(r'(page=)\d+', f'page={next_page_num}', current_url) + elif '?' in current_url: + next_url = current_url + f'&page={next_page_num}' + else: + next_url = current_url + f'?page={next_page_num}' + + logger.debug(f"[govtjobs] Navigating to page {next_page_num}: {next_url}") + page.goto(next_url, timeout=30000) + try: + page.wait_for_selector( + "h3 a[href*='/jobs/']", timeout=15000, state="attached" + ) + except Exception: + logger.debug("[govtjobs] No jobs found on next page — stopping") + return False + return True + + def normalize(self, raw: RawJobCard) -> JobPostingCandidate: + return super().normalize(raw) diff --git a/gimme_job/adapters/ihs.py b/gimme_job/adapters/ihs.py new file mode 100644 index 0000000..f0c2b6d --- /dev/null +++ b/gimme_job/adapters/ihs.py @@ -0,0 +1,79 @@ +"""Indian Health Service Dentistry adapter — single-page table, keyword post-filter.""" +from __future__ import annotations + +import re + +from loguru import logger + +from gimme_job.adapters.base import ManifestDrivenAdapter +from gimme_job.adapters.registry import register +from gimme_job.models.dto import JobPostingCandidate, RawJobCard + +_BASE_URL = "https://www.ihs.gov" +_KEYWORDS = ["orthodontist", "orthodontic", "dentist"] + + +@register("ihs") +class IHSAdapter(ManifestDrivenAdapter): + + def collect_cards(self, page, manifest): + """Extract all rows via JS, combine city+state into location, filter by keywords.""" + try: + page.wait_for_selector("table.cf_grid tbody tr", timeout=15000, state="attached") + except Exception: + logger.debug("[ihs] Table wait timed out") + + raw_data = page.evaluate("""() => { + const rows = document.querySelectorAll('table.cf_grid tbody tr'); + return Array.from(rows).map(row => { + const titleEl = row.querySelector('td:nth-child(2) a'); + const city = (row.querySelector('td:nth-child(3)')?.textContent || '').trim(); + const state = (row.querySelector('td:nth-child(4)')?.textContent || '').trim(); + return { + title: (titleEl?.textContent || '').trim(), + url: titleEl?.getAttribute('href') || '', + city: city, + state: state + }; + }).filter(r => r.title); + }""") + + cards = [] + for item in raw_data: + title = item["title"] + if not self._matches_keywords(title): + continue + + # Strip HPSA score prefix: "(N-HPSA Score) CityName" → "CityName" + city = re.sub(r"^\([^)]+\)\s*", "", item["city"]) + location = f"{city}, {item['state']}".strip(", ") + + url = item["url"] + if url.startswith("/"): + url = _BASE_URL + url + + cards.append(RawJobCard( + title=title, + company="Indian Health Service", + location=location or None, + url=url or None, + )) + + logger.info( + f"[ihs] Keyword filter: {len(cards)}/{len(raw_data)} cards matched" + ) + return cards + + def paginate(self, page, page_index: int, config) -> bool: + """Single page — no pagination.""" + return False + + def normalize(self, raw: RawJobCard) -> JobPostingCandidate: + return super().normalize(raw) + + @staticmethod + def _matches_keywords(title: str | None) -> bool: + if not title: + return False + title_lower = title.lower() + return any(kw in title_lower for kw in _KEYWORDS) diff --git a/gimme_job/adapters/nativehealth.py b/gimme_job/adapters/nativehealth.py new file mode 100644 index 0000000..1f5a30e --- /dev/null +++ b/gimme_job/adapters/nativehealth.py @@ -0,0 +1,94 @@ +"""NATIVE HEALTH adapter — SmartRecruiters career page, Show More AJAX, keyword filter.""" +from __future__ import annotations + +from loguru import logger + +from gimme_job.adapters.base import ManifestDrivenAdapter +from gimme_job.adapters.registry import register +from gimme_job.models.dto import JobPostingCandidate, RawJobCard + +_MAX_SHOW_MORE = 10 # max "Show more jobs" clicks to prevent infinite loops +_KEYWORDS = ["orthodontist", "orthodontic", "dentist"] + + +@register("nativehealth") +class NativeHealthAdapter(ManifestDrivenAdapter): + + def collect_cards(self, page, manifest) -> list[RawJobCard]: + """Click all 'Show more jobs' links, then extract and keyword-filter all jobs.""" + try: + page.wait_for_selector( + "a[href*='smartrecruiters.com/NATIVEHEALTH/']", + timeout=15000, + state="attached", + ) + except Exception: + logger.debug("[nativehealth] Initial jobs wait timed out") + + # Click "Show more jobs" links until none remain (AJAX loads more into same page) + for i in range(_MAX_SHOW_MORE): + show_more = page.query_selector("a:text('Show more jobs')") + if not show_more: + break + + prev_count = len( + page.query_selector_all("a[href*='smartrecruiters.com/NATIVEHEALTH/']") + ) + show_more.click() + + try: + page.wait_for_function( + f"document.querySelectorAll(\"a[href*='smartrecruiters.com/NATIVEHEALTH/']\").length > {prev_count}", + timeout=10000, + ) + logger.debug(f"[nativehealth] Show more {i + 1}: loaded more jobs") + except Exception: + logger.debug(f"[nativehealth] Show more {i + 1}: no new jobs appeared") + break + + # Extract all jobs via JS, getting location from h3 heading of each group + raw_data = page.evaluate("""() => { + const links = document.querySelectorAll("a[href*='smartrecruiters.com/NATIVEHEALTH/']"); + return Array.from(links).map(a => { + // Walk up: a → li → ul (job list) → location group div → h3 + const locationDiv = a.closest('li')?.parentElement?.parentElement; + const location = locationDiv?.querySelector('h3')?.textContent?.trim() || null; + return { + title: (a.querySelector('h4')?.textContent || '').trim(), + employment: (a.querySelector('p')?.textContent || '').trim(), + url: a.href, + location + }; + }).filter(j => j.title); + }""") + + cards = [] + for item in raw_data: + if not self._matches_keywords(item["title"]): + continue + cards.append(RawJobCard( + title=item["title"], + company="NATIVE HEALTH", + location=item["location"] or None, + employment_type=item["employment"] or None, + url=item["url"] or None, + )) + + logger.info( + f"[nativehealth] Keyword filter: {len(cards)}/{len(raw_data)} cards matched" + ) + return cards + + def paginate(self, page, page_index: int, config) -> bool: + """Single page after Show More clicks — no further pagination.""" + return False + + def normalize(self, raw: RawJobCard) -> JobPostingCandidate: + return super().normalize(raw) + + @staticmethod + def _matches_keywords(title: str | None) -> bool: + if not title: + return False + title_lower = title.lower() + return any(kw in title_lower for kw in _KEYWORDS) diff --git a/gimme_job/adapters/srpmic.py b/gimme_job/adapters/srpmic.py new file mode 100644 index 0000000..6191e3c --- /dev/null +++ b/gimme_job/adapters/srpmic.py @@ -0,0 +1,93 @@ +"""SRPMIC (Salt River Pima-Maricopa Indian Community) adapter — GovernmentJobs.com.""" +from __future__ import annotations + +import re + +from loguru import logger + +from gimme_job.adapters.base import ManifestDrivenAdapter +from gimme_job.adapters.registry import register +from gimme_job.models.dto import JobPostingCandidate, RawJobCard + + +@register("srpmic") +class SRPMICAdapter(ManifestDrivenAdapter): + + def collect_cards(self, page, manifest) -> list[RawJobCard]: + """Extract job cards via JS from the current results page.""" + raw_data = page.evaluate("""() => { + const links = document.querySelectorAll('a[href*="/careers/srpmic/jobs/"]'); + return Array.from(links).map(a => { + const li = a.closest('li'); + // Details list is the first