raise editorial quality gate and revise 2018 spring
Build and deploy / deploy (push) Successful in 15s
Build and deploy / deploy (push) Successful in 15s
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
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 '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
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>CODE</code> из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?'),
|
||||
paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'),
|
||||
heading('Что даёт системный транслит'),
|
||||
paragraph('В Bitrix для этой задачи есть <code>CUtil::translit</code>. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'),
|
||||
paragraph('Но нормализация не делает два разных названия разными. «Кофе Classic 250 г», «Кофе Classic-250 г» и «Кофе Classic 250 г» вполне могут прийти к одному кандидату. Это не ошибка <code>CUtil::translit</code>. Функция честно выполнила свою работу: привела вход к одному виду. Сравнивать и разрешать конфликт должен вызывающий код.'),
|
||||
figure('/assets/editorial/2018/bitrix-slug-build-2018.svg', 'Схема построения символьного кода: имя, транслитерация, проверка через GetList, суффикс или создание элемента', 'Транслит формирует кандидата. Решение о свободном коде появляется только после проверки в нужном инфоблоке.'),
|
||||
heading('Минимальный контракт'),
|
||||
paragraph('Для одного каталога достаточно договориться о нескольких вещах до написания функции. Они не привязаны к шаблону страницы и не требуют большой переделки. Зато по ним сразу видно, почему повторный импорт изменил адрес или почему карточка попала не в тот раздел.'),
|
||||
dataTable(
|
||||
['Шаг', 'Что считаем результатом', 'Что проверяем'],
|
||||
[
|
||||
['Имя', 'Есть непустое название', 'Не передаём в транслит пустую строку и не придумываем код из ID молча'],
|
||||
['Нормализация', 'Один предсказуемый кандидат', 'Регистр, дефис, длина и повторяющиеся разделители заданы явно'],
|
||||
['Поиск', 'Нет элемента с тем же CODE', 'Ищем внутри конкретного <code>IBLOCK_ID</code>, а не по всему сайту'],
|
||||
['Сохранение', 'Метод Add вернул ID', 'При ошибке сохраняем <code>LAST_ERROR</code> и исходное имя'],
|
||||
['Проверка ссылки', 'Каталог находит именно эту запись', 'Сверяем URL-шаблон и фильтр детального компонента'],
|
||||
],
|
||||
),
|
||||
heading('Воспроизводимый пример'),
|
||||
paragraph('Ниже функция для последовательного добавления из админки или небольшого импорта. Число 50 здесь не ограничение Bitrix, а мой предел для понятной ошибки: если за пятьдесят попыток не найден свободный вариант, лучше остановиться и посмотреть на входные данные. В реальном проекте ID инфоблока и правило суффикса стоит вынести в конфигурацию.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'function getFreeElementCode($iblockId, $name)',
|
||||
'{',
|
||||
' $base = CUtil::translit(trim($name), "ru", array(',
|
||||
' "max_len" => 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('Знак <code>=</code> в фильтре делает намерение явным: мы ищем конкретный код, а не похожую строку. В выборку достаточно взять <code>ID</code>; имя, картинка и свойства для решения о занятости не нужны. Это маленькая деталь, но она не даёт диагностическому запросу превращаться в выборку всего каталога.'),
|
||||
heading('Сохраняем код вместе с элементом'),
|
||||
paragraph('После проверки не нужно делать отдельный <code>Update</code> ради <code>CODE</code>. Документация <code>CIBlockElement::Add</code> допускает поле <code>CODE</code> в массиве полей. Добавляю его в тот же вызов и обязательно разбираю ошибку. Возвращённый ID доказывает запись, но ещё не доказывает, что путь компонента совпадает с проектным URL.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$element = new CIBlockElement();',
|
||||
'$id = $element->Add(array(',
|
||||
' "IBLOCK_ID" => 12,',
|
||||
' "NAME" => $name,',
|
||||
' "CODE" => getFreeElementCode(12, $name),',
|
||||
' "ACTIVE" => "N",',
|
||||
'));',
|
||||
'',
|
||||
'if ($id === false) {',
|
||||
' throw new RuntimeException($element->LAST_ERROR);',
|
||||
'}',
|
||||
'',
|
||||
'// Публикуем только после проверки обязательных данных и ссылки.',
|
||||
]),
|
||||
heading('Последовательность проверки'),
|
||||
orderedList([
|
||||
'Взять два названия, которые различаются только знаками и пробелами, и получить для них кандидаты.',
|
||||
'Создать первый элемент на тестовом инфоблоке с исходным кандидатом.',
|
||||
'Запустить функцию для второго имени и убедиться, что она вернула суффикс, а не прежний код.',
|
||||
'Прочитать оба элемента через <code>CIBlockElement::GetList</code> с тем же <code>IBLOCK_ID</code>.',
|
||||
'Открыть детальные страницы и сверить ID в шаблоне или временном логе. Так мы проверяем не только данные, но и используемый компонентом маршрут.',
|
||||
]),
|
||||
heading('Граница этого решения'),
|
||||
paragraph('Проверка «сначала <code>GetList</code>, потом <code>Add</code>» не является атомарной. Два параллельных воркера могут одновременно увидеть свободный код и попытаться сохранить одинаковое значение. Для ручного ввода и последовательного импорта этого обычно достаточно. Для параллельной синхронизации нужен отдельный проектный механизм: очередь, блокировка или код, связанный со стабильным внешним идентификатором. Какой именно — зависит от версии Bitrix, базы и требований к существующим URL.'),
|
||||
paragraph('Не стоит лечить эту задачу случайным числом в каждом коде. Такой адрес перестаёт быть повторяемым при повторном импорте, а диагностика становится сложнее. Если данные поставщика имеют стабильный артикул, полезно заранее решить, будет ли он участвовать в <code>CODE</code> или останется отдельным свойством. Главное — зафиксировать правило до публикации первой тысячи карточек.'),
|
||||
heading('Итог'),
|
||||
paragraph('Символьный код начинается с <code>CUtil::translit</code>, но не заканчивается на нём. Сначала делаем читаемого кандидата, затем проверяем его в нужном инфоблоке, сохраняем результат вместе с элементом и отдельно открываем ссылку. Такой порядок не решает гонку параллельного импорта, зато честно показывает её границу и избавляет от тихих совпадений в обычной работе.'),
|
||||
].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. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением <code>ELEMENT_CODE</code>?'),
|
||||
paragraph('Это полезно отделить в голове. Адрес <code>/catalog/kofe/classic-250-g/</code> не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка <code>CODE</code>, хотя ошибка сидит в шаблоне или в имени переменной.'),
|
||||
heading('Что делает движок ЧПУ'),
|
||||
paragraph('В документации <code>CComponentEngine::ParseComponentPath</code> описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в <code>ELEMENT_CODE</code>.'),
|
||||
paragraph('Шаблон пишется относительно папки компонента. Например, для папки <code>/catalog/</code> внутри массива нужен путь <code>#SECTION_CODE#/#ELEMENT_CODE#/</code>, а не полный адрес с начальным слешем. Это не вкусовщина: документация отдельно предупреждает, что лишний слеш в шаблоне меняет результат разбора.'),
|
||||
figure('/assets/editorial/2018/bitrix-slug-route-2018.svg', 'Схема: запрос браузера разбирается SEF-шаблоном, превращается в SECTION_CODE и ELEMENT_CODE, затем используется в выборке', 'Переменная из URL и элемент инфоблока живут на разных шагах. Между ними стоит проектный фильтр компонента.'),
|
||||
heading('Четыре значения, которые должны совпасть'),
|
||||
dataTable(
|
||||
['Участок', 'Пример', 'Как проверить'],
|
||||
[
|
||||
['Папка ЧПУ', '<code>/catalog/</code>', 'Сравнить с <code>SEF_FOLDER</code> вызванного компонента'],
|
||||
['Шаблон детали', '<code>#SECTION_CODE#/#ELEMENT_CODE#/</code>', 'Проверить отсутствие лишнего начального слеша и нужные маркеры'],
|
||||
['Переменная', '<code>ELEMENT_CODE = classic-250-g</code>', 'Вывести массив, полученный после разбора, на тестовой среде'],
|
||||
['Выборка', '<code>IBLOCK_ID + CODE + ACTIVE</code>', 'Сравнить фильтр компонента с контрольным <code>GetList</code>'],
|
||||
['Ссылка в шаблоне', 'Тот же набор маркеров', 'Собрать URL из значений и открыть его вручную'],
|
||||
],
|
||||
),
|
||||
heading('Минимальный воспроизводимый разбор'),
|
||||
paragraph('Ниже не готовый комплексный компонент, а короткая проверка его основания. Запускаю её на тестовой странице с известным путём. Если <code>$page</code> не равен <code>detail</code>, до запроса к инфоблоку дело вообще не дошло. Если код страницы найден, но <code>ELEMENT_CODE</code> пуст, виноват шаблон или сам адрес.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'CModule::IncludeModule("iblock");',
|
||||
'',
|
||||
'$arUrlTemplates = array(',
|
||||
' "detail" => "#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('Метод <code>CComponentEngine::MakePathFromTemplate</code> подставляет значения массива в маркеры шаблона. Это удобная точка для проверки обратного направления: у нас есть <code>SECTION_CODE</code> и <code>ELEMENT_CODE</code>, собираем путь и затем разбираем его тем же шаблоном. Если после такого круга переменная изменилась или пропала, в коде сайта уже есть расхождение.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$url = CComponentEngine::MakePathFromTemplate(',
|
||||
' "#SECTION_CODE#/#ELEMENT_CODE#/",',
|
||||
' array(',
|
||||
' "SECTION_CODE" => "kofe",',
|
||||
' "ELEMENT_CODE" => "classic-250-g",',
|
||||
' )',
|
||||
');',
|
||||
'',
|
||||
'// $url: kofe/classic-250-g/',
|
||||
'// Для ссылки добавляем папку /catalog/ в одном месте проекта.',
|
||||
]),
|
||||
heading('Последовательность от ссылки до карточки'),
|
||||
orderedList([
|
||||
'Взять реальный адрес, который не открывается, и сохранить его без ручной правки.',
|
||||
'Сверить папку и шаблон детали в параметрах вызванного компонента.',
|
||||
'На тестовой среде вывести код страницы и массив переменных после <code>ParseComponentPath</code>.',
|
||||
'Передать полученный <code>ELEMENT_CODE</code> в короткий <code>CIBlockElement::GetList</code> с теми же базовыми фильтрами.',
|
||||
'Если элемент найден, сравнить с фильтром самого компонента: раздел, активность, права и проектные свойства.',
|
||||
'Собрать обратную ссылку из тех же маркеров и повторить проверку после изменения шаблона.',
|
||||
]),
|
||||
heading('Частые расхождения'),
|
||||
dataTable(
|
||||
['Симптом', 'Где искать', 'Безопасная проверка'],
|
||||
[
|
||||
['Страница не определяется', 'Папка ЧПУ или шаблон детали', 'Проверить результат <code>ParseComponentPath</code> до обращения к инфоблоку'],
|
||||
['Страница определяется, код пуст', 'Маркер отличается от имени, которое ждёт компонент', 'Сравнить ключи массива переменных с параметрами компонента'],
|
||||
['Код есть, элемента нет', 'CODE, инфоблок, активность или дополнительный фильтр', 'Запустить <code>GetList</code> сначала с базовыми, затем с проектными условиями'],
|
||||
['Ссылка формируется иначе, чем разбирается', 'Два разных URL-шаблона в шаблоне и компоненте', 'Собрать путь через <code>MakePathFromTemplate</code> и разобрать его обратно'],
|
||||
],
|
||||
),
|
||||
heading('Ограничения'),
|
||||
paragraph('Эта диагностика начинается в момент, когда PHP-компонент уже получил запрос. Если веб-сервер или правила перенаправления не передали путь в приложение, <code>ParseComponentPath</code> не сможет это исправить. Тогда проверять нужно предыдущий слой: фактический URI, правило маршрутизации и точку входа сайта. Не стоит менять <code>CODE</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>CODE</code> или широкий фильтр до того, как менять данные?'),
|
||||
paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'),
|
||||
heading('Не путать симптом и причину'),
|
||||
paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому <code>ELEMENT_CODE</code>.'),
|
||||
figure('/assets/editorial/2018/bitrix-slug-conflict-2018.svg', 'Дерево диагностики неправильной карточки: путь, переменная ELEMENT_CODE, число совпадений GetList и дальнейшие действия', 'Сначала считаем набор совпадений. Кеш проверяем только после пути и данных.'),
|
||||
heading('Какие данные собрать до исправления'),
|
||||
dataTable(
|
||||
['Факт', 'Зачем он нужен', 'Как зафиксировать'],
|
||||
[
|
||||
['Исходный URL', 'Показывает, что реально запросил браузер', 'Сохранить полный путь из адресной строки или access-лога'],
|
||||
['Ожидаемый ID', 'Не даёт спорить о том, какая запись считается правильной', 'Взять ID из админки или из результата импорта'],
|
||||
['ELEMENT_CODE', 'Связывает путь с данными компонента', 'Вывести переменную после разбора ЧПУ на тестовом стенде'],
|
||||
['Все записи по CODE', 'Отличает один результат от конфликта', 'Сделать ограниченный <code>GetList</code> в том же инфоблоке'],
|
||||
['Фильтр детали', 'Объясняет, почему часть записей исключена или выбрана', 'Сверить с параметрами и кодом конкретного компонента'],
|
||||
],
|
||||
),
|
||||
heading('Контрольная выборка'),
|
||||
paragraph('Документация <code>CIBlockElement::GetList</code> позволяет явно задать сортировку, фильтр, ограничение и набор полей. Для диагностики беру только те поля, которые помогают отличить записи: ID, имя, CODE, основной раздел и шаблон детального URL. Запрос не должен случайно тянуть свойства всего каталога: его задача — показать размер набора и порядок элементов.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'function findActiveElementsByCode($iblockId, $code)',
|
||||
'{',
|
||||
' $result = CIBlockElement::GetList(',
|
||||
' array("ID" => "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('Не нужно угадать имя переменной по шаблону. Комплексный компонент разбирает путь через <code>CComponentEngine::ParseComponentPath</code> и возвращает переменные, восстановленные из маркеров. Для проблемной ссылки полезно на тестовой копии вывести <code>$page</code> и <code>$arVariables</code>. Так видно, не потерялся ли раздел и действительно ли <code>ELEMENT_CODE</code> равен строке из адреса.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$templates = array(',
|
||||
' "detail" => "#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('Если в массиве нет <code>SECTION_CODE</code>, а детальный запрос должен учитывать раздел, коды элементов могут быть вполне корректны. Ошибка будет в URL-шаблоне или в логике компонента, который не применяет восстановленную переменную. И наоборот: если переменные верны, а <code>GetList</code> возвращает несколько записей, искать надо в данных и условиях выборки, не в роутинге.'),
|
||||
heading('Исправление без потери истории'),
|
||||
paragraph('Когда конфликт подтверждён, сначала выбираю правило для нового адреса: суффикс, артикул или раздел. Затем сохраняю старый URL и список мест, которые на него ссылаются. Смена <code>CODE</code> меняет адрес, поэтому публикацию лучше выполнять отдельным шагом с проверкой ссылок. Метод <code>CIBlockElement::Update</code> возвращает результат изменения; при ошибке не пропускаем <code>LAST_ERROR</code>.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$element = new CIBlockElement();',
|
||||
'$updated = $element->Update($duplicateId, array(',
|
||||
' "CODE" => "classic-250-g-2",',
|
||||
'));',
|
||||
'',
|
||||
'if (!$updated) {',
|
||||
' throw new RuntimeException($element->LAST_ERROR);',
|
||||
'}',
|
||||
'',
|
||||
'// После изменения снова выполняем findActiveElementsByCode().',
|
||||
]),
|
||||
heading('Порядок работы в продовой задаче'),
|
||||
orderedList([
|
||||
'Зафиксировать URL, ожидаемый ID и время, когда ошибка наблюдалась.',
|
||||
'На тестовой копии получить переменные, восстановленные из того же пути.',
|
||||
'Сделать выборку по фактическому <code>CODE</code> в нужном <code>IBLOCK_ID</code> и посчитать результаты.',
|
||||
'Сравнить полученные ID с тем, что показывает детальный компонент после его дополнительных фильтров.',
|
||||
'Если есть конфликт, выбрать новое стабильное правило кода и проверить все старые ссылки, которые важны для проекта.',
|
||||
'После изменения повторить URL-проверку. Кеш и индекс обновлять только по принятому в проекте порядку, когда данные и маршрут уже доказаны.',
|
||||
]),
|
||||
heading('Ограничения'),
|
||||
paragraph('Эта заметка не утверждает, что любое совпадение <code>CODE</code> ошибочно. В некоторых каталогах один и тот же код допустим в разных витринах или разделах, и тогда адрес и фильтр обязаны включать этот контекст. Не следует добавлять раздел в запрос автоматически: сначала нужно понять, что именно считает идентичностью текущий компонент.'),
|
||||
paragraph('Также не стоит менять десятки кодов одной SQL-командой. У Bitrix есть API изменения элемента, обработчики событий и проектные зависимости от адресов. Сначала правим один доказанный конфликт на тестовых данных, проверяем маршрут и только потом составляем отдельный план для массовой миграции.'),
|
||||
heading('Итог'),
|
||||
paragraph('Когда адрес открывает не тот товар, удобнее не спорить о кеше, а посчитать факты. URL даёт переменную, переменная даёт набор элементов, набор показывает — это маршрут, фильтр или конфликт данных. После такой проверки изменение <code>CODE</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('<figure>') || !draft.bodyHtml.includes('<table>') || !draft.bodyHtml.includes('<pre><code>')) {
|
||||
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'),
|
||||
};
|
||||
});
|
||||
|
||||
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 {
|
||||
console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user