Парсинг Авито на Python: Playwright, requests, мобильное API — разбор с кодом


Полгода назад мне поручили собрать цены на аренду квартир в Москве для аналитического отчёта: 3000+ объявлений с ценой, адресом, площадью и телефоном. Первый скрипт на requests упал через 5 запросов — Авито вернул капчу. Selenium продержался 40 минут, потом — блокировка IP. Рабочее решение собралось только после мобильных прокси, Playwright со stealth-плагином и случайных задержек. Авито это крупнейший классифайд в России с миллионами активных объявлений, и он активно защищается от автоматизированного сбора данных .
В этой статье разбираю три подхода к парсингу Авито: через Playwright (автоматизация браузера), через HTTP-запросы и через реверс-инжиниринг мобильного API. Для каждого — рабочий код, ограничения и методы обхода блокировок.
Какие данные можно собрать
Карточка объявления на Авито содержит набор полей, полезных для аналитики, лидогенерации и мониторинга цен.
- Название объявления и категория
- Цена и валюта
- Описание товара или услуги
- Адрес, район, координаты
- Телефон и имя продавца
- Дата публикации и дата обновления
- Фотографии
- Характеристики (площадь, марка, пробег — зависит от категории)
- Рейтинг и отзывы о продавце
Почему Авито сложно парсить
Авито использует многоуровневую систему защиты от ботов . В отличие от простых сайтов, здесь недостаточно отправить GET-запрос и разобрать HTML.
Уровни защиты
Контент загружается через JavaScript (React/Vue), поэтому requests + BeautifulSoup получают пустой HTML-каркас без данных . Нужен инструмент, который исполняет JS: Playwright или Selenium.
Способ 1: Playwright (рекомендуемый)
Playwright это фреймворк от Microsoft для автоматизации браузера. Он быстрее Selenium, имеет встроенное автоматическое ожидание элементов и поддерживает перехват сетевых запросов .
pip install playwright playwright-stealth pandas
playwright install chromiumПлагин playwright-stealth маскирует признаки автоматизации: скрывает флаг navigator.webdriver, подменяет WebGL-отпечатки и другие маркеры .
Этап 1: сбор ссылок со страницы категории
import asyncio
from playwright.async_api import async_playwright
from playwright_stealth import stealth_async
import random
async def get_ad_links(category_url: str, max_pages: int = 5) -> list[str]:
all_links = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080},
locale='ru-RU',
)
page = await context.new_page()
await stealth_async(page)
for i in range(1, max_pages + 1):
page_url = f'{category_url}?p={i}'
print(f'Страница {i}: {page_url}')
await page.goto(page_url, wait_until='domcontentloaded')
try:
await page.wait_for_selector(
'[data-marker='catalog-serp']', timeout=15000
)
except Exception:
print('Контейнер не загрузился, возможно капча')
break
# Эмуляция скроллинга
for _ in range(3):
await page.mouse.wheel(0, random.randint(300, 700))
await page.wait_for_timeout(random.randint(500, 1500))
# Извлечение ссылок
items = page.locator('[data-marker='item-title']')
count = await items.count()
for j in range(count):
href = await items.nth(j).get_attribute('href')
if href:
all_links.append(f'https://www.avito.ru{href}')
print(f' Найдено: {count} объявлений')
await page.wait_for_timeout(random.randint(3000, 6000))
await browser.close()
return list(set(all_links))
# Запуск
links = asyncio.run(get_ad_links(
'https://www.avito.ru/moskva/kvartiry/prodam-ASgBAgICAUSSA8YQ',
max_pages=3
))
print(f'Собрано {len(links)} ссылок')Селектор [data-marker='catalog-serp'] указывает на контейнер со списком объявлений. Атрибуты data-marker более стабильны, чем CSS-классы, потому что Авито использует их для внутренней разметки. Случайные задержки и эмуляция скроллинга снижают вероятность детекции.
Этап 2: парсинг детальной карточки
async def scrape_ad(page, url: str) -> dict:
data = {'url': url}
try:
await page.goto(url, wait_until='domcontentloaded')
await page.wait_for_timeout(random.randint(2000, 4000))
# Название
title = page.locator('[data-marker='item-view/title-info']')
data['title'] = await title.inner_text() if await title.count() > 0 else ''
# Цена
price = page.locator('[data-marker='item-view/item-price']')
data['price'] = (
await price.get_attribute('content') if await price.count() > 0 else ''
)
# Описание
desc = page.locator('[data-marker='item-view/item-description']')
data['description'] = await desc.inner_text() if await desc.count() > 0 else ''
# Адрес
address = page.locator('[data-marker='item-view/item-address']')
data['address'] = (
await address.inner_text() if await address.count() > 0 else ''
)
# Характеристики
params = page.locator('li.params-paramsList__item')
params_count = await params.count()
chars = []
for i in range(params_count):
text = await params.nth(i).inner_text()
chars.append(text.strip())
data['params'] = '; '.join(chars)
# Имя продавца
seller = page.locator('[data-marker='seller-info/name']')
data['seller'] = await seller.inner_text() if await seller.count() > 0 else ''
except Exception as e:
data['error'] = str(e)
return dataКаждое поле извлекается через data-marker селекторы. Метод get_attribute("content") для цены получает числовое значение из HTML-атрибута, а не отформатированный текст с пробелами и символом рубля.
Этап 3: обход всех ссылок и сохранение в CSV
import csv
async def scrape_all(links: list[str], output_file: str = 'avito_ads.csv'):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080},
locale='ru-RU',
)
page = await context.new_page()
await stealth_async(page)
results = []
for idx, url in enumerate(links, 1):
print(f'[{idx}/{len(links)}] {url[:80]}')
data = await scrape_ad(page, url)
results.append(data)
# Случайная задержка 4-8 секунд
await page.wait_for_timeout(random.randint(4000, 8000))
# Каждые 20 объявлений — длинная пауза
if idx % 20 == 0:
pause = random.randint(30000, 60000)
print(f' Пауза {pause // 1000} сек...')
await page.wait_for_timeout(pause)
await browser.close()
# Сохранение
if results:
keys = results[0].keys()
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=keys)
writer.writeheader()
writer.writerows(results)
print(f'Сохранено {len(results)} объявлений в {output_file}')
asyncio.run(scrape_all(links))Длинная пауза каждые 20 объявлений имитирует поведение человека, который делает перерыв. Без таких пауз Авито блокирует сессию через 30–50 запросов.
Способ 2: HTTP-запросы (для продвинутых)
Если отловить внутренние API-запросы Авито через DevTools (вкладка Network), можно отправлять их напрямую через requests без браузера. Этот способ в десятки раз быстрее Playwright, но нестабилен: эндпоинты и параметры меняются без предупреждения.
import requests
session = requests.Session()
session.headers.update(
{
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Accept-Language': 'ru-RU,ru;q=0.9',
'Referer': 'https://www.avito.ru/',
}
)
# Эндпоинт может отличаться — проверьте через DevTools
url = 'https://m.avito.ru/api/11/items'
params = {
'key': 'af0deccbgcgidddjgnvljitntccdduijhdinfgjgfjir',
'categoryId': 24, # Квартиры
'locationId': 637640, # Москва
'params[201]': 1060, # Продажа
'page': 1,
'limit': 30,
'sort': 'date',
}
response = session.get(url, params=params)
if response.status_code == 200:
data = response.json()
for item in data.get('result', {}).get('items', []):
print(
f'{item.get('title')} | {item.get('priceDetailed', {}).get('value')} руб.'
)
else:
print(f'Ошибка: {response.status_code}')Параметр key это API-ключ, зашитый в JavaScript-код Авито. Он меняется при обновлениях фронтенда. categoryId и locationId определяют категорию и город. Этот подход требует постоянного мониторинга и обновления параметров.
Обход блокировок
Прокси: выбор типа
Мобильные прокси наиболее устойчивы, потому что за одним мобильным IP сидят тысячи реальных пользователей, и Авито не может заблокировать его без ущерба для аудитории.
Ротация прокси в Playwright
async def create_context_with_proxy(browser, proxy_url):
context = await browser.new_context(
proxy={'server': proxy_url},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080},
locale='ru-RU',
)
return context
PROXIES = [
'http://user:pass@mobile1.example.com:8080',
'http://user:pass@mobile2.example.com:8080',
]
# Менять контекст (и прокси) каждые 15-20 запросов
proxy_idx = 0
context = await create_context_with_proxy(browser, PROXIES[proxy_idx])
page = await context.new_page()
await stealth_async(page)
for idx, url in enumerate(links):
if idx > 0 and idx % 15 == 0:
await context.close()
proxy_idx = (proxy_idx + 1) % len(PROXIES)
context = await create_context_with_proxy(browser, PROXIES[proxy_idx])
page = await context.new_page()
await stealth_async(page)
print(f'Сменили прокси на {PROXIES[proxy_idx][:30]}...')
data = await scrape_ad(page, url)Смена контекста каждые 15 запросов сбрасывает куки, localStorage и IP-адрес. Для Авито это выглядит как новый пользователь.
Решение CAPTCHA
Когда Авито показывает капчу, есть два варианта:
# Вариант 1: Сервис автоматического решения (2Captcha, CapSolver)
import requests as req
def solve_captcha(site_key, page_url):
# Отправляем задачу
resp = req.post(
'https://api.2captcha.com/createTask',
json={
'clientKey': 'ваш-ключ-2captcha',
'task': {
'type': 'RecaptchaV2TaskProxyless',
'websiteURL': page_url,
'websiteKey': site_key,
},
},
)
task_id = resp.json()['taskId']
# Ждём результат
import time
for _ in range(30):
time.sleep(5)
result = req.post(
'https://api.2captcha.com/getTaskResult',
json={
'clientKey': 'ваш-ключ-2captcha',
'taskId': task_id,
},
).json()
if result['status'] == 'ready':
return result['solution']['gRecaptchaResponse']
return None
# Вариант 2: Пауза и ручное решение (для отладки)
async def wait_for_human(page):
print('⚠️ Капча! Решите вручную в окне браузера...')
await page.wait_for_selector('[data-marker=\'catalog-serp\']', timeout=120000)
print('Капча решена, продолжаем.')
Сервисы решения капчи стоят $2–3 за 1000 решений. Для небольших проектов дешевле, чем мобильные прокси.
Маскировка отпечатка браузера
<ЗДЕСЬ НЕ ХВАТАЕТ ПРИМЕРА С КОДОМ>
Плагин playwright-stealth закрывает основные утечки, но продвинутые системы проверяют десятки параметров. Дополнительные скрипты маскируют характеристики системы, которые stealth не покрывает.
Парсинг телефонов продавцов
Телефон на Авито скрыт за кнопкой "Показать телефон". При клике отправляется отдельный AJAX-запрос.
async def get_phone(page, item_id: str) -> str:
try:
# Нажимаем кнопку 'Показать телефон'
phone_button = page.locator('[data-marker=\'item-phone-button/card\']')
if await phone_button.count() > 0:
await phone_button.click()
await page.wait_for_timeout(random.randint(2000, 4000))
# Ждём появления номера
phone_el = page.locator('[data-marker=\'phone-popup/phone-number\']')
if await phone_el.count() > 0:
return await phone_el.inner_text()
except Exception as e:
print(f'Ошибка при получении телефона: {e}')
return ''Каждый клик на "Показать телефон" регистрируется Авито как действие. Слишком частые запросы телефонов это самый быстрый путь к блокировке. Ограничивайте до 5–10 телефонов в минуту.
Сохранение в SQLite
Для больших объёмов данных CSV неудобен. SQLite работает без сервера и позволяет делать SQL-запросы.
import sqlite3
def init_db(db_path='avito.db'):
conn = sqlite3.connect(db_path)
conn.execute(
'''
CREATE TABLE IF NOT EXISTS ads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT UNIQUE,
title TEXT,
price TEXT,
address TEXT,
description TEXT,
params TEXT,
seller TEXT,
phone TEXT,
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
'''
)
conn.commit()
return conn
def save_ad(conn, data: dict):
try:
conn.execute(
'INSERT OR IGNORE INTO ads (url, title, price, address, description, '
'params, seller, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(
data.get('url'),
data.get('title'),
data.get('price'),
data.get('address'),
data.get('description'),
data.get('params'),
data.get('seller'),
data.get('phone', ''),
),
)
conn.commit()
except sqlite3.Error as e:
print(f'Ошибка БД: {e}')
# Аналитика
def top_sellers(db_path='avito.db'):
conn = sqlite3.connect(db_path)
cur = conn.execute(
'''
SELECT seller, COUNT(*) as cnt, AVG(CAST(price AS REAL)) as avg_price
FROM ads
WHERE seller != '' AND price != ''
GROUP BY seller
ORDER BY cnt DESC
LIMIT 10
'''
)
for row in cur.fetchall():
print(f'{row[0]}: {row[1]} объявлений, средняя цена {row[2]:,.0f} руб.')
conn.close()INSERT OR IGNORE пропускает дубликаты по уникальному URL. Колонка scraped_at автоматически фиксирует время сбора.
Сравнение подходов
Юридические риски
Парсинг Авито несёт серьёзные правовые риски. Пользовательское соглашение (пункт 2.4) прямо запрещает использование автоматизированных скриптов, ботов и краулеров для сбора данных.
- ФЗ-152 "О персональных данных": Сбор телефонов, имён и email без согласия субъекта нарушает закон. С 2021 года публикация данных в открытом доступе не является согласием на их обработку.
- ГК РФ, ст. 1334: База данных Авито защищена как "инвестиционная" база данных. Систематическое извлечение существенной части данных нарушает исключительное право изготовителя. Компенсация до 5 млн рублей.
- УК РФ, ст. 272: Обход технических средств защиты (капча, блокировки) может быть квалифицирован как неправомерный доступ к компьютерной информации.
Наиболее безопасный путь: собирайте только те данные, которые действительно нужны, не извлекайте персональные данные для перепродажи, не создавайте чрезмерной нагрузки на серверы.
Неочевидные детали
Первый факт: Авито меняет CSS-классы при каждом обновлении фронтенда, но атрибуты data-marker остаются стабильными значительно дольше. Всегда используйте data-marker вместо классов в селекторах.
Второй факт: headless-режим Playwright (headless=True) детектируется Авито чаще, чем обычный режим с видимым окном. Для стабильной работы запускайте с headless=False на сервере через виртуальный дисплей (Xvfb).
Третий факт: Авито показывает разный контент в зависимости от региона, определяемого по IP. Московский прокси покажет московские объявления, даже если в URL указан Петербург. Используйте прокси из целевого региона.
Четвёртый факт: мобильная версия Авито (m.avito.ru) имеет другую структуру HTML и другие API-эндпоинты. Некоторые парсеры работают стабильнее через мобильную версию, потому что она проще.
Пятый факт: парсер без сохранения состояния (cookies, localStorage) при каждом запросе выглядит как новый пользователь. Авито воспринимает это как аномалию. Используйте browser.new_context() с сохранением cookies между запросами в рамках одной сессии, но сбрасывайте каждые 15–20 запросов.
FAQ
Можно ли парсить Авито через requests без браузера?
Напрямую нет: контент рендерится через JavaScript. Но можно перехватить внутренние API-запросы через DevTools и отправлять их напрямую. Этот подход быстрее, но нестабилен.
Какие прокси лучше для Авито?
Мобильные прокси с ротацией IP — наиболее устойчивый вариант. Серверные прокси блокируются за минуты. Резидентные работают, но дорогие. Минимальная цена мобильного прокси — около 500–1000 руб./мес.
Как часто ломаются селекторы?
CSS-классы меняются каждые 1–2 недели. Атрибуты data-marker стабильнее: обновления раз в 1–3 месяца. Закладывайте время на поддержку парсера.
Сколько объявлений можно собрать за день?
С одним мобильным прокси и Playwright — порядка 500–1500 объявлений за день при осторожных задержках. С пулом из 5–10 прокси — до 5000–10000. Без прокси — 30–50 до блокировки.
Есть ли у Авито официальный API?
Авито предоставляет API для зарегистрированных профессиональных продавцов (Avito Pro) для управления собственными объявлениями. Публичного API для массового сбора чужих объявлений не существует.
Мой совет: начните с Playwright + stealth + один мобильный прокси. Это даёт оптимальный баланс между стабильностью, скоростью и стоимостью. Для первых тестов хватит headless=False без прокси — 30–50 объявлений соберёте до первой капчи, зато поймёте структуру данных. И помните: юридические риски парсинга Авито реальны, особенно при сборе персональных данных для коммерческих целей.




