From 56acafc6e6f5b1806a62b9b372fc36806643e9af Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:33:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(realtor):=20add=20Realtor=20sub-client=20?= =?UTF-8?q?=E2=80=94=20US=20+=20CA=20real=20estate=20(0.15.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.realtor with search/autocomplete/property-detail/markets across realtor.com (US) and realtor.ca (CA) behind a single market param. Fully typed Pydantic models. Bumps 0.15.4 -> 0.15.5. (SCR-98) --- CHANGELOG.md | 6 + pyproject.toml | 2 +- src/scrapebadger/__init__.py | 86 ++++++- src/scrapebadger/_internal/client.py | 2 +- src/scrapebadger/client.py | 23 ++ src/scrapebadger/realtor/__init__.py | 70 ++++++ src/scrapebadger/realtor/client.py | 111 +++++++++ src/scrapebadger/realtor/models.py | 318 +++++++++++++++++++++++++ src/scrapebadger/realtor/properties.py | 65 +++++ src/scrapebadger/realtor/reference.py | 50 ++++ src/scrapebadger/realtor/search.py | 155 ++++++++++++ tests/test_realtor.py | 102 ++++++++ uv.lock | 2 +- 13 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 src/scrapebadger/realtor/__init__.py create mode 100644 src/scrapebadger/realtor/client.py create mode 100644 src/scrapebadger/realtor/models.py create mode 100644 src/scrapebadger/realtor/properties.py create mode 100644 src/scrapebadger/realtor/reference.py create mode 100644 src/scrapebadger/realtor/search.py create mode 100644 tests/test_realtor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c6e48..b24abb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.5] - 2026-07-02 + +### Added + +- **Realtor sub-client** (`client.realtor`) — real-estate listings across realtor.com (US) and realtor.ca (Canada) behind a single `market` parameter. Four endpoints: `search.search()`, `search.autocomplete()`, `properties.get_property()`, `reference.list_markets()`, with fully-typed Pydantic models (`RealtorSearchResponse`, `RealtorPropertyDetail`, etc.). (SCR-98) + ## [0.15.4] - 2026-06-30 ### Added diff --git a/pyproject.toml b/pyproject.toml index d655975..25a17ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.15.4" +version = "0.15.5" 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 1403620..5f06747 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -154,6 +154,67 @@ async def main(): SellerProfileResponse as EbaySellerProfileResponse, ) from scrapebadger.google.client import GoogleClient +from scrapebadger.realtor.client import RealtorClient +from scrapebadger.realtor.models import ( + Address as RealtorAddress, +) +from scrapebadger.realtor.models import ( + Agent as RealtorAgent, +) +from scrapebadger.realtor.models import ( + AutocompleteResponse as RealtorAutocompleteResponse, +) +from scrapebadger.realtor.models import ( + Coordinate as RealtorCoordinate, +) +from scrapebadger.realtor.models import ( + DetailGroup as RealtorDetailGroup, +) +from scrapebadger.realtor.models import ( + Estimate as RealtorEstimate, +) +from scrapebadger.realtor.models import ( + Flags as RealtorFlags, +) +from scrapebadger.realtor.models import ( + MarketInfo as RealtorMarketInfo, +) +from scrapebadger.realtor.models import ( + MarketsResponse as RealtorMarketsResponse, +) +from scrapebadger.realtor.models import ( + Office as RealtorOffice, +) +from scrapebadger.realtor.models import ( + OpenHouse as RealtorOpenHouse, +) +from scrapebadger.realtor.models import ( + Phone as RealtorPhone, +) +from scrapebadger.realtor.models import ( + Photo as RealtorPhoto, +) +from scrapebadger.realtor.models import ( + PriceEvent as RealtorPriceEvent, +) +from scrapebadger.realtor.models import ( + Property as RealtorProperty, +) +from scrapebadger.realtor.models import ( + PropertyDetail as RealtorPropertyDetail, +) +from scrapebadger.realtor.models import ( + School as RealtorSchool, +) +from scrapebadger.realtor.models import ( + SearchResponse as RealtorSearchResponse, +) +from scrapebadger.realtor.models import ( + Suggestion as RealtorSuggestion, +) +from scrapebadger.realtor.models import ( + TaxRecord as RealtorTaxRecord, +) from scrapebadger.reddit.models import ( PostCommentsResponse, PostDetailResponse, @@ -420,7 +481,7 @@ async def main(): Video as YoutubeVideo, ) -__version__ = "0.15.4" +__version__ = "0.15.5" __all__ = [ # TikTok core models @@ -496,6 +557,28 @@ async def main(): "Product", "ProductDetailResponse", "RateLimitError", + "RealtorAddress", + "RealtorAgent", + "RealtorAutocompleteResponse", + # TikTok client + "RealtorClient", + "RealtorCoordinate", + "RealtorDetailGroup", + "RealtorEstimate", + "RealtorFlags", + "RealtorMarketInfo", + "RealtorMarketsResponse", + "RealtorOffice", + "RealtorOpenHouse", + "RealtorPhone", + "RealtorPhoto", + "RealtorPriceEvent", + "RealtorProperty", + "RealtorPropertyDetail", + "RealtorSchool", + "RealtorSearchResponse", + "RealtorSuggestion", + "RealtorTaxRecord", # Reddit core models "RedditAward", "RedditComment", @@ -536,7 +619,6 @@ async def main(): "TikTokAd", "TikTokAdVideo", "TikTokAuthor", - # TikTok client "TikTokClient", "TikTokComment", # TikTok response envelopes diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index bc82194..3a9b469 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.1" +SDK_VERSION = "0.15.5" USER_AGENT = f"scrapebadger-python/{SDK_VERSION}" diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index cb50f8a..37320f5 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.realtor.client import RealtorClient from scrapebadger.reddit.client import RedditClient from scrapebadger.tiktok.client import TikTokClient from scrapebadger.twitter.client import TwitterClient @@ -124,6 +125,7 @@ def __init__( self._ebay: EbayClient | None = None self._youtube: YoutubeClient | None = None self._tiktok: TikTokClient | None = None + self._realtor: RealtorClient | None = None @property def config(self) -> ClientConfig: @@ -304,6 +306,27 @@ def youtube(self) -> YoutubeClient: self._youtube = YoutubeClient(self._base_client) return self._youtube + @property + def realtor(self) -> RealtorClient: + """Access Realtor Scraper API operations. + + Returns: + RealtorClient providing access to all 4 Realtor endpoints + (search, property detail, autocomplete, markets) across + realtor.com (US) and realtor.ca (Canada). + + Example: + ```python + results = await client.realtor.search.search("Austin, TX") + detail = await client.realtor.properties.get_property("1234567890") + hits = await client.realtor.search.autocomplete("toronto", market="ca") + markets = await client.realtor.reference.list_markets() + ``` + """ + if self._realtor is None: + self._realtor = RealtorClient(self._base_client) + return self._realtor + @property def tiktok(self) -> TikTokClient: """Access TikTok Scraper API operations. diff --git a/src/scrapebadger/realtor/__init__.py b/src/scrapebadger/realtor/__init__.py new file mode 100644 index 0000000..9dfebd6 --- /dev/null +++ b/src/scrapebadger/realtor/__init__.py @@ -0,0 +1,70 @@ +"""Realtor API module for ScrapeBadger SDK. + +This module provides a comprehensive async client for scraping real-estate +listings through the ScrapeBadger API, unifying realtor.com (US) and +realtor.ca (Canada) behind a single ``market`` parameter. 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: + # Search for listings + results = await client.realtor.search.search("Austin, TX") + for prop in results.results: + print(prop.property_id, prop.list_price) + + # Get property detail + detail = await client.realtor.properties.get_property("1234567890") + print(detail.beds, detail.baths, detail.sqft) + ``` +""" + +from scrapebadger.realtor.client import RealtorClient +from scrapebadger.realtor.models import ( + Address, + Agent, + AutocompleteResponse, + Coordinate, + DetailGroup, + Estimate, + Flags, + MarketInfo, + MarketsResponse, + Office, + OpenHouse, + Phone, + Photo, + PriceEvent, + Property, + PropertyDetail, + School, + SearchResponse, + Suggestion, + TaxRecord, +) + +__all__ = [ + "Address", + "Agent", + "AutocompleteResponse", + "Coordinate", + "DetailGroup", + "Estimate", + "Flags", + "MarketInfo", + "MarketsResponse", + "Office", + "OpenHouse", + "Phone", + "Photo", + "PriceEvent", + "Property", + "PropertyDetail", + "RealtorClient", + "School", + "SearchResponse", + "Suggestion", + "TaxRecord", +] diff --git a/src/scrapebadger/realtor/client.py b/src/scrapebadger/realtor/client.py new file mode 100644 index 0000000..53cb5be --- /dev/null +++ b/src/scrapebadger/realtor/client.py @@ -0,0 +1,111 @@ +"""Realtor API client combining all sub-clients. + +This module provides the main RealtorClient class that serves as the +entry point for all Realtor API operations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.realtor.properties import PropertiesClient +from scrapebadger.realtor.reference import ReferenceClient +from scrapebadger.realtor.search import SearchClient + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class RealtorClient: + """Client for all Realtor API operations. + + This class provides access to all Realtor scraping endpoints through + organized sub-clients for different resource types. It unifies realtor.com + (US) and realtor.ca (Canada) behind a single ``market`` parameter. + + Attributes: + search: Client for property search and location autocomplete. + properties: Client for single-property detail. + reference: Client for reference data (markets). + + Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search for listings + results = await client.realtor.search.search("Austin, TX") + for prop in results.results: + print(prop.property_id, prop.list_price) + + # Get property detail + detail = await client.realtor.properties.get_property("1234567890") + print(detail.beds, detail.baths, detail.sqft) + + # Location autocomplete + hits = await client.realtor.search.autocomplete("toronto", market="ca") + + # Get supported markets + markets = await client.realtor.reference.list_markets() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `realtor` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize Realtor client with all sub-clients. + + Args: + client: The base HTTP client for making API requests. + """ + self._client = client + + # Initialize sub-clients + self._search = SearchClient(client) + self._properties = PropertiesClient(client) + self._reference = ReferenceClient(client) + + @property + def search(self) -> SearchClient: + """Access property search and autocomplete endpoints. + + Returns: + SearchClient for property search and location autocomplete. + + Example: + ```python + results = await client.realtor.search.search("Miami, FL") + hits = await client.realtor.search.autocomplete("miami") + ``` + """ + return self._search + + @property + def properties(self) -> PropertiesClient: + """Access the single-property detail endpoint. + + Returns: + PropertiesClient for fetching full property detail. + + Example: + ```python + detail = await client.realtor.properties.get_property("1234567890") + ``` + """ + return self._properties + + @property + def reference(self) -> ReferenceClient: + """Access reference data endpoints. + + Returns: + ReferenceClient for fetching supported markets. + + Example: + ```python + markets = await client.realtor.reference.list_markets() + ``` + """ + return self._reference diff --git a/src/scrapebadger/realtor/models.py b/src/scrapebadger/realtor/models.py new file mode 100644 index 0000000..b63c9d5 --- /dev/null +++ b/src/scrapebadger/realtor/models.py @@ -0,0 +1,318 @@ +"""Pydantic models for Realtor API responses. + +These models mirror the backend ``realtor_scraper`` response schema +field-for-field. All models are immutable (frozen) and ignore unknown fields +for forward compatibility. Every datetime field ships in BOTH ``*_utc`` (Unix +float) and ``*_at`` (ISO-8601 Z string). +""" + +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 Coordinate(_BaseModel): + """A latitude/longitude pair.""" + + lat: float | None = None + lon: float | None = None + + +class Phone(_BaseModel): + """A single phone number for an agent or office.""" + + number: str | None = None + type: str | None = None + ext: str | None = None + primary: bool | None = None + + +class Address(_BaseModel): + """A property street address.""" + + line: str | None = None + city: str | None = None + state: str | None = None + state_code: str | None = None + postal_code: str | None = None + country: str | None = None + neighborhood: str | None = None + county: str | None = None + coordinate: Coordinate | None = None + + +class Photo(_BaseModel): + """A single listing photo with resolution variants.""" + + href: str | None = None + href_high: str | None = None + href_med: str | None = None + href_low: str | None = None + tags: list[str] = Field(default_factory=list) + description: str | None = None + + +class Office(_BaseModel): + """The brokerage office an agent belongs to.""" + + name: str | None = None + email: str | None = None + href: str | None = None + logo: str | None = None + phones: list[Phone] = Field(default_factory=list) + address: Address | None = None + + +class Agent(_BaseModel): + """A listing agent (with office/broker contacts).""" + + agent_id: str | None = None + name: str | None = None + first_name: str | None = None + last_name: str | None = None + title: str | None = None + type: str | None = None + email: str | None = None + phones: list[Phone] = Field(default_factory=list) + photo: str | None = None + href: str | None = None + office: Office | None = None + broker: str | None = None + nrds_id: str | None = None + state_license: str | None = None + + +class OpenHouse(_BaseModel): + """A single scheduled open house.""" + + start_utc: str | None = None + start_at: str | None = None + end_utc: str | None = None + end_at: str | None = None + description: str | None = None + time_zone: str | None = None + href: str | None = None + + +class School(_BaseModel): + """A school associated with a property.""" + + name: str | None = None + rating: float | None = None + education_levels: list[str] = Field(default_factory=list) + grades: str | None = None + distance_miles: float | None = None + district: str | None = None + + +class TaxRecord(_BaseModel): + """A single year of tax/assessment history.""" + + year: int | None = None + tax: float | None = None + assessment_building: float | None = None + assessment_land: float | None = None + assessment_total: float | None = None + + +class PriceEvent(_BaseModel): + """A single price-history event (listing, sale, price change).""" + + date_utc: str | None = None + date_at: str | None = None + event: str | None = None + price: float | None = None + price_per_sqft: float | None = None + source_listing_id: str | None = None + + +class Estimate(_BaseModel): + """A third-party value estimate for a property.""" + + source: str | None = None + estimate: float | None = None + estimate_high: float | None = None + estimate_low: float | None = None + date_utc: str | None = None + date_at: str | None = None + + +class DetailGroup(_BaseModel): + """A named group of free-text detail lines (e.g. "Interior Features").""" + + category: str | None = None + text: list[str] = Field(default_factory=list) + + +class Flags(_BaseModel): + """Boolean status flags for a listing.""" + + is_new_listing: bool | None = None + is_pending: bool | None = None + is_contingent: bool | None = None + is_foreclosure: bool | None = None + is_new_construction: bool | None = None + is_price_reduced: bool | None = None + is_coming_soon: bool | None = None + + +# ============================================================================= +# Property (search card) and PropertyDetail +# ============================================================================= + + +class Property(_BaseModel): + """A property listing (search result card).""" + + property_id: str | None = None + listing_id: str | None = None + mls_number: str | None = None + market: str | None = None + country: str | None = None + url: str | None = None + status: str | None = None + transaction_type: str | None = None + currency: str | None = None + list_price: float | None = None + list_price_formatted: str | None = None + list_price_min: float | None = None + list_price_max: float | None = None + price_per_sqft: float | None = None + price_reduced_amount: float | None = None + last_sold_price: float | None = None + last_sold_date_utc: str | None = None + last_sold_date_at: str | None = None + hoa_fee: float | None = None + property_type: str | None = None + sub_type: str | None = None + beds: float | None = None + baths: float | None = None + baths_full: float | None = None + baths_half: float | None = None + sqft: float | None = None + lot_sqft: float | None = None + year_built: int | None = None + stories: float | None = None + garage: float | None = None + rooms: float | None = None + parking_spaces: float | None = None + address: Address | None = None + description_text: str | None = None + primary_photo: str | None = None + photo_count: int | None = None + photos: list[Photo] = Field(default_factory=list) + virtual_tours: list[str] = Field(default_factory=list) + videos: list[str] = Field(default_factory=list) + flags: Flags | None = None + tags: list[str] = Field(default_factory=list) + list_date_utc: str | None = None + list_date_at: str | None = None + last_update_utc: str | None = None + last_update_at: str | None = None + days_on_market: int | None = None + agents: list[Agent] = Field(default_factory=list) + source_mls_id: str | None = None + source_mls_name: str | None = None + open_houses: list[OpenHouse] = Field(default_factory=list) + + +class PropertyDetail(Property): + """Full property detail (/properties/{property_id}). + + Extends :class:`Property` with the heavy detail blocks that are only + returned on the single-property endpoint. + """ + + details: list[DetailGroup] = Field(default_factory=list) + amenities: list[str] = Field(default_factory=list) + tax_history: list[TaxRecord] = Field(default_factory=list) + price_history: list[PriceEvent] = Field(default_factory=list) + schools: list[School] = Field(default_factory=list) + estimates: list[Estimate] = Field(default_factory=list) + + +# ============================================================================= +# Autocomplete +# ============================================================================= + + +class Suggestion(_BaseModel): + """A single autocomplete suggestion (location or address).""" + + id: str | None = None + type: str | None = None + label: str | None = None + city: str | None = None + state_code: str | None = None + postal_code: str | None = None + country: str | None = None + slug_id: str | None = None + geo_id: str | None = None + coordinate: Coordinate | None = None + market: str | None = None + + +# ============================================================================= +# Reference +# ============================================================================= + + +class MarketInfo(_BaseModel): + """A single supported marketplace (for /markets).""" + + code: str + domain: str + country: str + currency: str + locale: str + name: str + + +# ============================================================================= +# Response envelopes +# ============================================================================= + + +class AutocompleteResponse(_BaseModel): + """Response for /autocomplete.""" + + market: str + query: str + suggestions: list[Suggestion] = Field(default_factory=list) + + +class SearchResponse(_BaseModel): + """Response for /search.""" + + market: str + country: str | None = None + total: int | None = None + count: int | None = None + page: int | None = None + total_pages: int | None = None + results: list[Property] = Field(default_factory=list) + + +class MarketsResponse(_BaseModel): + """Response for /markets.""" + + markets: list[MarketInfo] = Field(default_factory=list) diff --git a/src/scrapebadger/realtor/properties.py b/src/scrapebadger/realtor/properties.py new file mode 100644 index 0000000..4837eeb --- /dev/null +++ b/src/scrapebadger/realtor/properties.py @@ -0,0 +1,65 @@ +"""Realtor Properties API client. + +Provides the method for fetching a single property's full detail. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.realtor.models import PropertyDetail + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class PropertiesClient: + """Client for the Realtor property-detail endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + detail = await client.realtor.properties.get_property("1234567890") + print(detail.address.line if detail.address else detail.property_id) + for event in detail.price_history: + print(f"{event.date_at}: {event.event} {event.price}") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize properties client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_property( + self, + property_id: str, + *, + market: str = "us", + ) -> PropertyDetail: + """Get a single property's full detail. + + Args: + property_id: The property id. + market: Market — "us" or "ca". Defaults to "us". + + Returns: + Full property detail including price/tax history, schools, estimates, + amenities, and agent/office contacts. + + Raises: + NotFoundError: If the property doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + detail = await client.realtor.properties.get_property("1234567890") + print(f"{detail.beds}bd/{detail.baths}ba, {detail.sqft} sqft") + ``` + """ + params: dict[str, Any] = {"market": market} + response = await self._client.get(f"/v1/realtor/properties/{property_id}", params=params) + return PropertyDetail.model_validate(response) diff --git a/src/scrapebadger/realtor/reference.py b/src/scrapebadger/realtor/reference.py new file mode 100644 index 0000000..8a2f7bf --- /dev/null +++ b/src/scrapebadger/realtor/reference.py @@ -0,0 +1,50 @@ +"""Realtor Reference Data API client. + +Provides the method for fetching the static list of supported markets. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.realtor.models import MarketsResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ReferenceClient: + """Client for Realtor reference data endpoints (markets). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + markets = await client.realtor.reference.list_markets() + for m in markets.markets: + print(f"{m.code}: {m.domain} ({m.currency})") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize reference client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def list_markets(self) -> MarketsResponse: + """Get all supported Realtor markets. + + Returns: + Markets response with all supported markets (us, ca). + + Example: + ```python + result = await client.realtor.reference.list_markets() + for m in result.markets: + print(f"{m.code}: {m.name} ({m.domain})") + ``` + """ + response = await self._client.get("/v1/realtor/markets") + return MarketsResponse.model_validate(response) diff --git a/src/scrapebadger/realtor/search.py b/src/scrapebadger/realtor/search.py new file mode 100644 index 0000000..609e3c2 --- /dev/null +++ b/src/scrapebadger/realtor/search.py @@ -0,0 +1,155 @@ +"""Realtor Search API client. + +Provides methods for property search and location autocomplete across the +supported real-estate markets (realtor.com US, realtor.ca CA). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.realtor.models import AutocompleteResponse, SearchResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class SearchClient: + """Client for Realtor search and autocomplete endpoints. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + results = await client.realtor.search.search("Austin, TX") + for prop in results.results: + print(prop.address.line if prop.address else prop.property_id) + + suggestions = await client.realtor.search.autocomplete("miami") + for s in suggestions.suggestions: + print(s.label) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize search client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def search( + self, + location: str | None = None, + *, + market: str = "us", + status: str = "for_sale", + price_min: float | None = None, + price_max: float | None = None, + beds_min: int | None = None, + baths_min: float | None = None, + sqft_min: int | None = None, + sqft_max: int | None = None, + property_type: str | None = None, + sort: str = "relevant", + page: int = 1, + limit: int | None = None, + lat_min: float | None = None, + lat_max: float | None = None, + lng_min: float | None = None, + lng_max: float | None = None, + ) -> SearchResponse: + """Search a real-estate market for property listings. + + Args: + location: Freetext location ("Austin, TX", a ZIP, or "Toronto, ON"). + Required unless a CA bounding box (lat/lng) is supplied. + market: Market — "us" (realtor.com, USD) or "ca" (realtor.ca, CAD). + Defaults to "us". + status: Listing status ("for_sale", "for_rent", "sold", "pending"). + Defaults to "for_sale". + price_min: Minimum price filter. + price_max: Maximum price filter. + beds_min: Minimum number of bedrooms. + baths_min: Minimum number of bathrooms. + sqft_min: Minimum living area in sqft (US). + sqft_max: Maximum living area in sqft (US). + property_type: Property type filter (US, CSV: single_family, condos, + townhomes, multi_family, mobile, land). + sort: Sort order ("relevant", "newest", "price_low", "price_high", + "photo_count"). Defaults to "relevant". + page: Page number (1-indexed). Defaults to 1. + limit: Results per page (1-200). + lat_min: Bounding-box minimum latitude (CA power-user bbox). + lat_max: Bounding-box maximum latitude (CA power-user bbox). + lng_min: Bounding-box minimum longitude (CA power-user bbox). + lng_max: Bounding-box maximum longitude (CA power-user bbox). + + Returns: + Search response with matching property results and pagination metadata. + + Raises: + AuthenticationError: If the API key is invalid. + ValidationError: If the parameters are invalid. + + Example: + ```python + results = await client.realtor.search.search( + "Austin, TX", + price_min=300000, + price_max=600000, + beds_min=3, + sort="newest", + ) + print(f"Page {results.page} of {results.total_pages}") + ``` + """ + params: dict[str, Any] = { + "location": location, + "market": market, + "status": status, + "price_min": price_min, + "price_max": price_max, + "beds_min": beds_min, + "baths_min": baths_min, + "sqft_min": sqft_min, + "sqft_max": sqft_max, + "property_type": property_type, + "sort": sort, + "page": page, + "limit": limit, + "lat_min": lat_min, + "lat_max": lat_max, + "lng_min": lng_min, + "lng_max": lng_max, + } + response = await self._client.get("/v1/realtor/search", params=params) + return SearchResponse.model_validate(response) + + async def autocomplete( + self, + query: str, + *, + market: str = "us", + limit: int = 10, + ) -> AutocompleteResponse: + """Get location/address autocomplete suggestions. + + Args: + query: Partial location or address query. + market: Market — "us" or "ca". Defaults to "us". + limit: Maximum suggestions to return (1-25). Defaults to 10. + + Returns: + Autocomplete response with location/address suggestions. + + Example: + ```python + result = await client.realtor.search.autocomplete("austin") + for s in result.suggestions: + print(s.label) + ``` + """ + params: dict[str, Any] = {"query": query, "market": market, "limit": limit} + response = await self._client.get("/v1/realtor/autocomplete", params=params) + return AutocompleteResponse.model_validate(response) diff --git a/tests/test_realtor.py b/tests/test_realtor.py new file mode 100644 index 0000000..0e3b033 --- /dev/null +++ b/tests/test_realtor.py @@ -0,0 +1,102 @@ +"""Unit tests for Realtor SDK methods and models. + +Tests cover: +- TestRealtorModels: Pydantic model construction and validation +- TestRealtorClient: RealtorClient sub-client wiring +- TestSearchClient / TestPropertiesClient / TestReferenceClient: endpoint + routing (path + params) via a mocked HTTP client +- TestRealtorImports: public API importability +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from scrapebadger.realtor.client import RealtorClient +from scrapebadger.realtor.models import ( + AutocompleteResponse, + MarketInfo, + MarketsResponse, + PropertyDetail, + SearchResponse, +) +from scrapebadger.realtor.properties import PropertiesClient +from scrapebadger.realtor.reference import ReferenceClient +from scrapebadger.realtor.search import SearchClient + + +class TestRealtorModels: + def test_search_response_from_payload(self) -> None: + r = SearchResponse.model_validate( + { + "market": "us", + "total": 2, + "page": 1, + "results": [{"property_id": "1", "list_price": 500000}], + } + ) + assert r.market == "us" + assert r.results[0].property_id == "1" + + def test_property_detail_all_optional(self) -> None: + assert PropertyDetail.model_validate({}).property_id is None + + def test_market_info_required(self) -> None: + with pytest.raises(ValueError): + MarketInfo.model_validate({"code": "us"}) # missing required fields + + +class TestRealtorClient: + def test_subclient_wiring(self) -> None: + c = RealtorClient(MagicMock()) + assert isinstance(c.search, SearchClient) + assert isinstance(c.properties, PropertiesClient) + assert isinstance(c.reference, ReferenceClient) + + +class TestRouting: + @pytest.mark.asyncio + async def test_search_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"market": "us", "results": []}) + out = await SearchClient(http).search("Austin, TX", market="us", beds_min=3) + path, kwargs = http.get.call_args[0][0], http.get.call_args[1] + assert path == "/v1/realtor/search" + assert kwargs["params"]["location"] == "Austin, TX" + assert kwargs["params"]["beds_min"] == 3 + assert isinstance(out, SearchResponse) + + @pytest.mark.asyncio + async def test_autocomplete_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"market": "ca", "query": "toronto", "suggestions": []}) + out = await SearchClient(http).autocomplete("toronto", market="ca") + assert http.get.call_args[0][0] == "/v1/realtor/autocomplete" + assert isinstance(out, AutocompleteResponse) + + @pytest.mark.asyncio + async def test_property_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"property_id": "42"}) + out = await PropertiesClient(http).get_property("42", market="us") + assert http.get.call_args[0][0] == "/v1/realtor/properties/42" + assert isinstance(out, PropertyDetail) + + @pytest.mark.asyncio + async def test_markets_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"markets": []}) + out = await ReferenceClient(http).list_markets() + assert http.get.call_args[0][0] == "/v1/realtor/markets" + assert isinstance(out, MarketsResponse) + + +class TestRealtorImports: + def test_public_api(self) -> None: + from scrapebadger import RealtorClient as ExportedClient + from scrapebadger import RealtorSearchResponse + + assert ExportedClient is RealtorClient + assert RealtorSearchResponse is SearchResponse diff --git a/uv.lock b/uv.lock index a3ad213..54d38fb 100644 --- a/uv.lock +++ b/uv.lock @@ -752,7 +752,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.15.4" +version = "0.15.5" source = { editable = "." } dependencies = [ { name = "httpx" },