Разбираем на примере Russia Today, Коммерсант и Meduza*

Возможно для вашего проекта/ресерча иногда требовалось собрать большое количество статей из каких-либо источников в виде веб-сайтов. В эпоху больших языковых моделей полноценный сбор информации с сайтов все еще не самый очевидный сценарий, требующий учета многих мелких деталей, а также понимания принципов работы сайта и взаимодействия с ним. В этом случае единственный оптимальный метод сбора такой информации — это парсинг.
В данной статье мы сфокусируемся на парсинге сайтов российских СМИ, в числе которых Meduza,* как официально запрещенное в РФ и более государственно-подконтрольных RussiaToday и Коммерсанта. Как основные инструменты используем классические библиотеки в Python: requests, BeautifulSoup, Selenium. Несмотря на то, что сейчас во многом можно использовать их более современные аналоги по типу Playwrite, который неплохо работает с имитацией поведения пользователя в браузере, для удобства понимания кода большинством читателей сделаем упор на классику.
*признан иноагентом на территории РФ.
Стандартный путь выбора метода и основных инструментов для парсинга представляет собой несколько этапов:
-
Владелец сайта предоставляет открытый API.
Application Programming Interface — программный интерфейс, который позволяет передавать информацию между двумя программами. В нашем случае проще это описать так: мы делаем запрос на API, и в этом процессе ваш компьютер обращается к сайту, чтобы получить информацию. Например, чтобы отобразить карту на сайте, связанному с недвижимостью, там может быть подключен API Яндекс Карт. В этом с случае, процесс парсинга становится крайне простым и сводится к запросу и выбору необходимой информации из json-файла.
-
Официального API нет. Нужно искать скрытый.
Не самый тривиальный способ, т.к. возможно если вы только начинаете свой путь в парсинге, то скорее всего пропустите такую возможность. Он работает по такому же принципу, как и официальный, но содержится в бекенд части сайта.
Найти его не так сложно. Пару кликов в панели для разработчиков (вкладкаNetwork) и внимательность.
Далее мы разберем процесс поиска на конкретном примере. -
BeautifulSoup: парсим обычную HTML-страницу.
Страницы нашего сайта представляют собой нединамический сайт, который можно разобрать по частям как конструктор, вытащив из кода нужную информацию. Отправляем запрос с помощью
requestsи парсим наш html. Большой минус заключается в том, что структура сайта может быстро меняться: например, названия классов и css-селекторов, по которым мы выбираем условный заголовок, дату и тд. Но это не самый сложный путь развития. -
Selenium: динамический сайт с JS-элементами.
В современном интернете большинство сайтов именно такие. Содержимое страницы генерируется автоматически с JavaScript кодом. При отправки запроса на получение html мы уже сталкиваемся с тем, что защита сайта распознает робота и не подгружает нам страницу. В этом случае, необходимо имитировать поведение пользователя, и самая классическая библиотека, помогающая это реализовать — Selenium. При помощи Chrome-драйвера библиотека подгружает страницу сайта и с помощью различных опций по устранению возможности быть распознанным сайтом как робот в итоге получаем html страницу, которую далее парсим с BeautifulSoup.
Начнем с кейса Медузы*.
Медуза* не предоставляет официальный API, поэтому начинаем поиск скрытого.
Заходим на сайт и вбиваем в поиск ключевые слова нужных статей. Традиционно начинаем исследование с клика левой кнопки мыши и нажатию на 'inspect'. Получаем исходный код HTML‑страницы.

В верхней панели ищем вкладку Network , здесь открывается весь бекенд сайта и его механизм работы.

Теперь нам доступен список запросов к сайту (колонка под Name), которые обязательно выполняются при загрузки сайта. Среди них по названию можно примерно понять, что он делает и найти нужный нам API запрос. Обычно это что-то типо search, news с возвращаемым json файлом в response.

Внимательно поищем желанный, скрытый API, находим get-запрос new_search,смотрим на параметры и response, который он возвращает. В нем видим json-ответ, это то, что нам нужно.
Создаем класс MeduzaParser в Python, он будет содержать все необходимые методы для парсинга новостей.
Инициализируем наши аргументы в методе init.
class MeduzaParser: def __init__(self, phrase_search: str): self.phrase_search = phrase_search self.headers = {'User-Agent': random.choice(user_agents)} self.api = 'https://meduza.io/api/w5/new_search' self.proxies = { 'http': proxy, 'https': proxy, } self.request_body = {'term': self.phrase_search, 'per_page': 15, 'locale': 'ru'}
В self.headers для уменьшения вероятности быть заблокированным спустя несколько запросов прописываем User-Agent, который поможет маскировать название нашего браузера при отправки запроса. Предварительно загружаем список случайных юзер-агентов, которые можно найти например в этом репозитории. Из них рандомно выбирается один случайный, и так для каждого запроса. В self.request_body определяем параметры запроса: ключевая фраза/слово по которому будем искать, число статей при одном запросе и язык.
Медуза является запрещенной в России организацией, поэтому для отправки запроса немаловажным аспектом является использование прокси-сервера. С российского API-адреса вы не получите ответ после отправки запроса. Оптимальный вариант — использование пула прокси-серверов с их динамической сменой, чтобы снизить риск блокировки.
Далее необходимо добавить метод для отправки запроса на API через библиотеку requests. Так как максимальное количество статей в запросе — 15, необходима итерация по страницам, поэтому на вход принимаем аргумент page.
С блоком try - except оставляем пять попыток получить ответ в виде json объекта c основными ключами _count — количество статей в запросе, collection — ссылки на все статьи и documents — метаданные по статьям.
def send_request(self, page: int) -> Optional[tuple]: '''Get the json response from the Meduza hidden API. ''' params = { **self.request_body, 'page': page } for attempt in range(5): try: response = requests.get(self.api, params=params, proxies=self.proxies, headers=self.headers, verify=False).json() print('Got a response') return response['documents'], response['_count'] except Exception as e: print(f"Can't get a response for api request - {e}") continue return None
Как возвращаемые значения метода оставляем метаданные и количество статей.
Documents возвращает очень подробную информацию о статьях, если в статье есть аудио, картинки, то это все можно найти в ответе, вот так выглядит очень скромный ответ:
{'datetime': 1730966709, 'layout': 'simple', 'mobile_layout': 'simple', 'source': {'trust': 0}, 'title': 'Зеленский рассказал, что созвонился с\xa0Трампом', 'url': 'news/2024/11/07/zelenskiy-rasskazal-chto-sozvonilsya-s-trampom', 'version': 3}
И так ответ, содержащий метаданные о всех файлах в статье:
Скрытый текст
{'datetime': 1730712566, 'image': {'base_urls': {'elarge_url': '/image/attachments/images/010/580/173/elarge/jErsR06_OK513LOROdBzmA.jpg', 'is1to2': '/image/attachment_overrides/images/010/580/173/ov/ujms6916u68Ql64Av-qOgQ.jpg', 'is1to3': '/image/attachments/images/010/580/173/wh_810_540/jErsR06_OK513LOROdBzmA.jpg', 'is1to4': '/image/attachments/images/010/580/173/wh_810_540/jErsR06_OK513LOROdBzmA.jpg', 'isMobile': '/impro/yMxV8ai5e8gtkOeFaQl8_Oo16DkSTO50yiB9sBoO2_Y/resizing_type:fit/width:782/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL2xhcmdl/L2pFcnNSMDZfT0s1/MTNMT1JPZEJ6bUEu/anBn.jpg', 'wh_300_200_url': '/image/attachments/images/010/580/173/wh_300_200/jErsR06_OK513LOROdBzmA.jpg', 'wh_405_270_url': '/image/attachments/images/010/580/173/wh_405_270/jErsR06_OK513LOROdBzmA.jpg'}, 'cc': 'default', 'credit': 'Chip Somodevilla / Getty Images', 'display': 'default', 'elarge_url': '/image/attachments/images/010/580/173/elarge/jErsR06_OK513LOROdBzmA.jpg', 'gradients': {'bg_rgb': '0,0,0', 'text_rgb': '255,255,255'}, 'height': 890, 'is1to1': '/image/attachment_overrides/images/010/580/173/ov/eYU01BuHD8uQXl7fyONPRA.jpg', 'is1to2': '/image/attachment_overrides/images/010/580/173/ov/ujms6916u68Ql64Av-qOgQ.jpg', 'is1to3': '/image/attachments/images/010/580/173/wh_810_540/jErsR06_OK513LOROdBzmA.jpg', 'is1to4': '/image/attachments/images/010/580/173/wh_810_540/jErsR06_OK513LOROdBzmA.jpg', 'isMobile': '/impro/yMxV8ai5e8gtkOeFaQl8_Oo16DkSTO50yiB9sBoO2_Y/resizing_type:fit/width:782/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL2xhcmdl/L2pFcnNSMDZfT0s1/MTNMT1JPZEJ6bUEu/anBn.jpg', 'mobile_ratio': 1.5, 'optimised_urls': {'elarge_url': '/impro/CGnSJrpx94CGYSVB0j-fJs9sWbbgasIZu8vWxkuXQ9g/resizing_type:fit/width:0/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL2VsYXJn/ZS9qRXJzUjA2X09L/NTEzTE9ST2RCem1B/LmpwZw.webp', 'is1to2': '/impro/JRRhxXheqdVXhOGWMqCUjaTnN38eRZktbG3PWBzbrHo/resizing_type:fit/width:0/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudF9v/dmVycmlkZXMvaW1h/Z2VzLzAxMC81ODAv/MTczL292L3VqbXM2/OTE2dTY4UWw2NEF2/LXFPZ1EuanBn.webp', 'is1to3': '/impro/FnOI0FOccrlOdodgEMg16-8XwitHiGEQXGfWx-9KpUg/resizing_type:fit/width:0/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL3doXzgx/MF81NDAvakVyc1Iw/Nl9PSzUxM0xPUk9k/QnptQS5qcGc.webp', 'is1to4': '/impro/FnOI0FOccrlOdodgEMg16-8XwitHiGEQXGfWx-9KpUg/resizing_type:fit/width:0/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL3doXzgx/MF81NDAvakVyc1Iw/Nl9PSzUxM0xPUk9k/QnptQS5qcGc.webp', 'isMobile': '/impro/A3SRAMBVlzsLmMAArkNbFtHqIqGx8pB8DuyG7GJzvjc/resizing_type:fit/width:782/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL2xhcmdl/L2pFcnNSMDZfT0s1/MTNMT1JPZEJ6bUEu/anBn.webp', 'wh_300_200_url': '/impro/6c6c9Re0Lbi_sSLgqEHC5oQBbnLE_9QlzT6Afoq3Og0/resizing_type:fit/width:0/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL3doXzMw/MF8yMDAvakVyc1Iw/Nl9PSzUxM0xPUk9k/QnptQS5qcGc.webp', 'wh_405_270_url': '/impro/FBtTpPfA2tzXzdFqpOW1mUF9pVHS7EMzUHFG1nB_01I/resizing_type:fit/width:0/height:0/enlarge:1/quality:80/aHR0cHM6Ly9tZWR1/emEuaW8vaW1hZ2Uv/YXR0YWNobWVudHMv/aW1hZ2VzLzAxMC81/ODAvMTczL3doXzQw/NV8yNzAvakVyc1Iw/Nl9PSzUxM0xPUk9k/QnptQS5qcGc.webp'}, 'show': True, 'wh_1245_500_url': '/image/attachments/images/010/580/173/wh_1245_500/jErsR06_OK513LOROdBzmA.jpg', 'wh_300_200_url': '/image/attachments/images/010/580/173/wh_300_200/jErsR06_OK513LOROdBzmA.jpg', 'wh_405_270_url': '/image/attachments/images/010/580/173/wh_405_270/jErsR06_OK513LOROdBzmA.jpg', 'wh_810_540_url': '/image/attachments/images/010/580/173/wh_810_540/jErsR06_OK513LOROdBzmA.jpg', 'width': 1335}, 'layout': 'rich', 'mobile_layout': 'rich', 'mobile_theme': '255,255,255', 'second_title': 'Выпуск рассылки «Сигнал» на\xa0«Медузе»', 'tag': {'name': 'истории', 'path': 'articles'}, 'title': 'Дональд Трамп требует честных выборов\xa0— а\xa0его обвиняют в\xa0' 'подрыве демократии. Так возможны\xa0ли фальсификации в\xa0США?', 'url': 'feature/2024/11/04/donald-tramp-trebuet-chestnyh-vyborov-a-ego-obvinyayut-v-podryve-demokratii-tak-vozmozhny-li-falsifikatsii-v-ssha', 'version': 3}
Теперь необходимо пропарсить json и вытащить информацию о названии, дате, типе статьи и саму ссылку на статью.
Получить информацию о дате будет довольно трудоемким процессом, поэтому ее, как и основной текст страницы мы получим с помощью парсинга html-страницы.
Создаем новый метод parse_article, который на вход примет ссылку на статью и данные в виде словаря.
Сначала выбираем ключ ‘tag’ из словаря, он представлен не в каждом блоке информации о статье, поэтому оборачиваем в try-except. По наблюдению можно понять, что тег отсутствует в рублике ‘news’ поэтому дадим значение самостоятельно.
Вынимаем заголовок и чистим от символа-знака пробела.
def parse_article(self, link: str, data: dict) -> Optional[dict]: '''Get article metadata and main text. Using BeautifulSoup to parse html page. ''' try: tag = data['tag'] except KeyError: tag = 'новости' title = data['title'].replace('\xa0', ' ') url = 'https://meduza.io/' + link html = self.get_page(url) if not html: return None soup = BeautifulSoup(html, 'html.parser') date = None date_tag = soup.find('time', attrs={'data-testid': 'timestamp'}) if date_tag: date_str = date_tag.text if 'назад' not in date_str: date = str(datetime.strptime(date_str.split(', ')[1], '%d %B %Y').date()) text = None text_tag = soup.find('div', class_=[ 'GeneralMaterial-module-article', 'SlidesMaterial-module-slides' ]) if text_tag: text = text_tag.text.replace('\xa0', ' ') article_data = { 'title': title, 'tag': tag, 'date': date, 'link': url, 'text': text } return article_data
Теперь нужно получить дату и текст статьи. В ход идет BeautifulSoup. Создадим отдельный метод get_page для получения страницы и передадим в объект soup.
def get_page(self, link: str) -> Optional[str]: '''Get the html page of article from url request. ''' try: html = requests.get(link, proxies=self.proxies, headers=self.headers).text return html except Exception as e: print(f"Can't get a response from page") return None
Ищем дату на странице по тегу time и атрибуту data-testid. В процессе написания парсера объект супа не сразу находил нужный тег, поэтому в этом случае необходима тщательная обработка значения. Если дата находится, то в некоторых статьях медузы, а точнее в подкастах дата представлена не в формате ‘dd-mm-YYYY’, а строкой ‘n месяцев назад’, точной даты не получить, поэтому в таких типах статей вместо даты оставим пропущенное значение.
Находим текст статьи по одному из классов, из-за разных рубрик статей, текст может быть либо в 'GeneralMaterial-module-article' либо в 'SlidesMaterial-module-slides'.
... # продолжение метода parse_article url = 'https://meduza.io/' + link html = self.get_page(url) if not html: return None soup = BeautifulSoup(html, 'html.parser') date = None date_tag = soup.find('time', attrs={'data-testid': 'timestamp'}) if date_tag: date_str = date_tag.text if 'назад' not in date_str: date = str(datetime.strptime(date_str.split(', ')[1], '%d %B %Y').date()) text = None text_tag = soup.find('div', class_=[ 'GeneralMaterial-module-article', 'SlidesMaterial-module-slides' ]) if text_tag: text = text_tag.text.replace('\xa0', ' ') article_data = { 'title': title, 'tag': tag, 'date': date, 'link': url, 'text': text } return article_data
Сохраняем полученные данные в словарь article_data и возвращаем его.
В методе page_iterate будет содержаться весь алгоритм работы парсера. Его задача — итерироваться по страницам и получить данные запроса по каждой из них, для каждой статьи запроса получить данные по ней и сохранить в лист articles_data.
Создаем бесконечный цикл while True, где с каждой итерацией число страниц будет возрастать на один и цикл будет обрываться только в момент получения ответа от запроса, где статей уже нет. То есть в этот момент парсер собрал все статьи и их больше нет.
def page_iterate(self) -> list: '''Get all articles as a DataFrame with the use of page iteration. ''' articles_data = [] articles_total = 0 page = 0 while True: data = self.send_request(page) if data is None: print(f'Got None for this request on page {page}') page += 1 continue article_data, articles_num = data if articles_num == 0: print('No more articles found') break for link, metadata in article_data.items(): parsed_data = self.parse_article(link, metadata) if parsed_data: articles_data.append(parsed_data) articles_total += articles_num page += 1 print(f'Parsed {articles_total} articles') return pd.DataFrame(articles_data)
По итогу сохраняем полученный результат в DataFrame. Создаем объект класса, вызываем финальный метод page_iterate и получаем такой результат.
CPU times: user 21.9 s, sys: 1.89 s, total: 23.8 s Wall time: 3min 27s

По запросу ‘выборы в сша’ у нас собралось 486 статей за 3.5 минуты.
В случае RussiaToday, открыв вкладку разработчика в поисках нужного запроса единственным похожим на что-то, содержащее информацию о статьях является запрос search?. Но возвращает он не сырой json, a html страницу, что не совсем удобно для нас. Тут можно принять факт отсутствия удобного формата и вооружиться BeautifulSoup, но если присмотреться внимательнее, в payload можно увидеть параметр API запроса ‘format’, который как раз и принимает аргументом ‘json’. Это наше решение.

По аналогии с парсером медузы создадим class RussiaTodayParser с теми же атрибутами метода init, за исключением параметров запроса и прокси-сервера, тут он не понадобится. Здесь к нему добавятся такие параметры как df и dt, отвечающие за даты с которой по какое необходимо получить статьи, и format, где указываем json.
self.params = { 'q': query, 'df': date_from, 'dt': date_to, 'pageSize': 100, 'format': 'json' }
def get_metadata(self, article: dict) -> dict: '''Get metadata of article''' link = f'{self.base_url}{article['href']}' text = self.get_article_text(link) category = article['category'] if 'category' in article else None date = str(datetime.fromtimestamp( int(article['date'])).date()) article_data = { 'id': article['id'], 'link': link, 'date': date, 'type': article['type'], 'category': category, 'title': article['title'], 'summary': article['summary'], 'text': text } return article_data
Текст получаем в отдельном методе, где он находится по классу на HTML-странице. Не забываем перевести дату из формата timestamp. Последний метод iterate_pages, позволяющий собрать весь механизм работы парсера можно увидеть в гитхаб репозитории, его механизм также похож на тот, что мы видели в предыдущем парсере.
Теперь пришла очередь Коммерсанта. Тщательно изучив все доступные запросы в панели разработчика на сайте, запроса, который смог бы вернуть нам необходимую информацию о статьях не нашлось. К тому же, получить страницу со статьями с помощью обычного запроса requests не выйдет. Система защиты сайта распознает работу робота, поэтому нам следует сымитировать действие пользователя при помощи Selenium.
В классе KommersantParser создаем метод get_driver, где определим базовые опции ChromeDriver.
При инициализации драйвера чтобы минимизировать ошибки и вероятность обнаружения робота используем дополненую версию ChromeDriver — undetected chrome-driver , который автоматически предотвращает быть пойманным такими системами защиты как: Cloudflare, Distil Networks.
Опция '--no-sandbox' поможет снизить вероятность ошибок или сбоев, вызванных песочницей — места, где браузер проходит проверки безопасности операционной системы. --headless=new отключит автоматический вызов окна браузера при каждой попытке получения страницы драйвером.
def get_driver(self): '''Getting ChromeDriver to imitate user behaviour in browser. ''' options = uc.ChromeOptions() options.add_argument("--no-sandbox") options.add_argument("--headless=new") options.add_argument(f'--user-agent={random.choice(user_agents)}') driver = uc.Chrome(version_main=138, options=options) return driver
В первую очередь, нужно собрать ссылки со всех страниц, для этого необходимо получение драйвером всех доступных страниц запроса.
Создаем метод get_links, перед тем, как дать возможность получить страницу, пропишем функцию delete_all_cookies(), она поможет избежать ошибки “no such window: target window already closed”, которая связанна с наличием файлов куки от прошлого запроса. Т.к. мы неоднократно отправляем запросы, то не очищенные куки с прошлого являются помехой.
Прописываем функцию wait для прогрузки всего блока статей и ищем его по css-селектору.
def get_links(self, url: str) -> Optional[list]: '''Getting articles links on website page. ''' links = [] driver = self.get_driver() try: driver.delete_all_cookies() driver.get(url) wait(driver, 10).until( EC.visibility_of_element_located((By.CSS_SELECTOR, 'article.uho'))) articles = driver.find_elements( By.CSS_SELECTOR, 'article.uho') except TimeoutException: return None for article in articles: link = article.find_element( By.CSS_SELECTOR, 'a.uho__link').get_attribute('href') links.append(link) driver.close() return links
Далее из каждого блока html-кода статьи вынимаем ссылку.
Теперь при помощи BeautifulSoup прописываем метод парсинга статей исходя из полученных ссылок, здесь ничего нового:
def select_part(self, soup, css_selector: str) -> Optional[str]: page_object = soup.select_one( css_selector).text.strip() return page_object if page_object else '' def get_metadata(self, article_link: str) -> dict: '''Getting the metadata and article text. ''' html = requests.get(article_link).text soup = BeautifulSoup(html, 'html.parser') title = self.select_part(soup, 'h1.doc_header__name') date = self.select_part(soup, 'time.doc_header__publish_time') text = self.select_part(soup, 'div.doc__body') article_data = { 'title': title, 'date': date, 'link': article_link, 'text': text } return article_data
У коммерсанта максимальное количество страниц с ссылками на статьи, которые мы можем получить это — 100. Чтобы не потерять наши статьи, сначала разобъем наш временной промежуток на более мелкие с дельтой равной месяцу. И для каждого месяца будем делать отдельный запрос.
В этом же цикле у нас получается второй вложенный, где уже идет итерация по страницам. На выходе получаем список из всех ссылок на статьи.
def iterate_pages(self) -> list: '''Iteration through the date range and pages ''' article_links = [] dates = pd.date_range( self.df, self.dt, freq='31D') dates = dates.strftime('%Y-%m-%d').tolist() for i in tqdm(range(1, len(dates))): print(dates[i]) params = { **self.params, 'datestart': dates[i-1], 'dateend': dates[i] } for page in range(1, 101): request_payload = { **params, 'page': page } url = self.base_url + urllib.parse.urlencode( request_payload) links = self.get_links(url) if links: article_links.extend(links) else: break print(f'Found {len(article_links)} article links.') return article_links
И наконец полученные всех данных о статьях при помощи последнего метода get_articles:
def get_articles(self) -> pd.DataFrame: '''Getting the final results with articles in DataFrame ''' articles_data = [] article_links = self.iterate_pages() for link in article_links: data = self.get_metadata(link) if data: articles_data.append(data) return pd.DataFrame(articles_data)
Итак, в результате нам удалось спарсить статьи из трех наиболее известных российских СМИ. Теперь можно и узнать как такие влиятельные медиа-ресурсы представляют и позиционируют выборы в США и другие животрепещущие темы, также используя весь функционал питона и его библиотек.
Исходный код парсеров можно найти в этом репозитории.
ссылка на оригинал статьи https://habr.com/ru/articles/930188/
Добавить комментарий