mod: rework sitemap generation and seo coverage
This commit is contained in:
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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),
|
||||
|
||||
]
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<ul>
|
||||
<li><b><a href="/service/tmp">Страница для тестирования верстки текста в блоге</a></b></li>
|
||||
</ul><ul>
|
||||
<li><b><a href="/service/make_sitemaps">Пересоздать файлы sitemaps.xml</a></b> (исполняется около 10 минут)</li>
|
||||
<li><a href="/service/make_JavaScripts4maps">Пересоздать JavaScript для карт на основе API Яндекс.Крат</a> (исполняется около 1 минуты)</li>
|
||||
<li><a href="/service/make_SeriaInfoRoot">Построить id корневых серий</a> (исполняется около 1 часа)</li>
|
||||
</ul><ul>
|
||||
|
||||
2
oknardia/web/management/__init__.py
Normal file
2
oknardia/web/management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
2
oknardia/web/management/commands/__init__.py
Normal file
2
oknardia/web/management/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
612
oknardia/web/management/commands/generate_sitemaps.py
Normal file
612
oknardia/web/management/commands/generate_sitemaps.py
Normal file
@@ -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} сек."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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" <url>\n" \
|
||||
f" <loc>https://oknardia.ru/tsena-odnogo-okna/{int(i.iWinWidth*10)}x{int(i.iWinHight*10)}mm/tip{i.id}</loc>\n"\
|
||||
f" <lastmod>{str_time()}</lastmod>\n <changefreq>weekly</changefreq>\n <priority>0.5</priority>\n" \
|
||||
f" </url>\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"<?xml version='1.0' encoding='UTF-8'?>" \
|
||||
f"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>\n{msg}</urlset>"
|
||||
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" <url>\n <loc>https://oknardia.ru/{i.id}/{i.ap_id}/{pytils.translit.slugify(i.sAddress)}</loc>\n" \
|
||||
f" <lastmod>{str_time()}</lastmod>\n <changefreq>weekly</changefreq>\n <priority>0.5</priority>\n" \
|
||||
f" </url>\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"<?xml version='1.0' encoding='UTF-8'?>\n" \
|
||||
f"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>\n{msg}</urlset>"
|
||||
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" <url>\n <loc>https://oknardia.ru/compare_offers/{i}</loc>\n <lastmod>{str_time()}</lastmod>\n" \
|
||||
f" <changefreq>weekly</changefreq>\n <priority>0.45</priority>\n </url>\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"<?xml version='1.0' encoding='UTF-8'?>\n" \
|
||||
f"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>\n{msg}</urlset>"
|
||||
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"<?xml version='1.0' encoding='UTF-8'?>\n"
|
||||
f"<urlset xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>\n{msg}</urlset>")
|
||||
# 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"<?xml version='1.0' encoding='UTF-8'?>\n<urlset "
|
||||
f"xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>\n{msg}</urlset>")
|
||||
# Создаём объединяющий sitemap.xml с перечислением всего множества sitemap-файлов...
|
||||
msg = "<?xml version='1.0' encoding='UTF-8'?>\n" \
|
||||
"<sitemapindex xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'>\n"
|
||||
for i in range(0, count_file+1):
|
||||
msg += f" <sitemap>\n <loc>https://oknardia.ru/sitemap{i:04d}.xml</loc>\n" \
|
||||
f" <lastmod>{str_time()}</lastmod>\n </sitemap>\n"
|
||||
msg += u"</sitemapindex>"
|
||||
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"<pre>{msg}\n\nвремя выполнения: {float(time.time()-time_start)} сек.</pre>")
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user