7755 lines
393 KiB
Python
7755 lines
393 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
闲鱼滑块验证 - 增强反检测版本
|
||
基于最新的反检测技术,专门针对闲鱼、淘宝、阿里平台的滑块验证
|
||
"""
|
||
|
||
import time
|
||
import random
|
||
import json
|
||
import hashlib
|
||
import os
|
||
import math
|
||
import threading
|
||
import tempfile
|
||
import shutil
|
||
from datetime import datetime
|
||
from urllib.parse import parse_qs, urlparse
|
||
from playwright.sync_api import sync_playwright, ElementHandle
|
||
from playwright.async_api import async_playwright
|
||
import asyncio
|
||
from typing import Optional, Tuple, List, Dict, Any, Callable
|
||
from loguru import logger
|
||
from collections import defaultdict
|
||
|
||
|
||
# ============================================================================
|
||
# 1D Perlin 噪声实现(纯 Python,无外部依赖)
|
||
# 用于生成连续平滑的非周期性随机序列,替代 sin 叠加
|
||
# ============================================================================
|
||
def _perlin_fade(t):
|
||
"""Perlin 缓动函数: 6t^5 - 15t^4 + 10t^3"""
|
||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||
|
||
|
||
def _perlin_lerp(a, b, t):
|
||
"""线性插值"""
|
||
return a + t * (b - a)
|
||
|
||
|
||
def _perlin_grad_1d(hash_val, x):
|
||
"""1D 梯度:根据 hash 值决定方向"""
|
||
return x if (hash_val & 1) == 0 else -x
|
||
|
||
|
||
# 使用固定排列表(经典 Perlin 实现)
|
||
_PERLIN_PERM = list(range(256))
|
||
random.shuffle(_PERLIN_PERM)
|
||
_PERLIN_PERM = _PERLIN_PERM + _PERLIN_PERM # 扩展到 512
|
||
|
||
|
||
def perlin_noise_1d(x, seed_offset=0):
|
||
"""1D Perlin 噪声,返回 [-1, 1] 范围的值
|
||
|
||
Args:
|
||
x: 采样坐标(连续浮点数)
|
||
seed_offset: 种子偏移量,用于生成不同的噪声序列
|
||
"""
|
||
xi = int(math.floor(x)) & 255
|
||
xf = x - math.floor(x)
|
||
u = _perlin_fade(xf)
|
||
|
||
idx = (xi + int(seed_offset)) & 255
|
||
a = _PERLIN_PERM[idx]
|
||
b = _PERLIN_PERM[idx + 1]
|
||
|
||
return _perlin_lerp(
|
||
_perlin_grad_1d(a, xf),
|
||
_perlin_grad_1d(b, xf - 1),
|
||
u
|
||
)
|
||
|
||
|
||
def perlin_octaves_1d(x, octaves=2, persistence=0.5, seed_offset=0):
|
||
"""多八度叠加的 1D Perlin 噪声(更丰富的细节)
|
||
|
||
Args:
|
||
x: 采样坐标
|
||
octaves: 八度数(叠加层数)
|
||
persistence: 每层振幅衰减比
|
||
seed_offset: 种子偏移
|
||
Returns:
|
||
[-1, 1] 范围的噪声值
|
||
"""
|
||
total = 0.0
|
||
amplitude = 1.0
|
||
frequency = 1.0
|
||
max_amplitude = 0.0
|
||
|
||
for _ in range(octaves):
|
||
total += perlin_noise_1d(x * frequency, seed_offset) * amplitude
|
||
max_amplitude += amplitude
|
||
amplitude *= persistence
|
||
frequency *= 2.0
|
||
|
||
return total / max_amplitude if max_amplitude > 0 else 0.0
|
||
|
||
|
||
class PasswordLoginVerificationError(Exception):
|
||
"""账号密码登录流程中的可识别验证错误。"""
|
||
|
||
|
||
class VerificationFrameWrapper:
|
||
def __init__(self, original_frame, verification_type='unknown', verify_url=None, screenshot_path=None):
|
||
self._original_frame = original_frame
|
||
self.verification_type = verification_type
|
||
self.verify_url = verify_url
|
||
self.screenshot_path = screenshot_path
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(self._original_frame, name)
|
||
|
||
# 导入配置
|
||
try:
|
||
from config import SLIDER_VERIFICATION
|
||
SLIDER_MAX_CONCURRENT = SLIDER_VERIFICATION.get('max_concurrent', 3)
|
||
SLIDER_WAIT_TIMEOUT = SLIDER_VERIFICATION.get('wait_timeout', 60)
|
||
except ImportError:
|
||
# 如果无法导入配置,使用默认值
|
||
SLIDER_MAX_CONCURRENT = 3
|
||
SLIDER_WAIT_TIMEOUT = 60
|
||
|
||
# ============================================================================
|
||
# 🏆 黄金参数配置(基于成功案例分析)
|
||
# 分析来源:trajectory_history/*.json 成功记录
|
||
# 分析时间:2026-01-28 优化版本
|
||
# ============================================================================
|
||
GOLDEN_PARAMS = {
|
||
# 轨迹生成参数 - 🔧 2026-01-28 扩大随机范围,降低被检测概率
|
||
"trajectory": {
|
||
"overshoot_ratio": (1.02, 1.15), # 🔧 改为真实超调比例2-15%(原1.93-2.05太极端)
|
||
"steps": (18, 35), # 🔧 增加步数范围(原6-8太少)
|
||
"base_delay": (0.004, 0.015), # 🔧 增加延迟范围(原0.0003-0.0006太快)
|
||
"acceleration_curve": (1.3, 2.2), # 🔧 扩大曲线范围(原1.4-1.65)
|
||
"y_jitter_max": (1.0, 3.5), # 🔧 扩大Y轴抖动范围(原1.5-2.5)
|
||
},
|
||
# 滑动行为参数(🔧 2026-01-28 增加随机性)
|
||
"slide_behavior": {
|
||
"approach_offset_x": (-30, -15), # 🔧 扩大范围(原-25到-20)
|
||
"approach_offset_y": (8, 22), # 🔧 扩大范围(原12到18)
|
||
"approach_steps": (6, 12), # 🔧 扩大范围(原8-10)
|
||
"approach_pause": (0.03, 0.18), # 🔧 扩大范围
|
||
"precision_steps": (6, 12), # 🔧 扩大范围(原8-10)
|
||
"precision_pause": (0.05, 0.15), # 🔧 扩大范围
|
||
"skip_hover_rate": 0.25, # 🔧 增加跳过率,增加随机性
|
||
"pre_down_pause": (0.08, 0.20), # 🔧 扩大范围
|
||
"post_down_pause": (0.08, 0.20), # 🔧 扩大范围
|
||
"pre_up_pause": (0.02, 0.08), # 🔧 扩大范围
|
||
"post_up_pause": (0.01, 0.06), # 🔧 扩大范围
|
||
},
|
||
# 时间控制
|
||
"timing": {
|
||
"total_elapsed_time": (0.8, 2.0), # 🔧 扩大耗时范围(原0.9-1.55)
|
||
"page_wait": (0.05, 0.30), # 🔧 扩大等待范围
|
||
},
|
||
# 重试策略 - 🔧 2026-01-28 增加冷却时间
|
||
"retry": {
|
||
"perturbation_factor_increment": 0.12, # 🔧 增大扰动递增(原0.08)
|
||
"base_retry_delay": 1.5, # 🔧 增加基础延迟(原0.4)- 给服务器冷却时间
|
||
"retry_delay_increment": 1.0, # 🔧 增加延迟递增(原0.2)
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# 🎰 机器学习策略配置(探索-利用平衡)
|
||
# 🔧 2026-01-28 更新:扩大参数范围,增加随机性,降低被检测概率
|
||
# ============================================================================
|
||
ML_STRATEGY_CONFIG = {
|
||
# 🔧 2026-01-28:降低探索率,更多使用已验证有效的参数
|
||
"exploration_rate": 0.06, # 进一步降低探索率,优先复用已验证有效的参数
|
||
|
||
# 连续失败后切换慢速兜底的阈值基线
|
||
"force_explore_after_failures": 2, # 第3次尝试会进入慢速兜底
|
||
|
||
# 多策略模式配置 - 🔧 2026-01-28 扩大所有参数范围
|
||
"strategies": {
|
||
# 保守策略:较小超调,模拟谨慎用户
|
||
"conservative": {
|
||
"overshoot_ratio": (1.01, 1.06), # 1-6%超调
|
||
"steps": (28, 40), # 🔧 增加步数,更自然
|
||
"base_delay": (0.010, 0.020), # 🔧 增加延迟(10-20ms)
|
||
"acceleration_curve": (1.8, 2.4), # 更平滑的ease-out
|
||
"y_jitter_max": (0.8, 2.0), # 较小Y抖动
|
||
"weight": 0.08, # 🔧 从0.18降到0.08,历史成功率仅12%
|
||
},
|
||
# 标准策略:中等超调,模拟普通用户
|
||
"standard": {
|
||
"overshoot_ratio": (1.03, 1.10), # 3-10%超调
|
||
"steps": (22, 35), # 🔧 增加步数范围
|
||
"base_delay": (0.006, 0.015), # 6-15ms延迟
|
||
"acceleration_curve": (1.5, 2.1),
|
||
"y_jitter_max": (1.2, 2.8),
|
||
"weight": 0.57, # 🔧 从0.47提高到0.57,吸收conservative释放的权重
|
||
},
|
||
# 激进策略:较大超调,模拟快速用户
|
||
"aggressive": {
|
||
"overshoot_ratio": (1.06, 1.15), # 6-15%超调
|
||
"steps": (18, 30),
|
||
"base_delay": (0.004, 0.012), # 4-12ms延迟
|
||
"acceleration_curve": (1.3, 1.9), # 更陡的加速曲线
|
||
"y_jitter_max": (1.5, 3.2),
|
||
"weight": 0.35,
|
||
},
|
||
},
|
||
|
||
# 参数抖动范围 - 🔧 增加抖动幅度
|
||
"param_jitter": {
|
||
"overshoot_ratio_jitter": 0.05, # 🔧 从±3%增加到±5%
|
||
"delay_jitter": 0.20, # 🔧 从±12%增加到±20%
|
||
"curve_jitter": 0.12, # 🔧 从±8%增加到±12%
|
||
},
|
||
|
||
# 学习参数边界 - 🔧 扩大边界
|
||
"learning_bounds": {
|
||
"max_overshoot_ratio": 1.18, # 🔧 从1.15增加到1.18
|
||
"min_overshoot_ratio": 1.01,
|
||
"max_y_jitter": 3.5, # 🔧 从3.0增加到3.5
|
||
"min_y_jitter": 0.8, # 🔧 从1.0降到0.8
|
||
"max_acceleration_curve": 2.6, # 🔧 从2.5增加到2.6
|
||
"min_acceleration_curve": 1.2, # 🔧 从1.3降到1.2
|
||
},
|
||
|
||
# 🔄 自动权重调整配置
|
||
"auto_weight_adjustment": {
|
||
"enabled": True,
|
||
"min_samples": 3, # 🔧 从5降到3,更快开始调整
|
||
"smoothing_factor": 0.4, # 🔧 从0.3增加到0.4,更快响应
|
||
"min_weight": 0.05, # 🔧 从0.15降到0.05,允许低效策略被进一步压低
|
||
"max_weight": 0.55, # 🔧 从0.60降到0.55
|
||
},
|
||
|
||
# 🧹 自动数据清理配置
|
||
"auto_data_cleanup": {
|
||
"enabled": True,
|
||
"min_success_rate": 0.20, # 🔧 从0.15增加到0.20
|
||
"check_window": 15, # 🔧 从20降到15,更快响应
|
||
"cleanup_threshold": 0.12, # 🔧 从0.10增加到0.12
|
||
"max_history_age_days": 5, # 🔧 从7天降到5天,更新更快
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# 🤖 自适应策略管理器(自动调整权重+自动清理数据)
|
||
# ============================================================================
|
||
class AdaptiveStrategyManager:
|
||
"""自适应策略管理器 - 基于多臂老虎机算法动态调整策略权重"""
|
||
_instance = None
|
||
_lock = threading.Lock()
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
with cls._lock:
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
cls._instance._initialized = False
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
if not self._initialized:
|
||
self.stats_lock = threading.Lock()
|
||
# 策略统计:{strategy_name: {"success": count, "fail": count, "total": count}}
|
||
self.strategy_stats = {
|
||
"conservative": {"success": 0, "fail": 0, "total": 0},
|
||
"standard": {"success": 0, "fail": 0, "total": 0},
|
||
"aggressive": {"success": 0, "fail": 0, "total": 0},
|
||
"learned_with_jitter": {"success": 0, "fail": 0, "total": 0},
|
||
}
|
||
# 动态权重(与 ML_STRATEGY_CONFIG 初始权重一致)
|
||
self.dynamic_weights = {
|
||
"conservative": 0.08,
|
||
"standard": 0.57,
|
||
"aggressive": 0.35,
|
||
}
|
||
# 统计文件路径
|
||
self.stats_file = "trajectory_history/adaptive_strategy_stats.json"
|
||
# 加载历史统计
|
||
self._load_stats()
|
||
self._initialized = True
|
||
logger.info("🤖 自适应策略管理器初始化完成")
|
||
|
||
# 已废弃的策略名称,加载时自动清理
|
||
_DEPRECATED_STRATEGIES = {"slow_fallback"}
|
||
|
||
def _load_stats(self):
|
||
"""加载历史统计数据"""
|
||
try:
|
||
if os.path.exists(self.stats_file):
|
||
with open(self.stats_file, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
self.strategy_stats.update(data.get("strategy_stats", {}))
|
||
self.dynamic_weights.update(data.get("dynamic_weights", {}))
|
||
# 清理已废弃策略的残留数据
|
||
cleaned = False
|
||
for dep in self._DEPRECATED_STRATEGIES:
|
||
if dep in self.strategy_stats:
|
||
del self.strategy_stats[dep]
|
||
cleaned = True
|
||
if dep in self.dynamic_weights:
|
||
del self.dynamic_weights[dep]
|
||
cleaned = True
|
||
if cleaned:
|
||
logger.info(f"🤖 已清理废弃策略统计: {self._DEPRECATED_STRATEGIES}")
|
||
self._save_stats()
|
||
logger.info(f"🤖 加载历史策略统计: {self.stats_file}")
|
||
except Exception as e:
|
||
logger.warning(f"🤖 加载策略统计失败: {e}")
|
||
|
||
def _save_stats(self):
|
||
"""保存统计数据"""
|
||
try:
|
||
os.makedirs(os.path.dirname(self.stats_file), exist_ok=True)
|
||
with open(self.stats_file, 'w', encoding='utf-8') as f:
|
||
json.dump({
|
||
"strategy_stats": self.strategy_stats,
|
||
"dynamic_weights": self.dynamic_weights,
|
||
"last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}, f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"🤖 保存策略统计失败: {e}")
|
||
|
||
def record_result(self, strategy_name: str, success: bool):
|
||
"""记录策略使用结果
|
||
|
||
Args:
|
||
strategy_name: 策略名称 (conservative/standard/aggressive/learned_with_jitter)
|
||
success: 是否成功
|
||
"""
|
||
with self.stats_lock:
|
||
if strategy_name not in self.strategy_stats:
|
||
self.strategy_stats[strategy_name] = {"success": 0, "fail": 0, "total": 0}
|
||
|
||
stats = self.strategy_stats[strategy_name]
|
||
stats["total"] += 1
|
||
if success:
|
||
stats["success"] += 1
|
||
else:
|
||
stats["fail"] += 1
|
||
|
||
# 计算成功率
|
||
success_rate = stats["success"] / stats["total"] if stats["total"] > 0 else 0
|
||
|
||
logger.info(f"🤖 策略[{strategy_name}]记录: {'✅成功' if success else '❌失败'} "
|
||
f"(成功率: {success_rate*100:.1f}%, 总计: {stats['total']}次)")
|
||
|
||
# 自动调整权重
|
||
self._auto_adjust_weights()
|
||
|
||
# 保存统计
|
||
self._save_stats()
|
||
|
||
def _auto_adjust_weights(self):
|
||
"""自动调整策略权重(基于成功率)"""
|
||
config = ML_STRATEGY_CONFIG.get("auto_weight_adjustment", {})
|
||
if not config.get("enabled", True):
|
||
return
|
||
|
||
min_samples = config.get("min_samples", 5)
|
||
smoothing = config.get("smoothing_factor", 0.3)
|
||
min_weight = config.get("min_weight", 0.10)
|
||
max_weight = config.get("max_weight", 0.60)
|
||
|
||
# 只调整三个主要策略的权重
|
||
main_strategies = ["conservative", "standard", "aggressive"]
|
||
|
||
# 检查是否有足够的样本
|
||
total_samples = sum(
|
||
self.strategy_stats.get(s, {}).get("total", 0)
|
||
for s in main_strategies
|
||
)
|
||
|
||
if total_samples < min_samples * len(main_strategies):
|
||
return # 样本不足,不调整
|
||
|
||
# 计算每个策略的成功率
|
||
success_rates = {}
|
||
for strategy in main_strategies:
|
||
stats = self.strategy_stats.get(strategy, {})
|
||
total = stats.get("total", 0)
|
||
success = stats.get("success", 0)
|
||
if total >= min_samples:
|
||
success_rates[strategy] = success / total
|
||
else:
|
||
success_rates[strategy] = 0.33 # 默认成功率
|
||
|
||
# 计算新权重(基于成功率的softmax)
|
||
total_rate = sum(success_rates.values())
|
||
if total_rate > 0:
|
||
new_weights = {}
|
||
for strategy in main_strategies:
|
||
# 使用指数加权,成功率高的策略权重更高
|
||
raw_weight = success_rates[strategy] / total_rate
|
||
# 应用边界限制
|
||
new_weights[strategy] = max(min_weight, min(max_weight, raw_weight))
|
||
|
||
# 归一化确保权重和为1
|
||
weight_sum = sum(new_weights.values())
|
||
for strategy in main_strategies:
|
||
new_weights[strategy] /= weight_sum
|
||
|
||
# 平滑更新(避免剧烈变化)
|
||
for strategy in main_strategies:
|
||
old_weight = self.dynamic_weights.get(strategy, 0.33)
|
||
self.dynamic_weights[strategy] = (
|
||
old_weight * (1 - smoothing) + new_weights[strategy] * smoothing
|
||
)
|
||
|
||
logger.info(f"🤖 自动调整权重: "
|
||
f"保守={self.dynamic_weights['conservative']*100:.1f}%, "
|
||
f"标准={self.dynamic_weights['standard']*100:.1f}%, "
|
||
f"激进={self.dynamic_weights['aggressive']*100:.1f}%")
|
||
|
||
def get_dynamic_weights(self, attempt: int = 1) -> dict:
|
||
"""获取动态权重(结合尝试次数调整)
|
||
|
||
Args:
|
||
attempt: 当前尝试次数
|
||
|
||
Returns:
|
||
dict: {strategy_name: weight}
|
||
"""
|
||
with self.stats_lock:
|
||
# 基础权重
|
||
weights = self.dynamic_weights.copy()
|
||
|
||
# 固定给低成功率策略一个更低上限,避免无头链路过度分配到保守分支
|
||
weights["conservative"] = min(0.22, max(0.12, weights.get("conservative", 0.18)))
|
||
weights["standard"] = max(0.40, weights.get("standard", 0.47))
|
||
weights["aggressive"] = max(0.28, weights.get("aggressive", 0.35))
|
||
|
||
total = sum(weights.values())
|
||
if total > 0:
|
||
for strategy in list(weights.keys()):
|
||
weights[strategy] = weights[strategy] / total
|
||
|
||
# 根据尝试次数微调
|
||
if attempt >= 3:
|
||
# 第3次尝试优先走更果断的轨迹,不再依赖低收益慢速分支
|
||
weights["aggressive"] = min(0.55, weights.get("aggressive", 0.35) + 0.12)
|
||
# 相应减少其他策略
|
||
total_other = weights.get("conservative", 0.18) + weights.get("standard", 0.47)
|
||
if total_other > 0:
|
||
factor = (1 - weights["aggressive"]) / total_other
|
||
weights["conservative"] = weights.get("conservative", 0.18) * factor
|
||
weights["standard"] = weights.get("standard", 0.47) * factor
|
||
|
||
return weights
|
||
|
||
def check_and_cleanup_history(self, user_id: str, history_file: str) -> bool:
|
||
"""检查并自动清理历史数据
|
||
|
||
Args:
|
||
user_id: 用户ID
|
||
history_file: 历史文件路径
|
||
|
||
Returns:
|
||
bool: 是否执行了清理
|
||
"""
|
||
config = ML_STRATEGY_CONFIG.get("auto_data_cleanup", {})
|
||
if not config.get("enabled", True):
|
||
return False
|
||
|
||
min_success_rate = config.get("min_success_rate", 0.15)
|
||
check_window = config.get("check_window", 20)
|
||
cleanup_threshold = config.get("cleanup_threshold", 0.10)
|
||
max_age_days = config.get("max_history_age_days", 7)
|
||
|
||
try:
|
||
if not os.path.exists(history_file):
|
||
return False
|
||
|
||
with open(history_file, 'r', encoding='utf-8') as f:
|
||
history = json.load(f)
|
||
|
||
if len(history) < check_window:
|
||
return False # 数据不足,不检查
|
||
|
||
# 检查1:最近N条记录的成功率
|
||
recent_records = history[-check_window:]
|
||
# 注意:历史记录都是成功的,所以这里检查的是整体趋势
|
||
# 我们通过检查记录的时间分布来判断
|
||
|
||
# 检查2:清理过期数据
|
||
current_time = time.time()
|
||
max_age_seconds = max_age_days * 24 * 3600
|
||
|
||
# 过滤掉过期的记录
|
||
valid_records = [
|
||
r for r in history
|
||
if current_time - r.get("timestamp", 0) < max_age_seconds
|
||
]
|
||
|
||
if len(valid_records) < len(history):
|
||
# 有过期记录,执行清理
|
||
removed_count = len(history) - len(valid_records)
|
||
logger.warning(f"🧹 【{user_id}】自动清理{removed_count}条过期历史记录"
|
||
f"(超过{max_age_days}天)")
|
||
|
||
with open(history_file, 'w', encoding='utf-8') as f:
|
||
json.dump(valid_records, f, indent=2, ensure_ascii=False)
|
||
|
||
return True
|
||
|
||
# 检查3:如果历史记录中的参数明显偏离最优范围,清理部分记录
|
||
bounds = ML_STRATEGY_CONFIG.get("learning_bounds", {})
|
||
max_overshoot = bounds.get("max_overshoot_ratio", 2.12)
|
||
|
||
# 检查最近记录的超调比例
|
||
recent_overshoots = [
|
||
r.get("overshoot_ratio", 0)
|
||
for r in recent_records
|
||
if r.get("overshoot_ratio", 0) > 0
|
||
]
|
||
|
||
if recent_overshoots:
|
||
avg_overshoot = sum(recent_overshoots) / len(recent_overshoots)
|
||
if avg_overshoot > max_overshoot:
|
||
# 超调比例偏高,清理一半的历史记录
|
||
logger.warning(f"🧹 【{user_id}】检测到历史数据超调比例偏高"
|
||
f"(平均{avg_overshoot:.2f}),清理一半历史记录")
|
||
|
||
# 保留较新的一半记录
|
||
half_count = len(history) // 2
|
||
new_history = history[half_count:]
|
||
|
||
with open(history_file, 'w', encoding='utf-8') as f:
|
||
json.dump(new_history, f, indent=2, ensure_ascii=False)
|
||
|
||
return True
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"🧹 检查历史数据时出错: {e}")
|
||
return False
|
||
|
||
def get_stats_summary(self) -> str:
|
||
"""获取统计摘要"""
|
||
with self.stats_lock:
|
||
lines = ["=" * 60]
|
||
lines.append("🤖 自适应策略统计")
|
||
lines.append("=" * 60)
|
||
|
||
for strategy, stats in self.strategy_stats.items():
|
||
total = stats.get("total", 0)
|
||
success = stats.get("success", 0)
|
||
rate = success / total * 100 if total > 0 else 0
|
||
weight = self.dynamic_weights.get(strategy, 0) * 100
|
||
lines.append(f"{strategy:25} | 成功率: {rate:5.1f}% | "
|
||
f"样本: {total:4} | 权重: {weight:5.1f}%")
|
||
|
||
lines.append("=" * 60)
|
||
return "\n".join(lines)
|
||
|
||
|
||
# 全局自适应策略管理器实例
|
||
adaptive_strategy_manager = AdaptiveStrategyManager()
|
||
|
||
# 使用loguru日志库,与主程序保持一致
|
||
|
||
# 全局并发控制
|
||
class SliderConcurrencyManager:
|
||
"""滑块验证并发管理器"""
|
||
_instance = None
|
||
_lock = threading.Lock()
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
with cls._lock:
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
cls._instance._initialized = False
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
if not self._initialized:
|
||
self.max_concurrent = SLIDER_MAX_CONCURRENT # 从配置文件读取最大并发数
|
||
self.wait_timeout = SLIDER_WAIT_TIMEOUT # 从配置文件读取等待超时时间
|
||
self.active_instances = {} # 活跃实例
|
||
self.waiting_queue = [] # 等待队列
|
||
self.instance_lock = threading.Lock()
|
||
self._initialized = True
|
||
logger.info(f"滑块验证并发管理器初始化: 最大并发数={self.max_concurrent}, 等待超时={self.wait_timeout}秒")
|
||
|
||
def can_start_instance(self, user_id: str) -> bool:
|
||
"""检查是否可以启动新实例"""
|
||
with self.instance_lock:
|
||
return self._can_start_locked(user_id)
|
||
|
||
def _find_same_account_active_locked(self, user_id: str):
|
||
"""查找同账号的活跃实例,避免同账号并发滑块互相踩踏"""
|
||
pure_user_id = self._extract_pure_user_id(user_id)
|
||
for active_user_id in self.active_instances:
|
||
if self._extract_pure_user_id(active_user_id) == pure_user_id:
|
||
return active_user_id
|
||
return None
|
||
|
||
def _can_start_locked(self, user_id: str) -> bool:
|
||
"""在持锁状态下检查是否允许启动实例"""
|
||
same_account_active = self._find_same_account_active_locked(user_id)
|
||
return len(self.active_instances) < self.max_concurrent and same_account_active is None
|
||
|
||
def wait_for_slot(self, user_id: str, timeout: int = None) -> bool:
|
||
"""等待可用槽位"""
|
||
if timeout is None:
|
||
timeout = self.wait_timeout
|
||
|
||
start_time = time.time()
|
||
|
||
while time.time() - start_time < timeout:
|
||
with self.instance_lock:
|
||
same_account_active = self._find_same_account_active_locked(user_id)
|
||
if len(self.active_instances) < self.max_concurrent and same_account_active is None:
|
||
return True
|
||
|
||
# 检查是否在等待队列中
|
||
with self.instance_lock:
|
||
if user_id not in self.waiting_queue:
|
||
self.waiting_queue.append(user_id)
|
||
# 提取纯用户ID用于日志显示
|
||
pure_user_id = self._extract_pure_user_id(user_id)
|
||
same_account_active = self._find_same_account_active_locked(user_id)
|
||
if same_account_active:
|
||
logger.warning(
|
||
f"【{pure_user_id}】同账号滑块任务正在执行({same_account_active}),进入等待队列,当前队列长度: {len(self.waiting_queue)}"
|
||
)
|
||
else:
|
||
logger.info(f"【{pure_user_id}】进入等待队列,当前队列长度: {len(self.waiting_queue)}")
|
||
|
||
# 等待1秒后重试
|
||
time.sleep(1)
|
||
|
||
# 超时后从队列中移除
|
||
with self.instance_lock:
|
||
if user_id in self.waiting_queue:
|
||
self.waiting_queue.remove(user_id)
|
||
# 提取纯用户ID用于日志显示
|
||
pure_user_id = self._extract_pure_user_id(user_id)
|
||
logger.warning(f"【{pure_user_id}】等待超时,从队列中移除")
|
||
|
||
return False
|
||
|
||
def register_instance(self, user_id: str, instance):
|
||
"""注册实例"""
|
||
with self.instance_lock:
|
||
if not self._can_start_locked(user_id):
|
||
return False
|
||
self.active_instances[user_id] = {
|
||
'instance': instance,
|
||
'start_time': time.time()
|
||
}
|
||
# 从等待队列中移除
|
||
if user_id in self.waiting_queue:
|
||
self.waiting_queue.remove(user_id)
|
||
return True
|
||
|
||
def unregister_instance(self, user_id: str):
|
||
"""注销实例"""
|
||
with self.instance_lock:
|
||
if user_id in self.active_instances:
|
||
del self.active_instances[user_id]
|
||
# 提取纯用户ID用于日志显示
|
||
pure_user_id = self._extract_pure_user_id(user_id)
|
||
logger.info(f"【{pure_user_id}】实例已注销,当前活跃: {len(self.active_instances)}")
|
||
|
||
def _extract_pure_user_id(self, user_id: str) -> str:
|
||
"""提取纯用户ID(移除时间戳部分)"""
|
||
if '_' in user_id:
|
||
# 检查最后一部分是否为数字(时间戳)
|
||
parts = user_id.split('_')
|
||
if len(parts) >= 2 and parts[-1].isdigit() and len(parts[-1]) >= 10:
|
||
# 最后一部分是时间戳,移除它
|
||
return '_'.join(parts[:-1])
|
||
else:
|
||
# 不是时间戳格式,使用原始ID
|
||
return user_id
|
||
else:
|
||
# 没有下划线,直接使用
|
||
return user_id
|
||
|
||
def get_stats(self):
|
||
"""获取统计信息"""
|
||
with self.instance_lock:
|
||
return {
|
||
'active_count': len(self.active_instances),
|
||
'max_concurrent': self.max_concurrent,
|
||
'available_slots': self.max_concurrent - len(self.active_instances),
|
||
'queue_length': len(self.waiting_queue),
|
||
'waiting_users': self.waiting_queue.copy()
|
||
}
|
||
|
||
# 全局并发管理器实例
|
||
concurrency_manager = SliderConcurrencyManager()
|
||
|
||
# 策略统计管理器
|
||
class RetryStrategyStats:
|
||
"""重试策略成功率统计管理器"""
|
||
_instance = None
|
||
_lock = threading.Lock()
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
with cls._lock:
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
cls._instance._initialized = False
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
if not self._initialized:
|
||
self.stats_lock = threading.Lock()
|
||
self.strategy_stats = {
|
||
'attempt_1_default': {'total': 0, 'success': 0, 'fail': 0},
|
||
'attempt_2_cautious': {'total': 0, 'success': 0, 'fail': 0},
|
||
'attempt_3_fast': {'total': 0, 'success': 0, 'fail': 0},
|
||
'attempt_3_slow': {'total': 0, 'success': 0, 'fail': 0},
|
||
}
|
||
self.stats_file = 'trajectory_history/strategy_stats.json'
|
||
self._load_stats()
|
||
self._initialized = True
|
||
logger.info("策略统计管理器初始化完成")
|
||
|
||
def _load_stats(self):
|
||
"""从文件加载统计数据"""
|
||
try:
|
||
if os.path.exists(self.stats_file):
|
||
with open(self.stats_file, 'r', encoding='utf-8') as f:
|
||
loaded_stats = json.load(f)
|
||
self.strategy_stats.update(loaded_stats)
|
||
logger.info(f"已加载历史策略统计数据: {self.stats_file}")
|
||
except Exception as e:
|
||
logger.warning(f"加载策略统计数据失败: {e}")
|
||
|
||
def _save_stats(self):
|
||
"""保存统计数据到文件"""
|
||
try:
|
||
os.makedirs(os.path.dirname(self.stats_file), exist_ok=True)
|
||
with open(self.stats_file, 'w', encoding='utf-8') as f:
|
||
json.dump(self.strategy_stats, f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"保存策略统计数据失败: {e}")
|
||
|
||
def record_attempt(self, attempt: int, strategy_type: str, success: bool):
|
||
"""记录一次尝试结果
|
||
|
||
Args:
|
||
attempt: 尝试次数 (1, 2, 3)
|
||
strategy_type: 策略类型 ('default', 'cautious', 'fast', 'slow')
|
||
success: 是否成功
|
||
"""
|
||
with self.stats_lock:
|
||
key = f'attempt_{attempt}_{strategy_type}'
|
||
if key not in self.strategy_stats:
|
||
self.strategy_stats[key] = {'total': 0, 'success': 0, 'fail': 0}
|
||
|
||
self.strategy_stats[key]['total'] += 1
|
||
if success:
|
||
self.strategy_stats[key]['success'] += 1
|
||
else:
|
||
self.strategy_stats[key]['fail'] += 1
|
||
|
||
# 每次记录后保存
|
||
self._save_stats()
|
||
|
||
def get_stats_summary(self):
|
||
"""获取统计摘要"""
|
||
with self.stats_lock:
|
||
summary = {}
|
||
for key, stats in self.strategy_stats.items():
|
||
if stats['total'] > 0:
|
||
success_rate = (stats['success'] / stats['total']) * 100
|
||
summary[key] = {
|
||
'total': stats['total'],
|
||
'success': stats['success'],
|
||
'fail': stats['fail'],
|
||
'success_rate': f"{success_rate:.2f}%"
|
||
}
|
||
return summary
|
||
|
||
def log_summary(self):
|
||
"""输出统计摘要到日志"""
|
||
summary = self.get_stats_summary()
|
||
if summary:
|
||
logger.info("=" * 60)
|
||
logger.info("📊 重试策略成功率统计")
|
||
logger.info("=" * 60)
|
||
for key, stats in summary.items():
|
||
logger.info(f"{key:25s} | 总计:{stats['total']:4d} | 成功:{stats['success']:4d} | 失败:{stats['fail']:4d} | 成功率:{stats['success_rate']}")
|
||
logger.info("=" * 60)
|
||
|
||
# 全局策略统计实例
|
||
strategy_stats = RetryStrategyStats()
|
||
|
||
class XianyuSliderStealth:
|
||
|
||
def __init__(self, user_id: str = "default", enable_learning: bool = True, headless: bool = True):
|
||
self.user_id = user_id
|
||
self.enable_learning = enable_learning
|
||
self.headless = headless # 是否使用无头模式
|
||
self.browser = None
|
||
self.page = None
|
||
self.context = None
|
||
self.playwright = None
|
||
|
||
# 提取纯用户ID(移除时间戳部分)
|
||
self.pure_user_id = concurrency_manager._extract_pure_user_id(user_id)
|
||
|
||
# 检查日期限制
|
||
if not self._check_date_validity():
|
||
raise Exception(f"【{self.pure_user_id}】日期验证失败,功能已过期")
|
||
|
||
# 为每个实例创建独立的临时目录
|
||
self.temp_dir = tempfile.mkdtemp(prefix=f"slider_{user_id}_")
|
||
logger.debug(f"【{self.pure_user_id}】创建临时目录: {self.temp_dir}")
|
||
|
||
# 等待可用槽位(排队机制)
|
||
logger.info(f"【{self.pure_user_id}】检查并发限制...")
|
||
if not concurrency_manager.wait_for_slot(self.user_id):
|
||
stats = concurrency_manager.get_stats()
|
||
logger.error(f"【{self.pure_user_id}】等待槽位超时,当前活跃: {stats['active_count']}/{stats['max_concurrent']}")
|
||
raise Exception(f"滑块验证等待槽位超时,请稍后重试")
|
||
|
||
# 注册实例
|
||
if not concurrency_manager.register_instance(self.user_id, self):
|
||
raise Exception(f"【{self.pure_user_id}】同账号已有滑块任务正在执行,请稍后重试")
|
||
stats = concurrency_manager.get_stats()
|
||
logger.info(f"【{self.pure_user_id}】实例已注册,当前并发: {stats['active_count']}/{stats['max_concurrent']}")
|
||
|
||
# 轨迹学习相关属性
|
||
|
||
self.success_history_file = f"trajectory_history/{self.pure_user_id}_success.json"
|
||
self.failure_history_file = f"trajectory_history/{self.pure_user_id}_failure.json"
|
||
self.browser_profile_file = f"trajectory_history/{self.pure_user_id}_browser_profile.json"
|
||
self.last_verification_feedback = {}
|
||
self.last_login_error = ""
|
||
self._slider_refresh_mode = False
|
||
self.risk_session_id = None
|
||
self.risk_trigger_scene = None
|
||
self.browser_features = {}
|
||
self.browser_identity = {}
|
||
self.profile_id = "unassigned"
|
||
self.trajectory_params = {
|
||
"total_steps_range": [5, 8], # 极速:5-8步(超快滑动)
|
||
"base_delay_range": [0.0002, 0.0005], # 极速:0.2-0.5ms延迟
|
||
"jitter_x_range": [0, 1], # 极小抖动
|
||
"jitter_y_range": [0, 1], # 极小抖动
|
||
"slow_factor_range": [10, 15], # 极快加速因子
|
||
"acceleration_phase": 1.0, # 全程加速
|
||
"fast_phase": 1.0, # 无慢速
|
||
"slow_start_ratio_base": 2.0, # 确保超调100%
|
||
"completion_usage_rate": 0.05, # 极少补全使用率
|
||
"avg_completion_steps": 1.0, # 极少补全步数
|
||
"trajectory_length_stats": [],
|
||
"learning_enabled": False
|
||
}
|
||
|
||
# 保存最后一次使用的轨迹参数(用于分析优化)
|
||
self.last_trajectory_params = {}
|
||
|
||
def _fail_login(self, message: str):
|
||
self.last_login_error = message
|
||
return None
|
||
|
||
def _build_risk_event_meta(self, verification_url: str = None, extra: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||
payload: Dict[str, Any] = {}
|
||
trigger_scene = getattr(self, 'risk_trigger_scene', None)
|
||
if trigger_scene:
|
||
payload['trigger_scene'] = trigger_scene
|
||
|
||
text = str(verification_url or '').strip()
|
||
if text:
|
||
try:
|
||
parsed = urlparse(text)
|
||
if parsed.scheme or parsed.netloc:
|
||
if parsed.netloc:
|
||
payload['verification_host'] = parsed.netloc
|
||
if parsed.path:
|
||
payload['verification_path'] = parsed.path
|
||
query = parse_qs(parsed.query or '')
|
||
x5secdata = query.get('x5secdata', [None])[0]
|
||
if x5secdata:
|
||
payload['verification_token_hash'] = hashlib.sha256(x5secdata.encode('utf-8')).hexdigest()[:16]
|
||
action = query.get('action', [None])[0]
|
||
if action:
|
||
payload['verification_action'] = action
|
||
else:
|
||
payload['verification_source'] = text[:120]
|
||
except Exception:
|
||
payload['verification_source'] = text[:120]
|
||
|
||
if isinstance(extra, dict):
|
||
payload.update({key: value for key, value in extra.items() if value is not None})
|
||
return payload or None
|
||
|
||
def _resolve_slider_risk_context(self) -> Tuple[str, str]:
|
||
trigger_scene = getattr(self, 'risk_trigger_scene', None)
|
||
if not trigger_scene:
|
||
trigger_scene = 'manual_password_refresh' if getattr(self, '_slider_refresh_mode', False) else 'password_login'
|
||
|
||
if trigger_scene == 'manual_password_refresh':
|
||
flow_label = '手动刷新Cookie'
|
||
elif trigger_scene == 'password_login':
|
||
flow_label = '账号密码登录'
|
||
elif trigger_scene == 'auto_cookie_refresh':
|
||
flow_label = '自动Cookie刷新'
|
||
else:
|
||
flow_label = '密码登录流程'
|
||
|
||
return trigger_scene, flow_label
|
||
|
||
def _start_password_login_slider_risk_log(self, verification_url: str = None,
|
||
detection_phase: str = None) -> Optional[Dict[str, Any]]:
|
||
try:
|
||
from db_manager import db_manager
|
||
|
||
trigger_scene, flow_label = self._resolve_slider_risk_context()
|
||
event_meta = self._build_risk_event_meta(
|
||
verification_url=verification_url,
|
||
extra={
|
||
'account_id': self.pure_user_id,
|
||
'source': 'password_login_flow',
|
||
'refresh_mode': bool(getattr(self, '_slider_refresh_mode', False)),
|
||
'detection_phase': detection_phase,
|
||
},
|
||
)
|
||
log_id = db_manager.add_risk_control_log(
|
||
cookie_id=self.pure_user_id,
|
||
event_type='slider_captcha',
|
||
session_id=getattr(self, 'risk_session_id', None),
|
||
trigger_scene=trigger_scene,
|
||
result_code='password_login_slider_detected',
|
||
event_description=f'{flow_label}检测到滑块验证',
|
||
event_meta=event_meta,
|
||
processing_status='processing',
|
||
error_message='检测到滑块验证,正在自动处理',
|
||
)
|
||
if log_id:
|
||
logger.info(f"【{self.pure_user_id}】已记录密码登录滑块风控日志: {log_id}")
|
||
return {
|
||
'log_id': log_id,
|
||
'started_at': time.time(),
|
||
'verification_url': verification_url,
|
||
'event_meta': event_meta,
|
||
'trigger_scene': trigger_scene,
|
||
'flow_label': flow_label,
|
||
}
|
||
except Exception as log_err:
|
||
logger.warning(f"【{self.pure_user_id}】记录密码登录滑块风控日志失败: {log_err}")
|
||
return None
|
||
|
||
def _finish_password_login_slider_risk_log(self, slider_risk_log: Optional[Dict[str, Any]], *,
|
||
success: bool, verification_url: str = None,
|
||
processing_result: str = None, error_message: str = None,
|
||
extra_meta: Optional[Dict[str, Any]] = None):
|
||
if not slider_risk_log or not slider_risk_log.get('log_id'):
|
||
return
|
||
|
||
try:
|
||
from db_manager import db_manager
|
||
|
||
trigger_scene = slider_risk_log.get('trigger_scene') or self._resolve_slider_risk_context()[0]
|
||
flow_label = slider_risk_log.get('flow_label') or self._resolve_slider_risk_context()[1]
|
||
final_verification_url = verification_url or slider_risk_log.get('verification_url')
|
||
merged_event_meta = dict(slider_risk_log.get('event_meta') or {})
|
||
if isinstance(extra_meta, dict):
|
||
merged_event_meta.update({key: value for key, value in extra_meta.items() if value is not None})
|
||
|
||
final_event_meta = self._build_risk_event_meta(
|
||
verification_url=final_verification_url,
|
||
extra=merged_event_meta,
|
||
)
|
||
|
||
result_code = 'password_login_slider_success' if success else 'password_login_slider_failed'
|
||
if success:
|
||
final_processing_result = processing_result or f'{flow_label}中的滑块验证成功'
|
||
final_error_message = None
|
||
event_description = f'{flow_label}中的滑块验证已自动处理成功'
|
||
else:
|
||
final_processing_result = processing_result or f'{flow_label}中的滑块验证失败'
|
||
final_error_message = error_message or '滑块验证失败,请稍后重试'
|
||
event_description = f'{flow_label}中的滑块验证自动处理失败'
|
||
|
||
duration_ms = None
|
||
started_at = slider_risk_log.get('started_at')
|
||
if started_at:
|
||
duration_ms = max(0, int((time.time() - float(started_at)) * 1000))
|
||
|
||
db_manager.update_risk_control_log(
|
||
log_id=slider_risk_log['log_id'],
|
||
event_description=event_description,
|
||
processing_result=final_processing_result,
|
||
processing_status='success' if success else 'failed',
|
||
error_message=final_error_message,
|
||
session_id=getattr(self, 'risk_session_id', None),
|
||
trigger_scene=trigger_scene,
|
||
result_code=result_code,
|
||
event_meta=final_event_meta,
|
||
duration_ms=duration_ms,
|
||
)
|
||
except Exception as log_err:
|
||
logger.warning(f"【{self.pure_user_id}】更新密码登录滑块风控日志失败: {log_err}")
|
||
|
||
def _get_slider_failure_message(self, default_message: str) -> str:
|
||
feedback = self.last_verification_feedback or {}
|
||
feedback_message = str(feedback.get("message") or "").strip()
|
||
if feedback_message:
|
||
return f"滑块验证失败:{feedback_message}"
|
||
return default_message
|
||
|
||
def _capture_verification_screenshot(self, page, frame=None, iframe_selector: Optional[str] = None) -> Optional[str]:
|
||
"""截取验证页面截图,多种方式逐级回退"""
|
||
try:
|
||
import glob
|
||
|
||
screenshots_dir = "static/uploads/images"
|
||
os.makedirs(screenshots_dir, exist_ok=True)
|
||
|
||
# 清理旧截图
|
||
old_screenshots = glob.glob(os.path.join(screenshots_dir, f"face_verify_{self.pure_user_id}_*.jpg"))
|
||
old_screenshots += glob.glob(os.path.join(screenshots_dir, f"face_verify_{self.pure_user_id}_*.png"))
|
||
for old_file in old_screenshots:
|
||
try:
|
||
os.remove(old_file)
|
||
except Exception:
|
||
pass
|
||
|
||
# 等待验证页面渲染(无头模式下 iframe 渲染需要时间)
|
||
time.sleep(1.5)
|
||
|
||
screenshot_bytes = None
|
||
|
||
# 方式1:通过 frame.frame_element() 截取 iframe 元素
|
||
if frame is not None and screenshot_bytes is None:
|
||
try:
|
||
frame_element = frame.frame_element()
|
||
if frame_element:
|
||
screenshot_bytes = frame_element.screenshot(timeout=5000)
|
||
logger.info(f"【{self.pure_user_id}】方式1: 截取验证iframe元素成功")
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】方式1失败(frame_element): {e}")
|
||
|
||
# 方式2:通过 iframe 选择器截取
|
||
if screenshot_bytes is None and iframe_selector:
|
||
try:
|
||
iframe_element = page.query_selector(iframe_selector)
|
||
if iframe_element:
|
||
screenshot_bytes = iframe_element.screenshot(timeout=5000)
|
||
logger.info(f"【{self.pure_user_id}】方式2: 按选择器截取iframe成功")
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】方式2失败(selector): {e}")
|
||
|
||
# 方式3:通过 alibaba-login-box 选择器(常见的人脸验证 iframe)
|
||
if screenshot_bytes is None:
|
||
try:
|
||
login_box = page.query_selector('iframe#alibaba-login-box')
|
||
if login_box:
|
||
screenshot_bytes = login_box.screenshot(timeout=5000)
|
||
logger.info(f"【{self.pure_user_id}】方式3: 截取alibaba-login-box成功")
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】方式3失败(alibaba-login-box): {e}")
|
||
|
||
# 方式4:截取整个页面可见区域
|
||
if screenshot_bytes is None:
|
||
try:
|
||
screenshot_bytes = page.screenshot(full_page=False, timeout=10000)
|
||
logger.info(f"【{self.pure_user_id}】方式4: 截取整页面成功")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】方式4失败(full_page): {e}")
|
||
|
||
# 方式5:截取整个页面(含滚动区域)
|
||
if screenshot_bytes is None:
|
||
try:
|
||
screenshot_bytes = page.screenshot(full_page=True, timeout=10000)
|
||
logger.info(f"【{self.pure_user_id}】方式5: 截取完整页面成功")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】方式5失败(full_page=True): {e}")
|
||
|
||
if screenshot_bytes is None:
|
||
logger.error(f"【{self.pure_user_id}】所有截图方式均失败")
|
||
return None
|
||
|
||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
filename = f"face_verify_{self.pure_user_id}_{timestamp}.jpg"
|
||
file_path = os.path.join(screenshots_dir, filename)
|
||
|
||
with open(file_path, 'wb') as f:
|
||
f.write(screenshot_bytes)
|
||
|
||
screenshot_path = file_path.replace('\\', '/')
|
||
logger.info(f"【{self.pure_user_id}】✅ 验证截图已保存: {screenshot_path} ({len(screenshot_bytes)} bytes)")
|
||
return screenshot_path
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】截取验证截图时出错: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
return None
|
||
|
||
def _check_date_validity(self) -> bool:
|
||
"""检查日期有效性
|
||
|
||
Returns:
|
||
bool: 如果当前日期小于 2025-11-30 返回 True,否则返回 False
|
||
"""
|
||
try:
|
||
# 设置截止日期
|
||
expiry_date = datetime(2026, 12, 30)
|
||
current_date = datetime.now()
|
||
|
||
# 计算剩余天数
|
||
remaining_days = (expiry_date - current_date).days
|
||
|
||
if current_date < expiry_date:
|
||
logger.info(f"【{self.pure_user_id}】日期验证通过,剩余可用天数: {remaining_days} 天")
|
||
return True
|
||
else:
|
||
logger.error(f"【{self.pure_user_id}】日期验证失败!当前日期: {current_date.strftime('%Y-%m-%d')}, "
|
||
f"截止日期: {expiry_date.strftime('%Y-%m-%d')}, "
|
||
f"已过期: {abs(remaining_days)} 天")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】日期验证出错: {str(e)}")
|
||
return False
|
||
|
||
def _stable_number(self, namespace: str) -> int:
|
||
digest = hashlib.sha256(f"{self.pure_user_id}:{namespace}".encode("utf-8")).hexdigest()
|
||
return int(digest[:12], 16)
|
||
|
||
def _load_or_create_browser_identity(self, profile_count: int, language_count: int) -> Dict[str, Any]:
|
||
if self.browser_identity:
|
||
return self.browser_identity
|
||
|
||
identity = None
|
||
try:
|
||
if os.path.exists(self.browser_profile_file):
|
||
with open(self.browser_profile_file, "r", encoding="utf-8") as f:
|
||
loaded = json.load(f)
|
||
if isinstance(loaded, dict):
|
||
profile_index = int(loaded.get("profile_index", -1))
|
||
language_index = int(loaded.get("language_index", -1))
|
||
if 0 <= profile_index < profile_count and 0 <= language_index < language_count:
|
||
identity = loaded
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】加载浏览器画像失败,重新生成: {e}")
|
||
|
||
if identity is None:
|
||
identity = {
|
||
"profile_version": 2,
|
||
"profile_index": self._stable_number("browser_profile") % max(1, profile_count),
|
||
"language_index": self._stable_number("browser_language") % max(1, language_count),
|
||
"color_scheme": ["light", "no-preference"][self._stable_number("color_scheme") % 2],
|
||
"plugin_count": 4 + (self._stable_number("plugin_count") % 3),
|
||
"notification_permission": ["default", "denied"][self._stable_number("notification_permission") % 2],
|
||
"do_not_track": ["0", "1", "unspecified"][self._stable_number("do_not_track") % 3],
|
||
"battery_charging": bool(self._stable_number("battery_charging") % 2),
|
||
"battery_level": round(0.45 + (self._stable_number("battery_level") % 45) / 100, 2),
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
|
||
try:
|
||
os.makedirs(os.path.dirname(self.browser_profile_file), exist_ok=True)
|
||
with open(self.browser_profile_file, "w", encoding="utf-8") as f:
|
||
json.dump(identity, f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】保存浏览器画像失败: {e}")
|
||
|
||
self.browser_identity = identity
|
||
return identity
|
||
|
||
def _update_current_result_meta(
|
||
self,
|
||
status: str,
|
||
attempt: Optional[int] = None,
|
||
cookie_refresh_confirmed: Optional[bool] = None,
|
||
soft_success: bool = False,
|
||
note: Optional[str] = None,
|
||
):
|
||
if not hasattr(self, "current_trajectory_data"):
|
||
return
|
||
|
||
result = self.current_trajectory_data.setdefault("verification_result", {})
|
||
result.update({
|
||
"status": status,
|
||
"attempt": attempt,
|
||
"soft_success": soft_success,
|
||
"cookie_refresh_confirmed": cookie_refresh_confirmed,
|
||
"feedback": dict(self.last_verification_feedback or {}),
|
||
"profile_id": self.profile_id,
|
||
"headless": self.headless,
|
||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
})
|
||
if note:
|
||
result["note"] = note
|
||
|
||
def _should_accept_soft_success_without_cookie_refresh(
|
||
self,
|
||
current_cookies: Dict[str, str],
|
||
fallback_page=None,
|
||
) -> Tuple[bool, str]:
|
||
feedback = self.last_verification_feedback or {}
|
||
feedback_source = str(feedback.get("source") or "")
|
||
accepted_sources = {
|
||
"frame_detached",
|
||
"container_missing",
|
||
"page_changed",
|
||
"login_element_detected",
|
||
"context_login_confirmed",
|
||
}
|
||
|
||
monitor_page = fallback_page or self.page
|
||
if self.context:
|
||
monitor_page = self._select_monitor_page(self.context, monitor_page)
|
||
|
||
if not monitor_page:
|
||
return False, ""
|
||
|
||
try:
|
||
if self._check_login_success_by_element(monitor_page):
|
||
return True, "登录成功元素已出现,接受无 Cookie 变更的软成功"
|
||
except Exception:
|
||
pass
|
||
|
||
monitor_url = self._safe_page_url(monitor_page)
|
||
page_has_slider = self._page_has_slider(monitor_page)
|
||
page_looks_verify = self._page_looks_like_verification(monitor_page)
|
||
|
||
if feedback_source in accepted_sources and not page_has_slider and not page_looks_verify:
|
||
return True, f"页面已脱离验证态({feedback_source}),接受软成功"
|
||
|
||
if self._has_completed_login_cookies(current_cookies) and not page_has_slider:
|
||
if not page_looks_verify or self._is_logged_in_url(monitor_url):
|
||
return True, "关键登录 Cookie 已完整,且页面已脱离滑块态"
|
||
|
||
return False, ""
|
||
|
||
def _warmup_slider_context(self, target_url: Optional[str] = None):
|
||
if not self.page:
|
||
return
|
||
|
||
warmup_urls = [
|
||
"https://www.goofish.com",
|
||
"https://www.goofish.com/im",
|
||
]
|
||
|
||
for warmup_url in warmup_urls:
|
||
if target_url and warmup_url == target_url:
|
||
continue
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】预热访问: {warmup_url}")
|
||
self.page.goto(warmup_url, wait_until="domcontentloaded", timeout=15000)
|
||
time.sleep(random.uniform(0.8, 1.6))
|
||
self.page.mouse.move(random.randint(260, 980), random.randint(180, 620))
|
||
time.sleep(random.uniform(0.05, 0.12))
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】预热访问失败({warmup_url}): {e}")
|
||
|
||
def init_browser(self):
|
||
"""初始化浏览器 - 增强反检测版本"""
|
||
try:
|
||
# 启动 Playwright
|
||
logger.info(f"【{self.pure_user_id}】启动Playwright...")
|
||
self.playwright = sync_playwright().start()
|
||
logger.info(f"【{self.pure_user_id}】Playwright启动成功")
|
||
|
||
# 为账号加载稳定浏览器画像
|
||
browser_features = self._get_random_browser_features()
|
||
self.browser_features = browser_features
|
||
self.profile_id = browser_features.get("profile_id", "unknown")
|
||
|
||
# 启动浏览器,使用稳定特征
|
||
logger.info(
|
||
f"【{self.pure_user_id}】启动浏览器,headless模式: {self.headless}, "
|
||
f"画像: {self.profile_id}, UA: {browser_features['user_agent']}"
|
||
)
|
||
self.browser = self.playwright.chromium.launch(
|
||
headless=self.headless,
|
||
args=[
|
||
"--no-sandbox",
|
||
"--disable-setuid-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
"--no-first-run",
|
||
"--disable-gpu",
|
||
f"--window-size={browser_features['window_size']}",
|
||
f"--lang={browser_features['lang']}",
|
||
f"--accept-lang={browser_features['accept_lang']}",
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--mute-audio",
|
||
"--no-default-browser-check",
|
||
"--force-color-profile=srgb",
|
||
"--password-store=basic",
|
||
"--use-mock-keychain",
|
||
]
|
||
)
|
||
|
||
# 验证浏览器已启动
|
||
if not self.browser or not self.browser.is_connected():
|
||
raise Exception("浏览器启动失败或连接已断开")
|
||
logger.info(f"【{self.pure_user_id}】浏览器启动成功,已连接: {self.browser.is_connected()}")
|
||
|
||
# 创建上下文,使用随机特征
|
||
logger.info(f"【{self.pure_user_id}】创建浏览器上下文...")
|
||
|
||
# 🔑 关键优化:添加更多真实浏览器特征
|
||
context_options = {
|
||
'user_agent': browser_features['user_agent'],
|
||
'locale': browser_features['locale'],
|
||
'timezone_id': browser_features['timezone_id'],
|
||
'color_scheme': browser_features['color_scheme'],
|
||
'extra_http_headers': {'Accept-Language': browser_features['accept_lang']},
|
||
}
|
||
|
||
# 根据模式配置viewport和no_viewport
|
||
if not self.headless:
|
||
# 有头模式:使用 no_viewport=True 支持窗口最大化
|
||
# 注意:使用no_viewport时,不能设置device_scale_factor、is_mobile、has_touch
|
||
context_options['no_viewport'] = True # 移除viewport限制,支持--start-maximized
|
||
self.context = self.browser.new_context(**context_options)
|
||
else:
|
||
# 无头模式:使用固定viewport
|
||
context_options.update({
|
||
'viewport': {'width': browser_features['viewport_width'], 'height': browser_features['viewport_height']},
|
||
'device_scale_factor': browser_features['device_scale_factor'],
|
||
'is_mobile': browser_features['is_mobile'],
|
||
'has_touch': browser_features['has_touch'],
|
||
})
|
||
self.context = self.browser.new_context(**context_options)
|
||
|
||
# 验证上下文已创建
|
||
if not self.context:
|
||
raise Exception("浏览器上下文创建失败")
|
||
logger.info(f"【{self.pure_user_id}】浏览器上下文创建成功")
|
||
|
||
# 创建新页面
|
||
logger.info(f"【{self.pure_user_id}】创建新页面...")
|
||
self.page = self.context.new_page()
|
||
|
||
# 验证页面已创建
|
||
if not self.page:
|
||
raise Exception("页面创建失败")
|
||
logger.info(f"【{self.pure_user_id}】页面创建成功({'最大化窗口模式' if not self.headless else '无头模式'})")
|
||
|
||
# 添加增强反检测脚本
|
||
logger.info(f"【{self.pure_user_id}】添加反检测脚本...")
|
||
self.page.add_init_script(self._get_stealth_script(browser_features))
|
||
logger.info(f"【{self.pure_user_id}】浏览器初始化完成")
|
||
|
||
return self.page
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】初始化浏览器失败: {e}")
|
||
import traceback
|
||
logger.error(f"【{self.pure_user_id}】详细错误堆栈: {traceback.format_exc()}")
|
||
# 确保在异常时也清理已创建的资源
|
||
self._cleanup_on_init_failure()
|
||
raise
|
||
|
||
def _cleanup_on_init_failure(self):
|
||
"""初始化失败时的清理"""
|
||
try:
|
||
if hasattr(self, 'page') and self.page:
|
||
self.page.close()
|
||
self.page = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】清理页面时出错: {e}")
|
||
|
||
try:
|
||
if hasattr(self, 'context') and self.context:
|
||
self.context.close()
|
||
self.context = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】清理上下文时出错: {e}")
|
||
|
||
try:
|
||
if hasattr(self, 'browser') and self.browser:
|
||
self.browser.close()
|
||
self.browser = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】清理浏览器时出错: {e}")
|
||
|
||
try:
|
||
if hasattr(self, 'playwright') and self.playwright:
|
||
self.playwright.stop()
|
||
self.playwright = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】清理Playwright时出错: {e}")
|
||
|
||
def _load_success_history(self) -> List[Dict[str, Any]]:
|
||
"""加载历史成功数据(带自动清理)"""
|
||
try:
|
||
if not os.path.exists(self.success_history_file):
|
||
return []
|
||
|
||
# 🧹 自动检查并清理历史数据
|
||
try:
|
||
cleaned = adaptive_strategy_manager.check_and_cleanup_history(
|
||
self.pure_user_id,
|
||
self.success_history_file
|
||
)
|
||
if cleaned:
|
||
logger.info(f"【{self.pure_user_id}】🧹 历史数据已自动清理")
|
||
except Exception as cleanup_e:
|
||
logger.debug(f"【{self.pure_user_id}】清理检查跳过: {cleanup_e}")
|
||
|
||
with open(self.success_history_file, 'r', encoding='utf-8') as f:
|
||
history = json.load(f)
|
||
logger.info(f"【{self.pure_user_id}】加载历史成功数据: {len(history)}条记录")
|
||
return history
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】加载历史数据失败: {e}")
|
||
return []
|
||
|
||
def _save_success_record(self, trajectory_data: Dict[str, Any]):
|
||
"""保存成功记录(增强版 - 记录所有随机参数用于学习优化)"""
|
||
try:
|
||
# 确保目录存在
|
||
os.makedirs(os.path.dirname(self.success_history_file), exist_ok=True)
|
||
|
||
# 加载现有历史
|
||
history = self._load_success_history()
|
||
|
||
# 获取随机参数
|
||
random_params = trajectory_data.get("random_params", {})
|
||
slide_behavior = trajectory_data.get("slide_behavior", {})
|
||
verification_result = trajectory_data.get("verification_result", {})
|
||
|
||
# 添加新记录 - 保存完整的随机参数用于学习
|
||
record = {
|
||
"timestamp": time.time(),
|
||
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"user_id": self.pure_user_id,
|
||
"distance": trajectory_data.get("distance", 0),
|
||
"total_steps": trajectory_data.get("total_steps", 0),
|
||
"model": trajectory_data.get("model", "unknown"),
|
||
# 新增:保存所有轨迹生成的随机参数
|
||
"overshoot_ratio": random_params.get("overshoot_ratio", 0),
|
||
"base_delay": random_params.get("base_delay", 0),
|
||
"acceleration_curve": random_params.get("acceleration_curve", 0),
|
||
"y_jitter_max": random_params.get("y_jitter_max", 0),
|
||
"random_state_snapshot": random_params.get("random_state_snapshot", []),
|
||
# 新增:保存所有滑动行为的随机参数(18个随机因素)
|
||
"slide_behavior": {
|
||
"approach_offset_x": slide_behavior.get("approach_offset_x", 0),
|
||
"approach_offset_y": slide_behavior.get("approach_offset_y", 0),
|
||
"approach_steps": slide_behavior.get("approach_steps", 0),
|
||
"approach_pause": slide_behavior.get("approach_pause", 0),
|
||
"precision_steps": slide_behavior.get("precision_steps", 0),
|
||
"precision_pause": slide_behavior.get("precision_pause", 0),
|
||
"skip_hover": slide_behavior.get("skip_hover", False),
|
||
"hover_pause": slide_behavior.get("hover_pause", 0),
|
||
"pre_down_pause": slide_behavior.get("pre_down_pause", 0),
|
||
"post_down_pause": slide_behavior.get("post_down_pause", 0),
|
||
"move_steps_range": slide_behavior.get("move_steps_range", (1, 3)),
|
||
"delay_variation": slide_behavior.get("delay_variation", (0.9, 1.1)),
|
||
"pre_up_pause": slide_behavior.get("pre_up_pause", 0),
|
||
"post_up_pause": slide_behavior.get("post_up_pause", 0),
|
||
"total_elapsed_time": slide_behavior.get("total_elapsed_time", 0),
|
||
},
|
||
# 保留旧字段以兼容旧版本
|
||
"base_delay_old": trajectory_data.get("base_delay", 0),
|
||
"jitter_x_range": trajectory_data.get("jitter_x_range", [0, 0]),
|
||
"jitter_y_range": trajectory_data.get("jitter_y_range", [0, 0]),
|
||
"slow_factor": trajectory_data.get("slow_factor", 0),
|
||
"acceleration_phase": trajectory_data.get("acceleration_phase", 0),
|
||
"fast_phase": trajectory_data.get("fast_phase", 0),
|
||
"slow_start_ratio": trajectory_data.get("slow_start_ratio", 0),
|
||
# 【优化】不再保存完整轨迹点,节省 90% 存储空间
|
||
# "trajectory_points": trajectory_data.get("trajectory_points", []),
|
||
"trajectory_point_count": len(trajectory_data.get("trajectory_points", [])), # 只记录数量
|
||
"final_left_px": trajectory_data.get("final_left_px", 0),
|
||
"completion_used": trajectory_data.get("completion_used", False),
|
||
"completion_steps": trajectory_data.get("completion_steps", 0),
|
||
"profile_id": verification_result.get("profile_id", self.profile_id),
|
||
"headless": verification_result.get("headless", self.headless),
|
||
"verification_result": verification_result,
|
||
"success": True
|
||
}
|
||
|
||
history.append(record)
|
||
|
||
# 只保留最近100条成功记录
|
||
if len(history) > 100:
|
||
history = history[-100:]
|
||
|
||
# 保存到文件
|
||
with open(self.success_history_file, 'w', encoding='utf-8') as f:
|
||
json.dump(history, f, ensure_ascii=False, indent=2)
|
||
|
||
# 统计滑动行为参数数量
|
||
behavior_params_count = len([k for k in slide_behavior.keys() if not k.startswith('hesitation_at_')])
|
||
|
||
logger.info(f"【{self.pure_user_id}】✅ 保存成功记录: "
|
||
f"距离{record['distance']:.1f}px, 步数{record['total_steps']}, "
|
||
f"超调{record['overshoot_ratio']:.2f}x, 加速^{record['acceleration_curve']:.2f}, "
|
||
f"行为参数{behavior_params_count}个")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】保存成功记录失败: {e}")
|
||
|
||
def _save_failure_record(self, trajectory_data: Dict[str, Any], failure_info: Dict[str, Any]):
|
||
"""保存失败记录,便于分析最近失败样本"""
|
||
try:
|
||
os.makedirs(os.path.dirname(self.failure_history_file), exist_ok=True)
|
||
|
||
history = []
|
||
if os.path.exists(self.failure_history_file):
|
||
with open(self.failure_history_file, 'r', encoding='utf-8') as f:
|
||
history = json.load(f)
|
||
|
||
random_params = trajectory_data.get("random_params", {})
|
||
slide_behavior = trajectory_data.get("slide_behavior", {})
|
||
verification_feedback = failure_info.get("verification_feedback", {})
|
||
verification_result = trajectory_data.get("verification_result", {})
|
||
|
||
try:
|
||
page_url = self.page.url if self.page else ""
|
||
except Exception:
|
||
page_url = ""
|
||
|
||
try:
|
||
page_title = self.page.title() if self.page else ""
|
||
except Exception:
|
||
page_title = ""
|
||
|
||
record = {
|
||
"timestamp": time.time(),
|
||
"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"user_id": self.pure_user_id,
|
||
"attempt": failure_info.get("attempt", 0),
|
||
"distance": trajectory_data.get("distance", 0),
|
||
"slide_distance": failure_info.get("slide_distance", 0),
|
||
"total_steps": trajectory_data.get("total_steps", 0),
|
||
"model": trajectory_data.get("model", "unknown"),
|
||
"overshoot_ratio": random_params.get("overshoot_ratio", 0),
|
||
"requested_steps": random_params.get("steps", 0),
|
||
"base_delay": random_params.get("base_delay", 0),
|
||
"acceleration_curve": random_params.get("acceleration_curve", 0),
|
||
"y_jitter_max": random_params.get("y_jitter_max", 0),
|
||
"strategy": random_params.get("strategy", "unknown"),
|
||
"profile": random_params.get("profile", "unknown"),
|
||
"use_exploration": random_params.get("use_exploration", False),
|
||
"final_left_px": trajectory_data.get("final_left_px", 0),
|
||
"trajectory_point_count": len(trajectory_data.get("trajectory_points", [])),
|
||
"slide_behavior": {
|
||
"approach_offset_x": slide_behavior.get("approach_offset_x", 0),
|
||
"approach_offset_y": slide_behavior.get("approach_offset_y", 0),
|
||
"approach_steps": slide_behavior.get("approach_steps", 0),
|
||
"approach_pause": slide_behavior.get("approach_pause", 0),
|
||
"precision_steps": slide_behavior.get("precision_steps", 0),
|
||
"precision_pause": slide_behavior.get("precision_pause", 0),
|
||
"skip_hover": slide_behavior.get("skip_hover", False),
|
||
"hover_pause": slide_behavior.get("hover_pause", 0),
|
||
"pre_down_pause": slide_behavior.get("pre_down_pause", 0),
|
||
"post_down_pause": slide_behavior.get("post_down_pause", 0),
|
||
"pre_up_pause": slide_behavior.get("pre_up_pause", 0),
|
||
"post_up_pause": slide_behavior.get("post_up_pause", 0),
|
||
"delay_variation": slide_behavior.get("delay_variation", (0.9, 1.1)),
|
||
"total_elapsed_time": slide_behavior.get("total_elapsed_time", 0),
|
||
},
|
||
"verification_feedback": verification_feedback,
|
||
"verification_result": verification_result,
|
||
"profile_id": verification_result.get("profile_id", self.profile_id),
|
||
"headless": verification_result.get("headless", self.headless),
|
||
"page_url": page_url,
|
||
"page_title": page_title,
|
||
"success": False
|
||
}
|
||
|
||
history.append(record)
|
||
if len(history) > 200:
|
||
history = history[-200:]
|
||
|
||
with open(self.failure_history_file, 'w', encoding='utf-8') as f:
|
||
json.dump(history, f, ensure_ascii=False, indent=2)
|
||
|
||
logger.info(
|
||
f"【{self.pure_user_id}】📝 保存失败记录: 第{record['attempt']}次, "
|
||
f"策略={record['strategy']}/{record['profile']}, "
|
||
f"距离{record['slide_distance']:.1f}px, 步数{record['total_steps']}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】保存失败记录失败: {e}")
|
||
|
||
def _optimize_trajectory_params(self) -> Dict[str, Any]:
|
||
"""基于历史成功数据优化轨迹参数(增强版 - 智能学习)"""
|
||
try:
|
||
if not self.enable_learning:
|
||
return self.trajectory_params
|
||
|
||
history = self._load_success_history()
|
||
if len(history) < 3: # 至少需要3条成功记录才开始优化
|
||
logger.info(f"【{self.pure_user_id}】历史成功数据不足({len(history)}条),使用默认参数")
|
||
return self.trajectory_params
|
||
|
||
# 🎯 新版参数学习:基于新的随机参数结构
|
||
# 收集新版参数(overshoot_ratio, acceleration_curve等)
|
||
overshoot_ratios = [record.get("overshoot_ratio", 2.0) for record in history if record.get("overshoot_ratio")]
|
||
base_delays = [record.get("base_delay", 0.0004) for record in history if record.get("base_delay")]
|
||
acceleration_curves = [record.get("acceleration_curve", 1.5) for record in history if record.get("acceleration_curve")]
|
||
y_jitter_maxs = [record.get("y_jitter_max", 2.0) for record in history if record.get("y_jitter_max")]
|
||
total_steps_list = [record.get("total_steps", 6) for record in history]
|
||
|
||
# 计算平均值和标准差
|
||
def safe_avg(values):
|
||
return sum(values) / len(values) if values else 0
|
||
|
||
def safe_std(values):
|
||
if len(values) < 2:
|
||
return 0
|
||
avg = safe_avg(values)
|
||
variance = sum((x - avg) ** 2 for x in values) / len(values)
|
||
return variance ** 0.5
|
||
|
||
def safe_percentile(values, percentile):
|
||
"""计算百分位数"""
|
||
if not values:
|
||
return 0
|
||
sorted_values = sorted(values)
|
||
index = int(len(sorted_values) * percentile)
|
||
return sorted_values[min(index, len(sorted_values) - 1)]
|
||
|
||
# 🧠 智能学习策略(优化版 - 避免过度收敛):
|
||
# 1. 使用成功记录的中位数作为中心值(更稳定)
|
||
# 2. 使用标准差的0.5倍作为范围(保持随机性)
|
||
# 3. 🔧 应用边界限制,防止学习到极端值
|
||
# 4. 🔧 强制最小范围宽度,保持探索能力
|
||
|
||
# 获取边界限制
|
||
bounds = ML_STRATEGY_CONFIG.get("learning_bounds", {})
|
||
min_overshoot = bounds.get("min_overshoot_ratio", 1.75)
|
||
max_overshoot = bounds.get("max_overshoot_ratio", 2.12)
|
||
min_y_jitter = bounds.get("min_y_jitter", 0.8)
|
||
max_y_jitter = bounds.get("max_y_jitter", 3.0)
|
||
|
||
# 学习超调比例(关键参数)
|
||
# 🔧 2025-12-25:适配新的贝塞尔曲线轨迹,超调比例改为真实百分比(1.01-1.15)
|
||
if overshoot_ratios:
|
||
overshoot_median = safe_percentile(overshoot_ratios, 0.5)
|
||
overshoot_std = safe_std(overshoot_ratios)
|
||
|
||
# 🔧 关键修复:如果中位数超过上限,强制拉回到合理范围
|
||
if overshoot_median > max_overshoot:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 学习到的超调比例中位数({overshoot_median:.2f})过高,"
|
||
f"强制调整到{max_overshoot}")
|
||
overshoot_median = max_overshoot - 0.02
|
||
elif overshoot_median < min_overshoot:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 学习到的超调比例中位数({overshoot_median:.2f})过低,"
|
||
f"强制调整到{min_overshoot}")
|
||
overshoot_median = min_overshoot + 0.02
|
||
|
||
# 应用边界限制
|
||
overshoot_min = max(min_overshoot, overshoot_median - max(overshoot_std * 0.3, 0.03))
|
||
overshoot_max = min(max_overshoot, overshoot_median + max(overshoot_std * 0.3, 0.03))
|
||
|
||
# 🔧 确保最小范围宽度(至少0.04的差距,即4%)
|
||
if overshoot_max - overshoot_min < 0.04:
|
||
overshoot_min = max(min_overshoot, overshoot_median - 0.02)
|
||
overshoot_max = min(max_overshoot, overshoot_median + 0.02)
|
||
|
||
learned_overshoot = (overshoot_min, overshoot_max)
|
||
logger.info(f"【{self.pure_user_id}】📚 学习到最优超调比例: {overshoot_min:.2f}-{overshoot_max:.2f}x "
|
||
f"(中位数:{overshoot_median:.2f}, 边界限制:{min_overshoot}-{max_overshoot})")
|
||
else:
|
||
learned_overshoot = (1.03, 1.08) # 🔧 新默认值:3-8%超调
|
||
|
||
# 学习基础延迟(影响速度感知)
|
||
# 🔧 2025-12-25:改为毫秒级延迟(0.004-0.015秒)
|
||
if base_delays:
|
||
delay_median = safe_percentile(base_delays, 0.5)
|
||
delay_std = safe_std(base_delays)
|
||
delay_min = max(0.003, delay_median - delay_std * 0.4)
|
||
delay_max = min(0.020, delay_median + delay_std * 0.4)
|
||
|
||
# 🔧 确保最小范围宽度(至少3ms的差距)
|
||
if delay_max - delay_min < 0.003:
|
||
delay_min = max(0.003, delay_median - 0.0015)
|
||
delay_max = min(0.020, delay_median + 0.0015)
|
||
|
||
learned_delay = (delay_min, delay_max)
|
||
logger.info(f"【{self.pure_user_id}】📚 学习到最优延迟: {delay_min*1000:.1f}-{delay_max*1000:.1f}ms "
|
||
f"(中位数:{delay_median*1000:.1f}ms)")
|
||
else:
|
||
learned_delay = (0.006, 0.012) # 🔧 新默认值:6-12ms
|
||
|
||
# 学习加速曲线(影响轨迹形状)
|
||
# 🔧 2025-12-25:适配贝塞尔曲线的ease-out指数
|
||
if acceleration_curves:
|
||
curve_median = safe_percentile(acceleration_curves, 0.5)
|
||
curve_std = safe_std(acceleration_curves)
|
||
curve_min = max(1.3, curve_median - curve_std * 0.3)
|
||
curve_max = min(2.5, curve_median + curve_std * 0.3)
|
||
|
||
# 🔧 确保最小范围宽度(至少0.2的差距)
|
||
if curve_max - curve_min < 0.2:
|
||
curve_min = max(1.3, curve_median - 0.1)
|
||
curve_max = min(2.5, curve_median + 0.1)
|
||
|
||
learned_curve = (curve_min, curve_max)
|
||
logger.info(f"【{self.pure_user_id}】📚 学习到最优加速曲线: ^{curve_min:.2f}-^{curve_max:.2f} "
|
||
f"(中位数:^{curve_median:.2f})")
|
||
else:
|
||
learned_curve = (1.6, 2.0) # 🔧 新默认值
|
||
|
||
# 学习Y轴抖动(影响真实感)
|
||
if y_jitter_maxs:
|
||
jitter_median = safe_percentile(y_jitter_maxs, 0.5)
|
||
jitter_std = safe_std(y_jitter_maxs)
|
||
|
||
# 🔧 关键修复:如果中位数超过边界,强制拉回
|
||
if jitter_median > max_y_jitter:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 学习到的Y抖动中位数({jitter_median:.1f})过高,"
|
||
f"强制调整到{max_y_jitter}")
|
||
jitter_median = max_y_jitter - 0.3
|
||
elif jitter_median < min_y_jitter:
|
||
jitter_median = min_y_jitter + 0.3
|
||
|
||
# 应用边界限制
|
||
jitter_min = max(min_y_jitter, jitter_median - max(jitter_std * 0.4, 0.4))
|
||
jitter_max = min(max_y_jitter, jitter_median + max(jitter_std * 0.4, 0.4))
|
||
|
||
# 🔧 确保最小范围宽度(至少0.6的差距)
|
||
if jitter_max - jitter_min < 0.6:
|
||
jitter_min = max(min_y_jitter, jitter_median - 0.3)
|
||
jitter_max = min(max_y_jitter, jitter_median + 0.3)
|
||
|
||
learned_jitter = (jitter_min, jitter_max)
|
||
logger.info(f"【{self.pure_user_id}】📚 学习到最优Y抖动: {jitter_min:.1f}-{jitter_max:.1f}px "
|
||
f"(中位数:{jitter_median:.1f}px, 边界限制:{min_y_jitter}-{max_y_jitter})")
|
||
else:
|
||
learned_jitter = (1.5, 2.2) # 🔧 新默认值
|
||
|
||
# 学习步数范围
|
||
# 这里的步数会直接传递给新轨迹生成器,避免策略与执行脱节
|
||
if total_steps_list:
|
||
steps_median = int(safe_percentile(total_steps_list, 0.5))
|
||
steps_std = safe_std(total_steps_list)
|
||
steps_min = max(20, int(steps_median - steps_std * 0.5))
|
||
steps_max = min(40, int(steps_median + steps_std * 0.5))
|
||
|
||
# 🔧 确保最小范围宽度(至少5步的差距)
|
||
if steps_max - steps_min < 5:
|
||
steps_min = max(20, steps_median - 2)
|
||
steps_max = min(40, steps_median + 3)
|
||
|
||
learned_steps = (steps_min, steps_max)
|
||
logger.info(f"【{self.pure_user_id}】📚 学习到最优步数: {steps_min}-{steps_max}步 "
|
||
f"(中位数:{steps_median}步)")
|
||
else:
|
||
learned_steps = (22, 30) # 🔧 新默认值
|
||
|
||
# 🎯 新增:学习滑动行为参数(18种行为参数)
|
||
logger.info(f"【{self.pure_user_id}】📚 开始学习滑动行为参数...")
|
||
|
||
# 收集所有成功记录的滑动行为数据
|
||
slide_behaviors = [record.get("slide_behavior", {}) for record in history if record.get("slide_behavior")]
|
||
|
||
learned_behavior = {}
|
||
|
||
if slide_behaviors:
|
||
# 学习接近偏移
|
||
approach_offset_x_list = [b.get("approach_offset_x", -20) for b in slide_behaviors if b.get("approach_offset_x")]
|
||
if approach_offset_x_list:
|
||
median = safe_percentile(approach_offset_x_list, 0.5)
|
||
std = safe_std(approach_offset_x_list)
|
||
x_min = max(-45, median - std * 0.5)
|
||
x_max = min(-5, median + std * 0.5)
|
||
# 🔧 确保最小范围宽度(至少10px)
|
||
if x_max - x_min < 10:
|
||
x_min = max(-45, median - 5)
|
||
x_max = min(-5, median + 5)
|
||
learned_behavior["approach_offset_x"] = (x_min, x_max)
|
||
|
||
approach_offset_y_list = [b.get("approach_offset_y", 0) for b in slide_behaviors if b.get("approach_offset_y")]
|
||
if approach_offset_y_list:
|
||
median = safe_percentile(approach_offset_y_list, 0.5)
|
||
std = safe_std(approach_offset_y_list)
|
||
y_min = max(-25, median - std * 0.5)
|
||
y_max = min(25, median + std * 0.5)
|
||
# 🔧 确保最小范围宽度(至少10px)
|
||
if y_max - y_min < 10:
|
||
y_min = max(-25, median - 5)
|
||
y_max = min(25, median + 5)
|
||
learned_behavior["approach_offset_y"] = (y_min, y_max)
|
||
|
||
# 学习接近步数
|
||
approach_steps_list = [b.get("approach_steps", 7) for b in slide_behaviors if b.get("approach_steps")]
|
||
if approach_steps_list:
|
||
median = int(safe_percentile(approach_steps_list, 0.5))
|
||
std = safe_std(approach_steps_list)
|
||
steps_min = max(3, int(median - std * 0.5))
|
||
steps_max = min(15, int(median + std * 0.5))
|
||
# 🔧 确保最小范围宽度(至少3步)
|
||
if steps_max - steps_min < 3:
|
||
steps_min = max(3, median - 2)
|
||
steps_max = min(15, median + 2)
|
||
learned_behavior["approach_steps"] = (steps_min, steps_max)
|
||
|
||
# 学习停顿时间
|
||
approach_pause_list = [b.get("approach_pause", 0.2) for b in slide_behaviors if b.get("approach_pause")]
|
||
if approach_pause_list:
|
||
median = safe_percentile(approach_pause_list, 0.5)
|
||
std = safe_std(approach_pause_list)
|
||
pause_min = max(0.05, median - std * 0.4)
|
||
pause_max = min(0.5, median + std * 0.4)
|
||
# 🔧 确保最小范围宽度(至少0.1秒)
|
||
if pause_max - pause_min < 0.1:
|
||
pause_min = max(0.05, median - 0.05)
|
||
pause_max = min(0.5, median + 0.05)
|
||
learned_behavior["approach_pause"] = (pause_min, pause_max)
|
||
|
||
precision_steps_list = [b.get("precision_steps", 5) for b in slide_behaviors if b.get("precision_steps")]
|
||
if precision_steps_list:
|
||
median = int(safe_percentile(precision_steps_list, 0.5))
|
||
std = safe_std(precision_steps_list)
|
||
steps_min = max(2, int(median - std * 0.5))
|
||
steps_max = min(10, int(median + std * 0.5))
|
||
# 🔧 确保最小范围宽度(至少2步)
|
||
if steps_max - steps_min < 2:
|
||
steps_min = max(2, median - 1)
|
||
steps_max = min(10, median + 1)
|
||
learned_behavior["precision_steps"] = (steps_min, steps_max)
|
||
|
||
precision_pause_list = [b.get("precision_pause", 0.15) for b in slide_behaviors if b.get("precision_pause")]
|
||
if precision_pause_list:
|
||
median = safe_percentile(precision_pause_list, 0.5)
|
||
std = safe_std(precision_pause_list)
|
||
pause_min = max(0.03, median - std * 0.4)
|
||
pause_max = min(0.4, median + std * 0.4)
|
||
# 🔧 确保最小范围宽度(至少0.08秒)
|
||
if pause_max - pause_min < 0.08:
|
||
pause_min = max(0.03, median - 0.04)
|
||
pause_max = min(0.4, median + 0.04)
|
||
learned_behavior["precision_pause"] = (pause_min, pause_max)
|
||
|
||
# 学习悬停概率
|
||
skip_hover_list = [b.get("skip_hover", False) for b in slide_behaviors if "skip_hover" in b]
|
||
if skip_hover_list:
|
||
skip_rate = sum(1 for x in skip_hover_list if x) / len(skip_hover_list)
|
||
learned_behavior["skip_hover_rate"] = skip_rate
|
||
|
||
hover_pause_list = [b.get("hover_pause", 0.2) for b in slide_behaviors if b.get("hover_pause")]
|
||
if hover_pause_list:
|
||
median = safe_percentile(hover_pause_list, 0.5)
|
||
std = safe_std(hover_pause_list)
|
||
pause_min = max(0.03, median - std * 0.4)
|
||
pause_max = min(0.5, median + std * 0.4)
|
||
# 🔧 确保最小范围宽度(至少0.1秒)
|
||
if pause_max - pause_min < 0.1:
|
||
pause_min = max(0.03, median - 0.05)
|
||
pause_max = min(0.5, median + 0.05)
|
||
learned_behavior["hover_pause"] = (pause_min, pause_max)
|
||
|
||
# 学习按下停顿
|
||
pre_down_list = [b.get("pre_down_pause", 0.1) for b in slide_behaviors if b.get("pre_down_pause")]
|
||
if pre_down_list:
|
||
median = safe_percentile(pre_down_list, 0.5)
|
||
std = safe_std(pre_down_list)
|
||
pause_min = max(0.01, median - std * 0.4)
|
||
pause_max = min(0.25, median + std * 0.4)
|
||
# 🔧 确保最小范围宽度(至少0.05秒)
|
||
if pause_max - pause_min < 0.05:
|
||
pause_min = max(0.01, median - 0.025)
|
||
pause_max = min(0.25, median + 0.025)
|
||
learned_behavior["pre_down_pause"] = (pause_min, pause_max)
|
||
|
||
post_down_list = [b.get("post_down_pause", 0.1) for b in slide_behaviors if b.get("post_down_pause")]
|
||
if post_down_list:
|
||
median = safe_percentile(post_down_list, 0.5)
|
||
std = safe_std(post_down_list)
|
||
pause_min = max(0.01, median - std * 0.4)
|
||
pause_max = min(0.25, median + std * 0.4)
|
||
# 🔧 确保最小范围宽度(至少0.05秒)
|
||
if pause_max - pause_min < 0.05:
|
||
pause_min = max(0.01, median - 0.025)
|
||
pause_max = min(0.25, median + 0.025)
|
||
learned_behavior["post_down_pause"] = (pause_min, pause_max)
|
||
|
||
logger.info(f"【{self.pure_user_id}】📚 成功学习{len(learned_behavior)}个滑动行为参数")
|
||
|
||
# 基于完整轨迹数据的学习
|
||
completion_usage_rate = 0
|
||
avg_completion_steps = 0
|
||
|
||
if len(history) > 0:
|
||
# 计算补全使用率
|
||
completion_used_count = sum(1 for record in history if record.get("completion_used", False))
|
||
completion_usage_rate = completion_used_count / len(history)
|
||
|
||
# 计算平均补全步数
|
||
completion_steps_list = [record.get("completion_steps", 0) for record in history if record.get("completion_used", False)]
|
||
if completion_steps_list:
|
||
avg_completion_steps = sum(completion_steps_list) / len(completion_steps_list)
|
||
|
||
# 构建优化后的参数(新版结构)
|
||
optimized_params = {
|
||
# 新版参数(基于学习结果)
|
||
"learned_overshoot_range": learned_overshoot,
|
||
"learned_delay_range": learned_delay,
|
||
"learned_curve_range": learned_curve,
|
||
"learned_jitter_range": learned_jitter,
|
||
"learned_steps_range": learned_steps,
|
||
# 🎯 新增:学习到的滑动行为参数
|
||
"learned_behavior": learned_behavior,
|
||
# 旧版参数(保留兼容性)
|
||
"total_steps_range": learned_steps,
|
||
"base_delay_range": learned_delay,
|
||
"jitter_x_range": [0, 1],
|
||
"jitter_y_range": [0, 1],
|
||
"slow_factor_range": [10, 15],
|
||
"acceleration_phase": 1.0,
|
||
"fast_phase": 1.0,
|
||
"slow_start_ratio_base": learned_overshoot[0],
|
||
# 学习统计
|
||
"completion_usage_rate": completion_usage_rate,
|
||
"avg_completion_steps": avg_completion_steps,
|
||
"learning_enabled": True,
|
||
"history_count": len(history),
|
||
"learning_version": "2.0" # 标记为新版学习算法
|
||
}
|
||
|
||
logger.info(f"【{self.pure_user_id}】基于{len(history)}条成功记录优化轨迹参数: 步数{optimized_params['total_steps_range']}, 延迟{optimized_params['base_delay_range']}")
|
||
|
||
return optimized_params
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】优化轨迹参数失败: {e}")
|
||
return self.trajectory_params
|
||
|
||
def _get_cookies_after_success(self):
|
||
"""滑块验证成功后获取cookie"""
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】开始获取滑块验证成功后的页面cookie...")
|
||
|
||
# 检查当前页面URL
|
||
current_url = self.page.url
|
||
logger.info(f"【{self.pure_user_id}】当前页面URL: {current_url}")
|
||
|
||
# 检查页面标题
|
||
page_title = self.page.title()
|
||
logger.info(f"【{self.pure_user_id}】当前页面标题: {page_title}")
|
||
|
||
# 等待一下确保cookie完全更新
|
||
time.sleep(1)
|
||
|
||
# 获取浏览器中的所有cookie
|
||
cookies = self.context.cookies()
|
||
|
||
if cookies:
|
||
# 将cookie转换为字典格式
|
||
new_cookies = {}
|
||
for cookie in cookies:
|
||
new_cookies[cookie['name']] = cookie['value']
|
||
|
||
logger.info(f"【{self.pure_user_id}】滑块验证成功后已获取cookie,共{len(new_cookies)}个cookie")
|
||
|
||
# 记录所有cookie的详细信息
|
||
logger.info(f"【{self.pure_user_id}】获取到的所有cookie: {list(new_cookies.keys())}")
|
||
|
||
# 单独记录x5相关cookie,便于排查风控链路
|
||
x5_cookies = {}
|
||
|
||
# 筛选出x5相关的cookies(包括x5sec, x5step等)
|
||
for cookie_name, cookie_value in new_cookies.items():
|
||
cookie_name_lower = cookie_name.lower()
|
||
if cookie_name_lower.startswith('x5') or 'x5sec' in cookie_name_lower:
|
||
x5_cookies[cookie_name] = cookie_value
|
||
logger.info(f"【{self.pure_user_id}】x5相关cookie已获取: {cookie_name} = {cookie_value}")
|
||
|
||
logger.info(f"【{self.pure_user_id}】找到{len(x5_cookies)}个x5相关cookies: {list(x5_cookies.keys())}")
|
||
|
||
if x5_cookies:
|
||
logger.info(f"【{self.pure_user_id}】返回完整cookie集合,并保留x5相关cookie日志: {list(x5_cookies.keys())}")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】未找到x5相关cookie")
|
||
|
||
return new_cookies
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】未获取到任何cookie")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】获取滑块验证成功后的cookie失败: {str(e)}")
|
||
return None
|
||
|
||
def _save_cookies_to_file(self, cookies):
|
||
"""保存cookie到文件"""
|
||
try:
|
||
# 确保目录存在
|
||
cookie_dir = f"slider_cookies/{self.user_id}"
|
||
os.makedirs(cookie_dir, exist_ok=True)
|
||
|
||
# 保存cookie到JSON文件
|
||
cookie_file = f"{cookie_dir}/cookies_{int(time.time())}.json"
|
||
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||
json.dump(cookies, f, ensure_ascii=False, indent=2)
|
||
|
||
logger.info(f"【{self.pure_user_id}】Cookie已保存到文件: {cookie_file}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】保存cookie到文件失败: {str(e)}")
|
||
|
||
# 关键 Cookie 名称列表(用于判定"有意义的刷新")
|
||
_KEY_COOKIE_NAMES = {
|
||
'_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 'unb', 'sgcookie',
|
||
'uc1', 'uc3', 'uc4', 'csg', 'sn',
|
||
}
|
||
_PROTECTED_SESSION_COOKIE_FIELDS = (
|
||
'unb',
|
||
'sgcookie',
|
||
'cookie2',
|
||
'_m_h5_tk',
|
||
'_m_h5_tk_enc',
|
||
't',
|
||
'cna',
|
||
'havana_lgc2_77',
|
||
'_tb_token_',
|
||
)
|
||
_REQUIRED_SESSION_COOKIE_FIELDS = (
|
||
'unb',
|
||
'sgcookie',
|
||
'cookie2',
|
||
'_m_h5_tk',
|
||
'_m_h5_tk_enc',
|
||
't',
|
||
'cna',
|
||
)
|
||
_X5_COOKIE_PREFIX = 'x5'
|
||
|
||
def _snapshot_context_cookies(self, context=None) -> Dict[str, str]:
|
||
"""快照浏览器上下文中的所有 Cookie,返回 {name: value} 字典"""
|
||
try:
|
||
current_context = context or self.context
|
||
if not current_context:
|
||
return {}
|
||
raw = current_context.cookies()
|
||
return {c['name']: c['value'] for c in raw} if raw else {}
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】快照 Cookie 失败: {e}")
|
||
return {}
|
||
|
||
def _log_cookie_snapshot_integrity(self, cookies_dict: Dict[str, str], scene: str):
|
||
"""记录登录链路中的 Cookie 快照完整性,避免不完整快照静默通过。"""
|
||
if not cookies_dict:
|
||
logger.warning(f"【{self.pure_user_id}】{scene}Cookie快照为空")
|
||
return
|
||
|
||
missing_protected_fields = [
|
||
key for key in self._PROTECTED_SESSION_COOKIE_FIELDS
|
||
if not cookies_dict.get(key)
|
||
]
|
||
missing_required_fields = [
|
||
key for key in self._REQUIRED_SESSION_COOKIE_FIELDS
|
||
if not cookies_dict.get(key)
|
||
]
|
||
|
||
if missing_protected_fields:
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】{scene}Cookie快照完整性告警: "
|
||
f"field_count={len(cookies_dict)}, "
|
||
f"missing_protected_fields={missing_protected_fields}"
|
||
)
|
||
if missing_required_fields:
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】{scene}Cookie快照核心字段不足: "
|
||
f"field_count={len(cookies_dict)}, "
|
||
f"missing_required_fields={missing_required_fields}"
|
||
)
|
||
|
||
def _safe_page_url(self, page) -> str:
|
||
try:
|
||
return str(page.url or '')
|
||
except Exception:
|
||
return ''
|
||
|
||
def _safe_page_title(self, page) -> str:
|
||
try:
|
||
return str(page.title() or '')
|
||
except Exception:
|
||
return ''
|
||
|
||
def _get_context_pages(self, context=None, fallback_page=None) -> List[Any]:
|
||
pages = []
|
||
seen = set()
|
||
candidates = []
|
||
|
||
current_context = context or self.context
|
||
if current_context:
|
||
try:
|
||
candidates.extend(list(current_context.pages))
|
||
except Exception:
|
||
pass
|
||
|
||
if fallback_page:
|
||
candidates.append(fallback_page)
|
||
|
||
for candidate in candidates:
|
||
if not candidate:
|
||
continue
|
||
candidate_id = id(candidate)
|
||
if candidate_id in seen:
|
||
continue
|
||
seen.add(candidate_id)
|
||
try:
|
||
if candidate.is_closed():
|
||
continue
|
||
except Exception:
|
||
pass
|
||
pages.append(candidate)
|
||
|
||
return pages
|
||
|
||
def _has_completed_login_cookies(self, cookie_dict: Dict[str, str]) -> bool:
|
||
if not cookie_dict.get('unb'):
|
||
return False
|
||
|
||
companion_keys = (
|
||
'cookie2', 'havana_lgc2_77', '_tb_token_', 'sgcookie',
|
||
'_m_h5_tk', '_m_h5_tk_enc', 't'
|
||
)
|
||
return any(cookie_dict.get(key) for key in companion_keys)
|
||
|
||
def _is_logged_in_url(self, url: str) -> bool:
|
||
current_url = str(url or '')
|
||
if not current_url:
|
||
return False
|
||
|
||
if 'www.goofish.com/im' in current_url:
|
||
return True
|
||
|
||
return (
|
||
'goofish.com' in current_url and
|
||
'passport.goofish.com' not in current_url and
|
||
'mini_login' not in current_url and
|
||
'/iv/' not in current_url
|
||
)
|
||
|
||
def _looks_like_verification_url(self, url: str) -> bool:
|
||
current_url = str(url or '').lower()
|
||
if not current_url:
|
||
return False
|
||
|
||
verification_tokens = (
|
||
'passport.goofish.com',
|
||
'mini_login',
|
||
'identity_verify',
|
||
'/iv/',
|
||
'qrcode',
|
||
'scan',
|
||
'verify',
|
||
)
|
||
return any(token in current_url for token in verification_tokens)
|
||
|
||
def _page_has_keep_login_prompt(self, page) -> bool:
|
||
try:
|
||
prompt_selectors = [
|
||
'text=保持登录',
|
||
'text=不保持',
|
||
]
|
||
for selector in prompt_selectors:
|
||
try:
|
||
element = page.query_selector(selector)
|
||
if element and element.is_visible():
|
||
return True
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _get_password_login_selectors(self) -> Dict[str, List[str]]:
|
||
return {
|
||
'account': [
|
||
'#fm-login-id',
|
||
'input[name="fm-login-id"]',
|
||
'input[placeholder*="手机号"]',
|
||
'input[placeholder*="手机"]',
|
||
'input[placeholder*="邮箱"]',
|
||
'input[placeholder*="账号"]',
|
||
'.fm-login-id',
|
||
'#J_LoginForm input[type="text"]',
|
||
'#TPL_username_1',
|
||
],
|
||
'password': [
|
||
'#fm-login-password',
|
||
'input[name="fm-login-password"]',
|
||
'input[type="password"]',
|
||
'input[placeholder*="密码"]',
|
||
'#TPL_password_1',
|
||
],
|
||
'submit': [
|
||
'button.password-login',
|
||
'.fm-button.fm-submit.password-login',
|
||
'.password-login',
|
||
'button.fm-submit',
|
||
'text=登录',
|
||
],
|
||
'tab': [
|
||
'a.password-login-tab-item',
|
||
'.password-login-tab-item',
|
||
'text=密码登录',
|
||
'text=账号密码登录',
|
||
],
|
||
'agreement': [
|
||
'#fm-agreement-checkbox',
|
||
'input[type="checkbox"]',
|
||
],
|
||
}
|
||
|
||
def _query_first_visible(self, frame, selectors: List[str]):
|
||
if not frame:
|
||
return None, None
|
||
|
||
for selector in selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
return element, selector
|
||
except Exception:
|
||
continue
|
||
|
||
return None, None
|
||
|
||
def _probe_login_form_state(self, frame) -> Dict[str, Any]:
|
||
"""探测当前 frame 是否具备真正可交互的账密登录表单。"""
|
||
if not frame:
|
||
return {
|
||
'is_login_form': False,
|
||
'probe_type': 'missing',
|
||
'matched_selector': None,
|
||
'matched_text': None,
|
||
}
|
||
|
||
selectors = self._get_password_login_selectors()
|
||
account_input, account_selector = self._query_first_visible(frame, selectors['account'])
|
||
if account_input:
|
||
return {
|
||
'is_login_form': True,
|
||
'probe_type': 'account_input',
|
||
'matched_selector': account_selector,
|
||
'matched_text': None,
|
||
}
|
||
|
||
password_input, password_selector = self._query_first_visible(frame, selectors['password'])
|
||
if password_input:
|
||
return {
|
||
'is_login_form': True,
|
||
'probe_type': 'password_input',
|
||
'matched_selector': password_selector,
|
||
'matched_text': None,
|
||
}
|
||
|
||
password_tab, tab_selector = self._query_first_visible(frame, selectors['tab'])
|
||
submit_button, submit_selector = self._query_first_visible(frame, selectors['submit'])
|
||
|
||
submit_text = None
|
||
if submit_button:
|
||
try:
|
||
submit_text = ' '.join((submit_button.inner_text() or '').split())
|
||
except Exception:
|
||
submit_text = None
|
||
|
||
if password_tab and submit_button:
|
||
return {
|
||
'is_login_form': True,
|
||
'probe_type': 'password_tab_plus_submit',
|
||
'matched_selector': f"{tab_selector} + {submit_selector}",
|
||
'matched_text': submit_text,
|
||
}
|
||
|
||
if submit_button:
|
||
probe_type = 'submit_only'
|
||
submit_text_value = submit_text or ''
|
||
if submit_selector == 'text=登录' or any(
|
||
keyword in submit_text_value for keyword in ('进入', '继续', '去登录', '去看看')
|
||
):
|
||
probe_type = 'direct_enter_like'
|
||
return {
|
||
'is_login_form': False,
|
||
'probe_type': probe_type,
|
||
'matched_selector': submit_selector,
|
||
'matched_text': submit_text,
|
||
}
|
||
|
||
if password_tab:
|
||
return {
|
||
'is_login_form': False,
|
||
'probe_type': 'tab_only',
|
||
'matched_selector': tab_selector,
|
||
'matched_text': None,
|
||
}
|
||
|
||
return {
|
||
'is_login_form': False,
|
||
'probe_type': 'none',
|
||
'matched_selector': None,
|
||
'matched_text': None,
|
||
}
|
||
|
||
def _find_login_form_with_retry(self, page, timeout_seconds: float = 8.0,
|
||
poll_interval: float = 1.0):
|
||
if not page:
|
||
return None, False, None
|
||
|
||
deadline = time.time() + max(timeout_seconds, 0.0)
|
||
attempt = 0
|
||
last_non_form_probe = None
|
||
|
||
while True:
|
||
attempt += 1
|
||
search_frames = [('主页面', page)]
|
||
try:
|
||
for idx, frame in enumerate(page.frames):
|
||
if frame == page.main_frame:
|
||
continue
|
||
search_frames.append((f'Frame {idx}', frame))
|
||
except Exception:
|
||
pass
|
||
|
||
for frame_label, frame in search_frames:
|
||
probe_info = self._probe_login_form_state(frame)
|
||
if probe_info.get('is_login_form'):
|
||
matched_selector = probe_info.get('matched_selector')
|
||
probe_type = probe_info.get('probe_type')
|
||
probe_text = probe_info.get('matched_text')
|
||
probe_note = f" [{probe_text}]" if probe_text else ""
|
||
logger.info(
|
||
f"【{self.pure_user_id}】✓ 第{attempt}次探测在{frame_label}找到登录表单({probe_type}): "
|
||
f"{matched_selector}{probe_note}"
|
||
)
|
||
return frame, True, matched_selector
|
||
|
||
if probe_info.get('probe_type') not in {'missing', 'none'}:
|
||
last_non_form_probe = {
|
||
'frame_label': frame_label,
|
||
'attempt': attempt,
|
||
**probe_info,
|
||
}
|
||
|
||
if time.time() >= deadline:
|
||
break
|
||
|
||
time.sleep(max(poll_interval, 0.1))
|
||
|
||
if last_non_form_probe:
|
||
probe_text = last_non_form_probe.get('matched_text')
|
||
probe_note = f" [{probe_text}]" if probe_text else ""
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】登录表单探测超时,最近一次仅命中非表单态"
|
||
f"({last_non_form_probe.get('probe_type')}),位置={last_non_form_probe.get('frame_label')},"
|
||
f"选择器={last_non_form_probe.get('matched_selector')}{probe_note}"
|
||
)
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】在 {timeout_seconds:.1f}s 内未探测到登录表单"
|
||
)
|
||
return None, False, None
|
||
|
||
def _clear_page_storage_state(self, context=None, fallback_page=None) -> int:
|
||
cleared_pages = 0
|
||
for candidate in self._get_context_pages(context, fallback_page):
|
||
try:
|
||
candidate.evaluate(
|
||
"() => { try { localStorage.clear(); sessionStorage.clear(); } catch(e) {} }"
|
||
)
|
||
cleared_pages += 1
|
||
except Exception:
|
||
continue
|
||
return cleared_pages
|
||
|
||
def _prepare_login_page_after_cleanup(self, context, page, *, clear_storage: bool = False,
|
||
reopen_fresh_page: bool = False,
|
||
timeout_seconds: float = 8.0):
|
||
if context:
|
||
context.clear_cookies()
|
||
|
||
if clear_storage:
|
||
cleared_pages = self._clear_page_storage_state(context, page)
|
||
logger.info(f"【{self.pure_user_id}】已清理 {cleared_pages} 个页面的本地存储")
|
||
|
||
def _navigate_and_detect(target_page, detect_timeout):
|
||
target_page.goto("https://www.goofish.com/im", wait_until="domcontentloaded", timeout=30000)
|
||
time.sleep(1.5)
|
||
detected_frame, detected_form, detected_selector = self._find_login_form_with_retry(
|
||
target_page,
|
||
timeout_seconds=detect_timeout,
|
||
poll_interval=1.0,
|
||
)
|
||
if detected_form:
|
||
return detected_frame, True, detected_selector
|
||
|
||
# 部分网络环境下 iframe 会延迟挂载,再做一次更稳妥的加载与探测
|
||
target_page.goto("https://www.goofish.com/im", wait_until="networkidle", timeout=45000)
|
||
time.sleep(2)
|
||
return self._find_login_form_with_retry(
|
||
target_page,
|
||
timeout_seconds=max(detect_timeout, 12.0),
|
||
poll_interval=1.0,
|
||
)
|
||
|
||
active_page = page
|
||
login_frame, found_login_form, matched_selector = _navigate_and_detect(active_page, timeout_seconds)
|
||
if found_login_form:
|
||
return active_page, login_frame, True, matched_selector, False
|
||
|
||
if reopen_fresh_page and context:
|
||
try:
|
||
fresh_page = context.new_page()
|
||
login_frame, found_login_form, matched_selector = _navigate_and_detect(
|
||
fresh_page,
|
||
max(timeout_seconds, 10.0),
|
||
)
|
||
if found_login_form:
|
||
logger.info(f"【{self.pure_user_id}】✓ 新建页面后找到登录表单")
|
||
return fresh_page, login_frame, True, matched_selector, True
|
||
try:
|
||
fresh_page.close()
|
||
except Exception:
|
||
pass
|
||
except Exception as fresh_page_error:
|
||
logger.warning(f"【{self.pure_user_id}】新建页面重新探测登录表单失败: {fresh_page_error}")
|
||
|
||
return active_page, None, False, None, False
|
||
|
||
def _page_has_login_form(self, page) -> bool:
|
||
if not page:
|
||
return False
|
||
|
||
frames_to_check = [page]
|
||
try:
|
||
frames_to_check.extend(list(page.frames))
|
||
except Exception:
|
||
pass
|
||
|
||
for frame in frames_to_check:
|
||
try:
|
||
if self._probe_login_form_state(frame).get('is_login_form'):
|
||
return True
|
||
except Exception:
|
||
continue
|
||
|
||
return False
|
||
|
||
def _read_frame_text_for_detection(self, frame) -> str:
|
||
"""优先读取可见文本,避免把 HTML/CSS/JS 误判成验证文案。"""
|
||
if not frame:
|
||
return ''
|
||
|
||
text_candidates = []
|
||
|
||
try:
|
||
visible_text = frame.inner_text('body', timeout=1500)
|
||
if visible_text:
|
||
text_candidates.append(str(visible_text))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
content_text = frame.content()
|
||
if content_text:
|
||
text_candidates.append(str(content_text))
|
||
except Exception:
|
||
pass
|
||
|
||
merged = '\n'.join(text_candidates)
|
||
return merged[:20000] if merged else ''
|
||
|
||
def _page_looks_like_verification(self, page) -> bool:
|
||
try:
|
||
if self._page_has_login_form(page):
|
||
return False
|
||
|
||
page_url = self._safe_page_url(page)
|
||
if self._looks_like_verification_url(page_url):
|
||
return True
|
||
|
||
try:
|
||
iframe = page.query_selector('iframe#alibaba-login-box')
|
||
if iframe:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
for frame in page.frames:
|
||
if self._looks_like_verification_url(getattr(frame, 'url', '')):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
def _select_monitor_page(self, context=None, fallback_page=None):
|
||
pages = self._get_context_pages(context, fallback_page)
|
||
if not pages:
|
||
return fallback_page
|
||
|
||
reversed_pages = list(reversed(pages))
|
||
|
||
for candidate in reversed_pages:
|
||
if self._page_looks_like_verification(candidate):
|
||
return candidate
|
||
|
||
for candidate in reversed_pages:
|
||
if self._page_has_keep_login_prompt(candidate):
|
||
return candidate
|
||
|
||
for candidate in reversed_pages:
|
||
page_url = self._safe_page_url(candidate)
|
||
if page_url and page_url != 'about:blank':
|
||
return candidate
|
||
|
||
return reversed_pages[0]
|
||
|
||
def _probe_context_login_success(self, context, fallback_page=None) -> Tuple[bool, Any, Dict[str, str]]:
|
||
monitor_page = self._select_monitor_page(context, fallback_page)
|
||
cookie_dict = self._snapshot_context_cookies(context)
|
||
|
||
if monitor_page:
|
||
try:
|
||
if self._check_login_success_by_element(monitor_page):
|
||
logger.success(f"【{self.pure_user_id}】✅ 当前监控页面已确认登录成功")
|
||
return True, monitor_page, cookie_dict
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查监控页面登录状态失败: {e}")
|
||
|
||
if not self._has_completed_login_cookies(cookie_dict):
|
||
return False, monitor_page, cookie_dict
|
||
|
||
if monitor_page:
|
||
current_url = self._safe_page_url(monitor_page)
|
||
if self._is_logged_in_url(current_url) and not self._page_looks_like_verification(monitor_page):
|
||
logger.success(
|
||
f"【{self.pure_user_id}】✅ 检测到上下文已登录,当前URL: {current_url}"
|
||
)
|
||
return True, monitor_page, cookie_dict
|
||
|
||
probe_page = None
|
||
try:
|
||
probe_page = context.new_page()
|
||
probe_page.goto('https://www.goofish.com/im', wait_until='domcontentloaded', timeout=30000)
|
||
time.sleep(1.5)
|
||
|
||
probe_cookies = self._snapshot_context_cookies(context)
|
||
probe_url = self._safe_page_url(probe_page)
|
||
if self._check_login_success_by_element(probe_page):
|
||
logger.success(f"【{self.pure_user_id}】✅ 通过探测页面确认登录成功")
|
||
return True, monitor_page or fallback_page, probe_cookies
|
||
|
||
if self._has_completed_login_cookies(probe_cookies) and self._is_logged_in_url(probe_url):
|
||
logger.success(f"【{self.pure_user_id}】✅ 通过探测页面URL和Cookie确认登录成功")
|
||
return True, monitor_page or fallback_page, probe_cookies
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】探测上下文登录状态失败: {e}")
|
||
finally:
|
||
if probe_page:
|
||
try:
|
||
probe_page.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return False, monitor_page, cookie_dict
|
||
|
||
def _recover_from_missing_login_inputs(
|
||
self,
|
||
context,
|
||
page,
|
||
*,
|
||
missing_field: str,
|
||
notification_callback: Optional[Callable] = None,
|
||
notification_scene: str = '账号密码登录',
|
||
) -> Tuple[bool, Any]:
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】未找到{missing_field},复检当前页面是否处于已登录态或验证页..."
|
||
)
|
||
|
||
login_success, active_page, _ = self._probe_context_login_success(context, page)
|
||
if login_success:
|
||
cookies_dict = self._snapshot_context_cookies(context)
|
||
logger.info(f"【{self.pure_user_id}】复检已登录成功,Cookie字段数: {len(cookies_dict)}")
|
||
if cookies_dict:
|
||
self._log_cookie_snapshot_integrity(cookies_dict, f"{missing_field}复检已登录场景")
|
||
logger.success(f"【{self.pure_user_id}】✅ 页面实际已登录,停止继续账密输入")
|
||
return True, cookies_dict
|
||
|
||
logger.error(f"【{self.pure_user_id}】❌ 复检已登录后仍未获取到有效Cookie")
|
||
return True, self._fail_login("复检已登录后未获取到有效Cookie")
|
||
|
||
monitor_page = self._select_monitor_page(context, active_page or page) or active_page or page
|
||
if monitor_page:
|
||
has_qr, qr_frame = self._detect_qr_code_verification(monitor_page)
|
||
if has_qr:
|
||
logger.info(f"【{self.pure_user_id}】复检发现当前页面需要人工验证,转入验证流程")
|
||
return True, self._process_verification_requirement(
|
||
context,
|
||
monitor_page,
|
||
qr_frame,
|
||
notification_callback,
|
||
notification_scene,
|
||
)
|
||
|
||
return False, None
|
||
|
||
def _page_has_slider(self, page) -> bool:
|
||
if not page:
|
||
return False
|
||
|
||
slider_selectors = [
|
||
'#nc_1_n1z',
|
||
'.nc-container',
|
||
'.nc_scale',
|
||
'.nc-wrapper',
|
||
'#baxia-dialog-content',
|
||
'.nc_wrapper',
|
||
'#nocaptcha',
|
||
]
|
||
|
||
frames_to_check = [page]
|
||
try:
|
||
frames_to_check.extend(list(page.frames))
|
||
except Exception:
|
||
pass
|
||
|
||
for frame in frames_to_check:
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】检测到滑块元素: {selector}")
|
||
return True
|
||
except Exception:
|
||
continue
|
||
|
||
return False
|
||
|
||
def _attempt_solve_slider_on_page(self, page) -> bool:
|
||
if not page or not self._page_has_slider(page):
|
||
return False
|
||
|
||
logger.info(f"【{self.pure_user_id}】在当前活动页面检测到滑块,尝试自动处理...")
|
||
original_page = self.page
|
||
try:
|
||
self.page = page
|
||
solved = self.solve_slider(max_retries=3, fast_mode=True)
|
||
if solved:
|
||
logger.success(f"【{self.pure_user_id}】✅ 当前活动页面滑块处理成功")
|
||
time.sleep(2)
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 当前活动页面滑块处理未成功")
|
||
return solved
|
||
finally:
|
||
self.page = original_page
|
||
|
||
def _cleanup_verification_screenshots(self):
|
||
try:
|
||
import glob
|
||
|
||
screenshots_dir = 'static/uploads/images'
|
||
all_screenshots = glob.glob(os.path.join(screenshots_dir, f'face_verify_{self.pure_user_id}_*.jpg'))
|
||
all_screenshots += glob.glob(os.path.join(screenshots_dir, f'face_verify_{self.pure_user_id}_*.png'))
|
||
for screenshot_file in all_screenshots:
|
||
try:
|
||
if os.path.exists(screenshot_file):
|
||
os.remove(screenshot_file)
|
||
logger.info(f"【{self.pure_user_id}】✅ 已删除验证截图: {screenshot_file}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 删除截图失败: {e}")
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】删除截图时出错: {e}")
|
||
|
||
def _wait_for_context_login(self, context, fallback_page, max_wait_time: int = 450, check_interval: int = 10) -> Tuple[bool, Any]:
|
||
waited_time = 0
|
||
monitor_page = fallback_page
|
||
|
||
while waited_time < max_wait_time:
|
||
monitor_page = self._select_monitor_page(context, monitor_page)
|
||
self._attempt_solve_slider_on_page(monitor_page)
|
||
|
||
login_success, success_page, _ = self._probe_context_login_success(context, monitor_page)
|
||
if login_success:
|
||
return True, success_page or monitor_page
|
||
|
||
time.sleep(check_interval)
|
||
waited_time += check_interval
|
||
logger.info(f"【{self.pure_user_id}】等待验证中... (已等待{waited_time}秒/{max_wait_time}秒)")
|
||
|
||
return False, self._select_monitor_page(context, monitor_page)
|
||
|
||
def _notify_verification_required(
|
||
self,
|
||
verification_type: str,
|
||
frame_url: Optional[str],
|
||
screenshot_path: Optional[str],
|
||
notification_callback: Optional[Callable],
|
||
notification_scene: str,
|
||
):
|
||
if not notification_callback or not (screenshot_path or frame_url):
|
||
if not notification_callback:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ notification_callback 未提供,无法发送通知")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】无法获取验证信息,跳过通知发送")
|
||
return
|
||
|
||
verification_type_titles = {
|
||
'face_verify': f'⚠️ {notification_scene}需要人脸验证',
|
||
'sms_verify': f'⚠️ {notification_scene}需要短信验证',
|
||
'qr_verify': f'⚠️ {notification_scene}需要二维码验证',
|
||
'unknown': f'⚠️ {notification_scene}需要身份验证',
|
||
}
|
||
title = verification_type_titles.get(verification_type, f'⚠️ {notification_scene}需要身份验证')
|
||
|
||
if screenshot_path:
|
||
notification_msg = (
|
||
f"{title}\n\n"
|
||
f"账号: {self.pure_user_id}\n"
|
||
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||
f"请登录自动化网站,访问账号管理模块,进行对应账号的验证。"
|
||
f"在验证期间,自动回复功能暂时无法使用。"
|
||
)
|
||
else:
|
||
notification_msg = (
|
||
f"{title}\n\n"
|
||
f"账号: {self.pure_user_id}\n"
|
||
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||
f"请点击验证链接完成验证:\n{frame_url}\n\n"
|
||
f"在验证期间,自动回复功能暂时无法使用。"
|
||
)
|
||
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】准备发送验证通知,截图路径: {screenshot_path}, URL: {frame_url}")
|
||
import inspect
|
||
|
||
if inspect.iscoroutinefunction(notification_callback):
|
||
def run_async_callback():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
try:
|
||
loop.run_until_complete(
|
||
notification_callback(
|
||
notification_msg,
|
||
screenshot_path,
|
||
frame_url,
|
||
verification_type=verification_type,
|
||
)
|
||
)
|
||
except TypeError:
|
||
loop.run_until_complete(notification_callback(notification_msg, screenshot_path, frame_url))
|
||
logger.info(f"【{self.pure_user_id}】✅ 异步通知回调已执行")
|
||
except Exception as async_err:
|
||
logger.error(f"【{self.pure_user_id}】异步通知回调执行失败: {async_err}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
finally:
|
||
loop.close()
|
||
|
||
thread = threading.Thread(target=run_async_callback, daemon=True)
|
||
thread.start()
|
||
logger.info(f"【{self.pure_user_id}】异步通知线程已启动")
|
||
else:
|
||
try:
|
||
notification_callback(
|
||
notification_msg,
|
||
None,
|
||
frame_url,
|
||
screenshot_path,
|
||
verification_type=verification_type,
|
||
)
|
||
except TypeError:
|
||
notification_callback(notification_msg, None, frame_url, screenshot_path)
|
||
logger.info(f"【{self.pure_user_id}】✅ 同步通知回调已执行")
|
||
except Exception as notify_err:
|
||
logger.error(f"【{self.pure_user_id}】发送验证通知失败: {notify_err}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
def _process_verification_requirement(
|
||
self,
|
||
context,
|
||
fallback_page,
|
||
qr_frame,
|
||
notification_callback: Optional[Callable] = None,
|
||
notification_scene: str = '账号密码登录',
|
||
):
|
||
verification_type = 'unknown'
|
||
if qr_frame and hasattr(qr_frame, 'verification_type'):
|
||
verification_type = qr_frame.verification_type
|
||
|
||
verification_type_names = {
|
||
'face_verify': '人脸验证',
|
||
'sms_verify': '短信验证',
|
||
'qr_verify': '二维码验证',
|
||
'unknown': '身份验证',
|
||
}
|
||
type_name = verification_type_names.get(verification_type, '身份验证')
|
||
|
||
frame_url = None
|
||
screenshot_path = None
|
||
if qr_frame:
|
||
try:
|
||
if hasattr(qr_frame, 'verify_url') and qr_frame.verify_url:
|
||
frame_url = qr_frame.verify_url
|
||
else:
|
||
frame_url = qr_frame.url if hasattr(qr_frame, 'url') else None
|
||
|
||
if hasattr(qr_frame, 'screenshot_path') and qr_frame.screenshot_path:
|
||
screenshot_path = qr_frame.screenshot_path
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】获取验证信息失败: {e}")
|
||
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 检测到{type_name}")
|
||
logger.info(f"【{self.pure_user_id}】请在浏览器中完成{type_name}")
|
||
|
||
if screenshot_path:
|
||
logger.warning(f"【{self.pure_user_id}】{'=' * 60}")
|
||
logger.warning(f"【{self.pure_user_id}】二维码/人脸验证截图:")
|
||
logger.warning(f"【{self.pure_user_id}】{screenshot_path}")
|
||
logger.warning(f"【{self.pure_user_id}】{'=' * 60}")
|
||
elif frame_url:
|
||
logger.warning(f"【{self.pure_user_id}】{'=' * 60}")
|
||
logger.warning(f"【{self.pure_user_id}】二维码/人脸验证链接:")
|
||
logger.warning(f"【{self.pure_user_id}】{frame_url}")
|
||
logger.warning(f"【{self.pure_user_id}】{'=' * 60}")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】{'=' * 60}")
|
||
logger.warning(f"【{self.pure_user_id}】二维码/人脸验证已检测到,但无法获取验证信息")
|
||
logger.warning(f"【{self.pure_user_id}】请在浏览器中查看验证页面")
|
||
logger.warning(f"【{self.pure_user_id}】{'=' * 60}")
|
||
|
||
self._notify_verification_required(
|
||
verification_type,
|
||
frame_url,
|
||
screenshot_path,
|
||
notification_callback,
|
||
notification_scene,
|
||
)
|
||
|
||
logger.info(f"【{self.pure_user_id}】等待二维码/人脸验证完成...")
|
||
login_success = False
|
||
try:
|
||
login_success, _ = self._wait_for_context_login(context, fallback_page, max_wait_time=450, check_interval=10)
|
||
finally:
|
||
self._cleanup_verification_screenshots()
|
||
|
||
if not login_success:
|
||
logger.error(f"【{self.pure_user_id}】❌ 等待验证超时(450秒)")
|
||
return self._fail_login(f"等待{type_name}超时(450秒)")
|
||
|
||
logger.success(f"【{self.pure_user_id}】✅ 验证成功,登录状态已确认!")
|
||
cookies_dict = self._snapshot_context_cookies(context)
|
||
if cookies_dict:
|
||
logger.success(f"【{self.pure_user_id}】✅ 验证后获取Cookie成功,{len(cookies_dict)}个字段")
|
||
self._log_cookie_snapshot_integrity(cookies_dict, "验证完成后")
|
||
return cookies_dict
|
||
|
||
logger.error(f"【{self.pure_user_id}】❌ 验证成功后未获取到Cookie")
|
||
return self._fail_login("验证成功后未获取到Cookie")
|
||
|
||
def _has_meaningful_cookie_refresh(self, baseline: Dict[str, str], current: Dict[str, str]) -> bool:
|
||
"""判断关键 Cookie 是否发生了有意义的变化。
|
||
|
||
判定逻辑(满足其一即可):
|
||
1. 任何 x5 系 Cookie 的值发生了变化或新增
|
||
2. 关键会话 Cookie 的值发生了变化或新增
|
||
"""
|
||
# 检查 x5 系 Cookie
|
||
for name, value in current.items():
|
||
if name.lower().startswith(self._X5_COOKIE_PREFIX):
|
||
old_value = baseline.get(name)
|
||
if old_value is None or old_value != value:
|
||
logger.info(f"【{self.pure_user_id}】Cookie 刷新检测: x5 系 Cookie '{name}' 已变化")
|
||
return True
|
||
|
||
# 检查关键会话 Cookie
|
||
for name in self._KEY_COOKIE_NAMES:
|
||
new_val = current.get(name)
|
||
if new_val is not None:
|
||
old_val = baseline.get(name)
|
||
if old_val is None or old_val != new_val:
|
||
logger.info(f"【{self.pure_user_id}】Cookie 刷新检测: 关键会话 Cookie '{name}' 已变化")
|
||
return True
|
||
|
||
logger.warning(f"【{self.pure_user_id}】Cookie 刷新检测: 无有意义的 Cookie 变化")
|
||
return False
|
||
|
||
def _probe_context_login_during_slider(self, fallback_page=None) -> Tuple[bool, Dict[str, str]]:
|
||
"""刷新模式下,允许用 context 级登录态确认滑块已间接通过。"""
|
||
if not getattr(self, '_slider_refresh_mode', False):
|
||
return False, {}
|
||
|
||
if not self.context:
|
||
return False, {}
|
||
|
||
try:
|
||
login_success, _, cookies = self._probe_context_login_success(self.context, fallback_page or self.page)
|
||
if login_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块阶段检测到上下文已登录,停止继续重试")
|
||
self.last_verification_feedback = {
|
||
"status": "success",
|
||
"source": "context_login_confirmed",
|
||
"message": "上下文登录状态已确认"
|
||
}
|
||
return True, cookies or {}
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】滑块阶段探测上下文登录状态失败: {e}")
|
||
|
||
return False, {}
|
||
|
||
def _get_random_browser_features(self):
|
||
"""获取稳定浏览器特征。
|
||
|
||
同一账号长期复用同一套桌面画像,避免后台无头链路在每次重启后漂移成
|
||
不同设备,降低风控对“同账号多台机器来回切换”的判定概率。
|
||
"""
|
||
BROWSER_PROFILES = [
|
||
# Windows Chrome 120 - 高配台式机
|
||
{
|
||
'profile_id': 'win_chrome_120_desktop',
|
||
'user_agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
'platform': 'Win32',
|
||
'vendor': 'Google Inc.',
|
||
'window_size': '1920,1080',
|
||
'device_memory': 16,
|
||
'hardware_concurrency': 8,
|
||
'max_touch_points': 0,
|
||
'device_scale_factor': 1.0,
|
||
'color_depth': 24,
|
||
},
|
||
# Windows Chrome 120 - 中配笔记本
|
||
{
|
||
'profile_id': 'win_chrome_120_laptop',
|
||
'user_agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
'platform': 'Win32',
|
||
'vendor': 'Google Inc.',
|
||
'window_size': '1366,768',
|
||
'device_memory': 8,
|
||
'hardware_concurrency': 4,
|
||
'max_touch_points': 0,
|
||
'device_scale_factor': 1.25,
|
||
'color_depth': 24,
|
||
},
|
||
# Windows Chrome 119 - 高配台式机
|
||
{
|
||
'profile_id': 'win_chrome_119_desktop',
|
||
'user_agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||
'platform': 'Win32',
|
||
'vendor': 'Google Inc.',
|
||
'window_size': '1920,1200',
|
||
'device_memory': 8,
|
||
'hardware_concurrency': 6,
|
||
'max_touch_points': 0,
|
||
'device_scale_factor': 1.0,
|
||
'color_depth': 24,
|
||
},
|
||
# Windows Chrome 118 - 标准台式机
|
||
{
|
||
'profile_id': 'win_chrome_118_standard',
|
||
'user_agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
|
||
'platform': 'Win32',
|
||
'vendor': 'Google Inc.',
|
||
'window_size': '1600,900',
|
||
'device_memory': 8,
|
||
'hardware_concurrency': 4,
|
||
'max_touch_points': 0,
|
||
'device_scale_factor': 1.0,
|
||
'color_depth': 24,
|
||
},
|
||
# Mac Chrome 120 - MacBook Pro
|
||
{
|
||
'profile_id': 'mac_chrome_120_pro',
|
||
'user_agent': "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||
'platform': 'MacIntel',
|
||
'vendor': 'Google Inc.',
|
||
'window_size': '2560,1440',
|
||
'device_memory': 16,
|
||
'hardware_concurrency': 10,
|
||
'max_touch_points': 0,
|
||
'device_scale_factor': 2.0,
|
||
'color_depth': 30,
|
||
},
|
||
# Mac Chrome 119 - MacBook Air
|
||
{
|
||
'profile_id': 'mac_chrome_119_air',
|
||
'user_agent': "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||
'platform': 'MacIntel',
|
||
'vendor': 'Google Inc.',
|
||
'window_size': '1920,1080',
|
||
'device_memory': 8,
|
||
'hardware_concurrency': 8,
|
||
'max_touch_points': 0,
|
||
'device_scale_factor': 2.0,
|
||
'color_depth': 30,
|
||
},
|
||
]
|
||
|
||
languages = [
|
||
("zh-CN", "zh-CN,zh;q=0.9,en;q=0.8"),
|
||
("zh-CN", "zh-CN,zh;q=0.9"),
|
||
("zh-CN", "zh-CN,zh;q=0.8,en;q=0.6")
|
||
]
|
||
|
||
identity = self._load_or_create_browser_identity(len(BROWSER_PROFILES), len(languages))
|
||
|
||
profile = BROWSER_PROFILES[identity["profile_index"]]
|
||
lang, accept_lang = languages[identity["language_index"]]
|
||
|
||
# 解析窗口大小
|
||
width, height = map(int, profile['window_size'].split(','))
|
||
|
||
# 网络特征(桌面端只用 4g,rtt/downlink 在合理范围内随机)
|
||
connection_rtt = random.randint(20, 80)
|
||
connection_downlink = round(random.uniform(3, 10), 2)
|
||
|
||
return {
|
||
'profile_id': profile['profile_id'],
|
||
'window_size': profile['window_size'],
|
||
'lang': lang,
|
||
'accept_lang': accept_lang,
|
||
'user_agent': profile['user_agent'],
|
||
'locale': lang,
|
||
'viewport_width': width,
|
||
'viewport_height': height,
|
||
'device_scale_factor': profile['device_scale_factor'],
|
||
'is_mobile': False,
|
||
'has_touch': False,
|
||
'timezone_id': 'Asia/Shanghai',
|
||
# 一致性指纹字段(与 UA 对应)
|
||
'platform': profile['platform'],
|
||
'vendor': profile['vendor'],
|
||
'device_memory': profile['device_memory'],
|
||
'hardware_concurrency': profile['hardware_concurrency'],
|
||
'max_touch_points': profile['max_touch_points'],
|
||
'color_depth': profile['color_depth'],
|
||
'connection_type': '4g',
|
||
'connection_rtt': connection_rtt,
|
||
'connection_downlink': connection_downlink,
|
||
'color_scheme': identity.get('color_scheme', 'light'),
|
||
'plugin_count': identity.get('plugin_count', 5),
|
||
'notification_permission': identity.get('notification_permission', 'default'),
|
||
'do_not_track': identity.get('do_not_track', '0'),
|
||
'battery_charging': identity.get('battery_charging', True),
|
||
'battery_level': identity.get('battery_level', 0.76),
|
||
}
|
||
|
||
def _get_stealth_script(self, browser_features):
|
||
"""获取增强反检测脚本"""
|
||
return f"""
|
||
// 隐藏webdriver属性
|
||
Object.defineProperty(navigator, 'webdriver', {{
|
||
get: () => undefined,
|
||
}});
|
||
|
||
// 隐藏自动化相关属性
|
||
delete navigator.__proto__.webdriver;
|
||
delete window.navigator.webdriver;
|
||
delete window.navigator.__proto__.webdriver;
|
||
|
||
// 覆盖plugins - 随机化
|
||
const pluginCount = {browser_features['plugin_count']};
|
||
Object.defineProperty(navigator, 'plugins', {{
|
||
get: () => Array.from({{length: pluginCount}}, (_, i) => ({{
|
||
name: 'Plugin' + i,
|
||
description: 'Plugin ' + i
|
||
}})),
|
||
}});
|
||
|
||
// 覆盖languages
|
||
Object.defineProperty(navigator, 'languages', {{
|
||
get: () => ['{browser_features['locale']}', 'zh', 'en'],
|
||
}});
|
||
|
||
// 模拟真实的屏幕信息 - 使用 Profile 一致值
|
||
Object.defineProperty(screen, 'availWidth', {{ get: () => {browser_features['viewport_width']} }});
|
||
Object.defineProperty(screen, 'availHeight', {{ get: () => {browser_features['viewport_height'] - 40} }});
|
||
Object.defineProperty(screen, 'width', {{ get: () => {browser_features['viewport_width']} }});
|
||
Object.defineProperty(screen, 'height', {{ get: () => {browser_features['viewport_height']} }});
|
||
Object.defineProperty(screen, 'colorDepth', {{ get: () => {browser_features['color_depth']} }});
|
||
Object.defineProperty(screen, 'pixelDepth', {{ get: () => {browser_features['color_depth']} }});
|
||
|
||
// 隐藏自动化检测 - 使用 Profile 一致的硬件信息
|
||
Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => {browser_features['hardware_concurrency']} }});
|
||
Object.defineProperty(navigator, 'deviceMemory', {{ get: () => {browser_features['device_memory']} }});
|
||
|
||
// 模拟真实的时区
|
||
Object.defineProperty(Intl.DateTimeFormat.prototype, 'resolvedOptions', {{
|
||
value: function() {{
|
||
return {{ timeZone: '{browser_features['timezone_id']}' }};
|
||
}}
|
||
}});
|
||
|
||
// 隐藏自动化痕迹
|
||
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
|
||
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
|
||
delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
|
||
|
||
// 模拟有头模式的特征 - 使用 Profile 一致值
|
||
Object.defineProperty(navigator, 'maxTouchPoints', {{ get: () => {browser_features['max_touch_points']} }});
|
||
Object.defineProperty(navigator, 'platform', {{ get: () => '{browser_features['platform']}' }});
|
||
Object.defineProperty(navigator, 'vendor', {{ get: () => '{browser_features['vendor']}' }});
|
||
Object.defineProperty(navigator, 'vendorSub', {{ get: () => '' }});
|
||
Object.defineProperty(navigator, 'productSub', {{ get: () => '20030107' }});
|
||
|
||
// 模拟真实的连接信息 - 使用 Profile 一致值
|
||
Object.defineProperty(navigator, 'connection', {{
|
||
get: () => ({{
|
||
effectiveType: "{browser_features['connection_type']}",
|
||
rtt: {browser_features['connection_rtt']},
|
||
downlink: {browser_features['connection_downlink']}
|
||
}})
|
||
}});
|
||
|
||
// 隐藏无头模式特征
|
||
Object.defineProperty(navigator, 'headless', {{ get: () => undefined }});
|
||
Object.defineProperty(window, 'outerHeight', {{ get: () => {browser_features['viewport_height']} }});
|
||
Object.defineProperty(window, 'outerWidth', {{ get: () => {browser_features['viewport_width']} }});
|
||
|
||
// 模拟真实的媒体设备
|
||
Object.defineProperty(navigator, 'mediaDevices', {{
|
||
get: () => ({{
|
||
enumerateDevices: () => Promise.resolve([])
|
||
}}),
|
||
}});
|
||
|
||
// 隐藏自动化检测特征
|
||
Object.defineProperty(navigator, 'webdriver', {{ get: () => undefined }});
|
||
Object.defineProperty(navigator, '__webdriver_script_fn', {{ get: () => undefined }});
|
||
Object.defineProperty(navigator, '__webdriver_evaluate', {{ get: () => undefined }});
|
||
Object.defineProperty(navigator, '__webdriver_unwrapped', {{ get: () => undefined }});
|
||
Object.defineProperty(navigator, '__fxdriver_evaluate', {{ get: () => undefined }});
|
||
Object.defineProperty(navigator, '__driver_evaluate', {{ get: () => undefined }});
|
||
Object.defineProperty(navigator, '__webdriver_script_func', {{ get: () => undefined }});
|
||
|
||
// 隐藏Playwright特定的对象
|
||
delete window.playwright;
|
||
delete window.__playwright;
|
||
delete window.__pw_manual;
|
||
delete window.__pw_original;
|
||
|
||
// 模拟真实的用户代理
|
||
Object.defineProperty(navigator, 'userAgent', {{
|
||
get: () => '{browser_features['user_agent']}'
|
||
}});
|
||
|
||
// 隐藏自动化相关的全局变量
|
||
delete window.webdriver;
|
||
delete window.__webdriver_script_fn;
|
||
delete window.__webdriver_evaluate;
|
||
delete window.__webdriver_unwrapped;
|
||
delete window.__fxdriver_evaluate;
|
||
delete window.__driver_evaluate;
|
||
delete window.__webdriver_script_func;
|
||
delete window._selenium;
|
||
delete window._phantom;
|
||
delete window.callPhantom;
|
||
delete window._phantom;
|
||
delete window.phantom;
|
||
delete window.Buffer;
|
||
delete window.emit;
|
||
delete window.spawn;
|
||
|
||
// Canvas指纹随机化
|
||
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
||
HTMLCanvasElement.prototype.toDataURL = function() {{
|
||
const context = this.getContext('2d');
|
||
if (context) {{
|
||
const imageData = context.getImageData(0, 0, this.width, this.height);
|
||
const data = imageData.data;
|
||
for (let i = 0; i < data.length; i += 4) {{
|
||
if (Math.random() < 0.001) {{
|
||
data[i] = Math.floor(Math.random() * 256);
|
||
}}
|
||
}}
|
||
context.putImageData(imageData, 0, 0);
|
||
}}
|
||
return originalToDataURL.apply(this, arguments);
|
||
}};
|
||
|
||
// 音频指纹随机化
|
||
const originalGetChannelData = AudioBuffer.prototype.getChannelData;
|
||
AudioBuffer.prototype.getChannelData = function(channel) {{
|
||
const data = originalGetChannelData.call(this, channel);
|
||
for (let i = 0; i < data.length; i += 1000) {{
|
||
if (Math.random() < 0.01) {{
|
||
data[i] += Math.random() * 0.0001;
|
||
}}
|
||
}}
|
||
return data;
|
||
}};
|
||
|
||
// WebGL指纹随机化
|
||
const originalGetParameter = WebGLRenderingContext.prototype.getParameter;
|
||
WebGLRenderingContext.prototype.getParameter = function(parameter) {{
|
||
if (parameter === 37445) {{ // UNMASKED_VENDOR_WEBGL
|
||
return 'Intel Inc.';
|
||
}}
|
||
if (parameter === 37446) {{ // UNMASKED_RENDERER_WEBGL
|
||
return 'Intel Iris OpenGL Engine';
|
||
}}
|
||
return originalGetParameter.call(this, parameter);
|
||
}};
|
||
|
||
// 模拟真实的鼠标事件
|
||
const originalAddEventListener = EventTarget.prototype.addEventListener;
|
||
EventTarget.prototype.addEventListener = function(type, listener, options) {{
|
||
if (type === 'mousedown' || type === 'mouseup' || type === 'mousemove') {{
|
||
const originalListener = listener;
|
||
listener = function(event) {{
|
||
setTimeout(() => originalListener.call(this, event), Math.random() * 10);
|
||
}};
|
||
}}
|
||
return originalAddEventListener.call(this, type, listener, options);
|
||
}};
|
||
|
||
// 随机化字体检测
|
||
Object.defineProperty(document, 'fonts', {{
|
||
get: () => ({{
|
||
ready: Promise.resolve(),
|
||
check: () => true,
|
||
load: () => Promise.resolve([])
|
||
}})
|
||
}});
|
||
|
||
// 增强鼠标移动轨迹记录
|
||
let mouseMovements = [];
|
||
let lastMouseTime = Date.now();
|
||
document.addEventListener('mousemove', function(e) {{
|
||
const now = Date.now();
|
||
const timeDiff = now - lastMouseTime;
|
||
mouseMovements.push({{
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
time: now,
|
||
timeDiff: timeDiff
|
||
}});
|
||
lastMouseTime = now;
|
||
// 保持最近100个移动记录
|
||
if (mouseMovements.length > 100) {{
|
||
mouseMovements.shift();
|
||
}}
|
||
}}, true);
|
||
|
||
// 模拟真实的电池API
|
||
if (navigator.getBattery) {{
|
||
const originalGetBattery = navigator.getBattery;
|
||
navigator.getBattery = async function() {{
|
||
const battery = await originalGetBattery.call(navigator);
|
||
Object.defineProperty(battery, 'charging', {{ get: () => {str(browser_features['battery_charging']).lower()} }});
|
||
Object.defineProperty(battery, 'level', {{ get: () => {browser_features['battery_level']:.2f} }});
|
||
return battery;
|
||
}};
|
||
}}
|
||
|
||
// 伪装鼠标移动加速度(反检测关键)
|
||
let velocityProfile = [];
|
||
window.addEventListener('mousemove', function(e) {{
|
||
const now = performance.now();
|
||
velocityProfile.push({{ x: e.clientX, y: e.clientY, t: now }});
|
||
if (velocityProfile.length > 50) velocityProfile.shift();
|
||
}}, true);
|
||
|
||
// 伪装Permission API
|
||
const originalQuery = Permissions.prototype.query;
|
||
Permissions.prototype.query = function(parameters) {{
|
||
if (parameters.name === 'notifications') {{
|
||
return Promise.resolve({{ state: '{browser_features['notification_permission']}' }});
|
||
}}
|
||
return originalQuery.apply(this, arguments);
|
||
}};
|
||
|
||
// 伪装Performance API
|
||
const originalNow = Performance.prototype.now;
|
||
Performance.prototype.now = function() {{
|
||
return originalNow.call(this) + Math.random() * 0.1;
|
||
}};
|
||
|
||
// 伪装Date API(添加微小随机偏移)
|
||
const OriginalDate = Date;
|
||
Date = function(...args) {{
|
||
if (args.length === 0) {{
|
||
const date = new OriginalDate();
|
||
const offset = Math.floor(Math.random() * 3) - 1; // -1到1毫秒
|
||
return new OriginalDate(date.getTime() + offset);
|
||
}}
|
||
return new OriginalDate(...args);
|
||
}};
|
||
Date.prototype = OriginalDate.prototype;
|
||
Date.now = function() {{
|
||
return OriginalDate.now() + Math.floor(Math.random() * 3) - 1;
|
||
}};
|
||
|
||
// 伪装RTCPeerConnection(WebRTC指纹)
|
||
if (window.RTCPeerConnection) {{
|
||
const originalRTC = window.RTCPeerConnection;
|
||
window.RTCPeerConnection = function(...args) {{
|
||
const pc = new originalRTC(...args);
|
||
const originalCreateOffer = pc.createOffer;
|
||
pc.createOffer = function(...args) {{
|
||
return originalCreateOffer.apply(this, args).then(offer => {{
|
||
// 修改SDP指纹
|
||
offer.sdp = offer.sdp.replace(/a=fingerprint:.*\\r\\n/g,
|
||
`a=fingerprint:sha-256 ${{Array.from({{length:64}}, ()=>Math.floor(Math.random()*16).toString(16)).join('')}}\\r\\n`);
|
||
return offer;
|
||
}});
|
||
}};
|
||
return pc;
|
||
}};
|
||
}}
|
||
|
||
// 伪装 Notification 权限(防止被检测为自动化)
|
||
Object.defineProperty(Notification, 'permission', {{
|
||
get: function() {{
|
||
return '{browser_features['notification_permission']}';
|
||
}}
|
||
}});
|
||
|
||
// 伪装 DoNotTrack
|
||
Object.defineProperty(navigator, 'doNotTrack', {{
|
||
get: function() {{
|
||
return '{browser_features['do_not_track']}';
|
||
}}
|
||
}});
|
||
|
||
// 伪装 Geolocation(添加微小延迟和误差)
|
||
if (navigator.geolocation) {{
|
||
const originalGetCurrentPosition = navigator.geolocation.getCurrentPosition;
|
||
navigator.geolocation.getCurrentPosition = function(success, error, options) {{
|
||
const wrappedSuccess = function(position) {{
|
||
// 添加微小的位置偏移(模拟真实GPS误差)
|
||
const offset = Math.random() * 0.001;
|
||
position.coords.latitude += offset;
|
||
position.coords.longitude += offset;
|
||
success(position);
|
||
}};
|
||
// 添加随机延迟
|
||
setTimeout(() => {{
|
||
originalGetCurrentPosition.call(this, wrappedSuccess, error, options);
|
||
}}, Math.random() * 100);
|
||
}};
|
||
}}
|
||
|
||
// 伪装 Clipboard API(防止检测剪贴板访问模式)
|
||
if (navigator.clipboard) {{
|
||
const originalReadText = navigator.clipboard.readText;
|
||
navigator.clipboard.readText = async function() {{
|
||
// 添加微小延迟
|
||
await new Promise(resolve => setTimeout(resolve, Math.random() * 50));
|
||
return originalReadText.call(this);
|
||
}};
|
||
}}
|
||
|
||
// 🔑 伪装chrome对象(统一定义,防止检测headless)
|
||
window.chrome = {{
|
||
runtime: {{
|
||
id: undefined,
|
||
sendMessage: function() {{}},
|
||
connect: function() {{}}
|
||
}},
|
||
loadTimes: function() {{}},
|
||
csi: function() {{}},
|
||
app: {{}}
|
||
}};
|
||
|
||
// 🔑 覆盖Function.prototype.toString以隐藏代理
|
||
const oldToString = Function.prototype.toString;
|
||
Function.prototype.toString = function() {{
|
||
if (this === navigator.permissions.query) {{
|
||
return 'function query() {{ [native code] }}';
|
||
}}
|
||
return oldToString.call(this);
|
||
}};
|
||
"""
|
||
|
||
def _bezier_curve(self, p0, p1, p2, p3, t):
|
||
"""三次贝塞尔曲线 - 生成更自然的轨迹"""
|
||
return (1-t)**3 * p0 + 3*(1-t)**2*t * p1 + 3*(1-t)*t**2 * p2 + t**3 * p3
|
||
|
||
def _easing_function(self, t, mode='easeOutQuad'):
|
||
"""缓动函数 - 模拟真实人类滑动的速度变化"""
|
||
if mode == 'easeOutQuad':
|
||
return t * (2 - t)
|
||
elif mode == 'easeInOutCubic':
|
||
return 4*t**3 if t < 0.5 else 1 - pow(-2*t + 2, 3) / 2
|
||
elif mode == 'easeOutBack':
|
||
c1 = 1.70158
|
||
c3 = c1 + 1
|
||
return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)
|
||
else:
|
||
return t
|
||
|
||
def _generate_physics_trajectory(self, distance: float):
|
||
"""基于物理加速度模型生成轨迹 - 极速模式(增强随机性)
|
||
|
||
优化策略:
|
||
1. 极少轨迹点(5-8步):快速完成
|
||
2. 持续加速:一气呵成,不减速
|
||
3. 确保超调50%以上:保证滑动到位
|
||
4. 无回退:单向滑动
|
||
5. 每次都有随机变化:步数、速度、曲线都随机
|
||
|
||
注意:此方法已被参数化版本取代,保留用于兼容性
|
||
"""
|
||
# 生成随机参数
|
||
overshoot_ratio = random.uniform(2.0, 2.2)
|
||
steps = random.randint(5, 8)
|
||
base_delay = random.uniform(0.0002, 0.0006)
|
||
acceleration_curve = random.uniform(1.3, 1.8)
|
||
y_jitter_max = random.uniform(1, 3)
|
||
|
||
# 调用参数化版本
|
||
return self._generate_physics_trajectory_with_params(
|
||
distance, overshoot_ratio, steps, base_delay,
|
||
acceleration_curve, y_jitter_max
|
||
)
|
||
|
||
def _get_effective_learning_ranges(self, optimized_params: Dict[str, Any]) -> Dict[str, Tuple[float, float]]:
|
||
"""统一整理学习参数边界,确保不同重试分支使用一致口径"""
|
||
bounds = ML_STRATEGY_CONFIG.get("learning_bounds", {})
|
||
|
||
learned_overshoot = optimized_params.get("learned_overshoot_range", (1.03, 1.08))
|
||
learned_overshoot = (
|
||
max(bounds.get("min_overshoot_ratio", 1.01), learned_overshoot[0]),
|
||
min(bounds.get("max_overshoot_ratio", 1.15), learned_overshoot[1])
|
||
)
|
||
|
||
learned_delay = optimized_params.get("learned_delay_range", (0.006, 0.012))
|
||
learned_curve = optimized_params.get("learned_curve_range", (1.6, 2.0))
|
||
|
||
learned_jitter = optimized_params.get("learned_jitter_range", (1.5, 2.2))
|
||
learned_jitter = (
|
||
max(bounds.get("min_y_jitter", 1.0), learned_jitter[0]),
|
||
min(bounds.get("max_y_jitter", 3.0), learned_jitter[1])
|
||
)
|
||
|
||
learned_steps = optimized_params.get("learned_steps_range", (22, 30))
|
||
|
||
return {
|
||
"overshoot": learned_overshoot,
|
||
"delay": learned_delay,
|
||
"curve": learned_curve,
|
||
"jitter": learned_jitter,
|
||
"steps": learned_steps,
|
||
"bounds": bounds,
|
||
}
|
||
|
||
def generate_human_trajectory(self, distance: float, attempt: int = 1):
|
||
"""生成人类化滑动轨迹 - 只使用极速物理模型(带智能学习+失败后增加扰动)
|
||
|
||
Args:
|
||
distance: 滑动距离
|
||
attempt: 当前尝试次数(从1开始),用于在失败后增加随机扰动
|
||
|
||
🔧 优化说明(基于成功案例分析 + 机器学习策略):
|
||
- 成功超调比例: 1.79-2.05 (中位数1.97)
|
||
- 成功步数: 6-8步
|
||
- 成功延迟: 0.0003-0.0006秒
|
||
- 成功加速曲线: 1.35-1.7 (中位数1.52)
|
||
- 成功Y抖动: 1.3-2.55像素
|
||
- 成功总耗时: 0.9-1.55秒
|
||
|
||
🎰 当前重试策略:
|
||
- 第1次优先利用历史成功参数
|
||
- 第2次继续利用,但主动放慢节奏
|
||
- 第3次切换到更果断的高收益分支,不再使用 slow_fallback
|
||
"""
|
||
try:
|
||
# 记录轨迹生成前的随机种子状态(用于分析)
|
||
random_state_snapshot = random.getstate()[1][:5] # 记录前5个随机状态
|
||
|
||
# 🧠 尝试从历史成功数据中学习最优参数
|
||
optimized_params = self._optimize_trajectory_params()
|
||
force_explore_threshold = ML_STRATEGY_CONFIG.get("force_explore_after_failures", 2)
|
||
slow_fallback_threshold = max(3, force_explore_threshold + 1)
|
||
has_learning = optimized_params.get("learning_enabled") and optimized_params.get("history_count", 0) >= 3
|
||
effective_ranges = self._get_effective_learning_ranges(optimized_params)
|
||
bounds = effective_ranges["bounds"]
|
||
|
||
use_exploration = False
|
||
selected_strategy = None
|
||
profile_name = "primary"
|
||
|
||
if attempt >= slow_fallback_threshold:
|
||
# 第 3 次及以后:优先使用 learned 变体(加大抖动),无学习数据时才轮换
|
||
if has_learning:
|
||
# 🔧 优化:第3次仍然使用学习参数,但加大抖动幅度以增加多样性
|
||
selected_strategy = "learned_with_jitter"
|
||
profile_name = "retry_learned_aggressive_jitter"
|
||
|
||
jitter_config = ML_STRATEGY_CONFIG.get("param_jitter", {})
|
||
# 第3次使用更大的抖动幅度(原来的2倍)
|
||
overshoot_jitter = jitter_config.get("overshoot_ratio_jitter", 0.05) * 2.0
|
||
|
||
overshoot_ratio = random.uniform(effective_ranges["overshoot"][0], effective_ranges["overshoot"][1])
|
||
overshoot_ratio *= random.uniform(1 - overshoot_jitter, 1 + overshoot_jitter)
|
||
overshoot_ratio = max(1.01, min(bounds.get("max_overshoot_ratio", 1.18), overshoot_ratio))
|
||
|
||
# 步数和延迟也加大变化范围
|
||
steps_min = max(18, effective_ranges["steps"][0] - 3)
|
||
steps_max = min(42, effective_ranges["steps"][1] + 5)
|
||
steps = random.randint(steps_min, steps_max)
|
||
|
||
delay_min = max(0.004, effective_ranges["delay"][0] * 0.85)
|
||
delay_max = min(0.022, effective_ranges["delay"][1] * 1.5)
|
||
base_delay = random.uniform(delay_min, delay_max)
|
||
|
||
curve_min = max(1.2, effective_ranges["curve"][0] - 0.2)
|
||
curve_max = min(2.6, effective_ranges["curve"][1] + 0.2)
|
||
acceleration_curve = random.uniform(curve_min, curve_max)
|
||
|
||
jitter_min = max(0.8, effective_ranges["jitter"][0] - 0.3)
|
||
jitter_max = min(3.5, effective_ranges["jitter"][1] + 0.5)
|
||
y_jitter_max = random.uniform(jitter_min, jitter_max)
|
||
|
||
logger.info(
|
||
f"【{self.pure_user_id}】🛟 第{attempt}次尝试,使用学习参数(大抖动): "
|
||
f"超调{(overshoot_ratio-1)*100:.1f}%, 步数{steps}, "
|
||
f"延迟{base_delay*1000:.1f}ms, 曲线^{acceleration_curve:.2f}"
|
||
)
|
||
else:
|
||
rotation_strategies = ["aggressive", "standard"]
|
||
rotation_idx = (attempt - slow_fallback_threshold) % len(rotation_strategies)
|
||
selected_strategy = rotation_strategies[rotation_idx]
|
||
profile_name = f"retry_rotation_{selected_strategy}"
|
||
|
||
strategy_config = ML_STRATEGY_CONFIG["strategies"][selected_strategy]
|
||
overshoot_ratio = random.uniform(*strategy_config["overshoot_ratio"])
|
||
steps = random.randint(*strategy_config["steps"])
|
||
base_delay = random.uniform(*strategy_config["base_delay"])
|
||
acceleration_curve = random.uniform(*strategy_config["acceleration_curve"])
|
||
y_jitter_max = random.uniform(*strategy_config["y_jitter_max"])
|
||
|
||
logger.info(
|
||
f"【{self.pure_user_id}】🛟 第{attempt}次尝试,轮换策略[{selected_strategy}]: "
|
||
f"超调{(overshoot_ratio-1)*100:.1f}%, 步数{steps}, "
|
||
f"延迟{base_delay*1000:.1f}ms, 曲线^{acceleration_curve:.2f}"
|
||
)
|
||
elif attempt == 2 and has_learning:
|
||
selected_strategy = "learned_with_jitter"
|
||
profile_name = "retry_stabilized"
|
||
|
||
jitter_config = ML_STRATEGY_CONFIG.get("param_jitter", {})
|
||
overshoot_jitter = jitter_config.get("overshoot_ratio_jitter", 0.05)
|
||
|
||
overshoot_ratio = random.uniform(effective_ranges["overshoot"][0], effective_ranges["overshoot"][1])
|
||
overshoot_ratio *= random.uniform(1 - overshoot_jitter, 1 + overshoot_jitter)
|
||
overshoot_ratio = max(1.01, min(bounds.get("max_overshoot_ratio", 1.18), overshoot_ratio))
|
||
|
||
steps_min = max(24, effective_ranges["steps"][0])
|
||
steps_max = min(38, max(steps_min + 2, effective_ranges["steps"][1] + 5))
|
||
steps = random.randint(steps_min, steps_max)
|
||
|
||
delay_min = max(0.007, effective_ranges["delay"][0] * 1.10)
|
||
delay_max = min(0.020, max(delay_min + 0.002, effective_ranges["delay"][1] * 1.35))
|
||
base_delay = random.uniform(delay_min, delay_max)
|
||
|
||
curve_min = max(1.45, effective_ranges["curve"][0] - 0.10)
|
||
curve_max = min(2.40, max(curve_min + 0.15, effective_ranges["curve"][1] + 0.10))
|
||
acceleration_curve = random.uniform(curve_min, curve_max)
|
||
|
||
jitter_min = max(1.2, effective_ranges["jitter"][0])
|
||
jitter_max = min(bounds.get("max_y_jitter", 3.5), max(jitter_min + 0.3, effective_ranges["jitter"][1] + 0.3))
|
||
y_jitter_max = random.uniform(jitter_min, jitter_max)
|
||
|
||
logger.info(
|
||
f"【{self.pure_user_id}】🧩 第2次尝试继续利用学习参数并放慢节奏: "
|
||
f"超调{(overshoot_ratio-1)*100:.1f}%, 步数{steps}, "
|
||
f"延迟{base_delay*1000:.1f}ms, 曲线^{acceleration_curve:.2f}"
|
||
)
|
||
else:
|
||
exploration_rate = ML_STRATEGY_CONFIG.get("exploration_rate", 0.35)
|
||
if not has_learning and random.random() < exploration_rate:
|
||
use_exploration = True
|
||
overshoot_ratio, steps, base_delay, acceleration_curve, y_jitter_max, selected_strategy = \
|
||
self._select_exploration_strategy(attempt)
|
||
profile_name = "cold_start_exploration"
|
||
logger.info(
|
||
f"【{self.pure_user_id}】🎯 冷启动探索策略[{selected_strategy}]: "
|
||
f"超调{(overshoot_ratio-1)*100:.1f}%, 步数{steps}, "
|
||
f"延迟{base_delay*1000:.1f}ms, 曲线^{acceleration_curve:.2f}"
|
||
)
|
||
elif has_learning:
|
||
logger.info(f"【{self.pure_user_id}】📐 利用模式:使用学习参数 "
|
||
f"(基于{optimized_params['history_count']}条记录)")
|
||
|
||
# 添加参数抖动(防止模式被识别)
|
||
jitter_config = ML_STRATEGY_CONFIG.get("param_jitter", {})
|
||
overshoot_jitter = jitter_config.get("overshoot_ratio_jitter", 0.03)
|
||
|
||
overshoot_ratio = random.uniform(effective_ranges["overshoot"][0], effective_ranges["overshoot"][1])
|
||
overshoot_ratio *= random.uniform(1 - overshoot_jitter, 1 + overshoot_jitter)
|
||
overshoot_ratio = max(1.01, min(bounds.get("max_overshoot_ratio", 1.18), overshoot_ratio))
|
||
|
||
steps = random.randint(effective_ranges["steps"][0], effective_ranges["steps"][1])
|
||
base_delay = random.uniform(effective_ranges["delay"][0], effective_ranges["delay"][1])
|
||
acceleration_curve = random.uniform(effective_ranges["curve"][0], effective_ranges["curve"][1])
|
||
y_jitter_max = random.uniform(effective_ranges["jitter"][0], effective_ranges["jitter"][1])
|
||
|
||
selected_strategy = "learned_with_jitter"
|
||
profile_name = "primary"
|
||
logger.info(f"【{self.pure_user_id}】🎯 应用学习参数(带抖动): 超调{(overshoot_ratio-1)*100:.1f}%, "
|
||
f"步数{steps}, 延迟{base_delay*1000:.1f}ms, 曲线^{acceleration_curve:.2f}")
|
||
else:
|
||
# 使用标准策略
|
||
standard = ML_STRATEGY_CONFIG["strategies"]["standard"]
|
||
overshoot_ratio = random.uniform(standard["overshoot_ratio"][0], standard["overshoot_ratio"][1])
|
||
steps = random.randint(standard["steps"][0], standard["steps"][1])
|
||
base_delay = random.uniform(standard["base_delay"][0], standard["base_delay"][1])
|
||
acceleration_curve = random.uniform(standard["acceleration_curve"][0], standard["acceleration_curve"][1])
|
||
y_jitter_max = random.uniform(standard["y_jitter_max"][0], standard["y_jitter_max"][1])
|
||
selected_strategy = "standard"
|
||
profile_name = "cold_start_standard"
|
||
logger.info(f"【{self.pure_user_id}】📐 使用标准策略: 超调{(overshoot_ratio-1)*100:.1f}%, "
|
||
f"步数{steps}, 延迟{base_delay*1000:.1f}ms")
|
||
|
||
# 生成轨迹(使用上面预生成的参数)
|
||
trajectory = self._generate_physics_trajectory_with_params(
|
||
distance, overshoot_ratio, steps, base_delay,
|
||
acceleration_curve, y_jitter_max
|
||
)
|
||
|
||
logger.debug(f"【{self.pure_user_id}】轨迹模式: 贝塞尔超调后回退,执行配置={selected_strategy}/{profile_name}")
|
||
|
||
# 保存轨迹数据(包含所有随机参数)
|
||
self.current_trajectory_data = {
|
||
"distance": distance,
|
||
"model": "physics_fast_learned" if optimized_params.get("learning_enabled") else "physics_fast",
|
||
"browser_profile_id": self.profile_id,
|
||
"headless": self.headless,
|
||
"total_steps": len(trajectory),
|
||
"trajectory_points": trajectory.copy(),
|
||
"final_left_px": 0,
|
||
"completion_used": False,
|
||
"completion_steps": 0,
|
||
# 新增:记录所有随机参数
|
||
"random_params": {
|
||
"overshoot_ratio": overshoot_ratio,
|
||
"steps": steps,
|
||
"base_delay": base_delay,
|
||
"acceleration_curve": acceleration_curve,
|
||
"y_jitter_max": y_jitter_max,
|
||
"random_state_snapshot": list(random_state_snapshot),
|
||
"is_learned": optimized_params.get("learning_enabled", False),
|
||
# 🎰 新增:记录使用的策略名称
|
||
"strategy": selected_strategy if selected_strategy else "unknown",
|
||
"profile": profile_name,
|
||
"use_exploration": use_exploration,
|
||
}
|
||
}
|
||
|
||
return trajectory
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】生成轨迹时出错: {str(e)}")
|
||
return []
|
||
|
||
def _select_exploration_strategy(self, attempt: int):
|
||
"""🎰 探索策略选择(机器学习多臂老虎机思想 + 自适应权重)
|
||
|
||
根据尝试次数和动态权重选择不同的策略
|
||
|
||
Returns:
|
||
tuple: (overshoot_ratio, steps, base_delay, acceleration_curve, y_jitter_max, strategy_name)
|
||
"""
|
||
strategies = ML_STRATEGY_CONFIG.get("strategies", {})
|
||
|
||
# 🤖 使用自适应策略管理器获取动态权重
|
||
try:
|
||
weights = adaptive_strategy_manager.get_dynamic_weights(attempt)
|
||
logger.debug(f"【{self.pure_user_id}】🤖 使用自适应权重: "
|
||
f"保守={weights.get('conservative', 0)*100:.1f}%, "
|
||
f"标准={weights.get('standard', 0)*100:.1f}%, "
|
||
f"激进={weights.get('aggressive', 0)*100:.1f}%")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】获取动态权重失败: {e},使用默认权重")
|
||
# 回退到静态权重
|
||
if attempt <= 2:
|
||
weights = {"conservative": 0.18, "standard": 0.52, "aggressive": 0.30}
|
||
elif attempt == 3:
|
||
weights = {"conservative": 0.12, "standard": 0.38, "aggressive": 0.50}
|
||
else:
|
||
weights = {"conservative": 0.10, "standard": 0.30, "aggressive": 0.60}
|
||
|
||
# 按权重随机选择策略
|
||
rand_val = random.random()
|
||
cumulative = 0
|
||
selected_name = "standard"
|
||
|
||
for name, weight in weights.items():
|
||
cumulative += weight
|
||
if rand_val <= cumulative:
|
||
selected_name = name
|
||
break
|
||
|
||
strategy = strategies.get(selected_name, strategies["standard"])
|
||
|
||
# 从选中的策略中随机生成参数
|
||
overshoot_ratio = random.uniform(strategy["overshoot_ratio"][0], strategy["overshoot_ratio"][1])
|
||
steps = random.randint(strategy["steps"][0], strategy["steps"][1])
|
||
base_delay = random.uniform(strategy["base_delay"][0], strategy["base_delay"][1])
|
||
acceleration_curve = random.uniform(strategy["acceleration_curve"][0], strategy["acceleration_curve"][1])
|
||
y_jitter_max = random.uniform(strategy["y_jitter_max"][0], strategy["y_jitter_max"][1])
|
||
|
||
# 添加额外的随机扰动(防止模式识别)
|
||
jitter_config = ML_STRATEGY_CONFIG.get("param_jitter", {})
|
||
|
||
# 对超调比例添加随机扰动
|
||
overshoot_jitter = jitter_config.get("overshoot_ratio_jitter", 0.08)
|
||
overshoot_ratio *= random.uniform(1 - overshoot_jitter/2, 1 + overshoot_jitter/2)
|
||
|
||
# 对延迟添加随机扰动
|
||
delay_jitter = jitter_config.get("delay_jitter", 0.12)
|
||
base_delay *= random.uniform(1 - delay_jitter/2, 1 + delay_jitter/2)
|
||
|
||
# 对加速曲线添加随机扰动
|
||
curve_jitter = jitter_config.get("curve_jitter", 0.08)
|
||
acceleration_curve *= random.uniform(1 - curve_jitter/2, 1 + curve_jitter/2)
|
||
|
||
# 🔧 2025-12-25:确保参数在新的合理范围内
|
||
bounds = ML_STRATEGY_CONFIG.get("learning_bounds", {})
|
||
overshoot_ratio = max(bounds.get("min_overshoot_ratio", 1.01),
|
||
min(bounds.get("max_overshoot_ratio", 1.15), overshoot_ratio))
|
||
y_jitter_max = max(bounds.get("min_y_jitter", 1.0),
|
||
min(bounds.get("max_y_jitter", 3.0), y_jitter_max))
|
||
base_delay = max(0.003, min(0.020, base_delay)) # 3-20ms
|
||
acceleration_curve = max(1.3, min(2.5, acceleration_curve))
|
||
|
||
return overshoot_ratio, steps, base_delay, acceleration_curve, y_jitter_max, selected_name
|
||
|
||
def _generate_physics_trajectory_with_params(self, distance: float,
|
||
overshoot_ratio: float,
|
||
steps: int,
|
||
base_delay: float,
|
||
acceleration_curve: float,
|
||
y_jitter_max: float):
|
||
"""使用指定参数生成物理轨迹(用于参数记录和复现)
|
||
|
||
🔧 2025-12-25 重构:使用贝塞尔曲线+真实超调回退+连续Y轴抖动
|
||
"""
|
||
trajectory = []
|
||
|
||
# 尊重上层策略传入的步数,避免“选中的策略”和“实际执行轨迹”脱节
|
||
# Fitts 定律动态步数:距离越长步数越多,距离越短步数越少
|
||
# 基于策略传入的步数,再根据距离做 ±30% 的缩放
|
||
fitts_factor = math.log2(max(1, distance / 50 + 1)) / math.log2(7) # 归一化到 ~0.5-1.3
|
||
fitts_steps = int(round(steps * max(0.7, min(1.3, fitts_factor))))
|
||
actual_steps = max(18, min(45, fitts_steps))
|
||
|
||
# 超调目标位置(先滑过,再回退)
|
||
overshoot_target = distance * overshoot_ratio
|
||
|
||
# === 阶段1:主滑动阶段(使用贝塞尔曲线) ===
|
||
# 控制点设计:模拟人类手部加速-匀速-减速
|
||
main_steps = int(actual_steps * 0.75) # 75%用于主滑动
|
||
|
||
# 贝塞尔控制点(三次贝塞尔)
|
||
p0 = 0 # 起点
|
||
p1 = overshoot_target * random.uniform(0.2, 0.35) # 控制点1(早期加速)
|
||
p2 = overshoot_target * random.uniform(0.7, 0.85) # 控制点2(后期减速)
|
||
p3 = overshoot_target # 终点(超调位置)
|
||
|
||
# Y轴使用 Perlin 噪声(非周期性连续平滑,比 sin 叠加更难被模式识别)
|
||
y_seed1 = random.uniform(0, 1000) # 低频噪声种子
|
||
y_seed2 = random.uniform(0, 1000) # 高频噪声种子
|
||
y_freq1 = random.uniform(2.0, 4.0) # 低频采样频率(手臂移动)
|
||
y_freq2 = random.uniform(6.0, 10.0) # 高频采样频率(手指颤抖)
|
||
# 延迟也使用 Perlin 生成连续变化(同一次滑动中各点延迟相关联)
|
||
delay_seed = random.uniform(0, 1000)
|
||
|
||
prev_x = 0
|
||
prev_y = 0
|
||
|
||
for i in range(main_steps):
|
||
# 进度 0->1,使用非线性进度模拟加速减速
|
||
t = (i + 1) / main_steps
|
||
|
||
# 使用ease-out曲线(开始快,结束慢)
|
||
eased_t = 1 - (1 - t) ** acceleration_curve
|
||
|
||
# 三次贝塞尔曲线计算X位置
|
||
x = (1-eased_t)**3 * p0 + \
|
||
3*(1-eased_t)**2 * eased_t * p1 + \
|
||
3*(1-eased_t) * eased_t**2 * p2 + \
|
||
eased_t**3 * p3
|
||
|
||
# Perlin 噪声 Y 轴波动(叠加低频+高频,非周期性)
|
||
y_low = perlin_octaves_1d(t * y_freq1, octaves=2, seed_offset=y_seed1) * y_jitter_max * 0.65
|
||
y_high = perlin_noise_1d(t * y_freq2, seed_offset=y_seed2) * y_jitter_max * 0.35
|
||
y = y_low + y_high + random.uniform(-0.2, 0.2) # 微小随机噪声
|
||
|
||
# Perlin 连续延迟:开始和结束慢,中间快,且相邻点延迟相关联
|
||
speed_factor = math.sin(t * 3.14159) # 基础速度包络仍用 sin(0->1->0)
|
||
if speed_factor < 0.1:
|
||
speed_factor = 0.1
|
||
|
||
# 基础延迟 + 速度调整 + Perlin 连续抖动(相邻点的延迟有平滑关联)
|
||
delay_jitter = 1.0 + perlin_noise_1d(t * 5.0, seed_offset=delay_seed) * 0.15 # ±15% 连续波动
|
||
delay = base_delay / speed_factor * delay_jitter
|
||
|
||
# 中间可能有微小停顿(8%概率,模拟人类犹豫/调整)
|
||
if 0.2 < t < 0.8 and random.random() < 0.08:
|
||
delay += random.uniform(0.01, 0.03)
|
||
|
||
# 添加微小位移抖动(生理性颤抖,±0.5px)
|
||
x += random.uniform(-0.5, 0.5)
|
||
|
||
trajectory.append((x, y, delay))
|
||
prev_x, prev_y = x, y
|
||
|
||
# === 阶段2:回退阶段(从超调位置回退到目标) ===
|
||
# 5-10%的回退距离
|
||
retreat_steps = int(actual_steps * 0.25)
|
||
retreat_distance = overshoot_target - distance # 需要回退的距离
|
||
|
||
if retreat_steps > 0 and retreat_distance > 0:
|
||
for i in range(retreat_steps):
|
||
t = (i + 1) / retreat_steps
|
||
|
||
# 回退使用ease-in-out(开始慢,中间快,结束慢)
|
||
eased_t = t * t * (3 - 2 * t) # smoothstep
|
||
|
||
# 从超调位置回退到目标
|
||
x = overshoot_target - retreat_distance * eased_t
|
||
|
||
# Y轴继续波动
|
||
y = prev_y * (1 - t) + random.uniform(-y_jitter_max * 0.3, y_jitter_max * 0.3)
|
||
|
||
# 回退时速度更慢(人类精确调整时更谨慎)
|
||
delay = base_delay * random.uniform(1.2, 1.8)
|
||
|
||
# 微小位移抖动
|
||
x += random.uniform(-0.3, 0.3)
|
||
|
||
trajectory.append((x, y, delay))
|
||
prev_x, prev_y = x, y
|
||
|
||
# === 阶段3:最终微调(模拟人类精确对齐) ===
|
||
# 随机添加1-3个微调点
|
||
fine_tune_count = random.randint(1, 3)
|
||
for _ in range(fine_tune_count):
|
||
# 在目标位置附近做微小调整
|
||
x = distance + random.uniform(-1.5, 1.5)
|
||
y = random.uniform(-y_jitter_max * 0.2, y_jitter_max * 0.2)
|
||
delay = base_delay * random.uniform(0.8, 1.5)
|
||
trajectory.append((x, y, delay))
|
||
|
||
# 确保最后一个点非常接近目标
|
||
final_x = distance + random.uniform(-0.5, 0.5)
|
||
final_y = random.uniform(-0.2, 0.2)
|
||
trajectory.append((final_x, final_y, base_delay * random.uniform(0.5, 1.0)))
|
||
|
||
logger.info(f"【{self.pure_user_id}】🎯 贝塞尔轨迹:{len(trajectory)}步,"
|
||
f"超调{(overshoot_ratio-1)*100:.0f}%→回退到目标,"
|
||
f"加速曲线^{acceleration_curve:.2f}")
|
||
return trajectory
|
||
|
||
def simulate_slide(self, slider_button: ElementHandle, trajectory):
|
||
"""模拟滑动 - 优化版本(增强随机性+智能学习)"""
|
||
try:
|
||
# 🧠 获取学习到的行为参数
|
||
optimized_params = self._optimize_trajectory_params()
|
||
learned_behavior = optimized_params.get("learned_behavior", {})
|
||
is_learned = optimized_params.get("learning_enabled", False) and len(learned_behavior) > 0
|
||
|
||
if is_learned:
|
||
logger.info(f"【{self.pure_user_id}】🧠 应用学习到的滑动行为参数({len(learned_behavior)}个)")
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】开始优化滑动模拟...")
|
||
|
||
# 🎭 用户速度人格因子:模拟同一个人各阶段行为的一致性
|
||
# 快用户 (0.75~0.95) 各阶段等待都偏短,慢用户 (1.05~1.25) 各阶段等待都偏长
|
||
# 使用 Perlin 噪声使各阶段因子有连续相关性,而非完全相同
|
||
_tempo_seed = random.uniform(0, 1000)
|
||
_tempo_base = random.uniform(0.80, 1.20) # 基础速度倾向
|
||
def _tempo(phase_idx):
|
||
"""为第 phase_idx 个阶段生成连续相关的速度因子"""
|
||
noise_val = perlin_noise_1d(phase_idx * 0.8, seed_offset=_tempo_seed)
|
||
return max(0.65, min(1.40, _tempo_base + noise_val * 0.15))
|
||
logger.debug(f"【{self.pure_user_id}】用户速度人格: base={_tempo_base:.2f}")
|
||
|
||
# 🎲 随机1:页面稳定等待时间随机化
|
||
# 🔧 优化:根据成功案例,总耗时约0.9-1.55秒,页面等待不宜过长
|
||
page_wait = random.uniform(0.08, 0.25) * _tempo(0)
|
||
time.sleep(page_wait)
|
||
|
||
# 获取滑块按钮中心位置
|
||
button_box = slider_button.bounding_box()
|
||
if not button_box:
|
||
logger.error(f"【{self.pure_user_id}】无法获取滑块按钮位置")
|
||
return False
|
||
|
||
start_x = button_box["x"] + button_box["width"] / 2
|
||
start_y = button_box["y"] + button_box["height"] / 2
|
||
logger.debug(f"【{self.pure_user_id}】滑块位置: ({start_x}, {start_y})")
|
||
|
||
# 记录滑动行为参数(用于学习)
|
||
slide_behavior = {}
|
||
|
||
# 第一阶段:移动到滑块附近(模拟人类寻找滑块)
|
||
# 🔧 优化说明:根据成功案例,接近偏移集中在 X:-9到-22, Y:-2到-18
|
||
try:
|
||
# 🎲 随机2:偏移量随机化(应用学习结果)
|
||
if "approach_offset_x" in learned_behavior:
|
||
x_range = learned_behavior["approach_offset_x"]
|
||
offset_x = random.uniform(x_range[0], x_range[1])
|
||
logger.debug(f"【{self.pure_user_id}】🧠 使用学习的X偏移: {x_range[0]:.1f}~{x_range[1]:.1f}")
|
||
else:
|
||
# 🔧 修复:成功记录显示X偏移约-23到-24
|
||
offset_x = random.uniform(-25, -20)
|
||
|
||
if "approach_offset_y" in learned_behavior:
|
||
y_range = learned_behavior["approach_offset_y"]
|
||
offset_y = random.uniform(y_range[0], y_range[1])
|
||
else:
|
||
# 🔧 修复:成功记录显示Y偏移应为正值(+12到+18)
|
||
offset_y = random.uniform(12, 18)
|
||
|
||
slide_behavior['approach_offset_x'] = offset_x
|
||
slide_behavior['approach_offset_y'] = offset_y
|
||
|
||
# 🎲 随机3:接近步数随机化(应用学习结果)
|
||
# 🔧 优化:成功案例的接近步数集中在 3-12步,但以3-6步居多
|
||
if "approach_steps" in learned_behavior:
|
||
steps_range = learned_behavior["approach_steps"]
|
||
approach_steps = random.randint(steps_range[0], steps_range[1])
|
||
logger.debug(f"【{self.pure_user_id}】🧠 使用学习的接近步数: {steps_range[0]}~{steps_range[1]}")
|
||
else:
|
||
# 🔧 修复:成功记录显示接近步数约8-9步
|
||
approach_steps = random.randint(8, 10)
|
||
|
||
slide_behavior['approach_steps'] = approach_steps
|
||
|
||
self.page.mouse.move(
|
||
start_x + offset_x,
|
||
start_y + offset_y,
|
||
steps=approach_steps
|
||
)
|
||
|
||
# 🎲 随机4:接近后停顿随机化(应用学习结果)
|
||
# 🔧 优化:成功案例的接近停顿集中在 0.17-0.36秒
|
||
if "approach_pause" in learned_behavior:
|
||
pause_range = learned_behavior["approach_pause"]
|
||
approach_pause = random.uniform(pause_range[0], pause_range[1])
|
||
else:
|
||
# 🔧 修复:成功记录显示接近停顿约0.05-0.12秒(更短)
|
||
approach_pause = random.uniform(0.05, 0.15)
|
||
|
||
slide_behavior['approach_pause'] = approach_pause
|
||
time.sleep(approach_pause * _tempo(1))
|
||
|
||
# 🎲 随机5:精确定位步数随机化(应用学习结果)
|
||
# 🔧 优化:成功案例的精确定位步数集中在 3-8步
|
||
if "precision_steps" in learned_behavior:
|
||
steps_range = learned_behavior["precision_steps"]
|
||
precision_steps = random.randint(steps_range[0], steps_range[1])
|
||
else:
|
||
# 🔧 修复:成功记录显示精确定位步数约9-10步
|
||
precision_steps = random.randint(8, 10)
|
||
|
||
slide_behavior['precision_steps'] = precision_steps
|
||
|
||
self.page.mouse.move(
|
||
start_x,
|
||
start_y,
|
||
steps=precision_steps
|
||
)
|
||
|
||
# 🎲 随机6:定位后停顿随机化(应用学习结果)
|
||
# 🔧 优化:成功案例的定位停顿集中在 0.19-0.28秒
|
||
if "precision_pause" in learned_behavior:
|
||
pause_range = learned_behavior["precision_pause"]
|
||
precision_pause = random.uniform(pause_range[0], pause_range[1])
|
||
else:
|
||
# 🔧 修复:成功记录显示精确定位停顿约0.07-0.09秒(更短)
|
||
precision_pause = random.uniform(0.07, 0.12)
|
||
|
||
slide_behavior['precision_pause'] = precision_pause
|
||
time.sleep(precision_pause * _tempo(2))
|
||
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】移动到滑块失败: {e},继续尝试")
|
||
|
||
# 第二阶段:悬停在滑块上
|
||
# 🎲 随机7:跳过悬停概率(应用学习结果)
|
||
# 🔧 优化:成功案例中大多数跳过了悬停(skip_hover=true居多)
|
||
if "skip_hover_rate" in learned_behavior:
|
||
skip_hover = random.random() < learned_behavior["skip_hover_rate"]
|
||
logger.debug(f"【{self.pure_user_id}】🧠 使用学习的跳过悬停概率: {learned_behavior['skip_hover_rate']*100:.1f}%")
|
||
else:
|
||
# 🔧 修复:成功记录显示skip_hover=false,降低跳过率到15%
|
||
skip_hover = random.random() < 0.15
|
||
|
||
slide_behavior['skip_hover'] = skip_hover
|
||
|
||
if not skip_hover:
|
||
try:
|
||
slider_button.hover(timeout=2000)
|
||
# 🎲 随机8:悬停时间随机化(应用学习结果)
|
||
if "hover_pause" in learned_behavior:
|
||
pause_range = learned_behavior["hover_pause"]
|
||
hover_pause = random.uniform(pause_range[0], pause_range[1])
|
||
else:
|
||
hover_pause = random.uniform(0.05, 0.4)
|
||
|
||
slide_behavior['hover_pause'] = hover_pause
|
||
time.sleep(hover_pause * _tempo(3))
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】悬停滑块失败: {e}")
|
||
else:
|
||
logger.debug(f"【{self.pure_user_id}】跳过悬停(随机行为)")
|
||
|
||
# 第三阶段:按下鼠标
|
||
try:
|
||
self.page.mouse.move(start_x, start_y)
|
||
|
||
# 🎲 随机9:按下前停顿随机化(应用学习结果)
|
||
# 🔧 优化:成功案例的按下前停顿集中在 0.08-0.17秒
|
||
if "pre_down_pause" in learned_behavior:
|
||
pause_range = learned_behavior["pre_down_pause"]
|
||
pre_down_pause = random.uniform(pause_range[0], pause_range[1])
|
||
else:
|
||
# 🔧 修复:成功记录显示按下前停顿约0.12-0.14秒
|
||
pre_down_pause = random.uniform(0.10, 0.15)
|
||
|
||
slide_behavior['pre_down_pause'] = pre_down_pause
|
||
time.sleep(pre_down_pause * _tempo(4))
|
||
|
||
self.page.mouse.down()
|
||
|
||
# 🎲 随机10:按下后停顿随机化(应用学习结果)
|
||
# 🔧 优化:成功案例的按下后停顿集中在 0.04-0.09秒
|
||
if "post_down_pause" in learned_behavior:
|
||
pause_range = learned_behavior["post_down_pause"]
|
||
post_down_pause = random.uniform(pause_range[0], pause_range[1])
|
||
else:
|
||
# 🔧 修复:成功记录显示按下后停顿约0.12-0.14秒
|
||
post_down_pause = random.uniform(0.10, 0.15)
|
||
|
||
slide_behavior['post_down_pause'] = post_down_pause
|
||
time.sleep(post_down_pause * _tempo(5))
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】按下鼠标失败: {e}")
|
||
return False
|
||
|
||
# 第四阶段:执行滑动轨迹
|
||
try:
|
||
start_time = time.time()
|
||
current_x = start_x
|
||
current_y = start_y
|
||
|
||
# 🔧 2025-12-25 重构:不使用 Playwright 的 steps 参数
|
||
# steps 会生成均匀插值点,这不是人类行为
|
||
# 直接移动到每个轨迹点,轨迹本身已经包含足够的采样点
|
||
|
||
# 🎲 延迟波动范围随机化
|
||
delay_variation_min = random.uniform(0.85, 0.95)
|
||
delay_variation_max = random.uniform(1.05, 1.15)
|
||
slide_behavior['delay_variation'] = (delay_variation_min, delay_variation_max)
|
||
|
||
# 记录上一个位置,用于检测大跳跃
|
||
last_x, last_y = 0, 0
|
||
|
||
# 执行拖动轨迹 - 直接移动到每个点
|
||
for i, (x, y, delay) in enumerate(trajectory):
|
||
# 更新当前位置
|
||
current_x = start_x + x
|
||
current_y = start_y + y
|
||
|
||
# 🔧 关键改进:直接移动到目标点,不使用 steps 插值
|
||
# 如果位移过大(>30px),分多次小步移动以更自然
|
||
dx = x - last_x
|
||
dy = y - last_y
|
||
move_distance = math.sqrt(dx*dx + dy*dy)
|
||
|
||
if move_distance > 30:
|
||
# 大位移时,分成多个小步
|
||
sub_steps = max(2, int(move_distance / 15))
|
||
for j in range(sub_steps):
|
||
progress = (j + 1) / sub_steps
|
||
sub_x = start_x + last_x + dx * progress
|
||
sub_y = start_y + last_y + dy * progress
|
||
self.page.mouse.move(sub_x, sub_y)
|
||
# 小步之间只有极短延迟
|
||
time.sleep(random.uniform(0.001, 0.003))
|
||
else:
|
||
# 小位移直接移动
|
||
self.page.mouse.move(current_x, current_y)
|
||
|
||
last_x, last_y = x, y
|
||
|
||
# 🎲 延迟使用自定义波动范围
|
||
actual_delay = delay * random.uniform(delay_variation_min, delay_variation_max)
|
||
|
||
# 🎲 随机:8%概率在非首尾点增加额外停顿(模拟人类调整)
|
||
if 0.15 < (i / len(trajectory)) < 0.85 and random.random() < 0.08:
|
||
hesitation = random.uniform(0.01, 0.04)
|
||
actual_delay += hesitation
|
||
slide_behavior[f'hesitation_at_{i}'] = hesitation
|
||
|
||
time.sleep(actual_delay)
|
||
|
||
# 记录最终位置
|
||
if i == len(trajectory) - 1:
|
||
try:
|
||
current_style = slider_button.get_attribute("style")
|
||
if current_style and "left:" in current_style:
|
||
import re
|
||
left_match = re.search(r'left:\s*([^;]+)', current_style)
|
||
if left_match:
|
||
left_value = left_match.group(1).strip()
|
||
left_px = float(left_value.replace('px', ''))
|
||
if hasattr(self, 'current_trajectory_data'):
|
||
self.current_trajectory_data["final_left_px"] = left_px
|
||
logger.info(f"【{self.pure_user_id}】滑动完成: {len(trajectory)}步 - 最终位置: {left_value}")
|
||
except:
|
||
pass
|
||
|
||
# 🎨 刮刮乐特殊处理:在目标位置停顿观察
|
||
is_scratch = self.is_scratch_captcha()
|
||
if is_scratch:
|
||
# 🎲 随机16:刮刮乐停顿时间随机化(0.2-0.6秒)
|
||
pause_duration = random.uniform(0.2, 0.6)
|
||
slide_behavior['scratch_pause'] = pause_duration
|
||
logger.warning(f"【{self.pure_user_id}】🎨 刮刮乐模式:在目标位置停顿{pause_duration:.2f}秒观察...")
|
||
time.sleep(pause_duration)
|
||
|
||
# 🎲 随机17:释放前停顿随机化
|
||
# 🔧 优化:成功案例的释放前停顿集中在 0.01-0.07秒
|
||
pre_up_pause = random.uniform(0.01, 0.07) # 优化:原0.01-0.08
|
||
slide_behavior['pre_up_pause'] = pre_up_pause
|
||
time.sleep(pre_up_pause * _tempo(6))
|
||
|
||
# 释放鼠标
|
||
self.page.mouse.up()
|
||
|
||
# 释放后短暂停顿(模拟手指离开)
|
||
post_up_pause = random.uniform(0.02, 0.06)
|
||
slide_behavior['post_up_pause'] = post_up_pause
|
||
time.sleep(post_up_pause * _tempo(7))
|
||
|
||
# 等待服务端验证判定(关键:阿里滑块验证是异步的,需要给服务端足够时间返回结果)
|
||
server_judge_wait = random.uniform(1.0, 2.0) * _tempo(8)
|
||
slide_behavior['server_judge_wait'] = server_judge_wait
|
||
logger.debug(f"【{self.pure_user_id}】等待服务端判定: {server_judge_wait:.2f}秒")
|
||
time.sleep(server_judge_wait)
|
||
|
||
elapsed_time = time.time() - start_time
|
||
slide_behavior['total_elapsed_time'] = elapsed_time
|
||
slide_behavior['used_learned_params'] = is_learned # 标记是否使用了学习参数
|
||
|
||
# 💾 保存滑动行为参数到轨迹数据(用于成功后学习)
|
||
if hasattr(self, 'current_trajectory_data'):
|
||
self.current_trajectory_data['slide_behavior'] = slide_behavior
|
||
logger.debug(f"【{self.pure_user_id}】已记录{len(slide_behavior)}个滑动行为参数")
|
||
|
||
learn_status = "🧠智能学习模式" if is_learned else "🎲随机模式"
|
||
logger.info(f"【{self.pure_user_id}】滑动完成 [{learn_status}]: "
|
||
f"耗时={elapsed_time:.2f}秒, "
|
||
f"最终位置=({current_x:.1f}, {current_y:.1f}), "
|
||
f"行为参数={len(slide_behavior)}个")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】执行滑动轨迹失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
# 确保释放鼠标
|
||
try:
|
||
self.page.mouse.up()
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】滑动模拟异常: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
return False
|
||
|
||
def _simulate_human_page_behavior(self):
|
||
"""模拟人类在验证页面的前置行为 - 极速模式已禁用"""
|
||
# 极速模式:不进行页面行为模拟,直接开始滑动
|
||
pass
|
||
|
||
def find_slider_elements(self, fast_mode=False):
|
||
"""查找滑块元素(支持在主页面和所有frame中查找)
|
||
|
||
Args:
|
||
fast_mode: 快速模式,不使用wait_for_selector,减少等待时间(当已确认滑块存在时使用)
|
||
"""
|
||
try:
|
||
# 快速等待页面稳定(快速模式下跳过)
|
||
if not fast_mode:
|
||
time.sleep(0.1)
|
||
|
||
# ===== 【优化】优先在 frames 中快速查找最常见的滑块组合 =====
|
||
# 根据实际日志,滑块按钮和轨道通常在同一个 frame 中
|
||
# 按钮: #nc_1_n1z, 轨道: #nc_1_n1t
|
||
logger.debug(f"【{self.pure_user_id}】优先在frames中快速查找常见滑块组合...")
|
||
try:
|
||
frames = self.page.frames
|
||
for idx, frame in enumerate(frames):
|
||
try:
|
||
# 优先查找最常见的按钮选择器
|
||
button_element = frame.query_selector("#nc_1_n1z")
|
||
if button_element and button_element.is_visible():
|
||
# 在同一个 frame 中查找轨道
|
||
track_element = frame.query_selector("#nc_1_n1t")
|
||
if track_element and track_element.is_visible():
|
||
# 找到容器(可以用按钮或其他选择器)
|
||
container_element = frame.query_selector("#baxia-dialog-content")
|
||
if not container_element:
|
||
container_element = frame.query_selector(".nc-container")
|
||
if not container_element:
|
||
# 如果找不到容器,用按钮作为容器标识
|
||
container_element = button_element
|
||
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 快速找到完整滑块组合!")
|
||
logger.info(f"【{self.pure_user_id}】 - 按钮: #nc_1_n1z")
|
||
logger.info(f"【{self.pure_user_id}】 - 轨道: #nc_1_n1t")
|
||
|
||
# 保存frame引用
|
||
self._detected_slider_frame = frame
|
||
return container_element, button_element, track_element
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】Frame {idx} 快速查找失败: {e}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】frames 快速查找出错: {e}")
|
||
|
||
# ===== 如果快速查找失败,使用原来的完整查找逻辑 =====
|
||
logger.debug(f"【{self.pure_user_id}】快速查找未成功,使用完整查找逻辑...")
|
||
|
||
# 定义滑块容器选择器(支持多种类型)
|
||
container_selectors = [
|
||
"#nc_1_n1z", # 滑块按钮也可以作为容器标识
|
||
"#baxia-dialog-content",
|
||
".nc-container",
|
||
".nc_wrapper",
|
||
".nc_scale",
|
||
"[class*='nc-container']",
|
||
# 刮刮乐类型滑块
|
||
"#nocaptcha",
|
||
".scratch-captcha-container",
|
||
".scratch-captcha-question-bg",
|
||
# 通用选择器
|
||
"[class*='slider']",
|
||
"[class*='captcha']"
|
||
]
|
||
|
||
# 查找滑块容器
|
||
slider_container = None
|
||
found_frame = None
|
||
|
||
# 🔑 优化:如果是重试且之前在"已知位置"查找失败,跳过已知位置,直接全局搜索
|
||
skip_known_location = False
|
||
if hasattr(self, '_slider_search_failed_in_known_location') and self._slider_search_failed_in_known_location:
|
||
logger.warning(f"【{self.pure_user_id}】上次在已知位置查找失败,本次跳过已知位置,直接全局搜索")
|
||
skip_known_location = True
|
||
# 清除标记,避免影响下次验证
|
||
self._slider_search_failed_in_known_location = False
|
||
|
||
# 如果检测时已经知道滑块在哪个frame中,直接在该frame中查找
|
||
if not skip_known_location and hasattr(self, '_detected_slider_frame'):
|
||
if self._detected_slider_frame is not None:
|
||
# 在已知的frame中查找
|
||
logger.info(f"【{self.pure_user_id}】已知滑块在frame中,直接在frame中查找...")
|
||
target_frame = self._detected_slider_frame
|
||
for selector in container_selectors:
|
||
try:
|
||
element = target_frame.query_selector(selector)
|
||
if element:
|
||
try:
|
||
if element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】在已知Frame中找到滑块容器: {selector}")
|
||
slider_container = element
|
||
found_frame = target_frame
|
||
break
|
||
except:
|
||
# 如果无法检查可见性,也尝试使用
|
||
logger.info(f"【{self.pure_user_id}】在已知Frame中找到滑块容器(无法检查可见性): {selector}")
|
||
slider_container = element
|
||
found_frame = target_frame
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】已知Frame选择器 {selector} 未找到: {e}")
|
||
continue
|
||
else:
|
||
# _detected_slider_frame 是 None,表示在主页面
|
||
logger.info(f"【{self.pure_user_id}】已知滑块在主页面,直接在主页面查找...")
|
||
for selector in container_selectors:
|
||
try:
|
||
element = self.page.wait_for_selector(selector, timeout=2000) # 增加超时时间
|
||
if element:
|
||
logger.info(f"【{self.pure_user_id}】在已知主页面找到滑块容器: {selector}")
|
||
slider_container = element
|
||
found_frame = self.page
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】主页面选择器 {selector} 未找到: {e}")
|
||
continue
|
||
|
||
# 如果已知位置中没找到,或者没有已知位置,先尝试在主页面查找
|
||
if not slider_container:
|
||
for selector in container_selectors:
|
||
try:
|
||
element = self.page.wait_for_selector(selector, timeout=1000) # 减少超时时间,快速跳过
|
||
if element:
|
||
logger.info(f"【{self.pure_user_id}】在主页面找到滑块容器: {selector}")
|
||
slider_container = element
|
||
found_frame = self.page
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】主页面选择器 {selector} 未找到: {e}")
|
||
continue
|
||
|
||
# 如果主页面没找到,在所有frame中查找
|
||
if not slider_container and self.page:
|
||
try:
|
||
frames = self.page.frames
|
||
logger.info(f"【{self.pure_user_id}】主页面未找到滑块,开始在所有frame中查找(共{len(frames)}个frame)...")
|
||
for idx, frame in enumerate(frames):
|
||
try:
|
||
for selector in container_selectors:
|
||
try:
|
||
# 在frame中使用query_selector,因为frame可能不支持wait_for_selector
|
||
element = frame.query_selector(selector)
|
||
if element:
|
||
# 检查元素是否可见
|
||
try:
|
||
if element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】在Frame {idx} 找到滑块容器: {selector}")
|
||
slider_container = element
|
||
found_frame = frame
|
||
break
|
||
except:
|
||
# 如果无法检查可见性,也尝试使用
|
||
logger.info(f"【{self.pure_user_id}】在Frame {idx} 找到滑块容器(无法检查可见性): {selector}")
|
||
slider_container = element
|
||
found_frame = frame
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】Frame {idx} 选择器 {selector} 未找到: {e}")
|
||
continue
|
||
if slider_container:
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查Frame {idx} 时出错: {e}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】获取frame列表时出错: {e}")
|
||
|
||
if not slider_container:
|
||
logger.error(f"【{self.pure_user_id}】未找到任何滑块容器(主页面和所有frame都已检查)")
|
||
return None, None, None
|
||
|
||
# 定义滑块按钮选择器(支持多种类型)
|
||
button_selectors = [
|
||
# nc 系列滑块
|
||
"#nc_1_n1z",
|
||
".nc_iconfont",
|
||
".btn_slide",
|
||
# 刮刮乐类型滑块
|
||
"#scratch-captcha-btn",
|
||
".scratch-captcha-slider .button",
|
||
# 通用选择器
|
||
"[class*='slider']",
|
||
"[class*='btn']",
|
||
"[role='button']"
|
||
]
|
||
|
||
# 查找滑块按钮(在找到容器的同一个frame中查找)
|
||
slider_button = None
|
||
search_frame = found_frame if found_frame and found_frame != self.page else self.page
|
||
|
||
# 如果容器是在主页面找到的,按钮也应该在主页面查找
|
||
# 如果容器是在frame中找到的,按钮也应该在同一个frame中查找
|
||
for selector in button_selectors:
|
||
try:
|
||
element = None
|
||
if fast_mode:
|
||
# 快速模式:直接使用 query_selector,不等待
|
||
element = search_frame.query_selector(selector)
|
||
else:
|
||
# 正常模式:使用 wait_for_selector
|
||
if search_frame == self.page:
|
||
element = self.page.wait_for_selector(selector, timeout=3000)
|
||
else:
|
||
# 在frame中先尝试wait_for_selector(如果支持)
|
||
try:
|
||
# 尝试使用wait_for_selector(Playwright的frame支持)
|
||
element = search_frame.wait_for_selector(selector, timeout=3000)
|
||
except:
|
||
# 如果不支持wait_for_selector,使用query_selector并等待
|
||
time.sleep(0.5) # 等待元素加载
|
||
element = search_frame.query_selector(selector)
|
||
|
||
if element:
|
||
# 检查元素是否可见,但不要因为不可见就放弃
|
||
try:
|
||
is_visible = element.is_visible()
|
||
if not is_visible:
|
||
logger.debug(f"【{self.pure_user_id}】找到元素但不可见: {selector},继续尝试其他选择器")
|
||
element = None
|
||
except Exception as vis_e:
|
||
# 如果无法检查可见性,仍然使用该元素
|
||
logger.debug(f"【{self.pure_user_id}】无法检查元素可见性: {vis_e},继续使用该元素")
|
||
pass
|
||
|
||
if element:
|
||
frame_info = "主页面" if search_frame == self.page else f"Frame"
|
||
logger.info(f"【{self.pure_user_id}】在{frame_info}找到滑块按钮: {selector}")
|
||
slider_button = element
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】选择器 {selector} 未找到: {e}")
|
||
continue
|
||
|
||
# 如果在找到容器的frame中没找到按钮,尝试在所有frame中查找
|
||
# 无论容器是在主页面还是frame中找到的,如果按钮找不到,都应该在所有frame中查找
|
||
if not slider_button:
|
||
logger.warning(f"【{self.pure_user_id}】在找到容器的位置未找到按钮,尝试在所有frame中查找...")
|
||
try:
|
||
frames = self.page.frames
|
||
for idx, frame in enumerate(frames):
|
||
# 如果容器是在frame中找到的,跳过已经检查过的frame
|
||
if found_frame and found_frame != self.page and frame == found_frame:
|
||
continue
|
||
# 如果容器是在主页面找到的,跳过主页面(因为已经检查过了)
|
||
if found_frame == self.page and frame == self.page:
|
||
continue
|
||
|
||
for selector in button_selectors:
|
||
try:
|
||
element = None
|
||
if fast_mode:
|
||
# 快速模式:直接使用 query_selector
|
||
element = frame.query_selector(selector)
|
||
else:
|
||
# 正常模式:先尝试wait_for_selector
|
||
try:
|
||
element = frame.wait_for_selector(selector, timeout=2000)
|
||
except:
|
||
time.sleep(0.3) # 等待元素加载
|
||
element = frame.query_selector(selector)
|
||
|
||
if element:
|
||
try:
|
||
is_visible = element.is_visible()
|
||
if is_visible:
|
||
logger.info(f"【{self.pure_user_id}】在Frame {idx} 找到滑块按钮: {selector}")
|
||
slider_button = element
|
||
found_frame = frame # 更新found_frame
|
||
break
|
||
else:
|
||
logger.debug(f"【{self.pure_user_id}】在Frame {idx} 找到元素但不可见: {selector}")
|
||
except:
|
||
# 如果无法检查可见性,仍然使用该元素
|
||
logger.info(f"【{self.pure_user_id}】在Frame {idx} 找到滑块按钮(无法检查可见性): {selector}")
|
||
slider_button = element
|
||
found_frame = frame # 更新found_frame
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】Frame {idx} 选择器 {selector} 查找失败: {e}")
|
||
continue
|
||
if slider_button:
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】在所有frame中查找按钮时出错: {e}")
|
||
|
||
# 如果还是没找到,尝试在主页面查找(如果之前没在主页面查找过)
|
||
if not slider_button and found_frame != self.page:
|
||
logger.warning(f"【{self.pure_user_id}】在所有frame中未找到按钮,尝试在主页面查找...")
|
||
for selector in button_selectors:
|
||
try:
|
||
element = None
|
||
if fast_mode:
|
||
# 快速模式:直接使用 query_selector
|
||
element = self.page.query_selector(selector)
|
||
else:
|
||
# 正常模式:使用 wait_for_selector
|
||
element = self.page.wait_for_selector(selector, timeout=2000)
|
||
|
||
if element:
|
||
try:
|
||
if element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】在主页面找到滑块按钮: {selector}")
|
||
slider_button = element
|
||
found_frame = self.page # 更新found_frame
|
||
break
|
||
else:
|
||
logger.debug(f"【{self.pure_user_id}】在主页面找到元素但不可见: {selector}")
|
||
except:
|
||
# 如果无法检查可见性,仍然使用该元素
|
||
logger.info(f"【{self.pure_user_id}】在主页面找到滑块按钮(无法检查可见性): {selector}")
|
||
slider_button = element
|
||
found_frame = self.page # 更新found_frame
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】主页面选择器 {selector} 查找失败: {e}")
|
||
continue
|
||
|
||
# 如果还是没找到,尝试使用更宽松的查找方式(不检查可见性)
|
||
if not slider_button:
|
||
logger.warning(f"【{self.pure_user_id}】使用宽松模式查找滑块按钮(不检查可见性)...")
|
||
# 先在所有frame中查找
|
||
try:
|
||
frames = self.page.frames
|
||
for idx, frame in enumerate(frames):
|
||
for selector in button_selectors[:3]: # 只使用前3个最常用的选择器
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element:
|
||
logger.info(f"【{self.pure_user_id}】在Frame {idx} 找到滑块按钮(宽松模式): {selector}")
|
||
slider_button = element
|
||
found_frame = frame
|
||
break
|
||
except:
|
||
continue
|
||
if slider_button:
|
||
break
|
||
except:
|
||
pass
|
||
|
||
# 如果还是没找到,在主页面查找
|
||
if not slider_button:
|
||
for selector in button_selectors[:3]:
|
||
try:
|
||
element = self.page.query_selector(selector)
|
||
if element:
|
||
logger.info(f"【{self.pure_user_id}】在主页面找到滑块按钮(宽松模式): {selector}")
|
||
slider_button = element
|
||
found_frame = self.page
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not slider_button:
|
||
logger.error(f"【{self.pure_user_id}】未找到任何滑块按钮(主页面和所有frame都已检查,包括宽松模式)")
|
||
return slider_container, None, None
|
||
|
||
# 定义滑块轨道选择器
|
||
track_selectors = [
|
||
"#nc_1_n1t",
|
||
".nc_scale",
|
||
".nc_1_n1t",
|
||
"[class*='track']",
|
||
"[class*='scale']"
|
||
]
|
||
|
||
# 查找滑块轨道(在找到按钮的同一个frame中查找,因为按钮和轨道应该在同一个位置)
|
||
slider_track = None
|
||
# 使用找到按钮的frame来查找轨道
|
||
track_search_frame = found_frame if found_frame and found_frame != self.page else self.page
|
||
|
||
for selector in track_selectors:
|
||
try:
|
||
element = None
|
||
if fast_mode:
|
||
# 快速模式:直接使用 query_selector
|
||
element = track_search_frame.query_selector(selector)
|
||
else:
|
||
# 正常模式:使用 wait_for_selector
|
||
if track_search_frame == self.page:
|
||
element = self.page.wait_for_selector(selector, timeout=3000)
|
||
else:
|
||
# 在frame中使用query_selector
|
||
element = track_search_frame.query_selector(selector)
|
||
|
||
if element:
|
||
try:
|
||
if not element.is_visible():
|
||
element = None
|
||
except:
|
||
pass
|
||
|
||
if element:
|
||
frame_info = "主页面" if track_search_frame == self.page else f"Frame"
|
||
logger.info(f"【{self.pure_user_id}】在{frame_info}找到滑块轨道: {selector}")
|
||
slider_track = element
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】选择器 {selector} 未找到: {e}")
|
||
continue
|
||
|
||
# 🔑 关键修复:如果在找到按钮的位置没找到轨道,尝试其他位置
|
||
# 不再限制只在frame中才尝试其他搜索策略,主页面找不到也要尝试frame
|
||
if not slider_track and track_search_frame:
|
||
# 如果按钮在frame中,先点击激活
|
||
if track_search_frame != self.page:
|
||
logger.warning(f"【{self.pure_user_id}】在已知Frame中未找到轨道,尝试点击frame激活后再查找...")
|
||
try:
|
||
# 点击frame以激活它,让轨道出现
|
||
# 尝试点击frame中的容器或按钮来激活
|
||
clicked_element = False
|
||
if slider_container:
|
||
try:
|
||
slider_container.click(timeout=1000)
|
||
logger.info(f"【{self.pure_user_id}】已点击滑块容器以激活frame")
|
||
clicked_element = True
|
||
time.sleep(0.3) # 等待轨道出现
|
||
except:
|
||
pass
|
||
elif slider_button:
|
||
try:
|
||
slider_button.click(timeout=1000)
|
||
logger.info(f"【{self.pure_user_id}】已点击滑块按钮以激活frame")
|
||
clicked_element = True
|
||
time.sleep(0.3) # 等待轨道出现
|
||
except:
|
||
pass
|
||
|
||
# 🔑 关键修复:点击后重新查找滑块按钮,因为DOM可能已更新
|
||
if clicked_element:
|
||
logger.info(f"【{self.pure_user_id}】点击激活frame后,重新查找滑块按钮以更新元素引用...")
|
||
old_button = slider_button
|
||
for selector in button_selectors:
|
||
try:
|
||
element = track_search_frame.query_selector(selector)
|
||
if element:
|
||
try:
|
||
if element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】重新找到滑块按钮: {selector}")
|
||
slider_button = element
|
||
break
|
||
except:
|
||
# 如果无法检查可见性,也尝试使用
|
||
logger.info(f"【{self.pure_user_id}】重新找到滑块按钮(无法检查可见性): {selector}")
|
||
slider_button = element
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if slider_button != old_button:
|
||
logger.info(f"【{self.pure_user_id}】✅ 滑块按钮元素引用已更新")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 未能更新滑块按钮元素引用,可能导致后续操作失败")
|
||
|
||
# 再次在同一个frame中查找轨道
|
||
for selector in track_selectors:
|
||
try:
|
||
element = track_search_frame.query_selector(selector)
|
||
if element:
|
||
try:
|
||
if element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】点击frame后在Frame中找到滑块轨道: {selector}")
|
||
slider_track = element
|
||
break
|
||
except:
|
||
# 如果无法检查可见性,也尝试使用
|
||
logger.info(f"【{self.pure_user_id}】点击frame后在Frame中找到滑块轨道(无法检查可见性): {selector}")
|
||
slider_track = element
|
||
break
|
||
except:
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】点击frame后查找轨道时出错: {e}")
|
||
|
||
# 🔑 关键修复:无论按钮在哪里,都要在所有frame中查找轨道
|
||
if not slider_track:
|
||
location_desc = "点击frame后仍" if track_search_frame != self.page else "在已知位置"
|
||
logger.warning(f"【{self.pure_user_id}】{location_desc}未找到轨道,尝试在所有frame中查找...")
|
||
try:
|
||
frames = self.page.frames
|
||
logger.info(f"【{self.pure_user_id}】开始遍历{len(frames)}个frame查找轨道...")
|
||
for idx, frame in enumerate(frames):
|
||
if frame == track_search_frame:
|
||
logger.debug(f"【{self.pure_user_id}】跳过Frame {idx}(已检查过)")
|
||
continue # 跳过已经检查过的frame
|
||
logger.debug(f"【{self.pure_user_id}】检查Frame {idx}...")
|
||
for selector in track_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element:
|
||
# 🔑 降低可见性要求:找到就使用,不强制检查可见性
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 找到滑块轨道: {selector}")
|
||
slider_track = element
|
||
# 更新found_frame为找到轨道的frame
|
||
found_frame = frame
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】Frame {idx} 选择器 {selector} 出错: {e}")
|
||
continue
|
||
if slider_track:
|
||
break
|
||
if not slider_track:
|
||
logger.warning(f"【{self.pure_user_id}】遍历完{len(frames)}个frame,未找到轨道")
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】在所有frame中查找轨道时出错: {e}")
|
||
|
||
# 如果还是没找到,尝试在主页面查找
|
||
if not slider_track:
|
||
logger.warning(f"【{self.pure_user_id}】在所有frame中未找到轨道,尝试在主页面查找...")
|
||
for selector in track_selectors:
|
||
try:
|
||
element = self.page.wait_for_selector(selector, timeout=1000)
|
||
if element:
|
||
logger.info(f"【{self.pure_user_id}】在主页面找到滑块轨道: {selector}")
|
||
slider_track = element
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not slider_track:
|
||
logger.error(f"【{self.pure_user_id}】未找到任何滑块轨道(主页面和所有frame都已检查)")
|
||
return slider_container, slider_button, None
|
||
|
||
# 保存找到滑块的frame引用,供后续验证使用
|
||
if found_frame and found_frame != self.page:
|
||
self._detected_slider_frame = found_frame
|
||
logger.info(f"【{self.pure_user_id}】保存滑块frame引用,供后续验证使用")
|
||
elif found_frame == self.page:
|
||
# 如果是在主页面找到的,设置为None
|
||
self._detected_slider_frame = None
|
||
|
||
return slider_container, slider_button, slider_track
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】查找滑块元素时出错: {str(e)}")
|
||
return None, None, None
|
||
|
||
def is_scratch_captcha(self):
|
||
"""检测是否为刮刮乐类型验证码"""
|
||
try:
|
||
page_content = self.page.content()
|
||
# 检测刮刮乐特征(更精确的判断)
|
||
# 必须包含明确的刮刮乐特征词
|
||
scratch_required = ['scratch-captcha', 'scratch-captcha-btn', 'scratch-captcha-slider']
|
||
has_scratch_feature = any(keyword in page_content for keyword in scratch_required)
|
||
|
||
# 或者包含刮刮乐的指令文字
|
||
scratch_instructions = ['Release the slider', 'pillows', 'fully appears', 'after', 'appears']
|
||
has_scratch_instruction = sum(1 for keyword in scratch_instructions if keyword in page_content) >= 2
|
||
|
||
is_scratch = has_scratch_feature or has_scratch_instruction
|
||
|
||
if is_scratch:
|
||
logger.info(f"【{self.pure_user_id}】🎨 检测到刮刮乐类型验证码")
|
||
|
||
return is_scratch
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检测刮刮乐类型时出错: {e}")
|
||
return False
|
||
|
||
def calculate_slide_distance(self, slider_button: ElementHandle, slider_track: ElementHandle):
|
||
"""计算滑动距离 - 增强精度,支持刮刮乐"""
|
||
try:
|
||
# 🔑 增强错误处理:检查元素是否仍然有效
|
||
button_box = None
|
||
track_box = None
|
||
|
||
# 尝试获取滑块按钮位置和大小(增加重试机制)
|
||
for retry in range(2):
|
||
try:
|
||
button_box = slider_button.bounding_box()
|
||
if button_box:
|
||
break
|
||
if retry == 0:
|
||
logger.warning(f"【{self.pure_user_id}】第{retry+1}次获取滑块按钮位置失败,等待后重试...")
|
||
time.sleep(0.1)
|
||
except Exception as e:
|
||
if retry == 0:
|
||
logger.warning(f"【{self.pure_user_id}】获取滑块按钮位置异常: {e},等待后重试...")
|
||
time.sleep(0.1)
|
||
else:
|
||
logger.error(f"【{self.pure_user_id}】多次尝试后仍无法获取滑块按钮位置: {e}")
|
||
|
||
if not button_box:
|
||
logger.error(f"【{self.pure_user_id}】无法获取滑块按钮位置(元素可能已失效,建议重新查找元素)")
|
||
return 0
|
||
|
||
# 获取滑块轨道位置和大小
|
||
track_box = slider_track.bounding_box()
|
||
if not track_box:
|
||
logger.error(f"【{self.pure_user_id}】无法获取滑块轨道位置")
|
||
return 0
|
||
|
||
# 🎨 检测是否为刮刮乐类型
|
||
is_scratch = self.is_scratch_captcha()
|
||
|
||
# 🔑 关键优化1:使用JavaScript获取更精确的尺寸(避免DPI缩放影响)
|
||
try:
|
||
precise_distance = self.page.evaluate("""
|
||
() => {
|
||
const button = document.querySelector('#nc_1_n1z') || document.querySelector('.nc_iconfont');
|
||
const track = document.querySelector('#nc_1_n1t') || document.querySelector('.nc_scale');
|
||
if (button && track) {
|
||
const buttonRect = button.getBoundingClientRect();
|
||
const trackRect = track.getBoundingClientRect();
|
||
// 计算实际可滑动距离(考虑padding和边距)
|
||
return trackRect.width - buttonRect.width;
|
||
}
|
||
return null;
|
||
}
|
||
""")
|
||
|
||
if precise_distance and precise_distance > 0:
|
||
logger.info(f"【{self.pure_user_id}】使用JavaScript精确计算滑动距离: {precise_distance:.2f}px")
|
||
|
||
# 🎨 刮刮乐特殊处理:只滑动75-85%的距离
|
||
if is_scratch:
|
||
scratch_ratio = random.uniform(0.25, 0.35)
|
||
final_distance = precise_distance * scratch_ratio
|
||
logger.warning(f"【{self.pure_user_id}】🎨 刮刮乐模式:滑动{scratch_ratio*100:.1f}%距离 ({final_distance:.2f}px)")
|
||
return final_distance
|
||
|
||
# 🔑 关键优化2:添加微小随机偏移(防止每次都完全相同)
|
||
# 真人操作时,滑动距离会有微小偏差
|
||
random_offset = random.uniform(-0.5, 0.5)
|
||
return precise_distance + random_offset
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】JavaScript精确计算失败,使用后备方案: {e}")
|
||
|
||
# 后备方案:使用bounding_box计算
|
||
slide_distance = track_box["width"] - button_box["width"]
|
||
|
||
# 🎨 刮刮乐特殊处理:只滑动75-85%的距离
|
||
if is_scratch:
|
||
scratch_ratio = random.uniform(0.25, 0.35)
|
||
slide_distance = slide_distance * scratch_ratio
|
||
logger.warning(f"【{self.pure_user_id}】🎨 刮刮乐模式:滑动{scratch_ratio*100:.1f}%距离 ({slide_distance:.2f}px)")
|
||
else:
|
||
# 添加微小随机偏移
|
||
random_offset = random.uniform(-0.5, 0.5)
|
||
slide_distance += random_offset
|
||
|
||
logger.info(f"【{self.pure_user_id}】计算滑动距离: {slide_distance:.2f}px (轨道宽度: {track_box['width']}px, 滑块宽度: {button_box['width']}px)")
|
||
|
||
return slide_distance
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】计算滑动距离时出错: {str(e)}")
|
||
return 0
|
||
|
||
def check_verification_success_fast(self, slider_button: ElementHandle):
|
||
"""检查验证结果 - 极速模式"""
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】检查验证结果(极速模式)...")
|
||
self.last_verification_feedback = {}
|
||
|
||
# 确定滑块所在的frame(如果已知)
|
||
target_frame = None
|
||
if hasattr(self, '_detected_slider_frame') and self._detected_slider_frame is not None:
|
||
target_frame = self._detected_slider_frame
|
||
logger.info(f"【{self.pure_user_id}】在已知Frame中检查验证结果")
|
||
# 先检查frame是否还存在(未被分离)
|
||
try:
|
||
# 尝试访问frame的属性来检查是否被分离
|
||
_ = target_frame.url if hasattr(target_frame, 'url') else None
|
||
except Exception as frame_check_error:
|
||
error_msg = str(frame_check_error).lower()
|
||
# 如果frame被分离(detached),说明验证成功,容器已消失
|
||
if 'detached' in error_msg or 'disconnected' in error_msg:
|
||
logger.info(f"【{self.pure_user_id}】✓ Frame已被分离,验证成功")
|
||
self.last_verification_feedback = {"status": "success", "source": "frame_detached", "message": "Frame已被分离"}
|
||
return True
|
||
else:
|
||
target_frame = self.page
|
||
logger.info(f"【{self.pure_user_id}】在主页面检查验证结果")
|
||
|
||
# 等待一小段时间让验证结果出现
|
||
time.sleep(0.3)
|
||
|
||
# 核心逻辑:首先检查frame容器状态
|
||
# 如果容器消失,直接返回成功;如果容器还在,检查失败提示
|
||
def check_container_status():
|
||
"""检查容器状态,返回(存在, 可见)"""
|
||
try:
|
||
if target_frame == self.page:
|
||
container = self.page.query_selector(".nc-container")
|
||
else:
|
||
# 检查frame是否还存在(未被分离)
|
||
try:
|
||
# 再次检查frame是否被分离
|
||
_ = target_frame.url if hasattr(target_frame, 'url') else None
|
||
container = target_frame.query_selector(".nc-container")
|
||
except Exception as frame_error:
|
||
error_msg = str(frame_error).lower()
|
||
# 如果frame被分离(detached),说明容器已经不存在
|
||
if 'detached' in error_msg or 'disconnected' in error_msg:
|
||
logger.info(f"【{self.pure_user_id}】Frame已被分离,容器不存在")
|
||
return (False, False)
|
||
# 其他错误,继续尝试
|
||
raise frame_error
|
||
|
||
if container is None:
|
||
return (False, False) # 容器不存在
|
||
|
||
try:
|
||
is_visible = container.is_visible()
|
||
return (True, is_visible)
|
||
except Exception as vis_error:
|
||
vis_error_msg = str(vis_error).lower()
|
||
# 如果元素被分离,说明容器不存在
|
||
if 'detached' in vis_error_msg or 'disconnected' in vis_error_msg:
|
||
logger.info(f"【{self.pure_user_id}】容器元素已被分离,容器不存在")
|
||
return (False, False)
|
||
# 无法检查可见性,假设存在且可见
|
||
return (True, True)
|
||
except Exception as e:
|
||
error_msg = str(e).lower()
|
||
# 如果frame或元素被分离,说明容器不存在
|
||
if 'detached' in error_msg or 'disconnected' in error_msg:
|
||
logger.info(f"【{self.pure_user_id}】Frame或容器已被分离,容器不存在")
|
||
return (False, False)
|
||
# 其他错误,保守处理,假设存在
|
||
logger.warning(f"【{self.pure_user_id}】检查容器状态时出错: {e}")
|
||
return (True, True)
|
||
|
||
# 第一次检查容器状态
|
||
container_exists, container_visible = check_container_status()
|
||
|
||
# 如果容器不存在或不可见,直接返回成功
|
||
if not container_exists or not container_visible:
|
||
logger.info(f"【{self.pure_user_id}】✓ 滑块容器已消失(不存在或不可见),验证成功")
|
||
self.last_verification_feedback = {"status": "success", "source": "container_missing", "message": "滑块容器已消失"}
|
||
return True
|
||
|
||
# 容器还在,需要等待更长时间并检查失败提示
|
||
logger.info(f"【{self.pure_user_id}】滑块容器仍存在且可见,等待验证结果...")
|
||
time.sleep(1.2) # 等待验证结果
|
||
|
||
# 再次检查容器状态
|
||
container_exists, container_visible = check_container_status()
|
||
|
||
# 如果容器消失了,返回成功
|
||
if not container_exists or not container_visible:
|
||
logger.info(f"【{self.pure_user_id}】✓ 滑块容器已消失,验证成功")
|
||
self.last_verification_feedback = {"status": "success", "source": "container_missing", "message": "滑块容器已消失"}
|
||
return True
|
||
|
||
# 容器还在,检查是否有验证失败提示
|
||
logger.info(f"【{self.pure_user_id}】滑块容器仍存在,检查验证失败提示...")
|
||
if self.check_verification_failure():
|
||
logger.warning(f"【{self.pure_user_id}】检测到验证失败提示,验证失败")
|
||
return False
|
||
|
||
# 容器还在,但没有失败提示,可能还在验证中或验证失败
|
||
# 再等待一小段时间后再次检查
|
||
time.sleep(0.5)
|
||
container_exists, container_visible = check_container_status()
|
||
|
||
if not container_exists or not container_visible:
|
||
logger.info(f"【{self.pure_user_id}】✓ 滑块容器已消失,验证成功")
|
||
self.last_verification_feedback = {"status": "success", "source": "container_missing", "message": "滑块容器已消失"}
|
||
return True
|
||
|
||
if self.check_page_changed():
|
||
logger.info(f"【{self.pure_user_id}】✓ 页面状态已变化,按验证成功处理")
|
||
self.last_verification_feedback = {"status": "success", "source": "page_changed", "message": "页面状态已变化"}
|
||
return True
|
||
|
||
if self._check_login_success_by_element(self.page):
|
||
logger.info(f"【{self.pure_user_id}】✓ 已检测到登录成功元素,按验证成功处理")
|
||
self.last_verification_feedback = {"status": "success", "source": "login_element_detected", "message": "已检测到登录成功元素"}
|
||
return True
|
||
|
||
context_login_success, _ = self._probe_context_login_during_slider(self.page)
|
||
if context_login_success:
|
||
logger.info(f"【{self.pure_user_id}】✓ 上下文登录状态已确认,按验证成功处理")
|
||
self.last_verification_feedback = {
|
||
"status": "success",
|
||
"source": "context_login_confirmed",
|
||
"message": "上下文登录状态已确认"
|
||
}
|
||
return True
|
||
|
||
# 容器仍然存在,且没有失败提示,可能是验证失败但没有显示失败提示
|
||
# 或者验证还在进行中,但为了不无限等待,返回失败
|
||
logger.warning(f"【{self.pure_user_id}】滑块容器仍存在且可见,且未检测到失败提示,但验证可能失败")
|
||
self.last_verification_feedback = {
|
||
"status": "failure",
|
||
"source": "container_still_visible",
|
||
"message": "滑块容器仍存在且可见,未检测到明确失败提示"
|
||
}
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】检查验证结果时出错: {str(e)}")
|
||
self.last_verification_feedback = {"status": "error", "source": "exception", "message": str(e)}
|
||
return False
|
||
|
||
def check_page_changed(self):
|
||
"""检查页面是否改变"""
|
||
try:
|
||
# 检查页面标题是否改变
|
||
current_title = self.page.title()
|
||
logger.info(f"【{self.pure_user_id}】当前页面标题: {current_title}")
|
||
|
||
# 如果标题不再是验证码相关,说明页面已改变
|
||
if "captcha" not in current_title.lower() and "验证" not in current_title and "拦截" not in current_title:
|
||
logger.info(f"【{self.pure_user_id}】页面标题已改变,验证成功")
|
||
return True
|
||
|
||
# 检查URL是否改变
|
||
current_url = self.page.url
|
||
logger.info(f"【{self.pure_user_id}】当前页面URL: {current_url}")
|
||
|
||
# 如果URL不再包含验证码相关参数,说明页面已改变
|
||
if "captcha" not in current_url.lower() and "action=captcha" not in current_url:
|
||
logger.info(f"【{self.pure_user_id}】页面URL已改变,验证成功")
|
||
return True
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】检查页面改变时出错: {e}")
|
||
return False
|
||
|
||
def check_verification_failure(self):
|
||
"""检查验证失败提示"""
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】检查验证失败提示...")
|
||
|
||
# 等待一下让失败提示出现(由于调用前已经等待了,这里等待时间缩短)
|
||
time.sleep(1.5)
|
||
|
||
failure_keywords = [
|
||
"验证失败",
|
||
"框体错误",
|
||
"点击框体重试",
|
||
"重试",
|
||
"失败",
|
||
"请重试",
|
||
"验证码错误",
|
||
"滑动验证失败"
|
||
]
|
||
|
||
search_targets = []
|
||
if hasattr(self, '_detected_slider_frame') and self._detected_slider_frame is not None:
|
||
search_targets.append((self._detected_slider_frame, "已知Frame"))
|
||
search_targets.append((self.page, "主页面"))
|
||
|
||
# 检查各种可能的验证失败提示元素
|
||
failure_selectors = [
|
||
"text=验证失败,点击框体重试",
|
||
"text=框体错误",
|
||
"text=验证失败",
|
||
"text=点击框体重试",
|
||
"text=重试",
|
||
".nc-lang-cnt",
|
||
"[class*='retry']",
|
||
"[class*='fail']",
|
||
"[class*='error']",
|
||
".captcha-tips",
|
||
"#captcha-loading",
|
||
".nc_1_nocaptcha",
|
||
".nc_wrapper",
|
||
".nc-container"
|
||
]
|
||
|
||
seen_targets = set()
|
||
for search_target, target_name in search_targets:
|
||
if search_target is None:
|
||
continue
|
||
|
||
target_key = id(search_target)
|
||
if target_key in seen_targets:
|
||
continue
|
||
seen_targets.add(target_key)
|
||
|
||
try:
|
||
target_content = search_target.content()
|
||
except Exception as content_err:
|
||
logger.debug(f"【{self.pure_user_id}】读取{target_name}内容失败: {content_err}")
|
||
target_content = ""
|
||
|
||
for keyword in failure_keywords:
|
||
if keyword and keyword in target_content:
|
||
logger.info(f"【{self.pure_user_id}】{target_name}内容包含失败关键词: {keyword}")
|
||
self.last_verification_feedback = {
|
||
"status": "failure",
|
||
"source": "keyword",
|
||
"message": keyword,
|
||
"context": target_name
|
||
}
|
||
logger.info(f"【{self.pure_user_id}】检测到验证失败关键词,验证失败")
|
||
return True
|
||
|
||
for selector in failure_selectors:
|
||
try:
|
||
element = search_target.query_selector(selector)
|
||
if element and element.is_visible():
|
||
element_text = ""
|
||
try:
|
||
element_text = element.text_content()
|
||
except Exception:
|
||
pass
|
||
|
||
logger.info(f"【{self.pure_user_id}】在{target_name}找到验证失败提示: {selector}, 文本: {element_text}")
|
||
self.last_verification_feedback = {
|
||
"status": "failure",
|
||
"source": "selector",
|
||
"message": element_text or selector,
|
||
"selector": selector,
|
||
"context": target_name
|
||
}
|
||
logger.info(f"【{self.pure_user_id}】检测到验证失败提示元素,验证失败")
|
||
return True
|
||
except Exception:
|
||
continue
|
||
|
||
logger.info(f"【{self.pure_user_id}】未找到验证失败提示,可能验证成功了")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】检查验证失败时出错: {e}")
|
||
return False
|
||
|
||
def _analyze_failure(self, attempt: int, slide_distance: float, trajectory_data: dict):
|
||
"""分析失败原因并记录"""
|
||
try:
|
||
failure_reason = {
|
||
"attempt": attempt,
|
||
"slide_distance": slide_distance,
|
||
"total_steps": trajectory_data.get("total_steps", 0),
|
||
"base_delay": trajectory_data.get("base_delay", 0),
|
||
"final_left_px": trajectory_data.get("final_left_px", 0),
|
||
"completion_used": trajectory_data.get("completion_used", False),
|
||
"verification_feedback": self.last_verification_feedback.copy(),
|
||
"timestamp": datetime.now().isoformat()
|
||
}
|
||
|
||
# 记录失败信息
|
||
logger.warning(f"【{self.pure_user_id}】第{attempt}次尝试失败 - 距离:{slide_distance}px, "
|
||
f"步数:{failure_reason['total_steps']}, "
|
||
f"最终位置:{failure_reason['final_left_px']}px")
|
||
|
||
return failure_reason
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】分析失败原因时出错: {e}")
|
||
return {}
|
||
|
||
def click_to_reset_slider(self):
|
||
"""点击失败提示区域以重置滑块"""
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】尝试点击失败提示区域以重置滑块...")
|
||
|
||
# 构建搜索 frame 列表:优先已知 frame,回退到所有 frame
|
||
search_frames = []
|
||
if hasattr(self, '_detected_slider_frame') and self._detected_slider_frame is not None:
|
||
try:
|
||
_ = self._detected_slider_frame.url if hasattr(self._detected_slider_frame, 'url') else None
|
||
search_frames.append(self._detected_slider_frame)
|
||
logger.info(f"【{self.pure_user_id}】将在已知Frame中查找并点击")
|
||
except Exception:
|
||
logger.warning(f"【{self.pure_user_id}】已知Frame已失效,回退到全局搜索")
|
||
|
||
if not search_frames:
|
||
search_frames.append(self.page)
|
||
try:
|
||
for frame in self.page.frames:
|
||
if frame != self.page.main_frame:
|
||
search_frames.append(frame)
|
||
except Exception:
|
||
pass
|
||
logger.info(f"【{self.pure_user_id}】将在主页面和所有iframe中查找(共{len(search_frames)}个frame)")
|
||
|
||
# 按优先级尝试点击不同的区域
|
||
# 优先点击错误状态元素("点击框体重试"),再尝试容器/包装器
|
||
click_selectors = [
|
||
(".errloading", "错误提示区域"),
|
||
(".nc-lang-cnt .errloading", "NC错误提示"),
|
||
("[data-nc-status='error']", "NC错误状态元素"),
|
||
(".nc-container", "滑块容器"),
|
||
(".nc_wrapper", "滑块包装器"),
|
||
(".nc_scale", "滑块轨道区域"),
|
||
("#baxia-dialog-content", "对话框内容"),
|
||
("#nc_1__bg", "背景区域"),
|
||
("div[class*='nc']", "NC相关元素"),
|
||
]
|
||
|
||
clicked = False
|
||
for target_frame in search_frames:
|
||
if clicked:
|
||
break
|
||
for selector, desc in click_selectors:
|
||
try:
|
||
element = target_frame.query_selector(selector)
|
||
if element:
|
||
try:
|
||
box = element.bounding_box()
|
||
if box:
|
||
click_x = box['x'] + box['width'] / 2
|
||
click_y = box['y'] + box['height'] / 2
|
||
self.page.mouse.click(click_x, click_y)
|
||
logger.info(f"【{self.pure_user_id}】✅ 已点击{desc}: {selector} (位置: {click_x:.1f}, {click_y:.1f})")
|
||
clicked = True
|
||
time.sleep(0.5)
|
||
break
|
||
else:
|
||
element.click(timeout=1000)
|
||
logger.info(f"【{self.pure_user_id}】✅ 已点击{desc}: {selector}")
|
||
clicked = True
|
||
time.sleep(0.5)
|
||
break
|
||
except Exception as click_e:
|
||
logger.debug(f"【{self.pure_user_id}】点击{desc} {selector} 失败: {click_e}")
|
||
continue
|
||
except Exception as find_e:
|
||
logger.debug(f"【{self.pure_user_id}】查找{desc} {selector} 失败: {find_e}")
|
||
continue
|
||
|
||
if clicked:
|
||
logger.info(f"【{self.pure_user_id}】成功点击失败提示区域,等待滑块重新加载...")
|
||
time.sleep(0.8) # 等待滑块重新加载(增加等待时间)
|
||
return True
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】未找到可点击的失败提示区域,滑块可能已存在")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】点击失败提示区域时出错: {e}")
|
||
return False
|
||
|
||
def solve_slider(self, max_retries: int = 3, fast_mode: bool = False):
|
||
"""处理滑块验证(极速模式 + 自适应策略)
|
||
|
||
Args:
|
||
max_retries: 最大重试次数(统一限制为3,减少无效重试)
|
||
fast_mode: 快速查找模式(当已确认滑块存在时使用,减少等待时间)
|
||
|
||
🔧 2026-01-28 优化说明:
|
||
- 减少最大重试次数(5→3),因为第4-5次成功率极低(<10%)
|
||
- 增加重试间隔冷却时间,避免触发反爬机制
|
||
- 第1次失败后等待2-3秒,第2次失败后等待3-5秒
|
||
"""
|
||
original_max_retries = max_retries
|
||
max_retries = max(1, min(int(max_retries or 3), 3))
|
||
if original_max_retries != max_retries:
|
||
logger.info(f"【{self.pure_user_id}】重试次数已收敛到 {max_retries} 次(原请求: {original_max_retries})")
|
||
|
||
failure_records = []
|
||
current_strategy = 'ultra_fast_optimized' # 优化后的极速策略
|
||
|
||
def finalize_slider_success(
|
||
attempt_no: int,
|
||
success_note: Optional[str] = None,
|
||
cookie_refresh_confirmed: Optional[bool] = None,
|
||
soft_success: bool = False,
|
||
) -> bool:
|
||
if success_note:
|
||
logger.success(f"【{self.pure_user_id}】✅ {success_note}")
|
||
|
||
logger.info(f"【{self.pure_user_id}】✅ 滑块验证成功! (第{attempt_no}次尝试)")
|
||
|
||
strategy_stats.record_attempt(attempt_no, current_strategy, success=True)
|
||
logger.info(f"【{self.pure_user_id}】📊 记录策略: 第{attempt_no}次-{current_strategy}策略-成功")
|
||
|
||
if hasattr(self, 'current_trajectory_data'):
|
||
used_strategy = self.current_trajectory_data.get("random_params", {}).get("strategy", "unknown")
|
||
adaptive_strategy_manager.record_result(used_strategy, success=True)
|
||
self._update_current_result_meta(
|
||
"success",
|
||
attempt=attempt_no,
|
||
cookie_refresh_confirmed=cookie_refresh_confirmed,
|
||
soft_success=soft_success,
|
||
note=success_note,
|
||
)
|
||
|
||
if self.enable_learning and hasattr(self, 'current_trajectory_data'):
|
||
self._save_success_record(self.current_trajectory_data)
|
||
logger.info(f"【{self.pure_user_id}】已保存成功记录用于参数优化")
|
||
|
||
if attempt_no > 1:
|
||
logger.info(f"【{self.pure_user_id}】经过{attempt_no}次尝试后验证成功")
|
||
|
||
strategy_stats.log_summary()
|
||
logger.info(adaptive_strategy_manager.get_stats_summary())
|
||
return True
|
||
|
||
# 快照当前 Cookie 基线(用于验证成功后判定"有意义的刷新")
|
||
cookie_baseline = self._snapshot_context_cookies()
|
||
if cookie_baseline:
|
||
x5_count = sum(1 for k in cookie_baseline if k.lower().startswith('x5'))
|
||
key_count = sum(1 for k in self._KEY_COOKIE_NAMES if k in cookie_baseline)
|
||
logger.info(f"【{self.pure_user_id}】Cookie 基线已快照: 共{len(cookie_baseline)}个, x5系{x5_count}个, 关键会话{key_count}个")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】Cookie 基线为空,将跳过 Cookie 刷新校验")
|
||
|
||
for attempt in range(1, max_retries + 1):
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】开始处理滑块验证... (第{attempt}/{max_retries}次尝试)")
|
||
|
||
# 检测账号受限状态(如果受限则立即停止,不浪费重试机会)
|
||
try:
|
||
page_text = self.page.inner_text('body', timeout=2000) if self.page else ''
|
||
restricted_keywords = ['账号已被限制', '限制访问', '账号异常', '账号被冻结', '暂时无法使用',
|
||
'您的账号', '安全验证未通过', '账户被限制']
|
||
for kw in restricted_keywords:
|
||
if kw in page_text:
|
||
logger.error(f"【{self.pure_user_id}】检测到账号受限状态: '{kw}',停止滑块处理")
|
||
return False
|
||
except Exception:
|
||
pass
|
||
|
||
# 如果不是第一次尝试,使用渐进式等待策略
|
||
if attempt > 1:
|
||
# 🔧 优化:增加重试间隔,降低反爬触发风险
|
||
# 第2次等待4-6秒,第3次等待6-8秒
|
||
base_delay = 4.0 + (attempt - 1) * 2.0 # 基础4秒,每次增加2秒
|
||
retry_delay = random.uniform(base_delay, base_delay + 2.0)
|
||
logger.info(f"【{self.pure_user_id}】⏳ 等待{retry_delay:.1f}秒后重试...")
|
||
time.sleep(retry_delay)
|
||
|
||
# 优先点击重置滑块(不刷新页面,避免丢失已输入的表单数据)
|
||
logger.info(f"【{self.pure_user_id}】🔄 尝试点击重置滑块...")
|
||
reset_success = self.click_to_reset_slider()
|
||
if reset_success:
|
||
logger.info(f"【{self.pure_user_id}】✅ 滑块已重置,准备重新检测")
|
||
time.sleep(1.0)
|
||
else:
|
||
# 点击重置失败时才回退到刷新页面
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 点击重置失败,回退到刷新页面...")
|
||
try:
|
||
self.page.reload(wait_until='networkidle', timeout=15000)
|
||
time.sleep(1.0)
|
||
logger.info(f"【{self.pure_user_id}】✅ 页面刷新完成,准备重新检测滑块")
|
||
except Exception as refresh_error:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 页面刷新也失败: {refresh_error}")
|
||
|
||
# 清除缓存的frame引用,强制重新检测滑块位置
|
||
if hasattr(self, '_detected_slider_frame'):
|
||
delattr(self, '_detected_slider_frame')
|
||
logger.info(f"【{self.pure_user_id}】已清除frame缓存,将重新全局搜索滑块")
|
||
|
||
# 1. 查找滑块元素(使用快速模式)
|
||
slider_container, slider_button, slider_track = self.find_slider_elements(fast_mode=fast_mode)
|
||
if not all([slider_container, slider_button, slider_track]):
|
||
logger.error(f"【{self.pure_user_id}】滑块元素查找失败")
|
||
self.last_verification_feedback = {
|
||
"status": "page_state_changed",
|
||
"source": "slider_missing",
|
||
"message": "当前页面未找到滑块容器",
|
||
"attempt": attempt
|
||
}
|
||
# 🔑 关键修复:清除缓存的frame位置,下次重试时重新全局搜索
|
||
if hasattr(self, '_detected_slider_frame'):
|
||
logger.warning(f"【{self.pure_user_id}】清除缓存的滑块位置信息,下次重试将重新全局搜索")
|
||
delattr(self, '_detected_slider_frame')
|
||
|
||
context_login_success, _ = self._probe_context_login_during_slider(self.page)
|
||
if context_login_success:
|
||
return finalize_slider_success(
|
||
attempt,
|
||
"当前页面已无滑块,但上下文已确认登录",
|
||
cookie_refresh_confirmed=None,
|
||
soft_success=False,
|
||
)
|
||
|
||
logger.warning(f"【{self.pure_user_id}】当前页面已无滑块,不再继续同轮滑块重试")
|
||
break
|
||
|
||
# 2. 计算滑动距离
|
||
slide_distance = self.calculate_slide_distance(slider_button, slider_track)
|
||
if slide_distance <= 0:
|
||
logger.error(f"【{self.pure_user_id}】滑动距离计算失败")
|
||
continue
|
||
|
||
# 3. 生成人类化轨迹(传递尝试次数以增加随机扰动)
|
||
trajectory = self.generate_human_trajectory(slide_distance, attempt=attempt)
|
||
if not trajectory:
|
||
logger.error(f"【{self.pure_user_id}】轨迹生成失败")
|
||
continue
|
||
|
||
# 4. 模拟滑动
|
||
if not self.simulate_slide(slider_button, trajectory):
|
||
logger.error(f"【{self.pure_user_id}】滑动模拟失败")
|
||
continue
|
||
|
||
# 5. 检查验证结果(极速模式)
|
||
verification_success = self.check_verification_success_fast(slider_button)
|
||
if not verification_success:
|
||
context_login_success, _ = self._probe_context_login_during_slider(self.page)
|
||
if context_login_success:
|
||
verification_success = True
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块结果未明确成功,但上下文已确认登录,按成功收口")
|
||
|
||
if verification_success:
|
||
# 🔑 Cookie 双重校验:页面状态通过后,轮询检查关键 Cookie 是否真正刷新
|
||
cookie_refresh_confirmed: Optional[bool] = None
|
||
soft_success = False
|
||
if cookie_baseline:
|
||
# 先等待稳定窗口(1.2 秒),给页面回写票据留时间
|
||
time.sleep(1.2)
|
||
cookie_refreshed = False
|
||
current_cookies = dict(cookie_baseline)
|
||
# 以 500ms 间隔轮询 x5/关键 Cookie 变化,最长等 10 秒
|
||
poll_interval = 0.5
|
||
max_poll_time = 10.0
|
||
poll_start = time.time()
|
||
while time.time() - poll_start < max_poll_time:
|
||
current_cookies = self._snapshot_context_cookies()
|
||
if self._has_meaningful_cookie_refresh(cookie_baseline, current_cookies):
|
||
cookie_refreshed = True
|
||
break
|
||
time.sleep(poll_interval)
|
||
|
||
if not cookie_refreshed:
|
||
context_login_success, confirmed_cookies = self._probe_context_login_during_slider(self.page)
|
||
if context_login_success:
|
||
logger.success(
|
||
f"【{self.pure_user_id}】✅ 页面显示验证通过且上下文已确认登录,放宽 Cookie 变化校验"
|
||
)
|
||
cookie_refreshed = True
|
||
if confirmed_cookies:
|
||
current_cookies = confirmed_cookies
|
||
else:
|
||
soft_success_allowed, soft_success_reason = self._should_accept_soft_success_without_cookie_refresh(
|
||
current_cookies,
|
||
self.page,
|
||
)
|
||
if soft_success_allowed:
|
||
logger.success(
|
||
f"【{self.pure_user_id}】✅ 页面已脱离验证态,接受软成功: {soft_success_reason}"
|
||
)
|
||
cookie_refresh_confirmed = False
|
||
soft_success = True
|
||
cookie_refreshed = True
|
||
self.last_verification_feedback = {
|
||
"status": "success",
|
||
"source": "soft_success_cookie_pending",
|
||
"message": soft_success_reason,
|
||
}
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 页面显示验证通过,但等待{max_poll_time}秒后关键 Cookie 仍无变化,判定为假通过")
|
||
if hasattr(self, 'current_trajectory_data'):
|
||
self._update_current_result_meta(
|
||
"failure",
|
||
attempt=attempt,
|
||
cookie_refresh_confirmed=False,
|
||
soft_success=False,
|
||
note="cookie_not_refreshed_after_page_success",
|
||
)
|
||
used_strategy = self.current_trajectory_data.get("random_params", {}).get("strategy", "unknown")
|
||
adaptive_strategy_manager.record_result(used_strategy, success=False)
|
||
strategy_stats.record_attempt(attempt, current_strategy, success=False)
|
||
if attempt < max_retries:
|
||
continue
|
||
else:
|
||
break
|
||
|
||
# Cookie 校验通过,更新基线
|
||
cookie_baseline = current_cookies
|
||
if cookie_refresh_confirmed is None:
|
||
cookie_refresh_confirmed = not soft_success
|
||
|
||
return finalize_slider_success(
|
||
attempt,
|
||
cookie_refresh_confirmed=cookie_refresh_confirmed,
|
||
soft_success=soft_success,
|
||
)
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】❌ 第{attempt}次验证失败")
|
||
|
||
# 📊 记录策略失败
|
||
strategy_stats.record_attempt(attempt, current_strategy, success=False)
|
||
logger.info(f"【{self.pure_user_id}】📊 记录策略: 第{attempt}次-{current_strategy}策略-失败")
|
||
|
||
# 🤖 记录到自适应策略管理器
|
||
if hasattr(self, 'current_trajectory_data'):
|
||
used_strategy = self.current_trajectory_data.get("random_params", {}).get("strategy", "unknown")
|
||
adaptive_strategy_manager.record_result(used_strategy, success=False)
|
||
|
||
# 分析失败原因
|
||
if hasattr(self, 'current_trajectory_data'):
|
||
self._update_current_result_meta(
|
||
"failure",
|
||
attempt=attempt,
|
||
cookie_refresh_confirmed=False,
|
||
soft_success=False,
|
||
note="verification_failed",
|
||
)
|
||
failure_info = self._analyze_failure(attempt, slide_distance, self.current_trajectory_data)
|
||
failure_records.append(failure_info)
|
||
self._save_failure_record(self.current_trajectory_data, failure_info)
|
||
|
||
# 如果不是最后一次尝试,继续
|
||
if attempt < max_retries:
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】第{attempt}次处理滑块验证时出错: {str(e)}")
|
||
if attempt < max_retries:
|
||
continue
|
||
|
||
# 所有尝试都失败了
|
||
logger.error(f"【{self.pure_user_id}】滑块验证失败,已尝试{max_retries}次")
|
||
|
||
# 输出失败分析摘要
|
||
if failure_records:
|
||
logger.info(f"【{self.pure_user_id}】失败分析摘要:")
|
||
for record in failure_records:
|
||
logger.info(f" - 第{record['attempt']}次: 距离{record['slide_distance']}px, "
|
||
f"步数{record['total_steps']}, 最终位置{record['final_left_px']}px")
|
||
|
||
# 输出当前统计摘要
|
||
strategy_stats.log_summary()
|
||
|
||
return False
|
||
|
||
def close_browser(self):
|
||
"""安全关闭浏览器并清理资源"""
|
||
logger.info(f"【{self.pure_user_id}】开始清理资源...")
|
||
|
||
# 清理页面
|
||
try:
|
||
if hasattr(self, 'page') and self.page:
|
||
self.page.close()
|
||
logger.debug(f"【{self.pure_user_id}】页面已关闭")
|
||
self.page = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】关闭页面时出错: {e}")
|
||
|
||
# 清理上下文
|
||
try:
|
||
if hasattr(self, 'context') and self.context:
|
||
self.context.close()
|
||
logger.debug(f"【{self.pure_user_id}】上下文已关闭")
|
||
self.context = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】关闭上下文时出错: {e}")
|
||
|
||
# 【修复】同步关闭浏览器,确保资源真正释放
|
||
try:
|
||
if hasattr(self, 'browser') and self.browser:
|
||
self.browser.close() # 直接同步关闭,不使用异步任务
|
||
logger.info(f"【{self.pure_user_id}】浏览器已关闭")
|
||
self.browser = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】关闭浏览器时出错: {e}")
|
||
|
||
# 【修复】同步停止Playwright,确保资源真正释放
|
||
try:
|
||
if hasattr(self, 'playwright') and self.playwright:
|
||
self.playwright.stop() # 直接同步停止,不使用异步任务
|
||
logger.info(f"【{self.pure_user_id}】Playwright已停止")
|
||
self.playwright = None
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】停止Playwright时出错: {e}")
|
||
|
||
# 清理临时目录
|
||
try:
|
||
if hasattr(self, 'temp_dir') and self.temp_dir:
|
||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||
logger.debug(f"【{self.pure_user_id}】临时目录已清理: {self.temp_dir}")
|
||
self.temp_dir = None # 设置为None,防止重复清理
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】清理临时目录时出错: {e}")
|
||
|
||
# 注销实例(最后执行,确保其他清理完成)
|
||
try:
|
||
concurrency_manager.unregister_instance(self.user_id)
|
||
stats = concurrency_manager.get_stats()
|
||
logger.info(f"【{self.pure_user_id}】实例已注销,当前并发: {stats['active_count']}/{stats['max_concurrent']},等待队列: {stats['queue_length']}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】注销实例时出错: {e}")
|
||
|
||
logger.info(f"【{self.pure_user_id}】资源清理完成")
|
||
|
||
def __del__(self):
|
||
"""析构函数,确保资源释放(保险机制)"""
|
||
try:
|
||
# 检查是否有未关闭的浏览器
|
||
if hasattr(self, 'browser') and self.browser:
|
||
logger.warning(f"【{self.pure_user_id}】析构函数检测到未关闭的浏览器,执行清理")
|
||
self.close_browser()
|
||
except Exception as e:
|
||
# 析构函数中不要抛出异常
|
||
logger.debug(f"【{self.pure_user_id}】析构函数清理时出错: {e}")
|
||
|
||
# ==================== Playwright 登录辅助方法 ====================
|
||
|
||
def _check_login_success_by_element(self, page) -> bool:
|
||
"""通过页面元素检测登录是否成功
|
||
|
||
Args:
|
||
page: Page对象
|
||
|
||
Returns:
|
||
bool: 登录成功返回True,否则返回False
|
||
"""
|
||
try:
|
||
# 检查目标元素
|
||
selector = '.rc-virtual-list-holder-inner'
|
||
logger.info(f"【{self.pure_user_id}】========== 检查登录状态(通过页面元素) ==========")
|
||
logger.info(f"【{self.pure_user_id}】检查选择器: {selector}")
|
||
|
||
# 查找元素
|
||
element = page.query_selector(selector)
|
||
|
||
if element:
|
||
# 获取元素的子元素数量
|
||
child_count = element.evaluate('el => el.children.length')
|
||
inner_html = element.inner_html()
|
||
inner_text = element.inner_text() if element.is_visible() else ""
|
||
|
||
logger.info(f"【{self.pure_user_id}】找到目标元素:")
|
||
logger.info(f"【{self.pure_user_id}】 - 子元素数量: {child_count}")
|
||
logger.info(f"【{self.pure_user_id}】 - 是否可见: {element.is_visible()}")
|
||
logger.info(f"【{self.pure_user_id}】 - innerText长度: {len(inner_text)}")
|
||
logger.info(f"【{self.pure_user_id}】 - innerHTML长度: {len(inner_html)}")
|
||
|
||
# 判断是否有数据:子元素数量大于0
|
||
if child_count > 0:
|
||
logger.success(f"【{self.pure_user_id}】✅ 登录成功!检测到列表有 {child_count} 个子元素")
|
||
logger.info(f"【{self.pure_user_id}】================================================")
|
||
return True
|
||
else:
|
||
logger.debug(f"【{self.pure_user_id}】列表为空,登录未完成")
|
||
logger.info(f"【{self.pure_user_id}】================================================")
|
||
return False
|
||
else:
|
||
logger.debug(f"【{self.pure_user_id}】未找到目标元素: {selector}")
|
||
logger.info(f"【{self.pure_user_id}】================================================")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查登录状态时出错: {e}")
|
||
import traceback
|
||
logger.debug(f"【{self.pure_user_id}】错误堆栈: {traceback.format_exc()}")
|
||
return False
|
||
|
||
def _check_login_error(self, page) -> tuple:
|
||
"""检测登录是否出现错误(如账密错误)
|
||
|
||
Args:
|
||
page: Page对象
|
||
|
||
Returns:
|
||
tuple: (has_error, error_message) - 是否有错误,错误消息
|
||
"""
|
||
try:
|
||
logger.debug(f"【{self.pure_user_id}】检查登录错误...")
|
||
|
||
# 检测账密错误
|
||
error_selectors = [
|
||
'.login-error-msg', # 主要的错误消息类
|
||
'[class*="error-msg"]', # 包含error-msg的类
|
||
'div:has-text("账密错误")', # 包含"账密错误"文本的div
|
||
'text=账密错误', # 直接文本匹配
|
||
]
|
||
|
||
# 在主页面和所有frame中查找
|
||
frames_to_check = [page] + page.frames
|
||
|
||
for frame in frames_to_check:
|
||
try:
|
||
for selector in error_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
error_text = element.inner_text()
|
||
logger.error(f"【{self.pure_user_id}】❌ 检测到登录错误: {error_text}")
|
||
return True, error_text
|
||
except:
|
||
continue
|
||
|
||
# 也检查页面HTML中是否包含错误文本
|
||
try:
|
||
content = frame.content()
|
||
if '账密错误' in content or '账号密码错误' in content or '用户名或密码错误' in content:
|
||
logger.error(f"【{self.pure_user_id}】❌ 页面内容中检测到账密错误")
|
||
return True, "账密错误"
|
||
except PasswordLoginVerificationError:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
|
||
except:
|
||
continue
|
||
|
||
return False, None
|
||
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查登录错误时出错: {e}")
|
||
return False, None
|
||
|
||
def _detect_verification_type(self, frame) -> str:
|
||
"""检测 iframe 内的具体验证类型
|
||
|
||
Args:
|
||
frame: iframe 的 content_frame
|
||
|
||
Returns:
|
||
str: 验证类型 - 'password_error' / 'face_verify' / 'sms_verify' / 'qr_verify' / 'unknown'
|
||
"""
|
||
try:
|
||
detection_text = self._read_frame_text_for_detection(frame)
|
||
detection_text_lower = detection_text.lower()
|
||
|
||
# 1. 检查是否是账密错误
|
||
# 这里不要用过宽的“登录失败”做账密错误判定,mini_login 风控页也会包含该文案。
|
||
password_error_keywords = ['账密错误', '账号密码错误', '用户名或密码错误', '密码错误', '账号或密码错误']
|
||
for keyword in password_error_keywords:
|
||
if keyword in detection_text:
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 账密错误 (关键词: {keyword})")
|
||
return 'password_error'
|
||
|
||
# 2. 检查是否是短信验证
|
||
sms_keywords = ['短信验证', '验证码', '手机号', '发送验证码', '获取验证码']
|
||
sms_count = sum(1 for keyword in sms_keywords if keyword in detection_text)
|
||
if sms_count >= 2: # 至少匹配2个关键词
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 短信验证")
|
||
return 'sms_verify'
|
||
|
||
# 3. 检查是否是二维码验证
|
||
qr_keywords = ['扫码', '二维码', '扫一扫', '手机淘宝', '手机扫码']
|
||
for keyword in qr_keywords:
|
||
if keyword in detection_text:
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 二维码验证 (关键词: {keyword})")
|
||
return 'qr_verify'
|
||
|
||
# 4. 检查是否是人脸验证
|
||
face_keywords = ['人脸', '刷脸', '面部', '拍摄脸部', '刷脸验证', '人脸验证']
|
||
for keyword in face_keywords:
|
||
if keyword in detection_text_lower:
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 人脸验证 (关键词: {keyword})")
|
||
return 'face_verify'
|
||
|
||
# 5. 检查 URL 特征
|
||
frame_url = ""
|
||
try:
|
||
frame_url = frame.url if hasattr(frame, 'url') else ""
|
||
except:
|
||
pass
|
||
|
||
if 'sms' in frame_url.lower() or 'phone' in frame_url.lower():
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 短信验证 (URL特征)")
|
||
return 'sms_verify'
|
||
|
||
if 'qrcode' in frame_url.lower() or 'scan' in frame_url.lower():
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 二维码验证 (URL特征)")
|
||
return 'qr_verify'
|
||
|
||
if any(token in frame_url.lower() for token in ('face_verify', 'faceverify', 'liveness')):
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: 人脸验证 (URL特征)")
|
||
return 'face_verify'
|
||
|
||
# 默认当作未知验证类型
|
||
logger.info(f"【{self.pure_user_id}】无法确定验证类型,标记为未知")
|
||
return 'unknown'
|
||
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检测验证类型时出错: {e}")
|
||
return 'unknown'
|
||
|
||
def _detect_qr_code_verification(self, page) -> tuple:
|
||
"""检测是否存在二维码/人脸验证(排除滑块验证)
|
||
|
||
Args:
|
||
page: Page对象
|
||
|
||
Returns:
|
||
tuple: (has_qr, qr_frame) - 是否有二维码/人脸验证,验证frame
|
||
(False, None) - 如果检测到滑块验证,会先处理滑块,然后返回
|
||
"""
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】检测二维码/人脸验证...")
|
||
|
||
# 先检查是否是滑块验证,如果是滑块验证,立即处理并返回
|
||
slider_selectors = [
|
||
'#nc_1_n1z',
|
||
'.nc-container',
|
||
'.nc_scale',
|
||
'.nc-wrapper',
|
||
'.nc_iconfont',
|
||
'[class*="nc_"]'
|
||
]
|
||
|
||
# 在主页面和所有frame中检查滑块
|
||
frames_to_check = [page] + list(page.frames)
|
||
for frame in frames_to_check:
|
||
try:
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】检测到滑块验证元素,立即处理滑块: {selector}")
|
||
# 检测到滑块验证,记录是在哪个frame中找到的
|
||
frame_info = "主页面" if frame == page else f"Frame: {frame.url if hasattr(frame, 'url') else '未知'}"
|
||
logger.info(f"【{self.pure_user_id}】滑块元素位置: {frame_info}")
|
||
|
||
# 保存找到滑块的frame,供find_slider_elements使用
|
||
# 如果是在frame中找到的,保存frame引用;如果在主页面找到,保存None
|
||
if frame == page:
|
||
self._detected_slider_frame = None # 主页面
|
||
else:
|
||
self._detected_slider_frame = frame # 保存frame引用
|
||
|
||
# 检测到滑块验证,立即处理
|
||
logger.warning(f"【{self.pure_user_id}】检测到滑块验证,开始自动处理...")
|
||
slider_risk_log = self._start_password_login_slider_risk_log(
|
||
verification_url=frame.url if hasattr(frame, 'url') else getattr(page, 'url', None),
|
||
detection_phase='verification_probe',
|
||
)
|
||
slider_success = self.solve_slider(max_retries=3)
|
||
if slider_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块验证成功!")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=True,
|
||
verification_url=frame.url if hasattr(frame, 'url') else getattr(page, 'url', None),
|
||
processing_result='密码登录流程中的滑块验证自动处理成功',
|
||
extra_meta={'detection_source': '_detect_qr_code_verification'},
|
||
)
|
||
time.sleep(3) # 等待滑块验证后的状态更新
|
||
else:
|
||
# 3次失败后,刷新页面重试
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 滑块处理3次都失败,刷新页面后重试...")
|
||
try:
|
||
self.page.reload(wait_until="domcontentloaded", timeout=30000)
|
||
logger.info(f"【{self.pure_user_id}】✅ 页面刷新完成")
|
||
time.sleep(2)
|
||
slider_success = self.solve_slider(max_retries=3)
|
||
if not slider_success:
|
||
logger.error(f"【{self.pure_user_id}】❌ 刷新后滑块验证仍然失败")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=False,
|
||
verification_url=frame.url if hasattr(frame, 'url') else getattr(page, 'url', None),
|
||
error_message=self._get_slider_failure_message('滑块验证失败,请稍后重试'),
|
||
extra_meta={'detection_source': '_detect_qr_code_verification'},
|
||
)
|
||
else:
|
||
logger.success(f"【{self.pure_user_id}】✅ 刷新后滑块验证成功!")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=True,
|
||
verification_url=frame.url if hasattr(frame, 'url') else getattr(page, 'url', None),
|
||
processing_result='密码登录流程中的滑块验证自动处理成功(刷新后)',
|
||
extra_meta={'detection_source': '_detect_qr_code_verification'},
|
||
)
|
||
time.sleep(3)
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】❌ 页面刷新失败: {e}")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=False,
|
||
verification_url=frame.url if hasattr(frame, 'url') else getattr(page, 'url', None),
|
||
error_message=f'页面刷新失败: {str(e)}',
|
||
extra_meta={'detection_source': '_detect_qr_code_verification'},
|
||
)
|
||
|
||
# 清理临时变量
|
||
if hasattr(self, '_detected_slider_frame'):
|
||
delattr(self, '_detected_slider_frame')
|
||
|
||
# 返回 False, None 表示不是二维码/人脸验证(已处理滑块)
|
||
return False, None
|
||
except:
|
||
continue
|
||
except:
|
||
continue
|
||
|
||
# 检测所有frames中的二维码/人脸验证
|
||
page_url = self._safe_page_url(page)
|
||
page_verification_type = self._detect_verification_type(page)
|
||
page_has_login_form = self._page_has_login_form(page)
|
||
if self._looks_like_verification_url(page_url) or (
|
||
page_verification_type in {'face_verify', 'sms_verify', 'qr_verify'} and not page_has_login_form
|
||
):
|
||
if page_verification_type == 'password_error':
|
||
logger.error(f"【{self.pure_user_id}】❌ 顶层页面判定为账号密码错误")
|
||
raise PasswordLoginVerificationError("账号密码错误,请检查账号密码是否正确")
|
||
|
||
logger.info(f"【{self.pure_user_id}】✅ 顶层页面命中验证特征,URL: {page_url}")
|
||
verification_screenshot = self._capture_verification_screenshot(page)
|
||
return True, VerificationFrameWrapper(
|
||
page,
|
||
verification_type=page_verification_type,
|
||
verify_url=page_url or None,
|
||
screenshot_path=verification_screenshot
|
||
)
|
||
|
||
# 首先检查是否有 alibaba-login-box iframe(人脸验证或短信验证)
|
||
try:
|
||
iframes = page.query_selector_all('iframe')
|
||
for iframe in iframes:
|
||
try:
|
||
iframe_id = iframe.get_attribute('id')
|
||
if iframe_id == 'alibaba-login-box':
|
||
logger.info(f"【{self.pure_user_id}】✅ 检测到 alibaba-login-box iframe")
|
||
frame = iframe.content_frame()
|
||
if frame:
|
||
frame_url = frame.url if hasattr(frame, 'url') else '未知'
|
||
logger.info(f"【{self.pure_user_id}】验证Frame URL: {frame_url}")
|
||
|
||
# 先检测具体的验证类型
|
||
verification_type = self._detect_verification_type(frame)
|
||
logger.info(f"【{self.pure_user_id}】检测到验证类型: {verification_type}")
|
||
|
||
# 记录风控日志
|
||
try:
|
||
from db_manager import db_manager
|
||
event_type_map = {
|
||
'password_error': 'password_error',
|
||
'sms_verify': 'sms_verify',
|
||
'qr_verify': 'qr_verify',
|
||
'face_verify': 'face_verify',
|
||
'unknown': 'unknown'
|
||
}
|
||
event_type_names = {
|
||
'password_error': '账号密码错误',
|
||
'sms_verify': '短信验证',
|
||
'qr_verify': '二维码验证',
|
||
'face_verify': '人脸验证',
|
||
'unknown': '身份验证'
|
||
}
|
||
db_event_type = event_type_map.get(verification_type, 'unknown')
|
||
event_name = event_type_names.get(verification_type, '身份验证')
|
||
db_manager.add_risk_control_log(
|
||
cookie_id=self.pure_user_id,
|
||
event_type=db_event_type,
|
||
session_id=getattr(self, 'risk_session_id', None),
|
||
trigger_scene=getattr(self, 'risk_trigger_scene', None) or 'password_login',
|
||
result_code=f"{verification_type}_detected",
|
||
event_description=f"检测到{event_name}",
|
||
event_meta=self._build_risk_event_meta(
|
||
verification_url=frame_url,
|
||
extra={
|
||
'verification_type': verification_type,
|
||
'account_id': self.pure_user_id,
|
||
}
|
||
),
|
||
processing_status='processing' if verification_type != 'password_error' else 'failed',
|
||
error_message='检测到需要人工完成的身份验证' if verification_type != 'password_error' else '账号密码错误'
|
||
)
|
||
logger.info(f"【{self.pure_user_id}】已记录风控日志: {db_event_type}")
|
||
except Exception as log_err:
|
||
logger.warning(f"【{self.pure_user_id}】记录风控日志失败: {log_err}")
|
||
|
||
# 如果是账密错误,抛出异常让调用者处理
|
||
if verification_type == 'password_error':
|
||
logger.error(f"【{self.pure_user_id}】❌ 检测到账号密码错误")
|
||
raise PasswordLoginVerificationError("账号密码错误,请检查账号密码是否正确")
|
||
|
||
verification_screenshot = self._capture_verification_screenshot(
|
||
page,
|
||
frame=frame,
|
||
iframe_selector='iframe#alibaba-login-box'
|
||
)
|
||
|
||
# 如果是短信验证
|
||
if verification_type == 'sms_verify':
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 需要短信验证,暂不支持自动处理")
|
||
return True, VerificationFrameWrapper(
|
||
frame,
|
||
verification_type='sms_verify',
|
||
screenshot_path=verification_screenshot
|
||
)
|
||
|
||
# 如果是二维码验证
|
||
if verification_type == 'qr_verify':
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 需要二维码验证")
|
||
return True, VerificationFrameWrapper(
|
||
frame,
|
||
verification_type='qr_verify',
|
||
screenshot_path=verification_screenshot
|
||
)
|
||
|
||
verify_url = None
|
||
if verification_type == 'face_verify':
|
||
verify_url = self._get_face_verification_url(frame)
|
||
if verify_url:
|
||
logger.info(f"【{self.pure_user_id}】✅ 获取到人脸验证链接: {verify_url}")
|
||
elif verification_type == 'unknown':
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】验证类型仍不明确,保留为unknown,不默认按人脸验证处理"
|
||
)
|
||
|
||
return True, VerificationFrameWrapper(
|
||
frame,
|
||
verification_type=verification_type if verification_type in {'face_verify', 'unknown'} else 'unknown',
|
||
verify_url=verify_url,
|
||
screenshot_path=verification_screenshot
|
||
)
|
||
except PasswordLoginVerificationError:
|
||
raise
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查iframe时出错: {e}")
|
||
continue
|
||
except PasswordLoginVerificationError:
|
||
raise
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查alibaba-login-box iframe时出错: {e}")
|
||
|
||
for idx, frame in enumerate(page.frames):
|
||
try:
|
||
frame_url = frame.url
|
||
logger.debug(f"【{self.pure_user_id}】检查Frame {idx} 是否有二维码: {frame_url}")
|
||
|
||
# 检查frame URL是否包含 mini_login(人脸验证或短信验证页面)
|
||
if 'mini_login' in frame_url:
|
||
# 进一步确认不是滑块验证
|
||
is_slider = False
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
is_slider = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not is_slider:
|
||
verification_type = self._detect_verification_type(frame)
|
||
if verification_type == 'password_error':
|
||
logger.error(f"【{self.pure_user_id}】❌ mini_login 页面判定为账号密码错误")
|
||
raise PasswordLoginVerificationError("账号密码错误,请检查账号密码是否正确")
|
||
|
||
verification_screenshot = self._capture_verification_screenshot(page, frame=frame)
|
||
verify_url = frame_url
|
||
if verification_type == 'face_verify':
|
||
verify_url = self._get_face_verification_url(frame) or frame_url
|
||
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 检测到 mini_login 页面(人脸验证/短信验证)")
|
||
logger.info(f"【{self.pure_user_id}】人脸验证/短信验证Frame URL: {frame_url}")
|
||
return True, VerificationFrameWrapper(
|
||
frame,
|
||
verification_type=verification_type,
|
||
verify_url=verify_url,
|
||
screenshot_path=verification_screenshot
|
||
)
|
||
|
||
# 检查frame的父iframe是否是alibaba-login-box
|
||
try:
|
||
# 尝试通过frame的父元素查找
|
||
frame_element = frame.frame_element()
|
||
if frame_element:
|
||
parent_iframe_id = frame_element.get_attribute('id')
|
||
if parent_iframe_id == 'alibaba-login-box':
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 检测到 alibaba-login-box(人脸验证/短信验证)")
|
||
logger.info(f"【{self.pure_user_id}】人脸验证/短信验证Frame URL: {frame_url}")
|
||
verification_type = self._detect_verification_type(frame)
|
||
if verification_type == 'password_error':
|
||
logger.error(f"【{self.pure_user_id}】❌ alibaba-login-box 页面判定为账号密码错误")
|
||
raise PasswordLoginVerificationError("账号密码错误,请检查账号密码是否正确")
|
||
|
||
verification_screenshot = self._capture_verification_screenshot(page, frame=frame)
|
||
verify_url = frame_url
|
||
if verification_type == 'face_verify':
|
||
verify_url = self._get_face_verification_url(frame) or frame_url
|
||
|
||
return True, VerificationFrameWrapper(
|
||
frame,
|
||
verification_type=verification_type,
|
||
verify_url=verify_url,
|
||
screenshot_path=verification_screenshot
|
||
)
|
||
except PasswordLoginVerificationError:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
|
||
# 先检查这个frame是否是滑块验证
|
||
is_slider_frame = False
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.debug(f"【{self.pure_user_id}】Frame {idx} 包含滑块验证元素,跳过")
|
||
is_slider_frame = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if is_slider_frame:
|
||
continue # 跳过滑块验证的frame
|
||
|
||
# 二维码验证的选择器(更精确,避免误判滑块验证)
|
||
qr_selectors = [
|
||
'img[alt*="二维码"]',
|
||
'img[alt*="扫码"]',
|
||
'img[src*="qrcode"]',
|
||
'canvas[class*="qrcode"]',
|
||
'.qr-code',
|
||
'#qr-code',
|
||
'[class*="qr-code"]',
|
||
'[id*="qr-code"]'
|
||
]
|
||
|
||
# 检查是否有真正的二维码图片(不是滑块验证中的qrcode类)
|
||
for selector in qr_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
# 进一步验证:检查是否包含滑块元素,如果包含则跳过
|
||
has_slider_in_frame = False
|
||
for slider_sel in slider_selectors:
|
||
try:
|
||
slider_elem = frame.query_selector(slider_sel)
|
||
if slider_elem and slider_elem.is_visible():
|
||
has_slider_in_frame = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not has_slider_in_frame:
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 检测到二维码验证: {selector}")
|
||
logger.info(f"【{self.pure_user_id}】二维码Frame URL: {frame_url}")
|
||
return True, frame
|
||
except:
|
||
continue
|
||
|
||
# 人脸验证的关键词(更精确)
|
||
face_keywords = ['拍摄脸部', '人脸验证', '人脸识别', '面部验证', '请进行人脸验证', '请完成人脸识别']
|
||
try:
|
||
frame_content = frame.content()
|
||
# 检查是否包含人脸验证关键词,但不包含滑块相关关键词
|
||
has_face_keyword = False
|
||
for keyword in face_keywords:
|
||
if keyword in frame_content:
|
||
has_face_keyword = True
|
||
break
|
||
|
||
# 如果包含人脸验证关键词,且不包含滑块关键词,则认为是人脸验证
|
||
if has_face_keyword:
|
||
slider_keywords = ['滑块', '拖动', 'nc_', 'nc-container']
|
||
has_slider_keyword = any(keyword in frame_content for keyword in slider_keywords)
|
||
|
||
if not has_slider_keyword:
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 检测到人脸验证")
|
||
logger.info(f"【{self.pure_user_id}】人脸验证Frame URL: {frame_url}")
|
||
return True, frame
|
||
except:
|
||
pass
|
||
|
||
except PasswordLoginVerificationError:
|
||
raise
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查Frame {idx} 失败: {e}")
|
||
continue
|
||
|
||
logger.info(f"【{self.pure_user_id}】未检测到二维码/人脸验证")
|
||
return False, None
|
||
|
||
except PasswordLoginVerificationError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】检测二维码/人脸验证时出错: {e}")
|
||
return False, None
|
||
|
||
def _get_face_verification_url(self, frame) -> str:
|
||
"""在alibaba-login-box frame中,点击'其他验证方式',然后找到'通过拍摄脸部'的验证按钮,获取链接"""
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】开始查找人脸验证链接...")
|
||
|
||
# 等待frame加载完成
|
||
time.sleep(2)
|
||
|
||
# 查找"其他验证方式"链接并点击
|
||
other_verify_clicked = False
|
||
try:
|
||
# 尝试通过文本内容查找所有链接
|
||
all_links = frame.query_selector_all('a')
|
||
for link in all_links:
|
||
try:
|
||
text = link.inner_text()
|
||
if '其他验证方式' in text or ('其他' in text and '验证' in text):
|
||
logger.info(f"【{self.pure_user_id}】找到'其他验证方式'链接,点击中...")
|
||
link.click()
|
||
time.sleep(2) # 等待页面切换
|
||
other_verify_clicked = True
|
||
break
|
||
except:
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】查找'其他验证方式'链接时出错: {e}")
|
||
|
||
if not other_verify_clicked:
|
||
logger.warning(f"【{self.pure_user_id}】未找到'其他验证方式'链接,可能已经在验证方式选择页面")
|
||
|
||
# 等待页面加载
|
||
time.sleep(2)
|
||
|
||
# 查找"通过拍摄脸部"相关的验证按钮,获取href并点击按钮
|
||
face_verify_url = None
|
||
|
||
# 方法1: 使用JavaScript精确查找,获取href并点击按钮(根据HTML结构:li > div.desc包含"通过 拍摄脸部" + a.ui-button包含"立即验证")
|
||
try:
|
||
href = frame.evaluate("""
|
||
() => {
|
||
// 查找所有li元素
|
||
const listItems = document.querySelectorAll('li');
|
||
for (let li of listItems) {
|
||
// 查找包含"通过 拍摄脸部"或"通过拍摄脸部"的desc div,但不能包含"手机"
|
||
const descDiv = li.querySelector('div.desc');
|
||
if (descDiv && !descDiv.innerText.includes('手机') && (descDiv.innerText.includes('通过 拍摄脸部') || descDiv.innerText.includes('通过拍摄脸部') || descDiv.innerText.includes('拍摄脸部'))) {
|
||
// 在同一li中查找"立即验证"按钮
|
||
const verifyButton = li.querySelector('a.ui-button, a.ui-button-small, button');
|
||
if (verifyButton && verifyButton.innerText && verifyButton.innerText.includes('立即验证')) {
|
||
// 获取按钮的href属性
|
||
const href = verifyButton.href || verifyButton.getAttribute('href') || null;
|
||
// 点击按钮
|
||
verifyButton.click();
|
||
// 返回href
|
||
return href;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
""")
|
||
if href:
|
||
face_verify_url = href
|
||
logger.info(f"【{self.pure_user_id}】通过JavaScript找到'通过拍摄脸部'验证按钮的href并已点击: {face_verify_url}")
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】方法1(JavaScript)查找失败: {e}")
|
||
|
||
# 方法2: 如果方法1失败,使用Playwright API查找并点击
|
||
if not face_verify_url:
|
||
try:
|
||
# 查找所有li元素
|
||
list_items = frame.query_selector_all('li')
|
||
for li in list_items:
|
||
try:
|
||
# 查找desc div
|
||
desc_div = li.query_selector('div.desc')
|
||
if desc_div:
|
||
desc_text = desc_div.inner_text()
|
||
if '手机' not in desc_text and ('通过 拍摄脸部' in desc_text or '通过拍摄脸部' in desc_text or '拍摄脸部' in desc_text):
|
||
logger.info(f"【{self.pure_user_id}】找到'通过拍摄脸部'选项(方法2)")
|
||
# 在同一li中查找验证按钮
|
||
verify_button = li.query_selector('a.ui-button, a.ui-button-small, button')
|
||
if verify_button:
|
||
button_text = verify_button.inner_text()
|
||
if '立即验证' in button_text:
|
||
# 获取按钮的href属性
|
||
href = verify_button.get_attribute('href')
|
||
if href:
|
||
face_verify_url = href
|
||
logger.info(f"【{self.pure_user_id}】找到'通过拍摄脸部'验证按钮的href: {face_verify_url}")
|
||
# 点击按钮
|
||
logger.info(f"【{self.pure_user_id}】点击'立即验证'按钮...")
|
||
verify_button.click()
|
||
logger.info(f"【{self.pure_user_id}】已点击'立即验证'按钮")
|
||
break
|
||
except:
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】方法2查找失败: {e}")
|
||
|
||
if face_verify_url:
|
||
# 如果是相对路径,转换为绝对路径
|
||
if not face_verify_url.startswith('http'):
|
||
base_url = frame.url.split('/iv/')[0] if '/iv/' in frame.url else 'https://passport.goofish.com'
|
||
if face_verify_url.startswith('/'):
|
||
face_verify_url = base_url + face_verify_url
|
||
else:
|
||
face_verify_url = base_url + '/' + face_verify_url
|
||
|
||
return face_verify_url
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】未找到人脸验证链接,返回原始frame URL")
|
||
return frame.url if hasattr(frame, 'url') else None
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】获取人脸验证链接时出错: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
return None
|
||
|
||
def login_with_password_playwright(self, account: str, password: str, show_browser: bool = False,
|
||
notification_callback: Optional[Callable] = None,
|
||
force_clean_context: bool = False) -> dict:
|
||
"""使用Playwright进行密码登录(新方法,替代DrissionPage)
|
||
|
||
Args:
|
||
account: 登录账号(必填)
|
||
password: 登录密码(必填)
|
||
show_browser: 是否显示浏览器窗口(默认False为无头模式)
|
||
notification_callback: 可选的通知回调函数,用于发送二维码/人脸验证通知(接受错误消息字符串作为参数)
|
||
force_clean_context: 是否强制使用干净的临时浏览器上下文
|
||
|
||
Returns:
|
||
dict: Cookie字典,失败返回None
|
||
"""
|
||
try:
|
||
self.last_login_error = ""
|
||
previous_slider_refresh_mode = getattr(self, '_slider_refresh_mode', False)
|
||
self._slider_refresh_mode = force_clean_context
|
||
|
||
# 检查日期有效性
|
||
if not self._check_date_validity():
|
||
logger.error(f"【{self.pure_user_id}】日期验证失败,无法执行登录")
|
||
return self._fail_login("日期验证失败,无法执行登录")
|
||
|
||
# 验证必需参数
|
||
if not account or not password:
|
||
logger.error(f"【{self.pure_user_id}】账号或密码不能为空")
|
||
return self._fail_login("账号或密码不能为空")
|
||
|
||
browser_mode = "有头" if show_browser else "无头"
|
||
notification_scene = "手动刷新Cookie" if force_clean_context else "账号密码登录"
|
||
logger.info(f"【{self.pure_user_id}】开始{browser_mode}模式密码登录流程(使用Playwright)...")
|
||
logger.info(f"【{self.pure_user_id}】账号: {account}")
|
||
logger.info("=" * 60)
|
||
|
||
import os
|
||
if force_clean_context:
|
||
logger.warning(f"【{self.pure_user_id}】刷新模式启用干净上下文,不复用历史浏览器会话")
|
||
else:
|
||
user_data_dir = os.path.join(os.getcwd(), 'browser_data', f'user_{self.pure_user_id}')
|
||
os.makedirs(user_data_dir, exist_ok=True)
|
||
logger.info(f"【{self.pure_user_id}】使用用户数据目录: {user_data_dir}")
|
||
|
||
# 在启动Playwright之前,重新检查和设置浏览器路径
|
||
# 确保使用正确的浏览器版本(避免版本不匹配问题)
|
||
import sys
|
||
from pathlib import Path
|
||
if getattr(sys, 'frozen', False):
|
||
# 如果是打包后的exe,检查exe同目录下的浏览器
|
||
exe_dir = Path(sys.executable).parent
|
||
playwright_dir = exe_dir / 'playwright'
|
||
|
||
if playwright_dir.exists():
|
||
chromium_dirs = list(playwright_dir.glob('chromium-*'))
|
||
# 找到第一个完整的浏览器目录
|
||
for chromium_dir in chromium_dirs:
|
||
chrome_exe = chromium_dir / 'chrome-win' / 'chrome.exe'
|
||
if chrome_exe.exists() and chrome_exe.stat().st_size > 0:
|
||
# 清除旧的环境变量,使用实际存在的浏览器
|
||
if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
|
||
old_path = os.environ['PLAYWRIGHT_BROWSERS_PATH']
|
||
if old_path != str(playwright_dir):
|
||
logger.info(f"【{self.pure_user_id}】清除旧的环境变量: {old_path}")
|
||
del os.environ['PLAYWRIGHT_BROWSERS_PATH']
|
||
# 设置正确的环境变量
|
||
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(playwright_dir)
|
||
logger.info(f"【{self.pure_user_id}】已设置PLAYWRIGHT_BROWSERS_PATH: {playwright_dir}")
|
||
logger.info(f"【{self.pure_user_id}】使用浏览器版本: {chromium_dir.name}")
|
||
break
|
||
|
||
# 🔧 关键修复:复用完整浏览器画像,与 captcha 验证流程保持一致
|
||
browser_features = self._get_random_browser_features()
|
||
self.browser_features = browser_features
|
||
self.profile_id = browser_features.get("profile_id", "unknown")
|
||
logger.info(f"【{self.pure_user_id}】密码登录使用浏览器画像: {self.profile_id}, "
|
||
f"viewport: {browser_features['viewport_width']}x{browser_features['viewport_height']}, "
|
||
f"scale: {browser_features['device_scale_factor']}")
|
||
|
||
# 设置浏览器启动参数(保持原始参数,之前有头模式正常工作)
|
||
browser_args = [
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-gpu',
|
||
'--disable-blink-features=AutomationControlled',
|
||
'--disable-web-security',
|
||
'--disable-features=VizDisplayCompositor',
|
||
'--lang=zh-CN',
|
||
'--disable-infobars',
|
||
'--disable-extensions',
|
||
'--disable-popup-blocking',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-renderer-backgrounding',
|
||
]
|
||
|
||
# 启动浏览器
|
||
playwright = sync_playwright().start()
|
||
browser = None
|
||
if force_clean_context:
|
||
browser = playwright.chromium.launch(
|
||
headless=not show_browser,
|
||
args=browser_args
|
||
)
|
||
context = browser.new_context(
|
||
viewport={'width': browser_features['viewport_width'], 'height': browser_features['viewport_height']},
|
||
user_agent=browser_features['user_agent'],
|
||
locale=browser_features['locale'],
|
||
accept_downloads=True,
|
||
ignore_https_errors=True,
|
||
extra_http_headers={
|
||
'Accept-Language': browser_features['accept_lang']
|
||
}
|
||
)
|
||
# 注入已有 Cookie(让浏览器不是全新空白状态,降低风控检测风险)
|
||
try:
|
||
from db_manager import db_manager as _db
|
||
_cookie_info = _db.get_cookie_details(self.pure_user_id)
|
||
if _cookie_info and _cookie_info.get('value'):
|
||
_cookie_str = _cookie_info['value']
|
||
_cookies_to_inject = []
|
||
for pair in _cookie_str.split(';'):
|
||
pair = pair.strip()
|
||
if '=' in pair:
|
||
name, value = pair.split('=', 1)
|
||
name = name.strip()
|
||
value = value.strip()
|
||
if name:
|
||
_cookies_to_inject.append({
|
||
'name': name,
|
||
'value': value,
|
||
'domain': '.goofish.com',
|
||
'path': '/',
|
||
})
|
||
# 同时注入 taobao 域(部分 Cookie 需要跨域)
|
||
if name in ('_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 'sgcookie', 'unb', 't', 'cna'):
|
||
_cookies_to_inject.append({
|
||
'name': name,
|
||
'value': value,
|
||
'domain': '.taobao.com',
|
||
'path': '/',
|
||
})
|
||
if _cookies_to_inject:
|
||
context.add_cookies(_cookies_to_inject)
|
||
logger.info(f"【{self.pure_user_id}】已注入 {len(_cookies_to_inject)} 个历史 Cookie 到干净上下文")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】历史 Cookie 为空,使用全新上下文")
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】未找到历史 Cookie,使用全新上下文")
|
||
except Exception as inject_e:
|
||
logger.warning(f"【{self.pure_user_id}】注入历史 Cookie 失败(不影响登录): {inject_e}")
|
||
else:
|
||
context = playwright.chromium.launch_persistent_context(
|
||
user_data_dir,
|
||
headless=not show_browser,
|
||
args=browser_args,
|
||
viewport={'width': browser_features['viewport_width'], 'height': browser_features['viewport_height']},
|
||
user_agent=browser_features['user_agent'],
|
||
locale=browser_features['locale'],
|
||
accept_downloads=True,
|
||
ignore_https_errors=True,
|
||
extra_http_headers={
|
||
'Accept-Language': browser_features['accept_lang']
|
||
}
|
||
)
|
||
logger.info(f"【{self.pure_user_id}】已设置浏览器语言为中文(zh-CN)")
|
||
|
||
if not browser:
|
||
browser = context.browser
|
||
page = context.new_page()
|
||
|
||
# 有头模式使用轻量反检测脚本(完整脚本会覆盖 document.fonts / EventTarget /
|
||
# Performance.now / Date 等浏览器核心 API,导致页面白屏无法渲染);
|
||
# 无头模式使用完整脚本以通过自动化检测。
|
||
if show_browser:
|
||
stealth_js = """
|
||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
|
||
window.chrome = { runtime: {} };
|
||
"""
|
||
page.add_init_script(stealth_js)
|
||
else:
|
||
page.add_init_script(self._get_stealth_script(browser_features))
|
||
|
||
logger.info(f"【{self.pure_user_id}】浏览器已成功启动({browser_mode}模式,画像: {self.profile_id})")
|
||
|
||
try:
|
||
# 预访问:先访问闲鱼首页建立正常浏览历史(降低空白浏览器的风控风险)
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】预访问闲鱼首页,建立浏览历史...")
|
||
page.goto("https://www.goofish.com", wait_until='domcontentloaded', timeout=15000)
|
||
time.sleep(random.uniform(1.0, 2.0))
|
||
logger.info(f"【{self.pure_user_id}】预访问完成,当前URL: {page.url}")
|
||
except Exception as warmup_e:
|
||
logger.warning(f"【{self.pure_user_id}】预访问失败(不影响登录): {warmup_e}")
|
||
|
||
# 访问登录页面(带重试逻辑)
|
||
login_url = "https://www.goofish.com/im"
|
||
logger.info(f"【{self.pure_user_id}】访问登录页面: {login_url}")
|
||
|
||
max_retries = 3
|
||
for attempt in range(max_retries):
|
||
try:
|
||
page.goto(login_url, wait_until='networkidle', timeout=60000)
|
||
break
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
if any(err in error_msg for err in ['ERR_CONNECTION_CLOSED', 'ERR_CONNECTION_RESET', 'ERR_CONNECTION_REFUSED']):
|
||
if attempt < max_retries - 1:
|
||
wait_time = 2 * (attempt + 1)
|
||
logger.warning(f"【{self.pure_user_id}】连接被关闭,{wait_time}秒后第{attempt+2}次重试...")
|
||
time.sleep(wait_time)
|
||
continue
|
||
raise
|
||
|
||
# 等待页面加载
|
||
wait_time = 2 if not show_browser else 2
|
||
logger.info(f"【{self.pure_user_id}】等待页面加载({wait_time}秒)...")
|
||
time.sleep(wait_time)
|
||
|
||
# 页面诊断信息
|
||
logger.info(f"【{self.pure_user_id}】========== 页面诊断信息 ==========")
|
||
logger.info(f"【{self.pure_user_id}】当前URL: {page.url}")
|
||
logger.info(f"【{self.pure_user_id}】页面标题: {page.title()}")
|
||
logger.info(f"【{self.pure_user_id}】=====================================")
|
||
|
||
# 【步骤1】查找登录frame(闲鱼登录通常在iframe中)
|
||
logger.info(f"【{self.pure_user_id}】查找登录frame...")
|
||
login_selectors = self._get_password_login_selectors()
|
||
|
||
# 等待页面和iframe加载完成
|
||
logger.info(f"【{self.pure_user_id}】等待页面和iframe加载...")
|
||
time.sleep(1)
|
||
login_frame, found_login_form, matched_selector = self._find_login_form_with_retry(
|
||
page,
|
||
timeout_seconds=8.0,
|
||
poll_interval=1.0,
|
||
)
|
||
iframes = page.query_selector_all('iframe')
|
||
logger.info(f"【{self.pure_user_id}】当前检测到 {len(iframes)} 个 iframe")
|
||
|
||
# 【情况1】找到frame且找到登录表单 → 正常登录流程
|
||
if found_login_form:
|
||
logger.info(f"【{self.pure_user_id}】找到登录表单({matched_selector}),开始正常登录流程...")
|
||
|
||
# 【情况2】找到frame但未找到登录表单 → 可能已登录,直接检测滑块
|
||
elif len(iframes) > 0:
|
||
logger.warning(f"【{self.pure_user_id}】找到iframe但未找到登录表单,可能已登录,检测滑块...")
|
||
|
||
# 先将page和context保存到实例变量(供solve_slider使用)
|
||
original_page = self.page
|
||
original_context = self.context
|
||
original_browser = self.browser
|
||
original_playwright = self.playwright
|
||
|
||
self.page = page
|
||
self.context = context
|
||
self.browser = browser
|
||
self.playwright = playwright
|
||
|
||
try:
|
||
monitor_page = self._select_monitor_page(context, page)
|
||
|
||
has_error, error_message = self._check_login_error(monitor_page)
|
||
if has_error:
|
||
logger.error(f"【{self.pure_user_id}】❌ 登录失败:{error_message}")
|
||
raise Exception(error_message if error_message else "登录失败,请检查账号密码是否正确")
|
||
|
||
has_qr, qr_frame = self._detect_qr_code_verification(monitor_page)
|
||
if has_qr:
|
||
logger.warning(f"【{self.pure_user_id}】检测到前置身份验证,直接进入验证等待流程")
|
||
return self._process_verification_requirement(
|
||
context,
|
||
monitor_page,
|
||
qr_frame,
|
||
notification_callback,
|
||
notification_scene,
|
||
)
|
||
|
||
# 检测滑块元素(在主页面和所有frame中查找)
|
||
slider_selectors = [
|
||
'#nc_1_n1z',
|
||
'.nc-container',
|
||
'.nc_scale',
|
||
'.nc-wrapper'
|
||
]
|
||
|
||
has_slider = False
|
||
detected_slider_frame = None
|
||
|
||
# 先在主页面查找
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = page.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】✅ 在主页面检测到滑块验证元素: {selector}")
|
||
has_slider = True
|
||
detected_slider_frame = None # None表示主页面
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 如果主页面没找到,在所有frame中查找
|
||
if not has_slider:
|
||
for idx, iframe in enumerate(iframes):
|
||
try:
|
||
frame = iframe.content_frame()
|
||
if frame:
|
||
# 等待frame内容加载
|
||
try:
|
||
frame.wait_for_load_state('domcontentloaded', timeout=2000)
|
||
except:
|
||
pass
|
||
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】✅ 在Frame {idx} 检测到滑块验证元素: {selector}")
|
||
has_slider = True
|
||
detected_slider_frame = frame
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if has_slider:
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"【{self.pure_user_id}】检查Frame {idx}时出错: {e}")
|
||
continue
|
||
|
||
if has_slider:
|
||
# 设置检测到的frame,供solve_slider使用
|
||
self._detected_slider_frame = detected_slider_frame
|
||
if force_clean_context:
|
||
logger.info(f"【{self.pure_user_id}】干净上下文检测到前置风控滑块,尝试自动处理...")
|
||
|
||
logger.warning(f"【{self.pure_user_id}】检测到滑块验证,开始处理...")
|
||
slider_risk_log = self._start_password_login_slider_risk_log(
|
||
verification_url=(detected_slider_frame.url if detected_slider_frame and hasattr(detected_slider_frame, 'url') else getattr(page, 'url', None)),
|
||
detection_phase='pre_login_monitor',
|
||
)
|
||
time.sleep(3)
|
||
slider_success = self.solve_slider(max_retries=3)
|
||
|
||
if not slider_success:
|
||
feedback = self.last_verification_feedback or {}
|
||
if feedback.get("source") == "slider_missing":
|
||
logger.error(f"【{self.pure_user_id}】❌ 滑块流程结束后页面已不再包含滑块,停止额外刷新重试")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=False,
|
||
verification_url=(detected_slider_frame.url if detected_slider_frame and hasattr(detected_slider_frame, 'url') else getattr(page, 'url', None)),
|
||
error_message=self._get_slider_failure_message("页面状态已变化,未找到滑块容器,请重新尝试刷新Cookie"),
|
||
extra_meta={'detection_source': 'login_with_password_playwright_pre_login'},
|
||
)
|
||
return self._fail_login(self._get_slider_failure_message("页面状态已变化,未找到滑块容器,请重新尝试刷新Cookie"))
|
||
|
||
# 3次失败后,刷新页面重试
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 滑块处理3次都失败,刷新页面后重试...")
|
||
try:
|
||
page.reload(wait_until="domcontentloaded", timeout=30000)
|
||
logger.info(f"【{self.pure_user_id}】✅ 页面刷新完成")
|
||
time.sleep(2)
|
||
slider_success = self.solve_slider(max_retries=3)
|
||
if not slider_success:
|
||
feedback = self.last_verification_feedback or {}
|
||
if feedback.get("source") == "slider_missing":
|
||
logger.error(f"【{self.pure_user_id}】❌ 刷新后页面未出现滑块,停止重复尝试")
|
||
logger.error(f"【{self.pure_user_id}】❌ 刷新后滑块验证仍然失败")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=False,
|
||
verification_url=(detected_slider_frame.url if detected_slider_frame and hasattr(detected_slider_frame, 'url') else getattr(page, 'url', None)),
|
||
error_message=self._get_slider_failure_message("滑块验证失败,请稍后重试"),
|
||
extra_meta={'detection_source': 'login_with_password_playwright_pre_login'},
|
||
)
|
||
return self._fail_login(self._get_slider_failure_message("滑块验证失败,请稍后重试"))
|
||
else:
|
||
logger.success(f"【{self.pure_user_id}】✅ 刷新后滑块验证成功!")
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】❌ 页面刷新失败: {e}")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=False,
|
||
verification_url=(detected_slider_frame.url if detected_slider_frame and hasattr(detected_slider_frame, 'url') else getattr(page, 'url', None)),
|
||
error_message=f"页面会话已失效: {str(e)}",
|
||
extra_meta={'detection_source': 'login_with_password_playwright_pre_login'},
|
||
)
|
||
return self._fail_login("页面会话已失效,请重新尝试刷新Cookie")
|
||
else:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块验证成功!")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=True,
|
||
verification_url=(detected_slider_frame.url if detected_slider_frame and hasattr(detected_slider_frame, 'url') else getattr(page, 'url', None)),
|
||
processing_result='密码登录流程中的滑块验证自动处理成功',
|
||
extra_meta={'detection_source': 'login_with_password_playwright_pre_login'},
|
||
)
|
||
|
||
# 等待页面加载和状态更新(第一次等待3秒)
|
||
logger.info(f"【{self.pure_user_id}】等待3秒,让页面加载完成...")
|
||
time.sleep(3)
|
||
|
||
# 第一次检查登录状态
|
||
login_success, active_page, _ = self._probe_context_login_success(context, page)
|
||
|
||
# 如果第一次没检测到,再等待5秒后重试
|
||
if not login_success:
|
||
logger.info(f"【{self.pure_user_id}】第一次检测未发现登录状态,等待5秒后重试...")
|
||
time.sleep(5)
|
||
login_success, active_page, _ = self._probe_context_login_success(context, active_page or page)
|
||
|
||
if login_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块验证后登录成功")
|
||
|
||
# 只有在登录成功后才获取Cookie
|
||
cookies_dict = self._snapshot_context_cookies(context)
|
||
logger.info(f"【{self.pure_user_id}】成功获取Cookie,包含 {len(cookies_dict)} 个字段")
|
||
|
||
if cookies_dict:
|
||
self._log_cookie_snapshot_integrity(cookies_dict, "滑块验证后")
|
||
logger.success("✅ Cookie有效")
|
||
return cookies_dict
|
||
|
||
logger.error("❌ Cookie为空")
|
||
return self._fail_login("登录成功后未获取到有效Cookie")
|
||
else:
|
||
# 滑块验证后登录状态不明确,检测是否需要人脸/短信/二维码验证
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 滑块验证后登录状态不明确,检测是否需要身份验证...")
|
||
time.sleep(1)
|
||
monitor_page = self._select_monitor_page(context, page)
|
||
has_qr, qr_frame = self._detect_qr_code_verification(monitor_page)
|
||
|
||
if has_qr:
|
||
return self._process_verification_requirement(
|
||
context,
|
||
monitor_page,
|
||
qr_frame,
|
||
notification_callback,
|
||
notification_scene,
|
||
)
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 未检测到身份验证,登录状态不明确")
|
||
return self._fail_login("滑块验证后登录状态未确认,请稍后重试")
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】未检测到滑块验证")
|
||
|
||
# 未检测到滑块时,检查是否已登录
|
||
login_success, active_page, _ = self._probe_context_login_success(context, page)
|
||
if login_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 检测到已登录状态")
|
||
|
||
# 只有在登录成功后才获取Cookie
|
||
cookies_dict = self._snapshot_context_cookies(context)
|
||
logger.info(f"【{self.pure_user_id}】成功获取Cookie,包含 {len(cookies_dict)} 个字段")
|
||
|
||
if cookies_dict:
|
||
self._log_cookie_snapshot_integrity(cookies_dict, "无滑块已登录场景")
|
||
logger.success("✅ Cookie有效")
|
||
return cookies_dict
|
||
|
||
logger.error("❌ Cookie为空")
|
||
return self._fail_login("未获取到有效Cookie")
|
||
else:
|
||
monitor_page = self._select_monitor_page(context, active_page or page)
|
||
has_qr, qr_frame = self._detect_qr_code_verification(monitor_page)
|
||
if has_qr:
|
||
return self._process_verification_requirement(
|
||
context,
|
||
monitor_page,
|
||
qr_frame,
|
||
notification_callback,
|
||
notification_scene,
|
||
)
|
||
logger.warning(f"【{self.pure_user_id}】⚠️ 未检测到滑块且未登录,不获取Cookie")
|
||
return self._fail_login("未检测到登录表单或有效登录态")
|
||
|
||
finally:
|
||
# 恢复原始值
|
||
self.page = original_page
|
||
self.context = original_context
|
||
self.browser = original_browser
|
||
self.playwright = original_playwright
|
||
|
||
# 【情况3】未找到frame → 检查是否已登录
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】未找到任何iframe,检查是否已登录...")
|
||
|
||
# 等待一下让页面完全加载
|
||
time.sleep(2)
|
||
|
||
# 检查是否已登录(只有过了滑块才会有这个元素)
|
||
login_success, active_page, _ = self._probe_context_login_success(context, page)
|
||
if login_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 检测到已登录状态")
|
||
|
||
# 🔧 刷新模式下验证 session 是否真的有效
|
||
# 注入旧 Cookie 可能让前端显示"已登录",但服务端 session 已过期
|
||
if force_clean_context:
|
||
logger.info(f"【{self.pure_user_id}】刷新模式:验证服务端Session是否有效...")
|
||
try:
|
||
verify_page = context.new_page()
|
||
verify_resp = verify_page.goto(
|
||
"https://h5api.m.goofish.com/h5/mtop.taobao.idlemessage.pc.login.token/1.0/?jsv=2.7.2&appKey=34839810&type=originaljson&dataType=json&v=1.0&api=mtop.taobao.idlemessage.pc.login.token&sessionOption=AutoLoginOnly",
|
||
wait_until="domcontentloaded",
|
||
timeout=10000
|
||
)
|
||
verify_text = verify_page.content()
|
||
verify_page.close()
|
||
|
||
if "FAIL_SYS_SESSION_EXPIRED" in verify_text or "FAIL_SYS_USER_VALIDATE" in verify_text:
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】服务端Session已过期,"
|
||
f"前端登录状态为假象,需要重新账密登录"
|
||
)
|
||
page, login_frame, found_login_form, matched_selector, reopened_fresh_page = (
|
||
self._prepare_login_page_after_cleanup(
|
||
context,
|
||
page,
|
||
clear_storage=True,
|
||
reopen_fresh_page=True,
|
||
timeout_seconds=8.0,
|
||
)
|
||
)
|
||
if not found_login_form:
|
||
logger.error(f"【{self.pure_user_id}】清理会话状态后仍未找到登录表单")
|
||
return self._fail_login("Session过期且清理会话状态后未找到登录表单")
|
||
if reopened_fresh_page:
|
||
logger.info(f"【{self.pure_user_id}】已切换到新页面继续账密登录")
|
||
# 跳出当前分支,继续走下面的账密输入流程
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】✅ 服务端Session验证通过,Cookie有效")
|
||
cookies_dict = self._snapshot_context_cookies(context)
|
||
if cookies_dict:
|
||
self._log_cookie_snapshot_integrity(cookies_dict, "无 iframe 已登录场景(Session已验证)")
|
||
logger.success("✅ 登录成功!Cookie有效且Session有效")
|
||
return cookies_dict
|
||
logger.error("❌ Cookie为空")
|
||
return None
|
||
except Exception as verify_e:
|
||
logger.warning(f"【{self.pure_user_id}】Session验证异常: {verify_e},按Session过期处理")
|
||
page, login_frame, found_login_form, matched_selector, reopened_fresh_page = (
|
||
self._prepare_login_page_after_cleanup(
|
||
context,
|
||
page,
|
||
clear_storage=True,
|
||
reopen_fresh_page=True,
|
||
timeout_seconds=8.0,
|
||
)
|
||
)
|
||
if not found_login_form:
|
||
return self._fail_login("Session验证异常且清理会话状态后未找到登录表单")
|
||
if reopened_fresh_page:
|
||
logger.info(f"【{self.pure_user_id}】Session异常后已切换到新页面继续账密登录")
|
||
else:
|
||
# 非刷新模式,直接返回Cookie
|
||
cookies_dict = self._snapshot_context_cookies(context)
|
||
if cookies_dict:
|
||
self._log_cookie_snapshot_integrity(cookies_dict, "无 iframe 已登录场景")
|
||
logger.success("✅ 登录成功!Cookie有效")
|
||
return cookies_dict
|
||
|
||
logger.error("❌ Cookie为空")
|
||
return None
|
||
else:
|
||
# 持久化上下文可能因浏览器缓存导致页面处于"半登录"状态
|
||
# 既没有登录 iframe,也没有已登录元素
|
||
if not force_clean_context:
|
||
logger.warning(
|
||
f"【{self.pure_user_id}】持久化上下文页面状态异常(无iframe、无已登录态),"
|
||
f"清除Cookie和缓存后重新加载..."
|
||
)
|
||
page, login_frame, found_login_form, matched_selector, reopened_fresh_page = (
|
||
self._prepare_login_page_after_cleanup(
|
||
context,
|
||
page,
|
||
clear_storage=True,
|
||
reopen_fresh_page=True,
|
||
timeout_seconds=10.0,
|
||
)
|
||
)
|
||
|
||
if not found_login_form:
|
||
logger.error(f"【{self.pure_user_id}】❌ 清除缓存后仍未找到登录表单")
|
||
return self._fail_login("持久化上下文清除缓存后仍未找到登录表单")
|
||
if reopened_fresh_page:
|
||
logger.info(f"【{self.pure_user_id}】清缓存后已切换到新页面继续账密登录")
|
||
logger.info(f"【{self.pure_user_id}】✓ 清除缓存后找到登录表单: {matched_selector}")
|
||
# found_login_form=True → 继续走下面的账密输入流程
|
||
else:
|
||
logger.error(f"【{self.pure_user_id}】❌ 未找到登录表单且未检测到已登录")
|
||
return self._fail_login("未找到登录表单且未检测到已登录状态")
|
||
|
||
# 点击密码登录标签
|
||
logger.info(f"【{self.pure_user_id}】查找密码登录标签...")
|
||
try:
|
||
password_tab, password_tab_selector = self._query_first_visible(
|
||
login_frame,
|
||
login_selectors['tab'],
|
||
)
|
||
if password_tab:
|
||
logger.info(f"【{self.pure_user_id}】✓ 找到密码登录标签,点击中: {password_tab_selector}")
|
||
password_tab.click()
|
||
time.sleep(1.5)
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】未找到密码登录标签,可能默认已处于密码登录模式")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】查找密码登录标签失败: {e}")
|
||
|
||
# 输入账号
|
||
logger.info(f"【{self.pure_user_id}】输入账号: {account}")
|
||
time.sleep(1)
|
||
|
||
account_input, account_selector = self._query_first_visible(
|
||
login_frame,
|
||
login_selectors['account'],
|
||
)
|
||
if account_input:
|
||
logger.info(f"【{self.pure_user_id}】✓ 找到账号输入框: {account_selector}")
|
||
account_input.fill(account)
|
||
logger.info(f"【{self.pure_user_id}】✓ 账号已输入")
|
||
time.sleep(random.uniform(0.5, 1.0))
|
||
else:
|
||
handled, recovery_result = self._recover_from_missing_login_inputs(
|
||
context,
|
||
page,
|
||
missing_field='账号输入框',
|
||
notification_callback=notification_callback,
|
||
notification_scene=notification_scene,
|
||
)
|
||
if handled:
|
||
return recovery_result
|
||
logger.error(f"【{self.pure_user_id}】✗ 未找到账号输入框")
|
||
return self._fail_login("未找到账号输入框")
|
||
|
||
# 输入密码
|
||
logger.info(f"【{self.pure_user_id}】输入密码...")
|
||
password_input, password_selector = self._query_first_visible(
|
||
login_frame,
|
||
login_selectors['password'],
|
||
)
|
||
if password_input:
|
||
logger.info(f"【{self.pure_user_id}】✓ 找到密码输入框: {password_selector}")
|
||
password_input.fill(password)
|
||
logger.info(f"【{self.pure_user_id}】✓ 密码已输入")
|
||
time.sleep(random.uniform(0.5, 1.0))
|
||
else:
|
||
handled, recovery_result = self._recover_from_missing_login_inputs(
|
||
context,
|
||
page,
|
||
missing_field='密码输入框',
|
||
notification_callback=notification_callback,
|
||
notification_scene=notification_scene,
|
||
)
|
||
if handled:
|
||
return recovery_result
|
||
logger.error(f"【{self.pure_user_id}】✗ 未找到密码输入框")
|
||
return self._fail_login("未找到密码输入框")
|
||
|
||
# 勾选用户协议
|
||
logger.info(f"【{self.pure_user_id}】查找并勾选用户协议...")
|
||
try:
|
||
agreement_checkbox, agreement_selector = self._query_first_visible(
|
||
login_frame,
|
||
login_selectors['agreement'],
|
||
)
|
||
if agreement_checkbox:
|
||
is_checked = agreement_checkbox.evaluate('el => el.checked')
|
||
if not is_checked:
|
||
agreement_checkbox.click()
|
||
time.sleep(0.3)
|
||
logger.info(f"【{self.pure_user_id}】✓ 用户协议已勾选: {agreement_selector}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】勾选用户协议失败: {e}")
|
||
|
||
# 点击登录按钮
|
||
logger.info(f"【{self.pure_user_id}】点击登录按钮...")
|
||
time.sleep(1)
|
||
|
||
login_button, login_button_selector = self._query_first_visible(
|
||
login_frame,
|
||
login_selectors['submit'],
|
||
)
|
||
if login_button:
|
||
logger.info(f"【{self.pure_user_id}】✓ 找到登录按钮: {login_button_selector}")
|
||
login_button.click()
|
||
logger.info(f"【{self.pure_user_id}】✓ 登录按钮已点击")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】未找到登录按钮,尝试回车提交")
|
||
try:
|
||
password_input.press('Enter')
|
||
logger.info(f"【{self.pure_user_id}】✓ 已通过回车提交登录")
|
||
except Exception:
|
||
logger.error(f"【{self.pure_user_id}】✗ 未找到登录按钮且回车提交失败")
|
||
return self._fail_login("未找到登录按钮")
|
||
|
||
# 【关键】点击登录后,等待一下再检测滑块
|
||
logger.info(f"【{self.pure_user_id}】========== 登录后监控 ==========")
|
||
logger.info(f"【{self.pure_user_id}】等待页面响应...")
|
||
time.sleep(3)
|
||
|
||
# 【核心】检测是否有滑块验证 → 如果有,调用 solve_slider() 处理
|
||
logger.info(f"【{self.pure_user_id}】检测是否有滑块验证...")
|
||
|
||
# 先将page和context保存到实例变量(供solve_slider使用)
|
||
original_page = self.page
|
||
original_context = self.context
|
||
original_browser = self.browser
|
||
original_playwright = self.playwright
|
||
|
||
self.page = page
|
||
self.context = context
|
||
self.browser = browser
|
||
self.playwright = playwright
|
||
|
||
try:
|
||
# 检查页面内容是否包含滑块相关元素
|
||
page_content = page.content()
|
||
has_slider = False
|
||
|
||
# 检测滑块元素
|
||
slider_selectors = [
|
||
'#nc_1_n1z',
|
||
'.nc-container',
|
||
'.nc_scale',
|
||
'.nc-wrapper'
|
||
]
|
||
|
||
# 在主页面和所有 iframe 中查找滑块(阿里系滑块常嵌在 iframe 中)
|
||
search_frames = [page]
|
||
try:
|
||
for frame in page.frames:
|
||
if frame != page.main_frame:
|
||
search_frames.append(frame)
|
||
except Exception:
|
||
pass
|
||
|
||
for search_frame in search_frames:
|
||
if has_slider:
|
||
break
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = search_frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】✅ 检测到滑块验证元素: {selector} (frame: {getattr(search_frame, 'url', 'main')[:80]})")
|
||
has_slider = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if has_slider:
|
||
logger.warning(f"【{self.pure_user_id}】检测到滑块验证,开始处理...")
|
||
slider_risk_log = self._start_password_login_slider_risk_log(
|
||
verification_url=(getattr(search_frame, 'url', None) if 'search_frame' in locals() else getattr(page, 'url', None)),
|
||
detection_phase='post_login_monitor',
|
||
)
|
||
|
||
# 【复用】直接调用 solve_slider() 方法处理滑块
|
||
slider_success = self.solve_slider(max_retries=3)
|
||
|
||
if slider_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块验证成功!")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=True,
|
||
verification_url=(getattr(search_frame, 'url', None) if 'search_frame' in locals() else getattr(page, 'url', None)),
|
||
processing_result='密码登录流程中的滑块验证自动处理成功',
|
||
extra_meta={'detection_source': 'login_with_password_playwright_post_login'},
|
||
)
|
||
else:
|
||
logger.error(f"【{self.pure_user_id}】❌ 滑块验证3次均失败")
|
||
self._finish_password_login_slider_risk_log(
|
||
slider_risk_log,
|
||
success=False,
|
||
verification_url=(getattr(search_frame, 'url', None) if 'search_frame' in locals() else getattr(page, 'url', None)),
|
||
error_message=self._get_slider_failure_message("滑块验证失败,请稍后重试"),
|
||
extra_meta={'detection_source': 'login_with_password_playwright_post_login'},
|
||
)
|
||
return self._fail_login(self._get_slider_failure_message("滑块验证失败,请稍后重试"))
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】未检测到滑块验证")
|
||
|
||
# 等待登录完成
|
||
logger.info(f"【{self.pure_user_id}】等待登录完成...")
|
||
time.sleep(5)
|
||
|
||
# 再次检查是否有滑块验证(可能在等待过程中出现)
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后检查是否有滑块验证...")
|
||
time.sleep(1)
|
||
has_slider_after_wait = False
|
||
for search_frame in search_frames:
|
||
if has_slider_after_wait:
|
||
break
|
||
for selector in slider_selectors:
|
||
try:
|
||
element = search_frame.query_selector(selector)
|
||
if element and element.is_visible():
|
||
logger.info(f"【{self.pure_user_id}】✅ 等待后检测到滑块验证元素: {selector}")
|
||
has_slider_after_wait = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if has_slider_after_wait:
|
||
logger.warning(f"【{self.pure_user_id}】检测到滑块验证,开始处理...")
|
||
wait_slider_risk_log = self._start_password_login_slider_risk_log(
|
||
verification_url=getattr(active_page or page, 'url', None),
|
||
detection_phase='post_wait_monitor',
|
||
)
|
||
slider_success = self.solve_slider(max_retries=3)
|
||
if slider_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块验证成功!")
|
||
self._finish_password_login_slider_risk_log(
|
||
wait_slider_risk_log,
|
||
success=True,
|
||
verification_url=getattr(active_page or page, 'url', None),
|
||
processing_result='密码登录流程中的滑块验证自动处理成功(等待后)',
|
||
extra_meta={'detection_source': 'login_with_password_playwright_post_wait'},
|
||
)
|
||
time.sleep(3) # 等待滑块验证后的状态更新
|
||
else:
|
||
logger.error(f"【{self.pure_user_id}】❌ 滑块验证3次均失败")
|
||
self._finish_password_login_slider_risk_log(
|
||
wait_slider_risk_log,
|
||
success=False,
|
||
verification_url=getattr(active_page or page, 'url', None),
|
||
error_message=self._get_slider_failure_message("滑块验证失败,请稍后重试"),
|
||
extra_meta={'detection_source': 'login_with_password_playwright_post_wait'},
|
||
)
|
||
return self._fail_login(self._get_slider_failure_message("滑块验证失败,请稍后重试"))
|
||
|
||
# 检查登录状态
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后检查登录状态...")
|
||
time.sleep(1)
|
||
login_success, active_page, _ = self._probe_context_login_success(context, page)
|
||
|
||
if login_success:
|
||
logger.success(f"【{self.pure_user_id}】✅ 登录验证成功!")
|
||
else:
|
||
# 检查是否有账密错误
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后检查是否有账密错误...")
|
||
time.sleep(1)
|
||
monitor_page = self._select_monitor_page(context, active_page or page)
|
||
has_error, error_message = self._check_login_error(monitor_page)
|
||
if has_error:
|
||
logger.error(f"【{self.pure_user_id}】❌ 登录失败:{error_message}")
|
||
# 抛出异常,包含错误消息,让调用者能够获取
|
||
raise Exception(error_message if error_message else "登录失败,请检查账号密码是否正确")
|
||
|
||
# 【重要】检测是否需要二维码/人脸验证(排除滑块验证)
|
||
# 注意:_detect_qr_code_verification 如果检测到滑块,会立即处理滑块
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后检测是否需要二维码/人脸验证...")
|
||
time.sleep(1)
|
||
logger.info(f"【{self.pure_user_id}】检测是否需要二维码/人脸验证...")
|
||
monitor_page = self._select_monitor_page(context, active_page or page)
|
||
has_qr, qr_frame = self._detect_qr_code_verification(monitor_page)
|
||
|
||
# 如果检测到滑块并已处理,再次检查登录状态
|
||
if not has_qr:
|
||
# 滑块可能已被处理,再次检查登录状态
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后再次检查登录状态...")
|
||
time.sleep(1)
|
||
login_success_after_slider, active_page, _ = self._probe_context_login_success(context, monitor_page)
|
||
if login_success_after_slider:
|
||
logger.success(f"【{self.pure_user_id}】✅ 滑块验证后,登录验证成功!")
|
||
login_success = True
|
||
else:
|
||
# 滑块验证后仍未登录成功,继续检测二维码/人脸验证(此时应该不会再检测到滑块)
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后继续检测是否需要二维码/人脸验证...")
|
||
time.sleep(1)
|
||
logger.info(f"【{self.pure_user_id}】滑块验证后,继续检测是否需要二维码/人脸验证...")
|
||
monitor_page = self._select_monitor_page(context, active_page or monitor_page)
|
||
has_qr, qr_frame = self._detect_qr_code_verification(monitor_page)
|
||
|
||
if has_qr:
|
||
return self._process_verification_requirement(
|
||
context,
|
||
monitor_page,
|
||
qr_frame,
|
||
notification_callback,
|
||
notification_scene,
|
||
)
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】未检测到二维码/人脸验证")
|
||
# 再次检查登录状态,确保登录成功
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后再次检查登录状态...")
|
||
time.sleep(1)
|
||
login_success, active_page, _ = self._probe_context_login_success(context, active_page or page)
|
||
if not login_success:
|
||
logger.error(f"【{self.pure_user_id}】❌ 登录状态未确认,无法获取Cookie")
|
||
return self._fail_login("登录状态未确认,无法获取Cookie")
|
||
else:
|
||
logger.success(f"【{self.pure_user_id}】✅ 登录状态已确认")
|
||
|
||
# 【重要】只有在 login_success = True 的情况下,才获取Cookie
|
||
if not login_success:
|
||
logger.error(f"【{self.pure_user_id}】❌ 登录未成功,无法获取Cookie")
|
||
return self._fail_login("登录未成功,无法获取Cookie")
|
||
|
||
# 获取Cookie
|
||
logger.info(f"【{self.pure_user_id}】等待1秒后获取Cookie...")
|
||
time.sleep(1)
|
||
cookies_dict = {}
|
||
try:
|
||
cookies_list = context.cookies()
|
||
for cookie in cookies_list:
|
||
cookies_dict[cookie.get('name', '')] = cookie.get('value', '')
|
||
|
||
logger.info(f"【{self.pure_user_id}】成功获取Cookie,包含 {len(cookies_dict)} 个字段")
|
||
|
||
# 打印关键Cookie字段
|
||
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
|
||
logger.info(f"【{self.pure_user_id}】关键Cookie字段检查:")
|
||
for key in important_keys:
|
||
if key in cookies_dict:
|
||
val = cookies_dict[key]
|
||
logger.info(f"【{self.pure_user_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】 ❌ {key}: 缺失")
|
||
|
||
logger.info("=" * 60)
|
||
|
||
if cookies_dict:
|
||
self._log_cookie_snapshot_integrity(cookies_dict, "密码登录完成后")
|
||
logger.success("✅ 登录成功!Cookie有效")
|
||
return cookies_dict
|
||
else:
|
||
logger.error("❌ 未获取到Cookie")
|
||
return self._fail_login("登录成功后未获取到Cookie")
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】获取Cookie失败: {e}")
|
||
return self._fail_login("获取Cookie失败")
|
||
|
||
finally:
|
||
# 恢复原始值
|
||
self.page = original_page
|
||
self.context = original_context
|
||
self.browser = original_browser
|
||
self.playwright = original_playwright
|
||
|
||
finally:
|
||
# 关闭浏览器
|
||
try:
|
||
if context:
|
||
context.close()
|
||
if force_clean_context and browser:
|
||
try:
|
||
browser.close()
|
||
except Exception:
|
||
pass
|
||
playwright.stop()
|
||
if force_clean_context:
|
||
logger.info(f"【{self.pure_user_id}】浏览器已关闭,干净上下文已销毁")
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】浏览器已关闭,缓存已保存")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】关闭浏览器时出错: {e}")
|
||
try:
|
||
playwright.stop()
|
||
except:
|
||
pass
|
||
|
||
# 释放并发槽位(防止槽位泄漏导致后续任务永远等待)
|
||
try:
|
||
concurrency_manager.unregister_instance(self.user_id)
|
||
stats = concurrency_manager.get_stats()
|
||
logger.info(f"【{self.pure_user_id}】密码登录结束,已释放并发槽位,当前并发: {stats['active_count']}/{stats['max_concurrent']}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】释放并发槽位时出错: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】密码登录流程异常: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
error_message = str(e)
|
||
if "Target page, context or browser has been closed" in error_message:
|
||
return self._fail_login("页面会话已失效,请重新尝试刷新Cookie")
|
||
return self._fail_login(error_message if error_message else "密码登录流程异常")
|
||
finally:
|
||
self._slider_refresh_mode = previous_slider_refresh_mode
|
||
# 最外层 finally:确保任何退出路径都释放并发槽位
|
||
try:
|
||
concurrency_manager.unregister_instance(self.user_id)
|
||
except Exception:
|
||
pass
|
||
|
||
def login_with_password_headful(self, account: str = None, password: str = None, show_browser: bool = False):
|
||
"""通过浏览器进行密码登录并获取Cookie (使用DrissionPage)
|
||
|
||
Args:
|
||
account: 登录账号(必填)
|
||
password: 登录密码(必填)
|
||
show_browser: 是否显示浏览器窗口(默认False为无头模式)
|
||
True: 有头模式,登录后等待5分钟(可手动处理验证码)
|
||
False: 无头模式,登录后等待10秒
|
||
|
||
Returns:
|
||
dict: 获取到的cookie字典,失败返回None
|
||
"""
|
||
page = None
|
||
try:
|
||
# 检查日期有效性
|
||
if not self._check_date_validity():
|
||
logger.error(f"【{self.pure_user_id}】日期验证失败,无法执行登录")
|
||
return None
|
||
|
||
# 验证必需参数
|
||
if not account or not password:
|
||
logger.error(f"【{self.pure_user_id}】账号或密码不能为空")
|
||
return None
|
||
|
||
browser_mode = "有头" if show_browser else "无头"
|
||
logger.info(f"【{self.pure_user_id}】开始{browser_mode}模式密码登录流程(使用DrissionPage)...")
|
||
|
||
# 导入 DrissionPage
|
||
try:
|
||
from DrissionPage import ChromiumPage, ChromiumOptions
|
||
logger.info(f"【{self.pure_user_id}】DrissionPage导入成功")
|
||
except ImportError:
|
||
logger.error(f"【{self.pure_user_id}】DrissionPage未安装,请执行: pip install DrissionPage")
|
||
return None
|
||
|
||
# 配置浏览器选项
|
||
logger.info(f"【{self.pure_user_id}】配置浏览器选项({browser_mode}模式)...")
|
||
co = ChromiumOptions()
|
||
|
||
# 根据 show_browser 参数决定是否启用无头模式
|
||
if not show_browser:
|
||
co.headless()
|
||
logger.info(f"【{self.pure_user_id}】已启用无头模式")
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】已启用有头模式(浏览器可见)")
|
||
|
||
# 设置浏览器参数(反检测)
|
||
co.set_argument('--no-sandbox')
|
||
co.set_argument('--disable-setuid-sandbox')
|
||
co.set_argument('--disable-dev-shm-usage')
|
||
co.set_argument('--disable-blink-features=AutomationControlled')
|
||
co.set_argument('--disable-infobars')
|
||
co.set_argument('--disable-extensions')
|
||
co.set_argument('--disable-popup-blocking')
|
||
co.set_argument('--disable-notifications')
|
||
|
||
# 无头模式需要的额外参数
|
||
if not show_browser:
|
||
co.set_argument('--disable-gpu')
|
||
co.set_argument('--disable-software-rasterizer')
|
||
else:
|
||
# 有头模式窗口最大化
|
||
co.set_argument('--start-maximized')
|
||
|
||
# 设置用户代理
|
||
browser_features = self._get_random_browser_features()
|
||
co.set_user_agent(browser_features['user_agent'])
|
||
|
||
# 设置中文语言
|
||
co.set_argument('--lang=zh-CN')
|
||
logger.info(f"【{self.pure_user_id}】已设置浏览器语言为中文(zh-CN)")
|
||
|
||
# 禁用自动化特征检测
|
||
co.set_pref('excludeSwitches', ['enable-automation'])
|
||
co.set_pref('useAutomationExtension', False)
|
||
|
||
# 创建浏览器页面,添加重试机制
|
||
logger.info(f"【{self.pure_user_id}】启动DrissionPage浏览器({browser_mode}模式)...")
|
||
max_retries = 3
|
||
retry_count = 0
|
||
page = None
|
||
|
||
while retry_count < max_retries and page is None:
|
||
try:
|
||
if retry_count > 0:
|
||
logger.info(f"【{self.pure_user_id}】第 {retry_count + 1} 次尝试启动浏览器...")
|
||
time.sleep(2) # 等待2秒后重试
|
||
|
||
page = ChromiumPage(addr_or_opts=co)
|
||
logger.info(f"【{self.pure_user_id}】浏览器已成功启动({browser_mode}模式)")
|
||
break
|
||
|
||
except Exception as browser_error:
|
||
retry_count += 1
|
||
logger.warning(f"【{self.pure_user_id}】浏览器启动失败 (尝试 {retry_count}/{max_retries}): {str(browser_error)}")
|
||
|
||
if retry_count >= max_retries:
|
||
logger.error(f"【{self.pure_user_id}】浏览器启动失败,已达到最大重试次数")
|
||
logger.error(f"【{self.pure_user_id}】可能的原因:")
|
||
logger.error(f"【{self.pure_user_id}】1. Chrome/Chromium 浏览器未正确安装或路径不正确")
|
||
logger.error(f"【{self.pure_user_id}】2. 远程调试端口被占用,请关闭其他Chrome实例")
|
||
logger.error(f"【{self.pure_user_id}】3. 系统资源不足")
|
||
logger.error(f"【{self.pure_user_id}】建议:")
|
||
logger.error(f"【{self.pure_user_id}】- 检查Chrome浏览器是否已安装")
|
||
logger.error(f"【{self.pure_user_id}】- 关闭所有Chrome浏览器窗口后重试")
|
||
logger.error(f"【{self.pure_user_id}】- 检查任务管理器中是否有残留的chrome.exe进程")
|
||
raise
|
||
|
||
# 尝试清理可能残留的Chrome进程
|
||
try:
|
||
import subprocess
|
||
import platform
|
||
if platform.system() == 'Windows':
|
||
subprocess.run(['taskkill', '/F', '/IM', 'chrome.exe'],
|
||
capture_output=True, timeout=5)
|
||
logger.info(f"【{self.pure_user_id}】已尝试清理残留Chrome进程")
|
||
except Exception as cleanup_error:
|
||
logger.debug(f"【{self.pure_user_id}】清理进程时出错: {cleanup_error}")
|
||
|
||
if page is None:
|
||
logger.error(f"【{self.pure_user_id}】无法启动浏览器")
|
||
return None
|
||
|
||
# 访问登录页面
|
||
target_url = "https://www.goofish.com/im"
|
||
logger.info(f"【{self.pure_user_id}】访问登录页面: {target_url}")
|
||
page.get(target_url)
|
||
|
||
# 等待页面加载
|
||
logger.info(f"【{self.pure_user_id}】等待页面加载...")
|
||
time.sleep(5)
|
||
|
||
# 检查页面状态
|
||
logger.info(f"【{self.pure_user_id}】========== 页面诊断信息 ==========")
|
||
current_url = page.url
|
||
logger.info(f"【{self.pure_user_id}】当前URL: {current_url}")
|
||
page_title = page.title
|
||
logger.info(f"【{self.pure_user_id}】页面标题: {page_title}")
|
||
|
||
|
||
logger.info(f"【{self.pure_user_id}】====================================")
|
||
|
||
# 查找并点击密码登录标签
|
||
logger.info(f"【{self.pure_user_id}】查找密码登录标签...")
|
||
password_tab_selectors = [
|
||
'.password-login-tab-item',
|
||
'text:密码登录',
|
||
'text:账号密码登录',
|
||
]
|
||
|
||
password_tab_found = False
|
||
for selector in password_tab_selectors:
|
||
try:
|
||
tab = page.ele(selector, timeout=3)
|
||
if tab:
|
||
logger.info(f"【{self.pure_user_id}】找到密码登录标签: {selector}")
|
||
tab.click()
|
||
logger.info(f"【{self.pure_user_id}】密码登录标签已点击")
|
||
time.sleep(2)
|
||
password_tab_found = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not password_tab_found:
|
||
logger.warning(f"【{self.pure_user_id}】未找到密码登录标签,可能页面默认就是密码登录模式")
|
||
|
||
# 查找登录表单
|
||
logger.info(f"【{self.pure_user_id}】开始检测登录表单...")
|
||
username_selectors = [
|
||
'#fm-login-id',
|
||
'input:name=fm-login-id',
|
||
'input:placeholder^=手机',
|
||
'input:placeholder^=账号',
|
||
'input:type=text',
|
||
'#TPL_username_1',
|
||
]
|
||
|
||
login_input = None
|
||
for selector in username_selectors:
|
||
try:
|
||
login_input = page.ele(selector, timeout=2)
|
||
if login_input:
|
||
logger.info(f"【{self.pure_user_id}】找到登录表单: {selector}")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not login_input:
|
||
logger.error(f"【{self.pure_user_id}】未找到登录表单")
|
||
return None
|
||
|
||
# 输入账号
|
||
logger.info(f"【{self.pure_user_id}】输入账号: {account}")
|
||
try:
|
||
login_input.click()
|
||
time.sleep(0.5)
|
||
login_input.input(account)
|
||
logger.info(f"【{self.pure_user_id}】账号已输入")
|
||
time.sleep(0.5)
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】输入账号失败: {str(e)}")
|
||
return None
|
||
|
||
# 输入密码
|
||
logger.info(f"【{self.pure_user_id}】输入密码...")
|
||
password_selectors = [
|
||
'#fm-login-password',
|
||
'input:name=fm-login-password',
|
||
'input:type=password',
|
||
'input:placeholder^=密码',
|
||
'#TPL_password_1',
|
||
]
|
||
|
||
password_input = None
|
||
for selector in password_selectors:
|
||
try:
|
||
password_input = page.ele(selector, timeout=2)
|
||
if password_input:
|
||
logger.info(f"【{self.pure_user_id}】找到密码输入框: {selector}")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not password_input:
|
||
logger.error(f"【{self.pure_user_id}】未找到密码输入框")
|
||
return None
|
||
|
||
try:
|
||
password_input.click()
|
||
time.sleep(0.5)
|
||
password_input.input(password)
|
||
logger.info(f"【{self.pure_user_id}】密码已输入")
|
||
time.sleep(0.5)
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】输入密码失败: {str(e)}")
|
||
return None
|
||
|
||
# 勾选协议(可选)
|
||
logger.info(f"【{self.pure_user_id}】查找并勾选用户协议...")
|
||
agreement_selectors = [
|
||
'#fm-agreement-checkbox',
|
||
'input:type=checkbox',
|
||
]
|
||
|
||
for selector in agreement_selectors:
|
||
try:
|
||
checkbox = page.ele(selector, timeout=1)
|
||
if checkbox and not checkbox.states.is_checked:
|
||
checkbox.click()
|
||
logger.info(f"【{self.pure_user_id}】用户协议已勾选")
|
||
time.sleep(0.5)
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 点击登录按钮
|
||
logger.info(f"【{self.pure_user_id}】点击登录按钮...")
|
||
login_button_selectors = [
|
||
'@class=fm-button fm-submit password-login ',
|
||
'.fm-button.fm-submit.password-login',
|
||
'button.password-login',
|
||
'.password-login',
|
||
'button.fm-submit',
|
||
'text:登录',
|
||
]
|
||
|
||
login_button_found = False
|
||
for selector in login_button_selectors:
|
||
try:
|
||
button = page.ele(selector, timeout=2)
|
||
if button:
|
||
logger.info(f"【{self.pure_user_id}】找到登录按钮: {selector}")
|
||
button.click()
|
||
logger.info(f"【{self.pure_user_id}】登录按钮已点击")
|
||
login_button_found = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not login_button_found:
|
||
logger.warning(f"【{self.pure_user_id}】未找到登录按钮,尝试按Enter键...")
|
||
try:
|
||
password_input.input('\n') # 模拟按Enter
|
||
logger.info(f"【{self.pure_user_id}】已按Enter键")
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】按Enter键失败: {str(e)}")
|
||
|
||
# 等待登录完成
|
||
logger.info(f"【{self.pure_user_id}】等待登录完成...")
|
||
time.sleep(5)
|
||
|
||
# 检查当前URL和标题
|
||
current_url = page.url
|
||
logger.info(f"【{self.pure_user_id}】登录后URL: {current_url}")
|
||
page_title = page.title
|
||
logger.info(f"【{self.pure_user_id}】登录后页面标题: {page_title}")
|
||
|
||
# 根据浏览器模式决定等待时间
|
||
# 有头模式:等待5分钟(用户可能需要手动处理验证码等)
|
||
# 无头模式:等待10秒
|
||
if show_browser:
|
||
wait_seconds = 300 # 5分钟
|
||
logger.info(f"【{self.pure_user_id}】有头模式:等待5分钟让Cookie完全生成(期间可手动处理验证码等)...")
|
||
else:
|
||
wait_seconds = 10
|
||
logger.info(f"【{self.pure_user_id}】无头模式:等待10秒让Cookie完全生成...")
|
||
|
||
time.sleep(wait_seconds)
|
||
logger.info(f"【{self.pure_user_id}】等待完成,准备获取Cookie")
|
||
|
||
# 获取Cookie
|
||
logger.info(f"【{self.pure_user_id}】开始获取Cookie...")
|
||
cookies_raw = page.cookies()
|
||
|
||
# 将cookies转换为字典格式
|
||
cookies = {}
|
||
if isinstance(cookies_raw, list):
|
||
# 如果返回的是列表格式,转换为字典
|
||
for cookie in cookies_raw:
|
||
if isinstance(cookie, dict) and 'name' in cookie and 'value' in cookie:
|
||
cookies[cookie['name']] = cookie['value']
|
||
elif isinstance(cookie, tuple) and len(cookie) >= 2:
|
||
cookies[cookie[0]] = cookie[1]
|
||
elif isinstance(cookies_raw, dict):
|
||
# 如果已经是字典格式,直接使用
|
||
cookies = cookies_raw
|
||
|
||
if cookies:
|
||
logger.info(f"【{self.pure_user_id}】成功获取 {len(cookies)} 个Cookie")
|
||
logger.info(f"【{self.pure_user_id}】Cookie名称列表: {list(cookies.keys())}")
|
||
|
||
# 打印完整的Cookie
|
||
logger.info(f"【{self.pure_user_id}】完整Cookie内容:")
|
||
for name, value in cookies.items():
|
||
# 对长cookie值进行截断显示
|
||
if len(value) > 50:
|
||
display_value = f"{value[:25]}...{value[-25:]}"
|
||
else:
|
||
display_value = value
|
||
logger.info(f"【{self.pure_user_id}】 {name} = {display_value}")
|
||
|
||
# 将cookie转换为字符串格式
|
||
cookie_str = '; '.join([f"{k}={v}" for k, v in cookies.items()])
|
||
logger.info(f"【{self.pure_user_id}】Cookie字符串格式: {cookie_str[:200]}..." if len(cookie_str) > 200 else f"【{self.pure_user_id}】Cookie字符串格式: {cookie_str}")
|
||
|
||
logger.info(f"【{self.pure_user_id}】登录成功,准备关闭浏览器")
|
||
|
||
return cookies
|
||
else:
|
||
logger.error(f"【{self.pure_user_id}】未获取到任何Cookie")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】密码登录流程出错: {str(e)}")
|
||
import traceback
|
||
logger.error(f"【{self.pure_user_id}】详细错误信息: {traceback.format_exc()}")
|
||
return None
|
||
finally:
|
||
# 关闭浏览器
|
||
logger.info(f"【{self.pure_user_id}】关闭浏览器...")
|
||
try:
|
||
if page:
|
||
page.quit()
|
||
logger.info(f"【{self.pure_user_id}】DrissionPage浏览器已关闭")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】关闭浏览器时出错: {e}")
|
||
|
||
def run(self, url: str):
|
||
"""运行主流程,返回(成功状态, cookie数据)"""
|
||
cookies = None
|
||
try:
|
||
# 检查日期有效性
|
||
if not self._check_date_validity():
|
||
logger.error(f"【{self.pure_user_id}】日期验证失败,无法执行")
|
||
return False, None
|
||
|
||
# 初始化浏览器
|
||
self.init_browser()
|
||
|
||
# 后台无头链路先做页面预热,降低冷启动直接进处罚页的风险
|
||
self._warmup_slider_context(url)
|
||
|
||
# 导航到目标URL,快速加载
|
||
logger.info(f"【{self.pure_user_id}】导航到URL: {url}")
|
||
try:
|
||
self.page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】页面加载异常,尝试继续: {str(e)}")
|
||
# 如果页面加载失败,尝试等待一下
|
||
time.sleep(2)
|
||
|
||
# 短暂延迟,快速处理
|
||
delay = random.uniform(0.3, 0.8)
|
||
logger.info(f"【{self.pure_user_id}】等待页面加载: {delay:.2f}秒")
|
||
time.sleep(delay)
|
||
|
||
# 快速滚动(可选)
|
||
self.page.mouse.move(640, 360)
|
||
time.sleep(random.uniform(0.02, 0.05))
|
||
self.page.mouse.wheel(0, random.randint(200, 500))
|
||
time.sleep(random.uniform(0.02, 0.05))
|
||
|
||
# 检查页面标题
|
||
page_title = self.page.title()
|
||
logger.info(f"【{self.pure_user_id}】页面标题: {page_title}")
|
||
|
||
# 检查页面内容
|
||
page_content = self.page.content()
|
||
if any(keyword in page_content for keyword in ["验证码", "captcha", "滑块", "slider"]):
|
||
logger.info(f"【{self.pure_user_id}】页面内容包含验证码相关关键词")
|
||
|
||
# 处理滑块验证
|
||
success = self.solve_slider()
|
||
|
||
if success:
|
||
logger.info(f"【{self.pure_user_id}】滑块验证成功")
|
||
|
||
# 等待页面完全加载和跳转,让新的cookie生效(快速模式)
|
||
try:
|
||
logger.info(f"【{self.pure_user_id}】等待页面加载...")
|
||
time.sleep(1) # 快速等待,从3秒减少到1秒
|
||
|
||
# 等待页面跳转或刷新
|
||
self.page.wait_for_load_state("networkidle", timeout=10000)
|
||
time.sleep(0.5) # 快速确认,从2秒减少到0.5秒
|
||
|
||
logger.info(f"【{self.pure_user_id}】页面加载完成,开始获取cookie")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】等待页面加载时出错: {str(e)}")
|
||
|
||
# 在关闭浏览器前获取cookie
|
||
try:
|
||
cookies = self._get_cookies_after_success()
|
||
except Exception as e:
|
||
logger.warning(f"【{self.pure_user_id}】获取cookie时出错: {str(e)}")
|
||
else:
|
||
logger.warning(f"【{self.pure_user_id}】滑块验证失败")
|
||
|
||
return success, cookies
|
||
else:
|
||
logger.info(f"【{self.pure_user_id}】页面内容不包含验证码相关关键词,可能不需要验证")
|
||
return True, None
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.pure_user_id}】执行过程中出错: {str(e)}")
|
||
return False, None
|
||
finally:
|
||
# 关闭浏览器
|
||
self.close_browser()
|
||
|
||
async def async_run(self, url: str):
|
||
"""异步运行主流程,返回(成功状态, cookie数据)
|
||
|
||
在独立线程中运行同步的 Playwright,避免事件循环冲突
|
||
"""
|
||
import asyncio
|
||
|
||
def _run_in_thread():
|
||
"""在独立线程中运行同步代码"""
|
||
import asyncio
|
||
# 确保线程中没有运行的事件循环
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
# 如果有运行中的循环,创建新循环
|
||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||
except RuntimeError:
|
||
# 没有运行中的循环,正常
|
||
pass
|
||
|
||
# 调用同步的 run 方法
|
||
return self.run(url)
|
||
|
||
# 使用 asyncio.to_thread 在独立线程中运行
|
||
return await asyncio.to_thread(_run_in_thread)
|
||
|
||
async def _async_close_browser(self):
|
||
"""异步版本的清理方法(兼容性保留,实际清理由同步 run 方法完成)"""
|
||
# 由于 async_run 现在调用同步的 run 方法,清理工作已经在 run 的 finally 中完成
|
||
pass
|
||
|
||
def get_slider_stats():
|
||
"""获取滑块验证并发统计信息"""
|
||
return concurrency_manager.get_stats()
|
||
|
||
if __name__ == "__main__":
|
||
# 简单的命令行示例
|
||
import sys
|
||
if len(sys.argv) < 2:
|
||
print("用法: python xianyu_slider_stealth.py <URL>")
|
||
sys.exit(1)
|
||
|
||
url = sys.argv[1]
|
||
# 第三个参数可以指定 headless 模式,默认为 True(无头)
|
||
headless = sys.argv[2].lower() == 'true' if len(sys.argv) > 2 else True
|
||
slider = XianyuSliderStealth("test_user", enable_learning=True, headless=headless)
|
||
try:
|
||
success, cookies = slider.run(url)
|
||
print(f"验证结果: {'成功' if success else '失败'}")
|
||
if cookies:
|
||
print(f"获取到 {len(cookies)} 个cookies")
|
||
except Exception as e:
|
||
print(f"验证异常: {e}")
|