first commit
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// 移植自用户提供的 Fastify 版本,做了少量调整以适配 Next.js 环境
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
export interface LogAnalysisResult {
|
||||
uniqueIPCount: number;
|
||||
indexAccessCount: number;
|
||||
totalBytes: number;
|
||||
provinceCityProportions: { [key: string]: number };
|
||||
statusCodeDistribution: { [key: string]: number };
|
||||
topUrls: { url: string; count: number }[];
|
||||
timeAccess: { time: number; count: number }[];
|
||||
browserVersions: { [browser: string]: { [version: string]: number } };
|
||||
deviceTypes: { [type: string]: number };
|
||||
osVersions: { [os: string]: { [version: string]: number } };
|
||||
recentRawLog: string[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export async function analyzeLogs(log: string): Promise<LogAnalysisResult> {
|
||||
const uniqueIPs = new Set<string>();
|
||||
let indexAccessCount = 0;
|
||||
let totalBytes = 0;
|
||||
const ipCounts: Map<string, number> = new Map();
|
||||
const statusCodeCounts: Map<string, number> = new Map();
|
||||
const urlCounts: Map<string, number> = new Map();
|
||||
const timeCounts: Map<number, number> = new Map();
|
||||
|
||||
const browserVersionCounts: Map<string, Map<string, number>> = new Map();
|
||||
const deviceTypeCounts: Map<string, number> = new Map();
|
||||
const osVersionCounts: Map<string, Map<string, number>> = new Map();
|
||||
|
||||
const lines = log.split("\n");
|
||||
const logRegex =
|
||||
/^(\S+) - - \[(.*?)\] "(\S+) (\S+) (\S+)" (\d{3}) (\d+|-) "(.*?)" "(.*?)" ".*?"$/;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = logRegex.exec(line);
|
||||
if (match) {
|
||||
const ip = match[1];
|
||||
const datetime = match[2];
|
||||
const url = match[4];
|
||||
const statusCode = match[6];
|
||||
const bytesStr = match[7];
|
||||
const bytes = bytesStr === "-" ? 0 : parseInt(bytesStr, 10);
|
||||
const userAgentStr = match[9];
|
||||
|
||||
uniqueIPs.add(ip);
|
||||
if (url === "/" || url === "/index.html") indexAccessCount++;
|
||||
totalBytes += bytes;
|
||||
ipCounts.set(ip, (ipCounts.get(ip) || 0) + 1);
|
||||
statusCodeCounts.set(statusCode, (statusCodeCounts.get(statusCode) || 0) + 1);
|
||||
urlCounts.set(url, (urlCounts.get(url) || 0) + 1);
|
||||
|
||||
const dateTimeParts = datetime.split(":");
|
||||
if (dateTimeParts.length >= 2) {
|
||||
const datePart = dateTimeParts[0];
|
||||
const hourPart = dateTimeParts[1];
|
||||
const timeKey = `${datePart}:${hourPart}`;
|
||||
const date = new Date(parseLogDateTime(timeKey)).getTime();
|
||||
if (!Number.isNaN(date)) {
|
||||
timeCounts.set(date, (timeCounts.get(date) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const parser = new UAParser(userAgentStr as any);
|
||||
const browser = parser.getBrowser();
|
||||
const device = parser.getDevice();
|
||||
const os = parser.getOS();
|
||||
|
||||
const browserName = browser.name || "Unknown";
|
||||
const browserVersion = browser.version || "Unknown";
|
||||
if (!browserVersionCounts.has(browserName)) {
|
||||
browserVersionCounts.set(browserName, new Map());
|
||||
}
|
||||
const browserMap = browserVersionCounts.get(browserName)!;
|
||||
browserMap.set(browserVersion, (browserMap.get(browserVersion) || 0) + 1);
|
||||
|
||||
const deviceType = (device.type as string) || "Desktop";
|
||||
deviceTypeCounts.set(deviceType, (deviceTypeCounts.get(deviceType) || 0) + 1);
|
||||
|
||||
const osName = os.name || "Unknown";
|
||||
const osVersion = os.version || "Unknown";
|
||||
if (!osVersionCounts.has(osName)) {
|
||||
osVersionCounts.set(osName, new Map());
|
||||
}
|
||||
const osMap = osVersionCounts.get(osName)!;
|
||||
osMap.set(osVersion, (osMap.get(osVersion) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const provinceCityCounts: Map<string, number> = new Map();
|
||||
// 动态加载 geoip-lite,避免构建期读取数据文件
|
||||
const geoip = await import("geoip-lite");
|
||||
for (const [ip, count] of ipCounts.entries()) {
|
||||
const geo = geoip.lookup(ip as any);
|
||||
if (geo) {
|
||||
const country = geo.country || "Unknown";
|
||||
const region = (geo as any).region || "Unknown"; // geoip-lite typings
|
||||
const city = geo.city || "Unknown";
|
||||
const key = `${country}-${region}-${city}`;
|
||||
provinceCityCounts.set(key, (provinceCityCounts.get(key) || 0) + count);
|
||||
} else {
|
||||
provinceCityCounts.set("Unknown", (provinceCityCounts.get("Unknown") || 0) + count);
|
||||
}
|
||||
}
|
||||
|
||||
const totalAccesses = [...ipCounts.values()].reduce((a, b) => a + b, 0) || 1;
|
||||
const provinceCityProportions: { [key: string]: number } = {};
|
||||
for (const [key, count] of provinceCityCounts.entries()) {
|
||||
provinceCityProportions[key] = parseFloat(((count / totalAccesses) * 100).toFixed(2));
|
||||
}
|
||||
|
||||
const topUrls = Array.from(urlCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 20)
|
||||
.map(([url, count]) => ({ url, count }));
|
||||
|
||||
const timeAccess = Array.from(timeCounts.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([time, count]) => ({ time, count }));
|
||||
|
||||
const statusCodeDistribution: { [key: string]: number } = {};
|
||||
for (const [code, count] of statusCodeCounts.entries()) {
|
||||
statusCodeDistribution[code] = count;
|
||||
}
|
||||
|
||||
const browserVersions: { [browser: string]: { [version: string]: number } } = {};
|
||||
for (const [browser, versions] of browserVersionCounts.entries()) {
|
||||
browserVersions[browser] = {};
|
||||
for (const [version, count] of versions.entries()) {
|
||||
browserVersions[browser][version] = count;
|
||||
}
|
||||
}
|
||||
|
||||
const deviceTypes: { [type: string]: number } = {};
|
||||
for (const [type, count] of deviceTypeCounts.entries()) {
|
||||
deviceTypes[type] = count;
|
||||
}
|
||||
|
||||
const osVersions: { [os: string]: { [version: string]: number } } = {};
|
||||
for (const [os, versions] of osVersionCounts.entries()) {
|
||||
osVersions[os] = {};
|
||||
for (const [version, count] of versions.entries()) {
|
||||
osVersions[os][version] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uniqueIPCount: uniqueIPs.size,
|
||||
indexAccessCount,
|
||||
totalBytes,
|
||||
provinceCityProportions,
|
||||
statusCodeDistribution,
|
||||
topUrls,
|
||||
timeAccess,
|
||||
browserVersions,
|
||||
deviceTypes,
|
||||
osVersions,
|
||||
recentRawLog: lines.slice(-100),
|
||||
version: "1.0.3",
|
||||
};
|
||||
}
|
||||
|
||||
function parseLogDateTime(timeKey: string): string {
|
||||
const [datePart, hourPart] = timeKey.split(":");
|
||||
const [day, monthStr, year] = datePart.split("/");
|
||||
const month = monthStringToNumber(monthStr);
|
||||
return `${year}-${month}-${day}T${hourPart}:00:00+08:00`;
|
||||
}
|
||||
|
||||
function monthStringToNumber(monthStr: string): string {
|
||||
const months: { [key: string]: string } = {
|
||||
Jan: "01",
|
||||
Feb: "02",
|
||||
Mar: "03",
|
||||
Apr: "04",
|
||||
May: "05",
|
||||
Jun: "06",
|
||||
Jul: "07",
|
||||
Aug: "08",
|
||||
Sep: "09",
|
||||
Oct: "10",
|
||||
Nov: "11",
|
||||
Dec: "12",
|
||||
};
|
||||
return months[monthStr] || "01";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const WEBSITE_FILE_DIR =
|
||||
process.env.WEBSITE_FILE_DIR ||
|
||||
"/opt/1panel/apps/openresty/openresty/www/sites/";
|
||||
|
||||
export function safeJoinSitePath(site: string, ...parts: string[]) {
|
||||
// 防止路径穿越,仅允许字母数字、点、短横线和下划线
|
||||
const safe = site.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||
return [WEBSITE_FILE_DIR, safe, ...parts].join("/");
|
||||
}
|
||||
Reference in New Issue
Block a user