You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
126 lines
5.0 KiB
Python
126 lines
5.0 KiB
Python
"""Tribal Health job board adapter — Elementor WordPress loop, numbered-page pagination.
|
|
|
|
Tribal Health (tribalhealth.com) is a clinician staffing agency that places providers
|
|
at tribal / IHS sites. Its job board renders an Elementor loop of EVERY open job
|
|
(heavily PT/OT/RN); it exposes no usable keyword search, so we collect every card and
|
|
keyword-filter to dentist/orthodontist roles here (mirrors the nativehealth pattern).
|
|
|
|
The dental roles are sparse and never guaranteed to land on page 1, so collect_cards
|
|
walks ALL board pages itself rather than relying on the orchestrator's page loop —
|
|
which stops paginating the moment a page yields zero (post-filter) cards. paginate()
|
|
therefore always returns False.
|
|
"""
|
|
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
|
|
|
|
_DEFAULT_KEYWORDS = ["orthodontist", "orthodontic", "dentist"]
|
|
_COMPANY = "Tribal Health"
|
|
_BOARD_URL = "https://tribalhealth.com/job-board/"
|
|
_MAX_SCROLLS = 4 # Elementor loop lazy-renders cards as you scroll
|
|
|
|
_CARD_JS = """() => {
|
|
const cards = Array.from(document.querySelectorAll('.e-loop-item'));
|
|
return cards.map(c => {
|
|
const cls = c.className || '';
|
|
const a = c.querySelector("a[href*='/jobs/']");
|
|
const h = c.querySelector('.elementor-heading-title');
|
|
// Term list items render as [specialty, "City, State"].
|
|
const terms = Array.from(
|
|
c.querySelectorAll('.elementor-post-info__terms-list-item')
|
|
).map(t => t.textContent.trim()).filter(Boolean);
|
|
const location = terms.find(t => t.includes(',')) || terms[terms.length - 1] || null;
|
|
const postId = (cls.match(/post-(\\d+)/) || [])[1] || null;
|
|
return {
|
|
title: h ? h.textContent.trim() : (a ? a.textContent.trim() : null),
|
|
location: location,
|
|
url: a ? a.href : null,
|
|
external_job_id: postId,
|
|
};
|
|
}).filter(j => j.title && j.url);
|
|
}"""
|
|
|
|
|
|
@register("tribalhealth")
|
|
class TribalHealthAdapter(ManifestDrivenAdapter):
|
|
|
|
def _keywords(self) -> list[str]:
|
|
kws = self.manifest.post_filters.include_title_keywords or _DEFAULT_KEYWORDS
|
|
return [k.lower() for k in kws]
|
|
|
|
def _scrape_current_page(self, page) -> list[dict]:
|
|
"""Wait for cards, scroll to force lazy render, return raw card dicts."""
|
|
try:
|
|
page.wait_for_selector(".e-loop-item", timeout=12000, state="attached")
|
|
except Exception:
|
|
return []
|
|
for _ in range(_MAX_SCROLLS):
|
|
try:
|
|
page.mouse.wheel(0, 4000)
|
|
page.wait_for_timeout(400)
|
|
except Exception:
|
|
break
|
|
try:
|
|
return page.evaluate(_CARD_JS) or []
|
|
except Exception as e:
|
|
logger.debug(f"[tribalhealth] card evaluate failed: {e}")
|
|
return []
|
|
|
|
def collect_cards(self, page, manifest) -> list[RawJobCard]:
|
|
"""Walk every board page, gather all cards, then keyword-filter by title."""
|
|
max_pages = max(1, manifest.pagination.max_pages)
|
|
raw_data: list[dict] = []
|
|
seen_urls: set[str] = set()
|
|
|
|
for page_num in range(1, max_pages + 1):
|
|
if page_num > 1:
|
|
# Page 1 was already navigated to by the orchestrator.
|
|
try:
|
|
page.goto(f"{_BOARD_URL}{page_num}/", timeout=30000, wait_until="domcontentloaded")
|
|
except Exception:
|
|
break
|
|
page_raw = self._scrape_current_page(page)
|
|
if not page_raw:
|
|
break # empty page = end of board
|
|
new_on_page = 0
|
|
for item in page_raw:
|
|
if item["url"] in seen_urls:
|
|
continue
|
|
seen_urls.add(item["url"])
|
|
raw_data.append(item)
|
|
new_on_page += 1
|
|
logger.debug(f"[tribalhealth] page {page_num}: {new_on_page} new raw cards")
|
|
if new_on_page == 0:
|
|
break # pagination wrapped around / no new cards
|
|
|
|
keywords = self._keywords()
|
|
cards: list[RawJobCard] = []
|
|
for item in raw_data:
|
|
title = item["title"]
|
|
if not any(kw in title.lower() for kw in keywords):
|
|
continue
|
|
cards.append(RawJobCard(
|
|
title=title,
|
|
company=_COMPANY,
|
|
location=item["location"] or None,
|
|
url=item["url"],
|
|
external_job_id=item["external_job_id"],
|
|
))
|
|
|
|
logger.info(
|
|
f"[tribalhealth] Keyword filter: {len(cards)}/{len(raw_data)} cards matched "
|
|
f"across {min(page_num, max_pages)} page(s)"
|
|
)
|
|
return cards
|
|
|
|
def paginate(self, page, page_index: int, config) -> bool:
|
|
"""All pages are walked inside collect_cards — no orchestrator-level paging."""
|
|
return False
|
|
|
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
|
return super().normalize(raw)
|