Инструменты класса 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) я не замерял, только прочитал.

Комментарии (0)