Весь Хабр в одной базе

Добрый день. Прошло уже 2 года с момента написания последней статьи про парсинг Хабра, и некоторые моменты изменились.


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


Часть 2 | mega.nz | GitHub


Первая версия парсера. Один поток, много проблем


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


one_thread.py
from bs4 import BeautifulSoup
import sqlite3
import requests
from datetime import datetime

def main(min, max):
    conn = sqlite3.connect('habr.db')
    c = conn.cursor()
    c.execute('PRAGMA encoding = "UTF-8"')
    c.execute("CREATE TABLE IF NOT EXISTS habr(id INT, author VARCHAR(255), title VARCHAR(255), content  TEXT, tags TEXT)")

    start_time = datetime.now()
    c.execute("begin")
    for i in range(min, max):
        url = "https://m.habr.com/post/{}".format(i)
        try:
            r = requests.get(url)
        except:
            with open("req_errors.txt") as file:
                file.write(i)
            continue
        if(r.status_code != 200):
            print("{} - {}".format(i, r.status_code))
            continue

        html_doc = r.text
        soup = BeautifulSoup(html_doc, 'html.parser')

        try:
            author = soup.find(class_="tm-user-info__username").get_text()
            content = soup.find(id="post-content-body")
            content = str(content)
            title = soup.find(class_="tm-article-title__text").get_text()
            tags = soup.find(class_="tm-article__tags").get_text()
            tags = tags[5:]
        except:
            author,title,tags = "Error", "Error {}".format(r.status_code), "Error"
            content = "При парсинге этой странице произошла ошибка."

        c.execute('INSERT INTO habr VALUES (?, ?, ?, ?, ?)', (i, author, title, content, tags))
        print(i)
    c.execute("commit")
    print(datetime.now() - start_time)

main(1, 490406)

Всё по классике — используем Beautiful Soup, requests и быстрый прототип готов. Вот только…


  • Скачивание страницы идет в один поток


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


  • Парсинг первых 100 000 статей у меня занял 8 часов.



Дальше я нахожу статью пользователя cointegrated, которую я прочитал и нашел несколько лайфхаков, позволяющих ускорить сей процесс:


  • Использование многопоточности ускоряет скачивание в разы.
  • Можно получать не полную версию хабра, а его мобильную версию.
    Например, если статья cointegrated в десктопной версии весит 378 Кб, то в мобильной уже 126 Кб.

Вторая версия. Много потоков, временный бан от Хабра


Когда я прошерстил интернет на тему многопоточности в python, выбрал наиболее простой вариант с multiprocessing.dummy, то я заметил, что вместе с многопоточностью появились проблемы.


SQLite3 не хочет работать с более чем одним потоком.
Фиксится check_same_thread=False, но эта ошибка не единственная, при попытке вставки в базу иногда возникают ошибки, которые я так и не смог решить.


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


Хабр начинает банить за использование более чем трех потоков.
Особо рьяные попытки достучаться до Хабра могут закончится баном ip на пару часов. Так что приходится использовать лишь 3 потока, но и это уже хорошо, так время перебора 100 статей уменьшается с 26 до 12 секунд.


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


three_threads_v1.py
from bs4 import BeautifulSoup
import requests
import os, sys
import json
from multiprocessing.dummy import Pool as ThreadPool
from datetime import datetime
import logging

def worker(i):
    currentFile = "files\\{}.json".format(i)

    if os.path.isfile(currentFile):
        logging.info("{} - File exists".format(i))
        return 1

    url = "https://m.habr.com/post/{}".format(i)

    try: r = requests.get(url)
    except:
        with open("req_errors.txt") as file:
            file.write(i)
        return 2

    # Запись заблокированных запросов на сервер
    if (r.status_code == 503):
        with open("Error503.txt", "a") as write_file:
            write_file.write(str(i) + "\n")
            logging.warning('{} / 503 Error'.format(i))

    # Если поста не существует или он был скрыт
    if (r.status_code != 200):
        logging.info("{} / {} Code".format(i, r.status_code))
        return r.status_code

    html_doc = r.text
    soup = BeautifulSoup(html_doc, 'html5lib')

    try:
        author = soup.find(class_="tm-user-info__username").get_text()

        timestamp = soup.find(class_='tm-user-meta__date')
        timestamp = timestamp['title']

        content = soup.find(id="post-content-body")
        content = str(content)
        title = soup.find(class_="tm-article-title__text").get_text()
        tags = soup.find(class_="tm-article__tags").get_text()
        tags = tags[5:]

        # Метка, что пост является переводом или туториалом.
        tm_tag = soup.find(class_="tm-tags tm-tags_post").get_text()

        rating = soup.find(class_="tm-votes-score").get_text()
    except:
        author = title = tags = timestamp = tm_tag = rating = "Error" 
        content = "При парсинге этой странице произошла ошибка."
        logging.warning("Error parsing - {}".format(i))
        with open("Errors.txt", "a") as write_file:
            write_file.write(str(i) + "\n")

    # Записываем статью в json
    try:
        article = [i, timestamp, author, title, content, tm_tag, rating, tags]
        with open(currentFile, "w") as write_file:
            json.dump(article, write_file)
    except:
        print(i)
        raise

if __name__ == '__main__':
    if len(sys.argv) < 3:
        print("Необходимы параметры min и max. Использование: async_v1.py 1 100")
        sys.exit(1)
    min = int(sys.argv[1])
    max = int(sys.argv[2])

    # Если потоков >3
    # то хабр банит ipшник на время
    pool = ThreadPool(3)

    # Отсчет времени, запуск потоков
    start_time = datetime.now()
    results = pool.map(worker, range(min, max))

    # После закрытия всех потоков печатаем время
    pool.close()
    pool.join()
    print(datetime.now() - start_time)

Третья версия. Финальная


Отлаживая вторую версию, я обнаружил, что у Хабра, внезапно, есть API, к которому обращается мобильная версия сайта. Загружается оно быстрее, чем мобильная версия, так как это просто json, который даже парсить особо не нужно. В итоге я решил заново переписать мой скрипт.


Итак, обнаружив по этой ссылке API, можно приступать к его парсингу.


three_threads_v2.py
import requests
import os, sys
import json
from multiprocessing.dummy import Pool as ThreadPool
from datetime import datetime
import logging

def worker(i):
    currentFile = "files\\{}.json".format(i)

    if os.path.isfile(currentFile):
        logging.info("{} - File exists".format(i))
        return 1

    url = "https://m.habr.com/kek/v1/articles/{}/?fl=ru%2Cen&hl=ru".format(i)

    try:
        r = requests.get(url)
        if r.status_code == 503:
            logging.critical("503 Error")
            return 503
    except:
        with open("req_errors.txt") as file:
            file.write(i)
        return 2

    data = json.loads(r.text)

    if data['success']:
        article = data['data']['article']

        id = article['id']
        is_tutorial = article['is_tutorial']
        time_published = article['time_published']
        comments_count = article['comments_count']
        lang = article['lang']
        tags_string = article['tags_string']
        title = article['title']
        content = article['text_html']
        reading_count = article['reading_count']
        author = article['author']['login']
        score = article['voting']['score']

        data = (id, is_tutorial, time_published, title, content, comments_count, lang, tags_string, reading_count, author, score)
        with open(currentFile, "w") as write_file:
            json.dump(data, write_file)

if __name__ == '__main__':
    if len(sys.argv) < 3:
        print("Необходимы параметры min и max. Использование: asyc.py 1 100")
        sys.exit(1)
    min = int(sys.argv[1])
    max = int(sys.argv[2])

    # Если потоков >3
    # то хабр банит ipшник на время
    pool = ThreadPool(3)

    # Отсчет времени, запуск потоков
    start_time = datetime.now()
    results = pool.map(worker, range(min, max))

    # После закрытия всех потоков печатаем время
    pool.close()
    pool.join()
    print(datetime.now() - start_time)

В нем присутствует поля, относящиеся как к самой статье, так и к автору, который её написал.


API.png


Я не стал дампить полный json каждой статьи, а сохранял лишь нужные мне поля:


  • id
  • is_tutorial
  • time_published
  • title
  • content
  • comments_count
  • lang — язык, на котором написана статья. Пока что в ней только en и ru.
  • tags_string — все теги из поста
  • reading_count
  • author
  • score — рейтинг статьи.

Таким образом, используя API, я уменьшил время выполнения скрипта до 8 секунд на 100 url.


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


three_threads_parser.py
import json
import sqlite3
import logging
from datetime import datetime

def parser(min, max):
    conn = sqlite3.connect('habr.db')
    c = conn.cursor()
    c.execute('PRAGMA encoding = "UTF-8"')
    c.execute('PRAGMA synchronous = 0') # Отключаем подтверждение записи, так скорость увеличивается в разы.
    c.execute("CREATE TABLE IF NOT EXISTS articles(id INTEGER, time_published TEXT, author TEXT, title TEXT, content TEXT, \
    lang TEXT, comments_count INTEGER, reading_count INTEGER, score INTEGER, is_tutorial INTEGER, tags_string TEXT)")
    try:
        for i in range(min, max):
            try:
                filename = "files\\{}.json".format(i)
                f = open(filename)
                data = json.load(f)

                (id, is_tutorial, time_published, title, content, comments_count, lang,
                 tags_string, reading_count, author, score) = data

                # Ради лучшей читаемости базы можно пренебречь читаемостью кода. Или нет?
                # Если вам так кажется, можно просто заменить кортеж аргументом data. Решать вам.

                c.execute('INSERT INTO articles VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (id, time_published, author,
                                                                                        title, content, lang,
                                                                                        comments_count, reading_count,
                                                                                        score, is_tutorial,
                                                                                        tags_string))
                f.close()

            except IOError:
                logging.info('FileNotExists')
                continue

    finally:
        conn.commit()

start_time = datetime.now()
parser(490000, 490918)
print(datetime.now() - start_time)

Статистика


Ну и традиционно, напоследок можно извлечь немного статистики из данных:


  • Из ожидаемых 490 406 было скачано лишь 228 512 статей. Получается, что более половины(261894) статей на хабре было скрыто или удалено.
  • Вся база, состоящая из почти полумиллиона статей, весит 2.95 Гб. В сжатом виде — 495 Мб.
  • Всего на Хабре авторами являются 37804 человек. Напоминаю, что это статистика только из живых постов.
  • Самый продуктивный автор на Хабре — alizar — 8774 статьи.
  • Статья с самым большим рейтингом — 1448 плюсов
  • Самая читаемая статья — 1660841 просмотров
  • Самая обсуждаемая статья — 2444 комментария

Ну и в виде топов
Топ 15 авторов

Топ 15 по рейтингу

Топ 15 читаемых

Топ 15 обсуждаемых
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 51

    0

    Отсюда получилось-бы быстрее ;)

      0
      Хм… Но я не вижу, где там текст статьи. И гугл не позволил мне обнаружить другие xml, кроме такого же списка, но уже новостей. Тыкните, пожалуйста =)
        0
        Ну там и не будет текстов, но позволит сократить время на поиск актуальных страниц ;)
      +2
      Эх, и опять без комментариев. Раньше комментарии были зачастую полезней самих статей.
        0
        Нашел еще такой url, с него вполне можно стянуть комментарии к статье. Сейчас займусь этим =)
          +1
          Статьи с комментариями, сожмите каким-нибудь 7zip в хорошем формате и выложите где-нибудь, на левом dropbox-е или google drive.

          Ну и совсем хорошо, если раз в месяц обновлять.
            0
            Поддерживаю. Хотелось бы иметь локальную базу Хабра. Уж больно много тут интересной информации.
              +1
              Просто база комментариев вам вряд ли что даст — это сырые данные. Грубо говоря, там всего лишь текст комментария, указатели и метаданные об авторе. Здесь об этом написано подробнее. Вскоре я напишу статью, где сделаю маленький аналог СоХабра для удобного чтения базы статей и комментариев.
            0
            Теперь все есть. Осталось только документацию написать =)
            Как юзать
            image
            И в принципе у вас уже есть база на 10к статей
            +3
            Хабр, сделай api!
              0

              В статье же написано, что API есть?

                0
                Вот, что значит не поставить смайлик )
                  +1

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

                +1
                Давно хочу что-то подобное сделать. Иметь возможность скачивать сайты не в виде html страниц (как это делает teleport pro и подобные программы), а в некую базу (ту же sqlite), для которой можно написать GUI-оболочку. Т.е. в основном с ориентацией на форумы и обсуждения.
                В инете есть множество ценной информации, представленной в крайне неудобном виде. Например гугл-группы обсуждения предложений в стандарт C++. Еще несколько старых форумов, которые ценны информацией, но есть риск что они исчезнут ввиду того что сейчас там практически никого нет.
                Кто нибудь знает, существуют ли библиотеки для С++ (более привычного мне языка) для удобного скачиваная и парсинга веб-контента?
                  +1
                  Добрый день!
                  Набор C++ библиотек с github, которыми лично пользовался, думаю поможет :)
                  Для парсинга html могу посоветовать:
                  1. github.com/google/gumbo-parser
                  2. github.com/lexborisov/myhtml
                  Для работы с json:
                  1.https://github.com/nlohmann/json
                  Для запросов:
                  1. github.com/whoshuu/cpr — обертка над libcurl
                  +1

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

                    –1
                    Вся многопоточность здесь ограничена 3 потоками, если нет проксей в кармане. Кроме того, краулер нужен, чтобы «ходить» по вложенным ссылкам, здесь этого не требуется.
                    Кстати, было бы интересно сравнить скорость работы с пауком.
                    +3
                    Для монополизации доступа потоков/процессов к ресурсу можно использовать multiprocessing.Queue

                    Работать с очередями предельно просто. У вас будет 3 потока-поставщика контента, и 1 поток-потребитель, который и будет писать в базу или еще куда. Не требуется даже заморачиваться с блокировками.

                    Пример
                    Взят отсюда
                    from multiprocessing import Process, Queue
                     
                     
                    sentinel = -1
                     
                    def creator(data, q):
                        """
                        Creates data to be consumed and waits for the consumer
                        to finish processing
                        """
                        print('Creating data and putting it on the queue')
                        for item in data:
                            q.put(item)
                     
                     
                    def my_consumer(q):
                        """
                        Consumes some data and works on it
                        In this case, all it does is double the input
                        """
                        while True:
                            data = q.get()
                            print('data found to be processed: {}'.format(data))
                        
                            processed = data * 2
                            print(processed)
                        
                            if data is sentinel:
                                break
                     
                     
                    if __name__ == '__main__':
                        q = Queue()
                        data = [5, 10, 13, -1]
                        
                        process_one = Process(target=creator, args=(data, q))
                        process_two = Process(target=my_consumer, args=(q,))
                        
                        process_one.start()
                        process_two.start()
                        
                        q.close()
                        q.join_thread()
                        
                        process_one.join()
                        process_two.join()
                    



                      0
                      SQLite3 не хочет работать с более чем одним потоком

                      Внезапно!
                      Документацию почитать религия запретила?


                      На самом деле это древняя "фича" SQLite — он невыносимо реактивен в сравнении с другими RDB, но — только в одно лицо. При записи блокируется вся база, например.

                        0
                        Сейчас почитаю. Я пытался, но потом решил, что добавлять данные в базу уже после скачивания будет проще.
                          +1

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

                            0

                            Это почему же? Современные файловые системы нормально работают с папками на несколько сот тысяч файлов, никаких замедлений не замечено (в Windows по крайней мере). Если конечно вы не считаете замедлением, например, отображение или сортировку списка файлов этой папки в проводнике ))

                              0
                              Хм, кроме этого конкретно видна иногда начинает заметно тупить, если в папке темп несколько сот тысяч мелких файлов и простая очистка её помогает.
                                0

                                В Винде создание нового файла в папке со множеством файлов с похожими именами может замедляться из-за поведения по умолчанию, при котором создаются дополнительные DOS-совместимые имена файлов в формате 8.3 (8 символов имя файла + 3 символа расширение). Если файлы называются 000000001, 00000002 и т.д., то для DOS-имени системе придется перебирать все файлы папки, чтобы найти уникальное число для подстановки в укороченное имя файла (чтобы сохранить уникальность имен).
                                Это поведение можно отключить через fsutil behavior set disable8dot3 1

                        +2
                        но тогда и время выполнения скрипта увеличится в разы

                        SQLite при commit ожидает подтверждение записи данных от диска, что является наиболее долгой операцией, поскольку упирается в физическое ограничение диска. Однако SQLite имеет возможность отказаться от этого, выполнив
                        pragma synchronous = 0

                        В вашем случае, я считаю такое вполне допустимым. Скорость вставки увеличится в разы.
                        Можно подкрутить кеш, отключить индекс при загрузке и прочее, чтобы вставлять со скоростью ~100K записей/сек, но зачем?

                        Проблему много-поточности можно решить так: http-читатели скидывают данные в буфер, а отдельный процесс, который монопольно пишет в SQLite, периодически данные оттуда забирает и сохраняет в базу.
                          0
                          Из ожидаемых 490 406 было скачано лишь 228 512 статей. Получается, что более половины(261894) статей на хабре было скрыто или удалено.
                          Ещё вариант, что часть висят в черновиках даже без публикации.
                            0
                            Верно, к примеру у меня не меньше полудюжины статей в черновиках в разной степени готовности.
                              0
                              а ещё черновики можно использовать для заметок)
                            0
                            Из ожидаемых 490 406 было скачано лишь 228 512 статей. Получается, что более половины(261894) статей на хабре было скрыто или удалено.


                            А вы учитывали, что номера статьей не совсем порядковые? Когда я в свое время планировал сделать дамп, тест показал, что Хабр периодически меняет нумерацию — в какие-то периоды статьям присваиваются четные номера, в другие — нечетные. Иначе говоря, в вашем случае верней было б сказать, что отсутствовало около 17к статьей.
                              +1
                              В период разделения на «Гиктаймс» и «Хабрахабр» — на гиктаймсе были все нечетные, а на хабре четные (или наоборот), теперь опять все в кучу.
                              0
                              Я делаю немного по другому. Скачиваю все нужные мне ссылки сайта. А потом уже парсю локально. Потом все скачанные html файлы архивирую, и в облако. Если мне нужны еще какие-то данные (например, время комментариев), я не заново скачиваю ссылки, а скачиваю архив из облака, и достаю нужные мне данные из локальных файлов, что многократно ускоряет получение нужных мне данных.
                                0
                                Я вот уже лет 6 паршу хабр (ссылка нa мой «аггрегатор» у меня в профиле, если кому интересно)
                                Одно мне не понятно — зачем вы целиком скачиваете статьи, если для вашей статистики достаточно парсить только «списочные» страницы (типа `/hub/programming/`)?
                                У вас в статистике же сам контент статей никак не используется?
                                  +1
                                  профиль пуст
                                    0
                                    Ого, не ожидал что хабр другим пользователям как-то иначе мой профиль показывает.
                                    Если что, не реклама: hbrscnr.club
                                      0
                                      Не открывается, передаём привет РКН и диджитал оцеану.
                                        +1
                                        Ага, это еще со времен войны с телеграмом, на некоторых провайдерах до сих пор не работает :(
                                    0
                                    Моей основной целью было спарсить текст статьи с заголовком, чтобы позже любой мог сделать аналог СоХабра, все остальное было добавлено по принципу «Потому что есть». В первой версии вообще парсился только контент, название, автор и теги.
                                      0
                                      Главной фишкой СоХабра было еще то, что он парсил постоянно и сохранял даже удаленные статьи. Жалко что закрылся :(
                                    +1
                                    /kek/нул с адреса API
                                      0
                                      Если всю спарсированную дату записать на CSV файл и затем после парсинга импортировать файл в базу данных, то все это дело будет медленнее чем варинт, где подключаешься к бд и имротируешь дату с помощью query?
                                        0
                                        Если делать в лоб — то запись сразу в базу будет медленнее, чем записать, а потом прочитать. Но, как уже выше указал justhabrauser, можно настроить бд и увеличить её скорость, и она, скорее всего, будет быстрее, чем чтение из папки с 500 тыс. файлов. Но я этого, пока что, не проверял и утверждать, что это истина, не могу.
                                          0
                                          Спасибо за ответ.
                                        0

                                        А какой топ статей по нахождению в закладках у пользователей?

                                          +3

                                          Есть такой проект — Kiwix. Это коллекция дампов сайтов вроде Википедии и StackOverflow + оффлайновая читалка для них (в том числе в виде сервера). Они используют специальный формат для хранения контента, со сжатием и полнотекстовым поиском. Русскоязычная Википедия без картинок, например, весит всего несколько гигабайт. Если сделаете дамп Хабра и опубликуете его на их платформе — будет просто замечательно.

                                            0
                                            Да, это было бы замечательно, однако, как я понял, zim файл это коллекция сжатых html. Т.е. необходимо вновь парсить хабр и уже сохранять оригинальные html файлы.
                                            0
                                            Мне кажется более простая реализация многопоточности была бы через concurrent.futures чем через dummy. Проще отслеживать и обрабатывать кейсы которые упали по какой либо причине и пустить их на повтор, без записи в файл. Я бы рекомендовал всегда использовать в реквестах таймаут, во избежания залипания потоков.

                                            Ну и если изначальная цель собрать все данные, то использование сайтмапа уже упоминали выше + писать данные не в файлы, а сразу в базу по мере получения. Это позволит значительно ускорить процесс, сделать предварительную обработку того какие ссылки уже есть в базе, и работать только с оставшимися.
                                            Я бы использовал mongo, что бы вообще не разбирать данные json или postgres если SQL привычнее для работы, sqlite обычно получается более проблемным.

                                            Ну и из простого занудства, почему файлы содержат в названии async если в код не асинхронный.
                                              0
                                              Про concurrent.futures спасибо, сейчас прочту.
                                              Про базы — я не работал с другими, кроме mysql и sqlite. Позже посмотрю, может быть и имеет смысл перейти на другую.
                                              Сайтмап — не увидел его сразу. Так же, там ссылка на последние статьи, полного вроде бы нет. Так что да, придется тратить время на 404 ошибку.
                                              Насчет имени файлов — изменю :)
                                              Спасибо за замечания.
                                                0
                                                Быстрая проверка сайтмапа — habr.com/sitemap/sitemap8.xml. Хранит статьи за 2008 год. Потому осмелюсь предположить, что он хранит все статьи а не только новые.
                                              0
                                              Топ 15 авторов

                                              А rssbot — это человек? Или редакторы реально статей пишут больше, чем робот?
                                                0

                                                https://habr.com/ru/users/rssbot/
                                                1,4к публикаций, но последняя — 28 августа 2013. Видимо, с тех пор у некоторых редакторов действительно накопилось ещё больше.

                                                +1
                                                Почему все попытки спарить Хабр сводятся к тому, чтобы перебирать все айдишники статей? Ведь куда проще и удобнее воспользоваться такой штукой как sitemap.xml — там всегда все живые ссылки — бери и качай.

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

                                                Самое читаемое