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.

135 lines
5.0 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];
// Filter out <br> elements — they appear between spans and shift indices
const c1kids = cell1
? Array.from(cell1.children).filter(el => el.tagName !== 'BR')
: [];
// c1kids[0] = ul (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)