diff --git a/MANAGEMENT_RUNBOOK.md b/MANAGEMENT_RUNBOOK.md index 8c807a6..a758a58 100644 --- a/MANAGEMENT_RUNBOOK.md +++ b/MANAGEMENT_RUNBOOK.md @@ -10,7 +10,8 @@ ## Каталог команд 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/`. +## 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 символов (исключая теги ``) | +| `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 веб-сервера Важно: diff --git a/oknardia/web/management/commands/populate_seo_fields.py b/oknardia/web/management/commands/populate_seo_fields.py new file mode 100644 index 0000000..605def7 --- /dev/null +++ b/oknardia/web/management/commands/populate_seo_fields.py @@ -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"", "", 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']} ошибок. Проверьте логи.")) +