import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const articlesPath = join(webRoot, 'data', 'articles.json'); function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '

' + text + '

'; } function heading(text) { return '

' + text + '

'; } function codeBlock(lines) { return '
' + escapeHtml(lines.join('\n')) + '
'; } function figure(src, alt, caption) { return '
' + alt + '
' + caption + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function bulletList(items) { return ''; } function dataTable(headers, rows) { const head = '' + headers.map((header) => '' + header + '').join('') + ''; const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; return '
' + head + body + '
'; } function sourceList(items) { return ''; } function textFromHtml(html) { return html .replace(/<[^>]*>/g, ' ') .replaceAll(' ', ' ') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replace(/\s+/g, ' ') .trim(); } const translit = { title: 'Bitrix: CUtil::translit', url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php', note: 'параметры нормализации строки: регистр, замена пробелов и повторяющихся разделителей', }; const addElement = { title: 'Bitrix: CIBlockElement::Add', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y', note: 'создание элемента, поле CODE, возвращаемый ID и LAST_ERROR при ошибке', }; const getList = { title: 'Bitrix: CIBlockElement::GetList', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y', note: 'выборка элементов по фильтрам IBLOCK_ID, CODE, ACTIVE и с заданным порядком', }; const updateElement = { title: 'Bitrix: CIBlockElement::Update', url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y', note: 'изменение полей существующего элемента и результат операции', }; const parseComponentPath = { title: 'Bitrix: CComponentEngine::ParseComponentPath', url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php', note: 'разбор ЧПУ-пути по шаблонам и восстановление переменных компонента', }; const makePathFromTemplate = { title: 'Bitrix: CComponentEngine::MakePathFromTemplate', url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/makepathfromtemplate.php', note: 'подстановка значений массива в маркеры URL-шаблона', }; const drafts = [ { slug: 'editorial-2018-04-practice-bitrix-slugs', title: 'Bitrix API. Символьный код: как не получить два одинаковых адреса', categories: ['Bitrix', 'PHP', 'Практика'], cover: '/assets/editorial/2018/bitrix-slug-build-2018.svg', excerpt: 'Собираем символьный код элемента из имени, проверяем занятость в нужном инфоблоке и разбираем границу, за которой простой суффикс перестаёт быть защитой.', readingMinutes: 10, sources: [translit, getList, addElement], bodyHtml: [ paragraph('Добавляем товар в Bitrix и берём CODE из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех? Цена ошибки — неверная карточка, потерянная ссылка и ручная чистка дублей.'), paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'), heading('Что даёт системный транслит'), paragraph('В Bitrix для этой задачи есть CUtil::translit. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'), paragraph('Но нормализация не делает два разных названия разными. «Кофе Classic 250 г», «Кофе Classic-250 г» и «Кофе Classic 250 г» вполне могут прийти к одному кандидату. Это не ошибка CUtil::translit. Функция честно выполнила свою работу: привела вход к одному виду. Сравнивать и разрешать конфликт должен вызывающий код.'), figure('/assets/editorial/2018/bitrix-slug-build-2018.svg', 'Схема построения символьного кода: имя, транслитерация, проверка через GetList, суффикс или создание элемента', 'Транслит формирует кандидата. Решение о свободном коде появляется только после проверки в нужном инфоблоке.'), heading('Минимальный контракт'), paragraph('Для одного каталога достаточно договориться о нескольких вещах до написания функции. Они не привязаны к шаблону страницы и не требуют большой переделки. Зато по ним сразу видно, почему повторный импорт изменил адрес или почему карточка попала не в тот раздел.'), dataTable( ['Шаг', 'Что считаем результатом', 'Что проверяем'], [ ['Имя', 'Есть непустое название', 'Не передаём в транслит пустую строку и не придумываем код из ID молча'], ['Нормализация', 'Один предсказуемый кандидат', 'Регистр, дефис, длина и повторяющиеся разделители заданы явно'], ['Поиск', 'Нет элемента с тем же CODE', 'Ищем внутри конкретного IBLOCK_ID, а не по всему сайту'], ['Сохранение', 'Метод Add вернул ID', 'При ошибке сохраняем LAST_ERROR и исходное имя'], ['Проверка ссылки', 'Каталог находит именно эту запись', 'Сверяем URL-шаблон и фильтр детального компонента'], ], ), heading('Воспроизводимый пример'), paragraph('Ниже функция для последовательного добавления из админки или небольшого импорта. Число 50 здесь не ограничение Bitrix, а мой предел для понятной ошибки: если за пятьдесят попыток не найден свободный вариант, лучше остановиться и посмотреть на входные данные. В реальном проекте ID инфоблока и правило суффикса стоит вынести в конфигурацию.'), codeBlock([ ' 90,', ' "change_case" => "L",', ' "replace_space" => "-",', ' "replace_other" => "-",', ' "delete_repeat_replace" => true,', ' ));', '', ' $base = trim($base, "-");', ' if ($base === "") {', ' throw new InvalidArgumentException("Не удалось получить CODE из NAME");', ' }', '', ' for ($number = 1; $number <= 50; $number++) {', ' $candidate = $number === 1 ? $base : $base . "-" . $number;', ' $result = CIBlockElement::GetList(', ' array(),', ' array("IBLOCK_ID" => (int)$iblockId, "=CODE" => $candidate),', ' false,', ' array("nTopCount" => 1),', ' array("ID")', ' );', '', ' if (!$result->Fetch()) {', ' return $candidate;', ' }', ' }', '', ' throw new RuntimeException("Не найден свободный CODE за 50 попыток");', '}', ]), paragraph('Знак = в фильтре делает намерение явным: мы ищем конкретный код, а не похожую строку. В выборку достаточно взять ID; имя, картинка и свойства для решения о занятости не нужны. Это маленькая деталь, но она не даёт диагностическому запросу превращаться в выборку всего каталога.'), heading('Сохраняем код вместе с элементом'), paragraph('После проверки не нужно делать отдельный Update ради CODE. Документация CIBlockElement::Add допускает поле CODE в массиве полей. Добавляю его в тот же вызов и обязательно разбираю ошибку. Возвращённый ID доказывает запись, но ещё не доказывает, что путь компонента совпадает с проектным URL.'), codeBlock([ 'Add(array(', ' "IBLOCK_ID" => 12,', ' "NAME" => $name,', ' "CODE" => getFreeElementCode(12, $name),', ' "ACTIVE" => "N",', '));', '', 'if ($id === false) {', ' throw new RuntimeException($element->LAST_ERROR);', '}', '', '// Публикуем только после проверки обязательных данных и ссылки.', ]), heading('Последовательность проверки'), orderedList([ 'Взять два названия, которые различаются только знаками и пробелами, и получить для них кандидаты.', 'Создать первый элемент на тестовом инфоблоке с исходным кандидатом.', 'Запустить функцию для второго имени и убедиться, что она вернула суффикс, а не прежний код.', 'Прочитать оба элемента через CIBlockElement::GetList с тем же IBLOCK_ID.', 'Открыть детальные страницы и сверить ID в шаблоне или временном логе. Так мы проверяем не только данные, но и используемый компонентом маршрут.', ]), heading('Граница этого решения'), paragraph('Проверка «сначала GetList, потом Add» не является атомарной. Два параллельных воркера могут одновременно увидеть свободный код и попытаться сохранить одинаковое значение. Для ручного ввода и последовательного импорта этого обычно достаточно. Для параллельной синхронизации нужен отдельный проектный механизм: очередь, блокировка или код, связанный со стабильным внешним идентификатором. Какой именно — зависит от версии Bitrix, базы и требований к существующим URL.'), paragraph('Не стоит лечить эту задачу случайным числом в каждом коде. Такой адрес перестаёт быть повторяемым при повторном импорте, а диагностика становится сложнее. Если данные поставщика имеют стабильный артикул, полезно заранее решить, будет ли он участвовать в CODE или останется отдельным свойством. Главное — зафиксировать правило до публикации первой тысячи карточек.'), heading('Итог'), paragraph('Символьный код начинается с CUtil::translit, но не заканчивается на нём. Сначала делаем читаемого кандидата, затем проверяем его в нужном инфоблоке, сохраняем результат вместе с элементом и отдельно открываем ссылку. Такой порядок не решает гонку параллельного импорта, зато честно показывает её границу и избавляет от тихих совпадений в обычной работе.'), ].join('\n'), }, { slug: 'editorial-2018-04-mechanism-bitrix-slugs', title: 'Bitrix API. Как адрес каталога превращается в ELEMENT_CODE', categories: ['Bitrix', 'PHP', 'ЧПУ'], cover: '/assets/editorial/2018/bitrix-slug-route-2018.svg', excerpt: 'Разбираем, где ЧПУ-путь становится переменной компонента, почему URL-шаблон не равен запросу к инфоблоку и как проверить связку без гадания по кешу.', readingMinutes: 10, sources: [parseComponentPath, makePathFromTemplate, getList], bodyHtml: [ paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением ELEMENT_CODE? Цена ошибки — менять данные элемента, когда проблема находится в маршруте.'), paragraph('Это полезно отделить в голове. Адрес /catalog/kofe/classic-250-g/ не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка CODE, хотя ошибка сидит в шаблоне или в имени переменной.'), heading('Что делает движок ЧПУ'), paragraph('В документации CComponentEngine::ParseComponentPath описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в ELEMENT_CODE.'), paragraph('Шаблон пишется относительно папки компонента. Например, для папки /catalog/ внутри массива нужен путь #SECTION_CODE#/#ELEMENT_CODE#/, а не полный адрес с начальным слешем. Это не вкусовщина: документация отдельно предупреждает, что лишний слеш в шаблоне меняет результат разбора.'), figure('/assets/editorial/2018/bitrix-slug-route-2018.svg', 'Схема: запрос браузера разбирается SEF-шаблоном, превращается в SECTION_CODE и ELEMENT_CODE, затем используется в выборке', 'Переменная из URL и элемент инфоблока живут на разных шагах. Между ними стоит проектный фильтр компонента.'), heading('Четыре значения, которые должны совпасть'), dataTable( ['Участок', 'Пример', 'Как проверить'], [ ['Папка ЧПУ', '/catalog/', 'Сравнить с SEF_FOLDER вызванного компонента'], ['Шаблон детали', '#SECTION_CODE#/#ELEMENT_CODE#/', 'Проверить отсутствие лишнего начального слеша и нужные маркеры'], ['Переменная', 'ELEMENT_CODE = classic-250-g', 'Вывести массив, полученный после разбора, на тестовой среде'], ['Выборка', 'IBLOCK_ID + CODE + ACTIVE', 'Сравнить фильтр компонента с контрольным GetList'], ['Ссылка в шаблоне', 'Тот же набор маркеров', 'Собрать URL из значений и открыть его вручную'], ], ), heading('Минимальный воспроизводимый разбор'), paragraph('Ниже не готовый комплексный компонент, а короткая проверка его основания. Запускаю её на тестовой странице с известным путём. Если $page не равен detail, до запроса к инфоблоку дело вообще не дошло. Если код страницы найден, но ELEMENT_CODE пуст, виноват шаблон или сам адрес.'), codeBlock([ ' "#SECTION_CODE#/#ELEMENT_CODE#/",', ');', '$arVariables = array();', '', '$page = CComponentEngine::ParseComponentPath(', ' "/catalog/",', ' $arUrlTemplates,', ' $arVariables,', ' "/catalog/kofe/classic-250-g/"', ');', '', 'if ($page !== "detail" || empty($arVariables["ELEMENT_CODE"])) {', ' throw new RuntimeException("URL не разобран как детальная страница");', '}', '', '$result = CIBlockElement::GetList(', ' array(),', ' array(', ' "IBLOCK_ID" => 12,', ' "=CODE" => $arVariables["ELEMENT_CODE"],', ' "ACTIVE" => "Y",', ' ),', ' false,', ' array("nTopCount" => 1),', ' array("ID", "NAME", "CODE")', ');', '', '$element = $result->GetNext();', 'if (!$element) {', ' throw new RuntimeException("URL разобран, но элемент не найден");', '}', ]), paragraph('В примере я специально оставил фильтр небольшим. Реальный каталог может добавить раздел, права, цену, наличие или свойство витрины. Эти условия нельзя угадывать из адреса. Их нужно взять из конкретного компонента и применить в контрольной выборке. Иначе тест будет доказывать только то, что элемент вообще существует, а не то, что его видит пользователь.'), heading('Почему генерация и разбор должны пользоваться одной формой адреса'), paragraph('Метод CComponentEngine::MakePathFromTemplate подставляет значения массива в маркеры шаблона. Это удобная точка для проверки обратного направления: у нас есть SECTION_CODE и ELEMENT_CODE, собираем путь и затем разбираем его тем же шаблоном. Если после такого круга переменная изменилась или пропала, в коде сайта уже есть расхождение.'), codeBlock([ ' "kofe",', ' "ELEMENT_CODE" => "classic-250-g",', ' )', ');', '', '// $url: kofe/classic-250-g/', '// Для ссылки добавляем папку /catalog/ в одном месте проекта.', ]), heading('Последовательность от ссылки до карточки'), orderedList([ 'Взять реальный адрес, который не открывается, и сохранить его без ручной правки.', 'Сверить папку и шаблон детали в параметрах вызванного компонента.', 'На тестовой среде вывести код страницы и массив переменных после ParseComponentPath.', 'Передать полученный ELEMENT_CODE в короткий CIBlockElement::GetList с теми же базовыми фильтрами.', 'Если элемент найден, сравнить с фильтром самого компонента: раздел, активность, права и проектные свойства.', 'Собрать обратную ссылку из тех же маркеров и повторить проверку после изменения шаблона.', ]), heading('Частые расхождения'), dataTable( ['Симптом', 'Где искать', 'Безопасная проверка'], [ ['Страница не определяется', 'Папка ЧПУ или шаблон детали', 'Проверить результат ParseComponentPath до обращения к инфоблоку'], ['Страница определяется, код пуст', 'Маркер отличается от имени, которое ждёт компонент', 'Сравнить ключи массива переменных с параметрами компонента'], ['Код есть, элемента нет', 'CODE, инфоблок, активность или дополнительный фильтр', 'Запустить GetList сначала с базовыми, затем с проектными условиями'], ['Ссылка формируется иначе, чем разбирается', 'Два разных URL-шаблона в шаблоне и компоненте', 'Собрать путь через MakePathFromTemplate и разобрать его обратно'], ], ), heading('Ограничения'), paragraph('Эта диагностика начинается в момент, когда PHP-компонент уже получил запрос. Если веб-сервер или правила перенаправления не передали путь в приложение, ParseComponentPath не сможет это исправить. Тогда проверять нужно предыдущий слой: фактический URI, правило маршрутизации и точку входа сайта. Не стоит менять CODE, пока не доказано, что компонент вообще получил нужную переменную.'), paragraph('Ещё одна ловушка — перенос чужого шаблона без понимания его маркеров. В Bitrix можно назвать переменные по-разному, но компонент и его фильтр должны читать то же имя, которое восстановлено из пути. Я бы не делал универсальную функцию для всех страниц сайта: лучше зафиксировать один шаблон рядом с конкретным каталогом и покрыть его двумя-тремя адресами из реальных данных.'), heading('Итог'), paragraph('ЧПУ — это не «красивый CODE в базе», а связка из папки, шаблона, восстановленных переменных и фильтра элемента. Когда ссылка ведёт в 404, сначала смотрим результат разбора URL, затем выборку. После такой проверки становится видно, нужна ли правка в данных, компоненте или маршруте.'), ].join('\n'), }, { slug: 'editorial-2018-04-field-bitrix-slugs', title: 'Bitrix API. Карточка открывает не тот товар: проверяем конфликт CODE', categories: ['Bitrix', 'PHP', 'Диагностика'], cover: '/assets/editorial/2018/bitrix-slug-conflict-2018.svg', excerpt: 'Полевой разбор ситуации, когда адрес детали показывает другой элемент: считаем совпадения по CODE, сравниваем переменную ЧПУ с фильтром и меняем данные без потери следов.', readingMinutes: 11, sources: [getList, parseComponentPath, updateElement], bodyHtml: [ paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт CODE или широкий фильтр до того, как менять данные? Цена ошибки — исправить правильную запись и получить новый конфликт.'), paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'), heading('Не путать симптом и причину'), paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому ELEMENT_CODE.'), figure('/assets/editorial/2018/bitrix-slug-conflict-2018.svg', 'Дерево диагностики неправильной карточки: путь, переменная ELEMENT_CODE, число совпадений GetList и дальнейшие действия', 'Сначала считаем набор совпадений. Кеш проверяем только после пути и данных.'), heading('Какие данные собрать до исправления'), dataTable( ['Факт', 'Зачем он нужен', 'Как зафиксировать'], [ ['Исходный URL', 'Показывает, что реально запросил браузер', 'Сохранить полный путь из адресной строки или access-лога'], ['Ожидаемый ID', 'Не даёт спорить о том, какая запись считается правильной', 'Взять ID из админки или из результата импорта'], ['ELEMENT_CODE', 'Связывает путь с данными компонента', 'Вывести переменную после разбора ЧПУ на тестовом стенде'], ['Все записи по CODE', 'Отличает один результат от конфликта', 'Сделать ограниченный GetList в том же инфоблоке'], ['Фильтр детали', 'Объясняет, почему часть записей исключена или выбрана', 'Сверить с параметрами и кодом конкретного компонента'], ], ), heading('Контрольная выборка'), paragraph('Документация CIBlockElement::GetList позволяет явно задать сортировку, фильтр, ограничение и набор полей. Для диагностики беру только те поля, которые помогают отличить записи: ID, имя, CODE, основной раздел и шаблон детального URL. Запрос не должен случайно тянуть свойства всего каталога: его задача — показать размер набора и порядок элементов.'), codeBlock([ ' "ASC"),', ' array(', ' "IBLOCK_ID" => (int)$iblockId,', ' "=CODE" => $code,', ' "ACTIVE" => "Y",', ' ),', ' false,', ' array("nTopCount" => 20),', ' array("ID", "NAME", "CODE", "IBLOCK_SECTION_ID", "DETAIL_PAGE_URL")', ' );', '', ' $items = array();', ' while ($item = $result->GetNext()) {', ' $items[] = $item;', ' }', '', ' return $items;', '}', '', '$items = findActiveElementsByCode(12, "classic-250-g");', 'if (count($items) !== 1) {', ' throw new RuntimeException("Нужно разобрать " . count($items) . " совпадений");', '}', ]), paragraph('Сортировка по ID в этом примере нужна не для выбора «правильного» товара, а для повторяемого вывода. Если там два элемента, проблема уже доказана: детальный компонент не должен случайно решать, что меньший ID важнее. Дальше либо сужаем фильтр контекстом раздела, либо исправляем один из кодов по заранее выбранному правилу.'), heading('Как отличить три разных случая'), dataTable( ['Результат проверки', 'Что это значит', 'Следующий шаг'], [ ['0 совпадений', 'URL разобран, но элемент не проходит базовый фильтр', 'Проверить значение переменной, активность, инфоблок и шаблон ссылки'], ['1 совпадение, ID правильный', 'Данные и базовый фильтр совпали', 'Сравнить дополнительные условия компонента и только затем кеш'], ['1 совпадение, ID другой', 'Переменная из URL не соответствует ожидаемому товару', 'Проверить генерацию URL, шаблон и исходный CODE элемента'], ['2 и более совпадений', 'Фильтр недостаточно точный или коды конфликтуют', 'Решить, нужен ли контекст раздела, затем изменить конфликтующие данные'], ], ), heading('Проверяем, что компонент получил из URL'), paragraph('Не нужно угадать имя переменной по шаблону. Комплексный компонент разбирает путь через CComponentEngine::ParseComponentPath и возвращает переменные, восстановленные из маркеров. Для проблемной ссылки полезно на тестовой копии вывести $page и $arVariables. Так видно, не потерялся ли раздел и действительно ли ELEMENT_CODE равен строке из адреса.'), codeBlock([ ' "#SECTION_CODE#/#ELEMENT_CODE#/",', ');', '$variables = array();', '$page = CComponentEngine::ParseComponentPath(', ' "/catalog/",', ' $templates,', ' $variables,', ' "/catalog/kofe/classic-250-g/"', ');', '', 'if ($page !== "detail") {', ' throw new RuntimeException("Не найден шаблон detail");', '}', '', 'error_log(print_r($variables, true));', ]), paragraph('Если в массиве нет SECTION_CODE, а детальный запрос должен учитывать раздел, коды элементов могут быть вполне корректны. Ошибка будет в URL-шаблоне или в логике компонента, который не применяет восстановленную переменную. И наоборот: если переменные верны, а GetList возвращает несколько записей, искать надо в данных и условиях выборки, не в роутинге.'), heading('Исправление без потери истории'), paragraph('Когда конфликт подтверждён, сначала выбираю правило для нового адреса: суффикс, артикул или раздел. Затем сохраняю старый URL и список мест, которые на него ссылаются. Смена CODE меняет адрес, поэтому публикацию лучше выполнять отдельным шагом с проверкой ссылок. Метод CIBlockElement::Update возвращает результат изменения; при ошибке не пропускаем LAST_ERROR.'), codeBlock([ 'Update($duplicateId, array(', ' "CODE" => "classic-250-g-2",', '));', '', 'if (!$updated) {', ' throw new RuntimeException($element->LAST_ERROR);', '}', '', '// После изменения снова выполняем findActiveElementsByCode().', ]), heading('Порядок работы в продовой задаче'), orderedList([ 'Зафиксировать URL, ожидаемый ID и время, когда ошибка наблюдалась.', 'На тестовой копии получить переменные, восстановленные из того же пути.', 'Сделать выборку по фактическому CODE в нужном IBLOCK_ID и посчитать результаты.', 'Сравнить полученные ID с тем, что показывает детальный компонент после его дополнительных фильтров.', 'Если есть конфликт, выбрать новое стабильное правило кода и проверить все старые ссылки, которые важны для проекта.', 'После изменения повторить URL-проверку. Кеш и индекс обновлять только по принятому в проекте порядку, когда данные и маршрут уже доказаны.', ]), heading('Ограничения'), paragraph('Эта заметка не утверждает, что любое совпадение CODE ошибочно. В некоторых каталогах один и тот же код допустим в разных витринах или разделах, и тогда адрес и фильтр обязаны включать этот контекст. Не следует добавлять раздел в запрос автоматически: сначала нужно понять, что именно считает идентичностью текущий компонент.'), paragraph('Также не стоит менять десятки кодов одной SQL-командой. У Bitrix есть API изменения элемента, обработчики событий и проектные зависимости от адресов. Сначала правим один доказанный конфликт на тестовых данных, проверяем маршрут и только потом составляем отдельный план для массовой миграции.'), heading('Итог'), paragraph('Когда адрес открывает не тот товар, удобнее не спорить о кеше, а посчитать факты. URL даёт переменную, переменная даёт набор элементов, набор показывает — это маршрут, фильтр или конфликт данных. После такой проверки изменение CODE становится осознанной операцией, а не попыткой наугад исправить карточку.'), ].join('\n'), }, ]; const archive = JSON.parse(await readFile(articlesPath, 'utf8')); const archiveSlugs = new Set(archive.map((article) => article.slug)); const requiredSlugs = [ 'editorial-2018-04-practice-bitrix-slugs', 'editorial-2018-04-mechanism-bitrix-slugs', 'editorial-2018-04-field-bitrix-slugs', ]; for (const slug of requiredSlugs) { if (!archiveSlugs.has(slug)) { throw new Error('Article not found: ' + slug); } } const reports = drafts.map((draft) => { const bodyLength = textFromHtml(draft.bodyHtml).length; const sourceCount = draft.sources.length; if (bodyLength < 5000 || bodyLength > 15000) { throw new Error('Body length outside 5,000–15,000 characters: ' + draft.slug + ' (' + bodyLength + ')'); } if (sourceCount < 2) { throw new Error('At least two primary sources are required: ' + draft.slug); } if (!draft.bodyHtml.includes('
') || !draft.bodyHtml.includes('') || !draft.bodyHtml.includes('
')) {
    throw new Error('Figure, table or reproducible code is missing: ' + draft.slug);
  }

  return {
    slug: draft.slug,
    bodyCharacters: bodyLength,
    sourceCount,
    hasFigure: true,
    hasTable: true,
    hasCode: true,
  };
});

const revisions = drafts.map((draft) => {
  const { bodyHtml, sources, ...revision } = draft;
  return {
    ...revision,
    contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
  };
});
export { revisions };

if (process.argv.includes('--print-revisions')) {
  console.log(JSON.stringify(revisions, null, 2));
} else if (process.argv.includes('--check')) {
  console.log(JSON.stringify(reports, null, 2));
} else if (process.argv[1]?.endsWith('upgrade-2018-04.mjs')) {
  console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check');
  process.exitCode = 1;
}