Sitemap-first аудит большого сайта: как найти пустые посадочные без полного краулинга

от автора

Есть привычная ошибка в техническом аудите больших сайтов: открыть краулер, поставить лимит побольше и просканировать всё.

На сайте в пару тысяч страниц это работает. На сайте с семизначным инвентарём URL — нет. И дело не в цене инструмента. Полный краул такого проекта упирается в память, диск, сетевые таймауты, rate limit, JavaScript-рендеринг, дубли, параметры, бесконечные фасеты и в то, что через двое суток вы получаете таблицу на миллионы строк, которую всё равно придётся сегментировать с нуля.

Поэтому я начинаю не с краулера. Я начинаю с sitemap.

Не потому что sitemap идеален: он часто устаревает, содержит неканонические URL, дубли и технические хвосты. А потому что это самый дешёвый способ получить первичный URL-инвентарь, разложить его на типы страниц, вытащить паттерны, наложить на спрос и заранее найти зоны, где проект годами генерирует сотни тысяч посадочных без единого поискового основания.

Дальше — как именно я это делаю, в каком порядке, и почему всё это собирается до того, как появятся доступы к GSC, Яндекс.Вебмастеру и логам.

Все примеры обезличены: я убрал домен, точный объём инвентаря и узнаваемые URL-паттерны. Метод важнее конкретного проекта.

Что было на входе

Вводная типичная для большого проекта:

  • доступы к данным ещё не выданы — GSC и Вебмастер подключат позже;

  • серверных логов пока нет;

  • сайт слишком большой для комфортного desktop-краула;

  • рендерить миллионы URL — экономически и технически неадекватно;

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

То есть нормальная ситуация: аудит нужен сейчас, а данные будут потом. И это не повод сидеть без работы — это повод собрать гипотезы из того, что сайт и так отдаёт наружу.

Почему полный краул большого сайта — плохой первый шаг

Прямой обход такого сайта инженерно возможен — но как стартовая точка аудита он почти всегда ошибка. Краулер честно жжёт ресурс на то, что не отвечает на SEO-вопрос: на дубли, пагинацию, фасетные комбинации, трекинговые параметры и удалённые карточки, отдающие 301.

Облачные краулеры — JetOctopus, Sitebulb Cloud, OnCrawl — снимают часть боли с вашего железа, но не отменяют стоимость ошибки. У одних инструментов есть открытые тарифы от десятков-сотен долларов или фунтов в месяц, у других enterprise-цена считается по объёму обхода, логам, проектам или кредитам. JetOctopus отдельно гордится скоростью — 200 страниц в секунду, и это правда быстро; но скорость не отменяет того, что полный проход по многомиллионному, да ещё и нестабильному под нагрузкой сайту нужно повторять, и каждый проход чего-то стоит. На семизначном инвентаре проблема не в том, что краулер «не сможет». Проблема в том, что без предварительной сегментации он быстро и дорого соберёт мусор.

Главный тезис здесь не про деньги:

На больших сайтах цена ошибки в стратегии сканирования выше цены самого инструмента. Если не сегментировать инвентарь URL заранее, любой краулер — хоть облачный, хоть локальный — добросовестно соберёт вам мусор. Быстро и дорого.

Поэтому сначала дешёвый слой данных. Краул — потом, точечно, и только там, где он действительно нужен.

Sitemap-first вместо crawl-first

Перед тем как что-то качать, я смотрю на сам sitemap как на объект диагностики. У большого сайта это не один файл, а граф:

robots.txt└── sitemap index    ├── sitemap-listing-sale-*.xml.gz    ├── sitemap-listing-rent-*.xml.gz    ├── sitemap-newbuilding-*.xml.gz    ├── sitemap-geo-*.xml.gz    └── sitemap-static.xml
Sitemap graph большого сайта: robots.txt указывает на sitemap index, а тот разворачивается в набор gzip-карт по типам страниц. Один sitemap-файл ограничен 50 000 URL и 50 МБ в несжатом виде, поэтому крупные проекты почти всегда живут через sitemap index.

Sitemap graph большого сайта: robots.txt указывает на sitemap index, а тот разворачивается в набор gzip-карт по типам страниц. Один sitemap-файл ограничен 50 000 URL и 50 МБ в несжатом виде, поэтому крупные проекты почти всегда живут через sitemap index.

У sitemap есть физические ограничения: один sitemap-файл не должен превышать ни 50 000 URL, ни 50 МБ в несжатом виде (это документирует Google Search Central). Поэтому на больших сайтах sitemap почти всегда превращается в граф: index-файл, вложенные карты, gzip и раздельные sitemap по типам страниц. Сам факт, что у проекта несколько десятков gzip-карт в индексе, — уже первичная информация о масштабе и сегментации.

Что проверяю до выгрузки URL:

  • есть ли sitemap index и сколько в нём вложенных карт;

  • используется ли gzip;

  • какие типы карт существуют: листинги, карточки, гео, фильтры, статика, новости;

  • живёт ли lastmod или там у всех страниц одна дата с прошлого деплоя;

  • нет ли карт, которые отдают HTML вместо XML или 404;

  • нет ли внутри неканонических URL, параметров сортировки, пагинации и трекинга;

  • совпадает ли структура карт с реальной структурой сайта.

Формулировка, которую я держу в голове и которую не стыдно вынести в отчёт:

Sitemap — это не истина. Это декларация сайта о том, какие URL он считает достойными индекса. Но для первичного аудита большого проекта декларация уже бесценна: её можно скачать, распарсить и проверить на внутреннюю непротиворечивость.

На этом этапе я забираю только XML и GZ. Никакого тяжёлого обхода HTML — это дешёвый слой, и он должен оставаться дешёвым.

Ниже — обрезанная версия моего рабочего экспортёра (убраны CLI, запись файлов и сравнение нескольких источников). Сердце — рекурсивный обход: на входе либо urlset, либо sitemapindex, и во втором случае разворачиваем вложенные карты. visited защищает от циклических ссылок между картами — на больших сайтах они встречаются.

import gzipfrom io import BytesIOimport requestsfrom lxml import etreeNS = {"ns": "http://www.sitemaps.org/schemas/sitemap/0.9"}UA = "Mozilla/5.0 (compatible; SitemapURLExporter/1.0)"def fetch_xml(source: str) -> bytes:    if source.startswith(("http://", "https://")):        r = requests.get(source, headers={"User-Agent": UA,                         "Accept-Encoding": "gzip, deflate"}, timeout=30)        r.raise_for_status()        data = r.content    else:        data = open(source, "rb").read()    # .gz по расширению + проверка gzip-сигнатуры    if source.endswith(".gz") and data[:2] == b"\x1f\x8b":        return gzip.decompress(data)    return datadef walk_sitemap(source: str, visited: set[str] | None = None):    """Рекурсивно разворачивает sitemapindex → urlset → плоский список URL."""    visited = visited if visited is not None else set()    if source in visited:        return []    visited.add(source)    root = etree.parse(BytesIO(fetch_xml(source))).getroot()    tag = etree.QName(root).localname    if tag == "urlset":        return [loc.text.strip()                for loc in root.findall(".//ns:url/ns:loc", NS)                if loc.text and loc.text.strip()]    if tag != "sitemapindex":        raise ValueError(f"Unsupported root tag '{tag}' in {source}")    urls = []    for loc in root.findall(".//ns:sitemap/ns:loc", NS):        urls.extend(walk_sitemap(loc.text.strip(), visited))    return urls

В полной версии тот же проход сразу пишет три файла: все URL, уникальные (dict.fromkeys сохраняет порядок) и отчёт по дублям — какие URL в sitemap повторяются и сколько раз. Дубли прямо в карте — это уже первый сигнал: сайт сам декларирует один и тот же URL из разных мест. Туда же добавлены retry/backoff, журнал ошибок, сохранение source-карты для каждого URL и stream-обработка: фрагмент выше через etree.parse грузит XML целиком в память, а на многогигабайтных картах это меняют на потоковый iterparse. На выходе — инвентарь всего сайта, собранный за минуты, а не за двое суток, и без единого запроса к продакшну тяжелее самих карт.

Превращаем URL в датасет

Список URL — это ещё не аудит. Аудит начинается, когда каждый URL становится строкой в таблице с разобранной структурой:

url, scheme, host, path, depth,seg_1, seg_2, seg_3, ...,query, has_query, trailing_slash,sitemap_source, lastmod,template_candidate, path_hash

Нормализация — скучная, но решающая часть. Если её пропустить, вы будете считать /kupit/kvartira и /kupit/kvartira/ за две разные страницы, и вся последующая статистика поедет.

  • единый host, без фрагмента;

  • query хранится отдельно, не приклеен к пути;

  • percent-encoding декодирован;

  • trailing slash приведён к одному виду;

  • путь разбит на сегменты, посчитана глубина;

  • посчитана частота повторения каждого сегмента по позиции.

Ключевой приём — свести конкретный URL к паттерну, заменив идентификаторы на плейсхолдер. Карточки и гео-страницы с числовыми ID не должны раздувать словарь паттернов:

import redef url_to_pattern(url: str, host: str) -> str:    path = url.replace(host, "").strip("/")    out = []    for seg in path.split("/"):        # числовой id или slug, оканчивающийся на -NNNNN → плейсхолдер        if re.fullmatch(r"\d{5,}", seg) or re.search(r"-\d{5,}$", seg):            out.append("{id}")        else:            out.append(seg)    return "/" + "/".join(out) + "/"

После этого миллионы конкретных URL схлопываются в обозримое число паттернов. Здесь становится видно, как устроен сайт на самом деле — без единого захода на страницу.

Почему ЧПУ решает всё

Метод работает при одном жёстком условии: URL несут семантику.

Хороший случай — иерархические человекочитаемые адреса:

/{geo}/kupit/kvartira/odnokomnatnaya/{район}//{geo}/kupit/novostrojka/{жк}/ipoteka//{geo}/snyat/kvartira/{район}/posutochno/

Такой URL уже содержит часть смысла: действие (купить/снять), тип объекта, комнатность, рынок, фильтр, гео. Я могу разобрать сайт на типы страниц, не открывая ни одной из них.

Плохой случай — адреса вида:

/item/9283719283//search/?x=abc&y=123/p?id=918273

Здесь sitemap даёт только инвентарь, но не понимание. Тогда придётся добирать смысл из HTML, API, внутренних справочников или точечного краула. Это честная граница метода, и о ней надо говорить прямо:

Sitemap-first работает ровно настолько, насколько ЧПУ информативны. Неинформативные слаги превращают подход в простую перепись URL без интентов.

Мне повезло: на проекте слаги были иерархичны и осмысленны. Дальше — разбор этих слагов.

Slug mining: разбираем URL на смысловые атомы

Slug mining — это извлечение смысловых паттернов из строк URL. Я смотрю на адрес не как на путь к документу, а как на сериализованную сущность с атрибутами.

Сначала — частотный анализ сегментов по позициям. Берём все пути, режем на сегменты, считаем встречаемость каждого, отбрасывая числовые ID:

from collections import Counterseg_counts = Counter()for url in all_urls:    path = url.replace(HOST, "").strip("/")    for seg in path.split("/"):        if seg and not re.fullmatch(r"\d+", seg) and not re.search(r"-\d{5,}$", seg):            seg_counts[seg] += 1

Затем — и это самая полезная часть — каждый частотный сегмент раскладывается по смысловым группам. Получается словарь, который и есть карта осей фильтрации сайта:

Смысловая группа

Примеры сегментов

Действие

kupit, snyat

Тип объекта

kvartira, novostrojka, dom, uchastok, komnata, garazh, kommercheskaya-nedvizhimost

Комнатность

studiya, odnokomnatnaya, dvuhkomnatnaya, tryohkomnatnaya, 4-i-bolee

Рынок

vtorichniy-rynok, novostroyki

Условия

v-ipoteku, semeynaya-ipoteka, it-ipoteka, s-matkapitalom

Отделка

s-remontom, s-otdelkoy, pod-kluch, chistovaya-otdelka

Тип дома

kirpich, monolit, panel, v-pyatietazhnom-dome, khrushevskiy

Класс

ekonom-klass, komfort-klass, biznes-klass, elit-premium-klass

Удобства

s-balkonom, s-mebeliu, s-panoramnymi-oknami, evroplanirovka

Гео

street, railway, district, {район}

Теперь каждому URL-паттерну присваивается предварительный тип: category, subcategory, geo_category, filtered_listing, brand_listing, object_card, static, pagination, garbage. Это рабочая типизация, не финальный приговор. Её задача — дать карту местности до того, как появятся данные поисковиков.

И уже здесь видно главное свойство больших сайтов: типов страниц — единицы-десятки, а инстансов каждого типа — десятки и сотни тысяч. Это значит, что любое решение принимается не по URL, а по классу. Правка одного шаблона масштабируется на весь класс сразу.

Анализ дублей по ЧПУ: первые кандидаты на закрытие в robots

До всякой семантики из чистой структуры URL уже вылезают дубли. Их видно по форме слага — и часть из них нужно гасить, не дожидаясь данных Вебмастера.

Я отдельно прогоняю инвентарь через классификатор дублей. Логика простая: сравниваем путь дубля с предполагаемым каноником и смотрим на отношение и на наличие параметров.

def classify_dup(url: str, target: str) -> str:    if "?" in url and re.search(r"[?&](utm_|ybaip|sort)", url):        return "GET-параметры: трекинг/сортировка"        # → robots Disallow    up = url.replace(HOST, "").strip("/")    tp = (target or "").replace(HOST, "").strip("/")    if not tp:        return "Листинг: нет canonical"                   # → проставить canonical    if tp != up and up.startswith(tp):        return "Листинг: дубль с лишним сегментом"         # → canonical на target    if tp != up and tp.startswith(up):        return "Листинг: canonical глубже дубля"           # → пересмотреть логику шаблона    return "Листинг: каннибализация фильтров"              # → главный фильтр по трафику

Дальше каждому типу — своё действие. Здесь robots.txt берёт на себя ровно ту часть, которую нельзя и не нужно решать каноником:

Тип дубля по ЧПУ

Действие

Инструмент

GET-параметры: трекинг, сортировка, фасеты

убрать из обхода точечно

robots.txtDisallow по конкретным параметрам (см. ниже)

Технические разделы (плееры, webview, embed)

убрать из индекса, затем из обхода

сначала noindex/410, после деиндексации — Disallow в robots

Листинг с лишним сегментом пути

склеить

canonical на короткий URL

Каннибализация двух фильтров

выбрать главный

canonical на фильтр с трафиком, либо дифференцировать Title/H1/текст

Устаревший ID программы/бренда в слаге

перенести вес

301 на актуальный slug + удалить из sitemap

Правило, которое я держу железно: robots.txt — против обхода, canonical и noindex — против индексации. Их путают постоянно, и путаница стоит дорого. Закрыть фасеты Disallow в robots — это про экономию crawl budget: бот вообще не ходит в бесконечный параметрический слой. Но Disallow не выкидывает из индекса то, что туда уже попало по внешним ссылкам. Хуже того: если URL закрыт в robots, Google не сможет зайти на страницу и не увидит там meta noindex — то есть закрытие в robots может, наоборот, законсервировать мусор в индексе. Google это документирует прямо: robots.txt управляет доступом краулера и не является механизмом удаления страницы из индекса.

Поэтому у каждого класса URL в плане стоит не «закрыть», а конкретный сценарий:

Сценарий

Правильное действие

URL уже в индексе и его надо убрать

сначала дать боту увидеть noindex / вернуть 404/410 / canonical — не закрывать сразу в robots

Бесконечный параметрический слой, который не должен обходиться

Disallow в robots — но после проверки, что там нет ценных индексируемых URL

Дубли с внешними ссылками

canonical/301, а не robots

Технический мусор без ценности, внешних ссылок и признаков индексации

Disallow в robots для экономии обхода — допустимо сразу

Disallow по параметрам я держу точечным, а не ковровым:

# не универсальный рецепт, а пример ПОСЛЕ allowlist/indexability-аудитаDisallow: /*?utm_Disallow: /*?sort=Disallow: /*?view=Disallow: /*?session=

Глобальное Disallow: /*? допустимо только после отдельной проверки, что в query-слое нет индексируемых посадочных со спросом. На классифайде часть фильтров вполне может жить на параметрах и приносить трафик — ковровое закрытие убьёт их вместе с мусором.

Параметрический и дубль-слой на большом сайте — это первые десятки и сотни тысяч URL, которые бот листает вхолостую. Их можно убрать из обхода ещё до того, как у вас появится доступ к логам, потому что для диагноза «это фасетный мусор» достаточно формы самого URL.

Семантика по паттернам, а не по сайту целиком

«Собрать семантику по нише» на большом сайте — задача без дна. Я иду от паттернов URL, а не от абстрактного ядра.

Для каждого паттерна строится набор seed-фраз с подстановкой переменных:

/{geo}/kupit/kvartira/                    → "купить квартиру {город}"/{geo}/kupit/kvartira/odnokomnatnaya/     → "купить 1-комнатную квартиру {город}"/{geo}/kupit/kvartira/{район}/            → "купить квартиру {район} {город}"/{geo}/snyat/kvartira/{район}/posutochno/ → "снять квартиру посуточно {район} {город}"

Дальше иду в keys.so (или аналог) и снимаю частотность не хаотично, а строго по паттернам. Для каждого получаю частоту, варианты формулировок, коммерческие и гео-модификаторы, синонимы, конкурентов в топе и кластеры.

Здесь есть тонкость, на которой ломаются автоматические сборщики: народное слово против технического слага. Сайт может называть раздел так, как удобно разработчику, а спрос живёт в другой формулировке. Поэтому при матчинге я нормализую частоту, снимаю гео из фразы и проверяю синонимы:

SYNONYMS = {    "вторичный рынок": ["вторичка", "вторичное жилье"],    "1-комнатную":     ["однокомнатную", "1 комнатную"],    "европланировка":  ["евродвушка"],    "рядом с метро":   ["у метро", "около метро"],}# фразу-спрос дедуплицируем по нормализованной форме (без города),# берём максимальную частоту, отсекаем хвост < порога

И отдельный слой — фильтр шума, без которого матрица спроса превращается в помойку. В отсев идут:

  • бренды конкурентов и застройщиков (брендовый трафик не даст вам страницу без бренда);

  • информационные запросы («сколько стоит», «как выбрать») — это статьи журнала, не коммерческие посадочные;

  • обрезки парсинга: фразы, оканчивающиеся на предлог;

  • фразы с одиночными буквами и цифрами-артефактами;

  • слишком общие фразы короче двух значимых слов.

Главная находка: пустые посадочные в промышленных масштабах

Матчинг инвентаря и спроса даёт центральную матрицу аудита:

url_pattern, url_count, slug_tokens, query_cluster, frequency, geo_frequency, commerciality, competitors_present, current_template, decision

Здесь вскрывается то, ради чего всё затевалось, — пустые посадочные. Пустая посадочная — это не страница без текста. Это страница без поискового основания:

  • нет частотности и отдельного интента;

  • ни один конкурент не держит аналогичную посадочную;

  • нет ценности относительно родительской категории;

  • есть только механически сгенерированный URL и шаблонный контент, где Title меняется подстановкой одного слова.

На большом классифайде такие страницы рождаются комбинаторно: каждая ось фильтрации перемножается на гео и на комнатность, и генератор URL механически создаёт купить 4-комнатную квартиру в брежневке с панорамными окнами рядом с озером в {деревня}. URL валиден. Спроса — ноль. И таких — сотни тысяч.

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

Статус

Условие

Решение

has_demand

есть частота и конкуренты

растить: усилить шаблон

weak_demand

частота на грани

мониторить, не плодить

rename

спрос живёт в синониме

переименовать slug + 301

no_demand + duplicate

нет спроса, дублирует родителя

canonical / 301 / merge

no_demand + garbage

нет спроса, технический хвост

убрать из sitemap, noindex/Disallow

demand + weak_page

спрос есть, шаблон слабее конкурентов

ТЗ на доработку типа

demand + JS_only

контент не отдаётся без JS

SSR/prerender для SEO-блоков

Результат аудита — не список из сотен тысяч URL. Результат — правила обработки классов URL. Это принципиально другой по управляемости артефакт.

On-page: сравниваем типы страниц, а не страницы

После матчинга спроса я беру не все страницы, а репрезентативную выборку: по 10–20 URL из каждого крупного шаблона, плюс пограничные случаи — высокий потенциальный спрос при слабой видимости, пересечения гео и категории, страницы, которые должны ранжироваться, но не ранжируются.

Для каждого типа сравниваю с конкурентами по составу блоков: Title и H1 паттерны, наличие описательного текста и FAQ, фильтры, хлебные крошки, листинг и сортировки, микроразметка, блоки перелинковки по соседним категориям / гео / атрибутам, число и анкоры внутренних ссылок, обработка пагинации, canonical, soft 404, пустые состояния.

Вывод этого блока — не рекомендации по URL, а ТЗ на шаблон:

Если один шаблон отвечает за сотни тысяч URL, правка шаблона масштабируется на сотни тысяч страниц. Задача SEO здесь — доказать, какой шаблон менять и почему, а не писать рекомендации для каждой страницы.

Примеры формулировок ТЗ, которые уходят в разработку:

TZ-01  усилить шаблон geo-category: добавить вводный текст + FAQ из спросаTZ-02  блок «соседние районы» в листинге → внутренняя перелинковка по геоTZ-03  блок связанных фильтров (комнатность × рынок × отделка)TZ-04  правила canonical/noindex для слабых фасетовTZ-05  вынести SEO-критичный контент листинга в server-side HTML

Рендеринг: что бот видит на самом деле

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

Сравниваю три версии одной страницы:

raw HTML      — что отдаёт сервер на curl с UA ботаrendered DOM  — что видит headless-браузер после выполнения JSbot view      — что показывает URL Inspection в GSC / проверка в Вебмастере

Методика — три фазы. Сырой HTML двумя user-agent (Googlebot Desktop и Googlebot Smartphone), затем рендер в headless Chrome с ожиданием networkidle и паузой на гидратацию, затем диф по SEO-критичным элементам.

Googlebot Desktop:Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1;  +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36Googlebot Smartphone:Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36  (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36  (compatible; Googlebot/2.1; +http://www.google.com/bot.html)YandexBot:Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)

Что считаю и сравниваю между raw и rendered:

raw_status         vs render_statusraw_title          vs render_titleraw_canonical      vs render_canonicalrobots_meta        (не должен меняться на noindex после JS)raw_h1             vs render_h1raw_links_count    vs render_links_count    # тревога: raw < 0.7 × renderedraw_text_len       vs render_text_lenschema_in_raw      vs schema_in_renderedjs_errors_count, api_errors_count, render_time

Порог 70% — не стандарт поисковиков, а мой рабочий триггер для ручной проверки шаблона.

Снимок каждой версии страницы я кладу в один и тот же набор полей — тогда diff сводится к сравнению двух структур:

@dataclassclass PageSnapshot:    url: str; status_code: int | None    title: str; meta_description: str; canonical: str    headings: list[str]; links: list[str]    structured_data: list[str]; schema_types: list[str]    visible_text_lines: list[str]; html_bytes: int    errors: list[str]

Raw-версию даёт requests с UA бота + разбор BeautifulSoup. Rendered-версию — Playwright (networkidle, fallback на load). Ключевая деталь, без которой рендер-аудит врёт: из отрендеренного DOM я собираю заголовки и ссылки только если они реально видимы — не display:none, не visibility:hidden, не opacity:0, не нулевой размер:

const isVisible = (el) => {  const s = window.getComputedStyle(el);  if (!s || s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0')    return false;  if (el.getClientRects().length === 0) return false;  if (el.offsetWidth <= 0 || el.offsetHeight <= 0) return false;  return true;};// h1..h6 и <a> попадают в snapshot, только пройдя isVisible

Именно эта проверка ловит самый коварный случай: контент есть в DOM, в сыром HTML он формально присутствует, но визуальный слой держит его скрытым до JS. Бот, снимающий видимую страницу, его недополучает.

Опасные паттерны, которые этот диф вылавливает на реальных проектах:

  • Title/canonical появляются только после JS — для рендер-бюджета это риск: при отложенном рендеринге бот какое-то время видит страницу без них.

  • Листинг и внутренние ссылки генерируются на клиенте — навигация и карточки есть в браузере, но отсутствуют в сыром HTML. Краулер недополучает граф ссылок.

  • Сырые шаблонные переменные в Title и description — в <title> уходит незаполненный плейсхолдер CMS вместо артикула и категории, потому что переменная не подставилась при серверном рендере. Поисковик буквально видит CML2_ARTICLE.VALUE в заголовке. Это системный баг шаблона, бьющий по CTR на всём классе карточек.

  • Контент скрыт CSS до выполнения JS. Самый коварный случай. Контент присутствует в DOM, но скелетоны и display:none держат его невидимым, пока не отработает скрипт. В одном из рендер-аудитов разница между двумя bot view-сценариями доходила примерно до 70% против 20% видимого контента — на одной и той же странице. Это не универсальная пропорция, а симптом: шаблон зависит от того, в какой момент бот снимает визуальное состояние страницы.

  • Hydration error. Рассинхрон серверной и клиентской разметки (классический React error #418), после которого затронутые блоки уходят в client-side рендер и становятся ненадёжными для бота.

  • Вес HTML 1–1.7 МБ из-за инлайн-JSON всего состояния приложения в разметку. Бьёт по TTFB и расходует crawl budget на каждой странице.

Отрендерить весь сайт нельзя — но это и не нужно. Достаточно умной выборки по типам страниц, чтобы доказать, какие шаблоны зависят от JavaScript для SEO-критичного контента. Дальше это снова ТЗ на шаблон, а не на URL.

Что собрано до доступа к GSC и Вебмастеру

К этому моменту, ещё без единого доступа к панелям, на руках:

  • почти полный инвентарь URL из sitemap;

  • карта URL-паттернов и сегментация по типам страниц;

  • список параметрического и дубль-слоя под закрытие в robots/canonical;

  • спрос по slug-паттернам и матрица «инвентарь × спрос»;

  • список классов URL без частотности и классов с потенциалом;

  • on-page gap по типам страниц;

  • карта рендер-рисков по шаблонам;

  • черновые правила noindex/canonical/sitemap;

  • пакет ТЗ для разработки.

Это не гадание. Гипотеза собрана из пересечения независимых источников:

инвентарь URL  + паттерны слагов  + семантический спрос  + посадочные конкурентов  + on-page сравнение  + рендер-диагностика

GSC и Вебмастер нужны не чтобы начать аудит, а чтобы подтвердить, уточнить и приоритизировать уже найденное.

После доступа: логи подтверждают гипотезы

Когда появляются логи и панели, работа меняет характер — с поиска на верификацию.

Серверные логи — единственный источник правды о том, как бот реально ходит по сайту. Парсю access-логи, выделяю запросы именно поискового бота (по IP-листам бота, а не по подменяемому user-agent), классифицирую каждый URL тем же классификатором типов, что и на этапе slug mining, и считаю распределение crawl budget.

# Googlebot отделяем по верифицированным IP-листам, не по UA (UA подделывают)GOOGLE_BOT_LISTS = {"googlebot.json", "special-crawlers.json"}def classify_url(url: str) -> str:    for pattern, label in URL_TEMPLATES:   # те же паттерны, что в slug mining        if pattern.search(url):            return label    return "Прочее"

Что показывает лог-анализ и какие пороги я считаю сигналом проблемы:

Метрика обхода

Сигнал

Что значит

Редиректы 301/302

> 3% обхода

бот листает удалённые карточки, отдающие 301 → прямая потеря бюджета

Ошибки 4xx

> 1%

бюджет тратится на несуществующие URL

Серверные 5xx

> 0.5%

падает доверие и crawl rate

URL с параметрами

> 5%

фасетный мусор в обходе — то, что мы метили под robots

Ответ > 2с

> 3%

бот снижает crawl rate на медленных страницах

Заблокировано антиботом

> 100

бот получает отказы от защиты — IP не в whitelist

Это не универсальные стандарты поисковиков, а рабочие пороги тревоги для первичной сортировки логов. На проекте с другим оборотом URL, серверной архитектурой и частотой обновления они двигаются — их задача поднять подозрительные классы наверх, а не вынести вердикт.

Crawl Stats в GSC дают ещё два среза, которые я смотрю в первую очередь. Распределение кодов ответа: на одном крупном проекте оно выглядело как 200 — 67%, 301 — 17%, 404 — 10%, 304 — 5%. Доля 301 была заметно выше моего рабочего ориентира — каждый шестой запрос бота уходил в редирект. И цели сканирования — соотношение «обновление известного» к «открытию нового». Когда на переобход старого уходит три четверти бюджета, новые страницы ждут индексации неделями.

Отдельная недооценённая ручка — 304 Not Modified. Если сервер корректно отдаёт Last-Modified и отвечает 304 на условный запрос, бот не качает неизменившийся HTML и экономит бюджет. На таких каталогах я обычно хочу видеть долю 304 заметно выше 5% — при условии, что контент редко меняется и сервер корректно работает с Last-Modified. Низкая доля обычно объясняется одной из трёх причин: Last-Modified не отправляется вовсе, отдаётся статической датой (бот ей не верит) или рассинхронизирован между www и мобильным поддоменом. Корректный Last-Modified на большом редко меняющемся каталоге может заметно снизить объём повторно скачиваемого HTML без единой правки контента.

Отдельно полезный приём — посчитать, какую долю обхода съедают удалённые карточки, отдающие 301. На классифайде с быстрым оборотом объявлений это часто двузначный процент бюджета, который уходит в никуда. Но рекомендация здесь не «всегда 410», а decision tree. Для снятого объявления без точного аналога — 410 Gone и удаление из sitemap. Если есть полноценная замена — новая карточка того же объекта, актуальный лот, канонически близкая категория — оправдан 301. Ошибка начинается там, где все снятые карточки механически редиректятся на нерелевантные листинги: для бота это не «переезд», а шум, который он продолжает листать.

И финальная сверка — пересечение трёх множеств: sitemap, проиндексированные страницы из Вебмастера, реальные точки входа из Метрики. Самое ценное вылавливается на стыке:

# URL, который приносит трафик из поиска, существует в индексе,# но которого НЕТ в sitemap — сайт сам себя не декларируетvaluable_missing = (    url in metrika_entries        # есть поисковый трафик    and url in webmaster_indexed  # бот его знает    and url not in sitemap_urls   # но в карте его нет)

Зеркальный случай — URL в sitemap и в индексе, но с нулём трафика и нулём спроса. Это и есть подтверждённая пустая посадочная: гипотеза, выдвинутая на этапе матчинга, теперь стоит на данных трёх систем.

Отчёт индексации в GSC разводит пустые посадочные на два класса, и лечатся они по-разному. «Просканирована, но пока не проиндексирована» — бот дошёл, увидел страницу и отказался её индексировать: чаще всего это шаблонный листинг без спроса или дубль по canonical. «Обнаружена, но не проиндексирована» — бот узнал про URL, но даже не сходил на него: симптом исчерпанного crawl budget или конфликта robots/sitemap. Первый класс — сигнал чистить шаблоны и спрос, второй — чинить обход. На большом каталоге оба исчисляются десятками процентов инвентаря, и именно их я заранее метил на этапе матрицы «инвентарь × спрос».

Когда доступы появились, данные поисковиков не открыли проблему с нуля. Они подтвердили заранее собранную структуру: часть посадочных сгенерирована без спроса, часть имеет спрос, но проигрывает по шаблону, часть SEO-критичного контента зависит от клиентского рендеринга.

Сквозной пайплайн

Если собрать всё в одну последовательность, аудит большого сайта выглядит так:

Найти sitemap (robots.txt, /sitemap.xml, типовые пути)Скачать sitemap graph рекурсивно (index → gz → urlset)Извлечь инвентарь URL + lastmodНормализовать URLСвести URL к паттернам (заменить id на плейсхолдеры)Slug mining: частотность сегментов, смысловые группыКлассифицировать паттерны по типам страницПрогнать дубли по ЧПУ → план robots/canonical/301Построить seed-фразы по паттернамСнять спрос по паттернам (keys.so), отфильтровать шумМатрица «инвентарь × спрос» → статусы решенийВыборка репрезентативных URL по типамOn-page сравнение типов с конкурентами → ТЗ на шаблоныРендер-диагностика выборки (raw vs rendered vs bot view)После доступов: логи + GSC + Вебмастер + Метрика → подтверждение гипотез

Что на выходе

Не «аудит на 100 страниц текста», а набор рабочих таблиц и решений, каждое из которых масштабируется на класс URL:

  • инвентарь URL — паттерн, тип, глубина, источник в sitemap, статус;

  • карта паттернов — паттерн, число URL, пример, тип, риск, действие;

  • матрица спроса — паттерн, токены, кластер, частота, конкуренты, статус;

  • таблица решений по индексации — паттерн, число URL, спрос, риск дубля, действие (grow/merge/noindex/robots/301);

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

Где sitemap-first ломается

Метод — не серебряная пуля, и честнее сразу очертить, где он перестаёт работать:

  • ЧПУ не несут смысла — слаги вида /item/9283719283/. Sitemap даёт инвентарь, но не интенты.

  • Sitemap устарел или генерируется кривоlastmod у всех один, половина карт отдаёт 404, в картах неканонические URL. Тогда декларация врёт, и ей нельзя доверять как карте важного.

  • Важные URL не попадают в sitemap — тогда инвентарь неполный, и часть ценных посадочных вы найдёте только через логи и Метрику.

  • Сайт держит посадочные на query-параметрах — основной слой спроса живёт в ?, а не в пути. Slug mining по сегментам его не видит.

  • Карточки живут через JS/API, а в sitemap только shell-URL — смысл придётся добирать рендером и API, не разбором строк.

  • Спрос не снимается по slug-токенам — узкая или новая ниша, где keys.so/Wordstat пусты. Матрица «инвентарь × спрос» получается дырявой.

  • Гео и категории в URL не совпадают с тем, как ищут — структура сайта расходится с языком спроса, и матчинг даёт ложные «нет спроса».

  • Персонализированные или закрытые листинги — то, что отдаётся пользователю, не равно тому, что в sitemap.

Поэтому финальная честная формулировка:

Sitemap-first не заменяет краул, логи и панели. Он позволяет не начинать аудит вслепую.

Вывод

На большом сайте аудит начинается не с полного краула, а с инвентаризации URL-пространства. Sitemap, ЧПУ, slug mining и семантический матчинг находят массовые проблемы раньше, чем появятся доступы к GSC, Вебмастеру и логам.

Полный краул — не первый шаг, а один из инструментов валидации. Начнёте с него — потратите деньги и время на данные, которые всё равно придётся сегментировать заново. Начнёте с sitemap-first — к моменту получения доступов у вас уже есть карта проблем, список гипотез и ТЗ по типам страниц, которые масштабируются на сотни тысяч URL.

Главный выигрыш sitemap-first подхода не в экономии запросов, а в смене единицы анализа: вместо миллиона URL вы работаете с десятками классов страниц.

Большой сайт не нужно есть целиком. Его нужно сначала превратить из хаоса URL в анализируемую структуру. Всё остальное — детализация внутри неё.

ссылка на оригинал статьи https://habr.com/ru/articles/1049136/