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

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

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

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

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

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

У нас будет отдельный класс графического интерфейса. Назовем его 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

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


  1. bungu
    21.03.2024 12:14
    +5

    Пока у нас вся 1000 запросов не выполнится, остальная программа будет заблокирована. Для решения этой проблемы создали неблокирующие библиотеки. Примером неблокирующей библиотеки является aiohttp, которая также умеет отправлять веб-запросы.

    Полнейшая чушь. Я могу спокойно в многопоточном режиме отправить 1000 запросов в тред пул и дальше юзать программу не дожидаясь окончания выполнения запросов. При этом разницы в скорости по сравнению с asyncio никакой не будет

    Для решения этой проблемы создали неблокирующие библиотеки. Примером неблокирующей библиотеки является aiohttp, которая также умеет отправлять веб-запросы.

    Эти библиотеки были созданы неблокирующими в контексте использования эвент лупа, но это вовсе не значит что многопоточный код будет "блокирующим"

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

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

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

    Хочу так же обратить внимание что автор занимается изобретением велосипедов. Вместо того чтобы вытащить, например, через консоль разработчика API запрос с нормальным JSON-ответом, как отсюда

    https://api.flickr.com/services/rest?sort=relevance&parse_tags=1&extras=url_q,url_t,url_s,url_n,url_w,url_m,url_z,url_c,url_l&per_page=100&page=3&lang=en-US&text=sea&viewerNSID=&method=flickr.photos.search&csrf=&api_key=50bb125c6abd4d90c7bcc8e932bcaf6b&format=json&hermes=1&hermesClient=1&reqId=b9aa4e84-74a4-4a18-8f46-256b42a74b54&nojsoncallback=1


    он начинает парсить HTML-страницу с помощью bs. Думаю не стоит говорить о том, что это снижает скорость работы в разы

    А еще автор любитель поговнокодить и применить антипаттерны.

    Например

    async def get_html(self) -> None:
    Метод, начинающийся с get_ ничего не возвращает

    with open(f'{self.save_path}/{pic_file}.jpg', 'wb') as file:

    Чтобы складывать пути есть os.path.join или Pathlib



    1. IvanZaycev0717 Автор
      21.03.2024 12:14
      +1

      Полнейшая чушь. Я могу спокойно в многопоточном режиме отправить 1000 запросов в тред пул и дальше юзать программу не дожидаясь окончания выполнения запросов. При этом разницы в скорости по сравнению с asyncio никакой не будет.

      А в чем чушь полнейшая, если по вашим словам "разницы в скорости по сравнению с asyncio никакой не будет"? Это просто 2 разных подхода для решения одной и той же задачи

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

      Здесь больше эмоций, чем аргументированной критики. В моём заявлении нет ничего, что противоречило бы правильной реализации поставленной задачи

      Вместо того чтобы вытащить, например, через консоль разработчика API запрос с нормальным JSON-ответом

      Конкретно для этого примера можно так сделать. Но представте, что у нас на сайт не предоставляет API. Я описал универсальный подход.

      А еще автор любитель поговнокодить и применить антипаттерны.

      Все мы иногда любители этого. По поводу говнокода, я процитирую одного своего товарища, который толи в шутку, толи в заправду говорил: "Говнокод очень сильно ускоряет коммерческую разработку".

      Я написал это приложение в своё удовольствие, никакой коммерческой составляющей здесь и в помине нет. Здесь просто показано одно из множества решений данной проблемы. Эффективное оно или нет - вы решаете сами для себя.

      А теперь от меня вопрос:

      Интерестно, у вас на работе такая же корпоративная культура? Тогда я вам сочувствую


      1. bungu
        21.03.2024 12:14

        А в чем чушь полнейшая, если по вашим словам "разницы в скорости по сравнению с asyncio никакой не будет"? Это просто 2 разных подхода для решения одной и той же задачи

        Верно, но вы то в статьте пишете о том, что в многопоточном исполнении

        Пока у нас вся 1000 запросов не выполнится, остальная программа будет заблокирована.

        А это неверно. С чего она будет заблокирована? Есть основной поток с tkinter и есть остальные потоки с отправкой запросов. И почему основной поток будет блокироваться?

        Интерестно, у вас на работе такая же корпоративная культура?

        Какая такая? Код-ревью это вообще не про корпоративную культуру


        1. IvanZaycev0717 Автор
          21.03.2024 12:14
          +1

          Верно, но вы то в статьте пишете о том, что в многопоточном исполнении

          Похоже вы не поняли для чего я это сделал: вы не заметили, что при выполнении запросов плавно меняется progress bar. При нажатии кнопки "Начать" мы будем с  помощью aiohttp отправлять запросы с  максимальной скоростью. Пока картинки скачиваются, мы можем отправлять команды обновления progress bar хода выполнения из цикла событий asyncio в цикл событий Tkinter.
          Очень интересно посмотреть как вы сделаете отзывчивый UI через пул процессов?

          А это неверно. С чего она будет заблокирована? Есть основной поток с tkinter и есть остальные потоки с отправкой запросов. И почему основной поток будет блокироваться?

          Это вы вырвали из контекста статьи. Это было написано ДО того, как я заговорил о многопоточности. Т.е. если реализовать длительную блокирующую операцию в mainloop(), то программа "застынет" и ОС подумает, что она зависла. Если реализовать длительную блокирующую операцию в дополнительнм потоке - то он застынет на время выполнения операции, а главный поток будет нормально работать. Вы даже статью нормально прочитать не смогли, вам главное кое-что на вентилятор побыстрее накинуть

          Какая такая? Код-ревью это вообще не про корпоративную культуру

          Вы на Pull request такие же комментарии оставляете: "Зачем ты изобретаешь велоспед", "Хватит говнокодить"?

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


  1. cry_san
    21.03.2024 12:14

    "Также создадим два дополнительных потока приложения, чтобы обойти глобальную блокировку интерпретатора Python."
    Может я все проспал? Что за глобальная блокировка интерпретатора Python?


    1. IvanZaycev0717 Автор
      21.03.2024 12:14
      +1

      В tkinter имеется собственный цикл событий mainloop(), который блокирует главный поток. Это означает, что любая длительная операция может привести к заморозке пользовательского интерфейса.
      Избежать проблемы можно через выполнение функций, которые не блокируют этот цикл событий. Это достигается тем, что исполнение цикла событий в asyncio надо делать в отдельном потоке.
      Про глобальную блокировку интерпритатора GIL на Хабре написан не один десяток статей.


      1. cry_san
        21.03.2024 12:14

        Теперь понятно про что вы. Я уже было подумал, что сам язык стали блокировать и писать на нем приложения ))


  1. MrSunstrike
    21.03.2024 12:14

    Спасибо за такую подробную статью!


    1. IvanZaycev0717 Автор
      21.03.2024 12:14

      Спасибо за комментарий. Я привел только один из способов реализации. Другой способ - в самом первом комментарии