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?