"""Indeed job site adapter.""" from __future__ import annotations import time from typing import TYPE_CHECKING 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, SearchQuery if TYPE_CHECKING: from playwright.sync_api import Page from gimme_job.models.manifest import SiteManifest @register("indeed") class IndeedAdapter(ManifestDrivenAdapter): """Indeed-specific adapter with custom card extraction and URL normalization.""" def prepare(self, page: "Page", config: "SiteManifest") -> None: """Navigate to Indeed search URL with fromage=1 for last 24h.""" url = config.build_start_url( type("Q", (), { "keywords_urlencoded": page.__class__.__name__, # placeholder })() ) # Actually build URL using the query stored in the manifest pass # URL is built in orchestrator before adapter.prepare() is called def apply_search(self, page: "Page", query: SearchQuery) -> None: """Indeed uses URL params — no form input needed.""" pass def apply_filters(self, page: "Page", query: SearchQuery) -> None: """Indeed date filter is already in the URL (fromage=1). Nothing to click.""" pass def collect_cards(self, page: "Page", config: "SiteManifest") -> list[RawJobCard]: """Extract Indeed job cards.""" cards = [] # Try container selectors container_selectors = config.extract.container_selectors or [ "div.job_seen_beacon", "div[data-jk]", "td.resultContent", ] container_elements = [] for selector in container_selectors: try: elements = page.query_selector_all(selector) if elements: container_elements = elements logger.debug(f"[indeed] Container: '{selector}' ({len(elements)} items)") break except Exception: continue if not container_elements: logger.warning("[indeed] No job cards found") return cards for el in container_elements: try: card = self._extract_card(el) if card and card.title: cards.append(card) except Exception as e: logger.debug(f"[indeed] Card extraction error: {e}") logger.info(f"[indeed] Extracted {len(cards)} cards") return cards def _extract_card(self, el) -> RawJobCard | None: def get_text(*selectors: str) -> str | None: for sel in selectors: try: child = el.query_selector(sel) if child: text = child.inner_text() if text and text.strip(): return text.strip() except Exception: continue return None def get_attr(selector: str, attr: str) -> str | None: try: child = el.query_selector(selector) if child: val = child.get_attribute(attr) return val.strip() if val else None except Exception: return None title = get_text( "h2.jobTitle a span[id^='jobTitle']", "h2.jobTitle span", "h2 a span", "h2[class*='jobTitle'] a", "h2", ) if not title: return None company = get_text( "span[data-testid='company-name']", ".company", "span.companyName", ) location = get_text( "div[data-testid='text-location']", ".companyLocation", ) posted_text = get_text( "span[data-testid='myJobsStateDate']", "span.date", ) salary = get_text( "div.salary-snippet-container", "div[data-testid='attribute_snippet_testid']", ) # URL: prefer the job card link url = get_attr("h2.jobTitle a", "href") or get_attr("a[data-jk]", "href") if url and url.startswith("/"): url = "https://www.indeed.com" + url if url: url = self._clean_indeed_url(url) raw_text = None try: raw_text = el.inner_text() except Exception: pass return RawJobCard( title=title, company=company, location=location, posted_text=posted_text, url=url, salary_text=salary, raw_text=raw_text, ) def _clean_indeed_url(self, url: str) -> str: """Strip Indeed tracking parameters, keep the /viewjob?jk= part.""" from urllib.parse import parse_qs, urlencode, urlparse, urlunparse try: parsed = urlparse(url) if parsed.path == "/rc/clk" or "/clk" in parsed.path: # Extract jk parameter and build clean URL params = parse_qs(parsed.query) jk = params.get("jk", [None])[0] if jk: return f"https://www.indeed.com/viewjob?jk={jk}" return url except Exception: return url def normalize(self, raw: RawJobCard) -> JobPostingCandidate: return super().normalize(raw)