Files
getDomain/domainCheck/detect_worker.py

9440 lines
443 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: UTF-8 -*-
'''
@Project :domainScanDemo
@File :detect_worker.py
@IDE :PyCharm
@Author :梦伴
@Date :2026/4/10 22:40
@explain : 域名检测端程序
'''
import os
import signal
import sys
import json
import gc
import math
import time
import socket
import threading
import collections
import resource
import schedule
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
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.redis_client import get_redis_client
from app.utils.status_codes import (
DETECT_STATUS_BLACKLISTED,
DETECT_STATUS_COMPLETED,
DETECT_STATUS_FAILED,
DETECT_STATUS_RUNNING,
REGISTER_STATUS_AVAILABLE,
REGISTER_STATUS_CLIENT_HOLD,
REGISTER_STATUS_SERVER_HOLD,
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"
SHARED_PROXY_POOL_KEY_PREFIX = "domain_tool:shared_proxy_pool"
SHARED_PROXY_REFRESH_LOCK_KEY_PREFIX = "domain_tool:shared_proxy_refresh_lock"
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_thread_stack_size_bytes() -> int:
worker_mode = str(os.getenv("WORKER_MODE", "") or "").strip()
raw_override = str(os.getenv("DOMAINCHECK_THREAD_STACK_SIZE_KB", "") or "").strip()
if not raw_override and worker_mode != "linux-systemd":
return 0
default_kb = "1024" if worker_mode == "linux-systemd" else "0"
try:
target_kb = int(raw_override or default_kb or 0)
except Exception:
target_kb = int(default_kb or 0)
if target_kb <= 0:
return 0
return max(256 * 1024, target_kb * 1024)
def _configure_worker_thread_stack_size() -> int:
target_bytes = int(_resolve_worker_thread_stack_size_bytes() or 0)
try:
current_bytes = int(threading.stack_size() or 0)
except Exception:
current_bytes = 0
if target_bytes <= 0:
return current_bytes
if current_bytes == target_bytes:
return current_bytes
try:
threading.stack_size(target_bytes)
applied_bytes = int(threading.stack_size() or 0)
logger.info(f"已设置检测线程默认栈大小: {current_bytes} -> {applied_bytes}")
return applied_bytes
except Exception as exc:
logger.warning(f"设置检测线程默认栈大小失败: target={target_bytes}, error={exc}")
return current_bytes
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")
def _worker_local_config_roots() -> list[Path]:
seen = set()
roots: list[Path] = []
def append_root(raw_path) -> None:
text = str(raw_path or "").strip()
if not text:
return
path = Path(text).expanduser().resolve()
key = str(path)
if key in seen:
return
seen.add(key)
roots.append(path)
append_root(str(os.getenv("DOMAINCHECK_CONFIG_ROOT", "") or "").strip())
append_root(Path.cwd())
script_root = Path(__file__).resolve().parent
append_root(script_root)
for base_root in list(roots):
normalized = str(base_root)
if f"{os.sep}releases{os.sep}" in normalized:
install_root = normalized.split(f"{os.sep}releases{os.sep}", 1)[0]
append_root(Path(install_root) / "current" / "domainCheck")
append_root(Path(install_root) / "domainCheck")
elif f"{os.sep}current{os.sep}" in normalized:
install_root = normalized.split(f"{os.sep}current{os.sep}", 1)[0]
append_root(Path(install_root) / "current" / "domainCheck")
append_root(Path(install_root) / "domainCheck")
elif base_root.name == "domainCheck":
append_root(base_root.parent / "current" / "domainCheck")
append_root(base_root.parent / "domainCheck")
return roots
def _read_worker_local_json_config(filename: str):
normalized_name = str(filename or "").strip()
if not normalized_name:
return None
for root in _worker_local_config_roots():
candidate = root / normalized_name
try:
if candidate.exists():
with candidate.open("r", encoding="utf-8") as handle:
return json.load(handle)
except Exception:
continue
return None
def pending_control_key(node_code=None):
normalized_node_code = str(node_code or config.NODE_CODE or "").strip()
if not normalized_node_code:
return PENDING_CONTROL_KEY
return f"{PENDING_CONTROL_KEY}:{normalized_node_code}"
def control_target_node_codes(control_payload) -> list[str]:
if not isinstance(control_payload, dict):
return []
normalized_targets: list[str] = []
def append_target(raw_value):
normalized_value = str(raw_value or "").strip()
if normalized_value and normalized_value not in normalized_targets:
normalized_targets.append(normalized_value)
list_keys = ("target_node_codes", "node_codes")
scalar_keys = ("target_node_code", "node_code")
for key in list_keys:
raw_value = control_payload.get(key)
if isinstance(raw_value, (list, tuple, set)):
for item in raw_value:
append_target(item)
elif isinstance(raw_value, str) and raw_value.strip():
for item in raw_value.split(","):
append_target(item)
if normalized_targets:
return normalized_targets
for key in scalar_keys:
raw_value = control_payload.get(key)
if raw_value not in (None, ""):
append_target(raw_value)
if normalized_targets:
return normalized_targets
return normalized_targets
def control_targets_current_worker(control_payload, *, node_code=None) -> bool:
normalized_node_code = str(node_code or config.NODE_CODE or "").strip()
if not normalized_node_code:
return True
target_node_codes = control_target_node_codes(control_payload)
if not target_node_codes:
return True
return normalized_node_code in target_node_codes
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._worker_started_at = time.time()
self._last_worker_activity_at = self._worker_started_at
self._last_explicit_start_signal_at = 0.0
self._last_explicit_start_payload = {}
self._last_detection_start_at = 0.0
self._last_detection_start_source = ""
self._last_detection_start_job_id = None
self._last_detection_start_job_code = ""
self._last_detection_start_task_mode = ""
self._cached_active_detect_job = None
self._last_active_detect_job_refresh_at = 0.0
self._explicit_claim_scope_job_id = None
self._explicit_claim_scope_job_code = ""
self._explicit_claim_scope_until = 0.0
self._explicit_claim_scope_source = ""
self._ignored_targeted_job_id = None
self._ignored_targeted_job_code = ""
self._ignored_targeted_job_until = 0.0
self._ignored_targeted_job_targets = ""
self._last_explicit_scope_claim_log_at = 0.0
self._last_explicit_scope_claim_log_key = ""
self._last_explicit_scope_probe_log_at = 0.0
self._last_job_status_refresh_probe_log_at = 0.0
self._idle_recycle_requested = False
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._restart_release_handoff_job_id = None
self._restart_release_handoff_job_code = ""
self._restart_release_handoff_until = 0.0
self._restart_release_handoff_reason = ""
self.runtime_settings = {
"worker_log_sync_enabled": False,
"worker_log_sync_mode": "key",
"worker_step_trace_enabled": True,
"worker_step_trace_sync_full": True,
"claim_batch_floor": 0,
"claim_batch_ceil": 0,
"submit_backlog_floor": 0,
"submit_backlog_ceil": 0,
"dispatch_cap_multiplier": 1,
"pending_buffer_cap_multiplier": 1,
}
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._last_worker_log_sync_degrade_notice_at = 0.0
self._remote_debug_event_failure_streak = 0
self._remote_debug_event_cooldown_until = 0.0
self._last_remote_debug_event_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_release_lock = threading.Lock()
self._pending_job_releases = collections.deque()
self._pending_job_release_reasons = {}
self._last_job_release_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._last_job_item_release_at = 0.0
# 初始化数据库连接
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._maybe_release_node_job_items(reason="worker_bootstrap", min_interval_seconds=0.0)
if released:
logger.warning(f"Worker 启动时释放了 {released} 个当前节点遗留任务项,已重新回到 pending")
except Exception as e:
logger.warning(f"Worker 启动时清理遗留运行态失败: {e}")
# 初始化Redis连接
try:
self.redis_client = get_redis_client(role="standard")
# 测试连接
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.process_count = self.load_process_count() # 从配置文件加载进程数
self.runtime_settings = self.load_runtime_settings()
self.register_single_machine_mode_enabled = self._env_flag_enabled(
"DOMAINCHECK_REGISTER_SINGLE_MACHINE_MODE"
)
self.single_machine_site_direct_fallback_enabled = self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_SITE_DIRECT_FALLBACK",
default=self.register_single_machine_mode_enabled,
)
self.single_machine_aizhan_direct_first_enabled = self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_AIZHAN_DIRECT_FIRST",
default=False,
)
self.single_machine_baidu_direct_first_enabled = self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_BAIDU_DIRECT_FIRST",
default=False,
)
self.aizhan_remote_disconnect_degrade_enabled = self._env_flag_enabled(
"DOMAINCHECK_AIZHAN_REMOTE_DISCONNECT_DEGRADE",
default=False,
)
self.aizhan_external_fast_degrade_enabled = self._env_flag_enabled(
"DOMAINCHECK_AIZHAN_EXTERNAL_FAST_DEGRADE",
default=False,
)
self.register_single_machine_direct_streak_attempts = max(
0,
int(os.getenv("DOMAINCHECK_REGISTER_DIRECT_STREAK_ATTEMPTS", "2") or 2),
) if self.register_single_machine_mode_enabled else 0
# 初始化代理池
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._last_proxy_clock_skew_warning_at = 0.0
self.proxy_refresh_cooldown_seconds = 30
self.proxy_next_refresh_time = 0.0
self._last_proxy_refresh_reason_at = {}
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_direct_fallback_grace_overrides = {
"注册状态检测": max(
0.0,
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE_REGISTER", "0.08") or 0.08),
),
"360检测": max(
0.0,
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE_360", "0.12") or 0.12),
),
"百度site检测": max(
0.0,
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE_BAIDU", "0.12") or 0.12),
),
"站长之家检测": max(
0.0,
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE_CHINAZ", "0.12") or 0.12),
),
"爱站网检测": max(
0.0,
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE_AIZHAN", "0.12") or 0.12),
),
"时光机检测": max(
0.0,
float(os.getenv("DOMAINCHECK_PROXY_DIRECT_FALLBACK_GRACE_WAYBACK", "0.2") or 0.2),
),
}
self.proxy_direct_retry_wait_seconds = max(
0.0,
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT", "1.2") or 1.2),
)
self.proxy_direct_retry_wait_overrides = {
"注册状态检测": max(
0.0,
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT_REGISTER", "0.2") or 0.2),
),
"360检测": max(
0.0,
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT_360", "0.25") or 0.25),
),
"百度site检测": max(
0.0,
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT_BAIDU", "0.25") or 0.25),
),
"站长之家检测": max(
0.0,
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT_CHINAZ", "0.25") or 0.25),
),
"爱站网检测": max(
0.0,
float(os.getenv("DOMAINCHECK_DIRECT_RETRY_PROXY_WAIT_AIZHAN", "0.25") or 0.25),
),
}
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.proxy_active_leases = {}
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}")
logger.info(f"检测进程数: {self.process_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}")
try:
self._maybe_recycle_idle_worker(trigger_reason="heartbeat")
except Exception as e:
logger.debug(f"空闲 Worker 生命周期检查失败: {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 _enqueue_job_release(self, job_item_id, claim_token, reason=""):
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 False
release_key = (normalized_job_item_id, normalized_claim_token)
normalized_reason = str(reason or "").strip()[:1000]
with self._job_release_lock:
existing_reason = str(
getattr(self, "_pending_job_release_reasons", {}).get(release_key, "") or ""
).strip()
if release_key not in getattr(self, "_pending_job_release_reasons", {}):
self._pending_job_releases.append(release_key)
# Keep the newest non-empty reason, but avoid growing the queue for duplicates.
self._pending_job_release_reasons[release_key] = normalized_reason or existing_reason
return True
def _flush_pending_job_releases(self, *, force=False, batch_limit=128):
now_ts = time.time()
if not force and now_ts - float(getattr(self, "_last_job_release_flush_at", 0.0) or 0.0) < 0.20:
return 0
batch = []
with self._job_release_lock:
if not self._pending_job_releases:
if force:
self._last_job_release_flush_at = now_ts
return 0
while self._pending_job_releases and len(batch) < max(1, int(batch_limit or 1)):
release_key = self._pending_job_releases.popleft()
reason = str(self._pending_job_release_reasons.pop(release_key, "") or "").strip()
batch.append((int(release_key[0]), str(release_key[1]), reason))
updated_count = 0
flush_started_at = time.perf_counter()
try:
updated_count = int(self.db.release_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_release_lock:
for item in reversed(batch):
release_key = (int(item[0]), str(item[1]))
self._pending_job_releases.appendleft(release_key)
self._pending_job_release_reasons[release_key] = str(item[2] or "").strip()[:1000]
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_release_flush_at = now_ts
return updated_count
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
pending_keys = [pending_control_key()]
if pending_keys[0] != PENDING_CONTROL_KEY:
pending_keys.append(PENDING_CONTROL_KEY)
payload = None
payload_key = ""
try:
for key in pending_keys:
payload = self.redis_client.getdel(key)
if payload:
payload_key = key
break
except Exception:
try:
for key in pending_keys:
payload = self.redis_client.get(key)
if payload:
self.redis_client.delete(key)
payload_key = key
break
except Exception as e:
logger.debug(f"读取待执行控制指令失败: {e}")
return
if not payload:
return
logger.info(f"发现待执行 Worker 控制指令: key={payload_key or 'unknown'} payload={payload}")
self._handle_control_message(payload)
def _active_detect_job_refresh_interval_seconds(self, reason: str) -> float:
if reason == "heartbeat_autoresume":
raw_value = os.getenv("DOMAINCHECK_AUTORESUME_ACTIVE_JOB_MIN_REFRESH_SECONDS", "5")
else:
raw_value = os.getenv("DOMAINCHECK_ACTIVE_JOB_MIN_REFRESH_SECONDS", "1")
try:
return max(0.0, float(raw_value or 0.0))
except (TypeError, ValueError):
return 5.0 if reason == "heartbeat_autoresume" else 1.0
def _load_active_detect_job_snapshot(self, reason: str, now_ts: float):
refresh_interval = self._active_detect_job_refresh_interval_seconds(reason)
cached_job = getattr(self, "_cached_active_detect_job", None)
last_refresh_at = float(getattr(self, "_last_active_detect_job_refresh_at", 0.0) or 0.0)
if refresh_interval > 0 and cached_job is not None and now_ts - last_refresh_at < refresh_interval:
return dict(cached_job or {})
active_job = self.db.get_active_detect_job() or {}
self._cached_active_detect_job = dict(active_job or {})
self._last_active_detect_job_refresh_at = now_ts
return dict(active_job or {})
def _autoresume_same_job_restart_grace_seconds(self) -> float:
return max(
5.0,
float(os.getenv("DOMAINCHECK_AUTORESUME_SAME_JOB_RESTART_GRACE_SECONDS", "30") or 30),
)
def _bootstrap_autoresume_lock_ttl_seconds(self) -> int:
return max(
5,
int(os.getenv("DOMAINCHECK_BOOTSTRAP_AUTORESUME_LOCK_TTL_SECONDS", "30") or 30),
)
def _acquire_bootstrap_autoresume_lock(self, *, job_id: int, job_code: str = "") -> bool:
if not self.use_redis or self.redis_client is None:
return True
try:
normalized_job_id = int(job_id or 0)
except Exception:
normalized_job_id = 0
if normalized_job_id <= 0:
return True
lock_action = f"bootstrap-autoresume:{normalized_job_id}:{str(job_code or '').strip() or '-'}"
lock_key = self._shared_job_maintenance_lock_key(lock_action)
if not lock_key:
return True
owner_token = self._job_maintenance_owner_token(lock_action)
try:
acquired = self.redis_client.set(
lock_key,
owner_token,
nx=True,
ex=self._bootstrap_autoresume_lock_ttl_seconds(),
)
return bool(acquired)
except Exception as exc:
logger.debug(f"获取 bootstrap 自动回挂锁失败: action={lock_action}, error={exc}")
return True
def _should_skip_same_job_autoresume(self, *, active_job: dict, now_ts: float) -> tuple[bool, str]:
incoming_job_code = str(active_job.get("job_code") or "").strip()
incoming_job_id = active_job.get("id")
try:
normalized_incoming_job_id = int(incoming_job_id or 0)
except Exception:
normalized_incoming_job_id = 0
current_job_code = str(getattr(self, "current_job_code", "") or "").strip()
current_job_id = getattr(self, "current_job_id", None)
try:
normalized_current_job_id = int(current_job_id or 0)
except Exception:
normalized_current_job_id = 0
same_job = bool(
incoming_job_code
and current_job_code
and incoming_job_code == current_job_code
) or bool(
normalized_incoming_job_id > 0
and normalized_current_job_id > 0
and normalized_incoming_job_id == normalized_current_job_id
)
if not same_job:
return False, ""
live_active_threads = max(0, int(self._get_active_domain_threads() or 0))
if live_active_threads > 0:
return True, f"same_job_threads_active:{live_active_threads}"
handoff_job_id = self._restart_release_handoff_job_id_active()
if (
handoff_job_id not in (None, "", 0, "0")
and normalized_incoming_job_id > 0
and int(handoff_job_id) == normalized_incoming_job_id
):
return True, "same_job_restart_handoff_active"
recent_start_at = float(getattr(self, "_last_detection_start_at", 0.0) or 0.0)
recent_start_grace_seconds = self._autoresume_same_job_restart_grace_seconds()
recent_start_elapsed = max(0.0, now_ts - recent_start_at) if recent_start_at > 0 else 0.0
if recent_start_at > 0 and recent_start_elapsed < recent_start_grace_seconds:
return True, f"same_job_recent_start:{int(recent_start_elapsed)}s"
return False, ""
def _should_skip_explicit_scope_autoresume(self, *, active_job: dict) -> tuple[bool, str]:
explicit_job_id = self._explicit_claim_scope_job_id_active()
if explicit_job_id in (None, "", 0, "0"):
return False, ""
try:
normalized_explicit_job_id = int(explicit_job_id or 0)
except Exception:
return False, ""
try:
normalized_incoming_job_id = int(active_job.get("id") or 0)
except Exception:
normalized_incoming_job_id = 0
if normalized_explicit_job_id <= 0 or normalized_incoming_job_id <= 0:
return False, ""
if normalized_explicit_job_id != normalized_incoming_job_id:
return False, ""
explicit_job_code = str(getattr(self, "_explicit_claim_scope_job_code", "") or "").strip()
explicit_source = str(getattr(self, "_explicit_claim_scope_source", "") or "").strip() or "unknown"
detail_parts = [f"explicit_scope_active:{normalized_explicit_job_id}"]
if explicit_job_code:
detail_parts.append(explicit_job_code)
detail_parts.append(explicit_source)
return True, ":".join(detail_parts)
def _ignored_targeted_job_window_seconds(self) -> float:
return max(
30.0,
float(os.getenv("DOMAINCHECK_IGNORED_TARGETED_JOB_WINDOW_SECONDS", "180") or 180),
)
def _remember_ignored_targeted_control(self, control_payload=None) -> None:
if not isinstance(control_payload, dict):
return
if control_targets_current_worker(control_payload):
return
target_node_codes = control_target_node_codes(control_payload)
if not target_node_codes:
return
job_id = self._incoming_job_id(control_payload)
job_code = self._incoming_job_code(control_payload)
if job_id in (None, "", 0, "0") or not job_code:
return
self._ignored_targeted_job_id = int(job_id)
self._ignored_targeted_job_code = str(job_code or "").strip()
self._ignored_targeted_job_until = time.time() + self._ignored_targeted_job_window_seconds()
self._ignored_targeted_job_targets = ",".join(target_node_codes)
def _clear_ignored_targeted_job(self, *, reason: str = "") -> None:
self._ignored_targeted_job_id = None
self._ignored_targeted_job_code = ""
self._ignored_targeted_job_until = 0.0
self._ignored_targeted_job_targets = ""
def _should_skip_ignored_targeted_job_autoresume(self, *, active_job: dict) -> tuple[bool, str]:
ignored_job_id = getattr(self, "_ignored_targeted_job_id", None)
if ignored_job_id in (None, "", 0, "0"):
return False, ""
if time.time() >= float(getattr(self, "_ignored_targeted_job_until", 0.0) or 0.0):
self._clear_ignored_targeted_job(reason="expired")
return False, ""
try:
active_job_id = int(active_job.get("id") or 0)
except Exception:
active_job_id = 0
try:
normalized_ignored_job_id = int(ignored_job_id or 0)
except Exception:
normalized_ignored_job_id = 0
if active_job_id <= 0 or normalized_ignored_job_id <= 0:
return False, ""
if active_job_id != normalized_ignored_job_id:
return False, ""
ignored_job_code = str(getattr(self, "_ignored_targeted_job_code", "") or "").strip()
ignored_targets = str(getattr(self, "_ignored_targeted_job_targets", "") or "").strip() or "unknown"
detail_parts = [f"ignored_targeted_job:{normalized_ignored_job_id}"]
if ignored_job_code:
detail_parts.append(ignored_job_code)
detail_parts.append(ignored_targets)
return True, ":".join(detail_parts)
def _resume_active_detect_job_if_needed(self, reason: str = "worker_bootstrap"):
now_ts = time.time()
stale_detect = False
autoresume_reasons = {
"heartbeat_autoresume",
"service_runtime_bootstrap",
"redis_subscription_bootstrap",
}
node_role = str(getattr(config, "NODE_ROLE", "") or "").strip()
is_primary_worker = self._is_primary_job_maintenance_worker()
tail_handoff_probe_enabled = self._runtime_bool_override(
"tail_handoff_autoresume_enabled",
"DOMAINCHECK_TAIL_HANDOFF_AUTORESUME",
default=True,
)
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 (
node_role == "control"
and not self._runtime_bool_override(
"control_node_autoresume_enabled",
"DOMAINCHECK_CONTROL_NODE_AUTORESUME",
default=False,
)
):
log_skip("control_node_autoresume_disabled")
return False
if (
reason in autoresume_reasons
and node_role != "control"
and not is_primary_worker
and not tail_handoff_probe_enabled
):
log_skip("non_primary_autoresume_worker")
return False
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
explicit_start_grace_seconds = max(
15.0,
float(os.getenv("DOMAINCHECK_AUTORESUME_EXPLICIT_START_GRACE_SECONDS", "120") or 120),
)
last_explicit_start_at = float(getattr(self, "_last_explicit_start_signal_at", 0.0) or 0.0)
if (
last_explicit_start_at > 0
and now_ts - last_explicit_start_at < explicit_start_grace_seconds
):
log_skip(
"recent_explicit_start_signal"
)
return False
if self.detecting:
stale_detect, stale_reason = self._is_stale_detect_session()
if not stale_detect:
log_skip(stale_reason or "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._load_active_detect_job_snapshot(reason, now_ts)
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)
selection_reason = str(active_job.get("selection_reason") or "").strip()
if selection_reason == "running_job_stalled" or bool(active_job.get("running_job_stalled")):
self._maybe_recycle_stalled_job_items(
job_id,
reason=f"autoresume:{reason or 'unspecified'}",
)
log_skip(
"running_job_stalled"
)
return False
tail_handoff_candidate = bool(active_job.get("tail_handoff_candidate")) or selection_reason == "tail_handoff_pending"
if items_pending <= 0 and items_claimed <= 0 and items_running <= 0:
log_skip("active_job_empty")
return False
if reason == "heartbeat_autoresume":
should_skip_targeted_job, targeted_job_detail = self._should_skip_ignored_targeted_job_autoresume(
active_job=active_job,
)
if should_skip_targeted_job:
log_skip(targeted_job_detail or "ignored_targeted_job")
return False
should_skip_explicit_scope, explicit_scope_detail = self._should_skip_explicit_scope_autoresume(
active_job=active_job,
)
if should_skip_explicit_scope:
log_skip(explicit_scope_detail or "explicit_scope_autoresume_guard")
return False
should_skip_same_job, skip_detail = self._should_skip_same_job_autoresume(
active_job=active_job,
now_ts=now_ts,
)
if should_skip_same_job:
log_skip(skip_detail or "same_job_autoresume_guard")
return False
if reason in {"service_runtime_bootstrap", "redis_subscription_bootstrap"}:
if not self._acquire_bootstrap_autoresume_lock(job_id=int(job_id), job_code=job_code):
log_skip("bootstrap_autoresume_lock_held")
return False
if (
reason in autoresume_reasons
and node_role != "control"
and not is_primary_worker
and not tail_handoff_candidate
):
log_skip("non_primary_autoresume_worker")
return False
logger.warning(
f"检测 Worker 空闲但发现活动任务,准备自动回挂: "
f"job_code={job_code}, pending={items_pending}, claimed={items_claimed}, "
f"running={items_running}, reason={reason}, selection={selection_reason or '-'}"
)
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,
"selection_reason": selection_reason,
"tail_handoff_candidate": tail_handoff_candidate,
},
)
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}, "
f"selection={selection_reason or '-'}"
)
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
waiting_for_dispatch = bool(self.detecting and effective_active_threads <= 0)
merged_extra["waiting_for_dispatch"] = waiting_for_dispatch
explicit_current_load = (extra or {}).get("current_load")
default_current_load = effective_active_threads if effective_active_threads > 0 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
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")),
"waiting_for_dispatch": bool(payload.get("waiting_for_dispatch")),
"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):
now_ts = time.time()
self.detect_command_last_activity_at = now_ts
self._last_worker_activity_at = now_ts
def _note_domain_started(self):
now_ts = time.time()
self._last_domain_started_at = now_ts
self.detect_command_last_activity_at = now_ts
self._last_worker_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
self._last_worker_activity_at = now_ts
def _idle_worker_recycle_enabled(self):
return self._env_flag_enabled("DOMAINCHECK_IDLE_WORKER_RECYCLE_ENABLED", default=False)
def _idle_worker_recycle_after_seconds(self):
try:
return max(30.0, float(os.getenv("DOMAINCHECK_IDLE_WORKER_RECYCLE_AFTER_SECONDS", "180") or 180.0))
except Exception:
return 180.0
def _idle_worker_recycle_min_uptime_seconds(self):
try:
return max(30.0, float(os.getenv("DOMAINCHECK_IDLE_WORKER_RECYCLE_MIN_UPTIME_SECONDS", "300") or 300.0))
except Exception:
return 300.0
def _idle_worker_recycle_explicit_start_grace_seconds(self):
try:
return max(10.0, float(os.getenv("DOMAINCHECK_IDLE_WORKER_RECYCLE_START_GRACE_SECONDS", "90") or 90.0))
except Exception:
return 90.0
def _idle_worker_recycle_jitter_seconds(self):
try:
return max(0.0, float(os.getenv("DOMAINCHECK_IDLE_WORKER_RECYCLE_JITTER_SECONDS", "45") or 45.0))
except Exception:
return 45.0
def _idle_worker_recycle_jitter_offset(self):
jitter_window = float(self._idle_worker_recycle_jitter_seconds() or 0.0)
if jitter_window <= 0:
return 0.0
node_code = str(getattr(config, "NODE_CODE", "") or "").strip() or str(os.getpid())
spread = sum(ord(ch) for ch in node_code) % 1000
return (spread / 1000.0) * jitter_window
def _is_recyclable_idle_worker_instance(self):
if str(os.getenv("WORKER_MODE", "") or "").strip() != "linux-systemd":
return False
parent_node_code = self._worker_parent_node_code()
node_code = str(getattr(config, "NODE_CODE", "") or "").strip()
if not parent_node_code or not node_code:
return False
return node_code != parent_node_code and node_code.startswith(f"{parent_node_code}-")
def _idle_worker_waiting_for_dispatch(self):
runtime_extra = dict(getattr(self, "_last_runtime_extra", {}) or {})
if bool(runtime_extra.get("waiting_for_dispatch", False)):
return True
return bool(self.detecting and self._get_active_domain_threads() <= 0)
def _idle_worker_seconds(self):
last_activity_at = max(
float(getattr(self, "_last_worker_activity_at", 0.0) or 0.0),
float(getattr(self, "detect_command_last_activity_at", 0.0) 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:
last_activity_at = float(getattr(self, "_worker_started_at", 0.0) or time.time())
return max(0.0, time.time() - last_activity_at)
def _trigger_idle_worker_recycle(self, *, trigger_reason: str, idle_seconds: float):
if self._idle_recycle_requested:
return False
self._idle_recycle_requested = True
detail = (
f"子 Worker 空闲过久,触发生命周期回收: idle={int(idle_seconds)}s, "
f"trigger={trigger_reason or 'unknown'}"
)
logger.warning(detail)
try:
self._sync_worker_log_event(
"空闲 Worker 生命周期回收",
level='warning',
payload={
"idle_seconds": int(idle_seconds),
"trigger_reason": str(trigger_reason or "").strip() or "unknown",
"node_code": str(getattr(config, "NODE_CODE", "") or "").strip(),
},
mode='key',
)
except Exception:
pass
try:
self._update_runtime_state(
"restarting",
detail,
active_threads=0,
current_load=0,
recycle_reason=str(trigger_reason or "").strip() or "idle",
)
except Exception:
pass
try:
self._flush_pending_running_marks(force=True, batch_limit=2048)
self._flush_pending_job_releases(force=True, batch_limit=2048)
self._flush_pending_job_finalizations(force=True, batch_limit=2048)
self._flush_pending_domain_status_updates(force=True, batch_limit=2048)
self._flush_pending_domain_completions(force=True, batch_limit=2048)
self._flush_pending_review_status_updates(force=True, batch_limit=2048)
self._flush_completed_futures(force=True, batch_limit=2048)
except Exception as exc:
logger.debug(f"空闲 Worker 回收前 flush 失败: {exc}")
self.stop_requested = True
self.detecting = False
self.running = False
self._runtime_heartbeat_stop.set()
self._worker_log_sync_stop.set()
try:
self._worker_log_sync_queue.put_nowait(None)
except Exception:
pass
time.sleep(0.2)
os.kill(os.getpid(), signal.SIGTERM)
return True
def _maybe_recycle_idle_worker(self, *, trigger_reason: str = ""):
if self._idle_recycle_requested:
return False
if not self._idle_worker_recycle_enabled():
return False
if not self._is_recyclable_idle_worker_instance():
return False
if not self.running or self.stop_requested:
return False
if self._get_active_domain_threads() > 0:
return False
if not self._idle_worker_waiting_for_dispatch():
return False
if time.time() - float(getattr(self, "_worker_started_at", 0.0) or 0.0) < self._idle_worker_recycle_min_uptime_seconds():
return False
explicit_start_at = float(getattr(self, "_last_explicit_start_signal_at", 0.0) or 0.0)
if explicit_start_at > 0 and time.time() - explicit_start_at < self._idle_worker_recycle_explicit_start_grace_seconds():
return False
idle_seconds = self._idle_worker_seconds()
recycle_after = self._idle_worker_recycle_after_seconds() + self._idle_worker_recycle_jitter_offset()
if idle_seconds < recycle_after:
return False
return self._trigger_idle_worker_recycle(
trigger_reason=trigger_reason or "idle_waiting_for_dispatch",
idle_seconds=idle_seconds,
)
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 _detection_session_abort_reason(self, session_id: int = 0) -> str:
if not self.running:
return "worker_stopped"
normalized_session_id = int(session_id or 0)
if normalized_session_id <= 0:
return ""
current_owner = int(getattr(self, "_detect_session_owner", 0) or 0)
if current_owner > 0 and current_owner != normalized_session_id:
return f"session_replaced:{current_owner}"
return ""
def _release_job_item_for_session_abort(self, job_item_id, claim_token, *, session_id: int = 0, domain_name: str = "", detail: str = ""):
abort_reason = self._detection_session_abort_reason(session_id)
if not abort_reason:
return False
try:
normalized_job_item_id = int(job_item_id or 0)
except Exception:
normalized_job_item_id = 0
normalized_claim_token = str(claim_token or "").strip()
if normalized_job_item_id <= 0 or not normalized_claim_token:
return False
normalized_detail = str(detail or "").strip()
release_reason = f"released after session abort: {abort_reason}"
if normalized_detail:
release_reason = f"{release_reason} | {normalized_detail[:240]}"
release_reason = release_reason[:1000]
released = self._enqueue_job_release(
normalized_job_item_id,
normalized_claim_token,
reason=release_reason,
)
if released:
pending_release_count = 0
with self._job_release_lock:
pending_release_count = len(self._pending_job_releases)
if pending_release_count >= 64:
self._flush_pending_job_releases(batch_limit=512)
logger.warning(
f"检测会话已切换,任务项已登记回队列: domain={domain_name or 'unknown'}, "
f"job_item_id={normalized_job_item_id}, reason={abort_reason}, "
f"detail={str(detail or '').strip() or 'none'}"
)
return released
def _assert_detection_session_active(self, session_id: int = 0, *, domain_name: str = "", stage: str = "") -> None:
abort_reason = self._detection_session_abort_reason(session_id)
if not abort_reason:
return
normalized_stage = str(stage or "unknown").strip() or "unknown"
normalized_domain_name = str(domain_name or "").strip()
detail = f"检测会话已失效: reason={abort_reason} stage={normalized_stage}"
if normalized_domain_name:
detail = f"{detail} domain={normalized_domain_name}"
raise RuntimeError(detail)
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 _incoming_cycle_token(self, control_payload=None) -> str:
payload = control_payload or {}
return str(payload.get("cycle_token") or "").strip()
def _incoming_control_source(self, control_payload=None) -> str:
payload = control_payload or {}
return str(payload.get("source") or "").strip()
def _incoming_job_id(self, control_payload=None):
payload = control_payload or {}
# For domain_pipeline sync-pull commands, runtime/log identity may follow
# the upstream projection job_code/job_id, but local queue ownership must
# stay pinned to the ingested target_job_id. Claim/release/session scope
# logic therefore prefers target_job_id when it exists.
job_id = payload.get("target_job_id")
if job_id in (None, "", 0, "0"):
job_id = payload.get("job_id")
try:
return int(job_id) if job_id not in (None, "", 0, "0") else None
except Exception:
return None
def _is_overlap_handoff_command(self, control_payload=None, *, source: str = "") -> bool:
payload = control_payload or {}
normalized_source = str(source or self._incoming_control_source(payload) or "").strip()
if normalized_source == "overlap-handoff":
return True
return bool(payload.get("tail_handoff_candidate"))
def _matches_active_detect_session(self, control_payload=None) -> bool:
payload = control_payload or {}
incoming_job_code = self._incoming_job_code(payload)
incoming_job_id = self._incoming_job_id(payload)
incoming_cycle_token = self._incoming_cycle_token(payload)
current_job_code = str(self.current_job_code or "").strip()
current_cycle_token = str(self.current_cycle_token or "").strip()
current_job_id = getattr(self, "current_job_id", None)
matches_job_code = bool(
incoming_job_code and current_job_code and incoming_job_code == current_job_code
)
matches_job_id = bool(
incoming_job_id not in (None, 0)
and current_job_id not in (None, 0)
and int(incoming_job_id) == int(current_job_id)
)
if not (matches_job_code or matches_job_id):
return False
if incoming_cycle_token and current_cycle_token and incoming_cycle_token != current_cycle_token:
return False
return True
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)
incoming_cycle_token = self._incoming_cycle_token(control_payload)
incoming_task_mode = str(
(control_payload or {}).get("task_mode")
or (control_payload or {}).get("target_task_mode")
or ""
).strip()
current_job_code = str(self.current_job_code or "").strip()
current_cycle_token = str(self.current_cycle_token or "").strip()
if self._matches_active_detect_session(control_payload):
if idle_seconds < 180 and result_idle_seconds < 180:
return False, (
"同任务同周期重复唤起已忽略: "
f"job={incoming_job_code or current_job_code or 'unknown'} "
f"cycle={incoming_cycle_token or current_cycle_token or 'none'}"
)
session_scoped_to_job = bool(
self._is_single_step_session_active()
or incoming_task_mode == "single_step"
)
job_switched = bool(
session_scoped_to_job
and incoming_job_code
and current_job_code
and incoming_job_code != current_job_code
)
incoming_job_changed = bool(
session_scoped_to_job
and 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, ""
hold_for_proxy_shortage, hold_reason = self._should_hold_stale_detect_for_proxy_shortage(
idle_seconds=idle_seconds,
low_activity_recoverable=low_activity_recoverable,
incoming_job_changed=job_switched or incoming_job_changed,
)
if hold_for_proxy_shortage:
return False, hold_reason
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._clear_restart_release_handoff(reason="clear_active_cycle")
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 _shared_job_maintenance_scope(self):
parent_node_code = str(os.getenv("WORKER_PARENT_NODE_CODE", "") or "").strip()
scope_node = parent_node_code or str(getattr(config, "NODE_CODE", "") or "").strip() or "unknown"
db_host = str(getattr(config, "DB_HOST", "") or "").strip() or "db"
db_port = str(getattr(config, "DB_PORT", "") or "").strip() or "5432"
db_name = str(getattr(config, "DB_DATABASE", "") or "").strip() or "domain"
return f"{db_host}:{db_port}:{db_name}:{scope_node}"
def _shared_job_maintenance_lock_key(self, action: str) -> str:
if not self.use_redis or self.redis_client is None:
return ""
normalized_action = str(action or "").strip()
if not normalized_action:
return ""
return f"domaincheck:job-maintenance:{normalized_action}:{self._shared_job_maintenance_scope()}"
def _job_maintenance_owner_token(self, action: str) -> str:
return (
f"{str(getattr(config, 'NODE_CODE', '') or '').strip()}:"
f"{os.getpid()}:{threading.get_ident()}:{time.time_ns()}:{str(action or '').strip()}"
)
def _acquire_shared_job_maintenance_lock(self, action: str, *, ttl_seconds: int = 15) -> str:
redis_key = self._shared_job_maintenance_lock_key(action)
if not redis_key:
return "__local__" if self._is_primary_job_maintenance_worker() else ""
owner_token = self._job_maintenance_owner_token(action)
try:
acquired = self.redis_client.set(
redis_key,
owner_token,
nx=True,
ex=max(1, int(ttl_seconds or 15)),
)
except Exception as exc:
logger.debug(f"获取共享任务维护锁失败: action={action}, error={exc}")
return ""
return owner_token if acquired else ""
def _release_shared_job_maintenance_lock(self, action: str, owner_token: str) -> None:
if not owner_token or owner_token == "__local__":
return
redis_key = self._shared_job_maintenance_lock_key(action)
if not redis_key:
return
try:
current_owner = self.redis_client.get(redis_key)
if str(current_owner or "") == str(owner_token):
self.redis_client.delete(redis_key)
except Exception as exc:
logger.debug(f"释放共享任务维护锁失败: action={action}, error={exc}")
def _stalled_job_recycle_interval_seconds(self) -> float:
return max(
15.0,
float(os.getenv("DOMAINCHECK_STALLED_JOB_RECYCLE_INTERVAL_SECONDS", "60") or 60),
)
def _stalled_job_recycle_stall_seconds(self) -> int:
return max(
300,
int(os.getenv("DOMAINCHECK_RUNNING_JOB_STALL_SECONDS", "900") or 900),
)
def _maybe_recycle_stalled_job_items(self, job_id, *, reason: str = "") -> int:
try:
normalized_job_id = int(job_id or 0)
except Exception:
normalized_job_id = 0
if normalized_job_id <= 0:
return 0
now_ts = time.time()
last_attempts = getattr(self, "_last_stalled_job_recycle_attempt_at", None)
if not isinstance(last_attempts, dict):
last_attempts = {}
self._last_stalled_job_recycle_attempt_at = last_attempts
last_attempt_at = float(last_attempts.get(normalized_job_id, 0.0) or 0.0)
if now_ts - last_attempt_at < self._stalled_job_recycle_interval_seconds():
return 0
last_attempts[normalized_job_id] = now_ts
lock_action = f"recycle-stalled-job-items:{normalized_job_id}"
lock_owner = self._acquire_shared_job_maintenance_lock(lock_action, ttl_seconds=15)
if not lock_owner:
return 0
try:
recycled = int(
self.db.recycle_stalled_detect_job_items(
normalized_job_id,
stall_seconds=self._stalled_job_recycle_stall_seconds(),
)
or 0
)
if recycled:
logger.warning(
f"已定向回收长挂任务项: job_id={normalized_job_id}, "
f"recycled={recycled}, reason={reason or 'unspecified'}"
)
return recycled
finally:
self._release_shared_job_maintenance_lock(lock_action, lock_owner)
def _is_primary_job_maintenance_worker(self) -> bool:
node_code = str(getattr(config, "NODE_CODE", "") or "").strip()
parent_node_code = str(os.getenv("WORKER_PARENT_NODE_CODE", "") or "").strip()
if parent_node_code and node_code == parent_node_code:
return True
if parent_node_code and node_code == f"{parent_node_code}-a":
return True
return bool(node_code and node_code.endswith("-a"))
def _maybe_release_node_job_items(self, *, reason: str = "", min_interval_seconds: float = 300.0) -> int:
now_ts = time.time()
last_release_at = float(getattr(self, "_last_job_item_release_at", 0.0) or 0.0)
if min_interval_seconds > 0 and now_ts - last_release_at < float(min_interval_seconds):
return 0
released = int(self.db.release_detect_job_items_for_node(config.NODE_CODE) or 0)
self._last_job_item_release_at = now_ts
if released:
self._schedule_restart_release_handoff(
released_count=released,
reason=reason or "node_item_release",
)
logger.warning(
f"释放当前节点遗留任务项: count={released}, node={config.NODE_CODE}, "
f"reason={reason or 'unspecified'}"
)
return released
def _restart_release_handoff_window_seconds(self) -> float:
return max(
30.0,
float(os.getenv("DOMAINCHECK_RESTART_RELEASE_HANDOFF_WINDOW_SECONDS", "240") or 240),
)
def _restart_release_handoff_batch_limit(self, requested_limit: int) -> int:
configured = int(os.getenv("DOMAINCHECK_RESTART_RELEASE_HANDOFF_BATCH_LIMIT", "128") or 128)
safe_requested_limit = max(1, int(requested_limit or 1))
return max(1, min(safe_requested_limit, max(1, configured)))
def _schedule_restart_release_handoff(self, *, released_count: int = 0, reason: str = "") -> None:
if int(released_count or 0) <= 0:
return
job_id = getattr(self, "current_job_id", None)
if job_id in (None, "", 0, "0"):
return
try:
normalized_job_id = int(job_id)
except Exception:
return
self._restart_release_handoff_job_id = normalized_job_id
self._restart_release_handoff_job_code = str(getattr(self, "current_job_code", "") or "").strip()
self._restart_release_handoff_until = time.time() + self._restart_release_handoff_window_seconds()
self._restart_release_handoff_reason = str(reason or "").strip()
logger.warning(
f"登记重启尾批优先回收窗口: job_id={normalized_job_id}, "
f"job_code={self._restart_release_handoff_job_code or '-'}, "
f"released={int(released_count or 0)}, reason={self._restart_release_handoff_reason or 'unspecified'}"
)
def _clear_restart_release_handoff(self, *, reason: str = "") -> None:
job_id = getattr(self, "_restart_release_handoff_job_id", None)
if job_id not in (None, "", 0, "0"):
logger.info(
f"清理重启尾批优先回收窗口: job_id={job_id}, "
f"job_code={str(getattr(self, '_restart_release_handoff_job_code', '') or '').strip() or '-'}, "
f"reason={str(reason or 'unspecified').strip() or 'unspecified'}"
)
self._restart_release_handoff_job_id = None
self._restart_release_handoff_job_code = ""
self._restart_release_handoff_until = 0.0
self._restart_release_handoff_reason = ""
def _restart_release_handoff_job_id_active(self):
handoff_job_id = getattr(self, "_restart_release_handoff_job_id", None)
if handoff_job_id in (None, "", 0, "0"):
return None
if time.time() >= float(getattr(self, "_restart_release_handoff_until", 0.0) or 0.0):
self._clear_restart_release_handoff(reason="expired")
return None
current_job_id = getattr(self, "current_job_id", None)
if current_job_id not in (None, "", 0, "0"):
try:
if int(current_job_id) != int(handoff_job_id):
self._clear_restart_release_handoff(reason="job_changed")
return None
except Exception:
self._clear_restart_release_handoff(reason="job_changed")
return None
try:
return int(handoff_job_id)
except Exception:
self._clear_restart_release_handoff(reason="invalid_job_id")
return None
def _explicit_claim_scope_window_seconds(self) -> float:
return max(
30.0,
float(os.getenv("DOMAINCHECK_EXPLICIT_CLAIM_SCOPE_WINDOW_SECONDS", "180") or 180),
)
def _schedule_explicit_claim_scope(self, control_payload=None, *, source: str = "") -> None:
payload = dict(control_payload or {})
task_mode = str(
payload.get("task_mode")
or payload.get("target_task_mode")
or ""
).strip()
if task_mode == "single_step":
return
job_id = self._incoming_job_id(payload)
if job_id in (None, "", 0, "0"):
return
try:
normalized_job_id = int(job_id)
except Exception:
return
job_code = self._incoming_job_code(payload)
self._explicit_claim_scope_job_id = normalized_job_id
self._explicit_claim_scope_job_code = str(job_code or "").strip()
self._explicit_claim_scope_until = time.time() + self._explicit_claim_scope_window_seconds()
self._explicit_claim_scope_source = str(source or "").strip()
logger.warning(
f"登记显式启动定向领取窗口: job_id={normalized_job_id}, "
f"job_code={self._explicit_claim_scope_job_code or '-'}, "
f"source={self._explicit_claim_scope_source or 'unknown'}"
)
def _clear_explicit_claim_scope(self, *, reason: str = "") -> None:
job_id = getattr(self, "_explicit_claim_scope_job_id", None)
if job_id not in (None, "", 0, "0"):
logger.info(
f"清理显式启动定向领取窗口: job_id={job_id}, "
f"job_code={str(getattr(self, '_explicit_claim_scope_job_code', '') or '').strip() or '-'}, "
f"reason={str(reason or 'unspecified').strip() or 'unspecified'}"
)
self._explicit_claim_scope_job_id = None
self._explicit_claim_scope_job_code = ""
self._explicit_claim_scope_until = 0.0
self._explicit_claim_scope_source = ""
def _explicit_claim_scope_job_id_active(self):
explicit_job_id = getattr(self, "_explicit_claim_scope_job_id", None)
if explicit_job_id in (None, "", 0, "0"):
return None
if time.time() >= float(getattr(self, "_explicit_claim_scope_until", 0.0) or 0.0):
self._clear_explicit_claim_scope(reason="expired")
return None
try:
return int(explicit_job_id)
except Exception:
self._clear_explicit_claim_scope(reason="invalid_job_id")
return None
def _should_scope_claims_to_current_job(self):
"""
single_step 会话必须严格绑定当前 job
domain_pipeline 会话则应持续从全局队列补位,避免旧 job 占住 worker、
新 job 长时间堆在 pending。
"""
if self._is_single_step_session_active():
return True
if self._restart_release_handoff_job_id_active() not in (None, "", 0, "0"):
return True
return self._explicit_claim_scope_job_id_active() not in (None, "", 0, "0")
def _current_scoped_claim_job_id(self):
if self._is_single_step_session_active():
try:
return int(self.current_job_id) if self.current_job_id not in (None, "", 0, "0") else None
except Exception:
return None
handoff_job_id = self._restart_release_handoff_job_id_active()
if handoff_job_id not in (None, "", 0, "0"):
return handoff_job_id
explicit_job_id = self._explicit_claim_scope_job_id_active()
if explicit_job_id not in (None, "", 0, "0"):
return explicit_job_id
return None
def _log_explicit_scope_claim_result(self, *, scoped_job_id, limit, rows_count, reason=""):
try:
normalized_job_id = int(scoped_job_id or 0)
except Exception:
normalized_job_id = 0
if normalized_job_id <= 0:
return
normalized_rows = max(0, int(rows_count or 0))
normalized_limit = max(0, int(limit or 0))
detail_reason = str(reason or "").strip() or ("claimed" if normalized_rows > 0 else "empty")
log_key = f"{normalized_job_id}:{normalized_rows}:{detail_reason}"
now_ts = time.time()
min_interval_seconds = 1.0 if normalized_rows > 0 else 10.0
if (
log_key == str(getattr(self, "_last_explicit_scope_claim_log_key", "") or "")
and now_ts - float(getattr(self, "_last_explicit_scope_claim_log_at", 0.0) or 0.0) < min_interval_seconds
):
return
self._last_explicit_scope_claim_log_at = now_ts
self._last_explicit_scope_claim_log_key = log_key
log_message = (
f"显式定向领取{'命中' if normalized_rows > 0 else '未命中'}: "
f"scoped_job_id={normalized_job_id} | current_job_id={self.current_job_id or '-'} "
f"| current_job_code={self.current_job_code or '-'} | detecting={1 if self.detecting else 0} "
f"| limit={normalized_limit} | rows={normalized_rows} | reason={detail_reason}"
)
if normalized_rows > 0:
logger.warning(log_message)
else:
logger.info(log_message)
def _log_explicit_scope_probe(self, *, scoped_job_id, max_threads, refill_slots, claim_batch_size, pending_buffer):
try:
normalized_job_id = int(scoped_job_id or 0)
except Exception:
normalized_job_id = 0
if normalized_job_id <= 0:
return
now_ts = time.time()
if now_ts - float(getattr(self, "_last_explicit_scope_probe_log_at", 0.0) or 0.0) < 5.0:
return
self._last_explicit_scope_probe_log_at = now_ts
logger.warning(
f"显式定向领取准备开始: scoped_job_id={normalized_job_id} | current_job_id={self.current_job_id or '-'} "
f"| current_job_code={self.current_job_code or '-'} | detecting={1 if self.detecting else 0} "
f"| max_threads={max(0, int(max_threads or 0))} | refill_slots={max(0, int(refill_slots or 0))} "
f"| claim_batch_size={max(0, int(claim_batch_size or 0))} | pending_buffer={max(0, int(pending_buffer or 0))}"
)
def _log_job_status_refresh_probe(self, job_id):
try:
normalized_job_id = int(job_id or 0)
except Exception:
normalized_job_id = 0
if normalized_job_id <= 0:
return
now_ts = time.time()
if now_ts - float(getattr(self, "_last_job_status_refresh_probe_log_at", 0.0) or 0.0) < 10.0:
return
self._last_job_status_refresh_probe_log_at = now_ts
logger.info(
f"准备刷新检测任务聚合状态: job_id={normalized_job_id} | current_job_code={self.current_job_code or '-'} "
f"| detecting={1 if self.detecting else 0} | active_threads={self._get_active_domain_threads()}"
)
def _recent_duplicate_start_window_seconds(self) -> float:
return max(
15.0,
float(os.getenv("DOMAINCHECK_RECENT_START_DUPLICATE_WINDOW_SECONDS", "120") or 120),
)
def _record_detection_start_request(self, *, source: str = "", control_payload=None) -> None:
now_ts = time.time()
payload = dict(control_payload or {})
job_id = self._incoming_job_id(payload)
job_code = self._incoming_job_code(payload)
task_mode = str(
payload.get("task_mode")
or payload.get("target_task_mode")
or ""
).strip()
self._last_detection_start_at = now_ts
self._last_detection_start_source = str(source or "").strip()
self._last_detection_start_job_id = int(job_id) if job_id not in (None, 0) else None
self._last_detection_start_job_code = job_code
self._last_detection_start_task_mode = task_mode
def _mark_explicit_start_signal(self, control_payload=None, *, source: str = "") -> None:
self._last_explicit_start_signal_at = time.time()
self._last_explicit_start_payload = {
**dict(control_payload or {}),
"_source": str(source or "").strip(),
}
self._schedule_explicit_claim_scope(control_payload, source=source)
def _should_coalesce_recent_start(self, control_payload=None) -> tuple[bool, str]:
now_ts = time.time()
recent_start_at = float(getattr(self, "_last_detection_start_at", 0.0) or 0.0)
if recent_start_at <= 0:
return False, ""
if now_ts - recent_start_at > self._recent_duplicate_start_window_seconds():
return False, ""
if self.stop_requested:
return False, ""
thread = self.detect_command_thread
thread_alive = bool(thread and thread.is_alive())
if not bool(self.detecting) and not thread_alive:
# A recent start alone is not enough to suppress a legitimate retry.
# Once the previous session has fully stopped, repeated start_detection
# for the same job must be allowed to re-open tail work.
return False, ""
stale_detect, _ = self._is_stale_detect_session(control_payload)
if stale_detect:
# A stale session must never suppress a restart for the same job.
# Otherwise auto-resume loops forever on "already running" while
# active_threads stays at zero.
return False, ""
payload = dict(control_payload or {})
incoming_job_code = self._incoming_job_code(payload)
incoming_job_id = self._incoming_job_id(payload)
incoming_task_mode = str(
payload.get("task_mode")
or payload.get("target_task_mode")
or ""
).strip()
recent_job_code = str(getattr(self, "_last_detection_start_job_code", "") or "").strip()
recent_job_id = getattr(self, "_last_detection_start_job_id", None)
recent_task_mode = str(getattr(self, "_last_detection_start_task_mode", "") or "").strip()
if incoming_job_code and recent_job_code and incoming_job_code != recent_job_code:
return False, ""
if (
incoming_job_id not in (None, 0)
and recent_job_id not in (None, 0)
and int(incoming_job_id) != int(recent_job_id)
):
return False, ""
if incoming_task_mode == "single_step" and recent_task_mode and incoming_task_mode != recent_task_mode:
return False, ""
effective_job_code = incoming_job_code or recent_job_code or str(self.current_job_code or "").strip()
effective_job_id = incoming_job_id or recent_job_id or self.current_job_id
if not effective_job_code and effective_job_id in (None, 0):
return False, ""
return True, (
"最近已启动同一轮检测,会话仍在稳定中,忽略重复启动: "
f"job={effective_job_code or effective_job_id or 'none'} "
f"source={str(getattr(self, '_last_detection_start_source', '') or '').strip() or 'unknown'}"
)
def start_detection_async(self, source: str = "remote", control_payload=None):
"""
异步启动一次检测任务,避免阻塞 Redis 订阅线程。
"""
with self.detect_lock:
recent_duplicate_start, recent_duplicate_message = self._should_coalesce_recent_start(control_payload)
if recent_duplicate_start:
logger.info(recent_duplicate_message)
self._update_runtime_state(
"running",
recent_duplicate_message,
source=source,
cycle_token=self.current_cycle_token or self._incoming_cycle_token(control_payload),
job_id=self.current_job_id or self._incoming_job_id(control_payload),
job_code=str(self.current_job_code or "").strip() or self._incoming_job_code(control_payload),
)
return True
if self.detect_command_thread and self.detect_command_thread.is_alive():
duplicate_session_command = self._matches_active_detect_session(control_payload)
stale_detect, stale_reason = self._is_stale_detect_session(control_payload)
if not stale_detect:
incoming_job_code = self._incoming_job_code(control_payload)
incoming_cycle_token = self._incoming_cycle_token(control_payload)
incoming_job_id = self._incoming_job_id(control_payload)
overlap_handoff_requested = self._is_overlap_handoff_command(control_payload, source=source)
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 duplicate_session_command:
message = (
"收到同任务同周期重复启动指令,保持当前检测会话: "
f"job={current_job_code or incoming_job_code or 'none'} "
f"cycle={self.current_cycle_token or incoming_cycle_token or 'none'}"
)
logger.info(message)
self._update_runtime_state(
"running",
message,
source=source,
cycle_token=self.current_cycle_token or incoming_cycle_token,
job_id=self.current_job_id or incoming_job_id,
job_code=current_job_code or incoming_job_code,
)
return True
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
):
explicit_scope_job_id = self._explicit_claim_scope_job_id_active()
explicit_switch_requested = bool(
explicit_scope_job_id not in (None, "", 0, "0")
and incoming_job_id not in (None, "", 0, "0")
and int(explicit_scope_job_id) == int(incoming_job_id)
)
live_active_threads = max(0, int(self._get_active_domain_threads() or 0))
if overlap_handoff_requested and live_active_threads > 0:
message = (
"收到 overlap handoff 启动指令,但当前会话仍有活跃线程,忽略跨 job 抢占: "
f"current={current_job_code or 'none'} -> incoming={incoming_job_code} "
f"active_threads={live_active_threads}"
)
logger.info(message)
if explicit_switch_requested:
self._clear_explicit_claim_scope(reason="overlap_busy_ignore")
self._update_runtime_state(
"running",
message,
source=source,
cycle_token=self.current_cycle_token or incoming_cycle_token,
job_id=self.current_job_id or incoming_job_id,
job_code=current_job_code or incoming_job_code,
)
return True
if explicit_switch_requested:
message = (
"检测任务已在运行,收到显式 pipeline 切换指令,"
f"登记安全重启接棒: current={current_job_code or 'none'} -> incoming={incoming_job_code}"
)
logger.warning(message)
self.stop_requested = True
self._schedule_pending_restart(source, control_payload, message)
self._update_runtime_state(
"restarting",
message,
source=source,
cycle_token=incoming_cycle_token or self.current_cycle_token,
job_id=incoming_job_id,
job_code=incoming_job_code,
)
return True
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._record_detection_start_request(source=source, control_payload=control_payload)
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(session_id=session_id)
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._clear_active_cycle_context()
self._update_runtime_state(
"idle",
"检测任务结束Worker 保持待命",
source=source,
)
try:
self._maybe_recycle_idle_worker(trigger_reason="session_finalize")
except Exception as recycle_error:
logger.debug(f"检测任务结束后空闲回收检查失败: {recycle_error}")
else:
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
pending_keys = [pending_control_key()]
if pending_keys[0] != PENDING_CONTROL_KEY:
pending_keys.append(PENDING_CONTROL_KEY)
for key in pending_keys:
try:
raw_pending = self.redis_client.get(key)
if not raw_pending:
continue
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(key)
except Exception as e:
logger.debug(f"确认待执行控制指令失败: key={key}, error={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)}
if not control_targets_current_worker(control_payload):
self._remember_ignored_targeted_control(control_payload)
target_node_codes = ",".join(control_target_node_codes(control_payload)) or "unknown"
logger.info(
f"忽略发往其他 Worker 实例的控制指令: current={config.NODE_CODE}, targets={target_node_codes}"
)
return
self._acknowledge_pending_control_command(control_payload)
action = str(control_payload.get("action", "")).strip()
if action == "start_detection":
self._mark_explicit_start_signal(control_payload, source="redis-control")
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 getattr(self, "use_redis", False):
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 _worker_parent_node_code(self):
return str(os.getenv("WORKER_PARENT_NODE_CODE", "") or "").strip()
def _worker_node_code_candidates(self):
candidates = []
for candidate in (
str(getattr(config, "NODE_CODE", "") or "").strip(),
self._worker_parent_node_code(),
):
if candidate and candidate not in candidates:
candidates.append(candidate)
return candidates
def load_thread_count(self):
"""
加载检测线程数
"""
try:
node_code_candidates = self._worker_node_code_candidates()
node_code = node_code_candidates[0] if node_code_candidates else ""
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
for candidate in node_code_candidates:
if candidate 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 "-",
)
)
def _resolve_node_override(overrides, source_name):
if not isinstance(overrides, dict):
return None
for candidate in node_code_candidates:
node_thread_count_raw = overrides.get(candidate)
if node_thread_count_raw is None:
continue
thread_count = max(1, int(node_thread_count_raw))
if candidate == node_code:
logger.info(f"{source_name}加载节点专属检测线程数成功: {candidate} -> {thread_count}")
else:
logger.info(
f"{source_name}加载父节点检测线程覆盖成功: {candidate} -> {thread_count} (current={node_code})"
)
return thread_count
return None
# 从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}")
resolved_node_thread_count = _resolve_node_override(node_thread_counts, "Redis")
if resolved_node_thread_count is not None:
return resolved_node_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
# 从本地文件获取配置
node_thread_counts_payload = _read_worker_local_json_config('node_thread_counts.json')
if isinstance(node_thread_counts_payload, dict):
node_thread_counts = node_thread_counts_payload
resolved_node_thread_count = _resolve_node_override(node_thread_counts, "本地文件")
if resolved_node_thread_count is not None:
return resolved_node_thread_count
thread_config = _read_worker_local_json_config('thread_count.json')
if isinstance(thread_config, dict):
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 = 1000
logger.info(f"使用默认检测线程数: {default_thread_count}")
return default_thread_count
except Exception as e:
logger.error(f"加载检测线程数失败: {e}")
default_thread_count = 1000
logger.info(f"使用默认检测线程数: {default_thread_count}")
return default_thread_count
def load_process_count(self):
"""
加载检测进程数
"""
try:
node_code_candidates = self._worker_node_code_candidates()
node_code = node_code_candidates[0] if node_code_candidates else ""
node_process_counts = {}
redis_global_process_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
for candidate in node_code_candidates:
if candidate 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 "-",
)
)
def _resolve_node_override(overrides, source_name):
if not isinstance(overrides, dict):
return None
for candidate in node_code_candidates:
node_process_count_raw = overrides.get(candidate)
if node_process_count_raw is None:
continue
process_count = max(1, int(node_process_count_raw))
if candidate == node_code:
logger.info(f"{source_name}加载节点专属检测进程数成功: {candidate} -> {process_count}")
else:
logger.info(
f"{source_name}加载父节点检测进程覆盖成功: {candidate} -> {process_count} (current={node_code})"
)
return process_count
return None
if self.use_redis:
node_process_counts_raw = self.redis_client.get('domain_tool:node_process_counts')
if node_process_counts_raw:
try:
node_process_counts = json.loads(node_process_counts_raw)
except Exception as e:
logger.warning(f"解析 Redis 节点进程覆盖配置失败: {e}")
resolved_node_process_count = _resolve_node_override(node_process_counts, "Redis")
if resolved_node_process_count is not None:
return resolved_node_process_count
process_count_str = self.redis_client.get('domain_tool:process_count')
if process_count_str:
process_count = max(1, int(process_count_str))
redis_global_process_count = process_count
_warn_missing_node_override("Redis", node_process_counts, process_count)
logger.info(f"从Redis加载检测进程数成功: {process_count}")
return process_count
node_process_counts_payload = _read_worker_local_json_config('node_process_counts.json')
if isinstance(node_process_counts_payload, dict):
node_process_counts = node_process_counts_payload
resolved_node_process_count = _resolve_node_override(node_process_counts, "本地文件")
if resolved_node_process_count is not None:
return resolved_node_process_count
process_config = _read_worker_local_json_config('process_count.json')
if isinstance(process_config, dict):
process_count = process_config.get('process_count', '1')
process_count = max(1, int(process_count))
if redis_global_process_count is None:
_warn_missing_node_override("本地文件", node_process_counts, process_count)
logger.info(f"从本地文件加载检测进程数成功: {process_count}")
return process_count
default_process_count = max(
1,
int(os.getenv("DOMAINCHECK_NODE_PROCESS_COUNT", "1") or 1),
)
logger.info(f"使用默认检测进程数: {default_process_count}")
return default_process_count
except Exception as e:
logger.error(f"加载检测进程数失败: {e}")
default_process_count = max(
1,
int(os.getenv("DOMAINCHECK_NODE_PROCESS_COUNT", "1") or 1),
)
logger.info(f"使用默认检测进程数: {default_process_count}")
return default_process_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 refresh_process_count_runtime(self, *, force=False, min_interval=5.0):
"""
运行中热刷新进程数配置。
"""
now_ts = time.time()
last_refresh_at = float(getattr(self, "_last_process_count_refresh_at", 0.0) or 0.0)
if not force and now_ts - last_refresh_at < max(0.5, float(min_interval or 0.0)):
return max(1, int(getattr(self, "process_count", 1) or 1))
previous = max(1, int(getattr(self, "process_count", 1) or 1))
latest = max(1, int(self.load_process_count() or previous))
self._last_process_count_refresh_at = now_ts
if latest != previous:
self.process_count = latest
logger.info(f"检测进程数热更新: {previous} -> {latest}")
self.update_config_labels()
return max(1, int(getattr(self, "process_count", latest) or latest))
def _proxy_runtime_process_count(self):
current = max(1, int(getattr(self, "process_count", 1) or 1))
try:
latest = self.refresh_process_count_runtime(min_interval=5.0)
except Exception:
latest = current
return max(1, int(latest or current))
def _proxy_multi_process_divisor(self):
process_count = self._proxy_runtime_process_count()
if process_count <= 1:
return 1.0
return max(1.0, math.sqrt(float(process_count)))
def _proxy_refresh_holdoff_seconds(self):
process_count = self._proxy_runtime_process_count()
if process_count < 4 or not self.detecting:
return 0.0
pid_spread = (os.getpid() % 7) * 0.02
return min(0.9, 0.12 + math.log2(float(process_count)) * 0.06 + pid_spread)
def _proxy_coordination_node_code(self):
return self._worker_parent_node_code() or str(getattr(config, "NODE_CODE", "") or "").strip()
def _proxy_should_coordinate_shared_refresh(self):
return bool(
self.use_redis
and self.redis_client is not None
and self._proxy_runtime_process_count() > 1
and self._proxy_coordination_node_code()
)
def _shared_proxy_pool_key(self):
node_code = self._proxy_coordination_node_code()
if not node_code:
return ""
return f"{SHARED_PROXY_POOL_KEY_PREFIX}:{node_code}"
def _shared_proxy_refresh_lock_key(self):
node_code = self._proxy_coordination_node_code()
if not node_code:
return ""
return f"{SHARED_PROXY_REFRESH_LOCK_KEY_PREFIX}:{node_code}"
def _shared_proxy_snapshot_ttl_seconds(self):
configured = int(os.getenv("DOMAINCHECK_SHARED_PROXY_SNAPSHOT_TTL", "18") or 18)
return max(5, configured)
def _shared_proxy_refresh_lock_seconds(self):
configured = int(os.getenv("DOMAINCHECK_SHARED_PROXY_REFRESH_LOCK_SECONDS", "8") or 8)
return max(2, configured)
def _shared_proxy_wait_timeout_seconds(self):
configured = float(os.getenv("DOMAINCHECK_SHARED_PROXY_WAIT_TIMEOUT", "0.9") or 0.9)
return max(0.2, configured)
def _shared_proxy_reuse_gap(self):
configured = int(os.getenv("DOMAINCHECK_SHARED_PROXY_REUSE_GAP", "0") or 0)
if configured > 0:
return max(8, configured)
refresh_threshold = max(1, int(self._proxy_refresh_threshold() or 1))
return max(24, min(160, max(24, refresh_threshold // 3)))
def _shared_proxy_snapshot_should_replace_local_pool(self, shared_count, local_count, refresh_threshold):
normalized_shared = max(0, int(shared_count or 0))
normalized_local = max(0, int(local_count or 0))
normalized_threshold = max(1, int(refresh_threshold or 1))
if normalized_shared <= 0:
return False
if normalized_local <= 0:
return True
if normalized_shared >= normalized_threshold:
return True
if normalized_shared <= normalized_local:
return False
return (normalized_shared - normalized_local) >= self._shared_proxy_reuse_gap()
def _config_update_proxy_refresh_debounce_seconds(self):
configured = float(os.getenv("DOMAINCHECK_PROXY_CONFIG_UPDATE_REFRESH_DEBOUNCE", "20") or 20)
return max(2.0, configured)
def _shared_proxy_snapshot_owner(self):
return f"{config.NODE_CODE}:{os.getpid()}:{int(time.time() * 1000)}"
def _rotate_proxy_entries_for_current_worker(self, proxy_entries):
entries = list(proxy_entries or [])
if len(entries) <= 1:
return entries
seed_source = str(getattr(config, "NODE_CODE", "") or "").strip() or str(os.getpid())
seed = sum(ord(ch) for ch in seed_source) + int(os.getpid() or 0)
offset = seed % len(entries)
if offset <= 0:
return entries
return entries[offset:] + entries[:offset]
def _shared_proxy_payload_from_entries(self, proxy_entries, *, source_count=0, raw_items=0):
now_ts = time.time()
shareable_entries = []
for proxy_entry in list(proxy_entries or []):
if not isinstance(proxy_entry, dict) or not proxy_entry.get("proxy"):
continue
if not self._proxy_entry_can_be_reused(proxy_entry, now_ts=now_ts):
continue
shareable_entries.append(dict(proxy_entry))
if not shareable_entries:
return None
return {
"node_code": self._proxy_coordination_node_code(),
"owner": str(getattr(config, "NODE_CODE", "") or "").strip(),
"refreshed_at_ts": now_ts,
"source_count": int(source_count or 0),
"raw_items": int(raw_items or 0),
"available_count": len(shareable_entries),
"proxy_pool": shareable_entries,
}
def _load_shared_proxy_snapshot_payload(self, *, max_age_seconds=None):
if not self._proxy_should_coordinate_shared_refresh():
return None
redis_key = self._shared_proxy_pool_key()
if not redis_key:
return None
try:
raw_payload = self.redis_client.get(redis_key)
except Exception as exc:
logger.debug(f"读取共享代理快照失败: {exc}")
return None
if not raw_payload:
return None
try:
payload = json.loads(raw_payload)
except Exception as exc:
logger.debug(f"解析共享代理快照失败: {exc}")
return None
if not isinstance(payload, dict):
return None
refreshed_at_ts = float(payload.get("refreshed_at_ts", 0.0) or 0.0)
allowed_age = max(
1.0,
float(max_age_seconds if max_age_seconds is not None else self._shared_proxy_snapshot_ttl_seconds()),
)
if refreshed_at_ts > 0 and (time.time() - refreshed_at_ts) > allowed_age:
return None
shareable_payload = self._shared_proxy_payload_from_entries(
payload.get("proxy_pool") or [],
source_count=payload.get("source_count", 0),
raw_items=payload.get("raw_items", 0),
)
if not shareable_payload:
return None
shareable_payload["owner"] = str(payload.get("owner") or "").strip()
shareable_payload["refreshed_at_ts"] = refreshed_at_ts or shareable_payload.get("refreshed_at_ts", time.time())
return shareable_payload
def _apply_shared_proxy_snapshot(self, payload, *, status_prefix="复用共享代理快照"):
if not isinstance(payload, dict):
return False
proxy_entries = payload.get("proxy_pool") or []
if not isinstance(proxy_entries, list) or not proxy_entries:
return False
rotated_entries = self._rotate_proxy_entries_for_current_worker(proxy_entries)
with self.proxy_pool_lock:
self.proxy_pool = list(rotated_entries)
pool_size = len(self.proxy_pool)
refreshed_at_ts = float(payload.get("refreshed_at_ts", 0.0) or 0.0)
self.proxy_last_refresh_time = (
datetime.fromtimestamp(refreshed_at_ts) if refreshed_at_ts > 0 else datetime.now()
)
self.proxy_last_refresh_source_count = int(payload.get("source_count", 0) or 0)
self.proxy_last_refresh_total_items = int(payload.get("raw_items", 0) or 0)
self.proxy_last_validated_count = 0
self.proxy_last_available_count = pool_size
source_owner = str(payload.get("owner") or "").strip()
if source_owner and source_owner != str(getattr(config, "NODE_CODE", "") or "").strip():
self.proxy_last_refresh_status = f"{status_prefix} {pool_size} 个(来源 {source_owner}"
else:
self.proxy_last_refresh_status = f"{status_prefix} {pool_size}"
return True
def _publish_shared_proxy_snapshot(self, proxy_entries, *, source_count=0, raw_items=0):
if not self._proxy_should_coordinate_shared_refresh():
return False
payload = self._shared_proxy_payload_from_entries(
proxy_entries,
source_count=source_count,
raw_items=raw_items,
)
if not payload:
return False
redis_key = self._shared_proxy_pool_key()
if not redis_key:
return False
try:
self.redis_client.set(
redis_key,
json.dumps(payload, ensure_ascii=False),
ex=self._shared_proxy_snapshot_ttl_seconds(),
)
return True
except Exception as exc:
logger.debug(f"写入共享代理快照失败: {exc}")
return False
def _acquire_shared_proxy_refresh_lock(self):
if not self._proxy_should_coordinate_shared_refresh():
return ""
redis_key = self._shared_proxy_refresh_lock_key()
if not redis_key:
return ""
owner_token = self._shared_proxy_snapshot_owner()
try:
acquired = self.redis_client.set(
redis_key,
owner_token,
nx=True,
ex=self._shared_proxy_refresh_lock_seconds(),
)
except Exception as exc:
logger.debug(f"获取共享代理刷新锁失败: {exc}")
return ""
return owner_token if acquired else ""
def _release_shared_proxy_refresh_lock(self, owner_token):
if not owner_token or not self._proxy_should_coordinate_shared_refresh():
return
redis_key = self._shared_proxy_refresh_lock_key()
if not redis_key:
return
try:
current_owner = self.redis_client.get(redis_key)
if str(current_owner or "") == str(owner_token):
self.redis_client.delete(redis_key)
except Exception as exc:
logger.debug(f"释放共享代理刷新锁失败: {exc}")
def _wait_for_shared_proxy_snapshot(self, timeout_seconds=None):
if not self._proxy_should_coordinate_shared_refresh():
return False
deadline = time.time() + max(
0.2,
float(timeout_seconds if timeout_seconds is not None else self._shared_proxy_wait_timeout_seconds()),
)
while time.time() < deadline:
payload = self._load_shared_proxy_snapshot_payload()
if payload and self._apply_shared_proxy_snapshot(payload):
return True
time.sleep(0.05)
return False
def _should_trigger_proxy_refresh_for_config_update(
self,
*,
config_type="",
previous_proxy_config=None,
previous_thread_count=0,
current_thread_count=0,
):
previous_proxy_config = dict(previous_proxy_config or {})
previous_urls = tuple(previous_proxy_config.get('proxy_urls') or [])
current_urls = tuple((self.proxy_config or {}).get('proxy_urls') or [])
proxy_changed = (
bool(previous_proxy_config.get('proxy_enable', False)) != bool((self.proxy_config or {}).get('proxy_enable', False))
or bool(previous_proxy_config.get('allow_direct', False)) != bool((self.proxy_config or {}).get('allow_direct', False))
or previous_urls != current_urls
)
if proxy_changed:
return True, True
normalized_config_type = str(config_type or "").strip()
if normalized_config_type not in {"thread_count", "node_thread_counts"}:
return False, False
if int(current_thread_count or 0) == int(previous_thread_count or 0):
return False, False
with self.proxy_pool_lock:
local_available = len(self.proxy_pool)
shared_payload = self._load_shared_proxy_snapshot_payload()
shared_available = 0
if isinstance(shared_payload, dict):
shared_available = len(list(shared_payload.get("proxy_pool") or []))
effective_available = max(local_available, shared_available)
refresh_threshold = self._proxy_refresh_threshold()
if effective_available >= max(1, int(refresh_threshold or 1)):
return False, False
shortage = max(0, int(refresh_threshold or 0) - effective_available)
if effective_available <= 0:
return True, False
return shortage >= self._shared_proxy_reuse_gap(), False
def _pull_sync_tasks_until_available(self, *, thread_limit: int, claim_after_pull: bool = True) -> 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))
pull_limit = self._resolve_sync_pull_limit(current_thread_limit)
claim_batch_size = max(20, current_thread_limit)
claim_lease_seconds = max(300, min(1800, claim_batch_size * 30))
maintenance_lock_owner = self._acquire_shared_job_maintenance_lock(
"pull-sync-tasks",
ttl_seconds=max(15, max_rounds * 12),
)
if not maintenance_lock_owner:
return []
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("/")
try:
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',
)
if not claim_after_pull:
if queued_count > 0:
logger.info(
f"主动补货已写入本地队列: round={round_index}/{max_rounds}, "
f"queued_count={queued_count}, claim_after_pull=0"
)
return []
else:
domains = self._claim_detect_job_items(
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 []
finally:
self._release_shared_job_maintenance_lock("pull-sync-tasks", maintenance_lock_owner)
def _resolve_sync_pull_limit(self, current_thread_limit: int) -> int:
configured_pull_limit = int(os.getenv("DOMAINCHECK_SYNC_PULL_LIMIT", "0") or 0)
configured_pull_cap = int(os.getenv("DOMAINCHECK_SYNC_PULL_LIMIT_CAP", "200000") or 200000)
pull_cap = max(10000, configured_pull_cap)
if configured_pull_limit > 0:
return max(1000, min(configured_pull_limit, pull_cap))
normalized_thread_limit = max(1, int(current_thread_limit or getattr(self, "thread_count", 1) or 1))
process_count = max(1, int(self._proxy_runtime_process_count() or 1))
total_thread_capacity = max(
normalized_thread_limit,
normalized_thread_limit * process_count,
)
default_pull_limit = max(
4000,
min(pull_cap, total_thread_capacity * 2),
)
return max(1000, int(default_pull_limit or 1000))
def _process_pipeline_tasks_until_available(self, *, thread_limit: int) -> list[dict]:
"""
当本地任务队列空了时,主动让 controller 处理已完成步骤,尽量把下一步任务补出来。
"""
maintenance_lock_owner = self._acquire_shared_job_maintenance_lock(
"process-pipeline-tasks",
ttl_seconds=20,
)
if not maintenance_lock_owner:
return []
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:
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._claim_detect_job_items(
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
finally:
self._release_shared_job_maintenance_lock("process-pipeline-tasks", maintenance_lock_owner)
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,
"control_node_autoresume_enabled": False,
"claim_recent_jobs_first": False,
"claim_recent_jobs_limit": 0,
"claim_recent_jobs_window_hours": 0,
"claim_batch_floor": 0,
"claim_batch_ceil": 0,
"submit_backlog_floor": 0,
"submit_backlog_ceil": 0,
"dispatch_cap_multiplier": 1,
"pending_buffer_cap_multiplier": 1,
}
def _normalize_bool(value, default=False):
if value is None:
return bool(default)
if isinstance(value, bool):
return value
return str(value or "").strip().lower() not in {"", "0", "false", "no", "off"}
def _normalize_non_negative_int(value, default=0):
try:
normalized = int(value)
except (TypeError, ValueError):
normalized = int(default)
return max(0, normalized)
def _normalize_positive_int(value, default=1):
try:
normalized = int(value)
except (TypeError, ValueError):
normalized = int(default)
return max(1, normalized)
def _normalize_runtime_settings(payload):
runtime_settings = default_settings.copy()
runtime_settings.update(payload or {})
runtime_settings["worker_log_sync_enabled"] = _normalize_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"] = _normalize_bool(runtime_settings.get("worker_step_trace_enabled", True), default=True)
runtime_settings["worker_step_trace_sync_full"] = _normalize_bool(runtime_settings.get("worker_step_trace_sync_full", True), default=True)
runtime_settings["control_node_autoresume_enabled"] = _normalize_bool(
runtime_settings.get("control_node_autoresume_enabled", False),
default=False,
)
runtime_settings["claim_recent_jobs_first"] = _normalize_bool(
runtime_settings.get("claim_recent_jobs_first", False),
default=False,
)
for key in (
"claim_recent_jobs_limit",
"claim_recent_jobs_window_hours",
"claim_batch_floor",
"claim_batch_ceil",
"submit_backlog_floor",
"submit_backlog_ceil",
):
runtime_settings[key] = _normalize_non_negative_int(runtime_settings.get(key), default_settings[key])
for key in ("dispatch_cap_multiplier", "pending_buffer_cap_multiplier"):
runtime_settings[key] = _normalize_positive_int(runtime_settings.get(key), default_settings[key])
return runtime_settings
try:
if self.use_redis:
runtime_settings_raw = self.redis_client.get(RUNTIME_SETTINGS_KEY)
if runtime_settings_raw:
runtime_settings = _normalize_runtime_settings(json.loads(runtime_settings_raw))
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 = _normalize_runtime_settings(json.load(f))
logger.info(f"从本地文件加载运行时设置成功: {runtime_settings}")
return runtime_settings
except Exception as e:
logger.error(f"加载运行时设置失败: {e}")
return default_settings
def _runtime_optional_int_override(self, runtime_key, env_key):
runtime_settings = self.runtime_settings or {}
try:
runtime_value = int(runtime_settings.get(runtime_key, 0) or 0)
except (TypeError, ValueError):
runtime_value = 0
if runtime_value > 0:
return runtime_value
try:
return max(0, int(os.getenv(env_key, "0") or 0))
except (TypeError, ValueError):
return 0
def _runtime_bool_override(self, runtime_key, env_key, default=False):
env_value = os.getenv(env_key)
if env_value is not None:
return self._normalize_flag_value(env_value, default=default)
runtime_settings = self.runtime_settings or {}
if runtime_key in runtime_settings:
return self._normalize_flag_value(runtime_settings.get(runtime_key, default), default=default)
return bool(default)
def _runtime_positive_int_override(self, runtime_key, env_key, default=1):
runtime_settings = self.runtime_settings or {}
try:
runtime_value = int(runtime_settings.get(runtime_key, 0) or 0)
except (TypeError, ValueError):
runtime_value = 0
if runtime_value > 0:
return runtime_value
try:
return max(1, int(os.getenv(env_key, str(default)) or default))
except (TypeError, ValueError):
return max(1, int(default))
def _claim_recent_jobs_first_enabled(self):
return self._runtime_bool_override(
"claim_recent_jobs_first",
"DOMAINCHECK_CLAIM_RECENT_JOBS_FIRST",
default=False,
)
def _preferred_claim_job_limit(self):
return max(1, self._runtime_optional_int_override(
"claim_recent_jobs_limit",
"DOMAINCHECK_CLAIM_RECENT_JOBS_LIMIT",
) or 8)
def _preferred_claim_job_window_hours(self):
return max(1, self._runtime_optional_int_override(
"claim_recent_jobs_window_hours",
"DOMAINCHECK_CLAIM_RECENT_JOBS_WINDOW_HOURS",
) or 24)
def _claim_detect_job_items(self, *, limit, lease_seconds, job_id=None):
if job_id in (None, "", 0, "0") and not self._is_single_step_session_active():
handoff_job_id = self._restart_release_handoff_job_id_active()
if handoff_job_id:
if not self._is_primary_job_maintenance_worker():
return []
handoff_limit = self._restart_release_handoff_batch_limit(limit)
handoff_rows = self.db.claim_restart_released_detect_job_items(
config.NODE_CODE,
handoff_job_id,
limit=handoff_limit,
lease_seconds=lease_seconds,
)
if handoff_rows:
logger.warning(
f"优先回收重启尾批任务成功: job_id={handoff_job_id}, "
f"claimed={len(handoff_rows)}, reason={self._restart_release_handoff_reason or 'worker_restart'}"
)
remaining = max(0, int(limit or 0) - len(handoff_rows))
if remaining <= 0:
return handoff_rows
prefer_recent_jobs = self._claim_recent_jobs_first_enabled()
fallback_rows = self.db.claim_detect_job_items(
config.NODE_CODE,
limit=remaining,
lease_seconds=lease_seconds,
job_id=None,
prefer_recent_jobs=prefer_recent_jobs,
preferred_recent_job_limit=self._preferred_claim_job_limit(),
preferred_recent_job_window_hours=self._preferred_claim_job_window_hours(),
)
return list(handoff_rows or []) + list(fallback_rows or [])
self._clear_restart_release_handoff(reason="handoff_queue_drained")
explicit_scope_job_id = self._explicit_claim_scope_job_id_active()
if explicit_scope_job_id not in (None, "", 0, "0"):
scoped_rows = self.db.claim_detect_job_items(
config.NODE_CODE,
limit=limit,
lease_seconds=lease_seconds,
job_id=explicit_scope_job_id,
prefer_recent_jobs=False,
preferred_recent_job_limit=self._preferred_claim_job_limit(),
preferred_recent_job_window_hours=self._preferred_claim_job_window_hours(),
)
self._log_explicit_scope_claim_result(
scoped_job_id=explicit_scope_job_id,
limit=limit,
rows_count=len(scoped_rows or []),
reason="explicit_scope",
)
return scoped_rows
prefer_recent_jobs = bool(job_id in (None, "", 0, "0") and self._claim_recent_jobs_first_enabled())
return self.db.claim_detect_job_items(
config.NODE_CODE,
limit=limit,
lease_seconds=lease_seconds,
job_id=job_id,
prefer_recent_jobs=prefer_recent_jobs,
preferred_recent_job_limit=self._preferred_claim_job_limit(),
preferred_recent_job_window_hours=self._preferred_claim_job_window_hours(),
)
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"
@staticmethod
def _remote_debug_event_timeout_seconds():
configured_timeout = float(os.getenv("DOMAINCHECK_REMOTE_DEBUG_EVENT_TIMEOUT", "0") or 0.0)
if configured_timeout > 0:
return max(0.2, min(configured_timeout, 10.0))
if str(getattr(config, "NODE_ROLE", "") or "").strip() == "control":
return 1.2
return 2.0
@staticmethod
def _remote_debug_event_cooldown_seconds(failure_streak):
normalized_streak = max(1, int(failure_streak or 1))
base_seconds = max(
5.0,
float(os.getenv("DOMAINCHECK_REMOTE_DEBUG_EVENT_COOLDOWN_BASE_SECONDS", "15") or 15.0),
)
max_seconds = max(
base_seconds,
float(os.getenv("DOMAINCHECK_REMOTE_DEBUG_EVENT_COOLDOWN_MAX_SECONDS", "180") or 180.0),
)
return min(max_seconds, base_seconds * (2 ** max(0, normalized_streak - 1)))
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
now_ts = time.time()
cooldown_until = float(getattr(self, "_remote_debug_event_cooldown_until", 0.0) or 0.0)
if cooldown_until > now_ts:
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:
timeout_seconds = self._remote_debug_event_timeout_seconds()
with urllib.request.urlopen(request, timeout=timeout_seconds) 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}")
self._remote_debug_event_failure_streak = 0
self._remote_debug_event_cooldown_until = 0.0
except Exception as e:
failure_streak = int(getattr(self, "_remote_debug_event_failure_streak", 0) or 0) + 1
cooldown_seconds = self._remote_debug_event_cooldown_seconds(failure_streak)
self._remote_debug_event_failure_streak = failure_streak
self._remote_debug_event_cooldown_until = now_ts + cooldown_seconds
last_notice_at = float(getattr(self, "_last_remote_debug_event_notice_at", 0.0) or 0.0)
if failure_streak <= 2 or now_ts - last_notice_at >= max(10.0, min(cooldown_seconds, 30.0)):
logger.debug(
f"远端调试事件回传失败,进入冷却 {int(cooldown_seconds)} 秒: {e}"
)
self._last_remote_debug_event_notice_at = now_ts
def _worker_log_sync_mode(self):
if not bool((self.runtime_settings or {}).get("worker_log_sync_enabled", False)):
return "off"
configured_mode = (
"full"
if str((self.runtime_settings or {}).get("worker_log_sync_mode", "key")).strip().lower() == "full"
else "key"
)
if configured_mode != "full":
return configured_mode
degrade_reason = self._worker_log_sync_full_degrade_reason()
if not degrade_reason:
return configured_mode
now_ts = time.time()
last_notice_at = float(getattr(self, "_last_worker_log_sync_degrade_notice_at", 0.0) or 0.0)
if now_ts - last_notice_at >= 10.0:
logger.warning(f"Worker 全量日志同步已自动降级为 key: {degrade_reason}")
self._last_worker_log_sync_degrade_notice_at = now_ts
return "key"
def _worker_log_sync_full_active_thread_threshold(self):
configured_threshold = int(os.getenv("DOMAINCHECK_WORKER_LOG_SYNC_FULL_MAX_ACTIVE_THREADS", "0") or 0)
if configured_threshold > 0:
return max(32, configured_threshold)
thread_limit = max(1, int(getattr(self, "thread_count", 0) or 0))
return max(120, min(600, max(120, thread_limit // 3)))
def _worker_log_sync_full_queue_threshold(self):
configured_threshold = int(os.getenv("DOMAINCHECK_WORKER_LOG_SYNC_FULL_MAX_QUEUE", "0") or 0)
if configured_threshold > 0:
return max(256, configured_threshold)
queue_maxsize = int(getattr(getattr(self, "_worker_log_sync_queue", None), "maxsize", 0) or 0)
if queue_maxsize > 0:
return max(1000, min(15000, queue_maxsize // 5))
return 5000
def _worker_log_sync_full_degrade_reason(self):
queue_obj = getattr(self, "_worker_log_sync_queue", None)
queue_depth = 0
if queue_obj is not None:
try:
queue_depth = max(0, int(queue_obj.qsize() or 0))
except Exception:
queue_depth = 0
queue_threshold = self._worker_log_sync_full_queue_threshold()
if queue_depth >= queue_threshold:
return f"queue_backlog={queue_depth}, threshold={queue_threshold}"
live_active_threads = max(0, int(self._get_active_domain_threads() or 0))
active_threshold = self._worker_log_sync_full_active_thread_threshold()
if bool(getattr(self, "detecting", False)) and live_active_threads >= active_threshold:
return f"active_threads={live_active_threads}, threshold={active_threshold}"
return ""
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 _peek_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.get(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 "completed", 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 _proxy_item_expire_timestamp(self, proxy_item):
if not isinstance(proxy_item, dict):
return 0.0
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
if expire_at not in (None, "", 0, "0"):
return float(expire_at)
except Exception:
return 0.0
return 0.0
def _proxy_entry_expire_timestamp(self, proxy_entry):
if isinstance(proxy_entry, dict):
expire_at_ts = proxy_entry.get("expire_at_ts")
try:
if expire_at_ts not in (None, "", 0, "0"):
return float(expire_at_ts)
except Exception:
return 0.0
raw_item = proxy_entry.get("raw_item")
if isinstance(raw_item, dict):
return float(self._proxy_item_expire_timestamp(raw_item) or 0.0)
return float(self._proxy_item_expire_timestamp(proxy_entry) or 0.0)
def _proxy_remaining_ttl_seconds(self, proxy_entry, *, now_ts=None):
expire_at_ts = float(self._proxy_entry_expire_timestamp(proxy_entry) or 0.0)
if expire_at_ts <= 0:
return float("inf")
observed_now_ts = float(now_ts or time.time())
return max(0.0, expire_at_ts - observed_now_ts)
def _proxy_min_reuse_ttl_seconds(self, step_name=""):
normalized_step_name = str(step_name or "").strip()
override_map = {
"注册状态检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_REGISTER", "6") or 6),
"百度site检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_BAIDU", "8") or 8),
"360检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_360", "8") or 8),
"站长之家检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_CHINAZ", "10") or 10),
"爱站网检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_AIZHAN", "10") or 10),
"聚查检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_JUCHA", "12") or 12),
"桔子检测": float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_JUZISEO", "12") or 12),
}
if normalized_step_name in override_map:
return max(0.0, float(override_map.get(normalized_step_name, 0.0) or 0.0))
configured_default = float(os.getenv("DOMAINCHECK_PROXY_REUSE_MIN_TTL_SECONDS", "8") or 8)
return max(0.0, configured_default)
def _proxy_entry_can_be_reused(self, proxy_entry, step_name="", *, now_ts=None):
remaining_ttl_seconds = self._proxy_remaining_ttl_seconds(proxy_entry, now_ts=now_ts)
if remaining_ttl_seconds == float("inf"):
return True
return remaining_ttl_seconds >= self._proxy_min_reuse_ttl_seconds(step_name)
def _store_proxy_active_lease(self, proxy_entry, *, step_name="", already_locked=False):
if not isinstance(proxy_entry, dict):
return
proxy = proxy_entry.get("proxy")
proxy_key = self._proxy_key(proxy)
if not proxy or not proxy_key:
return
def _store():
active_leases = getattr(self, "proxy_active_leases", None)
if active_leases is None:
self.proxy_active_leases = {}
active_leases = self.proxy_active_leases
lease_entry = dict(proxy_entry)
lease_entry["leased_at"] = time.time()
lease_entry["leased_step_name"] = str(step_name or proxy_entry.get("leased_step_name") or "").strip()
active_leases[proxy_key] = lease_entry
if already_locked:
_store()
return
with self.proxy_pool_lock:
_store()
def _pop_proxy_active_lease(self, proxy, *, already_locked=False):
proxy_key = self._proxy_key(proxy)
if not proxy_key:
return None
def _pop():
active_leases = getattr(self, "proxy_active_leases", None)
if not isinstance(active_leases, dict):
return None
return active_leases.pop(proxy_key, None)
if already_locked:
return _pop()
with self.proxy_pool_lock:
return _pop()
def release_proxy(self, proxy, *, step_name="", discard=False, reason=""):
if not proxy:
return False
with self.proxy_pool_lock:
lease_entry = self._pop_proxy_active_lease(proxy, already_locked=True)
if not isinstance(lease_entry, dict):
return False
remaining_count = len(self.proxy_pool)
normalized_step_name = str(step_name or lease_entry.get("leased_step_name") or "").strip()
source_tag = str(lease_entry.get("source_tag") or "").strip()
reusable = (
not discard
and not self._is_proxy_quarantined(proxy)
and not self._is_proxy_source_quarantined(source_tag)
and self._proxy_entry_can_be_reused(lease_entry, normalized_step_name)
)
if reusable:
lease_entry.pop("leased_at", None)
lease_entry.pop("leased_step_name", None)
with self.proxy_pool_lock:
self.proxy_pool.append(lease_entry)
remaining_count = len(self.proxy_pool)
self._schedule_proxy_refresh_if_needed(remaining_count)
if not reusable:
remaining_ttl_seconds = self._proxy_remaining_ttl_seconds(lease_entry)
if remaining_ttl_seconds != float("inf"):
logger.debug(
f"代理归还时已丢弃: step={normalized_step_name or '-'} "
f"ttl={int(remaining_ttl_seconds)}s reason={reason or ('discard' if discard else 'ttl_low')}"
)
return reusable
def _is_proxy_item_expired(self, proxy_item, *, grace_seconds=0.0):
if not isinstance(proxy_item, dict):
return True
now_ts = time.time()
expire_at_ts = float(self._proxy_item_expire_timestamp(proxy_item) or 0.0)
if expire_at_ts <= 0:
return False
return expire_at_ts <= (now_ts - max(0.0, float(grace_seconds or 0.0)))
def _summarize_proxy_expire_clock_skew(self, proxy_items, *, now_ts=None):
if not isinstance(proxy_items, list) or not proxy_items:
return None
observed_now_ts = float(now_ts or time.time())
expire_timestamps = []
for proxy_item in proxy_items:
expire_at_ts = float(self._proxy_item_expire_timestamp(proxy_item) or 0.0)
if expire_at_ts > 0:
expire_timestamps.append(expire_at_ts)
if not expire_timestamps:
return None
latest_expire_at = max(expire_timestamps)
earliest_expire_at = min(expire_timestamps)
expired_timed_count = sum(1 for expire_at_ts in expire_timestamps if expire_at_ts <= observed_now_ts)
# 所有带过期时间的代理都比本机时间早很多时,优先提示“时钟异常/源数据陈旧”,
# 避免把问题误判成单纯的代理质量差。
if latest_expire_at > observed_now_ts - 900:
return None
if expired_timed_count < max(12, int(len(expire_timestamps) * 0.8)):
return None
return {
"observed_now_ts": observed_now_ts,
"observed_now_iso": datetime.fromtimestamp(observed_now_ts).strftime("%Y-%m-%d %H:%M:%S"),
"timed_proxy_count": len(expire_timestamps),
"expired_timed_count": expired_timed_count,
"latest_expire_at": latest_expire_at,
"latest_expire_at_iso": datetime.fromtimestamp(latest_expire_at).strftime("%Y-%m-%d %H:%M:%S"),
"earliest_expire_at": earliest_expire_at,
"earliest_expire_at_iso": datetime.fromtimestamp(earliest_expire_at).strftime("%Y-%m-%d %H:%M:%S"),
"clock_ahead_seconds": max(0.0, observed_now_ts - latest_expire_at),
}
def _proxy_recently_expired_reuse_grace_seconds(self):
configured_grace = float(os.getenv("DOMAINCHECK_PROXY_STALE_REUSE_GRACE_SECONDS", "0") or 0.0)
if configured_grace > 0:
return max(0.0, configured_grace)
if not self.detecting:
return 0.0
demand_threads = self._proxy_demand_threads()
if demand_threads >= 1600:
return 180.0
if demand_threads >= 1000:
return 120.0
return 90.0
def _proxy_recently_expired_backfill_floor(self):
configured_floor = int(os.getenv("DOMAINCHECK_PROXY_STALE_BACKFILL_FLOOR", "0") or 0)
if configured_floor > 0:
return max(8, configured_floor)
demand_threads = self._proxy_demand_threads()
return max(32, min(160, max(32, demand_threads // 12)))
def _backfill_recently_expired_proxy_entries(self, current_entries, stale_entries):
merged_entries = list(current_entries or [])
normalized_stale_entries = list(stale_entries or [])
if not self.detecting or not normalized_stale_entries:
return merged_entries, 0, 0
floor = self._proxy_recently_expired_backfill_floor()
if len(merged_entries) >= floor:
return merged_entries, 0, floor
before_count = len(merged_entries)
needed = max(0, floor - before_count)
if needed <= 0:
return merged_entries, 0, floor
merged_entries = self._merge_proxy_entries(normalized_stale_entries[:needed], merged_entries)
return merged_entries, max(0, len(merged_entries) - before_count), floor
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()
process_count = self._proxy_runtime_process_count()
source_count = len(source_urls)
provider_batch_cap = max(20, int(os.getenv("DOMAINCHECK_PROXY_PROVIDER_BATCH_CAP", "140") or 140))
provider_batch_floor = max(
20,
min(
provider_batch_cap,
int(round(provider_batch_cap / self._proxy_multi_process_divisor())),
),
)
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 * 10, 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_floor,
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,
"process_count": process_count,
"current_pool_size": current_pool_size,
"refresh_threshold": refresh_threshold,
"shortage": shortage,
"provider_batch_floor": provider_batch_floor,
}
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 float(getattr(self, "proxy_next_refresh_time", 0.0) or 0.0) > time.time():
return
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, debounce_seconds=None):
"""
代理配置一旦下发,就允许在非检测态主动补一次刷新,避免页面长期停在“未刷新”。
"""
if not self.proxy_config.get('proxy_enable', False):
return False
if not self._has_proxy_sources():
return False
normalized_reason = str(reason or "manual").strip() or "manual"
if debounce_seconds is None and normalized_reason.startswith("config_update:"):
debounce_seconds = self._config_update_proxy_refresh_debounce_seconds()
debounce_seconds = max(0.0, float(debounce_seconds or 0.0))
refresh_reason_timestamps = getattr(self, "_last_proxy_refresh_reason_at", None)
if not isinstance(refresh_reason_timestamps, dict):
refresh_reason_timestamps = {}
self._last_proxy_refresh_reason_at = refresh_reason_timestamps
trigger_key = "config_update" if normalized_reason.startswith("config_update:") else normalized_reason
now_ts = time.time()
last_trigger_at = float(refresh_reason_timestamps.get(trigger_key, 0.0) or 0.0)
if debounce_seconds > 0 and (now_ts - last_trigger_at) < debounce_seconds:
logger.debug(
f"代理池刷新触发去抖,跳过主动触发: {normalized_reason}, cooldown={debounce_seconds:.1f}s"
)
return False
if reset_cooldown:
self.proxy_next_refresh_time = 0.0
elif float(getattr(self, "proxy_next_refresh_time", 0.0) or 0.0) > time.time():
logger.debug(f"代理池仍在冷却窗口,跳过主动触发: {normalized_reason}")
return False
if self.proxy_refresh_lock.locked():
logger.debug(f"代理池刷新已在进行中,跳过主动触发: {normalized_reason}")
return False
refresh_reason_timestamps[trigger_key] = now_ts
logger.info(f"主动触发代理池刷新: {normalized_reason}")
threading.Thread(target=self.refresh_proxy_pool, daemon=True).start()
return True
def _proxy_refresh_threshold(self):
"""
代理池的补货阈值只用于触发后台刷新,不应反向限制并发。
"""
demand_threads = self._proxy_demand_threads()
base_threshold = max(840, demand_threads * 3)
process_count = self._proxy_runtime_process_count()
if process_count <= 1:
return base_threshold
scaled_threshold = int(round(base_threshold / self._proxy_multi_process_divisor()))
process_floor = max(120, min(480, max(120, demand_threads // 4)))
return max(process_floor, scaled_threshold)
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 _prepare_proxy_pool_for_detection_start(self):
"""
检测会话启动时,优先复用当前可用代理池/共享快照。
只有在本地和共享快照都为空时,才做同步刷新。
否则 large controller 会在每轮 auto-resume 前都卡一次 refresh_proxy_pool
线程池还没起就先停在 refreshing_proxy。
"""
with self.proxy_pool_lock:
current_pool_size = len(self.proxy_pool)
if current_pool_size > 0:
self._schedule_proxy_refresh_if_needed(current_pool_size)
return True, current_pool_size, "local_pool_ready"
if self._proxy_should_coordinate_shared_refresh():
shared_payload = self._load_shared_proxy_snapshot_payload()
if shared_payload and self._apply_shared_proxy_snapshot(
shared_payload,
status_prefix="检测启动复用共享代理快照",
):
with self.proxy_pool_lock:
shared_pool_size = len(self.proxy_pool)
self._schedule_proxy_refresh_if_needed(shared_pool_size)
return True, shared_pool_size, "shared_snapshot_ready"
return False, 0, "proxy_pool_empty"
def refresh_proxy_pool(self):
"""
刷新代理池
"""
if not self.proxy_refresh_lock.acquire(blocking=False):
logger.debug("代理池刷新已在进行中,跳过本次重复刷新")
return
shared_refresh_owner = ""
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)
holdoff_seconds = self._proxy_refresh_holdoff_seconds()
refresh_threshold = self._proxy_refresh_threshold()
with self.proxy_pool_lock:
has_cached_proxies = bool(self.proxy_pool)
cached_proxy_pool = list(self.proxy_pool)
if self._proxy_should_coordinate_shared_refresh():
shared_payload = self._load_shared_proxy_snapshot_payload()
if shared_payload:
shared_entries = shared_payload.get("proxy_pool") or []
if self._shared_proxy_snapshot_should_replace_local_pool(
len(shared_entries),
len(cached_proxy_pool),
refresh_threshold,
):
if self._apply_shared_proxy_snapshot(shared_payload):
self.proxy_next_refresh_time = time.time() + holdoff_seconds
return
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
if self._proxy_should_coordinate_shared_refresh():
shared_refresh_owner = self._acquire_shared_proxy_refresh_lock()
if not shared_refresh_owner:
if self._wait_for_shared_proxy_snapshot():
self.proxy_next_refresh_time = time.time() + holdoff_seconds
return
if cached_proxy_pool:
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 = f"共享刷新进行中,继续沿用缓存 {len(cached_proxy_pool)}"
else:
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 = 0
self.proxy_last_refresh_status = "共享代理刷新进行中,等待补货"
self.proxy_next_refresh_time = time.time() + retry_delay_seconds + holdoff_seconds
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, mode='key')
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)
refresh_threshold = int(fetch_plan_meta.get("refresh_threshold", 0) or refresh_threshold)
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
stale_reuse_grace_seconds = self._proxy_recently_expired_reuse_grace_seconds()
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,
'expire_at_ts': float(self._proxy_item_expire_timestamp(proxy_item) or 0.0),
'raw_item': dict(proxy_item),
}
if self._is_proxy_item_expired(proxy_item):
expired_count += 1
if (
stale_reuse_grace_seconds > 0
and not self._is_proxy_item_expired(
proxy_item,
grace_seconds=stale_reuse_grace_seconds,
)
):
fallback_stale_proxies.append(proxy_entry)
continue
new_proxies.append(proxy_entry)
clock_skew_hint = self._summarize_proxy_expire_clock_skew(proxy_list)
if clock_skew_hint:
now_ts = time.time()
last_warning_at = float(getattr(self, "_last_proxy_clock_skew_warning_at", 0.0) or 0.0)
if now_ts - last_warning_at >= 30.0:
self._last_proxy_clock_skew_warning_at = now_ts
warning_message = (
"疑似系统时钟异常或代理源返回陈旧数据: "
f"本机时间 {clock_skew_hint['observed_now_iso']}"
f"代理最晚过期 {clock_skew_hint['latest_expire_at_iso']}"
f"偏移约 {int(clock_skew_hint['clock_ahead_seconds'])}"
)
logger.warning(
warning_message
+ (
f",带过期时间代理 {clock_skew_hint['timed_proxy_count']} 个,"
f"其中已过期 {clock_skew_hint['expired_timed_count']}"
)
)
self._sync_worker_log_event(
warning_message,
level='warning',
payload=clock_skew_hint,
mode='key',
)
fallback_stale_count = 0
stale_backfill_count = 0
stale_backfill_floor = 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)
if fallback_stale_proxies and stale_reuse_grace_seconds > 0:
(
merged_proxy_pool,
stale_backfill_count,
stale_backfill_floor,
) = self._backfill_recently_expired_proxy_entries(
merged_proxy_pool,
fallback_stale_proxies,
)
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:
self._publish_shared_proxy_snapshot(
merged_proxy_pool,
source_count=source_count,
raw_items=self.proxy_last_refresh_total_items,
)
if new_proxies:
if fallback_stale_count > 0:
self.proxy_last_refresh_status = (
f"疑似过期回退增量入池 {len(new_proxies)} 个,当前池 {len(self.proxy_pool)} 个(跳过预验证)"
)
elif stale_backfill_count > 0:
self.proxy_last_refresh_status = (
f"最近过期代理回补 {stale_backfill_count} 个,当前池 {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 if len(self.proxy_pool) >= refresh_threshold else (time.time() + holdoff_seconds)
)
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)
)
next_retry_delay += holdoff_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)
)
next_retry_delay += holdoff_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,
"stale_backfill_count": stale_backfill_count,
"stale_backfill_floor": stale_backfill_floor,
"stale_reuse_grace_seconds": stale_reuse_grace_seconds,
"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)
)
next_retry_delay += holdoff_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)
+ holdoff_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 + holdoff_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._release_shared_proxy_refresh_lock(shared_refresh_owner)
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._pop_proxy_active_lease(proxy, already_locked=True):
removed += 1
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 _proxy_pool_scan_batch_size(self):
configured_batch_size = int(os.getenv("DOMAINCHECK_PROXY_POOL_SCAN_BATCH_SIZE", "0") or 0)
if configured_batch_size > 0:
return max(8, configured_batch_size)
demand_threads = self._proxy_demand_threads()
if demand_threads >= 1600:
return 64
if demand_threads >= 800:
return 48
return 32
def _pop_proxy_candidate_batch(self, batch_size):
normalized_batch_size = max(1, int(batch_size or 1))
with self.proxy_pool_lock:
if not self.proxy_pool:
return []
actual_batch_size = min(len(self.proxy_pool), normalized_batch_size)
candidates = []
for _ in range(actual_batch_size):
candidates.append(self.proxy_pool.pop(0))
return candidates
def _restore_proxy_candidate_batch(self, candidates, selected_entry=None, *, selected_step_name="", lease_selected=False):
restored_count = 0
with self.proxy_pool_lock:
if candidates:
self.proxy_pool.extend(candidates)
restored_count += len(candidates)
if selected_entry is not None:
if lease_selected:
self._store_proxy_active_lease(
selected_entry,
step_name=selected_step_name,
already_locked=True,
)
else:
self.proxy_pool.append(selected_entry)
restored_count += 1
remaining_count = len(self.proxy_pool)
return remaining_count, restored_count
def get_proxies(self, excluded_proxy_keys=None, step_name="", lease_selected=False):
"""
获取代理配置
"""
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:
if self._wait_for_shared_proxy_snapshot(timeout_seconds=min(0.25, self._shared_proxy_wait_timeout_seconds())):
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)
if current_pool_size > 0:
scan_batch_size = self._proxy_pool_scan_batch_size()
scanned_count = 0
while scanned_count < current_pool_size:
candidates = self._pop_proxy_candidate_batch(
min(scan_batch_size, max(1, current_pool_size - scanned_count))
)
if not candidates:
break
scanned_count += len(candidates)
deferred_candidates = []
selected_proxy = None
selected_entry = None
observed_now_ts = time.time()
for index, candidate in enumerate(candidates):
if isinstance(candidate, dict) and candidate.get('proxy'):
proxy = candidate['proxy']
proxy_key = self._proxy_key(proxy)
if self._is_proxy_quarantined(proxy):
deferred_candidates.append(candidate)
continue
source_tag = str(candidate.get("source_tag") or "").strip()
if self._is_proxy_source_quarantined(source_tag):
deferred_candidates.append(candidate)
continue
if proxy_key in excluded_proxy_keys:
deferred_candidates.append(candidate)
continue
if lease_selected and not self._proxy_entry_can_be_reused(candidate, step_name, now_ts=observed_now_ts):
continue
usage_count = int(candidate.get('usage_count', 0)) + 1
candidate['usage_count'] = 0 if usage_count >= self.proxy_max_reuse_count else usage_count
selected_proxy = proxy
selected_entry = candidate
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
deferred_candidates.extend(candidates[index + 1 :])
break
proxy = candidate
proxy_key = self._proxy_key(proxy)
if self._is_proxy_quarantined(proxy):
deferred_candidates.append(candidate)
continue
if proxy_key in excluded_proxy_keys:
deferred_candidates.append(candidate)
continue
logger.info(f"从代理池选择代理: {proxy}")
selected_proxy = proxy
selected_entry = candidate if isinstance(candidate, dict) else {
"proxy": proxy,
"usage_count": 0,
"source_tag": "",
}
deferred_candidates.extend(candidates[index + 1 :])
break
if selected_proxy is None:
deferred_candidates = candidates
remaining_count, _ = self._restore_proxy_candidate_batch(
deferred_candidates,
selected_entry=selected_entry,
selected_step_name=step_name,
lease_selected=lease_selected,
)
if selected_proxy is not None:
self._schedule_proxy_refresh_if_needed(remaining_count)
return selected_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))
@staticmethod
def _normalize_flag_value(value, default=False):
if value is None:
return bool(default)
if isinstance(value, bool):
return value
return str(value or "").strip().lower() not in {"", "0", "false", "no", "off"}
@staticmethod
def _env_flag_enabled(env_name, default=False):
value = os.getenv(env_name)
return DetectWorker._normalize_flag_value(value, default=default)
def _register_single_machine_mode_enabled(self):
cached = getattr(self, "register_single_machine_mode_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled("DOMAINCHECK_REGISTER_SINGLE_MACHINE_MODE")
def _register_single_machine_direct_streak_attempts(self):
cached = getattr(self, "register_single_machine_direct_streak_attempts", None)
if cached is not None:
return max(0, int(cached or 0))
if not self._register_single_machine_mode_enabled():
return 0
return max(0, int(os.getenv("DOMAINCHECK_REGISTER_DIRECT_STREAK_ATTEMPTS", "2") or 2))
def _single_machine_site_direct_fallback_enabled(self):
cached = getattr(self, "single_machine_site_direct_fallback_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_SITE_DIRECT_FALLBACK",
default=self._register_single_machine_mode_enabled(),
)
def _single_machine_aizhan_direct_first_enabled(self):
cached = getattr(self, "single_machine_aizhan_direct_first_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_AIZHAN_DIRECT_FIRST",
default=False,
)
def _single_machine_baidu_direct_first_enabled(self):
cached = getattr(self, "single_machine_baidu_direct_first_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_BAIDU_DIRECT_FIRST",
default=False,
)
def _single_machine_chinaz_direct_first_enabled(self):
cached = getattr(self, "single_machine_chinaz_direct_first_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled(
"DOMAINCHECK_SINGLE_MACHINE_CHINAZ_DIRECT_FIRST",
default=False,
)
def _aizhan_remote_disconnect_degrade_enabled(self):
cached = getattr(self, "aizhan_remote_disconnect_degrade_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled(
"DOMAINCHECK_AIZHAN_REMOTE_DISCONNECT_DEGRADE",
default=False,
)
def _aizhan_external_fast_degrade_enabled(self):
cached = getattr(self, "aizhan_external_fast_degrade_enabled", None)
if cached is not None:
return bool(cached)
return self._env_flag_enabled(
"DOMAINCHECK_AIZHAN_EXTERNAL_FAST_DEGRADE",
default=False,
)
def _should_force_direct_first_attempt(self, step_name, attempt_count):
if int(attempt_count or 0) != 0:
return False
if (
step_name == "爱站网检测"
and self._single_machine_aizhan_direct_first_enabled()
and self._allow_direct_connection_for_step(step_name)
):
return True
if (
step_name == "百度site检测"
and self._single_machine_baidu_direct_first_enabled()
and self._allow_direct_connection_for_step(step_name)
):
return True
if (
step_name == "站长之家检测"
and self._single_machine_chinaz_direct_first_enabled()
and self._allow_direct_connection_for_step(step_name)
):
return True
return False
def _should_degrade_remote_disconnect_immediately(self, step_name, reason):
if step_name != "爱站网检测" or not self._aizhan_remote_disconnect_degrade_enabled():
return False
lowered = str(reason or "").strip().lower()
if not lowered:
return False
return (
"remotedisconnected" in lowered
or "remote end closed connection without response" in lowered
)
def _should_degrade_external_issue_immediately(self, step_name, reason):
if step_name != "爱站网检测":
return False
if self._should_degrade_remote_disconnect_immediately(step_name, reason):
return True
if not self._aizhan_external_fast_degrade_enabled():
return False
return self._is_external_dependency_issue(reason)
def _allow_direct_connection_for_step(self, step_name):
if step_name == "注册状态检测" and self._register_single_machine_mode_enabled():
return True
if (
step_name in {"360检测", "百度site检测", "站长之家检测", "爱站网检测"}
and self._single_machine_site_direct_fallback_enabled()
):
return True
return self.allow_direct_connection()
def _proxy_status_indicates_shortage(self):
status = str(getattr(self, "proxy_last_refresh_status", "") or "").strip()
if not status:
return False
keywords = (
"冷却中",
"暂时冷却",
"无可用代理",
"未取到可用代理",
"继续沿用缓存 0",
"稍后继续补货",
)
return any(keyword in status for keyword in keywords)
def _proxy_pool_available_count(self):
try:
with self.proxy_pool_lock:
return len(list(getattr(self, "proxy_pool", []) or []))
except Exception:
return len(list(getattr(self, "proxy_pool", []) or []))
def _should_hold_stale_detect_for_proxy_shortage(
self,
*,
idle_seconds: float,
low_activity_recoverable: bool,
incoming_job_changed: bool,
):
if incoming_job_changed or not low_activity_recoverable:
return False, ""
if not bool(self.proxy_config.get('proxy_enable', False)):
return False, ""
if not self._proxy_status_indicates_shortage():
return False, ""
if self._proxy_pool_available_count() > 0:
return False, ""
grace_seconds = 120.0 if self.allow_direct_connection() else 180.0
if idle_seconds >= grace_seconds:
return False, ""
return True, (
f"代理池仍处于短缺/冷却窗口,当前空转 {int(idle_seconds)} 秒,"
f"小于保护阈值 {int(grace_seconds)} 秒,最近代理状态:{self.proxy_last_refresh_status}"
)
def _proxy_direct_fallback_grace_for_step(self, step_name):
default_grace = max(0.0, float(getattr(self, "proxy_direct_fallback_grace_seconds", 0.0) or 0.0))
override_map = getattr(self, "proxy_direct_fallback_grace_overrides", None) or {}
step_grace = max(0.0, float(override_map.get(step_name, default_grace) or 0.0))
if step_name == "注册状态检测" and self._register_single_machine_mode_enabled():
return 0.0
if not self._allow_direct_connection_for_step(step_name):
return step_grace
if (
step_name in {"注册状态检测", "360检测", "百度site检测", "站长之家检测", "爱站网检测"}
and self._proxy_status_indicates_shortage()
):
return min(step_grace, 0.05)
return step_grace
def _proxy_direct_retry_wait_for_step(self, step_name):
default_wait = max(0.0, float(getattr(self, "proxy_direct_retry_wait_seconds", 0.0) or 0.0))
override_map = getattr(self, "proxy_direct_retry_wait_overrides", None) or {}
step_wait = max(0.0, float(override_map.get(step_name, default_wait) or 0.0))
if step_name == "注册状态检测" and self._register_single_machine_mode_enabled():
return 0.0
if (
step_name in {"注册状态检测", "360检测", "百度site检测", "站长之家检测", "爱站网检测"}
and self._proxy_status_indicates_shortage()
):
return min(step_wait, 0.25)
return step_wait
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_for_step(step_name)
force_direct_once = self._consume_step_force_direct_once(step_name)
step_direct_grace_seconds = self._proxy_direct_fallback_grace_for_step(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(step_direct_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,
step_name=step_name,
lease_selected=True,
)
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_for_step(step_name):
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_for_step(step_name):
retry_proxy_wait_seconds = self._proxy_direct_retry_wait_for_step(step_name)
forced_wait_seconds = max(
self._proxy_direct_fallback_grace_for_step(step_name),
min(
self.proxy_step_wait_timeout_seconds or 0.0,
retry_proxy_wait_seconds,
) if self.proxy_step_wait_timeout_seconds > 0 else retry_proxy_wait_seconds,
)
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)
pending_direct_fallback = (
attempt_count < max_attempts
and self._allow_direct_connection_for_step(step_name)
and self._peek_step_force_direct_once(step_name)
)
if attempt_count < max_attempts and (elapsed_seconds < max_seconds or pending_direct_fallback):
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 = self._runtime_optional_int_override("submit_backlog_floor", "DOMAINCHECK_SUBMIT_BACKLOG_FLOOR")
configured_backlog_ceiling = self._runtime_optional_int_override("submit_backlog_ceil", "DOMAINCHECK_SUBMIT_BACKLOG_CEIL")
# 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 = self._runtime_positive_int_override(
"dispatch_cap_multiplier",
"DOMAINCHECK_DISPATCH_CAP_MULTIPLIER",
default=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 = self._runtime_optional_int_override("claim_batch_floor", "DOMAINCHECK_CLAIM_BATCH_FLOOR")
configured_claim_ceiling = self._runtime_optional_int_override("claim_batch_ceil", "DOMAINCHECK_CLAIM_BATCH_CEIL")
# 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 _should_proactively_top_up_pipeline(self, claimed_count, refill_slots, max_threads):
claimed_count = max(0, int(claimed_count or 0))
refill_slots = max(0, int(refill_slots or 0))
normalized_threads = max(1, int(max_threads or 1))
remaining_gap = max(0, refill_slots - claimed_count)
if remaining_gap <= 0:
return False
configured_threshold = int(os.getenv("DOMAINCHECK_PIPELINE_TOPUP_THRESHOLD", "0") or 0)
threshold = max(
1,
configured_threshold if configured_threshold > 0 else max(128, normalized_threads // 4),
)
return remaining_gap >= threshold
def _should_prefetch_sync_tasks(self, pending_buffer_count, inflight_count, max_threads):
pending_buffer_count = max(0, int(pending_buffer_count or 0))
inflight_count = max(0, int(inflight_count or 0))
normalized_threads = max(1, int(max_threads or 1))
local_work = pending_buffer_count + inflight_count
configured_threshold = int(os.getenv("DOMAINCHECK_SYNC_PULL_PREFETCH_THRESHOLD", "0") or 0)
threshold = max(
32,
configured_threshold if configured_threshold > 0 else max(64, min(normalized_threads, normalized_threads // 2)),
)
return local_work <= threshold
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 = ""
step_name = '注册状态检测'
direct_allowed = self._allow_direct_connection_for_step(step_name)
register_single_machine_mode = self._register_single_machine_mode_enabled()
direct_first_attempt_limit = 0
# 高并发下如果注册检测仍然直连优先,会先把 controller 本机的外连资源打爆,
# 导致大量线程卡在 direct request error而不是尽快让代理承接流量。
# 这里默认改成“代理优先,直连兜底”;只有显式打开环境变量,或最近已明确
# 进入代理短缺/冷却态时,才允许注册检测直接优先走直连。
register_direct_first = False
if direct_allowed:
register_direct_first = (
self._env_flag_enabled("DOMAINCHECK_REGISTER_DIRECT_FIRST")
or self._proxy_status_indicates_shortage()
or register_single_machine_mode
)
if register_direct_first and direct_allowed:
direct_first_attempt_limit = 1
if register_single_machine_mode and direct_allowed:
direct_first_attempt_limit = max(
direct_first_attempt_limit,
self._register_single_machine_direct_streak_attempts(),
)
direct_attempt_count = 0
while True:
budget_exhausted, budget_reason = self._check_proxy_step_retry_budget(
step_name,
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 direct_attempt_count < direct_first_attempt_limit:
direct_attempt_count += 1
self._emit_step_trace(
domain_name,
step_name,
'proxy_direct_fallback',
proxy_mode='direct_perf' if register_single_machine_mode else 'direct_first',
retry_attempts=attempt_count,
)
else:
proxy = self._get_proxy_for_step(domain_id, domain_name, step_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(
step_name,
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)
self.release_proxy(proxy, step_name='注册状态检测')
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 status in {REGISTER_STATUS_CLIENT_HOLD, REGISTER_STATUS_SERVER_HOLD}:
hold_label = (
"clientHold"
if int(status) == int(REGISTER_STATUS_CLIENT_HOLD)
else "serverHold"
)
blacklist_reason = f"注册状态命中 {hold_label}"
if self._is_current_single_step_task():
self._cache_single_step_result_payload(
domain_id,
'register_status',
self._step_result_payload(
ok=False,
state="blacklisted",
message=blacklist_reason,
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
self._mark_blacklisted(domain_id, domain_name, blacklist_reason)
logger.warning(f"注册状态检测命中黑名单: {domain_name}, 原因: {blacklist_reason}")
return False
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', step_name, domain_name):
continue
if proxy:
self.release_proxy(proxy, step_name=step_name, reason='register lookup failed')
self._mark_detection_failed(domain_id, domain_name, step_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):
if direct_allowed and self._proxy_status_indicates_shortage():
self._set_step_force_direct_once(step_name, True)
self._set_step_proxy_retry_wait(
step_name,
self._proxy_direct_fallback_grace_for_step(step_name),
)
logger.warning(
f"注册状态检测直连失败,代理源仍短缺,下一次继续快速直连: {domain_name}, 原因: {e}"
)
else:
logger.warning(f"注册状态检测直连失败,切换代理继续: {domain_name}, 原因: {e}")
continue
if self._should_rotate_proxy_on_error(proxy, str(e), step_name, domain_name):
continue
if proxy:
self.release_proxy(proxy, step_name=step_name, reason=str(e))
self._mark_detection_failed(domain_id, domain_name, step_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
if self._should_force_direct_first_attempt('站长之家检测', attempt_count):
self._emit_step_trace(
domain_name,
'站长之家检测',
'proxy_direct_fallback',
elapsed_ms=0,
proxy_mode='direct',
recent_proxy_status=self.proxy_last_refresh_status,
retry_attempts=int(attempt_count or 0),
reason='single_machine_direct_first',
)
proxy = None
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:
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):
if proxy:
self.release_proxy(proxy, step_name='站长之家检测', reason=message or 'blacklisted')
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):
if proxy:
self.release_proxy(proxy, step_name='站长之家检测', reason=message or 'external dependency issue')
self._mark_detection_degraded(domain_id, domain_name, '站长之家检测', message or 'external dependency issue', 'chinaz_info')
return True
if proxy:
self.release_proxy(proxy, step_name='站长之家检测', reason=message or 'business rejected')
self._mark_detection_rejected(domain_id, domain_name, '站长之家检测', message or 'business rejected', 'chinaz_info')
return False
if proxy:
self._clear_proxy_failure(proxy)
self._clear_proxy_source_failure(proxy)
self.release_proxy(proxy, step_name='站长之家检测')
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):
if proxy:
self.release_proxy(proxy, step_name='站长之家检测', reason=str(e))
self._mark_detection_degraded(domain_id, domain_name, '站长之家检测', str(e), 'chinaz_info')
return True
if proxy:
self.release_proxy(proxy, step_name='站长之家检测', reason=str(e))
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
if self._should_force_direct_first_attempt('爱站网检测', attempt_count):
self._set_step_force_direct_once('爱站网检测', 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):
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=message or 'blacklisted')
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_degrade_external_issue_immediately('爱站网检测', message):
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=message or 'external dependency issue')
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', message or 'external dependency issue', 'aizhan_info')
return True
if self._should_retry_external_issue(proxy, message, '爱站网检测', domain_name):
continue
if self._is_external_dependency_issue(message):
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=message or 'external dependency issue')
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', message or 'external dependency issue', 'aizhan_info')
return True
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=message or 'business rejected')
self._mark_detection_rejected(domain_id, domain_name, '爱站网检测', message or 'business rejected', 'aizhan_info')
return False
if proxy:
self._clear_proxy_failure(proxy)
self._clear_proxy_source_failure(proxy)
self.release_proxy(proxy, step_name='爱站网检测')
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_degrade_external_issue_immediately('爱站网检测', e):
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=str(e))
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', str(e), 'aizhan_info')
return True
if self._should_retry_external_issue(proxy, str(e), '爱站网检测', domain_name):
continue
if self._is_external_dependency_issue(e):
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=str(e))
self._mark_detection_degraded(domain_id, domain_name, '爱站网检测', str(e), 'aizhan_info')
return True
if proxy:
self.release_proxy(proxy, step_name='爱站网检测', reason=str(e))
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
if self._should_force_direct_first_attempt('百度site检测', attempt_count):
self._set_step_force_direct_once('百度site检测', 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):
if proxy:
self.release_proxy(proxy, step_name='百度site检测', reason=message or 'blacklisted')
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):
if proxy:
self.release_proxy(proxy, step_name='百度site检测', reason=message or 'external dependency issue')
self._mark_detection_degraded(domain_id, domain_name, '百度site检测', message, 'baidu_site')
return True
if proxy:
self.release_proxy(proxy, step_name='百度site检测', reason=message or 'business rejected')
self._mark_detection_rejected(domain_id, domain_name, '百度site检测', message or 'business rejected', 'baidu_site')
return False
if proxy:
self._clear_proxy_failure(proxy)
self._clear_proxy_source_failure(proxy)
self.release_proxy(proxy, step_name='百度site检测')
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):
if proxy:
self.release_proxy(proxy, step_name='百度site检测', reason=str(e))
self._mark_detection_degraded(domain_id, domain_name, '百度site检测', str(e), 'baidu_site')
return True
if proxy:
self.release_proxy(proxy, step_name='百度site检测', reason=str(e))
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):
if proxy:
self.release_proxy(proxy, step_name='360检测', reason=message or 'blacklisted')
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_retry_external_issue(proxy, message, '360检测', domain_name):
continue
if self._is_external_dependency_issue(message):
if proxy:
self.release_proxy(proxy, step_name='360检测', reason=message or 'external dependency issue')
self._mark_detection_degraded(domain_id, domain_name, '360检测', message or 'external dependency issue', 'qihu360_site')
return True
if proxy:
self.release_proxy(proxy, step_name='360检测', reason=message or 'business rejected')
self._mark_detection_rejected(domain_id, domain_name, '360检测', message or 'business rejected', 'qihu360_site')
return False
if proxy:
self._clear_proxy_failure(proxy)
self._clear_proxy_source_failure(proxy)
self.release_proxy(proxy, step_name='360检测')
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_retry_external_issue(proxy, str(e), '360检测', domain_name):
continue
if self._is_external_dependency_issue(e):
if proxy:
self.release_proxy(proxy, step_name='360检测', reason=str(e))
self._mark_detection_degraded(domain_id, domain_name, '360检测', str(e), 'qihu360_site')
return True
if proxy:
self.release_proxy(proxy, step_name='360检测', reason=str(e))
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"),
)
if proxy:
self._clear_proxy_failure(proxy)
self._clear_proxy_source_failure(proxy)
self.release_proxy(proxy, step_name='聚查检测')
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)
if proxy:
self._clear_proxy_failure(proxy)
self._clear_proxy_source_failure(proxy)
self.release_proxy(proxy, step_name='桔子检测')
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()
session_id = int(job_context.get('session_id') or 0)
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,
)
early_abort_reason = self._detection_session_abort_reason(session_id)
if early_abort_reason:
self._release_job_item_for_session_abort(
job_item_id,
claim_token,
session_id=session_id,
domain_name=domain_name,
detail="queued_before_start",
)
logger.info(
f"检测会话已切换,跳过旧域名任务派发: {domain_name}, "
f"job_item_id={job_item_id or 0}, reason={early_abort_reason}"
)
self._emit_step_trace(
domain_name,
"domain",
"aborted",
job_item_id=job_item_id,
elapsed_ms=0,
ok=False,
error=f"检测会话已失效: reason={early_abort_reason} stage=queued_before_start domain={domain_name}"[:300],
)
self._clear_current_task_context()
return
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
abort_reason = self._detection_session_abort_reason(session_id)
if abort_reason:
self._release_job_item_for_session_abort(
job_item_id,
claim_token,
session_id=session_id,
domain_name=domain_name,
detail=f"finalize:{final_status}",
)
logger.warning(
f"检测会话已切换,跳过任务项完成态回写并释放回队列: domain={domain_name}, "
f"job_item_id={job_item_id}, status={final_status}, reason={abort_reason}"
)
return
try:
db_host = str(getattr(self.db, "host", "") or getattr(config, "DB_HOST", "") or "").strip() or "db"
db_name = str(getattr(self.db, "database", "") or getattr(config, "DB_DATABASE", "") or "").strip() or "-"
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=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,
)
logger.info(
"登记 domain 终态事件入队: "
f"domain={domain_name} job_id={job_id or 0} job_item_id={job_item_id or 0} "
f"event_type=domain_{final_status} db={db_host}:{db_name}"
)
except Exception as finalize_error:
logger.warning(
f"回写任务项状态失败: {domain_name}, 错误: {finalize_error}, "
f"job_id={job_id or 0}, job_item_id={job_item_id or 0}, "
f"event_type=domain_{final_status}"
)
def renew_job_item_lease(detail=""):
if is_step_task or not job_item_id or not claim_token:
return
abort_reason = self._detection_session_abort_reason(session_id)
if abort_reason:
self._release_job_item_for_session_abort(
job_item_id,
claim_token,
session_id=session_id,
domain_name=domain_name,
detail=f"renew:{detail}",
)
logger.warning(
f"检测会话已切换,跳过任务项续租并释放回队列: domain={domain_name}, "
f"job_item_id={job_item_id}, detail={detail}, reason={abort_reason}"
)
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
self._assert_detection_session_active(session_id, domain_name=domain_name, stage="prepare")
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")
db_host = str(getattr(self.db, "host", "") or getattr(config, "DB_HOST", "") or "").strip() or "db"
db_name = str(getattr(self.db, "database", "") or getattr(config, "DB_DATABASE", "") or "").strip() or "-"
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,
"task_mode": task_mode,
"step_code": requested_step_code if is_step_task else "",
},
)
logger.info(
"写入 domain_started 事件成功: "
f"domain={domain_name} job_id={job_id or 0} job_item_id={job_item_id or 0} "
f"event_type=domain_started db={db_host}:{db_name} "
f"task_mode={task_mode} step_code={requested_step_code if is_step_task else '-'}"
)
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)
self._assert_detection_session_active(session_id, domain_name=domain_name, stage=f"{detect_key}:prepare")
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._assert_detection_session_active(session_id, domain_name=domain_name, stage=f"{detect_key}:finished")
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)
self._assert_detection_session_active(session_id, domain_name=domain_name, stage=f"{requested_step_code}:finalize")
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,
}
self._assert_detection_session_active(
session_id,
domain_name=domain_name,
stage=f"{ordered_key}:parallel_finalize",
)
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)
self._assert_detection_session_active(
session_id,
domain_name=domain_name,
stage=f"{ordered_key}:serial_finalize",
)
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)
self._assert_detection_session_active(session_id, domain_name=domain_name, stage=f"{detect_key}:finalize")
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
self._assert_detection_session_active(session_id, domain_name=domain_name, stage="completion")
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 RuntimeError as e:
if str(e).startswith("检测会话已失效:"):
self._release_job_item_for_session_abort(
job_item_id,
claim_token,
session_id=session_id,
domain_name=domain_name,
detail=str(e),
)
logger.info(f"检测会话切换,终止旧域名任务: {domain_name}, 详情: {e}")
self._emit_step_trace(
domain_name,
"domain",
"aborted",
job_item_id=job_item_id,
elapsed_ms=int((time.perf_counter() - domain_started_at) * 1000),
ok=False,
error=str(e)[:300],
)
return
raise
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, session_id: int = 0):
"""
开始检测
"""
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._maybe_release_node_job_items(reason="start_detection")
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')
proxy_ready, proxy_count, proxy_ready_reason = self._prepare_proxy_pool_for_detection_start()
if proxy_ready:
logger.info(
f"检测启动前代理池已就绪,跳过同步刷新: "
f"count={proxy_count}, reason={proxy_ready_reason}"
)
self._update_runtime_state(
"running",
f"检测启动前代理池已就绪,直接进入任务派发: count={proxy_count}, reason={proxy_ready_reason}",
)
self._sync_worker_log_event(
"检测启动前代理池已就绪,直接进入任务派发",
payload={"proxy_count": proxy_count, "reason": proxy_ready_reason},
mode='full',
)
elif 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
thread_count_refresh_interval = max(
0.5,
float(os.getenv("DOMAINCHECK_THREAD_COUNT_REFRESH_INTERVAL", "2.0") or 2.0),
)
job_status_refresh_interval = max(
5.0,
float(os.getenv("DOMAINCHECK_JOB_STATUS_REFRESH_INTERVAL", "15.0") or 15.0),
)
worker_pool_size = max(
1,
int(
self.refresh_thread_count_runtime(
force=True,
min_interval=thread_count_refresh_interval,
)
or 1
),
)
configured_thread_stack_size = _configure_worker_thread_stack_size()
worker_pool = ThreadPoolExecutor(
max_workers=worker_pool_size,
thread_name_prefix="detect-domain",
)
last_worker_pool_size = worker_pool_size
if configured_thread_stack_size > 0:
logger.info(
f"检测线程池初始化: max_workers={worker_pool_size}, thread_stack_size={configured_thread_stack_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
last_proactive_pipeline_topup_at = 0.0
last_proactive_sync_topup_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 = {
'cycle_token': self.current_cycle_token,
'job_code': domain.get('job_code') or self.current_job_code,
'session_id': session_id,
}
if using_job_queue:
job_context.update(
{
'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'),
}
)
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:
abort_reason = self._detection_session_abort_reason(session_id)
if abort_reason:
logger.warning(f"检测会话已切换,停止旧会话主循环: {abort_reason}")
self._mark_detection_phase("restarting", f"检测会话已切换,停止旧会话主循环: {abort_reason}")
self._sync_worker_log_event(
f"检测会话已切换,停止旧会话主循环: {abort_reason}",
level='warning',
mode='key',
)
final_phase = "restarting"
final_detail = f"检测会话已切换,停止旧会话主循环: {abort_reason}"
break
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_releases()
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(
min_interval=thread_count_refresh_interval
)
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:
recycle_lock_owner = self._acquire_shared_job_maintenance_lock(
"recycle-expired-job-items",
ttl_seconds=10,
)
if recycle_lock_owner:
try:
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")
finally:
self._release_shared_job_maintenance_lock(
"recycle-expired-job-items",
recycle_lock_owner,
)
if self.current_job_id and now_ts - last_job_status_refresh_at >= job_status_refresh_interval:
self._log_job_status_refresh_probe(self.current_job_id)
self.db.refresh_detect_job_status(self.current_job_id)
last_job_status_refresh_at = now_ts
pending_buffer_cap_multiplier = self._runtime_positive_int_override(
"pending_buffer_cap_multiplier",
"DOMAINCHECK_PENDING_BUFFER_CAP_MULTIPLIER",
default=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 必须绑定当前 jobdomain_pipeline 则持续从全局任务队列补位,
# 否则旧 job 未完全结束时,新 job 会长期堆在 pending。
scoped_job_id = self._current_scoped_claim_job_id()
if scoped_job_id not in (None, "", 0, "0"):
self._log_explicit_scope_probe(
scoped_job_id=scoped_job_id,
max_threads=max_threads,
refill_slots=refill_slots,
claim_batch_size=claim_batch_size,
pending_buffer=len(pending_domains),
)
claim_started_at = time.perf_counter()
domains = self._claim_detect_job_items(
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
proactive_topup_needed = (
not single_step_session_active
and self._should_proactively_top_up_pipeline(len(domains), refill_slots, max_threads)
)
if domains:
append_pending_domains(
domains,
True,
"任务队列",
claim_elapsed_ms,
legacy_fetch_elapsed_ms,
dispatch_capacity,
claim_batch_size,
claim_lease_seconds,
)
if proactive_topup_needed:
proactive_now = time.time()
if proactive_now - float(last_proactive_pipeline_topup_at or 0.0) >= 0.5:
pipeline_domains = self._process_pipeline_tasks_until_available(thread_limit=max_threads)
last_proactive_pipeline_topup_at = proactive_now
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,
)
elif proactive_now - float(last_proactive_sync_topup_at or 0.0) >= 1.0:
refill_domains = self._pull_sync_tasks_until_available(
thread_limit=max_threads,
claim_after_pull=False,
)
last_proactive_sync_topup_at = proactive_now
if refill_domains:
append_pending_domains(
refill_domains,
True,
"任务队列(主动补货补位)",
claim_elapsed_ms,
legacy_fetch_elapsed_ms,
dispatch_capacity,
claim_batch_size,
claim_lease_seconds,
)
if self._should_prefetch_sync_tasks(len(pending_domains), len(inflight_futures), max_threads):
proactive_now = time.time()
if proactive_now - float(last_proactive_sync_topup_at or 0.0) >= 1.0:
self._pull_sync_tasks_until_available(
thread_limit=max_threads,
claim_after_pull=False,
)
last_proactive_sync_topup_at = proactive_now
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_releases(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)
can_blocking_shutdown = (
not pending_domains
and not inflight_futures
and self._get_active_domain_threads() <= 0
)
worker_pool.shutdown(wait=can_blocking_shutdown, cancel_futures=True)
if can_blocking_shutdown:
gc.collect()
# 完成进度
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 = get_redis_client(role="pubsub")
# 订阅配置更新频道
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()
previous_thread_count = int(getattr(self, "thread_count", 0) or 0)
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()
should_refresh_proxy, reset_proxy_cooldown = self._should_trigger_proxy_refresh_for_config_update(
config_type=config_type,
previous_proxy_config=previous_proxy_config,
previous_thread_count=previous_thread_count,
current_thread_count=self.thread_count,
)
if should_refresh_proxy:
self.trigger_proxy_refresh(
reason=f"config_update:{config_type}",
reset_cooldown=reset_proxy_cooldown,
)
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秒以便查看错误信息