diff --git a/.env.sample b/.env.sample
index 9515621..ef144b7 100644
--- a/.env.sample
+++ b/.env.sample
@@ -16,6 +16,9 @@ DEBUG=False
# Допустимые хосты (разделены запятой без пробелов)
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
+# Базовый публичный URL сайта (используется для абсолютных URL в sitemap.xml)
+SITE_BASE_URL=https://yourdomain.com
+
# Админы для email-оповещений Django (формат: Имя:email,Имя2:email2)
ADMINS=Admin:admin@example.com
@@ -40,6 +43,9 @@ DATABASE_NAME=oknadria.sqlite3
# Пути вычисляются автоматически внутри settings.py от PROJECT_ROOT
TOUCH_RELOAD=/app/logs/touch-reload.txt
+# Подкаталог в MEDIA_ROOT, где хранится кеш sitemap-файлов
+SITEMAP_SUBDIR=_serv_sitemap
+
# ============================================================================
# EMAIL
# ============================================================================
diff --git a/oknardia/oknardia/settings.py b/oknardia/oknardia/settings.py
index c5303e7..bd24a5f 100644
--- a/oknardia/oknardia/settings.py
+++ b/oknardia/oknardia/settings.py
@@ -68,7 +68,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'django.contrib.humanize',
- # 'django.contrib.sitemaps',
+ 'django.contrib.sitemaps',
'oknardia.apps.OknardiaConfig',
'web.apps.WebConfig',
@@ -146,7 +146,14 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = str(PUBLIC_ROOT / 'media')
# STATIC_ROOT отделен от исходной статики, чтобы избежать staticfiles.E002.
STATIC_ROOT = str(PUBLIC_ROOT / 'static_collected')
-SITEMAP_ROOT = str(PUBLIC_ROOT)
+
+# Базовый URL сайта нужен для абсолютных URL в sitemap.xml.
+SITE_BASE_URL = env('SITE_BASE_URL', default='https://oknardia.ru').rstrip('/')
+# Файлы sitemap храним в media-volume, чтобы переживали пересоздание контейнера.
+SITEMAP_SUBDIR = env('SITEMAP_SUBDIR', default='_serv_sitemap').strip('/ ')
+SITEMAP_ROOT = str(Path(MEDIA_ROOT) / SITEMAP_SUBDIR)
+SITEMAP_URL_PREFIX = f"{MEDIA_URL.rstrip('/')}/{SITEMAP_SUBDIR}"
+SITEMAP_INDEX_URL = f"{SITE_BASE_URL}{SITEMAP_URL_PREFIX}/sitemap.xml"
# Каталоги, откуда Django читает исходную статику в DEBUG-режиме.
STATICFILES_DIRS = [
diff --git a/oknardia/oknardia/urls.py b/oknardia/oknardia/urls.py
index 611008f..9397347 100644
--- a/oknardia/oknardia/urls.py
+++ b/oknardia/oknardia/urls.py
@@ -96,8 +96,6 @@ urlpatterns = [
re_path(r'^service/tmp[/*]$', service.tmp),
# --- страничка "нет доступа"
re_path(r'^service/not-denice[/*]$', service.not_denice),
- # --- создание файлов sitemap.xml
- re_path(r'^service/make_sitemaps[/*]$', service.make_site_maps),
re_path(r'^service/make_rating[/*]$', service.make_rating),
]
diff --git a/oknardia/templates/service/index.html b/oknardia/templates/service/index.html
index 0eb9103..94ac955 100755
--- a/oknardia/templates/service/index.html
+++ b/oknardia/templates/service/index.html
@@ -12,7 +12,6 @@
diff --git a/oknardia/web/management/__init__.py b/oknardia/web/management/__init__.py
new file mode 100644
index 0000000..633f866
--- /dev/null
+++ b/oknardia/web/management/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+
diff --git a/oknardia/web/management/commands/__init__.py b/oknardia/web/management/commands/__init__.py
new file mode 100644
index 0000000..633f866
--- /dev/null
+++ b/oknardia/web/management/commands/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+
diff --git a/oknardia/web/management/commands/generate_sitemaps.py b/oknardia/web/management/commands/generate_sitemaps.py
new file mode 100644
index 0000000..b7e57e1
--- /dev/null
+++ b/oknardia/web/management/commands/generate_sitemaps.py
@@ -0,0 +1,612 @@
+# -*- 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} сек."
+ )
+ )
+
diff --git a/oknardia/web/service.py b/oknardia/web/service.py
index 53e8402..afef51c 100644
--- a/oknardia/web/service.py
+++ b/oknardia/web/service.py
@@ -7,8 +7,6 @@ import django.utils.dateformat
import django.utils.timezone
from oknardia.settings import *
import time
-import random
-import pytils
# Главная страница для вызова служебных процедур.
@@ -42,187 +40,6 @@ def tmp(request: HttpRequest) -> HttpResponse:
return render(request, "service/tmp.html", {'TAU': float(time.time()-t_start)})
-SITEMAP_MAX_ITEM = 40000 # максимальное число URL-ов в sitemap.xml -- 50000
-SITEMAP_MAX_FILE_SIZE = 5242880 # максимальный размер файла sitemap.xml -- 10Mb (10485760 байт)
-SITEMAP_MAX_FILES_QTY = 998 # максимальный число вложенных sitemap.xml -- 1000
-
-
-def str_time() -> str:
- """ Возвращает текущее время в ISO 8601 со смещением от текущего часового пояса
- """
- return django.utils.dateformat.format(django.utils.timezone.now(), 'c')
-
-
-def make_site_maps (request: HttpRequest) -> HttpResponse:
- """Функция создания sitemap.xml ... периодически надо вызывать через crone
-
- :param request: request
- :return: HttpResponse ( msg )
- """
- msg = ""
- time_start = time.time()
- count_total_item = 0
- count_item_per_file = 0
- count_file = 0
- # форматирование даты-времени в ISO 8601 со смещением от текущего часового пояса
- # str_time = django.utils.dateformat.format(django.utils.timezone.now(), 'c') # форматирование даты в ISO 8601
- # ПОЛУЧАЕМ ВСЕ СТРАНИЧКИ С ЦЕНАМИ ДЛЯ ОДИНОЧНОГО ПРОЕМА
- q1 = Win_MountDim.objects.raw("SELECT"
- " oknardia_win_mountdim.iWinWidth,"
- " oknardia_win_mountdim.iWinHight,"
- " oknardia_win_mountdim.id,"
- " COUNT(oknardia_priceoffer.kOffer2MountDim_id) AS NumOffer,"
- " oknardia_win_mountdim.sFlapConfig "
- "FROM oknardia_priceoffer"
- " INNER JOIN oknardia_win_mountdim"
- " ON oknardia_priceoffer.kOffer2MountDim_id = oknardia_win_mountdim.id "
- "GROUP BY oknardia_win_mountdim.id,"
- " oknardia_win_mountdim.iWinWidth,"
- " oknardia_win_mountdim.iWinHight,"
- " oknardia_win_mountdim.sFlapConfig "
- "ORDER BY COUNT(oknardia_priceoffer.kOffer2MountDim_id);")
- for i in q1:
- msg += f" \n" \
- f" https://oknardia.ru/tsena-odnogo-okna/{int(i.iWinWidth*10)}x{int(i.iWinHight*10)}mm/tip{i.id}\n"\
- f" {str_time()}\n weekly\n 0.5\n" \
- f" \n"
- count_total_item += 1
- # print "~~~> ", countTotalItem, " ::: /compare_offers/", Count
- count_item_per_file += 1
- if (count_item_per_file > SITEMAP_MAX_ITEM) or (len(msg) > SITEMAP_MAX_FILE_SIZE):
- # Файл sitemap.xml заполнен... нужно записать и продолжить записывать в следующем
- msg = f"" \
- f"\n{msg}"
- with open(f"{SITEMAP_ROOT}sitemap{count_file:04d}.xml", "w", encoding="utf-8") as f:
- f.write(msg)
- count_item_per_file = 0
- count_file += 1 # счетчик файлов
- if count_file > SITEMAP_MAX_FILES_QTY: # максимально число файлов SITEMAP_MAX_FILES_QTY
- break
- msg = "" # обнулить буфер для записи файла
- # ВСЕ СТРАНИЧКИ С ЦЕНОВЫМИ ПРЕДЛОЖЕНИЯМИ ПО АДРЕСАМ
- q1 = Building_Info.objects.raw(
- "SELECT DISTINCT oknardia_building_info.sAddress, oknardia_building_info.id as id,"
- " oknardia_apartment_type.id AS ap_id "
- "FROM oknardia_building_info"
- " INNER JOIN oknardia_seria_info"
- " ON oknardia_building_info.kSeria_Link_id = oknardia_seria_info.id"
- " INNER JOIN oknardia_apartment_type"
- " ON oknardia_apartment_type.kSeria_id = oknardia_seria_info.kRoot_id "
- "ORDER BY oknardia_building_info.id;")
- list_build = list(q1)
- random.shuffle(list_build) # перемешиваем случайным образом, чтобы поисковики видели изменения sitemap
- for i in list_build:
- msg += f" \n https://oknardia.ru/{i.id}/{i.ap_id}/{pytils.translit.slugify(i.sAddress)}\n" \
- f" {str_time()}\n weekly\n 0.5\n" \
- f" \n"
- count_total_item += 1
- # print("===> ", count_total_item, " ::: ", i.id, '/', i.ap_id, '/', pytils.translit.slugify(i.sAddress), sep="")
- count_item_per_file += 1
- if (count_item_per_file > SITEMAP_MAX_ITEM) or (len(msg) > SITEMAP_MAX_FILE_SIZE):
- # Файл sitemap.xml заполнен... нужно записать и продолжить записывать в следующем
- msg = f"\n" \
- f"\n{msg}"
- with open(f"{SITEMAP_ROOT}sitemap{count_file:04d}.xml", "w", encoding="utf-8") as f:
- f.write(msg)
- count_item_per_file = 0
- count_file += 1 # счетчик файлов
- if count_file > SITEMAP_MAX_FILES_QTY: # максимально число файлов SITEMAP_MAX_FILES_QTY
- break
- msg = "" # обнулить буфер для записи файла
-
- # ДОБАВЛЯЕМ В SITEMAP ВСЕ СТРАНИЧКИ СО СРВНЕНИЕМ НАБОРОВ
- dim_comp = compare()
- random.shuffle(dim_comp)
- for i in dim_comp:
- msg += f" \n https://oknardia.ru/compare_offers/{i}\n {str_time()}\n" \
- f" weekly\n 0.45\n \n"
- count_total_item += 1
- count_item_per_file += 1
- if (count_item_per_file > SITEMAP_MAX_ITEM) or (len(msg) > SITEMAP_MAX_FILE_SIZE):
- # Файл sitemap.xml заполнен... нужно записать и продолжить записывать в следующем
- msg = f"\n" \
- f"\n{msg}"
- with open(f"{SITEMAP_ROOT}sitemap{count_file:04d}.xml", "w", encoding="utf-8") as f:
- f.write(msg)
- count_item_per_file = 0
- count_file += 1 # счетчик файлов
- msg = "" # обнулить буфер для записи файла
- if count_file > SITEMAP_MAX_FILES_QTY: # максимально число файлов SITEMAP_MAX_FILES_QTY
- break
-
- # ЗАВЕРШАЕМ
- if count_file == 0:
- # Все ссылки уместились в один sitemap.xml... просто его записать
- with open(f"{SITEMAP_ROOT}sitemap.xml", "w", encoding="utf-8") as f:
- f.write(f"\n"
- f"\n{msg}")
- # print(SITEMAP_ROOT)
- msg = f"Создан единственный sitemap.xml\nВсего ссылок: {count_total_item:06d}"
- else:
- # Файлов sitemap.xml много.
- # Создаем завершающий файл sitemap
- with open(f"{SITEMAP_ROOT}sitemap{count_file:04d}.xml", "w", encoding="utf-8") as f:
- f.write(f"\n\n{msg}")
- # Создаём объединяющий sitemap.xml с перечислением всего множества sitemap-файлов...
- msg = "\n" \
- "\n"
- for i in range(0, count_file+1):
- msg += f" \n https://oknardia.ru/sitemap{i:04d}.xml\n" \
- f" {str_time()}\n \n"
- msg += u""
- with open(f"{SITEMAP_ROOT}sitemap.xml", "w", encoding="utf-8") as f:
- f.write(msg)
- msg = f"Создан каскадный sitemap.xml\nВсего вложенных файлов: {count_file+1:04d}\n" \
- f"Всего ссылок: {count_total_item:08d}"
- # print(msg)
- return HttpResponse(f"
{msg}\n\nвремя выполнения: {float(time.time()-time_start)} сек.")
-
-
-def compare() -> list:
- """ Возвращает список сравнения из всех возможных вариантов сравнения оконных наборов (из доступных в базе)
-
- :return: список сравнения
- """
- q_set_kit = SetKit.objects.raw('SELECT oknardia_setkit.id, oknardia_setkit.sSetActive '
- 'FROM oknardia_setkit '
- 'WHERE oknardia_setkit.sSetActive = TRUE')
- count = 0
- dim_comp = []
- l_set_kit = list(q_set_kit)
- for i1 in l_set_kit:
- for i2 in l_set_kit:
- if i1.id >= i2.id:
- continue
- dim_comp.append(f"{i1.id},{i2.id}")
- count += 1
- for i3 in l_set_kit:
- if i2.id >= i3.id:
- continue
- dim_comp.append(f"{i1.id},{i2.id},{i3.id}")
- count += 1
- for i4 in l_set_kit:
- if i3.id >= i4.id:
- continue
- dim_comp.append(f"{i1.id},{i2.id},{i3.id},{i4.id}")
- count += 1
- for i5 in l_set_kit:
- if i4.id >= i5.id:
- continue
- dim_comp.append(f"{i1.id},{i2.id},{i3.id},{i4.id},{i5.id}")
- count += 1
- for i6 in l_set_kit:
- if i5.id >= i6.id:
- continue
- dim_comp.append(f"{i1.id},{i2.id},{i3.id},{i4.id},{i5.id},{i6.id}")
- count += 1
- # random.shuffle(dim_comp)
- # for i1 in dim_comp:
- # print(i1)
- # print(f"---------------{count}---------------")
- return dim_comp
-
def make_rating(request: HttpRequest) -> HttpResponse:
"""
diff --git a/public/robots.txt b/public/robots.txt
index 2646f78..0932cc5 100755
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1,8 +1,7 @@
# для агрегатора коммерческих предложений пластиковых окон -- ОКНАРДИЯ
User-Agent: *
Allow: /
-Disallow: /service
-Disallow: /admin
Host: https://oknardia.ru
-Sitemap: https://oknardia.ru/sitemap.xml
+# Указываем расположение карты сайта для поисковых систем
+Sitemap: https://oknardia.ru/media/_serv_sitemap/sitemap.xml