Этичный парсинг: robots.txt, задержки и чтобы не забанили
Парсер собрал три тысячи страниц — а утром каждый запрос возвращает 403. Читаем robots.txt, представляемся по имени, держим вежливый темп и отвечаем на 429 так, чтобы дверь не закрылась навсегда.
Редакция Питоники
Ваш парсер из прошлого урока честно прошёл три тысячи страниц за ночь. Утром вы запускаете его снова — и каждый запрос возвращает 403 Forbidden. Сервер вас узнал и закрыл дверь. Никакой мистики: слишком быстрые, слишком одинаковые запросы неотличимы от атаки, и защита сайта сработала как задумано. Сегодня научимся ходить по чужим сайтам так, чтобы дверь не закрывалась: читать robots.txt, представляться по имени, держать вежливый темп и правильно реагировать на 403 и 429. И коротко — о правовой стороне: где проходит граница между сбором публичных данных и нарушением закона.
robots.txt: правила, написанные до вас
У любого приличного сайта в корне лежит файл robots.txt. Он адресован роботам: поисковым краулерам, аналитическим ботам — и вашим скриптам тоже. Файл не имеет юридической силы, это договор вежливости: поисковые системы считают его обязательным, а парсер-нарушитель рискует и репутацией, и доступом. Прежде чем писать код, откройте в браузере адрес сайт/robots.txt — вы удивитесь, сколько сайтов открыто пишут, что можно и нельзя.
# shop.example - правила для роботов
User-agent: *
Disallow: /cart
Disallow: /profile
Disallow: /orders
Disallow: /search
Allow: /catalog/
Crawl-delay: 2
User-agent: Googlebot
Disallow: /orders
Sitemap: https://shop.example/sitemap.xml
Директивы построчно
- User-agent: \* — к какой группе роботов относятся правила ниже; звёздочка означает «все», отдельно можно адресовать Googlebot или YandexBot;
- Disallow: /cart — запрещённый префикс: под правило попадает любой адрес, начинающийся с /cart;
- Allow: /catalog/ — исключение из запрета: этот префикс можно даже там, где общий блок запрещает больше;
- Crawl-delay: 2 — минимальная пауза в секундах между запросами одного робота;
- Sitemap: ... — карта сайта: готовый список адресов, который часто экономит целый парсер.
Правила читаются внутри одного блока User-agent, а блоки разделяются пустой строкой. Совпадение — префиксное: Disallow: /cart накроет и /cart/42, и /carts-archive. К чему это приводит на практике — разберём на живом примере.
from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.parse(["User-agent: *", "Disallow: /"])
print(rp.can_fetch("*", "/")) # главная
print(rp.can_fetch("*", "/catalog/page/2/"))
print(rp.can_fetch("*", "/blog"))
False False False
Проверяем правила кодом: urllib.robotparser
Глаза — плохой инструмент для robots.txt: правила пересекаются, и вручную легко ошибиться. В стандартной библиотеке Python есть парсер urllib.robotparser: скормите ему строки файла — и спрашивайте разрешения по каждому адресу. Сеть не нужна, разбор работает офлайн:
from urllib.robotparser import RobotFileParser
robots_txt = """
User-agent: *
Disallow: /cart
Disallow: /profile
Disallow: /search
Allow: /catalog/
Crawl-delay: 2
"""
rp = RobotFileParser()
rp.parse(robots_txt.splitlines())
print(rp.can_fetch("*", "/catalog/page/2/"))
print(rp.can_fetch("*", "/cart"))
print(rp.can_fetch("*", "/profile/orders/123"))
print(rp.can_fetch("*", "/catalog"))
print(rp.crawl_delay("*"))
True False False True 2
Разбираем вывод. Каталог можно, корзина и профиль нельзя — как и записано. Метод can_fetch получает имя вашего агента и путь и отвечает True или False. А /catalog без слэша разрешён, но не из-за Allow: правило Allow: /catalog/ со слэшем его не покрывает — просто ни один Disallow на него не попадает, а всё не запрещённое разрешено по умолчанию. Метод crawl_delay достаёт паузу из файла: сервер просил 2 секунды между запросами.
User-Agent: кто стучится в дверь
Каждый HTTP-запрос подписывается заголовком User-Agent. Библиотека requests по умолчанию подписывается как python-requests/2.31.0 — и именно такие запросы админы режут первыми: под этим именем ходят в основном дешёвые скрипты, от которых не ждут пользы. В уроке про requests мы уже передавали словарь headers — теперь понятно, зачем он нужен на каждом запросе.
user_agent = "CatalogWatcher/1.0 (+https://example.com/bot; ivan@example.com)"
headers = {
"User-Agent": user_agent,
"Accept-Language": "ru-RU,ru;q=0.9",
}
print(headers["User-Agent"])
print("Бот представился:", "Watcher" in user_agent)
CatalogWatcher/1.0 (+https://example.com/bot; ivan@example.com) Бот представился: True
Формат «Имя/версия (+адрес страницы с описанием; контакт)» — конвенция из мира поисковых роботов: по адресу из скобок админ находит, кто вы и как вас отключить. Честность окупается: на запрос «дайте разрешение или скажите нет» отвечают, а молчаливый скрипт под чужим именем банят молча.
import requests
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept-Language": "ru-RU,ru;q=0.9",
}
resp = requests.get("https://shop.example/catalog/", headers=headers, timeout=10)
print(resp.status_code)
print(resp.headers["Content-Type"])
200 text/html; charset=utf-8
Второй путь — маскировка под браузер: строка Mozilla/5.0 ... якобы от Chrome на Windows. Работает, но это серая зона: сайт уверен, что к нему пришёл человек. Для публичных каталогов и новостей разница невелика — страницы всё равно отдаются всем желающим. А вот если контент живёт за авторизацией, маскировка не спасёт и не должна: это уже не парсинг, а обход защиты.
Задержки: темп, который не злит сервер
Скорость — главный триггер банов. Человек листает каталог со скоростью страницы в несколько секунд; скрипт без пауз выдаёт десятки запросов в секунду. Для сервера второе выглядит как атака, и ответ приходит в виде 403. В уроке про пагинацию мы уже ставили time.sleep в цикл по страницам — теперь разберём, как выбирать паузу грамотно.
import time
import random
random.seed(42) # фиксированный сид - чтобы демо было повторяемым
pages = ["/catalog/page/1/", "/catalog/page/2/", "/catalog/page/3/"]
for page in pages:
# в бою: random.uniform(1, 3); здесь укорочено для демо
delay = round(random.uniform(0.1, 0.3), 2)
time.sleep(delay)
print(page, "пауза", delay, "c")
/catalog/page/1/ пауза 0.23 c /catalog/page/2/ пауза 0.11 c /catalog/page/3/ пауза 0.16 c
Зачем разброс, если можно sleep(1)? Потому что ровная периодичность — подпись робота: сто запросов строго через секунду видны в логах как метроном. random.uniform(1, 3) даёт рваный, живой темп. Если в robots.txt заявлен Crawl-delay — это нижняя граница: сервер явно попросил темп, и нарушать просьбу, которую вы уже прочитали, особенно некрасиво.
429 Too Many Requests и Retry-After
Прежде чем забанить, приличный сервер обычно предупреждает кодом 429 и заголовком Retry-After: столько-то секунд подождите. Это ещё не бан, а жёлтая карточка — и от вашей реакции зависит, станет ли она красной.
| Код | Что значит | Что делать |
|---|---|---|
| 200 | всё хорошо | работаем дальше |
| 403 | доступ запрещён: вас узнали и не хотят видеть | остановить прогон; проверить robots.txt, User-Agent и темп; вернуться через дни, а не минуты |
| 429 | слишком много запросов — лимит темпа | прочитать Retry-After, ждать ровно столько, продолжить медленнее |
| 503 | сервер перегружен | то же, что при 429: пауза и повтор с меньшей скоростью |
import time
status = 429 # то, что вернул сервер
retry_after = 30 # из заголовка Retry-After, секунд
for attempt in range(1, 4):
if status == 429:
print(f"Попытка {attempt}: лимит. Жду {retry_after} c")
time.sleep(retry_after)
status = 200 # после паузы сервер ответил нормально
else:
print("Успех:", status)
break
Попытка 1: лимит. Жду 30 c Успех: 200
Шаблон: парсер с вежливыми привычками
Соберём приёмы в каркас, с которого можно начинать свой скрипт: честный User-Agent, пауза с разбросом, таймаут, реакция на ошибки и сохранение из прошлого урока. В песочнице он не запустится — сеть и файлы недоступны, — но как заготовка для локальной работы работает целиком:
import csv
import random
import time
import requests
from bs4 import BeautifulSoup
HEADERS = {
"User-Agent": "CatalogWatcher/1.0 (+https://example.com/bot; ivan@example.com)",
}
MIN_DELAY, MAX_DELAY = 1.5, 3.0
def fetch(url):
# вежливая пауза перед каждым запросом
time.sleep(random.uniform(MIN_DELAY, MAX_DELAY))
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
return resp.text
def parse_catalog(pages):
rows = []
for num in range(1, pages + 1):
html = fetch(f"https://shop.example/catalog/page/{num}/")
soup = BeautifulSoup(html, "html.parser")
for card in soup.select("div.card"):
rows.append({
"name": card.select_one("h3").get_text(strip=True),
"price": card.select_one(".price").get_text(strip=True),
})
return rows
def save_csv(rows):
with open("output/catalog.csv", "w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["name", "price"], delimiter=";")
writer.writeheader()
writer.writerows(rows)
if __name__ == "__main__":
save_csv(parse_catalog(pages=5))
print("Готово: output/catalog.csv")
Готово: output/catalog.csv
Что парсить нельзя: красные линии
Техническая вежливость — половина дела. Вторая половина — понимание, какие данные собирать нельзя в принципе, каким бы быстрым ни был ваш скрипт. Список короткий, и его стоит выучить наизусть.
- Персональные данные: ФИО, телефоны, адреса, email. Их сбор и хранение регулируются законом, публичность страницы ничего не отменяет.
- Страницы за авторизацией: аккаунт даёт право смотреть данные глазами, а не утаскивать их скриптом по логину и паролю.
- Обход защиты: взлом капчи, подмена сессий, сканирование скрытых адресов — это уже не парсинг, а неправомерный доступ.
- Уникальный контент в промышленных объёмах: выкачивать целые базы статей и объявлений — нарушение чужих прав на контент.
API вместо парсинга
Прежде чем писать парсер, потратьте десять минут на поиск официального способа: у многих сайтов есть API, у новостных — RSS-лента, у маркетплейсов — выгрузки для партнёров. API стабильнее разметки (её меняют без предупреждения, контракт API — нет), быстрее и легитимнее: вам буквально говорят «берите данные вот тут».
Правило API-first я бы поставил первым в любом курсе парсинга: сначала официальный источник, потом RSS, и только затем разбор HTML. BeautifulSoup остаётся для случаев, когда API нет или он платный, а данные — публичные и простые.
Чек-лист вежливого парсера
- Прочитал robots.txt — запреты для группы * соблюдаю.
- Ставлю Crawl-delay из файла или паузу 1-3 секунды с разбросом, что больше.
- Подписываюсь в User-Agent с контактом — или понимаю, почему беру браузерный.
- На 429 отвечаю паузой по Retry-After, а не повтором.
- У каждого запроса есть timeout и raise_for_status.
- Не хожу за авторизацией и не собираю персональные данные.
- Сначала поискал API и RSS — HTML-парсер только запасной план.
Фреймворк Scrapy, к которому мы переходим в следующем уроке, зашил этот чек-лист в настройки: ROBOTSTXT_OBEY, DOWNLOAD_DELAY и автотроттлинг включаются строчками конфига — вежливость там по умолчанию, а не по памяти.
Что дальше
Краткий итог: robots.txt читаем до первого запроса и проверяем правилами RobotFileParser, представляемся User-Agent с контактом, держим паузу 1-3 секунды с разбросом, на 429 ждём Retry-After, персональные данные и страницы за авторизацией не трогаем, а перед парсером ищем API. Так вы соберёте нужные данные и не увидите ни одного 403. Это восьмая ступень из десяти; весь маршрут от первого тега до Scrapy — самоучитель BeautifulSoup.
Когда скрипт перестаёт помещаться в один файл, а страниц в обходе — в тысячи, приходит время фреймворка: в следующем уроке заводим первый проект Scrapy и смотрим, чем паук со встроенными pipeline и автотроттлингом отличается от нашего каркаса на requests и BeautifulSoup.
Сначала предскажи ответ в голове — это главный навык программиста.
from urllib.robotparser import RobotFileParser
robots_txt = """
User-agent: *
Disallow: /cart
Crawl-delay: 2
User-agent: GoodBot
Disallow: /
Crawl-delay: 5
"""
rp = RobotFileParser()
rp.parse(robots_txt.splitlines())
print(rp.can_fetch("GoodBot", "/catalog/"))
print(rp.can_fetch("GoodBot", "/cart"))
print(rp.can_fetch("Mozilla", "/cart"))
print(rp.crawl_delay("GoodBot"))
from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.parse(["User-agent: *", "Disallow: /cart"])
print(rp.can_fetch("*", "/cart/page/2/"))
print(rp.can_fetch("*", "/carts/"))
print(rp.can_fetch("*", "/catalog/"))
1. Что означает запись User-agent: * с последующим Disallow: /?
2. Что говорит сайту директива Crawl-delay: 2?
3. Сервер ответил кодом 429. Что делать?
4. Почему запросы с User-Agent python-requests/2.31.0 часто получают 403?
5. Какие данные нельзя собирать парсером без правового основания?
Разберите robots.txt магазина через urllib.robotparser и напечатайте вердикты для трёх адресов и задержку. Проверьте /catalog/page/1/, /profile/settings/ и /admin/login/ методом can_fetch для агента * и выведите каждую строку в формате «путь - True/False», затем «Задержка: N» из crawl_delay.
Нужно ли учитывать robots.txt при парсинге?
Да. Это общепринятый договор между сайтом и роботами: его соблюдают поисковые системы, а игнорирование легко превращает вас из гостя в нарушителя — от бана по IP до претензий со стороны владельца. Разбирать файл удобно стандартным urllib.robotparser: скормили строки — спрашиваете can_fetch по каждому адресу.
Как задать задержку между запросами при парсинге?
time.sleep перед каждым запросом, лучше со случайным разбросом: time.sleep(random.uniform(1, 3)). Ровный метроном из одинаковых пауз выглядит как робот в логах. Если сайт указал Crawl-delay в robots.txt — берите максимум из него и вашей паузы.
Как обойти ошибку 403 при парсинге?
Легальные пути: поставить осмысленный User-Agent вместо дефолтного python-requests, снизить темп запросов, проверить robots.txt и добавить таймаут с cookies сессии. Если 403 остаётся — сайт сознательно не хочет видеть автоматические запросы, и «обходить» это не стоит: ищите API или пишите владельцу за разрешением.
Законен ли парсинг в России?
Сбор публичных неконфиденциальных данных — цен, названий, заголовков — обычно правомерен при соблюдении robots.txt и разумной нагрузки. Красные линии: персональные данные (152-ФЗ требует правового основания даже для публичных ФИО и телефонов), страницы за авторизацией, обход защиты и копирование больших массивов чужого контента. При сомнениях — письменное разрешение владельца сайта.
Как проверить robots.txt кодом перед парсингом?
Стандартным urllib.robotparser: RobotFileParser().parse(robots_txt.splitlines()), затем rp.can_fetch("*", "/catalog/") по каждому адресу — вернёт True или False, а rp.crawl_delay("*") достанет требуемую паузу. Сеть не нужна: строки файла скармливаются парсеру офлайн.
Понравился урок? Сошлитесь на него
«Правило API-first я бы поставил первым в любом курсе парсинга: сначала официальный источник, потом RSS, и только затем разбор HTML.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
BeautifulSoup / Scrapy · Урок 7
Сохранение результатов: CSV, JSON и базы данных
Список словарей собран — теперь доведём его до файла: CSV для таблиц, JSON для вложенных структур, sqlite3 для больших объёмов. Плюс две классические ловушки: пустые строки и кракозябры в Excel.
BeautifulSoup / Scrapy · Урок 9
Scrapy для начинающих: пауки, проекты и почему он быстрее
BeautifulSoup разобрал страницу — но не умеет ходить по сайту, ставить паузы и писать CSV. Scrapy делает это сам: собираем проект, разбираем паука, yield и pipeline.
BeautifulSoup / Scrapy · Урок 6
Многостраничный парсинг: пагинация и обход каталога
Данные редко лежат на одной странице. Учим парсер ходить по пагинации, заходить в карточки товаров, выдерживать паузы и переживать обрывы связи.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
json · Урок 9
Битый JSON: JSONDecodeError и как его читать
Одинарные кавычки, висячая запятая, True вместо true — три классические причины JSONDecodeError, и текст ошибки, который честно называет строку и колонку.
json.loads ошибка pythonбитый json что делать
json · Урок 8
Путь к значению: достаём данные из глубины
Цепочки data["user"]["name"] и data["items"][0]["price"], KeyError у отсутствующих полей и get с умолчанием — спуск по типичному ответу API без падений.
json вложенные данные pythonкак получить поле из json python
BeautifulSoup / Scrapy · Урок 1
Парсинг сайтов на Python: как устроен веб и HTML для скрапера
Первый урок раздела: раз разбираем страницы — сначала поймём, из чего они состоят. Теги, атрибуты, DOM-дерево, селекторы и DevTools за один вечер.
парсинг pythonпарсинг сайтов python
Проверьте знания по BeautifulSoup / Scrapy
В челлендже — 20 задач по BeautifulSoup / Scrapy, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по BeautifulSoup / Scrapy: 20 задач