Skip to content

Commit

Permalink
Add Selenium project code
Browse files Browse the repository at this point in the history
  • Loading branch information
martin-martin committed Oct 14, 2024
1 parent 754c56e commit 56c2360
Show file tree
Hide file tree
Showing 13 changed files with 318 additions and 0 deletions.
42 changes: 42 additions & 0 deletions python-selenium/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Modern Web Automation With Python and Selenium

This repository contains the module `bandcamp`, which is the sample app built in the Real Python tutorial [Modern Web Automation With Python and Selenium](https://realpython.com/modern-web-automation-with-python-and-selenium/).

## Installation and Setup

Create and activate a [Python virtual environment](https://realpython.com/python-virtual-environments-a-primer/).

Then, install the requirements:

```sh
(venv) $ python -m pip install -r requirements.txt
```

The only direct dependency for this project is [Selenium](https://selenium-python.readthedocs.io/). You should use a Python version of at least 3.8.

## Run the Bandcamp Discover Player

To run the music placer, navigate to the `src/` folder, then execute the module from your command-line:

```sh
(venv) $ cd src/
(venv) $ python -m bandcamp
```

You'll see a text-based user interface that allows you to interact with the music player:

```
Type: [play <track number>], [tracks], [more], [exit]
>
```

Type one of the available commands to interact with Bandcamp's Discover section through your headless browser. Listen to songs with `play`, list available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`.

## About the Authors

Martin Breuss - Email: [email protected]
Bartosz Zaczyński - Email: [email protected]

## License

Distributed under the MIT license. See ``LICENSE`` for more information.
21 changes: 21 additions & 0 deletions python-selenium/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "bandcamp_player"
version = "0.1.0"
requires-python = ">=3.8"
description = "A web player for Bandcamp using Selenium"
authors = [
{ name = "Martin Breuss", email = "[email protected]" },
{ name = "Bartosz Zaczyński", email = "[email protected]" },
]
dependencies = [
"selenium",
]
[project.scripts]
bandcamp-player = "bandcamp.__main__:main"

[tool.setuptools.packages.find]
where = ["src"]
22 changes: 22 additions & 0 deletions python-selenium/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
appdirs==1.4.4
attrs==24.2.0
certifi==2024.7.4
h11==0.14.0
idna==3.7
jedi==0.19.1
outcome==1.3.0.post0
parso==0.8.4
prompt_toolkit==3.0.47
ptpython==3.0.29
Pygments==2.18.0
PySocks==1.7.1
selenium==4.23.1
sniffio==1.3.1
sortedcontainers==2.4.0
trio==0.26.1
trio-websocket==0.11.1
typing_extensions==4.12.2
urllib3==2.2.2
wcwidth==0.2.13
websocket-client==1.8.0
wsproto==1.2.0
Empty file.
11 changes: 11 additions & 0 deletions python-selenium/src/bandcamp/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from bandcamp.app.tui import TUI


def main():
"""Provides the main entry point for the app."""
tui = TUI()
tui.interact()


if __name__ == "__main__":
main()
Empty file.
45 changes: 45 additions & 0 deletions python-selenium/src/bandcamp/app/player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from selenium.webdriver import Firefox
from selenium.webdriver.firefox.options import Options

from bandcamp.web.element import TrackElement
from bandcamp.web.page import HomePage

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


class Player:
"""Plays tracks from Bandcamp's Discover section."""

def __init__(self) -> None:
self._driver = self._set_up_driver()
self.home = HomePage(self._driver)
self.discover = self.home.discover_tracklist
self._current_track = TrackElement(
self.home.discover_tracklist.available_tracks[0], self._driver
)

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, exc_tb):
"""Closes the headless browser."""
self._driver.close()

def play(self, track_number=None):
"""Plays the first track, or one of the available numbered tracks."""
if track_number:
self._current_track = TrackElement(
self.home.discover_tracklist.available_tracks[
track_number - 1
],
self._driver,
)
self._current_track.play()

def _set_up_driver(self):
"""Creates a headless browser pointing to Bandcamp."""
options = Options()
options.add_argument("--headless")
browser = Firefox(options=options)
browser.get(BANDCAMP_FRONTPAGE)
return browser
66 changes: 66 additions & 0 deletions python-selenium/src/bandcamp/app/tui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from bandcamp.app.player import Player


class TUI:
"""Provides a text-based user interface for a Bandcamp music player."""

COLUMN_WIDTH = CW = 30

def interact(self):
"""Controls the player through user interactions."""
with Player() as player:
while True:
print(
"\nType: [play <track number>], [tracks], [more], [exit]"
)
command = input("> ").strip().lower()

if command.startswith("play"):
try:
track_number = int(command.split()[1])
self.play(player, track_number)
except IndexError: # Play first track.
self.play(player)
except ValueError:
print("Please provide a valid track number.")
elif command == "tracks":
self.tracks(player)
elif command == "more":
player.discover.load_more()
self.tracks(player)
elif command == "exit":
print("Exiting the player...")
break
else:
print("Unknown command. Try again.")

def play(self, player, track_number=None):
"""Plays a track and shows info about the track."""
player.play(track_number)
print(player._current_track._get_track_info())

def tracks(self, player):
"""Displays information about the currently playable tracks."""
header = (
f"{'#':<5} {'Album':<{self.CW}} "
f"{'Artist':<{self.CW}} "
f"{'Genre':<{self.CW}}"
)
print(header)
print("-" * 100)
for track_number, track in enumerate(
player.discover.available_tracks, start=1
):
album, artist, *genre = track.text.split("\n")
album = self._truncate(album, self.CW)
artist = self._truncate(artist, self.CW)
genre = self._truncate(genre[0], self.CW) if genre else ""
print(
f"{track_number:<5} {album:<{self.CW}} "
f"{artist:<{self.CW}} {genre:<{self.CW}}"
)

@staticmethod
def _truncate(text, width):
"""Truncates track information."""
return text[: width - 3] + "..." if len(text) > width else text
Empty file.
27 changes: 27 additions & 0 deletions python-selenium/src/bandcamp/web/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from dataclasses import dataclass

from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.wait import WebDriverWait

MAX_WAIT_SECONDS = 10.0


@dataclass
class Track:
album: str
artist: str
genre: str
url: str


class WebPage:
def __init__(self, driver: WebDriver) -> None:
self._driver = driver
self._wait = WebDriverWait(driver, MAX_WAIT_SECONDS)


class WebComponent(WebPage):
def __init__(self, parent: WebElement, driver: WebDriver) -> None:
super().__init__(driver)
self._parent = parent
55 changes: 55 additions & 0 deletions python-selenium/src/bandcamp/web/element.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement

from bandcamp.web.base import Track, WebComponent
from bandcamp.web.locators import HomePageLocatorMixin, TrackLocatorMixin


class TrackElement(WebComponent, TrackLocatorMixin):
"""Models a playable track in Bandcamp's Discover section."""

def play(self) -> None:
"""Plays the track."""
if not self.is_playing():
self._get_play_button().click()
self._wait.until(lambda _: self.is_playing())

def is_playing(self) -> bool:
return "playing" in self._get_play_button().get_attribute("class")

def _get_track_info(self) -> Track:
"""Creates a representation of the track's relevant information."""
full_url = self._parent.find_element(*self.ALBUM).get_attribute("href")
# Cut off the referrer query parameter
clean_url = full_url.split("?")[0] if full_url else ""
return Track(
album=self._parent.find_element(*self.ALBUM).text,
artist=self._parent.find_element(*self.ARTIST).text,
genre=self._parent.find_element(*self.GENRE).text,
url=clean_url,
)

def _get_play_button(self):
return self._parent.find_element(*self.PLAY_BUTTON)


class DiscoverTrackList(WebComponent, HomePageLocatorMixin):
"""Models the track list in Bandcamp's Discover section."""

def __init__(self, parent: WebElement, driver: WebDriver = None) -> None:
super().__init__(parent, driver)
self.available_tracks = self._get_available_tracks()

def load_more(self) -> None:
"""Loads additional tracks in the Discover section."""
self._get_next_page_button().click()
self.available_tracks = self._get_available_tracks()

def _get_available_tracks(self) -> list:
"""Finds all currently available tracks in the Discover section."""
all_tracks = self._driver.find_elements(*self.TRACK)
return [track for track in all_tracks if track.is_displayed()]

def _get_next_page_button(self):
"""Locates and returns the 'Next' button that loads more results."""
return self._driver.find_elements(*self.PAGINATION_BUTTON)[-1]
14 changes: 14 additions & 0 deletions python-selenium/src/bandcamp/web/locators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from selenium.webdriver.common.by import By


class HomePageLocatorMixin:
DISCOVER_RESULTS = (By.CLASS_NAME, "discover-results")
TRACK = (By.CLASS_NAME, "discover-item")
PAGINATION_BUTTON = (By.CLASS_NAME, "item-page")


class TrackLocatorMixin:
PLAY_BUTTON = (By.CSS_SELECTOR, "a")
ALBUM = (By.CLASS_NAME, "item-title")
GENRE = (By.CLASS_NAME, "item-genre")
ARTIST = (By.CLASS_NAME, "item-artist")
15 changes: 15 additions & 0 deletions python-selenium/src/bandcamp/web/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from selenium.webdriver.remote.webdriver import WebDriver

from bandcamp.web.base import WebPage
from bandcamp.web.element import DiscoverTrackList
from bandcamp.web.locators import HomePageLocatorMixin


class HomePage(WebPage, HomePageLocatorMixin):
"""Models the relevant parts of the Bandcamp home page."""

def __init__(self, driver: WebDriver) -> None:
super().__init__(driver)
self.discover_tracklist = DiscoverTrackList(
self._driver.find_element(*self.DISCOVER_RESULTS), self._driver
)

0 comments on commit 56c2360

Please sign in to comment.