Add TinDog auto-like Selenium bot with tests, e2e harness and wiki #1
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.env
|
||||
.data/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "framework"]
|
||||
path = framework
|
||||
url = ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git
|
||||
@@ -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 |  |
|
||||
| 2 | Click LOG IN, then LOG IN WITH FACEBOOK |  |
|
||||
| 3 | Facebook pop-up window |  |
|
||||
| 4a | Allow location |  |
|
||||
| 4b | Not interested in notifications |  |
|
||||
| 4c | I accept cookies |  |
|
||||
| 5a | Liking profiles (waits while loading) |  |
|
||||
| 5b | Match pop-up, BACK TO TINDER |  |
|
||||
| 5c | Daily limit reached |  |
|
||||
|
||||
> Status: the screenshot files are generated by the Docker run; see
|
||||
> `tests/e2e/README.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 | <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.
|
||||
@@ -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
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
**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)).
|
||||
|
||||

|
||||
|
||||
**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
|
||||

|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
**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)).
|
||||
|
||||

|
||||
|
||||
**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
|
||||

|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
## 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`.
|
||||
@@ -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
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
**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
|
||||

|
||||
|
||||
**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)).
|
||||
|
||||

|
||||
|
||||
**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
|
||||

|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
selenium>=4.20
|
||||
@@ -0,0 +1 @@
|
||||
"""Day 50 - auto-like bot for TinDog (the 100 Days of Python Tinder clone)."""
|
||||
@@ -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()
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"]')
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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">♥</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>
|
||||
@@ -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; }
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user