diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..36e672e --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env (git-ignored). Never commit real credentials. +TINDER_URL=https://app.100daysofpython.dev/ +FACEBOOK_EMAIL= +FACEBOOK_PASSWORD= +MAX_LIKES=20 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c887dd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.env +.data/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a759dc9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "framework"] + path = framework + url = ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git diff --git a/README.md b/README.md index 325a1e5..9623369 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ -# 050-tinder-auto-find-match +# 050 - Tinder auto find match +Day 50 of *100 Days of Code: The Complete Python Pro Bootcamp*. A Selenium bot +that logs in to **TinDog** (the course's Tinder clone, +), dismisses the pop-ups and likes profiles. +It is a modernised version of the +[earlier solution](https://github.com/Tirsvad/udemy.python.100-days-of-code/tree/main/day-50-tinder-auto-find-match). + +Wiki (what each step teaches): [English](docs/wiki/en/Home.md) · +[Dansk](docs/wiki/da/Home.md) · [Bahasa Malaysia](docs/wiki/ms/Home.md) + +> Be honest with the people you date: tell them you automate your swiping. +> TinDog allows 20 likes a day, Tinder 100 on the free tier. + +## Run + +```bash +python -m venv .venv && .venv/Scripts/activate # Windows; use bin/activate on Linux +pip install -e ".[dev]" +cp .env.example .env # then fill in your own values +python -m tinder_bot +python -m pytest # unit tests +``` + +Credentials come from environment variables (`FACEBOOK_EMAIL`, +`FACEBOOK_PASSWORD`); `.env` is git-ignored. Sign in manually once so the +Chrome profile in `.data/` remembers the session and phone verification. + +## Layout + +| Path | Purpose | +| --- | --- | +| `src/tinder_bot/steps.py` | one function per lecture step | +| `src/tinder_bot/locators.py` | every XPath in one place | +| `src/tinder_bot/config.py` | settings from the environment | +| `tests/unit/` | tests with a fake driver | +| `tests/e2e/` | mock TinDog site and Docker runner that produces the screenshots | + +## Steps and screenshots + +Screenshots are produced by `tests/e2e` against a local mock of TinDog, run in +Docker (never on the host desktop), into `docs/screenshots/`. + +| Step | What the bot does | Screenshot | +| --- | --- | --- | +| 1 | Open the site | ![](docs/screenshots/01-home.png) | +| 2 | Click LOG IN, then LOG IN WITH FACEBOOK | ![](docs/screenshots/02-login-modal.png) | +| 3 | Facebook pop-up window | ![](docs/screenshots/03-facebook-popup.png) | +| 4a | Allow location | ![](docs/screenshots/04-location.png) | +| 4b | Not interested in notifications | ![](docs/screenshots/05-notifications.png) | +| 4c | I accept cookies | ![](docs/screenshots/06-cookies.png) | +| 5a | Liking profiles (waits while loading) | ![](docs/screenshots/07-liking.png) | +| 5b | Match pop-up, BACK TO TINDER | ![](docs/screenshots/08-match-popup.png) | +| 5c | Daily limit reached | ![](docs/screenshots/09-done.png) | + +> Status: the screenshot files are generated by the Docker run; see +> `tests/e2e/README.md`. diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md new file mode 100644 index 0000000..a0fd867 --- /dev/null +++ b/docs/artifact-registry.md @@ -0,0 +1,56 @@ +# Artifact Registry + +This project's artifact state. Types, short names and `CrossReference +Candidates` come from the framework catalog +(`framework/registry/artifact-catalog.md`); this file only records where +each document lives in *this* project and the next version to use. + +Delete rows for types you don't use. Add a row the first time you create a +document of a type. `Primary File` may contain a glob (e.g. +`docs/use-cases/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it. + +| Short Name | Artifact Type | Primary File | Next Available Version | +| --- | --- | --- | --- | +| BC | Business Case | docs/business-case.md | 001 | +| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 001 | + +## Languages + +Set the PO language when the project starts; `project-planning` asks for it +if it is missing. An artifact of a type marked "Written in the PO language" +exists once, in that language, under its normal name (`business-case.md`); the +`artifact` skill ("One file per artifact") has the rule. + +| Setting | Value | +| --- | --- | +| PO language | | +| PO domain | | +| High-level register | IT Executive English | +| Technical register | IT Professional English | +| Wiki languages | en and the PO language, at least (`framework/process/wiki.md`) | + +Every artifact of a type marked "Yes" below states its language and domain in +its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from +`PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`). +`Domain` is a value from this list; add a row to introduce a domain, so a +reviewer can see at once which professional vocabulary a document uses. + +| Domain | Meaning | +| --- | --- | +| it | Software and IT | +| medical | Healthcare and medical devices | +| construction | Construction and civil engineering | + +| Artifact types | Register | Written in the PO language | +| --- | --- | --- | +| BC, KPI, PP, MIL | IT Executive English | Yes | +| SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes | +| OC, SD, DCD, ERD, TP, AD, ADR, TM, RC, QC, source code | IT Professional English | No | + +## Notes + +- "Next Available Version" is the zero-padded (3-digit) version to use the + *next* time a new document of that type is created. Increment it only when + a brand-new document is created, not when an existing document's + `## Version History` gets a row. +- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types. diff --git a/docs/wiki/da/Home.md b/docs/wiki/da/Home.md new file mode 100644 index 0000000..b7a4c18 --- /dev/null +++ b/docs/wiki/da/Home.md @@ -0,0 +1,57 @@ +# Dag 50 - Tinder auto find match (Dansk) + +[English](../en/Home.md) · [Bahasa Malaysia](../ms/Home.md) + +Mål: automatisér login og "like" af profiler på TinDog med Python og Selenium. +Etik først: fortæl din partner, at du automatiserer, og respektér den daglige +grænse (TinDog 20, Tinder gratis 100). + +## Trin 1 - Se siden igennem manuelt +![Forside](../../screenshots/01-home.png) + +**Det lærte vi:** klik først selv igennem og notér, hvilke elementer koden skal +ramme. Gode locators kommer af at kende siden. + +## Trin 2 - Gå til login-siden +![Login-vindue](../../screenshots/02-login-modal.png) + +**Det lærte vi:** `driver.get`, XPath-locators (`locators.py`) og at vente med +`WebDriverWait` + `element_to_be_clickable` i stedet for faste `sleep`. Det +undgår `NoSuchElementException` på langsomme sider. + +## Trin 3 - Facebook-popup +![Facebook-popup](../../screenshots/03-facebook-popup.png) + +**Det lærte vi:** login åbner et ekstra browservindue. Vi skifter med +`driver.window_handles` og `switch_to.window`, udfylder formularen og skifter +tilbage. Loginoplysninger ligger i miljøvariabler, aldrig i koden. + +## Trin 4 - Luk alle forespørgsler +Lokation ([04](../../screenshots/04-location.png)), notifikationer +([05](../../screenshots/05-notifications.png)), cookies +([06](../../screenshots/06-cookies.png)). + +![Cookies](../../screenshots/06-cookies.png) + +**Det lærte vi:** modale popups blokerer alt bagved. Vi klikker ALLOW, NOT +INTERESTED og I ACCEPT og springer en popup over, hvis den ikke dukker op +(`TimeoutException` håndteres og logges). + +## Trin 5 - Tryk Like +![Liker](../../screenshots/07-liking.png) +![Match-popup](../../screenshots/08-match-popup.png) + +**Det lærte vi:** undtagelser som styring af en ustabil brugerflade: +`NoSuchElementException` betyder "indlæser stadig, vent 2 sek. og prøv igen"; +`ElementClickInterceptedException` betyder "en match-popup er i vejen, klik +BACK TO TINDER". En pause mellem likes gør, at botten ikke ligner en bot. +Løkken stopper ved den indstillede grænse. + +![Færdig](../../screenshots/09-done.png) + +## Modernisering ift. den tidligere løsning +- `src/`-pakke med én funktion pr. trin i stedet for én klasse med `sleep`. +- Eksplicitte ventetider i stedet for faste; alle locators ét sted. +- Facebook-login (som lektionen beder om) i stedet for Google; indstillinger i + en `Settings`-dataclass; enhedstests med en falsk driver. +- Ingen `from constants import *`, ingen HTML-dumps. diff --git a/docs/wiki/en/Home.md b/docs/wiki/en/Home.md new file mode 100644 index 0000000..7ef289f --- /dev/null +++ b/docs/wiki/en/Home.md @@ -0,0 +1,58 @@ +# Day 50 - Tinder auto find match (English) + +[Dansk](../da/Home.md) · [Bahasa Malaysia](../ms/Home.md) + +Goal: automate logging in and liking profiles on TinDog with Python and +Selenium. Ethics first: tell your partner you automate, and respect the daily +limit (TinDog 20, Tinder free tier 100). + +## Step 1 - Look at the site by hand +![Home](../../screenshots/01-home.png) + +**What we gained:** before coding, click through manually and note which +elements you must target. Good locators come from looking at the page first. + +## Step 2 - Navigate to the login page +![Login modal](../../screenshots/02-login-modal.png) + +**What we gained:** `driver.get`, locating buttons with XPath +(`locators.py`) and waiting for them with `WebDriverWait` + +`element_to_be_clickable` instead of fixed `sleep` calls. This avoids +`NoSuchElementException` on slow pages. + +## Step 3 - The Facebook pop-up +![Facebook popup](../../screenshots/03-facebook-popup.png) + +**What we gained:** the login opens a second browser window. We switch with +`driver.window_handles` and `switch_to.window`, fill the form, then switch back. +Credentials live in environment variables, never in code. + +## Step 4 - Dismiss all requests +Location ([04](../../screenshots/04-location.png)), notifications +([05](../../screenshots/05-notifications.png)), cookies +([06](../../screenshots/06-cookies.png)). + +![Cookies](../../screenshots/06-cookies.png) + +**What we gained:** modal pop-ups block everything behind them. We click ALLOW, +NOT INTERESTED and I ACCEPT, and skip a pop-up that does not appear +(`TimeoutException` is handled and logged). + +## Step 5 - Hit Like +![Liking](../../screenshots/07-liking.png) +![Match popup](../../screenshots/08-match-popup.png) + +**What we gained:** exception handling as control flow for a flaky UI: +`NoSuchElementException` means "still loading, wait 2 s and retry"; +`ElementClickInterceptedException` means "a match pop-up is in the way, click +BACK TO TINDER". A pause between likes keeps the bot from looking like a bot. +The loop stops at the configured limit. + +![Done](../../screenshots/09-done.png) + +## Modernisation compared with the earlier solution +- `src/` package with one function per step instead of one class with `sleep`. +- Explicit waits instead of fixed sleeps; locators in one module. +- Facebook login (as the lecture asks) instead of Google; settings via a + `Settings` dataclass; unit tests with a fake driver. +- No `from constants import *`, no HTML dumps, no profile path with `os.getcwd`. diff --git a/docs/wiki/ms/Home.md b/docs/wiki/ms/Home.md new file mode 100644 index 0000000..76a7760 --- /dev/null +++ b/docs/wiki/ms/Home.md @@ -0,0 +1,60 @@ +# Hari 50 - Tinder auto find match (Bahasa Malaysia) + +[English](../en/Home.md) · [Dansk](../da/Home.md) + +Matlamat: mengautomasikan log masuk dan "like" profil di TinDog menggunakan +Python dan Selenium. Etika dahulu: beritahu pasangan anda bahawa anda +menggunakan automasi, dan patuhi had harian (TinDog 20, Tinder percuma 100). + +## Langkah 1 - Lihat laman web secara manual +![Laman utama](../../screenshots/01-home.png) + +**Apa yang kita pelajari:** sebelum menulis kod, klik sendiri dan catat elemen +yang perlu disasarkan. Locator yang baik datang daripada mengenali halaman. + +## Langkah 2 - Pergi ke halaman log masuk +![Modal log masuk](../../screenshots/02-login-modal.png) + +**Apa yang kita pelajari:** `driver.get`, locator XPath (`locators.py`) dan +menunggu dengan `WebDriverWait` + `element_to_be_clickable` dan bukannya +`sleep` tetap. Ini mengelakkan `NoSuchElementException` pada halaman yang +perlahan. + +## Langkah 3 - Tetingkap timbul Facebook +![Tetingkap Facebook](../../screenshots/03-facebook-popup.png) + +**Apa yang kita pelajari:** log masuk membuka tetingkap pelayar kedua. Kita +bertukar dengan `driver.window_handles` dan `switch_to.window`, mengisi borang, +kemudian kembali. Kelayakan disimpan dalam pemboleh ubah persekitaran, bukan +dalam kod. + +## Langkah 4 - Tutup semua permintaan +Lokasi ([04](../../screenshots/04-location.png)), pemberitahuan +([05](../../screenshots/05-notifications.png)), kuki +([06](../../screenshots/06-cookies.png)). + +![Kuki](../../screenshots/06-cookies.png) + +**Apa yang kita pelajari:** tetingkap modal menghalang semua yang di +belakangnya. Kita klik ALLOW, NOT INTERESTED dan I ACCEPT, dan langkau yang +tidak muncul (`TimeoutException` dikendalikan dan dilog). + +## Langkah 5 - Tekan Like +![Like](../../screenshots/07-liking.png) +![Tetingkap padanan](../../screenshots/08-match-popup.png) + +**Apa yang kita pelajari:** pengecualian sebagai kawalan aliran untuk UI yang +tidak stabil: `NoSuchElementException` bermaksud "masih memuat, tunggu 2 saat +dan cuba lagi"; `ElementClickInterceptedException` bermaksud "tetingkap padanan +menghalang, klik BACK TO TINDER". Jeda antara "like" menjadikan bot kurang +ketara. Gelung berhenti pada had yang ditetapkan. + +![Selesai](../../screenshots/09-done.png) + +## Pemodenan berbanding penyelesaian terdahulu +- Pakej `src/` dengan satu fungsi bagi setiap langkah dan bukannya satu kelas + dengan `sleep`. +- Penantian eksplisit; semua locator di satu tempat. +- Log masuk Facebook (seperti pelajaran) dan bukannya Google; tetapan dalam + dataclass `Settings`; ujian unit dengan pemacu palsu. +- Tiada `from constants import *`, tiada longgokan HTML. diff --git a/framework b/framework new file mode 160000 index 0000000..351ddf0 --- /dev/null +++ b/framework @@ -0,0 +1 @@ +Subproject commit 351ddf0c995dbbb50757a711fd0c1a96591436de diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e1253af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "tinder-auto-find-match" +version = "0.1.0" +description = "Day 50 - Selenium bot that logs in to TinDog, dismisses pop-ups and likes profiles." +requires-python = ">=3.11" +dependencies = ["selenium>=4.20"] + +[project.optional-dependencies] +dev = ["pytest", "ruff"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests/unit"] + +[tool.ruff] +line-length = 88 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..28bba7c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +selenium>=4.20 diff --git a/src/tinder_bot/__init__.py b/src/tinder_bot/__init__.py new file mode 100644 index 0000000..c77fa8f --- /dev/null +++ b/src/tinder_bot/__init__.py @@ -0,0 +1 @@ +"""Day 50 - auto-like bot for TinDog (the 100 Days of Python Tinder clone).""" diff --git a/src/tinder_bot/__main__.py b/src/tinder_bot/__main__.py new file mode 100644 index 0000000..b4ba76a --- /dev/null +++ b/src/tinder_bot/__main__.py @@ -0,0 +1,31 @@ +"""Run with ``python -m tinder_bot``.""" + +import logging +from pathlib import Path + +from tinder_bot.config import Settings +from tinder_bot.driver import create_driver +from tinder_bot.steps import ( + dismiss_requests, + like_profiles, + open_login_page, + sign_in_with_facebook, +) + + +def main() -> None: + """Log in, dismiss pop-ups and like profiles.""" + logging.basicConfig(level=logging.INFO) + settings = Settings.from_env() + driver = create_driver(profile_dir=Path(".data").resolve()) + try: + open_login_page(driver, settings) + sign_in_with_facebook(driver, settings) + dismiss_requests(driver, settings) + like_profiles(driver, settings) + finally: + driver.quit() + + +if __name__ == "__main__": + main() diff --git a/src/tinder_bot/config.py b/src/tinder_bot/config.py new file mode 100644 index 0000000..d407d5a --- /dev/null +++ b/src/tinder_bot/config.py @@ -0,0 +1,29 @@ +"""Settings read from environment variables.""" + +import os +from dataclasses import dataclass + +DEFAULT_URL = "https://app.100daysofpython.dev/" + + +@dataclass(frozen=True) +class Settings: + """Everything the bot needs from the outside world.""" + + url: str = DEFAULT_URL + facebook_email: str = "" + facebook_password: str = "" + max_likes: int = 20 + like_delay: float = 1.5 + retry_delay: float = 2.0 + timeout: float = 10.0 + + @classmethod + def from_env(cls) -> "Settings": + """Build settings from the environment; unset values keep defaults.""" + return cls( + url=os.getenv("TINDER_URL", DEFAULT_URL), + facebook_email=os.getenv("FACEBOOK_EMAIL", ""), + facebook_password=os.getenv("FACEBOOK_PASSWORD", ""), + max_likes=int(os.getenv("MAX_LIKES", "20")), + ) diff --git a/src/tinder_bot/driver.py b/src/tinder_bot/driver.py new file mode 100644 index 0000000..e68c68b --- /dev/null +++ b/src/tinder_bot/driver.py @@ -0,0 +1,20 @@ +"""Browser factory.""" + +from pathlib import Path + +from selenium import webdriver + + +def create_driver( + *, headless: bool = False, profile_dir: Path | None = None +) -> webdriver.Chrome: + """Create a Chrome driver; a profile dir keeps the login between runs.""" + options = webdriver.ChromeOptions() + options.add_argument("--lang=en") + options.add_argument("--window-size=1280,900") + if headless: + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + if profile_dir is not None: + options.add_argument(f"--user-data-dir={profile_dir}") + return webdriver.Chrome(options=options) diff --git a/src/tinder_bot/locators.py b/src/tinder_bot/locators.py new file mode 100644 index 0000000..81f9ed5 --- /dev/null +++ b/src/tinder_bot/locators.py @@ -0,0 +1,20 @@ +"""XPath locators for the elements the lecture steps target.""" + +from selenium.webdriver.common.by import By + +Locator = tuple[str, str] + +LOGIN_BUTTON: Locator = (By.XPATH, '//*[normalize-space(text())="Log in"]') +FACEBOOK_BUTTON: Locator = ( + By.XPATH, + '//button[contains(normalize-space(.), "Log in with Facebook")]', +) +FACEBOOK_EMAIL: Locator = (By.ID, "email") +FACEBOOK_PASSWORD: Locator = (By.ID, "pass") + +ALLOW_LOCATION: Locator = (By.XPATH, '//button[normalize-space(.)="Allow"]') +NOT_INTERESTED: Locator = (By.XPATH, '//button[normalize-space(.)="Not interested"]') +ACCEPT_COOKIES: Locator = (By.XPATH, '//button[normalize-space(.)="I accept"]') + +LIKE_BUTTON: Locator = (By.XPATH, '//button[@aria-label="Like"]') +BACK_TO_TINDER: Locator = (By.XPATH, '//button[normalize-space(.)="Back to Tinder"]') diff --git a/src/tinder_bot/steps.py b/src/tinder_bot/steps.py new file mode 100644 index 0000000..22ca3b2 --- /dev/null +++ b/src/tinder_bot/steps.py @@ -0,0 +1,97 @@ +"""One function per lecture step. Each takes a driver and does one job.""" + +import logging +from collections.abc import Callable +from time import sleep + +from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, + TimeoutException, + WebDriverException, +) +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support import expected_conditions as ec +from selenium.webdriver.support.ui import WebDriverWait + +from tinder_bot import locators +from tinder_bot.config import Settings + +log = logging.getLogger(__name__) + + +def _click_when_ready( + driver: WebDriver, locator: locators.Locator, timeout: float +) -> None: + """Wait until the element is clickable, then click it.""" + WebDriverWait(driver, timeout).until(ec.element_to_be_clickable(locator)).click() + + +def open_login_page(driver: WebDriver, settings: Settings) -> None: + """Step 2: open the site, press LOG IN, then LOG IN WITH FACEBOOK.""" + driver.get(settings.url) + _click_when_ready(driver, locators.LOGIN_BUTTON, settings.timeout) + _click_when_ready(driver, locators.FACEBOOK_BUTTON, settings.timeout) + + +def sign_in_with_facebook(driver: WebDriver, settings: Settings) -> None: + """Fill the Facebook pop-up window and return to the main window.""" + main = driver.current_window_handle + WebDriverWait(driver, settings.timeout).until(lambda d: len(d.window_handles) > 1) + popup = next(h for h in driver.window_handles if h != main) + driver.switch_to.window(popup) + driver.find_element(*locators.FACEBOOK_EMAIL).send_keys(settings.facebook_email) + password = driver.find_element(*locators.FACEBOOK_PASSWORD) + password.send_keys(settings.facebook_password) + password.submit() + driver.switch_to.window(main) + + +def dismiss_requests(driver: WebDriver, settings: Settings) -> None: + """Step 4: ALLOW location, NOT INTERESTED notifications, I ACCEPT cookies.""" + for locator in ( + locators.ALLOW_LOCATION, + locators.NOT_INTERESTED, + locators.ACCEPT_COOKIES, + ): + try: + _click_when_ready(driver, locator, settings.timeout) + except TimeoutException: + log.warning("Pop-up not shown, skipping: %s", locator[1]) + + +def like_once( + driver: WebDriver, + settings: Settings, + pause: Callable[[float], None] = sleep, +) -> None: + """Click Like once, waiting out loading and dismissing match pop-ups.""" + while True: + try: + driver.find_element(*locators.LIKE_BUTTON).click() + return + except NoSuchElementException: + pause(settings.retry_delay) + except ElementClickInterceptedException: + try: + driver.find_element(*locators.BACK_TO_TINDER).click() + except NoSuchElementException: + pause(settings.retry_delay) + + +def like_profiles( + driver: WebDriver, + settings: Settings, + pause: Callable[[float], None] = sleep, +) -> int: + """Step 5: like up to ``max_likes`` profiles; return how many were liked.""" + liked = 0 + for _ in range(settings.max_likes): + try: + like_once(driver, settings, pause) + except WebDriverException: + log.exception("Stopping after %d likes", liked) + break + liked += 1 + pause(settings.like_delay) + return liked diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000..a4536e6 --- /dev/null +++ b/tests/e2e/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir "selenium>=4.20" + +WORKDIR /app +COPY src ./src +COPY tests/e2e ./tests/e2e + +# Selenium finds /usr/bin/chromedriver on PATH; point it at the distro Chromium. +ENV CHROME_BIN=/usr/bin/chromium +VOLUME /out +ENTRYPOINT ["python", "tests/e2e/run_e2e.py", "--out", "/out"] diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..4acbb16 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,20 @@ +# End-to-end run against a mock TinDog + +`site/` is a static mock of the TinDog flow (home, login modal, fake Facebook +popup, location / notifications / cookies pop-ups, cards with a 2 s loading +delay, a match modal every 4th like, stop at 20 likes). `run_e2e.py` serves it +with `http.server`, drives the bot with headless Chrome and saves one +screenshot per step (`01-home.png` ... `09-done.png`). + +Run it in Docker only, never on the host desktop. From the project root: + +```bash +docker build -f tests/e2e/Dockerfile -t tinder-e2e . +docker run --rm -v "$PWD/e2e-out:/out" tinder-e2e +``` + +The screenshots appear in `e2e-out/`. The exit code is 0 when 20 likes were +given. + +If Chrome fails to start as root in the container, `create_driver` already +passes `--no-sandbox`; add `--shm-size=1g` to `docker run` if it crashes. diff --git a/tests/e2e/run_e2e.py b/tests/e2e/run_e2e.py new file mode 100644 index 0000000..64efd84 --- /dev/null +++ b/tests/e2e/run_e2e.py @@ -0,0 +1,141 @@ +"""Run the bot against the static mock TinDog site and screenshot each step. + +Usage: ``python tests/e2e/run_e2e.py --out /out``. Run it in the Docker +container (see README.md), never on the host desktop. +""" + +import argparse +import logging +import sys +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from time import sleep + +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support import expected_conditions as ec +from selenium.webdriver.support.ui import WebDriverWait + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +from tinder_bot import locators # noqa: E402 +from tinder_bot.config import Settings # noqa: E402 +from tinder_bot.driver import create_driver # noqa: E402 +from tinder_bot.steps import like_once, sign_in_with_facebook # noqa: E402 + +SITE_DIR = Path(__file__).resolve().parent / "site" +LIKING_SHOT_AFTER = 2 +MATCH_EVERY = 4 # must equal MATCH_EVERY in site/app.js + +log = logging.getLogger("e2e") + + +class QuietHandler(SimpleHTTPRequestHandler): + """Static file handler that does not log each request.""" + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + return + + +def start_server() -> tuple[ThreadingHTTPServer, str]: + """Serve the mock site on a free port; return the server and its URL.""" + handler = partial(QuietHandler, directory=str(SITE_DIR)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, f"http://127.0.0.1:{server.server_address[1]}/index.html" + + +def shoot(driver: WebDriver, out: Path, name: str) -> None: + """Save a screenshot of the current window as ``name``.""" + driver.save_screenshot(str(out / name)) + log.info("saved %s", out / name) + + +def wait_visible(driver: WebDriver, locator: locators.Locator, timeout: float) -> None: + WebDriverWait(driver, timeout).until(ec.visibility_of_element_located(locator)) + + +def click(driver: WebDriver, locator: locators.Locator, timeout: float) -> None: + WebDriverWait(driver, timeout).until(ec.element_to_be_clickable(locator)).click() + + +def shoot_popup(driver: WebDriver, out: Path, timeout: float) -> None: + """Screenshot the Facebook window, then return to the main window.""" + main = driver.current_window_handle + WebDriverWait(driver, timeout).until(lambda d: len(d.window_handles) > 1) + popup = next(h for h in driver.window_handles if h != main) + driver.switch_to.window(popup) + wait_visible(driver, locators.FACEBOOK_EMAIL, timeout) + shoot(driver, out, "03-facebook-popup.png") + driver.switch_to.window(main) + + +def like_all(driver: WebDriver, settings: Settings, out: Path) -> int: + """Like ``max_likes`` profiles, screenshotting mid-run and the match pop-up.""" + liked = 0 + match_shot_taken = False + while liked < settings.max_likes: + like_once(driver, settings, sleep) + liked += 1 + if liked == LIKING_SHOT_AFTER: + shoot(driver, out, "07-liking.png") + if liked % MATCH_EVERY == 0 and not match_shot_taken: + wait_visible(driver, locators.BACK_TO_TINDER, settings.timeout) + shoot(driver, out, "08-match-popup.png") + match_shot_taken = True + sleep(settings.like_delay) + shoot(driver, out, "09-done.png") + return liked + + +def run(out: Path) -> int: + """Drive the whole flow against the mock site; return the likes given.""" + server, url = start_server() + settings = Settings( + url=url, + facebook_email="bot@example.com", + facebook_password="not-a-real-password", + like_delay=0.2, + retry_delay=0.5, + ) + driver = create_driver(headless=True) + try: + driver.get(settings.url) + wait_visible(driver, locators.LOGIN_BUTTON, settings.timeout) + shoot(driver, out, "01-home.png") + click(driver, locators.LOGIN_BUTTON, settings.timeout) + wait_visible(driver, locators.FACEBOOK_BUTTON, settings.timeout) + shoot(driver, out, "02-login-modal.png") + click(driver, locators.FACEBOOK_BUTTON, settings.timeout) + shoot_popup(driver, out, settings.timeout) + sign_in_with_facebook(driver, settings) + for locator, name in ( + (locators.ALLOW_LOCATION, "04-location.png"), + (locators.NOT_INTERESTED, "05-notifications.png"), + (locators.ACCEPT_COOKIES, "06-cookies.png"), + ): + wait_visible(driver, locator, settings.timeout) + shoot(driver, out, name) + click(driver, locator, settings.timeout) + return like_all(driver, settings, out) + finally: + driver.quit() + server.shutdown() + + +def main() -> int: + """Parse arguments, run the flow and return the process exit code.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True, help="screenshot directory") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO) + args.out.mkdir(parents=True, exist_ok=True) + liked = run(args.out) + log.info("liked %d profiles", liked) + return 0 if liked == Settings().max_likes else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/site/app.js b/tests/e2e/site/app.js new file mode 100644 index 0000000..5c545d8 --- /dev/null +++ b/tests/e2e/site/app.js @@ -0,0 +1,67 @@ +"use strict"; + +const LOADING_MS = 2000; +const MATCH_EVERY = 4; +const MAX_LIKES = 20; +const NAMES = ["Rex", "Luna", "Max", "Bella", "Charlie", "Daisy", "Cooper", "Molly"]; + +let likes = 0; + +function show(id) { document.getElementById(id).classList.remove("hidden"); } +function hide(id) { document.getElementById(id).classList.add("hidden"); } + +function showProfile() { + document.getElementById("name").textContent = + NAMES[likes % NAMES.length] + ", " + (2 + likes); + document.getElementById("counter").textContent = "Likes: " + likes; +} + +function startCards() { + hide("home"); + show("cards"); + // The Like button is only visible after the loading delay. + setTimeout(function () { + hide("loading"); + showProfile(); + show("profile"); + }, LOADING_MS); +} + +function onLike() { + likes += 1; + if (likes >= MAX_LIKES) { + document.getElementById("like").remove(); + hide("profile"); + show("finished"); + return; + } + showProfile(); + if (likes % MATCH_EVERY === 0) { show("match-modal"); } +} + +// Called by the fake Facebook window once its form is submitted. +window.tindogLoggedIn = function () { + hide("login-modal"); + show("location-popup"); +}; + +document.getElementById("home-login").addEventListener("click", function () { + show("login-modal"); +}); + +document.getElementById("facebook-login").addEventListener("click", function () { + window.open("facebook.html", "facebook", "width=500,height=600"); +}); + +document.querySelectorAll("[data-next]").forEach(function (button) { + button.addEventListener("click", function () { + button.closest(".overlay").classList.add("hidden"); + const next = button.getAttribute("data-next"); + if (next === "cards") { startCards(); } else { show(next); } + }); +}); + +document.getElementById("like").addEventListener("click", onLike); +document.getElementById("back").addEventListener("click", function () { + hide("match-modal"); +}); diff --git a/tests/e2e/site/facebook.html b/tests/e2e/site/facebook.html new file mode 100644 index 0000000..6a97b8c --- /dev/null +++ b/tests/e2e/site/facebook.html @@ -0,0 +1,28 @@ + + + + + Facebook (mock) + + + +
facebook
+
+
+

Log in to continue

+ + + +
+
+ + + diff --git a/tests/e2e/site/index.html b/tests/e2e/site/index.html new file mode 100644 index 0000000..79f14b8 --- /dev/null +++ b/tests/e2e/site/index.html @@ -0,0 +1,66 @@ + + + + + TinDog (mock) + + + +
+

TinDog

+ +
+ +
+

Find your furry match

+

Mock of the TinDog landing page used for end-to-end tests.

+
+ + + + + + + + + +
+
Loading...
+ + +
+ + + + + + diff --git a/tests/e2e/site/style.css b/tests/e2e/site/style.css new file mode 100644 index 0000000..2371909 --- /dev/null +++ b/tests/e2e/site/style.css @@ -0,0 +1,29 @@ +body { font-family: Arial, sans-serif; margin: 0; background: #fff5f5; color: #333; } +.hidden { display: none !important; } +header { background: #ff4c68; color: #fff; padding: 16px 32px; display: flex; + justify-content: space-between; align-items: center; } +header h1 { margin: 0; font-size: 28px; } +main { padding: 48px; text-align: center; } +button { font-size: 16px; padding: 10px 24px; border: 0; border-radius: 20px; + background: #ff4c68; color: #fff; cursor: pointer; } +button.secondary { background: #888; } +.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); + display: flex; align-items: center; justify-content: center; z-index: 10; } +.dialog { background: #fff; border-radius: 12px; padding: 32px; min-width: 340px; + text-align: center; } +.dialog h2 { margin-top: 0; } +.dialog button { margin: 8px; } +.card { width: 280px; height: 360px; margin: 24px auto; border-radius: 16px; + background: linear-gradient(135deg, #ffb199, #ff0844); position: relative; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); overflow: hidden; } +.avatar { position: absolute; left: 90px; top: 50px; width: 100px; height: 100px; + border-radius: 50%; background: #fff; } +.avatar::after { content: ""; position: absolute; left: -30px; top: 115px; + width: 160px; height: 120px; border-radius: 80px 80px 0 0; background: #fff; } +.card p { position: absolute; bottom: 8px; width: 100%; margin: 0; color: #fff; + font-size: 22px; } +.loading { font-size: 22px; margin: 120px 0; } +#counter { margin-top: 12px; } +form label { display: block; margin: 12px 0; text-align: left; } +form input { width: 100%; box-sizing: border-box; padding: 8px; } +.fb-header { background: #3b5998; color: #fff; padding: 12px 20px; } diff --git a/tests/unit/test_steps.py b/tests/unit/test_steps.py new file mode 100644 index 0000000..adec286 --- /dev/null +++ b/tests/unit/test_steps.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock + +import pytest +from selenium.common.exceptions import ( + ElementClickInterceptedException, + NoSuchElementException, +) + +from tinder_bot import locators +from tinder_bot.config import Settings +from tinder_bot.steps import like_once, like_profiles + + +def test_like_once_retries_while_loading() -> None: + driver = MagicMock() + button = MagicMock() + driver.find_element.side_effect = [NoSuchElementException(), button] + pauses: list[float] = [] + + like_once(driver, Settings(), pauses.append) + + button.click.assert_called_once() + assert pauses == [2.0] + + +def test_like_once_dismisses_match_popup() -> None: + driver = MagicMock() + like, back = MagicMock(), MagicMock() + like.click.side_effect = [ElementClickInterceptedException(), None] + driver.find_element.side_effect = lambda by, value: ( + back if value == locators.BACK_TO_TINDER[1] else like + ) + + like_once(driver, Settings(), lambda _: None) + + back.click.assert_called_once() + assert like.click.call_count == 2 + + +def test_like_profiles_stops_at_max_and_pauses_between_likes() -> None: + driver = MagicMock() + pauses: list[float] = [] + + liked = like_profiles(driver, Settings(max_likes=3), pauses.append) + + assert liked == 3 + assert pauses == [1.5, 1.5, 1.5] + + +def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MAX_LIKES", "5") + monkeypatch.setenv("FACEBOOK_EMAIL", "a@b.c") + + settings = Settings.from_env() + + assert settings.max_likes == 5 + assert settings.facebook_email == "a@b.c"