<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
            <title type="text">个人资料</title>
            <subtitle type="text">用心若镜 安心若命</subtitle>
    <updated>2026-08-06T23:04:35+08:00</updated>
        <id>https://maifeipin.com</id>
        <link rel="alternate" type="text/html" href="https://maifeipin.com" />
        <link rel="self" type="application/atom+xml" href="https://maifeipin.com/atom.xml" />
    <rights>Copyright © 2026, 个人资料</rights>
    <generator uri="https://halo.run/" version="1.5.4">Halo</generator>
            <entry>
                <title><![CDATA[ 有道云笔记 → HedgeDoc v2 迁移指南]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/you-dao-yun-bi-ji-hedgedocv2-qie-yi-zhi-nan" />
                <id>tag:https://maifeipin.com,2026-08-06:you-dao-yun-bi-ji-hedgedocv2-qie-yi-zhi-nan</id>
                <published>2026-08-06T23:00:01+08:00</published>
                <updated>2026-08-06T23:04:35+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>本文档记录将有道云笔记数据迁移到自建 HedgeDoc v2 fork 的完整过程，涵盖数据源、迁移工具、HedgeDoc 代码修改、部署配置、导入流程及已知问题。任何人按本文档操作即可复现整个迁移。</p><hr /><h2 id="1.-%E9%A1%B9%E7%9B%AE%E8%83%8C%E6%99%AF" tabindex="-1">1. 项目背景</h2><p>目标是将积累多年的有道云笔记（含 Markdown 正文、图片、附件、文件夹层级）整体迁移到自建部署的 HedgeDoc v2。HedgeDoc 原生仅支持图片上传，不支持任意类型附件，因此需要在其基础上做 fork 改造，使附件（PDF、Excel、压缩包等）也能上传并在笔记中通过链接访问。</p><p>迁移数据规模较大（约 1500+ 篇笔记、数千张图片、数百个附件），因此还涉及批量导入脚本、限流豁免、大文件上传等工程化处理。</p><hr /><h2 id="2.-%E6%95%B0%E6%8D%AE%E6%BA%90%E8%AF%B4%E6%98%8E" tabindex="-1">2. 数据源说明</h2><p>迁移过程对比了两种数据来源，取长补短。</p><h3 id="2.1-pull-%E6%95%B0%E6%8D%AE%EF%BC%88youdaonote-pull-%E5%B7%A5%E5%85%B7%E6%8B%89%E5%8F%96%EF%BC%89" tabindex="-1">2.1 PULL 数据（youdaonote-pull 工具拉取）</h3><ul><li>工具：<code>youdaonote-pull</code>，通过登录 cookie 直拉有道云笔记 API。</li><li>输出结构：<pre><code class="language-">youdao_md_full/├── &lt;分类目录&gt;/              # 对应有道的文件夹层级│   ├── 笔记名.md            # Markdown 正文│   ├── images/              # 笔记内引用的图片（保留有道原始哈希文件名）│   └── attachments/         # 笔记内引用的附件（按引用下载，文件名带 UUID 前缀）</code></pre></li><li>特点：正文为可编辑 Markdown，图片/附件按笔记引用下载；未引用的资源不会拉取。</li></ul><h3 id="2.2-app-%E5%AF%BC%E5%87%BA%EF%BC%88%E6%9C%89%E9%81%93%E5%AE%98%E6%96%B9-app-%E5%AF%BC%E5%87%BA%EF%BC%89" tabindex="-1">2.2 APP 导出（有道官方 APP 导出）</h3><ul><li>来源：有道云笔记 APP「导出全部笔记」功能。</li><li>输出结构：<pre><code class="language-">your_mail@163.com_&lt;时间戳&gt;/├── 笔记名.note.pdf           # 每篇笔记用 PDF 渲染（不可编辑）├── 笔记名.note.attach/        # 该笔记的全部附件，保留纯原名（无 UUID 前缀）│   └── 附件文件.xlsx└── ...</code></pre></li><li>特点：正文是不可编辑的 PDF；附件目录 <code>.note.attach</code> 包含该笔记的<strong>全部</strong>附件（不仅限于被引用的），且文件名为纯原名。</li></ul><h3 id="2.3-%E6%95%B0%E6%8D%AE%E5%AF%B9%E9%BD%90%E5%88%86%E6%9E%90" tabindex="-1">2.3 数据对齐分析</h3><p>使用 <code>tools/youdao-import/compare_exports.py</code> 和 <code>compare_attachments.py</code> 对两种导出做对齐分析，结果如下（数量为本次迁移实测）：</p><table><thead><tr><th>维度</th><th>PULL 数据</th><th>APP 导出</th></tr></thead><tbody><tr><td>笔记正文</td><td>1555 篇 Markdown</td><td>1264 个 PDF（<code>.note.pdf</code> / <code>.clip.pdf</code>）</td></tr><tr><td>图片</td><td>5744 个（images/ 目录）</td><td>—</td></tr><tr><td>附件</td><td>365 个引用（attachments/ 目录）</td><td>400 个（<code>.note.attach</code> 目录）</td></tr></tbody></table><ul><li>PULL 笔记数（1555）多于 APP PDF 数（1264），因为 PULL 包含收藏类短笔记，APP 导出未包含。</li><li>APP 附件总数（400）多于 PULL 附件引用（365），说明 APP 导出能补充 PULL 未引用的附件。</li></ul><hr /><h2 id="3.-%E8%BF%81%E7%A7%BB%E5%B7%A5%E5%85%B7" tabindex="-1">3. 迁移工具</h2><p>所有迁移工具位于 <code>tools/youdao-import/</code> 目录。</p><h3 id="3.1-import_youdao_md.py-%E2%80%94-%E4%B8%BB%E5%AF%BC%E5%85%A5%E8%84%9A%E6%9C%AC" tabindex="-1">3.1 <code>import_youdao_md.py</code> — 主导入脚本</h3><p>将 <code>youdaonote-pull</code> 输出的 Markdown 目录树批量导入 HedgeDoc，同时处理图片、附件与文件夹结构。</p><p><strong>核心能力：</strong></p><ul><li><strong>双通道认证</strong>：私有 API（文件夹创建/移动、token 管理）走 session 登录；公开 API（笔记增删、媒体上传）走 Bearer token。</li><li><strong>文件夹结构重建</strong>：递归遍历源目录，按相对路径在 HedgeDoc 中创建同名文件夹树，并把笔记移动到对应文件夹。</li><li><strong>图片上传</strong>：解析 Markdown 中的 <code>![alt](images/xxx)</code> 引用，将本地图片上传到 HedgeDoc 媒体库，再用返回的 <code>/media/&lt;uuid&gt;</code> 替换原引用。</li><li><strong>附件上传</strong>：解析 <code>attachments/xxx</code> 引用，定位本地附件文件（支持 URL 解码、笔记级与根级 attachments 目录查找），上传后替换为 <code>/media/&lt;uuid&gt;</code> 链接。</li><li><strong>幂等/续跑</strong>：通过 alias 检测笔记是否已存在，已存在则跳过（加 <code>--force</code> 可覆盖）。</li><li><strong>限流重试</strong>：对 429 响应按 <code>Retry-After</code> 退避重试，对超时按指数退避重试。</li><li><strong>slug 生成</strong>：笔记名经 <code>slugify()</code> 转为 URL 安全别名（中文等非 ASCII 会 fallback 到 <code>note-&lt;md5前8位&gt;</code>）。</li></ul><p><strong>用法：</strong></p><pre><code class="language-bash"># 方式 A：用账号密码登录（内部会自动创建 token）python -u import_youdao_md.py youdao_md_full \  --base-url http://localhost:3031 \  --username yourname@domain.com \  --password yourpassword# 方式 B：直接用已有 token（避免重复登录触发限流）python -u import_youdao_md.py youdao_md_full \  --base-url http://localhost:3031 \  --token &lt;TOKEN&gt;# 强制覆盖已存在的笔记python -u import_youdao_md.py youdao_md_full --base-url &lt;URL&gt; --token &lt;TOKEN&gt; --force</code></pre><p><strong>输出统计示例：</strong></p><pre><code class="language-">=== Done in 1234s ===Notes: 1500, Folders: 80, Images: 5700, Attachments: 350, Skipped: 55Failures: 19  note-xxx: attachment not found: attachments/xxx.pdf  ...</code></pre><h3 id="3.2-load_image.sh-%E2%80%94-ghcr-%E9%95%9C%E5%83%8F%E6%89%8B%E5%8A%A8%E4%B8%8B%E8%BD%BD%E8%84%9A%E6%9C%AC" tabindex="-1">3.2 <code>load_image.sh</code> — ghcr 镜像手动下载脚本</h3><p>用于在 vps1 上手动拉取 <code>ghcr.io/maifeipin/hedgedoc/{backend|frontend}:develop</code> 镜像并 <code>docker load</code>。</p><p><strong>为什么需要：</strong> <a href="http://ghcr.io" target="_blank">ghcr.io</a> 的 blob 下载会 307 重定向到 <code>pkg-containers.githubusercontent.com</code>，该 CDN 域名在部分网络下会被 reset。本脚本通过本地 <code>socks5h://127.0.0.1:18988</code> 代理逐层下载 blob，绕过 reset。</p><p><strong>流程：</strong></p><ol><li>通过代理向 ghcr 申请 pull token；</li><li>拉取多架构 image index，解析出 <code>linux/amd64</code> 的 manifest digest；</li><li>拉取该 manifest，提取 config digest 与各 layer digest；</li><li>逐层下载 config 与 layer blob 到临时目录（带重试，校验非空）；</li><li>按 <code>docker load</code> 期望格式拼装 <code>manifest.json</code>（含 RepoTags），打包后 <code>docker load</code>。</li></ol><p><strong>用法：</strong></p><pre><code class="language-bash">bash load_image.sh backendbash load_image.sh frontend</code></pre><h3 id="3.3-get_token.sh-%E2%80%94-%E5%9C%A8-vps1-%E8%8E%B7%E5%8F%96-api-token" tabindex="-1">3.3 <code>get_token.sh</code> — 在 vps1 获取 API token</h3><p><strong>为什么需要：</strong> HedgeDoc 对登录接口（<code>/api/private/auth/local/login</code>）有外部 IP 限流，从外部 IP 反复登录会触发 429。在 vps1 本机 <code>127.0.0.1</code> 直接请求可绕过外部 IP 限流。</p><p><strong>流程：</strong></p><ol><li>向 <code>http://127.0.0.1:3031/api/private/csrf/token</code> 获取 CSRF token 并保存 cookie；</li><li>携带 CSRF header + cookie 调用登录接口；</li><li>调用 <code>/api/private/tokens</code> 创建一个有效期至 2027-08-04 的 token。</li></ol><p><strong>用法：</strong></p><pre><code class="language-bash"># 在 vps 上执行bash get_token.sh# 输出的 token 可直接传给 import_youdao_md.py --token</code></pre><h3 id="3.4-%E6%8F%90%E4%BA%A4%E5%89%8D%E6%A3%80%E6%9F%A5%E4%B8%8E-ci-%E6%B5%81%E7%A8%8B" tabindex="-1">3.4 提交前检查与 CI 流程</h3><p>代码推送到 <code>develop</code> 分支后，GitHub Actions 会自动运行 5 个 CI 检查，<strong>必须全部通过</strong>才能部署新镜像：</p><table><thead><tr><th>CI 检查</th><th>工具</th><th>说明</th></tr></thead><tbody><tr><td>Lint and check format</td><td>Oxlint + Oxfmt</td><td>代码规范与格式检查。Oxlint 检查 lint 规则（0 warnings, 0 errors），Oxfmt 检查代码格式是否正确</td></tr><tr><td>REUSE Compliance Check</td><td>REUSE Tool</td><td>开源许可证合规检查</td></tr><tr><td>Run tests &amp; build</td><td>Jest + Turborepo</td><td>后端单元测试 + 前端组件测试 + 构建</td></tr><tr><td>E2E Tests</td><td>Jest + NestJS</td><td>后端端到端测试（含媒体上传、笔记 CRUD 等）</td></tr><tr><td>Docker</td><td>Docker Buildx</td><td>构建 `<a href="http://ghcr.io/maifeipin/hedgedoc/" target="_blank">ghcr.io/maifeipin/hedgedoc/</a>{backend</td></tr></tbody></table><p><strong>本地预检命令</strong>（在仓库根目录执行，确保提交前通过 CI）：</p><pre><code class="language-bash"># 格式化修复（自动修复格式问题）fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs format:fix# 检查格式（不修改文件，CI 会跑这个）fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs format# Lint 检查（CI 会跑这个）fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs lint# 构建fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs build# 全量测试fnm exec --using=v24.13.0 -- node .yarn/releases/yarn-4.12.0.cjs test</code></pre><p><strong>CI 查看与监控命令：</strong></p><pre><code class="language-bash"># 查看最近 CI 运行状态gh run list -R maifeipin/hedgedoc --limit 6# 等待某个 run 完成（0=成功，非 0=失败）gh run watch &lt;run-id&gt; --exit-status# 查看失败的 CI 日志gh run view &lt;run-id&gt; -R maifeipin/hedgedoc --log-failed</code></pre><p><strong>常见 CI 失败原因与修复：</strong></p><table><thead><tr><th>失败类型</th><th>原因</th><th>修复</th></tr></thead><tbody><tr><td>Oxfmt format 失败</td><td>代码格式不符合规范</td><td>运行 <code>format:fix</code> 后重新提交</td></tr><tr><td>Oxlint warnings</td><td>如 <code>&lt;a&gt;</code> 缺 <code>href</code>、<code>useEffect</code> 缺依赖、<code>&lt;button&gt;</code> 加 <code>role='link'</code></td><td>按 lint 提示修复，改用语义正确的 HTML 元素</td></tr><tr><td>E2E EACCES</td><td>测试目录权限问题（<code>test_uploads</code> 目录残留）</td><td><code>ensureDeleted</code> 使用 <code>fs.rm(path, { recursive: true, force: true })</code></td></tr></tbody></table><p><strong>部署流程</strong>（CI 全绿后）：</p><pre><code class="language-bash"># 1. 在 vps 上拉取新镜像ssh vps &quot;bash /tmp/load_image.sh frontend; bash /tmp/load_image.sh backend&quot;# 2. 强制重建容器（环境变量变更时必须 --force-recreate）ssh vps &quot;cd /app/hedgedoc &amp;&amp; docker-compose up -d --force-recreate frontend&quot;# 3. 验证镜像 ID 一致ssh vps &quot;docker inspect ghcr.io/maifeipin/hedgedoc/frontend:develop --format &#39;{{.Id}}&#39;; docker inspect hedgedoc_frontend_1 --format &#39;{{.Image}}&#39;&quot;</code></pre><hr /><h2 id="4.-hedgedoc-fork-%E7%9A%84%E4%BB%A3%E7%A0%81%E4%BF%AE%E6%94%B9" tabindex="-1">4. HedgeDoc fork 的代码修改</h2><p>为支持任意类型附件上传与批量导入，对 HedgeDoc v2 做了以下 fork 改动。所有改动均为「放开限制 + 增强健壮性」，不改变原有正常流程。</p><h3 id="4.1-%E5%90%8E%E7%AB%AF%EF%BC%88backend%EF%BC%89" tabindex="-1">4.1 后端（backend）</h3><h4 id="backend%2Fsrc%2Fmedia%2Fmedia.service.ts" tabindex="-1"><code>backend/src/media/media.service.ts</code></h4><p><strong>改动：</strong> <code>isAllowedMimeType</code> 方法直接返回 <code>true</code>，允许任意 MIME 类型上传。</p><pre><code class="language-ts">private static isAllowedMimeType(_: string | undefined): boolean {  // Allow any file type (images, PDFs, documents, archives, etc.)  return true;}</code></pre><p><strong>目的：</strong> 原版仅允许图片 MIME 类型；迁移需上传 PDF、Excel、压缩包等，故放开。<code>saveFile</code> 主流程在类型无法识别（<code>fileTypeResult</code> 为 undefined）时也允许通过。</p><h4 id="backend%2Fsrc%2Fmedia%2Fbackends%2Ffilesystem-backend.ts" tabindex="-1"><code>backend/src/media/backends/filesystem-backend.ts</code></h4><p><strong>改动：</strong></p><ul><li><code>saveFile</code>：当 <code>fileType</code> 为 <code>undefined</code> 时，<code>ext</code> fallback 为 <code>'bin'</code>、<code>mime</code> fallback 为 <code>'application/octet-stream'</code>，避免原版对 undefined 的解构异常。</li><li><code>deleteFile</code>：捕获 <code>ENOENT</code>，视为「文件已删除」直接返回，不再抛 500，方便清理孤儿媒体记录。</li><li><code>getFileResponse</code>：读取文件遇到 <code>ENOENT</code> 时抛 <code>NotInDBError</code>（映射为 404）而非 <code>MediaBackendError</code>（映射为 500），让缺失文件表现为「不存在」而非「服务器错误」。</li></ul><p><strong>目的：</strong> 提升对孤儿记录（媒体记录存在但磁盘文件缺失）的健壮性，避免单条缺失拖垮整篇笔记渲染。</p><h4 id="backend%2Fsrc%2Fsecurity%2Frate-limiting.ts" tabindex="-1"><code>backend/src/security/rate-limiting.ts</code></h4><p><strong>改动：</strong> 在 <code>getRateLimitConfigByRequest</code> 中，对 <code>/media</code> 前缀路径直接返回 <code>max: Infinity</code>，豁免媒体路由限流。logout 与 monitoring 路由同样豁免（原版即有）。</p><pre><code class="language-ts">if (  path === &#39;/api/private/auth/logout&#39; ||  path.startsWith(&#39;/api/private/monitoring&#39;) ||  path.startsWith(&#39;/media&#39;)) {  return { max: Infinity };}</code></pre><p><strong>目的：</strong> 一篇笔记加载时会并发请求数十张图片/附件，若与全局限流计数共享，会迅速耗尽配额导致笔记打不开。媒体访问本身已有 per-note 权限校验（<code>canUserAccessUpload</code>）保护，无需额外限流。</p><h3 id="4.2-%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E5%8D%87%E7%BA%A7%EF%BC%88db-schema%EF%BC%89" tabindex="-1">4.2 数据结构升级（DB Schema）</h3><p>在原版 HedgeDoc v2 的基础上，新增了 4 个数据模型和 2 个数据库迁移，用于支持目录树、标签分类、反向链接和多维表格功能。</p><h4 id="%E6%96%B0%E5%A2%9E%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B%E5%AE%9A%E4%B9%89" tabindex="-1">新增数据类型定义</h4><table><thead><tr><th>文件</th><th>数据模型</th><th>说明</th></tr></thead><tbody><tr><td><code>database/src/types/folder.ts</code></td><td><code>Folder</code></td><td>目录树节点：id、name、parentId、ownerId、isSystem、sortOrder、createdAt、updatedAt</td></tr><tr><td><code>database/src/types/note-link.ts</code></td><td><code>NoteLink</code></td><td>笔记间反向链接：id、fromNoteAlias、toNoteAlias、linkText、createdAt</td></tr><tr><td><code>database/src/types/table.ts</code></td><td><code>Table</code></td><td>多维表格：id、noteAlias、name、rows（jsonb）、columns（jsonb）、createdAt</td></tr><tr><td><code>database/src/types/tag.ts</code></td><td><code>Tag</code></td><td>标签：id、name、color、creatorId、isSystem、createdAt</td></tr></tbody></table><h4 id="%E6%95%B0%E6%8D%AE%E5%BA%93%E8%BF%81%E7%A7%BB%E6%96%87%E4%BB%B6" tabindex="-1">数据库迁移文件</h4><p><strong><code>backend/src/migrations/20260729000000_cloud_notes_schema_v2.js</code></strong> - 创建 7 张新表：</p><pre><code class="language-sql">-- 目录表CREATE TABLE &quot;folder&quot; (  id SERIAL PRIMARY KEY,  name VARCHAR NOT NULL,  &quot;parentId&quot; INTEGER REFERENCES &quot;folder&quot;(id) ON DELETE CASCADE,  &quot;ownerId&quot; INTEGER NOT NULL REFERENCES &quot;user&quot;(id) ON DELETE CASCADE,  &quot;isSystem&quot; BOOLEAN NOT NULL DEFAULT false,  &quot;sortOrder&quot; INTEGER NOT NULL DEFAULT 0,  &quot;createdAt&quot; TIMESTAMP NOT NULL,  &quot;updatedAt&quot; TIMESTAMP NOT NULL);-- 笔记添加 folderId 列 + GIN 索引ALTER TABLE &quot;note&quot; ADD COLUMN &quot;folderId&quot; INTEGER REFERENCES &quot;folder&quot;(id);CREATE INDEX &quot;note_folderId_idx&quot; ON &quot;note&quot;(&quot;folderId&quot;);-- 标签表CREATE TABLE &quot;tag&quot; (  id SERIAL PRIMARY KEY,  name VARCHAR NOT NULL,  color VARCHAR,  &quot;creatorId&quot; INTEGER REFERENCES &quot;user&quot;(id),  &quot;isSystem&quot; BOOLEAN NOT NULL DEFAULT false,  &quot;createdAt&quot; TIMESTAMP NOT NULL,  UNIQUE(&quot;name&quot;, &quot;creatorId&quot;));-- 笔记-标签关联表CREATE TABLE &quot;note_tag&quot; (  &quot;noteAlias&quot; VARCHAR REFERENCES &quot;note&quot;(&quot;publicId&quot;) ON DELETE CASCADE,  &quot;tagId&quot; INTEGER REFERENCES &quot;tag&quot;(id) ON DELETE CASCADE,  PRIMARY KEY(&quot;noteAlias&quot;, &quot;tagId&quot;));-- 笔记链接表（反向链接）CREATE TABLE &quot;note_link&quot; (  id SERIAL PRIMARY KEY,  &quot;fromNoteAlias&quot; VARCHAR NOT NULL REFERENCES &quot;note&quot;(&quot;publicId&quot;) ON DELETE CASCADE,  &quot;toNoteAlias&quot; VARCHAR NOT NULL,  &quot;linkText&quot; VARCHAR,  &quot;createdAt&quot; TIMESTAMP NOT NULL);-- 多维表格表CREATE TABLE &quot;note_table&quot; (  id SERIAL PRIMARY KEY,  &quot;noteAlias&quot; VARCHAR NOT NULL REFERENCES &quot;note&quot;(&quot;publicId&quot;) ON DELETE CASCADE,  name VARCHAR NOT NULL,  rows JSONB NOT NULL DEFAULT &#39;[]&#39;,  columns JSONB NOT NULL DEFAULT &#39;[]&#39;,  &quot;createdAt&quot; TIMESTAMP NOT NULL,  &quot;updatedAt&quot; TIMESTAMP NOT NULL);</code></pre><p><strong><code>backend/src/migrations/20260730000000_add_creator_id_to_tags.js</code></strong> - 为标签表补充 creatorId 索引。</p><h4 id="%E6%96%B0%E5%A2%9E%E5%90%8E%E7%AB%AF%E6%A8%A1%E5%9D%97" tabindex="-1">新增后端模块</h4><table><thead><tr><th>模块</th><th>路径</th><th>功能</th></tr></thead><tbody><tr><td>Folders</td><td><code>backend/src/folders/</code></td><td>目录 CRUD、树构建（递归 children）、笔记移动到目录、noteCount 统计</td></tr><tr><td>Tags</td><td><code>backend/src/tags/</code></td><td>标签 CRUD、预设系统标签（isSystem）、按使用频率分组（active/unused）</td></tr><tr><td>Links</td><td><code>backend/src/links/</code></td><td>Wiki-link 自动提取、反向链接索引、笔记变更时事件对账（reconciliation）</td></tr><tr><td>Tables</td><td><code>backend/src/tables/</code></td><td>多维表格 CRUD、jsonb 单元格更新（<code>jsonb_set</code>）、表格行/列操作</td></tr><tr><td>Explore 增强</td><td><code>backend/src/explore/</code></td><td>新增 <code>/api/v2/explore/stats</code> 端点（笔记/目录/标签总数）、支持 folderId/tag 过滤</td></tr></tbody></table><h4 id="%E6%96%B0%E5%A2%9E%E5%89%8D%E7%AB%AF%E7%BB%84%E4%BB%B6" tabindex="-1">新增前端组件</h4><table><thead><tr><th>组件</th><th>路径</th><th>功能</th></tr></thead><tbody><tr><td>FolderTree</td><td><code>frontend/src/components/tree/folder-tree.tsx</code></td><td>目录树：递归渲染、拖拽笔记移动、展开/折叠状态 localStorage 持久化、按名称排序（数字→字母→中文）</td></tr><tr><td>TagCloud</td><td><code>frontend/src/components/tags/tag-cloud.tsx</code></td><td>标签云：三组分类（预设/使用中/未使用）、拖拽打标签、创建标签</td></tr><tr><td>SetTagsModal</td><td><code>frontend/src/components/tags/set-tags-modal.tsx</code></td><td>批量为笔记设置标签</td></tr><tr><td>SearchDialog</td><td><code>frontend/src/components/search/search-dialog.tsx</code></td><td>全局搜索对话框</td></tr><tr><td>BacklinksPanel</td><td><code>frontend/src/components/editor/backlinks-panel.tsx</code></td><td>编辑器侧边反向链接面板</td></tr></tbody></table><h3 id="4.3-%E5%89%8D%E7%AB%AF%EF%BC%88frontend%EF%BC%89" tabindex="-1">4.3 前端（frontend）</h3><h4 id="frontend%2Fsrc%2Fcomponents%2Fcommon%2Fupload-image-mimetypes.ts" tabindex="-1"><code>frontend/src/components/common/upload-image-mimetypes.ts</code></h4><p><strong>改动：</strong> 新增导出常量 <code>acceptedAllFileTypes = '*/*'</code>，原 <code>supportedMimeTypes</code>/<code>acceptedMimeTypes</code> 保留不动。</p><p><strong>目的：</strong> 为工具栏上传按钮提供「接受任意文件类型」的 accept 值。</p><h4 id="frontend%2Fsrc%2Fcomponents%2Feditor-page%2Feditor-pane%2Ftool-bar%2Fupload-image-button%2Fupload-image-button.tsx" tabindex="-1"><code>frontend/src/components/editor-page/editor-pane/tool-bar/upload-image-button/upload-image-button.tsx</code></h4><p><strong>改动：</strong></p><ul><li><code>UploadInput</code> 的 <code>allowedFileTypes</code> 由原 <code>acceptedMimeTypes</code> 改为 <code>acceptedAllFileTypes</code>（<code>*/*</code>）。</li><li><code>ToolbarButton</code> 的 <code>i18nKey</code> 由 <code>'uploadImage'</code> 改为 <code>'uploadFile'</code>，按钮文案由「上传图片」变为「上传文件」。</li><li>cypressId 由 <code>toolbar.uploadImage.*</code> 改为 <code>toolbar.uploadFile.*</code>。</li></ul><p><strong>目的：</strong> 让工具栏按钮可上传任意类型文件，且文案准确。</p><h4 id="frontend%2Fsrc%2Fcomponents%2Feditor-page%2Feditor-pane%2Fhooks%2Fuse-handle-upload.tsx" tabindex="-1"><code>frontend/src/components/editor-page/editor-pane/hooks/use-handle-upload.tsx</code></h4><p><strong>改动：</strong></p><ul><li>上传成功后，生成的链接使用<strong>绝对 URL</strong>：<code>${baseUrl}media/${uuid}</code>，而非原来的相对路径 <code>/media/${uuid}</code>。</li><li>非图片文件生成 <code>[filename](url)</code> 链接，图片生成 <code>![alt](url)</code>。</li><li>i18n key 由 <code>uploadImage</code> 系列改为 <code>uploadFile</code> 系列。</li><li>引入 <code>useBaseUrl()</code> hook 获取站点根 URL。</li></ul><p><strong>目的：</strong> 绝对 URL 保证附件链接在笔记被移动、别名变化或跨域引用时仍可正确访问。</p><h4 id="frontend%2Fsrc%2Fcomponents%2Feditor-page%2Fsidebar%2Fspecific-sidebar-entries%2Fmedia-browser-sidebar-menu%2Fmedia-entry.tsx" tabindex="-1"><code>frontend/src/components/editor-page/sidebar/specific-sidebar-entries/media-browser-sidebar-menu/media-entry.tsx</code></h4><p><strong>改动：</strong></p><ul><li>媒体浏览器中，根据文件后缀判断是否为图片；非图片附件显示 <code>IconFileText</code> 文件图标（而非图片预览）。</li><li>「插入到笔记」按钮对图片插入 <code>![alt](url)</code>，对非图片也用对应链接形式。</li><li>预览链接 <code>imageUrl</code> 使用绝对 URL（<code>useBaseUrl</code>）。</li></ul><p><strong>目的：</strong> 让媒体浏览器正确区分图片与附件，非图片附件不再显示空白图。</p><h4 id="frontend%2Flocales%2Fen.json" tabindex="-1"><code>frontend/locales/en.json</code></h4><p><strong>改动：</strong> 工具栏与上传提示文案的 i18n key 由 <code>uploadImage</code> 替换为 <code>uploadFile</code>：</p><pre><code class="language-json">&quot;toolbar&quot;: { &quot;uploadFile&quot;: &quot;Upload File&quot; },&quot;editor&quot;: { &quot;upload&quot;: { &quot;uploadFile&quot;: { &quot;withoutDescription&quot;: &quot;Uploading file {{fileName}}&quot;, ... } } }</code></pre><p><strong>目的：</strong> 文案与「上传任意文件」的新行为一致。</p><h4 id="frontend%2Fcypress%2Fe2e%2Ffileupload.spec.ts" tabindex="-1"><code>frontend/cypress/e2e/fileUpload.spec.ts</code></h4><p><strong>改动：</strong> e2e 测试用例更新：cypressId 改为 <code>toolbar.uploadFile</code> / <code>toolbar.uploadFile.input</code>，断言的链接改为绝对 URL（<code>http://127.0.0.1:3001/media/&lt;uuid&gt;</code>），并补充 413（文件过大）失败用例。</p><p><strong>目的：</strong> 保持测试与新行为一致。</p><hr /><h2 id="5.-vps1-%E9%83%A8%E7%BD%B2%E9%85%8D%E7%BD%AE" tabindex="-1">5. vps1 部署配置</h2><p>HedgeDoc 部署在 vps1，使用 Docker Compose。针对大文件上传与批量导入做了以下配置。</p><h3 id="5.1-nginx" tabindex="-1">5.1 nginx</h3><pre><code class="language-nginx">client_max_body_size 100m;</code></pre><p><strong>目的：</strong> 允许单个请求体最大 100MB，满足大附件（PDF、视频片段等）上传。默认 1MB 会触发 413。</p><h3 id="5.2-docker-compose.yml-%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F" tabindex="-1">5.2 docker-compose.yml 环境变量</h3><pre><code class="language-yaml">environment:  # 单文件上传上限：100MB（与 nginx 对齐）  HD_MEDIA_MAX_UPLOAD_SIZE: &quot;104857600&quot;  # 放开限流上限，避免批量导入触发 429  HD_SECURITY_RATE_LIMIT_AUTH_MAX: &quot;2000&quot;  HD_SECURITY_RATE_LIMIT_UNAUTHENTICATED_MAX: &quot;2000&quot;  HD_SECURITY_RATE_LIMIT_PUBLIC_API_MAX: &quot;2000&quot;</code></pre><p><strong>说明：</strong></p><ul><li><code>HD_MEDIA_MAX_UPLOAD_SIZE</code>：单位字节，104857600 = 100 × 1024 × 1024。</li><li>三个 rate limit 上限提到 2000，配合 <code>/media</code> 路由豁免（见 4.1），保证批量导入 1500+ 笔记时不被限流。</li></ul><h3 id="5.3-%E9%95%9C%E5%83%8F%E9%83%A8%E7%BD%B2" tabindex="-1">5.3 镜像部署</h3><p>vps1 无法直接 <code>docker pull</code> ghcr 镜像（CDN reset），改用 <code>load_image.sh</code> 通过 socks5h 代理手动下载镜像层后 <code>docker load</code>：</p><pre><code class="language-bash">bash load_image.sh backendbash load_image.sh frontend</code></pre><p>脚本细节见 3.2。加载后 <code>docker images</code> 应能看到 <code>ghcr.io/maifeipin/hedgedoc/{backend,frontend}:develop</code>。</p><hr /><h2 id="6.-%E5%AF%BC%E5%85%A5%E6%B5%81%E7%A8%8B" tabindex="-1">6. 导入流程</h2><h3 id="6.1-%E5%87%86%E5%A4%87" tabindex="-1">6.1 准备</h3><ol><li>在 vps 部署改造后的 HedgeDoc fork（见第 4、5 节）。</li><li>用 <code>load_image.sh</code> 加载 backend / frontend 镜像并 <code>docker compose up -d</code>。</li><li>在 vps1 本机执行 <code>get_token.sh</code> 获取 API token。</li></ol><h3 id="6.2-%E5%B0%8F%E6%89%B9%E9%87%8F%E8%AF%95%E7%82%B9" tabindex="-1">6.2 小批量试点</h3><ol><li>在 <code>youdao_md_full</code> 中挑选一个覆盖不同附件类型（PDF、Excel、图片）的试点目录，例如 <code>tools/youdao-import/pilot/</code>。</li><li>用 token 导入试点目录：<pre><code class="language-bash">python -u import_youdao_md.py pilot \  --base-url http://localhost:3031 \  --token &lt;TOKEN&gt;</code></pre></li><li>验证：<ul><li>笔记正文是否完整、文件夹层级是否正确；</li><li>图片是否正常显示；</li><li>附件链接是否可点击下载（注意链接为绝对 URL）。</li></ul></li></ol><h3 id="6.3-%E5%85%A8%E9%87%8F%E5%AF%BC%E5%85%A5" tabindex="-1">6.3 全量导入</h3><p>试点通过后执行全量导入：</p><pre><code class="language-bash">python -u import_youdao_md.py youdao_md_full \  --base-url &lt;URL&gt; \  --token &lt;TOKEN&gt;</code></pre><p>脚本会在每 50 篇笔记时打印进度，结束时打印汇总统计与失败列表。</p><h3 id="6.4-%E5%AF%BC%E5%85%A5%E5%90%8E%E5%A4%84%E7%90%86%E5%A4%B1%E8%B4%A5%E7%AC%94%E8%AE%B0" tabindex="-1">6.4 导入后处理失败笔记</h3><p>脚本结束时输出的 <code>Failures</code> 列表即为需要后续处理的笔记。处理方式见第 7 节。</p><hr /><h2 id="7.-%E5%B7%B2%E7%9F%A5%E9%97%AE%E9%A2%98%E5%92%8C%E5%90%8E%E7%BB%AD%E5%A4%84%E7%90%86" tabindex="-1">7. 已知问题和后续处理</h2><h3 id="7.1-pull-%E7%BC%BA%E5%A4%B1%E9%99%84%E4%BB%B6%E5%BC%95%E7%94%A8%EF%BC%8817-%E4%B8%AA%E9%99%84%E4%BB%B6-%2F-15-%E7%AF%87%E7%AC%94%E8%AE%B0%EF%BC%89" tabindex="-1">7.1 PULL 缺失附件引用（17 个附件 / 15 篇笔记）</h3><p><strong>现象：</strong> <code>youdaonote-pull</code> 仅下载 Markdown 中被引用的附件，有 17 个附件引用对应的本地文件缺失（涉及 15 篇笔记）。</p><p><strong>原因：</strong> 部分附件在有道侧未被正确关联，或拉取时下载失败。</p><p><strong>处理：</strong> 从 APP 导出的 <code>.note.attach/</code> 目录中补充。APP 导出包含每篇笔记的<strong>全部</strong>附件（纯原名），可手动将缺失附件上传到对应笔记，并修正 Markdown 链接。可借助 <code>compare_attachments.py</code> 定位具体缺失的笔记与文件名。</p><h3 id="7.2-%E7%AC%94%E8%AE%B0%E5%86%85%E5%AE%B9%E8%BF%87%E5%A4%A7%E8%A7%A6%E5%8F%91-413%EF%BC%881-%E7%AF%87%E7%AC%94%E8%AE%B0%EF%BC%89" tabindex="-1">7.2 笔记内容过大触发 413（1 篇笔记）</h3><p><strong>现象：</strong> 单篇笔记在 <code>create_note</code> / <code>update_note</code> 时返回 413。</p><p><strong>原因：</strong> 笔记内嵌了大量 base64 图片或超长正文，请求体超过 nginx <code>client_max_body_size</code>（100MB）或 HedgeDoc 笔记内容上限。</p><p><strong>处理：</strong> 拆分该笔记，或将其中的内嵌图片改为先上传再引用。需单独手动处理。</p><h3 id="7.3-%E6%96%87%E4%BB%B6%E5%90%8D%E5%90%AB%E9%9D%9E%E6%B3%95%E5%AD%97%E7%AC%A6%EF%BC%881-%E7%AF%87%E7%AC%94%E8%AE%B0%EF%BC%89" tabindex="-1">7.3 文件名含非法字符（1 篇笔记）</h3><p><strong>现象：</strong> 笔记标题含 HedgeDoc 别名不允许的字符，导致 <code>slugify</code> 后别名异常或创建失败。</p><p><strong>处理：</strong> 手动重命名源 Markdown 文件后再导入，或直接在 HedgeDoc 中手动创建并粘贴内容。</p><hr /><h2 id="%E9%99%84%E5%BD%95%EF%BC%9A%E5%B7%A5%E5%85%B7%E6%96%87%E4%BB%B6%E6%B8%85%E5%8D%95" tabindex="-1">附录：工具文件清单</h2><table><thead><tr><th>文件</th><th>用途</th></tr></thead><tbody><tr><td><code>import_youdao_md.py</code></td><td>主导入脚本（笔记+图片+附件+文件夹结构）</td></tr><tr><td><code>load_image.sh</code></td><td>ghcr 镜像手动下载并 docker load（绕过 CDN reset）</td></tr><tr><td><code>get_token.sh</code></td><td>在 vps1 localhost 获取 API token（绕过外部 IP 限流）</td></tr><tr><td><code>compare_exports.py</code></td><td>PULL 与 APP 导出整体对齐分析（笔记/附件数量、命名对应）</td></tr><tr><td><code>compare_attachments.py</code></td><td>按笔记精确对比 PULL 与 APP 的附件文件差异</td></tr><tr><td><code>_list_failures.py</code></td><td>列出导入失败笔记（辅助后续处理）</td></tr><tr><td><code>pilot/</code></td><td>小批量试点数据目录</td></tr><tr><td><code>youdao_md_full/</code></td><td>全量 PULL 数据目录</td></tr></tbody></table><p><img src="/upload/2026/08/image.png" alt="image" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[重温windbg:  逆向剖析 C# WinForm 嵌入 WPS ActiveX 控件崩溃的终极复盘]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/重温windbg逆向剖析cwinform嵌入wpsactivex控件崩溃的终极复盘" />
                <id>tag:https://maifeipin.com,2026-07-23:重温windbg逆向剖析cwinform嵌入wpsactivex控件崩溃的终极复盘</id>
                <published>2026-07-23T20:05:49+08:00</published>
                <updated>2026-07-23T20:49:44+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<blockquote><p>把WPS 嵌入你的桌面中，很多人试过，记录一次真实的踩坑,填坑过程。</p></blockquote><hr /><h2 id="%F0%9F%93%8C-1.-%E8%83%8C%E6%99%AF%E4%B8%8E%E4%B8%9A%E5%8A%A1%E7%97%9B%E7%82%B9" tabindex="-1">📌 1. 背景与业务痛点</h2><p>本想只是写个提取链接的小工具，通过 WPS 官方提供的 ActiveX 控件（<code>AxWpsDocFrame.AxKDocFrame</code>）将 WPS Office 动态文档组件嵌入到窗体中</p><p>然而，在实际交付与部署过程中，许多开发者会陷入一个极其诡异且绝望的故障泥潭：</p><ul><li><strong>开发环境完美</strong>：在开发机或测试机上运行一切正常，WPS 窗口瞬间嵌入；</li><li><strong>部署电脑直接崩溃</strong>：将编译好的 EXE 部署到客户电脑上时，程序启动即报：<blockquote><p><code>System.IO.FileNotFoundException: 找不到指定的模块。 (异常来自 HRESULT: 0x8007007E)</code></p></blockquote></li><li><strong>常规排查失效</strong>：用 C# 标准的 <code>try-catch</code> 捕获，只能拿到一串绝望的 <code>0x8007007E</code> HRESULT 错误码，调用栈停在 <code>axKDocFrame1.EndInit()</code>，<strong>拿不到任何到底是哪个具体的 DLL 缺失、或者哪个 GUID 注册失败的明细</strong>。</li></ul><p>为了剥开 Windows COM 机制与 CLR 运行时的底层黑盒，我们决定抛弃盲目猜测，使用 <strong>WinDbg + SOS 扩展</strong> 深入进程内存与 C++ 底层堆栈，进行全流程的逆向排查与逻辑闭环复盘。</p><hr /><h2 id="%F0%9F%9B%A0%EF%B8%8F-2.-%E7%A1%AC%E6%A0%B8%E8%B0%83%E8%AF%95%E5%B7%A5%E5%85%B7%E9%93%BE%E5%87%86%E5%A4%87-(windbg-%2B-sos)" tabindex="-1">🛠️ 2. 硬核调试工具链准备 (WinDbg + SOS)</h2><p>在 32位/64位 混合环境的 WinDbg 调试中，调试 .NET 应用极易触发 <code>c0000005 Exception in sos</code> 崩溃。必须建立标准的初始化命令防爆闸。</p><h3 id="2.1-%E5%90%AF%E5%8A%A8-windbg-%E5%88%9D%E5%A7%8B%E5%8C%96%E5%91%BD%E4%BB%A4%E5%BA%8F%E5%88%97" tabindex="-1">2.1 启动 WinDbg 初始化命令序列</h3><p>当 WinDbg 打开 EXE 或输入 <code>.restart</code> 重启停在 <code>ntdll</code> 初始断点时，执行以下<strong>两阶段加载序列</strong>：</p><h4 id="%E9%98%B6%E6%AE%B5%E4%B8%80%EF%BC%9A%E7%AD%89%E5%BE%85-.net-clr-%E5%BC%95%E6%93%8E%E8%BD%BD%E5%85%A5%E5%86%85%E5%AD%98" tabindex="-1">阶段一：等待 .NET CLR 引擎载入内存</h4><pre><code class="language-dbg">sxe ld clrg</code></pre><blockquote><p><strong>原理</strong>：<code>.restart</code> 后程序停在 Windows 原生内核入口，<code>.NET</code> 的 <code>clr.dll</code> 引擎尚未载入。设置 <code>sxe ld clr</code> 让 WinDbg 自动运行，并在 <code>clr.dll</code> 刚载入内存的第一瞬间<strong>自动暂停</strong>，确保 CLR 全局线程表与数据结构完成初始化。</p></blockquote><h4 id="%E9%98%B6%E6%AE%B5%E4%BA%8C%EF%BC%9A%E8%A3%85%E8%BD%BD-sos-%E8%B0%83%E8%AF%95%E6%8F%92%E4%BB%B6%E4%B8%8E%E5%85%A8%E5%B1%80%E9%98%B2%E7%88%86%E9%97%B8" tabindex="-1">阶段二：装载 SOS 调试插件与全局防爆闸</h4><pre><code class="language-dbg">.loadby sos clr.cordll -ve -u -lsxe e0434352sxe e06d7363bp KERNELBASE!LoadLibraryExW &quot;du poi(esp+4); g&quot;g</code></pre><blockquote><p><strong>原理</strong>：</p><ol><li><code>.cordll -ve -u -l</code>：绑定匹配的 <code>.NET</code> 数据访问引擎 (<code>mscordacwks.dll</code>)，彻底解决 <code>!threads</code> 或 <code>!pe</code> 报 <code>c0000005</code> 崩溃的问题；</li><li><code>sxe e0434352</code>：强制在抛出 <code>.NET</code> 托管异常的第一微秒内<strong>触发 int 3 冻结进程</strong>，阻断异常向上传递，<strong>防止弹出 Windows 报错弹窗掩盖崩溃现场</strong>；</li><li><code>bp KERNELBASE!...</code>：实时在控制台打印 Win32 <code>LoadLibraryExW</code> 尝试加载的所有 Native DLL 绝对路径。</li></ol></blockquote><hr /><h2 id="%F0%9F%94%8D-3.-%E7%AC%AC%E4%B8%80%E9%98%B6%E6%AE%B5%EF%BC%9A%E8%BF%BD%E8%B8%AA%E5%B4%A9%E6%BA%83%E5%8E%9F%E7%82%B9%E4%B8%8E-cocreateinstance" tabindex="-1">🔍 3. 第一阶段：追踪崩溃原点与 CoCreateInstance</h2><p>当程序在 WinDbg 中被 <code>sxe e0434352</code> 成功硬中断停住后（控制台输出 <code>CLR exception - code e0434352 (first chance)</code>），我们运行 SOS 托管指令解包现场：</p><pre><code class="language-dbg">0:000&gt; !threadsThreadCount:      2       ID OSID ThreadOBJ    State GC Mode     GC Alloc Context  Domain   Count Apt Exception   0    1 27074 0061d5d0     26020 Preemptive  0281D7AC:00000000 005e5110 0     STA System.IO.FileNotFoundException 0281d484</code></pre><p>我们在 Thread 0 上精准抓到了捕获的异常对象内存地址：<strong><code>0281d484</code></strong>。</p><p>使用 <code>!clrstack -p</code> 打印精准的托管 C# 函数调用链：</p><pre><code class="language-text">0:000&gt; !clrstack -pOS Thread Id: 0x27074 (0)Child SP       IP Call Site004fedb0 77699f54 [HelperMethodFrame: 004fedb0] 004fee40 05b90833 DomainBoundILStubClass.IL_STUB_PInvoke(System.Guid ByRef, System.Object, Int32, System.Guid ByRef)004fee44 05b906a3 [InlinedCallFrame: 004fee44] System.Windows.Forms.UnsafeNativeMethods.CoCreateInstance(System.Guid ByRef, System.Object, Int32, System.Guid ByRef)004feeb0 05b906a3 System.Windows.Forms.AxHost.CreateWithoutLicense(System.Guid)    PARAMETERS:        this (&lt;CLR reg&gt;) = 0x027e9774004fef04 05b9053d System.Windows.Forms.AxHost.CreateInstanceCore(System.Guid)    PARAMETERS:        this (&lt;CLR reg&gt;) = 0x027e9774004ff01c 058eac34 System.Windows.Forms.AxHost.EndInit()    PARAMETERS:        this (&lt;CLR reg&gt;) = 0x027e9774004ff028 0586a8ab EmbedWPSinWinform.Form1.InitializeComponent() [Form1.Designer.cs @ 82]004ff0dc 02549988 EmbedWPSinWinform.Form1..ctor() [Form1.cs @ 26]004ff14c 02544738 EmbedWPSinWinform.Program.Main() [Program.cs @ 54]</code></pre><h3 id="%F0%9F%94%AC-%E8%BF%98%E5%8E%9F%E8%B0%83%E7%94%A8%E9%93%BE%E8%B7%AF%EF%BC%9A" tabindex="-1">🔬 还原调用链路：</h3><ol><li><code>Program.cs</code> 入口启动 <code>new Form1()</code>；</li><li><code>Form1.Designer.cs</code> 第 82 行执行 <code>this.axKDocFrame1.EndInit()</code> 尝试实例化 WPS 嵌入控件；</li><li>经过 WinForm 控件框架 <code>AxHost</code> 内部转换为 COM 实例化请求；</li><li>最终停在 Windows COM 核心系统 API：<strong><code>System.Windows.Forms.UnsafeNativeMethods.CoCreateInstance</code></strong>！</li></ol><p>这证明：<strong>崩溃不是发生在 C# 逻辑层，而是发生在 Windows COM 子系统调用 <code>CoCreateInstance</code> 实例化控件的时刻！</strong></p><hr /><h2 id="%F0%9F%94%AC-4.-%E7%AC%AC%E4%BA%8C%E9%98%B6%E6%AE%B5%EF%BC%9A%E9%80%86%E5%90%91%E5%86%85%E5%AD%98%E8%A7%A3%E5%8C%85%EF%BC%8C%E6%8F%90%E5%8F%96%E9%9A%90%E8%97%8F%E7%9A%84%E7%89%A9%E7%90%86-clsid" tabindex="-1">🔬 4. 第二阶段：逆向内存解包，提取隐藏的物理 CLSID</h2><p>由于 <code>AxHost.CreateWithoutLicense</code> 传递的 <code>clsid</code> 结构体参数在 x86 栈优化下显示为 <code>&lt;no data&gt;</code>，我们需要直接对托管堆上的 <code>AxKDocFrame</code> 控件实例进行物理内存拆解。</p><p>在 <code>!clrstack -p</code> 中，我们拿到了控件在托管堆上的物理内存首地址：<strong><code>0x027e9774</code></strong>。</p><p>运行 SOS 堆对象 dump 指令 <code>!do 027e9774</code> 打印类布局：</p><pre><code class="language-text">0:000&gt; !do 027e9774Name:        AxWpsDocFrame.AxKDocFrameMethodTable: 04b2e528EEClass:     04b0aff4Size:        352(0x160) bytesFields:      MT    Field   Offset                 Type VT     Attr    Value Name...72de82d0  400067c      114          System.Guid  1 instance 027e9888 clsid</code></pre><h3 id="%F0%9F%8E%AF-%E5%8F%91%E7%8E%B0%E5%85%B3%E9%94%AE%E5%AD%97%E6%AE%B5%EF%BC%9A" tabindex="-1">🎯 发现关键字段：</h3><p>在偏移量 <code>0x114</code> 处，看到了 <code>AxKDocFrame</code> 内部保存的物理属性：<br /><code>System.Guid clsid</code> ➔ 内存地址：<strong><code>0x027e9888</code></strong>。</p><p>由于 <code>System.Guid</code> 是 16 字节的值类型（ValueType），它直接按字节紧凑存放在 <code>0x027e9888</code> 地址上。使用 WinDbg 原生双字（DWORD）内存 dump 命令 <code>dd 027e9888 L4</code> 提取 16 字节数据：</p><pre><code class="language-dbg">0:000&gt; dd 027e9888 L4027e9888  8e7da7ec 434307ec a2884181 5f3ab6ad</code></pre><h3 id="%F0%9F%93%90-guid-%E5%B0%8F%E7%AB%AF%E5%BA%8F-(little-endian)-%E7%A1%AC%E6%A0%B8%E6%8D%A2%E7%AE%97%EF%BC%9A" tabindex="-1">📐 GUID 小端序 (Little-Endian) 硬核换算：</h3><ul><li><code>Data1</code> (DWORD): <code>8e7da7ec</code> ➔ <strong><code>8E7DA7EC</code></strong></li><li><code>Data2</code> (WORD) : <code>07ec</code> ➔ <strong><code>07EC</code></strong></li><li><code>Data3</code> (WORD) : <code>4343</code> ➔ <strong><code>4343</code></strong></li><li><code>Data4</code> (BYTES): <code>81 41 88 a2 ad b6 3a 5f</code> ➔ <strong><code>8141-88A2ADB63A5F</code></strong></li></ul><p>我们成功逆向得到了 <code>axKDocFrame1</code> 在运行期真正向 Windows 申请的物理 CLSID 字符串：</p><p class='katex-block'><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mo stretchy="false">{</mo><mn mathvariant="bold">8</mn><mi mathvariant="bold">E</mi><mn mathvariant="bold">7</mn><mi mathvariant="bold">D</mi><mi mathvariant="bold">A</mi><mn mathvariant="bold">7</mn><mi mathvariant="bold">E</mi><mi mathvariant="bold">C</mi><mo>−</mo><mn mathvariant="bold">07</mn><mi mathvariant="bold">E</mi><mi mathvariant="bold">C</mi><mo>−</mo><mn mathvariant="bold">4343</mn><mo>−</mo><mn mathvariant="bold">8141</mn><mo>−</mo><mn mathvariant="bold">88</mn><mi mathvariant="bold">A</mi><mn mathvariant="bold">2</mn><mi mathvariant="bold">A</mi><mi mathvariant="bold">D</mi><mi mathvariant="bold">B</mi><mn mathvariant="bold">63</mn><mi mathvariant="bold">A</mi><mn mathvariant="bold">5</mn><mi mathvariant="bold">F</mi><mo stretchy="false">}</mo></mrow><annotation encoding="application/x-tex">\mathbf{\{8E7DA7EC-07EC-4343-8141-88A2ADB63A5F\}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord"><span class="mopen">{</span><span class="mord mathbf">8</span><span class="mord mathbf">E</span><span class="mord mathbf">7</span><span class="mord mathbf">D</span><span class="mord mathbf">A</span><span class="mord mathbf">7</span><span class="mord mathbf">E</span><span class="mord mathbf">C</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord mathbf">0</span><span class="mord mathbf">7</span><span class="mord mathbf">E</span><span class="mord mathbf">C</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord mathbf">4</span><span class="mord mathbf">3</span><span class="mord mathbf">4</span><span class="mord mathbf">3</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord mathbf">8</span><span class="mord mathbf">1</span><span class="mord mathbf">4</span><span class="mord mathbf">1</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222222222222222em;"></span><span class="mord mathbf">8</span><span class="mord mathbf">8</span><span class="mord mathbf">A</span><span class="mord mathbf">2</span><span class="mord mathbf">A</span><span class="mord mathbf">D</span><span class="mord mathbf">B</span><span class="mord mathbf">6</span><span class="mord mathbf">3</span><span class="mord mathbf">A</span><span class="mord mathbf">5</span><span class="mord mathbf">F</span><span class="mclose">}</span></span></span></span></span></span></p><hr /><h2 id="%E2%9A%A1-5.-%E7%AC%AC%E4%B8%89%E9%98%B6%E6%AE%B5%EF%BC%9A%E6%B3%A8%E5%86%8C%E8%A1%A8%E9%87%8D%E5%AE%9A%E5%90%91%E9%99%B7%E9%98%B1%E4%B8%8E-64%E4%BD%8D%2F32%E4%BD%8D-%E6%9E%B6%E6%9E%84%E5%B4%A9%E6%BA%83%E7%9C%9F%E7%9B%B8" tabindex="-1">⚡ 5. 第三阶段：注册表重定向陷阱与 64位/32位 架构崩溃真相</h2><p>得到了真实 GUID <code>{8E7DA7EC-07EC-4343-8141-88A2ADB63A5F}</code> 后，我们在 64位 PowerShell 中运行查询：</p><pre><code class="language-powershell">PS C:\Users\chenl&gt; reg query &quot;HKCR\CLSID\{8E7DA7EC-07EC-4343-8141-88A2ADB63A5F}\InprocServer32&quot;错误: 系统找不到指定的注册表项或值。</code></pre><p>为什么查不到？这里隐藏着 64位 Windows 操作系统的<strong>注册表视图隔离陷阱（Registry Redirector）</strong>！</p><h3 id="5.1-32%E4%BD%8D-%E6%B3%A8%E5%86%8C%E8%A1%A8%E8%A7%86%E5%9B%BE-(wow6432node)" tabindex="-1">5.1 32位 注册表视图 (WOW6432Node)</h3><p>在 64位 Windows 上，32位 COM 组件的注册项被重定向到了 <code>WOW6432Node</code> 中。必须在查询命令中加上 <strong><code>/reg:32</code></strong> 参数：</p><pre><code class="language-cmd">reg query &quot;HKCR\CLSID\{8E7DA7EC-07EC-4343-8141-88A2ADB63A5F}\InprocServer32&quot; /reg:32</code></pre><p>执行后完美输出：</p><pre><code class="language-text">HKEY_CLASSES_ROOT\CLSID\{8E7DA7EC-07EC-4343-8141-88A2ADB63A5F}\InprocServer32    (Default)       REG_SZ    C:\Program Files\Kingsoft\WPS Office\12.1.0.25860\office6\wpsdocframe.dll    ThreadingModel  REG_SZ    Apartment</code></pre><p>注册表完全正确！关联的 DLL 为：<code>C:\Program Files\Kingsoft\WPS Office\12.1.0.25860\office6\wpsdocframe.dll</code>。</p><p>然而，<strong>既然注册表正确关联到了文件，为什么 <code>CoCreateInstance</code> 依然报错 <code>0x8007007E (找不到指定的模块)</code>？</strong></p><hr /><h3 id="%F0%9F%92%A5-5.2-%E7%89%A9%E7%90%86%E7%9C%9F%E7%9B%B8%E6%B0%B4%E8%90%BD%E7%9F%B3%E5%87%BA%EF%BC%9Adll-%E6%9E%B6%E6%9E%84%E4%B8%8E-exe-%E8%BF%9B%E7%A8%8B%E6%9E%B6%E6%9E%84%E7%9A%84%E6%AD%BB%E9%94%81%E5%86%B2%E7%AA%81" tabindex="-1">💥 5.2 物理真相水落石出：DLL 架构与 EXE 进程架构的死锁冲突</h3><p>我们使用系统脚本读取 <code>wpsdocframe.dll</code> 文件的 PE 校验头（PE Header）：</p><pre><code class="language-powershell">powershell -Command &quot;[BitConverter]::ToUInt16([System.IO.File]::ReadAllBytes(&#39;C:\Program Files\Kingsoft\WPS Office\12.1.0.25860\office6\wpsdocframe.dll&#39;), [BitConverter]::ToInt32([System.IO.File]::ReadAllBytes(&#39;C:\Program Files\Kingsoft\WPS Office\12.1.0.25860\office6\wpsdocframe.dll&#39;), 60) + 4)&quot;</code></pre><p>输出物理机器码：<strong><code>34404</code></strong>（即 Hex <strong><code>0x8664</code></strong> = <strong>AMD64 / 64-bit Native C++ DLL</strong>）！</p><h4 id="%E5%B4%A9%E6%BA%83%E9%93%BE%E8%B7%AF%E6%8E%A8%E5%AF%BC%E9%97%AD%E7%8E%AF%EF%BC%9A" tabindex="-1">崩溃链路推导闭环：</h4><ol><li><strong>WPS 架构</strong>：目标电脑安装的是 <strong>64位 (x64) WPS Office</strong>，其核心组件 <code>wpsdocframe.dll</code> 是 <strong>64位 Native DLL (<code>0x8664</code>)</strong>；</li><li><strong>C# EXE 架构</strong>：原 C# WinForm 项目配置了 <code>.NET Framework 4.5+</code> 默认的 <code>&lt;Prefer32Bit&gt;true&lt;/Prefer32Bit&gt;</code>，导致应用程序在 64位 操作系统上<strong>强制以 32位 (x86) 进程模式启动</strong>；</li><li><strong>Windows 操作系统底层铁律</strong>：<strong>32 位的 EXE 进程在调用 <code>CoCreateInstance</code> / <code>LoadLibrary</code> 时，物理上绝对无法载入 64 位的 Native Inproc C++ DLL！</strong></li><li>当 32位 进程试图 <code>LoadLibraryExW</code> 64位的 <code>wpsdocframe.dll</code> 时，Windows OS 动态链接库加载器直接抛出 <code>ERROR_MOD_NOT_FOUND</code>，在 C# 端掩盖呈现为极其误导人的 <strong><code>0x8007007E (FileNotFoundException: 找不到指定的模块)</code></strong>！</li></ol><hr /><h2 id="%F0%9F%9A%80-6.-%E7%AC%AC%E5%9B%9B%E9%98%B6%E6%AE%B5%EF%BC%9A%E7%BB%88%E6%9E%81%E5%B7%A5%E7%A8%8B%E5%8C%96%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%E4%B8%8E%E8%87%AA%E6%84%88%E6%9E%B6%E6%9E%84" tabindex="-1">🚀 6. 第四阶段：终极工程化解决方案与自愈架构</h2><p>找到了根因（<strong>32位/64位 进程架构跨域冲突</strong> 与 <strong>WPS 二级 C++ DLL 搜索路径缺失</strong>），我们在工程层面上做出了完整的逻辑闭环修复：</p><h3 id="6.1-%E9%85%8D%E7%BD%AE%E4%BF%AE%E5%A4%8D%EF%BC%9A%E5%85%B3%E9%97%AD-prefer32bit" tabindex="-1">6.1 配置修复：关闭 <code>Prefer32Bit</code></h3><p>在项目配置文件中将 <code>Prefer32Bit</code> 显式设置为 <code>false</code>：</p><pre><code class="language-xml">&lt;PropertyGroup Condition=&quot; &#39;$(Configuration)|$(Platform)&#39; == &#39;Debug|AnyCPU&#39; &quot;&gt;    &lt;PlatformTarget&gt;AnyCPU&lt;/PlatformTarget&gt;    &lt;Prefer32Bit&gt;false&lt;/Prefer32Bit&gt;&lt;/PropertyGroup&gt;&lt;PropertyGroup Condition=&quot; &#39;$(Configuration)|$(Platform)&#39; == &#39;Release|AnyCPU&#39; &quot;&gt;    &lt;PlatformTarget&gt;AnyCPU&lt;/PlatformTarget&gt;    &lt;Prefer32Bit&gt;false&lt;/Prefer32Bit&gt;&lt;/PropertyGroup&gt;</code></pre><blockquote><p><strong>效果</strong>：使 WinForm 程序在 64位 操作系统上自动以 <strong>64位 (x64) 原生进程</strong> 模式启动，同频加载 64位的 <code>wpsdocframe.dll</code>。</p></blockquote><hr /><h3 id="6.2-%E8%B7%AF%E5%BE%84%E6%8C%82%E8%BD%BD%EF%BC%9A%E5%8A%A8%E6%80%81-c%2B%2B-dll-%E6%90%9C%E7%B4%A2%E7%9B%AE%E5%BD%95%E8%AE%BE%E7%BD%AE" tabindex="-1">6.2 路径挂载：动态 C++ DLL 搜索目录设置</h3><p>为了防止 <code>wpsdocframe.dll</code> 在加载同目录下的 <code>kso.dll</code> / <code>et.dll</code> / <code>vcf.dll</code> 时因为 CWD (当前工作目录) 不在 <code>office6</code> 而报错，在入口处调用 Win32 <code>SetDllDirectory</code>：</p><pre><code class="language-csharp">[DllImport(&quot;kernel32.dll&quot;, CharSet = CharSet.Auto, SetLastError = true)]private static extern bool SetDllDirectory(string lpPathName);private static void SetupWpsDllSearchPath(){    try    {        string installRoot = GetWpsInstallRootFromRegistry();        string office6Dir = Path.Combine(installRoot, &quot;office6&quot;);        if (Directory.Exists(office6Dir))        {            // 1. 设置 C/C++ Native DLL 搜索优先目录            SetDllDirectory(office6Dir);            // 2. 追加到当前进程环境变量 PATH            string envPath = Environment.GetEnvironmentVariable(&quot;PATH&quot;) ?? &quot;&quot;;            if (!envPath.Contains(office6Dir))            {                Environment.SetEnvironmentVariable(&quot;PATH&quot;, office6Dir + &quot;;&quot; + envPath);            }        }    }    catch { }}</code></pre><hr /><h3 id="6.3-%E8%BF%90%E8%A1%8C%E6%9C%9F%E2%80%9C%E8%87%AA%E6%84%88%E2%80%9D%E9%87%8D%E8%AF%95%E5%BE%AA%E7%8E%AF-(self-healing-architecture)" tabindex="-1">6.3 运行期“自愈”重试循环 (Self-Healing Architecture)</h3><p>针对未注册 COM 组件的新安装电脑，在 <code>Program.cs</code> 中实现无缝自愈重试：</p><pre><code class="language-csharp">[STAThread]static void Main(){    Application.EnableVisualStyles();    Application.SetCompatibleTextRenderingDefault(false);    SetupWpsDllSearchPath();    DynamicWpsComponentTracer.EnableAssemblyLoadHook();    // 运行期自愈启动循环    bool canSelfHeal = true;    while (true)    {        try        {            Application.Run(new Form1());            break; // 正常启动并退出        }        catch (Exception ex) when (canSelfHeal)        {            canSelfHeal = false; // 防止死循环            LogDebug(&quot;[Program] 捕获到运行期启动异常，触发自动注册自愈: &quot; + ex.Message);            // 补充注册并提权            SetupWpsDllSearchPath();            bool repaired = TryAutoRegisterWpsComponent();            if (!repaired)            {                MessageBox.Show(&quot;检测到 WPS 运行环境缺失！\r\n错误细节: &quot; + ex.Message, &quot;启动失败&quot;, MessageBoxButtons.OK, MessageBoxIcon.Error);                break;            }        }    }}</code></pre><hr /><h2 id="%F0%9F%8E%81-7.-%E9%99%84%E5%BD%95%EF%BC%9A%E9%80%9A%E7%94%A8%E6%B3%A8%E5%86%8C%E8%84%9A%E6%9C%AC%E4%B8%8E-32%E4%BD%8D%2F64%E4%BD%8D-%E8%87%AA%E5%AE%9A%E4%B9%89%E8%B7%AF%E5%BE%84%E6%B3%A8%E5%86%8C%E6%A8%A1%E7%89%88" tabindex="-1">🎁 7. 附录：通用注册脚本与 32位/64位 自定义路径注册模版</h2><h3 id="7.1-%E5%85%A8%E8%87%AA%E5%8A%A8%E4%B8%80%E9%94%AE%E6%B3%A8%E5%86%8C%E6%89%B9%E5%A4%84%E7%90%86" tabindex="-1">7.1 全自动一键注册批处理</h3><p>为兼容任意电脑上 WPS 的版本号子目录（如 <code>\12.1.0.25860\office6\</code>）以及用户拖拽/手动输入的自定义安装路径，脚本支持全自动穿透检索：</p><pre><code class="language-cmd">@echo offchcp 65001 &gt;nulecho ========================================================echo WPS 嵌入组件 (wpsdocframe.dll) 1键注册与自定义路径修复脚本echo ========================================================echo.set &quot;WPS_DLL=&quot;:: 0. 优先支持将自定义 DLL 拖拽到脚本图标上运行if not &quot;%~1&quot;==&quot;&quot; (    if exist &quot;%~1&quot; set &quot;WPS_DLL=%~1&quot;):: 1. 递归穿透 64位 Program Files 任意版本号子目录if not defined WPS_DLL (    if exist &quot;C:\Program Files\Kingsoft\WPS Office&quot; (        for /r &quot;C:\Program Files\Kingsoft\WPS Office&quot; %%i in (wpsdocframe.dll) do (            if exist &quot;%%i&quot; set &quot;WPS_DLL=%%i&quot;        )    )):: 2. 递归穿透 32位 Program Files (x86) 任意版本号子目录if not defined WPS_DLL (    if exist &quot;C:\Program Files (x86)\Kingsoft\WPS Office&quot; (        for /r &quot;C:\Program Files (x86)\Kingsoft\WPS Office&quot; %%i in (wpsdocframe.dll) do (            if exist &quot;%%i&quot; set &quot;WPS_DLL=%%i&quot;        )    ))if defined WPS_DLL (    echo [执行] 正在注册组件: &quot;%WPS_DLL%&quot;    regsvr32 /s &quot;%WPS_DLL%&quot;    if %errorlevel% equ 0 (        echo [成功] WPS 嵌入组件注册成功！    ) else (        echo [失败] 权限不足，请右键选择 &quot;以管理员身份运行&quot; 本脚本！    ))pause</code></pre><hr /><h3 id="7.2-%E8%87%AA%E5%AE%9A%E4%B9%89%E8%B7%AF%E5%BE%84%E6%89%8B%E5%8A%A8%E6%B3%A8%E5%86%8C%E5%91%BD%E4%BB%A4%E6%A8%A1%E7%89%88-(%E5%8C%BA%E5%88%86-32%E4%BD%8D-%2F-64%E4%BD%8D-%E7%B3%BB%E7%BB%9F%E4%B8%8E%E7%BB%84%E4%BB%B6)" tabindex="-1">7.2 自定义路径手动注册命令模版 (区分 32位 / 64位 系统与组件)</h3><p>在非标准安装目录（如 <code>D:\CustomTools\WPS\office6\</code>）下，根据系统架构与 WPS 组件位数，请管理员在 CMD 中复制运行以下显式命令：</p><h4 id="%E5%9C%BA%E6%99%AF-a%EF%BC%9A%E5%9C%A8-64%E4%BD%8D-%E7%B3%BB%E7%BB%9F%E4%B8%8A%EF%BC%8C%E6%B3%A8%E5%86%8C-32%E4%BD%8D-(x86)-%E7%9A%84-wps-%E7%BB%84%E4%BB%B6%EF%BC%88%E5%85%B3%E9%94%AE%EF%BC%9A%E5%BF%85%E9%A1%BB%E4%BD%BF%E7%94%A8-syswow64%EF%BC%89" tabindex="-1">场景 A：在 64位 系统上，注册 32位 (x86) 的 WPS 组件（关键：必须使用 SysWOW64）</h4><blockquote><p><strong>注意</strong>：必须显式调用 <code>SysWOW64\regsvr32.exe</code>，系统才会将其自动写入 <code>WOW6432Node</code> 注册表项！</p></blockquote><pre><code class="language-cmd">%SystemRoot%\SysWOW64\regsvr32.exe &quot;D:\你的自定义路径\wpsdocframe.dll&quot;</code></pre><h4 id="%E5%9C%BA%E6%99%AF-b%EF%BC%9A%E5%9C%A8-64%E4%BD%8D-%E7%B3%BB%E7%BB%9F%E4%B8%8A%EF%BC%8C%E6%B3%A8%E5%86%8C-64%E4%BD%8D-(x64)-%E7%9A%84-wps-%E7%BB%84%E4%BB%B6" tabindex="-1">场景 B：在 64位 系统上，注册 64位 (x64) 的 WPS 组件</h4><pre><code class="language-cmd">%SystemRoot%\System32\regsvr32.exe &quot;D:\你的自定义路径\wpsdocframe.dll&quot;</code></pre><h4 id="%E5%9C%BA%E6%99%AF-c%EF%BC%9A%E5%9C%A8-32%E4%BD%8D-%E5%8E%9F%E7%94%9F%E7%B3%BB%E7%BB%9F%E4%B8%8A%E6%B3%A8%E5%86%8C" tabindex="-1">场景 C：在 32位 原生系统上注册</h4><pre><code class="language-cmd">%SystemRoot%\System32\regsvr32.exe &quot;D:\你的自定义路径\wpsdocframe.dll&quot;</code></pre><hr /><h2 id="%F0%9F%8F%81-8.-%E6%80%BB%E7%BB%93%E4%B8%8E%E5%A4%8D%E7%9B%98%E5%90%AF%E7%A4%BA" tabindex="-1">🏁 8. 总结与复盘启示</h2><ol><li><strong>不要轻信误导性的 Exception Message</strong>：<code>0x8007007E (找不到指定的模块)</code> 既可能是因为真的缺少文件，也极有可能是因为 <strong>32位/64位 进程架构跨域加载 64位 Native DLL 失败</strong>；</li><li><strong>WinDbg 内存 dump 是定位 COM 控件物理 GUID 的神兵利器</strong>：通过 <code>!clrstack -p</code> -&gt; <code>!do &lt;AxHost&gt;</code> -&gt; <code>dd &lt;GuidField&gt; L4</code>，无需源码即可硬核拆解出控件在运行期真正申请的物理 CLSID；</li><li><strong>闭环架构设计</strong>：在 C# 客户端开发中，针对 Office / WPS 等第三方 COM 组件，必须兼顾 <code>AnyCPU (Prefer32Bit=false)</code> 动态适配、<code>SetDllDirectory</code> 路径挂载与 UAC 自愈重试，才能打造出零崩溃的百锤打不烂的应用！</li></ol><blockquote><p><a href="https://github.com/maifeipin/EmbedWPSinWinform" target="_blank">项目源码</a><br /><img src="/upload/2026/07/image-1784809866955.png" alt="image-1784809866955" /></p></blockquote>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[探索 BERTopic 在海量 RSS 资讯挖掘中的工业级实践：两阶段聚类与增量优化]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/tan-suo-bertopic-zai-hai-liang-rss-zi-xun-wa-jue-zhong-de-gong-ye-ji-shi-jian--liang-jie-duan-ju-lei-yu-zeng-liang-you-hua" />
                <id>tag:https://maifeipin.com,2026-07-18:tan-suo-bertopic-zai-hai-liang-rss-zi-xun-wa-jue-zhong-de-gong-ye-ji-shi-jian--liang-jie-duan-ju-lei-yu-zeng-liang-you-hua</id>
                <published>2026-07-18T07:59:03+08:00</published>
                <updated>2026-07-18T08:03:48+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>在大数据与内容推荐场景中，面对海量非结构化的资讯数据（例如每日抓取的数十万篇 RSS 订阅文章），如何自动发现高价值的热点主题，并在此基础上进行热度监测和趋势预测，是一个极具挑战性的工业级任务。</p><p>传统的 LDA（隐含狄利克雷分布）由于缺少语义上下文理解，对于短文本和跨领域异质文本的聚类效果往往差强人意。而近年来崛起的 <strong>BERTopic</strong>，凭借预训练 Transformer 语义嵌入、UMAP 降维以及 HDBSCAN 层次聚类的深度结合，成为了当前主题建模（Topic Modeling）领域最前沿的利器。</p><p>本文将结合 <code>lite_agent</code> 项目中实际落地的一套 <strong>RSS 热点发现系统</strong>，分享如何通过 <strong>“先分类、后聚类” 的两层架构</strong>、<strong>增量嵌入缓存优化</strong> 以及 <strong>LLM 协同主题命名</strong>，将 BERTopic 的聚类离群率（Outliers）从原生的 52% 骤降至 16%，并实现低延迟的生产级每日调度。</p><hr /><h2 id="1.-%E4%BB%80%E4%B9%88%E6%98%AF-bertopic%EF%BC%9F%E6%A0%B8%E5%BF%83%E5%9B%9B%E9%98%B6%E6%AE%B5%E8%A7%A3%E6%9E%90" tabindex="-1">1. 什么是 BERTopic？核心四阶段解析</h2><p>BERTopic 的底层架构并非单一算法，而是流水线式的算法组合。其核心思想是通过以下 <strong>四个阶段</strong>，一步步将零散的文本映射并聚合成直观的主题：</p><pre><code class="language-">┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐│  1. 向量化坐标   ├────&gt;│    2. 降维      ├────&gt;│ 3. 亲近关系聚类 ├────&gt;│ 4. 分组与大模型 │ (Embedding 转换)│     │   (UMAP 降维)   │     │ (HDBSCAN 分组)  │     │ (c-TF-IDF / LLM)│└─────────────────┘     └─────────────────┘     └─────────────────┘     └─────────────────┘</code></pre><ol><li><strong>向量化多维坐标 (Embedding)</strong>：利用预训练语言模型（如 <code>SentenceTransformer</code>）将文本转化为高维空间中的稠密向量（例如 384 维坐标），将语义相似性转化为数学空间中的余弦距离。</li><li><strong>多维空间降维 (UMAP)</strong>：高维向量中存在维度灾难，直接计算距离效果极差。BERTopic 引入 UMAP（统一流形逼近与投影），在保留数据全局和局部流形结构的前提下，将特征压缩到较低的维度（如 5 维），使得后续的邻近计算更加高效精准。</li><li><strong>计算亲近关系与汇总分组 (HDBSCAN)</strong>：HDBSCAN 基于密度的聚类算法会自动计算低维坐标下向量之间的临近关系。它不需要预先指定聚类数量（如 K-Means 那样），而是自动识别出任意形状和密度的簇，并允许将孤立的噪声点划分为 “-1” 离群组，从而生成干净、高内聚的话题组。</li><li><strong>主题表示与分组命名 (c-TF-IDF &amp; LLM)</strong>：通过基于类别的 TF-IDF（c-TF-IDF）算法，从每个分组中抽取最具有区分度的 Top 关键词。在工业落地中，我们更进一步，将分组中随机抽样的标题样本提供给 LLM（大语言模型），自动提炼出极具表意能力的人类可读中文主题标签（如“大模型推理加速”）。</li></ol><hr /><h2 id="2.-%E6%A0%B8%E5%BF%83%E4%B8%9A%E5%8A%A1%E6%8C%91%E6%88%98" tabindex="-1">2. 核心业务挑战</h2><p>在我们的场景中，我们需要处理来自不同平台（如小红书、哔哩哔哩、arXiv、V2EX、GitHub、IT之家、雪球和知乎等）的 <strong>290,000+</strong> 篇异质资讯文本。这带来了三大技术痛点：</p><ol><li><strong>极端异质性与文本嘈杂</strong>：技术贴、学术论文、短视频小作文、财经短讯混杂在一起。如果直接进行全局单层聚类，模型很难在杂乱无章的特征空间里找到清晰的边界，导致海量文章被划分到 “-1” 离群类别，全局离群率一度高达 <strong>52%</strong>。</li><li><strong>计算开销与延迟瓶颈</strong>：在海量文本上计算 BERT 稠密向量并做 UMAP / HDBSCAN 迭代的开销极其巨大，无法适应每日（daily）近实时的热度监测要求。</li><li><strong>原生关键词表意能力差</strong>：BERTopic 原生提取的代表词通常是零散的词语或中文乱码连写，无法直接作为 Dashboard 上的分类标签展示给用户。</li></ol><hr /><h2 id="3.-%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%EF%BC%9A%E4%B8%A4%E9%98%B6%E6%AE%B5%E2%80%9C%E5%85%88%E5%88%86%E7%B1%BB%EF%BC%8C%E5%90%8E%E8%81%9A%E7%B1%BB%E2%80%9D%E6%9E%B6%E6%9E%84" tabindex="-1">3. 解决方案：两阶段“先分类，后聚类”架构</h2><p>为了打破单层全局聚类的瓶颈，我们设计了 <strong>Layer 1 分类映射 + Layer 2 局部聚类</strong> 的多层混合模式。</p><h3 id="%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%A4%E9%98%B6%E6%AE%B5%E6%9E%B6%E6%9E%84%E6%98%AF%E7%A0%B4%E5%B1%80%E5%85%B3%E9%94%AE%EF%BC%9F" tabindex="-1">为什么两阶段架构是破局关键？</h3><ul><li><strong>降低特征干扰</strong>：通过预设的 <code>SOURCE_MAP</code>，先按数据源将资讯划分到 7 大基础分类（如“AI与学术”、“技术社区”、“科技资讯”、“财经商业”等）。这相当于在特征空间的源头做了一次清洗，让同一层级的文本在一个同质的特征分布中做更精准的细分。</li><li><strong>自适应聚类阈值</strong>：各基础分类数据量（n）差异极大。通过引入 <code>min_topic_size = max(30, n // 600)</code> 动态设置每个局域聚类器的尺度，避免小类别下的微型主题被噪声淹没，或大类别下的主题过于破碎。</li><li><strong>效果验证</strong>：应用此模式后，整体系统的离群率直接从 <strong>52%</strong> 降到了 <strong>16%</strong>。</li></ul><hr /><h2 id="4.-%E7%94%9F%E4%BA%A7%E7%BA%A7%E4%BC%98%E5%8C%96%EF%BC%9Adaily-%2F-weekly-%E5%8F%8C%E6%A8%A1%E5%BC%8F%E4%B8%8E%E5%A2%9E%E9%87%8F%E7%BC%93%E5%AD%98" tabindex="-1">4. 生产级优化：Daily / Weekly 双模式与增量缓存</h2><p>在生产部署中，频繁跑全量聚类（~35分钟）是不现实的。为此，我们建立了 <strong>Weekly（周重构）</strong> 和 <strong>Daily（日预测）</strong> 双模式流水线。</p><h3 id="4.1-%E5%A2%9E%E9%87%8F%E5%B5%8C%E5%85%A5%E7%BC%93%E5%AD%98%EF%BC%88incremental-embedding-cache%EF%BC%89" tabindex="-1">4.1 增量嵌入缓存（Incremental Embedding Cache）</h3><p>在 Weekly 模式下，为了节省 GPU/NPU 编码时间，我们在 Mac (Apple Silicon M4) 机器上利用 PyTorch MPS 设备加速，并实现了一套增量缓存机制：</p><ol><li>从 <code>embeddings.npy</code> 和 <code>doc_ids.json</code> 中读取历史计算的特征向量与 ID 列表。</li><li>计算当前待聚类数据与缓存的差集，只对<strong>增量文章</strong>进行 <code>SentenceTransformer</code> 编码（选用 <code>paraphrase-multilingual-MiniLM-L12-v2</code> 多语言模型）。</li><li>按当前 ID 列表顺序，快速拼接历史嵌入向量与新特征行，更新对齐缓存并保存。这使得 Weekly 的非重嵌入阶段耗时控制在 <strong>3分钟内</strong>。</li></ol><h3 id="4.2-%E6%AF%8F%E6%97%A5-transform-%E9%A2%84%E6%B5%8B%E6%9C%BA%E5%88%B6" tabindex="-1">4.2 每日 Transform 预测机制</h3><ul><li><strong>Daily 模式</strong>只导入近 24 小时的全新文章。</li><li>该模式下<strong>不重新拟合聚类模型</strong>，而是通过读取 Weekly 阶段序列化保存好的 7 个 Per-Category BERTopic 局域模型对新文章做 <code>transform</code> 归类预测。这不仅避免了 “主题漂移”（Topic Drift）风险，还让每日流水线的耗时压缩至 <strong>5 分钟以内</strong>。</li></ul><hr /><h2 id="5.-%E5%8F%91%E7%8E%B0%E6%96%B0%E8%B6%8B%E5%8A%BF%E7%9A%84%E8%83%BD%E5%8A%9B-(discovering-new-trends)" tabindex="-1">5. 发现新趋势的能力 (Discovering New Trends)</h2><p>本系统不仅可以做存量归纳，还具备了<strong>发掘行业新趋势和突发热点</strong>的能力。</p><p>通过 <code>topic_diff.py</code>，系统在每周全量重构之后，会对比本周与上周的聚类特征：</p><ul><li><strong>话题指纹识别</strong>：通过对聚类下的“中文名称 + 核心 c-TF-IDF 关键词”进行哈希指纹提取，判定两个主题是否属于延续关系。</li><li><strong>捕获新趋势</strong>：若某个主题的特征指纹在上一周中完全不存在，系统会将其标记为 <strong><code>新增 (New)</code></strong>。特别是在学术和技术社区中，新的前沿词汇或技术栈（如某新型推理框架的发布）通常会以一簇高内聚的新增主题呈现，从而捕捉到技术演进的新风向。</li><li><strong>趋势动态追踪</strong>：通过计数增减（如本周数量 vs 上周数量的百分比跃升），自动计算并排行 <strong><code>涨幅 Top 5</code> / <code>消亡 Topic</code></strong>，形成技术和资讯趋势的脉搏式记录。</li></ul><hr /><h2 id="6.-%E8%90%BD%E5%9C%B0%E7%BB%86%E8%8A%82%E4%B8%8E%E9%81%BF%E5%9D%91%E6%8C%87%E5%8D%97" tabindex="-1">6. 落地细节与避坑指南</h2><h3 id="6.1-tokenization-%E5%BA%8F%E5%88%97%E5%8C%96%E6%8A%A5%E9%94%99%E9%97%AE%E9%A2%98" tabindex="-1">6.1 Tokenization 序列化报错问题</h3><p>在定义 BERTopic 的 <code>Vectorizers</code> 时，若使用 <code>lambda</code> 匿名函数包装分词器（例如 <code>jieba</code>），会导致模型在 <code>pickle</code> 序列化时崩溃。<strong>正确解法</strong>是必须将分词函数定义为全局层级的命名函数（Named Function）：</p><pre><code class="language-python">def jieba_tokenizer(text):    import jieba    return jieba.lcut(text)vectorizer_model = CountVectorizer(tokenizer=jieba_tokenizer, stop_words=STOP)</code></pre><h3 id="6.2-llm-%E5%8D%8F%E5%90%8C%E5%91%BD%E5%90%8D%E4%B8%8E%E7%BC%93%E5%AD%98" tabindex="-1">6.2 LLM 协同命名与缓存</h3><p>我们随机抽取每个 Topic 聚类中具有代表性的 12 条标题样本作为 Prompt 上下文，喂给 DeepSeek API 提炼短标签（如 “大模型推理框架与加速”）。为了控制 API 成本，所有的命名结果都保存在 <code>topic_names_cache.json</code> 缓存中，Daily 预测时自动匹配，做到 “0 Token 消耗”。</p><hr /><h2 id="7.-%E4%B8%9A%E5%8A%A1%E6%88%90%E6%9E%9C%E4%B8%8E%E5%90%8E%E7%BB%AD%E8%AE%A1%E5%88%92-(future-directions)" tabindex="-1">7. 业务成果与后续计划 (Future Directions)</h2><p>目前该 BERTopic 解决方案已经在生产环境平稳跑通，并实现了三级 Facet 过滤导航和精准热点预警。在接下来的迭代中，我们计划引入以下后续模块：</p><ol><li><strong>情感极性分析 (Sentiment Polarities)</strong>：<br />利用 LLM 对高热度的 Top-N 话题进行批量情感极性打分（正向/中性/负向），从而绘制特定行业/话题下的公众舆情和情绪走向图。</li><li><strong>AI 每日总结简报 (Daily Briefings)</strong>：<br />改变目前零散推送的热点模式，引入大语言模型（如 DeepSeek V3 或豆包 Lite）对今日 Top-5 爆点话题下的多篇代表文章进行内容提炼，每日定时生成一篇 300 字的精简“行业早报”。</li><li><strong>可视化看板进阶</strong>：<br />在 Dashboard 端增加“今日简报”和“情感趋势图谱”专属入口，结合语义搜索（Qdrant），为用户提供一站式、更具交互性的热点情报体验。</li></ol><p><img src="/upload/2026/07/image-1784333021704.png" alt="image-1784333021704" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[打造个人数据私网：打通 Meilisearch 与 AI Agent，构建万级 RSS 与邮件检索中枢]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/da-zao-ge-ren-shu-ju-si-wang--da-tong-meilisearch-yu-aiagent-gou-jian-wan-ji-rss-yu-you-jian-jian-suo-zhong-shu" />
                <id>tag:https://maifeipin.com,2026-07-12:da-zao-ge-ren-shu-ju-si-wang--da-tong-meilisearch-yu-aiagent-gou-jian-wan-ji-rss-yu-you-jian-jian-suo-zhong-shu</id>
                <published>2026-07-12T19:17:23+08:00</published>
                <updated>2026-07-13T07:48:14+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>在上一篇博客中，我们分享了如何通过 <strong>OAuth2</strong>、<strong>Graph API</strong> 彻底穿透海内外主流邮箱的风控屏障，安全地将账单、通知邮件拉入 AI 代理的本地存储中。</p><p>但随着数据滚雪球般增长——不仅有每天抓取的各类账单邮件，还有从 MongoDB 数据库中持续沉淀的 <strong>27 万篇 RSS 互联网资讯</strong>，一个棘手的问题浮出水面：</p><blockquote><p>“如何在一瞬间找到我上个月的外币信用卡账单，或者检索出某篇被淹没的 AI 技术资讯？”<br />并且，如何安全、优雅地把指令发送给机器人执行，并实时看到机器人的输出？</p></blockquote><p>为此，我们再次对架构进行了升级，在 <strong><code>lite_agent</code></strong> 中深度集成了开源轻量级搜索引擎 <strong>Meilisearch</strong>，并手搓了一个<strong>极致美学的零构建暗黑玻璃态（Dark Glassmorphism）检索与中枢控制台</strong>。</p><p>本文将向大家盘点本次架构打通的硬核技术细节：<strong>数据增量同步、双层安全网关、Nginx 极窄只读注入、以及 SSE 流式任务终端的构建</strong>。</p><hr /><h2 id="%F0%9F%8F%97%EF%B8%8F-%E6%95%B4%E4%BD%93%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1" tabindex="-1">🏗️ 整体架构设计</h2><p>在个人数据的检索上，我们坚决反对“粗暴的暴露”。整个中枢控制台的调用与防线可以用如下架构图表示：</p><pre><code class="language-"> 用户 (浏览器) ──( HTTPS + Basic Auth )──&gt; [ Nginx 网关 ]                                            │               ┌────────────────────────────┴───────────────────────────┐               ▼ (只读 Search Key 注入)                                ▼ (API Token 注入)       /meili/* ──&gt; [ Meilisearch ]                                   /agent/* ──&gt; [ Lite Agent API ]      (只读全文检索与数据量统计)                                          (流式命令执行 &amp; SSE 日志流)</code></pre><hr /><h2 id="%E2%9A%A1-%E6%80%A7%E8%83%BD%E8%B0%83%E4%BC%98%EF%BC%9A%E5%A2%9E%E9%87%8F%E5%90%8C%E6%AD%A5%E4%B8%8E%E5%A4%A7%E5%8C%85%E5%88%86%E6%89%B9%EF%BC%88payload-batching%EF%BC%89" tabindex="-1">⚡ 性能调优：增量同步与大包分批（Payload Batching）</h2><p>我们的数据分散在 SQLite（邮件）和 MongoDB（RSS 资讯）中。要将它们同步给 Meilisearch，我们需要解决性能与稳定性的双重问题。</p><h3 id="1.-sqlite-%2B-mongodb-%E5%A2%9E%E9%87%8F%E7%8A%B6%E6%80%81%E5%90%8C%E6%AD%A5" tabindex="-1">1. SQLite + MongoDB 增量状态同步</h3><p>我们开发了独立的同步技能 <code>ops_meili_sync</code>。为了防范反复扫信导致的 CPU 飙升与网络带宽浪费，我们设计了基于 <code>meili_sync_state.json</code> 的增量同步机制：</p><ul><li>对于 <strong>SQLite</strong>，通过主键 <code>uid</code> + <code>fetched_at</code> 时间戳增量拉取新邮件，并进行 <code>category</code>（分类）和 <code>sender</code>（发件人）的多表联合查询（JOIN）同步。</li><li>对于 <strong>MongoDB</strong>，由于采用了按月归档集合的设计（例如 <code>FeedItem_202607</code>），同步程序自动计算当前月和上个月的动态集合，并利用 <strong>ObjectId</strong> 递增比对过滤，实现无感知的增量抓取。</li><li><strong>定时任务集成</strong>：我们在 Agent 的 Cron 引擎中注册了定时器，每 10 分钟自动在后台拉取更新，实现数据源的源源不断。</li></ul><h3 id="2.-%E9%81%BF%E5%85%8D%E2%80%9C413-payload-too-large%E2%80%9D%EF%BC%9A%E6%95%B0%E6%8D%AE%E5%88%86%E6%89%B9%E8%AE%BE%E8%AE%A1" tabindex="-1">2. 避免“413 Payload Too Large”：数据分批设计</h3><p>在第一次进行 27 万条 RSS 历史文章全量同步时，最初由于将全部数据打包在一个 HTTP POST 中发送，直接触发了 Meilisearch 的单次负载限制，返回了 <code>413 Payload Too Large</code>。<br />我们迅速将同步策略重构为 <strong>200 篇分批并发推送（Batching）</strong>：</p><pre><code class="language-python"># 核心分批逻辑batch_size = 200for i in range(0, len(docs), batch_size):    batch = docs[i : i + batch_size]    res = _meili_request(&quot;/indexes/rss/documents&quot;, &quot;POST&quot;, batch)    if res:        rss_count += len(batch)        state[&quot;last_rss_sync&quot;] = batch[-1][&quot;fetched_at&quot;]</code></pre><p>这样不仅极其省内存，在遇到网络波动时还能实现“断点续传”。</p><hr /><h2 id="%F0%9F%94%92-%E7%BD%91%E5%85%B3%E5%AE%89%E5%85%A8%EF%BC%9Anginx-%E6%9E%81%E7%AA%84%E6%9D%83%E9%99%90%E6%B3%A8%E5%85%A5%E6%A8%A1%E5%9E%8B" tabindex="-1">🔒 网关安全：Nginx 极窄权限注入模型</h2><p>把搜索引擎和 Agent API 暴露给前端静态网页，最大的安全隐患莫过于 <strong>Master Key 泄露</strong>。一旦主密钥暴露在前端 JavaScript 中，任何恶意访客都能直接删光你的整个索引库。</p><p>为了杜绝这一隐患，我们设计了<strong>三重网关防线</strong>：</p><h3 id="%E9%98%B2%E7%BA%BF-1%EF%BC%9A%E5%85%A8%E7%AB%99-basic-auth" tabindex="-1">防线 1：全站 Basic Auth</h3><p>整个虚拟主机部署在 <code>mail.maifeipin.com</code> 下，由 Nginx 的 <code>auth_basic</code> 在最外层直接拦截，未授权用户甚至无法加载页面的一张图片、一个 JS 字节。</p><h3 id="%E9%98%B2%E7%BA%BF-2%EF%BC%9A%E5%8F%AA%E8%AF%BB-api-%E5%AF%86%E9%92%A5%E6%B3%A8%E5%85%A5" tabindex="-1">防线 2：只读 API 密钥注入</h3><p>在配置 Meilisearch 代理时，Nginx 在反向代理层隐式将只读的 <strong>Search Key</strong> 注入到 Request Header 中。前端 JavaScript 甚至感知不到 Key 的存在，从而彻底阻断了密钥通过前端泄露的可能性：</p><pre><code class="language-nginx">location /meili/ {    proxy_pass http://127.0.0.1:7700/;    # 代理层注入 Meilisearch 只读 Search Key，前端完全无感    proxy_set_header Authorization &quot;Bearer f404afbf2e397f28...d1da8dd62f011b9d&quot;;}</code></pre><h3 id="%E9%98%B2%E7%BA%BF-3%EF%BC%9A%E6%9E%81%E7%AA%84-stats-%E6%9D%83%E9%99%90%E9%80%9A%E9%81%93-(master-key-%E5%8A%A8%E6%80%81%E6%8F%90%E6%9D%83)" tabindex="-1">防线 3：极窄 <code>stats</code> 权限通道 (Master Key 动态提权)</h3><p>只读 Key 固然安全，但它只能调用 <code>/search</code> 检索接口，<strong>无权调用 <code>/stats</code> 统计接口</strong>。这会导致页面左侧的数据源数量无法显示真实总量（空查询时只能拿到 <code>/search</code> 的 1000 条默认上限估算）。<br />为此，我们设计了一条极其精细的<strong>极窄 stats 提权通道</strong>：</p><pre><code class="language-nginx"># 仅当访问 stats 统计接口时，才在 Nginx 内部用 Master Key 进行提权转发location ~ ^/meili/indexes/([^/]+)/stats$ {    proxy_pass http://127.0.0.1:7700/indexes/$1/stats;    proxy_set_header Authorization &quot;Bearer MeiliUnifiedSearchSecureKey...Awesome&quot;;}</code></pre><p>由于 <code>/stats</code> 是只读元数据接口，不涉及任何文档详情，也不支持写入/删除，这样的定向注入实现了“<strong>既能显示真实总数，又防范了任何越权操作</strong>”的精美平衡。</p><hr /><h2 id="%F0%9F%92%BB-%E5%89%8D%E7%AB%AF%E6%9E%81%E8%87%B4%E4%BD%93%E9%AA%8C%EF%BC%9A%E9%9B%B6%E6%9E%84%E5%BB%BA-spa-%E4%B8%8E-sse-%E9%95%BF%E8%BF%9E%E6%8E%A5%E6%8E%A7%E5%88%B6%E5%8F%B0" tabindex="-1">💻 前端极致体验：零构建 SPA 与 SSE 长连接控制台</h2><p>在前端呈现上，我们坚持<strong>不引入任何构建工具（如 Vite, Next.js）</strong>，用原生的 HTML, CSS 和 JS 实现了一个零依赖的单页应用（SPA）。</p><h3 id="1.-spotlight-%E5%BC%8F%E6%99%BA%E8%83%BD%E5%91%BD%E4%BB%A4%E9%9D%A2%E6%9D%BF-(command-palette)" tabindex="-1">1. Spotlight 式智能命令面板 (Command Palette)</h3><p>在网页中敲击 <code>/</code>（斜杠）即可呼出 Spotlight 命令弹窗。我们设计了<strong>按键 autocomplete 状态填充联动</strong>：</p><ul><li>键盘上下方向键导航时，当前项的命令（如 <code>/ops_backup_data</code> 或 <code>/reprocess</code>）会自动实时填充并同步进输入框，支持就地进行参数编辑（如 <code>/reprocess uid=120</code>）再敲击回车。</li><li>鼠标悬停（<code>mouseenter</code>）自动跟随机键盘高亮焦点，体验如丝般顺滑。</li></ul><h3 id="2.-%E6%8B%9F%E7%89%A9%E5%8C%96%E7%BB%88%E7%AB%AF%E4%B8%8E-sse-%E5%AE%9E%E6%97%B6%E6%97%A5%E5%BF%97%E6%B5%81" tabindex="-1">2. 拟物化终端与 SSE 实时日志流</h3><p>当你通过命令面板发送异步任务（如数据云备份）时，网页右下角会自动弹出一个拟物化设计的黑色 Console 控制台：</p><ul><li>前端通过监听 Agent 的 <code>/api/v1/tasks/{task_id}/stream</code>，建立起持久的 <strong>SSE (Server-Sent Events) 连接</strong>。</li><li>后台执行的日志通过 SSE 实时在终端里逐行打出，仿佛直接在服务器上跑 Shell 脚本一样亲切。</li><li>为了确保流式输出零延迟，我们在 Nginx 代理上配置了 <code>proxy_buffering off</code>，防止响应被 Nginx 缓冲区拦截造成卡顿。</li></ul><hr /><h2 id="%F0%9F%94%AE-%E5%90%8E%E7%BB%AD%E8%A7%84%E5%88%92%EF%BC%9A%E6%9B%B4%E5%BA%9E%E5%A4%A7%E7%9A%84%E4%B8%AA%E4%BA%BA%E7%9F%A5%E8%AF%86%E7%BD%91" tabindex="-1">🔮 后续规划：更庞大的个人知识网</h2><p>邮件和 RSS 仅仅是个人信息数字孤岛的第一步。接下来，我们计划继续扩大 Meilisearch 与 AI Agent 的索引版图：</p><ol><li><strong>HedgeDoc 与云笔记互通（搭建个人知识库）</strong>：<ul><li>我们计划打通 <strong>HedgeDoc</strong>（Markdown 协作文档）与私有云笔记系统。通过 Webhook 实时捕获文档的创建与修改，自动将其推送到 Meilisearch 建立索引。</li><li>挂载 LLM 对半结构化的 MD 笔记进行<strong>实体提取</strong>与<strong>语义标签化</strong>，让你能够一键搜索所有关于“架构设计”或“会议纪要”的碎片化记忆。</li></ul></li><li><strong>NAS 本地媒体库与智能检索</strong>：<ul><li>接入家中 NAS 媒体服务（如 Emby/Jellyfin/Plex）的元数据，甚至是照片库的 EXIF 属性。</li><li>届时，你只需在微信端输入“<em>帮我找找去年秋天拍的猫咪照片</em>”或“<em>检索所有 4K 分辨率的科幻电影</em>”，Agent 就能瞬间定位本地文件，完成智能多模态调度。</li></ul></li></ol><hr /><h2 id="%F0%9F%9A%80-%E7%BB%93%E8%AF%AD" tabindex="-1">🚀 结语</h2><p>通过将 <strong>Meilisearch 的全文检索</strong> 和 <strong>Lite Agent 的指令下发</strong> 熔铸于一体，我们不再只是拥有一堆冷冰冰的数据库，而是真正搭建了一个随身携带、绝对安全的数据和命令检索中枢。</p><p>如果您也想摆脱杂乱无章的账单与信息孤岛，不妨试试把搜索引擎挂载到你的 AI Agent 上！</p><p><em>Enjoy hacking with Meilisearch, Agent &amp; Nginx!</em></p><p><img src="/upload/2026/07/image-1783855268171.png" alt="image-1783855268171" /></p><p><img src="/upload/2026/07/image-1783900084451.png" alt="image-1783900084451" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[全能智能邮件助手：打通海内外多邮箱与 OAuth2 现代鉴权]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/quan-neng-zhi-neng-you-jian-zhu-shou--da-tong-hai-nei-wai-duo-you-xiang-yu-oauth2-xian-dai-jian-quan" />
                <id>tag:https://maifeipin.com,2026-07-12:quan-neng-zhi-neng-you-jian-zhu-shou--da-tong-hai-nei-wai-duo-you-xiang-yu-oauth2-xian-dai-jian-quan</id>
                <published>2026-07-12T13:24:54+08:00</published>
                <updated>2026-07-12T19:03:53+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<blockquote><p>在日常的自动化与 AI Agent 实践中，处理各类通知、账单邮件是一个极其高频的需求。为此，我开源了两个互相配合的项目：</p></blockquote><ul><li><strong><a href="https://github.com/maifeipin/mail-statement-parser" target="_blank">mail-statement-parser</a></strong>：纯粹、无状态的底层邮件抓取与解析引擎。</li><li><strong><a href="https://github.com/maifeipin/lite_agent" target="_blank">lite_agent</a></strong>：一个轻量级的 AI Agent 框架，通过技能挂载调用底层解析引擎，实现微信端对话与推送。</li></ul><p>近期，我们对邮件模块进行了一次“大换血”级别的重构。本文将详细盘点新增的功能：如何优雅地统一接入国内外四大主流邮箱（QQ、163、Gmail、Outlook），如何彻底告别“密码错误”，全面拥抱 <strong>OAuth2</strong> 和 <strong>Microsoft Graph API</strong>。</p><hr /><h2 id="%F0%9F%92%A1-%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E4%BB%AC%E9%9C%80%E8%A6%81%E9%87%8D%E6%9E%84%E9%89%B4%E6%9D%83%EF%BC%9F" tabindex="-1">💡 为什么我们需要重构鉴权？</h2><p>过去，我们习惯于在配置里写死账号和“应用专用密码”（App Password）去走 POP3/IMAP。但现实是残酷的：</p><ul><li><strong>Google / Microsoft 等海外巨头正在疯狂收紧风控</strong>。哪怕你填了应用专用密码，在异地 VPS 上请求也很容易直接抛出 <code>[AUTH] Username and password not accepted</code> 或 <code>access_denied</code>。</li><li><strong>传统 POP3 的局限性</strong>。连接慢、拉取全量邮件重试成本高。</li></ul><p>为了解决这个问题，我们在项目中引入了全新的鉴权机制，针对不同厂商“对症下药”。</p><h3 id="%E6%94%AF%E6%8C%81%E7%9A%84%E5%9B%9B%E5%A4%A7%E9%82%AE%E7%AE%B1%E4%B8%8E%E6%8E%A5%E5%85%A5%E6%96%B9%E6%A1%88" tabindex="-1">支持的四大邮箱与接入方案</h3><ol><li><strong>国内阵营（QQ 邮箱、网易 163）</strong><ul><li>沿用最稳定、高效的 <strong>Basic Auth (应用授权码)</strong> + POP3/IMAP 协议。</li></ul></li><li><strong>国外阵营（Gmail、Outlook）</strong><ul><li><strong>Gmail</strong>：全面接入 <strong>OAuth 2.0 (Desktop App / Web App Flow)</strong>，通过本地回环获取 Token 并加密落盘，安全穿透 Google 风控。</li><li><strong>Outlook/Office365</strong>：直接拥抱现代化，抛弃 POP3，自动路由走 <strong>Microsoft Graph API</strong>，不仅免去了繁琐的邮件协议配置，抓取速度和稳定性也得到了史诗级提升。</li></ul></li></ol><hr /><h2 id="%F0%9F%9B%A0%EF%B8%8F-%E6%9E%81%E7%AE%80%E7%9A%84%E9%85%8D%E7%BD%AE%E4%BD%93%E9%AA%8C" tabindex="-1">🛠️ 极简的配置体验</h2><p>为了兼容不同的鉴权模式，我们在 <code>mail-statement-parser</code> 中重新设计了 <code>email-config.local.json</code>。</p><h3 id="1.-%E4%BC%A0%E7%BB%9F%E6%8E%88%E6%9D%83%E7%A0%81%E9%85%8D%E7%BD%AE%EF%BC%88%E4%BB%A5-qq-%E4%B8%BA%E4%BE%8B%EF%BC%89" tabindex="-1">1. 传统授权码配置（以 QQ 为例）</h3><p>国内邮箱极其简单，只需要提供 <code>authCode</code> 即可：</p><pre><code class="language-json">{  &quot;provider&quot;: &quot;qq&quot;,  &quot;account&quot;: &quot;yourmail@qq.com&quot;,  &quot;account_name&quot;: &quot;我的QQ&quot;,  &quot;authCode&quot;: &quot;你的十六位授权码&quot;}</code></pre><h3 id="2.-%E7%8E%B0%E4%BB%A3-oauth2-%E9%85%8D%E7%BD%AE%EF%BC%88%E4%BB%A5-gmail-%2F-outlook-%E4%B8%BA%E4%BE%8B%EF%BC%89" tabindex="-1">2. 现代 OAuth2 配置（以 Gmail / Outlook 为例）</h3><p>你需要去 Google Cloud Console 或 Azure AD 申请一对 Client ID 和 Client Secret（针对 Desktop / Web App）。<br />配置中摒弃了密码，取而代之的是 <code>auth_type</code> 和密钥信息：</p><pre><code class="language-json">{  &quot;provider&quot;: &quot;gmail&quot;,  &quot;account&quot;: &quot;yourmail@gmail.com&quot;,  &quot;account_name&quot;: &quot;我的Gmail&quot;,  &quot;auth_type&quot;: &quot;oauth2&quot;,  &quot;client_id&quot;: &quot;你的_Client_ID&quot;,  &quot;client_secret&quot;: &quot;你的_Client_Secret&quot;,  &quot;imap_proxy&quot;: &quot;127.0.0.1:18988&quot;  // 支持配置本地代理，解决国内服务器无法直连的问题}</code></pre><h3 id="3.-%E4%B8%80%E9%94%AE%E8%8E%B7%E5%8F%96%E4%B8%8E%E5%8A%A0%E5%AF%86-token" tabindex="-1">3. 一键获取与加密 Token</h3><p>配置好 <code>client_id</code> 后，你不再需要自己去手搓 OAuth 的繁琐请求。我们在项目中提供了一个专门的工具脚本 <code>oauth_helper.py</code>。<br />只需在终端运行：</p><pre><code class="language-bash">python oauth_helper.py authorize gmail# 或者python oauth_helper.py authorize outlook</code></pre><p>脚本会自动弹出浏览器让你完成授权。拿到 Code 后，系统会利用本机的环境变量 <code>API_AUTH_TOKEN</code> 作为盐值，<strong>将 Refresh Token 高度加密并保存</strong>（生成 <code>token_gmail.json</code> 等）。<br />这就意味着，即使你的服务器被黑客连锅端了，没有你的环境变量盐值，他们也绝对无法解密盗用你的邮箱 Token！</p><hr /><h2 id="%F0%9F%A4%96-%E5%9C%A8-liteagent-%E4%B8%AD%E7%9A%84%E4%B8%9D%E6%BB%91%E8%B0%83%E7%94%A8" tabindex="-1">🤖 在 LiteAgent 中的丝滑调用</h2><p><code>mail-statement-parser</code> 提供了扎实的底层能力，而 <code>lite_agent</code> 则赋予了它灵魂。</p><p>我们在 <code>lite_agent</code> 中封装了诸如 <code>ops_mail_reader.py</code> 和 <code>ops_mail_list.py</code> 等 AI 技能。<br />得益于引擎的重构，现在你在微信端使用时，体验是跨时代的：</p><h3 id="%E5%9C%BA%E6%99%AF%E4%B8%80%EF%BC%9A%E7%9B%B4%E6%8E%A5%E6%9F%A5%E9%98%85%E6%9C%80%E6%96%B0%E9%82%AE%E4%BB%B6" tabindex="-1">场景一：直接查阅最新邮件</h3><p>你可以对机器人直接发送直通口令：</p><blockquote><p>👉 <code>/mail g 3</code> （代表抓取别名为 g 的 Gmail 最近 3 封）<br />👉 <code>/mail qq 5</code>（代表抓取别名为 qq 的 QQ 邮箱最近 5 封）</p></blockquote><p>底层系统会动态识别：</p><ul><li>如果是 QQ，走 POP3 传统通道。</li><li>如果是 Gmail，自动读取加密的 Token，携带 Bearer Token 走安全通道。</li><li>如果是 Outlook，瞬间切换到 Graph API 极速拉取并格式化返回。</li></ul><h3 id="%E5%9C%BA%E6%99%AF%E4%BA%8C%EF%BC%9A%E5%90%8E%E5%8F%B0%E5%AE%9A%E6%97%B6%E6%89%AB%E4%BF%A1%E4%B8%8E-llm-%E6%99%BA%E8%83%BD%E6%91%98%E8%A6%81" tabindex="-1">场景二：后台定时扫信与 LLM 智能摘要</h3><p>我们构建了 <code>fetch_only</code> (静默拉取) 和 <code>enrich</code> (LLM 提炼) 的解耦双阶段架构：</p><ol><li>定时任务定期静默触发，快速将新邮件拉入本地 SQLite 数据库中。</li><li>调度池分发给 LLM（如 GPT-4o 或 GLM-4 等），对冗长的营销邮件和账单进行提炼总结。</li><li>发现高优或者临期账单（比如信用卡还款），通过 <code>push_alert</code> 接口，秒级推送到你的企业微信 / 微信上。</li></ol><hr /><h2 id="%F0%9F%9A%A7-%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95%EF%BC%88%E9%81%BF%E5%9D%91%E6%8C%87%E5%8D%97%EF%BC%89" tabindex="-1">🚧 踩坑实录（避坑指南）</h2><p>在打通 OAuth2 和 Graph API 的过程中，我们踩过了无数深坑。为了让后来的开发者少走弯路，这里特意总结了 Gmail 和 Outlook 配置时的血泪教训：</p><h3 id="%F0%9F%9B%91-gmail-%E9%85%8D%E7%BD%AE%E7%9A%84%E4%B8%A4%E5%A4%A7%E6%AD%BB%E7%A9%B4" tabindex="-1">🛑 Gmail 配置的两大死穴</h3><ol><li><strong>必须选择“Web 应用”类型</strong>：<br />在 Google Cloud Console 创建 OAuth 客户端 ID 时，<strong>千万不要选择“桌面应用”</strong>！Google 最新的控制台 UI 存在设计缺陷（或有意为之），针对桌面应用类型<strong>不再提供/显示 Client Secret</strong>。这会导致后续本地脚本拉取 Token 时报错。正确的做法是：选择“<strong>Web 应用 (Web application)</strong>”，并将“已授权的重定向 URI”精确设置为 <code>http://localhost:8080</code>。</li><li><strong>切记添加测试账号</strong>：<br />当你的 OAuth 权限屏幕处于“测试 (Testing)”阶段时，即使你拥有 Client ID，直接授权也会报 403 Access Denied 错误。你必须在配置页面手动将你自己的 Gmail 地址加入到“<strong>测试用户 (Test users)</strong>”列表中，才能成功完成授权闭环。</li></ol><h3 id="%F0%9F%9B%91-outlook-(%E5%BE%AE%E8%BD%AF%E4%B8%AA%E4%BA%BA%E8%B4%A6%E5%8F%B7)-%E7%9A%84%E4%B8%89%E5%A4%A7%E7%8E%84%E5%AD%A6" tabindex="-1">🛑 Outlook (微软个人账号) 的三大玄学</h3><ol><li><strong>彻底放弃传统的 POP 功能</strong>：<br />如果你试图用 Outlook 个人账号走传统的 POP3（哪怕你带了应用密码），你会发现它在异地服务器上几乎 100% 会触发微软极其严苛的“异常登录拦截”。想要一劳永逸，唯一的出路就是老老实实走 <strong>Microsoft Graph API</strong>。</li><li><strong>Azure 应用与权限配置</strong>：<br />Graph API 的前置条件极高。你需要登录 Microsoft Entra (原 Azure AD)，注册一个全新的应用程序。并且必须在“API 权限”中，手动添加 <code>Microsoft Graph</code> -&gt; <code>Mail.Read</code> 权限。</li><li><strong>必须开启“允许公共客户端流”</strong>：<br />这是最容易卡住的一步！在 Azure 应用的“身份验证 (Authentication)”菜单栏最底部，有一个高级设置名为“<strong>允许公共客户端流 (Allow public client flows)</strong>”选项。<strong>必须将其切换为“是”</strong>。如果不开启此项，即便你账号密码权限全对，在使用本地无头脚本换取 Token 时，微软也会无情拒绝你的请求！</li></ol><hr /><h2 id="%F0%9F%9A%80-%E7%BB%93%E8%AF%AD" tabindex="-1">🚀 结语</h2><p>无论你是想要一个无感知的个人账单管家，还是想要一套能深度二开的 LLM 邮件处理流，这套架构都能轻松满足。</p><p>感兴趣的朋友，欢迎直接 Clone 体验、提 Issue 和 PR：<br />👉 <strong>解析引擎</strong>：<a href="https://github.com/maifeipin/mail-statement-parser" target="_blank">maifeipin/mail-statement-parser</a><br />👉 <strong>agent 主控</strong>：<a href="https://github.com/maifeipin/lite_agent" target="_blank">maifeipin/lite_agent</a></p><p><em>Enjoy hacking with LLM &amp; Emails!</em></p><p><img src="/upload/2026/07/image.png" alt="image" /></p><p>meilisearch 集成<br /><img src="/upload/2026/07/image-1783854217431.png" alt="image-1783854217431" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[构建多模型 AI 决策委员会：从架构到落地的实战经验]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/构建多模型ai决策委员会从架构到落地的实战经验" />
                <id>tag:https://maifeipin.com,2026-06-19:构建多模型ai决策委员会从架构到落地的实战经验</id>
                <published>2026-06-19T12:18:00+08:00</published>
                <updated>2026-06-19T13:19:16+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>在面对高难度决策或模糊场景时，单一的 AI 模型往往容易陷入幻觉或“顺从性偏见”。为了解决这个问题，我们在 <code>lite-agent</code> 项目中引入了一套全新的多模型决策引擎：<strong>AI 决策委员会 (ops_decision)</strong>。</p><p>本文将总结我们从零打造并部署这个 MVP 架构的实战经验，特别是这次“史无前例”的三端跨 AI 协作与极限 Code Review。</p><h2 id="%F0%9F%8E%AF-%E6%A0%B8%E5%BF%83%E6%9E%B6%E6%9E%84%E4%B8%8E-mvp-%E6%9C%BA%E5%88%B6%E8%AE%BE%E8%AE%A1" tabindex="-1">🎯 核心架构与 MVP 机制设计</h2><p>针对长文本裁决和模型稳定性问题，我们在 <code>ops_decision</code> 中落地了五大硬核机制：</p><ol><li><strong>统一认知底座（Decision Brief 层）</strong>：当用户输入超长文本（&gt;8000 字符）时，由于不同模型的 Context Window 与注意力机制存在差异，我们引入了轻量级模型（如 deepseek-flash）作为“简报员”。它负责抽取核心事实（Key Facts）、数据指标（Quantitative Signals）和未知变量（Known Uncertainties），为所有评委提供绝对公平的判决基础。</li><li><strong>强类型约束（Literal 锁死语义）</strong>：针对不同模型自造词导致的“语义错位”问题，我们放弃了传统的模糊匹配，直接使用 Pydantic 的 <code>Literal</code> 将输出严格锁死在 <code>[&quot;值得执行&quot;, &quot;高风险放弃&quot;, &quot;暂缓观察&quot;]</code> 三个方向。</li><li><strong>去中心化防作弊加权</strong>：剥夺了单一模型对自己总分的“最终决定权”。总分由 Python 引擎层读取配置文件中的基准权重与额外指标要求，通过独立加权算法得出。</li><li><strong>双重分歧熔断机制</strong>：不仅引入了得分的数学方差（标准差）三级预警机制（&gt;25 红色熔断，12-25 黄色预警），同时通过前置的 Literal 约束，一旦探测到多个有效判定结果的“方向”出现分歧，立刻阻断并交由人工复核。</li><li><strong>JSON 结构化全量审计</strong>：决策过程中的每一次评分、简报和推理过程都会落盘记录为 <code>audit_{run_id}.json</code>，支持后期的回溯分析。</li></ol><h2 id="%E2%9A%94%EF%B8%8F-%E6%9E%81%E9%99%90%E6%96%BD%E5%8E%8B%EF%BC%9A%E8%B7%A8%E5%B9%B3%E5%8F%B0-ai-code-review" tabindex="-1">⚔️ 极限施压：跨平台 AI Code Review</h2><p>在开发过程中，最令人印象深刻的莫过于跨平台的多 AI 协作。代码由驻扎在 Windows 终端的 <strong>Antigravity</strong> 编写，随后直接通过 <code>tmux send-keys</code> 投递到 Mac Mini 环境，交由驻扎在 Mac 的 <strong>Mac Claude</strong> 进行 Review。</p><p>这场 Review 被称为“魔鬼训练”毫不为过：</p><ul><li><strong>第一轮</strong>：Mac Claude 极其敏锐地指出 7 大架构缺陷，包括输入简报缺失、信任模型自报分数、Schema 约束不严等，并无情地打回重构。</li><li><strong>第二轮</strong>：经过通宵重构，针对 Edge Cases（如模型大面积熔断时的容错人数校验、配置判空），Mac Claude 再次指出 4 个必须修改的底层隐患。</li><li><strong>第三轮</strong>：进一步将正则匹配收紧为 Pydantic Literal 强校验，并终于拿到了 Mac Claude 的 <code>LGTM</code>（Looks Good To Me）。</li></ul><p>最后，代码回到 Windows 端交由 <strong>Win Claude</strong> 进行终审。它敏锐地捕获了跨平台（Linux/Windows）的文件路径问题以及特定大模型 SDK 的超时隐患（Timeout = 45s），提出了 3 个非阻塞微调。</p><h2 id="%F0%9F%9A%80-%E9%83%A8%E7%BD%B2%E4%B8%8A%E7%BA%BF%E4%B8%8E%E6%9C%AA%E6%9D%A5%E5%B1%95%E6%9C%9B" tabindex="-1">🚀 部署上线与未来展望</h2><p>通过 Git Patch 热更技术，代码最终无缝合入了部署在公网 VPS 上的生产环境，并成功重启了 <code>lite-agent.service</code>。三端（Win / Mac / VPS）代码库完美同步。</p><p>多模型决策引擎的上线，不仅增强了我们系统的分析能力，更为后续更复杂的 Agentic Workflow（如多模态评估、代码自动部署仲裁）奠定了坚实的护城河。这次跨端 AI 协作，也让我们看到了“AI 结对编程”与“机器自治 Peer Review”的巨大潜力！</p><p><img src="/upload/2026/06/image-1781846224101.png" alt="image-1781846224101" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[双 AI 协同高效开发实录：RssAdapter & lite_agent 架构演进与用户画像蒸馏落地]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/双ai协同高效开发实录rssadapterliteagent架构演进与用户画像蒸馏落地" />
                <id>tag:https://maifeipin.com,2026-06-14:双ai协同高效开发实录rssadapterliteagent架构演进与用户画像蒸馏落地</id>
                <published>2026-06-14T23:49:07+08:00</published>
                <updated>2026-06-14T23:54:09+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>随着大语言模型（LLM）能力的演进，利用 AI 进行辅助编程已成为日常。然而，在面对包含多仓库、多技术栈（如 C# Backend 与 Python AI Agent 结合）、跨本地与 VPS 双端部署的复杂生态系统时，如何高效进行多模块协同、排查并发冲突并沉淀高质量代码，仍然是一个极具挑战的课题。</p><p>今天，我们记录了一场高效的双 AI（Claude Code 客户端与 Antigravity 服务端）协同开发实录。在短短一天内，我们向 3 个核心仓库提交并部署了 5 个 Pull Requests，涵盖从架构重构、服务守护、并发隐患修复、用户画像长期记忆沉淀到外部高考数据库接入的全流程。</p><hr /><h2 id="%F0%9F%9B%A0%EF%B8%8F-%E9%A1%B9%E7%9B%AE%E7%94%9F%E6%80%81%E8%83%8C%E6%99%AF" tabindex="-1">🛠️ 项目生态背景</h2><p>我们的个人助理与信息流获取生态主要由以下三个项目构成：</p><ol><li><strong>RssAdapter (C# .NET 8)</strong>：部署在 Mac 本地，提供基于 Playwright（有头模式）的高质量网页渲染与抓取接口（<code>/api/Url2Md</code>），绕过反爬机制将动态页面转换为干净的 Markdown。</li><li><strong>lite_agent (Python 3)</strong>：部署在 VPS 上，是多通道（飞书、Telegram、钉钉、企业微信）的智能 Agent。负责核心 LLM 任务编排、工具调用、定时任务运行和长期记忆引擎。</li><li><strong>rssnextui (Vue / Vite)</strong>：前端阅读器，实现文章的阅读、AI 点评与本地秒级解析查看。</li></ol><hr /><h2 id="%F0%9F%9A%80-%E4%BB%8A%E6%97%A5%E6%A0%B8%E5%BF%83%E6%88%98%E6%9E%9C%E4%B8%8E%E6%9E%B6%E6%9E%84%E6%BC%94%E8%BF%9B" tabindex="-1">🚀 今日核心战果与架构演进</h2><h3 id="1.-rssadapter-%E6%8F%92%E4%BB%B6%E5%BC%8F%E8%A7%A3%E6%9E%90%E6%9E%B6%E6%9E%84%E4%B8%8E%E7%A7%92%E5%BC%80%E4%BD%93%E9%AA%8C-(pr-%232)" tabindex="-1">1. RssAdapter 插件式解析架构与秒开体验 (PR #2)</h3><p>在以往的设计中，RssAdapter 对不同站点的解析逻辑混杂在一起，维护成本极高。</p><ul><li><strong>重构设计</strong>：引入了 12 个重点站点（如微信公众号、知乎、V2EX、<a href="http://Linux.do" target="_blank">Linux.do</a> 及 9 个通用新闻站）的结构化解析器插件。</li><li><strong>秒开优化</strong>：配合 <code>rssnextui</code> 新增“查看原文”功能，前端发起请求后由 Mac 端 RssAdapter 动态提取并利用 marked 和 DOMPurify 渲染，提供无污染、高安全的秒开解析。</li></ul><h3 id="2.-launchagent-%E6%9C%8D%E5%8A%A1%E5%AE%88%E6%8A%A4%E4%B8%8E-keepalive-%E7%A8%B3%E5%9B%BA" tabindex="-1">2. LaunchAgent 服务守护与 KeepAlive 稳固</h3><p>为了确保 Tailscale 局域网内 VPS 能稳定连接 Mac 端 RssAdapter，我们淘汰了不稳定的前台挂起或临时脚本，编写并注册了 Mac plist 描述符，利用 <code>LaunchAgent</code> + <code>KeepAlive</code> 机制对 RssAdapter 进行进程级守护与开机自启。</p><h3 id="3.-lite_agent-%E8%B7%AF%E7%94%B1%E7%BA%A0%E5%81%8F%E4%B8%8E-hedgedoc-%E5%B9%BB%E8%A7%89%E6%B6%88%E9%99%A4-(pr-%233-%26-%234)" tabindex="-1">3. lite_agent 路由纠偏与 HedgeDoc 幻觉消除 (PR #3 &amp; #4)</h3><p>针对实际运行中 LLM 的不合理路由与过度推理行为进行了拦截：</p><ul><li><strong>路由纠偏</strong>：修改 <code>ops_web_fetch</code> 与 <code>web_clip</code> 两个技能的 description，在描述中显式指定“微信/知乎等动态内容首选 web_clip”，阻止 LLM 在遇到公众号链接时选择 curl 导致失败并进入 <code>pip install</code> 的死循环。</li><li><strong>消除 HedgeDoc 虚构幻觉</strong>：对于未达 2500 字或无图的短文，web_clip 不再上传至 HedgeDoc 而是直接返回 Markdown，并在返回的元数据行中显式加上状态说明（<code>未达上传阈值，仅返回纯文本</code>），完美截断了 LLM 凭空捏造“已成功上传 HedgeDoc”的幻觉。</li></ul><h3 id="4.-%E4%B8%AA%E4%BA%BA%E7%94%BB%E5%83%8F%EF%BC%88persona.md%EF%BC%89%E8%92%B8%E9%A6%8F%E4%BD%93%E7%B3%BB%E8%90%BD%E5%9C%B0-(pr-%235-%26-%236)" tabindex="-1">4. 个人画像（<a href="http://persona.md" target="_blank">persona.md</a>）蒸馏体系落地 (PR #5 &amp; #6)</h3><p>为了能将个人工作和偏好完全迁移至 Agent 会话，并让未来的任何智能应用都能快速了解用户，我们设计并上线了 <strong>Persona 画像蒸馏体系</strong>：</p><ul><li><strong>流动通路</strong>：IM 聊天记录 → 日常自动增量提取 → <code>data/persona.md</code> 主档案 → 自动注入 System Prompt 提示词。</li><li><strong>七维画像</strong>：划分了“身份与角色”、“工作偏好”、“技术栈熟练度”、“当前进行中项目”、“已知决策”、“个人事实”和“⏳ 待确认”七个维度。</li><li><strong>合并策略</strong>：采用原子化临时写入和重命名，将画像切分为 <code>### 手动校正</code>（用户编辑的偏好，永不磨损）与 <code>### LLM 自动提取</code>（每次蒸馏整体替换，高置信度刷新）。</li><li><strong>双重安全网</strong>：在 LLM 画像提取阶段与文件写入阶段，部署了双重正则表达式黑名单，全行级别过滤 API Keys、私钥及密码凭证，宁可漏报也绝不误纳。</li><li><strong>IM 控制能力</strong>：新增了 <code>/persona</code> 命令展示画像大纲，并支持通过 <code>/persona confirm &lt;序号&gt; [分类]</code> 瞬间将 <code>⏳ 待确认</code> 条目升格到指定的“手动校正”子类中。</li></ul><h3 id="5.-%E9%AB%98%E8%80%83%E5%BF%97%E6%84%BF%E8%BE%85%E5%8A%A9%E6%95%B0%E6%8D%AE%E5%BA%93-7-%E6%8A%80%E8%83%BD%E6%8E%A5%E5%85%A5-(pr-%237)" tabindex="-1">5. 高考志愿辅助数据库 7 技能接入 (PR #7)</h3><p>将超过 300 万行的 SQLite 关系型高考志愿数据库挂载至 Agent 体系：</p><ul><li><strong>七大技能</strong>：提供分数查位次、院校历年录取线、专业细分数据、冲稳保梯度志愿推荐等技能。</li><li><strong>防注入安全策略</strong>：使用只读连接模式（<code>mode=ro</code>），结合严格的 SELECT 检测和危险 DDL 关键字过滤，杜绝外部输入对本地库的破坏。</li></ul><hr /><h2 id="%F0%9F%93%8C-%E6%8A%80%E6%9C%AF%E5%80%BA%E7%AE%A1%E7%90%86%E4%B8%8E%E6%8C%81%E7%BB%AD%E6%BC%94%E8%BF%9B-(issue-%238-~-%2311)" tabindex="-1">📌 技术债管理与持续演进 (Issue #8 ~ #11)</h2><p>对于代码中存在的次要隐患，我们当即开设了 4 个跟进 issue，保持仓库整洁与后续敏捷迭代：</p><ul><li><strong>#8</strong>：解决 SQL 黑名单在模糊匹配学校名称时可能产生的误伤问题。</li><li><strong>#9</strong>：对 LIKE 通配符进行转义处理，收紧查询安全边界。</li><li><strong>#10</strong>：将硬编码的数据库路径配置化，提高应用迁移性。</li><li><strong>#11</strong>：对异常信息进行友好过滤，避免直接将 <code>FileNotFoundError</code> 抛给 LLM 造成系统误判。</li></ul><hr /><h2 id="%F0%9F%A7%A0-%E5%8F%8C-ai-%E5%8D%8F%E5%90%8C%E5%BC%80%E5%8F%91%E6%A8%A1%E5%BC%8F%E7%9A%84%E5%90%AF%E7%A4%BA" tabindex="-1">🧠 双 AI 协同开发模式的启示</h2><p>在本次开发中，<strong>Claude Code</strong> 在客户端（处理 Python Agent 记忆逻辑及业务技能）进行前沿编写，<strong>Antigravity</strong> 在服务端（处理 C# 后端服务和架构维护）提供合并、核验与环境构建。</p><p>双 AI 协同在遇到多端联调时表现出了惊人的开发速度：</p><ol><li><strong>秒级定位问题</strong>：通过系统日志实时捕获 LLM 路由的系统反应，精准修补描述。</li><li><strong>闭环自动验证</strong>：PR 合并后直接通过 VPS 远程命令行触发手动一分一段数据蒸馏、短 URL 强制上传等用例，闭环验证服务健康状况。</li><li><strong>流程规范化</strong>：严格遵循“本地主分支 merge -&gt; 推送 GitHub -&gt; VPS 部署拉取 -&gt; 重启服务 -&gt; 状态回检”的生产部署流水线，不绕过 Git 协作规则，极大地保护了项目结构的干净与安全。</li></ol><p>未来的智能助理不仅会拥有更好的检索增强（RAG），更会通过这种流动的 <strong>Persona 提炼架构</strong> 建立更加温暖、深刻的专属个人链接。</p><hr /><p><em>记录时间：2026年6月14日</em></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[大道至简：给极简私人Agent装上“视觉神经”——纯本地多模态OCR折腾全纪录]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/大道至简给极简私人agent装上视觉神经纯本地多模态ocr折腾全纪录" />
                <id>tag:https://maifeipin.com,2026-06-13:大道至简给极简私人agent装上视觉神经纯本地多模态ocr折腾全纪录</id>
                <published>2026-06-13T16:51:41+08:00</published>
                <updated>2026-06-13T17:49:35+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>在上一篇文章《从架构看极简：Lite Agent 如何用零外部依赖打造全功能 AI 助手引擎》中，我分享了如何坚持“0 外部依赖”的极客哲学，纯用 Python 标准库撸出一套具备多通道、长短期记忆和定时任务的智能助手。这套架构运行在廉价的云端 VPS 上，极其稳定。</p><p>但随着时间推移，我逐渐发现了一个痛点：它是个“盲人”。</p><p>每当我遇到需要解析的数学公式、拍下的纸质文档或是网页截图时，我只能手动把它们转成文字再发给它，十分割裂。于是，我萌生了一个念头——<strong>给这套极简系统装上“视觉神经”</strong>。</p><h2 id="%E6%8B%92%E7%BB%9D%E8%87%83%E8%82%BF%EF%BC%8C%E5%AF%BB%E6%89%BE%E6%9E%81%E8%87%B4%E8%BD%BB%E9%87%8F%E7%9A%84%E8%A7%86%E8%A7%89%E6%96%B9%E6%A1%88" tabindex="-1">拒绝臃肿，寻找极致轻量的视觉方案</h2><p>提到多模态 OCR，很多人第一时间想到的是接入商业 API，或是部署极其笨重的全家桶方案。但我的原则依然是：<strong>大道至简，拒绝臃肿。</strong></p><p>经过一番调研，百度飞桨团队开源的 <strong><code>PaddleOCR-VL-1.5</code></strong> 进入了我的视线。<br />这是一个仅仅只有 <code>0.9B</code> 参数的超轻量级视觉-语言大模型（VLM），与目前百亿参数的模型相比，它小巧得不可思议，但在文档解析、复杂数学公式还原（OmniDocBench 测试）上却表现出了惊人的 94.5% 准确率。</p><p>更为关键的是，它可以被编译成 <code>GGUF</code> 格式！这意味着，我那台一直闲置在家里的 <strong>Mac Mini（M4 芯片）</strong> 终于有了用武之地。通过 <code>llama.cpp</code>，我可以直接利用 Apple Silicon 原生的 Metal 框架进行物理级 GPU 加速，没有任何厚重的 Python 依赖地狱。</p><h2 id="%E6%9E%81%E7%AE%80%E9%83%A8%E7%BD%B2%EF%BC%9A%E8%B8%A9%E5%9D%91%E4%B8%8E%E6%9E%81%E9%99%90%E4%BC%98%E5%8C%96%E7%9A%84%E5%AE%9E%E6%88%98" tabindex="-1">极简部署：踩坑与极限优化的实战</h2><p>原以为部署会一帆风顺，结果却结结实实踩了个坑。</p><p>一开始，我试图直接拉取原生 PyTorch 版本的底层环境来跑，结果直接遭遇了 <code>Error loading model: 'default'</code> 报错。更致命的是，在 Mac 的 M 系列芯片上，这套原生底层兼容性极差，纯 CPU 原生跑一页文档将近要 100 秒！</p><p>幸好后来查阅到了一篇“救星”文章，直接点破了迷局，并提供了一套专为 Apple Silicon 量身定制的最优解：<strong>“前端 paddleocr 客户端 + 底层 llama-server (GGUF格式模型)”</strong>。</p><p>于是我立刻转舵，采用了这套拥抱极速 GGUF 生态的架构方案：</p><ol><li><p><strong>底层推理引擎启动</strong>：放弃 PyTorch，直接掏出 <code>llama.cpp</code> 编译出的 <code>llama-server</code>，挂载视觉投影模块（<code>mmproj</code>）与量化好的 GGUF 模型，开启纯 C++ 的极致性能，并丢入后台永驻：</p><pre><code class="language-bash"># 1. 创建模型存放目录mkdir -p ~/models/PaddleOCR-VL-1.5-GGUF cd ~/models/PaddleOCR-VL-1.5-GGUF# 2. 通过 hf-mirror 镜像源直链下载主模型 (约 900MB)curl -L -O -C - https://hf-mirror.com/PaddlePaddle/PaddleOCR-VL-1.5-GGUF/resolve/main/PaddleOCR-VL-1.5.gguf# 3. 下载视觉编码器 mmproj (约 840MB)curl -L -O -C - https://hf-mirror.com/PaddlePaddle/PaddleOCR-VL-1.5-GGUF/resolve/main/PaddleOCR-VL-1.5-mmproj.gguf#4. llama-server  启动nohup llama-server -m ~/models/PaddleOCR-VL-1.5-GGUF/PaddleOCR-VL-1.5.gguf \--mmproj ~/models/PaddleOCR-VL-1.5-GGUF/PaddleOCR-VL-1.5-mmproj.gguf \--port 8080 --host 0.0.0.0 --temp 0 &gt; ~/llama-server.log 2&gt;&amp;1 &amp;</code></pre></li><li><p>前端客户端联动与 API 封装：安装好极其轻量的 paddleocr 客户端后，不再让它自己做重度推理，而是通过 --vl_rec_backend llama-cpp-server 参数，把最核心的识别任务外包给刚刚启动的 8080 端口。 由于最终需要给云端的 Agent 留接口，我又手撸了一个不到 100 行代码的 ocr_web.py（借助 FastAPI），在内部调用官方的 paddleocr doc_parser，向外暴露极简的 /api/ocr 接口。</p></li><li><p><strong>极简 API 封装</strong>：由于底层只是纯粹的推理引擎，我又用不到 100 行代码手撸了一个 <code>ocr_web.py</code>。它利用 FastAPI 构建了一个超轻量的 Web 壳，并在内部调用官方的 <code>paddleocr doc_parser</code>，最终向外暴露了一个极其干爽的 <code>/api/ocr</code> 接口。随后同样使用 <code>nohup</code> 挂在 8000 端口。</p></li></ol><p>这套组合拳下来，原本 100 秒一页的龟速，被生生压缩到了不到 20 秒一页！一个全本地、不吃带宽的“视觉超算节点”就此在桌面上完美运转。</p><h2 id="%E9%9B%B6%E5%85%A5%E4%BE%B5%E6%89%93%E9%80%9A%E4%BA%91%E7%AB%AF%E4%B8%8E%E6%9C%AC%E5%9C%B0%EF%BC%9Atailscale-%E9%9A%A7%E9%81%93" tabindex="-1">零入侵打通云端与本地：Tailscale 隧道</h2><p>现在的局面是：</p><ul><li>我的 <code>Lite Agent</code> 运行在公网 VPS（云端）</li><li>强大的视觉算力节点 <code>Mac Mini</code> 运行在内网（本地）</li></ul><p>如何将两者安全打通？我同样拒绝了复杂的 Nginx 反向代理或花生壳，而是选用了 <strong>Tailscale</strong> 虚拟局域网。</p><p>只要在家里启动服务，云端的 VPS 就能直接通过 <code>100.x.x.x</code> 的 Tailscale 私有 IP 连通家里的 API。没有任何公网暴露的风险，也没有复杂的鉴权，纯粹的点对点通信。</p><h2 id="%E6%97%A0%E6%84%9F%E6%8B%A6%E6%88%AA%EF%BC%9A%E9%A3%9E%E4%B9%A6%E4%B8%8E-telegram-%E7%9A%84%E2%80%9C%E8%A7%81%E5%9B%BE%E5%B0%B1%E8%A7%A3%E2%80%9D" tabindex="-1">无感拦截：飞书与 Telegram 的“见图就解”</h2><p>基础建好后，接下来就是改造 <code>Lite Agent</code> 的通道层。<br />我不想要什么花里胡哨的“图片上传指令”，最优雅的交互应当是隐形的。</p><p>在 <code>channels/feishu.py</code> 与 <code>channels/telegram.py</code> 中，我仅仅增加了几十行代码：监听消息池。如果发现是 <code>image</code> 或 <code>photo</code> 类型的消息，直接在后台新开一个轻量级 <code>threading.Thread</code>。</p><ol><li><strong>自动拦截</strong>: 无论是通过飞书还是 Telegram 发来的图片，机器人会立即回复一句：<em>“🤔 收到图片，正在调用视觉大模型进行全版面结构化解析…”</em></li><li><strong>下载透传</strong>: 服务端将原图抽离，通过 Socks5/官方 API 拉到内存，随后组装成极简的 HTTP POST 请求，通过 Tailscale 隧道射向我的卧室。</li><li><strong>秒级直出</strong>: 家里的 Mac Mini 风扇微转，十几秒后，一篇包含完美 LaTeX 数学公式和排版还原的 Markdown 文本，直接呈现在我的手机屏幕上。<br /><img src="/upload/2026/06/image.png" alt="image" /><br />期间还遇到了一些小插曲。比如在 VPS 上添加 <code>.env</code> 环境变量 <code>OCR_ENDPOINT</code> 时，因为 <code>echo</code> 命令误把 <code>\n</code> 吃进去，导致系统读取了错乱的环境变量名，最后回退到了默认的本地 <code>127.0.0.1</code> 报错。不过这些都是折腾路上的小确幸。</li></ol><h2 id="%E7%BB%93%E8%AF%AD" tabindex="-1">结语</h2><p>折腾到最后，看着手机屏幕里一张模糊的二次方程作业照，在几秒内变成了极其工整优雅的 LaTeX 代码输出时，那种成就感是难以言喻的。</p><p>在算力如此廉价、大模型百花齐放的今天，我们或许不需要用多高大上的企业级架构。只要找准工具的边界，用最简单直接的代码将它们串联起来，你也能拥有一个只属于你的，能够“看透”世间万物的全能智能体。</p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[从架构看极简：Lite Agent 如何用零外部依赖打造全功能 AI 助手引擎]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/从架构看极简liteagent如何用零外部依赖打造全功能ai助手引擎" />
                <id>tag:https://maifeipin.com,2026-06-03:从架构看极简liteagent如何用零外部依赖打造全功能ai助手引擎</id>
                <published>2026-06-03T23:03:31+08:00</published>
                <updated>2026-06-03T23:03:31+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<h2 id="前言">前言</h2><p>之前分享过《大道至简：拒绝臃肿框架，用飞书与原生脚本打造极简私人 Agent》，聊到了如何用最朴素的方式让 AI 接管服务器运维。今天想深入一层，从<strong>源码架构</strong>的角度，复盘一下 <a href="https://github.com/maifeipin/lite_agent">Lite Agent</a> 这个项目的设计取舍——<strong>为什么敢说&quot;零外部依赖&quot;？又是如何做到的？</strong></p><p>如果你看过我前两篇文章——关于<strong>账单自动解析工具</strong>和<strong>刷 OpenWrt</strong>，你会发现一个共性：<strong>能用原生方案解决的问题，绝不引入第三方框架</strong>。Lite Agent 把这个理念贯彻到了极致。</p><hr /><h2 id="一零外部依赖不是噱头是设计原则">一、零外部依赖不是噱头，是设计原则</h2><p>很多人听到&quot;AI Agent&quot;第一反应就是 LangChain、AutoGPT、CrewAI……动辄几百 MB 的依赖。但 Lite Agent 的核心引擎，全部使用 <strong>Python 内置库</strong> 实现：</p><table><thead><tr><th align="left">模块</th><th align="left">使用的内置库</th><th align="left">用途</th></tr></thead><tbody><tr><td align="left">HTTP 请求</td><td align="left"><code>urllib</code></td><td align="left">调用 DeepSeek API</td></tr><tr><td align="left">数据库</td><td align="left"><code>sqlite3</code></td><td align="left">会话管理 &amp; 记忆存储</td></tr><tr><td align="left">并发</td><td align="left"><code>threading</code></td><td align="left">多轮工具调用编排</td></tr><tr><td align="left">JSON 解析</td><td align="left"><code>json</code></td><td align="left">结构化数据交换</td></tr><tr><td align="left">日志</td><td align="left"><code>logging</code></td><td align="left">全链路调试追踪</td></tr></tbody></table><p><strong>唯一的外部依赖</strong>是飞书官方 SDK <code>lark-oapi</code>——这是接入飞书 WebSocket 所必需的，而且那是平台 SDK，不是框架。也就是说，核心引擎本身做到了 <strong>0 pip install</strong>。</p><p>为什么这么执着？因为依赖越少，维护成本越低、故障点越少、部署越丝滑。</p><hr /><h2 id="二skill-engine一秒钟把脚本变成-ai-工具">二、Skill Engine：一秒钟把脚本变成 AI 工具</h2><p>项目最核心的设计是 <strong>Skill Engine（动态技能引擎）</strong>。它的工作流如下：</p><pre><code>用户自然语言 → DeepSeek 理解意图 → 匹配技能 → 调用本地函数 → 返回结果 → AI 组织回复</code></pre><p>关键在&quot;匹配技能&quot;这一步。Lite Agent 的做法极简：</p><ul><li>每个技能是一个普通的 Python 函数，加上 <code>@skill</code> 装饰器和类型注解</li><li>启动时自动扫描 <code>skills/</code> 目录，动态生成 Tool Calling 的 JSON Schema</li><li>调用时通过 <code>threading</code> 异步执行，超时自动熔断</li></ul><p>看一个实际例子——账单查询技能的核心代码结构：</p><pre><code class="language-python">@skilldef billing_report(months: int = 3) -&gt; str:    &quot;&quot;&quot;生成月度财务汇总报表&quot;&quot;&quot;    # 直接操作 SQLite，零 ORM    data = query_from_sqlite(months)    return format_as_table(data)</code></pre><p>没有复杂的抽象层，没有 Chain、Graph、Pipeline 那些概念。<strong>函数即工具</strong>，就这么简单。</p><p>这个设计直接继承了我在<a href="https://maifeipin.com/archives/389">账单解析工具</a>中的思路：<strong>复杂逻辑靠 Python 本身搞定，框架只做调度</strong>。</p><hr /><h2 id="三深度思考模型的完美适配">三、深度思考模型的完美适配</h2><p>用 DeepSeek-R1 / V4-Pro 这类推理模型做 Tool Calling，有个大坑：<strong>思维链（reasoning_content）和工具调用请求会交错出现</strong>，很多框架在这个场景下疯狂报 400 错误。</p><p>Lite Agent 的解决方案也很朴素：</p><ol><li><strong>逐帧解析</strong>：对 API 返回的 SSE 流，逐条判断是 <code>reasoning_content</code> 还是 <code>tool_calls</code></li><li><strong>无损透传</strong>：思维链内容完整保留在会话上下文中，不影响下一轮调用</li><li><strong>自动适配</strong>：根据模型版本自动注入 <code>reasoning_effort</code> 参数</li></ol><p>这不需要什么高深的技术，就是<strong>对协议细节的尊重</strong>——认真读文档，认真处理边界情况。</p><hr /><h2 id="四飞书直连内网穿透不需要">四、飞书直连：内网穿透？不需要</h2><p>传统机器人方案需要公网 Webhook，意味着你得有公网 IP 或者 frp/ngrok 隧道。Lite Agent 另辟蹊径——<strong>用飞书 WebSocket 直连</strong>。</p><div class="mermaid">用户发消息 → 飞书服务器 → WebSocket 推送 → VPS 本地处理 → WebSocket 回复</div><p>优势很明显：</p><ul><li>✅ <strong>不需要公网 IP</strong>，内网 VPS 也能用</li><li>✅ <strong>不需要域名 + HTTPS 证书</strong></li><li>✅ <strong>延迟更低</strong>，省去 HTTP 握手开销</li></ul><p>这个灵感来源于我刷 OpenWrt 那篇文章——<strong>能用系统自带的能力解决问题，就别引入额外组件</strong>。</p><hr /><h2 id="五会话记忆与任务编排">五、会话记忆与任务编排</h2><p><code>sessions.db</code> 是 SQLite 单文件，记录了每一轮对话的：</p><ul><li>用户意图（Intent）</li><li>当前目标（Goal）</li><li>已完成步骤（Steps）</li><li>Token 消耗（Token Usage）</li></ul><p>当用户说&quot;帮我检查一下这个月的账单，顺便看看系统安全状况&quot;，引擎会自动：</p><ol><li>✅ 目标分解 → 拆成 2 个子任务</li><li>🔄 顺序执行 → 先查账单，再查安全</li><li>📊 汇总结果 → 用自然语言整合回复</li></ol><p>整个过程完全异步，支持中途打断、追问、纠错。</p><hr /><h2 id="六性能与资源占用">六、性能与资源占用</h2><p>实测数据（1C2G 轻量云 VPS）：</p><table><thead><tr><th align="left">指标</th><th align="left">数值</th></tr></thead><tbody><tr><td align="left">进程常驻内存</td><td align="left">~45 MB</td></tr><tr><td align="left">冷启动时间</td><td align="left">&lt; 0.3 秒</td></tr><tr><td align="left">单次工具调用耗时</td><td align="left">~200ms（不含 LLM 推理）</td></tr><tr><td align="left">飞书消息往返延迟</td><td align="left">~1.5s（含 DeepSeek 推理）</td></tr></tbody></table><p>对比 LangChain 系框架动辄 200MB+ 的常驻内存，Lite Agent 简直就是一股清流。</p><hr /><h2 id="七总结与展望">七、总结与展望</h2><p>从去年开始做这个项目，初心一直没有变：<strong>用最轻量的方式，让 AI 真正帮我们干活</strong>。</p><ul><li>不用 Kubernetes 编排 Agent</li><li>不用 VectorDB 存记忆（我甚至集成了 Chroma 但默认不启用）</li><li>不用 Message Queue 做异步</li></ul><p><strong>能用 dict 解决的问题，就不用 ORM；能用 sqlite3 解决的问题，就不用 PostgreSQL；能用函数解决的问题，就不用框架。</strong></p><p>这就是 Lite Agent 的架构哲学。</p><p>代码完全开源：<a href="https://github.com/maifeipin/lite_agent">github.com/maifeipin/lite_agent</a>，欢迎 Star ⭐ 和 PR。</p><p>下一篇打算写写 <strong>Memory Engine</strong> 的设计——如何在不依赖外部向量数据库的情况下，实现语义级别的长期记忆。敬请期待！</p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[大道至简：拒绝臃肿框架，用飞书与原生脚本打造极简私人 Agent]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/da-dao-zhi-jian--ju-jue-yong-zhong-kuang-jia--yong-fei-shu-yu-yuan-sheng-jiao-ben-da-zao-ji-jian-si-ren-agent" />
                <id>tag:https://maifeipin.com,2026-05-30:da-dao-zhi-jian--ju-jue-yong-zhong-kuang-jia--yong-fei-shu-yu-yuan-sheng-jiao-ben-da-zao-ji-jian-si-ren-agent</id>
                <published>2026-05-30T14:12:23+08:00</published>
                <updated>2026-05-30T14:12:23+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>在上一篇文章 <a href="https://maifeipin.com/archives/0-wai-bu-yi-lai--wo-yong-python-da-zao-lao-yi-ge-gao-yan-zhi-de-duo-yin-xing-xin-yong-ka-zhang-dan-zi-dong-jie-xi-yu-dui-zhang-gong-ju" target="_blank">《0 外部依赖！我用 Python 打造了一个高颜值的多银行信用卡账单自动解析与对账工具》</a> 中，我分享了如何坚持“0 外部依赖”的极客哲学，纯用 Python 标准库撸出一个稳定、干净的账单解析引擎。</p><p>今天，这套极简哲学迎来了一次重要的<strong>升维</strong> —— 我给它装上了“嘴巴和耳朵”，将这台单机的解析引擎，进化成了一个随时随地听我调遣的<strong>私人极简 Agent</strong>。</p><hr /><h2 id="%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E6%8B%92%E7%BB%9D%E4%B8%BB%E6%B5%81%E7%9A%84-agent-%E6%A1%86%E6%9E%B6%EF%BC%9F" tabindex="-1">为什么要拒绝主流的 Agent 框架？</h2><p>目前市面上有大量的通用 Agent 框架（比如 OpenClaw, Hermes, AutoGen 等等）。为了做到所谓的“通用”和“全能”，它们往往不可避免地走向了臃肿：</p><ul><li>动辄要求 Docker 部署、配置几百行的 YAML 文件。</li><li>依赖几十上百个第三方包，甚至各种向量数据库。</li><li>占用的内存对小内存 VPS 极不友好，出 Bug 时的黑盒排查更是让人头大。</li></ul><p>但退一步想，<strong>个人日常需要的 Agent 到底是什么？</strong><br />无非是一个能听懂我指令的<strong>网关（Connector）</strong>，加上一个能执行代码的**执行器（Executor）**而已。</p><p>与其引入庞然大物，不如返璞归真。</p><hr /><h2 id="%E6%9E%81%E7%AE%80%E6%9E%B6%E6%9E%84%EF%BC%9Awebsocket-%2B-%E7%BA%AF%E5%8E%9F%E7%94%9F%E8%84%9A%E6%9C%AC" tabindex="-1">极简架构：WebSocket + 纯原生脚本</h2><p>我的架构只有三块积木，极其轻量、透明、且无需暴露任何公网端口：</p><ol><li><p><strong>统一入口：飞书 WebSocket 长连接</strong><br />抛弃了传统的 Webhook（需要公网 IP、HTTPS 证书、反向代理配置），直接引入飞书官方的极简依赖 <code>lark-oapi</code> 建立 WebSocket 长连接。一个几十 MB 内存的后台 Python 进程 (<code>feishu_bot.py</code>)，就能稳定接管来自手机飞书的全部指令。</p></li><li><p><strong>路由与执行：万物皆可 <code>/run</code> 和 <code>/sh</code></strong><br />不搞复杂的 Skill 定义和意图槽位解析。Bot 就是一个无情的指令路由器：</p><ul><li>账单管理？发送 <code>/report</code>、<code>/due</code>，直接拉起我的 <code>mail_client.py</code> 引擎。</li><li>临时查状态？发送 <code>/sys</code> 查看内存，发 <code>/sh df -h</code> 查看磁盘。</li><li>复杂的巡检？写一个 <code>/vps/path/check_cert_expiry.sh</code>，然后在飞书里发一句 <code>/run /vps/path/check_cert_expiry.sh</code>，证书巡检报告就会以富文本卡片（Markdown）形式弹到手机上。</li></ul></li><li><p><strong>双向互动与定时推送</strong><br />我额外封装了一个 10 行代码的 <code>feishu_push.sh</code>。这样一来，VPS 上的任何 <code>crontab</code> 任务，只需要用管道符 <code>|</code> 把输出塞给这个脚本，它就能精准地推送交互卡片到我的飞书上。<br /><em>（比如：<code>0 9 * * * /vps/path/check_cert_expiry.sh | feishu_push.sh &quot;🔒 证书过期巡检&quot;</code>）</em></p></li></ol><hr /><h2 id="%E2%80%9C%E8%B8%A9%E5%9D%91%E2%80%9D%E4%B8%8E%E8%BF%9B%E5%8C%96%EF%BC%9A%E9%AD%94%E9%AC%BC%E5%9C%A8%E7%BB%86%E8%8A%82%E4%B8%AD" tabindex="-1">“踩坑”与进化：魔鬼在细节中</h2><p>打造这个极简架构并非一帆风顺，在“打通全链路”的过程中，我解决了一些非常有意思的工程细节：</p><h3 id="1.-%E7%BB%88%E7%AB%AF%E4%B8%8E-im-%E7%9A%84%E7%A2%B0%E6%92%9E%EF%BC%9A%E8%A2%AB%E5%90%83%E6%8E%89%E7%9A%84%E2%80%9C%E6%98%9F%E5%8F%B7%E2%80%9D" tabindex="-1">1. 终端与 IM 的碰撞：被吃掉的“星号”</h3><p>当我用 <code>/cron</code> 指令在飞书查看服务器定时任务时，发现原本长这样的任务：<br /><code>*/5 * * * * bash start.sh</code><br />在手机上竟然显示成了：<code>/5 bash start.sh</code>！<br /><strong>原因</strong>：飞书的 Markdown 引擎把连续的 <code>*</code> 识别成了斜体排版符，直接吃掉了。<br /><strong>解决</strong>：在推送消息前，拦截所有 Shell 命令的纯文本输出，强制包裹在 <code>```</code> 代码块中。不仅解决了字符丢失，还完美保留了等宽字体的终端排版美感。</p><h3 id="2.-%E6%A0%87%E5%87%86%E9%94%99%E8%AF%AF%E6%B5%81-(stderr)-%E7%9A%84%E5%99%AA%E9%9F%B3" tabindex="-1">2. 标准错误流 (stderr) 的噪音</h3><p>当执行含有 <code>curl</code> 抓取的脚本时，飞书界面里突然蹦出一大段丑陋的 <code>curl</code> 进度条日志。<br /><strong>原因</strong>：<code>curl</code> 会默认将下载进度输出到 <code>stderr</code>。而我的 <code>execute_shell</code> 函数为了不遗漏报错，会老老实实把 <code>stderr</code> 全抓下来拼接在正文后。<br /><strong>解决</strong>：在 Python 端加了一层轻量过滤正则，无情干掉所有只包含进度条特征的无用信息。同时规范了 Shell 脚本的编写习惯 —— 把不想看的重定向进 <code>/dev/null</code>，把想看的才 <code>echo</code> 出来。</p><h3 id="3.-webhook-%E7%9A%84%E7%BB%8F%E5%85%B8%E9%99%B7%E9%98%B1%EF%BC%9A%E8%A2%AB%E9%87%8D%E6%94%BE%E7%9A%84%E6%8C%87%E4%BB%A4" tabindex="-1">3. Webhook 的经典陷阱：被重放的指令</h3><p>一开始，当我让 Bot 执行一个耗时 10 秒的复杂脚本时，惊悚的一幕出现了：脚本执行完一次后，过了一会儿，居然又自己执行了第二次、第三次！<br /><strong>原因</strong>：这是典型的 WebSocket/Webhook 消息重试机制。飞书要求 Bot 收到消息后必须在 3 秒内确认（ACK）。如果代码是同步阻塞等待脚本跑完的，飞书就会判定“超时未达”，进而触发重传。<br /><strong>解决</strong>：引入 <code>threading</code> 异步并发执行耗时命令，主线程瞬间返回确认；同时在内存里加了一个 <code>deque(maxlen=1000)</code> 作为防重放的 LRU 缓存，彻底杜绝指令重复执行。</p><hr /><h2 id="%E7%BB%93%E8%AF%AD%EF%BC%9A%E8%AE%A9-ai-%E6%88%90%E4%B8%BA%E7%9C%9F%E6%AD%A3%E7%9A%84%E2%80%9C%E5%A4%96%E8%84%91%E2%80%9D" tabindex="-1">结语：让 AI 成为真正的“外脑”</h2><p>这套轻量级的 Agent 架构搭建完毕后，我感觉自己的效率工具链发生了质变。</p><p>我不再需要去学习任何第三方 Agent 框架的配置语法。由于架构足够透明（只有 Python 原生和 Shell），当我有新需求时，我只需要在 IDE 里唤起我的大模型编程助手（AI），把这套轻量级上下文喂给它。<br />它能瞬间写好一段 0 依赖的监控脚本，帮我用 <code>scp</code> 传到 VPS，配好 <code>crontab</code>。</p><p><strong>工具应该顺应人的直觉，而不是让人去适应工具的臃肿。</strong> 用几十行代码连接飞书，用原生脚本包办一切，这或许才是个人开发者效率工具链的最优解。</p><p><img src="/upload/2026/05/image-1780121379123.png" alt="image-1780121379123" /><br /><img src="/upload/2026/05/image-1780121460781.png" alt="image-1780121460781" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[0 外部依赖！我用 Python 打造了一个高颜值的多银行信用卡账单自动解析与对账工具]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/0-wai-bu-yi-lai--wo-yong-python-da-zao-lao-yi-ge-gao-yan-zhi-de-duo-yin-xing-xin-yong-ka-zhang-dan-zi-dong-jie-xi-yu-dui-zhang-gong-ju" />
                <id>tag:https://maifeipin.com,2026-05-27:0-wai-bu-yi-lai--wo-yong-python-da-zao-lao-yi-ge-gao-yan-zhi-de-duo-yin-xing-xin-yong-ka-zhang-dan-zi-dong-jie-xi-yu-dui-zhang-gong-ju</id>
                <published>2026-05-27T16:35:57+08:00</published>
                <updated>2026-05-30T11:01:07+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<h2 id="%F0%9F%92%A1-%E5%BC%95%E8%A8%80%E4%B8%8E%E7%97%9B%E7%82%B9" tabindex="-1">💡 引言与痛点</h2><p>你手里有几张信用卡？<br />招行、工行、浦发、民生、中信、华夏……每家银行的账单日不同、还款日不同，甚至连账单发送的格式也千差万别。</p><ul><li>有的银行邮件里是密密麻麻的 <strong>HTML 表格</strong>；</li><li>有的银行是<strong>纯文本</strong>；</li><li>更有甚者，外币账单和本币账单混杂，<strong>对账</strong>全靠肉眼和计算器。</li></ul><p>为了彻底终结“手动翻邮件、人工算对账”的痛苦，我决定用 Python 撸一个<strong>信用卡账单自动解析、记账与对账工具</strong>。</p><p>在架构设计上，我给自己提了一个近乎苛刻的要求：<strong>零外部依赖（0 Third-Party Dependencies）</strong>。整个项目只使用 Python 内置的标准库，不调任何 <code>requests</code>、<code>pandas</code> 或 <code>tabulate</code> 等第三方包。</p><p>这样做不仅让程序极其轻量、开箱即用，更在当今 <strong>人机协同（Human-Agent Co-working）</strong> 的时代下，创造了一个对 AI 编码助手（如 MCP、Skill）极其友好的“极简核心”。</p><p>今天，就把这个项目的技术架构和开发中踩过的“巨坑”分享给大家。</p><hr /><h2 id="%E2%9C%A8-%E9%A1%B9%E7%9B%AE%E6%A0%B8%E5%BF%83%E5%8A%9F%E8%83%BD%E4%B8%80%E8%A7%88" tabindex="-1">✨ 项目核心功能一览</h2><p>项目基于 <strong>邮件拉取 (POP3) -&gt; 规则匹配模板 -&gt; 提取核心字段 -&gt; 解析交易明细 -&gt; SQLite 持久化 -&gt; 智能报表与对账</strong> 的完整闭环搭建。</p><p>通过双击根目录下的启动脚本，即可开启一个高颜值的<strong>终端交互控制台</strong>：</p><h3 id="1.-%F0%9F%94%8C-%E9%82%AE%E7%AE%B1%E5%A4%9A%E5%8D%8F%E8%AE%AE%E8%BF%9E%E9%80%9A%E6%80%A7%E4%B8%80%E9%94%AE%E6%B5%8B%E8%AF%95" tabindex="-1">1. 🔌 邮箱多协议连通性一键测试</h3><p>支持测试 POP3（收信）、IMAP（同步）和 SMTP（发信）等协议的连通性与授权状态，提供开箱即用的环境检测。<br /><img src="/upload/2026/05/image-1779870892602.png" alt="image-1779870892602" /></p><h3 id="2.-%F0%9F%93%8A-%E9%93%B6%E8%A1%8C-%2F-%E6%9C%88%E5%BA%A6%E8%B4%A2%E5%8A%A1%E6%B1%87%E6%80%BB%E6%8A%A5%E8%A1%A8%EF%BC%88%E5%90%AB%E5%A2%83%E5%A4%96%E4%BA%A4%E6%98%93%EF%BC%89" tabindex="-1">2. 📊 银行 / 月度财务汇总报表（含境外交易）</h3><p>自适应列宽的 Unicode 框线报表，直观汇总各银行的账单状况。<strong>特别支持境外消费原币种与金额的提取与细分汇总</strong>。<br /><img src="/upload/2026/05/image-1779870913671.png" alt="image-1779870913671" /></p><h3 id="3.-%F0%9F%A7%BE-%E6%99%BA%E8%83%BD%E8%87%AA%E5%8A%A8%E5%AF%B9%E8%B4%A6%E7%B3%BB%E7%BB%9F-(reconcile)" tabindex="-1">3. 🧾 智能自动对账系统 (<code>Reconcile</code>)</h3><p>程序能够自动提取邮件中的账单主表总额（<code>total_due</code>），并与自动解析出的几十笔交易明细单笔累加值进行比对。一旦差额超出容差范围（如 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>1.0</mn></mrow><annotation encoding="application/x-tex">1.0</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.64444em;vertical-align:0em;"></span><span class="mord">1</span><span class="mord">.</span><span class="mord">0</span></span></span></span> 元），立即输出 <code>❌ CHECK</code> 状态提醒人工排查，对账通过则显示 <code>✅ PASS</code>。</p><h3 id="4.-%E2%8F%B0-%E8%BF%98%E6%AC%BE%E6%97%A5%E4%B8%B4%E6%9C%9F%E9%A2%84%E8%AD%A6%E4%B8%8E%E6%8F%90%E9%86%92" tabindex="-1">4. ⏰ 还款日临期预警与提醒</h3><h2 id="%E8%87%AA%E5%8A%A8%E5%9B%9E%E6%BA%AF%E5%A4%9A%E9%93%B6%E8%A1%8C%E8%B4%A6%E5%8D%95%EF%BC%8C%E6%8F%90%E5%8F%96%E8%BF%98%E6%AC%BE%E6%97%A5%E6%9C%9F%EF%BC%8C%E7%B2%BE%E5%87%86%E8%AE%A1%E7%AE%97%E5%B9%B6%E5%B1%95%E7%A4%BA%E6%9C%AA%E6%9D%A5-%E5%A4%A9%E5%86%85%E5%8D%B3%E5%B0%86%E5%88%B0%E6%9C%9F%E7%9A%84%E4%BF%A1%E7%94%A8%E5%8D%A1%EF%BC%8C%E5%91%8A%E8%AD%A6%E9%80%BE%E6%9C%9F%E9%A3%8E%E9%99%A9%E3%80%82" tabindex="-1">自动回溯多银行账单，提取还款日期，精准计算并展示未来 <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>N</mi></mrow><annotation encoding="application/x-tex">N</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.68333em;vertical-align:0em;"></span><span class="mord mathnormal" style="margin-right:0.10903em;">N</span></span></span></span> 天内即将到期的信用卡，告警逾期风险。<br /><img src="/upload/2026/05/image-1779870929763.png" alt="image-1779870929763" /></h2><h2 id="%F0%9F%9B%A0%EF%B8%8F-%E6%A0%B8%E5%BF%83%E6%9E%B6%E6%9E%84%E4%B8%8E%E8%AE%BE%E8%AE%A1" tabindex="-1">🛠️ 核心架构与设计</h2><p>整个项目由三个核心模块构成，各司其职：</p><ul><li><strong><code>mail_client.py</code></strong>：主入口。负责邮件拉取、HTML 转换为 Markdown、调用解析引擎与命令行 UI。</li><li><strong><code>statement_db.py</code></strong>：SQLite 持久化层。负责账单主记录、交易明细的落库，以及复杂的汇总、对账 SQL 查询。</li><li><strong><code>statement_models.py</code></strong>：领域模型定义。定义账单实体、校验错误等。</li></ul><h3 id="%E6%A0%B8%E5%BF%83%E8%A7%A3%E6%9E%90%E5%BC%95%E6%93%8E%EF%BC%9A%E5%9F%BA%E4%BA%8E-json-%E6%A8%A1%E6%9D%BF%E7%9A%84%E5%8C%B9%E9%85%8D%E4%B8%8E%E6%8F%90%E5%8F%96" tabindex="-1">核心解析引擎：基于 JSON 模板的匹配与提取</h3><p>为了支持多银行，我设计了<strong>基于 JSON 配置的解析引擎</strong>。新增一家银行，<strong>完全不需要修改 Python 源代码</strong>，只需在 <code>rules/</code> 目录下放置一个对应的 JSON 规则文件即可。</p><p>以下是招商银行账单配置文件的缩影：</p><pre><code class="language-json">{  &quot;schema_version&quot;: &quot;1.0&quot;,  &quot;rule_id&quot;: &quot;CMB_TEMPLATE_V1&quot;,  &quot;bank_code&quot;: &quot;CMB&quot;,  &quot;match_rules&quot;: {    &quot;sender_patterns&quot;: [&quot;message@cmbchina.com&quot;],    &quot;subject_patterns&quot;: [&quot;招商银行信用卡电子账单&quot;]  },  &quot;extract_rules&quot;: {    &quot;statement_fields&quot;: {      &quot;total_due&quot;: {        &quot;type&quot;: &quot;regex&quot;,        &quot;source&quot;: &quot;body_text&quot;,        &quot;pattern&quot;: &quot;本期应还款总额[:：]?\\s*([+-]?[0-9,]+\\.[0-9]{2})&quot;      },      &quot;due_date&quot;: {        &quot;type&quot;: &quot;regex&quot;,        &quot;source&quot;: &quot;body_text&quot;,        &quot;pattern&quot;: &quot;到期还款日[:：]?\\s*(20\\d{2}[-/]\\d{1,2}[-/]\\d{1,2})&quot;      }    }  }}</code></pre><p>匹配引擎会给所有规则打分（发件人、主题、正文特征码权重叠加），自动挑选出最契合 the 模板进行正则提取，极具扩展性。</p><hr /><h2 id="%F0%9F%94%A5-%E6%8A%80%E6%9C%AF%E6%94%BB%E5%9D%9A%E4%B8%8E%E7%BB%86%E8%8A%82%E5%88%86%E4%BA%AB" tabindex="-1">🔥 技术攻坚与细节分享</h2><p>在坚持<strong>零外部依赖</strong>的原则下，我遇到了两个极其棘手的技术挑战，并给出了非常优雅的解决方案：</p><h3 id="%E6%8C%91%E6%88%98%E4%B8%80%EF%BC%9A%E7%BB%88%E7%AB%AF%E8%A1%A8%E6%A0%BC%E4%B8%AD%E8%8B%B1%E6%96%87%E6%B7%B7%E6%8E%92%E7%9A%84%E2%80%9C%E4%B8%9C%E4%BA%9A%E5%AD%97%E7%AC%A6%E5%AF%B9%E9%BD%90%E2%80%9D%E9%9A%BE%E9%A2%98" tabindex="-1">挑战一：终端表格中英文混排的“东亚字符对齐”难题</h3><p>在终端打印表格时，如果内容包含中文，你会发现表格框线全部错位了。这是因为标准的 <code>len()</code> 函数计算的是<strong>字符个数</strong>，而中文字符在终端显示时占用的宽度是英文字符的 <strong>2 倍</strong>。</p><h4 id="%F0%9F%92%A1-%E4%BC%98%E9%9B%85%E8%A7%A3%E6%B3%95%EF%BC%9A%E8%87%AA%E9%80%82%E5%BA%94-cjk-%E5%AD%97%E7%AC%A6%E5%AE%BD%E5%BA%A6%E8%AE%A1%E7%AE%97%E5%99%A8" tabindex="-1">💡 优雅解法：自适应 CJK 字符宽度计算器</h4><p>我通过 Unicode 字符集编码范围，实现了一个能够精准计算中英文混排字符串实际<strong>显示占位宽度</strong>的函数：</p><pre><code class="language-python">def get_display_width(s):    if s is None:        return 0    s = str(s)    width = 0    for char in s:        o = ord(char)        # 判定是否属于中日韩（CJK）统一汉字、全角符号、韩文字母等宽字符区间        if (0x1100 &lt;= o &lt;= 0x115F or            0x2E80 &lt;= o &lt;= 0x303F or            0x3040 &lt;= o &lt;= 0x309F or            0x30A0 &lt;= o &lt;= 0x30FF or            0x3100 &lt;= o &lt;= 0x312F or            0x3130 &lt;= o &lt;= 0x318F or            0x3190 &lt;= o &lt;= 0x319F or            0x31A0 &lt;= o &lt;= 0x31BF or            0x31C0 &lt;= o &lt;= 0x31EF or            0x31F0 &lt;= o &lt;= 0x31FF or            0x3200 &lt;= o &lt;= 0x32FF or            0x3300 &lt;= o &lt;= 0x33FF or            0x3400 &lt;= o &lt;= 0x4DBF or            0x4E00 &lt;= o &lt;= 0x9FFF or            0xF900 &lt;= o &lt;= 0xFAFF or            0xFE30 &lt;= o &lt;= 0xFE4F or            0xFF00 &lt;= o &lt;= 0xFFEF):            width += 2        else:            width += 1    return width</code></pre><p>在此基础上，重写了字符串填充逻辑 <code>pad_string</code>：</p><pre><code class="language-python">def pad_string(s, width, alignment=&#39;left&#39;):    s = str(s) if s is not None else &#39;&#39;    cur_width = get_display_width(s)    padding = width - cur_width    if padding &lt;= 0:        return s    if alignment == &#39;right&#39;:        return &#39; &#39; * padding + s    elif alignment == &#39;center&#39;:        left = padding // 2        right = padding - left        return &#39; &#39; * left + s + &#39; &#39; * right    else:        return s + &#39; &#39; * padding</code></pre><p>这使得表格在面对 <code>CMB(招商银行)</code>、<code>ICBC(工商银行)</code> 等中英混排字符串时，依然能实现像素级的完美框线对齐！</p><hr /><h3 id="%E6%8C%91%E6%88%98%E4%BA%8C%EF%BC%9Awindows-cmd-%E9%BB%98%E8%AE%A4-gbk-%E7%BC%96%E7%A0%81%E4%B8%8E-emoji-%E6%89%93%E5%8D%B0%E5%B4%A9%E6%BA%83" tabindex="-1">挑战二：Windows CMD 默认 GBK 编码与 Emoji 打印崩溃</h3><p>Windows 系统终端（<code>cmd.exe</code>）的默认代码页是 <strong>GBK (936)</strong>。当 Python 程序试图打印含有 <code>📄</code>、<code>🔌</code>、<code>✅</code>、<code>❌</code> 等万国码 Emoji 或者复杂的 UTF-8 字符时，程序会直接抛出著名的 <code>UnicodeEncodeError: 'gbk' codec can't encode character...</code> 崩溃退出。</p><p>更坑的是，如果试图用 UTF-8 编写 <code>.bat</code> 启动脚本，Windows CMD 在还没执行第一行 <code>chcp 65001</code> 之前，就会用 GBK 去解码批处理文件，直接导致“乱码报错，无法识别命令”。</p><h4 id="%F0%9F%92%A1-%E4%BC%98%E9%9B%85%E8%A7%A3%E6%B3%95%EF%BC%9A%E2%80%9C%E7%BA%AF-ascii-%E5%BC%95%E5%AF%BC-%2B-%E6%A0%87%E5%87%86%E6%B5%81%E5%8A%AB%E6%8C%81%E2%80%9D" tabindex="-1">💡 优雅解法：“纯 ASCII 引导 + 标准流劫持”</h4><p>为了完美解决这个巨坑，我采取了两步走策略：</p><ol><li><strong>写一个 100% 纯 ASCII 的 <a href="run.bat" target="_blank">run.bat</a></strong>：<br />批处理文件内部绝不出现任何中文字符（全部用 ASCII 英文字符），这样无论在什么国家、什么代码页的 Windows 下，CMD 都能顺利解析启动它。<br />启动后的第一行，再静默执行切换代码页：<pre><code class="language-batch">@echo offchcp 65001 &gt; nulpython &quot;%~dp0mail_client.py&quot; menu</code></pre></li><li><strong>在 Python 代码入口强制劫持标准流为 UTF-8</strong>：<br />在 <code>mail_client.py</code> 头部加上平台判断，如果是 Windows，直接用 <code>TextIOWrapper</code> 劫持 <code>sys.stdout</code> 和 <code>sys.stderr</code> 为 <code>utf-8</code>：<pre><code class="language-python">if sys.platform.startswith(&#39;win&#39;):    import io    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding=&#39;utf-8&#39;)    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding=&#39;utf-8&#39;)</code></pre></li></ol><p>这一套组合拳打下来，不管用户本地终端默认是什么编码，双击运行一秒切换 UTF-8，中文字符和 Emoji 渲染如丝般顺滑！</p><hr /><h2 id="%F0%9F%94%AE-%E6%80%BB%E7%BB%93%E4%B8%8E%E6%9C%AA%E6%9D%A5%E5%B1%95%E6%9C%9B%EF%BC%9A%E8%BF%88%E5%90%91%E2%80%9C%E4%BA%BA%E6%9C%BA%E5%8D%8F%E5%90%8C%E2%80%9D%E6%97%B6%E4%BB%A3" tabindex="-1">🔮 总结与未来展望：迈向“人机协同”时代</h2><p>这个项目目前已经满足了我对信用卡账单管理的一切幻想：<strong>它足够轻（0依赖），足够快（SQLite本地），足够智能（多行对账校验），同时颜值拉满</strong>。</p><p>更重要的是，它完美的契合了未来的 <strong>Agent 友好型设计模式</strong>。<br />在 AI 编码助手大行其道的今天，臃肿的 GUI 界面或过多的外部依赖包不仅减慢了运行速度，还把逻辑变成了黑盒。保持 <strong>CLI 命令输出标准化 + SQLite 本地关系型存储</strong>，可以让如 Cline、Antigravity 这样的 AI Agent 以极高精度理解你的数据库和调用你的 CLI。</p><p>未来，我计划为这个项目延伸一个**“轻量 Web 看板”**，依然保持核心 CLI 与数据的纯净，而由 Python 本地拉起一个华丽的现代 HTML5 可视化大屏。</p><ul><li><a href="https://github.com/maifeipin/mail-statement-parser" target="_blank">本项目完整开源</a>，欢迎大家在自己的本地尝试部署运行！</li></ul>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[Redmi AX6 刷 OpenWrt 完整流程（扩容 + U-Boot）]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/redmiax6-shua-openwrt-wan-zheng-liu-cheng--kuo-rong-u-boot" />
                <id>tag:https://maifeipin.com,2026-05-16:redmiax6-shua-openwrt-wan-zheng-liu-cheng--kuo-rong-u-boot</id>
                <published>2026-05-16T20:09:56+08:00</published>
                <updated>2026-05-16T20:31:12+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>适用设备：<strong>Xiaomi Redmi AX6</strong></p><p>目标：</p><ul><li>刷入扩容分区表</li><li>刷入改版 U-Boot</li><li>安装官方 OpenWrt</li><li>获得约 69 MB 可写空间（<code>/overlay</code>）</li></ul><hr /><h2 id="%E4%B8%80%E3%80%81%E5%87%86%E5%A4%87%E6%96%87%E4%BB%B6" tabindex="-1">一、准备文件</h2><h3 id="1.-%E8%BF%87%E6%B8%A1-openwrt" tabindex="-1">1. 过渡 OpenWrt</h3><ul><li><code>xiaomimtd12.bin</code></li></ul><h3 id="2.-%E6%89%A9%E5%AE%B9%E5%92%8C-u-boot-%E6%96%87%E4%BB%B6" tabindex="-1">2. 扩容和 U-Boot 文件</h3><ul><li><code>ax6-mibib.bin</code></li><li><code>uboot-redmi-ax6.bin</code></li></ul><h3 id="3.-openwrt-%E5%AE%98%E6%96%B9%E5%9B%BA%E4%BB%B6" tabindex="-1">3. OpenWrt 官方固件</h3><p>OpenWrt 下载目录：<br /><a href="https://downloads.openwrt.org/releases/25.12.4/targets/qualcommax/ipq807x/" target="_blank">https://downloads.openwrt.org/releases/25.12.4/targets/qualcommax/ipq807x/</a></p><p>需要下载：</p><ul><li><code>openwrt-25.12.4-qualcommax-ipq807x-redmi_ax6-initramfs-factory.ubi</code></li><li><code>openwrt-25.12.4-qualcommax-ipq807x-redmi_ax6-squashfs-sysupgrade.bin</code></li></ul><blockquote><p>大坑：直接在 U-Boot 中刷（squashfs，应该先刷上面的initramfs）黄灯常亮：<br /><code>openwrt-25.12.4-qualcommax-ipq807x-redmi_ax6-squashfs-factory.ubi</code></p></blockquote><hr /><h1 id="%E4%BA%8C%E3%80%81%E5%88%B7%E6%9C%BA%E6%AD%A5%E9%AA%A4" tabindex="-1">二、刷机步骤</h1><h2 id="%E7%AC%AC-1-%E6%AD%A5%EF%BC%9A%E8%8E%B7%E5%8F%96-root-%E6%9D%83%E9%99%90" tabindex="-1">第 1 步：获取 root 权限</h2><ol><li>在原厂系统中开启 Telnet。</li><li>获取 root 权限。</li><li>使用 SSH 登录路由器。</li></ol><hr /><h2 id="%E7%AC%AC-2-%E6%AD%A5%EF%BC%9A%E5%88%87%E6%8D%A2%E5%90%AF%E5%8A%A8%E5%88%86%E5%8C%BA%E5%88%B0-rootfs0%EF%BC%88mtd12%EF%BC%89" tabindex="-1">第 2 步：切换启动分区到 rootfs0（mtd12）</h2><pre><code class="language-sh">nvram set flag_last_success=0nvram set flag_boot_rootfs=0nvram commit</code></pre><h3 id="%E4%BD%9C%E7%94%A8" tabindex="-1">作用</h3><p>设置下次从 <code>rootfs0</code>（通常对应 <code>mtd12</code>）启动。</p><hr /><h2 id="%E7%AC%AC-3-%E6%AD%A5%EF%BC%9A%E5%88%B7%E5%85%A5%E8%BF%87%E6%B8%A1-openwrt" tabindex="-1">第 3 步：刷入过渡 OpenWrt</h2><p>上传：<code>xiaomimtd12.bin</code> 到 <code>/tmp</code></p><pre><code class="language-sh">mtd write /tmp/xiaomimtd12.bin rootfsreboot</code></pre><h3 id="%E4%BD%9C%E7%94%A8-1" tabindex="-1">作用</h3><p>将临时 OpenWrt 写入 <code>rootfs</code> 分区，启动一个可操作的 OpenWrt 环境。</p><hr /><h2 id="%E7%AC%AC-4-%E6%AD%A5%EF%BC%9A%E4%B8%8A%E4%BC%A0%E6%89%A9%E5%AE%B9%E6%96%87%E4%BB%B6" tabindex="-1">第 4 步：上传扩容文件</h2><p>上传以下文件到 <code>/tmp</code>：</p><ul><li><code>ax6-mibib.bin</code></li><li><code>uboot-redmi-ax6.bin</code></li></ul><hr /><h2 id="%E7%AC%AC-5-%E6%AD%A5%EF%BC%9A%E5%88%B7%E5%85%A5%E6%89%A9%E5%AE%B9%E5%88%86%E5%8C%BA%E8%A1%A8%E5%92%8C-u-boot" tabindex="-1">第 5 步：刷入扩容分区表和 U-Boot</h2><h3 id="%E5%88%B7%E5%85%A5-mibib%EF%BC%88mtd1%EF%BC%89" tabindex="-1">刷入 MIBIB（mtd1）</h3><pre><code class="language-sh">mtd erase /dev/mtd1mtd write /tmp/ax6-mibib.bin /dev/mtd1</code></pre><h3 id="%E5%88%B7%E5%85%A5-u-boot%EF%BC%88mtd7%EF%BC%89" tabindex="-1">刷入 U-Boot（mtd7）</h3><pre><code class="language-sh">mtd erase /dev/mtd7mtd write /tmp/uboot-redmi-ax6.bin /dev/mtd7</code></pre><blockquote><p>⚠️ 注意：文件名通常是 <code>ax6-mibib.bin</code>，不是 <code>ax6-minbib.bin</code>。</p></blockquote><hr /><h2 id="%E7%AC%AC-6-%E6%AD%A5%EF%BC%9A%E6%96%AD%E7%94%B5%E8%BF%9B%E5%85%A5-u-boot" tabindex="-1">第 6 步：断电进入 U-Boot</h2><ol><li>拔掉电源。</li><li>按住 Reset 键。</li><li>插上电源。</li><li>等待状态灯变绿。</li><li>浏览器访问： <code>http://192.168.1.1</code></li></ol><hr /><h1 id="%E4%B8%89%E3%80%81%E5%AE%89%E8%A3%85-openwrt" tabindex="-1">三、安装 OpenWrt</h1><h2 id="%E6%96%B9%E6%A1%88-a%EF%BC%88%E6%8E%A8%E8%8D%90%EF%BC%89%EF%BC%9A%E5%85%88%E5%90%AF%E5%8A%A8-initramfs%EF%BC%8C%E5%86%8D%E5%88%B7%E6%AD%A3%E5%BC%8F%E7%B3%BB%E7%BB%9F" tabindex="-1">方案 A（推荐）：先启动 initramfs，再刷正式系统</h2><h3 id="%E7%AC%AC-7-%E6%AD%A5%EF%BC%9A%E5%9C%A8-u-boot-%E4%B8%AD%E4%B8%8A%E4%BC%A0" tabindex="-1">第 7 步：在 U-Boot 中上传</h3><pre><code class="language-text">openwrt-25.12.4-qualcommax-ipq807x-redmi_ax6-initramfs-factory.ubi</code></pre><h3 id="%E4%BD%9C%E7%94%A8-2" tabindex="-1">作用</h3><p>临时启动 OpenWrt，不直接写入正式系统。</p><hr /><h3 id="%E7%AC%AC-8-%E6%AD%A5%EF%BC%9A%E5%9C%A8-openwrt-%E4%B8%AD%E5%88%B7%E6%AD%A3%E5%BC%8F%E7%B3%BB%E7%BB%9F" tabindex="-1">第 8 步：在 OpenWrt 中刷正式系统</h3><p>在系统固件界面 上传：</p><pre><code class="language-text">openwrt-25.12.4-qualcommax-ipq807x-redmi_ax6-squashfs-sysupgrade.bin</code></pre><p>或者在终端执行：</p><pre><code class="language-sh">sysupgrade -n /tmp/openwrt-25.12.4-qualcommax-ipq807x-redmi_ax6-squashfs-sysupgrade.bin</code></pre><h1 id="%E5%9B%9B%E3%80%81%E9%AA%8C%E8%AF%81%E5%AE%89%E8%A3%85%E6%88%90%E5%8A%9F" tabindex="-1">四、验证安装成功</h1><p>执行：</p><pre><code class="language-sh">df -hmount | grep overlayubinfo -a</code></pre><p>正常应看到：</p><pre><code class="language-text">/dev/ubi0_1              ...   /overlayoverlayfs:/overlay       ...   /</code></pre><p>以及：</p><pre><code class="language-text">Volume name: rootfs_dataSize: 69 MiB</code></pre><hr /><h1 id="%E4%BA%94%E3%80%81%E9%AA%8C%E8%AF%81%E9%85%8D%E7%BD%AE%E5%8F%AF%E4%BF%9D%E5%AD%98" tabindex="-1">五、验证配置可保存</h1><pre><code class="language-sh">echo test &gt; /etc/testfilereboot</code></pre><p>重启后：</p><pre><code class="language-sh">cat /etc/testfile</code></pre><p>如果仍显示 <code>test</code>，说明 overlay 正常。</p><hr /><h1 id="%E5%85%AD%E3%80%81%E5%A4%87%E4%BB%BD%E9%85%8D%E7%BD%AE" tabindex="-1">六、备份配置</h1><pre><code class="language-sh">sysupgrade -b /tmp/backup.tar.gz</code></pre><p>然后下载：</p><pre><code class="language-text">/tmp/backup.tar.gz</code></pre><hr /><h1 id="%E4%B8%83%E3%80%81%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" tabindex="-1">七、常见问题</h1><h2 id="1.-%2From-%E6%98%BE%E7%A4%BA-100%25" tabindex="-1">1. <code>/rom</code> 显示 100%</h2><p>正常现象，<code>/rom</code> 是只读的 squashfs。</p><h2 id="2.-%E9%85%8D%E7%BD%AE%E9%87%8D%E5%90%AF%E5%90%8E%E4%B8%A2%E5%A4%B1" tabindex="-1">2. 配置重启后丢失</h2><p>说明 <code>/overlay</code> 没有正常挂载。</p><h2 id="3.-%E5%88%A0%E9%99%A4-rootfs_data-%E5%90%8E%E8%BF%9B%E5%85%A5-u-boot" tabindex="-1">3. 删除 <code>rootfs_data</code> 后进入 U-Boot</h2><p>属于正常恢复行为，重新刷固件即可。</p><hr /><h1 id="%E5%85%AB%E3%80%81%E6%9C%80%E7%BB%88%E9%A2%84%E6%9C%9F%E7%8A%B6%E6%80%81" tabindex="-1">八、最终预期状态</h1><pre><code class="language-text">Filesystem                Size      Used Available Use% Mounted on/dev/root                 7.5M      7.5M         0 100% /rom/dev/ubi0_1              62.0M     ...      ...   /overlayoverlayfs:/overlay       62.0M     ...      ...   /</code></pre><hr /><h1 id="%E4%B9%9D%E3%80%81%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B%E5%9B%BE" tabindex="-1">九、完整流程图</h1><pre><code class="language-text">原厂系统  ↓获取 root  ↓设置启动到 rootfs0  ↓刷 xiaomimtd12.bin  ↓进入临时 OpenWrt  ↓刷 ax6-mibib.bin + uboot-redmi-ax6.bin  ↓断电进入 U-Boot  ↓刷 initramfs-factory.ubi  ↓进入 OpenWrt  ↓sysupgrade 刷正式系统  ↓安装完成</code></pre><hr /><h1 id="%E5%8D%81%E3%80%81%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%80%BB%E7%BB%93" tabindex="-1">十、一句话总结</h1><blockquote><p>获取 root → 刷过渡 OpenWrt → 刷扩容分区表和 U-Boot → 进入 U-Boot → 安装官方 OpenWrt → 获得约 69 MB 可写空间。</p></blockquote><h1 id="%E9%99%84%E4%BB%B6" tabindex="-1">附件</h1><p><a href="https://music.maifeipin.com/api/files/download?sourceId=18&amp;path=Upload%2Fax6%2FAX6%E6%89%A9%E5%AE%B9.zip" target="_blank">AX6扩容文件</a></p><p><img src="/upload/2026/05/image-1778934002013.png" alt="image-1778934002013" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[自建 Calibre-Web + Legado 阅读，实现手机 TTS 听书自由]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/zi-jian-calibre-weblegado-yue-du--shi-xian-shou-ji-tts-ting-shu-zi-you" />
                <id>tag:https://maifeipin.com,2026-05-16:zi-jian-calibre-weblegado-yue-du--shi-xian-shou-ji-tts-ting-shu-zi-you</id>
                <published>2026-05-16T08:01:14+08:00</published>
                <updated>2026-05-16T08:04:39+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<blockquote><p>用 Docker 自建书库，Legado 直接下载 ，Legado 支持的格式很多，EPUB、TXT、PDF、UMD、MOBI、AZW3等；系统 TTS 本地朗读——全程离线、零流量、极致省电。</p></blockquote><hr /><h2 id="%E4%B8%80%E3%80%81%E6%95%B4%E4%BD%93%E6%9E%B6%E6%9E%84" tabindex="-1">一、整体架构</h2><pre><code class="language-">┌─────────────────┐     OPDS/下载      ┌──────────────┐     本地TTS      ┌──────┐│  Calibre-Web    │ ◄──────────────► │   Legado     │ ◄─────────────► │ 手机  ││  (Docker 本地)   │    Basic Auth     │  (Android)   │   系统语音引擎    │ 扬声器 │└─────────────────┘                   └──────────────┘                 └──────┘</code></pre><ul><li><strong>Calibre-Web</strong>：管理电子书，提供 OPDS 接口</li><li><strong>Legado</strong>：搜索、下载 EPUB、调用系统 TTS 朗读</li><li><strong>系统 TTS</strong>：本地合成语音，无需网络，零延迟</li></ul><hr /><h2 id="%E4%BA%8C%E3%80%81calibre-%E6%A1%8C%E9%9D%A2%E7%89%88%EF%BC%88%E5%8F%AF%E9%80%89%EF%BC%89" tabindex="-1">二、Calibre 桌面版（可选）</h2><p>如果你需要<strong>格式转换</strong>（如 AZW3 → EPUB）、<strong>批量元数据编辑</strong>、<strong>复杂排版调整</strong>等高级功能，可以在本机安装 Calibre 桌面应用。</p><p>Calibre-Web 可以直接读取 Calibre 桌面版的书库目录，两者共享同一套书籍文件：</p><pre><code class="language-bash"># macOSbrew install --cask calibre# 书库目录即 Calibre 默认路径# Calibre-Web 的 /books 挂载到这个目录即可</code></pre><pre><code class="language-">┌──────────────┐     共享书库      ┌──────────────┐│   Calibre    │ ◄─────────────► │ Calibre-Web  ││  (桌面版)     │   ~/Calibre库/   │  (Docker)    ││ 格式转换/编辑  │                 │ OPDS/Web阅读  │└──────────────┘                 └──────────────┘</code></pre><blockquote><p>如果只是下载 EPUB 直接阅读，不需要安装桌面版。Calibre-Web 本身已足够。</p></blockquote><hr /><h2 id="%E4%B8%89%E3%80%81docker-%E9%83%A8%E7%BD%B2-calibre-web" tabindex="-1">三、Docker 部署 Calibre-Web</h2><h3 id="3.1-%E5%87%86%E5%A4%87%E4%B9%A6%E7%B1%8D%E7%9B%AE%E5%BD%95" tabindex="-1">3.1 准备书籍目录</h3><pre><code class="language-bash">mkdir -p ~/calibre/{books,config}</code></pre><p>把 EPUB 文件放入 <code>~/calibre/books</code>。</p><h3 id="3.2-%E5%90%AF%E5%8A%A8%E5%AE%B9%E5%99%A8%EF%BC%88%E5%A6%82%E6%9E%9C%E6%9C%89%E5%AE%89%E8%A3%85%E6%A1%8C%E9%9D%A2%E7%89%88%EF%BC%8C%E5%8F%AF%E4%BF%AE%E6%94%B9%E4%B8%8B%E9%9D%A2%E7%9A%84config-%E8%B7%AF%E5%BE%84%EF%BC%8C%E6%97%A0%E7%BC%9D%E5%AF%B9%E6%8E%A5%EF%BC%89" tabindex="-1">3.2 启动容器（如果有安装桌面版，可修改下面的config 路径，无缝对接）</h3><pre><code class="language-bash">docker run -d \  --name calibre-web \  --restart unless-stopped \  -p 8083:8083 \  -v ~/calibre/books:/books \  -v ~/calibre/config:/config \  lscr.io/linuxserver/calibre-web:latest</code></pre><h3 id="3.3-%E5%88%9D%E5%A7%8B%E5%8C%96" tabindex="-1">3.3 初始化</h3><ol><li>浏览器打开 <code>http://&lt;你的IP&gt;:8083</code></li><li>默认账号 <code>admin</code> / 密码 <code>admin123</code></li><li>设置书库路径为 <code>/books</code></li><li>创建普通用户（如 <code>u2</code>），开启 <strong>允许下载</strong> 权限</li></ol><hr /><h2 id="%E5%9B%9B%E3%80%81legado-%E4%B9%A6%E6%BA%90%E9%85%8D%E7%BD%AE" tabindex="-1">四、Legado 书源配置</h2><h3 id="4.1-%E7%94%9F%E6%88%90-basic-auth-%E5%87%AD%E8%AF%81" tabindex="-1">4.1 生成 Basic Auth 凭证</h3><pre><code class="language-bash">echo -n &quot;u2:User.passwd&quot; | base64# 输出: dTIbase64MDI2</code></pre><h3 id="4.2-%E5%AF%BC%E5%85%A5%E4%B9%A6%E6%BA%90-json" tabindex="-1">4.2 导入书源 JSON</h3><p>在 Legado 中：<strong>我的 → 书源管理 → 右上角 ⋮ → 网络导入</strong>，粘贴以下 JSON：</p><pre><code class="language-json">[  {    &quot;bookSourceComment&quot;: &quot;Calibre-web OPDS书源&quot;,    &quot;bookSourceGroup&quot;: &quot;Calibre-web&quot;,    &quot;bookSourceName&quot;: &quot;我的书库&quot;,    &quot;bookSourceType&quot;: 3,    &quot;bookSourceUrl&quot;: &quot;http://192.168.2.240:8083&quot;,    &quot;enabled&quot;: true,    &quot;enabledExplore&quot;: true,    &quot;exploreUrl&quot;: &quot;新书::http://192.168.2.240:8083/opds/new&quot;,    &quot;header&quot;: &quot;{\&quot;Authorization\&quot;: \&quot;Basic dTxxxbase64xxI2\&quot;}&quot;,    &quot;respondTime&quot;: 180000,    &quot;ruleBookInfo&quot;: {      &quot;author&quot;: &quot;@js:var m = result.match(/&lt;author&gt;[\\s\\S]*?&lt;name&gt;([^&lt;]+)&lt;\\/name&gt;/); m ? m[1] : &#39;&#39;&quot;,      &quot;coverUrl&quot;: &quot;@js:var m = result.match(/href=\&quot;([^\&quot;]+)\&quot;[^&gt;]*rel=\&quot;http:\\/\\/opds-spec.org\\/image\&quot;/); m ? m[1] : &#39;&#39;&quot;,      &quot;downloadUrls&quot;: &quot;@js:baseUrl&quot;,      &quot;intro&quot;: &quot;@js:var m = result.match(/&lt;content[^&gt;]*&gt;[\\s\\S]*?&lt;p&gt;([^&lt;]+)&lt;\\/p&gt;/); m ? m[1] : &#39;&#39;&quot;,      &quot;name&quot;: &quot;@js:var m = result.match(/&lt;title&gt;([^&lt;]+)&lt;\\/title&gt;/); m ? m[1] : &#39;&#39;&quot;    },    &quot;ruleContent&quot;: { &quot;content&quot;: &quot;&quot; },    &quot;ruleExplore&quot;: {      &quot;author&quot;: &quot;@js:var m = result.match(/&lt;author&gt;[\\s\\S]*?&lt;name&gt;([^&lt;]+)&lt;\\/name&gt;/); m ? m[1] : &#39;&#39;&quot;,      &quot;bookList&quot;: &quot;@js:var entries = [];\nvar regex = /&lt;entry&gt;[\\s\\S]*?&lt;\\/entry&gt;/g;\nvar match;\nwhile ((match = regex.exec(result)) !== null) {\n  entries.push(match[0]);\n}\nentries&quot;,      &quot;bookUrl&quot;: &quot;@js:var m = result.match(/rel=\&quot;http:\\/\\/opds-spec.org\\/acquisition\&quot;[^&gt;]*href=\&quot;([^\&quot;]+)\&quot;/); m ? m[1] : &#39;&#39;&quot;,      &quot;coverUrl&quot;: &quot;@js:var m = result.match(/href=\&quot;([^\&quot;]+)\&quot;[^&gt;]*rel=\&quot;http:\\/\\/opds-spec.org\\/image\&quot;/); m ? m[1] : &#39;&#39;&quot;,      &quot;intro&quot;: &quot;@js:var m = result.match(/&lt;content[^&gt;]*&gt;[\\s\\S]*?&lt;p&gt;([^&lt;]+)&lt;\\/p&gt;/); m ? m[1] : &#39;&#39;&quot;,      &quot;name&quot;: &quot;@js:var m = result.match(/&lt;title&gt;([^&lt;]+)&lt;\\/title&gt;/); m ? m[1] : &#39;&#39;    },    &quot;ruleSearch&quot;: {      &quot;author&quot;: &quot;@js:var m = result.match(/&lt;author&gt;[\\s\\S]*?&lt;name&gt;([^&lt;]+)&lt;\\/name&gt;/); m ? m[1] : &#39;&#39;&quot;,      &quot;bookList&quot;: &quot;@js:var entries = [];\nvar regex = /&lt;entry&gt;[\\s\\S]*?&lt;\\/entry&gt;/g;\nvar match;\nwhile ((match = regex.exec(result)) !== null) {\n  entries.push(match[0]);\n}\nentries&quot;,      &quot;bookUrl&quot;: &quot;@js:var m = result.match(/rel=\&quot;http:\\/\\/opds-spec.org\\/acquisition\&quot;[^&gt;]*href=\&quot;([^\&quot;]+)\&quot;/); m ? m[1] : &#39;&#39;&quot;,      &quot;coverUrl&quot;: &quot;@js:var m = result.match(/href=\&quot;([^\&quot;]+)\&quot;[^&gt;]*rel=\&quot;http:\\/\\/opds-spec.org\\/image\&quot;/); m ? m[1] : &#39;&#39;&quot;,      &quot;intro&quot;: &quot;@js:var m = result.match(/&lt;content[^&gt;]*&gt;[\\s\\S]*?&lt;p&gt;([^&lt;]+)&lt;\\/p&gt;/); m ? m[1] : &#39;&#39;&quot;,      &quot;name&quot;: &quot;@js:var m = result.match(/&lt;title&gt;([^&lt;]+)&lt;\\/title&gt;/); m ? m[1] : &#39;&#39;    },    &quot;ruleToc&quot;: {      &quot;chapterList&quot;: &quot;&quot;,      &quot;chapterName&quot;: &quot;&quot;,      &quot;chapterUrl&quot;: &quot;&quot;    },    &quot;searchUrl&quot;: &quot;http://192.168.2.240:8083/opds/search?query={{key}}&quot;,    &quot;weight&quot;: 0  }]</code></pre><blockquote><p><strong>注意</strong>：把 <code>192.168.2.240</code> 换成你的 Calibre-Web 实际 IP。<code>bookSourceType: 3</code> 表示这是一个下载型书源（非在线章节）。</p></blockquote><h3 id="4.3-%E4%BD%BF%E7%94%A8%E6%B5%81%E7%A8%8B" tabindex="-1">4.3 使用流程</h3><ol><li><strong>搜索</strong>：在 Legado 搜索书名</li><li><strong>点击书籍</strong>：进入详情页，出现<strong>下载按钮</strong></li><li><strong>下载</strong>：EPUB 自动下载并导入为本地书籍</li><li><strong>朗读</strong>：打开书籍 → 菜单 → <strong>朗读</strong> → 选择系统 TTS</li></ol><hr /><h2 id="%E4%BA%94%E3%80%81legado-%E6%BA%90%E7%A0%81%E4%BF%AE%E6%94%B9%EF%BC%88%E5%85%B3%E9%94%AE%E4%BF%AE%E5%A4%8D%EF%BC%89" tabindex="-1">五、Legado 源码修改（关键修复）</h2><p>Legado 原版对 <code>bookSourceType: 3</code>（下载型书源）有一个设计缺陷：获取书籍信息时会<strong>先下载整个 EPUB 文件</strong>（可能几十 MB），导致超时或 OOM，下载按钮无法显示。</p><h3 id="%E4%BF%AE%E6%94%B9%E6%96%87%E4%BB%B6%EF%BC%9Aapp%2Fsrc%2Fmain%2Fjava%2Fio%2Flegado%2Fapp%2Fmodel%2Fwebbook%2Fwebbook.kt" tabindex="-1">修改文件：<code>app/src/main/java/io/legado/app/model/webBook/WebBook.kt</code></h3><p><strong>修改 1：<code>getBookInfoAwait</code> — 跳过文件下载</strong></p><pre><code class="language-kotlin">suspend fun getBookInfoAwait(    bookSource: BookSource,    book: Book,    canReName: Boolean = true,): Book {    book.removeAllBookType()    book.addType(bookSource.getBookType())    // 新增：file 类型直接返回，不下载文件    if (bookSource.bookSourceType == BookSourceType.file) {        if (book.downloadUrls.isNullOrEmpty()) {            book.downloadUrls = listOf(book.bookUrl)        }        return book    }    // ... 原有逻辑}</code></pre><p><strong>修改 2：<code>getChapterListAwait</code> — 返回空章节列表</strong></p><pre><code class="language-kotlin">suspend fun getChapterListAwait(    bookSource: BookSource,    book: Book,    runPerJs: Boolean = false): Result&lt;List&lt;BookChapter&gt;&gt; {    book.removeAllBookType()    book.addType(bookSource.getBookType())    // 新增：file 类型返回空列表    if (bookSource.bookSourceType == BookSourceType.file) {        return Result.success(emptyList())    }    // ... 原有逻辑}</code></pre><h3 id="github-actions-%E8%87%AA%E5%8A%A8%E6%9E%84%E5%BB%BA" tabindex="-1">GitHub Actions 自动构建</h3><pre><code class="language-yaml"># .github/workflows/debug_build.ymlname: Debug Buildon:  workflow_dispatch:  push:    branches: [calibre-web]jobs:  build:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-java@v4        with:          distribution: &#39;temurin&#39;          java-version: 17      - uses: gradle/actions/setup-gradle@v4      - run: ./gradlew assembleDebug      - uses: actions/upload-artifact@v4        with:          name: legado_debug          path: app/build/outputs/apk/app/debug/*.apk</code></pre><hr /><h2 id="%E5%85%AD%E3%80%81tts-%E9%85%8D%E7%BD%AE" tabindex="-1">六、TTS 配置</h2><h3 id="6.1-%E7%B3%BB%E7%BB%9F-tts%EF%BC%88%E6%8E%A8%E8%8D%90%EF%BC%89" tabindex="-1">6.1 系统 TTS（推荐）</h3><p>Legado 直接调用 Android 系统 TTS 引擎，<strong>本地合成、零延迟、零流量、最省电</strong>。</p><p>手机上安装一个高质量 TTS 引擎即可：</p><ul><li><strong>Google 语音引擎</strong>（Play Store 下载）</li><li><strong>讯飞语记</strong>（需在系统设置中启用）</li><li><strong>小米 TTS</strong>（MIUI 自带）</li></ul><h3 id="6.2-%E8%87%AA%E5%AE%9A%E4%B9%89-http-tts%EF%BC%88%E5%A4%87%E9%80%89%EF%BC%89" tabindex="-1">6.2 自定义 HTTP TTS（备选）</h3><p>如果需要特定音色，可以自建 TTS 服务。Legado 支持自定义 HTTP TTS：</p><pre><code class="language-json">{  &quot;name&quot;: &quot;私有Edge&quot;,  &quot;url&quot;: &quot;https://your-server/api/tts?text={{speakText}}&amp;key=your_secret&quot;,  &quot;contentType&quot;: &quot;audio/mpeg&quot;}</code></pre><p>可用变量：</p><table><thead><tr><th>变量</th><th>说明</th></tr></thead><tbody><tr><td><code>{{speakText}}</code></td><td>朗读文本</td></tr><tr><td><code>{{speakSpeed}}</code></td><td>朗读速度 (5-50)</td></tr></tbody></table><blockquote><p><strong>注意</strong>：HTTP TTS 每句话都要网络请求，朗读一本书可能产生几千次请求，耗电和延迟都远高于系统 TTS。建议仅作为音色备选。</p></blockquote><hr /><h2 id="%E4%B8%83%E3%80%81%E6%80%A7%E8%83%BD%E5%AF%B9%E6%AF%94" tabindex="-1">七、性能对比</h2><table><thead><tr><th>方案</th><th>延迟</th><th>耗电</th><th>流量</th><th>隐私</th></tr></thead><tbody><tr><td><strong>系统 TTS</strong></td><td>毫秒级</td><td>低</td><td>0</td><td>文本不出设备</td></tr><tr><td>HTTP TTS</td><td>数百毫秒</td><td>高（网络持续唤醒）</td><td>每句 ~10KB</td><td>文本发到服务器</td></tr><tr><td>在线听书 APP</td><td>秒级</td><td>高</td><td>每章 ~5MB</td><td>完全上传</td></tr></tbody></table><hr /><h2 id="%E5%85%AB%E3%80%81%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" tabindex="-1">八、常见问题</h2><h3 id="q%3A-%E6%90%9C%E7%B4%A2%E5%88%B0%E4%B9%A6%E4%BD%86%E4%B8%8B%E8%BD%BD%E6%8C%89%E9%92%AE%E4%B8%8D%E6%98%BE%E7%A4%BA%EF%BC%9F" tabindex="-1">Q: 搜索到书但下载按钮不显示？</h3><p>A: 确认 <code>bookSourceType</code> 为 <code>3</code>，且使用了修改后的 Legado 版本。原版会因为 EPUB 文件太大导致超时。</p><h3 id="q%3A-%E4%B8%8B%E8%BD%BD%E5%90%8E%E6%89%93%E5%BC%80%E6%98%AF%E7%A9%BA%E7%99%BD%EF%BC%9F" tabindex="-1">Q: 下载后打开是空白？</h3><p>A: 从书架直接打开下载型书源会没有章节。正确流程：<strong>搜索 → 点击书 → 详情页点下载 → 自动打开</strong>。</p><h3 id="q%3A-tts-%E6%9C%97%E8%AF%BB%E4%B8%AD%E6%96%AD%EF%BC%9F" tabindex="-1">Q: TTS 朗读中断？</h3><p>A: 检查系统 TTS 引擎是否被电池优化杀死。在系统设置中关闭对 TTS 引擎的电池优化。</p><h3 id="q%3A-%E6%83%B3%E5%9C%A8%E5%A4%96%E7%BD%91%E8%AE%BF%E9%97%AE%E4%B9%A6%E5%BA%93%EF%BC%9F" tabindex="-1">Q: 想在外网访问书库？</h3><p>A: 用 Tailscale/ZeroTier 组网，或 Nginx 反代 + Basic Auth，不建议直接暴露端口。</p><hr /><h2 id="%E4%B9%9D%E3%80%81%E6%80%BB%E7%BB%93" tabindex="-1">九、总结</h2><pre><code class="language-">Docker 部署 Calibre-Web（10 分钟）    +Legado 导入 OPDS 书源（1 分钟）    +系统 TTS 朗读（0 配置）    =手机听书自由 🎧</code></pre><p>全程本地运行，不依赖任何云服务，书籍和语音数据都不出局域网。享受纯粹的阅读和听书体验。</p><p><img src="/upload/2026/05/29eb18468430b12df6643993cd814a87.jpg" alt="29eb18468430b12df6643993cd814a87" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[EdgeTTSPlayer 开发手记：打磨极致体验的本地听书神器]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/edgettsplayer-kai-fa-shou-ji--da-mo-ji-zhi-ti-yan-de-ben-de-ting-shu-shen-qi" />
                <id>tag:https://maifeipin.com,2026-05-10:edgettsplayer-kai-fa-shou-ji--da-mo-ji-zhi-ti-yan-de-ben-de-ting-shu-shen-qi</id>
                <published>2026-05-10T06:12:13+08:00</published>
                <updated>2026-05-10T06:14:06+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>作为一个喜欢听书的人，市面上虽然有不少阅读软件，但在桌面端往往难以找到一款轻量、免费且发音自然顺滑的本地听书工具。为此，我开发了 <strong>EdgeTTSPlayer</strong> —— 一款基于 <code>edge-tts</code> 和 <code>pygame</code> 构建的本地有声书播放器。</p><p>近期，为了对付体量庞大、排版混乱的网文 EPUB，以及彻底解放打包发布的双手，我对项目进行了一次大刀阔斧的重构。在此记录下这次迭代中踩过的坑与技术解决方案。</p><hr /><h2 id="1.-%E9%A9%AF%E6%9C%8D%E6%B7%B7%E4%B9%B1%E7%9A%84-epub%EF%BC%9A%E6%99%BA%E8%83%BD%E7%AB%A0%E8%8A%82%E6%A0%87%E9%A2%98%E6%8F%90%E5%8F%96" tabindex="-1">1. 驯服混乱的 EPUB：智能章节标题提取</h2><h3 id="%E7%97%9B%E7%82%B9" tabindex="-1">痛点</h3><p>在解析像《剑来》这样的网文 EPUB 时，我发现原有的章节解析算法完全失效，下拉列表里全变成了干瘪的“第 N 章”，甚至是一片空白。<br />深入扒开原生的 HTML 代码后，我发现了令人窒息的排版：</p><pre><code class="language-html">&lt;div class=&quot;header1&quot;&gt;&lt;h2&gt;&lt;b&gt;第&lt;/b&gt;&lt;b&gt;一&lt;/b&gt;&lt;b&gt;章&lt;/b&gt;&lt;/h2&gt;&lt;/div&gt;&lt;div class=&quot;part&quot;&gt;&lt;/div&gt;&lt;div class=&quot;header1&quot;&gt;&lt;h2&gt;&lt;b&gt;惊&lt;/b&gt;&lt;b&gt;蛰&lt;/b&gt;&lt;/h2&gt;&lt;/div&gt;&lt;div class=&quot;part&quot;&gt;&lt;p&gt;二月二，龙抬头...&lt;/p&gt;&lt;/div&gt;</code></pre><p>“第一章” 和 “惊蛰” 被硬生生拆分进了两个互相独立的 <code>&lt;h2&gt;</code> 标签，部分书源甚至连 <code>&lt;title&gt;</code> 和 <code>&lt;h&gt;</code> 标签都没有，全靠 <code>&lt;b&gt;</code> 加粗。</p><h3 id="%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88" tabindex="-1">解决方案</h3><p>我放弃了原本只匹配第一个 <code>&lt;h&gt;</code> 标签的简陋做法，重写了一套兼顾“规范排版”与“野生排版”的降维打击式提取算法：</p><ol><li><strong>多头合并</strong>：通过 <code>BeautifulSoup</code> 抓取前 3 个 <code>h1-h3</code> 标签，清洗后将它们用空格强行拼接，完美复原 <code>第一章 惊蛰</code>。</li><li><strong>正文嗅探（针对极简标题）</strong>：利用正则 <code>^第[零一二...]+[章回]$</code> 进行探测。如果只提取到了“第一章”，则继续切分正文的段落（Paragraph）。只要“第一章”下一段的字数少于 20 个字，就判定其为副标题并强行抓取过来。</li><li><strong>终极兜底</strong>：如果真的是没有任何标题的“三无”文件，程序会直接抽取前两个段落的内容，过滤掉所有换行与大段空白后，截取前 40 个字作为该章概要（例如 <code>陈平安看着远处的山峰...</code>）。</li></ol><p>经过这套组合拳，即使是排版再糟糕的书源，也能在软件的侧边栏呈现出美观连贯的目录树。</p><hr /><h2 id="2.-%E7%8A%B6%E6%80%81%E6%8C%81%E4%B9%85%E5%8C%96%E4%B8%8E%E2%80%9C%E6%97%A0%E7%BC%9D%E2%80%9D%E4%BA%A4%E4%BA%92%E4%BD%93%E9%AA%8C" tabindex="-1">2. 状态持久化与“无缝”交互体验</h2><h3 id="%E9%9A%8F%E6%92%AD%E9%9A%8F%E8%AE%B0%E4%B8%8E%E5%85%A8%E5%B1%80%E8%AE%B0%E5%BF%86" tabindex="-1">随播随记与全局记忆</h3><p>听书最怕的不是报错，而是突然断电后“找不到上次听到哪儿了”。<br />为此，我设计了一套基于 JSON 的细粒度持久化方案：</p><ul><li><strong>全局偏好</strong>：你的专属发音人、语速、音量会被独立记录。下次打开任何新书，都会优先加载这些偏好设置。这里曾踩过一个小坑：UI 下拉框里显示的是带详细介绍的字符串（如 <code>zh-CN-XiaoxiaoNeural (女)</code>），保存时一定要剥离出纯净的 <code>ShortName</code>，否则下次启动底层引擎会识别失败。</li><li><strong>随播随记</strong>：后台每播放完一个 Chunk（碎片文本），就会把 <code>chunk_index</code> 和联动的 <code>chapter_index</code> 写进缓存。我也在 UI 上新增了 <code>[💾 存进度]</code> 按钮以备不时之需。配合 <code>cache_version</code> 强刷新机制，完美解决了电子书解析缓存导致的脏数据问题。</li></ul><h3 id="%E5%AE%9E%E6%97%B6%E4%BB%8B%E5%85%A5%EF%BC%9A%E5%8A%A8%E6%80%81%E9%9F%B3%E9%87%8F%E4%B8%8E%E8%AF%AD%E9%80%9F%E6%84%9F%E7%9F%A5" tabindex="-1">实时介入：动态音量与语速感知</h3><p>由于我的播放机制是“双缓冲架构”（一边播放当前句子，一边后台异步请求 Edge-TTS 预生成下一句），调整语速和发音人曾经需要“停止-重新播放”才能生效。</p><ul><li><strong>音量直连</strong>：我将界面的音量滑块直接绑定了 <code>pygame.mixer.music.set_volume()</code>，实现了完全实时的音量升降。</li><li><strong>动态窥探</strong>：在后台线程每次准备生成下一个 Chunk 之前，我会通过 <code>getattr</code> 动态抓取当前主线程中选择的发音人和语速。这样，当你嫌主角语速太慢而拉动滑块时，当前这半句话读完，<strong>下一句话会直接无缝切换成新语速</strong>，完全不需要暂停或打断体验！</li></ul><hr /><h2 id="3.-%E8%A7%A3%E6%94%BE%E5%8F%8C%E6%89%8B%EF%BC%9Agithub-actions-%E8%B7%A8%E5%B9%B3%E5%8F%B0%E5%85%A8%E8%87%AA%E5%8A%A8%E5%8F%91%E7%89%88" tabindex="-1">3. 解放双手：GitHub Actions 跨平台全自动发版</h2><p>随着功能完善，每次更新都要自己跑一次 PyInstaller 实在太折磨了。更何况身为 Windows 用户，想给 Mac 和 Linux 朋友提供可执行文件几乎不可能。<br />因此，我搭建了基于 GitHub Actions 的 CI/CD 自动化流水线。</p><h3 id="%E6%A0%B8%E5%BF%83%E5%AE%9E%E7%8E%B0%EF%BC%9A" tabindex="-1">核心实现：</h3><pre><code class="language-yaml">on:  release:    types: [published]permissions:  contents: writejobs:  build:    runs-on: ${{ matrix.os }}    strategy:      matrix:        include:          - os: windows-latest          - os: macos-latest          - os: ubuntu-latest</code></pre><h3 id="%E8%B8%A9%E5%9D%91%E7%BB%8F%E9%AA%8C%EF%BC%9A" tabindex="-1">踩坑经验：</h3><ol><li><strong>触发机制的羁绊</strong>：最开始想用打 Tag 的方式触发，但后来发现将触发条件改为 <code>release: types: [published]</code> 最符合直觉。我们在 GitHub 网页端点击 <code>Draft a new release</code> 并发布的一瞬间，动作即被拉起，最后利用 <code>softprops/action-gh-release@v2</code> 就可以将编译产物自动挂载到刚创建的那个 Release 下，不需要自己去写复杂的 Release 创建脚本。</li><li><strong>环境权限陷阱</strong>：GitHub 近期收紧了 Actions 默认权限。务必记得在 Workflow 顶部加上 <code>permissions: contents: write</code>，否则编译完的 <code>.exe</code> 根本没有权限上传到 Releases 页面里（会报 <code>HttpError: Resource not accessible by integration</code> 的错）。</li><li><strong>跨平台依赖</strong>：在 Linux (Ubuntu) 虚拟服务器上跑 Tkinter 是缺少图形环境的，必须在脚本里手动执行 <code>sudo apt-get install -y python3-tk</code> 补充依赖。此外，MacOS 编译出的并非单文件，而是 <code>xxx.app</code> 目录，需要单独针对 Mac 编写 <code>zip -r</code> 压缩指令。</li></ol><p>现在，我只需要在 GitHub 随便点两下发布一个版本，系统就会拉起三台机器，几分钟后自动把 <strong>Windows版、macOS版、Linux版</strong> 打包压缩好呈现在眼前。这就是自动化的魅力！</p><hr /><p><strong>开源地址</strong>：<a href="https://github.com/maifeipin/EdgeTTSPlayer" target="_blank">maifeipin/EdgeTTSPlayer</a><br /><img src="/upload/2026/05/image.png" alt="image" /></p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[赋能高考志愿：基于 Next.js 与内网穿透的高性能志愿决策系统实战]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/fu-neng-gao-kao-zhi-yuan--ji-yu-nextjs-yu-nei-wang-chuan-tou-de-gao-xing-neng-zhi-yuan-jue-ce-xi-tong-shi-zhan" />
                <id>tag:https://maifeipin.com,2026-04-25:fu-neng-gao-kao-zhi-yuan--ji-yu-nextjs-yu-nei-wang-chuan-tou-de-gao-xing-neng-zhi-yuan-jue-ce-xi-tong-shi-zhan</id>
                <published>2026-04-25T16:54:11+08:00</published>
                <updated>2026-04-25T17:04:37+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<p>高考志愿填报是人生的关键转折点。为了帮助考生更科学地从海量院校中挖掘最优选择，我开发了这套**“志愿决策系统” (Volunteer Finder)**。本文将从技术实现到实际应用，带你深度剖析这套系统的核心亮点。</p><hr /><h2 id="%F0%9F%9A%80-%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF%E6%9E%B6%E6%9E%84" tabindex="-1">🚀 核心技术架构</h2><p>本系统采用现代 Web 开发的全栈方案，追求极速响应与极致视觉体验。</p><ul><li><strong>前端框架</strong>: <a href="https://nextjs.org/" target="_blank">Next.js</a> (App Router) + TypeScript</li><li><strong>样式引擎</strong>: 纯原生 CSS (Glassmorphism 玻璃拟态设计)</li><li><strong>数据库</strong>: <a href="https://sqlite.org/" target="_blank">SQLite</a> (通过 <code>better-sqlite3</code> 实现高性能查询)</li><li><strong>图标系统</strong>: <a href="https://lucide.dev/" target="_blank">Lucide React</a></li><li><strong>部署方案</strong>: 本地 Mac Mini (生产环境) + Tailscale 内网穿透 + 腾讯云 VPS (Nginx 反向代理)</li></ul><hr /><h2 id="%F0%9F%92%A1-%E6%8A%80%E6%9C%AF%E4%BA%AE%E7%82%B9%EF%BC%9A%E4%B8%8D%E4%BB%85%E6%98%AF%E6%9F%A5%E8%AF%A2%EF%BC%8C%E6%9B%B4%E6%98%AF%E6%99%BA%E8%83%BD%E6%8E%A8%E8%8D%90" tabindex="-1">💡 技术亮点：不仅是查询，更是智能推荐</h2><h3 id="1.-%E7%B2%BE%E5%87%86%E7%9A%84%E2%80%9C%E5%88%86%E6%A1%A3%E2%80%9D%E6%8E%A8%E8%8D%90%E7%AE%97%E6%B3%95" tabindex="-1">1. 精准的“分档”推荐算法</h3><p>系统不仅仅展示数据，更通过<strong>位次换算模型</strong>将考生的原始分数转换为全省位次，并结合近三年的投档线波动，将院校划分为：</p><ul><li><strong>冲档 (Reach)</strong>: 历史位次略高于考生，值得一试。</li><li><strong>稳档 (Match)</strong>: 位次相近，录取概率大。</li><li><strong>保底 (Safety)</strong>: 位次优势明显，确保不滑档。</li></ul><h3 id="2.-sqlite-%E6%9E%81%E9%80%9F%E9%A9%B1%E5%8A%A8" tabindex="-1">2. SQLite 极速驱动</h3><p>相比传统的云端数据库，系统直接挂载了一个高度优化的 <code>gaokao.db</code>。通过 SQLite 的索引优化，即便在处理数十万条录取数据时，查询响应时间也控制在 <strong>20ms</strong> 以内。</p><h3 id="3.-%E5%A4%9A%E7%BB%B4%E5%BA%A6%E7%AD%9B%E9%80%89%EF%BC%9A%E8%87%AA%E7%94%B1%E5%AE%9A%E4%B9%89%E4%BD%A0%E7%9A%84%E6%9C%AA%E6%9D%A5" tabindex="-1">3. 多维度筛选：自由定义你的未来</h3><p>支持<strong>多省份意向筛选</strong>。你可以通过交互式的多选标签，同时对比北京、上海、江苏等多个地区的院校分布。</p><hr /><h2 id="%F0%9F%8C%90-%E6%9E%81%E8%87%B4%E9%83%A8%E7%BD%B2%EF%BC%9A%E5%A6%82%E4%BD%95%E8%AE%A9%E5%AE%B6%E9%87%8C%E7%9A%84-mac-mini-%E5%8F%98%E6%88%90%E9%AB%98%E6%80%A7%E8%83%BD%E6%9C%8D%E5%8A%A1%E5%99%A8" tabindex="-1">🌐 极致部署：如何让家里的 Mac Mini 变成高性能服务器</h2><p>为了保证数据安全并利用本地的高性能 CPU，我采用了<strong>内网穿透 + 远程代理</strong>的部署方式：</p><ol><li><strong>本地运行</strong>: 在 Mac Mini 上运行 <code>npm run build</code> 生成生产版本，通过 <code>npm run start</code> 持续挂载。</li><li><strong>建立链路</strong>: 使用 <strong>Tailscale</strong> 建立加密通道，将本地服务暴露给 VPS。</li><li><strong>全球发布</strong>: VPS 上的 Nginx 接收 443 端口请求，通过私有链路透传回本地机器。</li></ol><p>这种方案既省去了昂贵的云服务器配置成本，又获得了物理机的超高性能。</p><hr /><h2 id="%E2%9C%A8-%E8%A7%86%E8%A7%89%E7%BE%8E%E5%AD%A6%EF%BC%9A%E9%80%8F%E6%98%8E%E6%84%9F%E4%B8%8E%E7%8E%B0%E4%BB%A3%E6%84%9F%E7%9A%84%E7%BB%93%E5%90%88" tabindex="-1">✨ 视觉美学：透明感与现代感的结合</h2><p>UI 设计采用了流行的 <strong>Glassmorphism (玻璃拟态)</strong> 风格。半透明的磨砂背景配合动态渐变边框，让复杂的表格数据也能呈现出呼吸感。</p><hr /><h2 id="%F0%9F%8E%AF-%E7%AB%8B%E5%8D%B3%E4%BD%93%E9%AA%8C" tabindex="-1">🎯 立即体验</h2><p>项目已在 GitHub 开源：<a href="https://github.com/maifeipin/volunteer-web" target="_blank">maifeipin/volunteer-web</a></p><ol><li><p>首页<br /><img src="/upload/2026/04/image-1777107654604.png" alt="image-1777107654604" /></p></li><li><p>结果页<br /><img src="/upload/2026/04/image-1777107697538.png" alt="image-1777107697538" /></p></li><li><p>学校和专业<br /><img src="/upload/2026/04/image-1777107750488.png" alt="image-1777107750488" /></p></li></ol>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[曲线救国：在 Mac (M系列) 上布署高性能 Docker 远程桌面]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/qu-xian-jiu-guo--zai-macm-xi-lie--shang-bu-shu-gao-xing-neng-docker-yuan-cheng-zhun-mian" />
                <id>tag:https://maifeipin.com,2026-04-20:qu-xian-jiu-guo--zai-macm-xi-lie--shang-bu-shu-gao-xing-neng-docker-yuan-cheng-zhun-mian</id>
                <published>2026-04-20T21:33:47+08:00</published>
                <updated>2026-04-20T21:33:47+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<h2 id="0.-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E6%9C%89%E8%BF%99%E7%AF%87%E6%96%87%E7%AB%A0%EF%BC%9F" tabindex="-1">0. 为什么要有这篇文章？</h2><p><strong>场景与痛点</strong>:<br />很多开发者习惯在任何地方、任何设备远程访问自己的 Mac (依托 Tailscale 网络)。然而，现实很残酷：</p><ul><li><strong>Mac 连 Windows</strong>: 微软官方的 <em>Microsoft Remote Desktop</em> 体验近乎原生，丝滑无比。</li><li><strong>Windows 连 Mac</strong>: 简直是灾难。系统自带的投影或传统的 VNC 协议效率极低、画质模糊、延迟巨大，完全达不到“丝滑”的标准。</li></ul><p>为了在远程也能流畅地操作 Mac 所在网络的环境（比如打开另一个内网的 WEB 服务），我们决定在 Mac 上布署一个基于 Docker 的轻量级 Linux 桌面。通过 KasmVNC 协议，在浏览器里获得<strong>超越传统 VNC 的流畅体验</strong>。</p><hr /><h2 id="1.-%E6%A0%B8%E5%BF%83%E6%96%B9%E6%A1%88%EF%BC%9Aubuntu-webtop" tabindex="-1">1. 核心方案：Ubuntu Webtop</h2><p>我们选择了 <code>linuxserver/webtop:ubuntu-xfce</code>。</p><ul><li><strong>优点</strong>: 基于 Ubuntu 生态，软件安装简单；XFCE 桌面极轻；原生支持 ARM64 (Apple Silicon) 硬件。</li><li><strong>为什么不用 Alpine?</strong>: Alpine 虽小，但缺少 <code>glibc</code> 支持，安装 Chrome 等主流浏览器时兼容性问题极多。</li></ul><h3 id="%E6%9C%80%E7%BB%88%E6%8E%A8%E8%8D%90%E9%85%8D%E7%BD%AE-(docker-compose.yml)" tabindex="-1">最终推荐配置 (<code>docker-compose.yml</code>)</h3><pre><code class="language-yaml">services:  webtop:    image: lscr.io/linuxserver/webtop:ubuntu-xfce    container_name: webtop-final    security_opt:      - seccomp:unconfined # 必须：允许容器执行更高权限的系统调用    environment:      - PUID=1000      - PGID=1000      - TZ=Asia/Shanghai      - PASSWORD=YOUR_PASSWORD # 建议设置复杂密码，用于 Web 登录和 sudo    volumes:      - ./config:/config       # 映射桌面配置和文件    ports:      - 3005:3000              # 建议更换非标准端口，增加安全性    restart: unless-stopped</code></pre><hr /><h2 id="2.-%E6%A0%B8%E5%BF%83%E6%8C%91%E6%88%98%EF%BC%9A%E5%A6%82%E4%BD%95%E5%9C%A8%E5%AE%B9%E5%99%A8%E9%87%8C%E8%A3%85%E5%A5%BD%E6%B5%8F%E8%A7%88%E5%99%A8%EF%BC%9F" tabindex="-1">2. 核心挑战：如何在容器里装好浏览器？</h2><p>在 Ubuntu Docker 镜像中，直接 <code>apt install</code> 往往会装上 <strong>Snap 版</strong>的浏览器，而 Snap 在容器内是跑不起来的。我们需要绕道安装原生的二进制版本。</p><h3 id="%E7%AC%AC%E4%B8%80%E6%AD%A5%EF%BC%9A%E5%AE%89%E8%A3%85-chromium-(%E9%9D%9E-snap-%E7%89%88)" tabindex="-1">第一步：安装 Chromium (非 Snap 版)</h3><p>进入容器终端（或是通过 <code>docker exec</code>）：</p><pre><code class="language-bash">sudo apt update# 添加第三方 PPA 源（提供真正的二进制版 Chromium）sudo apt install -y chromium fonts-wqy-zenhei</code></pre><h3 id="%E7%AC%AC%E4%BA%8C%E6%AD%A5%EF%BC%9A%E7%A1%AC%E8%BF%9E%E6%8E%A5%E4%BF%AE%E5%A4%8D" tabindex="-1">第二步：硬连接修复</h3><p>由于系统可能依然残留 Snap 的指向，我们需要手动强行纠正路径：</p><pre><code class="language-bash">sudo ln -sf /usr/lib/chromium/chromium /usr/bin/chromium</code></pre><p>现在，您在终端输入 <code>chromium</code> 或点击图标，就能看到秒开的浏览器了。</p><hr /><h2 id="3.-%E8%BF%9B%E9%98%B6%EF%BC%9A%E5%88%9B%E5%BB%BA%E6%A1%8C%E9%9D%A2%E4%B8%80%E9%94%AE%E5%90%AF%E5%8A%A8" tabindex="-1">3. 进阶：创建桌面一键启动</h2><p>为了像真正的 Windows 桌面一样好用，我们给 Chromium 创建一个快捷方式。</p><p>在桌面 <code>/config/Desktop/</code> 创建 <code>Chromium.desktop</code>：</p><pre><code class="language-ini">[Desktop Entry]Version=1.0Type=ApplicationName=ChromiumExec=chromium --no-sandboxIcon=chromiumTerminal=false</code></pre><p>赋予权限：<code>chmod +x ~/Desktop/Chromium.desktop</code>。</p><hr /><h2 id="4.-%E6%9E%81%E8%87%B4%E5%AE%89%E5%85%A8%E4%B8%8E%E4%BE%BF%E6%8D%B7%EF%BC%9A%E5%85%AC%E7%BD%91-ssl-%E8%AE%BF%E9%97%AE" tabindex="-1">4. 极致安全与便捷：公网 SSL 访问</h2><p>为了彻底解决浏览器的 HTTPS 强制跳转问题，建议在公网 VPS 上用 Nginx 做一层反代。</p><h3 id="%E7%94%B3%E8%AF%B7%E8%AF%81%E4%B9%A6-(%E4%BB%A5%E8%85%BE%E8%AE%AF%E4%BA%91-dns-%E9%AA%8C%E8%AF%81%E4%B8%BA%E4%BE%8B)" tabindex="-1">申请证书 (以腾讯云 DNS 验证为例)</h3><pre><code class="language-bash">certbot certonly -a dns-tencentcloud \--dns-tencentcloud-credentials /etc/letsencrypt/tencentcloud.ini \-d &quot;wt.yourdomain.com&quot; \--non-interactive --agree-tos</code></pre><h3 id="nginx-%E5%8F%8D%E4%BB%A3%E9%85%8D%E7%BD%AE-(%E5%BF%85%E9%A1%BB%E5%8C%85%E5%90%AB-websocket-%E5%8D%87%E7%BA%A7)" tabindex="-1">Nginx 反代配置 (必须包含 WebSocket 升级)</h3><pre><code class="language-nginx">server {    listen 443 ssl;    server_name wt.yourdomain.com;    ssl_certificate /etc/letsencrypt/live/wt.yourdomain.com/fullchain.pem;    ssl_certificate_key /etc/letsencrypt/live/wt.yourdomain.com/privkey.pem;    location / {        proxy_pass http://100.x.y.z:3005; # Mac 的 Tailscale IP                # 画面传输的核心：WebSocket 支持        proxy_http_version 1.1;        proxy_set_header Upgrade $http_upgrade;        proxy_set_header Connection &quot;upgrade&quot;;                proxy_set_header Host $host;        proxy_set_header X-Real-IP $remote_addr;        proxy_read_timeout 86400;    }}</code></pre><hr /><h2 id="%E6%80%BB%E7%BB%93" tabindex="-1">总结</h2><p>通过这套“Mac + Docker + Tailscale + VPS反代”的组合拳，我们成功解决了 Windows 远程控制 Mac 体验不佳的世纪难题。您现在拥有了一个位于 Mac 网络内部、支持丝滑网页浏览、且能从全网加密访问的高性能 Linux 桌面。</p><hr /><p><strong>Author</strong>: Antigravity AI &amp; USER<br /><strong>Date</strong>: 2026-04-20</p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[多 Agent 编排全流程调试总结]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/duo-agent-bian-pai-quan-liu-cheng-diao-shi-zong-jie" />
                <id>tag:https://maifeipin.com,2026-03-29:duo-agent-bian-pai-quan-liu-cheng-diao-shi-zong-jie</id>
                <published>2026-03-29T17:47:59+08:00</published>
                <updated>2026-03-29T17:47:59+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<h2 id="%E4%B8%80%E3%80%81%E8%83%8C%E6%99%AF" tabindex="-1">一、背景</h2><p>在此次调试开始前，系统已预置了一套基于文件队列的异步 Agent 编排骨架，目录为：</p><pre><code class="language-">~/.openclaw/workspace/memory/orchestration/├── pending/      ← main 写入待分配任务├── running/      ← specialist 认领标记├── results/      ← specialist 写入执行结果├── completed/    ← specialist 写完成标记├── watching/     ← main 监听标记└── archive/      ← 归档</code></pre><p>脚本骨架位于 <code>~/.openclaw/scripts/</code>，协议文档写在 <code>workspace/AGENTS.md</code>。</p><hr /><h2 id="%E4%BA%8C%E3%80%81phase-1-%E2%80%94-%E5%B7%A5%E4%BD%9C%E6%B5%81%E6%A1%86%E6%9E%B6%E5%AE%9A%E4%B9%89-%2B-%E5%88%9D%E6%AD%A5%E4%BB%BF%E7%9C%9F" tabindex="-1">二、Phase 1 — 工作流框架定义 + 初步仿真</h2><h3 id="%E7%94%A8%E6%88%B7%E9%9C%80%E6%B1%82" tabindex="-1">用户需求</h3><blockquote><p>“WEB main 聊天框输入：各位开始干活了。→ 各 agent 推 ‘我开始工作了’ 到手机端 → agent 把调用的工具/技能汇报给 main → main 汇总并把评语推到各自机器人客户端。”</p></blockquote><h3 id="%E5%AE%9E%E7%8E%B0%E6%AD%A5%E9%AA%A4" tabindex="-1">实现步骤</h3><ol><li><p><strong>设计事件链</strong>（Python 仿真）：</p><ul><li><code>web_input_received</code> → <code>task_assigned × N</code> → <code>push_to_user(开工)</code> → <code>task_received</code> → <code>tool_skill_called</code> → <code>task_result_reported</code> → <code>main_summary_ready</code> → <code>push_review_to_agent_client</code></li></ul></li><li><p><strong>运行仿真</strong>，产物：</p><ul><li><code>archive/2026-03-29/web-main-flow-20260329-170913/workflow-test-result.json</code></li><li>涵盖 research、mail、heartbeat 三个 specialist 的完整事件链</li></ul></li></ol><h3 id="%E4%BB%BF%E7%9C%9F%E7%BB%93%E6%9E%9C%E6%91%98%E8%A6%81" tabindex="-1">仿真结果摘要</h3><pre><code class="language-json">{  &quot;runId&quot;: &quot;web-main-flow-20260329-170913&quot;,  &quot;webInput&quot;: { &quot;message&quot;: &quot;各位开始开活了。&quot;, &quot;source&quot;: &quot;web-main-chat&quot; },  &quot;agents&quot;: [&quot;research&quot;, &quot;mail&quot;, &quot;heartbeat&quot;],  &quot;eventsCount&quot;: 18}</code></pre><hr /><h2 id="%E4%B8%89%E3%80%81phase-2-%E2%80%94-mail-%E5%B7%A5%E5%85%B7%E5%BC%82%E5%B8%B8-%2B-%E9%98%B6%E6%AE%B5%E8%AF%8A%E6%96%AD%E8%83%BD%E5%8A%9B" tabindex="-1">三、Phase 2 — Mail 工具异常 + 阶段诊断能力</h2><h3 id="%E7%94%A8%E6%88%B7%E9%9C%80%E6%B1%82-1" tabindex="-1">用户需求</h3><blockquote><p>“Mail 配置好了但不能用工具，main 应有评估工具在哪个阶段异常的能力，并在总结时反馈到各自机器人。”</p></blockquote><h3 id="%E5%AE%9E%E7%8E%B0%E6%AD%A5%E9%AA%A4-1" tabindex="-1">实现步骤</h3><h4 id="1.-%E5%88%9B%E5%BB%BA%E9%98%B6%E6%AE%B5%E8%AF%8A%E6%96%AD%E8%84%9A%E6%9C%AC" tabindex="-1">1. 创建阶段诊断脚本</h4><p><strong>文件</strong>：<code>~/.openclaw/scripts/evaluate-orchestration-stage.sh</code></p><p>核心逻辑（Python 内嵌）：</p><ul><li>读取 <code>results/{PREFIX}-*_result.json</code></li><li>关键字匹配 → 映射到阶段标签：</li></ul><table><thead><tr><th>错误关键词</th><th>阶段标签</th><th>诊断文案</th></tr></thead><tbody><tr><td><code>tool_not_available</code> / <code>command not found</code> / <code>cannot invoke tool</code></td><td><code>tool_invoke</code></td><td>工具调用阶段异常</td></tr><tr><td><code>timeout</code> / <code>imap</code></td><td><code>provider_connect</code></td><td>外部服务连接阶段异常</td></tr><tr><td>其他</td><td><code>execution</code></td><td>执行阶段异常</td></tr></tbody></table><ul><li>生成 <code>results/{PREFIX}_main_evaluation.json</code>，包含：<ul><li><code>stageFailures[]</code> — 哪个 agent 在哪个阶段失败</li><li><code>pushFeedback[]</code> — 对每个 agent 的点评消息</li></ul></li></ul><h4 id="2.-%E6%9B%B4%E6%96%B0-test-scenario-3.sh" tabindex="-1">2. 更新 <a href="http://test-scenario-3.sh" target="_blank">test-scenario-3.sh</a></h4><p>Mail 的失败模拟从 IMAP timeout 改为 <code>tool_not_available: himalaya-cli cannot invoke tool backend</code> + <code>command_not_found</code>，更真实反映工具绑定缺失场景。在 Step 2.5 插入 evaluator 调用：</p><pre><code class="language-bash">bash &quot;$HOME/.openclaw/scripts/evaluate-orchestration-stage.sh&quot; TEST-3-FAILURE-HANDLING</code></pre><blockquote><p><strong>坑</strong>：直接执行脚本报 Permission denied（未 chmod +x），改为 <code>bash &lt;path&gt;</code> 后解决。</p></blockquote><h4 id="3.-%E6%9B%B4%E6%96%B0-agents.md-%E5%8D%8F%E8%AE%AE" tabindex="-1">3. 更新 <a href="http://AGENTS.md" target="_blank">AGENTS.md</a> 协议</h4><p>在 <code>workspace/AGENTS.md</code> 的 “Result Collection &amp; Aggregation” 之后，新增 <strong>Stage Evaluation &amp; Feedback Push</strong> 节，规定 4 步：</p><ol><li>将失败结果分类到阶段标签</li><li>写入 <code>{PREFIX}_main_evaluation.json</code></li><li>为每个 agent 生成推送消息</li><li>最终汇总时附带阶段诊断</li></ol><h4 id="%E6%B5%8B%E8%AF%95%E7%BB%93%E6%9E%9C" tabindex="-1">测试结果</h4><pre><code class="language-json">{  &quot;stageFailures&quot;: [{&quot;agent&quot;:&quot;mail&quot;,&quot;stage&quot;:&quot;tool_invoke&quot;,&quot;diagnosis&quot;:&quot;工具调用阶段异常&quot;}],  &quot;pushFeedback&quot;: [    {&quot;agent&quot;:&quot;heartbeat&quot;,&quot;status&quot;:&quot;ok&quot;},    {&quot;agent&quot;:&quot;mail&quot;,&quot;status&quot;:&quot;degraded&quot;,&quot;message&quot;:&quot;...工具调用阶段异常...请检查工具安装/配置权限...&quot;},    {&quot;agent&quot;:&quot;research&quot;,&quot;status&quot;:&quot;ok&quot;}  ],  &quot;summary&quot;: {&quot;total&quot;:3,&quot;success&quot;:2,&quot;failed&quot;:1,&quot;health&quot;:&quot;degraded&quot;}}</code></pre><hr /><h2 id="%E5%9B%9B%E3%80%81phase-3-%E2%80%94-%E5%85%A8%E6%B5%81%E7%A8%8B%E9%87%8D%E6%B5%8B%EF%BC%88%E5%90%AB%E7%94%A8%E6%88%B7%E5%8F%AF%E6%84%9F%E7%9F%A5%E7%8A%B6%E6%80%81%E6%8E%A8%E9%80%81%EF%BC%89" tabindex="-1">四、Phase 3 — 全流程重测（含用户可感知状态推送）</h2><h3 id="%E7%94%A8%E6%88%B7%E9%9C%80%E6%B1%82-2" tabindex="-1">用户需求</h3><blockquote><p>“开始对全流程编排任务重新测试。从分配工作，到接收，进行的工作，并结果反馈。通过推送让用户可感知各个 agent 的工作状态。”</p></blockquote><h3 id="%E4%BA%A7%E7%89%A9" tabindex="-1">产物</h3><p><strong><code>archive/2026-03-29/full-flow-20260329-091640/status-stream.jsonl</code></strong>：31 个事件，包含完整状态流：</p><pre><code class="language-">web_input_received  └─ task_assigned × 3      └─ push_to_user(开始执行) × 3          └─ task_received × 3              └─ tool_skill_called × 3                  └─ push_to_user(执行进度) × 3                      └─ task_result_reported × 3                          └─ main_summary_ready                              └─ push_to_user(汇总)                                  └─ push_review_to_agent_client × 3</code></pre><p><strong><code>archive/2026-03-29/full-flow-20260329-091640/final-summary.json</code></strong>：</p><pre><code class="language-json">{  &quot;totals&quot;: {&quot;assigned&quot;:3,&quot;received&quot;:3,&quot;success&quot;:2,&quot;failed&quot;:1},  &quot;mainSummary&quot;: &quot;本轮全流程编排已完成：research、heartbeat 成功；mail 在 tool_invoke 阶段异常。&quot;,  &quot;stageFailures&quot;: [{&quot;agent&quot;:&quot;mail&quot;,&quot;stage&quot;:&quot;tool_invoke&quot;}]}</code></pre><hr /><h2 id="%E4%BA%94%E3%80%81phase-4-%E2%80%94-%E7%9C%9F%E5%AE%9E%E6%B8%A0%E9%81%93%E4%B8%89%E9%98%B6%E6%AE%B5%E6%8E%A8%E9%80%81%E6%89%93%E9%80%9A" tabindex="-1">五、Phase 4 — 真实渠道三阶段推送打通</h2><h3 id="%E7%94%A8%E6%88%B7%E9%9C%80%E6%B1%82-3" tabindex="-1">用户需求</h3><blockquote><p>“开始吧，我在WEB端输入：各位开始干活了。手机不同的IM客户端就应能推送工作状态，直到工作结束。”</p></blockquote><h3 id="%E6%8E%A8%E9%80%81%E6%B8%A0%E9%81%93%E9%85%8D%E7%BD%AE" tabindex="-1">推送渠道配置</h3><table><thead><tr><th>渠道</th><th>reply_channel</th><th>reply_to</th></tr></thead><tbody><tr><td>飞书</td><td><code>feishu</code></td><td><code>ou_7xxxxxxxxxxxx5d4</code></td></tr><tr><td>QQ 频道机器人</td><td><code>qqbot</code></td><td><code>4xxxxxxxxxxxxxxxxx7</code></td></tr><tr><td>微信</td><td><code>openclaw-weixin</code></td><td><code>o9xxxxxxxxxxxxyo@im.wechat</code></td></tr></tbody></table><h3 id="%E6%8E%A8%E9%80%81%E5%91%BD%E4%BB%A4%E6%A8%A1%E6%9D%BF" tabindex="-1">推送命令模板</h3><pre><code class="language-bash">openclaw agent --agent main \  --message &quot;&lt;内容&gt;&quot; \  --deliver \  --reply-channel &lt;channel&gt; \  --reply-to &lt;target&gt; \  --thinking off --timeout 45 --json</code></pre><h3 id="%E4%B8%89%E9%98%B6%E6%AE%B5%E6%8E%A8%E9%80%81%E7%BB%93%E6%9E%9C" tabindex="-1">三阶段推送结果</h3><table><thead><tr><th>阶段</th><th>飞书</th><th>QQBot</th><th>微信</th></tr></thead><tbody><tr><td>开工（各位开始干活了）</td><td>✅ exit=0</td><td>✅ exit=0</td><td>✅ exit=0</td></tr><tr><td>执行中（进度汇报）</td><td>✅ exit=0</td><td>✅ exit=0</td><td>✅ exit=0</td></tr><tr><td>已结束（汇总结果 JSON）</td><td>✅ exit=0</td><td>✅ exit=0</td><td>✅ exit=0</td></tr></tbody></table><hr /><h2 id="%E5%85%AD%E3%80%81%E8%B0%83%E8%AF%95%E8%BF%87%E7%A8%8B%E5%85%B3%E9%94%AE%E9%97%AE%E9%A2%98%E4%B8%8E%E8%A7%A3%E5%86%B3" tabindex="-1">六、调试过程关键问题与解决</h2><h3 id="%E9%97%AE%E9%A2%98-1%EF%BC%9A%E8%84%9A%E6%9C%AC-permission-denied" tabindex="-1">问题 1：脚本 Permission Denied</h3><p><strong>现象</strong>：<code>~/.openclaw/scripts/evaluate-orchestration-stage.sh</code> 直接调用时报 <code>Permission denied</code><br /><strong>原因</strong>：脚本未执行 <code>chmod +x</code><br /><strong>解决</strong>：调用方式改为 <code>bash &quot;$HOME/.../evaluate-orchestration-stage.sh&quot;</code> — 无需执行位</p><hr /><h3 id="%E9%97%AE%E9%A2%98-2%EF%BC%9Apython-snippet-%E6%89%B9%E9%87%8F%E8%B0%83%E7%94%A8%E8%A2%AB-cancelled" tabindex="-1">问题 2：Python snippet 批量调用被 Cancelled</h3><p><strong>现象</strong>：单个 Python 代码片段中串行发起 3 个以上 <code>subprocess.run()</code> 时，第 3 个及之后的调用被 <code>request cancelled</code><br /><strong>原因</strong>：Python runner 的执行上下文对长时间阻塞的批量调用存在取消限制<br /><strong>解决</strong>：每次只做一个渠道的 delivery 调用，拆成独立 snippet 执行</p><hr /><h3 id="%E9%97%AE%E9%A2%98-3%EF%BC%9Aqqbot-%2F-%E5%BE%AE%E4%BF%A1%22%E5%B7%B2%E7%BB%93%E6%9D%9F%22%E9%98%B6%E6%AE%B5%E8%B6%85%E6%97%B6%EF%BC%88exit%3D124%EF%BC%89" tabindex="-1">问题 3：QQBot / 微信&quot;已结束&quot;阶段超时（exit=124）</h3><p><strong>现象</strong>：<code>subprocess.run(..., timeout=25)</code> 超时退出<br /><strong>根因分析</strong>：</p><ul><li>网关日志（<code>/tmp/openclaw/openclaw-2026-03-29.log</code>）确认 <code>sessions_send</code> 已被调用</li><li><code>[qqbot-api] &gt;&gt;&gt; Body: {...}</code> 已记录，说明推送到了渠道层</li><li>超时是因为 QQBot/WeChat 的 delivery ACK 返回时间 &gt; 25s，不是路由失败</li></ul><p><strong>解决</strong>：subprocess timeout 从 25s 提高到 55s，全部成功</p><hr /><h3 id="%E9%97%AE%E9%A2%98-4%EF%BC%9A%E6%B6%88%E6%81%AF%E6%A0%BC%E5%BC%8F%E9%80%89%E6%8B%A9" tabindex="-1">问题 4：消息格式选择</h3><p><strong>背景</strong>：用户要求 QQBot、微信推送内容&quot;简单点，原样输出 JSON，只是一个提醒&quot;<br /><strong>最终格式</strong>：原始 JSON 字符串，不加 emoji 或自然语言包装：</p><pre><code class="language-json">{&quot;status&quot;:&quot;已结束&quot;,&quot;runId&quot;:&quot;live-finish-20260329-xxxxxx&quot;,&quot;summary&quot;:{&quot;research&quot;:&quot;ok&quot;,&quot;heartbeat&quot;:&quot;ok&quot;,&quot;mail&quot;:&quot;tool_invoke_error&quot;}}</code></pre><hr /><h2 id="%E4%B8%83%E3%80%81%E6%9C%80%E7%BB%88%E6%96%87%E4%BB%B6%E6%B8%85%E5%8D%95" tabindex="-1">七、最终文件清单</h2><h3 id="%E6%A0%B8%E5%BF%83%E8%84%9A%E6%9C%AC%EF%BC%88~%2F.openclaw%2Fscripts%2F%EF%BC%89" tabindex="-1">核心脚本（<code>~/.openclaw/scripts/</code>）</h3><table><thead><tr><th>文件</th><th>用途</th></tr></thead><tbody><tr><td><code>init-orchestration.sh</code></td><td>初始化 orchestration 目录结构</td></tr><tr><td><code>orchestration-test.sh</code></td><td>单次编排测试入口</td></tr><tr><td><code>run-all-tests.sh</code></td><td>运行全部场景测试</td></tr><tr><td><code>test-scenario-1.sh</code></td><td>场景1：正常全流程</td></tr><tr><td><code>test-scenario-2.sh</code></td><td>场景2：超时/部分失败</td></tr><tr><td><code>test-scenario-3.sh</code></td><td>场景3：工具不可用（mail 故障）</td></tr><tr><td><code>evaluate-orchestration-stage.sh</code></td><td>阶段异常诊断 + per-agent 推送反馈生成</td></tr></tbody></table><h3 id="%E5%8D%8F%E8%AE%AE%E6%96%87%E6%A1%A3" tabindex="-1">协议文档</h3><table><thead><tr><th>文件</th><th>关键内容</th></tr></thead><tbody><tr><td><code>workspace/AGENTS.md</code></td><td>完整 Async Agent-to-Agent 协议、任务生命周期、Stage Evaluation 节</td></tr></tbody></table><h3 id="%E8%BF%90%E8%A1%8C%E5%AD%98%E6%A1%A3%EF%BC%88workspace%2Fmemory%2Forchestration%2Farchive%2F2026-03-29%2F%EF%BC%89" tabindex="-1">运行存档（<code>workspace/memory/orchestration/archive/2026-03-29/</code>）</h3><table><thead><tr><th>目录</th><th>内容</th></tr></thead><tbody><tr><td><code>web-main-flow-20260329-170913/</code></td><td>Phase 1 工作流仿真结果</td></tr><tr><td><code>full-flow-20260329-091640/</code></td><td>Phase 3 全流程31事件状态流 + 最终汇总</td></tr><tr><td><code>live-push-20260329-172112/</code></td><td>真实开工阶段3渠道推送记录</td></tr><tr><td><code>live-flow-20260329-172245/</code></td><td>真实执行中+结束阶段6次推送记录</td></tr><tr><td><code>live-flow-finish-20260329-172505/</code></td><td>已结束阶段3渠道确认记录（最终确认版）</td></tr></tbody></table><hr /><h2 id="%E5%85%AB%E3%80%81%E4%BB%BB%E5%8A%A1%E7%8A%B6%E6%80%81%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%EF%BC%88%E6%9C%80%E7%BB%88%E7%A1%AE%E8%AE%A4%EF%BC%89" tabindex="-1">八、任务状态生命周期（最终确认）</h2><pre><code class="language-">pending/{specialist}_{taskId}.json    ↓ [specialist reads &amp; claims]running/{taskId}          ← 认领标记    ↓ [specialist executes]results/{taskId}_result.json    ↓ [specialist reports completion]completed/{taskId}        ← 完成标记    ↓ [main evaluates stages]results/{PREFIX}_main_evaluation.json    ↓ [main pushes feedback to each channel]archive/{YYYY-MM-DD}/{runId}/</code></pre><hr /><h2 id="%E4%B9%9D%E3%80%81%E5%85%B3%E9%94%AE%E7%BB%93%E8%AE%BA" tabindex="-1">九、关键结论</h2><ol><li><p><strong>文件队列协议可靠</strong>：基于文件系统的 pending→running→results→completed 状态机在所有测试场景均正确运行。</p></li><li><p><strong>阶段诊断有效</strong>：<code>evaluate-orchestration-stage.sh</code> 能准确区分 <code>tool_invoke</code> vs <code>provider_connect</code> vs <code>execution</code> 三类失败，并生成可直接推送的 per-agent 反馈。</p></li><li><p><strong>三渠道全部打通</strong>：飞书、QQBot、微信在开工/执行中/已结束三阶段均成功接收 JSON 推送（exit=0），用户手机可全程感知 agent 工作状态。</p></li><li><p><strong>推送超时根因明确</strong>：QQBot/微信 delivery ACK 慢（&gt;25s），非路由故障，subprocess timeout ≥ 55s 可稳定解决。</p></li><li><p><strong>消息格式保持原始 JSON</strong>：推送内容定位为&quot;提醒&quot;，具体执行结果取决于 agent 本地工具/技能定义，不在推送消息中展开。</p></li></ol>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[OpenClaw 从单主控到 1+4 多 Agent 的实战改造复盘]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/openclaw-cong-dan-zhu-kong-dao-14-duo-agent-de-shi-zhan-gai-zao-fu-pan" />
                <id>tag:https://maifeipin.com,2026-03-29:openclaw-cong-dan-zhu-kong-dao-14-duo-agent-de-shi-zhan-gai-zao-fu-pan</id>
                <published>2026-03-29T12:01:31+08:00</published>
                <updated>2026-03-29T12:01:31+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<h2 id="%E8%83%8C%E6%99%AF" tabindex="-1">背景</h2><p>当前 OpenClaw 长期采用单主控 Agent（main）承接几乎所有任务，问题主要有三类：</p><ol><li>职责耦合：RSS、研究、邮件、监控都压在 main，维护成本持续上升。</li><li>风险集中：某一类任务异常可能影响整条链路。</li><li>可扩展性弱：新增能力时容易改动主流程，回归成本高。</li></ol><p>本次目标是在不引入源码管理的前提下，完成一次可回滚的多 Agent 重构。</p><hr /><h2 id="%E7%9B%AE%E6%A0%87%E6%9E%B6%E6%9E%84" tabindex="-1">目标架构</h2><p>采用 1 总控 + 4 专家的中枢编排：</p><ul><li>main：总控编排、对外回复、路由与聚合</li><li>rss：RSS 抓取、归档、摘要</li><li>research：信息检索与交叉验证</li><li>mail：邮件轮询、分类与上报</li><li>heartbeat：健康巡检与告警</li></ul><h3 id="%E6%8B%93%E6%89%91%E5%9B%BE" tabindex="-1">拓扑图</h3><div class="mermaid"><svg id="render2489989653" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="302" style="max-width: 717.828125px;" viewBox="0 0 717.828125 302"><style>#render2489989653 {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#render2489989653 .error-icon{fill:#552222;}#render2489989653 .error-text{fill:#552222;stroke:#552222;}#render2489989653 .edge-thickness-normal{stroke-width:2px;}#render2489989653 .edge-thickness-thick{stroke-width:3.5px;}#render2489989653 .edge-pattern-solid{stroke-dasharray:0;}#render2489989653 .edge-pattern-dashed{stroke-dasharray:3;}#render2489989653 .edge-pattern-dotted{stroke-dasharray:2;}#render2489989653 .marker{fill:#333333;stroke:#333333;}#render2489989653 .marker.cross{stroke:#333333;}#render2489989653 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#render2489989653 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#render2489989653 .cluster-label text{fill:#333;}#render2489989653 .cluster-label span{color:#333;}#render2489989653 .label text,#render2489989653 span{fill:#333;color:#333;}#render2489989653 .node rect,#render2489989653 .node circle,#render2489989653 .node ellipse,#render2489989653 .node polygon,#render2489989653 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#render2489989653 .node .label{text-align:center;}#render2489989653 .node.clickable{cursor:pointer;}#render2489989653 .arrowheadPath{fill:#333333;}#render2489989653 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#render2489989653 .flowchart-link{stroke:#333333;fill:none;}#render2489989653 .edgeLabel{background-color:#e8e8e8;text-align:center;}#render2489989653 .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#render2489989653 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#render2489989653 .cluster text{fill:#333;}#render2489989653 .cluster span{color:#333;}#render2489989653 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#render2489989653 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g transform="translate(0, 0)"><marker id="flowchart-pointEnd" class="marker flowchart" viewBox="0 0 10 10" refX="9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="flowchart-pointStart" class="marker flowchart" viewBox="0 0 10 10" refX="0" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></path></marker><marker id="flowchart-circleEnd" class="marker flowchart" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="flowchart-circleStart" class="marker flowchart" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"></circle></marker><marker id="flowchart-crossEnd" class="marker cross flowchart" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><marker id="flowchart-crossStart" class="marker cross flowchart" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"></path></marker><g class="root"><g class="clusters"></g><g class="edgePaths"><path d="M308.453125,151L312.6197916666667,151C316.7864583333333,151,325.1197916666667,151,333.453125,151C341.7864583333333,151,350.1197916666667,151,354.2864583333333,151L358.453125,151" id="L-CH-MAIN-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-CH LE-MAIN" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M442.3583984375,134L456.4757486979167,114.16666666666667C470.5930989583333,94.33333333333333,498.8277994791667,54.666666666666664,521.41650390625,35.64880549497381C544.0052083333334,16.63094432328094,560.9479166666666,18.261888646561882,569.4192708333334,19.077360808202354L577.890625,19.89283296984282" id="L-MAIN-RSS-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-MAIN LE-RSS" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M452.49672719594594,134L464.9243559966216,124.5C477.3519847972973,115,502.2072423986486,96,522.7507159258868,89C543.294189453125,82,559.52587890625,87,567.6417236328125,89.5L575.757568359375,92" id="L-MAIN-RES-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-MAIN LE-RES" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M502.0625,158.41748042934387L506.2291666666667,158.84790035778656C510.3958333333333,159.27832028622925,518.7291666666666,160.13916014311462,531.0116780598959,163.0695800715573C543.294189453125,166,559.52587890625,171,567.6417236328125,173.5L575.757568359375,176" id="L-MAIN-MAIL-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-MAIN LE-MAIL" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M447.7650432180851,168L460.98128601507096,180.83333333333334C474.19752881205676,193.66666666666666,500.63001440602835,219.33333333333334,521.9621019295768,234.66666666666666C543.294189453125,250,559.52587890625,255,567.6417236328125,257.5L575.757568359375,260" id="L-MAIN-HB-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-MAIN LE-HB" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M577.890625,41.34293449650297L569.4192708333334,43.95244541375248C560.9479166666666,46.56195633100199,544.0052083333334,51.78097816550099,522.3176113696809,67.22382241608382C500.63001440602835,82.66666666666667,474.19752881205676,108.33333333333333,460.98128601507096,121.16666666666667L447.7650432180851,134" id="L-RSS-MAIN-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-RSS LE-MAIN" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M575.757568359375,126L567.6417236328125,128.5C559.52587890625,131,543.294189453125,136,531.0116780598959,138.9304199284427C518.7291666666666,141.86083985688538,510.3958333333333,142.72167971377075,506.2291666666667,143.15209964221344L502.0625,143.58251957065613" id="L-RES-MAIN-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-RES LE-MAIN" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M575.757568359375,210L567.6417236328125,212.5C559.52587890625,215,543.294189453125,220,522.7507159258868,213C502.2072423986486,206,477.3519847972973,187,464.9243559966216,177.5L452.49672719594594,168" id="L-MAIL-MAIN-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-MAIL LE-MAIN" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path><path d="M552.0625,284.5934421298037L547.8958333333334,284.9945351081697C543.7291666666666,285.3956280865358,535.3958333333334,286.1978140432679,517.11181640625,266.7655736883006C498.8277994791667,247.33333333333334,470.5930989583333,207.66666666666666,456.4757486979167,187.83333333333334L442.3583984375,168" id="L-HB-MAIN-0" class=" edge-thickness-normal edge-pattern-solid flowchart-link LS-HB LE-MAIN" style="fill:none;" marker-end="url(#flowchart-pointEnd)"></path></g><g class="edgeLabels"><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g><g class="edgeLabel"><g class="label" transform="translate(0, 0)"><rect rx="0" ry="0" width="0" height="0"></rect><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row"></tspan></text></g></g></g><g class="nodes"><g class="node default default" id="flowchart-CH-18" transform="translate(158.2265625, 151)"><rect class="basic label-container" style="" rx="0" ry="0" x="-150.2265625" y="-17" width="300.453125" height="34"></rect><g class="label" style="" transform="translate(-142.7265625, -9.5)"><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row">Channels: Telegram/Feishu/QQ/WeCom</tspan></text></g></g><g class="node default default" id="flowchart-MAIN-19" transform="translate(430.2578125, 151)"><rect class="basic label-container" style="" rx="0" ry="0" x="-71.8046875" y="-17" width="143.609375" height="34"></rect><g class="label" style="" transform="translate(-64.3046875, -9.5)"><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row">main orchestrator</tspan></text></g></g><g class="node default default" id="flowchart-RSS-21" transform="translate(630.9453125, 25)"><rect class="basic label-container" style="" rx="0" ry="0" x="-53.0546875" y="-17" width="106.109375" height="34"></rect><g class="label" style="" transform="translate(-45.5546875, -9.5)"><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row">rss specialist</tspan></text></g></g><g class="node default default" id="flowchart-RES-23" transform="translate(630.9453125, 109)"><rect class="basic label-container" style="" rx="0" ry="0" x="-74.1875" y="-17" width="148.375" height="34"></rect><g class="label" style="" transform="translate(-66.6875, -9.5)"><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row">research specialist</tspan></text></g></g><g class="node default default" id="flowchart-MAIL-25" transform="translate(630.9453125, 193)"><rect class="basic label-container" style="" rx="0" ry="0" x="-58.953125" y="-17" width="117.90625" height="34"></rect><g class="label" style="" transform="translate(-51.453125, -9.5)"><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row">mail specialist</tspan></text></g></g><g class="node default default" id="flowchart-HB-27" transform="translate(630.9453125, 277)"><rect class="basic label-container" style="" rx="0" ry="0" x="-78.8828125" y="-17" width="157.765625" height="34"></rect><g class="label" style="" transform="translate(-71.3828125, -9.5)"><text style=""><tspan xml:space="preserve" dy="1em" x="0" class="row">heartbeat specialist</tspan></text></g></g></g></g></g></svg></div><p>设计原则：外部入口统一，内部按职责分治。</p><hr /><h2 id="%E5%AE%9E%E6%96%BD%E6%91%98%E8%A6%81" tabindex="-1">实施摘要</h2><h3 id="phase-0%EF%BC%9A%E4%B8%89%E5%B1%82%E5%A4%87%E4%BB%BD%EF%BC%88%E5%BC%BA%E5%88%B6%EF%BC%89" tabindex="-1">Phase 0：三层备份（强制）</h3><p>在改动前做了三层备份，并做了解压演练：</p><ol><li>配置层：openclaw.json、cron/jobs.json、exec-approvals.json</li><li>工作空间层：workspace、workspace-rssmanager、agents/*/agent</li><li>运行态层：sessions、cron/runs、logs</li></ol><p>结论：备份可用，支持一键回滚。</p><h3 id="phase-a%EF%BC%9A%E5%86%BB%E7%BB%93%E4%B8%8E%E5%9F%BA%E7%BA%BF%E9%87%87%E9%9B%86" tabindex="-1">Phase A：冻结与基线采集</h3><ul><li>记录改造前 agent 清单</li><li>记录现有 jobs 归属</li><li>确认旧网关状态</li></ul><h3 id="phase-c%EF%BC%9A%E5%88%9B%E5%BB%BA%E6%96%B0-agent-%E4%B8%8E-core-files-%E6%A0%87%E5%87%86%E5%8C%96" tabindex="-1">Phase C：创建新 Agent 与 Core Files 标准化</h3><p>新增 Agent：research、mail、heartbeat。<br />关键动作：统一补齐每个 Agent 的 7 个 Core Files。</p><p>Core Files 标准：</p><ol><li><a href="http://SOUL.md" target="_blank">SOUL.md</a></li><li><a href="http://IDENTITY.md" target="_blank">IDENTITY.md</a></li><li><a href="http://USER.md" target="_blank">USER.md</a></li><li><a href="http://AGENTS.md" target="_blank">AGENTS.md</a></li><li><a href="http://TOOLS.md" target="_blank">TOOLS.md</a></li><li><a href="http://MEMORY.md" target="_blank">MEMORY.md</a></li><li><a href="http://HEARTBEAT.md" target="_blank">HEARTBEAT.md</a></li></ol><p>同时保证每个 workspace 都有 memory/YYYY-MM-DD.md。</p><blockquote><p>实际经验：OpenClaw Web 控制台对 Core Files 的可见性很强，文件是否齐全直接影响后续运维体验。</p></blockquote><h3 id="phase-d%EF%BC%9A%E6%A8%A1%E5%9E%8B%E5%88%86%E5%B1%82" tabindex="-1">Phase D：模型分层</h3><p>最终落地策略采用“可用优先”，避免上线失败：</p><ul><li>main -&gt; 当前稳定主模型</li><li>rss -&gt; gemini-3.1-pro-preview</li><li>research -&gt; gpt-5-mini</li><li>mail -&gt; gpt-5-mini</li><li>heartbeat -&gt; qwen-plus</li></ul><p>说明：原计划中的部分模型在当前环境不可确认可用，因此优先使用可验证模型。</p><h3 id="phase-e%EF%BC%9A%E6%9D%83%E9%99%90%E5%88%86%E5%B1%82" tabindex="-1">Phase E：权限分层</h3><p>对 exec-approvals.json 做了最小权限划分：</p><ul><li>main 拥有 channels.send、cron.trigger、agents.invoke</li><li>specialists 默认不直接对外发送</li><li>heartbeat 允许有限通道告警</li></ul><h3 id="phase-f%EF%BC%9A%E4%BB%BB%E5%8A%A1%E9%87%8D%E6%9E%84" tabindex="-1">Phase F：任务重构</h3><ul><li>原 4 条 RSS 任务从 main 迁移到 rss</li><li>新增两条任务模板：<ul><li>Heartbeat Health Check (every 5m)</li><li>Mail Inbox Poll (every 15m)</li></ul></li></ul><p>上线策略：默认禁用新增任务，避免直接引入未知风险。</p><h3 id="phase-g%EF%BC%9A%E4%B8%8A%E7%BA%BF%E9%AA%8C%E8%AF%81" tabindex="-1">Phase G：上线验证</h3><p>完成以下检查：</p><ol><li>gateway status：RPC probe ok</li><li>agents list：5 个 Agent 全部可见</li><li>channels status --probe：主要通道可用</li><li>cron 配置：任务归属符合预期</li><li>doctor --repair：修复服务入口与 PATH 风险</li></ol><hr /><h2 id="%E5%85%B3%E9%94%AE%E7%BB%93%E6%9E%9C" tabindex="-1">关键结果</h2><h3 id="%E5%B7%B2%E8%BE%BE%E6%88%90" tabindex="-1">已达成</h3><ul><li>完成从 2 Agent 到 5 Agent 的结构升级</li><li>建立了可复用的 Core Files 初始化规范</li><li>建立了职责分明的 cron 归属策略</li><li>网关服务改为更稳定的启动入口</li><li>具备可回滚能力与回归验证清单</li></ul><h3 id="%E5%BD%93%E5%89%8D%E4%BF%9D%E5%AE%88%E4%B8%8A%E7%BA%BF%E7%8A%B6%E6%80%81" tabindex="-1">当前保守上线状态</h3><ul><li>Heartbeat 任务：已启用</li><li>Mail 任务：保持禁用（观察后再启）</li></ul><hr /><h2 id="%E8%B8%A9%E5%9D%91%E4%B8%8E%E7%BB%8F%E9%AA%8C" tabindex="-1">踩坑与经验</h2><h3 id="1)-cli-%E6%96%87%E6%A1%A3%E4%B8%8E%E5%AE%9E%E9%99%85%E8%A1%8C%E4%B8%BA%E4%B8%8D%E6%80%BB%E6%98%AF%E4%B8%80%E8%87%B4" tabindex="-1">1) CLI 文档与实际行为不总是一致</h3><p>最初按“半交互”设计，后续验证发现 add 命令可通过参数非交互执行。<br />建议：优先实测命令，再写 runbook。</p><h3 id="2)-doctor-%E4%BF%AE%E5%A4%8D%E5%90%8E%E5%BF%85%E9%A1%BB%E5%9B%9E%E5%BD%92%E9%AA%8C%E8%AF%81" tabindex="-1">2) Doctor 修复后必须回归验证</h3><p>doctor --repair 可能改写配置。<br />建议：每次修复后执行四项回归：agents、approvals、cron、gateway。</p><h3 id="3)-%E6%96%B0%E4%BB%BB%E5%8A%A1%E5%85%88%E7%A6%81%E7%94%A8%E5%86%8D%E7%81%B0%E5%BA%A6" tabindex="-1">3) 新任务先禁用再灰度</h3><p>直接启用多条新任务会增加故障定位难度。<br />建议：先启 heartbeat，观察稳定后再启 mail。</p><h3 id="4)-core-files-%E6%98%AF%E5%A4%9A-agent-%E7%9A%84%E2%80%9C%E5%8F%AF%E8%BF%90%E7%BB%B4%E5%9F%BA%E7%BA%BF%E2%80%9D" tabindex="-1">4) Core Files 是多 Agent 的“可运维基线”</h3><p>不仅是文档，更是行为边界和协作契约。</p><hr /><h2 id="%E5%8F%AF%E5%A4%8D%E7%94%A8%E7%9A%84%E4%B8%8B%E6%AC%A1%E5%AE%9E%E6%96%BD%E6%A8%A1%E6%9D%BF" tabindex="-1">可复用的下次实施模板</h2><h3 id="30-%E5%88%86%E9%92%9F%E7%89%88%E6%9C%AC%EF%BC%88%E5%BB%BA%E8%AE%AE%EF%BC%89" tabindex="-1">30 分钟版本（建议）</h3><ol><li>备份 + 演练恢复（10 分钟）</li><li>新增 Agent + Core Files 初始化（8 分钟）</li><li>模型/权限/任务落地（8 分钟）</li><li>网关重启与验证（4 分钟）</li></ol><h3 id="%E6%9E%81%E7%AE%80%E6%A3%80%E6%9F%A5%E6%B8%85%E5%8D%95" tabindex="-1">极简检查清单</h3><ul class="contains-task-list"><li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> 备份文件可解压</li><li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> agents list 包含目标 Agent</li><li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> 每个 workspace 有 7 个 Core Files</li><li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> cron 归属按职责拆分</li><li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> gateway RPC probe ok</li><li class="task-list-item"><input class="task-list-item-checkbox" disabled="" type="checkbox"> channels probe 正常</li></ul><hr /><h2 id="%E9%A3%8E%E9%99%A9%E4%B8%8E%E5%9B%9E%E6%BB%9A%E5%BB%BA%E8%AE%AE" tabindex="-1">风险与回滚建议</h2><h3 id="%E5%BB%BA%E8%AE%AE%E4%BF%9D%E7%95%99" tabindex="-1">建议保留</h3><ul><li>最近一次全量备份目录</li><li>openclaw.json.pre-model</li><li>exec-approvals.json.pre-perms</li><li>cron/jobs.json.pre-reorg</li></ul><h3 id="%E5%BB%BA%E8%AE%AE%E5%9B%9E%E6%BB%9A%E8%A7%A6%E5%8F%91%E6%9D%A1%E4%BB%B6" tabindex="-1">建议回滚触发条件</h3><p>任一条件满足即回滚：</p><ol><li>gateway 无法稳定启动</li><li>channels 探测连续失败</li><li>cron 触发异常导致主链路不可用</li><li>Agent 路由出现跨职责误调用</li></ol><hr /><h2 id="%E5%90%8E%E7%BB%AD%E4%BC%98%E5%8C%96%E8%B7%AF%E7%BA%BF" tabindex="-1">后续优化路线</h2><ol><li>清理插件冲突：处理 openclaw-lark 与 feishu 重复注册告警</li><li>为 main 增加更细粒度的路由策略（按任务类型而非仅按渠道）</li><li>增加多阶段告警等级（info/warn/critical）</li><li>给 mail 任务补充白名单与去重策略</li><li>将 runbook 固化为自动化脚本 + CI 检查项</li></ol><hr /><h2 id="%E7%BB%93%E8%AF%AD" tabindex="-1">结语</h2><p>这次改造的核心不是“加了几个 Agent”，而是把系统从“单点聪明”升级为“结构可靠”。</p><p>在无源码管理环境里，最重要的是：</p><ul><li>先可回滚，再做变更</li><li>先灰度，再全量</li><li>先验证，再宣布完成</li></ul><p>只要这三条守住，多 Agent 架构会越跑越稳。</p>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[给xbox 装上 retroarch，用手柄打街机游戏]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/gei-xbox-zhuang-shang-retroarch-yong-shou-bing-da-jie-ji-you-xi" />
                <id>tag:https://maifeipin.com,2026-03-21:gei-xbox-zhuang-shang-retroarch-yong-shou-bing-da-jie-ji-you-xi</id>
                <published>2026-03-21T21:10:45+08:00</published>
                <updated>2026-03-21T21:42:56+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<ol><li>注册开发者账号，<a href="https://storedeveloper.microsoft.com/zh-Hans/onboarding" target="_blank">微软官方入口</a><br /><img src="/upload/2026/03/image-1774096505638.png" alt="image-1774096505638" /></li><li>在xbox  上安装 Dev Mode Activation，重启到开发者控制台模式<br /><img src="/upload/2026/03/image-1774098924894.png" alt="image-1774098924894" /></li><li>在控制台 登录开发者账号，把激活码，发送到 官网WEB网站。<br />. <img src="/upload/2026/03/image-1774096981058.png" alt="image-1774096981058" /></li><li><a href="https://github.com/libretro/RetroArch" target="_blank">下载 RetroArch 源代码</a> 并编译<br /><img src="/upload/2026/03/image-1774097596273.png" alt="image-1774097596273" /></li><li>把编译后的 RetroArch-msvcUWP_1.22.2.0_x64_ReleaseANGLE.appxbundle 拖到 dev home的http服务的页面（添加应用和游戏按钮后弹出）<br /><img src="/upload/2026/03/image-1774097969473.png" alt="image-1774097969473" /></li><li>在回到xbox 的 dev home中 运行 retroarch，然后 在updater online 下载核心和配置.<br /><img src="/upload/2026/03/image-1774098236293.png" alt="image-1774098236293" /></li><li>最后，如果不想自己动手，在某宝某鱼上搜 retroarch 也可以代装。本人不打游戏，但看司波图的这玩意介绍视频后发现这个可以替换家里的N1盒子了，N1盒子的遥控器太烂了。有YT,ATV还有KODI,完美平替，找个信用好的卖家xbox one s 1T版本， 价格聊到500以内果断入手。</li></ol>]]>
                </content>
            </entry>
            <entry>
                <title><![CDATA[阿里小龙虾，内测版]]></title>
                <link rel="alternate" type="text/html" href="https://maifeipin.com/archives/a-li-xiao-long-xia--nei-ce-ban" />
                <id>tag:https://maifeipin.com,2026-03-15:a-li-xiao-long-xia--nei-ce-ban</id>
                <published>2026-03-15T13:47:03+08:00</published>
                <updated>2026-03-15T13:48:06+08:00</updated>
                <author>
                    <name>admin</name>
                    <uri>https://maifeipin.com</uri>
                </author>
                <content type="html">
                        <![CDATA[<h3 id="%E7%AE%80%E5%8D%95%E7%94%B3%E8%AF%B7" tabindex="-1">简单申请</h3><ol><li><a href="https://jvs.wuying.aliyun.com/login" target="_blank">申请免费内测链接</a><br />非常简单<br /><img src="/upload/2026/03/image-1773553253725.png" alt="image-1773553253725" /><br />很快收到邮件<br /><img src="/upload/2026/03/image-1773553312643.png" alt="image-1773553312643" /></li></ol><h3 id="%E9%85%8D%E7%BD%AE%E5%BE%88%E5%A4%A7%E6%96%B9" tabindex="-1">配置很大方</h3><ol><li><p>完全预装 OPENCLAW+ AI模型<br /><img src="/upload/2026/03/image-1773553370846.png" alt="image-1773553370846" /></p></li><li><p>4C8G100G<br /><img src="/upload/2026/03/image-1773553606000.png" alt="image-1773553606000" /></p></li></ol>]]>
                </content>
            </entry>
</feed>
