add: Custom Management Command для автозаполнения SEO-атрибутов блога
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
## Каталог команд
|
## Каталог команд
|
||||||
|
|
||||||
1. `generate_sitemaps` — оффлайн генерация sitemap-файлов.
|
1. `generate_sitemaps` — оффлайн генерация sitemap-файлов.
|
||||||
2ю `regenerate_seria_prerender` — оффлайн пересборка pre-render шаблонов для `catalog_seria_info`.
|
2. `regenerate_seria_prerender` — оффлайн пересборка pre-render шаблонов для `catalog_seria_info`.
|
||||||
|
3. `populate_seo_fields` — автозаполнение SEO-полей блога из существующих данных.
|
||||||
|
|
||||||
## Общие правила запуска
|
## Общие правила запуска
|
||||||
|
|
||||||
@@ -112,6 +113,118 @@ poetry run python oknardia/manage.py regenerate_seria_prerender --seria-id 843 -
|
|||||||
- после массового обновления данных серий/окон/квартир;
|
- после массового обновления данных серий/окон/квартир;
|
||||||
- после очистки `seria_info/prepared/`.
|
- после очистки `seria_info/prepared/`.
|
||||||
|
|
||||||
|
## 3) Команда `populate_seo_fields`
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
- автозаполнить SEO-поля (`sSlug`, `sMetaDescription`, `sMetaKeywords`) для всех существующих записей блога.
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- при первом развертывании новой версии с автогенерацией SEO-полей;
|
||||||
|
- при восстановлении из бэкапа где SEO-поля пусты;
|
||||||
|
- при изменении логики автогенерации (с флагом `--force`).
|
||||||
|
|
||||||
|
### Базовый запуск
|
||||||
|
|
||||||
|
Заполнить только пустые SEO-поля (стандартный вариант):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/e-serg/PRJ/2022-oknardia
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры запуска
|
||||||
|
|
||||||
|
**`--dry-run`** — только показать что будет сделано (без сохранения в БД):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--force`** — переполнить ВСЕ SEO-поля, даже уже заполненные:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--clean`** — очистить все SEO-поля перед заполнением (для переделки):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
**Комбинация флагов** — сухой прогон переполнения всех полей:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields --dry-run --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что заполняется
|
||||||
|
|
||||||
|
| Поле | Источник | Результат |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| `sSlug` | `sPostHeader` | URL-безопасный слаг (max 200 символов) |
|
||||||
|
| `sMetaDescription` | `sPostContent` | Первые 160 символов (исключая теги `<cut>`) |
|
||||||
|
| `sMetaKeywords` | `sPostHeader` | Заголовок + префикс "oknardia, окнардия, блог, публикация" (max 256 символов) |
|
||||||
|
|
||||||
|
Пример результата:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sPostHeader = "Профиль Brusbox Super Aero"
|
||||||
|
↓
|
||||||
|
sSlug = "profil-brusbox-super-aero"
|
||||||
|
sMetaDescription = "brusbox-super-aero-pyatikamernaya-profil-sistema..."
|
||||||
|
sMetaKeywords = "oknardia, окнардия, блог, публикация, Профиль Brusbox Super Aero"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Когда запускать
|
||||||
|
|
||||||
|
- **После первого развертывания** — заполнить SEO-поля всех 29 существующих постов одной командой.
|
||||||
|
- **Один раз** — команда идемпотентна (при повторном запуске не будет ничего менять, т.к. пустые поля остатся).
|
||||||
|
- **При изменении логики** — использовать `--clean --force` для полной переделки всех SEO-полей.
|
||||||
|
|
||||||
|
### Пример полного сценария
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/e-serg/PRJ/2022-oknardia
|
||||||
|
|
||||||
|
# Шаг 1: Проверить что будет заполнено
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields --dry-run
|
||||||
|
|
||||||
|
# Шаг 2: Если результат устраивает — запустить реально
|
||||||
|
poetry run python oknardia/manage.py populate_seo_fields
|
||||||
|
|
||||||
|
# Шаг 3: Проверить что заполнилось
|
||||||
|
poetry run python oknardia/manage.py shell -c "
|
||||||
|
from oknardia.models import BlogPosts
|
||||||
|
posts = BlogPosts.objects.all()
|
||||||
|
print(f'Пусто sSlug: {posts.filter(sSlug=\"\").count()}')
|
||||||
|
print(f'Пусто sMetaDescription: {posts.filter(sMetaDescription=\"\").count()}')
|
||||||
|
print(f'Пусто sMetaKeywords: {posts.filter(sMetaKeywords=\"\").count()}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Возвращаемая информация
|
||||||
|
|
||||||
|
```
|
||||||
|
======================================================================
|
||||||
|
ИТОГОВЫЙ ОТЧЕТ
|
||||||
|
======================================================================
|
||||||
|
|
||||||
|
✓ sSlug заполнено: 28 раз
|
||||||
|
✓ sMetaDescription заполнено: 28 раз
|
||||||
|
✓ sMetaKeywords заполнено: 28 раз
|
||||||
|
✓ Записей обновлено в БД: 28
|
||||||
|
✗ Ошибок при обработке: 0
|
||||||
|
|
||||||
|
✅ Обновлено 28 записей успешно!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Откат и безопасность
|
||||||
|
|
||||||
|
- ✅ **Безопасна для повторного запуска** — пустые поля не изменяются при повторной работе.
|
||||||
|
- ✅ **Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';`
|
||||||
|
- ✅ **Всегда используй `--dry-run`** перед первым запуском для проверки.
|
||||||
|
|
||||||
## Оркестрация и reload веб-сервера
|
## Оркестрация и reload веб-сервера
|
||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
|
|||||||
189
oknardia/web/management/commands/populate_seo_fields.py
Normal file
189
oknardia/web/management/commands/populate_seo_fields.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Management-команда для автозаполнения SEO-полей (sSlug, sMetaDescription, sMetaKeywords)
|
||||||
|
у всех существующих записей блога.
|
||||||
|
|
||||||
|
Эта команда используется один раз при миграции на новую версию,
|
||||||
|
которая добавила автогенерацию SEO-полей в save() метод BlogPosts.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python manage.py populate_seo_fields
|
||||||
|
python manage.py populate_seo_fields --dry-run # только показать что будет сделано
|
||||||
|
python manage.py populate_seo_fields --clean # очистить все SEO-поля перед заполнением
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from oknardia.models import BlogPosts
|
||||||
|
from web.add_func import sanitize_slug
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Автозаполняет SEO-поля (sSlug, sMetaDescription, sMetaKeywords) для всех записей блога"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Только показать, что будет сделано, без сохранения в БД",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--clean",
|
||||||
|
action="store_true",
|
||||||
|
help="Очистить все SEO-поля перед заполнением (для переделки)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Перезаполнить SEO-поля (даже если они уже содержат значения)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options.get("dry_run", False)
|
||||||
|
clean = options.get("clean", False)
|
||||||
|
force = options.get("force", False)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.HTTP_INFO("=" * 70))
|
||||||
|
self.stdout.write(self.style.HTTP_INFO("АВТОЗАПОЛНЕНИЕ SEO-ПОЛЕЙ БЛОГА"))
|
||||||
|
self.stdout.write(self.style.HTTP_INFO("=" * 70))
|
||||||
|
|
||||||
|
# Получаем все посты
|
||||||
|
posts_qs = BlogPosts.objects.all()
|
||||||
|
total_posts = posts_qs.count()
|
||||||
|
self.stdout.write(f"\n✓ Всего записей в блоге: {total_posts}")
|
||||||
|
|
||||||
|
if total_posts == 0:
|
||||||
|
self.stdout.write(self.style.WARNING("⚠ Записей не найдено. Нечего заполнять."))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Опционально очищаем
|
||||||
|
if clean and not dry_run:
|
||||||
|
self.stdout.write("\n🧹 Очищаем существующие SEO-поля...")
|
||||||
|
posts_qs.update(sSlug="", sMetaDescription="", sMetaKeywords="")
|
||||||
|
self.stdout.write(self.style.SUCCESS(" ✓ SEO-поля очищены"))
|
||||||
|
|
||||||
|
# Фильтруем посты по пустым полям
|
||||||
|
if force:
|
||||||
|
filtered_posts = posts_qs
|
||||||
|
self.stdout.write(f"\n✓ Режим FORCE: будут переполнены ВСЕ {total_posts} записей")
|
||||||
|
else:
|
||||||
|
filtered_posts = posts_qs.filter(
|
||||||
|
sSlug="", # noqa: F841
|
||||||
|
) | posts_qs.filter(sMetaDescription="") | posts_qs.filter(sMetaKeywords="")
|
||||||
|
filtered_posts = posts_qs.filter(
|
||||||
|
sSlug="",
|
||||||
|
) | posts_qs.filter(sMetaDescription="") | posts_qs.filter(sMetaKeywords="")
|
||||||
|
|
||||||
|
posts_to_update = filtered_posts.count()
|
||||||
|
self.stdout.write(f"✓ Записей для обновления: {posts_to_update}")
|
||||||
|
|
||||||
|
if posts_to_update == 0:
|
||||||
|
self.stdout.write(self.style.SUCCESS("\n✅ Все записи уже имеют заполненные SEO-поля!"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Статистика по типам полей
|
||||||
|
stats = {
|
||||||
|
"sSlug": 0,
|
||||||
|
"sMetaDescription": 0,
|
||||||
|
"sMetaKeywords": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"errors": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обновляем каждый пост
|
||||||
|
self.stdout.write("\n🔄 Обробатываем посты...\n")
|
||||||
|
|
||||||
|
for idx, post in enumerate(filtered_posts, 1):
|
||||||
|
try:
|
||||||
|
old_values = {
|
||||||
|
"sSlug": post.sSlug,
|
||||||
|
"sMetaDescription": post.sMetaDescription,
|
||||||
|
"sMetaKeywords": post.sMetaKeywords,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Генерируем sSlug
|
||||||
|
if not post.sSlug and post.sPostHeader:
|
||||||
|
post.sSlug = sanitize_slug(post.sPostHeader, max_length=200)
|
||||||
|
stats["sSlug"] += 1
|
||||||
|
|
||||||
|
# Генерируем sMetaDescription
|
||||||
|
if not post.sMetaDescription and post.sPostContent:
|
||||||
|
content_clean = re.sub(r"<cut[\s\S]*?>", "", post.sPostContent, flags=re.IGNORECASE)
|
||||||
|
tizer = sanitize_slug(content_clean, max_length=200)
|
||||||
|
|
||||||
|
if len(tizer) > 160:
|
||||||
|
# Обрезаем по последнему пробелу перед 160-й позицией
|
||||||
|
tizer = tizer[:160].rsplit(" ", 1)[0] + "..." if " " in tizer[:160] else tizer[:160]
|
||||||
|
|
||||||
|
post.sMetaDescription = tizer
|
||||||
|
stats["sMetaDescription"] += 1
|
||||||
|
|
||||||
|
# Генерируем sMetaKeywords
|
||||||
|
if not post.sMetaKeywords and post.sPostHeader:
|
||||||
|
header_clean = re.sub(r"<[^>]+>", "", post.sPostHeader).strip()
|
||||||
|
fixed_keywords = "oknardia, окнардия, блог, публикация"
|
||||||
|
post.sMetaKeywords = f"{fixed_keywords}, {header_clean}"[:256]
|
||||||
|
stats["sMetaKeywords"] += 1
|
||||||
|
|
||||||
|
new_values = {
|
||||||
|
"sSlug": post.sSlug,
|
||||||
|
"sMetaDescription": post.sMetaDescription,
|
||||||
|
"sMetaKeywords": post.sMetaKeywords,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Логируем изменения
|
||||||
|
changes = []
|
||||||
|
if old_values["sSlug"] != new_values["sSlug"]:
|
||||||
|
changes.append(f"sSlug: '{old_values['sSlug'][:30]}...' → '{new_values['sSlug'][:30]}...'")
|
||||||
|
if old_values["sMetaDescription"] != new_values["sMetaDescription"]:
|
||||||
|
desc_old = (old_values["sMetaDescription"] or "").strip() or "(пусто)"
|
||||||
|
desc_new = new_values.get("sMetaDescription", "").strip() or "(пусто)"
|
||||||
|
changes.append(f"sMetaDescription: '{desc_old[:40]}...' → '{desc_new[:40]}...'")
|
||||||
|
if old_values["sMetaKeywords"] != new_values["sMetaKeywords"]:
|
||||||
|
kw_old = (old_values["sMetaKeywords"] or "").strip() or "(пусто)"
|
||||||
|
kw_new = new_values.get("sMetaKeywords", "").strip() or "(пусто)"
|
||||||
|
changes.append(f"sMetaKeywords: '{kw_old[:40]}...' → '{kw_new[:40]}...'")
|
||||||
|
|
||||||
|
# Вывод текущего прогресса
|
||||||
|
self.stdout.write(
|
||||||
|
f" [{idx:3d}/{posts_to_update}] Post #{post.id}: {post.sPostHeader[:50]}..."
|
||||||
|
)
|
||||||
|
if changes:
|
||||||
|
for change in changes:
|
||||||
|
self.stdout.write(f" → {change}")
|
||||||
|
self.stdout.write("")
|
||||||
|
|
||||||
|
# Сохраняем
|
||||||
|
if not dry_run:
|
||||||
|
post.save(update_fields=["sSlug", "sMetaDescription", "sMetaKeywords"])
|
||||||
|
stats["updated"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f" ❌ Ошибка при обработке поста #{post.id}: {str(e)}"))
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
# Итоговой отчет
|
||||||
|
self.stdout.write("\n" + "=" * 70)
|
||||||
|
self.stdout.write(self.style.SUCCESS("ИТОГОВЫЙ ОТЧЕТ"))
|
||||||
|
self.stdout.write("=" * 70)
|
||||||
|
|
||||||
|
self.stdout.write(f"\n✓ sSlug заполнено: {stats['sSlug']} раз")
|
||||||
|
self.stdout.write(f"✓ sMetaDescription заполнено: {stats['sMetaDescription']} раз")
|
||||||
|
self.stdout.write(f"✓ sMetaKeywords заполнено: {stats['sMetaKeywords']} раз")
|
||||||
|
self.stdout.write(f"✓ Записей обновлено в БД: {stats['updated']}")
|
||||||
|
self.stdout.write(f"✗ Ошибок при обработке: {stats['errors']}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("\n⚠️ Режим DRY-RUN: изменения НЕ были сохранены в БД"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\n✅ Обновлено {stats['updated']} записей успешно!"))
|
||||||
|
|
||||||
|
if stats["errors"] > 0:
|
||||||
|
self.stdout.write(self.style.ERROR(f"\n❌ Было {stats['errors']} ошибок. Проверьте логи."))
|
||||||
|
|
||||||
Reference in New Issue
Block a user