仰望星辰工作室

better-staridc-MNBT

better-staridc-MNBT/ bt_plugins/mnbt_connector/stats_api.py 46.0 KB · 1,089 行 原始文件
Z zfhsh first commit 1 天前
1#!/usr/bin/python
2# coding: utf-8
3
4from datetime import datetime, timedelta
5import os
6import re
7import sqlite3
8import subprocess
9import sys
10import time
11
12BASE_DIR = os.path.dirname(os.path.abspath(__file__))
13LOG_DIR = os.path.join(BASE_DIR, "../wwwlogs") if os.path.isdir(os.path.join(BASE_DIR, "../wwwlogs")) else "/www/wwwlogs"
14
15SPIDER_AGENTS = {
16 "Baiduspider", "Googlebot", "bingbot", "YandexBot", "Sogou",
17 "360Spider", "Bytespider", "Amazonbot", "AhrefsBot", "SemrushBot",
18 "DotBot", "BLEXBot", "Exabot", "MJ12bot", "SeznamBot",
19}
20
21MONTH_MAP = {
22 "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
23 "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12,
24}
25
26# 宝塔 Nginx 默认日志格式(与 stats_collector.py 保持一致)
27_NGINX_RE = re.compile(
28 r'^(\S+)' # 1: IP
29 r'\s+\S+\s+\S+' # ident user (通常为 - -)
30 r'\s+\[([^\]]+)\]' # 2: time_local
31 r'\s+"([^"]*)"' # 3: request (method uri protocol)
32 r'\s+(\d{3})' # 4: status
33 r'\s+(\d+)' # 5: body_bytes_sent
34 r'(?:\s+"([^"]*)")?' # 6: referer (可选)
35 r'(?:\s+"([^"]*)")?' # 7: user_agent (可选)
36)
37
38STATS_LABELS = [
39 ("pv", "浏览量(PV)"),
40 ("uv", "访客数(UV)"),
41 ("ip_count", "IP数"),
42 ("total_bytes", "总流量"),
43 ("requests", "请求数"),
44 ("error_count", "错误请求"),
45 ("spider_count", "蜘蛛请求"),
46 ("qps", "平均QPS"),
47]
48
49def format_bytes(n):
50 for unit in ("B", "KB", "MB", "GB", "TB"):
51 if abs(n) < 1024.0:
52 return f"{n:.1f} {unit}" if unit != "B" else f"{n:.0f} {unit}"
53 n /= 1024.0
54 return f"{n:.1f} PB"
55
56
57def format_number(n):
58 return f"{n:,}"
59
60
61class StatsAPIMixin:
62 STATS_DB = os.path.join(BASE_DIR, "maxiaole.db")
63 _site_cache = None
64 _site_cache_ts = 0
65
66 def _ensure_stats_db(self):
67 if os.path.exists(self.STATS_DB):
68 return
69 try:
70 subprocess.run(
71 [sys.executable, os.path.join(BASE_DIR, "worker.py"), "--once"],
72 capture_output=True, timeout=10
73 )
74 except Exception as exc:
75 print(f"[mnbt] DB 初始化异常: {exc}", file=sys.stderr)
76
77 def _stats_query(self, sql, params=None):
78 if not os.path.exists(self.STATS_DB):
79 self._ensure_stats_db()
80 if not os.path.exists(self.STATS_DB):
81 return []
82 conn = sqlite3.connect(self.STATS_DB)
83 conn.execute("PRAGMA journal_mode=WAL")
84 cursor = conn.cursor()
85 try:
86 if params:
87 cursor.execute(sql, params)
88 else:
89 cursor.execute(sql)
90 rows = cursor.fetchall()
91 return rows
92 except sqlite3.Error as exc:
93 print(f"[mnbt] SQLite 查询失败: {exc}; sql={sql}", file=sys.stderr)
94 return []
95 finally:
96 conn.close()
97
98 def _stats_query_one(self, sql, params=None):
99 rows = self._stats_query(sql, params)
100 return rows[0] if rows else None
101
102 @staticmethod
103 def _date_range(range_key):
104 today = datetime.now().strftime("%Y-%m-%d")
105 if range_key == "today":
106 return today, today
107 if range_key == "yesterday":
108 yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
109 return yesterday, yesterday
110 if range_key == "7d":
111 start = (datetime.now() - timedelta(days=6)).strftime("%Y-%m-%d")
112 return start, today
113 if range_key == "30d":
114 start = (datetime.now() - timedelta(days=29)).strftime("%Y-%m-%d")
115 return start, today
116 return today, today
117
118 def _paginate(self, count_sql, count_params, data_sql, data_params, page, page_size):
119 count_row = self._stats_query_one(count_sql, count_params)
120 total = count_row[0] if count_row else 0
121 page = max(1, int(page))
122 page_size = min(max(1, int(page_size)), 200)
123 offset = (page - 1) * page_size
124 safe_params = tuple(data_params or ()) + (page_size, offset)
125 rows = self._stats_query(data_sql + " LIMIT ? OFFSET ?", safe_params)
126 return rows, total, page, page_size
127
128 def _paginate_response(self, rows, total, page, page_size, field_map, source="sqlite", fallback=False, msg=""):
129 response = {
130 "status": True,
131 "data": [dict(zip(field_map, r)) for r in rows],
132 "total": total, "page": page, "page_size": page_size,
133 "source": source, "fallback": bool(fallback),
134 }
135 if msg:
136 response["msg"] = msg
137 return response
138
139 def _format_stat_labels(self, data, labels):
140 result = []
141 for key, name in labels:
142 result.append({"key": key, "name": name, "value": data.get(key, 0)})
143 return result
144
145 def _calc_qps(self, requests, range_key):
146 if requests <= 0:
147 return 0
148 if range_key in ("today",):
149 now = time.localtime()
150 elapsed = now.tm_hour * 3600 + now.tm_min * 60 + now.tm_sec
151 if elapsed < 1:
152 return 0
153 return round(requests / elapsed, 2)
154 return round(requests / 86400, 2)
155
156 # ---------- 站点列表(合并面板站点 + 统计数据,单次 GROUP BY 查询)----------
157
158 def get_site_list(self, args):
159 date = time.strftime("%Y-%m-%d")
160 now_ts = time.time()
161 if self._site_cache and (now_ts - self._site_cache_ts) < 30:
162 return self._site_cache
163 rows = self._stats_query(
164 "SELECT site_name, COALESCE(SUM(pv),0), COALESCE(SUM(uv),0), COALESCE(SUM(total_bytes),0) "
165 "FROM site_hourly_stats GROUP BY site_name ORDER BY site_name"
166 )
167 sites = [{"site_name": r[0], "pv": r[1], "uv": r[2], "total_bytes": r[3]} for r in rows]
168 result = {"status": True, "data": sites}
169 self._site_cache = result
170 self._site_cache_ts = now_ts
171 return result
172
173 # ---------- 统计标签(前后端字段名解耦) ----------
174
175 def get_stat_labels(self, args):
176 return {"status": True, "data": STATS_LABELS}
177
178 # ---------- 站点概览 ----------
179
180 def _query_date_metrics(self, site, date_str):
181 """查询某天的 PV/UV/流量/IP/错误/蜘蛛,返回 dict"""
182 row = self._stats_query_one(
183 "SELECT COALESCE(SUM(pv),0), COALESCE(SUM(uv),0), COALESCE(SUM(total_bytes),0) "
184 "FROM site_hourly_stats WHERE site_name=? AND hour>=? AND hour<=?",
185 (site, date_str + " 00", date_str + " 23")
186 )
187 pv, uv, total_bytes = row or (0, 0, 0)
188
189 ip_row = self._stats_query_one(
190 "SELECT COUNT(DISTINCT ip) FROM site_ip_stats WHERE site_name=? AND date>=? AND date<=?",
191 (site, date_str, date_str)
192 )
193 ip_count = ip_row[0] if ip_row else 0
194
195 error_row = self._stats_query_one(
196 "SELECT COALESCE(SUM(request_count),0) FROM site_status_stats "
197 "WHERE site_name=? AND date>=? AND date<=? AND status_code>=400",
198 (site, date_str, date_str)
199 )
200 error_count = error_row[0] if error_row else 0
201
202 spider_row = self._stats_query_one(
203 "SELECT COALESCE(SUM(request_count),0) FROM site_spider_stats "
204 "WHERE site_name=? AND date>=? AND date<=?",
205 (site, date_str, date_str)
206 )
207 spider_count = spider_row[0] if spider_row else 0
208
209 return {
210 "pv": pv, "uv": uv, "total_bytes": total_bytes,
211 "ip_count": ip_count, "error_count": error_count,
212 "spider_count": spider_count,
213 }
214
215 def _query_metrics_range(self, site, start_date, end_date):
216 """范围聚合:从各分表独立查询"""
217 pv, uv, total_bytes = self._stats_query_one(
218 "SELECT COALESCE(SUM(pv),0), COALESCE(SUM(uv),0), COALESCE(SUM(total_bytes),0) "
219 "FROM site_hourly_stats WHERE site_name=? AND hour>=? AND hour<=?",
220 (site, start_date + " 00", end_date + " 23")
221 ) or (0, 0, 0)
222
223 ip_row = self._stats_query_one(
224 "SELECT COUNT(DISTINCT ip) FROM site_ip_stats "
225 "WHERE site_name=? AND date>=? AND date<=?",
226 (site, start_date, end_date)
227 )
228 ip_count = ip_row[0] if ip_row else 0
229
230 error_row = self._stats_query_one(
231 "SELECT COALESCE(SUM(request_count),0) FROM site_status_stats "
232 "WHERE site_name=? AND date>=? AND date<=? AND status_code>=400",
233 (site, start_date, end_date)
234 )
235 error_count = error_row[0] if error_row else 0
236
237 spider_row = self._stats_query_one(
238 "SELECT COALESCE(SUM(request_count),0) FROM site_spider_stats "
239 "WHERE site_name=? AND date>=? AND date<=?",
240 (site, start_date, end_date)
241 )
242 spider_count = spider_row[0] if spider_row else 0
243
244 return {
245 "pv": int(pv), "uv": int(uv), "total_bytes": int(total_bytes),
246 "ip_count": int(ip_count), "error_count": int(error_count),
247 "spider_count": int(spider_count),
248 }
249
250 def get_site_overview(self, args):
251 site = args.get("site", "")
252 if not site:
253 return {"status": False, "msg": "缺少站点名称"}
254 range_key = args.get("range", "today")
255 start_date, end_date = self._date_range(range_key)
256
257 today = time.strftime("%Y-%m-%d")
258 yesterday = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400))
259 day_before = time.strftime("%Y-%m-%d", time.localtime(time.time() - 172800))
260
261 # 按范围聚合:单次查询而非逐日循环
262 if start_date == today:
263 curr = self._query_date_metrics(site, today)
264 if not any((curr.get("pv", 0), curr.get("ip_count", 0), curr.get("total_bytes", 0))):
265 recent = self._recent_metrics(site, today, today)
266 if recent.get("pv", 0) > 0:
267 curr = recent
268 prev = self._query_date_metrics(site, yesterday)
269 prev2 = self._query_date_metrics(site, day_before)
270 else:
271 span_days = (time.mktime(time.strptime(end_date, "%Y-%m-%d")) - time.mktime(time.strptime(start_date, "%Y-%m-%d"))) // 86400 + 1
272 prev_end = time.strftime("%Y-%m-%d", time.localtime(time.mktime(time.strptime(start_date, "%Y-%m-%d")) - 86400))
273 prev_start = time.strftime("%Y-%m-%d", time.localtime(time.mktime(time.strptime(start_date, "%Y-%m-%d")) - span_days * 86400))
274 curr = self._query_metrics_range(site, start_date, end_date)
275 if not any((curr.get("pv", 0), curr.get("ip_count", 0), curr.get("total_bytes", 0))):
276 recent = self._recent_metrics(site, start_date, end_date)
277 if recent.get("pv", 0) > 0:
278 curr = recent
279 prev = self._query_metrics_range(site, prev_start, prev_end)
280 prev2 = {}
281
282 qps = self._calc_qps(curr.get("pv", 0), range_key)
283
284 data = {
285 "pv": curr.get("pv", 0), "uv": curr.get("uv", 0),
286 "ip_count": curr.get("ip_count", 0), "total_bytes": curr.get("total_bytes", 0),
287 "requests": curr.get("pv", 0), "error_count": curr.get("error_count", 0),
288 "spider_count": curr.get("spider_count", 0), "qps": qps,
289 }
290 comparison = {
291 "prev": {
292 "pv": prev.get("pv", 0), "uv": prev.get("uv", 0),
293 "ip_count": prev.get("ip_count", 0), "total_bytes": prev.get("total_bytes", 0),
294 "error_count": prev.get("error_count", 0), "spider_count": prev.get("spider_count", 0),
295 },
296 "prev2": {
297 "pv": prev2.get("pv", 0), "uv": prev2.get("uv", 0),
298 "ip_count": prev2.get("ip_count", 0), "total_bytes": prev2.get("total_bytes", 0),
299 "error_count": prev2.get("error_count", 0), "spider_count": prev2.get("spider_count", 0),
300 },
301 }
302 has_cov, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
303 site, start_date, end_date, "site_hourly_stats")
304 used_fallback = not any((curr.get("pv", 0), curr.get("ip_count", 0), curr.get("total_bytes", 0)))
305 resp = {
306 "status": True,
307 "data": data,
308 "comparison": comparison,
309 "top": [{
310 "name": site,
311 "ip": curr.get("ip_count", 0), "uv": curr.get("uv", 0),
312 "pv": curr.get("pv", 0), "request": curr.get("pv", 0),
313 "traffic": curr.get("total_bytes", 0),
314 }],
315 "list": self._build_seven_day_list(site),
316 "labels": self._format_stat_labels(data, STATS_LABELS),
317 "source": "recent_log" if used_fallback else "sqlite",
318 "fallback": used_fallback,
319 "coverage": {
320 "has_data": has_cov,
321 "min_date": cov_min,
322 "max_date": cov_max,
323 "data_days": cov_days,
324 "rate": round(cov_rate, 2),
325 },
326 }
327 if not has_cov and used_fallback:
328 resp["msg"] = "聚合数据为空,已从最近访问日志临时统计"
329 elif cov_rate < 1.0:
330 resp["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
331 return resp
332
333 def _build_seven_day_list(self, site):
334 """构建近7天趋势数据"""
335 seven_dates = [time.strftime("%Y-%m-%d", time.localtime(time.time() - i * 86400)) for i in range(6, -1, -1)]
336 result = []
337 for d in seven_dates:
338 m = self._query_date_metrics(site, d)
339 result.append({
340 "date": d, "uv": m["uv"], "pv": m["pv"],
341 "ip": m["ip_count"], "request": m["pv"],
342 "traffic": m["total_bytes"],
343 })
344 return result
345
346 # ---------- 趋势数据 ----------
347
348 def get_site_trend(self, args):
349 site = args.get("site", "")
350 if not site:
351 return {"status": False, "msg": "缺少站点名称"}
352 range_key = args.get("range", "today")
353 start_date, end_date = self._date_range(range_key)
354 page = int(args.get("page", 1))
355 page_size = int(args.get("page_size", 10))
356
357 # 趋势数据:按小时聚合 pv/uv/total_bytes + 错误计数
358 count_sql = "SELECT COUNT(*) FROM site_hourly_stats WHERE site_name=? AND hour>=? AND hour<=?"
359 count_params = (site, start_date + " 00", end_date + " 23")
360 data_sql = (
361 "SELECT t.hour, t.pv, t.uv, t.total_bytes, "
362 "COALESCE(e.err_count,0) "
363 "FROM site_hourly_stats t "
364 "LEFT JOIN ("
365 " SELECT date, COALESCE(SUM(request_count),0) AS err_count "
366 " FROM site_status_stats WHERE site_name=? AND status_code>=400 "
367 " AND date>=? AND date<=? GROUP BY date"
368 ") e ON e.date = SUBSTR(t.hour,1,10) "
369 "WHERE t.site_name=? AND t.hour>=? AND t.hour<=? "
370 "ORDER BY t.hour ASC"
371 )
372 data_params = (site, start_date, end_date, site, start_date + " 00", end_date + " 23")
373 rows, total, page, page_size = self._paginate(
374 count_sql, count_params, data_sql, data_params, page, page_size
375 )
376 points = []
377 for hour, pv, uv, total_bytes, err_count in rows:
378 ip_row = self._stats_query_one(
379 "SELECT COUNT(DISTINCT ip) FROM site_ip_stats WHERE site_name=? AND date=?",
380 (site, hour[:10])
381 )
382 ip_count = ip_row[0] if ip_row else 0
383 points.append({
384 "time": hour,
385 "pv": pv, "uv": uv, "ip": ip_count,
386 "total_bytes": total_bytes, "requests": pv,
387 "traffic": total_bytes,
388 "errors": err_count,
389 })
390 has_cov, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
391 site, start_date, end_date, "site_hourly_stats")
392 resp = {
393 "status": True,
394 "data": points,
395 "total": total,
396 "page": page,
397 "page_size": page_size,
398 "source": "sqlite",
399 "fallback": False,
400 "coverage": {
401 "has_data": has_cov,
402 "min_date": cov_min,
403 "max_date": cov_max,
404 "data_days": cov_days,
405 "rate": round(cov_rate, 2),
406 },
407 }
408 if cov_rate < 1.0:
409 resp["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
410 return resp
411
412 @staticmethod
413 def _detect_spider(ua):
414 if not ua:
415 return None
416 ua_lower = ua.lower()
417 for name in SPIDER_AGENTS:
418 if name.lower() in ua_lower:
419 return name
420 return None
421
422 @staticmethod
423 def _detect_client(ua):
424 if not ua:
425 return "pc", "unknown"
426 ua_lower = ua.lower()
427 client_type = "mobile" if any(k in ua_lower for k in ("mobile", "android", "iphone", "ipad", "ipod")) else "pc"
428 if "edg" in ua_lower or "edge" in ua_lower:
429 client_name = "Edge"
430 elif "chrome" in ua_lower and "chromium" not in ua_lower:
431 client_name = "Chrome"
432 elif "firefox" in ua_lower:
433 client_name = "Firefox"
434 elif "safari" in ua_lower and "chrome" not in ua_lower:
435 client_name = "Safari"
436 elif "msie" in ua_lower or "trident" in ua_lower:
437 client_name = "IE"
438 elif "go-http-client" in ua_lower:
439 client_name = "Go-http-client"
440 else:
441 client_name = "unknown"
442 return client_type, client_name
443
444 @staticmethod
445 def _nginx_date(time_local):
446 try:
447 m = re.search(r'(\d+)/(\w+)/(\d+):(\d+):(\d+):(\d+)', time_local or "")
448 if not m:
449 return ""
450 day, mon, year = m.group(1), m.group(2), m.group(3)
451 return f"{year}-{MONTH_MAP.get(mon, 1):02d}-{int(day):02d}"
452 except Exception:
453 return ""
454
455 def _recent_parsed_logs(self, site, max_scan=2000):
456 if "\0" in site or ".." in site or "/" in site or "\\" in site:
457 return []
458 prefix = f"{site}.log"
459 base_path = os.path.normpath(os.path.join(LOG_DIR, prefix))
460 if not os.path.exists(base_path):
461 return []
462 all_files = []
463 try:
464 for entry in os.listdir(LOG_DIR):
465 if entry == prefix or entry.startswith(prefix + ".") or entry.startswith(prefix + "-"):
466 fpath = os.path.join(LOG_DIR, entry)
467 try:
468 all_files.append((fpath, os.path.getmtime(fpath)))
469 except OSError:
470 continue
471 except OSError:
472 all_files = [(base_path, 0)]
473 all_files.sort(key=lambda x: x[1], reverse=True)
474 collected = []
475 remaining = max_scan
476 for fpath, _ in all_files:
477 if remaining <= 0:
478 break
479 lines = self._read_reverse_lines(fpath, remaining)
480 collected.extend(reversed(lines))
481 remaining -= len(lines)
482 collected.reverse()
483 result = []
484 for line in collected:
485 parsed = self._parse_nginx_line(line)
486 if parsed:
487 parsed["date"] = self._nginx_date(parsed.get("time"))
488 result.append(parsed)
489 return result
490
491 @staticmethod
492 def _date_in_range(date_str, start_date, end_date):
493 return bool(date_str) and start_date <= date_str <= end_date
494
495 def _recent_logs_in_range(self, site, start_date, end_date, max_scan=200000):
496 return [
497 item for item in self._recent_parsed_logs(site, max_scan)
498 if self._date_in_range(item.get("date"), start_date, end_date)
499 ]
500
501 def _recent_metrics(self, site, start_date, end_date):
502 logs = self._recent_logs_in_range(site, start_date, end_date)
503 if not logs:
504 return {"pv": 0, "uv": 0, "total_bytes": 0, "ip_count": 0, "error_count": 0, "spider_count": 0}
505 ips = set()
506 spider_count = 0
507 error_count = 0
508 total_bytes = 0
509 for item in logs:
510 ip = item.get("ip") or ""
511 if ip:
512 ips.add(ip)
513 total_bytes += int(item.get("bytes") or 0)
514 if int(item.get("status") or 0) >= 400:
515 error_count += 1
516 if self._detect_spider(item.get("ua")):
517 spider_count += 1
518 return {
519 "pv": len(logs),
520 "uv": len(ips),
521 "total_bytes": total_bytes,
522 "ip_count": len(ips),
523 "error_count": error_count,
524 "spider_count": spider_count,
525 }
526
527 def _recent_trend_points(self, site, start_date, end_date):
528 buckets = {}
529 for item in self._recent_logs_in_range(site, start_date, end_date):
530 hour_key, _ = self._hour_from_parsed_log(item)
531 if not hour_key:
532 continue
533 if hour_key not in buckets:
534 buckets[hour_key] = {"pv": 0, "bytes": 0, "ips": set(), "errors": 0}
535 buckets[hour_key]["pv"] += 1
536 buckets[hour_key]["bytes"] += int(item.get("bytes") or 0)
537 if item.get("ip"):
538 buckets[hour_key]["ips"].add(item.get("ip"))
539 if int(item.get("status") or 0) >= 400:
540 buckets[hour_key]["errors"] += 1
541 points = []
542 for hour in sorted(buckets.keys()):
543 data = buckets[hour]
544 points.append({
545 "time": hour,
546 "pv": data["pv"],
547 "uv": len(data["ips"]),
548 "ip": len(data["ips"]),
549 "total_bytes": data["bytes"],
550 "requests": data["pv"],
551 "traffic": data["bytes"],
552 "errors": data["errors"],
553 })
554 return points
555
556 @staticmethod
557 def _hour_from_parsed_log(item):
558 time_local = item.get("time") or ""
559 try:
560 m = re.search(r'(\d+)/(\w+)/(\d+):(\d+):(\d+):(\d+)', time_local)
561 if not m:
562 return None, None
563 day, mon, year, hour = m.group(1), m.group(2), m.group(3), m.group(4)
564 mon_num = MONTH_MAP.get(mon)
565 if not mon_num:
566 return None, None
567 return f"{year}-{mon_num:02d}-{int(day):02d} {int(hour):02d}", f"{year}-{mon_num:02d}-{int(day):02d}"
568 except Exception:
569 return None, None
570
571 @staticmethod
572 def _empty_fallback_response(page, page_size, msg="聚合数据为空,最近日志中也没有当前时间范围的数据", sum_count=0):
573 return {
574 "status": True,
575 "data": [],
576 "total": 0,
577 "page": page,
578 "page_size": page_size,
579 "sum_count": sum_count,
580 "source": "recent_log",
581 "fallback": True,
582 "msg": msg,
583 }
584
585 @staticmethod
586 def _slice_rank_data(data, page, page_size):
587 start = (page - 1) * page_size
588 return data[start:start + page_size]
589
590 # ---------- 蜘蛛分析 ----------
591
592 def get_site_spider_analysis(self, args):
593 site = args.get("site", "")
594 if not site:
595 return {"status": False, "msg": "缺少站点名称"}
596 range_key = args.get("range", "today")
597 start_date, end_date = self._date_range(range_key)
598 page = int(args.get("page", 1))
599 page_size = int(args.get("page_size", 10))
600
601 rows, total, page, page_size = self._paginate(
602 "SELECT COUNT(DISTINCT spider_name) FROM site_spider_stats WHERE site_name=? AND date>=? AND date<=?",
603 (site, start_date, end_date),
604 "SELECT spider_name, COALESCE(SUM(request_count),0) FROM site_spider_stats "
605 "WHERE site_name=? AND date>=? AND date<=? GROUP BY spider_name ORDER BY 2 DESC",
606 (site, start_date, end_date),
607 page, page_size
608 )
609 response = self._paginate_response(rows, total, page, page_size,
610 ["spider", "count"])
611 sum_row = self._stats_query_one(
612 "SELECT COALESCE(SUM(request_count),0) FROM site_spider_stats "
613 "WHERE site_name=? AND date>=? AND date<=?",
614 (site, start_date, end_date)
615 )
616 response["sum_count"] = sum_row[0] if sum_row else 0
617 has_sql_data, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
618 site, start_date, end_date, "site_spider_stats")
619 response["coverage"] = {
620 "has_data": has_sql_data,
621 "min_date": cov_min,
622 "max_date": cov_max,
623 "data_days": cov_days,
624 "rate": round(cov_rate, 2),
625 }
626 use_sql = self._should_use_sql_data(site, start_date, end_date, range_key, "site_spider_stats")
627 if not use_sql:
628 counter = {}
629 parsed_logs = self._recent_parsed_logs(site, max_scan=self._fallback_max_scan(range_key))
630 for item in parsed_logs:
631 if not self._date_in_range(item.get("date"), start_date, end_date):
632 continue
633 spider = self._detect_spider(item.get("ua"))
634 if spider:
635 counter[spider] = counter.get(spider, 0) + 1
636 data = sorted(({"spider": k, "count": v} for k, v in counter.items()), key=lambda x: x["count"], reverse=True)
637 if not data:
638 return self._empty_fallback_response(page, page_size)
639 response.update({
640 "data": self._slice_rank_data(data, page, page_size),
641 "total": len(data),
642 "sum_count": sum(counter.values()),
643 "source": "recent_log",
644 "fallback": True,
645 "msg": "聚合数据为空,已从最近访问日志临时统计",
646 })
647 elif cov_rate < 1.0:
648 response["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
649 return response
650
651 # ---------- 客户端统计 ----------
652
653 def get_site_client_stats(self, args):
654 site = args.get("site", "")
655 if not site:
656 return {"status": False, "msg": "缺少站点名称"}
657 range_key = args.get("range", "today")
658 start_date, end_date = self._date_range(range_key)
659 page = int(args.get("page", 1))
660 page_size = int(args.get("page_size", 10))
661
662 rows, total, page, page_size = self._paginate(
663 "SELECT COUNT(DISTINCT client_type || '|' || client_name) FROM site_client_stats "
664 "WHERE site_name=? AND date>=? AND date<=?",
665 (site, start_date, end_date),
666 "SELECT client_type, client_name, COALESCE(SUM(request_count),0) FROM site_client_stats "
667 "WHERE site_name=? AND date>=? AND date<=? GROUP BY client_type, client_name ORDER BY client_type, 3 DESC",
668 (site, start_date, end_date),
669 page, page_size
670 )
671 result = []
672 for ctype, cname, cnt in rows:
673 result.append({"client": f"{cname}({ctype})", "count": cnt, "os": cname})
674 sum_row = self._stats_query_one(
675 "SELECT COALESCE(SUM(request_count),0) FROM site_client_stats "
676 "WHERE site_name=? AND date>=? AND date<=?",
677 (site, start_date, end_date)
678 )
679 sum_count = sum_row[0] if sum_row else 0
680 has_sql_data, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
681 site, start_date, end_date, "site_client_stats")
682 coverage_info = {
683 "has_data": has_sql_data,
684 "min_date": cov_min,
685 "max_date": cov_max,
686 "data_days": cov_days,
687 "rate": round(cov_rate, 2),
688 }
689 use_sql = self._should_use_sql_data(site, start_date, end_date, range_key, "site_client_stats")
690 if not use_sql:
691 counter = {}
692 parsed_logs = self._recent_parsed_logs(site, max_scan=self._fallback_max_scan(range_key))
693 for item in parsed_logs:
694 if not self._date_in_range(item.get("date"), start_date, end_date):
695 continue
696 ctype, cname = self._detect_client(item.get("ua"))
697 key = (ctype, cname)
698 counter[key] = counter.get(key, 0) + 1
699 data = [
700 {"client": f"{name}({ctype})", "count": count, "os": name, "client_type": ctype, "client_name": name}
701 for (ctype, name), count in counter.items()
702 ]
703 data.sort(key=lambda x: x["count"], reverse=True)
704 if not data:
705 return self._empty_fallback_response(page, page_size)
706 return {
707 "status": True,
708 "data": self._slice_rank_data(data, page, page_size),
709 "total": len(data),
710 "page": page,
711 "page_size": page_size,
712 "sum_count": sum(counter.values()),
713 "source": "recent_log",
714 "fallback": True,
715 "msg": "聚合数据为空,已从最近访问日志临时统计",
716 "coverage": coverage_info,
717 }
718 resp = {"status": True, "data": result, "total": total, "page": page, "page_size": page_size, "sum_count": sum_count, "source": "sqlite", "fallback": False, "coverage": coverage_info}
719 if cov_rate < 1.0:
720 resp["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
721 return resp
722
723 # ---------- 请求方式统计 ----------
724
725 def get_site_method_stats(self, args):
726 site = args.get("site", "")
727 if not site:
728 return {"status": False, "msg": "缺少站点名称"}
729 range_key = args.get("range", "today")
730 start_date, end_date = self._date_range(range_key)
731 page = int(args.get("page", 1))
732 page_size = int(args.get("page_size", 10))
733
734 rows, total, page, page_size = self._paginate(
735 "SELECT COUNT(DISTINCT method) FROM site_method_stats WHERE site_name=? AND date>=? AND date<=?",
736 (site, start_date, end_date),
737 "SELECT method, COALESCE(SUM(request_count),0) FROM site_method_stats "
738 "WHERE site_name=? AND date>=? AND date<=? GROUP BY method ORDER BY 2 DESC",
739 (site, start_date, end_date),
740 page, page_size
741 )
742 response = self._paginate_response(rows, total, page, page_size,
743 ["method", "count"])
744 sum_row = self._stats_query_one(
745 "SELECT COALESCE(SUM(request_count),0) FROM site_method_stats "
746 "WHERE site_name=? AND date>=? AND date<=?",
747 (site, start_date, end_date)
748 )
749 response["sum_count"] = sum_row[0] if sum_row else 0
750 has_sql_data, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
751 site, start_date, end_date, "site_method_stats")
752 response["coverage"] = {
753 "has_data": has_sql_data,
754 "min_date": cov_min,
755 "max_date": cov_max,
756 "data_days": cov_days,
757 "rate": round(cov_rate, 2),
758 }
759 use_sql = self._should_use_sql_data(site, start_date, end_date, range_key, "site_method_stats")
760 if not use_sql:
761 counter = {}
762 parsed_logs = self._recent_parsed_logs(site, max_scan=self._fallback_max_scan(range_key))
763 for item in parsed_logs:
764 if not self._date_in_range(item.get("date"), start_date, end_date):
765 continue
766 method = item.get("method") or "-"
767 counter[method] = counter.get(method, 0) + 1
768 data = sorted(({"method": k, "count": v} for k, v in counter.items()), key=lambda x: x["count"], reverse=True)
769 if not data:
770 return self._empty_fallback_response(page, page_size)
771 response.update({
772 "data": self._slice_rank_data(data, page, page_size),
773 "total": len(data),
774 "sum_count": sum(counter.values()),
775 "source": "recent_log",
776 "fallback": True,
777 "msg": "聚合数据为空,已从最近访问日志临时统计",
778 })
779 elif cov_rate < 1.0:
780 response["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
781 return response
782
783 # ---------- IP 排行 ----------
784
785 @staticmethod
786 def _fallback_max_scan(range_key):
787 if range_key == "30d":
788 return 300000
789 if range_key == "7d":
790 return 100000
791 return 50000
792
793 def _sql_coverage_info(self, site, start_date, end_date, table):
794 """获取 SQL 统计数据的覆盖信息,返回 (has_data, min_date, max_date, coverage_rate, data_days)"""
795 row = self._stats_query_one(
796 f"SELECT MIN(date), MAX(date), COUNT(DISTINCT date) FROM {table} WHERE site_name=? AND date>=? AND date<=?",
797 (site, start_date, end_date)
798 )
799 if not row or not row[0] or not row[1]:
800 return False, None, None, 0.0, 0
801 min_date, max_date, data_days = row[0], row[1], int(row[2] or 0)
802 expected = (datetime.strptime(end_date, "%Y-%m-%d") - datetime.strptime(start_date, "%Y-%m-%d")).days + 1
803 coverage = data_days / expected if expected > 0 else 0.0
804 return True, min_date, max_date, coverage, data_days
805
806 def _should_use_sql_data(self, site, start_date, end_date, range_key, table):
807 """判断是否应该使用 SQL 聚合数据(有数据就用,不要轻易 fallback)"""
808 if range_key not in ("7d", "30d"):
809 return True
810 has_data, _, _, _, _ = self._sql_coverage_info(site, start_date, end_date, table)
811 return has_data
812
813 def get_site_ip_rank(self, args):
814 site = args.get("site", "")
815 if not site:
816 return {"status": False, "msg": "缺少站点名称"}
817 range_key = args.get("range", "today")
818 start_date, end_date = self._date_range(range_key)
819 page = int(args.get("page", 1))
820 page_size = int(args.get("page_size", 10))
821
822 rows, total, page, page_size = self._paginate(
823 "SELECT COUNT(DISTINCT ip) FROM site_ip_stats WHERE site_name=? AND date>=? AND date<=?",
824 (site, start_date, end_date),
825 "SELECT ip, COALESCE(SUM(request_count),0), COALESCE(SUM(total_bytes),0) FROM site_ip_stats "
826 "WHERE site_name=? AND date>=? AND date<=? GROUP BY ip ORDER BY 2 DESC",
827 (site, start_date, end_date),
828 page, page_size
829 )
830 response = self._paginate_response(rows, total, page, page_size,
831 ["ip", "count", "bytes"])
832 sum_row = self._stats_query_one(
833 "SELECT COALESCE(SUM(request_count),0) FROM site_ip_stats "
834 "WHERE site_name=? AND date>=? AND date<=?",
835 (site, start_date, end_date)
836 )
837 response["sum_count"] = sum_row[0] if sum_row else 0
838 has_sql_data, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
839 site, start_date, end_date, "site_ip_stats")
840 response["coverage"] = {
841 "has_data": has_sql_data,
842 "min_date": cov_min,
843 "max_date": cov_max,
844 "data_days": cov_days,
845 "rate": round(cov_rate, 2),
846 }
847 use_sql = self._should_use_sql_data(site, start_date, end_date, range_key, "site_ip_stats")
848 if not use_sql:
849 counter = {}
850 parsed_logs = self._recent_parsed_logs(site, max_scan=self._fallback_max_scan(range_key))
851 for item in parsed_logs:
852 if not self._date_in_range(item.get("date"), start_date, end_date):
853 continue
854 ip = item.get("ip") or "-"
855 if ip not in counter:
856 counter[ip] = {"count": 0, "bytes": 0}
857 counter[ip]["count"] += 1
858 counter[ip]["bytes"] += int(item.get("bytes") or 0)
859 data = [
860 {"ip": ip, "count": item["count"], "bytes": item["bytes"]}
861 for ip, item in counter.items()
862 ]
863 data.sort(key=lambda x: x["count"], reverse=True)
864 if not data:
865 return self._empty_fallback_response(page, page_size)
866 response.update({
867 "data": self._slice_rank_data(data, page, page_size),
868 "total": len(data),
869 "sum_count": sum(item["count"] for item in counter.values()),
870 "source": "recent_log",
871 "fallback": True,
872 "msg": "聚合数据为空,已从最近访问日志临时统计",
873 })
874 elif cov_rate < 1.0:
875 response["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
876 return response
877
878 # ---------- URL 排行 ----------
879
880 def get_site_uri_rank(self, args):
881 site = args.get("site", "")
882 if not site:
883 return {"status": False, "msg": "缺少站点名称"}
884 range_key = args.get("range", "today")
885 start_date, end_date = self._date_range(range_key)
886 page = int(args.get("page", 1))
887 page_size = int(args.get("page_size", 10))
888
889 rows, total, page, page_size = self._paginate(
890 "SELECT COUNT(DISTINCT uri) FROM site_uri_stats WHERE site_name=? AND date>=? AND date<=?",
891 (site, start_date, end_date),
892 "SELECT uri, COALESCE(SUM(request_count),0), COALESCE(SUM(total_bytes),0) FROM site_uri_stats "
893 "WHERE site_name=? AND date>=? AND date<=? GROUP BY uri ORDER BY 2 DESC",
894 (site, start_date, end_date),
895 page, page_size
896 )
897 response = self._paginate_response(rows, total, page, page_size,
898 ["uri", "count", "bytes"])
899 sum_row = self._stats_query_one(
900 "SELECT COALESCE(SUM(request_count),0) FROM site_uri_stats "
901 "WHERE site_name=? AND date>=? AND date<=?",
902 (site, start_date, end_date)
903 )
904 response["sum_count"] = sum_row[0] if sum_row else 0
905 has_sql_data, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
906 site, start_date, end_date, "site_uri_stats")
907 response["coverage"] = {
908 "has_data": has_sql_data,
909 "min_date": cov_min,
910 "max_date": cov_max,
911 "data_days": cov_days,
912 "rate": round(cov_rate, 2),
913 }
914 use_sql = self._should_use_sql_data(site, start_date, end_date, range_key, "site_uri_stats")
915 if not use_sql:
916 counter = {}
917 parsed_logs = self._recent_parsed_logs(site, max_scan=self._fallback_max_scan(range_key))
918 for item in parsed_logs:
919 if not self._date_in_range(item.get("date"), start_date, end_date):
920 continue
921 uri = item.get("uri") or "-"
922 if uri not in counter:
923 counter[uri] = {"count": 0, "bytes": 0}
924 counter[uri]["count"] += 1
925 counter[uri]["bytes"] += int(item.get("bytes") or 0)
926 data = [
927 {"uri": uri, "count": item["count"], "bytes": item["bytes"]}
928 for uri, item in counter.items()
929 ]
930 data.sort(key=lambda x: x["count"], reverse=True)
931 if not data:
932 return self._empty_fallback_response(page, page_size)
933 response.update({
934 "data": self._slice_rank_data(data, page, page_size),
935 "total": len(data),
936 "sum_count": sum(item["count"] for item in counter.values()),
937 "source": "recent_log",
938 "fallback": True,
939 "msg": "聚合数据为空,已从最近访问日志临时统计",
940 })
941 elif cov_rate < 1.0:
942 response["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
943 return response
944
945 # ---------- 错误日志 ----------
946
947 def get_site_error_logs(self, args):
948 site = args.get("site", "")
949 if not site:
950 return {"status": False, "msg": "缺少站点名称"}
951 range_key = args.get("range", "today")
952 start_date, end_date = self._date_range(range_key)
953 page = int(args.get("page", 1))
954 page_size = int(args.get("page_size", 10))
955 min_status = int(args.get("min_status", 400))
956
957 rows, total, page, page_size = self._paginate(
958 "SELECT COUNT(*) FROM site_error_logs "
959 "WHERE site_name=? AND status>=? AND date>=? AND date<=?",
960 (site, min_status, start_date, end_date),
961 "SELECT time_local, ip, method, uri, status, bytes, referer, ua FROM site_error_logs "
962 "WHERE site_name=? AND status>=? AND date>=? AND date<=? ORDER BY id DESC",
963 (site, min_status, start_date, end_date),
964 page, page_size
965 )
966 response = self._paginate_response(rows, total, page, page_size,
967 ["time", "ip", "method", "uri", "status", "bytes", "referer", "ua"])
968 has_sql_data, cov_min, cov_max, cov_rate, cov_days = self._sql_coverage_info(
969 site, start_date, end_date, "site_error_logs")
970 response["coverage"] = {
971 "has_data": has_sql_data,
972 "min_date": cov_min,
973 "max_date": cov_max,
974 "data_days": cov_days,
975 "rate": round(cov_rate, 2),
976 }
977 use_sql = self._should_use_sql_data(site, start_date, end_date, range_key, "site_error_logs")
978 if not use_sql:
979 data = []
980 parsed_logs = self._recent_parsed_logs(site, max_scan=self._fallback_max_scan(range_key))
981 for item in parsed_logs:
982 if self._date_in_range(item.get("date"), start_date, end_date) and int(item.get("status") or 0) >= min_status:
983 data.append(item)
984 if not data:
985 return self._empty_fallback_response(page, page_size)
986 response.update({
987 "data": self._slice_rank_data(data, page, page_size),
988 "total": len(data),
989 "sum_count": len(data),
990 "source": "recent_log",
991 "fallback": True,
992 "msg": "聚合数据为空,已从最近访问日志临时筛选错误请求",
993 })
994 else:
995 response["sum_count"] = total
996 if cov_rate < 1.0:
997 response["msg"] = f"统计数据覆盖 {cov_days} 天({round(cov_rate * 100, 1)}%),数据可能不完整"
998 return response
999
1000 # ---------- 网站日志(反向读取 nginx 日志,优化版)----------
1001
1002 @staticmethod
1003 def _read_reverse_lines(path, n):
1004 try:
1005 with open(path, "rb") as handle:
1006 handle.seek(0, os.SEEK_END)
1007 size = handle.tell()
1008 if size == 0:
1009 return []
1010 lines = []
1011 buf = b""
1012 pos = size
1013 chunk_size = min(max(65536, size // 64), 1048576)
1014 while pos > 0 and len(lines) < n:
1015 chunk_size = min(chunk_size, pos)
1016 pos -= chunk_size
1017 handle.seek(pos)
1018 buf = handle.read(chunk_size) + buf
1019 while len(lines) < n:
1020 idx = buf.rfind(b"\n")
1021 if idx == -1:
1022 break
1023 line = buf[idx + 1:].decode("utf-8", errors="replace")
1024 buf = buf[:idx]
1025 if line or len(lines) > 0:
1026 lines.append(line)
1027 if buf and len(lines) < n:
1028 lines.append(buf.decode("utf-8", errors="replace"))
1029 lines.reverse()
1030 return lines[:n]
1031 except OSError:
1032 return []
1033
1034 def get_site_recent_logs(self, args):
1035 site = args.get("site", "")
1036 if not site:
1037 return {"status": False, "msg": "缺少站点名称"}
1038 if "\0" in site or ".." in site or "/" in site or "\\" in site:
1039 return {"status": False, "msg": "非法的站点名称"}
1040 log_path = os.path.normpath(os.path.join(LOG_DIR, f"{site}.log"))
1041 if not log_path.startswith(os.path.normpath(LOG_DIR)):
1042 return {"status": False, "msg": "非法的站点名称"}
1043 page = int(args.get("page", 1))
1044 page_size = min(int(args.get("page_size", 10)), 200)
1045
1046 if not os.path.exists(log_path):
1047 return {"status": False, "msg": f"日志文件不存在: {log_path}"}
1048
1049 try:
1050 max_scan = min(max(page * page_size + 1, 500), 1000)
1051 raw_lines = list(reversed(self._read_reverse_lines(log_path, max_scan)))
1052 start = (page - 1) * page_size
1053 page_lines = raw_lines[start:start + page_size]
1054 if not page_lines:
1055 return {"status": True, "data": [], "total": len(raw_lines), "page": page, "page_size": page_size}
1056 parsed = []
1057 for line in page_lines:
1058 p = self._parse_nginx_line(line)
1059 if p:
1060 parsed.append(p)
1061 else:
1062 parsed.append({"raw": line})
1063 return {"status": True, "data": parsed, "total": len(raw_lines), "page": page, "page_size": page_size}
1064 except Exception as exc:
1065 return {"status": False, "msg": str(exc)}
1066
1067 @staticmethod
1068 def _parse_nginx_line(line):
1069 try:
1070 m = _NGINX_RE.match(line.strip())
1071 if not m:
1072 return None
1073 ip = m.group(1)
1074 time_local = m.group(2)
1075 request = m.group(3)
1076 status = int(m.group(4))
1077 body_bytes = int(m.group(5))
1078 referer = m.group(6) or "-"
1079 ua = m.group(7) or "-"
1080 req_parts = request.split()
1081 method = req_parts[0] if req_parts else "-"
1082 uri = req_parts[1] if len(req_parts) > 1 else "-"
1083 return {
1084 "time": time_local, "ip": ip, "method": method,
1085 "uri": uri, "status": status, "bytes": body_bytes,
1086 "referer": referer, "ua": ua,
1087 }
1088 except Exception:
1089 return None