Тест зелёный, баг у клиента: чиним разделители для trafilatura

Клиент Словоправа получил отчёт, в котором среди рекомендованных терминов стояло слово ммаккумулятор. Такого слова нет. Оно склеилось из «Ширина, мм» и «Аккумулятор» — двух соседних ячеек в таблице характеристик, между которыми при извлечении текста пропал разделитель. Мы починили это в июле. В августе оно вернулось. Починили снова — вернулось опять. Разбираемся, почему обычный способ вставить разделители не работает и почему тесты этого не показывают.

Как склеиваются соседние элементы

Карточка товара в каталоге размечена вложенными div:

StoDum
<div class="attr-row">
  <div class="attr-title">Ширина печати:</div>
  <div class="attr-values">58 мм</div>
</div>
<div class="attr-row">
  <div class="attr-title">Аккумулятор:</div>
  <div class="attr-values">Нет</div>
</div>

В браузере всё разделено — блочные элементы переносят строку. А trafilatura в текстовом режиме отдаёт вот что:

StoDum
Ширина печати:58 ммАккумулятор:НетАвтоотрезчик:Да

Дальше по конвейеру эта строка попадает в токенизатор, ммаккумулятор получает лемму, лемма попадает в частотный анализ, и в итоге пользователь видит рекомендацию употребить несуществующее слово.

Две починки, которые не сработали

Вставляем пробелы

Решение выглядит очевидным. Проходим по DOM, вокруг блочных тегов ставим \n, вокруг строчных — пробел, и только потом отдаём документ экстрактору:

StoDum
for tag in soup.find_all(True):
    if tag.name in BLOCK_TAGS:
        tag.insert_before(NavigableString("\n"))
        tag.insert_after(NavigableString("\n"))

Проверяем — чисто. Пишем тест, тест зелёный. Выкатываем.

Через месяц ммаккумулятор возвращается.

Виноват оказался <a>

Первая версия набора строчных тегов содержала два элемента — span и button. Логика была такая: значение характеристики обычно лежит в span, его и разделяем.

Каталоги устроены иначе. В ссылку заворачивают и название товара, и подпись фильтра, поэтому две соседние <a> слипаются ровно так же, как пара <div>:

StoDum
'<div><a href="/1">Фискальный регистратор</a><a href="/2">Ширина, мм</a></div>'
# → 'Фискальный регистраторШирина, мм'

Расширяем набор до двадцати шести тегов, добавляем a, strong, b, em, а заодно легаси-теги font и big, которых на старых каталогах предостаточно. Пишем параметризованный тест по всем тегам сразу. Зелёный.

Выкатываем. Через несколько дней ммаккумулятор возвращается снова — вместе с двумя новыми склейками.

Вот теперь становится понятно, что дело не в наборе тегов.

Почему проверка врала

Вот две строчки, между которыми была спрятана вся проблема:

StoDum
BeautifulSoup(normalize(html), "lxml").get_text()   # чисто
trafilatura.extract(normalize(html))                # 39 склеек

Один и тот же нормализованный HTML. Разные способы достать из него текст.

BeautifulSoup.get_text() идёт по дереву и склеивает содержимое текстовых узлов — включая те, что состоят из одних пробелов. Вставленные разделители сохраняются. trafilatura работает иначе: она пересобирает документ, выбрасывая мусор, и пробельные текстовые узлы попадают в мусор первыми. Разделители исчезают до того, как из документа получится текст.

Конвейер звал trafilatura.extract(). Тесты читали get_text(). Фикс не работал ни дня, а тесты были зелёными всё это время, потому что проверяли путь, которого в продакшене нет.

Это и есть главный урок, ради которого написана статья: негативный контроль должен воспроизводиться на том же пути, что и продакшен. Иначе зелёный тест доказывает только то, что тест зелёный.

Что работает: невидимые маркеры

Разделитель должен пережить пересборку документа. Значит он не может быть пробелом — нужен символ, который экстрактор считает содержимым.

Кандидатов проверили пятью замерами на той же донорской странице. Считали склейки после извлечения и отдельно смотрели, во что превращается обычный абзац с выделением и ссылкой:

разделительсклейкипроза
\n и пробел (исходный)39цела
неразрывный пробел U+00A039цела
точка с пробелом .0изрублена точками
символ нулевой ширины0цела
вставка <br/>0цела

Неразрывный пробел не спасает: экстрактор считает его тем же пробелом. Точка работает, но превращает текст в набор обрубков. Остаются символ нулевой ширины и <br/>, и первый честнее — он ничего не добавляет в разметку.

Подходят два символа: U+2063 INVISIBLE SEPARATOR и U+2062 INVISIBLE TIMES. Они не имеют начертания, не встречаются в живых текстах и переживают извлечение. После извлечения меняем их обратно на перевод строки и пробел:

StoDum
import trafilatura
import unglue

text = unglue.extract(html, trafilatura.extract, include_tables=True)

Замеры на живых страницах. Проверено 7 августа 2026 года на Ubuntu 24.04, Python 3.12.3, trafilatura 2.0.0, beautifulsoup4 4.13.5:

страницасклейкитокены
каталог, 490 КБ15 → 4767 → 885
статья, 638 токенов0 → 0без изменений

Плюс 118 токенов на каталоге — слова, которые раньше были спаяны в мусор и теперь считаются по отдельности. На прозе не изменилось ничего, и это вторая половина смысла: разделители не должны рвать обычный текст.

Почему блочным тегам перевод строки, а строчным пробел, понятно из последствий. Перевод строки большинство токенизаторов считает жёсткой границей предложения — между ячейками таблицы это правильно, а внутри фразы со ссылкой или выделением это разрежет предложение на куски, и словосочетания через такую границу собираться перестанут.

Отдельная история — sup и sub. Их, в отличие от остальных строчных тегов, ставят внутри слова: м<sup>2</sup>, H<sub>2</sub>O. Разделитель там не чинит токен, а ломает, поэтому оба тега исключены.

Чего сделать не удалось

Четыре склейки на каталоге выживают. Экстрактор собирает текст не только обходом DOM, и до всех мест обхода не дотянуться. Библиотека, обещающая ноль, врала бы.

Три фикстуры, которые ничего не доказали

Юнит-тест на сквозной сценарий написать не получилось, и это стоит разобрать подробно — потому что каждая попытка выглядела рабочей.

Первая фикстура повторяла разметку карточки: четыре пары div вида «подпись — значение» внутри <article>. В тест я заложил негативный контроль — проверку, что без нормализации склейка воспроизводится. Контроль упал: trafilatura разобрала синтетический документ правильно и ничего не склеила. Тест не то чтобы прошёл — он не смог даже показать дефект.

Вторая попытка была умнее и потому опаснее. Раз сквозной сценарий не воспроизводится, решил проверять само свойство, на котором держится починка: маркер доходит до вывода экстрактора, а пробел не доходит. Написал проверку assert BLOCK_MARK in extracted на коротком документе. Упала и она — на маленьком документе trafilatura и без нормализации расставляет переводы строк правильно, а маркеры выбрасывает как лишнее. Свойство, которое я собирался закрепить, на этом масштабе просто не существует.

Третья попытка обошлась без синтетики. Я взял настоящий блок attrs-wrap с донорской страницы и перебрал пять обёрток: голый фрагмент, html/body, html/body/article, две копии подряд и фрагмент рядом с абзацем прозы. Склейка не воспроизвелась ни в одной. При этом та же страница целиком, из которой вырезаны только script и style — сто пятьдесят килобайт вместо четырёхсот девяноста, — воспроизводит её полностью.

Порог здесь не в структуре разметки, а в размере документа: trafilatura выбирает стратегию извлечения по эвристикам всего документа сразу, и на маленьком входе идёт другим путём. Значит честная фикстура — это фрагмент страницы в сотню килобайт, привязанный к чужой вёрстке, которая изменится без предупреждения.

Поэтому тесты закрепляют механизм и прямо пишут в докстринге, что откат на пробелы они не поймают, а настоящая проверка вынесена в скрипт, который ходит по живым страницам. Скрипт при этом сообщает «ничего не проверено» и возвращает ненулевой код, если все страницы оказались недоступны, — иначе он повторил бы ту самую ошибку, ради которой написан.

Диагностическая функция find_fusions ищет строчный кусок, за которым идёт заглавная буква внутри одного токена. На технической странице она честно находит addEvent, hasCookie и itHub, потому что camelCase от склейки структурно неотличим. Читать её нужно как разницу «до и после»: ложные срабатывания есть в обоих замерах и сокращаются. Абсолютное число она не показывает, и в документации это написано прямым текстом.

Библиотека

Код выложен под MIT: github.com/kotophalk/unglue

StoDum
pip install git+https://github.com/kotophalk/unglue

Четыре функции: mark вставляет разделители, unmark возвращает их в пробелы, extract оборачивает любой экстрактор, find_fusions показывает разницу. Наборы тегов экспортируются и переопределяются, если ваша разметка отличается.

Проверьте на своих страницах — вопрос не в том, есть ли у вас склейки, а в том, знаете ли вы об этом:

StoDum
before = unglue.find_fusions(trafilatura.extract(html))
after = unglue.find_fusions(unglue.extract(html, trafilatura.extract))
print(len(before), "->", len(after))

Если числа совпали — на этой странице склеек нет либо ваш экстрактор ведёт себя иначе. Если разошлись — вы только что нашли слова, которых не существует, в собственных данных.