Пишем асинхронный парсер и скрапер картинок на Python с графическим интерфейсом

Картинка для статьи создана Microsoft Designer

Картинка для статьи создана Microsoft Designer

В этой статье мы создадим desktop-приложение, которое по нашему запросу будет сохранять на нашем диске заданное количество картинок. Так как картинок будет много, мы воспользуемся асинхронностью Python для конкурентной реализации операций ввода-вывода. Посмотрим, чем отличаются библиотеки requests и aiohttp. Также создадим два дополнительных потока приложения, чтобы обойти глобальную блокировку интерпретатора Python.

Вместо тысячи слов…

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

0e1317589844e31d517d4d0ebbbf9ccc.gif

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

Классы графического интерфейса

У нас будет отдельный класс графического интерфейса. Назовем его UI — это главное окно программы. В этом окне есть две различные рамки (frames). Давайте эти рамки также представим различными классами:

  • Класс SearchFrame будет отвечать за ввод поискового запроса, по которому будет осуществляться поиск картинок.

  • Класс ScraperFrame будет отвечать за вывод количества доступных для скачивания картинок, задания пути сохранения этих картинок, выбора их размера и количества. Также в этом классе реализована шкала прогресса выполнения скачивания и сохранения, и информационное поле.

Реализуем эти классы с помощью стандартной библиотеки Python — tkinter.

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

Класс для парсинга PictureLinksParser

Выбор фотохостинга

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

Отключение JavaScript через инструменты разработчика Google Chrome.

Отключение JavaScript через инструменты разработчика Google Chrome.

Если у нас при обновлении страницы пропало все содержимое — скорее всего мы имеем дело с одностраничным приложением SPA (англ. Single Page Applications). А для парсинга таких сайтов требуются «продвинутые» библиотеки Python. Например, Scrapy с инструментом Splash, или еще хуже — Selenium. Scrapy — прекрасный инструмент, но не для нашего случая. Помните принцип KISS? Поэтому ищем сайт, где незначительное влияние JavaScript на контент.

Мой выбор остановился на сайте flickr.com. Единственная
проблема данного сайта в том, что при выводе картинок здесь отсутствует
пагинация страниц, а новые картинки появляются при прокрутке ленты. Тем не
менее 25 картинок без прокрутки мы имеем. Чуть позже в статье я расскажу, как
очень хитро обойти это ограничение.

Выбор библиотеки для парсинга

Самым простым парсером для Python является Beautiful Soup. Это библиотеки вполне будет достаточно для решения нашей задачи.

Выбор библиотеки для веб-запросов

Все знают, что существует библиотека requests. Проблема этой библиотеки в том, что она является блокирующей — на время выполнения запроса и получения данных у нас происходит глобальная блокировка интерпретатора Python (GIL). Это блокировка, которая не дает Python-процессу исполнять более одной команды байт-кода в каждый момент времени. У вас должен возникнуть вопрос –, а зачем ей тогда пользуются? Для одного веб-запроса GIL будет незаметна. А представьте, что у нас 1000 таких запросов. Пока у нас вся 1000 запросов не выполнится, остальная программа будет заблокирована. Для решения этой проблемы создали неблокирующие библиотеки. Примером неблокирующей библиотеки является aiohttp, которая также умеет отправлять веб-запросы.

И здесь я до вас должен донести одну важную мысль: для одного единственного веб-запроса с aiohttp мы ничего не выигрываем у requests. Выигрыш у aiohttp будет только, если мы выполняем несколько веб-запросов конкурентно.

В классе PictureLinksParser будет выполняться только один веб-запрос для получения HTML-документа. Но так как в другом классе мы будем выполнять конкурентно несколько веб-запросов — мы установим только aiohttp. Нам не нужна дополнительная библиотека requests для одно веб-запроса — здесь её заменить aiohttp. Алгоритм парсинга следующий:

def parse_html_to_get_links(self, html: str) -> None:
        """Parses HTML and adds links to the array."""
        soup = BeautifulSoup(html, 'lxml')
        box = soup.find_all(PHOTO_CONTAINER, class_=PHOTO_CLASS)
        for tag in box:
            img_tag = tag.find('img')
            src_value = img_tag.get('src')
            self.add_links('https:' + src_value)

    async def get_html(self) -> None:
        """Downloads HTML with pictures links."""
        async with aiohttp.ClientSession() as session:
            async with session.get(self.url) as response:
                html = await response.text()
        self.parse_html_to_get_links(html)

И вот здесь всплывает первый недостаток aiohttp по сравнению с requests. Requests имеет простейший интерфейс — написал метод get (адрес веб-страницы) и получил страницу. В aiohttp мы создаём клиентскую сессию, которая является средой исполнения для выполнения HTTP-запросов и управления соединениями. Также мы используем асинхронный менеджер контекста, который позволяет корректно начинать и закрывать HTTP-сеансы.

Выводы:

  • если в программе всего один запрос (получение токена, получение одной веб-страницы) — то мы применяем библиотеку requests.

  • если в программе необходимо выполнить одновременно множество запросов — то мы используем aiohttp (или другую неблокирующую библиотеку).

Класс для скрапинга картинок PictureScraperSaver

Когда после работы класса PictureLinksParser у нас было сформировано множество (set) ссылок картинок, мы должны перейти по этим адресам и сохранить картинки на наш диск.

Множества set () в Python

Это стандартный тип данных, о котором все знают. Это очень быстрая коллекция, которая построена на основе хеш-таблиц и содержит только уникальные элементы. Особенность нашего фотохостинга состоит в том, что при повторном запросе в HTML документе иногда появляются абсолютно новые ссылки, которых не было в предыдущем запросе. Используя множество set, мы при повторном запросе добавляем эти ссылки в наше множество ссылок и происходит следующее: число элементов множества автоматически увеличивается на число новых уникальных ссылок с новым запросом с тем же ключевым словом.

Выполняем веб-запросы конкурентно

Итак, множество ссылок у нас есть. Теперь по ним надо перейти и сохранить картинки на диске. Я реализовал это следующим образом (Для лучшей читаемости кода я не стал использовать list comprehension):

async def _save_image(self, session: ClientSession, url: str) -> None:
        """Asynchronously downloads the image and saves it on disk."""
        try:
            response = await session.get(url)
            if response.status == HTTPStatus.OK:
                image_data = await response.read()
                pic_file = f'{self.picture_name}{self.completed_requests}'
                with open(f'{self.save_path}/{pic_file}.jpg', 'wb') as file:
                    file.write(image_data)
                logging.info(f'Успешное сохранение картинки {url}')
            else:
                logging.error(
                    f'Ошибка при работе с картинкой {response.status}'
                    )
        except Exception as e:
            logging.exception(
                f"Ошибка при загрузке {url}: {response.status} {e}"
                )
        self.completed_requests += 1
        if self.completed_requests % self.refresh_rate == 0 or \
                self.completed_requests == self.total_requests:
            self.callback(self.completed_requests, self.total_requests)

    async def _make_requests(self) -> None:
        """Concurrently sends URL links to perform."""
        async with ClientSession() as session:
            reqs = []
            for _ in range(self.total_requests):
                current_link = self.links_array.pop()
                reqs.append(self._save_image(session, current_link))
            await asyncio.gather(*reqs)

Начнем с корутины _make_requests. Мы отдаем на конкурентное выполнение только то количество картинок, которое указали в графическом интерфейсе — атрибут self.total_requests. Методом pop () в множестве мы удаляем случайный элемент и отправляем его на скачивание и сохранение. И далее мы применяем метод asyncio.gather для конкурентного скачивания картинок по соответствующим URL адресам.

Что касается корутины _save_image — здесь все ещё проще. Мы проходим по ссылке картинки, получаем подтверждение, что все статус = 200. И далее сохраняем этот прочитанный контент с помощью стандартной функции open и бинарного режима записи по указанному адресу. На всех этапах логируем события.

Перекладываем парсер и скрапер на дополнительные потоки

Проблема в том, что у нас на этапе парсинга и скрапинга могут возникнуть длительные выполнения операций, которые у нас заблокируют графический интерфейс. На практике, это будет выглядеть так: пока выполняется 1000 запросов, у нас заблокирован графический интерфейс, он перестанет отвечать. И операционная система предложит нам снять этот процесс, посчитав его «зависшим».

Чтобы такого не было для операций ввода-вывода используют многопоточность. Мы создадим 2 дополнительных потока и передадим туда асинхронные циклы событий.

Что мы имеем:

  • главный поток: графический интерфейс

  • дополнительный поток №1: парсер

  • дополнительный поток №2: скрапер

Остается только реализовать потокобезопасность в классах парсера и скрапера.  Это достигается двумя методами asyncio:

  1. Метод call_soon_threadsafe принимает функцию Python (не корутину) и потокобезопасным образом планирует её выполнение на следующей итерации цикла событий.

  2. Метод run_coroutine_threadsafe принимает корутину, потокобезопасным образом подает её для выполнения и сразу же возвращает будущий объект, который позволит получить доступ к результату сопрограммы.

Посмотреть полный код приложения

Т.к. статья не даёт полного представления о том, что мы сделали, советую вам посмотреть полный код приложения в моём репозитории на GitHub.

Там же в описании к репозиторию вы найдете ссылку на exe-версию программы и можете с ней немного поиграться.

Перспективы использования

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

Приложение прошло мануальное тестирования на операционных системах Windows 11 и Ubuntu 22.04

© Habrahabr.ru