
Есть привычная ошибка в техническом аудите больших сайтов: открыть краулер, поставить лимит побольше и просканировать всё.
На сайте в пару тысяч страниц это работает. На сайте с семизначным инвентарём 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 есть физические ограничения: один 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 gzip
from io import BytesIO
import requests
from lxml import etree
NS = {"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 data
def 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 re
def 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 Counter
seg_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
Затем — и это самая полезная часть — каждый частотный сегмент раскладывается по смысловым группам. Получается словарь, который и есть карта осей фильтрации сайта:
Смысловая группа | Примеры сегментов |
|---|---|
Действие |
|
Тип объекта |
|
Комнатность |
|
Рынок |
|
Условия |
|
Отделка |
|
Тип дома |
|
Класс |
|
Удобства |
|
Гео |
|
Теперь каждому 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-параметры: трекинг, сортировка, фасеты | убрать из обхода точечно |
|
Технические разделы (плееры, webview, embed) | убрать из индекса, затем из обхода | сначала |
Листинг с лишним сегментом пути | склеить |
|
Каннибализация двух фильтров | выбрать главный |
|
Устаревший ID программы/бренда в слаге | перенести вес |
|
Правило, которое я держу железно: robots.txt — против обхода, canonical и noindex — против индексации. Их путают постоянно, и путаница стоит дорого. Закрыть фасеты Disallow в robots — это про экономию crawl budget: бот вообще не ходит в бесконечный параметрический слой. Но Disallow не выкидывает из индекса то, что туда уже попало по внешним ссылкам. Хуже того: если URL закрыт в robots, Google не сможет зайти на страницу и не увидит там meta noindex — то есть закрытие в robots может, наоборот, законсервировать мусор в индексе. Google это документирует прямо: robots.txt управляет доступом краулера и не является механизмом удаления страницы из индекса.
Поэтому у каждого класса URL в плане стоит не «закрыть», а конкретный сценарий:
Сценарий | Правильное действие |
|---|---|
URL уже в индексе и его надо убрать | сначала дать боту увидеть |
Бесконечный параметрический слой, который не должен обходиться |
|
Дубли с внешними ссылками |
|
Технический мусор без ценности, внешних ссылок и признаков индексации |
|
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 валиден. Спроса — ноль. И таких — сотни тысяч.
Решение зависит от причины, и его удобно свести к таблице статусов, которую потом просто отдаёшь в разработку:
Статус | Условие | Решение |
|---|---|---|
| есть частота и конкуренты | растить: усилить шаблон |
| частота на грани | мониторить, не плодить |
| спрос живёт в синониме | переименовать slug + 301 |
| нет спроса, дублирует родителя |
|
| нет спроса, технический хвост | убрать из sitemap, |
| спрос есть, шаблон слабее конкурентов | ТЗ на доработку типа |
| контент не отдаётся без 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-браузер после выполнения JS
bot 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.36
Googlebot 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_status
raw_title vs render_title
raw_canonical vs render_canonical
robots_meta (не должен меняться на noindex после JS)
raw_h1 vs render_h1
raw_links_count vs render_links_count # тревога: raw < 0.7 × rendered
raw_text_len vs render_text_len
schema_in_raw vs schema_in_rendered
js_errors_count, api_errors_count, render_timeПорог 70% — не стандарт поисковиков, а мой рабочий триггер для ручной проверки шаблона.
Снимок каждой версии страницы я кладу в один и тот же набор полей — тогда diff сводится к сравнению двух структур:
@dataclass
class 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 в анализируемую структуру. Всё остальное — детализация внутри неё.



























