CoderCastrov logo
CoderCastrov
Мотивация
просмотров
9 мин чтение
#2020 Президентские выборы

Повышение навыков парсинга веб-страниц: Извлечение голосов с президентских выборов 2020 года из новостных изображений

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

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

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

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


Поиск данных

Одним из источников информации, на которые я полагался во время недели выборов, была прямая трансляция CNN. Хорошо или плохо, было легко отслеживать результаты выборов, как в подробностях, так и поверхностно, время от времени.

"Когда у тебя есть молоток, все выглядит как гвоздь". После неудачной попытки получить данные о развитии подсчета голосов, я вскоре понял, что все данные, которые я искал, были прямо перед моими глазами. Прямые трансляции CNN были просто сборником сообщений с видеоснимками, чтобы иллюстрировать то, что говорил последний спикер.

Пример сообщения

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

Пока все идет хорошо. Давайте получим эти изображения!


Парсинг динамической живой истории

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

Президентские выборы были охвачены "только" 3 живыми историями. По одной на каждый день, и каждая из них имеет свой уникальный URL:

Эти страницы являются динамическими. Они обновляются по мере прокрутки вниз. Сначала я подумал, что мне нужно полагаться на Selenium или аналогичный инструмент для скрапинга. Но, пристально посмотрев на них, я понял, что данные живой истории загружаются одновременно, а статьи отображаются позже по требованию.

Я использую Google Chrome. Если вы откроете "Инструменты разработчика" в меню "Дополнительные инструменты" во время просмотра одной из этих страниц, вы заметите, что сразу после обновления страницы загружаются некоторые данные в формате JSON.

Вы в основном ищете GET-запрос к https://data.api.cnn.io/graphql. Вероятно, это не первый вызов, а второй.

graphql

Ответ на GET-запрос представляет собой структурированные данные в формате JSON, содержащие макеты, тексты и ссылки на медиа-файлы, используемые в живой истории. Для каждого сообщения вы также получите заголовок, дату создания, некоторые тексты и, конечно же, все ссылки на медиа-файлы (как видео, так и изображения).

Лучший способ получить доступ к этому - использовать curl с помощью инструментов разработчика Chrome. Просто скопируйте команду curl с помощью правой кнопки мыши.

graphql2

У вас должно быть что-то похожее на это:

curl 'https://data.api.cnn.io/graphql' \
  -H 'authority: data.api.cnn.io' \
  -H 'pragma: no-cache' \
  -H 'cache-control: no-cache' \
  -H 'sec-ch-ua: "Google Chrome";v="89", "Chromium";v="89", ";Not A Brand";v="99"' \
  -H 'accept: */*' \
  -H 'x-graphql-query-uuid: livestory---PostsWithGraph{"livestory_id":"h_f887fc510d5e8ae829971ae452386965","startId":null}---6cfb637bd4a95bb97e5bd7ba14b0415b7a76d0dfdc83caf87662b319f1354af9' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36' \
  -H 'x-api-key: P7LEOCujzt2RqSaWBeImz1spIoLq7dep7x983yQc' \
  -H 'content-type: application/json' \
  -H 'origin: https://www.cnn.com' \
  -H 'sec-fetch-site: cross-site' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-dest: empty' \
  -H 'referer: https://www.cnn.com/' \
  -H 'accept-language: en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7' \
  --data-raw '{"operationName":"PostsWithGraph","variables":{"livestory_id":"h_f887fc510d5e8ae829971ae452386965","startId":null},"query":"query PostsWithGraph($livestory_id: String) {\n  getLivestoryWebData(livestory_id: $livestory_id) {\n    id\n    lastPublishDate\n    lastPublishDateFormatted\n    activityStatus\n    pinnedPosts {\n      id\n      lastPublishDate\n      __typename\n    }\n    unpinnedPosts {\n      id\n      sourceId\n      lastPublishDate\n      lastPublishDateFormatted\n      headline\n      byline\n      content\n      tags\n      __typename\n    }\n    tags\n    __typename\n  }\n}\n"}' \
  --compressed

Запуск команды curl дает много информации. Не все из этого является значимым, поэтому я отфильтровал только то, что действительно имеет значение, используя jq. Это немного выходит за рамки этого блога, поэтому я также предоставляю конечные отфильтрованные JSON-файлы в своем аккаунте на GitHub (livestory05.json, livestory06.json, livestory07.json)


Исследование изображений

В случае, если вы пропустили предыдущий раздел, вы можете найти следующие файлы json на моем github аккаунте. Я также предоставил небольшой bash скрипт, чтобы воссоздать эти файлы.

Давайте исследуем данные, которые мы уже собрали.

import pandas as pdlivestories = ['livestory_05.json', 'livestory06.json', 'livestory07.json']

df = pd.concat(pd.read_json(page) for page in livestories)
df.columns = ['id', 'type', 'videoUrl', 'firstPublishedDate', 'videoId', 'thumbUrl']
df = df.set_index('firstPublishedDate')
df = df.sort_index()
df
png

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

from ipyplot import plot_images
plot_images(df.thumbUrl, labels=df.index, max_images=9)

Теперь пришло время загрузить все эти изображения для дальнейшей обработки. Вы также можете следовать коду в jupyter notebook, который я размещу на своем github аккаунте.

import time
from pathlib import Path

import requests
from PIL import Image
from tqdm.notebook import tqdm

df['localThumb'] = df.thumbUrl.apply(lambda x: Path('images') / Path(x).name)

for _, row in tqdm(df.iterrows()):
    if row['localThumb'].exists():
        continue
    url = row['thumbUrl']
    im = Image.open(requests.get(url, stream=True).raw)
    im.save(row['localThumb'])
    time.sleep(3)

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


Изображение в строку: спасение с помощью Tesseract

Теперь, когда изображения сохранены локально (или извлечены отсюда), мы можем с ними работать.

subcrops = [Image.open(x).crop((860, 910, 1490, 1060)) for x in df.localThumb[:9]]
plot_images(subcrops, labels=df.localThumb[:9])

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

from pytesseract import image_to_string
image_to_string(subcrops[0])
>>> '\n\x0c'

Что ж... Tesseract дает лучшие результаты только в том случае, если изображение содержит хорошо организованный текст и отсутствие графики. Давайте продолжим использовать фиксированное положение новостной ленты.

im = Image.open(df.localThumb[0])
im.crop((860, 910, 1340, 955))
png
image_to_string(im.crop((860, 910, 1340, 955)))>>> 'PENNSYLVANIA presipent\n\x0c'im.crop((912, 960, 1490, 1060))
png
image_to_string(im.crop((912, 960, 1490, 1060)))>>> 'TRUMP\nBIDEN\n\nGo|Go\n‘Olr\n=\n\n(051,555\n\x0c'

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

from PIL import ImageFilter
from skimage.morphology import black_tophat, rectangle

def remove_lines(z):
    zbw = (z.convert('L')
            .filter(ImageFilter.MedianFilter(size = 3))
      )
    zbt = 255 - black_tophat(np.asarray(zbw), rectangle(1, 100))
    zimg = Image.fromarray(zbt)
    return zimg

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

remove_lines(im.crop((860, 910, 1340, 955)))
png
remove_lines(im.crop((912, 960, 1490, 1060)))
png
txt = image_to_string(remove_lines(im.crop((860, 910, 1340, 955))))
txt.strip()>>> 'PENNSYLVANIA presipent'txt = image_to_string(remove_lines(im.crop((912, 960, 1490, 1060))))
txt.strip()>>> 'TRUMP 3,215,969\nBIDEN 3,051,555'

Все еще есть несколько ошибок, но теперь гораздо лучше! По крайней мере, имена кандидатов и числа теперь извлечены.


Сборка всего вместе

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

txt_states = [
    image_to_string(
        remove_lines(
            Image.open(localpath).crop((860, 910, 1340, 955))
    )).strip() for localpath in df.localThumb]txt_states>>> ['PENNSYLVANIA президент',
 'PENNSYLVANIA президент',
 'GEORGIA президент',
 'PENNSYLVANIA президент',
 'PENNSYLVANIA президент',
 'GEORGIA президент',
 'PENNSYLVANIA президент',
 'U.S. ПОПУЛЯРНОЕ ГОЛОСОВАНИЕ президент',
 'U.S. ПОПУЛЯРНОЕ ГОЛОСОВАНИЕ президент',
 'АРИЗОНА президент',
 'PENNSYLVANIA президент',
 'АРИЗОНА президент',
 'PENNSYЛВАНИЯ президент',
 'АРИЗОНА президент',
 'НЕВАДА президент',
 'PENNSYLVANIA президент',
 'PENNSYЛВАНИЯ президент',
 ...

Подсчет голосов требует более тщательной обработки, так как текстовый вывод из Tesseract требует дополнительной обработки перед использованием.

txt_votes = [
    image_to_string(
        remove_lines(
            Image.open(localpath).crop((912, 960, 1490, 1060))
    )).strip() for localpath in df.localThumb
]
txt_votes>>> ['ТРАМП 3,215,969\nБАЙДЕН 3,051,555',
 'ТРАМП 3,215,969\nБАЙДЕН 3,051,555',
 'ТРАМП 2,431,724\nБАЙДЕН 2,413,184',
 'ТРАМП 3,215,969\nБАЙДЕН 3,051,555',
 'ТРАМП 3,215,969\nБАЙДЕН 3,051,555',
 'ТРАМП 2,432,424\nБАЙДЕН 2,413,836',
 'ТРАМП 3,215,969\nБАЙДЕН 3,051,555',
 'БАЙДЕН 11,771,893\nТРАМП 68,119,889',
 'БАЙДЕН 11,771,893\nТРАМП 68,119,889',
 'БАЙДЕН 1,469,341\nТРАМП 1,400,951',
 'ТРАМП 3,224,422\nБАЙДЕН 3,088,796',
 'БАЙДЕН 1,469,341\nТРАМП 1,400,951',
 'ТРАМП 3,228,946\nБАЙДЕН 3,113,877',
 ...

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

import revotes = [re.findall('([A-Z]+) (\d+)', line.replace(',', '')) for line in txt_votes]
votes>>> [[('ТРАМП', '3215969'), ('БАЙДЕН', '3051555')],
 [('ТРАМП', '3215969'), ('БАЙДЕН', '3051555')],
 [('ТРАМП', '2431724'), ('БАЙДЕН', '2413184')],
 [('ТРАМП', '3215969'), ('БАЙДЕН', '3051555')],
 [('ТРАМП', '3215969'), ('БАЙДЕН', '3051555')],
 [('ТРАМП', '2432424'), ('БАЙДЕН', '2413836')],
 [('ТРАМП', '3215969'), ('БАЙДЕН', '3051555')],
 [('БАЙДЕН', '11771893'), ('ТРАМП', '68119889')],
 [('БАЙДЕН', '11771893'), ('ТРАМП', '68119889')],
 [('БАЙДЕН', '1469341'), ('ТРАМП', '1400951')],
 [('ТРАМП', '3224422'), ('БАЙДЕН', '3088796')],
 [('БАЙДЕН', '1469341'), ('ТРАМП', '1400951')],
 [('ТРАМП', '3228946'), ('БАЙДЕН', '3113877')],
 [('БАЙДЕН', '1469341'), ('ТРАМП', '1400951')],
 [('ДЕН', '604251'), ('ТРАМП', '592')],
 [('ТРАМП', '3232066'), ('БАЙДЕН', '3120697')],
 [('ТРАМП', '3234183'), ('БАЙДЕН', '3125494')],
 ....votes = pd.DataFrame(dict(x) for x in votes)
votes
png

Иногда Tesseract пропускает начало слова 'БАЙДЕН'. Вот почему у нас есть третий столбец. Давайте исправим это.

votes['БАЙДЕН'] = votes['БАЙДЕН'].where(votes['ДЕН'].isna(), other=votes['ДЕН'])
del votes['ДЕН']
votes
png

Наконец, давайте соберем все вместе:

votes.index = df.index
df = df.assign(state=txt_states).join(votes)
df[['state', 'БАЙДЕН', 'ТРАМП']]
png

Не все изображения были действительными, и некоторые данные не были восстановлены, что привело к некоторым NaN в таблице выше.

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

votes_pennsylvania = df.loc[df.state.str.contains('PENNSYLVANIA'), ['ТРАМП', 'БАЙДЕН']].astype('int')

fig, ax = plt.subplots(figsize=(9,7))
votes_pennsylvania.plot(ax=ax)ax.yaxis.set_major_formatter(plt.matplotlib.ticker.StrMethodFormatter('{x:,.0f}'))
plt.title('Эволюция голосов в Пенсильвании, опубликованная на CNN livestory')
plt.ylabel('Голоса')
plt.xlabel('Дата публикации')
fig.autofmt_xdate()
plt.tight_layout()

Заключение

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

Мы также могли бы провести более глубокий анализ, изучив все видео. Во время обсуждения докладчиков новостная лента регулярно обновлялась, и я уверен, что там можно найти еще больше данных. Методология останется той же, но нам нужно будет обрабатывать каждый n-й кадр из видео. Думаю, вы поняли идею.

Надеюсь, что этот блог вдохновил вас на то, как использовать изображения для сбора еще большего количества данных.