← Все статьи

Data-Driven аудит цитируемости: скрипт на Python для мониторинга видимости каталога в LLM

16 мин чтенияОбновлено 22.09.2026
Data-Driven аудит цитируемости: скрипт на Python для мониторинга видимости каталога в LLM

Смена парадигмы: от классического SERP к Generative Engine Optimization (GEO)

Традиционный поисковый аудит долгие годы опирался на бинарную логику ранжирования: позиция сайта по ключевому слову в органической выдаче от 1 до 100. Однако проникновение больших языковых моделей с веб-поиском — таких как SearchGPT, Perplexity AI и Яндекс Нейро — фундаментально изменило механику потребления информации. Пользователь все реже кликает по классическим «синим ссылкам», получая синтезированный ответ непосредственно в интерфейсе генеративной системы.

Для e-commerce проектов и масштабных каталогов критическим фактором выживания становится не просто нахождение в топ-10 Google или Яндекса, а фактологическое цитирование (Grounding). Если генеративная модель не ссылается на страницы вашего каталога в сносках к ответу, сайт фактически выпадает из воронки выбора потребителя на этапе сравнения и подбора товаров.

Ручной мониторинг сотен товарных категорий в нейросетях невозможен: ответы стохастичны, сноски динамически меняются в зависимости от контекста диалога, а поисковые индексы LLM обновляются по собственным алгоритмам. Единственное решение — построение регулярного Data-Driven пайплайна мониторинга цитируемости на Python.

Архитектура системы аудита цитируемости

Система автоматизированного аудита видимости каталога строится на связке пяти автономных функциональных слоев:

  • Слой сбора и хранения промптов: пул из 500 нормализованных коммерческих и смешанных запросов, сгруппированных по категориям и интентам.
  • Слой API-коннекторов: асинхронные модули отправки запросов к API поисковых нейросетей (Perplexity, SearchGPT / OpenAI Responses API, эмуляция запросов к Яндекс Нейро).
  • Слой парсинга и нормализации сносок: извлечение ссылок из метаданных ответа (citations, web search annotations), очистка от UTM-меток и приведение URL к каноническому виду.
  • Реляционное хранилище (Database Layer): фиксация временных рядов проверок для отслеживания динамики присутствия домена и конкурентов.
  • Аналитический модуль: расчет индексов видимости (Share of Voice, Presence Rate) и вычисление статистической корреляции с трафиком из веб-аналитики.

Методология формирования семантического ядра из 500 промптов

Главная ошибка при аудите видимости в LLM — отправка обычных коротких SEO-ключей (например, «купить холодильник москва»). Пользователи формулируют промпты для нейросетей в разговорном, исследовательском стиле. Репрезентативная выборка из 500 промптов для интернет-магазина должна быть распределена по четырем ключевым кластерам:

1. Категорийно-подборочные промпты (150 промптов)

Запросы широкого товарного охвата, где пользователь ищет спектр решений в рамках категории каталога:

  • «Посоветуй лучшие энергоэффективные стиральные машины с сушкой для маленькой ванны в 2026 году»
  • «Какие бренды беспроводных пылесосов с влажной уборкой сейчас лидируют по надежности»

2. Сравнительные и экспертные промпты (150 промптов)

Запросы глубокого сравнения характеристик, в которых LLM обращается к авторитетным каталогам с подробными техническими спецификациями:

  • «Что практичнее для загородного дома: инверторный генератор или портативная зарядная станция»
  • «Сравни кофемашины DeLonghi и Philips в ценовом сегменте до 60 тысяч рублей по качеству молочной пены»

3. Проблемно-ориентированные сценарии (100 промптов)

Запросы, в которых пользователь описывает проблему, ожидая от модели подбора конкретного класса товаров:

  • «Как избавиться от сухости воздуха в спальне 20 кв м, если есть аллергия на пыльцу»
  • «Какое крепление выбрать для тяжелого телевизора 75 дюймов на стену из гипсокартона»

4. Транзакционные промпты с конкретизацией бюджета и условий (100 промптов)

Запросы, максимально близкие к конверсии, где модель формирует списки магазинов с актуальными ценами и наличием:

  • «Где заказать оригинальные кроссовки для марафона с доставкой по РФ дешевле 15000 рублей»
  • «Лучшие интернет-магазины офисной мебели с услугой сборки и гарантией от 3 лет»

Проектирование структуры базы данных

Для сохранения истории проверок оптимально использовать SQLite (для локального скрипта) или PostgreSQL (для корпоративного контура). Плоские CSV-файлы непригодны для временных рядов с множественными связями «один промпт — несколько поисковых систем — десятки сносок».

Схема базы данных включает три основные таблицы:

  • prompts: справочник целевых поисковых запросов с указанием категории каталога и типа интента.
  • audit_runs: лог сессий запуска скрипта с меткой времени и глобальным статусом.
  • citations: детальные записи по каждой найденной сноске (URL источника, домен, позиция в сноске, принадлежность к нашему каталогу или конкурентам).
-- DDL-структура таблиц для SQLite / PostgreSQL

CREATE TABLE IF NOT EXISTS prompts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    prompt_text TEXT NOT NULL UNIQUE,
    category TEXT NOT NULL,
    intent_type TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS audit_runs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
    total_prompts INTEGER NOT NULL,
    notes TEXT
);

CREATE TABLE IF NOT EXISTS citations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL,
    prompt_id INTEGER NOT NULL,
    engine TEXT NOT NULL, -- 'perplexity', 'searchgpt', 'yandex_neuro'
    citation_position INTEGER NOT NULL,
    url TEXT NOT NULL,
    domain TEXT NOT NULL,
    is_target_domain BOOLEAN NOT NULL,
    FOREIGN KEY (run_id) REFERENCES audit_runs (id),
    FOREIGN KEY (prompt_id) REFERENCES prompts (id)
);

CREATE INDEX IF NOT EXISTS idx_citations_target ON citations (is_target_domain, engine);
CREATE INDEX IF NOT EXISTS idx_citations_domain ON citations (domain);
CREATE INDEX IF NOT EXISTS idx_citations_run ON citations (run_id);

Готовый скрипт на Python для аудита и сохранения сносок

Приведенный ниже модуль написан на Python 3.10+ с использованием библиотек requests, sqlite3 и urllib.parse. Скрипт принимает список промптов, выполняет параллельные запросы к API с контролем рейт-лимитов, парсит возвращаемые сноски и записывает результаты в базу данных.

import os
import re
import time
import sqlite3
import logging
from typing import List, Dict, Any, Optional
from urllib.parse import urlparse
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

TARGET_DOMAIN = "myshop-catalog.ru"
DB_NAME = "llm_visibility_audit.db"

# API Ключи (задаются через переменные окружения)
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY", "your_perplexity_key")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "your_openai_key")


def clean_url(raw_url: str) -> str:
    """Удаляет трекинговые UTM-метки, gclid, yclid и приводит URL к базовому виду."""
    parsed = urlparse(raw_url)
    clean = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
    return clean.rstrip("/")


def extract_domain(url: str) -> str:
    """Извлекает нормализованный домен второго уровня из URL."""
    netloc = urlparse(url).netloc.lower()
    if netloc.startswith("www."):
        netloc = netloc[4:]
    return netloc


class LLMAuditCollector:
    def __init__(self, db_path: str, target_domain: str):
        self.db_path = db_path
        self.target_domain = target_domain.lower().replace("www.", "")
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS prompts (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    prompt_text TEXT NOT NULL UNIQUE,
                    category TEXT NOT NULL,
                    intent_type TEXT NOT NULL
                );
            """)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS audit_runs (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    run_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
                    total_prompts INTEGER NOT NULL,
                    notes TEXT
                );
            """)
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS citations (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    run_id INTEGER NOT NULL,
                    prompt_id INTEGER NOT NULL,
                    engine TEXT NOT NULL,
                    citation_position INTEGER NOT NULL,
                    url TEXT NOT NULL,
                    domain TEXT NOT NULL,
                    is_target_domain BOOLEAN NOT NULL,
                    FOREIGN KEY (run_id) REFERENCES audit_runs (id),
                    FOREIGN KEY (prompt_id) REFERENCES prompts (id)
                );
            """)
            conn.commit()

    def query_perplexity(self, prompt: str) -> List[str]:
        """Отправляет запрос к API Perplexity (модель sonar) и возвращает список сносок."""
        url = "https://api.perplexity.ai/chat/completions"
        headers = {
            "Authorization": f"Bearer {PERPLEXITY_API_KEY}",
            "Content-Type": "application/json"
        }
        payload = {
            "model": "sonar",
            "messages": [
                {"role": "system", "content": "Будь точным, объективным консультантом по покупкам. Обязательно указывай источники информации."},
                {"role": "user", "content": prompt}
            ],
            "temperature": 0.2
        }
        try:
            response = requests.post(url, json=payload, headers=headers, timeout=45)
            response.raise_for_status()
            data = response.json()
            citations = data.get("citations", [])
            return [clean_url(u) for u in citations if isinstance(u, str)]
        except Exception as e:
            logging.error(f"Ошибка Perplexity API для промпта '{prompt[:30]}...': {e}")
            return []

    def query_searchgpt(self, prompt: str) -> List[str]:
        """Отправляет запрос к OpenAI API с поддержкой веб-поиска и парсит URL из сносок."""
        url = "https://api.openai.com/v1/chat/completions"
        headers = {
            "Authorization": f"Bearer {OPENAI_API_KEY}",
            "Content-Type": "application/json"
        }
        payload = {
            "model": "gpt-4o",
            "messages": [{"role": "user", "content": prompt}],
            # В производственной среде используется web_search tool
            "temperature": 0.2
        }
        try:
            response = requests.post(url, json=payload, headers=headers, timeout=45)
            response.raise_for_status()
            data = response.json()
            
            # Парсинг URL из текста и метаданных annotations
            urls = []
            message = data["choices"][0]["message"]
            content = message.get("content", "")
            
            # Регулярное выражение для извлечения веб-ссылок из markdown-разметки ответа
            found_urls = re.findall(r'\[(?:[^\]]+)\]\((https?://[^\)]+)\)', content)
            urls.extend(found_urls)
            
            # Извлечение из системных аннотаций при их наличии
            if "annotations" in message:
                for ann in message["annotations"]:
                    if ann.get("type") == "url_citation":
                        urls.append(ann.get("url_citation", {}).get("url", ""))

            return [clean_url(u) for u in urls if u.startswith("http")]
        except Exception as e:
            logging.error(f"Ошибка SearchGPT API для промпта '{prompt[:30]}...': {e}")
            return []

    def run_full_audit(self, prompts: List[Dict[str, Any]]):
        """Запускает процесс аудита по всему массиву промптов с фиксацией в БД."""
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.cursor()
            
            # 1. Регистрация нового аудита
            cursor.execute("INSERT INTO audit_runs (total_prompts, notes) VALUES (?, ?)", 
                           (len(prompts), "Регулярный аудит видимости каталога"))
            run_id = cursor.lastrowid
            conn.commit()

            logging.info(f"Старт аудита #{run_id} для {len(prompts)} промптов.")

            for item in prompts:
                prompt_text = item["prompt"]
                category = item.get("category", "General")
                intent = item.get("intent", "Commercial")

                # Добавление или получение ID промпта
                cursor.execute("""
                    INSERT OR IGNORE INTO prompts (prompt_text, category, intent_type)
                    VALUES (?, ?, ?)
                """, (prompt_text, category, intent))
                cursor.execute("SELECT id FROM prompts WHERE prompt_text = ?", (prompt_text,))
                prompt_id = cursor.fetchone()[0]

                # 2. Опрос Perplexity
                pplx_citations = self.query_perplexity(prompt_text)
                for pos, raw_url in enumerate(pplx_citations, start=1):
                    domain = extract_domain(raw_url)
                    is_target = (domain == self.target_domain)
                    cursor.execute("""
                        INSERT INTO citations (run_id, prompt_id, engine, citation_position, url, domain, is_target_domain)
                        VALUES (?, ?, 'perplexity', ?, ?, ?, ?)
                    """, (run_id, prompt_id, pos, raw_url, domain, is_target))

                # 3. Опрос SearchGPT
                sgpt_citations = self.query_searchgpt(prompt_text)
                for pos, raw_url in enumerate(sgpt_citations, start=1):
                    domain = extract_domain(raw_url)
                    is_target = (domain == self.target_domain)
                    cursor.execute("""
                        INSERT INTO citations (run_id, prompt_id, engine, citation_position, url, domain, is_target_domain)
                        VALUES (?, ?, 'searchgpt', ?, ?, ?, ?)
                    """, (run_id, prompt_id, pos, raw_url, domain, is_target))

                conn.commit()
                # Пауза для соблюдения лимитов запросов (Rate Limiting)
                time.sleep(1.5)

            logging.info(f"Аудит #{run_id} успешно завершен.")

Система расчетных метрик видимости в LLM

После сбора данных в реляционную базу сырые значения преобразуются в четыре интегральных показателя эффективности GEO (Generative Engine Optimization):

1. Share of Model Citations (SoMC / Доля цитируемости)

Отношение общего числа сносок на наш каталог ко всем сноскам, сформированным нейросетью по пулу из 500 запросов. Формула:

SoMC = (Количество сносок на целевой домен / Общее количество всех внешних сносок) * 100%

Метрика демонстрирует общую долю присутствия бренда в цифровой фактологической базе LLM по сравнению со всем рынком.

2. Citation Presence Rate (CPR / Охват промптов)

Процент промптов из выборки, в ответах на которые присутствует хотя бы одна сноска на страницу нашего каталога:

CPR = (Количество промптов с хотя бы 1 нашей сноской / 500) * 100%

Показывает, насколько равномерно каталог представлен в различных категориях и типах пользовательских сценариев.

3. Mean Citation Rank (MCR / Средняя глубина сноски)

Средний порядковый номер ссылки на наш сайт в списке источников под ответом модели. Первые 1–3 сноски получают до 80% всех переходов пользователей из интерфейсов SearchGPT и Perplexity.

4. Competitor Dominance Ratio (CDR / Индекс конкурентного доминирования)

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

Анализ корреляции: видимость в LLM и динамика реального трафика

Главный прикладной вопрос бизнеса: дает ли цитируемость в LLM реальный коммерческий эффект? Для подтверждения ценности GEO-оптимизации строится корреляционная модель между динамикой показателя SoMC и тремя потоками данных из систем веб-аналитики (Яндекс Метрика, Google Analytics 4):

  • Прямой реферальный трафик из LLM: переходы с рефереров perplexity.ai, chatgpt.com, android-app://com.openai.chatgpt, а также переходы из Яндекс Нейро.
  • Брендовый трафик в органическом поиске: число поисковых запросов с упоминанием названия каталога (пользователи видят упоминание бренда в ответе LLM и затем ищут магазин в основном поиске).
  • Прямые заходы (Direct Traffic) на карточки товаров: прямой ввод URL или переходы по скопированным из ответов нейросетей ссылкам.
import sqlite3
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

def analyze_correlation(db_path: str, traffic_csv_path: str):
    """
    Рассчитывает коэффициент корреляции Пирсона между еженедельной видимостью
    в LLM (SoMC) и фактическим трафиком на посадочные страницы каталога.
    """
    # 1. Извлечение метрик цитируемости по запускам аудита
    with sqlite3.connect(db_path) as conn:
        query = """
            SELECT 
                DATE(r.run_timestamp) as audit_date,
                COUNT(c.id) as total_citations,
                SUM(CASE WHEN c.is_target_domain = 1 THEN 1 ELSE 0 END) as target_citations
            FROM audit_runs r
            JOIN citations c ON r.id = c.run_id
            GROUP BY DATE(r.run_timestamp)
            ORDER BY audit_date ASC
        """
        df_llm = pd.read_sql_query(query, conn)
    
    df_llm["somc"] = (df_llm["target_citations"] / df_llm["total_citations"]) * 100

    # 2. Загрузка данных трафика (дата, визиты из LLM-рефералов и брендовой органики)
    df_traffic = pd.read_csv(traffic_csv_path)
    df_traffic["date"] = pd.to_datetime(df_traffic["date"])
    df_llm["audit_date"] = pd.to_datetime(df_llm["audit_date"])

    # 3. Объединение временных рядов
    merged = pd.merge(df_llm, df_traffic, left_on="audit_date", right_on="date", how="inner")

    # 4. Расчет коэффициента корреляции Пирсона
    corr_somc_traffic = merged["somc"].corr(merged["llm_referral_sessions"])
    corr_somc_brand = merged["somc"].corr(merged["brand_organic_sessions"])

    print(f"Корреляция SoMC с прямым LLM-трафиком: {corr_somc_traffic:.3f}")
    print(f"Корреляция SoMC с брендовым поиском: {corr_somc_brand:.3f}")

    # 5. Визуализация трендов
    fig, ax1 = plt.subplots(figsize=(12, 6))

    color = "tab:blue"
    ax1.set_xlabel("Дата аудита")
    ax1.set_ylabel("Share of Model Citations (%)", color=color)
    ax1.plot(merged["audit_date"], merged["somc"], color=color, marker="o", linewidth=2, label="SoMC (%)")
    ax1.tick_params(axis="y", labelcolor=color)

    ax2 = ax1.twinx()
    color = "tab:orange"
    ax2.set_ylabel("Трафик из LLM / Бренд (визиты)", color=color)
    ax2.plot(merged["audit_date"], merged["llm_referral_sessions"], color=color, linestyle="--", linewidth=2, label="LLM Traffic")
    ax2.tick_params(axis="y", labelcolor=color)

    plt.title("Динамика видимости каталога в LLM и фактического трафика")
    fig.tight_layout()
    plt.savefig("llm_visibility_vs_traffic.png", dpi=300)
    plt.close()

Прикладные выводы: как конвертировать данные аудита в рост видимости

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

1. Фактор Information Gain и структурная плотность данных

Поисковые LLM при заземлении ответов отдают предпочтение источникам с высоким индексом Information Gain (уникальной фактологической информацией). Если листинг каталога содержит только стандартную сетку товаров с ценами, модель обратится к сайту конкурента, где есть структурированные сравнительные списки, сводки плюсов и минусов, а также реальные выжимки из клиентского опыта.

2. Микроразметка Schema.org как машиночитаемый каркас

Страницы, внедряющие семантические типы микроразметки ItemList, Product с вложенными свойствами hasVariant, aggregateRating и offers, цитируются в SearchGPT и Perplexity в 2.4 раза чаще по сравнению со страницами без валидного семантического графа.

3. Форматирование контента под векторные эмбеддинги

Для гарантированного попадания в окно контекста RAG-систем (Retrieval-Augmented Generation) поисковых нейросетей контент страниц каталога должен быть разбит на логические блоки с четкими подзаголовками, маркированными списками ключевых характеристик и блоками «Для кого подходит / Для каких задач не подходит».

Экспертная оценка Cobalt Media: Мониторинг цитируемости в LLM перестал быть экспериментальным R&D-направлением. Сегодня это базовый инструмент технического контроля видимости бренда в цифровой среде. Компании, автоматизировавшие сбор сносок и понимающие механику попадания в RAG-индексы поисковых систем нового поколения, получают устойчивое конкурентное преимущество и забирают наиболее платежеспособный трафик еще до того, как пользователь сформулирует транзакционный запрос в классическом поиске.

Cobalt Media проведет предварительный аудит, определит приоритетные направления роста и подготовит прозрачную оценку работ без обещаний гарантированного места в выдаче.

по теме