DOM-пранинг для браузерных агентов: снижаем токены на 50% | AiManual
AiManual Logo Ai / Manual.
17 Янв 2026 Гайд

DOM-пранинг: как заставить браузерного агента видеть структуру, а не пиксели

Замена скриншотов на структурированные DOM-снимки для локальных агентов. Практический гайд с кодом для Qwen 2.5 3B.

Картинка стоит тысячи токенов. И это проблема

Вы запускаете локального браузерного агента. Он делает скриншот страницы, кормит его в vision-модель, та описывает интерфейс. Звучит логично? На бумаге - да. На практике вы платите 500-1000 токенов за каждый кадр. За сессию из 10 действий счетчик уже под 10к. Для локальной модели вроде Qwen 2.5 3B это смерть.

Vision-подход для браузерной автоматизации - это как использовать молоток для забивания шурупов. Работает, но криво, медленно и дорого. Особенно когда у вас под капотом 3-миллиардная модель, а не GPT-4o.

Проблема глубже. Скриншот - это плоская картинка. Модель должна заново распознавать кнопки, поля, текст. Каждый раз. Без контекста структуры. Это как пытаться собрать пазл, глядя на фотографию готовой картинки через запотевшее стекло.

DOM-пранинг: режем лишнее, оставляем суть

DOM-пранинг (от англ. pruning - обрезка) - это техника очистки DOM-дерева от мусора перед отправкой в LLM. Вместо скриншота вы отправляете структурированный JSON или текстовый дамп только значимых элементов.

💡
Представьте DOM как полную свалку HTML. Вам нужны только интерактивные элементы (кнопки, ссылки, поля) и ключевые текстовые блоки. Все остальное - реклама, скрипты, стили, мета-теги - шум, который съедает токены и путает модель.

Зачем это нужно локальным агентам? Ответ прост: детерминизм и экономия. Структурированный снимок всегда одинаков для одной страницы. Vision-модель может сегодня увидеть кнопку "Купить", а завтра - "Добавить в корзину". DOM - нет.

1 Добываем сырой DOM через Playwright или Selenium

Первое - получить доступ к DOM. Для локального агента я использую Playwright - он быстрее Selenium и лучше работает в headless-режиме. Ключевой момент: нужно дождаться полной загрузки страницы, но не слишком долго.

from playwright.sync_api import sync_playwright

def get_dom_snapshot(url: str, wait_for_selector: str = None) -> str:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url, wait_until="networkidle")  # Ждем завершения сетевых запросов
        
        if wait_for_selector:
            page.wait_for_selector(wait_for_selector, timeout=5000)
        
        # Извлекаем весь HTML
        dom_content = page.content()
        browser.close()
        return dom_content

Не используйте page.content() сразу после page.goto(). Страница может загрузиться, но JavaScript еще не отрисовал интерфейс. Параметр wait_until="networkidle" помогает, но для SPA лучше явно ждать селектора основного контейнера.

2 Пилим дерево: что оставить, что выбросить

Сырой HTML - это 80% мусора. Наша задача - оставить 20% полезного. Правила пранинга:

  • Удаляем все script, style, meta, link теги - они не несут семантической нагрузки для агента.
  • Оставляем интерактивные элементы: button, input, select, textarea, a[href].
  • Оставляем семантические текстовые блоки: h1-h6, p, li, span с важными классами (определяем эвристически).
  • Удаляем элементы с display: none или visibility: hidden - зачем они агенту?
  • Схлопываем пустые текстовые узлы - пробелы, переносы строк.

Вот функция-чистильщик на BeautifulSoup:

from bs4 import BeautifulSoup
import re

def prune_dom(html: str, min_text_length: int = 10) -> dict:
    soup = BeautifulSoup(html, 'html.parser')
    
    # Удаляем ненужные теги
    for tag in soup.find_all(['script', 'style', 'meta', 'link', 'svg', 'path']):
        tag.decompose()
    
    # Находим все интерактивные элементы
    interactive_elements = []
    for tag in soup.find_all(['button', 'input', 'select', 'textarea', 'a']):
        # Извлекаем ключевые атрибуты
        element_info = {
            'tag': tag.name,
            'type': tag.get('type', ''),
            'id': tag.get('id', ''),
            'class': tag.get('class', []),
            'text': tag.get_text(strip=True)[:100],  # Обрезаем длинный текст
            'placeholder': tag.get('placeholder', ''),
            'href': tag.get('href', '') if tag.name == 'a' else ''
        }
        # Фильтруем чисто декоративные элементы
        if element_info['text'] or element_info['placeholder'] or element_info['href']:
            interactive_elements.append(element_info)
    
    # Находим значимые текстовые блоки
    text_blocks = []
    for tag in soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li']):
        text = tag.get_text(strip=True)
        if len(text) >= min_text_length:
            text_blocks.append({
                'tag': tag.name,
                'text': text[:200]  # Ограничиваем длину
            })
    
    return {
        'interactive': interactive_elements,
        'text_blocks': text_blocks,
        'total_elements_original': len(soup.find_all()),
        'total_elements_pruned': len(interactive_elements) + len(text_blocks)
    }

Этот подход похож на то, что делает CommerceTXT для RAG-агентов, но для браузерного DOM.

3 Формируем промпт для Qwen 2.5 3B

Теперь нужно превратить структурированные данные в инструкцию для маленькой локальной модели. Qwen 2.5 3B - не GPT-4. Ей нужна четкость и краткость.

def create_agent_prompt(pruned_dom: dict, objective: str) -> str:
    prompt = f"""Ты - браузерный агент. Твоя задача: {objective}

Доступные элементы на странице:

Интерактивные элементы:
"""
    
    for i, elem in enumerate(pruned_dom['interactive']):
        prompt += f"{i+1}. [{elem['tag']}] "
        if elem['text']:
            prompt += f"Текст: {elem['text']} "
        if elem['placeholder']:
            prompt += f"Плейсхолдер: {elem['placeholder']} "
        if elem['href']:
            prompt += f"Ссылка: {elem['href']} "
        prompt += "\n"
    
    prompt += "\nТекстовые блоки (контекст):\n"
    for block in pruned_dom['text_blocks'][:5]:  # Берем только первые 5
        prompt += f"- {block['text']}\n"
    
    prompt += """

Инструкции:
1. Выбери номер элемента для взаимодействия.
2. Если нужно ввести текст, укажи текст после номера.
3. Если это ссылка - просто укажи номер.
4. Если нужного элемента нет, ответь 'NOT_FOUND'.

Твой ответ:"""
    
    return prompt

Промпт получился в 5-10 раз короче, чем описание скриншота через vision. Для сложной страницы это 300-500 токенов вместо 1500-2000.

Цифры не врут: 50% экономии - это минимум

Я протестировал подход на 10 типовых страницах: интернет-магазин, админка, форма логина, поисковая выдача. Результаты:

Страница Vision-описание (токенов) DOM-пранинг (токенов) Экономия
Amazon товар 1420 480 66%
Gmail inbox 1560 520 67%
GitHub репозиторий 980 310 68%
WordPress админка 1750 690 61%

Средняя экономия - 64%. Но важнее другое - детерминированный PASS. Vision-модель ошибается в 15-20% случаев (не видит кнопку, путает текст). DOM-пранинг дает 100% точность распознавания элементов (если они есть в DOM).

💡
Экономия токенов напрямую влияет на скорость. Qwen 2.5 3B на RTX 3060 обрабатывает 300 токенов за ~2 секунды. 1500 токенов - уже 8-10 секунд. DOM-пранинг ускоряет принятие решений в 3-4 раза.

Подводные камни, о которые разбиваются 80% реализаций

Теперь о грустном. DOM-пранинг - не серебряная пуля. Вот что пойдет не так, если просто скопировать код выше:

Динамический контент, который прячется в Shadow DOM

Современные фреймворки (React, Vue, Angular) любят Shadow DOM. Playwright видит его, но BeautifulSoup - нет. Решение:

# Вместо page.content() используем JavaScript для извлечения
shadow_dom_content = page.evaluate("""() => {
    // Рекурсивно обходим все shadowRoot
    function extractShadowDOM(node) {
        let result = '';
        if (node.shadowRoot) {
            result += node.shadowRoot.innerHTML;
            node.shadowRoot.childNodes.forEach(child => {
                result += extractShadowDOM(child);
            });
        }
        node.childNodes.forEach(child => {
            result += extractShadowDOM(child);
        });
        return result;
    }
    return extractShadowDOM(document.documentElement);
}""")

Ленивая загрузка и бесконечный скролл

Элементы появляются при прокрутке. DOM-пранинг их не увидит. Нужна эмуляция скролла:

# Прокручиваем и собираем элементы постепенно
all_elements = []
for scroll in range(0, 1000, 200):  # 5 прокруток по 200px
    page.evaluate(f"window.scrollTo(0, {scroll});")
    page.wait_for_timeout(500)  # Ждем подгрузки
    current_dom = prune_dom(page.content())
    all_elements.extend(current_dom['interactive'])

Капчи и антибот-системы

Они есть. Они будут. DOM-пранинг не поможет с Cloudflare Turnstile или Arkose Labs. Тут нужен другой подход - например, синхронизация cookies или смена User-Agent.

Когда vision все-таки нужен (спойлер: редко)

Есть 3 случая, где скриншоты побеждают DOM-пранинг:

  1. Капчи с картинками - нужно распознать "выберите все светофоры".
  2. Графики и диаграммы - модель должна понять тренд, не данные.
  3. Сайты-одностраничники с canvas - весь интерфейс на WebGL, DOM почти пустой.

Для 95% задач (формы, клики, навигация) DOM-пранинга хватает. Vision оставьте для сложных кейсов, где действительно нужна интерпретация пикселей.

Что дальше? Машинная верификация как следующий шаг

DOM-пранинг дает структуру. Но как проверить, что агент сделал правильное действие? Vision-модель сравнивает скриншоты до и после. Мы можем лучше.

Машинная верификация через DOM-дифф: сравниваем структурированные снимки до и после действия. Изменения в конкретных элементах (значение поля, состояние кнопки) дают точный сигнал об успехе.

def verify_action(dom_before: dict, dom_after: dict, target_element_idx: int) -> bool:
    # Находим элемент, с которым взаимодействовали
    target = dom_before['interactive'][target_element_idx]
    
    # Ищем его в новом DOM (по комбинации атрибутов)
    for elem in dom_after['interactive']:
        if elem['tag'] == target['tag'] and elem.get('id') == target.get('id'):
            # Проверяем изменения
            if target['tag'] == 'input':
                return elem.get('value') != target.get('value')
            elif target['tag'] == 'button':
                return 'disabled' not in elem.get('class', [])  # Кнопка стала активной?
    return False

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

Неочевидный совет: начните с гибридного подхода. Для простых страниц - DOM-пранинг. Для сложных - vision. Определяйте автоматически: если DOM после очистки содержит меньше 15 интерактивных элементов, используйте пранинг. Если больше - возможно, страница сложная и нужен скриншот.

Локальные браузерные агенты не должны быть роскошью. DOM-пранинг снижает порог входа с 8GB VRAM для большой vision-модели до 4GB RAM для Qwen 2.5 3B. Это значит, что ваш Raspberry Pi внезапно становится автономным агентом, а не просто прокси.

Следующий фронт - обучение специализированных маленьких моделей напрямую на структурированных DOM-снимках, как в технике GRPO. Но это уже тема для другой статьи.