时间好快，2025年转瞬即逝我感觉上一次写2024年度总结，好像还是前不久的事情，不得不说时间还是太快了。2025年发生了很多很多事情，也算是对我人生影响的关键一年，现在，在这一年即将结束之际，来回顾一下这一年的种种事情。接下来，请看我写流水账（）网站运营方面今年仍然保持了较高强度的更新，虽然我的更新态度是，没事就不更新，更新一般都是事件驱动的，但是因为喜欢各种折腾不知不觉就更新了不少文章。主要的文章还是围绕了解决各种遇到的技术问题，以及分享和记录一些自己感觉有意思的事情。我觉得整个写作过程还是比较有意思的，毕竟博客属于是个人是私人领地，你就是这里的主角。你可以自由的发表你的观点和看法，想骂就骂，想喷就喷，不用在意别人的看法。当然，难免会有人不爽你，不服的话就技术互撕咯，DDoS，CC攻击什么的，一般也都是脚本小子。撕完就又有写文章的素材了，多是一件美事（bushi）发布文章数量在发这篇总结之前，今年一共发了56篇文章（其实是57篇，有一篇因为某些原因隐藏了），算是很高产了平均每个月发4-5篇文章，已经遥遥领先绝大部分友链博客了。这其中，技术类文章的占比仍然是主流，毕竟我当时创立这个博客的想法，就是打算当作一个个人技术笔记来使用，并穿插一部分其他内容。网站被打次数小的攻击，上不了台面的，就算了，这里就统计对我网站造成实际影响的被攻击次数——7次！而且集中在今年下半年9-10月其中，DDoS攻击6次，最高攻击流量达到了惊人的79Gbps！被DDoS UDP流量狠狠灌注的感觉真是太爽了，特别是让对面发现使劲打了还打不死，更乐了。虽然云服务器被黑洞，但是仍然可以保证网站访问，我估计对面已经红温了。具体的配置方法，可以去看看这篇文章：绕过黑洞！利用VPC内网互通低成本实现DDoS攻击下网站高可用另外，在最后一次DDoS攻击之后，有一次大规模CC攻击，通过随机URL请求绕过了我的缓存策略，打在我服务器上拳拳到肉，直接把网站拖垮了。事后我也复盘了，搞了更优的限流防御策略，虽然仍然存在被打垮的可能，但是极大提高了攻击者的攻击代价。想看更详细的信息，可以看一下这篇文章：个人博客低成本CC防护：抵御85%+常见CC攻击就在这次CC后，我网站到目前为止，没有被打垮过了，也没再有过DDoS。在这里要感谢脚本小子的陪练。尝试开设新网站：猫娘答案屋因为之前觉得nekowork.org这个域名挺好的，就盘下来了，之前是作为个人信息聚合页面，感觉还是太浪费了，正巧我有路子可以获得一些网课答案，于是就想拿来搞一个用于分享大学网课答案的网站。但是看了一下搜索引擎的流量表现，好像确实不怎么样，基本上没什么权重，有点可惜。其实三月份的时候就尝试整理了几个网课的答案，然后发现太累太花时间，就没能坚持下来。所以中间断更了半年多，也没再怎么管过这个网站。然后最近手又痒痒了，想尝试一下自动化爬虫抓取答案，当作是爬虫练习去尝试。你别说，配合现在的大语言模型，写一些基础的代码还是相当轻松的，特别是当你有了程序实现逻辑的想法，直接让AI来帮你实现具体的代码就好了。经过几天的测试迭代，我也是成功写出了一套针对那个网课网站的自动化爬虫程序，可以实现自动获取章节作业的标题，随机选择答案，提交答案，保存题目和正确答案的过程，行云流水。但是这只是半自动爬虫，仍需要我手动进入课程，点开作业章节。不过，相对于手动提交答案后复制整理正确答案，速度确实快多了，而且不折磨人。如果网课作业量不算多，作业的章节10个左右的话，基本上一个网课10分钟能整理好，章节里面的题目越多，效率越高。我相信有这套工具加持，我应该是可以持续更新那个答案站的，只要我有时间有精力。白嫖华为云那些事2025年是华为云大撒币的最后一年了，大概10月份的时候，就是最后一次活动了。整个活动持续了大约一年，2024年9月开始有这类白嫖活动，高强度大撒币到2025年9月，后面就调整政策不撒了。有点可惜的是我8月份因为忙保研那些事情，没心思参加，缺席了一次活动，痛失1k代金券。估计后面华为云也不会再搞这类活动了，毕竟一次活动，一千多号人，一人一千这样发，虽然是云资源代金券，但是也是烧钱的。目前我的白嫖战果是：主服务器实例到 2035年6月1日过期 累计白嫖了十余年使用权（2024年9月20日-2035年6月1日）备用服务器实例到 2031年1月18日过期 累计白嫖五年多使用权（2025年9月28日-2031年1月18日）这两个服务器将是我未来网站运行的中流砥柱，关键力量，坚实根基，核心支撑，稳定基石，动力源泉，保障中坚，运转中枢，可靠依托，发展支柱，存续命脉，效能核心，运行保障，长远依托，根基所在，关键支撑，不可或缺，至关重要。它们既是网站平稳运行的固本之基，也是长远发展的赋能之要，更是持续服务的立命之本，为平台建设提供坚实保障，为功能拓展筑牢核心支撑，为效能提升注入不竭动力，为安全运行夯实稳固底盘，为创新发展搭建重要载体，助力网站在数字领域行稳致远、固本强基、持续向好、提质增效、再创佳绩，切实保障了服务供给的稳定性、持续性、安全性、高效性，为长远发展奠定坚实基础、凝聚强劲动能、筑牢安全屏障。(对不起，串味了）个人收藏方面今年又买了一些个人赛博收藏品，主要是一些老的顶级显卡：GTX780Ti （坏的，80包邮，后面朋友帮修好了）GTX TITAN Black 320包邮HD 6990 180包邮基本上没有太大使用价值了，特别是HD 6990 ，已经是纯赛博电阻了。不过，今年确实没再买过手办了（主要是没地方放了）另外，还有一个新增的赛博收藏：nekowork.net 很不错的一个域名，当时注册nekowork.org的时候，这个被人注册了，现在之前用的人不续费了，就到我手里了。一时半会不知道拿来干什么，先留着吧，好域名可遇不可求（）今年买的硬件感觉今年又是买硬件大出血呢！让我看看今年除了刚刚说的三张收藏品显卡，还买了什么东西：P104-100 买了五张，炸一张，一张79包邮，买来跑SheepIt渲染农场赚取渲染积分V100-SXM2 16G 和对应的配件，花费683，其中卡本体385，转接板200，散热器98E5-2667v3 两颗，28一颗，买来装上机架式服务器打游戏，玩对马岛之魂99包邮的杂牌X99主板，买来升级家里云两条16G REG ECC DDR4 2133p内存，海力士的，当时买是70一条包邮，现在涨价到450，太离谱了！四条DDR3 8G 1600MHz内存，买来装渲染机用的，一条24一个500W电脑电源，用于测试收藏的核弹显卡大概就是这么多吧，不过感觉现在内存涨价涨疯了，我有点想把我的服务器的RECC内存全部出掉，8条，一条买个500，4K直接到手！而且也有备用的电脑，备用的内存，等降价后再买更好的，岂不美哉？！这样看，如果真卖了，我白用一坤年机架式服务器，还倒赚500，太离谱了。目前有12条16G REG ECC DDR4 2133p内存，计划出10条，已经3960出掉了，血赚。虽然最后发现E5-2630v4做家里云实在是性能过剩，然后正巧买了V100，而H61配E3-1220v2因为硬件限制无法驱动V100，所以我又把家里云降级回到它原来的配置，用这套X99带V100了。有一说一，V100绝对是今年下半年性价比最高的垃圾佬AI神器，16G的HBM2显存，跑模型速度飞快！美中不足是驱动刚好停更了，但是也正是10月份穿出驱动停更的消息，让淘宝V100价格暴降200！我到手的时候还没停更，但是现在停更了，永远留在581版本的驱动了。不过就玩现在的主流模型还是没什么问题的，现在的模型也很可以了，只是未来可能就玩不了更好的了。相关文章：V100 GPU 初体验：AlmaLinux 8.10 快速部署 ComfyUI其他的小玩具看了一下，今年买的东西确实比前两年更少了，我感觉我现在消费也更理性了，不会轻易乱花钱。当然了，要是真心想要的东西，砸钱也是毫不犹豫。今年买的玩具：两个500W惠普服务器拆机电源，拿来当12V电源玩，现在已经丢在V100的电脑里面当副电源带显卡了，一个25，相当便宜！一个64G U盘，因为另一个U盘落在学校了，想在家装系统没U盘可以用，不见30米一套廉价APU板U套，以及其他用于DIY的配件和物品，拿来装洗洁精主机，还是挺有意思的，大概花费了一百多吧，感兴趣可以看看这篇文章：手搓洗洁精ITX主机教程一个1m的fufu玩偶，丢在宿舍，导致我每天只能抱着她才能睡觉，不然都没位置睡觉了（），花费100黑色星期五撸了两个灵车VPS，都是当玩具的，重要的东西不可能跑在上面。一个2USD一年，无限流量，100M带宽，配置1vCPU 1G内存 50G硬盘，感觉相当划算，CPU性能还凑合，超开不算严重。另一个我感觉有点血亏，9.99刀一年，CPU超开比较严重，也是1vCPU 1G内存 50G硬盘的配置，还限制每个月1TB流量，上下行都算，亏麻了！但是是1G带宽，速度非常快。一张RTX 5090......手办卡，打算送人的，花了118，差不多就是一个手办价格大概就这么多吧，感觉一年买的玩具也不算少了，不过考虑到我平时基本上不吃零食，也不喝奶茶饮料什么的，我觉得每天能省下来的钱，拿来买自己喜欢的玩具，不过分吧？个人学习生活方面这就是我认为这一年过的最紧张刺激，最意义重大的部分了！也算是成功对我在去年的未来展望最看重的部分兑现了，而且是超额完成任务！但是这一年也是我大学里面最焦虑的一年，特别是在暑假这个保研关键期阶段，确实是相当的焦虑了。总体来说整个暑假简直是行尸走肉般的生活，十分难熬，备战感觉无力，玩又玩不开。不过现在回想起来，一切都值得了！寒假生活记录寒假期间我自建了音乐媒体库，并以此为契机写了一篇文章：自建Jellyfin私人媒体服务器，告别屌毛网易云！，不再被网易云卡脖子了，拥有个人媒体库的感觉真好！另外，我在寒假期间狠狠地爽玩了《对马岛之魂》，用RTX2080Ti接家里面的大电视，开4K画质，连上PS4手柄坐在沙发上玩真的爽爆了！这种题材的游戏，我也是第一次玩，大概讲的是蒙古入侵日本的故事，扮演武士进行反抗，还是蛮有意思的。游戏的画面非常好看，走在地图场景里面，导航靠风这个确实很有创意，配上飘落的树叶真的是绝美！不过，要是玩这个游戏，配音建议开日语配音，英语配音感觉有点奇怪（）寒假的绝大部分时间都在古代的对马岛里面“体验武士生活”，当然了，春节期间还是被家里面拽出去走亲访友了。然后在农村亲戚家搞了窑鸡，挺有意思的：然后还看了村BA:逛完农村后又是天天对马岛启动，然后假期接近尾声的时候，把游戏通关完成了。期间再次被家里拽出去瞎逛，去看了一下本地的花卉小镇，离城区挺远的，不过我感觉也没什么好看的，在那里玩兔子：请问你今天要来点兔子吗？春季学期的学习生活寒假结束，开始保研冲刺的最后一个学期。不过好在，这个学期的课程已经非常少了，基本上很闲。课程设计唯一比较重要的课程设计，这次居然需要小组合作了，因为是要设计一个片区改造计划，而且是基于现实立的课题，自然就少不了调研分析了。可惜实际上说要我们进行改造的部分，规划上已经是要全拆了的，有一种理想和现实的冲突感。有一说一，这些老旧社区就像一个一个活生生的时间胶囊，记录了城市发展的点点滴滴，是一代甚至几代人的记忆根基。虽然说确实是“老破小”，但是我感觉直接这样子粗暴拆除，或许并不是最好的做法。调研的时候，搬迁和拆除工作已经进行到了一半，走进空荡荡人去楼空的小区，看着满目疮痍，总有一种偏伤感但是又说不出的感觉。要拆除的老厂房：老社区便利店老板娘养的哈基咪：老破小社区一角：拆除中的居民楼社区：逗猫：课程设计，最后也是如愿以偿拿了一个比较好的成绩，结束了这个最后对保研影响至关重要的过程。新的玩具因为感觉一般的fufu还是太小了，抱着不够爽，于是乎买了一个大的：五一假期因为这个学期的学习任务相对来说比较轻松，所以五一还是可以有闲情逸致和家里面一起出去玩的。然后就和几个老妈认识的朋友一起，直接去广东的电白，湛江去玩了。先去电白泡了温泉：然后又去湛江看了一下一些军事装备，虽然都是老掉牙的东西了，但是第一次进真的飞机驾驶舱还是挺好玩的：校园趣事不知道那个老师不注意，开车把学校里面的一个水井盖撞烂了，也是活久见了：爽玩游戏后面因为课程基本上接近尾声，并且那个学期没有考试，于是在学期后面一个月的空窗期，我直接爽玩赛博朋克2077，狠狠玩了一个月，每天都是吃完早餐直接去教室，开电脑，打游戏，中午和晚上出去吃个饭回来继续玩，高强度打任务，基本上玩到晚上11点才回宿舍。最后也是成功的把游戏的剧情刷了一遍，不得不说这个故事还是挺震撼的。玩游戏的工位Be Like：武汉之行因为要去参加某酒吧舞的学术夏令营，所以要去武汉，于是真来了一场说走就走的旅行，还是坐的绿皮火车！硬座，14小时，还跨夜，差点把我坐死在上面...不过后面顺带去武汉的几个景点看了一下，黄鹤楼，湖北省博物馆这些，顺带去参观了一下臭名昭著的武汉大学。详细的旅行日记，可以看看这篇文章：武汉旅行碎片煎熬的暑假保研准备的煎熬因为并不确定能保哪个学校，所以整个假期都在焦急的准备。之前问了保研成功的学姐的经验，她说我六月份再准备已经有点迟了，搞得我挺懊悔五月份的时候闲的蛋疼疯玩游戏，如果提早一个月会从容很多。不过那时候我还是以自己最快的速度把相关的，可能需要的材料都凑齐了，时间大概花费了一个星期，我感觉这是我大学从事学习方面的事情效率最高的一次，然后就是密切关注各个学校动态了。而这个密切关注，就贯穿了整个暑假，所以非常的煎熬。暑假之前去武汉某酒吧舞，就是其中一次的夏令营，我就当作是练手去的，结果并不理想。这导致我的暑假准备工作更加焦虑了，基本上每天都在反思是哪里出了问题，未来要去的学校考察侧重点在哪里，老师面试可能会问什么问题，我又应该怎么准备......我只能说，保研这条路并不轻松。我就这样煎熬了三个月，期间的过程我愿称之为痛苦，比当年高三还难熬。因为高三是又有外部压力压着你，而且和同学在一起，也算是有人“并肩作战”，尽管都是潜在的竞争对手；而保研就不一样了，这是你自己给自己的压力，比外部压力更难受，并且，你是孤军作战，并没有高中那种“零信息差”的感觉，而是一旦产生信息差，后果极其严重！你不知道对手的动态，也无法参考，唯一可靠的信息就是目标学校历年来发布的夏令营通知或者预推免通知，也就是一版冷冰冰的文字，虽然极具参考价值，但是少了一些人情味，特别很多学校都是一个报名系统，或者发送报名邮件，并没有即时的反馈，更加折磨人。出游散心或许这次，被家里面拽出去，是不错的选择了。因为精神压力实在是太大，家里面说不差这几天，劝说之下，我还是同意了出去散心这个建议。然后就是去了崇左那一带转了一下，看一下自然风光，主要就是明仕田园和德天瀑布：秋季学期：保研冲刺这个学期，我愿意称之为压力和事情两级分化最严重的学期，开学一个月的时间内，绝对是忙疯了，而且压力非常大！因为要准备各种保研材料递交给本校，还要准备面试材料去应对保研目标学校的面试，真的是忙的起飞。但是当保研工作结束后，就进入了闲的蛋疼的时间段了。因为实在是太闲，我甚至为了找点事干，顺带完成学校半硬性的实习要求，去找了一份实习。可以说，在预推免阶段，各个学校的面试节奏相当快，基本上一天就解决了，还要会遇到学校面试时间段撞车的事情。我就去面试了两个酒吧舞，没有像某些海王那样，报了十几个学校，日程直接排满。当时我在面试现场等候的时候，看到一个海王在和她的同学抱怨，说基本上一天一个，跑去面试都不带停的。看她们说，至少报了七八所学校，过于离谱了。好在面试过程还算顺利，甚至超出预期！因为我当时看学校太迟了，只能选了两所酒吧舞，一所稳的，一所冲的，结果，冲的居然上了！而且是压线上！太幸运了。面试完后，直接就顺带去珠海真实群友了，逛了逛石墩市，有网友带，边逛边聊，还是很不错的。华为好，华为美，华为给我增智慧MV拍摄地：拱北口岸：国庆假期出游保研结束后，心情和压力好了不少，于是当家里面说要出去玩的时候，欣然接受了。国庆期间，先去拜访了小三峡景区，不过没想到的是，就这么一个小景区，国庆期间也是人满为患，导致只能进去逛一下，想坐船游是没办法了。不过周边的自然风光确实很好，有一些地方拍照也很能出片。然后，我们又马不停蹄的往北上到贵州，去看了一下小七孔景区，自然风光还是相当可以的。其中，乘坐游船划水的时候，还近距离看到了鸳鸯：然后顺路故地重游了程阳八寨（对我来说），再去了桂林阳朔，参观20元人民币取景地。以及，看到了工具鸟——鸬鹚：万圣节Cosplay变装嗯，女装 Cosplay 这事，其实是当年我做 UP 还火的时候，那帮粉丝们天天喊着想看的东西，现在算是真尝试了，感觉还行。事情的起因是，在群里面口嗨，说你敢买，我就敢穿，结果真买了（）那就，穿上吧！看过番剧的，就知道是小林家的龙女仆里面的托尔了，选择这个角色的原因是，角色身高比较高，我一米八穿上，也不会显得违和。其实带上口罩挡脸再穿Cos，我觉得还算可以了，照片发群里他们居然说好看，是我没想到的。或许是因为我的身材比较标准偏瘦吧，甚至裙子还感觉做大了，松松垮垮的，要用要带的蝴蝶结绑一下才能固定好。设计院实习体验最近几年建筑设计院风评很差，甚至老师推荐的实习工作，还有一个月只给500块钱这种抽象工作，过于离谱。于是我问了一圈同学，找到了一个相对正常一点的，工作日每天给100的设计院，加班工资按劳动法另算，一个月能有个2.5K左右，我觉得还可以接受，就去了。有一说一，设计院强度确实高，加班是常态，甚至说是病态！对于正式工，基本上都要加班，而且工作安排什么的也不太合理，经常就是突然就派任务过来了，而且时间很急。不过，工资这一块，确实还行，虽然辛苦，但是给的确实也多，基本上八九千一个月还是有的，有一些肯干的，一万多一个月都不是问题。问题是，这种工作强度下，身体能不能撑得住呢？我去实习就是去看看实际的工作情况，毕竟后面还要升学读书，给的2K多的工资还想让我干正式工的活是不可能的。所以我就只做他们正式工工作的三分之一到四分之一，多了就不干，或者调到加班时间，额外要加班工资。好在带我的那个姐人挺好的，安排工作相对来说也比较合理，不会的地方也是手把手教，总体来说实习体验还算是不错的。而且其实也不是每时每刻都有活给实习生干，比如现在这篇文章，我就是再办公室里面用公司电脑写的。工作期间，还有一次外出踩盘机会，也就是参观现在的楼盘，当作是竞品考察和分析。我一开始还以为说房地产这块暴雷死透了，然而现实是，还是有新的项目落地，只不过赚钱没有之前这么容易了，开发商更卷了——卷配套，卷环境，卷体验，卷赠送。有一个同事感慨，说2019年那会高位买的房，和现在这个时间点买的房，同样的价格，天差地别！甚至现在买房只需要之前一半的价格，获得了更高的配套品质，之前想都不敢想。以前那是能造出来房子就能卖钱，现在不是了，要造好房子才能卖钱。参观了几个楼盘，和我十多年前陪我老爸去看房，已经是完全不同的体验了：看完之后感慨，房地产确实进化了，更卷了，但是对于建筑设计行业，那也意味着工作量增加了，工资还更低了。亲戚的婚礼这次我也算是有幸作为主角之一，参加了亲戚的婚礼——算是很亲的亲戚了，从小一起长大的。现在想想也十分感慨，这么快，他就结婚了。我是不是准备要被安排了？催婚这个，其实已经有了，只不过我一直懒得找女朋友，更想自己享受生活。这是一个原因，还有另一个原因是，我也不擅长搞这些情感上的东西，并且要和我的兴趣爱好，三观重合，为人比较好，这点还是比较难找的。我感觉如果真的有缘分，那应该是互相之间会越走越近，互相理解，发展共同的兴趣爱好，这才是我想要的女朋友（）想要的生活是，女朋友可以天天穿喜欢的角色的Cosplay，二次元浓度极高，当然，先排除国内二游（例如原神），这种不能接受，我想要的是纯正日系二次元的，然后还可以有计算机技术或者硬件技术方面的兴趣，这样就比较好有共同话题。不过这种想想就好了，几乎不太可能，也有理由推脱催婚了（）附上一张配亲戚去接新娘，到新娘老家的照片：灵山大卢村，算是古建重点保护单位了，大部分都是村民家族的祖公厅，家族客厅。虽然是景点，文物保护单位，但是这些古建筑的所有权，或者说使用权还是归本地村民。所以可以看到在文物保护单位建筑里面进行婚礼仪式，在里面开火做菜，吃席，也算是别具风格了。校园的变化年末校园里面最大的变化，就是图书馆后面的山被挖了，估计又是要新建什么建筑吧。最近几年学校确实在大兴土木搞校园建设，对比我刚入学那会，变化真挺大的。个人兴趣方面Blender动画之前我的计划是，今年进一步学Blneder动画，目前看是有了不少进展。准确来说，算是在实习期间，在公司电脑上摸鱼做的。先是照着教程搭建了一个教堂场景：Blender练习作品：动画风格教堂建模然后，尝试做了一个我之前一直想要做的3D抽象动画，内容是复刻刘华强买瓜的视频。全程高能，有一说一确实抽象，我一边做一边笑，自己都绷不住。丑到搞笑，荒诞到离谱。视频里面，把我的网站网址和一些关系比较好的友链的网址，作为彩蛋出现在了动画里面，主要是充当了街边店铺的招牌。因为过于抽象和丑陋，我就不放在博客毒害大家眼睛了，想看的可以直接去B站搜，很容易找到。AI绘图其实当时继续学Blender的时候我就在想，现在AI已经进化到这个程度了，只要学会给AI描述需求，AI就能生成令人满意的艺术作品，甚至比你苦学好几年的动画，绘画，建模都强，那我们还这样子学艺术的意义是什么呢？正好买了V100，所以可以名正言顺的去体验AI画图了，16G显存可以应对相当一部分本地AI模型的需求了。有一说一，相比于之前第一次玩的Stable Diffusion 1.5，现在新的AI模型画图能力确实强大了非常多。很明显已经没有了那种AI感，各种风格都能轻松驾驭，而且画面出错的情况也是越来越少了。最明显的提升是，手指基本上正常了，很少出现多根手指的情况了，人物躯干也不会画成一坨奇怪的东西了。而且，现在的ComfyUI工作流，可以更好的控制AI生成的功能，我觉得等有时间还是值得深入去学习一下这个工作流的使用。毕竟不可否认，AI就是未来的新质生产力，很有可能会颠覆整个创意行业，甚至世界。非常像计算机的“工业革命”。很巧的是，早些时候我就有过想要学习插画的想法，毕竟能画出自己的二次元老婆，多是一件美事！所以，我希望可以进一步学习这方面的知识，靠AI画出我想要的插画，而不是依赖于画师。这是之前调教了一下画出来的两张图，确实相当哇塞了：打电动今年玩过的游戏有：CS2，对马岛之魂，赛博朋客2077，霍格沃兹之遗其中，CS2单纯就是为了打箱子，打这逼游戏确实太败坏心情了（所以，真正用于体验，接触的新游戏：对马岛之魂：寒假一个月通关赛博朋克2077：春季学期下半学期一个月空窗期通关霍格沃兹之遗：12月Epic送的游戏，据说很好玩，试了一下确实不错，打算是连着寒假一起通关搞开发今年瞎搞的开发活动，有一个成功可喜可贺，那就是——「NekoWebShow」这是一个使用开源的第三方E-mote实现框架FreeMote做的猫娘乐园角色动态立绘展示，终于解决了我Linux桌面没有猫娘动态壁纸可用的问题。另外，还有一个VisitorLoggerPro-Enhanced，对访客插件完成了个性化改造；以及二次开发改造出来了一个Typecho-LinuxInfo插件，可以在Typecho后台看到服务器的基本占用信息。除了这种网站方面的开发，我还自己搞了一个浪潮SA5212M4服务器的风扇动态控制脚本，详情见文章：Python实现浪潮SA5212M4风扇根据温度自动调节最近，我又尝试用Go语言开发了一个校园网自动登录程序，并且能够契合学校上网时段管理规范，详情见文章：断网自动重连！用Go实现广西大学校园网断网自动登录社团活动方面没有参加新的社团，但是在之前参加的社团里面重新变活跃了——因为保研结束了，有一定的空窗时间了。参加了科技协会的电脑义务维修活动帮助科技协会恢复了协会的网站，这算是我对社团最大的贡献了主要是感觉这个社团真玩不起来什么，负责的协会会长天天说什么人才断代了，新生对技术的理解是一代不如一代了。比较让我震惊的是，副会长居然连一些基础的技术概念都不了解。虽然社群还算是又技术氛围，但是那些能讨论技术的基本上都是那几个老登，新人基本上不活跃，聊来聊去都是那几个人，而且大部分在群里面活跃的都毕业了，挺感慨的。明年我也要毕业了，本科生活完结撒花~对生活的看法和感想现在回归一路走过来的历程，我的感受可以用两个字来形容，第一个字是“快”，第二个字是“空”。“快”对于“快”这个字，确实很好理解，四年时光转瞬即逝，光阴似箭，回望高中生活，仿佛就在昨天，初识高中的同学竟然已经是7年前。现在想想是真的吓人，不知不觉就初中毕业了，不知不觉就高中毕业了，不知不觉就要大学毕业了，那未来，是不是不知不觉就研究生了，不知不觉就工作退休了，不知不觉就变老了，不知不觉人就没了。小学初中那会总感觉时间过得很慢，现在在看，太快了，真的太快了。当年的少年，俨然变成了大人的模样，尽管内心还是一个少年。而当年天天哭闹，到处给我爸妈“找麻烦”的弟弟，现在已经上小学三年级了，甚至还向我当年那样，饶有兴致地玩起了GTA......真的是......快！上面这三幅插画手稿，创作的时间点是2017年下半年的秋季学期，也就是我初三的上学期，至今已经过去了8年。不过，这三张插画并不是出自我的手，而是当时初三厚着脸皮让一个女生帮忙画的，现在看感觉有点太无理取闹了，不过或许这就是青春吧。现在在看这些画作，还是能回忆起初三的那个秋天，虽然作画质量一般，肯定是比不过AI出的图片了，但是这或许这种人情味，是AI再怎么精美的画作，也无法替代的吧？之前一直想要扫描这些画作，这次终于是有机会扫描了——再不扫描就越来越烂，越来越褪色了！我放出这几个画作的扫描件，只是想表达一下自己对时间流逝的伤感。年复一年，日复一日，日子就这么过着，也不知道当年的那些同窗，现在怎么样了，不好问，也不敢问——毕竟，已经相隔多年没联系了，各自在忙各自的事情，要聊也不知道聊什么了。顿时印证了鲁迅再《故乡》里面的那句话：我似乎打了一个寒噤；我就知道，我们之间已经隔了一层可悲的厚障壁了。我也说不出话。“空”而对于“空”这个字，可能在外人看来不好理解——在别人看来是本科赢家，绩点卷王，成功上岸酒吧舞，怎么就是“空”了呢？但是这个空，更多的是内心上的空虚——不知道自己真正想要什么，不知道未来要干什么。这里说的想要的和要干的，是更长远的目标，而不是当下一两年时间段的目标，短期的目标，还是有的，没有的话就不可能保研的。这种“空”的感觉，源于我对于人生意义的思考，然后越想越觉得——人生没意义。生老病死是人生常态，“死去元知万事空”，人走了之后，能留下什么吗？你又能带走什么吗？这确实是值得认真思考的问题。或许你思考玩后，也会像我一样，觉得人生没有太大意义，普通人不过是社会运转里面的一个小齿轮，只能按照既定的大框架活动。而你的一生，或许对社会的影响和意义微乎其微——你赚钱了，成家了，离世了，只有和你比较亲近的人才能知道。对于其他人，绝大部分人，毫无感知。或许人生的意义就是安安稳稳，在一个城市，一个固定的圈子过完一生？但是在我看来未免有些太过无聊了，我是希望我的人生更加充实，有趣一点，所以感觉当下的生活真的很“空”。又或许，对于我来说，人生真正有意义的，是过自己想要的生活，而不是被其他人的思想观念左右，干自己真正热爱的事情，认识真正志同道合的朋友，混迹自己真正喜欢的社区，这，才是我想要的生活。可惜现实很骨感，基本上没有太多的机会可以实现这样的生活，只能按照既定的大框架生活，那对我来说，确实就是“空”了用兴趣填补空虚，这算是我目前对抗这种感觉最好的途径了，所以我才会想着搞一个个人网站，学一下建模，玩一些有意思的游戏来体验一下现实中体验不到的世界......对未来的展望展望未来的话，我觉得还是相对比较期待的吧。毕竟准备就要去迎接酒吧舞研究生的生活，能去读酒吧舞，也算是对我当年高考失利最大的弥补了，算是迟到的愿望实现吧。别的就没有太多展望了，只是希望研究生的生活能够顺顺利利完成研究工作，毕业后能够找到薪酬待遇不错的工作，如果足够幸运的话，能够找到一个真正的“二次元”女友，有共同的兴趣爱好，这样的话生活会有趣不少。以及，继续推进我那些兴趣为导向的知识学习：Blender建模，动画制作服务器运维网页前端兴趣向编程和开发，例如学Go和Python年度回顾大概就这么多吧，今天是平安夜，那就祝大家圣诞节快乐！
断网自动重连！用Go实现广西大学校园网断网自动登录
广西大学校园网断网自动重连程序因为广西某211高校校园网有时候会不稳定波动，导致设备掉线。而如果在需要挂机进行一些工作的场合出现这个问题，将会相当蛋疼。再加上在宿舍每天晚上断网后，第二天都要手动重新登录着实有点麻烦，于是乎我就用我入门级中的入门级Go编程能力写了这个程序。程序实现登录方法查找因为学校推荐是使用浏览器打开认真网页进行登录，而经过抓包后，我发现相比于大一的时候，校园网的网页传输数据进行了加密。也就是，如果我要通过逆向网页JavaScript脚本获得登录请求发送逻辑，代价有点大。不过好在你西还是关照到了无GUI Linux服务器使用校园网的问题，在官网挂出了登录方法：linux系统宽带客户端-2024.12.30日后使用这就好办了嘛，只需要构造这个URL请求：http://172.17.0.2:801/eportal/portal/login?callback=dr1003&login_method=1&user_account=账号@cmcc&user_password=密码&wlan_user_ip=终端IP&wlan_user_ipv6=&wlan_user_mac=000000000000&wlan_ac_ip=&wlan_ac_name=&jsVersion=4.2.1&terminal_type=1&lang=zh-cn&v=5574&lang=zh发送给认证服务器就可以登录校园网了。不过我注意到了这个请求URL里面，MAC填写了000000000000，按理来说这部分应该填写设备的MAC，用于保持登录状态和后续无感知登录。这样填写无效的MAC后面大概率会因为校园网波动或者短暂断联掉登录，所以在我的程序逻辑上，这部分应该补上。现在需要发送的校园网认证信息就理清楚了：账号密码线路类型设备IP设备MAC我只需要写一个程序获取到以上信息，然后持续不断判断网络通断状态，当检测到断网后构造认证的URL并发送，就可以实现校园网断网自动重连了。用Go实现校园网自动登录其实之前我就用Python写过一版校园网自动登录程序，但是因为Python的效率和占用确实有点感人了，所以我觉得还是选择编译型语言来进行构建吧。好巧不巧之前学了一些Golang，要不就拿这个试试看？实现思路因为Go是编译型语言，登录的配置就不能像Python那样让用户打开源代码写在对应的变量里面了，得写一个配置文件机制。也就是首次运行创建一个文本的配置文件，然后用户填写了相关的信息，再运行程序，程序从配置文件读取信息并且执行校园网认证和断网自动重连工作。那这样看，实现思路就很清晰了：程序首次运行，输出配置文件模板，提示用户填写用户填写好后，读取配置文件并进行基础的校验，确保参数填写无误实现校园网登录认证的函数，网络通断检测函数，以及上网时段判断函数避免在非上网时段反复尝试重连对于配置文件的部分，我想采用类似ini配置文件的格式，这是模板：# 校园网登录脚本信息设置：（注意请不要改变格式） # 用户名：（填写示例：User=1807210721） User= # 密码：（填写示例：Password=www.nekopara.uk） Password= # 运营商选择，留空选择校园网，如果需要选择运营商，电信填写telecom，联通填写unicom，移动填写cmcc Net_Type= # 是否开启学生上网时段模式？1为开启，0为关闭，开启后周一到周五0:00-6:00将不会尝试重连 Student_Mode=0 # 开启路由器登录模式： # 如果填写以下两个参数（均非空），则使用指定的路由器IP和MAC进行认证。 # 否则使用本机IP和MAC。 # 示例： # Router_IP=172.16.6.6 # Router_MAC=36:88:8A:99:A4:CC Router_IP= Router_MAC=考虑到为了便于小白使用，我输出的是.txt文件，可以直接记事本打开。以及，添加一个命令行启动参数支持，可以让程序作为服务运行和部署，例如：./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -nettype telecom -studentmode -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC现在技术架构想清楚了，开始编程吧！实现代码package main import ( "bufio" "fmt" "os" "strings" "net/http" "net" "net/url" "time" "io" "flag" ) const configFileName = "config.txt" // Config 结构体保存配置 type Config struct { User string Password string NetType string // 新增字段 StudentMode bool // 路由器模式（当两者都非空时启用） RouterIP string RouterMAC string } // loadConfig 加载或创建配置文件 func loadConfig() (*Config, error) { // 检查文件是否存在 if _, err := os.Stat(configFileName); os.IsNotExist(err) { // 创建默认模板 defaultContent := `# 校园网登陆脚本信息设置：（注意请不要改变格式） # 用户名：（填写示例：User=1807210721） User= # 密码：（填写示例：Password=www.nekopara.uk） Password= # 运营商选择，留空选择校园网，如果需要选择运营商，电信填写telecom，联通填写unicom，移动填写cmcc Net_Type= # 是否开启学生上网时段模式？1为开启，0为关闭，开启后周一到周五0:00-6:00将不会尝试重连 Student_Mode=0 # 开启路由器登陆模式： # 如果填写以下两个参数（均非空），则使用指定的路由器IP和MAC进行认证。 # 否则使用本机IP和MAC。 # 示例： # Router_IP=172.16.6.6 # Router_MAC=36:88:8A:99:A4:CC Router_IP= Router_MAC= ` err = os.WriteFile(configFileName, []byte(defaultContent), 0644) if err != nil { return nil, fmt.Errorf("无法创建配置文件: %v", err) } return nil, fmt.Errorf("未找到配置文件，配置文件 '%s' 已创建，请先填写上网信息后重新运行程序", configFileName) } // 读取并解析 content, err := os.ReadFile(configFileName) if err != nil { return nil, fmt.Errorf("无法读取配置文件: %v", err) } cfg := &Config{} scanner := bufio.NewScanner(strings.NewReader(string(content))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // 跳过空行和注释 if line == "" || strings.HasPrefix(line, "#") { continue } // 按第一个 '=' 分割（避免密码含等号出错） parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue // 格式错误，跳过 } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { case "User": cfg.User = value case "Password": cfg.Password = value case "Net_Type": cfg.NetType = value // 新增这一行 case "Student_Mode": cfg.StudentMode = (value == "1") case "Router_IP": cfg.RouterIP = value case "Router_MAC": cfg.RouterMAC = value } } if err := scanner.Err(); err != nil { return nil, err } // 基础校验 if cfg.User == "" || cfg.Password == "" { return nil, fmt.Errorf("请在 '%s' 中填写用户名和密码", configFileName) } // 在 loadConfig 函数中，解析配置后添加： if cfg.NetType != "" { // 检查是否是合法的运营商 valid := false switch strings.ToLower(cfg.NetType) { case "telecom", "unicom", "cmcc": valid = true } if !valid { return nil, fmt.Errorf("错误：运营商类型必须为空、telecom、unicom或cmcc（不区分大小写），当前值: %s", cfg.NetType) } } return cfg, nil } func getLocalIP() (string, error) { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return "", err } defer conn.Close() return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil } func getMACAddress() (string, error) { interfaces, err := net.Interfaces() if err != nil { return "", err } for _, iface := range interfaces { if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { continue } mac := iface.HardwareAddr.String() if mac == "" { continue } addrs, _ := iface.Addrs() for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { return mac, nil } } } return "", fmt.Errorf("no active network interface with MAC found") } func isNetworkOK() bool { client := &http.Client{ Timeout: 1 * time.Second, } resp, err := client.Get("http://connect.rom.miui.com/generate_204") if err != nil { return false // 网络不通 / DNS 故障 / 超时 } defer resp.Body.Close() return resp.StatusCode == 204 } func login(cfg *Config, ip, mac string) { // 格式化 MAC：去掉冒号，转小写（适配你 bash 脚本的行为） cleanMAC := strings.ReplaceAll(strings.ToLower(mac), ":", "") userAccount := cfg.User if cfg.NetType != "" { userAccount = cfg.User + "@" + cfg.NetType } params := url.Values{ "callback": {"dr1003"}, "login_method": {"1"}, "user_account": {userAccount}, "user_password": {cfg.Password}, "wlan_user_ip": {ip}, "wlan_user_mac": {cleanMAC}, "wlan_user_ipv6": {""}, "wlan_ac_ip": {""}, "wlan_ac_name": {""}, "jsVersion": {"4.2.1"}, "terminal_type": {"1"}, "lang": {"zh-cn"}, "v": {"5574"}, } loginURL := "http://172.17.0.2:801/eportal/portal/login?" + params.Encode() resp, err := http.Get(loginURL) if err != nil { fmt.Printf("❌ 登录请求失败: %v\n", err) return } defer resp.Body.Close() // 读取并打印响应体 body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("❌ 读取响应体失败: %v\n", err) return } bodyStr := string(body) fmt.Printf("✅ 已发送登录请求（HTTP状态码: %d）\n", resp.StatusCode) fmt.Printf("响应内容: %s\n", bodyStr) } func shouldSkipLogin(cfg *Config) bool { if !cfg.StudentMode { return false } now := time.Now() weekday := now.Weekday() // Sunday = 0, Monday = 1, ..., Friday = 5 hour := now.Hour() // 周一到周五（1~5），且 0:00 ~ 5:59 if weekday >= time.Monday && weekday <= time.Friday && hour >= 0 && hour < 6 { fmt.Println("🌙 学生模式：当前为禁网时段，暂停重连") return true } return false } func getLoginInfo(cfg *Config) (ip, mac string, err error) { // 如果启用了路由器模式（两个字段都非空） if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Println("🌐 使用路由器模式进行认证") return cfg.RouterIP, cfg.RouterMAC, nil } // 否则使用本机信息 fmt.Println("💻 使用本机模式进行认证") ip, err = getLocalIP() if err != nil { return "", "", fmt.Errorf("获取本机IP失败: %w", err) } mac, err = getMACAddress() if err != nil { return "", "", fmt.Errorf("获取本机MAC失败: %w", err) } return ip, mac, nil } func printHelp() { fmt.Println(`广西大学校园网自动登陆程序参数说明： 必须参数： -user 用户名（必须提供） -passwd 密码（必须提供） 可选参数： -nettype 运营商类型（telecom, unicom, cmcc），不加参数则使用校园网 -studentmode 启用学生模式（不带值） -ip 路由器IP（必须与-mac一起使用） -mac 路由器MAC（必须与-ip一起使用） -help 显示此帮助信息 示例（Linux）： ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword /opt/GXU_Net_AutoLogin/GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -nettype telecom -studentmode ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC 示例（Windows）： GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -nettype telecom -studentmode C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC `) } func main() { fmt.Printf("🚀广西大学校园网自动登陆程序 By：GTX690战术核显卡导弹（www.nekopara.uk）\n") // 定义命令行参数 var ( user string passwd string nettype string studentMode bool ip string mac string help bool ) flag.StringVar(&user, "user", "", "用户名") flag.StringVar(&passwd, "passwd", "", "密码") flag.StringVar(&nettype, "nettype", "", "运营商类型（telecom, unicom, cmcc）") flag.BoolVar(&studentMode, "studentmode", false, "启用学生模式") flag.StringVar(&ip, "ip", "", "路由器IP（必须与-mac一起使用）") flag.StringVar(&mac, "mac", "", "路由器MAC（必须与-ip一起使用）") flag.BoolVar(&help, "help", false, "显示帮助信息") flag.Parse() // 显示帮助信息 if help { printHelp() os.Exit(0) } // 检查必须参数 if (user == "" && passwd == "") { // 从配置文件加载 cfg, err := loadConfig() if err != nil { fmt.Println("❌ 错误:", err) fmt.Println("💡 请编辑 config.txt 后重新运行本程序。") os.Exit(1) } fmt.Printf("✅ 配置加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 修复：将配置文件中的值赋给命令行变量 user = cfg.User passwd = cfg.Password nettype = cfg.NetType studentMode = cfg.StudentMode ip = cfg.RouterIP mac = cfg.RouterMAC } else if user != "" && passwd != "" { // 从命令行参数加载 cfg := &Config{ User: user, Password: passwd, NetType: nettype, StudentMode: studentMode, RouterIP: ip, RouterMAC: mac, } // 校验运营商类型 if nettype != "" { valid := false switch strings.ToLower(nettype) { case "telecom", "unicom", "cmcc": valid = true } if !valid { fmt.Printf("❌ 错误：运营商类型必须为telecom, unicom, cmcc（不区分大小写），当前值: %s\n", nettype) os.Exit(1) } } // 校验路由器IP/MAC if (ip != "" && mac == "") || (ip == "" && mac != "") { fmt.Println("❌ 错误：必须同时提供ip和mac参数，两者缺一不可") os.Exit(1) } // 显示配置 fmt.Printf("✅ 命令行参数加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 使用命令行参数配置 cfg.User = user cfg.Password = passwd cfg.NetType = nettype cfg.StudentMode = studentMode cfg.RouterIP = ip cfg.RouterMAC = mac } else { // 只提供了其中一个参数 fmt.Println("❌ 错误：必须同时提供user和passwd参数，或者都不提供（通过配置文件）") fmt.Println("💡 请使用 -help 查看参数说明") os.Exit(1) } // 获取用于登录的 IP 和 MAC（自动判断模式） ipAddr, macAddr, err := getLoginInfo(&Config{ RouterIP: ip, RouterMAC: mac, }) if err != nil { fmt.Printf("❌ %v\n", err) os.Exit(1) } fmt.Printf("✅ 守护进程启动：认证IP=%s | 认证MAC=%s\n", ipAddr, macAddr) // 主循环 for { if shouldSkipLogin(&Config{ StudentMode: studentMode, }) { time.Sleep(1 * time.Second) continue } if !isNetworkOK() { fmt.Println("⚠️ 检测到断网，正在重新登录...") login(&Config{ User: user, Password: passwd, NetType: nettype, }, ipAddr, macAddr) } time.Sleep(1 * time.Second) } }代码详细解释下面我将详细解释代码中每个函数的作用，帮助你理解这个校园网自动重连程序的工作原理。这些函数都是程序的核心逻辑，我将按照从配置加载到网络检测再到认证的流程进行说明。1. loadConfig()func loadConfig() (*Config, error) { // 检查文件是否存在 if _, err := os.Stat(configFileName); os.IsNotExist(err) { // 创建默认模板 defaultContent := `# 校园网登录脚本信息设置：（注意请不要改变格式） # 用户名：（填写示例：User=1807210721） User= # 密码：（填写示例：Password=www.nekopara.uk） Password= # 运营商选择，留空选择校园网，如果需要选择运营商，电信填写telecom，联通填写unicom，移动填写cmcc Net_Type= # 是否开启学生上网时段模式？1为开启，0为关闭，开启后周一到周五0:00-6:00将不会尝试重连 Student_Mode=0 # 开启路由器登录模式： # 如果填写以下两个参数（均非空），则使用指定的路由器IP和MAC进行认证。 # 否则使用本机IP和MAC。 # 示例： # Router_IP=172.16.6.6 # Router_MAC=36:88:8A:99:A4:CC Router_IP= Router_MAC= ` err = os.WriteFile(configFileName, []byte(defaultContent), 0644) if err != nil { return nil, fmt.Errorf("无法创建配置文件: %v", err) } return nil, fmt.Errorf("未找到配置文件，配置文件 '%s' 已创建，请先填写上网信息后重新运行程序", configFileName) } // 读取并解析 content, err := os.ReadFile(configFileName) if err != nil { return nil, fmt.Errorf("无法读取配置文件: %v", err) } cfg := &Config{} scanner := bufio.NewScanner(strings.NewReader(string(content))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // 跳过空行和注释 if line == "" || strings.HasPrefix(line, "#") { continue } // 按第一个 '=' 分割（避免密码含等号出错） parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue // 格式错误，跳过 } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) switch key { case "User": cfg.User = value case "Password": cfg.Password = value case "Net_Type": cfg.NetType = value // 新增这一行 case "Student_Mode": cfg.StudentMode = (value == "1") case "Router_IP": cfg.RouterIP = value case "Router_MAC": cfg.RouterMAC = value } } if err := scanner.Err(); err != nil { return nil, err } // 基础校验 if cfg.User == "" || cfg.Password == "" { return nil, fmt.Errorf("请在 '%s' 中填写用户名和密码", configFileName) } // 在 loadConfig 函数中，解析配置后添加： if cfg.NetType != "" { // 检查是否是合法的运营商 valid := false switch strings.ToLower(cfg.NetType) { case "telecom", "unicom", "cmcc": valid = true } if !valid { return nil, fmt.Errorf("错误：运营商类型必须为空、telecom、unicom或cmcc（不区分大小写），当前值: %s", cfg.NetType) } } return cfg, nil }函数解释：loadConfig() 是程序的配置加载核心函数。它的作用是检查是否存在配置文件，如果不存在则创建一个默认配置文件模板，让用户填写必要信息；如果存在则解析配置文件内容并返回配置结构体。关键点：首次运行时会创建config.txt文件，包含详细的填写说明使用bufio.Scanner逐行解析配置文件，跳过注释和空行对配置项进行基础校验，确保用户名和密码必填对运营商类型进行合法性检查（必须是telecom、unicom或cmcc）如果配置不合法，会返回错误信息提示用户这个函数是程序与用户交互的第一步，也是保证后续程序能正确运行的基础。2. getLocalIP()func getLocalIP() (string, error) { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return "", err } defer conn.Close() return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil }函数解释：getLocalIP() 用于获取当前设备的本地IP地址。这个函数通过与公共DNS服务器（8.8.8.8）建立UDP连接，然后获取连接的本地地址。关键点：使用net.Dial建立UDP连接，连接到Google DNS服务器（8.8.8.8:80）通过conn.LocalAddr()获取连接的本地IP地址这是获取本机IP的常用方法，避免了遍历所有网络接口的复杂性返回的IP地址是字符串格式，例如"192.168.1.100"这个函数在获取本机IP时非常实用，避免了手动遍历网络接口的麻烦。3. getMACAddress()func getMACAddress() (string, error) { interfaces, err := net.Interfaces() if err != nil { return "", err } for _, iface := range interfaces { if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { continue } mac := iface.HardwareAddr.String() if mac == "" { continue } addrs, _ := iface.Addrs() for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { return mac, nil } } } return "", fmt.Errorf("no active network interface with MAC found") }函数解释：getMACAddress() 用于获取当前设备的MAC地址。它会遍历所有网络接口，找到第一个非回环、启用的接口，并返回其MAC地址。关键点：使用net.Interfaces()获取所有网络接口信息过滤掉回环接口（FlagLoopback）和未启用的接口（FlagUp）从接口的硬件地址（HardwareAddr）获取MAC地址遍历接口的IP地址，确保返回的是有效网络接口的MAC返回的MAC格式如"36:88:8A:99:A4:CC"这个函数在获取本机MAC地址时非常关键，因为校园网认证需要MAC地址来保持登录状态。4. isNetworkOK()func isNetworkOK() bool { client := &http.Client{ Timeout: 1 * time.Second, } resp, err := client.Get("http://connect.rom.miui.com/generate_204") if err != nil { return false // 网络不通 / DNS 故障 / 超时 } defer resp.Body.Close() return resp.StatusCode == 204 }函数解释：isNetworkOK() 用于检测当前网络连接状态。它通过HTTP请求访问一个特殊URL（http://connect.rom.miui.com/generate_204），根据响应状态码判断网络是否通畅。关键点：使用http.Client发送GET请求，设置1秒超时请求的URL是MIUI系统用于检测网络连接的特殊地址如果响应状态码为204，表示网络通畅；否则表示断网这个检测方法非常轻量，不会产生太多网络流量通过defer resp.Body.Close()确保资源正确释放这个函数是程序检测断网的核心，决定了是否需要触发重连逻辑。5. login()func login(cfg *Config, ip, mac string) { // 格式化 MAC：去掉冒号，转小写（适配你 bash 脚本的行为） cleanMAC := strings.ReplaceAll(strings.ToLower(mac), ":", "") userAccount := cfg.User if cfg.NetType != "" { userAccount = cfg.User + "@" + cfg.NetType } params := url.Values{ "callback": {"dr1003"}, "login_method": {"1"}, "user_account": {userAccount}, "user_password": {cfg.Password}, "wlan_user_ip": {ip}, "wlan_user_mac": {cleanMAC}, "wlan_user_ipv6": {""}, "wlan_ac_ip": {""}, "wlan_ac_name": {""}, "jsVersion": {"4.2.1"}, "terminal_type": {"1"}, "lang": {"zh-cn"}, "v": {"5574"}, } loginURL := "http://172.17.0.2:801/eportal/portal/login?" + params.Encode() resp, err := http.Get(loginURL) if err != nil { fmt.Printf("❌ 登录请求失败: %v\n", err) return } defer resp.Body.Close() // 读取并打印响应体 body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("❌ 读取响应体失败: %v\n", err) return } bodyStr := string(body) fmt.Printf("✅ 已发送登录请求（HTTP状态码: %d）\n", resp.StatusCode) fmt.Printf("响应内容: %s\n", bodyStr) }函数解释：login() 是校园网认证的核心函数，负责构造认证请求并发送给认证服务器。关键点：对MAC地址进行格式化：去掉冒号，转为小写根据运营商类型（NetType）构造用户名（例如1807210721@cmcc）使用url.Values构建查询参数，确保参数编码正确拼接完整的认证URL（http://172.17.0.2:801/...）发送HTTP GET请求到认证服务器打印请求结果和响应内容，便于调试通过params.Encode()正确编码URL参数这个函数实现了校园网认证的关键逻辑，是整个程序的核心功能。6. shouldSkipLogin()func shouldSkipLogin(cfg *Config) bool { if !cfg.StudentMode { return false } now := time.Now() weekday := now.Weekday() // Sunday = 0, Monday = 1, ..., Friday = 5 hour := now.Hour() // 周一到周五（1~5），且 0:00 ~ 5:59 if weekday >= time.Monday && weekday <= time.Friday && hour >= 0 && hour < 6 { fmt.Println("🌙 学生模式：当前为禁网时段，暂停重连") return true } return false }函数解释：shouldSkipLogin() 用于判断在特定时段是否应该跳过登录尝试。这个函数实现了"学生模式"，即在周一到周五的0:00-6:00期间不尝试重连。关键点：仅在StudentMode为true时生效获取当前时间的星期几和小时判断是否为周一至周五的凌晨时段（0:00-5:59）如果是禁网时段，打印提示并返回true，表示应该跳过登录否则返回false，表示可以尝试登录这个函数是为了解决学生账号在凌晨时段断网后反复尝试自动重连的问题，避免持续不断发起无意义的请求。7. getLoginInfo()func getLoginInfo(cfg *Config) (ip, mac string, err error) { // 如果启用了路由器模式（两个字段都非空） if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Println("🌐 使用路由器模式进行认证") return cfg.RouterIP, cfg.RouterMAC, nil } // 否则使用本机信息 fmt.Println("💻 使用本机模式进行认证") ip, err = getLocalIP() if err != nil { return "", "", fmt.Errorf("获取本机IP失败: %w", err) } mac, err = getMACAddress() if err != nil { return "", "", fmt.Errorf("获取本机MAC失败: %w", err) } return ip, mac, nil }函数解释：getLoginInfo() 用于确定认证时使用的IP和MAC地址。它会根据配置决定是使用路由器的IP/MAC还是本机的IP/MAC。关键点：如果配置了Router_IP和Router_MAC，则使用路由器的IP和MAC否则，使用本机的IP和MAC（通过调用getLocalIP()和getMACAddress()）返回的IP和MAC格式符合校园网认证要求在使用路由器模式时，会打印提示信息这个函数是程序灵活应对不同网络环境和部署需求的关键，既适配在教室本机连接校园网时的认证，又适配在宿舍使用路由器共享上网的场景。8. printHelp()func printHelp() { fmt.Println(`广西大学校园网自动登录程序参数说明： 必须参数： -user 用户名（必须提供） -passwd 密码（必须提供） 可选参数： -nettype 运营商类型（telecom, unicom, cmcc），不加参数则使用校园网 -studentmode 启用学生模式（不带值） -ip 路由器IP（必须与-mac一起使用） -mac 路由器MAC（必须与-ip一起使用） -help 显示此帮助信息 示例（Linux）： ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword /opt/GXU_Net_AutoLogin/GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -nettype telecom -studentmode ./GXU_Net_AutoLogin -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC 示例（Windows）： GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -nettype telecom -studentmode C:\\Program Files\\GXU_Net_AutoLogin\\GXU_Net_AutoLogin.exe -user 1807210721 -passwd mypassword -ip 172.16.6.6 -mac 36:88:8A:99:A4:CC `) }函数解释：printHelp() 用于打印程序的使用帮助信息，包括参数说明和使用示例。关键点：提供了详细的参数说明，包括必须参数和可选参数列举了Linux和Windows平台的使用示例清晰地说明了各参数的作用便于用户快速了解如何使用程序这个函数在用户输入-help参数时调用，是程序的文档部分，帮助用户正确使用程序。9. main()func main() { fmt.Printf("🚀广西大学校园网自动登陆程序 By：GTX690战术核显卡导弹（www.nekopara.uk）\n") // 定义命令行参数 var ( user string passwd string nettype string studentMode bool ip string mac string help bool ) flag.StringVar(&user, "user", "", "用户名") flag.StringVar(&passwd, "passwd", "", "密码") flag.StringVar(&nettype, "nettype", "", "运营商类型（telecom, unicom, cmcc）") flag.BoolVar(&studentMode, "studentmode", false, "启用学生模式") flag.StringVar(&ip, "ip", "", "路由器IP（必须与-mac一起使用）") flag.StringVar(&mac, "mac", "", "路由器MAC（必须与-ip一起使用）") flag.BoolVar(&help, "help", false, "显示帮助信息") flag.Parse() // 显示帮助信息 if help { printHelp() os.Exit(0) } // 检查必须参数 if (user == "" && passwd == "") { // 从配置文件加载 cfg, err := loadConfig() if err != nil { fmt.Println("❌ 错误:", err) fmt.Println("💡 请编辑 config.txt 后重新运行本程序。") os.Exit(1) } fmt.Printf("✅ 配置加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 修复：将配置文件中的值赋给命令行变量 user = cfg.User passwd = cfg.Password nettype = cfg.NetType studentMode = cfg.StudentMode ip = cfg.RouterIP mac = cfg.RouterMAC } else if user != "" && passwd != "" { // 从命令行参数加载 cfg := &Config{ User: user, Password: passwd, NetType: nettype, StudentMode: studentMode, RouterIP: ip, RouterMAC: mac, } // 校验运营商类型 if nettype != "" { valid := false switch strings.ToLower(nettype) { case "telecom", "unicom", "cmcc": valid = true } if !valid { fmt.Printf("❌ 错误：运营商类型必须为telecom, unicom, cmcc（不区分大小写），当前值: %s\n", nettype) os.Exit(1) } } // 校验路由器IP/MAC if (ip != "" && mac == "") || (ip == "" && mac != "") { fmt.Println("❌ 错误：必须同时提供ip和mac参数，两者缺一不可") os.Exit(1) } // 显示配置 fmt.Printf("✅ 命令行参数加载成功！\n") fmt.Printf("用户: %s\n", cfg.User) fmt.Printf("密码: %s\n", cfg.Password) fmt.Printf("运营商: %s\n", cfg.NetType) fmt.Printf("学生模式: %t\n", cfg.StudentMode) if cfg.RouterIP != "" && cfg.RouterMAC != "" { fmt.Printf("路由器模式: IP=%s, MAC=%s\n", cfg.RouterIP, cfg.RouterMAC) } // 使用命令行参数配置 cfg.User = user cfg.Password = passwd cfg.NetType = nettype cfg.StudentMode = studentMode cfg.RouterIP = ip cfg.RouterMAC = mac } else { // 只提供了其中一个参数 fmt.Println("❌ 错误：必须同时提供user和passwd参数，或者都不提供（通过配置文件）") fmt.Println("💡 请使用 -help 查看参数说明") os.Exit(1) } // 获取用于登录的 IP 和 MAC（自动判断模式） ipAddr, macAddr, err := getLoginInfo(&Config{ RouterIP: ip, RouterMAC: mac, }) if err != nil { fmt.Printf("❌ %v\n", err) os.Exit(1) } fmt.Printf("✅ 守护进程启动：认证IP=%s | 认证MAC=%s\n", ipAddr, macAddr) // 主循环 for { if shouldSkipLogin(&Config{ StudentMode: studentMode, }) { time.Sleep(1 * time.Second) continue } if !isNetworkOK() { fmt.Println("⚠️ 检测到断网，正在重新登录...") login(&Config{ User: user, Password: passwd, NetType: nettype, }, ipAddr, macAddr) } time.Sleep(1 * time.Second) } }函数解释：main() 是程序的主入口函数，负责处理命令行参数、加载配置、启动守护进程并持续监控网络状态。关键点：使用flag包解析命令行参数支持两种配置方式：通过配置文件或命令行参数对配置进行校验，确保必要参数存在且格式正确根据配置决定使用路由器模式还是本机模式启动主循环，每秒检查网络状态当检测到断网时，调用login()函数进行重连在学生模式下，避免在特定时段重连这个函数是整个程序的"大脑"，协调所有其他函数的工作，实现断网自动重连的核心功能。使用方法你既可以从上面的源代码编译使用，也可以下载我编译好的程序。如果不放心我分发的二进制文件，要自己编译来用的话，把上面那段完整的代码保存为main.go，安装好Go语言环境，然后进行编译：go build -ldflags="-s -w" -o GXU_Net_AutoLogin main.go 或者直接运行也行：go run main.go二进制文件的话，我就直接只编译了win32的程序：GXU_Net_AutoLogin.zip对于Linux的话，我相信使用Linux的你应该知道怎么配置Go环境，源代码放在这了，你就自己编译去吧（其实真不难）。或许可以用wine运行？（bushi补充：其他常见系统架构文件已编译，文件位于Github的Release相关源代码已经放到Github仓库：github.com/Chocola-X/GXU-Net-AutoLogin
阿里云 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信息并更新数据库了。
解决PyTorch CUDA报错：no kernel image is available for execution on the device
修复GTX 10系显卡no kernel image报错今天想试试看P104-100玩AI画图怎么样，结果遇到了PyTorch相关的报错，解决后感觉也挺有代表性的，写篇文章简单记录一下。问题复现尝试使用P104-100跑ComfyUI玩，结果收到了报错：torch.AcceleratorError: CUDA error: no kernel image is available for execution on the device Search for `cudaErrorNoKernelImageForDevice' in https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__TYPES.html for more information. CUDA kernel errors might be asynchronously reported at some other API call, so the stacktrace below might be incorrect. For debugging consider passing CUDA_LAUNCH_BLOCKING=1 Compile with `TORCH_USE_CUDA_DSA` to enable device-side assertions.我就觉得纳闷，为什么同样的驱动版本，都是580版本的驱动，V100不报错，但是P104-100报错了。然后我运行了检查GPU compute capability的代码，得到结果如下：print(torch.cuda.get_device_properties(0)) /home/rin/.local/lib/python3.12/site-packages/torch/cuda/__init__.py:283: UserWarning: Found GPU0 NVIDIA P104-100 which is of cuda capability 6.1. Minimum and Maximum cuda capability supported by this version of PyTorch is (7.0) - (12.0) warnings.warn( /home/rin/.local/lib/python3.12/site-packages/torch/cuda/__init__.py:304: UserWarning: Please install PyTorch with a following CUDA configurations: 12.6 following instructions at https://pytorch.org/get-started/locally/ warnings.warn(matched_cuda_warn.format(matched_arches)) /home/rin/.local/lib/python3.12/site-packages/torch/cuda/__init__.py:283: UserWarning: Found GPU1 NVIDIA P104-100 which is of cuda capability 6.1. Minimum and Maximum cuda capability supported by this version of PyTorch is (7.0) - (12.0) warnings.warn( /home/rin/.local/lib/python3.12/site-packages/torch/cuda/__init__.py:326: UserWarning: NVIDIA P104-100 with CUDA capability sm_61 is not compatible with the current PyTorch installation. The current PyTorch install supports CUDA capabilities sm_70 sm_75 sm_80 sm_86 sm_90 sm_100 sm_120. If you want to use the NVIDIA P104-100 GPU with PyTorch, please check the instructions at https://pytorch.org/get-started/locally/ warnings.warn( _CudaDeviceProperties(name='NVIDIA P104-100', major=6, minor=1, total_memory=8109MB, multi_processor_count=15, uuid=eb87a003-8cda-0e4c-b06d-768185c4dc13, pci_bus_id=1, pci_device_id=0, pci_domain_id=0, L2_cache_size=2MB)这下就清楚了，V100不报错是因为其cuda capability为7.0，正好赶上末班车，而10系显卡的Pascal架构cuda capability为6.1，刚好被抛弃了。解决方法也很简单了，那就是去找出来最后支持cuda capability为6.1的PyTorch版本。修复报错查找了资料后，我得知PyTorch的2.7.1版本是最后支持Pascal架构的版本，我们只需要卸载当前的PyTorch，降级为2.7.1即可。pip3.12 uninstall torch torchvision torchaudio pip3.12 install torch==2.7.1 torchvision==0.22.1 torchaudio==2.7.1然后在尝试运行print(torch.cuda.get_device_properties(0))验证一下，应该就不报错了：print(torch.cuda.get_device_properties(0)) _CudaDeviceProperties(name='NVIDIA P104-100', major=6, minor=1, total_memory=8109MB, multi_processor_count=15, uuid=eb87a003-8cda-0e4c-b06d-768185c4dc13, L2_cache_size=2MB)如果得到这样的结果，comfyUI应该也可以正常运行了。最后我补充说明一下我的软件版本环境：Python 3.12ComfyUI 0.3.76
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曲子听不了搞得我红温（
自制二次元主题显卡：猫娘乐园巧克力 RTX 2080 Ti
给显卡换上自己最爱的猫娘乐园巧克力主题说实话，这件事我老早就想做了——因为我的显卡是RTX 2080 Ti，正巧因为是涡轮卡，所以可以覆盖贴纸的面积很大。正巧咸鱼看到了便宜的背板——25包邮的映众RTX2080Ti背板：关键还是全新！于是，一拍即合，我拿下了背板，开始设计显卡皮肤。测量显卡尺寸背板到货后，我先用A3纸放在显卡背板下面，然后用针管笔描出背板的轮廓，并点出螺丝的孔位。然后再拆开显卡的导风罩（因为我是涡轮卡），将导风罩放在A3纸上面，固定好位置依次描边，描了三个面，描出了它的展开图。然后使用扫描仪进行扫描得出准确的设计参考轮廓图，扫描时设置的分辨率为300PPI。下面是扫描原稿：当然了，因为是用要贴贴纸的目标面按在纸上面描的，所以和贴纸的设计轮廓是完全相反的，所以要进行镜像处理，把描边的线稿翻转过来，才是正确的。寻找风格参考图因为我并没有设计过显卡皮肤贴纸，所以我决定从案例借鉴作为切入点，去找适合我的参考风格。在咸鱼高强度刷显卡个性化贴纸相关的页面，看了几十个案例后，有一个案例我觉得是可以参考和借鉴的，就是这个：为什么这么说呢，因为总体比较简单，画面不复杂，意味着我的设计的工作量会比较少。虽然我肯定是喜欢更华丽的画面，有一些别的显卡皮肤也非常的好看，我也想做出来，但是奈何技术水平和设计能力也就那样了，这种简单好看的设计对我来说更有可操作性——用简单的画面换取还不错的设计效果。解构这个设计稿，组成非常简单：角色立绘+配色色块+角色名字艺术字+作品LOGO所以，照着葫芦画瓢，我要做一个猫娘乐园巧克力主题的显卡，只需要搞到这几个要素，将其根据实际的设计尺寸组合起来，确保符合自己的审美和尺寸构图，让角色尽可能避开一些螺丝孔的位置，就可以了。开始设计我打算先从背板开始设计。背板贴纸设计万事开头难，这个设计工作一开始就犯难了——找不到特别合适的立绘图：因为猫娘乐园好看的同人图本来就很少，基本上要好看的只能解包游戏拿CG图，但是游戏的CG图始终是有限的，并没有多少。找来找去，我还是觉得用这张图比较合适：算是游戏CG里面很好看的一张图了，然后交给AI抠图，把角色扣出来：这样子，立绘就有了。本来我像在另一边把香草也放上的，但是放上后感觉构图混乱，也难以驾驭，最终还是践行了建筑大师路德维希·密斯·凡德罗的观点：“Less is more”——“少即是多”，只保留了巧克力，把香草去掉了（呜呜呜我的香草）。然而，接着往下设计，我发现这样子反而更好驾驭，因为就可以直接使用巧克力的主题色——粉色，然后使用整个粉色系装饰显卡：和参考的案例一样。调好缩放大小，只保留了角色上半身，避开螺丝孔后，得到了初步设计的方案：画面的粉色是直接从角色的衣服上提取的颜色，确保了整体的协调，有一说一，粉色配白色还挺搭配的。但是感觉画面空空的，好像少了点什么。然后继续观察参考图，发现是一些小细节元素没有没有，导致整个画面看起来很空，于是，我加上了系列名称的艺术字，再加上猫爪和星星等可爱风格元素，并且针对性的减淡调色，确保只是作为装饰，而不抢占视觉注意力。另外，我看到很多二次元风的排版都喜欢用角色立绘做成半透明减淡后叠在背景增强效果，我也试了一下，感觉这个方法还是挺好用的，这是最终的画面效果：就算是放当作背景的半透明减淡立绘，我也尽可能避免角色的脸落到螺丝孔的位置，不然看着自己喜欢的角色脸上被打个洞确实不爽。导风罩贴纸设计一开始其实我也想再弄一个立绘，但是翻遍了解包的CG图，似乎没有合适的了。正巧有一大堆Q版立绘，不如拿去试试。结果发现意外的合适，而且背板是角色立绘，正面散热器上是Q版立绘，也能形成差别和对比，效果还是不错的。再设计的时候，我特地把背板的设计稿放在对应位置的上面，确保能让整体设计协调，先设计个初版看看效果：细心的读者应该发现了，颜色分割线是延续了下来的，这就是我保证整体设计协调的手段之一。另外，粉色色块区域正好位于涡轮进风口，能削弱一下与内部黑色涡轮扇的颜色对比。后面考虑到粉色块太大可能有点单调，做了一下切分，最右边又露出来白色。最后像背板那样如法炮制，加上一点细节，导风罩的设计稿就完工了：哦，忘了说了，作为自由软件运动的拥护者，这个设计稿是使用GIMP制作的。如果你碰巧有顶部供电的涡轮卡RTX2080Ti，并且喜欢Chocola的话，我的设计稿你可以拿去直接用：RTX2080Ti_Dseign.7z解压密码是：www.nekopara.uk打印贴纸不得不说，这种小量打印订单是真的贵！问了一下群里面做广告相关行业的群友，他说最适合的材料是车贴，基本上耐受显卡六七十度的高温没什么问题。但是，问题来了，去淘宝定制车贴，居然一张A3大小的车贴要收我40块钱？！怎么不去抢？！而且找了四五个店铺，都差不多这个价……关键是，贵就算了，找了一家店铺，客服谈好后下单就没下文了，说设计会很快联系，然后过了一天没联系我，给我整红温了。后面和做广告行业的群友聊，他问我急不急用，不急的话可以用边角料帮我印，出个运费就行。我寻思着反正我也不急，就马上给淘宝那边取消订单，给群友发了设计稿，过了两周，终于发货了。还是群友好（）贴贴纸有一说一，虽然我已经预料到会有色差，但是没想到色差居然会这么大——粉色都快变成紫色了（相比之下我的L14158打印机配合铜版纸打印出来的设计稿就基本上没什么色差，当时我自己也打印了，用于验证大小是否合适。照片中右上角露出一点点的那个是铜版纸打印的，可以很明显看出来色差，右边下面那个是普通A3纸打印的，色差也很大，相比之下铜版纸简直是杀疯了。不过考虑到是群友送的，不能要求太多，将就用吧。贴好贴纸后：不得不说，挖出螺丝孔位真的需要很有耐心并且细心。接下来就是贴导风罩（第一次贴还贴废了一张）：同样的，小心地挖出对应的孔位：顺带清理显卡反正都拆开了，不如给显卡顺带来一次大保健，然后打开显卡中框的一瞬间，我傻眼了——去年换的，号称耐久5年，自称华为供应商的导热垫，碎的稀巴烂！避坑避坑！这玩意还不便宜，现在给我留了一个烂摊子，还不好清理！花了一点时间，又是手指甲扣，又是牙签挑，又是刷子刷，终于清理干净了。好巧不巧正好买了V100-SXM2，来对比一下核心大小：当年的NVIDIA旗舰芯片GV100和TU102居然能在我手里同框，当时可是想都不敢想。相比之下，TU102的754 mm²核心面积就是弟弟了，GV100因为有HBM2显存，整个都大了一圈，其核心也不小，达到了815 mm²。清理完成后，贴上群友RTX 4090清灰用剩的莱德尔HD90000，然后开始组装显卡。顺带提一嘴，涂的硅脂——信越7921，也是群友用剩的，能用RTX 4090的富哥确实大方。效果展示花了一点时间，把显卡装了起来，下面就是成品展示了。感觉可能是教室灯光问题，也有可能是贴纸拆出来用了一段时间后自然氧化变色，后面再看颜色正常了一些：后记：做完成品才发现，显卡系列的单词拼写错了，GeForce不小心拼成GeFroce了 QAQ ...... 这下变成独一无二的"GeFroce"显卡了（X
当手机“反客为主”：是工具，还是牢笼？文章开头先叠甲：文章仅代表个人观点，如果你有不同的思考也欢迎评论区讨论。近日听闻国内手机厂商计划进一步限制系统级 VPN 功能，不禁感慨：我们手中的智能手机，究竟是解放我们的工具，还是精心设计的牢笼？作为一个见证过开放互联网余晖和亲历过安卓手机开放时代的人，我想记录下对这一“日常必需品”的深层思考——你的手机，真的属于你吗？你的手机真的属于你吗？如果是普通不太了解手机技术细节的读者，或许看到这个标题会纳闷：我自己购买的手机，我自己使用，为什么不属于我？但是，很遗憾，对于绝大部分读者而言，你的手机就是不属于你。除非……你解锁了手机的Bootloader。Bootloader：手机的第0道枷锁首先，既然提到了Bootloader这个词，那就先解释一下这是什么东西：手机 Bootloader 是固化在 ROM 中的开机引导程序，开机后优先运行。核心功能是初始化手机硬件（如处理器、内存），验证系统完整性，随后引导操作系统启动。默认通常锁定，防止未授权修改；解锁后可刷机、安装第三方系统，但可能影响设备保修与安全性，是手机启动和系统定制的关键环节。正如介绍那样，默认Bootloader是锁定状态，也就是无法更改手机的操作系统。虽然手机厂商美名其曰提高系统安全性，防止未授权修改；但是，如果手机厂商做的手机操作系统本身就是一坨狗屎，那你也会因为这个锁定的Bootloader被套牢——无法自行更换其他的操作系统。这是正是手机厂商胆敢其在用户头上拉屎的一个重要原因，尤其是国内厂商，因为这坨屎你吃定了，除非你能解锁Bootloader，要么就换手机。而所谓的未授权修改，如果我是机主本人，希望修改呢？我的设备连修改的权利都没有了吗？对于相当一部分不支持用户解锁Bootloader的厂商而言，就是没有——例如华为。更加雪上加霜的是，近年来曾经允许用户自由解锁Bootloader进行折腾的手机厂商也在收紧——例如小米。当然了，如果是苹果用户，那就是地牢，没有任何硬件和操作系统层面的自由，基本上没办法解锁，甚至不能自己安装软件。这些Bootloader限制，完完全全就是厂商故意干的，因为原本的安卓系统，只需要在设置里面进入开发者选项打开OEM解锁选项，重启进fastboot，连接电脑后执行fastboot oem unlock就可以解锁手机的Bootloader并开始愉快的玩耍了。所谓什么未授权修改完全不成立，因为这是手机用户自己的行为，他们完全有对自己设备的处置权。而很多手机厂商，特别是国内手机厂商，把这个最基本的用户权利给剥夺了。他们打着保护用户数据和隐私，优化用户体验等名号，故意限制用户拥有设备的完整控制权，将用户圈养和绑死在自家煞费苦心搞出来的广告ROM里面使劲割韭菜，属实龌龊！题外话：有一说一，户晨风在贬低安卓吹捧苹果，说什么安卓人，苹果人，也不完全是空穴来风；因为对于普通不会折腾的用户而言，同样是绑死生态和系统，苹果手机的使用体验确实吊打了安卓几条街。但是，如果你真会折腾和优化安卓，那将爆杀苹果。ARM 生态：手机的第1道枷锁或许你会觉得，只要解锁了 Bootloader，就能像装电脑系统一样自由更换操作系统了吧？很遗憾，手机的硬件通用性一坨狗屎，这几乎是个奢望。 手机的硬件生态高度碎片化，系统专机专用——每个型号都必须刷对应的定制 ROM，稍有不慎就是黑砖。你根本没法像在 PC 上那样，想装什么系统就装什么系统，Windows，GNU/Linux，Unix，甚至MacOS，想换就换。 所以，就算你费尽千辛万苦解锁了 Bootloader，如果没有开发者为你这台设备专门适配系统包，你依然只能被迫继续吃厂商预制的狗屎 ROM。当然，既然都解锁了，root 是必须的——把那些藏在系统深处的云控组件、后台监控、性能限制统统扬了，体验才能好一大截。那么，为什么手机就不能像电脑一样通用？ 问题不在 ARM 架构本身，而在围绕 ARM 构建的整个封闭生态。 ARM 作为一种指令集架构，本质上只是定义了 CPU 如何执行指令，它本身并不阻止系统通用。真正的问题在于：手机厂商使用高度定制化的 SoC（系统级芯片），集成自家或供应商的基带、GPU、ISP、电源管理模块等；这些关键组件的驱动程序几乎全部闭源，且只提供给特定 Android 版本；没有统一的固件标准（比如 PC 上的 UEFI/ACPI），导致操作系统无法跨设备识别和调用硬件；厂商有意为之：一个机型一个 ROM，既能控制用户体验，又能制造“升级换代”的刚需。于是，明明是同一套 Android 底层，每家厂商、每款机型都要单独维护一套系统镜像，用户则被牢牢绑死在厂商的更新节奏和功能取舍上。这不是技术限制，这是人为制造的割裂。反观 x86 PC 生态，尽管也有驱动兼容性问题（比如某些新显卡不支持老系统），但得益于开放的 BIOS/UEFI 接口、标准化的 PCIe/USB 协议，以及社区驱动的努力，一台普通电脑几乎可以安装任何主流 GNU/Linux 发行版。即便微软有时故意拖延旧系统驱动支持，整体生态仍远比手机开放得多。作为一个从 PC 走进数字世界的人，我对这种“伪通用、真绑定”的 ARM 消费电子生态深感厌恶——不是讨厌 ARM，而是讨厌借 ARM 之名行封闭之实的厂商逻辑。有时候我也在想，手机本质上就是一台嵌入式计算机，如果厂商愿意推动硬件抽象层标准化、开放基础驱动、允许通用系统安装，那将是双赢：厂商无需为每个机型重复造轮子，可以集中精力优化核心体验；用户则能自由选择系统，获得长期支持，不再被“三年停更”抛弃。但这个设想大概率永远不会实现。因为现在的技术栈早已固化，而更重要的是——封闭 = 控制 = 利润。所谓“安全”“体验”“兼容性”，不过是遮羞布罢了。所以，如果你的手机不能解锁，或者就算解锁了也没人给你做第三方 ROM，那你对设备的掌控权，依然牢牢攥在厂商手里。厂商定制ROM：手机的第2道枷锁众所周知，国内手机厂商对于安卓系统的定制和魔改程度，是非常深的，所以有人说安卓不是Android不是没道理的，因为“安卓”和“Android”确实不是一个东西了。原版的安卓，就像是勉强够用的，装修一般的住宅，但是内部整洁，干净。没有一大堆广告，系统干净，精简，但是缺失了很多国内ROM才有的特色功能，例如一键复制验证码，系统深度个性化等。而国内手机厂商定制版的安卓，就像是一个装修豪华的套间，配套设施完备，外表富丽堂皇；但是，每一个房间都安装有监控探头和远程控制装置，这些东西的控制终端却不在套间里面，而是在物业管理办公室；另外，物业还会安排广告推销员敲门上门推销各类商品，如果你要使用房间的设备，还得看一段广告才能使用。这就是国内ROM的现状——精装粪坑：定制功能非常多，不管你用不用得到，全部给你塞进去，便利程度确实是拉满的，但是系统也因此变得臃肿不堪；另外还有云控组件远程施法，可以让你的设备变得好用，也可以让你的设备变卡逼你换机！现在的国内ROM，这种情况愈演愈烈，不但人为降低用户使用体验，还会限制用户使用他们的设备——例如前段时间更新了一个功能：设置手机的热点WIFI名称必须联网检查。这就很搞笑了，我设备设置什么名称，关你屁事？手机是我买的，又不是你的。果然国内手机不解锁Bootloader只有使用权，没有所有权——这是事实。现在又说要限制和阉割VPN功能，这就更疑惑行为了。那我连使用自建EasyTier服务器，进行内网穿透组网访问我家里面的设备的资格都没有了？首先排除手机虚拟局域网组网？又或者说，我想设置广告过滤软件，让手机流量通过广告过滤软件清洗，也不行？技术是双刃剑，肯定有好的一面，也有坏的一面，但是似乎国内厂商更希望把技术坏的一面发扬光大，好的方面避之不谈。看到一个技术有坏的方面，如果对于他们影响不大，就火急火燎的一刀切，但是如果对他们影响较大，反倒是会认真考虑。吃相难看！除了这个，还有一点让我对国内ROM反感的就是——限制用户安装第三方APP。尽管我能理解是出于保护用户考虑，毕竟国内某些不良软件厂商的所作所为确实让人火大：点一下就各种威逼利诱让你下载它的apk文件，让后诱导你安装。然后这些被诱导安装的应用就是手机里面的流氓软件，各种广告，通知，弹窗不停歇，还想尽办法获取各种权限，恶性至极！我认为手机厂商拦截这些狗屎APP作恶，没什么不妥。但是，如果一刀切了，禁止用户手动安装任何APP，那确实过火了。更加稳妥的做法是，默认打开外部安装软件的限制，但是也明确在设置给出关闭开关，关闭时明确告知用户风险并做好对应的提示，这就够了。虽然苹果也不支持侧载应用，不过嘛，相比于国内手机厂商干的事情，苹果虽然在自由度层面是给你关入地牢，但是吃好喝好，AppStore有很多优质APP，而且对国内流氓应用的审核也更严格。至于国内厂商嘛~，给你锁起来吃大粪，应用商店很多应用就是一坨狗屎，真正好的小而精的应用根本看不到，连一些简单的小工具，都能给你塞一大堆广告进APP里面。这么对比下来，我感觉国内手机厂商这样子搞，只是为了更好的圈钱罢了，打着保护用户的名号去迫害用户，属实是招笑，自欺欺人。另外，这只是技术自由度上算的账，手机厂商在手机系统偷偷收集用户数据，这些账还没算呢！这算是你在购买手机第一次打开手机后“自愿”同意的条款内容——因为你不同意就用不了手机。表面上“合情合理”，说是收集什么数据，但是实际操作就不得而知——因为国内厂商定制手机ROM没有开源，是黑盒，还有云控这种东西，鬼知道背后干什么呢？我相信不少用户在QQ上刚聊着一个产品，从来没有在淘宝搜索过，打开淘宝就马上给你推送这个产品这样的经历吧？细思极恐啊！我认为手机厂商与其要搞这样的“霸王条款”，不如给用户一个选择：我可以拒绝同意所有条款内容，只使用手机最基础的功能，不依赖你厂商的任何云服务，你的系统任何和基础功能无关的组件不允许联网，也不会主动联网，这就够了。但是，这应该没有哪个厂商可以做到吧？手机是运行其他应用APP的基础底座，如果这个底座本身不可靠，那任何隐私和安全都是扯蛋！各类热门APP：手机的第3道枷锁对于部分，用户的自主权就稍微高了一点——我们可以不用某个APP。例如我本人就不用小红书，抖音，快手，B站，对，没错，我早就把B站卸载了。但是，对于一些刚需软件，就没有办法了，例如QQ，微信，支付宝，淘宝，各类银行APP等，这些基本上不可或缺。也就是，这属于是你不想用也得用的软件。对于这部分软件，他们的用户协议已经相当于半个霸王条款：因为你不同意就无法使用，但是这是刚需，你别无选择。我认为对于这种在互联网使用中核心级别的软件，更应该严格监管，确保它们的协议和所作所为不能侵犯用户权利。不过无论如何，对于相当一部分的手机APP，你一旦开始使用，就会进入软件厂商精心设计的“圈养”环境，然后任由算法支配。这些软件会改变你的生活习惯，消费习惯，审美习惯，甚至思考能力——尤其是内容型平台——如抖音，快手，小红书，B站等；当然，如果你有足够强的思辨和逻辑能力，或许可以在一定程度上抵御住这种侵蚀，但是，又有多少网民真的具备这样子的独立思考能力呢？这也就是时间的第3道枷锁了——算法圈养+信息茧房：他们只会让你看到他们希望你看到的，而他们不希望你看到的内容，你可能永远也看不到，哪怕这些内容能提高你的素养和认知。时间侵占+密集信息流：让你的大脑失去独立思考的时间，逐渐在密集的信息流中失去自我，时间和精力被不知不觉间剥夺。就我观察下来，其中毒害最甚的就是短视频。如果你在生活中注意观察，在地铁，公交车上，排队等候的场合等有碎片时间的常见，相当一部分人会刷短视频消磨时间。虽然我充分尊重其他人刷短视频的权利，但是我也想请问一下：对于那些花费了大量时间刷短视频的人，他们真的学到了什么有用的东西吗？很显然，好像并没有太多——因为很多真正的知识和技能是短视频无法在这样短的时间传授的。反倒是不少短视频博主为了博人眼球，贩卖焦虑，给其他人灌输了错误的知识，既浪费了别人的时间，又给他人的认知造成了负面影响——这种现象在抖音，快手和视频号最为严重。当然现在还出现了一种聚众“赛博吃屎”的行为——也就是明明知道内容是没有任何营养，只是博人眼球，或者具有较大的争议性，甚至是肮脏和龌龊的内容，但是因为有吃瓜和猎奇心态，总会忍不住点进去看：既浪费了时间，还脏眼，何必呢？那既然这样，与其都要浪费时间，为什么不把时间“浪费”在更有意义的地方：去学习计算机相关的知识，不再做赛博文盲；去学习和探索自己真正感兴趣的内容，培养自己的兴趣爱好；去看科普和知识类视频，扩展自己的眼界和认知…… 或者，就什么都不看，让大脑从现在网络密集的信息流解放出来，做一些独立的思考，你或许会对很多事情产生不一样的看法，也更有主见，而不是人云亦云。如果你不相信的话，你大可以按照我说的试一试，坚持一周或者一个月：卸载或者不再打开手机的短视频相关APP当有较短的空闲时间的时候，就直接让大脑放空一段时间，思考一下自己的生活情况，自己的兴趣爱好，自己真正喜欢的东西等。如果是有较多的空闲时间的话，可以尝试去了解一些之前并没有了解过的东西，多与他人交流，哪怕是线上，或许某一天，你会突然发现一个自己感兴趣且未曾探索过的知识新大陆。如果你有兴趣爱好，因为之前短视频侵占了时间导致兴趣爱好无法推进的，就可以就这次机会继续推进兴趣爱好了。以上只是我个人的观点，每个人真正喜欢和感兴趣的东西不一样，我这里只是给你提出几个比较可能且值得尝试的方向和方法，如果你有别的想做的事情，那就大胆去做，总之就是要尝试摆脱空闲时间被短视频侵占的状态。这么做下来，我相信许多人会找到比刷短视频更有意义的事情的。手机的便利性：手机的第4道枷锁或许有人会反驳这个观点：明明是更便利的使用体验，为什么会成为枷锁呢？然而事实就是这样——手机的过于便利性导致了很多网民失去了最基本的数字素养，失去了学习和解决问题的能力，成为了技术圈子里面的嘲讽和唾弃的对象——赛博文盲。现在的手机便利和傻瓜化程度远超电脑——应用商店点点就能安装APP，发布内容和视频模板一键生成…… 一切都是一键化，傻瓜化：不需要考虑兼容性问题，不需要性能调优，不需要关心任何稍微底层一点的东西，一切简单快速。虽然说，很多东西我们只需要会用就行，没必要关注底层的东西，但是，如果我们都只看表象，不进行深入的了解和挖掘这些光鲜亮丽的外表背后的运行逻辑，那我们只能做别人的鱼肉：你不了解手机存在云控机制，你怎么能知道某些厂商为了逼用户换机，故意通过云控下发降低性能的配置？明明你的手机可以用的更久，但是却被厂商计划报废。你不了解手机的不少APP在使劲往里面塞屎，让应用变得臃肿不堪，你怎么能知道为什么手机越用越卡的原因？明明2020年这些软件也是干这些事情，2025年也是干这些事情，但是体积却膨胀了好几倍，性能要求也翻了好几倍。手机厂商那边提升出来的性能，就这样被不知不觉的侵占了——而这部分性能本来属于你，你可以用来干其他更有意义的事情，而不是让这些糟糕的软件浪费。健康的做法应该是每一个软件做好自己份内的事情，把软件做好，小而精，就可以了，而不是什么都往里面塞——既浪费了用户的设备性能，又导致软件变得臃肿难以维护。你不了解手机输入法的隐私和联网逻辑，你怎么能知道你的所有输入，对于输入法软件厂商是一清二楚？这往往是很多人忽视的一点——手机输入法无论厂商这么保证所谓的“隐私”，输入法程序本身没有开源，就是一个黑盒，你怎么能确保他不剽窃隐私？如果你能认识到这一点，给手机的输入法断网，或者换社区开源的，值得信赖的输入法，可以极大程度的保护自己的隐私。不然你刚输入法输入了什么，其他平台就要开始推送了（笑）这只是列举了一些常见的情况，可见对技术的无知是多么可怕，而手机的傻瓜化和便利化，正在助长这种无知，而且是越来越严重——十年前还是人均root和刷机的时代，现在还能坚持root和刷机的又有多少呢？平心而论，你手机没有root，那这个手机你也是只有使用权——因为最高权限不在你手里。现在很多人连文件目录，压缩包，运行库这些最基本的计算机知识都不懂，甚至还出现了淘宝上面的软件代装服务这种离谱到了极致的东西，确实是数字时代的悲哀。然而，这对于手机厂商和软件厂商，这或许是好事——正因为用户无知，他们可以尽情为所欲为：手机厂商收紧解锁Bootloader策略：反正刷机群体不多，普通人用不到，绝大部分人不会反抗。事实上国内之前能随意解锁的厂商已经开始逐步收紧——温水煮青蛙这一块！但是这事实上是对用户对设备的所有权的侵犯！手机厂商随意云控用户设备：如果手机厂商想计划报废老机器，就可以设置降低性能的配置。普通人不知道云控这回事，只知道手机便卡了，该换手机了，却不知道这是手机厂商故意所作所为。但凡思考一下——一个设备，同样的配置，为什么几年前流畅运行，现在却不行，很明显，硬件还是那些硬件，是软件变慢了——就知道是怎么回事了。软件厂商不做优化，反而往里面塞屎：因为他们知道用户如果感觉使用变卡了，是觉得手机不行，而不是软件变得臃肿不堪，会去换手机，反而还给手机厂商助攻了。而用户却为这些软件厂商拉的屎买单——花钱买更好的设备，干着之前旧设备就能干的事情。软件厂商可以仗着用户不了解软件和系统的运作逻辑，强行给用户推送各种广告：这一现象在中老年群体的手机中尤为明显，手机的通知栏是被各种流氓软件强奸的重灾区，布满了各种乱七八糟的推送和广告。更有甚者，会直接获取悬浮窗权限给用户贴脸开大。这是对用户赤裸裸的侵犯！而可悲的是，很多人认为这居然是理所当然或者是很正常的事情，就这么用了——就像很多人认为电脑弹广告是理所当然的事情，而不是电脑里面的流氓软件所作所为。对于我这样了解过技术的人来说，这种现象实在是看不下去，而不少国内软件却对此乐此不疲。所以我鄙视国内软件，对于个人软件使用奉行去国产化原则，不是没有道理的——因为不用这些垃圾国产软件和平台，互联网使用体验会好非常多！那么，我们能做什么如果这个我们，只是自己只身一人，或者几个小群体，那确实做不了什么——因为我们影响力有限，达不到倒逼手机厂商和软件厂商做出改变的程度。但是，如果我们能像鲁迅先生说的一样：“愿中国青年都摆脱冷气，只是向上走……有一分热，发一分光，就令萤火一般，也可以在黑暗里发一点光”，让更多的普罗大众了解光鲜亮丽且便利的技术背后的黑暗和对个人的侵犯，或许有可能能够扭转局面，倒逼手机厂商和软件厂商不再“奴役”和“剥削”用户。虽然这个希望渺茫，但是清醒的人每多一个，这个愿望的实现的进度条就会往前走一点——哪怕可能只有0.0001%都不到。而我，敢于表达出来，就算是在我的个人层面发了一份光，给愿意阅读这篇文章的人抛砖引玉。另外，还有一个值得延伸思考的现象：如今许多学习和工作类 APP，正在把用户的私人手机变成管理者的监控终端，用用户的手机作为工具来奴役用户——学校强制使用跑步 APP 完成体测里程，学习通要求 GPS 定位+人脸拍照打卡，企业微信强制上班下班定位签到……明明是用户自己花钱买的设备，却被迫成为组织节省人力成本的工具。这是否意味着，我们的“数字私有财产”，早已在不知不觉中沦为他人的“生产资料”？你怎么看？希望你读完这篇文章也能有自己的思考，捍卫好自己的数字权利。
二次开发萌化VisitorLoggerPro插件其实之前我一直在用VisitorLoggerPro这个插件，但是由于之前的版本不够完善，也没想着进一步提升改造。前几天偶然翻了翻这个项目的GitHub仓库，发现基本上更新完善了，上一次更改已经是三个月之前，遂下了最新版回来尝试一下，发现功能非常满意。但是，美中不足的是，插件从jsdelivr.net下载相关的静态文件容易卡半天加载不出来。虽然插件写有智能回退使用网站的服务器进行加载，但是我发现它缺失了文件且返回路径不对，导致如果jsdelivr.net无法访问，页面会加载异常。于是，就萌生了修复这个插件的问题并且改造进行个性化的想法。打开了后台页面观摩了一下，并用浏览器的开发者工具随手调了一下CSS样式，最终明确了个性化的路径：设置一个全局背景图看板卡片进行半透明化于是说干就干，我先Fork了原项目，想了想要不就取名VisitorLoggerPro-Enhanced吧！然后开始了我的折腾和修改之旅。Github项目地址：VisitorLoggerPro-Enhanced可以去直接下载使用。修复静态文件回退到本地失败这肯定是最先需要解决的问题。如果不解决这个问题，该插件在我这里就处于不可用状态。补回缺失的本地文件这一步对于有网站搭建经验的人很简单，F12打开浏览器开发者工具，然后刷新页面观察网页都发送了那些外链请求，一下子就抓出来了：本地缺失的js：https://cdn.jsdelivr.net/npm/flatpickr本地缺失的css：https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css其中，flatpickr抓取回来是没有后缀的，得加上个.js后缀变成flatpickr.js。然后对号入座，flatpickr.js放到js/目录，新建一个css/目录放置flatpickr.min.css。补回文件后的插件目录文件树应该是这样的：tree . ├── action │ └── visitor-stats.php ├── Action.php ├── adapter.php ├── css │ └── flatpickr.min.css ├── getTrendData.php ├── getVisitStatistic.php ├── ip2region │ └── src │ ├── ip2region.xdb │ └── XdbSearcher.php ├── ipdata │ └── src │ ├── ipdbv6.func.php │ ├── IpLocation.php │ ├── ipv6wry-country.db │ ├── ipv6wrys.db │ ├── qqwry.dat │ ├── qqwrye.dat │ └── zxipv6wry.db ├── js │ ├── echarts.min.js │ ├── flatpickr.js │ └── README.md ├── panel.php ├── Plugin.php ├── README.md ├── trend.php └── visitor-stats.php 8 directories, 23 files修复完善静态文件获取逻辑原本插件本地静态文件的相对链接写法是：localScript.src = './js/echarts.min.js';这会导致获取文件时发生404错误，因为在插件页面上下文中，该相对路径会被解析为https://www.nekopara.uk/admin/js/echarts.min.js，那包404的。正确的URL连接位置应该是https://www.nekopara.uk/usr/plugins/VisitorLoggerPro/js/echarts.min.js，所以，这个相对链接应该改成../usr/plugins/VisitorLoggerPro/js/echarts.min.js。而且，原插件仅实现了echarts.min.js的本地回退逻辑，未处理另外两个静态文件（flatpickr.js和flatpickr.min.css），因此即使修复了路径，还是会卡加载。还有一个致命的缺陷是，原本的回退本地源的逻辑是等待失败，但是一等就是大几十秒，非常影响体验。于是我就设置了超时时间，一旦第三方的静态文件加载时间超过2秒，立即回退到本地，避免长时间等待。以下是完善后的逻辑代码：对于panel.php内嵌js部分的修改，在原文件64行的地方开始修改： 对于visitor-stats.php内嵌js部分的修改，在原文件90行的地方开始修改： 对于trend.php内嵌js部分的修改，在原文件12行的地方开始修改： 对实现显示图表的trend.php，panel.php和visitor-stats.php的修复主要解决以下核心问题：加载超时问题：原插件等待第三方CDN加载超时（大几十秒），现添加2秒超时机制，一旦CDN加载超时立即回退到本地，极大提升加载体验。加载逻辑完善：原逻辑仅处理echarts.min.js，现完整支持echarts.min.js、flatpickr.js和flatpickr.min.css的智能加载与回源。错误处理增强：添加加载成功/失败日志，以及加载失败时的用户提示，提升用户体验与问题排查效率。以上修复确保在jsdelivr.net不可用时，插件仍能快速加载并正常运行，同时保持原有功能完整性。修复我已经为GitHub上的原项目提交了的Pull Requests，能不能被合并就不知道了（接下来开始搞我的个性化了）萌化VisitorLoggerPro首先，最简单粗暴的萌化方式就是加上二次元背景图，这是最直接，最有效果的方式。我的修改思路是，先在浏览器开发者工具里面直接修改网页代码查看效果，然后再针对性去修改插件里面对应的代码。为插件添加背景图在插件界面，右键点击页面边缘的空白处，选择检查。然后在HTML代码区一路上划找到页面最底层的 盒子，发现是 ，然后针对性去找CSS样式表，真给我找到了： .main { padding: 20px; background-color: #f5f7fa; min-height: 100vh; }这时可以先在浏览器里面修改CSS样式表看看效果：.main { padding: 20px; min-height: 100vh; background-image: url('https://teachermate.oss-cn-qingdao.aliyuncs.com/irGnf-1725727799259-neko4_n33b.png'); background-repeat: no-repeat; background-position: center center; background-attachment: fixed; background-size: cover; }瞬间就对味了一大截：然后就需要修改规则了，在全部文件中检索.main这个CSS类选择器，然后进行代码修改。好了，那么现在问题来了，我虽然是可以直接找到对应的代码写死URL在上面，但是如果别人想换个图片，就没这么方便了。于是我就在思考，能不能把这个参数作为应该可以给用户设置的插件参数？得益于Typecho插件开发的便利性，答案是肯定的，只需要在插件设置界面给出设置插件的参数的地方，在后面通过Helper::options()获取这个参数即可。找Plugin.php添加多一个设置选项：插件背景设置 /* 插件背景设置 */ $backgroundUrl = new Typecho_Widget_Helper_Form_Element_Text( 'backgroundUrl', null, 'https://pic.nekopara.uk/?format=webp', // 默认值 _t('插件背景设置'), _t('可填写图片API的URL（如随机图片API）或具体图片的URL直链。默认使用猫娘乐园图片API') ); $form->addInput($backgroundUrl);我这里定义参数名称为backgroundUrl，然后在插件代码里面进行获取的方式就是：$backgroundUrl = Helper::options()->plugin('VisitorLoggerPro')->backgroundUrl ?: 'https://pic.nekopara.uk/?format=webp';在嵌入到PHP的CSS样式表部分，我只需要这样子修改： .main { padding: 20px; min-height: 100vh; background-image: url('<?php echo $backgroundUrl; ?>'); background-repeat: no-repeat; background-attachment: fixed; background-position: center center; background-size: cover; }就可以根据设置的URL返回特定的样式表了，让用户可以自定义背景图的问题也就迎刃而解了。插件默认的设置图片来源是我的猫娘乐园CG图API，可以自行更换为其他图片直链或者图片API。为卡片设置半透明颜色但是，只是修改一个背景图，卡片是不透明的把好看的图片都基本上档完了，多没意思？于是，我就想在保证可读性的情况下，让卡片半透明化，让背景图能被看到。继续F12大法，找到每个卡片对应的CSS规则：访客日志页面部分，对应panel.php文件： .page-header { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } .action-form { background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; flex-direction: column; } .logs-section { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); /* height: calc(100vh - 200px); */ overflow: auto; min-width: 0; border: 1px solid #e2e8f0; } .stats-section { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); /* height: calc(100vh - 200px); */ display: flex; flex-direction: column; gap: 24px; border: 1px solid #e2e8f0; } .chart-container { flex: 1; min-height: 260px; background: linear-gradient(135deg, #fff 0%, #f8fafc 100%); border-radius: 12px; padding: 8px; border: 1px solid #e2e8f0; position: relative; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); transition: all 0.3s ease; overflow: hidden; }访客日志趋势分析部分，对应trend.php文件： .page-header { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } .controls-section { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; } .trend-section { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; } .stats-summary { display: flex; justify-content: center; gap: 20px; margin-bottom: 20px; padding: 16px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; flex-wrap: wrap; } .metrics-explanation { background: #fff; border-radius: 12px; padding: 24px; margin-top: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; } .metric-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 20px; transition: all 0.3s ease; } .technical-notes { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 20px; border-left: 4px solid #3498db; }只需要将每个卡片的background: #fff;定义颜色部分设置成半透明即可。虽然最简单的方法就是保持原来的颜色，增加透明度，但是我考虑到这一片卡片后面如果是靠修改代码调整，调整起来也很花时间，不如像图片背景那样给用户输入一个颜色自己配。同样的方法，在Plugin.php中增加配置选项： /* 插件卡片颜色设置 */ $backgroundColour = new Typecho_Widget_Helper_Form_Element_Text( 'backgroundColour', null, '#ffffffc4', // 默认值 _t('插件展示内容卡片颜色设置，采用HTML颜色代码') ); $form->addInput($backgroundColour);然后在panel.php和trend.php加上：// 获取配置的卡片背景色（带默认值） $backgroundColour = Helper::options()->plugin('VisitorLoggerPro')->backgroundColour ?: '#ffffffc4';并针对性的设置需要设置的颜色CSS代码位置，例如： .page-header { background: <?php echo $backgroundColour; ?>; border-radius: 12px; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }找到上面说的CSS代码，将背景颜色代码部分替换为<?php echo $backgroundColour; ?>就完事了。这次的自定义修改，让我对Typecho的插件和PHP代码逻辑有了更进一步的认识，虽然仍是面向LLM编程，但是至少有实现是思路和方法，不会具体的语法和代码，倒是可以让LLM来写。效果展示最后展示一下萌化后的效果：这下暴露出小破站访问量了，果然是没什么人看的，被打的次数倒是不少（番外：使用方法进入你的Typecho部署目录，然后进入插件文件夹：cd /data/typecho/ cd usr/plugins/随后，克隆项目并重命名文件夹：git clone https://github.com/Chocola-X/VisitorLoggerPro-Enhanced mv VisitorLoggerPro-Enhanced/ VisitorLoggerPro/删除无用文件（可选）：cd VisitorLoggerPro/ rm -rf .git/ rm -rf .gitignore rm README.md 然后进入Typecho后台启用插件即可。当然，如果你凑巧服务器在国内无法正常访问GitHub的话，我这里有直链可以给你下载，进入插件目录下载并解压即可：wget https://teachermate.oss-cn-qingdao.aliyuncs.com/air2Q-1762344686455-VisitorLoggerPro.zip unzip https://teachermate.oss-cn-qingdao.aliyuncs.com/air2Q-1762344686455-VisitorLoggerPro.zip rm https://teachermate.oss-cn-qingdao.aliyuncs.com/air2Q-1762344686455-VisitorLoggerPro.zip