VPS Monitor 改造记:Token 用量 + 访问分析 + GA 风格 UI

第 1 页:访问者分析功能

数据来源

  • Nginx access.log(含 .gz 历史,覆盖 15 天)
  • IP → 地理位置:geoip-lite
  • User-Agent → 设备/浏览器/OS:ua-parser-js

功能

  • 时间范围:今日 / 昨日 / 过去7日 / 过去14日 / 全期间
  • KPI 卡:用户数、会话数、国家数、bot 检测(带环比)
  • 实时在线:最近 5 分钟活跃用户(绿点脉冲)
  • GA 风格填色地图:按访问量深浅填色
  • 图表:国家排行、设备饼图、Top 路径、流量来源
  • 明细表:IP / 国旗 / 国家 / 设备 / 路径 / 状态码
  • 扫描器过滤(默认隐藏 bot)

API 源码

import { NextResponse } from "next/server";
import fs from "fs";
import zlib from "zlib";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

type Entry = {
  ip: string; ts: number; time: string; method: string; path: string;
  status: number; referer: string; ua: string;
  country: string; countryCode: string; city: string;
  lat?: number; lon?: number;
  device: string; browser: string; os: string; isBot: boolean;
  source: string;
};

const BOT_RE = /bot|crawl|spider|scan|inspect|censys|opendir|nmap|masscan|zgrab|curl|wget|python-requests|go-http|scanner|probe|monitor/i;

let _geoip: any = null;
function loadGeoip() {
  if (_geoip) return _geoip;
  const req = eval("require");
  const mod = req("geoip-lite");
  _geoip = mod.default || mod;
  return _geoip;
}

let _UAParser: any = null;
function loadUAParser() {
  if (_UAParser) return _UAParser;
  const req = eval("require");
  const mod = req("ua-parser-js");
  _UAParser = mod.UAParser || mod.default || mod;
  return _UAParser;
}

function classifySource(referer: string): string {
  if (!referer || referer === "-") return "direct";
  const r = referer.toLowerCase();
  if (/google|bing|yahoo|duckduckgo|baidu|yandex/.test(r)) return "search";
  if (/twitter|x\.com|facebook|instagram|linkedin|reddit|weibo|telegram/.test(r)) return "social";
  return "referral";
}

function parseLine(line: string): Entry | null {
  const m = line.match(
    /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+)[^"]*" (\d{3}) (\S+) "([^"]*)" "([^"]*)"/
  );
  if (!m) return null;
  const [, ip, time, method, path_, status, , referer, ua] = m;
  const ts = Date.parse(time.replace(":", " ").replace(" +", " +"));
  const geoip = loadGeoip();
  const UAParser = loadUAParser();
  const geo = geoip.lookup(ip) || {};
  const parser = new UAParser(ua || "");
  const r = parser.getResult();
  const isBot = BOT_RE.test(ua || "") || BOT_RE.test(referer || "") || !r.browser.name;
  return {
    ip, time, ts: isNaN(ts) ? 0 : ts,
    method, path: path_.split("?")[0],
    status: parseInt(status, 10),
    referer: referer || "-", ua: ua || "-",
    country: geo.country || "Unknown",
    countryCode: geo.country || "XX",
    city: geo.city || "",
    lat: geo.ll?.[0], lon: geo.ll?.[1],
    device: r.device.type || (r.device.vendor ? r.device.vendor : "desktop"),
    browser: r.browser.name || "Unknown",
    os: r.os.name || "Unknown",
    isBot,
    source: classifySource(referer),
  };
}

function readFileLines(filePath: string): string[] {
  const lines: string[] = [];
  try {
    const buf = fs.readFileSync(/*turbopackIgnore: true*/ filePath);
    let content: string;
    if (filePath.endsWith(".gz")) {
      content = zlib.gunzipSync(buf).toString("utf-8");
    } else {
      content = buf.toString("utf-8");
    }
    for (const l of content.split("\n")) {
      if (l.trim()) lines.push(l);
    }
  } catch { /* ignore */ }
  return lines;
}

function getLogFiles(range: string): string[] {
  const base = "/var/log/nginx/access.log";
  const files: string[] = [];
  const all = [
    base, base + ".1",
    ...[2,3,4,5,6,7,8,9,10,11,12,13,14].map((i) => base + "." + i + ".gz"),
  ];
  const maxDays: Record<string, number> = { today: 1, yesterday: 1, "7d": 8, "14d": 15, all: 99 };
  const n = maxDays[range] ?? 99;
  for (let i = 0; i < Math.min(n, all.length); i++) {
    if (fs.existsSync(/*turbopackIgnore: true*/ all[i])) files.push(all[i]);
  }
  return files;
}

function filterByRange(entries: Entry[], range: string): Entry[] {
  if (!entries.length) return entries;
  const now = new Date();
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  const dayMs = 86400000;
  let from = 0, to = Date.now();
  if (range === "today") { from = startOfToday; to = Date.now(); }
  else if (range === "yesterday") { from = startOfToday - dayMs; to = startOfToday; }
  else if (range === "7d") { from = startOfToday - 6 * dayMs; }
  else if (range === "14d") { from = startOfToday - 13 * dayMs; }
  else { from = 0; }
  return entries.filter((e) => e.ts >= from && e.ts < to);
}

// ...(完整代码见文末附录)

完整代码过长,文末附录提供完整版本。核心逻辑:读日志 → 解析每行 → GeoIP → UA 解析 → 按范围过滤 → 聚合。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注