aaf84ea931
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.6 KiB
9.6 KiB
Чистый PDF — инструмент «URL → PDF» в LMS
Дата: 20260706
Статус: дизайн утверждается
Референс: Clean PDF (pdf.brainysnipe.ru) из поста https://t.me/flowing_abyss/94, инструкция https://flowing-abyss.com/Clean-PDF-for-Zotero
Что делаем
Инструмент для студентов школы: превращает любую веб-страницу в чистый PDF — без рекламы, меню и мусора, с нормальной типографикой, оглавлением, форматами A4/Letter и светлой/тёмной темой. Два сценария плюс открытый API:
- Веб-форма в кабинете (
/tools/clean-pdf): вставил URL — скачал PDF. - Zotero: персональный API-ключ + скрипт для плагина Actions & Tags — правый клик на записи → PDF генерируется и прикрепляется к ней.
- Открытый API для продвинутых:
curlс Bearer-ключом, документация на странице инструмента.
Принятые решения
| Вопрос | Решение |
|---|---|
| Архитектура | Всё внутри LMS (подход B): страница и API в Next.js, рендер — отдельный контейнер browserless в том же compose |
| Кому доступ | Любой платный студент: есть CourseEnrollment на курс со slug ≠ FREE_COURSE_SLUG; admin/curator — без ограничений |
| Видимость | Часть Obsidian Toolbox, живёт под существующим флагом TOOLBOX_VISIBLE; включение на проде — отдельное решение позже |
| Лимит | 100 генераций/месяц на студента, env PDF_MONTHLY_LIMIT (без пересборки), плюс burst-лимит 5/мин |
Пайплайн генерации
URL студента
→ Chromium (browserless) загружает страницу (JS отрабатывает)
→ Next.js забирает итоговый HTML
→ Defuddle (npm, через JSDOM) выделяет главный контент
→ чистый HTML-шаблон: типографика, A4/Letter, light/dark, оглавление из заголовков
→ Playwright page.pdf() в том же browserless
→ файл application/pdf в ответ
- Defuddle — библиотека kepano (автор Obsidian), та же, что у референса: https://github.com/kepano/defuddle
- browserless/chromium — отдельный контейнер в docker-compose:
MAX_CONCURRENT_SESSIONS=2, встроенная очередь,mem_limit~1 ГБ, порт наружу не публикуется. - Next.js подключается по WebSocket через
playwright-core(только клиент, Chromium в образ LMS не попадает — образ не растёт). - Таймаут всего пайплайна ~90 сек; понятные JSON-ошибки: URL недоступен, лимит исчерпан, нет платного доступа.
Данные (Prisma)
Новая модель:
model PdfApiKey {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
key String @unique // формат sbpdf_<random>, показывается студенту
createdAt DateTime @default(now())
}
- Ключ создаётся лениво при первом заходе на страницу инструмента.
- Перегенерация заменяет ключ (старый перестаёт работать сразу).
- Учёт использований — существующая
ToolUsageсtool = "clean-pdf-generate"; месячный счётчик = COUNT за календарный месяц. Именно отдельный идентификатор:CopyButtonна странице логирует копирования какtool = "clean-pdf", и они не должны тратить лимит генераций. - Проверка платного доступа выполняется на каждый запрос → рефанд или блокировка аккаунта автоматически отключает и веб, и ключ. Источник истины один — БД LMS.
API
GET /api/pdf?url=<...>&format=A4|Letter&theme=light|dark
- Авторизация, два пути: заголовок
Authorization: Bearer sbpdf_...(Zotero, curl) или активная сессия Better Auth (веб-форма — студенту ключ для веба не нужен). - Роут добавляется в
PUBLIC_ROUTESвmiddleware.ts(иначе 307 → /login — известные грабли). - Ответ:
application/pdf+ заголовкиX-Uses-Count/X-Max-Uses(их же показывает Zotero-скрипт). - Ошибки: JSON
{ error }c честными статусами (401 нет ключа/сессии, 403 нет платного доступа, 429 лимит, 422 плохой URL, 504 не отрендерилось).
Страница /tools/clean-pdf
По образцу существующих инструментов тулбокса (реестр TOOLS, ToolCard, страница + клиентская форма):
- Форма: поле URL, выбор формата и темы, кнопка «Скачать PDF», счётчик «использовано N из 100 в этом месяце».
- Блок «Zotero»: персональный ключ (показать/скопировать/перегенерировать), пошаговая инструкция по Actions & Tags, готовый скрипт с уже подставленными адресом школы и ключом студента (адаптация скрипта референса:
SERVICE_URL = https://school.second-brain.ru, endpoint/api/pdf). - Блок «API»: пример curl.
Дизайн — строго ДС-2 (перед вёрсткой перечитать SBT/02-Стандарты/Дизайн-LMS/DESIGN.md).
Безопасность
Сервис скачивает произвольные URL изнутри прод-стенда — главный риск SSRF:
- Только
http://иhttps://. - Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (
db,app,browserless). - Внутри browserless — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса).
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
- Куки/учётные данные пользователя на целевую страницу не передаются.
- Санитайз имени файла в
Content-Disposition. - Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
Деплой
docker-compose.yml(dev) иdocker-compose.prod.yml: сервисbrowserless(образghcr.io/browserless/chromium), внутренняя сеть, лимиты памяти.- env:
BROWSER_WS_URL,PDF_MONTHLY_LIMIT=100, существующийTOOLBOX_VISIBLE. - Prisma-миграция (
PdfApiKey). - Стандартная схема деплоя LMS: сборка на Hetzner →
docker save | ssh | docker loadна Hoster.kz; browserless на Hoster.kz — обычныйdocker pull. - Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает
PdfApiKeyиToolUsage).
Тестирование
- Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
- Интеграция на staging: рендер 3–5 реальных статей (лонгрид, статья с картинками, JS-тяжёлая страница), проверка лимитов и обоих путей авторизации.
- Zotero-скрипт — ручная проверка на живой библиотеке.
- Веб-форма — e2e через agent-browser в
--headed(headless режет антиспам — известные грабли).
Вне рамок (YAGNI)
- Отдельный домен
pdf.second-brain.ru— не нужен, всё наschool.second-brain.ru. - Хранение сгенерированных PDF на сервере — файл отдаётся сразу и не сохраняется.
- Пер-курсовый гейтинг, тарифные лимиты, платные квоты — не сейчас.
- Освещение в рассылке/анонсы — после включения
TOOLBOX_VISIBLEна проде, отдельной задачей.