Metacritic — англоязычный сайт-агрегатор, собирающий отзывы о музыкальных альбомах, играх, фильмах, телевизионных шоу и DVD-дисках. (с википедии).

Использованные библиотеки: lxml, asyncio, aiohttp (lxml — библиотека разбора HTML страниц с помощью Python, asyncio и aiohttp будем использовать для асинхронности и быстрого извлечения данных). Также будем активно использовать XPath. Кто не знает, что это такое, отличный туториал.

Получение ссылок на страницы всех игр


Сперва придется немного поработать ручками. Идем на www.metacritic.com/browse/games/genre/metascore/action/all?view=detailed и собираем все
URL-ы из этого списка:


image

И сохраняем их в .json файл с именем genres.json. Мы будем применять эти ссылки, чтобы парсить все игры с сайта по жанрам.

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

Смотрим в html-страницы:

image

И видим, что искомый элемент а, который содержит максимальное число страниц — наследник элемента li с уникальным аттрибутом class = page last_page, также замечаем, что url-ы всех страниц, кроме первой, имеют вид <url 1-ой страницы> + <&page=page_number>, и что вторая страница в параметре запроса имеет номер 1.

Собираем XPath для получения номера максимальной страницы:

//li[@class='page last_page']/a/text()

Теперь нужно получить каждую ссылку на каждую игру из этого листа.

image

Заглядываем в разметку листа и изучаем html.

image

Во первых нам в качестве корневого элемента для поиска нужно получить сам список (ol). У него есть уникальный для html-кода страницы аттрибут class = list_products list_product_summaries. Затем видим, что li имеет дочерний элемент h3 с дочерним элементом a, в аттрибуте href которого и находится искомая ссылка на игру.

Собираем всё воедино:

//ol[@class='list_products list_product_summaries']//h3[@class='product_title']/a/@href

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

# get_games.py
import csv
import requests
import json
from multiprocessing import Pool
from time import sleep
from lxml import html

# Базовый домен.
root = 'http://www.metacritic.com/'
# При большом количестве запросов Metacritic выдает ошибку 429 с описанием 'Slow down'
# будем избегать её, останавливая поток на определенное количество времени.
SLOW_DOWN = False

def get_html(url):
    # Metacritic запрещает запросы без заголовка User-Agent.
    headers = {"User-Agent": "Mozilla/5.001 (windows; U; NT4.0; en-US; rv:1.0) Gecko/25250101"}
    global SLOW_DOWN
    try:
        # Если у нас в каком-либо потоке появилась ошибка 429, то приостанавливаем все потоки
        # на 15 секунд.
        if SLOW_DOWN:
            sleep(15)
            SLOW_DOWN = False
        # Получаем html контент страницы стандартным модулем requests
        html = requests.get(url, headers=headers).content.decode('utf-8')
        # Если ошибка в html контенте, то присваеваем флагу SLOW_DOWN true.
        if '429 Slow down' in html:
            SLOW_DOWN = True
            print(' - - - SLOW DOWN')
            raise TimeoutError
        return html
    except TimeoutError:
        return get_html(url)


def get_pages(genre):
    # Сложим все жанры в папку Games
    with open('Games/' + genre.split('/')[-2] + '.csv', 'w') as file:
        writer = csv.writer(file, delimiter=',',
                            quotechar='"', quoting=csv.QUOTE_MINIMAL)
        # Структура url страниц > 1
        genre_page_sceleton = genre + '&page=%s'
        def scrape():
            page_content = get_html(genre)
            # Получаем корневой lxml элемент из html страницы.
            document = html.fromstring(page_content)

            try:
                # Извлекаем номер последней страницы и кастим в int.
                lpn_text = document.xpath("//li[@class='page last_page']/a/text()"
                last_page_number = int(lpn_text)[0])
                pages = [genre_page_sceleton % str(i) for i in range(1, last_page_number)]
                # Не забываем про первую страницу.
                pages += [genre]
                # Для каждой страницы собираем и сохраняем ссылки на игры в файл жанра.
                for page in pages:
                    document = html.fromstring(get_html(page))
                    urls_xpath = "//ol[@class='list_products list_product_summaries']//h3[@class='product_title']/a/@href"
                    # К ссылке каждой игры прибавляем url корня сайта.
                    games = [root + url for url in document.xpath(urls_xpath)]
                    print('Page: ' + page + " - - - Games: " + str(len(games)))
                    for game in games:
                        writer.writerow([game])
            except:
                # Скорее всего опять 429 ошибка. Парсим страницу заново.
                scrape()
        scrape()

def main():
    # Загружаем ссылки на страницы жанров из нашего .json файла.
    dict = json.load(open('genres.json', 'r'))
    p = Pool(4)
    # Простой пул. Функцией map отдаем каждому потоку его порцию жанров для парсинга.
    p.map(get_pages, [dict[key] for key in dict.keys()])
    print('Over')

if __name__ == "__main__":
    main()
    

Сливаем все файлы со ссылками в один файл. Если вы под линуксом, то просто используем cat и перенаправляем STDOUT в новый файл. Под виндой напишем небольшой скрипт и запустим его в папке с файлами жанров.

from os import listdir
from os.path import isfile, join
onlyfiles = [f for f in listdir('.') if isfile(join(mypath, f))]
fout=open("all_games.csv","a")
for path in onlyfiles:
    f = open(path)
    f.next()
    for line in f:
         fout.write(line)
    f.close()
fout.close()

Теперь у нас есть один большой .csv файл со ссылками на все игры на Metacritic. Достаточно большой, 25 тысяч записей. Плюс есть дупликаты, так как одна игра может иметь несколько жанров.

Извлечение информации о всех играх


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

Будем извлекать:

  • Название игры
  • Платформы
  • Описание
  • Metascore
  • Жанр
  • Дата выпуска

Чтобы сократить пост, сразу приведу xpath-ы, которыми извлекал данную информацию.

Название игры:

//h1[@class='product_title']//span[@itemprop='name']//text()

Платформ у нас может быть несколько, поэтому понадобятся два запроса:

//span[@itemprop='device']//text()
//li[@class='summary_detail product_platforms']//a//text()

Описание у нас находится в Summary:

//span[@itemprop='description']//text()

Metascore:

//span[@itemprop='ratingValue']//text()

Жанр:

//span[@itemprop='description']//text()

Дата выпуска:

//span[@itemprop='datePublished']//text()

Вспоминаем, что у нас 25 тысяч страниц и хватаемся за голову. Что делать? Даже с несколькими потоками будет долговато. Выход есть — асинхронность и неблокирующие корутины. Вот отличное видео от PyCon. Async-await упрощают асинхронное программирование в python 3.5.2. Туториал на Хабре.

Пишем код для нашего парсера.

from time import sleep
import asyncio
from aiohttp import ClientSession
from lxml import html

# Считываем ссылки на все игры из нашего файла, убираем дупликаты.
games_urls = list(set([line for line in open('Games/all_games.csv', 'r')]))
# Сюда будем складывать результат.
result = []
# Сколько всего страниц наша программа запарсила на данный момент.
total_checked = 0

async def get_one(url, session):
    global total_checked
    async with session.get(url) as response:
        # Ожидаем ответа и блокируем таск.
        page_content = await response.read()
        # Получаем информацию об игре и сохраняем в лист.
        item = get_item(page_content, url)
        result.append(item)
        total_checked += 1
        print('Inserted: ' + url + '  - - - Total checked: ' + str(total_checked))

async def bound_fetch(sm, url, session):
    try:
        async with sm:
            await get_one(url, session)
    except Exception as e:
        print(e)
        # Блокируем все таски на 30 секунд в случае ошибки 429.
        sleep(30)

async def run(urls):
    tasks = []
    # Выбрал лок от балды. Можете поиграться.
    sm = asyncio.Semaphore(50)
    headers = {"User-Agent": "Mozilla/5.001 (windows; U; NT4.0; en-US; rv:1.0) Gecko/25250101"}
    # Опять же оставляем User-Agent, чтобы не получить ошибку от Metacritic
    async with ClientSession(
            headers=headers) as session:
        for url in urls:
            # Собираем таски и добавляем в лист для дальнейшего ожидания.
            task = asyncio.ensure_future(bound_fetch(sm, url, session))
            tasks.append(task)
        # Ожидаем завершения всех наших задач.
        await asyncio.gather(*tasks)


def get_item(page_content, url):
    # Получаем корневой lxml элемент из html страницы.
    document = html.fromstring(page_content)

    def get(xpath):
        item = document.xpath(xpath)
        if item:
            return item[-1]
        # Если вдруг какая-либо часть информации на странице не найдена, то возвращаем None
        return None
        
    name = get("//h1[@class='product_title']//span[@itemprop='name']//text()")
    if name:
	name = name.replace('\n', '').strip()
    genre = get("//span[@itemprop='genre']//text()")
    date = get("//span[@itemprop='datePublished']//text()")
    main_platform = get("//span[@itemprop='device']//text()")
    if main_platform:
        main_platform = main_platform.replace('\n', '').strip()
    else:
        main_platform = ''
    other_platforms = document.xpath("//li[@class='summary_detail product_platforms']//a//text()")
    other_platforms = '/'.join(other_platforms)
    platforms = main_platform + '/' + other_platforms
    score = get("//span[@itemprop='ratingValue']//text()")
    desc = get("//span[@itemprop='description']//text()")
    #Возвращаем словарь с информацией об игре.
    return {'url': url,
		'name': name,
		'genre': genre,
		'date': date,
		'platforms': platforms,
		'score': score,
		'desc': desc}


def main():
    #Запускаем наш парсер.
    loop = asyncio.get_event_loop()
    future = asyncio.ensure_future(run(games_urls))
    loop.run_until_complete(future)
    # Выводим результат. Можете сохранить его куда-нибудь в файл.
    print(result)
    print('Over')


if __name__ == "__main__":
    main()

Получаеся очень даже неплохо, на моем компьютере Intel i5 6600K, 16 GB RAM, lan 10 mb/s получалось где-то 10 игр/сек. Можно подправить код и адаптировать скрипт под музыку\фильмы.
Поделиться с друзьями
-->

Комментарии (23)


  1. sallyruthstruik
    19.01.2017 18:57
    +7

    Для таких вещей очень подходит фреймворк Scrapy


  1. roller
    19.01.2017 19:45
    +4

    Хорошая статья… для школьника например. Потому что:
    1) Сайт вас не каптчил
    2) Сайт вас не банил
    3) Инфа доступна без регистрации
    4) Сайт не написан на реакте (а тем более с рендерингом на клиентской стороне)
    (это наверняка не полный список, но конкретно то с чем я сталкивался)


    1. sergio42
      20.01.2017 09:27

      У меня несколько недель 24/7 парсятся свежие Яндекс.Новости. Никто не банил, регистрации не надо, никакого реакта, каптча обходится таймаутом побольше. Что я делаю не так?


      1. jetexe
        20.01.2017 12:15

        Ну вы и статью по этому поводу не пишите


      1. roller
        20.01.2017 17:20

        С яндексом все проще (было?)(по сравнению с гуглом), ему главное правильную куку подсунуть.
        А вы попробуйте снимать 50-100К в сутки с обычного поиска и вордстата — вот тогда и хвастайтесь ;)


    1. Rastishka
      20.01.2017 13:44

      В дополнение из личного опыта парсинга:
      5) сайт не запрещал скачивать более 1 страницы в 3 секунды
      6) сайт не менял дизайн (и верстку соответственно) во время длительного парсинга
      7) пагинатор сайта не врал относительно количества страниц (sic!)
      8) сайт рандомно не возвращал 50* ошибки
      9) сайт не возвращал внезапно вместо ожидаемого JSON страницу html с ошибкой


      1. maxru
        20.01.2017 15:47
        +1

        10) Не требовал шевеления мышкой :)


    1. mmm201
      21.01.2017 15:37

      Для парсинга реакта (да и вообще любых не статических страниц) я недавно использовал связку selenium + phantomjs. Проблем не было. Разве что скорость парсинга оставляет желать лучшего…


      1. roller
        21.01.2017 18:42

        Phantomjs немного 1) тормозной 2) палевный. Так что использую Chrome/Firefox с xvfb через Watir::Webdriver
        Человек который научиться разбирать налету исходники реакта до этапа рендеринга для любых сайтов (и выдирать оттуда полезную инфу) — озолотиться


  1. leoismyname
    19.01.2017 19:53
    +2

    Спарсил и молодец. Нет вводного слова, для чего это вообще затевалось, нет итогового проекта на github, куда могли бы отправить свои правки читатели, нет заключительного слова (о том, как после этого данный проект купил Facebook). Интриги нет и накала страстей, а так, подход типичный для задачи – получаем страницу и ходим по DOM.


  1. nikitasius
    19.01.2017 22:16
    -2

    Я не гений питона — оно однопоточное? Если сайт и не банит и не блочит и рейты там аццкие, то надо делать многопоточку.


    1. JaktensTid
      21.01.2017 15:31

      Однопоточное, но написано через корутины. Т.е ожидание ответа от сервера не блокирует поток


  1. torrie
    19.01.2017 23:56
    +2

    Похоже на сниппет на stackoverflow, увы


  1. ruzhovt
    20.01.2017 09:41

    10 игр (страниц) в секунду? :) детский сад ;)


  1. sergio42
    20.01.2017 09:58
    -1

    Большое спасибо, вроде программирую на Питоне чуть не каждый день, а вот тема с async/await прошла мимо меня. Для увлекательных путешествий по DOMу кроме XPath могу посоветовать Beautiful Soup — чисто Питоновская библиотека для парсинга HTML (родные Питоновские библиотеки лично мне больше по душе, но дело вкуса). Также рекомендую, дабы не особо наглеть, добавлять задержку хотя бы 0.1 сек между запросами и все-таки не распараллеливать — на Метакритике сработало, а вот на более серьезных ресурсах обязательно нарветесь на капчу после 10 запросов.


  1. Dr_Zoidberg
    20.01.2017 13:34

    Если кто-то напишет как в 2017 году ботом делать высоконагруженный (1M запросов в месяц) поиск по гуглу или яндексу или бингу за адекватные деньги, то ему 100500 в карму.


    1. RomanL
      20.01.2017 13:46

      Адекватные деньги это сколько? Есть AWS с его спот-микро-инстансами, есть пачки прокси. Я не спец именно по ботам для поисковых систем, но мне кажется подход то один.


    1. maxru
      20.01.2017 15:48

      Хотите свою ПС открыть? )


    1. roller
      20.01.2017 17:15

      Это не высоногруженное ни разу. Мы снимали 100K каждые сутки.
      Серебрянной пули нет. Надо не сильно насиловать IPшники, иметь много IPшников, решать гугловые каптчи. Плюс ходить на гугл залогиненным (надо покупать гугловые учетки)
      Сейчас гугловые каптчи стали динамическими с картинками, как тут использовать такие сервисы как Anticapcha — хз.
      Ну и да, дешево это не получится.


    1. PEgorov
      20.01.2017 17:47

      скупать значительные лимиты яндекс xml?


  1. maxru
    20.01.2017 15:46

    Зачем это здесь?


    1. Shreedeer
      21.01.2017 15:33
      +1

      Зачем это здесь?


  1. Snooper
    21.01.2017 15:33

    В базе gamefaqs.com игр куда больше.