AI-агент для автономной навигации по веб-сайтам

Проектируем и внедряем системы искусственного интеллекта: от прототипа до production-ready решения. Наша команда объединяет экспертизу в машинном обучении, дата-инжиниринге и MLOps, чтобы AI работал не в лаборатории, а в реальном бизнесе.
Показано 1 из 1 услугВсе 1566 услуг
AI-агент для автономной навигации по веб-сайтам
Средняя
~2-4 недели
Часто задаваемые вопросы
Направления AI-разработки
Этапы разработки AI-решения
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1218
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    853
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1047
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    825

AI-агент для автономной навигации по веб-сайтам

Автономная веб-навигация — это когда агент получает высокоуровневую задачу («найди все открытые вакансии в компании X и сохрани контакты HR», «собери технические характеристики конкурирующих продуктов») и самостоятельно строит маршрут по сайту. Без жёстких скриптов, без заранее прописанных URL — агент планирует, навигирует, собирает данные и адаптируется к структуре каждого конкретного сайта.

Агент с планированием и памятью

Ключевое отличие от простого скрепера — агент ведёт внутренний план: что уже посещено, что ещё нужно изучить, как соотносить текущую страницу с общей задачей.

from anthropic import Anthropic
from playwright.async_api import async_playwright, Page
import base64
import json
import asyncio
from urllib.parse import urljoin, urlparse
from collections import deque

client = Anthropic()


class WebNavigationAgent:
    """Автономный агент для навигации по веб-сайтам"""

    NAV_TOOLS = [
        {
            "name": "analyze_page",
            "description": "Анализирует текущую страницу: контент, ссылки, данные",
            "input_schema": {
                "type": "object",
                "properties": {
                    "extract_data": {"type": "boolean", "default": True, "description": "Извлечь структурированные данные"},
                },
            },
        },
        {
            "name": "navigate_to",
            "description": "Переходит по ссылке или URL",
            "input_schema": {
                "type": "object",
                "properties": {
                    "url": {"type": "string"},
                    "reason": {"type": "string", "description": "Зачем переходим на эту страницу"},
                },
                "required": ["url"],
            },
        },
        {
            "name": "click_element",
            "description": "Кликает на элемент (кнопка, ссылка, пагинация)",
            "input_schema": {
                "type": "object",
                "properties": {
                    "selector": {"type": "string"},
                    "text": {"type": "string", "description": "Текст элемента для поиска"},
                },
            },
        },
        {
            "name": "search_on_page",
            "description": "Использует поиск на сайте",
            "input_schema": {
                "type": "object",
                "properties": {
                    "query": {"type": "string"},
                    "search_input_selector": {"type": "string", "default": 'input[type="search"], input[name="q"], .search-input'},
                },
                "required": ["query"],
            },
        },
        {
            "name": "store_result",
            "description": "Сохраняет найденные данные",
            "input_schema": {
                "type": "object",
                "properties": {
                    "data": {"type": "object", "description": "Собранные данные"},
                    "source_url": {"type": "string"},
                },
                "required": ["data"],
            },
        },
        {
            "name": "go_back",
            "description": "Возвращается на предыдущую страницу",
            "input_schema": {"type": "object", "properties": {}},
        },
        {
            "name": "mark_done",
            "description": "Отмечает задачу как выполненную",
            "input_schema": {
                "type": "object",
                "properties": {
                    "summary": {"type": "string"},
                },
                "required": ["summary"],
            },
        },
    ]

    def __init__(self, page: Page, max_pages: int = 30):
        self.page = page
        self.max_pages = max_pages
        self.visited_urls: set = set()
        self.collected_data: list = []
        self.navigation_log: list = []

    async def _get_page_context(self) -> dict:
        """Получает контекст текущей страницы"""
        screenshot_bytes = await self.page.screenshot(type="png")

        page_data = await self.page.evaluate("""
            () => {
                // Собираем ссылки с текстом
                const links = Array.from(document.querySelectorAll('a[href]'))
                    .map(a => ({text: a.textContent.trim().slice(0, 80), href: a.href}))
                    .filter(l => l.text && !l.href.startsWith('javascript:'))
                    .slice(0, 40);

                // Основной текстовый контент
                const mainContent = (() => {
                    const main = document.querySelector('main, article, .content, #content, .main');
                    return (main || document.body).innerText.slice(0, 3000);
                })();

                return {
                    url: location.href,
                    title: document.title,
                    links,
                    content_preview: mainContent,
                    has_pagination: !!document.querySelector('.pagination, [aria-label="pagination"], .page-next'),
                    has_search: !!document.querySelector('input[type="search"], input[name="q"]'),
                };
            }
        """)

        return {
            **page_data,
            "screenshot": base64.b64encode(screenshot_bytes).decode(),
        }

    async def _execute_tool(self, tool_name: str, tool_input: dict) -> str:
        self.navigation_log.append({"tool": tool_name, "input": tool_input, "url": self.page.url})

        if tool_name == "analyze_page":
            ctx = await self._get_page_context()
            screenshot = ctx.pop("screenshot")

            result = {"page_info": ctx}
            if tool_input.get("extract_data", True):
                # Извлекаем структурированные данные через LLM
                extraction = client.messages.create(
                    model="claude-haiku-4-5",
                    max_tokens=1024,
                    messages=[{
                        "role": "user",
                        "content": [
                            {
                                "type": "image",
                                "source": {
                                    "type": "base64",
                                    "media_type": "image/png",
                                    "data": screenshot,
                                }
                            },
                            {"type": "text", "text": f"Извлеки ключевые данные со страницы в JSON.\nКонтент: {ctx['content_preview'][:1000]}"}
                        ]
                    }],
                )
                try:
                    text = extraction.content[0].text
                    result["extracted_data"] = json.loads(text[text.find("{"):text.rfind("}") + 1])
                except (json.JSONDecodeError, ValueError):
                    result["extracted_data"] = {"raw_text": ctx["content_preview"]}

            return json.dumps(result, ensure_ascii=False)

        elif tool_name == "navigate_to":
            url = tool_input["url"]
            if url in self.visited_urls:
                return json.dumps({"skipped": True, "reason": "уже посещали"})

            self.visited_urls.add(url)

            # Проверяем домен (не уходим на другие сайты)
            current_domain = urlparse(self.page.url).netloc
            target_domain = urlparse(url).netloc
            if target_domain and target_domain != current_domain:
                return json.dumps({"error": f"Другой домен: {target_domain}"})

            await self.page.goto(url, wait_until="networkidle", timeout=15000)
            return json.dumps({"url": self.page.url, "title": await self.page.title()})

        elif tool_name == "click_element":
            try:
                if tool_input.get("text"):
                    await self.page.get_by_text(tool_input["text"], exact=False).first.click()
                elif tool_input.get("selector"):
                    await self.page.click(tool_input["selector"])
                await self.page.wait_for_load_state("networkidle", timeout=8000)
                return f"Кликнул, текущий URL: {self.page.url}"
            except Exception as e:
                return f"Ошибка клика: {e}"

        elif tool_name == "search_on_page":
            try:
                selector = tool_input.get("search_input_selector", 'input[type="search"]')
                await self.page.fill(selector, tool_input["query"])
                await self.page.keyboard.press("Enter")
                await self.page.wait_for_load_state("networkidle", timeout=8000)
                return f"Поиск выполнен: {tool_input['query']}, URL: {self.page.url}"
            except Exception as e:
                return f"Ошибка поиска: {e}"

        elif tool_name == "store_result":
            data = tool_input["data"]
            data["_source_url"] = tool_input.get("source_url", self.page.url)
            self.collected_data.append(data)
            return json.dumps({"stored": True, "total_collected": len(self.collected_data)})

        elif tool_name == "go_back":
            await self.page.go_back()
            await self.page.wait_for_load_state("networkidle", timeout=5000)
            return f"Вернулись на: {self.page.url}"

        elif tool_name == "mark_done":
            return json.dumps({"done": True, "summary": tool_input["summary"]})

        return "Unknown tool"

    async def navigate(self, task: str, start_url: str) -> dict:
        """Автономно выполняет навигационную задачу"""
        await self.page.goto(start_url, wait_until="networkidle")
        self.visited_urls.add(start_url)

        messages = [{
            "role": "user",
            "content": f"""Задача: {task}

Начальный URL: {start_url}
Ты можешь посетить не более {self.max_pages} страниц.

Начни с анализа текущей страницы, затем планируй навигацию.
Когда задача выполнена или данные собраны — вызови mark_done."""
        }]

        steps = 0
        done = False

        while steps < self.max_pages * 2 and not done:
            response = client.messages.create(
                model="claude-sonnet-4-5",
                max_tokens=2048,
                system=f"""Ты — автономный веб-агент. Выполняй задачу, navigating по сайту.
Стратегия: начни с широкого обзора, потом углубляйся в релевантные разделы.
Сохраняй данные через store_result по мере нахождения.
Текущий прогресс: посещено {len(self.visited_urls)} страниц, собрано {len(self.collected_data)} записей.""",
                tools=self.NAV_TOOLS,
                messages=messages,
            )

            tool_results = []

            for block in response.content:
                if block.type == "tool_use":
                    result = await self._execute_tool(block.name, block.input)

                    if block.name == "mark_done":
                        done = True

                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })

            if response.stop_reason == "end_turn" or done:
                break

            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
            steps += 1

        return {
            "collected_data": self.collected_data,
            "pages_visited": len(self.visited_urls),
            "navigation_log": self.navigation_log,
        }

Практический кейс: сбор данных о конкурентах

Задача: компания хотела еженедельно собирать данные о новых кейсах, публикациях, вакансиях и ценовых пакетах 20 конкурентов.

Реализация:

  • Агент получает список из 20 сайтов и задачу для каждого
  • Параллельный запуск 5 агентов через asyncio.gather
  • Данные сохраняются в PostgreSQL
async def collect_competitor_data(competitor_url: str) -> dict:
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        agent = WebNavigationAgent(page, max_pages=15)

        result = await agent.navigate(
            task="Найди: 1) последние 3 кейса/проекта с описанием, 2) открытые вакансии с требованиями, 3) тарифные планы или ценовые пакеты",
            start_url=competitor_url,
        )

        await browser.close()
        return result


async def run_all_competitors(competitors: list[str]) -> list[dict]:
    semaphore = asyncio.Semaphore(5)

    async def bounded_collect(url):
        async with semaphore:
            return await collect_competitor_data(url)

    return await asyncio.gather(*[bounded_collect(url) for url in competitors])

Результаты:

  • 20 конкурентов × 15 страниц: 35–45 минут (5 параллельных агентов)
  • Полнота данных: 78% (22% страниц с CAPTCHA или JS-heavy SPA без доступного контента)
  • Ручное время на тот же объём: 6–8 часов в неделю

Сроки

  • Базовый навигационный агент: 1 неделя
  • Специализированный агент для конкретного типа сайтов: +3–5 дней
  • Параллельный запуск + дедупликация данных: +3–5 дней
  • Scheduled мониторинг с алертами об изменениях: +1 неделя