Как программисты ищут квартиры

в 11:34, , рубрики: open source, python, недвижимость, ненормальное программирование, парсинг контента

image
На самом деле все происходит не так...

Один мой знакомый попросил меня написать эту статью. В статье пойдет рассказ о его похождениях, которые могут (могли) быть неправильно восприняты администрацией определенных интернет ресурсов. И те, в свою очередь, могут (могли) пожаловаться на моего знакомого куда следует. Поэтому пишу статью с его слов я. А он уехал. В Гондурас. Насовсем.

Проблема

Пару лет назад (наконец-то!) наступил в моей жизни момент, когда мне нужно можно было купить квартиру. Оставалось ее найти. Дело осложнялось тем, что были у меня свои взгляды на то, какой должна быть моя идеальная квартира. А именно — она должна была быть НА последнем этаже. Ну чтобы никто по мозгу не ходил. Ну и плевать на всех удобнее.

Центровой местный сайт по поиску недвижимости, как бы это помягче сказать, «сделан был немного неудобно». Поиск квартир на нем содержал стандартные для подобных сервисов настройки: год постройки, этажность, цена, не (!) последний/первый этаж и т.д. Причем он, поиск, когда я его просил выдать мне квартиры с раздельным санузлом, иногда выдавал квартиры с совмещенным. С балконом была похожая история. А раз он (поиск) иногда выдает квартиры несоответствующие моему запросу, то, возможно, он не показывает и соответствующие. А в мою выборку (квартира на последнем этаже, раздельный узел, этажность > 5, недалеко от метро, и бла бла бла) уже по определению не могло попадать много квартир…

Ковальски, варианты!

Оставалось только одно — выгрузить все квартиры с сайта к себе локально: сохраняешь их в какую-нибудь базу, берешь в руки SQL (ну или помоднее что-нить) «и погнал» (с).

Легко сказать, но сложно сделать. Первой идеей было посмотреть на движок сайта, поискать в нем какие-нибудь дыры, достучаться до сервера, где хранится вся информация о квартирах, ну и скопировать ее оттуда. Но это плохо, видимо, квалификации моей на тот момент не хватило.

На целевом сайте нашелся раздел для агентств по недвижимости. Сотрудничество, все дела. Там, при условии, что ты — агентство, можно было получить (купить?) доступ к специализированному ПО, которое, судя по инструкции к нему и скриншотам, позволяло в автоматизированном виде подавать объявления от лица агентства (от спамеры, да?). Теоретически, в этом ПО тоже можно было найти информацию о серверной стороне и вытащить оттуда информацию о квартирах. Тут-то моей квалификации, думаю, хватило бы. Но доступа к ПО у меня не было, а агентством становиться не хотелось.

Поэтому ничего не оставалось, кроме как написать его…

Парсер

Программно заходим на сайт, «ищем» все квартиры и парсим результаты, сохраняя их в локальную БД. Писать парсер решил на Python — относительно новый язык для меня на тот момент он был, а поднять уровень в нем было полезно (поэтому и код соответствующий).

Для скачки страниц применялась стандартная urllib:

from urllib import FancyURLopener, quote_plus
...

flatsPageContent = urlOpener.open(flatsPageURL).read()

Для парсинга HTML решил использовать (после активного гугления) библиотеку lxml:

from lxml.html import parse
...

flatsPageDocument = parse(flatsPageFilePath).getroot()
if flatsPageDocument is not None:
	flatsTables = flatsPageDocument.xpath('//*[@id="list"]')

Все это банально и неинтересно. А интересным было другое.

А далеко ли метро?

Будучи человеком безлошадным и перемещающимся строго на общественном транспорте, для меня было критично близость метро к моей будущей квартире. Эдак, метров не больше 2х тысяч. Поэтому возникла идея определения ближайшей станции метро к квартире, и ее расстояния к ней. А потом и реализация:

Немного кода
def getFlatLocation(flatPageName, flatAddress, mode, geoDBCursor):
	logging.info('Retrieving geo code info for flat '%s' (mode '%s')...' % (flatPageName, mode))
	flatFullAddress = (flatBaseAddress + flatAddress).encode('utf8')
	
	geoCodeResult = ''
	isGeoCodeResultCached = 1
	geoDBCursor.execute("SELECT geoCode FROM %s WHERE address = ?" % ("GeoG" if mode == 'G' else "GeoY"), (flatFullAddress,))
	geoCodeResultRow = geoDBCursor.fetchone()
	if geoCodeResultRow is not None:
		geoCodeResult = geoCodeResultRow[0]

	if geoCodeResult is None or len(geoCodeResult) == 0:
		isGeoCodeResultCached = 0
		geoCodeURL = ('http://maps.google.com/maps/api/geocode/json?sensor=false&address=' if mode == "G" else 'http://geocode-maps.yandex.ru/1.x/?format=json&geocode=') + quote_plus(flatFullAddress)
		urlOpener = UrlOpener()
		geoCodeResult = urlOpener.open(geoCodeURL).read()

	if geoCodeResult is None:
		geoCodeResult = ''

	logging.info('Geo code result for flat '%s' was fetched (mode '%s', from cache - %d)' % (flatPageName, mode, isGeoCodeResultCached))

	flatLocation = 0
	geoCodeJson = json.loads(geoCodeResult)
	if geoCodeJson is not None and (len(geoCodeJson['results']) if mode == 'G' else len(geoCodeJson['response'])):
		if isGeoCodeResultCached == 0:
			geoDBCursor.execute("INSERT INTO %s VALUES (?, ?)" % ("GeoG" if mode == 'G' else "GeoY"), (flatFullAddress, geoCodeResult))
		if mode == "G":
			geoCodeLocation = geoCodeJson['results'][0]['geometry']['location']
			flatLocation = {'lat': float(geoCodeLocation['lat']), 'lng': float(geoCodeLocation['lng'])}
		else:
			geoCodeLocation = geoCodeJson['response']['GeoObjectCollection']['featureMember'][0]['GeoObject']['Point']['pos']
			(flatLocationLng, flatLocationLat) = re.search('(.*) (.*)', geoCodeLocation).group(1, 2)
			flatLocation = {'lat': float(flatLocationLat), 'lng': float(flatLocationLng)}
		
		logging.info('Geo code info for flat '%s' was retrieved (mode '%s')' % (flatPageName, mode))
	else:
		logging.warning('Geo code info for flat '%s' was NOT retrieved (mode '%s')' % (flatPageName, mode))

	return (flatLocation, isGeoCodeResultCached)

Как видно из кода, в качестве источника данных геокодирования используются Google и Yandex. Почему не кто-то один? Просто для новых улиц (да и для старых, или некорректно введенных) кто-то из источников мог выдать неправильные или усредненные данные (координаты центра города, например). Поэтому два движка используются одновременно, чтобы можно было визуально отсеять явно неверные результаты. Понятное дело, что и у Google'а и Yandex'а существовала квота на количество запросов в сутки с IP. Поэтому результаты «пробивания» адресов бережно сохранялись в базу для использования при последующих запусках парсера.

С помочью гугло-карт была набита таблица с координатами станций метро, в том числе еще строящихся. А расстояние определялось просто с помощью теоремы Пифагора:

def calculateDistance(location1, location2):
	# haversine formula, see http://www.movable-type.co.uk/scripts/latlong.html for details
	R = 6371 * 1000 # Radius of the Earth in m
	dLat = (location2['lat'] - location1['lat']) * (math.pi / 180)
	dLng = (location2['lng'] - location1['lng']) * (math.pi / 180) 
	a = math.sin(dLat / 2) * math.sin(dLat / 2) + math.cos(location1['lat'] * (math.pi / 180)) * math.cos(location2['lat'] * (math.pi / 180)) * math.sin(dLng / 2) * math.sin(dLng / 2) 
	c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
	d = R * c
	return d

А вот и ближайшая станция метро:

def getFlatDistanceInfo(flatLocation):
	flatSubwayStationDistances = map(lambda subwayStationInfo: calculateDistance(flatLocation, subwayStationInfo['location']), subwayStationInfos)
	flatNearestSubwayStationDistance = min(flatSubwayStationDistances)
	flatNearestSubwayStationName = subwayStationInfos[flatSubwayStationDistances.index(flatNearestSubwayStationDistance)]['name']
	flatTownCenterDistance = flatSubwayStationDistances[0]
	return (flatNearestSubwayStationName, flatNearestSubwayStationDistance, flatTownCenterDistance)

Отслеживание цены квартиры

Все мы, наверное, не раз читали статьи типа «Цены на квартиры в городе N стали снижаться на X% в месяц». Так вот у меня на эту тему было свое мнение.

Раз все извлеченные квартиры сохранялись локально в базу, то можно было отслеживать изменения цен на них. Заглядывая в старую базу данных и находя информацию об извлекаемой квартире там, можно было рассчитать дельту ее цены:

isFlatInfoUpdated = 0
flatPriceDelta = 0
if len(oldFlatsDBFilePath):
	oldFlatsDBCursor.execute('''SELECT flatPriceInfo FROM Flats WHERE flatPageURL = ? AND flatAddress = ? AND flatWholeSquare = ? AND flatLivingSquare  = ? AND flatKitchenSquare = ?''', (flatPageURL, flatAddress, flatWholeSquare, flatLivingSquare, flatKitchenSquare,))
	oldFlatInfoRow = oldFlatsDBCursor.fetchone()
	if oldFlatInfoRow is not None and oldFlatInfoRow[0] is not None:
		isFlatInfoUpdated = 1
		oldFlatPriceInfo = oldFlatInfoRow[0]
		try:
			flatPriceDelta = float(flatPriceInfo) - float(oldFlatPriceInfo)
		except ValueError:
			pass

Поэтому, каждый раз читая статьи с анализом рынка недвижимости, я улыбался, зная, что «мои» квартиры вовсе не растут в цене. Может, они никому кроме меня не были нужны?

Вам раздельный или совмещенный?

Я программист, а программисты много думают. А разве такое возможно в совмещенном санузле?

Проблема была в том, что сайт поиска недвижимости скрывал эту информацию внутри страницы описания квартиры и не показывал в списке результатов поиска. Поэтому был добавлен специальный режим работы парсера, названный «flatsDeepParseMode». Как говориться, «We need to go deeper» (с). Он разрешал парсеру скачивать не только страницы результатов поиска квартир, но и непосредственно страницы описания квартир. А уже из них извлекалась дополнительная информация по санузлу и прочему.

Отказоустойчивость

В режиме глубокого парсинга скрипт мог сильно нагружать сервер, долбя его просьбами на отдачу тысяч страниц. Это в свою очередь иногда приводило к задумчивости сервера, а, бывало, и к его отказам выполнять запросы. Поэтому скрипт после таких случаев стал поддерживать механизмы «переспроса» страниц с постепенно увеличивающимся таймаутом между попытками.

Маскировка

Однажды скрипт перестал работать. Посыпались сообщения, что сервер что-то там не может ответить, таймауты и бла бла бла. Оказалось, что владельцы сайта недвижимости наняли группу специально обученных людей, чтобы те впилили фильтрацию юзер-агентов подключающихся к серверу клиентов (чего это они вдруг?). И мой скрипт попал под раздачу. А решилось это просто — скрипт прикинулся браузером:

class UrlOpener(FancyURLopener, object):
    version = 'Mozilla/5.0 (Windows; U; Windows NT 5.1; it; rv:1.8.1.11) Gecko/20071127 Firefox/2.0.0.11'
    pass

Но однажды случилось страшное…

You are banned!

Да, меня забанили. И не только меня, как оказалось. Придя утром на работу (нужно же было как-то на квартиру зарабатывать), я увидел уже знакомое сообщение об ошибке, что сервер что-то там, таймауты и бла бла бла. Замена юзер-агента на очередной браузерный не помогла. Ведь даже браузеры не могли открыть сайт недвижимости… Да, весь наш статический IP забанили на стороне сервера. Уж не знаю, почему это произошло. Возможно, «какая-то вирусная программа слала много запросов на сервер» или сразу несколько десятков сотрудников компании решили поискать себе жилье. Но, как бы то ни было, нас забанили.

Так уж случилось, но юристам нашей компании нужно было что-то поискать на том сайте (возможно, квартиру для наших заморских коллег). А вот они-то не дали спуску администрации обнаглевшего ресурса. Мы ведь ничего такого не совершали. Никого не ддосили. Правда-правда. В общем, нас разбанили. Под честное слово.

Фичи, фичи, фичи...

Парсер еще много чего умеет: парсит квартиры до определенной цены, помечает удаленные квартиры и вновь добавленные, подсчитывает количество фотографий квартиры и т.д.

Мы берем ее, заверните

Свою идеальную квартиру я все-таки нашел. Последний этаж, рядом с метро, все дела. Нашел бы я ее без написания парсера? Не знаю, возможно. Но это было бы неспортивно, не по-программистски как-то…

P.S.

И да, я помню про старую статью на хабре, где такой же извращенец энтузиаст, как я, парсил и анализировал квартиры на языке R. И он тоже купил свою правильную квартиру. А значит — это работает.

Кстати мой парсер может уже и не работать либо работать некорректно (в связи с возможными изменениями на сайте) т.к. использовался довольно давно. И поосторожнее с ним, а то могут забанить (бывали случаи).

А компот код?!

По просьбе знакомого выкладываю исходники парсера на bitbucket.org. В репе можно найти и файлик с довольно большим, выстраданным SQL запросом, визуализирующим все извлеченные данные. Код, естественно, размещается только для вашего ознакомления.

Всем спасибо за внимание.

Автор: goghAta

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js