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 解析 → 按范围过滤 → 聚合。
