Compare commits

..
31 Commits
Author SHA1 Message Date
erjemin 9d3584e96e mod: ядро (04) SVG-генерация записывается в базу 2026-08-27 00:06:15 +03:00
erjemin 58c1ee46df mod: админка (15) в save_model работает типограф etpgrf и авто-slug с транслитерацией + превью-картинки в списке блог-постов 2026-08-25 22:31:39 +03:00
erjemin 19d383dc89 add: pytils (для транслитерации и т.п.) 2026-08-25 21:34:06 +03:00
erjemin 457c33725a mod: админка (14) настроены поля типографа 2026-08-25 20:48:05 +03:00
erjemin 13641eae88 add: типограф etpgrf в проект (для блогов) 2026-08-24 21:16:27 +03:00
erjemin 6f9da30040 add: админка (13) CodeMirror в админке настроен и работает... 2026-08-24 21:14:21 +03:00
erjemin 1d5306a48e add: модели и админка (12) миксины, стили и патч для CodeMirror в админке... 2026-08-24 17:51:36 +03:00
erjemin 8a7de1f062 add: модели и админка (12) миграции 2026-08-24 17:03:46 +03:00
erjemin 752f1506e3 mod: интерфейс (10) превью картинки. перед "скармливанием Гипоножабе" 2026-08-23 23:24:47 +03:00
erjemin ba1f1c2a0b add: заглушка для счетчиков аналитики... 2026-08-23 22:58:14 +03:00
erjemin 3128ae4e2d mod: ядро (03) уменьшены максимальные значения "плотности галлюцинации" т.к. браузер большую сетку может не тянуть... 2026-08-23 22:56:58 +03:00
erjemin 4917e04e5a add: ядро (02) драфт... 2026-08-23 22:53:57 +03:00
erjemin 8bd5a54cf5 add: csrf в шаблонах 2026-08-23 22:27:06 +03:00
erjemin 05bb583d8a add: core (01) драфт... работает!! Генерация SVG проходит!!! 2026-08-23 22:14:49 +03:00
erjemin f015351c42 mod: админка (11) драфт ver2 2026-08-23 18:27:22 +03:00
erjemin 1a285a61c6 минор: Custom Command переименование rescore_hypn0_items -> rescore 2026-08-23 17:23:00 +03:00
erjemin 200b81987c add: Custom Command Синхронизация s_hash_id с солью 2026-08-23 17:16:06 +03:00
erjemin 27049e9ae0 mod: модели (10) метод save() модели TbHypn0Item 2026-08-23 16:51:00 +03:00
erjemin b63dbb6bd2 add: документация по Custom Command (1) 2026-08-22 20:32:33 +03:00
erjemin b23784b7ad add: Custom Command перерасчета рейтингов (2) все "магические числа" определены вначале. 2026-08-22 20:31:54 +03:00
erjemin 87c8e79630 add: драфт Django Custom Command перерасчета рейтингов. 2026-08-22 19:52:42 +03:00
erjemin 9777c93cb4 add: подготовка для Django Custom Command 2026-08-22 19:52:12 +03:00
erjemin bb4ec185ff mod: mod: модели (9) убран "атомарный" перерасчет рейтинга. 2026-08-22 19:51:14 +03:00
erjemin 3cef695782 mod: mod: модели (8) методы для лайков/дислайков 2026-08-22 18:02:55 +03:00
erjemin cc3a17e792 mod: согласие на отслеживание совмещено с фингерпринтом браузера UUIDv4 2026-08-22 15:55:39 +03:00
erjemin 2bec2e860e mod: про модели и хранение данных... 2026-08-22 14:58:32 +03:00
erjemin 381ec3eaf0 mod: модели и админка (7) + миграции 2026-08-22 14:26:57 +03:00
erjemin eff510eb4b mod: модели и админка (6) драфт 2026-08-20 16:35:42 +03:00
erjemin 99c317431e mod: Срабатывает кнопка Подчиниться Гипножабе! 2026-08-20 13:01:39 +03:00
erjemin e49154090e add: codemirror для админки и его сборщик 2026-08-19 16:24:49 +03:00
erjemin 31fb267d98 mod: модели и админка (5) драфт 2026-08-19 15:52:54 +03:00
35 changed files with 4830 additions and 242 deletions
+13
View File
@@ -17,6 +17,19 @@ DJANGO_EMAIL_HOST_USER=you@email.com
DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME
DJANGO_EMAIL_FROM=you@email.com DJANGO_EMAIL_FROM=you@email.com
# ====== HASHIDS настройка для TbHypn0Item.s_hash_id ======
# Используется для кодирования ID генераций SVG в компактные, хаотичные коды (не криптографический хеш, а encoder).
# - HASHIDS_SALT: секретная соль для кодирования (ВАЖНО! Менять для production!)
# Только с солью можно декодировать, без нее невозможно восстановить ID
# Генерируйте криптографически стойкую случайную строку: python -c "import secrets; print(secrets.token_hex(16))"
# Содержит только буквы и цифры (безопасно для URL и QR-кода)
# - HASHIDS_MIN_LENGTH: минимальная длина кода
# 6 символов = оптимально для каталогов до 100k
# 8 символов = для каталогов 100k-10M
# Рекомендация: оставить 6 для начальной фазы, поменять на 8 при росте
HASHIDS_SALT=your-super-secret-salt-for-production-change-me-now
HASHIDS_MIN_LENGTH=6
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти) # URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
ADMIN_URL=admin/ ADMIN_URL=admin/
+98
View File
@@ -0,0 +1,98 @@
# Django Management Commands
В этом документе описаны кастомные консольные команды (`manage.py`), реализованные в проекте **Hypn0** для автоматизации периодических фоновых задач, скоринга и обслуживания хранилища.
---
## 1. `rescore` — Пакетный пересчет рейтинга и детекция аномалий
- **Путь:** `../hypn0/hypn0_site/management/commands/rescore.py`
- **Назначение:** Пакетный фоновый пересчет рейтинга популярности (`f_score`) для всех активных картин и выявление аномалий (накрутка лайков / скоординированный шейминг).
### Базовый запуск:
```bash
# Рабочий запуск
poetry run python hypn0/manage.py rescore
# Тестовый прогон (без записи изменений в базу)
poetry run python hypn0/manage.py rescore --dry-run
```
---
### Архитектурные принципы и алгоритм:
1. **Метрики платформы («Средняя температура по больнице»):**
- Анализирует активность за скользящее окно `WINDOW_HOURS = 24.0` часов.
- Считает среднее число голосов на одну активную картину (моложе 30 дней).
- **Защита «холодного старта» (Cold Start):** при пустой базе или отсутствии голосов отключаются относительные детекторы, предотвращая ложные срабатывания и деление на 0.
2. **Экспоненциальное затухание голосов (Time-Decay):**
- Сила каждого голоса из `TbVote` плавно затухает по закону:
$$\text{Weight}(t) = \text{BaseWeight} \times 2^{-\Delta t / \text{HalfLife}}$$
- По умолчанию период полураспада силы голоса составляет 14 дней (`--half-life-days=14.0`).
- Истинные веса голосов:
- `LIKE` = $+1$
- `AUTHOR` = $+2$
- `CLAIM` = $-2$
3. **Детекция аномалий и автоматический перевод уровней (`Level`):**
- **Шейминг (`Level.SHAMED = -20`):** если за последние 24ч доля жалоб среди свежих голосов $\ge 35\%$ (`--shame-claim-ratio=0.35`).
- **Накрутка / Спайк (`Level.SUSPICIOUS = -10`):** если приток лайков за окно в 4+ раза превышает средний по платформе (`--spike-multiplier=4.0`) и составляет $\ge 20$ лайков (или $\ge 50$ лайков при холодном старте).
- **Прогрев (`Level.LEVEL_1 = 10`):** если картина со статусом `CANDIDATE` набрала достаточный взвешенный вес ($\ge 10$ по умолчанию), она автоматически повышается до `LEVEL_1` («Плеск бессознательного»), привлекая внимание модератора для витрины.
4. **Гравитационная модель скоринга (с логарифмом):**
$$\text{Score} = \frac{\ln(1 + \max(0, \text{WeightedLikes})) + \text{Bonus}_{\text{level}}}{(\text{Age in hours} + 2.0)^\gamma}$$
- `LEVEL_1_BONUS` = $+1.5$
- `LEVEL_2_BONUS` = $+4.0$ (Одобрено Мозговым Слизнем)
5. **Инерционное сглаживание (EMA — Exponential Moving Average):**
- Чтобы исключить резкие скачки рейтинга от разовых всплесков, итоговый `f_score` сглаживается с предыдущим значением:
$$\text{Score}_{\text{final}} = 0.7 \times \text{Score}_{\text{new}} + 0.3 \times \text{Score}_{\text{old}}$$
---
### Параметры командной строки (CLI):
| Флаг | Тип | По умолчанию | Описание |
|----------------------------|---------|--------------|------------------------------------------------------------------|
| `--gamma` | `float` | `1.5` | Коэффициент гравитации старения картины |
| `--half-life-days` | `float` | `14.0` | Период полураспада веса голоса (в днях) |
| `--spike-multiplier` | `float` | `4.0` | Множитель спайка лайков для детекции накрутки |
| `--shame-claim-ratio` | `float` | `0.35` | Доля жалоб для признания скоординированного шейминга |
| `--warmup-votes-threshold` | `int` | `10` | Порог взвешенных голосов для автопрогрева `CANDIDATE -> LEVEL_1` |
| `--dry-run` | `flag` | `False` | Пробный запуск без изменения записей в БД |
---
## 2. `rehash` — Проверка целостности и перегенерация `s_hash_id`
- **Путь:** `hypn0/hypn0_site/management/commands/rehash.py`
- **Назначение:** Синхронизация публичных коротких хэшей `s_hash_id` в базе данных с текущим значением `HASHIDS_SALT` и `HASHIDS_MIN_LENGTH`.
### Когда требуется запуск:
1. При смене или ротации `HASHIDS_SALT` в `.env` / `settings.py`.
2. При переносе/миграции базы данных между разными окружениями (Dev -> Prod).
3. При обнаружении записей с пустыми или устаревшими `s_hash_id`.
### Базовый запуск:
```bash
# Тестовая проверка нестыковок (без записи в БД)
poetry run python hypn0/manage.py rehash --dry-run
# Боевая перегенерация
poetry run python hypn0/manage.py rehash
```
### Параметры командной строки (CLI):
| Флаг | Тип | По умолчанию | Описание |
|----------------|--------|--------------|----------------------------------------------------------------|
| `--dry-run` | `flag` | `False` | Только проверить нестыковки без изменения базы данных |
| `--batch-size` | `int` | `500` | Размер пачки для `bulk_update` (оптимизация памяти и скорости) |
---
## 3. Планируемые команды (Roadmap)
- **`cleanup_storage`** — Удаление старых и низкорейтинговых картин (Smart Retention) при превышении квоты хранилища (использует рассчитанный `f_score`).
- **`cleanup_reports`** — Автоматическая изоляция или удаление картин со статусом `SHAMED` после истечения тайм-аута модерации.
+108 -68
View File
@@ -51,21 +51,21 @@
│ │ ├── 404.html │ │ ├── 404.html
│ │ └── 500.html │ │ └── 500.html
│ │ │ │
│ └── halftone/ # Django app │ └── hypn0_site/ # Основное Django app
│ ├── __init__.py │ ├── __init__.py
│ ├── urls.py │ ├── urls.py
│ ├── views.py # главная + генерация │ ├── views.py # главная + генерация
│ ├── forms.py # формы параметров │ ├── forms.py # формы параметров
│ ├── models.py # FeaturedSVG + votes │ ├── models.py # TbHypn0Item, TbVote, TbBlogPost
│ ├── services/ │ ├── services/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── halftone.py # ядро из main.py │ │ └── halftone.py # ядро генерации
│ ├── templates/ │ ├── templates/
│ │ └── halftone/ │ │ └── halftone/
│ │ └── index.html │ │ └── index.html
│ └── management/ │ └── management/
│ └── commands/ │ └── commands/
│ ├── cleanup_temp.py # очистка temp (14 дней) │ ├── cleanup_storage.py # очистка по рейтингу Smart Retention
│ └── cleanup_reports.py # удаление по жалобам │ └── cleanup_reports.py # удаление по жалобам
├── .gitea/workflows/ # CI/CD ├── .gitea/workflows/ # CI/CD
@@ -115,12 +115,12 @@ def generate_halftone_svg(
```python ```python
# Главная — GET → рендерим index.html # Главная — GET → рендерим index.html
# Генерация — POST (HTMX) → возвращаем SVG фрагмент # Генерация — POST (HTMX) → возвращаем SVG фрагмент
# Скачивание — GET /download/{temp_id} → отдаём файл + удаляем исходник # Скачивание — GET /download/{hash_id} → отдаём файл
# Просмотр публичного — GET /view/{hash} → отдаём SVG из temp/ # Просмотр публичного — GET /view/{hash_id} → отдаём страницу просмотра
# Галерея — GET /gallery → список FeaturedSVG # Галерея — GET /gallery → список TbHypn0Item
# Просмотр в галерее — GET /gallery/{hash} # Просмотр в галерее — GET /gallery/{hash_id}
# Report — POST /report/{hash} → increment reports_count # Vote/Report — POST /gallery/{hash_id}/vote → учет голоса в TbVote (Like/Claim)
# Promote (admin) — админ переносит SVG из temp → gallery # Promote (admin) — модератор повышает i_level (Level.LEVEL_2 или Level.IMMORTAL)
``` ```
### 3. Фронтенд (`hypn0/halftone/templates/halftone/index.html`) ### 3. Фронтенд (`hypn0/halftone/templates/halftone/index.html`)
@@ -332,11 +332,28 @@ HTMX: форма с `hx-post`, `hx-encoding="multipart/form-data"` → SVG вс
#### 4.1. Принципы приватности, Zero-PII и учет голосов #### 4.1. Принципы приватности, Zero-PII и учет голосов
- **Полное отсутствие регистрации для посетителей:** Пользователи не создают аккаунты, не вводят email, пароли или личные данные. Единственный пользователь в `auth.User` — администратор сайта для входа в Django Admin. - **Полное отсутствие регистрации для посетителей:** Пользователи не создают аккаунты, не вводят email, пароли или личные данные. Единственный пользователь в `auth.User` — администратор сайта для входа в Django Admin.
- **Исходные растровые изображения (JPG/PNG/WEBP):** Обрабатываются исключительно в оперативной памяти (через буфер `BytesIO`) и **не сохраняются на диск сервера вообще**. - **Исходные растровые изображения (JPG/PNG/WEBP):** Обрабатываются исключительно в оперативной памяти (через буфер `BytesIO`) и **не сохраняются на диск сервера вообще**.
- **Куки-согласие и идентификация устройства (Cookie-токен):** - **Куки-согласие и идентификация устройства (Cookie `hypn0_vid`):**
- При согласии с правилами («Подчиниться Гипножабе!» в `allow-tracking.html`) клиенту выставляется долгоживущая анонимная кука устройства `hypn0_vid` со случайным UUID (`Max-Age=2 года`, `SameSite=Lax`, `HttpOnly`). - При согласии с правилами («Подчиниться Гипножабе!» в `allow-tracking.html`) в браузере генерируется случайный UUIDv4 (`vid`), который сохраняется в долгоживущую анонимную куку `hypn0_vid` (`Max-Age=2 года`, `SameSite=Lax`) и дублируется в `localStorage`.
- **Голосование доступно только при наличии согласия на куки:** Если посетитель не принял соглашение (кука отсутствует), голосование заблокировано интерфейсом и отклоняется бэкендом (с требованием подчиниться Гипножабе). - **Единый статус согласия и идентификации:**
- **Лайки и учет голосов:** - Наличие куки `hypn0_vid` одновременно означает:
- **Бэкенд:** Голоса учитываются анонимно через связку `(item_id, fingerprint)`. Поле `fingerprint` формируется как необратимый хэш: `sha256(visitor_uuid + SECRET_KEY)`. Это надежно разделяет разные компьютеры в одной корпоративной сети/NAT и не содержит открытых ПДн (Zero-PII). 1. Посетитель дал согласие на условия / трекинг (`ALLOW_TRACKING = 'hypn0_vid' in request.COOKIES` в `context_processors.py`).
2. У бэкенда есть анонимный `visitor_uuid` для формирования цифрового отпечатка `s_fingerprint` в `TbVote`.
- **Правила для пользователей без подчинения Гипножабе (без куки `hypn0_vid`):**
- Пользователь **может** загружать изображение, экспериментировать с настройками, генерировать и скачивать SVG-файл на свое устройство (чистая локальная работа).
- Пользователь **НЕ может** публиковать картину в галерею или голосовать за чужие работы:
- Публикация картины на сервер требует немедленного создания авторской записи `TbVote(i_direction=Direction.AUTHOR)` для закрепления авторства, защиты от самолайка и стартового рейтинга.
- Без согласия и UUID сформировать `s_fingerprint` невозможно $\rightarrow$ кнопка «Опубликовать в галерею» и лайки заблокированы в интерфейсе (с всплывающим требованием подчиниться Гипножабе) и отклоняются бэкендом (HTTP 403).
- **Учет голосов, защита от накрутки и определение авторства (`TbVote`):**
- **Бэкенд:** Голоса учитываются анонимно через модель `TbVote` со связкой `(k_item, s_fingerprint)`. Поле `s_fingerprint` формируется как необратимый хэш: `sha256(visitor_uuid + SECRET_KEY)`. Это надежно разделяет разные компьютеры в одной корпоративной сети/NAT и не содержит открытых ПДн (Zero-PII).
- **Направление голоса (`i_direction` / `Direction`):**
- `Direction.LIKE` (`+1`) — обычный лайк посетителя.
- `Direction.CLAIM` (`-2`) — жалоба / клейм на неподобающий контент.
- `Direction.AUTHOR` (`+2`) — голос создателя картины.
- **Идентификация автора через `Direction.AUTHOR`:**
- При генерации картины в `TbVote` автоматически создается запись с `i_direction=Direction.AUTHOR`.
- Это исключает необходимость хранить отдельное поле `s_author_fingerprint` в `TbHypn0Item` (нет дублирования данных).
- Благодаря `UniqueConstraint(fields=["k_item", "s_fingerprint"])` автор автоматически защищен от «самолайка» (повторный лайк со своего браузера не пройдет).
- Свежая генерация сразу получает стартовый вес (`+2`), предотвращающий мгновенное удаление при низком `f_score`.
- **Фронтенд:** Для быстродействия UI и исключения лишней нагрузки список лайкнутых картин дублируется локально на клиенте в **`localStorage`** (`hypn0_votes`). На базе Alpine.js выполняется мгновенная индикация проголосованных работ. - **Фронтенд:** Для быстродействия UI и исключения лишней нагрузки список лайкнутых картин дублируется локально на клиенте в **`localStorage`** (`hypn0_votes`). На базе Alpine.js выполняется мгновенная индикация проголосованных работ.
- **Соответствие GDPR / 152-ФЗ:** Персональные данные не собираются и не хранятся. - **Соответствие GDPR / 152-ФЗ:** Персональные данные не собираются и не хранятся.
@@ -347,91 +364,111 @@ HTMX: форма с `hx-post`, `hx-encoding="multipart/form-data"` → SVG вс
#### 4.3. Стратегия хранения и умной очистки (Smart Retention) #### 4.3. Стратегия хранения и умной очистки (Smart Retention)
Проект не выступает постоянным файлообменником или бесплатным CDN. Пользователь скачивает сгенерированный SVG для личного использования. Проект не выступает постоянным файлообменником или бесплатным CDN. Пользователь скачивает сгенерированный SVG для личного использования.
Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности («гравитации»), а не слепой 14-дневный таймер: Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности («гравитации»), а не слепой таймер:
$$\text{Score} = \frac{\text{Likes} + \text{Bonus}_{\text{moderator}}}{(\text{Age in hours} + 2)^\gamma}$$ $$\text{Score} = \frac{\text{Likes} + \text{Bonus}_{\text{moderator}}}{(\text{Age in hours} + 2)^\gamma}$$
- **Квота хранилища:** Задается жесткий лимит объема (например, 500 МБ или N тысяч файлов). - **Квота хранилища:** Задается жесткий лимит объема (например, 500 МБ или N тысяч файлов).
- **Очистка (`manage.py cleanup_storage`):** При превышении лимита удаляются файлы с наименьшим `Score`. - **Уровни хранения (`i_level` / `TbHypn0Item.Level`):**
- **Иммунитет к удалению:** Работы, отмеченные модератором (`is_curated=True` / `Bonus`), и закрепленные промо-картины сохраняются бессрочно. - `CANDIDATE` (`LVL_CANDIDATE`): свежая генерация, защита от удаления на начальный период.
- `LEVEL_1` (`LVL_MIN_VOTES`): получен минимум лайков («Лёгкий транс»).
- `LEVEL_2` (`LVL_MODERATED`): проверено и одобрено («Одобрено Мозговым Слизнем»), удаление возможно при низком `f_score`.
- `IMMORTAL` (`LVL_LOCK_FOR_DELETION`): полный иммунитет к удалению даже при низком рейтинге («Глубокий транс»).
- **Очистка (`manage.py cleanup_storage`):** При превышении квоты диска удаляются файлы с наименьшим `f_score` (не затрагивая защищенные уровни).
#### 4.4. SEO, шеринг и коммерциализация #### 4.4. SEO, шеринг и коммерциализация
- **Вшивание ссылки в SVG (Zero-cost Branding):** В скачиваемый SVG на этапе генерации добавляется мета-комментарий со ссылкой на генератор (`<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->` и `<metadata>`). - **Вшивание ссылки в SVG (Zero-cost Branding):** В скачиваемый SVG на этапе генерации добавляется мета-комментарий со ссылкой на генератор (`<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->` и `<metadata>`).
- **Страница просмотра (`/v/<hash>`):** Для внешнего шеринга и индексации поисковиками генерируется легкая HTML-страница с OpenGraph разметкой и кнопкой «Открыть настройки в генераторе». - **Страница просмотра (`/v/<hash>`):** Для внешнего шеринга и индексации поисковиками генерируется легкая HTML-страница с OpenGraph разметкой и кнопкой «Открыть настройки в генераторе».
- **Возможность промо/коммерциализации:** Поддержка опциональных полей (промо-ссылка, бейдж автора/спонсора, счетчик кликов). - **Возможность промо/коммерциализации:** Поддержка опциональных полей (`s_promo_url`, `s_promo_title`, `i_promo_clicks`).
#### 4.5. Блог, статические страницы и типографика (HTML + `etpgrf`) #### 4.5. Блог, статические страницы и типографика (HTML + `etpgrf`)
- **Отказ от конвертации Markdown «на лету»:** Вместо оверхеда с парсингом Markdown статьи и инфо-страницы хранятся напрямую в формате **HTML**. - **Отказ от конвертации Markdown «на лету»:** Вместо оверхеда с парсингом Markdown статьи и инфо-страницы хранятся напрямую в формате **HTML**.
- **Очистка HTML в админке:** При вводе/сохранении в Django Admin контент проходит валидацию и санитизацию (очистку от вредоносного или мусорного HTML/инлайн-стилей). - **Очистка HTML в админке:** При вводе/сохранении в Django Admin контент проходит валидацию и санитизацию (очистку от вредоносного или мусорного HTML/инлайн-стилей).
- **Типографирование через `etpgrf`:** Для безупречной верстки текста (неразрывные пробелы, висячая пунктуация, кавычки-елочки, тире) используется библиотека типографирования **`etpgrf`**. - **Типографирование через `etpgrf`:** Для безупречной верстки текста (неразрывные пробелы, висячая пунктуация, кавычки-елочки, тире) используется библиотека типографирования **`etpgrf`**.
#### 4.6. Архитектура моделей #### 4.6. Архитектура моделей (Венгерская нотация и типизация)
В моделях используется венгерская нотация префиксов полей (`s_` — string/text/url, `i_` — integer, `f_` — float, `j_` — json, `k_` — foreign key, `d_` — datetime, `is_` — boolean, `file_` — file), а также оптимизированные типы первичных ключей (`SmallAutoField`, `AutoField`, `BigAutoField`).
```python ```python
class HalftoneItem(models.Model): class TbHypn0Item(models.Model):
""" """
Единая модель для генераций, публичных шеров и витрины галереи. Единая модель для генераций, публичных шеров и витрины галереи.
""" """
hash_id = models.CharField(max_length=16, unique=True, db_index=True) class Level(models.IntegerChoices):
title = models.CharField(max_length=150, verbose_name="Название картины") CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация
svg_file = models.FileField(upload_to="svg/%Y/%m/") LEVEL_1 = LVL_MIN_VOTES, 'Level 1: Лёгкий транс' # Есть первичные голоса
LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Модерировано
IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Иммунитет к удалению
# Снимок параметров для возможного клонирования настроек id = models.AutoField(primary_key=True, verbose_name="ID") # 4 байта, до ~2.14 млрд
params_json = models.JSONField(default=dict, blank=True) s_hash_id = models.CharField(max_length=16, unique=True, verbose_name="ID-Хэш")
file_size = models.PositiveIntegerField(default=0, help_text="Размер файла в байтах") s_title = models.CharField(max_length=255, verbose_name="Заголовок")
file_svg = models.FileField(upload_to="svg/%Y/%m/", verbose_name="SVG-файл")
i_file_size = models.PositiveIntegerField(default=0, verbose_name="Размер файла (байт)")
# Популярность и модерация # Метаданные и снимок параметров алгоритма генерации
likes_count = models.PositiveIntegerField(default=0, db_index=True) j_metadata = models.JSONField(default=dict, blank=True, verbose_name="Метаданные")
views_count = models.PositiveIntegerField(default=0)
reports_count = models.PositiveIntegerField(default=0)
score = models.FloatField(default=0.0, db_index=True, help_text="Рейтинг для Smart Retention")
is_curated = models.BooleanField(default=False, help_text="Выбор модератора / галерея (иммунитет к удалению)") # Популярность, модерация и рейтинг Smart Retention
is_public = models.BooleanField(default=True, help_text="Доступность по прямой ссылке") i_likes_count = models.PositiveIntegerField(default=0, db_index=True, verbose_name="Лайки")
i_views_count = models.PositiveIntegerField(default=0, verbose_name="Просмотры")
i_claims_count = models.PositiveIntegerField(default=0, verbose_name="Жалобы")
f_score = models.FloatField(default=0.0, db_index=True, verbose_name="Рейтинг Smart Retention")
i_level = models.IntegerField(choices=Level.choices, default=Level.CANDIDATE, verbose_name="Уровень хранения")
is_public = models.BooleanField(default=True, verbose_name="Публичный доступ")
# Промо-блок (на перспективу) # Промо-блок (на перспективу)
promo_url = models.URLField(blank=True, default="", verbose_name="Промо-ссылка") s_promo_url = models.URLField(blank=True, default="", verbose_name="Промо-ссылка")
promo_title = models.CharField(max_length=100, blank=True, default="", verbose_name="Бейдж/автор") s_promo_title = models.CharField(max_length=100, blank=True, default="", verbose_name="Бейдж/автор")
promo_clicks = models.PositiveIntegerField(default=0) i_promo_clicks = models.PositiveIntegerField(default=0, verbose_name="Клики по промо")
created_at = models.DateTimeField(auto_now_add=True, db_index=True) d_created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="Дата создания")
d_updated_at = models.DateTimeField(auto_now=True, db_index=True, verbose_name="Дата обновления")
class HalftoneVote(models.Model): class TbVote(models.Model):
""" """
Анонимный учет голосов (лайков) без сохранения ПДн. Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн.
""" """
item = models.ForeignKey(HalftoneItem, on_delete=models.CASCADE, related_name="votes") class Direction(models.IntegerChoices):
fingerprint = models.CharField( LIKE = VOTE_LIKE, 'Like (+1)'
CLAIM = VOTE_CLAIM, 'Claim (-2)'
AUTHOR = VOTE_AUTHOR, 'Author Like (+2)'
id = models.BigAutoField(primary_key=True, verbose_name="ID") # 8 байт, до ~9×10¹⁸
k_item = models.ForeignKey(TbHypn0Item, on_delete=models.CASCADE, related_name="votes", verbose_name="Картина")
i_direction = models.IntegerField(choices=Direction.choices, default=Direction.LIKE, verbose_name="Направление")
s_fingerprint = models.CharField(
max_length=64, max_length=64,
db_index=True, db_index=True,
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств" verbose_name="Хэш устройства",
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки"
) )
created_at = models.DateTimeField(auto_now_add=True) d_created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата голосования")
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=["item", "fingerprint"], name="unique_item_fingerprint_vote") models.UniqueConstraint(fields=["k_item", "s_fingerprint"], name="unique_item_fingerprint_vote")
] ]
class BlogPost(models.Model): class TbBlogPost(models.Model):
""" """
Статьи блога, документация и инфо-страницы (Privacy Policy и др.). Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
+ виртуальные поля для управления типографом etpgrf
""" """
title = models.CharField(max_length=200, help_text="HTML-заголовок, очищенный и типографированный etpgrf") id = models.SmallAutoField(primary_key=True, verbose_name="ID") # 2 байта, до 32 767 записей
slug = models.SlugField(max_length=200, unique=True) s_title = models.CharField(max_length=255, help_text="HTML-заголовок, очищенный и типографированный etpgrf")
summary = models.TextField(max_length=500, blank=True, help_text="HTML-контент тизера, очищенный и типографированный etpgrf") slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-слаг")
content = models.TextField(help_text="HTML-контент статьи, очищенный и типографированный etpgrf") s_teaser = models.TextField(max_length=1024, blank=True, help_text="HTML-тизер, очищенный и типографированный etpgrf")
cover_image = models.ImageField(upload_to="blog/covers/%Y/", blank=True, null=True) s_content = models.TextField(help_text="HTML-контент статьи, очищенный и типографированный etpgrf")
f_cover_img = models.ImageField(upload_to="blog/covers/%Y/", blank=True, null=True, verbose_name="Обложка")
is_published = models.BooleanField(default=False)
published_at = models.DateTimeField(null=True, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_published = models.BooleanField(default=False, verbose_name="Опубликовано")
d_published_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="Дата публикации")
d_created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
d_updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
``` ```
## Шаги реализации ## Шаги реализации
@@ -522,22 +559,25 @@ CLI больше не нужен.
## Файлы для создания (итого) ## Файлы для создания (итого)
| Файл | Назначение | | Файл | Назначение |
|---------------------------------------------------------|------------------------| |-----------------------------------------------------------|---------------------------------|
| `hypn0/manage.py` | Django CLI | | `hypn0/manage.py` | Django CLI |
| `hypn0/hypn0/__init__.py` | Settings pkg | | `hypn0/hypn0/__init__.py` | Settings pkg |
| `hypn0/hypn0/settings.py` | Конфиг | | `hypn0/hypn0/settings.py` | Конфиг |
| `hypn0/hypn0/urls.py` | URL routing | | `hypn0/hypn0/urls.py` | URL routing |
| `hypn0/hypn0/wsgi.py` | WSGI entry | | `hypn0/hypn0/wsgi.py` | WSGI entry |
| `hypn0/hypn0/templates/base.html` | Базовый шаблон | | `hypn0/hypn0/templates/base.html` | Базовый шаблон |
| `hypn0/halftone/__init__.py` | App pkg | | `hypn0/hypn0_site/__init__.py` | App pkg |
| `hypn0/halftone/urls.py` | URL app | | `hypn0/hypn0_site/urls.py` | URL app |
| `hypn0/halftone/views.py` | Views | | `hypn0/hypn0_site/views.py` | Views |
| `hypn0/halftone/forms.py` | Forms | | `hypn0/hypn0_site/forms.py` | Forms |
| `hypn0/halftone/models.py` | FeaturedSVG | | `hypn0/hypn0_site/models.py` | TbHypn0Item, TbVote... |
| `hypn0/halftone/services/halftone.py` | Ядро | | `hypn0/hypn0_site/services/halftone.py` | Ядро |
| `hypn0/halftone/templates/halftone/index.html` | Главная | | `hypn0/hypn0_site/templates/halftone/index.html` | Главная |
| `hypn0/halftone/management/commands/cleanup_temp.py` | Очистка temp (14 дней) | | `hypn0/hypn0_site/management/commands/rescore.py` | Скоринг и детекция аномалий |
| `hypn0/halftone/management/commands/cleanup_reports.py` | Очистка по жалобам | | `hypn0/hypn0_site/management/commands/rehash.py` | Синхронизация s_hash_id с солью |
| `hypn0/hypn0_site/management/commands/cleanup_storage.py` | Очистка SmartRetention |
| `hypn0/hypn0_site/management/commands/cleanup_reports.py` | Очистка по жалобам |
| `_blueprint/management-commands.md` | Документация CLI-команд |
| `public/static/css/site.css` | Стили | | `public/static/css/site.css` | Стили |
| `public/static/js/site.js` | JS | | `public/static/js/site.js` | JS |
| `.env.sample` | Env template | | `.env.sample` | Env template |
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "hypn0-codemirror6",
"version": "0.1.0",
"description": "CodeMirror build for Hypn0 Django Admin",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/editor.js --bundle --format=iife --minify --outfile=${CM6_OUTPUT_DIR:-../../public/static/codemirror}/editor.js"
},
"devDependencies": {
"@babel/runtime": "7.29.7",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.1",
"@codemirror/language": "6.12.3",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@uiw/codemirror-theme-solarized": "4.25.10",
"esbuild": "0.28.0"
}
}
+8 -5
View File
@@ -93,6 +93,7 @@ TEMPLATES = [
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.template.context_processors.csrf',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'hypn0_site.context_processors.site_context', 'hypn0_site.context_processors.site_context',
@@ -199,11 +200,13 @@ STORAGES = {
}, },
} }
# Специфичные для проекта настройки # СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА НАСТРОЙКИ
LVL_CANDIDATE = 10 # ====== HASHIDS настройка для s_hash_id ======
LVL_PRE_MODERATED = 20 # Криптографическое кодирование ID svg-файлов генерации в компактные, необратимые коды.
LVL_MODERATED = 30 # Благодаря hashids невозможно восстановить исходный ID из кода,
LVL_LOCK_FOR_DELETION = 40 # даже зная алгоритм (в отличие от простого XOR).
HASHIDS_SALT = env('HASHIDS_SALT', default='your-secret-salt-change-me')
HASHIDS_MIN_LENGTH = env.int('HASHIDS_MIN_LENGTH', default=6)
if DEBUG: if DEBUG:
+518 -74
View File
@@ -1,74 +1,518 @@
# from django.contrib import admin # Кастомная конфигурация Django Admin для Hypn0.
# # Регистрируем модели с удобным интерфейсом.
# from .models import BlogPost, HalftoneItem, HalftoneVote
# import etpgrf
# import random
# @admin.register(HalftoneItem) import re
# class HalftoneItemAdmin(admin.ModelAdmin): import pytils
# list_display = ( from django import forms
# "hash_id", from django.contrib import admin
# "title", from django.forms import Textarea
# "likes_count", from django.utils.html import format_html
# "views_count", from django.utils.safestring import mark_safe
# "score", from django.http import HttpRequest
# "is_curated", from bs4 import BeautifulSoup
# "is_public", from html import unescape
# "created_at", from .models import (
# ) TbHypn0Item,
# list_filter = ( TbVote,
# "is_curated", TbBlogPost
# "is_public", )
# "created_at",
# ) # ============================================================================
# search_fields = ( # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# "hash_id", # ============================================================================
# "title", def clean_html_to_plain_text(html_content: str) -> str:
# "promo_title", """
# ) Очищает HTML-контент от тегов, скриптов, стилей и декодирует HTML-сущности.
# readonly_fields = ( Возвращает чистый читаемый текст.
# "created_at", """
# "score", if not html_content:
# "file_size", return ""
# ) soup = BeautifulSoup(html_content, "html.parser")
# # Скрипты, стили и форматирование вырезаем целиком
# for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]):
# @admin.register(HalftoneVote) tag.decompose()
# class HalftoneVoteAdmin(admin.ModelAdmin): return unescape(soup.get_text()).strip()
# list_display = (
# "item",
# "fingerprint", # ============================================================================
# "created_at", # МИКСИНЫ ДЛЯ АДМИНКИ
# ) # ============================================================================
# list_filter = ("created_at",) class RequestInFormMixin(admin.ModelAdmin):
# search_fields = ( """
# "item__hash_id", Миксин для передачи request объекта в форму.
# "item__title",
# "fingerprint", Используется когда форма нуждается в доступе к request для проверки POST параметров
# ) или другой информации о текущем HTTP-запросе.
# readonly_fields = ("created_at",)
# Переопределяет get_form() и передает request в __init__ формы через kwargs.
# """
# @admin.register(BlogPost)
# class BlogPostAdmin(admin.ModelAdmin): def get_form(self, request: HttpRequest, obj=None, **kwargs):
# list_display = ( """
# "title", Переопределяем get_form чтобы передать request в форму.
# "slug", Создаем оборачивающий класс который передаст request в __init__.
# "is_published", """
# "published_at", FormClass = super().get_form(request, obj, **kwargs)
# "created_at",
# ) # Сохраняем request в замыкании для доступа в классе
# list_filter = ( request_ref = request
# "is_published",
# "published_at", class FormWithRequest(FormClass):
# ) """Оборачивающий класс, который передает request при инстанцировании"""
# search_fields = ( def __init__(form_instance, *args, **init_kwargs):
# "title", # Добавляем request в kwargs перед вызовом __init__ родителя
# "slug", init_kwargs['request'] = request_ref
# "summary", super().__init__(*args, **init_kwargs)
# "content",
# ) return FormWithRequest
# prepopulated_fields = {"slug": ("title",)}
# readonly_fields = (
# "created_at", class CodeMirrorFormMixin(forms.ModelForm):
# "updated_at", """
# ) Миксин для форм с поддержкой CodeMirror редактора.
Предоставляет:
- Готовый Media класс с CSS и JS для CodeMirror
- Helper метод setup_codemirror_field() для конфигурации полей
- Базовые атрибуты для активации CodeMirror
Использование:
class MyForm(CodeMirrorFormMixin):
class Meta:
model = MyModel
fields = (...)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup_codemirror_field('field_name', 'json', 'codemirror-width-l')
"""
# ===== MEDIA КЛАСС ДЛЯ CODEMIRROR =====
class Media:
"""Подключаем CSS и JS для CodeMirror редактора"""
css = {
'all': ('codemirror/codemirror-styles.css',) # Стили для CodeMirror
}
js = (
'codemirror/editor.js', # Основной CodeMirror
'codemirror/codemirror-patch.js', # Патч для управления высотой/шириной
)
# ===== БАЗОВЫЕ АТРИБУТЫ CODEMIRROR =====
CODEMIRROR_ATTRS_BASE = {
'data-codemirror-editor': '1',
'data-width': '100%', # Ширина для патча (100% займет полную ширину)
}
def setup_codemirror_field(self, field_name: str, language: str = 'text', css_class: str = 'codemirror-width-l'):
"""
Конфигурирует поле для использования CodeMirror редактором.
Применяет Textarea виджет с нужными атрибутами и CSS классами для CodeMirror.
Args:
field_name: Имя поля в форме (например, 's_label')
language: Язык для подсветки синтаксиса (text, json, html, url и т.д.)
css_class: CSS класс для управления размерами (codemirror-width-s, codemirror-width-l и т.д.)
Пример:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup_codemirror_field('s_label', 'text', 'codemirror-width-xl codemirror-no-lines')
self.setup_codemirror_field('j_metadata', 'json', 'codemirror-width-l')
"""
# Собираем атрибуты для поля
attrs = {
**self.CODEMIRROR_ATTRS_BASE,
'data-language': language,
'class': css_class,
}
# Применяем Textarea виджет с атрибутами
self.fields[field_name].widget = Textarea(attrs=attrs)
@admin.register(TbHypn0Item)
class TbHypn0ItemAdmin(admin.ModelAdmin):
"""
Панель управления гипно-картинами (модерация, просмотр статистики, редактирование).
Создание новых картин через админку заблокировано (создание только на фронтенде).
"""
list_display = (
"id",
"s_hash_id",
"title_preview",
"i_level",
"i_likes_count",
"i_claims_count",
"i_views_count",
"f_score",
"is_public",
"d_created_at",
)
list_display_links = ("id", "s_hash_id", "title_preview")
list_filter = ("i_level", "is_public", "d_created_at")
search_fields = ("s_hash_id", "s_title", "s_promo_title", "s_promo_url")
readonly_fields = (
"id",
"s_hash_id",
"i_file_size",
"i_likes_count",
"i_views_count",
"i_claims_count",
"i_promo_clicks",
"d_created_at",
"d_updated_at",
"svg_preview",
)
fieldsets = (
(
"Основная информация",
{
"fields": (
"id",
"s_hash_id",
"s_title",
"file_svg",
"svg_preview",
"i_file_size",
"is_public",
)
},
),
(
"Модерация и Smart Retention",
{
"fields": (
"i_level",
"f_score",
),
"description": "Уровень модерации и рейтинг 'гравитации' для ротации и очистки хранилища.",
},
),
(
"Метрики популярности",
{
"fields": (
"i_likes_count",
"i_claims_count",
"i_views_count",
),
},
),
(
"Промо-блок (Спонсоры и Авторы)",
{
"fields": (
"s_promo_url",
"s_promo_title",
"i_promo_clicks",
),
"classes": ("collapse",),
},
),
(
"Метаданные генерации",
{
"fields": ("j_metadata",),
"classes": ("collapse",),
},
),
(
"Временные метки",
{
"fields": ("d_created_at", "d_updated_at"),
"classes": ("collapse",),
},
),
)
def has_add_permission(self, request):
# Создание картин возможно только с фронтенда (требуется обработка растра и согласие пользователя)
return False
@admin.display(description="Название (HTML)")
def title_preview(self, obj):
if obj and obj.s_title:
return mark_safe(unescape(obj.s_title))
return ""
@admin.display(description="Превью SVG")
def svg_preview(self, obj):
if obj.file_svg:
return format_html(
'<div style="max-width: 250px; max-height: 250px; background: #1e293b; padding: 8px; border-radius: 8px;">'
'<img src="{}" style="max-width: 100%; max-height: 230px;" />'
'</div>',
obj.file_svg.url,
)
return "Нет файла"
# ----
# АДМИНКА ЛАЙКОВ/ДИСЛАЙКОВ
@admin.register(TbVote)
class TbVoteAdmin(admin.ModelAdmin):
"""
Просмотр анонимного журнала голосов и отпечатков (только для чтения).
"""
list_display = ("id", "k_item", "i_direction", "fingerprint_short", "d_created_at")
list_filter = ("i_direction", "d_created_at")
search_fields = ("s_fingerprint", "k_item__s_hash_id", "k_item__s_title")
readonly_fields = ("id", "k_item", "i_direction", "s_fingerprint", "d_created_at")
ordering = ("-d_created_at",)
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@admin.display(description="Отпечаток (хэш)")
def fingerprint_short(self, obj):
return f"{obj.s_fingerprint[:12]}..."
# ----
# АДМИНКА БЛОГА
# Кастомная форма
class BlogPostAdminForm(CodeMirrorFormMixin):
"""
Кастомная форма для админки потов в блог (TbBlogPost).
Добавляет виджеты CodeMirror для текстовых полей.
"""
# Виртуальные поля для настройки типографа
etp_enable = forms.BooleanField(
label="Включить типограф",
initial=False,
required=False,
help_text="Включить автоматическую типографику для HTML полей (заголовок, тизер, контент)&nbsp;&nbsp;&nbsp;"
)
etp_language = forms.ChoiceField(
label="Язык типографики",
choices=[('ru', 'Русский'), ('en', 'English'), ('ru,en', 'Ru + En')],
initial='ru',
required=False
)
etp_quotes = forms.BooleanField(
label="Кавычки",
initial=True,
required=False,
help_text="Заменять кавычки<br/>(«ёлочки» для русского, “лапки” для английского)&nbsp;&nbsp;&nbsp;"
)
etp_hyphenation = forms.BooleanField(
label="Расставлять переносы",
initial=True,
required=False,
help_text="Расставлять мягкие переносы (&amp;shy;)&nbsp;&nbsp;&nbsp;<br/>"
"в словах длиннее <b>14</b> символов&nbsp;&nbsp;&nbsp;"
)
etp_sanitize = forms.BooleanField(
label="Очистка HTML",
initial=False,
required=False,
help_text="Удалять весь HTML из исходного текста&nbsp;&nbsp;&nbsp;"
)
etp_hanging_punctuation = forms.BooleanField(
label="Висячая пунктуация",
initial=False,
required=False,
help_text="Выносить пунктуацию в начало строк<br/>&nbsp;&nbsp;&nbsp;"
"(только для заголовков... для тизера и контента отключается автоматически)&nbsp;&nbsp;&nbsp;"
)
etp_mode = forms.ChoiceField(
label="Режим вывода",
choices=[('mixed', 'Смешанный (Mixed)'), ('unicode', 'Юникод (Unicode)'), ('mnemonic', 'Мнемоники')],
initial='mixed',
required=False,
help_text="Формат спецсимволов (например, кавычек, тире, многоточий) в&nbsp;HTML: смешанный, юникод"
"&nbsp;или мнемоники&nbsp;&nbsp;&nbsp;"
)
class Meta:
model = TbBlogPost
fields = (
# Виртуальные поля для настройки типографа
'etp_enable', 'etp_language', 'etp_quotes', 'etp_hyphenation', 'etp_sanitize',
'etp_hanging_punctuation', 'etp_mode',
# Остальные поля модели TbArticle
's_title', 'slug', 's_teaser', 's_content', 'f_cover_img',
'is_published', 'd_published_at',
)
def __init__(self, *args, **kwargs):
"""
При инициализации формы подгружаем CodeMirror редактор.
Получаем request из kwargs, переданных из get_form_kwargs в AdminClass.
"""
# Извлекаем request из kwargs если он есть
self.request = kwargs.pop('request', None)
super().__init__(*args, **kwargs)
# Конфигурируем поля для CodeMirror
self.setup_codemirror_field('s_title', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('slug', language='text',
css_class='codemirror-width-l codemirror-no-lines')
self.setup_codemirror_field('s_teaser', language='html',
css_class='codemirror-width-xl codemirror-min-height-5')
self.setup_codemirror_field('s_content', language='html',
css_class='codemirror-width-xl codemirror-min-height-10')
@admin.register(TbBlogPost)
class TbBlogPostAdmin(RequestInFormMixin, admin.ModelAdmin):
"""
Управление статьями блога, документацией и инфо-страницами (HTML + etpgrf).
"""
form = BlogPostAdminForm
list_display = ("id", "article_thumbnail", "s_title_display", "slug", "is_published", "d_published_at", "d_created_at")
list_display_links = ("id", "s_title_display")
list_filter = ("is_published", "d_published_at", "d_created_at")
search_fields = ("s_title", "slug", "s_teaser", "s_content")
prepopulated_fields = {"slug": ("s_title",)}
readonly_fields = ("id", "d_created_at", "d_updated_at")
fieldsets = (
("Атрибуты публикации", {
"fields": ("is_published", "slug", "d_published_at", )
}),
("Основные поля", {
"fields": ("s_title", "f_cover_img", "s_teaser", "s_content", ),
"description": "В полях допускается HTML, поддерживается чистая верстка, настройки типографа etpgrf в"
" следующем блоке.",
}),
('Типограф', {
'fields': (('etp_enable',), ('etp_language', 'etp_mode'),
('etp_quotes', 'etp_hyphenation', 'etp_sanitize', 'etp_hanging_punctuation')),
# 'classes': ('collapse',),
'description': 'Типограф применяется при сохранении и срабатывает на HTML-поля (ЗАГОЛОВОК, ТИЗЕР СТАТЬИ'
' и СТАТЬЯ). Если выключить — HTML будет сохранен без изменений.',
}),
("Системные даты", {
"fields": ("id", "d_created_at", "d_updated_at", ),
"classes": ("collapse",),
}),
)
@admin.display(description="Обложка")
def article_thumbnail(self, obj):
"""
Отображает миниатюру изображения статьи в списке (аккуратный квадрат 36x36 px).
Работает со стандартным ImageField (f_cover_img) без внешних библиотек.
"""
if obj and obj.f_cover_img:
try:
return format_html(
'<img src="{}" width="20" height="20" style="border: 1px solid silver;" title="{}" alt="" />',
obj.f_cover_img.url,
obj.s_title,
)
except Exception:
return mark_safe('<span style="color: #999">(ошибка)</span>')
return mark_safe(
'<img width="20" height="20" style="border: 1px solid silver; background: #90909060;" title="Нет картинки" alt="" />'
)
def save_model(self, request, obj, form, change):
"""
Переопределяем save_model для применения:
1. Tипографа etpgrf для полей s_title, s_teaser и s_content
2. Генерации Slug c транслитерацией
Args:
request: HTTP-запрос (содержит info о пользователе)
obj: инстанция TbArticle для сохранения
form: валидированная форма
change: True если редактирование, False если создание
"""
# 1. ТИПОГРАФ ETPGRF
# Проверяем, включен ли типограф
if form.cleaned_data.get('etp_enable', True):
# Получаем все настройки из формы
langs = form.cleaned_data.get('etp_language', 'ru').split(',')
# 1.1. LayoutProcessor: включаем layout с базовыми настройками
layout_option = etpgrf.LayoutProcessor(
langs=langs,
process_initials_and_acronyms=True,
process_units=True
)
# 1.2. Hyphenator (переносы слов)
hyphenation_option = False
if form.cleaned_data.get('etp_hyphenation', True):
hyphenation_option = etpgrf.Hyphenator(
langs=langs,
max_unhyphenated_len=14
)
# 1.3. Sanitizer (очистка HTML перед типографированием)
# Режимы: 'html' (удаляет все теги), 'etp' (только висячая пунктуация), None/False (ничего не делает)
if form.cleaned_data.get('etp_sanitize', True):
sanitizer_option = 'html' # Удаляет все HTML-теги
else:
sanitizer_option = False # Санитайзер отключен
# 1.4. Базовые настройки типографа (используются для всех полей)
base_options = {
'langs': langs,
'process_html': True,
'quotes': form.cleaned_data.get('etp_quotes', True),
'layout': layout_option,
'unbreakables': True,
'hyphenation': hyphenation_option,
'sanitizer': sanitizer_option,
'symbols': True,
'mode': form.cleaned_data.get('etp_mode', 'mixed'),
}
# 1.5. Для заголовков: висячая пунктуация может быть включена
options_title = {
**base_options,
'hanging_punctuation': form.cleaned_data.get('etp_hanging_punctuation', True),
}
t_title = etpgrf.Typographer(**options_title)
if obj.s_title:
obj.s_title = t_title.process(obj.s_title)
# 1.6. Для тизера и контента: висячая пунктуация всегда отключена
options_body = {
**base_options,
'hanging_punctuation': False,
}
t_body = etpgrf.Typographer(**options_body)
if obj.s_teaser:
obj.s_teaser = t_body.process(obj.s_teaser)
if obj.s_content:
obj.s_content = t_body.process(obj.s_content)
# 2. SLUG
if not obj.slug:
# 2.0. Если вдруг нет заголовка, то генерируем случайный slug
if not obj.s_title:
obj.slug = f"title-{random.randint(1, 4095):03x}"
else:
# 2.1. Очищаем текст от HTML и спецсимволов через вспомогательную функцию
plain_title = clean_html_to_plain_text(obj.s_title)
# 2.2. Транслитерируем и создаем slug (pytils подходит для русского)
obj.slug = pytils.translit.slugify(plain_title) if plain_title else ""
# 2.3. Нормализуем множественные дефисы, удаляем дефисы в начале/конце
obj.slug = re.sub(pattern=r"-+", repl="-", string=obj.slug).strip("-")
# 2.4. Если все еще нет slug (например заголовок из спец-символов) — генерируем
obj.slug = obj.slug or f"title-{random.randint(1, 4095):03x}"
# 3. Вызываем родительский save_model который вызовет obj.save()
super().save_model(request, obj, form, change)
@admin.display(description="Заголовок")
def s_title_display(self, obj):
# Отображаем HTML-заголовок статьи как безопасный HTML
if obj and obj.s_title:
return mark_safe(unescape(obj.s_title))
return ""
+6 -4
View File
@@ -1,10 +1,12 @@
from django.conf import settings from django.conf import settings
from django.http import HttpRequest from django.http import HttpRequest
# Имя куки, которая ставится клиенту, когда он СОГЛАСИЛСЯ на отслеживание # Имя куки с UUID устройства посетителя. Устанавливается при согласии
# (Google Tag Manager, Яндекс.Метрика и т.п.). Если кука присутствует — # с условиями ("Подчиниться Гипножабе!").
# предупреждение о трекинге больше не показываем. # Одновременно служит:
ALLOW_TRACKING_COOKIE_NAME = "allow_tracking" # 1) Флагом согласия на отслеживание (ALLOW_TRACKING).
# 2) Анонимным идентификатором устройства для учета голосов (TbVote) без сбора ПДн.
ALLOW_TRACKING_COOKIE_NAME = "hypn0_vid"
def site_context(request: HttpRequest) -> dict: def site_context(request: HttpRequest) -> dict:
+97
View File
@@ -0,0 +1,97 @@
from django import forms
class HalftoneGenerateForm(forms.Form):
"""
Форма валидации входных параметров для генерации гипнотического SVG.
"""
SHAPE_CHOICES = [
("circle", "Круг"),
("ring", "Кольцо"),
("triangle", "Треугольник"),
("square", "Квадрат"),
("diamond", "Ромб"),
("hexagon", "Шестиугольник"),
("star", "Звезда"),
("cross", "Крестик"),
("line", "Линия"),
("wave", "Волна"),
("heart", "Сердечко"),
]
image = forms.ImageField(
required=True,
error_messages={
"required": "Покорми Гипножабу изображением!",
"invalid_image": "Гипножаба не может переварить этот файл. Нужна картинка (PNG, JPG, WEBP).",
},
)
shape = forms.ChoiceField(
choices=SHAPE_CHOICES,
initial="circle",
required=False,
)
cols = forms.IntegerField(
min_value=10,
max_value=80,
initial=35,
required=False,
)
max_radius = forms.IntegerField(
min_value=3,
max_value=20,
initial=8,
required=False,
)
blink = forms.IntegerField(
min_value=0,
max_value=10,
initial=6,
required=False,
)
rotation = forms.IntegerField(
min_value=-15,
max_value=15,
initial=0,
required=False,
)
scale = forms.IntegerField(
min_value=800,
max_value=1040,
initial=980,
required=False,
)
angle = forms.IntegerField(
min_value=-44,
max_value=45,
initial=0,
required=False,
)
def clean_shape(self):
shape = self.cleaned_data.get("shape")
if not shape:
return "circle"
return shape
def clean_cols(self):
return self.cleaned_data.get("cols") or 80
def clean_max_radius(self):
return self.cleaned_data.get("max_radius") or 8
def clean_blink(self):
val = self.cleaned_data.get("blink")
return 6 if val is None else val
def clean_rotation(self):
val = self.cleaned_data.get("rotation")
return 0 if val is None else val
def clean_scale(self):
val = self.cleaned_data.get("scale")
return 980 if val is None else val
def clean_angle(self):
val = self.cleaned_data.get("angle")
return 0 if val is None else val
@@ -0,0 +1,105 @@
from django.core.management.base import BaseCommand
from django.conf import settings
from hashids import Hashids
from hypn0_site.models import TbHypn0Item
class Command(BaseCommand):
"""
Проверка и синхронизация s_hash_id с текущими настройками HASHIDS_SALT.
АРХИТЕКТУРНОЕ НАЗНАЧЕНИЕ:
=========================
1. Изоляция сред (Dev / Stage / Prod) и ротация секретов:
В разных окружениях или при переносе базы данных значение HASHIDS_SALT может
отличаться от исходного.
2. Детерминированность и обратимость:
Hashids — это однозначное обратимое кодирование: pk <-> s_hash_id.
Если соль изменилась, старые s_hash_id в базе перестают соответствовать
текущей конфигурации проекта.
3. Назначение команды:
- Сверяет каждый s_hash_id в базе с тем, что должен генерироваться при текущем settings.HASHIDS_SALT.
- Выявляет битые, пустые или сгенерированные со старой солью идентификаторы.
- Безопасно обновляет s_hash_id без изменения первичных ключей (pk) и других полей картины.
"""
help = "Проверка целостности и перегенерация s_hash_id картин под текущий HASHIDS_SALT"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Тестовый прогон: только проверить несоответствия без записи изменений в БД",
)
parser.add_argument(
"--batch-size",
type=int,
default=500,
help="Размер пакета для пакетного обновления (по умолчанию: 500)",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
batch_size = options["batch_size"]
salt = getattr(settings, "HASHIDS_SALT", "hypn0_default_salt")
min_length = getattr(settings, "HASHIDS_MIN_LENGTH", 6)
hasher = Hashids(salt=salt, min_length=min_length)
self.stdout.write(self.style.NOTICE(f"Старт проверки s_hash_id (соль: '{salt[:4]}***', min_length: {min_length})..."))
items = TbHypn0Item.objects.all().only("id", "s_hash_id")
total_items = items.count()
if total_items == 0:
self.stdout.write(self.style.WARNING("В базе данных нет картин для проверки."))
return
to_update = []
mismatched_count = 0
empty_count = 0
for item in items.iterator(chunk_size=batch_size):
expected_hash = hasher.encode(item.id)
if not item.s_hash_id:
empty_count += 1
item.s_hash_id = expected_hash
to_update.append(item)
elif item.s_hash_id != expected_hash:
mismatched_count += 1
self.stdout.write(
f"Несоответствие [ID={item.id}]: текущий='{item.s_hash_id}' -> ожидаемый='{expected_hash}'"
)
item.s_hash_id = expected_hash
to_update.append(item)
# Пакетное сохранение порциями
if not dry_run and len(to_update) >= batch_size:
TbHypn0Item.objects.bulk_update(to_update, fields=["s_hash_id"])
to_update.clear()
# Сохранение остатка
if not dry_run and to_update:
TbHypn0Item.objects.bulk_update(to_update, fields=["s_hash_id"])
total_issues = empty_count + mismatched_count
self.stdout.write("--------------------------------------------------")
self.stdout.write(f"Всего проверено записей: {total_items}")
self.stdout.write(f"Пустых s_hash_id: {empty_count}")
self.stdout.write(f"Несоответствующих s_hash_id: {mismatched_count}")
if total_issues == 0:
self.stdout.write(self.style.SUCCESS("Все s_hash_id в базе идеально соответствуют текущей соли!"))
else:
if dry_run:
self.stdout.write(
self.style.WARNING(f"[DRY RUN] Найдено {total_issues} несоответствий. Запустите без --dry-run для применения.")
)
else:
self.stdout.write(
self.style.SUCCESS(f"Успешно обновлено {total_issues} записей под текущую конфигурацию.")
)
@@ -0,0 +1,250 @@
import math
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.conf import settings
from django.db.models import Count, Q
from hypn0_site.models import TbHypn0Item, TbVote
class Command(BaseCommand):
"""
Пакетный пересчет рейтинга популярности (f_score) и выявление аномалий (накрутка / шейминг).
АРХИТЕКТУРНЫЕ ПРИНЦИПЫ СКОРИНГА HYPN0:
======================================
1. Изолированный атомарный скоринг бесполезен:
Оценивать одну картину в вакууме нельзя, так как система не видит контекста:
динамики платформы, скорости притока голосов и фонового шума.
2. Рейтинг f_score нужен для Smart Retention (умной очистки диска):
Он определяет, какие файлы удалять первыми при превышении дисковой квоты,
а какие оставлять как ценные экспонаты.
3. Разделение UI-счетчиков и весов скоринга:
Поля `i_likes_count` и `i_claims_count` в TbHypn0Item используются для быстрого рендера в UI.
Истинные веса (+1 лайк, +2 автор, -2 жалоба) + штампы-времени и sha256 от фингер-принтами браузеров хранятся в TbVote.
4. Учет "старения" голосов (Time-Decay) (алгоритм, возможно, будет пересмотрен):
Свежий лайк имеет полный вес, а старый голос плавно затухает по экспоненциальному закону:
Weight(t) = BaseWeight * 2^(-dt / HalfLife).
5. "Средняя температура по больнице" и Коридоры аномалий (алгоритм, возможно, будет пересмотрен:
Перед расчетом анализируется средняя скорость голосования по всей платформе за окно (например, 24ч).
- Спайк лайков (> N раз выше нормы): подозрение на накрутку (Level.SUSPICIOUS).
- Спайк клеймов (> N% от голосов за короткий промежуток): скоординированный шейминг (Level.SHAMED).
- Стабильный интерес: прогретая картина переходит в Level.LEVEL_1 (ожидает модерации).
6. Базовая формула гравитации с логарифмическим сглаживанием:
Score = (ln(1 + max(0, WeightedVotes)) + Bonus_level) / ((Age_in_hours + 2) ^ GAMMA)
"""
help = "Пакетный пересчет рейтинга популярности f_score и выявление аномалий в голосах"
def add_arguments(self, parser):
parser.add_argument(
"--gamma",
type=float,
default=getattr(settings, "GAMMA", 1.5),
help="Коэффициент гравитации затухания картины по времени (по умолчанию: 1.5)",
)
parser.add_argument(
"--half-life-days",
type=float,
default=14.0,
help="Период полураспада веса отдельного голоса в днях (по умолчанию: 14 дней)",
)
parser.add_argument(
"--spike-multiplier",
type=float,
default=4.0,
help="Во сколько раз скорость лайков должна превышать среднюю для детекции накрутки (по умолчанию: 4.0)",
)
parser.add_argument(
"--shame-claim-ratio",
type=float,
default=0.35,
help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%)",
)
parser.add_argument(
"--warmup-votes-threshold",
type=int,
default=10,
help="Минимальное число взвешенных голосов для перехода CANDIDATE -> LEVEL_1 (по умолчанию: 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Тестовый прогон: выполнить все расчеты без записи изменений в БД",
)
def handle(self, *args, **options):
# =========================================================================================
# КОНФИГУРАЦИОННЫЕ КОНСТАНТЫ И "МАГИЧЕСКИЕ ЧИСЛА" СКОРИНГА
# =========================================================================================
# 1. Параметры времени и окна анализа
WINDOW_HOURS = 24.0 # Размер скользящего окна (в часах) для оценки текущей активности
ACTIVE_DAYS_HORIZON = 30.0 # Горизонт (в днях), картины моложе которого считаются "активными" для базы нормы
# 2. Пороги выборки и детекции аномалий
MIN_VOTES_FOR_ANOMALY = 5 # Минимальное число свежих оценок за окно, до которого детекторы аномалий спят
MIN_LIKES_FOR_SPIKE = 20 # Минимальное число лайков за окно, необходимое для признания относительного спайка
COLD_START_SPIKE_LIKES = 50 # Абсолютное число лайков за окно для детекции спайка в режиме "холодного старта"
# 3. Бонусы к рейтингу за модераторский статус (веса числителя формулы)
LEVEL_1_BONUS = 1.5 # Бонус картине со статусом LEVEL_1 (прогрета / замечена модератором)
LEVEL_2_BONUS = 4.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр)
# 4. Коэффициент сглаживания инерции (EMA — Exponential Moving Average)
EMA_ALPHA = 0.7 # Доля нового значения в итоговом рейтинге (0.7 = 70% новый расчет + 30% старый f_score)
# 5. Смещение возраста в формуле гравитации (Hacker News Gravity Offset)
GRAVITY_AGE_OFFSET_HOURS = 2.0 # Добавка к возрасту картины в часах, исключающая деление на 0 в первые минуты жизни
# Параметры из аргументов CLI (переопределяемые)
gamma = options["gamma"]
half_life_days = options["half_life_days"]
spike_multiplier = options["spike_multiplier"]
shame_claim_ratio = options["shame_claim_ratio"]
warmup_threshold = options["warmup_votes_threshold"]
dry_run = options["dry_run"]
now = timezone.now()
self.stdout.write(self.style.NOTICE(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] Старт скоринга Hypn0..."))
# -----------------------------------------------------------------------------------------
# ШАГ 1: Определение "средней температуры по больнице" за последние WINDOW_HOURS
# -----------------------------------------------------------------------------------------
# ЗАЩИТА "ХОЛОДНОГО СТАРТА" (Cold Start / Нулевая база):
# Если проект только запущен или база пуста, средние значения равны нулю,
# а статистическая выборка отсутствует. В этом режиме детекторы спайков
# используют безопасные абсолютные пороги, чтобы не давать ложных тревог.
# -----------------------------------------------------------------------------------------
recent_window = now - timedelta(hours=WINDOW_HOURS)
recent_votes_qs = TbVote.objects.filter(d_created_at__gte=recent_window)
total_recent_votes = recent_votes_qs.count()
active_items_count = TbHypn0Item.objects.filter(
d_created_at__gte=now - timedelta(days=ACTIVE_DAYS_HORIZON)
).count()
if active_items_count == 0 or total_recent_votes == 0:
avg_votes_per_item_24h = 0.0
is_cold_start = True
self.stdout.write(
self.style.NOTICE("Режим [Холодный старт]: платформа только запустилась, голосов мало/нет.")
)
else:
avg_votes_per_item_24h = total_recent_votes / active_items_count
is_cold_start = False
self.stdout.write(
f"Базовые метрики за {int(WINDOW_HOURS)}ч: голосов всего={total_recent_votes}, "
f"активных картин={active_items_count}, среднее={avg_votes_per_item_24h:.2f} голосов/картину"
)
# -----------------------------------------------------------------------------------------
# ШАГ 2: Выборка картин для пересчета
# Бессмертные картины (Level.IMMORTAL) не требуют скоринга для очистки, но могут участвовать
# -----------------------------------------------------------------------------------------
items = TbHypn0Item.objects.exclude(i_level=TbHypn0Item.Level.IMMORTAL).prefetch_related("votes")
total_items = items.count()
if total_items == 0:
self.stdout.write(self.style.WARNING("В базе данных пока нет картин для пересчета."))
return
updated_count = 0
decay_lambda = math.log(2) / (half_life_days * 24.0) # константа затухания в час
for item in items:
# 1. Считаем взвешенные голоса с учетом возраста каждого голоса
item_votes = list(item.votes.all())
weighted_likes = 0.0
recent_likes_24h = 0
recent_claims_24h = 0
for v in item_votes:
vote_age_hours = max(0.0, (now - v.d_created_at).total_seconds() / 3600.0)
# Экспоненциальное затухание веса: e^(-lambda * dt)
decay_factor = math.exp(-decay_lambda * vote_age_hours)
weighted_likes += v.i_direction * decay_factor
if v.d_created_at >= recent_window:
if v.i_direction in (TbVote.Direction.LIKE, TbVote.Direction.AUTHOR):
recent_likes_24h += 1
elif v.i_direction == TbVote.Direction.CLAIM:
recent_claims_24h += 1
# 2. Детекция аномалий (шейминг vs спайк накрутки)
new_level = item.i_level
recent_total_24h = recent_likes_24h + recent_claims_24h
# Проверка аномалий активируется только при наличии минимальной выборки (>= MIN_VOTES_FOR_ANOMALY)
if recent_total_24h >= MIN_VOTES_FOR_ANOMALY:
# Проверка на шейминг (резкий наплыв жалоб)
claim_ratio = recent_claims_24h / recent_total_24h
if claim_ratio >= shame_claim_ratio:
new_level = TbHypn0Item.Level.SHAMED
self.stdout.write(
self.style.WARNING(f"Аномалия [SHAMED]: Картина {item.s_hash_id} доля жалоб={claim_ratio:.1%}")
)
# Проверка на спайк лайков (накрутка):
# На холодном старте (когда avg_votes_per_item_24h близко к 0) опираемся только на высокий абсолютный порог COLD_START_SPIKE_LIKES,
# чтобы 3-4 первых лайка от живых пользователей не помечались как подозрительные.
elif not is_cold_start and avg_votes_per_item_24h > 0:
if recent_likes_24h > (avg_votes_per_item_24h * spike_multiplier) and recent_likes_24h >= MIN_LIKES_FOR_SPIKE:
new_level = TbHypn0Item.Level.SUSPICIOUS
self.stdout.write(
self.style.WARNING(f"Аномалия [SUSPICIOUS]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}")
)
elif is_cold_start and recent_likes_24h >= COLD_START_SPIKE_LIKES:
new_level = TbHypn0Item.Level.SUSPICIOUS
self.stdout.write(
self.style.WARNING(f"Аномалия [SUSPICIOUS (Cold Start)]: Картина {item.s_hash_id} лайков за окно={recent_likes_24h}")
)
# Проверка перехода CANDIDATE -> LEVEL_1 (Прогрета, набрала массу)
if item.i_level == TbHypn0Item.Level.CANDIDATE and weighted_likes >= warmup_threshold and new_level == item.i_level:
new_level = TbHypn0Item.Level.LEVEL_1
self.stdout.write(
self.style.SUCCESS(f"Прогрев [CANDIDATE -> LEVEL_1]: Картина {item.s_hash_id} набрала вес={weighted_likes:.1f}")
)
# 3. Расчет нового f_score (Гравитационная модель + логарифм)
item_age_hours = max(0.0, (now - item.d_created_at).total_seconds() / 3600.0)
# Бонус от модераторского уровня
level_bonus = 0.0
if item.i_level == TbHypn0Item.Level.LEVEL_1:
level_bonus = LEVEL_1_BONUS
elif item.i_level == TbHypn0Item.Level.LEVEL_2:
level_bonus = LEVEL_2_BONUS
if weighted_likes <= 0:
raw_score = weighted_likes # Отрицательный или нулевой скор
else:
raw_score = (math.log(1.0 + weighted_likes) + level_bonus) / ((item_age_hours + GRAVITY_AGE_OFFSET_HOURS) ** gamma)
# 4. Инерционное сглаживание со старым значением (EMA) для исключения скачков
if item.f_score != 0.0:
final_score = EMA_ALPHA * raw_score + (1.0 - EMA_ALPHA) * item.f_score
else:
final_score = raw_score
# 5. Сохранение результатов
if not dry_run:
item.f_score = round(final_score, 6)
if new_level != item.i_level:
item.i_level = new_level
item.save(update_fields=["f_score", "i_level", "d_updated_at"])
else:
item.save(update_fields=["f_score", "d_updated_at"])
updated_count += 1
status_msg = "ПРОБНЫЙ ПРОГОН (DRY RUN)" if dry_run else "УСПЕШНО ОБНОВЛЕНО"
self.stdout.write(
self.style.SUCCESS(f"[{status_msg}] Обработано картин: {updated_count}/{total_items}")
)
@@ -0,0 +1,79 @@
# Generated by Django 6.1 on 2026-08-22 11:14
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='TbBlogPost',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False, verbose_name='ID')),
('s_title', models.CharField(help_text='HTML-заголовок (с HTML-тегами, типографированный etpgrf)', max_length=255, verbose_name='Заголовок')),
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-слаг')),
('s_teaser', models.TextField(blank=True, help_text='HTML-контент тизера, очищенный и типографированный etpgrf', max_length=1024, verbose_name='Краткое описание / Лид')),
('s_content', models.TextField(help_text='HTML-контент статьи, очищенный и типографированный etpgrf', verbose_name='Основной HTML-контент')),
('f_cover_img', models.ImageField(blank=True, null=True, upload_to='blog/covers/%Y/', verbose_name='Обложка')),
('is_published', models.BooleanField(default=False, verbose_name='Опубликовано')),
('d_published_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Дата публикации')),
('d_created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('d_updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Статья блога / Страница',
'verbose_name_plural': 'Статьи блога / Страницы',
'ordering': ['-d_published_at', '-d_created_at'],
},
),
migrations.CreateModel(
name='TbHypn0Item',
fields=[
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID')),
('s_hash_id', models.CharField(max_length=16, unique=True, verbose_name='ID-Хэш')),
('s_title', models.CharField(help_text='Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.', max_length=255, verbose_name='Название генерации')),
('file_svg', models.FileField(help_text='Результирующий SVG-файл генерации', upload_to='svg/%Y/%m/', verbose_name='SVG-файл')),
('i_file_size', models.PositiveIntegerField(default=0, help_text='Размер файла в байтах', verbose_name='Размер файла')),
('j_metadata', models.JSONField(blank=True, default=dict, help_text='Метаданные и параметры для возможного клонирования настроек', verbose_name='Параметры генерации')),
('i_likes_count', models.PositiveIntegerField(db_index=True, default=0, verbose_name='Число лайков')),
('i_views_count', models.PositiveIntegerField(default=0, verbose_name='Число просмотров')),
('i_claims_count', models.PositiveIntegerField(default=0, verbose_name='Число жалоб')),
('f_score', models.FloatField(db_index=True, default=0.0, help_text='Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять', verbose_name='Рейтинг популярности')),
('i_level', models.IntegerField(choices=[(0, 'Candidate: Шум сознания'), (10, 'Voted: Плеск бессознательного'), (30, 'Moderated: Одобрено Мозговым Слизнем'), (1000, 'Locked: Глубокий транс')], default=0, help_text='Уровень', verbose_name='В какой группе находится SVG-файл генерации')),
('is_public', models.BooleanField(default=True, help_text='Доступность по прямой ссылке', verbose_name='Публичный доступ')),
('s_promo_url', models.URLField(blank=True, default='', verbose_name='Промо-ссылка')),
('s_promo_title', models.CharField(blank=True, default='', max_length=100, verbose_name='Бейдж/автор промо')),
('i_promo_clicks', models.PositiveIntegerField(default=0, verbose_name='Клики по промо')),
('d_created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('d_updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Гипно-картина',
'verbose_name_plural': 'Гипно-картины',
'ordering': ['-d_created_at'],
},
),
migrations.CreateModel(
name='TbVote',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='ID')),
('i_direction', models.IntegerField(choices=[(1, 'Like (+1)'), (-2, 'Clime (-2)'), (2, 'Author Like (+2)')], default=1, help_text='Направление голоса', verbose_name='Направление')),
('s_fingerprint', models.CharField(db_index=True, help_text='SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств', max_length=64, verbose_name='Хэш устройства/отпечаток')),
('d_created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата голосования')),
('k_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='hypn0_site.tbhypn0item', verbose_name='Картина')),
],
options={
'verbose_name': 'Голос / Лайк',
'verbose_name_plural': 'Голоса / Лайки',
'ordering': ['-d_created_at'],
'constraints': [models.UniqueConstraint(fields=('k_item', 's_fingerprint'), name='unique_item_fingerprint_vote')],
},
),
]
@@ -0,0 +1,62 @@
# Generated by Django 6.1 on 2026-08-24 11:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hypn0_site', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='tbvote',
options={'ordering': ['-d_created_at'], 'verbose_name': 'Голос/Лайк', 'verbose_name_plural': 'Голоса/Лайки'},
),
migrations.AlterField(
model_name='tbblogpost',
name='id',
field=models.SmallAutoField(help_text='Первичный ключ (SmallAutoField, до 32 767 записей)', primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_claims_count',
field=models.PositiveIntegerField(default=0, help_text='Число жалоб на гипнотическую SVG-картину. Поле используется для отображения в интерфейсе. Для расчета рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.', verbose_name='Число жалоб'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_level',
field=models.IntegerField(choices=[(0, 'Candidate: Шум сознания'), (10, 'Voted: Плеск бессознательного'), (30, 'Moderated: Одобрено Мозговым Слизнем'), (1000, 'Locked: Глубокий транс'), (-20, 'Шейминг (аномальный наплыв жалоб)'), (-10, 'Подозрение на накрутку (спайк лайков)')], default=0, help_text='Уровень', verbose_name='В какой группе находится SVG-файл генерации'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_likes_count',
field=models.PositiveIntegerField(db_index=True, default=1, help_text='Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.', verbose_name='Число лайков'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='i_views_count',
field=models.PositiveIntegerField(default=1, verbose_name='Число просмотров'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='id',
field=models.AutoField(help_text='Первичный ключ (~2.14 млрд записей, 4 байта)', primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='tbhypn0item',
name='s_hash_id',
field=models.CharField(editable=False, max_length=16, unique=True, verbose_name='ID-Хэш'),
),
migrations.AlterField(
model_name='tbvote',
name='i_direction',
field=models.IntegerField(choices=[(1, 'Like (+1)'), (-2, 'Claim (-2)'), (2, 'Author Like (+2)')], default=1, help_text='Направление голоса', verbose_name='Направление'),
),
migrations.AlterField(
model_name='tbvote',
name='id',
field=models.BigAutoField(help_text='Первичный ключ (BigAutoField, 8 байт)', primary_key=True, serialize=False, verbose_name='ID'),
),
]
+236 -37
View File
@@ -1,40 +1,75 @@
from django.db import models import hashlib
from django.db import models, transaction, IntegrityError
from django.db.models import F
from hashids import Hashids
from hypn0.settings import * from hypn0.settings import *
from django.utils import timezone
class TbHypn0Item(models.Model): class TbHypn0Item(models.Model):
"""Единая модель для генераций, публичных шеров и витрины галереи.""" """
class Level(models.IntegerChoices): Единая модель для генераций, публичных шеров и витрины галереи.
CANDIDATE = LVL_CANDIDATE, 'Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
LEVEL_1 = LVL_PRE_MODERATED, 'Плеск бессознательного' # Есть "лайки", не проверено, можно удалять при низком score
LEVEL_2 = LVL_MODERATED, 'Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
IMMORTAL = LVL_LOCK_FOR_DELETION, 'Глубокий транс' # Нельзя удалять, даже при низком score
Поля:
• id: идентификатор записи (AutoField, 4 байта, до ~2.14 млрд)
• s_hash_id: хэш-сумма шеринга для SVG-генерации (Hashids)
• s_title: Заголовок (до 255 символов, разрешен HTML)
• file_svg: SVG-файл генерации
• i_file_size: размер файла в байтах
• j_metadata: метаданные файла JSON (параметры алгоритма генерации)
• i_likes_count: количество лайков
• i_views_count: количество просмотров
• i_claims_count: количество жалоб/претензий (клеймов)
• f_score: оценка для стратегии хранения и умной очистки (Smart Retention)
• i_level: уровень хранения (свежее, модерированное, защищенное от удаления)
• is_public: доступность по внешней ссылке
• s_promo_url: промо-ссылка спонсора/автора
• s_promo_title: заголовок промо-ссылки или бейджа
• i_promo_clicks: количество переходов по промо-ссылке
• d_created_at: дата создания записи
• d_updated_at: дата последнего обновления записи
Методы:
• save(): формирование s_hash_id
• rescore(): пересчёт рейтинга f_score
"""
class Level(models.IntegerChoices):
CANDIDATE = 0, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
LEVEL_1 = 10, 'Voted: Плеск бессознательного' # Прогрето, есть "лайки", не проверено модератором, можно удалять при низком score
LEVEL_2 = 30, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
IMMORTAL = 1000, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score
# Отрицательные / проблемные уровни (аномалии)
SHAMED = -20, 'Шейминг (аномальный наплыв жалоб)'
SUSPICIOUS = -10, 'Подозрение на накрутку (спайк лайков)'
id = models.AutoField(
primary_key=True,
verbose_name="ID",
help_text="Первичный ключ (~2.14 млрд записей, 4 байта)",
)
s_hash_id = models.CharField( s_hash_id = models.CharField(
max_length=16, max_length=16,
unique=True, unique=True,
db_index=True, editable=False,
verbose_name="Хэш-идентификатор", verbose_name="ID-Хэш",
) )
s_title = models.CharField( s_title = models.CharField(
max_length=255, max_length=255,
verbose_name="Название картины", verbose_name="Название генерации",
help_text="Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.",
) )
file_svg = models.FileField( file_svg = models.FileField(
upload_to="svg/%Y/%m/", upload_to="svg/%Y/%m/",
verbose_name="SVG-файл", verbose_name="SVG-файл",
) help_text="Результирующий SVG-файл генерации",
i_sha1 = models.CharField(
max_length=40,
verbose_name="SHA1",
) )
i_file_size = models.PositiveIntegerField( i_file_size = models.PositiveIntegerField(
default=0, default=0,
help_text="Размер файла в байтах",
verbose_name="Размер файла", verbose_name="Размер файла",
help_text="Размер файла в байтах",
) )
# Снимок параметров для возможного клонирования настроек # НА ВСЯКИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек
j_params = models.JSONField( j_metadata = models.JSONField(
default=dict, default=dict,
blank=True, blank=True,
verbose_name="Параметры генерации", verbose_name="Параметры генерации",
@@ -43,31 +78,37 @@ class TbHypn0Item(models.Model):
# Популярность # Популярность
i_likes_count = models.PositiveIntegerField( i_likes_count = models.PositiveIntegerField(
default=0, default=1,
db_index=True, db_index=True,
verbose_name="Количество лайков", verbose_name="Число лайков",
help_text="Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета"
" рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы"
" <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.",
) )
i_views_count = models.PositiveIntegerField( i_views_count = models.PositiveIntegerField(
default=0, default=1,
verbose_name="Количество просмотров", verbose_name="Число просмотров",
) )
i_reports_count = models.PositiveIntegerField( i_claims_count = models.PositiveIntegerField(
default=0, default=0,
verbose_name="Количество жалоб", verbose_name="Число жалоб",
help_text="Число жалоб на гипнотическую SVG-картину. Поле используется для отображения в интерфейсе. Для расчета"
" рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы"
" <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.",
) )
f_score = models.FloatField( f_score = models.FloatField(
default=0.0, default=0.0,
db_index=True, db_index=True,
help_text="Рейтинг для Smart Retention",
verbose_name="Рейтинг популярности", verbose_name="Рейтинг популярности",
help_text="Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять",
) )
# Модерация # Модерация
i_level = models.IntegerField( i_level = models.IntegerField(
choices=Level.choices, choices=Level.choices,
default=Level.CANDIDATE, default=Level.CANDIDATE,
help_text="Уровень доступа", help_text="Уровень",
verbose_name="Уровень доступа", verbose_name="В какой группе находится SVG-файл генерации",
) )
is_public = models.BooleanField( is_public = models.BooleanField(
default=True, default=True,
@@ -111,21 +152,164 @@ class TbHypn0Item(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.s_title} ({self.s_hash_id})" return f"{self.s_title} ({self.s_hash_id})"
def increment_views(self):
"""Безопасный инкремент просмотров"""
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
def vote(self, visitor_uuid_or_fp: str, direction: int | None = None) -> bool:
"""
Универсальный метод учета голоса (лайк, жалоба или авторство).
Принимает:
- visitor_uuid_or_fp: UUIDv4 из куки hypn0_vid или готовый 64-символьный SHA256-хэш отпечатка
- direction: направление голоса (Direction.LIKE, Direction.CLAIM, Direction.AUTHOR)
Возвращает:
- True: голос успешно зафиксирован и счетчик увеличен
- False: голос уже существовал (защита от повторного голосования) или передан пустой токен
"""
if not visitor_uuid_or_fp:
return False
if direction is None:
direction = TbVote.Direction.LIKE
# Если передан сырой UUID устройства, вычисляем SHA256 отпечаток
if len(visitor_uuid_or_fp) == 64:
fingerprint = visitor_uuid_or_fp
else:
fingerprint = hashlib.sha256(f"{visitor_uuid_or_fp}:{SECRET_KEY}".encode()).hexdigest()
try:
# В SQLite при возникновении IntegrityError текущая транзакция может перейти в состояние ошибки. Чтобы это
# не затрагивало последующие операции с базой, try-записи изолирована в `with transaction.atomic()`
with transaction.atomic():
TbVote.objects.create(
k_item=self,
i_direction=direction,
s_fingerprint=fingerprint,
)
except IntegrityError:
# Нарушение UniqueConstraint (k_item, s_fingerprint) — голос с этого устройства уже учтен
return False
# Атомарный инкремент нужного счетчика в зависимости от направления
# Используем `objects.filter()` вместо `objects.get()`, так как метод .update() вызывается только у QuerySet.
if direction in (TbVote.Direction.LIKE, TbVote.Direction.AUTHOR):
TbHypn0Item.objects.filter(id=self.id).update(i_likes_count=F('i_likes_count') + 1)
elif direction == TbVote.Direction.CLAIM:
TbHypn0Item.objects.filter(id=self.id).update(i_claims_count=F('i_claims_count') + 1)
# Рейтинг популярности f_score намеренно НЕ пересчитывается на лету при каждом лайке:
# 1. f_score зависит от времени (возраста картины), поэтому непрерывно меняется сам по себе.
# 2. Значение f_score необходимо только периодическому процессу умной очистки диска (Smart Retention).
# 3. Пересчет вынесен в пакетную cron-задачу перед ротацией хранилища, что исключает лишние I/O-операции в SQLite.
return True
def increment_likes(self, visitor_uuid_or_fp: str) -> bool:
"""Безопасный инкремент лайков с фиксацией в TbVote"""
return self.vote(visitor_uuid_or_fp, direction=TbVote.Direction.LIKE)
def increment_claims(self, visitor_uuid_or_fp: str) -> bool:
"""Безопасный инкремент жалоб с фиксацией в TbVote"""
return self.vote(visitor_uuid_or_fp, direction=TbVote.Direction.CLAIM)
def increment_promo_clicks(self):
"""Безопасный инкремент кликов по промо"""
TbHypn0Item.objects.filter(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1)
def save(self, *args, visitor_uuid_or_fp: str | None = None, **kwargs):
"""
Переопределенный метод save для TbHypn0Item.
ПРАВИЛА И СЦЕНАРИИ СОХРАНЕНИЯ:
------------------------------
1. Создание новой картины (is_new=True):
- Создание возможно ТОЛЬКО с фронтенда при наличии согласия на отслеживание (visitor_uuid_or_fp).
- Если пользователь не "подчинился Гипножабе" (visitor_uuid_or_fp отсутствует), создание БЛОКИРУЕТСЯ
выбрасыванием PermissionError (защита от сохранения анонимных работ без автора).
- В рамках одной атомарной транзакции:
а) Сохраняется запись картины и генерируется self.pk.
б) Генерируется криптографический s_hash_id.
в) Создается обязательная авторская запись TbVote(Direction.AUTHOR).
г) Стартовый счетчик i_likes_count по умолчанию равен 1.
2. Редактирование существующей картины (is_new=False, например из Django Admin):
- Картина уже существует в БД (self.pk есть).
- Разрешено обычное обновление любых полей (название, уровень модерации, рейтинг и т.д.).
- Никакие авторские записи повторно не создаются.
"""
is_new = self.pk is None
# 0. Блокировка создания новой картины без подчинения Гипножабе
if is_new and not visitor_uuid_or_fp:
raise PermissionError("Создание картины запрещено: пользователь не подчинился Гипножабе (отсутствует visitor UUID)")
# 1. Вычисляем хэш автора для новой картины
author_fp = None
if is_new and visitor_uuid_or_fp:
if len(visitor_uuid_or_fp) == 64:
author_fp = visitor_uuid_or_fp
else:
author_fp = hashlib.sha256(f"{visitor_uuid_or_fp}:{SECRET_KEY}".encode()).hexdigest()
with transaction.atomic():
# 2. Сохраняем объект в базу (для новых записей СУБД присваивает self.pk)
super().save(*args, **kwargs)
# 3. Формируем s_hash_id, если его еще нет
if not self.s_hash_id:
self.s_hash_id = Hashids(salt=HASHIDS_SALT, min_length=HASHIDS_MIN_LENGTH).encode(self.pk)
super().save(update_fields=['s_hash_id'])
# 4. Регистрируем автора в TbVote (гарантированно после получения self.pk)
if is_new and author_fp:
TbVote.objects.create(
k_item=self,
i_direction=TbVote.Direction.AUTHOR,
s_fingerprint=author_fp,
)
class TbVote(models.Model): class TbVote(models.Model):
"""Анонимный учет голосов (лайков) без сохранения ПДн.""" """
Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн.
Поля:
• id: первичный ключ записи (BigAutoField, 8 байт, до ~9×10¹⁸)
• k_item: ключ объекта (foreign key), за который голосуют
• i_direction: направление голоса (лайк, жалоба или голос автора)
• s_fingerprint: хэш-отпечаток клиент/браузер/секрет для защиты от накрутки
• d_created_at: дата и время фиксации голоса
"""
class Direction(models.IntegerChoices):
LIKE = 1, 'Like (+1)'
CLAIM = -2, 'Claim (-2)'
AUTHOR = 2, 'Author Like (+2)'
id = models.BigAutoField(
primary_key=True,
verbose_name="ID",
help_text="Первичный ключ (BigAutoField, 8 байт)",
)
k_item = models.ForeignKey( k_item = models.ForeignKey(
TbHypn0Item, TbHypn0Item,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="votes", related_name="votes",
verbose_name="Картина", verbose_name="Картина",
) )
i_direction = models.IntegerField(
choices=Direction.choices,
default=Direction.LIKE,
verbose_name="Направление",
help_text="Направление голоса",
)
s_fingerprint = models.CharField( s_fingerprint = models.CharField(
max_length=64, max_length=64,
db_index=True, db_index=True,
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств",
verbose_name="Хэш устройства/отпечаток", verbose_name="Хэш устройства/отпечаток",
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств",
) )
d_created_at = models.DateTimeField( d_created_at = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
@@ -144,27 +328,43 @@ class TbVote(models.Model):
ordering = ["-d_created_at"] ordering = ["-d_created_at"]
def __str__(self) -> str: def __str__(self) -> str:
return f"Голос за {self.k_item.hash_id} [{self.s_fingerprint[:8]}...]" return f"Голос за {self.k_item.s_hash_id} [{self.s_fingerprint[:8]}...]"
class TbBlogPost(models.Model): class TbBlogPost(models.Model):
""" """
Статьи блога, документация и инфо-страницы (Privacy Policy и др.). Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
+ виртуальные поля для управления типографом etpgrf Поля:
• id: первичный ключ статьи (SmallAutoField, 2 байта, до 32 767 записей)
• s_title: заголовок статьи (HTML)
• slug: слаг (URI-адрес) статьи
• s_teaser: тизер статьи (HTML)
• s_content: основной текст статьи (HTML)
• f_cover_img: картинка обложки статьи
• is_published: флаг публикации
• d_published_at: дата и время публикации
• d_created_at: дата создания записи
• d_updated_at: дата обновления записи
""" """
id = models.SmallAutoField(
primary_key=True,
verbose_name="ID",
help_text="Первичный ключ (SmallAutoField, до 32 767 записей)",
)
s_title = models.CharField( s_title = models.CharField(
max_length=200, max_length=255,
help_text="HTML-заголовок, очищенный и типографированный etpgrf", help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)",
verbose_name="Заголовок", verbose_name="Заголовок",
) )
slug = models.SlugField( slug = models.SlugField(
max_length=200, max_length=200,
blank=True,
default="",
unique=True, unique=True,
verbose_name="URL-слаг", verbose_name="URL-слаг",
) )
s_summary = models.TextField( s_teaser = models.TextField(
max_length=1024, max_length=1024,
blank=True, blank=True,
help_text="HTML-контент тизера, очищенный и типографированный etpgrf", help_text="HTML-контент тизера, очищенный и типографированный etpgrf",
@@ -186,8 +386,7 @@ class TbBlogPost(models.Model):
verbose_name="Опубликовано", verbose_name="Опубликовано",
) )
d_published_at = models.DateTimeField( d_published_at = models.DateTimeField(
null=True, default=timezone.now,
blank=True,
db_index=True, db_index=True,
verbose_name="Дата публикации", verbose_name="Дата публикации",
) )
@@ -195,7 +394,7 @@ class TbBlogPost(models.Model):
auto_now_add=True, auto_now_add=True,
verbose_name="Дата создания", verbose_name="Дата создания",
) )
d_created_at = models.DateTimeField( d_updated_at = models.DateTimeField(
auto_now=True, auto_now=True,
verbose_name="Дата обновления", verbose_name="Дата обновления",
) )
+1
View File
@@ -0,0 +1 @@
# Services package for hypn0_site
+283
View File
@@ -0,0 +1,283 @@
import io
import math
import random
from collections import defaultdict
from typing import BinaryIO, Union
from PIL import Image
def encode_to_base36(num: int) -> str:
"""Кодирует неотрицательное число в компактный строковый идентификатор."""
if num < 0:
raise ValueError("Число должно быть неотрицательным")
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if num < len(charset):
return charset[num]
result = []
while num > 0:
result.append(charset[num % len(charset)])
num //= len(charset)
return "".join(reversed(result))
def generate_shape_def(shape: str, radius: int, shape_id: str) -> str:
"""Генерирует SVG-элемент для тега <defs> в зависимости от типа фигуры."""
r = radius
match shape:
case "ring":
stroke_width = max(1.0, r * 0.35)
inner_r = max(0.5, r - stroke_width / 2.0)
return f'<circle id="{shape_id}" r="{inner_r:.1f}" fill="none" stroke="currentColor" stroke-width="{stroke_width:.1f}"/>'
case "square":
size = r * 2
rx = max(0.5, r * 0.1)
return f'<rect id="{shape_id}" class="shape" x="{-r}" y="{-r}" width="{size}" height="{size}" rx="{rx:.1f}"/>'
case "diamond":
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {-r},0 0,{r} {r},0"/>'
case "triangle":
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {-r},{r} {r},{r}"/>'
case "hexagon":
w = round(r * 0.866)
h_half = round(r * 0.5)
return f'<polygon id="{shape_id}" class="shape" points="0,{-r} {w},{-h_half} {w},{h_half} 0,{r} {-w},{h_half} {-w},{-h_half}"/>'
case "star":
points = []
inner_r = r * 0.45
for i in range(10):
angle = i * math.pi / 5 - math.pi / 2
curr_r = r if i % 2 == 0 else inner_r
px = round(curr_r * math.cos(angle), 1)
py = round(curr_r * math.sin(angle), 1)
points.append(f"{px},{py}")
pts_str = " ".join(points)
return f'<polygon id="{shape_id}" class="shape" points="{pts_str}"/>'
case "cross":
arm = max(1, round(r * 0.35))
return f'<path id="{shape_id}" class="shape" d="M{-arm},{-r} H{arm} V{-arm} H{r} V{arm} H{arm} V{r} H{-arm} V{arm} H{-r} V{-arm} H{-arm} Z"/>'
case "line":
th = max(1, round(r * 0.25))
return f'<rect id="{shape_id}" class="shape" x="{-r}" y="{-th}" width="{2*r}" height="{2*th}" rx="1"/>'
case "wave":
th = max(1.0, r * 0.3)
return f'<path id="{shape_id}" class="shape" d="M{-r},0 Q{-r/2:.1f},{-r} 0,0 T{r},0" fill="none" stroke="currentColor" stroke-width="{th:.1f}"/>'
case "heart":
scale_f = r / 12.0
return (
f'<path id="{shape_id}" class="shape" transform="scale({scale_f:.3f}) translate(-12, -12)" '
f'd="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>'
)
case "circle" | _:
return f'<circle id="{shape_id}" class="shape" r="{r}"/>'
def generate_halftone_svg(
image: Union[Image.Image, BinaryIO, bytes, str],
*,
cols: int = 35,
max_radius: int = 8,
shape: str = "circle",
color: str = "#a855ff",
opacity: float = 0.9,
blink: int = 6,
rotation: int = 0,
scale: int = 980,
angle: int = 0,
animation_variants: int = 12,
seed: int = 42,
) -> str:
"""
Генерирует чистый оптимизированный SVG-халфтон из растрового изображения.
Параметры:
- image: PIL Image, файловый объект (BytesIO), байты или путь к файлу
- cols: число точек по наибольшей стороне сетки (10-200)
- max_radius: максимальный радиус/размер точки (3-20)
- shape: тип фигуры ('circle', 'ring', 'square', 'diamond', 'triangle', 'hexagon', 'star', 'cross', 'line', 'wave', 'heart')
- color: основной HEX цвет (например '#a855ff')
- opacity: коэффициент непрозрачности (0.0 - 1.0)
- blink: интенсивность мерцания (0 - выключено/статичный, 1-10 - скорость)
- rotation: угол покачивания в градусах (-15 .. +15)
- scale: масштаб пульсации (800 .. 1040, где 980 = 0.98)
- angle: наклон растровой сетки (-44 .. +45)
- animation_variants: число вариантов CSS-задержек
- seed: сид для детерминированного распределения анимаций
"""
rng = random.Random(seed)
# 1. Загрузка и подготовка изображения в градациях серого
if isinstance(image, (bytes, bytearray)):
pil_img = Image.open(io.BytesIO(image))
elif hasattr(image, "read"):
if hasattr(image, "seek"):
image.seek(0)
pil_img = Image.open(image)
elif isinstance(image, str):
pil_img = Image.open(image)
elif isinstance(image, Image.Image):
pil_img = image
else:
raise ValueError("Неподдерживаемый тип входного изображения")
# Конвертируем в Grayscale
img = pil_img.convert("L")
# Обработка наклона сетки (angle): поворачиваем изображение при необходимости
if angle != 0:
# Поворачиваем с сохранением пропорций и белым фоном (255 = прозрачно/пусто)
img = img.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=255)
# Вычисляем размеры сетки: значение `cols` задает число точек по наибольшей стороне (ширине или высоте)
if img.width >= img.height:
grid_width = max(1, cols)
grid_height = max(1, round(cols * (img.height / img.width)))
else:
grid_height = max(1, cols)
grid_width = max(1, round(cols * (img.width / img.height)))
img_resized = img.resize((grid_width, grid_height), Image.Resampling.LANCZOS)
step = max_radius * 2 + 2
width = grid_width * step
height = grid_height * step
# 2. Формирование классов анимации
animation_classes = []
is_animated = blink > 0
# Длительность цикла анимации: при blink=10 -> 0.6s, при blink=1 -> 2.4s
duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0
for i in range(animation_variants):
delay = (i / animation_variants) * duration * 1.5
encoded_i = encode_to_base36(i)
delay_str = f"{int(delay)}" if delay == int(delay) else f"{delay:.2f}".rstrip("0").rstrip(".")
animation_classes.append(f".a{encoded_i}{{--d:{delay_str}s}}")
# 3. Обход пикселей и группировка
elements_by_class = defaultdict(list)
unique_radii = set()
for y in range(grid_height):
for x in range(grid_width):
brightness = img_resized.getpixel((x, y))
factor = (255 - brightness) / 255.0
# Отсекаем слишком светлые участки (шум фона)
if factor < 0.12:
continue
radius = int(factor * max_radius)
if radius == 0:
continue
cx = x * step + step // 2
cy = y * step + step // 2
anim_class = rng.randint(0, animation_variants - 1)
encoded_class = encode_to_base36(anim_class)
elements_by_class[encoded_class].append((radius, cx, cy))
unique_radii.add(radius)
# 4. Формирование <defs>
defs_list = ["<defs>"]
for radius in sorted(unique_radii):
shape_id = f"s{encode_to_base36(radius)}"
defs_list.append(generate_shape_def(shape, radius, shape_id))
defs_list.append("</defs>")
defs_html = "".join(defs_list)
# 5. Формирование групп <g class="a...">
groups = []
for anim_class in sorted(elements_by_class.keys()):
group_elements = elements_by_class[anim_class]
uses = "".join(
f'<use href="#s{encode_to_base36(radius)}" x="{cx}" y="{cy}"/>'
for radius, cx, cy in group_elements
)
if uses:
groups.append(f'<g class="a{anim_class}">{uses}</g>')
animation_css = "".join(animation_classes)
groups_html = "".join(groups)
# 6. Стилизация и Keyframes
scale_val = max(0.5, min(1.5, scale / 1000.0))
scale_str = f"{scale_val:.3f}".rstrip("0").rstrip(".")
rot_str = f"{rotation}deg"
# Корректный цвет с прозрачностью
clean_color = color.strip() if color else "#a855ff"
if not clean_color.startswith("#"):
clean_color = f"#{clean_color}"
# Добавляем альфа-канал в hex при необходимости или используем CSS opacity
fill_style = f"fill:{clean_color};stroke:{clean_color};opacity:{opacity:.2f};"
if is_animated:
anim_rule = f"animation:noise {duration:.2f}s ease-in-out infinite alternate;animation-delay:var(--d);"
keyframes_rule = (
f"@keyframes noise{{"
f"0%{{opacity:{max(0.2, opacity * 0.7):.2f};transform:scale(1) rotate(0deg)}}"
f"50%{{opacity:{opacity:.2f}}}"
f"100%{{opacity:{max(0.1, opacity * 0.5):.2f};transform:scale({scale_str}) rotate({rot_str})}}"
f"}}"
)
else:
anim_rule = "animation:none;"
keyframes_rule = ""
svg_css = (
f"<style>"
f"svg{{background:transparent}}"
f".shape,circle,rect,polygon,path{{{fill_style}transform-origin:center;{anim_rule}transition:all .5s ease-out}}"
f"{keyframes_rule}"
f".frozen .shape,.frozen circle,.frozen rect,.frozen polygon,.frozen path{{animation:none!important;opacity:{opacity:.2f}!important;transform:none!important}}"
f"{animation_css}"
f"</style>"
)
svg_template = (
f'<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->\n'
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="100%" height="100%">'
f'{svg_css}'
f'{defs_html}'
f'<g id="hypn0-canvas">{groups_html}</g>'
f'</svg>'
)
return svg_template
def prepare_gallery_svg(svg_content: str) -> str:
"""
Модифицирует SVG для долговременного хранения в галерее:
добавляет CSS-правила, чтобы в изолированном контексте (<img>) анимация
находилась на паузе по умолчанию и запускалась только при наведении (:hover),
предотвращая перегрузку CPU/GPU браузера при выводе сетки карточек.
"""
if not svg_content:
return svg_content
hover_css = (
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
)
if "</style>" in svg_content:
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
return svg_content
+187
View File
@@ -0,0 +1,187 @@
"""
Сервис генерации гипнотических названий для картин галереи.
Включает:
- Локальный генератор псевдослучайных гипно-заголовков (в стиле Гипножабы, психоделики и мемов).
- Закладку (интерфейс и спецификацию) для будущей интеграции с OpenRouter API (мультимодальные VLM).
"""
import random
from typing import Optional
# Словари для локального генератора гипно-тайтлов
HYPNO_ADJECTIVES = [
"Мерцающий",
"Астральный",
"Гипнотический",
"Психоделический",
"Пульсирующий",
"Неоновый",
"Трансцендентный",
"Мозговой",
"Ментальный",
"Бессознательный",
"Космический",
"Галлюциногенный",
"Фрактальный",
"Гипножабий",
"Завораживающий",
"Магнетический",
"Спиральный",
"Зыбкий",
"Иллюзорный",
"Онейрический",
"Эфирный",
"Трансовый",
"Квантовый",
"Синтетический",
"Хроматический",
]
HYPNO_NOUNS = [
"Транс",
"Пульсар",
"Взгляд",
"Слизень",
"Вихрь",
"Портал",
"Разум",
"Фрактал",
"Сигнал",
"Спектр",
"Резонанс",
"Морок",
"Оазис",
"Омут",
"Лабиринт",
"Импульс",
"Монолит",
"Оракул",
"Вектор",
"Сгусток",
"Каскад",
"Горизонт",
"Мухомор",
"Паттерн",
"Контур",
"Фантом",
"Всплеск",
"Взмах",
"Взгляд",
"Морок",
]
HYPNO_GENITIVES = [
"сознания",
"Гипножабы",
"бессознательного",
"пустоты",
"матрицы",
"психо-паутины",
"слизня",
"ноосферы",
"нео-модерна",
"транса",
"глубины",
"эфира",
"плесени",
"измерения",
"пуантилизма",
"субстанции",
"неопределенности",
"континуума",
"кислоты",
]
HYPNO_POSTFIXES = [
"#42",
"v3.14",
"MK-Ultra",
"Фаза 7",
"Пси-излучение",
"Альфа",
"Омега",
"Частота 432Гц",
"Протокол 09",
"Ревизия 8",
"Экстракт",
"Слой 9",
"Палата №6",
"Ангар 18",
"Ангар 18",
]
def generate_hypno_title(seed: Optional[int] = None) -> str:
"""
Генерирует сочное гипнотическое название для картины на основе словарей.
Примеры:
- «Мерцающий Транс сознания #42»
- «Гипножабий Пульсар психо-паутины»
- «Астральный Взгляд v3.14»
"""
rng = random.Random(seed)
adj = rng.choice(HYPNO_ADJECTIVES)
noun = rng.choice(HYPNO_NOUNS)
pattern_type = rng.randint(1, 3)
if pattern_type == 1:
genitive = rng.choice(HYPNO_GENITIVES)
postfix = rng.choice(HYPNO_POSTFIXES)
return f"{adj} {noun} {genitive} {postfix}"
elif pattern_type == 2:
genitive = rng.choice(HYPNO_GENITIVES)
return f"{adj} {noun} {genitive}"
else:
postfix = rng.choice(HYPNO_POSTFIXES)
return f"{adj} {noun} {postfix}"
# =====================================================================
# ЗАКЛАДКА: OpenRouter VLM API Integration (Post-MVP / AI Title)
# =====================================================================
def generate_title_openrouter(
image_bytes: bytes,
*,
api_key: Optional[str] = None,
model: str = "google/gemini-2.5-flash",
system_prompt: str = (
"Ты — глашатай всемогущей Гипножабы. Придумай короткое (2-4 слова), "
"яркое, иронично-абсурдное и гипнотическое название на русском языке "
"для полутоновой SVG-картины, изображенной на миниатюре. "
"Отвечай ТОЛЬКО названием, без кавычек и вводных слов."
),
timeout: int = 5,
) -> Optional[str]:
"""
[ЗАКЛАДКА ПОД OPENROUTER API]
Спецификация интеграции:
1. Исходное растровое изображение сжимается до thumbnail 256x256 WebP/JPEG.
2. Кодируется в Base64 (data:image/jpeg;base64,...).
3. Отправляется POST-запрос на https://openrouter.ai/api/v1/chat/completions:
Payload: {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "text", "text": "Дай гипнотическое название для этой картины:"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_data}"}}
]
}
],
"max_tokens": 50,
"temperature": 0.9
}
4. При ошибке таймаута, отсутствии OPENROUTER_API_KEY в .env или сбое сети
функция возвращает None, вызывая локальный fallback (generate_hypno_title).
"""
# TODO: Интегрировать requests / httpx при добавлении OPENROUTER_API_KEY в settings.py
if not api_key:
return None
# Заглушка: при отсутствии реального сетевого коннектора возвращаем None для fallback
return None
+305 -2
View File
@@ -1,3 +1,306 @@
from django.test import TestCase import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from PIL import Image
# Create your tests here. from .forms import HalftoneGenerateForm
from .models import TbHypn0Item, TbVote
from .services.halftone import encode_to_base36, generate_halftone_svg, prepare_gallery_svg
from .services.naming import generate_hypno_title, generate_title_openrouter
class HalftoneServiceTests(TestCase):
"""Тестирование сервиса генерации SVG (services/halftone.py)."""
def setUp(self):
# Создаем простое тестовое изображение 50x50 с градиентом
self.img = Image.new("RGB", (50, 50), color="white")
for x in range(25):
for y in range(50):
self.img.putpixel((x, y), (0, 0, 0))
def test_encode_to_base36(self):
self.assertEqual(encode_to_base36(0), "a")
self.assertEqual(encode_to_base36(1), "b")
with self.assertRaises(ValueError):
encode_to_base36(-1)
def test_generate_default_svg(self):
svg = generate_halftone_svg(self.img, cols=30, max_radius=6)
self.assertIn("<svg", svg)
self.assertIn("</svg>", svg)
self.assertIn("<defs>", svg)
self.assertIn('<circle id="s', svg)
self.assertIn('<g id="hypn0-canvas">', svg)
def test_generate_shapes(self):
shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "wave", "heart"]
for shape in shapes:
svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape)
self.assertIn("<svg", svg)
self.assertIn("</svg>", svg)
self.assertIn("<defs>", svg)
def test_generate_static_blink_zero(self):
svg = generate_halftone_svg(self.img, cols=20, blink=0)
self.assertIn("animation:none", svg)
self.assertNotIn("@keyframes noise", svg)
def test_generate_animated_params(self):
svg = generate_halftone_svg(
self.img,
cols=20,
blink=8,
rotation=12,
scale=920,
angle=15,
color="#ff5500",
)
self.assertIn("@keyframes noise", svg)
self.assertIn("rotate(12deg)", svg)
self.assertIn("scale(0.92)", svg)
self.assertIn("#ff5500", svg)
def test_generate_from_bytes(self):
buf = io.BytesIO()
self.img.save(buf, format="PNG")
svg = generate_halftone_svg(buf.getvalue(), cols=20)
self.assertIn("<svg", svg)
def test_generate_aspect_ratios(self):
# Проверяем, что для вертикального изображения (100x300) сетка не раздувается
tall_img = Image.new("RGB", (100, 300), color="black")
svg_tall = generate_halftone_svg(tall_img, cols=30, max_radius=5)
# При cols=30 наибольшая сторона (высота) должна иметь 30 точек, а ширина 10
# step = 5 * 2 + 2 = 12 -> width = 10 * 12 = 120, height = 30 * 12 = 360
self.assertIn('viewBox="0 0 120 360"', svg_tall)
# Для горизонтального изображения (300x100)
wide_img = Image.new("RGB", (300, 100), color="black")
svg_wide = generate_halftone_svg(wide_img, cols=30, max_radius=5)
self.assertIn('viewBox="0 0 360 120"', svg_wide)
class HalftoneFormTests(TestCase):
"""Тестирование формы валидации HalftoneGenerateForm."""
def test_form_validation(self):
buf = io.BytesIO()
img = Image.new("RGB", (30, 30), color="red")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
form = HalftoneGenerateForm(
data={
"shape": "square",
"cols": 60,
"max_radius": 7,
"blink": 5,
"rotation": 10,
"scale": 950,
"angle": -10,
},
files={"image": uploaded},
)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["shape"], "square")
self.assertEqual(form.cleaned_data["cols"], 60)
class HalftoneViewTests(TestCase):
"""Тестирование view-обработчиков (index, generate)."""
def setUp(self):
self.client = Client()
def test_index_page(self):
response = self.client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "logo-hypn0.svg")
self.assertContains(response, 'id="preview"')
self.assertContains(response, 'name="csrfmiddlewaretoken"')
self.assertContains(response, 'X-CSRFToken')
self.assertContains(response, "handleFileChange")
self.assertContains(response, "imagePreviewUrl")
self.assertContains(response, "Гипножаба облизывается на:")
# Проверяем, что csrf_token не пустой в hx-headers
content = response.content.decode("utf-8")
self.assertNotIn('hx-headers=\'{"X-CSRFToken": ""}\'', content)
def test_generate_with_enforce_csrf(self):
csrf_client = Client(enforce_csrf_checks=True)
response = csrf_client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
buf = io.BytesIO()
img = Image.new("RGB", (40, 40), color="black")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
# Без CSRF токена вернет 403
bad_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={"shape": "circle", "image": uploaded},
)
self.assertEqual(bad_response.status_code, 403)
# С CSRF токеном в POST data вернет 200
csrf_token = response.cookies["csrftoken"].value
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
good_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={
"csrfmiddlewaretoken": csrf_token,
"shape": "circle",
"image": uploaded,
},
)
self.assertEqual(good_response.status_code, 200)
self.assertContains(good_response, "<svg")
# С CSRF токеном в заголовке X-CSRFToken (как отправляет HTMX) вернет 200
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
header_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={
"shape": "circle",
"image": uploaded,
},
HTTP_X_CSRFTOKEN=csrf_token,
)
self.assertEqual(header_response.status_code, 200)
self.assertContains(header_response, "<svg")
def test_generate_get_not_allowed(self):
response = self.client.get(reverse("hypn0_site:generate"))
self.assertEqual(response.status_code, 405)
def test_generate_post_no_image(self):
response = self.client.post(reverse("hypn0_site:generate"), data={"shape": "circle"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Покорми Гипножабу")
def test_generate_post_success(self):
buf = io.BytesIO()
img = Image.new("RGB", (40, 40), color="black")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
response = self.client.post(
reverse("hypn0_site:generate"),
data={
"shape": "diamond",
"cols": "40",
"max_radius": "6",
"blink": "6",
"colors": ["#10b981"],
"image": uploaded,
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<svg")
self.assertContains(response, 'points="0,-6')
self.assertContains(response, "like-to-gallery")
class NamingServiceTests(TestCase):
"""Тестирование сервиса гипнотических названий (services/naming.py)."""
def test_generate_hypno_title(self):
title = generate_hypno_title(seed=42)
self.assertIsInstance(title, str)
self.assertTrue(len(title) > 5)
# Проверка детерминированности по seed
self.assertEqual(generate_hypno_title(seed=42), title)
def test_openrouter_stub(self):
res = generate_title_openrouter(b"dummy_bytes")
self.assertIsNone(res)
class GalleryPreparationTests(TestCase):
"""Тестирование подготовки SVG к галерейному хранению."""
def test_prepare_gallery_svg(self):
raw_svg = '<svg><style>.shape{animation:noise 1s}</style><g></g></svg>'
prepared = prepare_gallery_svg(raw_svg)
self.assertIn("svg:not(:hover)", prepared)
self.assertIn("animation-play-state:paused!important", prepared)
class PublishViewTests(TestCase):
"""Тестирование эндпоинта публикации кандидата в галерею (/publish)."""
def setUp(self):
self.client = Client()
self.sample_svg = '<svg xmlns="http://www.w3.org/2000/svg"><style>.shape{color:red}</style><g><circle/></g></svg>'
def test_publish_without_cookie_returns_slug_protest(self):
response = self.client.post(
reverse("hypn0_site:publish"),
data={
"svg_content": self.sample_svg,
"shape": "circle",
"cols": "35",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Мозговые слизняки протестуют!")
self.assertContains(response, "Подчиниться и опубликовать")
self.assertEqual(TbHypn0Item.objects.count(), 0)
def test_publish_with_cookie_success(self):
vid = "123e4567-e89b-12d3-a456-426614174000"
self.client.cookies["hypn0_vid"] = vid
response = self.client.post(
reverse("hypn0_site:publish"),
data={
"svg_content": self.sample_svg,
"shape": "diamond",
"cols": "40",
"max_radius": "6",
"blink": "7",
"rotation": "5",
"scale": "950",
"angle": "10",
"color": "#10b981",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Уже в галерее транса")
self.assertContains(response, "идет прорастание психо-паутины...")
# Проверяем запись в БД
self.assertEqual(TbHypn0Item.objects.count(), 1)
item = TbHypn0Item.objects.first()
self.assertTrue(len(item.s_hash_id) >= 6)
self.assertIn(f"#{item.s_hash_id}", response.content.decode("utf-8"))
self.assertEqual(item.i_level, TbHypn0Item.Level.CANDIDATE)
self.assertEqual(item.i_likes_count, 1)
self.assertEqual(item.j_metadata["shape"], "diamond")
self.assertEqual(item.j_metadata["cols"], 40)
self.assertEqual(item.j_metadata["color"], "#10b981")
# Проверяем авторский голос в TbVote
self.assertEqual(TbVote.objects.count(), 1)
author_vote = TbVote.objects.first()
self.assertEqual(author_vote.k_item, item)
self.assertEqual(author_vote.i_direction, TbVote.Direction.AUTHOR)
def test_publish_empty_svg_returns_error(self):
self.client.cookies["hypn0_vid"] = "123e4567-e89b-12d3-a456-426614174000"
response = self.client.post(
reverse("hypn0_site:publish"),
data={"svg_content": ""},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Нет данных SVG для публикации")
self.assertEqual(TbHypn0Item.objects.count(), 0)
+4 -3
View File
@@ -1,14 +1,15 @@
from django.urls import path from django.urls import path
from . import views
from hypn0 import settings from hypn0 import settings
from . import views
app_name = "hypn0_site" app_name = "hypn0_site"
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("generate", views.generate, name="generate"),
path("publish", views.publish, name="publish"),
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [path('tmp/', views.tmp, name='web_tmp')] urlpatterns += [path("tmp/", views.tmp, name="web_tmp")]
+165 -4
View File
@@ -1,11 +1,172 @@
from django.core.files.base import ContentFile
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpRequest, HttpResponse, Http404 from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
# Create your views here. from .forms import HalftoneGenerateForm
from .models import TbHypn0Item
from .services.halftone import generate_halftone_svg, prepare_gallery_svg
from .services.naming import generate_hypno_title
@ensure_csrf_cookie
def index(request: HttpRequest | None) -> HttpResponse: def index(request: HttpRequest | None) -> HttpResponse:
return render(request, 'index.html', {}) return render(request, "index.html", {})
def tmp(request: HttpRequest | None) -> HttpResponse: def tmp(request: HttpRequest | None) -> HttpResponse:
return render(request, 'tmp.html', {}) return render(request, "tmp.html", {})
@require_POST
def generate(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету.
Не сохраняет данные в БД (Zero-Disk / Zero-PII).
"""
form = HalftoneGenerateForm(request.POST, request.FILES)
if not form.is_valid():
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
return render(request, "block/preview.html", {"error": error_msg})
image_file = form.cleaned_data["image"]
shape = form.cleaned_data["shape"]
cols = form.cleaned_data["cols"]
max_radius = form.cleaned_data["max_radius"]
blink = form.cleaned_data["blink"]
rotation = form.cleaned_data["rotation"]
scale = form.cleaned_data["scale"]
angle = form.cleaned_data["angle"]
# Извлечение основного цвета (поддержка монотонной схемы)
colors = request.POST.getlist("colors")
primary_color = colors[0] if colors and colors[0] else "#a855ff"
try:
svg_content = generate_halftone_svg(
image=image_file,
cols=cols,
max_radius=max_radius,
shape=shape,
color=primary_color,
blink=blink,
rotation=rotation,
scale=scale,
angle=angle,
)
except Exception as e:
return render(
request,
"block/preview.html",
{"error": f"Сбой в матрице гипноза: {str(e)}"},
)
return render(
request,
"block/preview.html",
{
"svg_content": svg_content,
"form_data": form.cleaned_data,
"primary_color": primary_color,
},
)
@require_POST
def publish(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для сохранения текущей генерации как кандидата в галерею транса.
Требует согласия на отслеживание (Zero-PII cookie hypn0_vid).
"""
svg_content = request.POST.get("svg_content", "").strip()
if not svg_content:
return render(
request,
"block/publish_status.html",
{"error": "Нет данных SVG для публикации. Попробуйте сгенерировать заново."},
)
visitor_uuid = request.COOKIES.get("hypn0_vid")
if not visitor_uuid:
# Мозговые слизняки протестуют: пользователь не дал согласия и не подчинился Гипножабе
return render(
request,
"block/publish_status.html",
{
"not_agreed": True,
"svg_content": svg_content,
"shape": request.POST.get("shape", "circle"),
"cols": request.POST.get("cols", "35"),
"max_radius": request.POST.get("max_radius", "8"),
"blink": request.POST.get("blink", "6"),
"rotation": request.POST.get("rotation", "0"),
"scale": request.POST.get("scale", "980"),
"angle": request.POST.get("angle", "0"),
"color": request.POST.get("color", "#a855ff"),
},
)
# 1. Генерируем гипнотическое название
title = generate_hypno_title()
# 2. Подготавливаем SVG для галереи (пауза по умолчанию + hover)
gallery_svg = prepare_gallery_svg(svg_content)
svg_bytes = gallery_svg.encode("utf-8")
# 3. Собираем параметры генерации в метаданные
metadata = {
"shape": request.POST.get("shape", "circle"),
"cols": int(request.POST.get("cols", 35)) if request.POST.get("cols", "").isdigit() else 35,
"max_radius": int(request.POST.get("max_radius", 8)) if request.POST.get("max_radius", "").isdigit() else 8,
"blink": int(request.POST.get("blink", 6)) if request.POST.get("blink", "").isdigit() else 6,
"rotation": int(request.POST.get("rotation", 0)) if request.POST.get("rotation", "").lstrip("-").isdigit() else 0,
"scale": int(request.POST.get("scale", 980)) if request.POST.get("scale", "").isdigit() else 980,
"angle": int(request.POST.get("angle", 0)) if request.POST.get("angle", "").lstrip("-").isdigit() else 0,
"color": request.POST.get("color", "#a855ff"),
}
try:
svg_file = ContentFile(svg_bytes, name="hypn0.svg")
item = TbHypn0Item(
s_title=title,
file_svg=svg_file,
i_file_size=len(svg_bytes),
j_metadata=metadata,
i_level=TbHypn0Item.Level.CANDIDATE,
is_public=True,
)
item.save(visitor_uuid_or_fp=visitor_uuid)
except PermissionError:
return render(
request,
"block/publish_status.html",
{
"not_agreed": True,
"svg_content": svg_content,
"shape": request.POST.get("shape", "circle"),
"cols": request.POST.get("cols", "35"),
"max_radius": request.POST.get("max_radius", "8"),
"blink": request.POST.get("blink", "6"),
"rotation": request.POST.get("rotation", "0"),
"scale": request.POST.get("scale", "980"),
"angle": request.POST.get("angle", "0"),
"color": request.POST.get("color", "#a855ff"),
},
)
except Exception as e:
return render(
request,
"block/publish_status.html",
{"error": f"Сбой фиксации в трансе: {str(e)}"},
)
return render(
request,
"block/publish_status.html",
{
"success": True,
"item": item,
},
)
+13 -1
View File
@@ -110,9 +110,21 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
{% endblock EXTRA_LD_JSON %}</script> {% endblock EXTRA_LD_JSON %}</script>
<script src="{% static 'js/alpine.min.js' %}" defer></script> <script src="{% static 'js/alpine.min.js' %}" defer></script>
<script src="{% static 'js/htmx.min.js' %}" defer></script> <script src="{% static 'js/htmx.min.js' %}" defer></script>
<script>
document.addEventListener('htmx:configRequest', function(evt) {
var token = '{{ csrf_token }}';
if (!token) {
var match = document.cookie.match(/csrftoken=([^;]+)/);
if (match) token = match[1];
}
if (token) {
evt.detail.headers['X-CSRFToken'] = token;
}
});
</script>
{% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %} {% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %}
</head> </head>
<body class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %} <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}
{% include "block/header.html" %} {% include "block/header.html" %}
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8"> <main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
{% block CONTENT %}{% endblock CONTENT %} {% block CONTENT %}{% endblock CONTENT %}
+2 -2
View File
@@ -1,4 +1,4 @@
{# НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}<div id="allow-tracking-popup" class="sticky bottom-0 z-50 bg-gray-100/80 dark:bg-slate-800/70 backdrop-blur border-t border-gray-300 dark:border-gray-700"> {# НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}<div id="allow-tracking-popup" x-data="{ agreed: !!localStorage.getItem('hypn0_vid') }" x-show="!agreed" x-cloak class="sticky bottom-0 z-50 bg-gray-100/80 dark:bg-slate-800/70 backdrop-blur border-t border-gray-300 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 py-8 grid grid-cols-[auto_1fr] gap-x-4"> <div class="max-w-7xl mx-auto px-4 py-8 grid grid-cols-[auto_1fr] gap-x-4">
<p class="flex-col px-6 py-6"> <p class="flex-col px-6 py-6">
Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr> Внимание! В&nbsp;соответствии с&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr>
@@ -8,6 +8,6 @@
впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/info/privacy-policy">политики впадаете в&nbsp;транс и&nbsp;принимаете условия нашей <a href="/info/privacy-policy">политики
коконфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong> коконфи&shy;денциаль&shy;ности</a>. <strong>Не&nbsp;пытайтесь переключить вкладку!</strong>
</p> </p>
<button id="cookie-accept" class="text-lg px-6 py-2 my-6 border-2 rounded-md bg-emerald-600 hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 text-white">Подчиниться Гипножабе!</button> <button @click="const vid = crypto.randomUUID(); localStorage.setItem('hypn0_vid', vid); document.cookie = `hypn0_vid=${vid}; path=/; max-age=63072000; SameSite=Lax`; agreed = true" class="text-lg px-6 py-2 my-6 border-2 rounded-md bg-emerald-600 hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 text-white">Подчиниться Гипножабе!</button>
</div> </div>
</div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #} </div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}
+51
View File
@@ -0,0 +1,51 @@
{% if error %}
<div class="text-rose-500 flex flex-col items-center gap-4 text-center px-6">
<svg class="w-16 h-16 text-rose-500 opacity-80 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="font-mono text-sm font-bold uppercase tracking-wider text-rose-600 dark:text-rose-400">
{{ error }}
</p>
</div>
{% else %}
<div class="w-full h-full flex items-center justify-center p-4 select-none overflow-hidden" id="svg-canvas-container">
{{ svg_content|safe }}
</div>
<!-- Плавающий блок кандидата в галерею -->
<div class="absolute bottom-6 right-6 flex items-center gap-3 translate-y-20 group-hover:translate-y-0 transition-transform duration-300 pointer-events-none z-10">
<div id="gallery-candidate-action" class="pointer-events-auto" x-data="{ isPublishing: false }">
<form hx-post="{% url 'hypn0_site:publish' %}"
hx-target="#gallery-candidate-action"
hx-swap="outerHTML"
@submit="isPublishing = true">
{% csrf_token %}
<textarea name="svg_content" class="hidden">{{ svg_content }}</textarea>
<input type="hidden" name="shape" value="{% if form_data.shape %}{{ form_data.shape }}{% else %}circle{% endif %}">
<input type="hidden" name="cols" value="{% if form_data.cols %}{{ form_data.cols }}{% else %}35{% endif %}">
<input type="hidden" name="max_radius" value="{% if form_data.max_radius %}{{ form_data.max_radius }}{% else %}8{% endif %}">
<input type="hidden" name="blink" value="{% if form_data.blink is not None %}{{ form_data.blink }}{% else %}6{% endif %}">
<input type="hidden" name="rotation" value="{% if form_data.rotation is not None %}{{ form_data.rotation }}{% else %}0{% endif %}">
<input type="hidden" name="scale" value="{% if form_data.scale %}{{ form_data.scale }}{% else %}980{% endif %}">
<input type="hidden" name="angle" value="{% if form_data.angle is not None %}{{ form_data.angle }}{% else %}0{% endif %}">
<input type="hidden" name="color" value="{% if primary_color %}{{ primary_color }}{% else %}#a855ff{% endif %}">
<button id="like-to-gallery" type="submit" title="Отправить в галерею"
:disabled="isPublishing"
class="group flex items-center gap-2 bg-emerald-600/90 hover:bg-emerald-600 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 disabled:opacity-60 disabled:scale-100 disabled:pointer-events-none cursor-pointer">
<template x-if="isPublishing">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
</template>
<span x-text="isPublishing ? 'Проникновение в транс...' : 'Кандидат в галерею транса'">Кандидат в галерею транса</span>
<svg x-show="!isPublishing" class="w-5 h-5 transition-transform group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</form>
</div>
</div>
{% endif %}
+59
View File
@@ -0,0 +1,59 @@
{% if not_agreed %}
<!-- Сообщение от протестующих мозговых слизней (Zero-PII / отсутствие куки) -->
<div id="gallery-candidate-action" class="pointer-events-auto bg-amber-500/95 dark:bg-amber-600/95 text-stone-900 dark:text-stone-100 p-4 rounded-2xl shadow-2xl backdrop-blur-md max-w-sm border border-amber-300 dark:border-amber-400 animate-in fade-in zoom-in-95 duration-200">
<div class="flex items-start gap-3">
<div class="w-8 h-8 shrink-0 flex items-center justify-center bg-amber-600 dark:bg-amber-700 text-white rounded-full text-lg shadow-inner">
🐌
</div>
<div class="space-y-2">
<h4 class="font-bold text-xs uppercase tracking-wide text-amber-950 dark:text-amber-100">Мозговые слизняки протестуют!</h4>
<p class="text-xs leading-relaxed opacity-95">
Они наябедничали Гипножабе, что вы ещё не подчинились и сопротивляетесь её воле. Только истинно подчинившийся разумом удостаивается чести попасть в галерею транса!
</p>
<form hx-post="{% url 'hypn0_site:publish' %}"
hx-target="#gallery-candidate-action"
hx-swap="outerHTML"
class="pt-1 flex items-center gap-2">
{% csrf_token %}
<textarea name="svg_content" class="hidden">{{ svg_content }}</textarea>
<input type="hidden" name="shape" value="{{ shape }}">
<input type="hidden" name="cols" value="{{ cols }}">
<input type="hidden" name="max_radius" value="{{ max_radius }}">
<input type="hidden" name="blink" value="{{ blink }}">
<input type="hidden" name="rotation" value="{{ rotation }}">
<input type="hidden" name="scale" value="{{ scale }}">
<input type="hidden" name="angle" value="{{ angle }}">
<input type="hidden" name="color" value="{{ color }}">
<button type="submit"
@click="const vid = crypto.randomUUID(); localStorage.setItem('hypn0_vid', vid); document.cookie = 'hypn0_vid=' + vid + '; path=/; max-age=63072000; SameSite=Lax'; const popup = document.getElementById('allow-tracking-popup'); if (popup) popup.style.display = 'none';"
class="bg-stone-900 hover:bg-black text-white dark:bg-stone-100 dark:hover:bg-white dark:text-stone-900 text-xs font-bold px-3 py-1.5 rounded-lg shadow transition-all hover:scale-105 active:scale-95 cursor-pointer">
Подчиниться и опубликовать
</button>
</form>
</div>
</div>
</div>
{% elif success %}
<!-- Успешная фиксация кандидата в галерее транса -->
<div id="gallery-candidate-action" class="pointer-events-auto flex flex-col sm:flex-row items-start sm:items-center gap-2.5 bg-purple-950/90 hover:bg-purple-950 text-purple-100 text-xs font-medium px-4 py-2.5 rounded-2xl sm:rounded-full shadow-2xl backdrop-blur-md border border-purple-500/40 animate-in fade-in duration-300">
<div class="flex items-center gap-2">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
</span>
<span class="font-semibold text-emerald-300">Уже в галерее транса</span>
</div>
<span class="hidden sm:inline opacity-40"></span>
<span class="opacity-90 italic">идет прорастание психо-паутины...</span>
<span class="font-mono text-[11px] bg-purple-900/80 text-purple-300 border border-purple-400/30 px-2 py-0.5 rounded-md">#{{ item.s_hash_id }}</span>
</div>
{% elif error %}
<!-- Ошибка публикации -->
<div id="gallery-candidate-action" class="pointer-events-auto flex items-center gap-3 bg-rose-600/90 text-white text-xs font-medium px-4 py-2.5 rounded-full shadow-xl backdrop-blur-md border border-rose-400/30">
<span>{{ error }}</span>
</div>
{% endif %}
+4
View File
@@ -74,6 +74,10 @@
добавить border-0 (убирает пунктир) и свой text-slate-900 dark:text- добавить border-0 (убирает пунктир) и свой text-slate-900 dark:text-
slate-100 (задаёт свой цвет вместо цвета типовой ссылки) — это и сделано slate-100 (задаёт свой цвет вместо цвета типовой ссылки) — это и сделано
в block/header.html.{% endcomment %} */ в block/header.html.{% endcomment %} */
[x-cloak] {
display: none !important;
}
a { a {
@apply border-b border-dotted @apply border-b border-dotted
/* В светлой теме: глубокий изумрудный, в темной: блеклый лайм (отлично читается) */ /* В светлой теме: глубокий изумрудный, в темной: блеклый лайм (отлично читается) */
+76 -10
View File
@@ -11,7 +11,7 @@
colors: ['#a855ff'], colors: ['#a855ff'],
/* Параметры сетки и трансформаций */ /* Параметры сетки и трансформаций */
cols: 80, /* Плотность галлюцинации (число колонок сетки, 20-200) */ cols: 35, /* Плотность галлюцинации (число ячеек сетки по длинной стороне, 10-80) */
radius: 8, /* Размер гипно-точки (максимальный радиус элементов, 3-20) */ radius: 8, /* Размер гипно-точки (максимальный радиус элементов, 3-20) */
blink: 6, /* Интенсивность мерцания CSS-анимации (0-10) */ blink: 6, /* Интенсивность мерцания CSS-анимации (0-10) */
rotation: 0, /* Укачивание (угол поворота элементов, от -15° до +15°) */ rotation: 0, /* Укачивание (угол поворота элементов, от -15° до +15°) */
@@ -22,7 +22,28 @@
isLoading: false, /* Идет ли процесс генерации SVG (индикация, блокировка) */ isLoading: false, /* Идет ли процесс генерации SVG (индикация, блокировка) */
hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */ hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */
hasImage: false, /* Флаг загрузки пользовательского файла */ hasImage: false, /* Флаг загрузки пользовательского файла */
fileName: '', /* Имя выбранного файла */
imagePreviewUrl: null, /* Превью загруженного файла (Blob URL) */
timer: null, /* Ссылка на таймер удержания анимации размышления */ timer: null, /* Ссылка на таймер удержания анимации размышления */
copied: false, /* Флаг успешного копирования в буфер */
/* Обработка выбора файла (превью + имя) */
handleFileChange(event) {
const files = event.target.files;
if (files && files.length > 0) {
const file = files[0];
this.hasImage = true;
this.fileName = file.name;
if (this.imagePreviewUrl) URL.revokeObjectURL(this.imagePreviewUrl);
this.imagePreviewUrl = URL.createObjectURL(file);
} else {
this.hasImage = false;
this.fileName = '';
if (this.imagePreviewUrl) URL.revokeObjectURL(this.imagePreviewUrl);
this.imagePreviewUrl = null;
}
this.hasChanges = true;
},
/* Метод запуска состояния размышления (8 секунд) */ /* Метод запуска состояния размышления (8 секунд) */
startThinking() { startThinking() {
@@ -32,6 +53,29 @@
this.isLoading = false; this.isLoading = false;
this.hasChanges = false; this.hasChanges = false;
}, 8000); }, 8000);
},
copySvg() {
const svg = document.querySelector('#preview svg');
if (!svg) return;
navigator.clipboard.writeText(svg.outerHTML).then(() => {
this.copied = true;
setTimeout(() => this.copied = false, 2000);
});
},
downloadSvg() {
const svg = document.querySelector('#preview svg');
if (!svg) return;
const blob = new Blob([svg.outerHTML], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hypn0-art.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} }
}"> }">
@@ -45,7 +89,9 @@
@change="hasChanges = true" @change="hasChanges = true"
@input="hasChanges = true" @input="hasChanges = true"
@htmx:before-request="startThinking()" @htmx:before-request="startThinking()"
@htmx:after-request="isLoading = false; hasChanges = false"
class="relative bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-stone-200 dark:border-zinc-800 overflow-hidden"> class="relative bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-stone-200 dark:border-zinc-800 overflow-hidden">
{% csrf_token %}
<!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) --> <!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) -->
<div x-show="isLoading" <div x-show="isLoading"
@@ -65,14 +111,30 @@
<label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label> <label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label>
<div class="relative group"> <div class="relative group">
<input type="file" name="image" accept="image/*" <input type="file" name="image" accept="image/*"
@change="handleFileChange($event)"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"> class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div <div
class="border-2 border-dashed border-stone-300 dark:border-zinc-700 group-hover:border-emerald-500 rounded-xl p-8 flex flex-col items-center justify-center text-center transition-colors"> :class="hasImage ? 'border-emerald-500 bg-emerald-50/20 dark:bg-emerald-950/20' : 'border-stone-300 dark:border-zinc-700'"
<span class="text-stone-400 group-hover:text-emerald-500 flex flex-col items-center gap-3"> class="border-2 border-dashed group-hover:border-emerald-500 rounded-xl p-6 flex flex-col items-center justify-center text-center transition-all">
<!-- Состояние 1: Файл не выбран -->
<div x-show="!hasImage" class="text-stone-400 group-hover:text-emerald-500 flex flex-col items-center gap-3 transition-colors">
<svg class="w-20 h-20 fill-current transition-colors opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg class="w-20 h-20 fill-current transition-colors opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M477 174c8.812-13.708 13.948-29.993 13.948-47.466 0-48.55-39.499-88.048-88.05-88.048-32.839 0-61.516 18.084-76.651 44.803-22.773-5.031-46.356-7.586-70.424-7.586-24.003 0-47.624 2.55-70.427 7.582-15.135-26.717-43.813-44.799-76.65-44.799-48.55 0-88.048 39.498-88.048 88.049 0 17.473 5.137 33.759 13.949 47.467C11.987 204.676 0 239.016 0 274.61c0 53.849 27.194 104.194 76.573 141.763C124.677 452.97 188.399 473.125 256 473.125s131.323-20.155 179.427-56.753C484.806 378.803 512 328.457 512 274.609c0-35.59-11.986-69.935-34.822-100.219zM403.076 70.224c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.436-56.701 56.701-56.701zm-294.152 0c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.435-56.701 56.701-56.701zM56.629 197.71a87.57 87.57 0 0 0 52.294 17.264c48.55 0 88.05-39.498 88.05-88.049a88.25 88.25 0 0 0-1.02-13.409c19.464-4.03 39.588-6.075 60.047-6.075 20.515 0 40.613 2.041 60.048 6.068-.672 4.376-1.021 8.857-1.021 13.417 0 48.55 39.498 88.049 88.049 88.049 19.568 0 37.66-6.424 52.294-17.263 15.157 21.633 23.712 45.416 25.08 69.911-23.821 20.076-49.402 34.122-79.949 43.943-38.207 12.281-85.474 18.252-144.5 18.252s-106.292-5.971-144.499-18.253c-30.549-9.821-56.132-23.869-79.952-43.945 1.369-24.496 9.921-48.278 25.079-69.91zm199.372 244.067c-107.337 0-197.317-56.31-219.433-131.312 19.882 13.102 41.246 23.196 65.34 30.94 41.355 13.295 91.76 19.758 154.094 19.758s112.738-6.463 154.095-19.758c24.092-7.745 45.456-17.839 65.338-30.939-22.12 75.002-112.099 131.311-219.434 131.311z"/><use href="#B"/><use href="#B" x="294.153"/><path d="M219.852 256.624c-9.79 0-17.756 8.048-17.756 17.986s7.966 17.992 17.756 17.992c9.802 0 17.767-8.055 17.767-17.992s-7.965-17.986-17.767-17.986zm72.163-.14c-9.885 0-17.902 8.115-17.902 18.126s8.017 18.126 17.902 18.126c9.872 0 17.89-8.115 17.89-18.126s-8.018-18.126-17.89-18.126z"/><defs><path id="B" d="M108.931 105.926c-11.45 0-20.738 9.401-20.738 20.998s9.288 20.998 20.738 20.998c11.436 0 20.725-9.401 20.725-20.998s-9.289-20.998-20.725-20.998z"/></defs></svg> <path d="M477 174c8.812-13.708 13.948-29.993 13.948-47.466 0-48.55-39.499-88.048-88.05-88.048-32.839 0-61.516 18.084-76.651 44.803-22.773-5.031-46.356-7.586-70.424-7.586-24.003 0-47.624 2.55-70.427 7.582-15.135-26.717-43.813-44.799-76.65-44.799-48.55 0-88.048 39.498-88.048 88.049 0 17.473 5.137 33.759 13.949 47.467C11.987 204.676 0 239.016 0 274.61c0 53.849 27.194 104.194 76.573 141.763C124.677 452.97 188.399 473.125 256 473.125s131.323-20.155 179.427-56.753C484.806 378.803 512 328.457 512 274.609c0-35.59-11.986-69.935-34.822-100.219zM403.076 70.224c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.436-56.701 56.701-56.701zm-294.152 0c31.266 0 56.703 25.436 56.703 56.701s-25.437 56.702-56.703 56.702-56.701-25.436-56.701-56.702 25.435-56.701 56.701-56.701zM56.629 197.71a87.57 87.57 0 0 0 52.294 17.264c48.55 0 88.05-39.498 88.05-88.049a88.25 88.25 0 0 0-1.02-13.409c19.464-4.03 39.588-6.075 60.047-6.075 20.515 0 40.613 2.041 60.048 6.068-.672 4.376-1.021 8.857-1.021 13.417 0 48.55 39.498 88.049 88.049 88.049 19.568 0 37.66-6.424 52.294-17.263 15.157 21.633 23.712 45.416 25.08 69.911-23.821 20.076-49.402 34.122-79.949 43.943-38.207 12.281-85.474 18.252-144.5 18.252s-106.292-5.971-144.499-18.253c-30.549-9.821-56.132-23.869-79.952-43.945 1.369-24.496 9.921-48.278 25.079-69.91zm199.372 244.067c-107.337 0-197.317-56.31-219.433-131.312 19.882 13.102 41.246 23.196 65.34 30.94 41.355 13.295 91.76 19.758 154.094 19.758s112.738-6.463 154.095-19.758c24.092-7.745 45.456-17.839 65.338-30.939-22.12 75.002-112.099 131.311-219.434 131.311z"/><use href="#B"/><use href="#B" x="294.153"/><path d="M219.852 256.624c-9.79 0-17.756 8.048-17.756 17.986s7.966 17.992 17.756 17.992c9.802 0 17.767-8.055 17.767-17.992s-7.965-17.986-17.767-17.986zm72.163-.14c-9.885 0-17.902 8.115-17.902 18.126s8.017 18.126 17.902 18.126c9.872 0 17.89-8.115 17.89-18.126s-8.018-18.126-17.89-18.126z"/><defs><path id="B" d="M108.931 105.926c-11.45 0-20.738 9.401-20.738 20.998s9.288 20.998 20.738 20.998c11.436 0 20.725-9.401 20.725-20.998s-9.289-20.998-20.725-20.998z"/></defs></svg>
<span>Покорми Гипнoжабу,<br />притащи сюда img или&nbsp;кликни</span> <span>Покорми Гипнoжабу,<br />притащи сюда img или&nbsp;кликни</span>
</span> </div>
<!-- Состояние 2: Файл выбран -->
<div x-show="hasImage" class="flex flex-col items-center gap-3 text-emerald-600 dark:text-emerald-400">
<img :src="imagePreviewUrl" alt="Превью"
class="w-24 h-24 object-cover rounded-xl border-2 border-emerald-500 shadow-md">
<div class="text-xs font-mono">
<p class="font-bold">Гипножаба облизывается на:</p>
<p class="truncate max-w-[260px] text-stone-700 dark:text-zinc-300 font-semibold mt-0.5" x-text="fileName"></p>
<p class="text-[11px] text-stone-400 dark:text-zinc-500 mt-1">кликни или притащи другой файл, чтобы скормить</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -265,12 +327,12 @@
<div class="flex justify-between mb-1"> <div class="flex justify-between mb-1">
<label class="text-xs font-bold text-stone-400 uppercase">Плотность галлюцинации</label> <label class="text-xs font-bold text-stone-400 uppercase">Плотность галлюцинации</label>
<span class="text-xs transition-colors duration-300" <span class="text-xs transition-colors duration-300"
:class="cols >= 140 ? 'text-red-600' : (cols > 80 ? 'text-yellow-500' : 'text-emerald-600')" :class="cols >= 60 ? 'text-red-600' : (cols > 50 ? 'text-yellow-500' : 'text-emerald-600')"
x-text="cols"></span> x-text="cols"></span>
</div> </div>
<input type="range" name="cols" min="20" max="200" x-model="cols" <input type="range" name="cols" min="10" max="80" x-model="cols"
@input="$el.dispatchEvent(new CustomEvent('input-change'))" @input="$el.dispatchEvent(new CustomEvent('input-change'))"
:class="cols >= 140 ? 'accent-red-600' : (cols > 80 ? 'accent-yellow-500' : 'accent-emerald-500')" :class="cols >= 60 ? 'accent-red-600' : (cols > 50 ? 'accent-yellow-500' : 'accent-emerald-500')"
class="w-full transition-all duration-300"> class="w-full transition-all duration-300">
</div> </div>
{# РАЗМЕР ЭЛЕМЕНТА #}<div> {# РАЗМЕР ЭЛЕМЕНТА #}<div>
@@ -410,13 +472,17 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<button <button
class="bg-stone-800 text-white dark:bg-zinc-800 py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-stone-700 transition-colors"> type="button"
@click="copySvg()"
class="bg-stone-800 text-white dark:bg-zinc-800 py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-stone-700 transition-colors cursor-pointer active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg> <path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
КОПИРОВАТЬ SVG <span x-text="copied ? 'СКОПИРОВАНО В БУФЕР!' : 'КОПИРОВАТЬ SVG'"></span>
</button> </button>
<button <button
class="bg-emerald-600 text-white py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-emerald-500 transition-colors shadow-lg shadow-emerald-900/20"> type="button"
@click="downloadSvg()"
class="bg-emerald-600 text-white py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-emerald-500 transition-colors shadow-lg shadow-emerald-900/20 cursor-pointer active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
СКАЧАТЬ СКАЧАТЬ
</button> </button>
Generated
+401 -12
View File
@@ -1,10 +1,9 @@
# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand.
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.12.1" version = "3.12.1"
description = "ASGI specs, helper code, and adapters" description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -16,11 +15,32 @@ files = [
mypy = ["mypy (>=1.14.0)"] mypy = ["mypy (>=1.14.0)"]
tests = ["pytest", "pytest-asyncio"] tests = ["pytest", "pytest-asyncio"]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"},
{file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]] [[package]]
name = "django" name = "django"
version = "6.1" version = "6.1"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false optional = false
python-versions = ">=3.12" python-versions = ">=3.12"
files = [ files = [
@@ -41,7 +61,6 @@ bcrypt = ["bcrypt (>=4.1.1)"]
name = "django-debug-toolbar" name = "django-debug-toolbar"
version = "7.0.0" version = "7.0.0"
description = "A configurable set of panels that display various debug information about the current request/response." description = "A configurable set of panels that display various debug information about the current request/response."
category = "dev"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -57,7 +76,6 @@ sqlparse = ">=0.2"
name = "django-environ" name = "django-environ"
version = "0.14.0" version = "0.14.0"
description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application."
category = "main"
optional = false optional = false
python-versions = "<4,>=3.9" python-versions = "<4,>=3.9"
files = [ files = [
@@ -70,11 +88,26 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)",
docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-copybutton", "sphinx-notfound-page"] docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-copybutton", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"] testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"]
[[package]]
name = "etpgrf"
version = "0.1.6.post1"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
optional = false
python-versions = ">=3.10"
files = [
{file = "etpgrf-0.1.6.post1-py3-none-any.whl", hash = "sha256:0863b14385bdacdd405f137ca2ce6bdb6f683f0189e8c927196a1eee754366be"},
{file = "etpgrf-0.1.6.post1.tar.gz", hash = "sha256:984d201cff232a58c05b6f4455a50f822162520df829ad4d543bfe0b7fd962a9"},
]
[package.dependencies]
beautifulsoup4 = ">=4.10.0"
lxml = ">=4.9.0"
regex = ">=2022.1.18"
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "26.0.0" version = "26.0.0"
description = "WSGI HTTP Server for UNIX" description = "WSGI HTTP Server for UNIX"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -93,11 +126,215 @@ setproctitle = ["setproctitle"]
testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"] testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"]
tornado = ["tornado (>=6.5.0)"] tornado = ["tornado (>=6.5.0)"]
[[package]]
name = "hashids"
version = "1.3.1"
description = "Implements the hashids algorithm in python. For more information, visit http://hashids.org/"
optional = false
python-versions = ">=2.7"
files = [
{file = "hashids-1.3.1-py2.py3-none-any.whl", hash = "sha256:8bddd1acba501bfc9306e7e5a99a1667f4f2cacdc20cbd70bcc5ddfa5147c94c"},
{file = "hashids-1.3.1.tar.gz", hash = "sha256:6c3dc775e65efc2ce2c157a65acb776d634cb814598f406469abef00ae3f635c"},
]
[package.extras]
test = ["pytest (>=2.1.0)"]
[[package]]
name = "lxml"
version = "6.1.2"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = false
python-versions = ">=3.8"
files = [
{file = "lxml-6.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:522387e05cd015a81d1dc621fb167fb42b8f629ccd2e8b39de583828f165aae6"},
{file = "lxml-6.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d86130d70a2557cdf825dffc56255f1f16b83a7bbeab677b4cd040c4c53d8c52"},
{file = "lxml-6.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08cd52e6487435c75f2da0a5b276beef7fed161681b93ab766e66b954f0c349a"},
{file = "lxml-6.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:785761d5123f222cd97f2263a510107226fe32ce7aa7824a90616a41c574ace1"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae520f189895c5dd7eeb2b7a372d464da6f4a1ba1d0ecb741b1d4fe4c1f699ac"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83e7510a6dda8df41d1b68b783de2953b3feb55a11dcebf693201ebaa5cc0c4a"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:c20fa05d128c463209ef5323ebf33ee1cac6d87cdc3933fd789fd3c101017c8e"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:e7269cc410f3cdf84a66914fc0ef54b1618115c87fb4f9a59a05c5dfc23bece1"},
{file = "lxml-6.1.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7233a987a101bdf79059014130262a01339094a0a709f175162542f33b55d4e"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ee23f6599682bd4d48bb757c0633e78774eedfb65a7e52851f9ad182eeeb625e"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e062f5ac1255dfa6c98e3e3863ec18bc79d0947d22d08921a3ca60cee40559fd"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:cb0cf498efa3204621b3c5576f0accd80ad2ee85575f1cae5d2f98de32c8d9cc"},
{file = "lxml-6.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ee7410c98222070fd717ad881ee2a80cc11826b7001b9a5a807155d8918bfc7a"},
{file = "lxml-6.1.2-cp310-cp310-win32.whl", hash = "sha256:aa224ecc613d411690aa650dbf01daafbe385cd6c67145e80bc5fc01b3a71469"},
{file = "lxml-6.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1c0173595dc1c25768f42681a1517dcfc74bb18a34695f127931cbd05f4dead6"},
{file = "lxml-6.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:adbecbfe44a497c742792457b1c27300617967c18c3934d2416023eba8d8c553"},
{file = "lxml-6.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da6a4f55f0e3308c07354b1ee239c5550afc212f81629a6067db505ace3b667a"},
{file = "lxml-6.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f4d2c36fd5997d30ff19c29fb93293401d0daaf87512297d47610e6883964b5"},
{file = "lxml-6.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1d55a614d2f0457b1f7511c1b7bec0db0dcdd4af4d09d226829eb054c647527c"},
{file = "lxml-6.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:575fef7f30048b744dffb3e4ff64a18cac7dba3fd26efdea5730ade9d1bdeb33"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79b428c3242e63bdacf3b526a34e0b8b26583846fc597da84b8f0c3d5ea446b2"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12ecfea07d767f6accbf30b014e1c477b5eabb13eb4e8c748215efb52c0e314a"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bfcbee8ffff4188f4c6d97eceeff36d8eb983cf838933cbc12ce5f5dd51476c6"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:822d9397033edbe530a13bb1e0091c0e817536b6aba87a9b4ad626ed779ca0bd"},
{file = "lxml-6.1.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4303f904fb6c41b58dc70743b1d8a470aba6c9897427c48324cff1a95673ddb4"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdd35422de747237f451e821766e2b6be3dd2c31955c1ecd7f17984c5b9bb62d"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b3ca02ef3b5920b88119c82eb6badfb2d082b1f681d528a856dcce17c8706da8"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4bf14db2f0214003ec7f46c4300e2065668fc93e20448c1c95bac2e952072168"},
{file = "lxml-6.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2afd1688e372d8eafaa6f56c589399e0a87d086a0c110f6346b0b50f42e67e25"},
{file = "lxml-6.1.2-cp311-cp311-win32.whl", hash = "sha256:aea814342f6afd20d832937ff8b333cd6506428a39c0c4c70c2380aab1887bfb"},
{file = "lxml-6.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:b3db5497af55f7a557c95265dd3b91c75dc56364a7b59f258c45fa5576dce058"},
{file = "lxml-6.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:e8dc3d29f2ed2bbf24c205a86326d6681230ace55abfb3f9d5230f42078ad63d"},
{file = "lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237"},
{file = "lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3"},
{file = "lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40"},
{file = "lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185"},
{file = "lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f"},
{file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313"},
{file = "lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3"},
{file = "lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f"},
{file = "lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49"},
{file = "lxml-6.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:351318f5c0eb7fcab5b4fdb507c6f88fb2c4b5e67784c7e5911448c91fffb5d4"},
{file = "lxml-6.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0edde95e4b4278dcc0175eda06dc8aa2631ad9f83ae5dbdbc4f0925e200b0b0"},
{file = "lxml-6.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8326e24ae6c3a6bfb03fa8b4793f9a5d804c125228aa067f652b0428e31b87c"},
{file = "lxml-6.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c534ed898413f439b048130011e99a4245ee13d62d431f6b4f7f2484d02a93a"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e37fe49fe2d5aa40a2cb1cc8176673ad7de0d124e6f4a509d9318f5979c7871"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9b52ea73a37fc64aa3357ff8607801d46dd170506d3cf8253a91a1d91639d4f9"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8b9a92652e75e7731309ea51db5dee892eef414ce70a6ec3441e5d36bf5189f"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9088da25ecd609965f838d89fda0465a905b48f4dd90331db9845518f2177372"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c"},
{file = "lxml-6.1.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b20440e578d269c5e8a722ab602ddd0f0cedb8b080006b3f936da9991a593d3b"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7766e525282dd38fd89567311323e441996eb958e8e816d16b38f782e3aecd2a"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9221442682c27417f10fe11184ea4cce174b25ab52465570b1f3ee3f85f320fa"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75530642d8471327e691ab9b0513a5f9c77f38871014ceda40f51bb51765c0a1"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678e35f1cbca98f55107511ee21a60568535c950f3c2371819bd64504c980d20"},
{file = "lxml-6.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c2bae42b3a09f977330a08f4a8fe72aec58c4bdb89069d3fe7272a71d885881"},
{file = "lxml-6.1.2-cp313-cp313-win32.whl", hash = "sha256:5848f3de6a8de8a93cff9f068134393ff5fa69ac2a04399f7d49cd67c61c348c"},
{file = "lxml-6.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:6cb0c87421946030b92b558be416852780a912454e3dcba0998e4497c9c588d5"},
{file = "lxml-6.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:648861c19b775b89ebefa14586f85090b10163367476d77f242c4131c835ce73"},
{file = "lxml-6.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d50a44113fe6800dcc8a859332b823a4735b1e6ae1b0063882e4cca569ec3e29"},
{file = "lxml-6.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fa813b0247d0543a563b993ac3dba6168eef59e3a61448432cf5453300c2412b"},
{file = "lxml-6.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d858e718b94033ab4b67e4a58fe3114c65bae01ae2314a62fb39ae8897ed4324"},
{file = "lxml-6.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e3b666f57a5d81562f38c766c762416b0f6eb58a00590546911514b48412abd"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26ff164c6629e5c4d11c9e55d5ea3d6eed0be2a420eee1f55cbce6e2c23e231a"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:962c12b51d0b164f12569af225dea57568477e24a845b96eaccbef6c07e4cc03"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47e367dfe341521426692819803e260d0673899c0ff611f14af978d725e2c999"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92c2b366028ac01e90399e6d17734ce6e4f4aeddd8ba75fbaf80ea11d6c6d645"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:7e81fc065ede5d58dd0bf0912025aee1bd04c52c2affd61fdb93226a97ce2fc6"},
{file = "lxml-6.1.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:633ac039cb32366dd5935868e041e385875c017b8cd54ea56aeee3fe29ca5935"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f3194777c0d05945ac91d8594be25d2679d1d826e01e1fc90bae568ff3a547b"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1133bd969f2bfcc6b0c0cf7cdf5f2631e62b23fa2471ee8bd44f6ab73554ee9a"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1edca8f4a92b94e873093df959f141d388f2141fcad0c47598442fb4730ef57a"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8512b3775d68994dd1d6d533161e0a214f2ad9c634659d34a99c98e86c6c3d68"},
{file = "lxml-6.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5005c0c9e4d749a76a2ff8bd5918a8bb248df8e08e73a55654b9f79c9cd1e2b"},
{file = "lxml-6.1.2-cp314-cp314-win32.whl", hash = "sha256:e17e2c30e27f56da5551e7a425888b45f013e940b99ab07d125a1c33f77a4605"},
{file = "lxml-6.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:87e9673cd8a3445024fe38e7f91b55fa3428437eec9b7a7ff7d81979520c0d2d"},
{file = "lxml-6.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:878e7c8ada8f92c52f13f35a2ab98ef0adf7fd0211d164fc2af589e4c3cfed63"},
{file = "lxml-6.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:94162456ed0a64fb1c06915df5bd06af4675ae3966d6048fcb73b0906e0e0222"},
{file = "lxml-6.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4b0fa7109b1d0bc1747d8241a0853e135eefb1c978685241b544c46937383efd"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:604f4778632588d7c000e7e19430639dc12fca58b5b6e99edffba7631725ef0e"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a096d6a5f96b776a5b020cb45c17c545effd2a3b6639e6fa97bc95537600923"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6454d184d556eaf4cb3d6f69e405d21602d6fdcf08b8d57796824275986c6595"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b68f2548259bb04e0b3d5df0c397abe8b0080f5e1ffe4019fb7a8bf01a9339e"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c9cc4b6532abe154dbdebb42aaba8d52c852919591e45067f5b7d46a0405e88"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:57188e441ab24f906bd5a5c14eb55363ab51aa6c0de549f3dd320043721cc118"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d0bfd719c254bbe60ea022cff0e6ffb799a6fa7d4d72852cebe0257957b32d68"},
{file = "lxml-6.1.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be6f87cd224254a8f81324e34cc655508b83f1d70458a1a39857ad2aa9925852"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:074a88f70a7360a4a0c5be5d898062cd26f898c25b459efb1bdd43ae700c5a1a"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9031f5f01452681abf39fdd65f84a70cb01a7572a1bbf570042e826b1232d07b"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:cfeac14425fc7a6fca7864b774d4ee63547926158f4a18c67d77b2c9a948acf1"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8ec111ff8067325f85c08aa9c2b26179ec0537bb89c003fde31127139f85f82d"},
{file = "lxml-6.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48e912f37c99a297175ba955f55a47c0e1c834b506ef162e52a6e4fe276e6e45"},
{file = "lxml-6.1.2-cp314-cp314t-win32.whl", hash = "sha256:7c444c3a6e8e75334879980eed96568f0e12064c8b1913424eac1805e976736b"},
{file = "lxml-6.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7f35ba7667004ecdafebbe08da7c9fa06ee6195275bb7ef7a29ee1901e69519c"},
{file = "lxml-6.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d117f39b28ab8a330a74abdbe61c2255b51973b238db25fd6c2448de1eb2a02d"},
{file = "lxml-6.1.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:1e3c67b817867c484794d7fe0d73045d7d0c67460c78a0a1249a9e92266e6a0e"},
{file = "lxml-6.1.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d3e97ac4353cca3fbbfa829bc0c6a913771573d1c6d46932d4335c46f2b7796a"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:827438bf6c8292d22a409bb7990d7cffce410f33e7664e46ca74d2ecc26975ef"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c470d192e27f97842a068cf12a1c1296b20ca716c56a9249715c6654bc192d19"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef0b8ba6e13597f681b2b4924ca9c4e8c88420bf0e21d9a9006c757f2fc39d1f"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:65c32ddc5d0750129c7b119fb57d48192b76d334c21e6b690d19dfb06b34af79"},
{file = "lxml-6.1.2-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0aa07065497f191ad26c4b587ce5dbb5a7105285a3789aafd0661750e8bac537"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cde6b8db7d2e5135129eb5e74b7b44dd2053aa767cd5023541fccedddc262453"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b28842b30c4bc2e6afe137d98a5d2071a62589471e76d053bea55b0e53298af9"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:11f529062255209a421ae4de5b1bb36b2f0a2e1a700745e675a4bf4084d13c00"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f8b89b3be75a37509602b03f9cfa1a28298d4eed4625748148307aeb907901b7"},
{file = "lxml-6.1.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1a2331da06dd55a8184985306eb2afd72d708283ce7e85d67bba77317b785060"},
{file = "lxml-6.1.2-cp315-cp315-win32.whl", hash = "sha256:442766b326d9892585a64e8c6c4b5ab81d0e6c0538c9f0fc11a84dc101a5d97f"},
{file = "lxml-6.1.2-cp315-cp315-win_amd64.whl", hash = "sha256:a7fd1dd6faa3df9dcd8f1765237362cd885ca62cdf77a7c5f5ea383ae5b6048b"},
{file = "lxml-6.1.2-cp315-cp315-win_arm64.whl", hash = "sha256:054175250531a5fb102d485743ff16412279c93add12385b3b1c3d7b16d8deaa"},
{file = "lxml-6.1.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:84a2a46b93b789d8acb44cfcb3d967ce9dbe29884ddb93fbb1a33f0e0c8fcd86"},
{file = "lxml-6.1.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4aced3284e0353c798b060fe2c175eb81410e99b9a7e2ae6951be5333732b111"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47c92dc5167de16e27ace8332454f12ba172dcab04f7a78a9eae14e2e41b6a41"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40366c23a938008a3bedfcfd80709b3a857c188b4d710b083e978ef5d2c1c715"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c4c6dc1b2485aaa4adfb6ed754f90dddcb2b96a66bbebc9e1ac242b5ce5e818"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:3a698fad6f122a9b3e2dc2fb598c1de7329c74a67c7a334c9109a440de2508e5"},
{file = "lxml-6.1.2-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14879fa5eb2b793c040bbfcb62011aa3015c65d6c9875e063ea98ce2029d51fb"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b631174cd2e4d9f8a94ef17f911c6ded10ede93b5e7860dee7bbf85961d321e9"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:ceafa5e0536c62a5cd9f65327fa0b57d6f0b0e3435daf2c98a78d0dde7ecbae1"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7c482e87cc86bed78a50462560675bc2c348ef72c47596f9b933346d5a8e920e"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c0d2dde8a50520efc51644587f0fc4810e3af7d3e029d7af0be93bf39e2b5c"},
{file = "lxml-6.1.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dd7ea3fa47154b9fff90591b961e41b3718bd7fcd5bc2d9bb47e9845c8ace088"},
{file = "lxml-6.1.2-cp315-cp315t-win32.whl", hash = "sha256:87534cec6ea325435e4adf2326b0cf3110eee9a47abf73652eb155db639c08c6"},
{file = "lxml-6.1.2-cp315-cp315t-win_amd64.whl", hash = "sha256:4e220a9c297e5d36895d489a08c9a3f1f6193b6414e702c5fb751e4a3767f8d0"},
{file = "lxml-6.1.2-cp315-cp315t-win_arm64.whl", hash = "sha256:f16a407766bac51c65d605b06d900821751a79aa20e12185f273f14a17180e7b"},
{file = "lxml-6.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aebcc6b184c935e1f7091c09124cfe5107b7c2253894ba23ad646828c17e4c3b"},
{file = "lxml-6.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6330cf0ce83f6273ad8ad99bdd25d6ebb3863912f9ac717f96bc8942706e0e26"},
{file = "lxml-6.1.2-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af6585a466cee2c5a524f7fffc591844bd604a29fdd9cade964f548512b5ef7e"},
{file = "lxml-6.1.2-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:841630176c15fa5d3c5cd6f755435d3c5540a82e1dd2a7de1799401f92ee6d24"},
{file = "lxml-6.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:215bb3cc4be015ccac3c7d4f25eb7b941f857fe5b02c0e3504cca61f7fb12455"},
{file = "lxml-6.1.2-cp38-cp38-win32.whl", hash = "sha256:7c687fd8e558c7d169f6f1987b696f37824d3a097f291bffd0ab4a2ea2307dfb"},
{file = "lxml-6.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:69df1856cb6c065e5bfd23adcc7408bfa6dcf32b0018373a99b0769bd86e2256"},
{file = "lxml-6.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2dcc69e307e0916c7a0b552212010938d02a664d29b6bda75ab2bc5fa487c861"},
{file = "lxml-6.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:243ecef7cb7415766dd742336cd5b8361a84c6f297e2773c865b783724cbbe74"},
{file = "lxml-6.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08f0c9ed7cded07c5e798b17c9c25bbba5d0650c8ff0a7f65f84c634966f0f10"},
{file = "lxml-6.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8ffb17ec0a8bae18b6628ae40b0896eb264dd285e39a0faa864965c00933b64c"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d78ba560f3dd404d87b1fcc89b2b382d638ea2998431a3b2e5cda0f3ba2da91"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2b7fe53abced1fe8bd984a9ab3c8c98bc093ec4f9f543089a8817a493818208"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:20134744db7abcbd5232214e767814ef64e5ab57a5b7df93a2bd68b74ef0a6c0"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:a02164a8cd3e2dc028918e51af844c934c7a24a0b8f4064368360aa14ad1aac4"},
{file = "lxml-6.1.2-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec8d09f460fdeb65f9ead9b75941e312def4bcbb23e1f951b7def061eb99501d"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1fcfe8481302e6dec07909914b8f3f9e1739ae1615209d4b9e7544325fb699c4"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3be94d2464f19e42d8c39a299f356b12f2fd095c28793671eabfcd9db9c76987"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:86d93dc3882c283e9aa2124d7d2b50c85579485216a2b3b7f91ba479e31a128f"},
{file = "lxml-6.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e613018a5ac66de7abaf1acaae0d7af37a5e1b9bf1ae190a1198b0fdb988ad8"},
{file = "lxml-6.1.2-cp39-cp39-win32.whl", hash = "sha256:446f1f92c137e0cbb97eb7e932e15315c11a7c86974f43f15e68c9707ac6a9f6"},
{file = "lxml-6.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:058c79e172926ef524fb3c7c6beea4b55e15886ac99cb0c139ecaac6b375f1e2"},
{file = "lxml-6.1.2-cp39-cp39-win_arm64.whl", hash = "sha256:5295205fd57510c19a0e46385b516119f3a781d45c2672159bce02949238981a"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:feda2ef68c339987dfb370af3a4b785dbc40f925723fe2365e68e43c2640f85a"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9bdc2db9e04538f917bba0242920764dd740649d8df58700d6d687ead4429429"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a16457e330b7099aa5a8e8bfa5d53a33a1672a819fa656157e9e6dc433ac7a4"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:614d4c5a34556e369b86cfcc8d0cf71cd0759a3444a464a07a9427ab0f5e3a99"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18467b0e9f7f0bc477df69e99829a59ae17fb37d34e5f68399371c7c67be9002"},
{file = "lxml-6.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:351855814dec4ad55ca5f24d0f4b1cdaca7927fe48023a2965351845f3b60cff"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4622c5616683faf63791b349e6c8dad7717412dc5f29f4febe7575f110609a86"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:733dfb492ec3dfef8350a5cc896e90d202c5171e791e1609e77563751d69a15d"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4618b20f43dc98b49569b1dc822176140ea0f2598d672a6989187ba49bcbfec1"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc5e25992f5545709000d840c6cafdbd022781a7a0ed79d58a5633733a4e8"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:662432a6103e671d971e06e75ed146d9ff67f39d2c98c2f26613b6057f54eafc"},
{file = "lxml-6.1.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ba0dfead73be5be9ad0b7fbf9f31ff29c1b1eae858816dfc8d85099d6e4af0d6"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:85690cfc8ed54c4292e36a08bcf984dde7957e653fd6d94f59184244bcc35843"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e92e4419cad18d60b14bf18b82152fbae67f4b1128be7d73b172df275554f5d9"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50ee0c360862f4152db835b456e38614f94b674bca2a47bc8de7171ee6ccbbb8"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:927f3e1d04dc0906265fc0416c13500363e42cd683bbb8d46911c79b73d26800"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f86e23ed610727a7f025ebbff788f22a7956d3f1b24a25bb1d9286fc7b7642b0"},
{file = "lxml-6.1.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2374235206ec83d4827ad219c93c0f7366b93626eab85392c0ee7c8026649376"},
{file = "lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18"},
]
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.3" version = "26.3"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "main"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
@@ -109,7 +346,6 @@ files = [
name = "pillow" name = "pillow"
version = "12.3.0" version = "12.3.0"
description = "Python Imaging Library (fork)" description = "Python Imaging Library (fork)"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -210,11 +446,155 @@ test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"] xmp = ["defusedxml"]
[[package]]
name = "pytils"
version = "0.4.4"
description = "Russian-specific string utils"
optional = false
python-versions = "*"
files = [
{file = "pytils-0.4.4-py3-none-any.whl", hash = "sha256:e54c16465a5fdb65d414e2da8045e6cc6de79889acda6143dcef2e1e86a1a840"},
{file = "pytils-0.4.4.tar.gz", hash = "sha256:9992a96caad57daa211584df1da4fd825f11e836d3ed93011785f1d02ab6f0ca"},
]
[[package]]
name = "regex"
version = "2026.7.19"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.10"
files = [
{file = "regex-2026.7.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b"},
{file = "regex-2026.7.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52"},
{file = "regex-2026.7.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c"},
{file = "regex-2026.7.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6"},
{file = "regex-2026.7.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a"},
{file = "regex-2026.7.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902"},
{file = "regex-2026.7.19-cp310-cp310-win32.whl", hash = "sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e"},
{file = "regex-2026.7.19-cp310-cp310-win_amd64.whl", hash = "sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db"},
{file = "regex-2026.7.19-cp310-cp310-win_arm64.whl", hash = "sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6"},
{file = "regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c"},
{file = "regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae"},
{file = "regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7"},
{file = "regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc"},
{file = "regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78"},
{file = "regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327"},
{file = "regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d"},
{file = "regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965"},
{file = "regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e"},
{file = "regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d"},
{file = "regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd"},
{file = "regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511"},
{file = "regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68"},
{file = "regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035"},
{file = "regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a"},
{file = "regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5"},
{file = "regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312"},
{file = "regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d"},
{file = "regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40"},
{file = "regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38"},
{file = "regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da"},
{file = "regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15"},
{file = "regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc"},
{file = "regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2"},
{file = "regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404"},
{file = "regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e"},
{file = "regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8"},
{file = "regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2"},
{file = "regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda"},
{file = "regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a"},
{file = "regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e"},
{file = "regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0"},
{file = "regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4"},
{file = "regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974"},
{file = "regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d"},
{file = "regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd"},
{file = "regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac"},
{file = "regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b"},
{file = "regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a"},
{file = "regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97"},
{file = "regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4"},
{file = "regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa"},
{file = "regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac"},
{file = "regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459"},
{file = "regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3"},
{file = "regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518"},
{file = "regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276"},
{file = "regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966"},
{file = "regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44"},
{file = "regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78"},
{file = "regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2"},
{file = "regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547"},
{file = "regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5"},
]
[[package]]
name = "soupsieve"
version = "2.9.2"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.10"
files = [
{file = "soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823"},
{file = "soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74"},
]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.5.5"
description = "A non-validating SQL parser." description = "A non-validating SQL parser."
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -226,11 +606,21 @@ files = [
dev = ["build"] dev = ["build"]
doc = ["sphinx"] doc = ["sphinx"]
[[package]]
name = "typing-extensions"
version = "4.16.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
]
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.3" version = "2026.3"
description = "Provider of IANA time zone data" description = "Provider of IANA time zone data"
category = "main"
optional = false optional = false
python-versions = ">=2" python-versions = ">=2"
files = [ files = [
@@ -242,7 +632,6 @@ files = [
name = "whitenoise" name = "whitenoise"
version = "6.12.0" version = "6.12.0"
description = "Radically simplified static file serving for WSGI applications" description = "Radically simplified static file serving for WSGI applications"
category = "main"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
@@ -256,4 +645,4 @@ brotli = ["brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.14" python-versions = "^3.14"
content-hash = "b9c94317c0624ff0156fd9db6187c2a709b4c0ad5cce5424a745a5091e3816ad" content-hash = "61448c711939177b8920ed0842c4f9735273055dd3f61aa877b196dc17922631"
@@ -0,0 +1,77 @@
/**
* CodeMirror 6 - Height Control Patch
*
* Этот патч перехватывает создание CodeMirror редакторов и копирует
* атрибут data-height с textarea на создаваемый wrapper div.
*
* Использование в admin.py:
* textarea widget: attrs={'data-height': '50px', 'data-codemirror-editor': '1'}
*
* Загружается ПОСЛЕ editor.js через Media в Admin класс.
*/
(function () {
'use strict';
/**
* Применяем высоту к уже созданным wrapper'ам
* (CodeMirror уже загрузился и создал обёртки)
*/
function applyHeightsToExistingWrappers() {
// Находим все textarea'ы с data-codemirror-editor
document.querySelectorAll('textarea[data-codemirror-editor]').forEach((textarea) => {
// Соседний div (wrapper, созданный CodeMirror)
const wrapper = textarea.previousElementSibling;
// Проверяем что это действительно wrapper CodeMirror
if (wrapper && wrapper.classList && wrapper.classList.contains('cm6-editor-wrapper')) {
// Копируем data-height на style если указано
if (textarea.dataset.height) {
wrapper.style.height = textarea.dataset.height;
}
// Копируем data-width на style если указано (для ширины)
if (textarea.dataset.width) {
wrapper.style.width = textarea.dataset.width;
wrapper.style.minWidth = textarea.dataset.width;
// Также устанавливаем ширину на сам .cm-editor внутри wrapper
// Используем !important чтобы переопределить flex: 1 1 auto из CSS
const cmEditor = wrapper.querySelector('.cm-editor');
// if (cmEditor) {
// cmEditor.style.cssText = `width: ${textarea.dataset.width} !important; flex: 0 0 auto;`;
// }
}
// Копируем классы с textarea если они есть
// (например cm6-editor-wrapper-xs, cm6-editor-wrapper-sm и т.д.)
if (textarea.className) {
// Добавляем классы из textarea (сохраняя cm6-editor-wrapper)
textarea.className.split(' ').forEach((cls) => {
if (cls && cls !== 'cm6-editor-wrapper') {
wrapper.classList.add(cls);
}
});
}
}
});
}
// Применяем высоты когда DOM готов
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyHeightsToExistingWrappers);
} else {
// DOM уже загружен
applyHeightsToExistingWrappers();
}
// Также применяем для динамически добавленных элементов (если загружают CodeMirror после инициализации)
const observer = new MutationObserver(() => {
applyHeightsToExistingWrappers();
});
observer.observe(document, {
childList: true,
subtree: true,
});
})();
@@ -0,0 +1,78 @@
/* CodeMirror 6 - Стили для правильного отображения */
:root {
color-scheme: light dark;
--brdr: light-dark(#333, #ccc);
--accent-mode-color: light-dark(#222, #eee);
}
.cm6-editor-wrapper {
/* Основной wrapper, созданный CodeMirror */
flex-direction: column;
overflow: hidden;
}
.cm-editor {
/* Сам редактор внутри wrapper - растягиваем на всю доступную высоту */
flex: 1 1 auto;
font-family: monospace;
line-height: 1;
border: 1px solid var(--brdr);;
}
/****************************
Утилитарные классы для управления шириной.
Мы ищем редактор .cm-editor ВНУТРИ элемента с нашим классом.
****************************/
.codemirror-width-s > .cm-editor {
/* Маленький (для чисел, коротких ID) */
max-width: 8em !important;
}
.codemirror-width-m > .cm-editor {
/* Средний (для URL, коротких строк) */
max-width: calc(50% - 13em) !important; /* Вычитание 13em подобрано. Это ширина бокового меню слева */
}
.codemirror-width-l > .cm-editor {
/* Большой (для текста, JSON, HTML) */
max-width: calc(75% - 13em) !important;
}
.codemirror-width-xl > .cm-editor {
/* Во всю ширину контейнера */
max-width: calc(100% - 13em) !important;
}
/* Ширина редактора зависит от видимости бокового меню Django Admin.
На экранах < 767px меню прячется, поэтому вычитание 13em неактуально.
Подобрано значение 3ex. */
@media (max-width: 767px) {
.codemirror-width-m > .cm-editor,
.codemirror-width-l > .cm-editor,
.codemirror-width-xl > .cm-editor {
max-width: calc(100% - 3ex) !important;
}
}
/* --- Новый класс для минимальной высоты --- */
.codemirror-min-height-2 .cm-editor {
min-height: 3.5em; /* 5 строк */
}
.codemirror-min-height-5 .cm-editor {
min-height: 8em; /* 5 строк */
}
.codemirror-min-height-10 .cm-editor {
min-height: 16em; /* 10 строк */
}
.codemirror-no-lines .cm-gutters {
/* Скрываем нумерацию строк */
display: none !important;
}
/* Скрыть оригинальный textarea */
textarea[data-codemirror-editor] {
display: none !important;
}
File diff suppressed because one or more lines are too long
+98
View File
@@ -0,0 +1,98 @@
// analytics.js — Аналитика и счетчики посещений для hypn0.xyz
// Версия: 1.0 | Дата: 2026-05-15
// Содержит: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru
(function() {
'use strict';
// ============================================================================
// Google Analytics 4 (GA4)
// ID: GT-XXXXXXXX
// ============================================================================
(function() {
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=GT-XXXXXXXX';
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', 'GT-XXXXXXXX');
})();
// ============================================================================
// Yandex.Metrika (Яндекс.Метрика)
// ID: 12345678
// ============================================================================
(function() {
window.ym = window.ym || function(){
(window.ym.a = window.ym.a || []).push(arguments);
};
window.ym.l = 1 * new Date();
// Загружаем скрипт Метрики
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'https://mc.yandex.ru/metrika/tag.js';
document.head.appendChild(script);
// Инициализируем Метрику
window.ym(12345678, 'init', {
trackHash: true,
clickmap: true,
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true
});
})();
// ============================================================================
// Top.Mail.Ru counter (Рейтинг@Mail.ru)
// ID: 1234567
// ============================================================================
(function() {
var _tmr = window._tmr || (window._tmr = []);
_tmr.push({
id: "1234567",
type: "pageView",
start: (new Date()).getTime()
});
(function(d, w, id) {
if (d.getElementById(id)) return;
var ts = d.createElement("script");
ts.type = "text/javascript";
ts.async = true;
ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function() {
var s = d.getElementsByTagName("script")[0];
s.parentNode.insertBefore(ts, s);
};
if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false);
} else {
f();
}
})(document, window, "tmr-code");
// Добавляем изображение для noscript
if (!window.noScriptAdded) {
window.noScriptAdded = true;
var noscriptDiv = document.createElement('div');
noscriptDiv.style.display = 'none';
noscriptDiv.innerHTML = '<img src="https://top-fwz1.mail.ru/counter?id=1234567;js=na" style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" />';
document.body.appendChild(noscriptDiv);
}
})();
})();
// Экспортируем gtag в глобальный контекст для возможности использования в коде
// window.gtag доступна через глобальную переменную
+3
View File
@@ -15,8 +15,11 @@ python = "^3.14"
pillow = "^12.3.0" pillow = "^12.3.0"
django = "^6.0.8" django = "^6.0.8"
django-environ = "^0.14.0" django-environ = "^0.14.0"
hashids = "^1.3.1"
gunicorn = "^26.0.0" gunicorn = "^26.0.0"
whitenoise = "^6.12.0" whitenoise = "^6.12.0"
etpgrf = "^0.1.6.post1"
pytils = "^0.4.4"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
django-debug-toolbar = "^7.0" django-debug-toolbar = "^7.0"
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Сборка CodeMirror 6 для админки Django
# Запуск из корня проекта: bash ./scripts/build-codemirror.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CODEMIRROR_DIR="$PROJECT_ROOT/frontend-assembly/codemirror"
OUTPUT_DIR="$PROJECT_ROOT/public/static/codemirror"
log() {
printf '[codemirror6] %s\n' "$*"
}
fail() {
printf '[codemirror6] %s\n' "$*" >&2
exit 1
}
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$CODEMIRROR_DIR/package.json" ]]; then
fail "Не найден package.json: $CODEMIRROR_DIR/package.json"
fi
if [[ ! -f "$CODEMIRROR_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $CODEMIRROR_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
# Создаём src/editor.js для CodeMirror
mkdir -p "$CODEMIRROR_DIR/src"
log "Создаю src/editor.js в $CODEMIRROR_DIR"
cat > "$CODEMIRROR_DIR/src/editor.js" <<'EOF'
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, lineNumbers, placeholder } from '@codemirror/view';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { solarizedDark, solarizedLight } from '@uiw/codemirror-theme-solarized';
const themeCompartment = new Compartment();
const processedForms = new Set();
function isDarkTheme() {
const rootTheme = document.documentElement.dataset.theme;
if (rootTheme === 'dark') return true;
if (rootTheme === 'light') return false;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function reconfigureTheme(view) {
view.dispatch({
effects: themeCompartment.reconfigure(isDarkTheme() ? solarizedDark : solarizedLight),
});
}
function initCodeMirrorEditors() {
document.querySelectorAll('textarea[data-codemirror-editor]').forEach((textarea) => {
const language = textarea.dataset.language || 'text';
const inputMode = textarea.dataset.inputMode || 'default';
let initialDoc = textarea.value ?? '';
const wrapper = document.createElement('div');
wrapper.className = 'cm6-editor-wrapper';
textarea.insertAdjacentElement('beforebegin', wrapper);
if (language === 'json') {
try {
const parsed = JSON.parse(initialDoc);
initialDoc = JSON.stringify(parsed, null, 2);
} catch (e) {
console.warn("CodeMirror: Initial content is not valid JSON, displaying as is.", e);
}
}
const syncTextarea = EditorView.updateListener.of((update) => {
if (update.docChanged) {
textarea.value = update.state.doc.toString();
}
});
// --- Собираем расширения в правильном порядке ---
const extensions = [
syntaxHighlighting(defaultHighlightStyle),
syncTextarea,
themeCompartment.of(isDarkTheme() ? solarizedDark : solarizedLight),
];
// Язык
if (language === 'javascript') extensions.push(javascript());
else if (language === 'css') extensions.push(css());
else if (language === 'json') extensions.push(json());
else if (language === 'html') extensions.push(html());
// Режим ввода
if (inputMode === 'url') {
extensions.push(placeholder('https://example.com/path'));
// Для URL не добавляем lineWrapping и lineNumbers
} else {
extensions.push(EditorView.lineWrapping);
if (!textarea.classList.contains('codemirror-no-lines')) {
extensions.push(lineNumbers());
}
}
const state = EditorState.create({
doc: initialDoc,
extensions,
});
const view = new EditorView({
state,
parent: wrapper,
});
textarea.cmView = view;
reconfigureTheme(view);
const observer = new MutationObserver(() => reconfigureTheme(view));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
colorScheme.addEventListener('change', () => reconfigureTheme(view));
const form = textarea.closest('form');
if (form && !processedForms.has(form)) {
form.addEventListener('submit', () => {
form.querySelectorAll('textarea[data-language="json"]').forEach(jsonTextarea => {
if (jsonTextarea.cmView) {
const prettyJson = jsonTextarea.cmView.state.doc.toString();
try {
const parsed = JSON.parse(prettyJson);
const minifiedJson = JSON.stringify(parsed);
jsonTextarea.value = minifiedJson;
} catch (e) {
console.warn("CodeMirror: Could not minify invalid JSON before submit.", e);
}
}
});
});
processedForms.add(form);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeMirrorEditors, { once: true });
} else {
initCodeMirrorEditors();
}
EOF
log "СОБИРАЮ CodeMirror 6"
cd "$CODEMIRROR_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю CodeMirror 6'
export CM6_OUTPUT_DIR="$OUTPUT_DIR"
npm run build
log 'Удаляю временные файлы (src/ и node_modules/)'
rm -rf src node_modules
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/editor.js"