Files
2022_oknardia/oknardia/web/management/commands/generate_sitemaps.py

613 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from __future__ import annotations
"""
Команда генерации sitemap-файлов проекта.
Почему реализовано именно так:
- Генерация выполняется оффлайн (через management command), чтобы не нагружать веб-запросы.
- На выходе всегда создаются статические XML-файлы, которые потом отдает Nginx/прокси.
- URL-источники описаны через Django Sitemap API (классы Sitemap), но рендер XML
контролируем самостоятельно для точного управления лимитами размера/количества.
"""
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime
from itertools import combinations
from pathlib import Path
from typing import Iterable
from xml.etree import ElementTree as ET
from django.conf import settings
from django.contrib.sitemaps import Sitemap
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Count, F, Max, Min
from django.utils import timezone
from oknardia.models import (
Apartment_Type,
BlogPosts,
Building_Info,
MerchantBrand,
PriceOffer,
PVCprofiles,
Seria_Info,
SetKit,
Win_MountDim,
)
import pytils
# Namespace схемы sitemap.xml по стандарту sitemaps.org.
SITEMAP_XMLNS = "http://www.sitemaps.org/schemas/sitemap/0.9"
@dataclass(slots=True)
class SitemapBuildResult:
"""Итог генерации sitemap для удобного вывода в CLI и web-обертках."""
# Общее число URL, записанных во все sitemap-файлы.
total_urls: int
# Количество созданных файлов (1 = только sitemap.xml, >1 = sitemapindex + sitemapNNNN.xml).
files_count: int
# Время выполнения генерации в секундах.
elapsed_seconds: float
# Физический каталог, куда записаны файлы.
output_dir: Path
def _as_sitemap_date(value: date | datetime | None) -> str:
"""
Приводит дату/время к формату `YYYY-MM-DD`.
Для sitemap нам не нужна точность до секунд: поисковикам достаточно даты.
Если значение не передано, используем текущую локальную дату.
"""
if value is None:
return timezone.localdate().isoformat()
if isinstance(value, datetime):
return value.date().isoformat()
return value.isoformat()
class SingleWindowSitemap(Sitemap):
"""Источник URL для страниц цен одного проёма (/tsena-odnogo-okna/...)."""
changefreq = "weekly"
priority = 0.5
def __init__(self, lastmod_value: datetime):
# Один timestamp на весь прогон: так проще сравнивать выпуски sitemap.
self.lastmod_value = lastmod_value
def items(self):
# Берем только те монтажные размеры, где есть реальные офферы.
# Сортировка по числу офферов повторяет историческую логику из raw SQL.
mount_ids = (
PriceOffer.objects.values("kOffer2MountDim_id")
.annotate(num_offer=Count("id"))
.order_by("num_offer", "kOffer2MountDim_id")
.values_list("kOffer2MountDim_id", flat=True)
)
# Возвращаем сами объекты Win_MountDim, чтобы location() строил URL без доп. запросов.
return Win_MountDim.objects.filter(id__in=mount_ids).only("id", "iWinWidth", "iWinHight")
def location(self, item: Win_MountDim) -> str:
# В БД размеры в см (Decimal с 1 знаком). В URL исторически используются мм,
# поэтому умножаем на 10 и приводим к int.
width_mm = int(float(item.iWinWidth) * 10)
height_mm = int(float(item.iWinHight) * 10)
return f"/tsena-odnogo-okna/{width_mm}x{height_mm}mm/tip{item.id}"
def lastmod(self, item: Win_MountDim) -> datetime:
return self.lastmod_value
class BuildingOffersSitemap(Sitemap):
"""Источник URL для страниц ценовой выдачи по адресам (/{build_id}/{apart_id}/{slug})."""
changefreq = "weekly"
priority = 0.5
def __init__(self, lastmod_value: datetime):
self.lastmod_value = lastmod_value
def items(self):
# Получаем здания только с валидной привязкой к корневой серии.
buildings = list(
Building_Info.objects.filter(kSeria_Link__kRoot__isnull=False)
.select_related("kSeria_Link__kRoot")
.only("id", "sAddress", "kSeria_Link__kRoot")
.order_by("id")
)
# Для каждой корневой серии нужен список типов квартир, чтобы собрать итоговые URL.
root_ids = {
building.kSeria_Link.kRoot_id
for building in buildings
if building.kSeria_Link_id and building.kSeria_Link.kRoot_id
}
apartments_by_root: dict[int, list[int]] = defaultdict(list)
for root_id, apart_id in Apartment_Type.objects.filter(kSeria_id__in=root_ids).values_list("kSeria_id", "id"):
apartments_by_root[root_id].append(apart_id)
# Генерируем декартово произведение: здание x квартиры его корневой серии.
for building in buildings:
root_id = building.kSeria_Link.kRoot_id if building.kSeria_Link_id else None
if not root_id:
continue
for apart_id in apartments_by_root.get(root_id, []):
yield (building.id, apart_id, pytils.translit.slugify(building.sAddress))
def location(self, item: tuple[int, int, str]) -> str:
build_id, apart_id, slug = item
return f"/{build_id}/{apart_id}/{slug}"
def lastmod(self, item: tuple[int, int, str]) -> datetime:
return self.lastmod_value
class CompareOffersSitemap(Sitemap):
"""Источник URL для страниц сравнения наборов (/compare_offers/1,2,3...)."""
# Для compare-страниц изменения редки, поэтому просим роботов не дергать их часто.
changefreq = "monthly"
priority = 0.35
def __init__(self, lastmod_value: datetime, min_depth: int = 2, max_depth: int = 4):
self.lastmod_value = lastmod_value
# Жестко ограничиваем глубину до 2..4, чтобы не получить комбинаторный взрыв.
self.min_depth = max(2, min_depth)
self.max_depth = min(4, max_depth)
def items(self):
# Берем только активные наборы и строим combinations без повторов/перестановок.
set_ids = list(SetKit.objects.filter(sSetActive=True).order_by("id").values_list("id", flat=True))
for depth in range(self.min_depth, self.max_depth + 1):
for combo in combinations(set_ids, depth):
# Формат URL-параметра должен остаться историческим: "1,2,3".
yield ",".join(str(item) for item in combo)
def location(self, item: str) -> str:
return f"/compare_offers/{item}"
def lastmod(self, item: str) -> datetime:
return self.lastmod_value
class StaticPagesSitemap(Sitemap):
"""Набор важных статических/обзорных страниц, которые не требуют отдельной модели."""
def __init__(self, items: list[dict]):
self._items = items
def items(self):
return self._items
def location(self, item: dict) -> str:
return item["loc"]
def lastmod(self, item: dict) -> date | datetime | None:
return item.get("lastmod")
def changefreq(self, item: dict) -> str:
return item.get("changefreq", "weekly")
def priority(self, item: dict) -> float:
return float(item.get("priority", 0.5))
class BlogListSitemap(Sitemap):
"""Страницы пагинации блога: /blog/P0, /blog/P1, ..."""
changefreq = "weekly"
priority = 0.82
def __init__(self, lastmod_value: date | datetime | None):
self.lastmod_value = lastmod_value
def items(self):
posts_qs = BlogPosts.objects.filter(
dPostDataBegin__lte=timezone.now(),
bPublished=True,
bArchive=False,
)
total_posts = posts_qs.count()
if total_posts == 0:
return []
pages_total = (total_posts - 1) // settings.NUM_BLOG_TIZER_IN_PAGE + 1
return list(range(pages_total))
def location(self, item: int) -> str:
return f"/blog/P{item}"
def lastmod(self, item: int) -> date | datetime | None:
return self.lastmod_value
class BlogPostSitemap(Sitemap):
"""Публичные посты блога в каноническом URL без page_back."""
changefreq = "monthly"
priority = 0.90
def items(self):
return BlogPosts.objects.filter(
dPostDataBegin__lte=timezone.now(),
bPublished=True,
bArchive=False,
).only("id", "sPostHeader", "dPostDataModify")
def location(self, item: BlogPosts) -> str:
return f"/blogpost/{item.id}/{pytils.translit.slugify(item.sPostHeader).lower()}"
def lastmod(self, item: BlogPosts) -> date | datetime | None:
return item.dPostDataModify
class ProfileManufactureSitemap(Sitemap):
"""Страницы производителей профилей: /catalog/profile/{id}-{manufacturer}."""
changefreq = "monthly"
priority = 0.92
def items(self):
return list(
PVCprofiles.objects.values("sProfileManufacturer")
.annotate(first_id=Min("id"), lastmod=Max("dProfileModify"))
.order_by("sProfileManufacturer")
)
def location(self, item: dict) -> str:
manufacturer_slug = pytils.translit.slugify(item["sProfileManufacturer"]).lower()
return f"/catalog/profile/{item['first_id']}-{manufacturer_slug}"
def lastmod(self, item: dict) -> date | datetime | None:
return item.get("lastmod")
class ProfileModelSitemap(Sitemap):
"""Карточки конкретных профильных систем."""
changefreq = "monthly"
priority = 0.94
def items(self):
return PVCprofiles.objects.only("id", "sProfileManufacturer", "sProfileName", "dProfileModify")
def location(self, item: PVCprofiles) -> str:
manufacturer_slug = pytils.translit.slugify(item.sProfileManufacturer).lower()
model_slug = pytils.translit.slugify(item.sProfileName).lower()
# Исторически канонический URL использует id модели и в сегменте manufacturer_id, и в segment model_id.
return f"/catalog/profile/{item.id}-{manufacturer_slug}/{item.id}-{model_slug}"
def lastmod(self, item: PVCprofiles) -> date | datetime | None:
return item.dProfileModify
class SeriaDetailSitemap(Sitemap):
"""Страницы типовых серий домов — это одни из самых важных SEO-страниц проекта."""
changefreq = "monthly"
priority = 0.97
def items(self):
return Seria_Info.objects.filter(id__isnull=False, kRoot_id__isnull=False, id=F("kRoot_id")).only(
"id", "sName", "dSeriaInfoModify"
)
def location(self, item: Seria_Info) -> str:
return f"/catalog/seria/{pytils.translit.slugify(item.sName).lower()}/all{item.id}"
def lastmod(self, item: Seria_Info) -> date | datetime | None:
return item.dSeriaInfoModify
class CompanyDetailSitemap(Sitemap):
"""Страницы брендов/производителей оконных компаний."""
changefreq = "monthly"
priority = 0.91
def items(self):
return list(
MerchantBrand.objects.annotate(
last_offer_modify=Max("merchantoffice__ouruser__setkit__priceoffer__dOfferModify"),
last_office_modify=Max("merchantoffice__dOfficeDataModify"),
).only("id", "sMerchantName")
)
def location(self, item: MerchantBrand) -> str:
return f"/catalog/company/{item.id}-{pytils.translit.slugify(item.sMerchantName).lower()}"
def lastmod(self, item: MerchantBrand) -> date | datetime | None:
return getattr(item, "last_offer_modify", None) or getattr(item, "last_office_modify", None)
class SitemapXmlWriter:
"""
Низкоуровневый писатель XML.
Делит URL на несколько файлов по двум условиям:
- число URL в файле;
- приблизительный размер файла в байтах.
Если chunk-файлов больше одного, создается sitemapindex (sitemap.xml),
который перечисляет sitemap0000.xml, sitemap0001.xml и т.д.
"""
def __init__(
self,
output_dir: Path,
public_base_url: str,
max_items: int,
max_file_size: int,
max_files_qty: int,
):
self.output_dir = output_dir
# Публичный URL-префикс для ссылок в sitemapindex.
self.public_base_url = public_base_url.rstrip("/")
self.max_items = max_items
self.max_file_size = max_file_size
self.max_files_qty = max_files_qty
self.total_urls = 0
self.chunk_files: list[str] = []
self.current_urls: list[ET.Element] = []
# Небольшой стартовый запас размера на корневые XML-теги.
self.current_size = 128
def cleanup_old(self) -> None:
# Перед генерацией удаляем старые sitemap*.xml, чтобы не оставить устаревшие куски.
self.output_dir.mkdir(parents=True, exist_ok=True)
for file_path in self.output_dir.glob("sitemap*.xml"):
file_path.unlink(missing_ok=True)
def add_url(self, loc: str, lastmod: datetime, changefreq: str, priority: float) -> None:
# Собираем XML-элемент URL и оцениваем его вклад в размер файла.
url_element = self._build_url_element(loc=loc, lastmod=lastmod, changefreq=changefreq, priority=priority)
url_size = len(ET.tostring(url_element, encoding="utf-8"))
need_flush = False
if self.current_urls:
# Лимиты применяем только если файл уже что-то содержит:
# так мы гарантируем, что хотя бы один URL всегда будет записан.
if len(self.current_urls) >= self.max_items:
need_flush = True
elif self.current_size + url_size > self.max_file_size:
need_flush = True
if need_flush:
self._flush_chunk()
self.current_urls.append(url_element)
self.current_size += url_size
self.total_urls += 1
def finalize(self, generated_at: datetime) -> int:
# Если уже были chunk-файлы, значит итог должен быть в формате sitemapindex.
if self.chunk_files:
self._flush_chunk()
self._write_sitemap_index(generated_at)
return len(self.chunk_files)
# Иначе пишем единый sitemap.xml с URLSet.
self._write_single_sitemap()
return 1
def _flush_chunk(self) -> None:
if not self.current_urls:
return
chunk_idx = len(self.chunk_files)
if chunk_idx >= self.max_files_qty:
raise RuntimeError(
"Превышено максимальное количество sitemap-файлов. "
f"Текущий лимит: {self.max_files_qty}."
)
file_name = f"sitemap{chunk_idx:04d}.xml"
self._write_urlset(self.output_dir / file_name, self.current_urls)
self.chunk_files.append(file_name)
# Сбрасываем буфер для следующего chunk-файла.
self.current_urls = []
self.current_size = 128
def _write_single_sitemap(self) -> None:
self._write_urlset(self.output_dir / "sitemap.xml", self.current_urls)
self.current_urls = []
self.current_size = 128
def _write_sitemap_index(self, generated_at: datetime) -> None:
root = ET.Element("sitemapindex", xmlns=SITEMAP_XMLNS)
for file_name in self.chunk_files:
sitemap_element = ET.SubElement(root, "sitemap")
ET.SubElement(sitemap_element, "loc").text = f"{self.public_base_url}/{file_name}"
ET.SubElement(sitemap_element, "lastmod").text = _as_sitemap_date(generated_at)
xml_bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
(self.output_dir / "sitemap.xml").write_bytes(xml_bytes)
@staticmethod
def _write_urlset(file_path: Path, urls: Iterable[ET.Element]) -> None:
root = ET.Element("urlset", xmlns=SITEMAP_XMLNS)
for url in urls:
root.append(url)
xml_bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
file_path.write_bytes(xml_bytes)
@staticmethod
def _build_url_element(loc: str, lastmod: datetime, changefreq: str, priority: float) -> ET.Element:
element = ET.Element("url")
ET.SubElement(element, "loc").text = loc
ET.SubElement(element, "lastmod").text = _as_sitemap_date(lastmod)
ET.SubElement(element, "changefreq").text = changefreq
ET.SubElement(element, "priority").text = f"{priority:.2f}"
return element
def build_sitemaps(
output_dir: Path,
site_base_url: str,
sitemap_url_prefix: str,
max_items: int,
max_file_size: int,
max_files_qty: int,
compare_min_depth: int = 2,
compare_max_depth: int = 4,
) -> SitemapBuildResult:
"""Оркестратор полного прогона сборки sitemap-файлов."""
time_start = timezone.now()
generated_at = timezone.now()
compare_lastmod = generated_at.date().replace(day=1)
latest_blog_modify = BlogPosts.objects.filter(
dPostDataBegin__lte=timezone.now(),
bPublished=True,
bArchive=False,
).aggregate(lastmod=Max("dPostDataModify"))["lastmod"]
latest_profile_modify = PVCprofiles.objects.aggregate(lastmod=Max("dProfileModify"))["lastmod"]
latest_seria_modify = Seria_Info.objects.aggregate(lastmod=Max("dSeriaInfoModify"))["lastmod"]
latest_company_modify = MerchantBrand.objects.annotate(
last_offer_modify=Max("merchantoffice__ouruser__setkit__priceoffer__dOfferModify"),
last_office_modify=Max("merchantoffice__dOfficeDataModify"),
).aggregate(lastmod=Max("last_offer_modify"), lastmod_office=Max("last_office_modify"))
latest_company_date = latest_company_modify.get("lastmod") or latest_company_modify.get("lastmod_office")
base_url = site_base_url.rstrip("/")
url_prefix = sitemap_url_prefix.strip("/")
public_sitemap_base = f"{base_url}/{url_prefix}" if url_prefix else base_url
writer = SitemapXmlWriter(
output_dir=output_dir,
public_base_url=public_sitemap_base,
max_items=max_items,
max_file_size=max_file_size,
max_files_qty=max_files_qty,
)
writer.cleanup_old()
# Источники URL. Порядок можно менять, если нужно управлять наполнением chunk-файлов.
sitemaps = [
StaticPagesSitemap(
items=[
{"loc": "/", "lastmod": generated_at, "changefreq": "weekly", "priority": 1.00},
{"loc": "/catalog", "lastmod": generated_at, "changefreq": "weekly", "priority": 0.88},
{"loc": "/catalog/profile", "lastmod": latest_profile_modify, "changefreq": "weekly", "priority": 0.92},
{"loc": "/catalog/seria", "lastmod": latest_seria_modify, "changefreq": "weekly", "priority": 0.95},
{"loc": "/catalog/standard_opening", "lastmod": latest_seria_modify, "changefreq": "monthly", "priority": 0.86},
{"loc": "/catalog/company", "lastmod": latest_company_date, "changefreq": "weekly", "priority": 0.90},
{"loc": "/stat/rating/profiles_rank", "lastmod": latest_profile_modify, "changefreq": "monthly", "priority": 0.76},
]
),
BlogListSitemap(lastmod_value=latest_blog_modify),
BlogPostSitemap(),
ProfileManufactureSitemap(),
ProfileModelSitemap(),
SeriaDetailSitemap(),
CompanyDetailSitemap(),
SingleWindowSitemap(lastmod_value=generated_at),
BuildingOffersSitemap(lastmod_value=generated_at),
CompareOffersSitemap(
lastmod_value=compare_lastmod,
min_depth=compare_min_depth,
max_depth=compare_max_depth,
),
]
for sitemap in sitemaps:
for item in sitemap.items():
location = sitemap.location(item)
lastmod = sitemap.lastmod(item)
if not location.startswith("/"):
location = f"/{location}"
sitemap_changefreq = sitemap.changefreq(item) if callable(getattr(sitemap, "changefreq", None)) else str(sitemap.changefreq)
sitemap_priority = sitemap.priority(item) if callable(getattr(sitemap, "priority", None)) else float(sitemap.priority)
writer.add_url(
loc=f"{base_url}{location}",
lastmod=lastmod,
changefreq=sitemap_changefreq,
priority=sitemap_priority,
)
files_count = writer.finalize(generated_at=generated_at)
elapsed = (timezone.now() - time_start).total_seconds()
return SitemapBuildResult(
total_urls=writer.total_urls,
files_count=files_count,
elapsed_seconds=elapsed,
output_dir=output_dir,
)
class Command(BaseCommand):
help = "Генерирует sitemap.xml и sitemapNNNN.xml в файловый кэш."
def add_arguments(self, parser):
parser.add_argument(
"--compare-min-depth",
type=int,
default=2,
help="Минимальная глубина комбинаций compare_offers (по умолчанию 2).",
)
parser.add_argument(
"--compare-max-depth",
type=int,
default=4,
help="Максимальная глубина комбинаций compare_offers (по умолчанию 4).",
)
parser.add_argument(
"--max-items",
type=int,
default=40000,
help="Максимум URL в одном sitemap-файле (по умолчанию 40000).",
)
parser.add_argument(
"--max-file-size",
type=int,
default=5242880,
help="Максимальный размер sitemap-файла в байтах (по умолчанию 5242880).",
)
parser.add_argument(
"--max-files-qty",
type=int,
default=998,
help="Максимум вложенных sitemap-файлов (по умолчанию 998).",
)
def handle(self, *args, **options):
# Валидация глубины compare перед запуском тяжелой части генерации.
compare_min_depth = options["compare_min_depth"]
compare_max_depth = options["compare_max_depth"]
if compare_min_depth > compare_max_depth:
raise CommandError("--compare-min-depth не может быть больше --compare-max-depth")
result = build_sitemaps(
output_dir=Path(settings.SITEMAP_ROOT),
site_base_url=settings.SITE_BASE_URL,
sitemap_url_prefix=settings.SITEMAP_URL_PREFIX,
max_items=options["max_items"],
max_file_size=options["max_file_size"],
max_files_qty=options["max_files_qty"],
compare_min_depth=compare_min_depth,
compare_max_depth=compare_max_depth,
)
# Человекочитаемый отчет для логов CI/CD и контейнерных entrypoint-скриптов.
if result.files_count == 1:
self.stdout.write(
self.style.SUCCESS(
f"Создан единственный sitemap.xml. URL-ов: {result.total_urls}. "
f"Время: {result.elapsed_seconds:.2f} сек."
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f"Создан каскад sitemap. Файлов: {result.files_count}. URL-ов: {result.total_urls}. "
f"Время: {result.elapsed_seconds:.2f} сек."
)
)