Skip to content

justxor/Pythonparsing-

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 

Repository files navigation

🐍 Python Парсинг: Большой продвинутый бесплатный курс

Полное практическое руководство по веб-скрейпингу на Python — от основ HTTP до production-grade пауков, обхода антибот-защит, асинхронности и проектирования надёжных пайплайнов. Каждый раздел содержит рабочие примеры, типовые ошибки и продвинутые практики.

Python License PRs Welcome Made with ❤


📚 Содержание

Основы

  1. Введение: что такое парсинг
  2. Как устроен веб: HTTP, HTML, DOM
  3. Библиотека requests: глубокое погружение
  4. Парсинг HTML: BeautifulSoup
  5. CSS-селекторы и XPath (lxml)
  6. Регулярные выражения

Продвинутый уровень

  1. Работа с API, JSON и авторизацией
  2. Динамические сайты: Playwright и Selenium
  3. Асинхронный парсинг: aiohttp + asyncio
  4. Scrapy — промышленный фреймворк
  5. Обход защит и анти-бан стратегии

Инженерия данных

  1. Хранение и валидация данных
  2. Надёжность: ошибки, ретраи, логирование
  3. Тестирование парсеров
  4. Этика и законность

Практика

  1. Архитектура production-парсера
  2. Практические проекты
  3. Антипаттерны и частые ошибки
  4. Полезные ресурсы

Дополнительно

🔗 Полезные материалы

Подборка Telegram-каналов для прокачки в Python и смежных областях:

  • 🖥 Python (зеркало) — с помощью понятных картинок и коротких видео авторы объясняют сложные концепции и учат профессиональному подходу в разработке.
  • 🖥 Python Интервью — огромное количество разобранных вопросов с реальных собеседований Python-разработчика.
  • 🧠 Machine Learning — ИИ-инструменты для генерации Python-кода, умные агенты и всё, что нужно знать из области AI.
  • 📖 PythonBooks (зеркало) — канал с книгами по Linux и, наверное, самая большая подборка книг.
  • 💼 Python Jobs — вакансии и подработка для Python-разработчиков.
  • 🔝 Кладезь Python-ресурсов — целая подборка полезных Python-ресурсов для прокачки.

🗺 Другие полезные Roadmap

  • Machine Learning Roadmap — полный roadmap по машинному обучению 2026.
  • Linux Roadmap — полная карта изучения Linux: топ бесплатных ресурсов и гайдов.

🚀 Быстрый старт

# Создаём виртуальное окружение
python -m venv venv
source venv/bin/activate        # Linux / macOS
venv\Scripts\activate           # Windows

# Базовый стек
pip install requests beautifulsoup4 lxml
# Продвинутый стек
pip install aiohttp[speedups] playwright scrapy pandas pydantic tenacity
pip install fake-useragent loguru httpx selectolax
playwright install chromium

# Зафиксировать зависимости
pip freeze > requirements.txt

💡 selectolax — очень быстрый HTML-парсер на C (в 5–10 раз быстрее BeautifulSoup), httpx — современная замена requests с поддержкой async и HTTP/2.


🎓 Урок 1. Введение: что такое парсинг

Парсинг (web scraping) — автоматизированное извлечение данных с веб-страниц и их превращение в структурированный формат.

Когда парсинг оправдан:

  • У сайта нет публичного API, а данные нужны в объёме.
  • Нужен мониторинг изменений (цены, наличие, рейтинги).
  • Сбор обучающих датасетов.

Когда лучше НЕ парсить:

  • Есть официальный API — используйте его (стабильнее и легально).
  • Данные защищены авторским правом и нет лицензии.
  • Объём нагрузки навредит сайту.

Архитектура любого парсера:

┌──────────┐   ┌──────────┐   ┌────────────┐   ┌──────────┐   ┌─────────┐
│ Источник │ → │  Загруз- │ → │  Парсинг   │ → │ Валидация│ → │Хранение │
│  (URL)   │   │   чик    │   │ (extract)  │   │ (clean)  │   │ (store) │
└──────────┘   └──────────┘   └────────────┘   └──────────┘   └─────────┘
     ↑              │ retry         │ schema         │ dedupe       │
     └──────────────┘ rate-limit    │ normalize      │              │

Хороший парсер разделяет эти слои: загрузку, извлечение, валидацию и сохранение — так его легко тестировать и поддерживать.

⚠️ Перед началом всегда читайте robots.txt и Terms of Service (см. Урок 15).


🎓 Урок 2. Как устроен веб

Жизненный цикл запроса:

Клиент ──HTTP-запрос──▶ DNS ──▶ TCP/TLS ──▶ Сервер
Клиент ◀─HTTP-ответ─── (статус + заголовки + тело)

HTTP-методы:

Метод Назначение Идемпотентен
GET Получить данные
POST Создать / отправить
PUT Полностью заменить
PATCH Частично обновить
DELETE Удалить

Ключевые коды ответов для парсера:

  • 200 — OK, 204 — нет содержимого
  • 301/302 — редирект (следите за Location)
  • 401/403 — нужна авторизация / доступ запрещён
  • 404 — не найдено
  • 429 — слишком много запросов (читайте заголовок Retry-After!)
  • 5xx — ошибка сервера (имеет смысл повторить запрос)

Важные заголовки:

User-Agent      — кто делает запрос (браузер/бот)
Cookie          — сессия, авторизация
Referer         — откуда пришёл запрос
Accept          — какой формат ждём (text/html, application/json)
Content-Type    — формат тела запроса
Retry-After     — через сколько повторить (при 429/503)

DOM как дерево:

<article class="product" data-id="42">
  <h2 class="title">Ноутбук Pro</h2>
  <span class="price" data-currency="RUB">59990</span>
  <a href="/product/42">Подробнее</a>
</article>

Здесь data-id и data-currency — атрибуты данных, которые часто удобнее парсить, чем видимый текст.


🎓 Урок 3. Библиотека requests

Базовые запросы

import requests

r = requests.get("https://httpbin.org/get", timeout=10)
print(r.status_code, r.elapsed.total_seconds())
print(r.json())          # если ответ JSON
print(r.headers['Content-Type'])

Сессия с настройками по умолчанию (рекомендуется)

import requests

def make_session() -> requests.Session:
    s = requests.Session()
    s.headers.update({
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                      "AppleWebKit/537.36 (KHTML, like Gecko) "
                      "Chrome/120.0 Safari/537.36",
        "Accept-Language": "ru-RU,ru;q=0.9",
    })
    return s

session = make_session()
resp = session.get("https://example.com", timeout=(3.05, 27))  # (connect, read)

💡 Таймаут — кортеж (connect, read). Никогда не делайте запрос без таймаута: зависший сокет повесит весь парсер.

Автоматические ретраи с backoff

from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

retry = Retry(
    total=5,
    backoff_factor=1,                 # 1s, 2s, 4s, 8s...
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["GET", "POST"],
    respect_retry_after_header=True,
)
session.mount('https://', HTTPAdapter(max_retries=retry))
session.mount('http://', HTTPAdapter(max_retries=retry))

Потоковая загрузка больших файлов

with session.get("https://example.com/big.csv", stream=True) as r:
    r.raise_for_status()
    with open("big.csv", "wb") as f:
        for chunk in r.iter_content(chunk_size=8192):
            f.write(chunk)

Авторизация

# Basic Auth
session.get(url, auth=("user", "password"))

# Bearer-токен
session.headers["Authorization"] = "Bearer <token>"

# Формы логина (CSRF-токен часто прячут в HTML)
login_page = session.get("https://example.com/login")
# ... извлекаем csrf из login_page ...
session.post("https://example.com/login",
             data={"username": "u", "password": "p", "csrf": csrf})

🎓 Урок 4. Парсинг HTML с BeautifulSoup

Поиск элементов — все способы

from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")

# По тегу/классу/id
soup.find("h1")
soup.find_all("div", class_="product", limit=10)
soup.find(id="main")

# По нескольким классам и атрибутам
soup.find_all("a", class_=["btn", "primary"])
soup.find_all(attrs={"data-id": True})            # есть атрибут data-id
soup.find_all("input", attrs={"type": "hidden"})

# CSS-селекторы (мощно)
soup.select("div.card > h2.title")
soup.select("article[data-id] span.price")
soup.select_one("nav a:nth-of-type(2)")

Безопасное извлечение (защита от None)

def safe_text(node, selector, default=None):
    """Возвращает текст по CSS-селектору или default, не падая на None."""
    el = node.select_one(selector)
    return el.get_text(strip=True) if el else default

def safe_attr(node, selector, attr, default=None):
    el = node.select_one(selector)
    return el.get(attr, default) if el else default

# Использование
for card in soup.select('article.product'):
    product = {
        "title": safe_text(card, "h2.title"),
        "price": safe_text(card, "span.price", default="0"),
        "url": safe_attr(card, "a", "href"),
    }
    print(product)

💡 90% «случайных» падений парсера — это AttributeError: NoneType has no attribute. Хелперы safe_* решают проблему раз и навсегда.

Навигация по дереву

price = soup.find("span", class_="price")
price.parent                 # родитель
price.find_parent('article') # ближайший предок-article
price.next_sibling           # следующий узел
price.find_next('a')         # следующая ссылка в документе
[c for c in price.parents]   # все предки до корня

Извлечение таблиц целиком

import pandas as pd

# pandas сам распарсит все <table> на странице в список DataFrame
tables = pd.read_html(html)
df = tables[0]

🎓 Урок 5. CSS-селекторы и XPath

Шпаргалка соответствий

Задача CSS XPath
По классу .price //*[@class="price"]
Прямой потомок div > p //div/p
Любой потомок div p //div//p
N-й элемент li:nth-child(2) (//li)[2]
По атрибуту a[href] //a[@href]
Атрибут начинается с a[href^="/p"] //a[starts-with(@href,"/p")]
Содержит текст //*[contains(text(),"X")]
Родитель //span/..

lxml + XPath (быстро и мощно)

from lxml import html

tree = html.fromstring(page_html)

# Извлекаем сразу списками
titles = tree.xpath('//article[@class="product"]/h2/text()')
prices = tree.xpath('//span[@class="price"]/text()')
links  = tree.xpath('//a[contains(@class,"more")]/@href')

# Относительные пути от найденного узла
for card in tree.xpath('//article[@class="product"]'):
    title = card.xpath('.//h2/text()')          # точка = относительно card
    price = card.xpath('.//span[@class="price"]/text()')
    print(title, price)

# XPath по тексту и осям
tree.xpath('//th[text()="Цена"]/following-sibling::td/text()')

💡 XPath-ось following-sibling незаменима для таблиц «ключ-значение», где данные лежат в соседней ячейке.

selectolax — когда нужна скорость

from selectolax.parser import HTMLParser

tree = HTMLParser(page_html)
for node in tree.css('article.product'):
    title = node.css_first('h2.title')
    print(title.text(strip=True) if title else None)

🎓 Урок 6. Регулярные выражения

Регулярки — для финальной очистки текста, НЕ для парсинга структуры HTML.

import re

text = "Тел: +7 (999) 123-45-67, email: a.user@mail.ru, цена 59 990 ₽, скидка 15%"

# Именованные группы — читаемее
phone = re.search(r"\+7\s?\(?(?P<code>\d{3})\)?[\s-]?(?P<num>\d{3}[\s-]?\d{2}[\s-]?\d{2})", text)
if phone:
    print(phone.group("code"), phone.group("num"))

# Цена: убираем пробелы-разделители и приводим к int
raw = re.search(r"\d[\d\s]*\d", text).group()
price = int(re.sub(r"\s", "", raw))   # 59990

# Все email
emails = re.findall(r"[\w.+-]+@[\w-]+\.[\w.-]+", text)

# Компиляция + флаги для многократного использования
TAG_RE = re.compile(r"<[^>]+>")
clean = TAG_RE.sub("", "<b>Привет</b>")     # удалить теги: Привет

Полезные приёмы:

re.findall(r"(?<=id=)\d+", "id=42 id=99")   # lookbehind -> [42, 99]
re.split(r"[,;]\s*", "a, b; c")             # ["a","b","c"]

🎓 Урок 7. Работа с API и JSON

Часто данные уже есть в JSON — откройте DevTools → Network → Fetch/XHR и найдите запрос, который возвращает нужные данные. Это в разы быстрее и стабильнее парсинга HTML.

Базовый цикл с пагинацией

import requests

def fetch_all(base_url: str, session: requests.Session) -> list[dict]:
    items, page = [], 1
    while True:
        r = session.get(base_url, params={'page': page, 'per_page': 100}, timeout=15)
        r.raise_for_status()
        data = r.json()
        if not data['results']:
            break
        items.extend(data['results'])
        # курсорная пагинация:
        if not data.get('next'):
            break
        page += 1
    return items

Безопасный доступ к вложенному JSON

def deep_get(d: dict, path: str, default=None):
    """deep_get(obj, "user.address.city")"""
    cur = d
    for key in path.split("."):
        if isinstance(cur, dict) and key in cur:
            cur = cur[key]
        else:
            return default
    return cur

city = deep_get(payload, "user.address.city", default="—")

GraphQL и POST-API

query = {
    "query": "{ products(first: 50) { edges { node { title price } } } }"
}
r = session.post("https://shop.example/graphql", json=query, timeout=15)
nodes = [e['node'] for e in r.json()['data']['products']['edges']]

💡 Если API требует подпись/токен — он почти всегда виден в заголовках запроса в DevTools. Скопируйте запрос как cURL (правой кнопкой → Copy as cURL) и преобразуйте в Python через сайт curlconverter.com.


🎓 Урок 8. Динамические сайты

Если контент рендерится JS — requests вернёт пустой каркас. Нужен реальный браузер.

Playwright (рекомендуется)

from playwright.sync_api import sync_playwright

def scrape_spa(url: str) -> list[str]:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        ctx = browser.new_context(
            user_agent="Mozilla/5.0 ... Chrome/120.0 Safari/537.36",
            viewport={'width': 1920, 'height': 1080},
            locale='ru-RU',
        )
        page = ctx.new_page()
        page.goto(url, wait_until='networkidle', timeout=30000)

        # Ждём конкретный селектор, а не фиксированный sleep
        page.wait_for_selector('article.product')

        # Бесконечный скролл
        prev = 0
        while True:
            page.mouse.wheel(0, 5000)
            page.wait_for_timeout(1000)
            count = page.locator('article.product').count()
            if count == prev:
                break
            prev = count

        titles = page.locator('h2.title').all_inner_texts()
        browser.close()
        return titles

Перехват сетевых ответов (мощнейшая техника)

# Вместо парсинга DOM — ловим JSON, который грузит сама страница
captured = []

def handle_response(response):
    if '/api/products' in response.url:
        captured.append(response.json())

page.on('response', handle_response)
page.goto(url, wait_until='networkidle')
# captured теперь содержит чистый JSON без всякого HTML

Selenium (если требуется legacy-совместимость)

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

opts = webdriver.ChromeOptions()
opts.add_argument('--headless=new')
opts.add_argument('--disable-blink-features=AutomationControlled')
driver = webdriver.Chrome(options=opts)
try:
    driver.get(url)
    el = WebDriverWait(driver, 15).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, 'article.product')))
    for p in driver.find_elements(By.CSS_SELECTOR, 'article.product'):
        print(p.find_element(By.CSS_SELECTOR, '.title').text)
finally:
    driver.quit()

Порядок выбора инструмента: скрытый API (Урок 7) → перехват ответов в Playwright → парсинг DOM. Браузер в 50–100 раз медленнее HTTP-запроса.


🎓 Урок 9. Асинхронный парсинг

Для тысяч страниц asyncio + aiohttp дают кратный прирост скорости.

Полноценный асинхронный воркер с ограничением скорости

import asyncio
import aiohttp
from bs4 import BeautifulSoup

class AsyncScraper:
    def __init__(self, concurrency: int = 10, delay: float = 0.2):
        self.sem = asyncio.Semaphore(concurrency)
        self.delay = delay

    async def fetch(self, session: aiohttp.ClientSession, url: str) -> str | None:
        async with self.sem:                       # ограничение параллелизма
            try:
                async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as r:
                    if r.status == 429:
                        await asyncio.sleep(int(r.headers.get('Retry-After', 5)))
                        return await self.fetch(session, url)
                    r.raise_for_status()
                    await asyncio.sleep(self.delay)
                    return await r.text()
            except (aiohttp.ClientError, asyncio.TimeoutError) as e:
                print(f'[!] {url}: {e}')
                return None

    def parse(self, html: str) -> dict:
        soup = BeautifulSoup(html, 'lxml')
        h1 = soup.find('h1')
        return {'title': h1.get_text(strip=True) if h1 else None}

    async def run(self, urls: list[str]) -> list[dict]:
        connector = aiohttp.TCPConnector(limit=20, ttl_dns_cache=300)
        async with aiohttp.ClientSession(connector=connector) as session:
            htmls = await asyncio.gather(*(self.fetch(session, u) for u in urls))
        return [self.parse(h) for h in htmls if h]

urls = [f'https://example.com/page/{i}' for i in range(1, 1001)]
results = asyncio.run(AsyncScraper(concurrency=15).run(urls))
print(f'Собрано: {len(results)}')

Прогресс-бар для длинных задач

from tqdm.asyncio import tqdm_asyncio

htmls = await tqdm_asyncio.gather(*(self.fetch(s, u) for u in urls))

⚠️ Async ускоряет I/O, но не парсинг CPU. Тяжёлый разбор HTML выносите в loop.run_in_executor или ProcessPoolExecutor.


🎓 Урок 10. Scrapy

Scrapy — полноценный фреймворк: очереди, ретраи, throttling, пайплайны, экспорт «из коробки».

scrapy startproject shop && cd shop
scrapy genspider books books.toscrape.com

Паук с пагинацией и переходом на карточку

import scrapy

class BooksSpider(scrapy.Spider):
    name = 'books'
    start_urls = ['https://books.toscrape.com/']
    custom_settings = {
        'DOWNLOAD_DELAY': 0.5,
        'AUTOTHROTTLE_ENABLED': True,
        'CONCURRENT_REQUESTS': 8,
    }

    def parse(self, response):
        for book in response.css('article.product_pod'):
            detail_url = book.css('h3 a::attr(href)').get()
            yield response.follow(detail_url, callback=self.parse_book)
        next_page = response.css('li.next a::attr(href)').get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)

    def parse_book(self, response):
        yield {
            'title': response.css('h1::text').get(),
            'price': response.css('p.price_color::text').get(),
            'stock': response.css('p.availability::text').re_first(r'' + bs+'d+'),
            'desc':  response.css('#product_description + p::text').get(),
        }

Item Pipeline — валидация и очистка

# pipelines.py
from itemadapter import ItemAdapter

class PricePipeline:
    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        price = adapter.get('price', '')
        adapter['price'] = float(price.replace('£', '').strip() or 0)
        if adapter['price'] <= 0:
            from scrapy.exceptions import DropItem
            raise DropItem('Нет цены')
        return item

# settings.py
ITEM_PIPELINES = {'shop.pipelines.PricePipeline': 300}
scrapy crawl books -o books.json --logfile scrape.log

🎓 Урок 11. Обход защит

Легальные техники аккуратного и устойчивого парсинга.

Ротация User-Agent и заголовков

from fake_useragent import UserAgent
ua = UserAgent()

def realistic_headers() -> dict:
    return {
        'User-Agent': ua.random,
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8',
        'Accept-Encoding': 'gzip, deflate, br',
        'Connection': 'keep-alive',
        'Upgrade-Insecure-Requests': '1',
    }

Пул прокси с ротацией и проверкой

import random, itertools

class ProxyPool:
    def __init__(self, proxies: list[str]):
        self.proxies = proxies
        self.bad = set()

    def get(self) -> str | None:
        alive = [p for p in self.proxies if p not in self.bad]
        return random.choice(alive) if alive else None

    def mark_bad(self, proxy: str):
        self.bad.add(proxy)

pool = ProxyPool(['http://1.2.3.4:8080', 'http://5.6.7.8:3128'])
proxy = pool.get()
try:
    r = requests.get(url, proxies={'http': proxy, 'https': proxy}, timeout=10)
except requests.RequestException:
    pool.mark_bad(proxy)

Вежливый rate-limiting

import time, random

class RateLimiter:
    def __init__(self, rps: float = 1.0):
        self.min_interval = 1.0 / rps
        self.last = 0.0

    def wait(self):
        elapsed = time.monotonic() - self.last
        sleep_for = self.min_interval - elapsed + random.uniform(0, 0.3)
        if sleep_for > 0:
            time.sleep(sleep_for)
        self.last = time.monotonic()

Маскировка автоматизации в браузере

# Playwright: убираем navigator.webdriver
context.add_init_script(
    "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})")
# Либо используйте playwright-stealth / undetected-chromedriver

⚠️ Уважайте robots.txt, лимиты и закон. Обход CAPTCHA и защит против воли владельца сайта может быть незаконным. Эти техники — для снижения нагрузки и стабильности, а не для атак.


🎓 Урок 12. Хранение данных

Валидация через dataclass / pydantic

from pydantic import BaseModel, field_validator, HttpUrl

class Product(BaseModel):
    title: str
    price: float
    url: HttpUrl
    in_stock: bool = True

    @field_validator('price')
    @classmethod
    def price_positive(cls, v):
        if v < 0:
            raise ValueError('Цена не может быть отрицательной')
        return round(v, 2)

# Невалидные записи отсеются автоматически
raw = {'title': 'Книга', 'price': '19.99', 'url': 'https://shop/x'}
product = Product(**raw)        # price приведётся к float
print(product.model_dump())

CSV / JSON Lines (потоковая запись)

import csv, json

# JSON Lines — удобно дозаписывать построчно, не держа всё в памяти
with open('data.jsonl', 'a', encoding='utf-8') as f:
    for item in items:
        f.write(json.dumps(item, ensure_ascii=False) + '\n')

# CSV
with open('data.csv', 'w', newline='', encoding='utf-8-sig') as f:
    w = csv.DictWriter(f, fieldnames=['title', 'price'])
    w.writeheader()
    w.writerows(items)

💡 utf-8-sig добавляет BOM — Excel корректно откроет кириллицу.

SQLite с защитой от дублей (UPSERT)

import sqlite3

conn = sqlite3.connect('shop.db')
conn.execute('''
    CREATE TABLE IF NOT EXISTS products (
        url   TEXT PRIMARY KEY,
        title TEXT,
        price REAL,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )''')

conn.executemany('''
    INSERT INTO products (url, title, price) VALUES (:url, :title, :price)
    ON CONFLICT(url) DO UPDATE SET
        title=excluded.title, price=excluded.price,
        updated_at=CURRENT_TIMESTAMP
''', items)
conn.commit()

pandas для постобработки

import pandas as pd

df = pd.DataFrame(items)
df = df.drop_duplicates(subset='url').dropna(subset=['price'])
df['price'] = pd.to_numeric(df['price'], errors='coerce')
df.to_parquet('data.parquet')      # компактно и быстро для больших данных

🎓 Урок 13. Надёжность и обработка ошибок

Декоратор ретраев через tenacity

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import requests

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=2, max=30),
    retry=retry_if_exception_type(requests.RequestException),
    reraise=True,
)
def fetch(url: str, session: requests.Session) -> str:
    r = session.get(url, timeout=15)
    r.raise_for_status()
    return r.text

Структурное логирование (loguru)

from loguru import logger

logger.add('scraper.log', rotation='10 MB', retention='7 days', level='INFO')

logger.info('Старт: {} URL', len(urls))
try:
    html = fetch(url, session)
except Exception as e:
    logger.error('Ошибка на {}: {}', url, e)

Чекпоинты — продолжение после сбоя

import json, os

def load_done(path='done.json') -> set:
    return set(json.load(open(path))) if os.path.exists(path) else set()

def save_done(done: set, path='done.json'):
    json.dump(list(done), open(path, 'w'))

done = load_done()
for url in urls:
    if url in done:
        continue            # пропускаем уже обработанное
    process(url)
    done.add(url)
    if len(done) % 50 == 0:
        save_done(done)     # периодически сохраняем прогресс

💡 Чекпоинты обязательны для долгих задач: если парсер упадёт на 9000-й из 10000 страниц, вы не начнёте с нуля.


🎓 Урок 14. Тестирование парсеров

Парсеры ломаются, когда сайт меняет вёрстку. Тесты на сохранённом HTML ловят это мгновенно.

# tests/test_parser.py
import pytest
from myparser import parse_product

@pytest.fixture
def sample_html():
    return open('tests/fixtures/product.html', encoding='utf-8').read()

def test_parse_extracts_title(sample_html):
    result = parse_product(sample_html)
    assert result['title'] == 'Ноутбук Pro'
    assert result['price'] == 59990.0

def test_parse_handles_missing_price():
    html = '<article><h2>Без цены</h2></article>'
    result = parse_product(html)
    assert result['price'] is None      # не падает, возвращает None

Мокаем HTTP, чтобы не ходить в сеть

import responses, requests

@responses.activate
def test_fetch_retries_on_500():
    responses.add(responses.GET, 'https://x.com', status=500)
    responses.add(responses.GET, 'https://x.com', body='OK', status=200)
    # ваш fetch с ретраями должен вернуть 'OK'

💡 Сохраняйте «эталонные» HTML-страницы в tests/fixtures/. При изменении сайта обновляйте их и сразу видите, что сломалось.


🎓 Урок 15. Этика и законность

Чек-лист ответственного парсинга:

  • ✅ Читайте robots.txt и Terms of Service.
  • ✅ Делайте задержки, не кладите сайт нагрузкой.
  • ✅ Кэшируйте — не запрашивайте одно и то же дважды.
  • ✅ Честный User-Agent, при возможности — с контактом.
  • ✅ Учитывайте GDPR / 152-ФЗ при персональных данных.
  • ❌ Не обходите платный доступ и авторизацию против воли владельца.
  • ❌ Не перепродавайте чужой контент, нарушая авторские права.
import urllib.robotparser as urp

rp = urp.RobotFileParser()
rp.set_url('https://example.com/robots.txt')
rp.read()
if rp.can_fetch('*', 'https://example.com/catalog'):
    ...   # парсим
crawl_delay = rp.crawl_delay('*')   # уважайте указанную задержку

⚖️ Это образовательный материал, а не юридическая консультация. При коммерческом использовании консультируйтесь с юристом.


🎓 Урок 16. Архитектура production-парсера

Соберём всё вместе — модульный, тестируемый, устойчивый парсер.

project/
├── scraper/
│   ├── __init__.py
│   ├── client.py      # HTTP: сессия, ретраи, rate-limit, прокси
│   ├── parsers.py     # чистые функции HTML -> dict (легко тестировать)
│   ├── models.py      # pydantic-схемы валидации
│   ├── storage.py     # запись в БД/файлы, дедупликация
│   └── pipeline.py    # оркестрация: fetch -> parse -> validate -> store
├── tests/
│   └── fixtures/
├── config.py          # настройки, секреты из переменных окружения
└── main.py
# pipeline.py — оркестратор
from loguru import logger
from .client import HttpClient
from .parsers import parse_product
from .models import Product
from .storage import Storage

class Pipeline:
    def __init__(self, client: HttpClient, storage: Storage):
        self.client = client
        self.storage = storage

    def process(self, url: str) -> None:
        html = self.client.get(url)          # ретраи + лимиты внутри
        if not html:
            return
        raw = parse_product(html)            # чистая функция
        try:
            product = Product(**raw)         # валидация
        except ValueError as e:
            logger.warning('Невалидно {}: {}', url, e)
            return
        self.storage.upsert(product.model_dump())

    def run(self, urls: list[str]) -> None:
        for url in urls:
            try:
                self.process(url)
            except Exception as e:
                logger.exception('Сбой на {}: {}', url, e)

Принципы: загрузка, парсинг, валидация и хранение разделены. Парсеры — чистые функции (вход HTML → выход dict), их легко тестировать без сети. Секреты — в переменных окружения, а не в коде.


🛠 Практические проекты

От простого к сложному. Все площадки ниже легальны для обучения.

Уровень Проект Навыки Площадка
🟢 Парсер книг requests + BeautifulSoup, пагинация books.toscrape.com
🟢 Цитаты + авторы переход по ссылкам, связи quotes.toscrape.com
🟡 Тестирование запросов заголовки, статусы, cookies httpbin.org
🟡 Мониторинг цен расписание, дифф, уведомления свой тестовый сайт
🟡 Парсер через скрытый API DevTools, JSON, пагинация открытые публичные API
🔴 Async-агрегатор новостей aiohttp, дедуп, RSS публичные RSS
🔴 SPA на Playwright перехват ответов, скролл демо-SPA
🔴 Scrapy + pipelines очереди, валидация, экспорт books.toscrape.com

Готовый мини-проект «Мониторинг цен» с уведомлением об изменении

import json, os, requests
from bs4 import BeautifulSoup

STATE = 'prices.json'

def get_price(url: str) -> float:
    html = requests.get(url, timeout=10,
                        headers={'User-Agent': 'Mozilla/5.0'}).text
    soup = BeautifulSoup(html, 'lxml')
    raw = soup.select_one('.price').get_text(strip=True)
    return float(''.join(c for c in raw if c.isdigit() or c == '.'))

def load() -> dict:
    return json.load(open(STATE)) if os.path.exists(STATE) else {}

def check(url: str):
    old = load()
    new_price = get_price(url)
    if url in old and new_price < old[url]:
        print(f'📉 Цена упала: {old[url]} -> {new_price}')
    old[url] = new_price
    json.dump(old, open(STATE, 'w'))

check('https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html')

⚠️ Антипаттерны и частые ошибки

❌ Ошибка ✅ Как правильно
Запрос без timeout Всегда задавайте (connect, read)
tag.text без проверки на None Хелперы safe_text/safe_attr
Парсинг HTML регулярками BeautifulSoup / lxml / selectolax
Фиксированный time.sleep(5) для ожидания JS wait_for_selector
Браузер там, где хватит HTTP Сначала ищите скрытый API
Хранение всего в памяти Потоковая запись (JSONL) + чекпоинты
Хардкод User-Agent/токенов Конфиг + переменные окружения
Игнор 429/Retry-After Backoff и уважение лимитов
Нет дедупликации PRIMARY KEY / drop_duplicates
Парсер без тестов pytest на сохранённых фикстурах

📖 Полезные ресурсы

Документация:

Площадки для практики:

Инструменты:

  • DevTools → Network (поиск скрытых API)
  • regex101.com — отладка регулярок
  • curlconverter.com — cURL → Python
  • Insomnia / Postman — работа с API

🚀 Продвинутые темы с практикой

Этот раздел — для тех, кто уже освоил основы и хочет выйти на production-уровень. Каждая тема сопровождается практическим заданием.

1. Обход TLS/JA3-фингерпринта

Многие сайты определяют ботов по TLS-фингерпринту (JA3), который у requests отличается от реального браузера. Библиотека curl_cffi умеет имитировать TLS-отпечаток настоящего браузера.

from curl_cffi import requests

# Имитируем TLS-фингерпринт Chrome
resp = requests.get(
    "https://tls.peet.ws/api/all",
    impersonate="chrome120",
    timeout=20,
)
print(resp.json()["tls"]["ja3"])

🛠 Практика: сравните JA3-фингерпринт обычного requests и curl_cffi на сервисе tls.peet.ws. Найдите сайт, который блокирует первый, но пропускает второй.

2. Распределённый парсинг через очередь Redis

Когда одной машины мало, задачи раскладывают в очередь, а несколько воркеров разбирают её параллельно.

import redis, json

r = redis.Redis(host="localhost", port=6379, db=0)

# Продюсер: кладём URL-ы в очередь
def enqueue(urls):
    for url in urls:
        r.lpush("scrape:queue", json.dumps({"url": url}))

# Воркер: забираем задачи и обрабатываем
def worker():
    while True:
        _, raw = r.brpop("scrape:queue")
        task = json.loads(raw)
        process(task["url"])  # ваша логика парсинга

🛠 Практика: запустите 3 воркера в разных процессах и убедитесь, что 100 URL обрабатываются без дублей.

3. Кэширование запросов

requests-cache прозрачно кэширует ответы — это ускоряет разработку и бережёт сайт от лишней нагрузки.

import requests_cache

session = requests_cache.CachedSession(
    "http_cache",
    expire_after=3600,        # кэш живёт 1 час
    allowable_methods=["GET"],
)
resp = session.get("https://example.com")
print(resp.from_cache)  # False при первом запросе, True при повторном

🛠 Практика: оберните свой парсер в CachedSession и измерьте время повторного прогона.

4. Мониторинг и метрики

Production-парсеру нужны метрики: сколько запросов, ошибок, какова средняя задержка.

from dataclasses import dataclass, field
import time

@dataclass
class Metrics:
    requests: int = 0
    errors: int = 0
    started: float = field(default_factory=time.time)

    def hit(self): self.requests += 1
    def fail(self): self.errors += 1

    def report(self):
        elapsed = time.time() - self.started
        rps = self.requests / elapsed if elapsed else 0
        return f"{self.requests} запросов, {self.errors} ошибок, {rps:.1f} rps"

m = Metrics()

🛠 Практика: добавьте Metrics в свой парсер и выводите отчёт каждые 30 секунд.

5. Инкрементальный парсинг по хешу контента

Чтобы не обрабатывать одно и то же повторно, сохраняйте хеш контента и сравнивайте при следующем запуске.

import hashlib

def content_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

seen = {}  # url -> hash

def changed(url: str, html: str) -> bool:
    h = content_hash(html)
    if seen.get(url) == h:
        return False
    seen[url] = h
    return True

🛠 Практика: реализуйте сохранение хешей в SQLite, чтобы состояние переживало перезапуск.

6. Продвинутые антипаттерны

Антипаттерн Почему плохо Как правильно
Парсинг без time.sleep/лимитов Бан по IP, нагрузка на сайт Rate limiting, случайные задержки
Хранение всего в памяти OOM на больших объёмах Стриминг, запись в БД пачками
Один try/except на всё Скрывает реальные ошибки Точечная обработка по типам
Хардкод селекторов Ломается при редизайне Конфиг + мониторинг изменений

🗺 Что учить дальше

После этого курса логично двигаться в смежные области — карты ниже помогут спланировать путь:

🧰 Продвинутые приёмы парсинга

Набор практичных приёмов, которые экономят часы и делают парсер быстрее и надёжнее.

Когда сайт начинает блокировать — повышайте «незаметность» постепенно, от дешёвого к дорогому:

Простой requests
    │  заблокировали?
    ▼
Реалистичные заголовки + ротация User-Agent
    │  всё ещё блок?
    ▼
Задержки + rate-limit (вести себя как человек)
    │
    ▼
Ротация прокси (раздать нагрузку по IP)
    │
    ▼
curl_cffi — имитация TLS/JA3-отпечатка браузера
    │
    ▼
Реальный браузер (Playwright) + маскировка automation

💡 Не прыгайте сразу к браузеру: он в 50–100 раз медленнее. Поднимайтесь по лестнице только до нужной ступени.

1. Находим данные через sitemap.xml

Вместо обхода пагинации часто проще взять все URL прямо из карты сайта.

import requests
from lxml import etree

def urls_from_sitemap(sitemap_url: str) -> list[str]:
    xml = requests.get(sitemap_url, timeout=15).content
    root = etree.fromstring(xml)
    ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
    # sitemap может ссылаться на вложенные карты
    nested = root.xpath("//s:sitemap/s:loc/text()", namespaces=ns)
    if nested:
        out = []
        for sm in nested:
            out += urls_from_sitemap(sm)
        return out
    return root.xpath("//s:url/s:loc/text()", namespaces=ns)

🛠 Практика: соберите все URL товаров сайта из /sitemap.xml, минуя листинги и пагинацию.

2. Извлечение структурированных данных (JSON-LD)

Многие сайты кладут готовые данные в <script type="application/ld+json"> ради SEO — это чистый JSON без парсинга вёрстки.

import json
from bs4 import BeautifulSoup

def extract_jsonld(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    blocks = []
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "{}")
            blocks.extend(data if isinstance(data, list) else [data])
        except json.JSONDecodeError:
            continue
    return blocks

# Часто здесь лежит готовый Product с ценой, рейтингом и наличием
products = [b for b in extract_jsonld(html) if b.get("@type") == "Product"]

🛠 Практика: найдите товар на любом маркетплейсе и достаньте цену из JSON-LD вместо CSS-селектора.

3. Устойчивые селекторы вместо хрупких

Селекторы по сгенерированным классам (css-1a2b3c) ломаются при каждом релизе. Цепляйтесь за стабильные атрибуты.

# ❌ Хрупко — классы меняются при пересборке фронтенда
soup.select_one("div.css-1a2b3c > span.sc-9f8e7d")

# ✅ Надёжно — атрибуты данных и семантика живут дольше
soup.select_one("[data-testid='product-price']")
soup.select_one("[itemprop='price']")
soup.find("meta", attrs={"property": "og:title"})["content"]

🛠 Практика: перепишите один хрупкий селектор своего парсера на data-*/itemprop и проверьте на двух версиях страницы.

4. Переиспользование сессии браузера (cookies)

Логиниться в браузере на каждый запуск — дорого. Сохраните состояние один раз и подгружайте его.

from playwright.sync_api import sync_playwright

# Первый запуск: логинимся вручную/программно и сохраняем состояние
with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    ctx = browser.new_context()
    page = ctx.new_page()
    page.goto("https://example.com/login")
    # ... выполняем вход ...
    ctx.storage_state(path="state.json")   # сохранили cookies + localStorage
    browser.close()

# Последующие запуски: стартуем уже залогиненными
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    ctx = browser.new_context(storage_state="state.json")
    # сразу открываем закрытые страницы

🛠 Практика: сохраните storage_state после входа и убедитесь, что повторный запуск не требует логина.

5. Перенос cookies из Playwright в requests

Браузер нужен только чтобы пройти JS-проверку и получить cookies — дальше быстрее качать обычным requests.

import requests

# cookies получены из ctx.cookies() в Playwright
browser_cookies = ctx.cookies()

session = requests.Session()
for c in browser_cookies:
    session.cookies.set(c["name"], c["value"], domain=c["domain"])

# Теперь тяжёлые страницы тянем через requests — в десятки раз быстрее
resp = session.get("https://example.com/api/items", timeout=15)

🛠 Практика: пройдите JS-проверку браузером один раз, затем спарсите 50 страниц через requests с этими cookies.

6. Обход honeypot-ловушек

Сайты прячут невидимые ссылки/поля-приманки: переход по ним или их заполнение выдаёт бота.

def is_honeypot(node) -> bool:
    style = (node.get("style") or "").replace(" ", "").lower()
    cls = " ".join(node.get("class", [])).lower()
    return (
        "display:none" in style
        or "visibility:hidden" in style
        or node.get("hidden") is not None
        or "hidden" in cls or "trap" in cls
    )

# Берём только видимые ссылки
links = [a for a in soup.select("a[href]") if not is_honeypot(a)]

🛠 Практика: перед переходом по ссылкам отфильтруйте скрытые элементы и логируйте, сколько ловушек отсеяно.

7. Параллельный парсинг по процессам (CPU-bound)

asyncio ускоряет сеть, но разбор тысяч больших HTML упирается в CPU. Здесь помогает ProcessPoolExecutor.

from concurrent.futures import ProcessPoolExecutor
from functools import partial

def parse_html(html: str) -> dict:
    ...  # тяжёлый разбор: lxml/regex/нормализация

def parse_many(htmls: list[str]) -> list[dict]:
    with ProcessPoolExecutor(max_workers=4) as ex:
        return list(ex.map(parse_html, htmls, chunksize=20))

🛠 Практика: измерьте время разбора 1000 страниц в один поток и через ProcessPoolExecutor.


📦 Библиотеки для парсинга: что выбрать и почему

Не существует «лучшей» библиотеки — есть подходящая под задачу. Ниже — сравнение и практические рекомендации.

Каждая библиотека закрывает свой слой пайплайна — комбинируйте их по задаче:

┌─────────────────────────────────────────────────────────────┐
│  ЗАГРУЗКА          requests · httpx · aiohttp · curl_cffi     │
│  (получить HTML)   браузер: Playwright · Selenium            │
├─────────────────────────────────────────────────────────────┤
│  ПАРСИНГ           BeautifulSoup · lxml · selectolax · parsel │
│  (HTML → данные)   regex — только для финальной очистки       │
├─────────────────────────────────────────────────────────────┤
│  ВАЛИДАЦИЯ         pydantic · dataclasses                     │
├─────────────────────────────────────────────────────────────┤
│  ХРАНЕНИЕ          sqlite · pandas · csv/jsonl                │
└─────────────────────────────────────────────────────────────┘
         ▲ оркестрация всего цикла: Scrapy (фреймворк)

HTTP-клиенты (загрузка страниц)

Библиотека Сильные стороны Когда выбирать
requests Простой синхронный API, огромное комьюнити Скрипты, небольшие объёмы, обучение
httpx Sync + async, HTTP/2, таймауты «из коробки» Современные проекты, нужен async и HTTP/2
aiohttp Зрелый async-клиент, высокая пропускная способность Тысячи страниц, asyncio-пайплайны
curl_cffi Имитация TLS/JA3-отпечатка браузера Сайты, блокирующие по TLS-фингерпринту

Вывод: начинайте с requests для простых задач; для масштаба переходите на httpx/aiohttp; curl_cffi — точечно против антибот-защит.

Парсеры HTML (извлечение данных)

Библиотека Скорость Удобство Когда выбирать
BeautifulSoup (+lxml) Средняя Очень высокое Старт, читаемый код, нестрогий HTML
lxml Высокая Среднее (XPath) Большие объёмы, нужен XPath
selectolax Очень высокая (C) Высокое (CSS) Миллионы страниц, узкое место — парсинг
parsel Высокая Высокое Экосистема Scrapy, CSS + XPath вместе

Вывод: BeautifulSoup — лучший выбор по умолчанию для читаемости. Когда парсинг становится узким местом по скорости — переходите на selectolax (в 5–10 раз быстрее) или lxml с XPath.

Браузерная автоматизация (динамические сайты, JS)

Библиотека Плюсы Минусы Когда выбирать
Playwright Быстрый, авто-ожидания, перехват сети, async Тяжелее HTTP Современный выбор для SPA
Selenium Зрелость, поддержка legacy Медленнее, многословнее Legacy-проекты, специфичные драйверы
undetected-chromedriver Маскировка автоматизации Только Chrome, нестабильность Жёсткие антибот-проверки

Вывод: для новых проектов берите Playwright — он быстрее и удобнее Selenium. Браузер используйте только когда данных нет в HTTP-ответе или скрытом API.

Фреймворки (полный цикл парсинга)

Инструмент Что даёт Когда выбирать
Scrapy Очереди, ретраи, throttling, пайплайны, экспорт Крупные краулеры, много правил и доменов
Свой пайплайн (requests + bs4) Полный контроль, минимум зависимостей Небольшие/средние задачи, кастомная логика

Вывод: Scrapy оправдан на масштабных краулерах с десятками правил; для одного-двух источников проще и гибче собрать свой модульный пайплайн.

🎯 Как выбрать за 30 секунд

Данные есть в HTML/JSON-ответе?
├─ Да  → requests / httpx + BeautifulSoup (или selectolax для скорости)
│        └─ Тысячи страниц? → aiohttp/httpx (async)
│        └─ Блок по TLS?    → curl_cffi
└─ Нет (рендерится JS) → сначала ищем скрытый API в DevTools
                         └─ Не нашли → Playwright (перехват ответов или DOM)

Десятки доменов и правил, нужен промышленный краулер? → Scrapy

🛠 Практика: возьмите один сайт и решите задачу двумя стеками — requests + BeautifulSoup и httpx + selectolax. Сравните скорость и читаемость кода.

🐞 Топ-20 ошибок при парсинге (из реальной практики)

Реальные грабли, на которые наступают почти все. Список — чтобы вы наступили на них меньше.

# Ошибка Что происходит Как избежать
1 Запрос без timeout Зависший сокет вешает весь парсер навсегда timeout=(connect, read) в каждом запросе
2 Игнор кода 429 и Retry-After Бан по IP за флуд Backoff + чтение заголовка Retry-After
3 tag.text без проверки на None AttributeError: NoneType на первой же нестандартной странице Хелперы safe_text/safe_attr
4 Парсинг HTML регулярками Ломается на вложенности и любой смене вёрстки BeautifulSoup / lxml / selectolax
5 Фиксированный time.sleep(5) для JS То рано (пусто), то медленно wait_for_selector по конкретному элементу
6 Браузер там, где хватит HTTP В 50–100 раз медленнее, жрёт ресурсы Сначала ищем скрытый API в DevTools
7 Один User-Agent по умолчанию requests Мгновенная блокировка как бота Реалистичные заголовки, ротация UA
8 Хранение всего результата в памяти OOM на больших объёмах Потоковая запись (JSONL) + чекпоинты
9 Нет дедупликации Дубли в данных, повторные запросы PRIMARY KEY / drop_duplicates
10 Хрупкие селекторы по css-1a2b3c Парсер мрёт после каждого релиза сайта data-*, itemprop, семантика
11 Игнор кодировки ответа Кракозябры вместо кириллицы resp.encoding / utf-8-sig для Excel
12 Парсинг render-каркаса SPA через requests Получаете пустой <div id="root"> Playwright или перехват JSON-ответов
13 Относительные ссылки «как есть» Битые URL: /page вместо полного urljoin(base, href)
14 Нет ретраев на 5xx и сетевые сбои Падение на случайной ошибке сервера tenacity / Retry с экспон. backoff
15 Один try/except на весь цикл Скрывает реальные ошибки, теряете данные Точечная обработка по типам исключений
16 Нет чекпоинтов в долгой задаче Падение на 9000-й из 10000 → старт с нуля Сохранение прогресса каждые N итераций
17 Логин в браузере на каждый запуск Медленно, риск капчи и бана Сохранить storage_state, переиспользовать
18 Переход по honeypot-ссылкам Сайт помечает вас ботом по скрытым ловушкам Фильтровать display:none/hidden
19 Параллелизм без ограничения DDoS сайта и бан за агрессию Semaphore / rate-limit, вежливые задержки
20 Нет тестов на сохранённом HTML Молча собираете мусор после смены вёрстки pytest на фикстурах, мониторинг изменений

Быстрый чек-лист перед запуском в production

[ ] Таймаут стоит на каждом запросе
[ ] Есть ретраи с backoff и уважение Retry-After
[ ] Реалистичные заголовки + ротация User-Agent
[ ] Безопасное извлечение (нет падений на None)
[ ] Данные валидируются (pydantic) и дедуплицируются
[ ] Прогресс сохраняется (чекпоинты)
[ ] Селекторы устойчивы (data-*/itemprop)
[ ] Есть логирование и метрики ошибок
[ ] Есть тесты на эталонном HTML
[ ] robots.txt и лимиты соблюдаются

🛠 Практика: прогоните свой текущий парсер по этому чек-листу и закройте каждый незакрытый пункт.


🗺 Дорожная карта обучения

🟢 Новичок      →  Уроки 1–6    (requests, BeautifulSoup, CSS/XPath, regex)
🟡 Средний      →  Уроки 7–11   (API, динамика, async, Scrapy, обход защит)
🔴 Продвинутый  →  Уроки 12–14  (валидация, надёжность, тесты)
⚫ Профи         →  Уроки 15–16  (этика + production-архитектура)

🤝 Вклад в проект

PR и идеи приветствуются! Открывайте Issue с предложениями новых уроков, примеров или площадок для практики.

📄 Лицензия

Материалы распространяются под лицензией MIT — используйте свободно в учебных целях.


⭐ Если курс оказался полезным — поставьте звезду репозиторию!

About

Python большой продвинутый бесплатный курс. все что нужно знать про парсинг

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors