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
I Luk Kim 5 months ago
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…
Cancel
Save