Compare commits
10 Commits
c4f9d5638b
...
ca5d229ca2
| Author | SHA1 | Date |
|---|---|---|
|
|
ca5d229ca2 | 2 months ago |
|
|
7086dcb0ea | 2 months ago |
|
|
e13a25b3d5 | 3 months ago |
|
|
37e5b59126 | 3 months ago |
|
|
fa748bebf9 | 3 months ago |
|
|
2f33ea890d | 3 months ago |
|
|
a49a6539a1 | 4 months ago |
|
|
0835522d06 | 4 months ago |
|
|
5fa28b3948 | 4 months ago |
|
|
317f929059 | 5 months ago |
@ -0,0 +1,120 @@
|
|||||||
|
"""Google Jobs adapter — scroll to load more results, JS extraction."""
|
||||||
|
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 RawJobCard
|
||||||
|
|
||||||
|
_MAX_SCROLLS = 5
|
||||||
|
_SCROLL_WAIT_MS = 2000
|
||||||
|
_CARD_SELECTOR = 'div[jsname="y1Aese"]'
|
||||||
|
|
||||||
|
_EXTRACT_JS = """() => {
|
||||||
|
const cards = document.querySelectorAll('div[jsname="y1Aese"]');
|
||||||
|
return Array.from(cards).map(card => {
|
||||||
|
const shareEl = card.closest('[data-share-url]');
|
||||||
|
const shareUrl = shareEl ? shareEl.getAttribute('data-share-url') : '';
|
||||||
|
|
||||||
|
let htidocid = '';
|
||||||
|
if (shareUrl) {
|
||||||
|
const docMatch = shareUrl.match(/htidocid=([^&]+)/);
|
||||||
|
if (docMatch) htidocid = decodeURIComponent(docMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply URLs (Indeed, Glassdoor, Monster, etc.) live INSIDE a <template>
|
||||||
|
// tag within share_el. <template> content is inert in the rendered DOM —
|
||||||
|
// querySelectorAll('a[...]') from outside doesn't see them. We must
|
||||||
|
// traverse template.content explicitly. These are stable direct links
|
||||||
|
// to the source posting that work regardless of session/location.
|
||||||
|
let jobUrl = '';
|
||||||
|
if (shareEl) {
|
||||||
|
const templates = shareEl.querySelectorAll('template');
|
||||||
|
for (const t of templates) {
|
||||||
|
const link = t.content.querySelector('a[href*="utm_campaign=google_jobs_apply"]');
|
||||||
|
if (link) {
|
||||||
|
jobUrl = link.getAttribute('href');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateSpan = card.querySelector('span[aria-label^="Posted"]');
|
||||||
|
const salarySpan = card.querySelector('span[aria-label^="Salary"]');
|
||||||
|
const typeSpan = card.querySelector('span[aria-label^="Employment Type"]');
|
||||||
|
|
||||||
|
// Positional extraction: filter aria-hidden and known UI texts, then take by index.
|
||||||
|
// DOM order within a card is always: title → company → location.
|
||||||
|
const UI_TEXTS = new Set(['Share', 'Click to copy link', 'Share link', 'Link copied']);
|
||||||
|
const leafDivs = Array.from(card.querySelectorAll('div')).filter(d =>
|
||||||
|
d.children.length === 0 &&
|
||||||
|
d.getAttribute('aria-hidden') !== 'true' &&
|
||||||
|
d.textContent.trim() &&
|
||||||
|
!UI_TEXTS.has(d.textContent.trim())
|
||||||
|
);
|
||||||
|
const title = leafDivs[0]?.textContent.trim() || '';
|
||||||
|
const company = leafDivs[1]?.textContent.trim() || '';
|
||||||
|
// Location always contains " • via "; fallback to index 2
|
||||||
|
const locationEl = leafDivs.find(d => d.textContent.includes(' \u2022 via ')) || leafDivs[2];
|
||||||
|
const location = locationEl?.textContent.trim() || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
company,
|
||||||
|
location,
|
||||||
|
url: jobUrl,
|
||||||
|
external_job_id: htidocid,
|
||||||
|
posted_text: dateSpan ? dateSpan.getAttribute('aria-label').replace('Posted ', '') : '',
|
||||||
|
salary_text: salarySpan ? salarySpan.getAttribute('aria-label').replace('Salary ', '') : '',
|
||||||
|
employment_type: typeSpan ? typeSpan.getAttribute('aria-label').replace('Employment Type ', '') : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
@register("googlejobs")
|
||||||
|
class GoogleJobsAdapter(ManifestDrivenAdapter):
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest) -> list[RawJobCard]:
|
||||||
|
"""Scroll to load more results, then extract all cards via JS."""
|
||||||
|
self._scroll_to_load(page)
|
||||||
|
return self._extract_cards(page)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _scroll_to_load(self, page) -> None:
|
||||||
|
for i in range(_MAX_SCROLLS):
|
||||||
|
prev_count = page.evaluate(
|
||||||
|
f"document.querySelectorAll('{_CARD_SELECTOR}').length"
|
||||||
|
)
|
||||||
|
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
|
page.wait_for_timeout(_SCROLL_WAIT_MS)
|
||||||
|
new_count = page.evaluate(
|
||||||
|
f"document.querySelectorAll('{_CARD_SELECTOR}').length"
|
||||||
|
)
|
||||||
|
logger.debug(f"[googlejobs] Scroll {i+1}: {prev_count} → {new_count} cards")
|
||||||
|
if new_count <= prev_count:
|
||||||
|
logger.debug(f"[googlejobs] No new cards after scroll {i+1} — stopping")
|
||||||
|
break
|
||||||
|
|
||||||
|
def _extract_cards(self, page) -> list[RawJobCard]:
|
||||||
|
data = page.evaluate(_EXTRACT_JS)
|
||||||
|
cards = []
|
||||||
|
for item in data:
|
||||||
|
title = (item.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
cards.append(RawJobCard(
|
||||||
|
title=title,
|
||||||
|
company=(item.get("company") or "").strip() or None,
|
||||||
|
location=(item.get("location") or "").strip() or None,
|
||||||
|
url=item.get("url") or None,
|
||||||
|
external_job_id=(item.get("external_job_id") or "").strip() or None,
|
||||||
|
posted_text=(item.get("posted_text") or "").strip() or None,
|
||||||
|
salary_text=(item.get("salary_text") or "").strip() or None,
|
||||||
|
employment_type=(item.get("employment_type") or "").strip() or None,
|
||||||
|
))
|
||||||
|
logger.debug(f"[googlejobs] Extracted {len(cards)} cards")
|
||||||
|
return cards
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
"""HospitalRecruiting adapter — JS extraction from job cards."""
|
||||||
|
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.hospitalrecruiting.com"
|
||||||
|
|
||||||
|
_EXTRACT_JS = """() => {
|
||||||
|
const cards = document.querySelectorAll('div:has(> a[href^="/job/"][aria-label])');
|
||||||
|
return Array.from(cards).map(card => {
|
||||||
|
const link = card.querySelector('a[href^="/job/"][aria-label]');
|
||||||
|
const h2 = card.querySelector('h2');
|
||||||
|
const divs = Array.from(card.querySelectorAll(':scope > div'));
|
||||||
|
const companyDiv = divs.find(d => d.textContent.includes('Company:'));
|
||||||
|
const locationDiv = divs.find(d => !d.textContent.includes('Company:') && d.textContent.trim());
|
||||||
|
const href = link ? link.getAttribute('href') : '';
|
||||||
|
const idMatch = href.match(/\\/job\\/(\\d+)\\//);
|
||||||
|
return {
|
||||||
|
title: h2?.textContent?.trim() || link?.getAttribute('aria-label') || '',
|
||||||
|
url: href,
|
||||||
|
company: companyDiv ? companyDiv.textContent.replace('Company:', '').trim() : '',
|
||||||
|
location: locationDiv?.textContent?.trim() || '',
|
||||||
|
external_job_id: idMatch ? idMatch[1] : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
@register("hospitalrecruiting")
|
||||||
|
class HospitalRecruitingAdapter(ManifestDrivenAdapter):
|
||||||
|
|
||||||
|
def collect_cards(self, page, manifest) -> list[RawJobCard]:
|
||||||
|
try:
|
||||||
|
page.wait_for_selector('a[href^="/job/"][aria-label]', timeout=10000)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[hospitalrecruiting] job link wait timed out")
|
||||||
|
return self._extract_cards(page)
|
||||||
|
|
||||||
|
def paginate(self, page, page_index: int, config) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
||||||
|
# Make URL absolute
|
||||||
|
if raw.url and raw.url.startswith("/"):
|
||||||
|
raw = raw.model_copy(update={"url": _BASE_URL + raw.url})
|
||||||
|
|
||||||
|
# Strip "Specialty - " prefix from location
|
||||||
|
# e.g. "Orthodontist - Roanoke, Virginia" → "Roanoke, Virginia"
|
||||||
|
if raw.location:
|
||||||
|
m = re.match(r'^[^,]+ - (.+)$', raw.location)
|
||||||
|
if m:
|
||||||
|
raw = raw.model_copy(update={"location": m.group(1)})
|
||||||
|
|
||||||
|
return super().normalize(raw)
|
||||||
|
|
||||||
|
def _extract_cards(self, page) -> list[RawJobCard]:
|
||||||
|
data = page.evaluate(_EXTRACT_JS)
|
||||||
|
cards = []
|
||||||
|
for item in data:
|
||||||
|
title = (item.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
cards.append(RawJobCard(
|
||||||
|
title=title,
|
||||||
|
company=(item.get("company") or "").strip() or None,
|
||||||
|
location=(item.get("location") or "").strip() or None,
|
||||||
|
url=item.get("url") or None,
|
||||||
|
external_job_id=(item.get("external_job_id") or "").strip() or None,
|
||||||
|
))
|
||||||
|
logger.debug(f"[hospitalrecruiting] Extracted {len(cards)} cards")
|
||||||
|
return cards
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
"""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)
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
site_id: googlejobs
|
||||||
|
enabled: false
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Google Jobs
|
||||||
|
category: aggregator
|
||||||
|
base_url: https://www.google.com
|
||||||
|
start_url_template: "https://www.google.com/search?q=orthodontist+job&udm=8"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
search:
|
||||||
|
keyword_mode: url_param
|
||||||
|
result_list_wait_selector: "div[jsname='y1Aese']"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors: []
|
||||||
|
fields: {}
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_title_keywords:
|
||||||
|
- orthodontist
|
||||||
|
exclude_company_keywords:
|
||||||
|
- u.s. navy
|
||||||
|
- us navy
|
||||||
|
|
||||||
|
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,46 @@
|
|||||||
|
site_id: hospitalrecruiting
|
||||||
|
enabled: false
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: HospitalRecruiting
|
||||||
|
category: aggregator
|
||||||
|
base_url: https://www.hospitalrecruiting.com
|
||||||
|
start_url_template: "https://www.hospitalrecruiting.com/jobs/Orthodontist-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^='/job/'][aria-label]"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: none
|
||||||
|
max_pages: 1
|
||||||
|
|
||||||
|
extract:
|
||||||
|
container_selectors: []
|
||||||
|
fields: {}
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_posted_text: []
|
||||||
|
include_title_keywords:
|
||||||
|
- orthodontist
|
||||||
|
- orthodontic
|
||||||
|
|
||||||
|
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,64 @@
|
|||||||
|
site_id: tribalhealth
|
||||||
|
enabled: true
|
||||||
|
repair_needed: false
|
||||||
|
|
||||||
|
identity:
|
||||||
|
label: Tribal Health Job Board
|
||||||
|
category: staffing_agency
|
||||||
|
base_url: https://tribalhealth.com
|
||||||
|
start_url_template: "https://tribalhealth.com/job-board/"
|
||||||
|
|
||||||
|
browser:
|
||||||
|
profile_mode: persistent_chrome_profile
|
||||||
|
profile_name: JobAgent
|
||||||
|
headed_on_learn: true
|
||||||
|
headless_on_run: true
|
||||||
|
|
||||||
|
# The board is NOT keyword-searchable — it renders an Elementor loop of ALL open
|
||||||
|
# jobs (PT/OT/RN-heavy clinician staffing). We collect every card and keyword-filter
|
||||||
|
# to dental/ortho roles in the adapter + post_filters. Do NOT set
|
||||||
|
# multi_keyword_mode: separate (there is no per-keyword search to sweep).
|
||||||
|
search:
|
||||||
|
keyword_mode: none
|
||||||
|
location_mode: none
|
||||||
|
sort_mode: none
|
||||||
|
date_mode: none
|
||||||
|
result_list_wait_selector: ".e-loop-item"
|
||||||
|
|
||||||
|
pagination:
|
||||||
|
mode: url_increment # /job-board/{N}/ — walked inside TribalHealthAdapter.collect_cards
|
||||||
|
max_pages: 10 # board is ~3 pages today; headroom so page-4+ roles aren't silently dropped.
|
||||||
|
# collect_cards stops at the first empty page (dedup-by-URL guard prevents loops).
|
||||||
|
|
||||||
|
# NOTE: extraction is fully handled by TribalHealthAdapter.collect_cards (JS over .e-loop-item);
|
||||||
|
# this block is documentary only — the registry always returns the custom adapter, so these
|
||||||
|
# selectors are never executed by the manifest-driven path.
|
||||||
|
extract:
|
||||||
|
container_selectors:
|
||||||
|
- ".e-loop-item"
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
text:
|
||||||
|
- ".elementor-heading-title"
|
||||||
|
url:
|
||||||
|
attr:
|
||||||
|
selector: "a[href*='/jobs/']"
|
||||||
|
name: "href"
|
||||||
|
location:
|
||||||
|
text:
|
||||||
|
- ".elementor-post-info__terms-list-item" # adapter picks the term containing a comma ("City, State")
|
||||||
|
|
||||||
|
post_filters:
|
||||||
|
include_title_keywords:
|
||||||
|
- orthodontist
|
||||||
|
- orthodontic
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
min_items_expected: 0
|
||||||
|
required_fields:
|
||||||
|
- title
|
||||||
|
fail_if_zero_when_known_active: false
|
||||||
|
|
||||||
|
learn:
|
||||||
|
last_learned_at: 2026-06-12T00:00:00
|
||||||
|
source_url: "https://tribalhealth.com/job-board/"
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
"""Smoke test for the Tribal Health job board adapter.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
1. The manifest parses and exposes required fields
|
||||||
|
2. The adapter is registered
|
||||||
|
3. (network) The board opens and either dental/ortho cards are detected OR a
|
||||||
|
zero-result state is handled cleanly; any returned card matches the keyword filter
|
||||||
|
|
||||||
|
NOTE: The network test requires a connection and the Chrome browser.
|
||||||
|
Mark with @pytest.mark.network to allow skipping in CI.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tribalhealth_manifest():
|
||||||
|
from gimme_job.config import load_site_manifest
|
||||||
|
try:
|
||||||
|
return load_site_manifest("tribalhealth")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pytest.skip("tribalhealth.yaml not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_tribalhealth_manifest_valid(tribalhealth_manifest):
|
||||||
|
"""Manifest parses and has required fields."""
|
||||||
|
assert tribalhealth_manifest.site_id == "tribalhealth"
|
||||||
|
assert tribalhealth_manifest.identity.base_url
|
||||||
|
assert tribalhealth_manifest.identity.start_url_template
|
||||||
|
assert tribalhealth_manifest.extract.container_selectors
|
||||||
|
# Keyword filter must be present — the board is not keyword-searchable.
|
||||||
|
assert tribalhealth_manifest.post_filters.include_title_keywords
|
||||||
|
|
||||||
|
|
||||||
|
def test_tribalhealth_adapter_registered():
|
||||||
|
"""TribalHealthAdapter is registered in the registry."""
|
||||||
|
from gimme_job.adapters.registry import _load_all_adapters, _REGISTRY
|
||||||
|
_load_all_adapters()
|
||||||
|
assert "tribalhealth" in _REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def test_tribalhealth_start_url(tribalhealth_manifest):
|
||||||
|
"""Start URL points at the job board."""
|
||||||
|
from gimme_job.models.dto import SearchQuery
|
||||||
|
url = tribalhealth_manifest.build_start_url(SearchQuery())
|
||||||
|
assert "tribalhealth.com/job-board" in url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.network
|
||||||
|
def test_tribalhealth_smoke(tribalhealth_manifest):
|
||||||
|
"""Open the board and verify cards (keyword-matched) or a clean zero-result."""
|
||||||
|
from gimme_job.adapters.tribalhealth import TribalHealthAdapter
|
||||||
|
from gimme_job.runtime.browser import BrowserManager
|
||||||
|
|
||||||
|
adapter = TribalHealthAdapter(tribalhealth_manifest)
|
||||||
|
bm = BrowserManager(headless=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
bm.open_context(headless=True)
|
||||||
|
page = bm.new_page()
|
||||||
|
page.set_default_timeout(20000)
|
||||||
|
|
||||||
|
url = tribalhealth_manifest.build_start_url(
|
||||||
|
__import__("gimme_job.models.dto", fromlist=["SearchQuery"]).SearchQuery()
|
||||||
|
)
|
||||||
|
page.goto(url, timeout=45000, wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
cards = adapter.collect_cards(page, tribalhealth_manifest)
|
||||||
|
assert isinstance(cards, list), "collect_cards must return a list"
|
||||||
|
|
||||||
|
keywords = [k.lower() for k in tribalhealth_manifest.post_filters.include_title_keywords]
|
||||||
|
for card in cards:
|
||||||
|
assert card.title and card.title.strip(), "Card must have a title"
|
||||||
|
# Every returned card must satisfy the keyword filter.
|
||||||
|
assert any(kw in card.title.lower() for kw in keywords), (
|
||||||
|
f"Card slipped the keyword filter: {card.title!r}"
|
||||||
|
)
|
||||||
|
# At least one field beyond title.
|
||||||
|
assert any([card.location, card.url, card.company]), "Card needs a second field"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
bm.close()
|
||||||
Loading…
Reference in New Issue