Files
getDomain/domain-api/app/services/detect_service.py
Your Name 7cbde2aa78 d
2026-04-22 14:13:21 +08:00

1097 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
import subprocess
from datetime import datetime, timedelta, timezone
from app.core.config import settings
from app.core.db import get_db
from app.core.files import resolve_domain_path, tail_lines
from app.core.redis_client import get_redis
from app.services.debug_event_service import list_debug_events
from app.services.cluster_runtime_service import ensure_runtime_schema
from app.services.runtime_settings_service import get_runtime_settings
from app.services.detect_run_service import sync_detect_runs
from app.services.detect_job_service import get_active_detect_job_summary
from app.services.settings_service import get_settings_payload, resolve_thread_count
from app.services.sync_record_service import append_detect_result_projection_if_changed
from app.services.worker_control_service import detect_worker_runtime
_PROXY_COUNT_RE = re.compile(r"当前可用代理数[:]\s*(\d+)")
_THREAD_COUNT_RE = re.compile(r"当前实际线程数量[:]\s*(\d+)\s*/\s*(\d+)")
_STEP_TRACE_DOMAIN_RE = re.compile(r"domain=([^\s|]+)")
_REGISTER_DOMAIN_RE = re.compile(r"检测注册状态[:]\s*([^\s]+)")
_RUNTIME_STATE_KEY = "domain_tool:detect_runtime_state"
_TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S")
_SYSLOG_TIMESTAMP_FORMAT = "%b %d %H:%M:%S"
_REMOTE_LOG_MAX_CHARS = 500
_REMOTE_DEBUG_EVENT_TYPES = {
"worker_log",
"active_job_snapshot",
"domain_started",
"domain_completed",
"domain_failed",
"domain_blacklisted",
"task_pull_success",
"task_pull_partial",
"task_pull_failed",
"queue_overdue_leases",
}
def _extract_remote_log_node_code(line: str) -> str:
text = str(line or "").strip()
if not text.startswith("["):
return ""
first_close = text.find("]")
if first_close < 0:
return ""
second_open = text.find("[", first_close + 1)
second_close = text.find("]", second_open + 1) if second_open >= 0 else -1
if second_open < 0 or second_close < 0:
return ""
return text[second_open + 1:second_close].strip()
def _slice_remote_log_lines_fairly(lines: list[str], *, limit: int = 240, min_per_node: int = 12) -> list[str]:
safe_limit = max(1, int(limit or 240))
if len(lines) <= safe_limit:
return list(lines or [])
normalized_lines = [str(line or "").strip() for line in list(lines or []) if str(line or "").strip()]
if len(normalized_lines) <= safe_limit:
return normalized_lines
if min_per_node <= 0:
return normalized_lines[-safe_limit:]
kept_indexes: set[int] = set()
per_node_counts: dict[str, int] = {}
for index in range(len(normalized_lines) - 1, -1, -1):
node_code = _extract_remote_log_node_code(normalized_lines[index])
if not node_code:
continue
current_count = int(per_node_counts.get(node_code, 0) or 0)
if current_count >= min_per_node:
continue
kept_indexes.add(index)
per_node_counts[node_code] = current_count + 1
if len(kept_indexes) >= safe_limit:
break
for index in range(len(normalized_lines) - 1, -1, -1):
if len(kept_indexes) >= safe_limit:
break
kept_indexes.add(index)
return [normalized_lines[index] for index in sorted(kept_indexes)]
def _runtime_state_key(node_code: str | None = None) -> str:
normalized_node_code = str(node_code or settings.node_code or "").strip()
if not normalized_node_code:
return _RUNTIME_STATE_KEY
return f"{_RUNTIME_STATE_KEY}:{normalized_node_code}"
def _extract_dependency_alerts(lines: list[str]) -> list[dict]:
alerts: list[dict] = []
recent_lines = lines[-120:] if lines else []
degraded_line = ""
for line in reversed(recent_lines):
if "外部依赖异常,步骤降级继续执行" in line:
degraded_line = line
alerts.append(
{
"kind": "dependency_degraded",
"level": "warning",
"title": "外部依赖降级继续",
"detail": line,
}
)
break
if degraded_line:
return alerts
for line in reversed(recent_lines):
if "WaybackDetector" in line or "web.archive.org" in line:
alerts.append(
{
"kind": "wayback",
"level": "warning",
"title": "时光机依赖异常",
"detail": line,
}
)
break
for line in reversed(recent_lines):
if any(keyword in line for keyword in ("HTTPSConnectionPool", "Connection refused", "Read timed out", "ConnectTimeout")):
alerts.append(
{
"kind": "network",
"level": "warning",
"title": "外部网络波动",
"detail": line,
}
)
break
return alerts
def _extract_available_proxy_count(lines: list[str]) -> int:
for line in reversed(lines):
match = _PROXY_COUNT_RE.search(line)
if match:
return int(match.group(1))
return 0
def _extract_active_thread_snapshot(lines: list[str]) -> dict:
for line in reversed(lines):
match = _THREAD_COUNT_RE.search(line)
if match:
return {
"active": int(match.group(1)),
"max": int(match.group(2)),
}
return {"active": 0, "max": 0}
def _estimate_active_threads_from_recent_lines(lines: list[str], *, limit: int) -> int:
if not lines:
return 0
active_domains: list[str] = []
seen_domains: set[str] = set()
for line in reversed(lines[-80:]):
match = _STEP_TRACE_DOMAIN_RE.search(line) or _REGISTER_DOMAIN_RE.search(line)
if not match:
continue
domain = str(match.group(1) or "").strip()
if not domain or domain in seen_domains:
continue
seen_domains.add(domain)
active_domains.append(domain)
if len(active_domains) >= int(limit):
break
return len(active_domains)
def _parse_time(raw: str | None) -> datetime | None:
if not raw:
return None
text = str(raw).strip()
if not text:
return None
try:
return datetime.fromisoformat(text)
except ValueError:
pass
for fmt in _TIMESTAMP_FORMATS:
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
def _extract_log_time(line: str, *, reference_year: int | None = None) -> datetime | None:
if len(line) < 19:
text = str(line or "").strip()
else:
text = str(line or "")
candidates = [text[:26], text[:19]]
for candidate in candidates:
for fmt in _TIMESTAMP_FORMATS:
if len(candidate) != len(datetime.now().strftime(fmt)):
continue
try:
return datetime.strptime(candidate, fmt)
except ValueError:
continue
syslog_candidate = str(text[:15] or "").strip()
if syslog_candidate:
try:
parsed = datetime.strptime(syslog_candidate, _SYSLOG_TIMESTAMP_FORMAT)
return parsed.replace(year=int(reference_year or datetime.now().year))
except ValueError:
pass
return None
def _read_worker_journal_lines(service_name: str, *, max_lines: int) -> tuple[list[str], str | None]:
normalized_service_name = str(service_name or "").strip()
if not normalized_service_name:
return [], None
try:
completed = subprocess.run(
["journalctl", "-u", normalized_service_name, "-n", str(max_lines), "--no-pager"],
capture_output=True,
text=True,
timeout=12,
)
except Exception:
return [], None
output = str(completed.stdout or "").strip()
if completed.returncode != 0 or not output:
return [], None
lines = [str(line or "").rstrip() for line in output.splitlines() if str(line or "").strip()]
if not lines:
return [], None
return lines[-max_lines:], datetime.now(timezone.utc).isoformat()
def _load_recent_worker_lines(runtime_settings: dict, *, max_lines: int = 160) -> tuple[bool, str | None, list[str]]:
worker_log = resolve_domain_path("detect_worker.log", "logs/detect_worker.log")
worker_online = False
last_log_time: str | None = None
recent_lines = tail_lines("detect_worker.log", max_lines=max_lines)
if worker_log and worker_log.exists():
modified = datetime.fromtimestamp(worker_log.stat().st_mtime, tz=timezone.utc)
last_log_time = modified.isoformat()
worker_online = (datetime.now(timezone.utc) - modified).total_seconds() < 180
if str(runtime_settings.get("worker_mode") or "").strip() == "linux-systemd":
service_name = str(runtime_settings.get("worker_service_name") or "").strip() or "domaincheck-worker"
journal_lines, journal_last_time = _read_worker_journal_lines(service_name, max_lines=max_lines)
if journal_lines:
recent_lines = journal_lines
worker_online = True
if journal_last_time:
last_log_time = journal_last_time
return worker_online, last_log_time, recent_lines
def _filter_lines_since(lines: list[str], started_at: str | None) -> list[str]:
started_time = _parse_time(started_at)
if not started_time:
return lines
filtered: list[str] = []
parsed_any = False
for line in lines:
line_time = _extract_log_time(line, reference_year=started_time.year)
if line_time is None:
continue
parsed_any = True
if line_time >= started_time:
filtered.append(line)
if not parsed_any:
return lines
return filtered or lines
def _recent_event(lines: list[str]) -> str:
interesting_keywords = (
"开始检测",
"获取到",
"当前批次检测完成",
"域名检测任务完成",
"代理池刷新完成",
"没有需要检测的域名",
"检测已停止",
)
for line in reversed(lines):
if any(keyword in line for keyword in interesting_keywords):
return line
return ""
def _build_remote_log_lines(
active_job: dict | None,
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> list[str]:
return _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit)["lines"]
def _build_remote_log_snapshot(
active_job: dict | None,
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> dict:
if not enabled:
return {
"lines": [],
"line_count": 0,
"last_at": "",
"last_line": "",
"source_nodes": [],
"source_node_count": 0,
}
if not active_job:
return {
"lines": [],
"line_count": 0,
"last_at": "",
"last_line": "",
"source_nodes": [],
"source_node_count": 0,
}
events = list(active_job.get("current_cycle_events") or active_job.get("recent_events") or [])
if not events:
return {
"lines": [],
"line_count": 0,
"last_at": "",
"last_line": "",
"source_nodes": [],
"source_node_count": 0,
}
lines: list[str] = []
source_nodes: set[str] = set()
source_node_summaries: dict[str, dict] = {}
last_at = ""
last_line = ""
normalized_mode = str(mode or "key").strip().lower()
if normalized_mode not in {"key", "full"}:
normalized_mode = "key"
current_cycle_token = str(active_job.get("current_cycle_token") or "").strip()
for event in reversed(events):
event_type = str(event.get("event_type") or "").strip()
if event_type != "worker_log":
continue
created_at = str(event.get("created_at") or "").strip()
node_code = str(event.get("node_code") or "").strip() or "unknown"
message = str(event.get("message") or "").strip()
if not message:
continue
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
event_cycle_token = str(payload.get("cycle_token") or "").strip()
if current_cycle_token and event_cycle_token and event_cycle_token != current_cycle_token:
continue
event_mode = str(payload.get("log_mode") or "key").strip().lower()
if event_mode not in {"key", "full"}:
event_mode = "key"
if normalized_mode != "full" and event_mode == "full":
continue
if len(message) > _REMOTE_LOG_MAX_CHARS:
message = f"{message[:_REMOTE_LOG_MAX_CHARS]}..."
formatted_line = f"[{created_at}] [{node_code}] {message}"
lines.append(formatted_line)
source_nodes.add(node_code)
node_summary = source_node_summaries.setdefault(
node_code,
{
"node_code": node_code,
"line_count": 0,
"key_line_count": 0,
"full_line_count": 0,
"last_at": "",
"last_line": "",
},
)
node_summary["line_count"] += 1
if event_mode == "full":
node_summary["full_line_count"] += 1
else:
node_summary["key_line_count"] += 1
node_summary["last_at"] = created_at
node_summary["last_line"] = formatted_line
last_at = created_at
last_line = formatted_line
sliced_lines = lines[-max(1, int(limit or 240)) :]
sorted_source_node_summaries = sorted(
source_node_summaries.values(),
key=lambda item: (
str(item.get("last_at") or ""),
str(item.get("node_code") or ""),
),
reverse=True,
)
return {
"lines": sliced_lines,
"line_count": len(sliced_lines),
"last_at": last_at,
"last_line": last_line,
"source_nodes": sorted(source_nodes),
"source_node_count": len(source_nodes),
"source_node_summaries": sorted_source_node_summaries,
}
def _build_remote_log_snapshot_from_debug_events(
active_job: dict | None,
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> dict:
if not enabled:
return {
"lines": [],
"line_count": 0,
"last_at": "",
"last_line": "",
"source_nodes": [],
"source_node_count": 0,
"source_node_summaries": [],
}
normalized_mode = str(mode or "key").strip().lower()
if normalized_mode not in {"key", "full"}:
normalized_mode = "key"
participating_node_codes = {
str(item.get("node_code") or "").strip()
for item in list((active_job or {}).get("node_stats") or [])
if str(item.get("node_code") or "").strip() and str(item.get("node_code") or "").strip() != "unassigned"
}
created_after = (datetime.now() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
safe_limit = max(1, int(limit or 240))
node_limit = max(40, min(200, safe_limit))
records: list[dict] = []
if participating_node_codes:
for node_code in sorted(participating_node_codes):
payload = list_debug_events(
limit=node_limit,
created_after=created_after,
node_code=node_code,
)
records.extend(list(payload.get("records") or []))
records.sort(
key=lambda item: (
str(item.get("created_at") or ""),
int(item.get("id") or 0),
),
reverse=True,
)
else:
payload = list_debug_events(limit=max(safe_limit * 4, 240), created_after=created_after)
records = list(payload.get("records") or [])
if not records:
return {
"lines": [],
"line_count": 0,
"last_at": "",
"last_line": "",
"source_nodes": [],
"source_node_count": 0,
"source_node_summaries": [],
}
lines: list[str] = []
source_nodes: set[str] = set()
source_node_summaries: dict[str, dict] = {}
last_at = ""
last_line = ""
for record in reversed(records):
event_type = str(record.get("event_type") or "").strip()
if event_type not in _REMOTE_DEBUG_EVENT_TYPES:
continue
node_code = str(record.get("node_code") or "").strip() or "unknown"
if participating_node_codes and node_code not in participating_node_codes:
continue
message = str(record.get("message") or "").strip()
if not message:
continue
created_at = str(record.get("created_at") or "").strip()
payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
event_mode = str(payload.get("log_mode") or "key").strip().lower()
if event_mode not in {"key", "full"}:
event_mode = "key"
if normalized_mode != "full" and event_mode == "full":
continue
if len(message) > _REMOTE_LOG_MAX_CHARS:
message = f"{message[:_REMOTE_LOG_MAX_CHARS]}..."
formatted_line = f"[{created_at}] [{node_code}] {message}"
lines.append(formatted_line)
source_nodes.add(node_code)
node_summary = source_node_summaries.setdefault(
node_code,
{
"node_code": node_code,
"line_count": 0,
"key_line_count": 0,
"full_line_count": 0,
"last_at": "",
"last_line": "",
},
)
node_summary["line_count"] += 1
if event_mode == "full":
node_summary["full_line_count"] += 1
else:
node_summary["key_line_count"] += 1
node_summary["last_at"] = created_at
node_summary["last_line"] = formatted_line
last_at = created_at
last_line = formatted_line
sliced_lines = _slice_remote_log_lines_fairly(lines, limit=safe_limit)
sorted_source_node_summaries = sorted(
source_node_summaries.values(),
key=lambda item: (
str(item.get("last_at") or ""),
str(item.get("node_code") or ""),
),
reverse=True,
)
return {
"lines": sliced_lines,
"line_count": len(sliced_lines),
"last_at": last_at,
"last_line": last_line,
"source_nodes": sorted(source_nodes),
"source_node_count": len(source_nodes),
"source_node_summaries": sorted_source_node_summaries,
}
def _merge_remote_log_snapshots(primary: dict, secondary: dict, *, limit: int = 240) -> dict:
merged_lines: list[str] = []
seen_lines: set[str] = set()
for raw_line in list(primary.get("lines") or []) + list(secondary.get("lines") or []):
line = str(raw_line or "").strip()
if not line or line in seen_lines:
continue
seen_lines.add(line)
merged_lines.append(line)
if limit > 0:
merged_lines = _slice_remote_log_lines_fairly(merged_lines, limit=limit)
summaries: dict[str, dict] = {}
for snapshot in (primary, secondary):
for raw_summary in list(snapshot.get("source_node_summaries") or []):
if not isinstance(raw_summary, dict):
continue
node_code = str(raw_summary.get("node_code") or "").strip()
if not node_code:
continue
summary = summaries.setdefault(
node_code,
{
"node_code": node_code,
"line_count": 0,
"key_line_count": 0,
"full_line_count": 0,
"last_at": "",
"last_line": "",
},
)
summary["line_count"] = max(int(summary.get("line_count", 0) or 0), int(raw_summary.get("line_count", 0) or 0))
summary["key_line_count"] = max(int(summary.get("key_line_count", 0) or 0), int(raw_summary.get("key_line_count", 0) or 0))
summary["full_line_count"] = max(int(summary.get("full_line_count", 0) or 0), int(raw_summary.get("full_line_count", 0) or 0))
raw_last_at = str(raw_summary.get("last_at") or "")
if raw_last_at >= str(summary.get("last_at") or ""):
summary["last_at"] = raw_last_at
summary["last_line"] = str(raw_summary.get("last_line") or "")
source_nodes = sorted(
{
str(node_code or "").strip()
for node_code in list(primary.get("source_nodes") or []) + list(secondary.get("source_nodes") or [])
if str(node_code or "").strip()
}
)
last_at = max(str(primary.get("last_at") or ""), str(secondary.get("last_at") or ""))
last_line = str(primary.get("last_line") or "")
if str(secondary.get("last_at") or "") >= str(primary.get("last_at") or ""):
last_line = str(secondary.get("last_line") or last_line)
return {
"lines": merged_lines,
"line_count": len(merged_lines),
"last_at": last_at,
"last_line": last_line,
"source_nodes": source_nodes,
"source_node_count": len(source_nodes),
"source_node_summaries": sorted(
summaries.values(),
key=lambda item: (
str(item.get("last_at") or ""),
str(item.get("node_code") or ""),
),
reverse=True,
),
}
def _resolve_remote_log_lines(
active_job: dict | None,
runs: list[dict],
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> list[str]:
return _build_remote_log_lines(active_job, enabled=enabled, mode=mode, limit=limit)
def _resolve_remote_log_snapshot(
active_job: dict | None,
runs: list[dict],
*,
enabled: bool,
mode: str,
limit: int = 240,
) -> dict:
primary_snapshot = _build_remote_log_snapshot(active_job, enabled=enabled, mode=mode, limit=limit)
debug_snapshot = _build_remote_log_snapshot_from_debug_events(active_job, enabled=enabled, mode=mode, limit=limit)
if int(primary_snapshot.get("line_count", 0) or 0) <= 0:
return debug_snapshot
if int(debug_snapshot.get("line_count", 0) or 0) <= 0:
return primary_snapshot
return _merge_remote_log_snapshots(primary_snapshot, debug_snapshot, limit=limit)
def _load_runtime_state() -> dict:
try:
redis_client = get_redis()
for key in (_runtime_state_key(), _RUNTIME_STATE_KEY):
raw = redis_client.get(key)
if not raw:
continue
data = json.loads(raw)
if not isinstance(data, dict):
continue
if key == _RUNTIME_STATE_KEY:
payload_node_code = str(data.get("node_code") or "").strip()
if payload_node_code and payload_node_code != str(settings.node_code or "").strip():
continue
return data
return {}
except Exception:
return {}
def _load_runtime_state_from_cluster_node() -> dict:
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT current_load, metadata_json, last_heartbeat_at
FROM detect_worker_nodes
WHERE node_code = %s
LIMIT 1
""",
(settings.node_code,),
)
row = cur.fetchone()
if not row:
return {}
current_load, metadata_json, last_heartbeat_at = row
metadata = metadata_json if isinstance(metadata_json, dict) else {}
if not metadata:
return {}
runtime_state = {
"node_code": settings.node_code,
"phase": str(metadata.get("phase") or metadata.get("phase_label") or "").strip(),
"detail": str(metadata.get("detail") or metadata.get("phase_detail") or "").strip(),
"service_running": True,
"detecting": bool(metadata.get("detecting", False) or int(current_load or 0) > 0),
"stop_requested": False,
"available_proxy_count": int(metadata.get("available_proxy_count", 0) or 0),
"active_threads": int(metadata.get("active_threads", 0) or 0),
"max_threads": int(metadata.get("max_threads", 0) or 0),
"job_id": metadata.get("job_id"),
"job_code": str(metadata.get("job_code") or metadata.get("active_job_code") or "").strip(),
"updated_at": str(metadata.get("updated_at") or (_format_time(last_heartbeat_at) if last_heartbeat_at else "")).strip(),
}
if runtime_state["detail"] or runtime_state["active_threads"] > 0 or runtime_state["max_threads"] > 0:
return runtime_state
return {}
except Exception:
return {}
def _normalize_recent_warning(runtime_state: dict, recent_lines: list[str], available_proxy_count: int) -> str:
runtime_warning = str(runtime_state.get("recent_warning", "") or "").strip()
if runtime_warning:
if runtime_warning in {"未刷新", "代理未启用", "未配置代理池链接"}:
return ""
if available_proxy_count > 0 and ("无可用代理" in runtime_warning or "未取到可用代理数据" in runtime_warning):
return ""
return runtime_warning
recent_proxy_warning = next(
(line for line in reversed(recent_lines) if "代理池刷新失败" in line or "无可用代理" in line or "Redis订阅失败" in line),
"",
)
if available_proxy_count > 0 and "无可用代理" in recent_proxy_warning:
return ""
return recent_proxy_warning
def _build_proxy_runtime_snapshot(settings_payload: dict, runtime_state: dict, available_proxy_count: int) -> dict:
proxy_config = settings_payload.get("proxy_config") or {}
proxy_enable = bool(proxy_config.get("proxy_enable", False))
allow_direct = bool(proxy_config.get("allow_direct", False))
refresh_status = str(runtime_state.get("proxy_last_refresh_status", "") or "").strip()
refresh_time = str(runtime_state.get("proxy_last_refresh_time", "") or "").strip()
source_count = len(proxy_config.get("proxy_urls", []))
source_stats = runtime_state.get("proxy_last_source_stats") or []
raw_items = int(runtime_state.get("proxy_last_refresh_total_items", 0) or 0)
validated = int(runtime_state.get("proxy_last_validated_count", 0) or 0)
available = int(runtime_state.get("proxy_last_available_count", available_proxy_count) or available_proxy_count)
source_ok_count = sum(1 for item in source_stats if str(item.get("status", "") or "").strip() == "ok")
supplier_empty = bool(source_stats) and source_ok_count == len(source_stats) and raw_items <= 0
if not proxy_enable:
return {
"state": "disabled",
"label": "未启用代理",
"detail": "当前使用直连模式,未启用代理池",
"direct_fallback_active": True,
"reason": "proxy_disabled",
"last_refresh_status": refresh_status or "代理未启用",
"last_refresh_time": refresh_time,
"source_count": source_count,
"raw_items": raw_items,
"validated_count": validated,
"available_count": available,
"source_stats": source_stats,
"supplier_empty": False,
}
if available_proxy_count > 0:
detail = f"代理池当前可用 {available_proxy_count} 个代理,配置来源 {source_count}"
if refresh_status:
detail = f"{detail};最近状态:{refresh_status}"
return {
"state": "healthy",
"label": "代理正常",
"detail": detail,
"direct_fallback_active": False,
"reason": "healthy",
"last_refresh_status": refresh_status,
"last_refresh_time": refresh_time,
"source_count": source_count,
"raw_items": raw_items,
"validated_count": validated,
"available_count": available,
"source_stats": source_stats,
"supplier_empty": False,
}
if refresh_status in {"", "未刷新"} and source_count > 0:
if allow_direct:
return {
"state": "degraded_direct",
"label": "等待首刷",
"detail": f"代理配置已下发,但代理池尚未完成首轮刷新;当前先按直连继续执行;最近状态:{refresh_status or '未刷新'}",
"direct_fallback_active": True,
"reason": "proxy_not_refreshed_yet",
"last_refresh_status": refresh_status or "未刷新",
"last_refresh_time": refresh_time,
"source_count": source_count,
"raw_items": raw_items,
"validated_count": validated,
"available_count": available,
"source_stats": source_stats,
"supplier_empty": False,
}
return {
"state": "warming_up",
"label": "等待首刷",
"detail": "代理配置已下发,但代理池尚未完成首轮刷新;由于未允许直连,检测链路会等待代理刷新完成",
"direct_fallback_active": False,
"reason": "proxy_not_refreshed_yet",
"last_refresh_status": refresh_status or "未刷新",
"last_refresh_time": refresh_time,
"source_count": source_count,
"raw_items": raw_items,
"validated_count": validated,
"available_count": available,
"source_stats": source_stats,
"supplier_empty": False,
}
if allow_direct:
detail = "代理池当前无可用代理,已自动降级为直连继续执行"
reason = "no_available_proxy"
if supplier_empty:
reason = "supplier_empty_pool"
detail = "代理源最近都返回正常响应,但原始代理数为 0当前判断为供应池为空系统已自动降级为直连继续执行"
elif raw_items > 0 and validated > 0:
reason = "proxy_validation_zero"
detail = (
f"代理源最近返回了 {raw_items} 个代理,已验证 {validated} 个,但当前 0 个可用;"
"系统已自动降级为直连继续执行"
)
if refresh_status:
detail = f"{detail};最近状态:{refresh_status}"
return {
"state": "degraded_direct",
"label": "降级直连",
"detail": detail,
"direct_fallback_active": True,
"reason": reason,
"last_refresh_status": refresh_status or "当前无可用代理",
"last_refresh_time": refresh_time,
"source_count": source_count,
"raw_items": raw_items,
"validated_count": validated,
"available_count": available,
"source_stats": source_stats,
"supplier_empty": supplier_empty,
}
detail = "代理池当前无可用代理,且未允许直连,检测链路会等待代理恢复"
reason = "no_available_proxy"
if supplier_empty:
reason = "supplier_empty_pool"
detail = "代理源最近都返回正常响应,但原始代理数为 0当前判断为供应池为空由于未允许直连检测链路会等待代理恢复"
elif raw_items > 0 and validated > 0:
reason = "proxy_validation_zero"
detail = (
f"代理源最近返回了 {raw_items} 个代理,已验证 {validated} 个,但当前 0 个可用;"
"由于未允许直连,检测链路会等待代理恢复"
)
if refresh_status:
detail = f"{detail};最近状态:{refresh_status}"
return {
"state": "blocked_no_proxy",
"label": "等待代理",
"detail": detail,
"direct_fallback_active": False,
"reason": reason,
"last_refresh_status": refresh_status or "当前无可用代理",
"last_refresh_time": refresh_time,
"source_count": source_count,
"raw_items": raw_items,
"validated_count": validated,
"available_count": available,
"source_stats": source_stats,
"supplier_empty": supplier_empty,
}
def get_detect_status() -> dict:
try:
ensure_runtime_schema()
except Exception:
# Node agent heartbeats should degrade gracefully even if runtime schema
# initialization is temporarily unavailable.
pass
queries = {
"pending": "select count(*) from domains where detect_status = 0",
"completed": "select count(*) from domains where detect_status = 1",
"running": "select count(*) from domains where detect_status = 2",
"blacklisted": "select count(*) from domains where detect_status = 3",
"failed": "select count(*) from domains where detect_status = 4",
"registerable": "select count(*) from domains where detect_status = 1 and register_status = 2",
}
progress: dict[str, int] = {key: 0 for key in queries}
try:
with get_db() as conn:
with conn.cursor() as cur:
for key, query in queries.items():
try:
cur.execute(query)
progress[key] = cur.fetchone()[0]
except Exception:
progress[key] = 0
except Exception:
# Worker runtime/status pages should still render using runtime-state and
# cluster fallbacks even when the local DB endpoint is temporarily wrong
# or unreachable (for example remote worker nodes without direct DB access).
pass
settings_payload = get_settings_payload()
runtime_settings = get_runtime_settings()
worker_online, last_log_time, recent_lines = _load_recent_worker_lines(runtime_settings, max_lines=160)
runtime = detect_worker_runtime()
runtime_state = _load_runtime_state()
if not runtime_state:
runtime_state = _load_runtime_state_from_cluster_node()
runtime_started_at = runtime.get("latest_start_time", "")
recent_lines = _filter_lines_since(recent_lines, runtime_started_at)
available_proxy_count = _extract_available_proxy_count(recent_lines)
active_thread_snapshot = _extract_active_thread_snapshot(recent_lines)
if runtime_state:
available_proxy_count = int(runtime_state.get("available_proxy_count", available_proxy_count) or available_proxy_count)
active_thread_snapshot = {
"active": int(runtime_state.get("active_threads", active_thread_snapshot["active"]) or active_thread_snapshot["active"]),
"max": int(runtime_state.get("max_threads", active_thread_snapshot["max"]) or active_thread_snapshot["max"]),
}
registerable_count = int(progress.get("registerable", 0) or 0)
progress_total = sum(
int(progress.get(key, 0) or 0)
for key in ("pending", "completed", "running", "blacklisted", "failed")
)
progress_done = progress.get("completed", 0) + progress.get("blacklisted", 0) + progress.get("failed", 0)
progress_percent = round((progress_done / progress_total) * 100, 2) if progress_total > 0 else 0
runtime_started_at = runtime.get("latest_start_time", "")
recent_lines = _filter_lines_since(recent_lines, runtime_started_at)
recent_proxy_warning = _normalize_recent_warning(runtime_state, recent_lines, available_proxy_count)
proxy_runtime = _build_proxy_runtime_snapshot(settings_payload, runtime_state, available_proxy_count)
thread_count_resolution = resolve_thread_count(settings_payload=settings_payload)
effective_thread_count = int(thread_count_resolution["effective_thread_count"])
worker_online = worker_online or runtime.get("running", False)
if runtime_state.get("service_running") is True:
worker_online = True
if active_thread_snapshot["active"] <= 0 and (runtime_state.get("detecting", False) or runtime.get("running", False)):
estimated_active_threads = _estimate_active_threads_from_recent_lines(
recent_lines,
limit=max(1, effective_thread_count),
)
if estimated_active_threads > 0:
active_thread_snapshot["active"] = estimated_active_threads
inferred_detecting = bool(
runtime_state.get("detecting", False)
or int(progress.get("running", 0) or 0) > 0
or int(active_thread_snapshot.get("active", 0) or 0) > 0
)
if not inferred_detecting:
active_thread_snapshot = {"active": 0, "max": active_thread_snapshot["max"] or effective_thread_count}
settings_summary = {
"thread_count": effective_thread_count,
"thread_count_default": int(thread_count_resolution["default_thread_count"]),
"thread_count_source": str(thread_count_resolution["source"]),
"thread_count_override": thread_count_resolution["override_thread_count"],
"thread_count_node_code": str(thread_count_resolution["node_code"]),
"proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False),
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
}
try:
active_job = get_active_detect_job_summary(event_limit=240)
except Exception:
active_job = None
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
progress = {
"pending": int(active_job.get("items_pending", 0) or 0),
"running": int(
active_job.get("display_active_threads", active_job.get("display_items_running", active_job.get("items_running", 0)))
or 0
),
"completed": int(active_job.get("items_completed", 0) or 0),
"failed": int(active_job.get("items_failed", 0) or 0),
"blacklisted": int(active_job.get("items_blacklisted", 0) or 0),
"registerable": registerable_count,
}
progress_percent = float(active_job.get("progress_percent", 0) or 0)
local_node_bucket = {}
for item in list((active_job or {}).get("node_stats") or []):
if str(item.get("node_code") or "").strip() == str(settings.node_code or "").strip():
local_node_bucket = item
break
local_runtime_load = int(
local_node_bucket.get("active_threads")
or local_node_bucket.get("items_running")
or 0
)
local_runtime_max_threads = int(local_node_bucket.get("max_threads", 0) or 0)
if active_thread_snapshot["active"] <= 0 and local_runtime_load > 0:
active_thread_snapshot["active"] = local_runtime_load
if active_thread_snapshot["max"] <= 0:
active_thread_snapshot["max"] = local_runtime_max_threads or effective_thread_count
if settings.node_region == "overseas" and settings.node_role == "control" and active_job:
distributed_node_stats = list(active_job.get("distributed_node_stats") or active_job.get("node_stats") or [])
aggregated_active_threads = 0
aggregated_max_threads = 0
for item in distributed_node_stats:
node_code = str(item.get("node_code") or "").strip()
if not node_code or node_code == "unassigned":
continue
aggregated_active_threads += int(
item.get("active_threads")
or item.get("items_running")
or 0
)
aggregated_max_threads += int(item.get("max_threads", 0) or 0)
if aggregated_active_threads > 0:
active_thread_snapshot["active"] = aggregated_active_threads
if aggregated_max_threads > 0:
active_thread_snapshot["max"] = aggregated_max_threads
runtime_snapshot = {
**runtime,
"detecting": inferred_detecting,
"proxy_runtime_state": proxy_runtime["state"],
"proxy_runtime_label": proxy_runtime["label"],
"proxy_runtime_detail": proxy_runtime["detail"],
"proxy_direct_fallback_active": proxy_runtime["direct_fallback_active"],
"proxy_runtime_reason": proxy_runtime["reason"],
"proxy_supplier_empty": proxy_runtime["supplier_empty"],
}
runs = sync_detect_runs(runtime_snapshot, progress, settings_summary, active_job=active_job)
worker_log_sync_enabled = bool(runtime_settings.get("worker_log_sync_enabled", False))
worker_log_sync_mode = str(runtime_settings.get("worker_log_sync_mode", "key") or "key")
remote_log_snapshot = _resolve_remote_log_snapshot(
active_job,
runs,
enabled=worker_log_sync_enabled,
mode=worker_log_sync_mode,
limit=240,
)
remote_log_lines = list(remote_log_snapshot.get("lines") or [])
dependency_alerts = _extract_dependency_alerts(recent_lines)
append_detect_result_projection_if_changed(
detect={
"active_job": active_job,
"progress": progress,
"phase_label": runtime_state.get("phase", ""),
"phase_detail": runtime_state.get("detail", ""),
}
)
return {
"worker_online": worker_online,
"worker_mode": runtime.get("mode", "windows-local"),
"worker_service_name": runtime_settings.get("worker_service_name", ""),
"api_service_name": runtime_settings.get("api_service_name", ""),
"worker_process_count": runtime.get("process_count", 0),
"worker_latest_start_time": runtime.get("latest_start_time", ""),
"worker_runtime_message": runtime.get("message", ""),
"runtime_state": runtime_state,
"phase_label": runtime_state.get("phase", ""),
"phase_detail": runtime_state.get("detail", ""),
"detecting": inferred_detecting,
"thread_count": effective_thread_count,
"thread_count_default": int(thread_count_resolution["default_thread_count"]),
"thread_count_source": str(thread_count_resolution["source"]),
"thread_count_override": thread_count_resolution["override_thread_count"],
"thread_count_node_code": str(thread_count_resolution["node_code"]),
"active_thread_count": active_thread_snapshot["active"],
"max_thread_count": active_thread_snapshot["max"] or effective_thread_count,
"proxy_enable": settings_payload["proxy_config"].get("proxy_enable", False),
"allow_direct": settings_payload["proxy_config"].get("allow_direct", False),
"proxy_pool_count": len(settings_payload["proxy_config"].get("proxy_urls", [])),
"available_proxy_count": available_proxy_count,
"proxy_runtime_state": proxy_runtime["state"],
"proxy_runtime_label": proxy_runtime["label"],
"proxy_runtime_detail": proxy_runtime["detail"],
"proxy_direct_fallback_active": proxy_runtime["direct_fallback_active"],
"proxy_runtime_reason": proxy_runtime["reason"],
"proxy_supplier_empty": proxy_runtime["supplier_empty"],
"proxy_last_refresh_status": proxy_runtime["last_refresh_status"],
"proxy_last_refresh_time": proxy_runtime["last_refresh_time"],
"proxy_last_refresh_source_count": proxy_runtime["source_count"],
"proxy_last_refresh_total_items": proxy_runtime["raw_items"],
"proxy_last_validated_count": proxy_runtime["validated_count"],
"proxy_last_available_count": proxy_runtime["available_count"],
"proxy_source_stats": proxy_runtime["source_stats"],
"dependency_alerts": dependency_alerts,
"last_worker_log_time": last_log_time,
"progress": progress,
"progress_percent": progress_percent,
"recent_event": runtime_state.get("detail") or _recent_event(recent_lines),
"recent_warning": recent_proxy_warning,
"aggregate_detect_view": bool(settings.node_region == "overseas" and settings.node_role == "control" and active_job),
"log_lines": recent_lines,
"remote_log_lines": remote_log_lines,
"remote_log_line_count": int(remote_log_snapshot.get("line_count", 0) or 0),
"remote_log_last_at": str(remote_log_snapshot.get("last_at") or ""),
"remote_log_last_line": str(remote_log_snapshot.get("last_line") or ""),
"remote_log_nodes": list(remote_log_snapshot.get("source_nodes") or []),
"remote_log_node_count": int(remote_log_snapshot.get("source_node_count", 0) or 0),
"remote_log_node_summaries": list(remote_log_snapshot.get("source_node_summaries") or []),
"runs": runs,
"active_job": active_job,
"worker_log_sync_enabled": worker_log_sync_enabled,
"worker_log_sync_mode": worker_log_sync_mode,
}