3 posts tagged

Накодил

Анализ блога Ильи Бирмана. Часть 3: визуализация.

В двух предыдущих заметках я рассказал, как собирал данные и приводил базовый анализ на самые-самые заметки:

  1. Сбор данных.
  2. Анализ данных.
  3. Визуализация.

Доступные данные

Скрипт собрал данные о 4565 заметках. По каждой есть: заголовок, дата, длина, количество картинок, просмотры, комментарии, тэги и ссылка на саму заметку.

Первые заметки в блоге датированы 2002 годом, последняя заметка — от 26 сентября 2019. Активность в 2002-2004 годах отличается от последующих: два месяца в 2002 (заметки импортированы в 2005), ещё восемь супер-активных месяцев в 2003, чуть менее бурная активность в 2004. С 2005 года и дальше более-менее равномерно.

Общий вид

Первое, что заметил — это стабильность Ильи: за всё время не было ни периодов тишины, ни каких-то взрывов активности.

Напоминает концепцию «20-мильного марша» из книги «Великие по собственному выбору». Авторы книги нашли общую особенность у великих людей и компаний: они выбирали темп и всегда его придерживались. В неблагоприятных условиях это закаляло дисциплину, а в хороших — удерживало от неконтролируемого роста.

Эту регулярность можно увидеть на тепловой карте ниже. Слева количество заметок в каждый месяц из 17 лет, а справа — средняя длина заметки.

Чтобы видеть выбросы, за «среднее» брал именно арифметическое среднее, а не медиану.

в 2003 году Илья писал в блог по 2-3 заметки в день или 365 заметок за 7 месяцев

Аналитика уровня «пальцем в небо»: заметны относительно спокойные периоды и хочется найти в закономерность. Илья часто путешествует, а в путешествиях обычно столько всего интересного, что времени на блог остаётся меньше. Предположу, что «спокойные периоды» блога связаны именно с путешествиями: февраль..апрель и август..сентябрь в 2019, август в 2017, декабрь 2016..январь 2017.

Заметна тенденция: чем больше постов за месяц, тем больше их средняя длина. То есть Илья обычно пишет либо коротко и немного, либо и много, и длинно. Эту корреляцию можно увидеть на диаграмме рассеяния.

Динамику по годам количества заметок и их длины видно на диаграмме «ящик с усами»:

в 2019 в среднем заметки стали короче, но стало больше очень длинных заметок — «выбросов»

Интересные детали

Просмотры начали считаться с января 2018. Видимо, в это время движок блога научился их считать и показывать. На более ранних заметках счётчик просмотров может отсутствовать или показывать единицы просмотров.

На графике видны «выбросы» — месяцы с аномально высокими просмотрами. Заметки с самыми большими просмотрами я приводил в предыдущей заметке об анализе блога.
Вот они:

год просмотры тэги
О запятой после «С уважением» 2006 87974 русский язык
Переплата по кредиту 2013 39296 жизнь, общество, экономика
Числа π и e 2012 14387 математика
Война 2015 13601 красная таблетка, общество
Почему люди платят налоги 2014 9310 красная таблетка, общество, философия, экономика

Комментарии. До 2012 заметки в блоге набирали большое количество комментариев, а с января 2012 они почти исчезли. Потом появлялись только для отдельных заметок.

общее количество комментариев за месяц

Заметки после 2012 года с наибольшим количеством комментариев:

В предыдущих сериях

  1. Cбор данных: заметка и код на ГитХабе
  2. Анализ данных: заметка и код на ГитХабе
 47   1 mon   Python   Накодил   Сделал

Анализ блога Ильи Бирмана с помощью Python

Это вторая заметка из серии «Тренируем Python на блоге Ильи Бирмана».

  1. Сбор данных
  2. Анализ данных
  3. Визуализация результатов (coming soon)

В предыдущих сериях

Этап сбора данных закончился тем, что все данные собрали в одну большую табличку — dataframe. Чтобы данные не потерялись, выгрузил их в файл .csv

Подготовка данных

Чтобы начать работу, загружаем данные.

birman = pd.read_csv("birman.csv", sep = ";", engine = "python")

На всякий случай проверяем количество строк и столбцов.

birman.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4487 entries, 0 to 4486
Data columns (total 8 columns):
title       4487 non-null object
datetime    4487 non-null object
views       4487 non-null int64
comments    4487 non-null int64
length      4487 non-null int64
images      4487 non-null int64
tags        4392 non-null object
link        4487 non-null object
dtypes: int64(4), object(4)
memory usage: 280.5+ KB

Оу! Куда-то потерялись 95 тэгов. Надо проверить. Смотрим у каких строк тэги null.

birman[birman["tags"].isnull()][["title","link"]].head(3)
title link
Вильнюс https://ilyabirman.ru/meanwhile/all/vilnius/
Продам чехол «Люкса-2» для 11-дюймового Эйра https://ilyabirman.ru/meanwhile/all/avito-luxa2-11/
Продам сумку Инкейс для 11-дюймового Эйра https://ilyabirman.ru/meanwhile/all/avito-incase-11/

Переходим по ссылкам и видим, что действительно у некоторых заметок нет тэгов. Но у нескольких тэги почему-то потерялись.
Делаем ещё один проход по таким ссылкам и проверяем тэги. Добавляем у кого есть, у кого нет — ставим тэг «без тэга».

branch = birman

for item in branch[branch.tags.isnull()].itertuples():
    #print(item.link)
    
    blog_page = requests.get(item.link)
    blog_page_soup = BeautifulSoup(blog_page.content, "html.parser")

    string_with_tags = ""
                   
    for tag in blog_page_soup.select(".e2-tag"):
        string_with_tags += tag.get_text() + ", "

    if len(string_with_tags) > 0:
        birman.loc[item.Index, "tags"] = string_with_tags[0:-2]
    else:
        birman.loc[item.Index, "tags"] = "без тэгов"

Проверяем

birman.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4487 entries, 0 to 4486
Data columns (total 8 columns):
title       4487 non-null object
datetime    4487 non-null datetime64[ns]
views       4487 non-null int64
comments    4487 non-null int64
length      4487 non-null int64
images      4487 non-null int64
tags        4487 non-null object
link        4487 non-null object
dtypes: datetime64[ns](1), int64(4), object(3)
memory usage: 280.5+ KB

Всё на месте — все null заменены на нормальные данные. Можно работать.

Анализ данных — первое приближение, birdview

Для начала посмотрим общие цифры по годам, чтобы понять общую картину. Группируем данные по годам и складываем значения.

by_years = df.groupby(df.datetime.dt.strftime("%Y")).sum()

Добавляем количество заметок за год

by_years["count"] = df.groupby(df.datetime.dt.strftime("%Y")).views.count()

Результат:

year posts views comments length images
2002 11 36 0 2511 7
2003 365 597 398 23826 3
2004 505 3398 1125 39507 0
2005 375 1672 995 40191 3
2006 236 102054 1351 38190 7
2007 266 2766 2355 33408 4
2008 210 6003 2676 30365 7
2009 225 17182 4617 34210 22
2010 145 10541 2849 26297 15
2011 142 9294 3193 26091 96
2012 246 20573 10 43734 122
2013 280 48444 38 49875 342
2014 287 31730 535 63677 322
2015 199 23430 96 44668 288
2016 268 18869 286 54088 372
2017 339 19484 395 63107 986
2018 295 280784 723 70380 1210
2019 93 84838 228 34654 378

Посмотрим на каждый показатель отдельно:

  • Количество. Первые 11 заметок в блог Илья написал ещё в 2002 году. Далее с небольшими отклонениям было примерно по 300 заметок каждый год. Удивительное постоянство для такого большого периода.
  • Комментарии. До 2011 было в среднем 2000 комментариев в год с пиком в 4617 комментариев в 2009 году. Но в 2012 комментариев было только 10 штук. И все к первой заметке в году, дальше — ноль. Надо бы спросить у Ильи что случилось.
  • Длина . Средняя длина заметок росла: с 78 слов на одну заметку в 2004 году до 239 в 2018.
  • Картинки. Видно, что до 2010 картинок в заметках почти не было. В 2011-2012 годах уже примерно 100 картинок за год. Дальше — больше: в 2013-2016 уже по ~300 в год. С 2018 в среднем по тысяче.
  • Просмотры. С самого начала Эгея не показывала просмотры. До марта 2018 года у заметок в основном единицы просмотров. Были исключения, но, если я правильно понял механизм, эти просмотры Эгея посчитала уже после марта 2018.

Ориентироваться на просмотры можно за 2018 и половину 2019 года:

year posts views views_mean views_median
2018 295 280784 951 807
2019 93 84838 912 841

В 2018 году в среднем одну заметку посмотрели 951 раз, в 2019 — 912 раз. Значение медианы близко к среднему, значит — данные без сильного перекоса.

Самые-самые заметки за всё время

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

Самые просматриваемые

title date views tags
О запятой после «С уважением» 2006 87974 русский язык
Переплата по кредиту 2013 39296 жизнь, общество, экономика
Числа π и e 2012 14387 математика
Война 2015 13601 красная таблетка, общество
Почему люди платят налоги 2014 9310 красная таблетка, общество, философия, экономика

Получился хороший список для общеобразовательного чтения. Русский язык, математика и философия. Если дочитали заметку до сюда, рекомендую прочитать и заметки Ильи (и приведённые, и все остальные).

Самые комментируемые

title date comments tags
Ремонетизация 2009 200 реклама, этот сайт
Бананотехнология 2009 199 еда, жизнь
Почему люди платят налоги 2014 193 красная таблетка, общество, философия, экономика
Опенсос 2010 174 идиоты, опенсорс, софт
Кто на чём 2007 150 браузеры

Выборка постов получилась неслучайной. Во всех есть приглашение к комментированию. Особенно примечательна заметка из 2014: в том году комментарии были разрешены только к 12 заметкам (из них половина — рубрика «Дискуссии по понедельникам»).

Самые длинные

Здесь четыре рецензии на книги с большими выдержками и один «полный гид».

Если не засчитывать вставки из книг и пересчитать рейтинг без заметок с тэгом «книги», получится тоже интересная подборка:

title date length tags
Полный гид по клубу Бергхайн 2018 3493 Бергхайн
SynSUN — Phoenix 2006 1714 музыка, обзоры
Кофейные места 2017 1657 кофе
Голосовые объявления в лондонском метро 2013 1619 …навигация в общественных местах…
Как выучить иврит — 2 2018 1568 иврит

Самые… заметки, где больше всего картинок

title date images tags
Процесс создания логотипа Драйвинг-тестов. Часть 1 2018 36 портфолио, процесс
Телеграм за неделю 5—11 февраля 2018 2018 32 телеграм-канал
Телеграм за неделю 12—18 февраля 2018 2018 30 телеграм-канал
Санкт-Петербург: Гранд-макет 2018 29 мир, музеи и выставки, Санкт-Петербург
Тель-Авив: прогулка по Флорентину 2018 28 мир, Тель-Авив

Воспользуюсь правом, данным мне библиотекой pandas, и уберу заметки с тэгом «телеграм-канал». Получилось интереснее, как мне кажется.

title date images tags
Процесс создания логотипа Драйвинг-тестов. Часть 1 2018 36 портфолио, процесс
Санкт-Петербург: Гранд-макет 2018 29 мир, музеи и выставки, Санкт-Петербург
Тель-Авив: прогулка по Флорентину 2018 28 мир, Тель-Авив
Музей БМВ в Мюнхене 2016 26 автомобиль, Германия, музеи и выставки, фото
Регистрация на рейс «Аэрофлота» 2017 24 полёты, пользовательский интерфейс, студентам

Получилась уже длинна заметка. На этом пока всё. Далее по плану анализ тэгов:

  • общее количество,
  • самые долгоживущие,
  • самые популярные и т.д.

И, конечно, хочется все эти данные и выводы красиво визуализировать. К тому же, у Python есть классные библиотеки типа Seaborn и Bokeh. Но это уже в следующих сериях.

 24   4 mon   Python   Накодил   Сделал

Тренируем Python: веб-скрэпинг на примере блога Ильи Бирмана

Веб-скрэпинг — это автоматизированный сбор данных с сайтов в интернете.

Часто применяется, например, интернет-магазинами, чтобы следить за ценами и ассортиментом конкурентов. Ещё можно получить с сайта NASA данные об орбитах всех космических тел в Солнечной системе.

Это первая заметка из серии «Тренируем Python на блоге Ильи Бирмана».

  1. Сбор данных
  2. Анализ данных
  3. Визуализация результатов (coming soon)

Зачем всё это

На майских праздниках я начал изучать язык программирования Python.
Пайтон привлёк меня своей универсальностью: можно быстро что-нибудь автоматизировать, написать скрипт для веб-скрэпинга или проанализировать пару миллионов записей в базе данных. А если прокачаться в математическом анализе, теории вероятностей и линейной алгебре, можно писать код для машинного обучения.
На платформе Code Academy я изучил основы языка, прошёл несколько курсов, сделал много учебных примеров. По итогам решил сделать выпускной проект (бывает выпускной через месяц?), чтобы потренироваться на живом примере и на практике освоить полученные знания.

В теории нет разницы между теорией и практикой. А на практике есть

Йоги Берра — американский бейсболист и менеджер бейсбольной команды.

Выбор объекта исследования

Для объекта исследования выбрал блог Ильи Бирмана — дизайнера и арт-директора, диджея и музыканта, преподавателя и писателя, философа и математика, фотографа и путешественника, автора блога и видео-заметок.

3 причины такого выбора:

  1. Илья — интересный человек. Это, кажется, главное в личных проектах — чтобы было интересно и безудержно пёрло от процесса;
  2. большой массив данных для изучения: Илья регулярно ведёт блог с 2003 года;
  3. в блоге есть отдельная страничка со всеми-всеми записями — идеально для первого скрэпинга (ведь я ещё не знаю, как автоматически листать страницы);
  4. БОНУС! — передать привет Илье и сгенерировать ему просмотров на сайт (два или, может, даже три).

Инструменты

  • Python. Плюс дополнительные библиотеки:
    • BeautifulSoup + Requests — для, собственно, веб-скрэпинга
    • RE — для очистки данных с помощью регулярных выражений
    • Pandas — для хранения и обработки полученных данных
    • Matplotlib и Seaborn — для визуализации результатов
  • Jupyter Notebook — визуальная среда для кодинга
  • Chrome DevTools — чтобы понять как устроен искомый сайт внутри
  • Google и StackOverflow — чтобы понять, мой идеальный код не работает.

Знакомство с объектом исследования

Итак, есть блог Ильи Бирмана — https://ilyabirman.ru/meanwhile/

Блог работает на движке Эгея — кстати, тоже проект Ильи. У Эгеи есть отдельная страница со всеми записями. Для блога Ильи она доступна по адресу https://ilyabirman.ru/meanwhile/all/

Все 4486 заметок на одной странице — ух!

Страница отдельной заметки:

Какие параметры для исследования здесь можно добыть:

  • заголовок заметки, куда без него;
  • количество просмотров — можно найти самые просматриваемые;
  • количество комментариев — найти самые обсуждаемые заметки;
  • тэги заметки — может пригодиться;
  • дату публикации — чтобы привязать данные ко времени;
  • количество картинок — для общей картины;
  • количество слов в заметке — интересно будет поискать корреляцию длины с просмотрами или комментариями;
  • URL — чтобы потом включить в отчёт и быстро находить нужные заметки.

К делу — расчехляем Python

Писать код удобно в Jupyter Notebook. Это визуальная среда для программирования, где можно сразу посмотреть результат.

Импортируем нужные библиотеки

from bs4 import BeautifulSoup
import requests
import re

Чтобы разобраться, как добывать данные из веб-страницы, начнём с простого: возьмём одну заметку и попробуем добыть информацию из неё.
С помощью requests обращаемся к странице блога со всеми записями. И с помощью BeautifulSoup делаем «мыло» — soup объект.

webpage = requests.get("[https://ilyabirman.ru/meanwhile/all/chernobyl-podcast/](https://ilyabirman.ru/meanwhile/all/)")
soup = BeautifulSoup(webpage.content, "html.parser")

Заголовок заметки

С объектом soup уже можно работать: например, достать оттуда заголовок — тэг h1:

soup.h1

На это но выдаст весь тэг h1:

<h1 class="e2-published e2-smart-title">Про подкаст «Чернобыля»</h1>

Достать только видимую часть — текст — можно через метод .get_text():

title = soup.h1.get_text()

На выходе получили «\r\nПро\xa0подкаст «Чернобыля» \r\n». Похоже на правду, но не совсем.

\xa0, \r и \n — это специальные символы. На сайте браузер их не показывает, но после конвертации в строку они «проявились». Почистим данные с помощью регулярных выражений — библиотека re.

title = re.search("[^(\n|\r)]+", title).group()

На выходе строка: ‘Про\xa0подкаст «Чернобыля» ’

Функция search библиотеки re возвращает объект match. Чтобы получить на выходе обычную строку, надо добавить метод .group()

На все попытки засунуть в регулярное выражение ещё и \xa0, Пайтон упорно выдавал ошибку. Не понял как сделать поиск всё в одном, пришлось пройтись по тексту ещё раз простой заменой через .replace().

Меняем неразрывный пробел на обычный. Заодно удаляем пробелы в начале и конце.

title = title.replace("\xa0"," ").strip()

На выходе нужная строка: ‘Про подкаст «Чернобыля»

Просмотры

С заголовком заметки было просто — элемент h1 обычно только один на странице. Мы обратились к нему по типу, без дополнительного поиска.

С более простыми элементами сложнее: обычно их больше одного на странице. Чтобы с помощью BeautifulSoup найти нужный элемент, нужно о нём что-нибудь знать.

Здесь поможет встроенный инструмент браузера Chrome — DevTools — с его помощью можно залезть сайту под капот и посмотреть как там всё устроено.

Ищем на странице заметки счётчик просмотров:

Вот он — рядом с глазиком. Заметку посмотрели 632 человека.

Смотрим, как он выглядит в html коде. В Хроме наводим на элемент и нажимаем inspect

Или можно горячими клавишами shift+command+c открыть DevTools и навести на нужный элемент.

Искомая цифра 632 находится внутри тэга с классом e2-read-counter :

<div class="e2-note-meta">
  <span class="e2-read-counter">
  <span class="e2-svgi"> … </span> 
  632</span>
  <…>
</div>

С помощью BeautifulSoup обращаемся к элементу по его классу:

views_span = soup.select(".e2-read-counter")[0].get_text()

На выходе получаем « 632 ». ПРЯМОЕ ПОПАДАНИЕ! — не перестаю удивляться мощи программирования.

Выглядит как цифры но на самом деле это текст (да ещё и с пробелом!). Тип переменной проверяется через функцию type():

>>> print(type(views_span))
<class 'str'>

Через уже знакомые регулярные выражения добудем из этого текста только цифры:

views_span = re.search("\d+", views_span).group()

И переведём текст в цифры — тип integer:

views_span = int(views_span)
>>> print(type(views_span))
<class 'int'>

Итого: у нас уже есть заголовок заметки и количество просмотров.

Обработка исключений

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

На заметках старше 2017 года код начал выдавать ошибку. Я добавил отладчик в код, чтобы он показывал адрес страниц, где была ошибка. Заходил туда и пытался понять, в чём проблема. На вид эти страницы ничем не отличались от других. Кроме того что просмотров у них было 1.

Видимо, Эгея не сразу умела отображать просмотры страницы, но в какой-то момент научилась. На страницах, опубликованных до этого момента, просто нет тэга с классом e2-read-counter. Зато при первом заходе на такую страницу через браузер (но не через парсер!) Эгея его автоматически добавляет прямо налету.

Не знаю, как матёрые питонисты обрабатывают исключения, мне пришло в голову проверять наличие нужного элемента через его длину:

if len(soup.select(".e2-read-counter")) > 0:
	…сбор данных…
else: 
	views = 0

Если такого элемента на странице нет, то считаем, что просмотров ноль.

Комментарии

По примеру счётчика просмотров повторяем всю процедуру. Ищем элемента на странице. Видим, что комментарии в коде идут так:

<span id="e2-comments-count">4 комментария</span>

Здесь у тэга указан айди, а не класс (как у тэга с просмотрами). Пришлось воспользоваться другим методом BeautifulSoup — .find()

Комментарии — это тебе не просмотры. Здесь даже мне было очевидно, что у заметки их может и не быть, поэтому обработчик я добавил сразу.

if soup.find(id="e2-comments-count") != None:
  comments_span = blog_page_soup.find(id="e2-comments-count").get_text()
  comments = (int(re.search("\d+", comments_span).group(0)))
else:
  comments = 0

Всё по аналогии с просмотрами: ищем элемент, забираем текст, выуживаем число, переводим в интеджер.

Если элемента с комментариями нет, значит, их ноль.

Тэги

Повторяем знакомую процедуру для тэгов заметки.

<div class="e2-note-meta">
  …
  <a href="https://ilyabirman.ru/meanwhile/tags/movies/" class="e2-tag">кино</a> 
  &nbsp; 
  <a href="https://ilyabirman.ru/meanwhile/tags/podcast/" class="e2-tag">подкаст</a>
</div>

Новый челендж: в отличие от счётчиков, тэгов может быть несколько. Удобно, что к них есть отдельный класс, по которому можно обратиться. Делаем цикл по всем элементам с нужным классом и добавляем все элементы в заготовленный список:

tags = []
for tag in soup.select(".e2-tag"):
    tags.append(tag.get_text())

Проверяем, что получается:

>>> print(tags)
['кино', 'подкаст']

Всё норм!

Картинки

Подумал, что хорошо бы ещё знать, сколько было в заметке картинок. Не самая важная метрика, но было интересно попрактиковаться.

В нашей подопытной заметке про подкаст картинок нет, поэтому берём заметку про Черногорию. Смотрим в DevTools на первую картинку. HTML выглядит так:

<div class="e2-text-picture-imgwrapper" style="padding-bottom: 66.67%">
	<img src="https://ilyabirman.ru/meanwhile/pictures/kotor-DSCF1378.jpg" width="1200" height="800" alt="">
</div>

Решил получить нужную цифру через див: находим на странице все дивы с нужным классом: soup.find_all(“div”, class_=“e2-text-picture-imgwrapper”). Метод .find_all() возвращает список найденных элементов. То есть количество нужных элементов на странице — это длина этого списка.

images = len(soup.find_all("div", class_="e2-text-picture-imgwrapper"))

Проверяем:

>>> print(images)
16

На всякий случай пойдём и посчитаем пальчиком все фотографии Черногории в заметке — получилось 16. Работает.

Длина текста

Для полноты данных соберём длину текста заметки. Через DevTools ищем элемент, который содержит в себе только заметку, без прочего «обвеса». Находим элемент article. Отдельный и единственный — удобно будет к нему обращаться.

Подумал, что длину хорошо быть мерять в словах, а не символах, — будет нагляднее.

Чтобы получить длину в словах, достанем текст элемента article и разделим его на слова методом .split(). В качестве параметра функция принимает любой разделитель. Если параметр не указан — считает разделителем пробельные символы.

Функция .split() возвращает список слов. Количество слов — длина этого списка. Сохраняем в переменную.

words = len(soup.article.get_text().split())

Дата публикации

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

Начнём стандартно: через DevTools ищем в коде страницы дату. Находим такое:

<div class="e2-note-meta">
  <span class="e2-read-counter">
  <span class="e2-svgi">
    <svg…> … </svg>
  </span> 
  624
</span>
  <span title="10 июня 2019, 16:17, GMT+03:00">3 дн</span>
  <a…> … </a>
</div>

То есть дата публикации спрятана в title элемента span без какого-либо класса внутри элемента div с классом e2-note-meta. Все предыдущие данные мы доставали, обращаясь к элементу через его уникальный class или id. С датой такой подход не прокатит — у элемента нет ни того, ни другого. Зато у него есть title, он и он у каждой заметки свой.

Решил добраться до нужного элемента через его родителя — элемент див с уникальным классом. И обратиться напрямую к его четвертому дочернему элементу:

>>> soup.find("div", class_="e2-note-meta").contents[3]
<span title="10 июня 2019, 16:17, GMT+03:00">3 дн</span>

Код сработал!

Но, как и в случае с просмотрами, меня ждал сюрприз, когда выкатил этот код на весь объём блога. Оказывается-то, блок с просмотрами тоже дочерний элемент дива, через который я обращаюсь к элементу с датой. И, когда у заметки нет просмотров, порядок дочерних элементов меняется, и span с датой уже не четвёртый (и даже не третий — это я сразу проверил!).

Решение пришло на прогулке, куда я вышел после долгих попыток подгадать новый порядковый номер нужного элемента и чтобы он подходил для всех заметок. Оказалось, что в номере просто нет смысла, можно же вложить поиск внутри поиска и найти `span` с датой по другому его уникальному свойству, которое однозначно отличает его от соседей — полному отсутствию класса.

>>> date = soup.find("div", class_="e2-note-meta").find("span", class_="")
<span title="10 июня 2019, 16:17, GMT+03:00">3 дн</span>

Е-е-е-е!

Дело за малым — вытащить оттуда дату. Так, применяем уже стандартный подход с .get_text():

>>> date.get_text()
3 дн

Отлично, блин! — получили возраст публикации. Сама дата-то не внутри элемента, а в его title. Начинаем ~~изобретать велосипед~~ применим регулярные выражения.

>>> date_str = re.search("\".+\"", str(date)).group(0)
"10 июня 2019, 16:17, GMT+03:00"

Уже лучше. Пока ещё просто строка с датой и кавычками, но уже не возраст.

Из этой строки нужно достать: день, месяц, год. Повезло, что каждый из этих данных можно уникально закодировать через регулярные выражения:

  • день — единственные одна или две цифры в строке;
  • месяц — буквы между двумя пробелами;
  • год — единственные четыре цифры в строке.

Для дня и года всё бесхитростно, переводим на язык регулярных выражений:

day = int(re.search("[\d]{1,2}", date_str).group(0))
year = int(re.search("[\d]{4}", date_str).group(0))

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

В регулярных выражениях буквы можно задавать через последовательности, например, поиск по [a-zA-Z] выдаст все буквы в нижнем и верхнем регистре.

Ещё есть сокращения типа \w, что даст такой же результат как и [a-zA-Z0-9_].

Больше такого в английской документации к библиотеке re или на русской википедии

Воспользуемся вторым примером, но уберём оттуда цифры. И сразу уберём пробелы, по которым искали буквы.

>>> month_str = re.search(" [\w^(\d)]+ ", date_str).group(0).strip()
июня

Гут, идём дальше.

Чтобы потом было легче работать с датами, хорошо бы иметь дату в цифровом виде. Значит, надо перевести месяц в число. Наверняка, есть какие-то элегантные способы, но я поступил топорно: вбил руками список всех месяцев в родительном падеже и искал порядковый номер строки в этом списке:

months = ["января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"]
month_int = months.index(month_str) + 1

Помните, что индексы идут с нулевого?

Для полной картины ещё соберём часы и минуты публикации. Вдруг захотим построить график средней суточной активности по годам.

time_ = re.search("[\d]{2}:[\d]{2}", date_str).group(0)
hour = int(time_[0:2])
minute = int(time_[3:5])

Для работы со временем и датой в Пайтоне есть специальный объект —datetime. Удобнее всего хранить дату в нём.

date_time = datetime.datetime(year, month_int, day, hour=hour, minute=minute)

Итак, мы собрали все необходимые данные на примере одной заметки:

  • заголовок;
  • количество просмотров;
  • количество комментариев;
  • тэги заметки;
  • количество картинок;
  • количество слов в заметке;
  • дата публикации.

Ещё не хватает URL заметки, добавим позднее.

Теперь надо собрать такие же данные со всех 4486 заметок.

Массовый скрэпинг

Собираем цикл, который пойдёт по по всем заметкам и будет собирать данные по каждой. Скармливаем коду нужную страницу и делаем из неё «мыло».

webpage = requests.get("https://ilyabirman.ru/meanwhile/all/")
soup = BeautifulSoup(webpage.content, "html.parser")

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

titles = []
views = []
comments = []
tags = []
datetimes = []
images = []
words = []
links = []

Запускаем цикл для обработки всех ссылок на странице. На языке HTML ссылки обозначаются тэгами a.

for link in soup.find_all("a"):
	…

Проход цикла по всем 4486 заметкам занимает продолжительное время, поэтому сначала лучше ограничить цикл, чтобы отладить код. Я начал с ограничения в 50 записей и по мере улучшения выкатывал на 100-400-1000-2000 записей.

Ограничитель на первые 50 элементов — стандартный синтаксис для работы со списками:

for link in soup.find_all("a")[:50]:
	…

Оказалось, что не все тэги a содержат какие-то ссылки. Добавил в начале соответствующий обработчик: if type(link.get(‘href’)) == type(“string”)

Ещё оказалось, что на странице куча ссылок не на заметки, а на рабочие моменты блога, типа настроек и списка тегов. Пришлось вручную проверять ссылки на наличие в них строк @ajax, /settings/ и /tags/.

Контрольный выстрел: пропускать все ссылки короче 32 знаков (длина строки «https://ilyabirman.ru/meanwhile/», с которой начинаются ссылки всех заметок).

Сначала я отбирал ссылки по наличию в них http://ilyabirman.ru/meanwhile/all/ — адреса всех свежих заметок начинались так. Это дало чистые результаты. Но, когда выкатил цикл на все 4486 заметок, он дал результаты только в две тысячи. Адреса более ранних заметок больше не содержали «/all/». Пришлось внести коррективы.

После обработки всех исключений, цикл доходит до ссылки на очередную заметку. Чтобы её обработать, нужно и из неё сделать объект soup:

for link in soup.find_all("a"):
	post_tags = []
	parse_count += 1
	
	# drop not links (there are some 'None' object in scrapping results)
	if type(link.get('href')) == type("string"):
	    
	    # exclude blog engine settings links         
	    if ("@ajax" in link.get('href'))\
	    | ("/settings/" in link.get('href'))\
	    | ("/tags/" in link.get('href'))\
	    | (len(link.get("href")) <= 32) : # 32 is the length of "<https://ilyabirman.ru/meanwhile/>"
	        continue
	    
	    # drop all except links for blogposts
	    elif "ilyabirman.ru/meanwhile/" in link.get('href'):
	
	        # get a link itself and parse it with with BeautifulSoup
	        blog_page = requests.get(link.get('href'))
	        blog_page_soup = BeautifulSoup(blog_page.content, "html.parser")

Логика немудрёная: иди на страницу со всеми записями и найди там все ссылки, каждую ссылку проверь по условиям, если всё ок — делай из страницы по этой ссылке объект soup.

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

для экономии места уберу отступ; приведённый код внутри цикла, описанного выше;

# get a blogpost title, save to list of titles
titles.append(blog_page_soup.h1.get_text())

# check if a post have a view counter (old posts have no views counter)
if len(blog_page_soup.select(".e2-read-counter")) > 0:

    # get a span block with views count
    views_span = blog_page_soup.select(".e2-read-counter")[0].get_text()

    # get a number from text block and format as integer, save to list of views count
    views.append(int(re.search("\d+", views_span).group(0)))

else:
    views.append(1)

# get comments count
if blog_page_soup.find(id="e2-comments-count") != None:
    comments_span = blog_page_soup.find(id="e2-comments-count").get_text()
    comments.append(int(re.search("\d+", comments_span).group(0)))
else:
    comments.append(0)
    
# get tags for each post
for tag in blog_page_soup.select(".e2-tag"):
    post_tags.append(tag.get_text())
# list of posts' tags
tags.append(post_tags)

# append date and time for each post to the list
append_datetime_from_soup(blog_page_soup, datetimes)

# get images count
images.append(\
	len(blog_page_soup.find_all("div", class_="e2-text-picture-imgwrapper")))

# post's length (words count)
words.append(len(blog_page_soup.article.get_text().split()))

# get link
links.append(link.get('href'))

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

def append_datetime_from_soup(source_soup, list_with_results):
	span_datetime = str(source_soup.find("div", class_="e2-note-meta")\
	                    .find("span", class_=""))
	    
	# get string with date and time from <span> title
	date_str = re.search("\".+\"", span_datetime).group(0)
	
	# get day: one or two digits
	day = int(re.search("[\d]{1,2}", date_str).group(0))
	
	# get month as string
	month_str = str(re.search(" [\w^(\d)]+ ", date_str).group(0))
	month_str = month_str.strip()
	
	# convert string to integer
	months = ["января", "февраля", "марта", "апреля", "мая", "июня",
	          "июля", "августа", "сентября", "октября", "ноября", "декабря"]
	month_int = months.index(month_str) + 1
	
	# get year
	year = int(re.search("[\d]{4}", date_str).group(0))
	
	# get time
	time_ = re.search("[\d]{2}:[\d]{2}", date_str).group(0)
	hour = int(time_[0:2])
	minute = int(time_[3:5])
	
	# make a datetime object
	date_time = datetime.datetime(year, month_int, day, hour=hour, minute=minute)
	
	# append to the result
	list_with_results.append(date_time)

После того как получили результаты — проверьте их правильность. Сколько циклов прошёл парсер? Сколько результатов на выходе? Длина всех списков с результатами одинаковая?

Проверяем результаты:

>>> print(parse_count, len(views), len(images), len(words))
4536 4489 4489 4489

Итого 4536 проходов сделал парсер и собрал списки данных длиной 4489 записей каждая.

Списки с данными одной длины — это хорошо. Но записей больше, чем заметок, но не намного. Видимо, есть лишние или дубликаты. Разберёмся с ними на следующем этапе.

Что дальше?

Чтобы проанализировать собранные данные, воспользуемся библиотекой pandas и запихнём наши списки в одну большую таблицу на стероидах — dataframe.

dict = {"title": titles, 
       "datetime": datetimes,
        "views": views, 
        "comments": comments, 
       "length": words,
       "images": images,
        "tags": tags_as_string,
       "link": links} 

birman_frame = pd.DataFrame(data = dict)

В итоге получаем удобную для анализа структуру. Можно, например, посмотреть заметки с наибольшим количеством просмотров за всё время.

Три самых просматриваемых заметки: о грамматике, об экономике и о матемитике

На этом первая часть отчета закончена. В следующих сериях — анализ собранных данных и визуализация полученных выводов.

 55   4 mon   Python   Накодил   Сделал