Часть 1: Парсинг тарифов интернета и ТВ: Архитектура БД и бэкенд на SQL

На этапе тестирования я отобрал 6 городов (Москва, Санкт-Петербург, Новосибирск, Екатеринбург, Казань, Красноярск) и двух крупнейших провайдеров России - Ростелеком и Дом.ру. В планах масштабирование на большее количество городов и операторов.

Для парсинга тарифов у провайдеров применял связку Python + Selenium + BeautifulSoup, через хранимую процедуру складывал полученные данные в базу PostgreSQL.

Основные трудности, с которыми столкнулся при парсинге тарифов у провайдеров

Сайт Ростелекома оказался более «дружелюбным» к парсингу: все тарифы расположены на одной странице, каждый описан в отдельном HTML-блоке с понятной структурой, что оказалось удобным не только для парсинга, но и для пользователей которые хотят ознакомится с тарифами. Достаточно было один раз загрузить страницу и можно собирать данные. С Дом.ру ситуация сложилась иначе. Во-первых, сайт активно сопротивляется автоматическому сбору данных, без имитации поведения реального пользователя парсер моментально получал блокировку. Во-вторых, на карточке тарифа присутствует селектор выбора скорости, и цена динамически подгружается только после клика. Если просто взять исходный HTML, в нем останется цена за базовый вариант скорости, например, за 300 Мбит/с, даже если в интерфейсе выбрано 600 Мбит/с.

Шаг 1: Настройка Selenium Stealth

Чтобы сайт не заблокировал нас на второй секунде, используем selenium-stealth. Это маскирует автоматизированный браузер под обычного пользователя.

python

from selenium import webdriver
from selenium_stealth import stealth

options = webdriver.ChromeOptions()
options.add_argument("--headless=new") # Работаем в фоновом режиме
driver = webdriver.Chrome(options=options)

stealth(driver,
    languages=["ru-RU", "ru"],
    vendor="Google Inc.",
    platform="Win32",
    fix_hairline=True,
)

Шаг 2: Интерактивный парсинг (Кликаем и собираем)

Главная хитрость: нам нужно не просто собрать карточки, а проитерироваться по каждой кнопке внутри каждой карточки.

Важный нюанс: После клика через Selenium DOM обновляется. Чтобы BeautifulSoup увидел новые цены, объект супа нужно пересоздавать внутри цикла.

python

# Находим все карточки тарифов
cards_count = len(driver.find_elements(By.CSS_SELECTOR, 'article[aria-label="package-card"]'))

for card_idx in range(cards_count):
    # Находим кнопки переключения скоростей внутри карточки
    card_el = driver.find_elements(By.CSS_SELECTOR, 'article[aria-label="package-card"]')[card_idx]
    speed_btns = card_el.find_elements(By.CSS_SELECTOR, '.speed-selector li span')

    for btn_idx in range(len(speed_btns)):
        # Кликаем по кнопке (используем JS-клик для надежности)
        btn = driver.find_elements(By.CSS_SELECTOR, '...')[btn_idx]
        driver.execute_script("arguments[0].click();", btn)
        time.sleep(2) # Ждем асинхронного обновления цены

        # Магия: берем обновленный HTML и скармливаем его BeautifulSoup
        full_soup = BeautifulSoup(driver.page_source, 'html.parser')
        target_card = full_soup.find_all('article')[card_idx]
        
        # Теперь парсим актуальную цену и название
        price = target_card.find('span', class_='price').text

Шаг 3: Фильтры «Только интернет»

Часто тарифы без ТВ скрыты за отдельным чекбоксом. Чтобы не раздувать логику, мы просто оборачиваем наш парсинг в цикл по типам фильтров (bundle - с ТВ, mono - только интернет), принудительно кликая по чекбоксу перед началом сбора.

Шаг 4: Сохранение в PostgreSQL через процедуру

Зачем использовать хранимые процедуры (Stored Procedures) вместо простого INSERT?

  • Атомарность: Мы можем в одной транзакции пометить старые тарифы как архивные и добавить новые.

  • Чистота данных: Процедура может сама проверять дубликаты или обновлять цены, если тариф уже существует.

sql

CREATE OR REPLACE PROCEDURE upsert_tariff(
    p_city_id INT,
    p_provider_id INT,
    p_name TEXT,
    p_price INT,
    -- ... другие параметры
)
LANGUAGE plpgsql AS $$
BEGIN
    -- Обновляем, если такой тариф уже есть в этом городе у этого провайдера
    INSERT INTO tariffs (city_id, provider_id, name, price, last_updated)
    VALUES (p_city_id, p_provider_id, p_name, p_price, NOW())
    ON CONFLICT (city_id, provider_id, name) 
    DO UPDATE SET price = EXCLUDED.price, last_updated = NOW();
END;
$$;

Итоги

Связке Selenium + BeautifulSoup часто достается за медлительность. Но когда дело касается сложных сайтов-агрегаторов или, как в моём случае, сайтов провайдеров, где данные «размазаны» по кнопкам и табам - это надежный путь.

Мы получили:

  • Сбор всех вариаций скоростей одного тарифа.

  • Обход скрытых фильтров.

  • Надежное хранение с защитой от дублей в БД.

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

А как вы боретесь с динамическим контентом? Пишите в комментариях!

Комментарии (7)


  1. mixsture
    31.03.2026 18:34

    Зачем тут BeautifulSoup? вы как без него искали довольно простые элементы по css селектору, так и после него.

    Как будто бы его можно безболезненно выкинуть.


    1. MalblshProgrammist Автор
      31.03.2026 18:34

      Спасибо за вопрос! Selenium здесь выступает в роли "двигателя" (прокликать фильтры, дождаться загрузки), а BeautifulSoup быстро и точно достать данные. Работать с page_source через BS4 банально быстрее и надежнее, чем дергать find_elements для каждого чиха, нагружая канал между скриптом и драйвером браузера. Передавая page_source в BeautifulSoup, мы фиксируем состояние страницы в конкретный момент. Это позволяет спокойно "разобрать" все данные тарифа, не боясь, что скрипт на странице внезапно обновит DOM и уронит наш цикл


  1. koleso_O
    31.03.2026 18:34

    Поделитесь, пожалуйста, опытом обхода cloud flare. Были ли проблемы, как выполняли challenges, если случались?

    Использую headless chrome (chromedp библиотека под golang) и часто сталкиваюсь с тем, что cf не пропускает, просит выполнить challenge (видимо, уровень анализа на той стороне выставлен приличный). Так вот, хочу понять, проблема во мне, выбранном стеке или в чем-то еще?


    1. Harrunos
      31.03.2026 18:34

      koleso_O, проблема почти наверняка в стеке, не в вас.

      headless chrome (и chromedp, и selenium-stealth из статьи) работают поверх stock Chromium. JS-патчи применяются уже после старта движка - между запуском процесса и моментом, когда патч перехватывает управление, есть окно. Cloudflare Bot Management делает fingerprint-замер именно в этом окне: Canvas hash, WebGL RENDERER/VENDOR, navigator.webdriver - всё это успевает “засветиться” до того как ваш патч загрузился.

      Автору статьи повезло: Дом.ру, судя по описанию, использует менее агрессивную защиту. CF уровня “challenge при любом headless” - это уже L2-детект, который JS-патчи принципиально не закрывают.

      Попробуйте CloakBrowser - Chromium с патчами на уровне C++ до компиляции. Движок просто никогда не отдаёт automation-сигналы, нет окна, нет детекта.


      1. koleso_O
        31.03.2026 18:34

        Будем пробовать, спасибо)


    1. MalblshProgrammist Автор
      31.03.2026 18:34

      Проблема точно не в вас и не в Go, а в том, что Cloudflare "видит" стандартный headless-режим за версту. При использовании headless: true браузер отправляет специфические сигналы (HTTP-заголовки, отсутствие определенных JS-свойств), которые CF считывает как бота.
      В Python есть selenium-stealth (я упоминал её в статье) или undetected-chromedriver. Они патчат свойства браузера на лету, подменяя navigator.webdriver, параметры WebGL и специфические хром-функции, по которым CF вычисляет автоматизацию. Для Go (chromedp) есть аналогичные подходы по инъекции JS-скриптов перед загрузкой страницы.
      Попробуйте использовать режим --headless=new (в новых версиях Chrome). Он работает как полноценный браузер, просто без отрисовки окна, и его гораздо сложнее детектировать.


      1. koleso_O
        31.03.2026 18:34

        И это тоже попробуем, спасибо)