Клиент Словоправа получил отчёт, в котором среди рекомендованных терминов стояло слово ммаккумулятор. Такого слова нет. Оно склеилось из «Ширина, мм» и «Аккумулятор» — двух соседних ячеек в таблице характеристик, между которыми при извлечении текста пропал разделитель. Мы починили это в июле. В августе оно вернулось. Починили снова — вернулось опять. Разбираемся, почему обычный способ вставить разделители не работает и почему тесты этого не показывают.
Как склеиваются соседние элементы
Карточка товара в каталоге размечена вложенными div:
<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 в текстовом режиме отдаёт вот что:
Ширина печати:58 ммАккумулятор:НетАвтоотрезчик:Да
Дальше по конвейеру эта строка попадает в токенизатор, ммаккумулятор получает лемму, лемма попадает в частотный анализ, и в итоге пользователь видит рекомендацию употребить несуществующее слово.
Две починки, которые не сработали
Вставляем пробелы
Решение выглядит очевидным. Проходим по DOM, вокруг блочных тегов ставим \n, вокруг строчных — пробел, и только потом отдаём документ экстрактору:
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>:
'<div><a href="/1">Фискальный регистратор</a><a href="/2">Ширина, мм</a></div>'
# → 'Фискальный регистраторШирина, мм'
Расширяем набор до двадцати шести тегов, добавляем a, strong, b, em, а заодно легаси-теги font и big, которых на старых каталогах предостаточно. Пишем параметризованный тест по всем тегам сразу. Зелёный.
Выкатываем. Через несколько дней ммаккумулятор возвращается снова — вместе с двумя новыми склейками.
Вот теперь становится понятно, что дело не в наборе тегов.
Почему проверка врала
Вот две строчки, между которыми была спрятана вся проблема:
BeautifulSoup(normalize(html), "lxml").get_text() # чисто
trafilatura.extract(normalize(html)) # 39 склеек
Один и тот же нормализованный HTML. Разные способы достать из него текст.
BeautifulSoup.get_text() идёт по дереву и склеивает содержимое текстовых узлов — включая те, что состоят из одних пробелов. Вставленные разделители сохраняются. trafilatura работает иначе: она пересобирает документ, выбрасывая мусор, и пробельные текстовые узлы попадают в мусор первыми. Разделители исчезают до того, как из документа получится текст.
Конвейер звал trafilatura.extract(). Тесты читали get_text(). Фикс не работал ни дня, а тесты были зелёными всё это время, потому что проверяли путь, которого в продакшене нет.
Это и есть главный урок, ради которого написана статья: негативный контроль должен воспроизводиться на том же пути, что и продакшен. Иначе зелёный тест доказывает только то, что тест зелёный.
Что работает: невидимые маркеры
Разделитель должен пережить пересборку документа. Значит он не может быть пробелом — нужен символ, который экстрактор считает содержимым.
Кандидатов проверили пятью замерами на той же донорской странице. Считали склейки после извлечения и отдельно смотрели, во что превращается обычный абзац с выделением и ссылкой:
| разделитель | склейки | проза |
|---|---|---|
\n и пробел (исходный) | 39 | цела |
неразрывный пробел U+00A0 | 39 | цела |
точка с пробелом . | 0 | изрублена точками |
| символ нулевой ширины | 0 | цела |
вставка <br/> | 0 | цела |
Неразрывный пробел не спасает: экстрактор считает его тем же пробелом. Точка работает, но превращает текст в набор обрубков. Остаются символ нулевой ширины и <br/>, и первый честнее — он ничего не добавляет в разметку.
Подходят два символа: U+2063 INVISIBLE SEPARATOR и U+2062 INVISIBLE TIMES. Они не имеют начертания, не встречаются в живых текстах и переживают извлечение. После извлечения меняем их обратно на перевод строки и пробел:
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 → 4 | 767 → 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
pip install git+https://github.com/kotophalk/unglue
Четыре функции: mark вставляет разделители, unmark возвращает их в пробелы, extract оборачивает любой экстрактор, find_fusions показывает разницу. Наборы тегов экспортируются и переопределяются, если ваша разметка отличается.
Проверьте на своих страницах — вопрос не в том, есть ли у вас склейки, а в том, знаете ли вы об этом:
before = unglue.find_fusions(trafilatura.extract(html))
after = unglue.find_fusions(unglue.extract(html, trafilatura.extract))
print(len(before), "->", len(after))
Если числа совпали — на этой странице склеек нет либо ваш экстрактор ведёт себя иначе. Если разошлись — вы только что нашли слова, которых не существует, в собственных данных.