diff --git a/CHANGELOG.md b/CHANGELOG.md index fb148dd..d3a499d 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.17.0] - 2026-07-08 + +### Added + +- **LoopNet sub-client** (`client.loopnet`) — commercial-real-estate (CoStar) listings, brokers, and reference data across loopnet.com/.ca/.co.uk/.fr/.es (US/CA/UK/FR/ES). Endpoints: `search.search()` (for-lease / for-sale / auctions, all property types, filters, pagination), `listings.get()`, `brokers.get()`, `reference.markets()`, `reference.property_types()`, with fully-typed maximal-coverage Pydantic models (`LoopnetSearchResponse`, `LoopnetListingDetail` incl. offers/facts/brokers/media, `LoopnetBrokerProfile`, `LoopnetListingCard`, etc.). LoopNet is behind Akamai Bot Manager (browser-farm-only) — served via the ScrapeBadger farm. (SCR-102) + ## [0.15.7] - 2026-07-07 ### Added diff --git a/README.md b/README.md index f6a997f..4dd8f3f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ export SCRAPEBADGER_API_KEY="sb_live_xxxxxxxxxxxxx" | **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) | +| **LoopNet** | 5 endpoints across loopnet.com/.ca/.co.uk/.fr/.es — commercial-real-estate search (for-lease/for-sale/auctions), listing detail, broker profile, markets, property types | [LoopNet Guide](docs/loopnet.md) | ## Error Handling diff --git a/pyproject.toml b/pyproject.toml index 1fceb36..51dea5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.16.0" +version = "0.17.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 3182390..d8e07a0 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -279,6 +279,46 @@ async def main(): from scrapebadger.leboncoin.models import ( StoreRatingReview as LeboncoinStoreRatingReview, ) +from scrapebadger.loopnet.client import LoopNetClient +from scrapebadger.loopnet.models import ( + Broker as LoopnetBroker, +) +from scrapebadger.loopnet.models import ( + BrokerProfile as LoopnetBrokerProfile, +) +from scrapebadger.loopnet.models import ( + BrokerResponse as LoopnetBrokerResponse, +) +from scrapebadger.loopnet.models import ( + ListingCard as LoopnetListingCard, +) +from scrapebadger.loopnet.models import ( + ListingDetail as LoopnetListingDetail, +) +from scrapebadger.loopnet.models import ( + ListingResponse as LoopnetListingResponse, +) +from scrapebadger.loopnet.models import ( + MarketInfo as LoopnetMarketInfo, +) +from scrapebadger.loopnet.models import ( + MarketsResponse as LoopnetMarketsResponse, +) +from scrapebadger.loopnet.models import ( + Pagination as LoopnetPagination, +) +from scrapebadger.loopnet.models import ( + PropertyTypeInfo as LoopnetPropertyTypeInfo, +) +from scrapebadger.loopnet.models import ( + PropertyTypesResponse as LoopnetPropertyTypesResponse, +) +from scrapebadger.loopnet.models import ( + SearchResponse as LoopnetSearchResponse, +) +from scrapebadger.loopnet.models import ( + Space as LoopnetSpace, +) from scrapebadger.realtor.client import RealtorClient from scrapebadger.realtor.models import ( Address as RealtorAddress, @@ -697,7 +737,7 @@ async def main(): ZestimateHistoryPoint as ZillowZestimateHistoryPoint, ) -__version__ = "0.16.0" +__version__ = "0.17.0" __all__ = [ # TikTok core models @@ -803,6 +843,21 @@ async def main(): "LeboncoinSellerResponse", "LeboncoinSimilarResponse", "LeboncoinStoreRatingReview", + # LoopNet + "LoopNetClient", + "LoopnetBroker", + "LoopnetBrokerProfile", + "LoopnetBrokerResponse", + "LoopnetListingCard", + "LoopnetListingDetail", + "LoopnetListingResponse", + "LoopnetMarketInfo", + "LoopnetMarketsResponse", + "LoopnetPagination", + "LoopnetPropertyTypeInfo", + "LoopnetPropertyTypesResponse", + "LoopnetSearchResponse", + "LoopnetSpace", "MarketInfo", "MarketsResponse", "NewReleasesResponse", diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index c5a9cad..ce2505f 100644 --- a/src/scrapebadger/client.py +++ b/src/scrapebadger/client.py @@ -14,6 +14,7 @@ from scrapebadger.google.client import GoogleClient from scrapebadger.immobiliare.client import ImmobiliareClient from scrapebadger.leboncoin.client import LeboncoinClient +from scrapebadger.loopnet.client import LoopNetClient from scrapebadger.realtor.client import RealtorClient from scrapebadger.reddit.client import RedditClient from scrapebadger.tiktok.client import TikTokClient @@ -132,6 +133,7 @@ def __init__( self._zillow: ZillowClient | None = None self._leboncoin: LeboncoinClient | None = None self._immobiliare: ImmobiliareClient | None = None + self._loopnet: LoopNetClient | None = None @property def config(self) -> ClientConfig: @@ -405,6 +407,27 @@ def immobiliare(self) -> ImmobiliareClient: self._immobiliare = ImmobiliareClient(self._base_client) return self._immobiliare + @property + def loopnet(self) -> LoopNetClient: + """Access LoopNet Scraper API operations. + + Returns: + LoopNetClient providing access to all LoopNet commercial-real-estate + endpoints (search, listing detail, broker profile, markets, property + types) across loopnet.com/.ca/.co.uk/.fr/.es (US/CA/UK/FR/ES). + + Example: + ```python + results = await client.loopnet.search.search("Houston, TX") + detail = await client.loopnet.listings.get("12345678") + broker = await client.loopnet.brokers.get("jane-doe", "w7x123") + markets = await client.loopnet.reference.markets() + ``` + """ + if self._loopnet is None: + self._loopnet = LoopNetClient(self._base_client) + return self._loopnet + @property def tiktok(self) -> TikTokClient: """Access TikTok Scraper API operations. diff --git a/src/scrapebadger/loopnet/__init__.py b/src/scrapebadger/loopnet/__init__.py new file mode 100644 index 0000000..0c67746 --- /dev/null +++ b/src/scrapebadger/loopnet/__init__.py @@ -0,0 +1,64 @@ +"""LoopNet API module for ScrapeBadger SDK. + +This module provides a comprehensive async client for scraping LoopNet +commercial-real-estate data through the ScrapeBadger API. 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 listings + results = await client.loopnet.search.search("Houston, TX") + for card in results.results: + print(f"{card.position}. {card.address}") + + # Get listing detail + detail = await client.loopnet.listings.get("12345678") + print(detail.listing.price_text) + + # Get a broker profile + broker = await client.loopnet.brokers.get("jane-doe", "w7x123") + print(broker.broker.name) + ``` +""" + +from scrapebadger.loopnet.client import LoopNetClient +from scrapebadger.loopnet.models import ( + Broker, + BrokerProfile, + BrokerResponse, + ListingCard, + ListingDetail, + ListingResponse, + MarketInfo, + MarketsResponse, + Pagination, + PropertyTypeInfo, + PropertyTypesResponse, + SearchResponse, + Space, +) + +__all__ = [ + # Brokers + "Broker", + "BrokerProfile", + "BrokerResponse", + # Search + "ListingCard", + # Listing detail + "ListingDetail", + "ListingResponse", + # Client + "LoopNetClient", + # Reference + "MarketInfo", + "MarketsResponse", + "Pagination", + "PropertyTypeInfo", + "PropertyTypesResponse", + "SearchResponse", + "Space", +] diff --git a/src/scrapebadger/loopnet/brokers.py b/src/scrapebadger/loopnet/brokers.py new file mode 100644 index 0000000..f5992f1 --- /dev/null +++ b/src/scrapebadger/loopnet/brokers.py @@ -0,0 +1,65 @@ +"""LoopNet Brokers API client. + +Provides a broker/professional's profile and their active listings. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.loopnet.models import BrokerResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class BrokersClient: + """Client for the LoopNet broker profile endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + profile = await client.loopnet.brokers.get("jane-doe", "w7x123") + print(profile.broker.name, profile.broker.company) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize brokers client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get( + self, + slug: str, + broker_id: str, + *, + market: str = "us", + ) -> BrokerResponse: + """Get a LoopNet broker's profile and their listings. Costs 8 credits. + + Args: + slug: The broker's URL slug (e.g. "jane-doe"). + broker_id: The LoopNet broker id. + market: Coverage market ("us", "ca", "uk", "fr", "es"). Defaults to "us". + + Returns: + Broker profile response with the broker and their listings. + + Raises: + NotFoundError: If the broker doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + profile = await client.loopnet.brokers.get("jane-doe", "w7x123") + for card in profile.broker.listings: + print(card.address) + ``` + """ + params: dict[str, Any] = {"market": market} + response = await self._client.get(f"/v1/loopnet/brokers/{slug}/{broker_id}", params=params) + return BrokerResponse.model_validate(response) diff --git a/src/scrapebadger/loopnet/client.py b/src/scrapebadger/loopnet/client.py new file mode 100644 index 0000000..e6eeed4 --- /dev/null +++ b/src/scrapebadger/loopnet/client.py @@ -0,0 +1,127 @@ +"""LoopNet API client combining all sub-clients. + +This module provides the main LoopNetClient class that serves as the +entry point for all LoopNet API operations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.loopnet.brokers import BrokersClient +from scrapebadger.loopnet.listings import ListingsClient +from scrapebadger.loopnet.reference import ReferenceClient +from scrapebadger.loopnet.search import SearchClient + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class LoopNetClient: + """Client for all LoopNet API operations. + + This class provides access to all LoopNet commercial-real-estate scraping + endpoints through organized sub-clients for different resource types. + + Attributes: + search: Client for listing search (for-lease / for-sale / auctions). + listings: Client for listing detail (by listing id). + brokers: Client for broker profile + listings. + reference: Client for reference data (markets, property types). + + Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search listings + results = await client.loopnet.search.search("Houston, TX") + for card in results.results: + print(f"{card.position}. {card.address}") + + # Get listing detail + detail = await client.loopnet.listings.get("12345678") + print(detail.listing.price_text) + + # Get a broker profile + broker = await client.loopnet.brokers.get("jane-doe", "w7x123") + + # Reference data + markets = await client.loopnet.reference.markets() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `loopnet` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize LoopNet 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._listings = ListingsClient(client) + self._brokers = BrokersClient(client) + self._reference = ReferenceClient(client) + + @property + def search(self) -> SearchClient: + """Access the listing search endpoint. + + Returns: + SearchClient for for-lease / for-sale / auction listing search. + + Example: + ```python + results = await client.loopnet.search.search("Houston, TX") + ``` + """ + return self._search + + @property + def listings(self) -> ListingsClient: + """Access the listing detail endpoint. + + Returns: + ListingsClient for fetching a listing by its id. + + Example: + ```python + detail = await client.loopnet.listings.get("12345678") + ``` + """ + return self._listings + + @property + def brokers(self) -> BrokersClient: + """Access the broker profile endpoint. + + Returns: + BrokersClient for fetching a broker's profile and listings. + + Example: + ```python + profile = await client.loopnet.brokers.get("jane-doe", "w7x123") + ``` + """ + return self._brokers + + @property + def reference(self) -> ReferenceClient: + """Access reference data endpoints. + + Returns: + ReferenceClient for coverage markets and property types. + + Example: + ```python + markets = await client.loopnet.reference.markets() + types = await client.loopnet.reference.property_types() + ``` + """ + return self._reference diff --git a/src/scrapebadger/loopnet/listings.py b/src/scrapebadger/loopnet/listings.py new file mode 100644 index 0000000..73cc683 --- /dev/null +++ b/src/scrapebadger/loopnet/listings.py @@ -0,0 +1,58 @@ +"""LoopNet Listings API client. + +Provides full listing detail lookup by listing id. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.loopnet.models import ListingResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ListingsClient: + """Client for the LoopNet listing detail endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + detail = await client.loopnet.listings.get("12345678") + print(detail.listing.address, detail.listing.price_text) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize listings client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get(self, listing_id: str, *, market: str = "us") -> ListingResponse: + """Get a single LoopNet listing's full detail. Costs 12 credits. + + Args: + listing_id: The LoopNet listing id. + market: Coverage market ("us", "ca", "uk", "fr", "es"). Defaults to "us". + + Returns: + Listing detail response. + + Raises: + NotFoundError: If the listing doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + detail = await client.loopnet.listings.get("12345678") + for space in detail.listing.spaces: + print(space.name, space.rent_text) + ``` + """ + params: dict[str, Any] = {"market": market} + response = await self._client.get(f"/v1/loopnet/listings/{listing_id}", params=params) + return ListingResponse.model_validate(response) diff --git a/src/scrapebadger/loopnet/models.py b/src/scrapebadger/loopnet/models.py new file mode 100644 index 0000000..1425cef --- /dev/null +++ b/src/scrapebadger/loopnet/models.py @@ -0,0 +1,306 @@ +"""Pydantic models for LoopNet API responses. + +These models mirror the backend ``loopnet_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). + +LoopNet is multi-market (us/ca/uk/fr/es); every search response carries +``market`` + ``currency`` so a caller can tell CAD from GBP. Prices ship both +raw text (``price_text``) and parsed numeric (``price``) because CRE pricing +is heterogeneous ($/SF/YR, $/AC, total, "Contact"). +""" + +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 Models +# ============================================================================= + + +class Broker(_BaseModel): + """A LoopNet broker/agent attributed to a listing or profile.""" + + name: str | None = None + company: str | None = None + title: str | None = None + phone: str | None = None + email: str | None = None + photo: str | None = None + url: str | None = None + broker_id: str | None = None + city: str | None = None + region: str | None = None + + +class Space(_BaseModel): + """One leasable space / unit within a listing (lease listings).""" + + name: str | None = None + space_use: str | None = None + size_sqft: int | None = None + size_text: str | None = None + rent_text: str | None = None + rent_per_sqft: float | None = None + rent_period: str | None = None + term: str | None = None + condition: str | None = None + available_date: str | None = None + floor: str | None = None + + +class MarketInfo(_BaseModel): + """A supported coverage market (for /markets).""" + + code: str + domain: str + country: str + currency: str + locale: str + name: str + + +class PropertyTypeInfo(_BaseModel): + """A LoopNet property-type facet (for /property-types).""" + + slug: str + name: str + + +class Pagination(_BaseModel): + """Page-number pagination (LoopNet returns ~25 cards per page, caps ~500).""" + + current_page: int = 1 + per_page: int | None = None + total_pages: int | None = None + total_results: int | None = None + + +# ============================================================================= +# Search results +# ============================================================================= + + +class ListingCard(_BaseModel): + """One LoopNet search result card (search / broker listings).""" + + position: int + listing_id: str | None = None + property_id: str | None = None + url: str | None = None + # Taxonomy + listing_type: str | None = None + property_type: str | None = None + property_type_id: str | None = None + space_use: str | None = None + status: str | None = None + status_id: str | None = None + exposure_level: str | None = None + is_auction: bool | None = None + # Content + title: str | None = None + subtitle: str | None = None + description: str | None = None + building_rating: float | None = None + year_built: int | None = None + # Price + price_text: str | None = None + price: float | None = None + price_currency: str | None = None + price_period: str | None = None + # Size + size_text: str | None = None + size_min_sqft: int | None = None + size_max_sqft: int | None = None + # Address + address: str | None = None + city: str | None = None + state: str | None = None + zip: str | None = None + county: str | None = None + country: str | None = None + market_id: str | None = None + latitude: float | None = None + longitude: float | None = None + # Media / meta + thumbnail: str | None = None + has_virtual_tour: bool | None = None + page_rank: int | None = None + position_rank: int | None = None + brokers: list[Broker] = Field(default_factory=list) + + +# ============================================================================= +# Listing detail +# ============================================================================= + + +class ListingDetail(_BaseModel): + """Full LoopNet listing detail (JSON-LD RealEstateListing + DOM facts).""" + + # Identity + listing_id: str | None = None + property_id: str | None = None + url: str | None = None + market: str | None = None + country: str | None = None + listing_type: str | None = None + transaction_type: str | None = None + # Content + name: str | None = None + title: str | None = None + subtitle: str | None = None + description: str | None = None + highlights: list[str] = Field(default_factory=list) + # Price + price_text: str | None = None + price: float | None = None + price_currency: str | None = None + price_period: str | None = None + rental_rate_text: str | None = None + cap_rate: float | None = None + noi: str | None = None + price_per_sqft: float | None = None + # Building / property facts + property_type: str | None = None + property_sub_type: str | None = None + building_class: str | None = None + building_size_sqft: int | None = None + building_size_text: str | None = None + rentable_building_area: str | None = None + total_space_available: str | None = None + total_space_available_sqft: int | None = None + min_divisible: str | None = None + max_contiguous: str | None = None + typical_floor_size: str | None = None + building_height: str | None = None + ceiling_height: str | None = None + year_built: int | None = None + year_built_renovated: str | None = None + building_rating: float | None = None + lot_size_text: str | None = None + lot_size_acres: float | None = None + units: int | None = None + stories: int | None = None + percent_leased: str | None = None + tenancy: str | None = None + zoning: str | None = None + parcel_id: str | None = None + parking: str | None = None + walk_score: int | None = None + amenities: list[str] = Field(default_factory=list) + # Address / geo + address: str | None = None + city: str | None = None + state: str | None = None + zip: str | None = None + county: str | None = None + latitude: float | None = None + longitude: float | None = None + # Media + images: list[str] = Field(default_factory=list) + photo_count: int | None = None + videos: list[str] = Field(default_factory=list) + documents: list[str] = Field(default_factory=list) + has_virtual_tour: bool | None = None + # Spaces (lease) + brokers + spaces: list[Space] = Field(default_factory=list) + brokers: list[Broker] = Field(default_factory=list) + # Every additionalProperty name/value pair LoopNet ships, verbatim + additional_facts: list[dict[str, str]] = Field(default_factory=list) + # Timing + date_posted_utc: float | None = None + date_posted_at: str | None = None + date_updated_utc: float | None = None + date_updated_at: str | None = None + scraped_utc: float | None = None + scraped_at: str | None = None + + +# ============================================================================= +# Broker profile +# ============================================================================= + + +class BrokerProfile(_BaseModel): + """A LoopNet broker/professional profile with their listings.""" + + broker_id: str | None = None + name: str | None = None + company: str | None = None + title: str | None = None + phone: str | None = None + email: str | None = None + photo: str | None = None + url: str | None = None + bio: str | None = None + address: str | None = None + city: str | None = None + state: str | None = None + license_number: str | None = None + specialties: list[str] = Field(default_factory=list) + listing_count: int | None = None + listings: list[ListingCard] = Field(default_factory=list) + scraped_utc: float | None = None + scraped_at: str | None = None + + +# ============================================================================= +# Response envelopes +# ============================================================================= + + +class SearchResponse(_BaseModel): + """Response for /search.""" + + market: str + country: str + currency: str + listing_type: str + property_type: str | None = None + location: str | None = None + results: list[ListingCard] = Field(default_factory=list) + pagination: Pagination = Field(default_factory=Pagination) + scraped_utc: float | None = None + scraped_at: str | None = None + + +class ListingResponse(_BaseModel): + """Response for /listings/{listing_id}.""" + + listing: ListingDetail + + +class BrokerResponse(_BaseModel): + """Response for /brokers/{slug}/{broker_id}.""" + + broker: BrokerProfile + + +class MarketsResponse(_BaseModel): + """Response for /markets.""" + + markets: list[MarketInfo] = Field(default_factory=list) + + +class PropertyTypesResponse(_BaseModel): + """Response for /property-types.""" + + property_types: list[PropertyTypeInfo] = Field(default_factory=list) diff --git a/src/scrapebadger/loopnet/reference.py b/src/scrapebadger/loopnet/reference.py new file mode 100644 index 0000000..9656029 --- /dev/null +++ b/src/scrapebadger/loopnet/reference.py @@ -0,0 +1,70 @@ +"""LoopNet Reference Data API client. + +Provides the static coverage-market and property-type lists. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.loopnet.models import MarketsResponse, PropertyTypesResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ReferenceClient: + """Client for LoopNet reference endpoints (markets, property types). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + markets = await client.loopnet.reference.markets() + for m in markets.markets: + print(f"{m.code}: {m.name} ({m.currency})") + + types = await client.loopnet.reference.property_types() + for t in types.property_types: + print(t.slug, t.name) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize reference client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def markets(self) -> MarketsResponse: + """List LoopNet coverage markets (us/ca/uk/fr/es). Free (0 credits). + + Returns: + Markets response with all supported coverage markets. + + Example: + ```python + result = await client.loopnet.reference.markets() + for m in result.markets: + print(f"{m.code}: {m.name} ({m.domain})") + ``` + """ + response = await self._client.get("/v1/loopnet/markets") + return MarketsResponse.model_validate(response) + + async def property_types(self) -> PropertyTypesResponse: + """List LoopNet property-type facets. Free (0 credits). + + Returns: + Property-types response with all searchable property-type slugs. + + Example: + ```python + result = await client.loopnet.reference.property_types() + for t in result.property_types: + print(f"{t.slug}: {t.name}") + ``` + """ + response = await self._client.get("/v1/loopnet/property-types") + return PropertyTypesResponse.model_validate(response) diff --git a/src/scrapebadger/loopnet/search.py b/src/scrapebadger/loopnet/search.py new file mode 100644 index 0000000..eb6d10b --- /dev/null +++ b/src/scrapebadger/loopnet/search.py @@ -0,0 +1,98 @@ +"""LoopNet Search API client. + +Provides commercial-real-estate listing search for for-lease / for-sale / +auction listings across 5 markets (us/ca/uk/fr/es). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.loopnet.models import SearchResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class SearchClient: + """Client for the LoopNet listing search endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + results = await client.loopnet.search.search("Houston, TX") + for card in results.results: + print(f"{card.position}. {card.address} — {card.price_text}") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize search client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def search( + self, + location: str, + *, + market: str = "us", + listing_type: str = "for-lease", + property_type: str | None = None, + page: int = 1, + min_price: float | None = None, + max_price: float | None = None, + price_type: str | None = None, + min_size: int | None = None, + max_size: int | None = None, + ) -> SearchResponse: + """Search LoopNet for commercial listings. Costs 10 credits. + + Args: + location: City/state ("Houston, TX"), ZIP, state code, or "usa" (required). + market: Coverage market ("us", "ca", "uk", "fr", "es"). Defaults to "us". + listing_type: Listing type ("for-lease", "for-sale", "auctions"). + Defaults to "for-lease". + property_type: Property-type slug (from ``reference.property_types()``). + Defaults to all types. + page: Page number (1-20; LoopNet caps ~500 results). Defaults to 1. + min_price: Minimum price filter. + max_price: Maximum price filter. + price_type: Price basis for the price filters ("unit", "sf", "acre"). + min_size: Minimum size (square feet). + max_size: Maximum size (square feet). + + Returns: + Search response with matching listing cards and pagination. + + Raises: + AuthenticationError: If the API key is invalid. + ValidationError: If the parameters are invalid. + + Example: + ```python + results = await client.loopnet.search.search( + "Houston, TX", + listing_type="for-sale", + property_type="office", + max_price=5000000, + ) + print(f"Page {results.pagination.current_page}") + ``` + """ + params: dict[str, Any] = { + "location": location, + "market": market, + "listing_type": listing_type, + "property_type": property_type, + "page": page, + "min_price": min_price, + "max_price": max_price, + "price_type": price_type, + "min_size": min_size, + "max_size": max_size, + } + response = await self._client.get("/v1/loopnet/search", params=params) + return SearchResponse.model_validate(response) diff --git a/uv.lock b/uv.lock index 8e64910..42cfabc 100644 --- a/uv.lock +++ b/uv.lock @@ -752,7 +752,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.16.0" +version = "0.17.0" source = { editable = "." } dependencies = [ { name = "httpx" },