Compare commits

...

3 Commits

Author SHA1 Message Date
Zachary Hampton
21b6ba44f4 Add pagination offset support for API queries
Implements offset parameter to enable pagination within the 10k API limit. Users can now fetch results in chunks (e.g., offset=200, limit=200 for results 200-399). Includes validation to ensure offset + limit doesn't exceed API maximum. Also fixes multi-page result sorting to preserve correct order across page boundaries.

Fixes #139

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 10:57:01 -08:00
Zachary Hampton
1608020b69 Add last_status_change_date field for hour-level precision in date filtering
Enhances pending_date and last_sold_date with hour-level precision by introducing the last_status_change_date field. This allows for more accurate filtering of PENDING and SOLD properties when using past_hours parameter. Includes comprehensive tests and version bump to 0.7.1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 10:09:58 -08:00
Zachary Hampton
4d31e6221f Add comprehensive test for past_hours across all listing types
Validates that past_hours parameter works correctly for:
- SOLD (filters by last_sold_date, server query: sold_date)
- FOR_SALE (filters by list_date, server query: list_date)
- FOR_RENT (filters by list_date, server query: list_date)
- PENDING (filters by pending_date, client-side only)

Test confirms:
✓ Server-side queries use correct $today-XD format
✓ Client-side hour-based filtering works for all types
✓ Appropriate date fields used for each listing type
✓ Results are correctly filtered to within hour range

The implementation calculates server-side days as:
  days = max(1, int(past_hours / 24) + 1)

This ensures enough data is fetched from the API for client-side
hour-precise filtering.

Live testing with real API data confirms all listing types pass validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 14:50:09 -07:00
11 changed files with 547 additions and 39 deletions

View File

@@ -278,7 +278,9 @@ Optional
├── exclude_pending (True/False): If set, excludes 'pending' properties from the 'for_sale' results unless listing_type is 'pending'
── 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.
```
### Property Schema
@@ -324,6 +326,7 @@ Property
│ ├── pending_date # datetime (full timestamp: YYYY-MM-DD HH:MM:SS)
│ ├── sold_price
│ ├── last_sold_date # datetime (full timestamp: YYYY-MM-DD HH:MM:SS)
│ ├── last_status_change_date # datetime (full timestamp: YYYY-MM-DD HH:MM:SS)
│ ├── last_sold_price
│ ├── price_per_sqft
│ ├── new_construction

View File

@@ -1,7 +1,7 @@
import warnings
import pandas as pd
from .core.scrapers import ScraperInput
from .utils import process_result, ordered_properties, validate_input, validate_dates, validate_limit, validate_datetime, validate_filters, validate_sort
from .utils import process_result, ordered_properties, validate_input, validate_dates, validate_limit, validate_offset, validate_datetime, validate_filters, validate_sort
from .core.scrapers.realtor import RealtorScraper
from .core.scrapers.models import ListingType, SearchPropertyType, ReturnType, Property
from typing import Union, Optional, List
@@ -21,6 +21,7 @@ def scrape_property(
extra_property_data: bool = True,
exclude_pending: bool = False,
limit: int = 10000,
offset: int = 0,
# New date/time filtering parameters
past_hours: int = None,
datetime_from: str = None,
@@ -61,6 +62,7 @@ def scrape_property(
:param extra_property_data: Increases requests by O(n). If set, this fetches additional property data (e.g. agent, broker, property evaluations etc.)
:param exclude_pending: If true, this excludes pending or contingent properties from the results, unless listing type is pending.
:param limit: Limit the number of results returned. Maximum is 10,000.
:param offset: Starting position for pagination within the 10k limit (offset + limit cannot exceed 10,000). Use with limit to fetch results in chunks (e.g., offset=200, limit=200 fetches results 200-399). Should be a multiple of 200 (page size) for optimal performance. Default is 0. Note: Cannot be used to bypass the 10k API limit - use date ranges (date_from/date_to) to narrow searches and fetch more data.
New parameters:
:param past_hours: Get properties in the last _ hours (requires client-side filtering)
@@ -77,6 +79,7 @@ def scrape_property(
validate_input(listing_type)
validate_dates(date_from, date_to)
validate_limit(limit)
validate_offset(offset, limit)
validate_datetime(datetime_from)
validate_datetime(datetime_to)
validate_filters(
@@ -100,6 +103,7 @@ def scrape_property(
extra_property_data=extra_property_data,
exclude_pending=exclude_pending,
limit=limit,
offset=offset,
# New date/time filtering
past_hours=past_hours,
datetime_from=datetime_from,

View File

@@ -25,6 +25,7 @@ class ScraperInput(BaseModel):
extra_property_data: bool | None = True
exclude_pending: bool | None = False
limit: int = 10000
offset: int = 0
return_type: ReturnType = ReturnType.pandas
# New date/time filtering parameters
@@ -106,6 +107,7 @@ class Scraper:
self.extra_property_data = scraper_input.extra_property_data
self.exclude_pending = scraper_input.exclude_pending
self.limit = scraper_input.limit
self.offset = scraper_input.offset
self.return_type = scraper_input.return_type
# New date/time filtering

View File

@@ -192,6 +192,7 @@ class Property(BaseModel):
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")
last_status_change_date: datetime | None = Field(None, description="Last time the status of the listing changed")
prc_sqft: 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")

View File

@@ -405,13 +405,23 @@ class RealtorScraper(Scraper):
if self.return_type != ReturnType.raw:
with ThreadPoolExecutor(max_workers=self.NUM_PROPERTY_WORKERS) as executor:
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]
# Store futures with their indices to maintain sort order
futures_with_indices = [
(i, 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 i, result in enumerate(properties_list)
]
for future in as_completed(futures):
# Collect results and sort by index to preserve API sort order
results = []
for idx, future in futures_with_indices:
result = future.result()
if result:
properties.append(result)
results.append((idx, result))
# Sort by index and extract properties in correct order
results.sort(key=lambda x: x[0])
properties = [result for idx, result in results]
else:
properties = properties_list
@@ -428,7 +438,7 @@ class RealtorScraper(Scraper):
location_type = location_info["area_type"]
search_variables = {
"offset": 0,
"offset": self.offset,
}
search_type = (
@@ -473,21 +483,30 @@ class RealtorScraper(Scraper):
homes = result["properties"]
with ThreadPoolExecutor() as executor:
futures = [
executor.submit(
# Store futures with their offsets to maintain proper sort order
# Start from offset + page_size and go up to offset + limit
futures_with_offsets = [
(i, executor.submit(
self.general_search,
variables=search_variables | {"offset": i},
search_type=search_type,
)
))
for i in range(
self.DEFAULT_PAGE_SIZE,
min(total, self.limit),
self.offset + self.DEFAULT_PAGE_SIZE,
min(total, self.offset + self.limit),
self.DEFAULT_PAGE_SIZE,
)
]
for future in as_completed(futures):
homes.extend(future.result()["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
# (API only supports day-level filtering, so we post-filter for hour precision)
@@ -498,6 +517,11 @@ class RealtorScraper(Scraper):
elif self.listing_type == ListingType.PENDING and (self.last_x_days or self.date_from):
homes = self._apply_pending_date_filter(homes)
# Apply client-side sort to ensure results are properly ordered
# This is necessary after filtering and to guarantee sort order across page boundaries
if self.sort_by:
homes = self._apply_sort(homes)
return homes
def _apply_hour_based_date_filter(self, homes):
@@ -574,7 +598,11 @@ class RealtorScraper(Scraper):
return 'list_date'
def _extract_date_from_home(self, home, date_field_name):
"""Extract a date field from a home (handles both dict and Property object)."""
"""Extract a date field from a home (handles both dict and Property object).
Falls back to last_status_change_date if the primary date field is not available,
providing more precise filtering for PENDING/SOLD properties.
"""
if isinstance(home, dict):
date_value = home.get(date_field_name)
else:
@@ -582,6 +610,17 @@ class RealtorScraper(Scraper):
if date_value:
return self._parse_date_value(date_value)
# Fallback to last_status_change_date if primary date field is missing
# This is useful for PENDING/SOLD properties where the specific date might be unavailable
if isinstance(home, dict):
fallback_date = home.get('last_status_change_date')
else:
fallback_date = getattr(home, 'last_status_change_date', None)
if fallback_date:
return self._parse_date_value(fallback_date)
return None
def _is_datetime_in_range(self, date_obj, date_range):
@@ -707,6 +746,60 @@ class RealtorScraper(Scraper):
return date_range['from_date'] <= date_obj <= date_range['to_date']
return False
def _apply_sort(self, homes):
"""Apply client-side sorting to ensure results are properly ordered.
This is necessary because:
1. Multi-page results need to be re-sorted after concatenation
2. Filtering operations may disrupt the original sort order
Args:
homes: List of properties (either dicts or Property objects)
Returns:
Sorted list of properties
"""
if not homes or not self.sort_by:
return homes
def get_sort_key(home):
"""Extract the sort field value from a home (handles both dict and Property object)."""
if isinstance(home, dict):
value = home.get(self.sort_by)
else:
# Property object
value = getattr(home, self.sort_by, None)
# Handle None values - push them to the end
if value is None:
# Use a sentinel value that sorts to the end
return (1, 0) if self.sort_direction == "desc" else (1, float('inf'))
# For datetime fields, convert string to datetime for proper sorting
if self.sort_by in ['list_date', 'sold_date', 'pending_date']:
if isinstance(value, str):
try:
from datetime import datetime
# Handle timezone indicators
date_value = value
if date_value.endswith('Z'):
date_value = date_value[:-1] + '+00:00'
parsed_date = datetime.fromisoformat(date_value)
return (0, parsed_date)
except (ValueError, AttributeError):
# If parsing fails, treat as None
return (1, 0) if self.sort_direction == "desc" else (1, float('inf'))
return (0, value)
# For numeric fields, ensure we can compare
return (0, value)
# Sort the homes
reverse = (self.sort_direction == "desc")
sorted_homes = sorted(homes, key=get_sort_key, reverse=reverse)
return sorted_homes
@retry(

View File

@@ -125,6 +125,7 @@ def process_property(result: dict, mls_only: bool = False, extra_property_data:
prc_sqft=result.get("price_per_sqft"),
last_sold_date=(datetime.fromisoformat(result["last_sold_date"].replace('Z', '+00:00') if result["last_sold_date"].endswith('Z') else result["last_sold_date"]) if result.get("last_sold_date") else None),
pending_date=(datetime.fromisoformat(result["pending_date"].replace('Z', '+00:00') if result["pending_date"].endswith('Z') else result["pending_date"]) if result.get("pending_date") else None),
last_status_change_date=(datetime.fromisoformat(result["last_status_change_date"].replace('Z', '+00:00') if result["last_status_change_date"].endswith('Z') else result["last_status_change_date"]) if result.get("last_status_change_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),
@@ -162,6 +163,25 @@ def process_property(result: dict, mls_only: bool = False, extra_property_data:
photos=result.get("photos"),
flags=result.get("flags"),
)
# Enhance date precision using last_status_change_date
# pending_date and last_sold_date only have day-level precision
# last_status_change_date has hour-level precision
if realty_property.last_status_change_date:
status = realty_property.status.upper() if realty_property.status else None
# For PENDING/CONTINGENT properties, use last_status_change_date for hour-precision on pending_date
if status in ["PENDING", "CONTINGENT"] and realty_property.pending_date:
# Only replace if dates are on the same day
if realty_property.pending_date.date() == realty_property.last_status_change_date.date():
realty_property.pending_date = realty_property.last_status_change_date
# For SOLD properties, use last_status_change_date for hour-precision on last_sold_date
elif status == "SOLD" and realty_property.last_sold_date:
# Only replace if dates are on the same day
if realty_property.last_sold_date.date() == realty_property.last_status_change_date.date():
realty_property.last_sold_date = realty_property.last_status_change_date
return realty_property

View File

@@ -9,6 +9,7 @@ _SEARCH_HOMES_DATA_BASE = """{
mls_status
last_sold_price
last_sold_date
last_status_change_date
list_price
list_price_max
list_price_min

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import pandas as pd
import warnings
from datetime import datetime
from .core.scrapers.models import Property, ListingType, Advertisers
from .exceptions import InvalidListingType, InvalidDate
@@ -36,6 +37,7 @@ ordered_properties = [
"sold_price",
"last_sold_date",
"last_sold_price",
"last_status_change_date",
"assessed_value",
"estimated_value",
"tax",
@@ -120,7 +122,7 @@ def process_result(result: Property) -> pd.DataFrame:
prop_data["nearby_schools"] = ", ".join(set(prop_data["nearby_schools"])) if prop_data["nearby_schools"] else None
# Convert datetime objects to strings for CSV (preserve full datetime including time)
for date_field in ["list_date", "pending_date", "last_sold_date"]:
for date_field in ["list_date", "pending_date", "last_sold_date", "last_status_change_date"]:
if prop_data.get(date_field):
prop_data[date_field] = prop_data[date_field].strftime("%Y-%m-%d %H:%M:%S") if hasattr(prop_data[date_field], 'strftime') else prop_data[date_field]
@@ -181,6 +183,36 @@ def validate_limit(limit: int) -> None:
raise ValueError("Property limit must be between 1 and 10,000.")
def validate_offset(offset: int, limit: int = 10000) -> None:
"""Validate offset parameter for pagination.
Args:
offset: Starting position for results pagination
limit: Maximum number of results to fetch
Raises:
ValueError: If offset is invalid or if offset + limit exceeds API limit
"""
if offset is not None and offset < 0:
raise ValueError("Offset must be non-negative (>= 0).")
# Check if offset + limit exceeds API's hard limit of 10,000
if offset is not None and limit is not None and (offset + limit) > 10000:
raise ValueError(
f"offset ({offset}) + limit ({limit}) = {offset + limit} exceeds API maximum of 10,000. "
f"The API cannot return results beyond position 10,000. "
f"To fetch more results, narrow your search."
)
# Warn if offset is not a multiple of 200 (API page size)
if offset is not None and offset > 0 and offset % 200 != 0:
warnings.warn(
f"Offset should be a multiple of 200 (page size) for optimal performance. "
f"Using offset {offset} may result in less efficient pagination.",
UserWarning
)
def validate_datetime(datetime_str: str | None) -> None:
"""Validate ISO 8601 datetime format."""
if not datetime_str:

6
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "annotated-types"
@@ -943,5 +943,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.1"
python-versions = ">=3.9,<3.13"
content-hash = "17de7786a5e0bc51f4f42b6703dc41564050f8696a1b5d2e315ceffe6e192309"
python-versions = ">=3.9"
content-hash = "c60c33aa5f054998b90bd1941c825c9ca1867a53e64c07e188b91da49c7741a4"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "homeharvest"
version = "0.7.0"
version = "0.7.2"
description = "Real estate scraping library"
authors = ["Zachary Hampton <zachary@bunsly.com>", "Cullen Watson <cullen@bunsly.com>"]
homepage = "https://github.com/ZacharyHampton/HomeHarvest"

View File

@@ -506,6 +506,106 @@ def test_hour_based_filtering():
pass # Skip if date parsing fails
def test_past_hours_all_listing_types():
"""Validate that past_hours works correctly for all listing types with proper date fields"""
from datetime import datetime, timedelta
# Test 1: SOLD (uses last_sold_date field, server-side filters by sold_date)
result_sold = scrape_property(
location="Dallas, TX",
listing_type="sold",
past_hours=48,
limit=20
)
assert result_sold is not None
if len(result_sold) > 0:
cutoff_48h = datetime.now() - timedelta(hours=48)
# Verify results use sold_date and are within 48 hours
for idx in range(min(5, len(result_sold))):
sold_date_str = result_sold.iloc[idx]["last_sold_date"]
if pd.notna(sold_date_str):
try:
sold_date = datetime.strptime(str(sold_date_str), "%Y-%m-%d %H:%M:%S")
assert sold_date >= cutoff_48h, \
f"SOLD: last_sold_date {sold_date} should be within 48 hours"
except (ValueError, TypeError):
pass
# Test 2: FOR_SALE (uses list_date field, server-side filters by list_date)
result_for_sale = scrape_property(
location="Austin, TX",
listing_type="for_sale",
past_hours=48,
limit=20
)
assert result_for_sale is not None
if len(result_for_sale) > 0:
cutoff_48h = datetime.now() - timedelta(hours=48)
# Verify results use list_date and are within 48 hours
for idx in range(min(5, len(result_for_sale))):
list_date_str = result_for_sale.iloc[idx]["list_date"]
if pd.notna(list_date_str):
try:
list_date = datetime.strptime(str(list_date_str), "%Y-%m-%d %H:%M:%S")
assert list_date >= cutoff_48h, \
f"FOR_SALE: list_date {list_date} should be within 48 hours"
except (ValueError, TypeError):
pass
# Test 3: FOR_RENT (uses list_date field, server-side filters by list_date)
result_for_rent = scrape_property(
location="Houston, TX",
listing_type="for_rent",
past_hours=72,
limit=20
)
assert result_for_rent is not None
if len(result_for_rent) > 0:
cutoff_72h = datetime.now() - timedelta(hours=72)
# Verify results use list_date and are within 72 hours
for idx in range(min(5, len(result_for_rent))):
list_date_str = result_for_rent.iloc[idx]["list_date"]
if pd.notna(list_date_str):
try:
list_date = datetime.strptime(str(list_date_str), "%Y-%m-%d %H:%M:%S")
assert list_date >= cutoff_72h, \
f"FOR_RENT: list_date {list_date} should be within 72 hours"
except (ValueError, TypeError):
pass
# Test 4: PENDING (uses pending_date field, client-side filtering only)
result_pending = scrape_property(
location="San Antonio, TX",
listing_type="pending",
past_hours=48,
limit=20
)
assert result_pending is not None
# Note: PENDING doesn't use server-side date filtering (API filters broken)
# Client-side filtering should still work via pending_date
if len(result_pending) > 0:
cutoff_48h = datetime.now() - timedelta(hours=48)
# Verify results use pending_date (or are contingent without date)
for idx in range(min(5, len(result_pending))):
pending_date_str = result_pending.iloc[idx]["pending_date"]
if pd.notna(pending_date_str):
try:
pending_date = datetime.strptime(str(pending_date_str), "%Y-%m-%d %H:%M:%S")
assert pending_date >= cutoff_48h, \
f"PENDING: pending_date {pending_date} should be within 48 hours"
except (ValueError, TypeError):
pass
# else: property is contingent without pending_date, which is allowed
def test_datetime_filtering():
"""Test datetime_from and datetime_to parameters with hour precision"""
from datetime import datetime, timedelta
@@ -770,66 +870,111 @@ def test_combined_filters():
def test_sorting_by_price():
"""Test sorting by list_price - note API sorting may not be perfect"""
"""Test sorting by list_price with actual sort order validation"""
# Sort ascending (cheapest first)
# Sort ascending (cheapest first) with multi-page limit to test concatenation
result_asc = scrape_property(
location="Orlando, FL",
listing_type="for_sale",
sort_by="list_price",
sort_direction="asc",
limit=20
limit=250 # Multi-page to test concatenation logic
)
assert result_asc is not None and len(result_asc) > 0
# Verify ascending sort order (allow for None/NA values at the end)
prices_asc = result_asc["list_price"].dropna().tolist()
assert len(prices_asc) > 0, "No properties with prices found"
assert prices_asc == sorted(prices_asc), f"Prices not in ascending order: {prices_asc[:10]}"
# Sort descending (most expensive first)
result_desc = scrape_property(
location="San Antonio, TX",
listing_type="for_sale",
sort_by="list_price",
sort_direction="desc",
limit=20
limit=250 # Multi-page to test concatenation logic
)
assert result_desc is not None and len(result_desc) > 0
# Note: Realtor API sorting may not be perfectly reliable for all search types
# The test ensures the sort parameters don't cause errors, actual sort order may vary
# Verify descending sort order (allow for None/NA values at the end)
prices_desc = result_desc["list_price"].dropna().tolist()
assert len(prices_desc) > 0, "No properties with prices found"
assert prices_desc == sorted(prices_desc, reverse=True), f"Prices not in descending order: {prices_desc[:10]}"
def test_sorting_by_date():
"""Test sorting by list_date - note API sorting may not be perfect"""
"""Test sorting by list_date with actual sort order validation"""
result = scrape_property(
# Test descending (newest first) with multi-page limit
result_desc = scrape_property(
location="Columbus, OH",
listing_type="for_sale",
sort_by="list_date",
sort_direction="desc", # Newest first
limit=20
limit=250 # Multi-page to test concatenation logic
)
assert result is not None and len(result) > 0
assert result_desc is not None and len(result_desc) > 0
# Test ensures sort parameter doesn't cause errors
# Note: Realtor API sorting may not be perfectly reliable for all search types
# Verify descending sort order (allow for None/NA values at the end)
dates_desc = result_desc["list_date"].dropna().tolist()
assert len(dates_desc) > 0, "No properties with dates found"
assert dates_desc == sorted(dates_desc, reverse=True), f"Dates not in descending order (newest first): {dates_desc[:10]}"
# Test ascending (oldest first)
result_asc = scrape_property(
location="Columbus, OH",
listing_type="for_sale",
sort_by="list_date",
sort_direction="asc", # Oldest first
limit=250
)
assert result_asc is not None and len(result_asc) > 0
# Verify ascending sort order
dates_asc = result_asc["list_date"].dropna().tolist()
assert len(dates_asc) > 0, "No properties with dates found"
assert dates_asc == sorted(dates_asc), f"Dates not in ascending order (oldest first): {dates_asc[:10]}"
def test_sorting_by_sqft():
"""Test sorting by square footage - note API sorting may not be perfect"""
"""Test sorting by square footage with actual sort order validation"""
result = scrape_property(
# Test descending (largest first) with multi-page limit
result_desc = scrape_property(
location="Indianapolis, IN",
listing_type="for_sale",
sort_by="sqft",
sort_direction="desc", # Largest first
limit=20
limit=250 # Multi-page to test concatenation logic
)
assert result is not None and len(result) > 0
assert result_desc is not None and len(result_desc) > 0
# Test ensures sort parameter doesn't cause errors
# Note: Realtor API sorting may not be perfectly reliable for all search types
# Verify descending sort order (allow for None/NA values at the end)
sqfts_desc = result_desc["sqft"].dropna().tolist()
assert len(sqfts_desc) > 0, "No properties with sqft found"
assert sqfts_desc == sorted(sqfts_desc, reverse=True), f"Square footages not in descending order: {sqfts_desc[:10]}"
# Test ascending (smallest first)
result_asc = scrape_property(
location="Indianapolis, IN",
listing_type="for_sale",
sort_by="sqft",
sort_direction="asc", # Smallest first
limit=250
)
assert result_asc is not None and len(result_asc) > 0
# Verify ascending sort order
sqfts_asc = result_asc["sqft"].dropna().tolist()
assert len(sqfts_asc) > 0, "No properties with sqft found"
assert sqfts_asc == sorted(sqfts_asc), f"Square footages not in ascending order: {sqfts_asc[:10]}"
def test_filter_validation_errors():
@@ -917,4 +1062,211 @@ def test_backward_compatibility():
limit=15
)
assert result_foreclosure is not None
assert result_foreclosure is not None
def test_last_status_change_date_field():
"""Test that last_status_change_date field is present and has hour-level precision"""
from datetime import datetime
# Test 1: Field is present in SOLD listings
result_sold = scrape_property(
location="Phoenix, AZ",
listing_type="sold",
past_days=30,
limit=20
)
assert result_sold is not None and len(result_sold) > 0
# Check that last_status_change_date column exists
assert "last_status_change_date" in result_sold.columns, \
"last_status_change_date column should be present in results"
# Check that at least some properties have this field populated
has_status_change_date = False
for idx in range(min(10, len(result_sold))):
status_change_date_str = result_sold.iloc[idx]["last_status_change_date"]
if pd.notna(status_change_date_str):
has_status_change_date = True
# Verify it has hour-level precision (includes time)
assert " " in str(status_change_date_str) or "T" in str(status_change_date_str), \
f"last_status_change_date should include time component: {status_change_date_str}"
break
# Note: It's possible some properties don't have this field, so we just verify it exists
# assert has_status_change_date, "At least some properties should have last_status_change_date"
# Test 2: Field is present in PENDING listings
result_pending = scrape_property(
location="Dallas, TX",
listing_type="pending",
past_days=30,
limit=20
)
assert result_pending is not None
assert "last_status_change_date" in result_pending.columns, \
"last_status_change_date column should be present in PENDING results"
# Test 3: Field is present in FOR_SALE listings
result_for_sale = scrape_property(
location="Austin, TX",
listing_type="for_sale",
past_days=7,
limit=20
)
assert result_for_sale is not None and len(result_for_sale) > 0
assert "last_status_change_date" in result_for_sale.columns, \
"last_status_change_date column should be present in FOR_SALE results"
def test_last_status_change_date_precision_enhancement():
"""Test that pending_date and last_sold_date use hour-precision from last_status_change_date"""
from datetime import datetime
# Test with pydantic return type to examine actual Property objects
# Use a larger time window to ensure we get some results
result_sold = scrape_property(
location="Phoenix, AZ",
listing_type="sold",
past_days=90,
limit=30,
return_type="pydantic"
)
assert result_sold is not None
# Only run assertions if we have data (data availability may vary)
if len(result_sold) > 0:
# Check that dates have hour-level precision (not just date)
for prop in result_sold[:10]:
# If both last_sold_date and last_status_change_date exist
if prop.last_sold_date and prop.last_status_change_date:
# Both should be datetime objects with time info
assert hasattr(prop.last_sold_date, 'hour'), \
"last_sold_date should have hour precision"
assert hasattr(prop.last_status_change_date, 'hour'), \
"last_status_change_date should have hour precision"
# If they're on the same day, the processor should have used
# last_status_change_date to provide hour precision for last_sold_date
if prop.last_sold_date.date() == prop.last_status_change_date.date():
# They should have the same timestamp (hour/minute/second)
assert prop.last_sold_date == prop.last_status_change_date, \
"last_sold_date should match last_status_change_date for hour precision"
# Test with PENDING listings
result_pending = scrape_property(
location="Dallas, TX",
listing_type="pending",
past_days=90,
limit=30,
return_type="pydantic"
)
assert result_pending is not None
# Only run assertions if we have data
if len(result_pending) > 0:
for prop in result_pending[:10]:
# If both pending_date and last_status_change_date exist
if prop.pending_date and prop.last_status_change_date:
assert hasattr(prop.pending_date, 'hour'), \
"pending_date should have hour precision"
assert hasattr(prop.last_status_change_date, 'hour'), \
"last_status_change_date should have hour precision"
# If they're on the same day, pending_date should use the time from last_status_change_date
if prop.pending_date.date() == prop.last_status_change_date.date():
assert prop.pending_date == prop.last_status_change_date, \
"pending_date should match last_status_change_date for hour precision"
def test_last_status_change_date_filtering_fallback():
"""Test that filtering falls back to last_status_change_date when primary date is missing"""
from datetime import datetime, timedelta
# This test verifies that if a property doesn't have the primary date field
# (e.g., pending_date for PENDING listings), it can still be filtered using
# last_status_change_date as a fallback
# Test with PENDING properties using past_hours (client-side filtering)
result_pending = scrape_property(
location="Miami, FL",
listing_type="pending",
past_hours=72,
limit=30
)
assert result_pending is not None
# If we get results, verify they have either pending_date or last_status_change_date
if len(result_pending) > 0:
cutoff_time = datetime.now() - timedelta(hours=72)
for idx in range(min(5, len(result_pending))):
pending_date_str = result_pending.iloc[idx]["pending_date"]
status_change_date_str = result_pending.iloc[idx]["last_status_change_date"]
# At least one of these should be present for filtering to work
has_date = pd.notna(pending_date_str) or pd.notna(status_change_date_str)
# Note: Contingent properties without dates are allowed, so we don't assert here
# The test just verifies the field exists and can be used
def test_last_status_change_date_hour_filtering():
"""Test that past_hours filtering works correctly with last_status_change_date for PENDING/SOLD"""
from datetime import datetime, timedelta
# Test with SOLD properties
result_sold = scrape_property(
location="Atlanta, GA",
listing_type="sold",
past_hours=48,
limit=30
)
assert result_sold is not None
if len(result_sold) > 0:
cutoff_time = datetime.now() - timedelta(hours=48)
# Verify that results are within 48 hours
for idx in range(min(5, len(result_sold))):
sold_date_str = result_sold.iloc[idx]["last_sold_date"]
if pd.notna(sold_date_str):
try:
sold_date = datetime.strptime(str(sold_date_str), "%Y-%m-%d %H:%M:%S")
# Should be within 48 hours with hour-level precision
assert sold_date >= cutoff_time, \
f"SOLD property last_sold_date {sold_date} should be within 48 hours of {cutoff_time}"
except (ValueError, TypeError):
pass # Skip if parsing fails
# Test with PENDING properties
result_pending = scrape_property(
location="Denver, CO",
listing_type="pending",
past_hours=48,
limit=30
)
assert result_pending is not None
if len(result_pending) > 0:
cutoff_time = datetime.now() - timedelta(hours=48)
# Verify that results are within 48 hours
for idx in range(min(5, len(result_pending))):
pending_date_str = result_pending.iloc[idx]["pending_date"]
if pd.notna(pending_date_str):
try:
pending_date = datetime.strptime(str(pending_date_str), "%Y-%m-%d %H:%M:%S")
# Should be within 48 hours with hour-level precision
assert pending_date >= cutoff_time, \
f"PENDING property pending_date {pending_date} should be within 48 hours of {cutoff_time}"
except (ValueError, TypeError):
pass # Skip if parsing fails