Pull to refresh

Парсинг на Pуthon. Как собрать архив Голубятен

Reading time 9 min
Views 43K
Статья описывает разработку скрипта на языке Python. Скрипт выполняет парсинг HTML-кода, составление списка материалов сайта, скачивания статей и предварительную очистку текста статьи от «посторонних» элементов. Используется библиотеки urllib (получение HTML-страниц), lxml (парсинг HTML-кода, удаление элементов и сохранение «очищенной» статьи), re (работа с регулярными выражениями), configobj (чтение файлов конфигурации).

Для написания скрипта достаточно базовых знаний языка Python, навыков программирования и отладки кода.

В статье даются пояснения по применению библиотек на примере составления списка публикаций С.М. Голубицкого, приведена ссылка на работающий скрипт.

Предисловие или немного лирики


Едва ли ошибусь, сказав, что многие хабражители знакомы с неуемным творчеством Сергея Голубицкого. За без малого 15 лет околокомпьютерной публицистики имярек выдал на гора 433 статьи в безвременно почившей в бозе бумажной Компьтерре и более 300 Голубятен на портале Компьютерра-онлайн. И это не считая аналитических изысканий о героях забугорных гешефтов в Бизнес-журнале, приоткрытии завесы над тайнами творчества в “Домашнем Компьютере”, статей в “Русском журнале”, “D`” и проч. и проч. Претендующие на полноту обзоры жизнетворчества интересующиеся найдут по приведенным выше ссылкам.

В прошлом году начал работу авторский проект “Старый голубятник и его друзья”, который задумывался (и стал) в частности постоянно пополняемым архивом публикаций самого автора и площадкой для ведения культурповидлианских дискуссий. Как человек, неравнодушный к виртуозно вскрываемым автором темам сетевой жизни, социальной мифологии и саморазвития, а так же охочий до качественного досужего чтения, однажды стал завсегдатаем посиделок на Голубятне и я. По мере сил стараюсь не только держать проект в поле зрения, но и как-то участвовать в его развитии.

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

Постановка задачи


Итак, задача, на примере которой мы будем рассматривать парсинг сайтов на Python, состояла в следующем:
  • Составить список всех Голубятен, размещенных на Компьютерре-онлайн. Список должен включать название статьи, дату публикации, информацию о содержимом статьи (только текст, наличие картинок, видео), Синопсис, ссылку на источник.
  • Дополнить список материалами, опубликованными в бумажной Компьютерре, найти дубликаты.
  • Дополнить список материалами из архива сайта Internettrading.net
  • Загрузит ь список статей, уже опубликованных на портале “Старого голубятника”
  • Скачать статьи на локальный диск для дальнейшей обработки, по возможности автоматически очистив текст от ненужных элементов.

Подбор инструментария


В части языка программирования мой выбор сразу же и однозначно пал на Python. Не только потому, что несколько лет назад изучал его на досуге (а потом какое-то время использовал шелл Active Python как продвинутый калькулятор), но и за обилие библиотек, примеров исходного кода и простоту написания и отладки скриптов. Не в последнюю очередь интересовали и перспективы дальнейшего использования полученных навыков для решения очередных задач: интеграции с API Google Docs, автоматизации обработки текстов и т.д.

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

Итак, выбор инструментов начался определения подходящей версии Python. Первоначально пробовал использовать Python 3.2, но в процессе экспериментов остановился на Python 2.7, т.к. некоторые примеры на “тройке” не пошли.

Для упрощения установки дополнительных библиотек и пакетов использовал setuptools — средство для загрузки, сборки и инсталляции пакетов.

Дополнительно были установлены библиотеки:
  • urllib — получение HTML-страниц сайтов;
  • lxml — библиотека для парсинга XML и HTML кода;
  • configobj — библиотека для чтения файлов конфигурации.

В качестве подручных средств использовались:
  • Notepad++ — текстовый редактор с подсветкой синтаксиса:
  • FireBug — плагин браузера FireFox, позволяющий просматривать исходный код HTML-страниц
  • FirePath — плагин браузера FireFox для анализа и тестирования XPath:
  • Встроенный Python GUI для отладки кода.

Неоценимую помощь оказали статьи и обсуждения на Хабре:

А так же мануалы, примеры и документации:

И, конечно же, книга Язык программирования Python

Обзор решения


Задача включает в себя четыре однотипных процедуры загрузки материалов с четырех разных сайтов. На каждом из них есть одна или несколько страниц со списком статей и ссылками на материал. Чтобы не тратить много времени на формализацию и унификацию процедуры, был написан базовый скрипт, на основе которого под каждый сайт дорабатывался собственный скрипт с учетом особенностей структуры списка материалов и состава HTML страниц. Так, парсинг материалов на Internettrading.net, где HTML видимо формировался вручную, потребовал множество дополнительных проверок и сценариев разбора страницы, в то время как формируемые CMS Drupal (“Старый голубятник и его друзья”) и Bitrix (“Компьютерра-онлайн”, архивы бумажной Компьютерры) страницы содержали минимум особенностей.

В дальнейшем я буду ссылаться на детали исторически самого свежего скрипта парсинга портала Старого голубятника.

Список статей выводится в разделе “Протограф”. Здесь есть название, ссылка на статью и синопсис. Список разбит на несколько страниц. Перейти к следующей странице можно изменяя в цикле параметр в строке адреса (?page=n), но мне показалось изящнее доставать ссылку на следующую страницу из текста HTML.

На странице статьи есть дата публикации в формате DD Месяц YYYY, собственное ее текст и указание на источник в подписи.

Для работы с различными типами данных было создано два объекта: MaterialList(object) — список статей (содержит метод парсинга отдельной страницы списка _ParseList и метод получения URL следующей страницы _GetNextPage, хранит список материалов и их идентификаторов) и Material(object) — собственно статья(содержит метод формирования идентификатора на основе даты _InitID, метод парсинга страницы _ParsePage, метод определения источника публикации _GetSection и атрибуты статьи, такие как дата публикации, тип материала и проч.)

Дополнительно определены функции работы с элементами дерева документа:
  • get_text(item, path) — получение текста элемента по пути path в документе item
  • get_value(item) — получение текста ноды в документе item
  • get_value_path(item, path) — получение текста ноды в документе item по пути path
  • get_attr_path(item, path, attr) — получение аттрибута элемента по пути path в документе item

И функция get_month_by_name(month), возвращающая номер месяца по его названию для разбора даты.

Основной код (процедура main()) содержит загрузку конфигурации из файла, проход по страницам списка материалов с загрузкой содержимого в память и дальнейшее сохранение в файлы как самого списка (в формате CSV), так и текстов статей (в HTML, имя файла формируется на основе идентификатора материала).

В файле конфигурации хранятся URL начальной страницы списка материалов, все пути XPath для страниц материалов и списка статей, имена файлов и путь к каталогу для сохранения статей.

Детали реализации


В этой части я рассмотрю основные моменты кода, так или иначе вызвавшие затруднений или курение мануалов.

Для упрощения отладки путей внутри документов и облегчения чтения кода все XPath вынесены в отдельный конфигурационный файл. Для работы с файлом конфигурации вполне подошла библиотека configobj. Файл конфигурации имеет следующую структуру:
# Comment
[ Section_1 ]
   # Comment
   variable_1 = value_1
   # Comment
   variable_2 = value_2
   [[Subsection_1]]
      variable_3 = value_3
   [[Subsection_2]]
[ Section_2 ]


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

from configobj import ConfigObj

# Загрузить файл конфигурации
cfg = ConfigObj('sgolub-list.ini')
# Получить значение параметра url из секции sgolub
url = cfg['sgolub']['url']


Загрузка html-страницы реализована с помощью библиотеки urllib. С помощью lxml преобразуем документ в дерево и фиксим относительные ссылки:

import urllib
from lxml.html import fromstring

# Загрузка html-документа в строку
html = urllib.urlopen(url).read();

# Преобразование документа к типу lxml.html.HtmlElement
page = fromstring(html)

# Преобразование относительных ссылок внутри документа в абсолютные
page.make_links_absolute(url)


При разборе списка публикаций нам потребуется перебрать в цикле все элементы списка. Для этого подойдет метод lxml.html.HtmlElement.findall(path). Например:

for item in page.findall(path):
   url = get_attr_path(item,cfg['sgolub']['list']['xpath_link'],'href')


Сейчас самое время сделать замечание по поводу плагина FirePath и его использования для построения XPath. Действительно, как уже писали на Хабре, FirePath дает пути, которые отличаются от путей в lxml. Незначительно, но разница есть. Довольно скоро эти отличия удалось выявить и в дальнейшем использовать FirePath с поправками, например, тег tbody заменять на * (самая частая проблема). В то же время, скорректированные таким образом пути можно проверять в FirePath, что существенно ускоряет дело.

В то время как page.findall(path) возвращает список элементов, для получения отдельного элемента существует метод find(path). Например:

content = page.find(cfg['sgolub']['doc']['xpath_content'])

Методы find и findall работают только с простыми путями, не содержащими логических выражений в условиях, например:

xpath_blocks = './/*[@id='main-region']/div/div/div/table/*/tr/td'
xpath_nextpage = './/*[@id='main-region']/div/div/div/ul/li[@class="pager-next"]/a[@href]'


Для того, чтобы использовать более сложные условия, например, вида
xpath_purifytext = './/*[@id="fin" or @class="info"]'
потребуется уже метод xpath(path), который возвращает список элементов. Вот пример кода, вычищающий из дерева выбранные элементы (как работает эта магия я не понимаю до сих пор, но элементы действительно удаляются из дерева):

from lxml.html import tostring

for item in page.xpath(cfg['computerra']['doc']['xpath_purifytext']):
item.drop_tree()
text=tostring(page,encoding='cp1251')

В этом фрагменте также используется метод lxml.html.tostring, сохраняющий дерево (уже без лишних элементов!) в строку в заданной кодировке.

В заключение приведу два примера работы с библиотекой регулярных выражений re. Первый пример реализует разбор даты в формате «DD Месяц YYYY»:

import re
import datetime

# content имеет тип lxml.html.HtmlElement
# и является частью страницы, содержащей непосредственно статью
datestr=get_text(content,cfg['sgolub']['doc']['xpath_date'])
if len(datestr)>0:
   datesplit=re.split('\s+',datestr,0,re.U)
   self.id = self._InitID(list,datesplit[2].zfill(4)+str(get_month_by_name(datesplit[1])).zfill(2)+datesplit[0].zfill(2))
   self.date = datetime.date(int(datesplit[2]),get_month_by_name(datesplit[1]),int(datesplit[0]))
else:
   self.id = self._InitID(list,list.lastid[0:8])
   self.date = datetime.date(1970,1,1)


Используется функция re.split(regexp,string,start,options), которая формирует список из элементов строки, разделенных по определенной маске (в данном случае, по пробелу). Опция re.U позволяет работать со строками, содержащими русские символы в юникоде. Функция zfill(n) добивает строку нулями слева до указанного количества символов.

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

def _GetSection(item, path):
   # рекомендуется компилировать регулярные выражения
   reinfo = re.compile(r'.*«(?P<gsource>.*)».*',re.LOCALE)
   for info in item.xpath(path):
      src=get_value_path(info,'.').strip('\n').strip().encode('cp1251')
      if src.startswith('Впервые опубликовано'):
         parser = self.reinfo.search(src)
         if parser is not None:
            if parser.group('gsource')=='Бизнес-журнале':
               return 'Бизнес-журнал'
            else:
               return parser.group('gsource')
         break
      return ''


В приведенном примере показан код функции _GetSection(item, path), которой передается поддерево, содержащее указание на источник публикации, например «Впервые опубликовано в Бизнес-журнале». Обратите внимание на фрагмент регулярного выражения ?P<gsource>. Помещенный в скобки он позволяет определять именованные группы в строке и обращаться к ним с помощью parser.group('gsource'). Опция re.LOCALE аналогична re.U.

Исходный код парсера выложен в Google Docs. Чтобы уберечь сайт Старого голубятника от потока парсеров, выкладываю только код, без конфигурационного файла со ссылками и путями.

Заключение


Результатом применения технологии стал архив статей с четырех сайтов на жестком диске и списки всех публикаций Голубятен. Списки были вручную загружены в таблицу Google Docs, статьи из архива также переносятся вручную для правки в документы Google.

В планах решение задач:
  • Написание службы, автоматически отслеживающей новые публикации
  • Интеграция с API Google Docs для автоматического внесения новых публикаций в список
  • Преобразование архивных статей из HTML к XML-формату с автоматической коррекцией части ошибок и загрузкой в Google Docs


P.S. Большое спасибо всем за комментарии, поддержку и конструктивную критику. Надеюсь, что большинство замечаний станут мне полезны в будущем после внимательного изучения.
Tags:
Hubs:
+32
Comments 41
Comments Comments 41

Articles