Посмотрев на свой предыдущий ноутбук, я ощутила острое желание все переделать и реструктурировать.

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

Все необходимое я перенесла сюда, также добавила что-то новое.

В результате я хочу получить функцию cianParser(), которая возвращает DataFrame.


In [1]:
import requests
import re
from bs4 import BeautifulSoup
import pandas as pd
import time
import numpy as np

In [2]:
def html_stripper(text):
    return re.sub('<[^<]+?>', '', str(text))

Тут я соберу все ссылки на квартиры по каждому округу, запишу их в словарь links.

Я хочу хранить ссылки в словаре. Ключами будут округи, значениями - все найденные ссылки для округа.


In [6]:
links = dict([('NW' ,[np.nan]), ('C',[np.nan]), ('N',[np.nan]), ('NE',[np.nan]), ('E',[np.nan]), ('SE',[np.nan]), ('S',[np.nan]), ('SW',[np.nan]), ('W',[np.nan])])

Cian предлагает для просмотра не более 30 страниц выдачи для каждого запроса. Всего у нас получится не более 30 страниц х 25 квартир на странице х 9 округов = 6750 объектов. Вроде нормально. Но практика показала, что если выбросить дубликаты, то останется около 900 объектов. С чем это может быть связано? Я заметила, что некоторые объявления фигурируют больше, чем в одном округе (я даже видела дом по Бирюлевской улице, отнесенный не только к своему, но и к Тверскому району ЦАО), какие-то объявления появляются в топе выдачи не только первой страницы запроса. Но причины тут не так важны, их наверняка побольше. Важно другое - мне захотелось в итоге иметь побольше датасет.

Тогда я решила сделать свои запросы чуть более подробными и пройтись не по 9 округам, а по всем районам (в каждом округе их довольно много, порядка 15-20). Итого получилось прохождение по результатам 120 поисковых запросов.


In [9]:
districts = {1: 'NW', 4: 'C', 5:'N', 6:'NE', 7:'E', 8:'SE', 9:'S', 10:'SW', 11:'W'}
zone_bounds = {1: (125, 133), 4: (13, 23), 5: (23, 39), 6:(39, 56), 7:(56, 72), 8:(72, 84), 9:(84, 100), 10:(100, 112), 11:(112, 125)}

for i in districts.keys():
    left = zone_bounds[i][0]
    right = zone_bounds[i][1]
    
    for j in range(left, right):
        zone = 'http://www.cian.ru/cat.php?deal_type=sale&district%5B0%5D=' + str(j) + '&engine_version=2&offer_type=flat&room1=1&room2=1&room3=1&room4=1&room5=1&room6=1'     
    
        for page in range(1, 31):
            page_url =  zone.format(page)

            search_page = requests.get(page_url)
            search_page = search_page.content
            search_page = BeautifulSoup(search_page, 'lxml')

            flat_urls = search_page.findAll('div', attrs = {'ng-class':"{'serp-item_removed': offer.remove.state, 'serp-item_popup-opened': isPopupOpen}"})
            flat_urls = re.split('http://www.cian.ru/sale/flat/|/" ng-class="', str(flat_urls))


            for link in flat_urls:
                if link.isdigit():
                    links[districts.get(i)].append(link)

Здесь идет блок функций, наработанных и объясненных в прошлом ноутбуке. В этом я буду лишь указывать их предназначение.

Цена

По сравнению с предыдущим ноутбуком тут появилась обработка ситуации цены в долларах, которая мне встретилась только на этот раз


In [111]:
def getPrice(flat_page):
    price = flat_page.find('div', attrs={'class':'object_descr_price'})
    price = re.split('<div>|руб|\W', str(price))
    price = "".join([i for i in price if i.isdigit()][-4:])

    dollar = '808080'
    if dollar in price:
        price = price[6:]
        
    return int(price)

Расстояние до центра города (вспомогательные: гаверсинус - расстояние между двумя точками на сфере, получение координат)


In [30]:
from math import radians, cos, sin, asin, sqrt
AVG_EARTH_RADIUS = 6371

def haversine(point1, point2):

    # извлекаем долготу и широту
    lat1, lng1 = point1
    lat2, lng2 = point2

    # переводим все эти значения в радианы
    lat1, lng1, lat2, lng2 = map(radians, (lat1, lng1, lat2, lng2))

    # вычисляем расстояние по формуле
    lat = lat2 - lat1
    lng = lng2 - lng1
    d = sin(lat * 0.5) ** 2 + cos(lat1) * cos(lat2) * sin(lng * 0.5) ** 2
    h = 2 * AVG_EARTH_RADIUS * asin(sqrt(d))

    return h

In [31]:
def getCoords(flat_page):
    coords = flat_page.find('div', attrs={'class':'map_info_button_extend'}).contents[1]
    coords = re.split('&amp|center=|%2C', str(coords))
    coords_list = []
    for item in coords:
        if item[0].isdigit():
            coords_list.append(item)
    lat = float(coords_list[0])
    lon = float(coords_list[1])
    return lat, lon

In [32]:
def getDistance(coords):
    MSC_POINT_ZERO = (55.755831, 37.617673)
    return haversine(MSC_POINT_ZERO, coords)

Количество комнат

Тут я сменила значение mult на 6, так как решила все же впоследствии многокомнатным квартирам присваивать это число в это поле.


In [33]:
def getRoom(flat_page):
    rooms_n = flat_page.find('div', attrs={'class':'object_descr_title'})
    rooms_n = html_stripper(rooms_n)
    room_number = ''
    flag = 0
    for i in re.split('-|\n', rooms_n):
        if 'много' in i:
            flag = 1
            break
        elif 'комн' in i:
            break
        else:
            room_number += i
    
    if (flag):
        room_number = '6'
    room_number = "".join(room_number.split())
    return int(room_number)

Расстояние до метро


In [34]:
def getMetroDistance(flat_page):
    metro = flat_page.find('div', attrs={'class':'object_descr_metro'})
    metro = re.split('metro_name|мин', str(metro))
    if (len(metro) > 2):

        metro_dist = 0
        power = 0

        flag = 0
        for i in range(0, len(metro[1])):
            if metro[1][-i-1].isdigit():
                flag = 1
                metro_dist += int(metro[1][-i-1]) * 10 ** power
                power += 1
            elif (flag == 1):
                break
    else:
        metro_dist = np.nan

    return metro_dist

До метро пешком/на машине


In [35]:
def getMetroWalking(flat_page):
    metro = flat_page.find('div', attrs={'class':'object_descr_metro'})
    metro = re.split('metro_name|мин', str(metro))
    if (len(metro) > 2):

        if 'пешк' in metro[2]:
            walking = 1
        elif 'машин' in metro[2]:
            walking = 0
        else:
            walking = np.nan
            
    else:
        walking = np.nan

    return walking

Тип дома: материал, новостройка/вторичка


In [36]:
def getBrick(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    brick = np.nan
    
    building_block = re.split('Этаж|Тип продажи', table)[1]
    if 'Тип дом' in building_block:
        if (('кирпич' in building_block) | ('монолит' in building_block)):
            brick = 1
        elif (('панельн' in building_block) | ('деревян' in building_block) | ('сталин' in building_block) | 
              ('блочн' in building_block)):
            brick = 0

            
    return brick

In [37]:
def getNew(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    new = np.nan
    
    building_block = re.split('Этаж|Тип продажи', table)[1]
    if 'Тип дом' in building_block:
        if 'новостр' in building_block:
            new = 1
        elif 'втор' in building_block:
            new = 0

            
    return new

Этаж, этажность


In [38]:
def getFloor(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    floor_is = 0
    
    building_block = re.split('Этаж|Тип продажи', table)[1]
    floor_block = re.split('\xa0/\xa0|\n|\xa0', building_block)
    
    for i in range(1, len(floor_block[2]) + 1):
        if(floor_block[2][-i].isdigit()):
            floor_is += int(floor_block[2][-i]) * 10**(i - 1)
            
    return floor_is

In [39]:
def getNFloor(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    floors_count = np.nan
    
    building_block = re.split('Этаж|Тип продажи', table)[1]
    floor_block = re.split('\xa0/\xa0|\n|\xa0', building_block)
    
    if floor_block[3].isdigit():
        floors_count = int(floor_block[3])
            
    return floors_count

Общая площадь, жилая, площадь кухни (+ вспомогательный конвертор strToFloat() для чисел, для которых cian (в отличие от python) использует в качестве разделителя запятую, а не точку)

Немного подправила конвертор, который ошибался на данных, которых не бывает на Циане, но всё же


In [40]:
def myStrToFloat(string):
    delimiter = 0
    value = 0
    for i in range(0, len(string)):
        if string[i] == ',':
            delimiter = i
    for i in range(0, delimiter):
        value += int(string[delimiter - i - 1]) * 10 ** i

    for i in range(1, len(string) - delimiter):
        value += (int(string[delimiter + i]) * (10 ** (-i)))
    return value

In [41]:
def getTotsp(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    space_block = re.split('Общая площадь', table)[1]
   
    total = re.split('Площадь комнат', space_block)[0]
    total_space = re.split('\n|\xa0', total)[2]
    if total_space.isdigit():
        total_space = int(total_space)
    else:
        total_space = myStrToFloat(total_space)
            
    return total_space

Добавила обработку ситуации с прочерком


In [42]:
def getLivesp(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    space_block = re.split('Общая площадь', table)[1]
   
    living = re.split('Жилая площадь', space_block)[1]
    living_space = re.split('\n|\xa0', living)[2]
    if living_space.isdigit():
        living_space = int(living_space)
    elif (living_space == '–'):
        living_space = np.nan
    else:
        living_space = myStrToFloat(living_space)

    return living_space

In [43]:
def getKitsp(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    space_block = re.split('Общая площадь', table)[1]
    optional_block = re.split('Жилая площадь', space_block)[1]
    
    kitchen_space = np.nan
    
    if 'Площадь кухни' in optional_block:
        kitchen_block = re.split('Площадь кухни', optional_block)[1]
        if re.split('\n|\xa0', kitchen_block)[2] != '–':
            if re.split('\n|\xa0', kitchen_block)[2].isdigit():
                kitchen_space = int(re.split('\n|\xa0', kitchen_block)[2])
            else:
                kitchen_space = myStrToFloat(re.split('\n|\xa0', kitchen_block)[2])
            
    return kitchen_space

Отсутствие балкона/их количество, наличие телефона


In [44]:
def getBal(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    space_block = re.split('Общая площадь', table)[1]
    optional_block = re.split('Жилая площадь', space_block)[1]
    
    balcony = np.nan
    if 'Балкон' in optional_block:
        balcony_block = re.split('Балкон', optional_block)[1]
        if re.split('\n', balcony_block)[1] != 'нет':
            if re.split('\n', balcony_block)[1] != '–':
                balcony = int(re.split('\n', balcony_block)[1][0])
        else:
            balcony = 0
            
    return balcony

In [45]:
def getTel(flat_page):
    table = flat_page.find('table', attrs = {'class':'object_descr_props'})
    table = html_stripper(table)
    
    space_block = re.split('Общая площадь', table)[1]
    optional_block = re.split('Жилая площадь', space_block)[1]
    
    telephone = np.nan
    if 'Телефон' in optional_block:
        telephone_block = re.split('Телефон', optional_block)[1]
        if re.split('\n', telephone_block)[1] == 'да':
            telephone = 1
        elif re.split('\n', telephone_block)[1] == 'нет':
            telephone = 0
            
    return telephone

Теперь новые функции

Эти выведены в отдельные исключительно для удобства при чтении бОльших, содержащих эти части кода функций


In [106]:
def getFlatPage(link):
    
    flat_url = 'http://www.cian.ru/sale/flat/' + str(link) + '/'
    flat_page = requests.get(flat_url)
    flat_page = flat_page.content
    flat_page = BeautifulSoup(flat_page, 'lxml')
    
    return flat_page

In [47]:
def getFlatUrl(page):
    page_url =  district.format(page)

    search_page = requests.get(page_url)
    search_page = search_page.content
    search_page = BeautifulSoup(search_page, 'lxml')

    flat_url = search_page.findAll('div', attrs = {'ng-class':"{'serp-item_removed': offer.remove.state, 'serp-item_popup-opened': isPopupOpen}"})
    flat_url = re.split('http://www.cian.ru/sale/flat/|/" ng-class="', str(flat_url))
    
    return flat_url

getInfo() возвращает полную информацию по квартире, вызывая внутри себя вышеперечисленные функции для определения значений признаков


In [120]:
def getInfo(link):
    
    flat_page = getFlatPage(link)
    

    price = getPrice(flat_page)
    coords = getCoords(flat_page)
    distance = getDistance(coords)
    rooms = getRoom(flat_page)
    metrdist = getMetroDistance(flat_page)
    metro_walking = getMetroWalking(flat_page)
    brick = getBrick(flat_page)
    new = getNew(flat_page)
    floor = getFloor(flat_page)
    nfloors = getNFloor(flat_page)
    bal = getBal(flat_page)
    kitsp = getKitsp(flat_page)
    livesp = getLivesp(flat_page)
    tel = getTel(flat_page)
    totsp = getTotsp(flat_page)
    walk = getMetroWalking(flat_page)
    
    
    info = [bal, brick, distance, floor, kitsp, livesp, metrdist, new, nfloors, price, rooms, tel, totsp, walk]
    
    return info

Мне видится удобной схема из двух функций: парсер по округу и большой парсер. Большой парсер внутри себя вызывает парсер по округу для всех интересующих нас округов (их 9, я не рассматриваю Зеленоград, Новую Москву и т.д.)


In [121]:
def districtParser(links):

    apartments = []

    for link in links:

        apartment = getInfo(link)
        apartment.append(link)
        apartments.append(apartment)
        
    
    return apartments

In [122]:
districts


Out[122]:
{1: 'NW', 4: 'C', 5: 'N', 6: 'NE', 7: 'E', 8: 'SE', 9: 'S', 10: 'SW', 11: 'W'}

In [123]:
def cianParser(districts, links):
    tmp = dict([(0 ,[np.nan]), (1,[np.nan]), (2,[np.nan]), (3,[np.nan]), (4,[np.nan]), (5,[np.nan]), (6,[np.nan]), (7,[np.nan]), (8,[np.nan]), (9,[np.nan]), (10,[np.nan]), (11,[np.nan]), ('Distr', [np.nan])])
    data = pd.DataFrame(tmp)
    
    for i in districts.keys():
        
        district_name = districts.get(i)
        tmp_links = links[links['Distr'] == district_name]
        tmp_links = tmp_links['link']
        data_tmp = pd.DataFrame(districtParser(tmp_links))
        
        data_tmp['Distr'] = district_name
        data = data.append(data_tmp)
        print('district', districts.get(i), 'is done!')
        
    return data

Схема готова. Теперь поработаем со ссылками

Выше была вырезана часть, где я сохраняла собранные ссылки по округам в соответствующие csv. Теперь я подгружу оттуда данные.


In [124]:
full_links = pd.read_csv('/Users/tatanakuzenko/lbNW.csv')

In [125]:
full_links['Distr'] = 'NW'

In [126]:
full_links.head()


Out[126]:
0 Distr
0 NaN NW
1 152103519.0 NW
2 152104745.0 NW
3 152186332.0 NW
4 152103863.0 NW

Проделаю это для всех остальных округов, соединю все вертикально, тк дальше буду удалять дубликаты.


In [127]:
districts_cut = {4: 'C', 5:'N', 6:'NE', 7:'E', 8:'SE', 9:'S', 10:'SW', 11:'W'}

In [128]:
for i in districts_cut.values():
    links_append = pd.read_csv('/Users/tatanakuzenko/lb' + i + '.csv')
    
    links_append['Distr'] = i
    print(links_append.shape)
    full_links = full_links.append(links_append)


(8401, 2)
(13381, 2)
(14281, 2)
(13261, 2)
(9991, 2)
(13441, 2)
(10081, 2)
(10861, 2)

In [129]:
full_links.shape


Out[129]:
(100419, 2)

Nan был нулевым элементом для списка ссылок каждого округа. Удалим их (первая размерность должна стать на 9 меньше)


In [130]:
full_links = full_links.dropna()

In [131]:
full_links.shape


Out[131]:
(100410, 2)

Теперь удалим дубликаты


In [132]:
full_links = full_links.drop_duplicates()

In [133]:
full_links.shape


Out[133]:
(5241, 2)

Вот данных и поубавилось. Но нам хватит.

Приведем в порядок ссылки: установим верные индексы, переименуем колонку из 0 в link и переведем значения этой колонки в int.


In [136]:
full_links.index = [x for x in range(len(full_links.index))]
full_links.rename(columns={'0' : 'link'}, inplace = True)
full_links['link'] = full_links['link'].astype(np.int32)

In [139]:
full_links.head()


Out[139]:
link Distr
0 152103519 NW
1 152104745 NW
2 152186332 NW
3 152103863 NW
4 152103560 NW

Теперь можно запускать парсер


In [143]:
data = cianParser(districts, full_links)


district NW is done!
district C is done!
district N is done!
district NE is done!
district E is done!
district SE is done!
district S is done!
district SW is done!
district W is done!

In [144]:
data.head()


Out[144]:
0 1 2 3 4 5 6 7 8 9 10 11 Distr 12 13 14
0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
0 1.0 1.0 23.839112 2.0 NaN 0.0 20.0 1.0 15.0 3964118.0 2.0 NaN NW 47.0 1.0 152103519.0
1 1.0 1.0 23.839112 2.0 NaN 0.0 20.0 1.0 15.0 2801360.0 1.0 NaN NW 30.0 1.0 152104745.0
2 1.0 1.0 21.923458 5.0 12.0 47.0 15.0 0.0 8.0 13800000.0 3.0 NaN NW 82.0 0.0 152186332.0
3 1.0 1.0 23.861184 6.0 NaN 0.0 20.0 1.0 12.0 3302559.0 1.0 NaN NW 35.0 1.0 152103863.0

In [145]:
data.shape


Out[145]:
(5242, 16)

Данные получены, сохраним их и приступим к очистке и визуализации.


In [146]:
data.to_csv('cian_full_data.csv', index = False)