#!/usr/bin/env python3 import os import sys import time import random import socket import concurrent.futures import requests from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError # ============================================================ # 环境变量配置 # ============================================================ USERNAME = os.getenv("USERNAME") or "" PASSWORD = os.getenv("PASSWORD") or "" # 多账号配置(可选) # 多行文本,每行一个账号,格式:邮箱:密码 ACCOUNTS = os.getenv("ACCOUNTS") or "" TG_BOT_TOKEN = os.getenv("TG_BOT_TOKEN") or "" TG_CHAT_ID = os.getenv("TG_CHAT_ID") or "" # ============================================================ # 代理配置(可选) # ============================================================ PROXY_SERVER = os.getenv("PROXY_SERVER") or "" PROXY_USERNAME = os.getenv("PROXY_USERNAME") or "" PROXY_PASSWORD = os.getenv("PROXY_PASSWORD") or "" # ============================================================ # 基础配置 # ============================================================ # 默认站点(ACCOUNTS 行中未写站点前缀时使用) DEFAULT_SITE_URL = "https://agentrouter.org" USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/125.0.0.0 Safari/537.36" ) # 登录表单元素候选选择器 # (new-api 系站点兼容) LOGIN_FORM_INPUT_SELECTOR = ( 'input#username, input[name="username"], ' 'input[name="email"], input[type="password"]' ) USERNAME_SELECTORS = ( "input#username", 'input[name="username"]', 'input[name="email"]', 'form input[type="text"]', ) PASSWORD_SELECTORS = ( "input#password", 'input[name="password"]', 'input[type="password"]', ) SUBMIT_SELECTORS = ( 'button[type="submit"]', 'button:text-is("登录")', 'button:text-is("登 录")', 'button:text-is("Log In")', 'button:text-is("Sign In")', ) # 邮箱登录入口的候选文案 EMAIL_LOGIN_TEXTS = ( "Sign in with Email or Username", "Email or Username", "Sign in with Email", "账号登录", "邮箱登录", "密码登录", ) # ============================================================ # 代理池配置 # ============================================================ FREEPROXY_POOL_URL = ( "https://charlespikachu.github.io/freeproxy/proxies.json" ) PROXY_COUNTRY = os.getenv("PROXY_COUNTRY") or "" PROXY_SAMPLE_SIZE = int( os.getenv("PROXY_SAMPLE_SIZE") or "100" ) PROXY_MAX_ROUNDS = int( os.getenv("PROXY_MAX_ROUNDS") or "5" ) PROBE_TIMEOUT = 15 PROBE_WORKERS = 50 MAX_LOGIN_ATTEMPTS = 3 # 每个账号代理池登录轮数上限 # (一轮登录全部失败后, # 重新随机提取代理再试,最多这么多轮) PROXY_LOGIN_ROUNDS = int( os.getenv("PROXY_LOGIN_ROUNDS") or "5" ) # ============================================================ # 浏览器请求头 # ============================================================ BROWSER_HEADERS = { "accept": ( "text/html,application/xhtml+xml,application/xml;" "q=0.9,image/avif,image/webp,*/*;q=0.8" ), "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", "cache-control": "no-cache", "pragma": "no-cache", "sec-ch-ua": ( '"Not=A?Brand";v="99", ' '"Microsoft Edge";v="151", ' '"Chromium";v="151"' ), "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "none", "upgrade-insecure-requests": "1", "user-agent": USER_AGENT, } # ============================================================ # 代理池请求头 # ============================================================ POOL_HEADERS = { "accept": "*/*", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", "cache-control": "no-cache", "pragma": "no-cache", "referer": "https://charlespikachu.github.io/freeproxy/", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "user-agent": USER_AGENT, } # ============================================================ # WAF / 人机验证特征 # ============================================================ WAF_MARKERS = ( # 指纹级特征 "aliyun_waf_aa", "aliyun_waf_bb", "initaliyuncaptcha", "aliyuncaptcha-sliding-slider", "cf_app_waf", "captcha-frontend", # Cookie / WAF 标识 "acw_sc__v2", "__acw", "acw_tc", "cdn_sec_tc", "x5secdata", "punishpage", # 中文验证 "访问验证", "滑动验证", "安全验证", "人机验证", "滑动滑块", "拖动滑块", "完成以下验证", "请完成验证", "验证成功后", "异常访问拦截", "请求被拦截", # 通用验证 "verify you are human", "verifying you are human", "attention required", "just a moment", "challenge-platform", "turnstile", "cf-challenge", "unusual traffic", "captcha", ) # ============================================================ # 日志 # ============================================================ def log(message: str): """ 极简日志。 注意: 不允许传入用户名、User ID、密码、代理 IP、 URL、HTML、接口响应、异常详情等敏感信息。 """ print(message, flush=True) # ============================================================ # Telegram # ============================================================ def send_telegram(message: str) -> bool: """ 发送 Telegram 消息。 消息内容只允许包含: - 当前余额 - 签到状态 不发送账号、用户名、User ID 等用户信息。 """ if not TG_BOT_TOKEN or not TG_CHAT_ID: return False try: url = f"https://api.telegram.org/bot{TG_BOT_TOKEN}/sendMessage" data = { "chat_id": TG_CHAT_ID, "text": message, "parse_mode": "HTML", } response = requests.post( url, json=data, timeout=30, ) response.raise_for_status() return True except Exception: # 故意不打印异常,避免异常中包含敏感信息 return False # ============================================================ # 账号解析 # ============================================================ def parse_accounts() -> list: """ 解析账号列表。 优先级: 1. ACCOUNTS(多行,每行 [站点URL|]邮箱:密码) 2. USERNAME / PASSWORD(旧版单账号兼容) 说明: - 站点前缀可省略,省略时使用默认站点 - 站点与账号用 | 分隔 - 密码中可以包含冒号, 只按第一个冒号分割邮箱与密码 """ accounts = [] invalid_lines = [] if ACCOUNTS: for line_no, raw_line in enumerate( ACCOUNTS.splitlines(), start=1, ): # 清理 BOM / 零宽字符 / 全角竖线 line = ( raw_line.replace("\ufeff", "") .replace("\u200b", "") .replace("\uff5c", "|") .strip() ) if not line: continue site_url = "" body = line # 站点前缀 if "|" in line: site_url, _, body = line.partition("|") site_url = site_url.strip() # 全角冒号兜底 # (仅当行内没有半角冒号时才转换) if ":" not in body and ":" in body: body = body.replace(":", ":") if ":" not in body: invalid_lines.append(line_no) continue username, _, password = body.partition(":") username = username.strip() password = password.strip() if not username or not password: invalid_lines.append(line_no) continue # 站点归一化 if not site_url: site_url = DEFAULT_SITE_URL elif not site_url.startswith("http"): site_url = f"https://{site_url}" site_url = site_url.rstrip("/") accounts.append( { "site": site_url, "username": username, "password": password, } ) if invalid_lines: log( "配置警告:ACCOUNTS 第 " + ", ".join( str(n) for n in invalid_lines ) + " 行格式无效,已跳过" ) # 兼容旧单账号配置 if not accounts and USERNAME and PASSWORD: accounts.append( { "site": DEFAULT_SITE_URL, "username": USERNAME, "password": PASSWORD, } ) return accounts # ============================================================ # 站点打码 # ============================================================ def mask_site( site_url: str, ) -> str: """ 站点域名打码。 只保留域名前几位,其余屏蔽。 例:https://agentrouter.org -> agen*** """ domain = site_url or "" for prefix in ("https://", "http://"): if domain.startswith(prefix): domain = domain[len(prefix):] break domain = domain.split("/", 1)[0] visible = domain[:4] return f"{visible}***" def mask_proxy( proxy_item: dict, ) -> str: """ 代理 IP 打码。 只保留前两段,其余屏蔽。 例:124.248.13.5:1080 -> 124.248.***.***:1080 """ ip = str(proxy_item.get("ip") or "") port = proxy_item.get("port") or "" parts = ip.split(".") if ( len(parts) == 4 and all( part.isdigit() for part in parts ) ): return ( f"{parts[0]}.{parts[1]}" f".***.***:{port}" ) # 非 IPv4(域名 / IPv6): # 只保留前 4 位 visible = ip[:4] return f"{visible}***:{port}" # ============================================================ # 获取代理池 # ============================================================ def fetch_proxy_pool() -> list: """ 从 freeproxy 获取代理池。 """ response = requests.get( FREEPROXY_POOL_URL, headers=POOL_HEADERS, timeout=30, ) response.raise_for_status() payload = response.json() if isinstance(payload, dict): items = payload.get("data") or [] elif isinstance(payload, list): items = payload else: items = [] if not items: raise Exception("代理池为空") return items # ============================================================ # 构造代理地址 # ============================================================ def build_proxy_candidates( ip, port, protocol="" ) -> list: protocol = str(protocol or "").lower() base = f"{ip}:{int(port)}" tokens = { token.strip() for token in protocol.replace("/", ",").split(",") if token.strip() } if "http" in tokens: return [ ( "http", f"http://{base}" ) ] if "https" in tokens: return [ ( "https", f"https://{base}" ), ( "http", f"http://{base}" ), ] if "socks5" in tokens: return [ ( "socks5", f"socks5://{base}" ) ] if "socks4" in tokens: return [ ( "socks4", f"socks4://{base}" ) ] return [ ( "http", f"http://{base}" ) ] # ============================================================ # TCP 预检 # ============================================================ def _tcp_reachable( ip, port, timeout: float = 5 ) -> bool: try: with socket.create_connection( ( str(ip), int(port) ), timeout=timeout, ): return True except OSError: return False # ============================================================ # WAF 判断 # ============================================================ def looks_like_waf( status_code: int, html: str ) -> bool: if status_code in (403, 405, 429): return True body = (html or "").lower() return any( marker.lower() in body for marker in WAF_MARKERS ) # ============================================================ # 探测代理 # ============================================================ def probe_proxy( proxy_item: dict, site_url: str, ): ip = proxy_item.get("ip") port = proxy_item.get("port") try: candidates = build_proxy_candidates( ip, port, proxy_item.get("protocol"), ) except (TypeError, ValueError): return None, "BAD_FORMAT" # TCP 预检 if not _tcp_reachable(ip, port): return None, "PORT_UNREACHABLE" for scheme, server in candidates: try: response = requests.get( f"{site_url}/login", headers=BROWSER_HEADERS, proxies={ "http": server, "https": server, }, timeout=PROBE_TIMEOUT, allow_redirects=True, ) # WAF if looks_like_waf( response.status_code, response.text[:20000], ): return None, "WAF" # 非 200 if response.status_code != 200: return None, f"HTTP_{response.status_code}" return { "ip": ip, "port": int(port), "protocol": proxy_item.get( "protocol", "" ), "scheme": scheme, "anonymity": proxy_item.get( "anonymity", "" ), "speed": proxy_item.get( "speed", 0 ), "server": server, }, None except requests.exceptions.ProxyError: # 协议不匹配,继续尝试下一个 scheme continue except Exception: # 不返回异常文本 return None, "ERROR" return None, "PROXY_ERROR" # ============================================================ # 批量探测 # ============================================================ def _probe_batch( batch: list, site_url: str, ) -> tuple: clean = [] stats = {} if not batch: return clean, stats with concurrent.futures.ThreadPoolExecutor( max_workers=min( PROBE_WORKERS, len(batch), ) ) as executor: future_map = { executor.submit( probe_proxy, item, site_url, ): item for item in batch } for future in concurrent.futures.as_completed( future_map ): try: result, reason = future.result() except Exception: result = None reason = "ERROR" if result is not None: clean.append(result) else: stats[reason] = ( stats.get(reason, 0) + 1 ) return clean, stats # ============================================================ # 查找干净代理 # ============================================================ def find_clean_proxies( site_url: str, sample_size: int = PROXY_SAMPLE_SIZE, max_rounds: int = PROXY_MAX_ROUNDS, needed_count: int = 0, ) -> list: pool = fetch_proxy_pool() log(f"代理池获取: {len(pool)} 个代理") # 国家过滤 if PROXY_COUNTRY: pool = [ item for item in pool if str( item.get("country", "") ).upper() == PROXY_COUNTRY ] log( f"国家过滤后: {len(pool)} 个代理" ) if not pool: raise Exception("代理池过滤后为空") # 打乱代理 random.shuffle(pool) remaining = pool needed = needed_count or max( 1, MAX_LOGIN_ATTEMPTS, ) clean_proxies = [] stats = {} scanned = 0 for round_no in range( 1, max_rounds + 1, ): batch = remaining[:sample_size] remaining = remaining[sample_size:] if not batch: break round_clean, round_stats = _probe_batch( batch, site_url, ) clean_proxies.extend( round_clean ) scanned += len(batch) for key, value in round_stats.items(): stats[key] = ( stats.get(key, 0) + value ) if len(clean_proxies) >= needed: break # 按代理池速度排序 clean_proxies.sort( key=lambda item: ( item.get("speed") or 99999 ) ) # 只输出数量与阶段码,不含敏感信息 reason_text = ", ".join( f"{key} {value}" for key, value in sorted( stats.items(), key=lambda kv: -kv[1], )[:3] ) log( f"代理池探测: 扫描 {scanned} 个," f"可用 {len(clean_proxies)} 个" + (f"({reason_text})" if reason_text else "") ) return clean_proxies # ============================================================ # 共享代理队列(按站点独立) # ============================================================ CLEAN_PROXIES_BY_SITE = {} POOL_BROKEN_SITES = set() # 直连兜底已失败的站点 # ( Runner IP 被 WAF 拦截时, # 后续账号不再重复直连,避免无效耗时 ) DIRECT_FAILED_SITES = set() def pop_proxy( site_url: str, total_accounts: int, ) -> dict | None: """ 从该站点的共享代理队列取一个干净代理。 每个代理在交给浏览器登录前都会现场复检, 复检失败立即丢弃并尝试下一个; 队列耗尽时针对该站点重新探测少量代理, 探测失败则本次运行不再尝试该站点的代理池。 """ if site_url in POOL_BROKEN_SITES: return None queue = CLEAN_PROXIES_BY_SITE.get( site_url ) or [] # ------------------------------------------------ # 现场复检:剔除探测后已失效的代理 # (免费代理存活期短,预探测的代理 # 到真正登录时可能已经死了) # ------------------------------------------------ while queue: candidate = queue.pop(0) fresh, _ = probe_proxy( candidate, site_url, ) if fresh is not None: CLEAN_PROXIES_BY_SITE[site_url] = queue return fresh # ------------------------------------------------ # 队列耗尽:重新探测少量代理 # # 不再按账号数批量预留: # 预留越多,到使用时失效越多, # 且首次探测耗时随账号数爆炸 # ------------------------------------------------ log("代理队列耗尽,重新探测代理池") try: queue = find_clean_proxies( site_url=site_url, needed_count=MAX_LOGIN_ATTEMPTS, ) except Exception: # 不打印异常详情 queue = [] CLEAN_PROXIES_BY_SITE[site_url] = queue if not queue: POOL_BROKEN_SITES.add(site_url) log("代理池不可用,本次运行跳过代理池通道") return None # ------------------------------------------------ # 复检刚探测到的代理, # 全部当场失效则返回 None # ------------------------------------------------ while queue: candidate = queue.pop(0) fresh, _ = probe_proxy( candidate, site_url, ) if fresh is not None: CLEAN_PROXIES_BY_SITE[site_url] = queue return fresh return None # ============================================================ # 等待 WAF # ============================================================ def wait_for_waf_ready( page, context=None, timeout_ms: int = 45000, ) -> bool: WAF_COOKIE_NAMES = ( "acw_tc", "cdn_sec_tc", "acw_sc__v2", "__jsluid_s", "__cf_bm", ) deadline = ( time.time() + timeout_ms / 1000.0 ) reload_count = 0 last_reload_time = 0.0 while time.time() < deadline: try: state = page.evaluate( """ () => { const bodyText = (document.body && document.body.innerText) || ''; const hasLoginForm = !!document.querySelector( 'input#username, input[name="username"], input[name="email"], input[type="password"]' ); const hasLoginButton = /Sign in with Email or Username|Email or Username|Log In|Sign In|登录|账号登录|邮箱登录/ .test(bodyText); const challengeSelectors = [ '#challenge-form', '.cf-challenge', '#cf-challenge-running', 'iframe[src*="challenges.cloudflare.com"]', '[class*="turnstile"]', '[class*="captcha"]' ]; const isChallenge = challengeSelectors.some( sel => !!document.querySelector(sel) ) || /challenge|verify you are human|attention required|verification/i .test(bodyText.slice(0, 300)) || document.title .toLowerCase() .includes('verification'); return { hasLoginForm, hasLoginButton, isChallenge }; } """ ) except Exception: page.wait_for_timeout(1000) continue # 登录页面已经出现 if ( state.get("hasLoginForm") or state.get("hasLoginButton") ): return True # WAF 挑战 if state.get("isChallenge"): # Cookie 已生成时刷新 if ( context is not None and reload_count < 3 and ( time.time() - last_reload_time >= 10 ) ): try: cookie_names = { cookie.get("name") for cookie in context.cookies() } if any( name in cookie_names for name in WAF_COOKIE_NAMES ): page.reload( wait_until="domcontentloaded", timeout=30000, ) reload_count += 1 last_reload_time = time.time() except Exception: pass page.wait_for_timeout(1000) return False # ============================================================ # 点击邮箱 / 用户名登录 # ============================================================ def click_email_login_button( page, timeout_ms: int = 15000, ) -> bool: # 优先 Playwright 定位 # (首个候选给足超时,其余快速尝试) for index, text in enumerate( EMAIL_LOGIN_TEXTS ): try: target = page.get_by_text( text ) target.first.wait_for( state="visible", timeout=( timeout_ms if index == 0 else 3000 ), ) target.first.click( timeout=10000 ) return True except Exception: continue # JS 回退 try: clicked = page.evaluate( """ () => { const candidates = Array.from( document.querySelectorAll( 'button, [role="tab"], a, span, div' ) ); const wanted = [ 'Sign in with Email or Username', 'Email or Username', 'Sign in with Email', '账号登录', '邮箱登录', '密码登录' ]; const target = candidates.find(el => { const text = (el.innerText || '').trim(); return ( wanted.includes(text) || text.includes( 'Email or Username' ) ); }); if (target) { target.click(); return true; } return false; } """ ) if clicked: return True except Exception: pass return False # ============================================================ # 自有节点 → 目标站连通性预测试 # ============================================================ def own_node_reaches_site( site_url: str, ) -> bool: """ 测试自有节点能否连通目标站点。 仅判断传输层连通性: 收到任何 HTTP 响应(含 WAF 拦截页)都算可达; 只有超时 / 连接失败才算不可达。 """ proxies = { "http": PROXY_SERVER, "https": PROXY_SERVER, } try: requests.get( f"{site_url}/login", headers=BROWSER_HEADERS, proxies=proxies, timeout=PROBE_TIMEOUT, allow_redirects=True, ) return True except Exception: return False # ============================================================ # 浏览器登录 # ============================================================ class _LoginStageError(Exception): """ 登录流程阶段异常。 只携带阶段码,不携带任何页面信息。 """ def __init__(self, stage: str): super().__init__(stage) self.stage = stage def _first_visible_locator( page, selectors, timeout_ms: int = 4000, ): """ 依次尝试候选选择器, 返回第一个可见的 Locator,找不到返回 None。 """ for selector in selectors: try: locator = page.locator( selector ) if locator.count() == 0: continue locator.first.wait_for( state="visible", timeout=timeout_ms, ) return locator.first except Exception: continue return None def browser_login_complete( account: dict, proxy: dict | None = None, ) -> tuple: # -------------------------------------------------------- # 代理配置 # -------------------------------------------------------- proxy_config = None if proxy and proxy.get("server"): proxy_config = { "server": proxy["server"] } elif PROXY_SERVER: proxy_config = { "server": PROXY_SERVER } if PROXY_USERNAME and PROXY_PASSWORD: proxy_config["username"] = ( PROXY_USERNAME ) proxy_config["password"] = ( PROXY_PASSWORD ) # -------------------------------------------------------- # 启动浏览器 # -------------------------------------------------------- result = None stage = "UNKNOWN" with sync_playwright() as p: browser = None try: browser = p.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--disable-blink-features=AutomationControlled", ], proxy=proxy_config, ) context = browser.new_context( viewport={ "width": 1920, "height": 1080, }, user_agent=USER_AGENT, ) page = context.new_page() # ------------------------------------------------ # Step 1:访问登录页 # # commit:收到首包即认为导航开始, # 页面内容由后续 WAF 等待逻辑轮询, # 避免慢代理下 domcontentloaded 直接超时。 # 超时后重试一次。 # ------------------------------------------------ goto_done = False for _ in range(2): try: page.goto( f"{account['site']}/login", wait_until="commit", timeout=45000, ) goto_done = True break except PlaywrightTimeoutError: continue if not goto_done: raise _LoginStageError( "GOTO_TIMEOUT" ) # ------------------------------------------------ # Step 2:等待 WAF # ------------------------------------------------ if not wait_for_waf_ready( page, context=context, timeout_ms=60000, ): raise _LoginStageError( "WAF_TIMEOUT" ) # ------------------------------------------------ # 检查页面是否有登录表单输入框 # ------------------------------------------------ has_inputs = page.locator( LOGIN_FORM_INPUT_SELECTOR ).count() # ------------------------------------------------ # 没有输入框时切换登录方式 # ------------------------------------------------ if has_inputs == 0: clicked = click_email_login_button( page ) if not clicked: # 不输出页面文本、HTML、URL raise _LoginStageError( "FORM_NOT_FOUND" ) page.wait_for_timeout( 1500 ) # ------------------------------------------------ # Step 3:等待表单 # ------------------------------------------------ wait_result = page.evaluate( """ async () => { let attempts = 0; const maxAttempts = 30; while ( attempts < maxAttempts ) { const username = document.querySelector( 'input#username, input[name="username"], input[name="email"], form input[type="text"]' ); const password = document.querySelector( 'input#password, input[name="password"], input[type="password"]' ); const submit = document.querySelector( 'button[type="submit"]' ); const visible = el => el && ( el.offsetParent !== null || el.getClientRects().length > 0 ); if ( visible(username) && visible(password) && visible(submit) ) { return { success: true }; } await new Promise( resolve => setTimeout( resolve, 500 ) ); attempts++; } return { success: false }; } """ ) if not wait_result.get( "success" ): raise _LoginStageError( "FORM_NOT_FOUND" ) # ------------------------------------------------ # 填写用户名 # ------------------------------------------------ try: username_locator = _first_visible_locator( page, USERNAME_SELECTORS, ) if username_locator is None: raise _LoginStageError( "FORM_NOT_FOUND" ) username_locator.click( timeout=5000 ) username_locator.fill( account["username"], timeout=5000, ) # -------------------------------------------- # 填写密码 # -------------------------------------------- password_locator = _first_visible_locator( page, PASSWORD_SELECTORS, ) if password_locator is None: raise _LoginStageError( "FORM_NOT_FOUND" ) password_locator.click( timeout=5000 ) password_locator.fill( account["password"], timeout=5000, ) page.wait_for_timeout( 1000 ) # -------------------------------------------- # Step 4:提交 # -------------------------------------------- submit_locator = _first_visible_locator( page, SUBMIT_SELECTORS, ) if submit_locator is None: raise _LoginStageError( "FORM_NOT_FOUND" ) submit_locator.click( timeout=5000 ) except PlaywrightTimeoutError: raise _LoginStageError( "FORM_OPERATE_TIMEOUT" ) # ------------------------------------------------ # Step 5:等待登录 # ------------------------------------------------ page.wait_for_timeout( 3000 ) # 检查验证码 has_captcha = page.evaluate( """ () => !!document.querySelector( '#nc_1_n1z, .nc-container, [class*="captcha"]' ) """ ) if has_captcha: page.wait_for_timeout( 5000 ) # ------------------------------------------------ # Step 6:获取用户信息 # ------------------------------------------------ api_result = page.evaluate( """ async () => { try { let userStr = null; for ( let attempt = 0; attempt < 10; attempt++ ) { userStr = localStorage.getItem( 'user' ); if (userStr) { break; } await new Promise( resolve => setTimeout( resolve, 500 ) ); } if (!userStr) { return { success: false, error: "USER_NOT_FOUND" }; } const localUser = JSON.parse( userStr ); if (!localUser.id) { return { success: false, error: "USER_ID_INVALID" }; } const response = await fetch( '/api/user/self', { method: 'GET', headers: { 'Accept': 'application/json, text/plain, */*', 'New-API-User': String( localUser.id ) }, credentials: 'include', cache: 'no-store' } ); let payload; try { payload = await response.json(); } catch (err) { return { success: false, status: response.status, error: "INVALID_JSON" }; } return { success: response.ok, status: response.status, payload }; } catch (err) { return { success: false, error: "API_ERROR" }; } } """ ) # ------------------------------------------------ # API 请求失败 # ------------------------------------------------ if not api_result.get( "success" ): # 登录后拿不到用户信息, # 通常是凭据错误或验证码拦截 raise _LoginStageError( "USER_API_FAIL" ) payload = api_result.get( "payload" ) if ( not isinstance( payload, dict ) or payload.get( "success" ) is not True ): raise _LoginStageError( "API_PAYLOAD_FAIL" ) user_data = payload.get( "data" ) if not isinstance( user_data, dict ): raise _LoginStageError( "API_PAYLOAD_FAIL" ) # ------------------------------------------------ # 只读取 quota # ------------------------------------------------ quota = user_data.get( "quota" ) if ( isinstance(quota, bool) or not isinstance( quota, (int, float) ) ): raise _LoginStageError( "QUOTA_INVALID" ) # ------------------------------------------------ # 只返回 quota # # 不再返回: # user_id # username # 账户 # 其他用户资料 # ------------------------------------------------ result = { "quota": quota } stage = "SUCCESS" except _LoginStageError as exc: stage = exc.stage result = None except PlaywrightTimeoutError: # 不打印 URL、页面信息、异常内容 stage = "PAGE_TIMEOUT" result = None except Exception: # 不打印异常 # browser 未创建成功说明是启动阶段失败 if browser is None: stage = "BROWSER_LAUNCH_FAIL" else: stage = "UNEXPECTED" result = None finally: if browser is not None: try: browser.close() except Exception: pass return result, stage # ============================================================ # 余额转换 # ============================================================ def format_balance( quota: int ) -> str: """ quota 转换为美元。 假设: 500000 quota = $1 """ if quota is None: return "N/A" balance = quota / 500000 return f"{balance:.2f}$" # ============================================================ # 单账号签到 # ============================================================ def checkin_account( account: dict, total_accounts: int, label: str = "", ) -> tuple: last_stage = "UNKNOWN" # -------------------------------------------------------- # 通道一: # 自有代理 # -------------------------------------------------------- if PROXY_SERVER: log(f"{label} 尝试自有节点登录") result, last_stage = browser_login_complete( account ) if result: return result, last_stage log( f"{label} 尝试(自有节点): " f"{last_stage}" ) # -------------------------------------------------------- # 通道二: # 免费代理池(按站点独立队列,耗尽自动重探) # # 每轮最多尝试 MAX_LOGIN_ATTEMPTS 个代理 # (队列耗尽时随机提取新一批探测); # 整轮登录全部失败后重新提取代理再试, # 最多 PROXY_LOGIN_ROUNDS 轮 # -------------------------------------------------------- pool_attempted = False for round_no in range( 1, PROXY_LOGIN_ROUNDS + 1, ): for attempt_no in range( MAX_LOGIN_ATTEMPTS ): proxy = pop_proxy( account["site"], total_accounts, ) if proxy is None: break pool_attempted = True # 代理已现场复检通过,开始登录 # (IP 打码输出,避免完整泄露) log( f"{label} 使用代理 " f"{mask_proxy(proxy)} 登录" f"(第{round_no}" f"/{PROXY_LOGIN_ROUNDS}轮 " f"尝试{attempt_no + 1}" f"/{MAX_LOGIN_ATTEMPTS})" ) result, last_stage = browser_login_complete( account, proxy, ) if result: return result, last_stage log( f"{label} 第{round_no}轮" f"尝试{attempt_no + 1}" f"(代理池): {last_stage}" ) # 代理池已被标记不可用时, # 后续轮次不会再拿到代理,直接结束 if account["site"] in POOL_BROKEN_SITES: break if not pool_attempted: last_stage = "NO_PROXY" # -------------------------------------------------------- # 兜底: # 未配置自有节点时,代理池不可用或 # 全部尝试失败后,再尝试一次直连登录 # # 每个站点只直连兜底一次: # 直连失败说明 Runner IP 已被拦截, # 后续账号重复直连只会白耗时间 # -------------------------------------------------------- if ( not PROXY_SERVER and account["site"] not in DIRECT_FAILED_SITES ): log(f"{label} 尝试直连登录(兜底)") result, last_stage = browser_login_complete( account ) if result: return result, last_stage DIRECT_FAILED_SITES.add( account["site"] ) log( f"{label} 尝试(直连兜底): " f"{last_stage},本站点后续不再直连" ) return None, last_stage # ============================================================ # 主签到逻辑 # ============================================================ def run_checkin(): # -------------------------------------------------------- # 解析账号 # -------------------------------------------------------- accounts = parse_accounts() if not accounts: # 不打印具体账号 log("配置错误:登录凭据未配置") sys.exit(1) total = len(accounts) log(f"账号数量: {total}") # -------------------------------------------------------- # 逐账号签到 # -------------------------------------------------------- results = [] success_count = 0 # 每个站点的账号计数(用于标签序号) site_counters = {} for index, account in enumerate(accounts): site = account["site"] site_counters[site] = ( site_counters.get(site, 0) + 1 ) label = ( f"{mask_site(site)} " f"账号{site_counters[site]}" ) log( f"{label}: 开始签到" f"({index + 1}/{total})" ) login_result, stage = checkin_account( account, total, label, ) # ---------------------------------------------------- # 单账号失败,不影响后续账号 # ---------------------------------------------------- if not login_result: # 只输出阶段码,不输出任何敏感信息 log(f"{label}: 签到失败({stage})") results.append( (label, None, stage) ) continue # ---------------------------------------------------- # 获取余额 # ---------------------------------------------------- balance = format_balance( login_result.get( "quota", 0 ) ) # ---------------------------------------------------- # 只打印余额 # ---------------------------------------------------- log(f"{label}: 当前余额 {balance}") results.append( (label, balance, "") ) success_count += 1 # ---------------------------------------------------- # 多账号时随机间隔, # 降低同 IP 连续登录触发 WAF 的概率 # ---------------------------------------------------- if index < total - 1: time.sleep( random.uniform(3, 8) ) # -------------------------------------------------------- # 汇总 Telegram 通知 # -------------------------------------------------------- message_lines = [ "🎁 自动签到通知", "", ] for label, balance, stage in results: if balance is None: message_lines.append( f"❌ {label}: 签到失败({stage})" ) else: message_lines.append( f"✅ {label}: 余额 {balance}" ) message_lines.append("") message_lines.append( f"📊 成功 {success_count}/{total}" ) send_telegram( "\n".join(message_lines) ) # -------------------------------------------------------- # 任一账号失败则退出码 1 # -------------------------------------------------------- if success_count < total: log("部分账号签到失败") sys.exit(1) log("全部账号签到成功") # ============================================================ # Main # ============================================================ def main(): try: run_checkin() except KeyboardInterrupt: # 不打印任何敏感信息 sys.exit(130) except Exception as exc: # 不打印 traceback # 不打印 exception 内容(可能含敏感信息) # 只打印异常类型,便于定位 log(f"脚本执行失败: {type(exc).__name__}") send_telegram( "❌ 签到脚本执行失败" ) sys.exit(1) # ============================================================ # Entry # ============================================================ if __name__ == "__main__": main()