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