Add 6 new dental job site adapters
- ihs: Indian Health Service Dentistry (table, keyword filter) - gilariver: Gila River Health Care via Infor CloudSuite (Angular, click pagination) - nativehealth: NATIVE HEALTH via SmartRecruiters (AJAX Show More, keyword filter) - srpmic: Salt River Pima-Maricopa via GovernmentJobs company page (URL keyword search) - bfrench: Consulting BFrench via JazzHR (simple table, keyword filter) - govtjobs: GovernmentJobs.com main search (URL keyword search, URL pagination) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
83fb8542a9
commit
9d5251cead
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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 <ul> directly inside the job card <li>
|
||||||
|
const detailsUl = li?.querySelector(':scope > ul');
|
||||||
|
const details = detailsUl
|
||||||
|
? Array.from(detailsUl.querySelectorAll('li')).map(l => l.innerText.trim())
|
||||||
|
: [];
|
||||||
|
// Posted date is in a direct child element starting with "Posted"
|
||||||
|
const postedText = Array.from(li?.children || [])
|
||||||
|
.map(c => c.innerText?.trim())
|
||||||
|
.find(t => t && t.startsWith('Posted')) || null;
|
||||||
|
return {
|
||||||
|
title: a.innerText.trim(),
|
||||||
|
url: a.href,
|
||||||
|
location: details[0] || null,
|
||||||
|
salary: details[1] || null,
|
||||||
|
posted: postedText
|
||||||
|
};
|
||||||
|
}).filter(j => j.title && j.url.includes('/careers/srpmic/jobs/'));
|
||||||
|
}""")
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
for item in raw_data:
|
||||||
|
cards.append(RawJobCard(
|
||||||
|
title=item["title"],
|
||||||
|
company="Salt River Pima-Maricopa Indian Community",
|
||||||
|
location=item["location"],
|
||||||
|
salary_text=item["salary"],
|
||||||
|
posted_text=item["posted"],
|
||||||
|
url=item["url"],
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.info(f"[srpmic] Extracted {len(cards)} cards")
|
||||||
|
return cards
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
"""Navigate to next page using URL ?page=N parameter."""
|
||||||
|
# Check if Next link is present and enabled
|
||||||
|
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"[srpmic] Navigating to page {next_page_num}: {next_url}")
|
||||||
|
page.goto(next_url, timeout=30000)
|
||||||
|
try:
|
||||||
|
page.wait_for_selector(
|
||||||
|
"a[href*='/careers/srpmic/jobs/']", timeout=15000, state="attached"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[srpmic] No jobs found on next page — stopping")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
return super().normalize(raw)
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
site_id: bfrench
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Consulting BFrench Jobs
|
||||||
|
category: employer
|
||||||
|
base_url: https://consultbfrench.applytojob.com
|
||||||
|
start_url_template: "https://consultbfrench.applytojob.com/apply/jobs/"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "a[href*='/apply/jobs/details/']"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "a[href*='/apply/jobs/details/']"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text: []
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
site_id: gilariver
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Gila River Health Care
|
||||||
|
category: employer
|
||||||
|
base_url: https://css-vlublqzyke7bgjm6-prd.inforcloudsuite.com
|
||||||
|
start_url_template: "https://css-vlublqzyke7bgjm6-prd.inforcloudsuite.com/hcm/Jobs/form/JobBoard%281,EXTERNAL%29.JobSearchCompositeForm?csk.JobBoard=EXTERNAL&csk.HROrganization=1&csk.IsoLocale=en_US&menu=JobsNavigationMenu.NewJobSearch"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: ".lm-card-content"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 5
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- ".lm-card-content"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "span.lm-card-field.text-strong a"
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
site_id: govtjobs
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: GovernmentJobs.com
|
||||||
|
category: government
|
||||||
|
base_url: https://www.governmentjobs.com
|
||||||
|
start_url_template: "https://www.governmentjobs.com/jobs?keyword={keywords_urlencoded}"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: url_param
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "h3 a[href*='/jobs/']"
|
||||||
|
|
||||||
|
search_override:
|
||||||
|
keywords:
|
||||||
|
- orthodontist
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 5
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "h3 a[href*='/jobs/']"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text: []
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
site_id: ihs
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Indian Health Service Dentistry
|
||||||
|
category: government
|
||||||
|
base_url: https://www.ihs.gov
|
||||||
|
start_url_template: "https://www.ihs.gov/dentistry/otheropportunities/"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "table.cf_grid tbody tr"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "table.cf_grid tbody tr"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "td:nth-child(2) a"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "td:nth-child(2) a"
|
||||||
|
name: href
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
site_id: nativehealth
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: NATIVE HEALTH
|
||||||
|
category: employer
|
||||||
|
base_url: https://careers.smartrecruiters.com
|
||||||
|
start_url_template: "https://careers.smartrecruiters.com/NATIVEHEALTH"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "a[href*='smartrecruiters.com/NATIVEHEALTH/']"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "a[href*='smartrecruiters.com/NATIVEHEALTH/']"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "h4"
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
site_id: srpmic
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: SRPMIC Employment Opportunities
|
||||||
|
category: government
|
||||||
|
base_url: https://www.governmentjobs.com
|
||||||
|
start_url_template: "https://www.governmentjobs.com/careers/srpmic?keywords={keywords_urlencoded}"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: url_param
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: "a[href*='/careers/srpmic/jobs/']"
|
||||||
|
|
||||||
|
search_override:
|
||||||
|
keywords:
|
||||||
|
- dentist
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 5
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- "a[href*='/careers/srpmic/jobs/']"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- "h4"
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: null
|
||||||
|
source_url: null
|
||||||
Loading…
Reference in New Issue