Привет, хабровчане! Это моя первая статья на хабре, поэтому сильно не бейте:)
Сегодня я хочу рассказать вам о том как небольшая задача привела меня к созданию своего первого Composer пакета.
И так, у клиента из 1С, в реквизитах товара, прилетает превью описание такого вида:
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /><meta http-equiv="X-UA-Compatible" content="IE=Edge" /><meta name="format-detection" content="telephone=no" />
<style type="text/css">
body{margin:0;padding:8px;}p{line-height:1.15;margin:0;white-space:pre-wrap;}ol,ul{margin-top:0;margin-bottom:0;}img{border:none;}li>p{display:inline;}
</style>
</head>
<body>
<p style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">
<span style="font-weight: bold;background-color: #ffffff;color: #888888;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">Цвет</span>
</p>
<p style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">
<span style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;">Зеленый</span>
</p>
<p style="background-color: #ffffff;color: #888888;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">
<span style="font-weight: bold;background-color: #ffffff;color: #888888;font-family: Open Sans;font-size: 10pt;">Состав</span>
</p>
<p style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">
<span style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;">Эмульгатор (лецитин соевый), пищевой краситель E102, пищевой краситель E133, пропиленгликоль</span>
</p>
<p style="background-color: #ffffff;color: #888888;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">
<span style="font-weight: bold;background-color: #ffffff;color: #888888;font-family: Open Sans;font-size: 10pt;">Область применения</span>
</p>
<p style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">
<span style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;">Шоколад, шоколадная глазурь, шоколадный велюр, какао-масло, масляный крем, ганаш, влажный бисквит, мороженое пломбир, соусы</span>
</p>
</body>
</html>И задача была привести всю эту вакханалию к такому виду:
<p><b>Цвет</b></p>
<p>Зеленый</p>
<p><b>Состав</b></p>
<p>Эмульгатор (лецитин соевый), пищевой краситель E102, пищевой краситель E133, пропиленгликоль</p>
<p><b>Область применения</b></p>
<p>Шоколад, шоколадная глазурь, шоколадный велюр, какао-масло, масляный крем, ганаш, влажный бисквит, мороженое пломбир, соусы</p>Т.е.:
- убрать все стили, но оставить только text-aligh, если существует
- если в стилях есть font-weight: bold - заменить на тег <b>
- удалять пустые теги
- оставлять только разр��шенные теги <p>,<br>,<i>,<em>,<strong>,<b>,<u>,<ul>,<ol>,<li>
И тут я начал творить (изобретать велосипед), вместо того чтобы "загуглить" готовый функционал, который, к слову, как оказалось позже, был, но не позволял менять font-weight: bold на <b>
Я не буду показывать код прототип, который послужил базой для создания пакета (ну стыдно мне!) и перейду сразу к пакету и как я это все реализовал. Я не буду углубляться в кодовую базу, с ней вы можете ознакомиться здесь
RuleInterface
И так, для того чтобы я мог менять/удалять необходимую ноду в нашем html я решил создать единый класс, который будет называться Rule и реализовывать RuleInterface.
Принцип прост:
supports()проверяет ноду и возвращаетtrue, если текущая нода соответствует критериям для применения к нему дальнейших "трансформаций"apply()применяет те самые "трансформации"priority()возвращает приоритет (чем больше значение тем "первее" применится)
interface RuleInterface
{
public function supports(DOMNode $node): bool;
public function apply(DOMNode $node): bool;
public function priority(): int;
}Для удобства создания правил Rule, я создал RuleBuilder, который позволяет мне писать цепочки методов для создания правила. Например
/** @var Rule $rule */
$rule =
RuleBuilder::when(SelectorFacade::tag('span'))
->transform(Replace::tag('div'), 100);SelectorInterface
Это именно тот, кто проверяет соответствует ли DOMNode критериям и имеет только один метод matches() , который возвращает true, если текущая DOMNode соответствует критериям (где-то мы это уже слышали...)
interface SelectorInterface
{
public function matches(DOMNode $node): bool;
}Я реализовал несколько типов селекторов:
TagSelector - поиск по тегу
ClassSelector - поиск по классу
AttributeSelector - поиск по атрибуту
StyleSelector - поиск по inline-стилю
ChildSelector - поиск по очевидному потомку (аналог "div > ul")
DescendantSelector - поиск по всем потомкам (аналог "div ul li span")
EmptyTagSelector - поиск пустого тега
HasSelector - поиск существования потомка (аналог "div:has(ul)")
HasTextSelector - поиск по вхождения строки (аналог ":has-text()")
OrSelector, AndSelector, NotSelector - для логических операций между селекторами
Также другие, в основном служебные классы
Для удобства есть фасад SelectorFacade
Мне так хотелось простого селектора, а не танцы с классами, что я решил добавить возможность селекторов как в css (не прям все, но большинство). Получить готовый селектор из строки можно через метод SelectorFacade::query(string $selector). Вот теперь более проще!
Допускные CSS селекторы
div
div.class, div#id, div[some-attr="hello"]
div, span
div span.class
div > ul
:not(...)
:has(...)
:has-text()
Если я упустил важны селектор - отпишитесь в комментариях :)
TransformerInterface
Ну тут думаю и без описания понятно что делает apply(), кроме одного момента ): bool; - если возвращается false, то все остальные трансформации для данного DOMNode прекращаются (а их может быт�� много...)
interface TransformerInterface
{
public function apply(DOMNode $node): bool;
}Я реализовал несколько типов трансформеров, которых должно хватить (пока писал, понял, что есть куда расти):
AllowAttributesTransformer - Разрешает атрибуты, остальные удаляет
ChangeAttributeTransformer - Изменяет атрибут
StripAttributesTransformer - Удаляет атрибуты
AllowStylesTransformer - Разрешает стили, остальные удаляет
StripStylesTransformer - Удаляет стили
ReplaceTransformer - Заменяет тег на свой
WrapTransformer - Оборачивает в новый тег
UnwrapTransformer - Удаляет тег, оставляя содержимое
BatchTransformer - Серия трансформеров
Для удобства есть фасад TransformerFacade
HtmlCleaner
Основной класс для манипуляций с hrml который реализует шаблон программирования- Fluent Interface. Ниже представлю основные метод класса
final class HtmlCleaner
{
public static function make(
?EngineInterface $engine = null,
$encoding = 'utf-8'
): self;
public function transform(
string|array|SelectorInterface $selectors,
TransformerInterface|Closure|array $transformer,
int $priority = 0
): self;
public function transformAnd(
array $selectors,
TransformerInterface|Closure|array $transformer,
int $priority = 0
): self;
public function transformOr(
array $selectors,
TransformerInterface|Closure|array $transformer,
int $priority = 0
): self;
public function onlyText(...$tags): self;
public function drop(...$tags): self;
public function unwrap(...$tags): self;
public function wrap(
SelectorInterface|array|string $tags,
string $newTag
): self;
public function allowStyles(...$styles);
public function stripStyles(...$styles);
public function stripAttributes(...$attributes): self;
public function stripEmptyTag(...$tags): self;
public function changeAttr(
SelectorInterface|array|string $tags,
string|array $attr,
string|int|null $value = null
): self;
public function replaceTag(
SelectorInterface|array|string $tags,
string $tag, $copyAttrs = true
): self;
public function normalizeWhitespace(): self;
public function stripComments();
public function outputFragment(): self;
public function outputDocument(): self;
public function clean(string $html): string
}clean(string $html) - это именно тот метод который запускает трансформацию по всем правилам.outputFragment() - устанавливает флаг для возврата html из body если есть doctypeoutputDocument() - устанавливает флаг для возврата html с doctype, а если его не было - добавляет.
Принцип работы
Каждый метод в HtmlCleaner (кроме outputFragment, outputDocument и clean) добавляет новый Rule (правило) в котором есть уже описанные Сетекторы и Трансформации, в зависимости от приоритета - сортируются (с самым большим приоритетом - самые первые). Далее, после запуска метода clean создается DOMDocument из переданного html и проходит все Rule[] с помощью метода walk и если тег попадает под критерий в supports() - применяется трансформация DOMNode с помощью apply()
private function walk(DOMNode $node): void
{
foreach ($this->rules as $rule) {
if ($rule->supports($node)) {
if ($rule->apply($node)) {
break;
}
}
}
foreach (iterator_to_array($node->childNodes) as $child) {
$this->walk($child);
}
}И в конце концов возвращает готовый html. Вуа-ля! Вроде бы все легко :)
Что по производительности?
Протестировал на документе с 850+ тегов:
Скорость: 40ms
Использовало памяти: 4мб
Ниже сам код данного теста. Если у вас другие показатели - пишите, будем разбираться!
$html = file_get_contents(__DIR__ . '/test_files/large_doc_html');
$result =
HtmlCleaner::make()
->stripComments()
->normalizeWhitespace()
->stripEmptyTag('p')
->changeAttr('a[href^=/"]', 'target', '_blank')
->drop('iframe', 'script')
->wrap('a[rel="nofollow"]', 'noindex')
->outputDocument()
->clean($html)
;Примеры
Ниже приведу пару примеров использования данного пакета.
$html = '<span style="font-weight:bold">Hello</span><p>World</p>';
$result =
HtmlCleaner::make()
->transform(
SelectorFacade::style('font-weight', 'bold'),
TransformerFacade::replace('b')
)
->clean($html)
;
// <b>Hello</b><p>World</p>// Кастомный трансформер
$html = '<span style="font-weight:bold">Hello</span><p>World</p>';
$result =
HtmlCleaner::make()
->transform(
SelectorFacade::style('font-weight', 'bold'),
function (DomNode $node) {
$doc = $node->ownerDocument;
$parent = $node->parentNode;
if (!$doc || !$parent) {
return false;
}
$newNode = $doc->createElement('b');
$newNode->setAttribute('class', 'changed');
while ($node->firstChild) {
$newNode->appendChild($node->firstChild);
}
$parent->replaceChild($newNode, $node);
return true;
}
)
->clean($html)
;
// <b class="changed">Hello</b><p>World</p>$html = '<!DOCTYPE html><html dir="ltr"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /><meta http-equiv="X-UA-Compatible" content="IE=Edge" /><meta name="format-detection" content="telephone=no" /><style type="text/css">body{margin:0;padding:8px;}p{line-height:1.15;margin:0;white-space:pre-wrap;}ol,ul{margin-top:0;margin-bottom:0;}img{border:none;}li>p{display:inline;}</style></head><body class="bodyClass"><p><span style="background-color: #ffffff;color: #888888;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;">Цвет</span></p><p style="background-color: #ffffff;color: #333333;font-family: Open Sans;font-size: 10pt;font-style: normal;font-weight: normal;line-height: 1.38;"></body></html>';
$result =
HtmlCleaner::make()
->drop('meta[http-equiv="Content-Type"]', 'style')
->stripStyles('font-family', 'color', 'font-style', 'font-size', 'font-weight', 'line-height')
->normalizeWhitespace()
->outputDocument()
->clean($html)
;
// <!DOCTYPE html><html dir="ltr"><head><meta http-equiv="X-UA-Compatible" content="IE=Edge"><meta name="format-detection" content="telephone=no"></head><body class="bodyClass"><p><span style="background-color:#ffffff">Цвет</span></p><p style="background-color:#ffffff"></p></body></html>$html = '<div class="bold" style="color:red" data-id="5"><span>Hello</span></div>';
$result =
HtmlCleaner::make()
->transform(
'div.bold',
[
TransformerFacade::changeAttr('data-id', '10'),
TransformerFacade::wrap('article'),
TransformerFacade::dropAttrs('style')
]
)
->clean($html)
;
// <article><div class="bold" data-id="10"><span>Hello</span></div></article>С остальными примерами можете ознакомиться в тест кейсах.
Заключение
Спасибо, что дочитали до конца. Как я в начале говорил - это моя первая статья на хабре :)
Данный скрипт успешно прошел все тесты и сейчас используется на продакшене очищая вакханалию из html тегов и атрибутов.
Поделитесь своим мнением, если есть возможность - протестируйте и дайте фидбэк.
Для использования пакета - composer require mb4it/htmlcleaner
Репозиторий тут
