Як програмісти шукають квартири

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 геокодів 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 and None (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 and None 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-запитом, визуализирующим всі отримані дані. Код, природно, розміщується тільки для вашого ознайомлення.

Всім дякую за увагу.


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.