利用Cloudflare Pages搭建媒体库实战前段时间极客湾因为公开揭露手机厂商的龌龊行径——媒体特调机内幕，导致其视频被全网下架。随后极客湾开放视频转载权限，全网随即上演了补档大战，来表达对厂商捂嘴的不满。我也是拿到了极客湾的视频原文件，看了一眼，好家伙，8.4GB，还是4K 60FPS的视频，这要分享还有点费流量。在平常的一次去吃午饭的路上，我走在校园的道路上，突然灵光一闪：如果我能把这个视频放到Cloudflare Pages，岂不美哉？！但是，我突然想起来了Cloudflare Pages免费版的限制：25MB单文件大小——这几乎无法放大型的视频。不过，有一句诗说得好：“山穷水尽疑无路，柳暗花明又一村。”那既然限制文件大小，那我把视频文件切开，每一个块符合大小限制，不就行了吗？！而免费版的Cloudflare Pages单个项目文件数量限制为20000个，也就是理论上可以放20000x25MB=5000000MB，也就是大约488GB——这已经比一个500G机械硬盘的实际可用空间465GB还大了！最重要的是，Cloudflare并不限制项目数量，理论上对于个人来说约等于无限容量了！说干就干，我随即开始了理论的验证。验证想法可行性通过查阅资料，我了解到现代的浏览器可以利用hls.js实现对切分后视频的播放，目前早就有了成熟的方案，且各种视频平台就在用。需要的视频形态是一个.m3u8的视频索引文件，外加一大堆视频切割的碎片.ts文件，而把普通的视频转成这样的工作，ffmpeg就能干！这下不就把技术路线跑通了嘛，赶紧验证一下想法：ffmpeg -hwaccel cuda -hwaccel_output_format cuda -i 零售机_游戏性能大横评_2026.mp4 \ -c:v h264_nvenc -preset p6 -profile:v high -level:v 5.2 \ -rc vbr -b:v 18M -maxrate 24M -bufsize 36M \ -g 180 -keyint_min 180 -sc_threshold 0 \ -c:a aac -b:a 192k \ -hls_time 3 -hls_playlist_type vod -hls_segment_filename "chunk_%04d.ts" \ video-0.m3u8通过调整每个块的时间，确保每个块都不超过25MB，符合Cloudflare Pages的文件大小限制。然后再写一个html用于播放： 极客湾-手机2026游戏性能大横评（补档）4K 60FPS 准备加载视频... 把转化后的视频碎片放到html同目录下的video-data文件夹，本地启动Nginx设定好网站目录，浏览器打开，果然可以正常播放。这下就好玩了！这里还有极客湾的测试视频，也是我方案验证阶段的效果，可以去看看：极客湾-手机2026游戏性能大横评（补档）4K 60FPS重要提醒需要注意的是，执行下面的程序之前，需要把你的Cloudflare账户的ID和令牌导入到命令行环境中，例如：export CLOUDFLARE_API_TOKEN="www.nekopara.uk" export CLOUDFLARE_ACCOUNT_ID="www.nekopara.uk"请替换为你自己的真实 Token 和 Account ID。其中创建API Token时，正确的选择权限选项是：账户 > Cloudflare Pages > 编辑还有一个最重要的提醒，这些操作其实不是正常的Pages产品使用方式，存在账户封禁或限制使用等风险，请务必使用新创建的独立账号进行操作，以免造成不必要的损失！尝试把成果上传Pages但是，在这一步，我就踩坑了，还是踩了大坑！正当我尝试把转化好的文件夹通过Cloudflare Pages的部署页面上传时，我遇到了大坑：上传大概进行半个小时后，网页疯狂弹出API 403报错。一开始我还以为是网络的问题，开启了魔法网络环境，结果问题依旧。这时我意识到，上传的API应该是有临时Token超时机制的，当文件数量太多，体积太大，上传的时间变长了，就会触发这个问题。似乎这一切无解了？我能做出来符合文件大小限制的静态网页目录，却无法完整上传。但是，在看了Cloudflare的提示后，我发现了一个可能的路径：用wrangler进行部署——这个可以支持上传20000个文件的项目。而网页上传限制为1000个文件。于是乎，我就安装了wrangler进行尝试：因为wrangler需要nodejs环境，我们先安装nodejs:pacman -S nodejs npm然后在安装wrangler，系统级安装命令如下：npm install -g wrangler当然，也可以在当前目录进行安装，这个更推荐：npx wrangler安装完成后，我们来进行上传：npx wrangler pages deploy ./page --project-name geekwan因为网速比较慢，我就留着电脑上传，自己去吃饭了。结果回来一看还是报错403，那看来这个接口不但是网页会存在时间限制，用命令行工具也会。难道说。。。真的无解了吗？在LLM的帮助下，我快速实现了这个测试脚本：import os import shutil import subprocess import time from pathlib import Path # ================= 配置区域 ================= # 需要执行的特定 Bash 命令 (例如：处理当前目录下的所有文件) # 注意：命令应该设计为处理当前源文件夹中的文件，或者你需要根据实际逻辑调整命令参数 BASH_COMMAND = "npx wrangler pages deploy ./page --project-name geekwan" # 源文件夹路径 SOURCE_DIR = Path("/run/media/chocola/3T-DATA02/Projects/Pages/tmp") # 目标文件夹路径 (如果不存在会自动创建) TARGET_DIR = Path("/run/media/chocola/3T-DATA02/Projects/Pages/page/video-data") # 每次循环移动的文件数量 FILES_PER_BATCH = 30 # 最大循环次数 (设置为 None 表示直到源文件夹为空为止) MAX_LOOPS = None # =========================================== def run_bash_command(cmd): """执行 Bash 命令并返回是否成功""" print(f"\n[执行命令] {cmd}") try: # shell=True 允许运行带管道、重定向等的复杂 bash 命令 result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) print("[命令输出]:", result.stdout.strip()) return True except subprocess.CalledProcessError as e: print(f"[命令失败]: {e}") if e.stderr: print("[错误信息]:", e.stderr.strip()) return False def move_files(src, dst, count): """从 src 移动 count 个文件到 dst""" # 获取源文件夹中的所有文件 (排除子文件夹，只处理文件) # listdir() 顺序不固定，如果需要特定顺序(如按时间)，请使用 sorted(os.listdir(...), key=...) files = [f for f in src.iterdir() if f.is_file()] if not files: return 0 # 限制移动数量 files_to_move = files[:count] moved_count = 0 for file_path in files_to_move: target_path = dst / file_path.name try: shutil.move(str(file_path), str(target_path)) print(f"[已移动] {file_path.name} -> {dst}") moved_count += 1 except Exception as e: print(f"[移动失败] {file_path.name}: {e}") return moved_count def main(): # 确保目录存在 SOURCE_DIR.mkdir(parents=True, exist_ok=True) TARGET_DIR.mkdir(parents=True, exist_ok=True) loop_count = 0 while True: # 检查是否达到最大循环次数 if MAX_LOOPS is not None and loop_count >= MAX_LOOPS: print("\n[结束] 已达到最大循环次数。") break # 检查源文件夹是否为空 current_files = [f for f in SOURCE_DIR.iterdir() if f.is_file()] if not current_files: print(f"\n=== 第 {loop_count + 1} 轮循环 ===") success = run_bash_command(BASH_COMMAND) print("\n[结束] 源文件夹已空，任务完成。") break print(f"\n=== 第 {loop_count + 1} 轮循环 ===") # 1. 执行特定的 Bash 命令 # 假设命令是处理当前源文件夹中的数据。如果命令依赖于具体文件名， # 你可能需要在命令中动态插入文件名，或者让命令自行发现文件。 success = run_bash_command(BASH_COMMAND) if not success: print("[警告] 命令执行失败，跳过本次文件移动，稍后重试或退出？(此处选择继续下一轮，也可改为 break)") # 如果命令失败至关重要，可以取消下面这行的注释来停止脚本 # break time.sleep(2) # 失败后稍微等待 loop_count += 1 continue # 2. 移动文件 moved = move_files(SOURCE_DIR, TARGET_DIR, FILES_PER_BATCH) if moved == 0: print("没有文件可移动。") loop_count += 1 # 可选：每轮循环之间的间隔 # time.sleep(1) if __name__ == "__main__": main()我先观察好大概每个切片多大，设置好每次移动的文件数量，就让程序每执行一次推送命令之前，移动一部分文件回到目标文件夹。当命令运行结束后，再次搬运内容，直到临时文件夹搬空。经过验证，方案完全可行，我也成功把页面部署到了Cloudflare Pages，接下来我就该考虑如何让这个部署行为自动化了。实现思路当我确定搞自动化的时候，我就已经想好了应该怎么安排了：首先，写一套配置文件驱动的前端UI静态文件，通过传入文件目录结构的配置文件，让这个UI能够导航并浏览目录的内容。一个模块负责转化，把媒体库大于25MB的内容压制到25MB以内，按照原始目录结构整理，并且整理成目录结构配置文件供前端UI使用。另一个模块负责把文件进行上传和部署，根据预设的规则调配好每次部署的文件大小，避免上传时间超时导致403。前端实现不得不说 Gemini 3 Pro 确实是目前前端的神，几下就弄出来了简洁高效的代码。但光有代码还不够，得把逻辑理顺，毕竟我们要搞的是一个能“自动导航”的媒体库，而不是死板的单页播放。核心思路其实很清晰：动态加载 + 流式播放。目录结构的动态加载既然文件是切分后上传的，传统的文件夹浏览肯定行不通，浏览器可不会帮你把几千个 .ts 碎片渲染成列表。我们需要一个“地图”，也就是之前提到的目录结构配置文件（比如 index.json）。这个 JSON 文件由后端脚本生成，记录了所有可用媒体的元数据：图片路径，音频路径，视频的标题和对应的 .m3u8 索引路径，甚至还包括字幕文件的路径。前端的任务就是 fetch 这个 JSON，然后动态渲染出文件列表。我写了一个简单的递归渲染逻辑，支持多级目录嵌套，这样哪怕你的媒体库深不见底，也能一层层点进去：async function fetchIndex() { try { const response = await fetch('./index.json'); const data = await response.json(); const defaultItem = findDefaultMedia(data); if (defaultItem) { playMedia(defaultItem); } directoryStack = [{ name: "媒体资产库", children: data }]; renderCurrentDirectory(); } catch (error) { console.error("加载文件索引失败:", error); fileTreeEl.innerHTML = ' 索引加载失败，请确保使用本地服务器运行 '; } }这样一来，用户看到的就是一个类似播放器文件列表的整洁界面，点击文件夹进入子目录，点击视频直接开看。HLS 视频流与字幕的完美融合重头戏来了：如何在一个页面里既流畅播放切分的 .ts 视频流，又能挂载外挂字幕（.vtt）？hls.js 的强大之处就在于它对字幕原生的支持。我们不需要自己解析字幕文件，只需要在配置 Hls 实例时，把字幕轨道喂给它就行。我在之前的播放逻辑上做了升级，增加了一个字幕轨道的管理器。当视频加载时，它会同时拉取字幕文件，并允许用户在播放器原生控制条里切换字幕语言或关闭字幕：function playMedia(item) { const { type, src, subtitles } = item; placeholder.style.display = 'none'; videoViewer.style.display = 'none'; audioViewer.style.display = 'none'; imageViewer.style.display = 'none'; videoViewer.pause(); audioViewer.pause(); if (hls) { hls.destroy(); hls = null; } if (type === 'video') { videoViewer.style.display = 'block'; videoViewer.querySelectorAll('track').forEach(t => t.remove()); if (subtitles && subtitles.length > 0) { subtitles.forEach(sub => { const track = document.createElement('track'); track.kind = 'subtitles'; track.label = sub.label; track.srclang = sub.srclang; track.src = sub.src; videoViewer.appendChild(track); if (sub.default) { track.track.mode = 'showing'; } }); } loadVideo(src); } else if (type === 'audio') { audioViewer.style.display = 'block'; audioViewer.src = src; audioViewer.play().catch(e => console.log(e)); } else if (type === 'image') { imageViewer.style.display = 'block'; imageViewer.src = src; } } function loadVideo(src) { if (Hls.isSupported() && src.endsWith('.m3u8')) { hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }); hls.loadSource(src); hls.attachMedia(videoViewer); hls.on(Hls.Events.MANIFEST_PARSED, () => { videoViewer.play().catch(e => console.log(e)); }); } else { videoViewer.src = src; videoViewer.play().catch(e => console.log(e)); } }最后的拼图至此，前端的骨架已经搭好了：入口：加载 index.json，渲染出漂亮的媒体库网格。导航：点击文件夹动态刷新列表，实现无限层级浏览。播放：点击视频，hls.js 接管，自动加载切片流和内嵌字幕轨道。剩下的就是加一点 CSS 动画让切换更顺滑，再适配一下移动端的触摸手势。看着原本躺在硬盘里吃灰的几百 GB 视频，现在变成了一个个可以在云端随意点播、带字幕、还能倍速播放的在线资源，甚至画质还能吊打B站！ MediaPages ☰ 媒体库 媒体资产库 Powered by MediaPages 请在左侧选择文件进行预览 接下来，就要把这套前端逻辑和之前的自动化上传脚本彻底打通，实现“丢进文件夹，自动变网站”的终极梦想了。转换模块这个模块也是开发过程中碰壁比较多的模块，因为涉及媒体的转化和大小控制，一不小心就超了，需要反复调试。在调试的过程中，我发现对于1080P 60帧的高质量番剧，每个切片6秒，180帧一个关键帧是很不错的选择。既可以保证每个文件有一定的大小不至于太过于碎片化占用上传文件数，又可以确保当番剧到了画面变化较大的地方，单个切片仍然可以控制在25MB的大小限制内。对应的参数为hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '6', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ]而对于4K，则应该设置每个切片3秒，同样是180帧一个关键帧。hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '24M', '-bufsize', '36M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ]注意'-hls_time', '3'这里，你也可以根据你的需要去尝试不同的参数，以及码率的设置部分'-b:v', '18M', '-maxrate', '24M', '-bufsize', '36M',，我这里只是提供一个建议性的数值。毕竟是小工具，代码量不大，自己改改就行。程序的大体逻辑就是，对于文件进行分别的处理，小于25MB的部分直接复制，如果是MKV的视频转化成浏览器支持的MP4视频。而大于25MB的音频和图片文件则直接进行压缩，确保大小符合要求。视频就进行上面说的切分转换。具体的音频转化函数：def compress_audio(input_path, output_path): logging.info(f"Starting precise audio compression: {input_path}") temp_output = output_path + '.tmp' try: duration = get_audio_duration(input_path) orig_bitrate = get_audio_bitrate(input_path) if duration <= 0: duration = 60 safe_target_bytes = 23.5 * MB calculated_kbps = int((safe_target_bytes * 8) / (duration * 1000)) target_kbps = calculated_kbps if orig_bitrate > 0: target_kbps = min(target_kbps, orig_bitrate) target_kbps = min(target_kbps, 320) target_kbps = max(target_kbps, 32) while True: cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:a', 'libmp3lame', '-b:a', f'{target_kbps}k', '-f', 'mp3', temp_output ] subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL) final_size = os.path.getsize(temp_output) if final_size <= MAX_SIZE or target_kbps <= 32: shutil.move(temp_output, output_path) break target_kbps = int(target_kbps * 0.8) if target_kbps < 32: target_kbps = 32 except Exception as e: logging.error(f"Error compressing audio {input_path}: {e}") shutil.copy2(input_path, output_path) finally: if os.path.exists(temp_output): os.remove(temp_output)小体积视频转化函数：def convert_video_to_mp4(input_path, output_path): """用于处理小于 MAX_SIZE 的视频，仅进行标准 MP4 转码以确保浏览器兼容性""" logging.info(f"Standardizing small video to MP4: {input_path}") target_output = os.path.splitext(output_path)[0] + '.mp4' hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-tune', 'hq', '-profile:v', 'high', '-cq', '22', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) return True except subprocess.CalledProcessError: sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:v', 'libx264', '-preset', 'slow', '-crf', '22', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) return True except Exception as e: logging.error(f"Failed to convert {input_path}: {e}") if os.path.exists(target_output): os.remove(target_output) return False大体积视频转化函数：def compress_video(input_path, output_dir): """采用 HLS 切片方式处理视频，支持全格式和 CUDA 硬件加速""" input_dir_path = os.path.dirname(input_path) base_name = os.path.splitext(os.path.basename(input_path))[0] video_folder = os.path.join(output_dir, base_name) os.makedirs(video_folder, exist_ok=True) logging.info(f"Starting high-performance HLS processing: {input_path}") output_playlist = os.path.join(video_folder, f"{base_name}-0.m3u8") hls_segments = os.path.join(video_folder, "chunk_%04d.ts") # 基于用户提供的高性能 CUDA 优化参数 hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '6', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.warning(f"CUDA failed, falling back to CPU for {base_name}. Error: {e}") sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale=format=yuv420p', '-c:v', 'libx264', '-preset', 'medium', '-crf', '20', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) # 智能字幕处理 (保持原逻辑不变) process_subtitles(input_dir_path, base_name, video_folder, input_path)以及对应可能的字幕处理：def process_subtitles(input_dir_path, base_name, video_folder, input_video_path): """提取并转换关联字幕""" try: source_items = os.listdir(input_dir_path) for item in source_items: if item.startswith(base_name) and item.lower().endswith(tuple(SUBTITLE_EXTENSIONS)): if item == os.path.basename(input_video_path): continue subtitle_file = os.path.join(input_dir_path, item) sub_name_without_ext = os.path.splitext(item)[0] suffix = re.sub(r'^[\.\-\_]+', '', sub_name_without_ext[len(base_name):]) if not suffix: suffix = "Default" vtt_filename = f"{base_name}_{suffix}.vtt" vtt_path = os.path.join(video_folder, vtt_filename) if item.lower().endswith('.ass'): filtered_subtitle = os.path.join(video_folder, f"temp_{suffix}.ass") try: with open(subtitle_file, 'r', encoding='utf-8') as f_in: lines = f_in.readlines() with open(filtered_subtitle, 'w', encoding='utf-8') as f_out: for line in lines: if line.startswith('Dialogue:'): parts = line.split(',', 9) if len(parts) >= 4: style_lower = parts[3].lower() words = re.sub(r'[^a-z0-9]', ' ', style_lower).split() if any(x in words for x in ['jp', 'jpn']) or '日' in style_lower or 'romaji' in style_lower: continue f_out.write(line) subprocess.run(['ffmpeg', '-y', '-i', filtered_subtitle, vtt_path], check=True, stderr=subprocess.DEVNULL) finally: if os.path.exists(filtered_subtitle): os.remove(filtered_subtitle) else: subprocess.run(['ffmpeg', '-y', '-i', subtitle_file, vtt_path], check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.error(f"Subtitle error for {base_name}: {e}")字幕处理主要是针对动画的，去除日文字幕，防止转化为VTT字幕后混乱。整理清楚逻辑后，我们就得到了convert.py:import os import sys import shutil import subprocess import argparse import json from PIL import Image import math import re import logging # 配置日志 logging.basicConfig( level=logging.INFO, format='[%(asctime)s] %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) # 常量定义 MB = 1024 * 1024 MAX_SIZE = 24 * MB # 24MB # 支持的媒体类型 IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg'} VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.wmv', '.webm'} SUBTITLE_EXTENSIONS = {'.ass', '.srt', '.vtt'} def get_audio_duration(file_path): cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', file_path ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if result.returncode == 0: return float(result.stdout.strip()) return 60 def get_audio_bitrate(file_path): cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'stream=bit_rate', '-of', 'default=noprint_wrappers=1:nokey=1', file_path ] result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) if result.returncode == 0: bitrate_str = result.stdout.strip().split('\n')[0] if bitrate_str and bitrate_str != 'N/A': return int(float(bitrate_str) / 1000) return 0 def compress_image(input_path, output_path): logging.info(f"Starting image compression: {input_path}") try: img = Image.open(input_path) if img.mode != 'RGB': img = img.convert('RGB') ext = os.path.splitext(output_path)[1].lower() if ext not in {'.jpg', '.jpeg', '.png', '.gif', '.webp'}: output_path = os.path.splitext(output_path)[0] + '.webp' quality = 95 while quality >= 20: img.save(output_path, 'WEBP', quality=quality) if os.path.getsize(output_path) <= MAX_SIZE: return quality -= 5 scale = 0.9 while scale > 0.3: new_size = (int(img.width * scale), int(img.height * scale)) img_resized = img.resize(new_size, Image.LANCZOS) quality = 95 while quality >= 20: img_resized.save(output_path, 'WEBP', quality=quality) if os.path.getsize(output_path) <= MAX_SIZE: return quality -= 5 scale -= 0.1 img.save(output_path, 'WEBP', quality=20) except Exception as e: logging.error(f"Image compression error: {e}") shutil.copy2(input_path, output_path) def compress_audio(input_path, output_path): logging.info(f"Starting precise audio compression: {input_path}") temp_output = output_path + '.tmp' try: duration = get_audio_duration(input_path) orig_bitrate = get_audio_bitrate(input_path) if duration <= 0: duration = 60 safe_target_bytes = 23.5 * MB calculated_kbps = int((safe_target_bytes * 8) / (duration * 1000)) target_kbps = calculated_kbps if orig_bitrate > 0: target_kbps = min(target_kbps, orig_bitrate) target_kbps = min(target_kbps, 320) target_kbps = max(target_kbps, 32) while True: cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:a', 'libmp3lame', '-b:a', f'{target_kbps}k', '-f', 'mp3', temp_output ] subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL) final_size = os.path.getsize(temp_output) if final_size <= MAX_SIZE or target_kbps <= 32: shutil.move(temp_output, output_path) break target_kbps = int(target_kbps * 0.8) if target_kbps < 32: target_kbps = 32 except Exception as e: logging.error(f"Error compressing audio {input_path}: {e}") shutil.copy2(input_path, output_path) finally: if os.path.exists(temp_output): os.remove(temp_output) def convert_video_to_mp4(input_path, output_path): """用于处理小于 MAX_SIZE 的视频，仅进行标准 MP4 转码以确保浏览器兼容性""" logging.info(f"Standardizing small video to MP4: {input_path}") target_output = os.path.splitext(output_path)[0] + '.mp4' hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-tune', 'hq', '-profile:v', 'high', '-cq', '22', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) return True except subprocess.CalledProcessError: sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-c:v', 'libx264', '-preset', 'slow', '-crf', '22', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '320k', '-movflags', '+faststart', target_output ] try: subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) return True except Exception as e: logging.error(f"Failed to convert {input_path}: {e}") if os.path.exists(target_output): os.remove(target_output) return False def compress_video(input_path, output_dir): """采用 HLS 切片方式处理视频，支持全格式和 CUDA 硬件加速""" input_dir_path = os.path.dirname(input_path) base_name = os.path.splitext(os.path.basename(input_path))[0] video_folder = os.path.join(output_dir, base_name) os.makedirs(video_folder, exist_ok=True) logging.info(f"Starting high-performance HLS processing: {input_path}") output_playlist = os.path.join(video_folder, f"{base_name}-0.m3u8") hls_segments = os.path.join(video_folder, "chunk_%04d.ts") # 基于用户提供的高性能 CUDA 优化参数 hw_cmd = [ 'ffmpeg', '-y', '-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale_cuda=format=nv12', '-c:v', 'h264_nvenc', '-preset', 'p6', '-profile:v', 'high', '-level:v', '5.2', '-rc', 'vbr', '-b:v', '0', '-maxrate', '8M', '-bufsize', '16M', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] try: subprocess.run(hw_cmd, check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.warning(f"CUDA failed, falling back to CPU for {base_name}. Error: {e}") sw_cmd = [ 'ffmpeg', '-y', '-i', input_path, '-map', '0:v:0', '-map', '0:a:0', '-vf', 'scale=format=yuv420p', '-c:v', 'libx264', '-preset', 'medium', '-crf', '20', '-g', '180', '-keyint_min', '180', '-sc_threshold', '0', '-c:a', 'aac', '-b:a', '320k', '-hls_time', '3', '-hls_playlist_type', 'vod', '-hls_segment_filename', hls_segments, output_playlist ] subprocess.run(sw_cmd, check=True, stderr=subprocess.DEVNULL) # 智能字幕处理 (保持原逻辑不变) process_subtitles(input_dir_path, base_name, video_folder, input_path) def process_subtitles(input_dir_path, base_name, video_folder, input_video_path): """提取并转换关联字幕""" try: source_items = os.listdir(input_dir_path) for item in source_items: if item.startswith(base_name) and item.lower().endswith(tuple(SUBTITLE_EXTENSIONS)): if item == os.path.basename(input_video_path): continue subtitle_file = os.path.join(input_dir_path, item) sub_name_without_ext = os.path.splitext(item)[0] suffix = re.sub(r'^[\.\-\_]+', '', sub_name_without_ext[len(base_name):]) if not suffix: suffix = "Default" vtt_filename = f"{base_name}_{suffix}.vtt" vtt_path = os.path.join(video_folder, vtt_filename) if item.lower().endswith('.ass'): filtered_subtitle = os.path.join(video_folder, f"temp_{suffix}.ass") try: with open(subtitle_file, 'r', encoding='utf-8') as f_in: lines = f_in.readlines() with open(filtered_subtitle, 'w', encoding='utf-8') as f_out: for line in lines: if line.startswith('Dialogue:'): parts = line.split(',', 9) if len(parts) >= 4: style_lower = parts[3].lower() words = re.sub(r'[^a-z0-9]', ' ', style_lower).split() if any(x in words for x in ['jp', 'jpn']) or '日' in style_lower or 'romaji' in style_lower: continue f_out.write(line) subprocess.run(['ffmpeg', '-y', '-i', filtered_subtitle, vtt_path], check=True, stderr=subprocess.DEVNULL) finally: if os.path.exists(filtered_subtitle): os.remove(filtered_subtitle) else: subprocess.run(['ffmpeg', '-y', '-i', subtitle_file, vtt_path], check=True, stderr=subprocess.DEVNULL) except Exception as e: logging.error(f"Subtitle error for {base_name}: {e}") def build_index_tree(dir_path, output_root): children = [] try: items = sorted(os.listdir(dir_path)) except Exception: return children for item in items: full_path = os.path.join(dir_path, item) raw_rel_path = os.path.relpath(full_path, output_root).replace('\\', '/') web_src = f"./{raw_rel_path}" if os.path.isdir(full_path): try: folder_items = os.listdir(full_path) except: folder_items = [] m3u8_files = [f for f in folder_items if f.lower().endswith('.m3u8')] if m3u8_files: m3u8_file = m3u8_files[0] m3u8_rel = os.path.relpath(os.path.join(full_path, m3u8_file), output_root).replace('\\', '/') subtitles = [] vtt_files = [f for f in folder_items if f.lower().endswith('.vtt')] base_name = item for vtt in vtt_files: vtt_name_no_ext = os.path.splitext(vtt)[0] suffix = vtt_name_no_ext[len(base_name)+1:] if vtt_name_no_ext.startswith(base_name + "_") else vtt_name_no_ext label = suffix suffix_lower = suffix.lower() if any(x in suffix_lower for x in ['sc', 'chs', 'zh']): srclang, default = "zh", True elif any(x in suffix_lower for x in ['tc', 'cht', 'hant']): srclang, default = "zh-Hant", False else: srclang, default = "und", False vtt_rel = os.path.relpath(os.path.join(full_path, vtt), output_root).replace('\\', '/') subtitles.append({ "label": label, "srclang": srclang, "src": f"./{vtt_rel}", "default": default }) video_node = {"name": item, "type": "video", "src": f"./{m3u8_rel}"} if subtitles: video_node["subtitles"] = subtitles children.append(video_node) else: folder_children = build_index_tree(full_path, output_root) if folder_children: children.append({"name": item, "type": "folder", "children": folder_children}) else: ext = os.path.splitext(item)[1].lower() if item == 'index.json': continue if ext in AUDIO_EXTENSIONS: children.append({"name": item, "type": "audio", "src": web_src}) elif ext in IMAGE_EXTENSIONS: children.append({"name": item, "type": "image", "src": web_src}) elif ext in VIDEO_EXTENSIONS: # 注意：如果已经被 HLS 处理，它将作为文件夹存在，这里处理的是未被转码的小视频 children.append({"name": item, "type": "video", "src": web_src}) return children def generate_index(output_dir, set_default=True): logging.info("Generating nested JSON index...") root_nodes = build_index_tree(output_dir, output_dir) if set_default: for node in root_nodes: if node.get("type") == "video": node["isDefault"] = True break index_path = os.path.join(output_dir, 'index.json') with open(index_path, 'w', encoding='utf-8') as f: json.dump(root_nodes, f, indent=2, ensure_ascii=False) def main(): parser = argparse.ArgumentParser(description='Universal Media Processing Tool') parser.add_argument('input_dir', help='Input directory path') parser.add_argument('output_dir', help='Output directory path') parser.add_argument('--no-default', action='store_true') args = parser.parse_args() os.makedirs(args.output_dir, exist_ok=True) logging.info("--- Media Processing Tool Started ---") for root, _, files in os.walk(args.input_dir): rel_path = os.path.relpath(root, args.input_dir) out_root = os.path.join(args.output_dir, rel_path) os.makedirs(out_root, exist_ok=True) for file in files: input_path = os.path.join(root, file) output_path = os.path.join(out_root, file) ext = os.path.splitext(file)[1].lower() file_size = os.path.getsize(input_path) # 检查是否已处理过 (MP4 转换或 HLS 文件夹) if ext in VIDEO_EXTENSIONS: base_name = os.path.splitext(file)[0] if os.path.exists(os.path.join(out_root, base_name, f"{base_name}-0.m3u8")): continue # --- 核心处理逻辑 --- if ext in IMAGE_EXTENSIONS: if file_size <= MAX_SIZE: shutil.copy2(input_path, output_path) else: compress_image(input_path, output_path) elif ext in AUDIO_EXTENSIONS: if file_size <= MAX_SIZE: shutil.copy2(input_path, output_path) else: compress_audio(input_path, output_path) elif ext in VIDEO_EXTENSIONS: # 无论 MKV 还是 MP4，大于 MAX_SIZE 都进行 HLS 高性能切片 if file_size > MAX_SIZE: compress_video(input_path, out_root) else: # 小视频统一转成兼容性好的 MP4 convert_video_to_mp4(input_path, output_path) elif ext in SUBTITLE_EXTENSIONS: if ext != '.ass': # .ass 由 compress_video 内部处理，此处仅复制 srt/vtt shutil.copy2(input_path, output_path) # 清理残留 ass for root_dir, _, out_files in os.walk(args.output_dir): for f in out_files: if f.lower().endswith('.ass'): try: os.remove(os.path.join(root_dir, f)) except: pass generate_index(args.output_dir, set_default=not args.no_default) logging.info("--- Processing Complete ---") if __name__ == "__main__": main()这个文件是可以单独调用的，具体的方法如下：python convert.py '/run/media/chocola/3T-DATA02/[hyakuhuyu&VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu 3rd Season [Ma10p_1080p]/' '/run/media/chocola/3T-DATA02/wwwroot/MediaPages/test2'第一个目录是来源目录，第二个目录是输出目录。以及还有一个参数--no-default，如果加上则不会选取文件夹根目录第一个文件作为打开页面时的默认展示媒体。上传模块这个部分就相对简单了，无非就是最开始那个测试demo的增强版。但是我稍微优化了一下，因为考虑到每个媒体库处理后的结果肯定都不一样，所以我设计成了计算文件大小，按照设置的每次上传的大小分批次移动文件。思路大概是这样的：设置一个缓存文件夹，先把目标文件夹的内容搬到小于设置的阈值大小，执行一次上传，上传完成后从缓存文件夹搬回来阈值大小内的文件，再次执行上传，如此往复，直到上传完成。程序大概就长这样：upload.pyimport os import shutil import subprocess import argparse from pathlib import Path def get_directory_size_and_files(directory: Path): """ 计算目录下所有文件的总大小，并返回文件列表及其大小信息。 """ total_size = 0 file_list = [] for root, dirs, files in os.walk(directory): for name in files: filepath = Path(root) / name try: size = filepath.stat().st_size total_size += size rel_path = filepath.relative_to(directory) file_list.append((rel_path, size, filepath)) except OSError as e: print(f"无法获取文件大小 {filepath}: {e}") # 按文件大小降序排列 file_list.sort(key=lambda x: x[1], reverse=True) return total_size, file_list def move_files_with_structure(file_list, source_base: Path, dest_base: Path): """ 将文件列表中的文件连同其目录结构一起移动到新位置。 """ for i, (rel_path, size, _) in enumerate(file_list): src_file = source_base / rel_path dest_file = dest_base / rel_path if src_file.exists(): dest_file.parent.mkdir(parents=True, exist_ok=True) try: shutil.move(str(src_file), str(dest_file)) if (i + 1) % 500 == 0 or (i + 1) == len(file_list): print(f" -> 已移动 {i + 1}/{len(file_list)} 个文件...") except Exception as e: print(f"[移动失败] {src_file}: {e}") print(f" -> 文件移动操作完成。") def run_bash_command(cmd): """执行 Bash 命令并返回是否成功""" print(f"\n[执行命令] {cmd}") print(" -> 命令开始执行...") try: result = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) print(" -> 命令执行完毕。") print("[命令输出]:", result.stdout.strip()) return True except subprocess.CalledProcessError as e: print(f"[命令失败]: {e}") if e.stderr: print("[错误信息]:", e.stderr.strip()) return False def run_deploy(target_dir, cache_dir, bash_cmd, threshold, resume=False): """ 接收外部传入的路径和参数执行部署逻辑 """ print(f"目标目录: {target_dir}") print(f"缓存目录: {cache_dir}") print(f"大小阈值: {threshold / (1024**2):.2f} MB") print(f"断点续传: {'开启' if resume else '关闭'}\n") if resume: # 断点续传模式：直接对目标文件夹当前的内容重试部署，跳过清空阶段 print("--- 恢复模式：尝试重新部署当前目标目录中的文件 ---") success = run_bash_command(bash_cmd) if not success: print("[错误] 恢复模式下部署重试失败，程序终止。请检查网络或重新运行。") return print(" -> 恢复部署成功，直接进入阶段二，继续从缓存搬运文件...") else: # 第一阶段：正常模式，将所有超出阈值的文件搬到缓存 print("--- 阶段一：清空目标目录 ---") initial_size, initial_files = get_directory_size_and_files(target_dir) print(f"初始目录总大小: {initial_size / (1024**2):.2f} MB") if initial_size > threshold: files_to_move_initially = [] size_moved = 0 min_size_to_free = initial_size - threshold print(f"需要释放至少 {min_size_to_free / (1024**2):.2f} MB 的空间") for rel_path, size, full_path in initial_files: if size_moved < min_size_to_free: files_to_move_initially.append((rel_path, size, full_path)) size_moved += size else: break print(f"准备搬运 {len(files_to_move_initially)} 个文件到缓存") move_files_with_structure(files_to_move_initially, target_dir, cache_dir) remaining_size_after_initial_move, _ = get_directory_size_and_files(target_dir) print(f"搬运后，目标目录剩余大小: {remaining_size_after_initial_move / (1024**2):.2f} MB") # 执行第一次命令 print("\n[步骤] 执行首次部署命令...") success = run_bash_command(bash_cmd) if not success: print("[错误] 首次命令执行失败，程序终止。") return else: print("初始大小已满足阈值要求，无需搬运。") print("\n[步骤] 执行初始部署命令...") success = run_bash_command(bash_cmd) if not success: print("[错误] 初始命令执行失败，程序终止。") return # 第二阶段：从缓存分批取回文件并执行命令 print("\n--- 阶段二：从缓存取回文件并部署 ---") loop_count = 0 while True: loop_count += 1 print(f"\n--- 取回循环 #{loop_count} ---") # 检查缓存是否还有文件 cached_size, cached_files = get_directory_size_and_files(cache_dir) if cached_size == 0: print("缓存目录已空，进入最终步骤。") break # 从缓存中选取不超过阈值的文件 files_to_return = [] size_to_return = 0 for rel_path, size, full_path in cached_files: if size_to_return + size <= threshold: files_to_return.append((rel_path, size, full_path)) size_to_return += size else: if len(files_to_return) == 0: print(f" -> 警告: 单个文件超过阈值，但仍将其取出。") files_to_return.append((rel_path, size, full_path)) size_to_return = size break print(f" -> 从缓存取回 {len(files_to_return)} 个文件，总大小 {size_to_return / (1024**2):.2f} MB") move_files_with_structure(files_to_return, cache_dir, target_dir) # 执行命令 print(f" -> 执行部署命令...") success = run_bash_command(bash_cmd) if not success: print("[错误] 命令执行失败，程序终止。如果是因为网络波动，可以加上 -r 参数重新运行来续传。") return # 第三阶段：当缓存为空时，执行最终命令 print("\n--- 阶段三：完成任务 ---") print("\n[最终步骤] 执行最终部署命令，此时所有文件都已回到目标目录。") success = run_bash_command(bash_cmd) if not success: print("[错误] 最终命令执行失败。") return print("\n任务完成！所有文件已处理并完成最终部署。") def main(): parser = argparse.ArgumentParser(description="Cloudflare Pages 分批上传部署工具") parser.add_argument("-t", "--target", required=True, help="目标文件夹目录 (需要部署的内容所在路径)") parser.add_argument("-c", "--cache", required=True, help="缓存文件夹目录 (用于上传过程中的中转存放)") parser.add_argument("-p", "--project", required=True, help="远端 Cloudflare Pages 项目名称") parser.add_argument("--threshold", type=int, default=512, help="单次上传阈值(MB)，默认 512") # 新增的断点续传参数 parser.add_argument("-r", "--resume", action="store_true", help="启用断点续传：优先上传当前目标文件夹内剩余的文件，再继续从缓存搬运") args = parser.parse_args() target_dir = Path(args.target).resolve() cache_dir = Path(args.cache).resolve() if not target_dir.exists(): print(f"[错误] 目标目录不存在: {target_dir}") sys.exit(1) # 确保缓存目录存在 cache_dir.mkdir(parents=True, exist_ok=True) # 动态拼接 wrangler 部署命令 bash_cmd = f"npx wrangler pages deploy {target_dir} --project-name {args.project}" threshold_bytes = args.threshold * 1024 * 1024 print(f"--- 启动独立部署脚本 ---") print(f"远端项目: {args.project}") run_deploy(target_dir, cache_dir, bash_cmd, threshold_bytes, resume=args.resume) if __name__ == "__main__": main()和convert.py，这个upload.py也是可以进行单独调用的，调用实例：python upload.py -t "/run/media/chocola/3T-DATA02/Projects/Pages/work/dist" -c "/run/media/chocola/3T-DATA02/Projects/Pages/work/tmp" -p "frieren-anime" --threshold 1024其中的参数：-t 上传的目标文件夹-c 用于文件搬运缓存的文件夹-p Cloudflare Pages 项目名称--threshold 每次上传的大小，可选参数，默认512，单位为MB。眼尖的朋友应该也注意到了，我为了应对网络不稳定造成的上传中断，提供了恢复模式。如果是以恢复模式启动，也就是-r参数，就会先在目标文件夹执行一次上传操作，然后再去缓存文件夹按照设定的每次上传文件的阈值大小复制文件。python upload.py -t "/run/media/chocola/3T-DATA02/Projects/Pages/work/dist" -c "/run/media/chocola/3T-DATA02/Projects/Pages/work/tmp" -p "onimai-anime" --threshold 2048 -r主程序主程序主要就是调用这两个模块进行媒体库的处理和上传，以及在上传之前把静态的UI资源文件复制到目标文件夹并做好网页标题命名工作：import argparse import subprocess import sys import shutil from pathlib import Path # 导入同目录下的 upload 模块 import upload def run_shell_command(cmd, ignore_error=False): """辅助函数：运行 shell 命令""" print(f"执行命令: {cmd}") try: subprocess.run(cmd, shell=True, check=True) return True except subprocess.CalledProcessError as e: if ignore_error: print(f"[提示] 命令未成功返回，但已设置为忽略 (可能仓库或相关配置已存在)。") return False else: print(f"\n[致命错误] 命令执行失败: {cmd}") sys.exit(1) def main(): parser = argparse.ArgumentParser(description="多媒体转换与自动分批部署脚手架") parser.add_argument("-s", "--source", required=True, help="目标文件夹目录 (需要转换的原始文件所在路径)") parser.add_argument("-w", "--workspace", required=True, help="临时处理文件夹目录 (工作区路径)") parser.add_argument("-p", "--project-name", required=True, help="远端 Cloudflare Pages 的项目/仓库名称") # 新增的可选参数：网页标题 parser.add_argument("-t", "--title", help="自定义网页标题 (可选，例如: 某科学的超电磁炮)") # 可选参数，默认使用 512MB 阈值 parser.add_argument("--threshold", type=int, default=512, help="单次上传阈值(MB)，默认 512") args = parser.parse_args() source_dir = Path(args.source).resolve() workspace_dir = Path(args.workspace).resolve() if not source_dir.exists(): print(f"[错误] 源目录不存在: {source_dir}") sys.exit(1) # 临时目录里的两个文件夹 dist_dir = workspace_dir / "dist" # 1. 转换兼上传的文件夹 tmp_dir = workspace_dir / "tmp" # 2. 用于中转的 tmp 文件夹 # 确保目录结构存在 dist_dir.mkdir(parents=True, exist_ok=True) tmp_dir.mkdir(parents=True, exist_ok=True) print("\n" + "="*50) print("🚀 第一阶段：格式转换与压缩 (调用 convert.py)") print("="*50) # 调用 convert.py convert_cmd = f'"{sys.executable}" convert.py "{source_dir}" "{dist_dir}"' run_shell_command(convert_cmd) print("\n" + "="*50) print("🚀 附加阶段：复制静态资源并设置网页标题") print("="*50) # 获取 static 文件夹路径（假设它与 main.py 在同级目录下） static_dir = Path(__file__).parent / "static" if not static_dir.exists(): print(f"[警告] 找不到 static 目录: {static_dir}，跳过静态资源复制。") else: print(f"正在复制 {static_dir} 下的所有文件到 {dist_dir} ...") # 复制 static 目录下的所有内容到 dist_dir (dirs_exist_ok=True 允许覆盖写入) shutil.copytree(static_dir, dist_dir, dirs_exist_ok=True) print("静态资源复制完成。") # 如果传入了自定义标题参数，则处理 index.html if args.title: index_html_path = dist_dir / "index.html" if index_html_path.exists(): print(f"正在修改网页标题为: {args.title} - MediaPages") try: with open(index_html_path, "r", encoding="utf-8") as f: html_content = f.read() old_title_tag = " MediaPages " new_title_tag = f" {args.title} - MediaPages " if old_title_tag in html_content: html_content = html_content.replace(old_title_tag, new_title_tag) with open(index_html_path, "w", encoding="utf-8") as f: f.write(html_content) print("✅ 标题修改成功！") else: print(f"[警告] 在 index.html 中未找到特定的 `{old_title_tag}` 标签，标题替换失败。") except Exception as e: print(f"[错误] 修改 index.html 失败: {e}") else: print(f"[警告] 找不到 {index_html_path}，无法修改标题。") else: print("未提供自定义标题参数 (-t)，保留默认标题。") print("\n" + "="*50) print(f"🚀 第二阶段：创建远端仓库 [{args.project_name}]") print("="*50) # 创建 Cloudflare Pages 项目 create_cmd = f"npx wrangler pages project create {args.project_name} --production-branch main" run_shell_command(create_cmd, ignore_error=True) print("\n" + "="*50) print("🚀 第三阶段：分批上传与部署 (调用 upload.py)") print("="*50) # 动态组装部署命令 deploy_cmd = f"npx wrangler pages deploy {dist_dir} --project-name {args.project_name}" threshold_bytes = args.threshold * 1024 * 1024 print(f"转换输出目录: {dist_dir}") print(f"中转缓存目录: {tmp_dir}") print(f"部署命令: {deploy_cmd}") # 调用修改后的 upload.py 主函数 upload.run_deploy( target_dir=dist_dir, cache_dir=tmp_dir, bash_cmd=deploy_cmd, threshold=threshold_bytes ) print("\n" + "="*50) print("🧹 第四阶段：清理临时工作区") print("="*50) # 直接将整个 tmp_dir 连带里面的所有空文件夹结构一起销毁 if tmp_dir.exists(): shutil.rmtree(tmp_dir) print(f"已彻底删除中转临时目录: {tmp_dir}") print("\n🎉 全部流程执行完毕！") if __name__ == "__main__": main()这个主程序的调用示例如下：python main.py -s "/run/media/chocola/3T-DATA02/[Airota&VCB-Studio&LoliHouse] Toaru Kagaku no Railgun T [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work2" -p "railgun-t" -t "某科学的超电磁炮 T" --threshold 2048其中的参数：-s 上传的目标媒体库原始文件夹-w 转化工作文件夹，用于存放转化后的文件夹和搬运的缓存文件夹-p Cloudflare Pages 项目名称--threshold 每次上传的大小，可选参数，默认512，单位为MB。至此，开发的工作已经告一段落。多项目顺序处理技巧既然已经使用了这个程序，那想必你已经是有多个媒体库的了。人守着一直等待上传完成再去执行下一个命令肯定不合理，我这里给一个技巧——使用bash脚本在程序工作目录创建一个.sh文件，内容参考下面的内容：python main.py -s "/run/media/chocola/3T-DATA02/[Airota&VCB-Studio&LoliHouse] Toaru Kagaku no Railgun T [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work2" -p "railgun-t" -t "某科学的超电磁炮 T" --threshold 2048 python main.py -s "/run/media/chocola/3T-DATA02/[hyakuhuyu&VCB-Studio] Re Zero kara Hajimeru Isekai Seikatsu 3rd Season [Ma10p_1080p]" -w "/run/media/chocola/3T-DATA02/Projects/Pages/work3" -p "re0-3rd" -t "从零开始的异世界生活 第三季" --threshold 2048全套代码获取相关的代码我已经放在GitHub仓库：github.com/Chocola-X/MediaPages项目采用AGPLv3协议开源，但是程序生成的内容不受协议限制，可自由使用和部署。
高性能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
家里云服务优化：统一Web服务端口，减小攻击面
统一家里云的Web服务端口之前很长的一段时间，我的家里云部署的服务都是一个服务监听一个端口，管理相对比较混乱一点，而且因为监听的端口较多，且服务直接暴露，安全风险会更高一些。例如9898端口由Jellyfin监听提供服务，8848端口由Filebrowser监听提供服务，8086端口由NextCloud监听提供服务...这样子虽然在内网访问的情况下方便管理，但是也带来了多端口，多个后端服务直接暴露，管理相对混乱。而且还有一个问题，就是Filebrowser直接监听端口，如果浏览器发送的是http请求，也就是默认在地址栏输入域名+端口时的请求，会直接返回一段文字：client sent an HTTP request to an HTTPS server，然后需要再去地址栏在http后面加上个s，就很操蛋。而对于nginx服务器，则可以直接加上这条规则error_page 497 https://$host:8848$request_uri;即可实现自动跳转，体验要好不少。外加上我家里云其实有专门解析的域名，可以设置不同的子域名用于区分服务，并且我因为我一年多在网站上面的实践，也有了实操经验。所以，我决定把我家里云的Web服务全部统一服务端口，通过vhost进行区分，也就是利用https的sni进行区分，顺带再做一个回落的默认页面来防止低级的IP地址扫描器，还有可能存在的运营商对用户运行的服务的扫描。回落页面设置首先是回落页面设置，这也就是用于掩人耳目的设置，当域名不匹配时，就会返回这个内容。配置文件为/etc/nginx/conf.d/default.conf listen 8848 ssl default_server; listen [::]:8848 ssl default_server; server_name _; # 这是一个通配符，表示任何未匹配的主机名 ssl_certificate /home/chocola/data/certs/bt_cert.pem; ssl_certificate_key /home/chocola/data/certs/bt_cert.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES256-GCM-SHA384'; root /var/www/; # 静态文件的位置 index index.html; # 默认主页 location / { try_files $uri $uri/ =404; # 尝试提供请求的文件或目录，如果不存在则返回404 } # 处理错误页面 error_page 404 /404.html; location = /404.html { internal; } error_page 497 /index.html; location = /index.html { internal; # 只允许内部重定向访问 root /var/www; add_header Content-Type text/html; } }记得在对应的目录放置回落页面index.html和修改证书路径位置。以及在虚拟机安装宝塔面板后，拿宝塔面板的自签名证书文件和宝塔的回落页面放到你的服务器上做回落页面，让别人误以为你是个笨蛋在用宝塔（误）进行反代配置安装好Nginx后，我们就可以开始配置各种服务的反代配置了，只需要确保原始IP可以正常传递，并且后端服务支持使用传递的真实IP。Jellyfin这官方给了详细的Nginx反代配置模板，只需要稍加改动就可以正常使用。配置文件为/etc/nginx/conf.d/jellyfin-proxy.confserver { listen 8848 ssl http2; #IPV4 listen [::]:8848 ssl http2; #IPv6 server_name jellyfin.nekopara.uk; error_page 497 https://$host:8848$request_uri; ## The default `client_max_body_size` is 1M, this might not be enough for some posters, etc. client_max_body_size 32M; # Comment next line to allow TLSv1.0 and TLSv1.1 if you have very old clients ssl_protocols TLSv1.3 TLSv1.2; ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; # use a variable to store the upstream proxy set $jellyfin 127.0.0.1; # Security / XSS Mitigation Headers add_header X-Content-Type-Options "nosniff"; # Permissions policy. May cause issues with some clients add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; # Content Security Policy # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP # Enforces https content and restricts JS/CSS to origin # External Javascript (such as cast_sender.js for Chromecast) must be whitelisted. add_header Content-Security-Policy "default-src https: data: blob: ; img-src 'self' https://* ; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; font-src 'self'"; location / { # Proxy main Jellyfin traffic proxy_pass http://$jellyfin:8096; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; # Disable buffering when the nginx proxy gets very resource heavy upon streaming proxy_buffering off; } location /socket { # Proxy Jellyfin Websockets traffic proxy_pass http://$jellyfin:8096; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; } }Filebrowser对于这个应用，官方没有给Nginx的反代配置，于是我自己写一个：配置文件为/etc/nginx/conf.d/filebrowser-proxy.confserver { listen 8848 ssl http2; listen [::]:8848 ssl http2; server_name file.nekopara.uk nas.nekopara.uk; # 自动跳转 497 错误 error_page 497 https://$host:8848$request_uri; ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; # 优化后的 SSL 配置 ssl_session_timeout 1d; # 延长会话时间，提高性能 ssl_session_cache shared:SSL:10m; # 启用 SSL 缓存 ssl_session_tickets off; # 现代安全协议：只允许 TLS 1.2 和 1.3 ssl_protocols TLSv1.2 TLSv1.3; # 使用高性能且安全的加密套件 ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers on; location / { client_max_body_size 512m; # 关键优化：Filebrowser 传输大文件或流媒体时，缓冲区过小会导致中断 proxy_buffering off; proxy_request_buffering off; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # 根据 server_name 转发到不同端口（或者分两个 server 块写） if ($host = "file.nekopara.uk") { proxy_pass http://127.0.0.1:8001; } if ($host = "nas.nekopara.uk") { proxy_pass http://127.0.0.1:8002; } } }Filebrowser在配置的过程中踩了一个大坑，因为其他服务都开了http2，一开始Filebrowser的配置文件没开，因为我就沿用了我云服务器上面的配置文件，结果浏览器加载报错：NS_ERROR_NET_PARTIAL_TRANSFER和NS_ERROR_NET_INTERRUPT。对应的修复方式是添加这两个参数解决：proxy_buffering off;proxy_request_buffering off;这个问题折磨了我好久，并且是我配置完成NextCloud后才发现的爆雷。qBittorrent虽然官方给了Nginx反代的配置文件，但是也遇到了坑：反代后打开页面空白，什么都没有。经过排查是需要把 启用 Host header 属性验证 这个选项关掉，具体位置在：选项 > Web UI > Web 用户界面（远程控制） > 验证 > 启用 Host header 属性验证关掉后就正常了。配置文件我就用了官方的模板，反代的配置文件为/etc/nginx/conf.d/qbittorrent-proxy.confserver { listen 8848 ssl; server_name qb.nekopara.fun; error_page 497 https://$host:8848$request_uri; # 修改为你的ssl证书位置 ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; location / { proxy_pass http://127.0.0.1:8003/; proxy_http_version 1.1; # headers recognized by qBittorrent proxy_set_header Host $proxy_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; # optionally, you can adjust the POST request size limit, to allow adding a lot of torrents at once #client_max_body_size 100M; # No longer required since qBittorrent v5.1: # Since v4.2.2, is possible to configure qBittorrent # to set the "Secure" flag for the session cookie automatically. # However, that option does nothing unless using qBittorrent's built-in HTTPS functionality. # For this use case, where qBittorrent itself is using plain HTTP # (and regardless of whether or not the external website uses HTTPS), # the flag must be set here, in the proxy configuration itself. # Note: If this flag is set while the external website uses only HTTP, this will cause # the login mechanism to not work without any apparent errors in console/network resulting in "auth loops". proxy_cookie_path / "/; Secure"; } }Web服务器迁移因为本身这个服务器上面也运行有两个传统的网站架构应用，需要把Web服务器从Apache换成Nginx。NextCloudNextCloud本身在我家里云早就存在，甚至可以说我配置这个家里云很大程度就是为了用NextCloud。但是之前部署用的是Apache作为Web服务器而不是Nginx。现在因为需要Nginx在前面做一层反代，自然没必要运行两个独立的Web服务器，于是就考虑让Nginx来负责NextCloud的流量。而且Nginx相比于Apache也要更加轻量。对于NextCloud，官方给的配置文件就可以直接用，但是存在一个严重的问题：上传速度极慢！对应的问题是配置中设置：fastcgi_request_buffering on;改成：fastcgi_request_buffering off;问题解决，原因是禁用 FastCGI 请求缓冲可避免大文件上传时内存堆积，提升流式上传性能。扫除障碍后，NextCloud也平滑切换。配置文件为/etc/nginx/conf.d/nextcloud.conf# Version 2024-07-17 upstream php-handler { server 127.0.0.1:10082; #server unix:/run/php/php8.2-fpm.sock; } # Set the `immutable` cache control options only for assets with a cache busting `v` argument map $arg_v $asset_immutable { "" ""; default ", immutable"; } server { listen 8848 ssl http2; #IPV4 listen [::]:8848 ssl http2; #IPv6 server_name nextcloud.nekopara.uk; error_page 497 https://$host:8848$request_uri; # Path to the root of your installation root /opt/nextcloud; # Use Mozilla's guidelines for SSL/TLS settings # https://mozilla.github.io/server-side-tls/ssl-config-generator/ ssl_certificate /home/chocola/data/certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/nekopara_uk.key; # Prevent nginx HTTP Server Detection server_tokens off; # HSTS settings # WARNING: Only add the preload option once you read about # the consequences in https://hstspreload.org/. This option # will add the domain to a hardcoded list that is shipped # in all major browsers and getting removed from this list # could take several months. #add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always; # set max upload size and increase upload timeout: client_max_body_size 512M; client_body_timeout 300s; fastcgi_buffers 64 4K; # Enable gzip but do not remove ETag headers gzip on; gzip_vary on; gzip_comp_level 4; gzip_min_length 256; gzip_proxied expired no-cache no-store private no_last_modified no_etag auth; gzip_types application/atom+xml text/javascript application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/wasm application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy; # Pagespeed is not supported by Nextcloud, so if your server is built # with the `ngx_pagespeed` module, uncomment this line to disable it. #pagespeed off; # The settings allows you to optimize the HTTP2 bandwidth. # See https://blog.cloudflare.com/delivering-http-2-upload-speed-improvements/ # for tuning hints client_body_buffer_size 512k; # HTTP response headers borrowed from Nextcloud `.htaccess` add_header Referrer-Policy "no-referrer" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Permitted-Cross-Domain-Policies "none" always; add_header X-Robots-Tag "noindex, nofollow" always; add_header X-XSS-Protection "1; mode=block" always; # Remove X-Powered-By, which is an information leak fastcgi_hide_header X-Powered-By; # Set .mjs and .wasm MIME types # Either include it in the default mime.types list # and include that list explicitly or add the file extension # only for Nextcloud like below: include mime.types; types { text/javascript mjs; application/wasm wasm; } # Specify how to handle directories -- specifying `/index.php$request_uri` # here as the fallback means that Nginx always exhibits the desired behaviour # when a client requests a path that corresponds to a directory that exists # on the server. In particular, if that directory contains an index.php file, # that file is correctly served; if it doesn't, then the request is passed to # the front-end controller. This consistent behaviour means that we don't need # to specify custom rules for certain paths (e.g. images and other assets, # `/updater`, `/ocs-provider`), and thus # `try_files $uri $uri/ /index.php$request_uri` # always provides the desired behaviour. index index.php index.html /index.php$request_uri; # Rule borrowed from `.htaccess` to handle Microsoft DAV clients location = / { if ( $http_user_agent ~ ^DavClnt ) { return 302 /remote.php/webdav/$is_args$args; } } location = /robots.txt { allow all; log_not_found off; access_log off; } # Make a regex exception for `/.well-known` so that clients can still # access it despite the existence of the regex rule # `location ~ /(\.|autotest|...)` which would otherwise handle requests #/var/www/nextcloud for `/.well-known`. location ^~ /.well-known { # The rules in this block are an adaptation of the rules # in `.htaccess` that concern `/.well-known`. location = /.well-known/carddav { return 301 /remote.php/dav/; } location = /.well-known/caldav { return 301 /remote.php/dav/; } location /.well-known/acme-challenge { try_files $uri $uri/ =404; } location /.well-known/pki-validation { try_files $uri $uri/ =404; } # Let Nextcloud's API for `/.well-known` URIs handle all other # requests by passing them to the front-end controller. return 301 /index.php$request_uri; } # Rules borrowed from `.htaccess` to hide certain paths from clients location ~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/) { return 404; } location ~ ^/(?:\.|autotest|occ|issue|indie|db_|console) { return 404; } # Ensure this block, which passes PHP files to the PHP process, is above the blocks # which handle static assets (as seen below). If this block is not declared first, # then Nginx will encounter an infinite rewriting loop when it prepends `/index.php` # to the URI, resulting in a HTTP 500 error response. location ~ \.php(?:$|/) { # Required for legacy support rewrite ^/(?!index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+|.+\/richdocumentscode(_arm64)?\/proxy) /index.php$request_uri; fastcgi_split_path_info ^(.+?\.php)(/.*)$; set $path_info $fastcgi_path_info; try_files $fastcgi_script_name =404; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $path_info; fastcgi_param HTTPS on; fastcgi_param modHeadersAvailable true; # Avoid sending the security headers twice fastcgi_param front_controller_active true; # Enable pretty urls fastcgi_pass php-handler; fastcgi_intercept_errors on; fastcgi_request_buffering off; # Required as PHP-FPM does not support chunked transfer encoding and requires a valid ContentLength header. fastcgi_max_temp_file_size 0; } # Serve static files location ~ \.(?:css|js|mjs|svg|gif|ico|jpg|png|webp|wasm|tflite|map|ogg|flac|mp4|webm)$ { try_files $uri /index.php$request_uri; # HTTP response headers borrowed from Nextcloud `.htaccess` add_header Cache-Control "public, max-age=15778463$asset_immutable"; add_header Referrer-Policy "no-referrer" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Permitted-Cross-Domain-Policies "none" always; add_header X-Robots-Tag "noindex, nofollow" always; add_header X-XSS-Protection "1; mode=block" always; access_log off; # Optional: Don't log access to assets } location ~ \.(otf|woff2?)$ { try_files $uri /index.php$request_uri; expires 7d; # Cache-Control policy borrowed from `.htaccess` access_log off; # Optional: Don't log access to assets } # Rule borrowed from `.htaccess` location /remote { return 301 /remote.php$request_uri; } location / { try_files $uri $uri/ /index.php$request_uri; } }Typecho对，没错，虽然我博客现在已经迁移上云了，但是本地还是运行有一份独立的副本的。这主要是作为备份用途，平时也不会有访问流量，但是当云服务器遇到灾难性问题，本地仍然有一份可靠的副本。每写一篇新的文章发布，我都会用家里云从云服务器拉取最新的网站数据。同时这份副本也是做好了随时提供服务的准备的：配置好了Cloudflare的源服务器证书，随时可以通过Cloudflare切换解析套Cloudflare接管流量。当然，因为这只是作为备份和应急，不需要这么多防护规则，所以配置文件相对也简单得多。配置文件为/etc/nginx/conf.d/typecho.confserver { listen 8848 ssl http2; server_name www.nekopara.uk; # SSL certificate and key paths ssl_certificate /home/chocola/data/certs/cloudflare_certs/nekopara_uk.pem; ssl_certificate_key /home/chocola/data/certs/cloudflare_certs/nekopara_uk.key; # SSL settings (optional but recommended for security) ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # Root directory of the website root /home/typecho; index index.html index.php; client_max_body_size 16M; # Serve static files directly location / { try_files $uri $uri/ /index.html /index.php?$args; } # PHP processing location ~ \.php$ { fastcgi_pass 127.0.0.1:10082; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } # Deny access to hidden files location ~ /\.ht { deny all; } }完工验收当确认通过新的域名+端口访问服务都没问题后，就可以优雅地把防火墙之前开放的各种端口都关闭，并且停止Apache的进程。以及确保这些被反代的服务端关闭HTTPS监听，只监听本地回环127.0.0.1的HTTP请求。因为如果本地回环还是TLS的话会造成不必要的性能损耗，且本地回环足够安全，也没有TLS加密的必要，TLS终止于Nginx就可以了。至此，家里云服务架构优化完美收工。
萌化FileBrowser随着FileBrowser的版本逐渐迭代到2.57.1，我发现我当初写的版本不少地方效果已经失效，于是我打算优化自定义的样式表代码来适配新版的FileBrowser。因为跨的版本比较大，改动也是比较大的，甚至这个个性化自定义配置都有不小的改变。于是我打算重新彻底优化一下。果然，事情总是动态发展的，上一版我调教的终极美化，这次要进行修补更新了。文件目录结构说明新版的filebrowser页面风格定制化style文件夹目录结构如下：[chocola@Neko-X99 linux-amd64-filebrowser]$ tree . ├── CHANGELOG.md ├── filebrowser ├── filebrowser.db ├── LICENSE ├── README.md └── style ├── custom.css ├── img │ ├── background.jpg │ ├── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon.ico │ │ └── favicon.svg │ ├── JCuYuan.woff2 │ ├── login.jpg │ └── logo.svg ├── index.html └── manifest.json 4 directories, 17 filesfilebrowser相比于之前的版本，主要是图标数量进行了简化。而我这次的改进，有以下几点：使用了woff2格式的字体，减少页面加载流量和等待时间增加了提示框样式设置为文件预览添加了毛玻璃背景效果为新增的数值条增加样式适配优化弹窗背景，增加毛玻璃效果为文本编辑器添加半透明背景效果为新增的文件邮件菜单添加主题样式优化登陆界面信息提示的样式大概就这些吧，还有一些细枝末节的部分，我没有详细列出，有一些效果是因为代码改动牵一发动全身，修改反反复复搞了快一天，总之这个filebrowser主题改动不小。效果展示接下来我会用图片逐个展示修改后的效果。登陆界面：这个改动不算大，只是适配了一下新版的消息框。文件页面：适配了右键菜单的效果。文件选择效果（这部分没变）：下载弹窗背景毛玻璃模糊化：新建文件操作也是同理：优化后的文本编辑器样式：优化后的图片预览效果：对新的数字选择器进行样式适配：优化信息提示框：萌化方法萌化方法并不困难，只需要下载我准备好的style.zip压缩包，解压到filebrowser的目录后应用自定义样式就可以了，前文也有目录树说明了，详细操作可以去看这篇文章：个性化Filebrowser界面，NAS也要Kawaii~~~!唯一要注意的是需要下载FileBrowser的2.57.1版本，因为这版界面美化是针对这个版本写的。对于custom.css，我已经在不少位置都做了注释，想自己按需修改的话看注释照着改也不难。
在Manjaro Linux上部署Typecho开发环境因为后面主题的开发就需要实时修改php文件查看效果了，为了方便开发，我决定在自己的电脑上面设置一个基本的Nginx+PHP环境。对于数据库就使用sqlite好了，只是为了搭建一个本地的typecho作为测试平台。在配置期间踩了几个坑，记录一下。软件的安装非常简单，一条命令梭哈：pacman -S nginx php php-fpm php-sqlite php-gd配置软件因为manjaro和其他发行版不太一样，并且我需要这个web服务器以我的用户权限进行运行，方便我更新文件，所以我们需要做出一些更改。php.ini首先是php的配置文件，文件位于/etc/php/php.ini找到下面的部分进修改：上传大小限制：upload_max_filesize = 2M改为upload_max_filesize = 16M然后启用必要的模块，找到以下部分取消注释：extension=curl extension=gd extension=gettext extension=pdo_sqlitewww.conf然后是php的另一个配置文件，让php-fpm以用户身份运行，文件位于/etc/php/php-fpm.d/www.conf找到下面的部分进修改：user = chocola ; <--- 修改这里 group = chocola ; <--- 修改这里 listen.owner = chocola ; <--- 修改这里，确保 Nginx 能和 PHP 通讯 listen.group = chocola ; <--- 修改这里nginx.conf将Nginx的配置文件修改为如下所示： user chocola; worker_processes 1; #error_log logs/error.log; #error_log logs/error.log notice; #error_log logs/error.log info; #pid logs/nginx.pid; # Load all installed modules include modules.d/*.conf; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; #log_format main '$remote_addr - $remote_user [$time_local] "$request" ' # '$status $body_bytes_sent "$http_referer" ' # '"$http_user_agent" "$http_x_forwarded_for"'; #access_log logs/access.log main; sendfile on; #tcp_nopush on; #keepalive_timeout 0; keepalive_timeout 65; gzip on; server { listen 80; server_name localhost; #charset koi8-r; #access_log logs/host.access.log main; root /run/media/chocola/DATA/wwwroot; index index.php index.html index.htm; #error_page 404 /404.html; # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } # proxy the PHP scripts to Apache listening on 127.0.0.1:80 # #location ~ \.php$ { # proxy_pass http://127.0.0.1; #} # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 # location ~ .*\.php(\/.*)*$ { # 你的网站根目录 (如果 server 块里已经写了 root，这行可以省略，但加上保险) root /run/media/chocola/DATA/wwwroot; fastcgi_pass unix:/run/php-fpm/php-fpm.sock; fastcgi_index index.php; # 修改点 2：开启路径分割，把 /index.php/action/login 分成两部分 fastcgi_split_path_info ^(.+?.php)(/.*)$; # 修改点 3：重新定义 SCRIPT_FILENAME，使用分割后的变量 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # 修改点 4：这一行是 Typecho 后台必须的！告诉 PHP 具体的路径信息 fastcgi_param PATH_INFO $fastcgi_path_info; include fastcgi_params; } # deny access to .htaccess files, if Apache's document root # concurs with nginx's one # #location ~ /\.ht { # deny all; #} } # another virtual host using mix of IP-, name-, and port-based configuration # #server { # listen 8000; # listen somename:8080; # server_name somename alias another.alias; # location / { # root html; # index index.html index.htm; # } #} # HTTPS server # #server { # listen 443 ssl; # server_name localhost; # ssl_certificate cert.pem; # ssl_certificate_key cert.key; # ssl_session_cache shared:SSL:1m; # ssl_session_timeout 5m; # ssl_ciphers HIGH:!aNULL:!MD5; # ssl_prefer_server_ciphers on; # location / { # root html; # index index.html index.htm; # } #} }主要是让nginx以用户的身份进行运行，并且修改php部分的处理逻辑，以符合Typecho的要求。设置完成后，就可以把Typecho的安装包丢到目标目录进行安装了！
主题开发日志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();即可恢复动态立绘渲染。
阿里云 ESA 回源 IP 段分享，可用于源站保护
利用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信息并更新数据库了。
V100 GPU 初体验：AlmaLinux 8.10 快速部署 ComfyUI
在 AlmaLinux 8.10 上部署 ComfyUI最近买了一张 V100，为了爽玩 AI 绘图，我单独组装了一台 X99 平台主机来运行 ComfyUI。操作系统方面，我选择了 AlmaLinux 8.10，看中的是其稳定性以及对多版本 Python 的良好支持。硬件介绍显卡本体Tesla V100 显卡展示（和RTX2080Ti同框）：安装到转接板上：转接板是200买的，虽然比最便宜的99元转接板贵了一倍，但用的是原装座子，原装座子的转接板就算是10X10CM的小板子都170+了。我加多30，买个带温控的，尺寸大一点的板，还自带螺栓孔位固定，完美安装，他不香吗？装上散热器后的样子：我为了尽可能降低温度，延长使用寿命，买了最大的散热器，到手发现确实巨大无比，比想象中的要大得多。但是其实现在看，买小一号的9CM高度的可能更合适，因为更好安装8CM风扇，不会浪费散热面积。我最后还是选择了8CM的风扇来散热，放弃了之前使用12CM风扇的想法——因为没办法彻底吹透散热器，温度还高。装上风扇Be Like:因为两边会漏风，导致无法彻底吹透散热器，所以搞了一张纸包了一下，迫使气流贯穿散热器。配套硬件平台开头我先说一下我踩到的坑，首先平台选择就先栽了一头：一开始我想用我闲置的H61平台来带这个玩意，而且也确认看过那个主板有Above 4G Decoding选项，也打开了，但是就是死活没办法驱动这个显卡，系统识别到了这个设备，但是NVIDIA驱动无法初始化设备，导致无法使用。这个问题折磨了我很久，检查日志具体表现是：[root@Neko-P104 ~]# dmesg | tail -100 | grep -i -E 'nvidia|nvrm|gpu|bar' [ 3.121900] NVRM: This PCI I/O region assigned to your NVIDIA device is invalid: NVRM: BAR4 is 0M @ 0x0 (PCI:0000:01:00.0) [ 3.121902] NVRM: This PCI I/O region assigned to your NVIDIA device is invalid: NVRM: BAR5 is 0M @ 0x0 (PCI:0000:01:00.0) [ 3.322080] NVRM: loading NVIDIA UNIX x86_64 Kernel Module 580.105.08 Tue Apr 8 12:41:17 UTC 2025 [ 3.369648] nvidia-modeset: Loading NVIDIA Kernel Mode Setting Driver for UNIX platforms 580.105.08 Tue Apr 8 12:09:34 UTC 2025 [ 3.376189] [drm] [nvidia-drm] [GPU ID 0x00000100] Loading driver [ 3.376192] [drm] Initialized nvidia-drm 0.0.0 20160202 for 0000:01:00.0 on minor 0 [ 112.149308] caller _nv041720rm+0x3a/0xb0 [nvidia] mapping multiple BARs [ 112.163383] NVRM: GPU 0000:01:00.0: RmInitAdapter failed! (0x24:0x72:1568) [ 112.163415] NVRM: GPU 0000:01:00.0: rm_init_adapter failed, device minor number 0 [ 112.285687] nvidia-uvm: Loaded the UVM driver, major device number 237. [ 379.801748] caller _nv041720rm+0x3a/0xb0 [nvidia] mapping multiple BARs [ 379.815873] NVRM: GPU 0000:01:00.0: RmInitAdapter failed! (0x24:0x72:1568) [ 379.815912] NVRM: GPU 0000:01:00.0: rm_init_adapter failed, device minor number 0这表明：BAR 分配失败：GPU 的某些地址空间没有正确映射到系统内存中。RmInitAdapter 失败：驱动尝试初始化 GPU 时遇到了错误，无法完成初始化。所以这是平台限制，必须换平台了。后面我在我主力机（X99双路服务器）上面强行安装上显卡，结果成功驱动了。作死照片：于是，我就从我家NAS拆了这套X99，然后NAS换回H61，因为NAS用X99本来就性能过剩。之前手痒痒给家里云升级了X99，现在又被拆走，回归最开始的H61，这算是回归本质了（）以下是配置：主板：山寨X99主板，H81芯片组魔改。CPU：E5-2630 v4内存：SK REG ECC DDR4 2133p 2Rx4 16G 两条硬盘：500G机械硬盘GPU：Tesla V100装好起来就长这样了：如果你仔细观察的话，你会发现带显卡的是独立的服务器电源，也就是这个电脑用了两个电源。问就是靠继电器触发服务器电源给显卡提供额外的12V供电，继电器的电源是主电源的12V供电，这样就可以同步上电。这样就可以不用花这么多钱买大电源了，而且服务器拆机电源用料还好，垃圾佬基本操作（）搁这装炸弹呢（bushi硬件介绍完了，接下来就开始折腾吧！驱动安装因为默认安装的AlmaLinux 8.10并没有自带NVIDIA驱动，所以，我我们需要手动安装驱动。具体的操作我博客之前的文章有介绍过，可以搜索看一下，这里我就简要过一遍：yum install epel-release dnf config-manager --add-repo http://developer.download.nvidia.com/compute/cuda/repos/rhel8/$(uname -i)/cuda-rhel8.repo dnf install kernel-headers-$(uname -r) kernel-devel-$(uname -r) tar bzip2 make automake gcc gcc-c++ pciutils elfutils-libelf-devel libglvnd-opengl libglvnd-glx libglvnd-devel acpid pkgconfig dkms dnf module install nvidia-driver:580-dkms安装完成后，重启，执行nvidia-smi能看到显卡信息，就说明成功了：[rin@Neko-P104 ~]$ nvidia-smi Fri Dec 5 20:50:54 2025 +-----------------------------------------------------------------------------------------+ | NVIDIA-SMI 580.105.08 Driver Version: 580.105.08 CUDA Version: 13.0 | +-----------------------------------------+------------------------+----------------------+ | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. | | | | MIG M. | |=========================================+========================+======================| | 0 Tesla V100-SXM2-16GB Off | 00000000:03:00.0 Off | 0 | | N/A 45C P0 153W / 300W | 2733MiB / 16384MiB | 99% Default | | | | N/A | +-----------------------------------------+------------------------+----------------------+ +-----------------------------------------------------------------------------------------+ | Processes: | | GPU GI CI PID Type Process name GPU Memory | | ID ID Usage | |=========================================================================================| | 0 N/A N/A 319443 C ...2a49dd2e4e7a546c540f/rend.exe 2730MiB | +-----------------------------------------------------------------------------------------+部署 ComfyUI截至我写这篇文章的时候，ComfyUI的版本为0.3.76，本文写的教材都是在这个版本下进行操作的，进行参考时请注意。由于0.3.76版本的ComfyUI推荐Python版本是3.12，我们需要安装Python3.12。得益于AlmaLinux8.10支持多版本Python共存安装，事情变得很简单，不需要Anaconda这种东西。而且这个机器因为是单一的服务器用途，大概率相关的Python项目只会部署一个ComfyUI，所以直接在系统的Python环境部署即可。安装Python3.12：yum install python3.12 python3.12-pip下载并解压ComfyUI：wget https://github.com/comfyanonymous/ComfyUI/archive/refs/tags/v0.3.76.zip unzip v0.3.76.zip rm v0.3.76.zippip换源：cd ~ mkdir .pip nano .pip/pip.conf将以下内容写入配置文件：[global] index-url = https://pypi.mirrors.ustc.edu.cn/simple/ trusted-host = pypi.mirrors.ustc.edu.cn安装依赖环境：cd ComfyUI-0.3.76/ pip3.12 install -r requirements.txt如果你之前用过Stable Diffusion WebUI的话，可以照着extra_model_paths.yaml.example创建一个extra_model_paths.yaml，导入之前的模型。取消注释对应的部分，填上路径就可以了：a111: base_path: /home/rin/stable-diffusion-webui/ checkpoints: models/Stable-diffusion configs: models/Stable-diffusion vae: models/VAE loras: | models/Lora models/LyCORIS upscale_models: | models/ESRGAN models/RealESRGAN models/SwinIR embeddings: embeddings hypernetworks: models/hypernetworks controlnet: models/ControlNet一切设置妥当后，就可以ComfyUI启动了：python3.12 main.py --listen 0.0.0.0不出意外的话，访问服务器的IP地址+8188端口（例如192.168.6.6:8188）就可以看到界面了。尝试跑几张图，没问题就部署完成了。缺失模型下载技巧如果你的服务器位于国内，因为众所周知的原因，是没办法根据它给出的地址直接用wget拉取的，但是我发现了一个小技巧，不需要给服务器魔法上网环境，也可以高速拉取模型。例如，使用这个模板的时候缺失了模型：点击复制连接，然后找一台国外的VPS使用curl来获取下载连接：NyankoHost [~]# curl https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors Found. Redirecting to https://cas-bridge.xethub.hf.co/xet-bridge-us/689761ce345c5cafa2ebc6a6/dbc67890a57bd825922462d911e0d32c7ee7706ee7985f8ddf105b615a1e8e32?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=cas%2F20251205%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20251205T131704Z&X-Amz-Expires=3600&X-Amz-Signature=84def4e6cfc1dcd8ba5d646142f7d19680a030829568a0f67b4a887d35ecdb4b&X-Amz-SignedHeaders=host&X-Xet-Cas-Uid=public&response-content-disposition=inline%3B+filename*%3DUTF-8%27%27Qwen-Image-Lightning-8steps-V1.0.safetensors%3B+filename%3D%22Qwen-Image-Lightning-8steps-V1.0.safetensors%22%3B&x-id=GetObject&Expires=1764944224&Policy=[binary]&Signature=coo9prmThYpuZYwNqFpZafSP8LHUXhb4DYd6PZbIn4EnSgC%7EDQP6gE3EneVg4I-1-QJcoGBj1lihIWOn8qkrtH5xJfekUCYSSzA1m8jQl89MmrxUxwEZEfYBR3ZturhNeXY5FOdqiYElE98gqr7LHocp0ue5EJJUCbYmlwlJGr4HnwqpM7VkvN1MAATGsIwUbztXOvsvPD6WyjxFGPfeHUPidQVWfCkZiMm-yDHFWRyMT3TYeh6YH8f0ZsVPO2TMvBUFcKRjaGoyKUOHA0EuCz0yKH%7EfrLzkqAVsc2V9MpWEVE5gcCseZufIFFLPDg7garAB%7E2wJddHK4WPw5H-K9g__&Key-Pair-Id=K2L8F4GPSG1IFC NyankoHost [~]# 获取到了下载连接后，我们在服务器上进入对应的目录位置进行拉取：cd ComfyUI-0.3.76/models/loras/ wget -O Qwen-Image-Lightning-8steps-V1.0.safetensors 'https://cas-bridge.xethub.hf.co/xet-bridge-us/689761ce345c5cafa2ebc6a6/dbc67890a57bd825922462d911e0d32c7ee7706ee7985f8ddf105b615a1e8e32?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=cas%2F20251205%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20251205T131704Z&X-Amz-Expires=3600&X-Amz-Signature=84def4e6cfc1dcd8ba5d646142f7d19680a030829568a0f67b4a887d35ecdb4b&X-Amz-SignedHeaders=host&X-Xet-Cas-Uid=public&response-content-disposition=inline%3B+filename*%3DUTF-8%27%27Qwen-Image-Lightning-8steps-V1.0.safetensors%3B+filename%3D%22Qwen-Image-Lightning-8steps-V1.0.safetensors%22%3B&x-id=GetObject&Expires=1764944224&Policy=[binary]&Signature=coo9prmThYpuZYwNqFpZafSP8LHUXhb4DYd6PZbIn4EnSgC%7EDQP6gE3EneVg4I-1-QJcoGBj1lihIWOn8qkrtH5xJfekUCYSSzA1m8jQl89MmrxUxwEZEfYBR3ZturhNeXY5FOdqiYElE98gqr7LHocp0ue5EJJUCbYmlwlJGr4HnwqpM7VkvN1MAATGsIwUbztXOvsvPD6WyjxFGPfeHUPidQVWfCkZiMm-yDHFWRyMT3TYeh6YH8f0ZsVPO2TMvBUFcKRjaGoyKUOHA0EuCz0yKH%7EfrLzkqAVsc2V9MpWEVE5gcCseZufIFFLPDg7garAB%7E2wJddHK4WPw5H-K9g__&Key-Pair-Id=K2L8F4GPSG1IFC'然后你就能获得起飞一般的下载速度：[rin@Neko-P104 loras]$ wget -O Qwen-Image-Lightning-8steps-V1.0.safetensors 'https://cas-bridge.xethub.hf.co/xet-bridge-us/689761ce345c5cafa2ebc6a6/dbc67890a57bd825922462d911e0d32c7ee7706ee7985f8ddf105b615a1e8e32?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=cas%2F20251205%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20251205T131704Z&X-Amz-Expires=3600&X-Amz-Signature=84def4e6cfc1dcd8ba5d646142f7d19680a030829568a0f67b4a887d35ecdb4b&X-Amz-SignedHeaders=host&X-Xet-Cas-Uid=public&response-content-disposition=inline%3B+filename*%3DUTF-8%27%27Qwen-Image-Lightning-8steps-V1.0.safetensors%3B+filename%3D%22Qwen-Image-Lightning-8steps-V1.0.safetensors%22%3B&x-id=GetObject&Expires=1764944224&Policy=[binary]&Signature=coo9prmThYpuZYwNqFpZafSP8LHUXhb4DYd6PZbIn4EnSgC%7EDQP6gE3EneVg4I-1-QJcoGBj1lihIWOn8qkrtH5xJfekUCYSSzA1m8jQl89MmrxUxwEZEfYBR3ZturhNeXY5FOdqiYElE98gqr7LHocp0ue5EJJUCbYmlwlJGr4HnwqpM7VkvN1MAATGsIwUbztXOvsvPD6WyjxFGPfeHUPidQVWfCkZiMm-yDHFWRyMT3TYeh6YH8f0ZsVPO2TMvBUFcKRjaGoyKUOHA0EuCz0yKH%7EfrLzkqAVsc2V9MpWEVE5gcCseZufIFFLPDg7garAB%7E2wJddHK4WPw5H-K9g__&Key-Pair-Id=K2L8F4GPSG1IFC' --2025-12-05 21:21:03-- https://cas-bridge.xethub.hf.co/xet-bridge-us/689761ce345c5cafa2ebc6a6/dbc67890a57bd825922462d911e0d32c7ee7706ee7985f8ddf105b615a1e8e32?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=cas%2F20251205%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20251205T131704Z&X-Amz-Expires=3600&X-Amz-Signature=84def4e6cfc1dcd8ba5d646142f7d19680a030829568a0f67b4a887d35ecdb4b&X-Amz-SignedHeaders=host&X-Xet-Cas-Uid=public&response-content-disposition=inline%3B+filename*%3DUTF-8%27%27Qwen-Image-Lightning-8steps-V1.0.safetensors%3B+filename%3D%22Qwen-Image-Lightning-8steps-V1.0.safetensors%22%3B&x-id=GetObject&Expires=1764944224&Policy=[binary]&Signature=coo9prmThYpuZYwNqFpZafSP8LHUXhb4DYd6PZbIn4EnSgC%7EDQP6gE3EneVg4I-1-QJcoGBj1lihIWOn8qkrtH5xJfekUCYSSzA1m8jQl89MmrxUxwEZEfYBR3ZturhNeXY5FOdqiYElE98gqr7LHocp0ue5EJJUCbYmlwlJGr4HnwqpM7VkvN1MAATGsIwUbztXOvsvPD6WyjxFGPfeHUPidQVWfCkZiMm-yDHFWRyMT3TYeh6YH8f0ZsVPO2TMvBUFcKRjaGoyKUOHA0EuCz0yKH%7EfrLzkqAVsc2V9MpWEVE5gcCseZufIFFLPDg7garAB%7E2wJddHK4WPw5H-K9g__&Key-Pair-Id=K2L8F4GPSG1IFC Resolving cas-bridge.xethub.hf.co (cas-bridge.xethub.hf.co)... 13.33.183.18, 13.33.183.37, 13.33.183.46, ... Connecting to cas-bridge.xethub.hf.co (cas-bridge.xethub.hf.co)|13.33.183.18|:443... connected. HTTP request sent, awaiting response... 200 OK Length: 1698951104 (1.6G) Saving to: ‘Qwen-Image-Lightning-8steps-V1.0.safetensors’ Qwen-Image-Lightning-8steps-V1.0.safetens 100%[==================================================================================>] 1.58G 28.8MB/s in 1m 3s 2025-12-05 21:23:08 (13.1 MB/s) - ‘Qwen-Image-Lightning-8steps-V1.0.safetensors’ saved [1698951104/1698951104] [rin@Neko-P104 loras]$ 能这么操作的原因是，huggingface.co的域名被阻断了，而真正用于分发下载的cas-bridge.xethub.hf.co域名并没有被阻断，而且因为是亚太的amazon节点，速度还很快。番外：V100 的 SheepIt 战力我用顺带跑了一下SheepIt渲染农场的渲染速度，测试了几次，基本上就是大概116%的算力，还是可以的。