--- names: - feishu-doc-to-wechat-draft name: feishu-doc-to-wechat-draft aliases: - 飞书文档到微信草稿箱 - 飞书文档微信草稿箱 - lark-wechat-draft description: 从飞书/Feishu 文档直接生成微信公众号草稿内容(支持预览、默认样式、dry-run 发布),基于现有 wechat-draft-publisher 流程的独立封装。 version: 0.1.0 author: Draco license: MIT metadata: hermes: tags: [feishu, lark, wechat, draft, markdown, publisher, preview] --- # 飞书文档 → 微信草稿箱 这是一个**独立可运行**的 skill 封装:把飞书文档内容抓取为 Markdown,按公众号样式渲染,并支持 - 生成 HTML 预览(`render-preview-feishu-doc-default`) - dry-run 组装草稿 payload(`publish-feishu-doc-default`) > 说明:这是在保留旧 `wechat-official-account-draft-publisher` 能力的前提下,新增的“从飞书文档入库”方向,不会覆盖旧版本。 ## 目录结构 ```text feishu-doc-to-wechat-draft/ ├── scripts/ │ ├── run.py # 本地 CLI 入口 │ └── wechat_draft_publisher/ # 独立迁移后的核心逻辑(load/render/pipeline/cli) ├── examples/ │ └── default-publish-style.yaml ├── tests/ │ └── test_integration_example_doc.py ├── requirements.txt └── env.sample.txt ``` ## 安装与依赖 ```bash cd ~/.hermes/skills/productivity/feishu-doc-to-wechat-draft python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt ``` 当前渲染链路除 `PyYAML`、`markdown-it-py` 外,还需要 `Pygments`。 原因:代码块现在采用**服务端语法高亮**,而不是只靠简单的纯文本换行包装。这样发布到微信公众号草稿箱后,代码块才能更接近 Doocs 的真实实现效果。 ## 基础用法(示例文档) 示例文档: `https://g1mu6da08l.feishu.cn/docx/H0TZdmw3GoW2JSxGBFacw2jdnV8?from=from_copylink` ### 1) 生成预览 HTML ```bash python3 scripts/run.py render-preview-feishu-doc-default \ --doc "https://g1mu6da08l.feishu.cn/docx/H0TZdmw3GoW2JSxGBFacw2jdnV8?from=from_copylink" \ --output /tmp/example-feishu-to-wechat-preview.html ``` ### 2) 预览校验成功后,生成草稿 payload(dry-run) ```bash python3 scripts/run.py publish-feishu-doc-default \ --doc "https://g1mu6da08l.feishu.cn/docx/H0TZdmw3GoW2JSxGBFacw2jdnV8?from=from_copylink" \ --thumb-media-id DRY_RUN_MEDIA_ID \ --dry-run ``` 返回 JSON 中会包含 `payload`,可直接用于确认是否满足微信要求(标题、摘要、封面 ID、正文 HTML)。 ### 新增经验:dry-run 也必须传 `--thumb-media-id` 这次真实联调再次确认: - `publish-feishu-doc-default --dry-run` - **并不会**自动跳过封面校验 如果不传: - `--thumb-media-id` 会直接报: ```text ValueError: thumb_media_id is required in dry-run mode ``` 所以 dry-run 的最小正确写法应固定为: ```bash python3 scripts/run.py publish-feishu-doc-default \ --doc "<飞书文档URL>" \ --author "DracoVibeCoding" \ --thumb-media-id DRY_RUN_MEDIA_ID \ --dry-run ``` 也就是说: - **dry-run 用假的 `thumb_media_id` 占位即可** - 但参数本身不能省 ### 新增排障经验:公众号正文默认全左对齐 用户明确要求公众号草稿内容默认全左对齐。当前 doocs/grace 发布链路应满足: - `justify=false` - 正文不出现 `text-align: justify` - 正文不出现 `text-align: center` - 段落、标题、图注、分割线都显式或继承左对齐 注意:此前当前文档并不是两端对齐;`doocs` 默认 `justify=false`。真正容易造成“看起来没左齐”的残留是标题默认样式里的 `display: table; margin: ... auto` 和图注/星号分割线的居中样式。 ### 新增排障经验:飞书有序列表常导出成全 `1.` 飞书/Lark 文档导出的 Markdown 可能把每个有序列表项都写成 `1.`;如果列表项中间夹了截图、引用块,Markdown 渲染会把它们拆成多个单项列表,推到微信公众号后台后序号看起来全是 `1.`。 当前链路已做两层修复: - `normalize_lark_markdown()` 会把跨图片/引用块的连续步骤重编号,例如 `1,2,3...15`。 - renderer 会保留 Markdown `
    ` 的起始序号,并把它转成微信更稳的显式 `N.`,避免依赖微信后台的 `
      ` 默认样式。 验收时固定检查: - 正文 HTML 中 `md-ordered-index` 不应全是 `1.`。 - `lark-image://` 残留应为 0。 ## 真实发布 ### 重要排障经验:公众号 access_token 缓存必须按 AppID 隔离 这次真实联调踩到一个很隐蔽的坑: - 如果 token cache 固定写到同一个文件(例如 `~/.cache/wechat-draft-publisher/access_token.json`) - 机器上又曾经给别的公众号拿过 token - 后续发布时就可能误复用“别的 AppID 对应的 token” 表面现象会非常迷惑: - `draft/add` 看起来返回成功 - 也拿到了一个 `media_id` - 但当前公众号草稿箱里根本看不到这条草稿 - 再用当前号去 `draft/get` 这个 `media_id`,会得到 `invalid media_id` 稳妥做法: - token cache 文件必须按 `appid` 分开,例如: - `~/.cache/wechat-draft-publisher/access_token_.json` - 当用户反馈“明明返回成功但草稿箱里没有”时,优先检查: 1. `draft/batchget` 是否真能列出草稿 2. `draft/get` 是否能读取刚返回的 `media_id` 3. 本机 token cache 是否串用了别的公众号 token - 修复后,建议删掉旧的通用 cache 文件,再重新取 token 并重发一次 ### 新增排障经验:优先使用 stable_token,必要时 force_refresh 这次继续联调时又踩到一个新的真实坑: - 即使 `appid/appsecret` 没错 - 传统 `GET /cgi-bin/token` 也能返回 `access_token` - 但后续调用 `material/add_material`、`draft/add` 等接口时,微信仍可能返回: ```text 40001 invalid credential, access_token is invalid or not latest could get access_token by getStableAccessToken ``` 这类报错在当前公众号环境里,不能只理解成“密钥错了”,更常见的根因是: - 当前号要求使用 `POST /cgi-bin/stable_token` - 而且有时 `force_refresh=False` 仍会拿到一个**不是 latest** 的 token - 于是上传封面图、创建草稿都会失败 稳妥策略: 1. **默认优先改用 stable token 接口** ```http POST https://api.weixin.qq.com/cgi-bin/stable_token Content-Type: application/json { "grant_type": "client_credential", "appid": "...", "secret": "...", "force_refresh": true } ``` 2. 如果报错里明确出现: - `invalid credential` - `not latest` - `could get access_token by getStableAccessToken` 那就不要继续重试旧 token;直接切到: - `stable_token` - `force_refresh=true` 3. 对于“上传封面 → 发草稿”的一次性发布链路,宁可每次正式发布时都重新取一次最新 stable token,也不要过度相信本地缓存。 4. 修完后立即做最小验证: - 先验证 `get_access_token()` 是否成功 - 再验证 `upload_cover_image(...)` 是否成功 - 最后再发整篇文章 ### 建议更新实现 如果现有代码仍是: - 优先读本地 cache - 然后请求 `GET /cgi-bin/token` 则建议改成: - 直接 `POST /cgi-bin/stable_token` - 在正式发布路径中优先 `force_refresh=true` - 成功后再写回按 AppID 隔离的 cache 这样能显著减少“本地看着拿到 token 了,但微信后续接口仍判你不是 latest”的问题。 ### 新增经验:本地 Markdown 直发 `publish-default` 时,frontmatter 不是可选项 这次继续联调又踩到一个很容易忽略的坑: - 用 `publish-default --input /path/to/article.md` 直接发布本地 Markdown 时 - 即使正文第一行已经是 `# 从飞书文档一键发布到微信公众号` - 如果文件没有 YAML frontmatter,发布仍会失败,报: ```text ValueError: title is required ``` 根因是当前 `load_article(...)` 的取值规则是: - `title` / `author` / `digest` / `cover_image` / `source_url` - **只从 frontmatter 里读取** - 不会从正文里的 `# 一级标题` 自动回填 `title` 也就是说,下面这种文件**不能**直接用于 `publish-default`: ```md # 文章标题 正文…… ``` 稳妥做法是至少补上: ```yaml --- title: 文章标题 author: DracoVibeCoding --- ``` 然后再接正文 Markdown。 如果只是临时重发一篇本地稿,可以先在 `/tmp` 里生成一个带 frontmatter 的发布副本,再执行: ```bash python3 scripts/run.py publish-default \ --input /tmp/article_publishable.md \ --thumb-media-id "已有封面素材ID" ``` ### 新增经验:用户说“草稿箱没看到”时,不要只信 `media_id` 这次排查也再次证明: - 即使 CLI 已返回 `draft_media_id` - 也不应该只凭这个就断定“已经进草稿箱了” 更稳的收尾动作应是: 1. 记录返回的 `draft_media_id` 2. 立刻用当前公众号的最新 `access_token` 调 `draft/batchget` 3. 确认列表里确实能看到: - 对应 `media_id` - 对应 `title` - 合理的 `update_time` 也就是把“微信接口说成功”升级成“当前公众号后台可列出这条草稿”。 尤其当用户明确反馈“后台没看到新推送”时,优先排查: - 是否其实发的是另一篇文档 - 是否复用了旧 `thumb_media_id` - 是否命令只做了 dry-run - 是否成功返回了 `media_id`,但当前号的 `draft/batchget` 根本列不出来 需要正式发布到微信草稿箱时,请在环境变量中提供: ### 新增经验:没有现成 `thumb_media_id` 时,先本地做一个临时封面再发 这次把新的飞书文档直推公众号草稿箱时,真实踩到的第一道坎仍然是: ```text ValueError: cover_image or thumb_media_id is required for publish ``` 也就是说: - `publish-feishu-doc-default` 真发草稿时 - **必须提供其一**: - `--thumb-media-id` - `--cover-image` 如果手头没有现成封面素材,最省事的办法是: 1. 先跑一次预览,确认标题: ```bash python3 scripts/run.py render-preview-feishu-doc-default \ --doc "<飞书文档URL>" \ --author "DracoVibeCoding" \ --output /tmp/feishu_wechat_preview.html ``` 2. 用本机 `ffmpeg` 直接生成一张临时封面 PNG 这台机器上没有 `PIL`,但有 `ffmpeg`,下面这类命令可直接出图: ```bash ffmpeg -y \ -f lavfi -i color=c='#FA5151':s=900x383:d=1 \ -vf "drawbox=x=28:y=28:w=844:h=327:color=white@0.10:t=fill,\ drawtext=fontfile=/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc:text='文章标题':fontcolor=white:fontsize=46:x=(w-text_w)/2:y=130,\ drawtext=fontfile=/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc:text='DracoVibeCoding':fontcolor=white@0.92:fontsize=24:x=(w-text_w)/2:y=220" \ -frames:v 1 /tmp/wechat_cover.png ``` 3. 再真实发布: ```bash python3 scripts/run.py publish-feishu-doc-default \ --doc "<飞书文档URL>" \ --author "DracoVibeCoding" \ --cover-image /tmp/wechat_cover.png ``` 适用场景: - 你只是想快速把飞书文档推进草稿箱 - 还没有设计好的正式封面 - 本机没装 Pillow,但有 `ffmpeg` ### 新增经验:发布后别只信返回值,要立刻 `draft/get` 回查 这次成功发布后,又额外做了一步验证: 1. 用 `stable_token` 重新取最新 token 2. 调微信: - `cgi-bin/draft/get` 3. 用返回结果核对: - `title` - `author` - `thumb_media_id` - `media_id` 这样才能确认: - 草稿是真的进了当前公众号草稿箱 - 不是只拿到一个“看起来成功”的返回值 这次真实联调又踩到一个非常容易误判的问题: - 终端里曾经保留过旧的 `draft_media_id` - 或者误把“上一条文章的发布结果”当成“当前这条文章已经重新发布” - 如果这时只看控制台打印,特别容易误以为“新版已经推送到草稿箱” 更稳的做法是:**每次说“已经发到草稿箱”之前,都立即用微信 `draft/batchget` 回查一次**,确认当前公众号后台真的能列出目标标题。 推荐最小验证链路: 1. 记录本次发布返回的 `draft_media_id` 2. 重新取一次最新 `stable_token` 3. 调用: ```http POST https://api.weixin.qq.com/cgi-bin/draft/batchget?access_token=ACCESS_TOKEN Content-Type: application/json { "offset": 0, "count": 10, "no_content": 1 } ``` 4. 在返回的 `item[].content.news_item[]` 里同时核对: - `title` - `author` - `thumb_media_id` - `media_id` 如果用户反馈“草稿箱里没看到新稿”,优先不要争论;直接重新做这一步回查。 ### 新增排障经验:用 `publish-default` 发布本地 Markdown 时,缺 frontmatter 会直接报 `title is required` 这次还有一个非常具体、很容易再次遇到的坑: - 本地临时文章文件如果只是纯 Markdown 正文 - 没有 YAML frontmatter - 而又直接走 `publish-default --input article.md` 那么 `load_article()` 读不到: - `title` - `author` - `digest` - `cover_image` 此时发布链路会在校验阶段直接报: ```text ValueError: title is required ``` 稳妥做法: 1. 对本地 Markdown 发布稿,至少补最小 frontmatter: ```yaml --- title: 从飞书文档一键发布到微信公众号 author: DracoVibeCoding --- ``` 2. 然后再执行: ```bash python3 scripts/run.py publish-default \ --input /tmp/article_publishable.md \ --thumb-media-id YOUR_THUMB_MEDIA_ID ``` 3. 如果只是想快速重发一篇“当前会话里已经整理好的临时 Markdown”,最稳的做法是: - 从旧发布结果中提取可复用的 `thumb_media_id` - 先给临时 Markdown 补 frontmatter - 再真实发布 - 最后立刻 `draft/batchget` 回查 需要正式发布到微信草稿箱时,请在环境变量中提供: 如果用户反馈“公众号草稿箱里的 code block 看起来不对”,优先检查下面两件事,而不是先盲目改 CSS: 1. **有没有对 `` 内容做二次 HTML 转义** - Markdown 渲染器通常已经把代码内容 escape 过一轮。 - 如果后处理又把 `&` / `<` / `>` 再整体替换一遍,就会把 `"` 变成 `&quot;`。 - 这会直接导致公众号里的引号、尖括号和特殊字符显示异常。 2. **有没有真正做服务端语法高亮** - 如果只是把代码按行切开,再包成若干 `...`,外壳看起来像代码块,但底层并不接近 Doocs。 - 更稳妥的做法是: - 先从 `` 里取出已 escape 的内容 - 反解回 raw code - 用服务端高亮器(当前实现用 `Pygments`)生成带 inline style 的 token HTML - 再按 Doocs 的思路保留空格和换行 - 非行号模式:保留换行、空格;行号模式:按行高亮并生成独立行号列,同时裁掉代码块末尾的单个空行,避免多出一个尾部空白行号。 实践上,这类问题的根因通常是**渲染链路错误**,不是单纯“主题颜色不一致”。 ### 新增经验:公众号里的 code block 横向滚动,滚动容器必须优先放在 `
      `
      
      这次真实发布后又踩到一个非常关键的兼容性坑:
      
      - 在桌面浏览器里,把 code block 的横向滚动从外层 `
      ` 挪到内部 ``,表面上仍然可能看起来“能滚”
      - 但在**微信公众号文章 WebView**里,这样的结构很容易失效
      - 结果就是:
        - 长命令 / 长 YAML / 长 Python 行被右侧裁切
        - 用户无法左右滑动查看完整代码
      
      ### 触发问题的坏结构
      
      不要把外层改成:
      
      ```html
      
        ...
        
      ```
      
      这个结构在普通浏览器里可能还能工作,但在微信文章环境里不够稳。
      
      ### 更稳的公众号结构
      
      应优先保持:
      
      ```html
      
        ...
        
      ```
      
      也就是:
      - **`
      ` 负责横向滚动**
      - `` 只负责承载内容与内边距,不要再承担主要滚动职责
      
      ### 和 Mac code block 共存时的建议
      
      如果你还要保留 Doocs-like 的 Mac 顶部样式(红黄绿 dots / 顶部 header 感):
      
      1. 可以继续把顶部 dots 作为 `pre` 内部的独立块放在前面
      2. 但**不要**因此把 `pre` 改成 `overflow: hidden`
      3. 更不要把“真正的横滑职责”完全交给内部 `code`
      4. 推荐保留:
         - `overflow-x: auto; overflow-y: hidden;`
         - `-webkit-overflow-scrolling: touch;`
      5. 同时让 `code` 保留:
         - `white-space: pre`
         - `min-width: max-content`
         - `word-break: normal`
         - `overflow-wrap: normal`
      
      ### 建议新增的回归测试
      
      至少补一条针对公众号横滑的测试:
      
      - Mac code block 开启时:
        - 断言 `
      ` 上仍有 `overflow-x: auto`
        - 断言存在 `-webkit-overflow-scrolling: touch`
        - 断言不再出现 `overflow: hidden` 这种会吞掉横滑的结构
      
      一句话记忆:**在微信公众号里,code block 的横向滚动容器应优先是 `
      `,不是内部 ``。视觉细节可以继续优化,但不要为 Mac 顶部样式牺牲 `pre` 的横滑能力。**
      
      需要正式发布到微信草稿箱时,请在环境变量中提供:
      
      ```bash
      export WECHAT_APP_ID=""
      export WECHAT_APP_SECRET=""
      ```
      
      再执行:
      
      ```bash
      python3 scripts/run.py publish-feishu-doc-default \
        --doc "https://g1mu6da08l.feishu.cn/docx/H0TZdmw3GoW2JSxGBFacw2jdnV8?from=from_copylink"
      ```
      
      ## 运行测试(使用示例文档)
      
      ```bash
      source .venv/bin/activate
      pytest -q tests/test_integration_example_doc.py
      ```
      
      成功后可复用示例输出,确认新 skill 封装链路可用。
      
      ## 新增经验:运行时 skill 副本可能落后于 standalone 项目,导致已修复的渲染 bug 回归
      
      这次真实联调又踩到一个很蠢、但非常容易再次出现的坑:
      
      - 对外真正用于执行发布命令的入口,可能在:
        - `~/.hermes/skills/productivity/feishu-doc-to-wechat-draft/scripts/run.py`
      - 但你持续迭代、测试、修 bug 的 canonical 项目代码,可能在:
        - `/home/ubuntu/projects/draco-skills-collection/feishu-doc-to-wechat-draft/`
      
      如果两边代码漂移,就会出现非常迷惑的现象:
      - standalone 项目里 code block bug 已经修好
      - tests 也都通过
      - 但真正发布到微信公众号草稿箱时,又跑回旧版 renderer
      - 结果就是典型 regression:
        - code block 样式丢失
        - 代码被压成一行
        - 横向滚动/换行行为退回旧逻辑
      
      ### 这次的真实根因
      
      运行时副本里仍是旧逻辑,例如:
      - `display: -webkit-box`
      - `white-space: nowrap`
      - 没有后来的 `white-space: pre + min-width: max-content + pre 负责横向滚动`
      - 没有服务端高亮与 `
      ` 保留换行的修复 而 standalone 项目里已经是修复后的版本。 ### 稳妥做法 不要只靠“记得同步文件”。更稳的是: 1. 把运行时 wrapper 改成**优先导入 canonical standalone 项目** 2. 只有当 standalone 项目不存在时,才 fallback 到 skill 副本自身 例如 `scripts/run.py` 应优先类似这样做: ```python ROOT = Path(__file__).resolve().parent CANONICAL_ROOT = Path("/home/ubuntu/projects/draco-skills-collection/feishu-doc-to-wechat-draft/scripts") IMPORT_ROOT = CANONICAL_ROOT if CANONICAL_ROOT.exists() else ROOT sys.path.insert(0, str(IMPORT_ROOT)) ``` ### 发布前必须做的核验 当用户反馈“这个 bug 明明修过,为什么又回来了”时,优先不要先怀疑微信;先查: 1. 当前发布命令到底调用的是哪一个 `run.py` 2. 实际 import 到的 `renderer.py` 路径是哪一个 3. 发布产出的 HTML 里是否还残留旧特征,例如: - `white-space: nowrap` - `display: -webkit-box` 4. 修复版 HTML 里是否已经具备新特征,例如: - `white-space: pre` - `min-width: max-content` - `overflow-x: auto; overflow-y: hidden` - `-webkit-overflow-scrolling: touch` 一句话记忆:**如果 skill 既有运行时副本,又有 standalone canonical 项目,就不要让运行入口默认吃本地副本;否则回归只是时间问题。** ## 新增经验:standalone 项目与运行时 skill 副本可能漂移,发布前必须核对真正执行的那份代码 这次真实回归排查踩到了一个很隐蔽但很致命的坑: - 本机同时存在两份 `feishu-doc-to-wechat-draft`: 1. **运行时 skill 副本**: - `~/.hermes/skills/productivity/feishu-doc-to-wechat-draft/` 2. **standalone 项目副本**: - `/home/ubuntu/projects/draco-skills-collection/feishu-doc-to-wechat-draft/` - 之前修好的 code block 渲染修复其实已经在 standalone 项目里 - 但真正发布微信公众号草稿时,调用的是 `~/.hermes/skills/.../scripts/run.py` - 结果运行时副本里的 `renderer.py` 仍然是旧版,于是把 **white-space: nowrap / display: -webkit-box** 这些老逻辑又带回去了,导致: - code block 样式回退 - 所有代码挤成一行 - 用户以为“之前修好的 bug 又 regression 了” ### 这类问题的本质 不是微信随机抽风,也不是测试全白费。 是因为: - **测试跑的是 A 副本** - **真正发布跑的是 B 副本** - A 和 B 已经漂移 这类问题一旦出现,单看 HTML/CSS 症状会很像“神秘回归”,但真正根因是 **执行入口与源码来源不一致**。 ### 发布前必须做的核对 如果这台机器上同时存在: - Hermes 运行时 skill 副本 - 一个可公开/可测试的 standalone 项目副本 那么在重新发布公众号草稿箱前,必须先确认以下三件事: 1. **当前命令到底执行哪一份** 例如本技能实际发布命令是: ```bash python3 ~/.hermes/skills/productivity/feishu-doc-to-wechat-draft/scripts/run.py ... ``` 那就说明真正生效的是: - `~/.hermes/skills/productivity/feishu-doc-to-wechat-draft/scripts/wechat_draft_publisher/*.py` 而不是 `/home/ubuntu/projects/...` 那份。 2. **对关键文件做 diff,而不是凭印象说“之前修过”** 对于这次 code block 回归,至少应对拍: ```bash diff -u \ ~/.hermes/skills/productivity/feishu-doc-to-wechat-draft/scripts/wechat_draft_publisher/renderer.py \ /home/ubuntu/projects/draco-skills-collection/feishu-doc-to-wechat-draft/scripts/wechat_draft_publisher/renderer.py ``` 重点看是否还残留这些旧逻辑: - `display: -webkit-box` - `white-space: nowrap` - 缺少 `min-width: max-content` - 缺少 `overflow-x: auto; overflow-y: hidden` - 缺少 `-webkit-overflow-scrolling: touch` 3. **修完后要重新验证“运行时副本产出的 HTML”** 不要只测 standalone 项目。 应直接用运行时 skill 重新生成 preview / payload,并检查是否满足: - **不存在**: - `white-space: nowrap` - `display: -webkit-box` - **存在**: - `white-space: pre` - `min-width: max-content` - `overflow-x: auto; overflow-y: hidden` - `-webkit-overflow-scrolling: touch` ### 对本技能尤其重要的回归信号 如果用户反馈: - code block 又像没样式 - 所有 code 挤在一行 - 明明之前修过,怎么又坏了 优先不要先怪微信。 先检查: 1. 当前发布到底跑的是哪份 skill 2. `renderer.py` 是否还是旧副本 3. 运行时 preview HTML 里是否又出现 `white-space: nowrap` ### 建议的稳妥流程 以后凡是这类“standalone + 运行时副本共存”的技能,公众号真实发布前建议固定做: 1. 跑测试(standalone 项目) 2. 对关键文件做 diff(standalone vs runtime skill) 3. 必要时把修复同步到 `~/.hermes/skills/...` 4. 用 **运行时 skill** 重新生成 preview HTML 5. 再真实发布 6. 最后用 `draft/get` 回查最终草稿里的 HTML 关键片段 一句话记忆:**在这台机器上,发布是否成功,不只取决于你“修过哪份代码”,更取决于“命令实际执行的是哪份代码”。** ## 新增经验:飞书“分行编号说明块”的保真转换 飞书文档里经常出现一种视觉上很像有序列表、但抓回来的 Markdown 实际还只是普通段落的结构,例如: ```md 简单说,它解决三个核心问题: **1. 图片自动处理** 说明正文…… **2. 格式完整保留** 说明正文…… **3. 排版风格统一** 说明正文…… ``` 如果直接交给 Markdown 渲染器,或者只做“标题+正文硬拼成同一行”的粗暴重写,都会出问题。常见症状包括: - 折行节奏丢失 - `1 / 2 / 3` 不再保留原本的段落层次 - “标题”和“说明正文”被压成一行,公众号里看起来非常拥挤 - 回滚或重构后容易出现 regression:非 code 部分恢复了,但编号说明块的折行又丢了 更稳的做法,是在 `normalize_lark_markdown(...)` 阶段先做结构正规化: 1. 识别独占一行的 `**1. 标题**` 2. 把其后一段正文吸附到该条目上 3. 连续多项时,重写成真正的 markdown ordered list 4. **不要**把正文直接拼回标题同一行;应保留为 list item 内的独立段落 也就是优先转成: ```md 1. **图片自动处理** 说明正文…… 2. **格式完整保留** 说明正文…… 3. **排版风格统一** 说明正文…… ``` 而不是: ```md 1. **图片自动处理** 说明正文…… 2. **格式完整保留** 说明正文…… 3. **排版风格统一** 说明正文…… ``` 这样 MarkdownIt 会自然产出“一个 `
    1. ` 内含多个 `

      `”的结构,后续 HTML 渲染才能既保留有序列表语义,又保留段落边界。 ### 额外坑:renderer 也可能把段落再次压扁 即使 normalize 已经产出了正确的多段 list item,渲染层仍然可能把它破坏掉。 这次真实踩到的坑是: - `_rewrite_ordered_lists()` 如果把整个 `

    2. ...
    3. ` 内容塞回一个 inline `...` - 那么 list item 内原本独立的多个 `

      ` 会被再次 inline 化 - 最终又回到“折行丢失”的错误效果 稳妥做法: - ordered list 的重写容器应使用 block-level 容器(如 `

      ...
      `) - 编号和正文可以做左右布局,但正文容器必须允许内部保留块级段落 - 不要假设 `item.strip()` 后塞进一个 `` 就是安全的 ### 建议补的回归测试 至少补两类测试: 1. `normalize_lark_markdown(...)` 回归测试 - 输入:`**1. 标题**` + 下一行正文 + `**2. 标题**` + 下一行正文 - 断言:normalize 后出现 - `1. **标题**\n\n 正文` - 断言:不再保留旧的“标题一行、正文紧跟下一行但未入列表”的松散结构 2. renderer 回归测试 - 输入:包含“多段 list item”的 markdown ordered list - 断言:输出中 ordered item 的正文容器是 block-level,而不是单个 inline span - 断言:HTML 中仍能看到 list item 内部的多个段落节点 这次实际补上的测试方向包括: - `tests/test_lark_markdown_normalization.py` - `tests/test_ordered_list_start_and_code_theme.py` ### 新增经验:编号项内部的列表和补充说明,也必须保留在同一个 ordered item 里 这次继续联调又发现一个很容易遗漏的结构坑: 飞书原始 Markdown 里像下面这种内容: ```md **2. 微信公众号凭证** 登录微信公众平台,在「开发」-「基本配置」里获取: - AppID - AppSecret(只显示一次,记得保存) 同时把你的服务器 IP 添加到「IP 白名单」,否则调用接口会报错。 ``` 如果 normalize 只把 `**2. 微信公众号凭证**` 变成 ordered item 标题,却没有把: - 说明段落 - bullet 列表 - 列表后的补充说明 一起缩进并吸附进同一个 list item,就会在公众号里出现: - 编号单独一行 - bullet 列表像掉出该编号项 - 后续补充说明再次断层 - 视觉上像多出错误编号或额外折行 更稳的正规化目标应是: ```md 2. **微信公众号凭证** 登录微信公众平台,在「开发」-「基本配置」里获取: - AppID - AppSecret(只显示一次,记得保存) 同时把你的服务器 IP 添加到「IP 白名单」,否则调用接口会报错。 ``` ### 对 `strong-numbered block` 的更稳处理规则 在 `_convert_strong_numbered_blocks(...)` 里,建议采用下面的策略: 1. 识别 `**2. 标题**` 这类独占一行的强编号标题 2. 重写成: - `2. **标题**` 3. 然后继续吸收其后续 block,直到遇到以下任一边界: - 下一个 `**3. 标题**` 这类强编号标题 - 独占一行的普通 strong 小标题(例如 `**方法一:...**`) - 新的 section 边界(如 `#` / `##` / `
      `) 4. 吸收 block 时,**保留原 block 结构**,不要只拼接纯文本: - 普通段落继续保留为段落 - `- ...` 保留为 item 内部 bullet list - 代码块继续保留为 item 内部 code block - 列表后的补充说明继续保留为 item 内的独立段落 这意味着: - 对 `2. 微信公众号凭证`,后面的说明 + bullets + IP 白名单说明,都应继续属于同一个 ordered item - 但对 `3. 封面图的 media_id`,遇到 `**方法一:...**` 时必须停止吸收,让方法标题和后续步骤重新成为 item 外的独立块 ### 建议新增/更新的回归测试 除了“标题 + 下一行正文”的测试,还应覆盖: 1. **编号项内部 bullets 不掉出 item** - 输入:`**2. 微信公众号凭证**` + 段落 + bullet 列表 + 补充说明 - 断言:normalize 后 bullets 和补充说明仍缩进在同一个 `2.` item 内 2. **编号项遇到独立 strong 小标题时及时断开** - 输入:`**3. 封面图的 media_id**` 后面跟 `**方法一:...**` - 断言:`方法一` 不会被吞进 `3.` item 的正文段里 3. **最终 HTML 中 2/3 两个编号项连续且不重复错号** - 断言:`2. 微信公众号凭证` 与 `3. 封面图的 media_id` 都在同一 ordered list 体系内 - 断言:`2.` 项内部包含 bullets 与补充说明 - 断言:`3.` 项之后的方法标题和方法步骤是编号项外的独立块 ### 一句话经验 飞书抓回来的文本“看起来有结构”,不代表 Markdown 语义已经完整;而且就算 normalize 修对了,renderer 也可能把结构再次压扁。发布到公众号前,必须同时检查:**语义结构有没有补对,渲染后处理有没有把它破坏掉。对于编号项,既要避免把后续内容吞过头,也要避免只收标题不收其内部 bullets / 补充说明。** ## 新增经验:嵌套无序列表不能再用正则硬拆 `
      • ` 这次真实发布又踩到一个 renderer 层面的坑: - 飞书文档里明明是有层级的无序列表,例如: - `- Hermes:` - `- 模型:...` - `- 宿主:...` - 但发布到公众号后,子列表被压坏成: - 外层 bullet 里直接夹着残缺的 `
          ` / `
        • ` - HTML 结构错位 - 视觉上就变成“父级 bullet 和子级 bullet 混成一坨” 根因不是 normalize,而是 renderer 旧逻辑对无序列表做了这种事: - 用正则匹配整个 `
            (...)
          ` - 再用正则找内部 `
        • (...)
        • ` - 这种写法对**嵌套列表**天然不可靠,因为正则不懂树结构,遇到子级 `
            ` 时会在第一个 `` / `
          ` 处把层级拆坏 稳妥做法: - 对无序列表改成**树形解析**,不要再用正则硬拆 HTML - 例如先把片段包成 root,再用 XML/HTML 树遍历 - 只对 `ul.md-ul` 做递归重写 - 每个 bullet item 用 block 容器承载正文和子列表,别再用只适合单段文本的 `

          ` 包整个 item 这次实际修复采用的是: - `xml.etree.ElementTree` 解析当前 HTML 片段 - 递归重写 `ul.md-ul` - `md-bullet-item` 改成 block 级 `

          ` - `md-bullet-text` 允许继续包住下一层 `md-list md-list-unordered` 建议补的回归测试至少包括: 1. 输入一段二级无序列表 markdown 2. 断言输出里: - 至少出现两层 `md-list md-list-unordered` - 不再残留原始 `
            ` - 不再残留原始 `
          • ` - 父级文本和子级列表都同时保留 一句话记忆:**无序列表一旦有嵌套,就必须按树处理;用正则拆 `
              /
            • ` 迟早会炸。**