From 2106547e05b05db51a3fdde9f0185a273583bf10 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:36:36 +0300 Subject: [PATCH] feat(zillow): add Zillow dedicated scraper API (SCR-99) --- CHANGELOG.md | 6 + pyproject.toml | 2 +- src/scrapebadger/__init__.py | 125 ++++- src/scrapebadger/client.py | 24 + src/scrapebadger/zillow/__init__.py | 93 ++++ src/scrapebadger/zillow/agent.py | 71 +++ src/scrapebadger/zillow/client.py | 131 +++++ src/scrapebadger/zillow/models.py | 731 ++++++++++++++++++++++++++ src/scrapebadger/zillow/properties.py | 59 +++ src/scrapebadger/zillow/reference.py | 51 ++ src/scrapebadger/zillow/search.py | 164 ++++++ tests/test_zillow.py | 130 +++++ uv.lock | 2 +- 13 files changed, 1586 insertions(+), 3 deletions(-) create mode 100644 src/scrapebadger/zillow/__init__.py create mode 100644 src/scrapebadger/zillow/agent.py create mode 100644 src/scrapebadger/zillow/client.py create mode 100644 src/scrapebadger/zillow/models.py create mode 100644 src/scrapebadger/zillow/properties.py create mode 100644 src/scrapebadger/zillow/reference.py create mode 100644 src/scrapebadger/zillow/search.py create mode 100644 tests/test_zillow.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b24abb3..2e1eac6 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.6] - 2026-07-07 + +### Added + +- **Zillow sub-client** (`client.zillow`) — real-estate listings, property detail, and agent profiles from zillow.com (US + Canadian inventory). Five endpoints: `search.search()`, `search.autocomplete()`, `properties.get_property()`, `agents.get_agent()`, `reference.list_markets()`, with fully-typed maximal-coverage Pydantic models (`ZillowSearchResponse`, `ZillowProperty` incl. nested `home_facts`/`price_history`/`tax_history`/`schools`/`zestimate_history`, `ZillowAgent`, etc.). (SCR-99) + ## [0.15.5] - 2026-07-02 ### Added diff --git a/pyproject.toml b/pyproject.toml index 25a17ce..f18da3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.15.5" +version = "0.15.6" 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 5f06747..47c9b37 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -480,8 +480,99 @@ async def main(): from scrapebadger.youtube.models import ( Video as YoutubeVideo, ) +from scrapebadger.zillow.client import ZillowClient +from scrapebadger.zillow.models import ( + Address as ZillowAddress, +) +from scrapebadger.zillow.models import ( + Agent as ZillowAgent, +) +from scrapebadger.zillow.models import ( + AgentAttribution as ZillowAgentAttribution, +) +from scrapebadger.zillow.models import ( + AgentLicense as ZillowAgentLicense, +) +from scrapebadger.zillow.models import ( + AgentResponse as ZillowAgentResponse, +) +from scrapebadger.zillow.models import ( + AgentReview as ZillowAgentReview, +) +from scrapebadger.zillow.models import ( + AutocompleteResponse as ZillowAutocompleteResponse, +) +from scrapebadger.zillow.models import ( + AutocompleteResult as ZillowAutocompleteResult, +) +from scrapebadger.zillow.models import ( + HomeFacts as ZillowHomeFacts, +) +from scrapebadger.zillow.models import ( + LatLong as ZillowLatLong, +) +from scrapebadger.zillow.models import ( + Listing as ZillowListing, +) +from scrapebadger.zillow.models import ( + ListingSubType as ZillowListingSubType, +) +from scrapebadger.zillow.models import ( + MapBounds as ZillowMapBounds, +) +from scrapebadger.zillow.models import ( + MarketInfo as ZillowMarketInfo, +) +from scrapebadger.zillow.models import ( + MarketsResponse as ZillowMarketsResponse, +) +from scrapebadger.zillow.models import ( + MortgageRate as ZillowMortgageRate, +) +from scrapebadger.zillow.models import ( + MortgageRates as ZillowMortgageRates, +) +from scrapebadger.zillow.models import ( + NearbyRegion as ZillowNearbyRegion, +) +from scrapebadger.zillow.models import ( + OpenHouse as ZillowOpenHouse, +) +from scrapebadger.zillow.models import ( + Pagination as ZillowPagination, +) +from scrapebadger.zillow.models import ( + PastSale as ZillowPastSale, +) +from scrapebadger.zillow.models import ( + Photo as ZillowPhoto, +) +from scrapebadger.zillow.models import ( + PriceHistoryEvent as ZillowPriceHistoryEvent, +) +from scrapebadger.zillow.models import ( + Property as ZillowProperty, +) +from scrapebadger.zillow.models import ( + PropertyResponse as ZillowPropertyResponse, +) +from scrapebadger.zillow.models import ( + RegionSelection as ZillowRegionSelection, +) +from scrapebadger.zillow.models import ( + School as ZillowSchool, +) +from scrapebadger.zillow.models import ( + SearchResponse as ZillowSearchResponse, +) +from scrapebadger.zillow.models import ( + TaxHistoryEvent as ZillowTaxHistoryEvent, +) +from scrapebadger.zillow.models import ( + ZestimateHistoryPoint as ZillowZestimateHistoryPoint, +) -__version__ = "0.15.5" +__version__ = "0.15.6" __all__ = [ # TikTok core models @@ -723,6 +814,38 @@ async def main(): "YoutubeTrendingItem", "YoutubeTrendingResponse", "YoutubeVideo", + # Zillow + "ZillowAddress", + "ZillowAgent", + "ZillowAgentAttribution", + "ZillowAgentLicense", + "ZillowAgentResponse", + "ZillowAgentReview", + "ZillowAutocompleteResponse", + "ZillowAutocompleteResult", + "ZillowClient", + "ZillowHomeFacts", + "ZillowLatLong", + "ZillowListing", + "ZillowListingSubType", + "ZillowMapBounds", + "ZillowMarketInfo", + "ZillowMarketsResponse", + "ZillowMortgageRate", + "ZillowMortgageRates", + "ZillowNearbyRegion", + "ZillowOpenHouse", + "ZillowPagination", + "ZillowPastSale", + "ZillowPhoto", + "ZillowPriceHistoryEvent", + "ZillowProperty", + "ZillowPropertyResponse", + "ZillowRegionSelection", + "ZillowSchool", + "ZillowSearchResponse", + "ZillowTaxHistoryEvent", + "ZillowZestimateHistoryPoint", # Version "__version__", ] diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index 37320f5..7ab7fec 100644 --- a/src/scrapebadger/client.py +++ b/src/scrapebadger/client.py @@ -19,6 +19,7 @@ from scrapebadger.vinted.client import VintedClient from scrapebadger.web.client import WebClient from scrapebadger.youtube.client import YoutubeClient +from scrapebadger.zillow.client import ZillowClient if TYPE_CHECKING: from types import TracebackType @@ -126,6 +127,7 @@ def __init__( self._youtube: YoutubeClient | None = None self._tiktok: TikTokClient | None = None self._realtor: RealtorClient | None = None + self._zillow: ZillowClient | None = None @property def config(self) -> ClientConfig: @@ -327,6 +329,28 @@ def realtor(self) -> RealtorClient: self._realtor = RealtorClient(self._base_client) return self._realtor + @property + def zillow(self) -> ZillowClient: + """Access Zillow Scraper API operations. + + Returns: + ZillowClient providing access to all 5 Zillow endpoints + (search, property detail, agent profile, autocomplete, markets) + on zillow.com (US + Canadian inventory). + + Example: + ```python + results = await client.zillow.search.search("Austin, TX") + prop = await client.zillow.properties.get_property("2078133351") + agent = await client.zillow.agents.get_agent(username="jane-doe") + hits = await client.zillow.search.autocomplete("austin") + markets = await client.zillow.reference.list_markets() + ``` + """ + if self._zillow is None: + self._zillow = ZillowClient(self._base_client) + return self._zillow + @property def tiktok(self) -> TikTokClient: """Access TikTok Scraper API operations. diff --git a/src/scrapebadger/zillow/__init__.py b/src/scrapebadger/zillow/__init__.py new file mode 100644 index 0000000..0646b4b --- /dev/null +++ b/src/scrapebadger/zillow/__init__.py @@ -0,0 +1,93 @@ +"""Zillow API module for ScrapeBadger SDK. + +This module provides a comprehensive async client for scraping real-estate +listings, property detail, and agent profiles from zillow.com through the +ScrapeBadger API. Zillow is a single-domain target (US + Canadian inventory, +USD, en-US). 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.zillow.search.search("Austin, TX") + for listing in results.results: + print(listing.zpid, listing.price) + + # Get property detail + prop = await client.zillow.properties.get_property("2078133351") + print(prop.bedrooms, prop.bathrooms, prop.living_area) + + # Get an agent profile + their listings + agent = await client.zillow.agents.get_agent(username="jane-doe") + ``` +""" + +from scrapebadger.zillow.client import ZillowClient +from scrapebadger.zillow.models import ( + Address, + Agent, + AgentAttribution, + AgentLicense, + AgentResponse, + AgentReview, + AutocompleteResponse, + AutocompleteResult, + HomeFacts, + LatLong, + Listing, + ListingSubType, + MapBounds, + MarketInfo, + MarketsResponse, + MortgageRate, + MortgageRates, + NearbyRegion, + OpenHouse, + Pagination, + PastSale, + Photo, + PriceHistoryEvent, + Property, + PropertyResponse, + RegionSelection, + School, + SearchResponse, + TaxHistoryEvent, + ZestimateHistoryPoint, +) + +__all__ = [ + "Address", + "Agent", + "AgentAttribution", + "AgentLicense", + "AgentResponse", + "AgentReview", + "AutocompleteResponse", + "AutocompleteResult", + "HomeFacts", + "LatLong", + "Listing", + "ListingSubType", + "MapBounds", + "MarketInfo", + "MarketsResponse", + "MortgageRate", + "MortgageRates", + "NearbyRegion", + "OpenHouse", + "Pagination", + "PastSale", + "Photo", + "PriceHistoryEvent", + "Property", + "PropertyResponse", + "RegionSelection", + "School", + "SearchResponse", + "TaxHistoryEvent", + "ZestimateHistoryPoint", + "ZillowClient", +] diff --git a/src/scrapebadger/zillow/agent.py b/src/scrapebadger/zillow/agent.py new file mode 100644 index 0000000..f975287 --- /dev/null +++ b/src/scrapebadger/zillow/agent.py @@ -0,0 +1,71 @@ +"""Zillow Agent API client. + +Provides the method for fetching a real-estate professional's profile and +their active listings. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.zillow.models import Agent, AgentResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class AgentClient: + """Client for the Zillow agent-profile endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + agent = await client.zillow.agents.get_agent(username="jane-doe") + print(agent.name, agent.rating, agent.review_count) + for sale in agent.past_sales: + print(sale.street_address, sale.price) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize agent client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_agent( + self, + username: str | None = None, + *, + url: str | None = None, + ) -> Agent: + """Get a Zillow professional's profile and their active listings. + + Provide either a ``username`` (the profile screen name) or a full + profile ``url``. + + Args: + username: Zillow profile username (screen name). + url: Full Zillow /profile/... URL. + + Returns: + The agent profile including reviews, past sales, licenses, service + areas, contact info, and their active listings. + + Raises: + NotFoundError: If the agent doesn't exist. + AuthenticationError: If the API key is invalid. + ValidationError: If neither ``username`` nor ``url`` is provided. + + Example: + ```python + agent = await client.zillow.agents.get_agent(username="jane-doe") + for review in agent.reviews: + print(review.rating, review.comment) + ``` + """ + params: dict[str, Any] = {"username": username, "url": url} + response = await self._client.get("/v1/zillow/agent", params=params) + return AgentResponse.model_validate(response).agent diff --git a/src/scrapebadger/zillow/client.py b/src/scrapebadger/zillow/client.py new file mode 100644 index 0000000..ffed16a --- /dev/null +++ b/src/scrapebadger/zillow/client.py @@ -0,0 +1,131 @@ +"""Zillow API client combining all sub-clients. + +This module provides the main ZillowClient class that serves as the +entry point for all Zillow API operations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.zillow.agent import AgentClient +from scrapebadger.zillow.properties import PropertiesClient +from scrapebadger.zillow.reference import ReferenceClient +from scrapebadger.zillow.search import SearchClient + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ZillowClient: + """Client for all Zillow API operations. + + This class provides access to all Zillow scraping endpoints through + organized sub-clients for different resource types. Zillow is a + single-domain target (zillow.com) covering US + Canadian inventory. + + Attributes: + search: Client for property search and region/address autocomplete. + properties: Client for single-property detail. + agents: Client for real-estate professional profiles. + 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.zillow.search.search("Austin, TX") + for listing in results.results: + print(listing.address, listing.price) + + # Get property detail + prop = await client.zillow.properties.get_property("2078133351") + print(prop.bedrooms, prop.bathrooms, prop.living_area) + + # Get an agent profile + their listings + agent = await client.zillow.agents.get_agent(username="jane-doe") + + # Region/address autocomplete + hits = await client.zillow.search.autocomplete("austin") + + # Get supported markets + markets = await client.zillow.reference.list_markets() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `zillow` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize Zillow 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._agents = AgentClient(client) + self._reference = ReferenceClient(client) + + @property + def search(self) -> SearchClient: + """Access property search and autocomplete endpoints. + + Returns: + SearchClient for property search and region/address autocomplete. + + Example: + ```python + results = await client.zillow.search.search("Miami, FL") + hits = await client.zillow.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 + prop = await client.zillow.properties.get_property("2078133351") + ``` + """ + return self._properties + + @property + def agents(self) -> AgentClient: + """Access the agent-profile endpoint. + + Returns: + AgentClient for fetching a professional's profile + listings. + + Example: + ```python + agent = await client.zillow.agents.get_agent(username="jane-doe") + ``` + """ + return self._agents + + @property + def reference(self) -> ReferenceClient: + """Access reference data endpoints. + + Returns: + ReferenceClient for fetching supported markets. + + Example: + ```python + markets = await client.zillow.reference.list_markets() + ``` + """ + return self._reference diff --git a/src/scrapebadger/zillow/models.py b/src/scrapebadger/zillow/models.py new file mode 100644 index 0000000..7aeab24 --- /dev/null +++ b/src/scrapebadger/zillow/models.py @@ -0,0 +1,731 @@ +"""Pydantic models for Zillow API responses. + +These models mirror the backend ``zillow_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). + +Zillow is a single-domain, single-locale target (zillow.com, USD, en-US); US + +Canadian inventory are both served from zillow.com behind a US IP, so there is +no market/currency dimension on the models. +""" + +from __future__ import annotations + +from typing import Any + +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 LatLong(_BaseModel): + """A latitude/longitude pair.""" + + latitude: float | None = None + longitude: float | None = None + + +class Photo(_BaseModel): + """A single listing photo with its responsive source variants.""" + + url: str | None = None + caption: str | None = None + subject_type: str | None = None + # Responsive variants: [{"url": ..., "width": ..., "format": "jpeg"|"webp"}] + sources: list[dict[str, Any]] = Field(default_factory=list) + + +class Pagination(_BaseModel): + """Page-number pagination (Zillow search returns ~40 results per page).""" + + current_page: int = 1 + per_page: int | None = None + total_pages: int | None = None + total_results: int | None = None + + +class MapBounds(_BaseModel): + """The map bounding box a search covers — callers tile with this to beat + Zillow's ~820-result (20-page) cap by subdividing dense boxes.""" + + north: float | None = None + east: float | None = None + south: float | None = None + west: float | None = None + + +class RegionSelection(_BaseModel): + """The numeric region a search resolved to (region_id + region_type).""" + + region_id: int | None = None + region_type: int | None = None + + +class NearbyRegion(_BaseModel): + """A linked nearby region (city / neighborhood / zip) on a property page.""" + + name: str | None = None + region_type: str | None = None + url: str | None = None + + +class MarketInfo(_BaseModel): + """A supported coverage region (for /markets).""" + + code: str + country: str + currency: str + locale: str + name: str + domain: str + + +# ============================================================================= +# Search results +# ============================================================================= + + +class Listing(_BaseModel): + """One Zillow search card (search / agent listings). + + Merges the search ``listResult`` top-level with its richer + ``hdpData.homeInfo`` sub-object. + """ + + position: int + zpid: str | None = None + id: str | None = None + detail_url: str | None = None + # Status / type + home_type: str | None = None # SINGLE_FAMILY, CONDO, TOWNHOUSE, APARTMENT … + home_status: str | None = None # FOR_SALE, FOR_RENT, SOLD, PENDING … + status_text: str | None = None # "House for sale" + status_type: str | None = None + marketing_status: str | None = None + contingent_listing_type: str | None = None + # Price / valuation + price: int | None = None + price_raw: str | None = None # "$460,000" / "$2,400/mo" + currency: str | None = None + price_change: int | None = None + date_price_changed_utc: float | None = None + date_price_changed_at: str | None = None + price_reduction: str | None = None + flex_field_text: str | None = None # marketing badge ("Price cut", "$X (Nov 1)") + zestimate: int | None = None + rent_zestimate: int | None = None + tax_assessed_value: int | None = None + # Specs + beds: float | None = None + baths: float | None = None + living_area: int | None = None + lot_area_value: float | None = None + lot_area_unit: str | None = None + # Address + address: str | None = None + street_address: str | None = None + unit: str | None = None + city: str | None = None + state: str | None = None + zipcode: str | None = None + country: str | None = None + is_undisclosed_address: bool | None = None + latitude: float | None = None + longitude: float | None = None + # Listing meta + broker_name: str | None = None + provider_listing_id: str | None = None + days_on_zillow: int | None = None + is_zillow_owned: bool | None = None + is_featured: bool | None = None + is_showcase: bool | None = None + is_fsba: bool | None = None # for-sale-by-agent (from listing_sub_type) + is_new_construction: bool | None = None + is_premier_builder: bool | None = None + is_preforeclosure_auction: bool | None = None + is_non_owner_occupied: bool | None = None + # Media + img_src: str | None = None + has_image: bool | None = None + has_video: bool | None = None + has_3d_model: bool | None = None + has_open_house: bool | None = None + open_house_start: str | None = None + open_house_end: str | None = None + photos: list[str] = Field(default_factory=list) + + +# ============================================================================= +# Property detail nested +# ============================================================================= + + +class Address(_BaseModel): + """A property street address (property-detail block).""" + + street_address: str | None = None + city: str | None = None + state: str | None = None + zipcode: str | None = None + community: str | None = None + subdivision: str | None = None + neighborhood: str | None = None + + +class ListingSubType(_BaseModel): + """``listingSubType`` flags — the for-sale / foreclosure / auction taxonomy.""" + + is_fsba: bool | None = None # for sale by agent + is_fsbo: bool | None = None # for sale by owner + is_foreclosure: bool | None = None + is_bank_owned: bool | None = None + is_for_auction: bool | None = None + is_coming_soon: bool | None = None + is_new_home: bool | None = None + is_pending: bool | None = None + + +class OpenHouse(_BaseModel): + """A single scheduled open house.""" + + start_utc: float | None = None + start_at: str | None = None + end_utc: float | None = None + end_at: str | None = None + note: str | None = None + + +class ZestimateHistoryPoint(_BaseModel): + """One point in the Zestimate value history series (``homeValueChartData``).""" + + date: str | None = None + date_utc: float | None = None + date_at: str | None = None + value: int | None = None + + +class PriceHistoryEvent(_BaseModel): + """A single price-history event (listing, sale, price change).""" + + date: str | None = None + date_utc: float | None = None + date_at: str | None = None + event: str | None = None # "Listed for sale", "Price change", "Sold" … + price: int | None = None + price_per_square_foot: int | None = None + price_change_rate: float | None = None + source: str | None = None + buyer_agent: str | None = None + seller_agent: str | None = None + posting_is_rental: bool | None = None + + +class TaxHistoryEvent(_BaseModel): + """A single year of tax/assessment history.""" + + year_utc: float | None = None + year_at: str | None = None + value: int | None = None + value_increase_rate: float | None = None + tax_paid: float | None = None + tax_increase_rate: float | None = None + + +class School(_BaseModel): + """A school associated with a property.""" + + name: str | None = None + rating: int | None = None + grades: str | None = None + level: str | None = None # Elementary / Middle / High / Primary + type: str | None = None # Public / Private / Charter + distance: float | None = None + link: str | None = None + student_count: int | None = None + assigned: bool | None = None + + +class AgentAttribution(_BaseModel): + """Listing agent / broker attribution (from ``attributionInfo``).""" + + agent_name: str | None = None + agent_phone: str | None = None + agent_email: str | None = None + agent_license_number: str | None = None + co_agent_name: str | None = None + co_agent_number: str | None = None + co_agent_license_number: str | None = None + broker_name: str | None = None + broker_phone: str | None = None + buyer_agent_name: str | None = None + buyer_brokerage_name: str | None = None + mls_id: str | None = None + mls_name: str | None = None + mls_disclaimer: str | None = None + listing_agreement: str | None = None + listing_attribution_contact: str | None = None + provider_logo: str | None = None + true_status: str | None = None + last_checked: str | None = None + last_updated: str | None = None + listing_agents: list[dict[str, Any]] = Field(default_factory=list) + listing_offices: list[dict[str, Any]] = Field(default_factory=list) + + +class MortgageRate(_BaseModel): + """A single mortgage-rate quote for a loan product.""" + + rate: float | None = None + rate_source: str | None = None + last_updated_utc: float | None = None + last_updated_at: str | None = None + + +class MortgageRates(_BaseModel): + """Current mortgage rates by loan product (from ``mortgageRates``).""" + + fifteen_year_fixed: MortgageRate | None = None + thirty_year_fixed: MortgageRate | None = None + arm_5: MortgageRate | None = None + + +class HomeFacts(_BaseModel): + """High-value subset of Zillow's ``resoFacts`` MLS block. + + resoFacts carries ~187 keys; these are the ones competitors surface and + callers actually query. + """ + + # Bath breakdown + bathrooms_full: int | None = None + bathrooms_half: int | None = None + bathrooms_three_quarter: int | None = None + bathrooms_one_quarter: int | None = None + # Structure + stories: int | None = None + stories_decimal: float | None = None + levels: str | None = None + property_condition: str | None = None + architectural_style: str | None = None + structure_type: str | None = None + building_name: str | None = None + construction_materials: list[str] = Field(default_factory=list) + foundation_details: list[str] = Field(default_factory=list) + roof_type: str | None = None + year_built_effective: int | None = None + # Area breakdown + above_grade_finished_area: str | None = None + below_grade_finished_area: str | None = None + lot_size_dimensions: str | None = None + main_level_bedrooms: int | None = None + main_level_bathrooms: int | None = None + basement: str | None = None + has_basement: bool | None = None + attic: str | None = None + # Systems + heating: list[str] = Field(default_factory=list) + cooling: list[str] = Field(default_factory=list) + appliances: list[str] = Field(default_factory=list) + flooring: list[str] = Field(default_factory=list) + utilities: list[str] = Field(default_factory=list) + electric: list[str] = Field(default_factory=list) + gas: list[str] = Field(default_factory=list) + sewer: list[str] = Field(default_factory=list) + water_source: list[str] = Field(default_factory=list) + # Green / energy + green_building_verification_type: list[str] = Field(default_factory=list) + green_energy_efficient: list[str] = Field(default_factory=list) + green_energy_generation: list[str] = Field(default_factory=list) + green_sustainability: list[str] = Field(default_factory=list) + green_water_conservation: list[str] = Field(default_factory=list) + # Features + interior_features: list[str] = Field(default_factory=list) + exterior_features: list[str] = Field(default_factory=list) + lot_features: list[str] = Field(default_factory=list) + community_features: list[str] = Field(default_factory=list) + accessibility_features: list[str] = Field(default_factory=list) + door_features: list[str] = Field(default_factory=list) + window_features: list[str] = Field(default_factory=list) + laundry_features: list[str] = Field(default_factory=list) + patio_and_porch_features: list[str] = Field(default_factory=list) + fencing: list[str] = Field(default_factory=list) + other_structures: list[str] = Field(default_factory=list) + view: list[str] = Field(default_factory=list) + has_view: bool | None = None + waterfront_features: list[str] = Field(default_factory=list) + water_view: str | None = None + water_body_name: str | None = None + security_features: list[str] = Field(default_factory=list) + # Parking + parking_features: list[str] = Field(default_factory=list) + parking_capacity: int | None = None + garage_parking_capacity: int | None = None + carport_parking_capacity: int | None = None + covered_parking_capacity: int | None = None + open_parking_capacity: int | None = None + has_attached_garage: bool | None = None + has_garage: bool | None = None + has_carport: bool | None = None + has_open_parking: bool | None = None + # Amenities + pool_features: list[str] = Field(default_factory=list) + has_private_pool: bool | None = None + spa_features: list[str] = Field(default_factory=list) + fireplaces: int | None = None + fireplace_features: list[str] = Field(default_factory=list) + has_fireplace: bool | None = None + # HOA / fees / tax + association_name: str | None = None + association_name2: str | None = None + association_fee: str | None = None + association_fee2: str | None = None + association_fee_includes: list[str] = Field(default_factory=list) + association_amenities: list[str] = Field(default_factory=list) + association_phone: str | None = None + has_association: bool | None = None + hoa_fee: str | None = None + hoa_fee_total: str | None = None + tax_annual_amount: float | None = None + price_per_square_foot: int | None = None + # Land / lease + has_land_lease: bool | None = None + land_lease_amount: str | None = None + land_lease_expiration_date: str | None = None + can_raise_horses: bool | None = None + additional_parcels_description: str | None = None + road_surface_type: list[str] = Field(default_factory=list) + # Market timing + on_market_date: str | None = None + cumulative_days_on_market: int | None = None + offer_review_date: str | None = None + # Rental / multi-unit + number_of_units_in_community: int | None = None + availability_date: str | None = None + lease_term: str | None = None + tenant_pays: list[str] = Field(default_factory=list) + has_pets_allowed: bool | None = None + pets_max_weight: int | None = None + has_rent_control: bool | None = None + # Schools (as named on the MLS record) + elementary_school: str | None = None + middle_school: str | None = None + high_school: str | None = None + elementary_school_district: str | None = None + middle_school_district: str | None = None + high_school_district: str | None = None + # Parcel / legal + parcel_number: str | None = None + subdivision_name: str | None = None + municipality: str | None = None + city_region: str | None = None + zoning: str | None = None + zoning_description: str | None = None + ownership: str | None = None + ownership_type: str | None = None + property_sub_type: list[str] = Field(default_factory=list) + special_listing_conditions: str | None = None + listing_terms: str | None = None + inclusions: str | None = None + exclusions: str | None = None + # Flags + is_new_construction: bool | None = None + is_senior_community: bool | None = None + has_home_warranty: bool | None = None + furnished: bool | None = None + development_status: str | None = None + park_name: str | None = None + # Bulk fact lists Zillow ships verbatim (label/value pairs) + at_a_glance_facts: list[dict[str, Any]] = Field(default_factory=list) + rooms: list[dict[str, Any]] = Field(default_factory=list) + + +# ============================================================================= +# Property +# ============================================================================= + + +class Property(_BaseModel): + """Full Zillow property detail (from ``gdpClientCache[...].property``).""" + + # Identity + zpid: str + id: str | None = None + url: str | None = None + home_status: str | None = None + home_type: str | None = None + property_type: str | None = None + listing_type: str | None = None + posting_product_type: str | None = None + listing_data_source: str | None = None + mls_id: str | None = None + parcel_id: str | None = None + county_fips: str | None = None + provider_listing_id: str | None = None + broker_id: str | None = None + contingent_listing_type: str | None = None + listing_sub_type: ListingSubType | None = None + # Price / valuation + price: int | None = None + currency: str | None = None + list_price_low: int | None = None + monthly_hoa_fee: int | None = None + property_tax_rate: float | None = None + annual_homeowners_insurance: int | None = None + last_sold_price: int | None = None + date_sold_utc: float | None = None + date_sold_at: str | None = None + price_change: int | None = None + price_change_date_utc: float | None = None + price_change_date_at: str | None = None + zestimate: int | None = None + rent_zestimate: int | None = None + zestimate_low_percent: str | None = None + zestimate_high_percent: str | None = None + rent_zestimate_low_percent: str | None = None + rent_zestimate_high_percent: str | None = None + zestimate_30_days_ago: int | None = None + rent_zestimate_30_days_ago: int | None = None + tax_assessed_value: int | None = None + zestimate_history: list[ZestimateHistoryPoint] = Field(default_factory=list) + # Specs + bedrooms: float | None = None + bathrooms: float | None = None + living_area: int | None = None + living_area_units: str | None = None + lot_size: int | None = None + lot_area_value: float | None = None + lot_area_units: str | None = None + year_built: int | None = None + move_in_ready: bool | None = None + move_in_completion_date: str | None = None + # Location + latitude: float | None = None + longitude: float | None = None + street_address: str | None = None + abbreviated_address: str | None = None + city: str | None = None + state: str | None = None + zipcode: str | None = None + county: str | None = None + country: str | None = None + time_zone: str | None = None + neighborhood: str | None = None + is_undisclosed_address: bool | None = None + is_income_restricted: bool | None = None + # Engagement + days_on_zillow: int | None = None + time_on_zillow: str | None = None + page_view_count: int | None = None + favorite_count: int | None = None + tour_view_count: int | None = None + photo_count: int | None = None + # Content + description: str | None = None + what_i_love: str | None = None + home_insights: list[str] = Field(default_factory=list) + marketing_name: str | None = None + brokerage_name: str | None = None + is_showcase_listing: bool | None = None + has_vr_model: bool | None = None + has_3d_model: bool | None = None + virtual_tour_url: str | None = None + interactive_floor_plan_url: str | None = None + street_view_image_url: str | None = None + static_map_url: str | None = None + new_construction_type: str | None = None + builder_name: str | None = None + promotion_headline: str | None = None + promotion_description: str | None = None + has_promotion: bool | None = None + # Nested + address: Address | None = None + home_facts: HomeFacts | None = None + agent: AgentAttribution | None = None + mortgage_rates: MortgageRates | None = None + price_history: list[PriceHistoryEvent] = Field(default_factory=list) + tax_history: list[TaxHistoryEvent] = Field(default_factory=list) + schools: list[School] = Field(default_factory=list) + photos: list[Photo] = Field(default_factory=list) + open_house_schedule: list[OpenHouse] = Field(default_factory=list) + nearby_cities: list[NearbyRegion] = Field(default_factory=list) + nearby_neighborhoods: list[NearbyRegion] = Field(default_factory=list) + nearby_zipcodes: list[NearbyRegion] = Field(default_factory=list) + # nearby_homes / comps are lazy-loaded client-side (usually empty in SSR). + nearby_homes: list[Listing] = Field(default_factory=list) + # Timestamps + scraped_utc: float | None = None + scraped_at: str | None = None + + +# ============================================================================= +# Agent profile +# ============================================================================= + + +class AgentReview(_BaseModel): + """One review on a Zillow agent profile (from ``reviewsData.reviews``).""" + + rating: int | None = None + comment: str | None = None + date: str | None = None + date_utc: float | None = None + date_at: str | None = None + work_description: str | None = None + reviewer_name: str | None = None + rebuttal: str | None = None + # [{"description": "Responsiveness", "score": 5}, …] + sub_ratings: list[dict[str, Any]] = Field(default_factory=list) + + +class PastSale(_BaseModel): + """A closed transaction from an agent's ``pastSales`` block.""" + + zpid: str | None = None + street_address: str | None = None + city_state_zip: str | None = None + price: int | None = None + sold_date: str | None = None + sold_date_utc: float | None = None + sold_date_at: str | None = None + bedrooms: float | None = None + bathrooms: float | None = None + living_area: int | None = None + latitude: float | None = None + longitude: float | None = None + represented: str | None = None # buyer / seller / both + image_url: str | None = None + url: str | None = None + + +class AgentLicense(_BaseModel): + """A single professional license held by an agent.""" + + state: str | None = None + license_type: str | None = None + license_number: str | None = None + status: str | None = None + expiration: str | None = None + + +class Agent(_BaseModel): + """A Zillow real-estate professional profile (from /profile/{username}).""" + + username: str | None = None + encoded_zuid: str | None = None + name: str | None = None + url: str | None = None + profile_photo: str | None = None + phone: str | None = None + email: str | None = None + business_name: str | None = None + business_address: str | None = None + broker_name: str | None = None + title: str | None = None + bio: str | None = None + rating: float | None = None + review_count: int | None = None + recent_sales_count: int | None = None + total_sales_last_year: int | None = None + for_sale_count: int | None = None + for_rent_count: int | None = None + past_sales_count: int | None = None + years_experience: int | None = None + is_top_agent: bool | None = None + is_team_lead: bool | None = None + license_number: str | None = None + license_state: str | None = None + # Social / web + website_url: str | None = None + facebook_url: str | None = None + linkedin_url: str | None = None + x_url: str | None = None + video_url: str | None = None + specialties: list[str] = Field(default_factory=list) + service_areas: list[str] = Field(default_factory=list) + languages: list[str] = Field(default_factory=list) + licenses: list[AgentLicense] = Field(default_factory=list) + professional_information: list[dict[str, Any]] = Field(default_factory=list) + reviews: list[AgentReview] = Field(default_factory=list) + past_sales: list[PastSale] = Field(default_factory=list) + listings: list[Listing] = Field(default_factory=list) + scraped_utc: float | None = None + scraped_at: str | None = None + + +# ============================================================================= +# Autocomplete +# ============================================================================= + + +class AutocompleteResult(_BaseModel): + """A region/address suggestion resolved for a search term.""" + + display: str | None = None + region_id: int | None = None + region_type: str | None = None # city, zipcode, neighborhood, county, state + latitude: float | None = None + longitude: float | None = None + zpid: str | None = None # populated when the suggestion is a specific home + metro_id: int | None = None + + +# ============================================================================= +# Response envelopes +# ============================================================================= + + +class SearchResponse(_BaseModel): + """Response for /search.""" + + location: str | None = None + status: str = "for_sale" + results: list[Listing] = Field(default_factory=list) + map_results_count: int = 0 + region: RegionSelection | None = None + map_bounds: MapBounds | None = None + pagination: Pagination = Field(default_factory=Pagination) + scraped_utc: float | None = None + scraped_at: str | None = None + + +class PropertyResponse(_BaseModel): + """Response for /property/{zpid}.""" + + property: Property + + +class AgentResponse(_BaseModel): + """Response for /agent.""" + + agent: Agent + + +class AutocompleteResponse(_BaseModel): + """Response for /autocomplete.""" + + query: str + results: list[AutocompleteResult] = Field(default_factory=list) + + +class MarketsResponse(_BaseModel): + """Response for /markets.""" + + markets: list[MarketInfo] = Field(default_factory=list) diff --git a/src/scrapebadger/zillow/properties.py b/src/scrapebadger/zillow/properties.py new file mode 100644 index 0000000..949bff4 --- /dev/null +++ b/src/scrapebadger/zillow/properties.py @@ -0,0 +1,59 @@ +"""Zillow Properties API client. + +Provides the method for fetching a single property's full detail. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.zillow.models import Property, PropertyResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class PropertiesClient: + """Client for the Zillow property-detail endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + prop = await client.zillow.properties.get_property("2078133351") + print(prop.street_address, prop.price) + for event in prop.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, zpid: str) -> Property: + """Get a single Zillow property's full detail by its zpid. + + Args: + zpid: The Zillow property id (zpid). + + Returns: + Full property detail including price/valuation, specs, resoFacts + (``home_facts``), price & tax history, schools, listing agent, + mortgage rates, and photos. + + Raises: + NotFoundError: If the property doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + prop = await client.zillow.properties.get_property("2078133351") + print(f"{prop.bedrooms}bd/{prop.bathrooms}ba, {prop.living_area} sqft") + ``` + """ + response = await self._client.get(f"/v1/zillow/property/{zpid}") + return PropertyResponse.model_validate(response).property diff --git a/src/scrapebadger/zillow/reference.py b/src/scrapebadger/zillow/reference.py new file mode 100644 index 0000000..bc87e74 --- /dev/null +++ b/src/scrapebadger/zillow/reference.py @@ -0,0 +1,51 @@ +"""Zillow Reference Data API client. + +Provides the method for fetching the static list of coverage markets. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.zillow.models import MarketsResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ReferenceClient: + """Client for Zillow reference data endpoints (markets). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + markets = await client.zillow.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 Zillow coverage markets. + + Returns: + Markets response with all coverage regions (US + Canada, all served + via zillow.com). + + Example: + ```python + result = await client.zillow.reference.list_markets() + for m in result.markets: + print(f"{m.code}: {m.name} ({m.domain})") + ``` + """ + response = await self._client.get("/v1/zillow/markets") + return MarketsResponse.model_validate(response) diff --git a/src/scrapebadger/zillow/search.py b/src/scrapebadger/zillow/search.py new file mode 100644 index 0000000..65dc287 --- /dev/null +++ b/src/scrapebadger/zillow/search.py @@ -0,0 +1,164 @@ +"""Zillow Search API client. + +Provides methods for property search and region/address autocomplete on +zillow.com (for-sale / for-rent / recently-sold inventory). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.zillow.models import AutocompleteResponse, SearchResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class SearchClient: + """Client for Zillow search and autocomplete endpoints. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + results = await client.zillow.search.search("Austin, TX") + for listing in results.results: + print(listing.address, listing.price) + + hits = await client.zillow.search.autocomplete("austin") + for r in hits.results: + print(r.display, r.region_id) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize search client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def search( + self, + location: str, + *, + status: str = "for_sale", + page: int = 1, + sort: str | None = None, + price_min: int | None = None, + price_max: int | None = None, + beds_min: int | None = None, + baths_min: float | None = None, + home_type: str | None = None, + sqft_min: int | None = None, + sqft_max: int | None = None, + lot_min: int | None = None, + lot_max: int | None = None, + year_built_min: int | None = None, + year_built_max: int | None = None, + hoa_max: int | None = None, + keywords: str | None = None, + days_on: str | None = None, + north: float | None = None, + south: float | None = None, + east: float | None = None, + west: float | None = None, + ) -> SearchResponse: + """Search Zillow for property listings. + + Args: + location: City/state, ZIP, address, or neighborhood ("Austin, TX"). + status: Listing status ("for_sale", "for_rent", "sold"). + Defaults to "for_sale". + page: Page number (1-20; Zillow caps search at ~820 results). + Defaults to 1. + sort: Sort order ("homes_for_you", "newest", "price_high_to_low", + "price_low_to_high", "bedrooms", "bathrooms", "square_feet", + "lot_size", "year_built"). + price_min: Minimum price filter. + price_max: Maximum price filter. + beds_min: Minimum number of bedrooms (0-10). + baths_min: Minimum number of bathrooms (0-10). + home_type: Home type filter ("houses", "condos", "townhomes", + "apartments", "manufactured", "lots", "multi_family"). + sqft_min: Minimum living area in sqft. + sqft_max: Maximum living area in sqft. + lot_min: Minimum lot size in sqft. + lot_max: Maximum lot size in sqft. + year_built_min: Minimum year built. + year_built_max: Maximum year built. + hoa_max: Maximum monthly HOA fee. + keywords: Match listing description keywords. + days_on: Days-on-Zillow filter (e.g. "1", "7", "30"). + north: Map-bounds north latitude (for tiling past the 820 cap). + south: Map-bounds south latitude. + east: Map-bounds east longitude. + west: Map-bounds west longitude. + + Returns: + Search response with matching listings, the resolved region, map + bounds (for tiling), and pagination metadata. + + Raises: + AuthenticationError: If the API key is invalid. + ValidationError: If the parameters are invalid. + + Example: + ```python + results = await client.zillow.search.search( + "Austin, TX", + price_min=300000, + price_max=600000, + beds_min=3, + sort="newest", + ) + print(f"{results.map_results_count} homes on the map") + ``` + """ + params: dict[str, Any] = { + "location": location, + "status": status, + "page": page, + "sort": sort, + "price_min": price_min, + "price_max": price_max, + "beds_min": beds_min, + "baths_min": baths_min, + "home_type": home_type, + "sqft_min": sqft_min, + "sqft_max": sqft_max, + "lot_min": lot_min, + "lot_max": lot_max, + "year_built_min": year_built_min, + "year_built_max": year_built_max, + "hoa_max": hoa_max, + "keywords": keywords, + "days_on": days_on, + "north": north, + "south": south, + "east": east, + "west": west, + } + response = await self._client.get("/v1/zillow/search", params=params) + return SearchResponse.model_validate(response) + + async def autocomplete(self, query: str) -> AutocompleteResponse: + """Resolve a search term to Zillow regions/addresses. + + Args: + query: Partial location — city, ZIP, address, or neighborhood. + + Returns: + Autocomplete response with region/address suggestions (each with a + regionId and lat/lng; a zpid when the suggestion is a specific home). + + Example: + ```python + result = await client.zillow.search.autocomplete("austin") + for r in result.results: + print(r.display, r.region_type) + ``` + """ + params: dict[str, Any] = {"query": query} + response = await self._client.get("/v1/zillow/autocomplete", params=params) + return AutocompleteResponse.model_validate(response) diff --git a/tests/test_zillow.py b/tests/test_zillow.py new file mode 100644 index 0000000..0d1b5e8 --- /dev/null +++ b/tests/test_zillow.py @@ -0,0 +1,130 @@ +"""Unit tests for Zillow SDK methods and models. + +Tests cover: +- TestZillowModels: Pydantic model construction and validation +- TestZillowClient: ZillowClient sub-client wiring +- TestRouting: endpoint routing (path + params) via a mocked HTTP client +- TestZillowImports: public API importability +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from scrapebadger.zillow.agent import AgentClient +from scrapebadger.zillow.client import ZillowClient +from scrapebadger.zillow.models import ( + Agent, + AutocompleteResponse, + MarketInfo, + MarketsResponse, + Property, + SearchResponse, +) +from scrapebadger.zillow.properties import PropertiesClient +from scrapebadger.zillow.reference import ReferenceClient +from scrapebadger.zillow.search import SearchClient + + +class TestZillowModels: + def test_search_response_from_payload(self) -> None: + r = SearchResponse.model_validate( + { + "location": "Austin, TX", + "status": "for_sale", + "results": [{"position": 1, "zpid": "1", "price": 500000}], + "map_results_count": 42, + } + ) + assert r.location == "Austin, TX" + assert r.results[0].zpid == "1" + assert r.map_results_count == 42 + + def test_property_requires_zpid(self) -> None: + with pytest.raises(ValueError): + Property.model_validate({}) + + def test_property_nested_home_facts(self) -> None: + p = Property.model_validate( + {"zpid": "9", "home_facts": {"has_garage": True, "heating": ["Forced Air"]}} + ) + assert p.home_facts is not None + assert p.home_facts.has_garage is True + assert p.home_facts.heating == ["Forced Air"] + + def test_agent_all_optional(self) -> None: + assert Agent.model_validate({}).name is None + + def test_market_info_required(self) -> None: + with pytest.raises(ValueError): + MarketInfo.model_validate({"code": "us"}) # missing required fields + + +class TestZillowClient: + def test_subclient_wiring(self) -> None: + c = ZillowClient(MagicMock()) + assert isinstance(c.search, SearchClient) + assert isinstance(c.properties, PropertiesClient) + assert isinstance(c.agents, AgentClient) + assert isinstance(c.reference, ReferenceClient) + + +class TestRouting: + @pytest.mark.asyncio + async def test_search_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"location": "Austin, TX", "results": []}) + out = await SearchClient(http).search("Austin, TX", beds_min=3, sort="newest") + path, kwargs = http.get.call_args[0][0], http.get.call_args[1] + assert path == "/v1/zillow/search" + assert kwargs["params"]["location"] == "Austin, TX" + assert kwargs["params"]["beds_min"] == 3 + assert kwargs["params"]["sort"] == "newest" + assert isinstance(out, SearchResponse) + + @pytest.mark.asyncio + async def test_autocomplete_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"query": "austin", "results": []}) + out = await SearchClient(http).autocomplete("austin") + assert http.get.call_args[0][0] == "/v1/zillow/autocomplete" + assert isinstance(out, AutocompleteResponse) + + @pytest.mark.asyncio + async def test_property_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"property": {"zpid": "42"}}) + out = await PropertiesClient(http).get_property("42") + assert http.get.call_args[0][0] == "/v1/zillow/property/42" + assert isinstance(out, Property) + assert out.zpid == "42" + + @pytest.mark.asyncio + async def test_agent_routes(self) -> None: + http = MagicMock() + http.get = AsyncMock(return_value={"agent": {"name": "Jane Doe"}}) + out = await AgentClient(http).get_agent(username="jane-doe") + path, kwargs = http.get.call_args[0][0], http.get.call_args[1] + assert path == "/v1/zillow/agent" + assert kwargs["params"]["username"] == "jane-doe" + assert isinstance(out, Agent) + assert out.name == "Jane Doe" + + @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/zillow/markets" + assert isinstance(out, MarketsResponse) + + +class TestZillowImports: + def test_public_api(self) -> None: + from scrapebadger import ZillowClient as ExportedClient + from scrapebadger import ZillowSearchResponse + + assert ExportedClient is ZillowClient + assert ZillowSearchResponse is SearchResponse diff --git a/uv.lock b/uv.lock index 54d38fb..b93880d 100644 --- a/uv.lock +++ b/uv.lock @@ -752,7 +752,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.15.5" +version = "0.15.6" source = { editable = "." } dependencies = [ { name = "httpx" },