ru en

Парсим раздел science на reddit.com v 1.0

Опубликовано 01.08.2018 в education • 7 min read

Данная работа посвящена парсингу сайта reddit.com, секция - science для создания базы данных постов. Цель работы - накопление информации. Полученную информацию можно в дальнейшем использовать для проведения семантического анализа и практике в анализе Big Data. Поставленная цель включает в себя набор задач:

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

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

example Рис. 1. Пример выполнения скрипта.

1. Среда разработки и библиотеки

Основным этапом подобного рода работ является создание среды разработки и нужного окружения. Однако эта тема достаточно обширна и, что более важно, сугубо индивидуальна для каждого разумного существа. Мои наработки: использование python, среды разработки PyCharm IDE или Jupyter notebook. Для ведения заметок - редактор atom. Хотя в конце-концов, рабочим окружением может служить и обычный блокнот с командной строкой.

Основой для данной работы, послужили интересные и полезные статьи: 1, 2 и, конечно же, Google.

Обозначу еще несколько авторских особенностей:

  • код написан, тестирован и работал в системе Linux Ubuntu 16.04 x64;
  • сейчас код используется в Arch Linux и модернизирован для python 3.8;
  • используются Python 2.7 и Firefox Quantum 58.0.1 (64 bit).

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

Выбор необходимых библиотек

    #!/usr/bin/env python
    # -*- coding: utf-8 -*-

    import requests
    import json
    from fake_useragent import UserAgent  # маскировка под пользователя

    import pandas as pd  # создание базы данных

    from PIL import Image  # для корректного сохранения картинки облака слов на жесткий диск
    from wordcloud import WordCloud  # для построения облака слов
    import numpy as np
    from os import path

    import pymorphy2  # нормализация слов
    from nltk.corpus import stopwords  #
    from collections import Counter

    import time
    import re
    import random

Это - своеобразная “подготовка оборудования и реактивов”. Основными объектами исследования являются: сайт и набор данных (для чего нужны библиотеки request, json, fake_user). В качестве полезного действия предлагается построить облако наиболее часто встречающихся слов — для этого так же нужны свои библиотеки (wordcloud, numpy), в том числе и для работы с текстом (nltk, collection, pymorphy). Логика работы: необходимо получить информацию с сайта (парсинг), обработать ее, добавить ее в базу данных, сохранить базу данных на диске, рассчитать частоту слов в базе данных, построить облако слов (полезный результат).

Стоит отдельно отметить возможность запуска данного кода непосредственно из командной строки без указания интерпретатора (#!/usr/bin/env python). При этом вторая часть заголовка нужна для корректной работы с форматом utf-8 и unicode (# -*- coding: utf-8 -*-).

2. Парсинг сайта

Мы выбрали объекта исследования и подготовили все необходимое для нашей экспериментальной работы (раздел 1). Теперь нужно разобраться с идеологией получения и загрузки данных. Одним из самых очевидных и универсальных способов является непосредственный парсинг html-кода страницы — для чего необходимо указать адрес самой страницы и получить ее код. Однако так мы получим информацию, лежащую на поверхности (видимую пользователю). Этого обычно достаточно, но нередко бывает полезно почитать о дополнительных возможностях сайта.

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

Проверим это на практике. Попробуем открыть интересующую страницу в json формате в браузере (например, https://www.reddit.com/r/science/new/.json). Стоит обратить внимание на более четкую структуры и наличие дополнительной информации о новостях, расположенных на странице. Таким образом, имеет смысл выбрать наиболее информативный подход и парсить json версии страниц.

Для начала. Загрузим данные:

    # For all news
    def read_js(req):
        data = None
        try:
            data = json.loads(req.text)
        except ValueError:
            print 'error in js'
           # TODO: if rise this error — json hame a errors itself, need to hand-parsing
        return data
    def collect_news(num_news=100):
        url = 'https://www.reddit.com/r/science/new/.json'
        data_all = []
        while len(data_all) < num_news:
            time.sleep(2)
            url = 'https://www.reddit.com/r/science/new/.json'
            if len(data_all) != 0:
                last = data_all[-1]['data']['name']
                url = 'https://www.reddit.com/r/science/new/.json?after=' + str(last)
            req = requests.get(url, headers={'User-Agent': UserAgent().chrome})
            json_data = read_js(req)
            data_all += json_data['data']['children']
        keys_all = data_all[-1]['data'].keys()
        tidy_data = {}
        for k in keys_all:
            tidy_data[k] = []
        for d in data_all:
            data = d['data']
            for k in tidy_data:
                if k not in data:
                    tidy_data[k] += [None]
                else:
                    tidy_data[k] += [data[k]]
        return tidy_data
    num_post = 200
    tidy_data = collect_news(num_post)
    # print "raw new data len: ", len(tidy_data['title'])

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

На выходе функции collect_news получаем словарь для хранения характеристических данных всех новостей. Ключами словаря являются общие свойства последнего найденного поста (предполагаем, что свойства всех постов одинаковы). Каждому ключу соответствует список. Длина списка определяется количеством выгружаемых новостных постов (по умолчанию 100). Каждый такой список состоит из списков, в которых хранится информация по данному ключу из одного поста. Если для какого либо поста нет такого свойства, то в список заноситься список с None. Такая логика выбрана для того, чтобы в списке были одинаковые объекты (list), а каждый список ключа имел одинаковый размер (это понадобится нам для дальнейшего создания Data Frame).

Второй функцией read_js является парсинг json формата с помощью соответствующей библиотеки.

Стоит обратить внимание, что при парсинге json подразумевается, что формат файла правильный. Если это не так, то функция вернет None и код перестанет работать. Это отличная возможность проявить свои навыки, username, и переписать код так, что бы он работал и при возникновении данного исключения.

Рассмотрим получение информации с сайта более подробно. Для предотвращения бана (запрета на посещение сайта) — используем подпись запроса, стандартную для обычного пользователя. Для этого и нужна библиотека fake_useragent. Для начала, при достаточно больших интервалах между запросами (около 1 с, их можно рандомизировать) и относительно небольшим объемом скачиваемых страниц, этого будет достаточно.

Далее дело за малым — сохранить полученную информацию в виде матрицы “объекты-признаки” для дальнейшего использования.

3. Преобразование и сохранение данных

Основная часть написана, остается заняться накоплением данных, для чего отлично подойдет библиотека pandas.

    # Add data to exist df and save it
    def log_write(feature, value=None):
        # Write data to log file
        f_log = open('/home/username/scripts/reddit_science_log.txt', 'a')
        f_log.write(feature + value)
        f_log.close()

    # Start log file
    log_write('', str(time.asctime()) + '\t')


    len_old = 0
    len_new = 0
    try:
        final_df_new = pd.DataFrame.from_dict(tidy_data, orient='columns')
        final_df_old = pd.read_pickle('/home/username/scripts/science_reddit_df')  # load
        len_old = len(final_df_old.index)
        # print 'old len: ', len_old, ' new df len: ', len(final_df_new.index)
        final_df_old = final_df_old.append(final_df_new, ignore_index=True)
        final_df_old.drop_duplicates(subset=['id'], inplace=True)
        final_df = final_df_old.copy()
        len_new = len(final_df.index)
        # print 'final df len: ', len_new, '; len added: ', len_new-len_old
        log_write('\t new news message: ', str(len_new - len_old))
    except IOError:
        # Create new file
        final_df = pd.DataFrame.from_dict(tidy_data, orient='columns')

    # Save df to file
    final_df.to_pickle('/home/username/scripts/science_reddit_df')  # save

Вместе с проверкой существования базы данных (science_reddit_df) мы прописали простую функцию ведения логов log_write, которая позволит нам наблюдать за поведением нашего кода. Вид файла с логами:

Tue Jan 23 09:35:56 2018 new news message: 155

Tue Jan 23 09:39:21 2018 new news message: 0

Tue Jan 23 22:41:39 2018 new news message: 33

Wed Jan 24 09:23:09 2018 new news message: 11

Думаю, тут все понятно.

Логика работы по созданию базы данных научных новостей выглядит как: получение новой информации — проверка возможности открытия сохраненной базы данных — создание новой базы данных — объединение двух Data Frames — удаление дублирующихся строк (по столбу id-новости) — сохранение обновленной базы данных.

4. Построение облака слова

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

    ind_all = final_df.index
    all_text = final_df.loc[ind_all[-num_post:], ['title']]
    tag_re = re.compile(r'(<!--.*?-->|<[^>]*>)')
    # clear each post from tag and whitespace
    all_text = [re.sub(' +',' ',tag_re.sub(' ', x[0])) for x in all_text.values]
    list_in = [all_text]
    list_out = ['']

    for t in range(len(list_in)):
        self_messages = list_in[t]
        str_data = ' '.join(self_messages)
        str_data = str_data.lower()
        # clear from "bad" symbols and numbers
        def checkGood(symb):
            good1 = 'qwertyuiopasdfghjklzxcvbnm1234567890'.decode('utf-8')
            good2 = u'qwertyuiopasdfghjklzxcvbnm1234567890'
            if symb in good1:
                return True
            elif symb in good2:
                return True
            else:
                return False
        text = ''
        for i in str_data:
            if i == ' ' or i == '\n':
                text += ' '
            else:
                if checkGood(i):
                    text += i
                else:
                    text += ''
        text = re.sub(' +',' ', text)
        str_data = text[:]
        # normalize of words
        morph = pymorphy2.MorphAnalyzer()
        text = ''
        for i in str_data.split(' '):
            p = morph.parse(i)[0]
            text += p.normal_form + ' '
        str_data = text[:]

        # stop words check
        stop_words = stopwords.words('english')
        stop_words.extend([
            u'new', u'study', u'may'
        ])
        words = str_data.split(' ')
        w_before = len(words)
        words = [i for i in words if i not in stop_words]
        w_after = len(words)
        log_write('\t raw and tidy words: ', str([w_before, w_after]) + '\n')
        str_data = ' '.join(words)
        list_out[t] = str_data[:]

    str_news = list_out[0]

    gear_mask = np.array(Image.open("/home/username/scripts/gear2.png"))
    wc = WordCloud(background_color="black", mask=gear_mask, collocations=False)
    # generate word cloud
    wc.generate(str_news)

    def grey_color_func(word, font_size, position, orientation, random_state=None,
                        **kwargs):
        return "hsl(0, 0%%, %d%%)" % random.randint(60, 100)

    default_colors = wc.to_array()
    wc2 = wc.recolor(color_func=grey_color_func, random_state=3)

    # store to file
    wc2.to_file("/home/username/scripts/science_reddit_raw.png")

Логика кода следующая: выбор всех заголовков из базы данных - очистка текстов от html-тэгов и лишних пробелов — обработка всего текста в цикле — удаление всего, что не буква* - повторное удаление лишних пробелов — морфологизация слов (приведение к “нормальному” виду) — удаление “стоп” слов (наиболее часто встречающихся, таких как артикли, местоимения и т.д.) - построение и выдача одной большой строки из слов и пробелов между ними.

* - поскольку в Python 2.7 есть определенные проблемы с кодировками, используем на всякий случай два способа задания кодировки.

Полученную строку можно использовать для построения облака слов с помощью библиотеки wordcloud. После построения облака, перекрашиваем его в нужный цвет (в моем случае - это 50 оттенков белого). Красиво оформленное облако можно наложить на определенную маску (изображение). При этом маска должна быть бинаризованной (черно-белый формат из двух типов пикселей: 0 и 255). Для этого есть несколько сайтов, которые легко нагуглить. Я использовал этот: 3, операция называется threshold.

Напоследок, маленький подарок для пользователей Ubuntu (а может и не только для них, если подумать). Полученное изображение можно перевести в изображение с прозрачным фоном простой терминальной командой:

convert ~/news_raw.png -transparent black ~/news_ready.png

Так же, можно организовать автоматический парсинг сайта через автозапуск:

    Menu — Startup Application — Add:
    Name: reddit_science_reaser
    Command: sh -c «sleep 600 && /FULL_PATH/science_reddit.py»

Выставляем задержку на исполнение в секундах, что бы система успела подключиться к интернету и выполняем скрипт (для выполнения можно указать «python /FULL_PATH/science_reddit.py»).

5. Заключение

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

  • получение информации с сайта;
  • парсинг информации и выделение ключевых свойств для каждой новости;
  • сохранение каждой новости в матрице “объекты-признаки”.

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

5.1 Пути улучшения (задачи для самостоятельного решения)

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

  • написание обработчика ошибок (проверка значений, возвращаемых функциями и реакция, если это None или не целевое значение);
  • обработка текста в виде отдельной функции;
  • использовать объектно-ориентированный подход (использовать классы для новостей);
  • реализация html-парсинга с библиотекой beautiful soup;
  • написание пользовательского интерфейса (GUI);
  • переписать код на python 3.

Спасибо, что были с нами и приятного дня!