Привет, хабровчане! Это моя первая статья на хабре, поэтому сильно не бейте:)

Сегодня я хочу рассказать вам о том как небольшая задача привела меня к созданию своего первого 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 если есть doctype
outputDocument() - устанавливает флаг для возврата 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
Репозиторий тут