dsh-convert-core
v0.1.0-alpha.9
Published
Standalone conversion kernel: file / url (web page, WeChat article) / video / text to knowledge-ready Markdown + local assets, with an HTTP API and a React console built on @tita-agent/ui. The local media pipeline (audio/video/image → Markdown, subtitles
Readme
dsh-convert-core
转换内核:把 Office / OpenDocument / RTF / EPUB / CSV / 文本层 PDF、音视频与图片、
混合层 PDF、另存下来的网页(含微信公众号文章) 转成 知识就绪 Markdown + 本地图片,
自带 HTTP API 和一个 React(@tita-agent/ui)可视化控制台。
它可以独立运行,不依赖 DSH。
单一职责:只做转换,不联网取内容。 输入是磁盘上的一份文件(或一段内联文本),输出是
Markdown。需要浏览器才能读的页面(风控、验证码、JS 渲染、登录态)由宿主用自己的浏览器读下来
另存为 .html,再按文件交给内核 —— 内核没有「粘一个网址让它去抓」这条路。
npm install dsh-convert-core
npx tita-convert --port 8787
# → http://127.0.0.1:8787/DSH 侧只是它的再封装:dsh-data-domain-knowledge 把本包当 optionalDependency 懒加载,
设置项、Agent 工具与 /convert 路由在那边,解析逻辑全在这里。
引擎:@firecrawl/anydoc
解析全部交给 @firecrawl/anydoc(MIT,Rust 内核,
官方 Node/Python/WASM binding,本机用 @firecrawl/anydoc-darwin-arm64 预编译二进制)。
- 一个依赖覆盖 14 种格式:doc/docx/docm、ppt/pps/pot/pptx/pptm/ppsx/ppsm、 xls/xlsx/xlsm/xlsb、odt/ods/odp、rtf、epub、csv、pdf
- 按内容识别格式(PDF 头、RTF 开组、OLE 流名、ZIP mimetype),改错扩展名也能转
- 没有 Python、没有 torch、没有模型下载、没有外部二进制,中位数 4.4ms/文档
- 结构保真:标题层级、
w:numPr列表、合并单元格表格、脚注、公式转 LaTeX - 错误码精确:
unsupported / needsOcr / malformed / encrypted / resourceLimit / missingPart / io, 内核原样透出(前缀ANYDOC_)并附上可操作建议
唯一的胶水:内嵌图片
Markdown 无法内嵌字节,所以 anydoc 把内嵌图片渲染成它的 alt 文本,字节留在
document.assets 上(带 mediaType 和 originPart)。而知识召回只认本地相对路径的图片,
所以 src/adapters/anydoc.ts 做了三件事:
- 遍历
document.blocks找{ kind: 'image', source: { kind: 'asset', assetId } },按文档顺序取出图片; - 按内容哈希写成
assets/img-<sha1_12>.<ext>; - 把正文里那个 alt 行换成
;找不到唯一占位时追加到文末## 图片小节并记 warning,而不是猜位置。
非 image/* 的内嵌对象不落地,但计数并写 warning,不静默丢弃。
分层约定
| 层 | 负责 | 不负责 |
| --- | --- | --- |
| 内核(本包) | 解析、任务队列、进度、取消、交付目录、控制台 | 知识库、DSH 工具、用户审批 |
| DSH 插件 | 设置、Agent 工具、/convert 挂载、入库接缝 | 任何解析逻辑 |
内核提供两种集成形态,同一份实现:
const running = await startConvertServer({ port: 8787 }) // 独立运行
const service = await createConvertService({ rootDir }) // 嵌入宿主
const handler = createRequestHandler({ service, basePath: '/convert' })产物契约
<outRoot>/<YYYY-MM-DD>/<中文名>/ # 日期层由 bucketByDay 打开;叶子名见 deliveryDirName()
index.md front matter + 正文,图片引用为 assets/img-<sha1_12>.<ext>
assets/ 内容寻址的本地图片
source/ 原始输入(0048):抓下来的 HTML、上传的那份文件
manifest.json 来源、引擎与格式、warnings、content_hash、ingest 负载交付目录是用户会打开的东西,所以叶子名是文档的中文名(output/2026-10-02/月度销售汇总/),
不是 conv_<时间戳>_<随机>:名字取标题,没有标题就用文件名(去掉扩展名),最多 25 个字,
同一天同名让路成 -2、-3。让路结果记在任务的 deliveryName 上,所以重试与重启之后算出来的
都是同一个目录;那份记录早于这个字段时回退到 task id。工作目录不跟着改名(仍按 task id):
工地只要唯一,成品的位置要稳定。
source/ 是底稿,不是资产(0048)。抽取是有损的(正文外的元素、内嵌音视频都不在
产物里),而工作目录在交付成功那一刻就被删掉 —— 不留底稿,事后想换个抽取规则重来一遍就
没有素材了。网页那档由适配器交出真正被解析的那份 HTML(就是宿主交进来的那份 .html);
其他档由内核把暂存输入复制过来。manifest.original 记它的
rel / bytes / sha256,manifest.ingest.original 让知识层也能记住它。内联文本没有独立
底稿(index.md 就是它),所以不写这个键,而不是造一个空的。
manifest.document 记文档自身的元数据(作者、发布时间、站点、描述)—— 适配器能拿到
多少写多少,拿不到就不写。它一路走到 SourceRaw 的 front matter(OKF v0.2 的
description 与 sources[])。
front matter 与知识录入契约对齐:title / created_at / updated_at 必填,
external_id / source_format / content_hash / parser / source_path 由内核如实填写。
范围
文件类:14 种文档格式(含文本层 PDF)、本地音视频与图片、扫描件轻量 OCR、另存的页面(.html/.htm)。
还没做:内核内的浏览器档(Playwright / CDP)、在线视频字幕/转写、版面/表格/公式级的高质量扫描档
(docling / MinerU)。
抓取不在本包范围(原 kind=url 已删除)。需要真浏览器才能读的页面(公众号重复访问会跳
验证码页这类风控墙)本来就不是静态抓取能解决的,而宿主手里有真浏览器 —— 让它读下来另存为
.html,再按文件提交。本档会从 og:url / canonical 把原始地址读回来,产物与直抓一致。
Agent 侧的同一件事已经写成 skill:browser-page-to-markdown —— 用 Kimi WebBridge 驱动
用户的真实浏览器读一次页面、把渲染后的 HTML 落到本地,再交给本档(kind=file 的 .html)。
一个实测过的细节:把浏览器拿回来的 poc_token 拼回裸 fetch 没有用(仍 302),
凭证是绑会话的 —— 所以路线是「浏览器读、本档转」,不是「让直抓借用浏览器的凭证」。
needsOcr 不再是一条死路:.pdf 先走 anydoc(文本层质量最好、零依赖),它报 needsOcr
时自动改走 scanned 档,任务消息与 manifest.warnings 里写明换了什么(ADAPTER_FALLBACK),
产物里另有 SCANNED_TIER 说明这一档不做版面还原。判据在 resolveFallbackAdapter()
(src/adapters/index.ts),换档只发生一次。特别地,不使用 anydoc 的 ocr: 'hosted'
—— 它会把整份文档发往 Firecrawl Parse 云端,与本包「本地优先」的取舍冲突。
适配器
| 输入 | 适配器 | 说明 |
| --- | --- | --- |
| 14 种文档格式(含文本层 PDF) | anydoc | 见上;内嵌图片落地到 assets/ |
| 另存下来的网页 / 微信公众号文章(kind=file 的 .html/.htm) | web | 能力来自内核自带的网页管线 src/web/:读入一份已在手上的 HTML → 正文抽取(公众号走 #js_content,其他站点走 Readability)→ 图片按内容 sha1 落地 assets/ → turndown 出 GFM Markdown。不抓页面、不跟进链接(唯一的网络请求是正文图片);正文图片引用一律改成 assets/…,远程地址不会留在产物里。原始地址从页面自带的 og:url / canonical 读回来(相对地址、source_url、幂等身份都靠它) |
| 音视频(.mp4 .mov .mkv .webm …、.mp3 .m4a .wav …)与图片(.png .jpg .webp …) | iddoc | 能力来自内核自带的本地媒体管线 src/media/:字幕优先,无字幕时才本地转写(默认不下载模型,IDDOC_ASR=1 才允许);图片默认只收录不做 OCR;视频关键帧(均匀采样 + 场景变化 + 内容去重)落地到 assets/ |
| 扫描件 / 纯图片页 PDF | scanned | 同一包的 convertScannedDocument():pypdfium2 逐页渲染 + 本地 RapidOCR 识别,page-0001.png … 落地为 assets。默认作为 anydoc 报 needsOcr 时的自动兜底;SCANNED_OCR=1 是「跳过 anydoc 直接走 OCR」的显式出口。不做版面还原 |
| .md .markdown .txt .json .tsv .yml .log .adoc .rst .org .tex | text | 直通 + 契约归一化(重写 front matter)|
未登记扩展名只有在字节确实是文本(无 NUL、UTF-8 可解码)时才走 text,
否则返回 FORMAT_UNSUPPORTED 并列出支持的格式。
iddoc 与 scanned 是需要外部依赖的两个适配器:iddoc 要 ffmpeg/ffprobe
(转写/OCR 档还要 uvx),scanned 只要 uvx。web 不需要外部二进制,但需要网络。
缺依赖时按既有约定返回 DEPENDENCY_MISSING + 安装命令,能力上报里如实写
available: false,不静默降级。
API
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | /api/health | 服务与交付根目录 |
| GET | /api/capabilities | 适配器可用性、引擎版本、支持的扩展名 |
| POST | /api/tasks | 受理任务:JSON(text / url / local_path)、原始字节、或 multipart/form-data |
| GET | /api/tasks | 任务列表 |
| GET | /api/tasks/:id | 任务详情(含 ingest 负载) |
| POST | /api/tasks/:id/cancel | 取消(删除半成品交付) |
| POST | /api/tasks/:id/retry | 重试(复用已落盘的输入) |
| GET | /api/tasks/:id/markdown | 交付的 index.md |
| GET | /api/tasks/:id/files/<rel> | 交付目录内任意文件(拒绝路径穿越) |
| GET | /api/events | SSE 任务事件流 |
# 内联文本
curl -X POST localhost:8787/api/tasks -H 'content-type: application/json' \
-d '{"kind":"text","title":"标题","text":"# 内容"}'
# 本机文件(内核负责读取、限流、清理)
curl -X POST localhost:8787/api/tasks -H 'content-type: application/json' \
-d '{"kind":"file","local_path":"/abs/path/spec.docx"}'
# 网页 / 微信公众号文章(只抓这一个地址)
curl -X POST localhost:8787/api/tasks -H 'content-type: application/json' \
-d '{"kind":"url","url":"https://mp.weixin.qq.com/s?__biz=..."}'
# 上传
curl -X POST "localhost:8787/api/tasks?kind=file" -F "[email protected]"控制台
web/index.html + web/app.js + web/styles.css,由 build-console.mjs 用 esbuild
从 console/ 打出来。界面是 React + @tita-agent/ui(仓库共享的 shadcn 底座),
样式表直接拷 packages/ui 编译出的那一份 —— 控制台和三个插件穿同一套视觉,不再各造一套。
运行时仍然零构建:内核只负责把 web/ 下这三个静态文件发出去,npx tita-convert
不需要打包器;打包器只在构建期存在,产物自包含。代价是改控制台的类名之后要重编
packages/ui(@source 清单在那里),这也是三个插件一直以来的做法。
界面结构是两个视图(右上角切换):
- 转换 —— 一个输入框收文件(拖进来,或点「浏览文件」)、 依赖项清单(ffmpeg / anydoc / uvx / readability / whisper,名字 + 版本 + 它负责什么)、 支持的格式(按谁负责它分组,共 72 种)。
- 任务列表 —— 四列的表:标题(第二行是它出了什么事)| 状态 | 时间 | 操作。 失败的出路是行内一枚按钮,完整建议收进展开的「说明」活页。停下来的一行可以删除 (记录 + 交付目录一起删,删前确认)。
设计取舍
- 本地优先:全链路在本机完成,不调用任何云端解析;引擎没有网络代码。
- 图片必须落地:见上「唯一的胶水」。
- 可取消:任务用
AbortController,取消后删除半成品交付目录。 - 进程重启可恢复:任务状态落
tasks.json;重启时把running/queued标为INTERRUPTED_BY_RESTART并保留已落盘的输入,重试不必重来。 - 不伪造成功:引擎的错误码原样透出,
needsOcr/encrypted/resourceLimit各自带建议,没有「转成功但内容为空」的分支。
