rotate_log.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """Rotate log parsing helpers for zhuce6."""
  2. from __future__ import annotations
  3. from collections import deque
  4. from datetime import datetime
  5. import os
  6. from pathlib import Path
  7. import re
  8. import sys
  9. from core.paths import DEFAULT_DASHBOARD_LOG_FILE
  10. ROTATE_SUMMARY_PATTERN = re.compile(
  11. r"^\[(?P<time>[0-9:]+)\] \[rotate\] summary \| 主池: (?P<main_before>\d+) → (?P<main_after>\d+) "
  12. r"\| 401删除: (?P<deleted_401>\d+)"
  13. r"(?: \| quota探测: (?P<quota_probed>\d+) \| probe401: (?P<quota_probe_401>\d+) "
  14. r"\| probe429: (?P<quota_probe_429>\d+) \| probe跳过: (?P<quota_probe_skipped>\d+))?"
  15. r"(?: \| 429删除: (?P<deleted_429>\d+))?$"
  16. )
  17. def _dashboard_log_path():
  18. main_module = sys.modules.get("main")
  19. return getattr(main_module, "DEFAULT_DASHBOARD_LOG_FILE", DEFAULT_DASHBOARD_LOG_FILE)
  20. def _stream_log_path(fd: int) -> Path | None:
  21. if os.name == "nt":
  22. return None
  23. try:
  24. target = os.readlink(f"/proc/self/fd/{fd}")
  25. except OSError:
  26. return None
  27. path = Path(target)
  28. try:
  29. if path.is_file():
  30. return path.resolve()
  31. except OSError:
  32. return None
  33. return None
  34. def _candidate_rotate_log_paths() -> list[Path]:
  35. candidates: list[Path] = []
  36. for fd in (1, 2):
  37. path = _stream_log_path(fd)
  38. if path is not None:
  39. candidates.append(path)
  40. dashboard_path = Path(_dashboard_log_path())
  41. candidates.append(dashboard_path)
  42. unique: list[Path] = []
  43. seen: set[str] = set()
  44. for path in candidates:
  45. normalized = str(path)
  46. if normalized in seen:
  47. continue
  48. seen.add(normalized)
  49. unique.append(path)
  50. return unique
  51. def _parse_rotate_summary_line(line: str) -> dict[str, object] | None:
  52. match = ROTATE_SUMMARY_PATTERN.match(str(line or "").strip())
  53. if not match:
  54. return None
  55. payload: dict[str, object] = {"time": match.group("time"), "raw": str(line or "").strip()}
  56. for key in (
  57. "main_before",
  58. "main_after",
  59. "deleted_401",
  60. "quota_probed",
  61. "quota_probe_401",
  62. "quota_probe_429",
  63. "quota_probe_skipped",
  64. "deleted_429",
  65. ):
  66. raw_value = match.group(key)
  67. payload[key] = int(raw_value) if raw_value is not None else 0
  68. return payload
  69. def _empty_rotate_current_summary() -> dict[str, object]:
  70. return {
  71. "time": None,
  72. "raw": None,
  73. "main_before": None,
  74. "main_after": None,
  75. "deleted_401": 0,
  76. "quota_probed": 0,
  77. "quota_probe_401": 0,
  78. "quota_probe_429": 0,
  79. "quota_probe_skipped": 0,
  80. "deleted_429": 0,
  81. "partial": True,
  82. "event_count": 0,
  83. }
  84. def _update_rotate_current_summary(payload: dict[str, object], line: str) -> None:
  85. stripped = str(line or "").strip()
  86. if not stripped:
  87. return
  88. payload["raw"] = stripped
  89. payload["event_count"] = int(payload.get("event_count") or 0) + 1
  90. prefix_match = re.match(r"^\[(?P<time>[0-9:]+)\]", stripped)
  91. if prefix_match:
  92. payload["time"] = prefix_match.group("time")
  93. if " quota probe → " in stripped:
  94. payload["quota_probed"] = int(payload.get("quota_probed") or 0) + 1
  95. if "quota probe → 429" in stripped:
  96. payload["quota_probe_429"] = int(payload.get("quota_probe_429") or 0) + 1
  97. elif "quota probe → 401 invalidated" in stripped or "quota probe → deactivated" in stripped:
  98. payload["quota_probe_401"] = int(payload.get("quota_probe_401") or 0) + 1
  99. return
  100. if " 401删除" in stripped:
  101. payload["deleted_401"] = int(payload.get("deleted_401") or 0) + 1
  102. elif " 429删除" in stripped:
  103. payload["deleted_429"] = int(payload.get("deleted_429") or 0) + 1
  104. if payload.get("main_before") is not None:
  105. payload["main_after"] = int(payload.get("main_before") or 0) - int(payload.get("deleted_401") or 0) - int(payload.get("deleted_429") or 0)
  106. def _rotate_log_tail(limit: int = 120, event_limit: int = 16) -> dict[str, object]:
  107. log_path: Path | None = None
  108. for candidate in _candidate_rotate_log_paths():
  109. if not candidate.exists():
  110. continue
  111. try:
  112. with candidate.open("r", encoding="utf-8", errors="replace") as fh:
  113. if any("[rotate]" in raw_line for raw_line in fh):
  114. log_path = candidate
  115. break
  116. except OSError:
  117. continue
  118. if log_path is None:
  119. log_path = Path(_dashboard_log_path())
  120. if not log_path.exists():
  121. return {
  122. "available": False,
  123. "path": str(log_path),
  124. "updated_at": None,
  125. "updated_at_iso": None,
  126. "error": "dashboard log file not found",
  127. "lines": [],
  128. "recent_events": [],
  129. "latest_summary": None,
  130. "current_summary": None,
  131. }
  132. try:
  133. latest_summary = None
  134. current_summary = _empty_rotate_current_summary()
  135. current_events_seen = False
  136. with log_path.open("r", encoding="utf-8", errors="replace") as fh:
  137. rotate_lines: deque[str] = deque(maxlen=limit)
  138. for raw_line in fh:
  139. if "[rotate]" not in raw_line:
  140. continue
  141. line = raw_line.rstrip("\r\n")
  142. rotate_lines.append(line)
  143. parsed_summary = _parse_rotate_summary_line(line)
  144. if parsed_summary is not None:
  145. latest_summary = parsed_summary
  146. current_summary = _empty_rotate_current_summary()
  147. current_events_seen = False
  148. continue
  149. _update_rotate_current_summary(current_summary, line)
  150. current_events_seen = True
  151. stat = log_path.stat()
  152. except OSError as exc:
  153. return {
  154. "available": False,
  155. "path": str(log_path),
  156. "updated_at": None,
  157. "updated_at_iso": None,
  158. "error": str(exc),
  159. "lines": [],
  160. "recent_events": [],
  161. "latest_summary": None,
  162. "current_summary": None,
  163. }
  164. lines = list(rotate_lines)
  165. recent_events = [line for line in lines if "summary" not in line][-max(1, event_limit):]
  166. return {
  167. "available": True,
  168. "path": str(log_path),
  169. "updated_at": stat.st_mtime,
  170. "updated_at_iso": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
  171. "error": None,
  172. "lines": lines,
  173. "recent_events": recent_events,
  174. "latest_summary": latest_summary,
  175. "current_summary": current_summary if current_events_seen else None,
  176. }
  177. parse_rotate_summary_line = _parse_rotate_summary_line
  178. empty_rotate_current_summary = _empty_rotate_current_summary
  179. update_rotate_current_summary = _update_rotate_current_summary
  180. rotate_log_tail = _rotate_log_tail