6208 lines
291 KiB
Python
6208 lines
291 KiB
Python
# -*- coding: UTF-8 -*-
|
||
'''
|
||
@Project :domainScanDemo
|
||
@File :detect_worker.py
|
||
@IDE :PyCharm
|
||
@Author :梦伴
|
||
@Date :2026/4/10 22:40
|
||
@explain : 域名检测端程序
|
||
'''
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
import socket
|
||
import threading
|
||
import collections
|
||
import resource
|
||
import schedule
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from datetime import datetime
|
||
from loguru import logger
|
||
from queue import Empty, Full, Queue
|
||
from PySide6.QtWidgets import QApplication, QMainWindow, QTextEdit, QPushButton, QVBoxLayout, QHBoxLayout, QWidget, QLabel, QProgressBar, QGroupBox, QScrollArea, QFrame
|
||
from PySide6.QtCore import Qt, QThread, Signal, QMetaObject, Q_ARG, QCoreApplication, QEvent, QTimer
|
||
from PySide6.QtGui import QIcon
|
||
|
||
from app.utils.database import Database
|
||
from app.config import config
|
||
from app.detectors.wayback_detector import WaybackDetector
|
||
from app.utils.status_codes import (
|
||
DETECT_STATUS_BLACKLISTED,
|
||
DETECT_STATUS_COMPLETED,
|
||
DETECT_STATUS_FAILED,
|
||
DETECT_STATUS_RUNNING,
|
||
REGISTER_STATUS_AVAILABLE,
|
||
REVIEW_STATUS_PENDING,
|
||
THIRD_PARTY_STATUS_DONE,
|
||
)
|
||
import redis
|
||
from detect import aizhan, baidu, c360, chinaz, register, jucha, juziseo
|
||
|
||
CONFIG_UPDATE_CHANNEL = "domain_tool:config_update"
|
||
CONTROL_CHANNEL = "domain_tool:worker_control"
|
||
RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state"
|
||
PENDING_CONTROL_KEY = "domain_tool:worker_pending_command"
|
||
RUNTIME_SETTINGS_KEY = "domain_tool:runtime_settings"
|
||
REMOTE_DEBUG_EVENT_TIMEOUT = 5
|
||
|
||
|
||
def _raise_nofile_soft_limit():
|
||
try:
|
||
soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||
target_limit = hard_limit if hard_limit not in (-1, resource.RLIM_INFINITY) else soft_limit
|
||
if target_limit and soft_limit < target_limit:
|
||
resource.setrlimit(resource.RLIMIT_NOFILE, (target_limit, hard_limit))
|
||
logger.info(f"已提升 NOFILE 软限制: {soft_limit} -> {target_limit}")
|
||
except Exception as exc:
|
||
logger.warning(f"提升 NOFILE 软限制失败: {exc}")
|
||
|
||
|
||
def _resolve_worker_log_file() -> str:
|
||
candidate_dirs = [
|
||
str(getattr(config, "LOG_DIR", "") or "").strip(),
|
||
os.path.join("/opt/domaincheck", "runtime", "domainCheck", "logs"),
|
||
os.path.join("/tmp", "domaincheck", "logs"),
|
||
]
|
||
seen = set()
|
||
for raw_dir in candidate_dirs:
|
||
log_dir = os.path.abspath(raw_dir) if raw_dir else ""
|
||
if not log_dir or log_dir in seen:
|
||
continue
|
||
seen.add(log_dir)
|
||
try:
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
log_file_path = os.path.join(log_dir, "detect_worker.log")
|
||
with open(log_file_path, "a", encoding="utf-8"):
|
||
pass
|
||
return log_file_path
|
||
except Exception:
|
||
continue
|
||
return os.path.join("/tmp", "detect_worker.log")
|
||
|
||
class DetectThread(QThread):
|
||
"""
|
||
检测线程
|
||
"""
|
||
log_signal = Signal(str)
|
||
progress_signal = Signal(int, int)
|
||
finished_signal = Signal()
|
||
thread_count_signal = Signal(int, int)
|
||
|
||
def __init__(self, worker):
|
||
super().__init__()
|
||
self.worker = worker
|
||
# 设置线程名称
|
||
self.setObjectName("DetectThread")
|
||
|
||
def run(self):
|
||
# 将信号传递给worker
|
||
self.worker.detect_thread = self
|
||
self.worker.start_detection()
|
||
self.finished_signal.emit()
|
||
|
||
class DetectMainWindow(QMainWindow):
|
||
"""
|
||
检测端主窗口
|
||
"""
|
||
|
||
@staticmethod
|
||
def _button_style(
|
||
normal_color: str,
|
||
hover_color: str,
|
||
pressed_color: str,
|
||
disabled_color: str = "#6c757d",
|
||
*,
|
||
fancy_effects: bool = True,
|
||
) -> str:
|
||
hover_extra = ""
|
||
pressed_extra = ""
|
||
disabled_extra = ""
|
||
if fancy_effects:
|
||
hover_extra = "\n transform: translateY(-2px);\n box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);"
|
||
pressed_extra = "\n transform: translateY(1px);\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);"
|
||
disabled_extra = "\n transform: none;\n box-shadow: none;"
|
||
|
||
return f"""
|
||
QPushButton {{
|
||
background-color: {normal_color};
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
padding: 10px 20px;
|
||
min-width: 120px;
|
||
}}
|
||
QPushButton:hover {{
|
||
background-color: {hover_color};{hover_extra}
|
||
}}
|
||
QPushButton:pressed {{
|
||
background-color: {pressed_color};{pressed_extra}
|
||
}}
|
||
QPushButton:disabled {{
|
||
background-color: {disabled_color};
|
||
color: #adb5bd;{disabled_extra}
|
||
}}
|
||
"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("域名检测端")
|
||
self.setGeometry(100, 100, 980, 820)
|
||
self.setMinimumSize(860, 680)
|
||
|
||
# 设置窗口图标
|
||
# 打印当前文件路径以便诊断
|
||
current_file = os.path.abspath(__file__)
|
||
current_dir = os.path.dirname(current_file)
|
||
logger.info(f"当前文件路径: {current_file}")
|
||
logger.info(f"当前目录: {current_dir}")
|
||
|
||
# 获取PyInstaller打包后的临时目录;开发/部署态优先使用脚本所在目录。
|
||
base_dir = current_dir
|
||
if hasattr(sys, '_MEIPASS'):
|
||
base_dir = sys._MEIPASS
|
||
logger.info(f"PyInstaller临时目录: {base_dir}")
|
||
else:
|
||
logger.info(f"开发环境目录: {base_dir}")
|
||
|
||
def resolve_asset_path(filename: str) -> str:
|
||
candidates = [
|
||
os.path.join(base_dir, filename),
|
||
os.path.join(current_dir, filename),
|
||
os.path.join(os.path.dirname(current_dir), filename),
|
||
]
|
||
for candidate in candidates:
|
||
if os.path.exists(candidate):
|
||
return candidate
|
||
return candidates[0]
|
||
|
||
# 构建图标路径
|
||
icon_path = resolve_asset_path("new_logo.svg")
|
||
logger.info(f"SVG图标路径: {icon_path}")
|
||
|
||
if os.path.exists(icon_path):
|
||
logger.info(f"SVG图标文件存在: {icon_path}")
|
||
try:
|
||
icon = QIcon(icon_path)
|
||
if icon.isNull():
|
||
logger.warning(f"SVG图标加载失败,图标为空: {icon_path}")
|
||
else:
|
||
self.setWindowIcon(icon)
|
||
logger.info(f"设置SVG窗口图标成功: {icon_path}")
|
||
except Exception as e:
|
||
logger.error(f"加载SVG图标时出错: {e}")
|
||
else:
|
||
logger.warning(f"SVG图标文件不存在: {icon_path}")
|
||
|
||
# 如果SVG加载失败,使用ico文件作为备用
|
||
ico_icon_path = resolve_asset_path("favicon2.ico")
|
||
logger.info(f"ICO图标路径: {ico_icon_path}")
|
||
|
||
if os.path.exists(ico_icon_path):
|
||
logger.info(f"ICO图标文件存在: {ico_icon_path}")
|
||
try:
|
||
icon = QIcon(ico_icon_path)
|
||
if icon.isNull():
|
||
logger.warning(f"ICO图标加载失败,图标为空: {ico_icon_path}")
|
||
else:
|
||
self.setWindowIcon(icon)
|
||
logger.info(f"设置ICO窗口图标成功: {ico_icon_path}")
|
||
except Exception as e:
|
||
logger.error(f"加载ICO图标时出错: {e}")
|
||
else:
|
||
logger.warning(f"ICO图标文件不存在: {ico_icon_path}")
|
||
|
||
# 设置窗口样式
|
||
self.setStyleSheet(""
|
||
"QMainWindow {"
|
||
" background-color: #f5f5f5;"
|
||
" font-family: 'Microsoft YaHei', Arial, sans-serif;"
|
||
"}"
|
||
"QLabel {"
|
||
" font-size: 14px;"
|
||
" color: #333;"
|
||
" padding: 5px 0;"
|
||
"}"
|
||
"QGroupBox {"
|
||
" font-size: 16px;"
|
||
" font-weight: bold;"
|
||
" border: 1px solid #ddd;"
|
||
" border-radius: 8px;"
|
||
" margin-top: 15px;"
|
||
" padding: 15px;"
|
||
" background-color: #ffffff;"
|
||
"}"
|
||
"QGroupBox::title {"
|
||
" subcontrol-origin: margin;"
|
||
" subcontrol-position: top left;"
|
||
" padding: 0 15px;"
|
||
" background-color: #4CAF50;"
|
||
" color: white;"
|
||
" border-radius: 4px;"
|
||
" font-size: 14px;"
|
||
"}"
|
||
"QProgressBar {"
|
||
" border: 1px solid #ddd;"
|
||
" border-radius: 6px;"
|
||
" text-align: center;"
|
||
" background-color: #f0f0f0;"
|
||
" height: 25px;"
|
||
"}"
|
||
"QProgressBar::chunk {"
|
||
" background-color: #4CAF50;"
|
||
" border-radius: 6px;"
|
||
"}"
|
||
"QTextEdit {"
|
||
" font-family: Consolas, 'Courier New', monospace;"
|
||
" font-size: 12px;"
|
||
" border: 1px solid #ddd;"
|
||
" border-radius: 6px;"
|
||
" background-color: #2d2d2d;"
|
||
" color: #e0e0e0;"
|
||
" padding: 10px;"
|
||
"}"
|
||
"QPushButton {"
|
||
" font-family: 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;"
|
||
" font-size: 12px;"
|
||
" font-weight: 600;"
|
||
" padding: 6px 12px;"
|
||
" border: none;"
|
||
" border-radius: 4px;"
|
||
" color: white;"
|
||
" min-width: 80px;"
|
||
" min-height: 28px;"
|
||
" text-align: center;"
|
||
"}"
|
||
"QPushButton:hover {"
|
||
" opacity: 0.9;"
|
||
"}"
|
||
"QPushButton:disabled {"
|
||
" opacity: 0.5;"
|
||
"}"
|
||
"QPushButton#start_button {"
|
||
" background-color: #4CAF50;"
|
||
"}"
|
||
"QPushButton#stop_button {"
|
||
" background-color: #f44336;"
|
||
"}"
|
||
"QPushButton#exit_button {"
|
||
" background-color: #2196F3;"
|
||
"}"
|
||
"")
|
||
|
||
# 创建中心部件 + 内部滚动区域
|
||
scroll_area = QScrollArea()
|
||
scroll_area.setWidgetResizable(True)
|
||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.setCentralWidget(scroll_area)
|
||
|
||
central_widget = QWidget()
|
||
central_widget.setMinimumWidth(920)
|
||
scroll_area.setWidget(central_widget)
|
||
|
||
# 创建布局
|
||
layout = QVBoxLayout(central_widget)
|
||
layout.setSpacing(10)
|
||
layout.setContentsMargins(20, 20, 20, 20)
|
||
|
||
# 创建状态标签
|
||
self.status_label = QLabel("状态: 就绪")
|
||
self.status_label.setStyleSheet("font-family: 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif; font-size: 14px; font-weight: 600; color: #2c3e50;")
|
||
layout.addWidget(self.status_label)
|
||
|
||
# 创建配置信息显示区域
|
||
config_group = QGroupBox("配置信息")
|
||
config_group.setStyleSheet("""
|
||
QGroupBox {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #2c3e50;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
margin-top: 20px;
|
||
padding: 0;
|
||
background-color: #ffffff;
|
||
}
|
||
QGroupBox::title {
|
||
subcontrol-origin: margin;
|
||
subcontrol-position: top left;
|
||
padding: 8px 20px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 10px 10px 0 0;
|
||
font-family: 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
""")
|
||
config_layout = QVBoxLayout()
|
||
config_layout.setSpacing(12)
|
||
config_layout.setContentsMargins(20, 20, 20, 20)
|
||
|
||
# 创建配置标签
|
||
self.config_labels = {
|
||
'detect_options': QLabel("检测选项: 加载中..."),
|
||
'proxy_config': QLabel("代理配置: 加载中..."),
|
||
'thread_count': QLabel("检测线程数: 加载中...")
|
||
}
|
||
|
||
# 添加配置标签到布局
|
||
for label in self.config_labels.values():
|
||
label.setStyleSheet("""
|
||
QLabel {
|
||
font-family: 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
color: #4a5568;
|
||
background-color: #f7fafc;
|
||
padding: 12px 18px;
|
||
border-radius: 8px;
|
||
border-left: 4px solid #667eea;
|
||
margin: 0;
|
||
min-height: 64px;
|
||
white-space: normal;
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
QLabel:hover {
|
||
background-color: #edf2f7;
|
||
border-left-color: #764ba2;
|
||
}
|
||
""")
|
||
label.setWordWrap(True) # 启用自动换行
|
||
label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||
config_layout.addWidget(label)
|
||
|
||
config_group.setLayout(config_layout)
|
||
layout.addWidget(config_group)
|
||
|
||
# 创建进度条
|
||
self.progress_bar = QProgressBar()
|
||
self.progress_bar.setValue(0)
|
||
layout.addWidget(self.progress_bar)
|
||
|
||
# 创建日志文本框
|
||
self.log_text = QTextEdit()
|
||
self.log_text.setReadOnly(True)
|
||
self.log_text.setStyleSheet("font-family: Consolas, monospace; font-size: 12px; border: 1px solid #ddd; border-radius: 5px; background-color: #2d2d2d; color: #e0e0e0;")
|
||
self.log_text.setMinimumHeight(320)
|
||
layout.addWidget(self.log_text, 1)
|
||
|
||
# 创建按钮布局
|
||
button_layout = QHBoxLayout()
|
||
button_layout.setSpacing(10)
|
||
|
||
use_fancy_button_effects = os.environ.get("QT_QPA_PLATFORM", "").lower() != "offscreen"
|
||
|
||
# 按钮样式表
|
||
start_button_style = self._button_style(
|
||
"#28a745",
|
||
"#218838",
|
||
"#1e7e34",
|
||
fancy_effects=use_fancy_button_effects,
|
||
)
|
||
|
||
refresh_button_style = """
|
||
QPushButton {
|
||
background-color: #6f42c1;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
padding: 10px 20px;
|
||
min-width: 120px;
|
||
}
|
||
QPushButton:hover {
|
||
background-color: #5a32a3;
|
||
}
|
||
QPushButton:disabled {
|
||
background-color: #6c757d;
|
||
color: #adb5bd;
|
||
}
|
||
"""
|
||
stop_button_style = self._button_style(
|
||
"#dc3545",
|
||
"#c82333",
|
||
"#a71e2a",
|
||
fancy_effects=use_fancy_button_effects,
|
||
)
|
||
|
||
exit_button_style = self._button_style(
|
||
"#007bff",
|
||
"#0069d9",
|
||
"#0056b3",
|
||
fancy_effects=use_fancy_button_effects,
|
||
)
|
||
|
||
# 创建开始检测按钮
|
||
self.start_button = QPushButton("开始检测")
|
||
self.start_button.setObjectName("start_button")
|
||
self.start_button.clicked.connect(self.start_detection)
|
||
self.start_button.setFixedHeight(50)
|
||
self.start_button.setStyleSheet(start_button_style)
|
||
button_layout.addWidget(self.start_button)
|
||
|
||
# 创建停止按钮
|
||
self.stop_button = QPushButton("停止检测")
|
||
self.stop_button.setObjectName("stop_button")
|
||
self.stop_button.clicked.connect(self.stop_detection)
|
||
self.stop_button.setEnabled(False)
|
||
self.stop_button.setFixedHeight(50)
|
||
self.stop_button.setStyleSheet(stop_button_style)
|
||
button_layout.addWidget(self.stop_button)
|
||
|
||
self.refresh_proxy_button = QPushButton("刷新代理池")
|
||
self.refresh_proxy_button.clicked.connect(self.refresh_proxy_pool_manually)
|
||
self.refresh_proxy_button.setFixedHeight(50)
|
||
self.refresh_proxy_button.setStyleSheet(refresh_button_style)
|
||
button_layout.addWidget(self.refresh_proxy_button)
|
||
|
||
# 创建退出按钮
|
||
self.exit_button = QPushButton("退出")
|
||
self.exit_button.setObjectName("exit_button")
|
||
self.exit_button.clicked.connect(self.close)
|
||
self.exit_button.setFixedHeight(50)
|
||
self.exit_button.setStyleSheet(exit_button_style)
|
||
button_layout.addWidget(self.exit_button)
|
||
|
||
layout.addLayout(button_layout)
|
||
|
||
# 初始化检测端
|
||
self.worker = DetectWorker(self)
|
||
|
||
# 激活服务态运行时:启动控制链路,并在服务重启后立即尝试回挂活动检测任务。
|
||
self.worker.activate_service_runtime()
|
||
|
||
# 连接信号
|
||
self.worker.connect_signals()
|
||
|
||
# 初始化检测线程
|
||
self.detect_thread = None
|
||
|
||
# 初始化日志队列和处理线程
|
||
self.log_queue = Queue()
|
||
self.log_processing = True
|
||
# 启动日志处理线程
|
||
self.log_thread = threading.Thread(target=self.process_log_queue)
|
||
self.log_thread.daemon = True
|
||
self.log_thread.start()
|
||
|
||
# 配置日志
|
||
logger.add(self.enqueue_log_message, level="INFO")
|
||
|
||
# 启动定时任务
|
||
self.scheduler_thread = threading.Thread(target=self.start_scheduler)
|
||
self.scheduler_thread.daemon = True
|
||
self.scheduler_thread.start()
|
||
|
||
def enqueue_log_message(self, message):
|
||
"""
|
||
日志回调函数,将日志消息放入队列
|
||
"""
|
||
self.log_queue.put(message)
|
||
|
||
def process_log_queue(self):
|
||
"""
|
||
处理日志队列中的消息
|
||
"""
|
||
while self.log_processing:
|
||
try:
|
||
message = self.log_queue.get(timeout=1)
|
||
# 提取时间和消息部分
|
||
# 日志格式: 2026-04-13 00:22:05.365 | INFO | __main__:load_thread_count:846 - 从Redis加载检测线程数成功: 500
|
||
import re
|
||
match = re.match(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \| .*? \| .*? - (.*)', message)
|
||
if match:
|
||
time_str = match.group(1)
|
||
msg_str = match.group(2)
|
||
log_text = f"{time_str} | {msg_str}"
|
||
else:
|
||
log_text = message.replace("\n", "")
|
||
# 使用QMetaObject.invokeMethod确保线程安全的GUI更新
|
||
QMetaObject.invokeMethod(self.log_text, "append", Qt.QueuedConnection, Q_ARG(str, log_text))
|
||
QMetaObject.invokeMethod(self.log_text.verticalScrollBar(), "setValue", Qt.QueuedConnection, Q_ARG(int, self.log_text.verticalScrollBar().maximum()))
|
||
except Exception as e:
|
||
pass
|
||
|
||
def log_message(self, message):
|
||
"""
|
||
日志回调函数(保留用于信号连接)
|
||
"""
|
||
self.enqueue_log_message(message)
|
||
|
||
def start_detection(self):
|
||
"""
|
||
开始检测
|
||
"""
|
||
self.status_label.setText("状态: 检测中...")
|
||
self.start_button.setEnabled(False)
|
||
self.stop_button.setEnabled(True)
|
||
|
||
# 创建并启动检测线程
|
||
self.detect_thread = DetectThread(self.worker)
|
||
self.detect_thread.log_signal.connect(self.log_message)
|
||
self.detect_thread.progress_signal.connect(self.update_progress)
|
||
self.detect_thread.thread_count_signal.connect(self.update_thread_count_display)
|
||
self.detect_thread.finished_signal.connect(self.detection_finished)
|
||
self.detect_thread.start()
|
||
|
||
def stop_detection(self):
|
||
"""
|
||
停止检测
|
||
"""
|
||
self.status_label.setText("状态: 停止中...")
|
||
self.worker.stop()
|
||
self.stop_button.setEnabled(False)
|
||
|
||
def detection_finished(self):
|
||
"""
|
||
检测完成
|
||
"""
|
||
self.status_label.setText("状态: 检测完成")
|
||
self.start_button.setEnabled(True)
|
||
self.stop_button.setEnabled(False)
|
||
|
||
def update_progress(self, current, total):
|
||
"""
|
||
更新进度条
|
||
"""
|
||
# 直接在当前线程执行,避免使用QMetaObject.invokeMethod
|
||
try:
|
||
if total > 0:
|
||
progress = int((current / total) * 100)
|
||
self.progress_bar.setValue(progress)
|
||
except Exception as e:
|
||
print(f"更新进度失败: {e}")
|
||
|
||
def update_thread_count_display(self, active_count, total_count):
|
||
"""
|
||
更新线程数量显示
|
||
|
||
:param active_count: 当前活跃线程数量
|
||
:param total_count: 总线程数量
|
||
"""
|
||
try:
|
||
# 检查是否有配置标签
|
||
if hasattr(self, 'config_labels') and 'thread_count' in self.config_labels:
|
||
self.config_labels['thread_count'].setText(f"检测线程数: {total_count} (当前: {active_count})")
|
||
except Exception as e:
|
||
print(f"更新线程数量显示失败: {e}")
|
||
|
||
def refresh_proxy_pool_manually(self):
|
||
self.status_label.setText("状态: 正在刷新代理池...")
|
||
self.refresh_proxy_button.setEnabled(False)
|
||
|
||
def run_refresh():
|
||
try:
|
||
self.worker.refresh_proxy_pool()
|
||
with self.worker.proxy_pool_lock:
|
||
proxy_count = len(self.worker.proxy_pool)
|
||
self.worker.update_config_labels()
|
||
QMetaObject.invokeMethod(
|
||
self.status_label,
|
||
"setText",
|
||
Qt.QueuedConnection,
|
||
Q_ARG(str, f"状态: 代理池刷新完成,可用代理 {proxy_count} 个")
|
||
)
|
||
except Exception as e:
|
||
QMetaObject.invokeMethod(
|
||
self.status_label,
|
||
"setText",
|
||
Qt.QueuedConnection,
|
||
Q_ARG(str, f"状态: 刷新代理池失败: {e}")
|
||
)
|
||
finally:
|
||
QMetaObject.invokeMethod(
|
||
self.refresh_proxy_button,
|
||
"setEnabled",
|
||
Qt.QueuedConnection,
|
||
Q_ARG(bool, True)
|
||
)
|
||
|
||
threading.Thread(target=run_refresh, daemon=True).start()
|
||
|
||
def start_scheduler(self):
|
||
"""
|
||
启动定时任务
|
||
"""
|
||
# 每天凌晨1点执行检测
|
||
schedule.every().day.at("01:00").do(self.scheduled_detection)
|
||
|
||
# 循环执行定时任务
|
||
while True:
|
||
schedule.run_pending()
|
||
time.sleep(60)
|
||
|
||
def scheduled_detection(self):
|
||
"""
|
||
定时检测任务
|
||
"""
|
||
self.log_message(f"[{datetime.now()}] 开始定时检测任务")
|
||
self.start_detection()
|
||
|
||
def refresh_config(self):
|
||
"""
|
||
刷新配置
|
||
"""
|
||
try:
|
||
# 重新加载配置
|
||
new_detect_options = self.worker.load_detect_options()
|
||
new_proxy_config = self.worker.load_proxy_config()
|
||
new_thread_count = self.worker.load_thread_count()
|
||
new_sensitive_words = self.worker.db.get_all_sensitive_words()
|
||
self.worker.load_cookies_from_remote()
|
||
|
||
# 检查配置是否发生变化
|
||
config_changed = False
|
||
if new_detect_options != self.worker.detect_options:
|
||
self.worker.detect_options = new_detect_options
|
||
config_changed = True
|
||
if new_proxy_config != self.worker.proxy_config:
|
||
self.worker.proxy_config = new_proxy_config
|
||
config_changed = True
|
||
if new_thread_count != self.worker.thread_count:
|
||
self.worker.thread_count = new_thread_count
|
||
config_changed = True
|
||
if new_sensitive_words != self.worker.sensitive_words:
|
||
self.worker.sensitive_words = new_sensitive_words
|
||
config_changed = True
|
||
|
||
# 如果配置发生变化,更新标签
|
||
if config_changed:
|
||
self.worker.update_config_labels()
|
||
self.log_message(f"[{datetime.now()}] 配置已更新")
|
||
except Exception as e:
|
||
self.log_message(f"[{datetime.now()}] 刷新配置失败: {e}")
|
||
|
||
def event(self, event):
|
||
"""
|
||
处理事件
|
||
"""
|
||
if event.type() == ConfigUpdateEvent.Type:
|
||
self.update_config_labels()
|
||
return True
|
||
return super().event(event)
|
||
|
||
def update_config_labels(self):
|
||
"""
|
||
更新GUI配置标签
|
||
"""
|
||
logger.debug("开始更新GUI配置标签")
|
||
try:
|
||
if self.worker:
|
||
try:
|
||
logger.debug(f"worker对象存在,当前配置: {self.worker.detect_options}, {self.worker.proxy_config}, {self.worker.thread_count}")
|
||
# 准备更新数据
|
||
detect_options = self.worker.detect_options
|
||
free_options_display = []
|
||
paid_options_display = []
|
||
option_names = {
|
||
'detect_register': '检查注册',
|
||
'detect_baidu_site': '百度site查询',
|
||
'detect_360_site': '360的site查询',
|
||
'detect_chinaz': '站长之家查询',
|
||
'detect_aizhan': '爱站网查询',
|
||
'detect_wayback': '时光机检测',
|
||
'detect_jucha': '聚查查询',
|
||
'detect_juziseo': '桔子查询'
|
||
}
|
||
|
||
ordered_keys = detect_options.get('detect_order') or list(option_names.keys())
|
||
normalized_keys = [key for key in ordered_keys if key in option_names]
|
||
for key in option_names:
|
||
if key not in normalized_keys:
|
||
normalized_keys.append(key)
|
||
|
||
free_keys = {
|
||
'detect_register',
|
||
'detect_baidu_site',
|
||
'detect_360_site',
|
||
'detect_chinaz',
|
||
'detect_aizhan',
|
||
'detect_wayback',
|
||
}
|
||
|
||
for index, key in enumerate(normalized_keys, start=1):
|
||
name = option_names[key]
|
||
if key in detect_options and detect_options[key]:
|
||
item_text = f"{index}.{name}"
|
||
if key in free_keys:
|
||
free_options_display.append(item_text)
|
||
else:
|
||
paid_options_display.append(item_text)
|
||
|
||
free_text = " | ".join(free_options_display) if free_options_display else "无"
|
||
paid_text = " | ".join(paid_options_display) if paid_options_display else "无"
|
||
detect_options_text = (
|
||
"当前检测选项:\n"
|
||
f"免费优先: {free_text}\n"
|
||
f"付费后置: {paid_text}"
|
||
)
|
||
|
||
# 准备代理配置文本
|
||
proxy_enable = self.worker.proxy_config.get('proxy_enable', False)
|
||
allow_direct = self.worker.proxy_config.get('allow_direct', False)
|
||
proxy_urls = self.worker.proxy_config.get('proxy_urls') or []
|
||
if not proxy_urls and self.worker.proxy_config.get('proxy_url'):
|
||
proxy_urls = [self.worker.proxy_config.get('proxy_url', '')]
|
||
with self.worker.proxy_pool_lock:
|
||
proxy_pool_count = len(self.worker.proxy_pool)
|
||
last_refresh_time = (
|
||
self.worker.proxy_last_refresh_time.strftime('%Y-%m-%d %H:%M:%S')
|
||
if self.worker.proxy_last_refresh_time else '未刷新'
|
||
)
|
||
proxy_display = (
|
||
f"启用: {'是' if proxy_enable else '否'} | "
|
||
f"允许直连: {'是' if allow_direct else '否'} | "
|
||
f"代理池链接数: {len([url for url in proxy_urls if url])} | "
|
||
f"当前可用代理数: {proxy_pool_count}\n"
|
||
f"最近刷新: {last_refresh_time} | "
|
||
f"最近结果: {self.worker.proxy_last_refresh_status} | "
|
||
f"原始返回数: {self.worker.proxy_last_refresh_total_items}"
|
||
)
|
||
proxy_text = f"代理配置: {proxy_display}"
|
||
|
||
# 准备线程数文本
|
||
thread_count_text = f"检测线程数: {self.worker.thread_count}"
|
||
|
||
logger.debug(f"准备更新标签: {detect_options_text}, {proxy_text}, {thread_count_text}")
|
||
|
||
# 检查配置标签是否存在
|
||
if hasattr(self, 'config_labels'):
|
||
# 使用QMetaObject.invokeMethod确保在主线程中更新UI
|
||
from PySide6.QtCore import QMetaObject, Qt, Q_ARG
|
||
|
||
# 更新检测选项标签
|
||
if 'detect_options' in self.config_labels:
|
||
QMetaObject.invokeMethod(self.config_labels['detect_options'], "setText",
|
||
Qt.QueuedConnection,
|
||
Q_ARG(str, detect_options_text))
|
||
|
||
# 更新代理配置标签
|
||
if 'proxy_config' in self.config_labels:
|
||
QMetaObject.invokeMethod(self.config_labels['proxy_config'], "setText",
|
||
Qt.QueuedConnection,
|
||
Q_ARG(str, proxy_text))
|
||
|
||
# 更新线程数标签
|
||
if 'thread_count' in self.config_labels:
|
||
QMetaObject.invokeMethod(self.config_labels['thread_count'], "setText",
|
||
Qt.QueuedConnection,
|
||
Q_ARG(str, thread_count_text))
|
||
|
||
logger.debug("GUI配置标签更新完成")
|
||
else:
|
||
logger.debug("配置标签不存在,跳过更新")
|
||
except Exception as e:
|
||
logger.error(f"更新GUI标签失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
else:
|
||
logger.debug("worker对象不存在")
|
||
except Exception as e:
|
||
logger.error(f"执行update_config_labels失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
from PySide6.QtCore import QObject, Signal, QEvent
|
||
|
||
class ConfigUpdateEvent(QEvent):
|
||
"""
|
||
配置更新事件类
|
||
"""
|
||
Type = QEvent.Type(QEvent.User + 1)
|
||
|
||
def __init__(self):
|
||
super().__init__(ConfigUpdateEvent.Type)
|
||
|
||
class ConfigUpdateSignal(QObject):
|
||
"""
|
||
配置更新信号类
|
||
"""
|
||
config_updated = Signal()
|
||
|
||
def runtime_state_key(node_code=None):
|
||
normalized_node_code = str(node_code or config.NODE_CODE or "").strip()
|
||
if not normalized_node_code:
|
||
return RUNTIME_STATE_KEY
|
||
return f"{RUNTIME_STATE_KEY}:{normalized_node_code}"
|
||
|
||
|
||
class DetectWorker:
|
||
"""
|
||
域名检测端工作类
|
||
"""
|
||
def __init__(self, main_window=None):
|
||
"""
|
||
初始化检测端
|
||
|
||
:param main_window: 主窗口实例,用于更新GUI
|
||
"""
|
||
self.main_window = main_window
|
||
self.running = False
|
||
self.stop_requested = False
|
||
self.detecting = False
|
||
self.detect_threads = []
|
||
self.detect_thread = None # 检测线程实例
|
||
self.detect_command_thread = None
|
||
self.detect_lock = threading.Lock()
|
||
self._domain_thread_counter_lock = threading.Lock()
|
||
self._task_local = threading.local()
|
||
self._step_result_cache_lock = threading.Lock()
|
||
self._step_result_cache = {}
|
||
self._active_domain_threads = 0
|
||
self.detect_command_started_at = 0.0
|
||
self.detect_command_last_activity_at = 0.0
|
||
self._last_domain_started_at = 0.0
|
||
self._last_domain_result_at = 0.0
|
||
self._pending_restart_source = ""
|
||
self._pending_restart_payload = None
|
||
self._pending_restart_reason = ""
|
||
self._detect_session_seq = 0
|
||
self._detect_session_owner = 0
|
||
self._last_thread_count_refresh_at = 0.0
|
||
self.runtime_heartbeat_interval = max(
|
||
3.0,
|
||
float(os.getenv("DOMAINCHECK_RUNTIME_HEARTBEAT_INTERVAL", "5") or 5),
|
||
)
|
||
self._runtime_heartbeat_stop = threading.Event()
|
||
self._last_runtime_phase = "idle"
|
||
self._last_runtime_detail = "Worker 初始化中"
|
||
self._last_runtime_extra = {}
|
||
self._last_runtime_state_push_at = 0.0
|
||
self._last_runtime_state_push_phase = ""
|
||
self.current_cycle_token = ""
|
||
self.current_job_id = None
|
||
self.current_job_code = ""
|
||
self.current_job_task_mode = ""
|
||
self.runtime_settings = {
|
||
"worker_log_sync_enabled": False,
|
||
"worker_log_sync_mode": "key",
|
||
"worker_step_trace_enabled": True,
|
||
"worker_step_trace_sync_full": True,
|
||
}
|
||
self._last_synced_worker_log = ""
|
||
self._worker_log_sync_queue = Queue(maxsize=50000)
|
||
self._worker_log_sync_stop = threading.Event()
|
||
self._worker_log_sync_drop_count = 0
|
||
self._last_worker_log_sync_drop_notice_at = 0.0
|
||
self._running_mark_lock = threading.Lock()
|
||
self._pending_running_marks = collections.deque()
|
||
self._last_running_mark_flush_at = 0.0
|
||
self._job_finalize_lock = threading.Lock()
|
||
self._pending_job_finalizations = collections.deque()
|
||
self._last_job_finalize_flush_at = 0.0
|
||
self._domain_status_update_lock = threading.Lock()
|
||
self._pending_domain_status_updates = collections.deque()
|
||
self._last_domain_status_flush_at = 0.0
|
||
self._domain_completion_lock = threading.Lock()
|
||
self._pending_domain_completions = collections.deque()
|
||
self._last_domain_completion_flush_at = 0.0
|
||
self._review_status_update_lock = threading.Lock()
|
||
self._pending_review_status_updates = collections.deque()
|
||
self._last_review_status_flush_at = 0.0
|
||
self._completed_future_lock = threading.Lock()
|
||
self._completed_futures = collections.deque()
|
||
|
||
# 初始化数据库连接
|
||
self.db = Database()
|
||
try:
|
||
self.db.ensure_cluster_runtime_tables()
|
||
self.db.register_cluster_node(
|
||
config.NODE_CODE,
|
||
config.NODE_REGION,
|
||
config.NODE_ROLE,
|
||
status="online",
|
||
current_load=0,
|
||
metadata={"service": "detect-worker", "worker_mode": os.environ.get("WORKER_MODE", "")},
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"初始化多机运行库骨架失败: {e}")
|
||
try:
|
||
recycled = self.db.recycle_running_domains(DETECT_STATUS_FAILED)
|
||
if recycled:
|
||
logger.warning(f"Worker 启动时回收了 {recycled} 个遗留的检测中域名,已标记为失败待重试")
|
||
released = self.db.release_detect_job_items_for_node(config.NODE_CODE)
|
||
if released:
|
||
logger.warning(f"Worker 启动时释放了 {released} 个当前节点遗留任务项,已重新回到 pending")
|
||
except Exception as e:
|
||
logger.warning(f"Worker 启动时清理遗留运行态失败: {e}")
|
||
|
||
# 初始化Redis连接
|
||
try:
|
||
self.redis_client = redis.Redis(
|
||
host=config.REDIS_HOST,
|
||
port=config.REDIS_PORT,
|
||
password=config.REDIS_PASSWORD,
|
||
db=config.REDIS_DB,
|
||
decode_responses=True,
|
||
socket_connect_timeout=10,
|
||
socket_timeout=10,
|
||
retry_on_timeout=True,
|
||
health_check_interval=30
|
||
)
|
||
# 测试连接
|
||
self.redis_client.ping()
|
||
logger.info(f"Redis 连接成功: {config.REDIS_HOST}:{config.REDIS_PORT}")
|
||
self.use_redis = True
|
||
except Exception as e:
|
||
logger.warning(f"Redis 连接失败: {e},将使用本地配置")
|
||
self.redis_client = None
|
||
self.use_redis = False
|
||
|
||
# 初始化配置更新信号
|
||
self.config_signal = ConfigUpdateSignal()
|
||
|
||
# 加载配置
|
||
self.detect_options = self.load_detect_options()
|
||
self.proxy_config = self.load_proxy_config()
|
||
self.thread_count = self.load_thread_count() # 从配置文件加载线程数
|
||
self.runtime_settings = self.load_runtime_settings()
|
||
|
||
# 初始化代理池
|
||
self.proxy_pool = []
|
||
self.proxy_pool_lock = threading.Lock()
|
||
self.proxy_refresh_lock = threading.Lock()
|
||
self.proxy_last_refresh_time = None
|
||
self.proxy_last_refresh_status = "未刷新"
|
||
self.proxy_last_refresh_source_count = 0
|
||
self.proxy_last_refresh_total_items = 0
|
||
self.proxy_last_validated_count = 0
|
||
self.proxy_last_available_count = 0
|
||
self.proxy_last_source_stats = []
|
||
self.proxy_refresh_cooldown_seconds = 30
|
||
self.proxy_next_refresh_time = 0.0
|
||
self.proxy_max_reuse_count = max(8, self.thread_count * 4)
|
||
self.proxy_step_wait_timeout_seconds = max(
|
||
0.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_WAIT_TIMEOUT", "1.5") or 1.5),
|
||
)
|
||
self.proxy_direct_fallback_grace_seconds = max(
|
||
0.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE", "0.35") or 0.35),
|
||
)
|
||
self.proxy_step_retry_max_attempts = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS", "12") or 12),
|
||
)
|
||
self.proxy_step_retry_max_seconds = max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS", "45") or 45),
|
||
)
|
||
self.proxy_step_retry_budget_overrides = {
|
||
"注册状态检测": {
|
||
"max_attempts": max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS_REGISTER", "3") or 3),
|
||
),
|
||
"max_seconds": max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS_REGISTER", "8") or 8),
|
||
),
|
||
},
|
||
"百度site检测": {
|
||
"max_attempts": max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS_BAIDU", "3") or 3),
|
||
),
|
||
"max_seconds": max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS_BAIDU", "6") or 6),
|
||
),
|
||
},
|
||
"360检测": {
|
||
"max_attempts": max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS_360", "2") or 2),
|
||
),
|
||
"max_seconds": max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS_360", "5") or 5),
|
||
),
|
||
},
|
||
"站长之家检测": {
|
||
"max_attempts": max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS_CHINAZ", "2") or 2),
|
||
),
|
||
"max_seconds": max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS_CHINAZ", "5") or 5),
|
||
),
|
||
},
|
||
"爱站网检测": {
|
||
"max_attempts": max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS_AIZHAN", "2") or 2),
|
||
),
|
||
"max_seconds": max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS_AIZHAN", "5") or 5),
|
||
),
|
||
},
|
||
"时光机检测": {
|
||
"max_attempts": max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_ATTEMPTS_WAYBACK", "3") or 3),
|
||
),
|
||
"max_seconds": max(
|
||
5.0,
|
||
float(os.getenv("DOMAINCHECK_PROXY_STEP_MAX_SECONDS_WAYBACK", "12") or 12),
|
||
),
|
||
},
|
||
}
|
||
self._last_no_proxy_notice_at = 0.0
|
||
self.proxy_failure_lock = threading.Lock()
|
||
self.proxy_failure_counts = {}
|
||
self.proxy_quarantine_until = {}
|
||
self.proxy_source_by_key = {}
|
||
self.proxy_source_failure_counts = {}
|
||
self.proxy_source_quarantine_until = {}
|
||
self.redis_sub_thread = None
|
||
self._last_autoresume_skip_log_at = 0.0
|
||
self._last_autoresume_success_log_at = 0.0
|
||
|
||
# 加载敏感词(只加载一次,所有线程共享)
|
||
try:
|
||
self.sensitive_words = self._load_sensitive_words_runtime()
|
||
logger.info(f"加载敏感词完成,共 {len(self.sensitive_words)} 个敏感词")
|
||
except Exception as e:
|
||
logger.error(f"加载敏感词失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
self.sensitive_words = []
|
||
|
||
# 初始化运行状态
|
||
self.running = False
|
||
|
||
# 加载cookies
|
||
self.load_cookies_from_remote()
|
||
|
||
# 付费检测器按需初始化,避免在默认关闭时产生无效启动告警。
|
||
self.jc_instance = None
|
||
self.juziseo_instance = None
|
||
self._init_optional_detectors()
|
||
self.wayback_detector = WaybackDetector()
|
||
|
||
# 显示所有配置信息
|
||
logger.info("检测端配置信息:")
|
||
logger.info(f"检测选项: {self.detect_options}")
|
||
logger.info(f"代理配置: {self.proxy_config}")
|
||
logger.info(f"检测线程数: {self.thread_count}")
|
||
|
||
# Redis订阅线程将在start方法中启动
|
||
self._update_runtime_state("idle", "Worker 已启动,等待检测指令")
|
||
self._start_runtime_heartbeat_loop()
|
||
self._start_worker_log_sync_loop()
|
||
self.trigger_proxy_refresh(reason="worker_startup", reset_cooldown=True)
|
||
|
||
def _start_runtime_heartbeat_loop(self):
|
||
existing = getattr(self, "_runtime_heartbeat_thread", None)
|
||
if existing and existing.is_alive():
|
||
return
|
||
self._runtime_heartbeat_stop.clear()
|
||
self._runtime_heartbeat_thread = threading.Thread(
|
||
target=self._runtime_heartbeat_loop,
|
||
name="RuntimeHeartbeatThread",
|
||
daemon=True,
|
||
)
|
||
self._runtime_heartbeat_thread.start()
|
||
|
||
def _runtime_heartbeat_loop(self):
|
||
while not self._runtime_heartbeat_stop.wait(self.runtime_heartbeat_interval):
|
||
try:
|
||
self._consume_pending_control_command()
|
||
except Exception as e:
|
||
logger.debug(f"运行态心跳补偿消费待执行控制指令失败: {e}")
|
||
try:
|
||
self._resume_active_detect_job_if_needed(reason="heartbeat_autoresume")
|
||
except Exception as e:
|
||
logger.debug(f"运行态心跳自动回挂检测任务失败: {e}")
|
||
try:
|
||
self._update_runtime_state(
|
||
self._last_runtime_phase,
|
||
self._last_runtime_detail,
|
||
**dict(self._last_runtime_extra or {}),
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"运行态心跳续期失败: {e}")
|
||
|
||
def _start_worker_log_sync_loop(self):
|
||
existing = getattr(self, "_worker_log_sync_thread", None)
|
||
if existing and existing.is_alive():
|
||
return
|
||
self._worker_log_sync_stop.clear()
|
||
self._worker_log_sync_thread = threading.Thread(
|
||
target=self._worker_log_sync_loop,
|
||
name="WorkerLogSyncThread",
|
||
daemon=True,
|
||
)
|
||
self._worker_log_sync_thread.start()
|
||
|
||
def _worker_log_sync_loop(self):
|
||
while True:
|
||
if self._worker_log_sync_stop.is_set() and self._worker_log_sync_queue.empty():
|
||
break
|
||
try:
|
||
item = self._worker_log_sync_queue.get(timeout=0.5)
|
||
except Empty:
|
||
continue
|
||
if item is None:
|
||
self._worker_log_sync_queue.task_done()
|
||
if self._worker_log_sync_stop.is_set():
|
||
break
|
||
continue
|
||
|
||
if item.get("write_db", True):
|
||
try:
|
||
self.db.append_detect_run_event(
|
||
item["job_id"],
|
||
item["job_item_id"],
|
||
config.NODE_CODE,
|
||
event_type='worker_log',
|
||
message=item["message"],
|
||
level=item["level"],
|
||
payload=item["payload"],
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Worker 日志事件写库失败: {e}")
|
||
|
||
try:
|
||
self._push_remote_debug_event(
|
||
message=item["message"],
|
||
level=item["level"],
|
||
payload=item["payload"],
|
||
event_type="worker_log",
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Worker 日志事件远端回传失败: {e}")
|
||
finally:
|
||
self._worker_log_sync_queue.task_done()
|
||
|
||
def _enqueue_running_mark(self, job_item_id, claim_token):
|
||
normalized_claim_token = str(claim_token or "").strip()
|
||
try:
|
||
normalized_job_item_id = int(job_item_id or 0)
|
||
except Exception:
|
||
normalized_job_item_id = 0
|
||
if normalized_job_item_id <= 0 or not normalized_claim_token:
|
||
return
|
||
with self._running_mark_lock:
|
||
self._pending_running_marks.append((normalized_job_item_id, normalized_claim_token))
|
||
|
||
def _flush_pending_running_marks(self, *, force=False, batch_limit=512):
|
||
now_ts = time.time()
|
||
if not force and now_ts - float(getattr(self, "_last_running_mark_flush_at", 0.0) or 0.0) < 0.05:
|
||
return 0
|
||
batch = []
|
||
with self._running_mark_lock:
|
||
if not self._pending_running_marks:
|
||
if force:
|
||
self._last_running_mark_flush_at = now_ts
|
||
return 0
|
||
while self._pending_running_marks and len(batch) < max(1, int(batch_limit or 1)):
|
||
batch.append(self._pending_running_marks.popleft())
|
||
updated_count = 0
|
||
try:
|
||
updated_count = int(self.db.mark_detect_job_items_running_batch(batch) or 0)
|
||
except Exception as e:
|
||
logger.debug(f"批量刷新运行中任务项失败: {e}")
|
||
updated_count = -1
|
||
if updated_count < 0:
|
||
with self._running_mark_lock:
|
||
for item in reversed(batch):
|
||
self._pending_running_marks.appendleft(item)
|
||
logger.warning(
|
||
f"批量刷新运行中任务项失败,已回退待重试队列: batch={len(batch)} | force={1 if force else 0}"
|
||
)
|
||
updated_count = 0
|
||
self._last_running_mark_flush_at = now_ts
|
||
return updated_count
|
||
|
||
def _enqueue_job_finalization(
|
||
self,
|
||
*,
|
||
job_item_id,
|
||
claim_token,
|
||
final_status,
|
||
message="",
|
||
result_payload=None,
|
||
result_version="v1",
|
||
job_id=None,
|
||
node_code="",
|
||
event_type="",
|
||
event_level="info",
|
||
event_message="",
|
||
event_payload=None,
|
||
):
|
||
normalized_claim_token = str(claim_token or "").strip()
|
||
try:
|
||
normalized_job_item_id = int(job_item_id or 0)
|
||
except Exception:
|
||
normalized_job_item_id = 0
|
||
if normalized_job_item_id <= 0 or not normalized_claim_token:
|
||
return
|
||
try:
|
||
normalized_job_id = int(job_id or 0)
|
||
except Exception:
|
||
normalized_job_id = 0
|
||
with self._job_finalize_lock:
|
||
self._pending_job_finalizations.append(
|
||
{
|
||
"job_item_id": normalized_job_item_id,
|
||
"claim_token": normalized_claim_token,
|
||
"final_status": str(final_status or "").strip() or "failed",
|
||
"message": str(message or "").strip(),
|
||
"result_payload": result_payload,
|
||
"result_version": str(result_version or "v1").strip() or "v1",
|
||
"job_id": normalized_job_id,
|
||
"node_code": str(node_code or "").strip(),
|
||
"event_type": str(event_type or "").strip(),
|
||
"event_level": str(event_level or "info").strip() or "info",
|
||
"event_message": str(event_message or "").strip(),
|
||
"event_payload": dict(event_payload or {}),
|
||
}
|
||
)
|
||
|
||
def _flush_pending_job_finalizations(self, *, force=False, batch_limit=512):
|
||
now_ts = time.time()
|
||
if not force and now_ts - float(getattr(self, "_last_job_finalize_flush_at", 0.0) or 0.0) < 0.05:
|
||
return 0
|
||
batch = []
|
||
with self._job_finalize_lock:
|
||
if not self._pending_job_finalizations:
|
||
if force:
|
||
self._last_job_finalize_flush_at = now_ts
|
||
return 0
|
||
while self._pending_job_finalizations and len(batch) < max(1, int(batch_limit or 1)):
|
||
batch.append(self._pending_job_finalizations.popleft())
|
||
updated_count = 0
|
||
flush_started_at = time.perf_counter()
|
||
try:
|
||
updated_count = int(self.db.finalize_detect_job_items_batch(batch) or 0)
|
||
except Exception as e:
|
||
logger.debug(f"批量回写任务项完成态失败: {e}")
|
||
updated_count = -1
|
||
if updated_count < 0:
|
||
with self._job_finalize_lock:
|
||
for item in reversed(batch):
|
||
self._pending_job_finalizations.appendleft(item)
|
||
logger.warning(
|
||
f"批量回写任务项完成态失败,已回退待重试队列: batch={len(batch)} | force={1 if force else 0}"
|
||
)
|
||
updated_count = 0
|
||
flush_elapsed_ms = int((time.perf_counter() - flush_started_at) * 1000)
|
||
if batch and (flush_elapsed_ms >= 20 or len(batch) >= 64 or force):
|
||
logger.info(
|
||
f"任务项完成态批量回写: batch={len(batch)} | updated={updated_count} "
|
||
f"| elapsed_ms={flush_elapsed_ms} | force={1 if force else 0}"
|
||
)
|
||
self._last_job_finalize_flush_at = now_ts
|
||
return updated_count
|
||
|
||
def _enqueue_domain_status_update(self, domain_id, status):
|
||
try:
|
||
normalized_domain_id = int(domain_id or 0)
|
||
normalized_status = int(status)
|
||
except Exception:
|
||
return
|
||
if normalized_domain_id <= 0:
|
||
return
|
||
with self._domain_status_update_lock:
|
||
self._pending_domain_status_updates.append((normalized_domain_id, normalized_status))
|
||
|
||
def _enqueue_domain_completion(self, domain_id, *, register_status, use_status, expire_date):
|
||
try:
|
||
normalized_domain_id = int(domain_id or 0)
|
||
normalized_register_status = int(register_status or 0)
|
||
normalized_use_status = int(use_status or 0)
|
||
except Exception:
|
||
return
|
||
if normalized_domain_id <= 0:
|
||
return
|
||
with self._domain_completion_lock:
|
||
self._pending_domain_completions.append(
|
||
(
|
||
normalized_domain_id,
|
||
normalized_register_status,
|
||
normalized_use_status,
|
||
bool(expire_date),
|
||
)
|
||
)
|
||
|
||
def _flush_pending_domain_completions(self, *, force=False, batch_limit=512):
|
||
now_ts = time.time()
|
||
if not force and now_ts - float(getattr(self, "_last_domain_completion_flush_at", 0.0) or 0.0) < 0.05:
|
||
return 0
|
||
batch = []
|
||
with self._domain_completion_lock:
|
||
if not self._pending_domain_completions:
|
||
if force:
|
||
self._last_domain_completion_flush_at = now_ts
|
||
return 0
|
||
while self._pending_domain_completions and len(batch) < max(1, int(batch_limit or 1)):
|
||
batch.append(self._pending_domain_completions.popleft())
|
||
updated_count = 0
|
||
flush_started_at = time.perf_counter()
|
||
try:
|
||
updated_count = int(self.db.complete_domain_detection_batch(batch) or 0)
|
||
except Exception as e:
|
||
logger.debug(f"批量刷新域名完成态失败: {e}")
|
||
updated_count = -1
|
||
if updated_count < 0:
|
||
with self._domain_completion_lock:
|
||
for item in reversed(batch):
|
||
self._pending_domain_completions.appendleft(item)
|
||
logger.warning(
|
||
f"批量刷新域名完成态失败,已回退待重试队列: batch={len(batch)} | force={1 if force else 0}"
|
||
)
|
||
updated_count = 0
|
||
flush_elapsed_ms = int((time.perf_counter() - flush_started_at) * 1000)
|
||
if batch and (flush_elapsed_ms >= 20 or len(batch) >= 64 or force):
|
||
logger.info(
|
||
f"域名完成态批量回写: batch={len(batch)} | updated={updated_count} "
|
||
f"| elapsed_ms={flush_elapsed_ms} | force={1 if force else 0}"
|
||
)
|
||
self._last_domain_completion_flush_at = now_ts
|
||
return updated_count
|
||
|
||
def _flush_pending_domain_status_updates(self, *, force=False, batch_limit=512):
|
||
now_ts = time.time()
|
||
if not force and now_ts - float(getattr(self, "_last_domain_status_flush_at", 0.0) or 0.0) < 0.05:
|
||
return 0
|
||
batch = []
|
||
with self._domain_status_update_lock:
|
||
if not self._pending_domain_status_updates:
|
||
if force:
|
||
self._last_domain_status_flush_at = now_ts
|
||
return 0
|
||
while self._pending_domain_status_updates and len(batch) < max(1, int(batch_limit or 1)):
|
||
batch.append(self._pending_domain_status_updates.popleft())
|
||
updated_count = 0
|
||
try:
|
||
updated_count = int(self.db.update_domain_detect_status_batch(batch) or 0)
|
||
except Exception as e:
|
||
logger.debug(f"批量刷新域名检测状态失败: {e}")
|
||
updated_count = -1
|
||
if updated_count < 0:
|
||
with self._domain_status_update_lock:
|
||
for item in reversed(batch):
|
||
self._pending_domain_status_updates.appendleft(item)
|
||
logger.warning(
|
||
f"批量刷新域名检测状态失败,已回退待重试队列: batch={len(batch)} | force={1 if force else 0}"
|
||
)
|
||
updated_count = 0
|
||
self._last_domain_status_flush_at = now_ts
|
||
return updated_count
|
||
|
||
def _enqueue_review_status_update(self, domain_id, review_status):
|
||
try:
|
||
normalized_domain_id = int(domain_id or 0)
|
||
normalized_review_status = int(review_status)
|
||
except Exception:
|
||
return
|
||
if normalized_domain_id <= 0:
|
||
return
|
||
with self._review_status_update_lock:
|
||
self._pending_review_status_updates.append((normalized_domain_id, normalized_review_status))
|
||
|
||
def _flush_pending_review_status_updates(self, *, force=False, batch_limit=512):
|
||
now_ts = time.time()
|
||
if not force and now_ts - float(getattr(self, "_last_review_status_flush_at", 0.0) or 0.0) < 0.05:
|
||
return 0
|
||
batch = []
|
||
with self._review_status_update_lock:
|
||
if not self._pending_review_status_updates:
|
||
if force:
|
||
self._last_review_status_flush_at = now_ts
|
||
return 0
|
||
while self._pending_review_status_updates and len(batch) < max(1, int(batch_limit or 1)):
|
||
batch.append(self._pending_review_status_updates.popleft())
|
||
updated_count = 0
|
||
try:
|
||
updated_count = int(self.db.update_domain_review_status_batch(batch) or 0)
|
||
except Exception as e:
|
||
logger.debug(f"批量刷新域名复核状态失败: {e}")
|
||
updated_count = -1
|
||
if updated_count < 0:
|
||
with self._review_status_update_lock:
|
||
for item in reversed(batch):
|
||
self._pending_review_status_updates.appendleft(item)
|
||
logger.warning(
|
||
f"批量刷新域名复核状态失败,已回退待重试队列: batch={len(batch)} | force={1 if force else 0}"
|
||
)
|
||
updated_count = 0
|
||
self._last_review_status_flush_at = now_ts
|
||
return updated_count
|
||
|
||
def _enqueue_completed_future(self, future):
|
||
if future is None:
|
||
return
|
||
with self._completed_future_lock:
|
||
self._completed_futures.append(future)
|
||
|
||
def _consume_pending_control_command(self):
|
||
if not self.use_redis or self.redis_client is None:
|
||
return
|
||
try:
|
||
payload = self.redis_client.getdel(PENDING_CONTROL_KEY)
|
||
except Exception:
|
||
try:
|
||
payload = self.redis_client.get(PENDING_CONTROL_KEY)
|
||
if payload:
|
||
self.redis_client.delete(PENDING_CONTROL_KEY)
|
||
except Exception as e:
|
||
logger.debug(f"读取待执行控制指令失败: {e}")
|
||
return
|
||
if not payload:
|
||
return
|
||
logger.info(f"发现待执行 Worker 控制指令: {payload}")
|
||
self._handle_control_message(payload)
|
||
|
||
def _resume_active_detect_job_if_needed(self, reason: str = "worker_bootstrap"):
|
||
now_ts = time.time()
|
||
stale_detect = False
|
||
|
||
def log_skip(detail: str):
|
||
if now_ts - float(getattr(self, "_last_autoresume_skip_log_at", 0.0) or 0.0) >= 15.0:
|
||
logger.info(f"自动回挂跳过: reason={reason}, detail={detail}")
|
||
self._last_autoresume_skip_log_at = now_ts
|
||
|
||
if not self.running:
|
||
log_skip("worker_not_running")
|
||
return False
|
||
if self._consume_pending_restart_request(trigger_reason=reason):
|
||
return True
|
||
if self.stop_requested:
|
||
log_skip("stop_requested")
|
||
return False
|
||
if self.detecting:
|
||
stale_detect, stale_reason = self._is_stale_detect_session()
|
||
if not stale_detect:
|
||
log_skip("already_detecting")
|
||
return False
|
||
logger.warning(f"检测运行态疑似空转,允许自动回挂接管: {stale_reason}")
|
||
thread = self.detect_command_thread
|
||
if thread and thread.is_alive() and not stale_detect:
|
||
log_skip("detect_command_thread_alive")
|
||
return False
|
||
try:
|
||
active_job = self.db.get_active_detect_job() or {}
|
||
except Exception as e:
|
||
logger.debug(f"读取当前检测任务失败,跳过自动回挂: {e}")
|
||
return False
|
||
job_id = active_job.get("id")
|
||
job_code = str(active_job.get("job_code") or "").strip()
|
||
task_mode = str(active_job.get("task_mode") or "").strip()
|
||
if not job_id or not job_code:
|
||
log_skip("active_job_missing_id_or_code")
|
||
return False
|
||
items_pending = int(active_job.get("items_pending", 0) or 0)
|
||
items_claimed = int(active_job.get("items_claimed", 0) or 0)
|
||
items_running = int(active_job.get("items_running", 0) or 0)
|
||
if items_pending <= 0 and items_claimed <= 0 and items_running <= 0:
|
||
log_skip("active_job_empty")
|
||
return False
|
||
logger.warning(
|
||
f"检测 Worker 空闲但发现活动任务,准备自动回挂: "
|
||
f"job_code={job_code}, pending={items_pending}, claimed={items_claimed}, running={items_running}, reason={reason}"
|
||
)
|
||
started = self.start_detection_async(
|
||
source="auto-resume",
|
||
control_payload={
|
||
"source": reason,
|
||
"job_id": int(job_id),
|
||
"job_code": job_code,
|
||
"task_mode": task_mode,
|
||
"target_job_id": int(job_id),
|
||
"target_job_code": job_code,
|
||
"target_task_mode": task_mode,
|
||
},
|
||
)
|
||
if started and now_ts - float(getattr(self, "_last_autoresume_success_log_at", 0.0) or 0.0) >= 5.0:
|
||
logger.warning(
|
||
f"自动回挂已触发: reason={reason}, job_code={job_code}, "
|
||
f"pending={items_pending}, claimed={items_claimed}, running={items_running}"
|
||
)
|
||
self._last_autoresume_success_log_at = now_ts
|
||
return started
|
||
|
||
def activate_service_runtime(self):
|
||
if not self.running:
|
||
self.running = True
|
||
logger.info(
|
||
f"激活 Worker 服务态运行时: node={config.NODE_CODE}, "
|
||
f"thread_count={self.thread_count}, proxy_enabled={1 if self.proxy_config.get('proxy_enable', False) else 0}"
|
||
)
|
||
if self.use_redis:
|
||
thread = getattr(self, "redis_sub_thread", None)
|
||
if not thread or not thread.is_alive():
|
||
self.redis_sub_thread = threading.Thread(
|
||
target=self.start_redis_subscription,
|
||
name="RedisSubscriptionThread",
|
||
daemon=True,
|
||
)
|
||
self.redis_sub_thread.start()
|
||
logger.info("Redis 配置/控制订阅线程已启动")
|
||
try:
|
||
self._consume_pending_control_command()
|
||
except Exception as e:
|
||
logger.warning(f"服务态启动时消费待执行控制指令失败: {e}")
|
||
try:
|
||
self._resume_active_detect_job_if_needed(reason="service_runtime_bootstrap")
|
||
except Exception as e:
|
||
logger.warning(f"服务态启动时自动回挂活动任务失败: {e}")
|
||
|
||
def _update_runtime_state(self, phase: str, detail: str, **extra):
|
||
"""
|
||
更新 Redis 中的检测运行态,供 Web 后台读取。
|
||
"""
|
||
volatile_keys = {
|
||
"active_threads",
|
||
"current_load",
|
||
"cycle_token",
|
||
"job_id",
|
||
"job_code",
|
||
}
|
||
previous_extra = dict(getattr(self, "_last_runtime_extra", {}) or {})
|
||
merged_extra = {key: value for key, value in previous_extra.items() if key not in volatile_keys}
|
||
merged_extra.update({key: value for key, value in dict(extra or {}).items() if key not in volatile_keys})
|
||
if "max_threads" not in merged_extra:
|
||
merged_extra["max_threads"] = int(getattr(self, "thread_count", 0) or 0)
|
||
with self._domain_thread_counter_lock:
|
||
live_active_threads = int(self._active_domain_threads or 0)
|
||
explicit_active_threads = (extra or {}).get("active_threads")
|
||
if not bool(self.detecting):
|
||
effective_active_threads = 0
|
||
elif explicit_active_threads in (None, ""):
|
||
effective_active_threads = live_active_threads
|
||
else:
|
||
effective_active_threads = max(int(explicit_active_threads or 0), live_active_threads)
|
||
merged_extra["active_threads"] = effective_active_threads
|
||
|
||
explicit_current_load = (extra or {}).get("current_load")
|
||
default_current_load = effective_active_threads if effective_active_threads > 0 else (1 if self.detecting else 0)
|
||
if explicit_current_load in (None, ""):
|
||
merged_extra["current_load"] = default_current_load
|
||
else:
|
||
merged_extra["current_load"] = max(int(explicit_current_load or 0), default_current_load)
|
||
self._last_runtime_phase = phase
|
||
self._last_runtime_detail = detail
|
||
self._last_runtime_extra = merged_extra
|
||
if not self.use_redis or self.redis_client is None:
|
||
return
|
||
with self.proxy_pool_lock:
|
||
available_proxy_count = len(self.proxy_pool)
|
||
payload_warning = ""
|
||
if self.proxy_config.get('proxy_enable', False) and available_proxy_count <= 0:
|
||
if (
|
||
self.proxy_last_refresh_status
|
||
and self.proxy_last_refresh_status not in {"未刷新", "代理未启用", "未配置代理池链接"}
|
||
and "刷新成功" not in self.proxy_last_refresh_status
|
||
):
|
||
payload_warning = self.proxy_last_refresh_status
|
||
payload = {
|
||
"node_code": config.NODE_CODE,
|
||
"phase": phase,
|
||
"detail": detail,
|
||
"service_running": bool(self.running),
|
||
"detecting": bool(self.detecting),
|
||
"stop_requested": bool(self.stop_requested),
|
||
"available_proxy_count": available_proxy_count,
|
||
"proxy_last_refresh_status": self.proxy_last_refresh_status,
|
||
"proxy_last_refresh_time": self.proxy_last_refresh_time.strftime("%Y-%m-%d %H:%M:%S") if self.proxy_last_refresh_time else "",
|
||
"proxy_last_refresh_source_count": int(self.proxy_last_refresh_source_count or 0),
|
||
"proxy_last_refresh_total_items": int(self.proxy_last_refresh_total_items or 0),
|
||
"proxy_last_validated_count": int(self.proxy_last_validated_count or 0),
|
||
"proxy_last_available_count": int(self.proxy_last_available_count or 0),
|
||
"proxy_last_source_stats": list(self.proxy_last_source_stats or []),
|
||
"recent_warning": payload_warning,
|
||
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
payload.update(merged_extra)
|
||
if self.current_cycle_token and not payload.get("cycle_token"):
|
||
payload["cycle_token"] = self.current_cycle_token
|
||
if self.current_job_id and not payload.get("job_id"):
|
||
payload["job_id"] = self.current_job_id
|
||
if self.current_job_code and not payload.get("job_code"):
|
||
payload["job_code"] = self.current_job_code
|
||
now_ts = time.time()
|
||
runtime_state_busy_phases = {"preparing", "refreshing_proxy", "fetching", "running", "completing"}
|
||
runtime_state_min_interval = max(
|
||
0.05,
|
||
float(os.getenv("DOMAINCHECK_RUNTIME_STATE_MIN_INTERVAL", "0.25") or 0.25),
|
||
)
|
||
last_push_phase = str(getattr(self, "_last_runtime_state_push_phase", "") or "")
|
||
last_push_at = float(getattr(self, "_last_runtime_state_push_at", 0.0) or 0.0)
|
||
if (
|
||
phase in runtime_state_busy_phases
|
||
and phase == last_push_phase
|
||
and now_ts - last_push_at < runtime_state_min_interval
|
||
):
|
||
return
|
||
try:
|
||
self.redis_client.set(runtime_state_key(), json.dumps(payload, ensure_ascii=False))
|
||
except Exception as e:
|
||
logger.debug(f"写入检测运行态失败: {e}")
|
||
try:
|
||
actual_active_threads = int(payload.get("active_threads", 0) or 0)
|
||
max_threads = int(payload.get("max_threads", getattr(self, "thread_count", 0)) or 0)
|
||
current_load = actual_active_threads
|
||
if bool(payload.get("detecting")) and current_load <= 0:
|
||
current_load = 1
|
||
self.db.register_cluster_node(
|
||
config.NODE_CODE,
|
||
config.NODE_REGION,
|
||
config.NODE_ROLE,
|
||
status="busy" if payload.get("detecting") else "online",
|
||
current_load=current_load,
|
||
metadata={
|
||
"phase": phase,
|
||
"detail": detail,
|
||
"detecting": bool(payload.get("detecting")),
|
||
"available_proxy_count": int(payload.get("available_proxy_count", 0) or 0),
|
||
"active_threads": actual_active_threads,
|
||
"max_threads": max_threads,
|
||
"cycle_token": str(payload.get("cycle_token") or ""),
|
||
"job_id": payload.get("job_id"),
|
||
"job_code": str(payload.get("job_code") or ""),
|
||
"proxy_runtime": {
|
||
"sources": int(payload.get("proxy_last_refresh_source_count", 0) or 0),
|
||
"raw_items": int(payload.get("proxy_last_refresh_total_items", 0) or 0),
|
||
"validated": int(payload.get("proxy_last_validated_count", 0) or 0),
|
||
"available": int(payload.get("proxy_last_available_count", 0) or 0),
|
||
},
|
||
},
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"更新检测节点心跳失败: {e}")
|
||
self._last_runtime_state_push_at = now_ts
|
||
self._last_runtime_state_push_phase = phase
|
||
|
||
def _mark_detection_phase(self, phase: str, detail: str, **extra):
|
||
if phase not in {"idle", "completed", "stopped"}:
|
||
self._note_detection_activity()
|
||
self._update_runtime_state(phase, detail, **extra)
|
||
|
||
def _change_active_domain_threads(self, delta: int):
|
||
with self._domain_thread_counter_lock:
|
||
self._active_domain_threads = max(0, int(self._active_domain_threads or 0) + int(delta))
|
||
if self._active_domain_threads > 0:
|
||
self._note_detection_activity()
|
||
return self._active_domain_threads
|
||
|
||
def _get_active_domain_threads(self):
|
||
with self._domain_thread_counter_lock:
|
||
return int(self._active_domain_threads or 0)
|
||
|
||
def _note_detection_activity(self):
|
||
self.detect_command_last_activity_at = time.time()
|
||
|
||
def _note_domain_started(self):
|
||
now_ts = time.time()
|
||
self._last_domain_started_at = now_ts
|
||
self.detect_command_last_activity_at = now_ts
|
||
|
||
def _note_domain_result(self):
|
||
now_ts = time.time()
|
||
self._last_domain_result_at = now_ts
|
||
self.detect_command_last_activity_at = now_ts
|
||
|
||
def _schedule_pending_restart(self, source: str, control_payload=None, reason: str = ""):
|
||
self._pending_restart_source = str(source or "deferred-restart").strip() or "deferred-restart"
|
||
self._pending_restart_payload = dict(control_payload or {})
|
||
self._pending_restart_reason = str(reason or "").strip()
|
||
|
||
def _should_force_takeover_stale_session(self, stale_reason: str = "") -> bool:
|
||
thread = self.detect_command_thread
|
||
if not thread or not thread.is_alive():
|
||
return False
|
||
with self._domain_thread_counter_lock:
|
||
live_active_threads = int(self._active_domain_threads or 0)
|
||
if live_active_threads > 0:
|
||
return False
|
||
|
||
last_activity_at = max(
|
||
float(self.detect_command_last_activity_at or 0.0),
|
||
float(self.detect_command_started_at or 0.0),
|
||
float(getattr(self, "_last_domain_started_at", 0.0) or 0.0),
|
||
float(getattr(self, "_last_domain_result_at", 0.0) or 0.0),
|
||
)
|
||
idle_seconds = max(0.0, time.time() - last_activity_at) if last_activity_at > 0 else 0.0
|
||
thread_age_seconds = max(0.0, time.time() - float(self.detect_command_started_at or 0.0))
|
||
if idle_seconds >= 10 or thread_age_seconds >= 20:
|
||
return True
|
||
return bool(stale_reason)
|
||
|
||
def _consume_pending_restart_request(self, trigger_reason: str = ""):
|
||
with self.detect_lock:
|
||
if self.detect_command_thread and self.detect_command_thread.is_alive():
|
||
return False
|
||
source = str(self._pending_restart_source or "").strip()
|
||
payload = dict(self._pending_restart_payload or {})
|
||
pending_reason = str(self._pending_restart_reason or "").strip()
|
||
if not source and not payload:
|
||
return False
|
||
self._pending_restart_source = ""
|
||
self._pending_restart_payload = None
|
||
self._pending_restart_reason = ""
|
||
logger.warning(
|
||
"检测任务执行延期重启: "
|
||
f"trigger={trigger_reason or 'unknown'}, source={source or 'deferred-restart'}, "
|
||
f"detail={pending_reason or 'none'}"
|
||
)
|
||
return self.start_detection_async(source=source or "deferred-restart", control_payload=payload)
|
||
|
||
def _incoming_job_code(self, control_payload=None) -> str:
|
||
payload = control_payload or {}
|
||
job_code = str(payload.get("job_code") or "").strip()
|
||
if not job_code:
|
||
job_code = str(payload.get("target_job_code") or "").strip()
|
||
return job_code
|
||
|
||
def _is_stale_detect_session(self, control_payload=None):
|
||
thread = self.detect_command_thread
|
||
if not thread or not thread.is_alive():
|
||
return False, ""
|
||
|
||
live_active_threads = 0
|
||
with self._domain_thread_counter_lock:
|
||
live_active_threads = int(self._active_domain_threads or 0)
|
||
|
||
last_activity_at = max(
|
||
float(self.detect_command_last_activity_at or 0.0),
|
||
float(self.detect_command_started_at or 0.0),
|
||
float(getattr(self, "_last_domain_started_at", 0.0) or 0.0),
|
||
float(getattr(self, "_last_domain_result_at", 0.0) or 0.0),
|
||
)
|
||
if last_activity_at <= 0:
|
||
return False, ""
|
||
|
||
idle_seconds = max(0.0, time.time() - last_activity_at)
|
||
thread_age_seconds = max(0.0, time.time() - float(self.detect_command_started_at or 0.0))
|
||
last_result_at = float(getattr(self, "_last_domain_result_at", 0.0) or 0.0)
|
||
result_idle_seconds = max(0.0, time.time() - last_result_at) if last_result_at > 0 else idle_seconds
|
||
incoming_job_code = self._incoming_job_code(control_payload)
|
||
current_job_code = str(self.current_job_code or "").strip()
|
||
job_switched = bool(incoming_job_code and current_job_code and incoming_job_code != current_job_code)
|
||
incoming_job_changed = bool(incoming_job_code and incoming_job_code != current_job_code)
|
||
low_activity_recoverable = live_active_threads <= 1
|
||
multi_thread_force_recover = incoming_job_changed and thread_age_seconds >= 30 and result_idle_seconds >= 30
|
||
if not low_activity_recoverable and not multi_thread_force_recover:
|
||
return False, ""
|
||
|
||
if job_switched and idle_seconds >= 15:
|
||
return True, (
|
||
f"检测指令线程仍停留在旧任务 {current_job_code},"
|
||
f"但新任务已切换为 {incoming_job_code},且已空转 {int(idle_seconds)} 秒"
|
||
)
|
||
if incoming_job_changed and idle_seconds >= 20:
|
||
return True, (
|
||
f"检测指令线程未挂载新任务 {incoming_job_code},"
|
||
f"当前任务上下文为 {current_job_code or 'empty'},且已空转 {int(idle_seconds)} 秒"
|
||
)
|
||
if multi_thread_force_recover:
|
||
return True, (
|
||
f"检测指令线程已运行 {int(thread_age_seconds)} 秒,最近 {int(result_idle_seconds)} 秒无域名产出,"
|
||
f"且新任务 {incoming_job_code} 已到达,当前活跃线程 {live_active_threads}"
|
||
)
|
||
if idle_seconds >= 45:
|
||
return True, f"检测指令线程已空转 {int(idle_seconds)} 秒,当前活跃线程 {live_active_threads}"
|
||
if thread_age_seconds >= 30 and idle_seconds >= 20:
|
||
return True, (
|
||
f"检测指令线程已启动 {int(thread_age_seconds)} 秒,但最近 {int(idle_seconds)} 秒没有任何有效检测活动,当前活跃线程 {live_active_threads}"
|
||
)
|
||
return False, ""
|
||
|
||
def _set_active_cycle_context(self, control_payload=None):
|
||
payload = control_payload or {}
|
||
self._last_synced_worker_log = ""
|
||
self.current_cycle_token = str(payload.get("cycle_token") or "").strip()
|
||
job_id = payload.get("job_id")
|
||
if job_id in (None, "", 0, "0"):
|
||
job_id = payload.get("target_job_id")
|
||
try:
|
||
self.current_job_id = int(job_id) if job_id not in (None, "", 0, "0") else None
|
||
except Exception:
|
||
self.current_job_id = None
|
||
job_code = str(payload.get("job_code") or "").strip()
|
||
if not job_code:
|
||
job_code = str(payload.get("target_job_code") or "").strip()
|
||
self.current_job_code = job_code
|
||
task_mode = str(payload.get("task_mode") or payload.get("target_task_mode") or "").strip()
|
||
if not task_mode and str(job_code).startswith("step-"):
|
||
task_mode = "single_step"
|
||
self.current_job_task_mode = task_mode
|
||
|
||
def _clear_active_cycle_context(self):
|
||
self._last_synced_worker_log = ""
|
||
self.current_cycle_token = ""
|
||
self.current_job_id = None
|
||
self.current_job_code = ""
|
||
self.current_job_task_mode = ""
|
||
self.detect_command_started_at = 0.0
|
||
self.detect_command_last_activity_at = 0.0
|
||
self._last_domain_started_at = 0.0
|
||
self._last_domain_result_at = 0.0
|
||
|
||
def _is_single_step_session_active(self):
|
||
task_mode = str(getattr(self, "current_job_task_mode", "") or "").strip()
|
||
if task_mode == "single_step":
|
||
return True
|
||
return str(getattr(self, "current_job_code", "") or "").strip().startswith("step-")
|
||
|
||
def _should_scope_claims_to_current_job(self):
|
||
"""
|
||
single_step 会话必须严格绑定当前 job;
|
||
domain_pipeline 会话则应持续从全局队列补位,避免旧 job 占住 worker、
|
||
新 job 长时间堆在 pending。
|
||
"""
|
||
return self._is_single_step_session_active()
|
||
|
||
def start_detection_async(self, source: str = "remote", control_payload=None):
|
||
"""
|
||
异步启动一次检测任务,避免阻塞 Redis 订阅线程。
|
||
"""
|
||
with self.detect_lock:
|
||
if self.detect_command_thread and self.detect_command_thread.is_alive():
|
||
stale_detect, stale_reason = self._is_stale_detect_session(control_payload)
|
||
if not stale_detect:
|
||
incoming_job_code = self._incoming_job_code(control_payload)
|
||
current_job_code = str(self.current_job_code or "").strip()
|
||
incoming_task_mode = str(
|
||
(control_payload or {}).get("task_mode")
|
||
or (control_payload or {}).get("target_task_mode")
|
||
or ""
|
||
).strip()
|
||
if (
|
||
incoming_task_mode != "single_step"
|
||
and not self._is_single_step_session_active()
|
||
and incoming_job_code
|
||
and incoming_job_code != current_job_code
|
||
):
|
||
message = (
|
||
"检测任务已在运行,已接收新的 pipeline 唤醒指令,"
|
||
f"继续由当前 worker pool 补位处理: current={current_job_code or 'none'} -> incoming={incoming_job_code}"
|
||
)
|
||
logger.info(message)
|
||
self._update_runtime_state("running", message)
|
||
return True
|
||
logger.warning("收到启动检测指令,但检测任务已在运行,忽略重复启动")
|
||
self._update_runtime_state("running", "检测任务已在运行,忽略重复启动")
|
||
return False
|
||
logger.warning(f"检测任务线程疑似卡死,准备回收后重新启动: {stale_reason}")
|
||
self.stop_requested = True
|
||
recovery_deadline = time.time() + 8
|
||
while self.detect_command_thread and self.detect_command_thread.is_alive() and time.time() < recovery_deadline:
|
||
time.sleep(0.2)
|
||
if self.detect_command_thread and self.detect_command_thread.is_alive():
|
||
if self._should_force_takeover_stale_session(stale_reason):
|
||
logger.warning(f"检测任务线程超时未退出,执行强制接管: {stale_reason}")
|
||
self.detect_command_thread = None
|
||
self.detecting = False
|
||
self._clear_active_cycle_context()
|
||
self._update_runtime_state("restarting", f"检测线程疑似卡死,执行强制接管: {stale_reason}")
|
||
else:
|
||
self._schedule_pending_restart(source, control_payload, stale_reason)
|
||
self._update_runtime_state("restarting", f"检测线程疑似卡死,已登记延期重启: {stale_reason}")
|
||
return True
|
||
|
||
self.stop_requested = False
|
||
self.detect_command_started_at = time.time()
|
||
self.detect_command_last_activity_at = self.detect_command_started_at
|
||
self._detect_session_seq = int(self._detect_session_seq or 0) + 1
|
||
session_id = int(self._detect_session_seq or 0)
|
||
self._detect_session_owner = session_id
|
||
self.detect_command_thread = threading.Thread(
|
||
target=self._run_detection_session,
|
||
kwargs={
|
||
"source": source,
|
||
"control_payload": dict(control_payload or {}),
|
||
"session_id": session_id,
|
||
},
|
||
name="RemoteDetectCommandThread",
|
||
daemon=True,
|
||
)
|
||
self.detect_command_thread.start()
|
||
logger.info(f"已接受检测启动指令,来源: {source}")
|
||
self._set_active_cycle_context(control_payload)
|
||
self._update_runtime_state(
|
||
"starting",
|
||
f"已接受检测启动指令,来源: {source}",
|
||
source=source,
|
||
cycle_token=self.current_cycle_token,
|
||
job_id=self.current_job_id,
|
||
job_code=self.current_job_code,
|
||
)
|
||
return True
|
||
|
||
def _run_detection_session(self, source: str = "remote", control_payload=None, session_id: int = 0):
|
||
self._set_active_cycle_context(control_payload)
|
||
with self.detect_lock:
|
||
current_owner = int(getattr(self, "_detect_session_owner", 0) or 0)
|
||
if session_id and current_owner and session_id != current_owner:
|
||
logger.warning(
|
||
f"检测任务会话启动时发现 owner 已切换,忽略旧会话: session_id={session_id}, current_owner={current_owner}"
|
||
)
|
||
return
|
||
self.detecting = True
|
||
try:
|
||
logger.info(f"开始执行远程检测任务,来源: {source}")
|
||
self._sync_worker_log_event(f"开始执行检测任务,来源: {source}", payload={"source": source}, mode='key')
|
||
self._update_runtime_state(
|
||
"starting",
|
||
f"开始执行检测任务,来源: {source}",
|
||
source=source,
|
||
cycle_token=self.current_cycle_token,
|
||
job_id=self.current_job_id,
|
||
job_code=self.current_job_code,
|
||
)
|
||
self.start_detection()
|
||
except Exception as e:
|
||
logger.error(f"远程检测任务执行失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
self._update_runtime_state(
|
||
"failed",
|
||
f"检测任务执行失败: {e}",
|
||
source=source,
|
||
cycle_token=self.current_cycle_token,
|
||
job_id=self.current_job_id,
|
||
job_code=self.current_job_code,
|
||
)
|
||
finally:
|
||
current_thread = threading.current_thread()
|
||
with self.detect_lock:
|
||
current_owner = int(getattr(self, "_detect_session_owner", 0) or 0)
|
||
is_owner = not session_id or current_owner in {0, session_id}
|
||
if is_owner:
|
||
self.detecting = False
|
||
if session_id:
|
||
self._detect_session_owner = 0
|
||
if self.detect_command_thread is current_thread or not (
|
||
self.detect_command_thread and self.detect_command_thread.is_alive()
|
||
):
|
||
self.detect_command_thread = None
|
||
if not is_owner:
|
||
logger.warning(
|
||
f"检测任务旧会话结束,忽略清理: session_id={session_id}, current_owner={current_owner}"
|
||
)
|
||
return
|
||
restarted_from_pending = False
|
||
if self.running:
|
||
try:
|
||
restarted_from_pending = bool(
|
||
self._consume_pending_restart_request(trigger_reason="session_finalize")
|
||
)
|
||
except Exception as restart_error:
|
||
logger.warning(f"检测任务延期重启触发失败: {restart_error}")
|
||
if self.running and not self.stop_requested and not restarted_from_pending:
|
||
self._update_runtime_state(
|
||
"idle",
|
||
"检测任务结束,Worker 保持待命",
|
||
source=source,
|
||
cycle_token=self.current_cycle_token,
|
||
job_id=self.current_job_id,
|
||
job_code=self.current_job_code,
|
||
)
|
||
self._clear_active_cycle_context()
|
||
|
||
def request_stop_detection(self, source: str = "remote"):
|
||
"""
|
||
请求停止当前检测任务,但保留 Worker 进程存活。
|
||
"""
|
||
if not self.detecting and not (self.detect_command_thread and self.detect_command_thread.is_alive()):
|
||
logger.info("收到停止检测指令,但当前没有运行中的检测任务")
|
||
self._update_runtime_state("idle", "当前没有运行中的检测任务")
|
||
return False
|
||
logger.info(f"收到停止检测指令,来源: {source}")
|
||
self._sync_worker_log_event(f"收到停止检测指令,来源: {source}", payload={"source": source}, mode='key')
|
||
self.stop_requested = True
|
||
self._update_runtime_state(
|
||
"stopping",
|
||
f"收到停止检测指令,等待当前批次安全退出,来源: {source}",
|
||
cycle_token=self.current_cycle_token,
|
||
job_id=self.current_job_id,
|
||
job_code=self.current_job_code,
|
||
)
|
||
return True
|
||
|
||
def _acknowledge_pending_control_command(self, control_payload):
|
||
if not self.use_redis or self.redis_client is None:
|
||
return
|
||
request_id = str((control_payload or {}).get("request_id", "")).strip()
|
||
if not request_id:
|
||
return
|
||
try:
|
||
raw_pending = self.redis_client.get(PENDING_CONTROL_KEY)
|
||
if not raw_pending:
|
||
return
|
||
pending_payload = json.loads(raw_pending)
|
||
pending_request_id = str((pending_payload or {}).get("request_id", "")).strip()
|
||
if pending_request_id and pending_request_id == request_id:
|
||
self.redis_client.delete(PENDING_CONTROL_KEY)
|
||
except Exception as e:
|
||
logger.debug(f"确认待执行控制指令失败: {e}")
|
||
|
||
def _handle_control_message(self, payload):
|
||
try:
|
||
control_payload = json.loads(payload) if isinstance(payload, str) else payload
|
||
except Exception:
|
||
control_payload = {"action": str(payload)}
|
||
self._acknowledge_pending_control_command(control_payload)
|
||
action = str(control_payload.get("action", "")).strip()
|
||
if action == "start_detection":
|
||
self.start_detection_async(source="redis-control", control_payload=control_payload)
|
||
elif action == "stop_detection":
|
||
self.request_stop_detection(source="redis-control")
|
||
elif action:
|
||
logger.warning(f"收到未知 Worker 控制动作: {action}")
|
||
|
||
def connect_signals(self):
|
||
"""
|
||
连接信号到槽函数
|
||
"""
|
||
if self.main_window:
|
||
try:
|
||
# 连接信号到槽函数
|
||
self.config_signal.config_updated.connect(self.main_window.update_config_labels)
|
||
logger.debug("信号连接成功")
|
||
# 连接后立即更新一次配置
|
||
self.update_config_labels()
|
||
except Exception as e:
|
||
logger.error(f"连接信号失败: {e}")
|
||
|
||
def _init_optional_detectors(self):
|
||
if self.detect_options.get('detect_jucha') and self.jc_instance is None:
|
||
self.jc_instance = jucha.JC(proxies=None)
|
||
self.jc_instance.load_juming_cookies()
|
||
self.jc_instance.load_cookies()
|
||
if self.detect_options.get('detect_juziseo') and self.juziseo_instance is None:
|
||
self.juziseo_instance = juziseo.Juziseo(proxies=None)
|
||
self.juziseo_instance.load_cookies()
|
||
|
||
def load_detect_options(self):
|
||
"""
|
||
加载检测选项
|
||
"""
|
||
default_order = [
|
||
'detect_register',
|
||
'detect_baidu_site',
|
||
'detect_360_site',
|
||
'detect_chinaz',
|
||
'detect_aizhan',
|
||
'detect_wayback',
|
||
'detect_jucha',
|
||
'detect_juziseo',
|
||
]
|
||
default_options = {
|
||
'detect_register': True,
|
||
'detect_wayback': True,
|
||
'detect_chinaz': True,
|
||
'detect_aizhan': True,
|
||
'detect_baidu_site': True,
|
||
'detect_360_site': True,
|
||
'detect_jucha': False,
|
||
'detect_juziseo': False,
|
||
'detect_order': default_order,
|
||
}
|
||
try:
|
||
# 从Redis获取配置
|
||
if self.use_redis:
|
||
detect_options_str = self.redis_client.get('domain_tool:detect_options')
|
||
if detect_options_str:
|
||
detect_options = default_options.copy()
|
||
detect_options.update(json.loads(detect_options_str))
|
||
if detect_options.get('detect_whois') or detect_options.get('detect_beian') or detect_options.get('detect_intercept'):
|
||
detect_options['detect_jucha'] = True
|
||
if detect_options.get('detect_juziseo_outlink'):
|
||
detect_options['detect_juziseo'] = True
|
||
order = detect_options.get('detect_order') or []
|
||
detect_options['detect_order'] = [key for key in order if key in default_order]
|
||
for key in default_order:
|
||
if key not in detect_options['detect_order']:
|
||
detect_options['detect_order'].append(key)
|
||
logger.info(f"从Redis加载检测选项成功: {detect_options}")
|
||
return detect_options
|
||
|
||
# 从本地文件获取配置
|
||
if os.path.exists('detect_options.json'):
|
||
with open('detect_options.json', 'r', encoding='utf-8') as f:
|
||
detect_options = default_options.copy()
|
||
detect_options.update(json.load(f))
|
||
if detect_options.get('detect_whois') or detect_options.get('detect_beian') or detect_options.get('detect_intercept'):
|
||
detect_options['detect_jucha'] = True
|
||
if detect_options.get('detect_juziseo_outlink'):
|
||
detect_options['detect_juziseo'] = True
|
||
order = detect_options.get('detect_order') or []
|
||
detect_options['detect_order'] = [key for key in order if key in default_order]
|
||
for key in default_order:
|
||
if key not in detect_options['detect_order']:
|
||
detect_options['detect_order'].append(key)
|
||
logger.info(f"从本地文件加载检测选项成功: {detect_options}")
|
||
return detect_options
|
||
else:
|
||
logger.info(f"使用默认检测选项: {default_options}")
|
||
return default_options
|
||
except Exception as e:
|
||
logger.error(f"加载检测选项失败: {e}")
|
||
logger.info(f"使用默认检测选项: {default_options}")
|
||
return default_options
|
||
|
||
def load_proxy_config(self):
|
||
"""
|
||
加载代理配置
|
||
"""
|
||
default_config = {'proxy_enable': False, 'proxy_url': '', 'proxy_urls': [], 'allow_direct': False}
|
||
try:
|
||
# 从Redis获取配置
|
||
if self.use_redis:
|
||
proxy_config_str = self.redis_client.get('domain_tool:proxy_config')
|
||
if proxy_config_str:
|
||
proxy_config = default_config.copy()
|
||
proxy_config.update(json.loads(proxy_config_str))
|
||
proxy_urls = proxy_config.get('proxy_urls') or []
|
||
if not proxy_urls and proxy_config.get('proxy_url'):
|
||
proxy_urls = [proxy_config.get('proxy_url', '')]
|
||
proxy_config['proxy_urls'] = [url.strip() for url in proxy_urls if url and str(url).strip()]
|
||
proxy_config['proxy_url'] = proxy_config['proxy_urls'][0] if proxy_config['proxy_urls'] else ''
|
||
proxy_config['allow_direct'] = bool(proxy_config.get('allow_direct', False))
|
||
logger.info(f"从Redis加载代理配置成功: {proxy_config}")
|
||
return proxy_config
|
||
|
||
# 从本地文件获取配置
|
||
if os.path.exists('proxy_config.json'):
|
||
with open('proxy_config.json', 'r', encoding='utf-8') as f:
|
||
proxy_config = default_config.copy()
|
||
proxy_config.update(json.load(f))
|
||
logger.info(f"从本地文件加载代理配置成功: {proxy_config}")
|
||
proxy_urls = proxy_config.get('proxy_urls') or []
|
||
if not proxy_urls and proxy_config.get('proxy_url'):
|
||
proxy_urls = [proxy_config.get('proxy_url', '')]
|
||
proxy_config['proxy_urls'] = [url.strip() for url in proxy_urls if url and str(url).strip()]
|
||
proxy_config['proxy_url'] = proxy_config['proxy_urls'][0] if proxy_config['proxy_urls'] else ''
|
||
proxy_config['allow_direct'] = bool(proxy_config.get('allow_direct', False))
|
||
return proxy_config
|
||
else:
|
||
logger.info(f"使用默认代理配置: {default_config}")
|
||
return default_config
|
||
except Exception as e:
|
||
logger.error(f"加载代理配置失败: {e}")
|
||
logger.info(f"使用默认代理配置: {default_config}")
|
||
return default_config
|
||
|
||
def load_thread_count(self):
|
||
"""
|
||
加载检测线程数
|
||
"""
|
||
try:
|
||
node_code = str(getattr(config, "NODE_CODE", "") or "").strip()
|
||
node_thread_counts = {}
|
||
redis_global_thread_count = None
|
||
|
||
def _warn_missing_node_override(source_name, overrides, fallback_value):
|
||
if not node_code or not isinstance(overrides, dict) or not overrides:
|
||
return
|
||
if node_code in overrides:
|
||
return
|
||
available_codes = ", ".join(sorted(str(code) for code in overrides.keys() if str(code).strip()))
|
||
logger.warning(
|
||
"节点线程覆盖未命中,回退到%s通用线程数: node_code=%s, fallback=%s, available_node_codes=%s"
|
||
% (
|
||
source_name,
|
||
node_code,
|
||
fallback_value,
|
||
available_codes or "-",
|
||
)
|
||
)
|
||
|
||
# 从Redis获取配置
|
||
if self.use_redis:
|
||
node_thread_counts_raw = self.redis_client.get('domain_tool:node_thread_counts')
|
||
if node_thread_counts_raw:
|
||
try:
|
||
node_thread_counts = json.loads(node_thread_counts_raw)
|
||
except Exception as e:
|
||
logger.warning(f"解析 Redis 节点线程覆盖配置失败: {e}")
|
||
|
||
if node_code and isinstance(node_thread_counts, dict):
|
||
node_thread_count_str = node_thread_counts.get(node_code)
|
||
if node_thread_count_str is not None:
|
||
thread_count = max(1, int(node_thread_count_str))
|
||
logger.info(f"从Redis加载节点专属检测线程数成功: {node_code} -> {thread_count}")
|
||
return thread_count
|
||
|
||
thread_count_str = self.redis_client.get('domain_tool:thread_count')
|
||
if thread_count_str:
|
||
thread_count = max(1, int(thread_count_str))
|
||
redis_global_thread_count = thread_count
|
||
_warn_missing_node_override("Redis", node_thread_counts, thread_count)
|
||
logger.info(f"从Redis加载检测线程数成功: {thread_count}")
|
||
return thread_count
|
||
|
||
# 从本地文件获取配置
|
||
if os.path.exists('node_thread_counts.json'):
|
||
with open('node_thread_counts.json', 'r', encoding='utf-8') as f:
|
||
node_thread_counts = json.load(f)
|
||
if node_code and isinstance(node_thread_counts, dict):
|
||
node_thread_count = node_thread_counts.get(node_code)
|
||
if node_thread_count is not None:
|
||
thread_count = max(1, int(node_thread_count))
|
||
logger.info(f"从本地文件加载节点专属检测线程数成功: {node_code} -> {thread_count}")
|
||
return thread_count
|
||
|
||
if os.path.exists('thread_count.json'):
|
||
with open('thread_count.json', 'r', encoding='utf-8') as f:
|
||
thread_config = json.load(f)
|
||
thread_count = thread_config.get('thread_count', '10')
|
||
thread_count = max(1, int(thread_count))
|
||
if redis_global_thread_count is None:
|
||
_warn_missing_node_override("本地文件", node_thread_counts, thread_count)
|
||
logger.info(f"从本地文件加载检测线程数成功: {thread_count}")
|
||
return thread_count
|
||
else:
|
||
default_thread_count = 4 # 默认线程数,降低线程数量以减轻系统负担
|
||
logger.info(f"使用默认检测线程数: {default_thread_count}")
|
||
return default_thread_count
|
||
except Exception as e:
|
||
logger.error(f"加载检测线程数失败: {e}")
|
||
default_thread_count = 4 # 默认线程数,降低线程数量以减轻系统负担
|
||
logger.info(f"使用默认检测线程数: {default_thread_count}")
|
||
return default_thread_count
|
||
|
||
def refresh_thread_count_runtime(self, *, force=False, min_interval=2.0):
|
||
"""
|
||
运行中热刷新线程数配置。
|
||
|
||
Web 后台改了节点并发后,不应该等整批任务跑完才生效。
|
||
"""
|
||
now_ts = time.time()
|
||
last_refresh_at = float(getattr(self, "_last_thread_count_refresh_at", 0.0) or 0.0)
|
||
if not force and now_ts - last_refresh_at < max(0.2, float(min_interval or 0.0)):
|
||
return max(1, int(getattr(self, "thread_count", 1) or 1))
|
||
|
||
previous = max(1, int(getattr(self, "thread_count", 1) or 1))
|
||
latest = max(1, int(self.load_thread_count() or previous))
|
||
self._last_thread_count_refresh_at = now_ts
|
||
if latest != previous:
|
||
self.thread_count = latest
|
||
self.proxy_max_reuse_count = max(8, self.thread_count * 4)
|
||
logger.info(f"检测线程数热更新: {previous} -> {latest}")
|
||
self.update_config_labels()
|
||
return max(1, int(getattr(self, "thread_count", latest) or latest))
|
||
|
||
def _pull_sync_tasks_until_available(self, *, thread_limit: int) -> list[dict]:
|
||
"""
|
||
mainland-controller 在本地任务吃空时,不要立即退出检测循环,而是主动向海外控制面
|
||
连续补货几轮,尽量把本地队列重新喂满。
|
||
"""
|
||
if str(getattr(config, "NODE_REGION", "") or "").strip() != "mainland":
|
||
return []
|
||
if str(getattr(config, "NODE_ROLE", "") or "").strip() != "control":
|
||
return []
|
||
|
||
max_rounds = max(1, int(os.getenv("DOMAINCHECK_SYNC_PULL_BURST_ROUNDS", "4") or 4))
|
||
current_thread_limit = max(1, int(thread_limit or getattr(self, "thread_count", 1) or 1))
|
||
configured_pull_limit = int(os.getenv("DOMAINCHECK_SYNC_PULL_LIMIT", "0") or 0)
|
||
pull_limit = max(
|
||
1000,
|
||
configured_pull_limit if configured_pull_limit > 0 else min(20000, current_thread_limit * 4),
|
||
)
|
||
claim_batch_size = max(20, current_thread_limit)
|
||
claim_lease_seconds = max(300, min(1800, claim_batch_size * 30))
|
||
local_api_base_url = str(
|
||
os.getenv("DOMAINCHECK_LOCAL_API_BASE_URL", "http://127.0.0.1:8100/api/v1") or "http://127.0.0.1:8100/api/v1"
|
||
).strip().rstrip("/")
|
||
|
||
for round_index in range(1, max_rounds + 1):
|
||
try:
|
||
request = urllib.request.Request(
|
||
f"{local_api_base_url}/runtime/actions/pull_tasks",
|
||
data=json.dumps({"limit": pull_limit}, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(request, timeout=max(15, min(60, 10 + pull_limit // 100))) as response:
|
||
raw = response.read().decode("utf-8")
|
||
response_data = json.loads(raw) if raw else {}
|
||
pull_ok = int(response_data.get("code", 1) or 1) == 0
|
||
pull_message = str(response_data.get("message") or "").strip()
|
||
pull_data = response_data.get("data") or {}
|
||
except Exception as exc:
|
||
logger.warning(f"本地队列补货失败: round={round_index}/{max_rounds}, error={exc}")
|
||
self._sync_worker_log_event(
|
||
"本地队列补货失败",
|
||
level='warning',
|
||
payload={
|
||
"round": round_index,
|
||
"max_rounds": max_rounds,
|
||
"pull_limit": pull_limit,
|
||
"error": str(exc),
|
||
},
|
||
mode='full',
|
||
)
|
||
break
|
||
|
||
pull_state = str((pull_data or {}).get("pull_state") or "").strip()
|
||
queued_count = int((pull_data or {}).get("queued_count", 0) or 0)
|
||
worker_start_message = str((pull_data or {}).get("worker_start_message") or "").strip()
|
||
refill_message = (
|
||
f"本地队列主动补货: round={round_index}/{max_rounds} | "
|
||
f"pull_limit={pull_limit} | ok={1 if pull_ok else 0} | "
|
||
f"state={pull_state or 'unknown'} | queued_count={queued_count}"
|
||
)
|
||
if worker_start_message:
|
||
refill_message = f"{refill_message} | worker_start={worker_start_message}"
|
||
logger.info(f"{refill_message} | message={pull_message}")
|
||
self._sync_worker_log_event(
|
||
refill_message,
|
||
payload={
|
||
"round": round_index,
|
||
"max_rounds": max_rounds,
|
||
"pull_limit": pull_limit,
|
||
"pull_ok": bool(pull_ok),
|
||
"pull_state": pull_state,
|
||
"queued_count": queued_count,
|
||
"message": str(pull_message or "").strip(),
|
||
"worker_start_message": worker_start_message,
|
||
},
|
||
mode='full',
|
||
)
|
||
|
||
domains = self.db.claim_detect_job_items(
|
||
config.NODE_CODE,
|
||
limit=claim_batch_size,
|
||
lease_seconds=claim_lease_seconds,
|
||
)
|
||
if domains:
|
||
logger.info(
|
||
f"主动补货后已重新领取任务: round={round_index}/{max_rounds}, "
|
||
f"claim_batch_size={claim_batch_size}, domains={len(domains)}"
|
||
)
|
||
return domains
|
||
|
||
if pull_state == "idle":
|
||
break
|
||
if not pull_ok and pull_state not in {"ack_warning", "worker_start_warning"}:
|
||
break
|
||
time.sleep(0.2)
|
||
|
||
return []
|
||
|
||
def _process_pipeline_tasks_until_available(self, *, thread_limit: int) -> list[dict]:
|
||
"""
|
||
当本地任务队列空了时,主动让 controller 处理已完成步骤,尽量把下一步任务补出来。
|
||
"""
|
||
local_api_base_url = str(
|
||
os.getenv("DOMAINCHECK_LOCAL_API_BASE_URL", "http://127.0.0.1:8100/api/v1") or "http://127.0.0.1:8100/api/v1"
|
||
).strip().rstrip("/")
|
||
current_thread_limit = max(1, int(thread_limit or getattr(self, "thread_count", 1) or 1))
|
||
configured_process_limit = int(os.getenv("DOMAINCHECK_PIPELINE_PROCESS_LIMIT", "0") or 0)
|
||
process_limit = max(
|
||
20,
|
||
configured_process_limit if configured_process_limit > 0 else current_thread_limit * 4,
|
||
)
|
||
claim_batch_size = max(20, current_thread_limit)
|
||
claim_lease_seconds = max(300, min(1800, claim_batch_size * 30))
|
||
|
||
try:
|
||
request = urllib.request.Request(
|
||
f"{local_api_base_url}/runtime/actions/process_pipeline",
|
||
data=json.dumps({"limit": process_limit}, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(request, timeout=20) as response:
|
||
raw = response.read().decode("utf-8")
|
||
response_data = json.loads(raw) if raw else {}
|
||
process_ok = int(response_data.get("code", 1) or 1) == 0
|
||
process_message = str(response_data.get("message") or "").strip()
|
||
process_data = response_data.get("data") or {}
|
||
logger.info(
|
||
f"本地 pipeline 推进: ok={1 if process_ok else 0} | "
|
||
f"processed={int(process_data.get('processed_items', 0) or 0)} | "
|
||
f"advanced={int(process_data.get('advanced_items', 0) or 0)} | "
|
||
f"retried={int(process_data.get('retried_items', 0) or 0)} | "
|
||
f"message={process_message}"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(f"本地 pipeline 推进失败: {exc}")
|
||
self._sync_worker_log_event(
|
||
"本地 pipeline 推进失败",
|
||
level='warning',
|
||
payload={"error": str(exc), "process_limit": process_limit},
|
||
mode='full',
|
||
)
|
||
return []
|
||
|
||
domains = self.db.claim_detect_job_items(
|
||
config.NODE_CODE,
|
||
limit=claim_batch_size,
|
||
lease_seconds=claim_lease_seconds,
|
||
)
|
||
if domains:
|
||
self._sync_worker_log_event(
|
||
"本地 pipeline 推进后已重新领取任务",
|
||
payload={"domains": len(domains), "claim_batch_size": claim_batch_size},
|
||
mode='full',
|
||
)
|
||
return domains
|
||
|
||
def load_runtime_settings(self):
|
||
default_settings = {
|
||
"worker_log_sync_enabled": False,
|
||
"worker_log_sync_mode": "key",
|
||
"worker_step_trace_enabled": True,
|
||
"worker_step_trace_sync_full": True,
|
||
}
|
||
try:
|
||
if self.use_redis:
|
||
runtime_settings_raw = self.redis_client.get(RUNTIME_SETTINGS_KEY)
|
||
if runtime_settings_raw:
|
||
runtime_settings = default_settings.copy()
|
||
runtime_settings.update(json.loads(runtime_settings_raw))
|
||
runtime_settings["worker_log_sync_enabled"] = bool(runtime_settings.get("worker_log_sync_enabled", False))
|
||
runtime_settings["worker_log_sync_mode"] = "full" if str(runtime_settings.get("worker_log_sync_mode", "key")).strip().lower() == "full" else "key"
|
||
runtime_settings["worker_step_trace_enabled"] = bool(runtime_settings.get("worker_step_trace_enabled", True))
|
||
runtime_settings["worker_step_trace_sync_full"] = bool(runtime_settings.get("worker_step_trace_sync_full", True))
|
||
logger.info(f"从Redis加载运行时设置成功: {runtime_settings}")
|
||
return runtime_settings
|
||
|
||
candidate_paths = [
|
||
'runtime_settings.json',
|
||
os.path.join('runtime', 'runtime_settings.json'),
|
||
os.path.join('..', 'domain-api', 'runtime', 'runtime_settings.json'),
|
||
]
|
||
for candidate_path in candidate_paths:
|
||
if os.path.exists(candidate_path):
|
||
with open(candidate_path, 'r', encoding='utf-8') as f:
|
||
runtime_settings = default_settings.copy()
|
||
runtime_settings.update(json.load(f))
|
||
runtime_settings["worker_log_sync_enabled"] = bool(runtime_settings.get("worker_log_sync_enabled", False))
|
||
runtime_settings["worker_log_sync_mode"] = "full" if str(runtime_settings.get("worker_log_sync_mode", "key")).strip().lower() == "full" else "key"
|
||
runtime_settings["worker_step_trace_enabled"] = bool(runtime_settings.get("worker_step_trace_enabled", True))
|
||
runtime_settings["worker_step_trace_sync_full"] = bool(runtime_settings.get("worker_step_trace_sync_full", True))
|
||
logger.info(f"从本地文件加载运行时设置成功: {runtime_settings}")
|
||
return runtime_settings
|
||
except Exception as e:
|
||
logger.error(f"加载运行时设置失败: {e}")
|
||
return default_settings
|
||
|
||
def _load_sensitive_words_runtime(self):
|
||
try:
|
||
if self.use_redis and self.redis_client is not None:
|
||
sensitive_words_raw = self.redis_client.get("domain_tool:sensitive_words")
|
||
if sensitive_words_raw is not None:
|
||
try:
|
||
parsed = json.loads(sensitive_words_raw)
|
||
except Exception:
|
||
parsed = sensitive_words_raw
|
||
if isinstance(parsed, list):
|
||
words = [str(item).strip() for item in parsed if str(item).strip()]
|
||
else:
|
||
words = [line.strip() for line in str(parsed or "").splitlines() if line.strip()]
|
||
logger.info(f"从Redis加载敏感词成功,共 {len(words)} 个敏感词")
|
||
return words
|
||
|
||
candidate_paths = [
|
||
'runtime/sensitive_words.json',
|
||
'sensitive_words.json',
|
||
'sensitive_words.txt',
|
||
]
|
||
for candidate_path in candidate_paths:
|
||
if not os.path.exists(candidate_path):
|
||
continue
|
||
if candidate_path.endswith('.json'):
|
||
with open(candidate_path, 'r', encoding='utf-8') as f:
|
||
payload = json.load(f)
|
||
if isinstance(payload, dict):
|
||
if isinstance(payload.get('items'), list):
|
||
words = []
|
||
for item in payload.get('items') or []:
|
||
if isinstance(item, dict):
|
||
word = str(item.get('word') or '').strip()
|
||
else:
|
||
word = str(item).strip()
|
||
if word:
|
||
words.append(word)
|
||
else:
|
||
words = [line.strip() for line in str(payload.get('text') or '').splitlines() if line.strip()]
|
||
elif isinstance(payload, list):
|
||
words = [str(item).strip() for item in payload if str(item).strip()]
|
||
else:
|
||
words = []
|
||
else:
|
||
with open(candidate_path, 'r', encoding='utf-8') as f:
|
||
words = [line.strip() for line in f.read().splitlines() if line.strip()]
|
||
logger.info(f"从本地文件加载敏感词成功: {candidate_path},共 {len(words)} 个敏感词")
|
||
return words
|
||
except Exception as e:
|
||
logger.error(f"从运行时配置加载敏感词失败: {e}")
|
||
|
||
words = self.db.get_all_sensitive_words()
|
||
logger.info(f"从数据库加载敏感词成功,共 {len(words)} 个敏感词")
|
||
return words
|
||
|
||
@staticmethod
|
||
def _remote_debug_ingest_url():
|
||
base_url = str(os.getenv("SYNC_TARGET_API_BASE_URL", "") or "").strip().rstrip("/")
|
||
if not base_url:
|
||
return ""
|
||
if base_url.endswith("/api/v1"):
|
||
return f"{base_url}/runtime/debug-ingest"
|
||
if base_url.endswith("/api/v1/runtime"):
|
||
return f"{base_url}/debug-ingest"
|
||
return f"{base_url}/api/v1/runtime/debug-ingest"
|
||
|
||
def _push_remote_debug_event(self, *, message, level='info', payload=None, event_type='worker_log'):
|
||
ingest_url = self._remote_debug_ingest_url()
|
||
if not ingest_url:
|
||
return
|
||
request_payload = {
|
||
"source_region": config.NODE_REGION,
|
||
"node_code": config.NODE_CODE,
|
||
"hostname": socket.gethostname(),
|
||
"service": "worker-event",
|
||
"event_type": str(event_type or "worker_log"),
|
||
"level": str(level or "info"),
|
||
"message": str(message or "").strip()[:2000],
|
||
"payload": payload or {},
|
||
}
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
}
|
||
shared_token = str(os.getenv("SYNC_SHARED_TOKEN", "") or "").strip()
|
||
if shared_token:
|
||
headers["X-Domaincheck-Sync-Token"] = shared_token
|
||
request = urllib.request.Request(
|
||
ingest_url,
|
||
data=json.dumps(request_payload, ensure_ascii=False, sort_keys=True).encode("utf-8"),
|
||
headers=headers,
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=REMOTE_DEBUG_EVENT_TIMEOUT) as response:
|
||
raw = response.read().decode("utf-8", errors="ignore")
|
||
if raw:
|
||
parsed = json.loads(raw)
|
||
if isinstance(parsed, dict) and parsed.get("code") not in (0, "0", None, ""):
|
||
logger.debug(f"远端调试事件返回非成功: {parsed}")
|
||
except Exception as e:
|
||
logger.debug(f"远端调试事件回传失败: {e}")
|
||
|
||
def _worker_log_sync_mode(self):
|
||
if not bool((self.runtime_settings or {}).get("worker_log_sync_enabled", False)):
|
||
return "off"
|
||
return "full" if str((self.runtime_settings or {}).get("worker_log_sync_mode", "key")).strip().lower() == "full" else "key"
|
||
|
||
def _worker_step_trace_enabled(self):
|
||
return bool((self.runtime_settings or {}).get("worker_step_trace_enabled", True))
|
||
|
||
def _worker_step_trace_sync_full_enabled(self):
|
||
return bool((self.runtime_settings or {}).get("worker_step_trace_sync_full", True))
|
||
|
||
@staticmethod
|
||
def _single_step_cache_key(domain_id, field_name):
|
||
return f"domain_tool:single_step_result:{int(domain_id or 0)}:{str(field_name or '').strip()}"
|
||
|
||
def _set_current_task_context(self, *, domain_id, is_step_task, task_mode, job_item_id=None):
|
||
self._task_local.domain_id = int(domain_id or 0)
|
||
self._task_local.is_step_task = bool(is_step_task)
|
||
self._task_local.task_mode = str(task_mode or "").strip()
|
||
self._task_local.job_item_id = int(job_item_id or 0) if job_item_id else None
|
||
self._task_local.detect_status = None
|
||
self._task_local.step_proxy_retry_waits = {}
|
||
self._task_local.step_force_direct_once = {}
|
||
|
||
def _clear_current_task_context(self):
|
||
for attr in ("domain_id", "is_step_task", "task_mode", "job_item_id", "detect_status", "step_proxy_retry_waits", "step_force_direct_once"):
|
||
try:
|
||
delattr(self._task_local, attr)
|
||
except Exception:
|
||
pass
|
||
|
||
def _is_current_single_step_task(self):
|
||
return bool(getattr(self._task_local, "is_step_task", False))
|
||
|
||
def _set_task_detect_status(self, status):
|
||
try:
|
||
normalized_status = int(status)
|
||
except Exception:
|
||
normalized_status = None
|
||
self._task_local.detect_status = normalized_status
|
||
|
||
def _get_task_detect_status(self):
|
||
try:
|
||
value = getattr(self._task_local, "detect_status", None)
|
||
return int(value) if value is not None else None
|
||
except Exception:
|
||
return None
|
||
|
||
def _resolve_current_task_final_status(self):
|
||
current_status = self._get_task_detect_status()
|
||
if current_status == DETECT_STATUS_BLACKLISTED:
|
||
return 'blacklisted'
|
||
return 'failed'
|
||
|
||
def _set_step_proxy_retry_wait(self, step_name, wait_seconds):
|
||
normalized_step_name = str(step_name or "").strip()
|
||
if not normalized_step_name:
|
||
return
|
||
try:
|
||
normalized_wait_seconds = max(0.0, float(wait_seconds or 0.0))
|
||
except Exception:
|
||
normalized_wait_seconds = 0.0
|
||
retry_waits = getattr(self._task_local, "step_proxy_retry_waits", None)
|
||
if not isinstance(retry_waits, dict):
|
||
retry_waits = {}
|
||
self._task_local.step_proxy_retry_waits = retry_waits
|
||
retry_waits[normalized_step_name] = normalized_wait_seconds
|
||
|
||
def _consume_step_proxy_retry_wait(self, step_name):
|
||
normalized_step_name = str(step_name or "").strip()
|
||
if not normalized_step_name:
|
||
return 0.0
|
||
retry_waits = getattr(self._task_local, "step_proxy_retry_waits", None)
|
||
if not isinstance(retry_waits, dict):
|
||
return 0.0
|
||
try:
|
||
return max(0.0, float(retry_waits.pop(normalized_step_name, 0.0) or 0.0))
|
||
except Exception:
|
||
return 0.0
|
||
|
||
def _set_step_force_direct_once(self, step_name, enabled=True):
|
||
normalized_step_name = str(step_name or "").strip()
|
||
if not normalized_step_name:
|
||
return
|
||
force_map = getattr(self._task_local, "step_force_direct_once", None)
|
||
if not isinstance(force_map, dict):
|
||
force_map = {}
|
||
self._task_local.step_force_direct_once = force_map
|
||
force_map[normalized_step_name] = bool(enabled)
|
||
|
||
def _consume_step_force_direct_once(self, step_name):
|
||
normalized_step_name = str(step_name or "").strip()
|
||
if not normalized_step_name:
|
||
return False
|
||
force_map = getattr(self._task_local, "step_force_direct_once", None)
|
||
if not isinstance(force_map, dict):
|
||
return False
|
||
return bool(force_map.pop(normalized_step_name, False))
|
||
|
||
def _cache_single_step_result_payload(self, domain_id, field_name, payload):
|
||
normalized_field_name = str(field_name or "").strip()
|
||
if not normalized_field_name:
|
||
return
|
||
cache_key = self._single_step_cache_key(domain_id, normalized_field_name)
|
||
with self._step_result_cache_lock:
|
||
self._step_result_cache[(int(domain_id or 0), normalized_field_name)] = dict(payload or {})
|
||
if self.use_redis and self.redis_client is not None:
|
||
try:
|
||
self.redis_client.setex(cache_key, 600, json.dumps(payload or {}, ensure_ascii=False))
|
||
except Exception as e:
|
||
logger.debug(f"写入 Redis single_step 结果缓存失败: {e}")
|
||
|
||
def _load_cached_single_step_result_payload(self, domain_id, field_name):
|
||
normalized_field_name = str(field_name or "").strip()
|
||
if not normalized_field_name:
|
||
return None
|
||
cache_tuple_key = (int(domain_id or 0), normalized_field_name)
|
||
with self._step_result_cache_lock:
|
||
cached_payload = self._step_result_cache.pop(cache_tuple_key, None)
|
||
if isinstance(cached_payload, dict) and cached_payload:
|
||
return dict(cached_payload)
|
||
if self.use_redis and self.redis_client is not None:
|
||
try:
|
||
redis_key = self._single_step_cache_key(domain_id, normalized_field_name)
|
||
raw_payload = self.redis_client.get(redis_key)
|
||
if raw_payload:
|
||
self.redis_client.delete(redis_key)
|
||
parsed = json.loads(raw_payload)
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
except Exception as e:
|
||
logger.debug(f"读取 Redis single_step 结果缓存失败: {e}")
|
||
return None
|
||
|
||
def _sync_worker_log_event(self, message, level='info', payload=None, *, mode='key', job_item_id=None):
|
||
current_mode = self._worker_log_sync_mode()
|
||
if current_mode == "off":
|
||
return
|
||
if mode == 'full' and current_mode != "full":
|
||
return
|
||
normalized_message = str(message or '').strip()
|
||
if not normalized_message:
|
||
return
|
||
if normalized_message == self._last_synced_worker_log and mode != 'full':
|
||
return
|
||
event_payload = dict(payload or {})
|
||
explicit_job_id = event_payload.get("job_id")
|
||
try:
|
||
job_id = int(explicit_job_id) if explicit_job_id not in (None, "", 0, "0") else self.current_job_id
|
||
except Exception:
|
||
job_id = self.current_job_id
|
||
if not job_id:
|
||
return
|
||
if self.current_cycle_token and not event_payload.get("cycle_token"):
|
||
event_payload["cycle_token"] = self.current_cycle_token
|
||
if self.current_job_code and not event_payload.get("job_code"):
|
||
event_payload["job_code"] = self.current_job_code
|
||
event_payload["log_mode"] = mode
|
||
event_payload["synced_by"] = "worker_log_callback"
|
||
try:
|
||
queue_item = {
|
||
"job_id": job_id,
|
||
"job_item_id": job_item_id,
|
||
"message": normalized_message,
|
||
"level": level,
|
||
"payload": event_payload,
|
||
"write_db": not (mode == 'full' and self._is_current_single_step_task()),
|
||
}
|
||
try:
|
||
self._worker_log_sync_queue.put_nowait(queue_item)
|
||
except Full:
|
||
if mode != 'full':
|
||
self._worker_log_sync_queue.put(queue_item, timeout=0.2)
|
||
else:
|
||
raise
|
||
self._last_synced_worker_log = normalized_message
|
||
except Exception as e:
|
||
if mode == 'full':
|
||
self._worker_log_sync_drop_count += 1
|
||
now_ts = time.time()
|
||
if now_ts - self._last_worker_log_sync_drop_notice_at >= 10:
|
||
logger.warning(
|
||
f"Worker 全量日志同步队列拥堵,已丢弃 {self._worker_log_sync_drop_count} 条 full 日志"
|
||
)
|
||
self._last_worker_log_sync_drop_notice_at = now_ts
|
||
else:
|
||
logger.debug(f"回传Worker日志事件失败: {e}")
|
||
|
||
@staticmethod
|
||
def _step_display_name(detect_key):
|
||
mapping = {
|
||
'detect_register': '注册状态检测',
|
||
'detect_baidu_site': '百度site检测',
|
||
'detect_360_site': '360 site检测',
|
||
'detect_chinaz': '站长之家检测',
|
||
'detect_aizhan': '爱站检测',
|
||
'detect_wayback': '时光机检测',
|
||
'detect_jucha': '聚查检测',
|
||
'detect_juziseo': '桔子检测',
|
||
}
|
||
return mapping.get(str(detect_key or '').strip(), str(detect_key or '').strip() or 'unknown')
|
||
|
||
@staticmethod
|
||
def _step_result_field_name(detect_key):
|
||
mapping = {
|
||
'detect_register': 'register_status',
|
||
'detect_baidu_site': 'baidu_site',
|
||
'detect_360_site': 'qihu360_site',
|
||
'detect_chinaz': 'chinaz_info',
|
||
'detect_aizhan': 'aizhan_info',
|
||
'detect_wayback': 'wayback_info',
|
||
}
|
||
return mapping.get(str(detect_key or '').strip(), '')
|
||
|
||
def _build_single_step_job_result(self, *, domain_id, detect_key, step_ok, step_name):
|
||
field_name = self._step_result_field_name(detect_key)
|
||
cached_payload = self._load_cached_single_step_result_payload(domain_id, field_name)
|
||
if isinstance(cached_payload, dict) and cached_payload:
|
||
payload = dict(cached_payload)
|
||
payload["step_code"] = str(detect_key or "").strip()
|
||
payload["step_name"] = str(step_name or "").strip()
|
||
payload["field_name"] = field_name
|
||
payload["task_mode"] = "single_step"
|
||
payload["domain_id"] = int(domain_id or 0)
|
||
if payload.get("state") == "degraded":
|
||
payload["retry_recommended"] = True
|
||
return payload
|
||
try:
|
||
domain_snapshot = self.db.get_domain_by_id(domain_id) or {}
|
||
except Exception:
|
||
domain_snapshot = {}
|
||
raw_payload = domain_snapshot.get(field_name) if field_name else None
|
||
payload = dict(raw_payload) if isinstance(raw_payload, dict) else {}
|
||
if not payload:
|
||
payload = {
|
||
"status": bool(step_ok),
|
||
"state": "passed" if step_ok else "failed",
|
||
"message": "success" if step_ok else f"{step_name} 未通过",
|
||
}
|
||
payload["step_code"] = str(detect_key or "").strip()
|
||
payload["step_name"] = str(step_name or "").strip()
|
||
payload["field_name"] = field_name
|
||
payload["task_mode"] = "single_step"
|
||
payload["domain_id"] = int(domain_id or 0)
|
||
if payload.get("state") == "degraded":
|
||
payload["retry_recommended"] = True
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _resolve_single_step_finalization(result_payload):
|
||
payload = dict(result_payload or {})
|
||
state = str(payload.get("state") or "").strip().lower()
|
||
message = str(payload.get("message") or "").strip()
|
||
status = payload.get("status")
|
||
if state == "blacklisted":
|
||
return "blacklisted", message or "步骤命中黑名单"
|
||
if state == "degraded":
|
||
return "failed", message or "步骤外部依赖异常,建议重试"
|
||
if state in {"failed", "error", "rejected"}:
|
||
return "failed", message or "步骤执行失败"
|
||
if status is False:
|
||
return "failed", message or "步骤执行失败"
|
||
return "completed", message or "步骤执行完成"
|
||
|
||
def _emit_step_trace(self, domain_name, step_name, stage, *, elapsed_ms=None, ok=None, level='info', job_item_id=None, sync_mode=None, **extra):
|
||
if not self._worker_step_trace_enabled():
|
||
return
|
||
payload = {
|
||
"domain": str(domain_name or "").strip(),
|
||
"step": str(step_name or "").strip(),
|
||
"stage": str(stage or "").strip(),
|
||
}
|
||
if elapsed_ms is not None:
|
||
payload["elapsed_ms"] = int(elapsed_ms or 0)
|
||
if ok is not None:
|
||
payload["ok"] = bool(ok)
|
||
for key, value in dict(extra or {}).items():
|
||
if value in (None, ""):
|
||
continue
|
||
payload[key] = value
|
||
|
||
message_parts = [
|
||
f"domain={payload.get('domain') or '-'}",
|
||
f"step={payload.get('step') or '-'}",
|
||
f"stage={payload.get('stage') or '-'}",
|
||
]
|
||
if "elapsed_ms" in payload:
|
||
message_parts.append(f"elapsed_ms={payload['elapsed_ms']}")
|
||
if "ok" in payload:
|
||
message_parts.append(f"ok={1 if payload['ok'] else 0}")
|
||
for key in sorted(payload.keys()):
|
||
if key in {"domain", "step", "stage", "elapsed_ms", "ok"}:
|
||
continue
|
||
value = payload.get(key)
|
||
if isinstance(value, (dict, list, tuple)):
|
||
value = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||
message_parts.append(f"{key}={value}")
|
||
message = "检测步骤跟踪: " + " | ".join(message_parts)
|
||
logger.log(level.upper(), message)
|
||
|
||
should_sync_full = self._worker_step_trace_sync_full_enabled()
|
||
if sync_mode == 'off':
|
||
should_sync_full = False
|
||
if should_sync_full:
|
||
self._sync_worker_log_event(
|
||
message,
|
||
level=level,
|
||
payload=payload,
|
||
mode='full',
|
||
job_item_id=job_item_id,
|
||
)
|
||
|
||
def test_proxy(self, proxy_item, result_queue):
|
||
"""
|
||
测试单个代理的可用性
|
||
|
||
:param proxy_item: 代理信息
|
||
:param result_queue: 结果队列
|
||
"""
|
||
result_payload = {
|
||
"ok": False,
|
||
"proxy": None,
|
||
"reason": "invalid_proxy_item",
|
||
}
|
||
try:
|
||
if 'ip' in proxy_item and 'port' in proxy_item:
|
||
ip = proxy_item['ip']
|
||
port = proxy_item['port']
|
||
username = proxy_item.get('username', '')
|
||
password = proxy_item.get('password', '')
|
||
|
||
if username and password:
|
||
proxy_url = f"http://{username}:{password}@{ip}:{port}"
|
||
else:
|
||
proxy_url = f"http://{ip}:{port}"
|
||
|
||
import requests
|
||
test_proxies = {
|
||
'http': proxy_url,
|
||
'https': proxy_url
|
||
}
|
||
result_payload["proxy"] = test_proxies
|
||
|
||
test_targets = [
|
||
("http://www.baidu.com", 8),
|
||
("https://m.baidu.com", 8),
|
||
("http://www.qq.com", 8),
|
||
("http://www.360.cn", 8),
|
||
]
|
||
last_reason = "unknown"
|
||
session = requests.Session()
|
||
session.headers.update(
|
||
{
|
||
"User-Agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/123.0.0.0 Safari/537.36"
|
||
)
|
||
}
|
||
)
|
||
for target_url, timeout_seconds in test_targets:
|
||
try:
|
||
test_response = session.get(
|
||
target_url,
|
||
proxies=test_proxies,
|
||
timeout=timeout_seconds,
|
||
allow_redirects=True,
|
||
verify=False if target_url.startswith("https://") else True,
|
||
)
|
||
if test_response.status_code in (200, 204, 301, 302, 403):
|
||
result_payload["ok"] = True
|
||
result_payload["reason"] = f"ok:{target_url}:{test_response.status_code}"
|
||
break
|
||
last_reason = f"http_status_{test_response.status_code}@{target_url}"
|
||
except Exception as target_error:
|
||
error_text = str(target_error).strip() or target_error.__class__.__name__
|
||
last_reason = f"{target_error.__class__.__name__}@{target_url}:{error_text[:160]}"
|
||
session.close()
|
||
if not result_payload["ok"]:
|
||
result_payload["reason"] = last_reason
|
||
except Exception as e:
|
||
result_payload["reason"] = f"{e.__class__.__name__}:{str(e)[:160]}"
|
||
finally:
|
||
result_queue.put(result_payload)
|
||
result_queue.put(None)
|
||
|
||
def _extract_proxy_items(self, proxy_data):
|
||
if isinstance(proxy_data, dict):
|
||
if 'list' in proxy_data and isinstance(proxy_data['list'], list):
|
||
return proxy_data['list']
|
||
if 'ip' in proxy_data and 'port' in proxy_data:
|
||
return [proxy_data]
|
||
if isinstance(proxy_data, list):
|
||
return proxy_data
|
||
return []
|
||
|
||
def _proxy_source_tag_from_url(self, raw_url):
|
||
normalized_url = str(raw_url or "").strip()
|
||
if not normalized_url:
|
||
return "unknown"
|
||
try:
|
||
parsed = urllib.parse.urlsplit(normalized_url)
|
||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||
group_value = str((query.get("group") or [""])[0] or "").strip()
|
||
if group_value:
|
||
return group_value.upper()
|
||
if parsed.netloc:
|
||
return parsed.netloc
|
||
except Exception:
|
||
pass
|
||
return normalized_url
|
||
|
||
def _interleave_proxy_entries(self, proxy_entries):
|
||
if not proxy_entries:
|
||
return []
|
||
grouped_entries = collections.OrderedDict()
|
||
for proxy_entry in proxy_entries:
|
||
source_tag = str((proxy_entry or {}).get("source_tag") or "unknown").strip() or "unknown"
|
||
grouped_entries.setdefault(source_tag, collections.deque()).append(proxy_entry)
|
||
merged_entries = []
|
||
while grouped_entries:
|
||
exhausted_tags = []
|
||
for source_tag, items in grouped_entries.items():
|
||
if not items:
|
||
exhausted_tags.append(source_tag)
|
||
continue
|
||
merged_entries.append(items.popleft())
|
||
if not items:
|
||
exhausted_tags.append(source_tag)
|
||
for source_tag in exhausted_tags:
|
||
grouped_entries.pop(source_tag, None)
|
||
return merged_entries
|
||
|
||
def _merge_proxy_entries(self, new_entries, existing_entries):
|
||
merged = []
|
||
seen = set()
|
||
for proxy_entry in list(new_entries or []) + list(existing_entries or []):
|
||
if isinstance(proxy_entry, dict) and proxy_entry.get("proxy"):
|
||
signature = self._proxy_key(proxy_entry.get("proxy"))
|
||
else:
|
||
signature = self._proxy_key(proxy_entry)
|
||
if not signature or signature in seen:
|
||
continue
|
||
seen.add(signature)
|
||
merged.append(proxy_entry)
|
||
if merged and all(isinstance(item, dict) and item.get("proxy") for item in merged):
|
||
return self._interleave_proxy_entries(merged)
|
||
return merged
|
||
|
||
def _proxy_signature_from_item(self, proxy_item):
|
||
if not isinstance(proxy_item, dict):
|
||
return ""
|
||
ip = str(proxy_item.get("ip", "") or "").strip()
|
||
port = str(proxy_item.get("port", "") or "").strip()
|
||
username = str(proxy_item.get("username", "") or "").strip()
|
||
password = str(proxy_item.get("password", "") or "").strip()
|
||
if not ip or not port:
|
||
return ""
|
||
return json.dumps(
|
||
{
|
||
"ip": ip,
|
||
"port": port,
|
||
"username": username,
|
||
"password": password,
|
||
},
|
||
sort_keys=True,
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
def _is_proxy_item_expired(self, proxy_item):
|
||
if not isinstance(proxy_item, dict):
|
||
return True
|
||
now_ts = time.time()
|
||
expire_at_ms = proxy_item.get("expire_at_ms")
|
||
expire_at = proxy_item.get("expire_at")
|
||
try:
|
||
if expire_at_ms not in (None, "", 0, "0"):
|
||
return float(expire_at_ms) / 1000.0 <= now_ts
|
||
if expire_at not in (None, "", 0, "0"):
|
||
return float(expire_at) <= now_ts
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
def _set_proxy_request_count(self, raw_url, count):
|
||
normalized_url = str(raw_url or "").strip()
|
||
if not normalized_url:
|
||
return ""
|
||
try:
|
||
parsed = urllib.parse.urlsplit(normalized_url)
|
||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||
query["count"] = [str(max(1, int(count or 0)))]
|
||
return urllib.parse.urlunsplit(
|
||
(
|
||
parsed.scheme,
|
||
parsed.netloc,
|
||
parsed.path,
|
||
urllib.parse.urlencode(query, doseq=True),
|
||
parsed.fragment,
|
||
)
|
||
)
|
||
except Exception:
|
||
return normalized_url
|
||
|
||
def _prepare_proxy_fetch_plan(self, proxy_api_urls, current_pool_size=0):
|
||
source_urls = [str(url or "").strip() for url in proxy_api_urls if str(url or "").strip()]
|
||
if not source_urls:
|
||
return [], {
|
||
"target_total": 0,
|
||
"batch_size": 0,
|
||
"rounds": 0,
|
||
"source_count": 0,
|
||
}
|
||
|
||
demand_threads = self._proxy_demand_threads()
|
||
source_count = len(source_urls)
|
||
provider_batch_cap = max(20, int(os.getenv("DOMAINCHECK_PROXY_PROVIDER_BATCH_CAP", "140") or 140))
|
||
current_pool_size = max(0, int(current_pool_size or 0))
|
||
refresh_threshold = self._proxy_refresh_threshold()
|
||
shortage = max(0, refresh_threshold - current_pool_size)
|
||
headroom = max(source_count * 20, int(refresh_threshold * 0.15))
|
||
active_round_cap = max(1, int(os.getenv("DOMAINCHECK_PROXY_MAX_ROUNDS_ACTIVE", "2") or 2))
|
||
idle_round_cap = max(2, int(os.getenv("DOMAINCHECK_PROXY_MAX_ROUNDS_IDLE", "4") or 4))
|
||
round_cap = active_round_cap if self.detecting else idle_round_cap
|
||
target_total = max(
|
||
source_count * provider_batch_cap,
|
||
shortage + headroom,
|
||
)
|
||
per_round_capacity = max(1, source_count * provider_batch_cap)
|
||
rounds = max(1, min(round_cap, (target_total + per_round_capacity - 1) // per_round_capacity))
|
||
batch_size = max(
|
||
20,
|
||
min(
|
||
provider_batch_cap,
|
||
(target_total + (source_count * rounds) - 1) // (source_count * rounds),
|
||
),
|
||
)
|
||
fetch_plan = []
|
||
for round_index in range(rounds):
|
||
for source_url in source_urls:
|
||
fetch_plan.append(
|
||
{
|
||
"source_url": source_url,
|
||
"request_url": self._set_proxy_request_count(source_url, batch_size),
|
||
"batch_size": batch_size,
|
||
"round": round_index + 1,
|
||
}
|
||
)
|
||
|
||
return fetch_plan, {
|
||
"target_total": target_total,
|
||
"batch_size": batch_size,
|
||
"rounds": rounds,
|
||
"source_count": source_count,
|
||
"demand_threads": demand_threads,
|
||
"current_pool_size": current_pool_size,
|
||
"refresh_threshold": refresh_threshold,
|
||
"shortage": shortage,
|
||
}
|
||
|
||
def _proxy_source_fetch_timeout_seconds(self):
|
||
default_timeout = "4.0" if self.detecting else "6.0"
|
||
return max(
|
||
1.5,
|
||
float(os.getenv("DOMAINCHECK_PROXY_SOURCE_FETCH_TIMEOUT", default_timeout) or default_timeout),
|
||
)
|
||
|
||
def _proxy_key(self, proxy):
|
||
if isinstance(proxy, dict):
|
||
return json.dumps(proxy, sort_keys=True, ensure_ascii=False)
|
||
return str(proxy)
|
||
|
||
def _is_proxy_quarantined(self, proxy):
|
||
proxy_key = self._proxy_key(proxy)
|
||
now_ts = time.time()
|
||
with self.proxy_failure_lock:
|
||
retry_after = float(self.proxy_quarantine_until.get(proxy_key, 0) or 0)
|
||
if retry_after <= now_ts:
|
||
if proxy_key in self.proxy_quarantine_until:
|
||
self.proxy_quarantine_until.pop(proxy_key, None)
|
||
self.proxy_failure_counts.pop(proxy_key, None)
|
||
return False
|
||
return True
|
||
|
||
def _mark_proxy_failure(self, proxy):
|
||
proxy_key = self._proxy_key(proxy)
|
||
with self.proxy_failure_lock:
|
||
failure_count = int(self.proxy_failure_counts.get(proxy_key, 0) or 0) + 1
|
||
self.proxy_failure_counts[proxy_key] = failure_count
|
||
cooldown_seconds = min(180, 20 * failure_count)
|
||
retry_after = time.time() + cooldown_seconds
|
||
self.proxy_quarantine_until[proxy_key] = retry_after
|
||
logger.info(f"代理临时隔离 {cooldown_seconds}s: {proxy}")
|
||
return failure_count, cooldown_seconds
|
||
|
||
def _clear_proxy_failure(self, proxy):
|
||
proxy_key = self._proxy_key(proxy)
|
||
with self.proxy_failure_lock:
|
||
self.proxy_failure_counts.pop(proxy_key, None)
|
||
self.proxy_quarantine_until.pop(proxy_key, None)
|
||
|
||
def _proxy_source_tag_for_proxy(self, proxy):
|
||
proxy_key = self._proxy_key(proxy)
|
||
with self.proxy_failure_lock:
|
||
source_map = getattr(self, "proxy_source_by_key", None) or {}
|
||
return str(source_map.get(proxy_key, "") or "").strip()
|
||
|
||
def _is_proxy_source_quarantined(self, source_tag):
|
||
normalized_source_tag = str(source_tag or "").strip()
|
||
if not normalized_source_tag:
|
||
return False
|
||
now_ts = time.time()
|
||
with self.proxy_failure_lock:
|
||
quarantine_map = getattr(self, "proxy_source_quarantine_until", None) or {}
|
||
retry_after = float(quarantine_map.get(normalized_source_tag, 0) or 0)
|
||
if retry_after <= now_ts:
|
||
if normalized_source_tag in quarantine_map:
|
||
quarantine_map.pop(normalized_source_tag, None)
|
||
(getattr(self, "proxy_source_failure_counts", None) or {}).pop(normalized_source_tag, None)
|
||
return False
|
||
return True
|
||
|
||
def _is_proxy_auth_issue(self, message):
|
||
lowered = str(message or "").lower()
|
||
return any(
|
||
keyword in lowered
|
||
for keyword in (
|
||
"407 proxy authentication required",
|
||
"proxy authentication required",
|
||
"tunnel connection failed: 407",
|
||
"status code 407",
|
||
)
|
||
)
|
||
|
||
def _remove_proxy_source_from_pool(self, source_tag):
|
||
normalized_source_tag = str(source_tag or "").strip()
|
||
if not normalized_source_tag:
|
||
return 0
|
||
removed = 0
|
||
with self.proxy_pool_lock:
|
||
filtered_pool = []
|
||
for proxy_item in list(getattr(self, "proxy_pool", []) or []):
|
||
item_source_tag = ""
|
||
if isinstance(proxy_item, dict):
|
||
item_source_tag = str(proxy_item.get("source_tag") or "").strip()
|
||
if item_source_tag and item_source_tag == normalized_source_tag:
|
||
removed += 1
|
||
continue
|
||
filtered_pool.append(proxy_item)
|
||
if removed:
|
||
self.proxy_pool = filtered_pool
|
||
return removed
|
||
|
||
def _mark_proxy_source_failure(self, proxy, reason=""):
|
||
source_tag = self._proxy_source_tag_for_proxy(proxy)
|
||
if not source_tag:
|
||
return "", 0, 0, 0
|
||
|
||
with self.proxy_failure_lock:
|
||
failure_counts = getattr(self, "proxy_source_failure_counts", None)
|
||
if failure_counts is None:
|
||
self.proxy_source_failure_counts = {}
|
||
failure_counts = self.proxy_source_failure_counts
|
||
quarantine_map = getattr(self, "proxy_source_quarantine_until", None)
|
||
if quarantine_map is None:
|
||
self.proxy_source_quarantine_until = {}
|
||
quarantine_map = self.proxy_source_quarantine_until
|
||
failure_count = int(failure_counts.get(source_tag, 0) or 0) + 1
|
||
failure_counts[source_tag] = failure_count
|
||
if self._is_proxy_auth_issue(reason):
|
||
cooldown_seconds = min(600, max(90, 90 * failure_count))
|
||
else:
|
||
cooldown_seconds = min(300, max(30, 30 * failure_count))
|
||
quarantine_map[source_tag] = time.time() + cooldown_seconds
|
||
|
||
removed = self._remove_proxy_source_from_pool(source_tag)
|
||
logger.warning(
|
||
f"代理来源已临时隔离 {cooldown_seconds}s: source={source_tag}, failures={failure_count}, removed={removed}, reason={reason}"
|
||
)
|
||
return source_tag, failure_count, cooldown_seconds, removed
|
||
|
||
def _clear_proxy_source_failure(self, proxy):
|
||
source_tag = self._proxy_source_tag_for_proxy(proxy)
|
||
if not source_tag:
|
||
return
|
||
with self.proxy_failure_lock:
|
||
(getattr(self, "proxy_source_failure_counts", None) or {}).pop(source_tag, None)
|
||
(getattr(self, "proxy_source_quarantine_until", None) or {}).pop(source_tag, None)
|
||
|
||
def _schedule_proxy_refresh_if_needed(self, available_count=None):
|
||
if available_count is None:
|
||
with self.proxy_pool_lock:
|
||
current_count = len(self.proxy_pool)
|
||
else:
|
||
# 调用方可能已经持有 proxy_pool_lock;避免对同一把非重入锁再次加锁。
|
||
current_count = int(available_count or 0)
|
||
threshold = self._proxy_refresh_threshold()
|
||
if current_count < threshold and not self.proxy_refresh_lock.locked():
|
||
threading.Thread(target=self.refresh_proxy_pool, daemon=True).start()
|
||
|
||
def _has_proxy_sources(self):
|
||
proxy_urls = self.proxy_config.get('proxy_urls') or []
|
||
if not proxy_urls and self.proxy_config.get('proxy_url'):
|
||
proxy_urls = [self.proxy_config.get('proxy_url', '')]
|
||
return bool([url for url in proxy_urls if str(url or '').strip()])
|
||
|
||
def trigger_proxy_refresh(self, reason="", reset_cooldown=False):
|
||
"""
|
||
代理配置一旦下发,就允许在非检测态主动补一次刷新,避免页面长期停在“未刷新”。
|
||
"""
|
||
if not self.proxy_config.get('proxy_enable', False):
|
||
return False
|
||
if not self._has_proxy_sources():
|
||
return False
|
||
if reset_cooldown:
|
||
self.proxy_next_refresh_time = 0.0
|
||
if self.proxy_refresh_lock.locked():
|
||
logger.debug(f"代理池刷新已在进行中,跳过主动触发: {reason or 'manual'}")
|
||
return False
|
||
logger.info(f"主动触发代理池刷新: {reason or 'manual'}")
|
||
threading.Thread(target=self.refresh_proxy_pool, daemon=True).start()
|
||
return True
|
||
|
||
def _proxy_refresh_threshold(self):
|
||
"""
|
||
代理池的补货阈值只用于触发后台刷新,不应反向限制并发。
|
||
"""
|
||
demand_threads = self._proxy_demand_threads()
|
||
return max(840, demand_threads * 3)
|
||
|
||
def _proxy_demand_threads(self):
|
||
configured_threads = max(1, int(getattr(self, "thread_count", 0) or 0))
|
||
live_active_threads = max(0, int(self._get_active_domain_threads() or 0))
|
||
if self.detecting:
|
||
warm_floor = min(configured_threads, max(400, configured_threads // 2))
|
||
demand_threads = max(live_active_threads, warm_floor)
|
||
else:
|
||
demand_threads = min(configured_threads, 64)
|
||
return max(1, demand_threads)
|
||
|
||
def _should_emit_thread_progress(self, current_active, max_threads, last_emit_count, last_emit_at):
|
||
"""
|
||
线程创建/回收阶段的活跃数上报做节流,避免每起一个线程就同步
|
||
一次远端日志和运行态,反向把并发拉低。
|
||
"""
|
||
now = time.time()
|
||
step = max(1, min(10, max_threads // 10))
|
||
if current_active == 1:
|
||
return True, now
|
||
if current_active >= max_threads:
|
||
return True, now
|
||
if current_active == 0 and last_emit_count != 0:
|
||
return True, now
|
||
if current_active - last_emit_count >= step:
|
||
return True, now
|
||
if current_active > 0 and now - last_emit_at >= 5.0:
|
||
return True, now
|
||
return False, now
|
||
|
||
def _wait_for_proxy_refresh_settle(self, timeout_seconds=2.0):
|
||
deadline = time.time() + max(0.0, float(timeout_seconds or 0.0))
|
||
while self.proxy_refresh_lock.locked() and time.time() < deadline:
|
||
with self.proxy_pool_lock:
|
||
if self.proxy_pool:
|
||
return True
|
||
time.sleep(0.05)
|
||
with self.proxy_pool_lock:
|
||
return bool(self.proxy_pool)
|
||
|
||
def refresh_proxy_pool(self):
|
||
"""
|
||
刷新代理池
|
||
"""
|
||
if not self.proxy_refresh_lock.acquire(blocking=False):
|
||
logger.debug("代理池刷新已在进行中,跳过本次重复刷新")
|
||
return
|
||
|
||
if not self.proxy_config.get('proxy_enable', False):
|
||
self.proxy_last_refresh_time = datetime.now()
|
||
self.proxy_last_refresh_status = "代理未启用"
|
||
self.proxy_last_refresh_source_count = 0
|
||
self.proxy_last_refresh_total_items = 0
|
||
self.proxy_last_validated_count = 0
|
||
self.proxy_last_available_count = 0
|
||
self.proxy_last_source_stats = []
|
||
self.proxy_next_refresh_time = 0.0
|
||
self._update_runtime_state(
|
||
"idle" if not self.detecting else "running",
|
||
"代理未启用,使用直接连接",
|
||
)
|
||
self._sync_worker_log_event("代理未启用,使用直接连接", mode='key')
|
||
self.proxy_refresh_lock.release()
|
||
return
|
||
|
||
try:
|
||
now_ts = time.time()
|
||
retry_delay_seconds = 0.15 if self.detecting else min(self.proxy_refresh_cooldown_seconds, 10)
|
||
with self.proxy_pool_lock:
|
||
has_cached_proxies = bool(self.proxy_pool)
|
||
cached_proxy_pool = list(self.proxy_pool)
|
||
if not has_cached_proxies and self.proxy_next_refresh_time and now_ts < self.proxy_next_refresh_time:
|
||
wait_seconds = int(max(1, self.proxy_next_refresh_time - now_ts))
|
||
self.proxy_last_refresh_status = f"冷却中,{wait_seconds} 秒后再试"
|
||
logger.info(f"代理池刷新冷却中,{wait_seconds} 秒后再试")
|
||
self._update_runtime_state(
|
||
"refreshing_proxy" if self.detecting else "idle",
|
||
f"代理池刷新冷却中,{wait_seconds} 秒后再试",
|
||
)
|
||
self._sync_worker_log_event(f"代理池刷新冷却中,{wait_seconds} 秒后再试", mode='full')
|
||
return
|
||
|
||
proxy_api_urls = self.proxy_config.get('proxy_urls') or []
|
||
if not proxy_api_urls and self.proxy_config.get('proxy_url'):
|
||
proxy_api_urls = [self.proxy_config.get('proxy_url', '')]
|
||
proxy_api_urls = [url for url in proxy_api_urls if url]
|
||
proxy_fetch_plan, fetch_plan_meta = self._prepare_proxy_fetch_plan(
|
||
proxy_api_urls,
|
||
current_pool_size=len(cached_proxy_pool),
|
||
)
|
||
source_count = int(fetch_plan_meta.get("source_count", 0) or 0)
|
||
batch_size = int(fetch_plan_meta.get("batch_size", 0) or 0)
|
||
target_total = int(fetch_plan_meta.get("target_total", 0) or 0)
|
||
rounds = int(fetch_plan_meta.get("rounds", 0) or 0)
|
||
self.proxy_last_refresh_source_count = source_count
|
||
self.proxy_last_source_stats = []
|
||
if proxy_fetch_plan:
|
||
import requests
|
||
proxy_list = []
|
||
cooldown_detected = False
|
||
|
||
aggressive_proxy_rotation = str(os.getenv("DOMAINCHECK_PROXY_FAST_ROTATION", "1") or "1").strip().lower() not in {
|
||
"",
|
||
"0",
|
||
"false",
|
||
"no",
|
||
"off",
|
||
}
|
||
|
||
def fetch_proxy_source(plan_item):
|
||
source_url = str(plan_item.get("source_url") or "").strip()
|
||
requested_batch_size = max(1, int(plan_item.get("batch_size", 0) or batch_size or 1))
|
||
round_index = int(plan_item.get("round", 1) or 1)
|
||
source_stat = {
|
||
"url": source_url,
|
||
"source_url": source_url,
|
||
"status": "unknown",
|
||
"http_status": 0,
|
||
"raw_items": 0,
|
||
"error": "",
|
||
"requested_batch_size": requested_batch_size,
|
||
"effective_batch_size": requested_batch_size,
|
||
"round": round_index,
|
||
}
|
||
current_items = []
|
||
batch_attempts = []
|
||
if self.detecting and aggressive_proxy_rotation:
|
||
dynamic_fallbacks = [
|
||
requested_batch_size,
|
||
max(80, requested_batch_size // 2),
|
||
80,
|
||
50,
|
||
20,
|
||
10,
|
||
]
|
||
else:
|
||
dynamic_fallbacks = [
|
||
requested_batch_size,
|
||
max(500, requested_batch_size // 2),
|
||
200,
|
||
100,
|
||
50,
|
||
20,
|
||
10,
|
||
]
|
||
for candidate in dynamic_fallbacks:
|
||
candidate = max(1, int(candidate or 0))
|
||
if candidate not in batch_attempts:
|
||
batch_attempts.append(candidate)
|
||
|
||
for candidate_batch_size in batch_attempts:
|
||
request_url = self._set_proxy_request_count(source_url, candidate_batch_size)
|
||
source_stat["effective_batch_size"] = candidate_batch_size
|
||
try:
|
||
response = requests.get(
|
||
request_url,
|
||
timeout=self._proxy_source_fetch_timeout_seconds(),
|
||
)
|
||
source_stat["http_status"] = int(response.status_code or 0)
|
||
if response.status_code != 200:
|
||
source_stat["status"] = "http_error"
|
||
source_stat["error"] = f"HTTP {response.status_code}"
|
||
logger.warning(f"代理池链接响应异常: {request_url}, 状态码: {response.status_code}")
|
||
continue
|
||
|
||
proxy_data = response.json()
|
||
current_items = self._extract_proxy_items(proxy_data)
|
||
source_tag = self._proxy_source_tag_from_url(source_url)
|
||
annotated_items = []
|
||
for proxy_item in current_items:
|
||
if isinstance(proxy_item, dict):
|
||
item_copy = dict(proxy_item)
|
||
item_copy.setdefault("source_url", source_url)
|
||
item_copy.setdefault("source_tag", source_tag)
|
||
annotated_items.append(item_copy)
|
||
current_items = annotated_items
|
||
source_stat["raw_items"] = len(current_items)
|
||
if current_items:
|
||
source_stat["status"] = "ok"
|
||
logger.info(
|
||
f"代理池链接拉取成功: {request_url}, 原始代理数: {len(current_items)}, 轮次: {round_index}"
|
||
)
|
||
break
|
||
|
||
error_message = ""
|
||
if isinstance(proxy_data, dict):
|
||
error_message = str(proxy_data.get("error") or "").strip()
|
||
source_stat["status"] = "cooldown" if "cooldown" in error_message.lower() else "empty"
|
||
source_stat["error"] = error_message or "empty"
|
||
logger.info(
|
||
f"代理池链接空载,降批重试: {request_url}, 轮次: {round_index}, 提示: {source_stat['error']}"
|
||
)
|
||
if source_stat["status"] == "cooldown":
|
||
break
|
||
except Exception as api_error:
|
||
source_stat["status"] = "request_error"
|
||
source_stat["error"] = str(api_error)
|
||
logger.warning(f"代理池链接拉取失败: {request_url}, 错误: {api_error}")
|
||
source_stat["url"] = self._set_proxy_request_count(source_url, source_stat["effective_batch_size"])
|
||
return source_stat, current_items
|
||
|
||
round_plan_map = {}
|
||
for plan_item in proxy_fetch_plan:
|
||
round_key = max(1, int(plan_item.get("round", 1) or 1))
|
||
round_plan_map.setdefault(round_key, []).append(plan_item)
|
||
|
||
round_keys = sorted(round_plan_map.keys())
|
||
per_round_workers = max(1, min(source_count or len(proxy_api_urls) or 1, 12))
|
||
cooled_source_urls = set()
|
||
for round_index in round_keys:
|
||
round_plan_items = [
|
||
item
|
||
for item in (round_plan_map.get(round_index) or [])
|
||
if str(item.get("source_url") or "").strip() not in cooled_source_urls
|
||
]
|
||
if not round_plan_items:
|
||
continue
|
||
with ThreadPoolExecutor(max_workers=min(per_round_workers, len(round_plan_items))) as executor:
|
||
futures = [executor.submit(fetch_proxy_source, plan_item) for plan_item in round_plan_items]
|
||
for future in as_completed(futures):
|
||
source_stat, current_items = future.result()
|
||
if "cooldown" in str(source_stat.get("error") or "").lower() or source_stat.get("status") == "cooldown":
|
||
cooldown_detected = True
|
||
cooled_source_urls.add(str(source_stat.get("source_url") or "").strip())
|
||
self.proxy_last_source_stats.append(source_stat)
|
||
proxy_list.extend(current_items)
|
||
if len(proxy_list) >= target_total:
|
||
break
|
||
if self.detecting and aggressive_proxy_rotation and round_index < len(round_keys):
|
||
time.sleep(0.15 if cooldown_detected else 0.05)
|
||
|
||
if proxy_list:
|
||
self.proxy_last_refresh_total_items = len(proxy_list)
|
||
self.proxy_last_validated_count = 0
|
||
|
||
new_proxies = []
|
||
fallback_stale_proxies = []
|
||
seen_proxy_strings = set()
|
||
expired_count = 0
|
||
invalid_count = 0
|
||
|
||
for proxy_item in proxy_list:
|
||
signature = self._proxy_signature_from_item(proxy_item)
|
||
if not signature:
|
||
invalid_count += 1
|
||
continue
|
||
if signature in seen_proxy_strings:
|
||
continue
|
||
seen_proxy_strings.add(signature)
|
||
ip = str(proxy_item.get("ip", "") or "").strip()
|
||
port = str(proxy_item.get("port", "") or "").strip()
|
||
username = str(proxy_item.get("username", "") or "").strip()
|
||
password = str(proxy_item.get("password", "") or "").strip()
|
||
proxy_url = f"http://{username}:{password}@{ip}:{port}" if username and password else f"http://{ip}:{port}"
|
||
source_tag = self._proxy_source_tag_from_url(proxy_item.get("source_url") or proxy_item.get("source_tag") or "")
|
||
if self._is_proxy_source_quarantined(source_tag):
|
||
continue
|
||
proxy_entry = {
|
||
'proxy': {
|
||
'http': proxy_url,
|
||
'https': proxy_url,
|
||
},
|
||
'usage_count': 0,
|
||
'source_tag': source_tag,
|
||
}
|
||
|
||
if self._is_proxy_item_expired(proxy_item):
|
||
expired_count += 1
|
||
fallback_stale_proxies.append(proxy_entry)
|
||
continue
|
||
|
||
new_proxies.append(proxy_entry)
|
||
|
||
fallback_stale_count = 0
|
||
if not new_proxies and fallback_stale_proxies and not cached_proxy_pool:
|
||
new_proxies = fallback_stale_proxies
|
||
fallback_stale_count = len(new_proxies)
|
||
|
||
merged_proxy_pool = []
|
||
if new_proxies:
|
||
merged_proxy_pool = self._merge_proxy_entries(new_proxies, cached_proxy_pool)
|
||
elif cached_proxy_pool:
|
||
merged_proxy_pool = list(cached_proxy_pool)
|
||
|
||
with self.proxy_pool_lock:
|
||
self.proxy_pool = merged_proxy_pool
|
||
source_map = {}
|
||
for proxy_entry in merged_proxy_pool:
|
||
if isinstance(proxy_entry, dict) and proxy_entry.get("proxy"):
|
||
source_map[self._proxy_key(proxy_entry.get("proxy"))] = str(
|
||
proxy_entry.get("source_tag") or ""
|
||
).strip()
|
||
with self.proxy_failure_lock:
|
||
self.proxy_source_by_key = source_map
|
||
self.proxy_last_refresh_time = datetime.now()
|
||
self.proxy_last_available_count = len(self.proxy_pool)
|
||
if new_proxies:
|
||
if fallback_stale_count > 0:
|
||
self.proxy_last_refresh_status = (
|
||
f"疑似过期回退增量入池 {len(new_proxies)} 个,当前池 {len(self.proxy_pool)} 个(跳过预验证)"
|
||
)
|
||
else:
|
||
self.proxy_last_refresh_status = (
|
||
f"增量入池 {len(new_proxies)} 个,当前池 {len(self.proxy_pool)} 个(跳过预验证)"
|
||
)
|
||
self.proxy_next_refresh_time = 0.0
|
||
elif cached_proxy_pool:
|
||
self.proxy_last_refresh_status = f"本轮未拿到新代理,继续沿用缓存 {len(cached_proxy_pool)} 个"
|
||
next_retry_delay = 0.5 if (self.detecting and aggressive_proxy_rotation and cooldown_detected) else (2 if cooldown_detected else retry_delay_seconds)
|
||
self.proxy_next_refresh_time = time.time() + next_retry_delay
|
||
else:
|
||
self.proxy_last_refresh_status = "代理源暂时冷却中,稍后继续补货" if cooldown_detected else "未取到可用代理数据"
|
||
next_retry_delay = 0.5 if (self.detecting and aggressive_proxy_rotation and cooldown_detected) else (2 if cooldown_detected else retry_delay_seconds)
|
||
self.proxy_next_refresh_time = time.time() + next_retry_delay
|
||
logger.info(
|
||
f"代理池刷新完成,共 {len(self.proxy_pool)} 个可用代理,来源链接 {source_count} 个,"
|
||
f"原始 {self.proxy_last_refresh_total_items} 个,去过期 {expired_count} 个,非法 {invalid_count} 个,"
|
||
f"目标总量 {target_total},单批 {batch_size},轮次 {rounds}"
|
||
)
|
||
self._update_runtime_state(
|
||
"running" if self.detecting else "idle",
|
||
f"代理池刷新完成,共 {len(self.proxy_pool)} 个可用代理,来源链接 {source_count} 个,原始 {self.proxy_last_refresh_total_items} 个,单批 {batch_size},轮次 {rounds}",
|
||
)
|
||
self._sync_worker_log_event(
|
||
f"代理池刷新完成,共 {len(self.proxy_pool)} 个可用代理,来源链接 {source_count} 个,原始 {self.proxy_last_refresh_total_items} 个,单批 {batch_size},轮次 {rounds}",
|
||
payload={
|
||
"available_proxy_count": len(self.proxy_pool),
|
||
"source_count": source_count,
|
||
"raw_items": self.proxy_last_refresh_total_items,
|
||
"validated_count": 0,
|
||
"expired_count": expired_count,
|
||
"invalid_count": invalid_count,
|
||
"fallback_stale_count": fallback_stale_count,
|
||
"target_total": target_total,
|
||
"batch_size": batch_size,
|
||
"rounds": rounds,
|
||
},
|
||
mode='key',
|
||
)
|
||
else:
|
||
with self.proxy_pool_lock:
|
||
self.proxy_pool = cached_proxy_pool
|
||
self.proxy_last_refresh_time = datetime.now()
|
||
self.proxy_last_refresh_total_items = 0
|
||
self.proxy_last_validated_count = 0
|
||
self.proxy_last_available_count = len(self.proxy_pool)
|
||
if cached_proxy_pool:
|
||
self.proxy_last_refresh_status = f"未取到新代理,继续沿用缓存 {len(cached_proxy_pool)} 个"
|
||
else:
|
||
self.proxy_last_refresh_status = "代理源暂时冷却中,稍后继续补货" if cooldown_detected else "未取到可用代理数据"
|
||
next_retry_delay = 0.5 if (self.detecting and aggressive_proxy_rotation and cooldown_detected) else (2 if cooldown_detected else retry_delay_seconds)
|
||
self.proxy_next_refresh_time = time.time() + next_retry_delay
|
||
logger.warning("所有代理池链接均未返回可用代理数据")
|
||
self._update_runtime_state(
|
||
"refreshing_proxy" if self.detecting else "idle",
|
||
self.proxy_last_refresh_status,
|
||
)
|
||
self._sync_worker_log_event(self.proxy_last_refresh_status, level='warning', mode='key')
|
||
else:
|
||
with self.proxy_pool_lock:
|
||
self.proxy_pool = cached_proxy_pool
|
||
self.proxy_last_refresh_time = datetime.now()
|
||
self.proxy_last_refresh_total_items = 0
|
||
self.proxy_last_validated_count = 0
|
||
self.proxy_last_available_count = len(self.proxy_pool)
|
||
self.proxy_last_refresh_status = "未配置代理池链接"
|
||
self.proxy_next_refresh_time = time.time() + (0.5 if (self.detecting and aggressive_proxy_rotation) else retry_delay_seconds)
|
||
self._update_runtime_state(
|
||
"idle" if not self.detecting else "running",
|
||
"未配置代理池链接",
|
||
)
|
||
self._sync_worker_log_event("未配置代理池链接", level='warning', mode='key')
|
||
except Exception as e:
|
||
with self.proxy_pool_lock:
|
||
cached_proxy_pool = list(self.proxy_pool)
|
||
self.proxy_pool = cached_proxy_pool
|
||
self.proxy_last_refresh_time = datetime.now()
|
||
self.proxy_last_refresh_total_items = 0
|
||
self.proxy_last_validated_count = 0
|
||
self.proxy_last_available_count = len(self.proxy_pool)
|
||
if cached_proxy_pool:
|
||
self.proxy_last_refresh_status = f"刷新失败,继续沿用缓存 {len(cached_proxy_pool)} 个: {e}"
|
||
else:
|
||
self.proxy_last_refresh_status = f"刷新失败: {e}"
|
||
self.proxy_next_refresh_time = time.time() + retry_delay_seconds
|
||
logger.error(f"刷新代理池失败: {e}")
|
||
self._update_runtime_state(
|
||
"refreshing_proxy" if self.detecting else "failed",
|
||
self.proxy_last_refresh_status,
|
||
)
|
||
self._sync_worker_log_event(self.proxy_last_refresh_status, level='error', mode='key')
|
||
finally:
|
||
self.proxy_refresh_lock.release()
|
||
|
||
def remove_proxy(self, proxy):
|
||
"""
|
||
从代理池中移除失效的代理
|
||
|
||
:param proxy: 失效的代理
|
||
"""
|
||
failure_count, cooldown_seconds = self._mark_proxy_failure(proxy)
|
||
with self.proxy_pool_lock:
|
||
remaining_count = len(self.proxy_pool)
|
||
removed = 0
|
||
if self.proxy_pool and isinstance(self.proxy_pool[0], dict) and 'proxy' in self.proxy_pool[0]:
|
||
filtered_pool = []
|
||
for proxy_item in self.proxy_pool:
|
||
if proxy_item.get('proxy') == proxy:
|
||
removed += 1
|
||
continue
|
||
filtered_pool.append(proxy_item)
|
||
if removed:
|
||
self.proxy_pool = filtered_pool
|
||
remaining_count = len(self.proxy_pool)
|
||
logger.info(f"代理失效已从当前池移除 {removed} 个: {proxy}")
|
||
else:
|
||
filtered_pool = []
|
||
for existing_proxy in self.proxy_pool:
|
||
if existing_proxy == proxy:
|
||
removed += 1
|
||
continue
|
||
filtered_pool.append(existing_proxy)
|
||
if removed:
|
||
self.proxy_pool = filtered_pool
|
||
remaining_count = len(self.proxy_pool)
|
||
logger.info(f"代理失效已从当前池移除 {removed} 个: {proxy}")
|
||
|
||
logger.info(
|
||
f"代理失败计数已更新: 第 {failure_count} 次失败,隔离 {cooldown_seconds}s,当前池内代理 {remaining_count} 个"
|
||
)
|
||
self._schedule_proxy_refresh_if_needed(remaining_count)
|
||
|
||
def get_proxies(self, excluded_proxy_keys=None):
|
||
"""
|
||
获取代理配置
|
||
"""
|
||
excluded_proxy_keys = set(excluded_proxy_keys or [])
|
||
if self.proxy_config.get('proxy_enable', False):
|
||
with self.proxy_pool_lock:
|
||
current_pool_size = len(self.proxy_pool)
|
||
is_empty = current_pool_size == 0
|
||
is_insufficient = current_pool_size < self._proxy_refresh_threshold()
|
||
|
||
if is_empty:
|
||
# 不要在检测线程里同步阻塞等待代理刷新,否则线程数上去了也会卡死在这里。
|
||
self._schedule_proxy_refresh_if_needed(current_pool_size)
|
||
elif is_insufficient:
|
||
logger.debug(
|
||
f"代理池低水位,触发后台补货: 当前 {current_pool_size},阈值 {self._proxy_refresh_threshold()}"
|
||
)
|
||
self._schedule_proxy_refresh_if_needed(current_pool_size)
|
||
|
||
with self.proxy_pool_lock:
|
||
if self.proxy_pool:
|
||
pool_size = len(self.proxy_pool)
|
||
for _ in range(pool_size):
|
||
if isinstance(self.proxy_pool[0], dict) and 'proxy' in self.proxy_pool[0]:
|
||
proxy_item = self.proxy_pool.pop(0)
|
||
proxy = proxy_item['proxy']
|
||
proxy_key = self._proxy_key(proxy)
|
||
if self._is_proxy_quarantined(proxy):
|
||
self.proxy_pool.append(proxy_item)
|
||
continue
|
||
source_tag = str(proxy_item.get("source_tag") or "").strip()
|
||
if self._is_proxy_source_quarantined(source_tag):
|
||
self.proxy_pool.append(proxy_item)
|
||
continue
|
||
if proxy_key in excluded_proxy_keys:
|
||
self.proxy_pool.append(proxy_item)
|
||
continue
|
||
|
||
usage_count = int(proxy_item.get('usage_count', 0)) + 1
|
||
if usage_count >= self.proxy_max_reuse_count:
|
||
proxy_item['usage_count'] = 0
|
||
else:
|
||
proxy_item['usage_count'] = usage_count
|
||
self.proxy_pool.append(proxy_item)
|
||
with self.proxy_failure_lock:
|
||
source_map = getattr(self, "proxy_source_by_key", None)
|
||
if source_map is None:
|
||
self.proxy_source_by_key = {}
|
||
source_map = self.proxy_source_by_key
|
||
source_map[proxy_key] = source_tag
|
||
self._schedule_proxy_refresh_if_needed(len(self.proxy_pool))
|
||
return proxy
|
||
|
||
proxy = self.proxy_pool.pop(0)
|
||
proxy_key = self._proxy_key(proxy)
|
||
if self._is_proxy_quarantined(proxy):
|
||
self.proxy_pool.append(proxy)
|
||
continue
|
||
if proxy_key in excluded_proxy_keys:
|
||
self.proxy_pool.append(proxy)
|
||
continue
|
||
logger.info(f"从代理池选择代理: {proxy}")
|
||
self.proxy_pool.append(proxy)
|
||
self._schedule_proxy_refresh_if_needed(len(self.proxy_pool))
|
||
return proxy
|
||
if self.proxy_config.get('proxy_enable', False):
|
||
now_ts = time.time()
|
||
if now_ts - float(getattr(self, "_last_no_proxy_notice_at", 0.0) or 0.0) >= 5.0:
|
||
self._last_no_proxy_notice_at = now_ts
|
||
logger.warning("代理已启用,但当前无可用代理")
|
||
fallback_detail = (
|
||
f"代理已启用,但当前无可用代理;已允许直连兜底,最近代理状态:{self.proxy_last_refresh_status}"
|
||
if self.allow_direct_connection()
|
||
else f"代理已启用,但当前无可用代理;且未允许直连,最近代理状态:{self.proxy_last_refresh_status}"
|
||
)
|
||
self._update_runtime_state(
|
||
"running" if self.detecting else "idle",
|
||
fallback_detail,
|
||
)
|
||
else:
|
||
logger.info("未启用代理,使用直接连接")
|
||
return None
|
||
|
||
def allow_direct_connection(self):
|
||
return bool(self.proxy_config.get('allow_direct', False))
|
||
|
||
def _get_proxy_for_step(self, domain_id, domain_name, step_name, excluded_proxy_keys=None):
|
||
started_at = time.perf_counter()
|
||
excluded_proxy_keys = set(excluded_proxy_keys or [])
|
||
proxy_enabled = bool(self.proxy_config.get('proxy_enable', False))
|
||
direct_allowed = self.allow_direct_connection()
|
||
force_direct_once = self._consume_step_force_direct_once(step_name)
|
||
if proxy_enabled and direct_allowed and force_direct_once:
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"proxy_direct_fallback",
|
||
elapsed_ms=0,
|
||
proxy_mode="direct",
|
||
recent_proxy_status=self.proxy_last_refresh_status,
|
||
retry_attempts=0,
|
||
reason="external_retry_direct_fallback",
|
||
)
|
||
return None
|
||
direct_retry_wait_seconds = self._consume_step_proxy_retry_wait(step_name)
|
||
wait_window_seconds = 0.0
|
||
if proxy_enabled and not direct_allowed:
|
||
wait_window_seconds = self.proxy_step_wait_timeout_seconds
|
||
elif proxy_enabled and direct_allowed:
|
||
wait_window_seconds = max(self.proxy_direct_fallback_grace_seconds, direct_retry_wait_seconds)
|
||
wait_deadline = time.time() + wait_window_seconds
|
||
wait_logged_at = 0.0
|
||
attempt = 0
|
||
|
||
while True:
|
||
proxy = self.get_proxies(excluded_proxy_keys=excluded_proxy_keys)
|
||
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
||
if proxy:
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"proxy_selected",
|
||
elapsed_ms=elapsed_ms,
|
||
proxy_mode="proxy",
|
||
excluded_proxy_count=len(excluded_proxy_keys),
|
||
retry_attempts=attempt,
|
||
)
|
||
return proxy
|
||
|
||
if not proxy_enabled:
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"proxy_direct_fallback",
|
||
elapsed_ms=elapsed_ms,
|
||
proxy_mode="direct",
|
||
recent_proxy_status=self.proxy_last_refresh_status,
|
||
)
|
||
return None
|
||
|
||
now_ts = time.time()
|
||
if now_ts >= wait_deadline:
|
||
break
|
||
|
||
attempt += 1
|
||
if not self.proxy_refresh_lock.locked():
|
||
self.trigger_proxy_refresh(
|
||
reason=f"step_wait:{step_name}",
|
||
reset_cooldown=bool(direct_retry_wait_seconds) or step_name == '注册状态检测',
|
||
)
|
||
if now_ts - wait_logged_at >= 1.0:
|
||
wait_logged_at = now_ts
|
||
logger.info(
|
||
f"{step_name} 暂无可用代理,继续等待补货: {domain_name}, "
|
||
f"attempt={attempt}, waited_ms={elapsed_ms}, 最近状态: {self.proxy_last_refresh_status}"
|
||
)
|
||
time.sleep(0.05 if direct_retry_wait_seconds > 0 else 0.1)
|
||
|
||
if proxy_enabled and direct_allowed:
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"proxy_direct_fallback",
|
||
elapsed_ms=int((time.perf_counter() - started_at) * 1000),
|
||
proxy_mode="direct",
|
||
recent_proxy_status=self.proxy_last_refresh_status,
|
||
retry_attempts=attempt,
|
||
)
|
||
return None
|
||
|
||
detail = self.proxy_last_refresh_status
|
||
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
||
logger.error(
|
||
f"{step_name} 无可用代理,且当前不允许直连兜底: {domain_name},"
|
||
f"waited_ms={elapsed_ms}, attempts={attempt}, 最近代理状态: {detail}"
|
||
)
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"proxy_unavailable",
|
||
elapsed_ms=elapsed_ms,
|
||
level='warning',
|
||
proxy_mode="required",
|
||
recent_proxy_status=detail,
|
||
retry_attempts=attempt,
|
||
)
|
||
if not self._is_current_single_step_task():
|
||
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED)
|
||
return '__NO_PROXY__'
|
||
|
||
def _should_rotate_proxy_on_error(self, proxy, reason, step_name, domain_name):
|
||
if not proxy or not self.proxy_config.get('proxy_enable', False):
|
||
return False
|
||
normalized_reason = str(reason or "")
|
||
if not self._is_external_dependency_issue(normalized_reason):
|
||
return False
|
||
if self._is_proxy_auth_issue(normalized_reason):
|
||
self._mark_proxy_source_failure(proxy, normalized_reason)
|
||
self.remove_proxy(proxy)
|
||
try:
|
||
self.trigger_proxy_refresh(reason=f"proxy_rotate:{step_name}", reset_cooldown=True)
|
||
except Exception as refresh_error:
|
||
logger.debug(f"{step_name} 触发代理快速补货失败: {refresh_error}")
|
||
logger.warning(f"{step_name} 当前代理异常,切换下一个代理继续: {domain_name}, 原因: {normalized_reason}")
|
||
return True
|
||
|
||
def _should_retry_external_issue(self, proxy, reason, step_name, domain_name):
|
||
normalized_reason = str(reason or "")
|
||
if not self._is_external_dependency_issue(normalized_reason):
|
||
return False
|
||
if proxy and self.allow_direct_connection():
|
||
if self._is_proxy_auth_issue(normalized_reason):
|
||
self._mark_proxy_source_failure(proxy, normalized_reason)
|
||
self.remove_proxy(proxy)
|
||
self._set_step_force_direct_once(step_name, True)
|
||
try:
|
||
self.trigger_proxy_refresh(reason=f"direct_fallback:{step_name}", reset_cooldown=True)
|
||
except Exception as refresh_error:
|
||
logger.debug(f"{step_name} 触发直连兜底前代理补货失败: {refresh_error}")
|
||
logger.warning(
|
||
f"{step_name} 代理链路异常,下一次尝试直连兜底: "
|
||
f"{domain_name}, 原因: {normalized_reason}"
|
||
)
|
||
return True
|
||
if self._should_rotate_proxy_on_error(proxy, normalized_reason, step_name, domain_name):
|
||
return True
|
||
if self.proxy_config.get('proxy_enable', False):
|
||
if not proxy and self.allow_direct_connection():
|
||
forced_wait_seconds = max(
|
||
self.proxy_direct_fallback_grace_seconds,
|
||
min(
|
||
self.proxy_step_wait_timeout_seconds or 0.0,
|
||
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT", "1.2") or 1.2),
|
||
) if self.proxy_step_wait_timeout_seconds > 0 else float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT", "1.2") or 1.2),
|
||
)
|
||
self._set_step_proxy_retry_wait(step_name, forced_wait_seconds)
|
||
try:
|
||
self.trigger_proxy_refresh(reason=f"external_retry:{step_name}", reset_cooldown=True)
|
||
except Exception as refresh_error:
|
||
logger.debug(f"{step_name} 触发代理补货失败: {refresh_error}")
|
||
logger.warning(
|
||
f"{step_name} 外部链路异常,已触发代理补货并继续重试: "
|
||
f"{domain_name}, 原因: {normalized_reason}"
|
||
)
|
||
return True
|
||
return False
|
||
|
||
def _mark_blacklisted(self, domain_id, domain_name, reason):
|
||
self._set_task_detect_status(DETECT_STATUS_BLACKLISTED)
|
||
if self._is_current_single_step_task():
|
||
logger.info(f"single_step 域名命中黑名单,跳过本地域名表/黑名单表写入: {domain_name}, 原因: {reason}")
|
||
return
|
||
self.db.mark_domain_blacklisted(domain_id, domain_name, reason)
|
||
logger.info(f"域名已加入黑名单: {domain_name}, 原因: {reason}")
|
||
|
||
def _step_result_payload(self, *, ok, state, message="", **extra):
|
||
payload = {
|
||
"status": bool(ok),
|
||
"state": state,
|
||
"message": message or "",
|
||
"checked_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
payload.update(extra)
|
||
return payload
|
||
|
||
def _record_step_result(self, domain_id, field_name, *, ok, state, message="", **extra):
|
||
if not field_name:
|
||
return
|
||
payload = self._step_result_payload(ok=ok, state=state, message=message, **extra)
|
||
if self._is_current_single_step_task():
|
||
self._cache_single_step_result_payload(domain_id, field_name, payload)
|
||
return
|
||
with self._step_result_cache_lock:
|
||
self._step_result_cache[(int(domain_id or 0), field_name)] = dict(payload)
|
||
self._upsert_json_detection(domain_id, **{field_name: payload})
|
||
|
||
def _should_blacklist_result(self, message):
|
||
if not message:
|
||
return False
|
||
lowered = str(message).lower()
|
||
keywords = [
|
||
"检测到敏感词",
|
||
"黑名单分类",
|
||
"风险网站",
|
||
"高危网站",
|
||
"clienthold",
|
||
"serverhold",
|
||
"拦截",
|
||
"敏感内容",
|
||
]
|
||
return any(keyword.lower() in lowered for keyword in keywords)
|
||
|
||
def _mark_detection_failed(self, domain_id, domain_name, step_name, reason, field_name=None):
|
||
reason = reason or "未知错误"
|
||
if self._is_current_single_step_task():
|
||
self._set_task_detect_status(DETECT_STATUS_FAILED)
|
||
if field_name:
|
||
self._record_step_result(domain_id, field_name, ok=False, state="failed", message=reason, step=step_name)
|
||
logger.warning(f"{step_name} single_step 技术失败,跳过本地域名状态写入: {domain_name}, 原因: {reason}")
|
||
return
|
||
current_status = self._get_task_detect_status() or 0
|
||
if current_status == DETECT_STATUS_BLACKLISTED:
|
||
logger.warning(f"{step_name} 技术失败,但域名已是黑名单状态,保持黑名单不回退: {domain_name}, 原因: {reason}")
|
||
else:
|
||
self._enqueue_domain_status_update(domain_id, DETECT_STATUS_FAILED)
|
||
self._set_task_detect_status(DETECT_STATUS_FAILED)
|
||
if field_name:
|
||
self._record_step_result(domain_id, field_name, ok=False, state="failed", message=reason, step=step_name)
|
||
logger.warning(f"{step_name} 技术失败,域名标记为检测失败: {domain_name}, 原因: {reason}")
|
||
|
||
def _mark_detection_rejected(self, domain_id, domain_name, step_name, reason, field_name=None):
|
||
reason = reason or "业务判定未通过"
|
||
self._set_task_detect_status(DETECT_STATUS_FAILED)
|
||
if field_name:
|
||
self._record_step_result(domain_id, field_name, ok=False, state="rejected", message=reason, step=step_name)
|
||
if self._is_current_single_step_task():
|
||
logger.warning(f"{step_name} single_step 业务不通过,跳过本地域名状态写入: {domain_name}, 原因: {reason}")
|
||
return
|
||
self._enqueue_domain_status_update(domain_id, DETECT_STATUS_FAILED)
|
||
logger.warning(f"{step_name} 业务判定未通过,终止后续步骤: {domain_name}, 原因: {reason}")
|
||
|
||
def _mark_detection_degraded(self, domain_id, domain_name, step_name, reason, field_name=None, review_required=True):
|
||
reason = reason or "外部依赖异常"
|
||
if field_name:
|
||
self._record_step_result(domain_id, field_name, ok=False, state="degraded", message=reason, step=step_name)
|
||
if self._is_current_single_step_task():
|
||
logger.warning(f"{step_name} single_step 外部依赖异常,跳过本地 review 状态写入: {domain_name}, 原因: {reason}")
|
||
return
|
||
if review_required:
|
||
try:
|
||
self._enqueue_review_status_update(domain_id, REVIEW_STATUS_PENDING)
|
||
except Exception as review_error:
|
||
logger.warning(f"{step_name} 降级后设置待人工复核失败: {domain_name}, 错误: {review_error}")
|
||
logger.warning(f"{step_name} 外部依赖异常,步骤降级继续执行: {domain_name}, 原因: {reason}")
|
||
|
||
def _is_external_dependency_issue(self, message):
|
||
if not message:
|
||
return False
|
||
lowered = str(message).lower()
|
||
keywords = [
|
||
"timeout",
|
||
"timed out",
|
||
"超时",
|
||
"请求超时",
|
||
"连接超时",
|
||
"读取超时",
|
||
"网络超时",
|
||
"请求失败",
|
||
"网络异常",
|
||
"连接失败",
|
||
"连接中断",
|
||
"连接被重置",
|
||
"服务暂不可用",
|
||
"connection",
|
||
"connection reset",
|
||
"connection refused",
|
||
"max retries exceeded",
|
||
"httpsconnectionpool",
|
||
"proxyerror",
|
||
"remotedisconnected",
|
||
"temporarily unavailable",
|
||
"502 bad gateway",
|
||
"503 service unavailable",
|
||
"504 gateway timeout",
|
||
"network",
|
||
"ssl",
|
||
"read timeout",
|
||
"connecttimeout",
|
||
"operation timed out",
|
||
"connection timed out",
|
||
"resolving timed out",
|
||
"http 403",
|
||
"http 429",
|
||
"status code 403",
|
||
"status code 429",
|
||
"状态码: 403",
|
||
"状态码: 429",
|
||
"状态码: 502",
|
||
"状态码: 503",
|
||
"状态码: 504",
|
||
"forbidden",
|
||
"empty reply from server",
|
||
"recv failure",
|
||
"curl: (28)",
|
||
"curl:(28)",
|
||
"curl: (52)",
|
||
"curl:(52)",
|
||
"响应格式异常",
|
||
"cannot unpack non-iterable nonetype object",
|
||
]
|
||
return any(keyword in lowered for keyword in keywords)
|
||
|
||
def _resolve_proxy_step_retry_budget(self, step_name):
|
||
overrides = (getattr(self, "proxy_step_retry_budget_overrides", None) or {}).get(step_name) or {}
|
||
max_attempts = max(
|
||
1,
|
||
int(overrides.get("max_attempts", getattr(self, "proxy_step_retry_max_attempts", 12)) or 1),
|
||
)
|
||
max_seconds = max(
|
||
5.0,
|
||
float(overrides.get("max_seconds", getattr(self, "proxy_step_retry_max_seconds", 45)) or 5.0),
|
||
)
|
||
return max_attempts, max_seconds
|
||
|
||
def _check_proxy_step_retry_budget(self, step_name, domain_name, started_at, attempt_count, tried_proxy_keys, last_reason=""):
|
||
elapsed_seconds = max(0.0, time.perf_counter() - float(started_at or 0.0))
|
||
max_attempts, max_seconds = self._resolve_proxy_step_retry_budget(step_name)
|
||
if attempt_count < max_attempts and elapsed_seconds < max_seconds:
|
||
return False, ""
|
||
|
||
reason = (
|
||
f"{step_name} 重试预算耗尽: attempts={attempt_count}, "
|
||
f"elapsed_ms={int(elapsed_seconds * 1000)}, tried_proxies={len(tried_proxy_keys)}"
|
||
)
|
||
if last_reason:
|
||
reason = f"{reason}, last_reason={str(last_reason)[:200]}"
|
||
logger.warning(f"{reason}: {domain_name}")
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"retry_budget_exhausted",
|
||
level='warning',
|
||
elapsed_ms=int(elapsed_seconds * 1000),
|
||
retry_attempts=attempt_count,
|
||
tried_proxy_count=len(tried_proxy_keys),
|
||
last_reason=str(last_reason or "")[:200],
|
||
)
|
||
return True, reason
|
||
|
||
def _remaining_proxy_step_retry_budget_seconds(self, step_name, started_at, reserve_seconds=0.0):
|
||
elapsed_seconds = max(0.0, time.perf_counter() - float(started_at or 0.0))
|
||
_, max_seconds = self._resolve_proxy_step_retry_budget(step_name)
|
||
return max(0.0, float(max_seconds or 0.0) - elapsed_seconds - max(0.0, float(reserve_seconds or 0.0)))
|
||
|
||
def _resolve_submit_backlog_limit(self, max_threads, live_active):
|
||
normalized_threads = max(1, int(max_threads or 1))
|
||
live_active = max(0, int(live_active or 0))
|
||
configured_backlog_floor = int(os.getenv("DOMAINCHECK_SUBMIT_BACKLOG_FLOOR", "0") or 0)
|
||
configured_backlog_ceiling = int(os.getenv("DOMAINCHECK_SUBMIT_BACKLOG_CEIL", "0") or 0)
|
||
# Default to a full-thread warm window. Large machines should be allowed
|
||
# to keep enough local backlog to actually fill the configured worker
|
||
# pool instead of self-throttling to a few hundred runnable items.
|
||
default_backlog_floor = normalized_threads
|
||
backlog_floor = max(16, configured_backlog_floor if configured_backlog_floor > 0 else default_backlog_floor)
|
||
backlog_ceiling = max(
|
||
backlog_floor,
|
||
configured_backlog_ceiling if configured_backlog_ceiling > 0 else normalized_threads,
|
||
)
|
||
dynamic_target = max(
|
||
backlog_floor,
|
||
live_active + max(32, normalized_threads // 4),
|
||
)
|
||
return min(backlog_ceiling, dynamic_target)
|
||
|
||
def _resolve_dispatch_capacity(self, max_threads, live_active, backlog_limit):
|
||
normalized_threads = max(1, int(max_threads or 1))
|
||
live_active = max(0, int(live_active or 0))
|
||
backlog_limit = max(1, int(backlog_limit or 1))
|
||
dispatch_cap_multiplier = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_DISPATCH_CAP_MULTIPLIER", "1") or 1),
|
||
)
|
||
dispatch_capacity_cap = max(1, normalized_threads * dispatch_cap_multiplier)
|
||
return min(dispatch_capacity_cap, max(backlog_limit, live_active + backlog_limit))
|
||
|
||
def _resolve_claim_batch_size(self, current_limit, available_slots, live_active=None):
|
||
normalized_limit = max(1, int(current_limit or 1))
|
||
available_slots = max(1, int(available_slots or 0))
|
||
live_active = max(
|
||
0,
|
||
int(self._get_active_domain_threads() or 0) if live_active is None else int(live_active or 0),
|
||
)
|
||
claim_cap_multiplier = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_CLAIM_CAP_MULTIPLIER", "1") or 1),
|
||
)
|
||
configured_claim_floor = int(os.getenv("DOMAINCHECK_CLAIM_BATCH_FLOOR", "0") or 0)
|
||
configured_claim_ceiling = int(os.getenv("DOMAINCHECK_CLAIM_BATCH_CEIL", "0") or 0)
|
||
# Default to a full-thread claim window so large worker pools can
|
||
# actually fill their slots. Operators can still shrink this via env
|
||
# overrides if they explicitly want a tighter rolling buffer.
|
||
default_claim_floor = normalized_limit
|
||
claim_floor = max(4, configured_claim_floor if configured_claim_floor > 0 else default_claim_floor)
|
||
claim_ceiling = max(
|
||
claim_floor,
|
||
configured_claim_ceiling if configured_claim_ceiling > 0 else normalized_limit,
|
||
)
|
||
warm_window = max(
|
||
claim_floor,
|
||
live_active + max(32, normalized_limit // 4),
|
||
)
|
||
return max(
|
||
1,
|
||
min(
|
||
available_slots,
|
||
normalized_limit * claim_cap_multiplier,
|
||
max(claim_floor, min(claim_ceiling, warm_window)),
|
||
),
|
||
)
|
||
|
||
def _complete_detection(self, domain_id, domain_name, domain):
|
||
register_status = int((domain or {}).get('register_status', 0) or 0)
|
||
use_status = int((domain or {}).get('use_status', 0) or 0)
|
||
expire_date = (domain or {}).get('expire_date')
|
||
self._enqueue_domain_completion(
|
||
domain_id,
|
||
register_status=register_status,
|
||
use_status=use_status,
|
||
expire_date=expire_date,
|
||
)
|
||
if use_status == 0 and register_status == REGISTER_STATUS_AVAILABLE and expire_date:
|
||
logger.info(f"域名 {domain_name} 满足条件,已将expire_date置空")
|
||
domain['expire_date'] = None
|
||
if register_status == REGISTER_STATUS_AVAILABLE:
|
||
logger.info(f"域名 {domain_name} 满足条件,已设置为待人工复核")
|
||
|
||
def _should_run_jucha(self, domain):
|
||
return self.detect_options.get('detect_jucha', False) and domain.get('jucha_status', 0) != THIRD_PARTY_STATUS_DONE
|
||
|
||
def _should_run_juziseo(self, domain):
|
||
return self.detect_options.get('detect_juziseo', False) and domain.get('juziseo_status', 0) != THIRD_PARTY_STATUS_DONE
|
||
|
||
def _get_detect_execution_order(self):
|
||
default_order = [
|
||
'detect_register',
|
||
'detect_baidu_site',
|
||
'detect_360_site',
|
||
'detect_chinaz',
|
||
'detect_aizhan',
|
||
'detect_wayback',
|
||
'detect_jucha',
|
||
'detect_juziseo',
|
||
]
|
||
configured_order = self.detect_options.get('detect_order') or []
|
||
normalized_order = [key for key in configured_order if key in default_order]
|
||
for key in default_order:
|
||
if key not in normalized_order:
|
||
normalized_order.append(key)
|
||
free_keys = {
|
||
'detect_register',
|
||
'detect_baidu_site',
|
||
'detect_360_site',
|
||
'detect_chinaz',
|
||
'detect_aizhan',
|
||
'detect_wayback',
|
||
}
|
||
paid_keys = {'detect_jucha', 'detect_juziseo'}
|
||
enabled_order = [key for key in normalized_order if self.detect_options.get(key, False)]
|
||
free_order = [key for key in enabled_order if key in free_keys]
|
||
paid_order = [key for key in enabled_order if key in paid_keys]
|
||
return free_order + paid_order
|
||
|
||
def _run_detect_register(self, domain_id, domain, domain_name):
|
||
is_ykj = domain.get('source_type', 0) == 1
|
||
if is_ykj:
|
||
logger.info(f"一口价域名跳过注册状态检测: {domain_name}")
|
||
if self._is_current_single_step_task():
|
||
self._cache_single_step_result_payload(
|
||
domain_id,
|
||
'register_status',
|
||
self._step_result_payload(
|
||
ok=True,
|
||
state="passed",
|
||
message="一口价域名跳过注册状态检测",
|
||
source_type=domain.get('source_type'),
|
||
skipped=True,
|
||
),
|
||
)
|
||
return True
|
||
logger.info(f"检测注册状态: {domain_name}")
|
||
tried_proxy_keys = set()
|
||
started_at = time.perf_counter()
|
||
attempt_count = 0
|
||
last_reason = ""
|
||
# 高并发下如果注册检测仍然直连优先,会先把 controller 本机的外连资源打爆,
|
||
# 导致大量线程卡在 direct request error,而不是尽快让代理承接流量。
|
||
# 这里默认改成“代理优先,直连兜底”;只有显式打开环境变量时才允许直连优先。
|
||
register_direct_first = (
|
||
self.allow_direct_connection()
|
||
and str(os.getenv("DOMAINCHECK_REGISTER_DIRECT_FIRST", "0") or "0").strip().lower()
|
||
not in {"", "0", "false", "no", "off"}
|
||
)
|
||
direct_attempt_consumed = False
|
||
while True:
|
||
budget_exhausted, budget_reason = self._check_proxy_step_retry_budget(
|
||
'注册状态检测',
|
||
domain_name,
|
||
started_at,
|
||
attempt_count,
|
||
tried_proxy_keys,
|
||
last_reason=last_reason,
|
||
)
|
||
if budget_exhausted:
|
||
self._mark_detection_failed(domain_id, domain_name, '注册状态检测', budget_reason)
|
||
return False
|
||
|
||
proxy = None
|
||
if register_direct_first and not direct_attempt_consumed:
|
||
direct_attempt_consumed = True
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
'注册状态检测',
|
||
'proxy_direct_fallback',
|
||
proxy_mode='direct_first',
|
||
retry_attempts=attempt_count,
|
||
)
|
||
else:
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '注册状态检测', excluded_proxy_keys=tried_proxy_keys)
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
if proxy:
|
||
tried_proxy_keys.add(self._proxy_key(proxy))
|
||
attempt_count += 1
|
||
try:
|
||
tld = domain_name.split('.')[-1]
|
||
remaining_budget_seconds = self._remaining_proxy_step_retry_budget_seconds(
|
||
'注册状态检测',
|
||
started_at,
|
||
reserve_seconds=0.2,
|
||
)
|
||
status, expire_date = register.check_register(
|
||
domain_name,
|
||
tld,
|
||
proxy if proxy else None,
|
||
budget_seconds=remaining_budget_seconds,
|
||
)
|
||
if status != -1:
|
||
if proxy:
|
||
self._clear_proxy_failure(proxy)
|
||
self._clear_proxy_source_failure(proxy)
|
||
normalized_expire_date = expire_date
|
||
if expire_date:
|
||
from datetime import datetime, timedelta
|
||
try:
|
||
date_part = expire_date.split(' ')[0]
|
||
expire_date_obj = datetime.strptime(date_part, "%Y-%m-%d")
|
||
normalized_expire_date = (expire_date_obj + timedelta(days=75)).strftime("%Y-%m-%d")
|
||
except Exception as e:
|
||
logger.error(f"处理过期日期失败: {e}")
|
||
normalized_expire_date = expire_date
|
||
if self._is_current_single_step_task():
|
||
self._cache_single_step_result_payload(
|
||
domain_id,
|
||
'register_status',
|
||
self._step_result_payload(
|
||
ok=True,
|
||
state="passed",
|
||
message="success",
|
||
register_status=int(status),
|
||
expire_date=normalized_expire_date or "",
|
||
),
|
||
)
|
||
else:
|
||
self.db.update_domain_register_result(domain_id, status, normalized_expire_date)
|
||
domain['register_status'] = status
|
||
if normalized_expire_date:
|
||
domain['expire_date'] = normalized_expire_date
|
||
logger.info(f"注册状态检测完成: {domain_name}, 状态: {status}, 过期日期: {expire_date}")
|
||
return True
|
||
logger.warning(f"注册状态检测失败,不更新数据: {domain_name}")
|
||
last_reason = 'register lookup failed'
|
||
if self._should_rotate_proxy_on_error(proxy, 'register lookup failed', '注册状态检测', domain_name):
|
||
continue
|
||
self._mark_detection_failed(domain_id, domain_name, '注册状态检测', '注册状态检测返回未知状态')
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"注册状态检测失败: {domain_name}, 错误: {e}")
|
||
last_reason = str(e)
|
||
if proxy is None and self.proxy_config.get('proxy_enable', False):
|
||
logger.warning(f"注册状态检测直连失败,切换代理继续: {domain_name}, 原因: {e}")
|
||
continue
|
||
if self._should_rotate_proxy_on_error(proxy, str(e), '注册状态检测', domain_name):
|
||
continue
|
||
self._mark_detection_failed(domain_id, domain_name, '注册状态检测', str(e))
|
||
return False
|
||
return True
|
||
|
||
def _run_detect_wayback(self, domain_id, domain_name, sensitive_words, step_payload=None):
|
||
logger.info(f"检测时光机: {domain_name}")
|
||
try:
|
||
normalized_step_payload = dict(step_payload or {}) if isinstance(step_payload, dict) else {}
|
||
recent_years = normalized_step_payload.get("wayback_recent_years")
|
||
stop_on_first_hit = normalized_step_payload.get("wayback_stop_on_first_hit")
|
||
wayback_result = self.wayback_detector.scan_snapshots(
|
||
domain_name,
|
||
sensitive_words=sensitive_words,
|
||
stop_on_first_hit=bool(True if stop_on_first_hit is None else stop_on_first_hit),
|
||
recent_years=int(recent_years) if str(recent_years or "").strip() else None,
|
||
)
|
||
snapshot_years = wayback_result.get('snapshot_years') or []
|
||
is_step_task = self._is_current_single_step_task()
|
||
backlink_count = int(wayback_result.get('backlink_count', 0) or 0)
|
||
if not is_step_task:
|
||
self.db.update_domain_wayback_summary(
|
||
domain_id,
|
||
years=",".join(str(year) for year in snapshot_years) if snapshot_years else None,
|
||
backlink_count=backlink_count,
|
||
)
|
||
self._upsert_json_detection(
|
||
domain_id,
|
||
backlink_count_gt_10=bool(wayback_result.get('backlink_count_gt_10')),
|
||
)
|
||
if wayback_result.get('has_sensitive_content'):
|
||
reason = f"时光机快照命中敏感词: {wayback_result.get('matched_word', '')}".strip()
|
||
self._record_step_result(domain_id, 'wayback_info', ok=False, state="blacklisted", message=reason)
|
||
self._mark_blacklisted(domain_id, domain_name, reason)
|
||
return False
|
||
if int(wayback_result.get('request_error_count', 0) or 0) > 0 and int(wayback_result.get('checked_snapshot_count', 0) or 0) == 0:
|
||
reason = "时光机检测失败,快照索引请求异常: " + " | ".join(
|
||
(wayback_result.get('request_errors') or [])[:3]
|
||
)
|
||
self._mark_detection_degraded(domain_id, domain_name, '时光机检测', reason, 'wayback_info')
|
||
return True
|
||
if int(wayback_result.get('failed_snapshot_count', 0) or 0) > 0 and int(wayback_result.get('fetched_snapshot_count', 0) or 0) == 0:
|
||
reason = (
|
||
f"时光机检测失败,未成功抓取任何快照;失败次数 {wayback_result.get('failed_snapshot_count', 0)}"
|
||
)
|
||
self._mark_detection_degraded(domain_id, domain_name, '时光机检测', reason, 'wayback_info')
|
||
return True
|
||
self._record_step_result(
|
||
domain_id,
|
||
'wayback_info',
|
||
ok=True,
|
||
state="passed",
|
||
message="success",
|
||
checked_snapshot_count=int(wayback_result.get('checked_snapshot_count', 0) or 0),
|
||
fetched_snapshot_count=int(wayback_result.get('fetched_snapshot_count', 0) or 0),
|
||
failed_snapshot_count=int(wayback_result.get('failed_snapshot_count', 0) or 0),
|
||
backlink_count=int(wayback_result.get('backlink_count', 0) or 0),
|
||
backlink_count_gt_10=bool(wayback_result.get('backlink_count_gt_10')),
|
||
snapshot_years=list(snapshot_years),
|
||
)
|
||
logger.info(
|
||
"时光机检测完成: %s, 检查快照 %s 个, 成功抓取 %s 个, 失败 %s 个, 最大友链 %s, 耗时 %ss"
|
||
% (
|
||
domain_name,
|
||
wayback_result.get('checked_snapshot_count', 0),
|
||
wayback_result.get('fetched_snapshot_count', 0),
|
||
wayback_result.get('failed_snapshot_count', 0),
|
||
wayback_result.get('backlink_count', 0),
|
||
wayback_result.get('elapsed_seconds', 0),
|
||
)
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"时光机检测失败: {domain_name}, 错误: {e}")
|
||
self._mark_detection_degraded(domain_id, domain_name, '时光机检测', str(e), 'wayback_info')
|
||
return True
|
||
return True
|
||
|
||
def _run_detect_chinaz(self, domain_id, domain_name, sensitive_words):
|
||
logger.info(f"检测站长之家: {domain_name}")
|
||
tried_proxy_keys = set()
|
||
started_at = time.perf_counter()
|
||
attempt_count = 0
|
||
last_reason = ""
|
||
while True:
|
||
budget_exhausted, budget_reason = self._check_proxy_step_retry_budget(
|
||
'站长之家检测',
|
||
domain_name,
|
||
started_at,
|
||
attempt_count,
|
||
tried_proxy_keys,
|
||
last_reason=last_reason,
|
||
)
|
||
if budget_exhausted:
|
||
self._mark_detection_degraded(domain_id, domain_name, '站长之家检测', budget_reason, 'chinaz_info')
|
||
return True
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '站长之家检测', excluded_proxy_keys=tried_proxy_keys)
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
if proxy:
|
||
tried_proxy_keys.add(self._proxy_key(proxy))
|
||
attempt_count += 1
|
||
try:
|
||
remaining_budget_seconds = self._remaining_proxy_step_retry_budget_seconds(
|
||
'站长之家检测',
|
||
started_at,
|
||
reserve_seconds=0.2,
|
||
)
|
||
logger.info(
|
||
f"站长之家调用边界: domain={domain_name} | attempt={attempt_count} "
|
||
f"| module={getattr(chinaz, '__file__', 'unknown')} | proxy={'yes' if proxy else 'no'}"
|
||
)
|
||
success, message, seo_data = chinaz.check_title(
|
||
domain_name,
|
||
sensitive_words,
|
||
proxy,
|
||
budget_seconds=remaining_budget_seconds,
|
||
)
|
||
logger.info(
|
||
f"站长之家返回边界: domain={domain_name} | attempt={attempt_count} "
|
||
f"| success={success} | message={message}"
|
||
)
|
||
if not success:
|
||
logger.warning(f"站长之家检测未通过: {domain_name}, 原因: {message}")
|
||
last_reason = str(message or "")
|
||
if self._should_blacklist_result(message):
|
||
self._record_step_result(domain_id, 'chinaz_info', ok=False, state="blacklisted", message=message)
|
||
self._mark_blacklisted(domain_id, domain_name, message)
|
||
return False
|
||
if self._should_rotate_proxy_on_error(proxy, message, '站长之家检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(message):
|
||
self._mark_detection_degraded(domain_id, domain_name, '站长之家检测', message or 'external dependency issue', 'chinaz_info')
|
||
return True
|
||
self._mark_detection_rejected(domain_id, domain_name, '站长之家检测', message or 'business rejected', 'chinaz_info')
|
||
return False
|
||
self._record_step_result(domain_id, 'chinaz_info', ok=True, state="passed", message=message or "success", seo=seo_data)
|
||
logger.info(f"站长之家检测完成: {domain_name}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"站长之家检测失败: {domain_name}, 错误: {e}")
|
||
last_reason = str(e)
|
||
if self._should_rotate_proxy_on_error(proxy, str(e), '站长之家检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(e):
|
||
self._mark_detection_degraded(domain_id, domain_name, '站长之家检测', str(e), 'chinaz_info')
|
||
return True
|
||
self._mark_detection_failed(domain_id, domain_name, '站长之家检测', str(e), 'chinaz_info')
|
||
return False
|
||
return True
|
||
|
||
def _run_detect_aizhan(self, domain_id, domain_name, sensitive_words):
|
||
logger.info(f"检测爱站网: {domain_name}")
|
||
tried_proxy_keys = set()
|
||
started_at = time.perf_counter()
|
||
attempt_count = 0
|
||
last_reason = ""
|
||
while True:
|
||
budget_exhausted, budget_reason = self._check_proxy_step_retry_budget(
|
||
'爱站网检测',
|
||
domain_name,
|
||
started_at,
|
||
attempt_count,
|
||
tried_proxy_keys,
|
||
last_reason=last_reason,
|
||
)
|
||
if budget_exhausted:
|
||
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', budget_reason, 'aizhan_info')
|
||
return True
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '爱站网检测', excluded_proxy_keys=tried_proxy_keys)
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
if proxy:
|
||
tried_proxy_keys.add(self._proxy_key(proxy))
|
||
attempt_count += 1
|
||
try:
|
||
remaining_budget_seconds = self._remaining_proxy_step_retry_budget_seconds(
|
||
'爱站网检测',
|
||
started_at,
|
||
reserve_seconds=0.2,
|
||
)
|
||
success, message = aizhan.check_aizhan(
|
||
domain_name,
|
||
sensitive_words,
|
||
proxy,
|
||
budget_seconds=remaining_budget_seconds,
|
||
)
|
||
if not success:
|
||
logger.warning(f"爱站网检测未通过: {domain_name}, 原因: {message}")
|
||
last_reason = str(message or "")
|
||
if self._should_blacklist_result(message):
|
||
self._record_step_result(domain_id, 'aizhan_info', ok=False, state="blacklisted", message=message)
|
||
self._mark_blacklisted(domain_id, domain_name, message)
|
||
return False
|
||
if self._should_rotate_proxy_on_error(proxy, message, '爱站网检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(message):
|
||
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', message or 'external dependency issue', 'aizhan_info')
|
||
return True
|
||
self._mark_detection_rejected(domain_id, domain_name, '爱站网检测', message or 'business rejected', 'aizhan_info')
|
||
return False
|
||
self._record_step_result(domain_id, 'aizhan_info', ok=True, state="passed", message=message or "success")
|
||
logger.info(f"爱站网检测完成: {domain_name}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"爱站网检测失败: {domain_name}, 错误: {e}")
|
||
last_reason = str(e)
|
||
if self._should_rotate_proxy_on_error(proxy, str(e), '爱站网检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(e):
|
||
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', str(e), 'aizhan_info')
|
||
return True
|
||
self._mark_detection_failed(domain_id, domain_name, '爱站网检测', str(e), 'aizhan_info')
|
||
return False
|
||
return True
|
||
|
||
def _run_detect_baidu(self, domain_id, domain_name, sensitive_words):
|
||
logger.info(f"检测百度: {domain_name}")
|
||
tried_proxy_keys = set()
|
||
started_at = time.perf_counter()
|
||
attempt_count = 0
|
||
last_reason = ""
|
||
while True:
|
||
budget_exhausted, budget_reason = self._check_proxy_step_retry_budget(
|
||
'百度site检测',
|
||
domain_name,
|
||
started_at,
|
||
attempt_count,
|
||
tried_proxy_keys,
|
||
last_reason=last_reason,
|
||
)
|
||
if budget_exhausted:
|
||
self._mark_detection_degraded(domain_id, domain_name, '百度site检测', budget_reason, 'baidu_site')
|
||
return True
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '百度site检测', excluded_proxy_keys=tried_proxy_keys)
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
if proxy:
|
||
tried_proxy_keys.add(self._proxy_key(proxy))
|
||
attempt_count += 1
|
||
try:
|
||
remaining_budget_seconds = self._remaining_proxy_step_retry_budget_seconds(
|
||
'百度site检测',
|
||
started_at,
|
||
reserve_seconds=0.2,
|
||
)
|
||
success, message = baidu.check_site(
|
||
domain_name,
|
||
sensitive_words,
|
||
proxy,
|
||
budget_seconds=remaining_budget_seconds,
|
||
)
|
||
if not success:
|
||
logger.warning(f"百度site检测未通过: {domain_name}, 原因: {message}")
|
||
last_reason = str(message or "")
|
||
if self._should_blacklist_result(message):
|
||
self._record_step_result(domain_id, 'baidu_site', ok=False, state="blacklisted", message=message)
|
||
self._mark_blacklisted(domain_id, domain_name, message)
|
||
return False
|
||
if self._should_retry_external_issue(proxy, message, '百度site检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(message):
|
||
self._mark_detection_degraded(domain_id, domain_name, '百度site检测', message, 'baidu_site')
|
||
return True
|
||
self._mark_detection_rejected(domain_id, domain_name, '百度site检测', message or 'business rejected', 'baidu_site')
|
||
return False
|
||
self._record_step_result(domain_id, 'baidu_site', ok=True, state="passed", message=message or "success")
|
||
logger.info(f"百度site检测完成: {domain_name}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"百度site检测失败: {domain_name}, 错误: {e}")
|
||
last_reason = str(e)
|
||
if self._should_retry_external_issue(proxy, str(e), '百度site检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(e):
|
||
self._mark_detection_degraded(domain_id, domain_name, '百度site检测', str(e), 'baidu_site')
|
||
return True
|
||
self._mark_detection_failed(domain_id, domain_name, '百度site检测', str(e), 'baidu_site')
|
||
return False
|
||
return True
|
||
|
||
def _run_detect_360(self, domain_id, domain_name, sensitive_words):
|
||
logger.info(f"检测360: {domain_name}")
|
||
tried_proxy_keys = set()
|
||
started_at = time.perf_counter()
|
||
attempt_count = 0
|
||
last_reason = ""
|
||
while True:
|
||
budget_exhausted, budget_reason = self._check_proxy_step_retry_budget(
|
||
'360检测',
|
||
domain_name,
|
||
started_at,
|
||
attempt_count,
|
||
tried_proxy_keys,
|
||
last_reason=last_reason,
|
||
)
|
||
if budget_exhausted:
|
||
self._mark_detection_degraded(domain_id, domain_name, '360检测', budget_reason, 'qihu360_site')
|
||
return True
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '360检测', excluded_proxy_keys=tried_proxy_keys)
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
if proxy:
|
||
tried_proxy_keys.add(self._proxy_key(proxy))
|
||
attempt_count += 1
|
||
try:
|
||
remaining_budget_seconds = self._remaining_proxy_step_retry_budget_seconds(
|
||
'360检测',
|
||
started_at,
|
||
reserve_seconds=0.2,
|
||
)
|
||
passed, message = c360.check_domain(
|
||
domain_name,
|
||
sensitive_words,
|
||
proxy,
|
||
budget_seconds=remaining_budget_seconds,
|
||
)
|
||
if not passed:
|
||
logger.warning(f"360检测未通过: {domain_name}, 原因: {message}")
|
||
last_reason = str(message or "")
|
||
if self._should_blacklist_result(message):
|
||
self._record_step_result(domain_id, 'qihu360_site', ok=False, state="blacklisted", message=message)
|
||
self._mark_blacklisted(domain_id, domain_name, message)
|
||
return False
|
||
if self._should_rotate_proxy_on_error(proxy, message, '360检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(message):
|
||
self._mark_detection_degraded(domain_id, domain_name, '360检测', message or 'external dependency issue', 'qihu360_site')
|
||
return True
|
||
self._mark_detection_rejected(domain_id, domain_name, '360检测', message or 'business rejected', 'qihu360_site')
|
||
return False
|
||
self._record_step_result(domain_id, 'qihu360_site', ok=True, state="passed", message=message or "success")
|
||
logger.info(f"360检测完成: {domain_name}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"360检测失败: {domain_name}, 错误: {e}")
|
||
last_reason = str(e)
|
||
if self._should_rotate_proxy_on_error(proxy, str(e), '360检测', domain_name):
|
||
continue
|
||
if self._is_external_dependency_issue(e):
|
||
self._mark_detection_degraded(domain_id, domain_name, '360检测', str(e), 'qihu360_site')
|
||
return True
|
||
self._mark_detection_failed(domain_id, domain_name, '360检测', str(e), 'qihu360_site')
|
||
return False
|
||
return True
|
||
|
||
def _run_detect_jucha(self, domain_id, domain, domain_name):
|
||
if not self._should_run_jucha(domain):
|
||
return True
|
||
self._init_optional_detectors()
|
||
if self.jc_instance is None:
|
||
logger.warning("聚查检测器未初始化,跳过本次聚查检测")
|
||
return True
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '聚查检测')
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
self.jc_instance.session.proxies = proxy or {}
|
||
beian_update_payload = {}
|
||
logger.info(f"检测聚查WHOIS: {domain_name}")
|
||
try:
|
||
success, message, whois_info = self.jc_instance.check_whois_domain(domain_name)
|
||
if not success:
|
||
logger.warning(f"聚查WHOIS查询失败: {domain_name}, 原因: {message}")
|
||
else:
|
||
logger.info(f"WHOIS查询结果: {success}, {message}, {whois_info}")
|
||
if 'clientHold' in whois_info or 'serverHold' in whois_info:
|
||
logger.warning(f"聚查WHOIS检测未通过: {domain_name}, 原因: 域名状态包含clientHold或serverHold")
|
||
self._mark_blacklisted(domain_id, domain_name, '域名状态包含clientHold或serverHold')
|
||
return False
|
||
logger.info(f"聚查WHOIS检测完成: {domain_name}")
|
||
except Exception as e:
|
||
logger.error(f"聚查WHOIS检测失败: {domain_name}, 错误: {e}")
|
||
|
||
logger.info(f"检测聚查备案: {domain_name}")
|
||
try:
|
||
success, message, beian_info = self.jc_instance.beian_check_domain(domain_name)
|
||
if not success:
|
||
logger.warning(f"聚查备案查询失败: {domain_name}, 原因: {message}")
|
||
else:
|
||
logger.info(f"备案查询结果: {success}, {message}, {beian_info}")
|
||
if isinstance(beian_info, tuple) and len(beian_info) == 4:
|
||
beian_time, company_type, website_url, has_beian = beian_info
|
||
has_beian_flag = 2 if has_beian == '当前存在' else 3
|
||
beian_year = None
|
||
if beian_time:
|
||
try:
|
||
beian_year = beian_time.split('-')[0]
|
||
except Exception:
|
||
pass
|
||
beian_update_payload = {
|
||
"company_type": company_type,
|
||
"website_url": website_url,
|
||
"has_beian": has_beian_flag,
|
||
"beian_year": beian_year,
|
||
}
|
||
logger.info(f"聚查备案检测完成: {domain_name}")
|
||
except Exception as e:
|
||
logger.error(f"聚查备案检测失败: {domain_name}, 错误: {e}")
|
||
|
||
logger.info(f"检测聚查拦截: {domain_name}")
|
||
try:
|
||
success, message, safe_info = self.jc_instance.safe_check_domain(domain_name)
|
||
if not success:
|
||
logger.warning(f"聚查安全检测失败: {domain_name}, 原因: {message}")
|
||
else:
|
||
logger.info(f"安全检测结果: {success}, {message}, {safe_info}")
|
||
check_items = ['QQ检测', '微信检测', '抖音检测', '被墙检测', '百度检测', '谷歌检测', '火狐检测']
|
||
blacklist_reason = []
|
||
if isinstance(safe_info, tuple):
|
||
for i, item in enumerate(safe_info):
|
||
if isinstance(item, tuple):
|
||
if len(item) >= 1 and item[0] == 3 and i < len(check_items):
|
||
blacklist_reason.append(f"{check_items[i]}: 拦截")
|
||
elif len(item) >= 2 and item[0] == 2 and '拦截' in item[1] and i < len(check_items):
|
||
blacklist_reason.append(f"{check_items[i]}: 拦截")
|
||
if blacklist_reason:
|
||
reason_str = ";".join(blacklist_reason)
|
||
logger.warning(f"聚查安全检测未通过: {domain_name}, 原因: {reason_str}")
|
||
self._mark_blacklisted(domain_id, domain_name, reason_str)
|
||
return False
|
||
logger.info(f"聚查拦截检测完成: {domain_name}")
|
||
except Exception as e:
|
||
logger.error(f"聚查拦截检测失败: {domain_name}, 错误: {e}")
|
||
finally:
|
||
self.db.update_domain_beian_info_and_mark_jucha_detected(
|
||
domain_id,
|
||
company_type=beian_update_payload.get("company_type"),
|
||
website_url=beian_update_payload.get("website_url"),
|
||
has_beian=beian_update_payload.get("has_beian"),
|
||
beian_year=beian_update_payload.get("beian_year"),
|
||
)
|
||
return True
|
||
|
||
def _run_detect_juziseo(self, domain_id, domain, domain_name, sensitive_words):
|
||
if not self._should_run_juziseo(domain):
|
||
return True
|
||
self._init_optional_detectors()
|
||
if self.juziseo_instance is None:
|
||
logger.warning("桔子SEO检测器未初始化,跳过本次桔子检测")
|
||
return True
|
||
proxy = self._get_proxy_for_step(domain_id, domain_name, '桔子检测')
|
||
if proxy == '__NO_PROXY__':
|
||
return False
|
||
self.juziseo_instance.session.proxies = proxy or {}
|
||
logger.info(f"检测桔子历史: {domain_name}")
|
||
try:
|
||
success, message = self.juziseo_instance.check_history(domain_name, sensitive_words)
|
||
if not success:
|
||
logger.warning(f"桔子历史检测未通过: {domain_name}, 原因: {message}")
|
||
self._mark_blacklisted(domain_id, domain_name, message)
|
||
return False
|
||
logger.info(f"桔子历史检测完成: {domain_name}")
|
||
except Exception as e:
|
||
logger.error(f"桔子历史检测失败: {domain_name}, 错误: {e}")
|
||
|
||
logger.info(f"检测桔子外链: {domain_name}")
|
||
try:
|
||
success, message = self.juziseo_instance.check_external_link(domain_name, sensitive_words)
|
||
if not success:
|
||
logger.warning(f"桔子外链检测未通过: {domain_name}, 原因: {message}")
|
||
self._mark_blacklisted(domain_id, domain_name, message)
|
||
return False
|
||
logger.info(f"桔子外链检测完成: {domain_name}")
|
||
except Exception as e:
|
||
logger.error(f"桔子外链检测失败: {domain_name}, 错误: {e}")
|
||
finally:
|
||
self.db.mark_juziseo_detected(domain_id)
|
||
return True
|
||
|
||
def _upsert_json_detection(self, domain_id, **payload):
|
||
existing = self.db.fetch_one("SELECT id FROM domain_detections WHERE domain_id = %s", (domain_id,))
|
||
columns = []
|
||
params = []
|
||
for key, value in payload.items():
|
||
columns.append(key)
|
||
params.append(json.dumps(value) if isinstance(value, (dict, list, tuple)) else value)
|
||
if not columns:
|
||
return True
|
||
if existing:
|
||
assignments = ", ".join([f"{column} = %s" for column in columns] + ["update_time = CURRENT_TIMESTAMP"])
|
||
params.append(domain_id)
|
||
sql = f"UPDATE domain_detections SET {assignments} WHERE domain_id = %s"
|
||
else:
|
||
sql = f"INSERT INTO domain_detections (domain_id, {', '.join(columns)}) VALUES (%s, {', '.join(['%s'] * len(columns))})"
|
||
params = [domain_id] + params
|
||
return self.db.execute(sql, tuple(params))
|
||
|
||
def detect_domain(self, domain_id, domain, job_context=None):
|
||
"""
|
||
检测单个域名
|
||
|
||
:param domain_id: 域名ID
|
||
:param domain: 域名对象(包含id、domain、source_type等字段)
|
||
"""
|
||
import threading
|
||
thread_id = threading.current_thread().ident
|
||
|
||
# 从域名对象中获取域名字符串
|
||
domain_name = domain.get('domain', '')
|
||
logger.info(f"线程 {thread_id} 开始检测域名: {domain_name}")
|
||
|
||
job_context = job_context or {}
|
||
job_item_id = job_context.get('job_item_id')
|
||
job_id = job_context.get('job_id')
|
||
claim_token = job_context.get('claim_token')
|
||
task_mode = str(job_context.get('task_mode') or '').strip() or 'domain_pipeline'
|
||
step_payload = job_context.get('step_payload') if isinstance(job_context.get('step_payload'), dict) else {}
|
||
requested_step_code = str(step_payload.get('step_code') or job_context.get('step_code') or '').strip()
|
||
is_step_task = bool(requested_step_code)
|
||
cycle_token = str(job_context.get('cycle_token') or self.current_cycle_token or '').strip()
|
||
job_code = str(job_context.get('job_code') or self.current_job_code or '').strip()
|
||
domain_started_at = time.perf_counter()
|
||
self._set_current_task_context(
|
||
domain_id=domain_id,
|
||
is_step_task=is_step_task,
|
||
task_mode=task_mode,
|
||
job_item_id=job_item_id,
|
||
)
|
||
current_active_threads = self._change_active_domain_threads(1)
|
||
self._note_domain_started()
|
||
if self.detecting and (
|
||
current_active_threads <= 16
|
||
or current_active_threads % 10 == 0
|
||
or current_active_threads >= max(1, int(getattr(self, "thread_count", 1) or 1))
|
||
):
|
||
self._mark_detection_phase(
|
||
"running",
|
||
f"当前实际线程数量: {current_active_threads}/{self.thread_count}",
|
||
active_threads=current_active_threads,
|
||
max_threads=self.thread_count,
|
||
)
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
"domain",
|
||
"started",
|
||
job_item_id=job_item_id,
|
||
thread_id=thread_id,
|
||
cycle_token=cycle_token,
|
||
job_code=job_code,
|
||
)
|
||
|
||
def finalize_job_item(final_status, message="", result_payload=None):
|
||
self._note_domain_result()
|
||
if not job_item_id or not claim_token:
|
||
return
|
||
try:
|
||
event_payload = {
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
"status": final_status,
|
||
"cycle_token": cycle_token,
|
||
"job_code": job_code,
|
||
}
|
||
self._enqueue_job_finalization(
|
||
job_item_id=job_item_id,
|
||
claim_token=claim_token,
|
||
final_status=final_status,
|
||
message=message or final_status,
|
||
result_payload=result_payload,
|
||
job_id=job_id,
|
||
node_code=config.NODE_CODE,
|
||
event_type="" if is_step_task else f"domain_{final_status}",
|
||
event_level='error' if final_status == 'failed' else 'info',
|
||
event_message=message or f"{domain_name} -> {final_status}",
|
||
event_payload=event_payload,
|
||
)
|
||
except Exception as finalize_error:
|
||
logger.warning(f"回写任务项状态失败: {domain_name}, 错误: {finalize_error}")
|
||
|
||
def renew_job_item_lease(detail=""):
|
||
if is_step_task or not job_item_id or not claim_token:
|
||
return
|
||
try:
|
||
self.db.renew_detect_job_item_lease(
|
||
job_item_id,
|
||
claim_token,
|
||
# 租约续期必须和线程数解耦;线程数越高不代表单任务应该锁更久,
|
||
# 否则节点重启或线程异常后会把大量 running 项锁死数天。
|
||
lease_seconds=900,
|
||
)
|
||
except Exception as renew_error:
|
||
logger.warning(f"续租任务项失败: {domain_name}, 错误: {renew_error}")
|
||
|
||
try:
|
||
# 检查是否需要停止
|
||
if not self.running:
|
||
logger.info(f"检测已停止,跳过域名: {domain_name}")
|
||
finalize_job_item('failed', 'Worker 已停止,任务未执行')
|
||
return
|
||
|
||
logger.info(f"开始检测域名: {domain_name}")
|
||
if not is_step_task:
|
||
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_RUNNING)
|
||
self._set_task_detect_status(DETECT_STATUS_RUNNING)
|
||
if job_item_id and claim_token:
|
||
self._enqueue_running_mark(job_item_id, claim_token)
|
||
renew_job_item_lease("domain_started")
|
||
if not is_step_task:
|
||
self.db.append_detect_run_event(
|
||
job_id,
|
||
job_item_id,
|
||
config.NODE_CODE,
|
||
event_type='domain_started',
|
||
message=f"开始检测域名: {domain_name}",
|
||
payload={
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
"cycle_token": cycle_token,
|
||
"job_code": job_code,
|
||
},
|
||
)
|
||
self._sync_worker_log_event(
|
||
f"开始检测域名: {domain_name}",
|
||
payload={
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
},
|
||
mode='full',
|
||
job_item_id=job_item_id,
|
||
)
|
||
|
||
# 使用共享的敏感词列表
|
||
sensitive_words = self.sensitive_words
|
||
execution_order = self._get_detect_execution_order()
|
||
|
||
parallel_free_steps_enabled = bool(
|
||
int(os.getenv("DOMAINCHECK_PARALLEL_FREE_STEPS", "1") or 1)
|
||
)
|
||
parallel_free_step_keys = (
|
||
'detect_baidu_site',
|
||
'detect_360_site',
|
||
'detect_chinaz',
|
||
'detect_aizhan',
|
||
)
|
||
parallel_free_step_set = set(parallel_free_step_keys)
|
||
parallel_free_step_workers = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PARALLEL_FREE_STEP_WORKERS", "1") or 1),
|
||
)
|
||
|
||
def execute_detect_step(detect_key):
|
||
step_name = self._step_display_name(detect_key)
|
||
renew_job_item_lease(detect_key)
|
||
step_started_at = time.perf_counter()
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"started",
|
||
job_item_id=job_item_id,
|
||
detect_key=detect_key,
|
||
)
|
||
step_ok = True
|
||
if detect_key == 'detect_register':
|
||
step_ok = self._run_detect_register(domain_id, domain, domain_name)
|
||
elif detect_key == 'detect_baidu_site':
|
||
step_ok = self._run_detect_baidu(domain_id, domain_name, sensitive_words)
|
||
elif detect_key == 'detect_360_site':
|
||
step_ok = self._run_detect_360(domain_id, domain_name, sensitive_words)
|
||
elif detect_key == 'detect_chinaz':
|
||
step_ok = self._run_detect_chinaz(domain_id, domain_name, sensitive_words)
|
||
elif detect_key == 'detect_aizhan':
|
||
step_ok = self._run_detect_aizhan(domain_id, domain_name, sensitive_words)
|
||
elif detect_key == 'detect_wayback':
|
||
step_ok = self._run_detect_wayback(
|
||
domain_id,
|
||
domain_name,
|
||
sensitive_words,
|
||
step_payload=step_payload,
|
||
)
|
||
elif detect_key == 'detect_jucha':
|
||
step_ok = self._run_detect_jucha(domain_id, domain, domain_name)
|
||
elif detect_key == 'detect_juziseo':
|
||
step_ok = self._run_detect_juziseo(domain_id, domain, domain_name, sensitive_words)
|
||
else:
|
||
logger.error(f"不支持的检测步骤: {detect_key}")
|
||
step_ok = False
|
||
|
||
step_elapsed_ms = int((time.perf_counter() - step_started_at) * 1000)
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_name,
|
||
"finished",
|
||
job_item_id=job_item_id,
|
||
detect_key=detect_key,
|
||
elapsed_ms=step_elapsed_ms,
|
||
ok=step_ok,
|
||
)
|
||
return {
|
||
"detect_key": detect_key,
|
||
"step_name": step_name,
|
||
"step_ok": bool(step_ok),
|
||
"step_elapsed_ms": step_elapsed_ms,
|
||
}
|
||
|
||
if is_step_task:
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
"domain",
|
||
"plan_ready",
|
||
job_item_id=job_item_id,
|
||
step_count=1,
|
||
detect_order=requested_step_code,
|
||
sensitive_word_count=len(sensitive_words),
|
||
task_mode=task_mode,
|
||
)
|
||
step_result = execute_detect_step(requested_step_code)
|
||
result_payload = self._build_single_step_job_result(
|
||
domain_id=domain_id,
|
||
detect_key=requested_step_code,
|
||
step_ok=step_result["step_ok"],
|
||
step_name=step_result["step_name"],
|
||
)
|
||
final_status, final_message = self._resolve_single_step_finalization(result_payload)
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_result["step_name"],
|
||
"single_step_finalized",
|
||
job_item_id=job_item_id,
|
||
detect_key=requested_step_code,
|
||
elapsed_ms=step_result["step_elapsed_ms"],
|
||
ok=final_status in {"completed", "blacklisted"},
|
||
final_status=final_status,
|
||
result_state=result_payload.get("state"),
|
||
)
|
||
finalize_job_item(final_status, final_message, result_payload=result_payload)
|
||
return
|
||
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
"domain",
|
||
"plan_ready",
|
||
job_item_id=job_item_id,
|
||
step_count=len(execution_order),
|
||
detect_order=",".join(execution_order),
|
||
sensitive_word_count=len(sensitive_words),
|
||
task_mode=task_mode,
|
||
)
|
||
|
||
step_index = 0
|
||
while step_index < len(execution_order):
|
||
detect_key = execution_order[step_index]
|
||
if parallel_free_steps_enabled and detect_key in parallel_free_step_set:
|
||
parallel_group = []
|
||
while step_index < len(execution_order) and execution_order[step_index] in parallel_free_step_set:
|
||
parallel_group.append(execution_order[step_index])
|
||
step_index += 1
|
||
if len(parallel_group) > 1 and parallel_free_step_workers > 1:
|
||
parallel_message = (
|
||
f"域名步骤并行组启动: domain={domain_name} | "
|
||
f"steps={','.join(parallel_group)} | workers={min(parallel_free_step_workers, len(parallel_group))}"
|
||
)
|
||
logger.info(parallel_message)
|
||
self._sync_worker_log_event(
|
||
parallel_message,
|
||
payload={
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
"steps": parallel_group,
|
||
"workers": min(parallel_free_step_workers, len(parallel_group)),
|
||
},
|
||
mode='full',
|
||
job_item_id=job_item_id,
|
||
)
|
||
parallel_results = {}
|
||
with ThreadPoolExecutor(max_workers=min(parallel_free_step_workers, len(parallel_group))) as executor:
|
||
future_map = {executor.submit(execute_detect_step, key): key for key in parallel_group}
|
||
for future in as_completed(future_map):
|
||
key = future_map[future]
|
||
parallel_results[key] = future.result()
|
||
for ordered_key in parallel_group:
|
||
step_result = parallel_results.get(ordered_key) or {
|
||
"detect_key": ordered_key,
|
||
"step_name": self._step_display_name(ordered_key),
|
||
"step_ok": False,
|
||
"step_elapsed_ms": 0,
|
||
}
|
||
if not step_result["step_ok"]:
|
||
final_status = self._resolve_current_task_final_status()
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_result["step_name"],
|
||
"aborted",
|
||
job_item_id=job_item_id,
|
||
detect_key=ordered_key,
|
||
elapsed_ms=step_result["step_elapsed_ms"],
|
||
ok=False,
|
||
final_status=final_status,
|
||
)
|
||
failure_reason_map = {
|
||
'detect_register': '注册状态检测未通过',
|
||
'detect_baidu_site': '百度检测未通过',
|
||
'detect_360_site': '360检测未通过',
|
||
'detect_chinaz': '站长之家检测未通过',
|
||
'detect_aizhan': '爱站检测未通过',
|
||
'detect_wayback': '时光机检测未通过',
|
||
'detect_jucha': '聚查检测未通过',
|
||
'detect_juziseo': '桔子检测未通过',
|
||
}
|
||
finalize_job_item(final_status, failure_reason_map.get(ordered_key, f'{step_result["step_name"]} 未通过'))
|
||
return
|
||
continue
|
||
if len(parallel_group) > 1:
|
||
serial_message = (
|
||
f"域名步骤串行组推进: domain={domain_name} | "
|
||
f"steps={','.join(parallel_group)} | workers=1"
|
||
)
|
||
logger.info(serial_message)
|
||
self._sync_worker_log_event(
|
||
serial_message,
|
||
payload={
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
"steps": parallel_group,
|
||
"workers": 1,
|
||
},
|
||
mode='full',
|
||
job_item_id=job_item_id,
|
||
)
|
||
for ordered_key in parallel_group:
|
||
step_result = execute_detect_step(ordered_key)
|
||
if not step_result["step_ok"]:
|
||
final_status = self._resolve_current_task_final_status()
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_result["step_name"],
|
||
"aborted",
|
||
job_item_id=job_item_id,
|
||
detect_key=ordered_key,
|
||
elapsed_ms=step_result["step_elapsed_ms"],
|
||
ok=False,
|
||
final_status=final_status,
|
||
)
|
||
failure_reason_map = {
|
||
'detect_register': '注册状态检测未通过',
|
||
'detect_baidu_site': '百度检测未通过',
|
||
'detect_360_site': '360检测未通过',
|
||
'detect_chinaz': '站长之家检测未通过',
|
||
'detect_aizhan': '爱站检测未通过',
|
||
'detect_wayback': '时光机检测未通过',
|
||
'detect_jucha': '聚查检测未通过',
|
||
'detect_juziseo': '桔子检测未通过',
|
||
}
|
||
finalize_job_item(final_status, failure_reason_map.get(ordered_key, f'{step_result["step_name"]} 未通过'))
|
||
return
|
||
continue
|
||
|
||
step_index += 1
|
||
step_result = execute_detect_step(detect_key)
|
||
if not step_result["step_ok"]:
|
||
final_status = self._resolve_current_task_final_status()
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
step_result["step_name"],
|
||
"aborted",
|
||
job_item_id=job_item_id,
|
||
detect_key=detect_key,
|
||
elapsed_ms=step_result["step_elapsed_ms"],
|
||
ok=False,
|
||
final_status=final_status,
|
||
)
|
||
failure_reason_map = {
|
||
'detect_register': '注册状态检测未通过',
|
||
'detect_baidu_site': '百度检测未通过',
|
||
'detect_360_site': '360检测未通过',
|
||
'detect_chinaz': '站长之家检测未通过',
|
||
'detect_aizhan': '爱站检测未通过',
|
||
'detect_wayback': '时光机检测未通过',
|
||
'detect_jucha': '聚查检测未通过',
|
||
'detect_juziseo': '桔子检测未通过',
|
||
}
|
||
finalize_job_item(final_status, failure_reason_map.get(detect_key, f'{step_result["step_name"]} 未通过'))
|
||
return
|
||
|
||
completion_tail_started_at = time.perf_counter()
|
||
complete_detection_started_at = time.perf_counter()
|
||
self._complete_detection(domain_id, domain_name, domain)
|
||
complete_detection_elapsed_ms = int((time.perf_counter() - complete_detection_started_at) * 1000)
|
||
self._set_task_detect_status(DETECT_STATUS_COMPLETED)
|
||
finalize_enqueue_started_at = time.perf_counter()
|
||
finalize_job_item('completed', f"域名检测完成: {domain_name}")
|
||
finalize_enqueue_elapsed_ms = int((time.perf_counter() - finalize_enqueue_started_at) * 1000)
|
||
trace_emit_started_at = time.perf_counter()
|
||
if str(os.getenv("DOMAINCHECK_DOMAIN_COMPLETED_TRACE", "0") or "0").strip().lower() not in {"", "0", "false", "no", "off"}:
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
"domain",
|
||
"completed",
|
||
job_item_id=job_item_id,
|
||
elapsed_ms=int((time.perf_counter() - domain_started_at) * 1000),
|
||
ok=True,
|
||
sync_mode='off',
|
||
)
|
||
trace_emit_elapsed_ms = int((time.perf_counter() - trace_emit_started_at) * 1000)
|
||
logger.info(f"域名检测完成: {domain_name}")
|
||
completion_log_sync_started_at = time.perf_counter()
|
||
self._sync_worker_log_event(
|
||
f"域名检测完成: {domain_name}",
|
||
payload={
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
"status": "completed",
|
||
},
|
||
mode='full',
|
||
job_item_id=job_item_id,
|
||
)
|
||
completion_log_sync_elapsed_ms = int((time.perf_counter() - completion_log_sync_started_at) * 1000)
|
||
completion_tail_elapsed_ms = int((time.perf_counter() - completion_tail_started_at) * 1000)
|
||
if (
|
||
completion_tail_elapsed_ms >= 20
|
||
or complete_detection_elapsed_ms >= 10
|
||
or trace_emit_elapsed_ms >= 10
|
||
or completion_log_sync_elapsed_ms >= 10
|
||
):
|
||
logger.info(
|
||
f"域名完成尾部耗时: domain={domain_name} | total_ms={completion_tail_elapsed_ms} "
|
||
f"| complete_detection_ms={complete_detection_elapsed_ms} "
|
||
f"| finalize_enqueue_ms={finalize_enqueue_elapsed_ms} "
|
||
f"| trace_emit_ms={trace_emit_elapsed_ms} "
|
||
f"| completion_log_enqueue_ms={completion_log_sync_elapsed_ms}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"检测域名出错: {domain_name}, 错误: {e}")
|
||
if not is_step_task:
|
||
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED)
|
||
self._set_task_detect_status(DETECT_STATUS_FAILED)
|
||
finalize_job_item('failed', str(e))
|
||
self._emit_step_trace(
|
||
domain_name,
|
||
"domain",
|
||
"failed",
|
||
job_item_id=job_item_id,
|
||
elapsed_ms=int((time.perf_counter() - domain_started_at) * 1000),
|
||
ok=False,
|
||
error=str(e)[:300],
|
||
)
|
||
self._sync_worker_log_event(
|
||
f"检测域名出错: {domain_name}, 错误: {e}",
|
||
level='error',
|
||
payload={
|
||
"domain_id": domain_id,
|
||
"domain": domain_name,
|
||
"status": "failed",
|
||
},
|
||
mode='full',
|
||
job_item_id=job_item_id,
|
||
)
|
||
finally:
|
||
self._clear_current_task_context()
|
||
remaining_active_threads = self._change_active_domain_threads(-1)
|
||
if self.detecting:
|
||
self._mark_detection_phase(
|
||
"running",
|
||
f"当前实际线程数量: {remaining_active_threads}/{self.thread_count}",
|
||
active_threads=remaining_active_threads,
|
||
max_threads=self.thread_count,
|
||
)
|
||
|
||
def start_detection(self):
|
||
"""
|
||
开始检测
|
||
"""
|
||
logger.info("开始执行域名检测任务")
|
||
self.detecting = True
|
||
self.stop_requested = False
|
||
total_processed = 0
|
||
final_phase = "idle"
|
||
final_detail = "Worker 等待下一次启动"
|
||
self._mark_detection_phase("preparing", "开始执行域名检测任务,正在加载配置")
|
||
self._sync_worker_log_event("开始执行域名检测任务,正在加载配置", mode='key')
|
||
|
||
# 重新加载配置,确保获取最新的配置
|
||
try:
|
||
self.detect_options = self.load_detect_options()
|
||
self.proxy_config = self.load_proxy_config()
|
||
self.thread_count = self.load_thread_count()
|
||
self._last_thread_count_refresh_at = time.time()
|
||
self.proxy_max_reuse_count = max(8, self.thread_count * 4)
|
||
logger.info(f"检测线程数设置为: {self.thread_count}")
|
||
self.load_cookies_from_remote()
|
||
self._init_optional_detectors()
|
||
recycled = self.db.recycle_running_domains(DETECT_STATUS_FAILED)
|
||
if recycled:
|
||
logger.warning(f"检测启动前回收了 {recycled} 个遗留的检测中任务,已标记为检测失败待重试")
|
||
released_job_items = self.db.release_detect_job_items_for_node(config.NODE_CODE)
|
||
if released_job_items:
|
||
logger.warning(f"检测启动前释放了 {released_job_items} 个当前节点遗留任务项,已重新回到 pending")
|
||
except Exception as e:
|
||
logger.error(f"加载配置失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
self._mark_detection_phase("failed", f"加载配置失败: {e}")
|
||
self._sync_worker_log_event(f"加载配置失败: {e}", level='error', mode='key')
|
||
final_phase = "failed"
|
||
final_detail = f"加载配置失败: {e}"
|
||
return
|
||
|
||
# 重新加载cookies
|
||
self.load_cookies_from_remote()
|
||
|
||
# 重新加载JC实例的cookies
|
||
if self.detect_options.get('detect_jucha') and self.jc_instance is not None:
|
||
self.jc_instance.load_cookies()
|
||
self.jc_instance.load_juming_cookies()
|
||
|
||
# 重新加载Juziseo实例的cookies
|
||
if self.detect_options.get('detect_juziseo') and self.juziseo_instance is not None:
|
||
self.juziseo_instance.load_cookies()
|
||
|
||
# 刷新代理池
|
||
if self.proxy_config.get('proxy_enable', False):
|
||
logger.info("开始检测,刷新代理池")
|
||
self._mark_detection_phase("refreshing_proxy", "开始检测,正在刷新代理池")
|
||
self._sync_worker_log_event("开始检测,正在刷新代理池", mode='key')
|
||
if self.proxy_refresh_lock.locked():
|
||
if self._wait_for_proxy_refresh_settle(timeout_seconds=2.0):
|
||
logger.info("检测启动前等待到了可用代理,直接复用当前代理池")
|
||
else:
|
||
logger.info("检测启动前代理池仍未就绪,继续主动刷新一次")
|
||
self.refresh_proxy_pool()
|
||
else:
|
||
self.refresh_proxy_pool()
|
||
|
||
# 更新JC和Juziseo实例的代理设置
|
||
if self.detect_options.get('detect_jucha') and self.jc_instance is not None:
|
||
self.jc_instance.proxies = self.get_proxies()
|
||
if self.detect_options.get('detect_juziseo') and self.juziseo_instance is not None:
|
||
self.juziseo_instance.proxies = self.get_proxies()
|
||
|
||
# 重新加载敏感词,确保获取最新的敏感词列表
|
||
try:
|
||
self.sensitive_words = self._load_sensitive_words_runtime()
|
||
logger.info(f"重新加载敏感词完成,共 {len(self.sensitive_words)} 个敏感词")
|
||
except Exception as e:
|
||
logger.error(f"重新加载敏感词失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
self.sensitive_words = []
|
||
|
||
# 更新配置标签
|
||
self.update_config_labels()
|
||
|
||
try:
|
||
runtime_mode_summary = {
|
||
"qt_platform": str(os.environ.get("QT_QPA_PLATFORM", "") or "").strip() or "desktop",
|
||
"worker_mode": str(os.environ.get("WORKER_MODE", "") or "").strip() or "default",
|
||
"redis_control": bool(self.use_redis),
|
||
"log_sync_mode": self._worker_log_sync_mode(),
|
||
"step_trace_enabled": self._worker_step_trace_enabled(),
|
||
}
|
||
runtime_mode_message = (
|
||
"检测运行模式摘要: "
|
||
f"core=detect_worker.py | qt_platform={runtime_mode_summary['qt_platform']} | "
|
||
f"worker_mode={runtime_mode_summary['worker_mode']} | redis_control={1 if runtime_mode_summary['redis_control'] else 0} | "
|
||
f"log_sync_mode={runtime_mode_summary['log_sync_mode']} | "
|
||
f"step_trace_enabled={1 if runtime_mode_summary['step_trace_enabled'] else 0}"
|
||
)
|
||
logger.info(runtime_mode_message)
|
||
self._sync_worker_log_event(runtime_mode_message, payload=runtime_mode_summary, mode='full')
|
||
pending_domains = collections.deque()
|
||
inflight_futures = set()
|
||
legacy_tail_reached = False
|
||
last_thread_progress_emit_count = 0
|
||
last_thread_progress_emit_at = 0.0
|
||
last_job_status_refresh_at = 0.0
|
||
last_expired_job_recycle_at = 0.0
|
||
last_future_fallback_scan_at = 0.0
|
||
worker_pool_size = max(1, int(self.refresh_thread_count_runtime(force=True, min_interval=0.0) or 1))
|
||
worker_pool = ThreadPoolExecutor(
|
||
max_workers=worker_pool_size,
|
||
thread_name_prefix="detect-domain",
|
||
)
|
||
last_worker_pool_size = worker_pool_size
|
||
|
||
def prune_finished_futures():
|
||
nonlocal inflight_futures, last_future_fallback_scan_at
|
||
if not inflight_futures:
|
||
return 0
|
||
finished = []
|
||
with self._completed_future_lock:
|
||
while self._completed_futures and len(finished) < 4096:
|
||
finished.append(self._completed_futures.popleft())
|
||
if not finished:
|
||
now_ts = time.time()
|
||
if now_ts - last_future_fallback_scan_at < 0.5:
|
||
return 0
|
||
last_future_fallback_scan_at = now_ts
|
||
finished = [future for future in list(inflight_futures) if future.done()]
|
||
if not finished:
|
||
return 0
|
||
pruned_count = 0
|
||
for future in dict.fromkeys(finished):
|
||
if future not in inflight_futures:
|
||
continue
|
||
inflight_futures.discard(future)
|
||
pruned_count += 1
|
||
try:
|
||
future.result()
|
||
except Exception as future_error:
|
||
logger.error(f"检测任务 future 执行异常: {future_error}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
return pruned_count
|
||
|
||
def emit_thread_progress(max_threads, *, batch_size=0):
|
||
nonlocal last_thread_progress_emit_count, last_thread_progress_emit_at
|
||
current_active = max(len(inflight_futures), self._get_active_domain_threads())
|
||
should_emit_progress, emit_now = self._should_emit_thread_progress(
|
||
current_active,
|
||
max_threads,
|
||
last_thread_progress_emit_count,
|
||
last_thread_progress_emit_at,
|
||
)
|
||
if should_emit_progress:
|
||
logger.info(f"当前实际线程数量: {current_active}/{max_threads}")
|
||
self._mark_detection_phase(
|
||
"running",
|
||
f"当前实际线程数量: {current_active}/{max_threads}",
|
||
active_threads=current_active,
|
||
max_threads=max_threads,
|
||
batch_size=batch_size,
|
||
processed=total_processed,
|
||
pending_buffer=len(pending_domains),
|
||
)
|
||
last_thread_progress_emit_count = current_active
|
||
last_thread_progress_emit_at = emit_now
|
||
if self.detect_thread:
|
||
try:
|
||
self.detect_thread.thread_count_signal.emit(current_active, max_threads)
|
||
except Exception as e:
|
||
logger.error(f"发送线程数量更新信号失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
def inflight_submission_metrics(max_threads):
|
||
live_active = max(0, int(self._get_active_domain_threads() or 0))
|
||
submitted_total = len(inflight_futures)
|
||
queued_backlog = max(0, submitted_total - live_active)
|
||
backlog_limit = self._resolve_submit_backlog_limit(max_threads, live_active)
|
||
dispatch_capacity = self._resolve_dispatch_capacity(max_threads, live_active, backlog_limit)
|
||
effective_available = max(0, dispatch_capacity - submitted_total)
|
||
return live_active, submitted_total, queued_backlog, backlog_limit, dispatch_capacity, effective_available
|
||
|
||
def sync_worker_pool_size(desired_threads):
|
||
nonlocal last_worker_pool_size
|
||
normalized_threads = max(1, int(desired_threads or 1))
|
||
current_pool_size = max(1, int(getattr(worker_pool, "_max_workers", normalized_threads) or normalized_threads))
|
||
if current_pool_size != normalized_threads:
|
||
worker_pool._max_workers = normalized_threads
|
||
logger.info(f"动态调整 worker_pool 大小: {current_pool_size} -> {normalized_threads}")
|
||
self._sync_worker_log_event(
|
||
f"动态调整 worker_pool 大小: {current_pool_size} -> {normalized_threads}",
|
||
payload={
|
||
"previous_max_workers": current_pool_size,
|
||
"current_max_workers": normalized_threads,
|
||
},
|
||
mode='full',
|
||
)
|
||
last_worker_pool_size = normalized_threads
|
||
elif last_worker_pool_size != normalized_threads:
|
||
last_worker_pool_size = normalized_threads
|
||
return normalized_threads
|
||
|
||
def append_pending_domains(domains, using_job_queue, queue_label, claim_elapsed_ms, legacy_fetch_elapsed_ms, thread_limit, claim_batch_size, claim_lease_seconds):
|
||
current_batch_size = len(domains)
|
||
if current_batch_size <= 0:
|
||
return
|
||
for domain in domains:
|
||
pending_domains.append(
|
||
{
|
||
"domain": domain,
|
||
"using_job_queue": bool(using_job_queue),
|
||
}
|
||
)
|
||
fetch_trace_payload = {
|
||
"queue_label": queue_label,
|
||
"batch_size": current_batch_size,
|
||
"claim_elapsed_ms": claim_elapsed_ms,
|
||
"legacy_fetch_elapsed_ms": legacy_fetch_elapsed_ms,
|
||
"thread_limit": thread_limit,
|
||
"claim_batch_size": claim_batch_size,
|
||
"claim_lease_seconds": claim_lease_seconds,
|
||
"pending_buffer": len(pending_domains),
|
||
}
|
||
fetch_trace_message = (
|
||
f"批次领取跟踪: queue={queue_label} | batch_size={current_batch_size} | "
|
||
f"claim_elapsed_ms={claim_elapsed_ms} | legacy_fetch_elapsed_ms={legacy_fetch_elapsed_ms} | "
|
||
f"thread_limit={thread_limit} | claim_batch_size={claim_batch_size} | "
|
||
f"claim_lease_seconds={claim_lease_seconds} | pending_buffer={len(pending_domains)}"
|
||
)
|
||
logger.info(fetch_trace_message)
|
||
self._sync_worker_log_event(fetch_trace_message, payload=fetch_trace_payload, mode='full')
|
||
self._mark_detection_phase(
|
||
"fetching",
|
||
f"从{queue_label}获取到 {current_batch_size} 个需要检测的域名",
|
||
batch_size=current_batch_size,
|
||
queue_source="detect_job_items" if using_job_queue else "domains",
|
||
pending_buffer=len(pending_domains),
|
||
)
|
||
self._sync_worker_log_event(
|
||
f"从{queue_label}获取到 {current_batch_size} 个需要检测的域名",
|
||
payload={
|
||
"batch_size": current_batch_size,
|
||
"queue_source": "detect_job_items" if using_job_queue else "domains",
|
||
"pending_buffer": len(pending_domains),
|
||
},
|
||
mode='full',
|
||
)
|
||
|
||
def compute_claim_batch_size(current_limit, available_slots):
|
||
return self._resolve_claim_batch_size(current_limit, available_slots)
|
||
|
||
last_dispatchable_step_items_check_at = 0.0
|
||
dispatchable_step_items_cached = False
|
||
legacy_domain_fallback_enabled = bool(
|
||
int(os.getenv("DOMAINCHECK_ENABLE_LEGACY_DOMAIN_FALLBACK", "0") or 0)
|
||
)
|
||
last_legacy_fallback_skip_log_at = 0.0
|
||
|
||
def has_dispatchable_step_items():
|
||
nonlocal last_dispatchable_step_items_check_at, dispatchable_step_items_cached
|
||
now_ts = time.time()
|
||
if now_ts - float(last_dispatchable_step_items_check_at or 0.0) < 1.0:
|
||
return dispatchable_step_items_cached
|
||
try:
|
||
dispatchable_step_items_cached = bool(self.db.has_dispatchable_detect_job_items())
|
||
except Exception as e:
|
||
logger.debug(f"检查标准步骤待派发任务失败: {e}")
|
||
last_dispatchable_step_items_check_at = now_ts
|
||
return dispatchable_step_items_cached
|
||
|
||
def start_domain_thread(domain, using_job_queue, max_threads):
|
||
nonlocal total_processed
|
||
domain_id = domain['id']
|
||
try:
|
||
job_context = {
|
||
'job_item_id': domain.get('job_item_id'),
|
||
'job_id': domain.get('job_id'),
|
||
'claim_token': domain.get('claim_token'),
|
||
'task_mode': domain.get('task_mode'),
|
||
'step_code': domain.get('step_code'),
|
||
'step_payload': domain.get('step_payload'),
|
||
'cycle_token': self.current_cycle_token,
|
||
'job_code': domain.get('job_code') or self.current_job_code,
|
||
} if using_job_queue else None
|
||
future = worker_pool.submit(self.detect_domain, domain_id, domain, job_context)
|
||
future.add_done_callback(self._enqueue_completed_future)
|
||
inflight_futures.add(future)
|
||
total_processed += 1
|
||
if self.detect_thread:
|
||
try:
|
||
self.detect_thread.progress_signal.emit(total_processed, total_processed + len(pending_domains) + len(inflight_futures))
|
||
except Exception as e:
|
||
logger.error(f"发送进度更新信号失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
emit_thread_progress(max_threads, batch_size=len(pending_domains) + len(inflight_futures))
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"创建或启动线程失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
return False
|
||
|
||
try:
|
||
while self.running:
|
||
if self.stop_requested:
|
||
logger.info("检测任务收到停止请求,停止继续领取任务")
|
||
self._mark_detection_phase("stopping", "检测任务收到停止请求,准备安全退出")
|
||
self._sync_worker_log_event("检测任务收到停止请求,准备安全退出", level='warning', mode='key')
|
||
final_phase = "stopped"
|
||
final_detail = "检测任务收到停止请求,已停止继续领取任务"
|
||
break
|
||
prune_finished_futures()
|
||
self._flush_pending_running_marks()
|
||
self._flush_pending_job_finalizations()
|
||
self._flush_pending_domain_completions()
|
||
self._flush_pending_domain_status_updates()
|
||
self._flush_pending_review_status_updates()
|
||
current_thread_limit = self.refresh_thread_count_runtime(force=True, min_interval=0.0)
|
||
max_threads = sync_worker_pool_size(current_thread_limit)
|
||
live_active, submitted_total, queued_backlog, backlog_limit, dispatch_capacity, available_slots = inflight_submission_metrics(max_threads)
|
||
now_ts = time.time()
|
||
if now_ts - last_expired_job_recycle_at >= 2.0:
|
||
recycled_job_items = self.db.recycle_expired_detect_job_items()
|
||
last_expired_job_recycle_at = now_ts
|
||
if recycled_job_items:
|
||
logger.warning(f"已回收 {recycled_job_items} 个租约过期的任务项,重新回到 pending")
|
||
if self.current_job_id and now_ts - last_job_status_refresh_at >= 2.0:
|
||
self.db.refresh_detect_job_status(self.current_job_id)
|
||
last_job_status_refresh_at = now_ts
|
||
pending_buffer_cap_multiplier = max(
|
||
1,
|
||
int(os.getenv("DOMAINCHECK_PENDING_BUFFER_CAP_MULTIPLIER", "1") or 1),
|
||
)
|
||
pending_buffer_target = min(
|
||
max(4, backlog_limit),
|
||
max(4, int(max_threads or 0) * pending_buffer_cap_multiplier),
|
||
)
|
||
single_step_session_active = self._is_single_step_session_active()
|
||
refill_slots = max(0, min(int(available_slots or 0), pending_buffer_target - len(pending_domains)))
|
||
if refill_slots > 0:
|
||
claim_batch_size = compute_claim_batch_size(max_threads, refill_slots)
|
||
claim_lease_seconds = max(300, min(1800, claim_batch_size * 30))
|
||
# single_step 必须绑定当前 job;domain_pipeline 则持续从全局任务队列补位,
|
||
# 否则旧 job 未完全结束时,新 job 会长期堆在 pending。
|
||
scoped_job_id = (
|
||
self.current_job_id
|
||
if self.current_job_id and self._should_scope_claims_to_current_job()
|
||
else None
|
||
)
|
||
claim_started_at = time.perf_counter()
|
||
domains = self.db.claim_detect_job_items(
|
||
config.NODE_CODE,
|
||
limit=claim_batch_size,
|
||
lease_seconds=claim_lease_seconds,
|
||
job_id=scoped_job_id,
|
||
)
|
||
claim_elapsed_ms = int((time.perf_counter() - claim_started_at) * 1000)
|
||
legacy_fetch_elapsed_ms = 0
|
||
if domains:
|
||
append_pending_domains(
|
||
domains,
|
||
True,
|
||
"任务队列",
|
||
claim_elapsed_ms,
|
||
legacy_fetch_elapsed_ms,
|
||
dispatch_capacity,
|
||
claim_batch_size,
|
||
claim_lease_seconds,
|
||
)
|
||
elif not single_step_session_active:
|
||
pipeline_domains = self._process_pipeline_tasks_until_available(thread_limit=max_threads)
|
||
if pipeline_domains:
|
||
append_pending_domains(
|
||
pipeline_domains,
|
||
True,
|
||
"任务队列(pipeline推进)",
|
||
claim_elapsed_ms,
|
||
legacy_fetch_elapsed_ms,
|
||
dispatch_capacity,
|
||
claim_batch_size,
|
||
claim_lease_seconds,
|
||
)
|
||
else:
|
||
refill_domains = self._pull_sync_tasks_until_available(thread_limit=max_threads)
|
||
if refill_domains:
|
||
append_pending_domains(
|
||
refill_domains,
|
||
True,
|
||
"任务队列(主动补货)",
|
||
claim_elapsed_ms,
|
||
legacy_fetch_elapsed_ms,
|
||
dispatch_capacity,
|
||
claim_batch_size,
|
||
claim_lease_seconds,
|
||
)
|
||
elif has_dispatchable_step_items():
|
||
logger.info("标准步骤队列仍有待派发任务,跳过兼容旧链路补位")
|
||
elif legacy_domain_fallback_enabled and not legacy_tail_reached:
|
||
legacy_fetch_limit = max(1, claim_batch_size)
|
||
legacy_fetch_started_at = time.perf_counter()
|
||
legacy_domains = self.db.get_domains_to_detect(limit=legacy_fetch_limit, detect_options=self.detect_options)
|
||
legacy_fetch_elapsed_ms = int((time.perf_counter() - legacy_fetch_started_at) * 1000)
|
||
if legacy_domains:
|
||
append_pending_domains(
|
||
legacy_domains,
|
||
False,
|
||
"兼容旧链路",
|
||
claim_elapsed_ms,
|
||
legacy_fetch_elapsed_ms,
|
||
dispatch_capacity,
|
||
claim_batch_size,
|
||
claim_lease_seconds,
|
||
)
|
||
if len(legacy_domains) < legacy_fetch_limit:
|
||
legacy_tail_reached = True
|
||
elif not legacy_domain_fallback_enabled:
|
||
current_ts = time.time()
|
||
if current_ts - float(last_legacy_fallback_skip_log_at or 0.0) >= 5.0:
|
||
logger.info("兼容旧链路补位已禁用,当前仅允许 detect_job_items 标准步骤补位")
|
||
last_legacy_fallback_skip_log_at = current_ts
|
||
else:
|
||
logger.info("single_step 会话进行中,跳过 pipeline 推进 / 主动补货 / 兼容旧链路补位")
|
||
|
||
dispatched_this_round = 0
|
||
while pending_domains:
|
||
live_active, submitted_total, queued_backlog, backlog_limit, dispatch_capacity, effective_available = inflight_submission_metrics(max_threads)
|
||
if effective_available <= 0:
|
||
break
|
||
if not self.running or self.stop_requested:
|
||
logger.info("检测已停止,停止创建新线程")
|
||
self._mark_detection_phase("stopping", "检测已停止,停止创建新线程")
|
||
final_phase = "stopped"
|
||
final_detail = "检测已停止,停止创建新线程"
|
||
break
|
||
pending_entry = pending_domains.popleft()
|
||
domain = pending_entry.get("domain") or {}
|
||
using_job_queue = bool(pending_entry.get("using_job_queue"))
|
||
if start_domain_thread(domain, using_job_queue, max_threads):
|
||
dispatched_this_round += 1
|
||
# 派发大批量任务时,尽早把刚启动的任务项从 claimed 刷到 running,
|
||
# 避免数据库状态长期滞后,影响 controller 对真实活跃量的判断。
|
||
if dispatched_this_round % 64 == 0:
|
||
self._flush_pending_running_marks()
|
||
|
||
if dispatched_this_round:
|
||
dispatch_summary_message = (
|
||
f"持续补位派发完成: dispatched={dispatched_this_round} | "
|
||
f"active_threads={len(inflight_futures)} | pending_buffer={len(pending_domains)} | "
|
||
f"max_threads={max_threads} | queued_backlog={queued_backlog} | backlog_limit={backlog_limit}"
|
||
)
|
||
logger.info(dispatch_summary_message)
|
||
self._sync_worker_log_event(
|
||
dispatch_summary_message,
|
||
payload={
|
||
"dispatched": dispatched_this_round,
|
||
"active_threads": len(inflight_futures),
|
||
"pending_buffer": len(pending_domains),
|
||
"max_threads": max_threads,
|
||
"queued_backlog": queued_backlog,
|
||
"backlog_limit": backlog_limit,
|
||
},
|
||
mode='full',
|
||
)
|
||
|
||
if not pending_domains and not inflight_futures:
|
||
if legacy_tail_reached:
|
||
logger.info("兼容旧链路已到尾批,且当前没有活跃线程,本轮检测完成")
|
||
self._mark_detection_phase(
|
||
"completing",
|
||
"兼容旧链路已到尾批,且当前没有活跃线程,本轮检测完成",
|
||
processed=total_processed,
|
||
)
|
||
self._sync_worker_log_event(
|
||
"兼容旧链路已到尾批,且当前没有活跃线程,本轮检测完成",
|
||
payload={"processed": total_processed},
|
||
mode='key',
|
||
)
|
||
break
|
||
logger.info("没有需要检测的域名")
|
||
self._mark_detection_phase("idle", "当前没有需要检测的域名,Worker 等待下一次启动")
|
||
self._sync_worker_log_event("当前没有需要检测的域名,Worker 等待下一次启动", mode='key')
|
||
break
|
||
|
||
emit_thread_progress(max_threads, batch_size=len(pending_domains) + len(inflight_futures))
|
||
time.sleep(0.05 if pending_domains or inflight_futures else 0.2)
|
||
finally:
|
||
self._flush_pending_running_marks(force=True, batch_limit=5000)
|
||
self._flush_pending_job_finalizations(force=True, batch_limit=5000)
|
||
self._flush_pending_domain_completions(force=True, batch_limit=5000)
|
||
self._flush_pending_domain_status_updates(force=True, batch_limit=5000)
|
||
self._flush_pending_review_status_updates(force=True, batch_limit=5000)
|
||
if self.current_job_id:
|
||
self.db.refresh_detect_job_status(self.current_job_id)
|
||
worker_pool.shutdown(wait=False, cancel_futures=False)
|
||
|
||
# 完成进度
|
||
if self.detect_thread:
|
||
try:
|
||
# 通过信号发送进度更新
|
||
self.detect_thread.progress_signal.emit(100, 100)
|
||
except Exception as e:
|
||
logger.error(f"发送进度更新信号失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
logger.info("域名检测任务完成")
|
||
self._mark_detection_phase("completed", "域名检测任务完成")
|
||
self._sync_worker_log_event("域名检测任务完成", payload={"processed": total_processed}, mode='key')
|
||
final_phase = "completed"
|
||
final_detail = "域名检测任务完成"
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行检测任务出错: {e}")
|
||
self._mark_detection_phase("failed", f"执行检测任务出错: {e}")
|
||
self._sync_worker_log_event(f"执行检测任务出错: {e}", level='error', mode='key')
|
||
final_phase = "failed"
|
||
final_detail = f"执行检测任务出错: {e}"
|
||
finally:
|
||
self.detecting = False
|
||
if not self.running or self.stop_requested:
|
||
final_phase = "stopped"
|
||
final_detail = "检测任务已停止"
|
||
self._update_runtime_state(
|
||
final_phase,
|
||
final_detail,
|
||
active_threads=0,
|
||
max_threads=max(1, int(getattr(self, "thread_count", 1) or 1)),
|
||
processed=total_processed,
|
||
)
|
||
|
||
def run_daily_task(self):
|
||
"""
|
||
执行每日检测任务
|
||
"""
|
||
logger.info(f"[{datetime.now()}] 开始每日检测任务")
|
||
self.start_detection()
|
||
logger.info(f"[{datetime.now()}] 每日检测任务完成")
|
||
|
||
def start_scheduler(self):
|
||
"""
|
||
启动定时任务
|
||
"""
|
||
# 避免重复注册相同定时任务
|
||
schedule.clear('daily_detection')
|
||
# 每天凌晨1点执行检测;启动程序时不自动跑一轮,避免和人工启动混淆
|
||
schedule.every().day.at("01:00").do(self.run_daily_task).tag('daily_detection')
|
||
logger.info("定时任务已启动,每天凌晨1点执行检测;程序启动后不会立即自动执行")
|
||
|
||
# 循环执行定时任务
|
||
while self.running:
|
||
schedule.run_pending()
|
||
time.sleep(60)
|
||
|
||
def start(self):
|
||
"""
|
||
启动检测端
|
||
"""
|
||
logger.info("启动域名检测端")
|
||
self.running = True
|
||
|
||
# 启动定时任务
|
||
scheduler_thread = threading.Thread(target=self.start_scheduler)
|
||
scheduler_thread.daemon = True
|
||
scheduler_thread.start()
|
||
|
||
# 保持程序运行
|
||
try:
|
||
while self.running:
|
||
time.sleep(1)
|
||
except KeyboardInterrupt:
|
||
logger.info("检测端已停止")
|
||
self.running = False
|
||
|
||
def load_cookies_from_remote(self):
|
||
"""
|
||
从远程加载cookies
|
||
"""
|
||
try:
|
||
if self.use_redis:
|
||
# 加载聚名cookies
|
||
juming_cookies_str = self.redis_client.get('domain_tool:juming_cookies')
|
||
if juming_cookies_str:
|
||
# 将cookies保存到本地文件
|
||
import pickle
|
||
from requests.cookies import RequestsCookieJar
|
||
|
||
# 创建cookie jar
|
||
cookie_jar = RequestsCookieJar()
|
||
|
||
# 解析cookies字符串
|
||
try:
|
||
import ast
|
||
cookies_dict = ast.literal_eval(juming_cookies_str)
|
||
for name, value in cookies_dict.items():
|
||
cookie_jar.set(name, value)
|
||
except Exception as e:
|
||
logger.error(f"解析聚名cookies失败: {e}")
|
||
|
||
# 保存到本地文件
|
||
try:
|
||
with open('juming_cookies.pkl', 'wb') as f:
|
||
pickle.dump(cookie_jar, f)
|
||
logger.info("从Redis加载聚名cookies成功并保存到本地")
|
||
except Exception as e:
|
||
logger.error(f"保存聚名cookies到本地失败: {e}")
|
||
|
||
# 加载聚查cookies
|
||
jucha_cookies_str = self.redis_client.get('domain_tool:jucha_cookies')
|
||
if jucha_cookies_str:
|
||
# 将cookies保存到本地文件
|
||
import pickle
|
||
from requests.cookies import RequestsCookieJar
|
||
|
||
# 创建cookie jar
|
||
cookie_jar = RequestsCookieJar()
|
||
|
||
# 解析cookies字符串
|
||
try:
|
||
import ast
|
||
cookies_dict = ast.literal_eval(jucha_cookies_str)
|
||
for name, value in cookies_dict.items():
|
||
cookie_jar.set(name, value)
|
||
except Exception as e:
|
||
logger.error(f"解析聚查cookies失败: {e}")
|
||
|
||
# 保存到本地文件
|
||
try:
|
||
with open('jucha_cookies.pkl', 'wb') as f:
|
||
pickle.dump(cookie_jar, f)
|
||
logger.info("从Redis加载聚查cookies成功并保存到本地")
|
||
except Exception as e:
|
||
logger.error(f"保存聚查cookies到本地失败: {e}")
|
||
|
||
# 加载桔子SEO cookies
|
||
juziseo_cookies_str = self.redis_client.get('domain_tool:juziseo_cookies')
|
||
if juziseo_cookies_str:
|
||
# 将cookies保存到本地文件
|
||
import pickle
|
||
from requests.cookies import RequestsCookieJar
|
||
|
||
# 创建cookie jar
|
||
cookie_jar = RequestsCookieJar()
|
||
|
||
# 解析cookies字符串
|
||
try:
|
||
import ast
|
||
cookies_dict = ast.literal_eval(juziseo_cookies_str)
|
||
for name, value in cookies_dict.items():
|
||
cookie_jar.set(name, value)
|
||
except Exception as e:
|
||
logger.error(f"解析桔子SEO cookies失败: {e}")
|
||
|
||
# 保存到本地文件
|
||
try:
|
||
with open('juziseo_cookies.pkl', 'wb') as f:
|
||
pickle.dump(cookie_jar, f)
|
||
logger.info("从Redis加载桔子SEO cookies成功并保存到本地")
|
||
except Exception as e:
|
||
logger.error(f"保存桔子SEO cookies到本地失败: {e}")
|
||
except Exception as e:
|
||
logger.error(f"从远程加载cookies失败: {e}")
|
||
|
||
def update_config_labels(self):
|
||
"""
|
||
更新GUI配置标签
|
||
"""
|
||
# 使用QCoreApplication.postEvent发送配置更新事件,这是最安全的方法
|
||
try:
|
||
logger.debug("发送配置更新事件")
|
||
# 确保主窗口存在
|
||
if self.main_window:
|
||
from PySide6.QtCore import QCoreApplication
|
||
event = ConfigUpdateEvent()
|
||
QCoreApplication.postEvent(self.main_window, event)
|
||
logger.debug("配置更新事件已发送")
|
||
else:
|
||
logger.debug("主窗口不存在,跳过配置更新")
|
||
except Exception as e:
|
||
logger.error(f"发送配置更新事件失败: {e}")
|
||
|
||
|
||
|
||
def start_redis_subscription(self):
|
||
"""
|
||
启动Redis订阅,监听配置更新
|
||
"""
|
||
while self.running:
|
||
pubsub = None
|
||
try:
|
||
# 创建新的Redis客户端用于订阅
|
||
redis_sub_client = redis.Redis(
|
||
host=config.REDIS_HOST,
|
||
port=config.REDIS_PORT,
|
||
password=config.REDIS_PASSWORD,
|
||
db=config.REDIS_DB,
|
||
decode_responses=True,
|
||
socket_connect_timeout=30, # 增加连接超时时间
|
||
socket_timeout=60, # 增加读取超时时间
|
||
retry_on_timeout=True,
|
||
health_check_interval=30
|
||
)
|
||
|
||
# 订阅配置更新频道
|
||
pubsub = redis_sub_client.pubsub()
|
||
pubsub.subscribe(CONFIG_UPDATE_CHANNEL, CONTROL_CHANNEL)
|
||
|
||
# 使用logger.debug输出到文件日志,不输出到GUI日志框
|
||
logger.debug("开始监听配置更新...")
|
||
self._consume_pending_control_command()
|
||
self._resume_active_detect_job_if_needed(reason="redis_subscription_bootstrap")
|
||
|
||
# 循环监听消息
|
||
while self.running:
|
||
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||
if not message:
|
||
continue
|
||
if message['type'] == 'message':
|
||
channel = message.get('channel', '')
|
||
payload = message['data']
|
||
if channel == CONFIG_UPDATE_CHANNEL:
|
||
config_type = payload
|
||
logger.debug(f"收到配置更新消息: {config_type}")
|
||
previous_proxy_config = dict(self.proxy_config or {})
|
||
self.detect_options = self.load_detect_options()
|
||
self.proxy_config = self.load_proxy_config()
|
||
self.thread_count = self.load_thread_count()
|
||
self.sensitive_words = self._load_sensitive_words_runtime()
|
||
self.proxy_max_reuse_count = max(8, self.thread_count * 4)
|
||
self.runtime_settings = self.load_runtime_settings()
|
||
self.load_cookies_from_remote()
|
||
self.update_config_labels()
|
||
previous_urls = tuple(previous_proxy_config.get('proxy_urls') or [])
|
||
current_urls = tuple(self.proxy_config.get('proxy_urls') or [])
|
||
proxy_changed = (
|
||
bool(previous_proxy_config.get('proxy_enable', False)) != bool(self.proxy_config.get('proxy_enable', False))
|
||
or bool(previous_proxy_config.get('allow_direct', False)) != bool(self.proxy_config.get('allow_direct', False))
|
||
or previous_urls != current_urls
|
||
)
|
||
if config_type in {"proxy_config", "thread_count", "node_thread_counts", "runtime_settings"} or proxy_changed:
|
||
self.trigger_proxy_refresh(
|
||
reason=f"config_update:{config_type}",
|
||
reset_cooldown=proxy_changed or config_type == "proxy_config",
|
||
)
|
||
logger.debug("配置已更新")
|
||
continue
|
||
|
||
if channel == CONTROL_CHANNEL:
|
||
logger.info(f"收到 Worker 控制消息: {payload}")
|
||
self._handle_control_message(payload)
|
||
except redis.exceptions.TimeoutError:
|
||
# Pub/Sub 空闲超时在 Linux 服务态下属于常见现象,不需要中断订阅。
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"Redis订阅失败: {e}")
|
||
logger.debug("3秒后尝试重新连接...")
|
||
time.sleep(3) # 增加重试间隔
|
||
finally:
|
||
if pubsub is not None:
|
||
try:
|
||
pubsub.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def stop(self):
|
||
"""
|
||
停止检测端
|
||
"""
|
||
logger.info("停止域名检测端")
|
||
self._runtime_heartbeat_stop.set()
|
||
self._worker_log_sync_stop.set()
|
||
try:
|
||
self._worker_log_sync_queue.put_nowait(None)
|
||
except Exception:
|
||
pass
|
||
self.stop_requested = True
|
||
self.running = False
|
||
self.detecting = False
|
||
self._update_runtime_state("stopped", "Worker 已停止")
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
# 配置日志
|
||
log_file_path = _resolve_worker_log_file()
|
||
logger.add(log_file_path, rotation="1 day", level="DEBUG")
|
||
_raise_nofile_soft_limit()
|
||
logger.info("程序开始运行")
|
||
|
||
# 创建应用程序
|
||
app = QApplication([])
|
||
logger.info("创建应用程序成功")
|
||
|
||
# 创建并显示主窗口
|
||
window = DetectMainWindow()
|
||
logger.info("创建主窗口成功")
|
||
window.show()
|
||
logger.info("显示主窗口成功")
|
||
|
||
# 运行应用程序
|
||
logger.info("开始运行应用程序")
|
||
app.exec()
|
||
logger.info("应用程序运行结束")
|
||
|
||
# 当窗口关闭时,停止检测端
|
||
if window.worker:
|
||
logger.info("停止检测端")
|
||
window.worker.stop()
|
||
except Exception as e:
|
||
logger.error(f"程序运行出错: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
import time
|
||
time.sleep(10) # 等待10秒,以便查看错误信息
|