mirror of
https://github.com/Bunsly/HomeHarvest.git
synced 2026-03-04 19:44:29 -08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3579c10196 | ||
|
|
f5784e0191 | ||
|
|
57093f5d17 | ||
|
|
406ff97260 | ||
|
|
a8c9d0fd66 | ||
|
|
0b283e18bd | ||
|
|
8bf1f9e24b | ||
|
|
79b2b648f5 | ||
|
|
c2f01df1ad |
19
README.md
19
README.md
@@ -84,7 +84,7 @@ properties = scrape_property(
|
|||||||
#### Sorting & Listing Types
|
#### Sorting & Listing Types
|
||||||
```py
|
```py
|
||||||
# Sort options: list_price, list_date, sqft, beds, baths, last_update_date
|
# Sort options: list_price, list_date, sqft, beds, baths, last_update_date
|
||||||
# Listing types: "for_sale", "for_rent", "sold", "pending", list, or None (all)
|
# Listing types: "for_sale", "for_rent", "sold", "pending", "off_market", list, or None (common types)
|
||||||
properties = scrape_property(
|
properties = scrape_property(
|
||||||
location="Miami, FL",
|
location="Miami, FL",
|
||||||
listing_type=["for_sale", "pending"], # Single string, list, or None
|
listing_type=["for_sale", "pending"], # Single string, list, or None
|
||||||
@@ -94,6 +94,17 @@ properties = scrape_property(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Pagination Control
|
||||||
|
```py
|
||||||
|
# Sequential mode with early termination (more efficient for narrow filters)
|
||||||
|
properties = scrape_property(
|
||||||
|
location="Los Angeles, CA",
|
||||||
|
listing_type="for_sale",
|
||||||
|
updated_in_past_hours=2, # Narrow time window
|
||||||
|
parallel=False # Fetch pages sequentially, stop when filters no longer match
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
```plaintext
|
```plaintext
|
||||||
>>> properties.head()
|
>>> properties.head()
|
||||||
@@ -147,7 +158,7 @@ Required
|
|||||||
│ - 'other'
|
│ - 'other'
|
||||||
│ - 'ready_to_build'
|
│ - 'ready_to_build'
|
||||||
│ - List of strings returns properties matching ANY status: ['for_sale', 'pending']
|
│ - List of strings returns properties matching ANY status: ['for_sale', 'pending']
|
||||||
│ - None returns all listing types
|
│ - None returns common listing types (for_sale, for_rent, sold, pending, off_market)
|
||||||
│
|
│
|
||||||
Optional
|
Optional
|
||||||
├── property_type (list): Choose the type of properties.
|
├── property_type (list): Choose the type of properties.
|
||||||
@@ -234,7 +245,9 @@ Optional
|
|||||||
│
|
│
|
||||||
├── limit (integer): Limit the number of properties to fetch. Max & default is 10000.
|
├── limit (integer): Limit the number of properties to fetch. Max & default is 10000.
|
||||||
│
|
│
|
||||||
└── offset (integer): Starting position for pagination within the 10k limit. Use with limit to fetch results in chunks.
|
├── offset (integer): Starting position for pagination within the 10k limit. Use with limit to fetch results in chunks.
|
||||||
|
│
|
||||||
|
└── parallel (True/False): Controls pagination strategy. Default is True (fetch pages in parallel for speed). Set to False for sequential fetching with early termination (useful for rate limiting or narrow time windows).
|
||||||
```
|
```
|
||||||
|
|
||||||
### Property Schema
|
### Property Schema
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ def scrape_property(
|
|||||||
# New sorting parameters
|
# New sorting parameters
|
||||||
sort_by: str = None,
|
sort_by: str = None,
|
||||||
sort_direction: str = "desc",
|
sort_direction: str = "desc",
|
||||||
|
# Pagination control
|
||||||
|
parallel: bool = True,
|
||||||
) -> Union[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.
|
Scrape properties from Realtor.com based on a given location and listing type.
|
||||||
@@ -96,6 +98,9 @@ def scrape_property(
|
|||||||
:param year_built_min, year_built_max: Filter by year built
|
:param year_built_min, year_built_max: Filter by year built
|
||||||
:param sort_by: Sort results by field (list_date, sold_date, list_price, sqft, beds, baths, last_update_date)
|
:param sort_by: Sort results by field (list_date, sold_date, list_price, sqft, beds, baths, last_update_date)
|
||||||
:param sort_direction: Sort direction (asc, desc)
|
:param sort_direction: Sort direction (asc, desc)
|
||||||
|
:param parallel: Controls pagination strategy. True (default) = fetch all pages in parallel for maximum speed.
|
||||||
|
False = fetch pages sequentially with early termination checks (useful for rate limiting or narrow time windows).
|
||||||
|
Sequential mode will stop paginating as soon as time-based filters indicate no more matches are possible.
|
||||||
|
|
||||||
Note: past_days and past_hours also accept timedelta objects for more Pythonic usage.
|
Note: past_days and past_hours also accept timedelta objects for more Pythonic usage.
|
||||||
"""
|
"""
|
||||||
@@ -190,6 +195,8 @@ def scrape_property(
|
|||||||
# New sorting
|
# New sorting
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
sort_direction=sort_direction,
|
sort_direction=sort_direction,
|
||||||
|
# Pagination control
|
||||||
|
parallel=parallel,
|
||||||
)
|
)
|
||||||
|
|
||||||
site = RealtorScraper(scraper_input)
|
site = RealtorScraper(scraper_input)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class ScraperInput(BaseModel):
|
|||||||
sort_by: str | None = None
|
sort_by: str | None = None
|
||||||
sort_direction: str = "desc"
|
sort_direction: str = "desc"
|
||||||
|
|
||||||
|
# Pagination control
|
||||||
|
parallel: bool = True
|
||||||
|
|
||||||
|
|
||||||
class Scraper:
|
class Scraper:
|
||||||
session = None
|
session = None
|
||||||
@@ -73,26 +76,22 @@ class Scraper:
|
|||||||
total=3, backoff_factor=4, status_forcelist=[429, 403], allowed_methods=frozenset(["GET", "POST"])
|
total=3, backoff_factor=4, status_forcelist=[429, 403], allowed_methods=frozenset(["GET", "POST"])
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter = HTTPAdapter(max_retries=retries)
|
adapter = HTTPAdapter(max_retries=retries, pool_connections=10, pool_maxsize=20)
|
||||||
Scraper.session.mount("http://", adapter)
|
Scraper.session.mount("http://", adapter)
|
||||||
Scraper.session.mount("https://", adapter)
|
Scraper.session.mount("https://", adapter)
|
||||||
Scraper.session.headers.update(
|
Scraper.session.headers.update(
|
||||||
{
|
{
|
||||||
"accept": "application/json, text/javascript",
|
'Host': 'api.frontdoor.realtor.com',
|
||||||
"accept-language": "en-US,en;q=0.9",
|
'rdc-ab-test-client': 'ios_for_sale',
|
||||||
"cache-control": "no-cache",
|
'Content-Type': 'application/json',
|
||||||
"content-type": "application/json",
|
'apollographql-client-version': '26.9.25-26.9.25.0774600',
|
||||||
"origin": "https://www.realtor.com",
|
'Accept': '*/*',
|
||||||
"pragma": "no-cache",
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
"priority": "u=1, i",
|
'rdc-client-version': '26.9.25',
|
||||||
"rdc-ab-tests": "commute_travel_time_variation:v1",
|
'X-APOLLO-OPERATION-TYPE': 'query',
|
||||||
"sec-ch-ua": '"Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"',
|
'rdc-client-name': 'RDC_NATIVE_MOBILE-iPhone-com.move.Realtor',
|
||||||
"sec-ch-ua-mobile": "?0",
|
'apollographql-client-name': 'com.move.Realtor-apollo-ios',
|
||||||
"sec-ch-ua-platform": '"Windows"',
|
'User-Agent': 'Realtor.com/26.9.25.0774600 CFNetwork/3860.200.71 Darwin/25.1.0',
|
||||||
"sec-fetch-dest": "empty",
|
|
||||||
"sec-fetch-mode": "cors",
|
|
||||||
"sec-fetch-site": "same-origin",
|
|
||||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -141,6 +140,9 @@ class Scraper:
|
|||||||
self.sort_by = scraper_input.sort_by
|
self.sort_by = scraper_input.sort_by
|
||||||
self.sort_direction = scraper_input.sort_direction
|
self.sort_direction = scraper_input.sort_direction
|
||||||
|
|
||||||
|
# Pagination control
|
||||||
|
self.parallel = scraper_input.parallel
|
||||||
|
|
||||||
def search(self) -> list[Union[Property | dict]]: ...
|
def search(self) -> list[Union[Property | dict]]: ...
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -35,47 +35,109 @@ from .processors import (
|
|||||||
|
|
||||||
|
|
||||||
class RealtorScraper(Scraper):
|
class RealtorScraper(Scraper):
|
||||||
SEARCH_GQL_URL = "https://www.realtor.com/api/v1/rdc_search_srp?client_id=rdc-search-new-communities&schema=vesta"
|
SEARCH_GQL_URL = "https://api.frontdoor.realtor.com/graphql"
|
||||||
PROPERTY_URL = "https://www.realtor.com/realestateandhomes-detail/"
|
|
||||||
PROPERTY_GQL = "https://graph.realtor.com/graphql"
|
|
||||||
ADDRESS_AUTOCOMPLETE_URL = "https://parser-external.geo.moveaws.com/suggest"
|
|
||||||
NUM_PROPERTY_WORKERS = 20
|
NUM_PROPERTY_WORKERS = 20
|
||||||
DEFAULT_PAGE_SIZE = 200
|
DEFAULT_PAGE_SIZE = 200
|
||||||
|
|
||||||
def __init__(self, scraper_input):
|
def __init__(self, scraper_input):
|
||||||
super().__init__(scraper_input)
|
super().__init__(scraper_input)
|
||||||
|
|
||||||
def handle_location(self):
|
def _graphql_post(self, query: str, variables: dict, operation_name: str) -> dict:
|
||||||
# Get client_id from listing_type
|
"""
|
||||||
if self.listing_type is None:
|
Execute a GraphQL query with operation-specific headers.
|
||||||
client_id = "for-sale"
|
|
||||||
elif isinstance(self.listing_type, list):
|
|
||||||
client_id = self.listing_type[0].value.lower().replace("_", "-") if self.listing_type else "for-sale"
|
|
||||||
else:
|
|
||||||
client_id = self.listing_type.value.lower().replace("_", "-")
|
|
||||||
|
|
||||||
params = {
|
Args:
|
||||||
"input": self.location,
|
query: GraphQL query string (must include operationName matching operation_name param)
|
||||||
"client_id": client_id,
|
variables: Query variables dictionary
|
||||||
"limit": "1",
|
operation_name: Name of the GraphQL operation for Apollo headers
|
||||||
"area_types": "city,state,county,postal_code,address,street,neighborhood,school,school_district,university,park",
|
|
||||||
|
Returns:
|
||||||
|
Response JSON dictionary
|
||||||
|
"""
|
||||||
|
# Set operation-specific header (must match query's operationName)
|
||||||
|
self.session.headers['X-APOLLO-OPERATION-NAME'] = operation_name
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"operationName": operation_name, # Include in payload
|
||||||
|
"query": query,
|
||||||
|
"variables": variables,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.session.get(
|
response = self.session.post(self.SEARCH_GQL_URL, json=payload)
|
||||||
self.ADDRESS_AUTOCOMPLETE_URL,
|
return response.json()
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
response_json = response.json()
|
|
||||||
|
|
||||||
result = response_json["autocomplete"]
|
@retry(
|
||||||
|
retry=retry_if_exception_type(Exception),
|
||||||
|
wait=wait_exponential(multiplier=1, min=1, max=4),
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
)
|
||||||
|
def handle_location(self):
|
||||||
|
query = """query SearchSuggestions($searchInput: SearchSuggestionsInput!) {
|
||||||
|
search_suggestions(search_input: $searchInput) {
|
||||||
|
geo_results {
|
||||||
|
type
|
||||||
|
text
|
||||||
|
geo {
|
||||||
|
_id
|
||||||
|
area_type
|
||||||
|
city
|
||||||
|
state_code
|
||||||
|
postal_code
|
||||||
|
county
|
||||||
|
centroid { lat lon }
|
||||||
|
slug_id
|
||||||
|
geo_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
if not result:
|
variables = {
|
||||||
|
"searchInput": {
|
||||||
|
"search_term": self.location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response_json = self._graphql_post(query, variables, "SearchSuggestions")
|
||||||
|
|
||||||
|
if (
|
||||||
|
response_json is None
|
||||||
|
or "data" not in response_json
|
||||||
|
or response_json["data"] is None
|
||||||
|
or "search_suggestions" not in response_json["data"]
|
||||||
|
or response_json["data"]["search_suggestions"] is None
|
||||||
|
or "geo_results" not in response_json["data"]["search_suggestions"]
|
||||||
|
or not response_json["data"]["search_suggestions"]["geo_results"]
|
||||||
|
):
|
||||||
|
# If we got a 400 error with "Required parameter is missing", raise to trigger retry
|
||||||
|
if response_json and "errors" in response_json:
|
||||||
|
error_msgs = [e.get("message", "") for e in response_json.get("errors", [])]
|
||||||
|
if any("Required parameter is missing" in msg for msg in error_msgs):
|
||||||
|
raise Exception(f"Transient API error: {error_msgs}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return result[0]
|
geo_result = response_json["data"]["search_suggestions"]["geo_results"][0]
|
||||||
|
geo = geo_result.get("geo", {})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"text": geo_result.get("text"),
|
||||||
|
"area_type": geo.get("area_type"),
|
||||||
|
"city": geo.get("city"),
|
||||||
|
"state_code": geo.get("state_code"),
|
||||||
|
"postal_code": geo.get("postal_code"),
|
||||||
|
"county": geo.get("county"),
|
||||||
|
"centroid": geo.get("centroid"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if geo.get("area_type") == "address":
|
||||||
|
geo_id = geo.get("_id", "")
|
||||||
|
if geo_id.startswith("addr:"):
|
||||||
|
result["mpr_id"] = geo_id.replace("addr:", "")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def get_latest_listing_id(self, property_id: str) -> str | None:
|
def get_latest_listing_id(self, property_id: str) -> str | None:
|
||||||
query = """query Property($property_id: ID!) {
|
query = """query GetPropertyListingId($property_id: ID!) {
|
||||||
property(id: $property_id) {
|
property(id: $property_id) {
|
||||||
listings {
|
listings {
|
||||||
listing_id
|
listing_id
|
||||||
@@ -86,13 +148,7 @@ class RealtorScraper(Scraper):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
variables = {"property_id": property_id}
|
variables = {"property_id": property_id}
|
||||||
payload = {
|
response_json = self._graphql_post(query, variables, "GetPropertyListingId")
|
||||||
"query": query,
|
|
||||||
"variables": variables,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.post(self.SEARCH_GQL_URL, json=payload)
|
|
||||||
response_json = response.json()
|
|
||||||
|
|
||||||
property_info = response_json["data"]["property"]
|
property_info = response_json["data"]["property"]
|
||||||
if property_info["listings"] is None:
|
if property_info["listings"] is None:
|
||||||
@@ -108,31 +164,40 @@ class RealtorScraper(Scraper):
|
|||||||
return property_info["listings"][0]["listing_id"]
|
return property_info["listings"][0]["listing_id"]
|
||||||
|
|
||||||
def handle_home(self, property_id: str) -> list[Property]:
|
def handle_home(self, property_id: str) -> list[Property]:
|
||||||
|
"""Fetch single home with proper error handling."""
|
||||||
query = (
|
query = (
|
||||||
"""query Home($property_id: ID!) {
|
"""query GetHomeDetails($property_id: ID!) {
|
||||||
home(property_id: $property_id) %s
|
home(property_id: $property_id) %s
|
||||||
}"""
|
}"""
|
||||||
% HOMES_DATA
|
% HOMES_DATA
|
||||||
)
|
)
|
||||||
|
|
||||||
variables = {"property_id": property_id}
|
variables = {"property_id": property_id}
|
||||||
payload = {
|
|
||||||
"query": query,
|
|
||||||
"variables": variables,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.post(self.SEARCH_GQL_URL, json=payload)
|
try:
|
||||||
response_json = response.json()
|
data = self._graphql_post(query, variables, "GetHomeDetails")
|
||||||
|
|
||||||
property_info = response_json["data"]["home"]
|
# Check for errors or missing data
|
||||||
|
if "errors" in data or "data" not in data:
|
||||||
|
return []
|
||||||
|
|
||||||
if self.return_type != ReturnType.raw:
|
if data["data"] is None or "home" not in data["data"]:
|
||||||
return [process_property(property_info, self.mls_only, self.extra_property_data,
|
return []
|
||||||
self.exclude_pending, self.listing_type, get_key, process_extra_property_details)]
|
|
||||||
else:
|
|
||||||
return [property_info]
|
|
||||||
|
|
||||||
|
property_info = data["data"]["home"]
|
||||||
|
if property_info is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Process based on return type
|
||||||
|
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]
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
def general_search(self, variables: dict, search_type: str) -> Dict[str, Union[int, Union[list[Property], list[dict]]]]:
|
def general_search(self, variables: dict, search_type: str) -> Dict[str, Union[int, Union[list[Property], list[dict]]]]:
|
||||||
"""
|
"""
|
||||||
@@ -144,7 +209,15 @@ class RealtorScraper(Scraper):
|
|||||||
# Determine date field based on listing type
|
# Determine date field based on listing type
|
||||||
# Convert listing_type to list for uniform handling
|
# Convert listing_type to list for uniform handling
|
||||||
if self.listing_type is None:
|
if self.listing_type is None:
|
||||||
listing_types = []
|
# When None, return all common listing types as documented
|
||||||
|
# Note: NEW_COMMUNITY, OTHER, and READY_TO_BUILD are excluded as they typically return no results
|
||||||
|
listing_types = [
|
||||||
|
ListingType.FOR_SALE,
|
||||||
|
ListingType.FOR_RENT,
|
||||||
|
ListingType.SOLD,
|
||||||
|
ListingType.PENDING,
|
||||||
|
ListingType.OFF_MARKET,
|
||||||
|
]
|
||||||
date_field = None # When no listing_type is specified, skip date filtering
|
date_field = None # When no listing_type is specified, skip date filtering
|
||||||
elif isinstance(self.listing_type, list):
|
elif isinstance(self.listing_type, list):
|
||||||
listing_types = self.listing_type
|
listing_types = self.listing_type
|
||||||
@@ -277,10 +350,14 @@ class RealtorScraper(Scraper):
|
|||||||
else:
|
else:
|
||||||
sort_param = "" #: prioritize normal fractal sort from realtor
|
sort_param = "" #: prioritize normal fractal sort from realtor
|
||||||
|
|
||||||
# Handle PENDING with or_filters (applies if PENDING is in the list or is the single type)
|
# Handle PENDING with or_filters
|
||||||
|
# Only use or_filters when PENDING is the only type or mixed only with FOR_SALE
|
||||||
|
# Using or_filters with other types (SOLD, FOR_RENT, etc.) will exclude those types
|
||||||
has_pending = ListingType.PENDING in listing_types
|
has_pending = ListingType.PENDING in listing_types
|
||||||
|
other_types = [lt for lt in listing_types if lt not in [ListingType.PENDING, ListingType.FOR_SALE]]
|
||||||
|
use_or_filters = has_pending and len(other_types) == 0
|
||||||
pending_or_contingent_param = (
|
pending_or_contingent_param = (
|
||||||
"or_filters: { contingent: true, pending: true }" if has_pending else ""
|
"or_filters: { contingent: true, pending: true }" if use_or_filters else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build bucket parameter (only use fractal sort if no custom sort is specified)
|
# Build bucket parameter (only use fractal sort if no custom sort is specified)
|
||||||
@@ -317,7 +394,7 @@ class RealtorScraper(Scraper):
|
|||||||
is_foreclosure = "foreclosure: false"
|
is_foreclosure = "foreclosure: false"
|
||||||
|
|
||||||
if search_type == "comps": #: comps search, came from an address
|
if search_type == "comps": #: comps search, came from an address
|
||||||
query = """query Property_search(
|
query = """query GetHomeSearch(
|
||||||
$coordinates: [Float]!
|
$coordinates: [Float]!
|
||||||
$radius: String!
|
$radius: String!
|
||||||
$offset: Int!,
|
$offset: Int!,
|
||||||
@@ -350,20 +427,14 @@ class RealtorScraper(Scraper):
|
|||||||
GENERAL_RESULTS_QUERY,
|
GENERAL_RESULTS_QUERY,
|
||||||
)
|
)
|
||||||
elif search_type == "area": #: general search, came from a general location
|
elif search_type == "area": #: general search, came from a general location
|
||||||
query = """query Home_search(
|
query = """query GetHomeSearch(
|
||||||
$city: String,
|
$search_location: SearchLocation,
|
||||||
$county: [String],
|
|
||||||
$state_code: String,
|
|
||||||
$postal_code: String
|
|
||||||
$offset: Int,
|
$offset: Int,
|
||||||
) {
|
) {
|
||||||
home_search(
|
home_search(
|
||||||
query: {
|
query: {
|
||||||
%s
|
%s
|
||||||
city: $city
|
search_location: $search_location
|
||||||
county: $county
|
|
||||||
postal_code: $postal_code
|
|
||||||
state_code: $state_code
|
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
%s
|
%s
|
||||||
@@ -388,7 +459,7 @@ class RealtorScraper(Scraper):
|
|||||||
)
|
)
|
||||||
else: #: general search, came from an address
|
else: #: general search, came from an address
|
||||||
query = (
|
query = (
|
||||||
"""query Property_search(
|
"""query GetHomeSearch(
|
||||||
$property_id: [ID]!
|
$property_id: [ID]!
|
||||||
$offset: Int!,
|
$offset: Int!,
|
||||||
) {
|
) {
|
||||||
@@ -403,13 +474,7 @@ class RealtorScraper(Scraper):
|
|||||||
% GENERAL_RESULTS_QUERY
|
% GENERAL_RESULTS_QUERY
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = {
|
response_json = self._graphql_post(query, variables, "GetHomeSearch")
|
||||||
"query": query,
|
|
||||||
"variables": variables,
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.session.post(self.SEARCH_GQL_URL, json=payload)
|
|
||||||
response_json = response.json()
|
|
||||||
search_key = "home_search" if "home_search" in query else "property_search"
|
search_key = "home_search" if "home_search" in query else "property_search"
|
||||||
|
|
||||||
properties: list[Union[Property, dict]] = []
|
properties: list[Union[Property, dict]] = []
|
||||||
@@ -499,24 +564,16 @@ class RealtorScraper(Scraper):
|
|||||||
if not location_info.get("centroid"):
|
if not location_info.get("centroid"):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
coordinates = list(location_info["centroid"].values())
|
centroid = location_info["centroid"]
|
||||||
|
coordinates = [centroid["lon"], centroid["lat"]] # GeoJSON order: [lon, lat]
|
||||||
search_variables |= {
|
search_variables |= {
|
||||||
"coordinates": coordinates,
|
"coordinates": coordinates,
|
||||||
"radius": "{}mi".format(self.radius),
|
"radius": "{}mi".format(self.radius),
|
||||||
}
|
}
|
||||||
|
|
||||||
elif location_type == "postal_code":
|
else: #: general search (city, county, postal_code, etc.)
|
||||||
search_variables |= {
|
search_variables |= {
|
||||||
"postal_code": location_info.get("postal_code"),
|
"search_location": {"location": location_info.get("text")},
|
||||||
}
|
|
||||||
|
|
||||||
else: #: general search, location
|
|
||||||
search_variables |= {
|
|
||||||
"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:
|
if self.foreclosure:
|
||||||
@@ -526,39 +583,49 @@ class RealtorScraper(Scraper):
|
|||||||
total = result["total"]
|
total = result["total"]
|
||||||
homes = result["properties"]
|
homes = result["properties"]
|
||||||
|
|
||||||
# Pre-check: Should we continue pagination?
|
# Fetch remaining pages based on parallel parameter
|
||||||
# This optimization prevents unnecessary API calls when using time-based filters
|
if self.offset + self.DEFAULT_PAGE_SIZE < min(total, self.offset + self.limit):
|
||||||
# with date sorting. If page 1's last property is outside the time window,
|
if self.parallel:
|
||||||
# all future pages will also be outside (due to sort order).
|
# Parallel mode: Fetch all remaining pages in parallel
|
||||||
should_continue_pagination = self._should_fetch_more_pages(homes)
|
with ThreadPoolExecutor() as executor:
|
||||||
|
futures_with_offsets = [
|
||||||
|
(i, executor.submit(
|
||||||
|
self.general_search,
|
||||||
|
variables=search_variables | {"offset": i},
|
||||||
|
search_type=search_type,
|
||||||
|
))
|
||||||
|
for i in range(
|
||||||
|
self.offset + self.DEFAULT_PAGE_SIZE,
|
||||||
|
min(total, self.offset + self.limit),
|
||||||
|
self.DEFAULT_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
# Only launch parallel pagination if needed
|
# Collect results and sort by offset to preserve API sort order
|
||||||
if should_continue_pagination and self.offset + self.DEFAULT_PAGE_SIZE < min(total, self.offset + self.limit):
|
results = []
|
||||||
with ThreadPoolExecutor() as executor:
|
for offset, future in futures_with_offsets:
|
||||||
# Store futures with their offsets to maintain proper sort order
|
results.append((offset, future.result()["properties"]))
|
||||||
# Start from offset + page_size and go up to offset + limit
|
|
||||||
futures_with_offsets = [
|
results.sort(key=lambda x: x[0])
|
||||||
(i, executor.submit(
|
for offset, properties in results:
|
||||||
self.general_search,
|
homes.extend(properties)
|
||||||
variables=search_variables | {"offset": i},
|
else:
|
||||||
|
# Sequential mode: Fetch pages one by one with early termination checks
|
||||||
|
for current_offset in range(
|
||||||
|
self.offset + self.DEFAULT_PAGE_SIZE,
|
||||||
|
min(total, self.offset + self.limit),
|
||||||
|
self.DEFAULT_PAGE_SIZE,
|
||||||
|
):
|
||||||
|
# Check if we should continue based on time-based filters
|
||||||
|
if not self._should_fetch_more_pages(homes):
|
||||||
|
break
|
||||||
|
|
||||||
|
result = self.general_search(
|
||||||
|
variables=search_variables | {"offset": current_offset},
|
||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
))
|
|
||||||
for i in range(
|
|
||||||
self.offset + self.DEFAULT_PAGE_SIZE,
|
|
||||||
min(total, self.offset + self.limit),
|
|
||||||
self.DEFAULT_PAGE_SIZE,
|
|
||||||
)
|
)
|
||||||
]
|
page_properties = result["properties"]
|
||||||
|
homes.extend(page_properties)
|
||||||
# Collect results and sort by offset to preserve API sort order across pages
|
|
||||||
results = []
|
|
||||||
for offset, future in futures_with_offsets:
|
|
||||||
results.append((offset, future.result()["properties"]))
|
|
||||||
|
|
||||||
# Sort by offset and concatenate in correct order
|
|
||||||
results.sort(key=lambda x: x[0])
|
|
||||||
for offset, properties in results:
|
|
||||||
homes.extend(properties)
|
|
||||||
|
|
||||||
# Apply client-side hour-based filtering if needed
|
# Apply client-side hour-based filtering if needed
|
||||||
# (API only supports day-level filtering, so we post-filter for hour precision)
|
# (API only supports day-level filtering, so we post-filter for hour precision)
|
||||||
@@ -1028,8 +1095,8 @@ class RealtorScraper(Scraper):
|
|||||||
|
|
||||||
|
|
||||||
@retry(
|
@retry(
|
||||||
retry=retry_if_exception_type(JSONDecodeError),
|
retry=retry_if_exception_type((JSONDecodeError, Exception)),
|
||||||
wait=wait_exponential(min=4, max=10),
|
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||||
stop=stop_after_attempt(3),
|
stop=stop_after_attempt(3),
|
||||||
)
|
)
|
||||||
def get_bulk_prop_details(self, property_ids: list[str]) -> dict:
|
def get_bulk_prop_details(self, property_ids: list[str]) -> dict:
|
||||||
@@ -1048,15 +1115,19 @@ class RealtorScraper(Scraper):
|
|||||||
for property_id in property_ids
|
for property_id in property_ids
|
||||||
)
|
)
|
||||||
query = f"""{HOME_FRAGMENT}
|
query = f"""{HOME_FRAGMENT}
|
||||||
|
|
||||||
query GetHomes {{
|
|
||||||
{fragments}
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
response = self.session.post(self.SEARCH_GQL_URL, json={"query": query})
|
query GetBulkPropertyDetails {{
|
||||||
data = response.json()
|
{fragments}
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
data = self._graphql_post(query, {}, "GetBulkPropertyDetails")
|
||||||
|
|
||||||
if "data" not in data:
|
if "data" not in data:
|
||||||
|
# If we got a 400 error with "Required parameter is missing", raise to trigger retry
|
||||||
|
if data and "errors" in data:
|
||||||
|
error_msgs = [e.get("message", "") for e in data.get("errors", [])]
|
||||||
|
if any("Required parameter is missing" in msg for msg in error_msgs):
|
||||||
|
raise Exception(f"Transient API error: {error_msgs}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
properties = data["data"]
|
properties = data["data"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "homeharvest"
|
name = "homeharvest"
|
||||||
version = "0.8.3"
|
version = "0.8.7"
|
||||||
description = "Real estate scraping library"
|
description = "Real estate scraping library"
|
||||||
authors = ["Zachary Hampton <zachary@bunsly.com>", "Cullen Watson <cullen@bunsly.com>"]
|
authors = ["Zachary Hampton <zachary@bunsly.com>", "Cullen Watson <cullen@bunsly.com>"]
|
||||||
homepage = "https://github.com/ZacharyHampton/HomeHarvest"
|
homepage = "https://github.com/ZacharyHampton/HomeHarvest"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytz
|
import pytz
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
from homeharvest import scrape_property, Property
|
from homeharvest import scrape_property, Property
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -87,6 +88,25 @@ def test_realtor_date_range_sold():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_type_none_includes_sold():
|
||||||
|
"""Test that listing_type=None includes sold listings (issue #142)"""
|
||||||
|
# Get properties with listing_type=None (should include all common types)
|
||||||
|
result_none = scrape_property(
|
||||||
|
location="Warren, MI",
|
||||||
|
listing_type=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we got results
|
||||||
|
assert result_none is not None and len(result_none) > 0
|
||||||
|
|
||||||
|
# Verify sold listings are included
|
||||||
|
status_types = set(result_none['status'].unique())
|
||||||
|
assert 'SOLD' in status_types, "SOLD listings should be included when listing_type=None"
|
||||||
|
|
||||||
|
# Verify we get multiple listing types (not just one)
|
||||||
|
assert len(status_types) > 1, "Should return multiple listing types when listing_type=None"
|
||||||
|
|
||||||
|
|
||||||
def test_realtor_single_property():
|
def test_realtor_single_property():
|
||||||
results = [
|
results = [
|
||||||
scrape_property(
|
scrape_property(
|
||||||
@@ -288,6 +308,30 @@ def test_phone_number_matching():
|
|||||||
assert row["agent_phones"].values[0] == matching_row["agent_phones"].values[0]
|
assert row["agent_phones"].values[0] == matching_row["agent_phones"].values[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_search_consistency():
|
||||||
|
"""Test that the same search executed 3 times in parallel returns consistent results"""
|
||||||
|
def search_task():
|
||||||
|
return scrape_property(
|
||||||
|
location="Phoenix, AZ",
|
||||||
|
listing_type="for_sale",
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
|
futures = [executor.submit(search_task) for _ in range(3)]
|
||||||
|
results = [future.result() for future in as_completed(futures)]
|
||||||
|
|
||||||
|
# Verify all results are valid
|
||||||
|
assert all([result is not None for result in results])
|
||||||
|
assert all([isinstance(result, pd.DataFrame) for result in results])
|
||||||
|
assert all([len(result) > 0 for result in results])
|
||||||
|
|
||||||
|
# Verify all results have the same length (primary consistency check)
|
||||||
|
lengths = [len(result) for result in results]
|
||||||
|
assert len(set(lengths)) == 1, \
|
||||||
|
f"All parallel searches should return same number of results, got lengths: {lengths}"
|
||||||
|
|
||||||
|
|
||||||
def test_return_type():
|
def test_return_type():
|
||||||
results = {
|
results = {
|
||||||
"pandas": [scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100)],
|
"pandas": [scrape_property(location="Surprise, AZ", listing_type="for_rent", limit=100)],
|
||||||
|
|||||||
Reference in New Issue
Block a user