Задача выглядела тривиально: найти на популярном объявленном сайте карточки с редкой видеокартой. Стандартные запросы requests.get сразу получали 403 или 429 из‑за WAF, а поисковые системы ставили капчу. Мы заменили сетевой слой на «человеческий» браузер, а парсинг – на текстовые маркеры, и локальная модель Qwen 7B/8B собрала нужные данные без прокси и платных API.

Почему обычный скрейпинг сразу падает

Большие площадки объявлений используют несколько уровней защиты: WAF блокирует запросы без обычного пользовательского агента, а динамический фронтенд генерирует случайные CSS‑классы. Попытка парсить HTML через BeautifulSoup приводит к пустому дереву, потому что страница загружается клиентским JavaScript. Даже поиск через site:domain.ru в Google, Яндексе и DuckDuckGo заканчивается капчей или таймаутом в изолированной среде Strata. Поэтому нужно менять точку входа – от сетевого уровня к уровню браузера, а от CSS‑классов к человеческим текстовым маркерам.

Эмуляция «человеческого» браузера: Edge в видимом режиме

Антифрод‑системы легко обнаруживают headless‑браузеры по флагу navigator.webdriver. Чтобы выглядеть легитимным, мы запускаем системный Microsoft Edge в видимом режиме и принудительно выводим окно на передний план. Это достигается параметром channel="msedge" и флагами --start-maximized и --focus. После загрузки страницы делаем паузу 8 секунд, чтобы все скрипты защиты успели выполниться.

bash
# Установить Playwright с поддержкой Edge
pip install playwright==1.44.0
playwright install msedge
python
# visual_agent.py
import asyncio
from playwright.async_api import async_playwright

async def run():
    async with async_playwright() as p:
        # Запускаем системный Edge, а не встроенный Chromium
        browser = await p.chromium.launch(
            channel="msedge",
            headless=False,
            args=["--start-maximized", "--focus"]
        )
        page = await browser.new_page()
        await page.goto("https://example-announcements.ru")
        # Выводим окно на передний план
        await page.bring_to_front()
        # Даем время защите отработать
        await page.wait_for_timeout(8000)
        # Далее будет передача DOM модели
        # ...

asyncio.run(run())

Эти несколько строк превращают ваш скрипт в «человек, открывающий страницу», что позволяет пройти большинство проверок WAF и избавиться от 403/429.

Текстовый парсинг без CSS‑классов

После того как страница полностью прогрузилась, модель получает доступ к реальному DOM‑дереву. Вместо привычных селекторов вроде .styles-module-root-XyZ12 мы ищем «человеческие» маркеры: символ валюты, слово «руб.» или любой ценовой паттерн. Найдя такой узел, поднимаемся к родительскому контейнеру, вытягиваем заголовок и ссылку, проверяя её регулярным выражением _\d{5,} (уникальный цифровой ID объявления).

python
import re

PRICE_MARKERS = ["₽", "руб.", "$"]
ID_PATTERN = re.compile(r"_\d{5,}")

async def extract_items(page):
    items = []
    # Получаем все текстовые узлы страницы
    nodes = await page.eval_on_selector_all("*", "elements => elements.map(e => e.innerText)")
    for text in nodes:
        if any(m in text for m in PRICE_MARKERS):
            # Находим родительский элемент карточки
            parent = await page.evaluate("""(txt) => {
                const el = Array.from(document.querySelectorAll('*'))
                    .find(e => e.innerText.includes(txt));
                return el.closest('.card')?.innerHTML || null;
            }""", text)
            if parent:
                # Выделяем заголовок и ссылку
                title = await page.evaluate("""(html) => {
                    const div = document.createElement('div');
                    div.innerHTML = html;
                    return div.querySelector('h2')?.innerText || '';
                }""", parent)
                link = await page.evaluate("""(html) => {
                    const div = document.createElement('div');
                    div.innerHTML = html;
                    return div.querySelector('a[href]')?.href || '';
                }""", parent)
                # Проверяем ID в ссылке
                if ID_PATTERN.search(link):
                    items.append({
                        "title": title,
                        "price": text,
                        "url": link
                    })
    return items

Такой «пиксельно‑логический» подход гарантирует, что даже при полной перестановке CSS‑классов агент будет находить нужные карточки, потому что ориентируется на смысл, а не на стили.

Сборка разнесенных данных по уникальному ID объявления

Современные маркетплейсы часто разбивают информацию: название и цена находятся в одной карточке, а город продавца – в отдельном блоке, привязанном к тому же iid. Мы используем найденный в ссылке параметр ?iid= как ключ, собираем все фрагменты страницы в один словарь и склеиваем их.

python
async def gather_full_data(page, items):
    # Скачиваем весь HTML один раз
    html = await page.content()
    for item in items:
        iid_match = re.search(r"iid=([a-zA-Z0-9]+)", item["url"])
        if not iid_match:
            continue
        iid = iid_match.group(1)
        # Ищем блок с тем же iid, где может быть город
        city = await page.evaluate(f"""() => {{
            const el = document.querySelector(`[data-iid="{iid}"] .city`);
            return el ? el.innerText : '';
        }}""")
        item["city"] = city
    return items

Получив список словарей {title, price, url, city} мы можем экспортировать их в CSV или сразу передать в downstream‑модель для дальнейшего анализа.

Универсальный промпт‑контейнер для любого сайта

Чтобы не писать отдельный скрипт под каждый ресурс, автор оформил «промпт‑контейнер», который принимает URL и набор текстовых маркеров, а затем автоматически генерирует код, похожий на показанный выше. Пример вызова через CLI:

bash
python visual_agent.py \
    --url "https://example-announcements.ru" \
    --price-markers "₽,руб.,$" \
    --id-regex "_\d{5,}" \
    --output results.csv

Внутри контейнера происходит:

  1. Запуск Edge в видимом режиме.
  2. Пауза 8 секунд.
  3. Сбор полного DOM.
  4. Поиск маркеров цены → подъем к контейнеру.
  5. Выделение ссылки, проверка ID.
  6. Сбор дополнительных полей по iid.
  7. Запись результата.

Таким образом, локальная LLM получает «задание», а готовый пайплайн работает с любой страницей, где цены и ID представлены в виде текста.

Подводные камни

Что попробовать дальше

  • Подключить локальную модель Qwen 7B к цепочке через langchain и автоматизировать генерацию запросов к page.evaluate.
  • Добавить поддержку нескольких валютных маркеров и динамическое определение языка страницы.
  • Интегрировать полученные CSV‑файлы в базу данных Elastic для быстрого поиска и аналитики.
  • Экспериментировать с другими «человеческими» маркерами, например, датой публикации или именем продавца, чтобы повысить точность фильтрации.

FAQ

Какой размер модели нужен для работы агента?

Для описанного подхода достаточно локальной модели Qwen 7B / 8B (≈7–8 млрд параметров). Модели меньшего размера могут не справиться с генерацией сложных скриптов, а большие (30 B+) требуют отдельного GPU‑кластера.

Можно ли использовать Chrome вместо Edge?

Теоретически да, но большинство антифрод‑систем проверяют navigator.userAgent и navigator.webdriver. Edge с параметром channel="msedge" уже установлен в системе и имеет более «человеческий» профиль, поэтому вероятность блокировки ниже. При использовании Chrome потребуется добавить пользовательские профили и отключить --disable-blink-features=AutomationControlled.

Как обойти ограничения в headless‑режиме без вывода окна?

Можно включить --disable-features=IsolateOrigins,site-per-process и подменить navigator.webdriver через page.add_init_script. Однако такие обходы часто обнаруживаются новыми сигнатурами WAF, поэтому рекомендуется оставаться в видимом режиме.

Что делать, если сайт меняет структуру DOM после каждой загрузки?

В этом случае следует полагаться исключительно на текстовые маркеры (цену, валюту, ID) и использовать относительные XPath‑выражения, которые ищут соседние узлы независимо от их вложенности.

Как экспортировать результаты в JSON вместо CSV?

В конце скрипта замените блок записи:

python
import json
with open("results.json", "w", encoding="utf-8") as f:
    json.dump(items, f, ensure_ascii=False, indent=2)

Это позволит легко передать данные в downstream‑модели или веб‑сервисы.

Источники