From f047429c71d0eed090c8eb62de915e366e83ac64 Mon Sep 17 00:00:00 2001 From: Kristo Isberg Date: Tue, 15 Sep 2026 19:26:32 +0300 Subject: [PATCH] feat(email_scan): add 15 job platform modules - Add email validators for Baltic and Central European job platforms - Register loud password-reset modules --- user_scanner/core/helpers.py | 12 ++++- user_scanner/email_scan/jobs/cv_ee.py | 30 ++++++++++++ user_scanner/email_scan/jobs/cv_lv.py | 30 ++++++++++++ user_scanner/email_scan/jobs/cvkeskus.py | 50 ++++++++++++++++++++ user_scanner/email_scan/jobs/cvmarket_lt.py | 48 +++++++++++++++++++ user_scanner/email_scan/jobs/cvmarket_lv.py | 51 +++++++++++++++++++++ user_scanner/email_scan/jobs/cvonline_lt.py | 30 ++++++++++++ user_scanner/email_scan/jobs/dirbam.py | 31 +++++++++++++ user_scanner/email_scan/jobs/hercul.py | 40 ++++++++++++++++ user_scanner/email_scan/jobs/jobs_cz.py | 48 +++++++++++++++++++ user_scanner/email_scan/jobs/kandideeri.py | 49 ++++++++++++++++++++ user_scanner/email_scan/jobs/otsintood.py | 31 +++++++++++++ user_scanner/email_scan/jobs/pulser.py | 32 +++++++++++++ user_scanner/email_scan/jobs/seduo_cz.py | 48 +++++++++++++++++++ user_scanner/email_scan/jobs/seduo_sk.py | 48 +++++++++++++++++++ user_scanner/email_scan/jobs/visidarbi.py | 31 +++++++++++++ 16 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 user_scanner/email_scan/jobs/cv_ee.py create mode 100644 user_scanner/email_scan/jobs/cv_lv.py create mode 100644 user_scanner/email_scan/jobs/cvkeskus.py create mode 100644 user_scanner/email_scan/jobs/cvmarket_lt.py create mode 100644 user_scanner/email_scan/jobs/cvmarket_lv.py create mode 100644 user_scanner/email_scan/jobs/cvonline_lt.py create mode 100644 user_scanner/email_scan/jobs/dirbam.py create mode 100644 user_scanner/email_scan/jobs/hercul.py create mode 100644 user_scanner/email_scan/jobs/jobs_cz.py create mode 100644 user_scanner/email_scan/jobs/kandideeri.py create mode 100644 user_scanner/email_scan/jobs/otsintood.py create mode 100644 user_scanner/email_scan/jobs/pulser.py create mode 100644 user_scanner/email_scan/jobs/seduo_cz.py create mode 100644 user_scanner/email_scan/jobs/seduo_sk.py create mode 100644 user_scanner/email_scan/jobs/visidarbi.py diff --git a/user_scanner/core/helpers.py b/user_scanner/core/helpers.py index 2dc5268c1..be404ec77 100644 --- a/user_scanner/core/helpers.py +++ b/user_scanner/core/helpers.py @@ -56,6 +56,17 @@ "cambly", "superlive", "medium", + "cv.ee", + "cv.lv", + "cvkeskus", + "cvonline.lt", + "cvmarket.lv", + "cvmarket.lt", + "jobs.cz", + "pulser", + "hercul", + "seduo.cz", + "seduo.sk", ], } @@ -356,4 +367,3 @@ def save_config_value(key: str, value: Any, path: str | Path | None = None): content[key] = value cp.parent.mkdir(parents=True, exist_ok=True) cp.write_text(json.dumps(content, indent=2)) - diff --git a/user_scanner/email_scan/jobs/cv_ee.py b/user_scanner/email_scan/jobs/cv_ee.py new file mode 100644 index 000000000..679d53bdd --- /dev/null +++ b/user_scanner/email_scan/jobs/cv_ee.py @@ -0,0 +1,30 @@ +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.cv.ee/api/v1/authentication/password/forgot-password" +_NOT_FOUND = {"errorType": "RESOURCE_NOT_FOUND", "message": "Resource not found."} + + +async def validate_cv_ee(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://www.cv.ee" + + try: + response = await impersonate_request_async( + _URL, method="POST", json={"email": email} + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code == 404: + try: + if response.json() == _NOT_FOUND: + return Result.available(url=show_url) + except ValueError: + pass + if response.status_code == 200 and not response.content: + return Result.taken(url=show_url) + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) diff --git a/user_scanner/email_scan/jobs/cv_lv.py b/user_scanner/email_scan/jobs/cv_lv.py new file mode 100644 index 000000000..08809286b --- /dev/null +++ b/user_scanner/email_scan/jobs/cv_lv.py @@ -0,0 +1,30 @@ +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.cv.lv/api/v1/authentication/password/forgot-password" +_NOT_FOUND = {"errorType": "RESOURCE_NOT_FOUND", "message": "Resource not found."} + + +async def validate_cv_lv(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://www.cv.lv" + + try: + response = await impersonate_request_async( + _URL, method="POST", json={"email": email} + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code == 404: + try: + if response.json() == _NOT_FOUND: + return Result.available(url=show_url) + except ValueError: + pass + if response.status_code == 200 and not response.content: + return Result.taken(url=show_url) + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) diff --git a/user_scanner/email_scan/jobs/cvkeskus.py b/user_scanner/email_scan/jobs/cvkeskus.py new file mode 100644 index 000000000..46de45a7a --- /dev/null +++ b/user_scanner/email_scan/jobs/cvkeskus.py @@ -0,0 +1,50 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.cvkeskus.ee/remind/username" +_NOT_FOUND = "Sisestatud e-posti aadress puudub meie süsteemis." +_MULTIPLE_ACCOUNTS = "Selle e-posti aadressiga on seotud mitu kontot." +_REMINDER_SENT = "Saatsime Sinu kasutajanime meeldetuletuse e-postile:" + + +async def validate_cvkeskus(email: str) -> Result: + """Send a username reminder when the email belongs to one account.""" + show_url = "https://www.cvkeskus.ee" + + try: + page = await impersonate_request_async(_URL) + if page.status_code != 200 or 'id="reminderForm"' not in page.text: + return Result.error( + f"Unexpected reminder page response: {page.status_code}", + url=show_url, + ) + + token = re.search( + r"name=['\"]csrf_token['\"] value=['\"]([^'\"]+)", page.text + ) + if not token: + return Result.error("Could not find reminder CSRF token", url=show_url) + + response = await impersonate_request_async( + _URL, + method="POST", + data={"csrf_token": token.group(1), "email": email}, + headers={"Referer": _URL}, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected reminder response: {response.status_code}", url=show_url + ) + if _NOT_FOUND in response.text: + return Result.available(url=show_url) + if _MULTIPLE_ACCOUNTS in response.text: + return Result.taken(url=show_url, extra={"multiple_accounts": True}) + if _REMINDER_SENT in response.text: + return Result.taken(url=show_url, extra={"multiple_accounts": False}) + return Result.error("Unexpected reminder response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/cvmarket_lt.py b/user_scanner/email_scan/jobs/cvmarket_lt.py new file mode 100644 index 000000000..83bf0fdd1 --- /dev/null +++ b/user_scanner/email_scan/jobs/cvmarket_lt.py @@ -0,0 +1,48 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.cvmarket.lt/remind/username" +_NOT_FOUND = "Su tokiu el. paštu registracijos nėra." +_REMINDER_SENT = "Jeigu su šiuo el. paštu yra paskyrą, jūs turėjote gauti" +_SEND_FAILED = "Laiško nurodytu adresu {} išsiųsti nepavyko!" + + +async def validate_cvmarket_lt(email: str) -> Result: + """Send a username reminder when the email belongs to an account.""" + show_url = "https://www.cvmarket.lt" + + try: + page = await impersonate_request_async(_URL) + if page.status_code != 200 or 'id="reminderForm"' not in page.text: + return Result.error( + f"Unexpected reminder page response: {page.status_code}", + url=show_url, + ) + + token = re.search( + r"name=['\"]csrf_token['\"] value=['\"]([^'\"]+)", page.text + ) + if not token: + return Result.error("Could not find reminder CSRF token", url=show_url) + + response = await impersonate_request_async( + _URL, + method="POST", + data={"csrf_token": token.group(1), "email": email}, + headers={"Referer": _URL}, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected reminder response: {response.status_code}", url=show_url + ) + if _NOT_FOUND in response.text: + return Result.available(url=show_url) + if _REMINDER_SENT in response.text or _SEND_FAILED.format(email) in response.text: + return Result.taken(url=show_url) + return Result.error("Unexpected reminder response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/cvmarket_lv.py b/user_scanner/email_scan/jobs/cvmarket_lv.py new file mode 100644 index 000000000..8fb974e36 --- /dev/null +++ b/user_scanner/email_scan/jobs/cvmarket_lv.py @@ -0,0 +1,51 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.cvmarket.lv/remind/username" +_NOT_FOUND = "Trūkst e-pasta adreses!" +_REMINDER_SENT = "Ja esat reģistrējies portālā ar norādīto e-pasta adresi" +_RECENT_REQUEST = ( + "Pēdējās stundas laikā paroles maiņas pieprasījumu šai e-pasta " + "adresei jau tika saņemts." +) + + +async def validate_cvmarket_lv(email: str) -> Result: + """Send a username reminder when the email belongs to an account.""" + show_url = "https://www.cvmarket.lv" + + try: + page = await impersonate_request_async(_URL) + if page.status_code != 200 or 'id="reminderForm"' not in page.text: + return Result.error( + f"Unexpected reminder page response: {page.status_code}", + url=show_url, + ) + + token = re.search( + r"name=['\"]csrf_token['\"] value=['\"]([^'\"]+)", page.text + ) + if not token: + return Result.error("Could not find reminder CSRF token", url=show_url) + + response = await impersonate_request_async( + _URL, + method="POST", + data={"csrf_token": token.group(1), "email": email}, + headers={"Referer": _URL}, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected reminder response: {response.status_code}", url=show_url + ) + if _NOT_FOUND in response.text: + return Result.available(url=show_url) + if _REMINDER_SENT in response.text or _RECENT_REQUEST in response.text: + return Result.taken(url=show_url) + return Result.error("Unexpected reminder response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/cvonline_lt.py b/user_scanner/email_scan/jobs/cvonline_lt.py new file mode 100644 index 000000000..f16fd8f84 --- /dev/null +++ b/user_scanner/email_scan/jobs/cvonline_lt.py @@ -0,0 +1,30 @@ +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.cvonline.lt/api/v1/authentication/password/forgot-password" +_NOT_FOUND = {"errorType": "RESOURCE_NOT_FOUND", "message": "Resource not found."} + + +async def validate_cvonline_lt(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://www.cvonline.lt" + + try: + response = await impersonate_request_async( + _URL, method="POST", json={"email": email} + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code == 404: + try: + if response.json() == _NOT_FOUND: + return Result.available(url=show_url) + except ValueError: + pass + if response.status_code == 200 and not response.content: + return Result.taken(url=show_url) + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) diff --git a/user_scanner/email_scan/jobs/dirbam.py b/user_scanner/email_scan/jobs/dirbam.py new file mode 100644 index 000000000..ac9ffdafe --- /dev/null +++ b/user_scanner/email_scan/jobs/dirbam.py @@ -0,0 +1,31 @@ +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.dirbam.lt/patikrinti/el-pastas" + + +async def validate_dirbam(email: str) -> Result: + """Check Dirbam's email validator without creating an account.""" + show_url = "https://www.dirbam.lt" + + try: + response = await impersonate_request_async(_URL, params={"email": email}) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected validation response: {response.status_code}", url=show_url + ) + + try: + result = response.json() + except ValueError: + return Result.error("Unexpected validation response body", url=show_url) + + if result == "Paskyra su tokių el.paštu jau yra": + return Result.taken(url=show_url) + if result == "true": + return Result.available(url=show_url) + return Result.error("Unexpected validation response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/hercul.py b/user_scanner/email_scan/jobs/hercul.py new file mode 100644 index 000000000..eec192de1 --- /dev/null +++ b/user_scanner/email_scan/jobs/hercul.py @@ -0,0 +1,40 @@ +from urllib.parse import quote + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_API = "https://api.hercul.hr/api/auth" + + +async def validate_hercul(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://app.hercul.hr" + + try: + availability = await impersonate_request_async( + f"{_API}/email-available/{quote(email, safe='')}" + ) + data = availability.json() + is_available = data.get("isAvailable") if isinstance(data, dict) else None + if availability.status_code != 200 or not isinstance(is_available, bool): + return Result.error( + f"Unexpected email availability response: {availability.status_code}", + url=show_url, + ) + if is_available: + return Result.available(url=show_url) + + response = await impersonate_request_async( + f"{_API}/users/reset-password", + method="POST", + json={"email": email}, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code == 200 and not response.content: + return Result.taken(url=show_url) + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) diff --git a/user_scanner/email_scan/jobs/jobs_cz.py b/user_scanner/email_scan/jobs/jobs_cz.py new file mode 100644 index 000000000..609130136 --- /dev/null +++ b/user_scanner/email_scan/jobs/jobs_cz.py @@ -0,0 +1,48 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.jobs.cz/obnova-hesla/" +_NOT_FOUND = "Zadaný e-mail není registrovaný." +_SENT = "Posíláme odkaz pro obnovení hesla" + + +async def validate_jobs_cz(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://www.jobs.cz" + + try: + page = await impersonate_request_async(_URL) + token = re.search( + r'name="user_email\[_token\]" value="([^"]+)"', page.text + ) + if not token: + return Result.error("Could not find password reset token", url=show_url) + + response = await impersonate_request_async( + _URL, + method="POST", + data={ + "user_email[email]": email, + "user_email[submit]": "", + "user_email[_token]": token.group(1), + }, + allow_redirects=True, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code == 403: + return Result.error("Rate limited by Jobs.cz", url=show_url) + if response.status_code != 200: + return Result.error( + f"Unexpected password reset response: {response.status_code}", + url=show_url, + ) + if _NOT_FOUND in response.text: + return Result.available(url=show_url) + if _SENT in response.text: + return Result.taken(url=show_url) + return Result.error("Unexpected password reset response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/kandideeri.py b/user_scanner/email_scan/jobs/kandideeri.py new file mode 100644 index 000000000..53e8af699 --- /dev/null +++ b/user_scanner/email_scan/jobs/kandideeri.py @@ -0,0 +1,49 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.kandideeri.ee/registration/?user_group_id=JobSeeker" + + +async def validate_kandideeri(email: str) -> Result: + """Check Kandideeri's signup validator without creating an account.""" + show_url = "https://www.kandideeri.ee" + + try: + page = await impersonate_request_async(_URL) + if page.status_code != 200 or 'id="registr-form"' not in page.text: + return Result.error( + f"Unexpected signup page response: {page.status_code}", + url=show_url, + ) + + token = re.search( + r' Result: + """Check Otsintööd's email validator without creating an account.""" + show_url = "https://www.otsintood.ee" + + try: + response = await impersonate_request_async(_URL, params={"email": email}) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected validation response: {response.status_code}", url=show_url + ) + + try: + result = response.json() + except ValueError: + return Result.error("Unexpected validation response body", url=show_url) + + if result == "Selle e-postiga on juba kasutaja registreeritud": + return Result.taken(url=show_url) + if result == "true": + return Result.available(url=show_url) + return Result.error("Unexpected validation response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/pulser.py b/user_scanner/email_scan/jobs/pulser.py new file mode 100644 index 000000000..cad2bc797 --- /dev/null +++ b/user_scanner/email_scan/jobs/pulser.py @@ -0,0 +1,32 @@ +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://cms.zadovoljstvozaposlenika.hr/api/auth/password/request-reset" +_NOT_FOUND = "The selected email is invalid." +_SENT = {"data": {"status": True}} + + +async def validate_pulser(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://pulser.hr" + + try: + response = await impersonate_request_async( + _URL, + method="POST", + json={"email": email}, + headers={"Accept": "application/json, text/plain, */*"}, + ) + data = response.json() + except Exception as exc: + return Result.error(exc, url=show_url) + + message = data.get("message") if isinstance(data, dict) else None + if response.status_code == 422 and message == _NOT_FOUND: + return Result.available(url=show_url) + if response.status_code == 200 and data == _SENT: + return Result.taken(url=show_url) + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) diff --git a/user_scanner/email_scan/jobs/seduo_cz.py b/user_scanner/email_scan/jobs/seduo_cz.py new file mode 100644 index 000000000..73d7cbcce --- /dev/null +++ b/user_scanner/email_scan/jobs/seduo_cz.py @@ -0,0 +1,48 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.seduo.cz/poslat-zapomenute-heslo" +_NOT_FOUND = "Zadaný e-mail neexistuje, zkuste ho napsat znovu." +_SENT = "Na váš e-mail jsme odeslali odkaz pro vytvoření nového hesla." +_SSO = "Resetování hesla není povoleno. Uživatel se přihlašuje přes SSO." + + +async def validate_seduo_cz(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://www.seduo.cz" + + try: + page = await impersonate_request_async(_URL) + token = re.search( + r'name="forgot_password\[_token\]"[^>]*value="([^"]+)"', page.text + ) + if not token: + return Result.error("Could not find password reset token", url=show_url) + + response = await impersonate_request_async( + _URL, + method="POST", + data={ + "forgot_password[email]": email, + "forgot_password[send]": "", + "forgot_password[_token]": token.group(1), + }, + allow_redirects=True, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) + if _NOT_FOUND in response.text: + return Result.available(url=show_url) + if _SENT in response.text: + return Result.taken(url=show_url) + if _SSO in response.text: + return Result.taken(extra={"auth_method": "Azure SSO"}, url=show_url) + return Result.error("Unexpected password reset response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/seduo_sk.py b/user_scanner/email_scan/jobs/seduo_sk.py new file mode 100644 index 000000000..8b02ddcbd --- /dev/null +++ b/user_scanner/email_scan/jobs/seduo_sk.py @@ -0,0 +1,48 @@ +import re + +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.seduo.sk/poslat-zabudnute-heslo" +_NOT_FOUND = "Zadaný e-mail neexistuje, skúste ho napísať znova." +_SENT = "Na váš e-mail sme odoslali odkaz na vytvorenie nového hesla." +_SSO = "Resetovanie hesla nie je povolené. Používateľ sa prihlasuje cez SSO." + + +async def validate_seduo_sk(email: str) -> Result: + """Request a password reset when the email belongs to an account.""" + show_url = "https://www.seduo.sk" + + try: + page = await impersonate_request_async(_URL) + token = re.search( + r'name="forgot_password\[_token\]"[^>]*value="([^"]+)"', page.text + ) + if not token: + return Result.error("Could not find password reset token", url=show_url) + + response = await impersonate_request_async( + _URL, + method="POST", + data={ + "forgot_password[email]": email, + "forgot_password[send]": "", + "forgot_password[_token]": token.group(1), + }, + allow_redirects=True, + ) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected password reset response: {response.status_code}", url=show_url + ) + if _NOT_FOUND in response.text: + return Result.available(url=show_url) + if _SENT in response.text: + return Result.taken(url=show_url) + if _SSO in response.text: + return Result.taken(extra={"auth_method": "Azure SSO"}, url=show_url) + return Result.error("Unexpected password reset response body", url=show_url) diff --git a/user_scanner/email_scan/jobs/visidarbi.py b/user_scanner/email_scan/jobs/visidarbi.py new file mode 100644 index 000000000..cc1962814 --- /dev/null +++ b/user_scanner/email_scan/jobs/visidarbi.py @@ -0,0 +1,31 @@ +from user_scanner.core.impersonate import impersonate_request_async +from user_scanner.core.result import Result + + +_URL = "https://www.visidarbi.lv/parbaude/e-pasts" + + +async def validate_visidarbi(email: str) -> Result: + """Check VisiDarbi's email validator without creating an account.""" + show_url = "https://www.visidarbi.lv" + + try: + response = await impersonate_request_async(_URL, params={"email": email}) + except Exception as exc: + return Result.error(exc, url=show_url) + + if response.status_code != 200: + return Result.error( + f"Unexpected validation response: {response.status_code}", url=show_url + ) + + try: + result = response.json() + except ValueError: + return Result.error("Unexpected validation response body", url=show_url) + + if result == "Profils ar šādu e-pasta adresi jau ir reģistrēts": + return Result.taken(url=show_url) + if result == "true": + return Result.available(url=show_url) + return Result.error("Unexpected validation response body", url=show_url)