<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Liu Houliang</title><description>Liu Houliang 的个人博客，分享 Golang 开发、AI、NAS、独立开发与数码生活。</description><link>https://liuhouliang.com/</link><language>zh-cn</language><item><title>我开始习惯一股脑交给 AI，逐渐忘记古法编程</title><link>https://liuhouliang.com/post/2026half/</link><guid isPermaLink="true">https://liuhouliang.com/post/2026half/</guid><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;2026年一不小心消耗了一半额度，但每一个工作日都觉得是那么漫长。7月的河南热的让人昏昏欲睡，但下班到家开了空调又生龙活虎。&lt;/p&gt;
&lt;p&gt;最近的我也没什么写博客的动力，或者说能分享的东西也越来越少。也许我也变得越来越懒，因为我基本上全交给 AI 来写代码了，我是无情地提示词工程师。&lt;/p&gt;
&lt;p&gt;连写代码都是如此，又何况是更原始、更传统的博客呢？&lt;/p&gt;
&lt;p&gt;唯一利好地是我打字速度越来越快了，不过在很久没有更新博客后，我还是有点怀念手打博客的感觉。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;AI 降维打击了博客&lt;/div&gt;





&lt;p&gt;改变都是潜移默化和悄无声息的，以前在知乎上有不少讨论 AI 是不是吹的太厉害了、是不是泡沫、以及做不了实际工作之类的讨论。&lt;/p&gt;
&lt;p&gt;现在你再看看呢，基本上都是讨论 Codex 额度重置、Kimi 额度不经用、Claude 又封国区账号、Deepseek还没更新正式版之类的。辩论实际上没什么意义，因为一旦尝试过更强大的生产力工具，毫无例外地会最终转向它。&lt;/p&gt;
&lt;p&gt;但我发现一个问题，就是 AI 的诞生，让博客、教程、讨论都大幅减少了。原因无他，因为 AI 几乎替代了搜索引擎，也同时降维打击了教程和讨论。&lt;/p&gt;
&lt;p&gt;以前你遇到了一个问题，比如一个编译报错，你会去搜索引擎查询，有概率找到相关的解决方案，不行的话去 Github 或者这个库的文档翻阅，实在不行只能去论坛求助网友。&lt;/p&gt;
&lt;p&gt;后来演变成你把报错粘贴给 Deepseek，让它搜索文档给出解决方案，再到现在你直接在 IDE 插件里或者终端里直接让 Agent 解决这个问题。更扎心的是如果你全程 AI 生成，这个错误大概率就不会出现，因为 Agent 会在编译错误后会尝试修复。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;从半自动到全自动&lt;/div&gt;





&lt;p&gt;讲一个具体的例子吧，我主要用 GO 语言编写后端服务，一般单体架构就用 Gin + Gorm 这个架构，当然还会用到很多第三方库，比如 Redis、Zap、Cron 等。&lt;/p&gt;
&lt;p&gt;相信大家应该都有自己的 CLI 脚手架，我也一般是先用自己搭好的架构初始化项目，然后根据具体业务设计数据库表、编写核心业务代码和接口，大概是这么一个开发流程。&lt;/p&gt;
&lt;p&gt;之前我用 AI 一般是完成一个单独的任务，比如新增一个接口、设计新模块、编写测试代码、或者优化接口速度，修复BUG。也就是利用 AI 来辅助编码，一些简单的或者说重复性工作，可以用它提升效率。&lt;/p&gt;
&lt;p&gt;现在我甚至开始直接全部交给AI，直接告诉它项目核心需求，用什么语言，什么框架。先用 Plan 模式，基本上它会设计程序架构，一些没写清楚的会让你确定或者选择，确认无误后就开始编码了，而这个过程甚至比用 CLI 脚手架更快。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;提示词工程师也不是谁都能做&lt;/div&gt;





&lt;p&gt;也许你看到这里以为我是个 AI 吹，其实也不是。我是一个实用主义者，或者说是喜欢利用工具提升工作效率之人，AI 在我看来就是一个超级工具，用好了完全可以大幅提升工作效率，甚至是提升工作能力。&lt;/p&gt;
&lt;p&gt;虽然颠覆了很多技能，比如编程语言的语法、API 函数、第三方库用法，在这些维度上，AI 很大程度了抹平了不同程序员之间的差距。但并不代表每一个程序员使用 AI 都能呈现出差不多的效果。&lt;/p&gt;
&lt;p&gt;我认为现在更重要的是描述需求、系统设计、审查校验的能力，以及做决策的能力。比如你接到了一个任务，接收了一些不那么细化的产品需求，你需要整理这些需求，发掘出其中的难点。然后交给 AI 细化整理，确认技术难点是否可以解决。&lt;/p&gt;
&lt;p&gt;这期间 AI 会提出自己的建议，或者几个方案，这时候就需要你有一定的判断能力，选择更适合这个项目和团队的方案。然后把这些需求设计文档交给 AI 开始制作，用什么语言、架构、第三方库，要有自己的判断，当然也可以都听 AI 的。&lt;/p&gt;
&lt;p&gt;我觉得现在最容易出问题的是，一次让 AI 生成过多地代码，是很难挨个审查的，一方面需要的时间比较多，一方面这些代码完全不是自己的，相当于看别人的代码。真干过程序员的都知道看别人代码是最痛苦的，只看局部还好，如果要审查全部代码是否有问题，是一件相当痛苦的事。&lt;/p&gt;
&lt;p&gt;我的解决办法是让 AI 查 AI。比如我用 Deepseek V4 PRO 生成代码后，我会用别的 AI 比如 Qwen3.7 Plus 之类的进行代码审查，如果提出了比较严重的问题，我会用另一个 AI 交叉验证。&lt;/p&gt;
&lt;p&gt;这里其实也有问题，因为有的 AI 设定的比较古板，就是相当于学院派，会严格遵守一些范式和规则，但实干派的 AI 一眼就看出来这个属于过度设计或者干脆就是找茬，直接告诉你不用改。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;开始使用 OpenCode Go&lt;/div&gt;





&lt;p&gt;我的谷歌会员订阅还是被谷歌取消了，之前使用反重力比较多，Pro 会员的额度也勉强够用。这个月突然被谷歌拿下了，立马编程能力下降50%。&lt;/p&gt;
&lt;p&gt;只能选择其他订阅，在对比了 ChatGPT Plus 和 OpenCode Go 之后，还是决定先试用5美元首月的 OpenCode。&lt;/p&gt;
&lt;p&gt;OpenCode 的优点应该就是划算和模型多，基本上国内优秀的大模型都能使用，包括 GLM5.2、Kimi K3 等等。不过 Kimi 太贵了，我基本上使用 GLM5.2 和 Deepseek。&lt;/p&gt;
&lt;p&gt;OpenCode 体验不太一样的是，它用的是 TUI，就是比 CLI 稍微多了一点图形界面的形态，使用久了其实也习惯了，基本上也就是输入提示词，切换大模型和切换对话。&lt;/p&gt;
&lt;p&gt;不知道大家有没有更好的订阅计划推荐，或者使用心得分享，我打算下个月可以试试20美元的 ChatGPT Plus，对比一下。&lt;/p&gt;
&lt;p&gt;然后如果你也想试用 OpenCode 的话，可以填写我的邀请链接，我们都能获得5美元的使用额度 &lt;a href=&quot;https://opencode.ai/go?ref=MA70ZA0W2C&quot;&gt;OpenCode Go&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://opencode.ai/go?ref=MA70ZA0W2C
&lt;/code&gt;&lt;/pre&gt;
</content:encoded><category>Life</category></item><item><title>2026年我开始认真减肥了</title><link>https://liuhouliang.com/post/2026-weight-loss/</link><guid isPermaLink="true">https://liuhouliang.com/post/2026-weight-loss/</guid><pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;大学是我最瘦的时候，身高175cm，体重大概在130斤，那时候饭量也大，运动量也不算多，可能就是年轻代谢好，几乎没有小肚子。&lt;/p&gt;
&lt;p&gt;但从参加实习开始，我的体重就像吹起的气球一样一路上涨，一直维持在150斤上下。我还记得当时一起实习的大学同学，她很直白地说几乎快认不出来我了。&lt;/p&gt;
&lt;p&gt;大概是因为久坐、加班、以及放纵餐增多导致的，而体重一旦增长，就很难回去了，从那之后我的体重几乎就没有下过150，中间偶尔奋发图强，但最终都是以失败告终。&lt;/p&gt;
&lt;p&gt;而到了这两年，我的体重达到了新的巅峰，最重的时候达到了79KG，看到称上的数字着实有点吓人，但就算这样，我还是没有下定决心去减肥。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;为什么突然开始减肥&lt;/div&gt;





&lt;p&gt;相信你应该能猜到是什么让我真正开始减肥，没错，正是体检报告。&lt;/p&gt;
&lt;p&gt;其实这几年的体检报告上面都有一些略微超标的数值，几乎每年体检前我都会有点焦虑，然后体检后下定决心开始减肥自律。然后很快就开始摆烂，陷入了一个循环。&lt;/p&gt;
&lt;p&gt;可能是到了30岁开始更注重身体，也可能是体检报告上的超标太过刺眼，也可能是摸着自己的小肚有点闹心。总之，我开始认真减肥了。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;我做了哪些努力&lt;/div&gt;





&lt;p&gt;在把我的体检报告直接喂给 Deepseek 后，它给我的建议是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一是减少晚饭的摄入。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;以前我晚饭吃得挺多的，有时候甚至超过午饭。于是我决定先调整晚饭的饮食结构，减少主食和碳水摄入，以蛋白质和蔬菜为主。比如我典型的一顿晚饭构成大概是：&lt;/p&gt;
&lt;p&gt;蓝莓、无糖酸奶、生菜、鸡胸肉、黄瓜、水果玉米、鸡蛋。&lt;/p&gt;
&lt;p&gt;相比之前减少了主食以及精加工食品的摄入，热量降低了不少，而且也能填饱肚子，睡前不会觉得很饿。&lt;/p&gt;
&lt;p&gt;当然这个晚饭也不是完美的，后来我发现优质脂肪的摄入有点不足，所以有时候会再吃一小袋每日坚果。&lt;/p&gt;
&lt;p&gt;最重要的是，我几乎完全不吃零食了，之前我的晚饭热量很大一部分来自零食，比如：魔芋爽、薯条、辣条等等，现在我几乎完全不吃零食了，实在馋了可能会吃一小袋无糖果冻，或者再吃一点水果。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二是减少放纵餐。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个也是长胖的元凶之一，以前我其实工作日吃的还算克制，但一到周末就开始狠狠放纵，在外面吃什么：火锅、烧烤、烤肉、各种面食等等，我想着周末吃两顿放纵餐应该也问题不大。&lt;/p&gt;
&lt;p&gt;其实问题很大，因为这些太放纵了，集齐了：高油、高糖、高碳水、高盐、精加工一系列杀手，热量直接爆炸。再加上我平时也不怎么锻炼，一到周一开始在办公室久坐，这些就很容易转换为脂肪，导致我的体重居高不下。&lt;/p&gt;
&lt;p&gt;开始减肥后，一个多月时间了，每周最多吃一次放纵餐，比如吃小火锅，也会尽量地控制热量：不吃红油锅底，吃番茄锅；不吃炸的烤的，吃煮的；吃高蛋白的鱼肉鸡肉，多吃素菜和水果。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;减肥成果汇报&lt;/div&gt;





&lt;p&gt;当然最重要的还是成果说话，截止到目前，我已经减了10斤了，现在体重在73KG，BMI 回落到了正常区间。&lt;/p&gt;
&lt;p&gt;10斤听起来也不是很多，但我挺满意的。因为我完全没有用极端的方式减肥，就是靠调整饮食习惯，更科学地控制热量。不会因为体重骤降而出现精力降低、代谢严重下降的情况。&lt;/p&gt;
&lt;p&gt;最明显的感受是肚子上的肉明显少了，穿短袖也看不出来小肚子了，整个人感觉轻盈了一点。&lt;/p&gt;
&lt;p&gt;更重要的是，我觉得这种减肥方式是可以持续的。如果能继续保持下去也许可以形成良好的生活习惯，而不会快速反弹。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;接下来的计划&lt;/div&gt;





&lt;p&gt;我的减肥计划还没有结束，现在只是第一阶段的目标达成。接下来我会以增加肌肉作为下个阶段的目标。&lt;/p&gt;
&lt;p&gt;体重的话我初步的目标是减到72KG就可以了，继续靠饮食减肥的话，可能就不太好减了，再减下去可能掉的更多的是肌肉而不是脂肪了。&lt;/p&gt;
&lt;p&gt;接下来我打算加入一些运动，我买了引体向上杆，但是目前我一个完整的引体向上也拉不起来，肩膀上的力量和手腕的力量还是太弱了。&lt;/p&gt;
&lt;p&gt;初步的计划是增加引体向上、俯卧撑、深蹲、哑铃等室内简单训练。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;一点感想&lt;/div&gt;





&lt;p&gt;经过这一段时间的减肥，我发现减肥这件事可以说是：9分吃1分练，最重要的还是改善饮食习惯，减少高热量食品的摄入。一开始我也想着天天吃鸡胸肉生菜肯定受不了吧，吃多了其实觉得鸡胸肉稍微腌一下就挺好吃，生菜吃起来也甜甜的，完全谈不上难吃。&lt;/p&gt;
&lt;p&gt;当然最重要的是，减肥也好，运动也好，最重要的是保持身体的健康，不能一味地追求体重的减少，我觉得只要BMI正常，身体匀称就很好了。&lt;/p&gt;
&lt;p&gt;设定一个不那么严格的目标，让自己可以坚持下去，也许才是最有效的方式。如果你也在减肥，可以在评论区交流你的心得和经验，一起加油！&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>我的博客主题已开源，欢迎使用</title><link>https://liuhouliang.com/post/my-theme/</link><guid isPermaLink="true">https://liuhouliang.com/post/my-theme/</guid><description>基于 Hugo Theme Stack 打造的开箱即用博客模板，包含多项美化和功能增强。如果你喜欢我的博客样式，不妨试试它！</description><pubDate>Fri, 24 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;之前就有评论问我是否可以开源我的博客修改，不过由于该博客主题升级到了V4版本，我又做了好多修改，升级+开源的工作比较麻烦。&lt;/p&gt;
&lt;p&gt;所以拖了很久，终于上线了，欢迎大家使用~，开源版是基于 Hugo-Theme-Stack 主题 V4 版本进行修改的。&lt;/p&gt;
&lt;p&gt;这个博客主题本身就很受欢迎，有很多人都在使用，官方版本已经很不错了，也可以直接使用官方版本。&lt;/p&gt;
&lt;p&gt;项目地址：👉 &lt;a href=&quot;https://github.com/liu-houliang/hugo-stack-starter&quot;&gt;&lt;strong&gt;Hugo Stack Starter&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;我的 Demo：🌐 &lt;a href=&quot;https://liu-houliang.github.io/hugo-stack-starter/&quot;&gt;&lt;strong&gt;Live Demo&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;官方 Demo：🌐 &lt;a href=&quot;https://demo.stack.cai.im/&quot;&gt;&lt;strong&gt;Hugo Stack Official Demo&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;为什么要做这个模板？&lt;/h2&gt;
&lt;p&gt;我非常喜欢 &lt;a href=&quot;https://github.com/CaiJimmy/hugo-theme-stack&quot;&gt;Hugo Theme Stack&lt;/a&gt; 这个主题的极简和优雅，它在 GitHub 上有着极高的人气。我基于个人喜好，进行了一系列的修改：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;给代码块加上了 Mac 风格的三色圆点，并实现了长代码自动折叠。&lt;/li&gt;
&lt;li&gt;重构了移动端的导航栏，加入了动画效果和深色模式切换。&lt;/li&gt;
&lt;li&gt;开发了一个完整的 &lt;code&gt;/stats/&lt;/code&gt; 统计页，展示我的写作热力图和分类习惯。&lt;/li&gt;
&lt;li&gt;引入了 Waline 评论系统，并美化了它的样式。&lt;/li&gt;
&lt;li&gt;设计了双栏网格的 PC 端首页布局。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以上是本次开源的主要内容，其他内容暂未迁移，如果你喜欢我的其他模块，我也可以进行添加到开源版本。&lt;/p&gt;
&lt;h2&gt;它有什么特点？&lt;/h2&gt;
&lt;h3&gt;🚀 开箱即用&lt;/h3&gt;
&lt;p&gt;我已经将它设置为 &lt;strong&gt;GitHub Template&lt;/strong&gt;，并内置了 GitHub Actions 工作流。只需要在浏览器中点击 &lt;code&gt;Use this template&lt;/code&gt;，改一行 &lt;code&gt;config.toml&lt;/code&gt; 里的域名，你的博客就能立即部署到 GitHub Pages 上！&lt;/p&gt;
&lt;h3&gt;🎨 时尚的颜色外观&lt;/h3&gt;
&lt;p&gt;整套 UI 采用了&lt;strong&gt;深海蓝 + 暖米白&lt;/strong&gt;的护眼配色，暗色模式也经过了精心调优（采用类似 GitHub Dark 的黑灰搭配），并在卡片悬浮、菜单展开等地方加入了细腻的微交互动画。&lt;/p&gt;
&lt;h3&gt;📖 内置教程&lt;/h3&gt;
&lt;p&gt;为了降低大家的上手门槛，我在模板里内置了6篇教程文章。当你克隆并运行这个博客时，首页展示的不再是空荡荡的占位符，而是手把手教你如何配置域名、如何接入 Waline 评论、如何使用内置组件的图文指南。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;如果你也想要搭建一个属于自己的博客，或者想换一个新的主题，不妨来试试 &lt;a href=&quot;https://github.com/liu-houliang/hugo-stack-starter&quot;&gt;Hugo Stack Starter&lt;/a&gt; 吧！如果有任何建议，欢迎在仓库里提 Issue 或者在评论区直接告诉我。&lt;/p&gt;
&lt;p&gt;当然我本人能力有限，不代表这个博客一定符合所有人的审美，如果大家有更好的建议，也欢迎提出来。&lt;/p&gt;
</content:encoded><category>Web</category></item><item><title>用 Cloudflare Workers 免费给博客增加一个带有知识库的 AI 助手</title><link>https://liuhouliang.com/post/blog-ai-assistant/</link><guid isPermaLink="true">https://liuhouliang.com/post/blog-ai-assistant/</guid><pubDate>Tue, 21 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;如果你用电脑端浏览器访问我的博客，就会注意到左侧边栏头像右下角多了一个闪烁的小机器人图标——这就是我新上线的博客 AI 助手，点击它就能直接跟我聊天，欢迎你测试一下它是否足够聪明。&lt;/p&gt;
&lt;p&gt;它不仅能跟你像正常大模型那样聊天，更能调用我的博客知识库，以我的口吻为你推荐文章、解答技术问题。&lt;/p&gt;
&lt;p&gt;今天就来复盘一下，如何利用 &lt;strong&gt;Cloudflare Workers AI&lt;/strong&gt; 以及  &lt;strong&gt;Vectorize&lt;/strong&gt; 快速实现一个有知识库的 AI 助手。&lt;/p&gt;
&lt;h2&gt;第一步：让 AI 先跑起来&lt;/h2&gt;
&lt;p&gt;要想给静态博客加 AI，调用付费的 API 成本比较高，服务端本地运行大模型更不现实。但我发现 Cloudflare Workers AI 提供每天 10000 点的免费额度，足够个人博客使用了，提供的模型也比较丰富。&lt;/p&gt;
&lt;p&gt;在对话模型的选择上，我选择了 &lt;code&gt;@cf/qwen/qwen3-30b-a3b-fp8&lt;/code&gt;，这个模型速度快、消耗少、足够用于日常对话。还有一些模型也不错，比如&lt;code&gt;@cf/meta/llama-3.1-8b-instruct&lt;/code&gt;， &lt;a href=&quot;https://developers.cloudflare.com/workers-ai/models/&quot;&gt;其他模型参考官方文档&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;只需要短短几步，一个支持流式输出的云端 AI 后端就能跑起来：&lt;/p&gt;
&lt;h3&gt;创建 Worker 项目&lt;/h3&gt;
&lt;p&gt;首先安装 nodejs, 推荐用 nvm 安装，这样可以方便地管理多版本 node&lt;/p&gt;
&lt;p&gt;然后创建项目&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm create cloudflare@latest -- hello-ai
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;绑定 AI&lt;/h3&gt;
&lt;p&gt;编辑项目中的 wrangler.jsonc，或者是 wrangler.toml&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;ai&amp;quot;: {
    &amp;quot;binding&amp;quot;: &amp;quot;AI&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[ai]
binding = &amp;quot;AI&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样就给 Workers 绑定了 AI 能力&lt;/p&gt;
&lt;h3&gt;运行一个简单的 AI 对话&lt;/h3&gt;
&lt;p&gt;修改 src/index.ts 代码，快速运行一个简单的对话任务&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// src/index.ts
export interface Env {
  AI: Ai;
}

export default {
  async fetch(request, env): Promise&amp;lt;Response&amp;gt; {

    const messages = [
      { role: &amp;quot;system&amp;quot;, content: &amp;quot;You are a friendly assistant&amp;quot; },
      {
        role: &amp;quot;user&amp;quot;,
        content: &amp;quot;What is the origin of the phrase Hello, World&amp;quot;,
      },
    ];

    const stream = await env.AI.run(&amp;quot;@cf/qwen/qwen3-30b-a3b-fp8&amp;quot;, {
      messages,
      stream: true,
    });

    return new Response(stream, {
      headers: { &amp;quot;content-type&amp;quot;: &amp;quot;text/event-stream&amp;quot; },
    });
  },
} satisfies ExportedHandler&amp;lt;Env&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;测试和部署&lt;/h3&gt;
&lt;p&gt;你可以通过 &lt;code&gt;npx wrangler dev&lt;/code&gt; 命令快速本地测试，使用 curl 测试对话&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx wrangler dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试通过后就可以部署了，首先登录你的账号&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx wrangler login
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后执行 deploy命令进行部署&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx wrangler deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署后这个服务就运行在以下网址，你可以修改 wrangler.jsonc 里面的 name 字段修改 dev 域名，也可以直接绑定自己的域名&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;https://hello-ai.&amp;lt;YOUR_SUBDOMAIN&amp;gt;.workers.dev
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;第二步：增加 RAG 检索能力&lt;/h2&gt;
&lt;p&gt;为了让 AI 了解你的更多信息，以你的口吻解答访客关于你博客内容的问题，我们需要引入知识库。&lt;/p&gt;
&lt;p&gt;Cloudflare 提供了向量数据库 &lt;strong&gt;Vectorize&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;创建向量索引&lt;/h3&gt;
&lt;p&gt;在投喂数据之前，你需要先创建一个向量索引。这里我们使用 &lt;code&gt;bge-m3&lt;/code&gt; 模型，它的向量维度是 &lt;code&gt;1024&lt;/code&gt;，相似度算法选择 &lt;code&gt;cosine&lt;/code&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 创建名为 blog-index 的向量索引
npx wrangler vectorize create blog-index --dimensions=1024 --metric=cosine
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建好之后，记得在 &lt;code&gt;wrangler.jsonc&lt;/code&gt; 或 &lt;code&gt;wrangler.toml&lt;/code&gt; 中进行绑定：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;vectorize&amp;quot;: [
    {
      &amp;quot;binding&amp;quot;: &amp;quot;VECTORIZE&amp;quot;,
      &amp;quot;index_name&amp;quot;: &amp;quot;blog-index&amp;quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;投喂知识库&lt;/h3&gt;
&lt;p&gt;我编写了一段 Node 脚本，把博客 &lt;code&gt;content/post&lt;/code&gt; 里的技术文章全部扫描一遍，按照 450 个字符分割，去除没有必要的链接、图片等信息。&lt;/p&gt;
&lt;p&gt;最后，脚本将这些碎片通过 HTTP 发送给我们刚刚创建的 Cloudflare Worker，由 Worker 后端调用 &lt;code&gt;@cf/baai/bge-m3&lt;/code&gt; 将其转化成向量矩阵并存入 Vectorize 数据库：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Worker 内部的投喂入库逻辑
const { data } = await env.AI.run(&amp;quot;@cf/baai/bge-m3&amp;quot;, { text: [doc.text] }) as any;

await env.VECTORIZE.upsert([{
    id: doc.id,
    values: data[0],
    metadata: { text: doc.text, ...doc.metadata }
}]);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;检索知识库&lt;/h3&gt;
&lt;p&gt;   当访客提问时，先用 &lt;code&gt;bge-m3&lt;/code&gt; 把提问也转换成向量，并去 Vectorize 寻找最接近的 6 个片段作为上下文。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const { data } = await env.AI.run(&amp;quot;@cf/baai/bge-m3&amp;quot;, { text: [lastUserMessage] }) as any;
const matches = await env.VECTORIZE.query(data[0], { topK: 6, returnMetadata: true });

// 双重过滤：下限 0.50，且 top-1 必须 ≥ 0.60 才启用 RAG 上下文
const topScore = matches.matches[0]?.score || 0;
const relevantMatches = topScore &amp;gt;= 0.60
    ? matches.matches.filter(m =&amp;gt; m.score &amp;gt; 0.50)
    : [];

let context = &amp;quot;&amp;quot;;
const sources: any[] = [];

if (relevantMatches.length &amp;gt; 0) {
    const uniqueUrls = new Set();
    context = relevantMatches.map(m =&amp;gt; {
        const title = m.metadata?.title as string;
        const link = m.metadata?.url as string;
        const text = (m.metadata?.text as string) || &amp;quot;&amp;quot;;
        
        if (!uniqueUrls.has(link)) {
            sources.push({ title, url: link });
            uniqueUrls.add(link);
        }
        
        return `### 《${title}》\nURL: ${link}\n内容片段: ${text.slice(0, 600)}...`;
    }).join(&amp;quot;\n\n&amp;quot;);
} 
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;提示词优化&lt;/h3&gt;
&lt;p&gt;最后一步，加上决定性格的 System Prompt：&lt;/p&gt;
&lt;p&gt;“你不是助理，你是文章作者 Liu 的数字分身。这些上下文不是搜索到的文档，而是你脑海中真实的个人经历”&lt;/p&gt;
&lt;h2&gt;数字分身也许不远了&lt;/h2&gt;
&lt;p&gt;不过由于目前的流程还是比较简陋，比如大模型只有 30b，使用的 bge-m3 也是比较小的知识库模型，也没有用到 rerank 重排，所以它只是了解了我的一部分信息的高级客服，离真正的数字分身还差的远。&lt;/p&gt;
&lt;p&gt;但如果能投喂更多的数据，使用更强大的模型，使用更智能的检索信息的方式，再加上我的形象和声音，也许就真的可以成为一个以假乱真的 “数字分身” 了。&lt;/p&gt;
&lt;p&gt;到那时候，也许我也会思考一个更深邃的问题，那就是我是否有足够的心理准备，提前创建自己的数字生命。这让我想起曾经回答过的一个知乎问题：大概是问是否介意死后被亲友以 AI 的形式“复活”。&lt;/p&gt;
&lt;p&gt;当时我的答案是不介意，但如果是自己亲手制作，也许会有点不同，毕竟我不是什么名人，如果真正到了生命的自然终结，又有多少人会怀念我？但如果能给他们带来一些慰藉，这一切就有了意义。&lt;/p&gt;
</content:encoded><category>Web</category></item><item><title>清明假期：收拾家务、整理心情</title><link>https://liuhouliang.com/post/2026qingming/</link><guid isPermaLink="true">https://liuhouliang.com/post/2026qingming/</guid><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近一直想更新博客，奈何近来工作比较忙，感觉就忙忙碌碌的，虽然到头来也不知道忙出来什么结果。&lt;/p&gt;
&lt;p&gt;整个3月都几乎没有完全走出假期状态，倒不是说完全不想工作或者猛猛摸鱼，而是一种比较奇怪的状态，处于不想工作，没把工作干好又有点焦虑的叠加态。不得不承认，我陷入了一种焦虑的状态。&lt;/p&gt;
&lt;p&gt;之前我总说：焦虑是一种完全没有用的负面情绪，所以我们应该避免焦虑。但我现在有了更多的感受，那就是焦虑完全可能是被动的，是悄然出现的，甚至是不被发觉的。等到我意识到这个问题时，我甚至没有理清因为什么而焦虑。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;变化来自哪里？&lt;/div&gt;





&lt;p&gt;但我确实发现了一些我身上的变化，比如最明显的是我的消费几乎是大幅减少了。之前我的信用卡每个月大概要还2000-3000元，而上个月只有1000不到。回顾我的消费记录，几乎完全来自日常的吃喝，不是买菜、买水果就是去超市买牛奶和预制菜。&lt;/p&gt;
&lt;p&gt;而比较奇怪的是，这并不是我故意省钱或者克制消费欲，就是很自然的变化。过去的我上班烦了就很想消费，买个电子产品或者买个衣服让自己心情好点。我猜现在消费减少的一个重要原因就是电子产品的全面涨价，内存和硬盘涨了2倍不止，完全扼杀了我的消费欲。&lt;/p&gt;
&lt;p&gt;价格上涨的还不止内存和硬盘，几乎传导了整个市场，包括电视甚至扫地机器人，我关注的所有商品都有不同程度的涨价，于是，我把它们都从我的购物车里移除了。再加上最近战争导致的油价上涨，很多消费品都会随之涨价，在这种情况下，减少消费确实是一件自然而然又理智的选择。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;我居然更专注了？&lt;/div&gt;





&lt;p&gt;另一个神奇的事情是，我做事明显更专注了。上班其实是一个漫长的过程，上过班的人都知道，无所事事的摸鱼比认真做事还要漫长。而我最近上班确实很充实，不管是写代码还是调研新技术，都感觉时间过得很快，我还挺喜欢这种状态。&lt;/p&gt;
&lt;p&gt;回到家里我也是目标明确，因为最近沉迷《杀戮尖塔2》，已经玩了70个小时，几乎下了班吃完饭就开始启动。一局游戏差不多要半小时，如果打到第三层boss或者被不是精英的小怪一脚踢死，受挫感会更强，急着开下一把赢回来。而玩着玩着就10点了，收拾一下刷刷牙，就到了躺在床上看小说的时光了。虽然我经常看不到半个小时就开始昏昏欲睡，早上起来手机还停留在小说界面。&lt;/p&gt;
&lt;p&gt;这样的生活我觉得还真挺不错，一天过去的充实又不劳累，也许有时候有点负面情绪，但春季的早上明媚清爽，让我觉得好像没什么大不了的。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;AI确实让我焦虑&lt;/div&gt;





&lt;p&gt;一想到上篇博客长篇大论说，我们不应该因为 AI 过度焦虑，现在就有点绷不住了，因为我自己就陷入了焦虑。那时候的我还没有真正体验 OpenClaw，最近两周我收到了调研 OpenClaw 的任务，比较深入地使用了一下，包括安装、配置和落地具体应用场景。&lt;/p&gt;
&lt;p&gt;怎么说呢，这并没有让我感觉好受点，就是那种终于赶上热点潮流没落后于人的感觉，反而让我不是很舒服。为什么呢，因为这个 OpenClaw 的使用门槛其实相当高了，对于我这样还算有一定经验的程序员，配置起来也比较麻烦。最简单的来说在 Windows WSL 上安装就可以劝退很多普通人了，然后配置 Ollama 大模型也是在命令行进行输入配置，就连切换默认模型都要改 JSON 配置。&lt;/p&gt;
&lt;p&gt;而最绷不住的是这玩意 Token 消耗简直是无底洞，我用公司的5090跑 Qwen3.5:27B，跑起来我都觉得有点卡，因为还没问几个问题，上下文已经几百K了超出了限制。而使用线上 API 就更加直观了，我领了超算平台的 2000W Token，一天就烧完了。因为超算平台的 OpenClaw 我不知道怎么进入终端，于是我直接在网页上让它装一个企业微信插件，它装了一上午花了 1000W Token，最后告诉我网络连接失败。&lt;/p&gt;
&lt;p&gt;但这样并没有让我好受，因为我会担心是我配置的不好，别人宣传的都是干活嘎嘎好用，我用5090结果搞个企业微信知识库同步都磕磕绊绊，会不会是我不会配置？而老实说，这个使用场景如果用 Dify 这样的工具，估计半天就搞定了。&lt;/p&gt;
&lt;p&gt;总之我使用完毕，得出唯一确认的结论是：这玩意并不适合普通人，除非你有很明确的使用场景，并且这个场景 OpenClaw 比其他对话大模型和 Agent 工具更适合。虽然我暂时没有找出这个场景，但它可操作的行为比较多，如果是整理资料、检索信息，还是可以胜任的。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;对未来的焦虑&lt;/div&gt;





&lt;p&gt;房间里真正的大象，无疑是对未来和失业的焦虑。我本来以为我一直不会因为失业而焦虑，而现在我确实有点焦虑了，因为之前我觉得自己有一技之长，即使失业了也可以比较快地找到新的工作，或者把副业升级为主业。但目前的就业形势已经不太乐观了，每天看到的新闻都是大企业大规模裁员，大量的失业人才流入了就业市场，却没有那么多新的岗位，还要和毕业生一起抢工作，想想就觉得难顶。&lt;/p&gt;
&lt;p&gt;虽然 AI 并不是裁员最主要的原因，某种程度上是背锅了，本质上还是业务的缩紧不需要那么多员工，越来越多的企业更倾向于裁员来实现节约成本，再加上 AI 确实会提升效率，让更少的员工通过使用 AI 实现跟之前一样的工作效率，让资本家有了看似正当的裁员借口。&lt;/p&gt;
&lt;p&gt;但我觉得一切不应该是这样的，试想如果一个企业本来100个员工，因为使用 AI 裁员了50个员工，而省下来的钱并不会发给剩下的员工，甚至还可能会降薪。一部分钱烧给了 Token，剩下的掏进自己腰包。而这些员工都会因为失业或者失业风险进而减少消费，更别说买房买车了。而这是一种负面循环，最终反而可能导致企业的商品卖不出去。&lt;/p&gt;
&lt;p&gt;甚至 AI 服务商都不是赢家，因为各家基本上都在猛猛烧钱抢占市场，最明显的就是 OpenAI 至今都没想明白怎么盈利，还砍了 Sora2。可能唯一明确受益的就是英伟达这样的显卡厂商。到头来，这样的 AI 浪潮，最终还是会面临泡沫，而这个泡沫不是因为 AI 不能带来生产力进步，而是生产力进步没有带来正面的影响，反而降低了消费和工资。&lt;/p&gt;
&lt;p&gt;也许你会说：AI 利好单人创业或者说利好副业，我也是这样想的，也尝试了不少。确实 AI 大幅提升了我的生产力，让我可以完成之前不擅长的前端开发、桌面开发甚至是游戏开发。但同样的，面临的竞争力也增加了很多，别人同样也能利用 AI 很快实现一个好的点子。我还在探索，在这个信息和技术爆炸的时代，到底怎么利用 AI 真正做出来可以盈利的产品。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;享受假期&lt;/div&gt;





&lt;p&gt;回到生活中来，现在是清明假期的第一天上午，早上我起床做了很多家务：打扫了房间、扔掉了部分不穿的旧衣服、清理了厨房和厕所。完成这些家务后，我又给自己做了一个简单的早餐，吃了蓝莓、鸡蛋和牛奶。然后我又网购了一些日用品，给自己买了一双新鞋。&lt;/p&gt;
&lt;p&gt;做完这一切之后，我舒舒服服地打开电脑，整理了快没空间的C盘，然后打开 QQ音乐，打开 IDE，写下了这篇博客，完成之后，我又要开始吃午饭啦，我想吃螺蛳粉，然后再舒舒服服地睡个午觉。&lt;/p&gt;
&lt;p&gt;而今天阳光很好，下午也许我会出门骑个车兜兜风，也许是时候拿出我吃灰已久的相机，拍一拍春日的花朵。&lt;/p&gt;
&lt;p&gt;收拾了繁杂的情绪和思绪，我们应该回到最重要的事情上来，那就是好好地享受假期和生活。&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>逐渐难以逃离对于 AI 的焦虑</title><link>https://liuhouliang.com/post/2026ai/</link><guid isPermaLink="true">https://liuhouliang.com/post/2026ai/</guid><description>养龙虾热潮让人们看到了 AI 的威力，也让人们更加因为没有用上 AI 技术而感到焦虑。</description><pubDate>Mon, 16 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;本文章为人工手打，不包含 AI 生成的内容，但有受到 AI 的启发。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;AI 技术的发展已经到了日新月异的地步，而很多人对于 AI 还只有一个简单的概念，殊不知生活中已经充斥着 AI 技术的应用，拿最简单的例子，打开拼多多你会发现很多商品的模特图片都是 AI 生成的，甚至还用的是比较落后的图片模型。再比如打开抖音和B站，随处可见的 AI 视频，比如 《科比整治抽烟乱象》、《AI 孙燕姿唱歌》、甚至有 AI 生成的短剧。&lt;/p&gt;
&lt;p&gt;作为程序员，我本人是拥抱 AI 发展的，因为很简单，一方面程序员工作和 AI 本身就技术相关，另一方面程序员很容易因为对新技术的抗拒而被淘汰。即使如此，我也对 AI 技术的进步感到有点震惊，因为它进步太快了，去年我记得 AI 辅助生成的代码还有可能编译不通过，前后矛盾。现在随便找一个免费的 AI 助手，让它哪怕从0开始写一个小程序或者 APP 都不在话下。 即使是一个几乎没有接触过代码的普通人，如果有一定学习能力也能完成一个网站的搭建。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;打破质疑&lt;/div&gt;





&lt;p&gt;最恐怖的是有些网友对于 AI 的质疑声都开始消退了，从一开始提到 AI “用 AI 生成的代码，修复起来比写还麻烦”，逐渐变成 “AI 写点小需求还行，大型项目完全跑不起来”。再到现在，应该没有人怀疑 AI 可以取代程序员的工作了，因为身边已经有被裁员的案例了。&lt;/p&gt;
&lt;p&gt;当然说这些不是为了吹 AI 天下无敌，完全替代程序员还不现实，比如高级模型的价格还比较昂贵、自我迭代能力还比较弱、偶尔还是会出现一些幻觉类的错误。但如果因此就贬低甚至唾弃 AI，等于是按下了被淘汰的加速键，因为 AI 编程能力的进步太快了，人类的编程能力甚至在倒退，如果不借用这个工具，才是真正的生产力落后。&lt;/p&gt;
&lt;p&gt;比如一个很扎心的事实，以前还有很多关于 “谁是世界上最好的语言” 的争论和调侃，现在呢，连 Stack Overflow 都没什么人用了，越来越多的程序员直接问 AI 来解决问题，反而更容易得到答案。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;龙虾来袭&lt;/div&gt;





&lt;p&gt;龙虾（OpenClaw）的出现，彻底引爆了人们对于 AI 的焦虑。哪怕是从来没接触过 AI 技术的人，也开始找教程买电脑去尝试，倒不是他们需要用龙虾来实现什么需求，而是感觉如果再不用上当下热门的技术，就感觉自己落后于时代了。&lt;/p&gt;
&lt;p&gt;我觉得龙虾的大火是一种必然，之前普通人对于 AI 的印象更多的是文本生成和图片、视频生成，交互方式停留在文本输入或者语音输入，更多的是让 AI 完成文字总结、文学创作、分析数据等任务。而 Agent 类的产品，对于普通人门槛太高了，不管是安装环境还是配置参数，都需要有一定的技术基础。但龙虾让情况变得不一样了，它拥有非常高的权限，可以替你操作各种你不想手动完成的任务，一声令下它可以自动装软件、操作浏览器、写代码、聊天，像一个真正意义上的智能体助手，这种新奇体验确实比较容易出圈。&lt;/p&gt;
&lt;p&gt;当然龙虾也存在一些问题，比如它的权限过高，很容易导致重要数据泄露；它花费的 Token 比较不透明，消耗量惊人，很容易出现花了不少钱但没挣到钱的情况。我觉得对于普通人，使用龙虾确实没太大的必要，还是有一定的安装和配置门槛，而且部分人没有正经的桌面电脑，如果还需要买机器的话成本就太高了。&lt;/p&gt;
&lt;p&gt;除此之外我认为龙虾也很多正面的影响，比如它是一次很好的探索，探索如果赋予了 AI 很高的权限，它到底可以为我们做些什么，究竟能带来工作效率的进步，和工作方式的改变。很多重复性工作在 ChatGPT 时代就被颠覆了，如果更进一步，AI 是不是真的可以替我们工作和赚钱？另外也让普通人切实体会到了 AI 技术的潜力，更生动地了解它们的威力。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;焦虑究竟来自于什么？&lt;/div&gt;





&lt;p&gt;那么人们争先恐后地养龙虾，究竟是对于未来的焦虑还是对于落后的焦虑？我倾向于是后者，比如我在小红书刷到有人靠 Agent 自动开发、维护 APP 和网站，我也会感到一点焦虑，因为程序员的护城河可以说是完全不复存在了，0编程基础的人靠着大模型和 Agent 轻松开发出一款像模像样的 APP，甚至可以拿来盈利。&lt;/p&gt;
&lt;p&gt;如果我还在手写代码，手动调试解决BUG，把时间浪费在重复性工作上，那我相比别人的生产力无限落后，别人一天就开发个七七八八了，我还在写文档、查资料，就是在被别人降维打击。现在的副业行情就是信息差和行动速度最重要，当你还在想这样那样可以赚钱的时候，别人已经上线了。&lt;/p&gt;
&lt;p&gt;别的行业更是如此，比如小红书博主、视频博主、新媒体运营、翻译这类工作，如果你还在手动查资料、手动翻译、手动校正，然后看到别人一键生成博文、一键生成 PPT、一键生成公众号等等，就可能让自己陷入怀疑，是不是我这样做效率太低了？&lt;/p&gt;
&lt;p&gt;更进一步的说，人们开始焦虑自己的工作会不会被 AI 取代，因为已经是进行时了，最直接的影响就是翻译工作，现在甚至可以看视频显示实时字幕 + 翻译，也就是同声传译，传统翻译如果不用 AI 辅助，确实作用大不如往日了。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;快不等于好&lt;/div&gt;





&lt;p&gt;现代社会的节奏快到飞起，电视剧打败了电视节目、短剧打败了电视剧，而很快 AI 短剧开始替代短剧。上一秒短剧还是创新的代名词，不少地方和公司都在投资短剧，下一秒突然发现还有成本更低的拍短剧方式，这时候该怎么做？继续新的转型，还是继续好好拍短剧？没有人能给出正确的答案。&lt;/p&gt;
&lt;p&gt;因为也许有人靠 AI 短剧赚取了第一桶金，有人看各种各样的 AI 应用赚到了钱，但并不一定适用于每一个人。并不是人有了 AI 就会赚钱，我更倾向于有执行力和有灵感的人，借助 AI 这个工具，生产力大幅提升，让之前做不到的事情变得可能，这样的赚钱方式是符合逻辑的。所以我们也许不需要盲目地抓住每一个新技术，陷入好像学不会这个技术就被淘汰的焦虑中。也许我们可以花一点时间关注新技术的落地和应用，看是否能想到好的点子，或者是否对之前被困扰的问题有所帮助。&lt;/p&gt;
&lt;p&gt;一方面我也见识过 AI 批量制作的垃圾信息，比如低质量的视频、充满错误全是带节奏的公众号文章、以及自动发帖员等等，海量的真假信息让普通人很难分辨真假。我个人的经验来说，我现在看小红书的时间越来越少了，因为很容易让我感到焦虑，那么很简单，我可以主动控制减少接触这些让我不愉快的信息。不如看一些论坛上没什么营养的水帖和搞笑段子，虽然没有意义，但人生大多数时间就是无意义的浪费。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;如何减少焦虑&lt;/div&gt;





&lt;p&gt;焦虑本身就是一种完全负面的情绪，几乎毫无意义，因为焦虑无法解决任何问题，只会陷入让我们内耗的循环中。所以我觉得首先心态要放好，如果看到某个信息让你很焦虑，你可以选择不喜欢，可以取消关注，可以关闭软件，然后看一些自己感兴趣的内容，可以玩玩游戏、看看小说，转移一下注意力。&lt;/p&gt;
&lt;p&gt;然后我们需要拥抱 AI 等新技术，因为它很有可能对你带来帮助，不需要对它有抵触情绪，因为 AI 是唯物的，它本身没有对错和好坏，只是一个高效的工具和助手。我们需要认真思考它可以带来怎么样的提升，比如让你写月报速度快了几倍，比如可以帮助你整理电影和音乐，比如可以跟它对话聊天解决心理问题。哪怕是很小的事，如果可以切实带来帮助，也许你的心态就会好不少。&lt;/p&gt;
&lt;p&gt;最重要的是我们要分清工作和生活。工作上我们可以利用 AI 狠狠提升效率，而节省下来的精力和时间，我们可以投入生活。因为工作的意义就是让我们好好生活，不能本末倒置。当你回到家，问豆包怎么炒菜好吃，什么电视剧好看，推荐点美味零食的时候，也许是你看它最顺眼的时候。&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>开发了一个2026有奖发票抽奖助手，方便合并发票凑齐100元</title><link>https://liuhouliang.com/post/invoice_scanner/</link><guid isPermaLink="true">https://liuhouliang.com/post/invoice_scanner/</guid><pubDate>Sun, 15 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近大家应该都忙着开发票和抽奖，个人感觉刚开始中奖概率挺高的，平均一次抽奖能中个10块钱。&lt;/p&gt;
&lt;p&gt;基本上我的发票都是来自盒马和京东，盒马可以合并多个不满100元的订单，京东也支持合并换开。但是京东订单合并换开稍微复杂一点，只有相同销售方的订单才能合并换开，于是我就写了这个工具。&lt;/p&gt;
&lt;p&gt;这个工具的作用很简单，支持一键上传多张发票，可以是图片或者 PDF 文件。PDF 识别更准一点，因为图片是用 OCR 识别。然后自动识别满足抽奖要求的发票，同时自动合多张小额发票。&lt;/p&gt;
&lt;p&gt;特点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;批量处理&lt;/strong&gt;：支持同时上传多张发票，无需一个个处理。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;智能识别&lt;/strong&gt;：能够自动识别发票上的销售方、城市、金额等信息。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;合并功能&lt;/strong&gt;：支持合并满足金额要求的发票，尽量不浪费每张发票。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;隐私保护&lt;/strong&gt;：所有发票数据都在本地处理，不会上传到服务器。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;工具是完全免费且免注册的，如果你觉得好用，可以分享给你同样在凑发票的朋友！&lt;/p&gt;
&lt;p&gt;传送门：👉 &lt;a href=&quot;https://pricecalc.net/zh/tools/invoice-scanner&quot;&gt;发票抽奖助手 (PriceCalc.net)&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;3e547a7e3df4bc2ec1c0abb99b255969.png&quot; alt=&quot;工具截图&quot;&gt;&lt;/p&gt;
</content:encoded><category>Software</category></item><item><title>DesktopSnap：开源轻量、一键恢复，解决 Windows 桌面图标错乱的烦恼</title><link>https://liuhouliang.com/post/desktop-snap/</link><guid isPermaLink="true">https://liuhouliang.com/post/desktop-snap/</guid><description>基于 WinUI3 开发的轻量级桌面工具。支持多显示器布局一键备份与恢复，已上架微软商店。</description><pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;大家也许跟我一样会遇到 Windows 桌面图标顺序错乱的情况，有时候只是在桌面上不小心粘贴了一个文件，结果插入了图标导致顺序变了，然后撤回也只能删除文件，而不会恢复图标顺序，只能一个一个地移动图标，非常费劲。&lt;/p&gt;
&lt;p&gt;或者显示器分辨率变更、缩放变更、多显示器拔插、全屏游戏分辨率变更等等，都有可能导致桌面图标错位。为了彻底解决这个痛点，&lt;strong&gt;DesktopSnap&lt;/strong&gt; 诞生了。这是一次基于最新 WinUI3 技术的尝试，旨在为您提供最纯粹的桌面图标布局备份与恢复体验。&lt;/p&gt;
&lt;p&gt;目前软件已正式上架微软商店，通过了官方安全审核。&lt;/p&gt;
&lt;h2&gt;软件截图&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;preview.jpg&quot; alt=&quot;preview&quot;&gt;&lt;/p&gt;
&lt;h2&gt;软件特性&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;微软商店版&lt;/strong&gt;：支持自动更新，安装便捷且由微软平台保障。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;绿色免安装版&lt;/strong&gt;：采用了自包含打包技术。整个软件压缩后有 60MB 大小，解压即用，无需下载额外的运行环境。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;快捷保存&lt;/strong&gt;：启动软件时自动保存布局，使用快捷键 &lt;code&gt;CTRL + ALT + S&lt;/code&gt; 也可以快捷保存当前布局。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;支持托盘最小化&lt;/strong&gt;：支持开机自启，可以隐藏在 Windows 右下角托盘区。占用极低内存。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;支持多显示器&lt;/strong&gt;：支持多显示器布局保存和恢复，并且支持自动缩放和预览。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;系统要求&lt;/strong&gt;：Windows 10 / Windows 11 (64位)&lt;/p&gt;
&lt;h2&gt;软件下载&lt;/h2&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a href=&quot;https://apps.microsoft.com/zh-CN/detail/9n88rj6d0js3&quot;&gt;微软商店 下载 (推荐)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a href=&quot;https://github.com/liu-houliang/DesktopSnap/releases&quot;&gt;GitHub 下载 (免安装版)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a href=&quot;https://desktopsnap.liuhouliang.com&quot;&gt;访问 官方网站&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Github 仓库&lt;/h2&gt;
&lt;p&gt;如果您想查看项目的源代码，或者参与到项目的开发中，您可以访问项目的 Github 仓库。&lt;strong&gt;如果这个项目对您有所帮助，欢迎点个 Star 支持一下！&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a href=&quot;https://github.com/liu-houliang/DesktopSnap&quot;&gt;访问 Github 仓库&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;支持作者&lt;/h2&gt;
&lt;p&gt;这个小工具完全免费，一方面是为了解决自己的痛点，一方面也是分享出来给遇到同样困扰的朋友。如果你在使用过程中也觉得它拯救了你的桌面强迫症，欢迎通过下方的 “赞赏支持” 支持我，谢谢！&lt;/p&gt;
&lt;p&gt;欢迎在评论区留下你的使用反馈，或者提出你的宝贵建议！&lt;/p&gt;
</content:encoded><category>Software</category></item><item><title>聊聊程序员猝死事件</title><link>https://liuhouliang.com/post/2026work/</link><guid isPermaLink="true">https://liuhouliang.com/post/2026work/</guid><description>逝者已矣，但活着的人需要警醒。大环境很难改变，至少我们可以拒绝内卷，选择容错率更高的人生。</description><pubDate>Tue, 03 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前几天看到广州某程序员猝死的新闻，说实话心里挺不是滋味的。逝者已矣，我不想去吃人血馒头，也不想消费苦难。但同样作为在这个行业里虽然没那么卷但也算摸爬滚打的人，这件事确实让我不得不停下来，想写点什么。&lt;/p&gt;
&lt;p&gt;这不仅仅是一场意外，在我看来，这是社会环境和个人选择共同造成的悲剧。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;超长劳动&lt;/div&gt;





&lt;p&gt;据统计，中国劳动者的周平均工作时长是49小时。意味着如果按6天工作日算，每天至少工作8小时。考虑到这个数据应该不统计兼职，并且会被一些比较清闲工作平均，普通私企工作者的工作时长轻松超过50小时。&lt;/p&gt;
&lt;p&gt;而对于程序员这个工种，50小时应该算是入门级别。程序员普遍有久坐的问题，而久坐可能是猝死的第一元凶，再加上各种加班赶进度、出版本、修复线上BUG等等突发事件，存在工作时间长、工作强度大、突发事件多、久坐等多个 DEBUFF。&lt;/p&gt;
&lt;p&gt;除去这些硬件上的伤害，还会有各种心理上的压力，比如随时可能被裁员的风险，像高某这样的公司一般是实行末尾淘汰制，相当于给每个人头顶挂上一把随时可能会掉下来的剑。还会受到各种各样的职场 PUA 和 画大饼：比如 “现在环境这么不好，你有这样的工作和收入已经很不错了，公司对你不错。” 言下之意就是你得好好干报答公司。&lt;/p&gt;
&lt;p&gt;然而程序员只是容易被看到的职业，很多职业也一样存在过长劳动、劳动强度大、时薪低、没保障等各种问题，部分工厂不交社保、月休4天、节日福利发点方便面，每每看到这种血汗工厂我都想说求求你们搞点资本主义吧。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;婚姻陷阱&lt;/div&gt;





&lt;p&gt;高某跟我是老乡，我看到河南就懂为什么他会像黄牛一样对家庭任劳任怨，辛苦付出了。这简直是刻在了很大一部分河南家长的DNA里，在他们看来，到了年纪就该结婚生娃了，他们也不管现在结婚成本有多大、风险有多高，只管念叨着结婚了他们就安心了，不生孩子是不孝等等等等。结了婚就上交工资，拼命工作养家，简直是模范丈夫。&lt;/p&gt;
&lt;p&gt;那么代价是什么呢？我想大家现在都知道，以前那种所谓的组成家庭共同努力抵抗风险的共识已经几乎完全瓦解。上交工资意味着交出话语权导致地位低下，拼命工作伤害了身体，付出的再多也不意味着你能得到什么。如果你的另一半并不愿意和你一起打拼共担风险，她很有可能成为你的最大风险。&lt;/p&gt;
&lt;p&gt;不过高某似乎并不是父母催婚才结婚的，我收集到的信息显示他们是自由恋爱，但男方父母不太同意他们的婚事，主要是不太看好女方的家庭。然而高某执意结婚，婚礼一切从简只请了双方至亲好友，光看到这里感觉一切都还挺美好，自由恋爱加上简单的婚礼仪式，一切本来都应该往好的方向发展。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;高位买房&lt;/div&gt;





&lt;p&gt;真正的杀手出现了，虽然高某的收入还不错，但以当时广州的房价，盲目上车买房很难说的上理智，再加上夫妻双方家庭不算富裕，妻子不上班，一个月要还1w块的房贷，想想都觉得有点透不过气来。在广州买一套房子至少要200多万吧，以高某的收入看上去可以负担得起，但考虑到收入来源单一、收入来源不稳定、家庭开支较大，此时高某的压力已经不小了。&lt;/p&gt;
&lt;p&gt;而买房真正的压力是房价下降带来的心理压力，仅仅25年一年部分地区的房价下降超过20%，虽然房价下降但要还的贷款几乎没有变少，好在房贷存量利率也下降到了3%点多。但房价的下降会让人焦虑，自己挣钱的速度是固定的，很有可能算下来一年白干资产缩水。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;奉献者&lt;/div&gt;





&lt;p&gt;高某看上去是个十足的好人，他会打钱给在河南的父母，以及从小照顾他的姐姐；还要照顾生病的岳母，以及妻姐的儿子。甚至还要照顾在家备孕的妻子，妻子爆料出来说丈夫加班回家之后还会给她打洗脚水。可以说他一个支撑起了两个家庭，无论是钱财还是看护与陪伴，他都为两个家庭做出了巨大的贡献。&lt;/p&gt;
&lt;p&gt;如果不是最后的结局，我会直接说是 “供养者”，但这里还是换成了美化一点的词语 “奉献者”，我觉得有利他性是优点，但首先要保证的是利己性。几乎无条件地付出对他人好，不管是父母、亲戚，总要有一个度吧，不能超出自己的能力范围，说难听一点光付出不享受是反人性的。自己买40块钱的剃须刀不舍得，然后给妻子买苹果三件套，我无法评价这样的行为，因为也许我会给妻子买苹果三件套，但我自己必须先用上5090。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;躺平不可耻&lt;/div&gt;





&lt;p&gt;回过头看，超长劳动、高位买房、婚姻帮扶这些虽然极其压抑，但它们其实只是表象。核心的原因在于，他选择了内卷。&lt;/p&gt;
&lt;p&gt;他选择了在竞争最激烈的赛道里拼命向上爬，而不是找个安稳的地方苟着；他选择了透支未来去追求标配的中产生活，而不是量力而行。如果当初选择了一种低风险的生活方式，或者适当地“躺平”，也许故事的结局会完全不同。&lt;/p&gt;
&lt;p&gt;但人是很脆弱的，长期的过度劳动和精神压力迟早会压垮身心。而他的容错率几乎为零，一旦作为顶梁柱的身体出了问题，整个家庭的经济大厦瞬间崩塌。年入50万的高某看起来是高收入家庭，但完全可能一夜返贫，风险系数是很高的。&lt;/p&gt;
&lt;p&gt;说白了，这也是一场豪赌。赌注是自己的身体，赢了是阶级跃迁，输了就是人生崩盘。我们太想通过奋斗改变命运了，太想给家人最好的生活了，以至于有时候忘了，活着本身，才是一切的前提。&lt;/p&gt;
&lt;p&gt;亦或者，其实不需要过上那么好的生活，不需要买那么大/一线城市的房子，不需要那么好的消费品，不需要一定要上进和奋斗呢？慢下来，思考自己真正想要的生活，适当地躺平，躺平不会对不起任何人，只会让你得到休息，让你活的更久。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;最后&lt;/div&gt;





&lt;p&gt;写这些不是为了指责谁，纯粹是我自己的一种自我警醒。&lt;/p&gt;
&lt;p&gt;大环境很难改变，咱们也别指望资本家能良心发现。但至少，我们可以掌控自己的选择。在买房、结婚、职业规划这些重大的人生关口，认真思考带来的风险是否可以承受，带来的收益是否真的需要。&lt;/p&gt;
&lt;p&gt;很多时候看上去离了你不行的工作其实换谁来都行，看上去离了她不行的对象其实未必是你的最佳选择，看上去不买就会涨价升值的房子其实隔几个月就能再降10万。&lt;/p&gt;
&lt;p&gt;但命没了真就什么都没有了，希望大家都能更爱惜自己，减少内卷与内耗，慢下来，享受生活，浪费时间也是一种幸福。&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>最近对博客进行了一些装修，优化了样式和布局</title><link>https://liuhouliang.com/post/blog-renovation/</link><guid isPermaLink="true">https://liuhouliang.com/post/blog-renovation/</guid><pubDate>Thu, 25 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近抽空对网站的视觉效果又进行了一波微调。之前的布局虽然能用但总觉得有点老气，尤其是深色模式的配色和首页以前的三列布局，感觉还是差点意思，于是指挥 AI 对一些核心样式进行了优化。&lt;/p&gt;
&lt;p&gt;主要更新内容如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;首页改版：去除三列布局，博客卡片从单列变成双列网格&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;配色重构：深色模式和浅色模式的配色进行了优化&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;评论区优化：把 reaction 表情改为点赞&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;移动端导航优化：隐藏信息栏，改用快捷头部按钮&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;首页改版：视野更开阔&lt;/h3&gt;
&lt;p&gt;之前的首页采用了传统的三列布局，感觉有点重点不突出，最重要的博客也只有单列信息密度很低。于我直接去了首页右侧边栏，并将文章列表改成了更时尚的 &lt;strong&gt;双列网格&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;维度&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;之前&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;现在&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;布局结构&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;三列带左右侧栏&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;去除右侧栏，聚焦博客列表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;列表形态&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;单列垂直长卡片&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;双列网格&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;交互感&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;静态显示，反馈较弱&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;悬浮位移 + 柔和阴影 + 封面缩放&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;现在的卡片布局不仅能一眼看到更多文章，而且整体的精致感提升了不少，不再是之前那种扁平的感觉。&lt;/p&gt;
&lt;h3&gt;配色重构：减少报纸感&lt;/h3&gt;
&lt;p&gt;之前我几乎使用 Hugo Stack 主题的默认配色，怎么说呢，深色模式下的灰色和浅色模式下的白色挺护眼的，但感觉有点老气，于是我把深色改成更接近蓝灰的配色&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;模式&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;之前&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;现在&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;效果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;浅色&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;#EBEBEB&lt;/code&gt; 偏暖灰&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;#f4f6f9&lt;/code&gt; 偏冷白&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;看起来更干净&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;深色&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;灰+黑&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;&lt;code&gt;#101214&lt;/code&gt; 深黑背景 &lt;code&gt;#1c2128&lt;/code&gt; 蓝灰卡片&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;黑蓝配色更时尚&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;在深色模式下，我还为每个卡片增加了一层极细的边框。这种“亮边”处理在深色背景下非常有高级感，能拉开景深，防止元素糊在一起。&lt;/p&gt;
&lt;h3&gt;评论区优化：优化互动表情&lt;/h3&gt;
&lt;p&gt;之前的 Waline 评论区互动表情（Reaction）有点太占地方，而且绿色的文字和大大的表情有点突兀，于是我缩减了互动区域的大小，去除提示文字，只保留点赞和不喜欢两个选项。&lt;/p&gt;
&lt;p&gt;本来是想直接去除互动功能的，但觉得这个还是挺有意义的，因为很多人看完并不一定会给出评论，但可能更愿意点个赞或者不喜欢，也是一种默默的鼓励。而且我也可以根据反馈知道自己哪些文章更受欢迎。&lt;/p&gt;
&lt;p&gt;顺便优化了评论区 placeholder 文本，明确告知用户可以留下邮箱，这样如果有人回复了会收到邮件通知。&lt;/p&gt;
&lt;h3&gt;移动端导航：轻量化处理&lt;/h3&gt;
&lt;p&gt;移动端的空间非常珍贵，以前的版本顶部太占地方了有点遮挡正文，实际体验的时候光顶部信息栏就占了手机屏幕都三分之一还多&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th align=&quot;left&quot;&gt;维度&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;之前&lt;/th&gt;
&lt;th align=&quot;left&quot;&gt;现在&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;顶部高度&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;较厚，包含头像和个人信息&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;极简页头，仅保留核心操作&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;功能入口&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;主要靠侧边栏滑出&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;头部快捷按钮 (切换主题/语言/搜索)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;阅读体验&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;正文被页头和信息栏挤占&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;最大程度释放屏幕空间给内容&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;博客卡片&lt;/strong&gt;&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;间距比较大，标题文本比较大，但字体行间距小&lt;/td&gt;
&lt;td align=&quot;left&quot;&gt;缩小了间距和标题，但行间距增大看着更舒服&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;新设计的快捷按钮组固定在页头，操作触手可及，不仅增加了阅读面积反而更加便捷。&lt;/p&gt;
&lt;h3&gt;总结&lt;/h3&gt;
&lt;p&gt;主要是看久了总觉得自己的博客不老好看，其实也就才用了不到三年。本来甚至考虑直接用 Nextjs 重构的，但一想工作量还真不小，收益也比较有限。还是指挥 AI 去美化现有的样式比较好，一番施工后觉得还是比较满意，如果有人对我的博客感兴趣，到时候我可以上传代码到 Github 上。&lt;/p&gt;
</content:encoded><category>Web</category></item><item><title>如何给 Hugo 博客添加优雅的垂直时间线组件</title><link>https://liuhouliang.com/post/hugo-timeline/</link><guid isPermaLink="true">https://liuhouliang.com/post/hugo-timeline/</guid><pubDate>Wed, 17 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近有朋友问我栏目的时间线功能是怎么实现的。为了能够在文章中优雅地展示经历或版本更新记录，我使用 Hugo 的 Shortcode 功能配合 CSS 实现了一个垂直时间线组件。本文将简要介绍其实现原理，并分享完整代码。&lt;/p&gt;
&lt;h2&gt;实现思路&lt;/h2&gt;
&lt;p&gt;实现这个功能的核心思想是利用 Hugo 的 &lt;strong&gt;Shortcode&lt;/strong&gt; 来生成 HTML 结构，并通过 CSS 处理布局和连线。&lt;/p&gt;
&lt;p&gt;为了保持代码的语义化（Semantic HTML），我们将时间线视为一个无序列表（&lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;），每一个时间节点则是列表项（&lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt;）。这比单纯使用 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; 堆砌更符合 Web 标准，对 SEO 和辅助阅读设备也更友好。&lt;/p&gt;
&lt;h2&gt;代码实现&lt;/h2&gt;
&lt;p&gt;我们需要创建两个 Shortcode 文件：容器和列表项。&lt;/p&gt;
&lt;h3&gt;容器组件 (timeline)&lt;/h3&gt;
&lt;p&gt;创建文件 &lt;code&gt;layouts/shortcodes/timeline.html&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;ul class=&amp;quot;timeline-container&amp;quot;&amp;gt;
  {{ .Inner }}
&amp;lt;/ul&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;子项组件 (timeline-item)&lt;/h3&gt;
&lt;p&gt;创建文件 &lt;code&gt;layouts/shortcodes/timeline-item.html&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;li class=&amp;quot;timeline-item&amp;quot;&amp;gt;
  &amp;lt;div class=&amp;quot;timeline-marker&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;div class=&amp;quot;timeline-content&amp;quot;&amp;gt;
      &amp;lt;p class=&amp;quot;timeline-date&amp;quot;&amp;gt;{{ .Get &amp;quot;date&amp;quot; }}&amp;lt;/p&amp;gt;
      &amp;lt;div class=&amp;quot;timeline-text&amp;quot;&amp;gt;{{ .Inner }}&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/li&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;样式定义 (CSS)&lt;/h3&gt;
&lt;p&gt;以下是完整的 CSS 样式代码（支持响应式和深色模式）。你可以将其添加到你的 SCSS 或 CSS 文件中（例如 &lt;code&gt;assets/scss/custom.scss&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-scss&quot;&gt;/* 时间线样式 */
.timeline-container {
    max-width: 600px;
    margin: 40px auto;
    padding: 0 20px;
    position: relative;
    background: transparent;
    list-style: none;
}

.timeline-container::before {
    content: &amp;#39;&amp;#39;;
    position: absolute;
    top: 0;
    bottom: 0;
    left: 50%;
    width: 2px;
    background: var(--timeline-line, #e6e6e6);
    transform: translateX(-50%);
    transition: background 0.3s;
}

.timeline-item {
    display: flex;
    margin-bottom: 40px;
    position: relative;
}

.timeline-marker {
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: linear-gradient(135deg, var(--primary-color, #4285f4), var(--primary-color-dark, #1a237e));
    border: 3px solid var(--timeline-bg, #fff);
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    z-index: 1;
    box-shadow: 0 0 0 4px var(--timeline-shadow, #e3f2fd);
    transition: background 0.3s, box-shadow 0.3s;
}

.timeline-content {
    width: calc(50% - 40px);
    padding: 0px 10px 12px 15px;
    border-radius: 10px;
    background: var(--timeline-bg, #fff);
    box-shadow: 0 3px 16px rgba(0, 0, 0, 0.10);
    color: var(--timeline-text, #263238);
    transition: background 0.3s, color 0.3s, box-shadow 0.3s;
    margin-top: 0;
}

.timeline-item:nth-child(odd) .timeline-content {
    margin-left: auto;
}

.timeline-item:nth-child(even) .timeline-content {
    margin-right: auto;
}

.timeline-date {
    color: var(--primary-color, #4285f4);
    margin-bottom: 10px;
    font-weight: bold;
}

.timeline-text {
    line-height: 1.6;
}

/* 响应式设计 - 时间线在小屏幕上改为单列布局 */
@media (max-width: 768px) {
    .timeline-container {
        max-width: 100%;
    }

    .timeline-container::before {
        left: 20px;
    }

    .timeline-item {
        flex-direction: column;
    }

    .timeline-marker {
        left: 20px;
    }

    .timeline-content {
        width: calc(100% - 45px);
        margin-left: 45px !important;
    }
}

/* 时间线深色模式 */
[data-scheme=&amp;quot;dark&amp;quot;] {
    --timeline-bg: #23272e;
    --timeline-line: #31343a;
    --timeline-text: #e6e6e6;
    --timeline-shadow: #181a20;
    --primary-color: #64b5f6;
    --primary-color-dark: #1976d2;
}

[data-scheme=&amp;quot;dark&amp;quot;] .timeline-content {
    background: linear-gradient(135deg, #23272e 80%, #262a32 100%);
    color: var(--timeline-text, #e6e6e6);
    box-shadow: 0 3px 16px rgba(0, 0, 0, 0.40);
    border: 1px solid #31343a;
}

[data-scheme=&amp;quot;dark&amp;quot;] .timeline-marker {
    background: linear-gradient(135deg, #64b5f6, #1976d2);
    border: 3px solid #23272e;
    box-shadow: 0 0 0 4px #181a20;
}

[data-scheme=&amp;quot;dark&amp;quot;] .timeline-date {
    color: #90caf9;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;使用方法&lt;/h2&gt;
&lt;p&gt;定义好组件后，在 Markdown 文件中就能非常方便地调用了。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;{{&amp;lt;/* timeline */&amp;gt;}}
    {{&amp;lt;/* timeline-item date=&amp;quot;2025-12&amp;quot; */&amp;gt;}}
    发布了这篇关于时间线实现的教程。
    {{&amp;lt;/* /timeline-item */&amp;gt;}}

    {{&amp;lt;/* timeline-item date=&amp;quot;2024-01&amp;quot; */&amp;gt;}}
    博客正在进行代码重构优化。
    {{&amp;lt;/* /timeline-item */&amp;gt;}}
{{&amp;lt;/* /timeline */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以下是上述代码渲染后的实际效果：&lt;/p&gt;
&lt;div class=&quot;timeline&quot;&gt;
    &lt;div class=&quot;timeline-item&quot;&gt;&lt;div class=&quot;timeline-date&quot;&gt;2025-12&lt;/div&gt;&lt;div class=&quot;timeline-content&quot;&gt;
    发布了这篇关于时间线实现的教程。
    &lt;/div&gt;&lt;/div&gt;

&lt;pre&gt;&lt;code&gt;&amp;lt;div class=&amp;quot;timeline-item&amp;quot;&amp;gt;&amp;lt;div class=&amp;quot;timeline-date&amp;quot;&amp;gt;2024-01&amp;lt;/div&amp;gt;&amp;lt;div class=&amp;quot;timeline-content&amp;quot;&amp;gt;
博客正在进行代码重构优化。
&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;这样，一个清晰明了且代码优雅的时间线就完成啦。&lt;/p&gt;
</content:encoded><category>Web</category></item><item><title>也许和父母最佳的相处方式就是不住在一起</title><link>https://liuhouliang.com/post/2025home/</link><guid isPermaLink="true">https://liuhouliang.com/post/2025home/</guid><pubDate>Sat, 13 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;在逛论坛时经常会有网友分享自己的家庭问题，一般高赞回答都是建议他搬出去住然后经济独立，可以解决大多数问题。这个观点我是赞同的，但也没太当回事，因为这几年我一直和父母住在一起，而直到昨天我才真正理解了这句话的含义。&lt;/p&gt;
&lt;p&gt;父母和孩子注定无法相互理解，不管孩子是10岁、18岁还是30岁。父母的心态往往是：我是你爸/你妈，我不会害你，我都是为你好，哪怕孩子长大了有自己想法了，还是得听父母的话。而孩子在很小的时候就有自己的想法了，年少时尚未独立不得不听父母的话，可到了参加工作后，经济上开始独立，思想上开始独立思考，也有了自己的人生规划，父母的话孩子也会听一听考虑考虑，但绝不会言听计从。在这样的观念冲突下，矛盾无法避免，除非有一方过于弱势选择忍让，彻底爆发是早晚的事。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;雪天火锅&lt;/div&gt;





&lt;p&gt;昨天郑州下了大雪，是入冬以来的第一场大雪，一直从早上下到半夜，白雪悄悄覆盖了整个城市。孩子们开心玩耍，同事们也纷纷赏雪拍照，我自然也毫无心情工作，就等着下班回家吃饭玩游戏。前一天就约好了晚上吃羊肉火锅，少不了要陪我爸喝两口，我其实并不排斥陪我爸喝酒，只要不让我喝白酒，因为喝那玩意实在太痛苦了。&lt;/p&gt;
&lt;p&gt;本来饭桌上一片祥和，纷纷大口吃肉，讨论刚装好的新门锁和还在飘落的大雪。直到我提到了前几天的门锁事件：我提议把家里用了7年的旧门锁换掉，之前的锁芯不太安全，关门开门都不太方便。我妈说厨房还要装修可以先不换门锁，在我还没搞明白换门锁和厨房装修的关系是什么时，我爸说不行结婚可以住另一套房子。&lt;/p&gt;
&lt;p&gt;我听完有点懵懵的，我没有明白换门锁和装修，以及在哪结婚有几毛钱的关系。我心里不太舒服，但也没说什么，后来我就购买了门锁很快就装好了。在饭桌上时我提到这件事，就是想表达一下为什么我有点不舒服，我感觉我出发点是好的，想改善家里门锁的开锁便利性和安全性，也是我出钱，结果我妈说毫不相干的厨房装修就算了，我爸大手一挥让我去那边结婚我更是无语，就是感觉说什么都能扯到结婚，明明我最近根本没有结婚的打算。而且他们时时刻刻想着帮我安排我的人生，我爸更是帮我在哪结婚，在哪装修都想好了，唯独没有人考虑一下我是不是想结婚，我想结婚在哪，我想怎么装修。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;冲突升级&lt;/div&gt;





&lt;p&gt;虽然我父母这几年没少吵架，但我和他们至少好几年没有发生过真正的争吵了，偶尔有一次冲突，也基本上是我爸单方面地吵我。打心里说的话，我在家里的脾气是相当相当好了，有时候受了委屈，一想到他们是我的父母，晚上回去又是一副笑脸相迎。在他们吵架甚至矛盾升级的时候，我还帮助他们调解沟通，互相说对方的好话，然后自己回屋里的时候偷偷哭泣。他们似乎从来都没有认识到，我不想结婚的一个重要原因就是父母的婚姻，没有让我感觉是温馨和谐的，虽然好的时候俩人也挺好的，但坏起来的时候让人害怕。&lt;/p&gt;
&lt;p&gt;在饭桌上，我比较完整地表达了我的想法，大概就是这几点：1. 我们观念肯定有不同，互相理解，换位思考 2. 我这么大人了催我结婚也好，逼我结婚也好，也没什么用，甚至可能起反效果，最后大家都闹的不开心。3. 不要替我做决定，在哪结婚，怎么装修，都应该是我要考虑的东西，而且都是未来的事情，每天考虑这些没有意义。&lt;/p&gt;
&lt;p&gt;然后我爸就爆发了，站起来就走，嚷嚷着说 “不就是说了一句可以在哪结婚，有必要这样吵我吗？那我不说了中不中”。然后指责我太敏感，不至于生气。我当时也晕了，从到到尾，我没说一句责怪人的话，没有提高音量，我以为我们在沟通解决，结果直接爆发了冲突。&lt;/p&gt;
&lt;p&gt;那一刻，我看着那一桌还没吃完的羊肉火锅，看着父亲愤然离去的背影，突然感到一种深深的无力感。这不仅仅是一次争吵，在他们的逻辑里，我是孩子，是附属品，我的 “表达感受” 就是 “忤逆”，我的 “据理力争” 就是 “太敏感” 。他们不需要一个有独立意志的成年人，他们只需要一个听话的 “好孩子”。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;距离产生美&lt;/div&gt;





&lt;p&gt;我突然意识到，我们都没有错。父亲维护的是他作为一家之主的权威，母亲维护的是她在家庭琐事中的话语权，而我维护的是一个成年人基本的边界感。只是这三种维护挤在同一个屋檐下，注定会撞得头破血流。&lt;/p&gt;
&lt;p&gt;第二天，我也没有再试图去解释什么，而是平静地开始收拾衣服打包。搬家不是一次冲动的 “离家出走”，更不是为了惩罚父母。相反，这是我为了保全这段亲情所能做的最后努力。我不想让最后一点爱意，在日复一日的 “你太敏感” 和 “你怎么不听话” 中消耗殆尽。&lt;/p&gt;
&lt;p&gt;搬出去住的第一个晚上极其安静。没有电视的嘈杂声，没有父母拌嘴的声音，也没有那种随时担心被指责的紧绷感。虽然这里没有暖气，但是空调也一样很暖和，最重要的是我真正体会到了自由。&lt;/p&gt;
&lt;p&gt;30岁这年，我明白了一个道理：真正的孝顺，不是愚昧的顺从。真正的孝顺，是先把自己照顾好，活成一个情绪稳定、能够独立思考的人。搬出来，不是为了逃避，是为了更好地回家。&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>秋季的郑州 · 友情与亲情</title><link>https://liuhouliang.com/post/2025autumn/</link><guid isPermaLink="true">https://liuhouliang.com/post/2025autumn/</guid><pubDate>Tue, 28 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;人好像是一种奇特的动物，虽然每年都会经历春夏秋冬，但当秋季到来之时，还是会觉得天气骤降，嘴里念叨着 “咋突然这么冷呢？”。而实际上我已经度过了29个秋了，我应该非常明白天气变化的规律，也许是今年的秋天确实比往年冷；也许是我的记性每年都在刷新，反复获得新的体验；也许是我还保持着年轻的心态，对季节变换保持着热情。但秋天就这样不早不晚的来了，带来了降温和雨水，以及友情与亲情。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;友人回国&lt;/div&gt;





&lt;p&gt;思思于国庆期间回国，正赶上了郑州的秋雨连绵，几乎连续一周都在下雨，他嘴上总是骂骂咧咧的，再加上郑州的交通也是各凭本事各显神通，让习惯了在国外开车的他略微暴躁，路过不守规矩的驾驶员亲属难免被他一通问候。说起来中文也很神奇，同样的语气词在不同场合可以有着完全不同的含义，比如思思嘴里就常挂着 “一考” 这样的 local 词汇，有时候是表达亲切问候，有时候表达赞叹，有时候表达惊讶。但总体来说还是一个素质青年，再次遇到他时感觉是没什么变化，依然是平头夹克工装裤，三层脖子黑眼镜。&lt;/p&gt;
&lt;p&gt;不太一样的是这次回国他成为雪王和瑞幸的代言人，每天不是瑞就是蜜，也贡献了诸如 “喝热美式就是马尿”，以及 “天太冷了只能喝热美式” 这样的名场面。不得不说雪王确实很香，一杯咖啡只需要6块，抽盲盒只需要9.9，一杯柠檬水团购甚至不到3块。我们几个人也很有意思，一问奶茶喝不喝就是喝，喝啥？必须美式；加糖不？必须无糖啊，有糖那是马尿。&lt;/p&gt;
&lt;p&gt;男生之间的活动是简单快乐的，我们三个无非就是三部曲：逛gai、吃饭、打游戏。也许朋友的意义就在于让本身没有什么意思的活动变得有趣，比如我们去逛了金融岛，我之前去过金融岛觉得也就那样商业气息比较浓厚，但三个人一起可以在大雪王拍照，去唱片店吐槽价格，去尊贵的咖啡店品一品，主打一个瞎侃乱逛。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;东来之行&lt;/div&gt;





&lt;p&gt;胖东来已经发展成 6A 景区了，自然少不了凑这个热闹。于是我们选择在周日乘坐高铁绿色出行，前往位于许昌时代广场的胖东来。那天依旧是细雨蒙蒙，依旧是极限时间出行，在地铁上就开始规划路线计算时间，下了地铁一路狂奔，最终也是依旧在极限时间完成检票顺利坐上高铁，还没等我汗落了，就已经达到许昌东站。&lt;/p&gt;
&lt;p&gt;手里拿着热美式，打上了网约出租车，就这么顺利地到达了时代广场。时代广场商场的规模说实话也就是郑州的中型商超的水平，但这里的人可真不少。刚进去商场的我们像是无头苍蝇，也不知道从什么地方开始逛起。我们的策略也很简单，往人多的地方去就行了，于是我们来到了限时开放的茶叶区，这里充满了代购，我们也不懂茶叶，一人拿了一包茉莉花茶，只要60元，性价比这一块还是可以的。&lt;/p&gt;
&lt;p&gt;接下来就是吃吃喝喝逛逛，最开心的应该是在商场游戏厅的时光，几个人玩投篮、跳舞、桌球、赛车、射击游戏。然后互相竞赛、拍照、欢闹，仿佛回到了高中时光，但仔细一想即使在高中我们也没能这样一起度过游戏厅的欢乐时光。这份欢乐来之不易，以至于我们差点错过主线任务——逛超市。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;爆米花电影&lt;/div&gt;





&lt;p&gt;今年我只看了五部电影，其中三部都是动画片，甚至连电视剧也是主要看了 《凡人修仙传》动画版。去电影院的次数也是肉眼可见的变少了，但老友相聚自然少不了这个环节，我们一起去看了 IMAX 版本的 《创：战神》。&lt;/p&gt;
&lt;p&gt;时至今日再回想起这个电影，其实剧情很简单也不太经得起推敲，剧本也是经典的机器人变人，评分也很低：在豆瓣和 IMDB 都只有六点多的评分。而我个人至少会给出7分，因为我觉得这是一部合格的爆米花商业电影，纯粹的特效，纯粹的大场面，单纯的爽。&lt;/p&gt;
&lt;p&gt;如果是在网上，看到这种评分的电影我都不会点进去看一眼介绍。但在万达 IMAX 2D 影院，有着良好的视觉效果和听觉效果的情况下，以及好友的陪伴中，我几乎完全投入到了电影的节奏中，忽略掉了诸如 “永恒代码” 这样的离谱设定，陷入了肾上腺素拉满的追逐战和战斗中，爽就完了。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;爸妈的退休生活&lt;/div&gt;





&lt;p&gt;国庆最后一天，爸妈就踏上了去黄山的旅途，正好还能蹭上免过路费。而我也为他们的旅游计划提供了一些帮助，比如帮他们买好了黄山的门票、第一天晚上的酒店以及黄山山顶的住宿，而难度最高的是预约天都峰，这个需要在公众号提前三天预约，每天3000个名额，不过还好他们去的时候算是淡季比较好抢。&lt;/p&gt;
&lt;p&gt;其实父母已经两个人或者四个人（和其他亲戚）多次自驾旅行了，无论是购买门票、订购酒店，对他们来说不是难事。不过黄山旅游稍微特殊一点确实是比较难规划，需要规划爬山路线、看日出路线、订购山顶酒店、抢天都峰的票等等，这时候突然会觉得中老年人选择跟团游也是一种无奈，如果没有足够的精力或者财力，选择跟团是最省心的玩法，当然代价就是玩的肯定没有自驾游那么深度和自由。&lt;/p&gt;
&lt;p&gt;看着父母过上了我向往已久的退休生活，我是既开心又羡慕：因为我只能在节假日出游，住着涨价的酒店，看着人挤人的景点。而淡季出行人少价格便宜，几乎都是退休年龄的游客。不过就拿我爸妈来说，他们努力工作了一辈子，如果现在还不能退休享受生活，那努力工作的意义何在呢？&lt;/p&gt;
&lt;p&gt;当然我们这一代就不一定能享受到的拿着比较高的退休金旅游这种待遇了，一来是退休年龄推迟，未来很有可能继续推迟；二来出生率连年下降，未来退休金的金额也会随之下降。所以当代人已经不研究什么退休后享福了，而是选择直接躺平，享福在当下。今天享福了今天快乐，未来会是什么样管它呢。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;沙发与享乐&lt;/div&gt;





&lt;p&gt;父母也开始想开了，之前他们买什么都仔仔细细的，现在也开始享受品质生活了。前一段我提议买个芝华仕的单人沙发，结果一拍即合，我妈甚至提出要买个带按摩功能的，不过逛了一下发现纯按摩椅还是比较贵的，动辄七八千，而带有按摩功能的沙发则要3000多。不过最后因为双十一的价格没怎么优惠，再加上马上又要搬家了，决定明年再购入。&lt;/p&gt;
&lt;p&gt;本来他们去黄山的时候是准备住在山脚，第二天凌晨爬上山顶看日出，被我劝住了，我建议他们还是直接住山顶，不然体力跟不上。然后他们本来打算住那种单床位的，我一看一个床位都要300块，而一个房间是1200，多花一倍的钱明显能提升不少舒适度。在我的建议下还是帮他们买了一个标准间，回来后他们觉得还是挺合理的，钱嘛，花哪哪舒服。&lt;/p&gt;
&lt;p&gt;虽然说节省是好，但认真想想有些时候太过节省根本没必要，在家庭条件允许的情况下，吃饭、穿衣、住酒店这些消费根本多花不了多少钱，出行已经要花挺多钱了，如果在吃和住上面过度省钱，没有获得应有的良好体验，那钱不是白省了吗？&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;逃不过的催婚&lt;/div&gt;





&lt;p&gt;最近要参加我表妹的满月酒，虽然为她感到高兴，但一想到我又要被各路亲戚催婚就感觉有点不想去了。我能理解这些关系比较近的亲戚是真心为我好，也能理解他们眼中的幸福就是结婚生子合家欢，但我已经30岁了，我知道自己想要的是什么，不管怎么催我我都是知道了，明白了，尽快了，丝毫改变不了我的想法。&lt;/p&gt;
&lt;p&gt;而催婚本质上就是 “给压力”，或者 “制造焦虑”，不仅解决不了问题，只会增加焦虑情绪。假如一个人压根不想结婚，你催他结婚又有什么用呢？亦或者一个人想结婚，但是一直找不到合适的对象，你催他结婚他除了苦笑还能做什么呢？现如今结婚率暴降，男女彼此间都不信任，随随便便找一个人结婚，这样就能获得幸福吗，怕不是只能获得一地鸡毛。&lt;/p&gt;
&lt;p&gt;最难绷的是我大伯也催我，我的堂哥和堂姐都离婚了，逢年过节只有我大伯参加聚会，就算这样我大伯还能说出到年纪了该办事了，属实让我有点绷不住。但凡他们家里过上幸福生活我都觉得起码是一片好心。&lt;/p&gt;
&lt;p&gt;人又不是制定好程序的机器人，人生也不是设定好主线任务的游戏。如何幸福地过完自己的一生是要牢牢把握自己手中的，被各种声音影响到的话，到时候自己过的不幸福他们又不需要负责，再去后悔 “早知道不听他们的了” 为时已晚。&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>分享一个轻量的监控管理多服务器工具 Komari</title><link>https://liuhouliang.com/post/komari_monitor/</link><guid isPermaLink="true">https://liuhouliang.com/post/komari_monitor/</guid><pubDate>Fri, 24 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;一直需要一个监控多服务器的工具，用来监控我的 NAS 和服务器的运行状况：包括监控内存、CPU 占用，以及离线 / 上线通知等等。于是我找到了一个轻量级开源的监控工具  &lt;a href=&quot;https://github.com/komari-monitor/komari&quot;&gt;Komari&lt;/a&gt;，效果如下图所示&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image.png&quot; alt=&quot;monitor&quot;&gt;&lt;/p&gt;
&lt;p&gt;选择这个工具的原因：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;支持不同操作系统，包括 Windows、Linux、MacOS 以及 NAS&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;安装简单，使用 Docker 快速安装&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;占用资源较低，跑在我 2G 内存的服务器上也没有问题&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;支持邮件通知，监控服务器离线以及占用异常&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;界面友好，可以切换主题&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;服务端安装&lt;/h2&gt;
&lt;p&gt;文档地址：&lt;a href=&quot;https://komari-document.pages.dev/install/quick-start.html&quot;&gt;https://komari-document.pages.dev/install/quick-start.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Linux 发行版使用脚本安装，适用于使用了 systemd 的发行版 Ubuntu、Debian&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者使用 Docker&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p ./data
docker run -d \
  -p 25774:25774 \
  -v $(pwd)/data:/app/data \
  --name komari \
  --restart unless-stopped \
  ghcr.io/komari-monitor/komari:latest
docker logs komari
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我使用 Docker-Compose 安装的，具体配置如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;version: &amp;#39;3.8&amp;#39;

services:
  komari:
    image: ghcr.io/komari-monitor/komari:1.1.2a
    container_name: komari
    ports:
      - &amp;quot;25774:25774&amp;quot;
    volumes:
      - ./data:/app/data
    environment:
      - TZ=Asia/Shanghai  # 设置时区
    restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;安装成功后，打开浏览器访问 &lt;a href=&quot;http://ip:25774&quot;&gt;http://ip:25774&lt;/a&gt; 即可看到仪表盘&lt;/p&gt;
&lt;h2&gt;添加节点&lt;/h2&gt;
&lt;p&gt;进入管理后台 - 服务器列表，点击添加节点，输入名称后，点击下载按钮，会提示不同平台的安装 Agent 脚本，复制后在对应平台的服务器上执行命令安装 Agent&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-1.png&quot; alt=&quot;agent guide&quot;&gt;&lt;/p&gt;
&lt;p&gt;模式很简单，服务端跟不同 Agent 通过 Socket 通信，定时上传数据信息，Agent 是自动更新的&lt;/p&gt;
&lt;h2&gt;添加 NAS 节点&lt;/h2&gt;
&lt;p&gt;NAS 添加稍微复杂一点，因为 NAS 一般没有完整的 Linux 包管理，所以需要添加定时任务手动执行 Agent 程序&lt;/p&gt;
&lt;p&gt;首先去下载 Agent 程序，根据内核下载对应版本 &lt;a href=&quot;https://github.com/komari-monitor/komari-agent/releases&quot;&gt;Releases&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;下载后放在 NAS 的某个目录下，注意这个目录要有权限并且不会被 NAS 自动删除，比如我放在了 &lt;code&gt;/share/.qpkg/komari&lt;/code&gt; 目录下，然后给程序添加执行权限&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chmod +x ./komari-agent
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后编写一个定时任务脚本，放在任意目录下，记得给脚本添加执行权限。需要修改你的后台域名和创建节点生成的 token&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/sh
DAEMON=/share/CACHEDEV1_DATA/.qpkg/komari/agent
ARGS=&amp;quot;-e $your_domain -t $your_token --include-nics eth0&amp;quot;

if ps | grep -v grep | grep &amp;quot;$DAEMON&amp;quot; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
    exit 0
fi

&amp;quot;$DAEMON&amp;quot; $ARGS &amp;gt;&amp;gt;/tmp/komari.log 2&amp;gt;&amp;amp;1 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接着添加一个定时任务，每10分钟检查一下 Agent 是否在运行，如果没有则启动&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;*/10 * * * * /root/komari.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;修改主题&lt;/h2&gt;
&lt;p&gt;上面的截图就是用的 Light 主题，在管理后台 - 设置 - 主题中可以添加主题和切换主题，这里有一些官方推荐的主题 &lt;a href=&quot;https://komari-document.pages.dev/community/theme.html&quot;&gt;suggest theme&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-2.png&quot; alt=&quot;theme&quot;&gt;&lt;/p&gt;
&lt;p&gt;添加主题需要去 github 下载 zip 压缩包，然后在后台上传主题，可以手动更新主题版本&lt;/p&gt;
&lt;h2&gt;添加邮件通知&lt;/h2&gt;
&lt;p&gt;首先添加 SMTP 配置，在管理后台 - 设置 - 通知中添加，可以自定义通知模板&lt;/p&gt;
&lt;p&gt;然后分别添加 SMTP 服务器、端口、用户名、密码、发件人、收件人&lt;/p&gt;
&lt;p&gt;我使用的是 brevo 免费邮件服务，账号密码在 Settings - SMTP &amp;amp; API 中获取，发件人邮箱需要在域名添加 MX 记录&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-3.png&quot; alt=&quot;SMTP&quot;&gt;&lt;/p&gt;
&lt;p&gt;接下来设置邮件通知的场景，在通知 - 离线通知 / 负载通知 添加，可以设置离线宽限期，负载阈值等，可以对不同服务器单独设置&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-4.png&quot; alt=&quot;Send Email&quot;&gt;&lt;/p&gt;
&lt;h2&gt;站点设置&lt;/h2&gt;
&lt;p&gt;在管理后台 - 设置 - 站点中自定义站点信息，我修改了站点名称、描述和图标，这里最重要的一项是可以开启 &lt;strong&gt;私有站点&lt;/strong&gt;，开启后监控数据需要登陆才能查看&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-6.png&quot; alt=&quot;Custom&quot;&gt;&lt;/p&gt;
&lt;p&gt;我个人目前只用到了以上配置，很快就能部署配置完成了，还有一些实用的功能比如 &lt;strong&gt;远程执行&lt;/strong&gt;，可以在后台快捷地在对应服务器上执行一些命令，已经满足我的需求了&lt;/p&gt;
</content:encoded><category>Share</category></item><item><title>多雨的九月冲淡了八月的阴霾</title><link>https://liuhouliang.com/post/20250919/</link><guid isPermaLink="true">https://liuhouliang.com/post/20250919/</guid><pubDate>Fri, 19 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;距离30岁生日只有不到两个月了，竟然有了deadline的感觉，就好像是在这之前我还可以任性放纵，而一旦过了这条线，一切都变得不一样了。&lt;/p&gt;
&lt;p&gt;9月的雨水多的不太寻常，几乎连续下了两周了，而我也早早穿上了长裤外套，回想起小学和初中的我，是班级里出了名的不怕冷，有一次我甚至在11月生日的时候还在穿短袖，终究是不比当年勇了。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;下雨的周五&lt;/div&gt;





&lt;p&gt;而今天似乎格外的冷，只有大概14度。周五是一个特殊的日子，在这一天我的厌工情绪会达到高峰，哪怕只是坐在工位上发呆，也真的不想工作。也许说来矫情，但一天完整的8小时工作显得格外漫长，早上7点20就出门的我，大约6点30才能到家，算起来整整半天的时间都投入了工作。 &lt;/p&gt;
&lt;p&gt;而属于我个人的时间就只有6点半到11点半这五个小时了。还要除去吃饭、洗衣服、刷碗等杂事，真正的时间只有大概4个小时了。这样一算很多事就说的通了，比如我的游戏时间在逐年下降，回家更愿意看视频逛论坛；再比如一直没能保持良好的健身习惯。当然了主要是还是自然懒，但如果我的空闲时间能增加一小时，生活的丰富程度会提高不少。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;悲惨的八月&lt;/div&gt;





&lt;p&gt;今年8月可谓是屋漏偏逢连夜雨，惨的不行：一个月时间我修了两次车，加起来得有20多天；买的低温牛奶在冰箱里放了3天就变质发酵了；光找联通降套餐就折腾了好几天。其中一次车祸相当严重，车头都被撞烂了，只能说幸好没有伤到人，只有小红承担了所有。&lt;/p&gt;
&lt;p&gt;那次车祸相当离谱，我以40码速度通过绿灯，而另一方向的车以差不多70码的速度闯红灯，直直地从左侧撞向我的车头，在我观察到对方的车辆时，一切都晚了。我在懵懵的状态下打开车门，手颤抖着报了警，很快认定对方闯红灯全责。&lt;/p&gt;
&lt;p&gt;我赶忙离去，虽然发生了车祸但还是要赶回去上班，请假一个小时还要扣钱，牛马实在是太惨了。在路上我突然意识到一个问题，交警虽然认定对方闯红灯，但并没有开出罚单，于是我发短信和微信询问了交警是否会对闯红灯行为进行处罚，结果并没有回复我。我又通过其他方式联系了交警支队，前前后后也沟通了差不多两周时间，最后的结果就是，依然没有对闯红灯和超速行为进行处罚，因为我没有当场明确提出要处罚对方。&lt;/p&gt;
&lt;p&gt;所以说时间是个好东西，现在的我可以平淡地讲述这件事，而当初那个接到电话听到一堆离谱言论的我，又愤怒又无助。但认清了现实的我，还是理智地选择放下了这件事，让它过去吧。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;人生路漫漫&lt;/div&gt;





&lt;p&gt;30岁没有结婚生子是一件非常正常的事情，尤其是在当今社会，在一个二线以上的大城市。但你的父母可能不这么想，在他们眼里这还是人生最重要的一条主线，如果孩子没有完成这条主线，对他们来说相当于程序一直在报 warning，而一但孩子过了30岁，报的可能就是 error 了。&lt;/p&gt;
&lt;p&gt;我有一个四人的微信群，里面都是我要好的朋友，都是我的高中同学。至今群里无人结婚生子，海外的朋友天高地远自然是不受束缚，在国内的朋友也是两耳不闻婚恋事，该吃吃该玩玩主打一个享受生活。对我而言，如果没有来自家庭和父母的压力，应该压根不会想着婚育这些事情。&lt;/p&gt;
&lt;p&gt;毕竟当前版本婚姻对于男性并没有什么好处，只能保证离婚时可以分掉至少一半的财产，而且也开放了非婚生子，结婚早已不是一个必要选项。当然每个人想法不同，今年参加了两次婚礼，一次是同事的一次是同学的，还听我妈说儿时的邻居也已经生子了。&lt;/p&gt;
&lt;p&gt;这些其实都对我没什么影响，也不会让我焦虑，参加婚礼时我也会由衷地祝福和为他们感到高兴，我相信他们是因为真爱而走入婚姻殿堂，这是一种幸福。只不过如今获得幸福和快乐的方式越来越多，选择自己最舒适的生活方式，不需要被条条框框束缚住。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;事业难起步&lt;/div&gt;





&lt;p&gt;相比于婚恋也许事业对我来说更重要，但很可惜事业的成功似乎更难。公司最近效益不好，连续两轮裁员后，人数少了30%左右，我们部门比例更甚。还听到传闻可能会有新的动作，多少有点人人自危，如今这种社会环境下，大家都不敢轻易地离职，这两年只有一个同事是自己离职的。人人都知道这年头工作不好找，很多工作都是一个萝卜一个坑，还要跟刚毕业的大学生一起竞争，也是挺难的。&lt;/p&gt;
&lt;p&gt;虽然说我一直是想把工作做好做出成绩的，客观评价一下自己我觉得做的还算可以了：在公司几年做的项目都在稳定运行，没有出现过大的BUG，也没出现过崩溃停止的情况；做的项目基本上也都按时交付，没有拖延或者不达标。其实也没什么用，毕竟你做的好不一定会给领导留下很好的印象，或者说即使留下好印象也不一定会升职加薪。但只要你做的不好地方被领导发现，坏的影响是立竿见影的。&lt;/p&gt;
&lt;p&gt;但一份工作做久了容易懈怠，因为经常做一些重复性的工作，有时候新的工作靠经验就能完成，会疏于新技术的学习。再加上现在AI大爆炸，各种各样的AI辅助工具会让工作更加轻松，理想情况下多出来的时间可以用来提升自己，但实际上我们懂的都懂，现在让我断了网不要AI辅助写代码，倒也能写，但估计效率会很差。&lt;/p&gt;
&lt;p&gt;副业上还算是比较积极，最近用nextjs制作了一个工具网站，当然了代码几乎全是AI机写的，我充当了甲方的角色，指挥AI去完成我的构想，不满意的话还要反复修改。好消息是网站成功通过了adsense审核，坏消息是可能工具太大众了，搜索量和访问量都很低，也没带来广告收入，继续加油吧。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;世界在动荡&lt;/div&gt;





&lt;p&gt;不知道是什么原因，但最近愈发关注世界局势，比如在公众号、B站、NGA等平台，每天都会主动刷一些世界新闻。当然主角一般是老特和老胡，特朗普确实是太有活了，养活了一众UP主，可以说是一位最纯粹的资本主义总统：每天不是在搞钱就是赢而不麻，我双手支持特朗普获得今年的诺贝尔和平奖，反正这个奖早就烂完了。&lt;/p&gt;
&lt;p&gt;至于巴以冲突，每次看到波士顿圆脸关于巴以的视频，都还是感觉心很痛，就像他说的：很多观众看了这么多期视频，只能眼睁着看着惨剧发生却又无能无力，多少会影响心情。其实有时候我也会问自己，关注这个事真的有必要吗？似乎只会一次次被以色列的暴行气到，但波士顿圆脸给出了答案：这件事需要有像他这样的人来为此发生，起到一定的传播作用。&lt;/p&gt;
&lt;p&gt;作为中国人应该最能同情巴勒斯坦的遭遇，因为我们也曾在上世纪遭遇了相似的事情，被日本法西斯入侵，无数平民惨遭屠杀，几乎每一个中国人都会在看完《南京照相馆》感觉深深的愤怒和悲伤。而这样的事情在21世纪的今天，在加沙几乎每天都在发生，在这场全世界直播的种族灭绝面前，我无法保持沉默。&lt;/p&gt;
&lt;p&gt;当然也有好的消息传来，最近越来越多的国家开始承认巴勒斯坦的合法性，包括英法加澳等等，部分不承认的国家，也有民众上街游行抗议，声援加沙反对种族灭绝。而反对巴勒斯坦建国的国家有哪些呢？美日德意，这些国家可以说是每次都站在文明的对立面了。当然政府无法完全代表人民的意志，数万意大利民众走上街头抗议，任何正常人都无法容忍这样的暴行，更无法容忍支持这样暴行的国家。&lt;/p&gt;
&lt;div class=&quot;custom-title&quot; style=&quot;color: red;&quot;&gt;路在脚下&lt;/div&gt;





&lt;p&gt;上周朋友让我做了MBTI的测试，结果却跟之前不太一样，好像是INTP，原因也很简单：因为大多数题我无法选择最左边或者最右边的选项，往往是中间偏左或偏右，导致我的两项测试结果都在50%左右，很容易从ISTJ滑落到INTP。而且人是会变化的，随着年龄和阅历的增加，对于事物的认知也在发生变化。&lt;/p&gt;
&lt;p&gt;以前的我可能会问的更多，思考的更多，会纠结一件事的意义。现在似乎发生了一些变化，可能是更知道自己想要的是什么了，反而能更投入的去做事了，无论是工作、玩游戏还是做项目。而且减少了一些没有必要的焦虑，父母那一辈可能会比较焦虑未来怎么样，但现在的年轻人你再跟他说什么未来已经没有什么意义了，谁也不能预测未来，2020年之前谁能预料到这5年的变化呢？说不好听点，谁知道未来是什么时候退休、会遇到什么疾病、会不会遭遇战争、会不会经济危机呢？&lt;/p&gt;
&lt;p&gt;享受当下的生活，保持健康的身体和良好的心态，也许这才是当下年轻人的共识。&lt;/p&gt;
</content:encoded><category>Life</category></item><item><title>用 Huggingface Space 免费搭建 Metatube 后端服务</title><link>https://liuhouliang.com/post/metatube_hugging/</link><guid isPermaLink="true">https://liuhouliang.com/post/metatube_hugging/</guid><pubDate>Fri, 12 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Huggingface 上线了 Space 功能，提供了免费的 2CPU / 16G 内存的运行环境，可以运行Docker。于是我尝试部署了一个Metatube后端服务，很容易就实现了：地址如下，不需要Token，暂时也没被墙 &lt;a href=&quot;https://metatube-server.hf.space/&quot;&gt;Metatube Server&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://metatube-server.hf.space/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但它有个限制，48小时内没有访问就会关闭服务&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;If your Space runs on the default hardware, 
it will go to sleep if inactive for more than a set time (currently, 48 hours). 
Anyone visiting your Space will restart it automatically.cpu-basic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以我把项目设置成了公开，如果服务被关闭了，你可以访问链接唤醒 &lt;a href=&quot;https://huggingface.co/spaces/metatube/server&quot;&gt;Huggingface Hub&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://huggingface.co/spaces/metatube/server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果您只是需要免费后端，后续就不用看了，接下来讲一下怎么通过 Huggingface Space 搭建自己的 Metatube 后端服务&lt;/p&gt;
&lt;p&gt;也欢迎大家部署成功后分享地址在评论区，方便大家一起使用&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;注册账号&lt;/h2&gt;
&lt;p&gt;首先注册一个Huggingface账号，只需要输入邮箱和密码，然后去邮箱验证，需要代理：&lt;a href=&quot;https://huggingface.co/join&quot;&gt;Join Huggingface&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image.png&quot; alt=&quot;register&quot;&gt;&lt;/p&gt;
&lt;h2&gt;创建Space&lt;/h2&gt;
&lt;p&gt;注册后点击菜单栏右上角的 Spaces 按钮，点击 &lt;code&gt;New Space&lt;/code&gt;，开始创建&lt;/p&gt;
&lt;p&gt;输入任意名称，类型选择 Docker Blank ，选择 CPU Basic，选择 Public (Private访问需要密钥)，然后点击 &lt;code&gt;Create Space&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-1.png&quot; alt=&quot;create space&quot;&gt;&lt;/p&gt;
&lt;h2&gt;获取ACCESS TOKEN&lt;/h2&gt;
&lt;p&gt;点击头像下方的 &lt;code&gt;Acccess Tokens&lt;/code&gt; 按钮，点击 &lt;code&gt;+Create new token&lt;/code&gt;，选择 write 类型，生成的 token 作为 git 提交的密码&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-2.png&quot; alt=&quot;create token&quot;&gt;&lt;/p&gt;
&lt;h2&gt;上传代码&lt;/h2&gt;
&lt;p&gt;创建完 Space 会有一个仓库URL，拉取代码，账号是你的 Huggingface 账号，密码是上面生成的 token &lt;/p&gt;
&lt;p&gt;然后拉取我的代码，复制到你的仓库中，提交代码&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://huggingface.co/spaces/metatube/server
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;测试运行情况&lt;/h2&gt;
&lt;p&gt;此时你再访问 Space 会发现项目已经在Build了，很快就完成了，输出熟悉的&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&amp;quot;data&amp;quot;:{&amp;quot;app&amp;quot;:&amp;quot;metatube&amp;quot;,&amp;quot;version&amp;quot;:&amp;quot;v1.3.2-f2bbaee&amp;quot;}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你可以通过链接访问服务了，用你的账号和项目名称拼接而成的链接&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://{yourid}-{serverid}.hf.space/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;比如我的账号是 metatube，项目 id 是 server&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-url&quot;&gt;https://metatube-server.hf.space/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Space是如何运行的&lt;/h2&gt;
&lt;p&gt;Space支持三种模式：Gradio、Docker和静态网页，如果是Docker模式，需要配置Dockerfile和README.md&lt;/p&gt;
&lt;p&gt;比如这是我构建metatube的Dockerfile，拉取了metatube的镜像，配置端口和数据目录，最后运行metatube&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM ghcr.io/metatube-community/metatube-server:latest

ENV PORT=8080
EXPOSE 8080

RUN mkdir -p /data &amp;amp;&amp;amp; chmod 777 /data

ENV DSN=/data/metatube.db
ENTRYPOINT [&amp;quot;/metatube-server&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;README.md文件内容如下，这里我只配置了标题和端口号，默认端口号是7860&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;---
title: Metatube-Server
emoji: 🐠
colorFrom: green
colorTo: blue
sdk: docker
pinned: false
app_port: 8080
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;上传代码后，根据Dockerfile进行拉取和构建，注意运行的服务需要绑定0.0.0.0:port，因为会检查服务是否正在运行，运行成功后就可以访问网页或者API请求了。&lt;/p&gt;
</content:encoded><category>Share</category></item><item><title>Hugo 博客添加游戏记录栏目，并且支持自动抓取游戏数据</title><link>https://liuhouliang.com/post/auto_game_record/</link><guid isPermaLink="true">https://liuhouliang.com/post/auto_game_record/</guid><pubDate>Wed, 03 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;之前我给博客增加了 &lt;a href=&quot;/post/movie_record&quot;&gt;电影记录栏目&lt;/a&gt;，还是用手动添加数据的方式，比较麻烦。后来我又添加了游戏记录栏目，这次我决定用脚本抓取游戏数据，只需要输入名字或者ID。&lt;/p&gt;
&lt;p&gt;先看一下最终实现的效果，按照年份区分tab，支持点击跳转，并且有评分、游玩时间、平台等详细信息。我个人比较满意的，接下来我们一步一步实现这个效果。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-1.png&quot; alt=&quot;show result&quot;&gt;&lt;/p&gt;
&lt;h2&gt;新增页面文件&lt;/h2&gt;
&lt;p&gt;首先在 &lt;code&gt;content/page&lt;/code&gt; 目录下新增 &lt;code&gt;games&lt;/code&gt; 目录，然后添加index.md文件，下面这些参数只需要修改标题和描述，以及在菜单中的排序和图标，注意项目里默认是没有 game.svg 的，你需要手动下载放在 &lt;code&gt;assets/icons&lt;/code&gt; 目录下&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;---
title: 游戏
description: 个人年度游戏总结
slug: games
layout: games 
readingTime: false
toc: false
comments: false
license: false
menu:
    main:
        weight: 4
        params: 
            icon: game
---
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;新增html模版文件&lt;/h2&gt;
&lt;p&gt;新增&lt;code&gt;layouts/_dafault/games.html&lt;/code&gt;文件，内容如下，代码无需修改，可以修改一些样式：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;{{ define &amp;quot;body-class&amp;quot; }}template-movies{{ end }}
{{ define &amp;quot;main&amp;quot; }}
    &amp;lt;header&amp;gt;
        &amp;lt;h2 class=&amp;quot;article-title&amp;quot;&amp;gt;{{ .Title }}&amp;lt;/h2&amp;gt;
    &amp;lt;/header&amp;gt;

    &amp;lt;!-- Movies Section --&amp;gt;
    {{ $moviesData := .Site.Data.games }}
    {{ $currentYear := now.Format &amp;quot;2006&amp;quot; }}
    {{ $isEnglish := eq .Site.Language.Lang &amp;quot;en&amp;quot; }}

    {{ $sortedYears := slice }}
    {{ range $year, $movies := $moviesData }}
        {{ $sortedYears = $sortedYears | append $year }}
    {{ end }}
    {{ $sortedYears = sort $sortedYears &amp;quot;value&amp;quot; &amp;quot;desc&amp;quot; }}

    {{ $latestYear := index $sortedYears 0 }}

    &amp;lt;div class=&amp;quot;year-buttons&amp;quot;&amp;gt;
        {{ range $sortedYears }}
            &amp;lt;button class=&amp;quot;year-btn{{ if eq . $latestYear }} active{{ end }}&amp;quot; data-year=&amp;quot;{{ . }}&amp;quot;&amp;gt;{{ . }}&amp;lt;/button&amp;gt;
        {{ end }}
    &amp;lt;/div&amp;gt;


    {{ range $year, $movies := $moviesData }}
        &amp;lt;div class=&amp;quot;movies-grid{{ if eq $year $latestYear }} active{{ end }}&amp;quot; data-year=&amp;quot;{{ $year }}&amp;quot; data-type=&amp;quot;movies&amp;quot;&amp;gt;
            {{ range $movies }}
                &amp;lt;div  class=&amp;quot;movie-card {{ if .id }}clickable{{ end }}&amp;quot; onclick=&amp;quot;goToMoviePage(&amp;#39;{{ .id }}&amp;#39;)&amp;quot;&amp;gt;
                    &amp;lt;div class=&amp;quot;movie-poster&amp;quot;&amp;gt;
                        {{ if .cover }}
                            &amp;lt;img src=&amp;quot;{{ .cover }}&amp;quot; alt=&amp;quot;{{ .title }}&amp;quot; loading=&amp;quot;lazy&amp;quot;&amp;gt;
                        {{ else }}
                            &amp;lt;div class=&amp;quot;no-poster&amp;quot;&amp;gt;No Poster Available&amp;lt;/div&amp;gt;
                        {{ end }}
                    &amp;lt;/div&amp;gt;
                    &amp;lt;div class=&amp;quot;movie-info&amp;quot;&amp;gt;
                        &amp;lt;h3 class=&amp;quot;movie-title&amp;quot;&amp;gt;{{ if $isEnglish }}
                                    {{ .enTitle | default .title }}
                                {{ else }}
                                    {{ .title }}
                                {{ end }} &amp;lt;span class=&amp;quot;movie-year&amp;quot;&amp;gt;{{ .year }}&amp;lt;/span&amp;gt;&amp;lt;/h3&amp;gt;
                        &amp;lt;div class=&amp;quot;movie-meta&amp;quot;&amp;gt;
                            {{ if .rating }}
                                &amp;lt;span class=&amp;quot;rating&amp;quot;&amp;gt;{{ i18n &amp;quot;ratings&amp;quot; | default &amp;quot;媒体&amp;quot; }}⭐ {{ printf &amp;quot;%.1f&amp;quot; .rating }}&amp;lt;/span&amp;gt;
                            {{ end }}
    
                            {{ if .platform }}
                                &amp;lt;span class=&amp;quot;platform&amp;quot;&amp;gt;{{ .platform }}&amp;lt;/span&amp;gt;
                             {{ end }}
                        &amp;lt;/div&amp;gt;
                        &amp;lt;div class=&amp;quot;movie-meta&amp;quot;&amp;gt;
                            {{ if .selfRating }}
                                &amp;lt;span class=&amp;quot;rating&amp;quot;&amp;gt;{{ i18n &amp;quot;self_ratings&amp;quot; | default &amp;quot;个人&amp;quot; }}⭐ {{ printf &amp;quot;%.1f&amp;quot; .selfRating }}&amp;lt;/span&amp;gt;
                            {{ end }}
                            {{ if .playTime }}
                                &amp;lt;span class=&amp;quot;platform&amp;quot;&amp;gt;{{ .playTime  }}H&amp;lt;/span&amp;gt;
                             {{ end }}
                        &amp;lt;/div&amp;gt;
                    &amp;lt;/div&amp;gt;
                &amp;lt;/div&amp;gt;
            {{ end }}
        &amp;lt;/div&amp;gt;
    {{ end }}

    &amp;lt;style&amp;gt;
        .year-buttons {
            margin: 0rem 0;
            text-align: left;
        }

        .year-btn {
            padding: 0.7rem 1.5rem;
            margin: 0 0rem;
            border: 2px solid var(--card-background);
            background: transparent;
            color: var(--card-text-color-secondary);
            border-radius: 4px;
            cursor: pointer;
            transition: all 0.2s;
            font-size: 1.6rem;
            font-weight: 500;
            opacity: 0.7;
        }

        .year-btn:hover {
            opacity: 0.9;
            border-color: var(--accent-color);
        }

        .year-btn.active {
            background: var(--accent-color);
            color: var(--accent-color-text);
            border-color: var(--accent-color);
            opacity: 1;
        }

        .movies-grid {
            display: none;
            grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
            gap: 1.5rem;
            padding: 0rem 0;
        }
        
        @media (min-width: 560px) {
            .movies-grid {
                grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
                gap: 2rem;
            }
        }
        
        .movies-grid.active {
            display: grid;
        }
        
        .movie-card {
            background: var(--card-background);
            border-radius: 6px;
            overflow: hidden;
            transition: transform 0.25s ease, box-shadow 0.25s ease;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            cursor: default;
            display: flex; /* Make card a flex container */
            flex-direction: column; /* Stack children vertically */
        }

        .movie-card.clickable {
            cursor: pointer;
        }

        .movie-card.clickable:hover {
            transform: translateY(-4px);
            box-shadow: 0 6px 12px rgba(0,0,0,0.15);
        }

        .movie-card.clickable:hover .movie-poster img {
            filter: brightness(1.05) contrast(1.05);
        }

        .movie-poster {
            position: relative;
            padding-top: 57%;
            background: #2a2a2a;
        }

        .movie-poster img {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            object-fit: cover;
            transition: filter 0.25s ease;
        }

        
        .no-poster {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: #888;
            font-size: 0.9rem;
        }
        
        .movie-info {
            padding: 0.8rem 0.8rem 0.4rem 0.8rem;
            display: flex; /* ⭐ 1. Make this a flex container */
            flex-direction: column; /* ⭐ 2. Stack its children vertically */
            flex-grow: 1; /* ⭐ 3. Allow this container to grow and fill the card&amp;#39;s height */
        }
        
        .movie-title {
            margin: 0 0 0.8rem 0;
            font-size: 1.6rem;
            line-height: 1.3;
            font-weight: 700;
            color: var(--card-text-color-main);
            font-family: var(--article-font-family);
            display: flex;
            align-items: center;
            justify-content: space-between;
            flex-grow: 1; /* ⭐ 4. Make the title area grow, pushing the meta info down */
        }

        .movie-year {
            font-size: 1.2rem;
            color: var(--card-text-color-secondary);
            font-weight: 600;
            align-self: center;
            margin-left: 0.5rem;
        }
        
        .movie-meta {
            padding: 0.5rem 0 0.3rem 0;
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 1.3rem;
            color: var(--card-text-color-secondary);
        }

        @media (max-width: 768px) {
            .movie-title {
              font-size: 1.4rem;
            }
            .movie-meta {
              font-size: 1.2rem;
            }
        }
         
        .rating {
            font-weight: 500;
            color: var(--card-text-color-secondary);
        }
        
        .platform {
            font-size: 1.2rem;
            color: var(--card-text-color-secondary);
            font-weight: 500;
        }
    &amp;lt;/style&amp;gt;

    &amp;lt;script&amp;gt;
        document.addEventListener(&amp;#39;DOMContentLoaded&amp;#39;, function() {
            const yearButtons = document.querySelectorAll(&amp;#39;.year-btn&amp;#39;);
            const grids = document.querySelectorAll(&amp;#39;.movies-grid&amp;#39;);

            yearButtons.forEach(button =&amp;gt; {
                button.addEventListener(&amp;#39;click&amp;#39;, function() {
                    const year = this.dataset.year;
                    
                    yearButtons.forEach(btn =&amp;gt; btn.classList.remove(&amp;#39;active&amp;#39;));
                    grids.forEach(grid =&amp;gt; grid.classList.remove(&amp;#39;active&amp;#39;));
                    
                    this.classList.add(&amp;#39;active&amp;#39;);
                    
                    document.querySelectorAll(`.movies-grid[data-year=&amp;quot;${year}&amp;quot;]`).forEach(grid =&amp;gt; {
                        grid.classList.add(&amp;#39;active&amp;#39;);
                    });
                });
            });
        });
        function goToMoviePage(movieId) {
            if (movieId.includes(&amp;quot;https&amp;quot;)) {
                window.open(movieId, &amp;#39;_blank&amp;#39;);
                return;
            }
            window.open(&amp;#39;https://store.steampowered.com/app/&amp;#39; + movieId, &amp;#39;_blank&amp;#39;);
        }
    &amp;lt;/script&amp;gt;
    {{ partialCached &amp;quot;footer/footer&amp;quot; . }}
{{ end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;新建游戏数据文件&lt;/h2&gt;
&lt;p&gt;在根目录下新建一个data文件夹，创建 &lt;code&gt;games.yaml&lt;/code&gt; 文件，内容如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;2025:
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;新建script脚本&lt;/h2&gt;
&lt;p&gt;在根目录下新建一个scripts文件夹，创建一个 &lt;code&gt;fetch_game.py&lt;/code&gt; 文件：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;#!/usr/bin/env python3
# -*- coding: utf-8 -*-

&amp;quot;&amp;quot;&amp;quot;
fetch_game.py (Python 3.9 compatible)

Supports two main data sources: Steam (--appid) and Bangumi (--bgm), as well as searching by name (--name).
&amp;quot;&amp;quot;&amp;quot;

import argparse
import os
import re
import sys
import yaml
import requests
from datetime import datetime
from typing import Optional, Dict, Any, List
from urllib.parse import quote
from PIL import Image

if hasattr(sys.stdout, &amp;quot;reconfigure&amp;quot;):
    try:
        sys.stdout.reconfigure(encoding=&amp;quot;utf-8&amp;quot;)
        sys.stderr.reconfigure(encoding=&amp;quot;utf-8&amp;quot;)
    except Exception:
        pass

STEAM_APPDETAILS  = &amp;quot;https://store.steampowered.com/api/appdetails&amp;quot;
STEAM_APPREVIEWS  = &amp;quot;https://store.steampowered.com/appreviews/{appid}?json=1&amp;amp;language=all&amp;amp;purchase_type=all&amp;quot;
BGM_SEARCH_API    = &amp;quot;https://api.bgm.tv/v0/search/subjects&amp;quot;
BGM_SUBJECT_API   = &amp;quot;https://api.bgm.tv/v0/subjects/{subject_id}&amp;quot;

HEADERS = {&amp;quot;User-Agent&amp;quot;: &amp;quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36&amp;quot;}
PROXIES = {&amp;quot;http&amp;quot;: &amp;quot;http://127.0.0.1:10808&amp;quot;, &amp;quot;https&amp;quot;: &amp;quot;http://127.0.0.1:10808&amp;quot;}
DEFAULT_PLAYTIME = 15

# ---- HTTP ----
def http_get_json(url, params=None, timeout=15):
    try:
        r = requests.get(url, params=params, timeout=timeout, headers=HEADERS, proxies=PROXIES)
        r.raise_for_status()
        return r.json()
    except Exception:
        # Fallback to direct connection without proxy if proxy fails
        r = requests.get(url, params=params, timeout=timeout, headers=HEADERS)
        r.raise_for_status()
        return r.json()

def http_post_json(url, json_data=None, timeout=15):
    try:
        r = requests.post(url, json=json_data, timeout=timeout, headers=HEADERS, proxies=PROXIES)
        r.raise_for_status()
        return r.json()
    except Exception:
        # Fallback to direct connection without proxy if proxy fails
        r = requests.post(url, json=json_data, timeout=timeout, headers=HEADERS)
        r.raise_for_status()
        return r.json()

# ---- utils ----
def slugify(title: str) -&amp;gt; str:
    s = re.sub(r&amp;quot;[^a-z0-9\- ]+&amp;quot;, &amp;quot;&amp;quot;, title.lower())
    s = re.sub(r&amp;quot;\s+&amp;quot;, &amp;quot;-&amp;quot;, s)
    return s[:80].strip(&amp;quot;-&amp;quot;) or &amp;quot;game&amp;quot;

def ensure_dir_for_path(path: str):
    d = os.path.dirname(path)
    if d:
        os.makedirs(d, exist_ok=True)

def save_image(url: str, path: str) -&amp;gt; bool:
    try:
        ensure_dir_for_path(path)
        try:
            r = requests.get(url, stream=True, headers=HEADERS, timeout=20, proxies=PROXIES)
            r.raise_for_status()
        except Exception:
            r = requests.get(url, stream=True, headers=HEADERS, timeout=20)
            r.raise_for_status()
        
        with open(path, &amp;quot;wb&amp;quot;) as f:
            for chunk in r.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
        
        # 检查图片大小，如果超过200KB则进行压缩
        compress_image_if_needed(path, 200 * 1024)  # 200KB
        return True
    except Exception as e:
        print(f&amp;quot;⚠️ Failed to save image: {e}&amp;quot;)
        return False

def compress_image_if_needed(image_path: str, max_size_bytes: int):
    &amp;quot;&amp;quot;&amp;quot;检查图片大小，如果超过指定大小则通过降低分辨率进行压缩&amp;quot;&amp;quot;&amp;quot;
    try:
        # 检查文件大小
        file_size = os.path.getsize(image_path)
        if file_size &amp;lt;= max_size_bytes:
            return  # 文件大小未超过限制，无需压缩
        
        print(f&amp;quot;🔄 图片大小 {file_size/1024:.1f}KB 超过 {max_size_bytes/1024:.0f}KB，开始压缩...&amp;quot;)
        
        # 打开图片
        with Image.open(image_path) as img:
            # 如果是RGBA模式，转换为RGB模式以减小文件大小
            if img.mode in (&amp;#39;RGBA&amp;#39;, &amp;#39;LA&amp;#39;, &amp;#39;P&amp;#39;):
                # 创建白色背景
                background = Image.new(&amp;#39;RGB&amp;#39;, img.size, (255, 255, 255))
                if img.mode == &amp;#39;P&amp;#39;:
                    img = img.convert(&amp;#39;RGBA&amp;#39;)
                background.paste(img, mask=img.split()[-1] if img.mode in (&amp;#39;RGBA&amp;#39;, &amp;#39;LA&amp;#39;) else None)
                img = background
            
            # 计算目标尺寸以满足文件大小要求
            scale_factor = (max_size_bytes / file_size)
            target_width = int(img.width * scale_factor)
            target_height = int(img.height * scale_factor)
            
            # 确保最小尺寸不小于200像素
            target_width = max(target_width, 200)
            target_height = max(target_height, 200)
            
            # 调整图片尺寸
            resized_img = img.resize((target_width, target_height), Image.LANCZOS)
            
            # 保存调整尺寸后的图片
            resized_img.save(image_path, format=&amp;#39;JPEG&amp;#39;, quality=85, optimize=True)
            
            # 检查压缩后的文件大小
            final_size = os.path.getsize(image_path)
            if final_size &amp;lt;= max_size_bytes:
                print(f&amp;quot;✅ 图片通过降低分辨率压缩完成，最终尺寸 {target_width}x{target_height}，大小 {final_size/1024:.1f}KB&amp;quot;)
                return
            
            # 如果仍然超过大小限制，进一步降低质量
            quality = 70
            while quality &amp;gt;= 30 and final_size &amp;gt; max_size_bytes:
                resized_img.save(image_path, format=&amp;#39;JPEG&amp;#39;, quality=quality, optimize=True)
                final_size = os.path.getsize(image_path)
                print(f&amp;quot;🔄 进一步调整质量到 {quality}，当前大小 {final_size/1024:.1f}KB&amp;quot;)
                quality -= 10
            
            print(f&amp;quot;✅ 图片压缩完成，最终尺寸 {target_width}x{target_height}，大小 {final_size/1024:.1f}KB&amp;quot;)
                
    except Exception as e:
        print(f&amp;quot;⚠️ 图片压缩失败: {e}&amp;quot;)

def parse_year_from_date_str(date_str: str) -&amp;gt; Optional[int]:
    if not date_str:
        return None
    m = re.search(r&amp;quot;(20\d{2}|19\d{2})&amp;quot;, date_str)
    return int(m.group(1)) if m else None

def to_platform_list_from_steam(platforms: Dict[str, Any]) -&amp;gt; List[str]:
    out = []
    if platforms.get(&amp;quot;windows&amp;quot;): out.append(&amp;quot;PC&amp;quot;)
    if platforms.get(&amp;quot;mac&amp;quot;): out.append(&amp;quot;macOS&amp;quot;)
    if platforms.get(&amp;quot;linux&amp;quot;): out.append(&amp;quot;Linux&amp;quot;)
    return out

# ---- Steam ----
def search_steam(query_name: str) -&amp;gt; Optional[Dict[str, Any]]:
    url = f&amp;quot;https://store.steampowered.com/api/storesearch/?cc=CN&amp;amp;l=schinese&amp;amp;term={quote(query_name)}&amp;quot;
    try:
        data = http_get_json(url)
        return (data.get(&amp;quot;items&amp;quot;) or [None])[0]
    except Exception:
        return None

def fetch_appdetails(appid: int, lang=&amp;quot;schinese&amp;quot;) -&amp;gt; Optional[Dict[str, Any]]:
    try:
        params = {&amp;quot;appids&amp;quot;: str(appid), &amp;quot;l&amp;quot;: lang}
        data = http_get_json(STEAM_APPDETAILS, params=params)
        node = data.get(str(appid)) if isinstance(data, dict) else None
        if node and node.get(&amp;quot;success&amp;quot;) and isinstance(node.get(&amp;quot;data&amp;quot;), dict):
            return node[&amp;quot;data&amp;quot;]
    except Exception:
        return None

def fetch_review_ratio(appid: int) -&amp;gt; Optional[float]:
    try:
        url = STEAM_APPREVIEWS.format(appid=appid)
        data = http_get_json(url)
        qs = (data or {}).get(&amp;quot;query_summary&amp;quot;) or {}
        pos, neg = qs.get(&amp;quot;total_positive&amp;quot;, 0), qs.get(&amp;quot;total_negative&amp;quot;, 0)
        total = pos + neg
        return pos / total if total &amp;gt; 0 else None
    except Exception:
        return None

def build_record_from_steam(appid: int, images_root: str, fallback_name: Optional[str]=None) -&amp;gt; Optional[Dict[str, Any]]:
    zh = fetch_appdetails(appid, lang=&amp;quot;schinese&amp;quot;) 
    en = fetch_appdetails(appid, lang=&amp;quot;english&amp;quot;) 
    if not zh and not en:
        return None
    
    zh_title, en_title = zh.get(&amp;quot;name&amp;quot;), en.get(&amp;quot;name&amp;quot;)
    title = zh_title or en_title or fallback_name or &amp;quot;Unknown&amp;quot;
    enTitle = en_title if en_title != title else title
    
    year = datetime.now().year
    platforms = to_platform_list_from_steam(zh.get(&amp;quot;platforms&amp;quot;) or {}) or [&amp;quot;PC&amp;quot;]
    ratio = fetch_review_ratio(appid)
    rating10 = round(ratio * 10, 1) if ratio is not None else None

    slug = slugify(en_title or title)
    rel_dir = os.path.join(images_root, str(year))
    rel_path = os.path.join(rel_dir, slug + &amp;quot;.jpg&amp;quot;)
    cover_rel = f&amp;quot;/images/games/{year}/{slug}.jpg&amp;quot;
    header_url = f&amp;quot;https://cdn.akamai.steamstatic.com/steam/apps/{appid}/header.jpg&amp;quot;
    cover_saved = save_image(header_url, rel_path)
    if not cover_saved:
        bgm_rec = fetch_bgm(title, images_root)
        if bgm_rec and bgm_rec.get(&amp;quot;cover&amp;quot;):
            cover_rel = bgm_rec.get(&amp;quot;cover&amp;quot;)
        if not rating10 and bgm_rec:
            rating10 = bgm_rec.get(&amp;quot;rating&amp;quot;)

    return {
        &amp;quot;title&amp;quot;: title,
        &amp;quot;enTitle&amp;quot;: enTitle,
        &amp;quot;cover&amp;quot;: cover_rel,
        &amp;quot;year&amp;quot;: year,
        &amp;quot;rating&amp;quot;: rating10,
        &amp;quot;selfRating&amp;quot;: rating10,
        &amp;quot;platform&amp;quot;: &amp;quot;, &amp;quot;.join(platforms),
        &amp;quot;playTime&amp;quot;: DEFAULT_PLAYTIME,
        &amp;quot;id&amp;quot;: int(appid),
    }

# ---- Bangumi (bgm.tv) ----
def fetch_bgm(name: str, images_root: str) -&amp;gt; Optional[Dict[str, Any]]:
    try:
        payload = {&amp;quot;keyword&amp;quot;: name, &amp;quot;filter&amp;quot;: {&amp;quot;type&amp;quot;: [4]}}
        data = http_post_json(BGM_SEARCH_API, json_data=payload)
        items = data.get(&amp;quot;data&amp;quot;) or []
        if not items:
            return None
        g = items[0]
        bgm_id = g.get(&amp;quot;id&amp;quot;)

        try:
            detail = http_get_json(BGM_SUBJECT_API.format(subject_id=bgm_id))
        except Exception:
            detail = g

        name_cn = detail.get(&amp;quot;name_cn&amp;quot;) or detail.get(&amp;quot;name&amp;quot;)
        name_en = detail.get(&amp;quot;name&amp;quot;)

        infobox = detail.get(&amp;quot;infobox&amp;quot;) or []
        platforms = []
        en_alias = None
        for entry in infobox:
            key = entry.get(&amp;quot;key&amp;quot;)
            val = entry.get(&amp;quot;value&amp;quot;)
            if key in [&amp;quot;平台&amp;quot;, &amp;quot;Platform&amp;quot;, &amp;quot;平台:&amp;quot;]:
                if isinstance(val, list):
                    platforms = [v.get(&amp;quot;v&amp;quot;) for v in val if isinstance(v, dict) and v.get(&amp;quot;v&amp;quot;)]
                elif isinstance(val, str):
                    platforms = [val]
            elif key in [&amp;quot;别名&amp;quot;, &amp;quot;英文名&amp;quot;, &amp;quot;Alias&amp;quot;]:
                if isinstance(val, list):
                    for v in val:
                        v_str = v.get(&amp;quot;v&amp;quot;) if isinstance(v, dict) else str(v)
                        if v_str and re.match(r&amp;quot;^[A-Za-z0-9\s\-:\.\&amp;#39;\!]+$&amp;quot;, v_str):
                            en_alias = v_str
                            break
                elif isinstance(val, str) and re.match(r&amp;quot;^[A-Za-z0-9\s\-:\.\&amp;#39;\!]+$&amp;quot;, val):
                    en_alias = val

        title = name_cn or name
        enTitle = en_alias or (name_en if re.match(r&amp;quot;^[A-Za-z0-9\s\-:\.\&amp;#39;\!]+$&amp;quot;, name_en or &amp;quot;&amp;quot;) else title)

        date_str = detail.get(&amp;quot;date&amp;quot;) or &amp;quot;&amp;quot;
        year = parse_year_from_date_str(date_str) or datetime.now().year

        rating = None
        score = (detail.get(&amp;quot;rating&amp;quot;) or {}).get(&amp;quot;score&amp;quot;)
        if score:
            try:
                rating = round(float(score), 1)
            except Exception:
                pass

        cover_rel = None
        bg = (detail.get(&amp;quot;images&amp;quot;) or {}).get(&amp;quot;large&amp;quot;) or (detail.get(&amp;quot;images&amp;quot;) or {}).get(&amp;quot;common&amp;quot;)
        if bg:
            slug = slugify(enTitle or title)
            this_year = datetime.now().year
            rel_dir = os.path.join(images_root, str(this_year))
            rel_path = os.path.join(rel_dir, slug + &amp;quot;.jpg&amp;quot;)
            if save_image(bg, rel_path):
                cover_rel = f&amp;quot;/images/games/{this_year}/{slug}.jpg&amp;quot;

        return {
            &amp;quot;title&amp;quot;: title,
            &amp;quot;enTitle&amp;quot;: enTitle,
            &amp;quot;cover&amp;quot;: cover_rel,
            &amp;quot;year&amp;quot;: year,
            &amp;quot;rating&amp;quot;: rating,
            &amp;quot;selfRating&amp;quot;: rating,
            &amp;quot;platform&amp;quot;: &amp;quot;, &amp;quot;.join(platforms) if platforms else None,
            &amp;quot;playTime&amp;quot;: DEFAULT_PLAYTIME,
            &amp;quot;id&amp;quot;: f&amp;quot;https://bgm.tv/subject/{bgm_id}&amp;quot;,
        }
    except Exception as e:
        print(&amp;quot;⚠️ Bangumi fetch error: &amp;quot;, e)
        return None

# ---- YAML ----
def load_lines(path: str) -&amp;gt; List[str]:
    return open(path, &amp;quot;r&amp;quot;, encoding=&amp;quot;utf-8&amp;quot;).readlines() if os.path.exists(path) else []

def write_with_insertion(path: str, first_line: Optional[str], formatted_lines: List[str], rest_lines: List[str]):
    with open(path, &amp;quot;w&amp;quot;, encoding=&amp;quot;utf-8&amp;quot;) as f:
        if first_line: f.write(first_line.rstrip(&amp;quot;\n&amp;quot;) + &amp;quot;\n&amp;quot;)
        for line in formatted_lines: f.write(line.rstrip(&amp;quot;\n&amp;quot;) + &amp;quot;\n&amp;quot;)
        for line in rest_lines: f.write(line)

# ---- main ----
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument(&amp;quot;--appid&amp;quot;, type=int)
    ap.add_argument(&amp;quot;--name&amp;quot;, type=str)
    ap.add_argument(&amp;quot;--bgm&amp;quot;, type=str, help=&amp;quot;Search game on Bangumi&amp;quot;)
    ap.add_argument(&amp;quot;--yaml-path&amp;quot;, type=str, default=&amp;quot;./data/games.yaml&amp;quot;)
    ap.add_argument(&amp;quot;--images-root&amp;quot;, type=str, default=&amp;quot;./static/images/games&amp;quot;)
    args = ap.parse_args()

    record = None
    if args.appid:
        record = build_record_from_steam(args.appid, args.images_root)
        if not record:
            print(&amp;quot;❌ Steam not found&amp;quot;); sys.exit(1)
    elif args.name:
        item = search_steam(args.name)
        record = build_record_from_steam(item[&amp;quot;id&amp;quot;], args.images_root, args.name) if item else fetch_bgm(args.name, args.images_root)
        if not record:
            print(&amp;quot;❌ Steam/Bangumi not found&amp;quot;); sys.exit(1)
    elif args.bgm:
        record = fetch_bgm(args.bgm, args.images_root)
        if not record:
            print(&amp;quot;❌ Bangumi not found&amp;quot;); sys.exit(1)
    else:
        print(&amp;quot;⚠️ Must provide --appid / --name / --bgm&amp;quot;); sys.exit(1)

    lines = load_lines(args.yaml_path)
    first_line, rest_lines = (lines[0], lines[1:]) if lines else (None, [])

    single_yaml = yaml.dump(record, allow_unicode=True, sort_keys=False)
    formatted = [(&amp;quot;  - &amp;quot; + ln if i == 0 else &amp;quot;    &amp;quot; + ln) for i, ln in enumerate(single_yaml.splitlines())]

    write_with_insertion(args.yaml_path, first_line, formatted, rest_lines)

    print(&amp;quot;✅ Successfully written: &amp;quot;, record.get(&amp;quot;title&amp;quot;))
    if record.get(&amp;quot;enTitle&amp;quot;): print(&amp;quot;   English title: &amp;quot;, record.get(&amp;quot;enTitle&amp;quot;))

if __name__ == &amp;quot;__main__&amp;quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;执行python脚本抓取数据&lt;/h2&gt;
&lt;p&gt;需要你的电脑有python3.9及以上的环境，执行以下命令测试抓取游戏数据，如果 &lt;code&gt;python3&lt;/code&gt; 无法运行，请使用 &lt;code&gt;python&lt;/code&gt; 代替：&lt;/p&gt;
&lt;p&gt;通过steam&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python3 ./scripts/fetch_game.py --appid 1746030
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过Bangumi&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python3 ./scripts/fetch_game.py --bgm &amp;quot;Mario Kart World&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过名称&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python3 ./scripts/fetch_game.py --name &amp;quot;逸剑风云决&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;增加多语言支持&lt;/h2&gt;
&lt;p&gt;这一步可选，如果你的网站支持多语言，比如英文需要配置 &lt;code&gt;i18n/en.yaml&lt;/code&gt;,追加以下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;ratings: &amp;quot;RTG&amp;quot;
self_ratings: &amp;quot;MY&amp;quot; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;经过这六个步骤，你已经成功创建了这个博客游戏记录栏目了~，以后需要添加游戏只需要运行python命令，然后手动修改你的个人评分和游玩时长就可以啦。&lt;/p&gt;
</content:encoded><category>Share</category><category>Life</category></item><item><title>Nextjs 网站接入 Adsense 并手动放置广告</title><link>https://liuhouliang.com/post/nextjs_adsense/</link><guid isPermaLink="true">https://liuhouliang.com/post/nextjs_adsense/</guid><pubDate>Tue, 02 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;上一篇文章我们讲到了接入 adsense 遇到的第一个问题：&lt;a href=&quot;/post/adsense_preview&quot;&gt;adsense无法预览&lt;/a&gt;，紧接着我就遇到了第二个问题：自动广告没有生效。&lt;/p&gt;
&lt;p&gt;我的网站是 nextjs 制作的纯静态页面，我在广告审核通过之后就放置了 adsense 代码在 head 标签中，然后配置了自动广告，打开了几种广告类型，包括：侧边栏广告、穿插广告、页内广告，结果只有穿插广告生效了，其他广告类型均没有生效。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
    &amp;lt;head&amp;gt;
        &amp;lt;script async src=&amp;quot;https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-id&amp;quot; crossOrigin=&amp;quot;anonymous&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;/head&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;原因思考&lt;/h2&gt;
&lt;p&gt;而且由于穿插广告是有触发限制的，我设置的只有半个小时才能触发一次，所以在我访问网站的过程中，很长时间没有任何一个广告出现。&lt;/p&gt;
&lt;p&gt;这个现象有点奇怪，如果我配置有问题，那应该连穿插广告都不该有。那为什么只有穿插广告生效呢？AI 给出了一个答案，可能是因为我的网站是纯静态页面，而且跳转使用的是 Link 标签，相当于页内跳转，可能对于 adsense 来说这样的跳转不算是进入新页面，adsense js 代码可能没有正确刷新。&lt;/p&gt;
&lt;p&gt;一开始我有点不相信这个原因，因为 nextjs 并非 SPA 单页面程序，即使使用了 Link 标签，也只是体验上类似 SPA 页面。毕竟静态导出后每一个页面都是有单独的 html 文件。 而且 adsense 已经发展这么长时间了，应该可以兼容这种主流的网站类型吧？&lt;/p&gt;
&lt;h2&gt;尝试解决&lt;/h2&gt;
&lt;p&gt;然后我主动观察了路由跳转过程，发现确实没有加载 js 代码；而在 hugo 网站上，每一次跳转都重新加载了 js 代码。觉得可能还真是这个原因，在谷歌搜索后确实有不少遇到相同问题的，提出一个解决方案是在路由跳转后手动刷新 js 代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// pages/_app.js 
import { useEffect } from &amp;#39;react&amp;#39;;
import { useRouter } from &amp;#39;next/router&amp;#39;;

function MyApp({ Component, pageProps }) {
  const router = useRouter();

  useEffect(() =&amp;gt; {
    const handleRouteChange = () =&amp;gt; {
      try {
        // 确保 adsbygoogle 数组存在
        if (window.adsbygoogle &amp;amp;&amp;amp; window.adsbygoogle.push) {
          console.log(&amp;#39;AdSense: Triggering push after route change&amp;#39;);
          // 触发 AdSense 重新扫描页面并加载广告
          (window.adsbygoogle = window.adsbygoogle || []).push({});
        }
      } catch (e) {
        console.error(&amp;#39;Error pushing to adsbygoogle:&amp;#39;, e);
      }
    };

    // 监听路由变化结束事件
    router.events.on(&amp;#39;routeChangeComplete&amp;#39;, handleRouteChange);

    // 组件卸载时取消监听
    return () =&amp;gt; {
      router.events.off(&amp;#39;routeChangeComplete&amp;#39;, handleRouteChange);
    };
  }, [router.events]);

  return &amp;lt;Component {...pageProps} /&amp;gt;;
}

export default MyApp;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个代码是我随便找的，思路都是一样的，就是主动调用 &lt;code&gt;adsbygoogle.push()&lt;/code&gt;，我测试了一下好像没有生效，因为每次跳转页面都会报错提示不要push已经存在的元素。&lt;/p&gt;
&lt;h2&gt;Link标签改为a标签&lt;/h2&gt;
&lt;p&gt;当然还有最简单的方法，就是将 Link 标签改为 a 标签，这样路由跳转时相当于刷新了页面，就会触发js代码，从而实现广告的加载。不过这种方法不推荐，因为体验下降了不少，跳转不再丝滑快速了&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// before
&amp;lt;Link
    key={item.name}
    href={item.href}
    className={`group flex items-center px-3 py-1.5 sm:py-2 text-sm font-medium rounded-lg transition-colors ${
    isActive
    ? &amp;#39;bg-blue-100 text-blue-700&amp;#39;
    : &amp;#39;text-gray-700 hover:bg-gray-100 hover:text-gray-900&amp;#39;
    }`}
    onClick={() =&amp;gt; setSidebarOpen(false)}
    &amp;gt;
    &amp;lt;span className=&amp;quot;mr-3 text-lg&amp;quot;&amp;gt;{item.icon}&amp;lt;/span&amp;gt;
    {item.name}
&amp;lt;/Link&amp;gt;


// after
&amp;lt;a
    key={item.name}
    href={item.href}
    className={`group flex items-center px-3 py-1.5 sm:py-2 text-sm font-medium rounded-lg transition-colors ${
    isActive
    ? &amp;#39;bg-blue-100 text-blue-700&amp;#39;
    : &amp;#39;text-gray-700 hover:bg-gray-100 hover:text-gray-900&amp;#39;
    }`}
    onClick={() =&amp;gt; setSidebarOpen(false)}
    &amp;gt;
    &amp;lt;span className=&amp;quot;mr-3 text-lg&amp;quot;&amp;gt;{item.icon}&amp;lt;/span&amp;gt;
    {item.name}
&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;手动放置广告&lt;/h2&gt;
&lt;p&gt;尝试以上方法都没能解决问题，没办法只能再次手动放置广告了，首先去 adsense 后台创建新的广告单元，我选择的是展示广告，任何地方都可以放置，然后复制代码。这里面&lt;code&gt;ad-client&lt;/code&gt;是你的发布商id，&lt;code&gt;ad-slot&lt;/code&gt;是广告单元id&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;&amp;lt;ins class=&amp;quot;adsbygoogle&amp;quot;
     style=&amp;quot;display:block&amp;quot;
     data-ad-client=&amp;quot;ca-pub-id&amp;quot;
     data-ad-slot=&amp;quot;slot-id&amp;quot;
     data-ad-format=&amp;quot;auto&amp;quot;
     data-full-width-responsive=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;/ins&amp;gt;
&amp;lt;script&amp;gt;
     (adsbygoogle = window.adsbygoogle || []).push({});
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来创建一个广告组件&lt;code&gt;components/Adsense.tsx&lt;/code&gt;，这段代码是AI写的，没什么质量可言但可以运行。修改&lt;code&gt; data-ad-client&lt;/code&gt;，然后在&lt;code&gt;AdsenseUnits&lt;/code&gt;添加广告单元&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;&amp;quot;use client&amp;quot;;
import { useEffect } from &amp;quot;react&amp;quot;;

// 声明window对象的adsbygoogle属性类型
declare global {
  interface Window {
    adsbygoogle: Array&amp;lt;Record&amp;lt;string, unknown&amp;gt;&amp;gt;;
  }
}

interface AdsenseProps {
  /**
   * 广告单元ID (data-ad-slot)
   */
  adSlot: string;
  /**
   * 广告格式 (auto, rectangle, horizontal, vertical)
   */
  adFormat?: string;
  /**
   * 是否全宽响应式
   */
  fullWidthResponsive?: boolean;
  /**
   * 自定义CSS类名
   */
  className?: string;
  /**
   * 自定义样式
   */
  style?: React.CSSProperties;
  /**
   * 广告容器类名
   */
  containerClassName?: string;
  /**
   * 广告容器样式
   */
  containerStyle?: React.CSSProperties;
  /**
   * 是否启用移动端特殊处理
   */
  mobileOptimized?: boolean;
}

/**
 * 可复用的Google AdSense广告组件
 * 支持多种广告格式和响应式设计
 */
export default function Adsense({
  adSlot,
  adFormat = &amp;quot;auto&amp;quot;,
  fullWidthResponsive = true,
  className = &amp;quot;&amp;quot;,
  style = {},
  containerClassName = &amp;quot;&amp;quot;,
  containerStyle = {},
  mobileOptimized = true,
}: AdsenseProps) {
  useEffect(() =&amp;gt; {
    // 加载AdSense广告
    const loadAdSense = () =&amp;gt; {
      try {
        if (typeof window !== &amp;#39;undefined&amp;#39; &amp;amp;&amp;amp; window.adsbygoogle) {
          (window.adsbygoogle = window.adsbygoogle || []).push({});
        }
      } catch (error) {
        console.error(&amp;#39;AdSense error:&amp;#39;, error);
      }
    };

    // 延迟加载广告，确保DOM已渲染
    const timer = setTimeout(loadAdSense, 100);
    return () =&amp;gt; clearTimeout(timer);
  }, [adSlot]);

  return (
    &amp;lt;div 
      className={`ad-container ${containerClassName}`}
      style={containerStyle}
    &amp;gt;
      &amp;lt;ins
        className={`adsbygoogle ${className}`}
        style={{
          display: &amp;#39;block&amp;#39;,
          ...style,
        }}
        data-ad-client=&amp;quot;ca-pub-id&amp;quot;
        data-ad-slot={adSlot}
        data-ad-format={adFormat}
        data-full-width-responsive={fullWidthResponsive.toString()}
      /&amp;gt;
      {mobileOptimized &amp;amp;&amp;amp; (
        &amp;lt;style jsx&amp;gt;{`
          .ad-container {
            max-width: 100%;
            overflow: hidden;
            box-sizing: border-box;
          }
          .adsbygoogle {
            max-width: 100%;
            overflow: hidden;
            box-sizing: border-box;
          }
          @media (max-width: 640px) {
            .ad-container {
              width: 100%;
            }
            .adsbygoogle {
              width: 100%;
            }
          }
        `}&amp;lt;/style&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}

/**
 * 预定义的广告单元配置
 */
export const AdsenseUnits = {
  // HEADER: &amp;quot;your-header-ad-slot&amp;quot;,
  // FOOTER: &amp;quot;your-footer-ad-slot&amp;quot;,
} as const;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;接下来在需要显示的广告页面中引入该组件并传入广告单元的ID即可，我把广告放在了评论区上方&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Adsense, { AdsenseUnits } from &amp;#39;./Adsense&amp;#39;;

return (
&amp;lt;div className={`mt-6 sm:mt-8 ${className}`}&amp;gt;
    &amp;lt;Adsense
        adSlot={AdsenseUnits.WALINE_TOP}
        adFormat=&amp;quot;auto&amp;quot;
        fullWidthResponsive={true}
        mobileOptimized={true}
        containerClassName=&amp;quot;mb-6 sm:mt-8 -mx-4 sm:mx-0&amp;quot;
    /&amp;gt;
    
    &amp;lt;div className=&amp;quot;bg-white rounded-lg shadow-sm border border-gray-200 p-6&amp;quot;&amp;gt;
    &amp;lt;div ref={commentRef} className=&amp;quot;waline-container&amp;quot; /&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;仍需完善&lt;/h2&gt;
&lt;p&gt;目前通过手动放置广告，添加了横幅广告和侧边竖向广告，体验感觉还是一般，可能还不如自动广告放置的合理。但是自动广告还是没有生效，不排除可能是我的网站访问人数太少；也可能就是对于页内跳转的支持不好，只能手动放置广告。&lt;/p&gt;
&lt;p&gt;如果你知道更好的方法，欢迎评论区告诉我，如果愿意的话也可以访问我的网站帮我增加流量，谢谢！&lt;/p&gt;
</content:encoded><category>Share</category></item><item><title>解决 Cloudflare 部署的网站无法预览谷歌 Adsense 的问题</title><link>https://liuhouliang.com/post/adsense_preview/</link><guid isPermaLink="true">https://liuhouliang.com/post/adsense_preview/</guid><pubDate>Mon, 01 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近我的工具网站成功通过了adsense审核，正当我开心地准备给网站放置广告时，我发现一个不大不小的问题，就是我的网站无法在adsense后台预览广告效果，这让我有点困惑，因为之前我的博客网站接入广告时是可以正常预览的。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image.png&quot; alt=&quot;can&apos;t preview&quot;&gt;&lt;/p&gt;
&lt;p&gt;解决方案非常简单，这个错误是因为cloudflare自动开启了 “自动程序攻击模式”，这个模式会阻止一些爬虫和机器程序流量，顺手把谷歌Adsense的爬虫机器人拒之门外了。需要预览的时候只需要暂时关闭这个功能，预览完成之后再开启&lt;/p&gt;
&lt;p&gt;这个设置位于：安全性-概述-自动程序流量&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-1.png&quot; alt=&quot;config pannel&quot;&gt;&lt;/p&gt;
&lt;p&gt;点击设置，进入新的页面，关闭 “自动程序攻击模式” 选项，就可以预览了&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-2.png&quot; alt=&quot;close bot fight mode&quot;&gt;&lt;/p&gt;
&lt;p&gt;你可以在分析-事件页面查看还有哪些机器人也被拦截了&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-3.png&quot; alt=&quot;block events&quot;&gt;&lt;/p&gt;
</content:encoded><category>Share</category></item><item><title>根据 Sitemap 自动推送新网页到必应 IndexNow</title><link>https://liuhouliang.com/post/nextjs_indexnow/</link><guid isPermaLink="true">https://liuhouliang.com/post/nextjs_indexnow/</guid><pubDate>Sun, 31 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近通过nextjs制作了一个静态工具网站 &lt;a href=&quot;https://pricecalc.net/&quot;&gt;PriceCalc&lt;/a&gt;，依然是通过&lt;code&gt;Cloudflare Pages&lt;/code&gt;进行部署，并且开启了&lt;code&gt;Crawler Hints&lt;/code&gt;，也就是必应平台的&lt;code&gt;IndexNow&lt;/code&gt;功能。大概作用就是站点内容改变后，自动推送到必应，可以更快地进行抓取和索引。&lt;/p&gt;
&lt;p&gt;但这个功能好像对于我的网站来说是无效的，因为我打开必应后台后，发现&lt;code&gt;IndexNow&lt;/code&gt;一直在推送一些无效的页面，比如&lt;code&gt;_next/static&lt;/code&gt;下面的文件。这反而起了反效果，甚至这些页面有些还被收录到索引了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-1.png&quot; alt=&quot;error url&quot;&gt;&lt;/p&gt;
&lt;p&gt;然后我真正需要推送到新页面，反而没有被正确推送，必应经常提示我 &lt;code&gt;最近发布重要的页面未通过 IndexNow 提交&lt;/code&gt;。说实话真有点红温了，猪队友了说是。我小手一点就给这个功能关闭了，决定编写脚本通过API进行推送。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image.png&quot; alt=&quot;error msg&quot;&gt;&lt;/p&gt;
&lt;h2&gt;关闭Crawler Hints&lt;/h2&gt;
&lt;p&gt;造成这个现象的原因我问了AI，大概说是静态网站不是hash模式的问题，总之我们先登录Cloudflare控制台，进入域名-缓存-配置，关闭Crawler Hints按钮。当然如果你的网站类型不是静态的，也许只需要开启这个功能就可以了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-2.png&quot; alt=&quot;close Crawler Hints&quot;&gt;&lt;/p&gt;
&lt;h2&gt;通过api推送&lt;/h2&gt;
&lt;p&gt;好在IndexNow提供了API可以手动推送，教程地址如下 &lt;a href=&quot;https://www.bing.com/indexnow/getstarted&quot;&gt;Bing IndexNow Started&lt;/a&gt; 方式非常简单，只需要通过POST请求将URL批量发送给必应即可。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-3.png&quot; alt=&quot;post indexnow&quot;&gt;&lt;/p&gt;
&lt;h2&gt;配置api key&lt;/h2&gt;
&lt;p&gt;打开网页后，会随机生成一个密钥，绑定密钥到域名的方式很简单，点击下载这个密钥文件，放在网站的根目录下，最终实现可以通过域名+密钥访问这个文件。比如：&lt;code&gt;https://example.com/6cb1b9bebef041ae9ab7809a54b93128.txt&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;这里最好直接下载文件，不要手动输入，可能会造成格式问题。直接下载后放到public或者static等静态目录下，然后上传到线上，确保可以通过链接访问到这个文件，并且文件内容就是这个密钥。&lt;/p&gt;
&lt;h2&gt;编写js脚本&lt;/h2&gt;
&lt;p&gt;我这里的思路是对比线上已有的sitemap.xml文件，然后对比新构建后的sitemap.xml文件，找出新增的页面，通过api推送给必应。&lt;/p&gt;
&lt;p&gt;新建一个&lt;code&gt;scripts/pushIndexNow.js&lt;/code&gt;，注意必须要填写站点域名和密钥。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;#!/usr/bin/env node
/**
 * pushIndexNow.js - Auto push new pages to Bing IndexNow
 * Usage:
 *   Local dry-run (check new URLs):
 *     node pushIndexNow.js --check
 *   Actual push online:
 *     node pushIndexNow.js --push
 *
 * Note: This script is intended to run in Node.js (server-side), not in the browser.
 */

import fs from &amp;#39;fs&amp;#39;;
import path from &amp;#39;path&amp;#39;;
import process from &amp;#39;process&amp;#39;;

// ====== Configuration ======
const SITE_URL = &amp;#39;https://pricecalc.net&amp;#39;;         // Your website URL
const INDEXNOW_KEY = &amp;#39;87be74cabfba46a4a30454dc488dd285&amp;#39;;           // Your IndexNow Key
const NEW_SITEMAP_FILE = path.join(process.cwd(), &amp;#39;out&amp;#39;, &amp;#39;sitemap.xml&amp;#39;);  // Local sitemap
const OLD_SITEMAP_URL = `${SITE_URL}/sitemap.xml`;  // Online sitemap URL
const CHUNK_SIZE = 1000;                             // Max URLs per push

// ====== Command-line args ======
const args = process.argv.slice(2);
const PUSH_MODE = args.includes(&amp;#39;--push&amp;#39;);
const CHECK_MODE = args.includes(&amp;#39;--check&amp;#39;) || !PUSH_MODE;

// ====== Helper functions ======
function chunkArray(arr, size) {
  const out = [];
  for (let i = 0; i &amp;lt; arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

// Parse sitemap.xml using regex to extract &amp;lt;loc&amp;gt; URLs
function parseSitemap(xmlString) {
  const regex = /&amp;lt;loc&amp;gt;(.*?)&amp;lt;\/loc&amp;gt;/g;
  const urls = [];
  let match;
  while ((match = regex.exec(xmlString)) !== null) {
    urls.push(match[1]);
  }
  return urls;
}

// Send URLs to Bing IndexNow
async function sendIndexNow(urls) {
  const payload = {
    host: new URL(SITE_URL).hostname,
    key: INDEXNOW_KEY,
    keyLocation: `${SITE_URL}/${INDEXNOW_KEY}.txt`,
    urlList: urls
  };

  console.log(&amp;#39;=== IndexNow POST Payload Preview ===&amp;#39;);
  console.log(JSON.stringify(payload, null, 2));
  console.log(&amp;#39;====================================&amp;#39;);

  const res = await fetch(&amp;#39;https://api.indexnow.org/indexnow&amp;#39;, {
    method: &amp;#39;POST&amp;#39;,
    headers: { &amp;#39;Content-Type&amp;#39;: &amp;#39;application/json; charset=utf-8&amp;#39; },
    body: JSON.stringify(payload)
  });
  const text = await res.text();
  return { ok: res.ok, status: res.status, text };
}

// ====== Main ======
(async function main() {
  // 1. Read local sitemap.xml
  if (!fs.existsSync(NEW_SITEMAP_FILE)) {
    console.error(&amp;#39;Local sitemap.xml not found:&amp;#39;, NEW_SITEMAP_FILE);
    process.exit(1);
  }
  const xml = fs.readFileSync(NEW_SITEMAP_FILE, &amp;#39;utf-8&amp;#39;);
  const newUrls = parseSitemap(xml);

  // 2. Fetch online sitemap.xml
  let oldUrls = [];
  try {
    const res = await fetch(OLD_SITEMAP_URL);
    if (res.ok) {
      const oldXml = await res.text();
      oldUrls = parseSitemap(oldXml);
    } else {
      console.warn(&amp;#39;Online sitemap.xml not found (HTTP&amp;#39;, res.status, &amp;#39;). Assuming all URLs are new.&amp;#39;);
    }
  } catch (e) {
    console.warn(&amp;#39;Error fetching online sitemap.xml. Assuming all URLs are new.&amp;#39;, e.message);
  }

  // 3. Compute difference (new URLs)
  const oldSet = new Set(oldUrls);
  const toPush = newUrls.filter(u =&amp;gt; !oldSet.has(u));

  if (toPush.length === 0) {
    console.log(&amp;#39;No new pages to push.&amp;#39;);
    process.exit(0);
  }

  console.log(`Found ${toPush.length} new pages.`);

  if (CHECK_MODE) {
    console.log(&amp;#39;== Dry run mode ==&amp;#39;);
    toPush.forEach(u =&amp;gt; console.log(u));
    process.exit(0);
  }

  // 4. Push new URLs to Bing IndexNow
  const chunks = chunkArray(toPush, CHUNK_SIZE);
  for (let i = 0; i &amp;lt; chunks.length; i++) {
    const chunk = chunks[i];
    console.log(`Pushing chunk ${i + 1}/${chunks.length} (${chunk.length} URLs)...`);
    try {
      const res = await sendIndexNow(chunk);
      if (res.ok) console.log(`Chunk ${i + 1} succeeded (HTTP ${res.status})`);
      else console.error(`Chunk ${i + 1} failed (HTTP ${res.status})`, res.text);
    } catch (e) {
      console.error(`Chunk ${i + 1} encountered an error:`, e.message || e);
    }
    if (i &amp;lt; chunks.length - 1) await new Promise(r =&amp;gt; setTimeout(r, 500));
  }

  console.log(&amp;#39;IndexNow push completed.&amp;#39;);
})();
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;修改build脚本&lt;/h2&gt;
&lt;p&gt;加上最后一步&lt;code&gt;node scripts/pushIndexNow.js&lt;/code&gt;，注意这里我添加了npm run build-dev命令，因为本地运行时就会直接触发推送了，我们要实现的效果是在构建时触发，我这里就新加了一个本地运行的命令&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# before build command
&amp;quot;build&amp;quot;: &amp;quot;next build &amp;amp;&amp;amp; next-sitemap&amp;quot;,

# after build command
&amp;quot;build&amp;quot;: &amp;quot;next build &amp;amp;&amp;amp; next-sitemap &amp;amp;&amp;amp; node scripts/pushIndexNow.js --push&amp;quot;

&amp;quot;build-dev&amp;quot;: &amp;quot;next build &amp;amp;&amp;amp; next-sitemap &amp;amp;&amp;amp; node scripts/pushIndexNow.js --check&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;本地测试&lt;/h2&gt;
&lt;p&gt;这段代码可以本地测试运行，附带&lt;code&gt;--check&lt;/code&gt;参数进行预检测&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node scripts/pushIndexNow.js --check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意最好直接运行&lt;code&gt;npm run build-dev&lt;/code&gt;进行测试，因为本质上是读取sitemap.xml文件，如果你修改了代码但是没有构建，sitemap是不会更新的&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build-dev

#output:
Found 1 new pages.
== Dry run mode ==
https://example.com/new-page/
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;检测是否生效&lt;/h2&gt;
&lt;p&gt;添加新页面后，可以直接手动执行&lt;code&gt;--push&lt;/code&gt;，会打印日志，包括post请求具体参数，会返回的响应结果，200就是直接成功，202只能代表命令执行成功了，至于400和403就是参数和权限错误。&lt;/p&gt;
&lt;p&gt;然后去&lt;a href=&quot;https://www.bing.com/webmasters/indexnow&quot;&gt;Bing IndexNow控制台&lt;/a&gt;，检查最近推送的URL，推送成功的话就会显示你推送的链接，和推送方式，API推送会显示&lt;code&gt;self&lt;/code&gt;,到此就大功告成了~&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;image-4.png&quot; alt=&quot;result&quot;&gt;&lt;/p&gt;
&lt;h2&gt;代码开源&lt;/h2&gt;
&lt;p&gt;最后我把代码放在了GitHub上，欢迎大家 Star 谢谢~&lt;/p&gt;
</content:encoded><category>Share</category></item></channel></rss>