diff --git a/hypn0/hypn0/urls.py b/hypn0/hypn0/urls.py
index 18f0004..0d49b32 100644
--- a/hypn0/hypn0/urls.py
+++ b/hypn0/hypn0/urls.py
@@ -15,11 +15,16 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
+from django.contrib.sitemaps.views import sitemap
from django.urls import include, path
from django.conf.urls.static import static
+
+from hypn0_site.sitemaps import sitemaps
from . import settings
urlpatterns = [
+ # Карта сайта sitemap.xml для поисковой индексации
+ path("sitemap.xml", sitemap, {"sitemaps": sitemaps}, name="django.contrib.sitemaps.views.sitemap"),
# Админ-сайт с переименованными приложениями (переопределен в frontend/apps.py)
path(settings.ADMIN_URL, admin.site.urls),
path('', include('hypn0_site.urls')),
diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py
index b6a7d68..ac7d8b9 100644
--- a/hypn0/hypn0_site/models.py
+++ b/hypn0/hypn0_site/models.py
@@ -1,6 +1,7 @@
import hashlib
from django.db import models, transaction, IntegrityError
from django.db.models import F
+from django.urls import reverse
from hashids import Hashids
from hypn0.settings import *
from django.utils import timezone
@@ -157,6 +158,10 @@ class TbHypn0Item(models.Model):
def __str__(self) -> str:
return f"{self.s_title} ({self.s_hash_id})"
+ def get_absolute_url(self) -> str:
+ """Возвращает канонический URL детальной страницы картины."""
+ return reverse("hypn0_site:gallery_detail", kwargs={"hash_id": self.s_hash_id})
+
@property
def card_bg_style(self) -> str:
"""
diff --git a/hypn0/hypn0_site/sitemaps.py b/hypn0/hypn0_site/sitemaps.py
new file mode 100644
index 0000000..41fa512
--- /dev/null
+++ b/hypn0/hypn0_site/sitemaps.py
@@ -0,0 +1,66 @@
+"""
+Карты сайта (Sitemaps) для поисковой индексации HypnoSVG.
+"""
+from django.contrib.sitemaps import Sitemap
+from django.urls import reverse
+
+from .models import TbHypn0Item
+
+
+class StaticViewSitemap(Sitemap):
+ """
+ Карта сайта для ключевых страниц и разделов интерфейса.
+ """
+ changefreq = "daily"
+
+ def items(self):
+ return [
+ ("hypn0_site:index", 1.0, "daily"),
+ ("hypn0_site:gallery_archive", 0.9, "daily"),
+ ]
+
+ def location(self, item):
+ return reverse(item[0])
+
+ def priority(self, item):
+ return item[1]
+
+ def changefreq(self, item):
+ return item[2]
+
+
+class GalleryItemSitemap(Sitemap):
+ """
+ Карта сайта для публичных гипно-картин галереи сообщества.
+ """
+ changefreq = "weekly"
+
+ def items(self):
+ # Включаем только публичные картины, исключая подозрительные/заблокированные
+ return (
+ TbHypn0Item.objects.filter(is_public=True)
+ .exclude(i_level__in=[TbHypn0Item.Level.SHAMED, TbHypn0Item.Level.SUSPICIOUS])
+ .order_by("-d_created_at")
+ )
+
+ def lastmod(self, obj: TbHypn0Item):
+ return obj.d_created_at
+
+ def priority(self, obj: TbHypn0Item):
+ # Динамический приоритет в зависимости от уровня признания в психо-матрице
+ if obj.i_level == TbHypn0Item.Level.IMMORTAL:
+ return 0.9
+ elif obj.i_level == TbHypn0Item.Level.LEVEL_2:
+ return 0.8
+ elif obj.i_level == TbHypn0Item.Level.LEVEL_1:
+ return 0.7
+ return 0.6
+
+ def location(self, obj: TbHypn0Item):
+ return obj.get_absolute_url()
+
+
+sitemaps = {
+ "static": StaticViewSitemap,
+ "gallery": GalleryItemSitemap,
+}
diff --git a/hypn0/hypn0_site/tests.py b/hypn0/hypn0_site/tests.py
index 94e9cca..bc1f01c 100644
--- a/hypn0/hypn0_site/tests.py
+++ b/hypn0/hypn0_site/tests.py
@@ -1058,3 +1058,92 @@ class RescoreCommandTests(BaseMediaTestCase):
self.assertGreater(self.item_immortal.f_score, self.item_candidate.f_score)
# Статус бессмертной картины защищен и не должен измениться
self.assertEqual(self.item_immortal.i_level, TbHypn0Item.Level.IMMORTAL)
+
+
+class SitemapTests(BaseMediaTestCase):
+ """Тестирование автоматической генерации sitemap.xml."""
+
+ def setUp(self):
+ super().setUp()
+ self.vid = "123e4567-e89b-12d3-a456-426614174000"
+ svg_bytes = b''
+
+ # Создаем публичные картины разных уровней
+ self.item_immortal = TbHypn0Item(
+ s_title="Шедевр транса",
+ file_svg=ContentFile(svg_bytes, name="immortal.svg"),
+ i_level=TbHypn0Item.Level.IMMORTAL,
+ is_public=True,
+ )
+ self.item_immortal.save(visitor_uuid_or_fp=self.vid)
+
+ self.item_curated = TbHypn0Item(
+ s_title="Одобренная картина",
+ file_svg=ContentFile(svg_bytes, name="curated.svg"),
+ i_level=TbHypn0Item.Level.LEVEL_2,
+ is_public=True,
+ )
+ self.item_curated.save(visitor_uuid_or_fp=self.vid)
+
+ self.item_fresh = TbHypn0Item(
+ s_title="Свежий шум",
+ file_svg=ContentFile(svg_bytes, name="fresh.svg"),
+ i_level=TbHypn0Item.Level.CANDIDATE,
+ is_public=True,
+ )
+ self.item_fresh.save(visitor_uuid_or_fp=self.vid)
+
+ # Создаем непубличную картину и заблокированную (шейминг)
+ self.item_private = TbHypn0Item(
+ s_title="Приватная картина",
+ file_svg=ContentFile(svg_bytes, name="private.svg"),
+ i_level=TbHypn0Item.Level.CANDIDATE,
+ is_public=False,
+ )
+ self.item_private.save(visitor_uuid_or_fp=self.vid)
+
+ self.item_shamed = TbHypn0Item(
+ s_title="Заблокированная картина",
+ file_svg=ContentFile(svg_bytes, name="shamed.svg"),
+ i_level=TbHypn0Item.Level.SHAMED,
+ is_public=True,
+ )
+ self.item_shamed.save(visitor_uuid_or_fp=self.vid)
+
+ def test_item_get_absolute_url(self):
+ expected_url = f"/gallery/{self.item_immortal.s_hash_id}"
+ self.assertEqual(self.item_immortal.get_absolute_url(), expected_url)
+
+ def test_sitemap_xml_renders_valid_xml_with_static_and_gallery_pages(self):
+ response = self.client.get("/sitemap.xml")
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("xml", response["Content-Type"])
+
+ content = response.content.decode("utf-8")
+ self.assertIn("http://testserver/", content)
+ self.assertIn("http://testserver/gallery", content)
+
+ # Проверяем наличие публичных картин
+ self.assertIn(f"http://testserver/gallery/{self.item_immortal.s_hash_id}", content)
+ self.assertIn(f"http://testserver/gallery/{self.item_curated.s_hash_id}", content)
+ self.assertIn(f"http://testserver/gallery/{self.item_fresh.s_hash_id}", content)
+
+ # Проверяем отсутствие приватных и заблокированных картин
+ self.assertNotIn(f"http://testserver/gallery/{self.item_private.s_hash_id}", content)
+ self.assertNotIn(f"http://testserver/gallery/{self.item_shamed.s_hash_id}", content)
+
+ def test_sitemap_priorities_and_changefreq(self):
+ response = self.client.get("/sitemap.xml")
+ self.assertEqual(response.status_code, 200)
+ content = response.content.decode("utf-8")
+
+ # Приоритет главной страницы 1.0, архива 0.9, бессмертной 0.9, кураторской 0.8
+ self.assertIn("1.0", content)
+ self.assertIn("0.9", content)
+ self.assertIn("0.8", content)
+ self.assertIn("0.6", content)
+ self.assertIn("daily", content)
+ self.assertIn("weekly", content)
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..903c22c
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,13 @@
+User-agent: *
+Allow: /
+Disallow: /admin/
+Disallow: /generate
+Disallow: /publish
+Disallow: /gallery/*/vote
+Disallow: /gallery/*/download
+Disallow: /gallery/random
+Disallow: /*?*
+
+Clean-param: seed&floor&sort&page /
+
+Sitemap: https://hypn0.xyz/sitemap.xml