PHP HTML Cleaner - Когда задача на проекте привела к созданию своего Composer пакета

php

Вступление

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

© 2026 MB

Desing by mb4design