diff --git a/README.md b/README.md index 7a52bb5..f6a997f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ export SCRAPEBADGER_API_KEY="sb_live_xxxxxxxxxxxxx" | **eBay** | 12 endpoints across 18 markets — search, completed/sold search, item detail, item reviews, seller profile/items/feedback, category browse, categories, autocomplete, markets | [eBay Guide](docs/ebay.md) | | **YouTube** | 39 endpoints — search, autocomplete, video detail/related/comments/replies/transcript/captions/streams/live-chat/batch, channel detail + videos/shorts/streams/playlists/community/about/subscriber-count/in-channel-search/resolve, playlists/items/mixes, trending/hashtag/home, shorts, community post/comments, music search, oembed, categories/languages/regions | [YouTube Guide](docs/youtube.md) | | **TikTok** | 25 endpoints — user profile/videos/followers/following/liked/reposts, video detail/comments/replies/related/transcript/oEmbed, search (general/videos/hashtags/users), music detail/videos, hashtag detail/videos, trending videos/hashtags/songs, ad library, regions | [TikTok Guide](docs/tiktok.md) | +| **Immobiliare** | 8 endpoints across immobiliare.it, indomio.es, indomio.gr, immotop.lu — autocomplete, search, listing detail, agency profile/listings, price stats, markets, reference | [Immobiliare Guide](docs/immobiliare.md) | ## Error Handling diff --git a/pyproject.toml b/pyproject.toml index dd674e2..1fceb36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.15.7" +version = "0.16.0" description = "Official Python SDK for ScrapeBadger - Async web scraping APIs for Twitter and more" readme = "README.md" license = { text = "MIT" } diff --git a/src/scrapebadger/__init__.py b/src/scrapebadger/__init__.py index a89c047..3182390 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -154,6 +154,64 @@ async def main(): SellerProfileResponse as EbaySellerProfileResponse, ) from scrapebadger.google.client import GoogleClient +from scrapebadger.immobiliare.client import ImmobiliareClient +from scrapebadger.immobiliare.models import ( + Agency as ImmobiliareAgency, +) +from scrapebadger.immobiliare.models import ( + AgencyAgent as ImmobiliareAgencyAgent, +) +from scrapebadger.immobiliare.models import ( + AgencyListingsResponse as ImmobiliareAgencyListingsResponse, +) +from scrapebadger.immobiliare.models import ( + AgencyProfile as ImmobiliareAgencyProfile, +) +from scrapebadger.immobiliare.models import ( + Agent as ImmobiliareAgent, +) +from scrapebadger.immobiliare.models import ( + Feature as ImmobiliareFeature, +) +from scrapebadger.immobiliare.models import ( + Listing as ImmobiliareListing, +) +from scrapebadger.immobiliare.models import ( + Location as ImmobiliareLocation, +) +from scrapebadger.immobiliare.models import ( + Market as ImmobiliareMarket, +) +from scrapebadger.immobiliare.models import ( + Photo as ImmobiliarePhoto, +) +from scrapebadger.immobiliare.models import ( + Price as ImmobiliarePrice, +) +from scrapebadger.immobiliare.models import ( + PriceStatsPoint as ImmobiliarePriceStatsPoint, +) +from scrapebadger.immobiliare.models import ( + PriceStatsResponse as ImmobiliarePriceStatsResponse, +) +from scrapebadger.immobiliare.models import ( + PropertyUnit as ImmobiliarePropertyUnit, +) +from scrapebadger.immobiliare.models import ( + ReferenceResponse as ImmobiliareReferenceResponse, +) +from scrapebadger.immobiliare.models import ( + RelatedSearch as ImmobiliareRelatedSearch, +) +from scrapebadger.immobiliare.models import ( + SearchResponse as ImmobiliareSearchResponse, +) +from scrapebadger.immobiliare.models import ( + Suggestion as ImmobiliareSuggestion, +) +from scrapebadger.immobiliare.models import ( + SuggestResponse as ImmobiliareSuggestResponse, +) from scrapebadger.leboncoin.client import LeboncoinClient from scrapebadger.leboncoin.models import ( Ad as LeboncoinAd, @@ -639,7 +697,7 @@ async def main(): ZestimateHistoryPoint as ZillowZestimateHistoryPoint, ) -__version__ = "0.15.7" +__version__ = "0.16.0" __all__ = [ # TikTok core models @@ -698,6 +756,27 @@ async def main(): "FeedbackBreakdown", # Google Scraper "GoogleClient", + # Immobiliare + "ImmobiliareAgency", + "ImmobiliareAgencyAgent", + "ImmobiliareAgencyListingsResponse", + "ImmobiliareAgencyProfile", + "ImmobiliareAgent", + "ImmobiliareClient", + "ImmobiliareFeature", + "ImmobiliareListing", + "ImmobiliareLocation", + "ImmobiliareMarket", + "ImmobiliarePhoto", + "ImmobiliarePrice", + "ImmobiliarePriceStatsPoint", + "ImmobiliarePriceStatsResponse", + "ImmobiliarePropertyUnit", + "ImmobiliareReferenceResponse", + "ImmobiliareRelatedSearch", + "ImmobiliareSearchResponse", + "ImmobiliareSuggestResponse", + "ImmobiliareSuggestion", "InsufficientCreditsError", "ItemDetailResponse", # Leboncoin diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index 72af85d..1d15a2b 100644 --- a/src/scrapebadger/_internal/client.py +++ b/src/scrapebadger/_internal/client.py @@ -29,7 +29,7 @@ T = TypeVar("T") # User agent for SDK requests -SDK_VERSION = "0.15.6" +SDK_VERSION = "0.16.0" USER_AGENT = f"scrapebadger-python/{SDK_VERSION}" @@ -254,7 +254,8 @@ async def _request_with_retry( self._handle_error_response(response, data) # Check for application-level errors in response - if data.get("error"): + # (bare JSON array bodies, e.g. /markets, have no .get) + if isinstance(data, dict) and data.get("error"): raise ScrapeBadgerError( data["error"], response.status_code, diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index 6de2e9a..c5a9cad 100644 --- a/src/scrapebadger/client.py +++ b/src/scrapebadger/client.py @@ -12,6 +12,7 @@ from scrapebadger.amazon.client import AmazonClient from scrapebadger.ebay.client import EbayClient from scrapebadger.google.client import GoogleClient +from scrapebadger.immobiliare.client import ImmobiliareClient from scrapebadger.leboncoin.client import LeboncoinClient from scrapebadger.realtor.client import RealtorClient from scrapebadger.reddit.client import RedditClient @@ -130,6 +131,7 @@ def __init__( self._realtor: RealtorClient | None = None self._zillow: ZillowClient | None = None self._leboncoin: LeboncoinClient | None = None + self._immobiliare: ImmobiliareClient | None = None @property def config(self) -> ClientConfig: @@ -381,6 +383,28 @@ def leboncoin(self) -> LeboncoinClient: self._leboncoin = LeboncoinClient(self._base_client) return self._leboncoin + @property + def immobiliare(self) -> ImmobiliareClient: + """Access Immobiliare Scraper API operations. + + Returns: + ImmobiliareClient providing access to all Immobiliare-group + endpoints (autocomplete, search, listing detail, agency + profile/listings, price stats, markets, reference) across + immobiliare.it, indomio.es, indomio.gr, and immotop.lu. + + Example: + ```python + hits = await client.immobiliare.autocomplete("Milano") + results = await client.immobiliare.search(location="Milano") + detail = await client.immobiliare.get_listing(123456789) + markets = await client.immobiliare.list_markets() + ``` + """ + if self._immobiliare is None: + self._immobiliare = ImmobiliareClient(self._base_client) + return self._immobiliare + @property def tiktok(self) -> TikTokClient: """Access TikTok Scraper API operations. diff --git a/src/scrapebadger/immobiliare/__init__.py b/src/scrapebadger/immobiliare/__init__.py new file mode 100644 index 0000000..a52d5f4 --- /dev/null +++ b/src/scrapebadger/immobiliare/__init__.py @@ -0,0 +1,74 @@ +"""Immobiliare API module for ScrapeBadger SDK. + +This module provides an async client for scraping Immobiliare-group +real-estate data (immobiliare.it, indomio.es, indomio.gr, immotop.lu) through +the ScrapeBadger API. Endpoints: autocomplete, search, listing detail, agency +profile + listings, price stats (€/m² time series), markets, and reference. +All methods are async and return strongly-typed Pydantic models. + +Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Resolve a place, then search it + hits = await client.immobiliare.autocomplete("Milano") + city = hits.suggestions[0] + results = await client.immobiliare.search(city_id=city.city_id, price_max=500000) + for listing in results.listings: + print(f"{listing.id}: {listing.title}") + + # Full listing detail + detail = await client.immobiliare.get_listing(123456789) + print(detail.description) + ``` +""" + +from scrapebadger.immobiliare.client import ImmobiliareClient +from scrapebadger.immobiliare.models import ( + Agency, + AgencyAgent, + AgencyListingsResponse, + AgencyProfile, + Agent, + Feature, + Listing, + Location, + Market, + Photo, + Price, + PriceStatsPoint, + PriceStatsResponse, + PropertyUnit, + ReferenceResponse, + RelatedSearch, + SearchResponse, + Suggestion, + SuggestResponse, +) + +__all__ = [ + # Shared / nested models + "Agency", + "AgencyAgent", + # Response envelopes + "AgencyListingsResponse", + "AgencyProfile", + "Agent", + "Feature", + # Client + "ImmobiliareClient", + "Listing", + "Location", + "Market", + "Photo", + "Price", + "PriceStatsPoint", + "PriceStatsResponse", + "PropertyUnit", + "ReferenceResponse", + "RelatedSearch", + "SearchResponse", + "SuggestResponse", + "Suggestion", +] diff --git a/src/scrapebadger/immobiliare/client.py b/src/scrapebadger/immobiliare/client.py new file mode 100644 index 0000000..94c2d0e --- /dev/null +++ b/src/scrapebadger/immobiliare/client.py @@ -0,0 +1,309 @@ +"""Immobiliare API client. + +Immobiliare endpoints: autocomplete (resolve a free-text place to geography +ids), search (list listings by location or explicit ids), get_listing (full +single-listing detail), get_agency / get_agency_listings (agency profile + +active listings), price_stats (€/m² time series per area), list_markets, and +reference (filter enums). All methods are async and return strongly-typed +Pydantic models. Markets: it, es, gr, lu (the Immobiliare Group). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.immobiliare.models import ( + AgencyListingsResponse, + AgencyProfile, + Listing, + Market, + PriceStatsResponse, + ReferenceResponse, + SearchResponse, + SuggestResponse, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ImmobiliareClient: + """Client for all Immobiliare API operations. + + Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Resolve a place name to geography ids + hits = await client.immobiliare.autocomplete("Milano") + city = hits.suggestions[0] + + # Search listings + results = await client.immobiliare.search( + city_id=city.city_id, price_max=500000 + ) + for listing in results.listings: + print(f"{listing.id}: {listing.title}") + + # Full single-listing detail + detail = await client.immobiliare.get_listing(123456789) + print(detail.price.formatted if detail.price else None) + + # Supported markets + markets = await client.immobiliare.list_markets() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `immobiliare` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize Immobiliare client. + + Args: + client: The base HTTP client for making API requests. + """ + self._client = client + + async def autocomplete(self, query: str, *, market: str = "it") -> SuggestResponse: + """Resolve a free-text place name into geography ids for search. + + Args: + query: Free-text place name, e.g. "Milano". + market: "it", "es", "gr", or "lu". Defaults to "it". + + Returns: + Suggest response with region/province/city/zone id candidates. + + Example: + ```python + hits = await client.immobiliare.autocomplete("Milano") + for s in hits.suggestions: + print(s.label, s.type, s.city_id) + ``` + """ + params: dict[str, Any] = {"query": query, "market": market} + response = await self._client.get("/v1/immobiliare/autocomplete", params=params) + return SuggestResponse.model_validate(response) + + async def search( + self, + *, + market: str = "it", + location: str | None = None, + region_id: str | None = None, + province_id: str | None = None, + city_id: str | None = None, + contract: str = "sale", + category: str = "residential", + price_min: int | None = None, + price_max: int | None = None, + surface_min: int | None = None, + surface_max: int | None = None, + rooms_min: int | None = None, + rooms_max: int | None = None, + bathrooms_min: int | None = None, + sort: str = "relevance", + page: int = 1, + ) -> SearchResponse: + """Search Immobiliare-group listings. + + Scope the search by ``location`` (free text, auto-resolved) OR explicit + ``region_id`` / ``province_id`` / ``city_id`` from :meth:`autocomplete`. + + Args: + market: "it", "es", "gr", or "lu". Defaults to "it". + location: Free-text place, auto-resolved to geography ids. + region_id: Region id (fkRegione) from :meth:`autocomplete`. + province_id: Province id (idProvincia) from :meth:`autocomplete`. + city_id: City id (idComune) from :meth:`autocomplete`. + contract: "sale" or "rent". Defaults to "sale". + category: One of residential, commercial, garages, offices, land, + buildings, warehouses (see :meth:`reference`). Defaults to + "residential". + price_min: Minimum price filter (EUR). + price_max: Maximum price filter (EUR). + surface_min: Minimum surface filter (m²). + surface_max: Maximum surface filter (m²). + rooms_min: Minimum number of rooms. + rooms_max: Maximum number of rooms. + bathrooms_min: Minimum number of bathrooms. + sort: One of relevance, price_asc, price_desc, newest, oldest, + surface_desc, surface_asc. Defaults to "relevance". + page: Page number (>= 1). Defaults to 1. + + Returns: + Search response with ``listings`` and pagination metadata. + + Example: + ```python + results = await client.immobiliare.search( + location="Milano", + contract="rent", + price_max=2000, + sort="price_asc", + ) + print(f"Page {results.current_page}/{results.max_pages}") + ``` + """ + params: dict[str, Any] = { + "market": market, + "location": location, + "region_id": region_id, + "province_id": province_id, + "city_id": city_id, + "contract": contract, + "category": category, + "price_min": price_min, + "price_max": price_max, + "surface_min": surface_min, + "surface_max": surface_max, + "rooms_min": rooms_min, + "rooms_max": rooms_max, + "bathrooms_min": bathrooms_min, + "sort": sort, + "page": page, + } + response = await self._client.get("/v1/immobiliare/search", params=params) + return SearchResponse.model_validate(response) + + async def get_listing(self, listing_id: int, *, market: str = "it") -> Listing: + """Get the full detail for a single Immobiliare listing. + + Args: + listing_id: The Immobiliare listing id. + market: "it", "es", "gr", or "lu". Defaults to "it". + + Returns: + The full :class:`Listing` detail. + + Example: + ```python + detail = await client.immobiliare.get_listing(123456789) + print(detail.title, detail.energy_class) + ``` + """ + params: dict[str, Any] = {"market": market} + response = await self._client.get(f"/v1/immobiliare/listings/{listing_id}", params=params) + return Listing.model_validate(response) + + async def get_agency(self, agency_id: int, *, market: str = "it") -> AgencyProfile: + """Get an agency/advertiser profile. + + Args: + agency_id: The Immobiliare agency id. + market: "it", "es", "gr", or "lu". Defaults to "it". + + Returns: + The full :class:`AgencyProfile`. + + Example: + ```python + agency = await client.immobiliare.get_agency(12345) + print(agency.name, agency.real_estate_ads) + ``` + """ + params: dict[str, Any] = {"market": market} + response = await self._client.get(f"/v1/immobiliare/agencies/{agency_id}", params=params) + return AgencyProfile.model_validate(response) + + async def get_agency_listings( + self, + agency_id: int, + *, + market: str = "it", + page: int = 1, + ) -> AgencyListingsResponse: + """Get an agency's active listings (25 per page). + + Args: + agency_id: The Immobiliare agency id. + market: "it", "es", "gr", or "lu". Defaults to "it". + page: Page number (>= 1). Defaults to 1. + + Returns: + Agency-listings response with ``listings`` and pagination. + + Example: + ```python + result = await client.immobiliare.get_agency_listings(12345, page=2) + print(result.count, len(result.listings)) + ``` + """ + params: dict[str, Any] = {"market": market, "page": page} + response = await self._client.get( + f"/v1/immobiliare/agencies/{agency_id}/listings", params=params + ) + return AgencyListingsResponse.model_validate(response) + + async def price_stats( + self, + region_id: str, + *, + province_id: str | None = None, + city_id: str | None = None, + market: str = "it", + contract: str = "sale", + ) -> PriceStatsResponse: + """Get the historical average €/m² time series for an area. + + Args: + region_id: Region id, e.g. "lom" (required). + province_id: Province id, e.g. "MI". Optional. + city_id: City id (idComune). Optional. + market: "it", "es", "gr", or "lu". Defaults to "it". + contract: "sale" or "rent". Defaults to "sale". + + Returns: + Price-stats response with monthly ``points`` (label + EUR/m² value). + + Example: + ```python + stats = await client.immobiliare.price_stats("lom", province_id="MI") + for point in stats.points: + print(point.label, point.value) + ``` + """ + params: dict[str, Any] = { + "market": market, + "region_id": region_id, + "province_id": province_id, + "city_id": city_id, + "contract": contract, + } + response = await self._client.get("/v1/immobiliare/market-insights/prices", params=params) + return PriceStatsResponse.model_validate(response) + + async def list_markets(self) -> list[Market]: + """Get all supported Immobiliare-group markets (it, es, gr, lu). + + Returns: + List of supported markets (code, domain, country, locale, currency). + + Example: + ```python + markets = await client.immobiliare.list_markets() + for m in markets: + print(f"{m.code}: {m.domain} ({m.currency})") + ``` + """ + response = await self._client.get("/v1/immobiliare/markets") + # The endpoint returns a bare JSON array of markets. + return [Market.model_validate(m) for m in response] + + async def reference(self) -> ReferenceResponse: + """Get the filter enums accepted by :meth:`search`. + + Returns: + Reference response with ``contracts``, ``categories``, and ``sorts``. + + Example: + ```python + ref = await client.immobiliare.reference() + print(ref.categories) + ``` + """ + response = await self._client.get("/v1/immobiliare/reference") + return ReferenceResponse.model_validate(response) diff --git a/src/scrapebadger/immobiliare/models.py b/src/scrapebadger/immobiliare/models.py new file mode 100644 index 0000000..921aa7c --- /dev/null +++ b/src/scrapebadger/immobiliare/models.py @@ -0,0 +1,324 @@ +"""Pydantic models for Immobiliare API responses. + +These models mirror the backend ``immobiliare_scraper`` response schema, +which normalises Immobiliare's internal ``api-next`` shapes into a clean, +market-agnostic snake_case schema. All models are immutable (frozen) and +ignore unknown fields for forward compatibility. Markets: it (immobiliare.it), +es (indomio.es), gr (indomio.gr), lu (immotop.lu) — all EUR. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================= +# Base Configuration +# ============================================================================= + + +class _BaseModel(BaseModel): + """Base model with common configuration.""" + + model_config = ConfigDict( + frozen=True, + populate_by_name=True, + extra="ignore", + ) + + +# ============================================================================= +# Shared nested models +# ============================================================================= + + +class Photo(_BaseModel): + """A single listing photo in three sizes.""" + + id: int | None = None + caption: str | None = None + small: str | None = None + medium: str | None = None + large: str | None = None + + +class Price(_BaseModel): + """A listing price (``price_per_sqm`` / ``loan_from`` are detail-only).""" + + value: int | None = None + formatted: str | None = None + min_value: str | None = None + max_value: str | None = None + currency: str = "EUR" + visible: bool = True + price_per_sqm: str | None = None + loan_from: str | None = None + + +class Location(_BaseModel): + """The geographic location block of a listing.""" + + address: str | None = None + latitude: float | None = None + longitude: float | None = None + marker: str | None = None + region: str | None = None + province: str | None = None + city: str | None = None + macrozone: str | None = None + microzone: str | None = None + zipcode: str | None = None + nation_code: str | None = None + nation_name: str | None = None + + +class Feature(_BaseModel): + """A single listing feature/amenity.""" + + type: str | None = None + label: str | None = None + compact_label: str | None = None + + +class Agency(_BaseModel): + """The advertising agency summary attached to a listing.""" + + id: int | None = None + type: str | None = None + display_name: str | None = None + label: str | None = None + url: str | None = None + is_paid: bool | None = None + guaranteed: bool | None = None + show_logo: bool | None = None + image_small: str | None = None + image_large: str | None = None + phones: list[str] = Field(default_factory=list) + + +class Agent(_BaseModel): + """The individual agent behind a listing.""" + + type: str | None = None + display_name: str | None = None + label: str | None = None + image_gender: str | None = None + image_url: str | None = None + phones: list[str] = Field(default_factory=list) + + +class PropertyUnit(_BaseModel): + """One unit within a listing (a listing may bundle several — a 'project').""" + + is_main: bool = False + surface: str | None = None + surface_value: float | None = None + rooms: str | None = None + bathrooms: str | None = None + bedrooms: str | None = None + floor: str | None = None + elevator: bool | None = None + garage: str | None = None + heating: str | None = None + energy_class: str | None = None + condominium_fees: str | None = None + typology: str | None = None + category: str | None = None + caption: str | None = None + description: str | None = None + price: Price | None = None + features: list[Feature] = Field(default_factory=list) + ga4_features: list[str] = Field(default_factory=list) + views: list[str] = Field(default_factory=list) + photos: list[Photo] = Field(default_factory=list) + + +# ============================================================================= +# Listing (search card + /listings/{id} detail) +# ============================================================================= + + +class Listing(_BaseModel): + """A normalised Immobiliare listing (search card or detail).""" + + id: int + uuid: str | None = None + url: str | None = None + title: str | None = None + contract: str | None = None # "sale" | "rent" + is_new: bool | None = None + luxury: bool | None = None + is_project: bool | None = None + is_mosaic: bool | None = None + visibility: str | None = None + typology: str | None = None + category: str | None = None + price: Price | None = None + location: Location | None = None + # Flattened convenience fields (from the main property unit) + surface: str | None = None + rooms: str | None = None + bathrooms: str | None = None + floor: str | None = None + energy_class: str | None = None + description: str | None = None + photo_count: int | None = None + has_virtual_tour: bool | None = None + photos: list[Photo] = Field(default_factory=list) + agency: Agency | None = None + agent: Agent | None = None + properties_count: int | None = None + properties: list[PropertyUnit] = Field(default_factory=list) + # Detail-only fields (populated by GET /listings/{id}) + creation_date: str | None = None + last_modified_utc: float | None = None + last_modified_at: str | None = None + features_full: list[Feature] = Field(default_factory=list) + + +# ============================================================================= +# Agency profile (/agencies/{id}) +# ============================================================================= + + +class AgencyAgent(_BaseModel): + """A single agent on an agency profile.""" + + id: int | None = None + name: str | None = None + surname: str | None = None + gender: str | None = None + thumbnail: str | None = None + + +class AgencyProfile(_BaseModel): + """Full agency/advertiser profile (rendered from the agency page).""" + + id: int + type: str | None = None + name: str | None = None + url: str | None = None + address: str | None = None + description: str | None = None + website: str | None = None + image: str | None = None + is_paid: bool | None = None + partnership: str | None = None + real_estate_ads: int | None = None + real_estate_sales: int | None = None + region: str | None = None + province: str | None = None + city: str | None = None + macrozone: str | None = None + latitude: float | None = None + longitude: float | None = None + phones: list[str] = Field(default_factory=list) + opening_hours: list[str] = Field(default_factory=list) + agents: list[AgencyAgent] = Field(default_factory=list) + market: str = "it" + + +# ============================================================================= +# Autocomplete (/autocomplete) +# ============================================================================= + + +class Suggestion(_BaseModel): + """A geography autocomplete candidate → usable as a search location.""" + + id: str | None = None + label: str | None = None + type: str | None = None # nation | region | province | comune | zone + region_id: str | None = None + province_id: str | None = None + city_id: str | None = None + macrozone_ids: list[str] = Field(default_factory=list) + url: str | None = None + + +# ============================================================================= +# Market insights (/market-insights/prices) +# ============================================================================= + + +class PriceStatsPoint(_BaseModel): + """One point in the €/m² time series.""" + + label: str + value: float | None = None + + +# ============================================================================= +# Markets (/markets) +# ============================================================================= + + +class Market(_BaseModel): + """A single supported Immobiliare-group market.""" + + code: str + domain: str + country_code: str + locale: str + currency: str + name: str + + +# ============================================================================= +# Response envelopes +# ============================================================================= + + +class RelatedSearch(_BaseModel): + """A related-search suggestion returned alongside search results.""" + + label: str | None = None + url: str | None = None + + +class SuggestResponse(_BaseModel): + """Response for /autocomplete.""" + + suggestions: list[Suggestion] = Field(default_factory=list) + market: str = "it" + + +class SearchResponse(_BaseModel): + """Response for /search.""" + + listings: list[Listing] = Field(default_factory=list) + count: int | None = None + total_ads: int | None = None + current_page: int | None = None + max_pages: int | None = None + is_results_limit_reached: bool | None = None + agencies: list[Agency] = Field(default_factory=list) + related_searches: list[RelatedSearch] = Field(default_factory=list) + market: str = "it" + source: str = "api" + + +class AgencyListingsResponse(_BaseModel): + """Response for /agencies/{id}/listings.""" + + agency_id: int + listings: list[Listing] = Field(default_factory=list) + count: int | None = None + page: int = 1 + market: str = "it" + + +class PriceStatsResponse(_BaseModel): + """Response for /market-insights/prices (€/m² time series).""" + + contract: str + unit: str = "EUR/m²" + points: list[PriceStatsPoint] = Field(default_factory=list) + market: str = "it" + + +class ReferenceResponse(_BaseModel): + """Response for /reference (filter enums accepted by /search).""" + + contracts: list[str] = Field(default_factory=list) + categories: list[str] = Field(default_factory=list) + sorts: list[str] = Field(default_factory=list) diff --git a/uv.lock b/uv.lock index 017c676..8e64910 100644 --- a/uv.lock +++ b/uv.lock @@ -752,7 +752,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.15.7" +version = "0.16.0" source = { editable = "." } dependencies = [ { name = "httpx" },