用 Python 破解网易云 iframe 反爬，全自动构建本地音乐库
Python实现网易云音乐自动抓取整理事情的缘起很简单，我的jellyfin音乐库听腻的差不多了，想抓点新歌。虽然说之前已经抓取过1000多首歌曲，但是很多是我因为强迫症，为了媒体库的完整性，把整个专辑都抓下来了，实际上常听的就是两三百首。正因如此，我就想给我的媒体库补充点库存，于是开始折腾，有了这篇文章。实现思路上次因为没能自动化搞下载，且因为心急，所以几乎是半自动半手动收集的，费时费力，花费了差不多一个星期。而这次，我决定使用Python自动化完成，下面是工作流实现思路：当遇到喜欢的音乐后，我就保存其网易云的URL到文本文件，例如https://music.163.com/#/song?id=1499823984你可以点开看看是什么曲子（，每个曲子放一行，随听随收集。读取这个歌曲列表文件，逐行解析，获取专辑的ID，例如https://music.163.com/#/album?id=99010037，取出99010037，以及歌手KOTOKO。然后去解析专辑的页面，获取其中所有歌曲的song?id，例如1499823984，1499826814，1499823975和1499826811。调用第三方API进行歌曲解析，获取音频文件，歌词和封面。例如这次我用的就是https://jx.chksz.top/按照jellyfin要求的格式保存歌曲文件，也就是歌手/专辑/01.歌曲.flac，如：[chocola@Neko-Server KOTOKO]$ tree . └── ネコぱら vol.4 OP ⁄ ED ├── 01.SWEET×SWEET.flac ├── 01.SWEET×SWEET.lrc ├── 02.NEGAIGOTO.flac ├── 02.NEGAIGOTO.lrc ├── 03.SWEET×SWEET (Instrumental).flac ├── 03.SWEET×SWEET (Instrumental).lrc ├── 04.NEGAIGOTO (Instrumental).flac ├── 04.NEGAIGOTO (Instrumental).lrc └── cover.jpg最后还要打上艺术家，专辑，歌曲名的标签既然确定了思路和方法，接下来开始逐个攻破吧！观察网页布局打开音乐网页，开启F12大法，检查元素，迅速就定位到了目标：其实，后面发现在页面的meta标签其实也有信息：对于专辑部分，抓取列表就好了：看起来并不难，使用Python的requests模块获取html，然后解析内容就好了。尝试实现爬虫逻辑和LLM友好交流后，得出了每一部分的代码逻辑函数，例如解析歌曲页面信息的：def extract_music_info(url): # 模拟浏览器请求头 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } try: # 获取HTML内容 response = requests.get(url, headers=headers) response.raise_for_status() html = response.text print(html) # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"提取失败: {str(e)}") return None但是好巧不巧，我写完代码的第二天，正准备要爬取，却返回了空值。我就纳闷了，我昨天还能运行的代码，怎么今天就出问题了？然后重新检查了页面，才发现第二天后网易云把页面嵌入了iframe标签，让我的代码全部报废（还真是赶到点子上了呢，由于这部分加载实现需要模拟出来浏览器逻辑，所以我决定还是上selenium吧，尽管我并不喜欢这种臃肿低效的爬虫方式，但是现在看来如果想快速完成任务只能这样了。iframe标签反爬这招太狠了！selenium 搭配 firefox 大战 iframe配置和熟悉selenium首先，安装selenium:pip install selenium然后要去下载selenium的firefox驱动，前往github.com/mozilla/geckodriver/releases下载对应的版本，这里我下载的是geckodriver-v0.36.0-linux64.tar.gz，下载好后放在项目文件夹下。要用selenium首先要熟悉基本的用法，以下是简单示例：from selenium import webdriver from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.options import Options # 新增导入 # 创建 Service 对象，指定 geckodriver 的路径 service = Service(executable_path='./geckodriver') # 创建 Options 对象，指定 Firefox 的二进制路径 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动，同时传入 service 和 options driver = webdriver.Firefox(service=service, options=options) driver.get('https://www.nekopara.uk/') print(driver.page_source) driver.quit()大概就是获取了页面渲染后的html代码然后返回。进行爬取下面是爬取的实现函数，由于如果不关闭浏览器的话，获取的iframe标签还是第一次打开页面的内容，没办法复用，只能每次爬取完成后关闭浏览器然后重新打开。def extract_music_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML html = driver.page_source # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def get_album_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML iframe_html = driver.page_source soup = BeautifulSoup(iframe_html, 'html.parser') song_dict = {} # 遍历所有歌曲行（tr元素） for tr in soup.find_all('tr'): # 提取页面序号（在中） num_span = tr.find('span', class_='num') if not num_span: continue try: page_num = int(num_span.get_text(strip=True)) except ValueError: continue # 提取歌曲ID（在中） a_tag = tr.find('a', href=lambda href: href and href.startswith('/song?id=')) if not a_tag: continue href = a_tag['href'] song_id = href.split('=')[1] song_dict[page_num] = song_id return song_dict except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit()通过driver.switch_to.frame(iframe)才能获取到iframe内容，不然就和直接用python的requests模块的请求结果是一样的。这样我们就能获取到了艺术家名称，专辑名称，歌曲ID，可以进行下一步工作了。确实感觉自动化浏览器访问这个是爬虫的终极手段了，几乎什么都可以爬取了，只不过效率确实不如直接发送请求。从第三方解析服务API获取信息这里我用了https://jx.chksz.top/这个网站的解析服务，是在搜索引擎里面随便找的。简单看了一下，好像是一个初中生的，真是年少有为呢！一番F12后，抓取到了API的地址：https://api.kxzjoker.cn/api/163_music接下来就是构造请求并下载的函数了：def download_song_and_metadata(save_path:str,song_url: str,number_info:str, level: str = "jymaster") -> bool: # === 第一步：请求你的解析 API === api_endpoint = "https://api.kxzjoker.cn/api/163_music" form_data = { "url": song_url, "level": level, "type": "json" } try: print("正在请求歌曲信息...") resp = requests.post(api_endpoint, data=form_data, timeout=15) resp.raise_for_status() data = resp.json() if data.get("status") != 200: print(f"API 返回错误状态: {data.get('status')}") return False # 提取字段 title = sanitize_filename(data["name"]) artist = data["ar_name"] album = sanitize_filename(data["al_name"]) audio_url = data["url"] cover_url = data["pic"] lyric = data.get("lyric", "") tlyric = data.get("tlyric", "") # === 第二步：创建专辑目录 === album_dir = os.path.join(save_path, album) os.makedirs(album_dir, exist_ok=True) # === 第三步：下载音频文件 === audio_ext = ".flac" if ".flac" in audio_url else ".mp3" audio_path = os.path.join(album_dir, f"{number_info}.{title}{audio_ext}") if not os.path.exists(audio_path): print(f"正在下载音频: {title}{audio_ext}") audio_resp = requests.get(audio_url, stream=True, timeout=30) audio_resp.raise_for_status() with open(audio_path, "wb") as f: for chunk in audio_resp.iter_content(chunk_size=8192): f.write(chunk) else: print(f"音频已存在，跳过: {audio_path}") # === 第四步：合并歌词并保存 .lrc === lrc_path = os.path.join(album_dir, f"{number_info}.{title}.lrc") if tlyric != None: combined_lyric = merge_lyrics(lyric, tlyric) else: combined_lyric = lyric with open(lrc_path, "w", encoding="utf-8") as f: f.write(combined_lyric) print(f"歌词已保存: {lrc_path}") # === 第五步：下载封面为 folder.jpg === cover_path = os.path.join(album_dir, "folder.jpg") if not os.path.exists(cover_path): print("正在下载专辑封面...") cover_resp = requests.get(cover_url, timeout=15) cover_resp.raise_for_status() with open(cover_path, "wb") as f: f.write(cover_resp.content) print(f"封面已保存: {cover_path}") else: print("封面已存在，跳过") print(f"\n✅ 下载完成！路径: {album_dir}") return True except Exception as e: print(f"❌ 下载失败: {e}") return False def merge_lyrics(original: str, translation: str) -> str: """ 合并原文和翻译歌词，生成双语 LRC。 假设时间戳一致，将翻译放在下一行。 """ orig_lines = original.strip().splitlines() trans_dict = {} # 解析翻译歌词的时间 -> 文本 for line in translation.strip().splitlines(): if "]:" in line or line.startswith("[by:"): continue parts = line.split("]", 1) if len(parts) == 2: time_tag, text = parts[0] + "]", parts[1].strip() if text: trans_dict[time_tag] = text # 合并 merged = [] for line in orig_lines: merged.append(line) if line.startswith("[") and "]" in line: time_tag = line.split("]", 1)[0] + "]" if time_tag in trans_dict: merged.append(time_tag + trans_dict[time_tag]) return "\n".join(merged)最终缝合得到了各个部分的组件，然后就是缝合工作了，只需要传入一个分行的歌曲的URL文本文件，程序就可以全自动下载全部的相关文件，并且根据存放规范保存。下面是最终缝合代码：import requests import re from urllib.parse import urlparse from selenium import webdriver from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.options import Options # 新增导入 from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import os from bs4 import BeautifulSoup def get_album_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML iframe_html = driver.page_source soup = BeautifulSoup(iframe_html, 'html.parser') song_dict = {} # 遍历所有歌曲行（tr元素） for tr in soup.find_all('tr'): # 提取页面序号（在中） num_span = tr.find('span', class_='num') if not num_span: continue try: page_num = int(num_span.get_text(strip=True)) except ValueError: continue # 提取歌曲ID（在中） a_tag = tr.find('a', href=lambda href: href and href.startswith('/song?id=')) if not a_tag: continue href = a_tag['href'] song_id = href.split('=')[1] song_dict[page_num] = song_id return song_dict except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def sanitize_filename(filename: str) -> str: """移除文件名中的非法字符""" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename).strip() def download_song_and_metadata(save_path:str,song_url: str,number_info:str, level: str = "jymaster") -> bool: # === 第一步：请求你的解析 API === api_endpoint = "https://api.kxzjoker.cn/api/163_music" form_data = { "url": song_url, "level": level, "type": "json" } try: print("正在请求歌曲信息...") resp = requests.post(api_endpoint, data=form_data, timeout=15) resp.raise_for_status() data = resp.json() if data.get("status") != 200: print(f"API 返回错误状态: {data.get('status')}") return False # 提取字段 title = sanitize_filename(data["name"]) artist = data["ar_name"] album = sanitize_filename(data["al_name"]) audio_url = data["url"] cover_url = data["pic"] lyric = data.get("lyric", "") tlyric = data.get("tlyric", "") # === 第二步：创建专辑目录 === album_dir = os.path.join(save_path, album) os.makedirs(album_dir, exist_ok=True) # === 第三步：下载音频文件 === audio_ext = ".flac" if ".flac" in audio_url else ".mp3" audio_path = os.path.join(album_dir, f"{number_info}.{title}{audio_ext}") if not os.path.exists(audio_path): print(f"正在下载音频: {title}{audio_ext}") audio_resp = requests.get(audio_url, stream=True, timeout=30) audio_resp.raise_for_status() with open(audio_path, "wb") as f: for chunk in audio_resp.iter_content(chunk_size=8192): f.write(chunk) else: print(f"音频已存在，跳过: {audio_path}") # === 第四步：合并歌词并保存 .lrc === lrc_path = os.path.join(album_dir, f"{number_info}.{title}.lrc") if tlyric != None: combined_lyric = merge_lyrics(lyric, tlyric) else: combined_lyric = lyric with open(lrc_path, "w", encoding="utf-8") as f: f.write(combined_lyric) print(f"歌词已保存: {lrc_path}") # === 第五步：下载封面为 folder.jpg === cover_path = os.path.join(album_dir, "folder.jpg") if not os.path.exists(cover_path): print("正在下载专辑封面...") cover_resp = requests.get(cover_url, timeout=15) cover_resp.raise_for_status() with open(cover_path, "wb") as f: f.write(cover_resp.content) print(f"封面已保存: {cover_path}") else: print("封面已存在，跳过") print(f"\n✅ 下载完成！路径: {album_dir}") return True except Exception as e: print(f"❌ 下载失败: {e}") return False def merge_lyrics(original: str, translation: str) -> str: """ 合并原文和翻译歌词，生成双语 LRC。 假设时间戳一致，将翻译放在下一行。 """ orig_lines = original.strip().splitlines() trans_dict = {} # 解析翻译歌词的时间 -> 文本 for line in translation.strip().splitlines(): if "]:" in line or line.startswith("[by:"): continue parts = line.split("]", 1) if len(parts) == 2: time_tag, text = parts[0] + "]", parts[1].strip() if text: trans_dict[time_tag] = text # 合并 merged = [] for line in orig_lines: merged.append(line) if line.startswith("[") and "]" in line: time_tag = line.split("]", 1)[0] + "]" if time_tag in trans_dict: merged.append(time_tag + trans_dict[time_tag]) return "\n".join(merged) def extract_music_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML html = driver.page_source # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def download_album_from_song(url): info_result = extract_music_info(url) if info_result: print(f"艺术家: {info_result[0]}") print(f"专辑名称: {info_result[1]}") print(f"专辑ID: {info_result[2]}") else: print("提取失败，检查URL或页面结构") download_path = str(os.getcwd()+"/"+info_result[0]) songid_s = get_album_info("https://music.163.com/#/album?id="+info_result[2]) for number_info,songid_in in songid_s.items(): to_download_url = "https://music.163.com/song?id="+songid_in download_song_and_metadata(download_path,to_download_url,str(number_info), level="jymaster") def extract_songlist(filename: str): """ 主函数：读取指定文件并逐行处理。 :param filename: 要读取的 txt 文件名（位于当前目录） """ # 获取当前工作目录 current_dir = os.getcwd() file_path = os.path.join(current_dir, filename) # 检查文件是否存在 if not os.path.isfile(file_path): print(f"错误：文件 '{filename}' 不存在于当前目录 ({current_dir})") return try: with open(file_path, 'r', encoding='utf-8') as file: for line_number, line in enumerate(file, start=1): # 可选：跳过空行 if line.strip() == '': continue try: download_album_from_song(line) except Exception as e: print(f"处理第 {line_number} 行时出错: {e}") except Exception as e: print(f"读取文件时发生错误: {e}") # 使用示例 if __name__ == "__main__": txt_filename = "collect_list.txt" extract_songlist(txt_filename)装好需要的Python模块后，只需要在当前目录保存好collect_list.txt，放置浏览器驱动geckodriver，确保路径都正确，就可以Python启动了。过一会就会在当前目录获得想要的专辑文件了。整理音乐文件标签由于下载的flac缺少元数据，所以需要根据路径和文件名进行补全。这时候我也意识到我之前爬取的那一批媒体库文件并没有打好标签，导致jellyfin的展示效果还是有一定混乱。下面这个Python程序将会修复这个问题#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 将文件名中的曲号与曲名写入音频文件元数据（支持 FLAC/MP3/M4A） 用法示例： python3 write_title_and_track.py /home/chocola/data/files/Music --dry-run python3 write_title_and_track.py /home/chocola/data/files/Music --force """ import os, sys, re, argparse from pathlib import Path # try import mutagen, auto-install if missing (user mode) try: from mutagen import File from mutagen.flac import FLAC from mutagen.id3 import ID3, TIT2, TRCK, ID3NoHeaderError from mutagen.mp4 import MP4, MP4Tags except Exception: print("mutagen 未检测到，尝试安装（用户模式 pip3 install --user mutagen）...") import subprocess subprocess.check_call([sys.executable, "-m", "pip", "install", "--user", "mutagen"]) from mutagen import File from mutagen.flac import FLAC from mutagen.id3 import ID3, TIT2, TRCK, ID3NoHeaderError from mutagen.mp4 import MP4, MP4Tags AUDIO_EXTS = ('.flac', '.mp3', '.m4a', '.mp4', '.aac', '.ogg', '.opus') # will skip unknown handlers # regex to extract track number and title # matches: "01. Title", "1 - Title", "01 Title", "01_ Title", "01.Title", leading zeros allowed _leading_track_re = re.compile(r'^\s*(\d{1,3})\s*[\.\-_\s:]+\s*(.+)$') # fallback: "01Title" (no separator) _leading_track_nosep_re = re.compile(r'^\s*(\d{1,3})(.+)$') # normalize fullwidth parentheses and brackets to ascii def normalize_parens(s: str) -> str: return (s .replace('（', '(').replace('）', ')') .replace('［', '[').replace('］', ']') .replace('【', '[').replace('】', ']') ) def parse_filename_to_track_title(fn: str): name = fn # remove extension already handled by caller usually name = name.strip() name = normalize_parens(name) # try main regex m = _leading_track_re.match(name) if m: tr = m.group(1).lstrip('0') or '0' title = m.group(2).strip() return tr, title m2 = _leading_track_nosep_re.match(name) if m2: tr = m2.group(1).lstrip('0') or '0' title = m2.group(2).strip() # but if title is short (like only 1 char) and looks numeric, ignore return tr, title # No leading track detected: return (None, full name) return None, name def write_flac(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): audio = FLAC(path) tags = audio.tags or {} changed = False # ensure artist/album set if available if artist and (not tags.get('artist') or tags.get('artist') != [artist]): if not dry_run: audio['artist'] = [artist] changed = True if album and (not tags.get('album') or tags.get('album') != [album]): if not dry_run: audio['album'] = [album] changed = True # title cur_title = tags.get('title', None) if (force or not cur_title) and title: if not dry_run: audio['title'] = [title] changed = True # tracknumber cur_track = tags.get('tracknumber', None) if tracknum and (force or not cur_track): if not dry_run: audio['tracknumber'] = [str(tracknum)] changed = True if changed and not dry_run: audio.save() return changed def write_mp3(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): try: audio = ID3(path) except ID3NoHeaderError: audio = ID3() changed = False # artist / album frames: TPE1 / TALB if artist: cur = audio.getall('TPE1') cur_text = [f.text for f in cur] if cur else [] if force or not cur_text: if not dry_run: audio.delall('TPE1') audio.add(TIT2(encoding=3, text=[])) if False else None # noop to avoid pyflakes audio.add(ID3().get('TPE1')) if False else None # we'll set via frames below # set title cur_title = audio.getall('TIT2') if (force or not cur_title) and title: if not dry_run: audio.delall('TIT2') audio.add(TIT2(encoding=3, text=title)) changed = True # set tracknumber TRCK cur_tr = audio.getall('TRCK') if tracknum and (force or not cur_tr): if not dry_run: audio.delall('TRCK') audio.add(TRCK(encoding=3, text=str(tracknum))) changed = True # artist/album set via text frames TPE1/TALB if artist: cur_a = audio.getall('TPE1') if (force or not cur_a): if not dry_run: audio.delall('TPE1') audio.add(ID3().get('TPE1')) if False else None from mutagen.id3 import TPE1, TALB audio.add(TPE1(encoding=3, text=artist)) changed = True if album: cur_al = audio.getall('TALB') if (force or not cur_al): if not dry_run: from mutagen.id3 import TALB audio.delall('TALB') audio.add(TALB(encoding=3, text=album)) changed = True if changed and not dry_run: audio.save(path) return changed def write_mp4(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): audio = MP4(path) tags = audio.tags or MP4Tags() changed = False # artist if artist and (tags.get('\xa9ART') != [artist]): if not dry_run: tags['\xa9ART'] = [artist] changed = True # album if album and (tags.get('\xa9alb') != [album]): if not dry_run: tags['\xa9alb'] = [album] changed = True # title if (force or not tags.get('\xa9nam')) and title: if not dry_run: tags['\xa9nam'] = [title] changed = True # track number (trkn) is a tuple (track, total) if tracknum and (force or not tags.get('trkn')): try: tr = int(tracknum) except Exception: tr = None if tr: if not dry_run: tags['trkn'] = [(tr, 0)] changed = True if changed and not dry_run: audio.tags = tags audio.save() return changed def process_file(path: Path, root: Path, dry_run=False, force=False): # derive artist/album from path: root/.../Artist/Album/file rel = path.relative_to(root) parts = rel.parts if len(parts) < 3: # not deep enough; skip return False, "path_too_shallow" album = parts[-2] artist = parts[-3] stem = path.stem # name without extension # Normalize Unicode around parentheses stem = normalize_parens(stem) tracknum, title = parse_filename_to_track_title(stem) # if no tracknum found, leave tracknum None and title entire stem if tracknum is None: # remove trailing bracketed translations, e.g. "SongName (中文翻译)" -> keep whole as title title = stem.strip() # prefer title if present # write depending on extension ext = path.suffix.lower() try: if ext == '.flac': changed = write_flac(path, artist, album, tracknum, title, dry_run=dry_run, force=force) elif ext == '.mp3': changed = write_mp3(path, artist, album, tracknum, title, dry_run=dry_run, force=force) elif ext in ('.m4a', '.mp4'): changed = write_mp4(path, artist, album, tracknum, title, dry_run=dry_run, force=force) else: # unsupported ext -> try generic mutagen write as fallback audio = File(path) if audio is None: return False, "unsupported_format" # best-effort: set common tags changed = False if hasattr(audio, 'tags'): if force or not audio.tags.get('title'): if not dry_run: audio.tags['title'] = title changed = True if force or not audio.tags.get('artist'): if not dry_run: audio.tags['artist'] = artist changed = True if force or not audio.tags.get('album'): if not dry_run: audio.tags['album'] = album changed = True if tracknum and (force or not audio.tags.get('tracknumber')): if not dry_run: audio.tags['tracknumber'] = str(tracknum) changed = True if changed and not dry_run: audio.save() return changed, "done" return changed, "done" except Exception as e: return False, f"error:{e}" def main(): p = argparse.ArgumentParser(description="从文件名写入 title 与 track 到音频元数据（支持 FLAC/MP3/M4A）") p.add_argument("root", help="音乐库根目录（会递归）") p.add_argument("--dry-run", action="store_true", help="仅预览，不写入") p.add_argument("--force", action="store_true", help="强制覆盖已有标签") p.add_argument("--limit", type=int, default=0, help="仅处理前 N 个文件（测试用）") args = p.parse_args() root = Path(args.root).resolve() if not root.is_dir(): print("错误：root 不是目录:", root); sys.exit(1) total = 0 changed_cnt = 0 for dirpath, dirnames, filenames in os.walk(root): for fn in filenames: if not fn.lower().endswith(AUDIO_EXTS): continue total += 1 path = Path(dirpath) / fn changed, reason = process_file(path, root, dry_run=args.dry_run, force=args.force) if changed: changed_cnt += 1 print(("DRY " if args.dry_run else "") + f"修改: {path} ({reason})") # optional limit if args.limit and total >= args.limit: break if args.limit and total >= args.limit: break print(f"完成: 总文件={total}, 修改={changed_cnt}") if __name__ == "__main__": main()直接把音乐库路径传入进去执行即可，例如：python tag_music.py /run/media/chocola/6T-DATA/Projects/Get_Information/Fuck_Netease/Music/注意，目录下的存放格式要和之前说的一样，也是就是艺术家/专辑/01.歌曲名.flac执行完成后，你就会得到一个自己的媒体库啦！但是，这样就结束了，吗？揭露网易云伪母带骗局如果你有留意到的话，你应该会发现我抓取的歌曲参数，选择的是最高音质，也就是jymaster超清母带。母带的定义是什么呢？母带（Master）是音乐制作流程中的最终成品音频文件，用于后续复制、发行和流媒体分发。它是在混音完成后，通过母带处理（Mastering）对整体音频进行精细调整，包括均衡、压缩、限幅、动态控制等，以确保在各种播放设备上具有一致的音质表现，并符合行业标准的响度与格式要求。母带不仅是物理或数字发行的源文件，也代表了作品最终的声音面貌，是连接创作与听众的关键环节。高质量的母带能提升音乐的整体清晰度、空间感和专业度。看着好像说很好啊，但是实际上，一般情况下发行的音乐文件大部分都是CD导出的，也就是平时我们能接触到的音质天花板就是CD无损音质16-bit/44.1kHz。而网易云平台上的所谓母带，绝大部分都是基于CD音质后期超上去的，保存这些庞大的文件不但对听感没有太大提升，反而还更加占用储存空间。这也是和群里面玩耳机的群友交流才得知的信息。如果不信，你可以安装个sox分析一下音频频谱图。sox 1.SWEET×SWEET.flac -n spectrogram -o ./infox.png可以很明显的发现能量主要集中在0–20kHz区域，呈现黄白色/橙色/红色。而到了20–90kHz区域则是深紫色/蓝色，几乎无信号。如果还有疑问，这里还有一张更加高清的频谱分析图：sox "1.SWEET×SWEET.flac" -n spectrogram -o "high_res_spectrogram.png" -x 4000 -y 1200这是很明显的后期人为超分或者算法AI超分插值上去的痕迹，所以没有必要保留这些无用的数据，可以安全地降低回CD无损音质。利用脚本转换文件节省空间因为这些媒体文件确实占用不小，占用了我NAS 160G的储存空间，问AI说至少可以压缩60%-70%的空间，所以我决定整顿这个媒体库。把下面的代码保存为convert.py，记得修改 原媒体库路径 和 新媒体库路径。#!/usr/bin/env python3 import os import shutil import subprocess from pathlib import Path from mutagen import File as MutagenFile # ================== 配置区域（修改你的路径） ================== SOURCE = Path("/home/chocola/data/files/Music/") # 原媒体库路径（结尾必须加 /） TARGET = Path("/mnt/4T-RAID1/data/music_01/") # 新媒体库路径（结尾必须加 /） # ============================================================ # 确保路径以斜杠结尾 if str(SOURCE)[-1] != '/': SOURCE = SOURCE / '' if str(TARGET)[-1] != '/': TARGET = TARGET / '' # 验证路径 if not SOURCE.exists(): raise FileNotFoundError(f"源路径不存在: {SOURCE}") TARGET.mkdir(parents=True, exist_ok=True) def get_sample_rate(file_path): """安全获取FLAC文件采样率（双保险：mutagen + mediainfo）""" # 尝试用mutagen try: audio = MutagenFile(file_path) if audio and 'audio' in audio: return audio.info.sample_rate except Exception: pass # mutagen失败，尝试mediainfo # 尝试用mediainfo（关键修复！） try: result = subprocess.run( ['mediainfo', '--Inform=Audio;%SamplingRate%', str(file_path)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=True ) rate_str = result.stdout.decode().strip() # 处理不同格式：如 "48000" 或 "48.0 kHz" if 'kHz' in rate_str: rate_str = rate_str.replace(' kHz', '').replace('.', '') return int(rate_str) except Exception as e: print(f"⚠ 无法获取采样率 (file: {file_path}, error: {e})") return 0 # 0表示无效采样率 def is_high_resolution_flac(file_path): """检查FLAC是否为高采样率 (>44.1kHz)""" sample_rate = get_sample_rate(file_path) if sample_rate <= 0: print(f"⚠ 跳过文件 (无效采样率): {file_path}") return False print(f" [采样率] {file_path}: {sample_rate} Hz") # 调试日志 return sample_rate > 44100 # >44.1kHz def convert_flac(file_path): """安全转换FLAC文件（100%避免ffmpeg交互）""" rel_path = file_path.relative_to(SOURCE) target_path = TARGET / rel_path # 确保目标目录存在 target_path.parent.mkdir(parents=True, exist_ok=True) # 用mutagen安全提取元数据 audio = MutagenFile(file_path) title = audio.get('title', [os.path.splitext(file_path.name)[0]])[0] artist = audio.get('artist', [''])[0] album = audio.get('album', [''])[0] # 使用ffmpeg安全转码 cmd = [ 'ffmpeg', '-y', '-i', str(file_path), '-map', '0:a:0', '-c:a', 'flac', '-sample_fmt', 's16', '-ar', '44100', '-metadata', f'title={title}', '-metadata', f'artist={artist}', '-metadata', f'album={album}', str(target_path) ] try: subprocess.run( cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) print(f"✅ 转码完成: {rel_path} (原采样率: {get_sample_rate(file_path)}Hz)") except subprocess.CalledProcessError as e: print(f"❌ 转码失败 (回退到原文件): {file_path}") shutil.copy2(file_path, target_path) def copy_file(file_path): """安全复制非FLAC文件""" rel_path = file_path.relative_to(SOURCE) target_path = TARGET / rel_path target_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(file_path, target_path) print(f"✅ 复制: {rel_path}") def main(): print(f"开始处理媒体库: {SOURCE} → {TARGET}") print("⚠ 正在处理特殊字符文件名 (日语/中文)...") for root, _, files in os.walk(SOURCE): for file in files: file_path = Path(root) / file if file.startswith('.'): # 跳过隐藏文件 continue if file.lower().endswith('.flac'): if is_high_resolution_flac(file_path): convert_flac(file_path) else: copy_file(file_path) elif file.lower().endswith(('.mp3', '.lrc', '.jpg', '.png')): copy_file(file_path) print("\n🎉 操作完成！新库已生成到:", TARGET) print(" - 伪高解析FLAC (48kHz+/96kHz/192kHz) 已转码为CD质量") print(" - CD质量FLAC/MP3/辅助文件已原样复制") if __name__ == "__main__": try: main() except Exception as e: print(f"❌ 紧急错误: {e}") exit(1)执行完成后，媒体库直接从160G变成49G，瞬间舒服了。不过话说回来，我今天打算尝试登陆网易云看看什么情况，居然说我的账户可能被运营商二次放号，确认是我的账号后，发现居然解封了。之前是一直卡着说要我刷脸实名认证，我不想搞，一气之下自建媒体库，不知不觉就用了一年了。不过后面我也不会再用网易云了，维护好自己的媒体库就行了，省得动不动自己的歌单又有一片变成VIP曲子听不了搞得我红温（
自制二次元主题显卡：猫娘乐园巧克力 RTX 2080 Ti
给显卡换上自己最爱的猫娘乐园巧克力主题说实话，这件事我老早就想做了——因为我的显卡是RTX 2080 Ti，正巧因为是涡轮卡，所以可以覆盖贴纸的面积很大。正巧咸鱼看到了便宜的背板——25包邮的映众RTX2080Ti背板：关键还是全新！于是，一拍即合，我拿下了背板，开始设计显卡皮肤。测量显卡尺寸背板到货后，我先用A3纸放在显卡背板下面，然后用针管笔描出背板的轮廓，并点出螺丝的孔位。然后再拆开显卡的导风罩（因为我是涡轮卡），将导风罩放在A3纸上面，固定好位置依次描边，描了三个面，描出了它的展开图。然后使用扫描仪进行扫描得出准确的设计参考轮廓图，扫描时设置的分辨率为300PPI。下面是扫描原稿：当然了，因为是用要贴贴纸的目标面按在纸上面描的，所以和贴纸的设计轮廓是完全相反的，所以要进行镜像处理，把描边的线稿翻转过来，才是正确的。寻找风格参考图因为我并没有设计过显卡皮肤贴纸，所以我决定从案例借鉴作为切入点，去找适合我的参考风格。在咸鱼高强度刷显卡个性化贴纸相关的页面，看了几十个案例后，有一个案例我觉得是可以参考和借鉴的，就是这个：为什么这么说呢，因为总体比较简单，画面不复杂，意味着我的设计的工作量会比较少。虽然我肯定是喜欢更华丽的画面，有一些别的显卡皮肤也非常的好看，我也想做出来，但是奈何技术水平和设计能力也就那样了，这种简单好看的设计对我来说更有可操作性——用简单的画面换取还不错的设计效果。解构这个设计稿，组成非常简单：角色立绘+配色色块+角色名字艺术字+作品LOGO所以，照着葫芦画瓢，我要做一个猫娘乐园巧克力主题的显卡，只需要搞到这几个要素，将其根据实际的设计尺寸组合起来，确保符合自己的审美和尺寸构图，让角色尽可能避开一些螺丝孔的位置，就可以了。开始设计我打算先从背板开始设计。背板贴纸设计万事开头难，这个设计工作一开始就犯难了——找不到特别合适的立绘图：因为猫娘乐园好看的同人图本来就很少，基本上要好看的只能解包游戏拿CG图，但是游戏的CG图始终是有限的，并没有多少。找来找去，我还是觉得用这张图比较合适：算是游戏CG里面很好看的一张图了，然后交给AI抠图，把角色扣出来：这样子，立绘就有了。本来我像在另一边把香草也放上的，但是放上后感觉构图混乱，也难以驾驭，最终还是践行了建筑大师路德维希·密斯·凡德罗的观点：“Less is more”——“少即是多”，只保留了巧克力，把香草去掉了（呜呜呜我的香草）。然而，接着往下设计，我发现这样子反而更好驾驭，因为就可以直接使用巧克力的主题色——粉色，然后使用整个粉色系装饰显卡：和参考的案例一样。调好缩放大小，只保留了角色上半身，避开螺丝孔后，得到了初步设计的方案：画面的粉色是直接从角色的衣服上提取的颜色，确保了整体的协调，有一说一，粉色配白色还挺搭配的。但是感觉画面空空的，好像少了点什么。然后继续观察参考图，发现是一些小细节元素没有没有，导致整个画面看起来很空，于是，我加上了系列名称的艺术字，再加上猫爪和星星等可爱风格元素，并且针对性的减淡调色，确保只是作为装饰，而不抢占视觉注意力。另外，我看到很多二次元风的排版都喜欢用角色立绘做成半透明减淡后叠在背景增强效果，我也试了一下，感觉这个方法还是挺好用的，这是最终的画面效果：就算是放当作背景的半透明减淡立绘，我也尽可能避免角色的脸落到螺丝孔的位置，不然看着自己喜欢的角色脸上被打个洞确实不爽。导风罩贴纸设计一开始其实我也想再弄一个立绘，但是翻遍了解包的CG图，似乎没有合适的了。正巧有一大堆Q版立绘，不如拿去试试。结果发现意外的合适，而且背板是角色立绘，正面散热器上是Q版立绘，也能形成差别和对比，效果还是不错的。再设计的时候，我特地把背板的设计稿放在对应位置的上面，确保能让整体设计协调，先设计个初版看看效果：细心的读者应该发现了，颜色分割线是延续了下来的，这就是我保证整体设计协调的手段之一。另外，粉色色块区域正好位于涡轮进风口，能削弱一下与内部黑色涡轮扇的颜色对比。后面考虑到粉色块太大可能有点单调，做了一下切分，最右边又露出来白色。最后像背板那样如法炮制，加上一点细节，导风罩的设计稿就完工了：哦，忘了说了，作为自由软件运动的拥护者，这个设计稿是使用GIMP制作的。如果你碰巧有顶部供电的涡轮卡RTX2080Ti，并且喜欢Chocola的话，我的设计稿你可以拿去直接用：RTX2080Ti_Dseign.7z解压密码是：www.nekopara.uk打印贴纸不得不说，这种小量打印订单是真的贵！问了一下群里面做广告相关行业的群友，他说最适合的材料是车贴，基本上耐受显卡六七十度的高温没什么问题。但是，问题来了，去淘宝定制车贴，居然一张A3大小的车贴要收我40块钱？！怎么不去抢？！而且找了四五个店铺，都差不多这个价……关键是，贵就算了，找了一家店铺，客服谈好后下单就没下文了，说设计会很快联系，然后过了一天没联系我，给我整红温了。后面和做广告行业的群友聊，他问我急不急用，不急的话可以用边角料帮我印，出个运费就行。我寻思着反正我也不急，就马上给淘宝那边取消订单，给群友发了设计稿，过了两周，终于发货了。还是群友好（）贴贴纸有一说一，虽然我已经预料到会有色差，但是没想到色差居然会这么大——粉色都快变成紫色了（相比之下我的L14158打印机配合铜版纸打印出来的设计稿就基本上没什么色差，当时我自己也打印了，用于验证大小是否合适。照片中右上角露出一点点的那个是铜版纸打印的，可以很明显看出来色差，右边下面那个是普通A3纸打印的，色差也很大，相比之下铜版纸简直是杀疯了。不过考虑到是群友送的，不能要求太多，将就用吧。贴好贴纸后：不得不说，挖出螺丝孔位真的需要很有耐心并且细心。接下来就是贴导风罩（第一次贴还贴废了一张）：同样的，小心地挖出对应的孔位：顺带清理显卡反正都拆开了，不如给显卡顺带来一次大保健，然后打开显卡中框的一瞬间，我傻眼了——去年换的，号称耐久5年，自称华为供应商的导热垫，碎的稀巴烂！避坑避坑！这玩意还不便宜，现在给我留了一个烂摊子，还不好清理！花了一点时间，又是手指甲扣，又是牙签挑，又是刷子刷，终于清理干净了。好巧不巧正好买了V100-SXM2，来对比一下核心大小：当年的NVIDIA旗舰芯片GV100和TU102居然能在我手里同框，当时可是想都不敢想。相比之下，TU102的754 mm²核心面积就是弟弟了，GV100因为有HBM2显存，整个都大了一圈，其核心也不小，达到了815 mm²。清理完成后，贴上群友RTX 4090清灰用剩的莱德尔HD90000，然后开始组装显卡。顺带提一嘴，涂的硅脂——信越7921，也是群友用剩的，能用RTX 4090的富哥确实大方。效果展示花了一点时间，把显卡装了起来，下面就是成品展示了。感觉可能是教室灯光问题，也有可能是贴纸拆出来用了一段时间后自然氧化变色，后面再看颜色正常了一些：后记：做完成品才发现，显卡系列的单词拼写错了，GeForce不小心拼成GeFroce了 QAQ ...... 这下变成独一无二的"GeFroce"显卡了（X
当手机“反客为主”：是工具，还是牢笼？文章开头先叠甲：文章仅代表个人观点，如果你有不同的思考也欢迎评论区讨论。近日听闻国内手机厂商计划进一步限制系统级 VPN 功能，不禁感慨：我们手中的智能手机，究竟是解放我们的工具，还是精心设计的牢笼？作为一个见证过开放互联网余晖和亲历过安卓手机开放时代的人，我想记录下对这一“日常必需品”的深层思考——你的手机，真的属于你吗？你的手机真的属于你吗？如果是普通不太了解手机技术细节的读者，或许看到这个标题会纳闷：我自己购买的手机，我自己使用，为什么不属于我？但是，很遗憾，对于绝大部分读者而言，你的手机就是不属于你。除非……你解锁了手机的Bootloader。Bootloader：手机的第0道枷锁首先，既然提到了Bootloader这个词，那就先解释一下这是什么东西：手机 Bootloader 是固化在 ROM 中的开机引导程序，开机后优先运行。核心功能是初始化手机硬件（如处理器、内存），验证系统完整性，随后引导操作系统启动。默认通常锁定，防止未授权修改；解锁后可刷机、安装第三方系统，但可能影响设备保修与安全性，是手机启动和系统定制的关键环节。正如介绍那样，默认Bootloader是锁定状态，也就是无法更改手机的操作系统。虽然手机厂商美名其曰提高系统安全性，防止未授权修改；但是，如果手机厂商做的手机操作系统本身就是一坨狗屎，那你也会因为这个锁定的Bootloader被套牢——无法自行更换其他的操作系统。这是正是手机厂商胆敢其在用户头上拉屎的一个重要原因，尤其是国内厂商，因为这坨屎你吃定了，除非你能解锁Bootloader，要么就换手机。而所谓的未授权修改，如果我是机主本人，希望修改呢？我的设备连修改的权利都没有了吗？对于相当一部分不支持用户解锁Bootloader的厂商而言，就是没有——例如华为。更加雪上加霜的是，近年来曾经允许用户自由解锁Bootloader进行折腾的手机厂商也在收紧——例如小米。当然了，如果是苹果用户，那就是地牢，没有任何硬件和操作系统层面的自由，基本上没办法解锁，甚至不能自己安装软件。这些Bootloader限制，完完全全就是厂商故意干的，因为原本的安卓系统，只需要在设置里面进入开发者选项打开OEM解锁选项，重启进fastboot，连接电脑后执行fastboot oem unlock就可以解锁手机的Bootloader并开始愉快的玩耍了。所谓什么未授权修改完全不成立，因为这是手机用户自己的行为，他们完全有对自己设备的处置权。而很多手机厂商，特别是国内手机厂商，把这个最基本的用户权利给剥夺了。他们打着保护用户数据和隐私，优化用户体验等名号，故意限制用户拥有设备的完整控制权，将用户圈养和绑死在自家煞费苦心搞出来的广告ROM里面使劲割韭菜，属实龌龊！题外话：有一说一，户晨风在贬低安卓吹捧苹果，说什么安卓人，苹果人，也不完全是空穴来风；因为对于普通不会折腾的用户而言，同样是绑死生态和系统，苹果手机的使用体验确实吊打了安卓几条街。但是，如果你真会折腾和优化安卓，那将爆杀苹果。ARM 生态：手机的第1道枷锁或许你会觉得，只要解锁了 Bootloader，就能像装电脑系统一样自由更换操作系统了吧？很遗憾，手机的硬件通用性一坨狗屎，这几乎是个奢望。 手机的硬件生态高度碎片化，系统专机专用——每个型号都必须刷对应的定制 ROM，稍有不慎就是黑砖。你根本没法像在 PC 上那样，想装什么系统就装什么系统，Windows，GNU/Linux，Unix，甚至MacOS，想换就换。 所以，就算你费尽千辛万苦解锁了 Bootloader，如果没有开发者为你这台设备专门适配系统包，你依然只能被迫继续吃厂商预制的狗屎 ROM。当然，既然都解锁了，root 是必须的——把那些藏在系统深处的云控组件、后台监控、性能限制统统扬了，体验才能好一大截。那么，为什么手机就不能像电脑一样通用？ 问题不在 ARM 架构本身，而在围绕 ARM 构建的整个封闭生态。 ARM 作为一种指令集架构，本质上只是定义了 CPU 如何执行指令，它本身并不阻止系统通用。真正的问题在于：手机厂商使用高度定制化的 SoC（系统级芯片），集成自家或供应商的基带、GPU、ISP、电源管理模块等；这些关键组件的驱动程序几乎全部闭源，且只提供给特定 Android 版本；没有统一的固件标准（比如 PC 上的 UEFI/ACPI），导致操作系统无法跨设备识别和调用硬件；厂商有意为之：一个机型一个 ROM，既能控制用户体验，又能制造“升级换代”的刚需。于是，明明是同一套 Android 底层，每家厂商、每款机型都要单独维护一套系统镜像，用户则被牢牢绑死在厂商的更新节奏和功能取舍上。这不是技术限制，这是人为制造的割裂。反观 x86 PC 生态，尽管也有驱动兼容性问题（比如某些新显卡不支持老系统），但得益于开放的 BIOS/UEFI 接口、标准化的 PCIe/USB 协议，以及社区驱动的努力，一台普通电脑几乎可以安装任何主流 GNU/Linux 发行版。即便微软有时故意拖延旧系统驱动支持，整体生态仍远比手机开放得多。作为一个从 PC 走进数字世界的人，我对这种“伪通用、真绑定”的 ARM 消费电子生态深感厌恶——不是讨厌 ARM，而是讨厌借 ARM 之名行封闭之实的厂商逻辑。有时候我也在想，手机本质上就是一台嵌入式计算机，如果厂商愿意推动硬件抽象层标准化、开放基础驱动、允许通用系统安装，那将是双赢：厂商无需为每个机型重复造轮子，可以集中精力优化核心体验；用户则能自由选择系统，获得长期支持，不再被“三年停更”抛弃。但这个设想大概率永远不会实现。因为现在的技术栈早已固化，而更重要的是——封闭 = 控制 = 利润。所谓“安全”“体验”“兼容性”，不过是遮羞布罢了。所以，如果你的手机不能解锁，或者就算解锁了也没人给你做第三方 ROM，那你对设备的掌控权，依然牢牢攥在厂商手里。厂商定制ROM：手机的第2道枷锁众所周知，国内手机厂商对于安卓系统的定制和魔改程度，是非常深的，所以有人说安卓不是Android不是没道理的，因为“安卓”和“Android”确实不是一个东西了。原版的安卓，就像是勉强够用的，装修一般的住宅，但是内部整洁，干净。没有一大堆广告，系统干净，精简，但是缺失了很多国内ROM才有的特色功能，例如一键复制验证码，系统深度个性化等。而国内手机厂商定制版的安卓，就像是一个装修豪华的套间，配套设施完备，外表富丽堂皇；但是，每一个房间都安装有监控探头和远程控制装置，这些东西的控制终端却不在套间里面，而是在物业管理办公室；另外，物业还会安排广告推销员敲门上门推销各类商品，如果你要使用房间的设备，还得看一段广告才能使用。这就是国内ROM的现状——精装粪坑：定制功能非常多，不管你用不用得到，全部给你塞进去，便利程度确实是拉满的，但是系统也因此变得臃肿不堪；另外还有云控组件远程施法，可以让你的设备变得好用，也可以让你的设备变卡逼你换机！现在的国内ROM，这种情况愈演愈烈，不但人为降低用户使用体验，还会限制用户使用他们的设备——例如前段时间更新了一个功能：设置手机的热点WIFI名称必须联网检查。这就很搞笑了，我设备设置什么名称，关你屁事？手机是我买的，又不是你的。果然国内手机不解锁Bootloader只有使用权，没有所有权——这是事实。现在又说要限制和阉割VPN功能，这就更疑惑行为了。那我连使用自建EasyTier服务器，进行内网穿透组网访问我家里面的设备的资格都没有了？首先排除手机虚拟局域网组网？又或者说，我想设置广告过滤软件，让手机流量通过广告过滤软件清洗，也不行？技术是双刃剑，肯定有好的一面，也有坏的一面，但是似乎国内厂商更希望把技术坏的一面发扬光大，好的方面避之不谈。看到一个技术有坏的方面，如果对于他们影响不大，就火急火燎的一刀切，但是如果对他们影响较大，反倒是会认真考虑。吃相难看！除了这个，还有一点让我对国内ROM反感的就是——限制用户安装第三方APP。尽管我能理解是出于保护用户考虑，毕竟国内某些不良软件厂商的所作所为确实让人火大：点一下就各种威逼利诱让你下载它的apk文件，让后诱导你安装。然后这些被诱导安装的应用就是手机里面的流氓软件，各种广告，通知，弹窗不停歇，还想尽办法获取各种权限，恶性至极！我认为手机厂商拦截这些狗屎APP作恶，没什么不妥。但是，如果一刀切了，禁止用户手动安装任何APP，那确实过火了。更加稳妥的做法是，默认打开外部安装软件的限制，但是也明确在设置给出关闭开关，关闭时明确告知用户风险并做好对应的提示，这就够了。虽然苹果也不支持侧载应用，不过嘛，相比于国内手机厂商干的事情，苹果虽然在自由度层面是给你关入地牢，但是吃好喝好，AppStore有很多优质APP，而且对国内流氓应用的审核也更严格。至于国内厂商嘛~，给你锁起来吃大粪，应用商店很多应用就是一坨狗屎，真正好的小而精的应用根本看不到，连一些简单的小工具，都能给你塞一大堆广告进APP里面。这么对比下来，我感觉国内手机厂商这样子搞，只是为了更好的圈钱罢了，打着保护用户的名号去迫害用户，属实是招笑，自欺欺人。另外，这只是技术自由度上算的账，手机厂商在手机系统偷偷收集用户数据，这些账还没算呢！这算是你在购买手机第一次打开手机后“自愿”同意的条款内容——因为你不同意就用不了手机。表面上“合情合理”，说是收集什么数据，但是实际操作就不得而知——因为国内厂商定制手机ROM没有开源，是黑盒，还有云控这种东西，鬼知道背后干什么呢？我相信不少用户在QQ上刚聊着一个产品，从来没有在淘宝搜索过，打开淘宝就马上给你推送这个产品这样的经历吧？细思极恐啊！我认为手机厂商与其要搞这样的“霸王条款”，不如给用户一个选择：我可以拒绝同意所有条款内容，只使用手机最基础的功能，不依赖你厂商的任何云服务，你的系统任何和基础功能无关的组件不允许联网，也不会主动联网，这就够了。但是，这应该没有哪个厂商可以做到吧？手机是运行其他应用APP的基础底座，如果这个底座本身不可靠，那任何隐私和安全都是扯蛋！各类热门APP：手机的第3道枷锁对于部分，用户的自主权就稍微高了一点——我们可以不用某个APP。例如我本人就不用小红书，抖音，快手，B站，对，没错，我早就把B站卸载了。但是，对于一些刚需软件，就没有办法了，例如QQ，微信，支付宝，淘宝，各类银行APP等，这些基本上不可或缺。也就是，这属于是你不想用也得用的软件。对于这部分软件，他们的用户协议已经相当于半个霸王条款：因为你不同意就无法使用，但是这是刚需，你别无选择。我认为对于这种在互联网使用中核心级别的软件，更应该严格监管，确保它们的协议和所作所为不能侵犯用户权利。不过无论如何，对于相当一部分的手机APP，你一旦开始使用，就会进入软件厂商精心设计的“圈养”环境，然后任由算法支配。这些软件会改变你的生活习惯，消费习惯，审美习惯，甚至思考能力——尤其是内容型平台——如抖音，快手，小红书，B站等；当然，如果你有足够强的思辨和逻辑能力，或许可以在一定程度上抵御住这种侵蚀，但是，又有多少网民真的具备这样子的独立思考能力呢？这也就是时间的第3道枷锁了——算法圈养+信息茧房：他们只会让你看到他们希望你看到的，而他们不希望你看到的内容，你可能永远也看不到，哪怕这些内容能提高你的素养和认知。时间侵占+密集信息流：让你的大脑失去独立思考的时间，逐渐在密集的信息流中失去自我，时间和精力被不知不觉间剥夺。就我观察下来，其中毒害最甚的就是短视频。如果你在生活中注意观察，在地铁，公交车上，排队等候的场合等有碎片时间的常见，相当一部分人会刷短视频消磨时间。虽然我充分尊重其他人刷短视频的权利，但是我也想请问一下：对于那些花费了大量时间刷短视频的人，他们真的学到了什么有用的东西吗？很显然，好像并没有太多——因为很多真正的知识和技能是短视频无法在这样短的时间传授的。反倒是不少短视频博主为了博人眼球，贩卖焦虑，给其他人灌输了错误的知识，既浪费了别人的时间，又给他人的认知造成了负面影响——这种现象在抖音，快手和视频号最为严重。当然现在还出现了一种聚众“赛博吃屎”的行为——也就是明明知道内容是没有任何营养，只是博人眼球，或者具有较大的争议性，甚至是肮脏和龌龊的内容，但是因为有吃瓜和猎奇心态，总会忍不住点进去看：既浪费了时间，还脏眼，何必呢？那既然这样，与其都要浪费时间，为什么不把时间“浪费”在更有意义的地方：去学习计算机相关的知识，不再做赛博文盲；去学习和探索自己真正感兴趣的内容，培养自己的兴趣爱好；去看科普和知识类视频，扩展自己的眼界和认知…… 或者，就什么都不看，让大脑从现在网络密集的信息流解放出来，做一些独立的思考，你或许会对很多事情产生不一样的看法，也更有主见，而不是人云亦云。如果你不相信的话，你大可以按照我说的试一试，坚持一周或者一个月：卸载或者不再打开手机的短视频相关APP当有较短的空闲时间的时候，就直接让大脑放空一段时间，思考一下自己的生活情况，自己的兴趣爱好，自己真正喜欢的东西等。如果是有较多的空闲时间的话，可以尝试去了解一些之前并没有了解过的东西，多与他人交流，哪怕是线上，或许某一天，你会突然发现一个自己感兴趣且未曾探索过的知识新大陆。如果你有兴趣爱好，因为之前短视频侵占了时间导致兴趣爱好无法推进的，就可以就这次机会继续推进兴趣爱好了。以上只是我个人的观点，每个人真正喜欢和感兴趣的东西不一样，我这里只是给你提出几个比较可能且值得尝试的方向和方法，如果你有别的想做的事情，那就大胆去做，总之就是要尝试摆脱空闲时间被短视频侵占的状态。这么做下来，我相信许多人会找到比刷短视频更有意义的事情的。手机的便利性：手机的第4道枷锁或许有人会反驳这个观点：明明是更便利的使用体验，为什么会成为枷锁呢？然而事实就是这样——手机的过于便利性导致了很多网民失去了最基本的数字素养，失去了学习和解决问题的能力，成为了技术圈子里面的嘲讽和唾弃的对象——赛博文盲。现在的手机便利和傻瓜化程度远超电脑——应用商店点点就能安装APP，发布内容和视频模板一键生成…… 一切都是一键化，傻瓜化：不需要考虑兼容性问题，不需要性能调优，不需要关心任何稍微底层一点的东西，一切简单快速。虽然说，很多东西我们只需要会用就行，没必要关注底层的东西，但是，如果我们都只看表象，不进行深入的了解和挖掘这些光鲜亮丽的外表背后的运行逻辑，那我们只能做别人的鱼肉：你不了解手机存在云控机制，你怎么能知道某些厂商为了逼用户换机，故意通过云控下发降低性能的配置？明明你的手机可以用的更久，但是却被厂商计划报废。你不了解手机的不少APP在使劲往里面塞屎，让应用变得臃肿不堪，你怎么能知道为什么手机越用越卡的原因？明明2020年这些软件也是干这些事情，2025年也是干这些事情，但是体积却膨胀了好几倍，性能要求也翻了好几倍。手机厂商那边提升出来的性能，就这样被不知不觉的侵占了——而这部分性能本来属于你，你可以用来干其他更有意义的事情，而不是让这些糟糕的软件浪费。健康的做法应该是每一个软件做好自己份内的事情，把软件做好，小而精，就可以了，而不是什么都往里面塞——既浪费了用户的设备性能，又导致软件变得臃肿难以维护。你不了解手机输入法的隐私和联网逻辑，你怎么能知道你的所有输入，对于输入法软件厂商是一清二楚？这往往是很多人忽视的一点——手机输入法无论厂商这么保证所谓的“隐私”，输入法程序本身没有开源，就是一个黑盒，你怎么能确保他不剽窃隐私？如果你能认识到这一点，给手机的输入法断网，或者换社区开源的，值得信赖的输入法，可以极大程度的保护自己的隐私。不然你刚输入法输入了什么，其他平台就要开始推送了（笑）这只是列举了一些常见的情况，可见对技术的无知是多么可怕，而手机的傻瓜化和便利化，正在助长这种无知，而且是越来越严重——十年前还是人均root和刷机的时代，现在还能坚持root和刷机的又有多少呢？平心而论，你手机没有root，那这个手机你也是只有使用权——因为最高权限不在你手里。现在很多人连文件目录，压缩包，运行库这些最基本的计算机知识都不懂，甚至还出现了淘宝上面的软件代装服务这种离谱到了极致的东西，确实是数字时代的悲哀。然而，这对于手机厂商和软件厂商，这或许是好事——正因为用户无知，他们可以尽情为所欲为：手机厂商收紧解锁Bootloader策略：反正刷机群体不多，普通人用不到，绝大部分人不会反抗。事实上国内之前能随意解锁的厂商已经开始逐步收紧——温水煮青蛙这一块！但是这事实上是对用户对设备的所有权的侵犯！手机厂商随意云控用户设备：如果手机厂商想计划报废老机器，就可以设置降低性能的配置。普通人不知道云控这回事，只知道手机便卡了，该换手机了，却不知道这是手机厂商故意所作所为。但凡思考一下——一个设备，同样的配置，为什么几年前流畅运行，现在却不行，很明显，硬件还是那些硬件，是软件变慢了——就知道是怎么回事了。软件厂商不做优化，反而往里面塞屎：因为他们知道用户如果感觉使用变卡了，是觉得手机不行，而不是软件变得臃肿不堪，会去换手机，反而还给手机厂商助攻了。而用户却为这些软件厂商拉的屎买单——花钱买更好的设备，干着之前旧设备就能干的事情。软件厂商可以仗着用户不了解软件和系统的运作逻辑，强行给用户推送各种广告：这一现象在中老年群体的手机中尤为明显，手机的通知栏是被各种流氓软件强奸的重灾区，布满了各种乱七八糟的推送和广告。更有甚者，会直接获取悬浮窗权限给用户贴脸开大。这是对用户赤裸裸的侵犯！而可悲的是，很多人认为这居然是理所当然或者是很正常的事情，就这么用了——就像很多人认为电脑弹广告是理所当然的事情，而不是电脑里面的流氓软件所作所为。对于我这样了解过技术的人来说，这种现象实在是看不下去，而不少国内软件却对此乐此不疲。所以我鄙视国内软件，对于个人软件使用奉行去国产化原则，不是没有道理的——因为不用这些垃圾国产软件和平台，互联网使用体验会好非常多！那么，我们能做什么如果这个我们，只是自己只身一人，或者几个小群体，那确实做不了什么——因为我们影响力有限，达不到倒逼手机厂商和软件厂商做出改变的程度。但是，如果我们能像鲁迅先生说的一样：“愿中国青年都摆脱冷气，只是向上走……有一分热，发一分光，就令萤火一般，也可以在黑暗里发一点光”，让更多的普罗大众了解光鲜亮丽且便利的技术背后的黑暗和对个人的侵犯，或许有可能能够扭转局面，倒逼手机厂商和软件厂商不再“奴役”和“剥削”用户。虽然这个希望渺茫，但是清醒的人每多一个，这个愿望的实现的进度条就会往前走一点——哪怕可能只有0.0001%都不到。而我，敢于表达出来，就算是在我的个人层面发了一份光，给愿意阅读这篇文章的人抛砖引玉。另外，还有一个值得延伸思考的现象：如今许多学习和工作类 APP，正在把用户的私人手机变成管理者的监控终端，用用户的手机作为工具来奴役用户——学校强制使用跑步 APP 完成体测里程，学习通要求 GPS 定位+人脸拍照打卡，企业微信强制上班下班定位签到……明明是用户自己花钱买的设备，却被迫成为组织节省人力成本的工具。这是否意味着，我们的“数字私有财产”，早已在不知不觉中沦为他人的“生产资料”？你怎么看？希望你读完这篇文章也能有自己的思考，捍卫好自己的数字权利。
二次开发萌化VisitorLoggerPro插件其实之前我一直在用VisitorLoggerPro这个插件，但是由于之前的版本不够完善，也没想着进一步提升改造。前几天偶然翻了翻这个项目的GitHub仓库，发现基本上更新完善了，上一次更改已经是三个月之前，遂下了最新版回来尝试一下，发现功能非常满意。但是，美中不足的是，插件从jsdelivr.net下载相关的静态文件容易卡半天加载不出来。虽然插件写有智能回退使用网站的服务器进行加载，但是我发现它缺失了文件且返回路径不对，导致如果jsdelivr.net无法访问，页面会加载异常。于是，就萌生了修复这个插件的问题并且改造进行个性化的想法。打开了后台页面观摩了一下，并用浏览器的开发者工具随手调了一下CSS样式，最终明确了个性化的路径：设置一个全局背景图看板卡片进行半透明化于是说干就干，我先Fork了原项目，想了想要不就取名VisitorLoggerPro-Enhanced吧！然后开始了我的折腾和修改之旅。Github项目地址：VisitorLoggerPro-Enhanced可以去直接下载使用。修复静态文件回退到本地失败这肯定是最先需要解决的问题。如果不解决这个问题，该插件在我这里就处于不可用状态。补回缺失的本地文件这一步对于有网站搭建经验的人很简单，F12打开浏览器开发者工具，然后刷新页面观察网页都发送了那些外链请求，一下子就抓出来了：本地缺失的js：https://cdn.jsdelivr.net/npm/flatpickr本地缺失的css：https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css其中，flatpickr抓取回来是没有后缀的，得加上个.js后缀变成flatpickr.js。然后对号入座，flatpickr.js放到js/目录，新建一个css/目录放置flatpickr.min.css。补回文件后的插件目录文件树应该是这样的：tree . ├── action │ └── visitor-stats.php ├── Action.php ├── adapter.php ├── css │ └── flatpickr.min.css ├── getTrendData.php ├── getVisitStatistic.php ├── ip2region │ └── src │ ├── ip2region.xdb │ └── XdbSearcher.php ├── ipdata │ └── src │ ├── ipdbv6.func.php │ ├── IpLocation.php │ ├── ipv6wry-country.db │ ├── ipv6wrys.db │ ├── qqwry.dat │ ├── qqwrye.dat │ └── zxipv6wry.db ├── js │ ├── echarts.min.js │ ├── flatpickr.js │ └── README.md ├── panel.php ├── Plugin.php ├── README.md ├── trend.php └── visitor-stats.php 8 directories, 23 files修复完善静态文件获取逻辑原本插件本地静态文件的相对链接写法是：localScript.src = './js/echarts.min.js';这会导致获取文件时发生404错误，因为在插件页面上下文中，该相对路径会被解析为https://www.nekopara.uk/admin/js/echarts.min.js，那包404的。正确的URL连接位置应该是https://www.nekopara.uk/usr/plugins/VisitorLoggerPro/js/echarts.min.js，所以，这个相对链接应该改成../usr/plugins/VisitorLoggerPro/js/echarts.min.js。而且，原插件仅实现了echarts.min.js的本地回退逻辑，未处理另外两个静态文件（flatpickr.js和flatpickr.min.css），因此即使修复了路径，还是会卡加载。还有一个致命的缺陷是，原本的回退本地源的逻辑是等待失败，但是一等就是大几十秒，非常影响体验。于是我就设置了超时时间，一旦第三方的静态文件加载时间超过2秒，立即回退到本地，避免长时间等待。以下是完善后的逻辑代码：对于panel.php内嵌js部分的修改，在原文件64行的地方开始修改： 对于visitor-stats.php内嵌js部分的修改，在原文件90行的地方开始修改： 对于trend.php内嵌js部分的修改，在原文件12行的地方开始修改： 对实现显示图表的trend.php，panel.php和visitor-stats.php的修复主要解决以下核心问题：加载超时问题：原插件等待第三方CDN加载超时（大几十秒），现添加2秒超时机制，一旦CDN加载超时立即回退到本地，极大提升加载体验。加载逻辑完善：原逻辑仅处理echarts.min.js，现完整支持echarts.min.js、flatpickr.js和flatpickr.min.css的智能加载与回源。错误处理增强：添加加载成功/失败日志，以及加载失败时的用户提示，提升用户体验与问题排查效率。以上修复确保在jsdelivr.net不可用时，插件仍能快速加载并正常运行，同时保持原有功能完整性。修复我已经为GitHub上的原项目提交了的Pull Requests，能不能被合并就不知道了（接下来开始搞我的个性化了）萌化VisitorLoggerPro首先，最简单粗暴的萌化方式就是加上二次元背景图，这是最直接，最有效果的方式。我的修改思路是，先在浏览器开发者工具里面直接修改网页代码查看效果，然后再针对性去修改插件里面对应的代码。为插件添加背景图在插件界面，右键点击页面边缘的空白处，选择检查。然后在HTML代码区一路上划找到页面最底层的 盒子，发现是 ，然后针对性去找CSS样式表，真给我找到了： .main { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }这时可以先在浏览器里面修改CSS样式表看看效果：.main { padding: 20px; min-height: 100vh; background-image: url('https://teachermate.oss-cn-qingdao.aliyuncs.com/irGnf-1725727799259-neko4_n33b.png'); background-repeat: no-repeat; background-position: center center; background-attachment: fixed; background-size: cover; }瞬间就对味了一大截：然后就需要修改规则了，在全部文件中检索.main这个CSS类选择器，然后进行代码修改。好了，那么现在问题来了，我虽然是可以直接找到对应的代码写死URL在上面，但是如果别人想换个图片，就没这么方便了。于是我就在思考，能不能把这个参数作为应该可以给用户设置的插件参数？得益于Typecho插件开发的便利性，答案是肯定的，只需要在插件设置界面给出设置插件的参数的地方，在后面通过Helper::options()获取这个参数即可。找Plugin.php添加多一个设置选项：插件背景设置 /* 插件背景设置 */ $backgroundUrl = new Typecho_Widget_Helper_Form_Element_Text( 'backgroundUrl', null, 'https://pic.nekopara.uk/?format=webp', // 默认值 _t('插件背景设置'), _t('可填写图片API的URL（如随机图片API）或具体图片的URL直链。默认使用猫娘乐园图片API') ); $form->addInput($backgroundUrl);我这里定义参数名称为backgroundUrl，然后在插件代码里面进行获取的方式就是：$backgroundUrl = Helper::options()->plugin('VisitorLoggerPro')->backgroundUrl ?: 'https://pic.nekopara.uk/?format=webp';在嵌入到PHP的CSS样式表部分，我只需要这样子修改： .main { padding: 20px; min-height: 100vh; background-image: url('<?php echo $backgroundUrl; ?>'); background-repeat: no-repeat; background-attachment: fixed; background-position: center center; background-size: cover; }就可以根据设置的URL返回特定的样式表了，让用户可以自定义背景图的问题也就迎刃而解了。插件默认的设置图片来源是我的猫娘乐园CG图API，可以自行更换为其他图片直链或者图片API。为卡片设置半透明颜色但是，只是修改一个背景图，卡片是不透明的把好看的图片都基本上档完了，多没意思？于是，我就想在保证可读性的情况下，让卡片半透明化，让背景图能被看到。继续F12大法，找到每个卡片对应的CSS规则：访客日志页面部分，对应panel.php文件： .page-header { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } .action-form { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; flex-direction: column; } .logs-section { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); /* height: calc(100vh - 200px); */ overflow: auto; min-width: 0; border: 1px solid #e2e8f0; } .stats-section { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); /* height: calc(100vh - 200px); */ display: flex; flex-direction: column; gap: 24px; border: 1px solid #e2e8f0; } .chart-container { flex: 1; min-height: 260px; background: linear-gradient(135deg, #fff 0%, #f8fafc 100%); border-radius: 12px; padding: 8px; border: 1px solid #e2e8f0; position: relative; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); transition: all 0.3s ease; overflow: hidden; }访客日志趋势分析部分，对应trend.php文件： .page-header { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } .controls-section { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; } .trend-section { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; } .stats-summary { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; padding: 16px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; flex-wrap: wrap; } .metrics-explanation { background: #fff; border-radius: 12px; padding: 24px; margin-top: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; } .metric-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 20px; transition: all 0.3s ease; } .technical-notes { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 20px; border-left: 4px solid #3498db; }只需要将每个卡片的background: #fff;定义颜色部分设置成半透明即可。虽然最简单的方法就是保持原来的颜色，增加透明度，但是我考虑到这一片卡片后面如果是靠修改代码调整，调整起来也很花时间，不如像图片背景那样给用户输入一个颜色自己配。同样的方法，在Plugin.php中增加配置选项： /* 插件卡片颜色设置 */ $backgroundColour = new Typecho_Widget_Helper_Form_Element_Text( 'backgroundColour', null, '#ffffffc4', // 默认值 _t('插件展示内容卡片颜色设置，采用HTML颜色代码') ); $form->addInput($backgroundColour);然后在panel.php和trend.php加上：// 获取配置的卡片背景色（带默认值） $backgroundColour = Helper::options()->plugin('VisitorLoggerPro')->backgroundColour ?: '#ffffffc4';并针对性的设置需要设置的颜色CSS代码位置，例如： .page-header { background: <?php echo $backgroundColour; ?>; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }找到上面说的CSS代码，将背景颜色代码部分替换为<?php echo $backgroundColour; ?>就完事了。这次的自定义修改，让我对Typecho的插件和PHP代码逻辑有了更进一步的认识，虽然仍是面向LLM编程，但是至少有实现是思路和方法，不会具体的语法和代码，倒是可以让LLM来写。效果展示最后展示一下萌化后的效果：这下暴露出小破站访问量了，果然是没什么人看的，被打的次数倒是不少（番外：使用方法进入你的Typecho部署目录，然后进入插件文件夹：cd /data/typecho/ cd usr/plugins/随后，克隆项目并重命名文件夹：git clone https://github.com/Chocola-X/VisitorLoggerPro-Enhanced mv VisitorLoggerPro-Enhanced/ VisitorLoggerPro/删除无用文件（可选）：cd VisitorLoggerPro/ rm -rf .git/ rm -rf .gitignore rm README.md 然后进入Typecho后台启用插件即可。当然，如果你凑巧服务器在国内无法正常访问GitHub的话，我这里有直链可以给你下载，进入插件目录下载并解压即可：wget https://teachermate.oss-cn-qingdao.aliyuncs.com/air2Q-1762344686455-VisitorLoggerPro.zip unzip https://teachermate.oss-cn-qingdao.aliyuncs.com/air2Q-1762344686455-VisitorLoggerPro.zip rm https://teachermate.oss-cn-qingdao.aliyuncs.com/air2Q-1762344686455-VisitorLoggerPro.zip
个人博客低成本CC防护：抵御85%+常见CC攻击
低成本CC防护：我的实战配置与效果喜报：被大规模CC了！平常的一天，突然受到了来自衣食父母华为云的服务器告警：虚拟机网络连接数突破15400个，CPU占用超过 85% 。就知道肯定是被CC了。果然网站被打是催更技术类博主最好的方式（雾不过我之前是设置过缓存的，有点好奇怎么绕过缓存，事后我抓取了对应时间的Nginx日志access.log：43.152.29.37 - - [30/Oct/2025:23:27:16 +0800] "GET /3bb6bfea9/90a2c9c9fc?limit=ef616a8b1b145df&token=176fe0f01f&utm_source=a5d4f247077b HTTP/1.0" 404 29335 "http://www.nekopara.uk/3bb6bfea9/90a2c9c9fc?limit=ef616a8b1b145df&token=176fe0f01f&utm_source=a5d4f247077b" "Mozilla/5.0 (Windows NT 11.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0" "91.209.77.93, 43.152.29.37" 43.152.24.20 - - [30/Oct/2025:23:27:16 +0800] "GET /profile/config/delete?search=9844b7e151&version=504397e6490c&utm_campaign=bd5783ef290&page=4b9e8cc670d HTTP/1.0" 404 29335 "http://www.nekopara.uk/profile/config/delete?search=9844b7e151&version=504397e6490c&utm_campaign=bd5783ef290&page=4b9e8cc670d" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" "138.2.109.39, 43.152.24.20" 43.152.143.43 - - [30/Oct/2025:23:27:16 +0800] "GET /health/admin?utm_medium=eb37025 HTTP/1.0" 404 29335 "http://www.nekopara.uk/health/admin?utm_medium=eb37025" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" "163.61.245.58, 43.152.143.43" 43.174.55.12 - - [30/Oct/2025:23:27:16 +0800] "GET /5de3c/0727afb/info?type=f06d22885b8 HTTP/1.0" 404 29335 "http://www.nekopara.uk/5de3c/0727afb/info?type=f06d22885b8" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15" "103.184.54.67, 43.174.55.12" 43.152.14.36 - - [30/Oct/2025:23:27:16 +0800] "GET /edit/a97b/cba81414/content.htm?offset=09cd68b44dd60&key=030f41284 HTTP/1.0" 404 29335 "http://www.nekopara.uk/edit/a97b/cba81414/content.htm?offset=09cd68b44dd60&key=030f41284" "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" "49.145.113.40, 43.152.14.36" 43.152.14.27 - - [30/Oct/2025:23:27:16 +0800] "GET /status/356037c/list/app.json?id=8ccefcb24&key=257c67462c3ef80&token=45b973f&lang=373143c9654e06f HTTP/1.0" 404 29335 "http://www.nekopara.uk/status/356037c/list/app.json?id=8ccefcb24&key=257c67462c3ef80&token=45b973f&lang=373143c9654e06f" "Mozilla/5.0 (Windows NT 11.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0" "138.2.109.39, 43.152.14.27" 43.152.24.20 - - [30/Oct/2025:23:27:16 +0800] "GET /upload/view/detail?utm_source=0a511c HTTP/1.0" 404 29335 "http://www.nekopara.uk/upload/view/detail?utm_source=0a511c" "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36" "119.93.135.193, 43.152.24.20" 101.33.20.48 - - [30/Oct/2025:23:27:16 +0800] "GET /05ad3/list/view/data.txt?timestamp=7cf23ee09d6dc HTTP/1.0" 404 29335 "http://www.nekopara.uk/05ad3/list/view/data.txt?timestamp=7cf23ee09d6dc" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15" "187.49.87.255, 101.33.20.48" 43.175.30.25 - - [30/Oct/2025:23:27:16 +0800] "GET /profile/4e95d5?limit=b15210f1d9b08&token=63e194b&type=bfea84c37a29f8&nonce=b222a4b1b38a HTTP/1.0" 404 29335 "http://www.nekopara.uk/profile/4e95d5?limit=b15210f1d9b08&token=63e194b&type=bfea84c37a29f8&nonce=b222a4b1b38a" "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36" "206.84.201.101, 43.175.30.25" 43.152.29.37 - - [30/Oct/2025:23:27:16 +0800] "GET /8ac4af716/info.php?ref=b09658abd0&page=3d48aaa2614300&page=5b84ad8c3&token=717bad8cac HTTP/1.0" 404 16 "http://www.nekopara.uk/8ac4af716/info.php?ref=b09658abd0&page=3d48aaa2614300&page=5b84ad8c3&token=717bad8cac" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" "41.139.226.11, 43.152.29.37" 43.152.26.48 - - [30/Oct/2025:23:27:16 +0800] "GET /info?offset=34297ebb3908dec&timestamp=91d8330f0f6330&sort=8d7f7fe923&category=9f7158e6486f01f HTTP/1.0" 404 29335 "http://www.nekopara.uk/info?offset=34297ebb3908dec&timestamp=91d8330f0f6330&sort=8d7f7fe923&category=9f7158e6486f01f" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0" "2.139.62.85, 43.152.26.48" 43.152.143.41 - - [30/Oct/2025:23:27:16 +0800] "GET /6cc1/download?category=248162dd8&token=fc785d9170235a&id=e6c8ef9&limit=b852d3ab3d28 HTTP/1.0" 404 29335 "http://www.nekopara.uk/6cc1/download?category=248162dd8&token=fc785d9170235a&id=e6c8ef9&limit=b852d3ab3d28" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1" "203.76.222.119, 43.152.143.41" 好家伙，随机请求URL，每一次都不一样，难怪能MISS，CPU直接被干爆了！而且，我还惊奇的发现，记录下来的都是EdgeOne的IP，这也暴露了之前设置，传递来源IP没做好。既然发现了弱点和问题，那我们就来解决它！修复来源IP传递异常首先，这个问题出现在同VPC的跳板服务器上，之前只是添加了请求头，但是没有信任上一级也就是来源于CDN的IP的X-Forwarded-For请求头。并且还重复添加了请求头导致最终源服务器上呈现的是两个不一样的X-Forwarded-For，第一个是真实的IP,第二个是CDN的IP。修复方法很简单，不在跳板服务器添加请求头，并且信任CDN的X-Forwarded-For请求头就好了，直接传递过去。下面是配置文件：server { listen 443 ssl; server_name www.nekopara.uk; set_real_ip_from 0.0.0.0/0; real_ip_header X-Forwarded-For; ssl_certificate /data/certs/nekopara_uk_cf.pem; ssl_certificate_key /data/certs/nekopara_uk_cf.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; location / { client_max_body_size 32m; proxy_pass https://www.nekopara.uk:443; proxy_set_header X-Forwarded-Proto $scheme; }注意！我这里设置信任整个IP段的前提是，我已经在安全组设置了访问白名单，仅CDN的IP可以访问443和80端口，所以这样子设置是安全的。如果你没有设置访问白名单，这样子会有较大的安全风险！你应该在这部分逐个加入CDN的IP进行过滤，并且考虑屏蔽其他IP的请求。能够正常传递来源IP是后面进行设置的基础，如果没有正常传递来源IP，源站的Nginx配置规则会屏蔽掉CDN的访问导致访问全部被拒绝！一定要注意！为源站设置请求限流因为大量这种随机请求的资源消耗是相当可观的，我们需要进行严格的限制。第一次尝试的解决方案我第一次考虑的做法是考虑在Nginx的配置文件中添加limit_req_zone $binary_remote_addr zone=dynamic_limit:10m rate=1r/s;和limit_req zone=dynamic_limit burst=2 nodelay;进行限制，普通用户正常访问不以极高的手速快速刷新页面的话基本上不会被阻止。同时，使用limit_req_zone $binary_remote_addr zone=static_limit:10m rate=50r/s;和limit_req zone=static_limit burst=50 nodelay;对于静态的文件进行独立限流，因为访问网站会产生多个静态资源文件请求，限流规则应该设置得更为宽松，避免误伤正常用户。还有就是，如果设置了限流，默认Nginx会返回503错误代码并显示：503 Service Temporarily Unavailable。我认为不够友好，于是定义将错误返回为429，也就是Too Many Requests，更好的传达实际情况，并且自定义了429界面。请注意，返回的429界面需要尽可能的小和简单，传达清楚意思即可。如果太过于华丽，文件肯定会比较大，会占用较大的服务器上行带宽。以下是详细配置文件：主配置文件nginx.conf:# /etc/nginx/nginx.conf user nginx; # Set number of worker processes automatically based on number of CPU cores. worker_processes auto; # Enables the use of JIT for regular expressions to speed-up their processing. pcre_jit on; # Configures default error logger. error_log /var/log/nginx/error.log warn; # Includes files with directives to load dynamic modules. include /etc/nginx/modules/*.conf; # Include files with config snippets into the root context. include /etc/nginx/conf.d/*.conf; events { # The maximum number of simultaneous connections that can be opened by # a worker process. worker_connections 1024; } http { # 动态页面限流配置（严格） limit_req_zone $binary_remote_addr zone=dynamic_limit:10m rate=1r/s; # 静态资源限流配置（宽松） limit_req_zone $binary_remote_addr zone=static_limit:10m rate=50r/s; # 定义 FastCGI 缓存路径 fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=my_fastcgi_cache:16m inactive=60m; # 定义缓存键，通常包含请求方法、主机名和 URI fastcgi_cache_key "$scheme$request_method$host$request_uri"; # Includes mapping of file name extensions to MIME types of responses # and defines the default type. include /etc/nginx/mime.types; default_type application/octet-stream; # Name servers used to resolve names of upstream servers into addresses. # It's also needed when using tcpsocket and udpsocket in Lua modules. #resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001]; # Don't tell nginx version to the clients. Default is 'on'. server_tokens off; # Specifies the maximum accepted body size of a client request, as # indicated by the request header Content-Length. If the stated content # length is greater than this size, then the client receives the HTTP # error code 413. Set to 0 to disable. Default is '1m'. client_max_body_size 1m; # Sendfile copies data between one FD and other from within the kernel, # which is more efficient than read() + write(). Default is off. sendfile on; # Causes nginx to attempt to send its HTTP response head in one packet, # instead of using partial frames. Default is 'off'. tcp_nopush on; # Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2. # TIP: If you're not obligated to support ancient clients, remove TLSv1.1. ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; # Path of the file with Diffie-Hellman parameters for EDH ciphers. # TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048` #ssl_dhparam /etc/ssl/nginx/dh2048.pem; # Specifies that our cipher suits should be preferred over client ciphers. # Default is 'off'. ssl_prefer_server_ciphers on; # Enables a shared SSL cache with size that can hold around 8000 sessions. # Default is 'none'. ssl_session_cache shared:SSL:2m; # Specifies a time during which a client may reuse the session parameters. # Default is '5m'. ssl_session_timeout 1h; # Disable TLS session tickets (they are insecure). Default is 'on'. ssl_session_tickets off; # Enable gzipping of responses. #gzip on; # Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'. gzip_vary on; # Helper variable for proxying websockets. map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Specifies the main log format. log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; # Sets the path, format, and configuration for a buffered log write. access_log /var/log/nginx/access.log main; # Includes virtual hosts configs. include /etc/nginx/http.d/*.conf; }网站配置文件nekopara.conf:server { listen 443 ssl; server_name nekopara.uk; ssl_certificate /data/certs/nekopara_uk.pem; ssl_certificate_key /data/certs/nekopara_uk.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'; location / { return 302 https://www.nekopara.uk$request_uri; } } # Typecho HTTPS服务器块 server { listen 443 ssl; #proxy_protocol; server_name www.nekopara.uk; #set_real_ip_from 127.0.0.1; set_real_ip_from 192.168.1.255; real_ip_header X-Forwarded-For; #real_ip_recursive on; #set $real_host $http_x_forwarded_host; #if ($http_x_forwarded_host = '') { # set $real_host $host; #} # 添加这一行，确保生成的重定向不带后端端口 #port_in_redirect off; # 设置最大上传文件大小为 32M client_max_body_size 32M; # SSL证书和密钥路径 ssl_certificate /data/certs/nekopara_uk.pem; ssl_certificate_key /data/certs/nekopara_uk.key; # SSL设置（可选但推荐用于安全） ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # 网站根目录 root /data/typecho; index index.html index.php; # 1. 静态资源限流（宽松，50r/s） location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|eot|svg|mp4|webp|zip|rar|7z|psb)$ { limit_req zone=static_limit burst=50 nodelay; try_files $uri $uri/ =404; add_header Cache-Control "public"; } # PHP处理，并启用FastCGI缓存 location ~ \.php$ { limit_req zone=dynamic_limit burst=2 nodelay; fastcgi_ignore_headers Cache-Control Expires Set-Cookie; try_files $uri =404; fastcgi_pass unix:/var/run/php-fpm82/php8.2-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # 启用 FastCGI 缓存 fastcgi_cache my_fastcgi_cache; fastcgi_cache_valid 200 301 302 404 10s; # 设置缓存的有效时间 fastcgi_cache_methods GET HEAD; # 只缓存 GET 和 HEAD 请求 # 不缓存 POST 请求和其他非幂等性请求 set $skip_cache 0; if ($request_method = POST) { set $skip_cache 1; } if ($query_string != "") { set $skip_cache 1; } # 如果存在特定会话 cookie，则不缓存 if ($http_cookie ~* "PHPSESSID|__typecho_uid|__typecho_authCode") { set $skip_cache 1; } fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; # 添加头部信息显示缓存状态 add_header X-FastCGI-Cache $upstream_cache_status; } # 3. 默认路由（用于Typecho伪静态） location / { try_files $uri $uri/ /index.php?$args; autoindex off; } #自定义503为429 Too Many Requests error_page 503 =429 /429.html; location = /429.html { internal; root /etc/nginx/error_pages; #添加响应头告诉客户端或者爬虫请10秒后重试 add_header Retry-After 10 always; } # Deny access to hidden files location ~ /\.ht { deny all; } }自定义429界面429.html： 喵~别急！429 Too Many Requests 喵呜~刷新太快啦！ 429 Too Many Requests 小喵需要休息10秒~ ฅ^•ﻌ•^ฅ 但是在后续自己模拟单点慢速攻击的时候发现了一个问题，如果是持续不断的请求，即使大部分是429拒绝的相应，仍有夹杂着404的查询相应。虽然单个IP影响不大，但是如果攻击规模较大，仍然会拖垮服务器，并且也会比返回429错误消耗更多的带宽，需要更精准的限制和打击。这是测试用的Python程序：import requests import time import argparse import csv import os import string import random from datetime import datetime def generate_random_path(length=15): """生成指定长度的随机字符串（包含大小写字母和数字）""" characters = string.ascii_letters + string.digits return ''.join(random.choices(characters, k=length)) def main(): parser = argparse.ArgumentParser(description='随机URL请求测试脚本（用于验证服务器处理能力）') parser.add_argument('domain', help='目标域名（例如: example.com）') parser.add_argument('--interval', type=int, default=1000, help='请求间隔（毫秒，默认1000毫秒=1秒）') parser.add_argument('--count', type=int, default=100, help='请求总次数（默认100次）') parser.add_argument('--output', default='random_request_test.csv', help='输出CSV文件名（默认: random_request_test.csv）') parser.add_argument('--timeout', type=int, default=5, help='请求超时时间（秒，默认5秒）') parser.add_argument('--user-agent', default='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', help='自定义User-Agent') args = parser.parse_args() # 验证域名格式 if not args.domain.startswith('http'): args.domain = f"https://{args.domain}" # 创建请求头 headers = { 'User-Agent': args.user_agent, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', 'Connection': 'keep-alive' } # 准备结果列表 results = [] start_time = time.time() print(f"开始随机URL请求测试 (总请求: {args.count}, 间隔: {args.interval}ms, 超时: {args.timeout}s)") print(f"目标域名: {args.domain}") print(f"输出文件: {args.output}\n") # 创建CSV文件 with open(args.output, 'w', newline='', encoding='utf-8') as csvfile: fieldnames = ['timestamp', 'url', 'status', 'response_time_sec', 'content_length_bytes'] writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() # 执行请求 for i in range(1, args.count + 1): # 生成随机路径 random_path = generate_random_path() url = f"{args.domain}/{random_path}" # 记录请求开始时间 request_start = time.time() try: # 发送请求 response = requests.get( url, headers=headers, timeout=args.timeout ) response_time = time.time() - request_start status = response.status_code content_length = len(response.content) # 记录结果 result = { 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 'url': url, 'status': status, 'response_time_sec': response_time, 'content_length_bytes': content_length } results.append(result) # 输出进度 print(f"[{i}/{args.count}] {url} | 状态: {status} | 响应时间: {response_time:.3f}s | 大小: {content_length}B") # 写入CSV writer.writerow(result) except Exception as e: error_msg = str(e) result = { 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 'url': url, 'status': 'ERROR', 'response_time_sec': 0, 'content_length_bytes': 0 } results.append(result) print(f"[{i}/{args.count}] {url} | 错误: {error_msg}") # 间隔处理（最后一个请求不等待） if i < args.count: time.sleep(args.interval / 1000.0) # 生成报告 total_time = time.time() - start_time print(f"\n测试完成! 总耗时: {total_time:.2f}秒") print(f"结果已保存至: {args.output}") if __name__ == "__main__": main()这是测试的输出：[vanilla@NekoWorks ~]$ python3 random_request_test.py www.nekopara.uk --interval 50 --count 500 开始随机URL请求测试 (总请求: 500, 间隔: 50ms, 超时: 5s) 目标域名: https://www.nekopara.uk 输出文件: random_request_test.csv [1/500] https://www.nekopara.uk/6pKJpS6oAgTGGg9 | 状态: 404 | 响应时间: 0.430s | 大小: 29335B [2/500] https://www.nekopara.uk/h9utooL2wE8Iu9R | 状态: 404 | 响应时间: 0.373s | 大小: 29335B [3/500] https://www.nekopara.uk/VQr7eBnBMePGezI | 状态: 404 | 响应时间: 0.389s | 大小: 29335B [4/500] https://www.nekopara.uk/r87rqw8ftyrOV4E | 状态: 404 | 响应时间: 0.364s | 大小: 29335B [5/500] https://www.nekopara.uk/V8BBeJqFidADG8a | 状态: 429 | 响应时间: 0.274s | 大小: 266B [6/500] https://www.nekopara.uk/2FModsb5p1imNZY | 状态: 404 | 响应时间: 0.360s | 大小: 29335B [7/500] https://www.nekopara.uk/IGr3v06kf9xbw2L | 状态: 429 | 响应时间: 0.217s | 大小: 266B [8/500] https://www.nekopara.uk/vMUNbNh12WseZ79 | 状态: 429 | 响应时间: 0.265s | 大小: 266B [9/500] https://www.nekopara.uk/1qkNpklBD60HZlS | 状态: 429 | 响应时间: 0.218s | 大小: 266B [10/500] https://www.nekopara.uk/cg5pKmaTfI0pJF0 | 状态: 404 | 响应时间: 0.307s | 大小: 29335B [11/500] https://www.nekopara.uk/xPgKNHa3UTBVzBV | 状态: 429 | 响应时间: 0.217s | 大小: 266B [12/500] https://www.nekopara.uk/HvwRcAMHNb24egX | 状态: 429 | 响应时间: 0.293s | 大小: 266B [13/500] https://www.nekopara.uk/A7f0eWXeO35zUg3 | 状态: 404 | 响应时间: 0.287s | 大小: 29335B [14/500] https://www.nekopara.uk/SdadEt4mQFNUI2P | 状态: 429 | 响应时间: 0.278s | 大小: 266B [15/500] https://www.nekopara.uk/3ZJncTYxzQovjIA | 状态: 429 | 响应时间: 0.227s | 大小: 266B [16/500] https://www.nekopara.uk/gljFhsR4cYYk3aP | 状态: 404 | 响应时间: 0.301s | 大小: 29335B [17/500] https://www.nekopara.uk/CaFhCaIwy98bSRj | 状态: 429 | 响应时间: 0.216s | 大小: 266B [18/500] https://www.nekopara.uk/os3ZwlQY9d9gikA | 状态: 429 | 响应时间: 0.228s | 大小: 266B [19/500] https://www.nekopara.uk/Ai1OU2q1ucI5dRV | 状态: 404 | 响应时间: 0.366s | 大小: 29335B [20/500] https://www.nekopara.uk/HSzYv7RTC1bJBsD | 状态: 429 | 响应时间: 0.214s | 大小: 266B [21/500] https://www.nekopara.uk/QiX7850u9CB29wj | 状态: 429 | 响应时间: 0.218s | 大小: 266B [22/500] https://www.nekopara.uk/mczKcopcmVCSgO0 | 状态: 429 | 响应时间: 0.223s | 大小: 266B [23/500] https://www.nekopara.uk/zbodIK0sYxhY4Yy | 状态: 404 | 响应时间: 0.300s | 大小: 29335B [24/500] https://www.nekopara.uk/B5KkwqUZLWra8Ec | 状态: 429 | 响应时间: 0.235s | 大小: 266B [25/500] https://www.nekopara.uk/uy2IuH3f8gD9hfP | 状态: 429 | 响应时间: 0.210s | 大小: 266B [26/500] https://www.nekopara.uk/lpZdcdLIUEifinU | 状态: 404 | 响应时间: 0.297s | 大小: 29335B可以很明显的发现，每隔1秒就会有一个绕过防护的404请求返回，持续消耗服务器资源。现在只用了Nginx自带的限流功能，能做到的也就这么多了。虽然对于单个恶意IP，成功请求一次404消耗的资源极少，服务器压根就没动静；但是，如果是成百上千个恶意IP进行高强度请求，累计负载将变得相当可观！于是，我想要自己写防御规则，去更加智能的进行限制和防御。进阶：使用自定义规则进行防护这部分我们需要安装Nginx的Lua拓展，用Lua来实现防御的逻辑。apk add nginx-mod-http-lua说点题外话，在尝试进阶方案的时候，我终于深深感受到了国内外大语言模型的技术能力差距。我请教了通义千问，换了几种表述，并且把现有网站配置文件，已经安装的相关软件包都告诉他了，但是它生成的解决方案代码全部报错。试了五次没有一次成功。后面直接找ChatGPT，一次就秒了！看来国内大语言模型还是有待提高啊。实现思路AI只能为你写代码，但是具体的程序逻辑这个最好还是要根据自己博客运营的现实情况自己想防护策略，确保精准打击恶意访问，但是不影响正常访客。我设想的封禁策略是这样的：对于动态响应的请求，这部分一般是博客的页面返回的html，设置限流为单个IP每秒一个请求，对于个人博客来说这已经足以满足正常的访客需求了。并且允许进行两个突发请求，也就是在第一次的一秒内，允许三个请求，来应对第一次页面加载的情况。对于静态文件请求，这部分可以宽松一点，毕竟我实际上看完整加载一次网站静态资源数量会有大约50个，并且静态资源对服务器性能的消耗极少，主要是带宽的消耗。而对于带宽的话在之前的博客已经通过防火墙限制了单个IP能够发起的并发连接数，再加上文件小，基本上不太拉得起来带宽。所以考虑是适当放宽，额外允许50个突发请求，也就是第一秒内允许100个突发请求，足以满足一般个人博客对静态资源的需求。对于随机图片的API，因为后端PHP逻辑简单，代码量少，即使被较高并发轮询也不会消耗太多资源。且一般情况下访问主页，最极端的情况下，也就是文章封面全部是随机封面，且访客不停地往下拉，每秒10个查询请求已经足够，再加上突发再给20个请求，足以应付首页往下拉可以看到20个封面的情况了。剩下的页面需要点加载更多，这就已经需要数秒的时间，此时策略的令牌桶已经补充了10个令牌，足以满足下一步浏览产生的请求。特别的，为了防止前面提到的即使限制了请求速率，持续不断的恶意请求仍然会每秒产生一个404页面消耗资源，我还做出了进一步的限制策略：从访问开始，就会以10秒为单位作为时间窗口观察请求的数量和结果。如果IP请求在这10秒内请求的结果中429错误占到5次及以上，则进行为期10秒的临时封禁，期间任何请求均返回429错误。在封禁的10秒内，如果请求数量仍达到3次及以上，则延长10秒封禁时间，并在新的封禁时间内继续计数，如果请求数量仍达到3次及以上，继续延长封禁时间，以此类推。当最新的10秒观察窗口期内，请求数量降低到2个及以下，则解除封禁，可以继续正常访问。这样的策略，给了正常访客一定的折腾空间，只要封禁前访问请求次数不超限制，基本上就是正常浏览。但是对于恶意请求，一旦触发了封禁机制，就会被一直封禁。因为解除封禁的规则比判定封禁更严格。实现判定封禁的配置文件基于这个逻辑，我向ChatGPT详细描述了我的需求和实现思路，并且附上了现有的Nginx配置文件，以及告诉他目前已经安装的相关软件包和正在使用的发行版版本，询问是否需要补充安装软件包。这样详细的提示词给到ChatGPT，它直接轻松秒杀：修改后的nginx.conf：# /etc/nginx/nginx.conf user nginx; # Set number of worker processes automatically based on number of CPU cores. worker_processes auto; # Enables the use of JIT for regular expressions to speed-up their processing. pcre_jit on; # Configures default error logger. error_log /var/log/nginx/error.log warn; # Includes files with directives to load dynamic modules. include /etc/nginx/modules/*.conf; # Include files with config snippets into the root context. include /etc/nginx/conf.d/*.conf; events { # The maximum number of simultaneous connections that can be opened by # a worker process. worker_connections 1024; } http { lua_shared_dict abuse 10m; lua_shared_dict tb 10m; # 静态资源限流配置（宽松） limit_req_zone $binary_remote_addr zone=static_limit:10m rate=50r/s; # 定义 FastCGI 缓存路径 fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=my_fastcgi_cache:16m inactive=60m; # 定义缓存键，通常包含请求方法、主机名和 URI fastcgi_cache_key "$scheme$request_method$host$request_uri"; # Includes mapping of file name extensions to MIME types of responses # and defines the default type. include /etc/nginx/mime.types; default_type application/octet-stream; # Name servers used to resolve names of upstream servers into addresses. # It's also needed when using tcpsocket and udpsocket in Lua modules. #resolver 1.1.1.1 1.0.0.1 [2606:4700:4700::1111] [2606:4700:4700::1001]; # Don't tell nginx version to the clients. Default is 'on'. server_tokens off; # Specifies the maximum accepted body size of a client request, as # indicated by the request header Content-Length. If the stated content # length is greater than this size, then the client receives the HTTP # error code 413. Set to 0 to disable. Default is '1m'. client_max_body_size 1m; # Sendfile copies data between one FD and other from within the kernel, # which is more efficient than read() + write(). Default is off. sendfile on; # Causes nginx to attempt to send its HTTP response head in one packet, # instead of using partial frames. Default is 'off'. tcp_nopush on; # Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2. # TIP: If you're not obligated to support ancient clients, remove TLSv1.1. ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; # Path of the file with Diffie-Hellman parameters for EDH ciphers. # TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048` #ssl_dhparam /etc/ssl/nginx/dh2048.pem; # Specifies that our cipher suits should be preferred over client ciphers. # Default is 'off'. ssl_prefer_server_ciphers on; # Enables a shared SSL cache with size that can hold around 8000 sessions. # Default is 'none'. ssl_session_cache shared:SSL:2m; # Specifies a time during which a client may reuse the session parameters. # Default is '5m'. ssl_session_timeout 1h; # Disable TLS session tickets (they are insecure). Default is 'on'. ssl_session_tickets off; # Enable gzipping of responses. #gzip on; # Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'. gzip_vary on; # Helper variable for proxying websockets. map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Specifies the main log format. log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; # Sets the path, format, and configuration for a buffered log write. access_log /var/log/nginx/access.log main; # Includes virtual hosts configs. include /etc/nginx/http.d/*.conf; }这里新增了两个共享内存块abuse和tb用于存储相关的请求信息。修改后的nekopara.conf:server { listen 443 ssl; server_name nekopara.uk; ssl_certificate /data/certs/nekopara_uk.pem; ssl_certificate_key /data/certs/nekopara_uk.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'; location / { return 302 https://www.nekopara.uk$request_uri; } } # Typecho HTTPS服务器块 server { listen 443 ssl; #proxy_protocol; server_name www.nekopara.uk; set_real_ip_from 192.168.1.255; real_ip_header X-Forwarded-For; # 设置最大上传文件大小为 32M client_max_body_size 32M; # SSL证书和密钥路径 ssl_certificate /data/certs/nekopara_uk.pem; ssl_certificate_key /data/certs/nekopara_uk.key; # SSL设置（可选但推荐用于安全） ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # 网站根目录 root /data/typecho; index index.html index.php; # 1. 静态资源限流（宽松，50r/s） location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|eot|svg|mp4|webp|zip|rar|7z|psb)$ { limit_req zone=static_limit burst=50 nodelay; try_files $uri $uri/ =404; add_header Cache-Control "public"; } # PHP处理，并启用FastCGI缓存 location ~ \.php$ { fastcgi_ignore_headers Cache-Control Expires Set-Cookie; try_files $uri =404; fastcgi_pass unix:/var/run/php-fpm82/php8.2-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; # 启用 FastCGI 缓存 fastcgi_cache my_fastcgi_cache; fastcgi_cache_valid 200 301 302 404 10s; # 设置缓存的有效时间 fastcgi_cache_methods GET HEAD; # 只缓存 GET 和 HEAD 请求 # 不缓存 POST 请求和其他非幂等性请求 set $skip_cache 0; if ($request_method = POST) { set $skip_cache 1; } if ($query_string != "") { set $skip_cache 1; } # 如果存在特定会话 cookie，则不缓存 if ($http_cookie ~* "PHPSESSID|__typecho_uid|__typecho_authCode") { set $skip_cache 1; } fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; # 添加头部信息显示缓存状态 add_header X-FastCGI-Cache $upstream_cache_status; # 动态页面封禁逻辑规则 access_by_lua_block { local abuse = ngx.shared.abuse -- 计数/封禁存储 local tb = ngx.shared.tb -- token-bucket 存储 local ip = ngx.var.remote_addr or "unknown" local uri = ngx.var.uri or "/" -- 参数（可按需调整） local RATE = 1 -- 平均速率：1 r/s local BURST = 2 -- burst 值（与 nginx 的 burst 对应） local CAPACITY = BURST + 1 -- token 桶容量 (burst + 1) local TRIGGER_WINDOW = 10 -- 触发封禁的观察窗口（秒） local TRIGGER_COUNT = 5 -- 在观察窗口内达到此次数则封禁 local BAN_TTL = 10 -- 初始封禁时长（秒） local BAN_EXT_HIT_WINDOW = 10 -- 封禁期间统计窗口（秒） local BAN_EXT_HIT_COUNT = 3 -- 封禁期间若在最近 BAN_EXT_HIT_WINDOW 秒内请求次数 >= 3 则延长 -- 宽松策略（仅用于特定豁免路径） local PIC_RATE = 10 -- 更高速率，按需调整 local PIC_BURST = 20 -- 更大突发，按需调整 local PIC_CAPACITY = PIC_BURST + 1 -- 哪些 URI 被豁免为“宽松模式”（只对精确入口豁免） local function is_pic_exempt(u) if not u then return false end if u == "/pic_api" or u == "/pic_api/" or u == "/pic_api/index.php" then return true end return false end local pic_exempt = is_pic_exempt(uri) -- helper keys local ban_key = "ban:" .. ip local lim_key = "lim:" .. ip -- 5s 触发计数 key local banhit_key = "banhit:" .. ip -- 封禁期内计数 key -- 如果不是 pic_exempt，先检查封禁（pic_exempt 跳过 ban 检查） if not pic_exempt then local is_banned = abuse:get(ban_key) if is_banned then -- 记录封禁期内的请求计数以便延长封禁 local ok, err = abuse:incr(banhit_key, 1, 0, BAN_EXT_HIT_WINDOW) if not ok then abuse:set(banhit_key, 1, BAN_EXT_HIT_WINDOW) ok = 1 end if ok >= BAN_EXT_HIT_COUNT then abuse:set(ban_key, 1, BAN_TTL) abuse:set(banhit_key, 0, BAN_EXT_HIT_WINDOW) end return ngx.exit(503) end end -- token-bucket 通用实现（根据是否豁免使用不同参数） local now = ngx.now() local tokens_key = "tokens:" .. ip local last_key = "last:" .. ip local tokens = tb:get(tokens_key) local last = tb:get(last_key) if tokens == nil then if pic_exempt then tokens = PIC_CAPACITY else tokens = CAPACITY end end if last == nil then last = now end local delta = now - tonumber(last) if delta < 0 then delta = 0 end local rate = (pic_exempt and PIC_RATE) or RATE local capacity = (pic_exempt and PIC_CAPACITY) or CAPACITY local new_tokens = tonumber(tokens) + delta * rate if new_tokens > capacity then new_tokens = capacity end if new_tokens >= 1 then -- 允许请求，消耗 1 token new_tokens = new_tokens - 1 tb:set(tokens_key, new_tokens, 60) tb:set(last_key, now, 60) return else -- 被限流：如果是 pic_exempt，则我们不走封禁逻辑，只返回 503（表现为 429 页面） if pic_exempt then -- 可选：可以不计入 abuse 触发计数，避免误封 return ngx.exit(503) end -- 严格路径：记录触发计数并在达到阈值时封禁 local cnt, err = abuse:incr(lim_key, 1, 0, TRIGGER_WINDOW) if not cnt then abuse:set(lim_key, 1, TRIGGER_WINDOW) cnt = 1 end if cnt >= TRIGGER_COUNT then abuse:set(ban_key, 1, BAN_TTL) abuse:delete(lim_key) end return ngx.exit(503) end } } # 3. 默认路由（用于Typecho伪静态） location / { try_files $uri $uri/ /index.php?$args; autoindex off; } #自定义503为429 Too Many Requests error_page 503 =429 /429.html; location = /429.html { internal; root /etc/nginx/error_pages; #添加响应头告诉爬虫重试时间间隔 add_header Retry-After 10 always; } # Deny access to hidden files location ~ /\.ht { deny all; } }主要是新增了access_by_lua_block这个Lua脚本块来实现封禁逻辑，注释已经很清楚了，需要的进行按需修改即可。（虽然我不懂Lua，问LLM就完了）测试效果同样的，我们来尝试使用之前的测试脚本进行测试，查看返回的结果：[vanilla@NekoWorks ~]$ python3 random_request_test.py www.nekopara.uk --interval 50 --count 500 开始随机URL请求测试 (总请求: 500, 间隔: 50ms, 超时: 5s) 目标域名: https://www.nekopara.uk 输出文件: random_request_test.csv [1/500] https://www.nekopara.uk/tsq5L3DXmTP3pgt | 状态: 404 | 响应时间: 0.430s | 大小: 29758B [2/500] https://www.nekopara.uk/0imQYTBUKXMaWEe | 状态: 404 | 响应时间: 0.431s | 大小: 29758B [3/500] https://www.nekopara.uk/mb2uMeZihKa6Qx3 | 状态: 404 | 响应时间: 0.369s | 大小: 29758B [4/500] https://www.nekopara.uk/ik6TaHfby3dVnO6 | 状态: 404 | 响应时间: 0.446s | 大小: 29758B [5/500] https://www.nekopara.uk/kOaNyipoAoJp7oz | 状态: 429 | 响应时间: 0.222s | 大小: 266B [6/500] https://www.nekopara.uk/SQvv9uNbBOukoCM | 状态: 404 | 响应时间: 0.302s | 大小: 29758B [7/500] https://www.nekopara.uk/3N0aRXMhpnbn1gP | 状态: 429 | 响应时间: 0.310s | 大小: 266B [8/500] https://www.nekopara.uk/d0G1xWPmmVNqbHw | 状态: 429 | 响应时间: 0.224s | 大小: 266B [9/500] https://www.nekopara.uk/o2IpKDOdVMCD9r2 | 状态: 404 | 响应时间: 0.435s | 大小: 29758B [10/500] https://www.nekopara.uk/EdpSSsWhxNtcQ0E | 状态: 429 | 响应时间: 0.291s | 大小: 266B [11/500] https://www.nekopara.uk/V1GaQNNrt9dz4YP | 状态: 429 | 响应时间: 0.311s | 大小: 266B [12/500] https://www.nekopara.uk/qQiIxZrIBEAtbeB | 状态: 429 | 响应时间: 0.219s | 大小: 266B [13/500] https://www.nekopara.uk/c4jCFSFD5bgFwrw | 状态: 429 | 响应时间: 0.241s | 大小: 266B [14/500] https://www.nekopara.uk/ukHBaBKx9Dn1uIY | 状态: 429 | 响应时间: 0.315s | 大小: 266B [15/500] https://www.nekopara.uk/KcvHSqSHoamkTb9 | 状态: 429 | 响应时间: 0.277s | 大小: 266B [16/500] https://www.nekopara.uk/VQPfGcKjPSSx1mn | 状态: 429 | 响应时间: 0.279s | 大小: 266B [17/500] https://www.nekopara.uk/WCXISBiDEuuEze5 | 状态: 429 | 响应时间: 0.321s | 大小: 266B [18/500] https://www.nekopara.uk/LxsC9KgFCGojRVQ | 状态: 429 | 响应时间: 0.228s | 大小: 266B [19/500] https://www.nekopara.uk/aXwLD0nIDKrEw6z | 状态: 429 | 响应时间: 0.220s | 大小: 266B [20/500] https://www.nekopara.uk/mn5SgwFI1fB3pPL | 状态: 429 | 响应时间: 0.217s | 大小: 266B [21/500] https://www.nekopara.uk/cDtZOUEUDBeIam6 | 状态: 429 | 响应时间: 0.218s | 大小: 266B [22/500] https://www.nekopara.uk/xdikUSlbaqhkQBV | 状态: 429 | 响应时间: 0.227s | 大小: 266B [23/500] https://www.nekopara.uk/fYmwQCdwuUEvb6Q | 状态: 429 | 响应时间: 0.216s | 大小: 266B [24/500] https://www.nekopara.uk/bI0npEBugVcHenG | 状态: 429 | 响应时间: 0.217s | 大小: 266B [25/500] https://www.nekopara.uk/ecUcKf1We2GjNZE | 状态: 429 | 响应时间: 0.218s | 大小: 266B可以看到，一旦触发封禁，后面快速请求的结果都是429，非常的成功！总结与反思经过一番测试和调试，我可以很自信地说：我的博客已经能够有效抵御常见的中小规模 CC 攻击。对于一个纯粹的个人博客而言，这样的防护能力，我认为已经相当不错了。但你要问——这套方案能不能完全无视所有 CC 攻击？ 我必须很遗憾且负责任地告诉你：不行！目前的防御策略，对单 IP 高频、多 IP 高频这类“显眼包”式攻击有不错的拦截效果。但如果是海量 IP 发起的低频、慢速、行为模拟正常的 CC 攻击，那我的服务器大概率会当场暴毙。 毕竟，这类请求和真实用户的访问行为太像了，总不能为了防攻击，把正常访客也一并拒之门外吧 😅所以，如果你真的想用 CC 打垮我的网站，那我也不妨“贴心”地给你“指明方向”，顺便帮你算笔账：🎯 攻击指南（兼成本核算）IP 资源：你需要一个至少 1000+ 独立 IP 的僵尸网络。市面上靠谱的 DDoS 僵尸网络租赁服务，日租成本约 50–100 美元。攻击频率：每个 IP 每秒发起一次请求，或更低频但持续不断，还得随机访问不同 URL，避免触发我的频率封禁规则——别让我在日志里看出破绽。持续时间：想让我网站瘫痪一整天？那你得连续攻击 24 小时不停歇。这意味着：僵尸网络租赁：$100技术维护与调度（别让 IP 被批量封，算是DLC，可选项）：$200+总体成本保守估计：$100–500 美元/天而我这边呢？ 服务器是白嫖的华为云资源，带宽和计算资源基本零成本。你砸几百美元攻击我一天，我关掉网站喝杯咖啡，等你钱烧完，我秒恢复上线。🤔 所以，请问：你真的愿意花大几百美元，只为让一个不盈利、没广告、日均访问量不到百人的个人博客下线几天？这笔钱，够买几十杯星巴克，够报一门编程课，够买台树莓派折腾半年。 相比之下，把它砸在一个“打不赢也赚不到”的小博客上，真的值得吗？因此，对于那些脚本小子、网络喷子、或者闲着无聊想搞点事情的小学生来说——这套防护机制已经足够让你“出师未捷身先死”。而如果你真愿意花大价钱，动用专业资源对我持续攻击…… 那我只能说：承蒙厚爱，荣幸之至 😊