docs(review): consultant audit tracker, 10 post batches and the posts/case-studies upgrade plan

- docs/consultant-review/: review brief, per-batch post dumps (01-10),
  audit-tracker.json + AUDIT-TRACKER.md generated by scripts/manage_review.py
- scripts/manage_review.py: list/approve/approve-batch/generate over the tracker
- docs/consultant-review-2026-10-27-posts-and-casestudies-plan.md: locked
  decision register + 4 work orders for the /posts/ archive rework and the
  case-studies portfolio treatment
This commit is contained in:
hoelee committed 2026-10-27 03:28:58 +08:00
1 parent c5e473aaad
commit 86edd40c74
15 files changed
+43272

No files matched your search

@@ -0,0 +1,95 @@
# 博客升级执行计划:/posts/ 归档重构与 Case Studies 作品集化
> **记录时间**:2026-10-27
> **归档状态**:已锁定,待下个会话/回合指令开工
> **顾问裁定依据**:
> 1. 第一轮:《关于 Hoelee Enterprise 技术博客的整体评审与升级建议》
> 2. 第二轮:《关于 Case Studies Banner 尺寸与 Posts 筛选交互的补充裁定》
---
## 一、核心决策与交互规范(Decision Register)
### 1. `/categories/case-studies/` 作品集化
- **Hero 架构**:桌面端采用左右分栏紧凑 Hero(左侧文本 + 右侧视觉)。文字必须使用语义化 HTML,严禁将标题或文字烧入图片;移动端自然堆叠。
- **Banner 视觉规格**:
- 比例约 **21:9 至 3:1**(内容区 900px 下,目标显示高度控制在 **240–300px**,避免占用过多首屏空间)。
- 圆角:8–12px,克制。
- 视觉意象:展示 3 台生产宿主(Unraid / Synology / VPS)、Docker、Prometheus 监控、网络链路与自动化流向;配色遵循暗调终端灰绿 + 暖铜低饱和点缀。
- **案例排序与内容组织**:
- 弃用单纯时间倒序,顶部优先展示 3–4 篇旗舰案例(生产基础设施规模、本地 AI 工作流、端到端自动化)。
- 卡片突出解决的痛点与量化成果指标(如 `3 Hosts · 162 Containers`)。
- **行动入口(Affordance)**:
- 卡片保持整卡可点,摘要末尾增加小型文字链接 `Read case study →` / `阅读案例复盘 →`。
### 2. `/posts/` 全部文章归档页
- **结构模型**:
- **顶部**:实时搜索栏 + 分类与热门标签过滤条。
- **中部(Featured 精选区)**:展示 2–3 篇旗舰精选文章卡片(16:9 WebP 封面、简短摘要、阅读时间、核心标签)。
- **下部(完整归档)**:紧凑年份与月份条目列表,供高效快速扫读。
- **交互联动机制(顾问裁定)**:
- **默认态**:展示 Featured 精选卡片 + 默认全量归档列表。
- **触发筛选态**:一旦用户在搜索框输入首个有效字符,或点击选中任意 Category/Tag 芯片,Featured 精选区执行平滑收起(高度收起 + 150–200ms 过渡,兼容 `prefers-reduced-motion`),把全量视口空间让给检索结果。
- **恢复态**:用户清空输入且清除 Tag 筛选时,精选区重新展开。
- **状态反馈**:列表上方常驻显式筛选状态(当前过滤条件、命中篇数、一键 `Clear filters` 按钮)。
- **全局覆盖**:搜索算法对全量文章进行匹配,精选文章在收起状态下依然能在结果列表中被命中展示。
### 3. 全局组件体验优化
- `src/components/PostList.astro` 通用卡片组件补全轻量行动链接(`Read article →` / `Read case study →`)。
- 严格保证中英双语(EN / ZH)同等体验与无死角本地化。
- 保证 375px/390px 移动端无横向滚动溢出。
---
## 二、工单拆解与实施路线图
### 工单 1:`/posts/` 混合列表与动态联动(P0)
- **目标文件**:
- `src/pages/posts/index.astro`
- `src/pages/zh/posts/index.astro`
- `src/components/Archive.astro`
- **实施内容**:
1. 在 `Archive.astro` 或 `posts/index.astro` 中定义 `featuredPosts` 选取逻辑(复用首页验证过的 3 篇经典系统级案例)。
2. 实现 Featured 响应式栅格卡片(16:9 WebP 缩略图、标题、阅读时长、摘要、行动链接)。
3. 改造 `Archive.astro` 的客户端脚本:
- 增加 `#archive-featured` 容器引用与折叠状态类 `.is-collapsed`。
- 监听 `input` 输入与 Tag/Category 按钮点击,计算 `isFiltering = Boolean(query || activeTag || activeCat)`。
- 当 `isFiltering === true` 时,平滑收起 `#archive-featured`;重置为 false 时恢复。
- 增加 `#archive-filter-status` 状态栏(显示匹配数量与“清除筛选”按钮)。
4. 完善 CSS 过渡动画与 `@media (prefers-reduced-motion: reduce)` 降级支持。
### 工单 2:`/categories/case-studies/` 紧凑 Hero 与作品集重构(P0)
- **目标文件**:
- `src/pages/categories/[category].astro`
- `src/pages/zh/categories/[category].astro`
- `src/components/CategoryArt.astro`
- `src/components/PostList.astro`
- **实施内容**:
1. 为 `case-studies` 类别定制专用 Hero 结构:
- 桌面端 CSS Grid / Flex 两栏布局(左侧 H1 + 定位介绍,右侧 21:9 / 3:1 扁平架构视觉)。
- 移动端单栏自然堆叠。
2. 视觉资产准备:
- 为 `case-studies` 制作或导出具备 3 宿主 + Docker + Prometheus 拓扑辨识度的横幅素材(落地于 `/images/categories/case-studies-banner.webp`)。
- 在 `CategoryArt.astro` 或分类页直接引入响应式 `<picture>` 标签。
3. 列表项调整:
- `case-studies` 分类下的文章加入排序权重(旗舰案例前置)。
- 卡片增加解决问题、量化结果指标(利用已有的 `featuredMetrics` 数据源或 frontmatter 扩展)。
4. 增加卡片右下角 `Read case study →` / `阅读案例复盘 →` 引导文字。
### 工单 3:全站列表组件与多语言对齐(P1)
- **目标文件**:
- `src/components/PostList.astro`
- `src/styles/global.css`
- **实施内容**:
1. `PostList.astro` 全面增加可点击的小型文字链接:
- 普通分类:`Read article →` / `阅读文章 →`
- Case Studies:`Read case study →` / `阅读案例复盘 →`
2. 校验中英双语标签字典 `categoryLabel`,确保无未翻译的英文字段泄漏到 `/zh/` 页面。
3. 确保列表卡片在移动端下的行间距与点击区域(Touch Target ≥ 44px)符合无障碍规范。
### 工单 4:端到端验收与发布(P0)
- **验收流程**:
1. 本地执行 `npm run build`,确保 Astro 构建零错误。
2. 运行 `node scripts/check-assets.mjs`,确保静态资源引证有效。
3. 使用 Headless 探测工具或 Playwright 脚本验证 1920px 与 375px 视口下的视觉呈现与水平溢出(`scrollWidth === clientWidth`)。
4. 提交 Git 并推送到 Gitea(`git.hoelee.com`)与 GitHub 镜像。
@@ -0,0 +1,41 @@
# 外部顾问审阅前置须知与工程背景规范
> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。
## 1. 站点定位与双重业务目的
- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。
- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。
## 2. 核心人设铁律与工程硬约束(必须严格遵守)
1. **彻底杜绝业余爱好者/玩具口吻**:
- ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。
- ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。
2. **严禁捏造虚假指标与性能数字**:
- 所有 benchmark 数据必须有明确的测试基准与单设备限制。
- 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。
- 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。
3. **去模板化叙事与真实物理锚点**:
- 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。
- 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。
4. **代码与配置展示规范**:
- 超过 20 行的配置或日志采用 HTML `<details><summary>` 原生折叠,`<summary>` 必须精准标注文档名与行数。
## 3. 顾问重点开刀维度
- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大?
- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻?
- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)?
- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置?
## 4. 博客 7 大分类与内容策略参考
- `case-studies` (商业案例):求职与获客核心武器,以真实商业交付和解决瓶颈为核心。
- `devops` (私有基建):高并发网关、Docker、Traefik、存储阵列与监控,最强差异化资产。
- `ai` (人工智能与智能体):围绕自动化工作流、LiteLLM 网关、状态机防幻觉与本地私有大模型。
- `engineering` (系统架构与后端):Java/Spring、PHP/CodeIgniter、TS/React 的硬核实战。
- `tutorials` (技术教程):面向学习者的深度实战指南。
- `notes` (排障速记):轻量级踩坑手记与高频高价值 gotcha。
- `web3` (智能合约):Solidity/Foundry 严谨实践,诚实表达学习与验证阶段。
## 5. 既有核心参考文档清单(仓库内置)
- `docs/content-guide.md` — 博客内容定位、选题规范、反模式与 i18n 策略。
- `docs/post-publishing-sop-v2.md` — 5 步叙事模型、代码折叠与 Bento 卡片技术规范。
- `docs/consultant-review-2026-10-26-supplement.md` — 上一轮顾问诊断全景资料包。
+105
View File
@@ -0,0 +1,105 @@
# 全站文章顾问审阅状态总控台 (Audit Tracker)
> **最后同步时间**:2026-10-27 03:22:38
> **全站总文章数**:91 篇 | **已审批完成**:9 篇 (9.9%) | **待审阅**:82 篇
## 一、操作命令速查
- `python scripts/manage_review.py list` — 查看待审与已审概览
- `python scripts/manage_review.py approve <slug> [--notes '顾问通过']` — 标记单篇通过
- `python scripts/manage_review.py approve-batch <batch_num>` — 标记整个批次通过
- `python scripts/manage_review.py generate` — 重新分批(自动排除已审批文章,每批 < 200k 字)
## 二、文章审核状态总表
| # | 分类 | 文章 Slug | 中文标题 | 状态 | 所在批次 | 审批时间 | 顾问备注 |
|---|---|---|---|:---:|:---:|:---:|---|
| 1 | `ai` | `best-ai-video-generators-2026` | 2026 年最佳 AI 视频生成器:免费与付费对比,以及我的 LTX-2.3 实测 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 2 | `ai` | `five-step-ai-roadmap-what-i-actually-run` | 全网都在转的 AI 五步学习路线 —— 我真正在生产里跑的是哪几步 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 3 | `ai` | `giving-ai-your-mouse-and-keyboard-safely` | 把鼠标和键盘交给 AI 之前:Windows 桌面 MCP 的安全边界与实战防线 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 4 | `ai` | `i-let-an-agent-manage-my-shopee-vouchers` | 我让 AI 助手管我的 Shopee 优惠券 —— 它建了一张我没批准的券 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 5 | `ai` | `plugging-windows-desktop-control-into-four-ai-agents` | 把 Windows 桌面控制接进 4 款 AI Agent:Hermes、OpenCode、Claw 与 Claude 的实战架构 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 6 | `ai` | `running-ai-video-locally-on-a-12gb-gpu` | 12GB 显卡跑本地 AI 视频:Wan 2.2 加 SeedVR2 的真实数据 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 7 | `ai` | `running-tts-as-a-service-with-token-sidecars` | 我用两个 Cron 容器跑了一年的语音合成服务 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 8 | `ai` | `slashing-agent-context-with-a-code-graph` | 别再把整个 Repo 喂给 Agent:用 Code Graph 斩断 Token 膨胀与遗忘陷阱 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 9 | `ai` | `using-chinese-llm-apis-from-malaysia` | 从马来西亚使用中国大模型 API:真正可行的方法 | ✅ 已通过 | Batch-01 | 2026-10-27 03:00 | 外部顾问审阅完成,已采纳并落地全量修改意见 |
| 10 | `ai` | `why-i-still-bought-a-local-gpu` | Claude 和 GPT 明明更强,我为什么还是买了 GPU 跑本地 AI | ⏳ 待审阅 | Batch-01 | - | |
| 11 | `ai` | `yue2-on-a-12gb-gpu-songs-that-lost-their-endings` | 为什么我的 AI 歌曲总是丢结尾:在 12GB 显卡上跑 YuE2 | ⏳ 待审阅 | Batch-01 | - | |
| 12 | `case-studies` | `ai-furniture-compositing-with-flux-kontext` | 我如何用 FLUX.1 Kontext 打造 AI 家具合成演示 | ⏳ 待审阅 | Batch-01 | - | |
| 13 | `case-studies` | `from-audio-to-a-4-minute-ai-music-video` | 从一首歌到 4 分钟完整 MV:我如何用 Gemini、AI 生成与 Filmora 剪辑串起全流程实操 | ⏳ 待审阅 | Batch-01 | - | |
| 14 | `case-studies` | `how-i-built-an-automated-music-video-pipeline` | 我是如何用 Remotion 与 Docker CDP 打造全自动 MV 生成与多语种油管发布流的 | ⏳ 待审阅 | Batch-01 | - | |
| 15 | `case-studies` | `how-i-built-the-digikedai-telegram-bot` | 我是如何构建 DigiKedai Telegram AI 客服机器人的 | ⏳ 待审阅 | Batch-01 | - | |
| 16 | `case-studies` | `how-i-host-this-blog` | 我是如何托管这个博客的:Astro、Gitea Actions 与自托管 CI/CD | ⏳ 待审阅 | Batch-01 | - | |
| 17 | `case-studies` | `how-i-made-my-own-songs-with-suno-ai` | 我用 Suno AI 做出自己的歌 | ⏳ 待审阅 | Batch-01 | - | |
| 18 | `case-studies` | `how-i-vetted-20-vps-providers-with-parallel-subagents` | 如何用并行子代理在三小时内核查 20 家 VPS 服务商 | ⏳ 待审阅 | Batch-02 | - | |
| 19 | `case-studies` | `loop-engineering-without-a-coding-agent` | 没有 Coding Agent 的 Loop Engineering:撑起我这套基础设施的 11 个定时任务 | ⏳ 待审阅 | Batch-02 | - | |
| 20 | `case-studies` | `one-prometheus-for-unraid-synology-and-a-vps` | 用一个 Prometheus 监控 unRaid、群晖 NAS 和 VPS:我做错的四件事 | ⏳ 待审阅 | Batch-02 | - | |
| 21 | `case-studies` | `running-production-infrastructure-solo` | 一个人跑生产基础设施:三台主机、162 个容器 | ⏳ 待审阅 | Batch-02 | - | |
| 22 | `case-studies` | `self-healing-digital-goods-entitlements` | 数字商品的自愈式访问控制:NocoDB、n8n 与 AList | ⏳ 待审阅 | Batch-02 | - | |
| 23 | `case-studies` | `self-hosted-speech-to-text-api` | 自建语音转文字服务:打字速度的 3 倍以上,而且数据不出内网 | ⏳ 待审阅 | Batch-02 | - | |
| 24 | `case-studies` | `self-hosted-tts-cosyvoice2-n8n-gpu` | 从云端转本地:在 12GB 显卡与群晖 N8N 上搭建中英混读高保真 TTS 语音栈 | ⏳ 待审阅 | Batch-02 | - | |
| 25 | `case-studies` | `self-hosting-mem0-memory-stack` | 我如何在自家 NAS 上自托管 Mem0 记忆栈(让 AI 助手有记忆) | ⏳ 待审阅 | Batch-02 | - | |
| 26 | `case-studies` | `shipping-an-ai-photo-editor-as-a-wordpress-plugin` | 把 AI 修图工具做成 WordPress 插件:22 个版本换来的经验 | ⏳ 待审阅 | Batch-02 | - | |
| 27 | `case-studies` | `syncing-a-self-improving-ai-agent-across-machines` | 在多台机器之间同步一个会自我学习的 AI Agent | ⏳ 待审阅 | Batch-02 | - | |
| 28 | `case-studies` | `the-single-writer-rule-for-coding-agents` | 代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构 | ⏳ 待审阅 | Batch-03 | - | |
| 29 | `case-studies` | `when-to-give-your-agent-physical-clicks` | 什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用) | ⏳ 待审阅 | Batch-03 | - | |
| 30 | `case-studies` | `why-i-built-a-video-knowledge-base` | 为什么我要做视频精读知识库:把收藏夹吃灰的短视频变成可检索笔记 | ⏳ 待审阅 | Batch-03 | - | |
| 31 | `case-studies` | `why-your-headless-browser-cant-scrape-everything` | 为什么你的无头浏览器不是万能爬虫 | ⏳ 待审阅 | Batch-03 | - | |
| 32 | `devops` | `authentik-css-greater-than-bug` | 一个字符让我的 authentik CSS 静默失效 | ⏳ 待审阅 | Batch-03 | - | |
| 33 | `devops` | `authentik-forward-auth-gate-wasnt-live` | 验证完全正确的 Forward-Auth 网关——其实并未上线 | ⏳ 待审阅 | Batch-03 | - | |
| 34 | `devops` | `authentik-major-upgrade-gotchas` | Authentik 从 2025.8 升级到 2026.8:那些没人提醒你会坏掉的部分 | ⏳ 待审阅 | Batch-03 | - | |
| 35 | `devops` | `automating-cyberpanel-without-the-ui` | 不用界面管理 CyberPanel:逆向一个没有文档的 v2 API | ⏳ 待审阅 | Batch-03 | - | |
| 36 | `devops` | `docker-reported-exit-0-the-oom-killer-disagreed` | Docker 报告退出码 0,内核却早已执行 OOM-Kill | ⏳ 待审阅 | Batch-03 | - | |
| 37 | `devops` | `every-player-stuttered-the-file-was-fine` | 每个播放器都在同一个视频上卡顿,而文件本身是无辜的 | ⏳ 待审阅 | Batch-03 | - | |
| 38 | `devops` | `hardening-a-tor-onion-service` | 加固 Tor 洋葱服务:真正重要的是什么 | ⏳ 待审阅 | Batch-03 | - | |
| 39 | `devops` | `it-reported-success-nothing-had-changed` | 它报告了成功,可实际上什么都没变 | ⏳ 待审阅 | Batch-04 | - | |
| 40 | `devops` | `n8n-v1-to-v2-upgrade-gotchas` | n8n v1 升级到 v2:一个日志文件里的七项废弃警告 | ⏳ 待审阅 | Batch-04 | - | |
| 41 | `devops` | `one-hostname-public-tracker-sso-dashboard` | 公开的埋点与要 SSO 的仪表盘,怎么共用一个域名 | ⏳ 待审阅 | Batch-04 | - | |
| 42 | `devops` | `passbolt-hang-three-failure-modes` | Passbolt 界面反复卡死——一个缺失的配置文件引发的三种故障模式 | ⏳ 待审阅 | Batch-04 | - | |
| 43 | `devops` | `patching-workbench-26-for-mariadb` | 当数据库客户端对你撒谎:为 MariaDB 修补 Workbench 26 | ⏳ 待审阅 | Batch-04 | - | |
| 44 | `devops` | `php2shell-patching-every-php-runtime-i-manage` | PHP2Shell:我管的每台机器,PHP 运行时全都中了 | ⏳ 待审阅 | Batch-04 | - | |
| 45 | `devops` | `putting-a-front-door-on-an-app-you-cant-modify` | 给自己改不了源码的应用装一扇前门 | ⏳ 待审阅 | Batch-04 | - | |
| 46 | `devops` | `read-only-nocodb-dashboard-for-a-remote-database` | 给另一台机器上的数据库配一个 NocoDB 只读看板 | ⏳ 待审阅 | Batch-04 | - | |
| 47 | `devops` | `replacing-rdpguard-with-ipban` | 用 IPBan 替换 RDPGuard:没人写出来的三个坑 | ⏳ 待审阅 | Batch-04 | - | |
| 48 | `devops` | `space-reclamation-wasnt-stuck-it-was-waiting` | 空间回收没卡住,它只是在等明天早上 09:29 | ⏳ 待审阅 | Batch-05 | - | |
| 49 | `devops` | `synology-spreadsheet-api-is-a-container` | Synology 电子表格 API 是一个容器,不是一个 API 端点 | ⏳ 待审阅 | Batch-05 | - | |
| 50 | `devops` | `that-dying-ssd-was-just-a-bad-sata-cable` | 我差点把这颗 SSD 送修——真正坏的是 SATA 数据线 | ⏳ 待审阅 | Batch-05 | - | |
| 51 | `devops` | `the-cause-was-trim-not-the-ssds` | 换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD | ⏳ 待审阅 | Batch-05 | - | |
| 52 | `devops` | `the-upstream-was-deleted-then-came-back-rewritten` | 上游被删了,然后带着一个重写版回来 | ⏳ 待审阅 | Batch-05 | - | |
| 53 | `devops` | `unraid-stop-array-hangs-on-swapfile` | Unraid 停止阵列时卡死的真相:Swapfile 锁住挂载点 — 附修复方案 | ⏳ 待审阅 | Batch-05 | - | |
| 54 | `devops` | `uploading-large-videos-to-youtube-via-cdp` | 用 CDP 自动化上传百兆视频到 YouTube:跨越 DevTools 内存墙的 Docker 归档法 | ⏳ 待审阅 | Batch-05 | - | |
| 55 | `devops` | `vetting-an-open-source-dependency-before-you-bet-on-it` | 如何在押注前验证一个开源依赖:我用的六项检查 | ⏳ 待审阅 | Batch-05 | - | |
| 56 | `devops` | `when-smart-says-healthy-but-your-raid-is-corrupting-data` | SMART 显示健康,RAID 却在悄悄损坏数据 | ⏳ 待审阅 | Batch-05 | - | |
| 57 | `devops` | `why-chrome-forgets-its-tabs-in-a-container` | 为什么容器里的 Chrome 会忘记标签页 —— 以及我是怎么修好的 | ⏳ 待审阅 | Batch-05 | - | |
| 58 | `devops` | `why-telegram-bot-notifications-die` | Telegram 机器人通知为什么发不出去:IPv6、DNS 和一个折腾了一整天的 400 | ⏳ 待审阅 | Batch-05 | - | |
| 59 | `devops` | `why-your-cdp-clicks-silently-fail` | 为什么你的 CDP 点击会静默失效:平滑滚动、最小化窗口与 JS 派发事件 | ⏳ 待审阅 | Batch-06 | - | |
| 60 | `devops` | `why-your-grafana-dashboard-shows-no-data` | Prometheus 明明有数据,Grafana 面板却全是 No data 的原因 | ⏳ 待审阅 | Batch-06 | - | |
| 61 | `devops` | `your-disk-full-alert-is-lying` | 你的“磁盘将满”告警在说谎:别再按百分比告警 | ⏳ 待审阅 | Batch-06 | - | |
| 62 | `engineering` | `adding-english-mode-to-a-chinese-only-web-app` | 浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式 | ⏳ 待审阅 | Batch-06 | - | |
| 63 | `engineering` | `debugging-remotion-video-jitter` | 排查 Remotion 视频抽搐:为什么在背景图挂载音频低频 Scale 会毁了转场 | ⏳ 待审阅 | Batch-06 | - | |
| 64 | `engineering` | `embedding-fonts-so-a-pdf-looks-the-same-everywhere` | 把字体嵌进 PDF,让它在任何机器上都长一样 | ⏳ 待审阅 | Batch-06 | - | |
| 65 | `engineering` | `jpa-version-field-lost-update` | 那个把静默丢失更新变成 409 的 JPA 字段:@Version 乐观锁 | ⏳ 待审阅 | Batch-06 | - | |
| 66 | `engineering` | `migrating-codeigniter-iis-to-openlitespeed` | 把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed | ⏳ 待审阅 | Batch-06 | - | |
| 67 | `engineering` | `scraping-bot-walled-marketplace-warm-browser-session` | 用「热浏览器会话」爬取有反爬墙的电商网站 | ⏳ 待审阅 | Batch-06 | - | |
| 68 | `engineering` | `two-ways-a-javascript-file-stops-working-and-looks-fine` | JavaScript 文件静默失效的两种方式:而页面看上去一切正常 | ⏳ 待审阅 | Batch-06 | - | |
| 69 | `engineering` | `verifying-a-pdf-report-page-by-page` | 逐页验证一份 19 页的 PDF 报告 | ⏳ 待审阅 | Batch-06 | - | |
| 70 | `engineering` | `why-500000-lines-of-commit-churn-only-left-230000-in-production` | 30 天提交 51.8 万行,生产环境只留下 23.5 万行 | ⏳ 待审阅 | Batch-07 | - | |
| 71 | `notes` | `adding-a-domain-to-someone-elses-mail-server` | 给别人家的邮件服务器加一个域名 | ⏳ 待审阅 | Batch-07 | - | |
| 72 | `notes` | `hello-world` | 你好,世界 —— 关于这个博客 | ⏳ 待审阅 | Batch-07 | - | |
| 73 | `notes` | `my-backup-was-using-someone-elses-api-quota` | 我的备份爬得比爬虫还慢,因为我在用别人的 API 配额 | ⏳ 待审阅 | Batch-07 | - | |
| 74 | `notes` | `nocodb-sso-is-a-licensed-feature` | NocoDB SSO 是授权功能:环境变量不会告诉你的事 | ⏳ 待审阅 | Batch-07 | - | |
| 75 | `notes` | `reading-a-containers-own-api-docs` | 与其看供应商文档,不如直接读容器自带的 API 文档 | ⏳ 待审阅 | Batch-07 | - | |
| 76 | `notes` | `smartctl-exit-code-32-skips-the-disks-that-matter` | smartctl 退出码 32:专门跳过你最该看的硬盘的那个“错误” | ⏳ 待审阅 | Batch-07 | - | |
| 77 | `notes` | `stop-writing-agent-prompts-for-deterministic-work` | 确定性的活,别再用 Agent Prompt 去跑 | ⏳ 待审阅 | Batch-07 | - | |
| 78 | `notes` | `synology-api-401-with-the-correct-password` | 密码正确,Synology API 登录却返回 401,问题出在哪 | ⏳ 待审阅 | Batch-07 | - | |
| 79 | `notes` | `the-nocodb-attachment-that-wouldnt-update` | NocoDB 附件字段怎么改都不生效,和一个漏网的正则 | ⏳ 待审阅 | Batch-07 | - | |
| 80 | `notes` | `upgrading-codeigniter-46-to-47` | CodeIgniter 4.6 升级到 4.7:官方指南没说到的破坏性变更 | ⏳ 待审阅 | Batch-07 | - | |
| 81 | `notes` | `why-is-my-tts-model-repeating-percent` | 为什么本地语音合成一直在念 “Percent”?语音管线里的 URL 编码与沙盒避坑记 | ⏳ 待审阅 | Batch-07 | - | |
| 82 | `notes` | `why-multi-agent-coding-fails` | 为什么多个 AI 一起写代码总会烂尾?传话失真与隐式决策困境 | ⏳ 待审阅 | Batch-07 | - | |
| 83 | `notes` | `why-your-windows-mcp-calls-silently-fail` | 为什么你的 Windows 桌面 MCP 调用会静默失败:攻破 10 秒空闲门禁与避坑实录 | ⏳ 待审阅 | Batch-07 | - | |
| 84 | `tutorials` | `calibrating-intel-quick-sync-hevc-quality` | 校准 Intel Quick Sync HEVC:28 与 30 之间的悬崖,和一个毫无作用的 preset | ⏳ 待审阅 | Batch-07 | - | |
| 85 | `tutorials` | `does-hevc-actually-shrink-your-files` | 转成 HEVC 到底会不会让文件变小?我实测了 | ⏳ 待审阅 | Batch-07 | - | |
| 86 | `tutorials` | `generating-full-length-songs-with-yue2-comfyui` | 用 YuE2 BF16 生成完整长歌:突破时长限制与音频硬截断 | ⏳ 待审阅 | Batch-09 | - | |
| 87 | `tutorials` | `generating-subtitles-for-songs-without-timestamps` | 给没有时间戳的歌曲自动生成字幕 | ⏳ 待审阅 | Batch-10 | - | |
| 88 | `tutorials` | `how-to-verify-a-hosting-provider-before-you-buy` | 购买主机前,如何核实一家服务商的真实情况 | ⏳ 待审阅 | Batch-10 | - | |
| 89 | `web3` | `chainlink-vrf-v2-lottery-contract` | 一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约 | ⏳ 待审阅 | Batch-10 | - | |
| 90 | `web3` | `fully-on-chain-svg-nfts` | 全链上 SVG NFT:把艺术作品放进合约里 | ⏳ 待审阅 | Batch-10 | - | |
| 91 | `web3` | `why-my-on-chain-nft-art-changed-on-windows` | 为什么在 Windows 上克隆后,我的链上 NFT 图像变了 | ⏳ 待审阅 | Batch-10 | - | |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""
Blog Post Consultant Review Management Tool
- Tracks review status of all blog posts (pending, approved, needs_revision)
- Automatically partitions unapproved posts into batches (< 200k chars)
- Injects background, constraints, persona rules, and purpose into each batch
- Allows 1-command approval so approved posts are permanently skipped next time
"""
import os
import sys
import json
import glob
import re
import argparse
from datetime import datetime
REPO_ROOT = "D:/dev/hoelee-blog"
POSTS_DIR = os.path.join(REPO_ROOT, "src/content/posts")
ZH_POSTS_DIR = os.path.join(POSTS_DIR, "zh")
REVIEW_DIR = os.path.join(REPO_ROOT, "docs/consultant-review")
BATCHES_DIR = os.path.join(REVIEW_DIR, "batches")
STATUS_FILE = os.path.join(REVIEW_DIR, "audit-tracker.json")
TRACKER_MD = os.path.join(REVIEW_DIR, "AUDIT-TRACKER.md")
BRIEF_MD = os.path.join(REVIEW_DIR, "00-外部顾问审阅须知与背景规范.md")
BACKGROUND_HEADER = """# 外部顾问审阅前置须知与工程背景规范
> **本文档目标**:本批次包含 `blog.hoelee.com` 的待审阅技术文章全文。请外部顾问在审阅正文与技术主张时,以此规范为最高评估准则。
## 1. 站点定位与双重业务目的
- **受众 A(技术招聘官 / 资深技术主管)**:寻找具备 Senior 全栈 + DevOps + AI 智能体闭环能力的架构工程师。文章定位为**“不可伪造的工程实操交付证据(Proof of Expertise)”**,要求代码真实、日志可查、有架构权衡与技术深度。
- **受众 B(中小企业主 SME / 商业客户)**:寻找能提供系统研发、私有化部署、24/7 AI 获客系统、高可用基础设施的技术顾问。文章需清晰阐述业务痛点、商业 ROI、上线效率与交付保障。
## 2. 核心人设铁律与工程硬约束(必须严格遵守)
1. **彻底杜绝业余爱好者/玩具口吻**:
- ❌ **绝对禁用词**:`homelab`, `home lab`, `家用`, `小主机`, `玩具`, `折腾`。
- ✅ **标准替代词**:`the infrastructure I manage`(我管理的私有基础设施)、`self-hosted production node`(自托管生产节点)、`on-premise host`(私有化服务器)。
2. **严禁捏造虚假指标与性能数字**:
- 所有 benchmark 数据必须有明确的测试基准与单设备限制。
- 严格区分“耗时减少比例”与“吞吐加速倍数”(例如耗时从 352s 降至 218s 是耗时减少 38.0%,吞吐提升 1.61×,严禁偷换概念混淆)。
- 时间戳精度与物理帧对齐:视频帧率上限(如 30 fps 单帧 33.3ms)不可妄称“微秒级精确”。
3. **去模板化叙事与真实物理锚点**:
- 拒绝千篇一律的 AI 模板味。文章采用三大叙事架构:① 故障排查谜案(Symptom → False Lead → Kernel Evidence → Fix);② 商业交付复盘(Client Bottleneck → Trade-offs → State Machine → ROI);③ 架构演进对比(V1 Naive → Concurrency Crash → V2 Redesign)。
- 每篇文章必须包含至少一个物理真实世界锚点(主机真实硬件规格、生产维护窗口、真实业务场景)。
4. **代码与配置展示规范**:
- 超过 20 行的配置或日志采用 HTML `<details><summary>` 原生折叠,`<summary>` 必须精准标注文档名与行数。
## 3. 顾问重点开刀维度
- [ ] **事实与数字可核性**:技术判断与性能数据是否自洽?有无过度夸大?
- [ ] **人设与语调纯洁性**:有无遗漏的 hobbyist/homelab 口吻?
- [ ] **双轨转化引导有效性**:是否有清晰的业务价值提炼与行动召唤(CTA)?
- [ ] **代码与生产安全性**:有无未脱敏的凭据、绝对内部私密路径或不安全的权限配置?
"""
def load_or_init_tracker():
tracker = {}
if os.path.exists(STATUS_FILE):
with open(STATUS_FILE, "r", encoding="utf-8") as f:
try:
tracker = json.load(f)
except Exception:
tracker = {}
# Scan all posts in POSTS_DIR
en_files = sorted(glob.glob(os.path.join(POSTS_DIR, "*.md")))
for en_path in en_files:
slug = os.path.splitext(os.path.basename(en_path))[0]
zh_path = os.path.join(ZH_POSTS_DIR, f"{slug}.md")
with open(en_path, "r", encoding="utf-8") as f:
en_content = f.read()
zh_content = ""
if os.path.exists(zh_path):
with open(zh_path, "r", encoding="utf-8") as f:
zh_content = f.read()
# Extract category, title, pubDate safely without regex multiline newlines
category = "uncategorized"
title = slug
zh_title = slug
pub_date = "unknown"
for line in en_content.splitlines():
if line.startswith("category:"):
category = line.split(":", 1)[1].strip().strip('"\'')
elif line.startswith("title:"):
title = line.split(":", 1)[1].strip().strip('"\'')
elif line.startswith("pubDate:"):
pub_date = line.split(":", 1)[1].strip().strip('"\'')
for line in zh_content.splitlines():
if line.startswith("title:"):
zh_title = line.split(":", 1)[1].strip().strip('"\'')
if slug not in tracker:
tracker[slug] = {
"slug": slug,
"title": title,
"zh_title": zh_title,
"category": category,
"pub_date": pub_date,
"status": "pending", # pending | approved | rejected | needs_revision
"batch_id": None,
"approved_at": None,
"notes": "",
"en_chars": len(en_content),
"zh_chars": len(zh_content)
}
else:
# Update dynamic fields
tracker[slug]["title"] = title
tracker[slug]["zh_title"] = zh_title
tracker[slug]["category"] = category
tracker[slug]["pub_date"] = pub_date
tracker[slug]["en_chars"] = len(en_content)
tracker[slug]["zh_chars"] = len(zh_content)
save_tracker(tracker)
return tracker
def save_tracker(tracker):
os.makedirs(REVIEW_DIR, exist_ok=True)
with open(STATUS_FILE, "w", encoding="utf-8") as f:
json.dump(tracker, f, ensure_ascii=False, indent=2)
render_tracker_markdown(tracker)
def render_tracker_markdown(tracker):
total = len(tracker)
approved = sum(1 for p in tracker.values() if p.get("status") == "approved")
pending = total - approved
lines = [
"# 全站文章顾问审阅状态总控台 (Audit Tracker)",
"",
f"> **最后同步时间**:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ",
f"> **全站总文章数**:{total} 篇 | **已审批完成**:{approved} 篇 ({approved/total*100:.1f}%) | **待审阅**:{pending} 篇",
"",
"## 一、操作命令速查",
"- `python scripts/manage_review.py list` — 查看待审与已审概览",
"- `python scripts/manage_review.py approve <slug> [--notes '顾问通过']` — 标记单篇通过",
"- `python scripts/manage_review.py approve-batch <batch_num>` — 标记整个批次通过",
"- `python scripts/manage_review.py generate` — 重新分批(自动排除已审批文章,每批 < 200k 字)",
"",
"## 二、文章审核状态总表",
"| # | 分类 | 文章 Slug | 中文标题 | 状态 | 所在批次 | 审批时间 | 顾问备注 |",
"|---|---|---|---|:---:|:---:|:---:|---|"
]
sorted_posts = sorted(tracker.values(), key=lambda x: (x.get("category", ""), x.get("slug", "")))
for idx, p in enumerate(sorted_posts, 1):
status_icon = "✅ 已通过" if p.get("status") == "approved" else "⏳ 待审阅"
if p.get("status") == "needs_revision":
status_icon = "⚠️ 需修改"
batch = p.get("batch_id") or "未分配"
approved_time = p.get("approved_at") or "-"
notes = (p.get("notes") or "").replace("|", "/")
lines.append(f"| {idx} | `{p['category']}` | `{p['slug']}` | {p['zh_title']} | {status_icon} | {batch} | {approved_time} | {notes} |")
with open(TRACKER_MD, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
def write_master_brief():
with open(BRIEF_MD, "w", encoding="utf-8") as f:
f.write(BACKGROUND_HEADER + """
## 4. 博客 7 大分类与内容策略参考
- `case-studies` (商业案例):求职与获客核心武器,以真实商业交付和解决瓶颈为核心。
- `devops` (私有基建):高并发网关、Docker、Traefik、存储阵列与监控,最强差异化资产。
- `ai` (人工智能与智能体):围绕自动化工作流、LiteLLM 网关、状态机防幻觉与本地私有大模型。
- `engineering` (系统架构与后端):Java/Spring、PHP/CodeIgniter、TS/React 的硬核实战。
- `tutorials` (技术教程):面向学习者的深度实战指南。
- `notes` (排障速记):轻量级踩坑手记与高频高价值 gotcha。
- `web3` (智能合约):Solidity/Foundry 严谨实践,诚实表达学习与验证阶段。
## 5. 既有核心参考文档清单(仓库内置)
- `docs/content-guide.md` — 博客内容定位、选题规范、反模式与 i18n 策略。
- `docs/post-publishing-sop-v2.md` — 5 步叙事模型、代码折叠与 Bento 卡片技术规范。
- `docs/consultant-review-2026-10-26-supplement.md` — 上一轮顾问诊断全景资料包。
""")
def generate_batches(mode="bilingual", max_chars=180000):
tracker = load_or_init_tracker()
write_master_brief()
os.makedirs(BATCHES_DIR, exist_ok=True)
# Clean previous batch files
for old_b in glob.glob(os.path.join(BATCHES_DIR, "batch-*.md")):
try:
os.remove(old_b)
except Exception:
pass
# Filter unapproved posts
pending_posts = [p for p in tracker.values() if p.get("status") != "approved"]
if not pending_posts:
print("All posts are approved! No new batches needed.")
return
# Sort deterministically: category, then slug
pending_posts.sort(key=lambda x: (x.get("category", ""), x.get("slug", "")))
batches = []
current_batch = []
current_chars = 0
for p in pending_posts:
slug = p['slug']
en_path = os.path.join(POSTS_DIR, f"{slug}.md")
zh_path = os.path.join(ZH_POSTS_DIR, f"{slug}.md")
with open(en_path, "r", encoding="utf-8") as f:
en_text = f.read()
zh_text = ""
if os.path.exists(zh_path):
with open(zh_path, "r", encoding="utf-8") as f:
zh_text = f.read()
if mode == "bilingual":
item_chars = len(en_text) + len(zh_text)
if item_chars > max_chars:
# Split giant post into ZH and EN individual sub-batches
if current_batch:
batches.append(current_batch)
current_batch = []
current_chars = 0
batches.append([{
"p": p, "mode": "zh_only", "slug": slug + "-zh",
"content_zh": zh_text, "content_en": "",
"chars": len(zh_text)
}])
batches.append([{
"p": p, "mode": "en_only", "slug": slug + "-en",
"content_zh": "", "content_en": en_text,
"chars": len(en_text)
}])
continue
item = {
"p": p, "mode": "bi", "slug": slug,
"content_zh": zh_text, "content_en": en_text,
"chars": item_chars
}
elif mode == "zh":
item_chars = len(zh_text)
item = {
"p": p, "mode": "zh_only", "slug": slug,
"content_zh": zh_text, "content_en": "",
"chars": item_chars
}
else: # en
item_chars = len(en_text)
item = {
"p": p, "mode": "en_only", "slug": slug,
"content_zh": "", "content_en": en_text,
"chars": item_chars
}
if current_chars + item_chars > max_chars and current_batch:
batches.append(current_batch)
current_batch = [item]
current_chars = item_chars
else:
current_batch.append(item)
current_chars += item_chars
if current_batch:
batches.append(current_batch)
# Write each batch file
for b_idx, batch_items in enumerate(batches, 1):
batch_id = f"Batch-{b_idx:02d}"
batch_char_sum = sum(x['chars'] for x in batch_items)
batch_filename = f"batch-{b_idx:02d}.md"
batch_filepath = os.path.join(BATCHES_DIR, batch_filename)
lines = [
f"# 博客文章顾问审阅资料包 — {batch_id}",
"",
f"> **批次编号**:`{batch_id}` ",
f"> **包含文章数**:{len(batch_items)} 篇 ",
f"> **本批次总字符量**:{batch_char_sum:,} 字(严格限制 < 200,000 字) ",
f"> **模式**:{mode.upper()} ",
f"> **生成时间**:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ",
"",
BACKGROUND_HEADER,
"",
"---",
"",
"## 本批次文章目录索引",
""
]
for i, item in enumerate(batch_items, 1):
p = item['p']
lines.append(f"{i}. [{p['category']}] **{p['zh_title']}** (`{item['slug']}`) — ~{item['chars']:,} 字符")
lines.append("")
lines.append("---")
lines.append("")
for i, item in enumerate(batch_items, 1):
p = item['p']
slug = p['slug']
tracker[slug]["batch_id"] = batch_id
lines.append(f"## 文章 {i:02d} / {len(batch_items):02d}:{p['zh_title']}")
lines.append(f"- **Slug**: `{slug}`")
lines.append(f"- **英文标题**: {p['title']}")
lines.append(f"- **分类**: `{p['category']}` | **发布日期**: {p['pub_date']}")
lines.append(f"- **线上地址**: https://blog.hoelee.com/posts/{slug}/ (EN) | https://blog.hoelee.com/posts/zh/{slug}/ (ZH)")
lines.append(f"- **本篇审核状态**: ⏳ 待审核 (Pending)")
lines.append("")
lines.append("### ✍️ 顾问审阅意见区 (Review Feedback)")
lines.append("```text")
lines.append("【审阅结论】:[ ] 通过 [ ] 需微调 [ ] 需重构")
lines.append("【事实与数字核对】:")
lines.append("【人设与用词卫生】:")
lines.append("【CRO与转化建议】:")
lines.append("```")
lines.append("")
if item["content_zh"]:
lines.append("### 🇨🇳 中文版原文 (Chinese Markdown Twin)")
lines.append("````markdown")
lines.append(item["content_zh"].strip())
lines.append("````")
lines.append("")
if item["content_en"]:
lines.append("### 🇬🇧 英文版原文 (English Markdown Primary)")
lines.append("````markdown")
lines.append(item["content_en"].strip())
lines.append("````")
lines.append("")
lines.append("---")
lines.append("")
with open(batch_filepath, "w", encoding="utf-8") as bf:
bf.write("\n".join(lines) + "\n")
print(f"Generated {batch_id}: {len(batch_items)} posts, {batch_char_sum:,} chars -> {batch_filepath}")
save_tracker(tracker)
print(f"\n[OK] Generated {len(batches)} batches in {BATCHES_DIR}. Total pending posts processed: {len(pending_posts)}")
def approve_posts(slugs, notes=None):
tracker = load_or_init_tracker()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
count = 0
for slug in slugs:
if slug in tracker:
tracker[slug]["status"] = "approved"
tracker[slug]["approved_at"] = now_str
if notes:
tracker[slug]["notes"] = notes
count += 1
print(f"Approved: {slug}")
else:
print(f"Warning: Slug '{slug}' not found in tracker.")
save_tracker(tracker)
print(f"Successfully marked {count} post(s) as approved.")
def approve_batch(batch_num, notes=None):
tracker = load_or_init_tracker()
target_batch = f"Batch-{int(batch_num):02d}"
matched_slugs = [slug for slug, p in tracker.items() if p.get("batch_id") == target_batch]
if not matched_slugs:
print(f"No posts found belonging to {target_batch}.")
return
approve_posts(matched_slugs, notes=notes or f"Approved via {target_batch}")
def reset_posts(slugs):
tracker = load_or_init_tracker()
count = 0
for slug in slugs:
if slug in tracker:
tracker[slug]["status"] = "pending"
tracker[slug]["approved_at"] = None
tracker[slug]["notes"] = ""
count += 1
print(f"Reset to pending: {slug}")
save_tracker(tracker)
print(f"Successfully reset {count} post(s).")
def reset_all():
tracker = load_or_init_tracker()
for slug in tracker:
tracker[slug]["status"] = "pending"
tracker[slug]["approved_at"] = None
tracker[slug]["notes"] = ""
save_tracker(tracker)
print(f"Successfully reset all {len(tracker)} posts to pending.")
def main():
parser = argparse.ArgumentParser(description="Blog Post Consultant Review Management")
subparsers = parser.add_subparsers(dest="command")
# init
subparsers.add_parser("init", help="Initialize or update tracker from markdown files")
# generate
gen_p = subparsers.add_parser("generate", help="Generate review batches (< 200k chars each)")
gen_p.add_argument("--mode", choices=["bilingual", "zh", "en"], default="bilingual", help="Content mode")
gen_p.add_argument("--max-chars", type=int, default=180000, help="Max characters per batch")
# approve
app_p = subparsers.add_parser("approve", help="Mark slug(s) as approved")
app_p.add_argument("slugs", nargs="+", help="Post slug(s) to approve")
app_p.add_argument("--notes", default="Approved by consultant", help="Consultant review note")
# approve-batch
app_b = subparsers.add_parser("approve-batch", help="Mark all posts in a batch as approved")
app_b.add_argument("batch_num", type=int, help="Batch number (e.g. 1)")
app_b.add_argument("--notes", default="Approved by consultant", help="Consultant review note")
# reset
rst_p = subparsers.add_parser("reset", help="Reset slug(s) to pending")
rst_p.add_argument("slugs", nargs="+", help="Post slug(s) to reset")
# reset-all
subparsers.add_parser("reset-all", help="Reset all posts to pending")
# list
subparsers.add_parser("list", help="List summary of review status")
args = parser.parse_args()
if args.command == "init":
t = load_or_init_tracker()
print(f"Tracker initialized with {len(t)} posts.")
elif args.command == "generate":
generate_batches(mode=args.mode, max_chars=args.max_chars)
elif args.command == "approve":
approve_posts(args.slugs, notes=args.notes)
elif args.command == "approve-batch":
approve_batch(args.batch_num, notes=args.notes)
elif args.command == "reset":
reset_posts(args.slugs)
elif args.command == "reset-all":
reset_all()
elif args.command == "list" or not args.command:
tracker = load_or_init_tracker()
total = len(tracker)
approved = sum(1 for p in tracker.values() if p.get("status") == "approved")
pending = total - approved
print(f"=== Blog Post Consultant Review Status ===")
print(f"Total Posts: {total} | Approved: {approved} | Pending: {pending}")
by_cat = {}
for p in tracker.values():
cat = p['category']
by_cat.setdefault(cat, [0, 0])
if p.get('status') == 'approved':
by_cat[cat][0] += 1
else:
by_cat[cat][1] += 1
for cat, (app, pend) in sorted(by_cat.items()):
print(f" {cat:15s}: {app:2d} approved / {pend:2d} pending")
if __name__ == "__main__":
main()