В прошлой статье серии мы разобрали, что технически делает вас доступным для моделей: открытые retrieval-боты, цитируемая структура, а не волшебный llms.txt. Но доступность — не то же самое, что факт. Можно открыть всех ботов и по-прежнему не появляться ни в одном ответе. Единственный способ это узнать — регулярно спрашивать модели и смотреть, что они говорят про ваш бренд и ссылаются ли на вас. Разово это делают руками за пять минут. Дальше начинается проблема: «руками» невозможно превратить в тренд, а без тренда GEO нечем управлять. Здесь соберём минимальный автоматический счётчик AI-видимости — на том же стеке, которым уже измеряют отказ модели: cron пишет снимок, страница только читает снимок.
Это практика, а не обзор платных дашбордов. Метрики в GEO пока не стандартизированы (об этом — следующая и предыдущая статьи серии), поэтому счётчик мы строим сами, под свою задачу, и по ходу дела честно обсудим, где его цифры нельзя брать на веру.
Часть I. Что именно мы решаем измерять
Прежде чем писать код, назовём величину. В GEO-литературе (термины пока не устоялись, см. дисклеймер выше) для «видимости бренда в ответах моделей» используют связку понятий:
- Visibility (доля видимости) — как часто бренд вообще появляется в ответах на набор тестовых запросов. Грубая форма:
упоминания / число_прогонов. - Citation / source — не просто «упомянули всуе», а дали ссылку на источник. Различают домен в цитатах (
codestack.exampleгде-то в списке источников) и deep-link (конкретная ваша страница). Это разные события: вас могли процитировать по чужой статье о вас. - Sentiment — с какой интонацией бренд упомянут (нейтрально/позитивно/негативно). Самая ненадёжная из трёх — см. оговорку в Части IV.
- Share of Model (SoM) — ваш «доля рынка» на фоне конкурентов: как часто модель выбирает вас среди всех упомянутых брендов в той же категории. Прямая замена «share of voice» из классического маркетинга; определение — в первой статье серии.
Что реально может посчитать ваш счётчик сам, без платного API-партнёра: visibility и citation (по регуляркам над текстом ответа) — надёжно; sentiment — только если попросить модель классифицировать и держать это как оценочную метку. SoM требует набора брендов-конкурентов в том же промпте — тоже считаете, но закладываетесь на ручной список.
Зачем это вебмастеру в принципе. Не для красивого графика. Чтобы ответить на три практических вопроса: (1) после переписывания заголовков в утверждения из прошлой статьи — стала ли видимость расти; (2) какая из моделей/движков вас вообще показывает; (3) не исчезли ли вы после того, как вы (или вендор) что-то поменяли в robots.txt/индексе. Без регулярного замера любой из этих вопросов — гадание.
Ручная проверка «спроси ChatGPT про мой сайт» годится ровно один раз, чтобы понять порядок величин. Превратить её в тренд руками невозможно: нужен фоновый прогон, хранение истории и сравнение снимков. Строим.
Часть II. Почему один запрос — не метрика, и как считать честно
Самый частый способ испортить счётчик — сделать один запрос и записать «упомянули / не упомянули» как факт. Так не работает по двум причинам.
Причина 1 — LLM не детерминированна. Даже при temperature => 0 один и тот же запрос к той же модели сегодня и завтра даёт разный текст: модель за кэши, батчинг запросов у провайдера, её собственные обновления. temperature=0 снижает разброс, но не делает ответ воспроизводимым между запусками. Значит, «спросил один раз и увидел свой домен» не означает, что и следующий пользователь увидит. Видимость — вероятность, и оценивать её надо по частоте на множестве повторов однотипных запросов.
Причина 2 — короткая выборка красиво врёт. Допустим, на 10 запросах бренд упомянули 6 раз. Записать «видимость 60%» — ловушка: с 10 наблюдений такая точка почти ничего не гарантирует. Правильно давать не точку, а интервал, в котором с высокой вероятностью лежит истинная доля. Самый дешёвый и устойчивый для малых выборок вариант — интервал Уилсона (Wilson score interval):
<?php
// Доля p с 95% доверительным интервалом Уилсона.
// x — число успехов (упоминаний), n — число прогонов.
function visibility_ci( int $x, int $n, float $z = 1.956 ): array {
if ( $n < 1 ) {
return array( 'point' => null, 'lo' => null, 'hi' => null );
}
$p = $x / $n;
$z2 = $z * $z;
$den = 1 + $z2 / $n;
$cent = ( $p + $z2 / ( 2 * $n ) ) / $den;
$half = ( $z / $den ) * sqrt( $p * ( 1 - $p ) / $n + $z2 / ( 4 * $n * $n ) );
return array(
'point' => $p,
'lo' => max( 0.0, $cent - $half ),
'hi' => min( 1.0, $cent + $half ),
);
}Что он показывает на реальных числах (берём из того же примера):
Прогонов n | Упоминаний x | Точка | 95% интервал | Ширина |
|---|---|---|---|---|
| 10 | 6 | 0,60 | [0,31 … 0,83] | 0,52 |
| 20 | 12 | 0,60 | [0,39 … 0,78] | 0,39 |
| 100 | 62 | 0,62 | [0,52 … 0,71] | 0,19 |
Читайте так: «на 10 запросах» — это интервал шириной полведа, то есть вы не отличили бы «почти невидим» от «почти всегда виден». Чтобы отличить рост видемости на 10 процентных пунктов, нужно десятки повторов, а не один. Это меняет экономику: счётчик должен уметь делать N однотипных запросов за прогон — и делать это не дорого (см. квоту в Части VI). Точку на дашборде показываем, но всегда с интервалом, иначе график — декорация.
Часть III. Набор промптов — это прибор: версионируем и ловим дрейф
Если метрика считается по набору запросов, то сам набор — часть определения метрики, а не свободный текст. Сменили формулировку — прежний ряд истории становится несопоставимым, и «мы выросли с 40% до 55%» легко оказывается артефактом правки промпта. Отсюда два правила.
Правило 1 — промпты версионированы и неизменяемы после записи. Каждый снимок хранит номер версии набора (probe_set_v3). Историю сравниваем только внутри одной версии; при смене набора откатываемся на новую линию тренда и честно отмечаем разрыв на графике. Не «подкрутили промпт и полагали, что тренд тот же».
Правило 2 — набор отражает реальные вопросы, а не тестировщика. Если вы пишете промпты как «расскажи про бренд X полностью», вы измеряете не то, что спрашивают люди. Промпты-зонды собираются из интента, по которому вас должны находить: «как выбрать [категория]», «лучший [сервис] для [задача]», «сравни A и B», «где взять [решение]». Часть из них вы берёте из живой семантики — из ваших же запросов в поиске/чат-плагине, из топ-страниц по целевым темам. 10–20 таких формулировок, по каждой N повторов, — рабочий минимум.
Набор удобно держать отдельной табличкой/JSON, а не в коде счётчика, чтобы редактор мог добавлять вопросы, не трогая логику:
{
"version": 3,
"engine": "codestack.example",
"brand": "ExampleBrand",
"domain": "example.test",
"competitors": ["CompetitorA", "CompetitorB"],
"probes": [
"лучший сервис для генерации systemd-юнитов",
"чем проверить доступность модели на VPS",
"конструктор robots.txt онлайн"
]
}Поле brand/domain/competitors — то, на что потом смотрит парсер (Часть IV). Их тоже держите в версионированном наборе: если бренд ребрендингуется, прежнее измерение не переносится на новый brand автоматически.
Часть IV. Как извлечь событие из ответа: mention, citation, sentiment
У каждого ответа — три замера с растущей ненадёжностью. Разберём по честности, от твёрдого к мягкому.
4.1 Упоминание бренда. Самое простое и относительно надёжное — регулярка по имени бренда/домена, нормализуя регистр и частые опечатки. Хитрость: ищите и домен, и название (люди упоминают и так и так), и учитывайте, что бренд может пересекаться с обычным словом.
<?php
function brand_mentioned( string $answer, string $brand, string $domain ): bool {
// Нормализуем: убираем markdown-ссылки-синтаксис, приводим к нижнему регистру.
$a = mb_strtolower( preg_replace( '/\[[^\]]*\]\([^)]*\)/', ' ', $answer ) );
$b = mb_strtolower( $brand );
$d = mb_strtolower( $domain );
if ( false !== strpos( $a, $b ) ) { return true; }
// Домен ищем и с протоколом, и «голым» — часто цитируют как host.
$host = preg_replace( '#^https?://#', '', $d );
return false !== strpos( $a, $host );
}4.2 Цитата: домен против deep-link. Ссылку ищем в структурированном виде, где это возможно: у API с полем citations/sources/web_results берём их напрямую (надёжно). Если модель отдаёт markdown-ссылки в тексте — парсим URL из [текст](url) и из голого текста; это менее надёжно, и стоит помечать такие цитаты другим источником («в тексте» против «из поля sources»). Разделяйте два события: domain_cited (в источниках есть ваш хост, пусть даже другая страница) и url_cited (процитирована конкретная ваша страница) — они означают разное и лечатся по-разному.
<?php
// Собираем домены из markdown-ссылок и голых URL в ответе.
function cited_domains( string $answer ): array {
$urls = array();
// 1) markdown-ссылки [t](url)
if ( preg_match_all( '/\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/i', $answer, $m ) ) {
$urls = array_merge( $urls, $m[1] );
}
// 2) голые http-ссылки в тексте
if ( preg_match_all( '#https?://[^\s)\]<>"\']+#i', $answer, $m2 ) ) {
$urls = array_merge( $urls, $m2[0] );
}
$hosts = array();
foreach ( array_unique( $urls ) as $u ) {
$h = parse_url( $u, PHP_URL_HOST );
if ( $h ) { $hosts[] = mb_strtolower( preg_replace( '/^www\./', '', $h ) ); }
}
return array_values( array_unique( $hosts ) );
}⚠️ Ответ модели — недоверенный ввод. Вы только что извлекли из чужого ответа URL и вставите его куда-то: в отчёт, в ссылку на странице, в лог. Относитесь к нему как к любому пользовательскому вводу (то же правило, что в AI-безопасности): перед выводом — allowlist схем (
http/https, безjavascript:) и экранирование. Модель может вернуть ссылку, которой нет, или ссылку со встроенной разметкой — вы это не контролируете.
4.3 Sentiment — с оговоркой. Простая регулярка («плохо», «не рекомендую» против «рекомендую», «лучший») даёт мусор на коротком тексте. Честнее — отдельный классификационный запрос к модели: «нейтрально/позитивно/негативно про бренд X в этом ответе, одно слово в ответ». Но тогда sentiment — это оценка другой модели, и её надо (а) хранить вместе с моделью-классификатором и версией промпта-классификатора, (б) на дашборде показывать как «модельная оценка», а не как факт. Смешивать sentiment в одну колонку с «упомянут/не упомянут» и трактовать их одинаково достоверными — типичная ошибка: первое почти надёжно, второе — нет.
Итог Части IV — в событие снимка пишем раздельно: mentioned (bool), domain_cited (bool), url_cited (bool), sentiment (enum, nullable, источник=модель) и, для SoM, список конкурентов, упомянутых рядом.
Часть V. Архитектура счётчика: cron → снимок → рендер читает снимок
Теперь то, ради чего весь предыдущий перенос: точность метода зависит не от модели, а от дисциплины сбора — и эта дисциплина ровно та же, что в мониторе доступности моделей. Если вы читали «LLM как зависимость», вы уже знаете паттерн; здесь он переиспользуется почти без изменений, меняется только то, что мы кладём в снимок. Четыре инварианта:
1. Рендер ничего не запрашивает у API — только читает последний снимок. Иначе каждая перезагрузка страницы трекера = волна платных запросов к моделям. Снимок — строка в БД, которую обновляют ровно два источника: фоновый cron и явная кнопка «пересчитать сейчас».
2. Настоящий прогон — по внешнему будильнику, не по WP-Cron «по требованию». Ночью, когда никто не открывает страницы, «посмотрю утром тренд» превращается в «тренда нет». Системный cron (или wp cron event run) будит прогон по расписанию — тот же вывод, что в статье про зависимость.
3. Храним историю, а не «последнее значение». GEO — про тренд: «до переписывания заголовков / после». Минимальная схема — событие за прогон, плюс снимок-агрегат.
4. Прогон идемпотентен и квотирован. Если cron сработал дважды или прогон упал на середине — повтор не должен сдвоить историю или сожрать лишнюю квоту. Квота — часть архитектуры (см. Часть VI), а не «потом добавим».
Минимальная схема хранения (MySQL через $wpdb, префикс — на ваше имя плагина):
<?php
// Вызывается на активации. Таблица событий: по строке на (прогон, промпт, модель).
function cs_geo_install() {
global $wpdb;
$t = $wpdb->prefix . 'cs_geo_events';
$coll = $wpdb->collate;
$wpdb->query( "CREATE TABLE IF NOT EXISTS {$t} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
run_ts DATETIME NOT NULL, -- когда был прогон (для группировки в снимок)
probe_id INT NOT NULL, -- ссылка на версионированный набор промптов
probe_set_ver INT NOT NULL, -- версия набора (анти-дрейф, см. Часть III)
engine VARCHAR(64) NOT NULL, -- модель/движок, к которому слали запрос
mentioned TINYINT NOT NULL DEFAULT 0,
domain_cited TINYINT NOT NULL DEFAULT 0,
url_cited TINYINT NOT NULL DEFAULT 0,
sentiment VARCHAR(16) DEFAULT NULL, -- 'pos'|'neg'|'neu'|NULL (оценка модели)
src VARCHAR(8) NOT NULL DEFAULT 'api', -- 'api'|'inline' (откуда цитата)
INDEX (probe_set_ver, engine, run_ts)
) $coll" );
}Ядро прогона — цикл по промпты × повторы × движки, с вызовом API, извлечением событий (Часть IV) и вставкой строк. Сеть оборачиваем в transient-замок, чтобы две беды не случились: параллельные прогоны (двойная квота) и «рендер полез в сеть»:
<?php
add_action( 'cs_geo_run', function () {
// Анти-двойной прогон: кто-то уже считает — выходим.
if ( get_transient( 'cs_geo_running' ) ) { return; }
set_transient( 'cs_geo_running', 1, 300 ); // 5 мин замок на сетевой прогон
global $wpdb;
$set = cs_geo_current_probe_set(); // {version, brand, domain, competitors[], probes[]}
$run_ts = current_time( 'mysql', true );
foreach ( $set['probes'] as $pid => $probe ) {
for ( $i = 0; $i < CS_GEO_REPEATS; $i++ ) { // N повторов, а не 1 (Часть II)
foreach ( cs_geo_engines() as $engine ) { // список моделей/движков
$ans = cs_geo_query( $engine, $probe ); // один вызов API (с max_tokens и кэшем)
if ( '' === $ans ) { continue; } // транспорт/отказ — не пишем событие
$doms = cited_domains( $ans );
$wpdb->insert( $wpdb->prefix . 'cs_geo_events', array(
'run_ts' => $run_ts, 'probe_id' => $pid, 'probe_set_ver' => $set['version'],
'engine' => $engine,
'mentioned' => brand_mentioned( $ans, $set['brand'], $set['domain'] ) ? 1 : 0,
'domain_cited' => in_array( $set['domain'], $doms, true ) ? 1 : 0,
'url_cited' => cs_geo_deep_link_cited( $doms, $set['domain'] ) ? 1 : 0,
'sentiment' => null, 'src' => 'inline',
), array( '%s', '%d', '%d', '%s', '%d', '%d', '%d', '%s', '%s' ) );
}
}
}
delete_transient( 'cs_geo_running' );
} );Планирование и кнопка — те же два источника, что обновляют снимок в мониторе:
<?php
// Интервал фонового прогона: дискретный выбор (WP-Cron не принимает «7 минут»).
add_filter( 'cron_schedules', function ( $s ) {
$s['cs_geo_hourly'] = array( 'interval' => 3600, 'display' => 'Каждый час' );
return $s;
} );
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'cs_geo_run' ) ) {
wp_schedule_event( time() + 120, 'cs_geo_hourly', 'cs_geo_run' );
}
} );Агрегат (visibility + интервал Уилсона по движкам) считаем из событий и кладём в отдельный снимок-трансиент/опцию; страница трекера рендерит только агрегат. Пересчёт агрегата происходит после прогона и по кнопке — и не при открытии страницы.
Часть VI. Экономика, квота и как выглядит отчёт
Сколько это стоит. Прогон = число_промптов × повторы × число_движков вызовов. Возьмём реалистичный минимум: 15 промптов × 3 повтора × 2 движка = 90 запросов за прогон, каждый — на короткий вывод (просим ответ без простыни, жёсткий max_tokens, как в Части V у probe-запроса). При почасовом прогоне это 2160 запросов в сутки. Это уже заметная трата и по токенам, и по лимитам — отсюда:
- Ответ — только нужное. Просите модель ответить компактно; длинный «объяснительный» ответ и токенов съест, и парсить сложнее. Это тот же приём, что
max_tokens=1у probe-запроса: потолок вывода задаёт сервер - Квота и rate-limit — часть счётчика, а не «добавим позже». Дневной лимит на прогоны, пауза между вызовами, 429-ретрай — всё это в Защите API разобрано буквально; счётчик — ещё один потребитель тех же лимитов. Счётчик, не имеющий потолка, однажды сожрёт бюджет до конца месяца.
- Не все движки дают API. Часть «ответов» (веб-UI ChatGPT/Perplexity без API-доступа к истории) автоматизировать нельзя легально и стабильно — по ним ведите отдельную колонку «ручной spot-check» и не смешивайте с API-метрикой: это другое измерение.
Как выглядит отчёт и что с ним делать. Минимальный честный трекер показывает не одну цифру, а таблицу и её изменение:
- По каждому движку:
visibilityкак точка + интервал Уилсона (не точка!),domain_cited%,url_cited%, и сколько промптов вообще дали пустой/отказный ответ (src='api'пустые — это отказ, не «невидим»). - Trend-строка за последние K прогонов внутри одной
probe_set_ver(между версиями наборов — разрыв). - Top «откуда цитируют»: список доменов из
cited_domains— часто окажется, что модель чаще ссылается на Reddit/каталоги/обзоры о вас, чем на вас самих (это отдельный вывод, см. первую статью: часть AI-видимости живёт вне вашего сайта).
Действия после отчёта (иначе счётчик — просто числа):
- Прогалили конкретную страницу → переписываете её заголовки в утверждения и проверяете на следующем прогоне (гипотеза из прошлой статьи).
domain_citedвыше нуля, ноurl_citedноль → вас находят по чужим пересказам; усиливаете собственные deep-link-страницы (Q&A, канонические страницы под конкретный интент).- Один движок показывает стабильно ниже других → проверьте
robots.txtэтого движка (retrieval-бот открыт?) — прямая связь с Частью IV прошлой статьи. - Дрейф без смены промптов → почти наверняка обновление модели у вендора; фиксируйте
engine+дату в анализе, не приписывайте тренду то, что изменилась сама модель (тот же анти-дрейф-лог, что в зависимости про «429 — не значит упало всё»).
Счётчик не доказывает, что вы «стали лучше для SEO». Он превращает догадку «меня что, вообще кто-то в AI-ответах показывает?» в ряд чисел, на который можно реагировать — и это ровно та «полезность метрики, а не красивая цифра», которую разобрали в метриках и мониторинге.
Инструменты под задачу
Ручную стадию — «что модель говорит про мой сайт прямо сейчас» — удобно проходить через чат-ассистент на сайте: спрашиваете, смотрите ответ и его ссылки, сверяете с тем, что модель выдаёт из коробки, и попутно собираете кандидаты в промпты-зонды (реальные вопросы формулируются легче, когда их набираешь руками). Готовая страница ассистента: https://codestack.space/ai-pomoshhnik-dlja-koda/
Чек-лист граблей
- Сделали один запрос и записали «видимость». LLM не детерминирована даже при
temperature=0; считайте частоту на N повторов и интервал, а не точку. - Показали точку без доверительного интервала. 6/10 = «60%» из 10 наблюдений ни о чём; интервал Уилсона покажет, что отличить рост от шума нельзя.
- Подкрутили промпт и продолжили прежний тренд. Набор промптов — часть определения метрики; версии не смешиваются, на графике разрыв.
- Промпты написал тестировщик, а не отражают интент. Измеряете не то, что спрашивают люди; берите формулировки из живых запросов.
- Смешали sentiment из модели с mention в одну «достоверную» колонку. Mention надёжен, sentiment — оценка другой модели, помечайте источник и версию классификатора.
- Рендер трекера дёргает API при каждом F5. Волна платных запросов; рендер читает снимок, сеть — только cron и кнопка.
- WP-Cron считается будильником. Ночью «по требованию» не сработает; ставьте системный cron /
wp cron event run. - Нет transient-замка на прогон. Параллельные прогоны сдвигают историю и двойную квоту.
- Вывод URL из ответа модели без фильтра. Модель может вернуть несуществующую или
javascript:-ссылку; allowlist схем + экранирование, ответ = недоверенный ввод. - Не задали потолок
max_tokensи дневную квоту счётчику. 90+ запросов/прогон сольют бюджет; потолок и лимит — часть архитектуры. - Стали мешать ручной spot-check по веб-UI с API-метрикой в один ряд. Это разные измерения; не усредняйте «число движков», у половины нет API.
- Приписали тренду то, что обновилась модель вендора. Записывайте
engine+дату; дрейф без смены промптов — почти всегда смена модели, не ваш рост.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
