Вступление
Сегодня я хочу рассказать вам о том как небольшая задача привела меня к созданию своего первого Composer пакета (ну да, логично, тайтл статьи именно такой :D).
И так, у клиента из 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
Это именно тот, кто проверяет соответствует ли нода критериям и имеет только один метод matches(), который возвращает true, если текущая нода соответствует критериям (где-то мы это уже слышали...)
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 если есть doctype
Метод outputDocument() устанавливает флаг для возврата html с doctype, а если его не было - добавляет.
Принцип работы
Каждый метод в HtmlCleaner(кроме outputFragment,outputDocument и clean) добавляет новый Rule в котором есть уже описанные Сетекторы и Трансформации, в зависимости от приоритета - сортируются (с самым большим приоритетом - самые первые). Далее, после запуска метода clean создается DOMDocument из переданного html и проходит все Rule[] с помощью метода walk
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
Репозиторий тут