Переезд с OpenCart 2 после двадцати лет работы: что лежит в дампе и откуда берутся восемь тысяч редиректов
Разбираем переезд интернет-магазина с OpenCart 2 на Next.js и PostgreSQL. Магазин оптовый, электронные компоненты, сайту двадцать лет, в каталоге 4 751 товар. Если у вас OpenCart и вы думаете уезжать, то будет полезно почитать, что может всплыть в самый неожиданный момент.
Магазин запущен в боевом режиме, поэтому всё ниже про то, что уже перенесено и работает. Про трафик после переключения домена говорить рано, замера ещё нет. Обмен с учётной системой - отдельная большая тема, и здесь его нет.
Что за магазин и почему уезжали
OpenCart 2, двадцать лет жизни. Главных болей две: слабый параметрический поиск и админка, в которой неудобно работать с каталогом. Для радиодеталей первое критично: покупатель ищет не по названию, а по параметрам вроде ёмкости, напряжения и корпуса.
Объём такой: 4 751 товар, 115 разных атрибутов, в среднем пять на товар и до семнадцати у самых подробных. Картинки есть у 58% позиций, PDF-даташиты у 49%. Вместе с остальным медиа это около 10 ГБ на хостинге заказчика.
Сам по себе объём небольшой, PostgreSQL его не заметит. Сложность в другом: всё, что двадцать лет поддерживалось руками, живёт в базе OpenCart в своём формате, и забирать это придётся из дампа: API до этих данных не доходит.
Дамп вместо API
У OpenCart нет удобного экспорта того, что нам нужно: страниц производителей с логотипами и описаниями, новостей, старых адресов. Поэтому источник - обычный mysqldump.
Формат у него предсказуемый: каждая строка INSERT лежит на своей строке файла, поля разделены «,\t». Для разовой миграции этого достаточно, полноценный SQL-парсер не нужен, хватает прохода по строкам от INSERT INTO до строки, которая кончается точкой с запятой.
Первая неприятность вылезла на текстах.
Двойное экранирование
Часть текстов в базе экранирована дважды. В описании производителя лежит не « », а « ». Один проход декодера оставляет в тексте мусор, который на витрине выглядит как символы HTML посреди слова.
Решение - декодировать два раза. Но порядок замен внутри прохода важен (код из проекта, сокращён):
// «&» раскрывается ПЕРВЫМ: иначе « » превратится в « »
// уже после того, как замена « » отработала, и пробел останется в тексте.
const decodeOnce = s => s
.replace(/&/g, '&')
.replace(/</g, '<').replace(/>/g, '>')
.replace(/ /g, ' ').replace(/"/g, '"')
.replace(/«/g, '«').replace(/»/g, '»')
.replace(/'/g, "'")
const unescapeHtml = s => decodeOnce(decodeOnce(s))
Если поставить & в конец цепочки, один проход даст « », и второй его уже не увидит как двойное экранирование. В зависимости от порядка остальных замен неразрывный пробел либо останется сущностью, либо превратится в пробел только местами. На паре сотен описаний такое не ловится глазами.
Производители: контент в одном месте, написание в другом
Производителей 488. Всё, что про них поддерживалось вручную (логотип, ссылка на сайт, описание, адрес страницы), лежит в OpenCart. А товары на новом сайте отбираются по полю производителя из учётной системы. Написания там расходятся: «Pol-Sun» на сайте и «POLSUN» в учёте, «ON Bright» и «ON-BRIGHT».
Связывать по нормализованному названию соблазнительно и хрупко: сегодня сопоставилось, завтра в учёте завели новое написание, и производитель на витрине молча остался без товаров. Поэтому у каждого производителя хранится отдельное поле с исходными написаниями из учёта, и отбор товаров идёт по нему, а не по названию, которое видит покупатель.
Правило шире этого проекта: название для людей и ключ для связи - разные поля, даже когда сегодня они совпадают.
Адреса: 7 721 правило и догоняющий патч
Старые адреса товаров у OpenCart строятся из ЧПУ категорий и товара. Новые у нас идут по коду товара из учётной системы. Отсюда карта 301-редиректов на 7 721 правило, например:
/kondensatoryi/chip-kondensatoryi-0402-1/0402-x7r-22uf-10-25v → /product/001037675
В адресе категории стоит -1: OpenCart дописывает числовой суффикс, когда ЧПУ повторяется, и за двадцать лет таких суффиксов накапливается много. При сопоставлении старых адресов с товарами суффикс надо отрезать, иначе дубль не найдёт свою пару.
Вторая деталь - новые адреса. Часть кодов в учёте начинается с кириллического префикса, и в адресе товара он живёт в процентной кодировке: /product/%D0%AD%D0%9A-00000856. Это рабочий вариант, но его стоит проверить заранее на всей цепочке: генератор карты сайта, кэш, аналитика, логи. Везде, где адрес сравнивается строкой, кодированная и раскодированная формы окажутся разными ключами.
Теперь про то, что в первую карту не вошло. Первые 7 721 правило закрыли товары и категории. Страницы производителей вида /refond остались без сопоставления по простой причине: вести их было некуда, раздела производителей на новом сайте ещё не существовало. Когда раздел построили, вместе с ним выехали ещё 543 правила на /manufacturers/refond.
Отсюда вывод, который стоит сделать до начала работ. У OpenCart ЧПУ всех сущностей лежат в одной отдельной таблице, и её надо выгрузить целиком и разобрать по типам: товары, категории, производители, статьи, информационные страницы. Для каждого типа заранее решается, куда он переедет на новом сайте. Тип без адресата означает недостающий раздел, и узнать о нём лучше из таблицы, чем из поиска.
Медиа: 5 149 файлов и кириллица в путях
Картинки и даташиты лежат в /image/catalog/. В список на перенос попало 5 149 файлов, раскладка по папкам с датой: ГГГГММДД/файл.
Качать такой объём имеет смысл только с докачкой: уже скачанное пропускается, упавшее перезапускается без повтора всего списка. Вторая деталь - пути. В именах файлов встречаются пробелы и кириллица, и кодировать нужно каждый сегмент пути отдельно, сохраняя слэши между ними:
from urllib.parse import quote
def build_url(base, rel):
# каждый сегмент отдельно: пробелы -> %20, кириллица -> %XX,
# а слэши между сегментами остаются слэшами
safe = "/".join(quote(seg) for seg in rel.lstrip("/").split("/"))
return base.rstrip("/") + "/" + safe
Если закодировать путь целиком, слэши превратятся в %2F, и сервер отдаст 404 на каждый файл во вложенной папке.
Новости: переносить не всё
На старом сайте 330 новостей за двадцать лет. Перенесли 87, отбирал заказчик по таблице с заголовками и датами, которую мы собрали из дампа.
По умолчанию переезд хочется сделать полным. Но новость о событии десятилетней давности на новом сайте работает против него: покупатель видит её рядом с актуальным каталогом и делает выводы о магазине. Переезд - редкий момент, когда устаревшее можно не тащить, и заказчик этим воспользовался.
Грабля дня переключения: 301 съедает тело запроса
Эта грабля случилась в сам день переключения.
После переключения сайт отправлял заказы во внешнюю систему заказчика вебхуком. Заказы там появлялись, но пустые: ни покупателя, ни телефона, сумма ноль. Причина оказалась в адресе вебхука. В настройках был записан адрес, который после переезда начал отвечать 301, типовой случай, когда записан http://, а сервер переводит на https://.
При 301 клиент повторяет запрос по новому адресу уже методом GET, и тело теряется. А строка запроса сохраняется. Поэтому действие распознавалось, заказ заводился, но данных в нём не было.
Мы не стали доверять гипотезе и воспроизвели: подняли адрес, отвечающий переадресацией, отправили на него POST с телом 121 байт, до конечного адреса дошёл GET с телом 0 байт.
Проверяется одной командой с сервера: если первая строка ответа на запрос к адресу вебхука HTTP/1.1 301 или 302, причина ваша, а правильный адрес будет в заголовке Location. Лечится записью конечного адреса. Если переадресация нужна по сути, используют 307 или 308, они сохраняют метод и тело.
Это не особенности одного магазина
Насколько этот магазин типичен, мы проверили на данных: в обходе зоны .ru вручную разобрали 191 магазин на OpenCart.
Живых торгующих магазинов среди размеченных - 131 из 152, около 86%. Для сравнения, в похожей ручной проверке у WooCommerce годных было 33%: под WordPress стоит слишком много блогов и медиа. OpenCart ставят ради каталога, и почти всегда это действительно магазин.
Самая частая тема - стандартная default, у 33 магазинов. Следом unishop2 у 22. Остальное рассыпается по десяткам коммерческих тем с одной-пятью установками. Для переезда это значит одно: вёрстку и кастомизации придётся разбирать под каждый магазин, общего шаблона нет.
И учёт. Признак обмена с 1С снаружи нашёлся у 2 магазинов из 179, оба по косвенным следам: GUID-имена картинок в папке catalog/1c/ и скрипт темы, который чистит описания «если 1С выгрузила их». Учёт у таких магазинов почти наверняка есть, просто снаружи его не видно. Откуда магазин берёт цены и остатки, можно узнать только у владельца, и спрашивать об этом стоит до оценки переезда.
Что заложить, если уезжаете с OpenCart
Брать данные из дампа базы: всё, что поддерживалось руками, лежит в таблицах, до которых экспорт из админки не доходит.
Декодировать тексты дважды и раскрывать
&первым. Проверить на выборке описаний до переноса всего каталога.Строить карту адресов по таблице ЧПУ целиком, со всеми типами сущностей, и отрезать числовые суффиксы дублей.
Связывать товары с учётом по отдельному полю исходных написаний, название для покупателя для этого не годится.
Качать медиа с докачкой и кодировать путь по сегментам.
В день переключения проверить адреса вебхуков и интеграций на
301. Если переадресация нужна, использовать307или308.Устаревший контент отобрать. Целиком переносить только то, что до сих пор работает.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.