高性能IP查询服务端开发日志之前使用IP信息查询的服务也不少了，观察了不少的接口，大部分返回的数据都是json格式的数据，于是我就在想，我能不能也自己开发一个类似的查询工具玩一下？说干就干，在想清楚了软件的架构设计后，我决定开工。架构设计与编程语言选型在架构设计方面，我打算进行前后端彻底解耦，前端为静态网页，后端为纯API程序。对于前端，我计划利用JS来从API获取信息并更新页面对于后端，就做最纯粹的API服务，根据查询请求响应JSON字符串在亲眼见证过PHP，Python这类动态解释型语言的效率噩梦后，我打算使用GO语言进行开发，理由很简单：GO是编译型语言，运行效率比解释型语言要高得多。GO天生就适合开发后端，抗并发能力强。相比于C语言，GO语言要更适合上手。构建前端不得不说，就目前的情况，Gemini仍然是前端之神，我直接让Gemini根据我的想法设计了一套网页，还是相当不错的：代码如下： 猫娘IP地址查询 🐱 🐾 猫娘IP地址查询 您的 IP 地址为 正在获取喵... 🔍 查询喵 🐾 🌍 国家 - 🗺️ 省份 - 🏙️ 城市 - 🏢 ISP 运营商 - 🧭 经纬度 - 💡 开发者 API 说明 🛠️ API 调用说明 × 您可以通过发送 GET 请求到我们的接口来获取 IP 详细信息喵： GET https://api.maoniang.example/v1/ip?address={ip} 📦 返回数据格式示例 (JSON): { "code": 200, "msg": "success", "data": { "country": "美国", "province": "加利福尼亚州", "city": "圣克拉拉", "isp": "阿里云", "latitude": "37.355701", "longitude": "-121.955002" } } 我知道了喵 🐾 但是仔细观察，你会发现使用了实时构建的 Tailwind CSS ，其实这样做对于一个静态页面不太友好，于是我让他按照 Tailwind CSS 的样式，原生帮我写了一版样式。因为处理后代码变多了，我就不在这里放出来了。最后静态文件就三个：[chocola@Neko-X99 static]$ tree . ├── favicon.png ├── index.html └── main.css 1 directory, 3 files后续还有界面的美化，目前确定是这样了：变可爱了，有没有？后端设计因为项目的定位是轻量，所以我不打算依赖外部的数据库。再加上IP查询这个业务场景几乎全是读操作，所以使用SQLite是没问题的，读取这方面没有瓶颈。为了实现高效的 IP 查询，数据库表结构的设计至关重要。核心思路是将文本格式的 IP 地址转换为无符号 32 位整数（uint32），这样不仅运算效率高，也便于进行范围比较。数据库表（例如 ip_info）主要包含三个字段：network_start (INTEGER): 存储该 IP 段的起始地址，以 uint32 格式保存。这是查询的核心，我们会根据这个字段建立索引（CREATE INDEX idx_network_start ON ip_info (network_start);），以确保查询性能。network_end (INTEGER): 存储该 IP 段的结束地址，同样以 uint32 格式保存。用于在查找到候选记录后，进行二次验证，确保目标 IP 确实落在该网段内。ip_info_json (TEXT): 将该 IP 段对应的地理位置、运营商等详细信息，序列化成 JSON 字符串后存储在此字段。这种方案灵活度高，可以方便地增减返回的字段，而无需修改表结构。查询时，我们首先将待查询的 IP 转换为整数，然后利用 WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1 这样的 SQL 语句，借助索引快速定位到最接近且小于等于目标 IP 的那条记录。接着，再用 network_end 字段验证目标 IP 是否在该记录的范围内，从而得出最终结果。这种“先定位，后校验”的方式，配合索引，能够将查询耗时稳定在微秒级别。测试环境：CPU：i5-2410M内存：4GB DDR3 1600MHz硬盘：希捷 320G SATA2 机械硬盘Nginx：模拟真实环境，开启SSL进行反代第一版代码package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net" "net/http" "strings" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // 定义完全匹配前端需求的数据结构 type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` Province string `json:"province"` City string `json:"city"` ISP string `json:"isp"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` } // 定义标准的 API 响应格式 type APIResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` // 用 interface{} 以便在出错时返回 nil } // 辅助函数：获取客户端真实 IP func getClientIP(r *http.Request) string { ip := r.Header.Get("X-Real-IP") if ip == "" { ip = r.Header.Get("X-Forwarded-For") } if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) } // X-Forwarded-For 可能是逗号分隔的多个IP，取第一个真实的 ip = strings.Split(ip, ",")[0] // 清理可能包含的空格，提升健壮性 return strings.TrimSpace(ip) } func apiHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // 提取查询参数并去除两端空白字符 queryIPStr := strings.TrimSpace(r.URL.Query().Get("ip")) targetIP := queryIPStr if targetIP == "" { targetIP = getClientIP(r) } // 【安全防线 1】：严格限制为合法的 IPv4 格式。任何注入代码都会在这里被直接拦截。 parsedIP := net.ParseIP(targetIP) if parsedIP == nil || parsedIP.To4() == nil { json.NewEncoder(w).Encode(APIResponse{Code: 400, Msg: "非法的 IPv4 地址喵！", Data: nil}) return } // 【安全防线 2】：转为 uint32 整型。彻底杜绝字符型注入。 ipInt := uint32(parsedIP.To4()[0])<<24 | uint32(parsedIP.To4()[1])<<16 | uint32(parsedIP.To4()[2])<<8 | uint32(parsedIP.To4()[3]) var infoJSON string var networkEnd uint32 // 🌟 记得提前声明这个变量喵 // 【性能优化核心】：利用索引极速向下查找最近的一条记录 err := db.QueryRow(` SELECT network_end, ip_info_json FROM ip_info WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1`, ipInt).Scan(&networkEnd, &infoJSON) if err != nil { if err == sql.ErrNoRows { json.NewEncoder(w).Encode(APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) } else { // 避免将底层的数据库错误直接暴露给前端（防止信息泄露） log.Printf("数据库查询错误: %v\n", err) json.NewEncoder(w).Encode(APIResponse{Code: 500, Msg: "数据库查询出错了喵", Data: nil}) } return } // 【关键逻辑校验】：虽然找到了最近的起始 IP，但必须确认目标 IP 是否在这个网段的覆盖范围内 if ipInt > networkEnd { json.NewEncoder(w).Encode(APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) return } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { log.Printf("JSON 解析错误: %v\n", err) json.NewEncoder(w).Encode(APIResponse{Code: 500, Msg: "数据解析失败喵", Data: nil}) return } result := IPInfo{ IP: targetIP, Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } json.NewEncoder(w).Encode(APIResponse{ Code: 200, Msg: "success", Data: result, }) } func main() { // ================= 定义启动参数 ================= // flag.String("参数名", "默认值", "说明文字") dbPath := flag.String("db", "ip_info.db", "SQLite 数据库文件路径") port := flag.String("port", "8080", "API 服务监听端口") // 解析命令行参数 flag.Parse() var err error // 使用参数指定的数据库路径 db, err = sql.Open("sqlite3", *dbPath) if err != nil { log.Fatal("无法打开数据库: ", err) } defer db.Close() http.HandleFunc("/ipinfo", apiHandler) // 拼接监听地址 addr := fmt.Sprintf(":%s", *port) fmt.Printf("猫娘纯净 API 服务启动于 %s 喵... 🐾\n", addr) fmt.Printf("当前使用的数据库文件: %s\n", *dbPath) log.Fatal(http.ListenAndServe(addr, nil)) }测试下来直接就踩了一个大坑：因为没有设置索引，数据库查询效率非常低下，以至于到了难以置信的慢。通过这个命令：CREATE INDEX idx_network_start ON ip_info (network_start);建立好索引后，查询速度直接起飞。从原来每秒只能处理10个请求飙升至每秒可以处理5000+个请求，性能直接翻了500倍！太夸张了！第二版代码虽然第一版代码的性能已经可以做到很优秀，但是我还是希望尝试进一步优化，那就是——数据库存入内存！而而且我不但要存入内存，还要将其转化为Go原生的数据存储格式，进一步优化性能。package main import ( "database/sql" "encoding/json" "flag" "fmt" "log" "net" "net/http" "sort" "strings" "time" _ "github.com/mattn/go-sqlite3" ) var db *sql.DB // --- 全局配置与缓存 --- var ( ipCache []IPRule // 全量内存切片 useMemoryCache bool // 内存模式开关 enableDetailLog bool // 详细日志开关 ) type IPRule struct { NetworkStart uint32 NetworkEnd uint32 Info IPInfo } type IPInfo struct { IP string `json:"ip"` Country string `json:"country"` Province string `json:"province"` City string `json:"city"` ISP string `json:"isp"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` } type APIResponse struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data"` } // 辅助函数：获取客户端真实 IP func getClientIP(r *http.Request) string { ip := r.Header.Get("X-Real-IP") if ip == "" { ip = r.Header.Get("X-Forwarded-For") } if ip == "" { ip, _, _ = net.SplitHostPort(r.RemoteAddr) } ip = strings.Split(ip, ",")[0] return strings.TrimSpace(ip) } // 统一处理响应和日志打印 func sendJSONResponse(w http.ResponseWriter, clientIP, targetIP string, resp APIResponse) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") // 将结构体序列化为 JSON 字节 respBytes, err := json.Marshal(resp) if err != nil { log.Printf("响应序列化失败: %v", err) http.Error(w, `{"code":500,"msg":"系统内部错误","data":null}`, http.StatusInternalServerError) return } // 如果开启了日志，就在这里统一输出 if enableDetailLog { log.Printf("[访问日志] 来源IP: %-15s | 查询IP: %-15s | 结果: %s", clientIP, targetIP, string(respBytes)) } // 写入响应 w.Write(respBytes) } func loadDataToMemory() error { log.Println("正在将数据库载入内存，请稍候喵...") startTime := time.Now() rows, err := db.Query(`SELECT network_start, network_end, ip_info_json FROM ip_info ORDER BY network_start ASC`) if err != nil { return err } defer rows.Close() for rows.Next() { var start, end uint32 var infoJSON string if err := rows.Scan(&start, &end, &infoJSON); err != nil { return err } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { continue } info := IPInfo{ Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } ipCache = append(ipCache, IPRule{ NetworkStart: start, NetworkEnd: end, Info: info, }) } log.Printf("载入完成！共加载了 %d 条规则，耗时 %v 喵！", len(ipCache), time.Since(startTime)) return nil } func apiHandler(w http.ResponseWriter, r *http.Request) { clientIP := getClientIP(r) // 提取实际访问者的 IP，用于日志记录 queryIPStr := strings.TrimSpace(r.URL.Query().Get("ip")) targetIP := queryIPStr if targetIP == "" { targetIP = clientIP } parsedIP := net.ParseIP(targetIP) if parsedIP == nil || parsedIP.To4() == nil { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 400, Msg: "非法的 IPv4 地址喵！", Data: nil}) return } ipInt := uint32(parsedIP.To4()[0])<<24 | uint32(parsedIP.To4()[1])<<16 | uint32(parsedIP.To4()[2])<<8 | uint32(parsedIP.To4()[3]) if useMemoryCache { idx := sort.Search(len(ipCache), func(i int) bool { return ipCache[i].NetworkStart > ipInt }) if idx > 0 { rule := ipCache[idx-1] if ipInt <= rule.NetworkEnd { result := rule.Info result.IP = targetIP sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 200, Msg: "success", Data: result}) return } } sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "内存库里没有找到这个 IP 喵~", Data: nil}) } else { var infoJSON string var networkEnd uint32 err := db.QueryRow(` SELECT network_end, ip_info_json FROM ip_info WHERE network_start <= ? ORDER BY network_start DESC LIMIT 1`, ipInt).Scan(&networkEnd, &infoJSON) if err != nil { if err == sql.ErrNoRows { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) } else { log.Printf("数据库查询错误: %v\n", err) sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 500, Msg: "数据库查询出错了喵", Data: nil}) } return } if ipInt > networkEnd { sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 404, Msg: "数据库里没有找到这个 IP 喵~", Data: nil}) return } var rawData map[string]string if err := json.Unmarshal([]byte(infoJSON), &rawData); err != nil { log.Printf("JSON 解析错误: %v\n", err) sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 500, Msg: "数据解析失败喵", Data: nil}) return } result := IPInfo{ IP: targetIP, Country: rawData["country"], Province: rawData["province"], City: rawData["city"], ISP: rawData["isp"], Latitude: rawData["latitude"], Longitude: rawData["longitude"], } sendJSONResponse(w, clientIP, targetIP, APIResponse{Code: 200, Msg: "success", Data: result}) } } func main() { dbPath := flag.String("db", "ip_info.db", "SQLite 数据库文件路径") port := flag.String("port", "8080", "API 服务监听端口") memFlag := flag.Bool("mem", false, "是否开启全量内存模式（内存换取极致性能喵~）") logFlag := flag.Bool("log", false, "是否开启详细访问日志输出") flag.Parse() useMemoryCache = *memFlag enableDetailLog = *logFlag // 赋值给全局变量 var err error db, err = sql.Open("sqlite3", *dbPath) if err != nil { log.Fatal("无法打开数据库: ", err) } defer db.Close() if useMemoryCache { if err := loadDataToMemory(); err != nil { log.Fatal("致命错误：无法将数据加载到内存喵: ", err) } } http.HandleFunc("/ipinfo", apiHandler) addr := fmt.Sprintf(":%s", *port) fmt.Printf("猫娘 API 服务启动于 %s 喵...\n", addr) fmt.Printf("当前数据库文件: %s\n", *dbPath) if useMemoryCache { fmt.Println("当前运行模式: [极致性能] 全量内存 + 二分查找") } else { fmt.Println("当前运行模式: [省内存] SQLite 实时查询 (可加 -mem=true 提速)") } if enableDetailLog { fmt.Println("详细访问日志: 已开启 (将在控制台打印每次请求细节)") } else { fmt.Println("详细访问日志: 未开启 (可加 -log=true 开启)") } log.Fatal(http.ListenAndServe(addr, nil)) }另外，我还加上了日志输出的功能，方便进行API调用的监控，可以防范滥用。经过测试，在把数据库存进内存后，可以抗的并发请求从 5000+ 请求/秒 提升到了 7000+ 请求/秒，提升了40%！不过我也观察到，此时Nginx的CPU占用率明显上升，大约占据了40%+的CPU，而Go程序本身的CPU占用率则从50%左右降低到了35%！不过有点奇怪的是，大约有20%的CPU占用率不知道是被什么程序占据了，BTOP没有显示出来。其实在第一版代码的时候，Nginx的CPU占用已经挺高了，请求数量提升后更高。值得注意的是，两个版本的服务端进行极限压力测试的时候，整机的CPU占用率几乎都是100%。不知道各位对这个性能表现感觉如何？分析：“消失”的20% CPU占用去哪了？在测试中，我观察到一个有趣的现象：Nginx 占用了约 40%，Go 程序占用了约 35%，加起来只有 75% 左右，但整机 CPU 却已经打满（100%）。剩下的 20% 去哪了？难道被猫娘偷吃了吗？其实，这并非 BUG，而是高性能网络服务的典型特征——内核态开销（System CPU）。当 QPS 达到 7000+ 时，服务器每秒需要处理数万个网络数据包的接收、发送、上下文切换以及系统调用。这些操作并不发生在 Nginx 或 Go 程序的“用户态”代码中，而是直接由 Linux 内核 接管处理：网络协议栈处理：TCP/IP 包的解析、校验、重组。软中断（SoftIRQs）：网卡中断后的数据搬运。线程调度：在数千个并发连接间快速切换 CPU 时间片。结论：这“消失”的 20% 实际上是被 操作系统内核 消耗掉了，用于支撑如此高频率的网络吞吐。这也侧面证明了我们的程序优化已经非常到位，瓶颈不再在于应用层代码，而在于底层的网络 IO 处理能力。如果再想提升，可能就需要考虑内核参数调优（如调整 TCP 缓冲区、开启 RSS 多队列网卡等）或者升级更强的单核 CPU 了喵！提升程序的兼容性因为我们的项目依赖了go-sqlite3，这要求必须开启CGO进行编译。如果在一般的Linux发行版（如Ubuntu、CentOS）上直接编译，默认会动态链接系统的C语言标准库（glibc）。这必然会导致把程序放到不同年代、不同发行版的服务器上运行时，出现“找不到glibc版本”等兼容性报错。为了达到“一次编译，到处运行”的完美兼容性，我们需要进行纯静态链接打包。但是，在普通发行版上强行静态编译 glibc 是非常痛苦且容易踩坑的。因此，我们需要借助musl这个极其精简的C标准库。使用musl的典型代表就是Alpine Linux（这也是老朋友了，我的云服务器基本上都是这个发行版）。我们可以非常优雅地在 Alpine 环境下进行全静态编译操作：先安装必要的 C 语言编译工具链：apk add go gcc musl-dev进入代码目录后，初始化模块并拉取依赖：go mod init github.com/Chocola-X/NekoIPinfo go mod tidy最后，使用“静态编译参数”进行编译：CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-linkmode external -extldflags -static -s -w" -o neko-ip-api main.go这样编译出来的二进制文件内部已经打包了所有需要的底层库。你把它丢到绝大部分 x86_64 架构的 Linux 发行版上，都可以做到直接开箱即用！结尾项目已经开源到了Github：NekoIPinfo并且我也自己搭建了查询服务，感兴趣的可以来玩玩：NekoIPinfo Demo
使用Python把CAD高程图实体化为3D模型因为设计课程的需要，我需要为场地建立一个直观的3D模型进行形体推敲。但是这次老师给的CAD图纸只有高程点，等高线是缺失的，所以我希望是利用这些高程点的信息进行自动化建模，快速生成场地地形模型。转换CAD首先，因为CAD比较凌乱，我们需要整理出一个只包含了高程点的CAD图，并且导出为DXF格式，方便后续的Python程序进行对接。原CAD：处理后的CAD:处理完成后，导出DXF格式的文件，为Python程序化建模做准备。转化程序因为blender的Python环境不方便安装别的库，所以处理程序分为两个部分：第一部分使用系统安装的Python环境进行处理，使用json格式保存转好后的中间结果，也就是每个点的坐标.第二部分使用Blender的Python环境，读取已经处理好的地形信息json文件，进行建模。首先是第一部分的处理dxf文件：import ezdxf import json import re import os # --- 配置文件路径 --- DXF_FILE_PATH = "Drawing2.dxf" JSON_OUTPUT_PATH = "topography.json" def dxf_to_json(dxf_path, json_path): print(f"正在读取 DXF 文件: {dxf_path} ...") if not os.path.exists(dxf_path): print(f"错误：找不到文件 '{dxf_path}'。") return try: doc = ezdxf.readfile(dxf_path) except Exception as e: print(f"读取 DXF 失败: {e}") return msp = doc.modelspace() points = [] # 用于存储坐标的列表 for entity in msp.query('TEXT MTEXT'): try: text_str = entity.dxf.text # 提取数字（支持负数和小数） match = re.search(r'[-+]?\d*\.?\d+', text_str) if match: z = float(match.group()) x = entity.dxf.insert.x y = entity.dxf.insert.y # 将坐标作为一个小列表存入总列表 points.append([x, y, z]) except Exception: continue if points: # 将数据转储为 JSON 格式 with open(json_path, 'w', encoding='utf-8') as f: json.dump(points, f, indent=2) # indent=2 让 JSON 文件格式化，方便人类阅读 print(f"✅ 成功！共提取了 {len(points)} 个坐标点。") print(f"数据已保存为 JSON 文件: {json_path}") else: print("❌ 提取失败：未找到有效数据。") # 运行脚本 dxf_to_json(DXF_FILE_PATH, JSON_OUTPUT_PATH)以上代码在系统的Python环境运行，记得安装对应的Python库ezdxf。然后是在Blender里面运行的Python代码：import bpy import json import mathutils # --- 配置参数 --- JSON_FILE_PATH = "/run/media/chocola/3T-DATA02/设计/场地模型/topography.json" def create_terrain_from_json(filepath): # 1. 读取 JSON 数据 try: with open(filepath, 'r', encoding='utf-8') as f: points = json.load(f) except Exception as e: print(f"❌ 读取 JSON 失败: {e}") return if not points: print("❌ JSON 文件中没有数据！") return print(f"成功读取 {len(points)} 个坐标点，开始处理...") # 2. 计算边界框中心，处理坐标过大导致的浮点精度问题 min_x = min(p[0] for p in points) max_x = max(p[0] for p in points) min_y = min(p[1] for p in points) max_y = max(p[1] for p in points) center_x = (min_x + max_x) / 2.0 center_y = (min_y + max_y) / 2.0 verts_3d = [] verts_2d = [] # Delaunay 算法只需要 2D 平面坐标 for p in points: # 减去中心点坐标，将整个地形移至原点 (0,0,0) 附近 cx = p[0] - center_x cy = p[1] - center_y cz = p[2] verts_3d.append((cx, cy, cz)) verts_2d.append(mathutils.Vector((cx, cy))) # 3. 执行 2D 德劳内三角剖分 print("正在计算地形拓扑结构...") # delaunay_2d_cdt 返回值包含: (vertices, edges, faces, is_valid) # 我们只需要提取面 (faces) 的索引数据 try: result = mathutils.geometry.delaunay_2d_cdt(verts_2d, [], [], 0, 0.0001) faces = result[2] except Exception as e: print(f"❌ 三角剖分计算失败: {e}") return # 4. 创建 Blender 网格并赋予数据 mesh = bpy.data.meshes.new("Terrain_Mesh") # from_pydata 极其高效：直接通过顶点列表和面列表生成网格 mesh.from_pydata(verts_3d, [], faces) # 开启平滑着色 for poly in mesh.polygons: poly.use_smooth = True mesh.update() # 5. 生成物体并放入场景 obj = bpy.data.objects.new("Site_Terrain", mesh) bpy.context.collection.objects.link(obj) # 选中生成的物体 bpy.ops.object.select_all(action='DESELECT') bpy.context.view_layer.objects.active = obj obj.select_set(True) print(f"✅ 地形生成完毕！") print(f"提示: 原 CAD 坐标中心点已偏移：X={center_x:.2f}, Y={center_y:.2f}") # 运行脚本 create_terrain_from_json(JSON_FILE_PATH)最终效果按顺序执行了两个程序后，你的Blender里面应该就已经出现了对应的地形文件了。当然，是单面的模型，后续可以进行实体化操作等按需调整。还有一个需要提醒的是，生成的模型是三角面的模型，并不是Blender建模推荐的四边形建模，并且布线并不规整。所以这个模型是能用，但是不方便修改。
主题开发日志02：前端界面开发基本完成
博客主题前端界面开发工作基本完成经过一个多星期与Gemini 3 Pro的反复拉扯，终于是把主要的三个部分样式做完了：首页+文章列表+独立页面，文章阅读页面，后台管理页面。感觉还是相当不错的！因为还没开发完成，我这里只放截图。文章首页效果对，没错，我将会把动态立绘放在首页！不过因为立绘很吃性能，所以我特地做了优化，当不在首页浏览时，立绘渲染会暂停节省性能。文章列表效果独立页面效果文章页面效果细心的同学应该已经发现了，对话框是贯穿始终的，而且还是nekopara的风格。没错！对话框将会是主题的一大特色，作用相当于网站的导航栏，同时也会在特定的按钮和链接给出相关的提示，让你感觉像是在玩galgame!后台效果后台页面就完全按照nekopara的设置界面风格来制作了。目前主题的前端开发工作做的差不多了，接下来就是怎么缝合到typecho上面了。
给NekoWebShow加载提速为期三个月的实习结束了，准备迎来寒假了，我在想要不要做一些有意思的事情。受到Bhao小姐姐博客的启发，我萌生了想要重新装修博客的想法，主题也很明确——猫娘乐园主题。但是，如果还是像我现在这样，用别人的主题改一改，个性化一下，或许还是太没意思了。于是乎，我就想看看能不能自己从零开始做一个主题。真正驱动我做这件事的，是半年前我搞出来了那个NekoWebShow项目，我希望把巧克力的动态立绘直接放在博客首页，只有首页有这种持续的动画效果，其他页面就是静态展示，这样子的话就不会像现在那样打开我的网站后一直占用性能。我希望可以做到只在首页占用一些性能展示动态立绘，而往下浏览查看其他内容的时候，就没有活动的元素了。一方面可以凸显重点，保持整套主题风格的连贯性，另一方面节省访客电脑的性能。当时还是有点犹豫要不要搞，在Bhao小姐姐的群里问了一下，她坚定地回了一个字——“做！”她随后说其实用框架的话就和搭积木差不多，不是很难，于是我下定决心，开干。而今天完成的工作则算是这个主题正式开工之前的前置工作——优化NekoWebShow加载速度为什么需要优化尽管目前的猫娘展示网页运行正常，但是由于模型文件较大，达到了32MB或者64MB，会导致较长的加载时间，如果网速慢那更是雪上加霜。对于纯粹的猫娘展示网页还是可以接受，但是对于个人博客那就是致命的——访客可能会嫌打开太久离开。所以说，这个是必须要优化的。还有一点是，虽然这些专有格式体积庞大，但是转换前的文件却不算大——3-4MB左右的png纹理文件+3MB左右的json定义文件，很明显应该是有办法可以压缩下来的。优化传输的探索——碰壁asm.js一开始，我是希望逆向JS，顺着模型文件的传输路径去找到对应的处理代码，然后求助于AI解决。但是，可悲的是，我千辛万苦挖了半个小时，结果挖出来一段奇怪的js代码，例如：// EMSCRIPTEN_END_FUNCS var vd=[uB,Be,Ke,Fp,Iz,Pz,fA,uB];var wd=[vB,$e,df,ff,hf,eg,jg,lg];var xd=[wB,re,te,Ee,Fe,Qe,Re,We,Xe,Lf,bg,cg,dg,gg,wv,xg,Ji,Gp,yg,zg,Ne,Ag,Iu,Cg,Ki,Dg,Np,Hg,Qp,Rp,Li,an,bn,un,vn,Ln,Mn,Sn,Tn,bo,co,jo,ko,Co,Do,Xo,Yo,lp,mp,vp,wp,hz,Dp,Ep,Op,Ju,xv,Ew,Fw,Cz,Dz,Ez,Fz,Nz,Xz,Yz,$z,bA,dA,ly,Tz,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB,wB];var yd=[xB,ch];var zd=[yB,we,ye,Ce,Ge,He,Ie,Ve,Ze,bf,uf,Xf,hg,ng,zv,Ip];var Ad=[zB,Xg,vi,zB];var Bd=[AB,ue,ve,vg,xe,ze,De,Se,Te,wg,Ue,Ye,_e,cf,rf,vf,xf,Mf,Of,Qf,ag,fg,ig,og,pg,rg,Hp,gz,gy,Zz,sh,th,uh,Qg,oh,qh,Jh,Ch,Dh,Fh,Eh,xi,ci,zi,yh,xh,Bh,Bi,zh,_h,$h,wh,AB,AB,AB,AB,AB,AB,AB,AB,AB,AB,AB,AB];var Cd=[BB,jh];var Dd=[CB,Yf];var Ed=[DB,hi];var Fd=[EB,qf,_f,$f];var Gd=[FB,gi];var Hd=[GB,Gf];var Id=[HB,mf,of,sf,If,HB,HB,HB];var Jd=[IB,bh];var Kd=[JB,fh,wi,JB];var Ld=[KB,ih];var Md=[LB,Af,Eg,Mi,hy,iy,ny,Ny,Gz,aA,cA,Ng,Rg,Yg,Zg,_g,di,ah,Wh,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB,LB];var Nd=[MB,ui];var Od=[NB,Wg,li,NB];var Pd=[OB,Hz,Oz,eA];var Qd=[PB,Hh,Ih,PB];var Rd=[QB,Je,lf,Vf,Bg,Pp,QB,QB];var Sd=[RB,Kf];var Td=[SB,af,ef,gf,jf,nf,pf,tf,kg,mg,qg,Jf,Gi,Hi,Ig,Sp,Kh,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB,SB];var Ud=[TB,kf];var Vd=[UB,Ci,ji,ni,fi,ki,ti,ri];var Wd=[VB,Cf];var Xd=[WB,gh];var Yd=[XB,yi];var Zd=[YB,pi,oi,ii,mi,YB,YB,YB];var _d=[ZB,yf,zf,Nf,Pf,Rf,Sf,Tf,Wf,sg,yv,Fg,Ni,mh,Og,Pg,nh,Zh,Gh,ei,Oh,Nh,ai,Ai,bi,Yh,Ah,Qh,Ph,kh,Mh,qi,si,qr,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB,ZB];var $d=[_B,Jp,Sg,_B];var ae=[$B,Bf,Ff,Vg,Xh,Vh,$B,$B];var be=[aC,Ef];var ce=[bC,wf,Df,Uf,Zf,tg,Rh,Th,Sh,Uh,lh,Lh,bC,bC,bC,bC];var de=[cC,Ug];var ee=[dC,rh,ph,dC];var fe=[eC,Tg];var ge=[fC,vh,$g,dh];var he=[gC,eh,hh,gC];var ie=[hC,iC,zz,Yq,Sz,hC,hC,hC];var je=[jC,Ae,Le,Hf,Jz,Qz,gA,jC];return{_EmotePlayer_Update:Ci,_EmotePlayer_SetStereovisionVolume:ti,_EmotePlayer_FadeInTimeline:Hh,_EmotePlayer_GetVariableLabelAt:Yh,_bitshift64Lshr:sA,_EmotePlayer_SetColor:gi,_EmotePlayer_GetMainTimelineLabelAt:Oh,_EmotePlayer_SetOuterForce:li,_EmotePlayer_GetState:Rh,_EmotePlayer_StopTimeline:Ai,_EmoteDevice_DeleteEmoteTexture2:oh,_memcpy:tA,_EmotePlayer_SetMeshDivisionRatio:ki,_EmotePlayer_SetVariable:vi,_EmotePlayer_Initialize:Zh,_EmotePlayer_CountPlayingTimelines:zh,___udivmoddi4:xA,_EmotePlayer_Pass:ci,___cxa_can_catch:mA,_EmotePlayer_GetCharaProfile:Lh,_EmotePlayer_SetVariableDiff:wi,_free:hz,_EmoteDevice_Initialize:rh,_main:Di,_EmotePlayer_SetStereovisionRenderScreen:si,_EmotePlayer_GetCharaHeight:Kh,_EmotePlayer_IsCharaProfileAvailable:$h,_EmotePlayer_SetOuterRot:mi,___cxa_is_pointer_type:nA,_llvm_cttz_i32:wA,_EmotePlayer_GetDiffTimelineLabelAt:Nh,_EmotePlayer_IsTimelinePlaying:bi,_EmotePlayer_Step:zi,_EmotePlayer_SetGrayscale:ii,_EmotePlayer_GetVariableFrameLabelAt:Wh,_EmotePlayer_SetTimelineBlendRatio:ui,_EmoteDevice_CreateEmoteTexture2:nh,_EmotePlayer_SetScale:pi,_EmoteDevice_ChangeFrameBufferSize:mh,_EmotePlayer_DrawToTexture:Fh,_EmotePlayer_CountDiffTimelines:xh,_EmotePlayer_PlayTimeline:di,_EmotePlayer_GetVariableDiff:Vh,_EmotePlayer_SetBustScale:fi,_roundf:rA,_EmotePlayer_CountVariableFrameAt:Ah,_EmotePlayer_SetRot:oi,_EmotePlayer_GetTimelineBlendRatio:Sh,_EmotePlayer_IsAnimating:_h,_EmotePlayer_SetStereovisionEnabled:qi,_EmotePlayer_Draw:Dh,_sbrk:uA,_memset:zA,_EmotePlayer_Finish:Jh,_EmotePlayer_FadeOutTimeline:Ih,_EmotePlayer_DrawToScreen:Eh,_i64Subtract:pA,_EmotePlayer_Skip:xi,_EmotePlayer_CountMainTimelines:yh,___muldsi3:BA,_EmotePlayer_GetCharaProfileLabelAt:Mh,_EmotePlayer_GetPlayingTimelineFlagsAt:Ph,_EmotePlayer_SetAsOriginalScale:ei,_malloc:gz,_EmotePlayer_GetPlayingTimelineLabelAt:Qh,_EmotePlayer_StopWind:Bi,_EmoteDevice_SetMaskRegionClipping:th,_EmotePlayer_AttachRenderTexture:vh,_EmotePlayer_DrawToTexture2:Gh,___udivdi3:AA,_EmotePlayer_IsLoopTimeline:ai,_bitshift64Shl:DA,_EmotePlayer_GetVariableFrameValueAt:Xh,_EmotePlayer_SetHairScale:ji,_EmotePlayer_StartWind:yi,_EmotePlayer_SetStereovisionParallaxRatio:ri,___muldi3:CA,_EmotePlayer_CountVariables:Bh,___uremdi3:yA,_EmotePlayer_SetPartsScale:ni,_i64Add:qA,_pthread_self:EA,_EmoteDevice_SetProtectTranslucentTextureColor:uh,___getTypeName:fy,___errno_location:ky,_EmotePlayer_SetCoord:hi,_EmotePlayer_GetVariable:Uh,_memmove:vA,_EmotePlayer_CountCharaProfiles:wh,_EmoteDevice_Finish:ph,_EmoteDevice_GetEmoteTexture2TexId:qh,_EmotePlayer_DetachRenderTexture:Ch,_EmoteDevice_SetMaskMode:sh,_EmotePlayer_GetTimelineTotalFrameCount:Th,__GLOBAL__sub_I_emsbind_cpp:Lg,__GLOBAL__sub_I_bind_cpp:dy,runPostSets:oA,stackAlloc:ke,stackSave:le,stackRestore:me,establishStackSpace:ne,setThrew:oe,setTempRet0:pe,getTempRet0:qe,dynCall_viiiii:FA,dynCall_vid:GA,dynCall_vi:HA,dynCall_iiiidd:IA,dynCall_vii:JA,dynCall_iiiddd:KA,dynCall_ii:LA,dynCall_iiiddddd:MA,dynCall_viidddi:NA,dynCall_iidddd:OA,dynCall_viidd:PA,dynCall_iidddddd:QA,dynCall_viidddd:RA,dynCall_viddd:SA,dynCall_iiiidddi:TA,dynCall_iiiiddd:UA,dynCall_iiiidddd:VA,dynCall_iiii:WA,dynCall_iiidddi:XA,dynCall_iiidddd:YA,dynCall_viiiiii:ZA,dynCall_iiidd:_A,dynCall_viii:$A,dynCall_viddddd:aB,dynCall_di:bB,dynCall_vidddd:cB,dynCall_iid:dB,dynCall_viiddd:eB,dynCall_iiiiiddd:fB,dynCall_iiddddd:gB,dynCall_iiddd:hB,dynCall_iii:iB,dynCall_iiiiii:jB,dynCall_diii:kB,dynCall_viiiddd:lB,dynCall_dii:mB,dynCall_iiidddddd:nB,dynCall_i:oB,dynCall_iiid:pB,dynCall_iiiii:qB,dynCall_diiii:rB,dynCall_v:sB,dynCall_viiii:tB}})我寻思着是谁能写出来这么抽象的js代码，后面经过和LLM近一个多小时的拉扯和了解，得知这个js是用C或者C++通过Emscriptenn编译而来，属于asm.js，或更准确地说，是Emscripten生成的胶水代码。需要修改实现新功能，难度不亚于汇编，因为整个代码高度耦合，是在用js去模拟C的运行环境。并且多次好心劝退我不要尝试去修改这部分的代码，即使这些代码确实都是合规的JS代码。我第一次意识到，有些JS代码虽然语法合法，却因高度混淆和底层模拟，几乎无法安全修改或逆向理解。然后也意识到修改功能实现这个是死胡同了，只能另寻他法。优化传输的探索——轮子套轮子既然已经知道这段抽象的js代码不好惹，那就曲线救国。因为我发现这些模型文件，是经过处理后变大的，而且无论原本的png大小是多少，只要是4096x2048的纹理，处理后就是32MB，只要是4096x4096的纹理，处理后就是64MB。我就意识到这个文件里面肯定包含了很多空数据，应该使用压缩工具可以很好压缩。实验后事实的确如此——压缩后的.zip大概是3-4MB左右的大小，甚至2MB。于是，我的思路就转换过来了：服务器发送压缩后的文件，浏览器前端解压，然后把解压后的文件传给后续的js处理，这样就不需要动那一大堆如同狗屎的js了。为了解决这个问题，我打算引入fflate.js。fflate.js是一个js库，可以支持在前端的浏览器内进行压缩文件解压的操作，这个库也是LLM给我推荐的，后面试了一下确实很不错。首先是把js下载回来，并在html中调用： 接下来是修改代码逻辑： //增加前端解压zip功能，减少数据传输量 try { // 1. Fetch the ZIP file const resp = await fetch(zipUrl); if (!resp.ok) throw new Error(`Failed to load ${zipUrl}`); const zipData = new Uint8Array(await resp.arrayBuffer()); // 2. Decompress ZIP using fflate const files = await new Promise((resolve, reject) => { fflate.unzip(zipData, (err, unzipped) => { if (err) { console.error("ZIP decompression error:", err); reject(err); } else { resolve(unzipped); } }); }); // 3. Find the .psb model file (assume only one .psb file) const binFileName = Object.keys(files).find(name => name.endsWith('.psb')); if (!binFileName) { throw new Error("No .psb file found in ZIP"); } const modelData = files[binFileName]; // 4. Create blob URL for EmotePlayer const blob = new Blob([modelData], { type: 'application/octet-stream' }); const fakeUrl = URL.createObjectURL(blob); // 5. Load model await player.promiseLoadDataFromURL(fakeUrl); // ===== Model loaded successfully ===== document.getElementById('loading').innerHTML = "Done!"; setTimeout(() => { document.getElementById('loading').style.visibility = "hidden"; }, 1000);这部分的代码，改为获取zip压缩文件，解压后放在浏览器的blob上，然后给后续的player.promiseLoadDataFromURL()进行调用。这样一来，就可以极大的降低传输文件的流量消耗和等待时间。不过，需要注意的是，创建压缩文件的时候，需要内部只有一个文件，并且命名符合规范，采用的压缩算法需要是DEFLATE，否则会不支持。批量处理pure.psb文件因为角色模型文件有点多，一个一个手动压缩比较花时间，于是我就用bash脚本批量处理：#!/bin/bash # 遍历当前目录下所有 .pure.psb 文件 while IFS= read -r -d '' file; do # 构造目标 ZIP 文件名 zip_file="${file}.zip" # 如果 ZIP 已存在，跳过（可选：如需强制覆盖，请删除此 if 块） if -e "$zip_file" ; then echo "Skip: $zip_file already exists." continue fi echo "Compressing: $file -> $zip_file" # 使用 zip 最大压缩级别 (-9)，且只存一个文件（-j 可选，但建议保留路径结构） # 注意：这里不加 -j，保留文件名；若你希望 ZIP 内部无路径，可加 -j zip -9 -q "$zip_file" "$file" if $? -eq 0 ; then echo "✅ Success: $zip_file created." else echo "❌ Failed to compress: $file" fi done < <(find . -maxdepth 1 -type f -name "*.pure.psb" -print0) echo "All done!"保存为compress_psb.sh放在模型文件夹内，直接执行就可以全部完成压缩了。目前以上部分的代码改动和优化，已经同步到了NekoWebShow，访问的加载速度应该也可以变快了。番外：增加动画暂停功能接下来这个功能就是为了服务我博客主题构建的了——动画暂停因为我设想是动态的E-Mote立绘只放在首页，而浏览文章目录的时候不会展示，会滑上去看不到，这时如果还是继续运行就有点太浪费性能了。于是，我希望动画可以按需暂停和恢复，而且我想做的页面是一下子就滑掉的，所以只要把控好时机暂停和恢复，对用户观看页面就基本无感。感谢LLM，在把代码给LLM看过后，它说原始代码虽然没有这个功能，但是实现起来不难，只需要在加载播放器完成后执行下面的代码：// monkey-patch 在加载完 EmotePlayer 后执行，实现动画按需暂停，节省性能 // 暂停命令：EmotePlayer.device.pause(); 恢复命令：EmotePlayer.device.resume(); if (EmotePlayer.device) { EmotePlayer.device._originalKick = EmotePlayer.device.kickAnimation; EmotePlayer.device.isManuallyPaused = false; EmotePlayer.device.pause = function() { this.isManuallyPaused = true; if (this.animating) { this.animating = false; cancelAnimationFrame(this.requestId); } }; EmotePlayer.device.resume = function() { this.isManuallyPaused = false; this._originalKick(); // 触发原逻辑 }; // 重写 kickAnimation，使其尊重手动暂停 EmotePlayer.device.kickAnimation = function() { if (this.isManuallyPaused) return; this._originalKick(); }; } // monkey-patch END将这段代码插入到run()函数后面即可实现。这时，在控制台执行EmotePlayer.device.pause();即可实现动画暂停，CPU利用率回到0%需要恢复时，执行EmotePlayer.device.resume();即可恢复动态立绘渲染。
断网自动重连！用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），那只需要吧传入的字典部分改成八个对应的键值对即可。希望这个风扇调节脚本可以帮到你。