Upload Markdown to Lexiang
这是 Markdown → 乐享在线文档的唯一公共上传层。抓取、翻译、目录策略、增量同步等业务逻辑应留在上层 Skill。
可移植 Markdown 中的目标专有标注解析与乐享块渲染属于本 Skill,不由调用方上传后修补。
硬性规则
- 上传代码只维护在本 Skill;禁止复制到调用方。
- 只使用从 <https://lexiangla.com/ai/claw> 获取的个人凭证。
- 禁止读取 Agent MCP 配置、连接器 OAuth token 或扫描本地代理。
- 创建或修改页面前完成全部本地预检,写入后必须线上对账。
- stdout 只输出 JSON/页面 URL,诊断和进度输出到 stderr。
定位本 Skill
调用方不得写死某个 Agent 的安装目录。按以下顺序定位:
- 环境变量
LEXIANG_UPLOADER_HOME。 - 当前上层 Skill 所在
skills根目录的同级 Skill:
<skills-root>/upload-markdown-to-lexiang。 - 当前 Agent 暴露的已安装 Skill 列表。
- 当前平台的 Skill 管理器默认安装目录。
用户 ajaxhe 的本地开发默认目录是
~/.workbuddy/skills/upload-markdown-to-lexiang,这不是跨用户公共契约。
定位后调用:
python3 "<skill-root>/scripts/lexiang_upload.py" --version
上层 Skill 必须要求 cli_api == "1",不得 import 内部 Python 模块。
首次配置个人凭证
> 2026-09-22(v1.4.0):乐享 MCP 服务端已切换为网关模式——tools/list
> 只暴露 call_tool/get_tool_schema/lexiang_fetch/lexiang_search/
> list_tool_categories/search_tools/whoami 7 个元工具,业务工具
> (entry_*/block_*)直呼 tools/call 会被拒:tool is not allowed:
> <tool>。本 Skill 的 MCPClient._request 已内置自动回退:直呼失败且报
> tool is not allowed 时,改经 call_tool 元工具以
> {"tool_name": <tool>, "arguments": {...}} 转发,业务返回结构不变。
> 旧版直连服务端(直呼成功)行为不变。
页面提供的凭证是 lxmcp_ 前缀 MCP API Token 和 company_from。本 Skill
直接使用 Authorization: Bearer <lxmcp_token>,不做 OAuth token 交换。
python3 "<skill-root>/scripts/lexiang_upload.py" auth login
python3 "<skill-root>/scripts/lexiang_upload.py" auth status --check
凭证默认保存在 ~/.config/lexiang-upload/credentials.json,权限为 0600,
不会写进 Skill 或 Git 仓库。
多账号可使用命名 profile,保存到
~/.config/lexiang-upload/profiles/<profile>.json:
python3 "<skill-root>/scripts/lexiang_upload.py" auth login \
--profile obsidian-sync
python3 "<skill-root>/scripts/lexiang_upload.py" auth status \
--profile obsidian-sync --check
python3 "<skill-root>/scripts/lexiang_upload.py" upload article.md \
--profile obsidian-sync --parent-id "<PARENT_ID>" --json
也可用 --credential-file PATH 指定凭证保存/读取位置。auth login --file PATH
表示从导出文件读取凭证内容,不是保存目标。选择优先级为:
--credential-file、--profile、LEXIANG_UPLOAD_CREDENTIALS、
LEXIANG_UPLOAD_PROFILE、default;default 始终对应原有 credentials.json。
> ⚠️ 上传前核对凭证租户(2026-09-15 坑):default credentials.json 的
> company_from 可能不是目标知识库所在租户(例如被外链分享租户
> e6c565d6d16811efac17768586f8a025 覆盖)。租户不匹配时父目录会报
> code 71 不存在ID为...的kb_entry资源。遇到 code 71 先检查凭证
> company_from;ajaxhe 个人知识库(csig 租户)上传固定使用
> --profile csig。
上传
创建新页面:
python3 "<skill-root>/scripts/lexiang_upload.py" upload article.md \
--parent-id "<PARENT_ID>" --name "标题" --json
覆盖已有页面:
python3 "<skill-root>/scripts/lexiang_upload.py" upload article.md \
--entry-id "<ENTRY_ID>" --json
只做预检:
python3 "<skill-root>/scripts/lexiang_upload.py" upload article.md \
--parent-id "<PARENT_ID>" --dry-run --json
调用方职责
- 调用方只负责确定
parent_id、entry_id和归档策略(标题、置顶、来源等)。 - 本 Skill 负责 Markdown 解析、本地图片、公式转换、目标专有标注渲染、页面写入和线上对账。
- 本地图片路径支持
%20、未转义空格及<images/file name.png>形式;所有识别到的
本地图片都必须进入预检与线上数量对账。 > [!stat]、> [!definition]与> [!note]连续 blockquote 由本 Skill分别渲染为
📊/📖/💡 乐享 callout;note可承载文章亮点列表。调用方禁止二次 MCP 修补。
普通 blockquote 不受影响。- 非 Markdown 独立附件、视频和音频不属于本 Skill。
页面写入不变量(重要)
execute_plan 写入前,entry 必须为空(新建页初始 0 block;覆盖已有页时先
clear_existing_page)。因此所有片段(含首段)统一走
block_convert_content_to_blocks → block_create_block_descendant 逐块写入,
不再对首段使用 block_update_page 的 replace_content。
原因(2026-07-16 修复):replace_content 对含多行引用块等结构的首段,会触发
乐享服务端 round-trip 结构校验失败(报
replacement content is structurally unstable after round trip),且此时页面
已被 clear 清空 → 留下空壳 entry。改为 create_descendant 后同一内容可稳定写入,
线上对账 verified=true。修改此处务必保持「写入前页面为空」这一前置条件。
乐享平台硬限制(预检已覆盖)
| 限制 | 表现 | 本 Skill 的处理 |
|---|---|---|
| 单表列数 ≤ 10 | 超限时服务端报 column_size: ... less than or equal to 10(code 51),此时页面已被 clear 清空,留下半成品 entry |
check_table_columns 在预检阶段直接报错,要求调用方按列分组拆表(每张表保留第 0 列作行标签列) |
| 单表行数 ≤ 10 | 平台自动分页 | split_large_gfm_tables 主动按 10 行切分并重复表头 |
| 同站外链被改写 | [标题](https://lexiangla.com/pages/xxx) 在线上变成 <mention-page url="/pages/xxx">xxx</mention-page>:链接文字替换为条目 id、URL 变成相对路径 |
源文链接行不参与长锚点对账(见下) |
调用方遇到「列数超限」时不要指望上传层自动拆表:按列拆表会改变文档结构,
分组边界必须由了解表格语义的上层 Skill 决定。
线上对账锚点(重要)
verify_remote 用「长段落锚点」做线上对账:从源 Markdown 每行提取纯文本
(≥80 字),normalize_for_compare 归一后检查是否为远程 clean 文本的子串。
源文链接行不作为锚点(2026-09-15 修复):prepend_source_link 注入的
**原文链接**:[标题](url) 行,在线上会被乐享规范化成页内引用节点
(<mention-page url="/pages/<id>">),链接文字被替换成条目 id、URL 变成相对路径,
因此该行纯文本(含原始 URL)永远无法命中远程文本,会让每次上传都稳定误报
「缺少长段落锚点 1/N」。_anchors 用 _SOURCE_LINK_LINE_RE 跳过这一行。
围栏代码块内容不作为锚点(2026-09-22 修复):围栏(含 mermaid)内
的长行不参与对账。代码块内的 HTML 标签(如 mermaid 换行 `<br/>`)在源文侧
被 `_plain_text` 整体删除、在远端 clean 文本中却保留字面 "br" 字母
(`normalize_for_compare` 不剥 HTML 标签),子串匹配必然失败。实测含时序图
的文档 6 个锚点稳定误报缺失 1 个。`_anchors` 以 行开关 in_fence 状态,
跳过围栏内的所有行。
列表项锚点必须去掉行首列表标记(2026-07-20 修复):乐享 round-trip 会把
有序列表序号重编号(如 1. 2. 3. 全部变成 1. 1. 1.),序号本身不是稳定
锚点。_anchors 在提取前用 _LIST_PREFIX_RE 去掉 数字. / 数字) / - /
* / + 行首标记,使锚点只依赖正文内容。修改此处时勿重新引入对列表序号的
依赖,否则序号 ≥2 的列表项会在远程文本中找不到子串匹配而误判缺失
(报 缺少长段落锚点)。
锚点比对忽略 URL 本体(2026-09-24 修复):normalize_for_compare 先解开
markdown 链接/图片语法(只留显示文字),再把 http(s)://… 的 URL 本体整体删掉。
原因是同一个 URL 在两侧的呈现可能不同:源文里是裸 URL(出现 1 次),乐享 clean
文本会把它渲染成 [url](url)(出现 2 次);反之源文写作 [文字](url) 时
_plain_text 会保留 URL,而线上可能只留显示文字。任一侧不一致,子串匹配就必然
失败——表现为每次上传都 VERIFY_ERROR: 缺少长段落锚点 n/N,且因 verified≠true
不进 manifest,同一篇文档会被无限重传。实测案例:一篇正文含
访问 knot:https://knot.woa.com/... 的文章稳定误报。段落完整性改由正文文字
保证;归一化后长度 < 10 的锚点(整段几乎只有 URL)直接跳过,不误报缺失。
正文里的尖括号与强调符不是稳定锚点(2026-09-28、2026-10-08 两次复现):
源文普通段落中的 <...>(如「应用 <客户端标识>」)在线上会被当 HTML 标签丢弃,
成对下划线(如 server._sign(sid)、device_code)会被当强调符解析,两者都会让该段
的远程文本与源文不再一致,稳定报 缺少长段落锚点 1/N。围栏代码块已整体跳过对账
(见上),但行内代码跨度与普通正文仍参与。写作约定:含 <> 或 _ 的命令行、
函数签名、字段名,要么放进围栏代码块,要么在正文中改用不含这两类字符的自然语言
(如「设备码」而非 device_code)。修好后用 --entry-id 覆盖即可,无需重建条目。
完整参数、JSON 输出和退出码见 references/cli-contract.md。
修改与验证
发现公共上传问题时,只修改本仓库并运行:
python3 -m unittest discover -s tests -v
兼容改动升级次版本;破坏 CLI 契约时升级主版本和 cli_api。