mod: replace legacy joomla links
This commit is contained in:
85
cadpoint/web/legacy_links.py
Normal file
85
cadpoint/web/legacy_links.py
Normal file
@@ -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<url>(?:^|(?<=["\'\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<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('newsflash', r'(?:news/)?3-newsflash/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('publication-hardware', r'publication/32-hardware/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('publication-interview', r'publication/39-interview/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('runet-cad', r'runet-cad/37-runet-cad/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('mcad', r'section-blog/28-mcad/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('video', r'video/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('privat-blog', r'blogs/35-privat-blog/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('cad-company-feeds', r'cad-company-feeds/40-cad-company-feeds/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('component-article', r'component/content/article/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('categoryblog', r'categoryblog/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('category-table', r'category-table/(?P<content_id>\d+)(?:-[^"\'<>\s]*)?(?:\.html)?'),
|
||||
_compile_legacy_pattern('aboutcadpoint', r'aboutcadpoint\.html/(?P<content_id>\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
|
||||
|
||||
2
cadpoint/web/management/__init__.py
Normal file
2
cadpoint/web/management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Пакет management-команд для приложения web."""
|
||||
|
||||
2
cadpoint/web/management/commands/__init__.py
Normal file
2
cadpoint/web/management/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Набор management-команд для приложения web."""
|
||||
|
||||
85
cadpoint/web/management/commands/replace_legacy_links.py
Normal file
85
cadpoint/web/management/commands/replace_legacy_links.py
Normal file
@@ -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.'))
|
||||
|
||||
@@ -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 = (
|
||||
'<a href="/news/1-latest-news/123-old-title.html">link</a>'
|
||||
'<a href="http://www.cadpoint.ru/component/content/article/456-some-article.html">x</a>'
|
||||
'<a href="https://example.com/news/1-latest-news/123-old-title.html">external</a>'
|
||||
)
|
||||
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 = (
|
||||
'<a href="/news/1-latest-news/123-old-title.html">link</a>'
|
||||
'<img src="/images/stories/news/photo123.jpg" alt="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.
|
||||
|
||||
Reference in New Issue
Block a user