first commit

This commit is contained in:
2025-08-28 11:34:44 +08:00
parent 7d4af4b135
commit 40db604d14
15 changed files with 718 additions and 114 deletions
+187
View File
@@ -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";
}
+9
View File
@@ -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("/");
}