Шаблон находит слово, смысл он не проверяет: одна цитата со страницы и две её трактовки
Шаблон находит слово, смысл он не проверяет: одна цитата со страницы и две её трактовки

Нам понадобились свои данные о том, как устроены страницы, продающие франшизы. Не мнение, не подборка скриншотов, а числа, которые можно пересчитать через полгода и сравнить. Готовых датасетов по этой нише нет, поэтому мы написали скрипт: он забирает шестнадцать страниц, ищет на них набор признаков и складывает результат в JSON.

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

Что мы вообще пытались измерить

Соблазн при разборе рекламных страниц — оценивать: честная страница или нечестная, адекватная цифра или завышенная. Такая оценка не воспроизводится: через полгода другой человек с тем же файлом получит другие числа.

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

  • деньги: обещание прибыли рядом с числом, слово «окупаемость», сумма вложений «от», паушальный взнос, роялти;

  • приёмы страницы: форма заявки, калькулятор или квиз, счётчик точек, отзывы франчайзи, «под ключ», «без опыта»;

  • обещание и его страховка: слово «гарантия», оговорка к цифрам, упоминание договора концессии или товарного знака.

Разница между «страница обещает много» и «на странице есть слово „окупаемость“» кажется формальной, пока вы не начнёте считать. Она и стала главным источником ошибок.

Как набиралась выборка

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

Ниши выбраны не случайно: это то, что чаще всего показывают человеку без опыта, когда он первый раз интересуется франшизой. Внутри ниши мы брали первые страницы выдачи и не отбирали их по содержанию: ни одна страница не выброшена из‑за того, что показалась нам слишком агрессивной или слишком скромной.

Это по‑прежнему верхушка выдачи, а не рынок. Сети без рекламного бюджета в такую выборку не попадают вовсе, и любое обобщение на «рынок франшиз» из этих данных будет неправдой. Мы называем выборку выборкой, а не рынком, в каждом месте, где приводим доли.

Сбор: curl, кэш и порог в тысячу знаков

Собственно загрузка устроена скучно, и это намеренно. Страница берётся через curl с браузерным user‑agent, ответ кладётся в кэш, дальше скрипт работает с кэшем:

def скачать(url, файл, из_кэша):
    if из_кэша and файл.exists():
        return 200, файл.read_text(encoding="utf-8", errors="replace")
    готово = subprocess.run(
        ["curl", "-sL", "--max-time", "25", "-A", БРАУЗЕР,
         "-w", "\n===КОД:%{http_code}", url],
        capture_output=True, text=True, timeout=40)

Кэш здесь не оптимизация, а условие проверяемости. Рекламная страница меняется чаще, чем статья в блоге: за неделю на ней может смениться цифра, исчезнуть счётчик точек, появиться новая акция. Если у вас нет копии страницы на дату замера, ваши числа невозможно перепроверить, и спорить о них бессмысленно. Все спорные срабатывания мы потом разбирали именно по кэшу, а не по живому сайту.

Текст из HTML достаётся в три прохода: выкидываем скрипты, стили и комментарии, потом теги, потом схлопываем пробелы и переводим в нижний регистр.

def текст_страницы(html_):
    т = re.sub(r"<script.*?</script>|<style.*?</style>|<!--.*?-->", " ",
               html_, flags=re.S | re.I)
    т = re.sub(r"<[^>]+>", " ", т)
    т = re.sub(r"&nbsp;?|&#160;?", " ", т)
    return re.sub(r"\s+", " ", т).lower()

И сразу первое решение, о котором мы пожалели бы, если бы его не приняли: страницы, с которых пришло меньше тысячи знаков текста, не попадают в знаменатель.

МИНИМУМ_ЗНАКОВ = 1000

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

Словарь признаков

Каждый признак — регулярное выражение. Вот характерный фрагмент словаря:

НАЧАЛО_СЛОВА = r"(?<![а-яёa-z])"
ЧИСЛО = r"\d[\d\s .,]*(?:\d|млн|тыс|т\.р|тысяч|миллион\w*)"

ПРИЗНАКИ = {
    "обещание_прибыли": rf"(?:прибыл[ьи]|доход\w*|зарабат\w+|выручк\w+)\D{{0,25}}{ЧИСЛО}",
    "слово_окупаемость": НАЧАЛО_СЛОВА + r"окупа\w+",
    "паушальный_взнос": r"паушальн\w+",
    "роялти": r"роялти",
    "договор_или_знак": r"концесси\w+|товарн\w+ знак|роспатент",
}

Отдельно живут признаки, которые считаются только рядом с числом. Для них мало найти слово, нужно найти слово, у которого в окне в девяносто знаков стоит сумма или срок:

РЯДОМ_С_ЧИСЛОМ = {
    "окупаемость_с_цифрой": (НАЧАЛО_СЛОВА + r"окупа\w+", ЧИСЛО),
    "гарантия_у_денег": (r"гаранти(?:я|и|ю|ей|рован\w*|ру\w+)",
                         r"доход\w*|прибыл\w*|выручк\w*|окупа\w+|" + ЧИСЛО),
}
ОКНО = 90

Функция соседства возвращает не «да/нет», а цитату — кусок текста вокруг совпадения. Это мелочь, которая потом сэкономила нам день работы:

def рядом(текст, шаблон, сосед):
    """Найти шаблон, рядом с которым в окне стоит сосед. Вернуть цитату."""
    for м in re.finditer(шаблон, текст, flags=re.I):
        начало, конец = max(0, м.start() - ОКНО), м.end() + ОКНО
        кусок = текст[начало:конец]
        if re.search(сосед, кусок, flags=re.I):
            return кусок

Если бы признак возвращал булево значение, проверить его можно было бы только заново открыв страницу и поискав глазами. Возвращая цитату, скрипт сам приносит доказательство, и спор о каждом срабатывании занимает секунды.

Где метод соврал

Дальше самое интересное. Мы прогнали первую версию, получили доли, и уже собирались их использовать. Спасло правило, которое мы держим для любой своей выгрузки: перед тем как публиковать число, открой десяток совпадений и посмотри на них глазами.

Первое. «Окупаемость» нашлась там, где её нет. Шаблон окупа\w+ честно срабатывал на слове «покупателей»: внутри него есть «окупа». Лечится проверкой на начало слова, тем самым НАЧАЛО_СЛОВА выше. Классическая ошибка, про которую все знают, и которая всё равно случается, когда пишешь словарь из шестнадцати шаблонов подряд.

Второе. «Гарантия» оказалась не про деньги. Здесь ошибка интереснее, потому что регулярка технически права, а метод — нет. Слово «гарантия» на странице франшизы встречается постоянно, но относится к чему угодно:

  • «это гарантирует высокий уровень профессионализма вашей команды» — барбершоп, речь про обучение;

  • «как именно мы гарантируем качество и ту самую „магию“ в каждом центре» — детский центр, речь про услугу;

  • «имидж мойки самообслуживания, который гарантирует привлекательность» — автомойка, речь про формат;

  • «гарантийный срок оборудования» — пекарня, речь про технику.

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

Третье. Слово не равно обещанию. Страница, где написано «рассчитаем окупаемость вашей точки» в форме заявки, и страница, где написано «окупаемость 8 месяцев», по первой версии скрипта были одинаковыми. Отсюда правило «рядом с числом»: в свод идёт только второй случай.

Четвёртое, и самое дорогое. Оговорка. Признак «оговорка про цифры» ищет формулировки вида «не является публичной офертой», «носит информационный характер», «зависит от города и вашей работы». В первой выгрузке оговорка нашлась на семи страницах из шестнадцати. После разбора цитат осталось три из пятнадцати.

Разница вдвое взялась из одного механизма: регулярка находила фразу, похожую на оговорку, но относящуюся не к обещанной цифре, а к чему‑то соседнему. Живой пример из выборки доставки еды: «многое зависит от города и ваших стартовых инвестиций, поэтому мы совместно анализируем город». Шаблон зависит от (?:города|...) сработал. Но в контексте это не оговорка к прогнозу дохода, а описание того, как выбирают точку. Оговорки к цифрам на той странице нет.

Если бы мы опубликовали первую выгрузку, главный вывод исследования поменялся бы на противоположный по смыслу: «почти половина страниц страхует свои цифры оговоркой» вместо «оговорку к цифрам даёт каждая пятая».

Ручная проверка как часть данных

Мы не стали править числа в файле молча. Каждое решение, принятое глазами, лежит отдельным файлом ручная-правка.json, и у каждой правки одинаковый набор полей: страница, признак, было, стало, цитата, почему.

{
  "страница": "автомойка",
  "признак": "гарантия_у_денег",
  "было": true,
  "стало": false,
  "цитата": "имидж мойки самообслуживания, который гарантирует привлекательность автомойки",
  "почему": "гарантия относится к привлекательности формата, а не к доходу"
}

Таких правок восемь. Рядом лежит данные-до-правки.json — исходная машинная выгрузка, которую никто не трогал.

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

Что получилось после правок

Итоговые числа по выборке из пятнадцати размеченных страниц:

Признак

Сколько страниц

Доля

форма заявки

15 из 15

100%

обещание прибыли рядом с числом

12 из 15

80%

окупаемость с цифрой

12 из 15

80%

роялти назван

11 из 15

73%

паушальный взнос назван

10 из 15

67%

калькулятор или квиз

7 из 15

47%

оговорка к цифрам

3 из 15

20%

упомянут договор или товарный знак

3 из 15

20%

гарантия рядом с деньгами

2 из 15

13%

Если объединить два денежных признака, цифру дохода или окупаемости показывают тринадцать страниц из пятнадцати, а оговорку рядом с ней дают три. Две страницы выборки прогноза заработка не дают вовсе.

Мы сознательно не пишем, хорошо это или плохо. Скрипт считает наличие блоков, и на этом его полномочия заканчиваются.

Почему регулярки, а не модель

Вопрос законный: задача выглядит как классификация коротких фрагментов текста, и языковая модель справилась бы с контекстом лучше любого шаблона. «Гарантируем качество обучения» и «гарантируем доход» она различит без окна в девяносто знаков.

Мы всё же остались на регулярках, и вот по каким причинам.

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

Регулярка объяснима до последнего символа. Когда вы публикуете долю и кто‑то с ней спорит, вам нужно показать не «модель так решила», а шаблон и цитату, на которой он сработал. Ответ «мы спросили модель, она посчитала это оговоркой» в таком споре не работает: ваш собеседник не может его ни проверить, ни воспроизвести.

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

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

Границы, которые мы записали рядом с числами

Каждое число выше верно только внутри своей выборки. Ограничения мы держим в том же файле, что и результат, чтобы их нельзя было потерять при пересказе:

  1. отбор не случайный, страницы пришли из верхних позиций поисковой выдачи, поэтому выборка смещена в сторону тех, кто вкладывается в продвижение;

  2. признак говорит только о присутствии слова или блока и ничего — о его заметности: оговорка шестым кеглем внизу страницы и оговорка рядом с цифрой для скрипта неразличимы;

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

  4. часть содержимого дорисовывается скриптами после загрузки, и разметка этого не видит;

  5. сервер, с которого шёл сбор, стоит за пределами страны, поэтому страница, закрытая от зарубежных адресов, могла не отдаться;

  6. «цифра в начале страницы» считается по первой тысяче знаков текста в коде, это близко к первому экрану, но не равно ему;

  7. спорные срабатывания проверены вручную и перечислены отдельным файлом.

Пятый пункт стоит отдельного слова. География сервера — это не мелочь инфраструктуры, а ограничение выборки: она влияет на то, какие страницы вообще попали в данные.

Что мы вынесли из этой работы

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

Возвращайте цитату, а не булево значение. Это стоит трёх лишних строк и превращает проверку выгрузки из перечитывания страниц в чтение списка.

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

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

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

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