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.
159 lines
5.9 KiB
Python
159 lines
5.9 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,
|
|
)
|
|
|
|
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,
|
|
)
|