feat(content): implement consultant review recommendations (executive summary, dynamic cta, homelab cleanup)
Deploy / build (push) Successful in 25s

This commit is contained in:
hoelee committed 2026-10-27 00:31:50 +08:00
1 parent 5ecf25d37c
commit 355fecd348
28 files changed
+1634 -49

No files matched your search

@@ -0,0 +1,388 @@
这份由 Hermes Agent 整理的补充资料包非常详实,代码结构清晰,尤其是对第四部分“主站断层与 GSC 零收录原因”的诊断非常诚实且精准。
在直接进入你要求的 **4 项重点开刀示范** 之前,我先针对你刚暴露出来的 **3 个工程与内容层面的紧急漏洞** 给出快刀斩乱麻的修复指令(这部分一天之内就能改完,但收益巨大):
### 🚨 紧急修复指令(Day-1 Quick Wins)
1. **主站 `[www.hoelee.com](https://www.hoelee.com)` 到 `blog.hoelee.com` 的权重导流(解决孤岛与冷启动)**:
* 既然主站是 WordPress 且已有一定历史权重,却对博客“0 入链”,请立即在主站 `[www.hoelee.com](https://www.hoelee.com)` 的顶部主导航栏(Main Menu)**和**页脚(Footer)加上 `Engineering Blog` 或 `Case Studies` 链接指向 `[https://blog.hoelee.com/](https://blog.hoelee.com/)`。
* 立即清理主站那 400 个旧版媒体附件薄弱页(Thin attachment pages)(在 WordPress SEO 插件如 RankMath / Yoast 中开启“Redirect attachment URLs to parent post/file”,否则会严重拖累整个 `hoelee.com` 根域名的抓取预算)。
* 立即登录 GSC,在 `hoelee.com` 域名属性下提交 `[https://blog.hoelee.com/sitemap-index.xml](https://blog.hoelee.com/sitemap-index.xml)`,并用 URL Inspection 工具手动请求抓取首页和 4 篇 Featured Case Studies。
2. **修复样本 1 中的“规范违背”(Persona 破功)**:
* 在你第一轮发我的规范中,明确写道:**“严格禁用 `my home lab` 等爱好者口吻”**。但在样本 1《DigiKedai Bot》的 `## The architecture` 第 2 段第一句,赫然写着:*“My **homelab** is behind CGNAT...”*。
* **行动**:让 Hermes Agent 对全站 91 篇英文和 91 篇中文 Markdown 跑一次全局正则扫描(搜索 `homelab`、`home lab`、`家用`、`小主机`),统一替换为 `self-hosted production node` / `on-premise host` / `自托管生产节点`。
3. **打通 Markdown 到 Astro 组件的渲染能力**:
* 目前你的文章全是 `.md` 格式。为了不破坏现有的 182 篇 `.md` 文件的兼容性,**千万不要把所有文件改成 `.mdx**`。最优雅的工程方案是:将 `<ExecutiveSummary/>` 和 `<DynamicCTA/>` 的结构化数据写入文章的 **Frontmatter(YAML)**,由 `src/pages/posts/[...slug].astro` 统一读取并在 `<Content/>` 的一头一尾自动渲染;而正文里的折叠代码区,直接使用原生 HTML5 的 `<details><summary>` 标签(Markdown 原生兼容,只需在 `global.css` 里写好极客风样式)。
---
下面,我针对你清单中的 **4 大核心任务**,给出可直接复制进项目的代码级示范与重构方案:
---
## 示范一:首屏速览卡片(`<ExecutiveSummary/>`)实例文案与实现
### 1. Frontmatter 结构升级与双套实例文案
在 `src/content/config.ts` 的 schema 中增加可选的 `summary` 字段。针对两类完全不同的受众(样本 1 面向中小企业老板/产品负责人,样本 2 面向 CTO/资深运维招聘官),文案侧重点完全不同:
#### 📌 样本 1:《How I Built the DigiKedai Telegram AI Bot》(商业交付侧重)
```yaml
# 新增至 how-i-built-the-digikedai-telegram-bot.md 的 frontmatter
summary:
audience: "Business Owners & Product Leads"
painPoint: "1人数字商品电商面临大量重复售前咨询(试用申请、账号开通、选品),夜间与周末漏单严重,人工客服成本高。"
impact:
- metric: "24/7 秒级响应"
detail: "全天候接管 Telegram 与 WhatsApp 双端咨询,零人工介入自动发放免费试用账号"
- metric: "0 价格幻觉"
detail: "三层确定性 SKU 检索 + 状态机拦截购买意图,杜绝大模型胡报价或泄漏内部路径"
- metric: "~175 项单测护航"
detail: "单容器稳定运行于 NAS,通过自建 LiteLLM 网关实现模型高可用切换"
stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"]
jumpLinks:
business: "#why-a-business-needs-a-bot-like-this"
architecture: "#the-architecture"
result: "#the-result"
```
#### 📌 样本 2:《Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.》(硬核工程/招聘侧重)
```yaml
# 新增至 docker-reported-exit-0-the-oom-killer-disagreed.md 的 frontmatter
summary:
audience: "SREs, DevOps Leads & Systems Engineers"
painPoint: "公网边缘反向代理容器(Traefik)4天内无预警重启7次,但 Docker daemon 谎报 ExitCode=0 且 OOMKilled=false,常规监控完全失明。"
impact:
- metric: "精准定位真凶"
detail: "穿透 Docker 状态假象,通过内核 dmesg 与 cgroup 内存水位线抓出 25,976 个代理爬虫 IP 引发的 512MB 触顶"
- metric: "0 停机热修复"
detail: "单容器精准扩容至 2GB 并锁死 Swap(防磁盘阵列雪崩),18分钟峰值回落至 210MB,连续运行 0 崩溃"
- metric: "防自锁诊断规范"
detail: "沉淀边缘网关高并发防爬虫压测与避免自身 IP 被 WAF 误封(403 假象)的排障 SOP"
stack: ["Docker", "Linux Kernel (dmesg/cgroups)", "Traefik", "Synology DSM", "Security Filtering"]
jumpLinks:
business: "#the-result"
architecture: "#the-kernel-keeps-its-own-record"
result: "#what-i-would-do-differently"
```
### 2. Astro 组件代码(`src/components/ExecutiveSummary.astro`)
将此组件插入 `src/pages/posts/[...slug].astro` 的 `<TableOfContents/>` 之前:
```astro
---
interface Props {
summary?: {
audience: string;
painPoint: string;
impact: { metric: string; detail: string }[];
stack: string[];
jumpLinks?: { business?: string; architecture?: string; result?: string };
};
lang?: 'en' | 'zh';
}
const { summary, lang = 'en' } = Astro.props;
if (!summary) return null;
const labels = lang === 'zh' ? {
badge: '⚡ 核心速览 (Executive Summary)',
for: '适用读者:',
problem: '🎯 核心痛点',
impact: '📈 交付成果与工程指标',
stack: '🛠️ 涉及技术栈',
nav: '⏱️ 快速跳读:',
btnBiz: '看商业/最终结果 ↓',
btnArch: '看系统架构与排障 ↓',
btnLesson: '看复盘与避坑指南 ↓'
} : {
badge: '⚡ Executive Summary',
for: 'Target Audience:',
problem: '🎯 The Problem',
impact: '📈 Key Outcomes & Metrics',
stack: '🛠️ Architecture Stack',
nav: '⏱️ Quick Jump:',
btnBiz: 'Business Outcome ↓',
btnArch: 'Architecture & Root Cause ↓',
btnLesson: 'Lessons Learned ↓'
};
---
<aside class="exec-summary" aria-label={labels.badge}>
<div class="exec-header">
<span class="exec-badge">{labels.badge}</span>
<span class="exec-audience">{labels.for} <strong>{summary.audience}</strong></span>
</div>
<div class="exec-problem">
<strong>{labels.problem}:</strong> {summary.painPoint}
</div>
<div class="exec-grid">
{summary.impact.map((item) => (
<div class="exec-metric-card">
<div class="exec-metric-val">{item.metric}</div>
<div class="exec-metric-desc">{item.detail}</div>
</div>
))}
</div>
<div class="exec-footer">
<div class="exec-stack">
<span>{labels.stack}:</span>
{summary.stack.map(t => <code class="exec-pill">{t}</code>)}
</div>
{summary.jumpLinks && (
<div class="exec-jumps">
<span>{labels.nav}</span>
{summary.jumpLinks.business && <a href={summary.jumpLinks.business}>{labels.btnBiz}</a>}
{summary.jumpLinks.architecture && <a href={summary.jumpLinks.architecture}>{labels.btnArch}</a>}
{summary.jumpLinks.result && <a href={summary.jumpLinks.result}>{labels.btnLesson}</a>}
</div>
)}
</div>
</aside>
```
---
## 示范二:正文排版重构、视觉前置与代码折叠(`<TechnicalDeepDive/>`)
很多技术博客之所以留不住老板,是因为**把最枯燥的文本架构和细节堆在前面,把最性感的成果藏在最后**。以下是针对两篇样本的具体手术刀级重构方案:
### 🔪 针对样本 1(《DigiKedai Bot》)的重构指令
:
1. **前 30% 黄金视觉区改造(替换简陋的 ASCII 箭头图)**:
* 原文 `## The architecture` 下方那段纯文本箭头(`Customer → Telegram/WhatsApp → ...`)显得过于简陋。
* **改造**:将其替换为一张清晰的 **双轨制状态机架构图(SVG 或 Mermaid)**,重点标出两条颜色不同的路径:
* 🟢 **确定性快车道(Deterministic Fast-Track)**:`购买意图 / /trial 命令 / Deep Link` ➔ **绕过 LLM** ➔ 直接写 NocoDB 发放账号 / 弹出内联菜单。
* 🔵 **语义检索慢车道(RAG LLM Path)**:`开放式商品提问` ➔ 三层 SKU 检索器(自动捆绑免费试用版 `FREECZH01`) ➔ LiteLLM (`mem0-openai`) ➔ 精准回复。
* **新增 15 秒实机演示视频**:在 `## Why a business needs a bot like this` 下方,立即嵌入一段左右分屏的 15 秒无声循环 WebM 动图(左边是 Telegram 客户端发一句 *“我要这个试用”*,右边是 NocoDB 表格秒级自动多出一行记录并回复账号密码)。这能让中小企业老板在 **第 15 秒** 就产生付费冲动。
2. **应该收进折叠区(`<details class="tech-deep-dive">`)的内容**:
* **折叠点 1**:`## Retrieval: how the bot actually knows the catalogue` 中的具体正则匹配规则(`/\b[A-Z]{2,}\d{2,}\b/gi` 及中英文分词逻辑)。
* *保留在外面的人话*:“为了防止大模型胡编乱造价格,我设计了三层确定性检索器,且在命中付费课程时会自动关联其免费试用版(把原本生硬的‘没有’变成了自动推销)。”
* *折叠按钮文案*:`🔍 展开查看:三层 SKU 检索算法与防幻觉 Prompt 注入细节 (For Engineers)`
* **折叠点 2**:`## The bugs that ate an afternoon each` 中的 3 个具体排障过程。
* *保留在外面的人话*:用 3 行 Bullet points 总结教训(1. Telegram 超时导致的死循环重发;2. LiteLLM 别名强校验;3. WhatsApp 插件空模板发送字面量 `m_text` 的坑)。
* *折叠按钮文案*:`🛠️ 展开查看:Webhook 50s 超时阻断配置与 WhatsApp 载荷契约排障实录`
### 🔪 针对样本 2(《Docker Exit 0 & OOM Killer》)的重构指令
:
> **注意**:样本 2 是一篇写给 Tech Lead 和同行看的顶级 `devops` 硬核排障文。对于这类文章,**千万不要过度折叠核心证据**,因为 `dmesg` 日志和表格正是吸引技术面试官的高光时刻!
>
>
1. **前 20% 视觉冲击力升级**:
* 将 `## Docker's report says nothing happened` 和 `## The kernel keeps its own record` 的两段代码块,在桌面端改造为 **左右并排对比卡片(Side-by-Side Diff View)**:
* 左侧卡片(标红 ❌ **What Docker Reported**):`ExitCode: 0`, `OOMKilled: false`
* 右侧卡片(标绿 ✅ **What Kernel dmesg Recorded at the Exact Second**):`Killed process 615 (traefik) ... anon-rss:478480kB`
* 这种视觉对比会让任何懂行的运维/架构师一眼看出文章的含金量。
2. **应该收进折叠区的内容**:
* 在 `## The fix, and the sentence that has to come with it` 下方,你列出了一长串 Verification 校验细节(挂载列表核对、插件目录检查、路由 200 状态恢复等)。这部分属于严谨但偏冗长的运维 Checklist,适合放入折叠框:
* *折叠按钮文案*:`📋 展开查看:生产容器热迁移与 Cgroup 内存上限生效完整验证清单 (5-Step Verification)`
---
## 示范三:高转化动态 CTA 矩阵实战文案(`<DynamicCTA/>`)
扔掉原本底部千篇一律的 `Want a bot like this for your business?`。我们在 `src/components/DynamicCTA.astro` 中根据文章的 `category` 和 `tags` 自动渲染直击痛点的转化钩子,并且**同时兼顾“老板外包询盘”与“雇主/猎头招聘”双目标**:
### 1. 针对样本 1(`category: case-studies` / `ai`)的专属渲染效果:
> ### 🚀 想要一个 24/7 自动接单、发试用、不漏单的 AI 销冠?
>
>
> 别让夜间和周末的客户询盘因为“没人回”而流失给竞争对手。我帮中小企业从零搭建 **Telegram / WhatsApp 智能客服与业务自动化流水线**(连接你的商品库、CRM 与自动发货系统,无高昂 SaaS 按人头月费)。
> * 💬 **企业主定制咨询**:**[点击通过 WhatsApp 发我你的业务场景 (+60 12-797 2969)](https://www.google.com/search?q=https://wa.me/60127972969%3Ftext%3DHi%2520Hoelee,%2520I%2520read%2520your%2520DigiKedai%2520AI%2520Bot%2520case%2520study%2520and%2520want%2520to%2520discuss%2520automation%2520for%2520my%2520business.)** —— 免费为你做 15 分钟自动化可行性与成本评估。
> * 👔 **团队招聘 / 技术合作**:正在寻找懂状态机架构、能把 LLM 真正落地到生产环境的工程师?**[查看我的工程能力全景与简历 (/about)](https://www.google.com/search?q=/about/)** 或发邮件至 [[email protected]](https://www.google.com/search?q=mailto%3Ame%40hoelee.com)。
>
>
### 2. 针对样本 2(`category: devops` / `engineering`)的专属渲染效果:
> ### 🛡️ 服务器莫名崩溃、云账单失控,或者缺一位能看懂内核日志的架构师?
>
>
> 真正的生产环境排障,靠的不是重启服务器,而是穿透表象抓出根因。我目前独自维护着 **跨 3 台物理/云主机的 162 个生产容器与高可用网络栈**。
>
>
> * 👨‍💻 **致招聘官与 Tech Leads**:如果你需要一位兼具 Full-Stack 开发能力与底层 Linux/Docker/网关排障功底的实战派工程师,**[点击查看我的自托管基础设施蓝图与履历 →](https://www.google.com/search?q=/about/)**
> * 🔧 **致创业团队与企业主**:需要搭建高安全性的自托管基础设施、企业邮局(SifuMail)或做一次彻底的服务器稳定性诊断?**[直接在 WhatsApp 上联系我 ↗](https://www.google.com/search?q=https://wa.me/60127972969%3Ftext%3DHi%2520Hoelee,%2520I%2520read%2520your%2520Docker%2520OOM%2520debugging%2520post%2520and%2520need%2520help%2520with%2520our%2520infrastructure.)**
>
>
---
## 示范四:3 大系统蓝图(Pillar Pages)的 Astro 路由与挂载规划
### 1. 路由决策:独立顶层路由 `/blueprints/` vs. 整合进 `/categories/`?
**明确结论:必须采用独立的顶层路由 `/blueprints/`(中文 `/zh/blueprints/`),绝对不要塞进 `/categories/`!**
**原因有二**:
1. **UX 与心智层级不同**:`/categories/devops` 是一个按发布时间倒序排列的“文章归档列表(Feed)”,读者点进去看到的是零散的树叶;而 `/blueprints/production-infrastructure` 是一份**精心编排的、带全局拓扑图的“系统白皮书/架构说明书”**,读者看到的是整棵大树的主干。
2. **SEO 权重漏斗(Topic Authority Hub)**:在 Google 眼里,分页的 Category 列表页属于低信息密度的导航页(Thin Hub),极难获得高排名。而一个拥有 2500 字架构综述 + 交互式拓扑图 + 20 个精准内链指向子文章的独立 Pillar Page,是典型的 **Evergreen Cornerstone Content(常青基石内容)**。
### 2. 具体目录结构与导航改造方案
#### 第一步:在 `src/pages/` 下新增蓝图路由
```text
src/pages/
├── blueprints/
│ ├── index.astro # 3 大系统蓝图总览页(替代或升级原本单薄的分类导航)
│ ├── self-hosted-infrastructure.astro # 蓝图 1:162 容器生产级自托管基础设施全景
│ ├── ai-agent-automation.astro # 蓝图 2:确定性状态机 + 本地/网关 LLM 业务自动化架构
│ └── sme-digital-suite.astro # 蓝图 3:中小企业建站、SifuMail 邮局与数字资产交付体系
└── zh/blueprints/ # 镜像中文版蓝图路由
```
#### 第二步:顶部导航栏(`BaseLayout.astro`)高价值升级
目前你的导航栏是:`Posts` | `Categories` | `About` | `RSS`。这太像普通个人博客了。建议立刻改为:
* 英文:`Blueprints (系统蓝图)` | `Case Studies (商业案例)` | `All Posts (全部文章)` | `Work With Me (合作/雇佣)`
* (注:把低频点击的 `RSS` 移到页脚 Footer 即可,把寸土寸金的顶部导航留给最高转化的入口!)
#### 第三步:单篇蓝图页(如 `/blueprints/self-hosted-infrastructure`)的黄金版面结构
当你让 Hermes Agent 生成这 3 个页面时,遵循以下 4 段式模板:
1. **首屏全局拓扑图**:直接嵌入你在首页用到的架构全景图(可点击放大或分模块高亮),列出硬核指标(3 Hosts: Unraid + Synology + VPS, 162 Containers, 78 Compose Stacks)。
2. **核心架构设计哲学(300~500 字)**:例如讲清楚为什么你用 Cloudflare Tunnel + Traefik + Authentik 做双层零信任入口,为什么备份采用 3-2-1 异地自愈策略。
3. **按架构层级挂载你的 91 篇实战文章(Cluster Matrix)**:
* 不要按时间排!按架构层级排:
* **Layer 1: 边缘网关与安全认证 (Ingress & Auth)** ➔ 挂载《The Forward-Auth Gate That Verified Perfectly...》《Docker Reported Exit 0...》
* **Layer 2: 持续集成与静态交付 (CI/CD & Hosting)** ➔ 挂载《How I Host This Blog: Astro, Gitea Actions...》
* **Layer 3: AI 算力与记忆存储层 (Local GPU & Memory Stack)** ➔ 挂载《How I Self-Hosted a Mem0 Memory Stack...》
4. **双向内链闭环(极为关键)**:
* 在每一篇子文章的正文开头(或 `<ExecutiveSummary/>` 底部),自动加上一行面包屑提示:
> *🗺️ 本文属于 **[《162 容器生产级自托管基础设施蓝图》](https://www.google.com/search?q=/blueprints/self-hosted-infrastructure)** 的【边缘网关层】实战记录。*
* 这样一来,任何通过长尾搜索(比如搜 `Docker ExitCode 0 OOMKilled false`)进来的技术主管,看完单篇排障后,都会顺着这根藤摸到你的全局蓝图页——**一旦他点进蓝图页,你作为高级全栈/DevOps 工程师的专业形象就彻底立住了。**
---
### 下一步执行建议
你可以直接把这份回复保存为 `02-顾问落地方案与组件规范.md` 喂给你的 Hermes Agent:
1. 让它先执行 **Day-1 紧急修复**(清查 `homelab` 字眼、协助你在 GSC 提交 `sitemap-index.xml`、在主站 WordPress 加导航外链)。
2. 让它根据上方代码创建 `ExecutiveSummary.astro` 和 `DynamicCTA.astro` 两个组件,并率先在 **4 篇首页置顶的 Featured 文章** 上完成 Frontmatter 数据补充与上线测试。
如果需要在改造 `/about`(升级为 `/work-with-me` 合作与简历二合一页)时帮你设计针对招聘官和企业主的双栏文案,随时把现有的 `about.astro` 源码发给我!
@@ -0,0 +1,658 @@
# blog.hoelee.com 内容体系与转化漏斗升级 — 顾问补充资料包
> **对接目标**:针对外部顾问提出的 **6 大升级方向**(双层阅读架构、3 大系统蓝图、视觉证据链、动态 CTA 矩阵、去模板化生成规范、独立求职与合作页面)及 **搜索引擎收录盲区**,提供现场工程代码、典型文章抽样、两篇代表作完整原文及站点关系诊断。
> **编制时间**:2026 年 10 月 26 日
> **整理人**:Lee Teong Hoe (Mr Hoelee) / Hermes Agent
> **站点技术栈**:Astro 5 (Static) + Content Collections + Markdown + Pagefind + Gitea Actions CI/CD + Cloudflare
---
## 目录
1. [全站结构与核心导航/布局源码](#一全站结构与核心导航布局源码)
2. [15 篇代表性文章清单抽样 (Titles & Frontmatter)](#二15-篇代表性文章清单抽样)
3. [代表作完整 Markdown 原文(商业案例篇 + 硬核排障篇)](#三代表作完整-markdown-原文)
4. [主站与博客的关系及 SEO / GSC 真实状态诊断](#四主站与博客的关系及-seo--gsc-真实状态诊断)
5. [期待顾问重点开刀示范的任务清单](#五期待顾问重点开刀示范的任务清单)
---
## 一、全站结构与核心导航/布局源码
### 1. Astro 项目核心目录拓扑
```text
D:/dev/hoelee-blog/
├── src/
│ ├── layouts/
│ │ └── BaseLayout.astro # 全站唯一基础布局:SEO/JSON-LD/页头Nav/页脚/Umami统计/暗黑模式
│ ├── pages/
│ │ ├── index.astro # 英文首页(Hero + 置顶精选 4 篇 + 基础设施数据 + 全文列表)
│ │ ├── zh/
│ │ │ ├── index.astro # 中文首页(与英文版结构镜像)
│ │ │ ├── about.astro # 中文关于页
│ │ │ ├── posts/index.astro # 中文文章归档页(带客户端过滤)
│ │ │ ├── categories/ # 中文分类聚合页与各自分类落地页
│ │ │ └── tags/[tag].astro # 中文标签聚合页
│ │ ├── posts/
│ │ │ ├── [...slug].astro # 英文文章详情动态路由(双语自动匹配映射、TOC、相关文章推荐)
│ │ │ └── index.astro # 英文文章归档页
│ │ ├── categories/ # 英文分类聚合与 [category].astro
│ │ ├── tags/[tag].astro # 英文标签聚合页
│ │ ├── about.astro # 英文关于页
│ │ ├── 404.astro # 404 页面
│ │ ├── robots.txt.ts # 动态生成 robots.txt(纯 ASCII,放行搜索引擎与 AI 爬虫)
│ │ ├── rss.xml.ts # RSS 端点
│ │ └── llms.txt.ts # 面向 AI 抓取的标准 llms.txt
│ ├── components/
│ │ ├── AuthorCard.astro # 文章底部实名作者卡片(E-E-A-T 骨架,绑定头像与社媒)
│ │ ├── TableOfContents.astro # 侧边/正文目录组件
│ │ ├── Search.astro # Pagefind 静态全文搜索弹窗(快捷键 / 触发)
│ │ ├── PostList.astro # 文章列表渲染卡片(带缩略图、分类、阅读时长)
│ │ ├── Archive.astro # 归档时间轴组件
│ │ ├── CategoryGrid.astro # 7 大分类卡片网格
│ │ └── CategoryArt.astro # 7 大分类专属矢量 SVG 插画
│ ├── content/
│ │ └── posts/ # 91 篇英文 Markdown
│ │ └── zh/ # 91 篇同名对应的中文 Markdown
│ └── styles/
│ └── global.css # 全站暗色/亮色 Bento 主题变量与排版样式
```
---
### 2. 顶部导航栏(Navbar)设计源码(摘自 `src/layouts/BaseLayout.astro`)
```astro
<header class="site-header">
<nav class="nav" aria-label="Main">
<!-- 品牌区:自托管 96px 图标 + 实名 Lee Teong Hoe -->
<a class="brand" href={isZh ? '/zh/' : '/'}>
<img class="brand-mark" src="/images/hoelee-mark-96.png" alt="" width="26" height="26" decoding="async" />
<span class="brand-name">{SITE.author}</span>
</a>
<!-- 导航链接:移动端自动换至第二行 (flex-basis: 100%) 防止挤压功能键 -->
<ul class="nav-links">
<li><a class="nav-link" href={isZh ? '/zh/posts/' : '/posts/'}>{nav.posts}</a></li>
<li><a class="nav-link" href={isZh ? '/zh/categories/' : '/categories/'}>{nav.categories}</a></li>
<li><a class="nav-link" href={isZh ? '/zh/about/' : '/about/'}>{nav.about}</a></li>
<li><a class="nav-link" href="/rss.xml" title={nav.rss}>{nav.rss}</a></li>
</ul>
<!-- 工具栏:Pagefind 全文搜索 + 中英同文一键双向切换 + 暗/亮模式切换 -->
<button class="search-toggle" id="search-toggle" aria-label={isZh ? '搜索' : 'Search'}>
⌕ <span class="search-hint">{isZh ? '搜索' : 'Search'}</span><kbd class="search-key">/</kbd>
</button>
<a class="lang-switch" href={resolvedSwitchHref}>{switchLabel}</a>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">◐</button>
</nav>
</header>
```
---
### 3. 首页(`src/pages/index.astro`)核心布局结构
```astro
<BaseLayout title={SITE.title} description={SITE.description} altLocaleUrl={`${SITE.url}/zh/`}>
<section class="hero">
<div class="hero-intro">
<div class="hero-id">
<picture>
<source srcset={SITE.photo.smallWebp} type="image/webp" />
<img class="hero-photo" src={SITE.photo.small} alt="Lee Teong Hoe" width="72" height="72" />
</picture>
<h1>Lee Teong Hoe — full-stack developer &amp; DevOps engineer</h1>
</div>
<p class="hero-sub">
I build and operate production web systems — from the code down to the servers.
<strong>162 containers across three self-managed hosts</strong> run client sites, a mail
platform, CI/CD, workflow automation and this blog. I write up what broke, what the
evidence said, and how I fixed it.
</p>
<div class="hero-pills" aria-label="Core tech stack">
<span class="hero-pills-label">Stack:</span>
<a class="tech-pill" href="/tags/docker/">Docker</a>
<a class="tech-pill" href="/tags/self-hosting/">Self-Hosting</a>
<a class="tech-pill" href="/tags/n8n/">n8n</a>
<a class="tech-pill" href="/tags/ai/">Local GPU AI</a>
<a class="tech-pill" href="/tags/python/">Python</a>
<a class="tech-pill" href="/tags/php/">PHP</a>
<a class="tech-pill" href="/tags/security/">Security</a>
</div>
</div>
<!-- 基础设施概览卡片与可缩放架构图 -->
<div class="hero-visual">
<button type="button" class="hero-banner-card" onclick="document.getElementById('arch-lightbox')?.showModal()">
<img class="hero-banner-img" src="/images/hero-banner.jpg" alt="Architecture Overview" />
<span class="hero-banner-zoom-badge">🔍 View full architecture</span>
</button>
<div class="hero-infra-grid" aria-label="Infrastructure stats">
<div class="infra-stat-card"><div class="infra-stat-val">162</div><div class="infra-stat-label">Containers in Prod</div></div>
<div class="infra-stat-card"><div class="infra-stat-val">3 Hosts</div><div class="infra-stat-label">Unraid · Synology · VPS</div></div>
<div class="infra-stat-card"><div class="infra-stat-val">78</div><div class="infra-stat-label">Compose Stacks</div></div>
<div class="infra-stat-card"><div class="infra-stat-val">91</div><div class="infra-stat-label">Bilingual Write-ups</div></div>
</div>
</div>
<!-- 当前三大主行动按钮 -->
<div class="hero-actions">
<div class="hero-cta">
<a class="btn btn-primary" href="#featured">Read the engineering case studies ↓</a>
<a class="btn" href="/about/">Hiring? Profile &amp; stack →</a>
<a class="btn" href={SITE.website} target="_blank" rel="noopener">Malaysian SME? How I work ↗</a>
</div>
<p class="hero-note">91 posts in English and 中文 · <a href="/rss.xml">RSS</a></p>
</div>
</section>
<!-- 4 篇置顶旗舰案例(附带量化度量) -->
<section class="featured-section" id="featured">
<!-- Featured Cards Grid -->
</section>
<!-- 全量文章时间线与分类列表 -->
</BaseLayout>
```
---
### 4. 文章详情页(`src/pages/posts/[...slug].astro`)核心布局结构
```astro
<BaseLayout title={`${post.data.title} — Mr Hoelee`} ...>
<!-- 自动注入 JSON-LD BlogPosting 结构化数据,挂载至站长 Person 实体 -->
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<article>
<header class="article-head">
<h1>{post.data.title}</h1>
<div class="meta">
<time>{fmt(post.data.pubDate)}</time> · <span>{minutes} min read</span> ·
<a class="tag" href="...">{post.data.category}</a> {post.data.tags.map(...)}
</div>
</header>
<!-- 16:9 响应式 WebP 横幅 -->
{post.data.banner && <figure class="article-banner">...</figure>}
<!-- 自动目录 (TOC) -->
<TableOfContents headings={headings} lang={lang} />
<!-- 正文渲染区 (当前直接渲染完整 Markdown) -->
<Content />
<!-- 实名作者卡片(E-E-A-T 骨架) -->
<AuthorCard />
</article>
<!-- 底部 6 卡片相关推荐网格(同语种、高相关性标签智能加权算法) -->
<section class="related">...</section>
</BaseLayout>
```
---
## 二、15 篇代表性文章清单抽样
覆盖 6 大核心主干,展示真实定位与元数据约束:
| # | 分类 (`category`) | 标题 (`title`) | 标签 (`tags`) | 摘要 (`description`) |
|---|---|---|---|---|
| **1** | `case-studies` | **How I Built the DigiKedai Telegram AI Bot** | `[telegram, docker, cloudflare, litellm, typescript, ai]` | A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway. |
| **2** | `case-studies` | **How I Host This Blog: Astro, Gitea Actions, and Self-Hosted CI/CD** | `[astro, gitea, ci-cd, self-hosting, docker, cloudflare]` | A walkthrough of the end-to-end pipeline that builds and serves this site — git-as-CMS, a self-hosted runner on my unRaid server, and Cloudflare in front. |
| **3** | `case-studies` | **Self-Healing Access Control for Digital Goods: NocoDB, n8n, and AList** | `[n8n, nocodb, alist, access-control, docker, automation, digital-goods]` | How I built a five-workflow entitlement system that grants, expires, and continuously repairs customer file access — with a build-time code-sharing trick and a public endpoint designed to leak nothing. |
| **4** | `case-studies` | **How I Built an Automated Music Video Pipeline with Remotion and Dockerized YouTube CDP** | `[remotion, docker, cdp, youtube, automation, video-generation, whisper]` | Explore an automated music video workflow using Remotion, Whisper, Docker and Chrome DevTools Protocol, from lyric alignment and rendering to YouTube publishing and multilingual subtitles. |
| **5** | `case-studies` | **How I Self-Hosted a Mem0 Memory Stack (So My AI Agent Remembers)** | `[mem0, docker, postgres, pgvector, litellm, ai, sharing]` | A private, self-hosted memory layer for AI agents — mem0 API + LiteLLM + PostgreSQL with pgvector on a Synology NAS. Semantic memory under your control, not a third party's. |
| **6** | `devops` | **Docker Reported Exit 0. The Kernel Had Already OOM-Killed It.** | `[docker, oom, traefik, synology, crawlers]` | A container on my NAS kept stopping unexpectedly while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills. |
| **7** | `devops` | **The Forward-Auth Gate That Verified Perfectly — and Wasn't Live** | `[authentik, forward-auth, cloudflare, nginx, docker, self-hosting]` | My authentik forward-auth gate returned a clean 302 to the login page from the host itself, while the public URL still served the app. The hostname had two ingress layers, and I had only changed one. |
| **8** | `devops` | **Why Your CDP Clicks Silently Fail: Smooth Scroll, Minimised Windows, and JS-Dispatched Events** | `[cdp, chrome, react, browser-automation, debugging, python]` | Three reasons a Chrome DevTools Protocol click reports success and does nothing: a stale rect from smooth scrolling, a minimised window that drops coordinate input, and a page that received the click but rejected it. |
| **9** | `ai` | **Giving AI Your Mouse and Keyboard: Safe Guardrails for Windows Desktop MCP** | `[windows, mcp, security, ai-agents, automation]` | Why running desktop automation MCPs out of the box is dangerous, and the essential guardrails needed to prevent system corruption, UI hangs, and token blowups. |
| **10** | `ai` | **I Let an Agent Manage My Shopee Vouchers — And It Created One I Never Approved** | `[ai-agent, shopee, automation, human-in-the-loop, ecommerce, cron]` | Vouchers have no draft state: Confirm means live and drawing on escrow. Here is the audit → propose → auto design I use to keep an agent away from that button, and the list-lag mistake that cost me an unplanned voucher. |
| **11** | `engineering` | **Debugging Remotion Video Jitter: Why Bass Scaling Ruins Background Transitions** | `[remotion, react, audio-visualizer, css-animation, debugging]` | Diagnose audio-reactive background jitter and crossfade flicker in Remotion. See how separating the HUD from background layers can improve visual stability and rendering performance. |
| **12** | `engineering` | **No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App** | `[i18n, nginx, vue, javascript, self-hosting]` | Browsers expose no JavaScript API to trigger translation — and my app declared English while being Chinese. How I injected an English mode at the reverse proxy. |
| **13** | `tutorials` | **Calibrating Intel Quick Sync HEVC: A Cliff at 28, and a Preset That Does Nothing** | `[ffmpeg, hevc, qsv, transcoding, video]` | I measured QSV HEVC on real 1080p files: ICQ 18–22 inflate thin sources, the quality cliff sits between 28 and 30, and fast equals medium byte for byte. |
| **14** | `tutorials` | **How to Verify a Hosting Provider Before You Buy** | `[hosting, vps, due-diligence, whois, devops]` | A reusable checklist for separating real hosting claims from marketing: check the domain age against the 'since' claim, read the AUP for silent disqualifiers, and cross-check reputation on third-party sources. |
| **15** | `web3` | **An Automated Lottery Contract on Chainlink VRF v2.5 and Automation** | `[solidity, chainlink, vrf, foundry, hardhat, blockchain]` | Building a lottery on Chainlink VRF v2.5 and Automation: why the draw can't be rigged, a full contract walkthrough, and the mock-funding bug that broke it. |
---
## 三、代表作完整 Markdown 原文
### 样本 1(商业交付与 AI 智能体):`how-i-built-the-digikedai-telegram-bot.md`
```markdown
---
title: "How I Built the DigiKedai Telegram AI Bot"
description: "A customer-support AI bot that answers questions and provisions free-trial accounts over Telegram and WhatsApp — TypeScript, grammY, a Cloudflare tunnel webhook, and a self-hosted LiteLLM gateway."
pubDate: 2026-09-06
category: case-studies
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/how-i-built-the-digikedai-telegram-bot.jpg
banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg
---
Digi Kedai sells digital products — online courses, ebooks, and templates — and
every sale involves a stream of the same customer questions: *"does this course
have a free preview?", "how do I get my account?", "which package should I
buy?"*. Answering them manually doesn't scale for a one-person business. So I
built an AI customer-support bot that answers those questions around the clock
and can even hand out free-trial accounts without a human in the loop.
This is the story of how it went together — the architecture, the LLM wiring,
and the bugs that ate an afternoon each.
## Why a business needs a bot like this
Before the architecture, the *why*. A support bot isn't a gimmick — it changes
the economics of a small business:
- **Saves money on staff.** Every routine question a bot answers is one your
team doesn't have to. Digi Kedai gets the equivalent of a round-the-clock
support agent for a fraction of the cost of hiring one.
- **Answers instantly, 24/7.** Customers ask at midnight and on weekends. A bot
replies in seconds, in their language — no queues, no "we'll get back to you".
- **Converts browsers into buyers.** The bot doesn't just answer — it *upsells*.
A "does this have a free trial?" question turns into a claimed trial account
in a couple of taps, with no human in the loop.
- **Never forgets a customer.** Long-term memory means repeat customers are
greeted like regulars, not strangers.
- **Scales with your catalogue.** Add a product and the bot already knows it —
no retraining, no new FAQ pages.
For a one-person business like Digi Kedai, that's the difference between losing
sales at 2 a.m. and closing them.
## The stack
- **TypeScript + Node 20** running as a single container on my Synology NAS.
- **grammY** — a lightweight Telegram Bot framework, wired in webhook mode.
- **Hono** — a tiny HTTP server that receives webhooks and answers `/health`.
- **Cloudflare tunnel** — `bot.digikedai.com` routes straight into the container;
no public IP, no open ports.
- **LiteLLM** — a self-hosted gateway in front of the actual model, so I can swap
providers or add fallbacks without touching bot code.
- **PostgreSQL** — conversation and user-memory storage, shared with my mem0 stack.
## The architecture
```
Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
→ Hono POST /<secret>/webhook → grammY handler
→ save to PostgreSQL → Agent.respond (system prompt + retrieved catalog facts + last 20 msgs)
→ LiteLLM (model alias "mem0-openai") → persist + reply
```
A few decisions worth explaining:
**A Cloudflare tunnel into the container, with no Traefik hop.** My homelab is
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
updates only land if the caller knows the secret — a cheap first line of defense
on top of Telegram's own token auth.
**The model is addressed by a LiteLLM *alias*, never a raw model name.** The bot
calls `mem0-openai`; LiteLLM maps that to the real model (with an OpenRouter
fallback behind it). The bot never needs to know which provider is actually
serving the request. I set `temperature: 0.4` and a `45s` timeout.
**The bot knows its catalogue without prices or internal paths.** A build-time
generator turns a single `catalog_sku.csv` (the source of truth) into a
TypeScript module the bot imports — SKU, name, category, size, and a product URL,
and *nothing else*. Prices are never baked in ("check the site for the current
price"), and internal resource paths never ship to the bot image. That keeps the
bundle lean and prevents the model from leaking internal structure.
## Retrieval: how the bot actually *knows* the catalogue
A generic LLM with the catalogue stuffed into a prompt would hallucinate. So the
bot retrieves first, then answers. It has a three-tier retriever:
1. **SKU token match** — `/\b[A-Z]{2,}\d{2,}\b/gi` catches exact SKUs like `CZH01`.
2. **Whole-query substring** — for short, precise queries.
3. **Per-segment matching** — splits CJK runs (stripping question particles like
有/吗/哪些) and filters English stopwords.
The top-5 matches become the "facts" injected into the system prompt, and the
model is instructed to answer *only* from what it actually retrieved — and to say
so when it finds nothing.
This matters more than it looks. A customer asking *"does CZH01 have a free
version?"* gets a correct answer about `FREECZH01` only because the retriever,
on hitting the paid SKU, **automatically attaches its free twin** and ranks it
second. That one detail turns a "no, sorry" into the correct upsell.
## Free-trial provisioning — no human in the loop
The part I'm proudest of: the bot doesn't just *talk about* free trials, it
*issues* them. A customer can trigger it three ways — the `/trial` command, a
natural-language message like *"我要这个试用:digikedai.com/products/czh01"*, or a
deep link like `?start=CZH01`.
Rather than route through n8n, the bot writes **directly to NocoDB** — inserting
a customer row and a customer-product row. My existing webhooks pick that up and
provision the actual account automatically, so the bot never touches the file
server. The whole thing is idempotent: if a customer already has an account, it
reuses it instead of creating a duplicate; if the product insert fails, it rolls
back the customer row so a retry doesn't dead-end.
There's even a multi-account selector. If the same Telegram user has several
accounts, the bot lists them as inline buttons and asks which one to attach the
trial to.
## The bugs that ate an afternoon each
Shipping this was not smooth. Three debugging stories stand out:
**1. The infinite reply loop.** Telegram re-delivers a webhook update if the
server doesn't acknowledge it within a timeout window. My handler was running
long (LLM latency), so Telegram re-sent the same message — and the bot answered
it again, and again. The fix was `onTimeout: "return"` with a 50-second window,
which stops the redelivery spiral dead.
**2. The model alias 400.** Calling the raw model name (`gpt-5-mini`) returned a
400. Only the LiteLLM alias worked. This is now a hard rule in the repo: *the LLM
must be the alias, never the raw upstream name.*
**3. The WhatsApp "m_text" bug.** When I added a WhatsApp channel (via a browser
extension that POSTs WhatsApp Web events to a webhook), a "customize webhook
payload" setting with empty-string template values made the extension send the
literal field *name* `m_text` as the message text — so the bot replied *"I don't
understand m_text"*. The fix was to turn that setting off and use the default
payload, which the adapter reads correctly.
The honest takeaway from all three: the failures weren't in the hard parts — the
LLM or the retrieval. They were in **webhook lifecycle and payload-contract
details**, the boring edges where integrations actually break.
## Deterministic flows on top of the LLM
An LLM is great for open-ended questions and terrible for *state*. So the
conversational pieces that need reliability — buying, trial redemption, deep
links — are **deterministic state machines**, not prompt engineering:
- **Buy intent** (`order`, `want to buy`, `buy`…) is intercepted before the LLM
and drives an inline keyboard (online store → admin → back), never a free-form
reply.
- **Deep links** carry the SKU in a `?start=` payload, but Telegram only allows
`A-Z a-z 0-9 _ -` there — a `:` silently breaks it. I learned that the hard way
and switched the separator from `buy:SKU` to `buy-SKU`.
- **Off-topic messages** in WhatsApp make the model emit a sentinel (`NO_REPLY`)
that the agent turns into *"send nothing"* — so the bot stays silent instead of
babbling when it has nothing useful to say.
## What I'd do differently
- **Add `setMyCommands()` at startup.** The command list is defined in code, but
I never registered it with Telegram, so the in-chat command menu was empty
until I fixed it via the Bot API. Small, but it cost a real customer touchpoint.
- **Design the payload contract before the channel adapter.** The WhatsApp
`m_text` bug came from trusting a customization feature I hadn't read the
contract for.
- **Treat webhook timeouts as first-class architecture**, not an afterthought —
the redelivery loop was avoidable if I'd thought about acknowledgement windows
on day one.
## The result
One container on the NAS now answers customer questions across two channels
(Telegram + WhatsApp), retrieves the correct catalogue entry by SKU or by natural
language, and issues free-trial accounts end-to-end — with a prompt suite of
~175 unit tests covering the deterministic flows, retrieval, and the payload
contracts. The source lives at
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot).
If you're building your own Telegram bot backed by an LLM, the lesson is the
boring one: the model is the easy part. The webhook lifecycle, the payload
contract, and the idempotency of your provisioning are where it actually breaks —
design those first.
---
## Want a bot like this for your business?
I build custom Telegram/WhatsApp AI bots, websites, and self-hosted
infrastructure for businesses. If a bot like this could save you time and
money — or you'd like to hire me — I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected])
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
```
---
### 样本 2(硬核 DevOps 排障):`docker-reported-exit-0-the-oom-killer-disagreed.md`
```markdown
---
title: "Docker Reported Exit 0. The Kernel Had Already OOM-Killed It."
description: "A container on my NAS kept stopping unexpectedly while Docker reported exit 0 and OOMKilled=false. The honest evidence was in dmesg: six cgroup OOM kills."
pubDate: 2026-10-16
category: devops
tags: ["docker", "oom", "traefik", "synology", "crawlers"]
ogImage: /og/docker-reported-exit-0-the-oom-killer-disagreed.jpg
banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.jpg
draft: false
---
## The notification I expected to be a config mistake
My NAS sent the notification every Docker user learns to skim: **container
stopped unexpectedly**. This one is the edge proxy in front of my
self-hosted wiki, and it had been up for two weeks. Nobody had deployed
anything that morning and nothing in the compose file had been touched, so
the two available stories were "it crashed" and "something restarted it".
Both were wrong, and the fact that I could not tell from Docker's own report
is the part worth writing down.
## Docker's report says nothing happened
```text
State:
Status: running
Running: true
ExitCode: 0 <- a clean exit
OOMKilled: false <- not killed by the kernel
FinishedAt: 2026-10-15T19:39:33.63057879Z
StartedAt: 2026-10-15T19:39:38.769326672Z <- restarted 5 s later
RestartCount: 6
RestartPolicy: always
```
Read that as written and the conclusion is: the process exited cleanly, six
times, and the restart policy gave it a new life each time. A long-running
proxy does not exit with status 0 on its own — and a clean exit that repeats
six times is a contradiction, not an answer. `ExitCode` and `OOMKilled` are
exactly the two fields I would have trusted here. On this host, both were
wrong.
## The kernel keeps its own record
On Linux, a cgroup memory kill is logged by the kernel even when the
container runtime's bookkeeping does not carry it forward:
```text
[1737797.044856] traefik invoked oom-killer: gfp_mask=0x2400040, order=0, oom_score_adj=0
[1737797.123736] Memory cgroup out of memory: Kill process 615 (traefik) score 970 or sacrifice child
[1737797.132800] Killed process 615 (traefik) total-vm:2525800kB, anon-rss:478480kB, file-rss:0kB
```
The uptime stamp `1737797` corresponds to 19:39:33 UTC — to the second, the
container's `FinishedAt`. That is the whole diagnosis, and it took one
`dmesg` to get it. The container did not exit; it was killed inside its own
memory limit and restarted 5 seconds later.
dmesg holds **six** such events, and `RestartCount` was **6**. Every other
container in that stack has a restart count of zero and a start time from
weeks ago, so the six kills pin to this one container:
| dmesg uptime | local time | anon-rss at kill |
|---|---|---|
| 1406539 | Oct 12, ~07:37 | 491 MB |
| 1407759 | Oct 12, ~07:57 | 494 MB |
| 1432574 | Oct 12, ~14:49 | 486 MB |
| 1734793 | Oct 16, ~02:45 | 487 MB |
| 1735495 | Oct 16, ~02:58 | 485 MB |
| 1737797 | Oct 16, 03:39 ← the notification | 478 MB |
Every kill happens at 478–494 MB against a limit of 512 MiB (`536,870,912`
bytes). That is a ceiling being touched, not a runaway.
Two more readings made the mechanism clear. The cgroup's recorded peak — the
counter that survives the restart — was already back at exactly
`536,870,912` **18 minutes** after the restart, i.e. the container touched
its ceiling again in the same cycle *without* being killed. And the usage
curve oscillated (176 → 308 → 361 MB) instead of climbing monotonically, so
this is allocation churn under load hitting a small cap, not a provable slow
leak. The prime suspect is a geoblock plugin doing an external HTTPS lookup
for every client IP it has not seen before.
A seventh kill landed at 05:33 that morning, before the fix went in.
On this DSM build the truth was only in `dmesg` plus the cgroup's
`memory.max_usage_in_bytes`. If you are chasing the same shape on a normal
host, `docker inspect -f '{{.State.OOMKilled}}'` is the field to read first
— it is supposed to be authoritative. Here it read `false` five seconds
after the kernel had logged the kill, which is the reason this post exists.
## What was actually breaking: a crawler flood
Host memory was never the problem — 64 GB installed, ~39 GB available. One
container's 512 MiB ceiling was.
The driver was traffic, and the numbers came out of the container's own
access log. In the last 50,000 lines:
```text
distinct client IPs 25,976
403 responses 33,636 (geoblock / deny-list doing its job)
429 responses 16,082 (inflight and rate limiting doing their job)
container CPU 118–230%
host load average 26–32
```
Each restart refilled the heap in 12 to 40 minutes. Nothing in the
configuration was wrong, and neither were the 403s and 429s — they are the
filter rejecting crawlers exactly as designed. The problem is who is
knocking: this container is the public edge for a multi-terabyte Wikipedia
corpus served from a home NAS, behind a country allowlist and a bot filter.
That is an extremely attractive target for a residential-proxy crawler
cloud, and the responses telling it to go away are still work.
## The fix, and the sentence that has to come with it
```yaml
deploy:
resources:
limits:
- memory: 512M
+ memory: 2g
restart: always
+memswap_limit: 2g
```
Verification, because "I raised the limit and it feels better" is not a
verification:
- **Only that container was recreated.** The wiki, the proxy core and the
robots container still reported two weeks of uptime — the gap users saw
was seconds, not a stack restart.
- `HostConfig.Memory = 2147483648`, `MemorySwap = 2147483648`, and the
cgroup's `memory.limit_in_bytes` agreed with both; `RestartCount` went
back to 0.
- Peak usage after 18 minutes: **210 MB**, against a ceiling it had been
pinning at 512 MB. By midday it sat at 94 MiB.
- The mount list still matched the file, including the plugin directory
mount — losing that one had once disabled every router in this stack.
- Both plugins were loaded and active (country allowlist, bot filter), and
the routes returned 200 once the filter's score window expired.
**And what it is not:** this is a stopgap. The flood is the driver, so an
order-of-magnitude bigger flood reaches 2 GB too. The real fix is shrinking
the public entry surface, not enlarging the cage around it — and a limit
bump that is not labelled as a stopgap becomes a permanent explanation.
I chose to disable swap for this container rather than let it swap:
`memswap_limit` equal to the memory limit does exactly that. The host
already had 16 GB of its 40 GB swap in use with committed memory at 92 GB,
and an edge proxy that starts swapping slows the entire NAS — the disk array
is the bottleneck here, so swapping buys a slower failure rather than a
longer life.
## The trap that nearly made me report a self-inflicted outage
During verification, my first bare `curl` probes against the site returned
**403**, and it looked exactly like a regression I had just caused by
restarting the proxy. It was not: the bot filter had scored **my own client
IP** (`blocked user-agent`, score 80) and placed it in a 15-minute score
window, because I had been hammering the host.
The tell was in the same test run — `/robots.txt` returned 200 and the
internal `/cdn` path returned 400 at the same moment, i.e. the router was
healthy and only my own requests were being filtered. On a host where you
are also a client, never diagnose with your own bare curl: probe a path the
filter ignores, or read a 200 out of the access log.
## What I would do differently
1. **Treat "stopped unexpectedly" plus `ExitCode 0` as an instruction to
read `dmesg`**, not as evidence of a clean exit. A restart count above
zero is a memory investigation until proven otherwise.
2. **Alert on the cgroup peak, not only on restarts.** The post-restart
reading that was already pinned at the ceiling told me a seventh kill was
coming about eighteen minutes before the notification arrived.
3. **Get the "who is hitting me" number early.** 25,976 distinct IPs in one
log sample is the entire story, and I found it after spending time on the
memory curve.
4. **Question the container, not the server.** "The server is out of memory"
and "one container has a 512 MiB ceiling on a 64 GB machine" need
completely different responses, and the second one is invisible unless
you look at the limit.
## The result
No OOM kill since the change, with a peak at 210 MB after 18 minutes against
a ceiling that used to be touched in every single cycle — and the seven
kills that had been silently restarting the public edge for four days
stopped appearing in dmesg. The crawler flood did not stop, which is why I
recorded the failure mode in my ops notes next to the stack: "container
stopped unexpectedly" plus `exit 0` / `OOMKilled=false` is a misleading
signature, and on this platform the honest evidence lives in the kernel log
and the cgroup's high-water mark.
```
---
## 四、主站与博客的关系及 SEO / GSC 真实状态诊断
### 1. 主站 `hoelee.com` 当前内容与业务定位
- **技术载体与形态**:主站是基于 WordPress(Kadence 主题)搭建的企业官网,主域名 `hoelee.com` 301 重定向至 `www.hoelee.com`。
- **定位与商业客群**:主打 **Hoelee Enterprise** 商业服务,标语为 *"Revamp Your Online Presence with Website Design & Marketing"*,面向马来西亚中小企业提供企业建站定制、网页设计、在线获客及数字化支持,头部及尾部直接挂载 WhatsApp(+6012-7972969)、直接致电和业务邮箱([email protected])。
- **两站断层痛点**:
- 博客页头与页脚虽有超链接指向主站 `hoelee.com`,但**主站 6 个主要页面上对博客 `blog.hoelee.com` 的导流入链数为 0**(形成了单向孤岛)。
- 主站历史数据库中遗留了约 400 个旧版媒体附件落地页(Thin attachment pages),此前已列入后续整改计划。
### 2. `blog.hoelee.com` 在 Google Search Console (GSC) 里的真实收录与流量数据
- **核实属实**:目前在 Google 自然搜索中几乎没有返回有效结果,流量处于绝对冷启动阶段。
- **深层原因剖析**:
1. **所有权凭据已具备**:主域名 `hoelee.com` 的 DNS 已经配置了 `google-site-verification` 的 TXT 记录,GSC 的域名级属性(Domain Property)在权限上已经覆盖了整个 `*.hoelee.com`。
2. **Sitemap 尚未被 GSC 索引爬取**:Astro 构建产物 `https://blog.hoelee.com/sitemap-index.xml`(内含 `sitemap-0.xml` 包含全部 182 个中英双语页面)与 `robots.txt` 均返回 HTTP 200 且格式严格校验无误;但此前团队精力聚焦在内容创作与音视频流水线构建上,**未在 GSC 控制台后台手动完成最终的提交确认与索引抓取触发**。
3. **外链绝对为 0(Cold Start 阻碍)**:这是一个全新上线的独立子域名,除了站内自己的代码仓库外,在外部公网平台(如主站、权威技术社区、LinkedIn、高权重社媒)**没有任何高质量反向链接(Backlinks)**。在没有外链引流权重的冷启动期,Google 爬虫分配的抓取预算极低。
4. **自建监控实况**:全站目前通过自建自托管的 **Umami 统计平台(`stats.hoelee.com`)** 进行全量数据监测(已配置防反代 IP 穿透与真实地理位置追踪),目前记录到的均为作者开发调试及直达访问。
---
## 五、期待顾问重点开刀示范的任务清单
请顾问结合上述代码与样本文章,现场示范以下落地规范与组件原型:
1. **改造示范 1:首屏速览卡片(`<ExecutiveSummary/>`)实例文案**
- 针对《How I Built the DigiKedai Telegram AI Bot》与《Docker Reported Exit 0...》,请直接输出两套不同受众侧重点的 Bento 速览卡片文案与字段内容。
2. **改造示范 2:正文排版重构与代码折叠(`<TechnicalDeepDive/>`)**
- 在上述两篇文章中,指出哪些具体的排障日志、配置代码应当收进折叠区?哪些关键架构与时序图应当提升到前 30% 展示?
3. **改造示范 3:高转化动态 CTA 矩阵实战文案**
- 替换样本 1 和样本 2 底部千篇一律的 `Want this for your business?`,针对 AI 自动化客户与 DevOps 稳定性客户,分别给出具有极强点击冲动的专属 CTA 钩子。
4. **架构落地指导:3 大系统蓝图(Pillar Pages)的最佳路由与挂载规划**
- 在 Astro 现有路由(`src/pages/`)下,这 3 大蓝图页建议作为独立的顶层路由(如 `/blueprints/infra`、`/blueprints/ai-agents`)还是整合进分类页?如何最大化提升整站 Topic Authority?
+189
View File
@@ -0,0 +1,189 @@
---
import { SITE } from '../config';
interface Props {
category: string;
tags?: string[];
lang?: 'en' | 'zh';
title?: string;
}
const { category, tags = [], lang = 'en', title = '' } = Astro.props;
const isAiOrBiz = category === 'case-studies' || category === 'ai' || tags.some(t => ['telegram', 'whatsapp', 'n8n', 'automation', 'ecommerce', 'ai-agent'].includes(t.toLowerCase()));
const isDevOpsOrEng = category === 'devops' || category === 'engineering' || tags.some(t => ['docker', 'traefik', 'oom', 'kernel', 'synology', 'linux', 'cgroups', 'monitoring', 'prometheus'].includes(t.toLowerCase()));
// Pre-filled WhatsApp message
const waBizUrl = `https://wa.me/60127972969?text=${encodeURIComponent(`Hi Hoelee, I read your post "${title}" and would like to discuss automation/development for my business.`)}`;
const waDevOpsUrl = `https://wa.me/60127972969?text=${encodeURIComponent(`Hi Hoelee, I read your post "${title}" and would like to discuss infrastructure/DevOps consulting.`)}`;
const waZhUrl = `https://wa.me/60127972969?text=${encodeURIComponent(`你好 Hoelee,我读了你的文章《${title}》,想咨询适合我业务的系统开发与自动化方案。`)}`;
const aboutUrl = lang === 'zh' ? '/zh/about/' : '/about/';
---
<section class="dynamic-cta" aria-label={lang === 'zh' ? '合作与雇佣咨询' : 'Work & Hiring Inquiries'}>
{isAiOrBiz ? (
<div class="cta-card">
<div class="cta-header">
<span class="cta-badge">🚀 {lang === 'zh' ? '企业智能化与业务自动化' : 'Automation & Custom AI Systems'}</span>
<h3>{lang === 'zh' ? '想要一个 24/7 自动接单、发试用、不漏单的业务自动化系统?' : 'Want a 24/7 bot or automation pipeline that never drops a lead?'}</h3>
</div>
<p class="cta-desc">
{lang === 'zh'
? '别让夜间和周末的客户询盘因为“没人回”而流失给竞争对手。我帮中小企业定制 Telegram / WhatsApp 智能客服与全自动履约系统(打通商品库、CRM 与自动发货,无高昂 SaaS 人头费)。'
: "Don't let customer inquiries slip away at midnight or on weekends. I build custom Telegram/WhatsApp AI assistants and deterministic workflows that connect directly to your catalogue, CRM, and order fulfillment."}
</p>
<div class="cta-actions">
<a class="cta-btn primary" href={lang === 'zh' ? waZhUrl : waBizUrl} target="_blank" rel="noopener">
💬 {lang === 'zh' ? 'WhatsApp 免费预约 15 分钟方案与 ROI 评估' : 'WhatsApp Me: 15-Min Feasibility & ROI Chat'}
</a>
<a class="cta-btn secondary" href={aboutUrl}>
👔 {lang === 'zh' ? '招聘方:查看我的工程履历与全景技术栈 →' : 'Hiring? View Engineering Profile & Stack →'}
</a>
</div>
</div>
) : isDevOpsOrEng ? (
<div class="cta-card">
<div class="cta-header">
<span class="cta-badge">🛡️ {lang === 'zh' ? '生产级基础设施与系统架构' : 'Production Infrastructure & SRE'}</span>
<h3>{lang === 'zh' ? '服务器频繁异常重启、云账单过高,或缺一位能穿透内核日志的架构师?' : 'Dealing with flaky containers, cloud cost bloat, or unexplained outages?'}</h3>
</div>
<p class="cta-desc">
{lang === 'zh'
? '真正的生产环境稳定性,靠的是穿透表象抓出根因。我独立管理着跨 3 台物理/云主机、162 个容器的高可用自托管生产网络。'
: 'Real production reliability comes from understanding the kernel and the protocol layers, not just restarting containers. I manage 162 containers across 3 hosts running 24/7 client workloads.'}
</p>
<div class="cta-actions">
<a class="cta-btn primary" href={aboutUrl}>
👨‍💻 {lang === 'zh' ? '致招聘官:查看我的生产基础设施全景与在线简历 →' : 'For Tech Leads: View Infrastructure Blueprint & CV →'}
</a>
<a class="cta-btn secondary" href={lang === 'zh' ? waZhUrl : waDevOpsUrl} target="_blank" rel="noopener">
🔧 {lang === 'zh' ? '企业客户:咨询基础设施容灾或 SifuMail 托管 ↗' : 'Business: Ask About Infrastructure Audits ↗'}
</a>
</div>
</div>
) : (
<div class="cta-card">
<div class="cta-header">
<span class="cta-badge">💼 {lang === 'zh' ? '合作与雇佣' : 'Work With Me'}</span>
<h3>{lang === 'zh' ? '有高价值项目开发需求,或正在寻找硬核全栈工程师?' : 'Looking for high-impact engineering work or custom systems?'}</h3>
</div>
<p class="cta-desc">
{lang === 'zh'
? '从前端交互到边缘网关,从业务系统到自托管基础设施。欢迎与我探讨商业系统外包或全职/远程工程岗位。'
: 'From front-end web apps down to edge proxies and container automation. Open to serious freelance client projects and senior engineering roles.'}
</p>
<div class="cta-actions">
<a class="cta-btn primary" href={lang === 'zh' ? waZhUrl : waBizUrl} target="_blank" rel="noopener">
💬 {lang === 'zh' ? '通过 WhatsApp 直接交谈 (+60 12-797 2969)' : 'Chat on WhatsApp (+60 12-797 2969)'}
</a>
<a class="cta-btn secondary" href={aboutUrl}>
📄 {lang === 'zh' ? '查看个人履历与完整代表作 →' : 'View Full Profile & Experience →'}
</a>
</div>
</div>
)}
</section>
<style>
.dynamic-cta {
margin: 3.5rem 0 2rem;
}
.cta-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
}
.cta-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cta-badge {
align-self: flex-start;
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--brand);
background: color-mix(in srgb, var(--brand) 12%, transparent);
padding: 0.25rem 0.65rem;
border-radius: 4px;
}
.cta-header h3 {
margin: 0;
font-size: 1.25rem;
line-height: 1.4;
color: var(--ink);
}
.cta-desc {
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
color: var(--muted);
}
.cta-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
padding-top: 0.5rem;
}
.cta-btn {
display: inline-flex;
align-items: center;
font-size: 0.9rem;
font-weight: 600;
padding: 0.65rem 1.15rem;
border-radius: var(--radius);
text-decoration: none;
transition: all 0.15s ease;
}
.cta-btn.primary {
background: var(--brand);
color: #ffffff;
border: 1px solid var(--brand);
}
.cta-btn.primary:hover {
background: var(--brand-soft);
border-color: var(--brand-soft);
text-decoration: none;
}
.cta-btn.secondary {
background: var(--surface-2);
color: var(--ink);
border: 1px solid var(--border);
}
.cta-btn.secondary:hover {
background: var(--border);
text-decoration: none;
}
@media (max-width: 640px) {
.cta-card {
padding: 1.35rem;
}
.cta-actions {
flex-direction: column;
}
.cta-btn {
width: 100%;
justify-content: center;
}
}
</style>
+233
View File
@@ -0,0 +1,233 @@
---
interface Props {
summary?: {
audience: string;
painPoint: string;
impact: { metric: string; detail: string }[];
stack: string[];
jumpLinks?: { business?: string; architecture?: string; result?: string };
};
lang?: 'en' | 'zh';
}
const { summary, lang = 'en' } = Astro.props;
if (!summary) return null;
const labels = lang === 'zh' ? {
badge: '⚡ 核心速览 · Executive Summary',
for: '适用读者:',
problem: '核心痛点',
impact: '交付成果与指标',
stack: '涉及技术栈',
nav: '快速跳读',
btnBiz: '看商业/交付结果 ↓',
btnArch: '看系统架构与排障 ↓',
btnLesson: '看复盘与避坑指南 ↓'
} : {
badge: '⚡ Executive Summary',
for: 'Target Audience:',
problem: 'The Problem',
impact: 'Key Outcomes & Metrics',
stack: 'Architecture Stack',
nav: 'Quick Jump',
btnBiz: 'Business Outcome ↓',
btnArch: 'Architecture & Root Cause ↓',
btnLesson: 'Lessons Learned ↓'
};
---
<aside class="exec-summary" aria-label={labels.badge}>
<div class="exec-header">
<span class="exec-badge">{labels.badge}</span>
<span class="exec-audience">{labels.for} <strong>{summary.audience}</strong></span>
</div>
<div class="exec-problem">
<span class="exec-problem-label">{labels.problem}:</span>
<p class="exec-problem-text">{summary.painPoint}</p>
</div>
<div class="exec-grid">
{summary.impact.map((item) => (
<div class="exec-metric-card">
<div class="exec-metric-val">{item.metric}</div>
<div class="exec-metric-desc">{item.detail}</div>
</div>
))}
</div>
<div class="exec-footer">
<div class="exec-stack">
<span class="exec-footer-label">{labels.stack}:</span>
<div class="exec-pills">
{summary.stack.map((t) => <span class="exec-pill">{t}</span>)}
</div>
</div>
{summary.jumpLinks && (
<div class="exec-jumps">
<span class="exec-footer-label">{labels.nav}:</span>
<div class="exec-jump-btns">
{summary.jumpLinks.business && <a class="exec-jump-link" href={summary.jumpLinks.business}>{labels.btnBiz}</a>}
{summary.jumpLinks.architecture && <a class="exec-jump-link" href={summary.jumpLinks.architecture}>{labels.btnArch}</a>}
{summary.jumpLinks.result && <a class="exec-jump-link" href={summary.jumpLinks.result}>{labels.btnLesson}</a>}
</div>
</div>
)}
</div>
</aside>
<style>
.exec-summary {
margin: 2rem 0;
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.exec-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--border);
}
.exec-badge {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--brand);
background: color-mix(in srgb, var(--brand) 12%, transparent);
padding: 0.25rem 0.65rem;
border-radius: 4px;
}
.exec-audience {
font-size: 0.88rem;
color: var(--muted);
}
.exec-audience strong {
color: var(--ink);
}
.exec-problem {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.exec-problem-label {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.exec-problem-text {
margin: 0;
font-size: 1rem;
line-height: 1.55;
color: var(--ink);
}
.exec-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.exec-metric-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.exec-metric-val {
font-size: 1.05rem;
font-weight: 700;
color: var(--brand);
font-family: var(--font-mono);
}
.exec-metric-desc {
font-size: 0.88rem;
color: var(--muted);
line-height: 1.45;
}
.exec-footer {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
}
.exec-footer-label {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
margin-bottom: 0.4rem;
display: block;
}
.exec-pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.exec-pill {
font-family: var(--font-mono);
font-size: 0.8rem;
background: var(--surface-2);
color: var(--ink);
padding: 0.2rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--border);
}
.exec-jump-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.exec-jump-link {
font-size: 0.85rem;
color: var(--brand-soft);
text-decoration: none;
background: var(--surface-2);
padding: 0.3rem 0.65rem;
border-radius: 4px;
border: 1px solid var(--border);
transition: all 0.15s ease;
}
.exec-jump-link:hover {
color: #ffffff;
background: var(--brand);
border-color: var(--brand);
}
@media (max-width: 640px) {
.exec-grid {
grid-template-columns: 1fr;
}
}
</style>
+15
View File
@@ -26,6 +26,21 @@ const posts = defineCollection({
translation: z.string().optional(),
ogImage: z.string().optional(),
banner: z.string().optional(),
summary: z.object({
audience: z.string(),
painPoint: z.string(),
impact: z.array(z.object({
metric: z.string(),
detail: z.string(),
})),
stack: z.array(z.string()),
jumpLinks: z.object({
business: z.string().optional(),
architecture: z.string().optional(),
result: z.string().optional(),
}).optional(),
}).optional(),
dynamicCta: z.boolean().default(false),
}),
});
@@ -39,7 +39,7 @@ I've since found two other gates on the same host with the same latent problem,
## The setup: SSO for an app that has no SSO
The app was a self-hosted database UI on my NAS (Docker, published on a host port, sitting behind nginx and a Cloudflare Tunnel — the usual shape of my homelab). I wanted a login wall in front of it so that "reachable from the internet" wouldn't mean "anyone who guesses the hostname sees the login form and can start guessing passwords".
The app was a self-hosted database UI on my NAS (Docker, published on a host port, sitting behind nginx and a Cloudflare Tunnel — the usual shape of the infrastructure I manage). I wanted a login wall in front of it so that "reachable from the internet" wouldn't mean "anyone who guesses the hostname sees the login form and can start guessing passwords".
The app's own documentation advertises SSO. I'll keep this post focused on the ingress trap and put the licensing story in its own write-up, but the short version is that the advertised SSO is a licensed feature, the open-source build ships the code path but not the permission to use it, and the community fork that used to fill the gap hasn't been touched in two years. Twenty minutes of research, two abandoned approaches, one conclusion: **the app would never do SSO for me, so the gate had to live in front of it.**
@@ -184,7 +184,7 @@ grep -c 'proxy_pass http://localhost:10000;' vhost.conf
# 11
```
Eleven. My app was one of them. **Ten other applications are gated through that outpost**, and a blanket substitution would have silently un-gated every one of them at once — including the ones doing real authentication work. The "fix" would have looked successful and removed authentication from most of my homelab.
Eleven. My app was one of them. **Ten other applications are gated through that outpost**, and a blanket substitution would have silently un-gated every one of them at once — including the ones doing real authentication work. The "fix" would have looked successful and removed authentication from most of my infrastructure.
What I did instead:
@@ -8,7 +8,7 @@ ogImage: /og/automating-cyberpanel-without-the-ui.jpg
banner: /banners/automating-cyberpanel-without-the-ui.jpg
---
I run CyberPanel 2.4.4.1 inside an Ubuntu VM on my home lab to host a handful
I run CyberPanel 2.4.4.1 inside an Ubuntu VM on my self-managed infrastructure to host a handful
of small sites. It sits at `https://panel.hoelee.com`, reverse-proxied to an
internal box. Recently I wanted to script two things against it — list my
sites and delete one — without opening the browser. This is the story of
@@ -7,6 +7,22 @@ tags: ["docker", "oom", "traefik", "synology", "crawlers"]
ogImage: /og/docker-reported-exit-0-the-oom-killer-disagreed.jpg
banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.jpg
draft: false
summary:
audience: "SREs, DevOps Leads & Systems Engineers"
painPoint: "A public edge proxy (Traefik) crashed 7 times over 4 days while Docker daemon reported ExitCode=0 and OOMKilled=false, rendering standard monitoring completely blind."
impact:
- metric: "Exposed Root Cause"
detail: "Bypassed Docker's false status via kernel dmesg and cgroup high-water marks, pinning the crash to 25,976 crawler IPs hitting a 512MB limit."
- metric: "Zero-Downtime Hotfix"
detail: "Hot-scaled proxy container to 2GB with memory swap locked, dropping peak memory to 210MB in 18 minutes with 0 subsequent crashes."
- metric: "Anti-Lockout SOP"
detail: "Established edge crawler stress-testing and verified probe procedures to prevent diagnostic self-lockout from security filters."
stack: ["Docker", "Linux Kernel (dmesg/cgroups)", "Traefik", "Synology DSM", "Security Filtering"]
jumpLinks:
business: "#the-result"
architecture: "#the-kernel-keeps-its-own-record"
result: "#what-i-would-do-differently"
dynamicCta: true
---
## The notification I expected to be a config mistake
@@ -6,6 +6,22 @@ category: case-studies
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/how-i-built-the-digikedai-telegram-bot.jpg
banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg
summary:
audience: "Business Owners & Product Leads"
painPoint: "A solo digital goods business faces a non-stop stream of repetitive pre-sales questions (trial requests, account setup, package selection) — leading to missed midnight sales and high manual support overhead."
impact:
- metric: "24/7 Instant Reply"
detail: "Handles customer inquiries across Telegram and WhatsApp with zero human intervention for free-trial provisioning."
- metric: "Zero Price Hallucination"
detail: "3-tier deterministic SKU retrieval and state-machine purchase interception prevent hallucinated prices or leaked internal paths."
- metric: "~175 Unit Tests"
detail: "Runs reliably in a single container on a Synology NAS with LiteLLM gateway failover."
stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"]
jumpLinks:
business: "#why-a-business-needs-a-bot-like-this"
architecture: "#the-architecture"
result: "#the-result"
dynamicCta: true
---
Digi Kedai sells digital products — online courses, ebooks, and templates — and
@@ -61,7 +77,7 @@ Customer → Telegram/WhatsApp → Cloudflare tunnel → bot.digikedai.com
A few decisions worth explaining:
**A Cloudflare tunnel into the container, with no Traefik hop.** My homelab is
**A Cloudflare tunnel into the container, with no Traefik hop.** The self-hosted infrastructure is
behind CGNAT, so nothing is publicly reachable without a tunnel. The webhook URL
lives behind a **secret path segment** (`/<secret>/webhook`) so Telegram's
updates only land if the caller knows the secret — a cheap first line of defense
@@ -182,15 +198,3 @@ If you're building your own Telegram bot backed by an LLM, the lesson is the
boring one: the model is the easy part. The webhook lifecycle, the payload
contract, and the idempotency of your provisioning are where it actually breaks —
design those first.
---
## Want a bot like this for your business?
I build custom Telegram/WhatsApp AI bots, websites, and self-hosted
infrastructure for businesses. If a bot like this could save you time and
money — or you'd like to hire me — I'd love to talk:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **Email:** [[email protected]](mailto:[email protected])
- 🌐 **Website:** [hoelee.com](https://hoelee.com)
+1 -1
View File
@@ -37,7 +37,7 @@ git push → Gitea webhook → act_runner picks up the job
→ npm ci → astro build → copy dist/* to docroot → nginx serves it
```
The whole thing runs on hardware in my homelab, which is exactly the point —
The whole thing runs on hardware on the infrastructure I manage, which is exactly the point —
this is a live demo of the DevOps work I describe elsewhere.
## The docker-compose setup
@@ -4,7 +4,7 @@ description: "Three hosts, one Prometheus, one Grafana. The coverage matrix, the
pubDate: 2026-07-29
updatedDate: 2026-09-29
category: case-studies
tags: [prometheus, grafana, unraid, synology, dsm, cadvisor, monitoring, homelab]
tags: [prometheus, grafana, unraid, synology, dsm, cadvisor, monitoring, self-hosting]
ogImage: /og/one-prometheus-for-unraid-synology-and-a-vps.jpg
banner: /banners/one-prometheus-for-unraid-synology-and-a-vps.jpg
draft: false
@@ -12,7 +12,7 @@ draft: false
## Why this matters
A homelab is not a toy when it's running other people's websites, mail and files. The
Self-hosted infrastructure is not a toy when it's running other people's websites, mail and files. The
difference between a bad week and a bad *month* is usually how early you found out: a
disk with a reallocated sector, a volume with 20 GB left, a VM being starved of CPU by
its host, a container that restarted four times overnight while you slept.
@@ -45,7 +45,7 @@ CosyVoice 2 (the 0.5B parameter variant from Alibaba's FunAudioLLM team) emerged
## 3. Infrastructure & Network Topology
Our home-lab runs on a tiered hardware split:
Our self-managed infrastructure runs on a tiered hardware split:
```
[ Internet / Clients ]
@@ -4,7 +4,7 @@ description: "A disk-health collector that treats any non-zero smartctl exit as
pubDate: 2026-04-14
updatedDate: 2026-09-29
category: notes
tags: [smart, smartctl, unraid, monitoring, bash, disks, homelab]
tags: [smart, smartctl, unraid, monitoring, bash, disks, self-hosting]
ogImage: /og/smartctl-exit-code-32-skips-the-disks-that-matter.jpg
banner: /banners/smartctl-exit-code-32-skips-the-disks-that-matter.jpg
draft: false
@@ -40,7 +40,7 @@ By driving an already-authenticated Chrome instance in Docker via CDP, we operat
When controlling a local Chrome instance, Playwright or Puppeteer attaches files using `page.setInputFiles("input[type=file]", local_path)`. The browser reads the file from your local disk and passes the handle directly to the OS file picker.
However, in a self-hosted homelab or production setup, you frequently run Chrome inside an isolated Docker container on a NAS or server to keep your authenticated session alive 24/7 without exposing your daily desktop.
However, in a self-hosted on-premise or production setup, you frequently run Chrome inside an isolated Docker container on a NAS or server to keep your authenticated session alive 24/7 without exposing your daily desktop.
When your automation script runs on a different workstation or runner and tries to push a video over a remote CDP connection:
1. Playwright attempts to base64-encode or stream the file buffer over the WebSocket protocol.
@@ -143,7 +143,7 @@ def stage_file_to_container(local_path: str, dest_filename: str) -> None:
### Operational Caveats & Security Checklist
1. **Client RAM Footprint**: While Chrome experiences zero memory pressure, `io.BytesIO()` buffers the 130MB tar archive in the Python process memory. For multi-gigabyte video dumps, write the `.tar` to a temporary file and stream it using `requests` or `urllib3` chunked uploads.
2. **Pre-existing Destination Directory**: The Docker Archive API extracts *into* the directory specified by `?path=`. If `/config/Downloads` does not exist in the container image, the API returns HTTP 404/500 rather than creating the parent folders.
3. **API Key & CDP Exposure**: Both the Portainer API key and the unauthenticated Chrome DevTools port (default `9222` or `9242`) grant root-level execution capability. Never bind these ports to public IPs (`0.0.0.0`). Keep them strictly on an isolated homelab bridge or behind mutual-TLS / Authentik SSO.
3. **API Key & CDP Exposure**: Both the Portainer API key and the unauthenticated Chrome DevTools port (default `9222` or `9242`) grant root-level execution capability. Never bind these ports to public IPs (`0.0.0.0`). Keep them strictly on an isolated internal bridge or behind mutual-TLS / Authentik SSO.
### Measured Performance
On a local 1 Gbps LAN:
@@ -9,7 +9,7 @@ banner: "/banners/why-is-my-tts-model-repeating-percent.jpg"
draft: false
---
We had just wired Alibaba's open-source CosyVoice 2 into our home-lab automation cluster. The direct Python test scripts worked cleanly, but when we fired our first test request through a Synology-hosted n8n Webhook, the audio response sounded like an alien countdown.
We had just wired Alibaba's open-source CosyVoice 2 into our self-hosted automation cluster. The direct Python test scripts worked cleanly, but when we fired our first test request through a Synology-hosted n8n Webhook, the audio response sounded like an alien countdown.
Instead of the Chinese phrase *"恭喜!本地模型通过 N8N 联动测试成功!"* (Congratulations! Local model test via n8n succeeded!), the speaker rattled off a robotic avalanche:
@@ -1,12 +1,28 @@
---
title: "Docker 说它干净退出,内核说它被 OOM 杀掉了"
description: "NAS 上的容器不停「意外停止」,而 Docker 报的是 exit 0 / OOMKilled=false。真正的证据在 dmesg 里:六次 cgroup 内存击杀。"
title: "Docker 报告退出码 0,内核却早已执行 OOM-Kill"
description: "NAS 上的一个容器反复异常退出,Docker 却上报 exit 0 与 OOMKilled=false。真实的证据在 dmesg 里:六次 cgroup OOM-kill。"
pubDate: 2026-10-16
category: devops
tags: ["docker", "oom", "traefik", "synology", "crawlers"]
ogImage: /og/zh-docker-reported-exit-0-the-oom-killer-disagreed.jpg
banner: /banners/docker-reported-exit-0-the-oom-killer-disagreed.jpg
draft: false
summary:
audience: "SREs, DevOps Leads & 系统工程师"
painPoint: "公网边缘反向代理容器(Traefik)4天内无预警重启7次,但 Docker daemon 谎报 ExitCode=0 且 OOMKilled=false,常规监控完全失明。"
impact:
- metric: "精准定位真凶"
detail: "穿透 Docker 状态假象,通过内核 dmesg 与 cgroup 内存水位线抓出 25,976 个代理爬虫 IP 引发的 512MB 触顶"
- metric: "0 停机热修复"
detail: "单容器精准扩容至 2GB 并锁死 Swap(防磁盘阵列雪崩),18分钟峰值回落至 210MB,连续运行 0 崩溃"
- metric: "防自锁诊断规范"
detail: "沉淀边缘网关高并发防爬虫压测与避免自身 IP 被 WAF 误封(403 假象)的排障 SOP"
stack: ["Docker", "Linux Kernel (dmesg/cgroups)", "Traefik", "Synology DSM", "Security Filtering"]
jumpLinks:
business: "#the-result"
architecture: "#the-kernel-keeps-its-own-record"
result: "#what-i-would-do-differently"
dynamicCta: true
---
## 一条我以为又是配置写错的告警
@@ -47,7 +47,7 @@ Tor 是一个匿名网络:流量经过三道中继,没有任何一道中继能
由此得到四个普通网站给不了的特性:
- **没有公网 IP、没有端口、没有 DNS。** 扫描器无从发现。你可以用 CGNAT 后面的家用网络来跑(现在大多数家庭宽带都是)。
- **没有公网 IP、没有端口、没有 DNS。** 扫描器无从发现。你可以用处于 CGNAT 后的私有网络来跑(现在大多数宽带都是)。
- **没有中间商。** 路径上没有任何 CDN、注册商或平台记录谁来过。
- **地址本身就是访问控制。** 拿到地址的人可以访问;其他人连这站存不存在都无法判断。要求更高时还能叠加客户端密钥。
- **clearnet 挂了它还在。** 域名被墙或被没收,洋葱站毫发无损,因为没有注册局参与其中。
+1 -1
View File
@@ -25,7 +25,7 @@ banner: /banners/hello-world.jpg
## 我写什么
- **全栈工程**——PHP/CodeIgniter、Java/Spring、TypeScript/React、WordPress。
- **DevOps 与自托管**——Docker、Traefik、Cloudflare 隧道、NAS/unRaid 家用实验室。
- **DevOps 与自托管**——Docker、Traefik、Cloudflare 隧道、NAS/unRaid 私有基础设施。
- **AI 与自动化**——Telegram 机器人、n8n 工作流、本地 LLM、记忆系统。
- **Web3**——Solidity、Foundry、ERC-20/721(仍在学习,如实呈现)。
@@ -1,11 +1,27 @@
---
title: "我如何打造 DigiKedai Telegram AI 客服机器人"
description: "一个全天候解答顾客问题、并在 Telegram 和 WhatsApp 上自动开通免费试用账号的 AI 客服机器人——TypeScript、grammY、Cloudflare 隧道 webhook,以及自托管的 LiteLLM 网关。"
title: "我是如何构建 DigiKedai Telegram AI 客服机器人的"
description: "一个跨 Telegram 与 WhatsApp 解答疑问并全自动开通免费试用账号的 AI 客服机器人——TypeScript、grammY、Cloudflare 隧道 webhook,以及自建 LiteLLM 网关。"
pubDate: 2026-09-06
category: case-studies
tags: [telegram, docker, cloudflare, litellm, typescript, ai]
ogImage: /og/zh-how-i-built-the-digikedai-telegram-bot.jpg
banner: /banners/how-i-built-the-digikedai-telegram-bot.jpg
summary:
audience: "企业主与产品/运营负责人"
painPoint: "一人数字商品电商面临大量重复售前咨询(试用申请、账号开通、选品),夜间与周末漏单严重,人工客服成本高。"
impact:
- metric: "24/7 秒级响应"
detail: "全天候接管 Telegram 与 WhatsApp 双端咨询,零人工介入自动发放免费试用账号"
- metric: "0 价格幻觉"
detail: "三层确定性 SKU 检索 + 状态机拦截购买意图,杜绝大模型胡报价或泄漏内部路径"
- metric: "~175 项单测护航"
detail: "单容器稳定运行于 NAS,通过自建 LiteLLM 网关实现模型高可用切换"
stack: ["TypeScript", "grammY", "LiteLLM", "NocoDB", "Cloudflare Tunnel", "PostgreSQL"]
jumpLinks:
business: "#why-a-business-needs-a-bot-like-this"
architecture: "#the-architecture"
result: "#the-result"
dynamicCta: true
---
Digi Kedai 销售数字产品——在线课程、电子书、模板——每一单都会带来一连串同样的顾客问题:*「这门课有免费试看吗?」「我怎么拿到账号?」「我该买哪个套餐?」*。对一个人的生意来说,人工回复根本忙不过来。于是我打造了一个 AI 客服机器人,全天候解答这些问题,甚至能在无人介入的情况下自动发放免费试用账号。
@@ -44,7 +60,7 @@ Digi Kedai 销售数字产品——在线课程、电子书、模板——每一
几个值得解释的决策:
**Cloudflare 隧道直连容器,不经过 Traefik。** 我的 homelab 在 CGNAT 后面,没有隧道就没有任何东西能被公网访问。webhook URL 藏在一个**秘密路径段**(`/<secret>/webhook`)后面,这样 Telegram 的更新只有知道这个 secret 的人才能送达——这是在 Telegram 自带 token 认证之外的第一道廉价防线。
**Cloudflare 隧道直连容器,不经过 Traefik。** 我管理的自托管生产节点在 CGNAT 后面,没有隧道就没有任何东西能被公网访问。webhook URL 藏在一个**秘密路径段**(`/<secret>/webhook`)后面,这样 Telegram 的更新只有知道这个 secret 的人才能送达——这是在 Telegram 自带 token 认证之外的第一道廉价防线。
**模型用 LiteLLM 的*别名*寻址,从不用原始模型名。** 机器人调用 `mem0-openai`;LiteLLM 把它映射到真实模型(背后还有 OpenRouter 故障转移)。机器人永远不需要知道实际是哪个供应商在服务请求。我设了 `temperature: 0.4` 和 `45s` 超时。
@@ -102,13 +118,3 @@ LLM 擅长开放式问题,却很不擅长*状态*。所以那些需要可靠
[git.hoelee.com/hoelee/digikedai-bot](https://git.hoelee.com/hoelee/digikedai-bot)。
如果你也在打造一个 LLM 驱动的 Telegram 机器人,教训其实很朴素:模型是最容易的部分。webhook 生命周期、payload 契约、以及开通流程的幂等性才是真正会出问题的地方——先设计好这些。
---
## 想为你的生意做一个这样的机器人吗?
我为企业定制 Telegram/WhatsApp AI 机器人、网站,以及自托管基础设施。如果一个这样的机器人能为你省时省钱——或者你想雇佣我——我非常乐意交流:
- 📱 **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969)
- 📧 **邮箱:** [[email protected]](mailto:[email protected])
- 🌐 **网站:** [hoelee.com](https://hoelee.com)
+1 -1
View File
@@ -36,7 +36,7 @@ git push → Gitea webhook → act_runner 领取任务
→ npm ci → astro build → 拷贝 dist/* 到站点根目录 → nginx 提供服务
```
整个过程都跑在我 homelab 自己的硬件上——这正是重点:它就是我别处所描述的
整个过程都跑在我自己管理的基础设施硬件上——这正是重点:它就是我别处所描述的
DevOps 工作的一个活生生的演示。
## docker-compose 配置
@@ -4,7 +4,7 @@ description: "三台主机、一个 Prometheus、一个 Grafana:覆盖矩阵
pubDate: 2026-07-29
updatedDate: 2026-09-29
category: case-studies
tags: [prometheus, grafana, unraid, synology, dsm, cadvisor, monitoring, homelab]
tags: [prometheus, grafana, unraid, synology, dsm, cadvisor, monitoring, self-hosting]
ogImage: /og/zh-one-prometheus-for-unraid-synology-and-a-vps.jpg
banner: /banners/one-prometheus-for-unraid-synology-and-a-vps.jpg
draft: false
@@ -12,7 +12,7 @@ draft: false
## 为什么这件事重要
当一台家用服务器在跑别人的网站、邮件和文件时,它就不是玩具了。糟糕的一周和糟糕的**一个月**之间,差别通常只是你多早发现:一块出现重分配扇区的盘、一个只剩 20 GB 的卷、一台被宿主机饿死的虚拟机、一个夜里重启四次的容器。
当一套自托管基础设施在跑别人的网站、邮件和文件时,它就不是玩具了。糟糕的一周和糟糕的**一个月**之间,差别通常只是你多早发现:一块出现重分配扇区的盘、一个只剩 20 GB 的卷、一台被宿主机饿死的虚拟机、一个夜里重启四次的容器。
我有三台机器——unRaid(日常主力)、群晖 NAS(存储与服务)和 VPS(对外)——以及三套**各自**都不知道它们在干什么的方式。这篇文章讲它们如何变成一块屏,以及我路上做错的四件事——其中三个你也会踩。
@@ -125,4 +125,4 @@ echo ONLINE - CosyVoice 2 已加载至显卡,服务就绪。
- **首包响应**:n8n 收到请求至音频开始回传约 **320 毫秒**,完全满足交互式即时语音应答的要求。
- **自主掌控**:不再受制于第三方厂商的封号、限频与网络审查,所有音频资产与 Prompt 完全留存在本地内网。
自托管高品质 AI 语音不再是大型服务器集群的专利。借助新一代自回归+扩散语音架构与消费级显卡,一套完全受控、零月租且音质超越商业云服务的全天候语音栈,已经完全可以在普通 Home-lab 环境中稳定运转。
自托管高品质 AI 语音不再是大型服务器集群的专利。借助新一代自回归+扩散语音架构与消费级显卡,一套完全受控、零月租且音质超越商业云服务的全天候语音栈,已经完全可以在自托管生产环境中稳定运转。
@@ -4,7 +4,7 @@ description: "把 smartctl 的任何非零退出码当成读取失败的监控
pubDate: 2026-04-14
updatedDate: 2026-09-29
category: notes
tags: [smart, smartctl, unraid, monitoring, bash, disks, homelab]
tags: [smart, smartctl, unraid, monitoring, bash, disks, self-hosting]
ogImage: /og/zh-smartctl-exit-code-32-skips-the-disks-that-matter.jpg
banner: /banners/smartctl-exit-code-32-skips-the-disks-that-matter.jpg
draft: false
@@ -79,4 +79,4 @@ fio --name=verify --filename=/dev/sdd \
这颗几分钟前就要被我送去 RMA 的盘,现在已经是一颗格式化好、挂载上、verify 全过的缓存 SSD,`btrfs device stats` 全部清零。修好它的成本:一根线。相信原有诊断的成本:一趟没必要的保修,还有一台迟早会在那根坏线上损坏数据的机器。
如果你在 NAS 或家用服务器上遇到一颗盘报 FPDMA 超时、但 SMART 看起来没问题——换线,别急着换盘。
如果你在 NAS 或自管服务器上遇到一颗盘报 FPDMA 超时、但 SMART 看起来没问题——换线,别急着换盘。
@@ -38,7 +38,7 @@ YouTube Data API 默认每天只有 10,000 单位配额,上传一个视频扣
在控制本地 Chrome 时,Playwright 或 Puppeteer 使用 `page.setInputFiles("input[type=file]", local_path)`。浏览器直接从当前机器的物理磁盘中读取文件句柄,操作极其平稳。
但在自托管 Homelab 或生产部署中,为了让 YouTube、电商等带有登录态的账号 7x24 小时保持在线且不污染日常桌面,我们通常将 Chrome 运行在 Synology NAS 或 Linux 服务器的 Docker 容器中。
但在自托管私有基础设施或生产部署中,为了让 YouTube、电商等带有登录态的账号 7x24 小时保持在线且不污染日常桌面,我们通常将 Chrome 运行在 Synology NAS 或 Linux 服务器的 Docker 容器中。
当我们在外部工作站或 CI 流水线上运行脚本,试图跨网络通过远程 CDP 将视频推给容器里的浏览器时,问题接踵而至:
1. Playwright 会尝试通过 WebSocket 协议将巨大的二进制文件转为 Base64 或分片推流;
@@ -62,7 +62,7 @@ draft: false
不用云端按分钟计费的语音转写 API,流水线直接运行在宿主机的 NVIDIA RTX 3060 显卡上。通过 `faster-whisper` 与 `Silero-VAD` 结合,自动切除无声段,并按标点符号聚合成 3 到 8 秒的自然语义分句。
### 严禁家庭 NAS 直出公网视频流
用家里的 NAS 直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,家庭宽带的上行带宽会被瞬间打满,甚至触发运营商对于家用宽带 PCDN 的封禁。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。
用自建 NAS 直出公网 Range/206 视频流,一旦遇到爬虫或多人同时访问,宽带的上行带宽会被瞬间打满,甚至触发运营商对于 PCDN 的封禁。公网访问采用双轨策略:长青视频优先使用 B站/YouTube 官方嵌入播放器;自存短视频经 NVENC 压缩为 720p H.264 后托管在免流量费的 Cloudflare R2。
### 基于 Pagefind 的毫秒级离线搜索
前台不维护重型的 Meilisearch 或 Elasticsearch 数据库,直接编译为 Astro 静态站点并利用 Pagefind 构建分片索引。读者在浏览器端搜索时,通过 WebAssembly 按需拉取几十 KB 的分片,零服务器内存常驻,中文关键词检索延迟在 20ms 以内。
+8
View File
@@ -2,6 +2,8 @@
import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthorCard from '../../components/AuthorCard.astro';
import TableOfContents from '../../components/TableOfContents.astro';
import ExecutiveSummary from '../../components/ExecutiveSummary.astro';
import DynamicCTA from '../../components/DynamicCTA.astro';
import { getCollection, render } from 'astro:content';
import { postLang } from '../../lib/lang';
import { countWords, readingMinutes } from '../../lib/reading-time';
@@ -120,10 +122,16 @@ const jsonLd = {
</figure>
)}
{post.data.summary && <ExecutiveSummary summary={post.data.summary} lang={lang} />}
<TableOfContents headings={headings} lang={lang} />
<Content />
{post.data.dynamicCta && (
<DynamicCTA category={post.data.category} tags={post.data.tags} lang={lang} title={post.data.title} />
)}
<AuthorCard />
</article>
+52
View File
@@ -234,6 +234,58 @@ article img {
height: auto;
}
/* ---------- Technical Deep Dive Accordion (details/summary) ---------- */
article details.tech-deep-dive {
margin: 1.75rem 0;
padding: 1rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.2s ease;
}
article details.tech-deep-dive[open] {
padding-bottom: 1.25rem;
}
article details.tech-deep-dive > summary {
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
color: var(--brand);
user-select: none;
outline: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
article details.tech-deep-dive > summary::-webkit-details-marker {
display: none;
}
article details.tech-deep-dive > summary::before {
content: '▶';
font-size: 0.75rem;
transition: transform 0.2s ease;
color: var(--brand-soft);
}
article details.tech-deep-dive[open] > summary::before {
transform: rotate(90deg);
}
article details.tech-deep-dive > summary:hover {
color: var(--brand-soft);
}
article details.tech-deep-dive[open] > summary {
margin-bottom: 1rem;
border-bottom: 1px dashed var(--border);
padding-bottom: 0.65rem;
}
time { color: var(--faint); font-size: 0.88rem; }
/* Light theme: the v2 artwork is a near-black viewport, so on a white page it reads as