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.

160 lines
6.0 KiB
Python

"""Base adapter protocol and manifest-driven default implementation."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from loguru import logger
if TYPE_CHECKING:
from playwright.sync_api import Page
from gimme_job.models.dto import JobPostingCandidate, RawJobCard, SearchQuery
from gimme_job.models.manifest import SiteManifest
@runtime_checkable
class BaseJobSiteAdapter(Protocol):
"""Protocol that every site adapter must implement."""
site_id: str
def prepare(self, page: "Page", config: SiteManifest) -> None:
"""Navigate to the start URL and wait for page readiness."""
...
def apply_search(self, page: "Page", query: SearchQuery) -> None:
"""Inject keywords/location into the search form."""
...
def apply_filters(self, page: "Page", query: SearchQuery) -> None:
"""Click date/type filters in the UI."""
...
def collect_cards(self, page: "Page", config: SiteManifest) -> list[RawJobCard]:
"""Extract all visible job cards from the current page."""
...
def paginate(self, page: "Page", page_index: int, config: SiteManifest) -> bool:
"""Advance to the next page. Return True if successful, False if no more pages."""
...
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
"""Clean and normalize a RawJobCard into a JobPostingCandidate."""
...
class ManifestDrivenAdapter:
"""Default adapter that reads all behavior from a SiteManifest YAML."""
def __init__(self, manifest: SiteManifest):
self.manifest = manifest
self.site_id = manifest.site_id
def prepare(self, page: "Page", config: SiteManifest) -> None:
"""Navigate to the start URL and wait for initial load."""
from gimme_job.models.dto import SearchQuery
# build a minimal query for URL construction
query = SearchQuery()
url = config.build_start_url(query)
logger.info(f"[{self.site_id}] Navigating to {url}")
page.goto(url, timeout=config.browser.__class__.__fields__ # will be set from config
if False else 30000)
if config.search.result_list_wait_selector:
try:
page.wait_for_selector(
config.search.result_list_wait_selector,
timeout=15000,
state="attached",
)
except Exception:
logger.warning(f"[{self.site_id}] Wait selector timed out")
def apply_search(self, page: "Page", query: SearchQuery) -> None:
"""For URL-param sites, navigation already included keywords — nothing to do."""
pass
def apply_filters(self, page: "Page", query: SearchQuery) -> None:
"""Click date filter if configured."""
search_cfg = self.manifest.search
if search_cfg.date_mode != "click_filter":
return
if not search_cfg.date_filter_text:
return
logger.debug(f"[{self.site_id}] Applying date filter: {search_cfg.date_filter_text}")
try:
# Try to find and click an element matching the filter text
page.get_by_text(search_cfg.date_filter_text, exact=False).first.click(timeout=5000)
page.wait_for_load_state("networkidle", timeout=10000)
except Exception as e:
logger.warning(f"[{self.site_id}] Date filter click failed: {e}")
def collect_cards(self, page: "Page", config: SiteManifest) -> list[RawJobCard]:
"""Extract job cards using container and field selectors from the manifest."""
from gimme_job.runtime.extractor import extract_cards_from_page
return extract_cards_from_page(page, config.extract)
def paginate(self, page: "Page", page_index: int, config: SiteManifest) -> bool:
"""Click the next-page button, or return False if no more pages."""
pagination = config.pagination
if pagination.mode == "none":
return False
if page_index >= pagination.max_pages - 1:
return False
if pagination.mode == "next_button":
for selector in pagination.next_button_selectors:
try:
btn = page.query_selector(selector)
if btn and btn.is_visible() and btn.is_enabled():
btn.click()
page.wait_for_load_state("networkidle", timeout=10000)
return True
except Exception:
continue
return False
if pagination.mode == "url_increment":
# Handled by adapter subclass
return False
return False
def normalize(self, raw: RawJobCard) -> JobPostingCandidate:
"""Normalize a RawJobCard into a JobPostingCandidate."""
from gimme_job.utils.dates import normalize_posted_text
from gimme_job.utils.hashing import compute_fingerprint
from gimme_job.utils.text import normalize_whitespace
title = normalize_whitespace(raw.title)
company = normalize_whitespace(raw.company)
location = normalize_whitespace(raw.location)
url = raw.url
fingerprint = compute_fingerprint(
site_id=self.site_id,
title=title,
company=company,
location=location,
url=url,
external_job_id=raw.external_job_id,
)
return JobPostingCandidate(
site_id=self.site_id,
external_job_id=raw.external_job_id,
title=title,
company=company or None,
location=location or None,
posted_text=normalize_whitespace(raw.posted_text) or None,
posted_at_normalized=normalize_posted_text(raw.posted_text),
job_url=url,
salary_text=normalize_whitespace(raw.salary_text) or None,
employment_type=normalize_whitespace(raw.employment_type) or None,
raw_text=raw.raw_text,
fingerprint=fingerprint,
)