SEO-комбайн на AI-агентах

—

от автора

Как заменить Screaming Frog, Semrush и Ahrefs связкой LLM и API-шлюзов

В SEO-среде жив стереотип: LLM — это текстовый генератор, а технический аудит делается только классическим софтом. Аргумент звучит весомо: у языковой модели нет сетевого движка, она не умеет обходить дерево сайта и работать с протоколами в реальном времени. Замечание корректное, но вывод из него устарел. Проблема в том, что его авторы путают два уровня: LLM не должна собирать данные — она должна их оркестрировать. Современная агентская архитектура выносит сбор данных на внешние инструменты (tool calling), а модели оставляет планирование маршрута обхода, принятие решений на развилках и интерпретацию результатов. В таком виде связка закрывает значительную часть рутинных SEO-задач — от технического аудита до кластеризации семантики.

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

1. Краулинг: LLM как оркестратор, а не парсер

Классический десктопный краулер (Screaming Frog, Netpeak Spider) работает линейно: HTTP-запросы, коды ответов, парсинг HTML, граф связей. ИИ в этой схеме исторически жил на финале: специалист выгружал CSV и просил ChatGPT найти паттерны мусорных URL и написать регулярки.

Агентский подход устроен иначе — модель вынесена за пределы цикла обхода: [Агент / LLM] — план задачи: «собрать URL, Title, ответ сервера» │ tool call ▼ [Скилл краулера: headless-браузер / scraping-API] │ обход дерева сайта ▼ [Локальное хранилище: SQLite / CSV] │ агрегат, а не сырой HTML ▼ [LLM] — анализ сводных данных, формирование выводов

Ключевое решение: сырой HTML не попадает в контекстное окно. Модель получает не тысячу страниц, а агрегированный отчёт о них. Это одновременно экономия токенов на 2–3 порядка и снятие ограничения на размер сайта.

Что это даёт относительно классического софта:

SPA и рендеринг. Сайты на React/Vue/Angular не требуют ручной настройки рендеринга — headless-браузер (Playwright) отрабатывает скролл, клики и асинхронную подгрузку нативно. Живучесть. Сменилась пагинация или селектор — обычный скрипт падает. Агент видит, что элементы не найдены, переформулирует стратегию поиска и продолжает сессию. Точечные повторные запросы. Вместо полного пересканирования сайта ради проверки одной страницы агент запрашивает только её.

Скилл краулера целиком — обратите внимание, что наружу уходит только сводка, а не сырые страницы:

# pip install playwright aiohttp beautifulsoup4# playwright install chromiumimport asyncio, sqlite3from dataclasses import dataclass, asdictfrom urllib.parse import urljoin, urlparsefrom urllib.robotparser import RobotFileParserimport aiohttpfrom bs4 import BeautifulSoupfrom playwright.async_api import async_playwright@dataclassclass PageRecord:    url: str    status: int    title: str    depth: intclass CrawlerSkill:    """Агент вызывает этот класс как tool. HTML наружу не уходит —    наружу уходит только сводка и локальная БД."""    def __init__(self, start_url: str, max_pages: int = 500,                 delay: float = 1.0, render_js: bool = False):        self.start = start_url        self.max_pages = max_pages        self.delay = delay        self.render_js = render_js        self.domain = urlparse(start_url).netloc        self.robots = RobotFileParser(start_url + "/robots.txt")        try:            self.robots.read()        except OSError:            pass  # robots.txt недоступен — не падаем, продолжаем    def _allowed(self, url: str) -> bool:        return self.robots.can_fetch("SEOAgent", url)    async def _extract_links(self, html: str) -> list[str]:        soup = BeautifulSoup(html, "html.parser")        for tag in soup(["script", "style", "noscript"]):            tag.decompose()        links = set()        for a in soup.find_all("a", href=True):            href = urljoin(self.start, a["href"].split("#")[0])            if urlparse(href).netloc == self.domain and href.startswith("http"):                links.add(href)        return list(links)    async def run(self) -> dict:        db = sqlite3.connect("crawl.db")        db.execute('''CREATE TABLE IF NOT EXISTS pages                      (url TEXT PRIMARY KEY, status INT, title TEXT, depth INT)''')        seen, queue, done = {self.start}, [(self.start, 0)], []        async with aiohttp.ClientSession(            headers={"User-Agent": "SEOAgent/1.0 (contact@example.com)"}) as s:            if self.render_js:  # SPA — подключаем headless-браузер                self._pw = await async_playwright().start()                self._browser = await self._pw.chromium.launch()            while queue and len(done) < self.max_pages:                url, depth = queue.pop(0)                if not self._allowed(url):                    continue                try:                    if self.render_js:                        page = await self._browser.new_page()                        resp = await page.goto(url, timeout=30000)                        html = await page.content()                        status = resp.status if resp else 0                        await page.close()                    else:                        async with s.get(url, timeout=30) as r:                            html, status = await r.text(), r.status                except (aiohttp.ClientError, asyncio.TimeoutError):                    continue                title = BeautifulSoup(html, "html.parser").title                title = title.text.strip()[:200] if title else ""                db.execute("INSERT OR REPLACE INTO pages VALUES (?,?,?,?)",                           (url, status, title, depth))                db.commit()                done.append(asdict(PageRecord(url, status, title, depth)))                if depth < 5:                    queue += [(l, depth + 1) for l in await self._extract_links(html)                              if l not in seen]                    seen |= {l for l, _ in queue}                await asyncio.sleep(self.delay)  # вежливость к серверу        # Агенту возвращаем АГРЕГАТ, а не сырые страницы — экономия токенов        report = {            "crawled": len(done),            "status_codes": dict(db.execute(                "SELECT status, COUNT(*) FROM pages GROUP BY status").fetchall()),            "duplicate_titles": [t for t, c in db.execute(                "SELECT title, COUNT(*) c FROM pages GROUP BY title HAVING c > 1")],            "sample_errors": db.execute(                "SELECT url, status FROM pages WHERE status >= 400 LIMIT 20").fetchall(),        }        db.close()        return report```Предупреждение: по умолчанию этот скилл «вежливый» — он читает robots.txt, соблюдает задержки между запросами и использует честный User-Agent с контактом. Я сознательно не встраиваю в него обход антибот-систем — почему, объясню в следующем разделе.## 2. Транспортный уровень: чем заменить Screaming FrogFirecrawl — API, спроектированный под LLM-потребителей: сам обходит дерево сайта, рендерит JavaScript и отдаёт чистый Markdown вместо сырого HTML. Для публичных сайтов это пять строк вместо ста:```# pip install firecrawl-pyfrom firecrawl import FirecrawlAppapp = FirecrawlApp(api_key="fc-***")# /crawl обходит дерево сайта сам и отдаёт готовый Markdownresult = app.crawl_url(    "https://example-catalog.ru/catalog",    params={"limit": 200, "scrapeOptions": {"formats": ["markdown"]}},)pages = result["data"]          # список страниц в Markdown```Crawl4AI — open-source альтернатива для self-hosted-сценариев: если инфраструктура не должна отдавать ваши данные третьей стороне, это рабочий вариант.Browserless.io — удалённый Chrome по API: агент формулирует цель («дождись элемента X, кликни, верни результат»), а не селекторы.Оговорюсь отдельно, потому что в популярной подаче эта тема выглядит лучше, чем есть. Сопоставление фингерпринтов, ротация User-Agent и агрессивный обход антибот-систем (Cloudflare и т. п.) — юридически серая зона: парсинг публичных данных обычно допустим, но обход средств защиты сайта и нарушение ToS — это риск для проекта, а не «фича» агента. Я сознательно исключил из стека сервисы, чьё основное назначение — именно обход защит, и ограничиваюсь публичными страницами, robots.txt и вежливыми rate limits. Это же правило действует и для приёма «ходить агентом на бесплатные виджеты чужих сервисов»: он нарушает ToS этих сервисов, и в статье, рассчитанной на инженерную аудиторию, подавать его как преимущество — путь к справедливой критике в комментариях.## 3. Данные: Just-in-Time вместо терабайтных базSemrush и Ahrefs продают подписку на непрерывно обновляемые базы ключей и ссылок. Для конкретного проекта используется малая доля этих данных, но платите вы за всю базу.Агентский подход — точечные запросы в момент задачи:Сбор семантики: коннектор к DataForSEO или Keys.so. По команде «собери семантику категории X» агент получает JSON с ключами конкурентов, частотностью и CPC, фильтрует мусор и группирует по интенту. Кластеризацию по смыслу (а не по пересечению URL в ТОП-10) делает эмбеддинг-модель:```# cluster.py — кластеризация семантики по смыслу, а не по пересечению URL# pip install sentence-transformers scikit-learnimport csvfrom sentence_transformers import SentenceTransformerfrom sklearn.cluster import KMeanswith open("queries.csv", encoding="utf-8") as f:        # колонка: keyword    queries = [row["keyword"] for row in csv.DictReader(f)]model = SentenceTransformer("intfloat/multilingual-e5-base")X = model.encode([f"query: {q}" for q in queries], normalize_embeddings=True)k = max(3, len(queries) // 25)                          # ~25 запросов на кластерlabels = KMeans(n_clusters=k, n_init=10).fit_predict(X)groups = {}for q, label in zip(queries, labels):    groups.setdefault(int(label), []).append(q)with open("clusters.csv", "w", newline="", encoding="utf-8") as f:    writer = csv.writer(f)    for label, q in sorted(groups.items()):        writer.writerow([label] + q)

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

import numpy as npfrom sentence_transformers import SentenceTransformermodel = SentenceTransformer("intfloat/multilingual-e5-base")def embed(text: str) -> np.ndarray:    prefix = "passage: " if len(text) > 50 else "query: "    return model.encode(prefix + text, normalize_embeddings=True)# Страницы доноров и акцептора уже собраны скиллом из раздела 1links = [    {"url": "https://forum-auto.ru/t/123",     "context": "обсуждение тормозной системы ВАЗ"},    {"url": "https://kitchen-recipes.info/post/9",     "context": "рецепты салатов"},]acceptor = embed("как выбрать тормозные колодки для ВАЗ")for link in links:    link["score"] = round(float(np.dot(acceptor, embed(link["context"]))), 3)print(sorted(links, key=lambda x: -x["score"]))# донор с score < 0.5 — вероятный спам в тематике, в аутрич его не тратим

Проверка перелинковки: тот же приём сравнения эмбеддингов донора и акцептора выявляет ситуации «анкор релевантен, но околоссылочный текст размывает тематику» — для этого не нужен дорогой софт, достаточно embedding-модели.

Для рунетовской специфики SpyWords и Keys.so дают данные по контекстной рекламе и видимости, которых в западных базах физически нет.

4. Оркестрация: агентский цикл и No-Code

Минимальный агентский цикл на tool calling — модель сама решает, когда вызвать краулер и с какими параметрами:

# pip install openai — API совместим с DeepSeek, OpenAI, vLLM и др.import jsonfrom openai import OpenAIfrom crawler_skill import CrawlerSkillclient = OpenAI(base_url="https://api.deepseek.com", api_key="sk-***")tools = [{    "type": "function",    "function": {        "name": "crawl_site",        "description": "Краулит сайт, возвращает сводку: коды ответов, "                       "дубликаты title, страницы с ошибками",        "parameters": {"type": "object", "properties": {            "url": {"type": "string"},            "max_pages": {"type": "integer", "default": 300},            "render_js": {"type": "boolean", "default": False},        }, "required": ["url"]},    },}]def execute(name: str, args: dict) -> str:    if name == "crawl_site":        crawler = CrawlerSkill(args["url"], max_pages=args.get("max_pages", 300),                               render_js=args.get("render_js", False))        return json.dumps(crawler.run(), ensure_ascii=False)    raise ValueError(f"Неизвестный tool: {name}")messages = [{"role": "user",             "content": "Проведи техаудит https://example.ru. Если сайт на JS — "                        "включи рендеринг. По итогам дай топ-5 проблем."}]while True:    resp = client.chat.completions.create(model="deepseek-chat",                                          messages=messages, tools=tools)    msg = resp.choices[0].message    messages.append(msg.model_dump(exclude_none=True))    if not msg.tool_calls:        print(msg.content)  # финальный аналитический отчёт        break    for call in msg.tool_calls:        args = json.loads(call.function.arguments)        messages.append({"role": "tool", "tool_call_id": call.id,                         "content": execute(call.function.name, args)})```Если кода хочется меньше — платформы оркестрации:Dify — open-source, self-host бесплатный; визуальный конструктор цепочек «URL → Firecrawl → LLM → отчёт».n8n — self-hosted автоматизация с AI-узлами. Скелет сценария ежедневного аудита (импортируется как основа; точные имена узлов сверяю с актуальной библиотекой перед запуском):```{  "name": "SEO-аудит по расписанию",  "nodes": [    { "name": "Cron", "type": "n8n-nodes-base.scheduleTrigger" },    { "name": "Список сайтов", "type": "n8n-nodes-base.googleSheets",      "parameters": { "operation": "read", "range": "sites!A2:A" } },    { "name": "Firecrawl", "type": "n8n-nodes-base.httpRequest",      "parameters": { "method": "POST", "url": "https://api.firecrawl.dev/v1/crawl",                      "jsonBody": "={{ {\"url\": $json.site, \"limit\": 200} }}" } },    { "name": "LLM", "type": "@n8n/n8n-nodes-langchain.openAi",      "parameters": { "model": "deepseek-chat",                      "messages": [{ "role": "user",                        "content": "Найди SEO-проблемы, верни JSON: issue, severity, fix" }] } },    { "name": "Telegram", "type": "n8n-nodes-base.telegram" }  ],  "connections": {    "Cron": { "main": [[{ "node": "Список сайтов" }]] },    "Список сайтов": { "main": [[{ "node": "Firecrawl" }]] },    "Firecrawl": { "main": [[{ "node": "LLM" }]] },    "LLM": { "main": [[{ "node": "Telegram" }]] }  }}```CrewAI / LangGraph — если всё-таки хочется кода: многоролевая оркестрация для более сложных сценариев.## 5. Модели в 2026 году: что изменилосьВажное обновление к реалиям, потому что прошлогодние советы здесь уже не работают:Бесплатный Gemini больше не «абсолютно бесплатный». В декабре 2025 года Google урезал квоты Free Tier на 50–92% — рассчитывать на него как на рабочую лошадку для массового парсинга уже нельзя.DeepSeek — по-прежнему самый дешёвый вариант. V4 Pro: $0.44 за миллион входных и $0.88 за миллион выходных токенов, с удвоением цены в пиковые часы. Для задач аудита, где вход многословнее выхода, это оптимальный профиль расходов.Локальные модели (Llama, Qwen через Ollama/vLLM) — разумный вариант для чувствительных данных: их можно развернуть на своём сервере и исключить отправку клиентского контента во внешние API.Предупреждение: экономия на модели легко превращается в перерасход на токенах, если агент начинает читать сырые страницы. В архитектуре из раздела 1 это исключено конструкцией — модель видит только агрегаты. Если же вы даёте LLM полный HTML каждой страницы, счёт за API растёт линейно с размером сайта, и на крупных проектах переплата обгоняет годовую лицензию любого классического софта.## 6. Ограничения, которые никуда не делись**Честный список того, что агентская модель НЕ закрывает:**Highload-краулинг. Портал на 500 000+ URL в многопоточном режиме классический краулер пройдёт быстрее и стабильнее: накладные расходы на оркестрацию каждой итерации у агентов выше.Стоимость глубокой аналитики. Скилл «чистого» паука дешёвый, но как только модель начинает читать и осмыслять код страниц на лету, расходы на токены на масштабах реальных сайтов могут превысить стоимость годовой лицензии классического софта.Гарантии. Агент — вероятностная система. Для критичных процессов (мониторинг доступности, SLA-отчётность) детерминированный скрипт остаётся правильным выбором; агент лучше для аналитики и первичной разведки.### ВыводПравильная ментальная модель такая: LLM — не инструмент сбора данных, а планировщик и аналитик, а данные приходят из специализированных API и headless-браузеров. Для команд с ограниченным бюджетом и большим количеством рутинных SEO-задач агентский стек экономит и деньги, и часы ручной работы. Для высоконагруженного обхода и задач с жёстким SLA классический софт по-прежнему в деле.Рациональный план — не «заменить всё», а гибрид: детерминированный сбор сырых метрик + LLM-оркестратор для решений и интерпретации.*К сожалению, я не располагаю техническими возможностями для прогонов на нескольких боевых сайтах. Если у кого-то будет возможность прогнать это на живых проектах — буду признателен за результаты в комментариях. Но в любом случае заранее спасибо всем, кто найдёт время написать своё мнение о предлагаемом решении нейрокомбайна.*

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