from __future__ import annotations from datetime import datetime from uuid import uuid4 from app.core.files import load_detect_records, save_detect_records, tail_lines _MAX_LOG_LINES = 240 _LOG_TAIL_LINES = 1200 _ACTIVE_STATUSES = {"starting", "running", "stopping"} _TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S") def _now() -> str: return datetime.now().isoformat(sep=" ", timespec="seconds") def _save(records: list[dict]) -> None: save_detect_records(records) def _load() -> list[dict]: return load_detect_records() def _capture_worker_logs(max_lines: int = _LOG_TAIL_LINES) -> list[str]: lines = tail_lines("detect_worker.log", max_lines=max_lines) return lines[-max_lines:] 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) -> datetime | None: if len(line) < 19: return None candidates = [line[:26], line[: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 return None def _filter_logs_since(lines: list[str], started_at: str | None) -> list[str]: if not started_at: return lines[-_MAX_LOG_LINES:] started_time = _parse_time(started_at) if not started_time: return lines[-_MAX_LOG_LINES:] filtered = [line for line in lines if (_extract_log_time(line) or started_time) >= started_time] if filtered: return filtered[-_MAX_LOG_LINES:] return lines[-_MAX_LOG_LINES:] def _merge_logs(existing: list[str] | None, current: list[str]) -> list[str]: merged = list(existing or []) for line in current: if line not in merged[-40:]: merged.append(line) continue if not merged or merged[-1] != line: merged.append(line) return merged[-_MAX_LOG_LINES:] def _find_active(records: list[dict]) -> dict | None: return next((item for item in records if item.get("status") in _ACTIVE_STATUSES), None) def _same_session(active: dict | None, runtime: dict) -> bool: if not active: return False runtime_started_at = str(runtime.get("latest_start_time", "") or "").strip() active_started_at = str(active.get("started_at", "") or "").strip() if not runtime_started_at or not active_started_at: return True runtime_started = _parse_time(runtime_started_at) active_started = _parse_time(active_started_at) if not runtime_started or not active_started: return True return abs((runtime_started - active_started).total_seconds()) < 3 def _latest_matching_log(log_lines: list[str], keywords: tuple[str, ...]) -> str: for line in reversed(log_lines or []): if any(keyword in line for keyword in keywords): return line return "" def _sync_phase_history(record: dict, phase_label: str, phase_detail: str) -> None: history = list(record.get("phase_history") or []) current = { "at": _now(), "label": phase_label or "-", "detail": phase_detail or "", } if history: latest = history[-1] if latest.get("label") == current["label"] and latest.get("detail") == current["detail"]: return history.append(current) record["phase_history"] = history[-20:] def _phase_from_runtime(status: str, runtime: dict, progress: dict, log_lines: list[str], active_job: dict | None = None) -> tuple[str, str]: active_job = active_job or {} if status == "starting": return "启动中", "正在拉起检测服务并等待 Worker 就绪" if status == "stopping": return "停止中", "已发送停止请求,等待 Worker 退出并归档日志" if status == "completed": terminal = int(active_job.get("items_terminal", 0) or 0) return "已完成", f"本轮检测已自然完成,本次累计处理 {terminal} 个任务项" if status == "partial_failed": failed = int(active_job.get("items_failed", 0) or 0) terminal = int(active_job.get("items_terminal", 0) or 0) return "部分失败", f"本轮检测已结束,其中失败 {failed} 个,累计处理 {terminal} 个任务项" if status == "failed": return "失败", runtime.get("message") or "Worker 异常退出,请检查日志" if status == "stopped": return "已停止", runtime.get("message") or "检测服务已停止" if runtime.get("running"): proxy_runtime_state = str(runtime.get("proxy_runtime_state", "") or "").strip() proxy_runtime_detail = str(runtime.get("proxy_runtime_detail", "") or "").strip() running = (progress or {}).get("running", 0) pending = (progress or {}).get("pending", 0) phase_log = _latest_matching_log( log_lines, ( "Connection refused", "Read timed out", "ConnectTimeout", "HTTPSConnectionPool", "WaybackDetector", "域名检测任务完成", "当前批次检测完成", "当前实际线程数量", "开始创建线程", "获取到", "开始检测,刷新代理池", "刷新代理池", "没有需要检测的域名", "开始执行域名检测任务", ), ) if proxy_runtime_state == "blocked_no_proxy": return "等待代理", proxy_runtime_detail or "代理池当前无可用代理,且未允许直连" if proxy_runtime_state == "degraded_direct": return "降级直连", proxy_runtime_detail or "代理池暂无可用代理,当前使用直连继续执行" if "外部依赖异常,步骤降级继续执行" in phase_log: return "外部站点异常", "外部依赖当前波动,系统已按降级策略继续执行并保留人工复核" if "WaybackDetector" in phase_log or "web.archive.org" in phase_log: return "外部站点异常", "时光机依赖当前访问异常,任务仍在继续,建议关注网络或代理策略" if any(keyword in phase_log for keyword in ("Connection refused", "Read timed out", "ConnectTimeout", "HTTPSConnectionPool")): return "网络波动", phase_log if "域名检测任务完成" in phase_log: return "完成归档", "本轮检测已处理完成,正在等待下一轮任务或归档最终日志" if "当前批次检测完成" in phase_log: return "批次完成", phase_log if "当前实际线程数量" in phase_log or running > 0: if running > 0: return "检测中", f"Worker 正在处理 {running} 个检测任务" return "检测中", phase_log if "开始创建线程" in phase_log: return "建线程中", phase_log if "获取到" in phase_log: return "取任务中", phase_log if "刷新代理池" in phase_log: return "刷新代理池", phase_log if "没有需要检测的域名" in phase_log: return "空闲等待", "Worker 在线,当前没有待检测域名" if "开始执行域名检测任务" in phase_log: return "准备检测", phase_log if running > 0: return "检测中", f"Worker 正在处理 {running} 个检测任务" if pending > 0: return "取任务中", f"Worker 在线,等待或领取待检测域名,当前剩余 {pending} 个" return "运行中", "Worker 在线,当前没有活跃检测任务" return "已停止", runtime.get("message") or "检测服务已停止" def _sync_record( record: dict, *, status: str, message: str, runtime: dict, progress: dict, settings_summary: dict, log_lines: list[str], active_job: dict | None = None, ) -> dict: record["status"] = status record["message"] = message record["runtime"] = runtime record["progress"] = progress record["settings_summary"] = settings_summary record["updated_at"] = _now() if not record.get("started_at"): record["started_at"] = runtime.get("latest_start_time") or record["updated_at"] session_logs = _merge_logs(record.get("logs"), _filter_logs_since(log_lines, record.get("started_at"))) record["logs"] = session_logs record["active_job"] = active_job or {} record["phase_label"], record["phase_detail"] = _phase_from_runtime(status, runtime, progress, session_logs, active_job) _sync_phase_history(record, record.get("phase_label", ""), record.get("phase_detail", "")) if status in {"stopped", "failed", "completed", "partial_failed"} and not record.get("completed_at"): record["completed_at"] = _now() if status in _ACTIVE_STATUSES: record["completed_at"] = "" return record def create_detect_run_snapshot(message: str, runtime: dict, progress: dict, settings_summary: dict) -> dict: records = _load() active = _find_active(records) current_logs = _capture_worker_logs() if active: if active.get("status") == "stopping" and runtime.get("running"): active["status"] = "running" _sync_record( active, status=active.get("status", "starting"), message=message, runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=current_logs, ) _save(records) return dict(active) initial_started_at = runtime.get("latest_start_time") or _now() record = { "run_id": uuid4().hex, "status": "starting", "message": message, "created_at": _now(), "updated_at": _now(), "started_at": initial_started_at, "completed_at": "", "runtime": runtime, "progress": progress, "settings_summary": settings_summary, "phase_label": "", "phase_detail": "", "phase_history": [], "logs": [], } _sync_record( record, status="starting", message=message, runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=current_logs, ) records.insert(0, record) _save(records) return dict(record) def finalize_detect_run(message: str, runtime: dict, progress: dict, settings_summary: dict, active_job: dict | None = None) -> dict | None: records = _load() target = _find_active(records) if not target: return None final_status = "stopped" if target.get("status") == "stopping" else "failed" _sync_record( target, status=final_status, message=message, runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=_capture_worker_logs(), active_job=active_job, ) _save(records) return dict(target) def sync_detect_runs(runtime: dict, progress: dict, settings_summary: dict, active_job: dict | None = None) -> list[dict]: records = _load() active = _find_active(records) current_logs = _capture_worker_logs() active_job = active_job or {} runtime_detecting = bool(runtime.get("detecting", False)) active_job_status = str(active_job.get("status", "") or "").strip() active_job_open = active_job_status in {"pending", "running"} execution_active = runtime_detecting or active_job_open or int((progress or {}).get("running", 0) or 0) > 0 if runtime.get("running") and execution_active: if active and not _same_session(active, runtime): _sync_record( active, status="stopped", message="检测服务已重启,上一轮会话已归档", runtime=active.get("runtime") or runtime, progress=active.get("progress") or progress, settings_summary=active.get("settings_summary") or settings_summary, log_lines=current_logs, active_job=active.get("active_job") or active_job, ) active = None if active: next_status = "running" if active.get("status") != "stopping" else "stopping" _sync_record( active, status=next_status, message=runtime.get("message") or active.get("message") or "检测服务运行中", runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=current_logs, active_job=active_job, ) else: started_at = runtime.get("latest_start_time") or _now() record = { "run_id": uuid4().hex, "status": "running", "message": runtime.get("message") or "检测服务运行中", "created_at": _now(), "updated_at": _now(), "started_at": started_at, "completed_at": "", "runtime": runtime, "progress": progress, "settings_summary": settings_summary, "phase_label": "", "phase_detail": "", "phase_history": [], "logs": [], "active_job": active_job, } _sync_record( record, status="running", message=record["message"], runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=current_logs, active_job=active_job, ) records.insert(0, record) elif active: if runtime.get("running") and not execution_active: if active.get("status") == "stopping": final_status = "stopped" final_message = runtime.get("message") or "检测任务已停止,Worker 保持待命" elif active_job_status == "partial_failed": final_status = "partial_failed" final_message = "检测任务已结束,存在部分失败项" elif active_job_status == "failed": final_status = "failed" final_message = "检测任务已结束,任务结果为失败" else: final_status = "completed" final_message = "检测任务已自然完成,Worker 保持待命" else: final_status = "stopped" if active.get("status") == "stopping" else "failed" final_message = runtime.get("message") or ("检测服务已停止" if final_status == "stopped" else "检测服务异常退出") _sync_record( active, status=final_status, message=final_message, runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=current_logs, active_job=active_job, ) if records: records[0]["logs"] = _merge_logs( records[0].get("logs"), _filter_logs_since(current_logs, records[0].get("started_at")), ) _save(records) return records def mark_detect_run_stopping(message: str, runtime: dict, progress: dict, settings_summary: dict) -> dict | None: records = _load() target = _find_active(records) if not target: return None _sync_record( target, status="stopping", message=message, runtime=runtime, progress=progress, settings_summary=settings_summary, log_lines=_capture_worker_logs(), active_job=target.get("active_job") or {}, ) _save(records) return dict(target)