Add TinDog auto-like Selenium bot with tests, e2e harness and wiki #1

Merged
Tirsvad merged 1 commits from feature/tinder-auto-like-bot into main 2026-11-03 03:37:27 +01:00
25 changed files with 954 additions and 1 deletions
+5
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
.env
.data/
+3
View File
@@ -0,0 +1,3 @@
[submodule "framework"]
path = framework
url = ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git
+56 -1
View File
@@ -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,
<https://app.100daysofpython.dev/>), 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`.
+56
View File
@@ -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 | <language code, e.g. da> |
| PO domain | <value from the domain list below, e.g. it> |
| 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.
+57
View File
@@ -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.
+58
View File
@@ -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`.
+60
View File
@@ -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.
Submodule
+1
Submodule framework added at 351ddf0c99
+27
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
selenium>=4.20
+1
View File
@@ -0,0 +1 @@
"""Day 50 - auto-like bot for TinDog (the 100 Days of Python Tinder clone)."""
+31
View File
@@ -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()
+29
View File
@@ -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")),
)
+20
View File
@@ -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)
+20
View File
@@ -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"]')
+97
View File
@@ -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
+16
View File
@@ -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"]
+20
View File
@@ -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.
+141
View File
@@ -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())
+67
View File
@@ -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");
});
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Facebook (mock)</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="fb-header"><strong>facebook</strong></div>
<main>
<form id="fb-form">
<h2>Log in to continue</h2>
<label>Email <input id="email" name="email" type="text"></label>
<label>Password <input id="pass" name="pass" type="password"></label>
<button type="submit">Submit</button>
</form>
</main>
<script>
document.getElementById("fb-form").addEventListener("submit", function (event) {
event.preventDefault();
if (window.opener && window.opener.tindogLoggedIn) {
window.opener.tindogLoggedIn();
}
window.close();
});
</script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TinDog (mock)</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>TinDog</h1>
<button id="home-login" type="button">Log in</button>
</header>
<main id="home">
<h2>Find your furry match</h2>
<p>Mock of the TinDog landing page used for end-to-end tests.</p>
</main>
<div id="login-modal" class="overlay hidden">
<div class="dialog">
<h2>Welcome back</h2>
<button id="facebook-login" type="button">Log in with Facebook</button>
</div>
</div>
<div id="location-popup" class="overlay hidden">
<div class="dialog">
<h2>Enable location</h2>
<button type="button" data-next="notifications-popup">Allow</button>
</div>
</div>
<div id="notifications-popup" class="overlay hidden">
<div class="dialog">
<h2>Stay in the loop</h2>
<button type="button" class="secondary" data-next="cookies-popup">Not interested</button>
</div>
</div>
<div id="cookies-popup" class="overlay hidden">
<div class="dialog">
<h2>We use cookies</h2>
<button type="button" data-next="cards">I accept</button>
</div>
</div>
<main id="cards" class="hidden">
<div id="loading" class="loading">Loading...</div>
<div id="profile" class="hidden">
<div class="card"><div class="avatar"></div><p id="name"></p></div>
<button id="like" type="button" aria-label="Like">&#9829;</button>
<div id="counter"></div>
</div>
<div id="finished" class="hidden"><h2>No more profiles nearby</h2></div>
</main>
<div id="match-modal" class="overlay hidden">
<div class="dialog">
<h2>It is a match!</h2>
<button id="back" type="button">Back to Tinder</button>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
+29
View File
@@ -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; }
+57
View File
@@ -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"