diff --git a/cadpoint/web/legacy_links.py b/cadpoint/web/legacy_links.py new file mode 100644 index 0000000..186b6b2 --- /dev/null +++ b/cadpoint/web/legacy_links.py @@ -0,0 +1,85 @@ +"""Утилиты для поиска и замены старых Joomla-ссылок в HTML-контенте.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Iterable + + +@dataclass(frozen=True) +class LegacyLinkMatch: + """Результат найденной старой ссылки.""" + + pattern_name: str + content_id: int + old_url: str + new_url: str + + +def _compile_legacy_pattern(name: str, route_regex: str) -> tuple[str, re.Pattern[str]]: + """Компилирует паттерн только для внутренних ссылок CADpoint.""" + # Важно: регулярка ловит только внутренние маршруты сайта, а не внешние + # URL и не ссылки на картинки/медиа. + pattern = re.compile( + rf'(?P(?:^|(?<=["\'\s>]))(?:https?://(?:www\.)?cadpoint\.ru)?/?{route_regex})', + re.IGNORECASE, + ) + return name, pattern + + +LEGACY_ROUTE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + _compile_legacy_pattern('latest-news', r'(?:news/)?1-latest-news/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('newsflash', r'(?:news/)?3-newsflash/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('publication-hardware', r'publication/32-hardware/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('publication-interview', r'publication/39-interview/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('runet-cad', r'runet-cad/37-runet-cad/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('mcad', r'section-blog/28-mcad/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('video', r'video/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('privat-blog', r'blogs/35-privat-blog/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('cad-company-feeds', r'cad-company-feeds/40-cad-company-feeds/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('component-article', r'component/content/article/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('categoryblog', r'categoryblog/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('category-table', r'category-table/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), + _compile_legacy_pattern('aboutcadpoint', r'aboutcadpoint\.html/(?P\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'), +) + + +def build_canonical_url(content_id: int, slug: str) -> str: + """Строит текущий канонический URL контента.""" + safe_slug = slug.strip().strip('/') + if safe_slug: + return f'/item/{content_id}-{safe_slug}' + return f'/item/{content_id}-' + + +def replace_legacy_links(text: str, content_by_id: dict[int, str]) -> tuple[str, list[LegacyLinkMatch]]: + """Заменяет все старые Joomla-ссылки в тексте на текущий канонический URL. + + Возвращает обновлённый текст и список найденных замен. + """ + # По каждому шаблону делаем отдельный проход, чтобы сохранить понятную + # диагностику: какой именно legacy-шаблон и какой `content_id` сработал. + matches: list[LegacyLinkMatch] = [] + result = text + + for pattern_name, pattern in LEGACY_ROUTE_PATTERNS: + def _repl(match: re.Match[str]) -> str: + content_id = int(match.group('content_id')) + old_url = match.group('url') + slug = content_by_id.get(content_id, '') + new_url = build_canonical_url(content_id, slug) + matches.append(LegacyLinkMatch(pattern_name, content_id, old_url, new_url)) + return new_url + + result = pattern.sub(_repl, result) + + return result, matches + + +def iter_legacy_link_matches(text: str) -> Iterable[LegacyLinkMatch]: + """Находит все старые ссылки в тексте без замены.""" + # Используем тот же механизм, что и при замене, но без сохранения текста. + _, matches = replace_legacy_links(text, {}) + return matches + diff --git a/cadpoint/web/management/__init__.py b/cadpoint/web/management/__init__.py new file mode 100644 index 0000000..a97ced0 --- /dev/null +++ b/cadpoint/web/management/__init__.py @@ -0,0 +1,2 @@ +"""Пакет management-команд для приложения web.""" + diff --git a/cadpoint/web/management/commands/__init__.py b/cadpoint/web/management/commands/__init__.py new file mode 100644 index 0000000..954e42e --- /dev/null +++ b/cadpoint/web/management/commands/__init__.py @@ -0,0 +1,2 @@ +"""Набор management-команд для приложения web.""" + diff --git a/cadpoint/web/management/commands/replace_legacy_links.py b/cadpoint/web/management/commands/replace_legacy_links.py new file mode 100644 index 0000000..7dad3e5 --- /dev/null +++ b/cadpoint/web/management/commands/replace_legacy_links.py @@ -0,0 +1,85 @@ +"""Mass-замена старых Joomla-кросс-ссылок в HTML-контенте. + +Пока команда чинит только внутренние ссылки на статьи. Ссылки на картинки и +прочие медиа остаются без изменений. +""" + +from __future__ import annotations + +from collections import Counter +from django.core.management.base import BaseCommand +from django.db import transaction + +from web.legacy_links import iter_legacy_link_matches, replace_legacy_links +from web.models import TbContent + + +class Command(BaseCommand): + help = ( + 'Находит и заменяет старые Joomla-кросс-ссылки в HTML-контенте на ' + 'текущие Django-URL. Ссылки на медиа пока не трогает.' + ) + + def add_arguments(self, parser): + parser.add_argument( + '--apply', + action='store_true', + help='Сохранить изменения в базе. Без флага команда работает в режиме dry-run.', + ) + + def handle(self, *args, **options): + apply_changes = options['apply'] + # Сначала один раз собираем карту `id -> slug`, чтобы не делать лишние + # запросы к базе в цикле по каждому контенту. + content_by_id = dict( + TbContent.objects.values_list('id', 'szContentSlug') + ) + # Обрабатываем только HTML-поля, где реально встречаются старые ссылки. + fields = ('szContentIntro', 'szContentBody', 'szContentHead') + pattern_counter = Counter() + updated_objects = 0 + updated_fields = 0 + + for content in TbContent.objects.all().iterator(): + field_updates: dict[str, str] = {} + object_matches = [] + + for field_name in fields: + text = getattr(content, field_name) or '' + if not text: + continue + # Быстрая проверка: если в тексте нет legacy-ссылок, не тратим + # время на полноценную замену. + if not any(match for match in iter_legacy_link_matches(text)): + continue + + new_text, matches = replace_legacy_links(text, content_by_id) + if matches and new_text != text: + field_updates[field_name] = new_text + object_matches.extend(matches) + pattern_counter.update(match.pattern_name for match in matches) + + if not field_updates: + continue + + updated_objects += 1 + updated_fields += len(field_updates) + self.stdout.write( + f'#{content.pk}: {len(object_matches)} замен(ы) в полях {", ".join(field_updates)}' + ) + for match in object_matches[:5]: + self.stdout.write(f' - {match.pattern_name}: {match.old_url} -> {match.new_url}') + if len(object_matches) > 5: + self.stdout.write(f' ... ещё {len(object_matches) - 5} замен(ы)') + + if apply_changes: + # Записываем только те поля, которые действительно изменились. + with transaction.atomic(): + TbContent.objects.filter(pk=content.pk).update(**field_updates) + + self.stdout.write(self.style.SUCCESS(f'Затронуто объектов: {updated_objects}')) + self.stdout.write(self.style.SUCCESS(f'Затронуто полей: {updated_fields}')) + self.stdout.write(self.style.SUCCESS(f'Сводка по шаблонам: {dict(pattern_counter)}')) + if not apply_changes: + self.stdout.write(self.style.WARNING('Это dry-run. Для записи в БД добавь флаг --apply.')) + diff --git a/cadpoint/web/tests.py b/cadpoint/web/tests.py index 7ce503c..98c8fa1 100644 --- a/cadpoint/web/tests.py +++ b/cadpoint/web/tests.py @@ -1,3 +1,33 @@ -from django.test import TestCase +from django.test import SimpleTestCase + +from web.legacy_links import build_canonical_url, replace_legacy_links + + +class LegacyLinksTests(SimpleTestCase): + def test_build_canonical_url_without_slug(self): + self.assertEqual(build_canonical_url(123, ''), '/item/123-') + + def test_replace_legacy_links_rewrites_internal_urls(self): + text = ( + 'link' + 'x' + 'external' + ) + new_text, matches = replace_legacy_links(text, {123: 'new-title', 456: 'article-title'}) + + self.assertIn('/item/123-new-title', new_text) + self.assertIn('/item/456-article-title', new_text) + self.assertIn('https://example.com/news/1-latest-news/123-old-title.html', new_text) + self.assertEqual(len(matches), 2) + + def test_replace_legacy_links_does_not_touch_image_urls(self): + text = ( + 'link' + 'photo' + ) + new_text, matches = replace_legacy_links(text, {123: 'new-title'}) + + self.assertIn('/item/123-new-title', new_text) + self.assertIn('/images/stories/news/photo123.jpg', new_text) + self.assertEqual(len(matches), 1) -# Create your tests here.