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