萌化FileBrowser随着FileBrowser的版本逐渐迭代到2.57.1，我发现我当初写的版本不少地方效果已经失效，于是我打算优化自定义的样式表代码来适配新版的FileBrowser。因为跨的版本比较大，改动也是比较大的，甚至这个个性化自定义配置都有不小的改变。于是我打算重新彻底优化一下。果然，事情总是动态发展的，上一版我调教的终极美化，这次要进行修补更新了。文件目录结构说明新版的filebrowser页面风格定制化style文件夹目录结构如下：[chocola@Neko-X99 linux-amd64-filebrowser]$ tree . ├── CHANGELOG.md ├── filebrowser ├── filebrowser.db ├── LICENSE ├── README.md └── style ├── custom.css ├── img │ ├── background.jpg │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon.ico │ │ └── favicon.svg │ ├── JCuYuan.woff2 │ ├── login.jpg │ └── logo.svg ├── index.html └── manifest.json 4 directories, 17 filesfilebrowser相比于之前的版本，主要是图标数量进行了简化。而我这次的改进，有以下几点：使用了woff2格式的字体，减少页面加载流量和等待时间增加了提示框样式设置为文件预览添加了毛玻璃背景效果为新增的数值条增加样式适配优化弹窗背景，增加毛玻璃效果为文本编辑器添加半透明背景效果为新增的文件邮件菜单添加主题样式优化登陆界面信息提示的样式大概就这些吧，还有一些细枝末节的部分，我没有详细列出，有一些效果是因为代码改动牵一发动全身，修改反反复复搞了快一天，总之这个filebrowser主题改动不小。效果展示接下来我会用图片逐个展示修改后的效果。登陆界面：这个改动不算大，只是适配了一下新版的消息框。文件页面：适配了右键菜单的效果。文件选择效果（这部分没变）：下载弹窗背景毛玻璃模糊化：新建文件操作也是同理：优化后的文本编辑器样式：优化后的图片预览效果：对新的数字选择器进行样式适配：优化信息提示框：萌化方法萌化方法并不困难，只需要下载我准备好的style.zip压缩包，解压到filebrowser的目录后应用自定义样式就可以了，前文也有目录树说明了，详细操作可以去看这篇文章：个性化Filebrowser界面，NAS也要Kawaii~~~!唯一要注意的是需要下载FileBrowser的2.57.1版本，因为这版界面美化是针对这个版本写的。对于custom.css，我已经在不少位置都做了注释，想自己按需修改的话看注释照着改也不难。
断网自动重连！用Go实现广西大学校园网断网自动登录
广西大学校园网断网自动重连程序因为广西某211高校校园网有时候会不稳定波动，导致设备掉线。而如果在需要挂机进行一些工作的场合出现这个问题，将会相当蛋疼。再加上在宿舍每天晚上断网后，第二天都要手动重新登录着实有点麻烦，于是乎我就用我入门级中的入门级Go编程能力写了这个程序。程序实现登录方法查找因为学校推荐是使用浏览器打开认真网页进行登录，而经过抓包后，我发现相比于大一的时候，校园网的网页传输数据进行了加密。也就是，如果我要通过逆向网页JavaScript脚本获得登录请求发送逻辑，代价有点大。不过好在你西还是关照到了无GUI Linux服务器使用校园网的问题，在官网挂出了登录方法：linux系统宽带客户端-2024.12.30日后使用这就好办了嘛，只需要构造这个URL请求：http://172.17.0.2:801/eportal/portal/login?callback=dr1003&login_method=1&user_account=账号@cmcc&user_password=密码&wlan_user_ip=终端IP&wlan_user_ipv6=&wlan_user_mac=000000000000&wlan_ac_ip=&wlan_ac_name=&jsVersion=4.2.1&terminal_type=1&lang=zh-cn&v=5574&lang=zh发送给认证服务器就可以登录校园网了。不过我注意到了这个请求URL里面，MAC填写了000000000000，按理来说这部分应该填写设备的MAC，用于保持登录状态和后续无感知登录。这样填写无效的MAC后面大概率会因为校园网波动或者短暂断联掉登录，所以在我的程序逻辑上，这部分应该补上。现在需要发送的校园网认证信息就理清楚了：账号密码线路类型设备IP设备MAC我只需要写一个程序获取到以上信息，然后持续不断判断网络通断状态，当检测到断网后构造认证的URL并发送，就可以实现校园网断网自动重连了。用Go实现校园网自动登录其实之前我就用Python写过一版校园网自动登录程序，但是因为Python的效率和占用确实有点感人了，所以我觉得还是选择编译型语言来进行构建吧。好巧不巧之前学了一些Golang，要不就拿这个试试看？实现思路因为Go是编译型语言，登录的配置就不能像Python那样让用户打开源代码写在对应的变量里面了，得写一个配置文件机制。也就是首次运行创建一个文本的配置文件，然后用户填写了相关的信息，再运行程序，程序从配置文件读取信息并且执行校园网认证和断网自动重连工作。那这样看，实现思路就很清晰了：程序首次运行，输出配置文件模板，提示用户填写用户填写好后，读取配置文件并进行基础的校验，确保参数填写无误实现校园网登录认证的函数，网络通断检测函数，以及上网时段判断函数避免在非上网时段反复尝试重连对于配置文件的部分，我想采用类似ini配置文件的格式，这是模板：# 校园网登录脚本信息设置：（注意请不要改变格式） # 用户名：（填写示例：User=1807210721） User= # 密码：（填写示例：Password=www.nekopara.uk） Password= # 运营商选择，留空选择校园网，如果需要选择运营商，电信填写telecom，联通填写unicom，移动填写cmcc Net_Type= # 是否开启学生上网时段模式？1为开启，0为关闭，开启后周一到周五0:00-6:00将不会尝试重连 Student_Mode=0 # 开启路由器登录模式： # 如果填写以下两个参数（均非空），则使用指定的路由器IP和MAC进行认证。 # 否则使用本机IP和MAC。 # 示例： # Router_IP=172.16.6.6 # Router_MAC=36:88:8A:99:A4:CC Router_IP= Router_MAC=考虑到为了便于小白使用，我输出的是.txt文件，可以直接记事本打开。以及，添加一个命令行启动参数支持，可以让程序作为服务运行和部署，例如：./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -nettype telecom -studentmode -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC现在技术架构想清楚了，开始编程吧！实现代码package main import ( "bufio" "fmt" "os" "strings" "net/http" "net" "net/url" "time" "io" "flag" ) const configFileName = "config.txt" // Config 结构体保存配置 type Config struct { User string Password string NetType string // 新增字段 StudentMode bool // 路由器模式（当两者都非空时启用） RouterIP string RouterMAC string } // loadConfig 加载或创建配置文件 func loadConfig() (*Config, error) { // 检查文件是否存在 if _, err := os.Stat(configFileName); os.IsNotExist(err) { // 创建默认模板 defaultContent := `# 校园网登陆脚本信息设置：（注意请不要改变格式） # 用户名：（填写示例：User=1807210721） User= # 密码：（填写示例：Password=www.nekopara.uk） Password= # 运营商选择，留空选择校园网，如果需要选择运营商，电信填写telecom，联通填写unicom，移动填写cmcc Net_Type= # 是否开启学生上网时段模式？1为开启，0为关闭，开启后周一到周五0:00-6:00将不会尝试重连 Student_Mode=0 # 开启路由器登陆模式： # 如果填写以下两个参数（均非空），则使用指定的路由器IP和MAC进行认证。 # 否则使用本机IP和MAC。 # 示例： # Router_IP=172.16.6.6 # Router_MAC=36:88:8A:99:A4:CC Router_IP= Router_MAC= ` err = os.WriteFile(configFileName, []byte(defaultContent), 0644) if err != nil { return nil, fmt.Errorf("无法创建配置文件: %v", err) } return nil, fmt.Errorf("未找到配置文件，配置文件 '%s' 已创建，请先填写上网信息后重新运行程序", configFileName) } // 读取并解析 content, err := os.ReadFile(configFileName) if err != nil { return nil, fmt.Errorf("无法读取配置文件: %v", err) } cfg := &Config{} scanner := bufio.NewScanner(strings.NewReader(string(content))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // 跳过空行和注释 if line == "" || strings.HasPrefix(line, "#") { continue } // 按第一个 '=' 分割（避免密码含等号出错） parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue // 格式错误，跳过 } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { case "User": cfg.User = value case "Password": cfg.Password = value case "Net_Type": cfg.NetType = value // 新增这一行 case "Student_Mode": cfg.StudentMode = (value == "1") case "Router_IP": cfg.RouterIP = value case "Router_MAC": cfg.RouterMAC = value } } if err := scanner.Err(); err != nil { return nil, err } // 基础校验 if cfg.User == "" || cfg.Password == "" { return nil, fmt.Errorf("请在 '%s' 中填写用户名和密码", configFileName) } // 在 loadConfig 函数中，解析配置后添加： if cfg.NetType != "" { // 检查是否是合法的运营商 valid := false switch strings.ToLower(cfg.NetType) { case "telecom", "unicom", "cmcc": valid = true } if !valid { return nil, fmt.Errorf("错误：运营商类型必须为空、telecom、unicom或cmcc（不区分大小写），当前值: %s", cfg.NetType) } } return cfg, nil } func getLocalIP() (string, error) { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return "", err } defer conn.Close() return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil } func getMACAddress() (string, error) { interfaces, err := net.Interfaces() if err != nil { return "", err } for _, iface := range interfaces { if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { continue } mac := iface.HardwareAddr.String() if mac == "" { continue } addrs, _ := iface.Addrs() for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { return mac, nil } } } return "", fmt.Errorf("no active network interface with MAC found") } func isNetworkOK() bool { client := &http.Client{ Timeout: 1 * time.Second, } resp, err := client.Get("http://connect.rom.miui.com/generate_204") if err != nil { return false // 网络不通 / DNS 故障 / 超时 } defer resp.Body.Close() return resp.StatusCode == 204 } func login(cfg *Config, ip, mac string) { // 格式化 MAC：去掉冒号，转小写（适配你 bash 脚本的行为） cleanMAC := strings.ReplaceAll(strings.ToLower(mac), ":", "") userAccount := cfg.User if cfg.NetType != "" { userAccount = cfg.User + "@" + cfg.NetType } params := url.Values{ "callback": {"dr1003"}, "login_method": {"1"}, "user_account": {userAccount}, "user_password": {cfg.Password}, "wlan_user_ip": {ip}, "wlan_user_mac": {cleanMAC}, "wlan_user_ipv6": {""}, "wlan_ac_ip": {""}, "wlan_ac_name": {""}, "jsVersion": {"4.2.1"}, "terminal_type": {"1"}, "lang": {"zh-cn"}, "v": {"5574"}, } loginURL := "http://172.17.0.2:801/eportal/portal/login?" + params.Encode() resp, err := http.Get(loginURL) if err != nil { fmt.Printf("❌ 登录请求失败: %v\n", err) return } defer resp.Body.Close() // 读取并打印响应体 body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("❌ 读取响应体失败: %v\n", err) return } bodyStr := string(body) fmt.Printf("✅ 已发送登录请求（HTTP状态码: %d）\n", resp.StatusCode) fmt.Printf("响应内容: %s\n", bodyStr) } func shouldSkipLogin(cfg *Config) bool { if !cfg.StudentMode { return false } now := time.Now() weekday := now.Weekday() // Sunday = 0, Monday = 1, ..., Friday = 5 hour := now.Hour() // 周一到周五（1~5），且 0:00 ~ 5:59 if weekday >= time.Monday && weekday <= time.Friday && hour >= 0 && hour < 6 { fmt.Println("🌙 学生模式：当前为禁网时段，暂停重连") return true } return false } func getLoginInfo(cfg *Config) (ip, mac string, err error) { // 如果启用了路由器模式（两个字段都非空） if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Println("🌐 使用路由器模式进行认证") return cfg.RouterIP, cfg.RouterMAC, nil } // 否则使用本机信息 fmt.Println("💻 使用本机模式进行认证") ip, err = getLocalIP() if err != nil { return "", "", fmt.Errorf("获取本机IP失败: %w", err) } mac, err = getMACAddress() if err != nil { return "", "", fmt.Errorf("获取本机MAC失败: %w", err) } return ip, mac, nil } func printHelp() { fmt.Println(`广西大学校园网自动登陆程序参数说明： 必须参数： -user 用户名（必须提供） -passwd 密码（必须提供） 可选参数： -nettype 运营商类型（telecom, unicom, cmcc），不加参数则使用校园网 -studentmode 启用学生模式（不带值） -ip 路由器IP（必须与-mac一起使用） -mac 路由器MAC（必须与-ip一起使用） -help 显示此帮助信息 示例（Linux）： ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword /opt/GXU_Net_AutoLogin/GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -nettype telecom -studentmode ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC 示例（Windows）： GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -nettype telecom -studentmode C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC `) } func main() { fmt.Printf("🚀广西大学校园网自动登陆程序 By：GTX690战术核显卡导弹（www.nekopara.uk）\n") // 定义命令行参数 var ( user string passwd string nettype string studentMode bool ip string mac string help bool ) flag.StringVar(&user, "user", "", "用户名") flag.StringVar(&passwd, "passwd", "", "密码") flag.StringVar(&nettype, "nettype", "", "运营商类型（telecom, unicom, cmcc）") flag.BoolVar(&studentMode, "studentmode", false, "启用学生模式") flag.StringVar(&ip, "ip", "", "路由器IP（必须与-mac一起使用）") flag.StringVar(&mac, "mac", "", "路由器MAC（必须与-ip一起使用）") flag.BoolVar(&help, "help", false, "显示帮助信息") flag.Parse() // 显示帮助信息 if help { printHelp() os.Exit(0) } // 检查必须参数 if (user == "" && passwd == "") { // 从配置文件加载 cfg, err := loadConfig() if err != nil { fmt.Println("❌ 错误:", err) fmt.Println("💡 请编辑 config.txt 后重新运行本程序。") os.Exit(1) } fmt.Printf("✅ 配置加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 修复：将配置文件中的值赋给命令行变量 user = cfg.User passwd = cfg.Password nettype = cfg.NetType studentMode = cfg.StudentMode ip = cfg.RouterIP mac = cfg.RouterMAC } else if user != "" && passwd != "" { // 从命令行参数加载 cfg := &Config{ User: user, Password: passwd, NetType: nettype, StudentMode: studentMode, RouterIP: ip, RouterMAC: mac, } // 校验运营商类型 if nettype != "" { valid := false switch strings.ToLower(nettype) { case "telecom", "unicom", "cmcc": valid = true } if !valid { fmt.Printf("❌ 错误：运营商类型必须为telecom, unicom, cmcc（不区分大小写），当前值: %s\n", nettype) os.Exit(1) } } // 校验路由器IP/MAC if (ip != "" && mac == "") || (ip == "" && mac != "") { fmt.Println("❌ 错误：必须同时提供ip和mac参数，两者缺一不可") os.Exit(1) } // 显示配置 fmt.Printf("✅ 命令行参数加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 使用命令行参数配置 cfg.User = user cfg.Password = passwd cfg.NetType = nettype cfg.StudentMode = studentMode cfg.RouterIP = ip cfg.RouterMAC = mac } else { // 只提供了其中一个参数 fmt.Println("❌ 错误：必须同时提供user和passwd参数，或者都不提供（通过配置文件）") fmt.Println("💡 请使用 -help 查看参数说明") os.Exit(1) } // 获取用于登录的 IP 和 MAC（自动判断模式） ipAddr, macAddr, err := getLoginInfo(&Config{ RouterIP: ip, RouterMAC: mac, }) if err != nil { fmt.Printf("❌ %v\n", err) os.Exit(1) } fmt.Printf("✅ 守护进程启动：认证IP=%s | 认证MAC=%s\n", ipAddr, macAddr) // 主循环 for { if shouldSkipLogin(&Config{ StudentMode: studentMode, }) { time.Sleep(1 * time.Second) continue } if !isNetworkOK() { fmt.Println("⚠️ 检测到断网，正在重新登录...") login(&Config{ User: user, Password: passwd, NetType: nettype, }, ipAddr, macAddr) } time.Sleep(1 * time.Second) } }代码详细解释下面我将详细解释代码中每个函数的作用，帮助你理解这个校园网自动重连程序的工作原理。这些函数都是程序的核心逻辑，我将按照从配置加载到网络检测再到认证的流程进行说明。1. loadConfig()func loadConfig() (*Config, error) { // 检查文件是否存在 if _, err := os.Stat(configFileName); os.IsNotExist(err) { // 创建默认模板 defaultContent := `# 校园网登录脚本信息设置：（注意请不要改变格式） # 用户名：（填写示例：User=1807210721） User= # 密码：（填写示例：Password=www.nekopara.uk） Password= # 运营商选择，留空选择校园网，如果需要选择运营商，电信填写telecom，联通填写unicom，移动填写cmcc Net_Type= # 是否开启学生上网时段模式？1为开启，0为关闭，开启后周一到周五0:00-6:00将不会尝试重连 Student_Mode=0 # 开启路由器登录模式： # 如果填写以下两个参数（均非空），则使用指定的路由器IP和MAC进行认证。 # 否则使用本机IP和MAC。 # 示例： # Router_IP=172.16.6.6 # Router_MAC=36:88:8A:99:A4:CC Router_IP= Router_MAC= ` err = os.WriteFile(configFileName, []byte(defaultContent), 0644) if err != nil { return nil, fmt.Errorf("无法创建配置文件: %v", err) } return nil, fmt.Errorf("未找到配置文件，配置文件 '%s' 已创建，请先填写上网信息后重新运行程序", configFileName) } // 读取并解析 content, err := os.ReadFile(configFileName) if err != nil { return nil, fmt.Errorf("无法读取配置文件: %v", err) } cfg := &Config{} scanner := bufio.NewScanner(strings.NewReader(string(content))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // 跳过空行和注释 if line == "" || strings.HasPrefix(line, "#") { continue } // 按第一个 '=' 分割（避免密码含等号出错） parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue // 格式错误，跳过 } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { case "User": cfg.User = value case "Password": cfg.Password = value case "Net_Type": cfg.NetType = value // 新增这一行 case "Student_Mode": cfg.StudentMode = (value == "1") case "Router_IP": cfg.RouterIP = value case "Router_MAC": cfg.RouterMAC = value } } if err := scanner.Err(); err != nil { return nil, err } // 基础校验 if cfg.User == "" || cfg.Password == "" { return nil, fmt.Errorf("请在 '%s' 中填写用户名和密码", configFileName) } // 在 loadConfig 函数中，解析配置后添加： if cfg.NetType != "" { // 检查是否是合法的运营商 valid := false switch strings.ToLower(cfg.NetType) { case "telecom", "unicom", "cmcc": valid = true } if !valid { return nil, fmt.Errorf("错误：运营商类型必须为空、telecom、unicom或cmcc（不区分大小写），当前值: %s", cfg.NetType) } } return cfg, nil }函数解释：loadConfig() 是程序的配置加载核心函数。它的作用是检查是否存在配置文件，如果不存在则创建一个默认配置文件模板，让用户填写必要信息；如果存在则解析配置文件内容并返回配置结构体。关键点：首次运行时会创建config.txt文件，包含详细的填写说明使用bufio.Scanner逐行解析配置文件，跳过注释和空行对配置项进行基础校验，确保用户名和密码必填对运营商类型进行合法性检查（必须是telecom、unicom或cmcc）如果配置不合法，会返回错误信息提示用户这个函数是程序与用户交互的第一步，也是保证后续程序能正确运行的基础。2. getLocalIP()func getLocalIP() (string, error) { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return "", err } defer conn.Close() return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil }函数解释：getLocalIP() 用于获取当前设备的本地IP地址。这个函数通过与公共DNS服务器（8.8.8.8）建立UDP连接，然后获取连接的本地地址。关键点：使用net.Dial建立UDP连接，连接到Google DNS服务器（8.8.8.8:80）通过conn.LocalAddr()获取连接的本地IP地址这是获取本机IP的常用方法，避免了遍历所有网络接口的复杂性返回的IP地址是字符串格式，例如"192.168.1.100"这个函数在获取本机IP时非常实用，避免了手动遍历网络接口的麻烦。3. getMACAddress()func getMACAddress() (string, error) { interfaces, err := net.Interfaces() if err != nil { return "", err } for _, iface := range interfaces { if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { continue } mac := iface.HardwareAddr.String() if mac == "" { continue } addrs, _ := iface.Addrs() for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { return mac, nil } } } return "", fmt.Errorf("no active network interface with MAC found") }函数解释：getMACAddress() 用于获取当前设备的MAC地址。它会遍历所有网络接口，找到第一个非回环、启用的接口，并返回其MAC地址。关键点：使用net.Interfaces()获取所有网络接口信息过滤掉回环接口（FlagLoopback）和未启用的接口（FlagUp）从接口的硬件地址（HardwareAddr）获取MAC地址遍历接口的IP地址，确保返回的是有效网络接口的MAC返回的MAC格式如"36:88:8A:99:A4:CC"这个函数在获取本机MAC地址时非常关键，因为校园网认证需要MAC地址来保持登录状态。4. isNetworkOK()func isNetworkOK() bool { client := &http.Client{ Timeout: 1 * time.Second, } resp, err := client.Get("http://connect.rom.miui.com/generate_204") if err != nil { return false // 网络不通 / DNS 故障 / 超时 } defer resp.Body.Close() return resp.StatusCode == 204 }函数解释：isNetworkOK() 用于检测当前网络连接状态。它通过HTTP请求访问一个特殊URL（http://connect.rom.miui.com/generate_204），根据响应状态码判断网络是否通畅。关键点：使用http.Client发送GET请求，设置1秒超时请求的URL是MIUI系统用于检测网络连接的特殊地址如果响应状态码为204，表示网络通畅；否则表示断网这个检测方法非常轻量，不会产生太多网络流量通过defer resp.Body.Close()确保资源正确释放这个函数是程序检测断网的核心，决定了是否需要触发重连逻辑。5. login()func login(cfg *Config, ip, mac string) { // 格式化 MAC：去掉冒号，转小写（适配你 bash 脚本的行为） cleanMAC := strings.ReplaceAll(strings.ToLower(mac), ":", "") userAccount := cfg.User if cfg.NetType != "" { userAccount = cfg.User + "@" + cfg.NetType } params := url.Values{ "callback": {"dr1003"}, "login_method": {"1"}, "user_account": {userAccount}, "user_password": {cfg.Password}, "wlan_user_ip": {ip}, "wlan_user_mac": {cleanMAC}, "wlan_user_ipv6": {""}, "wlan_ac_ip": {""}, "wlan_ac_name": {""}, "jsVersion": {"4.2.1"}, "terminal_type": {"1"}, "lang": {"zh-cn"}, "v": {"5574"}, } loginURL := "http://172.17.0.2:801/eportal/portal/login?" + params.Encode() resp, err := http.Get(loginURL) if err != nil { fmt.Printf("❌ 登录请求失败: %v\n", err) return } defer resp.Body.Close() // 读取并打印响应体 body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("❌ 读取响应体失败: %v\n", err) return } bodyStr := string(body) fmt.Printf("✅ 已发送登录请求（HTTP状态码: %d）\n", resp.StatusCode) fmt.Printf("响应内容: %s\n", bodyStr) }函数解释：login() 是校园网认证的核心函数，负责构造认证请求并发送给认证服务器。关键点：对MAC地址进行格式化：去掉冒号，转为小写根据运营商类型（NetType）构造用户名（例如1807210721@cmcc）使用url.Values构建查询参数，确保参数编码正确拼接完整的认证URL（http://172.17.0.2:801/...）发送HTTP GET请求到认证服务器打印请求结果和响应内容，便于调试通过params.Encode()正确编码URL参数这个函数实现了校园网认证的关键逻辑，是整个程序的核心功能。6. shouldSkipLogin()func shouldSkipLogin(cfg *Config) bool { if !cfg.StudentMode { return false } now := time.Now() weekday := now.Weekday() // Sunday = 0, Monday = 1, ..., Friday = 5 hour := now.Hour() // 周一到周五（1~5），且 0:00 ~ 5:59 if weekday >= time.Monday && weekday <= time.Friday && hour >= 0 && hour < 6 { fmt.Println("🌙 学生模式：当前为禁网时段，暂停重连") return true } return false }函数解释：shouldSkipLogin() 用于判断在特定时段是否应该跳过登录尝试。这个函数实现了"学生模式"，即在周一到周五的0:00-6:00期间不尝试重连。关键点：仅在StudentMode为true时生效获取当前时间的星期几和小时判断是否为周一至周五的凌晨时段（0:00-5:59）如果是禁网时段，打印提示并返回true，表示应该跳过登录否则返回false，表示可以尝试登录这个函数是为了解决学生账号在凌晨时段断网后反复尝试自动重连的问题，避免持续不断发起无意义的请求。7. getLoginInfo()func getLoginInfo(cfg *Config) (ip, mac string, err error) { // 如果启用了路由器模式（两个字段都非空） if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Println("🌐 使用路由器模式进行认证") return cfg.RouterIP, cfg.RouterMAC, nil } // 否则使用本机信息 fmt.Println("💻 使用本机模式进行认证") ip, err = getLocalIP() if err != nil { return "", "", fmt.Errorf("获取本机IP失败: %w", err) } mac, err = getMACAddress() if err != nil { return "", "", fmt.Errorf("获取本机MAC失败: %w", err) } return ip, mac, nil }函数解释：getLoginInfo() 用于确定认证时使用的IP和MAC地址。它会根据配置决定是使用路由器的IP/MAC还是本机的IP/MAC。关键点：如果配置了Router_IP和Router_MAC，则使用路由器的IP和MAC否则，使用本机的IP和MAC（通过调用getLocalIP()和getMACAddress()）返回的IP和MAC格式符合校园网认证要求在使用路由器模式时，会打印提示信息这个函数是程序灵活应对不同网络环境和部署需求的关键，既适配在教室本机连接校园网时的认证，又适配在宿舍使用路由器共享上网的场景。8. printHelp()func printHelp() { fmt.Println(`广西大学校园网自动登录程序参数说明： 必须参数： -user 用户名（必须提供） -passwd 密码（必须提供） 可选参数： -nettype 运营商类型（telecom, unicom, cmcc），不加参数则使用校园网 -studentmode 启用学生模式（不带值） -ip 路由器IP（必须与-mac一起使用） -mac 路由器MAC（必须与-ip一起使用） -help 显示此帮助信息 示例（Linux）： ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword /opt/GXU_Net_AutoLogin/GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -nettype telecom -studentmode ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC 示例（Windows）： GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -nettype telecom -studentmode C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC `) }函数解释：printHelp() 用于打印程序的使用帮助信息，包括参数说明和使用示例。关键点：提供了详细的参数说明，包括必须参数和可选参数列举了Linux和Windows平台的使用示例清晰地说明了各参数的作用便于用户快速了解如何使用程序这个函数在用户输入-help参数时调用，是程序的文档部分，帮助用户正确使用程序。9. main()func main() { fmt.Printf("🚀广西大学校园网自动登陆程序 By：GTX690战术核显卡导弹（www.nekopara.uk）\n") // 定义命令行参数 var ( user string passwd string nettype string studentMode bool ip string mac string help bool ) flag.StringVar(&user, "user", "", "用户名") flag.StringVar(&passwd, "passwd", "", "密码") flag.StringVar(&nettype, "nettype", "", "运营商类型（telecom, unicom, cmcc）") flag.BoolVar(&studentMode, "studentmode", false, "启用学生模式") flag.StringVar(&ip, "ip", "", "路由器IP（必须与-mac一起使用）") flag.StringVar(&mac, "mac", "", "路由器MAC（必须与-ip一起使用）") flag.BoolVar(&help, "help", false, "显示帮助信息") flag.Parse() // 显示帮助信息 if help { printHelp() os.Exit(0) } // 检查必须参数 if (user == "" && passwd == "") { // 从配置文件加载 cfg, err := loadConfig() if err != nil { fmt.Println("❌ 错误:", err) fmt.Println("💡 请编辑 config.txt 后重新运行本程序。") os.Exit(1) } fmt.Printf("✅ 配置加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 修复：将配置文件中的值赋给命令行变量 user = cfg.User passwd = cfg.Password nettype = cfg.NetType studentMode = cfg.StudentMode ip = cfg.RouterIP mac = cfg.RouterMAC } else if user != "" && passwd != "" { // 从命令行参数加载 cfg := &Config{ User: user, Password: passwd, NetType: nettype, StudentMode: studentMode, RouterIP: ip, RouterMAC: mac, } // 校验运营商类型 if nettype != "" { valid := false switch strings.ToLower(nettype) { case "telecom", "unicom", "cmcc": valid = true } if !valid { fmt.Printf("❌ 错误：运营商类型必须为telecom, unicom, cmcc（不区分大小写），当前值: %s\n", nettype) os.Exit(1) } } // 校验路由器IP/MAC if (ip != "" && mac == "") || (ip == "" && mac != "") { fmt.Println("❌ 错误：必须同时提供ip和mac参数，两者缺一不可") os.Exit(1) } // 显示配置 fmt.Printf("✅ 命令行参数加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 使用命令行参数配置 cfg.User = user cfg.Password = passwd cfg.NetType = nettype cfg.StudentMode = studentMode cfg.RouterIP = ip cfg.RouterMAC = mac } else { // 只提供了其中一个参数 fmt.Println("❌ 错误：必须同时提供user和passwd参数，或者都不提供（通过配置文件）") fmt.Println("💡 请使用 -help 查看参数说明") os.Exit(1) } // 获取用于登录的 IP 和 MAC（自动判断模式） ipAddr, macAddr, err := getLoginInfo(&Config{ RouterIP: ip, RouterMAC: mac, }) if err != nil { fmt.Printf("❌ %v\n", err) os.Exit(1) } fmt.Printf("✅ 守护进程启动：认证IP=%s | 认证MAC=%s\n", ipAddr, macAddr) // 主循环 for { if shouldSkipLogin(&Config{ StudentMode: studentMode, }) { time.Sleep(1 * time.Second) continue } if !isNetworkOK() { fmt.Println("⚠️ 检测到断网，正在重新登录...") login(&Config{ User: user, Password: passwd, NetType: nettype, }, ipAddr, macAddr) } time.Sleep(1 * time.Second) } }函数解释：main() 是程序的主入口函数，负责处理命令行参数、加载配置、启动守护进程并持续监控网络状态。关键点：使用flag包解析命令行参数支持两种配置方式：通过配置文件或命令行参数对配置进行校验，确保必要参数存在且格式正确根据配置决定使用路由器模式还是本机模式启动主循环，每秒检查网络状态当检测到断网时，调用login()函数进行重连在学生模式下，避免在特定时段重连这个函数是整个程序的"大脑"，协调所有其他函数的工作，实现断网自动重连的核心功能。使用方法你既可以从上面的源代码编译使用，也可以下载我编译好的程序。如果不放心我分发的二进制文件，要自己编译来用的话，把上面那段完整的代码保存为main.go，安装好Go语言环境，然后进行编译：go build -ldflags="-s -w" -o GXU_Net_AutoLogin main.go 或者直接运行也行：go run main.go二进制文件的话，我就直接只编译了win32的程序：GXU_Net_AutoLogin.zip对于Linux的话，我相信使用Linux的你应该知道怎么配置Go环境，源代码放在这了，你就自己编译去吧（其实真不难）。或许可以用wine运行？（bushi补充：其他常见系统架构文件已编译，文件位于Github的Release相关源代码已经放到Github仓库：github.com/Chocola-X/GXU-Net-AutoLogin
利用B站IP查询API更新访客记录IP地理信息偶然的一次机会，在群里面聊天的时候，群友指出纯真数据库的数据并不准确，并且说有一个可以免费调用的IP地理信息库——B站的IP查询API。据说还是用的ipip.net的企业版，这下得狠狠白嫖了。前置任务：修复VisitorLoggerPro插件BUG如果使用过这个插件一段时间，你就应该发现了，在国家统计图表这项，非常的混乱，似乎是只要记录的字符不一致，就会被当作不同的国家处理，例如美国加利福尼亚洛杉矶和美国纽约就被当作不同的国家列在上面。经过深挖代码斗智斗勇，我发现这个问题我无法从根本解决，因为整个代码太奇怪了，而且用的本地IP数据库也都是乱七八糟的，根本没有做好国家的细分地区分类，一股脑返回，导致所有的信息被集中在一起写进了国家字段。于是，我放弃治疗，直接在后面的调用代码中，增加分类逻辑进行分类：这是原来的代码，单独处理和提取了国内省份： foreach ($countryCountsResult as $row) { $countryName = $row['country'] ?: '未知'; $count = $row['count']; if (!isset($countryData[$countryName])) { $countryData[$countryName] = 0; $totalCountries++; } $countryData[$countryName] += $count; if (strpos($countryName, '中国') !== false) { foreach ($provinces as $province) { if (strpos($countryName, $province) !== false) { if (!isset($provinceData[$province])) { $provinceData[$province] = 0; } $provinceData[$province] += $count; break; } } } }我决定在返回国家信息的时候，进行提取合并并返回： foreach ($countryCountsResult as $row) { $rawCountry = $row['country'] ?? ''; $count = (int)($row['count'] ?? 0); if ($rawCountry === '') { $stdCountry = '未知'; } elseif (strpos($rawCountry, '中国') !== false) { // 所有含“中国”的都归为“中国” $stdCountry = '中国'; // 同时尝试提取省份 foreach ($provinces as $province) { if (strpos($rawCountry, $province) !== false) { if (!isset($provinceData[$province])) { $provinceData[$province] = 0; } $provinceData[$province] += $count; break; // 匹配到第一个即停止 } } } else { // 非中国：尝试匹配标准国家名（用于归一化，如“美国加州”→“美国”） $stdCountry = $rawCountry; // 默认保留原值 foreach ($countries as $country) { if (strpos($rawCountry, $country) !== false) { $stdCountry = $country; break; // 匹配到即停止（因列表已按长优先排序） } } } // 累计国家统计 if (!isset($countryData[$stdCountry])) { $countryData[$stdCountry] = 0; $totalCountries++; } $countryData[$stdCountry] += $count; }当然了，需要补上完整的国家地区列表： $countries = [ // 长名称国家（必须放前面） '波斯尼亚和黑塞哥维那', '赤道几内亚', '圣文森特和格林纳丁斯', '特立尼达和多巴哥', '圣多美和普林西比', '阿拉伯联合酋长国', '沙特阿拉伯', '巴布亚新几内亚', '所罗门群岛', '马绍尔群岛', '密克罗尼西亚联邦', '帕劳共和国', '瓦努阿图共和国', '基里巴斯共和国', '瑙鲁共和国', '图瓦卢', '美属萨摩亚', '英属维尔京群岛', '美属维尔京群岛', '北马里亚纳群岛', '福克兰群岛（马尔维纳斯）', '法属圭亚那', '法属波利尼西亚', '法属南部领地', '皮特凯恩群岛', '托克劳', '库克群岛', '纽埃', '新喀里多尼亚', '关岛', '百慕大', '开曼群岛', '安圭拉', '蒙特塞拉特', '荷属圣马丁', '法属圣马丁', '圣皮埃尔和密克隆', '阿森松岛', '特里斯坦-达库尼亚', // 主权国家（按字母拼音排序） '阿富汗', '阿尔巴尼亚', '阿尔及利亚', '安道尔', '安哥拉', '安提瓜和巴布达', '阿根廷', '亚美尼亚', '澳大利亚', '奥地利', '阿塞拜疆', '巴哈马', '巴林', '孟加拉国', '巴巴多斯', '白俄罗斯', '比利时', '伯利兹', '贝宁', '不丹', '玻利维亚', '博茨瓦纳', '巴西', '文莱', '保加利亚', '布基纳法索', '布隆迪', '柬埔寨', '喀麦隆', '加拿大', '佛得角', '中非', '乍得', '智利', '哥伦比亚', '科摩罗', '刚果（布）', '刚果（金）', '哥斯达黎加', '科特迪瓦', '克罗地亚', '古巴', '塞浦路斯', '捷克', '丹麦', '吉布提', '多米尼克', '多米尼加', '厄瓜多尔', '埃及', '萨尔瓦多', '赤道几内亚', '厄立特里亚', '爱沙尼亚', '斯威士兰', '埃塞俄比亚', '斐济', '芬兰', '法国', '加蓬', '冈比亚', '格鲁吉亚', '德国', '加纳', '希腊', '格林纳达', '危地马拉', '几内亚', '几内亚比绍', '圭亚那', '海地', '洪都拉斯', '匈牙利', '冰岛', '印度', '印度尼西亚', '伊朗', '伊拉克', '爱尔兰', '以色列', '意大利', '牙买加', '日本', '约旦', '哈萨克斯坦', '肯尼亚', '基里巴斯', '朝鲜', '韩国', '科威特', '吉尔吉斯斯坦', '老挝', '拉脱维亚', '黎巴嫩', '莱索托', '利比里亚', '利比亚', '列支敦士登', '立陶宛', '卢森堡', '马达加斯加', '马拉维', '马来西亚', '马尔代夫', '马里', '马耳他', '马绍尔群岛', '毛里塔尼亚', '毛里求斯', '墨西哥', '密克罗尼西亚', '摩尔多瓦', '摩纳哥', '蒙古', '黑山', '摩洛哥', '莫桑比克', '缅甸', '纳米比亚', '瑙鲁', '尼泊尔', '荷兰', '新西兰', '尼加拉瓜', '尼日尔', '尼日利亚', '北马其顿', '挪威', '阿曼', '巴基斯坦', '帕劳', '巴拿马', '巴布亚新几内亚', '巴拉圭', '秘鲁', '菲律宾', '波兰', '葡萄牙', '卡塔尔', '罗马尼亚', '俄罗斯', '卢旺达', '圣基茨和尼维斯', '圣卢西亚', '圣马力诺', '圣多美和普林西比', '沙特阿拉伯', '塞内加尔', '塞尔维亚', '塞舌尔', '塞拉利昂', '新加坡', '斯洛伐克', '斯洛文尼亚', '所罗门群岛', '索马里', '南非', '南苏丹', '西班牙', '斯里兰卡', '苏丹', '苏里南', '瑞典', '瑞士', '叙利亚', '塔吉克斯坦', '坦桑尼亚', '泰国', '东帝汶', '多哥', '汤加', '特立尼达和多巴哥', '突尼斯', '土耳其', '土库曼斯坦', '图瓦卢', '乌干达', '乌克兰', '阿联酋', '英国', '美国', '乌拉圭', '乌兹别克斯坦', '瓦努阿图', '梵蒂冈', '委内瑞拉', '越南', '也门', '赞比亚', '津巴布韦' ];虽然我感觉好像把朝鲜放进去并没有什么意义（）修复后的代码已经提交到了我的github仓库：github.com/Chocola-X/VisitorLoggerPro-Enhanced编写更新脚本脚本程序主要的任务就是从数据库里面获取IP，然后从查询API获取到IP的相关信息，再去更新本地记录。考虑到服务器已经是有php环境了，干脆就直接用php来写了。更新已记录IP的IP信息脚本#!/usr/bin/env php <?php // ========== 数据库配置 ========== $DB_HOST = 'localhost'; $DB_PORT = '3306'; $DB_NAME = 'db_name'; $DB_USER = 'db_user'; $DB_PASS = 'www.nekopara.uk'; // =============================== $API_URL_TEMPLATE = 'https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr?ip=%s'; $LOOKBACK_MINUTES = 600; //向前追溯更新的时间，单位分钟 $BATCH_SIZE = 800; //最大更新IP条数 $API_TIMEOUT = 2; $DEBUG = true; // 创建数据库连接 try { $pdo = new PDO( "mysql:host=$DB_HOST;port=$DB_PORT;dbname=$DB_NAME;charset=utf8mb4", $DB_USER, $DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); } catch (PDOException $e) { fwrite(STDERR, "❌ 数据库连接失败: " . $e->getMessage() . "\n"); exit(1); } function queryIpLocation($ip, $apiTemplate, $timeout = 5) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) { return null; } $url = sprintf($apiTemplate, urlencode($ip)); $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'header' => "User-Agent: Neko-IP-Geo-Updater/1.0\r\nAccept: application/json\r\n" ] ]); $response = @file_get_contents($url, false, $context); if ($response === false) { return null; } $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return null; } if (($data['code'] ?? -1) !== 0) { return null; } if (!isset($data['data']) || !is_array($data['data'])) { return null; } $country = trim($data['data']['country'] ?? ''); $province = trim($data['data']['province'] ?? ''); $city = trim($data['data']['city'] ?? ''); $isp = trim($data['data']['isp'] ?? ''); // 构建去重后的部件列表 $parts = []; // 国家总是加入 if (!empty($country) && $country !== 'Unknown' && $country !== '未知') { $parts[] = $country; } // 省份：仅当不等于国家时才加入 if (!empty($province) && $province !== 'Unknown' && $province !== '未知' && $province !== $country) { $parts[] = $province; } // 城市：仅当不等于国家且不等于省份时才加入 if (!empty($city) && $city !== 'Unknown' && $city !== '未知' && $city !== $country && $city !== $province) { $parts[] = $city; } // ISP 总是加入（只要非空） if (!empty($isp) && $isp !== 'Unknown' && $isp !== '未知') { $parts[] = $isp; } // 去除可能的重复（保险起见再做一次 array_unique，保持顺序） $parts = array_values(array_unique($parts)); $location = implode(' ', $parts); return $location ?: 'Unknown'; } // ====================== // 主逻辑 // ====================== $isCli = php_sapi_name() === 'cli'; $log = function($msg) use ($isCli, $DEBUG) { if (!$DEBUG) return; $line = "[" . date('Y-m-d H:i:s') . "] $msg\n"; if ($isCli) { echo $line; } else { echo htmlspecialchars($line); } }; $log("🔍 开始处理最近 {$LOOKBACK_MINUTES} 分钟内的访客记录（将 country 字段替换为完整地理位置）..."); $stmt = $pdo->prepare(" SELECT id, ip, country FROM `typecho_visitor_log` WHERE `time` >= NOW() - INTERVAL ? MINUTE ORDER BY `time` ASC LIMIT ? "); $stmt->bindValue(1, (int)$LOOKBACK_MINUTES, PDO::PARAM_INT); $stmt->bindValue(2, (int)$BATCH_SIZE, PDO::PARAM_INT); $stmt->execute(); $records = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($records)) { $log("✅ 无符合条件的记录。"); exit(0); } $log("📥 找到 " . count($records) . " 条记录，开始查询 API..."); $updateStmt = $pdo->prepare(" UPDATE `typecho_visitor_log` SET `country` = ? WHERE `id` = ? "); $updated = 0; $failed = 0; foreach ($records as $record) { $id = $record['id']; $ip = $record['ip']; $oldCountry = $record['country']; $fullLocation = queryIpLocation($ip, $API_URL_TEMPLATE, $API_TIMEOUT); if ($fullLocation !== null) { $updateStmt->execute([$fullLocation, $id]); $updated++; $log("✅ ID=$id | IP=$ip | 替换为: \"$fullLocation\""); } else { $failed++; $log("❌ ID=$id | IP=$ip | API 查询失败（保留原值: \"$oldCountry\"）"); } } $log("📊 完成：更新 $updated 条，失败 $failed 条");这个脚本用于更新已经存在的IP记录信息，已经设置了相关的参数便于操作，下面是参数解释：$DB_HOST = 'localhost'; 这部分设置数据库地址，例如localhost或192.168.6.6$DB_PORT = '3306'; 这部分是数据库端口号，例如默认端口3306$DB_NAME = 'db_name'; 这部分设置数据库名称，和网站设置的保持一致即可$DB_USER = 'db_user'; 这部分设置数据库用户名，和网站设置的保持一致即可$DB_PASS = 'www.nekopara.uk'; 这部分设置数据库密码，和网站设置的保持一致即可$LOOKBACK_MINUTES = 600; 这部分是往前追溯更新时间设置，也就是设置更新你执行脚本的这个时间之前的多少分钟内的数据$BATCH_SIZE = 800;这部分是设置最大更新IP数量大小，防止IP太多导致长时间占用运行$API_TIMEOUT = 2; 这是API访问超时时间限制，设置2秒即可，如果网络不稳定可以改大一点把以上的脚本修改好对应信息后，保存为一个php文件（例如update_ip_info.php）丢到服务器的一个目录里面（例如/data/ip_info/），然后进入目录执行：php82 update_ip_info.php耐心等待数据更新完毕即可，如果设置数目较大会需要等待较长时间，可以考虑用tumx来挂着运行。自动更新记录脚本话说回来，我们总不能每次都手动上服务器执行这个更新脚本吧？所以，我们需要一个自动化，批量更新多个网站的脚本：<?php // ========== 多站点数据库配置 ========== $sites = [ [ 'name' => 'www.nekopara.uk', 'host' => 'localhost', 'port' => '3306', 'dbname' => 'typecho', 'user' => 'typecho', 'pass' => 'www.nekopara.uk', ], [ 'name' => 'demo.nekopara.uk', 'host' => 'localhost', 'port' => '3306', 'dbname' => 'demo_typecho', 'user' => 'demo_typecho', 'pass' => 'demo.nekopara.uk', ], // 可继续添加更多站点... ]; // ===================================== $API_URL_TEMPLATE = 'https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr?ip=%s'; $LOOKBACK_MINUTES = 10; $BATCH_SIZE = 500; $API_TIMEOUT = 2; $DEBUG = true; $isCli = php_sapi_name() === 'cli'; $log = function($msg) use ($isCli, $DEBUG) { if (!$DEBUG) return; $line = "[" . date('Y-m-d H:i:s') . "] $msg\n"; if ($isCli) { echo $line; } else { echo htmlspecialchars($line); } }; function queryIpLocation($ip, $apiTemplate, $timeout = 5) { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) { return null; } $url = sprintf($apiTemplate, urlencode($ip)); $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'header' => "User-Agent: Neko-IP-Geo-Updater/1.0\r\nAccept: application/json\r\n" ] ]); $response = @file_get_contents($url, false, $context); if ($response === false) { return null; } $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return null; } if (($data['code'] ?? -1) !== 0) { return null; } if (!isset($data['data']) || !is_array($data['data'])) { return null; } $country = trim($data['data']['country'] ?? ''); $province = trim($data['data']['province'] ?? ''); $city = trim($data['data']['city'] ?? ''); $isp = trim($data['data']['isp'] ?? ''); // 构建去重后的部件列表 $parts = []; // 国家总是加入 if (!empty($country) && $country !== 'Unknown' && $country !== '未知') { $parts[] = $country; } // 省份：仅当不等于国家时才加入 if (!empty($province) && $province !== 'Unknown' && $province !== '未知' && $province !== $country) { $parts[] = $province; } // 城市：仅当不等于国家且不等于省份时才加入 if (!empty($city) && $city !== 'Unknown' && $city !== '未知' && $city !== $country && $city !== $province) { $parts[] = $city; } // ISP 总是加入（只要非空） if (!empty($isp) && $isp !== 'Unknown' && $isp !== '未知') { $parts[] = $isp; } // 去除可能的重复（保险起见再做一次 array_unique，保持顺序） $parts = array_values(array_unique($parts)); $location = implode(' ', $parts); return $location ?: 'Unknown'; } foreach ($sites as $site) { $log("🌐 开始处理站点: {$site['name']} (数据库: {$site['dbname']})"); try { $pdo = new PDO( "mysql:host={$site['host']};port={$site['port']};dbname={$site['dbname']};charset=utf8mb4", $site['user'], $site['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); } catch (PDOException $e) { $log("❌ 连接失败 ({$site['name']}): " . $e->getMessage()); continue; // 跳过这个站点，继续下一个 } // 查询近期记录 $stmt = $pdo->prepare(" SELECT id, ip, country FROM `typecho_visitor_log` WHERE `time` >= NOW() - INTERVAL ? MINUTE ORDER BY `time` ASC LIMIT ? "); $stmt->bindValue(1, (int)$LOOKBACK_MINUTES, PDO::PARAM_INT); $stmt->bindValue(2, (int)$BATCH_SIZE, PDO::PARAM_INT); $stmt->execute(); $records = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($records)) { $log("✅ {$site['name']}: 无新记录"); continue; } $updateStmt = $pdo->prepare(" UPDATE `typecho_visitor_log` SET `country` = ? WHERE `id` = ? "); $updated = 0; $failed = 0; foreach ($records as $record) { $id = $record['id']; $ip = $record['ip']; $oldCountry = $record['country']; $fullLocation = queryIpLocation($ip, $API_URL_TEMPLATE, $API_TIMEOUT); if ($fullLocation !== null) { $updateStmt->execute([$fullLocation, $id]); $updated++; $log("✅ {$site['name']} | ID=$id | IP=$ip => \"$fullLocation\""); } else { $failed++; $log("❌ {$site['name']} | ID=$id | IP=$ip | API 失败（保留: \"$oldCountry\"）"); } } $log("📊 {$site['name']} 完成：更新 $updated 条，失败 $failed 条\n"); } $log("🏁 所有站点处理完毕！");这里我设置了往前更新10分钟内的记录，更新IP记录数量限制在500以内，配合cron计划任务，就可以实现定时自动更新访问IP信息：*/10 * * * * /usr/bin/php82 "/data/ip_info/cron_update_ip.php"这样设置后，服务器就会每10分钟自动去获取IP信息并更新数据库了。
用 Python 破解网易云 iframe 反爬，全自动构建本地音乐库
Python实现网易云音乐自动抓取整理事情的缘起很简单，我的jellyfin音乐库听腻的差不多了，想抓点新歌。虽然说之前已经抓取过1000多首歌曲，但是很多是我因为强迫症，为了媒体库的完整性，把整个专辑都抓下来了，实际上常听的就是两三百首。正因如此，我就想给我的媒体库补充点库存，于是开始折腾，有了这篇文章。媒体库的具体搭建教程可以去看这篇文章：自建Jellyfin私人媒体服务器，告别屌毛网易云！实现思路上次因为没能自动化搞下载，且因为心急，所以几乎是半自动半手动收集的，费时费力，花费了差不多一个星期。而这次，我决定使用Python自动化完成，下面是工作流实现思路：当遇到喜欢的音乐后，我就保存其网易云的URL到文本文件，例如https://music.163.com/#/song?id=1499823984你可以点开看看是什么曲子（，每个曲子放一行，随听随收集。读取这个歌曲列表文件，逐行解析，获取专辑的ID，例如https://music.163.com/#/album?id=99010037，取出99010037，以及歌手KOTOKO。然后去解析专辑的页面，获取其中所有歌曲的song?id，例如1499823984，1499826814，1499823975和1499826811。调用第三方API进行歌曲解析，获取音频文件，歌词和封面。例如这次我用的就是https://jx.chksz.top/按照jellyfin要求的格式保存歌曲文件，也就是歌手/专辑/01.歌曲.flac，如：[chocola@Neko-Server KOTOKO]$ tree . └── ネコぱら vol.4 OP ⁄ ED ├── 01.SWEET×SWEET.flac ├── 01.SWEET×SWEET.lrc ├── 02.NEGAIGOTO.flac ├── 02.NEGAIGOTO.lrc ├── 03.SWEET×SWEET (Instrumental).flac ├── 03.SWEET×SWEET (Instrumental).lrc ├── 04.NEGAIGOTO (Instrumental).flac ├── 04.NEGAIGOTO (Instrumental).lrc └── cover.jpg最后还要打上艺术家，专辑，歌曲名的标签既然确定了思路和方法，接下来开始逐个攻破吧！观察网页布局打开音乐网页，开启F12大法，检查元素，迅速就定位到了目标：其实，后面发现在页面的meta标签其实也有信息：对于专辑部分，抓取列表就好了：看起来并不难，使用Python的requests模块获取html，然后解析内容就好了。尝试实现爬虫逻辑和LLM友好交流后，得出了每一部分的代码逻辑函数，例如解析歌曲页面信息的：def extract_music_info(url): # 模拟浏览器请求头 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } try: # 获取HTML内容 response = requests.get(url, headers=headers) response.raise_for_status() html = response.text print(html) # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"提取失败: {str(e)}") return None但是好巧不巧，我写完代码的第二天，正准备要爬取，却返回了空值。我就纳闷了，我昨天还能运行的代码，怎么今天就出问题了？然后重新检查了页面，才发现第二天后网易云把页面嵌入了iframe标签，让我的代码全部报废（还真是赶到点子上了呢，由于这部分加载实现需要模拟出来浏览器逻辑，所以我决定还是上selenium吧，尽管我并不喜欢这种臃肿低效的爬虫方式，但是现在看来如果想快速完成任务只能这样了。iframe标签反爬这招太狠了！selenium 搭配 firefox 大战 iframe配置和熟悉selenium首先，安装selenium:pip install selenium然后要去下载selenium的firefox驱动，前往github.com/mozilla/geckodriver/releases下载对应的版本，这里我下载的是geckodriver-v0.36.0-linux64.tar.gz，下载好后放在项目文件夹下。要用selenium首先要熟悉基本的用法，以下是简单示例：from selenium import webdriver from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.options import Options # 新增导入 # 创建 Service 对象，指定 geckodriver 的路径 service = Service(executable_path='./geckodriver') # 创建 Options 对象，指定 Firefox 的二进制路径 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动，同时传入 service 和 options driver = webdriver.Firefox(service=service, options=options) driver.get('https://www.nekopara.uk/') print(driver.page_source) driver.quit()大概就是获取了页面渲染后的html代码然后返回。进行爬取下面是爬取的实现函数，由于如果不关闭浏览器的话，获取的iframe标签还是第一次打开页面的内容，没办法复用，只能每次爬取完成后关闭浏览器然后重新打开。def extract_music_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML html = driver.page_source # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def get_album_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML iframe_html = driver.page_source soup = BeautifulSoup(iframe_html, 'html.parser') song_dict = {} # 遍历所有歌曲行（tr元素） for tr in soup.find_all('tr'): # 提取页面序号（在中） num_span = tr.find('span', class_='num') if not num_span: continue try: page_num = int(num_span.get_text(strip=True)) except ValueError: continue # 提取歌曲ID（在中） a_tag = tr.find('a', href=lambda href: href and href.startswith('/song?id=')) if not a_tag: continue href = a_tag['href'] song_id = href.split('=')[1] song_dict[page_num] = song_id return song_dict except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit()通过driver.switch_to.frame(iframe)才能获取到iframe内容，不然就和直接用python的requests模块的请求结果是一样的。这样我们就能获取到了艺术家名称，专辑名称，歌曲ID，可以进行下一步工作了。确实感觉自动化浏览器访问这个是爬虫的终极手段了，几乎什么都可以爬取了，只不过效率确实不如直接发送请求。从第三方解析服务API获取信息这里我用了https://jx.chksz.top/这个网站的解析服务，是在搜索引擎里面随便找的。简单看了一下，好像是一个初中生的，真是年少有为呢！一番F12后，抓取到了API的地址：https://api.kxzjoker.cn/api/163_music接下来就是构造请求并下载的函数了：def download_song_and_metadata(save_path:str,song_url: str,number_info:str, level: str = "jymaster") -> bool: # === 第一步：请求你的解析 API === api_endpoint = "https://api.kxzjoker.cn/api/163_music" form_data = { "url": song_url, "level": level, "type": "json" } try: print("正在请求歌曲信息...") resp = requests.post(api_endpoint, data=form_data, timeout=15) resp.raise_for_status() data = resp.json() if data.get("status") != 200: print(f"API 返回错误状态: {data.get('status')}") return False # 提取字段 title = sanitize_filename(data["name"]) artist = data["ar_name"] album = sanitize_filename(data["al_name"]) audio_url = data["url"] cover_url = data["pic"] lyric = data.get("lyric", "") tlyric = data.get("tlyric", "") # === 第二步：创建专辑目录 === album_dir = os.path.join(save_path, album) os.makedirs(album_dir, exist_ok=True) # === 第三步：下载音频文件 === audio_ext = ".flac" if ".flac" in audio_url else ".mp3" audio_path = os.path.join(album_dir, f"{number_info}.{title}{audio_ext}") if not os.path.exists(audio_path): print(f"正在下载音频: {title}{audio_ext}") audio_resp = requests.get(audio_url, stream=True, timeout=30) audio_resp.raise_for_status() with open(audio_path, "wb") as f: for chunk in audio_resp.iter_content(chunk_size=8192): f.write(chunk) else: print(f"音频已存在，跳过: {audio_path}") # === 第四步：合并歌词并保存 .lrc === lrc_path = os.path.join(album_dir, f"{number_info}.{title}.lrc") if tlyric != None: combined_lyric = merge_lyrics(lyric, tlyric) else: combined_lyric = lyric with open(lrc_path, "w", encoding="utf-8") as f: f.write(combined_lyric) print(f"歌词已保存: {lrc_path}") # === 第五步：下载封面为 folder.jpg === cover_path = os.path.join(album_dir, "folder.jpg") if not os.path.exists(cover_path): print("正在下载专辑封面...") cover_resp = requests.get(cover_url, timeout=15) cover_resp.raise_for_status() with open(cover_path, "wb") as f: f.write(cover_resp.content) print(f"封面已保存: {cover_path}") else: print("封面已存在，跳过") print(f"\n✅ 下载完成！路径: {album_dir}") return True except Exception as e: print(f"❌ 下载失败: {e}") return False def merge_lyrics(original: str, translation: str) -> str: """ 合并原文和翻译歌词，生成双语 LRC。 假设时间戳一致，将翻译放在下一行。 """ orig_lines = original.strip().splitlines() trans_dict = {} # 解析翻译歌词的时间 -> 文本 for line in translation.strip().splitlines(): if "]:" in line or line.startswith("[by:"): continue parts = line.split("]", 1) if len(parts) == 2: time_tag, text = parts[0] + "]", parts[1].strip() if text: trans_dict[time_tag] = text # 合并 merged = [] for line in orig_lines: merged.append(line) if line.startswith("[") and "]" in line: time_tag = line.split("]", 1)[0] + "]" if time_tag in trans_dict: merged.append(time_tag + trans_dict[time_tag]) return "\n".join(merged)最终缝合得到了各个部分的组件，然后就是缝合工作了，只需要传入一个分行的歌曲的URL文本文件，程序就可以全自动下载全部的相关文件，并且根据存放规范保存。下面是最终缝合代码：import requests import re from urllib.parse import urlparse from selenium import webdriver from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.options import Options # 新增导入 from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import os from bs4 import BeautifulSoup def get_album_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML iframe_html = driver.page_source soup = BeautifulSoup(iframe_html, 'html.parser') song_dict = {} # 遍历所有歌曲行（tr元素） for tr in soup.find_all('tr'): # 提取页面序号（在中） num_span = tr.find('span', class_='num') if not num_span: continue try: page_num = int(num_span.get_text(strip=True)) except ValueError: continue # 提取歌曲ID（在中） a_tag = tr.find('a', href=lambda href: href and href.startswith('/song?id=')) if not a_tag: continue href = a_tag['href'] song_id = href.split('=')[1] song_dict[page_num] = song_id return song_dict except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def sanitize_filename(filename: str) -> str: """移除文件名中的非法字符""" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename).strip() def download_song_and_metadata(save_path:str,song_url: str,number_info:str, level: str = "jymaster") -> bool: # === 第一步：请求你的解析 API === api_endpoint = "https://api.kxzjoker.cn/api/163_music" form_data = { "url": song_url, "level": level, "type": "json" } try: print("正在请求歌曲信息...") resp = requests.post(api_endpoint, data=form_data, timeout=15) resp.raise_for_status() data = resp.json() if data.get("status") != 200: print(f"API 返回错误状态: {data.get('status')}") return False # 提取字段 title = sanitize_filename(data["name"]) artist = data["ar_name"] album = sanitize_filename(data["al_name"]) audio_url = data["url"] cover_url = data["pic"] lyric = data.get("lyric", "") tlyric = data.get("tlyric", "") # === 第二步：创建专辑目录 === album_dir = os.path.join(save_path, album) os.makedirs(album_dir, exist_ok=True) # === 第三步：下载音频文件 === audio_ext = ".flac" if ".flac" in audio_url else ".mp3" audio_path = os.path.join(album_dir, f"{number_info}.{title}{audio_ext}") if not os.path.exists(audio_path): print(f"正在下载音频: {title}{audio_ext}") audio_resp = requests.get(audio_url, stream=True, timeout=30) audio_resp.raise_for_status() with open(audio_path, "wb") as f: for chunk in audio_resp.iter_content(chunk_size=8192): f.write(chunk) else: print(f"音频已存在，跳过: {audio_path}") # === 第四步：合并歌词并保存 .lrc === lrc_path = os.path.join(album_dir, f"{number_info}.{title}.lrc") if tlyric != None: combined_lyric = merge_lyrics(lyric, tlyric) else: combined_lyric = lyric with open(lrc_path, "w", encoding="utf-8") as f: f.write(combined_lyric) print(f"歌词已保存: {lrc_path}") # === 第五步：下载封面为 folder.jpg === cover_path = os.path.join(album_dir, "folder.jpg") if not os.path.exists(cover_path): print("正在下载专辑封面...") cover_resp = requests.get(cover_url, timeout=15) cover_resp.raise_for_status() with open(cover_path, "wb") as f: f.write(cover_resp.content) print(f"封面已保存: {cover_path}") else: print("封面已存在，跳过") print(f"\n✅ 下载完成！路径: {album_dir}") return True except Exception as e: print(f"❌ 下载失败: {e}") return False def merge_lyrics(original: str, translation: str) -> str: """ 合并原文和翻译歌词，生成双语 LRC。 假设时间戳一致，将翻译放在下一行。 """ orig_lines = original.strip().splitlines() trans_dict = {} # 解析翻译歌词的时间 -> 文本 for line in translation.strip().splitlines(): if "]:" in line or line.startswith("[by:"): continue parts = line.split("]", 1) if len(parts) == 2: time_tag, text = parts[0] + "]", parts[1].strip() if text: trans_dict[time_tag] = text # 合并 merged = [] for line in orig_lines: merged.append(line) if line.startswith("[") and "]" in line: time_tag = line.split("]", 1)[0] + "]" if time_tag in trans_dict: merged.append(time_tag + trans_dict[time_tag]) return "\n".join(merged) def extract_music_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML html = driver.page_source # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def download_album_from_song(url): info_result = extract_music_info(url) if info_result: print(f"艺术家: {info_result[0]}") print(f"专辑名称: {info_result[1]}") print(f"专辑ID: {info_result[2]}") else: print("提取失败，检查URL或页面结构") download_path = str(os.getcwd()+"/"+info_result[0]) songid_s = get_album_info("https://music.163.com/#/album?id="+info_result[2]) for number_info,songid_in in songid_s.items(): to_download_url = "https://music.163.com/song?id="+songid_in download_song_and_metadata(download_path,to_download_url,str(number_info), level="jymaster") def extract_songlist(filename: str): """ 主函数：读取指定文件并逐行处理。 :param filename: 要读取的 txt 文件名（位于当前目录） """ # 获取当前工作目录 current_dir = os.getcwd() file_path = os.path.join(current_dir, filename) # 检查文件是否存在 if not os.path.isfile(file_path): print(f"错误：文件 '{filename}' 不存在于当前目录 ({current_dir})") return try: with open(file_path, 'r', encoding='utf-8') as file: for line_number, line in enumerate(file, start=1): # 可选：跳过空行 if line.strip() == '': continue try: download_album_from_song(line) except Exception as e: print(f"处理第 {line_number} 行时出错: {e}") except Exception as e: print(f"读取文件时发生错误: {e}") # 使用示例 if __name__ == "__main__": txt_filename = "collect_list.txt" extract_songlist(txt_filename)装好需要的Python模块后，只需要在当前目录保存好collect_list.txt，放置浏览器驱动geckodriver，确保路径都正确，就可以Python启动了。过一会就会在当前目录获得想要的专辑文件了。整理音乐文件标签由于下载的flac缺少元数据，所以需要根据路径和文件名进行补全。这时候我也意识到我之前爬取的那一批媒体库文件并没有打好标签，导致jellyfin的展示效果还是有一定混乱。下面这个Python程序将会修复这个问题#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 将文件名中的曲号与曲名写入音频文件元数据（支持 FLAC/MP3/M4A） 用法示例： python3 write_title_and_track.py /home/chocola/data/files/Music --dry-run python3 write_title_and_track.py /home/chocola/data/files/Music --force """ import os, sys, re, argparse from pathlib import Path # try import mutagen, auto-install if missing (user mode) try: from mutagen import File from mutagen.flac import FLAC from mutagen.id3 import ID3, TIT2, TRCK, ID3NoHeaderError from mutagen.mp4 import MP4, MP4Tags except Exception: print("mutagen 未检测到，尝试安装（用户模式 pip3 install --user mutagen）...") import subprocess subprocess.check_call([sys.executable, "-m", "pip", "install", "--user", "mutagen"]) from mutagen import File from mutagen.flac import FLAC from mutagen.id3 import ID3, TIT2, TRCK, ID3NoHeaderError from mutagen.mp4 import MP4, MP4Tags AUDIO_EXTS = ('.flac', '.mp3', '.m4a', '.mp4', '.aac', '.ogg', '.opus') # will skip unknown handlers # regex to extract track number and title # matches: "01. Title", "1 - Title", "01 Title", "01_ Title", "01.Title", leading zeros allowed _leading_track_re = re.compile(r'^\s*(\d{1,3})\s*[\.\-_\s:]+\s*(.+)$') # fallback: "01Title" (no separator) _leading_track_nosep_re = re.compile(r'^\s*(\d{1,3})(.+)$') # normalize fullwidth parentheses and brackets to ascii def normalize_parens(s: str) -> str: return (s .replace('（', '(').replace('）', ')') .replace('［', '[').replace('］', ']') .replace('【', '[').replace('】', ']') ) def parse_filename_to_track_title(fn: str): name = fn # remove extension already handled by caller usually name = name.strip() name = normalize_parens(name) # try main regex m = _leading_track_re.match(name) if m: tr = m.group(1).lstrip('0') or '0' title = m.group(2).strip() return tr, title m2 = _leading_track_nosep_re.match(name) if m2: tr = m2.group(1).lstrip('0') or '0' title = m2.group(2).strip() # but if title is short (like only 1 char) and looks numeric, ignore return tr, title # No leading track detected: return (None, full name) return None, name def write_flac(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): audio = FLAC(path) tags = audio.tags or {} changed = False # ensure artist/album set if available if artist and (not tags.get('artist') or tags.get('artist') != [artist]): if not dry_run: audio['artist'] = [artist] changed = True if album and (not tags.get('album') or tags.get('album') != [album]): if not dry_run: audio['album'] = [album] changed = True # title cur_title = tags.get('title', None) if (force or not cur_title) and title: if not dry_run: audio['title'] = [title] changed = True # tracknumber cur_track = tags.get('tracknumber', None) if tracknum and (force or not cur_track): if not dry_run: audio['tracknumber'] = [str(tracknum)] changed = True if changed and not dry_run: audio.save() return changed def write_mp3(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): try: audio = ID3(path) except ID3NoHeaderError: audio = ID3() changed = False # artist / album frames: TPE1 / TALB if artist: cur = audio.getall('TPE1') cur_text = [f.text for f in cur] if cur else [] if force or not cur_text: if not dry_run: audio.delall('TPE1') audio.add(TIT2(encoding=3, text=[])) if False else None # noop to avoid pyflakes audio.add(ID3().get('TPE1')) if False else None # we'll set via frames below # set title cur_title = audio.getall('TIT2') if (force or not cur_title) and title: if not dry_run: audio.delall('TIT2') audio.add(TIT2(encoding=3, text=title)) changed = True # set tracknumber TRCK cur_tr = audio.getall('TRCK') if tracknum and (force or not cur_tr): if not dry_run: audio.delall('TRCK') audio.add(TRCK(encoding=3, text=str(tracknum))) changed = True # artist/album set via text frames TPE1/TALB if artist: cur_a = audio.getall('TPE1') if (force or not cur_a): if not dry_run: audio.delall('TPE1') audio.add(ID3().get('TPE1')) if False else None from mutagen.id3 import TPE1, TALB audio.add(TPE1(encoding=3, text=artist)) changed = True if album: cur_al = audio.getall('TALB') if (force or not cur_al): if not dry_run: from mutagen.id3 import TALB audio.delall('TALB') audio.add(TALB(encoding=3, text=album)) changed = True if changed and not dry_run: audio.save(path) return changed def write_mp4(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): audio = MP4(path) tags = audio.tags or MP4Tags() changed = False # artist if artist and (tags.get('\xa9ART') != [artist]): if not dry_run: tags['\xa9ART'] = [artist] changed = True # album if album and (tags.get('\xa9alb') != [album]): if not dry_run: tags['\xa9alb'] = [album] changed = True # title if (force or not tags.get('\xa9nam')) and title: if not dry_run: tags['\xa9nam'] = [title] changed = True # track number (trkn) is a tuple (track, total) if tracknum and (force or not tags.get('trkn')): try: tr = int(tracknum) except Exception: tr = None if tr: if not dry_run: tags['trkn'] = [(tr, 0)] changed = True if changed and not dry_run: audio.tags = tags audio.save() return changed def process_file(path: Path, root: Path, dry_run=False, force=False): # derive artist/album from path: root/.../Artist/Album/file rel = path.relative_to(root) parts = rel.parts if len(parts) < 3: # not deep enough; skip return False, "path_too_shallow" album = parts[-2] artist = parts[-3] stem = path.stem # name without extension # Normalize Unicode around parentheses stem = normalize_parens(stem) tracknum, title = parse_filename_to_track_title(stem) # if no tracknum found, leave tracknum None and title entire stem if tracknum is None: # remove trailing bracketed translations, e.g. "SongName (中文翻译)" -> keep whole as title title = stem.strip() # prefer title if present # write depending on extension ext = path.suffix.lower() try: if ext == '.flac': changed = write_flac(path, artist, album, tracknum, title, dry_run=dry_run, force=force) elif ext == '.mp3': changed = write_mp3(path, artist, album, tracknum, title, dry_run=dry_run, force=force) elif ext in ('.m4a', '.mp4'): changed = write_mp4(path, artist, album, tracknum, title, dry_run=dry_run, force=force) else: # unsupported ext -> try generic mutagen write as fallback audio = File(path) if audio is None: return False, "unsupported_format" # best-effort: set common tags changed = False if hasattr(audio, 'tags'): if force or not audio.tags.get('title'): if not dry_run: audio.tags['title'] = title changed = True if force or not audio.tags.get('artist'): if not dry_run: audio.tags['artist'] = artist changed = True if force or not audio.tags.get('album'): if not dry_run: audio.tags['album'] = album changed = True if tracknum and (force or not audio.tags.get('tracknumber')): if not dry_run: audio.tags['tracknumber'] = str(tracknum) changed = True if changed and not dry_run: audio.save() return changed, "done" return changed, "done" except Exception as e: return False, f"error:{e}" def main(): p = argparse.ArgumentParser(description="从文件名写入 title 与 track 到音频元数据（支持 FLAC/MP3/M4A）") p.add_argument("root", help="音乐库根目录（会递归）") p.add_argument("--dry-run", action="store_true", help="仅预览，不写入") p.add_argument("--force", action="store_true", help="强制覆盖已有标签") p.add_argument("--limit", type=int, default=0, help="仅处理前 N 个文件（测试用）") args = p.parse_args() root = Path(args.root).resolve() if not root.is_dir(): print("错误：root 不是目录:", root); sys.exit(1) total = 0 changed_cnt = 0 for dirpath, dirnames, filenames in os.walk(root): for fn in filenames: if not fn.lower().endswith(AUDIO_EXTS): continue total += 1 path = Path(dirpath) / fn changed, reason = process_file(path, root, dry_run=args.dry_run, force=args.force) if changed: changed_cnt += 1 print(("DRY " if args.dry_run else "") + f"修改: {path} ({reason})") # optional limit if args.limit and total >= args.limit: break if args.limit and total >= args.limit: break print(f"完成: 总文件={total}, 修改={changed_cnt}") if __name__ == "__main__": main()直接把音乐库路径传入进去执行即可，例如：python tag_music.py /run/media/chocola/6T-DATA/Projects/Get_Information/Fuck_Netease/Music/注意，目录下的存放格式要和之前说的一样，也是就是艺术家/专辑/01.歌曲名.flac执行完成后，你就会得到一个自己的媒体库啦！但是，这样就结束了，吗？揭露网易云伪母带骗局如果你有留意到的话，你应该会发现我抓取的歌曲参数，选择的是最高音质，也就是jymaster超清母带。母带的定义是什么呢？母带（Master）是音乐制作流程中的最终成品音频文件，用于后续复制、发行和流媒体分发。它是在混音完成后，通过母带处理（Mastering）对整体音频进行精细调整，包括均衡、压缩、限幅、动态控制等，以确保在各种播放设备上具有一致的音质表现，并符合行业标准的响度与格式要求。母带不仅是物理或数字发行的源文件，也代表了作品最终的声音面貌，是连接创作与听众的关键环节。高质量的母带能提升音乐的整体清晰度、空间感和专业度。看着好像说很好啊，但是实际上，一般情况下发行的音乐文件大部分都是CD导出的，也就是平时我们能接触到的音质天花板就是CD无损音质16-bit/44.1kHz。而网易云平台上的所谓母带，绝大部分都是基于CD音质后期超上去的，保存这些庞大的文件不但对听感没有太大提升，反而还更加占用储存空间。这也是和群里面玩耳机的群友交流才得知的信息。如果不信，你可以安装个sox分析一下音频频谱图。sox 1.SWEET×SWEET.flac -n spectrogram -o ./infox.png可以很明显的发现能量主要集中在0–20kHz区域，呈现黄白色/橙色/红色。而到了20–90kHz区域则是深紫色/蓝色，几乎无信号。如果还有疑问，这里还有一张更加高清的频谱分析图：sox "1.SWEET×SWEET.flac" -n spectrogram -o "high_res_spectrogram.png" -x 4000 -y 1200这是很明显的后期人为超分或者算法AI超分插值上去的痕迹，所以没有必要保留这些无用的数据，可以安全地降低回CD无损音质。利用脚本转换文件节省空间因为这些媒体文件确实占用不小，占用了我NAS 160G的储存空间，问AI说至少可以压缩60%-70%的空间，所以我决定整顿这个媒体库。把下面的代码保存为convert.py，记得修改 原媒体库路径 和 新媒体库路径。#!/usr/bin/env python3 import os import shutil import subprocess from pathlib import Path from mutagen import File as MutagenFile # ================== 配置区域（修改你的路径） ================== SOURCE = Path("/home/chocola/data/files/Music/") # 原媒体库路径（结尾必须加 /） TARGET = Path("/mnt/4T-RAID1/data/music_01/") # 新媒体库路径（结尾必须加 /） # ============================================================ # 确保路径以斜杠结尾 if str(SOURCE)[-1] != '/': SOURCE = SOURCE / '' if str(TARGET)[-1] != '/': TARGET = TARGET / '' # 验证路径 if not SOURCE.exists(): raise FileNotFoundError(f"源路径不存在: {SOURCE}") TARGET.mkdir(parents=True, exist_ok=True) def get_sample_rate(file_path): """安全获取FLAC文件采样率（双保险：mutagen + mediainfo）""" # 尝试用mutagen try: audio = MutagenFile(file_path) if audio and 'audio' in audio: return audio.info.sample_rate except Exception: pass # mutagen失败，尝试mediainfo # 尝试用mediainfo（关键修复！） try: result = subprocess.run( ['mediainfo', '--Inform=Audio;%SamplingRate%', str(file_path)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=True ) rate_str = result.stdout.decode().strip() # 处理不同格式：如 "48000" 或 "48.0 kHz" if 'kHz' in rate_str: rate_str = rate_str.replace(' kHz', '').replace('.', '') return int(rate_str) except Exception as e: print(f"⚠ 无法获取采样率 (file: {file_path}, error: {e})") return 0 # 0表示无效采样率 def is_high_resolution_flac(file_path): """检查FLAC是否为高采样率 (>44.1kHz)""" sample_rate = get_sample_rate(file_path) if sample_rate <= 0: print(f"⚠ 跳过文件 (无效采样率): {file_path}") return False print(f" [采样率] {file_path}: {sample_rate} Hz") # 调试日志 return sample_rate > 44100 # >44.1kHz def convert_flac(file_path): """安全转换FLAC文件（100%避免ffmpeg交互）""" rel_path = file_path.relative_to(SOURCE) target_path = TARGET / rel_path # 确保目标目录存在 target_path.parent.mkdir(parents=True, exist_ok=True) # 用mutagen安全提取元数据 audio = MutagenFile(file_path) title = audio.get('title', [os.path.splitext(file_path.name)[0]])[0] artist = audio.get('artist', [''])[0] album = audio.get('album', [''])[0] # 使用ffmpeg安全转码 cmd = [ 'ffmpeg', '-y', '-i', str(file_path), '-map', '0:a:0', '-c:a', 'flac', '-sample_fmt', 's16', '-ar', '44100', '-metadata', f'title={title}', '-metadata', f'artist={artist}', '-metadata', f'album={album}', str(target_path) ] try: subprocess.run( cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) print(f"✅ 转码完成: {rel_path} (原采样率: {get_sample_rate(file_path)}Hz)") except subprocess.CalledProcessError as e: print(f"❌ 转码失败 (回退到原文件): {file_path}") shutil.copy2(file_path, target_path) def copy_file(file_path): """安全复制非FLAC文件""" rel_path = file_path.relative_to(SOURCE) target_path = TARGET / rel_path target_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(file_path, target_path) print(f"✅ 复制: {rel_path}") def main(): print(f"开始处理媒体库: {SOURCE} → {TARGET}") print("⚠ 正在处理特殊字符文件名 (日语/中文)...") for root, _, files in os.walk(SOURCE): for file in files: file_path = Path(root) / file if file.startswith('.'): # 跳过隐藏文件 continue if file.lower().endswith('.flac'): if is_high_resolution_flac(file_path): convert_flac(file_path) else: copy_file(file_path) elif file.lower().endswith(('.mp3', '.lrc', '.jpg', '.png')): copy_file(file_path) print("\n🎉 操作完成！新库已生成到:", TARGET) print(" - 伪高解析FLAC (48kHz+/96kHz/192kHz) 已转码为CD质量") print(" - CD质量FLAC/MP3/辅助文件已原样复制") if __name__ == "__main__": try: main() except Exception as e: print(f"❌ 紧急错误: {e}") exit(1)执行完成后，媒体库直接从160G变成49G，瞬间舒服了。不过话说回来，我今天打算尝试登陆网易云看看什么情况，居然说我的账户可能被运营商二次放号，确认是我的账号后，发现居然解封了。之前是一直卡着说要我刷脸实名认证，我不想搞，一气之下自建媒体库，不知不觉就用了一年了。不过后面我也不会再用网易云了，维护好自己的媒体库就行了，省得动不动自己的歌单又有一片变成VIP曲子听不了搞得我红温（
在Linux中自定义N卡的风扇转速曲线我个人对硬件的温度比较重视，认为是越低越好，显卡这玩意超过70度我觉得就已经是高温了。由于我已经主力使用Linux系统很久了，一直都是在需要给显卡上强度的时候，直接拉满显卡风扇。但是后面发现不是每时每刻都需要跑满的，没有负载的时候风扇还在呼呼吹确实是有点浪费了，于是就想搓一个bash脚本，用于显卡风扇控制。脚本本体没错，又是面向LLM编程！#!/bin/bash # 初始化上一次的风扇转速 last_speed=-1 while true; do # 获取 GPU 温度（单位：摄氏度） temp=$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits -i 0) # 判断温度范围并计算风扇转速 if (( $(echo "$temp <= 40" | bc -l) )); then current_speed=40 elif (( $(echo "$temp >= 70" | bc -l) )); then current_speed=100 else # 线性插值：温度在 40~70°C 之间，速度从 40%~100% current_speed=$(echo "scale=0; 40 + 2 * ($temp - 40)" | bc) fi # 如果转速发生变化，才执行设置并输出调试信息 if [ "$current_speed" -ne "$last_speed" ]; then echo "GPU Temp: $temp°C → Fan Speed: $current_speed%" nvidia-settings -a "[gpu:0]/GPUFanControlState=1" -a "[fan:0]/GPUTargetFanSpeed=$current_speed" > /dev/null 2>&1 last_speed=$current_speed fi # 每秒检测一次 sleep 1 done脚本说明脚本逻辑：40度以下，转速40%，40度到70度，线性递增，到70度及以上时为100%。至于为什么不是停转？因为我的是涡轮卡，没法停（ ，需要的话请自行修改。使用方法：把代码保存为set_GPU_fan.sh，赋予执行权限后用root执行。建议配合tmux挂在后台。注意事项：这个脚本只针对单GPU，单风扇的情况，如果你是多GPU或者nvidia-settings厘米出现了多个风扇控制，就需要进行脚本的完善和修改。我这个脚本只在有桌面环境的情况下测试过没问题，对于无GUI的情况，我是没有测试过的，据说是要运行X Server才可以运行nvidia-settings。希望这个脚本可以更好的帮助你控制显卡温度。
二次开发 Typecho 后台服务器状态查看插件
发现Typecho后台服务器状态插件在翻Typecho插件的时候，我偶然发现了两双筷子写的Typecho后台服务器状态查看插件，于是就尝试了一下下。这个插件在桌面端网页没问题，感觉还挺不错的。但是一旦变成移动端，后台的布局就会出现问题，逼死强迫症。而且，CPU利用率有时候还会变成0.9999999999%这种离谱的数据，我寻思着就不能取两位小数好看一点吗？于是，我决定，自己修改代码，把服务器信息移动到页面底部的位置，并且修改显示数据的逻辑，让信息看着更加舒适一点。（其实也不完全是我写，是面向LLM编程 X）修改代码的过程首先，需要把注册方法的地方改为页面底部Typecho_Plugin::factory('admin/footer.php')->begin = array('LinuxInfo_Plugin', 'render');然后就是修改一下插入的样式：echo <<<STR $info_str STR;因为我觉的系统信息用鼠标悬浮预览不明显，于是修改为直接独立一行把信息全部展示：return <<<SYS_INFO 服务器信息: 持续运行：{$uptime}，Web服务器：{$_SERVER['SERVER_SOFTWARE']}，PHP 版本：{$php['version']}，SAPI 接口：{$php['sapi']}，PHP内存限制：{$php['mem_max']}，上传文件限制：{$php['up_max']} SYS_INFO;以及让LLM帮忙完善了CPU信息获取的逻辑：private static function cpu_info(){ function GetCoreInformation() { $data = file('/proc/stat'); $cores = []; foreach( $data as $line ) { if( preg_match('/^cpu[0-9]/', $line) ) { $info = explode(' ', $line); $cores[] = [ 'user' => $info[1], 'nice' => $info[2], 'sys' => $info[3], 'idle' => $info[4], 'iowait' => $info[5], 'irq' => $info[6], 'softirq' => $info[7] ]; } } return $cores; } function GetCpuPercentages($stat1, $stat2) { if(count($stat1) !== count($stat2)){ return; } $cpus = []; for( $i = 0, $l = count($stat1); $i < $l; $i++) { $dif = []; $dif['user'] = $stat2[$i]['user'] - $stat1[$i]['user']; $dif['nice'] = $stat2[$i]['nice'] - $stat1[$i]['nice']; $dif['sys'] = $stat2[$i]['sys'] - $stat1[$i]['sys']; $dif['idle'] = $stat2[$i]['idle'] - $stat1[$i]['idle']; $dif['iowait'] = $stat2[$i]['iowait'] - $stat1[$i]['iowait']; $dif['irq'] = $stat2[$i]['irq'] - $stat1[$i]['irq']; $dif['softirq'] = $stat2[$i]['softirq'] - $stat1[$i]['softirq']; $total = array_sum($dif); $cpu = []; foreach($dif as $x=>$y) $cpu[$x] = round($y / $total * 100, 2); $cpus['CPU' . $i] = $cpu; } return $cpus; } $stat1 = GetCoreInformation(); sleep(1); $stat2 = GetCoreInformation(); $data = GetCpuPercentages($stat1, $stat2); $overall_cpu_usage = 0; $core_details = []; foreach ($data as $core => $usage) { $core_usage = 100 - $usage['idle']; $overall_cpu_usage += $core_usage; $core_details[] = "{$core}: " . number_format($core_usage, 2) . "%"; } $overall_cpu_usage = round($overall_cpu_usage / count($data), 2); $core_details_str = implode(", ", $core_details); $sys_load = sys_getloadavg(); $sys_load_1min = number_format($sys_load[0], 3); $sys_load_5min = number_format($sys_load[1], 3); $sys_load_15min = number_format($sys_load[2], 3); return <<<CPU_INFO CPU: {$overall_cpu_usage} % CPU_INFO; }原来的逻辑只是判断单个CPU的占有率，当CPU数量为多个的时候就会不准确，这里我优化成了整体利用率，并且悬停时会显示每个CPU的占用率信息。改造效果虽然不是彻底重构，但是我对改造后的插件还是比较满意的。手机的网页端也舒服了。修改后的插件分享修改后的插件已经放到了GitHub：Typecho-LinuxInfo反正源码也不多，我这里也可以直接放出来：Plugin.php:<?php if (!defined('__TYPECHO_ROOT_DIR__')) exit; /** * 查看系统信息，仅限 Linux 系统，基于两双筷子的插件进行二次开发，使用帮助 * * @package LinuxInfo * @author GTX690战术核显卡导弹 * @version 1.1.0 * @link https://www.nekopara.uk */ class LinuxInfo_Plugin implements Typecho_Plugin_Interface { // 插件名称 protected static $plugin_name = 'LinuxInfo'; /** * 激活插件方法,如果激活失败,直接抛出异常 * * @access public * @return void * @throws Typecho_Plugin_Exception */ public static function activate() { // 如果当前系统不是 linux 则抛出错误 if(PHP_OS != 'Linux') { throw new Typecho_Plugin_Exception(_t("此插件仅支持 Linux 系统")); } // 在后端页面底部 注册方法 Typecho_Plugin::factory('admin/footer.php')->begin = array('LinuxInfo_Plugin', 'render'); } /** * 禁用插件方法,如果禁用失败,直接抛出异常 * * @static * @access public * @return void * @throws Typecho_Plugin_Exception */ public static function deactivate(){} /** * 获取插件配置面板 * * @access public * @param Typecho_Widget_Helper_Form $form 配置面板 * @return void */ public static function config(Typecho_Widget_Helper_Form $form) { /** 分类名称 */ $show_item = array( 'cpu_info' => _t('CPU使用情况') . '（ CPU 使用率、系统负载）', 'mem_info' => _t('内存使用情况') . '（内存使用率、SWAP交换分区）', 'disk_info' => _t('磁盘使用情况') . '（磁盘使用率、磁盘已用空间、磁盘总空间）', 'sys_info' => _t('服务器信息') . ' （系统运行时间、服务器软件、PHP 版本、SAPI 接口、PHP 内存限制、上传文件限制）', ); // 设置默认选中 $show_option_value = ['cpu_info','mem_info','disk_info']; $ShowInfo = new Typecho_Widget_Helper_Form_Element_Checkbox('show_items',$show_item, $show_option_value, _t('选择显示系统信息')); $form->addInput($ShowInfo->multiMode()); $separator = new Typecho_Widget_Helper_Form_Element_Text('separator', NULL, ' ★ ', _t('分隔符'), '设置数据项分割字符'); $form->addInput($separator); } /** * 个人用户的配置面板 * * @access public * @param Typecho_Widget_Helper_Form $form * @return void */ public static function personalConfig(Typecho_Widget_Helper_Form $form){} /** * 插件实现方法 * * @access public * @return void */ public static function render() { $show_items = Typecho_Widget::widget('Widget_Options')->plugin(self::$plugin_name)->show_items; $separator = Typecho_Widget::widget('Widget_Options')->plugin(self::$plugin_name)->separator; // 获取 linux 系统消息 $info_str = ''; $show_items_max = count($show_items) - 1; foreach ($show_items as $show_item_k => $show_item) { $method = $show_item; if (method_exists(__CLASS__, $method)) { $info_str .= self::$method(); } if ($show_items_max != $show_item_k) { $info_str .= $separator; } } echo <<<STR $info_str STR; } // 获取CPU信息 private static function cpu_info(){ function GetCoreInformation() { $data = file('/proc/stat'); $cores = []; foreach( $data as $line ) { if( preg_match('/^cpu[0-9]/', $line) ) { $info = explode(' ', $line); $cores[] = [ 'user' => $info[1], 'nice' => $info[2], 'sys' => $info[3], 'idle' => $info[4], 'iowait' => $info[5], 'irq' => $info[6], 'softirq' => $info[7] ]; } } return $cores; } function GetCpuPercentages($stat1, $stat2) { if(count($stat1) !== count($stat2)){ return; } $cpus = []; for( $i = 0, $l = count($stat1); $i < $l; $i++) { $dif = []; $dif['user'] = $stat2[$i]['user'] - $stat1[$i]['user']; $dif['nice'] = $stat2[$i]['nice'] - $stat1[$i]['nice']; $dif['sys'] = $stat2[$i]['sys'] - $stat1[$i]['sys']; $dif['idle'] = $stat2[$i]['idle'] - $stat1[$i]['idle']; $dif['iowait'] = $stat2[$i]['iowait'] - $stat1[$i]['iowait']; $dif['irq'] = $stat2[$i]['irq'] - $stat1[$i]['irq']; $dif['softirq'] = $stat2[$i]['softirq'] - $stat1[$i]['softirq']; $total = array_sum($dif); $cpu = []; foreach($dif as $x=>$y) $cpu[$x] = round($y / $total * 100, 2); $cpus['CPU' . $i] = $cpu; } return $cpus; } $stat1 = GetCoreInformation(); sleep(1); $stat2 = GetCoreInformation(); $data = GetCpuPercentages($stat1, $stat2); $overall_cpu_usage = 0; $core_details = []; foreach ($data as $core => $usage) { $core_usage = 100 - $usage['idle']; $overall_cpu_usage += $core_usage; $core_details[] = "{$core}: " . number_format($core_usage, 2) . "%"; } $overall_cpu_usage = round($overall_cpu_usage / count($data), 2); $core_details_str = implode(", ", $core_details); $sys_load = sys_getloadavg(); $sys_load_1min = number_format($sys_load[0], 3); $sys_load_5min = number_format($sys_load[1], 3); $sys_load_15min = number_format($sys_load[2], 3); return <<<CPU_INFO CPU: {$overall_cpu_usage} % CPU_INFO; } // 获取内存信息 private static function mem_info(){ if (false === ($str = @file("/proc/meminfo"))) return false; $str = implode("", $str); preg_match_all("/MemTotal\s{0,}\:+\s{0,}([\d\.]+).+?MemFree\s{0,}\:+\s{0,}([\d\.]+).+?Cached\s{0,}\:+\s{0,}([\d\.]+).+?SwapTotal\s{0,}\:+\s{0,}([\d\.]+).+?SwapFree\s{0,}\:+\s{0,}([\d\.]+)/s", $str, $buf); preg_match_all("/Buffers\s{0,}\:+\s{0,}([\d\.]+)/s", $str, $buffers); $mem_info['memTotal'] = round($buf[1][0]/1024, 2); $mem_info['memFree'] = round($buf[2][0]/1024, 2); $mem_info['memBuffers'] = round($buffers[1][0]/1024, 2); $mem_info['memCached'] = round($buf[3][0]/1024, 2); $mem_info['memUsed'] = $mem_info['memTotal']-$mem_info['memFree']; $mem_info['memPercent'] = (floatval($mem_info['memTotal'])!=0)?round($mem_info['memUsed']/$mem_info['memTotal']*100,2):0; $mem_info['memRealUsed'] = $mem_info['memTotal'] - $mem_info['memFree'] - $mem_info['memCached'] - $mem_info['memBuffers']; //真实内存使用 $mem_info['memRealFree'] = $mem_info['memTotal'] - $mem_info['memRealUsed']; //真实空闲 $mem_info['memRealPercent'] = (floatval($mem_info['memTotal'])!=0)?round($mem_info['memRealUsed']/$mem_info['memTotal']*100,2):0; //真实内存使用率 $mem_info['memCachedPercent'] = (floatval($mem_info['memCached'])!=0)?round($mem_info['memCached']/$mem_info['memTotal']*100,2):0; //Cached内存使用率 $mem_info['swapTotal'] = round($buf[4][0]/1024, 2); $mem_info['swapFree'] = round($buf[5][0]/1024, 2); $mem_info['swapUsed'] = round($mem_info['swapTotal']-$mem_info['swapFree'], 2); $mem_info['swapPercent'] = (floatval($mem_info['swapTotal'])!=0)?round($mem_info['swapUsed']/$mem_info['swapTotal']*100,2):0; $mem_per = round($mem_info['memRealUsed'] / $mem_info['memTotal'] * 100, 2); return <<<MEM_INFO 内存: {$mem_per} % MEM_INFO; } // 获取磁盘信息 private static function disk_info(){ $dt = round(@disk_total_space(".")/(1024*1024*1024), 2); //总 $df = round(@disk_free_space(".")/(1024*1024*1024), 2); //可用 $du = $dt-$df; //已用 $disk_use = round($du / $dt * 100, 2); return <<<DISK_INFO 硬盘: {$disk_use} % DISK_INFO; } // 获取 系统信息 private static function sys_info() { if (false === ($str = @file("/proc/uptime"))) return false; $str = explode(" ", implode("", $str)); $str = trim($str[0]); $min = $str / 60; $hours = $min / 60; $days = floor($hours / 24); $hours = floor($hours - ($days * 24)); $min = floor($min - ($days * 60 * 24) - ($hours * 60)); if ($days !== 0) $uptime = $days."天"; if ($hours !== 0) $uptime .= $hours."小时"; $uptime .= $min."分钟"; $php['version'] = PHP_VERSION; $php['sapi'] = php_sapi_name(); $php['mem_max'] = get_cfg_var('memory_limit'); $php['up_max'] = get_cfg_var('upload_max_filesize'); return <<<SYS_INFO 服务器信息: 持续运行：{$uptime}，Web服务器：{$_SERVER['SERVER_SOFTWARE']}，PHP 版本：{$php['version']}，SAPI 接口：{$php['sapi']}，PHP内存限制：{$php['mem_max']}，上传文件限制：{$php['up_max']} SYS_INFO; } }优先使用Github上面的吧，万一那天我修复一些问题，可能只会修改Github上面的仓库的内容而忘记修改这篇文章。当然，我希望以后不会出现问题。使用方法将GitHub上面的Plugin.php或者这个页面的Plugin.php内容保存下载，放到Typecho的插件目录usr/plugins/下的LinuxInfo文件夹（需要自己创建），例如：typecho/usr/plugins/LinuxInfo/Plugin.php，进入Typecho后台启用插件即可。默认不显示系统详细信息，需要自己勾选显示才会展示。不过，需要注意的是，这个插件仅可用于Linux系统，Windows系统不适用。谁用Windows做服务器啊？！！不会吧不会吧？最后还是要感谢一下两双筷子写出来这个有意思的插件。
浪潮SA5212M4风扇调速Python脚本写脚本的原因之前每次开机都要进BMC把风扇转速调低，不然吵得要死。但是又不能调得太低，因为如果调的太低怕一不留神温度就飙上去了，导致硬件出问题。正好前几天看到了一个别人用Python写的简易风扇一键调节脚本，但是只能设置固定转速，没办法根据温度动态调节。于是我重构了这个脚本，做到了根据温度动态调节风扇转速。环境要求系统要求：Linux（Windows不可用，因为没有sensors软件包）软件环境：Python3环境，sensors软件包（用于获取CPU温度）Python源代码import subprocess import re import http.client import time # 全局变量用于存储会话连接和令牌 conn = None session_cookie = None def login(IP, User, Password): global conn, session_cookie if conn is not None: conn.close() # 关闭旧连接 conn = http.client.HTTPConnection(IP) payload = "WEBVAR_USERNAME=" + User + "&WEBVAR_PASSWORD=" + Password headers = { 'Content-Type': "application/x-www-form-urlencoded", 'Origin': "http://{}".format(IP), 'Referer': "http://{}/index.html".format(IP), 'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0", } conn.request("POST", "/rpc/WEBSES/create.asp", payload, headers) response = conn.getresponse().read().decode("utf-8") match = re.search(r"'SESSION_COOKIE' : '([^']*)'", response) if match: session_cookie = match.group(1) else: raise ValueError("SESSION_COOKIE not found in the string") def set_fan_speed(IP, fan_speeds): global conn, session_cookie # 设置风扇为手动模式 payload1 = "MODE=1" headers1 = { 'Content-Type': "application/x-www-form-urlencoded", 'Cookie': "BMC_IP_ADDR={}; SessionCookie=".format(IP) + session_cookie, } conn.request("POST", "/rpc/setfanmode.asp", payload1, headers1) conn.getresponse() # 调整风扇转速 headers2 = { 'Content-Type': "application/x-www-form-urlencoded", 'Cookie': "SessionCookie=" + session_cookie + "; BMC_IP_ADDR={}".format(IP), } for fan_ID, Speed_x in fan_speeds.items(): payload2 = "ID=" + fan_ID + "&PERCENT=" + Speed_x conn.request("POST", "/rpc/setfanspeed.asp", payload2, headers2) conn.getresponse() print("调整风扇{}转速{}%".format(fan_ID, Speed_x)) def get_package_temperatures(): output = subprocess.check_output(['sensors']).decode('utf-8') pattern = re.compile( r'^Package id (\d+):\s+\+([\d.]+)\.?\s*°C', re.MULTILINE ) matches = pattern.findall(output) result = {f"Package id {package_id}": int(float(temp_str)) for package_id, temp_str in matches} return result def compute_fan_speed(temp): if temp <= 45: return 10 elif temp >= 70: return 100 else: return int(10 + (temp - 45) * (90 / (70 - 45))) last_temp = None if __name__ == "__main__": IP = "192.168.0.6" #服务器BMC内网IP地址 User = "admin" #服务器BMC管理员账号 Password = "admin" #服务器BMC管理员密码 # 首次登录 login(IP, User, Password) while True: try: temperatures = get_package_temperatures() max_temp = max(temperatures.values()) if last_temp is None or abs(max_temp - last_temp) >= 2: fan_speed = str(compute_fan_speed(max_temp)) set_fan_speed(IP, {"0": fan_speed, "2": str(compute_fan_speed(max_temp)+3), "4": fan_speed, "6": fan_speed}) last_temp = max_temp print(temperatures, max_temp) else: continue #print(f"温度变化不足2度 ({last_temp}℃ -> {max_temp}℃)，跳过风扇转速调整.") except Exception as e: print(f"遇到错误: {e}") # 发生错误时重新登录 login(IP, User, Password) time.sleep(2)代码说明风扇转速的策略是，45度以下风扇转速10%，超过45度开始线性增加，70度时风扇转速100%。可以根据需要修改compute_fan_speed函数的逻辑。02号风扇默认比其他风扇转速高3%，因为这个风扇对应的是CPU1，为了安装显卡换了散热器，并且阻挡较多温度较高，所以风扇转速设置偏高一点平衡两边温度。如果不需要安装显卡可以修改set_fan_speed(IP, {"0": fan_speed, "2": str(compute_fan_speed(max_temp)+3), "4": fan_speed, "6": fan_speed})这部分，风扇速度传入的是一个字典，键值对是风扇ID:转速百分比。如果你的服务器是其他有八个风扇的型号（例如SA5112M4），那只需要吧传入的字典部分改成八个对应的键值对即可。希望这个风扇调节脚本可以帮到你。
Alpine Linux 部署私人在线聊天室 Fiora引言众所周知，在现代的信息社会，想保持不被监控是非常困难的事情。由于种种原因，总有人会希望监控你。例如，某些视频平台（例如B站）为了提高视频推送准确度，不惜用各种手段刺探用户喜好，虽然我不好说是用了什么手段，因为没有调查就没有发言权，但是有我这种感受的人估计不在少数。我就有过类似的经历：在QQ群谈论一个事物（例如洋垃圾平台），然后打开B站首页给你推的就是刚刚你谈论过的平台相关的内容，细思极恐啊兄弟们！还有一些购物网站也会这样，为了分析用户喜好，也会想尽各种办法来刺探用户的兴趣。我也有过刚谈论完某个感兴趣的物品，淘宝或者闲鱼一打开就是相关内容。给人各种不爽。值得一提的是，在我们日常生活中，对个人隐私泄漏最严重的设备是——手机。手机也是我最鄙视的电子产品之一，尤其是现在国内手机厂商开始搞封闭，限制用户自己安装应用，非要从他们提供的如同公厕一般的垃圾应用商店去下载APP。无语至极！你想换个系统？或者删掉自带的垃圾应用（例如广告浏览器）？没门，新的国内安卓系统已经给你堵死了，解锁Bootloader更是不可能。既然都要封闭起来给用户喂屎，为什么我不选择应用商店环境更好的苹果？扯远了。。。那么，在现在这个信息化时代，还有没有办法说悄悄话呢？答案是肯定的，绝对有办法，而且方法很多。例如：用记事本写下相关的消息，然后使用约定好的密码加密压缩打包后发送。或者，如果有能力的，可以自己写一套简单的文本消息加密算法。而这次，我打算尝试搭建一个有趣的开源项目——FioraFiora是什么？Fiora是由碎碎酱使用node.js，react和socket.io打造的一款在线聊天室，可以自己托管运行，具有较高的隐私安全性。简单来说就有点类似与简单的QQ，但是服务是有你自己进行托管的。可以在一定程度上规避监控和审查。所以适合用来键（bushi不过这个项目似乎不再维护了，最后一次更新已经是两年前的2023年了，不过只是更新了Readme，代码最后一次更新已经是2022年了。。。Fiora的搭建项目的文档有详细的搭建教程和方法，至于为什么我还要写这篇博客，那是因为我遇到了一些问题，按照官方的搭建教程文档无法进行下去，所以再次整理总结经验。注意，本教程操作系统环境为Alpine Linux 3.20!搭建环境准备我们需要做的事情有：安装 Node.js安装 MongoDB安装 redis安装Node.js和redis比较简单，一条命令搞定：apk add redis npm nodejs但是，安装MongoDB就相对麻烦一点了，由于新版的Alpine将MongoDB移出了软件包列表，我们只能通过旧版的软件源安装。首先，编辑apk的软件源：nano /etc/apk/repositories添加Alpine 3.9的软件源：http://mirrors.ustc.edu.cn/alpine/v3.9/main http://mirrors.ustc.edu.cn/alpine/v3.9/community然后安装MongoDB：apk update apk add mongodb安装完成后，一定，一定，一定要删除掉刚刚添加的软件源，不然后期安装软件可能导致系统崩溃！！！然后启动相关服务：rc-update add mongodb default rc-update add redis default rc-service mongodb start rc-service redis start运行项目先克隆项目到本地：git clone https://github.com/yinxin630/fiora.git -b master然后安装yarn：npm install -g yarn安装项目依赖：yarn install注意，这一步可能会因为网络的原因报错，因为依赖在GitHub上面，GitHub国内访问经常不稳定，所以可能需要挂代理才能正常安装依赖。终端代理：export https_proxy=http://192.168.0.18:20171 export http_proxy=http://192.168.0.18:20171然后接下来就是一个意想不到的大坑：构建客户端代码：yarn build:web然后你就会喜提如下报错：localhost:/mnt/data/fiora# yarn build:web yarn run v1.22.22 $ npx lerna run build:web --stream lerna notice cli v4.0.0 lerna info versioning independent lerna info Executing command in 1 package: "yarn run build:web" @fiora/web: warning package.json: "dependencies" has dependency "@testing-library/jest-dom" with range "^5.14.1" that collides with a dependency in "devDependencies" of the same name with version "^4.2.4" @fiora/web: $ rm -rf dist && cross-env NODE_ENV=production DOTENV_CONFIG_PATH=../../.env webpack --config build/webpack.prod.js && cp -r -f dist/fiora/* ../server/public @fiora/web: ℹ Compiling Webpack @fiora/web: Error: error:0308010C:digital envelope routines::unsupported @fiora/web: at new Hash (node:internal/crypto/hash:79:19) @fiora/web: at Object.createHash (node:crypto:139:10) @fiora/web: at BulkUpdateDecorator.hashFactory (/mnt/data/fiora/packages/web/node_modules/webpack/lib/util/createHash.js:145:18) @fiora/web: at BulkUpdateDecorator.update (/mnt/data/fiora/packages/web/node_modules/webpack/lib/util/createHash.js:46:50) @fiora/web: at RawSource.updateHash (/mnt/data/fiora/packages/web/node_modules/webpack/node_modules/webpack-sources/lib/RawSource.js:64:8) @fiora/web: at NormalModule._initBuildHash (/mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:868:17) @fiora/web: at handleParseResult (/mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:934:10) @fiora/web: at /mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:1026:4 @fiora/web: at processResult (/mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:743:11) @fiora/web: at /mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:807:5 @fiora/web: Browserslist: caniuse-lite is outdated. Please run: @fiora/web: npx browserslist@latest --update-db @fiora/web: Why you should do it regularly: @fiora/web: https://github.com/browserslist/browserslist#browsers-data-updating @fiora/web: node:internal/crypto/hash:79 @fiora/web: this[kHandle] = new _Hash(algorithm, xofLen, algorithmId, getHashCache()); @fiora/web: ^ @fiora/web: Error: error:0308010C:digital envelope routines::unsupported @fiora/web: at new Hash (node:internal/crypto/hash:79:19) @fiora/web: at Object.createHash (node:crypto:139:10) @fiora/web: at BulkUpdateDecorator.hashFactory (/mnt/data/fiora/packages/web/node_modules/webpack/lib/util/createHash.js:145:18) @fiora/web: at BulkUpdateDecorator.update (/mnt/data/fiora/packages/web/node_modules/webpack/lib/util/createHash.js:46:50) @fiora/web: at RawSource.updateHash (/mnt/data/fiora/packages/web/node_modules/webpack/node_modules/webpack-sources/lib/RawSource.js:64:8) @fiora/web: at NormalModule._initBuildHash (/mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:868:17) @fiora/web: at handleParseResult (/mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:934:10) @fiora/web: at /mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:1026:4 @fiora/web: at processResult (/mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:743:11) @fiora/web: at /mnt/data/fiora/packages/web/node_modules/webpack/lib/NormalModule.js:807:5 { @fiora/web: opensslErrorStack: [ @fiora/web: 'error:03000086:digital envelope routines::initialization error', @fiora/web: 'error:0308010C:digital envelope routines::unsupported' @fiora/web: ], @fiora/web: library: 'digital envelope routines', @fiora/web: reason: 'unsupported', @fiora/web: code: 'ERR_OSSL_EVP_UNSUPPORTED' @fiora/web: } @fiora/web: Node.js v20.15.1 @fiora/web: error Command failed with exit code 1. @fiora/web: info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. lerna ERR! yarn run build:web exited 1 in '@fiora/web' error Command failed with exit code 1. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. localhost:/mnt/data/fiora# 这错误通常与 Node.js 和 OpenSSL 的版本兼容性有关。这个错误在较新版本的 Node.js (如 v20.15.1) 中可能出现，尤其是在使用旧版本的依赖库或工具（比如 Webpack）时。问题的根本原因在于新的 Node.js 版本默认启用了更严格的 OpenSSL 配置，而一些旧的软件可能不支持这些配置。解决方案是设置一个环境变量，然后再构建：export NODE_OPTIONS=--openssl-legacy-provider yarn build:web配置 JwtSecret：echo "JwtSecret=GTX690" > .env这里你可以根据你的需求，设置秘密文本，我这里设置的是GTX690然后就是启动Fiora了：yarn start个人建议是使用Tmux这类工具挂着：tmux new -t fiora yarn start不出意外的话，至此，Fiora应该是可以正常运行了。Fiora的管理和配置相关的配置就写在项目目录下的.env文件中，以键值对的形式存在，官方有详细的说明：https://yinxin630.github.io/fiora/zh-Hans/docs/config写的很详细，我这里就不再赘述了。但是，我在尝试运行自带的管理脚本的时候，出现问题了：localhost:/mnt/data/fiora# fiora doctor /mnt/data/fiora/packages/i18n/node.index.ts:15 return languages[locale][key] || enUS[key] || key; ^ TypeError: Cannot read properties of undefined (reading 'getUserIdDescription') at Object.i18n [as default] (/mnt/data/fiora/packages/i18n/node.index.ts:15:29) at Object.<anonymous> (/mnt/data/fiora/index.ts:14:22) at Module._compile (node:internal/modules/cjs/loader:1358:14) at Module.m._compile (/mnt/data/fiora/node_modules/ts-node/src/index.ts:1225:23) at Module._extensions..js (node:internal/modules/cjs/loader:1416:10) at Object.require.extensions.<computed> [as .ts] (/mnt/data/fiora/node_modules/ts-node/src/index.ts:1228:12) at Module.load (node:internal/modules/cjs/loader:1208:32) at Function.Module._load (node:internal/modules/cjs/loader:1024:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:174:12) at main (/mnt/data/fiora/node_modules/ts-node/src/bin.ts:330:12) localhost:/mnt/data/fiora# 由于是Alpine Linux，默认最小化安装，所有有可能是缺了什么东西导致的问题。经过一番排查，我找到了问题所在——Locale支持缺失以下是解决方案：apk add libc6-compat libintl ettext export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8然后再运行管理工具就不会报错了。至此，搭建完成。配置SSL由于Fiora默认使用的是http通信，为明文通信，存在安全隐患，所以最好是用Nginx套一层反向代理实现Https的访问。为了使用Https，你需要有一个域名，或者一个固定的IP地址用于签发证书。以下是Nginx的配置文件：server { listen 80; # 修改为你的域名 server_name chat.nekopara.uk; return 301 https://$host$request_uri; } server { listen 443 ssl; # 修改为你的域名 server_name chat.nekopara.uk; # 修改为你的ssl证书位置 ssl_certificate /data/certs/nekopara_uk.pem; ssl_certificate_key /data/certs/nekopara_uk.key; ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-NginX-Proxy true; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; proxy_pass http://localhost:9200; } }配置完成即可实现Https访问DLC:修复Fiora背景覆盖问题我个人推断，应该是当时碎碎酱设计Fiora的时候，还是1080P屏幕的天下，为了实现毛玻璃效果，图片没有设置成随着浏览器窗口大小进行缩放剪裁。正因如此，导致了现在在高分辨率屏幕拉伸窗口，超过默认图片背景大小后剩余部分就是白色，十分的不好看。并且背景图也没有能随着浏览器窗口改变大小，让我很不爽。虽然我没用过nodejs，但是网页前端还是稍微懂一点的，解决这个问题很简单，为背景图的css样式添加两条css规则 backgroundPosition: center center;和backgroundSize: cover;即可。然而，这个项目似乎是用nodejs生成的css样式和html代码，给我的改造工作带来了不小的阻力。终于在阴差阳错下，我找到了对应的位置：fiora/packages/web/src/App.tsx在这个文件的第99行，就是要修改的位置，我贴出我修改那部分的代码： // 主体样式 const style = useMemo( () => ({ backgroundImage: `url(${backgroundImage})`, backgroundSize: `${backgroundWidth}px ${backgroundHeight}px`, backgroundRepeat: 'no-repeat', backgroundPosition: 'center center', backgroundSize: 'cover', }), [backgroundImage, backgroundWidth, backgroundHeight], ); // 聊天窗口样式 const childStyle = useMemo( () => ({ width: `${width * 100}%`, height: `${height * 100}%`, left: `${((1 - width) / 2) * 100}%`, top: `${((1 - height) / 2) * 100}%`, }), [width, height], ); // 模糊背景样式 const blurStyle = useMemo( () => ({ ...childStyle, }), [width, height, style, childStyle], );由于我技术水平有限，我只能把毛玻璃效果干掉了，如果要保留的话我不知道怎么设置图片的截取部分，这样子就直接让聊天背景透明了。效果还行吧。修改后的Fiora:Fiora.7z
为Alpine Linux 3.20 编译安装ZeroTier没有ZeroTier软件包！由于我云服务器使用的是Alpine Linux来最大程度节省资源用于实际服务，而ZeroTier官方没有给出Alpine Linux的安装包，所以要用点野路子才能安装上。使用官方的安装脚本 Be Like:Neko_SG:~# curl -s https://install.zerotier.com | bash *** ZeroTier Service Quick Install for Unix-like Systems *** Tested OSes / distributions: *** MacOS (10.13+) (just installs ZeroTier One.pkg) *** Debian Linux (7+) *** RedHat/CentOS Linux (6+) *** Fedora Linux (16+) *** SuSE Linux (12+) *** Mint Linux (20+) *** Kali Linux (2024.1+) *** Supported architectures vary by OS / distribution. We try to support *** every system architecture supported by the target. *** Please report problems by opening a GitHub issue or Pull Request at: *** https://github.com/zerotier/install.zerotier.com *** Please include the content of `/etc/os-release` for your distribution. *** Detecting Linux Distribution *** Unknown or unsupported distribution! Aborting. 想从包管理器安装 Be Like:Neko_SG:~# apk add zerotier fetch https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/x86_64/APKINDEX.tar.gz fetch https://dl-cdn.alpinelinux.org/alpine/latest-stable/community/x86_64/APKINDEX.tar.gz ERROR: unable to select packages: zerotier (no such package): required by: world[zerotier] Neko_SG:~# apk add zerotier-one ERROR: unable to select packages: zerotier-one (no such package): required by: world[zerotier-one] Neko_SG:~# apk search zerotier Neko_SG:~# 一个思路是去用别的发行版的软件包，解包提取二进制文件。但是由于Alpine Linux是用的C语言库是musl而不是libc，大部分发行版用点都是libc，所以不一定可以兼容。还有一个办法，那就相当硬核了，自己编译后，安装。这个方法肯定会遇到很多技术上的问题，所以也是最有挑战性的一条路子，但是，我还是决定开干。下载源代码之所以可以这么玩，是因为ZeroTier是开源的。 开源大法好！先创建一个用于干活的文件夹，然后直接从Github的Release下载源代码：mkdir /data/zerotier cd /data/zerotier wget https://github.com/zerotier/ZeroTierOne/archive/refs/tags/1.14.2.zip unzip 1.14.2.zip cd ZeroTierOne-1.14.2/准备相关环境进行编译因为Alpine Linux是轻量发行版，所以缺失的库很多，需要自己手动装。我也是边报错边安装，搞了好久。我的版本是Alpine Linux 3.20，以下是需要安装的软件包：apk add util-linux pkgconfig openssl-dev libffi cargo linux-headers g++ gcc make进行编译进入源代码目录，执行：make -j$(nproc)如果没有报错，一个会顺利在目录生成一个zerotier-one文件，然后还会有zerotier-cli和zerotier-idtool这两个软链接链接到zerotier-one。如果是这样，那就编译成功了。运行前的准备如果你直接运行。那大概率并不会出现ZeroTier的虚拟网络接口，因为相关的内核模块并没有启用。当然，这个虚拟的接口需要先加入了网络才会开启，不然也是没有的。运行这个命令开启内核模块：modprobe tun如果报错不行的话，试一下重启系统，模块要重启才能生效。但是，这样子设置的话，肯定重启就没了。所以要持久化设置，就需要修改/etc/modules文件：nano /etc/modules在文件后面加上tun：af_packet ipv6 tun这样设置后，重启也可以正常生效了。ZeroTier，启动！直接在编译好的目录下，执行./zerotier-one -d即可运行。但是，ZeroTier也还是会在/usr/lib/zerotier-one/目录下生成相关的配置文件，我个人更倾向于把那三个生成的文件放到这个目录下，跟官方的一致。不出意外的话，就能在ifconfig里面看到虚拟的接口了。编译好的文件分享如果你用的也是Alpine Linux，那你可以用我编译好的ZeroTier。这是编译好的文件下载地址：zerotier-musl.zip同样的，需要安装对应的软件包才能使用：apk add util-linux cargo linux-headers装完之后，参照上面的运行前准备部分，照做就行了。