# -*- 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} сек." ) )