Web Scraping с помощью python

    Введение


    Недавно заглянув на КиноПоиск, я обнаружила, что за долгие годы успела оставить более 1000 оценок и подумала, что было бы интересно поисследовать эти данные подробнее: менялись ли мои вкусы в кино с течением времени? есть ли годовая/недельная сезонность в активности? коррелируют ли мои оценки с рейтингом КиноПоиска, IMDb или кинокритиков?
    Но прежде чем анализировать и строить красивые графики, нужно получить данные. К сожалению, многие сервисы (и КиноПоиск не исключение) не имеют публичного API, так что, приходится засучить рукава и парсить html-страницы. Именно о том, как скачать и распарсить web-cайт, я и хочу рассказать в этой статье.
    В первую очередь статья предназначена для тех, кто всегда хотел разобраться с Web Scrapping, но не доходили руки или не знал с чего начать.

    Off-topic: к слову, Новый Кинопоиск под капотом использует запросы, которые возвращают данные об оценках в виде JSON, так что, задача могла быть решена и другим путем.

    Задача


    Задача будет состоять в том, чтобы выгрузить данные о просмотренных фильмах на КиноПоиске: название фильма (русское, английское), дату и время просмотра, оценку пользователя.
    На самом деле, можно разбить работу на 2 этапа:
    • Этап 1: выгрузить и сохранить html-страницы
    • Этап 2: распарсить html в удобный для дальнейшего анализа формат (csv, json, pandas dataframe etc.)

    Инструменты


    Для отправки http-запросов есть немало python-библиотек, наиболее известные urllib/urllib2 и Requests. На мой вкус Requests удобнее и лаконичнее, так что, буду использовать ее.
    Также необходимо выбрать библиотеку для парсинга html, небольшой research дает следующие варианты:
    • re
      Регулярные выражения, конечно, нам пригодятся, но использовать только их, на мой взгляд, слишком хардкорный путь, и они немного не для этого. Были придуманы более удобные инструменты для разбора html, так что перейдем к ним.
    • BeatifulSoup, lxml
      Это две наиболее популярные библиотеки для парсинга html и выбор одной из них, скорее, обусловлен личными предпочтениями. Более того, эти библиотеки тесно переплелись: BeautifulSoup стал использовать lxml в качестве внутреннего парсера для ускорения, а в lxml был добавлен модуль soupparser. Подробнее про плюсы и минусы этих библиотек можно почитать в обсуждении. Для сравнения подходов я буду парсить данные с помощью BeautifulSoup и используя XPath селекторы в модуле lxml.html.
    • scrapy
      Это уже не просто библиотека, а целый open-source framework для получения данных с веб-страниц. В нем есть множество полезных функций: асинхронные запросы, возможность использовать XPath и CSS селекторы для обработки данных, удобная работа с кодировками и многое другое (подробнее можно почитать тут). Если бы моя задача была не разовой выгрузкой, а production процессом, то я бы выбрала его. В текущей постановке это overkill.

    Загрузка данных


    Первая попытка


    Приступим к выгрузке данных. Для начала, попробуем просто получить страницу по url и сохранить в локальный файл.
    import requests
    user_id = 12345
    url = 'http://www.kinopoisk.ru/user/%d/votes/list/ord/date/page/2/#list' % (user_id) # url для второй страницы
    r = requests.get(url)
    with open('test.html', 'w') as output_file:
      output_file.write(r.text.encode('cp1251'))

    Открываем полученный файл и видим, что все не так просто: сайт распознал в нас робота и не спешит показывать данные.
    image

    Разберемся, как работает браузер


    Однако, у браузера отлично получается получать информацию с сайта. Посмотрим, как именно он отправляет запрос. Для этого воспользуемся панелью "Сеть" в "Инструментах разработчика" в браузере (я использую для этого Firebug), обычно нужный нам запрос — самый продолжительный.

    image

    Как мы видим, браузер также передает в headers UserAgent, cookie и еще ряд параметров. Для начала попробуем просто передать в header корректный UserAgent.
    headers = {
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:45.0) Gecko/20100101 Firefox/45.0'
          }
    r = requests.get(url, headers = headers)

    На этот раз все получилось, теперь нам отдаются нужные данные. Стоит отметить, что иногда сайт также проверяет корректность cookie, в таком случае помогут sessions в библиотеке Requests.

    Скачаем все оценки


    Теперь мы умеем сохранять одну страницу с оценками. Но обычно у пользователя достаточно много оценок и нужно проитерироваться по всем страницам. Интересующий нас номер страницы легко передать непосредственно в url. Остается только вопрос: "Как понять сколько всего страниц с оценками?" Я решила эту проблему следующим образом: если указать слишком большой номер страницы, то нам вернется вот такая страница без таблицы с фильмами. Таким образом мы можем итерироваться по страницам до тех, пор пока находится блок с оценками фильмов (<div class = "profileFilmsList">).

    image
    Полный код для загрузки данных
    import requests
    # establishing session
    s = requests.Session() 
    s.headers.update({
            'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:45.0) Gecko/20100101 Firefox/45.0'
        })
    
    def load_user_data(user_id, page, session):
        url = 'http://www.kinopoisk.ru/user/%d/votes/list/ord/date/page/%d/#list' % (user_id, page)
        request = session.get(url)
        return request.text
    
    def contain_movies_data(text):
        soup = BeautifulSoup(text)
        film_list = soup.find('div', {'class': 'profileFilmsList'})
        return film_list is not None
    
    # loading files
    page = 1
    while True:
        data = load_user_data(user_id, page, s)
        if contain_movies_data(data):
            with open('./page_%d.html' % (page), 'w') as output_file:
                output_file.write(data.encode('cp1251'))
                page += 1
        else:
                break


    Парсинг


    Немного про XPath


    XPath — это язык запросов к xml и xhtml документов. Мы будем использовать XPath селекторы при работе с библиотекой lxml (документация). Рассмотрим небольшой пример работы с XPath
    from lxml import html
    test = '''
        <html>
            <body>
                <div class="first_level">
                    <h2 align='center'>one</h2>
                    <h2 align='left'>two</h2>
                </div>
                <h2>another tag</h2>
            </body>
        </html>
    '''
    tree = html.fromstring(test)
    tree.xpath('//h2') # все h2 теги
    tree.xpath('//h2[@align]') # h2 теги с атрибутом align
    tree.xpath('//h2[@align="center"]') # h2 теги с атрибутом align равным "center"
    
    div_node = tree.xpath('//div')[0] # div тег
    div_node.xpath('.//h2') # все h2 теги, которые являются дочерними div ноде
    

    Подробнее про синтаксис XPath также можно почитать на W3Schools.

    Вернемся к нашей задаче


    Теперь перейдем непосредственно к получению данных из html. Проще всего понять как устроена html-страница используя функцию "Инспектировать элемент" в браузере. В данном случае все довольно просто: вся таблица с оценками заключена в теге <div class = "profileFilmsList">. Выделим эту ноду:
    from bs4 import BeautifulSoup
    from lxml import html
    
    # Beautiful Soup
    soup = BeautifulSoup(text)
    film_list = soup.find('div', {'class': 'profileFilmsList'})
    
    # lxml 
    tree = html.fromstring(text)
    film_list_lxml = tree.xpath('//div[@class = "profileFilmsList"]')[0]

    Каждый фильм представлен как <div class = "item"> или <div class = "item even">. Рассмотрим, как вытащить русское название фильма и ссылку на страницу фильма (также узнаем, как получить текст и значение атрибута).
    image
    # Beatiful Soup
    movie_link = item.find('div', {'class': 'nameRus'}).find('a').get('href')
    movie_desc = item.find('div', {'class': 'nameRus'}).find('a').text
    
    # lxml
    movie_link = item_lxml.xpath('.//div[@class = "nameRus"]/a/@href')[0]
    movie_desc = item_lxml.xpath('.//div[@class = "nameRus"]/a/text()')[0]

    Еще небольшой хинт для debug'a: для того, чтобы посмотреть, что внутри выбранной ноды в BeautifulSoup можно просто распечатать ее, а в lxml воспользоваться функцией tostring() модуля etree.
    # BeatifulSoup
    print item
    
    #lxml
    from lxml import etree
    print etree.tostring(item_lxml)

    Полный код для парсинга html-файлов под катом
    def read_file(filename):
        with open(filename) as input_file:
            text = input_file.read()
        return text
    
    def parse_user_datafile_bs(filename):
        results = []
        text = read_file(filename)
    
        soup = BeautifulSoup(text)
        film_list = film_list = soup.find('div', {'class': 'profileFilmsList'})
        items = film_list.find_all('div', {'class': ['item', 'item even']})
        for item in items:
            # getting movie_id
            movie_link = item.find('div', {'class': 'nameRus'}).find('a').get('href')
            movie_desc = item.find('div', {'class': 'nameRus'}).find('a').text
            movie_id = re.findall('\d+', movie_link)[0]
    
            # getting english name
            name_eng = item.find('div', {'class': 'nameEng'}).text
    
            #getting watch time
            watch_datetime = item.find('div', {'class': 'date'}).text
            date_watched, time_watched = re.match('(\d{2}\.\d{2}\.\d{4}), (\d{2}:\d{2})', watch_datetime).groups()
    
            # getting user rating
            user_rating = item.find('div', {'class': 'vote'}).text
            if user_rating:
                user_rating = int(user_rating)
    
            results.append({
                    'movie_id': movie_id,
                    'name_eng': name_eng,
                    'date_watched': date_watched,
                    'time_watched': time_watched,
                    'user_rating': user_rating,
                    'movie_desc': movie_desc
                })
        return results
    
    def parse_user_datafile_lxml(filename):
        results = []
        text = read_file(filename)
    
        tree = html.fromstring(text)
    
        film_list_lxml = tree.xpath('//div[@class = "profileFilmsList"]')[0]
        items_lxml = film_list_lxml.xpath('//div[@class = "item even" or @class = "item"]')  
        for item_lxml in items_lxml:
            # getting movie id
            movie_link = item_lxml.xpath('.//div[@class = "nameRus"]/a/@href')[0]
            movie_desc = item_lxml.xpath('.//div[@class = "nameRus"]/a/text()')[0]
            movie_id = re.findall('\d+', movie_link)[0]
    
            # getting english name
            name_eng = item_lxml.xpath('.//div[@class = "nameEng"]/text()')[0]
    
            # getting watch time
            watch_datetime = item_lxml.xpath('.//div[@class = "date"]/text()')[0]
            date_watched, time_watched = re.match('(\d{2}\.\d{2}\.\d{4}), (\d{2}:\d{2})', watch_datetime).groups()
    
            # getting user rating
            user_rating = item_lxml.xpath('.//div[@class = "vote"]/text()')
            if user_rating:
                user_rating = int(user_rating[0])
    
            results.append({
                    'movie_id': movie_id,
                    'name_eng': name_eng,
                    'date_watched': date_watched,
                    'time_watched': time_watched,
                    'user_rating': user_rating,
                    'movie_desc': movie_desc
                })
        return results


    Резюме


    В результате, мы научились парсить web-сайты, познакомились с библиотеками Requests, BeautifulSoup и lxml, а также получили пригодные для дальнейшего анализа данные о просмотренных фильмах на КиноПоиске.
    image
    Полный код проекта можно найти на github'e.

    UPD


    Как отметили в комментариях, в контексте Web Scrapping'a могут оказаться полезны следующие темы:
    • Аутентификация: зачастую для того, чтобы получить данные с сайта нужно пройти аутентификацию, в простейшем случае это просто HTTP Basic Auth: логин и пароль. Тут нам снова поможет библиотека Requests. Кроме того, широко распространена oauth2: как использовать oauth2 в python можно почитать на stackoverflow. Также в комментариях есть пример от Terras того, как пройти аутентификацию в web-форме.
    • Контролы: На сайте также могут быть дополнительные web-формы (выпадающие списки, check box'ы итд). Алгоритм работы с ними примерно тот же: смотрим, что посылает браузер и отправляем эти же параметры как data в POST-запрос (Requests, stackoverflow). Также могу порекомендовать посмотреть 2й урок курса "Data Wrangling" на Udacity, где подробно рассмотрен пример scrapping сайта US Department of Transportation и посылка данных web-форм.
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 48
    • +3
      Вопрос.
      Допустим у нас ряд ссылок динамически формируются с помощью JS, т.е. мы их не можем получить во время парсинга. А они нам нужны для перехода парсера по страничкам. Что делать?
      • +3
        Рендерить с помощью selenium, phantomjs и т.п. или разбираться как js их формирует (допустим выяснили что ссылки получают ajax запросом, делаем подобный запрос и парсим его). У каждого метода свои плюсы и минусы, какой использовать зависит уже от конкретного задания.
        • –4
          с помощью selenium

          Не вариант. Мелькание браузера и непонятно что происходящего с ним может очень здорово испугать пользователя. Я как-то автоматизировал одному человеку с помощью Selenium и ChromeDriver-а. Так меня потом человек раз 5 спрашивал, а его браузер не испорится ли случайно? ;)))
          • +4
            а причем тут пользователи?
            • +1
              Как уже было написано ниже, в большинстве случае если смысл посмотреть какие запросы делает браузер, и понять, каким образом JS заполняет сайт. Второй вариант — Selenium. Его совсем необязательно использовать в оконном режиме, к примеру: headless firefox
              • 0
                к примеру: headless firefox

                Спасибо. Посмотрю!
              • 0
                Отлично работает связка из Selenium + PhantomJS. Ничего не мелькает и при этом всё делает.
                P.S. Долго читал, медленно отвечал.
            • 0
              Использовать Phantomjs либо Selenium webdriver
              • 0
                использовать Selenium. позволяет подключать драйвер как реального (Chrome, Firefox, IE) так и виртуального (Phantom JS) браузера и через этот «браузер» получать динамически формируемые данные.
                • 0
                  Эмулятор браузера вам нужен — Selenium, например. Но скорость парсинга, конечно, упадет — сами понимаете.
                  • –1
                    Да, с R&C аккаунтом чувствуешь себя идиотом: после одобрения комментария оказывается, что точно такой же ответ дали еще трое человек до тебя и столько же — после, а опубликовалось все это оптом.
                    • +2
                      Так напишите статейку, избавьтесь от проблем.)
                  • 0
                    И использовать selenium вместе с phantomjs. Пример
                    • 0
                      Можно использовать Selenium или phantom.js для исполнения скриптов.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        • 0
                          Для Scrapy можно попробовать scrapyjs. Чем и займусь в ближайшее время…
                          • НЛО прилетело и опубликовало эту надпись здесь
                          • 0
                            Из опыта могу сказать что в каждом случае подход индивидуальный. Зависит от сайта — иногда ссылка есть, но не прямо в href/src. Иногда можно понять правило ее формирования, иногда можно запросить API сайта и получить ссылку там. Иногда комбинируется несколько вариантов. Т.е. производится своеобразный реверс-инжиниринг сайта.
                            В особо тяжелых случаях придется использовать (как писали выше) реальный браузер или же эмулятор, но такие на моей практике встречались нечасто.
                          • +1
                            Не осветили двух важных вопросов:
                            1. Авторизация на сайте. Популярные по логину и паролю, и oauth2
                            2. Не осветили вопроса вбивания данных в контролы на странице, к примеру на странице может быть Combobox для выбора города. Или Edit для ввода возраста. Тажке часто встречается снять\выделить галочку и т.д. и т.п.
                            • +1
                              Отвечу на второй вопрос. Такие контролы либо перезагружают страницу и тогда мы можем получить адрес, либо грузят дополнительные данные по Ajax, тогда нам совсем просто.
                              • +2
                                USERNAME = input('Введите вашу почту: ')
                                PASSWORD = input('Введите ваш пароль: ')
                                
                                LOGIN_URL = "***" # Страница Логина
                                URL = "***" # Страница самого контента для парсинга
                                
                                session_requests = requests.session()
                                
                                def parse_one():
                                
                                            # Create payload
                                    payload = {
                                        "email": USERNAME,
                                        "password": PASSWORD
                                    }
                                
                                    # Perform login
                                    result = session_requests.post(LOGIN_URL, data = payload, headers = dict(referer = LOGIN_URL))
                                
                                    # Scrape journal_url
                                    result = session_requests.get(URL, headers = dict(referer = URL))
                                    soup = BeautifulSoup(result.content)

                                вот так можно пройти логин, сохранить данные в сессию, перейти к странице контента, используя данные сессии.
                                • +1
                                  Я не про то что я не знаю. Я бы поленился подобный вопрос задавать, а просто и тупо вбил в гугл "python authentication to website", что привело бы меня к страничке на SO.
                                  Прочитайте внимательно формулировку предложений! Она звучит не "А не подскажите как сделать авторизацию?", нет, она звучит по-другому: "не раскрыты важные вопросы.
                                  По опыту могу сказать, что очень много людей ищущих ответы на вопросы в гугле не читаю больше чем 2-3 комментариев к статье, если вообще читают. Поэтому подобные вопросы должны освещаться непосредствено в самой статье!
                                  Мне показалось, что автор хочет передать знания, значит нужно осветить как можно больше важных вопросов, которые очень часто встают перед новичком. Если же автор ленится, то это уже не желание научить, а попытка сказать "смотрите какой я крутой".
                                • +1
                                  Спасибо, действительно эти темы могут пригодиться при решении задач Web Scrapping'a — добавила в статью.
                                • +5
                                  Для парсинга веб-страниц есть отличный фреймворк Grab. На Хабре были статьи о нём от автора.
                                  • +1
                                    Если скрэппинг нужен не на регулярной основе (например когда сервер постоянно откачивает чужой контент), а для разовой или периодических задач, когда можно запустить скрипт вручную, то мне гораздо быстрее и проще написать JS скрипт с использованием jQuery и запустить его прямо из командной строки инспектора. Тогда уже будет и правильный юзер агент и решена проблема с авторизацией и вообще проблем будет меньше.
                                    • 0
                                      Lxml получается быстрее? А если использовать beautifulsiup с бекэндом lxml разница остаётся ?
                                      • +2
                                        Стоит отметить, что BeautifulSoup выбирает оптимальный парсер из установленных:
                                        If you don’t specify anything, you’ll get the best HTML parser that’s installed. Beautiful Soup ranks lxml’s parser as being the best, then html5lib’s, then Python’s built-in parser
                                        (источник)

                                        Я измерила время работы на своих данных и в среднем получила такие цифры
                                        bs_html_parser: 0.43 секунды
                                        bs_lxml_parser: 0.43 секунды (значимой разницы между lxml и python's default html-parser в BS на своих данных я не вижу)
                                        lxml: 0.05 секунд (lxml явно выигрывает)
                                        • 0
                                          Я бы ещё потребление памяти померил.
                                      • +2
                                        В целом тема интересная, но конкретно вашу задачу можно решить чуть проще.
                                        По ссылке httр://www.kinopoisk.ru/user/<user_id>/votes/list/export/xls/vs/vote/ оно вернёт вам xls файл со всей необходимой информацией (и даже больше чем нужно).
                                        Правда, в новом кинопоиске такую замечательную возможность выпилили. Там нужно либо извращаться, либо не пользоваться.
                                        У меня даже где-то shell скрипт закронен, чтобы скачивать его раз в неделю (после осенних событий я им больше так не доверяю, как это было раньше).
                                        • +1
                                          Вот это действительно полезно. Как вы об этой ссылке узнали? Что-то еще подобное-полезное есть?
                                          (ps. кажется, у вас в ссылке p в http русская, вот корректная ссылка — http://www.kinopoisk.ru/user/<user_id>/votes/list/export/xls/vs/vote/)
                                          • +1
                                            Эта ссылка доступна всем желающим авторизованным пользователям на странице собственного профиля :)
                                            http://www.kinopoisk.ru/user/<user_id>/votes/ > прямо под ником там есть ссылка "экспорт в MS Excel".
                                            • +4
                                              Еще есть в открытом доступе ссылка, для получения рейтинга фильма или сериала:
                                              http://rating.kinopoisk.ru/{{kinopoisk_id}}.xml
                                              Вместо {{kinopoisk_id}} просто подставьте нужный ID. Например, для Зверополиса — http://www.kinopoisk.ru/film/775276/ — вам нужно подставить в ссылку id 775276
                                              Еще есть вот такой сервис с API для Кинопоиска — http://kinopoisk.cf/, узнал про него в свое время с Тостера
                                          • –1
                                            with open('test.html', 'w') as output_file:
                                              output_file.write(r.text.encode('cp1251'))

                                            А почему вы явно указываете кодировку?
                                            • +1
                                              Requests возвращает ответ в виде Unicode
                                              isinstance(r.text, unicode) # True

                                              При попытке записать unicode строку в файл по умолчанию будет использована кодировка ASCII и ничего не получится (ASCII не может закодировать символы >128):
                                              UnicodeEncodeError: 'ascii' codec can't encode characters in position 23-31: ordinal not in range(128).
                                              Вот поэтому я явным образом указываю кодировку cp1251.

                                              Если интересно, подробнее про кодировки в Python есть хорошая статья на Хабре.
                                              • 0
                                                Я про хардкод. Ведь можно же спросить про кодировку у пришедшего ответа. Если использовать urllib, то это как-то так:
                                                >>> from urllib.request import urlopen
                                                >>> URL = 'http://habrahabr.ru'
                                                >>> page = urlopen(URL)
                                                >>> page.info().get_content_charset()
                                                'utf-8'
                                                >>> charset = page.info().get_content_charset()
                                                >>> document = page.read().decode(charset)
                                                • 0
                                                  Снят Вопрос.
                                                  Не внимательно читаю код, в упор не видел вызова "save".
                                                  Вы же пишите, тогда все логично ;)))
                                            • НЛО прилетело и опубликовало эту надпись здесь
                                              • +1
                                                Да, верно: в полноценной production версии пришлось бы обвешать код дополнительными проверками на наличие тегов/атрибутов и обрабатывать exceptional cases
                                              • –1
                                                Если честно не понятно о чем статья. О том, что такое xpath и как парсить html есть целая куча статей. О том же scrapy и grab есть статьи на хабре.

                                                Вот если бы в статье рассматривались какие-то хитрые обходы блокировок сайтов от роботов, ну или хотя бы была раскрыта тема подмены прокси, куков, referrer. А тут извините очень банально все.
                                                • +3
                                                  Если ограничивать авторов в темах требованием писать всё более и более хардкорные вещи, пишущих авторов будет становится всё меньше и меньше.
                                                  Данная статья имеет свою целевую аудиторию — тех людей, кто не является профи в разборе страниц сайтов и хочет с чего-то начать. То, что вы не относитесь к этой категории, делает статью "банальной" лишь для вас.
                                                • +1
                                                  Возможно, стоило делать запросы к Kinopoisk в несколько потоков? Однопоточная версия скрипта не самая быстрая.
                                                  • 0
                                                    Да, Вы правы, это могло бы ускорить время выгрузки данных, но тут нужно пробовать: сайт может забанить за слишком частые запросы с одного IP.
                                                    • 0
                                                      Сайту ничего не мешает вас забанить и при запросах в один поток. Всё упирается в разумную паузу между отправкой запросов или пачками запросов (скажем, отправляем N запросов, засыпаем на M секунд, снова отправляем и т.д.).
                                                      • +1
                                                        Я, кстати, когда парсил кинопоиск, тоже был забанен за частые запросы =) Но голь на выдумку хитра. Чтобы это обойти, банально нужно авторизоваться на нем, плюс сохранить куки. В итоге в один поток парсил лимитировано по 150 сериалов за запрос, включая всю информацию по сезонам и эпизодам. Ничего, живем :)
                                                        Вот с afisha.mail.ru такое не прокатило(( Пришлось купить 3 персональных анонимных проксика, и уже с ними, рандомно выбирая при каждом запросе один из них, маилру тоже поддался и пропали баны за многократное обращение =)
                                                    • 0
                                                      Для imhonet нового никто не писал выгрузку своих оценок фильмов\книг?
                                                      • +1
                                                        Все отлично, хорошая статья для начинающих.
                                                        Только название можно было изменить: Web scraping
                                                        en.wikipedia.org/wiki/Web_scraping

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