修复37项安全漏洞和Bug
高危修复: - app.py: 添加ip_rate_limit_lock线程锁保护IP限流字典 - app.py: 添加validate_ip_port()验证代理IP/端口范围 - database.py: SQL字段名白名单验证防止注入 - playwright_automation.py: 改进浏览器进程强制清理逻辑 中危修复: - database.py: 统一时区处理函数get_cst_now() - database.py: 消除循环导入,移动app_security导入到顶部 - playwright_automation.py: 所有bare except改为except Exception - app_config.py: dotenv导入失败警告+安全配置检查 - db_pool.py: 添加详细异常堆栈日志 - app_security.py: 用户名过滤零宽字符 - database.py: delete_old_task_logs分批删除避免锁表 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
130
app.py
130
app.py
@@ -137,6 +137,7 @@ captcha_storage = {}
|
||||
|
||||
# IP限流存储:{ip: {"attempts": count, "lock_until": timestamp, "first_attempt": timestamp}}
|
||||
ip_rate_limit = {}
|
||||
ip_rate_limit_lock = threading.Lock() # Bug fix: 保护 ip_rate_limit 字典的线程安全
|
||||
|
||||
# 限流配置 - 从 config 读取,避免硬编码
|
||||
MAX_CAPTCHA_ATTEMPTS = config.MAX_CAPTCHA_ATTEMPTS
|
||||
@@ -402,6 +403,37 @@ def log_to_client(message, user_id=None, account_id=None):
|
||||
|
||||
|
||||
|
||||
def validate_ip_port(ip_port_str):
|
||||
"""验证IP:PORT格式是否有效
|
||||
|
||||
Bug fix: 验证IP范围(0-255)和端口范围(1-65535)
|
||||
|
||||
Args:
|
||||
ip_port_str: 格式为 "IP:PORT" 的字符串
|
||||
|
||||
Returns:
|
||||
bool: 是否有效
|
||||
"""
|
||||
import re
|
||||
pattern = re.compile(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$')
|
||||
match = pattern.match(ip_port_str)
|
||||
if not match:
|
||||
return False
|
||||
|
||||
# 验证IP每个部分在0-255范围内
|
||||
for i in range(1, 5):
|
||||
octet = int(match.group(i))
|
||||
if octet < 0 or octet > 255:
|
||||
return False
|
||||
|
||||
# 验证端口在1-65535范围内
|
||||
port = int(match.group(5))
|
||||
if port < 1 or port > 65535:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_proxy_from_api(api_url, max_retries=3):
|
||||
"""从API获取代理IP(支持重试)
|
||||
|
||||
@@ -413,7 +445,7 @@ def get_proxy_from_api(api_url, max_retries=3):
|
||||
代理服务器地址(格式: http://IP:PORT)或 None
|
||||
"""
|
||||
import re
|
||||
# IP:PORT 格式正则
|
||||
# IP:PORT 格式正则(基础格式检查)
|
||||
ip_port_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$')
|
||||
|
||||
for attempt in range(max_retries):
|
||||
@@ -442,13 +474,13 @@ def get_proxy_from_api(api_url, max_retries=3):
|
||||
# 不是JSON,继续使用原始文本
|
||||
pass
|
||||
|
||||
# 验证IP:PORT格式
|
||||
if ip_port_pattern.match(text):
|
||||
# 验证IP:PORT格式(基础格式检查 + 范围验证)
|
||||
if ip_port_pattern.match(text) and validate_ip_port(text):
|
||||
proxy_server = f"http://{text}"
|
||||
print(f"✓ 获取代理成功: {proxy_server} (尝试 {attempt + 1}/{max_retries})")
|
||||
return proxy_server
|
||||
else:
|
||||
print(f"✗ 代理格式无效: {text[:50]} (尝试 {attempt + 1}/{max_retries})")
|
||||
print(f"✗ 代理格式或范围无效: {text[:50]} (尝试 {attempt + 1}/{max_retries})")
|
||||
else:
|
||||
print(f"✗ 获取代理失败: HTTP {response.status_code} (尝试 {attempt + 1}/{max_retries})")
|
||||
except Exception as e:
|
||||
@@ -608,61 +640,69 @@ from task_checkpoint import get_checkpoint_manager, TaskStage
|
||||
checkpoint_mgr = None # 任务断点管理器
|
||||
|
||||
def check_ip_rate_limit(ip_address):
|
||||
"""检查IP是否被限流"""
|
||||
"""检查IP是否被限流
|
||||
|
||||
Bug fix: 使用线程锁保护 ip_rate_limit 字典操作,防止竞态条件
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# 安全修复:修正过期IP清理逻辑
|
||||
# 原问题:first_attempt不存在时默认使用current_time,导致永远不会被清理
|
||||
expired_ips = []
|
||||
for ip, data in ip_rate_limit.items():
|
||||
lock_expired = data.get("lock_until", 0) < current_time
|
||||
first_attempt = data.get("first_attempt")
|
||||
# 修复:如果first_attempt不存在或超过1小时,视为过期
|
||||
attempt_expired = first_attempt is None or (current_time - first_attempt > 3600)
|
||||
if lock_expired and attempt_expired:
|
||||
expired_ips.append(ip)
|
||||
with ip_rate_limit_lock:
|
||||
# 安全修复:修正过期IP清理逻辑
|
||||
# 原问题:first_attempt不存在时默认使用current_time,导致永远不会被清理
|
||||
expired_ips = []
|
||||
for ip, data in ip_rate_limit.items():
|
||||
lock_expired = data.get("lock_until", 0) < current_time
|
||||
first_attempt = data.get("first_attempt")
|
||||
# 修复:如果first_attempt不存在或超过1小时,视为过期
|
||||
attempt_expired = first_attempt is None or (current_time - first_attempt > 3600)
|
||||
if lock_expired and attempt_expired:
|
||||
expired_ips.append(ip)
|
||||
|
||||
for ip in expired_ips:
|
||||
del ip_rate_limit[ip]
|
||||
for ip in expired_ips:
|
||||
del ip_rate_limit[ip]
|
||||
|
||||
# 检查IP是否被锁定
|
||||
if ip_address in ip_rate_limit:
|
||||
ip_data = ip_rate_limit[ip_address]
|
||||
# 检查IP是否被锁定
|
||||
if ip_address in ip_rate_limit:
|
||||
ip_data = ip_rate_limit[ip_address]
|
||||
|
||||
# 如果IP被锁定且未到解锁时间
|
||||
if ip_data.get("lock_until", 0) > current_time:
|
||||
remaining_time = int(ip_data["lock_until"] - current_time)
|
||||
return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
|
||||
# 如果IP被锁定且未到解锁时间
|
||||
if ip_data.get("lock_until", 0) > current_time:
|
||||
remaining_time = int(ip_data["lock_until"] - current_time)
|
||||
return False, "IP已被锁定,请{}分钟后再试".format(remaining_time // 60 + 1)
|
||||
|
||||
# 如果超过1小时,重置计数
|
||||
first_attempt = ip_data.get("first_attempt")
|
||||
if first_attempt is None or current_time - first_attempt > 3600:
|
||||
ip_rate_limit[ip_address] = {
|
||||
"attempts": 0,
|
||||
"first_attempt": current_time
|
||||
}
|
||||
# 如果超过1小时,重置计数
|
||||
first_attempt = ip_data.get("first_attempt")
|
||||
if first_attempt is None or current_time - first_attempt > 3600:
|
||||
ip_rate_limit[ip_address] = {
|
||||
"attempts": 0,
|
||||
"first_attempt": current_time
|
||||
}
|
||||
|
||||
return True, None
|
||||
return True, None
|
||||
|
||||
|
||||
def record_failed_captcha(ip_address):
|
||||
"""记录验证码失败尝试"""
|
||||
"""记录验证码失败尝试
|
||||
|
||||
Bug fix: 使用线程锁保护 ip_rate_limit 字典操作,防止竞态条件
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
if ip_address not in ip_rate_limit:
|
||||
ip_rate_limit[ip_address] = {
|
||||
"attempts": 1,
|
||||
"first_attempt": current_time
|
||||
}
|
||||
else:
|
||||
ip_rate_limit[ip_address]["attempts"] += 1
|
||||
with ip_rate_limit_lock:
|
||||
if ip_address not in ip_rate_limit:
|
||||
ip_rate_limit[ip_address] = {
|
||||
"attempts": 1,
|
||||
"first_attempt": current_time
|
||||
}
|
||||
else:
|
||||
ip_rate_limit[ip_address]["attempts"] += 1
|
||||
|
||||
# 检查是否超过限制
|
||||
if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
|
||||
ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
|
||||
return True # 表示IP已被锁定
|
||||
# 检查是否超过限制
|
||||
if ip_rate_limit[ip_address]["attempts"] >= MAX_IP_ATTEMPTS_PER_HOUR:
|
||||
ip_rate_limit[ip_address]["lock_until"] = current_time + IP_LOCK_DURATION
|
||||
return True # 表示IP已被锁定
|
||||
|
||||
return False # 表示还未锁定
|
||||
return False # 表示还未锁定
|
||||
|
||||
|
||||
@app.route("/api/generate_captcha", methods=["POST"])
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# 尝试加载.env文件(如果存在)
|
||||
# Bug fix: 添加警告日志,避免静默失败
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).parent / '.env'
|
||||
@@ -17,8 +18,9 @@ try:
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
print(f"✓ 已加载环境变量文件: {env_path}")
|
||||
except ImportError:
|
||||
# python-dotenv未安装,跳过
|
||||
pass
|
||||
# python-dotenv未安装,记录警告
|
||||
import sys
|
||||
print("⚠ 警告: python-dotenv未安装,将不会加载.env文件。如需使用.env文件,请运行: pip install python-dotenv", file=sys.stderr)
|
||||
|
||||
|
||||
# 常量定义
|
||||
@@ -53,6 +55,7 @@ class Config:
|
||||
SECRET_KEY = get_secret_key()
|
||||
|
||||
# ==================== 会话安全配置 ====================
|
||||
# Bug fix: 生产环境安全警告
|
||||
SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'False').lower() == 'true'
|
||||
SESSION_COOKIE_HTTPONLY = True # 防止XSS攻击
|
||||
# SameSite配置:HTTP环境使用Lax,HTTPS环境使用None
|
||||
@@ -63,6 +66,24 @@ class Config:
|
||||
SESSION_COOKIE_PATH = '/'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=int(os.environ.get('SESSION_LIFETIME_HOURS', '24')))
|
||||
|
||||
# 安全警告检查
|
||||
@classmethod
|
||||
def check_security_warnings(cls):
|
||||
"""检查安全配置,输出警告"""
|
||||
import sys
|
||||
warnings = []
|
||||
env = os.environ.get('FLASK_ENV', 'production')
|
||||
|
||||
if env == 'production':
|
||||
if not cls.SESSION_COOKIE_SECURE:
|
||||
warnings.append("SESSION_COOKIE_SECURE=False: 生产环境建议启用HTTPS并设置SESSION_COOKIE_SECURE=true")
|
||||
|
||||
if warnings:
|
||||
print("\n⚠ 安全配置警告:", file=sys.stderr)
|
||||
for w in warnings:
|
||||
print(f" - {w}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DB_FILE = os.environ.get('DB_FILE', 'data/app_data.db')
|
||||
DB_POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', '5'))
|
||||
|
||||
@@ -237,6 +237,15 @@ def validate_username(username):
|
||||
if not re.match(r'^[\w\u4e00-\u9fa5]+$', username):
|
||||
return False, "用户名只能包含字母、数字、下划线和中文字符"
|
||||
|
||||
# Bug fix: 过滤零宽字符和其他不可见字符
|
||||
# 检查是否包含不可见/控制字符
|
||||
import unicodedata
|
||||
for char in username:
|
||||
category = unicodedata.category(char)
|
||||
# Cf = 格式字符 (包括零宽字符), Cc = 控制字符
|
||||
if category in ('Cf', 'Cc'):
|
||||
return False, "用户名不能包含不可见字符"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
|
||||
116
database.py
116
database.py
@@ -28,6 +28,25 @@ from password_utils import (
|
||||
from app_config import get_config
|
||||
from crypto_utils import encrypt_password, decrypt_password, migrate_password
|
||||
|
||||
# Bug fix: 将 app_security 导入移到顶部,避免循环导入
|
||||
# 注意:如果出现循环导入,需要检查 app_security 是否导入了 database
|
||||
try:
|
||||
from app_security import escape_html, sanitize_sql_like_pattern
|
||||
except ImportError:
|
||||
# 如果导入失败,提供基础实现
|
||||
import html
|
||||
def escape_html(text):
|
||||
"""基础HTML转义"""
|
||||
if text is None:
|
||||
return ''
|
||||
return html.escape(str(text))
|
||||
|
||||
def sanitize_sql_like_pattern(pattern):
|
||||
"""基础SQL LIKE模式清理"""
|
||||
if pattern is None:
|
||||
return ''
|
||||
return str(pattern).replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||
|
||||
# 获取配置
|
||||
config = get_config()
|
||||
|
||||
@@ -37,6 +56,30 @@ DB_FILE = config.DB_FILE
|
||||
# 数据库版本 (用于迁移管理)
|
||||
DB_VERSION = 5
|
||||
|
||||
# ==================== 时区处理工具函数 ====================
|
||||
# Bug fix: 统一时区处理,避免混用导致的问题
|
||||
CST_TZ = pytz.timezone("Asia/Shanghai")
|
||||
|
||||
def get_cst_now():
|
||||
"""获取当前CST时间(统一入口)"""
|
||||
return datetime.now(CST_TZ)
|
||||
|
||||
def get_cst_now_str():
|
||||
"""获取当前CST时间字符串"""
|
||||
return get_cst_now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
def parse_cst_datetime(datetime_str):
|
||||
"""解析CST时间字符串为带时区的datetime对象
|
||||
|
||||
Args:
|
||||
datetime_str: 格式为 'YYYY-MM-DD HH:MM:SS' 的字符串
|
||||
|
||||
Returns:
|
||||
带CST时区的datetime对象
|
||||
"""
|
||||
naive = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M:%S')
|
||||
return CST_TZ.localize(naive)
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Password hashing using bcrypt"""
|
||||
@@ -1040,7 +1083,19 @@ def update_system_config(max_concurrent=None, schedule_enabled=None, schedule_ti
|
||||
max_concurrent_per_account=None, max_screenshot_concurrent=None, proxy_enabled=None,
|
||||
proxy_api_url=None, proxy_expire_minutes=None,
|
||||
auto_approve_enabled=None, auto_approve_hourly_limit=None, auto_approve_vip_days=None):
|
||||
"""更新系统配置"""
|
||||
"""更新系统配置
|
||||
|
||||
Bug fix: 添加字段名白名单验证,防止SQL注入风险
|
||||
"""
|
||||
# 白名单:允许更新的字段名
|
||||
ALLOWED_FIELDS = {
|
||||
'max_concurrent_global', 'schedule_enabled', 'schedule_time',
|
||||
'schedule_browse_type', 'schedule_weekdays', 'max_concurrent_per_account',
|
||||
'max_screenshot_concurrent', 'proxy_enabled', 'proxy_api_url',
|
||||
'proxy_expire_minutes', 'auto_approve_enabled', 'auto_approve_hourly_limit',
|
||||
'auto_approve_vip_days', 'updated_at'
|
||||
}
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
updates = []
|
||||
@@ -1100,6 +1155,11 @@ def update_system_config(max_concurrent=None, schedule_enabled=None, schedule_ti
|
||||
|
||||
if updates:
|
||||
updates.append('updated_at = CURRENT_TIMESTAMP')
|
||||
# Bug fix: 验证所有字段名都在白名单中
|
||||
for update_clause in updates:
|
||||
field_name = update_clause.split('=')[0].strip()
|
||||
if field_name not in ALLOWED_FIELDS:
|
||||
raise ValueError(f"非法字段名: {field_name}")
|
||||
sql = f"UPDATE system_config SET {', '.join(updates)} WHERE id = 1"
|
||||
cursor.execute(sql, params)
|
||||
conn.commit()
|
||||
@@ -1172,8 +1232,7 @@ def get_task_logs(limit=100, offset=0, date_filter=None, status_filter=None,
|
||||
params.append(user_id_filter)
|
||||
|
||||
if account_filter:
|
||||
# 转义LIKE中的特殊字符,防止绕过过滤
|
||||
from app_security import sanitize_sql_like_pattern
|
||||
# 转义LIKE中的特殊字符,防止绕过过滤(使用顶部导入的函数)
|
||||
safe_filter = sanitize_sql_like_pattern(account_filter)
|
||||
where_clauses.append("tl.username LIKE ? ESCAPE '\\'")
|
||||
params.append(f"%{safe_filter}%")
|
||||
@@ -1266,16 +1325,39 @@ def get_task_stats(date_filter=None):
|
||||
}
|
||||
|
||||
|
||||
def delete_old_task_logs(days=30):
|
||||
"""删除N天前的任务日志"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
DELETE FROM task_logs
|
||||
WHERE created_at < datetime('now', '-' || ? || ' days')
|
||||
''', (days,))
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
def delete_old_task_logs(days=30, batch_size=1000):
|
||||
"""删除N天前的任务日志
|
||||
|
||||
Bug fix: 分批删除,避免长时间锁表
|
||||
|
||||
Args:
|
||||
days: 删除多少天前的日志
|
||||
batch_size: 每批删除的数量
|
||||
|
||||
Returns:
|
||||
int: 删除的总行数
|
||||
"""
|
||||
total_deleted = 0
|
||||
while True:
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
# 分批删除,使用LIMIT避免长时间锁表
|
||||
cursor.execute('''
|
||||
DELETE FROM task_logs
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM task_logs
|
||||
WHERE created_at < datetime('now', '-' || ? || ' days')
|
||||
LIMIT ?
|
||||
)
|
||||
''', (days, batch_size))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
if deleted == 0:
|
||||
break
|
||||
total_deleted += deleted
|
||||
|
||||
return total_deleted
|
||||
|
||||
|
||||
def get_user_run_stats(user_id, date_filter=None):
|
||||
@@ -1447,9 +1529,7 @@ def clean_old_operation_logs(days=30):
|
||||
# ==================== Bug反馈管理 ====================
|
||||
|
||||
def create_bug_feedback(user_id, username, title, description, contact=''):
|
||||
"""创建Bug反馈(带XSS防护)"""
|
||||
from app_security import escape_html
|
||||
|
||||
"""创建Bug反馈(带XSS防护)(使用顶部导入的escape_html函数)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||
@@ -1512,9 +1592,7 @@ def get_feedback_by_id(feedback_id):
|
||||
|
||||
|
||||
def reply_feedback(feedback_id, admin_reply):
|
||||
"""管理员回复反馈(带XSS防护)"""
|
||||
from app_security import escape_html
|
||||
|
||||
"""管理员回复反馈(带XSS防护)(使用顶部导入的escape_html函数)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_tz = pytz.timezone("Asia/Shanghai")
|
||||
|
||||
@@ -101,7 +101,10 @@ class ConnectionPool:
|
||||
except Exception as close_error:
|
||||
print(f"关闭多余连接失败: {close_error}")
|
||||
except Exception as e:
|
||||
# Bug fix: 记录详细的异常堆栈,便于调试
|
||||
import traceback
|
||||
print(f"归还连接失败(未知错误): {e}")
|
||||
print(f"异常堆栈:\n{traceback.format_exc()}")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as close_error:
|
||||
|
||||
@@ -227,7 +227,8 @@ class PlaywrightAutomation:
|
||||
if 'index.aspx' in current_url:
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
except Exception:
|
||||
# Bug fix: 明确捕获Exception而非所有异常
|
||||
return False
|
||||
|
||||
def quick_login(self, username: str, password: str, remember: bool = True):
|
||||
@@ -258,8 +259,8 @@ class PlaywrightAutomation:
|
||||
self.browser.close()
|
||||
if self.playwright:
|
||||
self.playwright.stop()
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
pass # 清理时忽略错误
|
||||
|
||||
# 正常登录
|
||||
result = self.login(username, password, remember)
|
||||
@@ -359,8 +360,8 @@ class PlaywrightAutomation:
|
||||
error_type = "password_error"
|
||||
else:
|
||||
error_type = "login_error"
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
pass # 获取错误提示失败时忽略
|
||||
|
||||
# 如果没有明确的错误提示,可能是网络问题,不认为是密码错误
|
||||
if error_type == "unknown":
|
||||
@@ -476,11 +477,11 @@ class PlaywrightAutomation:
|
||||
time.sleep(1.5)
|
||||
try:
|
||||
self.main_page.wait_for_load_state('domcontentloaded', timeout=5000)
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
pass
|
||||
try:
|
||||
self.main_page.wait_for_load_state('networkidle', timeout=10000)
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
pass
|
||||
|
||||
self.page = self.get_iframe_safe(retry=True, max_retries=3)
|
||||
@@ -529,10 +530,10 @@ class PlaywrightAutomation:
|
||||
# 等待表格加载
|
||||
try:
|
||||
self.page.locator("//table[@class='ltable']").wait_for(timeout=10000)
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
pass
|
||||
self.log(f"✓ iframe恢复成功(刷新后重新点击'{browse_type}')")
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
# 尝试点击label
|
||||
try:
|
||||
label_selector = f"//label[contains(text(), '{browse_type}')]"
|
||||
@@ -777,7 +778,7 @@ class PlaywrightAutomation:
|
||||
# 等待表格加载完成(最多等待10秒)
|
||||
try:
|
||||
self.page.locator("//table[@class='ltable']").wait_for(timeout=10000)
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
self.log("等待表格超时,继续尝试...")
|
||||
|
||||
# 额外等待,确保AJAX内容加载完成
|
||||
@@ -912,7 +913,7 @@ class PlaywrightAutomation:
|
||||
if match:
|
||||
expected_total = int(match.group(1))
|
||||
self.log(f"[总数] 预期浏览 {expected_total} 条内容")
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
pass
|
||||
|
||||
# 处理每一行 (每次从头重新获取所有行)
|
||||
@@ -1026,7 +1027,7 @@ class PlaywrightAutomation:
|
||||
try:
|
||||
current_rows_locator = self.page.locator("//table[@class='ltable']/tbody/tr[position()>1 and count(td)>=5]")
|
||||
row = current_rows_locator.nth(i)
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
break
|
||||
else:
|
||||
break
|
||||
@@ -1085,7 +1086,7 @@ class PlaywrightAutomation:
|
||||
# 关闭新窗口
|
||||
try:
|
||||
new_page.close()
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
pass
|
||||
self.log(f" - 新窗口已关闭")
|
||||
else:
|
||||
@@ -1134,7 +1135,7 @@ class PlaywrightAutomation:
|
||||
self.page = self.get_iframe_safe()
|
||||
if not self.page:
|
||||
self.recover_iframe(browse_type)
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
pass
|
||||
|
||||
# 处理完当前页后,检查是否需要翻页
|
||||
@@ -1212,7 +1213,7 @@ class PlaywrightAutomation:
|
||||
try:
|
||||
self.page.locator("//table[@class='ltable']").wait_for(timeout=30000)
|
||||
self.log("内容表格已加载")
|
||||
except:
|
||||
except Exception: # Bug fix: 明确捕获Exception
|
||||
self.log("等待表格加载超时,继续...")
|
||||
except Exception as e:
|
||||
if self.is_context_error(str(e)):
|
||||
@@ -1388,26 +1389,68 @@ class PlaywrightAutomation:
|
||||
# else部分日志已精简
|
||||
|
||||
def _cleanup_on_exit(self):
|
||||
"""进程退出时的清理函数(由atexit调用)"""
|
||||
# Bug #13 fix: 尝试获取锁,但不阻塞(避免退出时死锁)
|
||||
"""进程退出时的清理函数(由atexit调用)
|
||||
|
||||
Bug fix: 改进清理逻辑,即使锁获取失败也尝试清理资源
|
||||
"""
|
||||
# 尝试获取锁,但不阻塞(避免退出时死锁)
|
||||
acquired = self._lock.acquire(blocking=False)
|
||||
try:
|
||||
if not self._closed:
|
||||
try:
|
||||
# 静默关闭,避免在退出时产生过多日志
|
||||
if self.context:
|
||||
self.context.close()
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
if self.playwright:
|
||||
self.playwright.stop()
|
||||
self._closed = True
|
||||
except:
|
||||
pass # 退出时忽略所有错误
|
||||
self._force_cleanup()
|
||||
finally:
|
||||
if acquired:
|
||||
self._lock.release()
|
||||
|
||||
def _force_cleanup(self):
|
||||
"""强制清理资源(不依赖锁状态)
|
||||
|
||||
Bug fix: 添加进程级清理,确保浏览器进程被终止
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# 记录浏览器进程ID用于强制清理
|
||||
browser_pid = None
|
||||
try:
|
||||
if self.browser and hasattr(self.browser, '_impl_obj'):
|
||||
# 尝试获取浏览器进程ID
|
||||
impl = self.browser._impl_obj
|
||||
if hasattr(impl, '_browser_process') and impl._browser_process:
|
||||
browser_pid = impl._browser_process.pid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试正常关闭
|
||||
try:
|
||||
if self.context:
|
||||
self.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if self.playwright:
|
||||
self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果有浏览器进程ID且在Linux/Mac上,强制杀死进程
|
||||
if browser_pid and sys.platform != 'win32':
|
||||
try:
|
||||
import os
|
||||
import signal
|
||||
os.kill(browser_pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass # 进程可能已经退出
|
||||
|
||||
self._closed = True
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager支持 - 进入"""
|
||||
return self
|
||||
|
||||
Reference in New Issue
Block a user