The Jerusalem Post‘Hager (Land),’ an epic story of Ethiopian immigrants, wins Best Israeli Film at Haifa festESPNWeek 5 Power Rankings: Miami moves up, Missouri and Pitt join the top 25PunchTwo dead, one missing as floods hit Spain cityDaily MaverickSOCIAL (IN)SECURITY: A Cape Town mother buys bread on credit to stretch her children’s grants, then spends payday settling the debtRTP Desporto12h30 Henrique Calisto quer saída digna para Cristiano RonaldoSky TG24Istat, la pressione fiscale aumenta al 43,5%. Cala il potere d'acquisto delle famiglieZDF heuteAktuelle Pressemitteilungen des ZDFObservador DesportoAs notícias das 13hGlobal NewsU.S. Coast Guard suspends search for missing Ontario air ambulanceStraits Times SportFrom jiu-jitsu fighter Noah Lim a lovely lesson: Grace after gritABC NewsSouth Korea blames North Korean mines for border blast injuries and demands apologyسكاي نيوز عربيةمقاعد من الركام.. رحلة غزاوي لإعالة أسرته ومساعدة الطلاب
The Daily Newsstand · Free, Always
Monday, October 5, 2026

boilerplate_ratio = 1.73: что считает аудитор готовности страниц к ответам ИИ

Translate

Инструменты класса GEO (оптимизация страниц под ответы языковых моделей) выдают балл от 0 до 100 и список рекомендаций. Балл выглядит как измерение и уезжает в тикеты и дашборды.

Я взял один такой инструмент, geo-optimizer-skill (PyPI, MIT, автор Juan Camilo Auriti, репозиторий auriti-labs/geo-optimizer-skill), CLI версии 4.18.2, и прочитал, из чего балл складывается. Поводом стал батч: прогон по нескольким десяткам адресов вернул нули по всем. Я полез смотреть, как считается ноль, и остался читать дальше.

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

мини: пакет ratio 1.73, флаг True | правка ratio 0.87, флаг True | весь текст 60 знаков

Одной командой пакет проверяет robots.txt, llms.txt и четыре точки AI-дискавери, которые иначе смотришь руками по одной, а код разложен по файлу на проверку, так что его можно читать выборочно. Ниже шесть мест, где напечатанное число значит не то, что читается с ходу, и предложение по починке на каждое. Разбор нужен, чтобы понимать, какую именно величину вы читаете.

Как смотреть исходники

Пакет ставится как утилита, файлы лежат в её venv:

uv tool install "geo-optimizer-skill[mcp]"
PY=$(uv tool dir)/geo-optimizer-skill/bin/python
GEO=$($PY -c 'import geo_optimizer, pathlib; print(pathlib.Path(geo_optimizer.__file__).parent)')
ls $GEO   # cli core i18n mcp models skills utils web, плюс __init__.py, py.typed, __pycache__

Проверки в core/, по файлу на проверку; веса и пороги в models/config.py, сборка балла в core/scoring.py. Примеры ниже запускались тем самым $PY (3.11.13): в его venv уже стоят beautifulsoup4 4.15.0 и httpx 0.28.1. Замеры публичных страниц сделаны 02.10.2026.

Находка 1: доля шаблона, которая бывает больше единицы

core/audit_negative.py, цитата сокращена, комментарии убраны (фрагмент сам по себе не запускается):

main_content = text_soup.find("main") or text_soup.find("article") or text_soup.find(attrs={"role": "main"})
if main_content and total_text_len > 0:
    result.boilerplate_ratio = round(1.0 - (main_text_len / total_text_len), 2)
elif total_text_len > 0:
    nav_footer_len = 0
    for tag in text_soup.find_all(["nav", "footer", "header"]):
        nav_footer_len += len(tag.get_text(separator=" ", strip=True))
    if nav_footer_len > 0:
        result.boilerplate_ratio = round(nav_footer_len / total_text_len, 2)

Ветки считают разные вещи. Лендмарк (main, article, role="main") найден: доля шаблона это «весь текст минус текст лендмарка». Лендмарка нет: это сумма текста всех nav, footer, header, делённая на весь текст страницы.

Вторая ветка арифметически долей не является. find_all(["nav", "footer", "header"]) возвращает и header, и вложенный в него nav, поэтому текст меню входит в сумму дважды. Потолка нет, а boilerplate_high сравнивает величину с BOILERPLATE_RATIO_THRESHOLD = 0.6. Флаг идёт в signals_found, тот задаёт severity, а severity даёт штраф к баллу: артефакт доезжает до итоговой цифры.

Два документа, во втором двойной счёт переворачивает вердикт:

from bs4 import BeautifulSoup
from geo_optimizer.core.audit_negative import audit_negative_signals
from geo_optimizer.models.results import ContentResult, MetaResult, SchemaResult

MENU = "Главная Разделы Архив Теги"
mini = ("<html><body><header><nav>" + MENU + "</nav></header><p>Текст.</p>"
        "<footer><nav>" + MENU + "</nav></footer></body></html>")
page = mini.replace("<p>Текст.</p>", "<p>Короткий абзац про устройство формулы.</p>")

def fallback_fixed(s):
    total = len(s.get_text(" ", True))
    tops = [t for t in s.find_all(["nav", "footer", "header"])
            if t.find_parent(["nav", "footer", "header"]) is None]
    boiler = sum(len(t.get_text(" ", True)) for t in tops)
    return round(min(boiler / total, 1.0), 2) if total else 0.0

for name, html in (("мини", mini), ("второй", page)):
    s = BeautifulSoup(html, "html.parser")
    r = audit_negative_signals(s, html, ContentResult(), MetaResult(), SchemaResult(),
                               soup_clean=s).boilerplate_ratio
    f = fallback_fixed(s)
    print(f"{name}: пакет ratio {r}, флаг {r > 0.6} | правка ratio {f}, флаг {f > 0.6} |"
          f" весь текст {len(s.get_text(' ', True))} знаков")
мини: пакет ratio 1.73, флаг True | правка ratio 0.87, флаг True | весь текст 60 знаков
второй: пакет ratio 1.13, флаг True | правка ratio 0.57, флаг False | весь текст 92 знаков

В первом документе меню 26 знаков, узлов нашлось четыре (header, nav, footer, nav), 26 * 4 = 104 при 60 знаках текста, отсюда 1.73. Во втором одна и та же страница по коду пакета «перегружена шаблоном», а при подсчёте без двойного счёта остаётся ниже порога.

Правильная реализация в пакете уже есть: detect_boilerplate_ratio в core/citability.py убирает nav, header и footer через decompose, поэтому двойного счёта там нет, а величина ограничена единицей по построению. На тех же двух документах она печатает долю контента 0.1 и 0.41, то есть шаблон 0.9 и 0.59, и второе сходится с fallback_fixed. Обе реализации работают в одном прогоне geo audit: core/audit.py вызывает audit_citability рядом с негативными сигналами. На главной python.org первая печатает долю шаблона 0.65, то есть контента 35 процентов, вторая долю контента 0.03 (она не смотрит role="main" и уходит в эвристику). На листинге статей Хабра обе согласны: 0.02 и 0.98.

У лендмарк-ветки своя особенность: шаблоном считается всё вне лендмарка, включая контент соседних секций.

# продолжение предыдущего примера
from geo_optimizer.utils.http import fetch_url

for url in ("https://www.python.org/", "https://habr.com/ru/articles/"):
    raw = fetch_url(url)[0].text or ""
    soup = BeautifulSoup(raw, "html.parser")
    for t in soup(["script", "style", "noscript", "template"]):
        t.decompose()
    lm = soup.find("main") or soup.find("article") or soup.find(attrs={"role": "main"})
    r = audit_negative_signals(soup, raw, ContentResult(), MetaResult(), SchemaResult(),
                               soup_clean=soup).boilerplate_ratio
    print(url, "| лендмарк:", lm.name if lm else None, "| ratio:", r)
https://www.python.org/ | лендмарк: section | ratio: 0.65
https://habr.com/ru/articles/ | лендмарк: main | ratio: 0.02

На главной python.org лендмарк это section role="main" лишь с частью контента, на листинге Хабра в main попадает почти вся страница. Разница в тридцать раз между двумя страницами со сходным каркасом (шапка, подвал, поток блоков) задана только тем, что тема положила в лендмарк.

Лечится так: переиспользовать detect_boilerplate_ratio вместо второй ветки в audit_negative.py. Если держать две реализации, то в запасной ветке брать только верхнеуровневые узлы (как в fallback_fixed) либо объединять множества узлов перед подсчётом длины, плюс min(..., 1.0), чтобы величина с именем ratio оставалась долей.

Находка 2: регулярки, которые не знают кириллицы

core/audit_rag.py, цитата как в пакете:

_DEFINITION_RE = re.compile(
    r"^[A-Z][^.]{5,60}\b(?:is|are|refers?\s+to|means?|describes?|represents?)\b",
    re.MULTILINE,
)
_ANCHOR_RE = re.compile(r"(?<=[.!?]\s)[A-Z][^.!?]{30,200}[.!?]")

Первая проверяет, открывается ли страница определением вида «X is Y». Вторая считает «якорные» предложения: самодостаточные утверждения 30-200 знаков (комментарий над регуляркой при этом обещает «10-40 words», то есть документация и шаблон меряют разные единицы). Оба результата идут в chunk_readiness_score. Класс [A-Z] кириллицу не покрывает, а связки перечислены только английские: «X это Y», «называется», «представляет собой» в шаблон не попадают. Берём объекты прямо из пакета и один текст в двух языках:

import re
from geo_optimizer.core.audit_rag import _ANCHOR_RE, _DEFINITION_RE

UP = "A-ZА-ЯЁ"
DEF_FIX = re.compile(
    rf"^[{UP}][^.]{{5,60}}(?:\b(?:is|are|refers?\s+to|means?|describes?|represents?)\b"
    r"|\s(?:это|называется|представляет\s+собой|означает)\b)", re.MULTILINE)
ANCHOR_FIX = re.compile(rf"(?<=[.!?]\s)[{UP}][^.!?]{{30,200}}[.!?]")

ru = ("Генеративная оптимизация это набор приёмов для попадания в ответ модели. "
      "Модель забирает со страницы готовый фрагмент текста вместе с источником.")
en = ("Generative optimization is a set of techniques for landing in a model answer. "
      "The model lifts a ready fragment of text from the page along with its source.")

for name, text in (("ru", ru), ("en", en)):
    print(f"{name}: пакет definition={bool(_DEFINITION_RE.search(text[:150]))} "
          f"anchors={len(_ANCHOR_RE.findall(text))} | правка "
          f"definition={bool(DEF_FIX.search(text[:150]))} "
          f"anchors={len(ANCHOR_FIX.findall(text))}")
ru: пакет definition=False anchors=0 | правка definition=True anchors=1
en: пакет definition=True anchors=1 | правка definition=True anchors=1

Один и тот же по смыслу текст: на английском определение найдено и один якорь, на русском ноль якорей и определения нет. В _compute_score из того же файла это 10 баллов за определение и до 10 за якоря: до пятой части chunk_readiness_score недоступны русской странице независимо от её качества.

Починка: добавить кириллические классы и русские связки, как в примере, либо вынести язык в конфиг проекта (.geo-optimizer.yml пакет уже читает). И побочный эффект (?<=[.!?]\s): первое предложение якорем не считается никогда, отсюда один якорь на два предложения.

Находка 3: сто баллов складываются не из всего, что печатается

models/config.py:

CATEGORY_MAX = {
    "robots": 18, "llms": 18, "schema": 16, "meta": 14,
    "content": 12, "brand_entity": 10, "signals": 6, "ai_discovery": 6,
}

Сумма ровно 100. Но в разбивке из core/scoring.py есть ключ, которого в константе нет:

from geo_optimizer.models.config import CATEGORY_MAX
from geo_optimizer.core.scoring import compute_score_breakdown
from geo_optimizer.models.results import (ContentResult, LlmsTxtResult, MetaResult,
                                          RobotsResult, SchemaResult)

keys = list(compute_score_breakdown(RobotsResult(), LlmsTxtResult(), SchemaResult(),
                                    MetaResult(), ContentResult()))
print("сумма CATEGORY_MAX:", sum(CATEGORY_MAX.values()),
      "| нет в CATEGORY_MAX:", [k for k in keys if k not in CATEGORY_MAX])
print("rag_chunk и context_window в балле:", "rag_chunk" in keys, "context_window" in keys)
сумма CATEGORY_MAX: 100 | нет в CATEGORY_MAX: ['negative_penalty']
rag_chunk и context_window в балле: False False

negative_penalty отрицателен (от -1 до -5 по NEGATIVE_PENALTY_LOW/MED/HIGH), в сотню не входит и вычитается после, а итог возвращается как max(0, min(total, 100)). Значит, 100 недостижимы, если сработал хоть один негативный сигнал. geo audit --url https://www.python.org/ --format json дал 42 при разбивке robots 15, llms 0, schema 3, meta 11, content 10, signals 5, ai_discovery 0, brand_entity 3, negative_penalty -5: 47 положительных минус 5.

Второе следствие важнее: rag_chunk и context_window считаются на каждом прогоне и печатаются своими шкалами 0-100, но в балл не входят. У той же страницы балл 42 при chunk_readiness_score 47 (совпадение с суммой положительных категорий случайное, это разные величины). Высокий балл совместим с негодной для нарезки структурой текста.

Починка: собрать CATEGORY_MAX и штраф в одну структуру с тестом на инвариант, а в выводе отделить «балл» от «метрик вне балла», чтобы chunk_readiness_score рядом с итогом не читался как его часть.

Находка 4: десять секунд на пятнадцать запросов

core/batch_audit.py, цитата сокращена:

try:
    return await asyncio.wait_for(
        _audit_single_url(url, use_cache=use_cache, project_config=project_config),
        timeout=AUDIT_TIMEOUT_SECONDS,
    )
except asyncio.TimeoutError:
    result = AuditResult(url=url, error=f"Timeout ({AUDIT_TIMEOUT_SECONDS}s)", band="critical")

AUDIT_TIMEOUT_SECONDS = 10 отведены не на запрос, а на весь аудит одного адреса, под семафором на concurrency. Внутри адреса пакет одной пачкой тянет страницу, robots.txt, llms.txt, llms-full.txt и четыре точки AI-дискавери (/.well-known/ai.txt, /ai/{summary,faq,service}.json), а затем последовательно проверяет блокировку ботов на CDN: один запрос браузерным User-Agent и шесть ботовскими. Таймаут отдельного запроса тоже 10 секунд: timeout: int = 10 в utils/http_async.py на асинхронном пути и столько же в utils/http.py на синхронном. Один медленный запрос съедает весь бюджет адреса.

Поднимем заглушку и посчитаем запросы. В CLI это geo audit --sitemap <url> --concurrency 5 --max-urls 5, отдельной команды batch нет. Анти-SSRF не пускает на 127.0.0.1, поэтому проверка в примере подменена, только ради этого. Сохранить как demo.py, единственный аргумент это задержка ответа сервера в секундах:

import collections, sys, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import geo_optimizer.utils.validators as V
V.resolve_and_validate_url = lambda u: (True, None, ["127.0.0.1"])  # только ради 127.0.0.1
from geo_optimizer.core.batch_audit import run_batch_audit

DELAY, hits = float(sys.argv[1]), collections.Counter()
PAGES = [f"/p{i}" for i in range(1, 6)]
PAGE = b"<html><head><title>T</title></head><body><h1>H</h1><p>text</p></body></html>"

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        hits[self.path] += 1; time.sleep(DELAY)
        body = MAP if "sitemap" in self.path else PAGE if self.path in PAGES else b""
        self.send_response(200 if body else 404); self.send_header("Content-Length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
    def log_message(self, *a): pass

srv = ThreadingHTTPServer(("127.0.0.1", 0), H)   # 0 = свободный порт
BASE = f"http://127.0.0.1:{srv.server_address[1]}"
MAP = ("<urlset>" + "".join(f"<url><loc>{BASE}{p}</loc></url>" for p in PAGES) + "</urlset>").encode()
threading.Thread(target=srv.serve_forever, daemon=True).start()
t0 = time.perf_counter()
res = run_batch_audit(f"{BASE}/sitemap.xml", max_urls=5, concurrency=5)
print(f"задержка {DELAY}s, батч {time.perf_counter()-t0:.1f}s, баллы {[p.score for p in res.pages]}")
print("ошибки:", dict(collections.Counter(p.error or "нет" for p in res.pages)),
      "| запросов всего:", sum(hits.values()), "| к /p1:", hits["/p1"])
print("успешных:", res.successful_urls, "| упавших:", res.failed_urls,
      "| средний балл:", res.average_score)
$ $PY demo.py 0
задержка 0.0s, батч 0.2s, баллы [6, 6, 6, 6, 6]
ошибки: {'нет': 5} | запросов всего: 76 | к /p1: 8
успешных: 5 | упавших: 0 | средний балл: 6.0

$ $PY demo.py 1.5
задержка 1.5s, батч 13.7s, баллы [0, 0, 0, 0, 0]
ошибки: {'Timeout (10s)': 5} | запросов всего: 76 | к /p1: 8
успешных: 0 | упавших: 5 | средний балл: 0.0

76 запросов на пять адресов: один на карту сайта и по 15 на адрес, из них 8 к самой странице (один обычный и семь с подменой User-Agent) и 7 к сопутствующим файлам. Так что --concurrency 5 (это дефолт) держит в полёте пять адресов по пятнадцать запросов каждый: восемь уходят одним gather, семь следом последовательно. Заглушка со счётчиком одновременных соединений даёт пик 40, а не 75. Сервер с ответом за 1.5 секунды превращает весь батч в нули.

Среднее при этом автор защитил: _aggregate_batch_result считает его только по страницам без ошибки, поэтому в отчёте видно успешных: 0 и упавших: 5, а ноль в average_score это дефолт поля при пустой выборке. Но рядом стоит average_band: critical, и в такой паре ноль читается как оценка сайта, хотя описывает его отзывчивость.

Запросов по-прежнему 76, хотя все пять аудитов отменены. Проверка CDN идёт через asyncio.to_thread, а поток wait_for отменить не может: он продолжает ходить на сервер после того, как результат выброшен, поэтому батч заканчивается около четырнадцатой секунды вместо 11.5 (у меня 13.7-13.9 по прогонам). На синхронном пути (флаг --cache или отсутствие httpx) в поток уходит весь аудит, а не одна проверка CDN, так что там таймаут не отменяет вообще ничего.

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

Находка 5: порог веса страницы в сырых байтах HTML

core/agent_access.py, команда geo access (в балл geo audit эта проверка не входит), цитата сокращена:

result.page_weight_bytes = len(raw_html.encode("utf-8"))
if result.page_weight_bytes > 200_000:
    result.warnings.append(f"Heavy initial HTML: ... (target: under 200KB ...)")

Порог зашит числом и не настраивается. Что он ловит:

from bs4 import BeautifulSoup
from geo_optimizer.utils.http import fetch_url

for url in ("https://www.python.org/", "https://habr.com/ru/articles/"):
    raw = fetch_url(url)[0].text or ""
    soup = BeautifulSoup(raw, "html.parser")
    for t in soup(["script", "style", "noscript", "template"]):
        t.decompose()
    html, text = len(raw.encode()), len(soup.get_text(" ", True).encode())
    print(url, f"HTML {html/1000:.0f} KB,", "штраф" if html > 200_000 else "ок",
          f"| текст {text/1000:.0f} KB ({text/html:.0%} от веса)")
https://www.python.org/ HTML 53 KB, ок | текст 7 KB (13% от веса)
https://habr.com/ru/articles/ HTML 327 KB, штраф | текст 36 KB (11% от веса)

Листинг статей Хабра получает предупреждение о «тяжёлом HTML» при 36 КБ полезного текста. Доля текста у обеих страниц около 12 процентов. Модель работает с текстом после извлечения, поэтому штраф по сырым байтам говорит о фреймворке. Порог стоит сделать настраиваемым и считать отношение извлечённого текста к весу HTML: оно различает «тяжёлую страницу» и «тяжёлую обвязку вокруг тонкого текста», а это разные диагнозы.

Находка 6: флаг --lang на верхнем уровне и без эффекта

cli/main.py, цитата сокращена:

@click.group()
@click.option("--lang", default=None, envvar="GEO_LANG",
              type=click.Choice(["it", "en"], case_sensitive=False),
              help="Lingua output: it (default), en")
def cli(lang):
    ...

Опция объявлена на группе, а не на подкоманде, поэтому привычный порядок не работает (вывод второй команды обрезан до первой строки, дальше идёт обычная справка):

$ geo audit --lang en --url https://example.com
Usage: geo audit [OPTIONS]
Try 'geo audit --help' for help.

Error: No such option '--lang'.

$ geo --lang en audit --help 2>&1 | head -1
Note: --lang=en accepted. Full CLI localization is planned for a future release; output is currently in English regardless of this flag.

Предупреждение честное: файлы .mo в i18n/locales/ лежат для it и en, но строки через gettext не прогнаны, функция _ из i18n не импортируется больше нигде. Дефолтный язык итальянский (DEFAULT_LANG = "it", читается из GEO_LANG), выбор ограничен it и en. В докстроке модуля i18n как пример приведён именно нерабочий порядок, geo audit --lang en. Лечится дублем опции у подкоманд или подсказкой правильного порядка, а пример в докстроке стоит привести в соответствие с парсером.

Что из этого следует

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

Четыре правила переносятся на любой похожий код, включая ваш собственный:

  • find_all по набору вложенных тегов считает текст дважды, поэтому величине с именем ratio нужны дедупликация узлов и min(..., 1.0);

  • латинский класс [A-Z] в регулярке обнуляет метрику на нелатинском тексте молча, без ошибки и без предупреждения;

  • у составного балла должен быть один источник весов и тест на сумму, а метрики в той же шкале 0-100 рядом с итогом читаются как его часть;

  • таймаут, навешенный на составную задачу вместо запроса, превращает «медленно» в «ноль», а флаг concurrency начинает врать о числе запросов в полёте.

Если этот инструмент уже в работе:

  • в батче смотреть error раньше, чем score: Timeout (10s) и 0 в одной строке это не оценка страницы;

  • boilerplate_ratio читать вместе с фактом наличия лендмарка: значение около единицы на странице без main это артефакт ветки, а не 99 процентов шаблона;

  • chunk_readiness_score и context_window не складывать с итоговым баллом;

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

Четыре правки из шести локальные: кириллические классы в двух регулярках, min(..., 1.0) и фильтр вложенных узлов в одной ветке, два числа в конфиг, дубль опции у подкоманд. Две остальные крупнее: единая структура весов со штрафом и флаг отмены для синхронных проверок. Лицензия MIT, так что это кандидаты в pull request, а не повод искать замену.

Чего этот разбор не покрывает

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

  • Смотрел одну версию, 4.18.2, на Python 3.11.13. В другой версии ветки могли измениться.

  • Все шесть находок проверены запуском, но на синтетических документах, двух публичных страницах и локальной заглушке, а не на выборке сайтов. Инвариант «сумма весов ровно 100» проверен сложением константы, а не тестом пакета.

  • Числа с живых страниц дрейфуют между прогонами. Сегодня python.org даёт boilerplate_ratio 0.65 и chunk_readiness_score 47, в прогонах несколькими днями раньше там было 0.68 и 45; вес листинга Хабра у меня ходил в пределах 327-337 КБ, батч на быстрой заглушке 0.2-0.5 с. Сверять стоит порядок величины, а не цифру.

  • Длительность синхронного пути (--cache) я не замерял, только прочитал.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.