Современная веб-автоматизация с помощью Python и Selenium

Оглавление

В этом уроке вы узнаете о продвинутых методах веб-автоматизации на Python: использовании Selenium с "безголовым" браузером, экспорте собранных данных в CSV-файлы, а также обертке кода скраппинга в класс Python.

Мотивация: Отслеживание привычек слушателя

Предположим, вы уже некоторое время слушаете музыку на bandcamp, и вам захотелось вспомнить песню, которую вы слышали несколько месяцев назад.

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

"Было бы здорово, - думаете вы, - если бы у меня была запись моей истории прослушивания? Я мог бы просто поискать электронные песни двухмесячной давности и обязательно нашел бы их."

Сегодня вы создадите базовый класс Python под названием BandLeader, который подключается к сайту bandcamp.com, транслирует музыку из раздела "discovery" на первой странице и отслеживает историю прослушивания.

История прослушивания будет сохранена на диске в файле CSV. Затем вы можете изучить этот CSV-файл в вашем любимом приложении для работы с электронными таблицами или даже в Python.

Если у вас был опыт работы с web scraping в Python, вы знакомы с выполнением HTTP-запросов и использованием Pythonic API для навигации по DOM. Сегодня вы будете делать все то же самое, но с одним отличием.

Сегодня вы будете использовать полноценный браузер, работающий в режиме headless, чтобы выполнять HTTP-запросы за вас.

Безголовый браузер - это обычный веб-браузер, за исключением того, что он не содержит видимых элементов пользовательского интерфейса. Как и следовало ожидать, он может не только выполнять запросы: он также может отображать HTML (хотя вы его не видите), хранить информацию о сеансе и даже выполнять асинхронные сетевые взаимодействия, запуская JavaScript код.

Если вы хотите автоматизировать современный веб, безголовые браузеры просто необходимы.

Настройка

Ваш первый шаг, прежде чем написать хоть одну строчку на Python, - установить Selenium с поддержкой WebDriver для вашего любимого веб-браузера. Далее вы будете работать с Firefox, но Chrome тоже вполне может подойти.

Предполагая, что путь ~/.local/bin находится в вашем исполнении PATH, вот как вы установите Firefox WebDriver, называемый geckodriver, на Linux-машине:

$ wget https://github.com/mozilla/geckodriver/releases/download/v0.19.1/geckodriver-v0.19.1-linux64.tar.gz
$ tar xvfz geckodriver-v0.19.1-linux64.tar.gz
$ mv geckodriver ~/.local/bin

Далее вы устанавливаете пакет selenium, используя pip или любое другое удобное для вас средство. Если вы создали виртуальную среду для этого проекта, то просто введите:

$ pip install selenium

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

Теперь настало время для тест-драйва.

Тест вождения безголового браузера

Чтобы проверить, все ли работает, вы решаете опробовать базовый веб-поиск через DuckDuckGo. Вы запускаете предпочтительный интерпретатор Python и набираете следующее:

>>> from selenium.webdriver import Firefox
>>> from selenium.webdriver.firefox.options import Options
>>> opts = Options()
>>> opts.set_headless()
>>> assert opts.headless  # Operating in headless mode
>>> browser = Firefox(options=opts)
>>> browser.get('https://duckduckgo.com')

На данный момент вы создали безголовый браузер Firefox и перешли по адресу https://duckduckgo.com. Вы создали экземпляр Options и использовали его для активации безголового режима, когда передали его в конструктор Firefox. Это сродни вводу firefox -headless в командной строке.

Python Web Scraping: Duck Duck Go Screenshot

Теперь, когда страница загружена, вы можете запрашивать DOM с помощью методов, определенных для вашего только что созданного объекта browser. Но как узнать, что именно запрашивать?

Лучший способ - открыть веб-браузер и с помощью его инструментов разработчика просмотреть содержимое страницы. Сейчас вы хотите получить доступ к форме поиска, чтобы отправить запрос. Осмотрев главную страницу DuckDuckGo, вы обнаружите, что элемент формы поиска <input> имеет атрибут id "search_form_input_homepage". Это как раз то, что вам нужно:

>>> search_form = browser.find_element_by_id('search_form_input_homepage')
>>> search_form.send_keys('real python')
>>> search_form.submit()

Вы нашли форму поиска, использовали метод send_keys для ее заполнения, а затем метод submit для выполнения поиска "Real Python". Вы можете проверить верхний результат:

>>> results = browser.find_elements_by_class_name('result')
>>> print(results[0].text)

Real Python - Real Python
Get Real Python and get your hands dirty quickly so you spend more time making real applications. Real Python teaches Python and web development from the ground up ...
https://realpython.com

Все вроде бы работает. Чтобы невидимые экземпляры безголового браузера не скапливались на вашей машине, вы закрываете объект браузера перед выходом из сеанса Python:

>>> browser.close()
>>> quit()

"Groovin' on Tunes"

Вы проверили, что можете управлять безголовым браузером с помощью Python. Теперь вы можете применить это на практике:

  1. Вы хотите слушать музыку.
  2. Вы хотите просматривать и изучать музыку.
  3. Вы хотите получить информацию о том, какая музыка играет.

Для начала вы переходите на сайт https://bandcamp.com и начинаете копаться в инструментах разработчика вашего браузера. Вы обнаруживаете большую блестящую кнопку воспроизведения в нижней части экрана с атрибутом class, который содержит значение"playbutton". Вы проверяете, что она работает:

Python Web Scraping: Bandcamp Discovery Section

>>> opts = Option()
>>> opts.set_headless()
>>> browser = Firefox(options=opts)
>>> browser.get('https://bandcamp.com')
>>> browser.find_element_by_class('playbutton').click()

Вы должны услышать музыку! Оставьте ее играть и вернитесь в веб-браузер. Сразу за кнопкой воспроизведения находится раздел обнаружения. Вы снова осматриваете этот раздел и обнаруживаете, что каждый из доступных в данный момент треков имеет значение class, равное "discover-item", и что каждый элемент, похоже, можно нажать. В Python вы проверяете это:

>>> tracks = browser.find_elements_by_class_name('discover-item')
>>> len(tracks)  # 8
>>> tracks[3].click()

Должна заиграть новая композиция! Это первый шаг к изучению bandcamp с помощью Python! Вы проводите несколько минут, нажимая на различные треки в среде Python, но вскоре устаете от скудной библиотеки из восьми песен.

Изучение каталога

Заглянув в браузер, вы увидите кнопки для изучения всех треков, представленных в разделе музыкальных открытий bandcamp. Теперь это кажется знакомым: каждая кнопка имеет значение class, равное "item-page". Самая последняя кнопка - это кнопка "next", которая отображает следующие восемь треков в каталоге. Вы приступаете к работе:

>>> next_button = [e for e in browser.find_elements_by_class_name('item-page')
                   if e.text.lower().find('next') > -1]
>>> next_button.click()

Отлично! Теперь вы хотите посмотреть на новые треки, и вы думаете: "Я просто заново заполню переменную tracks, как я это сделал несколько минут назад". Но вот тут-то и начинаются сложности.

Во-первых, bandcamp создал свой сайт для людей, чтобы им было приятно пользоваться, а не для скриптов Python, чтобы получить к нему программный доступ. Когда вы вызываете next_button.click(), настоящий веб-браузер отвечает выполнением некоторого кода JavaScript.

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

Какое решение? Вы можете просто заснуть на секунду, или, если вы просто выполняете все это в оболочке Python, вы, вероятно, даже не заметите. В конце концов, на ввод текста у вас тоже уходит время.

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

>>> tracks = browser.find_elements_by_class_name('discover-item')
>>> assert(len(tracks) == 8)
AssertionError
...

Но вы замечаете нечто странное. len(tracks) не равен 8, хотя должна отображаться только следующая партия 8. Покопавшись еще немного, вы обнаруживаете, что в вашем списке есть несколько треков, которые отображались ранее. Чтобы получить только те треки, которые действительно видны в браузере, нужно немного отфильтровать результаты.

Попробовав несколько вариантов, вы решили сохранять трек только в том случае, если его x координаты на странице попадают в ограничительную рамку содержащего элемента. Контейнер каталога имеет class значение "discover-results". Вот как следует действовать:

>>> discover_section = self.browser.find_element_by_class_name('discover-results')
>>> left_x = discover_section.location['x']
>>> right_x = left_x + discover_section.size['width']
>>> discover_items = browser.find_element_by_class_name('discover_items')
>>> tracks = [t for t in discover_items
              if t.location['x'] >= left_x and t.location['x'] < right_x]
>>> assert len(tracks) == 8

Построение класса

Если вам надоело снова и снова набирать одни и те же команды в среде Python, вам стоит перенести часть из них в модуль. Базовый класс для манипуляций с bandcamp должен делать следующее:

  1. Инициализируйте безголовый браузер и перейдите на bandcamp
  2. .
  3. Вести список доступных треков
  4. Поддерживать поиск дополнительных треков
  5. Воспроизведение, пауза и пропуск треков

Вот основной код, весь в одном действии:

from selenium.webdriver import Firefox
from selenium.webdriver.firefox.options import Options
from time import sleep, ctime
from collections import namedtuple
from threading import Thread
from os.path import isfile
import csv


BANDCAMP_FRONTPAGE='https://bandcamp.com/'

class BandLeader():
    def __init__(self):
        # Create a headless browser
        opts = Options()
        opts.set_headless()     
        self.browser = Firefox(options=opts)
        self.browser.get(BANDCAMP_FRONTPAGE)

        # Track list related state
        self._current_track_number = 1
        self.track_list = []
        self.tracks()

    def tracks(self):
        '''
        Query the page to populate a list of available tracks.
        '''

        # Sleep to give the browser time to render and finish any animations
        sleep(1)

        # Get the container for the visible track list
        discover_section = self.browser.find_element_by_class_name('discover-results')
        left_x = discover_section.location['x']
        right_x = left_x + discover_section.size['width']

        # Filter the items in the list to include only those we can click
        discover_items = self.browser.find_elements_by_class_name('discover-item')
        self.track_list = [t for t in discover_items
                           if t.location['x'] >= left_x and t.location['x'] < right_x]

        # Print the available tracks to the screen
        for (i,track) in enumerate(self.track_list):
            print('[{}]'.format(i+1))
            lines = track.text.split('\n')
            print('Album  : {}'.format(lines[0]))
            print('Artist : {}'.format(lines[1]))
            if len(lines) > 2:
                print('Genre  : {}'.format(lines[2]))

    def catalogue_pages(self):
        '''
        Print the available pages in the catalogue that are presently
        accessible.
        '''
        print('PAGES')
        for e in self.browser.find_elements_by_class_name('item-page'):
            print(e.text)
        print('')


    def more_tracks(self,page='next'):
        '''
        Advances the catalogue and repopulates the track list. We can pass in a number
        to advance any of the available pages.
        '''

        next_btn = [e for e in self.browser.find_elements_by_class_name('item-page')
                    if e.text.lower().strip() == str(page)]

        if next_btn:
            next_btn[0].click()
            self.tracks()

    def play(self,track=None):
        '''
        Play a track. If no track number is supplied, the presently selected track
        will play.
        '''

       if track is None:
            self.browser.find_element_by_class_name('playbutton').click()
       elif type(track) is int and track <= len(self.track_list) and track >= 1:
            self._current_track_number = track
            self.track_list[self._current_track_number - 1].click()


    def play_next(self):
        '''
        Plays the next available track
        '''
        if self._current_track_number < len(self.track_list):
            self.play(self._current_track_number+1)
        else:
            self.more_tracks()
            self.play(1)


    def pause(self):
        '''
        Pauses the playback
        '''
        self.play()

Довольно аккуратно. Вы можете импортировать это в свою среду Python и запускать bandcamp программно! Но подождите, разве вы не начали все это потому, что хотели отслеживать информацию о своей истории прослушивания?

Сбор структурированных данных

Ваша последняя задача - отслеживать песни, которые вы действительно слушали. Как вы можете это сделать? Что вообще значит слушать что-то на самом деле? Если вы просматриваете каталог, останавливаясь на несколько секунд на каждой песне, считаются ли все эти песни? Скорее всего, нет. Вам нужно оставить время на "исследование", чтобы учесть его при сборе данных.

Теперь ваши цели:

  1. Собирать структурированную информацию о текущем воспроизводимом треке
  2. Хранить "базу данных" треков
  3. Сохранять и восстанавливать эту "базу данных" на диск и с диска

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

TrackRec = namedtuple('TrackRec', [
    'title', 
    'artist',
    'artist_url', 
    'album',
    'album_url', 
    'timestamp'  # When you played it
])

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

Исходя из этих соображений, вы пишете несколько методов:

    def is_playing(self):
        '''
        Returns `True` if a track is presently playing
        '''
        playbtn = self.browser.find_element_by_class_name('playbutton')
        return playbtn.get_attribute('class').find('playing') > -1


    def currently_playing(self):
        '''
        Returns the record for the currently playing track,
        or None if nothing is playing
        '''
        try:
            if self.is_playing():
                title = self.browser.find_element_by_class_name('title').text
                album_detail = self.browser.find_element_by_css_selector('.detail-album > a')
                album_title = album_detail.text
                album_url = album_detail.get_attribute('href').split('?')[0]
                artist_detail = self.browser.find_element_by_css_selector('.detail-artist > a')
                artist = artist_detail.text
                artist_url = artist_detail.get_attribute('href').split('?')[0]
                return TrackRec(title, artist, artist_url, album_title, album_url, ctime())

        except Exception as e:
            print('there was an error: {}'.format(e))

        return None

Для надежности вы также модифицируете метод play(), чтобы отслеживать текущую воспроизводимую дорожку:

    def play(self, track=None):
        '''
        Play a track. If no track number is supplied, the presently selected track
        will play.
        '''

        if track is None:
            self.browser.find_element_by_class_name('playbutton').click()
        elif type(track) is int and track <= len(self.track_list) and track >= 1:
            self._current_track_number = track
            self.track_list[self._current_track_number - 1].click()

        sleep(0.5)
        if self.is_playing():
            self._current_track_record = self.currently_playing()

Далее, вам нужно вести какую-то базу данных. Хотя в долгосрочной перспективе это может быть не очень удобно, вы можете далеко пойти с простым списком. Вы добавляете self.database = [] в метод BandCamp __init__(). Поскольку вы хотите, чтобы перед вводом объекта TrackRec в базу данных прошло время, вы решаете использовать средства Python для запуска отдельного процесса, поддерживающего базу данных в фоновом режиме.

Вы предоставите метод _maintain() экземплярам BandLeader, которые будут выполняться в отдельном потоке. Метод new будет периодически проверять значение self._current_track_record и добавлять его в базу данных, если оно новое.

Вы запустите поток при инстанцировании класса, добавив некоторый код в __init__():

    # The new init
    def __init__(self):
        # Create a headless browser
        opts = Options()
        opts.set_headless()     
        self.browser = Firefox(options=opts)
        self.browser.get(BANDCAMP_FRONTPAGE)

        # Track list related state
        self._current_track_number = 1
        self.track_list = []
        self.tracks()

        # State for the database
        self.database = []
        self._current_track_record = None

        # The database maintenance thread
        self.thread = Thread(target=self._maintain)
        self.thread.daemon = True    # Kills the thread with the main process dies
        self.thread.start()

        self.tracks()


    def _maintain(self):
        while True:
            self._update_db()
            sleep(20)          # Check every 20 seconds


    def _update_db(self):
        try:
            check = (self._current_track_record is not None
                     and (len(self.database) == 0
                          or self.database[-1] != self._current_track_record)
                     and self.is_playing())
            if check:
                self.database.append(self._current_track_record)

        except Exception as e:
            print('error while updating the db: {}'.format(e)

Если вы никогда не работали с многопоточным программированием в Python, вам стоит почитать об этом! Для нынешних целей можно представить поток как цикл, который выполняется в фоновом режиме главного процесса Python (того, с которым вы взаимодействуете напрямую). Каждые двадцать секунд цикл проверяет несколько параметров, чтобы узнать, нужно ли обновить базу данных, и если нужно, добавляет новую запись. Довольно круто.

Самый последний шаг - сохранение базы данных и восстановление из сохраненных состояний. Используя пакет csv, вы можете гарантировать, что ваша база данных будет сохранена в переносимом формате и останется пригодной для использования, даже если вы забросите свой замечательный класс BandLeader!

Метод __init__() должен быть еще раз изменен, на этот раз для приема пути к файлу, в котором вы хотите сохранить базу данных. Вы хотите загружать эту базу данных, если она доступна, и сохранять ее периодически, при каждом обновлении. Обновления выглядят следующим образом:

    def __init__(self,csvpath=None):
        self.database_path=csvpath
        self.database = []      

        # Load database from disk if possible
        if isfile(self.database_path):
            with open(self.database_path, newline='') as dbfile:
                dbreader = csv.reader(dbfile)
                next(dbreader)   # To ignore the header line
                self.database = [TrackRec._make(rec) for rec in dbreader]

        # .... The rest of the __init__ method is unchanged ....


    # A new save_db() method
    def save_db(self):
        with open(self.database_path,'w',newline='') as dbfile:
            dbwriter = csv.writer(dbfile)
            dbwriter.writerow(list(TrackRec._fields))
            for entry in self.database:
                dbwriter.writerow(list(entry))


    # Finally, add a call to save_db() to your database maintenance method
    def _update_db(self):
        try:
            check = (self._current_track_record is not None
                     and self._current_track_record is not None
                     and (len(self.database) == 0
                          or self.database[-1] != self._current_track_record)
                     and self.is_playing())
            if check:
                self.database.append(self._current_track_record)
                self.save_db()

        except Exception as e:
            print('error while updating the db: {}'.format(e)

Войла! Вы можете слушать музыку и записывать услышанное! Потрясающе.

Интересным в этой истории является то, что использование namedtuple действительно начинает окупаться. При преобразовании в формат CSV и обратно вы используете упорядоченность строк в файле CSV для заполнения строк в объектах TrackRec. Аналогичным образом можно создать строку заголовка CSV-файла, обратившись к атрибуту TrackRec._fields. Это одна из причин, по которой использование кортежа в конечном итоге имеет смысл для столбцовых данных.

Что дальше и чему вы научились?

Вы могли бы сделать гораздо больше! Вот несколько быстрых идей, которые позволят использовать мягкую суперсилу, которой является Python + Selenium:

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

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

Вернуться на верх