613 lines
25 KiB
Python
613 lines
25 KiB
Python
# -*- 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} сек."
|
||
)
|
||
)
|
||
|