248 lines
7.6 KiB
Python
248 lines
7.6 KiB
Python
# Copyright 2026 Philipp Matthias Schäfer <philipp.matthias.schaefer@posteo.de>
|
||
#
|
||
# This file is part of the Spielerplus Chat Watcher application.
|
||
#
|
||
# The Spielerplus Chat Watcher application is free software: you can redistribute
|
||
# it and/or modify it under the terms of the GNU Affero General Public License
|
||
# as published by the Free Software Foundation, either version 3 of the
|
||
# License, or (at your option) any later version.
|
||
#
|
||
# The Spielerplus Chat Watcher application is distributed in the hope that it
|
||
# will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
||
# General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU Affero General Public License
|
||
# along with the Clean CommonMark library. If not, see
|
||
# <https://www.gnu.org/licenses/>.
|
||
"""Spielerplus Chat Watcher.
|
||
|
||
Regularly checks for new chat messages in Spielerplus (https://www.spielerplus.de).
|
||
If a new message is encountered, a notifying email is sent to a configured address.
|
||
"""
|
||
|
||
import logging
|
||
from datetime import timedelta
|
||
from email.message import EmailMessage
|
||
from hashlib import sha256
|
||
from smtplib import SMTP
|
||
from time import sleep
|
||
from types import TracebackType
|
||
|
||
from pydantic import EmailStr
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
from selenium.common import NoSuchElementException, WebDriverException
|
||
from selenium.webdriver import Firefox
|
||
from selenium.webdriver.common.by import By
|
||
from selenium.webdriver.firefox.options import Options
|
||
from selenium.webdriver.support import expected_conditions
|
||
from selenium.webdriver.support.ui import WebDriverWait
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# Interval between two consecutive checks for new chat messages.
|
||
POLL_INTERVAL = timedelta(minutes=15)
|
||
|
||
|
||
class Configuration(BaseSettings):
|
||
"""Pydantic Settings based configuration.
|
||
|
||
Note that the variables in the .env file that is read to fill an object
|
||
of this class need to have capitalized names with a prefix as defined
|
||
below in the model configuration.
|
||
|
||
Attributes
|
||
----------
|
||
username : str
|
||
Username of the account for which chat messages should be checked.
|
||
password : str
|
||
Password of the account for which chat messages should be checked.
|
||
form_address : EmailStr
|
||
Email address used in the From-field of notification emails.
|
||
to_address : EmailStr
|
||
Email address to which notification emails are sent.
|
||
"""
|
||
|
||
username: str
|
||
password: str
|
||
|
||
from_address: EmailStr
|
||
to_address: EmailStr
|
||
|
||
model_config = SettingsConfigDict(env_prefix="spcw_", env_file=".env")
|
||
|
||
|
||
def send_email(configuration: Configuration, subject: str) -> None:
|
||
"""Send an empty email with the given subject to the configured email address.
|
||
|
||
Parameters
|
||
----------
|
||
configuration : Configuration
|
||
The configuration object which holds the email address for the From- and
|
||
To-fields of the to be sent email.
|
||
subject : str
|
||
The subject used in the to be sent email.
|
||
"""
|
||
message = EmailMessage()
|
||
|
||
message["From"] = configuration.from_address
|
||
message["To"] = configuration.to_address
|
||
message["Subject"] = subject
|
||
|
||
smtp = SMTP("localhost")
|
||
smtp.send_message(message)
|
||
logger.debug(
|
||
'Sent email from "%s" to "%s" with subject "%s"',
|
||
message["From"],
|
||
message["To"],
|
||
message["Subject"],
|
||
)
|
||
|
||
|
||
class FirefoxDriver:
|
||
"""Context manager wrapper for Selenium’s Firefox webdriver."""
|
||
|
||
def __enter__(self) -> Firefox:
|
||
"""Create a headless Firefox webdriver object and return it.
|
||
|
||
Returns
|
||
-------
|
||
Firefox
|
||
The created webdriver object.
|
||
"""
|
||
options = Options()
|
||
options.add_argument("--headless")
|
||
self.driver = Firefox(options=options)
|
||
logger.debug("Created Firefox webdriver")
|
||
return self.driver
|
||
|
||
def __exit__(
|
||
self,
|
||
_: type[BaseException] | None,
|
||
__: BaseException | None,
|
||
___: TracebackType | None,
|
||
) -> bool | None:
|
||
"""Close the previously created webdriver object.
|
||
|
||
Returns
|
||
-------
|
||
None
|
||
Nothing.
|
||
"""
|
||
self.driver.quit()
|
||
logger.debug("Closed Firefox webdriver")
|
||
return None
|
||
|
||
|
||
def get_last_chat_message(configuration: Configuration) -> str | None:
|
||
"""Use Selenium to retrieve the text of the last posted chat message.
|
||
|
||
Parameters
|
||
----------
|
||
configuration : Configuration
|
||
Configuration object that provides the login credentials for
|
||
Spielerplus.
|
||
|
||
Returns
|
||
-------
|
||
str | None
|
||
Either the text of the last chat message, which may be empty, if it was
|
||
deleted, or None, which indicates that no messages where found.
|
||
"""
|
||
with FirefoxDriver() as driver:
|
||
driver.get("https://www.spielerplus.de/en/site/login")
|
||
logger.debug("Opened login page")
|
||
|
||
current_url = driver.current_url
|
||
|
||
try:
|
||
driver.find_element(By.CSS_SELECTOR, ".cmpclose a").click()
|
||
logger.debug("Closed cookie banner")
|
||
except NoSuchElementException:
|
||
logger.debug("No cookie banner present")
|
||
except WebDriverException as e:
|
||
logger.warning(
|
||
"Caught unexpected exception while trying to close cookie banner: %s", e
|
||
)
|
||
|
||
# login
|
||
driver.find_element(By.ID, "loginform-email").send_keys(configuration.username)
|
||
driver.find_element(By.ID, "loginform-password").send_keys(
|
||
configuration.password
|
||
)
|
||
driver.find_element(By.CSS_SELECTOR, "#login-form button").click()
|
||
|
||
logger.debug("Submitted login form and wait for login")
|
||
|
||
WebDriverWait(driver, 15).until(expected_conditions.url_changes(current_url))
|
||
|
||
driver.get("https://www.spielerplus.de/en/chat")
|
||
|
||
logger.debug("Navigated to chat")
|
||
|
||
messages = driver.find_elements(By.CSS_SELECTOR, ".chat-message-item")
|
||
if not messages:
|
||
logger.warning("Found no messages on chat page")
|
||
return None
|
||
|
||
last_message = messages[-1]
|
||
paragraphs = last_message.find_elements(By.CSS_SELECTOR, ".chat-bubble p")
|
||
if not paragraphs:
|
||
paragraphs = []
|
||
text = "\n".join([p.text for p in paragraphs])
|
||
|
||
logger.debug("Scraped last message: %s", text)
|
||
|
||
return text
|
||
|
||
|
||
def hash_string(string: str) -> str:
|
||
"""Calculate and return hash of string.
|
||
|
||
Parameters
|
||
----------
|
||
string : str
|
||
The string that should be hashed.
|
||
|
||
Returns
|
||
-------
|
||
str
|
||
Hex encoded binary hash of string.
|
||
"""
|
||
m = sha256()
|
||
m.update(string.encode())
|
||
return m.hexdigest()
|
||
|
||
|
||
def main() -> None:
|
||
"""Entry point of script."""
|
||
logging.basicConfig(level=logging.WARNING)
|
||
|
||
# The values are read from the .env file, which mypy does not know.
|
||
configuration = Configuration() # type: ignore[call-arg]
|
||
|
||
logger.debug("Loaded configuration.")
|
||
|
||
# When the script is started, we will miss all messages until a non-empty
|
||
# one is posted. An empty message is, as far as we know, a deleted message.
|
||
last_hash = hash_string("")
|
||
while True:
|
||
last_message = get_last_chat_message(configuration)
|
||
|
||
if not last_message:
|
||
send_email(configuration, "Could not fetch last message")
|
||
continue
|
||
|
||
new_hash = hash_string(last_message)
|
||
|
||
if last_hash != new_hash:
|
||
send_email(configuration, "New message(s) in Spielerplus")
|
||
last_hash = new_hash
|
||
|
||
sleep(POLL_INTERVAL.seconds)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|