断网自动重连！用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。希望这个脚本可以更好的帮助你控制显卡温度。
浅尝开源项目：FreeMote这是一个比较冷门的开源项目，可以看作是E-Mote的开源实现。之前我还是用Windows做主力机使用的Nekopara动态壁纸就是这个项目实现的，而现在我已经切换到Linux作为主力系统使用一坤年了，是时候想想办法把我的巧克力弄到我的桌面上了。Linux想搞动态壁纸，主要就是两种：视频和网页。而FreeMote-SDK就有网页的WebGL实现。很早之前我就尝试过一次，但是那时候技术力不足，而且没学会查看文档，失败了。这次，既然我都能写文章发出来了，那肯定是取得了阶段性的进展了。相比于Live2D，E-Mote的应用就比较少了，主要是几个Galgame在用，其中就包括NEKOPARA。而绝大部分网站的动态二次元看板娘都是Live2D实现的，E-mote几乎看不见。所以文档少，案例少，基本上是摸著石头过河。我主要是研究网页端的FreeMote，下面，就开始探索吧。Demo分析这是项目Project-AZUSA给的网页Demo project-azusa.github.io 基本上可以在网页实现E-mote的功能。不过看了一下FreeMote-SDK，文档约等于没有，只能自己根据网页Demo进行推断了。看描述，说是需要Pure(unencrypted) PSB文件，当然，此PSB不是Photoshop的文件，而是这一套工具的文件格式。为了得到这个文件，则需要FreeMote的这些工具进行转换。接下来就是那个Demo网页用到的文件，我已经爬取下来了：. ├── data │ └── emote_test2.pure.emtbytes ├── driver │ ├── emoteplayer.js │ └── FreeMoteDriver.js ├── img │ └── bg.jpg ├── index.html └── main.js 4 directories, 6 files从文件命名就可以看出，emote_test2.pure.emtbytes是模型文件。但是其格式却不是psb？但是也有可能只是换了一下后缀名。搞清楚了大概的情况，关键点就在于模型文件的获取，以及尝试去替换另一个模型。虽然FreeMote-SDK没有什么文档，但是FreeMote项目的文档还是相对比较完善的，对于生成特定的模型文件具有比较高的参考价值。Demo模型转换测试开始之前，只需要下载FreeMote的工具集即可，在Release里面就有下载了。首先是先要分析现有的文件，按照文档的说法，我决定先尝试解包实例Demo的模型文件：[chocola@chocola-sa5212m4 data]$ wine ../../Ulysses-FreeMoteToolkit-v4.0.3/PsbDecompile.exe ./emote_test2.pure.emtbytes 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Decompiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Decompiling: emote_test2.pure Done. [chocola@chocola-sa5212m4 data]$ ls emote_test2.pure emote_test2.pure.emtbytes emote_test2.pure.json emote_test2.pure.resx.json [chocola@chocola-sa5212m4 data]$ tree . ├── emote_test2.pure │ └── tex-texture.png ├── emote_test2.pure.emtbytes ├── emote_test2.pure.json └── emote_test2.pure.resx.json 2 directories, 4 files [chocola@chocola-sa5212m4 data]$ 可以看到解包后有tex-texture.png纹理图片和emote_test2.pure.json设置文件，其中纹理图片和Live2D的纹理图片看起来很像。然后，再尝试合成回psb文件：[chocola@chocola-sa5212m4 data]$ wine ../../Ulysses-FreeMoteToolkit-v4.0.3/PsBuild.exe ./emote_test2.pure.json 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Compiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Compiling emote_test2.pure ... Compile emote_test2.pure done. Done. [chocola@chocola-sa5212m4 data]$ ls emote_test2.pure emote_test2.pure.emtbytes emote_test2.pure.json emote_test2.pure.pure.psb emote_test2.pure.resx.json可以看到，得到了emote_test2.pure.pure.psb文件，尝试去替换掉原本的emote_test2.pure.emtbytes文件，看看能不能正常工作。这里需要修改main.js文件，找到文件加载路径，将emote_test2.pure.emtbytes替换为emote_test2.pure.pure.psb就可以了： // load data then, register mouse event player.promiseLoadDataFromURL("./data/emote_test2.pure.pure.psb")然后我测试了一下，一切正常，说明整个工具链是可以用的，WebGL端确实是可以使用工具生成的pure.psb文件。尝试替换模型因为项目文档里面说有psb测试文件可以供使用，存放的仓库是FreeMote.Samples，于是我就去下了一个模型：[FlattenArray][PSBv4][win]dx_ふかみ_駅員服.psb，来进行尝试。因为这个模型文件名太长了不方便，我直接重命名为test.psb了，下面就是操作：[chocola@chocola-sa5212m4 Ext_Asset]$ wine ../Ulysses-FreeMoteToolkit-v4.0.3/PsbDecompile.exe test.psb 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Decompiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Decompiling: test Done. [chocola@chocola-sa5212m4 Ext_Asset]$ tree . ├── test │ └── tex-texture.png ├── test.json ├── test.psb └── test.resx.json 2 directories, 4 files [chocola@chocola-sa5212m4 Ext_Asset]$ wine ../Ulysses-FreeMoteToolkit-v4.0.3/PsBuild.exe test.json 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Compiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Compiling test ... Compile test done. Done. [chocola@chocola-sa5212m4 Ext_Asset]$ wine ../Ulysses-FreeMoteToolkit-v4.0.3/EmtConvert.exe test.pure.psb.lz4 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Converter by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Shell type: LZ4 Done. [chocola@chocola-sa5212m4 Ext_Asset]$ wine ../Ulysses-FreeMoteToolkit-v4.0.3/FreeMoteViewer.exe test.pure.psb.decompressed.psb 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote Viewer by Ulysses, wdwxy12345@gmail.com 最后一个命令，是查看生成的模型test.pure.psb.decompressed.psb是否正常，界面大概长这样：确定无误后，我满怀信心的将模型替换到WebGL端，结果。。。居然报错了？不过细看红色框出来的地方：motion file 'template' has not adaptive spec [win] of motion file. export psb again.似乎是因为模型属性不兼容，针对win这个关键字，我检索了两个模型的json文件。好兄弟，看出来区别了吗？正常运行的那个模型spec和Platform对应的值是ems而不能运行的对应的是win，所以。。。改一下？把win全换成ems。然后再重新转换一下：[chocola@chocola-sa5212m4 Ext_Asset]$ wine ../Ulysses-FreeMoteToolkit-v4.0.3/PsBuild.exe test.json 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Compiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Compiling test ... Compile test done. Done. [chocola@chocola-sa5212m4 Ext_Asset]$ wine ../Ulysses-FreeMoteToolkit-v4.0.3/EmtConvert.exe test.pure.psb.lz4 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Converter by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Shell type: LZ4 Done.不过，似乎改完之后颜色变奇怪了？然后发现原来的那个模型文件颜色也是奇怪的，那没事了，直接开始尝试！虽然动作有点奇怪，但是至少成功了！获取猫娘模型我先不打算解包NEKOPARA，怕东西太多了，打算先拿这个项目的作者自己搞得猫娘动态壁纸开刀。对，就是我之前提到的在Windows上面用的动态壁纸。看了一下那个动态壁纸的文件，应该是Unity3D做的，那只需要解包工具就好了，使用AssetStudio就可以完美解包了。资源的文件在NekoWallpaper_Data/data.unity3d。用AssetStudioGUI打开这个文件，在Asset List选项卡中，点一下Container标题，就可以按照存放的目录排序了，这样子比较好找模型。我们就拿牛奶来开刀吧，需要的文件是milk-winter和milk-winter-b_tex000.png，把这两个文件提取出来。其中没有后缀名称这个，个人推断是psb文件。然后对没有后缀名的文件进行转换：[chocola@chocola-sa5212m4 winter]$ wine ../../../Ulysses-FreeMoteToolkit-v4.0.3/PsbDecompile.exe milk-winter 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Decompiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Decompiling: milk-winter Done. [chocola@chocola-sa5212m4 winter]$ tree . ├── milk-winter ├── milk-winter-b_tex000.png ├── milk-winter.json ├── milk-winter-resources │ └── tex-texture.png └── milk-winter.resx.json 2 directories, 5 files可以看到多了一个纹理图片，但是我查看过后发现实际上是空图片，所以，我将提取出来的图片替换掉了解包出来的空图片。然后，合成psb:[chocola@chocola-sa5212m4 winter]$ wine ../../../Ulysses-FreeMoteToolkit-v4.0.3/PsBuild.exe milk-winter.json 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Compiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Compiling milk-winter ... Compile milk-winter done. Done.但是，有一部分的模型，解包出来是没有纹理的：[chocola@chocola-sa5212m4 pajama]$ tree . ├── vanilla-pajama ├── vanilla-pajama.json └── vanilla-pajama.resx.json 1 directory, 3 files这时候就需要自己添加纹理了，先将提取出来的纹理放到同一个目录下：[chocola@chocola-sa5212m4 pajama]$ tree . ├── vanilla-pajama ├── vanilla-pajama-a-ex2-type2_tex000.png ├── vanilla-pajama.json └── vanilla-pajama.resx.json 1 directory, 4 files在生成psb文件后关联纹理：[chocola@chocola-sa5212m4 pajama]$ wine ../../../Ulysses-FreeMoteToolkit-v4.0.3/PsBuild.exe vanilla-pajama.json 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Compiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Compiling vanilla-pajama ... Compile vanilla-pajama done. Done. [chocola@chocola-sa5212m4 pajama]$ wine ../../../Ulysses-FreeMoteToolkit-v4.0.3/PsBuild.exe link vanilla-pajama.pure.psb vanilla-pajama-a-ex2-type2_tex000.png 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:mscoree:parse_supported_runtime sku=L".NETFramework,Version=v4.8" not implemented 0024:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION FreeMote PSB Compiler by Ulysses, wdwxy12345@gmail.com 18 Plugins Loaded. Link vanilla-pajama.pure done. Done. [chocola@chocola-sa5212m4 pajama]$ tree . ├── vanilla-pajama ├── vanilla-pajama-a-ex2-type2_tex000.png ├── vanilla-pajama.json ├── vanilla-pajama.pure.linked.psb ├── vanilla-pajama.pure.psb └── vanilla-pajama.resx.json 1 directory, 6 files其中，vanilla-pajama.pure.linked.psb就是最终文件了。处理完成后，可以看到，效果非常好：接下来，如前所述，改一下属性值，尝试放到网页上。真不错，下一步工作就是继续完善了，这个阶段性成果我已经很满意了！
浪潮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），那只需要吧传入的字典部分改成八个对应的键值对即可。希望这个风扇调节脚本可以帮到你。
速通Golang后的想法前段时间我处于个人兴趣目的速通了Golang的基础知识，然后就想要干些什么玩一下，想到了我QQ群里面有一个Bot还在苟活，于是有点想改造一下。机器人使用的是ZeroBot-Plugin，项目是在Github开源的，使用的是Go语言编写，这下专业对口了。正好前几天折腾LLM本地部署，尝试了一下Ollama部署各种模型，觉得挺好玩的。然后想试试看NAS上的E3-1220v2能不能跑得动。一开始为选的1.5B的超小模型，跑起来速度挺快的，于是提高难度，用了3B的模型，速度变慢了，但是还行。试了一下回答的质量，感觉简单放群里面当个玩具还是可以的。最后，我选择的模型是Qwen2.5 3B。部署Ollama去Ollama的官网下载就行，对于Linux也有安装脚本。但是我还是更喜欢自己手动部署和管理。下载Ollama前往Github的Ollama项目界面，进入Release页面根据你的实际情况进行下载。我下载的是ollama-linux-amd64.tgz设置参数运行由于Ollama默认会把相关的文件放在你用户文件夹的.ollama文件夹下，一旦下大模型，系统盘将会爆炸。如果你Linux把/home放到了单独的数据分区或者你系统盘足够大就当我没说。我们需要改一下这个默认的目录，方法很简单，就是在当前目录文件夹新建一个用于放置数据的文件夹，然后设置环境变量指定HOME的目录：export HOME=./data/ ./bin/ollama serve把以上保持为run.sh放在Ollama的目录下，然后再在目录创建一个data文件夹，运行即可把相关文件保存在data文件夹中而不会放到你的Home目录。此时，Ollama已经启动了。测试Ollama接下来，就可以按照官方给的参数运行模型了。进入bin/目录，直接运行Ollama即可。例如运行Qwen5.2 3B模型：./ollama run qwen2.5:3b等待模型下载完成就可以运行了。爆改ZeroBot-Plugin在仔细品读代码后，我感觉好像也不是特别难，于是为就想试一下自己写一个插件来调用Ollama进行聊天。设置插件首先，下载ZeroBot-Plugin的源代码，建议是去Github的Release里面下。我用的是1.7.8版本，后面的版本似乎会有内存泄漏问题，而且相应变慢了，也不知道修了没有，反正一年前我用1.8.X的时候是这样的。下面就是插件的代码了，放到plugin/nekochat目录下，其中nekochat文件夹要自己创建。然后在nekochat文件夹创建一个nekochat.go文件：package nekochat import ( "bytes" "encoding/json" "net/http" //"github.com/FloatTech/imgfactory" ctrl "github.com/FloatTech/zbpctrl" "github.com/FloatTech/zbputils/control" zero "github.com/wdvxdr1123/ZeroBot" "github.com/wdvxdr1123/ZeroBot/message" ) var ( // 定义固定的前两条消息作为范例对话 fixedMessages = []Message{ { Role: "user", Content: "从现在开始你是猫娘，名字是亚托莉，要以猫娘的方式说话和思考问题。", }, { Role: "assistant", Content: "好的喵～，主人！ ୧(๑•̀⌄•́๑)૭ 亚托莉酱知道了，有什么需要本喵帮忙呢？", }, } // 使用固定消息初始化ChatContext context = NewChatContext(fixedMessages) //默认选择通义千问2.5 3B模型 model_choose = "qwen2.5:3b" ) // 定义最大消息数为11，但是前两条消息是固定的范例对话 const maxMessages = 12 // ChatContext 结构体用于保存聊天的上下文信息 type ChatContext struct { messages []Message // 消息列表，每个元素是一个 Message 结构体 } // Message 结构体表示一条消息，包含角色（如 user 或 assistant）和内容 type Message struct { Role string `json:"role"` // 角色字段，如 "user" 或 "assistant" Content string `json:"content"` // 内容字段，实际的消息文本 } // 初始化ChatContext并设置固定的前两条消息作为范例对话 func NewChatContext(fixedMessages []Message) *ChatContext { return &ChatContext{ messages: fixedMessages, // 初始消息列表仅包含固定消息 } } // AddMessage 方法向 ChatContext 的 messages 列表中添加新消息 func (c *ChatContext) AddMessage(role, content string) { newMsg := Message{Role: role, Content: content} c.messages = append(c.messages, newMsg) if len(c.messages) > maxMessages { // 如果消息数量超过了最大限制，则移除最早的非固定消息（即索引2及其之后的消息） c.messages = append(c.messages[:2], c.messages[3:]...) } } // SendRequestAndExtractResponse 函数发送请求至指定API端点，并提取出回答内容 func (c *ChatContext) SendRequestAndExtractResponse(userMessage string) (string, error) { // 添加用户的新消息到上下文中 c.AddMessage("user", userMessage) payload, err := json.Marshal(map[string]interface{}{ "model": model_choose, "stream": false, "messages": c.messages, }) if err != nil { return "", err } resp, err := http.Post("http://localhost:11434/api/chat", "application/json", bytes.NewBuffer(payload)) if err != nil { return "", err } defer resp.Body.Close() var response Response if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return "", err } return response.Message.Content, nil } // Response 结构体定义了API响应的结构 type Response struct { Model string `json:"model"` CreatedAt string `json:"created_at"` Message Message `json:"message"` DoneReason string `json:"done_reason"` Done bool `json:"done"` TotalDuration int64 `json:"total_duration"` LoadDuration int64 `json:"load_duration"` PromptEvalCnt int `json:"prompt_eval_count"` PromptEvalDur int64 `json:"prompt_eval_duration"` EvalCnt int `json:"eval_count"` EvalDur int64 `json:"eval_duration"` } func init() { engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{ DisableOnDefault: false, Brief: "猫娘聊天", Help: "本插件调用本地Ollama运行LLM进行回复\n" + "- 呼叫猫娘+要聊天内容\n" + " 例如：呼叫猫娘Python是什么？\n" + "- 猫娘记忆消除\n" + "- 设置猫娘对话模型+模型名称(仅限主人)\n" + "- 猫娘聊天详细说明", }) engine.OnPrefix("呼叫猫娘").SetBlock(true).Handle(get_answer) engine.OnPrefix("猫娘记忆消除").SetBlock(true).Handle(clean_memory) engine.OnPrefix("设置猫娘对话模型", zero.SuperUserPermission).SetBlock(true).Handle(set_model) engine.OnPrefix("猫娘聊天详细说明").SetBlock(true).Handle(get_information) } func get_information(ctx *zero.Ctx) { ctx.SendChain(message.Text("猫娘聊天插件设置详细说明：\n", "本插件目前设置是调用本地的Ollma运行LLM进行回复，默认是使用的Qwen2.5 3B模型，如果需要其他模型可以通过设置对话模型进行改变。\n", "请在部署完成Ollama后，运行 ollama run qwen2.5:3b 进行Qwen2.5 3B模型部署。\n", "后续可以对接其他模型，例如如果要换成Qwen2.5 32B：\n", "运行 ollama run qwen2.5:32b 进行模型下载初始化，然后给Bot发送命令: 设置猫娘对话模型qwen2.5:32b\n", "详细的内容可以访问我的博客： nekopara.uk", )) } func set_model(ctx *zero.Ctx) { previous_model := model_choose temp_choose := ctx.State["args"].(string) if temp_choose == "" { ctx.SendChain(message.Text("设置失败：没有填写模型名称\n", "可以发送“/用法 nekochat”查看使用方法！", )) return } model_choose = temp_choose ctx.SendChain(message.Text("已将模型 ", previous_model, " 更换为：", model_choose)) } // 消除模型记忆上下文 func clean_memory(ctx *zero.Ctx) { //question := ctx.State["args"].(string) name := ctx.Event.Sender.NickName context = NewChatContext(fixedMessages) ctx.SendChain(message.Text("回复 ", name, " :\n", "猫猫的记忆已经消除喵～开始新的聊天吧！", )) } // 回答问题或聊天对话 func get_answer(ctx *zero.Ctx) { question := ctx.State["args"].(string) name := ctx.Event.Sender.NickName if question == "" { ctx.SendChain(message.Text("回复 ", name, " :\n", "可以发送“/用法 nekochat”查看使用方法！", )) return } responseContent, err := context.SendRequestAndExtractResponse(question) if err != nil { ctx.SendChain(message.Text("回复 ", name, " :\n", "获取回答出错了喵！", err, )) return } if responseContent == "" { ctx.SendChain(message.Text("回复 ", name, " :\n", "获取回答出错了喵！", )) return } ctx.SendChain(message.Text("回复 ", name, " 喵:\n", "", responseContent, )) } 记得要放到中优先级里面。编译好像是需要Go的1.20版本，新版有一些插件不兼容。这里我把Go放到了项目外面一层的文件夹，运行编译命令：../go/bin/go build -ldflags="-s -w" -o ZeroBot-Plugin-1.7.8-MOD然后就可以执行了：./ZeroBot-Plugin-1.7.8-MOD -c config.jsonconfig.json根据项目的Readme设置即可。
为什么学Go?其实之前就有点想学，因为听说这个编程语言，既有Python的简单和方便，又有C的运行高效。实际上第一次接触到GO,是因为向自己改一下ZeroBot-Plugin这个项目的一点点代码，也就是CPU核心读取不正常的问题，我找LLM修复了一下面向LLM编程(bushi。看了看代码，有Python的基础，挺多能看懂的。这也坚定了我想要学Golang的想法。于是，这个寒假，在整理完成歌曲库，升级完成NAS后闲得蛋疼，我决定开始学习GO。本页面会随着我的学习进度逐步更新，权当是当个笔记了。第一个Ciallo WorLd！一般编程语言学习，第一个程序都是Hello World，但是这次我向整点不一样的：package main import "fmt" func main() { fmt.Println("Ciallo World!") }另外，有一个写代码小技巧，选取多行，按Tab可以整行缩进，Shift+Tab可以反向操作。Ctrl+/可以多行进行注释，对Debug很有用。变量与数据类型局部变量和全局变量局部变量示例：package main import "fmt" func main() { var ss int = 233 fmt.Println("Ciallo World!",ss) }全局变量示例：package main import "fmt" var ss int = 233 func main() { fmt.Println("Ciallo World!",ss) }或者这样，更加简洁：package main import "fmt" var( ss = 2333 st = "sxsxsx" fl = 1234.555 fl2 = 2323E+2 ) func main() { fmt.Println("Ciallo World!",ss,st,fl.fl2) }值得注意的是，在Go中，如果变量只声明而不使用，编译就会报错。int数据类型在Go语言中，int 类型与 int8, int16, int32, 和 int64 都是用来表示整数的数据类型，但它们之间存在一些关键区别，主要体现在大小、范围和平台依赖性上。下面是这些类型的区别列表说明：类型占用空间数值范围适用场景int88位（1字节）-128 到 127需要节省内存且数值范围较小的场景int1616位（2字节）-32,768 到 32,767比int8更大的范围，但仍需控制内存使用时int3232位（4字节）-2,147,483,648 到 2,147,483,647处理更广泛的数值，例如时间戳或计算密集型应用int6464位（8字节）-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807非常大的整数值或者需要精确到极大值的应用场景int取决于平台（32位系统上为4字节，在64位系统上为8字节）范围取决于系统的架构大小不考虑具体大小的通用情况，追求代码的可移植性和简洁性声明变量：直接赋值var a int8 = 10 // int8 类型变量a被赋予值10 var b int16 = -200 // int16 类型变量b被赋予值-200 var c int32 = 30000 // int32 类型变量c被赋予值30000 var d int64 = 400000 // int64 类型变量d被赋予值400000 var e int = -500 // 根据系统架构决定大小的int类型变量e被赋予值-500使用短变量声明f := int8(100) // 使用短变量声明为int8类型变量f赋值100 g := int16(-30000) // 使用短变量声明为int16类型变量g赋值-30000 h := int32(50000) // 使用短变量声明为int32类型变量h赋值50000 i := int64(600000) // 使用短变量声明为int64类型变量i赋值600000 j := int(-600) // 使用短变量声明为int类型变量j赋值-600float数据类型包括float32和float64：类型占用空间精度数值范围（大约）适用场景float3232位（4字节）小数点后约7位有效数字±1.5 × 10^-45 到 ±3.4 × 10^38对内存使用敏感且不需要高精度的场合，例如图形处理、物理仿真等需要大量浮点计算但对精度要求不是极高的应用float6464位（8字节）小数点后约15位有效数字±5.0 × 10^-324 到 ±1.7 × 10^308需要高精度的数值计算，如科学计算、金融计算等声明变量：直接赋值var k float32 = 123.456 // float32 类型变量k被赋予值123.456 var l float64 = 789.101112 // float64 类型变量l被赋予值789.101112使用短变量声明m := float32(1234.5678) // 使用短变量声明为float32类型变量m赋值1234.5678 n := float64(9876.54321) // 使用短变量声明为float64类型变量n赋值9876.54321其实赋值的时候也可以使用科学记数法：var a float = 888E+2 var b float = 888E-2上述分别的值是88800和8.88。string数据类型就是字符串嘛，常见的赋值方式有这些：直接赋值最简单直接的方式是通过字面量直接给变量赋值。s1 := "Hello, World!"使用 var 关键字声明并赋值可以使用 var 来声明一个 string 类型的变量，并且可以在声明时或之后进行赋值。var s2 string s2 = "Hello, Go!" // 或者同时声明和初始化 var s3 string = "Another Hello"多行字符串使用反引号（ ` ）可以定义多行字符串，这种方式下字符串内的换行符和其他空白字符都会被保留。s4 := `这是一个 多行 字符串`字符串拼接可以通过 + 操作符将多个字符串连接在一起。firstName := "John" lastName := "Doe" fullName := firstName + " " + lastName // 结果为 "John Doe"使用 fmt 包格式化字符串可以利用 fmt.Sprintf 函数来根据指定格式生成字符串。age := 30 description := fmt.Sprintf("My age is %d", age) // 结果为 "My age is 30"bool数据类型基本概念类型：bool取值：只有两个值，true 和 false默认值：false（如果变量声明但未赋值）声明和初始化可以使用 var 关键字来声明一个布尔变量，并且可以在声明时或之后进行赋值。var isActive bool // 声明一个布尔变量，初始值为 false isActive = true // 赋值为 true // 或者同时声明并初始化 isEnabled := false // 使用短变量声明并初始化逻辑运算符布尔值通常与逻辑运算符一起使用来进行更复杂的条件判断：逻辑与 (&&)：当两边的操作数都为 true 时，结果为 true。result := true && false // 结果为 false逻辑或 (||)：只要一边的操作数为 true，结果就为 true。result := true || false // 结果为 true逻辑非 (!)：对操作数的布尔值进行反转。result := !true // 结果为 false在控制结构中的应用布尔值最常用于控制结构如 if、for 等语句中，以决定程序的执行路径。if isActive { fmt.Println("The feature is active.") } for i := 0; i < 10 && isActive; i++ { fmt.Println(i) }比较运算符的结果比较运算符（如 ==, !=, <, >, <=, >=）的结果也是布尔值，这使得它们非常适合用于条件判断。isEqual := (5 == 5) // 结果为 true isGreater := (10 > 5) // 结果为 true基本数据类型的互相转换整型与整型之间的转换可以直接通过类型转换操作来进行不同整型之间的转换。需要注意的是，当从小范围类型向大范围类型转换时（如int8到int32）通常是安全的，反之则可能会导致数据丢失。var a int8 = 10 b := int16(a) // 将int8类型的a转换为int16类型的b整型与浮点型之间的转换可以使用类型转换将整型转换为浮点型，反之亦然。注意，从浮点型转换为整型时，小数部分会被截断。var c int = 5 d := float64(c) // 将int类型的c转换为float64类型的d var e float64 = 5.75 f := int(e) // 将float64类型的e转换为int类型的f，结果为5，小数部分被截断字符串与数字之间的转换字符串转数字：可以使用标准库中的 strconv 包提供的函数来实现。import "strconv" s := "123" i, err := strconv.Atoi(s) // 将字符串s转换为int类型的i if err != nil { // 处理错误 } f, err := strconv.ParseFloat("123.45", 64) // 将字符串"123.45"转换为float64类型的f数字转字符串：同样可以使用 strconv 包中的函数。import "strconv" num := 123 str := strconv.Itoa(num) // 将int类型的num转换为字符串 fnum := 123.45 fstr := strconv.FormatFloat(fnum, 'f', 6, 64) // 将float64类型的fnum转换为字符串，格式化参数根据需求调整指针基本概念取地址符(&)：用于获取变量的内存地址。解引用符(*)：用于访问指针所指向的变量的实际值。指针类型声明：使用*Type的形式声明一个指针类型的变量，其中Type是你希望指针指向的数据类型。代码示例下面是一个简单的例子，演示了如何声明指针、获取变量的地址以及如何通过指针访问或修改变量的值：package main import "fmt" func main() { // 声明一个整型变量a并赋值为10 a := 10 // 使用&获取变量a的地址，并将该地址赋给指针变量p var p *int = &a // 输出变量a的值、变量a的地址和指针p的内容（即a的地址） fmt.Println("Value of a:", a) fmt.Println("Address of a:", &a) fmt.Println("Value of p (address of a):", p) // 使用*来解引用指针p，以访问它指向的值 fmt.Println("Value pointed to by p:", *p) // 修改指针p指向的值（也就是修改变量a的值） *p = 20 fmt.Println("Modified value of a:", a) }在这个例子中：我们首先声明了一个整型变量a并赋予初始值10。然后，我们声明了一个指向整型的指针p，并将变量a的地址赋给了p。接下来，我们展示了如何打印出变量的值、它的地址以及指针变量的内容。最后，通过解引用指针p，我们直接修改了a的值，证明了通过指针可以直接访问和修改其指向的变量的值。变量是否允许跨包使用在变量命名中，如果是小写字母开头的变量，则只允许在当前的包中使用，类似于私有变量。但是，如果是大写字母开头的变量，则可以在别的地方通过导入包进行使用。例如：var miku int = 39 var Rin int = 12miku变量只能在当前包中的代码读取和使用，而Rin变量别的地方导入了这个包的话就可以使用。设置自己的包创建一个新的目录来存放你的包文件。例如，你可以创建一个名为mypackage的目录。将plugin.go移动到这个新目录中。在plugin.go中声明包名。如果你希望包名与目录名一致，则可以这样写：package mypackage在test.go中导入你刚刚创建的包，并使用其中的变量。5.初始化Go模块下面是一个具体的例子：项目结构如下：myproject/ ├── test.go └── mypackage/ └── plugin.go首先，在plugin.go中定义一个公共变量（注意：Go中的变量如果要导出，首字母必须大写）：package mypackage var MyVariable = "Hello from plugin.go"然后，在test.go中导入mypackage并使用MyVariable:package main import ( "fmt" "myproject/mypackage" ) func main() { fmt.Println(mypackage.MyVariable) } 然后进入到该目录，初始化Go模块：go mod init myproject运算符常见运算符算术运算符：package main import "fmt" func main() { a := 10 b := 5 fmt.Println("a + b =", a + b) // 加法：15 fmt.Println("a - b =", a - b) // 减法：5 fmt.Println("a * b =", a * b) // 乘法：50 fmt.Println("a / b =", a / b) // 除法：2 fmt.Println("a % b =", a % b) // 取模：0 }关系运算符：package main import "fmt" func main() { a := 10 b := 5 fmt.Println("a == b is", a == b) // false fmt.Println("a != b is", a != b) // true fmt.Println("a < b is", a < b) // false fmt.Println("a > b is", a > b) // true fmt.Println("a <= b is", a <= b) // false fmt.Println("a >= b is", a >= b) // true }逻辑运算符：package main import "fmt" func main() { a := true b := false fmt.Println("a && b is", a && b) // false fmt.Println("a || b is", a || b) // true fmt.Println("!a is", !a) // false }位运算符：package main import "fmt" func main() { a := 60 // 二进制: 0011 1100 b := 13 // 二进制: 0000 1101 fmt.Printf("a & b = %d\n", a & b) // 按位与: 12 (0000 1100) fmt.Printf("a | b = %d\n", a | b) // 按位或: 61 (0011 1101) fmt.Printf("a ^ b = %d\n", a ^ b) // 按位异或: 49 (0011 0001) fmt.Printf("a << 2 = %d\n", a << 2) // 左移两位: 240 (1111 0000) fmt.Printf("a >> 2 = %d\n", a >> 2) // 右移两位: 15 (0000 1111) }赋值运算符：package main import "fmt" func main() { var a int = 5 a += 3 // 相当于 a = a + 3 fmt.Println("a += 3 =", a) // 8 a -= 2 // 相当于 a = a - 2 fmt.Println("a -= 2 =", a) // 6 a *= 2 // 相当于 a = a * 2 fmt.Println("a *= 2 =", a) // 12 a /= 3 // 相当于 a = a / 3 fmt.Println("a /= 3 =", a) // 4 a %= 3 // 相当于 a = a % 3 fmt.Println("a %= 3 =", a) // 1 }获取用户输入1. fmt.Scanffmt.Scanf允许你按照指定的格式字符串来解析输入。它的工作方式类似于C语言中的scanf函数。package main import ( "fmt" ) func main() { var name string var age int fmt.Print("请输入你的名字和年龄: ") n, err := fmt.Scanf("%s %d", &name, &age) if err != nil { fmt.Println("读取失败:", err) } else { fmt.Printf("成功读取了 %d 个值 - 名字: %s, 年龄: %d\n", n, name, age) } }注意：%s只读取一个单词，即遇到空格就会停止。2. fmt.Scanlnfmt.Scanln与fmt.Scan类似，但它会在遇到换行符时停止扫描，并丢弃输入中的任何未读取的部分。这使得Scanln非常适合于读取整行输入，其中输入项由空格分隔。package main import ( "fmt" ) func main() { var name string var age int fmt.Print("请输入你的名字和年龄: ") n, err := fmt.Scanln(&name, &age) if err != nil { fmt.Println("读取失败:", err) } else { fmt.Printf("成功读取了 %d 个值 - 名字: %s, 年龄: %d\n", n, name, age) } }与Scanf不同的是，Scanln不需要格式化字符串来指示如何解析输入，它自动根据变量类型解析输入。区别fmt.Scanf：需要提供格式化字符串来指定输入应该如何被解析。适用于你需要精确控制如何解释输入的情况。fmt.Scanln：不需要格式化字符串，它会自动根据参数类型解析输入，直到遇到换行符为止。更适用于简单的输入场景。fmt.Scan：与Scanln相似，但不会跳过末尾的换行符，并且可以在一行内读取多个值，不等待换行。流程控制if 流程控制1. 单分支 if单分支的if语句仅包含一个条件判断和相应的执行代码块。如果条件为真，则执行该代码块；否则跳过。package main import "fmt" func main() { age := 20 if age >= 18 { fmt.Println("成年人") } }2. 双分支 if-else双分支的if-else语句除了有一个条件判断和相应的执行代码块外，还提供了一个else部分，当条件为假时执行。package main import "fmt" func main() { age := 16 if age >= 18 { fmt.Println("成年人") } else { fmt.Println("未成年人") } }3. 多分支 if-else if-else多分支的if-else if-else语句允许你检查多个条件，并对每个条件执行不同的代码块。一旦某个条件为真，相应的代码块被执行，其余的条件将不会被检查。package main import "fmt" func main() { score := 85 if score >= 90 { fmt.Println("优秀") } else if score >= 75 { fmt.Println("良好") } else if score >= 60 { fmt.Println("及格") } else { fmt.Println("不及格") } }switch 流程控制基本用法package main import ( "fmt" ) func main() { i := 2 switch i { case 1: fmt.Println("数字是1") case 2: fmt.Println("数字是2") case 3: fmt.Println("数字是3") default: fmt.Println("不是1, 2, 或3") } }使用表达式作为switch的条件switch不仅可以基于整数或字符串等简单类型的比较，还可以用于更复杂的表达式。package main import ( "fmt" ) func main() { a, b := 12, 6 switch { case a+b == 18: fmt.Println("a + b 等于18") case a-b == 6: fmt.Println("a - b 等于6") default: fmt.Println("没有匹配的情况") } }fallthrough 关键字如果你想在匹配的case后继续执行下一个case的代码，可以使用fallthrough。package main import ( "fmt" ) func main() { i := 1 switch i { case 0: fmt.Println("i 是0") fallthrough // 即使i不是0，也会尝试执行下一个case case 1: fmt.Println("i 是1或者从上一个case fallthrough过来") default: fmt.Println("i 不是0也不是1") } }switch 表达式类型为字符串switch语句也可以直接用于字符串比较。package main import ( "fmt" ) func main() { str := "hello" switch str { case "hello": fmt.Println("你好") case "world": fmt.Println("世界") default: fmt.Println("未知字符串") } }循环基本 for 循环最基本的for循环形式包括初始化语句、条件表达式以及后续操作（通常用于递增或递减计数器）。这种形式与C或Java中的for循环相似。package main import "fmt" func main() { sum := 0 for i := 0; i < 10; i++ { sum += i } fmt.Println("Sum:", sum) }在这个例子中，循环从i=0开始，每次循环i增加1，直到i不小于10为止。省略部分的 for 循环仅条件表达式：如果省略了初始化和后续操作，for循环就类似于其他语言中的while循环。package main import "fmt" func main() { i := 0 for i < 3 { fmt.Println(i) i++ } }无限循环：如果省略所有部分，就是一个无限循环。可以通过break语句来退出循环。package main import "fmt" func main() { for { fmt.Println("This will loop forever") break // 使用break退出循环 } }for range 循环for range提供了一种简洁的方式来遍历数组、切片、字符串、映射和通道。对于每种类型，range返回的值有所不同。遍历数组或切片package main import "fmt" func main() { nums := []int{2, 3, 4} for index, value := range nums { fmt.Printf("Index: %d, Value: %d\n", index, value) } }遍历字符串package main import "fmt" func main() { str := "hello" for i, c := range str { fmt.Printf("Character at position %d is %c\n", i, c) } }遍历映射package main import "fmt" func main() { m := map[string]int{"apple": 2, "banana": 3} for key, value := range m { fmt.Printf("%s -> %d\n", key, value) } }注意：遍历映射时，键值对的顺序不是固定的。控制流程关键字1. breakbreak 语句用来终止最内层的 for、switch 或 select 语句，并跳出该结构。它常用于提前退出循环或选择结构。package main import "fmt" func main() { for i := 0; i < 10; i++ { if i == 3 { break // 当i等于3时，跳出循环 } fmt.Println("Number:", i) } fmt.Println("Loop finished") }注意事项: break 只能用于终止最近的循环或选择结构。如果需要从嵌套多层的循环中跳出，可以给循环命名并用标签指定要跳出的循环。2. continuecontinue 语句用于跳过当前循环体中剩余的语句，并继续执行下一次循环（如果有的话）。它通常用于忽略某些特定情况下的后续逻辑。package main import "fmt" func main() { for i := 0; i < 5; i++ { if i == 2 { continue // 当i等于2时，跳过本次循环的剩余部分 } fmt.Println("Number:", i) } }注意事项: continue 同样只能影响最近的循环。对于复杂的嵌套结构，可能需要更清晰的逻辑设计来避免混淆。3. gotogoto 语句允许程序直接跳转到由标签标记的地方。虽然它提供了灵活的控制流，但过度使用会导致代码难以理解和维护。package main import "fmt" func main() { i := 0 start: if i < 5 { fmt.Println("Number:", i) i++ goto start // 跳转到名为start的标签处 } }注意事项: Go语言鼓励限制使用goto，因为它可能导致“意大利面条式”代码（即代码逻辑混乱，难以跟踪）。一般情况下，应尽量使用结构化的控制流工具如for、if等代替。4. returnreturn 语句用于从函数中返回，可选地带有一个返回值（对于有返回值的函数）。它是结束函数执行的标准方式。package main import "fmt" func add(a, b int) int { return a + b // 返回a+b的结果 } func main() { result := add(3, 4) fmt.Println("Result:", result) // 使用return提前结束main函数 if result > 5 { fmt.Println("结果大于5") return } }注意事项: 在定义了返回类型的函数中，必须确保每次执行路径都有一个return语句，否则会导致编译错误。同时，return语句可用于在满足特定条件时提前终止函数执行。函数与包函数基本函数定义与调用首先，我们来看一个简单的函数定义和调用的例子：package main import "fmt" // 定义一个名为add的函数，接受两个int类型的参数，返回它们的和 func add(a int, b int) int { return a + b } func main() { // 调用add函数并打印结果 result := add(5, 3) fmt.Println("5 + 3 =", result) }函数的重要特性多重返回值：Go语言允许函数返回多个值。这在错误处理等方面非常有用。func divide(a, b int) (int, error) { if b == 0 { return 0, fmt.Errorf("cannot divide by zero") } return a / b, nil }命名返回值：可以为返回值指定名称，并且可以直接从函数体内部返回这些值。func profile(name string) (fullname string) { fullname = name + " Doe" return // 省略了返回值，但会返回fullname变量的当前值 }匿名函数与闭包：Go支持匿名函数，可以作为参数传递或赋值给变量。func main() { // 定义一个匿名函数并立即执行 func(msg string) { fmt.Println(msg) }("Hello, World!") // 或者将其赋值给变量后调用 sayHello := func(msg string) { fmt.Println(msg) } sayHello("Hello again!") }可变参数函数：类似于其他语言中的varargs，Go语言允许函数接受不定数量的参数。func sum(nums ...int) int { total := 0 for _, num := range nums { total += num } return total } func main() { fmt.Println(sum(1, 2, 3)) // 输出6 }defer语句：用于安排一个函数调用在包含它的函数返回之后执行，常用于资源清理工作。func main() { defer fmt.Println("World") fmt.Println("Hello") } // 输出顺序是先"Hello"后"World"包包的定义首先，每个.go文件必须以package声明开头，这表示该文件属于哪个包。例如，创建一个名为mathutil的包：// 文件名: mathutil.go package mathutil import "fmt" // 定义一个公共函数（注意首字母大写） func Add(a, b int) int { return a + b } // 私有函数（首字母小写），仅在包内可见 func privateAdd(a, b int) int { fmt.Println("This is a private function.") return a + b }在这个例子中，Add函数因为其名字以大写字母开头，所以它是公共的，可以从其他包中访问。相反，privateAdd函数的名字以小写字母开头，因此它是私有的，只能在mathutil包内部使用。包的导入和使用要使用其他包中的功能，需要使用import关键字导入相应的包。继续上面的例子，我们可以在另一个文件中导入并使用mathutil包：// 文件名: main.go package main import ( "fmt" "path/to/mathutil" // 假设mathutil位于/path/to/目录下 ) func main() { result := mathutil.Add(10, 20) fmt.Printf("The result of adding is %d\n", result) }请注意，在导入自定义包时，你需要提供相对于$GOPATH/src或Go modules根目录的路径。包的别名当你想为导入的包指定一个简短或不同的名称时，可以使用别名。示例：包别名// 文件: main.go package main import ( m "path/to/mypackage" // 使用m作为mypackage的别名 ) func main() { result := m.Add(5, 3) println(result) }包的目录结构安排通常，每个独立的功能模块都应该有自己的目录，并且在这个目录下包含一个或多个.go文件，它们都声明相同的包名。也就是一个文件夹下的.go文件归为一个包。推荐的项目结构：myproject/ ├── cmd/ │ └── myapp/ │ └── main.go ├── pkg/ │ └── mypackage/ │ ├── add.go │ └── subtract.go └── go.modcmd/：存放应用的入口点。pkg/：存放可重用的包。mypackage为包名，导入的时候采用这个名字，但是实际调用函数的时候，以包中定义的package xxx为准。例如：ccc = xxx.Add(1,2)init 函数在Go语言中，init函数是一个特殊的函数，它不需要调用即可自动执行。每个.go文件可以包含一个或多个init函数，这些函数主要用于初始化操作，如设置变量的初始值、验证配置等。下面将详细介绍init函数的工作机制、使用场景以及一些需要注意的细节。init函数的基本特性自动执行：init函数无需显式调用，编译器会确保在程序启动时自动执行。无参数和返回值：init函数不能接受任何参数，也不能有返回值。包级别初始化：init函数用于包级别的初始化，这意味着它们会在导入包时被调用，但仅限于该包内部。执行顺序：在同一个文件内，init函数按照它们声明的顺序执行。如果一个包中有多个文件，init函数按文件名的字典顺序执行。包的所有init函数执行完毕后，才会执行导入它的包中的init函数。使用场景初始化不可变变量：对于需要复杂计算才能确定其值的常量，可以在init函数中进行计算并赋值。验证配置：可以在程序运行之前检查配置的有效性。资源加载：如数据库连接、配置文件读取等。示例代码以下是一个简单的示例，展示了如何使用init函数：package main import "fmt" var globalVar string func init() { fmt.Println("First init function called.") globalVar = "Initialized" } func init() { fmt.Println("Second init function called.") if globalVar != "Initialized" { fmt.Println("globalVar was not properly initialized!") } } func main() { fmt.Println("Main function called.") fmt.Printf("Global var: %s\n", globalVar) }输出结果为：First init function called. Second init function called. Main function called. Global var: Initialized注意事项避免过度使用：虽然init函数很方便，但过度依赖它可能会导致代码难以理解和维护。尽量让初始化逻辑清晰明确，特别是在大型项目中。依赖管理：由于init函数会自动执行，如果存在依赖关系（例如一个包的初始化依赖另一个包的初始化），需要小心处理初始化顺序，以避免潜在的问题。测试困难：由于init函数是隐式执行的，这可能使得某些类型的单元测试变得复杂。考虑将初始化逻辑移到显式的函数中，以便更容易地进行测试。匿名函数在Go语言中，匿名函数（Anonymous Function）是指那些未命名的函数，它们可以直接赋值给变量、作为参数传递给其他函数或在需要的地方直接定义和使用。匿名函数提供了极大的灵活性，特别是在需要短生命周期的函数或者当函数逻辑较为简单且仅需使用一次时。匿名函数的基本形式匿名函数的语法与普通函数相似，但没有函数名。以下是匿名函数的基本结构：func(参数列表) 返回值类型 { 函数体 }例如，一个接受两个整数并返回它们之和的匿名函数可以这样定义：sum := func(a, b int) int { return a + b }在这个例子中，sum变量现在指向这个匿名函数，可以通过sum(3, 4)来调用它。立即执行的匿名函数在Go中，还可以定义后立即执行匿名函数。这种模式通常用于避免变量泄漏到外部作用域，或者当你只需要一次性使用的函数时非常有用。要立即执行匿名函数，只需在定义后加上圆括号并传入参数（如果有的话）：result := func(a, b int) int { return a + b }(5, 3) fmt.Println("Result:", result) // 输出: Result: 8闭包闭包（Closure）是编程中的一个重要概念，尤其是在支持匿名函数的语言中，如Go语言。理解闭包可以帮助你编写更加灵活和模块化的代码。下面我将尽量简单详细地解释什么是闭包，以及如何在Go语言中使用它们。什么是闭包？简而言之，闭包是一个函数加上该函数创建时的环境。这里的“环境”指的是函数定义时可以访问到的所有变量。换句话说，即使函数在其原始作用域之外被调用，它仍然能够记住并访问其创建时的作用域中的变量。为什么需要闭包？闭包允许我们封装值，并在不同的上下文中使用这些封装的值。这在处理回调函数、延迟计算或封装私有数据时特别有用。Go语言中的闭包示例考虑一个简单的例子，展示如何在Go中创建和使用闭包：package main import "fmt" // makeAdder 返回一个匿名函数，这个匿名函数形成一个闭包 func makeAdder() func(int) int { sum := 0 // 这个变量是makeAdder内部的局部变量 return func(x int) int { sum += x // 匿名函数可以访问并修改sum return sum } } func main() { adder := makeAdder() // adder现在指向一个闭包 fmt.Println(adder(1)) // 输出: 1 fmt.Println(adder(2)) // 输出: 3 fmt.Println(adder(3)) // 输出: 6 }在这个例子中，makeAdder函数返回了一个匿名函数，这个匿名函数形成了一个闭包。闭包捕获了makeAdder函数内部的sum变量。因此，每次调用adder函数时，它都能记住之前的sum值，并在此基础上进行累加。闭包的工作原理外部函数执行完毕后，通常情况下其局部变量会被销毁。但是，如果有一个闭包引用了这些局部变量，那么这些变量将不会被销毁，而是继续存在，直到不再有任何引用为止。在上面的例子中，尽管makeAdder函数已经执行完毕，但由于返回的匿名函数（闭包）仍然持有对sum变量的引用，所以sum变量会持续存在于内存中，供闭包使用。defer 关键字在Go语言中，defer关键字用于延迟函数或方法的执行直到其所在的goroutine即将结束。通常，defer用于简化函数调用后的清理代码，比如关闭文件、解锁互斥锁等。当有多个defer语句时，它们会按照后进先出（LIFO）的顺序执行。基本用法考虑一个简单的例子，其中我们想要确保在函数结束前打印一条消息。package main import "fmt" func main() { defer fmt.Println("世界") fmt.Println("你好") }输出将是：你好 世界这里，尽管defer fmt.Println("世界")写在了前面，但由于defer的作用，它会在包含它的函数main结束之前才执行。多个defer语句当你在一个函数中有多个defer语句时，它们会以LIFO顺序执行。package main import "fmt" func main() { for i := 0; i < 5; i++ { defer fmt.Print(i) } }这段代码将输出：43210可以看到，数字是按相反顺序打印出来的，这是因为每个defer语句都是在循环迭代结束时被安排执行，并且按照后进先出的顺序执行。使用defer进行资源管理defer最常用的场景之一是在打开资源（如文件、数据库连接等）之后立即计划如何释放这些资源，即使在处理过程中出现错误也能保证资源得到正确释放。package main import ( "fmt" "os" ) func main() { file, err := os.Open("test.txt") if err != nil { fmt.Println("Error:", err) return } // 确保在函数退出时关闭文件 defer file.Close() // 执行文件操作... }在这个例子中，无论文件操作是否成功，file.Close()都会被执行，从而避免了资源泄露。错误处理利用defer和recover来处理错误package main import ( "fmt" "log" ) // 定义一个函数用于执行可能抛出panic的操作 func divide(a, b int) { // 使用defer与recover来捕获panic defer func() { if r := recover(); r != nil { // 记录错误信息 log.Printf("Recovered in divide: %v", r) } }() // 执行除法操作 result := a / b fmt.Printf("%d / %d = %d\n", a, b, result) } func main() { // 正常情况 divide(10, 2) // 可能造成除以0的情况 divide(10, 0) // 确认程序继续执行 fmt.Println("Program continues after potential panic.") }代码解释：recover()：在Go中，当发生panic时，可以使用recover来捕获并恢复正常执行流程。它必须在一个延迟调用的函数内部调用。defer：defer语句会将函数推迟到外层函数返回之后执行。在这个例子中，我们将包含recover的匿名函数推迟执行，以便在发生panic时能够恢复控制。在divide函数中，如果第二个参数b为0，则会发生除以零的运行时错误，这会导致一个panic。但是，因为我们使用了defer和recover，这个panic会被捕获，相应的错误消息也会被记录下来，而不会导致整个程序停止执行。在main函数中，我们首先正常地调用了divide函数，然后尝试用0作为除数再次调用它，以此展示即使遇到可能导致程序崩溃的错误，我们的程序仍能继续执行。自定义错误自定义错误但程序正常运行package main import ( "errors" "fmt" ) // 自定义一个函数，它可能会返回一个错误 func divide(a, b int) (int, error) { if b == 0 { return 0, errors.New("cannot divide by zero") } return a / b, nil } func main() { // 正常情况 result, err := divide(10, 2) if err != nil { fmt.Println("Error:", err) } else { fmt.Printf("Result: %d\n", result) } // 可能导致错误的情况 result, err = divide(10, 0) if err != nil { fmt.Println("Error:", err) } else { fmt.Printf("Result: %d\n", result) } // 确认程序继续执行 fmt.Println("Program continues after handling the error.") }注释：在divide函数中，我们检查了除数是否为0。如果是，则返回一个自定义的错误信息。在main函数中，我们通过检查返回的错误值来决定下一步操作。即使发生了错误（如除以零），程序仍然能够继续执行。直接抛出错误终止程序package main import ( "fmt" ) // 自定义一个函数，当遇到错误时直接抛出异常终止程序 func divide(a, b int) { if b == 0 { panic("division by zero") // 直接抛出错误并终止程序 } fmt.Printf("%d / %d = %d\n", a, b, a/b) } func main() { // 正常情况 divide(10, 2) // 这将导致程序崩溃 divide(10, 0) // 下面这条消息不会被打印，因为程序已经由于panic而终止 fmt.Println("This message will not be printed.") }注释：在这个例子中，当尝试除以零时，divide函数使用panic来抛出一个运行时错误，并立即终止程序的执行。panic是一个内置函数，用于停止常规的控制流。一旦panic被调用，程序会停止当前函数的执行，并开始回溯goroutine的调用栈，直到找到一个recover（如果没有recover，则程序终止）。因此，在main函数中，当divide(10, 0)被调用时，程序会立即终止，不会执行后续的任何代码（例如最后的fmt.Println语句）。
自建Jellyfin私人媒体服务器，告别屌毛网易云！
Jellyfin私人媒体服务器搭建指南成功搭建了jellyfin服务器，拥有了自己的私人媒体库，记录一下折腾过程。起因∶网易云无故被封促进我自建媒体库的原因，是因为网易云乱封号，两次把我的号给封了。理由居然是——播放数据异常真就是，想封你，随便编个理由，直接就封。纯脑残！其实之前我就有点想搞媒体服务器了，但是由于我看番都是用种子下载看番，而且番剧看一次也就过去了，我觉得也没用留着的必要。毕竟番剧的一话，1080P 60帧，都要差不多1GB了，看番多的话，文件破TB级那是轻轻松松。这次封号，算是坚定了我的想法。我觉得国内互联网公司绝大部分都是狗屎，让人无语。其实是2024年10月封的号，但是我2025年1月才开始折腾媒体服务器。因为之前都在学校，课业繁忙，乐的要死，因为整理音乐库费时费力，所以我打算放假再搞。事实证明我的想法是对的，我前前后后整理了快一个星期，整理了绝大部分，但是还有相当一部分歌曲还没整理。太累了，让我歇歇。目前已经整理了1300+首歌，文件夹已经90G+了。。。为什么选择了Jellyfin？免费，开源。开源，开源，开源，还是他妈的开源！我是支持GNU自由软件运动的人，自然更倾向于使用开源的解决方案，尤其是使用GPL协议的开源软件。顺带一提，我主力操作系统早就已经换成GNU/Linux了。部署Jellyfin下载Jellyfin直接前往官方网页下载即可，我用的最新版，目前是10.10.3。下载链接：repo.jellyfin.org/?path=/server/linux/latest-stable下载FFmpeg由于我服务器使用的是AlmaLinux8.10，是服务器发行版，其自带的FFmpeg比较老，新版的Jellyfin并不兼容。虽然可以通过yum包管理器直接安装对应版本的Jellyfin，并且不会出现兼容问题。但是旧版的Jellyfin并没有歌词支持，对我来说比较蛋疼，因为我主要想做的就是歌曲的媒体服务器。我下载的是7.0.2版本的，这种的话，我更倾向于使用最新版的前一个稳定版。下载地址：ffmpeg.org/releases/ffmpeg-7.0.2.tar.xz自定义环境变量，以使用自定义的ffmpeg我的做法是，把FFmpeg和Jellyfin解压后，分别放到了不同的文件夹，然后在根目录文件夹放了一个启动脚本。以下是启动脚本的内容：#!/bin/bash # 定义FFmpeg二进制文件所在的路径 FFMPEG_PATH="./ffmpeg/bin" # 将FFmpeg路径添加到PATH环境变量的最前面 export PATH="$FFMPEG_PATH:$PATH" # 启动Jellyfin ./jellyfin/jellyfin脚本保存为run.sh，然后再：chmod +x ./run.sh试一下执行这个脚本，看看Jellyfin有没有启动成功，如果启动成功后就完事了。配置Jellyfin初始化配置打开浏览器，输入服务器对应的ip+8096端口，例如：127.0.0.1:8096按照指引，设置用户名和密码。这部过于简单，有手就行，不多赘述。配置HTTPS访问这个Jellyfin比较特别，需要使用pfx证书，而不是常见的pem+key的证书组合。我们需要把pem和key证书转成pfx格式。下面是一个shell脚本，可以便利当前目录的pem+key证书，并且转换为pfx证书放到当前目录的pfx_cert/文件夹内：#!/bin/bash # 定义输出目录 OUTPUT_DIR="pfx_cert" # 创建输出目录（如果它不存在） mkdir -p "$OUTPUT_DIR" # 遍历所有的 .key 文件 for key in *.key; do # 获取不带扩展名的文件名 base_name="${key%.key}" # 检查对应的 .pem 文件是否存在 if [[ -f "${base_name}.pem" ]]; then echo "正在处理 $base_name..." # 转换为 PFX 格式 openssl pkcs12 -export \ -out "${OUTPUT_DIR}/${base_name}.pfx" \ -inkey "$key" \ -in "${base_name}.pem" \ -certfile le-chain.pem echo "已生成 ${OUTPUT_DIR}/${base_name}.pfx" else echo "警告：找不到与 $key 对应的 .pem 文件" fi done把以上内容保存到to_pfx.sh，然后：chmod +x ./to_pfx.sh ./to_pfx.sh当要求输入密码的时候，直接回车，不设置密码。在Jellyfin设置证书，选择得到的pfx证书文件。记得勾上强制HTTPS和允许远程连接保存后，重启Jellyfin，应该就可以通过HTTPS访问了。别忘了添加域名解析记录。至此，基本上配置完成。添加媒体注意事项如果是音乐的话，最好的目录分类方式应该是：歌手 > 专辑 > 音乐文件/歌词文件例如，如果是Taylor Swift的《Red》专辑，目录结构就长这样：/path/to/music_library/ │ ├── Taylor Swift │ └── Red (2012) │ ├── 01. State of Grace.mp3 │ ├── 01. State of Grace.lrc │ ├── 02. Red.mp3 │ ├── 02. Red.lrc │ ├── 03. Treacherous.mp3 │ ├── 03. Treacherous.lrc │ ├── 04. I Knew You Were Trouble.mp3 │ ├── 04. I Knew You Were Trouble.lrc │ ├── 05. All Too Well.mp3 │ ├── 05. All Too Well.lrc │ ├── 06. 22.mp3 │ ├── 06. 22.lrc │ ├── 07. I Almost Do.mp3 │ ├── 07. I Almost Do.lrc │ ├── 08. We Are Never Ever Getting Back Together.mp3 │ ├── 08. We Are Never Ever Getting Back Together.lrc │ ├── 09. Stay Stay Stay.mp3 │ ├── 09. Stay Stay Stay.lrc │ ├── 10. The Last Time.mp3 │ ├── 10. The Last Time.lrc │ ├── 11. Holy Ground.mp3 │ ├── 11. Holy Ground.lrc │ ├── 12. Sad Beautiful Tragic.mp3 │ ├── 12. Sad Beautiful Tragic.lrc │ ├── 13. Begin Again.mp3 │ ├── 13. Begin Again.lrc │ ├── 14. Girl At Home.mp3 │ ├── 14. Girl At Home.lrc │ └── cover.jpg # 封面图片整理好后，在Jellyfin添加媒体库，选择音乐，在添加上对应的文件夹路径，就可以了。如果是按照这个方法添加的话，Jellyfin是可以自动检索元数据并且添加的，可以改善音乐库的观感：歌词的效果：效果还是可以的！音乐库去哪找？我是用了网易云无损解析这类工具，把歌曲一首一首下回来，自己整理，工作量巨大！好处是，有一些工具可以一并把歌词下来，比较舒服。这种工具估计网上一搜，都一大把，很多都是网站在线解析的，并不需要下载软件。什么？你不会找？找不到？是不是搜索引擎是垃圾百度啊？还是360搜索？如果还在用国内这种垃圾搜索引擎，还不会换，那我只能说你是赛博文盲！当然，你也可以去某宝，花几块钱去购买并下载资源，但是大概率给你个百度网盘链接（改名百度软盘得了），然后下载龟速。2333我的收集方法我是用了这个:api.toubiec.cn/wyapi.html 我是一次下一个专辑，由于下载的每首歌都是一个zip压缩包，本来下载就费时费力，整理更加费时费力。于是，我就整了一点小工具做一下辅助：open_zip.sh放在下载目录，负责遍历解压压缩包，文件内容如下：#!/bin/bash # 检查是否有zip文件存在 if [ "$(ls -A *.zip 2> /dev/null)" ]; then # 遍历所有.zip文件 for zip_file in *.zip; do # 获取不带扩展名的文件名 base_name="${zip_file%.zip}" # 创建以文件名为名的新文件夹 mkdir -p "$base_name" # 解压到新建的文件夹内 unzip -q "$zip_file" -d "$base_name" # 检查解压是否成功 if [ $? -eq 0 ]; then echo "Extracted $zip_file into folder $base_name" # 如果解压成功，删除zip文件 rm "$zip_file" echo "Deleted $zip_file" else echo "Failed to extract $zip_file" fi done else echo "No zip files found." fiout_folder.sh放在下载目录，负责遍历移动子文件夹里面的内容到工作目录，文件内容如下：#!/bin/bash # 使用 find 命令查找所有文件并将它们移动到当前目录。 # -mindepth 2 确保我们只获取子文件夹中的文件，而不是当前目录中的文件。 # -maxdepth 2 确保我们不会深入到更深层次的子文件夹中。 # -type f 确保我们只处理文件，而不处理文件夹。 find . -mindepth 2 -maxdepth 2 -type f -exec mv {} . \; # 查找并删除空的子文件夹 # -empty 匹配空文件或空文件夹 # -type d 确保我们只处理文件夹 # -delete 删除匹配到的文件夹 find . -mindepth 1 -maxdepth 1 -type d -empty -delete echo "文件移动完成，并且空文件夹已被删除。"rename_songs.py负责重命名和整理歌曲相关文件，依据截取到的html进行排序整理。需要把网易云音乐网页版的专辑曲目部分的的html代码保存到同目录的content.txt文件中，执行后会自动解析保存的html代码并依据此进行重命名和排序。文件内容如下：import os from bs4 import BeautifulSoup import re import unicodedata def sanitize_title(title): """清理标题以进行精确匹配，保留非ASCII字符""" # 只移除ASCII范围内的非字母数字字符，并转换为小写 return ''.join([c for c in unicodedata.normalize('NFKC', title) if c.isalnum() or not (0 <= ord(c) < 128)]).lower() def clean_title(title): """清理并标准化歌曲标题""" # 将HTML实体替换为空格 title = re.sub(r' ', ' ', title) # 移除多余的空白字符，但保留单词间的单个空格 title = re.sub(r'\s+', ' ', title).strip() # 去除首尾可能出现的特殊字符或符号 title = re.sub(r'^[^\w\s]+|[^\w\s]+$', '', title) # 统一下划线和空格 title = re.sub(r'_', ' ', title) return title def parse_html(html_content): soup = BeautifulSoup(html_content, 'html.parser') # 查找所有的表格行，仅限于 中的行 rows = soup.select('tbody tr') songs = [] for row in rows: # 获取编号 num_tag = row.find('span', class_='num') number = num_tag.get_text(strip=True) if num_tag else 'N/A' # 尝试获取歌曲标题，考虑了可能存在的多种情况 title_tag = row.select_one('td:nth-of-type(2) b, td:nth-of-type(2) a') if title_tag: # 清理标题文本，移除干扰元素 for unwanted in title_tag.find_all('div', class_='soil'): unwanted.extract() # 移除不需要的 标签 title = clean_title(title_tag.get('title') or title_tag.get_text(strip=True)) sanitized_title = sanitize_title(title) else: title = 'Title Not Found' sanitized_title = sanitize_title(title) # 只有当number不是'N/A'且title不是'Title Not Found'时加入列表 if number != 'N/A' and title != 'Title Not Found': songs.append((number, title, sanitized_title)) print("Parsed Songs:") for song in songs: print(f"Number: {song[0]}, Title: {song[1]}, Sanitized Title: {song[2]}") return songs def standardize_filename(filename): """标准化文件名，将下划线替换为空格，并清理多余空格""" base_name, ext = os.path.splitext(filename) standardized_base_name = re.sub(r'_', ' ', base_name) standardized_base_name = re.sub(r'\s+', ' ', standardized_base_name).strip() return f"{standardized_base_name}{ext}" def rename_files_in_directory(directory_path, songs): # 遍历目录中的所有文件 renamed_files = set() # 用于跟踪已经重命名的文件，避免重复重命名 for filename in os.listdir(directory_path): file_path = os.path.join(directory_path, filename) # 如果是文件并且文件名（不包括扩展名）与解析出的歌曲名称匹配，则重命名 if os.path.isfile(file_path) and not filename.endswith('.txt') and not filename.endswith('.py'): standardized_filename = standardize_filename(filename) base_name, ext = os.path.splitext(standardized_filename) matched = False for i, (number, title, sanitized_title) in enumerate(songs, start=1): sanitized_base_name = sanitize_title(base_name) # 使用清理后的字符串进行精确匹配 if sanitized_base_name == sanitized_title: # 格式化编号为两位数 formatted_number = f"{i:02d}" new_filename = f"{formatted_number}. {base_name}{ext}" # 使用标准化后的文件名作为歌曲名 new_file_path = os.path.join(directory_path, new_filename) if not os.path.exists(new_file_path): # 检查新文件名是否已存在 if filename not in renamed_files: print(f'Renaming "{filename}" to "{new_filename}"...') try: os.rename(file_path, new_file_path) renamed_files.add(filename) # 添加到已重命名集合中 matched = True except Exception as e: print(f"Error renaming '{filename}': {e}") else: print(f"New filename '{new_filename}' already exists.") break # 文件名匹配成功后跳出循环，避免重复重命名 if not matched: print(f"No match found for file '{filename}'.") def handle_jpg_files(directory_path): """处理目录中的所有 .jpg 文件，保留一个并重命名为 cover.jpg，删除其余的""" jpg_files = [f for f in os.listdir(directory_path) if f.endswith('.jpg')] if len(jpg_files) > 0: # 选择第一个找到的 .jpg 文件作为封面 cover_file = jpg_files[0] cover_path = os.path.join(directory_path, cover_file) new_cover_path = os.path.join(directory_path, 'cover.jpg') # 如果cover.jpg不存在，则重命名第一个找到的.jpg文件为cover.jpg if not os.path.exists(new_cover_path): os.rename(cover_path, new_cover_path) print(f'Renamed "{cover_file}" to "cover.jpg".') # 删除其他的 .jpg 文件 for jpg in jpg_files[1:]: jpg_path = os.path.join(directory_path, jpg) os.remove(jpg_path) print(f'Deleted "{jpg}".') else: print("No .jpg files found to process.") if __name__ == "__main__": # 获取当前脚本所在的目录 directory_path = os.path.dirname(os.path.abspath(__file__)) # 处理 .jpg 文件 handle_jpg_files(directory_path) # 读取同目录下的 content.txt 文件获取 HTML 内容 content_file_path = os.path.join(directory_path, 'content.txt') try: with open(content_file_path, 'r', encoding='utf-8') as file: html_content = file.read() except FileNotFoundError: print("Error: The file 'content.txt' was not found in the current directory.") exit(1) except Exception as e: print(f"An error occurred while reading 'content.txt': {e}") exit(1) songs = parse_html(html_content) rename_files_in_directory(directory_path, songs) print("All processes completed.")至于怎么获取专辑曲目部分的的html代码，直接在浏览器右键歌曲列表，检查代码，一层一层往上找，当显示是整个列表的内容的元素，那就找到了。然后，右键，复制内部html，这就是需要的东西了。把这些html代码粘贴到content.txt就行了。注意，上述的四个文件：open_zip.sh，out_folder.sh，rename_songs.py，content.txt都是放在专辑的下载目录下。以上准备工作做完之后，在当前目录打开命令行，就可以施展组合拳了：./open_zip.sh ./out_folder.sh python rename_songs.py什么？Python提示ModuleNotFoundError: No module named 'xxx'?缺模块就去装啊，自己去搜，看了资料还不会装的话，只能说你唐！唐完了！！！如果一切顺利，目录就会变成上面示例目录结构专辑文件夹里面的样子了。小福利：Taylor Swift 的专辑为了更直观的看到是怎么组织文件目录的，我就把我整理的Taylor Swift的几个专辑分享出来吧！分别是：《Red》《1989》《Fearless》《Lover》《Speak Now》《Taylor Swift》以下是压缩分卷，解压密码是GTX1080Ti：Taylor Swift.part1.rar Taylor Swift.part5.rar Taylor Swift.part4.rar Taylor Swift.part3.rar Taylor Swift.part2.rar 别告诉我你不会解压缩，不然我只会骂你唐货！2025年12月补充：新文章有更好的音乐爬取方法，详情看用 Python 破解网易云 iframe 反爬，全自动构建本地音乐库 这篇文章