"""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)