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.
132 lines
4.8 KiB
Python
132 lines
4.8 KiB
Python
"""HRSA Health Workforce Connector adapter — form keyword search, client-side 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
|
|
|
|
_DEFAULT_KEYWORD = "dentist"
|
|
|
|
|
|
@register("hrsa")
|
|
class HRSAConnectorAdapter(ManifestDrivenAdapter):
|
|
|
|
def apply_search(self, page, query) -> None:
|
|
"""Type keyword into the search form and submit."""
|
|
keyword = (query.keywords[0] if query.keywords else _DEFAULT_KEYWORD)
|
|
|
|
# Wait for initial JS render to complete
|
|
try:
|
|
page.wait_for_load_state("networkidle", timeout=20000)
|
|
except Exception:
|
|
pass
|
|
|
|
# Dismiss feedback dialog if present
|
|
try:
|
|
no_thanks = page.locator('button:has-text("No Thanks")').first
|
|
if no_thanks.is_visible():
|
|
no_thanks.click()
|
|
page.wait_for_timeout(500)
|
|
except Exception:
|
|
pass
|
|
|
|
# Wait for the main keyword input (not the banner header input)
|
|
kw_locator = page.locator('[placeholder="Keyword, Name, or Job Title"]').first
|
|
try:
|
|
kw_locator.wait_for(state="visible", timeout=15000)
|
|
except Exception:
|
|
logger.warning("[hrsa] Keyword input not visible — skipping search")
|
|
return
|
|
|
|
kw_locator.fill(keyword)
|
|
|
|
# Press Enter or JS-click the Search button (bypasses Playwright visibility check)
|
|
try:
|
|
page.evaluate("""() => {
|
|
const btn = document.querySelector('button[label="Search"]') ||
|
|
Array.from(document.querySelectorAll('button'))
|
|
.find(b => b.textContent.trim() === 'Search');
|
|
if (btn) btn.click();
|
|
}""")
|
|
except Exception:
|
|
kw_locator.press("Enter")
|
|
|
|
# Wait for results to load
|
|
try:
|
|
page.wait_for_load_state("networkidle", timeout=15000)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.debug(f"[hrsa] Search submitted for keyword: '{keyword}'")
|
|
|
|
def collect_cards(self, page, manifest) -> list[RawJobCard]:
|
|
"""Extract job opportunity rows from the results table."""
|
|
raw_data = page.evaluate("""() => {
|
|
const links = document.querySelectorAll(
|
|
'a[href*="/site-profile/"][href*="/opportunity/"]'
|
|
);
|
|
return Array.from(links).map(a => {
|
|
const td = a.closest('td');
|
|
if (!td) return null;
|
|
const tr = td.closest('tr');
|
|
const cells = tr ? Array.from(tr.querySelectorAll('td')) : [];
|
|
const cell1 = cells[0];
|
|
const cell2 = cells[1];
|
|
|
|
const c1kids = cell1 ? Array.from(cell1.children) : [];
|
|
// c1kids[0] = list (title link + specialty)
|
|
// c1kids[1] = "Job"
|
|
// c1kids[2] = employment type (Full-Time / Part-Time)
|
|
// c1kids[3] = site/company name
|
|
|
|
const employmentType = c1kids[2]?.innerText?.trim() || null;
|
|
const company = c1kids[3]?.innerText?.trim() || null;
|
|
|
|
const c2kids = cell2 ? Array.from(cell2.children) : [];
|
|
// c2kids[0] = programs + status
|
|
// c2kids[1] = location
|
|
const location = c2kids[1]?.innerText?.trim() || null;
|
|
|
|
return {
|
|
title: a.innerText.trim(),
|
|
url: a.href,
|
|
company,
|
|
location,
|
|
employment_type: employmentType,
|
|
};
|
|
}).filter(j => j && j.title && j.url.includes('/opportunity/'));
|
|
}""")
|
|
|
|
cards = []
|
|
for item in raw_data:
|
|
cards.append(RawJobCard(
|
|
title=item["title"],
|
|
company=item["company"],
|
|
location=item["location"],
|
|
employment_type=item["employment_type"],
|
|
url=item["url"],
|
|
))
|
|
|
|
logger.info(f"[hrsa] Extracted {len(cards)} cards")
|
|
return cards
|
|
|
|
def paginate(self, page, page_index: int, config) -> bool:
|
|
"""Click 'Go to next page' button if available (client-side SPA pagination)."""
|
|
next_btn = page.query_selector('button[aria-label="Go to next page"]')
|
|
if not next_btn or not next_btn.is_enabled():
|
|
return False
|
|
|
|
next_btn.click()
|
|
try:
|
|
page.wait_for_load_state("networkidle", timeout=15000)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.debug(f"[hrsa] Navigated to page {page_index + 2}")
|
|
return True
|
|
|
|
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
|
|
return super().normalize(raw)
|