mirror of
https://github.com/Bunsly/HomeHarvest.git
synced 2026-03-04 19:44:29 -08:00
Compare commits
17 Commits
e378feeefe
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d75ca4dfa | ||
|
|
ca1be85a93 | ||
|
|
145c337b55 | ||
|
|
6c6243eba4 | ||
|
|
79082090cb | ||
|
|
8311f4dfbc | ||
|
|
0d85100091 | ||
|
|
851ba53d81 | ||
|
|
0fdc309262 | ||
|
|
62b6726d42 | ||
|
|
ccf5786ce2 | ||
|
|
b4f05b254a | ||
|
|
941d1081f7 | ||
|
|
c788b3318d | ||
|
|
68a3438c6e | ||
|
|
a3c5e9060e | ||
|
|
d06595fe56 |
@@ -8,8 +8,6 @@
|
||||
- **Data Format**: Structures data to resemble MLS listings.
|
||||
- **Export Flexibility**: Options to save as either CSV or Excel.
|
||||
|
||||
[Video Guide for HomeHarvest](https://youtu.be/J1qgNPgmSLI) - _updated for release v0.3.4_
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
@@ -4,13 +4,13 @@ from .core.scrapers import ScraperInput
|
||||
from .utils import process_result, ordered_properties, validate_input, validate_dates, validate_limit
|
||||
from .core.scrapers.realtor import RealtorScraper
|
||||
from .core.scrapers.models import ListingType, SearchPropertyType, ReturnType, Property
|
||||
|
||||
from typing import Union, Optional, List
|
||||
|
||||
def scrape_property(
|
||||
location: str,
|
||||
listing_type: str = "for_sale",
|
||||
return_type: str = "pandas",
|
||||
property_type: list[str] | None = None,
|
||||
property_type: Optional[List[str]] = None,
|
||||
radius: float = None,
|
||||
mls_only: bool = False,
|
||||
past_days: int = None,
|
||||
@@ -21,7 +21,7 @@ def scrape_property(
|
||||
extra_property_data: bool = True,
|
||||
exclude_pending: bool = False,
|
||||
limit: int = 10000
|
||||
) -> pd.DataFrame | list[dict] | list[Property]:
|
||||
) -> Union[pd.DataFrame, list[dict], list[Property]]:
|
||||
"""
|
||||
Scrape properties from Realtor.com based on a given location and listing type.
|
||||
:param location: Location to search (e.g. "Dallas, TX", "85281", "2530 Al Lipscomb Way")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
import requests
|
||||
@@ -9,10 +8,10 @@ import uuid
|
||||
from ...exceptions import AuthenticationError
|
||||
from .models import Property, ListingType, SiteName, SearchPropertyType, ReturnType
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScraperInput:
|
||||
class ScraperInput(BaseModel):
|
||||
location: str
|
||||
listing_type: ListingType
|
||||
property_type: list[SearchPropertyType] | None = None
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, computed_field, HttpUrl, Field
|
||||
|
||||
|
||||
class ReturnType(Enum):
|
||||
@@ -44,12 +45,6 @@ class ListingType(Enum):
|
||||
SOLD = "SOLD"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Agent:
|
||||
name: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class PropertyType(Enum):
|
||||
APARTMENT = "APARTMENT"
|
||||
BUILDING = "BUILDING"
|
||||
@@ -74,57 +69,86 @@ class PropertyType(Enum):
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Address:
|
||||
class Address(BaseModel):
|
||||
full_line: str | None = None
|
||||
street: str | None = None
|
||||
unit: str | None = None
|
||||
city: str | None = None
|
||||
state: str | None = None
|
||||
zip: str | None = None
|
||||
city: str | None = Field(None, description="The name of the city")
|
||||
state: str | None = Field(None, description="The name of the state")
|
||||
zip: str | None = Field(None, description="zip code")
|
||||
|
||||
# Additional address fields from GraphQL
|
||||
street_direction: str | None = None
|
||||
street_number: str | None = None
|
||||
street_name: str | None = None
|
||||
street_suffix: str | None = None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def formatted_address(self) -> str | None:
|
||||
"""Computed property that combines full_line, city, state, and zip into a formatted address."""
|
||||
parts = []
|
||||
|
||||
if self.full_line:
|
||||
parts.append(self.full_line)
|
||||
|
||||
city_state_zip = []
|
||||
if self.city:
|
||||
city_state_zip.append(self.city)
|
||||
if self.state:
|
||||
city_state_zip.append(self.state)
|
||||
if self.zip:
|
||||
city_state_zip.append(self.zip)
|
||||
|
||||
if city_state_zip:
|
||||
parts.append(", ".join(city_state_zip))
|
||||
|
||||
return ", ".join(parts) if parts else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Description:
|
||||
primary_photo: str | None = None
|
||||
alt_photos: list[str] | None = None
|
||||
|
||||
|
||||
class Description(BaseModel):
|
||||
primary_photo: HttpUrl | None = None
|
||||
alt_photos: list[HttpUrl] | None = None
|
||||
style: PropertyType | None = None
|
||||
beds: int | None = None
|
||||
baths_full: int | None = None
|
||||
baths_half: int | None = None
|
||||
sqft: int | None = None
|
||||
lot_sqft: int | None = None
|
||||
sold_price: int | None = None
|
||||
year_built: int | None = None
|
||||
garage: float | None = None
|
||||
stories: int | None = None
|
||||
beds: int | None = Field(None, description="Total number of bedrooms")
|
||||
baths_full: int | None = Field(None, description="Total number of full bathrooms (4 parts: Sink, Shower, Bathtub and Toilet)")
|
||||
baths_half: int | None = Field(None, description="Total number of 1/2 bathrooms (2 parts: Usually Sink and Toilet)")
|
||||
sqft: int | None = Field(None, description="Square footage of the Home")
|
||||
lot_sqft: int | None = Field(None, description="Lot square footage")
|
||||
sold_price: int | None = Field(None, description="Sold price of home")
|
||||
year_built: int | None = Field(None, description="The year the building/home was built")
|
||||
garage: float | None = Field(None, description="Number of garage spaces")
|
||||
stories: int | None = Field(None, description="Number of stories in the building")
|
||||
text: str | None = None
|
||||
|
||||
# Additional description fields
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
|
||||
@dataclass
|
||||
class AgentPhone: #: For documentation purposes only (at the moment)
|
||||
|
||||
class AgentPhone(BaseModel):
|
||||
number: str | None = None
|
||||
type: str | None = None
|
||||
primary: bool | None = None
|
||||
ext: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
name: str
|
||||
class Entity(BaseModel):
|
||||
name: str | None = None # Make name optional since it can be None
|
||||
uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Agent(Entity):
|
||||
mls_set: str | None = None
|
||||
nrds_id: str | None = None
|
||||
phones: list[dict] | AgentPhone | None = None
|
||||
email: str | None = None
|
||||
href: str | None = None
|
||||
state_license: str | None = Field(None, description="Advertiser agent state license number")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Office(Entity):
|
||||
mls_set: str | None = None
|
||||
email: str | None = None
|
||||
@@ -132,63 +156,212 @@ class Office(Entity):
|
||||
phones: list[dict] | AgentPhone | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Broker(Entity):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Builder(Entity):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Advertisers:
|
||||
class Advertisers(BaseModel):
|
||||
agent: Agent | None = None
|
||||
broker: Broker | None = None
|
||||
builder: Builder | None = None
|
||||
office: Office | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Property:
|
||||
property_url: str
|
||||
|
||||
property_id: str
|
||||
class Property(BaseModel):
|
||||
property_url: HttpUrl
|
||||
property_id: str = Field(..., description="Unique Home identifier also known as property id")
|
||||
#: allows_cats: bool
|
||||
#: allows_dogs: bool
|
||||
|
||||
listing_id: str | None = None
|
||||
permalink: str | None = None
|
||||
|
||||
mls: str | None = None
|
||||
mls_id: str | None = None
|
||||
status: str | None = None
|
||||
status: str | None = Field(None, description="Listing status: for_sale, for_rent, sold, off_market, active (New Home Subdivisions), other (if none of the above conditions were met)")
|
||||
address: Address | None = None
|
||||
|
||||
list_price: int | None = None
|
||||
list_price: int | None = Field(None, description="The current price of the Home")
|
||||
list_price_min: int | None = None
|
||||
list_price_max: int | None = None
|
||||
|
||||
list_date: str | None = None
|
||||
pending_date: str | None = None
|
||||
last_sold_date: str | None = None
|
||||
list_date: datetime | None = Field(None, description="The time this Home entered Move system")
|
||||
pending_date: datetime | None = Field(None, description="The date listing went into pending state")
|
||||
last_sold_date: datetime | None = Field(None, description="Last time the Home was sold")
|
||||
prc_sqft: int | None = None
|
||||
new_construction: bool | None = None
|
||||
hoa_fee: int | None = None
|
||||
days_on_mls: int | None = None
|
||||
new_construction: bool | None = Field(None, description="Search for new construction homes")
|
||||
hoa_fee: int | None = Field(None, description="Search for homes where HOA fee is known and falls within specified range")
|
||||
days_on_mls: int | None = Field(None, description="An integer value determined by the MLS to calculate days on market")
|
||||
description: Description | None = None
|
||||
tags: list[str] | None = None
|
||||
details: list[dict] | None = None
|
||||
details: list[HomeDetails] | None = None
|
||||
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
neighborhoods: Optional[str] = None
|
||||
county: Optional[str] = None
|
||||
fips_code: Optional[str] = None
|
||||
nearby_schools: list[str] = None
|
||||
county: Optional[str] = Field(None, description="County associated with home")
|
||||
fips_code: Optional[str] = Field(None, description="The FIPS (Federal Information Processing Standard) code for the county")
|
||||
nearby_schools: list[str] | None = None
|
||||
assessed_value: int | None = None
|
||||
estimated_value: int | None = None
|
||||
tax: int | None = None
|
||||
tax_history: list[dict] | None = None
|
||||
tax_history: list[TaxHistory] | None = None
|
||||
|
||||
advertisers: Advertisers | None = None
|
||||
|
||||
# Additional fields from GraphQL that aren't currently parsed
|
||||
mls_status: str | None = None
|
||||
last_sold_price: int | None = None
|
||||
|
||||
# Structured data from GraphQL
|
||||
open_houses: list[OpenHouse] | None = None
|
||||
pet_policy: PetPolicy | None = None
|
||||
units: list[Unit] | None = None
|
||||
monthly_fees: HomeMonthlyFee | None = Field(None, description="Monthly fees. Currently only some rental data will have them.")
|
||||
one_time_fees: list[HomeOneTimeFee] | None = Field(None, description="One time fees. Currently only some rental data will have them.")
|
||||
parking: HomeParkingDetails | None = Field(None, description="Parking information. Currently only some rental data will have it.")
|
||||
terms: list[PropertyDetails] | None = None
|
||||
popularity: Popularity | None = None
|
||||
tax_record: TaxRecord | None = None
|
||||
parcel_info: dict | None = None # Keep as dict for flexibility
|
||||
current_estimates: list[PropertyEstimate] | None = None
|
||||
estimates: HomeEstimates | None = None
|
||||
photos: list[dict] | None = None # Keep as dict for photo structure
|
||||
flags: HomeFlags | None = Field(None, description="Home flags for Listing/Property")
|
||||
|
||||
|
||||
# Specialized models for GraphQL types
|
||||
|
||||
class HomeMonthlyFee(BaseModel):
|
||||
description: str | None = None
|
||||
display_amount: str | None = None
|
||||
|
||||
|
||||
class HomeOneTimeFee(BaseModel):
|
||||
description: str | None = None
|
||||
display_amount: str | None = None
|
||||
|
||||
|
||||
class HomeParkingDetails(BaseModel):
|
||||
unassigned_space_rent: int | None = None
|
||||
assigned_spaces_available: int | None = None
|
||||
description: str | None = Field(None, description="Parking information. Currently only some rental data will have it.")
|
||||
assigned_space_rent: int | None = None
|
||||
|
||||
|
||||
class PetPolicy(BaseModel):
|
||||
cats: bool | None = Field(None, description="Search for homes which allow cats")
|
||||
dogs: bool | None = Field(None, description="Search for homes which allow dogs")
|
||||
dogs_small: bool | None = Field(None, description="Search for homes with allow small dogs")
|
||||
dogs_large: bool | None = Field(None, description="Search for homes which allow large dogs")
|
||||
|
||||
|
||||
class OpenHouse(BaseModel):
|
||||
start_date: datetime | None = None
|
||||
end_date: datetime | None = None
|
||||
description: str | None = None
|
||||
time_zone: str | None = None
|
||||
dst: bool | None = None
|
||||
href: HttpUrl | None = None
|
||||
methods: list[str] | None = None
|
||||
|
||||
|
||||
class HomeFlags(BaseModel):
|
||||
is_pending: bool | None = None
|
||||
is_contingent: bool | None = None
|
||||
is_new_construction: bool | None = None
|
||||
is_coming_soon: bool | None = None
|
||||
is_new_listing: bool | None = None
|
||||
is_price_reduced: bool | None = None
|
||||
is_foreclosure: bool | None = None
|
||||
|
||||
|
||||
class PopularityPeriod(BaseModel):
|
||||
clicks_total: int | None = None
|
||||
views_total: int | None = None
|
||||
dwell_time_mean: float | None = None
|
||||
dwell_time_median: float | None = None
|
||||
leads_total: int | None = None
|
||||
shares_total: int | None = None
|
||||
saves_total: int | None = None
|
||||
last_n_days: int | None = None
|
||||
|
||||
|
||||
class Popularity(BaseModel):
|
||||
periods: list[PopularityPeriod] | None = None
|
||||
|
||||
|
||||
class Assessment(BaseModel):
|
||||
building: int | None = None
|
||||
land: int | None = None
|
||||
total: int | None = None
|
||||
|
||||
|
||||
class TaxHistory(BaseModel):
|
||||
assessment: Assessment | None = None
|
||||
market: Assessment | None = Field(None, description="Market values as provided by the county or local taxing/assessment authority")
|
||||
appraisal: Assessment | None = Field(None, description="Appraised value given by taxing authority")
|
||||
value: Assessment | None = Field(None, description="Value closest to current market value used for assessment by county or local taxing authorities")
|
||||
tax: int | None = None
|
||||
year: int | None = None
|
||||
assessed_year: int | None = Field(None, description="Assessment year for which taxes were billed")
|
||||
|
||||
|
||||
class TaxRecord(BaseModel):
|
||||
cl_id: str | None = None
|
||||
public_record_id: str | None = None
|
||||
last_update_date: datetime | None = None
|
||||
apn: str | None = None
|
||||
tax_parcel_id: str | None = None
|
||||
|
||||
|
||||
class EstimateSource(BaseModel):
|
||||
type: str | None = Field(None, description="Type of the avm vendor, list of values: corelogic, collateral, quantarium")
|
||||
name: str | None = Field(None, description="Name of the avm vendor")
|
||||
|
||||
|
||||
class PropertyEstimate(BaseModel):
|
||||
estimate: int | None = Field(None, description="Estimated value of a property")
|
||||
estimate_high: int | None = Field(None, description="Estimated high value of a property")
|
||||
estimate_low: int | None = Field(None, description="Estimated low value of a property")
|
||||
date: datetime | None = Field(None, description="Date of estimation")
|
||||
is_best_home_value: bool | None = None
|
||||
source: EstimateSource | None = Field(None, description="Source of the latest estimate value")
|
||||
|
||||
|
||||
class HomeEstimates(BaseModel):
|
||||
current_values: list[PropertyEstimate] | None = Field(None, description="Current valuation and best value for home from multiple AVM vendors")
|
||||
|
||||
|
||||
class PropertyDetails(BaseModel):
|
||||
category: str | None = None
|
||||
text: list[str] | None = None
|
||||
parent_category: str | None = None
|
||||
|
||||
|
||||
class HomeDetails(BaseModel):
|
||||
category: str | None = None
|
||||
text: list[str] | None = None
|
||||
parent_category: str | None = None
|
||||
|
||||
|
||||
class UnitDescription(BaseModel):
|
||||
baths_consolidated: str | None = None
|
||||
baths: float | None = None # Changed to float to handle values like 2.5
|
||||
beds: int | None = None
|
||||
sqft: int | None = None
|
||||
|
||||
|
||||
class UnitAvailability(BaseModel):
|
||||
date: datetime | None = None
|
||||
|
||||
|
||||
class Unit(BaseModel):
|
||||
availability: UnitAvailability | None = None
|
||||
description: UnitDescription | None = None
|
||||
photos: list[dict] | None = None # Keep as dict for photo structure
|
||||
list_price: int | None = None
|
||||
|
||||
@@ -11,7 +11,7 @@ import json
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
from typing import Dict, Union, Optional
|
||||
from typing import Dict, Union
|
||||
|
||||
from tenacity import (
|
||||
retry,
|
||||
@@ -23,18 +23,15 @@ from tenacity import (
|
||||
from .. import Scraper
|
||||
from ..models import (
|
||||
Property,
|
||||
Address,
|
||||
ListingType,
|
||||
Description,
|
||||
PropertyType,
|
||||
Agent,
|
||||
Broker,
|
||||
Builder,
|
||||
Advertisers,
|
||||
Office,
|
||||
ReturnType
|
||||
)
|
||||
from .queries import GENERAL_RESULTS_QUERY, SEARCH_HOMES_DATA, HOMES_DATA, HOME_FRAGMENT
|
||||
from .processors import (
|
||||
process_property,
|
||||
process_extra_property_details,
|
||||
get_key
|
||||
)
|
||||
|
||||
|
||||
class RealtorScraper(Scraper):
|
||||
@@ -121,116 +118,13 @@ class RealtorScraper(Scraper):
|
||||
|
||||
property_info = response_json["data"]["home"]
|
||||
|
||||
return [self.process_property(property_info)]
|
||||
if self.return_type != ReturnType.raw:
|
||||
return [process_property(property_info, self.mls_only, self.extra_property_data,
|
||||
self.exclude_pending, self.listing_type, get_key, process_extra_property_details)]
|
||||
else:
|
||||
return [property_info]
|
||||
|
||||
@staticmethod
|
||||
def process_advertisers(advertisers: list[dict] | None) -> Advertisers | None:
|
||||
if not advertisers:
|
||||
return None
|
||||
|
||||
def _parse_fulfillment_id(fulfillment_id: str | None) -> str | None:
|
||||
return fulfillment_id if fulfillment_id and fulfillment_id != "0" else None
|
||||
|
||||
processed_advertisers = Advertisers()
|
||||
|
||||
for advertiser in advertisers:
|
||||
advertiser_type = advertiser.get("type")
|
||||
if advertiser_type == "seller": #: agent
|
||||
processed_advertisers.agent = Agent(
|
||||
uuid=_parse_fulfillment_id(advertiser.get("fulfillment_id")),
|
||||
nrds_id=advertiser.get("nrds_id"),
|
||||
mls_set=advertiser.get("mls_set"),
|
||||
name=advertiser.get("name"),
|
||||
email=advertiser.get("email"),
|
||||
phones=advertiser.get("phones"),
|
||||
)
|
||||
|
||||
if advertiser.get("broker") and advertiser["broker"].get("name"): #: has a broker
|
||||
processed_advertisers.broker = Broker(
|
||||
uuid=_parse_fulfillment_id(advertiser["broker"].get("fulfillment_id")),
|
||||
name=advertiser["broker"].get("name"),
|
||||
)
|
||||
|
||||
if advertiser.get("office"): #: has an office
|
||||
processed_advertisers.office = Office(
|
||||
uuid=_parse_fulfillment_id(advertiser["office"].get("fulfillment_id")),
|
||||
mls_set=advertiser["office"].get("mls_set"),
|
||||
name=advertiser["office"].get("name"),
|
||||
email=advertiser["office"].get("email"),
|
||||
phones=advertiser["office"].get("phones"),
|
||||
)
|
||||
|
||||
if advertiser_type == "community": #: could be builder
|
||||
if advertiser.get("builder"):
|
||||
processed_advertisers.builder = Builder(
|
||||
uuid=_parse_fulfillment_id(advertiser["builder"].get("fulfillment_id")),
|
||||
name=advertiser["builder"].get("name"),
|
||||
)
|
||||
|
||||
return processed_advertisers
|
||||
|
||||
def process_property(self, result: dict) -> Property | None:
|
||||
mls = result["source"].get("id") if "source" in result and isinstance(result["source"], dict) else None
|
||||
|
||||
if not mls and self.mls_only:
|
||||
return
|
||||
|
||||
able_to_get_lat_long = (
|
||||
result
|
||||
and result.get("location")
|
||||
and result["location"].get("address")
|
||||
and result["location"]["address"].get("coordinate")
|
||||
)
|
||||
|
||||
is_pending = result["flags"].get("is_pending")
|
||||
is_contingent = result["flags"].get("is_contingent")
|
||||
|
||||
if (is_pending or is_contingent) and (self.exclude_pending and self.listing_type != ListingType.PENDING):
|
||||
return
|
||||
|
||||
property_id = result["property_id"]
|
||||
prop_details = self.process_extra_property_details(result) if self.extra_property_data else {}
|
||||
|
||||
property_estimates_root = result.get("current_estimates") or result.get("estimates", {}).get("currentValues")
|
||||
estimated_value = self.get_key(property_estimates_root, [0, "estimate"])
|
||||
|
||||
advertisers = self.process_advertisers(result.get("advertisers"))
|
||||
|
||||
realty_property = Property(
|
||||
mls=mls,
|
||||
mls_id=(
|
||||
result["source"].get("listing_id")
|
||||
if "source" in result and isinstance(result["source"], dict)
|
||||
else None
|
||||
),
|
||||
property_url=result["href"],
|
||||
property_id=property_id,
|
||||
listing_id=result.get("listing_id"),
|
||||
status=("PENDING" if is_pending else "CONTINGENT" if is_contingent else result["status"].upper()),
|
||||
list_price=result["list_price"],
|
||||
list_price_min=result["list_price_min"],
|
||||
list_price_max=result["list_price_max"],
|
||||
list_date=(result["list_date"].split("T")[0] if result.get("list_date") else None),
|
||||
prc_sqft=result.get("price_per_sqft"),
|
||||
last_sold_date=result.get("last_sold_date"),
|
||||
new_construction=result["flags"].get("is_new_construction") is True,
|
||||
hoa_fee=(result["hoa"]["fee"] if result.get("hoa") and isinstance(result["hoa"], dict) else None),
|
||||
latitude=(result["location"]["address"]["coordinate"].get("lat") if able_to_get_lat_long else None),
|
||||
longitude=(result["location"]["address"]["coordinate"].get("lon") if able_to_get_lat_long else None),
|
||||
address=self._parse_address(result, search_type="general_search"),
|
||||
description=self._parse_description(result),
|
||||
neighborhoods=self._parse_neighborhoods(result),
|
||||
county=(result["location"]["county"].get("name") if result["location"]["county"] else None),
|
||||
fips_code=(result["location"]["county"].get("fips_code") if result["location"]["county"] else None),
|
||||
days_on_mls=self.calculate_days_on_mls(result),
|
||||
nearby_schools=prop_details.get("schools"),
|
||||
assessed_value=prop_details.get("assessed_value"),
|
||||
estimated_value=estimated_value if estimated_value else None,
|
||||
advertisers=advertisers,
|
||||
tax=prop_details.get("tax"),
|
||||
tax_history=prop_details.get("tax_history"),
|
||||
)
|
||||
return realty_property
|
||||
|
||||
def general_search(self, variables: dict, search_type: str) -> Dict[str, Union[int, Union[list[Property], list[dict]]]]:
|
||||
"""
|
||||
@@ -305,15 +199,20 @@ class RealtorScraper(Scraper):
|
||||
)
|
||||
elif search_type == "area": #: general search, came from a general location
|
||||
query = """query Home_search(
|
||||
$location: String!,
|
||||
$city: String,
|
||||
$county: [String],
|
||||
$state_code: String,
|
||||
$postal_code: String
|
||||
$offset: Int,
|
||||
) {
|
||||
home_search(
|
||||
query: {
|
||||
%s
|
||||
search_location: {location: $location}
|
||||
city: $city
|
||||
county: $county
|
||||
postal_code: $postal_code
|
||||
state_code: $state_code
|
||||
status: %s
|
||||
unique: true
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
@@ -383,11 +282,20 @@ class RealtorScraper(Scraper):
|
||||
extra_property_details = self.get_bulk_prop_details(property_ids) or {}
|
||||
|
||||
for result in properties_list:
|
||||
result.update(extra_property_details.get(result["property_id"], {}))
|
||||
specific_details_for_property = extra_property_details.get(result["property_id"], {})
|
||||
|
||||
#: address is retrieved on both homes and search homes, so when merged, homes overrides,
|
||||
# this gets the internal data we want and only updates that (migrate to a func if more fields)
|
||||
if "location" in specific_details_for_property:
|
||||
result["location"].update(specific_details_for_property["location"])
|
||||
del specific_details_for_property["location"]
|
||||
|
||||
result.update(specific_details_for_property)
|
||||
|
||||
if self.return_type != ReturnType.raw:
|
||||
with ThreadPoolExecutor(max_workers=self.NUM_PROPERTY_WORKERS) as executor:
|
||||
futures = [executor.submit(self.process_property, result) for result in properties_list]
|
||||
futures = [executor.submit(process_property, result, self.mls_only, self.extra_property_data,
|
||||
self.exclude_pending, self.listing_type, get_key, process_extra_property_details) for result in properties_list]
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
@@ -439,7 +347,11 @@ class RealtorScraper(Scraper):
|
||||
|
||||
else: #: general search, location
|
||||
search_variables |= {
|
||||
"location": self.location,
|
||||
"city": location_info.get("city"),
|
||||
"county": location_info.get("county"),
|
||||
"state_code": location_info.get("state_code"),
|
||||
"postal_code": location_info.get("postal_code"),
|
||||
|
||||
}
|
||||
|
||||
if self.foreclosure:
|
||||
@@ -468,54 +380,7 @@ class RealtorScraper(Scraper):
|
||||
|
||||
return homes
|
||||
|
||||
@staticmethod
|
||||
def get_key(data: dict, keys: list):
|
||||
try:
|
||||
value = data
|
||||
for key in keys:
|
||||
value = value[key]
|
||||
|
||||
return value or {}
|
||||
except (KeyError, TypeError, IndexError):
|
||||
return {}
|
||||
|
||||
def process_extra_property_details(self, result: dict) -> dict:
|
||||
schools = self.get_key(result, ["nearbySchools", "schools"])
|
||||
assessed_value = self.get_key(result, ["taxHistory", 0, "assessment", "total"])
|
||||
tax_history = self.get_key(result, ["taxHistory"])
|
||||
|
||||
schools = [school["district"]["name"] for school in schools if school["district"].get("name")]
|
||||
|
||||
# Process tax history
|
||||
latest_tax = None
|
||||
processed_tax_history = None
|
||||
if tax_history and isinstance(tax_history, list):
|
||||
tax_history = sorted(tax_history, key=lambda x: x.get("year", 0), reverse=True)
|
||||
|
||||
if tax_history and "tax" in tax_history[0]:
|
||||
latest_tax = tax_history[0]["tax"]
|
||||
|
||||
processed_tax_history = []
|
||||
for entry in tax_history:
|
||||
if "year" in entry and "tax" in entry:
|
||||
processed_entry = {
|
||||
"year": entry["year"],
|
||||
"tax": entry["tax"],
|
||||
}
|
||||
if "assessment" in entry and isinstance(entry["assessment"], dict):
|
||||
processed_entry["assessment"] = {
|
||||
"building": entry["assessment"].get("building"),
|
||||
"land": entry["assessment"].get("land"),
|
||||
"total": entry["assessment"].get("total"),
|
||||
}
|
||||
processed_tax_history.append(processed_entry)
|
||||
|
||||
return {
|
||||
"schools": schools if schools else None,
|
||||
"assessed_value": assessed_value if assessed_value else None,
|
||||
"tax": latest_tax,
|
||||
"tax_history": processed_tax_history,
|
||||
}
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(JSONDecodeError),
|
||||
@@ -552,117 +417,4 @@ class RealtorScraper(Scraper):
|
||||
properties = data["data"]
|
||||
return {data.replace('home_', ''): properties[data] for data in properties if properties[data]}
|
||||
|
||||
@staticmethod
|
||||
def _parse_neighborhoods(result: dict) -> Optional[str]:
|
||||
neighborhoods_list = []
|
||||
neighborhoods = result["location"].get("neighborhoods", [])
|
||||
|
||||
if neighborhoods:
|
||||
for neighborhood in neighborhoods:
|
||||
name = neighborhood.get("name")
|
||||
if name:
|
||||
neighborhoods_list.append(name)
|
||||
|
||||
return ", ".join(neighborhoods_list) if neighborhoods_list else None
|
||||
|
||||
@staticmethod
|
||||
def handle_none_safely(address_part):
|
||||
if address_part is None:
|
||||
return ""
|
||||
|
||||
return address_part
|
||||
|
||||
@staticmethod
|
||||
def _parse_address(result: dict, search_type):
|
||||
if search_type == "general_search":
|
||||
address = result["location"]["address"]
|
||||
else:
|
||||
address = result["address"]
|
||||
|
||||
return Address(
|
||||
full_line=address.get("line"),
|
||||
street=" ".join(
|
||||
part
|
||||
for part in [
|
||||
address.get("street_number"),
|
||||
address.get("street_direction"),
|
||||
address.get("street_name"),
|
||||
address.get("street_suffix"),
|
||||
]
|
||||
if part is not None
|
||||
).strip(),
|
||||
unit=address["unit"],
|
||||
city=address["city"],
|
||||
state=address["state_code"],
|
||||
zip=address["postal_code"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_description(result: dict) -> Description | None:
|
||||
if not result:
|
||||
return None
|
||||
|
||||
description_data = result.get("description", {})
|
||||
|
||||
if description_data is None or not isinstance(description_data, dict):
|
||||
description_data = {}
|
||||
|
||||
style = description_data.get("type", "")
|
||||
if style is not None:
|
||||
style = style.upper()
|
||||
|
||||
primary_photo = ""
|
||||
if (primary_photo_info := result.get("primary_photo")) and (
|
||||
primary_photo_href := primary_photo_info.get("href")
|
||||
):
|
||||
primary_photo = primary_photo_href.replace("s.jpg", "od-w480_h360_x2.webp?w=1080&q=75")
|
||||
|
||||
return Description(
|
||||
primary_photo=primary_photo,
|
||||
alt_photos=RealtorScraper.process_alt_photos(result.get("photos", [])),
|
||||
style=(PropertyType.__getitem__(style) if style and style in PropertyType.__members__ else None),
|
||||
beds=description_data.get("beds"),
|
||||
baths_full=description_data.get("baths_full"),
|
||||
baths_half=description_data.get("baths_half"),
|
||||
sqft=description_data.get("sqft"),
|
||||
lot_sqft=description_data.get("lot_sqft"),
|
||||
sold_price=(
|
||||
result.get("last_sold_price") or description_data.get("sold_price")
|
||||
if result.get("last_sold_date") or result["list_price"] != description_data.get("sold_price")
|
||||
else None
|
||||
), #: has a sold date or list and sold price are different
|
||||
year_built=description_data.get("year_built"),
|
||||
garage=description_data.get("garage"),
|
||||
stories=description_data.get("stories"),
|
||||
text=description_data.get("text"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def calculate_days_on_mls(result: dict) -> Optional[int]:
|
||||
list_date_str = result.get("list_date")
|
||||
list_date = datetime.strptime(list_date_str.split("T")[0], "%Y-%m-%d") if list_date_str else None
|
||||
last_sold_date_str = result.get("last_sold_date")
|
||||
last_sold_date = datetime.strptime(last_sold_date_str, "%Y-%m-%d") if last_sold_date_str else None
|
||||
today = datetime.now()
|
||||
|
||||
if list_date:
|
||||
if result["status"] == "sold":
|
||||
if last_sold_date:
|
||||
days = (last_sold_date - list_date).days
|
||||
if days >= 0:
|
||||
return days
|
||||
elif result["status"] in ("for_sale", "for_rent"):
|
||||
days = (today - list_date).days
|
||||
if days >= 0:
|
||||
return days
|
||||
|
||||
@staticmethod
|
||||
def process_alt_photos(photos_info: list[dict]) -> list[str] | None:
|
||||
if not photos_info:
|
||||
return None
|
||||
|
||||
return [
|
||||
photo_info["href"].replace("s.jpg", "od-w480_h360_x2.webp?w=1080&q=75")
|
||||
for photo_info in photos_info
|
||||
if photo_info.get("href")
|
||||
]
|
||||
|
||||
48881
homeharvest/core/scrapers/realtor/introspection.json
Normal file
48881
homeharvest/core/scrapers/realtor/introspection.json
Normal file
File diff suppressed because it is too large
Load Diff
279
homeharvest/core/scrapers/realtor/parsers.py
Normal file
279
homeharvest/core/scrapers/realtor/parsers.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Parsers for realtor.com data processing
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from ..models import Address, Description, PropertyType
|
||||
|
||||
|
||||
def parse_open_houses(open_houses_data: list[dict] | None) -> list[dict] | None:
|
||||
"""Parse open houses data and convert date strings to datetime objects"""
|
||||
if not open_houses_data:
|
||||
return None
|
||||
|
||||
parsed_open_houses = []
|
||||
for oh in open_houses_data:
|
||||
parsed_oh = oh.copy()
|
||||
|
||||
# Parse start_date and end_date
|
||||
if parsed_oh.get("start_date"):
|
||||
try:
|
||||
parsed_oh["start_date"] = datetime.fromisoformat(parsed_oh["start_date"].replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
parsed_oh["start_date"] = None
|
||||
|
||||
if parsed_oh.get("end_date"):
|
||||
try:
|
||||
parsed_oh["end_date"] = datetime.fromisoformat(parsed_oh["end_date"].replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
parsed_oh["end_date"] = None
|
||||
|
||||
parsed_open_houses.append(parsed_oh)
|
||||
|
||||
return parsed_open_houses
|
||||
|
||||
|
||||
def parse_units(units_data: list[dict] | None) -> list[dict] | None:
|
||||
"""Parse units data and convert date strings to datetime objects"""
|
||||
if not units_data:
|
||||
return None
|
||||
|
||||
parsed_units = []
|
||||
for unit in units_data:
|
||||
parsed_unit = unit.copy()
|
||||
|
||||
# Parse availability date
|
||||
if parsed_unit.get("availability") and parsed_unit["availability"].get("date"):
|
||||
try:
|
||||
parsed_unit["availability"]["date"] = datetime.fromisoformat(parsed_unit["availability"]["date"].replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
parsed_unit["availability"]["date"] = None
|
||||
|
||||
parsed_units.append(parsed_unit)
|
||||
|
||||
return parsed_units
|
||||
|
||||
|
||||
def parse_tax_record(tax_record_data: dict | None) -> dict | None:
|
||||
"""Parse tax record data and convert date strings to datetime objects"""
|
||||
if not tax_record_data:
|
||||
return None
|
||||
|
||||
parsed_tax_record = tax_record_data.copy()
|
||||
|
||||
# Parse last_update_date
|
||||
if parsed_tax_record.get("last_update_date"):
|
||||
try:
|
||||
parsed_tax_record["last_update_date"] = datetime.fromisoformat(parsed_tax_record["last_update_date"].replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
parsed_tax_record["last_update_date"] = None
|
||||
|
||||
return parsed_tax_record
|
||||
|
||||
|
||||
def parse_current_estimates(estimates_data: list[dict] | None) -> list[dict] | None:
|
||||
"""Parse current estimates data and convert date strings to datetime objects"""
|
||||
if not estimates_data:
|
||||
return None
|
||||
|
||||
parsed_estimates = []
|
||||
for estimate in estimates_data:
|
||||
parsed_estimate = estimate.copy()
|
||||
|
||||
# Parse date
|
||||
if parsed_estimate.get("date"):
|
||||
try:
|
||||
parsed_estimate["date"] = datetime.fromisoformat(parsed_estimate["date"].replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
parsed_estimate["date"] = None
|
||||
|
||||
# Parse source information
|
||||
if parsed_estimate.get("source"):
|
||||
source_data = parsed_estimate["source"]
|
||||
parsed_estimate["source"] = {
|
||||
"type": source_data.get("type"),
|
||||
"name": source_data.get("name")
|
||||
}
|
||||
|
||||
parsed_estimates.append(parsed_estimate)
|
||||
|
||||
return parsed_estimates
|
||||
|
||||
|
||||
def parse_estimates(estimates_data: dict | None) -> dict | None:
|
||||
"""Parse estimates data and convert date strings to datetime objects"""
|
||||
if not estimates_data:
|
||||
return None
|
||||
|
||||
parsed_estimates = estimates_data.copy()
|
||||
|
||||
# Parse current_values (which is aliased as currentValues in GraphQL)
|
||||
current_values = parsed_estimates.get("currentValues") or parsed_estimates.get("current_values")
|
||||
if current_values:
|
||||
parsed_current_values = []
|
||||
for estimate in current_values:
|
||||
parsed_estimate = estimate.copy()
|
||||
|
||||
# Parse date
|
||||
if parsed_estimate.get("date"):
|
||||
try:
|
||||
parsed_estimate["date"] = datetime.fromisoformat(parsed_estimate["date"].replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
parsed_estimate["date"] = None
|
||||
|
||||
# Parse source information
|
||||
if parsed_estimate.get("source"):
|
||||
source_data = parsed_estimate["source"]
|
||||
parsed_estimate["source"] = {
|
||||
"type": source_data.get("type"),
|
||||
"name": source_data.get("name")
|
||||
}
|
||||
|
||||
# Convert GraphQL aliases to Pydantic field names
|
||||
if "estimateHigh" in parsed_estimate:
|
||||
parsed_estimate["estimate_high"] = parsed_estimate.pop("estimateHigh")
|
||||
if "estimateLow" in parsed_estimate:
|
||||
parsed_estimate["estimate_low"] = parsed_estimate.pop("estimateLow")
|
||||
if "isBestHomeValue" in parsed_estimate:
|
||||
parsed_estimate["is_best_home_value"] = parsed_estimate.pop("isBestHomeValue")
|
||||
|
||||
parsed_current_values.append(parsed_estimate)
|
||||
|
||||
parsed_estimates["current_values"] = parsed_current_values
|
||||
|
||||
# Remove the GraphQL alias if it exists
|
||||
if "currentValues" in parsed_estimates:
|
||||
del parsed_estimates["currentValues"]
|
||||
|
||||
return parsed_estimates
|
||||
|
||||
|
||||
def parse_neighborhoods(result: dict) -> Optional[str]:
|
||||
"""Parse neighborhoods from location data"""
|
||||
neighborhoods_list = []
|
||||
neighborhoods = result["location"].get("neighborhoods", [])
|
||||
|
||||
if neighborhoods:
|
||||
for neighborhood in neighborhoods:
|
||||
name = neighborhood.get("name")
|
||||
if name:
|
||||
neighborhoods_list.append(name)
|
||||
|
||||
return ", ".join(neighborhoods_list) if neighborhoods_list else None
|
||||
|
||||
|
||||
def handle_none_safely(address_part):
|
||||
"""Handle None values safely for address parts"""
|
||||
if address_part is None:
|
||||
return ""
|
||||
return address_part
|
||||
|
||||
|
||||
def parse_address(result: dict, search_type: str) -> Address:
|
||||
"""Parse address data from result"""
|
||||
if search_type == "general_search":
|
||||
address = result["location"]["address"]
|
||||
else:
|
||||
address = result["address"]
|
||||
|
||||
return Address(
|
||||
full_line=address.get("line"),
|
||||
street=" ".join(
|
||||
part
|
||||
for part in [
|
||||
address.get("street_number"),
|
||||
address.get("street_direction"),
|
||||
address.get("street_name"),
|
||||
address.get("street_suffix"),
|
||||
]
|
||||
if part is not None
|
||||
).strip(),
|
||||
unit=address["unit"],
|
||||
city=address["city"],
|
||||
state=address["state_code"],
|
||||
zip=address["postal_code"],
|
||||
|
||||
# Additional address fields
|
||||
street_direction=address.get("street_direction"),
|
||||
street_number=address.get("street_number"),
|
||||
street_name=address.get("street_name"),
|
||||
street_suffix=address.get("street_suffix"),
|
||||
)
|
||||
|
||||
|
||||
def parse_description(result: dict) -> Description | None:
|
||||
"""Parse description data from result"""
|
||||
if not result:
|
||||
return None
|
||||
|
||||
description_data = result.get("description", {})
|
||||
|
||||
if description_data is None or not isinstance(description_data, dict):
|
||||
description_data = {}
|
||||
|
||||
style = description_data.get("type", "")
|
||||
if style is not None:
|
||||
style = style.upper()
|
||||
|
||||
primary_photo = None
|
||||
if (primary_photo_info := result.get("primary_photo")) and (
|
||||
primary_photo_href := primary_photo_info.get("href")
|
||||
):
|
||||
primary_photo = primary_photo_href.replace("s.jpg", "od-w480_h360_x2.webp?w=1080&q=75")
|
||||
|
||||
return Description(
|
||||
primary_photo=primary_photo,
|
||||
alt_photos=process_alt_photos(result.get("photos", [])),
|
||||
style=(PropertyType.__getitem__(style) if style and style in PropertyType.__members__ else None),
|
||||
beds=description_data.get("beds"),
|
||||
baths_full=description_data.get("baths_full"),
|
||||
baths_half=description_data.get("baths_half"),
|
||||
sqft=description_data.get("sqft"),
|
||||
lot_sqft=description_data.get("lot_sqft"),
|
||||
sold_price=(
|
||||
result.get("last_sold_price") or description_data.get("sold_price")
|
||||
if result.get("last_sold_date") or result["list_price"] != description_data.get("sold_price")
|
||||
else None
|
||||
), #: has a sold date or list and sold price are different
|
||||
year_built=description_data.get("year_built"),
|
||||
garage=description_data.get("garage"),
|
||||
stories=description_data.get("stories"),
|
||||
text=description_data.get("text"),
|
||||
|
||||
# Additional description fields
|
||||
name=description_data.get("name"),
|
||||
type=description_data.get("type"),
|
||||
)
|
||||
|
||||
|
||||
def calculate_days_on_mls(result: dict) -> Optional[int]:
|
||||
"""Calculate days on MLS from result data"""
|
||||
list_date_str = result.get("list_date")
|
||||
list_date = datetime.strptime(list_date_str.split("T")[0], "%Y-%m-%d") if list_date_str else None
|
||||
last_sold_date_str = result.get("last_sold_date")
|
||||
last_sold_date = datetime.strptime(last_sold_date_str, "%Y-%m-%d") if last_sold_date_str else None
|
||||
today = datetime.now()
|
||||
|
||||
if list_date:
|
||||
if result["status"] == "sold":
|
||||
if last_sold_date:
|
||||
days = (last_sold_date - list_date).days
|
||||
if days >= 0:
|
||||
return days
|
||||
elif result["status"] in ("for_sale", "for_rent"):
|
||||
days = (today - list_date).days
|
||||
if days >= 0:
|
||||
return days
|
||||
|
||||
|
||||
def process_alt_photos(photos_info: list[dict]) -> list[str] | None:
|
||||
"""Process alternative photos from photos info"""
|
||||
if not photos_info:
|
||||
return None
|
||||
|
||||
return [
|
||||
photo_info["href"].replace("s.jpg", "od-w480_h360_x2.webp?w=1080&q=75")
|
||||
for photo_info in photos_info
|
||||
if photo_info.get("href")
|
||||
]
|
||||
224
homeharvest/core/scrapers/realtor/processors.py
Normal file
224
homeharvest/core/scrapers/realtor/processors.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Processors for realtor.com property data processing
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from ..models import (
|
||||
Property,
|
||||
ListingType,
|
||||
Agent,
|
||||
Broker,
|
||||
Builder,
|
||||
Advertisers,
|
||||
Office,
|
||||
ReturnType
|
||||
)
|
||||
from .parsers import (
|
||||
parse_open_houses,
|
||||
parse_units,
|
||||
parse_tax_record,
|
||||
parse_current_estimates,
|
||||
parse_estimates,
|
||||
parse_neighborhoods,
|
||||
parse_address,
|
||||
parse_description,
|
||||
calculate_days_on_mls,
|
||||
process_alt_photos
|
||||
)
|
||||
|
||||
|
||||
def process_advertisers(advertisers: list[dict] | None) -> Advertisers | None:
|
||||
"""Process advertisers data from GraphQL response"""
|
||||
if not advertisers:
|
||||
return None
|
||||
|
||||
def _parse_fulfillment_id(fulfillment_id: str | None) -> str | None:
|
||||
return fulfillment_id if fulfillment_id and fulfillment_id != "0" else None
|
||||
|
||||
processed_advertisers = Advertisers()
|
||||
|
||||
for advertiser in advertisers:
|
||||
advertiser_type = advertiser.get("type")
|
||||
if advertiser_type == "seller": #: agent
|
||||
processed_advertisers.agent = Agent(
|
||||
uuid=_parse_fulfillment_id(advertiser.get("fulfillment_id")),
|
||||
nrds_id=advertiser.get("nrds_id"),
|
||||
mls_set=advertiser.get("mls_set"),
|
||||
name=advertiser.get("name"),
|
||||
email=advertiser.get("email"),
|
||||
phones=advertiser.get("phones"),
|
||||
state_license=advertiser.get("state_license"),
|
||||
)
|
||||
|
||||
if advertiser.get("broker") and advertiser["broker"].get("name"): #: has a broker
|
||||
processed_advertisers.broker = Broker(
|
||||
uuid=_parse_fulfillment_id(advertiser["broker"].get("fulfillment_id")),
|
||||
name=advertiser["broker"].get("name"),
|
||||
)
|
||||
|
||||
if advertiser.get("office"): #: has an office
|
||||
processed_advertisers.office = Office(
|
||||
uuid=_parse_fulfillment_id(advertiser["office"].get("fulfillment_id")),
|
||||
mls_set=advertiser["office"].get("mls_set"),
|
||||
name=advertiser["office"].get("name"),
|
||||
email=advertiser["office"].get("email"),
|
||||
phones=advertiser["office"].get("phones"),
|
||||
)
|
||||
|
||||
if advertiser_type == "community": #: could be builder
|
||||
if advertiser.get("builder"):
|
||||
processed_advertisers.builder = Builder(
|
||||
uuid=_parse_fulfillment_id(advertiser["builder"].get("fulfillment_id")),
|
||||
name=advertiser["builder"].get("name"),
|
||||
)
|
||||
|
||||
return processed_advertisers
|
||||
|
||||
|
||||
def process_property(result: dict, mls_only: bool = False, extra_property_data: bool = False,
|
||||
exclude_pending: bool = False, listing_type: ListingType = ListingType.FOR_SALE,
|
||||
get_key_func=None, process_extra_property_details_func=None) -> Property | None:
|
||||
"""Process property data from GraphQL response"""
|
||||
mls = result["source"].get("id") if "source" in result and isinstance(result["source"], dict) else None
|
||||
|
||||
if not mls and mls_only:
|
||||
return None
|
||||
|
||||
able_to_get_lat_long = (
|
||||
result
|
||||
and result.get("location")
|
||||
and result["location"].get("address")
|
||||
and result["location"]["address"].get("coordinate")
|
||||
)
|
||||
|
||||
is_pending = result["flags"].get("is_pending")
|
||||
is_contingent = result["flags"].get("is_contingent")
|
||||
|
||||
if (is_pending or is_contingent) and (exclude_pending and listing_type != ListingType.PENDING):
|
||||
return None
|
||||
|
||||
property_id = result["property_id"]
|
||||
prop_details = process_extra_property_details_func(result) if extra_property_data and process_extra_property_details_func else {}
|
||||
|
||||
property_estimates_root = result.get("current_estimates") or result.get("estimates", {}).get("currentValues")
|
||||
estimated_value = get_key_func(property_estimates_root, [0, "estimate"]) if get_key_func else None
|
||||
|
||||
advertisers = process_advertisers(result.get("advertisers"))
|
||||
|
||||
realty_property = Property(
|
||||
mls=mls,
|
||||
mls_id=(
|
||||
result["source"].get("listing_id")
|
||||
if "source" in result and isinstance(result["source"], dict)
|
||||
else None
|
||||
),
|
||||
property_url=result["href"],
|
||||
property_id=property_id,
|
||||
listing_id=result.get("listing_id"),
|
||||
permalink=result.get("permalink"),
|
||||
status=("PENDING" if is_pending else "CONTINGENT" if is_contingent else result["status"].upper()),
|
||||
list_price=result["list_price"],
|
||||
list_price_min=result["list_price_min"],
|
||||
list_price_max=result["list_price_max"],
|
||||
list_date=(datetime.fromisoformat(result["list_date"].split("T")[0]) if result.get("list_date") else None),
|
||||
prc_sqft=result.get("price_per_sqft"),
|
||||
last_sold_date=(datetime.fromisoformat(result["last_sold_date"]) if result.get("last_sold_date") else None),
|
||||
pending_date=(datetime.fromisoformat(result["pending_date"].split("T")[0]) if result.get("pending_date") else None),
|
||||
new_construction=result["flags"].get("is_new_construction") is True,
|
||||
hoa_fee=(result["hoa"]["fee"] if result.get("hoa") and isinstance(result["hoa"], dict) else None),
|
||||
latitude=(result["location"]["address"]["coordinate"].get("lat") if able_to_get_lat_long else None),
|
||||
longitude=(result["location"]["address"]["coordinate"].get("lon") if able_to_get_lat_long else None),
|
||||
address=parse_address(result, search_type="general_search"),
|
||||
description=parse_description(result),
|
||||
neighborhoods=parse_neighborhoods(result),
|
||||
county=(result["location"]["county"].get("name") if result["location"]["county"] else None),
|
||||
fips_code=(result["location"]["county"].get("fips_code") if result["location"]["county"] else None),
|
||||
days_on_mls=calculate_days_on_mls(result),
|
||||
nearby_schools=prop_details.get("schools"),
|
||||
assessed_value=prop_details.get("assessed_value"),
|
||||
estimated_value=estimated_value if estimated_value else None,
|
||||
advertisers=advertisers,
|
||||
tax=prop_details.get("tax"),
|
||||
tax_history=prop_details.get("tax_history"),
|
||||
|
||||
# Additional fields from GraphQL
|
||||
mls_status=result.get("mls_status"),
|
||||
last_sold_price=result.get("last_sold_price"),
|
||||
tags=result.get("tags"),
|
||||
details=result.get("details"),
|
||||
open_houses=parse_open_houses(result.get("open_houses")),
|
||||
pet_policy=result.get("pet_policy"),
|
||||
units=parse_units(result.get("units")),
|
||||
monthly_fees=result.get("monthly_fees"),
|
||||
one_time_fees=result.get("one_time_fees"),
|
||||
parking=result.get("parking"),
|
||||
terms=result.get("terms"),
|
||||
popularity=result.get("popularity"),
|
||||
tax_record=parse_tax_record(result.get("tax_record")),
|
||||
parcel_info=result.get("location", {}).get("parcel"),
|
||||
current_estimates=parse_current_estimates(result.get("current_estimates")),
|
||||
estimates=parse_estimates(result.get("estimates")),
|
||||
photos=result.get("photos"),
|
||||
flags=result.get("flags"),
|
||||
)
|
||||
return realty_property
|
||||
|
||||
|
||||
def process_extra_property_details(result: dict, get_key_func=None) -> dict:
|
||||
"""Process extra property details from GraphQL response"""
|
||||
if get_key_func:
|
||||
schools = get_key_func(result, ["nearbySchools", "schools"])
|
||||
assessed_value = get_key_func(result, ["taxHistory", 0, "assessment", "total"])
|
||||
tax_history = get_key_func(result, ["taxHistory"])
|
||||
else:
|
||||
nearby_schools = result.get("nearbySchools")
|
||||
schools = nearby_schools.get("schools", []) if nearby_schools else []
|
||||
tax_history_data = result.get("taxHistory", [])
|
||||
assessed_value = tax_history_data[0]["assessment"]["total"] if tax_history_data and tax_history_data[0].get("assessment", {}).get("total") else None
|
||||
tax_history = tax_history_data
|
||||
|
||||
if schools:
|
||||
schools = [school["district"]["name"] for school in schools if school["district"].get("name")]
|
||||
|
||||
# Process tax history
|
||||
latest_tax = None
|
||||
processed_tax_history = None
|
||||
if tax_history and isinstance(tax_history, list):
|
||||
tax_history = sorted(tax_history, key=lambda x: x.get("year", 0), reverse=True)
|
||||
|
||||
if tax_history and "tax" in tax_history[0]:
|
||||
latest_tax = tax_history[0]["tax"]
|
||||
|
||||
processed_tax_history = []
|
||||
for entry in tax_history:
|
||||
if "year" in entry and "tax" in entry:
|
||||
processed_entry = {
|
||||
"year": entry["year"],
|
||||
"tax": entry["tax"],
|
||||
}
|
||||
if "assessment" in entry and isinstance(entry["assessment"], dict):
|
||||
processed_entry["assessment"] = {
|
||||
"building": entry["assessment"].get("building"),
|
||||
"land": entry["assessment"].get("land"),
|
||||
"total": entry["assessment"].get("total"),
|
||||
}
|
||||
processed_tax_history.append(processed_entry)
|
||||
|
||||
return {
|
||||
"schools": schools if schools else None,
|
||||
"assessed_value": assessed_value if assessed_value else None,
|
||||
"tax": latest_tax,
|
||||
"tax_history": processed_tax_history,
|
||||
}
|
||||
|
||||
|
||||
def get_key(data: dict, keys: list):
|
||||
"""Get nested key from dictionary safely"""
|
||||
try:
|
||||
value = data
|
||||
for key in keys:
|
||||
value = value[key]
|
||||
return value or {}
|
||||
except (KeyError, TypeError, IndexError):
|
||||
return {}
|
||||
@@ -3,8 +3,10 @@ _SEARCH_HOMES_DATA_BASE = """{
|
||||
listing_id
|
||||
property_id
|
||||
href
|
||||
permalink
|
||||
list_date
|
||||
status
|
||||
mls_status
|
||||
last_sold_price
|
||||
last_sold_date
|
||||
list_price
|
||||
@@ -12,6 +14,15 @@ _SEARCH_HOMES_DATA_BASE = """{
|
||||
list_price_min
|
||||
price_per_sqft
|
||||
tags
|
||||
open_houses {
|
||||
start_date
|
||||
end_date
|
||||
description
|
||||
time_zone
|
||||
dst
|
||||
href
|
||||
methods
|
||||
}
|
||||
details {
|
||||
category
|
||||
text
|
||||
@@ -36,6 +47,13 @@ _SEARCH_HOMES_DATA_BASE = """{
|
||||
sqft
|
||||
__typename
|
||||
}
|
||||
photos(https: true) {
|
||||
title
|
||||
href
|
||||
tags {
|
||||
label
|
||||
}
|
||||
}
|
||||
list_price
|
||||
__typename
|
||||
}
|
||||
@@ -90,12 +108,17 @@ _SEARCH_HOMES_DATA_BASE = """{
|
||||
}
|
||||
}
|
||||
tax_record {
|
||||
cl_id
|
||||
public_record_id
|
||||
last_update_date
|
||||
apn
|
||||
tax_parcel_id
|
||||
}
|
||||
primary_photo(https: true) {
|
||||
href
|
||||
}
|
||||
photos(https: true) {
|
||||
title
|
||||
href
|
||||
tags {
|
||||
label
|
||||
@@ -142,6 +165,7 @@ _SEARCH_HOMES_DATA_BASE = """{
|
||||
}
|
||||
mls_set
|
||||
nrds_id
|
||||
state_license
|
||||
rental_corporation {
|
||||
fulfillment_id
|
||||
}
|
||||
@@ -160,6 +184,23 @@ fragment HomeData on Home {
|
||||
nearbySchools: nearby_schools(radius: 5.0, limit_per_level: 3) {
|
||||
__typename schools { district { __typename id name } }
|
||||
}
|
||||
popularity {
|
||||
periods {
|
||||
clicks_total
|
||||
views_total
|
||||
dwell_time_mean
|
||||
dwell_time_median
|
||||
leads_total
|
||||
shares_total
|
||||
saves_total
|
||||
last_n_days
|
||||
}
|
||||
}
|
||||
location {
|
||||
parcel {
|
||||
parcel_id
|
||||
}
|
||||
}
|
||||
taxHistory: tax_history { __typename tax year assessment { __typename building land total } }
|
||||
monthly_fees {
|
||||
description
|
||||
@@ -194,6 +235,23 @@ HOMES_DATA = """%s
|
||||
description
|
||||
display_amount
|
||||
}
|
||||
popularity {
|
||||
periods {
|
||||
clicks_total
|
||||
views_total
|
||||
dwell_time_mean
|
||||
dwell_time_median
|
||||
leads_total
|
||||
shares_total
|
||||
saves_total
|
||||
last_n_days
|
||||
}
|
||||
}
|
||||
location {
|
||||
parcel {
|
||||
parcel_id
|
||||
}
|
||||
}
|
||||
parking {
|
||||
unassigned_space_rent
|
||||
assigned_spaces_available
|
||||
|
||||
@@ -8,11 +8,14 @@ ordered_properties = [
|
||||
"property_url",
|
||||
"property_id",
|
||||
"listing_id",
|
||||
"permalink",
|
||||
"mls",
|
||||
"mls_id",
|
||||
"status",
|
||||
"mls_status",
|
||||
"text",
|
||||
"style",
|
||||
"formatted_address",
|
||||
"full_street_line",
|
||||
"street",
|
||||
"unit",
|
||||
@@ -29,8 +32,10 @@ ordered_properties = [
|
||||
"list_price_min",
|
||||
"list_price_max",
|
||||
"list_date",
|
||||
"pending_date",
|
||||
"sold_price",
|
||||
"last_sold_date",
|
||||
"last_sold_price",
|
||||
"assessed_value",
|
||||
"estimated_value",
|
||||
"tax",
|
||||
@@ -63,60 +68,70 @@ ordered_properties = [
|
||||
"office_phones",
|
||||
"nearby_schools",
|
||||
"primary_photo",
|
||||
"alt_photos",
|
||||
"alt_photos"
|
||||
]
|
||||
|
||||
|
||||
def process_result(result: Property) -> pd.DataFrame:
|
||||
prop_data = {prop: None for prop in ordered_properties}
|
||||
prop_data.update(result.__dict__)
|
||||
prop_data.update(result.model_dump())
|
||||
|
||||
if "address" in prop_data:
|
||||
if "address" in prop_data and prop_data["address"]:
|
||||
address_data = prop_data["address"]
|
||||
prop_data["full_street_line"] = address_data.full_line
|
||||
prop_data["street"] = address_data.street
|
||||
prop_data["unit"] = address_data.unit
|
||||
prop_data["city"] = address_data.city
|
||||
prop_data["state"] = address_data.state
|
||||
prop_data["zip_code"] = address_data.zip
|
||||
prop_data["full_street_line"] = address_data.get("full_line")
|
||||
prop_data["street"] = address_data.get("street")
|
||||
prop_data["unit"] = address_data.get("unit")
|
||||
prop_data["city"] = address_data.get("city")
|
||||
prop_data["state"] = address_data.get("state")
|
||||
prop_data["zip_code"] = address_data.get("zip")
|
||||
prop_data["formatted_address"] = address_data.get("formatted_address")
|
||||
|
||||
if "advertisers" in prop_data and prop_data.get("advertisers"):
|
||||
advertiser_data: Advertisers | None = prop_data["advertisers"]
|
||||
if advertiser_data.agent:
|
||||
agent_data = advertiser_data.agent
|
||||
prop_data["agent_id"] = agent_data.uuid
|
||||
prop_data["agent_name"] = agent_data.name
|
||||
prop_data["agent_email"] = agent_data.email
|
||||
prop_data["agent_phones"] = agent_data.phones
|
||||
prop_data["agent_mls_set"] = agent_data.mls_set
|
||||
prop_data["agent_nrds_id"] = agent_data.nrds_id
|
||||
advertiser_data = prop_data["advertisers"]
|
||||
if advertiser_data.get("agent"):
|
||||
agent_data = advertiser_data["agent"]
|
||||
prop_data["agent_id"] = agent_data.get("uuid")
|
||||
prop_data["agent_name"] = agent_data.get("name")
|
||||
prop_data["agent_email"] = agent_data.get("email")
|
||||
prop_data["agent_phones"] = agent_data.get("phones")
|
||||
prop_data["agent_mls_set"] = agent_data.get("mls_set")
|
||||
prop_data["agent_nrds_id"] = agent_data.get("nrds_id")
|
||||
|
||||
if advertiser_data.broker:
|
||||
broker_data = advertiser_data.broker
|
||||
prop_data["broker_id"] = broker_data.uuid
|
||||
prop_data["broker_name"] = broker_data.name
|
||||
if advertiser_data.get("broker"):
|
||||
broker_data = advertiser_data["broker"]
|
||||
prop_data["broker_id"] = broker_data.get("uuid")
|
||||
prop_data["broker_name"] = broker_data.get("name")
|
||||
|
||||
if advertiser_data.builder:
|
||||
builder_data = advertiser_data.builder
|
||||
prop_data["builder_id"] = builder_data.uuid
|
||||
prop_data["builder_name"] = builder_data.name
|
||||
if advertiser_data.get("builder"):
|
||||
builder_data = advertiser_data["builder"]
|
||||
prop_data["builder_id"] = builder_data.get("uuid")
|
||||
prop_data["builder_name"] = builder_data.get("name")
|
||||
|
||||
if advertiser_data.office:
|
||||
office_data = advertiser_data.office
|
||||
prop_data["office_id"] = office_data.uuid
|
||||
prop_data["office_name"] = office_data.name
|
||||
prop_data["office_email"] = office_data.email
|
||||
prop_data["office_phones"] = office_data.phones
|
||||
prop_data["office_mls_set"] = office_data.mls_set
|
||||
if advertiser_data.get("office"):
|
||||
office_data = advertiser_data["office"]
|
||||
prop_data["office_id"] = office_data.get("uuid")
|
||||
prop_data["office_name"] = office_data.get("name")
|
||||
prop_data["office_email"] = office_data.get("email")
|
||||
prop_data["office_phones"] = office_data.get("phones")
|
||||
prop_data["office_mls_set"] = office_data.get("mls_set")
|
||||
|
||||
prop_data["price_per_sqft"] = prop_data["prc_sqft"]
|
||||
prop_data["nearby_schools"] = filter(None, prop_data["nearby_schools"]) if prop_data["nearby_schools"] else None
|
||||
prop_data["nearby_schools"] = ", ".join(set(prop_data["nearby_schools"])) if prop_data["nearby_schools"] else None
|
||||
|
||||
# Convert datetime objects to strings for CSV
|
||||
for date_field in ["list_date", "pending_date", "last_sold_date"]:
|
||||
if prop_data.get(date_field):
|
||||
prop_data[date_field] = prop_data[date_field].strftime("%Y-%m-%d") if hasattr(prop_data[date_field], 'strftime') else prop_data[date_field]
|
||||
|
||||
# Convert HttpUrl objects to strings for CSV
|
||||
if prop_data.get("property_url"):
|
||||
prop_data["property_url"] = str(prop_data["property_url"])
|
||||
|
||||
description = result.description
|
||||
if description:
|
||||
prop_data["primary_photo"] = description.primary_photo
|
||||
prop_data["alt_photos"] = ", ".join(description.alt_photos) if description.alt_photos else None
|
||||
prop_data["primary_photo"] = str(description.primary_photo) if description.primary_photo else None
|
||||
prop_data["alt_photos"] = ", ".join(str(url) for url in description.alt_photos) if description.alt_photos else None
|
||||
prop_data["style"] = (
|
||||
description.style
|
||||
if isinstance(description.style, str)
|
||||
|
||||
1059
poetry.lock
generated
1059
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "homeharvest"
|
||||
version = "0.4.7"
|
||||
version = "0.5.0"
|
||||
description = "Real estate scraping library"
|
||||
authors = ["Zachary Hampton <zachary@bunsly.com>", "Cullen Watson <cullen@bunsly.com>"]
|
||||
homepage = "https://github.com/Bunsly/HomeHarvest"
|
||||
@@ -11,10 +11,10 @@ homeharvest = "homeharvest.cli:main"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9,<3.13"
|
||||
requests = "^2.31.0"
|
||||
pandas = "^2.1.1"
|
||||
pydantic = "^2.7.4"
|
||||
tenacity = "^9.0.0"
|
||||
requests = "^2.32.4"
|
||||
pandas = "^2.3.1"
|
||||
pydantic = "^2.11.7"
|
||||
tenacity = "^9.1.2"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
@@ -254,16 +254,6 @@ def test_apartment_list_price():
|
||||
)
|
||||
|
||||
|
||||
def test_builder_exists():
|
||||
listing = scrape_property(
|
||||
location="18149 W Poston Dr, Surprise, AZ 85387",
|
||||
extra_property_data=False,
|
||||
)
|
||||
|
||||
assert listing is not None
|
||||
assert listing["builder_name"].nunique() > 0
|
||||
|
||||
|
||||
def test_phone_number_matching():
|
||||
searches = [
|
||||
scrape_property(
|
||||
@@ -292,11 +282,94 @@ def test_phone_number_matching():
|
||||
|
||||
def test_return_type():
|
||||
results = {
|
||||
"pandas": scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100),
|
||||
"pydantic": scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100, return_type="pydantic"),
|
||||
"raw": scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100, return_type="raw"),
|
||||
"pandas": [scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100)],
|
||||
"pydantic": [scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100, return_type="pydantic")],
|
||||
"raw": [
|
||||
scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100, return_type="raw"),
|
||||
scrape_property(location="66642", listing_type="for_rent", limit=100, return_type="raw"),
|
||||
],
|
||||
}
|
||||
|
||||
assert isinstance(results["pandas"], pd.DataFrame)
|
||||
assert isinstance(results["pydantic"][0], Property)
|
||||
assert isinstance(results["raw"][0], dict)
|
||||
assert all(isinstance(result, pd.DataFrame) for result in results["pandas"])
|
||||
assert all(isinstance(result[0], Property) for result in results["pydantic"])
|
||||
assert all(isinstance(result[0], dict) for result in results["raw"])
|
||||
|
||||
|
||||
def test_has_open_house():
|
||||
address_result = scrape_property("1 Hawthorne St Unit 12F, San Francisco, CA 94105", return_type="raw")
|
||||
assert address_result[0]["open_houses"] is not None #: has open house data from address search
|
||||
|
||||
zip_code_result = scrape_property("94105", return_type="raw")
|
||||
address_from_zip_result = list(filter(lambda row: row["property_id"] == '1264014746', zip_code_result))
|
||||
|
||||
assert address_from_zip_result[0]["open_houses"] is not None #: has open house data from general search
|
||||
|
||||
|
||||
|
||||
def test_return_type_consistency():
|
||||
"""Test that return_type works consistently between general and address searches"""
|
||||
|
||||
# Test configurations - different search types
|
||||
test_locations = [
|
||||
("Dallas, TX", "general"), # General city search
|
||||
("75201", "zip"), # ZIP code search
|
||||
("2530 Al Lipscomb Way", "address") # Address search
|
||||
]
|
||||
|
||||
for location, search_type in test_locations:
|
||||
# Test all return types for each search type
|
||||
pandas_result = scrape_property(
|
||||
location=location,
|
||||
listing_type="for_sale",
|
||||
limit=3,
|
||||
return_type="pandas"
|
||||
)
|
||||
|
||||
pydantic_result = scrape_property(
|
||||
location=location,
|
||||
listing_type="for_sale",
|
||||
limit=3,
|
||||
return_type="pydantic"
|
||||
)
|
||||
|
||||
raw_result = scrape_property(
|
||||
location=location,
|
||||
listing_type="for_sale",
|
||||
limit=3,
|
||||
return_type="raw"
|
||||
)
|
||||
|
||||
# Validate pandas return type
|
||||
assert isinstance(pandas_result, pd.DataFrame), f"pandas result should be DataFrame for {search_type}"
|
||||
assert len(pandas_result) > 0, f"pandas result should not be empty for {search_type}"
|
||||
|
||||
required_columns = ["property_id", "property_url", "list_price", "status", "formatted_address"]
|
||||
for col in required_columns:
|
||||
assert col in pandas_result.columns, f"Missing column {col} in pandas result for {search_type}"
|
||||
|
||||
# Validate pydantic return type
|
||||
assert isinstance(pydantic_result, list), f"pydantic result should be list for {search_type}"
|
||||
assert len(pydantic_result) > 0, f"pydantic result should not be empty for {search_type}"
|
||||
|
||||
for item in pydantic_result:
|
||||
assert isinstance(item, Property), f"pydantic items should be Property objects for {search_type}"
|
||||
assert item.property_id is not None, f"property_id should not be None for {search_type}"
|
||||
|
||||
# Validate raw return type
|
||||
assert isinstance(raw_result, list), f"raw result should be list for {search_type}"
|
||||
assert len(raw_result) > 0, f"raw result should not be empty for {search_type}"
|
||||
|
||||
for item in raw_result:
|
||||
assert isinstance(item, dict), f"raw items should be dict for {search_type}"
|
||||
assert "property_id" in item, f"raw items should have property_id for {search_type}"
|
||||
assert "href" in item, f"raw items should have href for {search_type}"
|
||||
|
||||
# Cross-validate that different return types return related data
|
||||
pandas_ids = set(pandas_result["property_id"].tolist())
|
||||
pydantic_ids = set(prop.property_id for prop in pydantic_result)
|
||||
raw_ids = set(item["property_id"] for item in raw_result)
|
||||
|
||||
# All return types should have some properties
|
||||
assert len(pandas_ids) > 0, f"pandas should return properties for {search_type}"
|
||||
assert len(pydantic_ids) > 0, f"pydantic should return properties for {search_type}"
|
||||
assert len(raw_ids) > 0, f"raw should return properties for {search_type}"
|
||||
Reference in New Issue
Block a user