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