Трекинг бренда в LLM: свой счётчик видимости за вечер на PHP

В прошлой статье серии мы разобрали, что технически делает вас доступным для моделей: открытые 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
<?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% интервалШирина
1060,60[0,31 … 0,83]0,52
20120,60[0,39 … 0,78]0,39
100620,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, а не в коде счётчика, чтобы редактор мог добавлять вопросы, не трогая логику:

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
<?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
<?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
<?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
<?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
<?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-видимости живёт вне вашего сайта).

Действия после отчёта (иначе счётчик — просто числа):

  1. Прогалили конкретную страницу → переписываете её заголовки в утверждения и проверяете на следующем прогоне (гипотеза из прошлой статьи).
  2. domain_cited выше нуля, но url_cited ноль → вас находят по чужим пересказам; усиливаете собственные deep-link-страницы (Q&A, канонические страницы под конкретный интент).
  3. Один движок показывает стабильно ниже других → проверьте robots.txt этого движка (retrieval-бот открыт?) — прямая связь с Частью IV прошлой статьи.
  4. Дрейф без смены промптов → почти наверняка обновление модели у вендора; фиксируйте 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+дату; дрейф без смены промптов — почти всегда смена модели, не ваш рост.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.