Эта статья была полезной?
Как парсить AVITO: практическое руководство
Технический специалист
Avito (avito.ru) — крупнейшая доска объявлений в России: телефоны, автомобили, недвижимость, одежда, услуги от частных продавцов и магазинов. Для мониторинга цен и анализа рынка всё нужное есть на открытых страницах: названия, цены и скидки, местоположение, состояние и характеристики товара, данные продавца.
Собрать эти данные сложнее, чем загрузить HTML и разобрать его CSS-селекторами:
- Avito защищён собственным файрволом. Если с одного адреса приходит слишком много запросов, сайт отвечает страницей «Доступ ограничен: проблема с IP». Обычный HTTP-клиент через неё не проходит:
requestsилиcurlполучают HTTP 429 даже с полным набором браузерных заголовков. - Файрвол может показать капчу. В наших тестах это всегда была GeeTest v4 — пазл со слайдером.
- Один URL — две разметки. Одна и та же страница выдачи приходит то только с обычной разметкой, то ещё и со встроенным JSON-состоянием, в котором больше полей.
- Очевидное поле цены содержит не ту цену. У товара со скидкой поле
priceв данных Avito содержит цену до скидки, а не ту, которую видит покупатель.
В этом руководстве разберём проект с открытым исходным кодом 2scraper/avito-scraper: как он открывает страницы, откуда берёт данные и что делает в каждом состоянии файрвола. Основное внимание уделим движку Playwright, который рекомендуют авторы репозитория.
Что мы создадим
Парсер с тремя режимами:
listing— страница категории или поиска с пагинацией через?p=N. На каждую карточку — одна строка.item— страницы отдельных объявлений: по списку URL или прямо из результата режимаlisting.seller— профиль продавца и все его объявления.
Строка выдачи содержит: sku (id объявления на Avito), title, price, currency, original_price и discount_pct (если сайт их указывает), region_slug, city, location, summary_params, published_at, seller_name, seller_type, seller_id, seller_rating, seller_reviews, promoted, badges, delivery, page и position.
Режим item добавляет description, params (все характеристики), images, category_path, address, lat/lng, views_total, views_today, seller_since, seller_reply_time и is_active.
Чего парсер не собирает: всё, что доступно только после входа в аккаунт (в том числе номера телефонов), и замаскированный IMEI, который в некоторых категориях указан среди характеристик. Частных продавцов Avito обезличивает сам, поэтому у них seller_name пустой, а seller_type равен private.
Каждый запуск сохраняет рядом с данными файл метаданных. По нему видно, получен ли результат полностью или частично либо запуск был заблокирован.
Требования
- Python 3.9+
- Playwright (рекомендуется). В репозитории есть ещё движки pyppeteer и Selenium. Устанавливайте в каждое виртуальное окружение только один движок: их зависимости конфликтуют.
beautifulsoup4иrequests
Из-за файрвола также понадобятся:
- Российский резидентный прокси с постоянным IP на всю сессию. Браузер и сервис решения капчи должны работать с одного IP — почему, объясним ниже.
- API-ключ RuCaptcha (
TWOCAPTCHA_KEY) для решения GeeTest на странице файрвола. - По желанию — адрес подключения к Browser API RuCaptcha (
AVITO_CDP_ENDPOINT): это удалённый браузер, к которому подключаются по протоколу Chrome DevTools (CDP). Именно этот вариант рекомендуют авторы репозитория.
В отличие от многих сайтов, с Avito не получится работать только бесплатными средствами. Обычные HTTP-запросы сайт отклоняет, а новый локальный браузер почти сразу получает капчу. Приёмы разбора данных из статьи работают при любом способе подключения. Однако чтобы получить сами страницы, нужны браузер, российский IP-адрес и в большинстве случаев решённая капча.
Настройка проекта
Клонируйте репозиторий, затем установите основные зависимости и движок Playwright:
bash
git clone https://github.com/2scraper/avito-scraper.git
cd avito-scraper
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
pip install -r requirements-playwright.txt
playwright install chromium
cp .env.example .env
Парсер читает из .env ровно четыре переменные:
| Переменная | Что это |
|---|---|
TWOCAPTCHA_KEY |
API-ключ RuCaptcha для решения GeeTest на странице файрвола |
AVITO_PROXY |
Российский резидентный прокси, http://user:pass@host:port |
AVITO_CDP_ENDPOINT |
Адрес подключения к Browser API по CDP (только для Playwright и pyppeteer) |
AVITO_URL |
Необязательный --url по умолчанию |
Храните учётные данные в .env, а не в командной строке: аргументы запуска видны любому процессу на машине через ps и попадают в историю shell и в логи CI.
Если вы используете Browser API, адрес подключения и прокси создаются вместе, чтобы удалённый браузер и сервис решения капчи работали с одного IP. В репозитории есть скрипт, который делает это одной командой и записывает оба значения в .env, не выводя их на экран:
bash
python3 tools/browser_profile_client.py accounts
python3 tools/browser_profile_client.py connection --account-id <id> --mint-session --write-env
Прокси-сессия действует ограниченное число минут (параметр -sessTime- в логине). Когда она истекает, адрес подключения на её основе перестаёт работать. Тогда достаточно повторить ту же команду.
python3 env_config.py показывает, какие переменные заданы, не выводя их значений.
Быстрый старт
bash
# Категория, три страницы, JSON и CSV
python3 playwright_scraper.py --mode listing \
--url "https://www.avito.ru/moskva/telefony/mobilnye_telefony/apple-ASgBAgICAkS0wA3OqzmwwQ2I_Dc" \
--pages 3 --out out/iphones
# Поиск
python3 playwright_scraper.py --mode listing --url "https://www.avito.ru/moskva?q=iphone+15" --pages 2 --out out/search
# Страницы объявлений из результата предыдущего запуска
python3 playwright_scraper.py --mode item --from out/iphones.json --max-products 20 --out out/items
# Профиль продавца и его объявления
python3 playwright_scraper.py --mode seller --url "https://www.avito.ru/brands/<seller>" --out out/seller
С параметром --verbose парсер для каждой страницы выводит, с какой проверкой он столкнулся и что помогло её пройти.
Каждый запуск создаёт <out>.json, <out>.csv или оба файла, а также два файла метаданных:
<out>.meta.jsonописывает данные, сохранённые рядом с ним.<out>.latest_attempt.meta.jsonсоздаётся при любом исходе: успехе, блокировке, ошибке подключения или сбое.
Второй файл важен для автоматизации. Запуск, который ничего не нашёл, не перезаписывает предыдущий удачный результат пустым []. Поэтому именно по файлу попытки планировщик узнаёт, что последний запуск не удался.
robots.txt Avito запрещает параметры сортировки и фильтров (?s=, ?f=, ?radius=, ?view=). Парсер отказывается работать с такими URL ещё до первого запроса. Сужайте выборку с помощью пути в URL, как это делают ссылки самого сайта: город → категория → подкатегория. Поиск ?q= и пагинация ?p= разрешены.
Как работает парсер
Репозиторий разделяет два вопроса: когда загружать страницу и что на ней написано.
| Файл | Роль |
|---|---|
playwright_scraper.py, puppeteer_scraper.py, selenium_scraper.py |
Драйверы движков: запустить браузер, открыть страницу, вернуть HTML |
browser_bridge.py |
Общий для всех движков цикл запуска и параметры командной строки |
page_flow.py |
Правила для каждого состояния страницы: ждать, решать капчу, менять IP или останавливаться |
product_parser.py |
Всё, что репозиторий знает о разметке Avito |
captcha_solver.py |
Запросы к API решения капчи |
proxy_pool.py |
Разбор прокси, сессии с постоянным IP, смена IP |
output_writer.py |
Модель строки, запись JSON/CSV, метаданные запуска |
Порядок обработки одной страницы выдачи:
- Собрать URL страницы (
?p=N, начиная со второй страницы). - Открыть страницу и подождать (не дольше заданного времени), пока она загрузится. Для выдачи «готова» значит, что карточек больше одной (
[data-marker="item"][data-item-id]): селектор может совпасть с одним посторонним элементом, похожим на карточку, ещё до того, как отрисуется сетка. - Определить состояние документа:
content,challenge,captcha,empty,blockedилиunknown. - Применить правило для этого состояния (см. следующий раздел).
- Разобрать карточки, убрать дубликаты по
sku(Avito повторяет некоторые продвигаемые карточки на разных страницах), перейти к следующей странице. - Остановиться на последней странице сайта, на странице без новых объявлений или при достижении
--max-products.
Правила хранятся в page_flow.py как данные, поэтому три движка не могут разойтись в поведении:
python
STATE_POLICY = {
"content": {"retry": False, "solve": False, "wait": False, "rotate": False, "blocked": False},
"challenge": {"retry": False, "solve": False, "wait": True, "rotate": True, "blocked": True},
"captcha": {"retry": False, "solve": True, "wait": True, "rotate": True, "blocked": True},
"empty": {"retry": False, "solve": False, "wait": False, "rotate": False, "blocked": False},
"blocked": {"retry": False, "solve": False, "wait": False, "rotate": True, "blocked": True},
"unknown": {"retry": True, "solve": False, "wait": False, "rotate": False, "blocked": False},
}
Пустая выдача (empty) считается правильным ответом, а не ошибкой. Состояние blocked означает «попробовать с другого адреса», а не «повторить ещё раз».
Почему браузер, а не requests? Файрвол сразу отклоняет обычные HTTP-клиенты, а одно из его состояний — страница proof-of-work (проверка, при которой браузер должен выполнить вычислительную задачу), и пройти её может только браузер. Сами данные содержатся в HTML, который отдаёт сервер, поэтому после прохождения файрвола остаётся обычный разбор HTML и JSON.
Извлечение данных
Выдача: две разметки одного URL
Один и тот же URL выдачи приходит в одном из двух видов:
- Обычная разметка. Сетка есть только в DOM: 50 карточек на страницу, у каждой — микроразметка schema.org (
itemprop="price",priceCurrency,name,url). - Разметка плюс встроенный JSON. Те же карточки и блок
<script type="mime/invalid" data-mfe-state>. В егоcatalog.itemsесть поля, которых нет в DOM: время публикации, старая цена товара со скидкой, тип продавца.
Парсер читает оба источника. Если JSON есть, данные берутся из него, а в каждой строке поле price_source указывает источник (state или dom):
python
def listing_state(html):
m = re.search(r"<script[^>]*data-mfe-state[^>]*>(.*?)</script>", html, re.S)
if not m:
return None
data = json.loads(m.group(1))
return data["loaderData"]["data"]["catalog"] # catalog["items"] — список объявлений
Из DOM данные берутся по атрибутам карточки и микроразметке:
python
for card in soup.select('[data-marker="item"][data-item-id]'):
sku = card["data-item-id"]
title = card.select_one('[data-marker="item-title"]') or card.select_one('[itemprop="name"]')
price = card.select_one('[itemprop="price"]') # атрибут `content`
location = card.select_one('[data-marker="item-location"]')
params = card.select_one('[data-marker="item-specific-params"]')
rating = card.select_one('[data-marker="seller-rating/score"]')
Карточка, которой нет в JSON, всё равно берётся из DOM, так что неполный JSON-блок не приводит к потере строк.
Ловушка с ценой
У товара со скидкой поле верхнего уровня priceDetailed.value в JSON выдачи (и item.price на странице объявления) содержит старую цену. Цена, которую видит покупатель, находится в компоненте price блока PriceStep, а старая цена отдельно указана в компоненте discount:
python
for step in item["iva"]["PriceStep"]:
component = step["componentData"]["component"]
if component == "price":
price = step["payload"]["priceDetailed"]["value"] # показанная цена
elif component == "discount":
original_price = step["payload"]["old_string"] # например, "69 989 ₽"
discount_pct = step["payload"]["percent"]
В DOM микроразметка itemprop="price" уже содержит показанную цену. Парсер никогда не вычисляет original_price: поле заполняется только значением, которое указал сам сайт, поэтому в строках из обычной разметки оно остаётся пустым.
Карточки, которые не являются результатами
На странице выдачи есть два вида карточек с той же разметкой, что и у настоящих результатов:
- Поиск без совпадений всё равно возвращает около пятидесяти карточек под заголовком «Похожие объявления рядом и в других городах». Парсер обрезает страницу по этому заголовку. Такой поиск завершается пустым результатом (код выхода 4), а не результатом, который выглядит полным, но относится к другому запросу.
- Карусель рекомендаций «Подобрали для вас» может находиться прямо внутри сетки результатов, и её карточки имеют ту же разметку
data-marker="item". Парсер отбрасывает все карточки внутри[data-marker="itemsCarousel"]. В JSON-состоянии эти карточки лежат отдельным списком, так что фильтр нужен только при разборе DOM.
python
cards = [c for c in cards if c.find_parent(attrs={"data-marker": "itemsCarousel"}) is None]
Страницы объявлений
Страница объявления хранит данные в объекте начального состояния:
javascript
window.__staticRouterHydrationData = JSON.parse("…")
Это строковый литерал JavaScript, внутри которого находится JSON. Поэтому парсер декодирует его дважды: сначала литерал, затем JSON внутри него. После этого он берёт buyerItem из loaderData:
python
k = html.find("__staticRouterHydrationData")
q = html.find('JSON.parse("', k)
literal, _ = json.JSONDecoder().raw_decode(html, q + len("JSON.parse("))
data = json.loads(literal)
buyer_item = next(v["buyerItem"] for v in data["loaderData"].values()
if isinstance(v, dict) and "buyerItem" in v)
Цену, которую видит покупатель, на странице объявления содержит item.formattedPrice.value, а не item.price.
Профили продавцов
Страница продавца (/brands/<slug>) разбирается по DOM: имя, рейтинг, число отзывов, срок на сайте («На Авито с …»), подписчики и счётчик активных объявлений. Сам профиль показывает только первые 15 объявлений. Чтобы получить остальные, парсер открывает ленту продавца, нажимает кнопку «Показать все» и прокручивает страницу; каждая прокрутка подгружает ещё 15. Если собрано меньше объявлений, чем показывает счётчик профиля, запуск помечается как частичный, а не полный.
Работа с антибот-защитой Avito
Защита Avito — собственный файрвол со страницей «Доступ ограничен: проблема с IP». В тексте этой страницы сказано, почему она появляется: с этого адреса идёт очень много запросов. То есть файрвол принимает решение по частоте запросов с конкретного IP.
Репозиторий различает три состояния этой страницы:
| Что вы видите | HTTP | Что помогает |
|---|---|---|
Страница proof-of-work, которая вызывает /web/3/firewallPow/… и сама перезагружается |
439 | Браузер проходит её сам, обычно за несколько секунд. Парсер ждёт до 25 с и ничего не нажимает и не перезагружает страницу: иначе проверка начнётся заново |
| Страница с капчей: «Продолжить» → пазл GeeTest v4 со слайдером | 429 | Решение, отправленное через собственную форму страницы |
| «подождите немного и обновите страницу» — капчи на странице нет | 429 | Другой IP-адрес. Эту страницу сайт показывает после отклонённого решения |
Третье состояние — не капча, и отправлять его на решение бессмысленно. Парсер помечает его как blocked и, если может, переходит на новый IP-адрес.
Признаки проверяются в порядке убывания надёжности:
python
WALL_TITLE = "Доступ ограничен"
WAIT_TEXT = "подождите немного и обновите страницу"
def page_state(html):
if WALL_TITLE not in title_of(html):
return "content" if has_listing_cards(html) else ...
if re.search(r"<form\b[^>]*\bjs-firewall-form\b", html):
return "captcha" # на странице есть форма капчи файрвола
if WAIT_TEXT in html:
return "blocked" # решение отклонено: нужен новый IP
return "challenge" # proof-of-work: браузер пройдёт его сам
Стоит обратить внимание на две детали:
- Ищите элемент формы, а не подстроку. Скрипт страницы файрвола упоминает
.js-firewall-formвquerySelectorнезависимо от того, есть ли форма на странице. - Не ищите в HTML слово «captcha». Browser API добавляет свои скрипты
…/captcha/…на каждую загружаемую страницу, и такая проверка считала бы заблокированной любую из них.
Поскольку файрвол реагирует на частоту запросов с одного адреса, парсер не разрешает --concurrency больше 1 и по умолчанию выдерживает паузу 2 с между страницами (--delay).
Использование прокси
Поддержка прокси уже есть в репозитории. AVITO_PROXY — это один URL прокси:
text
http://user:pass@host:port
Для шлюза резидентных прокси RuCaptcha регион и сессия с постоянным IP задаются прямо в логине:
text
http://{login}-zone-custom-region-RU-session-{id}-sessTime-120:{password}@<хост-шлюза>:<порт>
Как используется прокси, зависит от того, где работает браузер:
-
Локальный браузер: через прокси идёт весь трафик браузера. Учётные данные Playwright получает в собственных полях
proxy, а не в аргументе--proxy-server=, поэтому они не попадают в командную строку браузера:pythonlaunch["proxy"] = {"server": "http://host:port", "username": user, "password": password} browser = pw.chromium.launch(**launch)Без
-session-в логине шлюз менял бы IP при каждом запросе, поэтому парсер автоматически закрепляет одну сессию на весь запуск. -
Browser API: удалённый браузер работает со своим IP-адресом.
AVITO_PROXYв этом случае нужен только для решения капчи и должен совпадать с сессией, для которой создан адрес подключения.
Если страница в локальном браузере так и остаётся заблокированной, парсер запускает новый браузер с новым IP-адресом (--proxy-block-retries, по умолчанию 1). Парсер никогда не переносит активную сессию на другой IP: если cookies, выданные файрволом одному адресу, начинают приходить с другого, это выглядит подозрительнее, чем любой из адресов по отдельности. Новые IP-адреса берутся из списка прокси --proxy-file или, для шлюза RuCaptcha, из новых сессий. --proxy-rotate per-page берёт новый IP-адрес для каждой страницы, но ценой перезапуска браузера.
Работа с капчей (GeeTest v4)
В наших тестах файрвол всегда показывал GeeTest v4 (слайдер). Ниже описано, как её решает парсер.
1. Обнаружение
Страница файрвола содержит блоки для трёх типов капчи, и скрипт страницы показывает тот, который выбрал сервер. wall_variant() возвращает первый блок без display: none. Для GeeTest это #geetest_captcha, при этом на странице также должен быть идентификатор капчи.
2. Параметры
Для GeeTest v4 нужен один параметр — captcha_id. Он указан во встроенном скрипте страницы файрвола:
python
captcha_id = re.search(r"captchaId\s*=\s*['\"]([0-9a-f]{32})['\"]", html).group(1)
3. Отправка на решение — с IP браузера
От этой детали зависит результат. Если капча решена не с IP браузера, Avito отклоняет токен и показывает новую капчу. Поэтому задача отправляется как GeeTestTask с параметрами прокси — той же сессии с постоянным IP, через которую работает браузер:
python
task = {
"type": "GeeTestTask",
"websiteURL": page_url,
"version": 4,
"initParameters": {"captcha_id": captcha_id},
"proxyType": "http",
"proxyAddress": proxy_host,
"proxyPort": proxy_port,
"proxyLogin": proxy_login,
"proxyPassword": proxy_password,
}
task_id = requests.post("https://api.rucaptcha.com/createTask",
json={"clientKey": API_KEY, "task": task}).json()["taskId"]
while True:
time.sleep(5)
result = requests.post("https://api.rucaptcha.com/getTaskResult",
json={"clientKey": API_KEY, "taskId": task_id}).json()
if result.get("status") == "ready":
solution = result["solution"]
# {captcha_id, lot_number, pass_token, gen_time, captcha_output}
break
По умолчанию репозиторий не создаёт задачу без прокси (solve_geetest_v4 в этом случае возвращает ошибку): лучше сообщить, что страница заблокирована, чем платить за токен, который сайт отклонит. Результат парсер ждёт до 240 с — дольше, чем сервис может потратить, прежде чем признать сложный пазл нерешаемым. При меньшем таймауте парсер перестал бы ждать раньше, чем стала бы известна причина неудачи.
4. Передача решения через форму страницы
Вместо прямого запроса к адресу проверки Avito парсер записывает решение туда, куда его записал бы код самой страницы, и вызывает собственный обработчик отправки формы. Обработчик добавляет нужные сайту заголовки, отправляет запрос на /web/3/firewallCaptcha/verify и перезагружает страницу — так же, как при решении капчи человеком:
python
value = json.dumps({"captcha_id": captcha_id, "lot_number": solution["lot_number"],
"pass_token": solution["pass_token"], "gen_time": solution["gen_time"],
"captcha_output": solution["captcha_output"]})
page.evaluate("""value => {
const inp = document.querySelector('input[name=captcha-response]');
const form = document.querySelector('.js-firewall-form');
inp.value = value;
form.dispatchEvent(new Event('submit'));
}""", value)
Затем парсер проверяет, что пришло в ответ:
- Содержимое страницы (карточки, объявление, профиль или действительно пустая выдача): решение принято.
- Страница «подождите…»: решение отклонено, парсер переходит на новый IP-адрес.
- Новая капча: на этот раз решение не принято. Страница остаётся заблокированной, и запуск сообщает об этом.
Исчезновение страницы файрвола ещё не означает успеха: во время перезагрузки страница проходит через промежуточные документы. Пока идёт навигация, page.content() в Playwright может даже выбросить исключение «page is navigating», поэтому во время ожидания читайте HTML внутри try.
Правильное решение не гарантирует, что Avito примет сессию. В наших тестах решения, полученные через IP-адрес Browser API, обычно принимались. Решения для локального Chromium отклонялись чаще, даже когда капча решалась через собственный IP браузера: сайт отвечал новой капчей или страницей «подождите…», и выдача приходила только после перехода на новый IP-адрес и повторного решения. Предусмотрите в логике смену IP и обрабатывайте результат каждой страницы как обычные данные, а не как исключение.
Запасные варианты: картинка и hCaptcha
В коде страницы файрвола предусмотрены ещё два типа капчи: собственная картинка Avito и hCaptcha. В наших тестах ни одна из них не появлялась, но парсер обрабатывает обе на случай, если сервер начнёт их выдавать:
- Капча-картинка (
#inner-captcha): изображение приходит какdata:URL и решается задачейImageToTextTask. Ответ записывается в#form-input, затем форма отправляется так же. - hCaptcha (
#h-captcha): ключ сайта (sitekey) берётся из атрибутаdata-sitekeyна странице, капча решается задачейHCaptchaTaskчерез IP-адрес браузера. Токен записывается в#h-captcha-response.
Поле transport_facts.solves[].variant в метаданных запуска показывает, какой тип встретился (geetest, image или hcaptcha). Если там окажется один из двух последних вариантов, значит, сайт изменил поведение и это стоит проверить.
Использование Browser API RuCaptcha (CDP)
Вместо запуска локального Chromium движки Playwright и pyppeteer могут подключаться к удалённому браузеру по CDP:
python
browser = pw.chromium.connect_over_cdp(cdp_endpoint)
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
Если в .env задан AVITO_CDP_ENDPOINT, парсер подключается так автоматически. Команды из раздела «Быстрый старт» не меняются.
Для Avito авторы репозитория рекомендуют именно этот вариант, и на это есть практические причины:
- Профиль сохраняется между запусками. После того как профиль один раз прошёл файрвол, сохранённые cookies позволяют проходить его и при следующих запросах: в наших запусках страницы выдачи, объявлений и продавцов подряд приходили без повторной проверки. Новый локальный браузер каждый раз начинает с нуля.
- Страницу proof-of-work удалённый браузер проходит сам.
- У удалённого браузера собственный отпечаток, поэтому парсер не задаёт user agent.
При подключении парсер также включает встроенное автоматическое решение капчи удалённого браузера (Captcha.setAutoSolve) и даёт ему 25 с. На GeeTest Avito автоматическое решение в наших тестах не срабатывало, поэтому парсер переходит к описанной выше задаче GeeTestTask. Именно поэтому AVITO_PROXY должен совпадать с сессией, для которой создан адрес подключения.
Код учитывает несколько особенностей Browser API:
- Одно активное подключение на профиль. Второе подключение получает ответ
500 profile_locked, пока первое не завершится. После корректного завершения профиль освобождается быстро, после принудительного прерывания процесса — заметно дольше. Драйвер всегда закрывает подключение в конце работы. - Адрес подключения перестаёт работать вместе с прокси-сессией. При попытке подключения сервер возвращает
500 proxy_error; создайте адрес подключения заново командойbrowser_profile_client.py connection --mint-session --write-env. - Selenium не поддерживает Browser API. В Chromedriver некуда передать учётные данные для подключения, поэтому движок Selenium работает только с локальным Chrome.
Масштабирование парсера
Репозиторий даёт базовые механизмы, а управление запусками в рабочем проекте придётся выстроить самостоятельно:
-
Для параллельной работы нужно больше IP-адресов, а не больше потоков.
--concurrencyбольше 1 не разрешён: параллельные запросы с одного адреса заставляют файрвол появляться чаще. Профиль Browser API к тому же допускает только одно подключение. Чтобы работать быстрее, запускайте отдельные процессы, каждый со своим IP-адресом или профилем. -
Сохраняйте паузы.
--delayпо умолчанию — 2 с между страницами. В репозитории не указана «безопасная» частота запросов для Avito, поэтому измеряйте на собственном трафике, как часто появляется файрвол. -
Переиспользуйте сессии. Для регулярного мониторинга выгоднее использовать профиль Browser API, который уже прошёл файрвол, чем создавать новую сессию для каждого запуска.
-
Отличайте таймаут от отказа. При таймауте навигации запрос повторяется с того же IP (
--retries,--retry-delay). При нерабочем прокси (ERR_CONNECTION_RESET,ERR_PROXY_…) или заблокированной странице берётся новый IP-адрес (--proxy-block-retries). -
Используйте коды выхода:
Код Значение 0Полный результат 1Сбой — ошибка в коде, а не на сайте или в подключении 2Неверный запуск — не тот сайт или режим либо URL запрещён в robots.txt 3Заблокировано — страница файрвола не исчезла после всех попыток 4Нет результатов, в том числе поиск без совпадений 5Ошибка подключения — подключиться к сайту не удалось (таймаут, нерабочий прокси, profile_locked,proxy_error)6Частичный результат — часть страниц получена, затем работа остановлена Коды
3и5требуют разных действий:5— это не блокировка файрволом: нужно создать адрес подключения заново или повторить запуск. -
Проверяйте полноту. Поле
statusв<out>.meta.jsonпринимает значенияcomplete,partialилиfailed. Вtransport_factsперечислены все встреченные страницы файрвола, шаг, который помог их пройти, каждое решение капчи с указанием времени, стоимости иtask_id, а также каждая смена IP. -
Сравнивайте запуски корректно.
diff_runs.py --old A.json --new B.jsonпоказывает добавленные, удалённые и изменённые объявления поsku. Неполные запуски, а также запуски по другой выдаче или с другим числом страниц он не сравнивает: иначе разница выглядела бы как «исчезновение» объявлений. Если одна и та же карточка в двух запусках пришла в разных видах разметки, он не считает это изменением цены.
Устранение проблем
| Проблема | Возможная причина | Решение |
|---|---|---|
HTTP 429 у requests/curl |
Файрвол Avito отклоняет обычные HTTP-клиенты | Используйте браузерный движок: через обычный HTTP данные не получить |
| Код 3, на странице кнопка «Продолжить» и пазл со слайдером | Капча GeeTest v4 | Задайте TWOCAPTCHA_KEY и AVITO_PROXY; парсер решит её через форму страницы |
В логе решения "error": "no proxy for the solver" |
Нет прокси для решения капчи | Задайте AVITO_PROXY. При работе через CDP это должна быть сессия, для которой создан адрес подключения |
| Капча решена, но страница файрвола вернулась или появилось «подождите немного и обновите страницу» | Avito отклонил решение для этой сессии/IP | Укажите --proxy-block-retries, чтобы парсер перешёл на новый IP-адрес; при работе через CDP создайте адрес подключения и прокси заново |
| Прокси и адрес подключения не совпадают | Одно из значений создано заново, другое — нет | tools/browser_profile_client.py connection --mint-session --write-env |
ERROR_CAPTCHA_UNSOLVABLE |
Сервис не смог решить этот пазл | Запустите снова: с нового IP-адреса придёт новый пазл |
Код 5, 500 … proxy_error при подключении |
Истекла прокси-сессия, для которой создан адрес подключения | Создайте адрес подключения заново |
Код 5, 500 … profile_locked |
Профиль ещё занят предыдущим подключением | Дождитесь освобождения; не подключайтесь к нему повторно в это время |
| Код 4 при поиске | Совпадений нет — Avito показал «Похожие объявления…» | Это ожидаемое поведение: ответ правильный |
Код 2, robots.txt says Disallow |
В URL есть ?s= / ?f= / ?radius= / ?view= |
Сужайте выборку с помощью пути в URL: город → категория → подкатегория |
Пустые original_price, published_at, delivery |
Строка получена из обычной разметки (price_source = dom) |
Это ожидаемо; для таких объявлений запустите --mode item |
city часто пустой |
В обычной разметке город есть только в alt-тексте некоторых фото | Группируйте по region_slug: он есть в каждой строке |
Пустой seller_name |
Частный продавец: Avito скрывает имена таких продавцов | Это ожидаемо |
Пример результата
Строка выдачи (--mode listing) получена из обычной разметки, поэтому поля, которые есть только в JSON, равны null; имя продавца и его идентификатор в адресе заменены условными значениями:
json
{
"source": "avito.ru",
"scraped_at": "2026-10-06T11:43:07.634024+00:00",
"url": "https://www.avito.ru/moskva/telefony/iphone_17_256_gb_sim_esim_8212942358",
"sku": "8212942358",
"title": "iPhone 17, 256 ГБ, SIM + eSIM",
"image_url": "https://…img.avito.st/image/1/…",
"price": 64935.0,
"currency": "RUB",
"category": null,
"price_source": "dom",
"original_price": null,
"discount_pct": null,
"region_slug": "moskva",
"city": "Москва",
"location": "Багратионовская, 6–10 мин.",
"summary_params": "Новый",
"published_at": null,
"published_text": null,
"seller_name": "Продавец 1",
"seller_type": null,
"seller_id": "seller1",
"seller_url": "https://www.avito.ru/brands/seller1",
"seller_rating": 5.0,
"seller_reviews": 624,
"promoted": true,
"badges": ["Реквизиты проверены", "яПомогаю"],
"delivery": null,
"page": 1,
"position": 1
}
Строка объявления (--mode item, в сокращении) дополнительно содержит, например, все характеристики:
json
{
"sku": "7924452716",
"price": 39990.0,
"original_price": null,
"params": {
"Состояние": "Новое",
"Производитель": "Apple",
"Модель": "iPhone 15",
"Встроенная память": "128 ГБ",
"SIM-карты": "SIM + eSIM",
"Цвет": "Розовый"
},
"views_total": 4503,
"seller_type": "company",
"is_active": true
}
Метаданные запуска (<out>.meta.json, в сокращении):
json
{
"source": "avito.ru",
"mode": "listing",
"status": "complete",
"stop_reason": "completed",
"pages_requested": 3,
"pages_completed": 3,
"pages_failed": [],
"products": 147,
"transport_facts": {
"over_cdp": true,
"walls": [],
"solves": [],
"rotations": 0
}
}
Полный компактный пример
Ниже — основная логика репозитория, собранная в одном файле. Скрипт открывает выдачу в Playwright (локально через прокси или через Browser API по CDP), определяет состояние файрвола, решает капчу GeeTest v4 через IP-адрес браузера, если файрвол её показал, и разбирает карточки с учётом ловушки с ценой, карусели рекомендаций и блока «Похожие объявления». Пагинации, смены IP, обработки страниц объявлений и продавцов, а также файлов метаданных здесь нет: для этого используйте репозиторий.
python
"""Минимальный парсер выдачи avito.ru — ядро 2scraper/avito-scraper в одном файле.
pip install playwright beautifulsoup4 requests && playwright install chromium
export AVITO_PROXY='http://user:pass@host:port' # российский резидентный, с постоянным IP
export TWOCAPTCHA_KEY='...' # нужен, только если Avito покажет GeeTest
python3 avito_minimal.py "https://www.avito.ru/moskva?q=iphone+15"
Чтобы работать через удалённый браузер вместо локального Chromium, задайте AVITO_CDP_ENDPOINT.
"""
import json
import os
import re
import sys
import time
from urllib.parse import urlparse
import requests
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
WALL_TITLE = "Доступ ограничен"
WAIT_WALL_TEXT = "подождите немного и обновите страницу"
CARD = '[data-marker="item"][data-item-id]'
# ------------------------------------------------------- состояние страницы
def page_state(html: str) -> str:
title = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
if not title or WALL_TITLE not in title.group(1):
return "content" if 'data-marker="item"' in html else "unknown"
if re.search(r"<form\b[^>]*\bjs-firewall-form\b", html):
return "captcha" # GeeTest v4 за кнопкой «Продолжить»
if WAIT_WALL_TEXT in html:
return "blocked" # приходит после отклонённого решения: нужен другой IP
return "challenge" # proof-of-work, браузер проходит его сам
# ---------------------------------------------------------- решение GeeTest
def solve_geetest(api_key: str, captcha_id: str, page_url: str, proxy: str) -> dict:
p = urlparse(proxy)
task = {"type": "GeeTestTask", "websiteURL": page_url, "version": 4,
"initParameters": {"captcha_id": captcha_id},
# Решать нужно с IP браузера, иначе Avito отклонит токен.
"proxyType": p.scheme, "proxyAddress": p.hostname, "proxyPort": p.port,
"proxyLogin": p.username, "proxyPassword": p.password}
r = requests.post("https://api.rucaptcha.com/createTask",
json={"clientKey": api_key, "task": task}, timeout=30).json()
if r.get("errorId"):
raise RuntimeError(r.get("errorCode"))
for _ in range(48): # до 240 с
time.sleep(5)
res = requests.post("https://api.rucaptcha.com/getTaskResult",
json={"clientKey": api_key, "taskId": r["taskId"]}, timeout=30).json()
if res.get("errorId"):
raise RuntimeError(res.get("errorCode"))
if res.get("status") == "ready":
return res["solution"]
raise TimeoutError("нет решения за 240 с")
SUBMIT_JS = """value => {
const inp = document.querySelector('input[name=captcha-response]');
const form = document.querySelector('.js-firewall-form');
if (!inp || !form) return false;
inp.value = value;
form.dispatchEvent(new Event('submit')); // обработчик страницы сам отправит /verify
return true;
}"""
def content(page) -> str:
try:
return page.content()
except Exception: # страница в процессе перезагрузки
return ""
def pass_wall(page, url: str, proxy: str) -> str:
html = content(page)
for _ in range(25): # proof-of-work: просто ждём
if page_state(html) != "challenge":
break
page.wait_for_timeout(1000)
html = content(page)
if page_state(html) != "captcha":
return html
key = os.environ.get("TWOCAPTCHA_KEY")
captcha_id = re.search(r"captchaId\s*=\s*['\"]([0-9a-f]{32})['\"]", html)
if not (key and proxy and captcha_id):
return html
sol = solve_geetest(key, captcha_id.group(1), url, proxy)
value = json.dumps({k: sol.get(k) for k in
("lot_number", "pass_token", "gen_time", "captcha_output")}
| {"captcha_id": captcha_id.group(1)})
page.evaluate(SUBMIT_JS, value)
for _ in range(20): # ждём перезагрузки
page.wait_for_timeout(1500)
html = content(page) or html
if page_state(html) in ("content", "blocked"):
break
return html
# ------------------------------------------------------------------ парсинг
def listing_state(html: str) -> dict:
m = re.search(r"<script[^>]*data-mfe-state[^>]*>(.*?)</script>", html, re.S)
if not m:
return {}
try:
catalog = json.loads(m.group(1))["loaderData"]["data"]["catalog"]
except (ValueError, KeyError, TypeError):
return {}
return {str(i["id"]): i for i in catalog.get("items", []) if i.get("type", "item") == "item"}
def prices(item: dict):
"""(показанная цена, старая цена). Поле `priceDetailed` верхнего уровня — цена ДО скидки;
цена, которую видит покупатель, лежит в компоненте "price" блока `PriceStep`."""
shown, old = (item.get("priceDetailed") or {}).get("value"), None
for step in (item.get("iva") or {}).get("PriceStep") or []:
component = (step.get("componentData") or {}).get("component")
if component == "price":
shown = step["payload"]["priceDetailed"]["value"]
elif component == "discount":
digits = re.sub(r"\D", "", step["payload"].get("old_string") or "") # "69 989 ₽"
old = float(digits) if digits else None
return shown, old
def parse_listing(html: str) -> list:
soup = BeautifulSoup(html, "html.parser")
state = listing_state(html)
# Поиск без совпадений всё равно отдаёт карточки — под заголовком «Похожие объявления…».
cut = [i for i in (html.find("extraTitle-"), html.find("Похожие объявления")) if i >= 0]
results = set(re.findall(r'data-item-id="(\d+)"', html[:min(cut)])) if cut else None
rows, seen = [], set()
for card in soup.select(CARD):
if results is not None and card["data-item-id"] not in results:
continue # «похожие» объявления, а не результаты
if card.find_parent(attrs={"data-marker": "itemsCarousel"}):
continue # «Подобрали для вас» — не результаты
sku = card["data-item-id"]
if sku in seen:
continue
seen.add(sku)
if sku in state:
item = state[sku]
price, old = prices(item)
rows.append({"sku": sku, "title": item.get("title"),
"price": price, "original_price": old, "price_source": "state",
"url": "https://www.avito.ru" + item.get("urlPath", "").split("?")[0]})
else:
title = card.select_one('[itemprop="name"]')
price = card.select_one('[itemprop="price"]')
link = card.select_one('a[itemprop="url"], a[data-marker="item-title"]')
rows.append({"sku": sku,
"title": title.get_text(" ", strip=True) if title else None,
"price": float(price["content"]) if price and price.get("content") else None,
"original_price": None, "price_source": "dom",
"url": "https://www.avito.ru" + link["href"].split("?")[0] if link else None})
return rows
# ---------------------------------------------------------------------- main
def main(url: str) -> int:
proxy = os.environ.get("AVITO_PROXY")
cdp = os.environ.get("AVITO_CDP_ENDPOINT")
with sync_playwright() as pw:
if cdp:
browser = pw.chromium.connect_over_cdp(cdp)
context = browser.contexts[0] if browser.contexts else browser.new_context()
else:
launch = {"headless": True}
if proxy:
p = urlparse(proxy)
launch["proxy"] = {"server": f"{p.scheme}://{p.hostname}:{p.port}",
"username": p.username, "password": p.password}
browser = pw.chromium.launch(**launch)
context = browser.new_context(locale="ru-RU")
page = context.pages[0] if context.pages else context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
try:
page.wait_for_function(
"() => document.title.includes('Доступ ограничен') || "
"document.querySelectorAll('[data-marker=\"item\"][data-item-id]').length > 1",
timeout=20_000)
page.wait_for_load_state("load", timeout=20_000) # выдача продолжает догружаться после первых карточек
except Exception:
pass
html = pass_wall(page, url, proxy)
state = page_state(html)
browser.close()
if state != "content":
print(f"заблокировано: состояние страницы {state!r}", file=sys.stderr)
return 3
rows = parse_listing(html)
print(json.dumps(rows[:5], ensure_ascii=False, indent=2))
print(f"строк: {len(rows)}", file=sys.stderr)
return 0 if rows else 4
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else
"https://www.avito.ru/moskva?q=iphone+15"))
Если страница всё ещё заблокирована, пример завершается с кодом 3. В рабочем проекте в этот момент нужно запустить новый браузер с другим IP-адресом — это и делает функция fetch_with_rotation в репозитории.
Тестирование и ограничения
В репозитории есть набор офлайн-тестов, которым не нужны ни сеть, ни браузер:
bash
python3 smoke_test.py
python3 -m pytest -q
Тесты проверяют часть логики, не зависящую от сети, на фрагментах реальных страниц: распознавание всех трёх состояний файрвола, обе разметки выдачи, ловушку с ценой, разбор объявлений и продавцов, сборку задач для решения капчи, работу с прокси и запись результатов и метаданных.
Поведение на реальном сайте проверяется отдельно. На нём подтверждено:
- Работа Playwright через Browser API в режимах выдачи, объявлений и продавцов.
- Решение GeeTest v4 задачей
GeeTestTaskчерез IP-адрес браузера: сайт принимает его, но не каждый раз. - Все три состояния файрвола: proof-of-work, GeeTest и страница «подождите…» после отклонённого решения.
- Полная загрузка ленты продавца — до значения счётчика объявлений в профиле.
Ограничения, которые стоит учитывать:
- Локальный Chromium получает капчу GeeTest почти в каждой новой сессии, и решения для него принимаются реже, чем при работе через Browser API, поэтому локальному запуску часто нужны смена IP и повторное решение.
- Обработка капчи-картинки и hCaptcha написана по коду страницы файрвола, но в наших тестах эти капчи не появлялись.
- На реальном сайте проверялся только движок Playwright; pyppeteer и Selenium покрыты офлайн-тестами.
- Пороги срабатывания файрвола нигде не опубликованы, и в репозитории они не указаны.
Заключение
Парсинг Avito сводится к двум вещам: брать данные там, где их на самом деле хранит страница, и считать файрвол обычной частью рабочего процесса.
Что касается данных: карточки выдачи берутся из встроенного JSON data-mfe-state, если он есть на странице, иначе — из микроразметки DOM. Данные страниц объявлений — из __staticRouterHydrationData. Всегда берите показанную цену, а не поле price верхнего уровня. Карусели рекомендаций и блоки «Похожие объявления» выглядят как результаты, но ими не являются.
Что касается доступа: через обычный HTTP данные Avito не получить. Нужен браузер с российским IP-адресом. Страницу proof-of-work браузер проходит сам, решение GeeTest v4 нужно отправлять через форму страницы, и оно засчитывается, только если получено с IP самого браузера. Страница «подождите…» — вовсе не капча, а сигнал сменить IP-адрес.
Для разовой выгрузки может хватить локального запуска Playwright с резидентным прокси и ключом RuCaptcha, но тогда в каждой новой сессии придётся решать капчу. Для регулярного мониторинга лучше подходит постоянный профиль Browser API, а метаданные запусков и diff_runs.py не позволят принять временную блокировку за исчезновение объявлений.