6 марта 2015 в 17:40

Веб-парсинг на Ruby из песочницы tutorial

image
Это перевод статьи «Web Scraping with Ruby», которую я нашел полезной при изучении языка программирования Ruby. Парсинг меня интересует в личных целях. Мне кажется, это не только полезный навык, но и хороший способ изучить язык.

Парсинг веба на Ruby легче, чем вы можете думать. Давайте начнем с простого примера: я хочу получить красиво отформатированный JSON массив объектов, представляющий список фильмов с сайта местного независимого кинотеатра.

В начале нам нужен способ скачать html страницу, которая содержит все объявления о фильмах. В Ruby есть встроенный http клиент, Net::HTTP, а также надстройка над ним — open-uri.

Open-uri
Open-uri хорош для базовых вещей, вроде тех, что мы делаем в уроке, но у него есть некоторые проблемы, поэтому, возможно, вы захотите найти другой http клиент для продакшн среды.


Итак, первая вещь, которую надо сделать — это скачать html с удаленного сервера.

require 'open-uri'

url = 'http://www.cubecinema.com/programme'
html = open(url)

Отлично, теперь у нас есть страница, которую мы хотим парсить, теперь нам нужно вытащить некоторую информацию из нее. Лучший инструмент для этого — Nokogiri. Мы создаем новый экземпляр Nokogiri для нашего html, который мы только что скачали.

require 'nokogiri'

doc = Nokogiri::HTML(html)

Nokogiri крут, потому что позволяет обращаться к html используя CSS селекторы, что, на мой взгляд, гораздо удобнее чем использовать xpath.

Ок, теперь у нас есть документ, из которого мы можем вытащить список кинофильмов. Каждый элемент списка имеет такую html структуру, как показано ниже.

<div class="showing" id="event_7557">
  <a href="/programme/event/live-stand-up-monty-python-and-the-holy-grail,7557/">
    <img src="" alt="Picture for event Live stand up + Monty Python and the Holy Grail">
  </a>
  <span class="tags"><a href="/programme/view/comedy/" class="tag_comedy">comedy</a> <a href="/programme/view/dvd/" class="tag_dvd">dvd</a> <a href="/programme/view/film/" class="tag_film">film</a> </span>
  <h1>
    <a href="/programme/event/live-stand-up-monty-python-and-the-holy-grail,7557/">
      <span class="pre_title">Comedy Combo presents</span>
      Live stand up + Monty Python and the Holy Grail
      <span class="post_title">Rare screening from 35mm!</span>
    </a>
  </h1>
  <div class="event_details">
    <p class="start_and_pricing">
      Sat 20 December | 19:30
      <br>
    </p>
    <p class="copy">Brave (and not so brave) Knights of the Round Table! Gain shelter from the vicious chicken of Bristol as we gather to bear witness to this 100% factually accurate retelling ... [<a class="more" href="/programme/event/live-stand-up-monty-python-and-the-holy-grail,7557/">more...</a>]</p>
  </div>
</div>

Обработка html


Каждый фильм имеет css класс .showing, так что мы можем выбрать все шоу и обработать их по очереди.

showings = []
doc.css('.showing').each do |showing|
  showing_id = showing['id'].split('_').last.to_i
  tags = showing.css('.tags a').map { |tag| tag.text.strip }
  title_el = showing.at_css('h1 a')
  title_el.children.each { |c| c.remove if c.name == 'span' }
  title = title_el.text.strip
  dates = showing.at_css('.start_and_pricing').inner_html.strip
  dates = dates.split('<br>').map(&:strip).map { |d| DateTime.parse(d) }
  description = showing.at_css('.copy').text.gsub('[more...]', '').strip
  showings.push(
    id: showing_id,
    title: title,
    tags: tags,
    dates: dates,
    description: description
  )
end

Давайте разберем по частям код, представленный выше.

showing_id = showing['id'].split('_').last.to_i

В начале мы берем уникальный идентификатор id, который любезно выставлен как атрибут html идентификатора в разметке. Используя квадратные скобки, мы можем получить доступ к атрибутам элементов. Таким образом, в случае html, представленного выше, showing['id'] должен быть «event_7557». Нам интересен только числовой идентификатор, так что мы разделяем результат с помощью подчеркивания .split('_') и затем берем последний элемент из получившегося массива и конвертируем в целочисленный формат .last.to_i.

tags = showing.css('.tags a').map { |tag| tag.text.strip }

Здесь мы находим все теги для фильма, используя .css метод, который возвращает массив совпадающих элементов. Затем мы мапим (применяем метод map) элементы, берем из них текст и убираем в нем пробелы. Для нашего html результат будет ["comedy", "dvd", "film"].

title_el = showing.at_css('h1 a')
title_el.children.each { |c| c.remove if c.name == 'span' }
title = title_el.text.strip

Код для получения заголовка немного сложнее, потому что этот элемент в html содержит некоторые добавочные span элементы с префиксами и суффиксами. Мы берем заголовок, используя .at_css, который возвращает один соответствующий элемент. Затем мы перебираем каждого потомка заголовка и удаляем лишние span. В конце, когда span убраны мы получаем текст заголовка и чистим его от лишних пробелов.

dates = showing.at_css('.start_and_pricing').inner_html.strip
dates = dates.split('<br>').map(&:strip).map { |d| DateTime.parse(d) }

Далее код для получения даты и времени показа. Здесь немного сложнее, потому что фильмы могут показывать несколько дней и, иногда, цена может быть в этом же элементе. Мы мапим даты, которые найдем с помощью DateTime.parse и в результате получаем массив Ruby объектов — DateTime.

description = showing.at_css('.copy').text.gsub('[more...]', '').strip

Получение описания довольно простой процесс, единственное что стоит сделать, это убрать текст [more...] используя .gsub

showings.push(
    id: showing_id,
    title: title,
    tags: tags,
    dates: dates,
    description: description
  )

Теперь, имея все необходимые части в переменных, мы можем записать их в наш хеш (hash), созданный для отображения всех фильмов.

Вывод в JSON


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

require 'json'

puts JSON.pretty_generate(showings)

Данный код выводит массив showings перекодированный в формат JSON, при запуске скрипта вывод можно перенаправить в файл или другую программу для дальнейшей обработки.

Собираем все вместе


Собрав все части в одном месте, мы получаем полную версию нашего скрипта:

require 'open-uri'
require 'nokogiri'
require 'json'

url = 'http://www.cubecinema.com/programme'
html = open(url)

doc = Nokogiri::HTML(html)
showings = []
doc.css('.showing').each do |showing|
  showing_id = showing['id'].split('_').last.to_i
  tags = showing.css('.tags a').map { |tag| tag.text.strip }
  title_el = showing.at_css('h1 a')
  title_el.children.each { |c| c.remove if c.name == 'span' }
  title = title_el.text.strip
  dates = showing.at_css('.start_and_pricing').inner_html.strip
  dates = dates.split('<br>').map(&:strip).map { |d| DateTime.parse(d) }
  description = showing.at_css('.copy').text.gsub('[more...]', '').strip
  showings.push(
    id: showing_id,
    title: title,
    tags: tags,
    dates: dates,
    description: description
  )
end

puts JSON.pretty_generate(showings)

Если сохранить это в файл, например, scraper.rb и запустить ruby scraper.rb, то вы должны увидеть вывод в формате JSON. Он должен быть похож на то, что представлено ниже.

[
  {
    "id": 7686,
    "title": "Harry Dean Stanton - Partly Fiction",
    "tags": [
      "dcp",
      "film",
      "ttt"
    ],
    "dates": [
      "2015-01-19T20:00:00+00:00",
      "2015-01-20T20:00:00+00:00"
    ],
    "description": "A mesmerizing, impressionistic portrait of the iconic actor in his intimate moments, with film clips from some of his 250 films and his own heart-breaking renditions of American folk songs. ..."
  },
  {
    "id": 7519,
    "title": "Bang the Bore Audiovisual Spectacle: VA AA LR + Stephen Cornford + Seth Cooke",
    "tags": [
      "music"
    ],
    "dates": [
      "2015-01-21T20:00:00+00:00"
    ],
    "description": "An evening of hacked TVs, 4 screen cinematic drone and electroacoustics. VAAALR: Vasco Alves, Adam Asnan and Louie Rice create spectacles using distress flares, C02 and junk electronics. Stephen Cornford: ..."
  }
]

Всё. И это всего лишь базовый пример парсинга. Сложнее парсить сайт, который требует в начале авторизоваться. Для таких случаев я рекомендую посмотреть в сторону mechanize, который работает над Nokogiri.

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

Также я планирую перевести другую статью на тему парсинга от этого же автора.

Все статьи серии:
Антон Рябов @tonymadbrain
карма
4,2
рейтинг 0,0
Системный администратор
Самое читаемое Разработка

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

  • +1
    Уж слишком базовый пример: парсинг одной страницы без перехода по ссылкам. В реальной жизни такой usecase встречается не часто. Так что автор правильно рекомендует mechanize в конце статьи.
    • +1
      В ближайшее время планирую перевод следующей статьи этого автора, там рассказывается про использование mechanize.
    • +1
      Расскажите о решении каких сложностей вы хотели бы услышать. Возможно, смогу рассказать.
      • 0
        Самая большая сложность — сайты, на которых dom генерируется средствами javascript. Для таких целей пришлось использовать casperjs (который использует headless браузер phantomjs). А хотелось бы такие проблемы решать на ruby.
        • +2
          Мы используем PhantomJS из Ruby. С помощью Capybara. Наверное, это немного извращение, но работает.
          • 0
            Тоже использую Capybara, но с headless webkit (gem capybara-webkit).
            Не помню, почему не PhantomJS не стал использовать.
        • +1
          selenium есть, который рубями управляется, но он имеет некоторое количество ограничений и неудобств, с фантомом проще работать
    • 0
      Переход по ссылкам — не самая лучшая идея, если вы собрались всерьез и надолго парсить сайт. Преимущественно вы знаете на какой странице находятся нужный данные и как можно сконструировать ссылку на нее. Каждый переход добавляет один HTTP запрос, а это не самое быстрое занятие. Возможно, если вы изучаете теорию алгоритмов, константы можно игнорировать, но в реальном мире миллион и два миллиона запросов — это две большие разницы. Конечно, если сайт очень хитрый и обфусцирует свои ссылки, ничего не поделаешь.
      • 0
        Не задумывался, вот тогда такой вопрос. Вот недавний пример из моей практики: сайт — каталог. Воск ссылки на две разные турбины из каталога: раз и два. Как видите, url состоит из:
        • Засекреченного номера, идентифицирующего марку автомобиля
        • oem артикул турбины
        • артикул турбины

        Мне надо собрать информацию по всем турбинам на сайте. Я же правильно понимаю, что обход сайта по ссылкам — единственное решение?

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

    Могу посоветовать также хороший цикл статей по этой теме, там пошире немного: ruby.bastardsbook.com/chapters/html-parsing/
  • 0
    от себя добавлю — механиз нужен в принципе только если на сайте есть логин. А так нокогири вполне себе «голый» отлично со всем справляется. А вот если нужно эмулировать браузер то да, механиз.
    • 0
      Не то, чтобы нужен, но с ним удобнее из-за наличия cookie jar. Залогиниться можно и с помощью Net::HTTP. Отправляем POST запрос с логином, паролем и, возможно, CSRF токеном; запоминаем возвращенный заголовок Set-Cookie. При этом, если нет CSRF, можно еще и сэкономить один запрос.
      • 0
        ну, если говорить о том что Вам хочется этот процесс оптимизировать — да, возможно такой подход во многих конкретных ситуациях оправдан. Но когда не хочется заморачиваться то 1 гем с простым интерфейсом самое то. Как в статье и продемонстрировано — простота. Создал обьект, и через простые методы обращаешься к нему а за кулисами он делает что-нибудь полезное и увы ресурсоемкое возможно.

        По сему механиз вполне себе «когда не хочешь заморачиваться», особенно если скрапинг нужен на 1 фазе работы а не далее. Типа 1 раз срабатывание механиза, засунем в рельсы куда нибудь его в seeds.rb этот код или в миграцию, 1 раз отработаем начальное заполнение и норм )
  • 0
    Предлагаю ещё задуматься о nokogumbo, как о замене nokogiri.
  • +1
    А что это за PHP-style в первом куске кода из «Обработка html»?

    Мне кажется, что имеет смысл сначала узнать про map-reduce, а потом писать статьи. А то не дай бог попадется джуниору какому — он так и будет людей кошмарить вот таким: `title_el.children.each { |c| c.remove if c.name == 'span' }` вместо `select`.
    • +1
      В защиту автора, статья — перевод. А на junior'ов есть code review. Как напишут так и перепишут.
      • 0
        К переводчику — вопросов нет, кроме «а почему бы не дать тут примечанием „как правильно“».

        • +1
          Потому, что программирование — это французская поэзия, а не математика. Здесь нет правильно и неправильно. Вот, например, как нравится мне (на коленке, я понимаю, что можно много где улучшить). Но я уверен, что мой подход не понравится сторонникам функционального программирования, например. Кстати, по время рефакторинга заметил, что `remove` автор делает потому, что ему нужно потом получить текст parent node без span'ов. Я заменил это на dup, чтобы вызов метода не разрушал состояние объекта.

          class Showing
            attr_accessor :element
          
            def initialize(element)
              self.element = element
            end
          
            def id
              element['id'].split('_').last.to_i
            end
          
            def tags
              tag_links.map(&:text).map(&:strip)
            end
          
            def title
              title_link.dup.touch { |title| title.children.reject! { |c| c.name == 'span' } }.text.strip
            end
          
            def dates
              element.at_css('.start_and_pricing').inner_html.split('<br>').map(&:strip).map(&DateTime.method(:parse))
            end
          
            def description
              element.at_css('.copy').text.gsub('[more...]', '').strip
            end
          
            def to_h
              {
                id: id,
                title: title,
                tags: tags,
                dates: dates,
                description: description
              }
            end
          
            private
          
            def tag_links
              element.css('.tags a')
            end
          
            def title_link
              element.at_css('h1 a')
            end
          end
          
          showings = doc.css('.showing').map(&Showing.method(:initialize)).map(&:to_h)
          
          • 0
            def to_h
               Hash[%w(id title tags dates description).map { |w| [w.to_sym, "#{w}"] }]
            end

            :)
            • 0
              Спасибо, но я же не претендовал на идеальный код, только хотел показать объектно-ориентированный способ. Кстати, ваш код не заработает: вы нигде не вызываете метод.

                def to_h
                  attributes = %i(id title tags dates description)
                  attributes.zip(attributes.map(&method(:public_send))).to_h
                end
              
              • 0
                Ой, да, пардон. Да дело же не в идеальном коде, на самом деле. Дело в том, что в “руководствах” имеет смысл сразу демонстрировать более-менее приемлемые практики. Ваш код легко читаем, внятен и укладывается в принятые стили программирования в руби. Код из статьи — ужасен.
    • 0
      в php есть array_filter, который аналогичен рубишному .select, так что не могу назвать это php-style.
      • 0
        array_filter медленнее, чем foreach, почти вдвое.
        • 0
          спасибо. а можно пруфлинк?
        • +1
          А код на php медленнее чем на c++ и что?
          В большинстве случаев более понятный код важнее чем производительность.
          • 0
            соглашусь
          • 0
            Так, да не так. Язык, как обычный разговорный, так и язык программирования, семантическими конструкциями провоцирует говорить/писать определенным образом. Тривиальный map-reduce, будучи записан с использованием php-синтаксиса, превращается в малочитаемую кашу.

            Помимо производительности, разная нотация (`array_map` первым параметром хочет callback, `array_reduce` — уже наоборот), недоступность ключей без извращений типа `array_map(function($el) use($arr) { next($arr); ...` и прочие палки в колесах рано или поздно научат не выпендриваться и звать `foreach`.

            Точно так же, как перевод Пруста или Манна на английский подчистую уничтожает авторский стиль.
            • 0
              То что вы написали верно, но именно задача фильтрации выглядит изящнее при array_filter, нежели foreach.

              P.S. не хочу пост про прекрасный Ruby портить кодом на php, но можете сами написать реализацию для фильтрации по полю и посмотреть.
              • 0
                Только до тех пор, пока не нужен фильтр inplace. Или пока это не map-reduce. В общем, пока это фильтр из учебника.
  • +2
    Добавлю для страждущих github.com/chriskite/anemone — очень прелестный Ruby инструмент для обхода сайта.

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