From dc8c15959fa57c3ff05143be8ea48b4a839ef167 Mon Sep 17 00:00:00 2001 From: Cullen Watson Date: Mon, 18 Sep 2023 13:38:17 -0500 Subject: [PATCH 1/4] fix: use zillow backend ep --- homeharvest/__init__.py | 102 +++--- homeharvest/core/scrapers/models.py | 66 ++-- homeharvest/core/scrapers/zillow/__init__.py | 350 +++++++++++++------ homeharvest/exceptions.py | 4 + 4 files changed, 340 insertions(+), 182 deletions(-) diff --git a/homeharvest/__init__.py b/homeharvest/__init__.py index f817806..009aee6 100644 --- a/homeharvest/__init__.py +++ b/homeharvest/__init__.py @@ -1,7 +1,7 @@ from .core.scrapers.redfin import RedfinScraper from .core.scrapers.realtor import RealtorScraper from .core.scrapers.zillow import ZillowScraper -from .core.scrapers.models import ListingType, Property, Building, SiteName +from .core.scrapers.models import ListingType, Property, SiteName from .core.scrapers import ScraperInput from .exceptions import InvalidSite, InvalidListingType from typing import Union @@ -25,60 +25,62 @@ def validate_input(site_name: str, listing_type: str) -> None: ) -def get_ordered_properties(result: Union[Building, Property]) -> list[str]: - if isinstance(result, Property): - return [ - "listing_type", - "address_one", - "city", - "state", - "zip_code", - "address_two", - "url", - "property_type", - "price", - "beds", - "baths", - "square_feet", - "price_per_square_foot", - "lot_size", - "stories", - "year_built", - "agent_name", - "mls_id", - "description", - ] - elif isinstance(result, Building): - return [ - "address_one", - "city", - "state", - "zip_code", - "address_two", - "url", - "num_units", - "min_unit_price", - "max_unit_price", - "avg_unit_price", - "listing_type", - ] - return [] +def get_ordered_properties(result: Property) -> list[str]: + return [ + "property_url", + "site_name", + "listing_type", + "property_type", + "status_text", + "currency", + "price", + "apt_min_price", + "tax_assessed_value", + "square_feet", + "price_per_sqft", + "beds", + "baths", + "lot_area_value", + "lot_area_unit", + "street_address", + "unit", + "city", + "state", + "zip_code", + "country", + "posted_time", + "bldg_min_beds", + "bldg_min_baths", + "bldg_min_area", + "bldg_unit_count", + "bldg_name", + "stories", + "year_built", + "agent_name", + "mls_id", + "description", + "img_src", + "latitude", + "longitude", + ] -def process_result(result: Union[Building, Property]) -> pd.DataFrame: +def process_result(result: Property) -> pd.DataFrame: prop_data = result.__dict__ - address_data = prop_data["address"] prop_data["site_name"] = prop_data["site_name"].value - prop_data["listing_type"] = prop_data["listing_type"].value + prop_data["listing_type"] = prop_data["listing_type"].value.lower() prop_data["property_type"] = prop_data["property_type"].value.lower() - prop_data["address_one"] = address_data.address_one - prop_data["city"] = address_data.city - prop_data["state"] = address_data.state - prop_data["zip_code"] = address_data.zip_code - prop_data["address_two"] = address_data.address_two + if "address" in prop_data: + address_data = prop_data["address"] + prop_data["street_address"] = address_data.street_address + 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_code + prop_data["country"] = address_data.country - del prop_data["address"] + del prop_data["address"] properties_df = pd.DataFrame([prop_data]) properties_df = properties_df[get_ordered_properties(result)] @@ -90,7 +92,7 @@ def scrape_property( location: str, site_name: str, listing_type: str = "for_sale", #: for_sale, for_rent, sold -) -> Union[list[Building], list[Property]]: +) -> list[Property]: validate_input(site_name, listing_type) scraper_input = ScraperInput( @@ -103,5 +105,7 @@ def scrape_property( results = site.search() properties_dfs = [process_result(result) for result in results] + if not properties_dfs: + return pd.DataFrame() return pd.concat(properties_dfs, ignore_index=True) diff --git a/homeharvest/core/scrapers/models.py b/homeharvest/core/scrapers/models.py index 1a3db97..b3075c5 100644 --- a/homeharvest/core/scrapers/models.py +++ b/homeharvest/core/scrapers/models.py @@ -9,22 +9,28 @@ class SiteName(Enum): class ListingType(Enum): - FOR_SALE = "for_sale" - FOR_RENT = "for_rent" - SOLD = "sold" + FOR_SALE = "FOR_SALE" + FOR_RENT = "FOR_RENT" + SOLD = "SOLD" class PropertyType(Enum): HOUSE = "HOUSE" + BUILDING = "BUILDING" CONDO = "CONDO" TOWNHOUSE = "TOWNHOUSE" SINGLE_FAMILY = "SINGLE_FAMILY" MULTI_FAMILY = "MULTI_FAMILY" MANUFACTURED = "MANUFACTURED" + NEW_CONSTRUCTION = "NEW_CONSTRUCTION" APARTMENT = "APARTMENT" + APARTMENTS = "APARTMENTS" LAND = "LAND" + LOT = "LOT" OTHER = "OTHER" + BLANK = "BLANK" + @classmethod def from_int_code(cls, code): mapping = { @@ -38,48 +44,56 @@ class PropertyType(Enum): 13: cls.SINGLE_FAMILY, } - return mapping.get(code, cls.OTHER) + return mapping.get(code, cls.BLANK) @dataclass class Address: - address_one: str + street_address: str city: str state: str zip_code: str - - address_two: str | None = None - - -@dataclass() -class Realty: - site_name: SiteName - address: Address - url: str - listing_type: ListingType | None = None + unit: str + country: str | None = None @dataclass -class Property(Realty): +class Property: + property_url: str + site_name: SiteName + listing_type: ListingType + property_type: PropertyType + address: Address + + # house for sale price: int | None = None + tax_assessed_value: int | None = None + currency: str | None = None + square_feet: int | None = None beds: int | None = None baths: float | None = None + lot_area_value: float | None = None + lot_area_unit: str | None = None stories: int | None = None year_built: int | None = None - square_feet: int | None = None - price_per_square_foot: int | None = None + price_per_sqft: int | None = None year_built: int | None = None mls_id: str | None = None agent_name: str | None = None - property_type: PropertyType | None = None - lot_size: int | None = None + img_src: str | None = None description: str | None = None + status_text: str | None = None + latitude: float | None = None + longitude: float | None = None + posted_time: str | None = None + # building for sale + bldg_name: str | None = None + bldg_unit_count: int | None = None + bldg_min_beds: int | None = None + bldg_min_baths: float | None = None + bldg_min_area: int | None = None -@dataclass -class Building(Realty): - num_units: int | None = None - min_unit_price: int | None = None - max_unit_price: int | None = None - avg_unit_price: int | None = None + # apt + apt_min_price: int | None = None diff --git a/homeharvest/core/scrapers/zillow/__init__.py b/homeharvest/core/scrapers/zillow/__init__.py index 1b25c16..5843b40 100644 --- a/homeharvest/core/scrapers/zillow/__init__.py +++ b/homeharvest/core/scrapers/zillow/__init__.py @@ -1,6 +1,6 @@ import re import json -from ..models import Property, Address, Building, ListingType, PropertyType +from ..models import Property, Address, ListingType, PropertyType, SiteName from ....exceptions import NoResultsFound, PropertyNotFound from .. import Scraper @@ -13,6 +13,8 @@ class ZillowScraper(Scraper): self.url = f"https://www.zillow.com/homes/for_sale/{self.location}_rb/" elif self.listing_type == ListingType.FOR_RENT: self.url = f"https://www.zillow.com/homes/for_rent/{self.location}_rb/" + else: + self.url = f"https://www.zillow.com/homes/recently_sold/{self.location}_rb/" def search(self): resp = self.session.get(self.url, headers=self._get_headers()) @@ -33,10 +35,17 @@ class ZillowScraper(Scraper): data = json.loads(json_str) if "searchPageState" in data["props"]["pageProps"]: - houses = data["props"]["pageProps"]["searchPageState"]["cat1"][ - "searchResults" - ]["listResults"] - return [self._parse_home(house) for house in houses] + pattern = r'window\.mapBounds = \{\s*"west":\s*(-?\d+\.\d+),\s*"east":\s*(-?\d+\.\d+),\s*"south":\s*(-?\d+\.\d+),\s*"north":\s*(-?\d+\.\d+)\s*\};' + + match = re.search(pattern, content) + + if match: + coords = [float(coord) for coord in match.groups()] + return self._fetch_properties_backend(coords) + + else: + raise BoxBoundsNotFound("Box bounds could not be located.") + elif "gdpClientCache" in data["props"]["pageProps"]: gdp_client_cache = json.loads(data["props"]["pageProps"]["gdpClientCache"]) main_key = list(gdp_client_cache.keys())[0] @@ -47,45 +56,188 @@ class ZillowScraper(Scraper): return [property] raise PropertyNotFound("Specific property data not found in the response.") - def _parse_home(self, home: dict): - """ - This method is used when a user enters a generic location & zillow returns more than one property - """ - url = ( - f"https://www.zillow.com{home['detailUrl']}" - if "zillow.com" not in home["detailUrl"] - else home["detailUrl"] + def _fetch_properties_backend(self, coords): + url = "https://www.zillow.com/async-create-search-page-state" + + filter_state_for_sale = { + "sortSelection": { + # "value": "globalrelevanceex" + "value": "days" + }, + "isAllHomes": {"value": True}, + } + + filter_state_for_rent = { + "isForRent": {"value": True}, + "isForSaleByAgent": {"value": False}, + "isForSaleByOwner": {"value": False}, + "isNewConstruction": {"value": False}, + "isComingSoon": {"value": False}, + "isAuction": {"value": False}, + "isForSaleForeclosure": {"value": False}, + "isAllHomes": {"value": True}, + } + + filter_state_sold = { + "isRecentlySold": {"value": True}, + "isForSaleByAgent": {"value": False}, + "isForSaleByOwner": {"value": False}, + "isNewConstruction": {"value": False}, + "isComingSoon": {"value": False}, + "isAuction": {"value": False}, + "isForSaleForeclosure": {"value": False}, + "isAllHomes": {"value": True}, + } + + selected_filter = ( + filter_state_for_rent + if self.listing_type == ListingType.FOR_RENT + else filter_state_for_sale + if self.listing_type == ListingType.FOR_SALE + else filter_state_sold ) - if "hdpData" in home and "homeInfo" in home["hdpData"]: - price_data = self._extract_price(home) - address = self._extract_address(home) - agent_name = self._extract_agent_name(home) - beds = home["hdpData"]["homeInfo"]["bedrooms"] - baths = home["hdpData"]["homeInfo"]["bathrooms"] - property_type = home["hdpData"]["homeInfo"].get("homeType") + payload = json.dumps( + { + "searchQueryState": { + "pagination": {}, + "isMapVisible": True, + "mapBounds": { + "west": coords[0], + "east": coords[1], + "south": coords[2], + "north": coords[3], + }, + "filterState": selected_filter, + "isListVisible": True, + "mapZoom": 11, + }, + "wants": {"cat1": ["mapResults"]}, + "isDebugRequest": False, + } + ) + print(payload) + resp = self.session.put(url, headers=self._get_headers(), data=payload) + resp.raise_for_status() + a = resp.json() + return self._parse_properties(resp.json()) - return Property( - site_name=self.site_name, - address=address, - agent_name=agent_name, - url=url, - beds=beds, - baths=baths, - listing_type=self.listing_type, - property_type=PropertyType(property_type), - **price_data, - ) - else: - keys = ("addressStreet", "addressCity", "addressState", "addressZipcode") - address_one, city, state, zip_code = (home[key] for key in keys) - address_one, address_two = self._parse_address_two(address_one) - address = Address(address_one, city, state, zip_code, address_two) + def _parse_properties(self, property_data: dict): + mapresults = property_data["cat1"]["searchResults"]["mapResults"] - building_info = self._extract_building_info(home) - return Building( - site_name=self.site_name, address=address, url=url, **building_info + properties_list = [] + + for result in mapresults: + try: + if "hdpData" in result: + home_info = result["hdpData"]["homeInfo"] + address_data = { + "street_address": home_info["streetAddress"], + "unit": home_info.get("unit"), + "city": home_info["city"], + "state": home_info["state"], + "zip_code": home_info["zipcode"], + "country": home_info["country"], + } + property_data = { + "site_name": self.site_name, + "address": Address(**address_data), + "property_url": f"https://www.zillow.com{result['detailUrl']}", + "beds": int(home_info["bedrooms"]) + if "bedrooms" in home_info + else None, + "baths": home_info.get("bathrooms"), + "square_feet": int(home_info["livingArea"]) + if "livingArea" in home_info + else None, + "currency": home_info["currency"], + "price": home_info.get("price"), + "square_feet": int(home_info["livingArea"]) + if "livingArea" in home_info + else None, + "tax_assessed_value": int(home_info["taxAssessedValue"]) + if "taxAssessedValue" in home_info + else None, + "property_type": PropertyType(home_info["homeType"]), + "listing_type": ListingType( + home_info["statusType"] + if "statusType" in home_info + else self.listing_type + ), + "lot_area_value": round(home_info["lotAreaValue"], 2) + if "lotAreaValue" in home_info + else None, + "lot_area_unit": home_info.get("lotAreaUnit"), + "latitude": result["latLong"]["latitude"], + "longitude": result["latLong"]["longitude"], + "status_text": result.get("statusText"), + "posted_time": result["variableData"]["text"] + if "variableData" in result + and "text" in result["variableData"] + and result["variableData"]["type"] == "TIME_ON_INFO" + else None, + "img_src": result.get("imgSrc"), + "price_per_sqft": int( + home_info["price"] // home_info["livingArea"] + ) + if "livingArea" in home_info and "price" in home_info + else None, + } + property_obj = Property(**property_data) + properties_list.append(property_obj) + + elif "isBuilding" in result: + price = result["price"] + building_data = { + "property_url": f"https://www.zillow.com{result['detailUrl']}", + "site_name": self.site_name, + "property_type": PropertyType("BUILDING"), + "listing_type": ListingType(result["statusType"]), + "img_src": result["imgSrc"], + "price": int(price.replace("From $", "").replace(",", "")) + if "From $" in price + else None, + "apt_min_price": int( + price.replace("$", "").replace(",", "").replace("+/mo", "") + ) + if "+/mo" in price + else None, + "address": self._extract_address(result["address"]), + "bldg_min_beds": result["minBeds"], + "currency": "USD", + "bldg_min_baths": result["minBaths"], + "bldg_min_area": result.get("minArea"), + "bldg_unit_count": result["unitCount"], + "bldg_name": result.get("communityName"), + "status_text": result["statusText"], + "latitude": result["latLong"]["latitude"], + "longitude": result["latLong"]["longitude"], + } + building_obj = Property(**building_data) + properties_list.append(building_obj) + + except Exception as e: + print(home_info) + traceback.print_exc() + sys.exit() + + return properties_list + + def _extract_units(self, result: dict): + units = {} + if "units" in result: + num_units = result.get("availabilityCount", len(result["units"])) + prices = [ + int(unit["price"].replace("$", "").replace(",", "").split("+")[0]) + for unit in result["units"] + ] + units["apt_availability_count"] = num_units + units["apt_min_unit_price"] = min(prices) + units["apt_max_unit_price"] = max(prices) + units["apt_avg_unit_price"] = ( + sum(prices) // num_units if num_units else None ) + return units def _get_single_property_page(self, property_data: dict): """ @@ -97,32 +249,38 @@ class ZillowScraper(Scraper): else property_data["hdpUrl"] ) address_data = property_data["address"] - address_one, address_two = self._parse_address_two( - address_data["streetAddress"] - ) + unit = self._parse_address_two(address_data["streetAddress"]) address = Address( - address_one=address_one, - address_two=address_two, + street_address=address_data["streetAddress"], + unit=unit, city=address_data["city"], state=address_data["state"], zip_code=address_data["zipcode"], + country=property_data.get("country"), ) property_type = property_data.get("homeType", None) - return Property( site_name=self.site_name, address=address, - url=url, + property_url=url, beds=property_data.get("bedrooms", None), baths=property_data.get("bathrooms", None), year_built=property_data.get("yearBuilt", None), price=property_data.get("price", None), - lot_size=property_data.get("lotSize", None), + tax_assessed_value=property_data.get("taxAssessedValue", None), + latitude=property_data.get("latitude"), + longitude=property_data.get("longitude"), + img_src=property_data.get("streetViewTileImageUrlMediumAddress"), + currency=property_data.get("currency", None), + lot_area_value=property_data.get("lotAreaValue"), + lot_area_unit=property_data["lotAreaUnits"].lower() + if "lotAreaUnits" in property_data + else None, agent_name=property_data.get("attributionInfo", {}).get("agentName", None), stories=property_data.get("resoFacts", {}).get("stories", None), description=property_data.get("description", None), mls_id=property_data.get("attributionInfo", {}).get("mlsId", None), - price_per_square_foot=property_data.get("resoFacts", {}).get( + price_per_sqft=property_data.get("resoFacts", {}).get( "pricePerSquareFoot", None ), square_feet=property_data.get("livingArea", None), @@ -130,81 +288,59 @@ class ZillowScraper(Scraper): listing_type=self.listing_type, ) - def _extract_building_info(self, home: dict) -> dict: - num_units = len(home["units"]) - prices = [ - int(unit["price"].replace("$", "").replace(",", "").split("+")[0]) - for unit in home["units"] - ] - return { - "listing_type": self.listing_type, - "num_units": len(home["units"]), - "min_unit_price": min( - ( - int(unit["price"].replace("$", "").replace(",", "").split("+")[0]) - for unit in home["units"] - ) - ), - "max_unit_price": max( - ( - int(unit["price"].replace("$", "").replace(",", "").split("+")[0]) - for unit in home["units"] - ) - ), - "avg_unit_price": sum(prices) // len(prices) if num_units else None, - } - - @staticmethod - def _extract_price(home: dict) -> dict: - price = int(home["hdpData"]["homeInfo"]["priceForHDP"]) - square_feet = home["hdpData"]["homeInfo"].get("livingArea") - - lot_size = home["hdpData"]["homeInfo"].get("lotAreaValue") - price_per_square_foot = price // square_feet if square_feet and price else None - - return { - k: v - for k, v in locals().items() - if k in ["price", "square_feet", "lot_size", "price_per_square_foot"] - } - - @staticmethod - def _extract_agent_name(home: dict) -> str | None: - broker_str = home.get("brokerName", "") - match = re.search(r"Listing by: (.+)", broker_str) - return match.group(1) if match else None - @staticmethod def _parse_address_two(address_one: str): apt_match = re.search(r"(APT\s*.+|#[\s\S]+)$", address_one, re.I) - address_two = apt_match.group().strip() if apt_match else None - address_one = ( - address_one.replace(address_two, "").strip() if address_two else address_one - ) - return address_one, address_two + return apt_match.group().strip() if apt_match else None - @staticmethod - def _extract_address(home: dict) -> Address: - keys = ("streetAddress", "city", "state", "zipcode") - address_one, city, state, zip_code = ( - home["hdpData"]["homeInfo"][key] for key in keys + def _extract_address(self, address_str): + """ + Extract address components from a string formatted like '555 Wedglea Dr, Dallas, TX', + and return an Address object. + """ + parts = address_str.split(", ") + + if len(parts) != 3: + raise ValueError(f"Unexpected address format: {address_str}") + + street_address = parts[0].strip() + city = parts[1].strip() + state_zip = parts[2].split(" ") + + if len(state_zip) == 1: + state = state_zip[0].strip() + zip_code = None + elif len(state_zip) == 2: + state = state_zip[0].strip() + zip_code = state_zip[1].strip() + else: + raise ValueError(f"Unexpected state/zip format in address: {address_str}") + + unit = self._parse_address_two(street_address) + return Address( + street_address=street_address, + city=city, + unit=unit, + state=state, + zip_code=zip_code, + country="USA", ) - address_one, address_two = ZillowScraper._parse_address_two(address_one) - return Address(address_one, city, state, zip_code, address_two=address_two) @staticmethod def _get_headers(): return { - "authority": "parser-external.geo.moveaws.com", + "authority": "www.zillow.com", "accept": "*/*", "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + "cookie": 'zjs_user_id=null; zg_anonymous_id=%220976ab81-2950-4013-98f0-108b15a554d2%22; zguid=24|%246b1bc625-3955-4d1e-a723-e59602e4ed08; g_state={"i_p":1693611172520,"i_l":1}; zgsession=1|d48820e2-1659-4d2f-b7d2-99a8127dd4f3; zjs_anonymous_id=%226b1bc625-3955-4d1e-a723-e59602e4ed08%22; JSESSIONID=82E8274D3DC8AF3AB9C8E613B38CF861; search=6|1697585860120%7Crb%3DDallas%252C-TX%26rect%3D33.016646%252C-96.555516%252C32.618763%252C-96.999347%26disp%3Dmap%26mdm%3Dauto%26sort%3Ddays%26listPriceActive%3D1%26fs%3D1%26fr%3D0%26mmm%3D0%26rs%3D0%26ah%3D0%26singlestory%3D0%26abo%3D0%26garage%3D0%26pool%3D0%26ac%3D0%26waterfront%3D0%26finished%3D0%26unfinished%3D0%26cityview%3D0%26mountainview%3D0%26parkview%3D0%26waterview%3D0%26hoadata%3D1%263dhome%3D0%26commuteMode%3Ddriving%26commuteTimeOfDay%3Dnow%09%0938128%09%7B%22isList%22%3Atrue%2C%22isMap%22%3Atrue%7D%09%09%09%09%09; AWSALB=gAlFj5Ngnd4bWP8k7CME/+YlTtX9bHK4yEkdPHa3VhL6K523oGyysFxBEpE1HNuuyL+GaRPvt2i/CSseAb+zEPpO4SNjnbLAJzJOOO01ipnWN3ZgPaa5qdv+fAki; AWSALBCORS=gAlFj5Ngnd4bWP8k7CME/+YlTtX9bHK4yEkdPHa3VhL6K523oGyysFxBEpE1HNuuyL+GaRPvt2i/CSseAb+zEPpO4SNjnbLAJzJOOO01ipnWN3ZgPaa5qdv+fAki; search=6|1697587741808%7Crect%3D33.37188814545521%2C-96.34484483007813%2C32.260490641365685%2C-97.21001816992188%26disp%3Dmap%26mdm%3Dauto%26p%3D1%26sort%3Ddays%26z%3D1%26listPriceActive%3D1%26fs%3D1%26fr%3D0%26mmm%3D0%26rs%3D0%26ah%3D0%26singlestory%3D0%26housing-connector%3D0%26abo%3D0%26garage%3D0%26pool%3D0%26ac%3D0%26waterfront%3D0%26finished%3D0%26unfinished%3D0%26cityview%3D0%26mountainview%3D0%26parkview%3D0%26waterview%3D0%26hoadata%3D1%26zillow-owned%3D0%263dhome%3D0%26featuredMultiFamilyBuilding%3D0%26commuteMode%3Ddriving%26commuteTimeOfDay%3Dnow%09%09%09%7B%22isList%22%3Atrue%2C%22isMap%22%3Atrue%7D%09%09%09%09%09', "origin": "https://www.zillow.com", - "referer": "https://www.zillow.com/", + "referer": "https://www.zillow.com/homes/Dallas,-TX_rb/", "sec-ch-ua": '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", - "sec-fetch-site": "cross-site", + "sec-fetch-site": "same-origin", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", } diff --git a/homeharvest/exceptions.py b/homeharvest/exceptions.py index 99df9ef..299e02b 100644 --- a/homeharvest/exceptions.py +++ b/homeharvest/exceptions.py @@ -12,3 +12,7 @@ class NoResultsFound(Exception): class PropertyNotFound(Exception): """Raised when no property is found for the given address""" + + +class BoxBoundsNotFound(Exception): + """Raised when no property is found for the given address""" From 471e53118e1061b5bbc0f20b99a2686964b49e92 Mon Sep 17 00:00:00 2001 From: Cullen Watson Date: Mon, 18 Sep 2023 14:07:37 -0500 Subject: [PATCH 2/4] refactor(redfin): fit to use updated models --- homeharvest/core/scrapers/models.py | 2 +- homeharvest/core/scrapers/redfin/__init__.py | 21 +++++++++++++------- homeharvest/core/scrapers/zillow/__init__.py | 20 ++++++++----------- homeharvest/utils.py | 6 ++++++ 4 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 homeharvest/utils.py diff --git a/homeharvest/core/scrapers/models.py b/homeharvest/core/scrapers/models.py index b3075c5..6ae6955 100644 --- a/homeharvest/core/scrapers/models.py +++ b/homeharvest/core/scrapers/models.py @@ -53,7 +53,7 @@ class Address: city: str state: str zip_code: str - unit: str + unit: str | None = None country: str | None = None diff --git a/homeharvest/core/scrapers/redfin/__init__.py b/homeharvest/core/scrapers/redfin/__init__.py index 29855a7..f1d9c29 100644 --- a/homeharvest/core/scrapers/redfin/__init__.py +++ b/homeharvest/core/scrapers/redfin/__init__.py @@ -1,7 +1,8 @@ import json -from ..models import Property, Address, PropertyType -from .. import Scraper from typing import Any +from .. import Scraper +from ....utils import parse_address_two +from ..models import Property, Address, PropertyType class RedfinScraper(Scraper): @@ -38,20 +39,26 @@ class RedfinScraper(Scraper): return home[key]["value"] if not single_search: + unit = parse_address_two(get_value("streetLine")) address = Address( - address_one=get_value("streetLine"), + street_address=get_value("streetLine"), city=home["city"], state=home["state"], zip_code=home["zip"], + unit=unit, + country="USA", ) else: address_info = home["streetAddress"] + unit = parse_address_two(address_info["assembledAddress"]) address = Address( - address_one=address_info["assembledAddress"], + street_address=address_info["assembledAddress"], city=home["city"], state=home["state"], zip_code=home["zip"], + unit=unit, + country="USA", ) url = "https://www.redfin.com{}".format(home["url"]) property_type = home["propertyType"] if "propertyType" in home else None @@ -69,7 +76,7 @@ class RedfinScraper(Scraper): site_name=self.site_name, listing_type=self.listing_type, address=address, - url=url, + property_url=url, beds=home["beds"] if "beds" in home else None, baths=home["baths"] if "baths" in home else None, stories=home["stories"] if "stories" in home else None, @@ -79,9 +86,9 @@ class RedfinScraper(Scraper): if not single_search else home["yearBuilt"], square_feet=get_value("sqFt"), - lot_size=lot_size, + lot_area_value=lot_size, property_type=PropertyType.from_int_code(home.get("propertyType")), - price_per_square_foot=get_value("pricePerSqFt"), + price_per_sqft=get_value("pricePerSqFt"), price=get_value("price"), mls_id=get_value("mlsId"), ) diff --git a/homeharvest/core/scrapers/zillow/__init__.py b/homeharvest/core/scrapers/zillow/__init__.py index 5843b40..6c36196 100644 --- a/homeharvest/core/scrapers/zillow/__init__.py +++ b/homeharvest/core/scrapers/zillow/__init__.py @@ -1,8 +1,9 @@ import re import json -from ..models import Property, Address, ListingType, PropertyType, SiteName -from ....exceptions import NoResultsFound, PropertyNotFound from .. import Scraper +from ....utils import parse_address_two +from ....exceptions import NoResultsFound, PropertyNotFound +from ..models import Property, Address, ListingType, PropertyType, SiteName class ZillowScraper(Scraper): @@ -120,7 +121,7 @@ class ZillowScraper(Scraper): resp = self.session.put(url, headers=self._get_headers(), data=payload) resp.raise_for_status() a = resp.json() - return self._parse_properties(resp.json()) + return parse_properties(resp.json()) def _parse_properties(self, property_data: dict): mapresults = property_data["cat1"]["searchResults"]["mapResults"] @@ -249,7 +250,7 @@ class ZillowScraper(Scraper): else property_data["hdpUrl"] ) address_data = property_data["address"] - unit = self._parse_address_two(address_data["streetAddress"]) + unit = parse_address_two(address_data["streetAddress"]) address = Address( street_address=address_data["streetAddress"], unit=unit, @@ -288,11 +289,6 @@ class ZillowScraper(Scraper): listing_type=self.listing_type, ) - @staticmethod - def _parse_address_two(address_one: str): - apt_match = re.search(r"(APT\s*.+|#[\s\S]+)$", address_one, re.I) - return apt_match.group().strip() if apt_match else None - def _extract_address(self, address_str): """ Extract address components from a string formatted like '555 Wedglea Dr, Dallas, TX', @@ -309,14 +305,14 @@ class ZillowScraper(Scraper): if len(state_zip) == 1: state = state_zip[0].strip() - zip_code = None + zip_code = None elif len(state_zip) == 2: state = state_zip[0].strip() zip_code = state_zip[1].strip() else: raise ValueError(f"Unexpected state/zip format in address: {address_str}") - unit = self._parse_address_two(street_address) + unit = parse_address_two(street_address) return Address( street_address=street_address, city=city, @@ -335,7 +331,7 @@ class ZillowScraper(Scraper): "content-type": "application/json", "cookie": 'zjs_user_id=null; zg_anonymous_id=%220976ab81-2950-4013-98f0-108b15a554d2%22; zguid=24|%246b1bc625-3955-4d1e-a723-e59602e4ed08; g_state={"i_p":1693611172520,"i_l":1}; zgsession=1|d48820e2-1659-4d2f-b7d2-99a8127dd4f3; zjs_anonymous_id=%226b1bc625-3955-4d1e-a723-e59602e4ed08%22; JSESSIONID=82E8274D3DC8AF3AB9C8E613B38CF861; search=6|1697585860120%7Crb%3DDallas%252C-TX%26rect%3D33.016646%252C-96.555516%252C32.618763%252C-96.999347%26disp%3Dmap%26mdm%3Dauto%26sort%3Ddays%26listPriceActive%3D1%26fs%3D1%26fr%3D0%26mmm%3D0%26rs%3D0%26ah%3D0%26singlestory%3D0%26abo%3D0%26garage%3D0%26pool%3D0%26ac%3D0%26waterfront%3D0%26finished%3D0%26unfinished%3D0%26cityview%3D0%26mountainview%3D0%26parkview%3D0%26waterview%3D0%26hoadata%3D1%263dhome%3D0%26commuteMode%3Ddriving%26commuteTimeOfDay%3Dnow%09%0938128%09%7B%22isList%22%3Atrue%2C%22isMap%22%3Atrue%7D%09%09%09%09%09; AWSALB=gAlFj5Ngnd4bWP8k7CME/+YlTtX9bHK4yEkdPHa3VhL6K523oGyysFxBEpE1HNuuyL+GaRPvt2i/CSseAb+zEPpO4SNjnbLAJzJOOO01ipnWN3ZgPaa5qdv+fAki; AWSALBCORS=gAlFj5Ngnd4bWP8k7CME/+YlTtX9bHK4yEkdPHa3VhL6K523oGyysFxBEpE1HNuuyL+GaRPvt2i/CSseAb+zEPpO4SNjnbLAJzJOOO01ipnWN3ZgPaa5qdv+fAki; search=6|1697587741808%7Crect%3D33.37188814545521%2C-96.34484483007813%2C32.260490641365685%2C-97.21001816992188%26disp%3Dmap%26mdm%3Dauto%26p%3D1%26sort%3Ddays%26z%3D1%26listPriceActive%3D1%26fs%3D1%26fr%3D0%26mmm%3D0%26rs%3D0%26ah%3D0%26singlestory%3D0%26housing-connector%3D0%26abo%3D0%26garage%3D0%26pool%3D0%26ac%3D0%26waterfront%3D0%26finished%3D0%26unfinished%3D0%26cityview%3D0%26mountainview%3D0%26parkview%3D0%26waterview%3D0%26hoadata%3D1%26zillow-owned%3D0%263dhome%3D0%26featuredMultiFamilyBuilding%3D0%26commuteMode%3Ddriving%26commuteTimeOfDay%3Dnow%09%09%09%7B%22isList%22%3Atrue%2C%22isMap%22%3Atrue%7D%09%09%09%09%09', "origin": "https://www.zillow.com", - "referer": "https://www.zillow.com/homes/Dallas,-TX_rb/", + "referer": "https://www.zillow.com", "sec-ch-ua": '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', diff --git a/homeharvest/utils.py b/homeharvest/utils.py new file mode 100644 index 0000000..a22cdcf --- /dev/null +++ b/homeharvest/utils.py @@ -0,0 +1,6 @@ +import re + + +def parse_address_two(address_one: str): + apt_match = re.search(r"(APT\s*.+|#[\s\S]+)$", address_one, re.I) + return apt_match.group().strip() if apt_match else None From 869d7e7c5183577a95a8cefa59ee70d8030faa2f Mon Sep 17 00:00:00 2001 From: Cullen Watson Date: Mon, 18 Sep 2023 15:43:44 -0500 Subject: [PATCH 3/4] refator(realtor): fit to updated models --- homeharvest/__init__.py | 7 +- homeharvest/core/scrapers/models.py | 11 +- homeharvest/core/scrapers/realtor/__init__.py | 152 +++++++++++------- homeharvest/core/scrapers/redfin/__init__.py | 24 +-- homeharvest/core/scrapers/zillow/__init__.py | 40 +++-- tests/test_realtor.py | 14 +- tests/test_redfin.py | 12 +- tests/test_zillow.py | 12 +- 8 files changed, 163 insertions(+), 109 deletions(-) diff --git a/homeharvest/__init__.py b/homeharvest/__init__.py index c3ec0d3..27ea8cb 100644 --- a/homeharvest/__init__.py +++ b/homeharvest/__init__.py @@ -70,7 +70,10 @@ def process_result(result: Property) -> pd.DataFrame: prop_data["site_name"] = prop_data["site_name"].value prop_data["listing_type"] = prop_data["listing_type"].value.lower() - prop_data["property_type"] = prop_data["property_type"].value.lower() + if "property_type" in prop_data and prop_data["property_type"] is not None: + prop_data["property_type"] = prop_data["property_type"].value.lower() + else: + prop_data["property_type"] = None if "address" in prop_data: address_data = prop_data["address"] prop_data["street_address"] = address_data.street_address @@ -108,7 +111,7 @@ def scrape_property( scraper_input = ScraperInput( location=location, listing_type=ListingType[listing_type.upper()], - site_name=SiteName[site_name.upper()], + site_name=SiteName.get_by_value(site_name.lower()), ) site = _scrapers[site_name.lower()](scraper_input) diff --git a/homeharvest/core/scrapers/models.py b/homeharvest/core/scrapers/models.py index b08ac69..8385405 100644 --- a/homeharvest/core/scrapers/models.py +++ b/homeharvest/core/scrapers/models.py @@ -7,6 +7,13 @@ class SiteName(Enum): REDFIN = "redfin" REALTOR = "realtor.com" + @classmethod + def get_by_value(cls, value): + for item in cls: + if item.value == value: + return item + raise ValueError(f"{value} not found in {cls}") + class ListingType(Enum): FOR_SALE = "FOR_SALE" @@ -57,14 +64,13 @@ class Address: country: str | None = None - @dataclass class Property: property_url: str site_name: SiteName listing_type: ListingType - property_type: PropertyType address: Address + property_type: PropertyType | None = None # house for sale price: int | None = None @@ -78,7 +84,6 @@ class Property: stories: int | None = None year_built: int | None = None price_per_sqft: int | None = None - year_built: int | None = None mls_id: str | None = None agent_name: str | None = None diff --git a/homeharvest/core/scrapers/realtor/__init__.py b/homeharvest/core/scrapers/realtor/__init__.py index d3660f6..f6490f6 100644 --- a/homeharvest/core/scrapers/realtor/__init__.py +++ b/homeharvest/core/scrapers/realtor/__init__.py @@ -3,6 +3,7 @@ from ..models import Property, Address from .. import Scraper from typing import Any, Generator from ....exceptions import NoResultsFound +from ....utils import parse_address_two from concurrent.futures import ThreadPoolExecutor, as_completed @@ -29,7 +30,7 @@ class RealtorScraper(Scraper): params = { "input": self.location, - "client_id": self.listing_type.value.replace('_', '-'), + "client_id": self.listing_type.value.lower().replace("_", "-"), "limit": "1", "area_types": "city,state,county,postal_code,address,street,neighborhood,school,school_district,university,park", } @@ -96,46 +97,57 @@ class RealtorScraper(Scraper): } }""" - variables = { - 'property_id': property_id - } + variables = {"property_id": property_id} payload = { - 'query': query, - 'variables': variables, + "query": query, + "variables": variables, } response = self.session.post(self.search_url, json=payload) response_json = response.json() - property_info = response_json['data']['property'] + property_info = response_json["data"]["property"] + street_address = property_info["address"]["line"] + unit = parse_address_two(street_address) - return [Property( - site_name=self.site_name, - address=Address( - address_one=property_info['address']['line'], - city=property_info['address']['city'], - state=property_info['address']['state_code'], - zip_code=property_info['address']['postal_code'], - ), - url="https://www.realtor.com/realestateandhomes-detail/" + property_info['details']['permalink'], - beds=property_info['basic']['beds'], - baths=property_info['basic']['baths'], - stories=property_info['details']['stories'], - year_built=property_info['details']['year_built'], - square_feet=property_info['basic']['sqft'], - price_per_square_foot=property_info['basic']['price'] / property_info['basic']['sqft'] - if property_info['basic']['sqft'] is not None and - property_info['basic']['price'] is not None - else None, - price=property_info['basic']['price'], - mls_id=property_id, - listing_type=self.listing_type, - lot_size=property_info['public_record']['lot_size'] if property_info['public_record'] is not None else None, - )] + return [ + Property( + site_name=self.site_name, + address=Address( + street_address=street_address, + city=property_info["address"]["city"], + state=property_info["address"]["state_code"], + zip_code=property_info["address"]["postal_code"], + unit=unit, + country="USA", + ), + property_url="https://www.realtor.com/realestateandhomes-detail/" + + property_info["details"]["permalink"], + beds=property_info["basic"]["beds"], + baths=property_info["basic"]["baths"], + stories=property_info["details"]["stories"], + year_built=property_info["details"]["year_built"], + square_feet=property_info["basic"]["sqft"], + price_per_sqft=property_info["basic"]["price"] + // property_info["basic"]["sqft"] + if property_info["basic"]["sqft"] is not None + and property_info["basic"]["price"] is not None + else None, + price=property_info["basic"]["price"], + mls_id=property_id, + listing_type=self.listing_type, + lot_area_value=property_info["public_record"]["lot_size"] + if property_info["public_record"] is not None + else None, + ) + ] - def handle_area(self, variables: dict, return_total: bool = False) -> list[Property] | int: - query = """query Home_search( + def handle_area( + self, variables: dict, return_total: bool = False + ) -> list[Property] | int: + query = ( + """query Home_search( $city: String, $county: [String], $state_code: String, @@ -193,42 +205,57 @@ class RealtorScraper(Scraper): } } } - }""" % self.listing_type.value + }""" + % self.listing_type.value.lower() + ) payload = { - 'query': query, - 'variables': variables, + "query": query, + "variables": variables, } response = self.session.post(self.search_url, json=payload) + response.raise_for_status() response_json = response.json() if return_total: - return response_json['data']['home_search']['total'] + return response_json["data"]["home_search"]["total"] properties: list[Property] = [] - for result in response_json['data']['home_search']['results']: + if ( + response_json is None + or "data" not in response_json + or response_json["data"] is None + or "home_search" not in response_json["data"] + or response_json["data"]["home_search"] is None + or "results" not in response_json["data"]["home_search"] + ): + return [] + + for result in response_json["data"]["home_search"]["results"]: realty_property = Property( address=Address( - address_one=result['location']['address']['line'], - city=result['location']['address']['city'], - state=result['location']['address']['state_code'], - zip_code=result['location']['address']['postal_code'], - address_two=result['location']['address']['unit'], + street_address=result["location"]["address"]["line"], + city=result["location"]["address"]["city"], + state=result["location"]["address"]["state_code"], + zip_code=result["location"]["address"]["postal_code"], + unit=result["location"]["address"]["unit"], + country="USA", ), site_name=self.site_name, - url="https://www.realtor.com/realestateandhomes-detail/" + result['property_id'], - beds=result['description']['beds'], - baths=result['description']['baths'], - stories=result['description']['stories'], - year_built=result['description']['year_built'], - square_feet=result['description']['sqft'], - price_per_square_foot=result['price_per_sqft'], - price=result['list_price'], - mls_id=result['property_id'], + property_url="https://www.realtor.com/realestateandhomes-detail/" + + result["property_id"], + beds=result["description"]["beds"], + baths=result["description"]["baths"], + stories=result["description"]["stories"], + year_built=result["description"]["year_built"], + square_feet=result["description"]["sqft"], + price_per_sqft=result["price_per_sqft"], + price=result["list_price"], + mls_id=result["property_id"], listing_type=self.listing_type, - lot_size=result['description']['lot_sqft'], + lot_area_value=result["description"]["lot_sqft"], ) properties.append(realty_property) @@ -239,17 +266,17 @@ class RealtorScraper(Scraper): location_info = self.handle_location() location_type = location_info["area_type"] - if location_type == 'address': - property_id = location_info['mpr_id'] + if location_type == "address": + property_id = location_info["mpr_id"] return self.handle_address(property_id) offset = 0 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'), - 'offset': offset, + "city": location_info.get("city"), + "county": location_info.get("county"), + "state_code": location_info.get("state_code"), + "postal_code": location_info.get("postal_code"), + "offset": offset, } total = self.handle_area(search_variables, return_total=True) @@ -258,8 +285,11 @@ class RealtorScraper(Scraper): with ThreadPoolExecutor(max_workers=10) as executor: futures = [ executor.submit( - self.handle_area, variables=search_variables | {'offset': i}, return_total=False - ) for i in range(0, total, 200) + self.handle_area, + variables=search_variables | {"offset": i}, + return_total=False, + ) + for i in range(0, total, 200) ] for future in as_completed(futures): diff --git a/homeharvest/core/scrapers/redfin/__init__.py b/homeharvest/core/scrapers/redfin/__init__.py index bec2cce..5350460 100644 --- a/homeharvest/core/scrapers/redfin/__init__.py +++ b/homeharvest/core/scrapers/redfin/__init__.py @@ -100,28 +100,27 @@ class RedfinScraper(Scraper): address=Address( street_address=" ".join( [ - building['address']['streetNumber'], - building['address']['directionalPrefix'], - building['address']['streetName'], - building['address']['streetType'], + building["address"]["streetNumber"], + building["address"]["directionalPrefix"], + building["address"]["streetName"], + building["address"]["streetType"], ] ), - city=building['address']['city'], - state=building['address']['stateOrProvinceCode'], - zip_code=building['address']['postalCode'], + city=building["address"]["city"], + state=building["address"]["stateOrProvinceCode"], + zip_code=building["address"]["postalCode"], unit=" ".join( [ - building['address']['unitType'], - building['address']['unitValue'], + building["address"]["unitType"], + building["address"]["unitValue"], ] - ) + ), ), property_url="https://www.redfin.com{}".format(building["url"]), listing_type=self.listing_type, bldg_unit_count=building["numUnitsForSale"], ) - def handle_address(self, home_id: str): """ EPs: @@ -160,7 +159,8 @@ class RedfinScraper(Scraper): homes = [ self._parse_home(home) for home in response_json["payload"]["homes"] ] + [ - self._parse_building(building) for building in response_json["payload"]["buildings"].values() + self._parse_building(building) + for building in response_json["payload"]["buildings"].values() ] return homes diff --git a/homeharvest/core/scrapers/zillow/__init__.py b/homeharvest/core/scrapers/zillow/__init__.py index 4aa60a7..0b4d889 100644 --- a/homeharvest/core/scrapers/zillow/__init__.py +++ b/homeharvest/core/scrapers/zillow/__init__.py @@ -98,26 +98,24 @@ class ZillowScraper(Scraper): else filter_state_sold ) - payload = json.dumps( - { - "searchQueryState": { - "pagination": {}, - "isMapVisible": True, - "mapBounds": { - "west": coords[0], - "east": coords[1], - "south": coords[2], - "north": coords[3], - }, - "filterState": selected_filter, - "isListVisible": True, - "mapZoom": 11, + payload = { + "searchQueryState": { + "pagination": {}, + "isMapVisible": True, + "mapBounds": { + "west": coords[0], + "east": coords[1], + "south": coords[2], + "north": coords[3], }, - "wants": {"cat1": ["mapResults"]}, - "isDebugRequest": False, - } - ) - resp = self.session.put(url, headers=self._get_headers(), data=payload) + "filterState": selected_filter, + "isListVisible": True, + "mapZoom": 11, + }, + "wants": {"cat1": ["mapResults"]}, + "isDebugRequest": False, + } + resp = self.session.put(url, headers=self._get_headers(), json=payload) resp.raise_for_status() a = resp.json() return self._parse_properties(resp.json()) @@ -176,9 +174,7 @@ class ZillowScraper(Scraper): and result["variableData"]["type"] == "TIME_ON_INFO" else None, "img_src": result.get("imgSrc"), - "price_per_sqft": int( - home_info["price"] // home_info["livingArea"] - ) + "price_per_sqft": int(home_info["price"] // home_info["livingArea"]) if "livingArea" in home_info and "price" in home_info else None, } diff --git a/tests/test_realtor.py b/tests/test_realtor.py index 291eb12..24d5281 100644 --- a/tests/test_realtor.py +++ b/tests/test_realtor.py @@ -3,9 +3,17 @@ from homeharvest import scrape_property def test_realtor(): results = [ - scrape_property(location="2530 Al Lipscomb Way", site_name="realtor.com"), - scrape_property(location="Phoenix, AZ", site_name="realtor.com"), #: does not support "city, state, USA" format - scrape_property(location="Dallas, TX", site_name="realtor.com"), #: does not support "city, state, USA" format + scrape_property( + location="2530 Al Lipscomb Way", + site_name="realtor.com", + listing_type="for_sale", + ), + scrape_property( + location="Phoenix, AZ", site_name="realtor.com", listing_type="for_rent" + ), #: does not support "city, state, USA" format + scrape_property( + location="Dallas, TX", site_name="realtor.com", listing_type="sold" + ), #: does not support "city, state, USA" format scrape_property(location="85281", site_name="realtor.com"), ] diff --git a/tests/test_redfin.py b/tests/test_redfin.py index 78fa541..575d1b4 100644 --- a/tests/test_redfin.py +++ b/tests/test_redfin.py @@ -3,9 +3,15 @@ from homeharvest import scrape_property def test_redfin(): results = [ - scrape_property(location="2530 Al Lipscomb Way", site_name="redfin"), - scrape_property(location="Phoenix, AZ, USA", site_name="redfin"), - scrape_property(location="Dallas, TX, USA", site_name="redfin"), + scrape_property( + location="2530 Al Lipscomb Way", site_name="redfin", listing_type="for_sale" + ), + scrape_property( + location="Phoenix, AZ, USA", site_name="redfin", listing_type="for_rent" + ), + scrape_property( + location="Dallas, TX, USA", site_name="redfin", listing_type="sold" + ), scrape_property(location="85281", site_name="redfin"), ] diff --git a/tests/test_zillow.py b/tests/test_zillow.py index d9a56dc..38c3114 100644 --- a/tests/test_zillow.py +++ b/tests/test_zillow.py @@ -3,9 +3,15 @@ from homeharvest import scrape_property def test_zillow(): results = [ - scrape_property(location="2530 Al Lipscomb Way", site_name="zillow"), - scrape_property(location="Phoenix, AZ, USA", site_name="zillow"), - scrape_property(location="Dallas, TX, USA", site_name="zillow"), + scrape_property( + location="2530 Al Lipscomb Way", site_name="zillow", listing_type="for_sale" + ), + scrape_property( + location="Phoenix, AZ, USA", site_name="zillow", listing_type="for_rent" + ), + scrape_property( + location="Dallas, TX, USA", site_name="zillow", listing_type="sold" + ), scrape_property(location="85281", site_name="zillow"), ] From 5d0f519a8570829a5f50cf9d889c9132f4af9c75 Mon Sep 17 00:00:00 2001 From: Cullen Watson Date: Mon, 18 Sep 2023 15:44:13 -0500 Subject: [PATCH 4/4] chore: update version number --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3ecd478..403ba1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "homeharvest" -version = "0.1.3" +version = "0.1.4" description = "Real estate scraping library" authors = ["Zachary Hampton ", "Cullen Watson "] homepage = "https://github.com/ZacharyHampton/HomeHarvest"