Задача выглядела тривиально: найти на популярном объявленном сайте карточки с редкой видеокартой. Стандартные запросы 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 секунд, чтобы все скрипты защиты успели выполниться.
# Установить Playwright с поддержкой Edge
pip install playwright==1.44.0
playwright install msedge# 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 объявления).
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= как ключ, собираем все фрагменты страницы в один словарь и склеиваем их.
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:
python visual_agent.py \
--url "https://example-announcements.ru" \
--price-markers "₽,руб.,$" \
--id-regex "_\d{5,}" \
--output results.csvВнутри контейнера происходит:
- Запуск Edge в видимом режиме.
- Пауза 8 секунд.
- Сбор полного DOM.
- Поиск маркеров цены → подъем к контейнеру.
- Выделение ссылки, проверка ID.
- Сбор дополнительных полей по
iid. - Запись результата.
Таким образом, локальная 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?
В конце скрипта замените блок записи:
import json
with open("results.json", "w", encoding="utf-8") as f:
json.dump(items, f, ensure_ascii=False, indent=2)Это позволит легко передать данные в downstream‑модели или веб‑сервисы.
