MoreRSS

site iconLawtee | 法律小茶馆修改

原海若博客。89年,湖南人,法律工作者,可以提供法律帮助。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Lawtee | 法律小茶馆的 RSS 预览

使用 CloudFlare R2 作为图床时的安全防护设置,避免被恶意刷B类文件请求

2025-07-11 19:00:00

Featured image of post 使用 CloudFlare R2 作为图床时的安全防护设置,避免被恶意刷B类文件请求

CloudFlare R2 是大善人推出的重磅免费服务,默认即为用户提供 10G 存储空间和无限流量。不过这个无限还是有限制的,即每月写入类操作不超过 100 万次,每月读取类操作不能超过 1000万次,否则就将按量计费。最近我搬迁了 11 万多张图片到 R2 ,有点担心使用超限,所以对其作了严格的防护设置。

防护原理

我需要设置防护的这个 R2 存储桶是此前在 将Soomal.cc迁移到Hugo 这篇文章中提到的 soomal.cc 网站中的图片。

该网站已经托管在 Cloudflare Pages ,由 Cloudflare Pages 提供免费服务,只需要对 html 中引用的图片进行防护。

1.限制直接对R2存储桶访问。禁止使用链接直接访问 R2 存储桶,而是只能通过 Cloudflare Workers 服务进行访问。

2.限制对图库链接的直接访问。禁止直接使用 https://images.soomal.cc/test.webp 这种网址请求图片,对所有图片的访问均需在原网站下进行。

3.对原站启用适当防护策略。由于图片本身不适合设置太多防护规则,主要是通过在原站上设置,变相增加直接请求图片链接的难度。


设置方法

禁用 R2 公开地址

在 R2 设置中,不要设置自定义域访问,不要在网络上公开 R2。

在 Cors 策略中,设置仅通过原站访问。

R2设置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[
 {
 "AllowedOrigins": [
 "https://soomal.cc",
 "https://www.soomal.cc"
 ],
 "AllowedMethods": [
 "GET",
 "HEAD"
 ],
 "AllowedHeaders": [
 "*"
 ],
 "ExposeHeaders": [
 "ETag"
 ],
 "MaxAgeSeconds": 3600
 }
]

添加 Workers 访问规则

  1. 创建一个 Worker

创建worker

  1. 绑定 R2 存储桶

绑定存储桶

  1. 添加自定义域和路由

添加路由

  1. 添加 Workers代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
addEventListener('fetch', event => {
 event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
 const url = new URL(request.url);
 const referer = request.headers.get('referer');
 const allowedDomains = ['soomal.cc', 'www.soomal.cc'];

 // 处理 images.soomal.cc 的图片请求
 if (url.host === 'images.soomal.cc') {
 if (referer && allowedDomains.some(domain => referer.includes(domain))) {
 const filePath = url.pathname.replace(/^\//, '');
 try {
 const object = await R2.get(filePath);
 if (object === null) {
 console.log(`File not found: ${filePath}`);
 return new Response('File Not Found', {
 status: 404,
 headers: { 'Content-Type': 'text/plain' }
 });
 }
 const headers = new Headers();
 headers.set('Content-Type', object.httpMetadata.contentType || 'image/webp');
 headers.set('Cache-Control', 'public, max-age=31536000');
 headers.set('Access-Control-Allow-Origin', 'https://soomal.cc');
 return new Response(object.body, { status: 200, headers });
 } catch (error) {
 console.log(`Error: ${error.message}`);
 return new Response('Internal Server Error', {
 status: 500,
 headers: { 'Content-Type': 'text/plain' }
 });
 }
 }

 console.log(`Unauthorized: Referer ${referer} not allowed`);
 return new Response('Unauthorized Access', {
 status: 403,
 headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store, no-cache' }
 });
 }

 // 如果主机名不是 images.soomal.cc 或 www.soomal.cc,直接返回 404
 return new Response('Not Found', {
 status: 404,
 headers: { 'Content-Type': 'text/plain' }
 });
}

这个脚本主要作用是将所有包含 images.soomal.cc 的请求,都通过 Workers 去访问。

利用 Workers 每天 10 万次的限额(30天就是300万次),确保对 R2 存储桶的访问数不会爆单。

其实设置到这里,我的目的也就达到了。因为每天 10万次访问,已经足够这个备份网站使用。一旦超额,workers 直接罢工,图片也就无法访问了。

添加必要的防护策略(可选)

经过上边设置后,所有对 R2 存储桶的请求,都只能通过原站 soomal.cc 访问。

这样,就可以继续通过对原站添加必要防护,变相提升对图片的防护能力。

  1. 启用SSL严格模式

开启SSL严格模式

  1. 启用缓存。建议是能缓存的全缓存。

缓存规则

  1. 安全规则。可以启用连续脚本监视、浏览器完整性检查、速率限制、自动程序攻击模式等功能,进一步增加防护。

简单速率限制

开启境外访问托管质询

从天水幼儿园铅中毒事件来看,为什么食品安全这么难保障

2025-07-10 13:00:00

Featured image of post 从天水幼儿园铅中毒事件来看,为什么食品安全这么难保障

最近天水幼儿园幼儿集体铅中毒事件引发广泛热议,从食品监管、工业原料、医疗检测、地下水污染到地方政府一系列“谜之操作”,导致所有怨气集中爆发。这里也趁机谈谈为什么食品安全保障这么困难。


天水事件的反常之处

网上对天水事件最大的质疑是,为什么幼儿园会放着便宜的食用色素不用,而使用工业染料。

这其实涉及一个深层次的“生活哲学”问题,就是在现实中我们每次遇到那些不可思议的事件结果时,总能在其背后发现一堆不可思议的过程。

也就是俗称的,当你第一次发现家里有一只蟑螂时,实际上搞不好暗地里已经有100只了。

具体到这个事件中。

当一个厨师能够想出使用染料对食品染色这种操作时,我想,就不要期望它能知道染料的成分是什么,不要期望它能知道色素比染料更便宜这回事了。

我们很多人在网上分析问题时,喜欢用逻辑推导行为过程,但实际生活中,很多时候是没有正常逻辑可言的,可能那个厨师刚好在网上刷到过染料视频,或自己在网购平台看到过随机推荐的染料商品便产生了这种想法。

天水事件如何定性

是否故意投毒

作为两个小孩的父亲,我很理解网上对天水事件喊打喊杀的想法,不过这个事件从现有情况来看,相关责任人应该是很难被判死刑的。

一个争议在于,相关责任人是否故意投毒。

但从常理推断,目前很难证明这个是故意投毒行为,毕竟这种报复社会的方式,怎么都说不通: 厨师串通厨工、园长连续投毒几个月意图谋害幼儿,这已经完全超出人类理解。

是否能判死刑

从目前来看,这个事件构成生产销售有毒有害食品罪应该是疑问不大的,其他像故意杀人、故意伤害、以危险方法危害公共安全等,应该是说不大通的。

虽然生产销售有毒有害食品罪最高可以到死刑,但参照“三聚氰胺”等类似事件,一般情况是致人死亡的,才判死刑,否则还是有点困难。

当然,这种量刑最终还得看具体案情,即便没有受害人死亡,这么严重的后果,判个死刑也不是不合理。

另外,生产销售有毒有害食品罪并不需要考虑相关责任人是不是真想害人,只要是真想往食品中添加有毒有害物质就行,从现有情况看,显然是符合的。


厨师懂不懂食品安全

之所以我倾向认为天水事件中这些人是因为“无知”而不是“故意”投毒,一个重要原因是我对厨师这个群体的学识水平真的不抱太高期望。

我亲戚里边有好几个厨师,有的农村搞酒席,有的在工厂当厨师,还有一个在学校食堂当厨师。

这些亲戚大部分都是高小或初中文化,你要问他们什么是色素,人家永远只有一句话:我从来不用。

我想,对于他们来说,这句“从来都不用”就已经是他们对食品安全最深刻的理解。

经验主义

日常工作中,我这些亲戚对食品安全的认识,完全停留在经验层面,馊了的不能吃、臭了的不能吃、烂了的不能吃,至于成分,那不是他们能够想得清楚的事情。

问他们什么叫“食品安全”,那显然只有“原生态”才是最高标准。

比如,市场上卖的那些肉蛋鱼鸡,在他们眼里那就是“饲料猪”“饲料蛋”“饲料鱼”“饲料鸡”,都是下品食材。

一旦深入一点探讨,说这些“走地鸡”可能接触过土壤中的重金属、农药残留、寄生虫或被污染的野外水源,搞不好还不如流水线生产的“饲料鸡”安全时,不可避免就得进入“开骂”环节了。

这个问题在所谓的“食品从业人员考试”中体现的淋漓尽致。

厨师考试

我曾不止一次指导亲戚们参加各种“食品从业人员考试”,这些考试大多是在手机上操作,大概是在 20 分钟内回答 50 道题,80 分以上合格。

这些考试通常会区分食品安全管理人员、食品从业人员、食品负责人员和食品专业技术人员等身份,有不同的考试试题。

但是,题目确实也有点复杂,我想现实中,应该很少有厨师能够真正独立通过考试。

毕竟,都已经干厨师这行了,你还能指望他们有一定的化学、医学、生物、法律水平吗?

这里我摘录部分题目如下:

  1. 【单选题】下列哪种水产加工时禁止使用明矾浸泡?
    A. 海蜇头 B. 鲎肉 C. 河豚肝脏 D. 泥螺

  2. 【单选题】集体用餐分装菜肴时,热藏温度不得低于:
    A. 50℃ B. 60℃ C. 70℃ D. 80℃

  3. 【单选题】制作含维生素C的果汁时,不宜添加:
    A. 山梨酸钾 B. 苯甲酸钠 C. 乳酸链球菌素 D. 纳他霉素

  4. 【单选题】某餐厅使用过期咖喱粉制作套餐,违法所得2000元,应处以:
    A. 5万-10万罚款 B. 10万-20万罚款 C. 吊销许可证 D. 仅警告

  5. 【单选题】下列哪种野生菌允许加工但需标注警示语?
    A. 鸡枞菌 B. 见手青 C. 松茸 D. 羊肚菌

  6. 【多选题】以下哪些深海鱼必须去除内脏后才能冷冻储存?
    A. 油甘鱼 B. 鲭鱼 C. 石房蛤 D. 裸盖鱼

  7. 【多选题】下列哪组食材禁止在同一容器中预处理?
    A. 黄豆芽与猪血 B. 螃蟹与柿子 C. 生牡蛎与柠檬汁 D. 菠菜与豆腐

  8. 【判断题】作为膨松剂的亚硫酸氢钠(INS 222)可用于生鲜肉类腌制。

  9. 【判断题】真空包装的溏心蛋需在4小时内将中心温度降至8℃以下。

  10. 【判断题】未取得健康证的厨师接触即食食品,可处餐厅5000元以下罚款。

当然,这也解释了,为什么大多数学校食堂,永远都是那几个菜,为什么农村酒席几乎都是固定的菜式。

毕竟只有这样才是相对安全、保险的,一旦食材类型复杂、加工过程复杂、保存过程复杂,那就得乱套了。


管理能不能跟得上

这次天水事件让我想到一个更宏大的背景。

我们正处在一个剧烈转型的夹缝中,几千年小农经济留下的经验主义,与快速的工业化、信息化碰撞,导致整个食品安全体系依然千疮百孔。

想想看,我们日常吃的多少东西,源头还在那些星星点点的散户手里。

自家后院养几十只鸡,地里种点菜,作坊里榨点油、腌点咸菜,这些东西往往都像毛细血管一样分散、隐秘,永远搞不清到底安不安全。

特别是很多还打着“乡村振兴”“扶贫助农”旗号,在道义上就让食品安全这个问题不了了之。

至于那庞大规模的餐饮行业,层不出穷的外卖专供食品,更是搞不清里边到底放了什么。

以目前市场监管部门的治理水平,我认为恐怕他们也是压根没能力做到监管到位。

多数时候只能靠事后追责,突击检查解决问题。

天水事件里的幼儿园,本质上也是这种“零散加工”的缩影。

规模不大,采购随意,加工过程更是全凭厨师的经验和“良心”。

在这种体系里,所谓的食品安全,往往就是墙上贴几张制度、抽屉里塞几本健康证了事。

园长、老板们首要考虑的是成本、是省事、是别吃出“馊了臭了”的“眼前”问题。

至于原料里含不含铅?添加剂用量符不符合国标?这很有可能不是他们知识储备里能容纳的范畴。


“懂行”的人在哪里

天水这个事情,讲白了还是个小概率问题,总体上,我对食品安全还是有一定信心的,起码这种事情也不是经常发生,不是大面积发生。

但另一方面,我依然对未来是否会继续发生这种问题感到悲观。

或许经过这次事件,含铅染料不得食用这个事,能在一段时期内广为人知,避免继续类似的悲剧。

但事件平息后呢,若干年后呢? 恐怕很难说大多数人都还能记得起这个教训。

我们不能指望每一个接触食品环节的个体,都天然具备专业的食品安全素养。

也不要指望通过现在的所谓“考试”“培训”就能将全国几千万餐饮从业人员安全素质和能力提升上去。

特别是现有那些扯淡的“考试”,让大量只有中低文化水平的厨师去精通化学分析、微生物控制和食品安全法规,完全不现实。

根本上,还是得让更多真正的“内行”进入到产业中,让真正懂风险点、懂操作规范,能识别原料风险,能监控加工过程的人去当好“守门员”,而不是随便挂个名,或者拿现有的人弄虚作假搞个资质凑数。

不过,这又涉及到一个扯不下的“孔乙己”长衫问题,相信也只有继续内卷,才能将真正有能力的人卷到厨房去吧。

最起码,卷去厨房搞食品安全应该比润出国刷盘子来得强。

电脑桌理线改造

2025-07-08 11:30:00

Featured image of post 电脑桌理线改造

我的电脑桌放在家里一间墙体不够方正的房间中,房间内 3 面直墙,另一面靠窗的是弧形,而电脑桌正是按照这个弧形墙面的弧度所定制的,因此桌面与墙面完全粘和,没法像普通办公桌一样把线材从桌背后扔下去,导致出现理线灾难。

先前理线方法

此前,我几乎没有考虑过理线这回事,都是把线材直接往显示器背后扔,只要显示器能把线材挡住就行,眼不见为净。

之前桌面情况

但这种方法,在买了一个海景房机箱后,很难再复刻。

这个机箱里边,默认就有 15 个风扇,即便去掉 8 个进出风的,也还有 7 个风扇,分别是 3 个 CPU 水冷风扇, 3 个显卡风扇,1 个电源风扇,运行起来,低频噪音很明显。

这种情况下,我不可能再像以往使用 ITX 机箱一样,直接搬到桌面上来,只能考虑将机箱放置在桌下。

这一小小改变,就给理线带来很大灾难。

先前桌面只要稍微把显示器抬高点,线材就非常乱

线缆连接需求

我大概数了一下,桌上目前共计有 30 多条线缆需要连接。

各种线材关系错综复杂,大概有 7 个节点需要各种线材进进出出,在桌面这么狭小的范围布线,真是太困难了。

线缆连接图

考虑到日后显卡升级,可能会出现两台电脑均连接两台显示器的情况,还得继续增加线材。

后续升级线缆连接图

另外,由于我这间房只在显示器背后有四个墙插,使得其他接台灯、风扇、各种手机平板充电器,也都只能从显示器后方墙插上引线到桌面另一头,进一步导致桌子上整体看起来特别乱。

改造情况

由于线材过多,特别是考虑到要实现桌上桌下线材互通,因此首要问题就是把桌面打洞。

木工开孔器

在某东 10 块钱买了个开孔六件套便开干,总共打了 4 个洞,将线材能引入桌下的都引入。然后购买多个桌下理线器,使用螺丝固定到桌面下方。将笔记本电源、插线板、交换机、KVM切换器等一众设备,一股脑塞到桌子底下。

不过,由于线材数量过多,粗的、细的、长的、短的,各种线五花八门,而桌下理线器空间实际非常狭小,导致真的很难将线材弄得规整,最终也只是一顿乱塞结束。

改造结果

流程图制作

在做上边这个图的过程中,我发现用 AI 搭配 Mermaid 制作流程图还是挺舒服的,比自己画图简单太多,而且 Mermaid 官网上渲染模板也有不少。唯一遗憾的是,本博客所用的 Hugo Theme Stack 没有内置支持 Mermaid 渲染,由于平常也少用,就不折腾,直接截图算了。

顺手再做了个家庭网络拓扑图。

网络拓扑图

如何选购显示器:微星 272URDF E16 双模显示器使用问题

2025-07-07 17:00:00

Featured image of post 如何选购显示器:微星 272URDF E16 双模显示器使用问题

多年来,一直未使用过高刷新率的显示器,这次趁着 618 优惠活动,买了款微星型号为 272URDF E16 的显示器,主要是槽点太多,所以单开一篇文章讲讲使用过程中遇到的那些问题,以及一些显示器选购方面的个人教训。

显示器选购需求

我此前有 3 个显示器,3 套搭配方案,其中最新一个显示器都是 5 年前购买的 AOC 卢瓦尔。

  1. ThinkPad P53 + Benq PD2700U 4K 60Hz
  2. intel NUC 11 + AOC LV273HUPR 4K 60Hz
  3. Redmi Book 14 2024 + Dell U2412M 1080P 60Hz

前些天添加一台台式机后,台式机和 ThinkPad P53 一直在共用明基 PD2700U 显示器。但总感觉有点怪怪的,因为台式机玩游戏时,FPS 能达到 500 多,可显示器依然是 60Hz,明显搭配不怎么合理,于是想新购一台显示器。

面板

我此前选购明基 PD2700U 和 AOC LV273HUPR 主要需求都是色彩准确和颜色丰富。这两款也是当年热门的中端显示器,前者购入价 3999,后者购入价 2999,都使用的是 IPS 面板。

但到 2025 年的当下,或许 IPS 不再是最佳选择,毕竟更强的 OLED 面板已经广泛铺货。

不过,我这次购入显示器主要为了玩游戏,在性价比驱动下,高刷新率和低延迟的 Fast-IPS 似乎是唯一的选择。

另外,我并不是追求极致高刷的用户,不想显示器就只能玩游戏,像 500Hz 以上的 TN 屏,显然不是我的需求所在。

刷新率

目前主流价位(2000 以下)的 Fast-IPS 高刷显示器,主要有 144Hz 160Hz 180Hz 240Hz 320Hz 等选择。我个人觉得,上到 160Hz 就差不多了,比 60Hz 快了两倍多,除非眼睛使劲盯着屏幕某个地方的变化看,不然很难判断出与 320Hz 的区别。

分辨率与尺寸

分辨率与尺寸是购买显示器时最头疼的选项,当前主要是在 24 27 32 三个尺寸间纠结,其中 24 寸主要与玩游戏有关,除非预算特别低或其他特殊需求,不然很少会考虑 24 寸及以下显示器了。

在 FPS 游戏里边,24 寸应该说是黄金尺寸,再大的话,一般人眼就难以捕捉到画面边缘信息,一旦错过信息,则很容易导致误判。

分辨率方面,24 寸 1280x960 的 4:3 比例分辨率,是当前 CS2 游戏中最佳选择。这是一个沿袭了近 20 年的分辨率选择,我在 20 年前购买第一台电脑时,就是的用二手 17 寸索尼特丽珑 CRT 显示器,那时显示器最大支持 1600x1200 分辨率,而玩 CS1.6 时则调整到 800x600 或 1024x768 分辨率。

在主流游戏都在拼 4K 画质甚至 8K 画质的当下,CS2 可谓是一股清流,一举带火了低分辨率显示器的销量。甚至有专门为 CS2 开发的高刷小屏显示器,售价普遍在 5000 元以上。

不过,我早已过了硬核 FPS 玩家年龄,现在都是佛系玩,也没必要刻意追求小屏和低分辨率。此前一度纠结在 32寸 2K 与 27 寸 4K 两个选项上。

最终,还是选了 27 寸,主要原因是家里原本两个 27 寸,如果突兀来个 32 寸的,双屏扩展时,好像有点怪怪的。

接口

显示器接口是一个很多人忽略的问题。我现有的明基和 AOC 显示器都有着比较丰富的接口。比如明基 PD2700U ,有 HDMI2.0 DP1.2 Mini-DP 1.2 三个输入接口,1 个 DP 输出接口,以及 2 个 USB-B 输入接口和 4 个 USB-A 输出接口,另外还有个 3.5mm 音频接口。这些接口足够满足我两台电脑同时使用一个显示器的需求。

Mermaid 图1

但是我在查找当下在售的显示器时发现,2000 元以下的显示器里边,除了个别办公用的 60Hz 显示器之外,游戏显示器里边几乎没有一款能支持这么多接口的,大部分就只有 DP 和 HDMI 接口,少数会增加一个 3.5mm 音频接口。

显示器多接口的易用性,让我选择又犯难。游戏显示器这边,能够支持丰富接口的,几乎都是 OLED 旗舰产品,售价基本都在 4000元 以上,与我这次选购需求显然有比较大的距离。

竞品选择

我挑选了 4 款 27 寸 Fast-IPS 面板的双模显示器作为竞品选择,它们都拥有 4K 分辨率,可供日常使用,都拥有低分辨率下的高刷新率性能,足够游戏使用。

品牌型号 尺寸 分辨率 刷新率(4K/1080P) 显示接口 其他接口 标价
红米 G27U 27" 4K 160Hz/320Hz DP×2, HDMI×2 3.5mm×1 1599
华硕 XG27UCG 27" 4K 160Hz/320Hz DP×1, HDMI×1 USB-C×1, 3.5mm×1 2699
微星 272URDF 27" 4K 160Hz/320Hz DP×1, HDMI×2 3.5mm×1, USB-A×2, USB-B×1 1799
AOC U27G4 27" 4K 160Hz/320Hz DP×1, HDMI×2 3.5mm×1 1699

在 618 期间,这些显示器都有或多或少折扣,以及国家补贴,例如红米 G27U 叠加国补能降到 1200 元,我最后选择购买的微星 272URDF 是 1350 元。


微星 272URDF E16 双模显示器使用情况

经过一段时期使用,我对这台显示器总体还是比较满意,起码在玩游戏时达到了预期要求,相比之前 60Hz 的显示器,游戏时的画面撕裂感明显没有了,操作起来感觉更为流畅。

在显示效果方面,调整显示器为显示模式为“用户模式”,并开启 Windows 系统 HDR 功能后,整体色彩相比明基 PD2700U 没有太大显示差距,毕竟这是相隔 8 年的产品,新的 Fast-IPS 再拉跨,基本素质也提上去了。

但是在使用过程中有几个问题还是比较糟心。

接口数偏少

我原本以为微星这个显示器对我来说只是少了 USB 接口,但没想到,最后使用时发现是显示接口少了。

此前,我的 ThinkPad P53 和明基 PD2700U 以及用来链接两者的 ThinkPad Thunderbolt 3 Dock Gen 2 扩展坞都使用的是比较老的 DP1.2 和 HDMI 2.0 接口,其中 HDMI 2.0 接口无法支持 10bit 输出,导致我长期只用 DP 线连接。

接口类型 带宽 4K配置 色深 实现方式 HDR支持
DP 1.2 17.28 Gbps 4K@60Hz RGB 4:4:4 8bit 原生无损 有限
4K@60Hz 4:2:2 10bit 色度抽样(画质损失) 有限
DP 1.4 32.4 Gbps 4K@120Hz RGB 4:4:4 10bit 原生无损
4K@144Hz RGB 4:4:4 8bit 原生无损
HDMI 2.0 18 Gbps 4K@60Hz RGB 4:4:4 8bit 原生无损 有限
4K@60Hz 4:2:0 10bit 色度抽样(画质损失) 有限
HDMI 2.1 48 Gbps 4K@120Hz RGB 4:4:4 12bit 原生无损
4K@144Hz RGB 4:4:4 10bit 原生无损

又因为 ThinkPad 雷电 3 扩展坞在外接显示器时,DP 和 HDMI 无法同时使用,要么两个 DP 要么两个 HDMI,所以我在接双屏使用时,也都是用的 DP 接口。

原来在使用明基和 AOC 或戴尔组双屏时,都用 DP 接口还没啥问题,但这次换微星显示器后才后知后觉,微星居然只有 1 个 DP 接口,直接导致我没法用 ThinkPad 同时接明基和微星两个显示器(这种情况下,我只需要微星运行在 60Hz 就行了,毕竟不需要这款老笔记本继续玩游戏)。

一个折中的方案是,我的台式机用 HDMI 接口连微星显示器,DP 接口连明基显示器,把微星上的 DP 接口让给 ThinkPad。我也是按照这个方案购买了 HDMI 数据线。

结果万万没想到,我的 RTX 2080 显卡又不支持 HDMI 2.1,它上边共 5 个输出接口,其中 3 个 DP1.4a, 1 个 USB-C 都可以通过 DSC 技术支持这款微星显示器的 4K 160Hz 10bit HDR(略微压缩画质情况下),但 HDMI 只能支持到 4K 120Hz。毕竟 DP1.4a 也拥有 32.4 Gbps 带宽,而 HDMI 2.0 只有 18 Gbps 带宽。

到这里,也就没啥办法了。只能台式机接双显示器,ThinkPad 目前没办法接双显示器。

唯一希望是后续我可能会更换 RTX2080 显卡,比如 RTX5070 之类的(相比 RTX 2080 当年的价格,RTX 5080 实在太贵),新显卡都有 HDMI 2.1 接口 ,问题也就迎刃而解。

显示器按钮很难按

以前没想过,显示器上的按键居然能成为吐槽显示器的理由。

但不得不说,现在这些放置在背面的显示器按钮,按起来真是太别扭了。相比而言,我还是更喜欢放置在底部的按钮。

主要是我的显示器放置的位置很低,显示器下方基本没有留出多少空间,导致伸手进去按时,手部操作受限。

特别是这么一款双模游戏显示器,需要经常性进行显示器设置。例如,调整双模模式,调整游戏和普通画面模式,设置显示刷新率,调整它的一些游戏设置等等,这个问题也就显得比较突出。

双模切换比较头疼

我到现在还没太搞懂双模切换的逻辑,很容易搞错设置。

主要是设置分辨率、刷新率的切换,总共有 3 个地方需要调整。

  1. Window 系统中分辨率和刷新率设置。
  2. 游戏设置中分辨率和刷新率设置。
  3. 显示器上双模切换。

一旦搞错顺序,很容易出问题。比如,有时进入游戏后发现刷新率只有 120Hz 或 160Hz,甚至 60Hz;有时在游戏中调整分辨率时,发现画面只在显示器的左上角显示;有时调整完显示设置,发现鼠标怎么点也点不中游戏中的设置项目,就像错位一般;有时调整完设置后,鼠标箭头都找不到了。

我理解是,在正常使用 4K 160Hz 过程中,直接在显示器切换双模到 1080P 320Hz 即可。但实际上,在未进入游戏前就需要先切换双模,让显示器先进入 1080P 320Hz 状态,进入游戏后再通过游戏设置确认显示模式。

由于我不止玩一款游戏。比如,CS2 我需要低分辨率 320Hz,而 Fortnite 我更喜欢 4K 160Hz。

因此在设置这些显示模式过程中真是很糟心,经常是进入游戏后才发现又搞错了。


总结

我此前从未在购买一个电脑硬件的过程中,花过这么长时间,折腾这么久,这次真是有点蛋疼的感觉。

总的来说还是消费降级所引发的连锁反应,按照以往的搞法,大概率也就是戴尔 U3225QE 或者雷鸟 U9 这类产品了。

可惜再也回不到从前。

目前接口情况

mermaid 图2

将Soomal.cc迁移到Hugo

2025-07-04 12:00:00

Featured image of post 将Soomal.cc迁移到Hugo

今年初,在拿到 Soomal.com 网站源码后,我将源码上传到自己 VPS 上。但由于原网站架构较为陈旧,不便于管理以及手机访问,近期我对网站进行重构,将其整体转换并迁移到 Hugo。

迁移方案设计

对于 Soomal.cc 的重构,我其实早有想法。此前也简单测试过,但发现存在不少问题,之前就放下此事了。

存在困难和挑战

  1. 原网站文章数量较大

    Soomal 上共有 9630 篇文章,最早能追溯到 2003 年,总字数达到 1900 万

    Soomal 上共有 32.6 万张 JPG 图片,4700 多个图片文件夹,大多数图片都有 3 种尺寸,但也存在少量缺失的情况,整体容量接近 70 GB。

  2. 文章转换难度较大

    由于 Soomal 网站源码中只有文章页面的 htm 文件,虽然这些文件可能都来自同一个程序制作。但我此前对这些 htm 文件进行简单测试时,发现页面内容架构也发生过多次变化,在不同阶段使用过不同的标签来命名,从 htm 中提取信息难度很大。

    • 编码问题:Soomal 原来的 htm 都是使用 GB2312 编码,并且可能使用的是 Windows 服务器,在转换时需要处理特殊字符、转义字符问题。

    • 图片问题:Soomal 网站中有大量图片内容,这些图片正是 Soomal 的精华所在,但图片使用了多种标签和样式,在提取图片链接和描述时,需要尽量避免缺漏。

    • 标签和分类问题: Soomal 网站中标签数量庞大,有近 1.2 万个文章标签,另外有 20 多个文章分类。但在文章的 htm 文件中,缺少分类的内容,分类信息只能在 2000 多个分类切片 htm 中找到;而标签部分有些有空格,有些有特殊字符,还有一些同一篇文章重复标签的。

    • 文章内容: Soomal 文章 htm 中包括正文、相关文章列表、标签等内容,都放在 DOC 标签下,我此前没留意到相关文章列表均使用的是小写的 doc 标签,造成测试时总是提取出错。这次主要是在打开网页时偶尔发现这个问题,才重新启动转换计划。

  3. 存储方案选择困难

    我原本将 Soomal.cc 放在一台 VPS 上,几个月下来,发现虽然访问量不高,但流量掉的飞快,差不多用掉 1.5TB 流量。虽然是无限流量的 VPS,但看着也比较头疼。而在转换至 Hugo 后,主流的免费服务都很难使用,包括 Github 建议仓库小于 1GB,CloudFlare Pages 限制文件 2 万个, CloudFlare R2 存储限制文件 10GB,Vercel 和 Netlify 都限制流量 100GB 等等。


转换方法

考虑到 Soomal 转换为 Hugo 过程中可能存在的诸多问题,我设计了五步走的转换方案。

第一步:将 htm 文件转换为 markdown 文件

  1. 明确转换需求

    • 提取标题:在 <head> 标签中提取出文章标题。例如,<title>刘延作品 - 谈谈手机产业链和手机厂商的相互影响 [Soomal]</title> 中提取 谈谈手机产业链和手机厂商的相互影响 这个标题。
    • 提取标签:使用关键词过滤方式,找到 Htm 中的标签位置,提取标签名称,并加上引号,解决标签命名中的空格问题。
    • 提取正文:在 DOC 标签中提取出文章的正文信息,并截断 doc 标签之后的内容。
    • 提取日期、作者和页首图片信息:在 htm 中查找相应元素,并提取。
    • 提取图片:在页面中通过查找图片元素标签,将 smallpic, bigpic, smallpic2, wrappic 等图片信息全部提取出来。
    • 提取特殊信息:例如:二级标题、下载链接、表格等内容。
  2. 转换文件 由于转换需求较为明确,这里我直接用 Python 脚本进行转换。

点击查看转换脚本示例
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import os
import re
from bs4 import BeautifulSoup, Tag, NavigableString
from datetime import datetime

def convert_html_to_md(html_path, output_dir):
 try:
 # 读取GB2312编码的HTML文件
 with open(html_path, 'r', encoding='gb2312', errors='ignore') as f:
 html_content = f.read()

 soup = BeautifulSoup(html_content, 'html.parser')

 # 1. 提取标题
 title = extract_title(soup)

 # 2. 提取书签标签
 bookmarks = extract_bookmarks(soup)

 # 3. 提取标题图片和info
 title_img, info_content = extract_title_info(soup)

 # 4. 提取正文内容
 body_content = extract_body_content(soup)

 # 生成YAML frontmatter
 frontmatter = f"""---
title: "{title}"
date: {datetime.now().strftime('%Y-%m-%dT%H:%M:%S+08:00')}
tags: {bookmarks}
title_img: "{title_img}"
info: "{info_content}"
---\n\n"""

 # 生成Markdown内容
 markdown_content = frontmatter + body_content

 # 保存Markdown文件
 output_path = os.path.join(output_dir, os.path.basename(html_path).replace('.htm', '.md'))
 with open(output_path, 'w', encoding='utf-8') as f:
 f.write(markdown_content)

 return f"转换成功: {os.path.basename(html_path)}"
 except Exception as e:
 return f"转换失败 {os.path.basename(html_path)}: {str(e)}"

def extract_title(soup):
 """提取标题"""
 if soup.title:
 return soup.title.string.strip()
 return ""

def extract_bookmarks(soup):
 """提取书签标签,每个标签用双引号包裹"""
 bookmarks = []
 bookmark_element = soup.find(string=re.compile(r'本文的相关书签:'))

 if bookmark_element:
 parent = bookmark_element.find_parent(['ul', 'li'])
 if parent:
 # 提取所有<a>标签的文本
 for a_tag in parent.find_all('a'):
 text = a_tag.get_text().strip()
 if text:
 # 用双引号包裹每个标签
 bookmarks.append(f'"{text}"')

 return f"[{', '.join(bookmarks)}]" if bookmarks else "[]"

def extract_title_info(soup):
 """提取标题图片和info内容"""
 title_img = ""
 info_content = ""

 titlebox = soup.find('div', class_='titlebox')
 if titlebox:
 # 提取标题图片
 title_img_div = titlebox.find('div', class_='titleimg')
 if title_img_div and title_img_div.img:
 title_img = title_img_div.img['src']

 # 提取info内容
 info_div = titlebox.find('div', class_='info')
 if info_div:
 # 移除所有HTML标签,只保留文本
 info_content = info_div.get_text().strip()

 return title_img, info_content

def extract_body_content(soup):
 """提取正文内容并处理图片"""
 body_content = ""
 doc_div = soup.find('div', class_='Doc') # 注意是大写D

 if doc_div:
 # 移除所有小写的doc标签(嵌套的div class="doc")
 for nested_doc in doc_div.find_all('div', class_='doc'):
 nested_doc.decompose()

 # 处理图片
 process_images(doc_div)

 # 遍历所有子元素并构建Markdown内容
 for element in doc_div.children:
 if isinstance(element, Tag):
 if element.name == 'div' and 'subpagetitle' in element.get('class', []):
 # 转换为二级标题
 body_content += f"## {element.get_text().strip()}\n\n"
 else:
 # 保留其他内容
 body_content += element.get_text().strip() + "\n\n"
 elif isinstance(element, NavigableString):
 body_content += element.strip() + "\n\n"

 return body_content.strip()

def process_images(container):
 """处理图片内容(A/B/C规则)"""
 # A: 处理<li data-src>标签
 for li in container.find_all('li', attrs={'data-src': True}):
 img_url = li['data-src'].replace('..', 'https://soomal.cc', 1)
 caption_div = li.find('div', class_='caption')
 content_div = li.find('div', class_='content')

 alt_text = caption_div.get_text().strip() if caption_div else ""
 meta_text = content_div.get_text().strip() if content_div else ""

 # 创建Markdown图片语法
 img_md = f"![{alt_text}]({img_url})\n\n{meta_text}\n\n"
 li.replace_with(img_md)

 # B: 处理<span class="smallpic">标签
 for span in container.find_all('span', class_='smallpic'):
 img = span.find('img')
 if img and 'src' in img.attrs:
 img_url = img['src'].replace('..', 'https://soomal.cc', 1)
 caption_div = span.find('div', class_='caption')
 content_div = span.find('div', class_='content')

 alt_text = caption_div.get_text().strip() if caption_div else ""
 meta_text = content_div.get_text().strip() if content_div else ""

 # 创建Markdown图片语法
 img_md = f"![{alt_text}]({img_url})\n\n{meta_text}\n\n"
 span.replace_with(img_md)

 # C: 处理<div class="bigpic">标签
 for div in container.find_all('div', class_='bigpic'):
 img = div.find('img')
 if img and 'src' in img.attrs:
 img_url = img['src'].replace('..', 'https://soomal.cc', 1)
 caption_div = div.find('div', class_='caption')
 content_div = div.find('div', class_='content')

 alt_text = caption_div.get_text().strip() if caption_div else ""
 meta_text = content_div.get_text().strip() if content_div else ""

 # 创建Markdown图片语法
 img_md = f"![{alt_text}]({img_url})\n\n{meta_text}\n\n"
 div.replace_with(img_md)

if __name__ == "__main__":
 input_dir = 'doc'
 output_dir = 'markdown_output'

 # 创建输出目录
 os.makedirs(output_dir, exist_ok=True)

 # 处理所有HTML文件
 for filename in os.listdir(input_dir):
 if filename.endswith('.htm'):
 html_path = os.path.join(input_dir, filename)
 result = convert_html_to_md(html_path, output_dir)
 print(result)

第二步:处理分类和摘要信息

受制于原来文章 htm 文件中没有包含分类信息影响,所以只能将文章分类目录单独进行处理,在处理分类时,可以顺便将文章摘要内容一并处理。

  1. 提取分类和摘要信息

    主要是通过 Python 将 2000 多个分类页面中的分类和摘要信息提取出来,并处理成数据格式。

点击查看转换代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import os
import re
from bs4 import BeautifulSoup
import codecs
from collections import defaultdict

def extract_category_info(folder_path):
 # 使用defaultdict自动初始化嵌套字典
 article_categories = defaultdict(set) # 存储文章ID到分类的映射
 article_summaries = {} # 存储文章ID到摘要的映射

 # 遍历文件夹中的所有htm文件
 for filename in os.listdir(folder_path):
 if not filename.endswith('.htm'):
 continue

 file_path = os.path.join(folder_path, filename)

 try:
 # 使用GB2312编码读取文件并转换为UTF-8
 with codecs.open(file_path, 'r', encoding='gb2312', errors='replace') as f:
 content = f.read()

 soup = BeautifulSoup(content, 'html.parser')

 # 提取分类名称
 title_tag = soup.title
 if title_tag:
 title_text = title_tag.get_text().strip()
 # 提取第一个短横杠前的内容
 category_match = re.search(r'^([^-]+)', title_text)
 if category_match:
 category_name = category_match.group(1).strip()
 # 如果分类名称包含空格,则添加双引号
 if ' ' in category_name:
 category_name = f'"{category_name}"'
 else:
 category_name = "Unknown_Category"
 else:
 category_name = "Unknown_Category"

 # 提取文章信息
 for item in soup.find_all('div', class_='item'):
 # 提取文章ID
 article_link = item.find('a', href=True)
 if article_link:
 href = article_link['href']
 article_id = re.search(r'../doc/(\d+)\.htm', href)
 if article_id:
 article_id = article_id.group(1)
 else:
 continue
 else:
 continue

 # 提取文章摘要
 synopsis_div = item.find('div', class_='synopsis')
 synopsis = synopsis_div.get_text().strip() if synopsis_div else ""

 # 存储文章分类信息
 article_categories[article_id].add(category_name)

 # 存储摘要信息(只保存一次,避免重复覆盖)
 if article_id not in article_summaries:
 article_summaries[article_id] = synopsis

 except UnicodeDecodeError:
 # 尝试使用GBK编码作为备选方案
 try:
 with codecs.open(file_path, 'r', encoding='gbk', errors='replace') as f:
 content = f.read()
 # 重新处理内容...
 # 注意:这里省略了重复的处理代码,实际应用中应提取为函数
 # 但为了保持代码完整,我们将重复处理逻辑
 soup = BeautifulSoup(content, 'html.parser')
 title_tag = soup.title
 if title_tag:
 title_text = title_tag.get_text().strip()
 category_match = re.search(r'^([^-]+)', title_text)
 if category_match:
 category_name = category_match.group(1).strip()
 if ' ' in category_name:
 category_name = f'"{category_name}"'
 else:
 category_name = "Unknown_Category"
 else:
 category_name = "Unknown_Category"

 for item in soup.find_all('div', class_='item'):
 article_link = item.find('a', href=True)
 if article_link:
 href = article_link['href']
 article_id = re.search(r'../doc/(\d+)\.htm', href)
 if article_id:
 article_id = article_id.group(1)
 else:
 continue
 else:
 continue

 synopsis_div = item.find('div', class_='synopsis')
 synopsis = synopsis_div.get_text().strip() if synopsis_div else ""

 article_categories[article_id].add(category_name)

 if article_id not in article_summaries:
 article_summaries[article_id] = synopsis

 except Exception as e:
 print(f"处理文件 {filename} 时出错(尝试GBK后): {str(e)}")
 continue

 except Exception as e:
 print(f"处理文件 {filename} 时出错: {str(e)}")
 continue

 return article_categories, article_summaries

def save_to_markdown(article_categories, article_summaries, output_path):
 with open(output_path, 'w', encoding='utf-8') as md_file:
 # 写入Markdown标题
 md_file.write("# 文章分类与摘要信息\n\n")
 md_file.write("> 本文件包含所有文章的ID、分类和摘要信息\n\n")

 # 按文章ID排序
 sorted_article_ids = sorted(article_categories.keys(), key=lambda x: int(x))

 for article_id in sorted_article_ids:
 # 获取分类列表并排序
 categories = sorted(article_categories[article_id])
 # 格式化为列表字符串
 categories_str = ", ".join(categories)

 # 获取摘要
 summary = article_summaries.get(article_id, "无摘要内容")

 # 写入Markdown内容
 md_file.write(f"## 文件名: {article_id}\n")
 md_file.write(f"**分类**: {categories_str}\n")
 md_file.write(f"**摘要**: {summary}\n\n")
 md_file.write("---\n\n")

if __name__ == "__main__":
 # 配置输入和输出路径
 input_folder = '分类' # 替换为你的HTM文件夹路径
 output_md = 'articles_categories.md'

 # 执行提取
 article_categories, article_summaries = extract_category_info(input_folder)

 # 保存结果到Markdown文件
 save_to_markdown(article_categories, article_summaries, output_md)

 # 打印统计信息
 print(f"成功处理 {len(article_categories)} 篇文章的数据")
 print(f"已保存到 {output_md}")
 print(f"处理过程中发现 {len(article_summaries)} 篇有摘要的文章")
  1. 将分类和摘要信息写入 markdown 文件

    这一步比较简单,将上边提取出的分类和摘要数据逐个写入先前转换的 markdown 文件。

点击查看写入脚本
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import os
import re
import ruamel.yaml
from collections import defaultdict

def parse_articles_categories(md_file_path):
 """
 解析articles_categories.md文件,提取文章ID、分类和摘要信息
 """
 article_info = defaultdict(dict)
 current_id = None

 try:
 with open(md_file_path, 'r', encoding='utf-8') as f:
 for line in f:
 # 匹配文件名
 filename_match = re.match(r'^## 文件名: (\d+)$', line.strip())
 if filename_match:
 current_id = filename_match.group(1)
 continue

 # 匹配分类信息
 categories_match = re.match(r'^\*\*分类\*\*: (.+)$', line.strip())
 if categories_match and current_id:
 categories_str = categories_match.group(1)
 # 清理分类字符串,移除多余空格和引号
 categories = [cat.strip().strip('"') for cat in categories_str.split(',')]
 article_info[current_id]['categories'] = categories
 continue

 # 匹配摘要信息
 summary_match = re.match(r'^\*\*摘要\*\*: (.+)$', line.strip())
 if summary_match and current_id:
 summary = summary_match.group(1)
 article_info[current_id]['summary'] = summary
 continue

 # 遇到分隔线时重置当前ID
 if line.startswith('---'):
 current_id = None

 except Exception as e:
 print(f"解析articles_categories.md文件时出错: {str(e)}")

 return article_info

def update_markdown_files(article_info, md_folder):
 """
 更新Markdown文件,添加分类和摘要信息到frontmatter
 """
 updated_count = 0
 skipped_count = 0

 # 初始化YAML解析器
 yaml = ruamel.yaml.YAML()
 yaml.preserve_quotes = True
 yaml.width = 1000 # 避免长摘要被换行

 for filename in os.listdir(md_folder):
 if not filename.endswith('.md'):
 continue

 article_id = filename[:-3] # 去除.md后缀
 file_path = os.path.join(md_folder, filename)

 # 检查是否有此文章的信息
 if article_id not in article_info:
 skipped_count += 1
 continue

 try:
 with open(file_path, 'r', encoding='utf-8') as f:
 content = f.read()

 # 解析frontmatter
 frontmatter_match = re.search(r'^---\n(.*?)\n---', content, re.DOTALL)
 if not frontmatter_match:
 print(f"文件 {filename} 中没有找到frontmatter,跳过")
 skipped_count += 1
 continue

 frontmatter_content = frontmatter_match.group(1)

 # 将frontmatter转为字典
 data = yaml.load(frontmatter_content)
 if data is None:
 data = {}

 # 添加分类和摘要信息
 info = article_info[article_id]

 # 添加分类
 if 'categories' in info:
 # 如果已存在分类,则合并(去重)
 existing_categories = set(data.get('categories', []))
 new_categories = set(info['categories'])
 combined_categories = sorted(existing_categories.union(new_categories))
 data['categories'] = combined_categories

 # 添加摘要(如果摘要存在且不为空)
 if 'summary' in info and info['summary']:
 # 只有当摘要不存在或新摘要不为空时才更新
 if 'summary' not in data or info['summary']:
 data['summary'] = info['summary']

 # 重新生成frontmatter
 new_frontmatter = '---\n'
 with ruamel.yaml.StringIO() as stream:
 yaml.dump(data, stream)
 new_frontmatter += stream.getvalue().strip()
 new_frontmatter += '\n---'

 # 替换原frontmatter
 new_content = content.replace(frontmatter_match.group(0), new_frontmatter)

 # 写入文件
 with open(file_path, 'w', encoding='utf-8') as f:
 f.write(new_content)

 updated_count += 1

 except Exception as e:
 print(f"更新文件 {filename} 时出错: {str(e)}")
 skipped_count += 1

 return updated_count, skipped_count

if __name__ == "__main__":
 # 配置路径
 articles_md = 'articles_categories.md' # 包含分类和摘要信息的Markdown文件
 md_folder = 'markdown_output' # 包含Markdown文章的文件夹

 # 解析articles_categories.md文件
 print("正在解析articles_categories.md文件...")
 article_info = parse_articles_categories(articles_md)
 print(f"成功解析 {len(article_info)} 篇文章的信息")

 # 更新Markdown文件
 print(f"\n正在更新 {len(article_info)} 篇文章的分类和摘要信息...")
 updated, skipped = update_markdown_files(article_info, md_folder)

 # 打印统计信息
 print(f"\n处理完成!")
 print(f"成功更新: {updated} 个文件")
 print(f"跳过处理: {skipped} 个文件")
 print(f"找到信息的文章: {len(article_info)} 篇")

第三步:转换文章 frontmatter 信息

这一步主要是对输出的 markdown 文件中 frontmatter 部分进行修正,以适应 Hugo 主题需要。

  1. 将文章头部信息转按 frontmatter 规范进行修正 主要是处理包括特殊字符,日期格式,作者,文章首图、标签、分类等内容。
查看转换代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
import os
import re
import frontmatter
import yaml
from datetime import datetime

def escape_special_characters(text):
 """转义YAML中的特殊字符"""
 # 转义反斜杠,但保留已经转义的字符
 return re.sub(r'(?<!\\)\\(?!["\\/bfnrt]|u[0-9a-fA-F]{4})', r'\\\\', text)

def process_md_files(folder_path):
 for filename in os.listdir(folder_path):
 if filename.endswith(".md"):
 file_path = os.path.join(folder_path, filename)
 try:
 # 读取文件内容
 with open(file_path, 'r', encoding='utf-8') as f:
 content = f.read()

 # 手动分割frontmatter和内容
 if content.startswith('---\n'):
 parts = content.split('---\n', 2)
 if len(parts) >= 3:
 fm_text = parts[1]
 body_content = parts[2] if len(parts) > 2 else ""

 # 转义特殊字符
 fm_text = escape_special_characters(fm_text)

 # 重新组合内容
 new_content = f"---\n{fm_text}---\n{body_content}"

 # 使用安全加载方式解析frontmatter
 post = frontmatter.loads(new_content)

 # 处理info字段
 if 'info' in post.metadata:
 info = post.metadata['info']

 # 提取日期
 date_match = re.search(r'于 (\d{4}\.\d{1,2}\.\d{1,2} \d{1,2}:\d{2}:\d{2})', info)
 if date_match:
 date_str = date_match.group(1)
 try:
 dt = datetime.strptime(date_str, "%Y.%m.%d %H:%M:%S")
 post.metadata['date'] = dt.strftime("%Y-%m-%dT%H:%M:%S+08:00")
 except ValueError:
 # 保留原始日期作为备选
 pass

 # 提取作者
 author_match = re.match(r'^(.+?)作品', info)
 if author_match:
 authors = author_match.group(1).strip()
 # 分割多个作者
 author_list = [a.strip() for a in re.split(r'\s+', authors) if a.strip()]
 post.metadata['author'] = author_list

 # 创建description
 desc_parts = info.split('|', 1)
 if len(desc_parts) > 1:
 post.metadata['description'] = desc_parts[1].strip()

 # 删除原始info
 del post.metadata['info']

 # 处理title_img
 if 'title_img' in post.metadata:
 img_url = post.metadata['title_img'].replace("../", "https://soomal.cc/")
 # 处理可能的双斜杠
 img_url = re.sub(r'(?<!:)/{2,}', '/', img_url)
 post.metadata['cover'] = {
 'image': img_url,
 'caption': "",
 'alt': "",
 'relative': False
 }
 del post.metadata['title_img']

 # 修改title
 if 'title' in post.metadata:
 title = post.metadata['title']
 # 移除"-"之前的内容
 if '-' in title:
 new_title = title.split('-', 1)[1].strip()
 post.metadata['title'] = new_title

 # 保存修改后的文件
 with open(file_path, 'w', encoding='utf-8') as f_out:
 f_out.write(frontmatter.dumps(post))
 except Exception as e:
 print(f"处理文件 {filename} 时出错: {str(e)}")
 # 记录错误文件以便后续检查
 with open("processing_errors.log", "a", encoding="utf-8") as log:
 log.write(f"Error in {filename}: {str(e)}\n")

if __name__ == "__main__":
 folder_path = "markdown_output" # 替换为您的实际路径
 process_md_files(folder_path)
 print("所有Markdown文件frontmatter处理完成!")
  1. 精简标签和分类 Soomal.com 原本有 20 多个文章分类,但其中个别分类没有什么意义,比如“全部文章”分类,并且文章分类和文章标签有不少重复现象,为保证分类和标签的唯一性,对这部分进一步精简。另一个目的也是为了在最后生成网站文件时尽量减少文件数量。
查看精简标签和分类代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import os
import yaml
import frontmatter

def clean_hugo_tags_categories(folder_path):
 """
 清理Hugo文章的标签和分类信息
 1. 删除分类中的"所有文章"
 2. 移除标签中与分类重复的项
 """
 # 需要保留的分类列表(已移除"所有文章")
 valid_categories = [
 "数码设备", "音频", "音乐", "移动数码", "评论", "介绍", "测评报告", "图集",
 "智能手机", "Android", "耳机", "音乐人", "影像", "数码终端", "音箱", "iOS",
 "相机", "声卡", "品碟", "平板电脑", "技术", "应用", "随身听", "Windows",
 "数码附件", "随笔", "解码器", "音响", "镜头", "乐器", "音频编解码"
 ]

 # 遍历文件夹中的所有Markdown文件
 for filename in os.listdir(folder_path):
 if not filename.endswith('.md'):
 continue

 filepath = os.path.join(folder_path, filename)
 with open(filepath, 'r', encoding='utf-8') as f:
 post = frontmatter.load(f)

 # 1. 清理分类(删除无效分类并去重)
 if 'categories' in post.metadata:
 # 转换为集合去重 + 过滤无效分类
 categories = list(set(post.metadata['categories']))
 cleaned_categories = [
 cat for cat in categories
 if cat in valid_categories
 ]
 post.metadata['categories'] = cleaned_categories

 # 2. 清理标签(移除与分类重复的项)
 if 'tags' in post.metadata:
 current_cats = post.metadata.get('categories', [])
 # 转换为集合去重 + 过滤与分类重复项
 tags = list(set(post.metadata['tags']))
 cleaned_tags = [
 tag for tag in tags
 if tag not in current_cats
 ]
 post.metadata['tags'] = cleaned_tags

 # 保存修改后的文件
 with open(filepath, 'w', encoding='utf-8') as f_out:
 f_out.write(frontmatter.dumps(post))

if __name__ == "__main__":
 # 使用示例(修改为你的实际路径)
 md_folder = "./markdown_output"
 clean_hugo_tags_categories(md_folder)
 print(f"已完成处理: {len(os.listdir(md_folder))} 个文件")

第四步:精简图片数量

在 htm 转 md 文件的过程中,由于只提取文章内的信息,所以原网站中很多裁切图片已不再需要。为此,可以按照转换后的 md 文件内容,对应查找原网站图片,筛选出新网站所需的图片即可。

通过本步骤,网站所需图片数量从原来的 32.6 万下降到 11.8 万。

  1. 提取图片链接 从 md 文件中,提取出所有的图片链接。由于此前在转换图片连接时已经有统一的图片连接格式,所以操作起来比较容易。
查看提取代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import os
import re
import argparse

def extract_image_links(directory):
 """提取目录中所有md文件的图片链接"""
 image_links = set()
 pattern = re.compile(r'https://soomal\.cc[^\s\)\]\}]*?\.jpg', re.IGNORECASE)

 for root, _, files in os.walk(directory):
 for filename in files:
 if filename.endswith('.md'):
 filepath = os.path.join(root, filename)
 try:
 with open(filepath, 'r', encoding='utf-8') as f:
 content = f.read()
 matches = pattern.findall(content)
 if matches:
 image_links.update(matches)
 except Exception as e:
 print(f"处理文件 {filepath} 时出错: {str(e)}")

 return sorted(image_links)

def save_links_to_file(links, output_file):
 """将链接保存到文件"""
 with open(output_file, 'w', encoding='utf-8') as f:
 for link in links:
 f.write(link + '\n')

if __name__ == "__main__":
 parser = argparse.ArgumentParser(description='提取Markdown中的图片链接')
 parser.add_argument('--input', default='markdown_output', help='Markdown文件目录路径')
 parser.add_argument('--output', default='image_links.txt', help='输出文件路径')
 args = parser.parse_args()

 print(f"正在扫描目录: {args.input}")
 links = extract_image_links(args.input)

 print(f"找到 {len(links)} 个唯一图片链接")
 save_links_to_file(links, args.output)
 print(f"链接已保存至: {args.output}")
  1. 复制对应图片 使用上边提取出的图片链接数据,从原网站目录中查找对应文件并提取。过程中需要注意文件目录的准确性。
A.查看Windows中复制代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import os
import shutil
import time
import sys

def main():
 # 配置参数
 source_drive = "F:\\"
 target_drive = "D:\\"
 image_list_file = r"D:\trans-soomal\image_links.txt"
 log_file = r"D:\trans-soomal\image_copy_log.txt"
 error_log_file = r"D:\trans-soomal\image_copy_errors.txt"

 print("图片复制脚本启动...")

 # 记录开始时间
 start_time = time.time()

 # 创建日志文件
 with open(log_file, "w", encoding="utf-8") as log, open(error_log_file, "w", encoding="utf-8") as err_log:
 log.write(f"图片复制日志 - 开始时间: {time.ctime(start_time)}\n")
 err_log.write("以下文件复制失败:\n")

 try:
 # 读取图片列表
 with open(image_list_file, "r", encoding="utf-8") as f:
 image_paths = [line.strip() for line in f if line.strip()]

 total_files = len(image_paths)
 success_count = 0
 fail_count = 0
 skipped_count = 0

 print(f"找到 {total_files} 个待复制的图片文件")

 # 处理每个文件
 for i, relative_path in enumerate(image_paths):
 # 显示进度
 progress = (i + 1) / total_files * 100
 sys.stdout.write(f"\r进度: {progress:.2f}% ({i+1}/{total_files})")
 sys.stdout.flush()

 # 构建完整路径
 source_path = os.path.join(source_drive, relative_path)
 target_path = os.path.join(target_drive, relative_path)

 try:
 # 检查源文件是否存在
 if not os.path.exists(source_path):
 err_log.write(f"源文件不存在: {source_path}\n")
 fail_count += 1
 continue

 # 检查目标文件是否已存在
 if os.path.exists(target_path):
 log.write(f"文件已存在,跳过: {target_path}\n")
 skipped_count += 1
 continue

 # 创建目标目录
 target_dir = os.path.dirname(target_path)
 os.makedirs(target_dir, exist_ok=True)

 # 复制文件
 shutil.copy2(source_path, target_path)

 # 记录成功
 log.write(f"[成功] 复制 {source_path}{target_path}\n")
 success_count += 1

 except Exception as e:
 # 记录失败
 err_log.write(f"[失败] {source_path} -> {target_path} : {str(e)}\n")
 fail_count += 1

 # 计算耗时
 end_time = time.time()
 elapsed_time = end_time - start_time
 minutes, seconds = divmod(elapsed_time, 60)
 hours, minutes = divmod(minutes, 60)

 # 写入统计信息
 summary = f"""
================================
复制操作完成
开始时间: {time.ctime(start_time)}
结束时间: {time.ctime(end_time)}
总耗时: {int(hours)}小时 {int(minutes)}分钟 {seconds:.2f}
文件总数: {total_files}
成功复制: {success_count}
跳过(已存在): {skipped_count}
失败: {fail_count}
================================
"""
 log.write(summary)
 print(summary)

 except Exception as e:
 print(f"\n发生错误: {str(e)}")
 err_log.write(f"脚本错误: {str(e)}\n")

if __name__ == "__main__":
 main()
B.查看Linux中复制代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
#!/bin/bash

# 配置参数
LINK_FILE="/user/image_links.txt" # 替换为实际链接文件路径
SOURCE_BASE="/user/soomal.cc/index"
DEST_BASE="/user/images.soomal.cc/index"
LOG_FILE="/var/log/image_copy_$(date +%Y%m%d_%H%M%S).log"
THREADS=3 # 自动获取CPU核心数作为线程数

# 开始记录日志
{
echo "===== 复制任务开始: $(date) ====="
echo "源基础目录: $SOURCE_BASE"
echo "目标基础目录: $DEST_BASE"
echo "链接文件: $LINK_FILE"
echo "使用线程数: $THREADS"

# 验证路径示例
echo -e "\n=== 路径验证 ==="
sample_url="https://soomal.cc/images/doc/20090406/00000007.jpg"
expected_src="${SOURCE_BASE}/images/doc/20090406/00000007.jpg"
expected_dest="${DEST_BASE}/images/doc/20090406/00000007.jpg"

echo "示例URL: $sample_url"
echo "预期源路径: $expected_src"
echo "预期目标路径: $expected_dest"

if [[ -f "$expected_src" ]]; then
 echo "验证成功:示例源文件存在"
else
 echo "验证失败:示例源文件不存在!请检查路径"
 exit 1
fi

# 创建目标基础目录
mkdir -p "${DEST_BASE}/images"

# 准备并行处理
echo -e "\n=== 开始处理 ==="
total=$(wc -l < "$LINK_FILE")
echo "总链接数: $total"
counter=0

# 处理函数
process_link() {
 local url="$1"
 local rel_path="${url#https://soomal.cc}"

 # 构建完整路径
 local src_path="${SOURCE_BASE}${rel_path}"
 local dest_path="${DEST_BASE}${rel_path}"

 # 创建目标目录
 mkdir -p "$(dirname "$dest_path")"

 # 复制文件
 if [[ -f "$src_path" ]]; then
 if cp -f "$src_path" "$dest_path"; then
 echo "SUCCESS: $rel_path"
 return 0
 else
 echo "COPY FAILED: $rel_path"
 return 2
 fi
 else
 echo "MISSING: $rel_path"
 return 1
 fi
}

# 导出函数以便并行使用
export -f process_link
export SOURCE_BASE DEST_BASE

# 使用parallel进行并行处理
echo "启动并行复制..."
parallel --bar --jobs $THREADS --progress \
 --halt soon,fail=1 \
 --joblog "${LOG_FILE}.jobs" \
 --tagstring "{}" \
 "process_link {}" < "$LINK_FILE" | tee -a "$LOG_FILE"

# 统计结果
success=$(grep -c 'SUCCESS:' "$LOG_FILE")
missing=$(grep -c 'MISSING:' "$LOG_FILE")
failed=$(grep -c 'COPY FAILED:' "$LOG_FILE")

# 最终统计
echo -e "\n===== 复制任务完成: $(date) ====="
echo "总链接数: $total"
echo "成功复制: $success"
echo "缺失文件: $missing"
echo "复制失败: $failed"
echo "成功率: $((success * 100 / total))%"

} | tee "$LOG_FILE"

# 保存缺失文件列表
grep '^MISSING:' "$LOG_FILE" | cut -d' ' -f2- > "${LOG_FILE%.log}_missing.txt"
echo "缺失文件列表: ${LOG_FILE%.log}_missing.txt"

第五步:压缩图片体积

我此前已经对网站源图进行过一次压缩,但还不够,我期望是将图片容量压缩到 10 GB 以内,用以适应日后可能需要迁移到 CloudFlare R2 的限制要求。

  1. 将 JPG 转换为 Webp 我此前使用 webp 对图片压缩后,考虑到 htm 众多,为避免图片无法访问,仍以 JPG 格式将图片保存。由于这次需要搬迁到 Hugo,JPG 格式也就没必要继续保留,直接转换为 Webp 即可。另外,由于我网页已经设置 960px 宽度,考虑到网站体积,也没有引入 fancy 灯箱等插件,直接使用 960px 缩放图片可以进一步压缩体积。

实测经过这次压缩,图片体积下降到 7.7GB ,但是我发现图片处理逻辑还是有点小问题。主要是 Soomal 上不仅有很多竖版图片,也有不少横版图片,另外,960px 的宽度,在 4K 显示器下还是显得有点不够看。我最终按照图片中短边最大 1280px 质量 85% 的设定转换了图片,体积约 14GB,刚好可以放入我 20GB 硬盘的 VPS 中。另外我也按短边最大 1150px 质量 80% 测试了一下,刚好可以达到 10GB 体积要求。

查看图片转换代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import os
import subprocess
import time
import sys
import shutil
from pathlib import Path

def main():
 # 配置文件路径
 source_dir = Path("D:\\images") # 原始图片目录
 output_dir = Path("D:\\images_webp") # WebP输出目录
 temp_dir = Path("D:\\temp_webp") # 临时处理目录
 magick_path = "C:\\webp\\magick.exe" # ImageMagick路径

 # 创建必要的目录
 output_dir.mkdir(parents=True, exist_ok=True)
 temp_dir.mkdir(parents=True, exist_ok=True)

 # 日志文件
 log_file = output_dir / "conversion_log.txt"
 stats_file = output_dir / "conversion_stats.csv"

 print("图片转换脚本启动...")
 print(f"源目录: {source_dir}")
 print(f"输出目录: {output_dir}")
 print(f"临时目录: {temp_dir}")

 # 初始化日志
 with open(log_file, "w", encoding="utf-8") as log:
 log.write(f"图片转换日志 - 开始时间: {time.ctime()}\n")

 # 初始化统计文件
 with open(stats_file, "w", encoding="utf-8") as stats:
 stats.write("原始文件,转换后文件,原始大小(KB),转换后大小(KB),节省空间(KB),节省百分比\n")

 # 收集所有图片文件
 image_exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.gif')
 all_images = []
 for root, _, files in os.walk(source_dir):
 for file in files:
 if file.lower().endswith(image_exts):
 all_images.append(Path(root) / file)

 total_files = len(all_images)
 converted_files = 0
 skipped_files = 0
 error_files = 0

 print(f"找到 {total_files} 个图片文件需要处理")

 # 处理每个图片
 for idx, img_path in enumerate(all_images):
 try:
 # 显示进度
 progress = (idx + 1) / total_files * 100
 sys.stdout.write(f"\r进度: {progress:.2f}% ({idx+1}/{total_files})")
 sys.stdout.flush()

 # 创建相对路径结构
 rel_path = img_path.relative_to(source_dir)
 webp_path = output_dir / rel_path.with_suffix('.webp')
 webp_path.parent.mkdir(parents=True, exist_ok=True)

 # 检查是否已存在
 if webp_path.exists():
 skipped_files += 1
 continue

 # 创建临时文件路径
 temp_path = temp_dir / f"{img_path.stem}_temp.webp"

 # 获取原始文件大小
 orig_size = img_path.stat().st_size / 1024 # KB

 # 使用ImageMagick进行转换和大小调整
 cmd = [
 magick_path,
 str(img_path),
 "-resize", "960>", # 仅当宽度大于960时调整
 "-quality", "85", # 初始质量85
 "-define", "webp:lossless=false",
 str(temp_path)
 ]

 # 执行命令
 result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

 if result.returncode != 0:
 # 转换失败,记录错误
 with open(log_file, "a", encoding="utf-8") as log:
 log.write(f"[错误] 转换 {img_path} 失败: {result.stderr}\n")
 error_files += 1
 continue

 # 移动临时文件到目标位置
 shutil.move(str(temp_path), str(webp_path))

 # 获取转换后文件大小
 new_size = webp_path.stat().st_size / 1024 # KB

 # 计算节省空间
 saved = orig_size - new_size
 saved_percent = (saved / orig_size) * 100 if orig_size > 0 else 0

 # 记录统计信息
 with open(stats_file, "a", encoding="utf-8") as stats:
 stats.write(f"{img_path},{webp_path},{orig_size:.2f},{new_size:.2f},{saved:.2f},{saved_percent:.2f}\n")

 converted_files += 1

 except Exception as e:
 with open(log_file, "a", encoding="utf-8") as log:
 log.write(f"[异常] 处理 {img_path} 时出错: {str(e)}\n")
 error_files += 1

 # 完成报告
 total_size = sum(f.stat().st_size for f in output_dir.glob('**/*') if f.is_file())
 total_size_gb = total_size / (1024 ** 3) # 转换为GB

 end_time = time.time()
 elapsed = end_time - time.time()
 mins, secs = divmod(elapsed, 60)
 hours, mins = divmod(mins, 60)

 with open(log_file, "a", encoding="utf-8") as log:
 log.write("\n转换完成报告:\n")
 log.write(f"总文件数: {total_files}\n")
 log.write(f"成功转换: {converted_files}\n")
 log.write(f"跳过文件: {skipped_files}\n")
 log.write(f"错误文件: {error_files}\n")
 log.write(f"输出目录总大小: {total_size_gb:.2f} GB\n")

 print("\n\n转换完成!")
 print(f"总文件数: {total_files}")
 print(f"成功转换: {converted_files}")
 print(f"跳过文件: {skipped_files}")
 print(f"错误文件: {error_files}")
 print(f"输出目录总大小: {total_size_gb:.2f} GB")

 # 清理临时目录
 try:
 shutil.rmtree(temp_dir)
 print(f"已清理临时目录: {temp_dir}")
 except Exception as e:
 print(f"清理临时目录时出错: {str(e)}")

 print(f"日志文件: {log_file}")
 print(f"统计文件: {stats_file}")
 print(f"总耗时: {int(hours)}小时 {int(mins)}分钟 {secs:.2f}秒")

if __name__ == "__main__":
 main()
  1. 进一步压缩图片 本来我设计了这一步,即在前边转换+缩放后,假如图片未能压缩到10GB以下,就继续启用压缩,但没想到前一步就把图片问题解决,也就没必要继续压缩。但我还是测试了一下,按照短边最大 1280px 60% 质量压缩为 webp 后,总容量只有 9GB。
查看图片二次压缩代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import os
import subprocess
import time
import sys
import shutil
from pathlib import Path

def main():
 # 配置文件路径
 webp_dir = Path("D:\\images_webp") # WebP图片目录
 temp_dir = Path("D:\\temp_compress") # 临时处理目录
 cwebp_path = "C:\\Windows\\System32\\cwebp.exe" # cwebp路径

 # 创建临时目录
 temp_dir.mkdir(parents=True, exist_ok=True)

 # 日志文件
 log_file = webp_dir / "compression_log.txt"
 stats_file = webp_dir / "compression_stats.csv"

 print("WebP压缩脚本启动...")
 print(f"处理目录: {webp_dir}")
 print(f"临时目录: {temp_dir}")

 # 初始化日志
 with open(log_file, "w", encoding="utf-8") as log:
 log.write(f"WebP压缩日志 - 开始时间: {time.ctime()}\n")

 # 初始化统计文件
 with open(stats_file, "w", encoding="utf-8") as stats:
 stats.write("原始文件,压缩后文件,原始大小(KB),新大小(KB),节省空间(KB),节省百分比\n")

 # 收集所有WebP文件
 all_webp = list(webp_dir.glob('**/*.webp'))
 total_files = len(all_webp)

 if total_files == 0:
 print("未找到WebP文件,请先运行转换脚本")
 return

 print(f"找到 {total_files} 个WebP文件需要压缩")

 compressed_count = 0
 skipped_count = 0
 error_count = 0

 # 处理每个WebP文件
 for idx, webp_path in enumerate(all_webp):
 try:
 # 显示进度
 progress = (idx + 1) / total_files * 100
 sys.stdout.write(f"\r进度: {progress:.2f}% ({idx+1}/{total_files})")
 sys.stdout.flush()

 # 原始大小
 orig_size = webp_path.stat().st_size / 1024 # KB

 # 创建临时文件路径
 temp_path = temp_dir / f"{webp_path.stem}_compressed.webp"

 # 使用cwebp进行二次压缩
 cmd = [
 cwebp_path,
 "-q", "75", # 质量参数
 "-m", "6", # 最高压缩模式
 str(webp_path),
 "-o", str(temp_path)
 ]

 # 执行命令
 result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

 if result.returncode != 0:
 # 压缩失败,记录错误
 with open(log_file, "a", encoding="utf-8") as log:
 log.write(f"[错误] 压缩 {webp_path} 失败: {result.stderr}\n")
 error_count += 1
 continue

 # 获取新文件大小
 new_size = temp_path.stat().st_size / 1024 # KB

 # 如果新文件比原文件大,则跳过
 if new_size >= orig_size:
 skipped_count += 1
 temp_path.unlink() # 删除临时文件
 continue

 # 计算节省空间
 saved = orig_size - new_size
 saved_percent = (saved / orig_size) * 100 if orig_size > 0 else 0

 # 记录统计信息
 with open(stats_file, "a", encoding="utf-8") as stats:
 stats.write(f"{webp_path},{webp_path},{orig_size:.2f},{new_size:.2f},{saved:.2f},{saved_percent:.2f}\n")

 # 替换原文件
 webp_path.unlink() # 删除原文件
 shutil.move(str(temp_path), str(webp_path))
 compressed_count += 1

 except Exception as e:
 with open(log_file, "a", encoding="utf-8") as log:
 log.write(f"[异常] 处理 {webp_path} 时出错: {str(e)}\n")
 error_count += 1

 # 完成报告
 total_size = sum(f.stat().st_size for f in webp_dir.glob('**/*') if f.is_file())
 total_size_gb = total_size / (1024 ** 3) # 转换为GB

 end_time = time.time()
 elapsed = end_time - time.time()
 mins, secs = divmod(elapsed, 60)
 hours, mins = divmod(mins, 60)

 with open(log_file, "a", encoding="utf-8") as log:
 log.write("\n压缩完成报告:\n")
 log.write(f"处理文件数: {total_files}\n")
 log.write(f"成功压缩: {compressed_count}\n")
 log.write(f"跳过文件: {skipped_count}\n")
 log.write(f"错误文件: {error_count}\n")
 log.write(f"输出目录总大小: {total_size_gb:.2f} GB\n")

 print("\n\n压缩完成!")
 print(f"处理文件数: {total_files}")
 print(f"成功压缩: {compressed_count}")
 print(f"跳过文件: {skipped_count}")
 print(f"错误文件: {error_count}")
 print(f"输出目录总大小: {total_size_gb:.2f} GB")

 # 清理临时目录
 try:
 shutil.rmtree(temp_dir)
 print(f"已清理临时目录: {temp_dir}")
 except Exception as e:
 print(f"清理临时目录时出错: {str(e)}")

 print(f"日志文件: {log_file}")
 print(f"统计文件: {stats_file}")
 print(f"总耗时: {int(hours)}小时 {int(mins)}分钟 {secs:.2f}秒")

if __name__ == "__main__":
 main()

构建方案

选择合适 Hugo 模板

对于一个上万 md 文件的 Hugo 项目来说,选择模板真是很折磨人。

我试过用一款比较精美的模板测试,发现连续构建三个小时都没能生成结束;试过有的模板生成过程中不断的报错;试过有的模板生成文件数量超过 20 万个。

最后我选择了最稳妥的 PaperMod 模板,这个模板默认只有 100 多个文件,生成网站后文件总数不到 5 万,总体来说还算好。

虽然达不到 Cloudflare Page 2万个文件的限制标准,但也相当精简了,在 Github Pages 上构建花了 6 分半钟,在 Vercel 上构建花了 8 分钟。

不过,在构建过程中还是发生一些小问题,比如搜索功能,因为文章数据实在有点大,默认索引文件达到 80MB,几乎没有实用性,最后只能忍痛将索引限制在文章标题和摘要内容上。

还有站点地图生成问题,默认生成站点地图 4MB,在提交到 Google Console 后一直读取失败。Bing Webmaster 那边倒是没问题。

另外,分页问题也是个比较头疼的是。默认 12000 个标签,采用 20 篇文章一个页面,都能生成 6 万文件,在我将文章数提高到 200 一个页面后,仍然有 3.7 万个文件。与此同时,其他文件加起来也只有 1.2 万个。

不过,这个标签问题倒也也给了进一步改造的可能性,即只提取前使用数量在前 1000 位的标签,将其他标签作为标题的一部分。这样,应该可以将文件数控制在 2 万以内,满足 Cloudflare Pages 的限制需求。

选择合适的静态页面托管服务

由于 Hugo 项目本身只有 100MB 不到(其中文章 md 文件 80M),所以托管到 Github 没有任何问题。考虑到 Github Pages 访问速度较慢,我选择将网站部署到 Vercel,虽然 Vercel 只有 100GB 流量,但对于静态页面来说,应该是够用了。

选择合适的图片托管服务

目前仍在找。本想将图片托管到 Cloudflare R2,但看那个免费计划也有点不敢用,虽然有一定免费额度,但怕爆账单。先继续用我 7 美刀包年的假阿里云 VPS 吧。

印度的强奸率为何比中国还低

2025-06-26 16:00:00

Featured image of post 印度的强奸率为何比中国还低

在知乎上看到一个稀奇古怪的观点,说当前中国的强奸率比印度还高,质疑中国是否还是一个安全的国家。经过一番研究,发现这背后确实存在一些有意思的现象。


对数据来源的重复确认

首先,知乎上这个答案提到的数据来源确实没什么大的问题。中国和印度的数据,都源自双方警务部门数据,可以说算得上是正常的对比。而其他一些国家的案件数,有的来源检察部门,有的来自法院最终判决数,相对来说对比的范围会有所区别。

我手头上正好有一本 2024 年《中国法律年鉴》,上边摘录了 2023 年公安部的刑事立案数,其中强奸立案是 39038 宗。按照国际通用的每 10 万人犯罪率换算下来,大约是 每 10 万人 2.8 件。

2024 中国法律年鉴

而印度的数据,我从网上查到印度内政部犯罪记录局的 报告 ,从 2017 - 2022 年,每年大概都是 3 万件左右。

Rape in India

到这里,可以说,中印两国以相近的人口,确实统计出了相近的强奸案件数。


数据与现实的巨大反差

虽然数据上看,中印强奸案数量似乎都差不多,但这种数据很不符合公众的印象。毕竟印度的强奸案,那真是五花八门,千奇百怪,一想起来就让人心惊胆战,而中国的强奸案,从日常感受来看,绝对说不上“严重”。

例如,美国每年都针对中印等国搞所谓的“人权报告”,其中关于中国这方面的批评,一句话写完了:

(在中国)强奸妇女是违法的,可判处三年监禁至死刑。法律不承认配偶强奸或同性强奸。另有一项关于性侵犯的法律,涵盖男性受害者,但最高刑罚较轻,为五年监禁。大多数强奸指控都是通过私人和解而非起诉结案的。

而关于印度的批评,却写了三页纸 报告原文

(在印度)法律根据受害者年龄等因素对强奸行为规定了不同程度的惩罚。该法律将大多数强奸案件定为犯罪,最低刑罚为10年监禁,但当女性年龄超过15岁时,婚内强奸并不违法。据法律专家称,法律并未将强奸成年男性定为犯罪。强奸16岁以下女孩的最低刑期为20年至终身监禁;轮奸12岁以下女孩的最低刑期为终身监禁或死刑。国家审查委员会(NCRB)的《2021年印度犯罪报告》指出,2021年(最新数据)报告了428,278起针对女性的犯罪案件,包括强奸和家庭暴力。全国犯罪统计数据显示,来自边缘化、弱势和部落社区的女性,包括达利特女性,是尤为严重的受害者。强奸受害者的执法和法律援助不足,司法系统也无法有效解决这个问题。非政府组织国际妇女研究中心指出,强奸案定罪率低是性暴力持续不断且有时未报告的主要原因之一。非政府组织观察到,审判时间过长、幸存者支持不足以及对证人和幸存者的保护不足是主要问题。政府努力解决强奸案审判时间过长的问题,并寻求加快涉及女性的案件的审理速度。截至7月,全国已有855个快速审理法庭投入运作。此外,一些高等法院还指示州政府设立更多快速审理法庭,以迅速结案。在某些情况下,警方鼓励强奸受害者与施暴者和解,或鼓励女性强奸受害者与施暴者结婚。有报道称,在对强奸受害者进行法医检查时,警方使用了所谓的贞操测试。民间社会组织为暴力幸存者提供性侵犯意识培训和以幸存者为中心、非歧视性、保密且免费的护理,并协助其转介至三级医疗机构、社会福利和法律服务机构。一些组织还为遭受强奸的妇女和儿童幸存者提供短期庇护。其中一些服务旨在鼓励妇女和儿童举报案件。

中央政府实施了一些项目,以改善妇女在举报暴力行为时的安全保障。这些项目包括设立举报和获取医疗支持的中心、在警察局设立妇女服务台以方便举报、通过移动应用程序构建应急响应支持系统以报告紧急情况,以及为警察、检察官、医务人员和司法人员提供培训项目,以便他们以富有同情心和尊重的方式对待受害者。

今年5月,在德里举行的抗议活动中,数名女摔跤运动员被警方拘留,抗议活动要求逮捕印度摔跤联合会主席布里吉·布尚·沙兰·辛格,指控其性骚扰。针对辛格的案件正在调查中。截至10月26日,最高法院尚未就11名男子提前释放提出异议的请愿书作出裁决。这11名男子被判处终身监禁,他们在2002年古吉拉特邦骚乱期间轮奸了比尔基斯·巴诺,并杀害了其14名家人,其中包括巴诺3岁的女儿。根据法院量刑指南,这11名男子有资格获得假释,并于2022年获释。

事实上,只要稍微看看新闻就知道印度那些夸张的强奸事迹数不胜数;而中国这方面更多是关于强奸方面司法案件的争议。

  1. 奥里萨邦海滩轮奸案(2025年6月)

    • 标题Odisha gang-rape: NHRC seeks report from govt, police in 2 weeks
    • 内容:一名20岁女大学生在奥里萨邦哥帕尔普尔海滩庆祝节日时,遭10名男子(包括4名未成年人)轮奸。案件引发印度国家人权委员会(NHRC)介入,要求地方政府两周内提交调查报告。
  2. 以色列游客与印度民宿主人遭轮奸案(2025年3月)

    • 标题美国发布印度旅行警告,强调印度性侵犯罪问题
    • 内容:一名以色列女游客与她的印度民宿主人在卡纳塔克邦亨皮遭多名男子轮奸。案件促使美国国务院发布旅行警告,特别提醒女性游客在印度需提高警惕。
  3. 北方邦瓦拉纳西轮奸案(2025年4月)

    • 标题印度北方邦轮奸案震惊全球!19岁少女遭23人囚禁6天
    • 内容:一名19岁少女被诱骗至瓦拉纳西一家水烟吧,遭下药后遭23名男子轮流性侵并囚禁6天。案件细节曝光后引发民众抗议,警方最终逮捕部分嫌疑人。
  4. 西班牙网红夫妇遭袭案(2024年3月)

    • 标题西班牙网红在印度遭7人性侵,其丈夫也遭殴
    • 内容:一名西班牙旅游博主与丈夫在贾坎德邦杜姆卡露营时,遭7名男子袭击,女性遭轮奸,丈夫被殴打。案件促使西班牙外交部发布印度旅行警告。
  5. 婚内强奸致死案(2025年2月)

    • 标题毁三观,印度女子遭丈夫强暴致死,法院改判男方无罪引发愤怒
    • 内容:一名妻子遭丈夫强迫性行为后因重伤死亡,初审法院判丈夫10年监禁,但高等法院以“婚内强奸不构成犯罪”为由改判无罪,引发全国抗议。
  6. 柔道教练性侵未成年运动员案(2025年6月)

    • 标题印度性犯罪伸向“摔跤吧,爸爸”
    • 内容:一名柔道教练因强奸10岁国家级柔道运动员被判无期徒刑,另一起案件中,一名教练以训练为名性侵12岁男孩,被判15年监禁。

古怪的全球强奸率统计

抛开中印两国对比,回到全球各国关于强奸率的统计来看看。

我在联合国打击毒品和犯罪局官网下载了 各国强奸案件数据 ,这不看还好,一看吓一跳。

各国强奸案件发生率

发达国家强奸率普遍很高

我筛选了一下表上 150 多个国家和地区强奸率数据,其中有的国家因为没有最新数据,使用的是表上最近一年的数据。

简单排序发现,整个发达国家的强奸率几乎都遥遥领先。

其中,大英帝国以每 10 万分之 117 的强奸率荣登榜首

美国强奸率高达 10万分之 41 。

强奸率最高的国家

部分发展中国家强奸率低的离谱

把表格倒序排了一下,又是毁三观的结果。全球强奸率最低的是黎巴嫩,只有 10 万分之 0.01 的发生率,不到英国的万分之一。

沙特、叙利亚、巴勒斯坦等中东国家,集体处于全球领先水平。就连尼日利亚、莫桑比克、埃及等非洲国家强奸率都低于 10万分之 0.5 ,也属于全球领先水平。

强奸率最低的国家

这他妈都是什么鬼神仙数据!

反直觉的数据统计

如果只看亚洲部分数据,系统性偏差倒没有全球那么大,最高的蒙古是十万分之 16,但与最低值也差了 1000 倍以上,即便剔除最高值与最低值,以色列和沙特的差距也达到 150 倍。

亚洲各地强奸率

我重点研究了一下这个系统性偏差的问题,结论其实就是联合国打击毒品和犯罪局在之前报告上的一句话。

这些数据反映的是司法系统运作方式,而非实际犯罪发生率。 ——UNODC 2018 年全球犯罪报告技术注释


中印司法系统运作方式存在的巨大差别

在前边查找印度数据时,我便发现,在印度内政部犯罪记录局的报告中,就有很多稀奇古怪的数据。

印度法院审理强奸案数据

比如上边这个印度法院审理强奸案的数据。2022年印度法院积压了 17万起强奸案未审理。占新收案件的接近 7 倍。

而在中国,如此高比例的积案,几乎不可能存在。特别是在刑事诉讼领域,每年统计时,最多也就全年 1/12 的案件未结,实际相当于每年底进行统计时,当月新收的案件数。

中国法院刑事积案数

同时,我也似乎看到为什么印度强奸案数据如此偏少的一些原因。

印度警察调查效率低下

印度3成以上强奸案无法及时完成调查

在印度内政部报告中,印度每年有 3 成以上强奸案无法及时破案,特别是在强奸杀人案中,高达 4 成的强奸杀人案未能及时破案。

印度警察取证能力不足

印度2成以上强奸案在调查阶段因证据不足而撤销

在调查取证阶段,每年印度都有 2 成以上的强奸案在调查后被撤销。最终送到法院的案件数,平均每年只有 2.6 万件。大概只占调查总数 6 成。

印度法院无罪判决率高

印度超过6成的强奸案在法院作出无罪判决

在法院审理阶段,印度 6 成以上的强奸案都在法院作出无罪判决,最终每年因强奸案进监狱的人数平均不到 1 万人。甚至于,在强奸杀人案中,无罪判决的比例也接近 30 %。

印度近3成强奸杀人案以无罪判决告终

印度法院工作效率低下

如同前边提到印度法院积压案件数,在最严重的强奸/轮奸杀人案件,印度也挤压了大量案件,受审人员中,平均每年只有 13% 的嫌疑人被判刑。算下来,平均得等七八年才能判下来。

印度法院受审强奸杀人案中每年只有1成多被判刑


总结

到这里,我也懒得再看下去了,印度的报告确实看的人心惊肉跳。

以前总是怀疑,为啥印度一个强奸案就能搞的全国上下鸡飞狗跳,动则引发骚乱。

就这种货色,也好意思拿来跟中国比数据。确实有点吃饱了撑的。

最后讲讲为啥各国之间数据存在如此巨大差异。

  1. 统计标准差异

比如,美国在各种人权报告中反复提到某个国家的强奸入罪标准问题,看似无的放矢,其实也是为了给自己国家的高强奸率找补。一部分原因在于,有些欧美国家会将“未经同意的任何性接触”定义为强奸,典型就是婚内强奸,一定程度拉高了强奸率。与之对比,沙特等中东国家甚至不承认婚外强奸,认为婚外强奸属于“通奸”,除了处罚强奸者,同时也要处罚被害的女性。例如,2007年,沙特 19 岁女子遭 6 人轮奸后报案,却因“单独与陌生男性同处车内”被判鞭刑 200 下监禁半年。这种情况下,相信受害人基本都无力报案了。

  1. 文化与社会因素

很多宗教国家,对强奸有着强烈的抵触情绪,被害人容易在宗教背景下遭遇污名化和社会性死亡的风险。典型的,印度、埃及以往不少轰动全球的案件中,都出现过类似现象。例如,2014 年,印度比哈尔邦一名低种姓少女遭高种姓青年轮奸后,村长老援引《摩奴法典》“污染论”裁决受害者全家缴纳 5000 卢比赎罪金,受害者嫁予施暴者之一,同时受害人家庭被逐出种姓社区,禁止使用公共水井,受害人父亲也因“未能保护种姓纯洁”自杀。

  1. 执法能力显著区别

虽然各国都有警察,但讲白了,很多国家的警察未必是人,未必就能履行警察职责。典型的,2020 年,一名女子在印度被五名男子轮奸,她去报案时又被一名警察强奸;2021 年,印度一名 16 岁少女在 6 个月期间遭到 400 人强奸,其中包括两名警察;2022 年,印度一 13 岁女孩遭 4 人轮奸后报警,在警局又被负责人强奸;2024 年,印度一警察在加尔各答的州立医学院强奸谋杀一名年轻女医生引发当地骚乱。另外,像中美洲墨西哥、尼日利亚等部分军阀、帮派势力地区,警察作用几乎很难在普通人身上体现,富人自己养军队、养帮派、养警察,穷人只能认命。这种情况下,受害人大多也就选择忍气吞声了。