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