# Task: Multi-marketplace abstraction layer ## Description Currently, `scraper.py` is tightly coupled to willhaben's API format and URL. Adding a second marketplace (e.g., Kleinanzeigen, Facebook Marketplace) would require extensive refactoring of the core logic — duplicating pagination, error handling, and notification code with subtle differences per source. This task introduces an **abstract base class** for scrapers and a **standardized ad schema**, making it trivial to add new marketplaces by implementing only marketplace-specific parsing logic. ## Architecture ``` ┌───────────────────────────────────────┐ │ scraper.py (module) │ │ │ │ ┌───────────────────────────────────┐│ │ │ ScraperBase (ABC) ││ │ │ ││ │ │ Properties: ││ │ │ name str ││ │ │ base_url str ││ │ │ max_pages int ││ │ │ ││ │ │ Abstract methods: ││ │ │ build_query(url, params) → URL ││ │ │ parse_page(html/json) → list ││ │ │ normalize_ad(raw) → dict ││ │ │ ││ │ │ Concrete methods (shared): ││ │ │ fetch_ads(keyword, cursor) ││ │ │ _fetch_with_retry(url) ││ │ └───────────────────────────────────┘│ └──────┬────────────────────────────────┘ │ inherits ▼ ┌───────────────────────────────────────┐ │ scrapers/willhaben.py │ │ │ │ class WillhabenScraper(ScraperBase): │ │ name = "willhaben" ││ │ base_url = ".../api/v1/ad-search" ││ │ build_query() → willhaben URL ││ │ parse_page(json) → list ││ │ normalize_ad(raw) → standard dict ││ └───────────────────────────────────────┘ Standard ad schema (dict): { "id": str, # marketplace-specific ID "marketplace": str, # e.g. "willhaben" "title": str, # ad title "price": int | None, # price in cents "currency": str, # e.g. "EUR" "url": str, # full URL to the ad page "published_at": datetime | None, "location": { │ "city": str, │ "postcode": str | None, │ "region": str | None │ }, │ "attributes": dict # marketplace-specific extras } Scheduler (in main.py): scrapers: list[ScraperBase] = [ WillhabenScraper(), KleinanzeigenScraper(), # future ] for scraper in scrapers: ads_raw, total_hits = await scraper.fetch_ads(keyword) # ... process with same pipeline (filters, notifications) ``` ### Key design decisions - **Abstract base class** defines the contract. Concrete scrapers only implement what's different per marketplace — URL building, response parsing, and field normalization. - *Alternative*: Could use a plugin architecture with entry_points, but that adds significant complexity for what is currently expected to be ≤3 marketplaces. - **Standardized output schema** ensures the downstream pipeline (filters, notifications) works identically regardless of source. Marketplace-specific fields are stored in `attributes`. - **Pagination logic lives in the base class**. Most marketplaces use offset/limit pagination; the abstract method handles this generically. Special cases override `_fetch_page()`. ## Implementation Details ### 1. Create `worker/src/scrapers/__init__.py` ```python from .willhaben import WillhabenScraper __all__ = ["WillhabenScraper"] def get_scriper_by_name(name: str) -> "ScraperBase": """Factory function to instantiate scrapers by name.""" registry = { "willhaben": WillhabenScraper, } cls = registry.get(name.lower()) if not cls: raise ValueError(f"Unknown marketplace: {name}") return cls() ``` ### 2. Create `worker/src/scrapers/base.py` (abstract base class) ```python import abc import asyncio import logging from datetime import datetime, timezone from typing import Any logger = logging.getLogger(__name__) class ScraperBase(abc.ABC): """Abstract base class for marketplace scrapers.""" name: str = "unknown" base_url: str = "" max_pages: int = 2 @abc.abstractmethod def build_query(self, keyword: str, offset: int) -> str: """Build the full API URL/endpoint for a keyword + offset.""" ... @abc.abstractmethod def parse_page(self, response_content: Any) -> list[dict]: """Parse raw response into list of ad dicts (marketplace-specific).""" ... @abc.abstractmethod def normalize_ad(self, raw_ad: dict) -> dict: """Convert marketplace-specific format to standard schema.""" ... async def fetch_ads( self, keyword: str, cursor_at: datetime | None = None, max_pages: int | None = None, ) -> tuple[list[dict], int]: """Fetch ads with pagination. Shared implementation.""" pages = max_pages or self.max_pages all_ads: list[dict] = [] total_hits = 0 from ..scraper import get_client # httpx singleton client = await get_client() for page in range(pages): url = self.build_query(keyword, offset=page * 30) try: content = await self._fetch_with_retry(client, url) except Exception as exc: logger.warning( "%s: fetch failed at page %d for '%s': %s", self.name, page, keyword, exc ) break raw_ads = self.parse_page(content) if not raw_ads: logger.info("%s: no more ads on page %d for '%s'", self.name, page, keyword) break # Normalize and filter by cursor normalized = [] for raw in raw_ads: ad = self.normalize_ad(raw) if cursor_at and ad["published_at"] and ad["published_at"] <= cursor_at: continue normalized.append(ad) all_ads.extend(normalized) # Politeness delay if page < pages - 1 and normalized: await asyncio.sleep(1.0) return all_ads, total_hits async def _fetch_with_retry( self, client: Any, url: str, max_retries: int = 3, ) -> Any: """Generic retry wrapper for HTTP fetches.""" import httpx for attempt in range(max_retries): try: resp = await client.get(url) resp.raise_for_status() return resp.json() if "application/json" in (resp.headers.get("content-type") or "") else resp.text except httpx.ConnectError as exc: logger.warning("%s: transport error attempt %d: %s", self.name, attempt + 1, exc) if attempt < max_retries - 1: await asyncio.sleep(2 ** attempt) else: raise @property def headers(self) -> dict[str, str]: """HTTP headers for requests. Override per marketplace.""" return {} ``` ### 3. Create `worker/src/scrapers/willhaben.py` (refactor existing logic) Move the current willhaben-specific code from `scraper.py` into this implementation: ```python import logging from datetime import datetime, timezone from typing import Any from .base import ScraperBase logger = logging.getLogger(__name__) class WillhabenScraper(ScraperBase): name = "willhaben" base_url = "https://api.willhaben.at/external/api/v1/ad-search" @property def headers(self) -> dict[str, str]: return { "Accept": "application/json", "User-Agent": "Mozilla/5.0 (compatible; WillhabenTracker/1.0)", } def build_query(self, keyword: str, offset: int = 0) -> str: """Build willhaben API URL with keyword + pagination.""" import urllib.parse params = { "keyword": keyword, "rows": 30, "sort": 1, # newest first "offset": offset, } return f"{self.base_url}?{urllib.parse.urlencode(params)}" def parse_page(self, response_content: dict) -> list[dict]: """Parse willhaben JSON response into raw ad dicts.""" ads_list = (response_content.get("advertSummaryList") or {}).get( "advertSummary", [] ) total_hits = int(response_content.get("rowsFound", 0)) return ads_list def normalize_ad(self, raw_ad: dict) -> dict: """Convert willhaben ad format to standard schema.""" # Extract attributes from the nested format attrs = self._parse_attributes(raw_ad) # Extract ID ad_id_raw = raw_ad.get("id", "") # Extract title (handle various formats) title_raw = raw_ad.get("title") or raw_ad.get("Title", {}) title = title_raw.get("Value", title_raw) if isinstance(title_raw, dict) else str(title_raw or "") # Extract price price_str = attrs.get("PRICE_String") or attrs.get("priceString", "") try: price_cents = int(float(price_str.replace(".", ""))) if price_str else None except (ValueError, TypeError): price_cents = None # Extract published date pub_str = attrs.get("PUBLISHED_String") or attrs.get("publishedString", "") published_at = None try: published_at = datetime.fromisoformat(pub_str.replace("Z", "+00:00")) except (ValueError, TypeError): pass # Extract location city = attrs.get("LOCATION_CityName") or "" postcode = attrs.get("LOCATION_ZIP") or "" region = attrs.get("LOCATION_Region") or "" return { "id": ad_id_raw, "marketplace": self.name, "title": title.strip(), "price": price_cents, "currency": "EUR", "url": raw_ad.get("linkUrl", ""), "published_at": published_at, "location": { "city": city, "postcode": postcode if postcode else None, "region": region if region else None, }, "attributes": attrs, # preserve marketplace-specific fields } @staticmethod def _parse_attributes(ad_dict: dict) -> dict: """Parse willhaben's nested attribute format into flat dict.""" result = {} for attr_group in ad_dict.get("attributes", []): if not isinstance(attr_group, dict): continue group_name = attr_group.get("name") or "" for item in attr_group.get("items", []): key = f"{group_name}_{item['name']}" if group_name else item["name"] result[key] = item.get("valueString", "") return result ``` ### 4. Update `main.py` scheduler to use the new scraper factory Replace direct calls to `fetch_ads(keyword)` with: ```python from scrapers import get_scriper_by_name # At startup: scrapers_config = os.getenv("SCRAPERS", "willhaben").split(",") active_scrapers = [get_scriper_by_name(s) for s in scrapers_config] # In scheduler loop: for scraper in active_scrapers: ads_raw, total_hits = await scraper.fetch_ads(keyword, cursor_at=cursor) # ... process with existing pipeline (price filter, postcode filter, etc.) ``` ### 5. Add `.env.example` configuration for scrapers ```bash # Marketplace sources to scrape (comma-separated) SCRAPERS=willhaben ``` ## Acceptance Criteria - [ ] `WillhabenScraper` produces identical output to the current `scraper.py` implementation (no regression in ad extraction) - [ ] Adding a new marketplace requires only: creating one file under `scrapers/`, registering it in `__init__.py`, and listing it in SCRAPERS env var - [ ] The standardized ad schema includes all fields needed by the downstream pipeline (price, postcode, published_at, URL) - [ ] Pagination logic works correctly through the base class for willhaben - [ ] Error handling (retries, timeouts) continues to work with the new abstraction - [ ] All existing bot commands and notifications function identically after refactoring