From dc379fa8da08fc409f3dc0bf20843b377fba399a Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 10 May 2026 23:34:00 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D1=83=D0=BD=D0=B8=D1=84=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20slug-?= =?UTF-8?q?=D0=BE=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- oknardia/web/add_func.py | 121 ++++++++++++++---- oknardia/web/blog.py | 16 +-- oknardia/web/catalog.py | 15 +-- oknardia/web/catalog_companies.py | 19 +-- oknardia/web/catalog_openings.py | 12 +- oknardia/web/catalog_profiles.py | 36 +++--- oknardia/web/catalog_series.py | 19 +-- .../management/commands/generate_sitemaps.py | 19 ++- .../commands/regenerate_seria_prerender.py | 4 +- oknardia/web/prices.py | 34 ++--- oknardia/web/report1.py | 8 +- oknardia/web/report2.py | 6 +- oknardia/web/views.py | 10 +- 13 files changed, 176 insertions(+), 143 deletions(-) diff --git a/oknardia/web/add_func.py b/oknardia/web/add_func.py index d9d4398..b8a4fca 100644 --- a/oknardia/web/add_func.py +++ b/oknardia/web/add_func.py @@ -3,42 +3,117 @@ __author__ = 'Sergei Erjemin' from PIL import Image, ImageDraw from oknardia.settings import * +from pytils.translit import slugify import os import math import re +import html import urllib3 import xml.dom.minidom def safe_html_spec_symbols(s: str) -> str: - """ Очистка строки от HTML-разметки типографа + """ Очистка строки от HTML-разметки и получение чистого текста. - :param s: str -- строка которую надо очистить - :return: str: str -- очищенная строка + Функция удаляет HTML-теги, содержимое исключённых тегов (script, style, object, embed, applet, + iframe, svg, canvas, code, kbd, pre, var, samp, output, noscript, link, meta, form, input, + button, textarea, select, base, title, head, body, track, source, picture), заменяет HTML-мнемоники + на Unicode-символы и убирает лишние пробелы. + + :param s: str -- строка которую надо очистить + :return: str -- очищенная строка с чистым текстом """ - # очистка строки от некоторых спец-символов HTML - result = s.replace('­', '­') - result = result.replace('', '') - result = result.replace('', '') - result = result.replace('', '') - result = result.replace('', '') - result = result.replace('', '') - result = result.replace('', ' ') - result = result.replace('', '') - result = result.replace('', '') - result = result.replace(' ', ' ') - result = result.replace('«', '«') - result = result.replace('»', '»') - result = result.replace('…', '…') - result = result.replace('', '') - result = result.replace('', '') - result = result.replace('—', '—') - result = result.replace('№', '№') - result = result.replace('
', ' ') - result = result.replace('
', ' ') + # Шаг 1: Удаляем содержимое "опасных" и невидимых тегов + # Опасные: script, object, embed, applet, iframe, svg, canvas + # Техническое содержимое: style, code, kbd, pre, var, samp, output, noscript + # Формы: form, input, button, textarea, select + # Служебные: meta, link, base, title, head, body, track, source, picture + # Используем флаг IGNORECASE и DOTALL для работы с многострочным контентом + result = re.sub( + r'<(script|style|code|kbd|pre|var|samp|output|noscript|link|meta|iframe|object|embed|applet|form|input|button|textarea|select|svg|canvas|base|title|head|body|track|source|picture)(?:\s[^>]*)?>.*?', + '', + s, + flags=re.IGNORECASE | re.DOTALL + ) + + # Удаляем самозакрывающиеся теги (что-то типа , , и т.д.) + result = re.sub( + r'<(input|embed|meta|link|base|track|source|img)(?:\s[^>]*)?/>', + '', + result, + flags=re.IGNORECASE + ) + + # Шаг 2: Удаляем все остальные HTML-теги (в т.ч. самозакрывающиеся) + result = re.sub(r'<[^>]+>', '', result) + + # Шаг 3: Заменяем HTML-мнемоники на Unicode-символы (включая числовые и именованные) + # html.unescape() обрабатывает:  , <, №, € и т.д. + result = html.unescape(result) + + # Шаг 4: Очищаем множественные пробелы (в т.ч. табуляцию и переводы строк) + result = re.sub(r'\s+', ' ', result) + + # Шаг 5: Убираем пробелы в начале и конце строки + result = result.strip() + return result +def sanitize_slug(text: str, separator: str = '-', max_length: int = 200) -> str: + """ Преобразует текст в URL-безопасный слаг (slug). + + Функция очищает текст от HTML-разметки, выполняет транслитерацию русского текста в + латиницу, заменяет пробелы и недопустимые символы на разделитель (по умолчанию дефис), + и возвращает готовый к использованию в URL слаг. + + Этапы обработки: + 1. Очистка от HTML-разметки через safe_html_spec_symbols() + 2. Транслитерация русского текста в латиницу через pytils.translit.slugify() + 3. Замена множественных разделителей на один + 4. Удаление разделителя в начале и конце + 5. Прерывание на max_length символов + + :param text: str -- исходный текст, может содержать HTML и русский текст + :param separator: str -- разделитель для слага (по умолчанию дефис '-') + pytils.slugify() всегда использует дефис, этот параметр + конвертирует результат в нужный разделитель + :param max_length: int -- максимальная длина слага в символах (по умолчанию 200) + :return: str -- очищенный и готовый к использованию слаг + + Примеры: + >>> sanitize_slug(' Тест — HTML текст ') + 'test-html-tekst' + >>> sanitize_slug('Привет мир!!! @#$') + 'privet-mir' + >>> sanitize_slug('

Русский текст в слаге

') + 'russkii-tekst-v-slage' + >>> sanitize_slug('Проверка_слага', separator='_') + 'proverka_slaga' + """ + # Шаг 1: Очищаем от HTML и мнемоник, убираем лишние пробелы + cleaned = safe_html_spec_symbols(text) + + # Шаг 2: Транслитерируем русский текст в латиницу (pytils.slugify использует дефис) + slug = slugify(cleaned) + + # Шаг 3: Конвертируем разделитель если нужен другой (не дефис) + if separator != '-': + slug = slug.replace('-', separator) + + # Шаг 4: Убираем множественные разделители (например, '---' -> '-') + slug = re.sub(f'{re.escape(separator)}+', separator, slug) + + # Шаг 5: Убираем разделитель в начале и конце если он есть + slug = slug.strip(separator) + + # Шаг 6: Обрезаем по max_length если нужно (и убираем разделитель в конце) + if max_length and len(slug) > max_length: + slug = slug[:max_length].rstrip(separator) + + return slug.lower() + + # def Rus2Lat(RusString): # return translit(re.sub( # r'<[\s\S]*?>', '', re.sub(r'&[\S]*?;', '-', RusString) diff --git a/oknardia/web/blog.py b/oknardia/web/blog.py index adfba72..d8b9353 100644 --- a/oknardia/web/blog.py +++ b/oknardia/web/blog.py @@ -6,10 +6,9 @@ from django.core.exceptions import ObjectDoesNotExist from oknardia.models import BlogPosts from oknardia.settings import * from django.utils import timezone -from web.add_func import safe_html_spec_symbols +from web.add_func import safe_html_spec_symbols, sanitize_slug from time import time import re -import pytils from oknardia.settings import * @@ -88,7 +87,7 @@ def blog_list_posts(request: HttpRequest, page: str = "0") -> HttpResponse: 'PUB_DAT': post.dPostDataBegin, 'HEADER': post.sPostHeader, 'HEADER_D': safe_html_spec_symbols(post.sPostHeader), - 'HEADER_T': pytils.translit.slugify(safe_html_spec_symbols(post.sPostHeader)).lower(), + 'HEADER_T': sanitize_slug(post.sPostHeader).lower(), 'POST_ID': post.id, 'USER_STATUS': post.kBlogAuthorUser.get_sUserStatus_display(), 'USER_AVATAR': post.kBlogAuthorUser.sUserAvatarImg, @@ -160,20 +159,19 @@ def blog_post(request: HttpRequest, post_id: str = "0", page_back: str = None) - to_template.update({'PUB_DAT': q.dPostDataBegin, 'PUB_MODIFY': q.dPostDataModify, 'HEADER': q.sPostHeader, - 'HEADER_T': pytils.translit.slugify(safe_html_spec_symbols(q.sPostHeader)).lower(), + 'HEADER_T': sanitize_slug(q.sPostHeader).lower(), 'USER_STATUS': q.kBlogAuthorUser.get_sUserStatus_display(), 'USER_AVATAR': q.kBlogAuthorUser.sUserAvatarImg, 'USER_TITLE': q.kBlogAuthorUser.sUserJobTitle, 'USER_FROM_ID_OFFICE': q.kBlogAuthorUser.kMerchantOffice, 'CONTENT': re.sub(r'', '', q.sPostContent, 0, re.IGNORECASE)}) - to_template.update({'TIZER': safe_html_spec_symbols( - re.sub('||', - '', to_template["CONTENT"], 0, re.IGNORECASE))}) + content = to_template.get('CONTENT', '') + to_template.update({'TIZER': sanitize_slug(str(content))}) # получаем следующую по дате запись try: q1 = BlogPosts.objects.filter(dPostDataBegin__gt=q.dPostDataBegin, dPostDataBegin__lt=timezone.now(), bPublished=True, bArchive=False).order_by('dPostDataBegin')[0] - to_template.update({'FORW_HEADER_T': pytils.translit.slugify(safe_html_spec_symbols(q1.sPostHeader)).lower(), + to_template.update({'FORW_HEADER_T': sanitize_slug(q1.sPostHeader).lower(), 'FORW_ID': q1.id}) except(IndexError, ObjectDoesNotExist, BlogPosts.DoesNotExist): to_template.update({'FORW_DISABLE': True}) @@ -181,7 +179,7 @@ def blog_post(request: HttpRequest, post_id: str = "0", page_back: str = None) - try: q1 = BlogPosts.objects.filter(dPostDataBegin__lt=q.dPostDataBegin, bPublished=True, bArchive=False).order_by('-dPostDataBegin')[0] - to_template.update({'BACK_HEADER_T': pytils.translit.slugify(safe_html_spec_symbols(q1.sPostHeader)).lower(), + to_template.update({'BACK_HEADER_T': sanitize_slug(q1.sPostHeader).lower(), 'BACK_ID': q1.id}) except(IndexError, ObjectDoesNotExist, BlogPosts.DoesNotExist): to_template.update({'BACK_DISABLE': True}) diff --git a/oknardia/web/catalog.py b/oknardia/web/catalog.py index 484b326..20dde2d 100644 --- a/oknardia/web/catalog.py +++ b/oknardia/web/catalog.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- import time -import pytils.translit from django.http import HttpRequest, HttpResponse from django.shortcuts import render, redirect from oknardia.models import Seria_Info, SetKit -from web.add_func import get_rating_set_for_stars +from web.add_func import get_rating_set_for_stars, sanitize_slug from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list @@ -32,7 +31,7 @@ def catalog_sets(request: HttpRequest) -> HttpResponse: Для каждого набора собирается dict с полями набора, профиля, стеклопакета и компании-установщика. Цепочка FK: SetKit.kSet2User → OurUser.kMerchantOffice → MerchantOffice.kMerchantName (MerchantBrand). - Слаги URL формируются через pytils.translit.slugify. + Слаги URL формируются через sanitize_slug. :param request: HttpRequest -- входящий http-запрос :return response: HttpResponse -- исходящий http-ответ @@ -69,15 +68,13 @@ def catalog_sets(request: HttpRequest) -> HttpResponse: 'glazing': glazing, # компания-установщик 'merchant_id': brand.id if brand else None, - 'merchant_slug': pytils.translit.slugify(brand.sMerchantName) if brand else "", + 'merchant_slug': sanitize_slug(brand.sMerchantName) if brand else "", 'merchant_name': brand.sMerchantName if brand else "", 'merchant_logo': str(brand.pMerchantLogo) if brand and brand.pMerchantLogo else "", 'merchant_url': brand.sMerchantMainURL if brand else "", # слаги для ссылок на профиль в каталоге профилей - 'profile_manufacturer_slug': pytils.translit.slugify( - profile.sProfileManufacturer) if profile else "", - 'profile_slug': pytils.translit.slugify( - profile.sProfileName) if profile else "", + 'profile_manufacturer_slug': sanitize_slug(profile.sProfileManufacturer) if profile else "", + 'profile_slug': sanitize_slug(profile.sProfileName) if profile else "", }) to_template: dict[str, object] = { @@ -99,7 +96,7 @@ def report_all_info_seria_redirect(request: HttpRequest, seria_id: str = "12") - seria_id = int(seria_id) q_seria = Seria_Info.objects.get(id=seria_id) if q_seria.id == q_seria.kRoot_id: - return redirect("f/catalog/seria/{pytils.translit.slugify(q_seria.sName)}/all{seria_id}") + return redirect(f"/catalog/seria/{sanitize_slug(q_seria.sName)}/all{seria_id}") except (Seria_Info.DoesNotExist, ValueError): return redirect("/catalog/seria") return redirect("/catalog/seria") diff --git a/oknardia/web/catalog_companies.py b/oknardia/web/catalog_companies.py index 8d0005b..a8cea76 100644 --- a/oknardia/web/catalog_companies.py +++ b/oknardia/web/catalog_companies.py @@ -17,8 +17,8 @@ from oknardia.models import ( SetKit, PriceOffer, ) -from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list -from web.add_func import get_rating_set_for_stars +from web.report1 import get_last_all_user_visit_list +from web.add_func import get_rating_set_for_stars, sanitize_slug import django.utils.dateformat import time import random @@ -131,12 +131,10 @@ def _format_company_for_template(company_data: dict) -> dict: dict: Отформатированные данные компании """ formatted = company_data.copy() - # Вычисляем звёзды на основе рейтинга formatted['STARS'] = get_rating_set_for_stars( formatted['RatingAVG'] ) - # Применяем правильные формы множественного числа formatted['NumSets'] = pytils.numeral.get_plural( formatted['NumSets'], @@ -146,7 +144,6 @@ def _format_company_for_template(company_data: dict) -> dict: formatted['NumOffers'], "вариант, варианта, вариантов" ) - # Конвертируем время последнего обновления в читаемый формат if formatted['lastUpdate']: timestamp = int( @@ -158,12 +155,8 @@ def _format_company_for_template(company_data: dict) -> dict: formatted['lastUpdate'] = pytils.dt.distance_of_time_in_words( timestamp ) - # Генерируем slug из имени компании для URL - formatted['sMerchantMainURL'] = pytils.translit.slugify( - formatted['sMerchantName'] - ) - + formatted['sMerchantMainURL'] = sanitize_slug(formatted['sMerchantName']) return formatted @@ -387,11 +380,11 @@ def _format_set_for_template(set_data: dict, empty_values: list) -> dict: 'iProfileCameras': profile.iProfileCameras, 'sProfileName': { 'NAME': profile.sProfileName, - 'NAME_T': pytils.translit.slugify(profile.sProfileName) + 'NAME_T': sanitize_slug(profile.sProfileName) }, 'sProfileManufacturer': { 'NAME': profile.sProfileManufacturer, - 'NAME_T': pytils.translit.slugify(profile.sProfileManufacturer) + 'NAME_T': sanitize_slug(profile.sProfileManufacturer) }, 'sProfileColor': profile.sProfileColor, 'sProfileSealDescription': profile.sProfileSealDescription, @@ -482,7 +475,7 @@ def catalog_company_detail( raise Http404("Компания не найдена") # Проверяем что slug совпадает (для SEO и красивых URL) - actual_slug = pytils.translit.slugify(company.sMerchantName) + actual_slug = sanitize_slug(company.sMerchantName) if actual_slug != company_name_slug: return redirect( f'/catalog/company/{company_id_int}-{actual_slug}' diff --git a/oknardia/web/catalog_openings.py b/oknardia/web/catalog_openings.py index e5bced4..3f02a9d 100644 --- a/oknardia/web/catalog_openings.py +++ b/oknardia/web/catalog_openings.py @@ -3,20 +3,14 @@ from django.db.models import F from django.shortcuts import render from django.http import HttpRequest, HttpResponse from oknardia.models import MountDim2Apartment -from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list -from web.add_func import get_flaps_for_mini_pictures +from web.report1 import get_last_all_user_visit_list +from web.add_func import get_flaps_for_mini_pictures, sanitize_slug import time -import pytils from typing import Any from itertools import groupby from operator import itemgetter -def _make_slug(value: str) -> str: - """Транслитерирует строку в slug (pytils).""" - return pytils.translit.slugify(value) - - def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None: """Дописывает в контекст стандартный хвост: визиты и время выполнения.""" to_template.update({ @@ -73,7 +67,7 @@ def standard_opening(request: HttpRequest) -> HttpResponse: serias_for_opening = [ { 'ID': row['kApartment__kSeria_id'], - 'NAME_T': _make_slug(row['kApartment__kSeria__sName']), + 'NAME_T': sanitize_slug(row['kApartment__kSeria__sName']), 'NAME': row['kApartment__kSeria__sName'], } for row in rows_for_opening diff --git a/oknardia/web/catalog_profiles.py b/oknardia/web/catalog_profiles.py index fa70c4f..0b9e701 100644 --- a/oknardia/web/catalog_profiles.py +++ b/oknardia/web/catalog_profiles.py @@ -7,8 +7,8 @@ from django.shortcuts import render, redirect from django.http import HttpRequest, HttpResponse from oknardia.settings import * from oknardia.models import Catalog2Profile, PVCprofiles, PriceOffer -from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list -from web.add_func import normalize, get_rating_set_for_stars +from web.report1 import get_last_all_user_visit_list +from web.add_func import normalize, get_rating_set_for_stars, sanitize_slug import time import json import re @@ -17,19 +17,13 @@ import pytils # --------------------------------------------------------------------------- # Модульные хелперы, общие для всех вьюх этого файла # --------------------------------------------------------------------------- - -def make_slug(value: str) -> str: - """Транслитерирует строку в slug (pytils).""" - return pytils.translit.slugify(value).lower() - - def _merchant_row_to_dict(row: dict) -> dict: """Преобразует ORM-строку с данными партнёра в словарь для шаблона.""" merchant_name = row["kOffer2SetKit__kSet2User__kMerchantOffice__kMerchantName__sMerchantName"] return { "MERCHANT_ID": row["kOffer2SetKit__kSet2User__kMerchantOffice__kMerchantName__id"], "MERCHANT_NAME": merchant_name, - "MERCHANT_NAME_T": make_slug(merchant_name), + "MERCHANT_NAME_T": sanitize_slug(merchant_name), "MERCHANT_LOGO_URL": row["kOffer2SetKit__kSet2User__kMerchantOffice__kMerchantName__pMerchantLogo"], "MERCHANT_OFFERS": row["offers_by_merchant"], } @@ -40,7 +34,7 @@ def _profile_row_to_dict(profile: dict) -> dict: return { "PROFILE_NAME": profile["sProfileBriefDescription"], "PROFILE_ID": profile["id"], - "PROFILE_URL": make_slug(profile["sProfileName"]), + "PROFILE_URL": sanitize_slug(profile["sProfileName"]), "PROFILE_RATING": profile["fProfileRating"], "PROFILE_RATING_STARS": get_rating_set_for_stars(profile["fProfileRating"]), } @@ -94,11 +88,11 @@ def catalog_profile(request: HttpRequest) -> HttpResponse: list_profile_manufactures.append({ "PROF_MAN_ID": profile["id"], "PROF_MAN": profile["sProfileManufacturer"], - "PROF_MAN_T": make_slug(profile["sProfileManufacturer"]), + "PROF_MAN_T": sanitize_slug(profile["sProfileManufacturer"]), "PROF_MAN_LIST": [{ "PROF_NAME_ID": profile["id"], "PROF_NAME": profile["sProfileBriefDescription"], - "PROF_NAME_T": make_slug(profile["sProfileName"]), + "PROF_NAME_T": sanitize_slug(profile["sProfileName"]), }] }) else: @@ -106,7 +100,7 @@ def catalog_profile(request: HttpRequest) -> HttpResponse: list_profile_manufactures[-1]["PROF_MAN_LIST"].append({ "PROF_NAME_ID": profile["id"], "PROF_NAME": profile["sProfileBriefDescription"], - "PROF_NAME_T": make_slug(profile["sProfileName"]), + "PROF_NAME_T": sanitize_slug(profile["sProfileName"]), }) to_template.update({ @@ -128,17 +122,17 @@ def catalog_profile_model(request: HttpRequest, manufacture_id: int, manufacture :param request: HttpRequest -- входящий http-запрос :param manufacture_id: id профиля. Предполагается, что это первый id при сортировке по sProfileBriefDescription - :param manufacture_name: название производителя (транслитерированное pytils.translit.slugify()) + :param manufacture_name: название производителя (транслитерированное sanitize_slug()) :param model_id: id модели (марки) профиля - :param model_name: модель (марка) профиля (транслитерированное pytils.translit.slugify(sProfileName)) + :param model_name: модель (марка) профиля (транслитерированное sanitize_slug(sProfileName)) :return response: HttpResponse -- исходящий http-ответ """ time_start = time.perf_counter() manufacture_id = int(manufacture_id) model_id = int(model_id) q_pvc_by_id = PVCprofiles.objects.get(id=model_id) - manufacturer_slug = pytils.translit.slugify(q_pvc_by_id.sProfileManufacturer) - model_slug = pytils.translit.slugify(q_pvc_by_id.sProfileName) + manufacturer_slug = sanitize_slug(q_pvc_by_id.sProfileManufacturer) + model_slug = sanitize_slug(q_pvc_by_id.sProfileName) if manufacturer_slug != manufacture_name \ or model_slug != model_name \ or manufacture_id != model_id: @@ -268,21 +262,21 @@ def catalog_profile_manufacture(request: HttpRequest, manufacture_id: int, manuf :param request: HttpRequest -- входящий http-запрос :param manufacture_id: id профиля. Предполагается, что это первый id при сортировке по sProfileBriefDescription - :param manufacture_name: название производителя (транслитерированное pytils.translit.slugify()) + :param manufacture_name: название производителя (транслитерированное sanitize_slug()) :return response: HttpResponse -- исходящий http-ответ """ time_start = time.perf_counter() manufacture_id = int(manufacture_id) q_pvc_by_id = PVCprofiles.objects.get(id=manufacture_id) - if pytils.translit.slugify(q_pvc_by_id.sProfileManufacturer) != manufacture_name: + if sanitize_slug(q_pvc_by_id.sProfileManufacturer) != manufacture_name: return redirect(f'/catalog/profile/{manufacture_id}-' - f'{pytils.translit.slugify(q_pvc_by_id.sProfileManufacturer)}') + f'{sanitize_slug(q_pvc_by_id.sProfileManufacturer)}') else: q_pvc_by_id = PVCprofiles.objects.order_by('id') \ .filter(sProfileManufacturer=q_pvc_by_id.sProfileManufacturer).first() if q_pvc_by_id.id != manufacture_id: return redirect(f'/catalog/profile/{q_pvc_by_id.id}-' - f'{pytils.translit.slugify(q_pvc_by_id.sProfileManufacturer)}') + f'{sanitize_slug(q_pvc_by_id.sProfileManufacturer)}') to_template: dict[str, object] = {'CATALOG_MANUFACT': q_pvc_by_id.sProfileManufacturer, 'CATALOG_MAN2URL': manufacture_name, 'CATALOG_URL': f"{manufacture_id}-{manufacture_name}"} diff --git a/oknardia/web/catalog_series.py b/oknardia/web/catalog_series.py index 77129d0..021135f 100644 --- a/oknardia/web/catalog_series.py +++ b/oknardia/web/catalog_series.py @@ -14,18 +14,13 @@ from oknardia.models import ( Building_Info, ) from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list -from web.add_func import get_flaps_for_big_pictures +from web.add_func import get_flaps_for_big_pictures, sanitize_slug import time import os import math import pytils -def _make_slug(value: str) -> str: - """Транслитерирует строку в slug (pytils).""" - return pytils.translit.slugify(value) - - def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None: """Дописывает в контекст стандартный хвост: визиты и время выполнения.""" to_template.update({ @@ -54,7 +49,7 @@ def catalog_seria(request: HttpRequest) -> HttpResponse: 'ID': row['id'], 'URL': row['sURL2IMG'], 'NAME': row['sName'], - 'NAME_T': _make_slug(row['sName']), + 'NAME_T': sanitize_slug(row['sName']), } for row in q_seria ] @@ -87,8 +82,8 @@ def catalog_seria_info( try: seria_id = int(seria_id) q_seria = Seria_Info.objects.only("id", "kRoot_id", "sName").get(id=seria_id) - if q_seria.id != q_seria.kRoot_id or seria_name_translit != pytils.translit.slugify(q_seria.sName): - return redirect(f"/catalog/seria/{pytils.translit.slugify(q_seria.sName)}/all{seria_id}") + if q_seria.id != q_seria.kRoot_id or seria_name_translit != sanitize_slug(q_seria.sName): + return redirect(f"/catalog/seria/{sanitize_slug(q_seria.sName)}/all{seria_id}") except (ObjectDoesNotExist, ValueError): return redirect("/catalog/") @@ -295,7 +290,7 @@ def all_seria_nav(seria_id: int, q_seria) -> tuple[int, dict]: one_seria = { "SERIA_R": seria_name, "ID2URL": seria_id_value, - "SERIA_L": pytils.translit.slugify(seria_name), + "SERIA_L": sanitize_slug(seria_name), } if seria_id_value == seria_id: # Изображение серии: используется в OG-image в шаблоне seria_info @@ -309,7 +304,7 @@ def all_seria_nav(seria_id: int, q_seria) -> tuple[int, dict]: "THIS_SERIA_DESCRIPTION": seria_description, # ID и slug серии нужны для canonical URL и JSON-LD в шаблоне "THIS_SERIA_ID": seria_id_value, - "THIS_SERIA_NAME_T": pytils.translit.slugify(seria_name), + "THIS_SERIA_NAME_T": sanitize_slug(seria_name), # URL изображения серии для OG-тегов (путь относительно /media/) "THIS_SERIA_IMAGE_URL": str(seria_image) if seria_image else "", }) @@ -417,7 +412,7 @@ def seria_info_geo_code(seria_id: int | str = DEFAULT_SERIA_ID_FOR_CATALOG) -> d seria_to_geo.append({"LATITUDE": latitude, "LONGITUDE": longitude, "ADDR_ID": count["id"], - "ADDR_LAT": pytils.translit.slugify(count["sAddress"]), + "ADDR_LAT": sanitize_slug(count["sAddress"]), "ADDR_RUS": count["sAddress"], "SER_ID": count["kSeria_Link__kRoot_id"] }) diff --git a/oknardia/web/management/commands/generate_sitemaps.py b/oknardia/web/management/commands/generate_sitemaps.py index c658a1e..f789688 100644 --- a/oknardia/web/management/commands/generate_sitemaps.py +++ b/oknardia/web/management/commands/generate_sitemaps.py @@ -36,8 +36,7 @@ from oknardia.models import ( SetKit, Win_MountDim, ) -import pytils - +from web.add_func import sanitize_slug # Namespace схемы sitemap.xml по стандарту sitemaps.org. SITEMAP_XMLNS = "http://www.sitemaps.org/schemas/sitemap/0.9" @@ -138,7 +137,7 @@ class BuildingOffersSitemap(Sitemap): 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)) + yield (building.id, apart_id, sanitize_slug(building.sAddress)) def location(self, item: tuple[int, int, str]) -> str: build_id, apart_id, address_slug = item @@ -147,7 +146,7 @@ class BuildingOffersSitemap(Sitemap): building = Building_Info.objects.select_related('kSeria_Link__kRoot').get(id=build_id) seria = building.kSeria_Link.kRoot seria_id = seria.id - seria_slug = pytils.translit.slugify((seria.sName or "").strip()).lower() + seria_slug = sanitize_slug((seria.sName or "")) except Exception: # fallback на старый роутинг, если что-то пошло не так return f"/{build_id}/{apart_id}/{address_slug}" @@ -250,7 +249,7 @@ class BlogPostSitemap(Sitemap): ).only("id", "sPostHeader", "dPostDataModify") def location(self, item: BlogPosts) -> str: - return f"/blogpost/{item.id}/{pytils.translit.slugify(item.sPostHeader).lower()}" + return f"/blogpost/{item.id}/{sanitize_slug(item.sPostHeader)}" def lastmod(self, item: BlogPosts) -> date | datetime | None: return item.dPostDataModify @@ -270,7 +269,7 @@ class ProfileManufactureSitemap(Sitemap): ) def location(self, item: dict) -> str: - manufacturer_slug = pytils.translit.slugify(item["sProfileManufacturer"]).lower() + manufacturer_slug = sanitize_slug(item["sProfileManufacturer"]) return f"/catalog/profile/{item['first_id']}-{manufacturer_slug}" def lastmod(self, item: dict) -> date | datetime | None: @@ -287,8 +286,8 @@ class ProfileModelSitemap(Sitemap): 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() + manufacturer_slug = sanitize_slug(item.sProfileManufacturer) + model_slug = sanitize_slug(item.sProfileName) # Исторически канонический URL использует id модели и в сегменте manufacturer_id, и в segment model_id. return f"/catalog/profile/{item.id}-{manufacturer_slug}/{item.id}-{model_slug}" @@ -308,7 +307,7 @@ class SeriaDetailSitemap(Sitemap): ) def location(self, item: Seria_Info) -> str: - return f"/catalog/seria/{pytils.translit.slugify(item.sName).lower()}/all{item.id}" + return f"/catalog/seria/{sanitize_slug(item.sName)}/all{item.id}" def lastmod(self, item: Seria_Info) -> date | datetime | None: return item.dSeriaInfoModify @@ -329,7 +328,7 @@ class CompanyDetailSitemap(Sitemap): ) def location(self, item: MerchantBrand) -> str: - return f"/catalog/company/{item.id}-{pytils.translit.slugify(item.sMerchantName).lower()}" + return f"/catalog/company/{item.id}-{sanitize_slug(item.sMerchantName)}" def lastmod(self, item: MerchantBrand) -> date | datetime | None: return getattr(item, "last_offer_modify", None) or getattr(item, "last_office_modify", None) diff --git a/oknardia/web/management/commands/regenerate_seria_prerender.py b/oknardia/web/management/commands/regenerate_seria_prerender.py index 741c572..df25e2b 100644 --- a/oknardia/web/management/commands/regenerate_seria_prerender.py +++ b/oknardia/web/management/commands/regenerate_seria_prerender.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -import pytils from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db.models import F @@ -11,6 +10,7 @@ from django.test import RequestFactory from oknardia.models import Seria_Info from web import catalog_series +from web.add_func import sanitize_slug class Command(BaseCommand): @@ -76,7 +76,7 @@ class Command(BaseCommand): if target_file.exists(): target_file.unlink() - slug = pytils.translit.slugify(seria.sName) + slug = sanitize_slug(seria.sName) request = request_factory.get(f"/catalog/seria/{slug}/all{seria.id}") # В команде принудительно включаем «production-mode» для вьюхи, diff --git a/oknardia/web/prices.py b/oknardia/web/prices.py index 57e0ce6..837abd3 100644 --- a/oknardia/web/prices.py +++ b/oknardia/web/prices.py @@ -15,8 +15,8 @@ from oknardia.models import ( ) from oknardia.settings import * from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list -from web.add_func import normalize, get_rating_set_for_stars, get_flaps_for_big_pictures, get_flaps_for_mini_pictures, \ - get_geo_distance +from web.add_func import get_rating_set_for_stars, get_flaps_for_big_pictures, get_flaps_for_mini_pictures, \ + get_geo_distance, sanitize_slug import django.utils.dateformat import time import os @@ -26,11 +26,6 @@ from types import SimpleNamespace import pytils -def _slugify_lower(value: str | None) -> str: - """Транслитерирует строку в slug и всегда приводит к нижнему регистру.""" - return pytils.translit.slugify((value or "").strip()).lower() - - def _one_win_price_canonical_path(win_width_mm: int | str, win_height_mm: int | str, win_id: int | str) -> str: """Возвращает канонический путь страницы цен для одного типового окна.""" return f"/catalog/standard_opening/price-{int(win_width_mm)}x{int(win_height_mm)}mm-tip{int(win_id)}/" @@ -271,9 +266,9 @@ def report_price_frame(apartment_id: int, mount_dim_per_offer: int, address_long 'GLAZING_TONING': offer.sGlazingToning, 'PVC_ID': offer.pwc_id, 'PVC_NAME': offer.sProfileName, - 'PVC_NAME_T': _slugify_lower(offer.sProfileName), + 'PVC_NAME_T': sanitize_slug(offer.sProfileName), 'PVC_MANUFACTURER': offer.sProfileManufacturer, - 'PVC_MANUFACTURER_T': _slugify_lower(offer.sProfileManufacturer), + 'PVC_MANUFACTURER_T': sanitize_slug(offer.sProfileManufacturer), 'PVC_SEAL': offer.sProfileSealDescription, 'SETS_CLIMATE_CONTROL': offer.sSetClimateControl, 'SETS_SILL': offer.sSetSill, @@ -530,9 +525,9 @@ def report_price_frame(apartment_id: int, mount_dim_per_offer: int, address_long 'GLAZING_TONING': i2.sGlazingToning, 'PVC_ID': i2.pwc_id, 'PVC_NAME': i2.sProfileName, - 'PVC_NAME_T': _slugify_lower(i2.sProfileName), + 'PVC_NAME_T': sanitize_slug(i2.sProfileName), 'PVC_MANUFACTURER': i2.sProfileManufacturer, - 'PVC_MANUFACTURER_T': _slugify_lower(i2.sProfileManufacturer), + 'PVC_MANUFACTURER_T': sanitize_slug(i2.sProfileManufacturer), 'PVC_SEAL': i2.sProfileSealDescription, 'SETS_CLIMATE_CONTROL': i2.sSetClimateControl, 'SETS_SILL': i2.sSetSill, @@ -710,7 +705,7 @@ def report_one_win_price(request: HttpRequest, list_seria_for_win.append(SimpleNamespace( id=seria_item['kApartment__kSeria__id'], sName=seria_name, - sNameLat=_slugify_lower(seria_name), + sNameLat=sanitize_slug(seria_name), num_variation_of_apartment=pytils.numeral.sum_string( seria_item['num_variation_of_apartment'], pytils.numeral.MALE, @@ -793,7 +788,7 @@ def report_price(request: HttpRequest, build_id: str = "22427", apart_id: str = # если кто-то нахимичит ID квартиры не для этого дома, то сделаем так, что он будет от этого дома! apart_inside = any(ap.id == apart_id for ap in list_apart) - address_slug = _slugify_lower(building.sAddress) + address_slug = sanitize_slug(building.sAddress) if not apart_inside or slug != address_slug: # Переадресация 302, если с apart_id (ID-квартиры нахимичили) или slug-ом. # Нужно для склейки парных URL в поисковиках @@ -867,7 +862,7 @@ def report_price(request: HttpRequest, build_id: str = "22427", apart_id: str = # узнаем базовую серию дома q_base_seria = building.kSeria_Link.kRoot - base_seria_slug = _slugify_lower(q_base_seria.sName) + base_seria_slug = sanitize_slug(q_base_seria.sName) to_template.update({'BASE_SERIA': q_base_seria.sName, 'BASE_SERIA_LAT': base_seria_slug, 'BASE_SERIA_ID': q_base_seria.id}) @@ -1015,8 +1010,8 @@ def report_price_new(request, seria_id, seria_slug, apart_id, address_id, addres except Exception: return redirect("/") # Проверяем slug'и, если не совпадает — делаем 301 на канонический URL (новый формат) - seria_slug_real = pytils.translit.slugify((seria.sName or "").strip()).lower() - address_slug_real = pytils.translit.slugify((building.sAddress or "").strip()).lower() + seria_slug_real = sanitize_slug((seria.sName or "").strip()).lower() + address_slug_real = sanitize_slug((building.sAddress or "").strip()).lower() if seria_slug != seria_slug_real or address_slug != address_slug_real: # Новый формат: /price/seriaID--/appartAD/addressID--/ return redirect(f"/price/seriaID{seria_id}--{seria_slug_real}/appartID{apart_id}/addressID{address_id}--{address_slug_real}/", permanent=True) @@ -1037,10 +1032,7 @@ def report_price_legacy_redirect(request, build_id, apart_id, slug): except Exception: return redirect("/") import pytils - seria_slug = pytils.translit.slugify((seria.sName or "").strip()).lower() - address_slug = pytils.translit.slugify((building.sAddress or "").strip()).lower() + seria_slug = sanitize_slug((seria.sName or "").strip()).lower() + address_slug = sanitize_slug((building.sAddress or "").strip()).lower() # Новый формат: /price/seriaID--/appartID/addressID--/ return redirect(f"/price/seriaID{seria.id}--{seria_slug}/appartID{apart_id}/addressID{build_id}--{address_slug}/", permanent=True) - seria_slug = pytils.translit.slugify((seria.sName or "").strip()).lower() - address_slug = pytils.translit.slugify((building.sAddress or "").strip()).lower() - return redirect(f"/price/seriaID{seria.id}--{seria_slug}/appartID{apart_id}/addressID{build_id}--{address_slug}/", permanent=True) diff --git a/oknardia/web/report1.py b/oknardia/web/report1.py index 5818843..14a3b99 100644 --- a/oknardia/web/report1.py +++ b/oknardia/web/report1.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.db.models import F, Q, ExpressionWrapper, BooleanField, Max, Count, Avg from oknardia.models import LogVisitPriceReport, SetKit from oknardia.settings import * -from web.add_func import normalize, get_rating_set_for_stars, sum_through +from web.add_func import normalize, get_rating_set_for_stars, sum_through, sanitize_slug # from time import time import django.utils.dateformat import time @@ -290,7 +290,7 @@ def compare_offers(request: HttpRequest, to_compare: str = "1,2") -> HttpRespons "MERCHANT": i.sMerchantName, "MERCHANT_ID": i.MERCHANT_ID, "IS_COMMERCIAL": i.bCommercial, - "MERCHANT_T": pytils.translit.slugify(i.sMerchantName), + "MERCHANT_T": sanitize_slug(i.sMerchantName), "MERCHANT_URL": i.sMerchantMainURL, "MERCHANT_URL_SHOT": re.sub(r"(?:^https?://|/$|www\.)", "", i.sMerchantMainURL), "SET_NAME": i.sSetName, @@ -300,9 +300,9 @@ def compare_offers(request: HttpRequest, to_compare: str = "1,2") -> HttpRespons "RATING_SET_COLOR": rating_set_color, "PROFILE_ID": i.PROFILE_ID, "PROFILE_NAME": i.sProfileName, - "PROFILE_NAME_T": pytils.translit.slugify(i.sProfileName), + "PROFILE_NAME_T": sanitize_slug(i.sProfileName), "PROFILE_MANUFACTURER": i.sProfileManufacturer, - "PROFILE_MANUFACTURER_T": pytils.translit.slugify(i.sProfileManufacturer), + "PROFILE_MANUFACTURER_T": sanitize_slug(i.sProfileManufacturer), "PROFILE_NUM_COLOR": i.sProfileColor, "PROFILE_NUM_CAMERAS": i.iProfileCameras, # Число камер рамы/створки "PROFILE_NUM_CAMERAS_COLOR": _color_hi(profile_num_cameras, min_cameras, max_cameras, threshold=1), diff --git a/oknardia/web/report2.py b/oknardia/web/report2.py index 9a4515b..aa87ea6 100644 --- a/oknardia/web/report2.py +++ b/oknardia/web/report2.py @@ -3,7 +3,7 @@ from django.shortcuts import render, redirect from django.http import HttpRequest, HttpResponse from oknardia.models import PVCprofiles from oknardia.settings import * -from web.add_func import normalize, get_rating_set_for_stars +from web.add_func import normalize, get_rating_set_for_stars, sanitize_slug from time import time import json import pytils @@ -73,9 +73,9 @@ def profiles_rating(request: HttpRequest) -> HttpResponse: "ID": profile.id, "R_REAL": rating_real, "BRAND": profile.sProfileManufacturer, - "BRAND_URL": pytils.translit.slugify(profile.sProfileManufacturer), + "BRAND_URL": sanitize_slug(profile.sProfileManufacturer), "NAME": profile.sProfileName, - "NAME_URL": pytils.translit.slugify(profile.sProfileName), + "NAME_URL": sanitize_slug(profile.sProfileName), "K_ARR": k_arr, "RATING_STAR": get_rating_set_for_stars(profile.fProfileRating), "RATING_N": profile.fProfileRating, diff --git a/oknardia/web/views.py b/oknardia/web/views.py index edfe6e3..62e9a69 100644 --- a/oknardia/web/views.py +++ b/oknardia/web/views.py @@ -6,12 +6,8 @@ from django.db.models import ExpressionWrapper, FloatField, F, Count from django.db.models.functions import Abs from smtplib import SMTPException from oknardia.models import Seria_Info, Building_Info, Apartment_Type -from web.add_func import get_yandex_geocode_by_address, get_geo_distance -import json -import datetime +from web.add_func import get_yandex_geocode_by_address, get_geo_distance, sanitize_slug import time -import pytils - # from django.core.context_processors import csrf @@ -240,9 +236,9 @@ def get_address(request: HttpRequest) -> HttpResponse: to_template.update({ 'SERIA_BASE': q1.sName if q1 else "", 'BASE_SERIA_ID': seria_root.id if seria_root else "", - 'BASE_SERIA_LAT': pytils.translit.slugify((seria_root.sName or "").strip()).lower() if seria_root else "", + 'BASE_SERIA_LAT': sanitize_slug((seria_root.sName or "").strip()) if seria_root else "", 'addr': addr, - 'addr_T': pytils.translit.slugify(addr), + 'addr_T': sanitize_slug(addr), 'ticks': float(time.perf_counter() - time_start), }) return render(request, "popup/popup_show_apartment_variants.html", to_template)