diff --git a/python-selenium/README.md b/python-selenium/README.md new file mode 100644 index 0000000000..f9b1d5c658 --- /dev/null +++ b/python-selenium/README.md @@ -0,0 +1,64 @@ +# 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.10, which is necessary to support [structural pattern matching](https://realpython.com/structural-pattern-matching/). + +You'll need a [Firefox Selenium driver](https://selenium-python.readthedocs.io/installation.html#drivers) called `geckodriver` to run the project as-is. Make sure to [download and install](https://github.com/mozilla/geckodriver/releases) it before running the project. + +## Run the Bandcamp Discover Player + +To run the music player, install the package, then use the entry-point command from your command line: + +```sh +(venv) $ python -m pip install . +(venv) $ bandcamp-player +``` + +You'll see a text-based user interface that allows you to interact with the music player: + +``` +Type: play [] | pause | 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`, pause the current song with `pause`, and restart it with `play`. List available tracks with `tracks`, and load more songs using `more`. You can exit the music player by typing `exit`. + +## Troubleshooting + +If the music player seems to hang when you run the script, confirm whether you've correctly set up your WebDriver based on the following points. + +### Version Compatibility + +Confirm that your browser and corresponding WebDriver are in sync. If you followed the previous suggestion, then you should be using Firefox and geckodriver. If that doesn't work for some reason, then you may need to switch your browser _and_ WebDriver. + +For example, if you're using Chrome, then you need to install ChromeDriver and it must match your Chrome version. Otherwise, you may run into errors like `SessionNotCreatedException`. +For more details, refer to the official [ChromeDriver documentation](https://sites.google.com/chromium.org/driver/) or [geckodriver releases](https://github.com/mozilla/geckodriver/releases). + +### Driver Installation and Path Issues + +Once you've confirmed that your browser and driver match, make sure that the WebDriver executable is properly installed: + +- **Path Configuration:** The driver must be in your system's PATH, or you need to specify its full path in your code. +- **Permissions:** Ensure the driver file has the necessary execution permissions. + +If you're still running into issues executing the script, then consult the [Selenium Documentation](https://www.selenium.dev/documentation/) for additional troubleshooting tips or leave a comment in the tutorial. + +## About the Authors + +Martin Breuss - Email: martin@realpython.com +Bartosz Zaczyński - Email: bartosz@realpython.com + +## License + +Distributed under the MIT license. diff --git a/python-selenium/pyproject.toml b/python-selenium/pyproject.toml new file mode 100644 index 0000000000..940a8631a8 --- /dev/null +++ b/python-selenium/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pycamp" +version = "0.1.1" +requires-python = ">=3.10" +description = "A CLI music player for Bandcamp's Discover page using Python and Selenium" +license = "MIT" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent" +] +authors = [ + { name = "Martin Breuss", email = "martin@realpython.com" }, + { name = "Bartosz Zaczyński", email = "bartosz@realpython.com" }, +] +dependencies = [ + "selenium", +] +[project.scripts] +discover = "bandcamp.__main__:main" +pycamp = "bandcamp.__main__:main" diff --git a/python-selenium/requirements.txt b/python-selenium/requirements.txt new file mode 100644 index 0000000000..42fd421e96 --- /dev/null +++ b/python-selenium/requirements.txt @@ -0,0 +1,15 @@ +attrs==25.2.0 +certifi==2025.1.31 +h11==0.14.0 +idna==3.10 +outcome==1.3.0.post0 +pysocks==1.7.1 +selenium==4.29.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +trio==0.29.0 +trio-websocket==0.12.2 +typing-extensions==4.12.2 +urllib3==2.3.0 +websocket-client==1.8.0 +wsproto==1.2.0 diff --git a/python-selenium/src/bandcamp/__init__.py b/python-selenium/src/bandcamp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-selenium/src/bandcamp/__main__.py b/python-selenium/src/bandcamp/__main__.py new file mode 100644 index 0000000000..f0cba06399 --- /dev/null +++ b/python-selenium/src/bandcamp/__main__.py @@ -0,0 +1,6 @@ +from bandcamp.app.tui import interact + + +def main(): + """Provide the main entry point for the app.""" + interact() diff --git a/python-selenium/src/bandcamp/app/__init__.py b/python-selenium/src/bandcamp/app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-selenium/src/bandcamp/app/player.py b/python-selenium/src/bandcamp/app/player.py new file mode 100644 index 0000000000..f5cebe6ea2 --- /dev/null +++ b/python-selenium/src/bandcamp/app/player.py @@ -0,0 +1,43 @@ +from selenium.webdriver import Firefox +from selenium.webdriver.firefox.options import Options + +from bandcamp.web.pages import DiscoverPage + +BANDCAMP_DISCOVER_URL = "https://bandcamp.com/discover/" + + +class Player: + """Play tracks from Bandcamp's Discover page.""" + + def __init__(self) -> None: + self._driver = self._set_up_driver() + self.page = DiscoverPage(self._driver) + self.tracklist = self.page.discover_tracklist + self._current_track = self.tracklist.available_tracks[0] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + """Close the headless browser.""" + self._driver.quit() + + def play(self, track_number=None): + """Play the first track, or one of the available numbered tracks.""" + if track_number: + self._current_track = self.tracklist.available_tracks[ + track_number - 1 + ] + self._current_track.play() + + def pause(self): + """Pause the current track.""" + self._current_track.pause() + + def _set_up_driver(self): + """Create a headless browser pointing to Bandcamp.""" + options = Options() + options.add_argument("--headless") + browser = Firefox(options=options) + browser.get(BANDCAMP_DISCOVER_URL) + return browser diff --git a/python-selenium/src/bandcamp/app/tui.py b/python-selenium/src/bandcamp/app/tui.py new file mode 100644 index 0000000000..c12e166ced --- /dev/null +++ b/python-selenium/src/bandcamp/app/tui.py @@ -0,0 +1,77 @@ +from bandcamp.app.player import Player + +COLUMN_WIDTH = CW = 30 +MAX_TRACKS = 100 # Allows to load more tracks once. + + +def interact(): + """Control the player through user interactions.""" + with Player() as player: + while True: + print( + "\nType: play [] | pause | tracks | more | exit" + ) + match input("> ").strip().lower().split(): + case ["play"]: + play(player) + case ["play", track]: + try: + track_number = int(track) + play(player, track_number) + except ValueError: + print("Please provide a valid track number.") + case ["pause"]: + pause(player) + case ["tracks"]: + display_tracks(player) + case ["more"] if ( + len(player.tracklist.available_tracks) >= MAX_TRACKS + ): + print( + "Can't load more tracks. Pick one from the track list." + ) + case ["more"]: + player.tracklist.load_more() + display_tracks(player) + case ["exit"]: + print("Exiting the player...") + break + case _: + print("Unknown command. Try again.") + + +def play(player, track_number=None): + """Play a track and show info about the track.""" + try: + player.play(track_number) + print(player._current_track._get_track_info()) + except IndexError: + print( + "Please provide a valid track number. " + "You can list available tracks with `tracks`." + ) + + +def pause(player): + """Pause the current track.""" + player.pause() + + +def display_tracks(player): + """Display information about the currently playable tracks.""" + header = f"{'#':<5} {'Album':<{CW}} {'Artist':<{CW}} {'Genre':<{CW}}" + print(header) + print("-" * 80) + for track_number, track_element in enumerate( + player.tracklist.available_tracks, start=1 + ): + track = track_element._get_track_info() + album = _truncate(track.album, CW) + artist = _truncate(track.artist, CW) + genre = _truncate(track.genre, CW) + print(f"{track_number:<5} {album:<{CW}} {artist:<{CW}} {genre:<{CW}}") + + +def _truncate(text, width): + """Truncate track information.""" + return text[: width - 3] + "..." if len(text) > width else text diff --git a/python-selenium/src/bandcamp/web/__init__.py b/python-selenium/src/bandcamp/web/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-selenium/src/bandcamp/web/base.py b/python-selenium/src/bandcamp/web/base.py new file mode 100644 index 0000000000..4212844f13 --- /dev/null +++ b/python-selenium/src/bandcamp/web/base.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from pprint import pformat + +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 +DEFAULT_WINDOW_SIZE = (1920, 3000) + + +@dataclass +class Track: + album: str + artist: str + genre: str + url: str + + def __str__(self): + return pformat(self) + + +class WebPage: + def __init__(self, driver: WebDriver) -> None: + self._driver = driver + self._driver.set_window_size(*DEFAULT_WINDOW_SIZE) + self._driver.implicitly_wait(5) + self._wait = WebDriverWait(driver, MAX_WAIT_SECONDS) + + +class WebComponent(WebPage): + def __init__(self, parent: WebElement, driver: WebDriver) -> None: + super().__init__(driver) + self._parent = parent diff --git a/python-selenium/src/bandcamp/web/elements.py b/python-selenium/src/bandcamp/web/elements.py new file mode 100644 index 0000000000..a1a43275da --- /dev/null +++ b/python-selenium/src/bandcamp/web/elements.py @@ -0,0 +1,90 @@ +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support import expected_conditions as EC + +from bandcamp.web.base import Track, WebComponent +from bandcamp.web.locators import TrackListLocator, TrackLocator + + +class TrackListElement(WebComponent): + """Model the track list on Bandcamp's Discover page.""" + + 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: + """Load additional tracks.""" + view_more_button = self._driver.find_element( + *TrackListLocator.PAGINATION_BUTTON + ) + view_more_button.click() + # The button is disabled until all new tracks are loaded. + self._wait.until( + EC.element_to_be_clickable(TrackListLocator.PAGINATION_BUTTON) + ) + self.available_tracks = self._get_available_tracks() + + def _get_available_tracks(self) -> list: + """Find all currently available tracks.""" + self._wait.until( + self._track_text_loaded, + message="Timeout waiting for track text to load", + ) + + all_tracks = self._driver.find_elements(*TrackListLocator.ITEM) + + # Filter tracks that are displayed and have text. + return [ + TrackElement(track, self._driver) + for track in all_tracks + if track.is_displayed() and track.text.strip() + ] + + def _track_text_loaded(self, driver): + """Check if the track text has loaded.""" + return any( + e.is_displayed() and e.text.strip() + for e in driver.find_elements(*TrackListLocator.ITEM) + ) + + +class TrackElement(WebComponent): + """Model a playable track on Bandcamp's Discover page.""" + + def play(self) -> None: + """Play the track.""" + if not self.is_playing: + self._get_play_button().click() + + def pause(self) -> None: + """Pause the track.""" + if self.is_playing: + self._get_play_button().click() + + @property + def is_playing(self) -> bool: + return "Pause" in self._get_play_button().get_attribute("aria-label") + + def _get_play_button(self): + return self._parent.find_element(*TrackLocator.PLAY_BUTTON) + + def _get_track_info(self) -> Track: + """Create a representation of the track's relevant information.""" + full_url = self._parent.find_element(*TrackLocator.URL).get_attribute( + "href" + ) + # Cut off the referrer query parameter + clean_url = full_url.split("?")[0] if full_url else "" + # Some tracks don't have a genre + try: + genre = self._parent.find_element(*TrackLocator.GENRE).text + except NoSuchElementException: + genre = "" + return Track( + album=self._parent.find_element(*TrackLocator.ALBUM).text, + artist=self._parent.find_element(*TrackLocator.ARTIST).text, + genre=genre, + url=clean_url, + ) diff --git a/python-selenium/src/bandcamp/web/locators.py b/python-selenium/src/bandcamp/web/locators.py new file mode 100644 index 0000000000..7626707c3f --- /dev/null +++ b/python-selenium/src/bandcamp/web/locators.py @@ -0,0 +1,22 @@ +from selenium.webdriver.common.by import By + + +class DiscoverPageLocator: + DISCOVER_RESULTS = (By.CLASS_NAME, "results-grid") + COOKIE_ACCEPT_NECESSARY = ( + By.CSS_SELECTOR, + "#cookie-control-dialog button.g-button.outline", + ) + + +class TrackListLocator: + ITEM = (By.CLASS_NAME, "results-grid-item") + PAGINATION_BUTTON = (By.ID, "view-more") + + +class TrackLocator: + PLAY_BUTTON = (By.CSS_SELECTOR, "button.play-pause-button") + URL = (By.CSS_SELECTOR, "div.meta p a") + ALBUM = (By.CSS_SELECTOR, "div.meta p a strong") + GENRE = (By.CSS_SELECTOR, "div.meta p.genre") + ARTIST = (By.CSS_SELECTOR, "div.meta p a span") diff --git a/python-selenium/src/bandcamp/web/pages.py b/python-selenium/src/bandcamp/web/pages.py new file mode 100644 index 0000000000..f3a258dac2 --- /dev/null +++ b/python-selenium/src/bandcamp/web/pages.py @@ -0,0 +1,27 @@ +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.remote.webdriver import WebDriver + +from bandcamp.web.base import WebPage +from bandcamp.web.elements import TrackListElement +from bandcamp.web.locators import DiscoverPageLocator + + +class DiscoverPage(WebPage): + """Model the relevant parts of the Bandcamp Discover page.""" + + def __init__(self, driver: WebDriver) -> None: + super().__init__(driver) + self._accept_cookie_consent() + self.discover_tracklist = TrackListElement( + self._driver.find_element(*DiscoverPageLocator.DISCOVER_RESULTS), + self._driver, + ) + + def _accept_cookie_consent(self) -> None: + """Accept the necessary cookie consent.""" + try: + self._driver.find_element( + *DiscoverPageLocator.COOKIE_ACCEPT_NECESSARY + ).click() + except NoSuchElementException: + pass diff --git a/python-selenium/training/communication.py b/python-selenium/training/communication.py new file mode 100644 index 0000000000..96f77bc0e6 --- /dev/null +++ b/python-selenium/training/communication.py @@ -0,0 +1,31 @@ +import time + +from selenium import webdriver +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By + +driver = webdriver.Firefox() # Run in normal mode +driver.implicitly_wait(5) + +driver.get("https://bandcamp.com/discover/") + +# Accept cookies, if required +try: + cookie_accept_button = driver.find_element( + By.CSS_SELECTOR, + "#cookie-control-dialog button.g-button.outline", + ) + cookie_accept_button.click() +except NoSuchElementException: + pass + +time.sleep(0.5) + +search = driver.find_element(By.CLASS_NAME, "site-search-form") +search_field = search.find_element(By.TAG_NAME, "input") +search_field.send_keys("selenium") +search_field.submit() + +time.sleep(5) + +driver.quit() diff --git a/python-selenium/training/interaction.py b/python-selenium/training/interaction.py new file mode 100644 index 0000000000..50724f5fa8 --- /dev/null +++ b/python-selenium/training/interaction.py @@ -0,0 +1,25 @@ +import time + +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options + +options = Options() +options.add_argument("--headless") +driver = webdriver.Firefox(options=options) +driver.implicitly_wait(5) + +driver.get("https://bandcamp.com/discover/") + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +pagination_button = driver.find_element(By.ID, "view-more") +pagination_button.click() + +time.sleep(0.5) + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +driver.quit() diff --git a/python-selenium/training/navigation.py b/python-selenium/training/navigation.py new file mode 100644 index 0000000000..b02e3c3382 --- /dev/null +++ b/python-selenium/training/navigation.py @@ -0,0 +1,24 @@ +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options + +options = Options() +options.add_argument("--headless") +driver = webdriver.Firefox(options=options) +driver.implicitly_wait(5) + +driver.get("https://bandcamp.com/discover/") +print(driver.title) + +pagination_button = driver.find_element(By.ID, "view-more") +print(pagination_button.accessible_name) + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) +print(tracks[0].text) + +track_1 = tracks[0] +album = track_1.find_element(By.CSS_SELECTOR, "div.meta a strong") +print(album.text) + +driver.quit() diff --git a/python-selenium/training/observation.py b/python-selenium/training/observation.py new file mode 100644 index 0000000000..74eeeffed2 --- /dev/null +++ b/python-selenium/training/observation.py @@ -0,0 +1,36 @@ +from selenium import webdriver +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By +from selenium.webdriver.firefox.options import Options +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +options = Options() +options.add_argument("--headless") +driver = webdriver.Firefox(options=options) +driver.implicitly_wait(5) + +driver.get("https://bandcamp.com/discover/") + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +try: + cookie_accept_button = driver.find_element( + By.CSS_SELECTOR, + "#cookie-control-dialog button.g-button.outline", + ) + cookie_accept_button.click() +except NoSuchElementException: + pass + +pagination_button = driver.find_element(By.ID, "view-more") +pagination_button.click() + +wait = WebDriverWait(driver, 10) +wait.until(EC.element_to_be_clickable((By.ID, "view-more"))) + +tracks = driver.find_elements(By.CLASS_NAME, "results-grid-item") +print(len(tracks)) + +driver.quit() diff --git a/python-selenium/uv.lock b/python-selenium/uv.lock new file mode 100644 index 0000000000..3557ceda08 --- /dev/null +++ b/python-selenium/uv.lock @@ -0,0 +1,220 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "bandcamp-player" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "selenium" }, +] + +[package.metadata] +requires-dist = [{ name = "selenium" }] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 }, +] + +[[package]] +name = "selenium" +version = "4.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/f8/12e5c86f5d4b26758151a2145cb0909d2b811a3ac846b645dd7c63023543/selenium-4.30.0.tar.gz", hash = "sha256:16ab890fc7cb21a01e1b1e9a0fbaa9445fe30837eabc66e90b3bacf12138126a", size = 859424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/cb/6e9c6f9072eb09d0f0cdfc52e33ad6583a6bd5232a322fccdd378104c6e0/selenium-4.30.0-py3-none-any.whl", hash = "sha256:90bcd3be86a1762100a093b33e5e4530b328226da94208caadb15ce13243dffd", size = 9353816 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + +[[package]] +name = "trio" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/47/f62e62a1a6f37909aed0bf8f5d5411e06fa03846cfcb64540cd1180ccc9f/trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf", size = 588952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/55/c4d9bea8b3d7937901958f65124123512419ab0eb73695e5f382521abbfb/trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66", size = 492920 }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 }, +]