Программирование
July 6, 2024

Сервис подборок с использованием NoORM / #1

Содержание

Введение

На прошлой неделе я писал про подход "не только ORM", в конце которого анонсировал публикацию нескольких постов по теме пет-проекта с использованием python-библиотеки true-noorm. Это первая часть, сегодня поговорим об организационных моментах, спроектируем сервис, поищем оптимальные решения, помучаем LLM и даже частично набросаем MVP.

В качестве проекта, на котором я хочу использовать NoORM был выбран сервис для формирования подборок из постов по интересующим темам.

Для начала хочется применить NoORM на этапе создания MVP. Критерии для того, чтобы можно было считать, что MVP реализовано:

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

Общая архитектура для MVP (с учётом возможностей расширения):

По мере развития сервиса, хотелось бы реализовать следующие пункты:

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

Выбор технологий

Если по основным технологиям всё было понятно, то вот что делать с RSS — мыслей не было никаких, я никогда ранее не работал с RSS самостоятельно, пара минут в гугле привели меня к библиотеке feedparser.

В качестве технологий я хочу использовать:

  • FastAPI,
  • NoORM,
  • feedparser,
  • vue.js + antd + pinia (для визуализации подборки),
  • postgresql,
  • docker,
  • nginx.

На клиентскую часть решил взять vue + antd, чтобы попрактиковаться в использовании antd в рамках vue (ранее пробовал его только с react).

Проектирование БД

Для рисования диаграмм БД я использую drawio. Я не преследую целей соблюдать нотацию на все сто процентов, мне нужна только наглядность и отношение сущностей между собой.

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

Парсинг хабра

В целом, на самом хабре прекрасно описано, что надо сделать, чтобы получить доступ к RSS. Из всех возможных RSS-лент на хабре, лично мне пока хватит двух лент:

  • лучшие публикации сайта;
  • все публикации сайта подряд.

В итоге за примерно 30-35 минут накидал следующий файлик (rss.py) с кодом для парсинга RSS с хабра:

import feedparser
from dataclasses import dataclass

habr_all_publications = 'https://habr.com/ru/rss/articles/?fl=ru'
habr_best_publications = 'https://habr.com/ru/rss/articles/top/daily/?fl=ru'


@dataclass
class Post:
    title: str
    description: str
    meta_info: dict[str, any]


def parse(link):
    feed = feedparser.parse(link)

    posts = []

    for post in feed.entries:
        posts.append(
            Post(
                title=post.title,
                description=post.summary,
                meta_info={
                    'published_at': post.published,
                    'author': post.author,
                    'tags': list(map(lambda tag: tag['term'], post.tags)),
                    'link': post.link,
                }
            )
        )

    return posts


def generate_feed(sources):
    posts = []

    for source in sources:
        posts_by_source = parse(source)

        posts.extend(posts_by_source)

    return posts

sources = [habr_all_publications, habr_best_publications]

results = generate_feed(sources)

print('Length of posts:', len(results))

Функция parse выкачивает RSS-ленту по ссылке, собирает коллекцию постов и возвращает её. Функция generate_feed проходит по всем ссылкам, полученным на вход, формирует коллекцию публикаций, обращаясь к функции parse.

Создание БД

Поскольку, подход NoORM не предусматривает автоматического создания БД и заполнения БД, необходимо собрать SQL-скрипт для инициализации БД.

Я довольно ленивый, когда дело доходит до SQL, поэтому скормил в LLM такой промпт:

"Напиши sql запрос для создания таблицы dt_user со следующими полями: id (первичный ключ), login, password, таблицы dt_feed со следующими полями: id (первичный ключ), name, created_at, foreign key на таблицу dt_user, таблицы dt_source с полями id (первичный ключ), name, link, type, таблицы dt_post со следующими полями: id (первичный ключ), title, description, meta_info, raw_content, mark, created_at, foreign key на таблицу dt_feed, foreign key на таблицу dt_source"

На выходе получил такой результат:

CREATE TABLE dt_user (
    id INT AUTO_INCREMENT PRIMARY KEY,
    login VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
);

CREATE TABLE dt_feed (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    user_id INT,
    FOREIGN KEY (user_id) REFERENCES dt_user(id)
);

CREATE TABLE dt_source (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    link VARCHAR(255) NOT NULL,
    type ENUM('RSS', 'ATOM') NOT NULL
);

CREATE TABLE dt_post (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    meta_info JSON, -- если используется MySQL версии 5.7 и выше
    raw_content TEXT,
    mark BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    feed_id INT,
    source_id INT,
    FOREIGN KEY (feed_id) REFERENCES dt_feed(id),
    FOREIGN KEY (source_id) REFERENCES dt_source(id)
);

В целом, довольно неплохо, но мне надо для psql, поэтому решил перезапросить с уточнением:

"Напиши sql запрос с использованием СУБД postgresql для создания таблицы dt_user со следующими полями: id (первичный ключ), login, password, таблицы dt_feed со следующими полями: id (первичный ключ), name, created_at, foreign key на таблицу dt_user, таблицы dt_source с полями id (первичный ключ), name, link, type, таблицы dt_post со следующими полями: id (первичный ключ), title, description, meta_info, raw_content, mark, created_at, foreign key на таблицу dt_feed, foreign key на таблицу dt_source"
CREATE TABLE dt_user (
    id SERIAL PRIMARY KEY,
    login VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
);

CREATE TABLE dt_feed (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    user_id INTEGER REFERENCES dt_user(id) ON DELETE CASCADE
);

CREATE TABLE dt_source (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    link VARCHAR(255) NOT NULL,
    type VARCHAR(10) NOT NULL CHECK (type = 'RSS' OR type = 'ATOM')
);

CREATE TABLE dt_post (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    meta_info JSONB, -- JSONB поддерживает индексацию и поиск
    raw_content TEXT,
    mark BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    feed_id INTEGER REFERENCES dt_feed(id) ON DELETE CASCADE,
    source_id INTEGER REFERENCES dt_source(id) ON DELETE RESTRICT
);

Как видно, скрипт вполне неплохой получился. От себя, я добавил только IF NOT EXISTS к созданию каждой таблицы. А по названию полей LLM попыталась расставить типы и вполне неплохо с этим справилась. Интересно, что здесь даже предусмотрено сразу ограничение типов источника, что пока не слишком актуально, но в целом, — очень даже неплохо. Подытоживая, такого скрипта мне более, чем достаточно для MVP.

Теперь, когда скрипт init.sql готов, осталось только накидать простенький docker-compose.yml, чтобы запустить БД:

services:
    db:
        image: postgres
        container_name: podborka_db
        environment:
            - POSTGRES_DB=maindb
            - POSTGRES_USER=maindb
            - POSTGRES_PASSWORD=maindb
            - POSTGRES_HOST=db
            - POSTGRES_PORT=5432

        ports:
            - 15432:5432

        volumes:
            - ./dbs/postgres-data:/var/lib/postgresql/data
            - ./init.sql:/docker-entrypoint-initdb.d/init.sql

Проверим, что всё создалось, зайдя в БД:

maindb=# \dt;
          List of relations
 Schema |   Name    | Type  | Owner  
--------+-----------+-------+--------
 public | dt_feed   | table | maindb
 public | dt_post   | table | maindb
 public | dt_source | table | maindb
 public | dt_user   | table | maindb
(4 rows)

Как видим, всё на месте. Можно переходить к настройке работы через NoORM.

NoORM

Чтобы использовать NoORM вместе с PostgreSQL, надо установить адаптор. В данный момент, я остановился на библиотеке psycopg2. В будущем планирую перейти на asyncpg, но для MVP в этом никакой необходимости нет.

Установим зависимости (я использую именно binary-версию для psycopg2):

pip3 install true-noorm psycopg2-binary

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

from dataclasses import dataclass

import noorm.psycopg2 as nm


@dataclass
class DbUser:
    id: int
    login: str

@nm.sql_fetch_all(DbUser, "SELECT id, login FROM dt_user")
def get_all_users():
    ...

@nm.sql_one_or_none(
    DbUser, "SELECT id, login FROM dt_user id = :id"
)
def get_user_by_id(id: int):
    return nm.params(id=id)

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

Реализуем функцию для создания нового пользователя через INSERT-запрос (опять же, посредством допроса LLMки):

@nm.sql_execute("INSERT INTO dt_user (login, password) VALUES (%s, %s)")
def create_new_user(login: str, password: str):
    return nm.params(login, password)


def main():
    import psycopg2

    with psycopg2.connect(
     database='maindb', user='maindb',
     password='maindb', host='localhost',
     port='15432'
 ) as conn:
        create_new_user(conn, 'test', 'test')
        conn.commit()

main()

Синтаксис корректного запроса (имею ввиду, что в данном случае параметры не именованные и это неочевидный момент) я подглядел в примерах, которые лежали внутри кода библиотеки. И первое, что меня расстроило в работе с NoORM: в результате выполнения запроса я не получил никакой информации о только что созданной записи, только None. А всё-таки, хотелось бы получить, хотя бы, id. С другой стороны, этим автор библиотеки неявно подталкивает нас к тому, чтобы делать в такой ситуации два запроса и сохранять при этом простоту взаимодействия.

Опишем такие же круды для остальных таблиц. Полный код проекта можно посмотреть на моём github.

Точка входа

Теперь, когда мы уже написали все функции для работы с БД, а также простой инструмент для парсинга RSS-лент, нам следует подружить две эти части нашего приложения между собой. Для этого я немного модифицировал файл rss.py:

import feedparser
from dataclasses import dataclass, asdict

import psycopg2
import json

from db.posts import create_new_post


@dataclass
class Post:
    title: str
    description: str
    meta_info: dict[str, any]


def parse(link):
    feed = feedparser.parse(link)

    posts = []

    for post in feed.entries:
        posts.append(
            Post(
                title=post.title,
                description=post.summary,
                meta_info={
                    'published_at': post.published,
                    'author': post.author,
                    'tags': list(map(lambda tag: tag['term'], post.tags)),
                    'link': post.link,
                    # TODO: добавить поля
                    # 'raw_content': '-',
                    # 'mark': '-',
                    # 'feed_id': 1,
                    # 'source_id': 1,
                }
            )
        )

    return posts


def generate_feed(sources):
    posts = []

    for source in sources:
        posts_by_source = parse(source)

        posts.extend(posts_by_source)

    return posts


def save_posts(posts):
    with psycopg2.connect(database='maindb', user='maindb', password='maindb', host='localhost', port='15432') as conn:
        for post in posts:
            create_new_post(conn, **{ **asdict(post), 'meta_info': json.dumps(post.meta_info) })

        conn.commit()


def load_posts(sources):
    results = generate_feed(sources)

    print('First result:', results[0])

    print('Length of posts:', len(results))

    save_posts(results)

По сути, мы дописали две функции: save_posts для записи результатов парсинга в БД и load_posts для объединения процессов генерации нашей подборки с последующим сохранением в БД.

Настало время реализовать точку входа, которая в будущем перейдёт под FastAPI, файл main.py:

from parser.rss import load_posts

habr_all_publications = 'https://habr.com/ru/rss/articles/?fl=ru'
habr_best_publications = 'https://habr.com/ru/rss/articles/top/daily/?fl=ru'

sources = [habr_all_publications, habr_best_publications]

load_posts(sources)

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

$ python3 main.py 
Rirst result: Post(title='Team Lead VS Engineering Manager', description='<img src="https://habrastorage.org/getpro/habr/upload_files/800/cdc/5f1/800cdc5f15df57e3ef7d1ff7d736a6ed.jpeg" /><p>Приветствую! Меня зовут Василиса Версус и я в прошлом трижды СТО в небольших стартапах (20-50 чел), а также head of инфраструктуры / разработки в таких компаниях как Яндекс и Сбермаркет</p><p>Сегодня мне очень хочется поделиться несколькими мыслями на тему карьерного развития. И недостающими пазлами между позицией тимлида и желаемой многими позиции СтанцииТехническогоОбслуживания.</p> <a href="https://habr.com/ru/articles/827094/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=827094#habracut">Читать далее</a>', meta_info={'published_at': 'Sat, 06 Jul 2024 15:16:46 GMT', 'author': 'dcversus', 'tags': ['engineering management', 'team leading', 'development', 'career advice', 'cto', 'тимлидство'], 'link': 'https://habr.com/ru/articles/827094/?utm_source=habrahabr&utm_medium=rss&utm_campaign=827094'})
Length of posts: 61

На всякий случай, проверим это и запросом в БД:

maindb=# select count(*) from dt_post;
 count 
-------
    61
(1 row)

Заключение

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

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