Статья Парсим и скачиваем «нескучные обои» с использованием потоков в Python

Давайте сегодня подумаем о красоте. А именно, о красоте рабочего стола. А украсить его могут только « ». Привет, Денис Попов :LOL:. Ну, а если более серьезно, то скачаем картинки с обоями с сайта, на котором их очень и очень много. Конечно же, для того, чтобы скачать картинки мы будем использовать Python, а загрузка картинок будет происходить в многопоточном режиме. Ну и наиболее полезная часть данной статьи состоит в том, что мы немного попрактикуемся в парсинге.

000.jpg


Что понадобиться?

Для отправки запросов и скачивание картинок будем использовать библиотеку requests. Для того, чтобы распарсить полученные результаты и получить из них ссылки на картинки BeautifulSoup и lxml. Поэтому, для начала их нужно установить:

pip install bs4 requests lxml

В работе скрипта так же потребуется библиотека для создания потоков threading, а так же библиотеки time, os и json. Поэтому перед началом работы давайте все это импортируем в наш скрипт.

Python:
import json
import os.path
import threading
import time

import requests
from bs4 import BeautifulSoup

Сайт, с которого будет происходить скачивание обоев, , на самом деле очень лоялен ко всякого рода попыткам его парсить. Он не сбрасывает соединение, не психует и не нервничает, когда к нему прилетает слишком много запросов. Идеальный пациент, можно сказать. Думаю, что он будет отдавать все данные даже в том случае, если мы не станем указывать заголовки запроса. Но, все же, чтобы работать по правилам и попрактиковаться, создадим их. Идем на сайт и смотрим заголовки в любом запросе. Забираем user-agent и accept.

Для этого щелкаем правой кнопкой мыши и в Яндекс.Браузере выбираем пункт «Исследовать элемент». Если же это будет Edge, то данный пункт называется «Проверить». Ну и так далее. Суть в том, что нужно попасть в инструменты разработчика.

screenshot1.png

Копируем их и вставляем в скрипт:

Python:
headers = {
    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.119 '
                  'YaBrowser/22.3.0.2434 Yowser/2.5 Safari/537.36',
    'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,'
              'application/signed-exchange;v=b3;q=0.9 '
}


Функция получения пагинации

Давайте для начала создадим функцию получения пагинации со страницы из категории. Назову ее get_page_count(url). На вход она принимает только лишь один параметр, это ссылка на страницу категории. Далее выполняется запрос и полученные данные передаются в BeautifulSoup. После этого ищем блок с тэгом div, у которого id=pages. Получаем из этого блока все ссылки на страницы и забираем последнюю ссылку. Далее, разделяем ее и обрезаем лишние пробелы. Но, опытным путем было выявлено, что в некоторых категориях нет стрелки в тексте последней ссылки. Так как количество страниц с картинками помещается в блок пагинации целиком. И в этом случае скрипт падает с ошибкой. Для этого добавим блок try – except, чтобы эту ошибку отловить и просто забрать текст, без обрезки, из последней ссылки в блоке пагинации.

Python:
def get_page_count(url):
    req = requests.get(url=url, headers=headers)
    soup = BeautifulSoup(req.text, 'lxml')
    try:
        page_count = int(soup.find('div', id='pages').find_all('a')[-1].text.split(" ")[1].strip())
    except:
        page_count = int(soup.find('div', id='pages').find_all('a')[-1].text.strip())
    return page_count


Получение ссылок на категории, названия категорий и количества страниц в каждой из них

Для того, чтобы названия категорий, ссылки на них и количество страниц в каждой категории хранились локально, нужно их во что-то сохранить. Я решил, что удобнее всего это будет сделать в файл JSON. А для того, чтобы ускорить получение ссылок на категории и прочих параметров, буду обрабатывать это в многопоточном режиме.

Для начала напишем функцию для нахождения названия категории, ссылки на нее и количества в ней страниц. Назвал я ее def get_link_category(url). На вход она получает ссылку на категорию. И дальше в коде для начала формируется общая ссылка на категорию. Потом ссылка, которую передали в функцию разделяется по слэшу и снова собирается для записи этой ссылки, в которой уже нет привязки к определенной странице, для записи в JSON. Это будет нужно для того, чтобы впоследствии брать ссылку из JSON и формировать из нее ссылку на загрузку пагинации и прочих параметров с определенной страницы.

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

Python:
def get_link_category(url):
    url_cats = 'https://w-dog.ru' + url.find('div', class_='word').find('a')['href']
    url_cat = str('https://w-dog.ru' + url.find('div', class_='word').find('a')['href']).split("/")
    url_cat_s = f'{url_cat[0]}//{url_cat[2]}/{url_cat[3]}/{url_cat[4]}/{url_cat[5]}'
    name_category = url.find('div', class_='word').find('a').text.strip()
    p_count = get_page_count(url_cats)
    category_dict[name_category] = {
        'url_category': url_cat_s,
        'page_count': p_count
    }
    with open('category_res.json', 'w', encoding='utf-8') as file:
        json.dump(category_dict, file, indent=4, ensure_ascii=False)

Чтобы было понятнее. В данной функции получаются данные только из одной категории. А вот каждая последующая ссылка на категорию передается из функции, в которой запускаются потоки. Назвал я ее def thread_func_category(). На входе она ничего не принимает. В ней есть ссылка, по которой делается запрос на страницу и собираются все ссылки на категории, которые потом записываются в словарь.

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

Полный код функции старта потоков загрузки ссылок на категории:

Python:
def thread_func_category():
    url = 'https://w-dog.ru/'
    req = requests.get(url=url, headers=headers)
    soup = BeautifulSoup(req.text, 'lxml')
    all_category = soup.find_all('div', class_='wpitem category')
    for url in all_category:
        t = threading.Thread(target=get_link_category, kwargs={'url': url})
        t.start()


Загрузка картинок из категории, выбранной пользователем

Теперь можно перейти к важной части, а именно к загрузке картинок обоев из категории, которую указал пользователь. Назову ее get_pict_download(item, name_cat). Здесь на вход прилетает объект из найденных ссылок страницы, а так же имя категории. Оно будет нужно для того, чтобы указать папку с именем категории и загрузить в нее картинки. На первом этапе ищется имя картинки, под которым она будет сохранена и это имя очищается от мусора в виде всяких специальных символов, которые не совместимы с сохранением файлов в операционной системе.

Затем делается проверка на наличие картинки с данным названием в папке. Если такого названия нет, то картинка загружается. Если же есть, то не делается ничего. Это позволяет не дублировать загрузку одних и тех же картинок с перезаписью. Что позволяет, в случае, если вы не докачали категорию, а вы можете ее не докачать, так как картинок там о-о-очень много, ускорить работу скрипта. Ну и дальше формируется ссылка на картинку, после чего происходит ее загрузка и запись на диск.

Код загрузки картинки:

Python:
def get_pict_download(item, name_cat):
    name_pict = item.find('b', class_='word').text.strip().replace("/", " ").replace('"', ''). \
        replace("'", "").replace(".", "")
    if not os.path.isfile(os.path.join(name_cat, f'{name_pict}.jpg')):
        url_pict = 'https://w-dog.ru' + item.find('div', class_='action-buttons').find('a')['href']
        req = requests.get(url=url_pict, headers=headers)
        with open(os.path.join(name_cat, f'{name_pict}.jpg'), 'wb') as file:
            file.write(req.content)

А дальше нужно создать функцию, в которой будут запускаться потоки для скачивания картинок. Назовем ее thread_func(url_cat, count_cat, name_cat). На входе данная функция принимает ссылку на категорию, пагинацию и имя категории.

Затем получаем время старта функции, для измерения скорости загрузки картинок. После выводим сообщение о том, что загружается категория такая-то, такая-то. И количество в ней страниц. От вывода сообщения о загрузке определенной картинки я отказался, потому, что порою вывод просто не успевает отобразиться в терминале. И получается перемешанная каша. Так как в итоге отображается все. Поэтому здесь я ограничился выводом сообщения о загрузке определенной страницы. Далее в цикле запускаем запрос, в котором получаем ссылки на картинки с первой и последующих страниц. Затем данные из запроса передаются в объект супа. Проверяется, если ли папка с именем категории. Если нет, создается. И в цикле запускаются потоки, которые привязаны к функции загрузки картинки и передают в нее необходимые параметры. А после того, как скрипт завершит свою работу, выводиться время загрузки картинок.

Python:
def thread_func(url_cat, count_cat, name_cat):
    start_time = time.monotonic()
    print(f'[+] Загружаю категорию "{name_cat}". Количество страниц: {count_cat}\n')
    if not os.path.isdir(name_cat):
        os.mkdir(name_cat)
    for nc in range(1, count_cat + 1):
        print(f'[+] Загружаю >> Страница: {nc}/{count_cat}...')
        req = requests.get(url=f"{url_cat}{nc}/best/", headers=headers)
        soup = BeautifulSoup(req.text, 'lxml')
        all_url_page = soup.find_all('div', class_='wpitem')
        for item in all_url_page:
            t = threading.Thread(target=get_pict_download, kwargs={'item': item, 'name_cat': name_cat})
            t.start()
    print(f'\nВремя загрузки файлов: {time.monotonic() - start_time}')

Осталась только функция main(). В ней для начала запускается функция обновления словаря. Мало ли что, может быть за время, пока не использовался скрипт появились новые категории или добавились обои и поэтому количество страниц может измениться. Тут есть один нюанс, который заключается в том, что запись категорий в словарь будет каждый раз производиться в произвольном порядке, а именно в порядке завершения работы потока. Поэтому отображение категорий всегда будет выводиться по-разному. Ну, или почти всегда. Далее делается небольшая пауза, чтобы дать сохраниться файлу на диск. Так как если этой паузы не делать, скрипт продолжает свою работу, файл еще не успевает сохраниться. А так как на следующем этапе этот словарь открывается для чтения, скрипт вываливается с ошибкой, что файл не найден. Потом формируется словарь из открытого JSON, с помощью которого определяется название категории, а так же ссылка и количество страниц.

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

Python:
def main():
    print('[+] Обновляю словарь...\n')
    thread_func_category()
    time.sleep(2)
    with open('category_res.json', 'r', encoding='utf-8') as file:
        cat_dict = json.load(file)
    dict_cat = {}
    for num, cat in enumerate(cat_dict):
        print(f'{num}. {cat} | {cat_dict[cat]["page_count"]} страниц...')
        dict_cat[num] = {
            'url_category': cat_dict[cat]["url_category"],
            'page_count': cat_dict[cat]["page_count"],
            'name_cat': cat
        }
    num_cat = int(input('\n[+] - Введите номер категории для загрузки: '))

    # передача данных для запуска потоков загрузки картинок
    if num_cat in dict_cat:
        thread_func(f"{dict_cat[num_cat]['url_category']}/", dict_cat[num_cat]['page_count'], dict_cat[num_cat]['name_cat'])
    else:
        print('[-] Вы ввели неверный номер категории для загрузки.')
        exit(0)

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

Я пробовал загрузить самую маленькую категорию, в которой шесть страниц. На каждой странице по двенадцать картинок. То есть, получается, что загружается 69. Скорее всего, потому, что некоторые картинки просто повторяются на страницах. Будем считать, что так, я просто не стал проверять. Так как это слишком долго и муторно. Так вот скрипт без потоков работает примерно от одной до двух минут. В зависимости от загруженности сети. А вот скрипт с потоками от пяти до семи секунд.

Вот небольшое видео, в котором я даю некоторые пояснения по сбору данных со страницы, а так же сравниваю скорость работы скрипта:


Python:
import json
import os.path
import threading
import time

import requests
from bs4 import BeautifulSoup

# заголовки для запроса
headers = {
    'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.119 '
                  'YaBrowser/22.3.0.2434 Yowser/2.5 Safari/537.36',
    'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,'
              'application/signed-exchange;v=b3;q=0.9 '
}

category_dict = {}


# получение пагинации
# находим последнюю станицу и чистим от мусора
# исключение добавлено потому, что есть разделы, у которых
# меньше 7 страниц. В этом случае пагинация немного отличается
def get_page_count(url):
    req = requests.get(url=url, headers=headers)
    soup = BeautifulSoup(req.text, 'lxml')
    try:
        page_count = int(soup.find('div', id='pages').find_all('a')[-1].text.split(" ")[1].strip())
    except:
        page_count = int(soup.find('div', id='pages').find_all('a')[-1].text.strip())
    return page_count


# получаем ссылки на категории и сохраняем с JSON
# получение ссылок происходит при каждом запуске программы
# так как считывается так же и количество стараниц в каждой категории
# и по прошествии времени оно может изменяться
def get_link_category(url):
    url_cats = 'https://w-dog.ru' + url.find('div', class_='word').find('a')['href']
    url_cat = str('https://w-dog.ru' + url.find('div', class_='word').find('a')['href']).split("/")
    url_cat_s = f'{url_cat[0]}//{url_cat[2]}/{url_cat[3]}/{url_cat[4]}/{url_cat[5]}'
    name_category = url.find('div', class_='word').find('a').text.strip()
    p_count = get_page_count(url_cats)
    category_dict[name_category] = {
        'url_category': url_cat_s,
        'page_count': p_count
    }

    with open('category_res.json', 'w', encoding='utf-8') as file:
        json.dump(category_dict, file, indent=4, ensure_ascii=False)


def thread_func_category():
    url = 'https://w-dog.ru/'
    req = requests.get(url=url, headers=headers)
    soup = BeautifulSoup(req.text, 'lxml')
    all_category = soup.find_all('div', class_='wpitem category')
    for url in all_category:
        t = threading.Thread(target=get_link_category, kwargs={'url': url})
        t.start()


# загрузка картинок из категории
# получение названия категории
# создание папки с именем категории куда будут загружаться картинки
# поиск всех ссылок настранице, скачивание их в цикле
# и сохранение в созданную папку
def get_pict_download(item, name_cat, count_cat):
    name_pict = item.find('b', class_='word').text.strip().replace("/", " ").replace('"', ''). \
        replace("'", "").replace(".", "")
    if not os.path.isfile(os.path.join(name_cat, f'{name_pict}.jpg')):
        url_pict = 'https://w-dog.ru' + item.find('div', class_='action-buttons').find('a')['href']
        req = requests.get(url=url_pict, headers=headers)
        with open(os.path.join(name_cat, f'{name_pict}.jpg'), 'wb') as file:
            file.write(req.content)


def thread_func(url_cat, count_cat, name_cat):
    start_time = time.monotonic()
    print(f'[+] Загружаю категорию "{name_cat}". Количество страниц: {count_cat}\n')
    for nc in range(1, count_cat + 1):
        print(f'[+] Загружаю >> Страница: {nc}/{count_cat}...')
        req = requests.get(url=f"{url_cat}{nc}/best/", headers=headers)
        soup = BeautifulSoup(req.text, 'lxml')
        name_cat = soup.find('div', id='content-top').find('h2').text.strip()
        if not os.path.isdir(name_cat):
            os.mkdir(name_cat)
        all_url_page = soup.find_all('div', class_='wpitem')
        for item in all_url_page:
            t = threading.Thread(target=get_pict_download, kwargs={'item': item, 'name_cat': name_cat,
                                                                   'count_cat': count_cat})
            t.start()
    print(f'\nВремя загрузки файлов: {time.monotonic() - start_time}')


def main():
    print('[+] Обновляю словарь...\n')
    thread_func_category()
    time.sleep(2)
    with open('category_res.json', 'r', encoding='utf-8') as file:
        cat_dict = json.load(file)
    dict_cat = {}
    for num, cat in enumerate(cat_dict):
        print(f'{num}. {cat} | {cat_dict[cat]["page_count"]} страниц...')
        dict_cat[num] = {
            'url_category': cat_dict[cat]["url_category"],
            'page_count': cat_dict[cat]["page_count"],
            'name_cat': cat
        }
    num_cat = int(input('\n[+] - Введите номер категории для загрузки: '))

    # передача данных для запуска потоков загрузки картинок
    if num_cat in dict_cat:
        thread_func(f"{dict_cat[num_cat]['url_category']}/", dict_cat[num_cat]['page_count'], dict_cat[num_cat]['name_cat'])
    else:
        print('[-] Вы ввели неверный номер категории для загрузки.')
        exit(0)


if __name__ == "__main__":
    main()

Спасибо за внимание. Надеюсь, что данная информация будет кому-нибудь полезна
 
Python:
Код:
def thread_func_category():
    url = 'https://w-dog.ru/'
    req = requests.get(url=url, headers=headers)
    soup = BeautifulSoup(req.text, 'lxml')
    all_category = soup.find_all('div', class_='wpitem category')
    for url in all_category:
        t = threading.Thread(target=get_link_category, kwargs={'url': url})
        t.start()
Не знаю ли правильно я это понял, но этот кусок кудо он переходит по разным страницам пагинации?
Типо если в пагинации цифра 2, то он переходит в page=2, +парсит?
 
Не знаю ли правильно я это понял, но этот кусок кудо он переходит по разным страницам пагинации?
Типо если в пагинации цифра 2, то он переходит в page=2, +парсит?

Он собирает названия категорий, количество страниц и ссылку на категорию

Python:
def get_link_category(url):
    url_cats = 'https://w-dog.ru' + url.find('div', class_='word').find('a')['href']
    url_cat = str('https://w-dog.ru' + url.find('div', class_='word').find('a')['href']).split("/")
    url_cat_s = f'{url_cat[0]}//{url_cat[2]}/{url_cat[3]}/{url_cat[4]}/{url_cat[5]}'
    name_category = url.find('div', class_='word').find('a').text.strip()
    p_count = get_page_count(url_cats)
    category_dict[name_category] = {
        'url_category': url_cat_s,
        'page_count': p_count
    }

То есть, для каждой ссылки запускается поток, который обрабатывает ссылку вот этой функцией.
 
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!