在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%的算力，还是可以的。
用 Python 破解网易云 iframe 反爬，全自动构建本地音乐库
Python实现网易云音乐自动抓取整理事情的缘起很简单，我的jellyfin音乐库听腻的差不多了，想抓点新歌。虽然说之前已经抓取过1000多首歌曲，但是很多是我因为强迫症，为了媒体库的完整性，把整个专辑都抓下来了，实际上常听的就是两三百首。正因如此，我就想给我的媒体库补充点库存，于是开始折腾，有了这篇文章。媒体库的具体搭建教程可以去看这篇文章：自建Jellyfin私人媒体服务器，告别屌毛网易云！实现思路上次因为没能自动化搞下载，且因为心急，所以几乎是半自动半手动收集的，费时费力，花费了差不多一个星期。而这次，我决定使用Python自动化完成，下面是工作流实现思路：当遇到喜欢的音乐后，我就保存其网易云的URL到文本文件，例如https://music.163.com/#/song?id=1499823984你可以点开看看是什么曲子（，每个曲子放一行，随听随收集。读取这个歌曲列表文件，逐行解析，获取专辑的ID，例如https://music.163.com/#/album?id=99010037，取出99010037，以及歌手KOTOKO。然后去解析专辑的页面，获取其中所有歌曲的song?id，例如1499823984，1499826814，1499823975和1499826811。调用第三方API进行歌曲解析，获取音频文件，歌词和封面。例如这次我用的就是https://jx.chksz.top/按照jellyfin要求的格式保存歌曲文件，也就是歌手/专辑/01.歌曲.flac，如：[chocola@Neko-Server KOTOKO]$ tree . └── ネコぱら vol.4 OP ⁄ ED ├── 01.SWEET×SWEET.flac ├── 01.SWEET×SWEET.lrc ├── 02.NEGAIGOTO.flac ├── 02.NEGAIGOTO.lrc ├── 03.SWEET×SWEET (Instrumental).flac ├── 03.SWEET×SWEET (Instrumental).lrc ├── 04.NEGAIGOTO (Instrumental).flac ├── 04.NEGAIGOTO (Instrumental).lrc └── cover.jpg最后还要打上艺术家，专辑，歌曲名的标签既然确定了思路和方法，接下来开始逐个攻破吧！观察网页布局打开音乐网页，开启F12大法，检查元素，迅速就定位到了目标：其实，后面发现在页面的meta标签其实也有信息：对于专辑部分，抓取列表就好了：看起来并不难，使用Python的requests模块获取html，然后解析内容就好了。尝试实现爬虫逻辑和LLM友好交流后，得出了每一部分的代码逻辑函数，例如解析歌曲页面信息的：def extract_music_info(url): # 模拟浏览器请求头 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } try: # 获取HTML内容 response = requests.get(url, headers=headers) response.raise_for_status() html = response.text print(html) # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"提取失败: {str(e)}") return None但是好巧不巧，我写完代码的第二天，正准备要爬取，却返回了空值。我就纳闷了，我昨天还能运行的代码，怎么今天就出问题了？然后重新检查了页面，才发现第二天后网易云把页面嵌入了iframe标签，让我的代码全部报废（还真是赶到点子上了呢，由于这部分加载实现需要模拟出来浏览器逻辑，所以我决定还是上selenium吧，尽管我并不喜欢这种臃肿低效的爬虫方式，但是现在看来如果想快速完成任务只能这样了。iframe标签反爬这招太狠了！selenium 搭配 firefox 大战 iframe配置和熟悉selenium首先，安装selenium:pip install selenium然后要去下载selenium的firefox驱动，前往github.com/mozilla/geckodriver/releases下载对应的版本，这里我下载的是geckodriver-v0.36.0-linux64.tar.gz，下载好后放在项目文件夹下。要用selenium首先要熟悉基本的用法，以下是简单示例：from selenium import webdriver from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.options import Options # 新增导入 # 创建 Service 对象，指定 geckodriver 的路径 service = Service(executable_path='./geckodriver') # 创建 Options 对象，指定 Firefox 的二进制路径 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动，同时传入 service 和 options driver = webdriver.Firefox(service=service, options=options) driver.get('https://www.nekopara.uk/') print(driver.page_source) driver.quit()大概就是获取了页面渲染后的html代码然后返回。进行爬取下面是爬取的实现函数，由于如果不关闭浏览器的话，获取的iframe标签还是第一次打开页面的内容，没办法复用，只能每次爬取完成后关闭浏览器然后重新打开。def extract_music_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML html = driver.page_source # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def get_album_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML iframe_html = driver.page_source soup = BeautifulSoup(iframe_html, 'html.parser') song_dict = {} # 遍历所有歌曲行（tr元素） for tr in soup.find_all('tr'): # 提取页面序号（在中） num_span = tr.find('span', class_='num') if not num_span: continue try: page_num = int(num_span.get_text(strip=True)) except ValueError: continue # 提取歌曲ID（在中） a_tag = tr.find('a', href=lambda href: href and href.startswith('/song?id=')) if not a_tag: continue href = a_tag['href'] song_id = href.split('=')[1] song_dict[page_num] = song_id return song_dict except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit()通过driver.switch_to.frame(iframe)才能获取到iframe内容，不然就和直接用python的requests模块的请求结果是一样的。这样我们就能获取到了艺术家名称，专辑名称，歌曲ID，可以进行下一步工作了。确实感觉自动化浏览器访问这个是爬虫的终极手段了，几乎什么都可以爬取了，只不过效率确实不如直接发送请求。从第三方解析服务API获取信息这里我用了https://jx.chksz.top/这个网站的解析服务，是在搜索引擎里面随便找的。简单看了一下，好像是一个初中生的，真是年少有为呢！一番F12后，抓取到了API的地址：https://api.kxzjoker.cn/api/163_music接下来就是构造请求并下载的函数了：def download_song_and_metadata(save_path:str,song_url: str,number_info:str, level: str = "jymaster") -> bool: # === 第一步：请求你的解析 API === api_endpoint = "https://api.kxzjoker.cn/api/163_music" form_data = { "url": song_url, "level": level, "type": "json" } try: print("正在请求歌曲信息...") resp = requests.post(api_endpoint, data=form_data, timeout=15) resp.raise_for_status() data = resp.json() if data.get("status") != 200: print(f"API 返回错误状态: {data.get('status')}") return False # 提取字段 title = sanitize_filename(data["name"]) artist = data["ar_name"] album = sanitize_filename(data["al_name"]) audio_url = data["url"] cover_url = data["pic"] lyric = data.get("lyric", "") tlyric = data.get("tlyric", "") # === 第二步：创建专辑目录 === album_dir = os.path.join(save_path, album) os.makedirs(album_dir, exist_ok=True) # === 第三步：下载音频文件 === audio_ext = ".flac" if ".flac" in audio_url else ".mp3" audio_path = os.path.join(album_dir, f"{number_info}.{title}{audio_ext}") if not os.path.exists(audio_path): print(f"正在下载音频: {title}{audio_ext}") audio_resp = requests.get(audio_url, stream=True, timeout=30) audio_resp.raise_for_status() with open(audio_path, "wb") as f: for chunk in audio_resp.iter_content(chunk_size=8192): f.write(chunk) else: print(f"音频已存在，跳过: {audio_path}") # === 第四步：合并歌词并保存 .lrc === lrc_path = os.path.join(album_dir, f"{number_info}.{title}.lrc") if tlyric != None: combined_lyric = merge_lyrics(lyric, tlyric) else: combined_lyric = lyric with open(lrc_path, "w", encoding="utf-8") as f: f.write(combined_lyric) print(f"歌词已保存: {lrc_path}") # === 第五步：下载封面为 folder.jpg === cover_path = os.path.join(album_dir, "folder.jpg") if not os.path.exists(cover_path): print("正在下载专辑封面...") cover_resp = requests.get(cover_url, timeout=15) cover_resp.raise_for_status() with open(cover_path, "wb") as f: f.write(cover_resp.content) print(f"封面已保存: {cover_path}") else: print("封面已存在，跳过") print(f"\n✅ 下载完成！路径: {album_dir}") return True except Exception as e: print(f"❌ 下载失败: {e}") return False def merge_lyrics(original: str, translation: str) -> str: """ 合并原文和翻译歌词，生成双语 LRC。 假设时间戳一致，将翻译放在下一行。 """ orig_lines = original.strip().splitlines() trans_dict = {} # 解析翻译歌词的时间 -> 文本 for line in translation.strip().splitlines(): if "]:" in line or line.startswith("[by:"): continue parts = line.split("]", 1) if len(parts) == 2: time_tag, text = parts[0] + "]", parts[1].strip() if text: trans_dict[time_tag] = text # 合并 merged = [] for line in orig_lines: merged.append(line) if line.startswith("[") and "]" in line: time_tag = line.split("]", 1)[0] + "]" if time_tag in trans_dict: merged.append(time_tag + trans_dict[time_tag]) return "\n".join(merged)最终缝合得到了各个部分的组件，然后就是缝合工作了，只需要传入一个分行的歌曲的URL文本文件，程序就可以全自动下载全部的相关文件，并且根据存放规范保存。下面是最终缝合代码：import requests import re from urllib.parse import urlparse from selenium import webdriver from selenium.webdriver.firefox.service import Service from selenium.webdriver.firefox.options import Options # 新增导入 from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import os from bs4 import BeautifulSoup def get_album_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML iframe_html = driver.page_source soup = BeautifulSoup(iframe_html, 'html.parser') song_dict = {} # 遍历所有歌曲行（tr元素） for tr in soup.find_all('tr'): # 提取页面序号（在中） num_span = tr.find('span', class_='num') if not num_span: continue try: page_num = int(num_span.get_text(strip=True)) except ValueError: continue # 提取歌曲ID（在中） a_tag = tr.find('a', href=lambda href: href and href.startswith('/song?id=')) if not a_tag: continue href = a_tag['href'] song_id = href.split('=')[1] song_dict[page_num] = song_id return song_dict except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def sanitize_filename(filename: str) -> str: """移除文件名中的非法字符""" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename).strip() def download_song_and_metadata(save_path:str,song_url: str,number_info:str, level: str = "jymaster") -> bool: # === 第一步：请求你的解析 API === api_endpoint = "https://api.kxzjoker.cn/api/163_music" form_data = { "url": song_url, "level": level, "type": "json" } try: print("正在请求歌曲信息...") resp = requests.post(api_endpoint, data=form_data, timeout=15) resp.raise_for_status() data = resp.json() if data.get("status") != 200: print(f"API 返回错误状态: {data.get('status')}") return False # 提取字段 title = sanitize_filename(data["name"]) artist = data["ar_name"] album = sanitize_filename(data["al_name"]) audio_url = data["url"] cover_url = data["pic"] lyric = data.get("lyric", "") tlyric = data.get("tlyric", "") # === 第二步：创建专辑目录 === album_dir = os.path.join(save_path, album) os.makedirs(album_dir, exist_ok=True) # === 第三步：下载音频文件 === audio_ext = ".flac" if ".flac" in audio_url else ".mp3" audio_path = os.path.join(album_dir, f"{number_info}.{title}{audio_ext}") if not os.path.exists(audio_path): print(f"正在下载音频: {title}{audio_ext}") audio_resp = requests.get(audio_url, stream=True, timeout=30) audio_resp.raise_for_status() with open(audio_path, "wb") as f: for chunk in audio_resp.iter_content(chunk_size=8192): f.write(chunk) else: print(f"音频已存在，跳过: {audio_path}") # === 第四步：合并歌词并保存 .lrc === lrc_path = os.path.join(album_dir, f"{number_info}.{title}.lrc") if tlyric != None: combined_lyric = merge_lyrics(lyric, tlyric) else: combined_lyric = lyric with open(lrc_path, "w", encoding="utf-8") as f: f.write(combined_lyric) print(f"歌词已保存: {lrc_path}") # === 第五步：下载封面为 folder.jpg === cover_path = os.path.join(album_dir, "folder.jpg") if not os.path.exists(cover_path): print("正在下载专辑封面...") cover_resp = requests.get(cover_url, timeout=15) cover_resp.raise_for_status() with open(cover_path, "wb") as f: f.write(cover_resp.content) print(f"封面已保存: {cover_path}") else: print("封面已存在，跳过") print(f"\n✅ 下载完成！路径: {album_dir}") return True except Exception as e: print(f"❌ 下载失败: {e}") return False def merge_lyrics(original: str, translation: str) -> str: """ 合并原文和翻译歌词，生成双语 LRC。 假设时间戳一致，将翻译放在下一行。 """ orig_lines = original.strip().splitlines() trans_dict = {} # 解析翻译歌词的时间 -> 文本 for line in translation.strip().splitlines(): if "]:" in line or line.startswith("[by:"): continue parts = line.split("]", 1) if len(parts) == 2: time_tag, text = parts[0] + "]", parts[1].strip() if text: trans_dict[time_tag] = text # 合并 merged = [] for line in orig_lines: merged.append(line) if line.startswith("[") and "]" in line: time_tag = line.split("]", 1)[0] + "]" if time_tag in trans_dict: merged.append(time_tag + trans_dict[time_tag]) return "\n".join(merged) def extract_music_info(url): # 创建 Service 对象 service = Service(executable_path='./geckodriver') # 创建 Options 对象 options = Options() options.binary_location = "/opt/firefox-esr/firefox" # 替换为你的实际路径 # 初始化 Firefox 驱动 driver = webdriver.Firefox(service=service, options=options) try: # 访问目标页面 driver.get(url) # 等待iframe加载完成 iframe = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.ID, "g_iframe")) ) # 切换到iframe driver.switch_to.frame(iframe) # 等待iframe内部内容加载（等待10秒） WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) # 获取iframe中的完整HTML html = driver.page_source # 提取艺术家（og:music:artist） artist_match = re.search(r'<meta property="og:music:artist" content="([^"]+)"', html) artist = artist_match.group(1) if artist_match else None # 提取专辑名称（og:music:album） album_name_match = re.search(r'<meta property="og:music:album" content="([^"]+)"', html) album_name = album_name_match.group(1) if album_name_match else None # 提取专辑ID（从music:album的URL中解析） album_url_match = re.search(r'<meta property="music:album" content="([^"]+)"', html) album_id = None if album_url_match: url_content = album_url_match.group(1) id_match = re.search(r'id=(\d+)', url_content) album_id = id_match.group(1) if id_match else None # 检查是否所有字段都提取成功 if artist and album_name and album_id: return (artist, album_name, album_id) else: return None except Exception as e: print(f"❌ 处理失败: {str(e)}") finally: driver.quit() def download_album_from_song(url): info_result = extract_music_info(url) if info_result: print(f"艺术家: {info_result[0]}") print(f"专辑名称: {info_result[1]}") print(f"专辑ID: {info_result[2]}") else: print("提取失败，检查URL或页面结构") download_path = str(os.getcwd()+"/"+info_result[0]) songid_s = get_album_info("https://music.163.com/#/album?id="+info_result[2]) for number_info,songid_in in songid_s.items(): to_download_url = "https://music.163.com/song?id="+songid_in download_song_and_metadata(download_path,to_download_url,str(number_info), level="jymaster") def extract_songlist(filename: str): """ 主函数：读取指定文件并逐行处理。 :param filename: 要读取的 txt 文件名（位于当前目录） """ # 获取当前工作目录 current_dir = os.getcwd() file_path = os.path.join(current_dir, filename) # 检查文件是否存在 if not os.path.isfile(file_path): print(f"错误：文件 '{filename}' 不存在于当前目录 ({current_dir})") return try: with open(file_path, 'r', encoding='utf-8') as file: for line_number, line in enumerate(file, start=1): # 可选：跳过空行 if line.strip() == '': continue try: download_album_from_song(line) except Exception as e: print(f"处理第 {line_number} 行时出错: {e}") except Exception as e: print(f"读取文件时发生错误: {e}") # 使用示例 if __name__ == "__main__": txt_filename = "collect_list.txt" extract_songlist(txt_filename)装好需要的Python模块后，只需要在当前目录保存好collect_list.txt，放置浏览器驱动geckodriver，确保路径都正确，就可以Python启动了。过一会就会在当前目录获得想要的专辑文件了。整理音乐文件标签由于下载的flac缺少元数据，所以需要根据路径和文件名进行补全。这时候我也意识到我之前爬取的那一批媒体库文件并没有打好标签，导致jellyfin的展示效果还是有一定混乱。下面这个Python程序将会修复这个问题#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 将文件名中的曲号与曲名写入音频文件元数据（支持 FLAC/MP3/M4A） 用法示例： python3 write_title_and_track.py /home/chocola/data/files/Music --dry-run python3 write_title_and_track.py /home/chocola/data/files/Music --force """ import os, sys, re, argparse from pathlib import Path # try import mutagen, auto-install if missing (user mode) try: from mutagen import File from mutagen.flac import FLAC from mutagen.id3 import ID3, TIT2, TRCK, ID3NoHeaderError from mutagen.mp4 import MP4, MP4Tags except Exception: print("mutagen 未检测到，尝试安装（用户模式 pip3 install --user mutagen）...") import subprocess subprocess.check_call([sys.executable, "-m", "pip", "install", "--user", "mutagen"]) from mutagen import File from mutagen.flac import FLAC from mutagen.id3 import ID3, TIT2, TRCK, ID3NoHeaderError from mutagen.mp4 import MP4, MP4Tags AUDIO_EXTS = ('.flac', '.mp3', '.m4a', '.mp4', '.aac', '.ogg', '.opus') # will skip unknown handlers # regex to extract track number and title # matches: "01. Title", "1 - Title", "01 Title", "01_ Title", "01.Title", leading zeros allowed _leading_track_re = re.compile(r'^\s*(\d{1,3})\s*[\.\-_\s:]+\s*(.+)$') # fallback: "01Title" (no separator) _leading_track_nosep_re = re.compile(r'^\s*(\d{1,3})(.+)$') # normalize fullwidth parentheses and brackets to ascii def normalize_parens(s: str) -> str: return (s .replace('（', '(').replace('）', ')') .replace('［', '[').replace('］', ']') .replace('【', '[').replace('】', ']') ) def parse_filename_to_track_title(fn: str): name = fn # remove extension already handled by caller usually name = name.strip() name = normalize_parens(name) # try main regex m = _leading_track_re.match(name) if m: tr = m.group(1).lstrip('0') or '0' title = m.group(2).strip() return tr, title m2 = _leading_track_nosep_re.match(name) if m2: tr = m2.group(1).lstrip('0') or '0' title = m2.group(2).strip() # but if title is short (like only 1 char) and looks numeric, ignore return tr, title # No leading track detected: return (None, full name) return None, name def write_flac(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): audio = FLAC(path) tags = audio.tags or {} changed = False # ensure artist/album set if available if artist and (not tags.get('artist') or tags.get('artist') != [artist]): if not dry_run: audio['artist'] = [artist] changed = True if album and (not tags.get('album') or tags.get('album') != [album]): if not dry_run: audio['album'] = [album] changed = True # title cur_title = tags.get('title', None) if (force or not cur_title) and title: if not dry_run: audio['title'] = [title] changed = True # tracknumber cur_track = tags.get('tracknumber', None) if tracknum and (force or not cur_track): if not dry_run: audio['tracknumber'] = [str(tracknum)] changed = True if changed and not dry_run: audio.save() return changed def write_mp3(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): try: audio = ID3(path) except ID3NoHeaderError: audio = ID3() changed = False # artist / album frames: TPE1 / TALB if artist: cur = audio.getall('TPE1') cur_text = [f.text for f in cur] if cur else [] if force or not cur_text: if not dry_run: audio.delall('TPE1') audio.add(TIT2(encoding=3, text=[])) if False else None # noop to avoid pyflakes audio.add(ID3().get('TPE1')) if False else None # we'll set via frames below # set title cur_title = audio.getall('TIT2') if (force or not cur_title) and title: if not dry_run: audio.delall('TIT2') audio.add(TIT2(encoding=3, text=title)) changed = True # set tracknumber TRCK cur_tr = audio.getall('TRCK') if tracknum and (force or not cur_tr): if not dry_run: audio.delall('TRCK') audio.add(TRCK(encoding=3, text=str(tracknum))) changed = True # artist/album set via text frames TPE1/TALB if artist: cur_a = audio.getall('TPE1') if (force or not cur_a): if not dry_run: audio.delall('TPE1') audio.add(ID3().get('TPE1')) if False else None from mutagen.id3 import TPE1, TALB audio.add(TPE1(encoding=3, text=artist)) changed = True if album: cur_al = audio.getall('TALB') if (force or not cur_al): if not dry_run: from mutagen.id3 import TALB audio.delall('TALB') audio.add(TALB(encoding=3, text=album)) changed = True if changed and not dry_run: audio.save(path) return changed def write_mp4(path: Path, artist: str, album: str, tracknum: str, title: str, dry_run=False, force=False): audio = MP4(path) tags = audio.tags or MP4Tags() changed = False # artist if artist and (tags.get('\xa9ART') != [artist]): if not dry_run: tags['\xa9ART'] = [artist] changed = True # album if album and (tags.get('\xa9alb') != [album]): if not dry_run: tags['\xa9alb'] = [album] changed = True # title if (force or not tags.get('\xa9nam')) and title: if not dry_run: tags['\xa9nam'] = [title] changed = True # track number (trkn) is a tuple (track, total) if tracknum and (force or not tags.get('trkn')): try: tr = int(tracknum) except Exception: tr = None if tr: if not dry_run: tags['trkn'] = [(tr, 0)] changed = True if changed and not dry_run: audio.tags = tags audio.save() return changed def process_file(path: Path, root: Path, dry_run=False, force=False): # derive artist/album from path: root/.../Artist/Album/file rel = path.relative_to(root) parts = rel.parts if len(parts) < 3: # not deep enough; skip return False, "path_too_shallow" album = parts[-2] artist = parts[-3] stem = path.stem # name without extension # Normalize Unicode around parentheses stem = normalize_parens(stem) tracknum, title = parse_filename_to_track_title(stem) # if no tracknum found, leave tracknum None and title entire stem if tracknum is None: # remove trailing bracketed translations, e.g. "SongName (中文翻译)" -> keep whole as title title = stem.strip() # prefer title if present # write depending on extension ext = path.suffix.lower() try: if ext == '.flac': changed = write_flac(path, artist, album, tracknum, title, dry_run=dry_run, force=force) elif ext == '.mp3': changed = write_mp3(path, artist, album, tracknum, title, dry_run=dry_run, force=force) elif ext in ('.m4a', '.mp4'): changed = write_mp4(path, artist, album, tracknum, title, dry_run=dry_run, force=force) else: # unsupported ext -> try generic mutagen write as fallback audio = File(path) if audio is None: return False, "unsupported_format" # best-effort: set common tags changed = False if hasattr(audio, 'tags'): if force or not audio.tags.get('title'): if not dry_run: audio.tags['title'] = title changed = True if force or not audio.tags.get('artist'): if not dry_run: audio.tags['artist'] = artist changed = True if force or not audio.tags.get('album'): if not dry_run: audio.tags['album'] = album changed = True if tracknum and (force or not audio.tags.get('tracknumber')): if not dry_run: audio.tags['tracknumber'] = str(tracknum) changed = True if changed and not dry_run: audio.save() return changed, "done" return changed, "done" except Exception as e: return False, f"error:{e}" def main(): p = argparse.ArgumentParser(description="从文件名写入 title 与 track 到音频元数据（支持 FLAC/MP3/M4A）") p.add_argument("root", help="音乐库根目录（会递归）") p.add_argument("--dry-run", action="store_true", help="仅预览，不写入") p.add_argument("--force", action="store_true", help="强制覆盖已有标签") p.add_argument("--limit", type=int, default=0, help="仅处理前 N 个文件（测试用）") args = p.parse_args() root = Path(args.root).resolve() if not root.is_dir(): print("错误：root 不是目录:", root); sys.exit(1) total = 0 changed_cnt = 0 for dirpath, dirnames, filenames in os.walk(root): for fn in filenames: if not fn.lower().endswith(AUDIO_EXTS): continue total += 1 path = Path(dirpath) / fn changed, reason = process_file(path, root, dry_run=args.dry_run, force=args.force) if changed: changed_cnt += 1 print(("DRY " if args.dry_run else "") + f"修改: {path} ({reason})") # optional limit if args.limit and total >= args.limit: break if args.limit and total >= args.limit: break print(f"完成: 总文件={total}, 修改={changed_cnt}") if __name__ == "__main__": main()直接把音乐库路径传入进去执行即可，例如：python tag_music.py /run/media/chocola/6T-DATA/Projects/Get_Information/Fuck_Netease/Music/注意，目录下的存放格式要和之前说的一样，也是就是艺术家/专辑/01.歌曲名.flac执行完成后，你就会得到一个自己的媒体库啦！但是，这样就结束了，吗？揭露网易云伪母带骗局如果你有留意到的话，你应该会发现我抓取的歌曲参数，选择的是最高音质，也就是jymaster超清母带。母带的定义是什么呢？母带（Master）是音乐制作流程中的最终成品音频文件，用于后续复制、发行和流媒体分发。它是在混音完成后，通过母带处理（Mastering）对整体音频进行精细调整，包括均衡、压缩、限幅、动态控制等，以确保在各种播放设备上具有一致的音质表现，并符合行业标准的响度与格式要求。母带不仅是物理或数字发行的源文件，也代表了作品最终的声音面貌，是连接创作与听众的关键环节。高质量的母带能提升音乐的整体清晰度、空间感和专业度。看着好像说很好啊，但是实际上，一般情况下发行的音乐文件大部分都是CD导出的，也就是平时我们能接触到的音质天花板就是CD无损音质16-bit/44.1kHz。而网易云平台上的所谓母带，绝大部分都是基于CD音质后期超上去的，保存这些庞大的文件不但对听感没有太大提升，反而还更加占用储存空间。这也是和群里面玩耳机的群友交流才得知的信息。如果不信，你可以安装个sox分析一下音频频谱图。sox 1.SWEET×SWEET.flac -n spectrogram -o ./infox.png可以很明显的发现能量主要集中在0–20kHz区域，呈现黄白色/橙色/红色。而到了20–90kHz区域则是深紫色/蓝色，几乎无信号。如果还有疑问，这里还有一张更加高清的频谱分析图：sox "1.SWEET×SWEET.flac" -n spectrogram -o "high_res_spectrogram.png" -x 4000 -y 1200这是很明显的后期人为超分或者算法AI超分插值上去的痕迹，所以没有必要保留这些无用的数据，可以安全地降低回CD无损音质。利用脚本转换文件节省空间因为这些媒体文件确实占用不小，占用了我NAS 160G的储存空间，问AI说至少可以压缩60%-70%的空间，所以我决定整顿这个媒体库。把下面的代码保存为convert.py，记得修改 原媒体库路径 和 新媒体库路径。#!/usr/bin/env python3 import os import shutil import subprocess from pathlib import Path from mutagen import File as MutagenFile # ================== 配置区域（修改你的路径） ================== SOURCE = Path("/home/chocola/data/files/Music/") # 原媒体库路径（结尾必须加 /） TARGET = Path("/mnt/4T-RAID1/data/music_01/") # 新媒体库路径（结尾必须加 /） # ============================================================ # 确保路径以斜杠结尾 if str(SOURCE)[-1] != '/': SOURCE = SOURCE / '' if str(TARGET)[-1] != '/': TARGET = TARGET / '' # 验证路径 if not SOURCE.exists(): raise FileNotFoundError(f"源路径不存在: {SOURCE}") TARGET.mkdir(parents=True, exist_ok=True) def get_sample_rate(file_path): """安全获取FLAC文件采样率（双保险：mutagen + mediainfo）""" # 尝试用mutagen try: audio = MutagenFile(file_path) if audio and 'audio' in audio: return audio.info.sample_rate except Exception: pass # mutagen失败，尝试mediainfo # 尝试用mediainfo（关键修复！） try: result = subprocess.run( ['mediainfo', '--Inform=Audio;%SamplingRate%', str(file_path)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=True ) rate_str = result.stdout.decode().strip() # 处理不同格式：如 "48000" 或 "48.0 kHz" if 'kHz' in rate_str: rate_str = rate_str.replace(' kHz', '').replace('.', '') return int(rate_str) except Exception as e: print(f"⚠ 无法获取采样率 (file: {file_path}, error: {e})") return 0 # 0表示无效采样率 def is_high_resolution_flac(file_path): """检查FLAC是否为高采样率 (>44.1kHz)""" sample_rate = get_sample_rate(file_path) if sample_rate <= 0: print(f"⚠ 跳过文件 (无效采样率): {file_path}") return False print(f" [采样率] {file_path}: {sample_rate} Hz") # 调试日志 return sample_rate > 44100 # >44.1kHz def convert_flac(file_path): """安全转换FLAC文件（100%避免ffmpeg交互）""" rel_path = file_path.relative_to(SOURCE) target_path = TARGET / rel_path # 确保目标目录存在 target_path.parent.mkdir(parents=True, exist_ok=True) # 用mutagen安全提取元数据 audio = MutagenFile(file_path) title = audio.get('title', [os.path.splitext(file_path.name)[0]])[0] artist = audio.get('artist', [''])[0] album = audio.get('album', [''])[0] # 使用ffmpeg安全转码 cmd = [ 'ffmpeg', '-y', '-i', str(file_path), '-map', '0:a:0', '-c:a', 'flac', '-sample_fmt', 's16', '-ar', '44100', '-metadata', f'title={title}', '-metadata', f'artist={artist}', '-metadata', f'album={album}', str(target_path) ] try: subprocess.run( cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) print(f"✅ 转码完成: {rel_path} (原采样率: {get_sample_rate(file_path)}Hz)") except subprocess.CalledProcessError as e: print(f"❌ 转码失败 (回退到原文件): {file_path}") shutil.copy2(file_path, target_path) def copy_file(file_path): """安全复制非FLAC文件""" rel_path = file_path.relative_to(SOURCE) target_path = TARGET / rel_path target_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(file_path, target_path) print(f"✅ 复制: {rel_path}") def main(): print(f"开始处理媒体库: {SOURCE} → {TARGET}") print("⚠ 正在处理特殊字符文件名 (日语/中文)...") for root, _, files in os.walk(SOURCE): for file in files: file_path = Path(root) / file if file.startswith('.'): # 跳过隐藏文件 continue if file.lower().endswith('.flac'): if is_high_resolution_flac(file_path): convert_flac(file_path) else: copy_file(file_path) elif file.lower().endswith(('.mp3', '.lrc', '.jpg', '.png')): copy_file(file_path) print("\n🎉 操作完成！新库已生成到:", TARGET) print(" - 伪高解析FLAC (48kHz+/96kHz/192kHz) 已转码为CD质量") print(" - CD质量FLAC/MP3/辅助文件已原样复制") if __name__ == "__main__": try: main() except Exception as e: print(f"❌ 紧急错误: {e}") exit(1)执行完成后，媒体库直接从160G变成49G，瞬间舒服了。不过话说回来，我今天打算尝试登陆网易云看看什么情况，居然说我的账户可能被运营商二次放号，确认是我的账号后，发现居然解封了。之前是一直卡着说要我刷脸实名认证，我不想搞，一气之下自建媒体库，不知不觉就用了一年了。不过后面我也不会再用网易云了，维护好自己的媒体库就行了，省得动不动自己的歌单又有一片变成VIP曲子听不了搞得我红温（
二次开发萌化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 美元/天而我这边呢？ 服务器是白嫖的华为云资源，带宽和计算资源基本零成本。你砸几百美元攻击我一天，我关掉网站喝杯咖啡，等你钱烧完，我秒恢复上线。🤔 所以，请问：你真的愿意花大几百美元，只为让一个不盈利、没广告、日均访问量不到百人的个人博客下线几天？这笔钱，够买几十杯星巴克，够报一门编程课，够买台树莓派折腾半年。 相比之下，把它砸在一个“打不赢也赚不到”的小博客上，真的值得吗？因此，对于那些脚本小子、网络喷子、或者闲着无聊想搞点事情的小学生来说——这套防护机制已经足够让你“出师未捷身先死”。而如果你真愿意花大价钱，动用专业资源对我持续攻击…… 那我只能说：承蒙厚爱，荣幸之至 😊
绕过黑洞！利用VPC内网互通低成本实现DDoS攻击下网站高可用
VPC内网互通：让被黑洞的服务器仍能访问事情起因很简单，前段时间我的华为云服务器又被闲的蛋疼的人刷了DDoS礼物，也是成功地触发了EIP（弹性公网IP）黑洞封堵。喜报如下：尊敬的(Rin):感谢您使用华为云产品，非常抱歉打扰您。在北京时间2025/09/23 10:26，华为云监测到您的弹性公网IP(EIP)实例 发生EIP封堵事件。 详情：节点：亚太-新加坡实例名称：119.8.185.128实例ID：b002926d-60de-4951-a9e9-b337e3a550d8告警描述：{"reason_zh": 尊敬的Rin：华为云DDoS防护服务在2025-09-23 10:26:56检测到您的EIP 119.8.185.128 异常流量已超过DDoS防护峰值触发封堵，封堵时带宽为 79 Gbps, 您服务器的访问将受到限制，24小时后系统将自动为您解封。您可登陆官网购买DDoS高防服务提升防护能力，保障服务器稳定运行。, "reason_en": Dear Rin: HUAWEI CLOUD Anti-DDoS Service detects that your EIP 119.8.185.128 abnormal traffic has exceeded the DDoS protection peak at 2025-09-23 10:26:56 and triggers blocking. Bandwidth when blocking is 79 Gbps, Access to your server will be restricted, and the system will automatically unblock it for you after 24 hours. You can log in to the official website of HUAWEI CLOUD to purchase Advanced Anti-DDoS service to enhance protection capabilities and ensure stable server operation.}原因分析：尊敬的Rin：华为云DDoS防护服务在2025-09-23 10:26:56检测到您的EIP 119.8.185.128 异常流量已超过DDoS防护峰值触发封堵，封堵时带宽为 79 Gbps您服务器的访问将受到限制，24小时后系统将自动为您解封。您可登陆官网购买DDoS高防服务提升防护能力，保障服务器稳定运行。如有疑问，您可以登录华为云官网提交工单咨询，会有相应工程师为您核实查看。IP我就懒得打码了，反正就是这个网站域名平时解析的IP，有什么好打码的，ping一下就能看到了。我在之前的文章，就有说过一套抗DDoS的方案，并且已经付诸实践了（详见文章：《当网站遭遇DDoS：如何优雅地应对云服务器被封堵》）。很显然，并没有太大影响，在网站主服务器被打黑洞的三分钟后，脚本就自动切换服务器到备用服务器并且套上了Cloudflare的代理，无需我干预，唯一的不足就是在被DDoS期间网站访问变慢了。但是，当时我就有说过，那套方案是有缺陷的，也就是因为网站数据同步是单向的，在被攻击期间交互产生的数据，后面会被覆盖掉。好巧不巧，华为云又送钱了，我寻思着我之前开的华为云服务器已经续费十年多了，不如开个新的云服务器，搞VPC内网互通，彻底解决上述的痛点问题。这里说个题外话，我发现新开的华为云 Flexus L 实例相比于之前已经大幅缩水，网络回国优化没了，而且CPU性能只有原来的三分之一了，已经变得跟阿里云的轻量服务器一个水平了，而且价格还更高。除非你和我一样是白嫖的，否则真金白银买我是不推荐，因为差不多一样的东西，阿里云那边要大概便宜个10%-20%，买华为云真不划算了。现在华为云的 Flexus L 实例算是把华为手机的高价低配学过来了，难绷。但是老实例性能和网络那还是相当不错的。资源准备为了实现这个方案，你需要准备两台云服务器，并且两台服务器必须在同一个地域，以便进行VPC内网互通。虽然同一个地域就足够，但将两台服务器放在同一个可用区会获得更好的内网延迟（通常<1ms），而跨可用区的内网延迟可能会稍高（但通常也在1-5ms范围内）。这就是最重要的前提要求，日常访问的可以是两台服务器的任意一台，只需要它的性能可以满足业务要求。对于另一台服务器，那是作为网站流量跳板服务器，最低配即可，方便节约成本，因为只需要在上面运行一个代理流量程序，在主实例EIP被封堵时代理接管流量入口。因为本方案抗DDoS采用的是Cloudflare的CDN，所以跳板实例的网络可以差一点，只需要保证到Cloudflare的回源服务器线路通畅即可，国内访问绕路都不是事。平时流量也不会从这边走，只是作为保障用途应急使用。网络配置一般情况下，云服务器在同一个地域会默认分配到同一个VPC实现内网互通，如果没有互通，修改两个云服务器的VPC为同一个VPC即可，或者使用VPC对等连接（VPC Peering），详情可以查阅各类云的参考文档，或者直接去发工单骚扰客服。当其中一个服务器ping另一个服务器的内网IP地址有反应，那就说明内网互通成功了，可以进行下一步工作。就像这样：主服务器:NyankoHost [~]# ping 192.168.1.255 PING 192.168.1.255 (192.168.1.255): 56 data bytes 64 bytes from 192.168.1.255: seq=0 ttl=64 time=1.152 ms 64 bytes from 192.168.1.255: seq=1 ttl=64 time=0.950 ms 64 bytes from 192.168.1.255: seq=2 ttl=64 time=0.932 ms 64 bytes from 192.168.1.255: seq=3 ttl=64 time=0.879 ms 64 bytes from 192.168.1.255: seq=4 ttl=64 time=0.899 ms 64 bytes from 192.168.1.255: seq=5 ttl=64 time=0.891 ms 64 bytes from 192.168.1.255: seq=6 ttl=64 time=0.885 ms 64 bytes from 192.168.1.255: seq=7 ttl=64 time=0.878 ms ^C --- 192.168.1.255 ping statistics --- 8 packets transmitted, 8 packets received, 0% packet loss round-trip min/avg/max = 0.878/0.933/1.152 ms代理服务器：MewHost:~# ping 192.168.13.229 PING 192.168.13.229 (192.168.13.229): 56 data bytes 64 bytes from 192.168.13.229: seq=0 ttl=64 time=1.051 ms 64 bytes from 192.168.13.229: seq=1 ttl=64 time=1.032 ms 64 bytes from 192.168.13.229: seq=2 ttl=64 time=1.006 ms 64 bytes from 192.168.13.229: seq=3 ttl=64 time=0.978 ms 64 bytes from 192.168.13.229: seq=4 ttl=64 time=0.988 ms 64 bytes from 192.168.13.229: seq=5 ttl=64 time=0.978 ms 64 bytes from 192.168.13.229: seq=6 ttl=64 time=0.980 ms 64 bytes from 192.168.13.229: seq=7 ttl=64 time=0.999 ms ^C --- 192.168.13.229 ping statistics --- 8 packets transmitted, 8 packets received, 0% packet loss round-trip min/avg/max = 0.978/1.001/1.051 ms设置代理服务器代理服务器Nginx配置文件设置建议使用一个轻量化的Linux发行版来节省资源，例如Alpine，本教程就是基于Alpine Linux 3.20部署的，然后安装上Nginx。编辑Nginx的默认站点配置文件/etc/nginx/http.d/default.conf，修改如下：server { listen 80 default_server; #listen [::]:443 ssl default_server; server_name _; # 这是一个通配符，表示任何未匹配的主机名 #return 444; # 关闭连接，不发送任何响应 root /var/www/; # 静态文件的位置 index index.html; # 默认主页 location / { try_files $uri $uri/ =404; # 尝试提供请求的文件或目录，如果不存在则返回404 } # 处理错误页面 error_page 404 /404.html; location = /404.html { internal; } } server { listen 443 ssl default_server; #listen [::]:443 ssl default_server; server_name _; # 这是一个通配符，表示任何未匹配的主机名 ssl_certificate /etc/nginx/ssl/self_cert.pem; ssl_certificate_key /etc/nginx/ssl/self_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; } }这里的配置文件是匹配默认站点，对于https则采用自签名证书匹配后返回默认页面，防止被证书扫描暴露备用服务器IP导致一起被打。至于怎么安装Nginx，怎么启动，怎么生成自签名证书，这些我之前的文章说过很多次了，不会的可以翻一下我的博客文章，例如《实战：帮群友迁移Typecho博客并解决数据库问题》，这里不再赘述。接下来配置代理的配置文件/etc/nginx/http.d/proxy_nekopara_uk.conf：server { listen 443 ssl; server_name www.nekopara.uk; 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; # 必要的代理头，保留原始 Host（按需） proxy_set_header Host $host; # 或改成 $proxy_host / example.com proxy_set_header X-Real-IP $remote_addr; # 把客户端 IP 放入 X-Real-IP proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }采用的证书建议是使用Cloudflare生成的源服务器证书，因为可以设置15年有效期，有效防止证书忘记更新的情况。这里别忘了，要把代理服务器的hosts文件改一下，因为如果在内网直接配置IP回源的话，会被自己匹配到默认站点而不是网站，你也看到了我配置文件回源的配置是proxy_pass https://www.nekopara.uk:443;，所以你需要修改/etc/hosts文件，加上以下内容：192.168.13.229 nekopara.uk www.nekopara.uk其中，192.168.13.229是主服务器的IP地址，后面则是匹配的域名，这样子反代后的流量走到主服务器就不会匹配默认站点了。代理服务器安全组设置（可选）这部分属于安全加固的内容，为可选项，建议配置。为了最大程度保护用于代理的备用服务器不被扫描暴露，我们需要配置安全组白名单，在80和443端口只放行来自Cloudflare IP的流量。Cloudflare的IP地址段可以在IP 地址范围 | Cloudflare查询到，包括以下IP段：173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22在云服务器的安全组里面配置一个 Cloudflare IP 地址组，添加到80和443端口放行规则里面，只放行该地址组的IP，以及必须的端口，如22，最小化攻击面。设置IP地址组，添加Cloudflare IP：创建新的安全组，并设置80和443放行的源地址为 Cloudflare IP 地址组：切换备用服务器的安全组为新创建的安全组：这样，就保证了备用服务器不会被轻易扫描出来，除了Cloudflare的IP，在别的IP地址访问下，这个服务器看起来压根就没运行网站。其实如果可以，连22端口的规则都可以删了，反正可以内网直接连接，也省得22端口每天都在被高强度爆操。主服务器配置修改你主服务器网站的Nginx配置文件，在服务器块的前面部分加上下面的内容：set_real_ip_from 192.168.1.255; real_ip_header X-Forwarded-For;这是信任内网的服务器提供的远端IP地址，避免在网站的访问日志里面看到访问的IP全是代理服务器的内网IP。完整的配置示例文件如下：# Typecho HTTPS服务器块 server { listen 443 ssl; server_name www.nekopara.uk; #信任并设置代理服务器提供的远端IP 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; # Serve static files directly location / { try_files $uri $uri/ /index.php?$args; autoindex off; } # 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; } # Deny access to hidden files location ~ /\.ht { deny all; } }验证代理情况下真实IP传递效果我采用面向LLM编程写了下面的测试代码，用于展示客户端访问IP的情况，保存为ip.php放到网站目录访问即可看到效果：<?php // 获取客户端IP地址 $ip = $_SERVER['REMOTE_ADDR']; $ip2 = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '未通过代理/CDN'; // 显示IP地址（带简单HTML格式） echo " 您的IP地址信息 "; // 显示REMOTE_ADDR（直接连接IP） echo " "; echo "REMOTE_ADDR: " . htmlspecialchars($ip) . " "; echo "通常是直接连接服务器的IP（可能是代理或真实IP）"; echo " "; // 显示HTTP_X_FORWARDED_FOR（原始客户端IP） echo " "; echo "HTTP_X_FORWARDED_FOR: " . htmlspecialchars($ip2) . " "; echo "通常表示原始客户端IP（仅当使用代理/CDN时存在）"; echo " "; // 添加说明 echo " "; echo "说明： "; echo "• 如果您未使用代理或CDN，HTTP_X_FORWARDED_FOR 可能为空或显示'未通过代理/CDN' "; echo "• 如果您使用了CDN（如Cloudflare）、代理或负载均衡，HTTP_X_FORWARDED_FOR 通常包含您真实的IP地址 "; echo "• REMOTE_ADDR 通常是与服务器直接建立连接的设备IP"; echo " "; ?>通过主服务器IP直接访问会在REMOTE_ADDR显示客户端真实的IP地址，HTTP_X_FORWARDED_FOR显示未通过代理/CDN；通过代理服务器访问则REMOTE_ADDR显示客户端真实的IP地址，HTTP_X_FORWARDED_FOR也显示客户端真实的IP地址。这才是配置正确的情况。这里有一个小技巧，如果需要测试代理的可访问性，不需要配置DNS，这样反而会暴露备用服务器的IP地址，可以编辑自己电脑的hosts文件，指定主机名对应的IP为代理服务器IP进行测试即可。而配置错误的情况下，REMOTE_ADDR或HTTP_X_FORWARDED_FOR会显示VPC内另一个服务器的内网IP地址，这就需要重新配置，毕竟会影响网站的访问记录。直接访问的情况：通过代理访问的情况：配置错误的情况：注意⚠️：测试完成后请删除ip.php文件，避免服务器信息泄漏。配置自动切换脚本我们需要在备用服务器上面运行一个监测脚本监测主服务器状态，当主服务器被黑洞时可以更改Cloudflare DNS配置让流量通过Cloudflare代理后回源，避免访问中断且保护备用服务器。这部分就和之前那篇文章是一样的了，基本上没有什么变化：#!/bin/bash # 🐱 Cloudflare API Token CF_API_TOKEN="neko????????????????????????????????para" # 🐱 域名，例如 nekopara.uk CF_ZONE_NAME="nekopara.uk" # 🐱 记录名称列表，例如 "nekopara.uk www.nekopara.uk" CF_RECORD_NAMES=(nekopara.uk www.nekopara.uk) # 🐱 目标 IP TARGET_IP="119.8.185.128" # 🐱 目标端口 TARGET_PORT="80" # 🐱 备用 IP BACKUP_IP="172.16.1.233" # 🐱 获取 Zone ID get_zone_id() { curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${CF_ZONE_NAME}" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" | jq -r '.result[0].id' } # 🐱 获取 DNS 记录 ID get_record_id() { local zone_id="$1" local record_name="$2" curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=A&name=${record_name}" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" | jq -r '.result[0].id' } # 🐱 更新 DNS 记录 update_dns_record() { local zone_id="$1" local record_id="$2" local record_name="$3" local ip="$4" local proxied="$5" curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${record_id}" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" \ --data "{\"type\":\"A\",\"name\":\"${record_name}\",\"content\":\"${ip}\",\"ttl\":1,\"proxied\":${proxied}}" | jq } # 🐱 检测目标 IP 的 HTTP 状态码 check_target() { curl -s --connect-timeout 10 "http://${TARGET_IP}:${TARGET_PORT}" -o /dev/null return $? } # 🐱 初始化 zone_id=$(get_zone_id) declare -A record_ids for record_name in "${CF_RECORD_NAMES[@]}"; do record_ids["$record_name"]=$(get_record_id "$zone_id" "$record_name") done current_ip="$TARGET_IP" proxied=false failure_count=0 success_count=0 # 🐱 监控循环 while true; do if check_target; then echo "🐾 目标 IP ${TARGET_IP} 响应正常" failure_count=0 success_count=$((success_count + 1)) if [ "$current_ip" != "$TARGET_IP" ] && [ "$success_count" -ge 3 ]; then echo "🐾 切换回原始 IP ${TARGET_IP}，关闭代理" for record_name in "${CF_RECORD_NAMES[@]}"; do update_dns_record "$zone_id" "${record_ids[$record_name]}" "$record_name" "$TARGET_IP" false done current_ip="$TARGET_IP" proxied=false success_count=0 fi else echo "🐾 目标 IP ${TARGET_IP} 无响应" failure_count=$((failure_count + 1)) success_count=0 if [ "$current_ip" != "$BACKUP_IP" ] && [ "$failure_count" -ge 3 ]; then echo "🐾 切换到备用 IP ${BACKUP_IP}，开启代理" for record_name in "${CF_RECORD_NAMES[@]}"; do update_dns_record "$zone_id" "${record_ids[$record_name]}" "$record_name" "$BACKUP_IP" true done current_ip="$BACKUP_IP" proxied=true failure_count=0 fi fi sleep 60 done详细的配置方法，请参考《当网站遭遇DDoS：如何优雅地应对云服务器被封堵》，这里不再赘述。小尾巴最后还有一点别忘了，那就是将VPC内网IP加入iptables访问白名单，如果你按照我之前的文章设置过单IP访问连接数限制的话。在规则列表前面添加白名单：iptables -I INPUT 1 -p tcp -s 192.168.0.0/16 --dport 80 -j ACCEPT iptables -I INPUT 2 -p tcp -s 192.168.0.0/16 --dport 443 -j ACCEPT如果不这么做，当流量变大时，VPC内网的代理服务器的请求就会全部被拦截，导致访问失败，跟网站挂掉没什么两样了。（我 防 我 自 己）总结这样以来，我们就成功构建了一个在平时能够快速访问，而在被DDoS时又不会彻底挂掉的网站，用较低的成本平衡了访问速度和抗DDoS能力。在平时直接走服务器的IP提升访问速度，而在被DDoS时，通过Cloudflare代理保护源服务器。其工作的逻辑和过程大概如下：当被DDoS攻击时：主服务器EIP被打黑洞无法访问 > 备用服务器监测到主服务器无法访问 > 切换为Cloudflare代理流量，并更改DNS解析记录到备用服务器的EIP > 网站继续可访问，但是速度变慢当DDoS停止，主服务器EIP解封：备用服务器监测到主服务器正常访问 > 关闭Cloudflare代理流量，并更改DNS解析记录到主服务器的EIP > 网站恢复访问速度