From d1c611750f5ce66213ca5452a65fe14ee98fbaea Mon Sep 17 00:00:00 2001 From: TheRaiwy Date: Fri, 10 Jul 2026 12:57:21 +0300 Subject: [PATCH] Initial commit: RobloxVerify automation tool --- .gitignore | 13 + README.md | 28 ++ accs.txt | 1 + login_password_cookie.txt | 3 + main.py | 828 ++++++++++++++++++++++++++++++++++++++ proxy.txt | 3 + requirements.txt | 4 + tests/test_parse.py | 61 +++ 8 files changed, 941 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 accs.txt create mode 100644 login_password_cookie.txt create mode 100644 main.py create mode 100644 proxy.txt create mode 100644 requirements.txt create mode 100644 tests/test_parse.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c35ee45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.session +*.zip +haarcascade_frontalface_default.xml +logs/ +result/ +videos/ +fix_cv.py +telegram_cleaner.py +accs.txt +proxy.txt +login_password_cookie.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..877d300 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# RobloxVerify + +Автоматизация прохождения age verification на Roblox через Persona. + +## Форматы accs.txt + +``` +login:pass +login:pass:cookie +login:cookie +just_the_cookie_token +``` + +## Установка + +```bash +pip install -r requirements.txt +``` + +## Запуск + +```bash +python main.py +# Тестовый режим (пауза между аккаунтами): +python main.py --test +``` + +Требуется Chrome 149+. diff --git a/accs.txt b/accs.txt new file mode 100644 index 0000000..267e524 --- /dev/null +++ b/accs.txt @@ -0,0 +1 @@ +# Format: login:pass or login:pass:cookie or login:cookie or just-cookie diff --git a/login_password_cookie.txt b/login_password_cookie.txt new file mode 100644 index 0000000..cf97706 --- /dev/null +++ b/login_password_cookie.txt @@ -0,0 +1,3 @@ +# Format: login:password:cookie +# Example: +# user:pass:_|WARNING:-DO-NOT-SHARE-THIS.--(token) diff --git a/main.py b/main.py new file mode 100644 index 0000000..d26c313 --- /dev/null +++ b/main.py @@ -0,0 +1,828 @@ +import io +import json +import logging +import os +import random +import subprocess +import sys +import time +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + +import cv2 +import threading + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") + +import undetected_chromedriver as uc +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +PROXY_FILE = Path("proxy.txt") +ACCOUNTS_FILE = Path("accs.txt") +LOCAL_BRIDGE_HOST = "127.0.0.1" +LOCAL_BRIDGE_PORT = 18080 +LOG_DIR = Path("logs") +VIDEOS_DIR = Path("videos") +HAAR_PATH = Path("haarcascade_frontalface_default.xml") + +THREADS = 3 +TEST_MODE = "--test" in sys.argv + +log: logging.Logger = None +RESULT_DIR: Path = None +_processed_logins: set = set() # accounts finished (any outcome) + +# Thread-safety primitives +_chrome_lock = threading.Lock() +_port_locks = [threading.Lock() for _ in range(THREADS)] +_port_owners = [None] * THREADS + + +def load_proxies() -> list[str]: + """Returns list of normalized socks5:// proxy strings.""" + if not PROXY_FILE.exists(): + return [] + proxies = [] + for line in PROXY_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + + s = line.replace("socks5://", "") + if "@" in s: + auth, rest = s.split("@", 1) + user, pw = auth.split(":", 1) + host, port = rest.split(":", 1) + proxies.append(f"socks5://{user}:{pw}@{host}:{port}") + else: + parts = s.split(":") + if len(parts) == 4: + # host:port:user:pass + host, port, user, pw = parts + proxies.append(f"socks5://{user}:{pw}@{host}:{port}") + elif len(parts) == 2: + host, port = parts + proxies.append(f"socks5://{host}:{port}") + return proxies + + +def parse_account_line(line: str) -> dict | None: + raw = line.strip() + result = {"_raw": raw} + first = raw.find(":") + if first == -1: + if len(raw) > 50 or raw.startswith("_|WARNING"): + result.update({"login": "", "password": "", "cookie": raw}) + return result + return None + + login_part = raw[:first] + rest_after_first = raw[first + 1:] + + # cookie-only: login_part выглядит как начало куки + if login_part.startswith("_|WARNING"): + result.update({"login": "", "password": "", "cookie": raw}) + return result + + # login:cookie — кука начинается сразу после первого : + if rest_after_first.startswith("_|WARNING"): + result.update({"login": login_part, "password": "", "cookie": rest_after_first}) + return result + + # login:pass:cookie или login:pass + second = rest_after_first.find(":") + if second != -1: + result.update({"login": login_part, "password": rest_after_first[:second], "cookie": rest_after_first[second + 1:]}) + return result + + # Без второго : — login:pass или login:cookie (кука без _|WARNING) + if len(rest_after_first) > 50: + result.update({"login": login_part, "password": "", "cookie": rest_after_first}) + else: + result.update({"login": login_part, "password": rest_after_first, "cookie": ""}) + return result + + +def start_pproxy_bridge(proxy: str, port: int) -> subprocess.Popen | None: + remote = proxy + s = proxy.replace("socks5://", "", 1) + if "@" in s: + auth, rest = s.split("@", 1) + remote = f"socks5://{rest}#{auth}" + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + for attempt in range(5): + try: + proc = subprocess.Popen( + [sys.executable, "-m", "pproxy", "-l", f"http://{LOCAL_BRIDGE_HOST}:{port}", "-r", remote, "-v"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + time.sleep(1.5) + if proc.poll() is not None: + err = proc.stderr.read() if proc.stderr else "" + log.error("pproxy on port %d failed (attempt %d/5): %s", port, attempt + 1, err.strip()[:200]) + time.sleep(2) + continue + return proc + except OSError as e: + log.warning("pproxy on port %d attempt %d/5: %s", port, attempt + 1, e) + time.sleep(2) + return None + + +def _ensure_haar_cascade() -> str: + if HAAR_PATH.exists(): + return str(HAAR_PATH) + url = "https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml" + log.info("Downloading haarcascade_frontalface_default.xml...") + urllib.request.urlretrieve(url, str(HAAR_PATH)) + return str(HAAR_PATH) + + +def _list_videos() -> list[Path]: + video_exts = {".mp4", ".webm", ".avi", ".mov", ".mkv"} + if not VIDEOS_DIR.exists(): + return [] + return sorted([f for f in VIDEOS_DIR.iterdir() if f.suffix.lower() in video_exts and not f.name.startswith(".")]) + + +def _get_video_size(video_path: Path) -> tuple[int, int]: + cap = cv2.VideoCapture(str(video_path)) + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + cap.release() + return w, h + + +def _get_video_duration(video_path: Path) -> float: + cap = cv2.VideoCapture(str(video_path)) + fps = cap.get(cv2.CAP_PROP_FPS) + count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + if fps <= 0: + return 0 + return count / fps + + +def _extract_frames_jpg_b64(video_path: Path, w: int, h: int, step: int = 2) -> list[str]: + import cv2 + import base64 + cap = cv2.VideoCapture(str(video_path)) + ow = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + oh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + frames = [] + idx = 0 + while True: + ret, frame = cap.read() + if not ret: + break + if idx % step != 0: + idx += 1 + continue + idx += 1 + if (ow, oh) != (w, h): + frame = cv2.resize(frame, (w, h)) + frames.append(frame) + cap.release() + + result = [] + for frame in frames: + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) + result.append(base64.b64encode(buf.tobytes()).decode()) + return result + + +def _analyze_video_directions(video_path: Path) -> tuple[dict[str, list[int]], list[float]]: + """Returns (dir_ranges, face_cx_list). + dir_ranges: {dir: [start_frame, end_frame]} for center/left/right. + face_cx_list: face center X per frame (original coords), -1 if no face. + """ + haar_src = cv2.objdetect if hasattr(cv2, "objdetect") and hasattr(cv2.objdetect, "CascadeClassifier") else cv2 + if not hasattr(haar_src, "CascadeClassifier"): + raise RuntimeError( + "cv2.CascadeClassifier not available. " + "Reinstall opencv-python: pip install --force-reinstall opencv-python" + ) + haar_path = _ensure_haar_cascade() + face_cascade = haar_src.CascadeClassifier(haar_path) + cap = cv2.VideoCapture(str(video_path)) + fw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + low = fw * 0.45 + high = fw * 0.55 + directions = [] + cx_list = [] + + while True: + ret, frame = cap.read() + if not ret: + break + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(60, 60)) + if len(faces) > 0: + x, _, fw_face, _ = faces[0] + cx = x + fw_face / 2 + cx_list.append(cx) + if cx < low: + directions.append("right") + elif cx > high: + directions.append("left") + else: + directions.append("center") + else: + cx_list.append(-1.0) + directions.append("center") + cap.release() + + ranges: dict[str, list[int]] = {"center": [], "left": [], "right": []} + for d in ranges: + start = None + for i, v in enumerate(directions): + if v == d and start is None: + start = i + elif v != d and start is not None: + if i - start >= 5: + ranges[d].append([start, i - 1]) + start = None + if start is not None and len(directions) - start >= 5: + ranges[d].append([start, len(directions) - 1]) + + result = {} + for d, segs in ranges.items(): + if segs: + result[d] = max(segs, key=lambda s: s[1] - s[0]) + else: + result[d] = [0, 0] + return result, cx_list + + +def setup_logger() -> logging.Logger: + LOG_DIR.mkdir(exist_ok=True) + ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_file = LOG_DIR / f"session_{ts}.log" + logger = logging.getLogger("robverify") + logger.setLevel(logging.INFO) + fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") + fh = logging.FileHandler(log_file, encoding="utf-8") + fh.setFormatter(fmt) + logger.addHandler(fh) + sh = logging.StreamHandler(sys.stdout) + sh.setFormatter(fmt) + logger.addHandler(sh) + return logger + + +def human_type(element, text: str) -> None: + for ch in text: + element.send_keys(ch) + time.sleep(random.uniform(0.03, 0.12)) + + +def save_result(acc: dict, category: str, msg: str = "") -> None: + label = f"{acc['login']}:{acc['password']}" if acc['password'] else acc['login'] + if not label: + label = acc['cookie'][:40] + "..." + line = f"{label} - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + if msg: + line += f" - {msg}" + file_path = RESULT_DIR / f"{category}.txt" + with open(file_path, "a", encoding="utf-8") as f: + f.write(line + "\n") + log.info("Saved to %s", file_path.name) + + +def _cookie_login(driver, cookie: str) -> bool: + driver.get("https://www.roblox.com") + time.sleep(2) + try: + accept = WebDriverWait(driver, 3).until(EC.element_to_be_clickable((By.XPATH, "//button[text()='Accept All']"))) + accept.click() + time.sleep(1) + except Exception: + pass + try: + driver.add_cookie({"name": ".ROBLOSECURITY", "value": cookie, "domain": ".roblox.com"}) + driver.get("https://www.roblox.com/home") + time.sleep(3) + return "/login" not in driver.current_url + except Exception: + return False + + +def process_account(driver, acc: dict, donor_video: Path | None, + frames_b64: list[str] | None = None, w: int = 0, h: int = 0, + dir_ranges: dict | None = None, speed_multiplier: float = 1.0) -> bool: + login, password, cookie = acc["login"], acc["password"], acc["cookie"] + log.info("=== %s ===", login or "cookie-only") + + if cookie: + if _cookie_login(driver, cookie): + log.info("Logged in via cookie") + elif login and password: + log.info("Cookie expired, falling back to login:pass") + cookie = "" + else: + log.warning("Cookie expired and no password — skipping") + return False + + if not cookie: + driver.get("https://www.roblox.com/login") + WebDriverWait(driver, 15).until(EC.presence_of_element_located((By.ID, "login-username"))) + + try: + accept = WebDriverWait(driver, 3).until(EC.element_to_be_clickable((By.XPATH, "//button[text()='Accept All']"))) + accept.click() + time.sleep(1) + except Exception: + pass + + username_input = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "login-username"))) + ActionChains(driver).move_to_element(username_input).pause(0.3).perform() + time.sleep(0.1); username_input.click(); time.sleep(0.1); username_input.clear() + human_type(username_input, login) + + password_input = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "login-password"))) + ActionChains(driver).move_to_element(password_input).pause(0.3).perform() + time.sleep(0.1); password_input.click(); time.sleep(0.1); password_input.clear() + human_type(password_input, password) + + login_btn = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "login-button"))) + ActionChains(driver).move_to_element(login_btn).pause(0.2).perform() + time.sleep(0.1); login_btn.click() + + WebDriverWait(driver, 30).until(lambda d: "/login" not in d.current_url) + + try: + agree = WebDriverWait(driver, 3).until(EC.element_to_be_clickable((By.XPATH, "//button[contains(@class, 'modal-button') and contains(@class, 'btn-control-md')]"))) + agree.click() + log.info("Accepted Updated Agreements") + time.sleep(2) + except Exception: + pass + + driver.get("https://www.roblox.com/my/account#!/info") + time.sleep(3) + + try: + body = driver.find_element(By.TAG_NAME, "body").text + if "Did we get your age group right" in body: + log.info("VERIFIED: already verified (age group banner)") + save_result(acc, "verified", "age group banner") + return "verified" + except Exception: + pass + + try: + close_btn = driver.find_element(By.XPATH, "//div[@data-state='open' and contains(@class, 'dialog-overlay')]/..//button[contains(@class, 'icon')]") + close_btn.click() + log.info("Dismissed overlay dialog") + time.sleep(1) + except Exception: + pass + + camera_btn = WebDriverWait(driver, 15).until(EC.presence_of_element_located((By.XPATH, "//span[text()='Continue with camera']/ancestor::button"))) + try: + camera_btn.click() + except Exception: + driver.execute_script("arguments[0].click();", camera_btn) + log.info("Clicked camera button via JS") + + time.sleep(15) + + for text in ["Sorry, your verification was declined", "Session expired"]: + try: + el = driver.find_element(By.XPATH, f"//*[contains(text(), '{text}')]") + msg = el.text + log.info(msg) + cat = "expired" if "Session expired" in msg else "declined" + save_result(acc, cat, msg) + return "Session expired" in msg + except Exception: + pass + + try: + allow_btn = WebDriverWait(driver, 5).until( + EC.element_to_be_clickable((By.XPATH, "//button[contains(text(), 'Continue')] | //button[contains(text(), 'Allow')] | //button[contains(text(), 'Accept')]")) + ) + allow_btn.click() + log.info("Accepted Roblox permission dialog") + time.sleep(3) + except Exception: + log.info("No permission dialog to accept") + + if not donor_video: + log.info("No donor video — all clear.") + return False + + try: + iframe = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.XPATH, "//iframe[contains(@src, '/widget') or contains(@src, 'inquiry.withpersona.com/widget')]")) + ) + log.info("Persona iframe found") + except Exception: + log.warning("Persona widget iframe not found") + return False + + if frames_b64 is None and donor_video: + w, h = _get_video_size(donor_video) + if dir_ranges is None: + dir_ranges, _ = _analyze_video_directions(donor_video) + frames_b64 = _extract_frames_jpg_b64(donor_video, w, h, step=2) + log.info("Extracted %d frames from %s", len(frames_b64), donor_video.name) + if dir_ranges is None and donor_video: + dir_ranges, _ = _analyze_video_directions(donor_video) + log.info("Direction ranges: %s", dir_ranges) + + driver.switch_to.frame(iframe) + log.info("Switched to Persona iframe") + + try: + body_text = driver.find_element(By.TAG_NAME, "body").text + if "Session expired" in body_text: + log.info("Session expired detected in Persona iframe before video flow") + save_result(acc, "expired", "in iframe before video") + driver.switch_to.default_content() + return True + except Exception: + pass + + try: + consent = driver.find_element(By.XPATH, "//input[@type='checkbox' and @aria-label='I consent to the processing of my biometric information']") + driver.execute_script("arguments[0].click();", consent) + log.info("Biometric consent checkbox clicked") + time.sleep(0.5) + except Exception: + pass + + all_b64 = ",".join(frames_b64) + script = f""" + (function() {{ + if (window.__camReady) return; + window.__camReady = true; + + var _frames = '{all_b64}'.split(','); + var _canvas, _stream, _idx = 0, _paused = true; + var _currentDir = 'center'; + var _pulseTheta = 0, _pulseFreq = 0.05, _pulseIntensity = 0.003; + var _frameInterval = Math.round(33 / {speed_multiplier}); + var _streamResolve; + var _streamPromise = new Promise(function(r) {{ _streamResolve = r; }}); + var _rotation = 0, _warmth = 0, _filter = 'none', _scaleX = 1, _scaleY = 1; + + function _applyArtifacts(ctx, w, h) {{ + var imgData = ctx.getImageData(0, 0, w, h); + var data = imgData.data; + _pulseTheta += _pulseFreq; + var pulseDelta = Math.sin(_pulseTheta) * _pulseIntensity; + var warmth = _warmth * 255; + for (var i = 0; i < data.length; i += 4) {{ + var noise = (Math.random() - 0.5) * 6; + data[i] = Math.min(255, Math.max(0, data[i] + noise + (pulseDelta * 255) + warmth)); + data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + noise)); + data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + noise - warmth)); + }} + ctx.putImageData(imgData, 0, 0); + ctx.translate((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.4); + }} + + function _draw() {{ + if (!_paused) {{ + _idx = (_idx + 1) % _frames.length; + }} + var img = new Image(); + img.onload = function() {{ + var ctx = _canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, _canvas.width, _canvas.height); + ctx.filter = _filter; + var cx = _canvas.width / 2, cy = _canvas.height / 2; + ctx.translate(cx, cy); + ctx.rotate(_rotation * Math.PI / 180); + ctx.scale(_scaleX, _scaleY); + if (_currentDir !== 'center') {{ + ctx.scale(-1, 1); + }} + ctx.drawImage(img, -cx, -cy, _canvas.width, _canvas.height); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.filter = 'none'; + _applyArtifacts(ctx, _canvas.width, _canvas.height); + setTimeout(_draw, _frameInterval); + }}; + img.src = 'data:image/jpeg;base64,' + _frames[_idx]; + }} + + _canvas = document.createElement('canvas'); + _canvas.width = {w}; + _canvas.height = {h}; + var img0 = new Image(); + img0.onload = function() {{ + var ctx = _canvas.getContext('2d'); + ctx.drawImage(img0, 0, 0); + _applyArtifacts(ctx, _canvas.width, _canvas.height); + _stream = _canvas.captureStream(30); + _streamResolve(_stream); + }}; + img0.src = 'data:image/jpeg;base64,' + _frames[0]; + + window.__startVideo = function() {{ + _rotation = (Math.random() - 0.5) * 5; + _warmth = (Math.random() - 0.5) * 0.08; + var blur = (Math.random() * 0.5 + 0.2).toFixed(1) + 'px'; + var contrast = (1 + (Math.random() - 0.5) * 0.1).toFixed(3); + var brightness = (1 + (Math.random() - 0.5) * 0.08).toFixed(3); + _filter = 'blur(' + blur + ') contrast(' + contrast + ') brightness(' + brightness + ')'; + _scaleX = 1 + (Math.random() - 0.5) * 0.08; + _scaleY = 1 + (Math.random() - 0.5) * 0.08; + _paused = false; + _draw(); + }}; + window.__seekDirection = function(dir) {{ _currentDir = dir; }}; + + if (navigator.mediaDevices) {{ + navigator.mediaDevices.getUserMedia = function(constraints) {{ + return _streamPromise; + }}; + navigator.mediaDevices.enumerateDevices = function() {{ + return Promise.resolve([{{deviceId:'cam',kind:'videoinput',label:'Cam',groupId:'g'}}]); + }}; + }} + if (navigator.permissions) {{ + var _pq = navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query = function(d) {{ + if (d && d.name === 'camera') return Promise.resolve({{state:'granted',onchange:null}}); + return _pq(d); + }}; + }} + }})(); + """ + + driver.execute_script(script) + log.info("Camera override injected (%dx%d)", w, h) + + time.sleep(10) + + for _ in range(20): + try: + driver.find_element(By.XPATH, "//button[@data-test='button__primary' and @id='button_submit']").click() + log.info("Clicked primary Continue") + break + except Exception: + time.sleep(1) + + for _ in range(20): + try: + driver.find_element(By.XPATH, "//button[@data-test='selfie-prompt__button--camera']").click() + log.info("Clicked selfie Continue") + time.sleep(3) + break + except Exception: + time.sleep(1) + + log.info("Waiting for center instruction...") + for _ in range(60): + try: + alert = driver.find_element(By.XPATH, "//span[@role='alert']") + txt = alert.text.lower() + if "center" in txt or "центр" in txt: + driver.execute_script("window.__startVideo();") + log.info("Center instruction detected, video started") + break + except Exception: + pass + time.sleep(1) + + end_time = time.time() + 120 + log.info("Verification loop — manual mode") + while time.time() < end_time: + try: + body = driver.find_element(By.TAG_NAME, "body").text + if "Try taking another photo" in body or "couldn't estimate your age" in body: + log.info("Photo rejected, restarting browser") + driver.switch_to.default_content() + return True + if "Session expired" in body: + log.info("Session expired detected during verification") + driver.switch_to.default_content() + return True + if "estimated your age" in body.lower() and "full access" in body.lower(): + log.info("VERIFIED: age verified successfully") + save_result(acc, "verified", "in iframe") + try: + ok_btn = driver.find_element(By.XPATH, "//button[@id='complete__button' and @data-test='button__primary']") + ok_btn.click() + time.sleep(2) + except Exception: + pass + driver.switch_to.default_content() + return "verified" + except Exception: + pass + time.sleep(2) + + try: + body_text = driver.find_element(By.TAG_NAME, "body").text + if "Session expired" in body_text: + log.info("Session expired detected after verification") + driver.switch_to.default_content() + return True + except Exception: + pass + + driver.switch_to.default_content() + + try: + body = driver.find_element(By.TAG_NAME, "body").text + if "estimated your age" in body.lower() and "full access" in body.lower(): + log.info("VERIFIED: age verified successfully") + save_result(acc, "verified", "main page") + return "verified" + except Exception: + pass + + for text in ["Sorry, your verification was declined", "Session expired"]: + try: + el = driver.find_element(By.XPATH, f"//*[contains(text(), '{text}')]") + msg = el.text + log.info(msg) + cat = "expired" if "Session expired" in msg else "declined" + save_result(acc, cat, msg) + return "Session expired" in msg + except Exception: + pass + + log.info("No decline message — pending (wait for days)") + save_result(acc, "pending", "no result in 120s loop") + return False + + +def ensure_files() -> None: + if not PROXY_FILE.exists(): + PROXY_FILE.write_text("# Format: socks5://user:pass@host:port\n# Or: host:port:user:pass\n# Or: host:port\n", encoding="utf-8") + log.info("Created %s — fill in your proxy.", PROXY_FILE) + if not ACCOUNTS_FILE.exists(): + ACCOUNTS_FILE.write_text("# Format: login:pass or login:pass:cookie or login:cookie or just-cookie\n", encoding="utf-8") + log.info("Created %s — add your accounts.", ACCOUNTS_FILE) + + +def _acquire_port(tid: int) -> int: + """Claim a port slot. Blocks until a slot is free.""" + slot = tid % THREADS + _port_locks[slot].acquire() + _port_owners[slot] = threading.current_thread().name + return LOCAL_BRIDGE_PORT + slot + + +def _release_port(port: int) -> None: + slot = port - LOCAL_BRIDGE_PORT + if 0 <= slot < THREADS: + _port_owners[slot] = None + _port_locks[slot].release() + + +def _process_one(tid: int, acc: dict, + all_videos: list[Path], all_proxies: list[str], + result_dir: Path) -> None: + """Process one account in its own thread.""" + global log + login = acc["login"] or acc["cookie"][:20] + "..." + + proxy = random.choice(all_proxies) if all_proxies else None + donor_video = random.choice(all_videos) + + log.info("[T%d] === %s — video: %s%s ===", tid, login, donor_video.name, + f" proxy: {proxy}" if proxy else "") + + try: + fw, fh = _get_video_size(donor_video) + dur = _get_video_duration(donor_video) + speed_multiplier = 0.5 if dur < 15 else 1.0 + if speed_multiplier < 1: + log.info("Video is %.1fs — slowing playback to %.0f%%", dur, speed_multiplier * 100) + dir_ranges, _ = _analyze_video_directions(donor_video) + frames_b64 = _extract_frames_jpg_b64(donor_video, fw, fh, step=2) + except Exception as e: + log.error("[T%d] Video analysis failed for %s: %s", tid, login, e) + _processed_logins.add(login) + return + + port = _acquire_port(tid) + try: + pproxy_proc = start_pproxy_bridge(proxy, port) if proxy else None + + max_retries = 15 + for attempt in range(max_retries): + options = uc.ChromeOptions() + options.add_argument("--disable-blink-features=AutomationControlled") + if pproxy_proc: + options.add_argument(f"--proxy-server=http://{LOCAL_BRIDGE_HOST}:{port}") + + driver = None + try: + with _chrome_lock: + driver = uc.Chrome(options=options, headless=False, version_main=149) + result = process_account(driver, acc, donor_video, frames_b64, fw, fh, dir_ranges, speed_multiplier) + if result == "verified": + log.info("[T%d] Account verified", tid) + elif not result: + log.info("[T%d] Account done (no retry needed)", tid) + else: + log.info("[T%d] Session expired, retrying (attempt %d/%d)", tid, attempt + 1, max_retries) + continue + break + except Exception as e: + log.error("[T%d] Error: %s", tid, e) + break + finally: + if TEST_MODE: + input(f"[T{tid}] Press Enter to close browser for {login} and continue...\n") + if driver: + try: + driver.quit() + except Exception: + pass + + if pproxy_proc: + pproxy_proc.terminate() + try: + pproxy_proc.wait(timeout=5) + except Exception: + pass + finally: + _release_port(port) + + _processed_logins.add(login) + + +def main() -> None: + global log, RESULT_DIR + log = setup_logger() + + ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + RESULT_DIR = Path("result") / ts.replace(":", "-") + RESULT_DIR.mkdir(parents=True, exist_ok=True) + log.info("Results: %s", RESULT_DIR) + log.info("Threads: %d", THREADS) + + if TEST_MODE: + log.info("TEST MODE enabled — will pause between accounts.") + + ensure_files() + + all_proxies = load_proxies() + if PROXY_FILE.exists() and PROXY_FILE.read_text(encoding="utf-8").strip() and not all_proxies: + log.warning("Invalid proxy format in %s, running without proxy", PROXY_FILE) + + raw_lines = [l.strip() for l in ACCOUNTS_FILE.read_text(encoding="utf-8").splitlines() + if l.strip() and not l.startswith("#")] + accounts = [] + for line in raw_lines: + parsed = parse_account_line(line) + if parsed: + accounts.append(parsed) + else: + log.warning("Skipping unrecognized line: %s", line) + if not accounts: + log.error("No valid accounts found in %s (formats: login:pass, login:pass:cookie, login:cookie, or just cookie)", ACCOUNTS_FILE) + return + + all_videos = _list_videos() + if not all_videos: + log.error("No videos in videos/") + return + log.info("Found %d accounts, %d videos, %d proxies", len(accounts), len(all_videos), len(all_proxies)) + + # Process accounts in parallel + finished_lines = [] + with ThreadPoolExecutor(max_workers=THREADS) as pool: + fut_map = {} + for tid, acc in enumerate(accounts): + fut = pool.submit(_process_one, tid, acc, + all_videos, all_proxies, RESULT_DIR) + fut_map[fut] = acc + + for fut in as_completed(fut_map): + acc = fut_map[fut] + try: + fut.result() + except Exception as e: + label = acc["login"] or acc["cookie"][:20] + log.error("Thread crashed on %s: %s", label, e) + finished_lines.append(acc["_raw"]) + + # Remove all processed accounts from accs.txt + remaining = [l for l in ACCOUNTS_FILE.read_text(encoding="utf-8").splitlines() + if l.strip() not in finished_lines] + ACCOUNTS_FILE.write_text("\n".join(remaining) + "\n", encoding="utf-8") if remaining else ACCOUNTS_FILE.write_text("", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/proxy.txt b/proxy.txt new file mode 100644 index 0000000..8859247 --- /dev/null +++ b/proxy.txt @@ -0,0 +1,3 @@ +# Format: socks5://user:pass@host:port +# Or: host:port:user:pass +# Or: host:port diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5f7e34 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +undetected-chromedriver +selenium +opencv-python +pproxy diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..5763595 --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,61 @@ +import sys, ast, os + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +with open("main.py", encoding="utf-8") as f: + src = f.read() + +# isolate parse_account_line (no external deps) +tree = ast.parse(src) +func_def = None +for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "parse_account_line": + func_def = node + break + +# exec only the function +exec(compile(ast.Module([func_def], []), "", "exec")) + +def test_login_pass(): + r = parse_account_line("user:pass") + assert r["login"] == "user" + assert r["password"] == "pass" + assert r["cookie"] == "" + +def test_login_pass_cookie(): + raw = "user:pass:_|WARNING:some-cookie:with:colons" + r = parse_account_line(raw) + assert r["login"] == "user" + assert r["password"] == "pass" + assert r["cookie"] == "_|WARNING:some-cookie:with:colons" + +def test_login_cookie(): + raw = "user:_|WARNING:some-cookie:with:colons" + r = parse_account_line(raw) + assert r["login"] == "user" + assert r["password"] == "" + assert r["cookie"] == "_|WARNING:some-cookie:with:colons" + +def test_cookie_only(): + raw = "_|WARNING:" + "x" * 100 + r = parse_account_line(raw) + assert r["login"] == "" + assert r["password"] == "" + assert r["cookie"] == raw + +def test_short_line_no_colon(): + assert parse_account_line("short") is None + +def test_raw_preserved(): + raw = "user:pass:cookie" + r = parse_account_line(raw) + assert r["_raw"] == raw + +if __name__ == "__main__": + test_login_pass() + test_login_pass_cookie() + test_login_cookie() + test_cookie_only() + test_short_line_no_colon() + test_raw_preserved() + print("All tests passed")