Часто случается так, что ваш личный или корпоративный парсер может остановиться где-то в середине процесса и выдать ошибку. А может просто начать записывать какой-то мусор в базу данных. И всё из-за того, что дизайнеры немного обновили вёрстку или чуть-чуть переработали структуру макета. Этого уже может быть достаточно для того, чтобы целевой сайт стал «нечитаемым» для скрипта.
Как избежать таких проблем? Этот материал о том, как сделать свой скрипт веб-парсинга на Python более устойчивым к незначительным обновлениям на целевых сайтах и страницах. Начнём с наиболее вероятных причин.
Рассмотрим самые популярные проблемы, из-за которых процесс парсинга может прерваться:
Давайте расскажем о них немного поподробнее, чтобы иметь правильное представление о том, как их искать и реагировать в случае возникновения.
Большинство парсеров, если они не используют при своей работе компьютерное зрение или большие языковые модели (LLM, такие как ChatGPT), при разборе страницы на составляющие опираются на её DOM-структуру – это в первую очередь дерево HTML-тегов, а также CSS-стили, классы, идентификаторы и т.п. Так устроены и работают стандартные парсеры.
И если искомый элемент исчезает или меняются его атрибуты, извлечение данных становится невозможным – парсер не может найти нужный элемент на странице. Или, если находит что-то с похожими критериями, то данные внутри могут оказаться мусорными, то есть бесполезными.
Изменения HTML-кода страниц могут происходить по разным причинам:
Чаще всего проблеме подвержены парсеры, которые опираются при поиске на абсолютные пути, а не на относительные.
Например, в синтаксисе XPath это может выглядеть так:
Чтобы минимизировать проблемы с неправильной вёрсткой, можно задействовать специальный модуль, который будет проверять работоспособность парсера перед масштабным запуском. А ещё лучше – наладить постоянный мониторинг на наличие ошибок. Но будьте внимательны: контролируемая «песочница» может отнимать много времени и вычислительных ресурсов.
Современные сайты, созданные с помощью таких фреймворков, как React, Angular, Vue.js и т.п., часто не отдают готовую HTML-структуру. Браузер получает почти пустой HTML-файл и ссылки на JavaScript-скрипты. В итоге сайт «собирается» по кусочкам – по мере выполнения этих скриптов — это частая проблема парсинга динамических сайтов.
Так как страница не имеет нужного кода и выстраивается асинхронно, то классические парсеры, например, с использованием библиотек requests + BeautifulSoup, видят только функциональный «скелет» страницы, без итогового полезного содержимого. Соответственно, спарсить ничего не получится, так как искомых данных на странице буквально нет (они могут подтянуться позже, одним из JS-скриптов).
Выстроить и получить тот контент, который видит конечный пользователь, в данном случае можно только с помощью реальных браузеров или с помощью особых движков для рендеринга JavaScript (например, как в HtmlUnit). Для автоматизации браузеров нужно задействовать библиотеки-посредники, такие как Selenium, Playwright, Puppeteer. С недавних пор в Google есть встроенный протокол CDP (Chrome DevTool Protocol), но и для взаимодействия с ним может потребоваться библиотека-посредник.
Безголовые браузеры – это максимально надёжно, но очень медленно и ресурсоёмко. А ещё стоит помнить, что некоторые продвинутые антифрод-системы научились выявлять признаки headless-браузеров в общем потоке и успешно их блокировать.
Владельцы сайтов не хотят, чтобы их парсили. В определённый момент паразитная нагрузка может привести к существенным расходам на хостинг. Поэтому администраторы и программисты прибегают к различным механизмам защиты. В их числе:
Если система безопасности обнаруживает бота, то вместо положенной полноценной веб-страницы она отдаёт парсеру только код ошибки, экономя тем самым ресурсы сервера.
Логичный выход – включить в свой скрипт парсинга на Python обработчик ошибок сервера и логику для их обхода. Например,сразу задать ротацию прокси.
Многие парсеры для обхода большого каталога статей, а также для навигации по сайту могут опираться на структуру URL-адресов. Например, для поиска в Google можно воспользоваться схемой вида «https://www.google.com/search?q=my%20query». Это простейший вариант, так как внутри можно задействовать специальные операторы и настройки. Это почти готовый API-интерфейс. А ведь у сайтов может быть и реальный API со своей структурой команд.
И если раньше, например, для обращения к странице товара использовалась конструкция вида «site.com/products/123», то после смены её на что-то типа «site.com/catalog/item/123» парсер уже не сможет найти страницу и выдаст ошибку.
С классификацией проблем разобрались, осталось рассказать о более эффективном использовании Python для веб-скрапинга. Ниже обозначим те подходы, которые помогут уменьшить количество сбоев и повысить стабильность работы вашего парсера.
Правильно выбранные библиотеки, фреймворки и другие инструменты – это половина успеха. Но стоит помнить, что у каждого технического решения своя область применения, ограничения и возможности, синтаксис команд, архитектура и т.п.
Не существует волшебных библиотек «на все случаи жизни». Поэтому подбирать их нужно строго под свои задачи и специфику целевых сайтов.
Например:
Об абсолютных путях до селекторов мы уже упоминали, но это лишь одна из самых частых проблем. Чтобы конструкция для выборки данных в HTML-документах работала устойчиво, следует придерживаться следующих правил:
Идеальные прокси-серверы для доступа к ценным данным со всего мира.
Не стоит тешить себя надеждой, что ваш парсер будет работать как часы, без ошибок и сбоев. Нужно предвидеть возникновение проблем и заранее готовиться к типовым сценариям реагирования. Какие-то ошибки могут быть новыми и нестандартными, но большинство можно предугадать даже без большого опыта.
Наиболее успешные практики:
Обратите внимание, что многие ошибки связаны с блокировкой парсера по IP или на основе анализа поведенческих факторов. Поэтому не забывайте рандомизировать время между отдельными запросами, а также используйте качественные прокси с гео-таргетингом.
Мы уже говорили о том, что безголовые браузеры позволяют вашему скрипту парсинга на Python «видеть» итоговый код так, как его видят конечные пользователи. Более того, с помощью таких браузеров вполне можно имитировать действия юзеров: заполнять поля, двигать курсором, прокручивать страницу и прочее.
Но тут тоже есть ряд нюансов:
Регулярные выражения (regex) – это один из самых мощных инструментов для извлечения данных из неструктурированного текста. Его возможности просто поражают. С помощью regex можно отслеживать повторяющиеся паттерны (префиксы, окончания, блоки кода и т.п.), а также очищать и нормализовать данные.
Регулярные выражения могут стать палочкой-выручалочкой и отвечать за реализацию резервных стратегий, когда ни один из предыдущих подходов не сработал.
Например, с помощью regex можно максимально быстро извлекать «узнаваемые» данные, такие как номера телефонов, адреса email, цены и т.п.
В отличие от синтаксических анализаторов, таких как Beautiful Soup, регулярки привязываются к структуре самих данных, а не к структуре HTML-документа.
Масштабный парсинг может выполняться очень долго. А многие задачи запускаются повторно – с заданной периодичностью. Соответственно, в таких случаях стоит заранее подумать о:
Но это касается непосредственно работы парсера. А ведь можно превентивно реагировать и на изменения в вёрстке целевого сайта. Например, можно настроить небольшую задачу для парсинга определённых страниц с целью выявления на них изменений. Если извлечение данных с них завершается с ошибкой – это отдельный сигнал разработчику о том, что нужно заранее поменять логику сбора данных и донастроить конструкции, на основе которых делаются выборки.
Давайте лучше покажем пример кода скрипта Python-парсинга с обработкой ошибок и отказоустойчивостью.
#!/usr/bin/env python3
"""
Robust scraper: requests -> fallback Playwright (headless)
Features:
- requests session with Retry/backoff
- UA rotation + optional proxy rotation
- CSS selector -> XPath fallback parsing
- DOM snapshot + diff detection (canonicalization + SHA256 signature)
- Telegram notifications for alerts
- Save progress to resume
- Playwright fallback: waits for a specific selector to appear
"""
import os
import time
import random
import json
import logging
import hashlib
import difflib
from typing import Optional, List, Dict
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from bs4 import BeautifulSoup
from lxml import html
from playwright.sync_api import sync_playwright
# ---------------- НАСТРОЙКИ ----------------
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15",
# добавьте больше реалистичных строк User-Agent
]
PROXIES = [
# "http://user:pass@proxy1.example:3128",
# "http://proxy2.example:3128",
]
HEADERS_BASE = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
MAX_RETRIES = 3
BACKOFF_FACTOR = 1.0
SAVE_FILE = "scrape_progress.json"
LOG_FILE = "scraper.log"
SNAPSHOT_DIR = "snapshots"
# Настройки Telegram (укажите свои значения)
TELEGRAM_BOT_TOKEN = "" # например, "123456:ABC-DEF..."
TELEGRAM_CHAT_ID = "" # например, "987654321"
# Настройки Playwright
PLAYWRIGHT_TIMEOUT_MS = 20000 # таймаут по умолчанию 20с для навигации/селекторов
# ----------------------------------------
os.makedirs(SNAPSHOT_DIR, exist_ok=True)
# Логирование
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()]
)
# ---------- Telegram ----------
def telegram_send(text: str):
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
logging.debug("Telegram not configured; skipping send.")
return
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {"chat_id": TELEGRAM_CHAT_ID, "text": text}
try:
r = requests.post(url, json=payload, timeout=10)
if r.status_code != 200:
logging.warning("Telegram send failed: %s %s", r.status_code, r.text)
except Exception as e:
logging.exception("Telegram send exception: %s", e)
def send_alert(msg: str):
logging.warning("ALERT: %s", msg)
telegram_send(f"ALERT: {msg}")
# ---------- построение сессии ----------
def build_session(proxy: Optional[str] = None) -> requests.Session:
s = requests.Session()
retries = Retry(
total=MAX_RETRIES,
backoff_factor=BACKOFF_FACTOR,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET", "POST"])
)
adapter = HTTPAdapter(max_retries=retries)
s.mount("https://", adapter)
s.mount("http://", adapter)
if proxy:
s.proxies.update({"http": proxy, "https": proxy})
return s
# ---------- загрузка через requests ----------
def fetch_url_requests(url: str, session: requests.Session, timeout: int = 20) -> Optional[requests.Response]:
headers = HEADERS_BASE.copy()
headers["User-Agent"] = random.choice(USER_AGENTS)
try:
# вежливая пауза (джиттер)
time.sleep(random.uniform(0.4, 1.6))
resp = session.get(url, headers=headers, timeout=timeout)
resp.raise_for_status()
# базовая проверка: тело не пустое и содержит <html> или <body>
if resp.text and ("<html" in resp.text.lower() or "<body" in resp.text.lower()):
return resp
logging.debug("Requests вернул небольшой/пустой ответ для %s", url)
except requests.HTTPError as e:
logging.error("HTTP ошибка для %s: %s", url, e)
except requests.RequestException as e:
logging.error("Ошибка запроса для %s: %s", url, e)
return None
# ---------- запасной вариант: Playwright ----------
def fetch_with_playwright(url: str, wait_selector: str = "body", timeout_ms: int = PLAYWRIGHT_TIMEOUT_MS) -> Optional[str]:
"""
Load page with Playwright (Chromium headless) and wait for wait_selector to appear.
Returns rendered HTML or None.
"""
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(user_agent=random.choice(USER_AGENTS))
page = context.new_page()
# переходим и ждём DOMContentLoaded; затем ждём нужный селектор
page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded")
# ждём селектор (если не найден -> TimeoutError)
page.wait_for_selector(wait_selector, timeout=timeout_ms)
content = page.content()
browser.close()
# проверка корректности
if content and ("<html" in content.lower() or "<body" in content.lower()):
return content
logging.debug("Playwright вернул неожиданный контент для %s", url)
except Exception as e:
logging.exception("Сбой Playwright для %s: %s", url, e)
send_alert(f"Playwright error for {url}: {e}")
return None
# ---------- комбинированная загрузка: requests + Playwright ----------
def robust_fetch_html(url: str, proxy: Optional[str], wait_selector: str) -> Optional[str]:
session = build_session(proxy=proxy)
resp = fetch_url_requests(url, session)
if resp:
return resp.text
logging.info("Requests не справился или вернул плохой HTML для %s — переключаемся на Playwright", url)
html_text = fetch_with_playwright(url, wait_selector=wait_selector)
if html_text:
logging.info("Playwright успешно загрузил %s", url)
else:
logging.error("И requests, и Playwright не справились для %s", url)
return html_text
# ---------- парсинг: помощники ----------
def parse_with_css(soup: BeautifulSoup, css_selector: str) -> Optional[str]:
try:
el = soup.select_one(css_selector)
return el.get_text(strip=True) if el else None
except Exception:
return None
def parse_with_xpath(text: str, xpath_expr: str) -> Optional[str]:
try:
tree = html.fromstring(text)
result = tree.xpath(xpath_expr)
if not result:
return None
if isinstance(result[0], html.HtmlElement):
return result[0].text_content().strip()
return str(result[0]).strip()
except Exception:
return None
def extract_item(html_text: str, css_selector: str, xpath_expr: str) -> Optional[str]:
soup = BeautifulSoup(html_text, "lxml")
val = parse_with_css(soup, css_selector)
if val:
return val
return parse_with_xpath(html_text, xpath_expr)
# ---------- каноникализация DOM + подпись ----------
def canonicalize_html_fragment(html_fragment: str) -> str:
soup = BeautifulSoup(html_fragment, "lxml")
for el in soup.find_all(True):
attrs = dict(el.attrs)
new_attrs = {}
for k, v in attrs.items():
if k.startswith("on"):
continue
if k in ("id", "class"):
if isinstance(v, list):
v = " ".join(v)
# удаляем цифры, чтобы снизить шум динамических id/class
normalized = "".join(ch for ch in v if not ch.isdigit())
new_attrs[k] = normalized
else:
new_attrs[k] = v
el.attrs = new_attrs
text = soup.prettify()
lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
return "\n".join(lines)
def signature_of_fragment(html_fragment: str) -> str:
canonical = canonicalize_html_fragment(html_fragment)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
# ---------- хранилище прогресса ----------
def load_snapshot(url_key: str) -> Optional[Dict]:
path = os.path.join(SNAPSHOT_DIR, f"{url_key}.json")
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def save_snapshot(url_key: str, canonical_html: str, sig: str):
path = os.path.join(SNAPSHOT_DIR, f"{url_key}.json")
payload = {"signature": sig, "html": canonical_html, "ts": int(time.time())}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def diff_fragments(old: str, new: str) -> str:
return "\n".join(difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm=""))
def load_progress() -> Dict:
try:
with open(SAVE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {"visited": [], "data": []}
except Exception:
return {"visited": [], "data": []}
def save_progress(progress: Dict):
try:
with open(SAVE_FILE, "w", encoding="utf-8") as f:
json.dump(progress, f, ensure_ascii=False, indent=2)
except Exception as e:
logging.error("Не удалось сохранить прогресс: %s", e)
# ---------- мониторинг структуры ----------
def monitor_structure(url: str, html_text: str, css_container: str, url_key: str) -> bool:
soup = BeautifulSoup(html_text, "lxml")
el = soup.select_one(css_container) if css_container else soup.body
if el is None:
logging.warning("Контейнер по селектору '%s' не найден на %s", css_container, url)
send_alert(f"Container selector '{css_container}' not found on {url}")
return False
canonical = canonicalize_html_fragment(str(el))
sig = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
prev = load_snapshot(url_key)
if not prev:
save_snapshot(url_key, canonical, sig)
logging.info("Сохранён исходный снимок для %s", url)
return False
if prev.get("signature") != sig:
old_html = prev.get("html", "")
diff_text = diff_fragments(old_html, canonical)
diff_path = os.path.join(SNAPSHOT_DIR, f"{url_key}_diff_{int(time.time())}.txt")
with open(diff_path, "w", encoding="utf-8") as f:
f.write(diff_text)
save_snapshot(url_key, canonical, sig)
logging.info("Обнаружены изменения структуры %s, diff сохранён в %s", url, diff_path)
send_alert(f"STRUCTURE CHANGE: {url}\nSelector: {css_container}\nDiff saved: {diff_path}")
return True
return False
# ---------- основная логика ----------
def main(urls: List[str], container_selector_for_monitoring: str = "main", wait_selector_for_playwright: str = "body"):
progress = load_progress()
visited = set(progress.get("visited", []))
for url in urls:
if url in visited:
logging.info("Пропускаем уже посещённый: %s", url)
continue
proxy = random.choice(PROXIES) if PROXIES else None
html_text = robust_fetch_html(url, proxy, wait_selector_for_playwright)
if not html_text:
logging.error("Не удалось получить %s ни через requests, ни через Playwright", url)
send_alert(f"Failed to fetch {url} via both requests and Playwright")
visited.add(url)
progress["visited"] = list(visited)
save_progress(progress)
continue
# мониторим структуру
url_key = hashlib.sha1(url.encode("utf-8")).hexdigest()
try:
changed = monitor_structure(url, html_text, container_selector_for_monitoring, url_key)
if changed:
logging.info("Обнаружено изменение структуры на %s", url)
except Exception as e:
logging.exception("Сбой монитора структуры для %s: %s", url, e)
send_alert(f"Structure monitor exception for {url}: {e}")
# пример извлечения: сначала CSS, затем откат на XPath
css_selector = "h1.product-title" # настройте под сайт
xpath_expr = "//h1[contains(@class,'product') or contains(.,'Product')]/text()"
try:
item = extract_item(html_text, css_selector, xpath_expr)
if item:
logging.info("Извлечено с %s : %s", url, item)
progress["data"].append({"url": url, "title": item})
else:
logging.info("Основной метод не сработал для %s; пробуем мета-тег og:title", url)
meta_title = parse_with_xpath(html_text, "//meta[@property='og:title']/@content")
if meta_title:
progress["data"].append({"url": url, "title": meta_title})
else:
send_alert(f"Parsing failed for {url}")
except Exception as e:
logging.exception("Ошибка парсинга для %s: %s", url, e)
send_alert(f"Parsing crash {url}: {e}")
visited.add(url)
progress["visited"] = list(visited)
save_progress(progress)
time.sleep(random.uniform(1.0, 3.0))
logging.info("Скрапинг завершён. Собрано элементов: %d", len(progress.get("data", [])))
telegram_send(f"Scraping finished. Items: {len(progress.get('data', []))}")
# ---------- запуск ----------
if __name__ == "__main__":
seed_urls = [
# замените на реальные URL
"https://example.com/product/1",
"https://example.com/product/2",
]
# контейнер для мониторинга (важный блок для отслеживания структурных изменений)
container_selector = "div#content"
# селектор, появления которого ждёт Playwright, чтобы считать страницу отрендеренной
wait_selector = "div#content"
main(seed_urls, container_selector_for_monitoring=container_selector, wait_selector_for_playwright=wait_selector)
В этом скрипте вы можете задать свой список прокси на ротацию, свои заголовки для user-агента, а также список целевых страниц для парсинга.
Ключевые фишки: скрипт сначала пробует скачать страницу через requests (с retry/backoff), при неудаче автоматически переключается на headless-браузер через Playwright, ждёт появления заданного CSS-селектора и возвращает отрендеренный HTML.
Здесь также есть: логирование, Telegram-уведомления (укажите свои токены), снимки DOM + детект изменений (через функцию diff), сохранение прогресса и базовая ротация прокси/UA. Парсинг изначально осуществляется на основе CSS. Если он не срабатывает, то включается XPath fallback.
Не забудьте установить необходимые библиотеки и зависимости:
pip install requests beautifulsoup4 lxml playwright
Затем проинициализируйте браузер для playwright:
python -m playwright install chromium
Это лишь один из вариантов того, как может выглядеть скрипт парсинга на Python, устойчивый к изменениям в вёрстке.
Итак, примерно здесь должно прийти понимание, что недостаточно просто написать самую суть скрипта и начать парсить целевой сайт. Мало найти повторяющийся паттерн в DOM-структуре. Так вы не сможете создать стабильный бизнес-процесс и не добьётесь впечатляющих результатов при больших масштабах сбора данных.
Нужно ещё подумать об отходных путях: логировании, ротации прокси, реагировании на ошибки, автоматизации изменений в URL/DOM-структуре, сохранении прогресса и многом другом.
Чем больше вы предусмотрите, тем стабильнее будет работать ваш скрипт парсинга на Python.
Крупные коммерческие парсеры невозможны без надёжной сетевой инфраструктуры и качественных прокси. И они как раз есть у Froxy. В вашем распоряжении 10+ миллионов IP с точным таргетингом и автоматической ротацией по времени или при каждом новом запросе.