From 2463ecb2d79c506205f563ee49fb30f049ff15b7 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:34:08 +0300 Subject: [PATCH] feat(zillow): add Zillow dedicated scraper API (SCR-99) --- package.json | 2 +- src/client.ts | 5 + src/index.ts | 1 + src/zillow/agent.ts | 43 +++ src/zillow/client.ts | 69 ++++ src/zillow/index.ts | 57 +++ src/zillow/properties.ts | 41 +++ src/zillow/reference.ts | 38 ++ src/zillow/search.ts | 85 +++++ src/zillow/types.ts | 752 +++++++++++++++++++++++++++++++++++++++ tests/zillow.test.ts | 95 +++++ 11 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 src/zillow/agent.ts create mode 100644 src/zillow/client.ts create mode 100644 src/zillow/index.ts create mode 100644 src/zillow/properties.ts create mode 100644 src/zillow/reference.ts create mode 100644 src/zillow/search.ts create mode 100644 src/zillow/types.ts create mode 100644 tests/zillow.test.ts diff --git a/package.json b/package.json index f9509eb..437411b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrapebadger", - "version": "0.15.4", + "version": "0.15.5", "description": "Official Node.js SDK for ScrapeBadger - Async web scraping APIs for Twitter, Google, Vinted, Reddit, and more", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 016265a..a229d51 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,6 +17,7 @@ import { TikTokClient } from "./tiktok/client.js"; import { EbayClient } from "./ebay/client.js"; import { YoutubeClient } from "./youtube/client.js"; import { RealtorClient } from "./realtor/client.js"; +import { ZillowClient } from "./zillow/client.js"; /** * ScrapeBadger API client. @@ -86,6 +87,9 @@ export class ScrapeBadger { /** Realtor scraper API client — 4 endpoints across 2 markets (us, ca) */ readonly realtor: RealtorClient; + /** Zillow scraper API client — 5 endpoints (search, property, agent, autocomplete, markets) */ + readonly zillow: ZillowClient; + /** * Create a new ScrapeBadger client. * @@ -136,5 +140,6 @@ export class ScrapeBadger { this.ebay = new EbayClient(this.baseClient); this.youtube = new YoutubeClient(this.baseClient); this.realtor = new RealtorClient(this.baseClient); + this.zillow = new ZillowClient(this.baseClient); } } diff --git a/src/index.ts b/src/index.ts index 21dd651..a1175e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,7 @@ export * from "./ebay/index.js"; // Re-export YouTube module — YoutubeClient + Youtube*-prefixed sub-clients and types export * from "./youtube/index.js"; export * from "./realtor/index.js"; +export * from "./zillow/index.js"; // Re-export Google module — GoogleClient is exposed directly; sub-clients // are aliased with a Google* prefix to avoid collisions with other modules diff --git a/src/zillow/agent.ts b/src/zillow/agent.ts new file mode 100644 index 0000000..22b6920 --- /dev/null +++ b/src/zillow/agent.ts @@ -0,0 +1,43 @@ +/** + * Zillow Agent API client. + * + * Provides a method for fetching an agent profile and their listings. + */ + +import type { BaseClient } from "../internal/client.js"; +import type { ZillowAgentOptions, AgentResponse } from "./types.js"; + +/** + * Client for the zillow agent profile endpoint. + * + * @example + * ```typescript + * const client = new ScrapeBadger({ apiKey: "key" }); + * + * const { agent } = await client.zillow.agent.getAgent({ username: "some-agent" }); + * console.log(agent.name, agent.review_count, agent.listings.length); + * ``` + */ +export class AgentClient { + private readonly client: BaseClient; + + constructor(client: BaseClient) { + this.client = client; + } + + /** + * Get a Zillow real-estate professional's profile and their active listings. + * + * Provide either a `username` (screen name) or a full `url`. + * + * @param options - The agent `username` or profile `url`. + * @returns Agent profile wrapped in `{ agent }`. + * @throws NotFoundError - If the agent doesn't exist. + * @throws ValidationError - If neither username nor url is provided. + */ + async getAgent(options: ZillowAgentOptions = {}): Promise { + return this.client.request("/v1/zillow/agent", { + params: { username: options.username, url: options.url }, + }); + } +} diff --git a/src/zillow/client.ts b/src/zillow/client.ts new file mode 100644 index 0000000..b30e1aa --- /dev/null +++ b/src/zillow/client.ts @@ -0,0 +1,69 @@ +/** + * Zillow API client. + * + * Provides access to all Zillow API endpoints through specialized sub-clients. + */ + +import type { BaseClient } from "../internal/client.js"; +import { SearchClient } from "./search.js"; +import { PropertiesClient } from "./properties.js"; +import { AgentClient } from "./agent.js"; +import { ReferenceClient } from "./reference.js"; + +/** + * Zillow API client with access to all Zillow endpoints. + * + * Maximal-coverage real-estate API over zillow.com (US + Canadian inventory, + * all served from zillow.com behind a US IP). + * + * Provides sub-clients for different resource types: + * - `search` - Property search and location autocomplete + * - `properties` - Full property detail + * - `agent` - Agent profile + their listings + * - `reference` - Reference data (markets) + * + * @example + * ```typescript + * const client = new ScrapeBadger({ apiKey: "key" }); + * + * // Search properties + * const results = await client.zillow.search.search("Austin, TX"); + * + * // Location autocomplete + * const suggestions = await client.zillow.search.autocomplete("Miami"); + * + * // Get property detail + * const { property } = await client.zillow.properties.getProperty("2078029085"); + * + * // Get an agent profile + * const { agent } = await client.zillow.agent.getAgent({ username: "some-agent" }); + * + * // Reference data + * const markets = await client.zillow.reference.listMarkets(); + * ``` + */ +export class ZillowClient { + /** Client for property search and location autocomplete */ + readonly search: SearchClient; + + /** Client for full property detail */ + readonly properties: PropertiesClient; + + /** Client for agent profile + listings */ + readonly agent: AgentClient; + + /** Client for reference data (markets) */ + readonly reference: ReferenceClient; + + /** + * Create a new Zillow client. + * + * @param client - The base HTTP client for making requests. + */ + constructor(client: BaseClient) { + this.search = new SearchClient(client); + this.properties = new PropertiesClient(client); + this.agent = new AgentClient(client); + this.reference = new ReferenceClient(client); + } +} diff --git a/src/zillow/index.ts b/src/zillow/index.ts new file mode 100644 index 0000000..52ecae2 --- /dev/null +++ b/src/zillow/index.ts @@ -0,0 +1,57 @@ +/** + * Zillow API module. + * + * @module zillow + */ + +export { ZillowClient } from "./client.js"; +export { SearchClient as ZillowSearchClient } from "./search.js"; +export { PropertiesClient as ZillowPropertiesClient } from "./properties.js"; +export { AgentClient as ZillowAgentClient } from "./agent.js"; +export { ReferenceClient as ZillowReferenceClient } from "./reference.js"; + +// Export all types +export type { + // Shared + LatLong as ZillowLatLong, + Photo as ZillowPhoto, + Pagination as ZillowPagination, + MapBounds as ZillowMapBounds, + RegionSelection as ZillowRegionSelection, + NearbyRegion as ZillowNearbyRegion, + MarketInfo as ZillowMarketInfo, + // Search results + Listing as ZillowListing, + // Property detail + Address as ZillowAddress, + ListingSubType as ZillowListingSubType, + OpenHouse as ZillowOpenHouse, + ZestimateHistoryPoint as ZillowZestimateHistoryPoint, + PriceHistoryEvent as ZillowPriceHistoryEvent, + TaxHistoryEvent as ZillowTaxHistoryEvent, + School as ZillowSchool, + AgentAttribution as ZillowAgentAttribution, + MortgageRate as ZillowMortgageRate, + MortgageRates as ZillowMortgageRates, + HomeFacts as ZillowHomeFacts, + Property as ZillowProperty, + // Agent profile + AgentReview as ZillowAgentReview, + PastSale as ZillowPastSale, + AgentLicense as ZillowAgentLicense, + Agent as ZillowAgent, + // Autocomplete + AutocompleteResult as ZillowAutocompleteResult, + // Response envelopes + SearchResponse as ZillowSearchResponse, + PropertyResponse as ZillowPropertyResponse, + AgentResponse as ZillowAgentResponse, + AutocompleteResponse as ZillowAutocompleteResponse, + MarketsResponse as ZillowMarketsResponse, + // Param enums + ZillowStatus, + ZillowSort, + // Request params + ZillowSearchOptions, + ZillowAgentOptions, +} from "./types.js"; diff --git a/src/zillow/properties.ts b/src/zillow/properties.ts new file mode 100644 index 0000000..6b8ca03 --- /dev/null +++ b/src/zillow/properties.ts @@ -0,0 +1,41 @@ +/** + * Zillow Properties API client. + * + * Provides a method for fetching full property detail. + */ + +import type { BaseClient } from "../internal/client.js"; +import type { PropertyResponse } from "./types.js"; + +/** + * Client for the zillow property detail endpoint. + * + * @example + * ```typescript + * const client = new ScrapeBadger({ apiKey: "key" }); + * + * const { property } = await client.zillow.properties.getProperty("2078029085"); + * console.log(property.street_address, property.price); + * ``` + */ +export class PropertiesClient { + private readonly client: BaseClient; + + constructor(client: BaseClient) { + this.client = client; + } + + /** + * Get a single Zillow property's full detail by its zpid. + * + * Returns price/valuation, specs, resoFacts (home_facts), price & tax + * history, schools, listing agent, mortgage rates and photos. + * + * @param zpid - The Zillow property id. + * @returns Property detail wrapped in `{ property }`. + * @throws NotFoundError - If the property doesn't exist. + */ + async getProperty(zpid: string): Promise { + return this.client.request(`/v1/zillow/property/${zpid}`); + } +} diff --git a/src/zillow/reference.ts b/src/zillow/reference.ts new file mode 100644 index 0000000..3f99a0c --- /dev/null +++ b/src/zillow/reference.ts @@ -0,0 +1,38 @@ +/** + * Zillow Reference Data API client. + * + * Provides a method for fetching the coverage market list. + */ + +import type { BaseClient } from "../internal/client.js"; +import type { MarketsResponse } from "./types.js"; + +/** + * Client for zillow reference data endpoints (markets). + * + * @example + * ```typescript + * const client = new ScrapeBadger({ apiKey: "key" }); + * + * const markets = await client.zillow.reference.listMarkets(); + * for (const m of markets.markets) { + * console.log(`${m.code}: ${m.domain} (${m.currency})`); + * } + * ``` + */ +export class ReferenceClient { + private readonly client: BaseClient; + + constructor(client: BaseClient) { + this.client = client; + } + + /** + * List Zillow coverage regions (US + Canada, all served via zillow.com). + * + * @returns Markets response with all supported markets. + */ + async listMarkets(): Promise { + return this.client.request("/v1/zillow/markets"); + } +} diff --git a/src/zillow/search.ts b/src/zillow/search.ts new file mode 100644 index 0000000..63a24b9 --- /dev/null +++ b/src/zillow/search.ts @@ -0,0 +1,85 @@ +/** + * Zillow Search API client. + * + * Provides methods for property search and location autocomplete. + */ + +import type { BaseClient } from "../internal/client.js"; +import type { ZillowSearchOptions, SearchResponse, AutocompleteResponse } from "./types.js"; + +/** + * Client for zillow search and autocomplete endpoints. + * + * @example + * ```typescript + * const client = new ScrapeBadger({ apiKey: "key" }); + * + * const results = await client.zillow.search.search("Austin, TX", { bedsMin: 3 }); + * for (const l of results.results) { + * console.log(`${l.address} — ${l.price_raw}`); + * } + * + * const suggestions = await client.zillow.search.autocomplete("Miami"); + * ``` + */ +export class SearchClient { + private readonly client: BaseClient; + + constructor(client: BaseClient) { + this.client = client; + } + + /** + * Search Zillow for properties. + * + * Beat the ~820-result (20-page) cap by re-issuing the search over + * subdivided `north/south/east/west` map-bound boxes returned in + * `map_bounds`. + * + * @param location - City/state, ZIP, address, or neighborhood ("Austin, TX"). + * @param options - Filters, sorting, pagination, and map bounds. + * @returns Search results with pagination and map metadata. + * @throws AuthenticationError - If the API key is invalid. + * @throws ValidationError - If the parameters are invalid. + */ + async search(location: string, options: ZillowSearchOptions = {}): Promise { + return this.client.request("/v1/zillow/search", { + params: { + location, + status: options.status, + page: options.page, + sort: options.sort, + price_min: options.priceMin, + price_max: options.priceMax, + beds_min: options.bedsMin, + baths_min: options.bathsMin, + home_type: options.homeType, + sqft_min: options.sqftMin, + sqft_max: options.sqftMax, + lot_min: options.lotMin, + lot_max: options.lotMax, + year_built_min: options.yearBuiltMin, + year_built_max: options.yearBuiltMax, + hoa_max: options.hoaMax, + keywords: options.keywords, + days_on: options.daysOn, + north: options.north, + south: options.south, + east: options.east, + west: options.west, + }, + }); + } + + /** + * Resolve a search term to Zillow regions/addresses (regionId, lat/lng). + * + * @param query - Partial location — city, ZIP, address, or neighborhood. + * @returns Autocomplete suggestions. + */ + async autocomplete(query: string): Promise { + return this.client.request("/v1/zillow/autocomplete", { + params: { query }, + }); + } +} diff --git a/src/zillow/types.ts b/src/zillow/types.ts new file mode 100644 index 0000000..d8138d0 --- /dev/null +++ b/src/zillow/types.ts @@ -0,0 +1,752 @@ +/** + * TypeScript types for Zillow API responses. + * + * These interfaces mirror the backend `zillow_scraper` response schema + * field-for-field. Backend keys are snake_case (e.g. `home_status`, + * `price_history`, `home_facts`, `scraped_at`) and are kept snake_case here to + * match the JSON exactly. Most model fields are optional and nullable + * (`field?: type | null`); list fields default to `[]` on the backend and are + * typed as arrays. Every datetime field ships in BOTH `*_utc` (Unix float + * seconds) and `*_at` (ISO-8601 Z string) form. + * + * 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. + */ + +// ============================================================================= +// Shared Types +// ============================================================================= + +/** A lat/lon coordinate pair. */ +export interface LatLong { + latitude?: number | null; + longitude?: number | null; +} + +/** A single listing photo with its responsive source variants. */ +export interface Photo { + url?: string | null; + caption?: string | null; + subject_type?: string | null; + /** Responsive variants: [{ url, width, format: "jpeg" | "webp" }] */ + sources?: Record[]; +} + +/** Page-number pagination (Zillow search returns ~40 results per page). */ +export interface Pagination { + current_page?: number; + per_page?: number | null; + total_pages?: number | null; + total_results?: number | null; +} + +/** + * The map bounding box a search covers — callers tile with this to beat + * Zillow's ~820-result (20-page) cap by subdividing dense boxes. + */ +export interface MapBounds { + north?: number | null; + east?: number | null; + south?: number | null; + west?: number | null; +} + +/** The numeric region Zillow resolved a search location to. */ +export interface RegionSelection { + region_id?: number | null; + region_type?: number | null; +} + +/** A linked nearby region (city / neighborhood / zip) on a property page. */ +export interface NearbyRegion { + name?: string | null; + region_type?: string | null; + url?: string | null; +} + +/** A supported coverage region (for /markets). */ +export interface MarketInfo { + code: string; + country: string; + currency: string; + locale: string; + name: string; + domain: string; +} + +// ============================================================================= +// Search results +// ============================================================================= + +/** + * One Zillow search card (search / agent listings). + * + * Merges the search `listResult` top-level with its richer + * `hdpData.homeInfo` sub-object. + */ +export interface Listing { + position: number; + zpid?: string | null; + id?: string | null; + detail_url?: string | null; + // Status / type + home_type?: string | null; + home_status?: string | null; + status_text?: string | null; + status_type?: string | null; + marketing_status?: string | null; + contingent_listing_type?: string | null; + // Price / valuation + price?: number | null; + price_raw?: string | null; + currency?: string | null; + price_change?: number | null; + date_price_changed_utc?: number | null; + date_price_changed_at?: string | null; + price_reduction?: string | null; + flex_field_text?: string | null; + zestimate?: number | null; + rent_zestimate?: number | null; + tax_assessed_value?: number | null; + // Specs + beds?: number | null; + baths?: number | null; + living_area?: number | null; + lot_area_value?: number | null; + lot_area_unit?: string | null; + // Address + address?: string | null; + street_address?: string | null; + unit?: string | null; + city?: string | null; + state?: string | null; + zipcode?: string | null; + country?: string | null; + is_undisclosed_address?: boolean | null; + latitude?: number | null; + longitude?: number | null; + // Listing meta + broker_name?: string | null; + provider_listing_id?: string | null; + days_on_zillow?: number | null; + is_zillow_owned?: boolean | null; + is_featured?: boolean | null; + is_showcase?: boolean | null; + is_fsba?: boolean | null; + is_new_construction?: boolean | null; + is_premier_builder?: boolean | null; + is_preforeclosure_auction?: boolean | null; + is_non_owner_occupied?: boolean | null; + // Media + img_src?: string | null; + has_image?: boolean | null; + has_video?: boolean | null; + has_3d_model?: boolean | null; + has_open_house?: boolean | null; + open_house_start?: string | null; + open_house_end?: string | null; + photos?: string[]; +} + +// ============================================================================= +// Property detail nested +// ============================================================================= + +/** A property's structured address. */ +export interface Address { + street_address?: string | null; + city?: string | null; + state?: string | null; + zipcode?: string | null; + community?: string | null; + subdivision?: string | null; + neighborhood?: string | null; +} + +/** `listingSubType` flags — the for-sale / foreclosure / auction taxonomy. */ +export interface ListingSubType { + is_fsba?: boolean | null; + is_fsbo?: boolean | null; + is_foreclosure?: boolean | null; + is_bank_owned?: boolean | null; + is_for_auction?: boolean | null; + is_coming_soon?: boolean | null; + is_new_home?: boolean | null; + is_pending?: boolean | null; +} + +/** A scheduled open house. */ +export interface OpenHouse { + start_utc?: number | null; + start_at?: string | null; + end_utc?: number | null; + end_at?: string | null; + note?: string | null; +} + +/** One point in the Zestimate value history series (`homeValueChartData`). */ +export interface ZestimateHistoryPoint { + date?: string | null; + date_utc?: number | null; + date_at?: string | null; + value?: number | null; +} + +/** A single price-history event (listed, price change, sold, etc.). */ +export interface PriceHistoryEvent { + date?: string | null; + date_utc?: number | null; + date_at?: string | null; + event?: string | null; + price?: number | null; + price_per_square_foot?: number | null; + price_change_rate?: number | null; + source?: string | null; + buyer_agent?: string | null; + seller_agent?: string | null; + posting_is_rental?: boolean | null; +} + +/** A single year's tax + assessment record. */ +export interface TaxHistoryEvent { + year_utc?: number | null; + year_at?: string | null; + value?: number | null; + value_increase_rate?: number | null; + tax_paid?: number | null; + tax_increase_rate?: number | null; +} + +/** A nearby / assigned school. */ +export interface School { + name?: string | null; + rating?: number | null; + grades?: string | null; + level?: string | null; + type?: string | null; + distance?: number | null; + link?: string | null; + student_count?: number | null; + assigned?: boolean | null; +} + +/** Listing agent / broker attribution (from `attributionInfo`). */ +export interface AgentAttribution { + agent_name?: string | null; + agent_phone?: string | null; + agent_email?: string | null; + agent_license_number?: string | null; + co_agent_name?: string | null; + co_agent_number?: string | null; + co_agent_license_number?: string | null; + broker_name?: string | null; + broker_phone?: string | null; + buyer_agent_name?: string | null; + buyer_brokerage_name?: string | null; + mls_id?: string | null; + mls_name?: string | null; + mls_disclaimer?: string | null; + listing_agreement?: string | null; + listing_attribution_contact?: string | null; + provider_logo?: string | null; + true_status?: string | null; + last_checked?: string | null; + last_updated?: string | null; + listing_agents?: Record[]; + listing_offices?: Record[]; +} + +/** A single mortgage rate quote. */ +export interface MortgageRate { + rate?: number | null; + rate_source?: string | null; + last_updated_utc?: number | null; + last_updated_at?: string | null; +} + +/** Current mortgage rates surfaced on the property page. */ +export interface MortgageRates { + fifteen_year_fixed?: MortgageRate | null; + thirty_year_fixed?: MortgageRate | null; + arm_5?: MortgageRate | null; +} + +/** + * High-value subset of Zillow's `resoFacts` MLS block. + * + * resoFacts carries ~187 keys; these are the ones competitors surface and + * callers actually query. + */ +export interface HomeFacts { + // Bath breakdown + bathrooms_full?: number | null; + bathrooms_half?: number | null; + bathrooms_three_quarter?: number | null; + bathrooms_one_quarter?: number | null; + // Structure + stories?: number | null; + stories_decimal?: number | null; + levels?: string | null; + property_condition?: string | null; + architectural_style?: string | null; + structure_type?: string | null; + building_name?: string | null; + construction_materials?: string[]; + foundation_details?: string[]; + roof_type?: string | null; + year_built_effective?: number | null; + // Area breakdown + above_grade_finished_area?: string | null; + below_grade_finished_area?: string | null; + lot_size_dimensions?: string | null; + main_level_bedrooms?: number | null; + main_level_bathrooms?: number | null; + basement?: string | null; + has_basement?: boolean | null; + attic?: string | null; + // Systems + heating?: string[]; + cooling?: string[]; + appliances?: string[]; + flooring?: string[]; + utilities?: string[]; + electric?: string[]; + gas?: string[]; + sewer?: string[]; + water_source?: string[]; + // Green / energy + green_building_verification_type?: string[]; + green_energy_efficient?: string[]; + green_energy_generation?: string[]; + green_sustainability?: string[]; + green_water_conservation?: string[]; + // Features + interior_features?: string[]; + exterior_features?: string[]; + lot_features?: string[]; + community_features?: string[]; + accessibility_features?: string[]; + door_features?: string[]; + window_features?: string[]; + laundry_features?: string[]; + patio_and_porch_features?: string[]; + fencing?: string[]; + other_structures?: string[]; + view?: string[]; + has_view?: boolean | null; + waterfront_features?: string[]; + water_view?: string | null; + water_body_name?: string | null; + security_features?: string[]; + // Parking + parking_features?: string[]; + parking_capacity?: number | null; + garage_parking_capacity?: number | null; + carport_parking_capacity?: number | null; + covered_parking_capacity?: number | null; + open_parking_capacity?: number | null; + has_attached_garage?: boolean | null; + has_garage?: boolean | null; + has_carport?: boolean | null; + has_open_parking?: boolean | null; + // Amenities + pool_features?: string[]; + has_private_pool?: boolean | null; + spa_features?: string[]; + fireplaces?: number | null; + fireplace_features?: string[]; + has_fireplace?: boolean | null; + // HOA / fees / tax + association_name?: string | null; + association_name2?: string | null; + association_fee?: string | null; + association_fee2?: string | null; + association_fee_includes?: string[]; + association_amenities?: string[]; + association_phone?: string | null; + has_association?: boolean | null; + hoa_fee?: string | null; + hoa_fee_total?: string | null; + tax_annual_amount?: number | null; + price_per_square_foot?: number | null; + // Land / lease + has_land_lease?: boolean | null; + land_lease_amount?: string | null; + land_lease_expiration_date?: string | null; + can_raise_horses?: boolean | null; + additional_parcels_description?: string | null; + road_surface_type?: string[]; + // Market timing + on_market_date?: string | null; + cumulative_days_on_market?: number | null; + offer_review_date?: string | null; + // Rental / multi-unit + number_of_units_in_community?: number | null; + availability_date?: string | null; + lease_term?: string | null; + tenant_pays?: string[]; + has_pets_allowed?: boolean | null; + pets_max_weight?: number | null; + has_rent_control?: boolean | null; + // Schools (as named on the MLS record) + elementary_school?: string | null; + middle_school?: string | null; + high_school?: string | null; + elementary_school_district?: string | null; + middle_school_district?: string | null; + high_school_district?: string | null; + // Parcel / legal + parcel_number?: string | null; + subdivision_name?: string | null; + municipality?: string | null; + city_region?: string | null; + zoning?: string | null; + zoning_description?: string | null; + ownership?: string | null; + ownership_type?: string | null; + property_sub_type?: string[]; + special_listing_conditions?: string | null; + listing_terms?: string | null; + inclusions?: string | null; + exclusions?: string | null; + // Flags + is_new_construction?: boolean | null; + is_senior_community?: boolean | null; + has_home_warranty?: boolean | null; + furnished?: boolean | null; + development_status?: string | null; + park_name?: string | null; + // Bulk fact lists Zillow ships verbatim (label/value pairs) + at_a_glance_facts?: Record[]; + rooms?: Record[]; +} + +/** Full Zillow property detail (from `gdpClientCache[...].property`). */ +export interface Property { + // Identity + zpid: string; + id?: string | null; + url?: string | null; + home_status?: string | null; + home_type?: string | null; + property_type?: string | null; + listing_type?: string | null; + posting_product_type?: string | null; + listing_data_source?: string | null; + mls_id?: string | null; + parcel_id?: string | null; + county_fips?: string | null; + provider_listing_id?: string | null; + broker_id?: string | null; + contingent_listing_type?: string | null; + listing_sub_type?: ListingSubType | null; + // Price / valuation + price?: number | null; + currency?: string | null; + list_price_low?: number | null; + monthly_hoa_fee?: number | null; + property_tax_rate?: number | null; + annual_homeowners_insurance?: number | null; + last_sold_price?: number | null; + date_sold_utc?: number | null; + date_sold_at?: string | null; + price_change?: number | null; + price_change_date_utc?: number | null; + price_change_date_at?: string | null; + zestimate?: number | null; + rent_zestimate?: number | null; + zestimate_low_percent?: string | null; + zestimate_high_percent?: string | null; + rent_zestimate_low_percent?: string | null; + rent_zestimate_high_percent?: string | null; + zestimate_30_days_ago?: number | null; + rent_zestimate_30_days_ago?: number | null; + tax_assessed_value?: number | null; + zestimate_history?: ZestimateHistoryPoint[]; + // Specs + bedrooms?: number | null; + bathrooms?: number | null; + living_area?: number | null; + living_area_units?: string | null; + lot_size?: number | null; + lot_area_value?: number | null; + lot_area_units?: string | null; + year_built?: number | null; + move_in_ready?: boolean | null; + move_in_completion_date?: string | null; + // Location + latitude?: number | null; + longitude?: number | null; + street_address?: string | null; + abbreviated_address?: string | null; + city?: string | null; + state?: string | null; + zipcode?: string | null; + county?: string | null; + country?: string | null; + time_zone?: string | null; + neighborhood?: string | null; + is_undisclosed_address?: boolean | null; + is_income_restricted?: boolean | null; + // Engagement + days_on_zillow?: number | null; + time_on_zillow?: string | null; + page_view_count?: number | null; + favorite_count?: number | null; + tour_view_count?: number | null; + photo_count?: number | null; + // Content + description?: string | null; + what_i_love?: string | null; + home_insights?: string[]; + marketing_name?: string | null; + brokerage_name?: string | null; + is_showcase_listing?: boolean | null; + has_vr_model?: boolean | null; + has_3d_model?: boolean | null; + virtual_tour_url?: string | null; + interactive_floor_plan_url?: string | null; + street_view_image_url?: string | null; + static_map_url?: string | null; + new_construction_type?: string | null; + builder_name?: string | null; + promotion_headline?: string | null; + promotion_description?: string | null; + has_promotion?: boolean | null; + // Nested + address?: Address | null; + home_facts?: HomeFacts | null; + agent?: AgentAttribution | null; + mortgage_rates?: MortgageRates | null; + price_history?: PriceHistoryEvent[]; + tax_history?: TaxHistoryEvent[]; + schools?: School[]; + photos?: Photo[]; + open_house_schedule?: OpenHouse[]; + nearby_cities?: NearbyRegion[]; + nearby_neighborhoods?: NearbyRegion[]; + nearby_zipcodes?: NearbyRegion[]; + /** nearby_homes / comps are lazy-loaded client-side (usually empty in SSR). */ + nearby_homes?: Listing[]; + // Timestamps + scraped_utc?: number | null; + scraped_at?: string | null; +} + +// ============================================================================= +// Agent profile +// ============================================================================= + +/** One review on a Zillow agent profile (from `reviewsData.reviews`). */ +export interface AgentReview { + rating?: number | null; + comment?: string | null; + date?: string | null; + date_utc?: number | null; + date_at?: string | null; + work_description?: string | null; + reviewer_name?: string | null; + rebuttal?: string | null; + /** [{ description: "Responsiveness", score: 5 }, …] */ + sub_ratings?: Record[]; +} + +/** A closed transaction from an agent's `pastSales` block. */ +export interface PastSale { + zpid?: string | null; + street_address?: string | null; + city_state_zip?: string | null; + price?: number | null; + sold_date?: string | null; + sold_date_utc?: number | null; + sold_date_at?: string | null; + bedrooms?: number | null; + bathrooms?: number | null; + living_area?: number | null; + latitude?: number | null; + longitude?: number | null; + represented?: string | null; + image_url?: string | null; + url?: string | null; +} + +/** A professional license held by an agent. */ +export interface AgentLicense { + state?: string | null; + license_type?: string | null; + license_number?: string | null; + status?: string | null; + expiration?: string | null; +} + +/** A Zillow real-estate professional profile (from /profile/{username}). */ +export interface Agent { + username?: string | null; + encoded_zuid?: string | null; + name?: string | null; + url?: string | null; + profile_photo?: string | null; + phone?: string | null; + email?: string | null; + business_name?: string | null; + business_address?: string | null; + broker_name?: string | null; + title?: string | null; + bio?: string | null; + rating?: number | null; + review_count?: number | null; + recent_sales_count?: number | null; + total_sales_last_year?: number | null; + for_sale_count?: number | null; + for_rent_count?: number | null; + past_sales_count?: number | null; + years_experience?: number | null; + is_top_agent?: boolean | null; + is_team_lead?: boolean | null; + license_number?: string | null; + license_state?: string | null; + // Social / web + website_url?: string | null; + facebook_url?: string | null; + linkedin_url?: string | null; + x_url?: string | null; + video_url?: string | null; + specialties?: string[]; + service_areas?: string[]; + languages?: string[]; + licenses?: AgentLicense[]; + professional_information?: Record[]; + reviews?: AgentReview[]; + past_sales?: PastSale[]; + listings?: Listing[]; + scraped_utc?: number | null; + scraped_at?: string | null; +} + +// ============================================================================= +// Autocomplete +// ============================================================================= + +/** A region/address suggestion resolved for a search term. */ +export interface AutocompleteResult { + display?: string | null; + region_id?: number | null; + region_type?: string | null; + latitude?: number | null; + longitude?: number | null; + /** Populated when the suggestion is a specific home. */ + zpid?: string | null; + metro_id?: number | null; +} + +// ============================================================================= +// Response Envelopes +// ============================================================================= + +/** Response from the /search endpoint. */ +export interface SearchResponse { + location?: string | null; + status: string; + results: Listing[]; + map_results_count: number; + region?: RegionSelection | null; + map_bounds?: MapBounds | null; + pagination: Pagination; + scraped_utc?: number | null; + scraped_at?: string | null; +} + +/** Response from the /property endpoints. */ +export interface PropertyResponse { + property: Property; +} + +/** Response from the /agent endpoint. */ +export interface AgentResponse { + agent: Agent; +} + +/** Response from the /autocomplete endpoint. */ +export interface AutocompleteResponse { + query: string; + results: AutocompleteResult[]; +} + +/** Response from the /markets endpoint. */ +export interface MarketsResponse { + markets: MarketInfo[]; +} + +// ============================================================================= +// Request Parameter Types +// ============================================================================= + +/** Listing status filter. */ +export type ZillowStatus = "for_sale" | "for_rent" | "sold"; + +/** Sort order for search results. */ +export type ZillowSort = + | "homes_for_you" + | "newest" + | "price_high_to_low" + | "price_low_to_high" + | "bedrooms" + | "bathrooms" + | "square_feet" + | "lot_size" + | "year_built"; + +/** Options for the search endpoint. */ +export interface ZillowSearchOptions { + /** Listing status (default: "for_sale") */ + status?: ZillowStatus; + /** Page number, 1-20 (Zillow caps search at 20 pages / ~820 results) */ + page?: number; + /** Sort order */ + sort?: ZillowSort; + /** Minimum price */ + priceMin?: number; + /** Maximum price */ + priceMax?: number; + /** Minimum bedrooms (0-10) */ + bedsMin?: number; + /** Minimum bathrooms (0-10) */ + bathsMin?: number; + /** Home type (houses|condos|townhomes|apartments|manufactured|lots|multi_family) */ + homeType?: string; + /** Minimum living area (sqft) */ + sqftMin?: number; + /** Maximum living area (sqft) */ + sqftMax?: number; + /** Minimum lot size (sqft) */ + lotMin?: number; + /** Maximum lot size (sqft) */ + lotMax?: number; + /** Minimum year built */ + yearBuiltMin?: number; + /** Maximum year built */ + yearBuiltMax?: number; + /** Max monthly HOA */ + hoaMax?: number; + /** Match listing description keywords */ + keywords?: string; + /** Days on Zillow filter (e.g. "1", "7", "30") */ + daysOn?: string; + /** Map bounds north latitude (for tiling past the 820 cap) */ + north?: number; + /** Map bounds south latitude */ + south?: number; + /** Map bounds east longitude */ + east?: number; + /** Map bounds west longitude */ + west?: number; +} + +/** Options for the agent endpoint. Provide a `username` or a `url`. */ +export interface ZillowAgentOptions { + /** Zillow profile username (screen name) */ + username?: string; + /** Full Zillow /profile/... URL */ + url?: string; +} diff --git a/tests/zillow.test.ts b/tests/zillow.test.ts new file mode 100644 index 0000000..f07ebd4 --- /dev/null +++ b/tests/zillow.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for the Zillow API client. + * + * Uses vitest and a fetch mock that intercepts calls made by BaseClient. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ScrapeBadger } from "../src/client.js"; +import { ZillowClient } from "../src/zillow/client.js"; +import type { + ZillowSearchResponse, + ZillowPropertyResponse, + ZillowAgentResponse, + ZillowAutocompleteResponse, + ZillowMarketsResponse, +} from "../src/zillow/index.js"; + +function makeClient(): ScrapeBadger { + return new ScrapeBadger({ + apiKey: "test-api-key", + baseUrl: "https://api.scrapebadger.com", + maxRetries: 0, + }); +} + +function mockFetch(body: unknown, status = 200): void { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: { get: () => "application/json" }, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }) + ); +} + +function capturedUrl(): string { + const mock = vi.mocked(fetch); + expect(mock).toHaveBeenCalledOnce(); + return (mock.mock.calls[0] as [string, RequestInit])[0]; +} + +describe("ZillowClient", () => { + beforeEach(() => vi.restoreAllMocks()); + + it("is wired onto the top-level client", () => { + expect(makeClient().zillow).toBeInstanceOf(ZillowClient); + }); + + it("search() routes to /v1/zillow/search with params", async () => { + mockFetch({ status: "for_sale", results: [{ position: 1, zpid: "1" }] }); + const res: ZillowSearchResponse = await makeClient().zillow.search.search("Austin, TX", { + bedsMin: 3, + }); + const url = capturedUrl(); + expect(url).toContain("/v1/zillow/search"); + expect(url).toContain("location=Austin"); + expect(url).toContain("beds_min=3"); + expect(res.status).toBe("for_sale"); + }); + + it("autocomplete() routes to /v1/zillow/autocomplete", async () => { + mockFetch({ query: "miami", results: [] }); + const res: ZillowAutocompleteResponse = await makeClient().zillow.search.autocomplete("miami"); + expect(capturedUrl()).toContain("/v1/zillow/autocomplete"); + expect(res.query).toBe("miami"); + }); + + it("getProperty() routes to /v1/zillow/property/{zpid}", async () => { + mockFetch({ property: { zpid: "42" } }); + const res: ZillowPropertyResponse = await makeClient().zillow.properties.getProperty("42"); + expect(capturedUrl()).toContain("/v1/zillow/property/42"); + expect(res.property.zpid).toBe("42"); + }); + + it("getAgent() routes to /v1/zillow/agent with params", async () => { + mockFetch({ agent: { username: "some-agent" } }); + const res: ZillowAgentResponse = await makeClient().zillow.agent.getAgent({ + username: "some-agent", + }); + const url = capturedUrl(); + expect(url).toContain("/v1/zillow/agent"); + expect(url).toContain("username=some-agent"); + expect(res.agent.username).toBe("some-agent"); + }); + + it("listMarkets() routes to /v1/zillow/markets", async () => { + mockFetch({ markets: [] }); + const res: ZillowMarketsResponse = await makeClient().zillow.reference.listMarkets(); + expect(capturedUrl()).toContain("/v1/zillow/markets"); + expect(res.markets).toEqual([]); + }); +});