Привет, хабровчане! Это моя первая статья на хабре, поэтому сильно не бейте:)
Сегодня я хочу рассказать вам о том как небольшая задача привела меня к созданию своего первого 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
Репозиторий тут