Весь Хабр в одной базе. Комментарии и веб-приложение

от автора

КДПВ

Часть 1 | mega.nz | Онлайн демо

Наверное, это продолжение статьи, в которой я парсил Хабр в базу данных. Теперь настало время её применить.

Маленькие правки, упрощающие жизнь

Структура базы данных

Ускоряем базу данных с помощью создания индекса.

CREATE INDEX article_indx ON articles(id); CREATE INDEX comments_indx ON comments(article);

Время запроса до создания индекса — 1 с.

Время запроса первой строки — 5 мс.

Время запроса последней строки — 10 мс.

Парсим комментарии

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

Однако из-за моей кривости рук мне не хватило терпения решить задачу без использования еще одного слоя абстракции(сколько же боли скрывается за этим предложением). Да здравствует sqlite3worker и его автор. Его библиотека позволяет вставлять значения в базу данных, не задумываясь о порядке, блокировки базы и других важных вещах.

Код комментариев

from sqlite3worker import Sqlite3Worker from multiprocessing.dummy import Pool as ThreadPool from datetime import datetime import json import requests import logging  sql_worker = Sqlite3Worker("habr.db") sql_worker.execute("CREATE TABLE IF NOT EXISTS comments(id INTEGER,"                    "parent_id INTEGER,"                    "article INTEGER,"                    "level INTEGER,"                    "timePublished TEXT,"                    "score INTEGER,"                    "message TEXT,"                    "children TEXT,"                    "author TEXT)")  def worker(i):     url = "https://m.habr.com/kek/v2/articles/{}/comments/?fl=ru%2Cen&hl=ru".format(i)      try:         r = requests.get(url)         if r.status_code == 503:             logging.critical("503 Error")             raise SystemExit     except:         with open("req_errors.txt", "a") as file:             logging.critical("requests error")             file.write(str(i))         return 2      try: data = json.loads(r.text)     except: logging.warning("[{}] Json loads failed".format(i))      if data['success']:         comments = data['data']['comments']          for comment in comments:             current = comments[comment]              id = current['id']             parent_id = current['parentId']             article = i             level = current['level']             time_published = current['timePublished']             score = current['score']             message = current['message']             children = [children for children in current['children']]             author = current['author']              try: data = (id,                     parent_id,                     article,                     level,                     time_published,                     score,                     message,                     str(children),                     str(author['login']))             except:                 data = (None, None, None, None, None, None, None, None, None)              sql_worker.execute("INSERT INTO comments VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", data)          logging.info("Comments on article {} were parsed".format(i))  min = 490000 max = 495000 pool = ThreadPool(3)  start_time = datetime.now() results = pool.map(worker, range(min, max))  pool.close() pool.join() sql_worker.close() print(datetime.now() - start_time)

Как грамотно хранить комментарии в базе до меня уже рассказал dug в своей статье.
За меня все уже сделали авторы Хабра и мне оставалось лишь сохранить поля в базе.

Веб-приложение

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

Создаем приложение

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

from flask import Flask, g, render_template, request, redirect, url_for import sqlite3  app = Flask(__name__) app.config['DEBUG'] = True app.config['SECRET_KEY'] = 'supersecretkey'  DATABASE = "./habr.db"

Функция-помощник

Возвращает нам объект базы данных.

def get_db():     db = getattr(g, '_database', None)     if db is None:         db = g._database = sqlite3.connect(DATABASE)     return db

Главная страница

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

@app.route("/") def index():     return render_template("index.html")

Страница поста

Здесь мы создаем обработчик страницы /post/id, где делаем запрос к базе и передаем данные в шаблон.

@app.route('/post/<int:post_id>/') def show_post(post_id):     cur = get_db().cursor()     res = cur.execute("SELECT * FROM articles WHERE id = :id", {"id": post_id} )     article = res.fetchone()      return render_template("post.html", post=article)

Шаблоны

/post/id page

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

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

Для лучшего понимания, эту конструкцию можно представить как {{ var }} = print(var) в Python.

<!doctype html> <html lang="en"> <head> <meta charset="UTF-8">              <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">                          <meta http-equiv="X-UA-Compatible" content="ie=edge">              <title>{{ post[3] }}</title> </head> <body>  </body> </html>

Структура базы данных

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

<div class="header">     <h1 class="display-4">{{ post[3] }}</h1>     <br>     <p class="">         <strong>{{ date }}</strong> от <strong> {{ post[2] }}</strong>     </p>      <!--  Если присутствует метка о том, что это туториал  -->     {% if post[9] == 1 %}         <mark>Tutorial</mark> <br>     {% endif %}      <!--  6 - комментарии, 8 - рейтинг, 7 - прочитано раз  -->     <i class="fas fa-comments"></i> {{ post[6] }} |     <i class="fas fa-arrows-alt-v"></i> {{ post[8] }} |     <i class="fas fa-eye"></i> {{ post[7] }} раз </div>     <div class="content">         <div>             <!-- safe - печатать текст, сохраняя исходное оформление, не кодируя теги в текст     -->             <p>{{ post[4]|safe }}</p>         </div>         <br>         <strong><i class="fas fa-tags"></i> Теги:</strong> {{ post[10] }}     </div> </div>

Комментарии

Теперь о комментариях. В базе они хранятся в отдельной таблице comments.
Давайте извлечем еще одну таблицу, добавив к исходному скрипту

@app.route('/post/<int:post_id>/') def show_post(post_id):     cur = get_db().cursor()      ...      res = cur.execute("SELECT * FROM comments WHERE article = :id", {"id": post_id} )     comments = res.fetchall()      return render_template("post.html", post=article, comments=comments)

Далее выведем их в нашем шаблоне:

<div id="comments">     <h2>Комментарии</h2>     {% for comment in comments %}         <div id="{{ comment[0] }}" class="shadow-sm p-3 mb-5 bg-light rounded">             <div class="comment-header">                 <small>#{{comment[0]}}</small>                 <strong>{{ comment[8] }}</strong>                 <small>{{comment[4]}} | <i class="fas fa-arrows-alt-v"></i> {{ comment[5] }}</small>             </div>             {% if comment[1] != 0 %}                 <small><a href="#{{comment[1]}}"><mark>В ответ #{{ comment[1] }}</mark></a></small>                 {{comment[6]|safe}}             {% else %}                 {{comment[6]|safe}}             {% endif %}             <br>         </div>     {% endfor %} </div>

Для условных выражений Jinja использует конструкцию {% %}

Index page

К созданию главной страницы никаких особых требованией нет — она просто должна выглядеть прилично.
Для того, чтобы работал переход к статьям, добавим стандартную форму с текстовым полем и кнопкой.
Скрипт будет на этой же странице принимать POST-запрос и перенаправлять нас к статье:
main.py

@app.route('/', methods=['POST']) def index_post():     text = request.form['url']     id = ''.join(x for x in text if x.isdigit())     if id != '':         return redirect(url_for('show_post', post_id=id))     else: return "Некорректный ввод"

index.html

... <form method="POST">     <input type="url" name="url">     <div>         <button type="submit" id="search">Перейти</button>     </div> </form> ...

Так же давайте добавим отображение количества статей в базе:

@app.route("/") def index():     cur = get_db().cursor()     res = cur.execute("SELECT min(id), max(id) FROM articles")     counter = res.fetchone()     return render_template("index.html", counter = counter)

... <div class="container">         ...          <p>Промежуток доступных статей = {{ counter[0] }}..{{ counter [1] }}</p>          ... </div> ...

Теперь как это выглядит:

Прочие жизненно-необходимые костыли

  • Итак, наше веб-приложение практически готово. Однако есть шероховатости. Например, дата поста отображается в формате ISO 8601, что немного не удобно читать.
    Давайте исправим это: будем форматировать дату и передавать её отдельной переменной в шаблон.

    import dateutil.parser from datetime import datetime ... @app.route('/post/<int:post_id>/') def show_post(post_id): ... date = dateutil.parser.parse(article[1]) date = datetime.strftime(date, "%d.%m.%Y %H:%M") ... return render_template("post.html", post=article, date=date, comments=comments)

  • Хабр использует lazy load для изображений, соответственно, добавим поддержку и в наше приложение:

    // Загрузка изображений (async () => { for (let node of document.getElementsByTagName('img')) {     await new Promise(res => {         if(node.dataset.src !== undefined){             node.src = node.dataset.src;             node.onload = () => res();         }     }) } })();

    КДПВ, в отличии от остальных изображений, загружается напрямую, поэтому есть проверка на data-src тег

  • В некоторых постах есть спойлеры, давайте же "оживим" их:

    $(document).on('click', '.spoiler_title', function (e) { e.preventDefault(); $(this).toggleClass('active'); $(this).parent().find('.spoiler_text').first().slideToggle(300); });

  • Ну и в завершение. Мне всегда нравилась темная тема, благо её можно легко добавить скриптом Darkmode.js

    <script src="https://cdn.jsdelivr.net/npm/darkmode-js@1.5.5/lib/darkmode-js.min.js"></script> <script> new Darkmode().showWidget(); </script>

Разворачиваем на боевом сервере

Для того, чтобы веб-приложением можно было комфортно пользоваться отовсюду, его нужно где-нибудь разместить(Ваш кэп).

Локально

В самом простом(но не безопасном!) случае достаточно воспользоваться встроенным во flask сервером с отключенным дебаггером.

export FLASK_APP=main.py export FLASK_ENV=production flask run

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

Всему миру

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

pip install uwsgi uwsgi --socket 0.0.0.0:3031 --protocol http --wsgi-file main.py --callable app

Не уверен, что это безопасно — вероятнее всего, нужно прятать за nginx, но я в его настройке профан

Конец

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

Посмотреть, как это выглядит, можно здесь(база в несколько тысяч статей, сервер в России).
Ну и код как всегда на github

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

ссылка на оригинал статьи https://habr.com/ru/post/496892/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *