Compare commits
6 Commits
99927c2263
...
04a22ccbf6
| Author | SHA1 | Date | |
|---|---|---|---|
| 04a22ccbf6 | |||
| 9f04dac50b | |||
| 9f686270c2 | |||
| a21f5063c8 | |||
| c693e23888 | |||
| 856700fbe0 |
@@ -2,7 +2,18 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(uv:*)",
|
||||
"WebSearch"
|
||||
"WebSearch",
|
||||
"WebFetch(domain:pypi.org)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(pip index:*)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(openspec status:*)",
|
||||
"Bash(openspec instructions proposal:*)",
|
||||
"Bash(openspec instructions design:*)",
|
||||
"Bash(openspec instructions specs:*)",
|
||||
"Bash(openspec instructions tasks:*)",
|
||||
"Bash(openspec new:*)",
|
||||
"Bash(openspec instructions apply:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
55
commands/lyxy-kb/ask.md
Normal file
55
commands/lyxy-kb/ask.md
Normal file
@@ -0,0 +1,55 @@
|
||||
基于知识库项目进行问答。
|
||||
|
||||
**输入**: `/lyxy-kb-ask` 后的参数为项目名称,可选附带问题。例如:
|
||||
- `/lyxy-kb-ask my-project` — 进入问答模式
|
||||
- `/lyxy-kb-ask my-project 这个系统用了什么技术栈?` — 直接提问
|
||||
|
||||
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill,了解渐进式查询策略和来源引用格式。
|
||||
|
||||
**步骤**
|
||||
|
||||
1. **获取项目名称并验证结构**
|
||||
|
||||
从参数中获取项目名称。如果未提供参数,提示用户输入。
|
||||
|
||||
按照 lyxy-kb skill「结构完整性验证」规则检查项目目录,不完整则提示用户先 init。
|
||||
|
||||
2. **加载项目摘要**
|
||||
|
||||
读取 `<project-name>/project.md`,获取项目概述和文件索引。
|
||||
|
||||
按照 lyxy-kb skill「空知识库」规则,如果文件索引为空(尚无已入库文件),告知用户知识库为空,建议先使用 `/lyxy-kb-ingest <project-name>` 入库文档,终止操作。
|
||||
|
||||
3. **进入问答模式**
|
||||
|
||||
如果用户在参数中附带了问题,直接回答该问题。否则提示用户可以开始提问。
|
||||
|
||||
**对每个问题,按照 lyxy-kb skill「渐进式查询策略」执行**:
|
||||
|
||||
**a) 分析问题与文件索引的关联**
|
||||
|
||||
根据用户的问题内容,对照 project.md 文件索引表中各文件的摘要,判断需要查阅哪些 parsed 文件。
|
||||
|
||||
**b) 按需加载 parsed 文件**
|
||||
|
||||
读取相关的 `<project-name>/parsed/<文件名>.md` 文件。如果文件较大,可以先提取标题结构,再读取相关章节。
|
||||
|
||||
**c) 回答并标注来源**
|
||||
|
||||
基于获取的信息回答问题。按照 lyxy-kb skill「来源引用格式」标注来源:
|
||||
|
||||
```
|
||||
根据《文件名》(parsed/文件名.md),...
|
||||
```
|
||||
|
||||
如果回答综合了多个文件的信息,分别标注各信息点的来源。
|
||||
|
||||
**d) 无相关信息处理**
|
||||
|
||||
按照 lyxy-kb skill「无相关信息」规则,明确告知用户当前知识库中未找到相关信息,不编造答案。
|
||||
|
||||
4. **保持会话上下文**
|
||||
|
||||
回答完成后,保持当前的知识库上下文。用户可以继续提问,无需每次重新加载 project.md。已加载的 parsed 文件内容可在后续问答中复用。
|
||||
|
||||
会话的退出由用户自然决定(开启新话题或新会话),不主动终止问答模式。
|
||||
65
commands/lyxy-kb/ingest.md
Normal file
65
commands/lyxy-kb/ingest.md
Normal file
@@ -0,0 +1,65 @@
|
||||
解析 sources/ 中的新文件并增量更新知识库。
|
||||
|
||||
**输入**: `/lyxy-kb-ingest` 后的参数为项目名称。
|
||||
|
||||
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill,了解知识库的完整规范。
|
||||
|
||||
**步骤**
|
||||
|
||||
1. **获取项目名称并验证结构**
|
||||
|
||||
从参数中获取项目名称。如果未提供参数,提示用户输入。
|
||||
|
||||
按照 lyxy-kb skill「结构完整性验证」规则检查项目目录,不完整则提示用户先 init。
|
||||
|
||||
2. **检查 office 文档解析能力**
|
||||
|
||||
按照 lyxy-kb skill「Office 文档解析」规则,查找当前环境中名为 **lyxy-reader-office** 的 skill。如果不存在且无其他可替代的文档解析 skill,则提示用户无法处理 office 文档并中止流程。
|
||||
|
||||
3. **读取 manifest.json**
|
||||
|
||||
读取 `<project-name>/manifest.json`,获取已入库文件的信息。
|
||||
|
||||
4. **递归扫描 sources/ 目录**
|
||||
|
||||
按照 lyxy-kb skill「sources/ 扫描规则」,递归检查 sources/ 及其所有子目录中的文件。如果无任何文件,提示用户无待处理文件并终止。
|
||||
|
||||
5. **预检查**
|
||||
|
||||
**空文件检测**:按照 lyxy-kb skill「空文件处理」规则,识别 0 字节文件,标记为跳过。
|
||||
|
||||
**同名不同扩展名冲突检测**:按照 lyxy-kb skill「同名不同扩展名冲突检测」中的两条检测规则执行。冲突文件标记为跳过。
|
||||
|
||||
如果有跳过的文件,列出详情(空文件 / 冲突文件分别列出)。如果所有文件都被跳过,终止流程。
|
||||
|
||||
6. **逐个处理文件**
|
||||
|
||||
对每个通过预检查的文件:
|
||||
|
||||
**a) 解析**:按照 lyxy-kb skill「文件类型解析策略」判断解析方式。office 文档使用 lyxy-reader-office skill(查找并阅读该 skill 获取具体命令),其他文件直接读取。
|
||||
|
||||
**b) 写入 parsed**:按照 lyxy-kb skill「parsed 文件元信息标记」格式,在内容头部添加元信息注释,写入 `<project-name>/parsed/<文件名>.md`(同名覆盖)。
|
||||
|
||||
**c) 归档**:按照 lyxy-kb skill「归档命名规则」,移动原始文件到 archive/(带时间戳后缀 `YYYYMMDDHHmm`)。
|
||||
|
||||
**d) 更新 manifest.json**:新文件追加条目,已有文件在 versions 数组追加新版本。使用 `sha256sum` 计算文件哈希。更新 `last_ingest`。
|
||||
|
||||
**e) 解析失败处理**:按照 lyxy-kb skill「解析失败处理」规则,失败文件保留在 sources/ 中不移动,报告错误,继续处理下一个文件。
|
||||
|
||||
7. **增量更新 project.md**
|
||||
|
||||
按照 lyxy-kb skill「增量追加」策略:
|
||||
- 对每个新处理的文件,读取其 parsed 内容生成简要摘要(1-2 句话)
|
||||
- 新文件:在文件索引表追加新行
|
||||
- 已有文件更新:更新文件索引表中对应行
|
||||
- 在更新记录追加本次 ingest 条目
|
||||
- 不修改概述和关键信息部分
|
||||
|
||||
8. **输出结果**
|
||||
|
||||
汇总显示:
|
||||
- 成功处理的文件列表
|
||||
- 跳过的文件(空文件 / 冲突文件 / 解析失败文件,分别列出)
|
||||
- 当前项目已入库文件总数
|
||||
- 提示可使用 `/lyxy-kb-rebuild <project-name>` 更新概述和关键信息
|
||||
- 提示可使用 `/lyxy-kb-ask <project-name>` 进行知识问答
|
||||
67
commands/lyxy-kb/init.md
Normal file
67
commands/lyxy-kb/init.md
Normal file
@@ -0,0 +1,67 @@
|
||||
初始化一个知识库项目。
|
||||
|
||||
**输入**: `/lyxy-kb-init` 后的参数为项目名称。
|
||||
|
||||
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill,了解知识库的目录结构规范、项目名称规则和 project.md 格式规范。
|
||||
|
||||
**步骤**
|
||||
|
||||
1. **获取项目名称**
|
||||
|
||||
从参数中获取项目名称。如果未提供参数,提示用户输入项目名称。
|
||||
|
||||
2. **验证项目名称**
|
||||
|
||||
按照 lyxy-kb skill 中的「项目名称规则」验证名称是否合法(只允许中文、英文、数字、短横线、下划线,不允许空格和其他特殊字符)。不合法时提示用户修改。
|
||||
|
||||
3. **检查目标目录是否已存在**
|
||||
|
||||
检查 CWD 下是否已存在同名目录。如果目录已存在,提示用户该目录已存在,不覆盖任何现有内容,终止操作。
|
||||
|
||||
4. **创建目录结构**
|
||||
|
||||
```bash
|
||||
mkdir -p <project-name>/parsed <project-name>/sources <project-name>/archive
|
||||
```
|
||||
|
||||
5. **创建 project.md**
|
||||
|
||||
按照 lyxy-kb skill 中定义的「project.md 格式规范」,生成初始内容:
|
||||
|
||||
```markdown
|
||||
# <项目名称>
|
||||
|
||||
## 概述
|
||||
|
||||
(待补充)
|
||||
|
||||
## 关键信息
|
||||
|
||||
(待补充)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|
||||
|--------|----------|----------|------|
|
||||
|
||||
## 更新记录
|
||||
- <YYYY-MM-DD HH:mm>: 初始化项目
|
||||
```
|
||||
|
||||
6. **创建 manifest.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<项目名称>",
|
||||
"created_at": "<当前时间 ISO 格式>",
|
||||
"last_ingest": null,
|
||||
"files": []
|
||||
}
|
||||
```
|
||||
|
||||
7. **输出结果**
|
||||
|
||||
提示用户:
|
||||
- 项目已创建,显示完整的目录结构
|
||||
- 引导用户将文档放入 `<project-name>/sources/` 目录
|
||||
- 提示使用 `/lyxy-kb-ingest <project-name>` 解析入库
|
||||
55
commands/lyxy-kb/rebuild.md
Normal file
55
commands/lyxy-kb/rebuild.md
Normal file
@@ -0,0 +1,55 @@
|
||||
全量重新生成 project.md。
|
||||
|
||||
**输入**: `/lyxy-kb-rebuild` 后的参数为项目名称。
|
||||
|
||||
**前置条件**: 查找并阅读名为 **lyxy-kb** 的 skill,了解 project.md 格式规范和全量重写策略。
|
||||
|
||||
**步骤**
|
||||
|
||||
1. **获取项目名称并验证结构**
|
||||
|
||||
从参数中获取项目名称。如果未提供参数,提示用户输入。
|
||||
|
||||
按照 lyxy-kb skill「结构完整性验证」规则检查项目目录,不完整则提示用户先 init。
|
||||
|
||||
2. **检查 parsed 目录**
|
||||
|
||||
列出 `<project-name>/parsed/` 下的所有 `.md` 文件。如果为空,提示用户尚无已解析文件,建议先执行 `/lyxy-kb-ingest <project-name>`。
|
||||
|
||||
3. **检查 sources/ 待处理文件**
|
||||
|
||||
检查 `<project-name>/sources/` 中是否还有未 ingest 的文件。如果有,提醒用户 sources/ 中存在未入库文件,rebuild 将仅基于已有的 parsed 文件生成,建议先执行 ingest。
|
||||
|
||||
4. **确认操作**
|
||||
|
||||
向用户说明 rebuild 将覆盖当前 project.md 的概述、关键信息和文件索引(更新记录会保留),请求用户确认是否继续。用户确认后再执行。
|
||||
|
||||
5. **读取所有 parsed 文件**
|
||||
|
||||
逐个读取 `<project-name>/parsed/` 下的所有 `.md` 文件内容。
|
||||
|
||||
6. **读取 manifest.json**
|
||||
|
||||
读取 `<project-name>/manifest.json`,获取文件元信息(用于生成文件索引表中的归档路径等信息)。
|
||||
|
||||
7. **读取现有更新记录**
|
||||
|
||||
读取当前 `<project-name>/project.md`,提取 `## 更新记录` 部分的内容以保留历史记录。
|
||||
|
||||
8. **全量重新生成 project.md**
|
||||
|
||||
按照 lyxy-kb skill「全量重写」策略和 project.md 格式规范,基于所有 parsed 文件内容重新生成:
|
||||
|
||||
- **概述**:基于所有文件内容,生成高度总结的项目信息(几百字以内)
|
||||
- **关键信息**:从所有文档中提炼核心要点
|
||||
- **文件索引**:基于 manifest.json 和 parsed 文件,重新生成完整索引表(文件名、解析文件路径、最新归档路径、简要摘要)
|
||||
- **更新记录**:保留历史记录,追加本次 rebuild 条目,格式:`- <YYYY-MM-DD HH:mm>: 全量重建 project.md`
|
||||
|
||||
将生成的内容写入 `<project-name>/project.md`,覆盖原有内容。
|
||||
|
||||
9. **输出结果**
|
||||
|
||||
提示用户:
|
||||
- project.md 已全量重建
|
||||
- 显示处理的文件数量
|
||||
- 提示可使用 `/lyxy-kb-ask <project-name>` 进行知识问答
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-17
|
||||
@@ -0,0 +1,76 @@
|
||||
## Context
|
||||
|
||||
当前项目中存在 `lyxy-reader-docx` skill,仅支持 DOCX 格式的文档解析。`skills/lyxy-reader-office/scripts/` 目录下已完成全部解析脚本的开发(parser.py、common.py、docx_parser.py、pptx_parser.py、xlsx_parser.py、pdf_parser.py、README.md),支持 DOCX、PPTX、XLSX、PDF 四种格式。现在需要创建 `SKILL.md` 作为 skill 的入口文件,并清理被替代的旧 skill。
|
||||
|
||||
已有资源:
|
||||
- `skills/lyxy-reader-docx/SKILL.md`:参考模板,已验证的 skill 结构
|
||||
- `skills/lyxy-runner-python/SKILL.md`:Python 执行 skill,与本 skill 协作
|
||||
- `skills/lyxy-reader-office/scripts/README.md`:完整的脚本使用文档
|
||||
- `document/specification.md`:skill 格式规范
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 创建 `skills/lyxy-reader-office/SKILL.md`,遵循 skill 格式规范
|
||||
- SKILL.md 的 description 能覆盖 .docx、.xlsx、.pptx、.pdf 四种文件扩展名关键词,确保大模型在发现阶段能正确匹配
|
||||
- 引导大模型优先使用该 skill 读取这四种格式的文件
|
||||
- 引导大模型阅读 `scripts/README.md` 获取详细的脚本使用说明
|
||||
- 引导大模型在 `lyxy-runner-python` skill 可用时必须使用该 skill 来运行 Python 脚本
|
||||
- 删除 `skills/lyxy-reader-docx` 目录
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不修改已有的解析脚本代码
|
||||
- 不修改 `lyxy-runner-python` skill
|
||||
- 不新增任何解析功能
|
||||
- 不创建单独的文档说明文件(如旧 skill 的 `docx_parser.md`),详细用法通过引导阅读 README.md 解决
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:SKILL.md 结构参考 lyxy-reader-docx 但大幅精简
|
||||
|
||||
**选择**:SKILL.md 专注于发现(description)、激活引导(何时使用、触发条件)和执行入口(指向 README.md),不在 SKILL.md 中重复 README.md 的内容。
|
||||
|
||||
**理由**:
|
||||
- 规范建议 SKILL.md 保持在 500 行以下
|
||||
- README.md 已包含完整的命令行用法、参数说明、安装指南、错误处理等
|
||||
- 重复内容会导致维护负担,且增加不必要的 token 消耗
|
||||
- 渐进式披露:SKILL.md 负责激活判断,README.md 负责执行细节
|
||||
|
||||
**替代方案**:将 README.md 全部内容嵌入 SKILL.md — 拒绝,因为会超过 500 行限制且违反渐进式披露原则。
|
||||
|
||||
### 决策 2:强制引导使用 lyxy-runner-python
|
||||
|
||||
**选择**:在 SKILL.md 中明确要求大模型在 `lyxy-runner-python` skill 可用时**必须**使用该 skill 来运行 parser.py,不提供直接 Python 执行的选项。仅在 `lyxy-runner-python` 不可用时降级到直接 Python 执行。
|
||||
|
||||
**理由**:
|
||||
- lyxy-runner-python 使用 uv 管理依赖,能自动安装解析器所需的第三方库
|
||||
- 环境隔离,不污染系统 Python
|
||||
- 与 lyxy-reader-docx 保持一致的执行策略
|
||||
|
||||
**替代方案**:将 lyxy-runner-python 作为可选推荐 — 拒绝,因为用户明确要求"必须使用"。
|
||||
|
||||
### 决策 3:通过引导阅读 README.md 提供详细用法
|
||||
|
||||
**选择**:SKILL.md 中仅给出基本的执行命令格式和常见示例,详细的参数说明、依赖安装、解析器对比等内容通过引导大模型阅读 `scripts/README.md` 获取。
|
||||
|
||||
**理由**:
|
||||
- 符合渐进式披露原则:SKILL.md 是指令层,README.md 是资源层
|
||||
- README.md 已有完整且结构化的文档,无需重复
|
||||
- 减少 SKILL.md 的体积,提高激活阶段的效率
|
||||
|
||||
### 决策 4:删除 lyxy-reader-docx 而非保留兼容
|
||||
|
||||
**选择**:直接删除整个 `skills/lyxy-reader-docx/` 目录。
|
||||
|
||||
**理由**:
|
||||
- lyxy-reader-office 完全覆盖了 lyxy-reader-docx 的功能
|
||||
- 保留旧 skill 会导致两个 skill 争抢 .docx 文件的处理权,造成歧义
|
||||
- 用户明确要求删除
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[旧 skill 引用残留]** → 检查项目中是否有其他地方引用了 `lyxy-reader-docx`(如 .claude/settings 中的 skill 配置),删除时需同步清理
|
||||
- **[description 覆盖度]** → description 需要包含 docx、xlsx、pptx、pdf 等关键词,确保大模型在发现阶段能匹配到四种文件类型。但 description 有 1024 字符限制,需要精简表达
|
||||
- **[SKILL.md 体积控制]** → 需在"提供足够的执行引导"和"保持 500 行以内"之间平衡。通过引导阅读 README.md 解决详细信息的需求
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
当前项目中仅有 `lyxy-reader-docx` skill 用于解析 DOCX 文档,无法覆盖 XLSX、PPTX、PDF 等常见办公文档格式。用户在日常工作中经常需要大模型读取多种格式的文档,缺少统一的多格式文档解析能力会导致大模型无法有效处理这些文件。需要创建一个统一的办公文档解析 skill,覆盖四种主流格式,并替换功能已被覆盖的旧 skill。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `lyxy-reader-office` skill,支持解析 DOCX、XLSX、PPTX、PDF 四种格式
|
||||
- 该 skill 使用 `scripts/parser.py` 作为统一入口,自动识别文件类型并分派到对应的格式解析器
|
||||
- 引导大模型在遇到这四种文件时优先激活并使用该 skill
|
||||
- 引导大模型通过阅读 `scripts/README.md` 了解脚本的详细使用方式
|
||||
- 引导大模型在环境中存在 `lyxy-runner-python` skill 时,必须使用该 skill 来运行 Python 脚本
|
||||
- **BREAKING**:删除 `skills/lyxy-reader-docx` 目录,因为其功能已完全被 `lyxy-reader-office` 覆盖
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `office-document-parsing`: 统一的办公文档解析能力,覆盖 DOCX、XLSX、PPTX、PDF 四种格式,支持全文提取、标题提取、章节提取、正则搜索、字数统计、行数统计等功能,PDF 额外支持 OCR 高精度模式
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `docx-text-extraction`: 该能力将被 `office-document-parsing` 完全替代,原 spec 不再适用
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增文件**:`skills/lyxy-reader-office/SKILL.md`(skill 主文件)
|
||||
- **脚本文件**:`skills/lyxy-reader-office/scripts/` 下的所有解析脚本已就绪(parser.py、common.py、docx_parser.py、pptx_parser.py、xlsx_parser.py、pdf_parser.py、README.md)
|
||||
- **删除目录**:`skills/lyxy-reader-docx/`(整个目录,包含 SKILL.md、docx_parser.md、scripts/docx_parser.py)
|
||||
- **依赖关系**:运行时依赖 Python 3.6+,推荐通过 `lyxy-runner-python` skill 使用 `uv` 自动管理依赖
|
||||
- **Spec 变更**:`docx-text-extraction` spec 将被废弃,新增 `office-document-parsing` spec
|
||||
@@ -0,0 +1,29 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Delegate execution to lyxy-runner-python skill
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「必须通过 lyxy-runner-python 执行脚本」需求完全替代
|
||||
**Migration**: 使用 lyxy-reader-office skill 替代 lyxy-reader-docx skill
|
||||
|
||||
### Requirement: Extract full text from DOCX file
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「支持多格式文档解析」需求完全替代
|
||||
**Migration**: 使用 lyxy-reader-office skill 的 parser.py 解析 DOCX 文件
|
||||
|
||||
### Requirement: Extract document metadata
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「统一的查询功能」需求完全替代
|
||||
**Migration**: 使用 lyxy-reader-office skill 的 `-c` 和 `-l` 参数
|
||||
|
||||
### Requirement: Extract document titles
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「统一的查询功能」需求完全替代
|
||||
**Migration**: 使用 lyxy-reader-office skill 的 `-t` 参数
|
||||
|
||||
### Requirement: Extract chapter content by title name
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「统一的查询功能」需求完全替代
|
||||
**Migration**: 使用 lyxy-reader-office skill 的 `-tc` 参数
|
||||
|
||||
### Requirement: Search document with regex
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「统一的查询功能」需求完全替代
|
||||
**Migration**: 使用 lyxy-reader-office skill 的 `-s` 参数
|
||||
|
||||
### Requirement: Multi-strategy parsing fallback
|
||||
**Reason**: 该能力已被 `office-document-parsing` spec 中的「多策略解析降级」需求完全替代,且新版本支持更多格式和更多解析策略
|
||||
**Migration**: 使用 lyxy-reader-office skill,支持 DOCX/PPTX/XLSX/PDF 四种格式的多策略降级
|
||||
@@ -0,0 +1,115 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 优先使用 lyxy-reader-office 解析办公文档
|
||||
大模型在遇到 .docx、.xlsx、.pptx、.pdf 文件时,SHALL 优先激活并使用 lyxy-reader-office skill 来读取文件内容。
|
||||
|
||||
#### Scenario: 用户请求读取支持的文件格式
|
||||
- **WHEN** 用户请求读取或解析 .docx、.xlsx、.pptx 或 .pdf 文件
|
||||
- **THEN** 大模型 SHALL 激活 lyxy-reader-office skill
|
||||
- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析
|
||||
|
||||
#### Scenario: 文件扩展名自动识别
|
||||
- **WHEN** 用户提供的文件路径以 .docx、.xlsx、.pptx 或 .pdf 结尾
|
||||
- **THEN** 大模型 SHALL 自动识别为 lyxy-reader-office skill 的处理范围
|
||||
- **AND** 无需用户显式指定使用哪个 skill
|
||||
|
||||
### Requirement: 必须通过 lyxy-runner-python 执行脚本
|
||||
当环境中存在 lyxy-runner-python skill 时,大模型 SHALL 必须使用该 skill 来运行 parser.py 脚本。
|
||||
|
||||
#### Scenario: lyxy-runner-python 可用
|
||||
- **WHEN** 大模型环境中存在 lyxy-runner-python skill
|
||||
- **THEN** 大模型 SHALL 通过 lyxy-runner-python skill 执行 parser.py
|
||||
- **AND** 利用 lyxy-runner-python 的自动依赖管理功能(uv)安装所需的 Python 包
|
||||
|
||||
#### Scenario: lyxy-runner-python 不可用
|
||||
- **WHEN** 大模型环境中不存在 lyxy-runner-python skill
|
||||
- **THEN** 大模型 SHALL 降级到直接使用 Python 执行 parser.py
|
||||
- **AND** 提示用户当前使用直接执行模式
|
||||
- **AND** 禁止自动执行 pip install 安装依赖
|
||||
|
||||
### Requirement: 引导阅读 README 获取详细用法
|
||||
大模型在需要了解 parser.py 的详细使用方式时,SHALL 阅读 `scripts/README.md` 文件。
|
||||
|
||||
#### Scenario: 首次使用 skill 执行解析
|
||||
- **WHEN** 大模型首次使用 lyxy-reader-office skill 或不确定具体参数用法
|
||||
- **THEN** 大模型 SHALL 阅读 `scripts/README.md` 获取命令行参数、依赖安装和使用示例等详细信息
|
||||
|
||||
#### Scenario: 遇到特殊参数需求
|
||||
- **WHEN** 用户请求使用特殊功能(如 PDF OCR、章节提取、正则搜索等)
|
||||
- **THEN** 大模型 SHALL 参考 `scripts/README.md` 中的对应参数说明
|
||||
|
||||
### Requirement: 支持多格式文档解析
|
||||
系统 SHALL 支持 DOCX、PPTX、XLSX、PDF 四种格式的文档解析,将文件转换为 Markdown 格式输出。
|
||||
|
||||
#### Scenario: 解析 DOCX 文件
|
||||
- **WHEN** 用户请求解析 .docx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 保留标题、列表、表格、粗体、斜体等格式
|
||||
- **AND** 移除图片
|
||||
|
||||
#### Scenario: 解析 PPTX 文件
|
||||
- **WHEN** 用户请求解析 .pptx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 每张幻灯片以 `## Slide N` 为标题
|
||||
- **AND** 幻灯片之间以 `---` 分隔
|
||||
|
||||
#### Scenario: 解析 XLSX 文件
|
||||
- **WHEN** 用户请求解析 .xlsx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 以 `## SheetName` 区分工作表
|
||||
- **AND** 数据以 Markdown 表格呈现
|
||||
|
||||
#### Scenario: 解析 PDF 文件
|
||||
- **WHEN** 用户请求解析 .pdf 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 默认使用普通文本提取模式
|
||||
|
||||
#### Scenario: PDF OCR 高精度模式
|
||||
- **WHEN** 用户请求对 PDF 文件启用 OCR 或高精度解析
|
||||
- **THEN** 系统使用 `--high-res` 参数启用 OCR 版面分析
|
||||
- **AND** 通过 Docling OCR 或 unstructured hi_res 策略处理
|
||||
|
||||
### Requirement: 统一的查询功能
|
||||
系统 SHALL 对所有支持的文件格式提供统一的查询接口,包括全文提取、元数据查询、标题提取、章节提取和正则搜索。
|
||||
|
||||
#### Scenario: 获取文档字数
|
||||
- **WHEN** 用户请求获取文档的字数
|
||||
- **THEN** 系统使用 `-c` 参数返回文档的总字符数
|
||||
|
||||
#### Scenario: 获取文档行数
|
||||
- **WHEN** 用户请求获取文档的行数
|
||||
- **THEN** 系统使用 `-l` 参数返回文档的总行数
|
||||
|
||||
#### Scenario: 提取所有标题
|
||||
- **WHEN** 用户请求提取文档的标题结构
|
||||
- **THEN** 系统使用 `-t` 参数返回所有 1-6 级标题
|
||||
|
||||
#### Scenario: 提取指定章节内容
|
||||
- **WHEN** 用户请求提取特定标题名称的章节内容
|
||||
- **THEN** 系统使用 `-tc` 参数返回该章节的完整内容
|
||||
- **AND** 包含完整的上级标题链和所有下级内容
|
||||
|
||||
#### Scenario: 正则表达式搜索
|
||||
- **WHEN** 用户请求在文档中搜索关键词或模式
|
||||
- **THEN** 系统使用 `-s` 参数返回所有匹配结果及上下文
|
||||
- **AND** 默认包含前后各 2 行非空行上下文
|
||||
- **AND** 支持 `-n` 参数自定义上下文行数
|
||||
|
||||
### Requirement: 多策略解析降级
|
||||
系统 SHALL 对每种文件格式按优先级尝试多种解析策略,确保最大的兼容性。
|
||||
|
||||
#### Scenario: 解析器按优先级降级
|
||||
- **WHEN** 优先级最高的解析器不可用或解析失败
|
||||
- **THEN** 系统自动尝试下一优先级的解析器
|
||||
- **AND** 记录每个解析器的失败原因
|
||||
|
||||
#### Scenario: 所有解析器失败
|
||||
- **WHEN** 某种格式的所有解析策略均失败
|
||||
- **THEN** 系统返回详细的失败信息
|
||||
- **AND** 列出每种解析策略的失败原因
|
||||
- **AND** 以退出码 1 退出
|
||||
|
||||
#### Scenario: DOCX/PPTX/XLSX 无依赖运行
|
||||
- **WHEN** 未安装任何第三方解析库
|
||||
- **THEN** DOCX、PPTX、XLSX 文件 SHALL 仍可通过内置 XML 原生解析工作
|
||||
- **AND** PDF 至少需要 pypdf 才能解析
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. 创建 SKILL.md
|
||||
|
||||
- [x] 1.1 创建 `skills/lyxy-reader-office/SKILL.md` 文件,包含符合规范的 YAML frontmatter(name、description、compatibility)
|
||||
- [x] 1.2 编写 SKILL.md 正文:Purpose 部分,说明 skill 用途及与 lyxy-runner-python 的协作关系(必须使用 lyxy-runner-python,不可用时降级到直接 Python 执行)
|
||||
- [x] 1.3 编写 SKILL.md 正文:When to Use 部分,列出典型场景、触发词和支持的文件扩展名(.docx、.xlsx、.pptx、.pdf)
|
||||
- [x] 1.4 编写 SKILL.md 正文:Capabilities 部分,概述四种格式的解析能力和统一查询功能(全文提取、元数据、标题、章节、搜索)
|
||||
- [x] 1.5 编写 SKILL.md 正文:Execution 部分,给出基本的执行命令格式,并引导大模型阅读 `scripts/README.md` 获取详细参数说明和依赖安装指南
|
||||
- [x] 1.6 编写 SKILL.md 正文:Examples 部分,给出各格式的基本使用示例
|
||||
- [x] 1.7 编写 SKILL.md 正文:Notes 部分,说明限制和注意事项
|
||||
- [x] 1.8 验证 SKILL.md 总行数不超过 500 行,符合渐进式披露原则
|
||||
|
||||
## 2. 清理旧 Skill
|
||||
|
||||
- [x] 2.1 删除 `skills/lyxy-reader-docx/` 整个目录(包含 SKILL.md、docx_parser.md、scripts/docx_parser.py)
|
||||
- [x] 2.2 检查项目中是否有其他位置引用了 `lyxy-reader-docx`(如 .claude/settings 配置),如有则清理引用
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [x] 3.1 确认 `skills/lyxy-reader-office/SKILL.md` 存在且 frontmatter 格式正确
|
||||
- [x] 3.2 确认 `skills/lyxy-reader-office/scripts/` 下所有脚本文件完整(parser.py、common.py、docx_parser.py、pptx_parser.py、xlsx_parser.py、pdf_parser.py、README.md)
|
||||
- [x] 3.3 确认 `skills/lyxy-reader-docx/` 目录已被删除
|
||||
- [x] 3.4 使用各格式的测试文件(temp/test.docx、temp/test.xlsx、temp/test.pptx、temp/test.pdf)验证 parser.py 可正常运行
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-18
|
||||
176
openspec/changes/archive/2026-02-19-add-lyxy-kb/design.md
Normal file
176
openspec/changes/archive/2026-02-19-add-lyxy-kb/design.md
Normal file
@@ -0,0 +1,176 @@
|
||||
## Context
|
||||
|
||||
当前项目已有 `lyxy-reader-office` skill(解析 docx/pdf/pptx/xlsx 为 markdown)和 `lyxy-runner-python` skill(uv 执行 Python 脚本)。需要在此基础上新增知识库管理能力,让用户能将项目文档组织为可被大模型高效检索和问答的知识库。
|
||||
|
||||
核心约束:
|
||||
- Skill 和 Command 分离:skill 定义底层规范和能力,command 定义用户交互流程
|
||||
- 纯文件驱动:不依赖数据库或向量存储,所有数据以文件形式存在
|
||||
- 渐进式查询:优先读摘要索引,按需加载详细内容,节省 token
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 提供完整的文档入库流程:放入文件 → 解析 → 归档 → 生成摘要
|
||||
- 支持增量更新和全量重建两种 project.md 维护模式
|
||||
- 基于知识库进行多轮问答,回答时标注文件来源
|
||||
- 复用已有 lyxy-reader-office 解析 office 文档
|
||||
- 支持任意纯文本文件直接入库
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不做向量化或语义搜索
|
||||
- 不做跨项目知识关联
|
||||
- 不做文档版本对比或 diff
|
||||
- 不做权限控制或多用户协作
|
||||
- 不做 Web UI 或可视化界面
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 目录结构:CWD 即知识库根目录
|
||||
|
||||
每个知识项目是 CWD 下的一个子目录,内含固定结构:
|
||||
|
||||
```
|
||||
CWD/
|
||||
└── <project-name>/
|
||||
├── project.md # 高度摘要 + 文件索引
|
||||
├── manifest.json # 增量追踪
|
||||
├── parsed/ # 解析后的 markdown
|
||||
├── sources/ # 待处理区
|
||||
└── archive/ # 原始文件备份
|
||||
```
|
||||
|
||||
**为什么不加 `knowledge/` 外层目录**:CWD 本身就是用户选定的知识库工作目录,额外嵌套层没有意义。用户可以在任意位置 `mkdir my-kb && cd my-kb` 然后使用 commands。
|
||||
|
||||
### 2. 文档生命周期:sources → parsed + archive
|
||||
|
||||
文件流转:
|
||||
1. 用户将文档放入 `sources/`
|
||||
2. ingest 解析文件,生成 `parsed/<文件名>.md`
|
||||
3. 原始文件移入 `archive/<文件名_YYYYMMDDHHmm>.<ext>`(每个版本都带时间戳)
|
||||
4. 同名文件覆盖 parsed 中的旧版本,archive 中保留所有历史版本
|
||||
|
||||
**为什么 archive 全部带时间戳**:统一规则,无需判断"是否已有同名文件"。每次入库都是一个带时间戳的快照。
|
||||
|
||||
**同名不同扩展名冲突处理**:如 `技术方案.pdf` 和 `技术方案.docx` 同时存在于 sources/,因为 parsed 产物都会命名为 `技术方案.md`,产生冲突。此时拒绝处理并提示用户重命名。
|
||||
|
||||
### 3. 增量追踪:manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "my-project",
|
||||
"created_at": "2026-02-18T16:00",
|
||||
"last_ingest": "2026-02-18T17:25",
|
||||
"files": [
|
||||
{
|
||||
"name": "需求文档",
|
||||
"ext": ".docx",
|
||||
"parsed": "parsed/需求文档.md",
|
||||
"versions": [
|
||||
{
|
||||
"archived": "archive/需求文档_202602181600.docx",
|
||||
"hash": "sha256:abc123...",
|
||||
"ingested_at": "2026-02-18T16:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
manifest 用于:
|
||||
- 检测 sources/ 中哪些文件是新文件
|
||||
- 检测同名不同扩展名冲突(对比 files 中已有的 name 和 ext)
|
||||
- 记录版本历史,关联 parsed 和 archive 文件
|
||||
|
||||
### 4. project.md 格式与更新策略
|
||||
|
||||
project.md 结构:
|
||||
|
||||
```markdown
|
||||
# <项目名称>
|
||||
|
||||
## 概述
|
||||
(高度总结的项目信息)
|
||||
|
||||
## 关键信息
|
||||
(从所有文档中提炼的核心要点)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|
||||
|--------|----------|----------|------|
|
||||
| 需求文档 | parsed/需求文档.md | archive/需求文档_202602181600.docx | 简要摘要... |
|
||||
|
||||
## 更新记录
|
||||
- 2026-02-18 16:00: 解析 需求文档.docx
|
||||
```
|
||||
|
||||
**默认增量追加**(`lyxy-kb-ingest`):
|
||||
- 新文件:在文件索引表追加新行,在更新记录追加条目
|
||||
- 概述和关键信息部分**不**自动更新
|
||||
|
||||
**全量重写**(`lyxy-kb-rebuild`):
|
||||
- 读取所有 parsed/*.md,重新生成整个 project.md
|
||||
- 概述、关键信息、文件索引全部重新生成
|
||||
|
||||
### 5. 解析后 markdown 的元信息标记
|
||||
|
||||
每个 parsed 文件头部包含元信息注释:
|
||||
|
||||
```markdown
|
||||
<!-- source: 技术方案.pdf -->
|
||||
<!-- archived: archive/技术方案_202602181725.pdf -->
|
||||
<!-- parsed_at: 2026-02-18 17:25 -->
|
||||
|
||||
# 技术方案
|
||||
(文档正文内容...)
|
||||
```
|
||||
|
||||
用于问答时标注来源。
|
||||
|
||||
### 6. 文件类型解析策略
|
||||
|
||||
| 文件类型 | 解析方式 |
|
||||
|----------|----------|
|
||||
| .docx, .pdf, .pptx, .xlsx | lyxy-reader-office(通过 lyxy-runner-python 执行) |
|
||||
| .md, .txt, .csv, .json, .xml, .yaml/.yml, .log, .html 等 | 直接读取文件内容 |
|
||||
|
||||
判断逻辑:先检查是否为 office 文档扩展名,是则调用 lyxy-reader-office;否则视为纯文本直接读取。
|
||||
|
||||
### 7. 渐进式查询策略(ask 模式)
|
||||
|
||||
```
|
||||
① 读取 project.md → 获取整体概述和文件索引
|
||||
② 根据用户问题判断需要哪些 parsed 文件
|
||||
③ 读取相关 parsed 文件(可能只需部分章节)
|
||||
④ 回答问题,标注来源格式:「根据《文件名》(parsed/文件名.md),...」
|
||||
⑤ 保持会话上下文,用户可继续追问
|
||||
```
|
||||
|
||||
### 8. Skill 与 Command 的职责划分
|
||||
|
||||
**Skill(`skills/lyxy-kb/SKILL.md`)**:
|
||||
- 定义知识库目录结构规范
|
||||
- 定义 project.md 格式规范
|
||||
- 定义 manifest.json 结构
|
||||
- 定义解析规则和文件类型映射
|
||||
- 定义渐进式查询策略
|
||||
- 定义来源引用格式
|
||||
|
||||
**Commands(`commands/lyxy-kb/`)**:
|
||||
- `init.md`:创建项目目录和初始文件的交互流程
|
||||
- `ingest.md`:触发解析、展示进度、增量更新 project.md 的交互流程
|
||||
- `rebuild.md`:全量重写 project.md 的交互流程
|
||||
- `ask.md`:进入会话问答模式的交互流程
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[project.md 增量追加导致概述过时]** → 用户可通过 `lyxy-kb-rebuild` 全量重写来更新概述。增量模式优先保证 token 效率,trade-off 是概述不会自动融入新文件的信息。
|
||||
|
||||
**[大量文件时 rebuild 的 token 消耗]** → 如果 parsed 文件总量很大,全量重写时需要读取所有文件。目前无特殊缓解措施,依赖大模型上下文窗口限制自然约束项目规模。
|
||||
|
||||
**[parsed 文件名冲突]** → 同名不同扩展名直接拒绝,要求用户重命名。简单但可能偶尔不便。
|
||||
|
||||
**[纯文本文件的摘要质量]** → CSV、JSON 等结构化数据直接读取后,摘要可能不如 office 文档自然。大模型需要自行判断如何提取关键信息。
|
||||
31
openspec/changes/archive/2026-02-19-add-lyxy-kb/proposal.md
Normal file
31
openspec/changes/archive/2026-02-19-add-lyxy-kb/proposal.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
在使用大模型辅助工作时,经常需要基于一组项目相关文档(需求文档、技术方案、数据表等)进行问答和分析。目前缺乏一种轻量的、基于文件的方式来组织这些文档,让大模型能够高效地读取、总结和检索。需要一套 skill + command 来实现个人知识库的初始化、文档解析入库、渐进式问答。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `lyxy-kb` skill,定义知识库的目录结构规范、文档生命周期、解析规则、渐进式查询策略等底层能力
|
||||
- 新增 `lyxy-kb-init` command,引导用户创建知识项目目录结构
|
||||
- 新增 `lyxy-kb-ingest` command,解析 sources/ 中的新文件,增量更新 project.md
|
||||
- 新增 `lyxy-kb-rebuild` command,全量重新生成 project.md
|
||||
- 新增 `lyxy-kb-ask` command,进入会话式问答模式,基于项目知识渐进式查询回答问题并标注来源
|
||||
- 复用已有的 `lyxy-reader-office` skill 解析 office 文档,纯文本类文件直接读取
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `kb-project-management`:知识项目的初始化与目录结构管理,包括 project.md、parsed/、sources/、archive/ 的创建和维护
|
||||
- `kb-document-ingestion`:文档解析入库流程,包括文件类型识别、调用解析器、生成 parsed markdown、归档原始文件(带时间戳)、增量追踪(manifest.json)、同名冲突检测
|
||||
- `kb-knowledge-query`:基于知识库的渐进式问答能力,包括读取 project.md 摘要索引、按需加载 parsed 文件、回答时标注文件来源
|
||||
- `kb-project-summary`:project.md 的生成与维护策略,包括增量追加(默认)和全量重写两种模式
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增文件**:`skills/lyxy-kb/SKILL.md`、`commands/lyxy-kb/` 下 4 个 command 文件(init.md、ingest.md、rebuild.md、ask.md)
|
||||
- **依赖**:运行时依赖 `lyxy-reader-office` skill 解析 office 文档,依赖 `lyxy-runner-python` skill 执行 Python 脚本
|
||||
- **用户侧影响**:用户在任意项目目录下即可使用 command 创建和管理知识库项目,CWD 即为知识库根目录
|
||||
@@ -0,0 +1,71 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 扫描并识别待处理文件
|
||||
系统 SHALL 扫描项目 `sources/` 目录下的所有文件,并根据扩展名判断解析方式:
|
||||
- Office 文档(.docx、.pdf、.pptx、.xlsx):调用 lyxy-reader-office skill 解析
|
||||
- 其他文件(.md、.txt、.csv、.json、.xml、.yaml、.yml、.log、.html 等):直接读取内容
|
||||
|
||||
#### Scenario: sources 中有 office 文档
|
||||
- **WHEN** sources/ 中存在 `报告.docx`
|
||||
- **THEN** 系统 SHALL 使用 lyxy-reader-office skill(通过 lyxy-runner-python 执行)将其解析为 markdown
|
||||
|
||||
#### Scenario: sources 中有纯文本文件
|
||||
- **WHEN** sources/ 中存在 `config.json`
|
||||
- **THEN** 系统 SHALL 直接读取文件内容作为 parsed 产物
|
||||
|
||||
#### Scenario: sources 目录为空
|
||||
- **WHEN** sources/ 中没有任何文件
|
||||
- **THEN** 系统 SHALL 提示用户 sources/ 中无待处理文件
|
||||
|
||||
### Requirement: 同名不同扩展名冲突检测
|
||||
系统 SHALL 在解析前检测 sources/ 中是否存在同名但不同扩展名的文件(如 `技术方案.pdf` 和 `技术方案.docx`),因为 parsed 产物都会命名为相同的 `.md` 文件。同时也需检测 sources/ 中的文件名是否与 manifest.json 中已有记录的不同扩展名文件冲突。
|
||||
|
||||
#### Scenario: sources 中存在同名不同扩展名文件
|
||||
- **WHEN** sources/ 中同时存在 `技术方案.pdf` 和 `技术方案.docx`
|
||||
- **THEN** 系统 SHALL 拒绝处理这两个文件,并提示用户重命名其中一个以消除冲突
|
||||
|
||||
#### Scenario: sources 中文件与已入库文件同名但不同扩展名
|
||||
- **WHEN** manifest.json 中已有 `技术方案`(ext: `.pdf`)的记录,且 sources/ 中出现 `技术方案.docx`
|
||||
- **THEN** 系统 SHALL 拒绝处理该文件,并提示用户重命名
|
||||
|
||||
### Requirement: 生成 parsed markdown 文件
|
||||
系统 SHALL 将解析后的内容写入 `parsed/<文件名>.md`,文件头部 MUST 包含元信息注释:
|
||||
```
|
||||
<!-- source: <原始文件名含扩展名> -->
|
||||
<!-- archived: archive/<文件名_时间戳>.<扩展名> -->
|
||||
<!-- parsed_at: <解析时间 YYYY-MM-DD HH:mm> -->
|
||||
```
|
||||
若 parsed/ 中已存在同名文件(同一文档的更新版本),SHALL 覆盖旧文件。
|
||||
|
||||
#### Scenario: 首次解析文件
|
||||
- **WHEN** 解析 `需求文档.docx`,parsed/ 中不存在 `需求文档.md`
|
||||
- **THEN** 系统创建 `parsed/需求文档.md`,头部包含 source、archived、parsed_at 元信息,正文为解析后的 markdown 内容
|
||||
|
||||
#### Scenario: 更新已有文件
|
||||
- **WHEN** 解析 `技术方案.pdf`,parsed/ 中已存在 `技术方案.md`(上一版本)
|
||||
- **THEN** 系统覆盖 `parsed/技术方案.md`,元信息更新为最新版本的 archive 路径和时间
|
||||
|
||||
### Requirement: 归档原始文件
|
||||
系统 SHALL 将已解析的原始文件从 `sources/` 移动到 `archive/`,文件名格式为 `<文件名_YYYYMMDDHHmm>.<扩展名>`。每个进入 archive 的文件都 MUST 带有时间戳后缀,即使该文件只有一个版本。
|
||||
|
||||
#### Scenario: 归档文件
|
||||
- **WHEN** `需求文档.docx` 解析完成,当前时间为 2026-02-18 16:00
|
||||
- **THEN** 原始文件移动为 `archive/需求文档_202602181600.docx`
|
||||
|
||||
#### Scenario: 同名文件多次入库
|
||||
- **WHEN** `技术方案.pdf` 第二次入库,archive 中已有 `技术方案_202602181600.pdf`
|
||||
- **THEN** 新版本归档为 `技术方案_202602181725.pdf`(以当前时间为时间戳),两个版本并存于 archive
|
||||
|
||||
### Requirement: 更新 manifest.json
|
||||
系统 SHALL 在每个文件处理完成后更新 manifest.json:
|
||||
- 新文件:在 files 数组中追加新条目,包含 name、ext、parsed 路径,versions 数组包含首个版本的 archived 路径、hash 和 ingested_at
|
||||
- 已有文件更新:在对应条目的 versions 数组中追加新版本记录
|
||||
- last_ingest 时间戳 SHALL 更新为当前 ingest 的时间
|
||||
|
||||
#### Scenario: 新文件入库更新 manifest
|
||||
- **WHEN** `需求文档.docx` 首次解析完成
|
||||
- **THEN** manifest.json 的 files 数组中追加 `{"name": "需求文档", "ext": ".docx", "parsed": "parsed/需求文档.md", "versions": [{"archived": "archive/需求文档_202602181600.docx", "hash": "sha256:...", "ingested_at": "2026-02-18T16:00"}]}`
|
||||
|
||||
#### Scenario: 已有文件更新 manifest
|
||||
- **WHEN** `技术方案.pdf` 第二次入库
|
||||
- **THEN** manifest.json 中该文件条目的 versions 数组追加新版本记录,不删除旧版本记录
|
||||
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 渐进式查询策略
|
||||
系统 SHALL 采用渐进式查询策略回答用户问题,以节省 token 消耗:
|
||||
1. 首先读取 project.md 获取整体概述和文件索引
|
||||
2. 根据用户问题和文件索引判断需要查阅哪些 parsed 文件
|
||||
3. 按需读取相关 parsed 文件的全部或部分内容
|
||||
4. 基于获取的信息回答问题
|
||||
|
||||
#### Scenario: 问题可通过摘要回答
|
||||
- **WHEN** 用户提问"这个项目主要做什么?",且 project.md 的概述中已包含足够信息
|
||||
- **THEN** 系统仅基于 project.md 内容回答,不加载 parsed 文件
|
||||
|
||||
#### Scenario: 问题需要查阅具体文件
|
||||
- **WHEN** 用户提问"系统的权限控制是怎么设计的?",且 project.md 文件索引中 `需求文档` 的摘要提到了权限相关内容
|
||||
- **THEN** 系统读取 `parsed/需求文档.md` 获取详细信息后回答
|
||||
|
||||
### Requirement: 来源标注
|
||||
系统在回答中引用具体信息时 SHALL 标注文件来源,格式为:「根据《文件名》(parsed/文件名.md),...」。来源标注 MUST 指向 parsed 目录下的具体文件。
|
||||
|
||||
#### Scenario: 回答中包含来源标注
|
||||
- **WHEN** 系统从 `parsed/技术方案.md` 中获取信息来回答问题
|
||||
- **THEN** 回答中 SHALL 包含类似「根据《技术方案》(parsed/技术方案.md),系统采用微服务架构...」的来源标注
|
||||
|
||||
#### Scenario: 回答综合多个文件
|
||||
- **WHEN** 回答需要综合 `parsed/需求文档.md` 和 `parsed/技术方案.md` 的信息
|
||||
- **THEN** 回答中 SHALL 分别标注各信息点的来源文件
|
||||
|
||||
### Requirement: 会话问答模式
|
||||
系统 SHALL 在 ask 模式下保持会话上下文,用户可以连续提问而无需每次重新加载知识库。会话的退出由用户自然决定(开启新话题或新会话),系统不主动终止会话。
|
||||
|
||||
#### Scenario: 多轮追问
|
||||
- **WHEN** 用户先问"系统用了什么技术栈?",接着追问"数据库选型的理由是什么?"
|
||||
- **THEN** 系统在第二次回答时保持之前的上下文,可复用已加载的 parsed 文件内容
|
||||
|
||||
#### Scenario: 知识库中无相关信息
|
||||
- **WHEN** 用户提出的问题在 project.md 和所有 parsed 文件中均无相关信息
|
||||
- **THEN** 系统 SHALL 明确告知用户当前知识库中未找到相关信息,而非编造答案
|
||||
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 初始化知识项目目录结构
|
||||
系统 SHALL 在 CWD 下创建以指定名称命名的子目录,并在其中生成以下固定结构:
|
||||
- `project.md`:初始内容包含项目名称标题、空的概述/关键信息段落、空的文件索引表和空的更新记录
|
||||
- `manifest.json`:初始内容包含项目名称、创建时间、空的 files 数组
|
||||
- `parsed/` 目录
|
||||
- `sources/` 目录
|
||||
- `archive/` 目录
|
||||
|
||||
#### Scenario: 成功初始化新项目
|
||||
- **WHEN** 用户执行 `/lyxy-kb-init my-project`,且 CWD 下不存在 `my-project` 目录
|
||||
- **THEN** 系统创建 `my-project/` 目录及完整子结构(project.md、manifest.json、parsed/、sources/、archive/),并提示用户将文档放入 sources/ 目录
|
||||
|
||||
#### Scenario: 目标目录已存在
|
||||
- **WHEN** 用户执行 `/lyxy-kb-init my-project`,且 CWD 下已存在 `my-project` 目录
|
||||
- **THEN** 系统 SHALL 提示用户该目录已存在,不覆盖任何现有内容
|
||||
|
||||
### Requirement: 项目目录结构规范
|
||||
知识项目 SHALL 遵循固定的目录结构:`project.md`、`manifest.json`、`parsed/`、`sources/`、`archive/`。所有 command 和 skill 操作 SHALL 基于此结构进行,不在结构外创建额外文件或目录。
|
||||
|
||||
#### Scenario: 验证项目结构完整性
|
||||
- **WHEN** 任何 command(ingest/rebuild/ask)在指定项目目录上执行
|
||||
- **THEN** 系统 SHALL 先检查目录结构是否完整(包含 project.md、manifest.json、parsed/、sources/、archive/),若不完整则提示用户先执行 init
|
||||
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: project.md 格式规范
|
||||
project.md SHALL 遵循以下固定结构:
|
||||
|
||||
```markdown
|
||||
# <项目名称>
|
||||
|
||||
## 概述
|
||||
(高度总结的项目信息)
|
||||
|
||||
## 关键信息
|
||||
(从所有文档中提炼的核心要点)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|
||||
|--------|----------|----------|------|
|
||||
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
初始化时概述和关键信息为空,文件索引表为空表头,更新记录为空。
|
||||
|
||||
#### Scenario: 初始化后的 project.md
|
||||
- **WHEN** 执行 `/lyxy-kb-init my-project`
|
||||
- **THEN** 生成的 project.md 包含 `# my-project` 标题、空的概述/关键信息段落、空的文件索引表(仅表头)和空的更新记录
|
||||
|
||||
### Requirement: 增量追加模式
|
||||
执行 ingest 时,系统 SHALL 以增量方式更新 project.md:
|
||||
- 在文件索引表中追加新解析文件的行(文件名、parsed 路径、最新 archive 路径、该文件的简要摘要)
|
||||
- 在更新记录中追加本次 ingest 的条目(时间和处理的文件列表)
|
||||
- 已有文件更新时:覆盖文件索引表中对应行的最新归档路径和摘要
|
||||
- 概述和关键信息部分 SHALL NOT 在增量模式下自动更新
|
||||
|
||||
#### Scenario: 首次 ingest 追加索引
|
||||
- **WHEN** 首次 ingest 解析了 `需求文档.docx`
|
||||
- **THEN** project.md 文件索引表中追加一行,更新记录中追加 `- 2026-02-18 16:00: 解析 需求文档.docx`
|
||||
|
||||
#### Scenario: 已有文件更新时追加索引
|
||||
- **WHEN** `技术方案.pdf` 第二次入库
|
||||
- **THEN** project.md 文件索引表中该文件的最新归档路径和摘要被更新,更新记录追加新条目
|
||||
|
||||
### Requirement: 全量重写模式
|
||||
执行 rebuild 时,系统 SHALL 读取所有 `parsed/*.md` 文件,重新生成整个 project.md:
|
||||
- 概述:基于所有 parsed 文件内容重新生成高度总结
|
||||
- 关键信息:重新提炼核心要点
|
||||
- 文件索引:基于 manifest.json 和 parsed 文件重新生成完整索引表
|
||||
- 更新记录:保留历史记录,追加本次 rebuild 条目
|
||||
|
||||
#### Scenario: 全量重写
|
||||
- **WHEN** 用户执行 `/lyxy-kb-rebuild my-project`,项目中有 3 个 parsed 文件
|
||||
- **THEN** 系统读取所有 3 个 parsed 文件,重新生成 project.md 的概述、关键信息和文件索引,更新记录追加 rebuild 条目
|
||||
26
openspec/changes/archive/2026-02-19-add-lyxy-kb/tasks.md
Normal file
26
openspec/changes/archive/2026-02-19-add-lyxy-kb/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## 1. Skill 基础
|
||||
|
||||
- [x] 1.1 创建 `skills/lyxy-kb/` 目录
|
||||
- [x] 1.2 编写 `skills/lyxy-kb/SKILL.md`,定义知识库底层规范,包括:目录结构规范(project.md、manifest.json、parsed/、sources/、archive/)、project.md 格式规范(标题/概述/关键信息/文件索引表/更新记录)、manifest.json 结构定义、文件类型解析策略(office 文档用 lyxy-reader-office,其他纯文本直接读取)、parsed 文件元信息标记格式、渐进式查询策略、来源引用格式、同名不同扩展名冲突检测规则
|
||||
|
||||
## 2. Command: lyxy-kb-init
|
||||
|
||||
- [x] 2.1 创建 `commands/lyxy-kb/` 目录
|
||||
- [x] 2.2 编写 `commands/lyxy-kb/init.md`,实现初始化交互流程:接收项目名称参数,检查目标目录是否已存在(已存在则提示不覆盖),在 CWD 下创建项目子目录及完整结构(project.md、manifest.json、parsed/、sources/、archive/),project.md 按规范格式生成初始内容(空概述/关键信息/文件索引/更新记录),manifest.json 初始化为含项目名和创建时间的空结构
|
||||
|
||||
## 3. Command: lyxy-kb-ingest
|
||||
|
||||
- [x] 3.1 编写 `commands/lyxy-kb/ingest.md`,实现增量解析入库交互流程,包括以下步骤:
|
||||
- 接收项目名称参数,验证项目目录结构完整性
|
||||
- 读取 manifest.json,扫描 sources/ 下所有文件
|
||||
- 执行同名不同扩展名冲突检测(sources/ 内部互相检测 + 与 manifest 已有记录检测)
|
||||
- 对每个无冲突的文件:根据扩展名判断解析方式(office → lyxy-reader-office,其他 → 直接读取),生成 parsed markdown(含头部元信息注释),移动原始文件到 archive(带时间戳后缀),更新 manifest.json
|
||||
- 增量更新 project.md:在文件索引表追加/更新行,在更新记录追加条目,不修改概述和关键信息
|
||||
|
||||
## 4. Command: lyxy-kb-rebuild
|
||||
|
||||
- [x] 4.1 编写 `commands/lyxy-kb/rebuild.md`,实现全量重写交互流程:接收项目名称参数,验证项目目录结构完整性,读取所有 parsed/*.md 文件,基于全部内容重新生成 project.md(概述、关键信息、文件索引表全部重写),保留历史更新记录并追加本次 rebuild 条目
|
||||
|
||||
## 5. Command: lyxy-kb-ask
|
||||
|
||||
- [x] 5.1 编写 `commands/lyxy-kb/ask.md`,实现会话问答交互流程:接收项目名称参数,验证项目目录结构完整性,指导大模型执行渐进式查询策略(先读 project.md 摘要索引 → 按需加载 parsed 文件 → 回答并标注来源),进入持续会话模式(用户可连续追问),知识库无相关信息时明确告知
|
||||
@@ -1,7 +1,7 @@
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
忽略项目目录下的「.opencode」和「opencode」两个目录,与开发的skill无关;
|
||||
忽略项目目录下的「.opencode」、「opencode」、「.claude」、「.codex」这几个目录,与开发的skill无关;
|
||||
这个项目是专门用于开发用于大模型工具的 skill;
|
||||
所有开发的 skill 都放在「skills」目录下,每个子目录都代表一个 skill,目录名为 skill 的名称;
|
||||
开发过程中的文档使用中文,面向中文开发者进行交流;
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: Delegate execution to lyxy-runner-python skill
|
||||
当大模型执行 lyxy-reader-docx skill 时,应优先使用 lyxy-runner-python skill 来运行 docx_parser.py 脚本。如果 lyxy-runner-python skill 不可用,则直接使用 Python 执行。
|
||||
|
||||
#### Scenario: lyxy-runner-python available
|
||||
- **WHEN** 大模型环境中存在 lyxy-runner-python skill
|
||||
- **THEN** 大模型通过 lyxy-runner-python skill 执行 docx_parser.py
|
||||
- **AND** 利用 lyxy-runner-python 的自动依赖管理功能安装所需的 Python 包(markitdown 或 python-docx)
|
||||
|
||||
#### Scenario: lyxy-runner-python unavailable
|
||||
- **WHEN** 大模型环境中不存在 lyxy-runner-python skill
|
||||
- **THEN** 大模型直接使用 Python 执行 docx_parser.py
|
||||
- **AND** 提示用户正在使用直接执行模式(未使用 lyxy-runner-python)
|
||||
- **AND** 依赖 docx_parser.py 内部的多策略解析降级机制
|
||||
|
||||
### Requirement: Extract full text from DOCX file
|
||||
系统 SHALL 能够将 DOCX 文件完整解析为 Markdown 格式的文本内容,移除所有图片但保留文本格式(如标题、列表、粗体、斜体、表格)。
|
||||
|
||||
#### Scenario: Successful full text extraction
|
||||
- **WHEN** 用户请求解析一个有效的 .docx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 所有文本内容被保留
|
||||
- **AND** 所有图片被移除
|
||||
- **AND** 表格、列表、粗体、斜体等格式被转换为 Markdown 语法
|
||||
- **AND** 连续的空行被合并为一个空行
|
||||
|
||||
#### Scenario: Invalid DOCX file
|
||||
- **WHEN** 提供的文件不是有效的 DOCX 格式或已损坏
|
||||
- **THEN** 系统返回错误信息
|
||||
- **AND** 错误信息明确说明文件格式问题
|
||||
|
||||
#### Scenario: Empty DOCX file
|
||||
- **WHEN** 提供的 DOCX 文件为空或无文本内容
|
||||
- **THEN** 系统返回空字符串或相应的提示信息
|
||||
|
||||
### Requirement: Extract document metadata
|
||||
系统 SHALL 能够提供 DOCX 文档的元数据信息,包括字数和行数。
|
||||
|
||||
#### Scenario: Get word count
|
||||
- **WHEN** 用户请求获取 DOCX 文档的字数
|
||||
- **THEN** 系统返回文档的总字数(数字)
|
||||
- **AND** 字数统计基于解析后的 Markdown 内容
|
||||
|
||||
#### Scenario: Get line count
|
||||
- **WHEN** 用户请求获取 DOCX 文档的行数
|
||||
- **THEN** 系统返回文档的总行数(数字)
|
||||
- **AND** 行数统计基于解析后的 Markdown 内容
|
||||
|
||||
### Requirement: Extract document titles
|
||||
系统 SHALL 能够提取 DOCX 文档中的所有标题(1-6级标题),并按原始层级关系返回。
|
||||
|
||||
#### Scenario: Extract all titles
|
||||
- **WHEN** 用户请求提取 DOCX 文档的所有标题
|
||||
- **THEN** 系统返回所有 1-6 级标题
|
||||
- **AND** 每个标题以 Markdown 标题格式返回(如 `# 主标题`、`## 二级标题`)
|
||||
- **AND** 标题按文档中的原始顺序返回
|
||||
- **AND** 保留原始标题层级关系
|
||||
|
||||
#### Scenario: Document with no titles
|
||||
- **WHEN** DOCX 文档中不包含任何标题
|
||||
- **THEN** 系统返回空列表或无标题提示
|
||||
|
||||
### Requirement: Extract chapter content by title name
|
||||
系统 SHALL 能够根据标题名称提取指定章节的内容,包括完整的上级标题链和所有下级内容。
|
||||
|
||||
#### Scenario: Extract single chapter content
|
||||
- **WHEN** 用户请求提取特定标题名称的章节内容
|
||||
- **THEN** 系统返回该章节的完整内容
|
||||
- **AND** 包含完整的上级标题链(如:主标题 -> 章标题 -> 小节)
|
||||
- **AND** 包含该标题下的所有下级内容
|
||||
- **AND** 返回内容使用 Markdown 格式
|
||||
|
||||
#### Scenario: Extract multiple chapters with same name
|
||||
- **WHEN** 文档中存在多个同名标题且用户请求提取该标题内容
|
||||
- **THEN** 系统返回所有同名标题的章节内容
|
||||
- **AND** 每个章节都包含其完整的上级标题链
|
||||
- **AND** 各章节内容之间有明确的分隔
|
||||
|
||||
#### Scenario: Title not found
|
||||
- **WHEN** 用户请求提取的标题名称在文档中不存在
|
||||
- **THEN** 系统返回未找到标题的错误信息
|
||||
|
||||
### Requirement: Search document with regex
|
||||
系统 SHALL 能够使用正则表达式在 DOCX 文档中搜索关键词,并返回所有匹配结果及其上下文。
|
||||
|
||||
#### Scenario: Basic keyword search
|
||||
- **WHEN** 用户使用关键词搜索 DOCX 文档
|
||||
- **THEN** 系统返回所有匹配的文本片段
|
||||
- **AND** 每个结果包含默认的上下文(前后各 2 行)
|
||||
- **AND** 各结果之间使用 `---` 分隔
|
||||
- **AND** 上下文行数不包含空行
|
||||
|
||||
#### Scenario: Custom context lines
|
||||
- **WHEN** 用户指定上下文行数进行搜索
|
||||
- **THEN** 系统按指定行数返回上下文
|
||||
- **AND** 支持 0 行上下文(仅返回匹配行)
|
||||
- **AND** 上下文行数不包含空行
|
||||
|
||||
#### Scenario: Regex pattern search
|
||||
- **WHEN** 用户使用正则表达式模式搜索
|
||||
- **THEN** 系统支持 Python 标准正则表达式语法
|
||||
- **AND** 正确处理特殊字符的转义
|
||||
- **AND** 返回所有匹配模式的文本片段
|
||||
|
||||
#### Scenario: No matches found
|
||||
- **WHEN** 搜索关键词或模式在文档中无匹配
|
||||
- **THEN** 系统返回未找到匹配的提示信息
|
||||
|
||||
#### Scenario: Invalid regex pattern
|
||||
- **WHEN** 用户提供的正则表达式格式无效
|
||||
- **THEN** 系统返回正则表达式无效的错误信息
|
||||
|
||||
### Requirement: Multi-strategy parsing fallback
|
||||
系统 SHALL 按优先级尝试多种解析策略,确保最大的兼容性。
|
||||
|
||||
#### Scenario: Prefer MarkItDown parser
|
||||
- **WHEN** 系统检测到 `markitdown` 库已安装
|
||||
- **THEN** 系统优先使用 MarkItDown 解析器解析 DOCX 文件
|
||||
|
||||
#### Scenario: Fallback to python-docx
|
||||
- **WHEN** MarkItDown 解析失败或未安装,且 `python-docx` 库已安装
|
||||
- **THEN** 系统使用 python-docx 作为备选解析器
|
||||
|
||||
#### Scenario: Fallback to XML parsing
|
||||
- **WHEN** MarkItDown 和 python-docx 均未安装或解析失败
|
||||
- **THEN** 系统使用 XML 原生解析作为最后备选方案
|
||||
|
||||
#### Scenario: All parsers failed
|
||||
- **WHEN** 所有解析策略均失败
|
||||
- **THEN** 系统返回详细的失败信息
|
||||
- **AND** 列出每种解析策略的失败原因
|
||||
71
openspec/specs/kb-document-ingestion/spec.md
Normal file
71
openspec/specs/kb-document-ingestion/spec.md
Normal file
@@ -0,0 +1,71 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 扫描并识别待处理文件
|
||||
系统 SHALL 扫描项目 `sources/` 目录下的所有文件,并根据扩展名判断解析方式:
|
||||
- Office 文档(.docx、.pdf、.pptx、.xlsx):调用 lyxy-reader-office skill 解析
|
||||
- 其他文件(.md、.txt、.csv、.json、.xml、.yaml、.yml、.log、.html 等):直接读取内容
|
||||
|
||||
#### Scenario: sources 中有 office 文档
|
||||
- **WHEN** sources/ 中存在 `报告.docx`
|
||||
- **THEN** 系统 SHALL 使用 lyxy-reader-office skill(通过 lyxy-runner-python 执行)将其解析为 markdown
|
||||
|
||||
#### Scenario: sources 中有纯文本文件
|
||||
- **WHEN** sources/ 中存在 `config.json`
|
||||
- **THEN** 系统 SHALL 直接读取文件内容作为 parsed 产物
|
||||
|
||||
#### Scenario: sources 目录为空
|
||||
- **WHEN** sources/ 中没有任何文件
|
||||
- **THEN** 系统 SHALL 提示用户 sources/ 中无待处理文件
|
||||
|
||||
### Requirement: 同名不同扩展名冲突检测
|
||||
系统 SHALL 在解析前检测 sources/ 中是否存在同名但不同扩展名的文件(如 `技术方案.pdf` 和 `技术方案.docx`),因为 parsed 产物都会命名为相同的 `.md` 文件。同时也需检测 sources/ 中的文件名是否与 manifest.json 中已有记录的不同扩展名文件冲突。
|
||||
|
||||
#### Scenario: sources 中存在同名不同扩展名文件
|
||||
- **WHEN** sources/ 中同时存在 `技术方案.pdf` 和 `技术方案.docx`
|
||||
- **THEN** 系统 SHALL 拒绝处理这两个文件,并提示用户重命名其中一个以消除冲突
|
||||
|
||||
#### Scenario: sources 中文件与已入库文件同名但不同扩展名
|
||||
- **WHEN** manifest.json 中已有 `技术方案`(ext: `.pdf`)的记录,且 sources/ 中出现 `技术方案.docx`
|
||||
- **THEN** 系统 SHALL 拒绝处理该文件,并提示用户重命名
|
||||
|
||||
### Requirement: 生成 parsed markdown 文件
|
||||
系统 SHALL 将解析后的内容写入 `parsed/<文件名>.md`,文件头部 MUST 包含元信息注释:
|
||||
```
|
||||
<!-- source: <原始文件名含扩展名> -->
|
||||
<!-- archived: archive/<文件名_时间戳>.<扩展名> -->
|
||||
<!-- parsed_at: <解析时间 YYYY-MM-DD HH:mm> -->
|
||||
```
|
||||
若 parsed/ 中已存在同名文件(同一文档的更新版本),SHALL 覆盖旧文件。
|
||||
|
||||
#### Scenario: 首次解析文件
|
||||
- **WHEN** 解析 `需求文档.docx`,parsed/ 中不存在 `需求文档.md`
|
||||
- **THEN** 系统创建 `parsed/需求文档.md`,头部包含 source、archived、parsed_at 元信息,正文为解析后的 markdown 内容
|
||||
|
||||
#### Scenario: 更新已有文件
|
||||
- **WHEN** 解析 `技术方案.pdf`,parsed/ 中已存在 `技术方案.md`(上一版本)
|
||||
- **THEN** 系统覆盖 `parsed/技术方案.md`,元信息更新为最新版本的 archive 路径和时间
|
||||
|
||||
### Requirement: 归档原始文件
|
||||
系统 SHALL 将已解析的原始文件从 `sources/` 移动到 `archive/`,文件名格式为 `<文件名_YYYYMMDDHHmm>.<扩展名>`。每个进入 archive 的文件都 MUST 带有时间戳后缀,即使该文件只有一个版本。
|
||||
|
||||
#### Scenario: 归档文件
|
||||
- **WHEN** `需求文档.docx` 解析完成,当前时间为 2026-02-18 16:00
|
||||
- **THEN** 原始文件移动为 `archive/需求文档_202602181600.docx`
|
||||
|
||||
#### Scenario: 同名文件多次入库
|
||||
- **WHEN** `技术方案.pdf` 第二次入库,archive 中已有 `技术方案_202602181600.pdf`
|
||||
- **THEN** 新版本归档为 `技术方案_202602181725.pdf`(以当前时间为时间戳),两个版本并存于 archive
|
||||
|
||||
### Requirement: 更新 manifest.json
|
||||
系统 SHALL 在每个文件处理完成后更新 manifest.json:
|
||||
- 新文件:在 files 数组中追加新条目,包含 name、ext、parsed 路径,versions 数组包含首个版本的 archived 路径、hash 和 ingested_at
|
||||
- 已有文件更新:在对应条目的 versions 数组中追加新版本记录
|
||||
- last_ingest 时间戳 SHALL 更新为当前 ingest 的时间
|
||||
|
||||
#### Scenario: 新文件入库更新 manifest
|
||||
- **WHEN** `需求文档.docx` 首次解析完成
|
||||
- **THEN** manifest.json 的 files 数组中追加 `{"name": "需求文档", "ext": ".docx", "parsed": "parsed/需求文档.md", "versions": [{"archived": "archive/需求文档_202602181600.docx", "hash": "sha256:...", "ingested_at": "2026-02-18T16:00"}]}`
|
||||
|
||||
#### Scenario: 已有文件更新 manifest
|
||||
- **WHEN** `技术方案.pdf` 第二次入库
|
||||
- **THEN** manifest.json 中该文件条目的 versions 数组追加新版本记录,不删除旧版本记录
|
||||
38
openspec/specs/kb-knowledge-query/spec.md
Normal file
38
openspec/specs/kb-knowledge-query/spec.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 渐进式查询策略
|
||||
系统 SHALL 采用渐进式查询策略回答用户问题,以节省 token 消耗:
|
||||
1. 首先读取 project.md 获取整体概述和文件索引
|
||||
2. 根据用户问题和文件索引判断需要查阅哪些 parsed 文件
|
||||
3. 按需读取相关 parsed 文件的全部或部分内容
|
||||
4. 基于获取的信息回答问题
|
||||
|
||||
#### Scenario: 问题可通过摘要回答
|
||||
- **WHEN** 用户提问"这个项目主要做什么?",且 project.md 的概述中已包含足够信息
|
||||
- **THEN** 系统仅基于 project.md 内容回答,不加载 parsed 文件
|
||||
|
||||
#### Scenario: 问题需要查阅具体文件
|
||||
- **WHEN** 用户提问"系统的权限控制是怎么设计的?",且 project.md 文件索引中 `需求文档` 的摘要提到了权限相关内容
|
||||
- **THEN** 系统读取 `parsed/需求文档.md` 获取详细信息后回答
|
||||
|
||||
### Requirement: 来源标注
|
||||
系统在回答中引用具体信息时 SHALL 标注文件来源,格式为:「根据《文件名》(parsed/文件名.md),...」。来源标注 MUST 指向 parsed 目录下的具体文件。
|
||||
|
||||
#### Scenario: 回答中包含来源标注
|
||||
- **WHEN** 系统从 `parsed/技术方案.md` 中获取信息来回答问题
|
||||
- **THEN** 回答中 SHALL 包含类似「根据《技术方案》(parsed/技术方案.md),系统采用微服务架构...」的来源标注
|
||||
|
||||
#### Scenario: 回答综合多个文件
|
||||
- **WHEN** 回答需要综合 `parsed/需求文档.md` 和 `parsed/技术方案.md` 的信息
|
||||
- **THEN** 回答中 SHALL 分别标注各信息点的来源文件
|
||||
|
||||
### Requirement: 会话问答模式
|
||||
系统 SHALL 在 ask 模式下保持会话上下文,用户可以连续提问而无需每次重新加载知识库。会话的退出由用户自然决定(开启新话题或新会话),系统不主动终止会话。
|
||||
|
||||
#### Scenario: 多轮追问
|
||||
- **WHEN** 用户先问"系统用了什么技术栈?",接着追问"数据库选型的理由是什么?"
|
||||
- **THEN** 系统在第二次回答时保持之前的上下文,可复用已加载的 parsed 文件内容
|
||||
|
||||
#### Scenario: 知识库中无相关信息
|
||||
- **WHEN** 用户提出的问题在 project.md 和所有 parsed 文件中均无相关信息
|
||||
- **THEN** 系统 SHALL 明确告知用户当前知识库中未找到相关信息,而非编造答案
|
||||
24
openspec/specs/kb-project-management/spec.md
Normal file
24
openspec/specs/kb-project-management/spec.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 初始化知识项目目录结构
|
||||
系统 SHALL 在 CWD 下创建以指定名称命名的子目录,并在其中生成以下固定结构:
|
||||
- `project.md`:初始内容包含项目名称标题、空的概述/关键信息段落、空的文件索引表和空的更新记录
|
||||
- `manifest.json`:初始内容包含项目名称、创建时间、空的 files 数组
|
||||
- `parsed/` 目录
|
||||
- `sources/` 目录
|
||||
- `archive/` 目录
|
||||
|
||||
#### Scenario: 成功初始化新项目
|
||||
- **WHEN** 用户执行 `/lyxy-kb-init my-project`,且 CWD 下不存在 `my-project` 目录
|
||||
- **THEN** 系统创建 `my-project/` 目录及完整子结构(project.md、manifest.json、parsed/、sources/、archive/),并提示用户将文档放入 sources/ 目录
|
||||
|
||||
#### Scenario: 目标目录已存在
|
||||
- **WHEN** 用户执行 `/lyxy-kb-init my-project`,且 CWD 下已存在 `my-project` 目录
|
||||
- **THEN** 系统 SHALL 提示用户该目录已存在,不覆盖任何现有内容
|
||||
|
||||
### Requirement: 项目目录结构规范
|
||||
知识项目 SHALL 遵循固定的目录结构:`project.md`、`manifest.json`、`parsed/`、`sources/`、`archive/`。所有 command 和 skill 操作 SHALL 基于此结构进行,不在结构外创建额外文件或目录。
|
||||
|
||||
#### Scenario: 验证项目结构完整性
|
||||
- **WHEN** 任何 command(ingest/rebuild/ask)在指定项目目录上执行
|
||||
- **THEN** 系统 SHALL 先检查目录结构是否完整(包含 project.md、manifest.json、parsed/、sources/、archive/),若不完整则提示用户先执行 init
|
||||
53
openspec/specs/kb-project-summary/spec.md
Normal file
53
openspec/specs/kb-project-summary/spec.md
Normal file
@@ -0,0 +1,53 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: project.md 格式规范
|
||||
project.md SHALL 遵循以下固定结构:
|
||||
|
||||
```markdown
|
||||
# <项目名称>
|
||||
|
||||
## 概述
|
||||
(高度总结的项目信息)
|
||||
|
||||
## 关键信息
|
||||
(从所有文档中提炼的核心要点)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|
||||
|--------|----------|----------|------|
|
||||
|
||||
## 更新记录
|
||||
```
|
||||
|
||||
初始化时概述和关键信息为空,文件索引表为空表头,更新记录为空。
|
||||
|
||||
#### Scenario: 初始化后的 project.md
|
||||
- **WHEN** 执行 `/lyxy-kb-init my-project`
|
||||
- **THEN** 生成的 project.md 包含 `# my-project` 标题、空的概述/关键信息段落、空的文件索引表(仅表头)和空的更新记录
|
||||
|
||||
### Requirement: 增量追加模式
|
||||
执行 ingest 时,系统 SHALL 以增量方式更新 project.md:
|
||||
- 在文件索引表中追加新解析文件的行(文件名、parsed 路径、最新 archive 路径、该文件的简要摘要)
|
||||
- 在更新记录中追加本次 ingest 的条目(时间和处理的文件列表)
|
||||
- 已有文件更新时:覆盖文件索引表中对应行的最新归档路径和摘要
|
||||
- 概述和关键信息部分 SHALL NOT 在增量模式下自动更新
|
||||
|
||||
#### Scenario: 首次 ingest 追加索引
|
||||
- **WHEN** 首次 ingest 解析了 `需求文档.docx`
|
||||
- **THEN** project.md 文件索引表中追加一行,更新记录中追加 `- 2026-02-18 16:00: 解析 需求文档.docx`
|
||||
|
||||
#### Scenario: 已有文件更新时追加索引
|
||||
- **WHEN** `技术方案.pdf` 第二次入库
|
||||
- **THEN** project.md 文件索引表中该文件的最新归档路径和摘要被更新,更新记录追加新条目
|
||||
|
||||
### Requirement: 全量重写模式
|
||||
执行 rebuild 时,系统 SHALL 读取所有 `parsed/*.md` 文件,重新生成整个 project.md:
|
||||
- 概述:基于所有 parsed 文件内容重新生成高度总结
|
||||
- 关键信息:重新提炼核心要点
|
||||
- 文件索引:基于 manifest.json 和 parsed 文件重新生成完整索引表
|
||||
- 更新记录:保留历史记录,追加本次 rebuild 条目
|
||||
|
||||
#### Scenario: 全量重写
|
||||
- **WHEN** 用户执行 `/lyxy-kb-rebuild my-project`,项目中有 3 个 parsed 文件
|
||||
- **THEN** 系统读取所有 3 个 parsed 文件,重新生成 project.md 的概述、关键信息和文件索引,更新记录追加 rebuild 条目
|
||||
115
openspec/specs/office-document-parsing/spec.md
Normal file
115
openspec/specs/office-document-parsing/spec.md
Normal file
@@ -0,0 +1,115 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: 优先使用 lyxy-reader-office 解析办公文档
|
||||
大模型在遇到 .docx、.xlsx、.pptx、.pdf 文件时,SHALL 优先激活并使用 lyxy-reader-office skill 来读取文件内容。
|
||||
|
||||
#### Scenario: 用户请求读取支持的文件格式
|
||||
- **WHEN** 用户请求读取或解析 .docx、.xlsx、.pptx 或 .pdf 文件
|
||||
- **THEN** 大模型 SHALL 激活 lyxy-reader-office skill
|
||||
- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析
|
||||
|
||||
#### Scenario: 文件扩展名自动识别
|
||||
- **WHEN** 用户提供的文件路径以 .docx、.xlsx、.pptx 或 .pdf 结尾
|
||||
- **THEN** 大模型 SHALL 自动识别为 lyxy-reader-office skill 的处理范围
|
||||
- **AND** 无需用户显式指定使用哪个 skill
|
||||
|
||||
### Requirement: 必须通过 lyxy-runner-python 执行脚本
|
||||
当环境中存在 lyxy-runner-python skill 时,大模型 SHALL 必须使用该 skill 来运行 parser.py 脚本。
|
||||
|
||||
#### Scenario: lyxy-runner-python 可用
|
||||
- **WHEN** 大模型环境中存在 lyxy-runner-python skill
|
||||
- **THEN** 大模型 SHALL 通过 lyxy-runner-python skill 执行 parser.py
|
||||
- **AND** 利用 lyxy-runner-python 的自动依赖管理功能(uv)安装所需的 Python 包
|
||||
|
||||
#### Scenario: lyxy-runner-python 不可用
|
||||
- **WHEN** 大模型环境中不存在 lyxy-runner-python skill
|
||||
- **THEN** 大模型 SHALL 降级到直接使用 Python 执行 parser.py
|
||||
- **AND** 提示用户当前使用直接执行模式
|
||||
- **AND** 禁止自动执行 pip install 安装依赖
|
||||
|
||||
### Requirement: 引导阅读 README 获取详细用法
|
||||
大模型在需要了解 parser.py 的详细使用方式时,SHALL 阅读 `scripts/README.md` 文件。
|
||||
|
||||
#### Scenario: 首次使用 skill 执行解析
|
||||
- **WHEN** 大模型首次使用 lyxy-reader-office skill 或不确定具体参数用法
|
||||
- **THEN** 大模型 SHALL 阅读 `scripts/README.md` 获取命令行参数、依赖安装和使用示例等详细信息
|
||||
|
||||
#### Scenario: 遇到特殊参数需求
|
||||
- **WHEN** 用户请求使用特殊功能(如 PDF OCR、章节提取、正则搜索等)
|
||||
- **THEN** 大模型 SHALL 参考 `scripts/README.md` 中的对应参数说明
|
||||
|
||||
### Requirement: 支持多格式文档解析
|
||||
系统 SHALL 支持 DOCX、PPTX、XLSX、PDF 四种格式的文档解析,将文件转换为 Markdown 格式输出。
|
||||
|
||||
#### Scenario: 解析 DOCX 文件
|
||||
- **WHEN** 用户请求解析 .docx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 保留标题、列表、表格、粗体、斜体等格式
|
||||
- **AND** 移除图片
|
||||
|
||||
#### Scenario: 解析 PPTX 文件
|
||||
- **WHEN** 用户请求解析 .pptx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 每张幻灯片以 `## Slide N` 为标题
|
||||
- **AND** 幻灯片之间以 `---` 分隔
|
||||
|
||||
#### Scenario: 解析 XLSX 文件
|
||||
- **WHEN** 用户请求解析 .xlsx 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 以 `## SheetName` 区分工作表
|
||||
- **AND** 数据以 Markdown 表格呈现
|
||||
|
||||
#### Scenario: 解析 PDF 文件
|
||||
- **WHEN** 用户请求解析 .pdf 文件
|
||||
- **THEN** 系统返回完整的 Markdown 格式文本
|
||||
- **AND** 默认使用普通文本提取模式
|
||||
|
||||
#### Scenario: PDF OCR 高精度模式
|
||||
- **WHEN** 用户请求对 PDF 文件启用 OCR 或高精度解析
|
||||
- **THEN** 系统使用 `--high-res` 参数启用 OCR 版面分析
|
||||
- **AND** 通过 Docling OCR 或 unstructured hi_res 策略处理
|
||||
|
||||
### Requirement: 统一的查询功能
|
||||
系统 SHALL 对所有支持的文件格式提供统一的查询接口,包括全文提取、元数据查询、标题提取、章节提取和正则搜索。
|
||||
|
||||
#### Scenario: 获取文档字数
|
||||
- **WHEN** 用户请求获取文档的字数
|
||||
- **THEN** 系统使用 `-c` 参数返回文档的总字符数
|
||||
|
||||
#### Scenario: 获取文档行数
|
||||
- **WHEN** 用户请求获取文档的行数
|
||||
- **THEN** 系统使用 `-l` 参数返回文档的总行数
|
||||
|
||||
#### Scenario: 提取所有标题
|
||||
- **WHEN** 用户请求提取文档的标题结构
|
||||
- **THEN** 系统使用 `-t` 参数返回所有 1-6 级标题
|
||||
|
||||
#### Scenario: 提取指定章节内容
|
||||
- **WHEN** 用户请求提取特定标题名称的章节内容
|
||||
- **THEN** 系统使用 `-tc` 参数返回该章节的完整内容
|
||||
- **AND** 包含完整的上级标题链和所有下级内容
|
||||
|
||||
#### Scenario: 正则表达式搜索
|
||||
- **WHEN** 用户请求在文档中搜索关键词或模式
|
||||
- **THEN** 系统使用 `-s` 参数返回所有匹配结果及上下文
|
||||
- **AND** 默认包含前后各 2 行非空行上下文
|
||||
- **AND** 支持 `-n` 参数自定义上下文行数
|
||||
|
||||
### Requirement: 多策略解析降级
|
||||
系统 SHALL 对每种文件格式按优先级尝试多种解析策略,确保最大的兼容性。
|
||||
|
||||
#### Scenario: 解析器按优先级降级
|
||||
- **WHEN** 优先级最高的解析器不可用或解析失败
|
||||
- **THEN** 系统自动尝试下一优先级的解析器
|
||||
- **AND** 记录每个解析器的失败原因
|
||||
|
||||
#### Scenario: 所有解析器失败
|
||||
- **WHEN** 某种格式的所有解析策略均失败
|
||||
- **THEN** 系统返回详细的失败信息
|
||||
- **AND** 列出每种解析策略的失败原因
|
||||
- **AND** 以退出码 1 退出
|
||||
|
||||
#### Scenario: DOCX/PPTX/XLSX 无依赖运行
|
||||
- **WHEN** 未安装任何第三方解析库
|
||||
- **THEN** DOCX、PPTX、XLSX 文件 SHALL 仍可通过内置 XML 原生解析工作
|
||||
- **AND** PDF 至少需要 pypdf 才能解析
|
||||
293
skills/lyxy-kb/SKILL.md
Normal file
293
skills/lyxy-kb/SKILL.md
Normal file
@@ -0,0 +1,293 @@
|
||||
---
|
||||
name: lyxy-kb
|
||||
description: 基于文件的个人知识库管理 skill,提供知识项目的目录结构规范、文档解析入库、渐进式问答等底层能力定义。配合 commands/lyxy-kb/ 下的 command 使用。
|
||||
compatibility: 依赖 lyxy-reader-office skill 解析 office 文档(.docx/.pdf/.pptx/.xlsx),依赖 lyxy-runner-python skill 执行 Python 脚本。
|
||||
---
|
||||
|
||||
# 个人知识库 Skill
|
||||
|
||||
基于文件的个人知识库管理系统。将项目相关文档组织为可被大模型高效检索和问答的知识库,支持文档解析入库、增量摘要、渐进式问答。
|
||||
|
||||
## Purpose
|
||||
|
||||
**纯文件驱动**:不依赖数据库或向量存储,所有数据以文件形式存在于项目目录中。
|
||||
|
||||
**渐进式查询**:通过 project.md 摘要索引 + parsed 详细文件的分层结构,优先读取摘要,按需加载详细内容,节省 token 消耗。
|
||||
|
||||
**增量管理**:支持增量解析入库和增量更新摘要,避免重复处理已入库的文档。
|
||||
|
||||
## When to Use
|
||||
|
||||
任何需要基于一组项目文档进行知识管理和问答的场景。
|
||||
|
||||
### 典型场景
|
||||
- **项目文档管理**:将需求文档、技术方案、数据表等组织为结构化知识库
|
||||
- **文档解析入库**:将 office 文档和纯文本文件解析为 markdown 并生成摘要
|
||||
- **知识问答**:基于已入库的文档回答问题,并标注信息来源
|
||||
|
||||
### 不适用场景
|
||||
- 需要语义搜索或向量化检索
|
||||
- 需要跨多个知识项目关联查询
|
||||
- 需要多人协作或权限控制
|
||||
|
||||
## 配套 Commands
|
||||
|
||||
| Command | 触发方式 | 说明 |
|
||||
|---------|----------|------|
|
||||
| init | `/lyxy-kb-init <name>` | 初始化知识项目目录结构 |
|
||||
| ingest | `/lyxy-kb-ingest <name>` | 解析 sources/ 中新文件,增量更新 project.md |
|
||||
| rebuild | `/lyxy-kb-rebuild <name>` | 全量重新生成 project.md |
|
||||
| ask | `/lyxy-kb-ask <name>` | 基于知识库进行会话问答 |
|
||||
|
||||
## 项目名称规则
|
||||
|
||||
项目名称只允许使用以下字符:
|
||||
- 中文字符
|
||||
- 英文字母(a-z、A-Z)
|
||||
- 数字(0-9)
|
||||
- 短横线(-)
|
||||
- 下划线(_)
|
||||
|
||||
**不允许包含空格或其他特殊字符。** 不符合规则时应提示用户修改。
|
||||
|
||||
## 知识项目目录结构
|
||||
|
||||
每个知识项目是当前工作目录(CWD)下的一个子目录,包含以下固定结构:
|
||||
|
||||
```
|
||||
<project-name>/
|
||||
├── project.md # 高度摘要 + 文件索引
|
||||
├── manifest.json # 增量追踪
|
||||
├── parsed/ # 解析后的 markdown(中间产物)
|
||||
├── sources/ # 待处理区(用户放入原始文档)
|
||||
└── archive/ # 原始文件备份(带时间戳)
|
||||
```
|
||||
|
||||
### 各目录/文件职责
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| `project.md` | 项目的高度摘要和文件索引,作为问答时的入口文件 |
|
||||
| `manifest.json` | 记录已处理文件的元信息,用于增量检测和版本追踪 |
|
||||
| `parsed/` | 存放解析后的 markdown 文件,便于大模型读取分析 |
|
||||
| `sources/` | 用户放入待处理文档的目录,解析后文件会被移走 |
|
||||
| `archive/` | 原始文件的备份,每个文件都带时间戳后缀 |
|
||||
|
||||
### 结构完整性验证
|
||||
|
||||
执行任何 command(ingest / rebuild / ask)时,必须先验证项目目录结构是否完整,即以下 5 项是否全部存在:
|
||||
- `<project-name>/project.md`
|
||||
- `<project-name>/manifest.json`
|
||||
- `<project-name>/parsed/`
|
||||
- `<project-name>/sources/`
|
||||
- `<project-name>/archive/`
|
||||
|
||||
若不完整,提示用户先执行 `/lyxy-kb-init <project-name>`,终止当前操作。
|
||||
|
||||
## project.md 格式规范
|
||||
|
||||
```markdown
|
||||
# <项目名称>
|
||||
|
||||
## 概述
|
||||
(高度总结的项目信息,几百字以内)
|
||||
|
||||
## 关键信息
|
||||
(从所有文档中提炼的核心要点)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|
||||
|--------|----------|----------|------|
|
||||
| 需求文档 | parsed/需求文档.md | archive/需求文档_202602181600.docx | 简要摘要... |
|
||||
|
||||
## 更新记录
|
||||
- 2026-02-18 16:00: 解析 需求文档.docx
|
||||
```
|
||||
|
||||
### 更新策略
|
||||
|
||||
**增量追加**(默认,由 ingest 触发):
|
||||
- 新文件:在文件索引表追加新行,在更新记录追加条目
|
||||
- 已有文件更新:覆盖文件索引表中对应行的最新归档路径和摘要
|
||||
- 概述和关键信息部分**不**自动更新
|
||||
|
||||
**全量重写**(由 rebuild 触发):
|
||||
- 读取所有 parsed/*.md 文件
|
||||
- 重新生成概述、关键信息、文件索引
|
||||
- 保留历史更新记录,追加本次 rebuild 条目
|
||||
|
||||
## manifest.json 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "<项目名称>",
|
||||
"created_at": "2026-02-18T16:00",
|
||||
"last_ingest": "2026-02-18T17:25",
|
||||
"files": [
|
||||
{
|
||||
"name": "需求文档",
|
||||
"ext": ".docx",
|
||||
"parsed": "parsed/需求文档.md",
|
||||
"versions": [
|
||||
{
|
||||
"archived": "archive/需求文档_202602181600.docx",
|
||||
"hash": "sha256:abc123...",
|
||||
"ingested_at": "2026-02-18T16:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `project` | 项目名称 |
|
||||
| `created_at` | 项目创建时间 |
|
||||
| `last_ingest` | 最近一次 ingest 的时间 |
|
||||
| `files[].name` | 文件名(不含扩展名) |
|
||||
| `files[].ext` | 原始文件扩展名 |
|
||||
| `files[].parsed` | 解析产物的相对路径 |
|
||||
| `files[].versions` | 版本历史数组 |
|
||||
| `files[].versions[].archived` | 归档文件的相对路径 |
|
||||
| `files[].versions[].hash` | 文件内容的 SHA-256 哈希(使用 `sha256sum` 命令计算) |
|
||||
| `files[].versions[].ingested_at` | 该版本的入库时间 |
|
||||
|
||||
## 文件类型解析策略
|
||||
|
||||
| 文件类型 | 解析方式 |
|
||||
|----------|----------|
|
||||
| `.docx`, `.pdf`, `.pptx`, `.xlsx` | 使用名为 **lyxy-reader-office** 的 skill 解析 |
|
||||
| 其他所有文件(`.md`, `.txt`, `.csv`, `.json`, `.xml`, `.yaml`, `.yml`, `.log`, `.html` 等) | 直接读取文件内容 |
|
||||
|
||||
### Office 文档解析
|
||||
|
||||
解析 office 文档时,必须查找当前环境中名为 **lyxy-reader-office** 的 skill,阅读其 SKILL.md 获取具体的执行方式和命令。
|
||||
|
||||
**如果环境中不存在 lyxy-reader-office skill,且没有其他可替代的文档解析 skill,则提示用户无法处理 office 文档,中止整个 ingest 流程。**
|
||||
|
||||
### sources/ 扫描规则
|
||||
|
||||
扫描 sources/ 时**递归检查所有子目录**中的文件。parsed 产物的路径仍为 `parsed/<文件名>.md`(扁平化存放),不保留 sources 中的子目录结构。
|
||||
|
||||
### 空文件处理
|
||||
|
||||
sources/ 中 0 字节的空文件应**跳过处理**,不解析、不归档、不更新 manifest。处理完成后向用户列出被跳过的空文件列表,提示用户检查。
|
||||
|
||||
### 解析失败处理
|
||||
|
||||
如果某个文件解析失败(如文档损坏、解析器报错),该文件**保留在 sources/ 中不移动**,报告错误信息,继续处理其他文件。
|
||||
|
||||
## parsed 文件元信息标记
|
||||
|
||||
每个 parsed markdown 文件头部必须包含元信息注释:
|
||||
|
||||
```markdown
|
||||
<!-- source: 技术方案.pdf -->
|
||||
<!-- archived: archive/技术方案_202602181725.pdf -->
|
||||
<!-- parsed_at: 2026-02-18 17:25 -->
|
||||
|
||||
# 技术方案
|
||||
(文档正文内容...)
|
||||
```
|
||||
|
||||
| 元信息 | 说明 |
|
||||
|--------|------|
|
||||
| `source` | 原始文件名(含扩展名) |
|
||||
| `archived` | 对应的归档文件相对路径 |
|
||||
| `parsed_at` | 解析时间(YYYY-MM-DD HH:mm 格式) |
|
||||
|
||||
## 文档生命周期
|
||||
|
||||
```
|
||||
用户放入 sources/(支持子目录)
|
||||
│
|
||||
▼
|
||||
检查文件(跳过空文件、检测冲突)
|
||||
│
|
||||
▼
|
||||
解析文件内容(失败则保留在 sources/)
|
||||
│
|
||||
├──▶ 写入 parsed/<文件名>.md(含头部元信息)
|
||||
│
|
||||
├──▶ 移动原始文件到 archive/<文件名_YYYYMMDDHHmm>.<ext>
|
||||
│
|
||||
├──▶ 更新 manifest.json
|
||||
│
|
||||
└──▶ 增量更新 project.md(追加文件索引和更新记录)
|
||||
```
|
||||
|
||||
### 归档命名规则
|
||||
|
||||
所有进入 archive 的文件都必须带时间戳后缀,格式为 `<文件名_YYYYMMDDHHmm>.<扩展名>`,即使只有一个版本。
|
||||
|
||||
示例:
|
||||
- `需求文档.docx` → `archive/需求文档_202602181600.docx`
|
||||
- `技术方案.pdf`(第二次入库)→ `archive/技术方案_202602181725.pdf`
|
||||
|
||||
### 同名文件更新
|
||||
|
||||
同名同扩展名的文件再次入库时:
|
||||
- `parsed/` 中的 markdown 文件被覆盖为最新版本
|
||||
- `archive/` 中保留所有历史版本(每个版本独立的时间戳文件)
|
||||
- `manifest.json` 中该文件条目的 `versions` 数组追加新版本记录
|
||||
|
||||
## 同名不同扩展名冲突检测
|
||||
|
||||
因为 parsed 产物以文件名(不含扩展名)+ `.md` 命名,同名不同扩展名的文件会产生冲突。
|
||||
|
||||
### 检测规则
|
||||
|
||||
1. **sources/ 内部检测**:扫描 sources/ 中所有文件(含子目录),如果存在同名但不同扩展名的文件(如 `技术方案.pdf` 和 `技术方案.docx`),拒绝处理并提示用户重命名
|
||||
2. **与已入库文件检测**:将 sources/ 中文件的名称(不含扩展名)与 manifest.json 中已有记录对比,如果名称相同但扩展名不同,拒绝处理并提示用户重命名
|
||||
|
||||
### 处理方式
|
||||
|
||||
冲突文件不予处理,保留在 sources/ 中,提示用户重命名后重新执行 ingest。非冲突文件正常处理。
|
||||
|
||||
## 渐进式查询策略
|
||||
|
||||
问答时采用分层加载策略,节省 token:
|
||||
|
||||
1. **读取 project.md**:获取项目概述和文件索引(低 token 开销)
|
||||
2. **判断相关文件**:根据用户问题和文件索引中的摘要,判断需要查阅哪些 parsed 文件
|
||||
3. **按需加载**:读取相关 parsed 文件的全部或部分内容
|
||||
4. **回答并标注来源**:基于获取的信息回答问题
|
||||
|
||||
### 来源引用格式
|
||||
|
||||
回答中引用具体信息时,使用以下格式标注来源:
|
||||
|
||||
```
|
||||
根据《文件名》(parsed/文件名.md),...
|
||||
```
|
||||
|
||||
多个来源时分别标注各信息点的来源文件。
|
||||
|
||||
### 无相关信息
|
||||
|
||||
当知识库中未找到与用户问题相关的信息时,明确告知用户,不编造答案。
|
||||
|
||||
### 空知识库
|
||||
|
||||
如果 project.md 文件索引为空(尚无已入库文件),应告知用户知识库为空,建议先使用 `/lyxy-kb-ingest` 入库文档。
|
||||
|
||||
## Notes
|
||||
|
||||
### 依赖关系
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| lyxy-reader-office | 解析 .docx、.pdf、.pptx、.xlsx 文件为 markdown |
|
||||
| lyxy-runner-python | 通过 uv 执行 lyxy-reader-office 的 Python 解析脚本 |
|
||||
|
||||
### 限制
|
||||
|
||||
- 不支持向量化语义搜索
|
||||
- 不支持跨知识项目关联查询
|
||||
- 不支持文档版本对比或 diff
|
||||
- 不支持多用户协作或权限控制
|
||||
- 大量文件全量重写时 token 消耗较高
|
||||
@@ -1,279 +0,0 @@
|
||||
---
|
||||
name: lyxy-reader-docx
|
||||
description: 优先解析 docx 文档的 skill,将 DOCX 文件转换为纯文本内容,不支持图片和格式提取。
|
||||
compatibility: Requires Python 3.6+ and at least one of: markitdown or python-docx
|
||||
---
|
||||
|
||||
# DOCX 文档解析 Skill
|
||||
|
||||
将 Microsoft Word (.docx) 文档解析为纯文本内容,支持多种解析模式和检索功能。
|
||||
|
||||
## Purpose
|
||||
|
||||
**依赖选项**: 此 skill 可以使用 lyxy-runner-python skill(推荐)或直接使用 Python 执行。
|
||||
|
||||
### 优先使用 lyxy-runner-python
|
||||
|
||||
如果环境中存在 lyxy-runner-python skill,应优先使用它来执行 docx_parser.py 脚本:
|
||||
- lyxy-runner-python 使用 uv 管理依赖,自动安装 markitdown 或 python-docx
|
||||
- 环境隔离,不污染系统 Python
|
||||
- 跨平台兼容(Windows/macOS/Linux)
|
||||
|
||||
### 降级到直接执行
|
||||
|
||||
如果环境中不存在 lyxy-runner-python skill,则直接使用 Python 执行 docx_parser.py:
|
||||
- 需要手动安装 markitdown 或 python-docx
|
||||
- 脚本内部实现了多策略解析降级:MarkItDown → python-docx → XML 原生
|
||||
|
||||
## When to Use
|
||||
|
||||
任何需要读取或解析 .docx 文件内容的任务都应使用此 skill。
|
||||
|
||||
### 典型场景
|
||||
- **文档内容提取**: 将 Word 文档转换为可读的文本内容
|
||||
- **文档元数据**: 获取文档的字数、行数等信息
|
||||
- **标题分析**: 提取文档的标题结构
|
||||
- **章节提取**: 提取特定章节的内容
|
||||
- **内容搜索**: 在文档中搜索关键词或模式
|
||||
|
||||
### 不适用场景
|
||||
- ✗ 需要提取图片内容(仅支持纯文本)
|
||||
- ✗ 需要保留复杂的格式信息(如字体、颜色、布局)
|
||||
- ✗ 需要编辑或修改 .docx 文件
|
||||
- ✗ 需要处理 .doc 或其他文档格式
|
||||
|
||||
## Capabilities
|
||||
|
||||
### 1. 全文转换为 Markdown
|
||||
将完整的 DOCX 文档解析为 Markdown 格式文本,移除所有图片但保留文本格式。
|
||||
|
||||
**支持格式转换**:
|
||||
- 标题(1-6级)
|
||||
- 列表(有序和无序)
|
||||
- 表格
|
||||
- 粗体、斜体、下划线
|
||||
- 连续空行规范化
|
||||
|
||||
### 2. 获取文档元信息
|
||||
提供文档的基本统计信息:
|
||||
- 字数统计(使用 `-c` 参数)
|
||||
- 行数统计(使用 `-l` 参数)
|
||||
|
||||
### 3. 标题列表提取
|
||||
提取文档中的所有标题(1-6级),按原始层级关系返回。
|
||||
|
||||
### 4. 指定章节内容提取
|
||||
根据标题名称提取特定章节的完整内容,包括:
|
||||
- 完整的上级标题链
|
||||
- 该标题下的所有下级内容
|
||||
|
||||
支持同名标题提取,返回所有匹配章节。
|
||||
|
||||
### 5. 正则表达式搜索
|
||||
在文档中搜索关键词或模式:
|
||||
- 支持标准正则表达式
|
||||
- 可自定义上下文行数(默认前后各 2 行)
|
||||
- 多个匹配结果用 `---` 分隔
|
||||
- 上下文行数不包含空行
|
||||
|
||||
## Triggers
|
||||
|
||||
### 中文触发词
|
||||
- "读取 docx"
|
||||
- "解析 docx"
|
||||
- "打开 word 文档"
|
||||
- "提取 word 文档内容"
|
||||
- "解析 word 文件"
|
||||
|
||||
### 英文触发词
|
||||
- "read docx"
|
||||
- "parse docx"
|
||||
- "extract from word document"
|
||||
- "parse word file"
|
||||
- "read word document"
|
||||
|
||||
### 文件扩展名
|
||||
- `.docx` 文件
|
||||
|
||||
## Execution
|
||||
|
||||
### 优先使用 lyxy-runner-python
|
||||
|
||||
**推荐执行方式**(如果 lyxy-runner-python skill 可用):
|
||||
|
||||
```bash
|
||||
# 使用 lyxy-runner-python 执行(推荐)
|
||||
uv run --with markitdown skills/lyxy-reader-docx/scripts/docx_parser.py /path/to/document.docx
|
||||
|
||||
# 或使用 python-docx
|
||||
uv run --with python-docx skills/lyxy-reader-docx/scripts/docx_parser.py /path/to/document.docx
|
||||
```
|
||||
|
||||
**大模型应优先检查 lyxy-runner-python skill 是否存在,如果存在则使用它执行。**
|
||||
|
||||
### 降级到直接执行
|
||||
|
||||
如果 lyxy-runner-python skill 不可用,则直接使用 Python:
|
||||
|
||||
```bash
|
||||
# 直接使用 Python 执行
|
||||
python3 skills/lyxy-reader-docx/scripts/docx_parser.py /path/to/document.docx
|
||||
```
|
||||
|
||||
**提示用户**: 建议用户安装 markitdown 或 python-docx 以获得更好的解析效果:
|
||||
```
|
||||
建议:安装 markitdown 或 python-docx 以获得更好的解析效果
|
||||
|
||||
安装命令:
|
||||
pip install markitdown # 推荐
|
||||
pip install python-docx # 备选
|
||||
```
|
||||
|
||||
**禁止自动安装**: 不得自动执行 pip install 命令安装依赖。仅向用户提示安装建议即可。
|
||||
|
||||
**降级策略说明**: 脚本内部实现了多策略解析降级(MarkItDown → python-docx → XML 原生),即使未安装第三方库也能通过 XML 原生解析工作,只是功能可能受限。
|
||||
|
||||
## Command Usage
|
||||
|
||||
### 基本语法
|
||||
```bash
|
||||
python3 docx_parser.py [options] <file_path>
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ----------- | ------------------- |
|
||||
| `file_path` | DOCX 文件的绝对路径 |
|
||||
|
||||
### 选项参数
|
||||
|
||||
| 参数 | 长参数 | 类型 | 默认值 | 说明 |
|
||||
| ---- | ----------- | ---- | ------ | -------------------------------------------------------------- |
|
||||
| `-n` | `--context` | 整数 | 2 | 与 `-s` 配合使用,指定每个检索结果包含的前后行数(不包含空行) |
|
||||
|
||||
### 互斥参数(只能使用其中一个)
|
||||
|
||||
| 参数 | 长参数 | 说明 |
|
||||
| ----- | ----------------- | ----------------------------------------------------- |
|
||||
| `-c` | `--count` | 返回解析后的 markdown 文档的总字数 |
|
||||
| `-l` | `--lines` | 返回解析后的 markdown 文档的总行数 |
|
||||
| `-t` | `--titles` | 返回解析后的 markdown 文档的标题行(1-6级) |
|
||||
| `-tc` | `--title-content` | 指定标题名称,输出该标题及其下级内容(不包含#号) |
|
||||
| `-s` | --search | 使用正则表达式搜索文档,返回所有匹配结果(用---分隔) |
|
||||
|
||||
## Examples
|
||||
|
||||
### 示例 1: 提取完整文档内容
|
||||
```bash
|
||||
# 提取完整文档
|
||||
python3 docx_parser.py /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:完整的 Markdown 格式文档内容
|
||||
|
||||
### 示例 2: 获取文档字数
|
||||
```bash
|
||||
# 获取字数
|
||||
python3 docx_parser.py -c /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:文档总字数(数字)
|
||||
|
||||
### 示例 3: 提取所有标题
|
||||
```bash
|
||||
# 提取标题
|
||||
python3 docx_parser.py -t /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:所有 1-6 级标题列表
|
||||
|
||||
### 示例 4: 提取指定章节
|
||||
```bash
|
||||
# 提取 "第一章" 内容
|
||||
python3 docx_parser.py -tc "第一章" /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:该章节的完整内容(包含上级标题链和所有下级内容)
|
||||
|
||||
### 示例 5: 搜索关键词
|
||||
```bash
|
||||
# 搜索关键词(默认 2 行上下文)
|
||||
python3 docx_parser.py -s "关键词" /path/to/document.docx
|
||||
|
||||
# 自定义 5 行上下文
|
||||
python3 docx_parser.py -s "关键词" -n 5 /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:所有匹配结果及其上下文,用 `---` 分隔
|
||||
|
||||
## 依赖安装
|
||||
|
||||
### 推荐方式:使用 lyxy-runner-python
|
||||
|
||||
如果使用 lyxy-runner-python skill,依赖会自动管理,无需手动安装。
|
||||
|
||||
### 手动安装(降级模式)
|
||||
|
||||
如果直接使用 Python 执行,需要手动安装至少一个解析库:
|
||||
|
||||
```bash
|
||||
# 安装 MarkItDown(推荐)
|
||||
pip install markitdown
|
||||
|
||||
# 安装 python-docx(备选)
|
||||
pip install python-docx
|
||||
```
|
||||
|
||||
**重要限制**:
|
||||
- ✗ **禁止自动安装**: 不得自动执行 pip install 命令安装依赖
|
||||
- ✗ **仅提示即可**: 向用户展示安装建议,但由用户决定是否安装
|
||||
- ✗ **不阻塞执行**: 即使未安装依赖,脚本也能通过 XML 原生解析运行
|
||||
|
||||
### 多策略解析
|
||||
|
||||
脚本自动尝试以下解析方法,确保最大兼容性:
|
||||
1. **MarkItDown**(微软官方库,效果最佳)
|
||||
2. **python-docx**(成熟的 Python 库)
|
||||
3. **XML 原生解析**(备选方案,无需任何依赖)
|
||||
|
||||
即使未安装任何依赖库,脚本也会尝试使用 XML 原生解析,但功能可能受限。
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 常见错误
|
||||
|
||||
| 错误类型 | 说明 | 解决方案 |
|
||||
| --------- | ---- | -------- |
|
||||
| 文件不存在 | 提供的文件路径无效 | 检查文件路径是否正确 |
|
||||
| 无效的 DOCX | 文件不是有效的 DOCX 格式或已损坏 | 确认文件格式正确 |
|
||||
| 未找到标题 | 指定的标题名称不存在 | 使用 `-t` 参数查看所有标题 |
|
||||
| 正则表达式无效 | 提供的正则表达式格式错误 | 检查正则表达式语法 |
|
||||
| 解析库未安装 | 未安装 markitdown 或 python-docx | 提示用户安装以获得更好的解析效果,但禁止自动安装。脚本会自动降级到 XML 原生解析。 |
|
||||
|
||||
## Notes
|
||||
|
||||
### 为什么选择 lyxy-runner-python?
|
||||
|
||||
| 特性 | 优势 |
|
||||
| ------ | ------ |
|
||||
| 环境隔离 | 不污染系统 Python |
|
||||
| 自动依赖 | 自动安装 markitdown 或 python-docx |
|
||||
| 快速启动 | 比 venv 快 10-100 倍 |
|
||||
| 跨平台 | 自动适配 Windows/macOS/Linux |
|
||||
| 零配置 | 开箱即用,无需预安装依赖 |
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **优先使用 lyxy-runner-python**: 如果环境中存在,应优先使用 lyxy-runner-python 执行脚本
|
||||
2. **大文件处理**: 对于大文档,建议使用章节提取或关键词搜索来限制处理范围
|
||||
3. **依赖管理**: 使用 lyxy-runner-python 可以自动管理依赖,避免环境配置问题
|
||||
4. **错误处理**: 脚本会自动尝试多种解析方法,确保最大兼容性
|
||||
5. **禁止自动安装**: 在降级到直接 Python 执行时,仅向用户提示安装依赖,不得自动执行 pip install
|
||||
|
||||
### 限制
|
||||
|
||||
- ✗ 不支持图片提取(仅纯文本)
|
||||
- ✗ 不支持复杂的格式保留(字体、颜色、布局等)
|
||||
- ✗ 不支持文档编辑或修改
|
||||
- ✗ 仅支持 .docx 格式(不支持 .doc 或其他格式)
|
||||
@@ -1,319 +0,0 @@
|
||||
# DOCX 解析器使用说明
|
||||
|
||||
## 简介
|
||||
|
||||
`docx_parser.py` 是一个功能强大的 DOCX 文件解析工具,支持将 Microsoft Word (.docx) 文档转换为 Markdown 格式。该脚本采用多策略解析机制,按优先级尝试以下解析方法:
|
||||
|
||||
1. **MarkItDown**(微软官方库)
|
||||
2. **python-docx**(成熟的 Python 库)
|
||||
3. **XML 原生解析**(备选方案)
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.6+
|
||||
- pip
|
||||
|
||||
## 安装依赖
|
||||
|
||||
根据你的需求安装相应的解析库:
|
||||
|
||||
```bash
|
||||
# 安装 MarkItDown(推荐)
|
||||
pip install markitdown
|
||||
|
||||
# 安装 python-docx(备选)
|
||||
pip install python-docx
|
||||
```
|
||||
|
||||
> 注意:建议至少安装一种解析库。如果未安装任何库,脚本会尝试使用 XML 原生解析,但功能可能受限。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py [options] <file_path>
|
||||
```
|
||||
|
||||
### 位置参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
| ----------- | ------------------- |
|
||||
| `file_path` | DOCX 文件的绝对路径 |
|
||||
|
||||
### 选项参数
|
||||
|
||||
| 参数 | 长参数 | 类型 | 默认值 | 说明 |
|
||||
| ---- | ----------- | ---- | ------ | -------------------------------------------------------------- |
|
||||
| `-n` | `--context` | 整数 | 2 | 与 `-s` 配合使用,指定每个检索结果包含的前后行数(不包含空行) |
|
||||
|
||||
### 互斥参数
|
||||
|
||||
以下参数只能使用其中一个:
|
||||
|
||||
| 参数 | 长参数 | 说明 |
|
||||
| ----- | ----------------- | ----------------------------------------------------- |
|
||||
| `-c` | `--count` | 返回解析后的 markdown 文档的总字数 |
|
||||
| `-l` | `--lines` | 返回解析后的 markdown 文档的总行数 |
|
||||
| `-t` | `--titles` | 返回解析后的 markdown 文档的标题行(1-6级) |
|
||||
| `-tc` | `--title-content` | 指定标题名称,输出该标题及其下级内容(不包含#号) |
|
||||
| `-s` | `--search` | 使用正则表达式搜索文档,返回所有匹配结果(用---分隔) |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 输出完整 Markdown 内容
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:完整的 Markdown 格式文档内容
|
||||
|
||||
### 2. 获取文档字数
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py -c /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:文档总字数(数字)
|
||||
|
||||
### 3. 获取文档行数
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py -l /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:文档总行数(数字)
|
||||
|
||||
### 4. 提取所有标题
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py -t /path/to/document.docx
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
# 主标题
|
||||
## 第一章
|
||||
### 1.1 简介
|
||||
### 1.2 内容
|
||||
## 第二章
|
||||
```
|
||||
|
||||
### 5. 提取指定标题内容
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py -tc "第一章" /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:包含所有上级标题的指定章节内容
|
||||
|
||||
**特点:**
|
||||
|
||||
- 支持多个同名标题
|
||||
- 自动包含完整的上级标题链
|
||||
- 包含所有下级内容
|
||||
|
||||
示例输出:
|
||||
|
||||
```
|
||||
# 主标题
|
||||
## 第一章
|
||||
这是第一章的内容
|
||||
包含所有子章节...
|
||||
|
||||
### 1.1 简介
|
||||
简介内容
|
||||
|
||||
### 1.2 内容
|
||||
详细内容
|
||||
```
|
||||
|
||||
### 6. 搜索关键词
|
||||
|
||||
#### 6.1 基本搜索
|
||||
|
||||
```bash
|
||||
python3 docx_parser.py -s "关键词" /path/to/document.docx
|
||||
```
|
||||
|
||||
输出:所有匹配关键词的内容片段,默认前后各 2 行上下文,用 `---` 分隔
|
||||
|
||||
#### 6.2 自定义上下文行数
|
||||
|
||||
```bash
|
||||
# 前后各 5 行
|
||||
python3 docx_parser.py -s "关键词" -n 5 /path/to/document.docx
|
||||
|
||||
# 不包含上下文
|
||||
python3 docx_parser.py -s "关键词" -n 0 /path/to/document.docx
|
||||
```
|
||||
|
||||
#### 6.3 正则表达式搜索
|
||||
|
||||
```bash
|
||||
# 搜索包含数字的行
|
||||
python3 docx_parser.py -s r"数字\d+" /path/to/document.docx
|
||||
|
||||
# 搜索邮箱地址
|
||||
python3 docx_parser.py -s r"\b[\w.-]+@[\w.-]+\.\w+\b" /path/to/document.docx
|
||||
|
||||
# 搜索日期格式
|
||||
python3 docx_parser.py -s r"\d{4}-\d{2}-\d{2}" /path/to/document.docx
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
这是前一行
|
||||
包含匹配关键词
|
||||
这是后一行
|
||||
---
|
||||
另一个匹配
|
||||
---
|
||||
第三个匹配
|
||||
```
|
||||
|
||||
### 7. 将输出保存到文件
|
||||
|
||||
```bash
|
||||
# 保存完整 Markdown
|
||||
python3 docx_parser.py /path/to/document.docx > output.md
|
||||
|
||||
# 保存标题内容
|
||||
python3 docx_parser.py -tc "第一章" /path/to/document.docx > chapter1.md
|
||||
|
||||
# 保存搜索结果
|
||||
python3 docx_parser.py -s "关键词" /path/to/document.docx > search_results.md
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 多策略解析
|
||||
|
||||
脚本自动尝试三种解析方法,确保最大的兼容性:
|
||||
|
||||
1. **MarkItDown**:微软官方库,解析效果最佳
|
||||
2. **python-docx**:功能完善的第三方库
|
||||
3. **XML 原生解析**:不依赖任何库的备选方案
|
||||
|
||||
### 智能匹配
|
||||
|
||||
#### 标题提取
|
||||
|
||||
- 支持 1-6 级标题识别
|
||||
- 自动处理不同样式的标题(Title、Heading 1-6)
|
||||
- 保留原始标题层级关系
|
||||
|
||||
#### 标题内容提取
|
||||
|
||||
- 支持同名标题提取
|
||||
- 自动构建完整上级标题链
|
||||
- 包含所有下级内容
|
||||
- 保持文档结构完整
|
||||
|
||||
#### 搜索功能
|
||||
|
||||
- 支持正则表达式
|
||||
- 智能合并相近匹配
|
||||
- 上下文行数控制(不包含空行)
|
||||
- 结果用 `---` 清晰分隔
|
||||
|
||||
### 文档处理
|
||||
|
||||
- 自动移除 Markdown 图片
|
||||
- 规范化空白行(连续多个空行合并为一个)
|
||||
- 支持表格、列表、粗体、斜体、下划线等格式
|
||||
|
||||
### 错误处理
|
||||
|
||||
- 文件存在性检查
|
||||
- DOCX 格式验证
|
||||
- 解析失败时自动尝试下一种方法
|
||||
- 详细的错误提示信息
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何处理大文档?
|
||||
|
||||
A: 对于非常大的文档,建议:
|
||||
|
||||
1. 使用 `-tc` 参数只提取需要的章节
|
||||
2. 使用 `-s` 参数搜索特定内容
|
||||
3. 将输出重定向到文件进行处理
|
||||
|
||||
### Q: 搜索功能支持哪些正则表达式?
|
||||
|
||||
A: 支持所有 Python 标准正则表达式语法。需要注意特殊字符的转义:
|
||||
|
||||
```bash
|
||||
# 错误:括号需要转义
|
||||
python3 docx_parser.py -s "(关键词)" /path/to/document.docx
|
||||
|
||||
# 正确
|
||||
python3 docx_parser.py -s "\(关键词\)" /path/to/document.docx
|
||||
```
|
||||
|
||||
### Q: 如何获取更多上下文?
|
||||
|
||||
A: 使用 `-n` 参数调整上下文行数:
|
||||
|
||||
```bash
|
||||
# 默认 2 行(推荐)
|
||||
python3 docx_parser.py -s "关键词" /path/to/document.docx
|
||||
|
||||
# 更多上下文(5 行)
|
||||
python3 docx_parser.py -s "关键词" -n 5 /path/to/document.docx
|
||||
|
||||
# 不包含上下文
|
||||
python3 docx_parser.py -s "关键词" -n 0 /path/to/document.docx
|
||||
```
|
||||
|
||||
### Q: 多个同名标题如何处理?
|
||||
|
||||
A: `-tc` 参数会返回所有同名标题,每个标题都包含其完整的上级标题链:
|
||||
|
||||
```markdown
|
||||
# 主标题
|
||||
|
||||
## 同名标题 1
|
||||
|
||||
内容1
|
||||
|
||||
# 主标题
|
||||
|
||||
## 同名标题 2
|
||||
|
||||
内容2
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 标题识别规则
|
||||
|
||||
| 样式名称 | Markdown 标题级别 |
|
||||
| --------- | ----------------- |
|
||||
| Title | # |
|
||||
| Heading 1 | # |
|
||||
| Heading 2 | ## |
|
||||
| Heading 3 | ### |
|
||||
| Heading 4 | #### |
|
||||
| Heading 5 | ##### |
|
||||
| Heading 6 | ###### |
|
||||
|
||||
### 列表识别规则
|
||||
|
||||
| 样式名称 | Markdown 列表格式 |
|
||||
| -------------------- | ----------------- |
|
||||
| List Bullet / Bullet | - (无序列表) |
|
||||
| List Number / Number | 1. (有序列表) |
|
||||
|
||||
### 文本格式支持
|
||||
|
||||
| 格式 | 转换结果 |
|
||||
| ------ | ----------------- |
|
||||
| 粗体 | `**文本**` |
|
||||
| 斜体 | `*文本*` |
|
||||
| 下划线 | `<u>文本</u>` |
|
||||
| 表格 | Markdown 表格格式 |
|
||||
@@ -1,551 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""整合的 DOCX 解析器,按优先级尝试多种解析方法:
|
||||
1. MarkItDown (微软官方库)
|
||||
2. python-docx (成熟的 Python 库)
|
||||
3. XML 原生解析 (备选方案)
|
||||
|
||||
代码风格要求:
|
||||
- Python 3.6+ 兼容
|
||||
- 遵循 PEP 8 规范
|
||||
- 所有公共 API 函数添加类型提示
|
||||
- 字符串优先内联使用,不提取为常量,除非被使用超过3次
|
||||
- 其他被多次使用的对象根据具体情况可考虑被提取为常量(如正则表达式)
|
||||
- 模块级和公共 API 函数保留文档字符串
|
||||
- 内部辅助函数不添加文档字符串(函数名足够描述)
|
||||
- 变量命名清晰,避免单字母变量名
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]+\)")
|
||||
|
||||
|
||||
def normalize_markdown_whitespace(content: str) -> str:
|
||||
lines = content.split("\n")
|
||||
result = []
|
||||
empty_count = 0
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
empty_count += 1
|
||||
if empty_count == 1:
|
||||
result.append(line)
|
||||
else:
|
||||
empty_count = 0
|
||||
result.append(line)
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def is_valid_docx(file_path: str) -> bool:
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_file:
|
||||
required_files = ["[Content_Types].xml", "_rels/.rels", "word/document.xml"]
|
||||
for required in required_files:
|
||||
if required not in zip_file.namelist():
|
||||
return False
|
||||
return True
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile):
|
||||
return False
|
||||
|
||||
|
||||
def remove_markdown_images(markdown_text: str) -> str:
|
||||
return IMAGE_PATTERN.sub("", markdown_text)
|
||||
|
||||
|
||||
def extract_titles(markdown_text: str) -> List[str]:
|
||||
"""提取 markdown 文本中的所有标题行(1-6级)"""
|
||||
title_lines = []
|
||||
for line in markdown_text.split("\n"):
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
level = 0
|
||||
for char in stripped:
|
||||
if char == "#":
|
||||
level += 1
|
||||
else:
|
||||
break
|
||||
if 1 <= level <= 6:
|
||||
title_lines.append(stripped)
|
||||
return title_lines
|
||||
|
||||
|
||||
def get_heading_level(line: str) -> int:
|
||||
stripped = line.lstrip()
|
||||
if not stripped.startswith("#"):
|
||||
return 0
|
||||
level = 0
|
||||
for char in stripped:
|
||||
if char == "#":
|
||||
level += 1
|
||||
else:
|
||||
break
|
||||
return level if 1 <= level <= 6 else 0
|
||||
|
||||
|
||||
def extract_title_content(markdown_text: str, title_name: str) -> Optional[str]:
|
||||
"""提取所有指定标题及其下级内容(每个包含上级标题)"""
|
||||
lines = markdown_text.split("\n")
|
||||
match_indices = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
level = get_heading_level(line)
|
||||
if level > 0:
|
||||
stripped = line.lstrip()
|
||||
title_text = stripped[level:].strip()
|
||||
if title_text == title_name:
|
||||
match_indices.append(i)
|
||||
|
||||
if not match_indices:
|
||||
return None
|
||||
|
||||
result_lines = []
|
||||
for idx in match_indices:
|
||||
target_level = get_heading_level(lines[idx])
|
||||
|
||||
parent_titles = []
|
||||
current_level = target_level
|
||||
for i in range(idx - 1, -1, -1):
|
||||
line_level = get_heading_level(lines[i])
|
||||
if line_level > 0 and line_level < current_level:
|
||||
parent_titles.append(lines[i])
|
||||
current_level = line_level
|
||||
if current_level == 1:
|
||||
break
|
||||
|
||||
parent_titles.reverse()
|
||||
result_lines.extend(parent_titles)
|
||||
|
||||
result_lines.append(lines[idx])
|
||||
for i in range(idx + 1, len(lines)):
|
||||
line = lines[i]
|
||||
line_level = get_heading_level(line)
|
||||
if line_level == 0 or line_level > target_level:
|
||||
result_lines.append(line)
|
||||
else:
|
||||
break
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def search_markdown(
|
||||
content: str, pattern: str, context_lines: int = 0
|
||||
) -> Optional[str]:
|
||||
"""使用正则表达式搜索 markdown 文档,返回匹配结果及其上下文"""
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
except re.error:
|
||||
return None
|
||||
|
||||
lines = content.split("\n")
|
||||
|
||||
non_empty_indices = []
|
||||
non_empty_to_original = {}
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip():
|
||||
non_empty_indices.append(i)
|
||||
non_empty_to_original[i] = len(non_empty_indices) - 1
|
||||
|
||||
matched_non_empty_indices = []
|
||||
for orig_idx in non_empty_indices:
|
||||
if regex.search(lines[orig_idx]):
|
||||
matched_non_empty_indices.append(non_empty_to_original[orig_idx])
|
||||
|
||||
if not matched_non_empty_indices:
|
||||
return None
|
||||
|
||||
merged_ranges = []
|
||||
current_start = matched_non_empty_indices[0]
|
||||
current_end = matched_non_empty_indices[0]
|
||||
|
||||
for idx in matched_non_empty_indices[1:]:
|
||||
if idx - current_end <= context_lines * 2:
|
||||
current_end = idx
|
||||
else:
|
||||
merged_ranges.append((current_start, current_end))
|
||||
current_start = idx
|
||||
current_end = idx
|
||||
merged_ranges.append((current_start, current_end))
|
||||
|
||||
results = []
|
||||
for start, end in merged_ranges:
|
||||
actual_start = max(0, start - context_lines)
|
||||
actual_end = min(len(non_empty_indices) - 1, end + context_lines)
|
||||
|
||||
start_line_idx = non_empty_indices[actual_start]
|
||||
end_line_idx = non_empty_indices[actual_end]
|
||||
|
||||
selected_indices = set(non_empty_indices[actual_start : actual_end + 1])
|
||||
result_lines = [
|
||||
line
|
||||
for i, line in enumerate(lines)
|
||||
if start_line_idx <= i <= end_line_idx
|
||||
and (line.strip() or i in selected_indices)
|
||||
]
|
||||
results.append("\n".join(result_lines))
|
||||
|
||||
return "\n---\n".join(results)
|
||||
|
||||
|
||||
def parse_with_markitdown(file_path: str) -> Optional[Tuple[str, None]]:
|
||||
try:
|
||||
from markitdown import MarkItDown
|
||||
|
||||
md = MarkItDown()
|
||||
result = md.convert(file_path)
|
||||
if not result.text_content.strip():
|
||||
return None, "文档为空"
|
||||
return result.text_content, None
|
||||
except ImportError:
|
||||
return None, "MarkItDown 库未安装"
|
||||
except Exception as e:
|
||||
return None, f"MarkItDown 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_with_python_docx(file_path: str) -> Optional[Tuple[str, None]]:
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
return None, "python-docx 库未安装"
|
||||
|
||||
try:
|
||||
doc = Document(file_path)
|
||||
|
||||
def get_heading_level(para) -> int:
|
||||
if para.style and para.style.name:
|
||||
style_name = para.style.name
|
||||
if "Heading 1" in style_name or "Title" in style_name:
|
||||
return 1
|
||||
elif "Heading 2" in style_name:
|
||||
return 2
|
||||
elif "Heading 3" in style_name:
|
||||
return 3
|
||||
elif "Heading 4" in style_name:
|
||||
return 4
|
||||
elif "Heading 5" in style_name:
|
||||
return 5
|
||||
elif "Heading 6" in style_name:
|
||||
return 6
|
||||
return 0
|
||||
|
||||
def get_list_style(para) -> Optional[str]:
|
||||
if not para.style or not para.style.name:
|
||||
return None
|
||||
style_name = para.style.name
|
||||
if "List Bullet" in style_name or "Bullet" in style_name:
|
||||
return "bullet"
|
||||
elif "List Number" in style_name or "Number" in style_name:
|
||||
return "number"
|
||||
return None
|
||||
|
||||
def convert_runs_to_markdown(runs) -> str:
|
||||
result = []
|
||||
for run in runs:
|
||||
text = run.text
|
||||
if not text:
|
||||
continue
|
||||
if run.bold:
|
||||
text = f"**{text}**"
|
||||
if run.italic:
|
||||
text = f"*{text}*"
|
||||
if run.underline:
|
||||
text = f"<u>{text}</u>"
|
||||
result.append(text)
|
||||
return "".join(result)
|
||||
|
||||
def convert_table_to_markdown(table) -> str:
|
||||
md_lines = []
|
||||
for i, row in enumerate(table.rows):
|
||||
cells = []
|
||||
for cell in row.cells:
|
||||
cell_text = cell.text.strip().replace("\n", " ")
|
||||
cells.append(cell_text)
|
||||
if cells:
|
||||
md_line = "| " + " | ".join(cells) + " |"
|
||||
md_lines.append(md_line)
|
||||
if i == 0:
|
||||
sep_line = "| " + " | ".join(["---"] * len(cells)) + " |"
|
||||
md_lines.append(sep_line)
|
||||
return "\n".join(md_lines)
|
||||
|
||||
markdown_lines = []
|
||||
|
||||
for para in doc.paragraphs:
|
||||
text = convert_runs_to_markdown(para.runs)
|
||||
if not text.strip():
|
||||
continue
|
||||
heading_level = get_heading_level(para)
|
||||
if heading_level > 0:
|
||||
markdown_lines.append(f"{'#' * heading_level} {text}")
|
||||
else:
|
||||
list_style = get_list_style(para)
|
||||
if list_style == "bullet":
|
||||
markdown_lines.append(f"- {text}")
|
||||
elif list_style == "number":
|
||||
markdown_lines.append(f"1. {text}")
|
||||
else:
|
||||
markdown_lines.append(text)
|
||||
markdown_lines.append("")
|
||||
|
||||
for table in doc.tables:
|
||||
table_md = convert_table_to_markdown(table)
|
||||
markdown_lines.append(table_md)
|
||||
markdown_lines.append("")
|
||||
|
||||
content = "\n".join(markdown_lines)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"python-docx 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_with_xml(file_path: str) -> Optional[Tuple[str, None]]:
|
||||
word_namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
namespaces = {"w": word_namespace}
|
||||
|
||||
def safe_open_zip(zip_file: zipfile.ZipFile, name: str):
|
||||
if name.startswith("..") or "/" not in name:
|
||||
return None
|
||||
return zip_file.open(name)
|
||||
|
||||
def get_heading_level(style_id: Optional[str], style_to_level: dict) -> int:
|
||||
return style_to_level.get(style_id, 0)
|
||||
|
||||
def get_list_style(style_id: Optional[str], style_to_list: dict) -> Optional[str]:
|
||||
return style_to_list.get(style_id, None)
|
||||
|
||||
def extract_text_with_formatting(para, namespaces: dict) -> str:
|
||||
texts = []
|
||||
for run in para.findall(".//w:r", namespaces=namespaces):
|
||||
text_elem = run.find(".//w:t", namespaces=namespaces)
|
||||
if text_elem is not None and text_elem.text:
|
||||
text = text_elem.text
|
||||
bold = run.find(".//w:b", namespaces=namespaces) is not None
|
||||
italic = run.find(".//w:i", namespaces=namespaces) is not None
|
||||
if bold:
|
||||
text = f"**{text}**"
|
||||
if italic:
|
||||
text = f"*{text}*"
|
||||
texts.append(text)
|
||||
return "".join(texts).strip()
|
||||
|
||||
def convert_table_to_markdown(table_elem, namespaces: dict) -> str:
|
||||
rows = table_elem.findall(".//w:tr", namespaces=namespaces)
|
||||
if not rows:
|
||||
return ""
|
||||
md_lines = []
|
||||
for i, row in enumerate(rows):
|
||||
cells = row.findall(".//w:tc", namespaces=namespaces)
|
||||
cell_texts = []
|
||||
for cell in cells:
|
||||
cell_text = extract_text_with_formatting(cell, namespaces)
|
||||
cell_text = cell_text.replace("\n", " ").strip()
|
||||
cell_texts.append(cell_text if cell_text else "")
|
||||
if cell_texts:
|
||||
md_line = "| " + " | ".join(cell_texts) + " |"
|
||||
md_lines.append(md_line)
|
||||
if i == 0:
|
||||
sep_line = "| " + " | ".join(["---"] * len(cell_texts)) + " |"
|
||||
md_lines.append(sep_line)
|
||||
return "\n".join(md_lines)
|
||||
|
||||
try:
|
||||
style_to_level = {}
|
||||
style_to_list = {}
|
||||
markdown_lines = []
|
||||
|
||||
with zipfile.ZipFile(file_path) as zip_file:
|
||||
try:
|
||||
styles_file = safe_open_zip(zip_file, "word/styles.xml")
|
||||
if styles_file:
|
||||
styles_root = ET.parse(styles_file)
|
||||
for style in styles_root.findall(
|
||||
".//w:style", namespaces=namespaces
|
||||
):
|
||||
style_id = style.get(f"{{{word_namespace}}}styleId")
|
||||
style_name_elem = style.find("w:name", namespaces=namespaces)
|
||||
if style_id and style_name_elem is not None:
|
||||
style_name = style_name_elem.get(f"{{{word_namespace}}}val")
|
||||
if style_name:
|
||||
if style_name == "Title":
|
||||
style_to_level[style_id] = 1
|
||||
elif style_name == "heading 1":
|
||||
style_to_level[style_id] = 1
|
||||
elif style_name == "heading 2":
|
||||
style_to_level[style_id] = 2
|
||||
elif style_name == "heading 3":
|
||||
style_to_level[style_id] = 3
|
||||
elif style_name == "heading 4":
|
||||
style_to_level[style_id] = 4
|
||||
elif style_name == "heading 5":
|
||||
style_to_level[style_id] = 5
|
||||
elif style_name == "heading 6":
|
||||
style_to_level[style_id] = 6
|
||||
elif (
|
||||
"List Bullet" in style_name
|
||||
or "Bullet" in style_name
|
||||
):
|
||||
style_to_list[style_id] = "bullet"
|
||||
elif (
|
||||
"List Number" in style_name
|
||||
or "Number" in style_name
|
||||
):
|
||||
style_to_list[style_id] = "number"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
document_file = safe_open_zip(zip_file, "word/document.xml")
|
||||
if not document_file:
|
||||
return None, "document.xml 不存在或无法访问"
|
||||
|
||||
root = ET.parse(document_file)
|
||||
body = root.find(".//w:body", namespaces=namespaces)
|
||||
if body is None:
|
||||
return None, "document.xml 中未找到 w:body 元素"
|
||||
|
||||
for child in body.findall("./*", namespaces=namespaces):
|
||||
if child.tag.endswith("}p"):
|
||||
style_elem = child.find(".//w:pStyle", namespaces=namespaces)
|
||||
style_id = (
|
||||
style_elem.get(f"{{{word_namespace}}}val")
|
||||
if style_elem is not None
|
||||
else None
|
||||
)
|
||||
|
||||
heading_level = get_heading_level(style_id, style_to_level)
|
||||
list_style = get_list_style(style_id, style_to_list)
|
||||
para_text = extract_text_with_formatting(child, namespaces)
|
||||
|
||||
if para_text:
|
||||
if heading_level > 0:
|
||||
markdown_lines.append(f"{'#' * heading_level} {para_text}")
|
||||
elif list_style == "bullet":
|
||||
markdown_lines.append(f"- {para_text}")
|
||||
elif list_style == "number":
|
||||
markdown_lines.append(f"1. {para_text}")
|
||||
else:
|
||||
markdown_lines.append(para_text)
|
||||
markdown_lines.append("")
|
||||
|
||||
elif child.tag.endswith("}tbl"):
|
||||
table_md = convert_table_to_markdown(child, namespaces)
|
||||
if table_md:
|
||||
markdown_lines.append(table_md)
|
||||
markdown_lines.append("")
|
||||
|
||||
content = "\n".join(markdown_lines)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"XML 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="将 DOCX 文件解析为 Markdown")
|
||||
|
||||
parser.add_argument("file_path", help="DOCX 文件的绝对路径")
|
||||
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--context",
|
||||
type=int,
|
||||
default=2,
|
||||
help="与 -s 配合使用,指定每个检索结果包含的前后行数(不包含空行)",
|
||||
)
|
||||
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"-c", "--count", action="store_true", help="返回解析后的 markdown 文档的总字数"
|
||||
)
|
||||
group.add_argument(
|
||||
"-l", "--lines", action="store_true", help="返回解析后的 markdown 文档的总行数"
|
||||
)
|
||||
group.add_argument(
|
||||
"-t",
|
||||
"--titles",
|
||||
action="store_true",
|
||||
help="返回解析后的 markdown 文档的标题行(1-6级)",
|
||||
)
|
||||
group.add_argument(
|
||||
"-tc",
|
||||
"--title-content",
|
||||
help="指定标题名称,输出该标题及其下级内容(不包含#号)",
|
||||
)
|
||||
group.add_argument(
|
||||
"-s",
|
||||
"--search",
|
||||
help="使用正则表达式搜索文档,返回所有匹配结果(用---分隔)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.file_path):
|
||||
print(f"错误: 文件不存在: {args.file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not args.file_path.lower().endswith(".docx"):
|
||||
print(f"警告: 文件扩展名不是 .docx: {args.file_path}")
|
||||
|
||||
if not is_valid_docx(args.file_path):
|
||||
print(f"错误: 文件不是有效的 DOCX 格式或已损坏: {args.file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
parsers = [
|
||||
("MarkItDown", parse_with_markitdown),
|
||||
("python-docx", parse_with_python_docx),
|
||||
("XML 原生解析", parse_with_xml),
|
||||
]
|
||||
|
||||
failures = []
|
||||
content = None
|
||||
|
||||
for parser_name, parser_func in parsers:
|
||||
content, error = parser_func(args.file_path)
|
||||
if content is not None:
|
||||
content = remove_markdown_images(content)
|
||||
content = normalize_markdown_whitespace(content)
|
||||
break
|
||||
else:
|
||||
failures.append(f"- {parser_name}: {error}")
|
||||
|
||||
if content is None:
|
||||
print("所有解析方法均失败:")
|
||||
for failure in failures:
|
||||
print(failure)
|
||||
sys.exit(1)
|
||||
|
||||
if args.count:
|
||||
print(len(content.replace("\n", "")))
|
||||
elif args.lines:
|
||||
print(len(content.split("\n")))
|
||||
elif args.titles:
|
||||
titles = extract_titles(content)
|
||||
for title in titles:
|
||||
print(title)
|
||||
elif args.title_content:
|
||||
title_content = extract_title_content(content, args.title_content)
|
||||
if title_content is None:
|
||||
print(f"错误: 未找到标题 '{args.title_content}'")
|
||||
sys.exit(1)
|
||||
print(title_content, end="")
|
||||
elif args.search:
|
||||
search_result = search_markdown(content, args.search, args.context)
|
||||
if search_result is None:
|
||||
print(f"错误: 正则表达式无效或未找到匹配: '{args.search}'")
|
||||
sys.exit(1)
|
||||
print(search_result, end="")
|
||||
else:
|
||||
print(content, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
212
skills/lyxy-reader-office/SKILL.md
Normal file
212
skills/lyxy-reader-office/SKILL.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
name: lyxy-reader-office
|
||||
description: 优先解析 docx、xlsx、pptx、pdf 四种办公文档的 skill,将文档转换为 Markdown 格式,支持全文提取、标题提取、章节提取、正则搜索、字数统计、行数统计,PDF 额外支持 OCR 高精度模式。使用时请阅读 scripts/README.md 获取详细用法。
|
||||
compatibility: Requires Python 3.6+. DOCX/PPTX/XLSX 无需额外依赖(XML 原生解析);PDF 至少需要 pypdf。推荐通过 lyxy-runner-python skill 使用 uv 自动管理依赖。
|
||||
---
|
||||
|
||||
# 办公文档解析 Skill
|
||||
|
||||
将 Microsoft Office 文档(.docx、.pptx、.xlsx)和 PDF 文件解析为 Markdown 格式,支持多种查询模式。
|
||||
|
||||
## Purpose
|
||||
|
||||
**统一入口**:使用 `scripts/parser.py` 作为统一的命令行入口,自动识别文件类型并分派到对应的格式解析器。
|
||||
|
||||
**依赖选项**:此 skill 必须优先使用 lyxy-runner-python skill 执行,不可用时降级到直接 Python 执行。
|
||||
|
||||
### 必须使用 lyxy-runner-python
|
||||
|
||||
如果环境中存在 lyxy-runner-python skill,**必须**使用它来执行 parser.py 脚本:
|
||||
- lyxy-runner-python 使用 uv 管理依赖,自动安装所需的第三方库
|
||||
- 环境隔离,不污染系统 Python
|
||||
- 跨平台兼容(Windows/macOS/Linux)
|
||||
|
||||
### 降级到直接执行
|
||||
|
||||
**仅当** lyxy-runner-python skill 不存在时,才降级到直接 Python 执行:
|
||||
- 需要用户手动安装依赖
|
||||
- DOCX/PPTX/XLSX 无需依赖也可通过 XML 原生解析工作
|
||||
- PDF 至少需要安装 pypdf
|
||||
- **禁止自动执行 pip install**,仅向用户提示安装建议
|
||||
|
||||
## When to Use
|
||||
|
||||
任何需要读取或解析 .docx、.xlsx、.pptx、.pdf 文件内容的任务都应使用此 skill。
|
||||
|
||||
### 典型场景
|
||||
- **文档内容提取**:将 Word/PPT/Excel/PDF 文档转换为可读的 Markdown 文本
|
||||
- **文档元数据**:获取文档的字数、行数等信息
|
||||
- **标题分析**:提取文档的标题结构
|
||||
- **章节提取**:提取特定章节的内容
|
||||
- **内容搜索**:在文档中搜索关键词或模式
|
||||
- **PDF OCR**:对扫描版 PDF 启用 OCR 高精度解析
|
||||
|
||||
### 不适用场景
|
||||
- 需要提取图片内容(仅支持纯文本)
|
||||
- 需要保留复杂的格式信息(字体、颜色、布局)
|
||||
- 需要编辑或修改文档
|
||||
- 需要处理 .doc、.xls、.ppt 等旧格式
|
||||
|
||||
### 触发词
|
||||
|
||||
**中文触发词**
|
||||
- "读取/解析/打开 docx/word 文档"
|
||||
- "读取/解析/打开 xlsx/excel 文件"
|
||||
- "读取/解析/打开 pptx/ppt 文件"
|
||||
- "读取/解析/打开 pdf 文件"
|
||||
|
||||
**英文触发词**
|
||||
- "read/parse/extract docx/word/xlsx/excel/pptx/powerpoint/pdf"
|
||||
|
||||
**文件扩展名**
|
||||
- `.docx`、`.xlsx`、`.pptx`、`.pdf`
|
||||
|
||||
## Capabilities
|
||||
|
||||
### 1. 全文转换为 Markdown
|
||||
将完整文档解析为 Markdown 格式,移除图片但保留文本格式(标题、列表、表格、粗体、斜体等)。
|
||||
|
||||
各格式的输出特点:
|
||||
- **DOCX**:标准 Markdown 文档结构
|
||||
- **PPTX**:每张幻灯片以 `## Slide N` 为标题,幻灯片之间以 `---` 分隔
|
||||
- **XLSX**:以 `## SheetName` 区分工作表,数据以 Markdown 表格呈现
|
||||
- **PDF**:纯文本流,使用 `--high-res` 可启用 OCR 版面分析识别标题
|
||||
|
||||
### 2. 获取文档元信息
|
||||
- 字数统计(`-c` 参数)
|
||||
- 行数统计(`-l` 参数)
|
||||
|
||||
### 3. 标题列表提取
|
||||
提取文档中所有 1-6 级标题(`-t` 参数),按原始层级关系返回。
|
||||
|
||||
### 4. 指定章节内容提取
|
||||
根据标题名称提取特定章节的完整内容(`-tc` 参数),包含上级标题链和所有下级内容。
|
||||
|
||||
### 5. 正则表达式搜索
|
||||
在文档中搜索关键词或模式(`-s` 参数),支持自定义上下文行数(`-n` 参数,默认 2 行)。
|
||||
|
||||
### 6. PDF OCR 高精度模式
|
||||
对 PDF 文件启用 OCR 版面分析(`--high-res` 参数),适用于扫描版 PDF 或需要识别标题层级的场景。
|
||||
|
||||
## Execution
|
||||
|
||||
### 详细用法参考
|
||||
|
||||
**执行前请阅读 `scripts/README.md`**,其中包含:
|
||||
- 完整的命令行参数说明
|
||||
- 各格式的依赖安装指南(pip 和 uv 方式)
|
||||
- 解析器优先级和对比
|
||||
- 输出格式说明
|
||||
- 错误处理和常见问题
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
python parser.py <file_path> [options]
|
||||
```
|
||||
|
||||
### 使用 lyxy-runner-python 执行(必须优先使用)
|
||||
|
||||
```bash
|
||||
# DOCX - 推荐依赖
|
||||
uv run --with "markitdown[docx]" skills/lyxy-reader-office/scripts/parser.py /path/to/file.docx
|
||||
|
||||
# PPTX - 推荐依赖
|
||||
uv run --with "markitdown[pptx]" skills/lyxy-reader-office/scripts/parser.py /path/to/file.pptx
|
||||
|
||||
# XLSX - 推荐依赖
|
||||
uv run --with "markitdown[xlsx]" skills/lyxy-reader-office/scripts/parser.py /path/to/file.xlsx
|
||||
|
||||
# PDF - 推荐依赖
|
||||
uv run --with "markitdown[pdf]" --with pypdf skills/lyxy-reader-office/scripts/parser.py /path/to/file.pdf
|
||||
|
||||
# PDF OCR 高精度模式
|
||||
uv run --with docling --with pypdf skills/lyxy-reader-office/scripts/parser.py /path/to/file.pdf --high-res
|
||||
```
|
||||
|
||||
> **注意**:以上为最小推荐依赖,更多解析器依赖和完整安装命令请查阅 `scripts/README.md` 的安装部分。
|
||||
|
||||
### 降级到直接 Python 执行
|
||||
|
||||
仅当 lyxy-runner-python skill 不存在时使用:
|
||||
|
||||
```bash
|
||||
python3 skills/lyxy-reader-office/scripts/parser.py /path/to/file.docx
|
||||
```
|
||||
|
||||
### 互斥参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| (无参数) | 输出完整 Markdown 内容 |
|
||||
| `-c` | 字数统计 |
|
||||
| `-l` | 行数统计 |
|
||||
| `-t` | 提取所有标题 |
|
||||
| `-tc <name>` | 提取指定标题的章节内容(name 不含 # 号) |
|
||||
| `-s <pattern>` | 正则表达式搜索 |
|
||||
| `-n <num>` | 与 `-s` 配合,指定上下文行数(默认 2) |
|
||||
| `--high-res` | PDF 专用,启用 OCR 版面分析 |
|
||||
|
||||
## Examples
|
||||
|
||||
### 提取完整文档内容
|
||||
```bash
|
||||
# DOCX
|
||||
uv run --with "markitdown[docx]" skills/lyxy-reader-office/scripts/parser.py /path/to/report.docx
|
||||
|
||||
# PPTX
|
||||
uv run --with "markitdown[pptx]" skills/lyxy-reader-office/scripts/parser.py /path/to/slides.pptx
|
||||
|
||||
# XLSX
|
||||
uv run --with "markitdown[xlsx]" skills/lyxy-reader-office/scripts/parser.py /path/to/data.xlsx
|
||||
|
||||
# PDF
|
||||
uv run --with "markitdown[pdf]" --with pypdf skills/lyxy-reader-office/scripts/parser.py /path/to/doc.pdf
|
||||
```
|
||||
|
||||
### 获取文档字数
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" skills/lyxy-reader-office/scripts/parser.py -c /path/to/report.docx
|
||||
```
|
||||
|
||||
### 提取所有标题
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" skills/lyxy-reader-office/scripts/parser.py -t /path/to/report.docx
|
||||
```
|
||||
|
||||
### 提取指定章节
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" skills/lyxy-reader-office/scripts/parser.py -tc "第一章" /path/to/report.docx
|
||||
```
|
||||
|
||||
### 搜索关键词
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" skills/lyxy-reader-office/scripts/parser.py -s "关键词" -n 3 /path/to/report.docx
|
||||
```
|
||||
|
||||
### PDF OCR 高精度解析
|
||||
```bash
|
||||
uv run --with docling --with pypdf skills/lyxy-reader-office/scripts/parser.py /path/to/scanned.pdf --high-res
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
### 多策略解析降级
|
||||
|
||||
每种文件格式配备多个解析器,按优先级依次尝试,前一个失败自动回退到下一个。详细的解析器优先级和对比请查阅 `scripts/README.md`。
|
||||
|
||||
### 限制
|
||||
|
||||
- 不支持图片提取(仅纯文本)
|
||||
- 不支持复杂的格式保留(字体、颜色、布局等)
|
||||
- 不支持文档编辑或修改
|
||||
- 仅支持 .docx、.xlsx、.pptx、.pdf 格式(不支持 .doc、.xls、.ppt 等旧格式)
|
||||
- PDF 无内置 XML 原生解析,至少需要安装 pypdf
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **必须优先使用 lyxy-runner-python**:如果环境中存在,必须使用 lyxy-runner-python 执行脚本
|
||||
2. **查阅 README**:详细参数、依赖安装、解析器对比等信息请阅读 `scripts/README.md`
|
||||
3. **大文件处理**:对于大文档,建议使用章节提取(`-tc`)或搜索(`-s`)来限制处理范围
|
||||
4. **PDF 标题**:PDF 是版面描述格式,默认不含语义化标题;需要标题层级时使用 `--high-res`
|
||||
5. **禁止自动安装**:降级到直接 Python 执行时,仅向用户提示安装依赖,不得自动执行 pip install
|
||||
449
skills/lyxy-reader-office/scripts/README.md
Normal file
449
skills/lyxy-reader-office/scripts/README.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Document Parser 使用说明
|
||||
|
||||
模块化文档解析器,将 DOCX、PPTX、XLSX、PDF 文件转换为 Markdown 格式。
|
||||
|
||||
每种文档类型配备多个解析器,按优先级依次尝试,前一个失败自动回退到下一个。不安装任何第三方库时,DOCX/PPTX/XLSX 仍可通过内置 XML 原生解析工作(PDF 至少需要 pypdf)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 最简运行(XML 原生解析,无需安装依赖)
|
||||
python parser.py report.docx
|
||||
|
||||
# 安装推荐依赖后运行
|
||||
pip install "markitdown[docx]"
|
||||
python parser.py report.docx
|
||||
|
||||
# 使用 uv 一键运行(自动安装依赖,无需手动 pip install)
|
||||
uv run --with "markitdown[docx]" parser.py report.docx
|
||||
```
|
||||
|
||||
## 命令行用法
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
python parser.py <file_path> [options]
|
||||
```
|
||||
|
||||
`file_path` 为 DOCX、PPTX、XLSX 或 PDF 文件路径(相对或绝对路径)。不带任何选项时输出完整 Markdown 内容。
|
||||
|
||||
### 参数说明
|
||||
|
||||
以下参数互斥,一次只能使用一个:
|
||||
|
||||
| 短选项 | 长选项 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `-c` | `--count` | 输出解析后文档的总字符数(不含换行符) |
|
||||
| `-l` | `--lines` | 输出解析后文档的总行数 |
|
||||
| `-t` | `--titles` | 输出所有标题行(1-6 级,含 `#` 前缀) |
|
||||
| `-tc <name>` | `--title-content <name>` | 提取指定标题及其下级内容(`name` 不包含 `#` 号) |
|
||||
| `-s <pattern>` | `--search <pattern>` | 使用正则表达式搜索文档,返回匹配结果 |
|
||||
|
||||
搜索辅助参数(与 `-s` 配合使用):
|
||||
|
||||
| 短选项 | 长选项 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `-n <num>` | `--context <num>` | 每个匹配结果包含的前后非空行数(默认:2) |
|
||||
|
||||
PDF 专用参数:
|
||||
|
||||
| 长选项 | 说明 |
|
||||
|--------|------|
|
||||
| `--high-res` | 启用 OCR 版面分析(需要额外依赖,处理较慢) |
|
||||
|
||||
### 退出码
|
||||
|
||||
| 退出码 | 含义 |
|
||||
|--------|------|
|
||||
| `0` | 解析成功 |
|
||||
| `1` | 错误(文件不存在、格式无效、所有解析器失败、标题未找到、正则无效或无匹配) |
|
||||
|
||||
### 使用示例
|
||||
|
||||
**输出完整 Markdown:**
|
||||
|
||||
```bash
|
||||
python parser.py report.docx # 输出到终端
|
||||
python parser.py report.docx > output.md # 重定向到文件
|
||||
```
|
||||
|
||||
**统计信息(`-c` / `-l`):**
|
||||
|
||||
输出单个数字,适合管道处理。
|
||||
|
||||
```bash
|
||||
$ python parser.py report.docx -c
|
||||
8500
|
||||
|
||||
$ python parser.py report.docx -l
|
||||
215
|
||||
```
|
||||
|
||||
**提取标题(`-t`):**
|
||||
|
||||
每行一个标题,保留 `#` 前缀和层级。PDF 通常不包含语义化标题层级。
|
||||
|
||||
```bash
|
||||
$ python parser.py report.docx -t
|
||||
# 第一章 概述
|
||||
## 1.1 背景
|
||||
## 1.2 目标
|
||||
# 第二章 实现
|
||||
```
|
||||
|
||||
**提取标题内容(`-tc`):**
|
||||
|
||||
输出指定标题及其下级内容。如果文档中有多个同名标题,用 `---` 分隔。每段输出包含上级标题链。
|
||||
|
||||
```bash
|
||||
$ python parser.py report.docx -tc "1.1 背景"
|
||||
# 第一章 概述
|
||||
## 1.1 背景
|
||||
这是背景的详细内容...
|
||||
```
|
||||
|
||||
**搜索(`-s`):**
|
||||
|
||||
支持 Python 正则表达式语法。多个匹配结果用 `---` 分隔。`-n` 控制上下文行数。
|
||||
|
||||
```bash
|
||||
$ python parser.py report.docx -s "测试" -n 1
|
||||
上一行内容
|
||||
包含**测试**关键词的行
|
||||
下一行内容
|
||||
---
|
||||
另一处上一行
|
||||
另一处**测试**内容
|
||||
另一处下一行
|
||||
```
|
||||
|
||||
### 批量处理
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
for file in *.docx; do
|
||||
python parser.py "$file" > "${file%.docx}.md"
|
||||
done
|
||||
|
||||
# Windows PowerShell
|
||||
Get-ChildItem *.docx | ForEach-Object {
|
||||
python parser.py $_.FullName > ($_.BaseName + ".md")
|
||||
}
|
||||
```
|
||||
|
||||
### 管道使用
|
||||
|
||||
```bash
|
||||
# 过滤包含关键词的行
|
||||
python parser.py report.docx | grep "重要" > important.md
|
||||
|
||||
# 统计含表格行数
|
||||
python parser.py data.xlsx | grep -c "^|"
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
脚本基于 Python 3.6+ 运行。每种文档类型有多个解析器按优先级依次尝试,建议安装对应类型的**所有**依赖以获得最佳兼容性。也可以只安装部分依赖,脚本会自动选择可用的解析器。
|
||||
|
||||
### DOCX
|
||||
|
||||
优先级:Docling → unstructured → pypandoc-binary → MarkItDown → python-docx → XML 原生
|
||||
|
||||
```bash
|
||||
# pip
|
||||
pip install docling "unstructured[docx]" markdownify pypandoc-binary "markitdown[docx]" python-docx
|
||||
|
||||
# uv(一键运行,无需预安装)
|
||||
uv run --with docling --with "unstructured[docx]" --with markdownify --with pypandoc-binary --with "markitdown[docx]" --with python-docx parser.py report.docx
|
||||
```
|
||||
|
||||
### PPTX
|
||||
|
||||
优先级:Docling → unstructured → MarkItDown → python-pptx → XML 原生
|
||||
|
||||
```bash
|
||||
# pip
|
||||
pip install docling "unstructured[pptx]" markdownify "markitdown[pptx]" python-pptx
|
||||
|
||||
# uv
|
||||
uv run --with docling --with "unstructured[pptx]" --with markdownify --with "markitdown[pptx]" --with python-pptx parser.py presentation.pptx
|
||||
```
|
||||
|
||||
### XLSX
|
||||
|
||||
优先级:Docling → unstructured → MarkItDown → pandas → XML 原生
|
||||
|
||||
```bash
|
||||
# pip
|
||||
pip install docling "unstructured[xlsx]" markdownify "markitdown[xlsx]" pandas tabulate
|
||||
|
||||
# uv
|
||||
uv run --with docling --with "unstructured[xlsx]" --with markdownify --with "markitdown[xlsx]" --with pandas --with tabulate parser.py data.xlsx
|
||||
```
|
||||
|
||||
### PDF
|
||||
|
||||
默认优先级:Docling → unstructured (fast) → MarkItDown → pypdf
|
||||
|
||||
`--high-res` 优先级:Docling OCR → unstructured OCR (hi_res) → Docling → unstructured (fast) → MarkItDown → pypdf
|
||||
|
||||
```bash
|
||||
# pip - 基础文本提取(fast 策略,无需 OCR)
|
||||
pip install docling "unstructured[pdf]" markdownify "markitdown[pdf]" pypdf
|
||||
|
||||
# pip - OCR 版面分析(--high-res 所需依赖)
|
||||
pip install docling "unstructured[pdf]" unstructured-paddleocr "paddlepaddle==2.6.2" ml-dtypes markdownify "markitdown[pdf]" pypdf
|
||||
|
||||
# uv - 基础文本提取
|
||||
uv run --with docling --with "unstructured[pdf]" --with markdownify --with "markitdown[pdf]" --with pypdf parser.py report.pdf
|
||||
|
||||
# uv - OCR 版面分析
|
||||
uv run --with docling --with "unstructured[pdf]" --with unstructured-paddleocr --with "paddlepaddle==2.6.2" --with ml-dtypes --with markdownify --with "markitdown[pdf]" --with pypdf parser.py report.pdf --high-res
|
||||
```
|
||||
|
||||
> PDF 无内置 XML 原生解析,至少需要安装 pypdf。默认模式下 Docling 不启用 OCR,unstructured 使用 fast 策略。指定 `--high-res` 后,Docling 启用 OCR,unstructured 使用 hi_res 策略配合 PaddleOCR 进行版面分析。hi_res 策略需要额外安装 `unstructured-paddleocr`、`paddlepaddle==2.6.2`、`ml-dtypes`。PaddlePaddle 必须锁定 2.x 版本,3.x 在 Windows 上有 OneDNN 兼容问题。
|
||||
>
|
||||
|
||||
### 安装所有依赖
|
||||
|
||||
```bash
|
||||
# pip - 基础文本提取(不包含 PDF OCR)
|
||||
pip install docling "unstructured[docx,pptx,xlsx,pdf]" markdownify pypandoc-binary "markitdown[docx,pptx,xlsx]" python-docx python-pptx pandas tabulate pypdf
|
||||
|
||||
# pip - 完整版(包含 PDF OCR)
|
||||
pip install docling "unstructured[docx,pptx,xlsx,pdf]" markdownify unstructured-paddleocr "paddlepaddle==2.6.2" ml-dtypes pypandoc-binary "markitdown[docx,pptx,xlsx,pdf]" python-docx python-pptx pandas tabulate pypdf
|
||||
|
||||
# uv - 基础文本提取
|
||||
uv run --with docling --with "unstructured[docx,pptx,xlsx,pdf]" --with markdownify --with pypandoc-binary --with "markitdown[docx,pptx,xlsx]" --with python-docx --with python-pptx --with pandas --with tabulate --with pypdf parser.py file.docx
|
||||
|
||||
# uv - 完整版(包含 PDF OCR)
|
||||
uv run --with docling --with "unstructured[docx,pptx,xlsx,pdf]" --with markdownify --with unstructured-paddleocr --with "paddlepaddle==2.6.2" --with ml-dtypes --with pypandoc-binary --with "markitdown[docx,pptx,xlsx,pdf]" --with python-docx --with python-pptx --with pandas --with tabulate --with pypdf parser.py file.docx
|
||||
```
|
||||
|
||||
### 依赖说明
|
||||
|
||||
**MarkItDown**:需要按文档类型安装可选依赖,直接 `pip install markitdown` 不包含任何格式支持。
|
||||
|
||||
```bash
|
||||
pip install "markitdown[docx]" # DOCX
|
||||
pip install "markitdown[pptx]" # PPTX
|
||||
pip install "markitdown[xlsx]" # XLSX
|
||||
pip install "markitdown[pdf]" # PDF
|
||||
pip install "markitdown[docx,pptx,xlsx,pdf]" # 全部
|
||||
```
|
||||
|
||||
**Docling**:DOCX/PPTX/XLSX 使用 SimplePipeline 直接解析 XML 结构,不涉及 OCR。PDF 默认不启用 OCR(`do_ocr=False`),指定 `--high-res` 后启用 OCR(`do_ocr=True`)。首次运行 OCR 模式会自动下载模型到缓存目录,需保持网络连通。
|
||||
|
||||
**unstructured**:需同时安装 `markdownify`。支持按文档类型安装特定 extras 以减少依赖量:
|
||||
|
||||
- `unstructured[docx]` - DOCX 处理(仅需 `python-docx`)
|
||||
- `unstructured[pptx]` - PPTX 处理(仅需 `python-pptx`)
|
||||
- `unstructured[xlsx]` - XLSX 处理(需 `openpyxl`、`xlrd`、`pandas` 等)
|
||||
- `unstructured` - 基础包(用于 PDF fast 策略)
|
||||
- `unstructured[all-docs]` - 所有文档类型(包含大量不必要的 OCR/视觉依赖)
|
||||
|
||||
**PaddleOCR**:不能用 `paddleocr` 代替 `unstructured-paddleocr`,unstructured 查找的模块名是 `unstructured_paddleocr`。
|
||||
|
||||
## 输出格式
|
||||
|
||||
### Markdown 文档结构
|
||||
|
||||
无选项时输出完整 Markdown,包含以下元素:
|
||||
|
||||
```markdown
|
||||
# 一级标题
|
||||
|
||||
正文段落
|
||||
|
||||
## 二级标题
|
||||
|
||||
- 无序列表项
|
||||
- 无序列表项
|
||||
|
||||
1. 有序列表项
|
||||
2. 有序列表项
|
||||
|
||||
| 列1 | 列2 | 列3 |
|
||||
|------|------|------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
|
||||
**粗体** *斜体* <u>下划线</u>
|
||||
```
|
||||
|
||||
### 各格式特有结构
|
||||
|
||||
**PPTX** — 每张幻灯片以 `## Slide N` 为标题,幻灯片之间以 `---` 分隔:
|
||||
|
||||
```markdown
|
||||
## Slide 1
|
||||
|
||||
幻灯片 1 的内容
|
||||
|
||||
---
|
||||
|
||||
## Slide 2
|
||||
|
||||
幻灯片 2 的内容
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
**XLSX** — 以 `## SheetName` 区分工作表,数据以 Markdown 表格呈现:
|
||||
|
||||
```markdown
|
||||
# Excel数据转换结果 (原生XML解析)
|
||||
|
||||
## Sheet1
|
||||
|
||||
| 列1 | 列2 | 列3 |
|
||||
|------|------|------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
|
||||
## Sheet2
|
||||
|
||||
| 列A | 列B |
|
||||
|------|------|
|
||||
| 值1 | 值2 |
|
||||
```
|
||||
|
||||
**PDF** — 纯文本流,通常不包含语义化标题层级(PDF 是版面描述格式,标题只是视觉样式)。使用 Docling 或 unstructured hi_res 策略可通过版面分析识别部分标题,但准确度取决于排版质量。
|
||||
|
||||
### 内容自动处理
|
||||
|
||||
输出前会自动进行以下处理:
|
||||
|
||||
| 处理 | 说明 |
|
||||
|------|------|
|
||||
| 图片移除 | 删除 `` 语法 |
|
||||
| 空行规范化 | 连续多个空行合并为一个 |
|
||||
| RGB 噪声过滤 | 移除 `R:255 G:128 B:0` 格式的颜色值行(仅 unstructured 解析器) |
|
||||
| 页码噪声过滤 | 移除 `— 3 —` 格式的页码行(仅 unstructured 解析器) |
|
||||
| 页眉/页脚过滤 | 自动跳过 Header/Footer 元素(仅 unstructured 解析器) |
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误消息
|
||||
|
||||
```bash
|
||||
# 文件不存在
|
||||
$ python parser.py missing.docx
|
||||
错误: 文件不存在: missing.docx
|
||||
|
||||
# 格式无效
|
||||
$ python parser.py readme.txt
|
||||
错误: 不是有效的 DOCX、PPTX、XLSX 或 PDF 格式: readme.txt
|
||||
|
||||
# 所有解析器失败(DOCX 示例)
|
||||
$ python parser.py report.docx
|
||||
所有解析方法均失败:
|
||||
- Docling: docling 库未安装
|
||||
- unstructured: unstructured 库未安装
|
||||
- pypandoc-binary: pypandoc-binary 库未安装
|
||||
- MarkItDown: MarkItDown 库未安装
|
||||
- python-docx: python-docx 库未安装
|
||||
- XML 原生解析: document.xml 不存在或无法访问
|
||||
|
||||
# 标题未找到
|
||||
$ python parser.py report.docx -tc "不存在的标题"
|
||||
错误: 未找到标题 '不存在的标题'
|
||||
|
||||
# 无效正则或无匹配
|
||||
$ python parser.py report.docx -s "[invalid"
|
||||
错误: 正则表达式无效或未找到匹配: '[invalid'
|
||||
```
|
||||
|
||||
### 解析器回退机制
|
||||
|
||||
脚本按优先级依次尝试各解析器。每个解析器失败后记录原因(库未安装 / 解析失败 / 文档为空),然后自动尝试下一个。全部失败时输出汇总信息并以退出码 1 退出。
|
||||
|
||||
## 解析器对比
|
||||
|
||||
### DOCX
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | 单一依赖覆盖全格式;自动 OCR;输出结构稳定 | 首次需下载模型;内存占用较高 | 一键解析;需要 OCR |
|
||||
| **unstructured** | 元素类型感知;自动过滤噪声;HTML 表格转 Markdown | 需 `unstructured[docx]` / `[pptx]` / `[xlsx]` + `markdownify` | 结构化输出;表格转换 |
|
||||
| **pypandoc-binary** | 自带 Pandoc;输出整洁;错误信息清晰 | 仅 DOCX;包体积大 | 标准化 Markdown |
|
||||
| **MarkItDown** | 微软官方;格式规范 | 输出简洁 | 标准格式;自动化处理 |
|
||||
| **python-docx** | 输出最详细;保留完整结构;支持复杂样式 | 可能含多余空行 | 精确控制输出 |
|
||||
| **XML 原生** | 无需依赖;速度快 | 样式处理有限 | 依赖不可用时兜底 |
|
||||
|
||||
### PPTX
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | 文本/表格/图片 OCR;统一 Markdown | 需下载模型 | 一次性转换;含图片的 PPTX |
|
||||
| **unstructured** | 元素感知;过滤 RGB 噪声;表格转换 | 需 `unstructured[pptx]` + `markdownify` | 结构化输出 |
|
||||
| **MarkItDown** | 自动 Slide 分隔;简洁 | 详细度低 | 快速预览 |
|
||||
| **python-pptx** | 输出最详细;支持层级列表 | 依赖私有 API | 完整内容提取 |
|
||||
| **XML 原生** | 无需依赖;速度快 | 分组简单 | 依赖不可用时兜底 |
|
||||
|
||||
### XLSX
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | 全表导出;处理合并单元格/图像 OCR | 大表可能慢 | 快速全表转换 |
|
||||
| **unstructured** | 元素感知;过滤噪声;表格转换 | 需 `unstructured[xlsx]` + `markdownify` | 结构化输出 |
|
||||
| **MarkItDown** | 支持多工作表;简洁 | 详细度低 | 快速预览 |
|
||||
| **pandas** | 功能强大;支持复杂表格 | 需 `pandas` + `tabulate` | 数据分析 |
|
||||
| **XML 原生** | 无需依赖;支持所有单元格类型 | 无数据处理能力 | 依赖不可用时兜底 |
|
||||
|
||||
### PDF
|
||||
|
||||
| 解析器 | 模式 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|------|--------|---------|
|
||||
| **Docling** | 默认 | 结构化 Markdown;表格/图片占位 | 首次需下载模型 | 有文本层的 PDF |
|
||||
| **Docling OCR** | `--high-res` | 内置 OCR;结构化 Markdown | 模型体积大;OCR 耗时长 | 扫描版 PDF;多语言 |
|
||||
| **unstructured** | 默认 | fast 策略;速度快 | 不做版面分析;标题不可靠 | 快速文本提取 |
|
||||
| **unstructured OCR** | `--high-res` | hi_res 版面分析 + PaddleOCR;标题识别 | 需额外 PaddleOCR 依赖 | 版面分析;OCR |
|
||||
| **MarkItDown** | 通用 | 微软官方;格式规范 | 输出简洁 | 标准格式 |
|
||||
| **pypdf** | 通用 | 轻量;速度快;安装简单 | 功能简单 | 快速文本提取 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么有些内容没有提取到?
|
||||
|
||||
不同解析器输出详细度不同。优先级高的解析器不一定输出最详细——Docling 和 unstructured 侧重结构化,python-docx/python-pptx 输出最详细但不做噪声过滤。建议安装对应类型的所有依赖,脚本会自动选择优先级最高的可用解析器。
|
||||
|
||||
### PDF 文件没有标题层级?
|
||||
|
||||
PDF 是版面描述格式,不包含语义化标题结构。使用 `--high-res` 参数可启用 Docling OCR 或 unstructured hi_res 策略,通过版面分析识别部分标题,准确度取决于排版质量。默认模式下建议用 `-s` 搜索定位内容,或用 `-c` / `-l` 了解文档规模。
|
||||
|
||||
### 表格格式不正确?
|
||||
|
||||
XML 原生解析器对复杂表格(合并单元格、嵌套表格)支持有限。安装 Docling、unstructured 或对应的专用库可获得更好的表格处理效果。
|
||||
|
||||
### 中文显示乱码?
|
||||
|
||||
脚本输出 UTF-8 编码,确保终端支持:
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
# Windows PowerShell
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
```
|
||||
|
||||
### 如何只使用特定解析器?
|
||||
|
||||
当前版本不支持指定解析器,总是按优先级自动选择。可以通过只安装目标解析器的依赖来间接控制——未安装的解析器会被跳过。
|
||||
|
||||
### 大文件处理慢?
|
||||
|
||||
Docling 和 unstructured 对大文件较慢(尤其是 OCR)。如果只需要快速提取文本,可以只安装轻量依赖(如 pypdf、python-docx),让脚本回退到这些解析器。DOCX/PPTX/XLSX 不安装任何依赖时使用 XML 原生解析,速度最快。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── common.py # 公共函数和常量
|
||||
├── docx_parser.py # DOCX 文件解析
|
||||
├── pptx_parser.py # PPTX 文件解析
|
||||
├── xlsx_parser.py # XLSX 文件解析
|
||||
├── pdf_parser.py # PDF 文件解析
|
||||
├── parser.py # 命令行入口
|
||||
└── README.md # 本文档
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,11 @@ from typing import List, Optional, Tuple
|
||||
|
||||
IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]+\)")
|
||||
|
||||
# unstructured 噪声匹配: pptx 中的 RGB 颜色值(如 "R:255 G:128 B:0")
|
||||
_RGB_PATTERN = re.compile(r"^R:\d+\s+G:\d+\s+B:\d+$")
|
||||
# unstructured 噪声匹配: 破折号页码(如 "— 3 —")
|
||||
_PAGE_NUMBER_PATTERN = re.compile(r"^—\s*\d+\s*—$")
|
||||
|
||||
|
||||
def parse_with_markitdown(
|
||||
file_path: str,
|
||||
@@ -80,60 +85,41 @@ def safe_open_zip(zip_file: zipfile.ZipFile, name: str) -> Optional[zipfile.ZipE
|
||||
return zip_file.open(name)
|
||||
|
||||
|
||||
_CONSECUTIVE_BLANK_LINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def normalize_markdown_whitespace(content: str) -> str:
|
||||
"""规范化 Markdown 空白字符,保留单行空行"""
|
||||
lines = content.split("\n")
|
||||
result = []
|
||||
empty_count = 0
|
||||
return _CONSECUTIVE_BLANK_LINES.sub("\n\n", content)
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
empty_count += 1
|
||||
if empty_count == 1:
|
||||
result.append(line)
|
||||
else:
|
||||
empty_count = 0
|
||||
result.append(line)
|
||||
|
||||
return "\n".join(result)
|
||||
def _is_valid_ooxml(file_path: str, required_files: List[str]) -> bool:
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_file:
|
||||
names = set(zip_file.namelist())
|
||||
return all(r in names for r in required_files)
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile):
|
||||
return False
|
||||
|
||||
|
||||
_DOCX_REQUIRED = ["[Content_Types].xml", "_rels/.rels", "word/document.xml"]
|
||||
_PPTX_REQUIRED = ["[Content_Types].xml", "_rels/.rels", "ppt/presentation.xml"]
|
||||
_XLSX_REQUIRED = ["[Content_Types].xml", "_rels/.rels", "xl/workbook.xml"]
|
||||
|
||||
|
||||
def is_valid_docx(file_path: str) -> bool:
|
||||
"""验证文件是否为有效的 DOCX 格式"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_file:
|
||||
names = set(zip_file.namelist())
|
||||
required_files = ["[Content_Types].xml", "_rels/.rels", "word/document.xml"]
|
||||
return all(r in names for r in required_files)
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile):
|
||||
return False
|
||||
return _is_valid_ooxml(file_path, _DOCX_REQUIRED)
|
||||
|
||||
|
||||
def is_valid_pptx(file_path: str) -> bool:
|
||||
"""验证文件是否为有效的 PPTX 格式"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_file:
|
||||
names = set(zip_file.namelist())
|
||||
required_files = [
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"ppt/presentation.xml",
|
||||
]
|
||||
return all(r in names for r in required_files)
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile):
|
||||
return False
|
||||
return _is_valid_ooxml(file_path, _PPTX_REQUIRED)
|
||||
|
||||
|
||||
def is_valid_xlsx(file_path: str) -> bool:
|
||||
"""验证文件是否为有效的 XLSX 格式"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_file:
|
||||
names = set(zip_file.namelist())
|
||||
required_files = ["[Content_Types].xml", "_rels/.rels", "xl/workbook.xml"]
|
||||
return all(r in names for r in required_files)
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile):
|
||||
return False
|
||||
return _is_valid_ooxml(file_path, _XLSX_REQUIRED)
|
||||
|
||||
|
||||
def is_valid_pdf(file_path: str) -> bool:
|
||||
@@ -156,12 +142,8 @@ def get_heading_level(line: str) -> int:
|
||||
stripped = line.lstrip()
|
||||
if not stripped.startswith("#"):
|
||||
return 0
|
||||
level = 0
|
||||
for char in stripped:
|
||||
if char == "#":
|
||||
level += 1
|
||||
else:
|
||||
break
|
||||
without_hash = stripped.lstrip("#")
|
||||
level = len(stripped) - len(without_hash)
|
||||
if not (1 <= level <= 6):
|
||||
return 0
|
||||
if len(stripped) == level:
|
||||
@@ -275,9 +257,6 @@ def search_markdown(
|
||||
start_line_idx = non_empty_indices[context_start_idx]
|
||||
end_line_idx = non_empty_indices[context_end_idx]
|
||||
|
||||
selected_indices = set(
|
||||
non_empty_indices[context_start_idx : context_end_idx + 1]
|
||||
)
|
||||
result_lines = [
|
||||
line
|
||||
for i, line in enumerate(lines)
|
||||
@@ -288,22 +267,71 @@ def search_markdown(
|
||||
return "\n---\n".join(results)
|
||||
|
||||
|
||||
_FILE_TYPE_VALIDATORS = {
|
||||
".docx": is_valid_docx,
|
||||
".pptx": is_valid_pptx,
|
||||
".xlsx": is_valid_xlsx,
|
||||
".pdf": is_valid_pdf,
|
||||
}
|
||||
|
||||
|
||||
def detect_file_type(file_path: str) -> Optional[str]:
|
||||
"""检测文件类型,返回 'docx'、'pptx'、'xlsx' 或 'pdf'"""
|
||||
_, ext = os.path.splitext(file_path)
|
||||
ext = ext.lower()
|
||||
|
||||
if ext == ".docx":
|
||||
if is_valid_docx(file_path):
|
||||
return "docx"
|
||||
elif ext == ".pptx":
|
||||
if is_valid_pptx(file_path):
|
||||
return "pptx"
|
||||
elif ext == ".xlsx":
|
||||
if is_valid_xlsx(file_path):
|
||||
return "xlsx"
|
||||
elif ext == ".pdf":
|
||||
if is_valid_pdf(file_path):
|
||||
return "pdf"
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
validator = _FILE_TYPE_VALIDATORS.get(ext)
|
||||
if validator and validator(file_path):
|
||||
return ext.lstrip(".")
|
||||
return None
|
||||
|
||||
|
||||
def _unstructured_elements_to_markdown(
|
||||
elements: list, trust_titles: bool = True
|
||||
) -> str:
|
||||
"""将 unstructured 解析出的元素列表转换为 Markdown 文本"""
|
||||
try:
|
||||
import markdownify as md_lib
|
||||
from unstructured.documents.elements import (
|
||||
Footer,
|
||||
Header,
|
||||
Image,
|
||||
ListItem,
|
||||
PageBreak,
|
||||
PageNumber,
|
||||
Table,
|
||||
Title,
|
||||
)
|
||||
except ImportError:
|
||||
return "\n\n".join(
|
||||
el.text for el in elements if hasattr(el, "text") and el.text and el.text.strip()
|
||||
)
|
||||
|
||||
skip_types = (Header, Footer, PageBreak, PageNumber)
|
||||
parts = []
|
||||
|
||||
for el in elements:
|
||||
if isinstance(el, skip_types):
|
||||
continue
|
||||
text = el.text.strip() if hasattr(el, "text") else str(el).strip()
|
||||
if not text or _RGB_PATTERN.match(text) or _PAGE_NUMBER_PATTERN.match(text):
|
||||
continue
|
||||
|
||||
if isinstance(el, Table):
|
||||
html = getattr(el.metadata, "text_as_html", None)
|
||||
if html:
|
||||
parts.append(md_lib.markdownify(html, strip=["img"]).strip())
|
||||
else:
|
||||
parts.append(str(el))
|
||||
elif isinstance(el, Title) and trust_titles:
|
||||
depth = getattr(el.metadata, "category_depth", None) or 1
|
||||
depth = min(max(depth, 1), 4)
|
||||
parts.append(f"{'#' * depth} {text}")
|
||||
elif isinstance(el, ListItem):
|
||||
parts.append(f"- {text}")
|
||||
elif isinstance(el, Image):
|
||||
path = getattr(el.metadata, "image_path", None) or ""
|
||||
if path:
|
||||
parts.append(f"")
|
||||
else:
|
||||
parts.append(text)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
@@ -6,6 +6,7 @@ import zipfile
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from common import (
|
||||
_unstructured_elements_to_markdown,
|
||||
build_markdown_table,
|
||||
parse_with_docling,
|
||||
parse_with_markitdown,
|
||||
@@ -18,6 +19,23 @@ def parse_docx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str
|
||||
return parse_with_docling(file_path)
|
||||
|
||||
|
||||
def parse_docx_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 DOCX 文件"""
|
||||
try:
|
||||
from unstructured.partition.docx import partition_docx
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_docx(filename=file_path, infer_table_structure=True)
|
||||
content = _unstructured_elements_to_markdown(elements)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_docx_with_pypandoc(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 pypandoc-binary 库解析 DOCX 文件。"""
|
||||
try:
|
||||
@@ -59,32 +77,29 @@ def parse_docx_with_python_docx(file_path: str) -> Tuple[Optional[str], Optional
|
||||
try:
|
||||
doc = Document(file_path)
|
||||
|
||||
_HEADING_LEVELS = {
|
||||
"Title": 1, "Heading 1": 1, "Heading 2": 2, "Heading 3": 3,
|
||||
"Heading 4": 4, "Heading 5": 5, "Heading 6": 6,
|
||||
}
|
||||
|
||||
def get_heading_level(para: Any) -> int:
|
||||
if para.style and para.style.name:
|
||||
style_name = para.style.name
|
||||
if style_name == "Title":
|
||||
return 1
|
||||
elif style_name == "Heading 1":
|
||||
return 1
|
||||
elif style_name == "Heading 2":
|
||||
return 2
|
||||
elif style_name == "Heading 3":
|
||||
return 3
|
||||
elif style_name == "Heading 4":
|
||||
return 4
|
||||
elif style_name == "Heading 5":
|
||||
return 5
|
||||
elif style_name == "Heading 6":
|
||||
return 6
|
||||
return _HEADING_LEVELS.get(para.style.name, 0)
|
||||
return 0
|
||||
|
||||
_LIST_STYLES = {
|
||||
"Bullet": "bullet", "Number": "number",
|
||||
}
|
||||
|
||||
def get_list_style(para: Any) -> Optional[str]:
|
||||
if not para.style or not para.style.name:
|
||||
return None
|
||||
style_name = para.style.name
|
||||
if style_name.startswith("List Bullet") or style_name == "Bullet":
|
||||
if style_name in _LIST_STYLES:
|
||||
return _LIST_STYLES[style_name]
|
||||
if style_name.startswith("List Bullet"):
|
||||
return "bullet"
|
||||
elif style_name.startswith("List Number") or style_name == "Number":
|
||||
if style_name.startswith("List Number"):
|
||||
return "number"
|
||||
return None
|
||||
|
||||
@@ -170,6 +185,11 @@ def parse_docx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
word_namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
namespaces = {"w": word_namespace}
|
||||
|
||||
_STYLE_NAME_TO_HEADING = {
|
||||
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
|
||||
"heading 4": 4, "heading 5": 5, "heading 6": 6,
|
||||
}
|
||||
|
||||
def get_heading_level(style_id: Optional[str], style_to_level: dict) -> int:
|
||||
return style_to_level.get(style_id, 0)
|
||||
|
||||
@@ -195,8 +215,8 @@ def parse_docx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
rows = table_elem.findall(".//w:tr", namespaces=namespaces)
|
||||
if not rows:
|
||||
return ""
|
||||
md_lines = []
|
||||
for i, row in enumerate(rows):
|
||||
rows_data = []
|
||||
for row in rows:
|
||||
cells = row.findall(".//w:tc", namespaces=namespaces)
|
||||
cell_texts = []
|
||||
for cell in cells:
|
||||
@@ -204,12 +224,8 @@ def parse_docx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
cell_text = cell_text.replace("\n", " ").strip()
|
||||
cell_texts.append(cell_text if cell_text else "")
|
||||
if cell_texts:
|
||||
md_line = "| " + " | ".join(cell_texts) + " |"
|
||||
md_lines.append(md_line)
|
||||
if i == 0:
|
||||
sep_line = "| " + " | ".join(["---"] * len(cell_texts)) + " |"
|
||||
md_lines.append(sep_line)
|
||||
return "\n".join(md_lines)
|
||||
rows_data.append(cell_texts)
|
||||
return build_markdown_table(rows_data)
|
||||
|
||||
try:
|
||||
style_to_level = {}
|
||||
@@ -230,20 +246,8 @@ def parse_docx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
style_name = style_name_elem.get(f"{{{word_namespace}}}val")
|
||||
if style_name:
|
||||
style_name_lower = style_name.lower()
|
||||
if style_name_lower == "title":
|
||||
style_to_level[style_id] = 1
|
||||
elif style_name_lower == "heading 1":
|
||||
style_to_level[style_id] = 1
|
||||
elif style_name_lower == "heading 2":
|
||||
style_to_level[style_id] = 2
|
||||
elif style_name_lower == "heading 3":
|
||||
style_to_level[style_id] = 3
|
||||
elif style_name_lower == "heading 4":
|
||||
style_to_level[style_id] = 4
|
||||
elif style_name_lower == "heading 5":
|
||||
style_to_level[style_id] = 5
|
||||
elif style_name_lower == "heading 6":
|
||||
style_to_level[style_id] = 6
|
||||
if style_name_lower in _STYLE_NAME_TO_HEADING:
|
||||
style_to_level[style_id] = _STYLE_NAME_TO_HEADING[style_name_lower]
|
||||
elif (
|
||||
style_name_lower.startswith("list bullet")
|
||||
or style_name_lower == "bullet"
|
||||
@@ -2,8 +2,17 @@
|
||||
"""文档解析器命令行交互模块,提供命令行接口。支持 DOCX、PPTX、XLSX 和 PDF 文件。"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
# 抑制第三方库的进度条和日志,仅保留解析结果输出
|
||||
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
||||
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
||||
os.environ["TQDM_DISABLE"] = "1"
|
||||
warnings.filterwarnings("ignore")
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import common
|
||||
import docx_parser
|
||||
@@ -27,6 +36,12 @@ def main() -> None:
|
||||
help="与 -s 配合使用,指定每个检索结果包含的前后行数(不包含空行)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--high-res",
|
||||
action="store_true",
|
||||
help="PDF 解析时启用 OCR 版面分析(需要额外依赖,处理较慢)",
|
||||
)
|
||||
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"-c", "--count", action="store_true", help="返回解析后的 markdown 文档的总字数"
|
||||
@@ -65,6 +80,7 @@ def main() -> None:
|
||||
if file_type == "docx":
|
||||
parsers = [
|
||||
("docling", docx_parser.parse_docx_with_docling),
|
||||
("unstructured", docx_parser.parse_docx_with_unstructured),
|
||||
("pypandoc-binary", docx_parser.parse_docx_with_pypandoc),
|
||||
("MarkItDown", docx_parser.parse_docx_with_markitdown),
|
||||
("python-docx", docx_parser.parse_docx_with_python_docx),
|
||||
@@ -73,6 +89,7 @@ def main() -> None:
|
||||
elif file_type == "pptx":
|
||||
parsers = [
|
||||
("docling", pptx_parser.parse_pptx_with_docling),
|
||||
("unstructured", pptx_parser.parse_pptx_with_unstructured),
|
||||
("MarkItDown", pptx_parser.parse_pptx_with_markitdown),
|
||||
("python-pptx", pptx_parser.parse_pptx_with_python_pptx),
|
||||
("XML 原生解析", pptx_parser.parse_pptx_with_xml),
|
||||
@@ -80,17 +97,28 @@ def main() -> None:
|
||||
elif file_type == "xlsx":
|
||||
parsers = [
|
||||
("docling", xlsx_parser.parse_xlsx_with_docling),
|
||||
("unstructured", xlsx_parser.parse_xlsx_with_unstructured),
|
||||
("MarkItDown", xlsx_parser.parse_xlsx_with_markitdown),
|
||||
("pandas", xlsx_parser.parse_xlsx_with_pandas),
|
||||
("XML 原生解析", xlsx_parser.parse_xlsx_with_xml),
|
||||
]
|
||||
else:
|
||||
parsers = [
|
||||
("docling", pdf_parser.parse_pdf_with_docling),
|
||||
("MarkItDown", pdf_parser.parse_pdf_with_markitdown),
|
||||
("unstructured", pdf_parser.parse_pdf_with_unstructured),
|
||||
("pypdf", pdf_parser.parse_pdf_with_pypdf),
|
||||
]
|
||||
if args.high_res:
|
||||
parsers = [
|
||||
("docling OCR", pdf_parser.parse_pdf_with_docling_ocr),
|
||||
("unstructured OCR", pdf_parser.parse_pdf_with_unstructured_ocr),
|
||||
("docling", pdf_parser.parse_pdf_with_docling),
|
||||
("unstructured", pdf_parser.parse_pdf_with_unstructured),
|
||||
("MarkItDown", pdf_parser.parse_pdf_with_markitdown),
|
||||
("pypdf", pdf_parser.parse_pdf_with_pypdf),
|
||||
]
|
||||
else:
|
||||
parsers = [
|
||||
("docling", pdf_parser.parse_pdf_with_docling),
|
||||
("unstructured", pdf_parser.parse_pdf_with_unstructured),
|
||||
("MarkItDown", pdf_parser.parse_pdf_with_markitdown),
|
||||
("pypdf", pdf_parser.parse_pdf_with_pypdf),
|
||||
]
|
||||
|
||||
failures = []
|
||||
content = None
|
||||
134
skills/lyxy-reader-office/scripts/pdf_parser.py
Normal file
134
skills/lyxy-reader-office/scripts/pdf_parser.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PDF 文件解析模块,提供多种解析方法。"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from common import _unstructured_elements_to_markdown, parse_with_markitdown
|
||||
|
||||
|
||||
def parse_pdf_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析 PDF 文件(不启用 OCR)"""
|
||||
try:
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
||||
from docling.document_converter import DocumentConverter, PdfFormatOption
|
||||
except ImportError:
|
||||
return None, "docling 库未安装"
|
||||
|
||||
try:
|
||||
converter = DocumentConverter(
|
||||
format_options={
|
||||
InputFormat.PDF: PdfFormatOption(
|
||||
pipeline_options=PdfPipelineOptions(do_ocr=False)
|
||||
)
|
||||
}
|
||||
)
|
||||
result = converter.convert(file_path)
|
||||
markdown_content = result.document.export_to_markdown()
|
||||
if not markdown_content.strip():
|
||||
return None, "文档为空"
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"docling 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pdf_with_docling_ocr(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析 PDF 文件(启用 OCR)"""
|
||||
try:
|
||||
from docling.document_converter import DocumentConverter
|
||||
except ImportError:
|
||||
return None, "docling 库未安装"
|
||||
|
||||
try:
|
||||
converter = DocumentConverter()
|
||||
result = converter.convert(file_path)
|
||||
markdown_content = result.document.export_to_markdown()
|
||||
if not markdown_content.strip():
|
||||
return None, "文档为空"
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"docling OCR 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pdf_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 PDF 文件(fast 策略)"""
|
||||
try:
|
||||
from unstructured.partition.pdf import partition_pdf
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_pdf(
|
||||
filename=file_path,
|
||||
infer_table_structure=True,
|
||||
strategy="fast",
|
||||
languages=["chi_sim"],
|
||||
)
|
||||
# fast 策略不做版面分析,Title 类型标注不可靠
|
||||
content = _unstructured_elements_to_markdown(elements, trust_titles=False)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pdf_with_unstructured_ocr(
|
||||
file_path: str,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 PDF 文件(hi_res 策略 + PaddleOCR)"""
|
||||
try:
|
||||
from unstructured.partition.pdf import partition_pdf
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
from unstructured.partition.utils.constants import OCR_AGENT_PADDLE
|
||||
except ImportError:
|
||||
return None, "unstructured-paddleocr 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_pdf(
|
||||
filename=file_path,
|
||||
infer_table_structure=True,
|
||||
strategy="hi_res",
|
||||
languages=["chi_sim"],
|
||||
ocr_agent=OCR_AGENT_PADDLE,
|
||||
table_ocr_agent=OCR_AGENT_PADDLE,
|
||||
)
|
||||
content = _unstructured_elements_to_markdown(elements, trust_titles=True)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured OCR 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pdf_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析 PDF 文件"""
|
||||
return parse_with_markitdown(file_path)
|
||||
|
||||
|
||||
def parse_pdf_with_pypdf(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 pypdf 库解析 PDF 文件"""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
return None, "pypdf 库未安装"
|
||||
|
||||
try:
|
||||
reader = PdfReader(file_path)
|
||||
md_content = []
|
||||
|
||||
for page in reader.pages:
|
||||
text = page.extract_text(extraction_mode="plain")
|
||||
if text and text.strip():
|
||||
md_content.append(text.strip())
|
||||
md_content.append("")
|
||||
|
||||
content = "\n".join(md_content).strip()
|
||||
if not content:
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"pypdf 解析失败: {str(e)}"
|
||||
@@ -7,6 +7,7 @@ import zipfile
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from common import (
|
||||
_unstructured_elements_to_markdown,
|
||||
build_markdown_table,
|
||||
flush_list_stack,
|
||||
parse_with_docling,
|
||||
@@ -19,6 +20,25 @@ def parse_pptx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str
|
||||
return parse_with_docling(file_path)
|
||||
|
||||
|
||||
def parse_pptx_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 PPTX 文件"""
|
||||
try:
|
||||
from unstructured.partition.pptx import partition_pptx
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_pptx(
|
||||
filename=file_path, infer_table_structure=True, include_metadata=True
|
||||
)
|
||||
content = _unstructured_elements_to_markdown(elements)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pptx_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析 PPTX 文件"""
|
||||
return parse_with_markitdown(file_path)
|
||||
@@ -74,6 +94,8 @@ def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional
|
||||
except ImportError:
|
||||
return None, "python-pptx 库未安装"
|
||||
|
||||
_A_NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
|
||||
|
||||
try:
|
||||
prs = Presentation(file_path)
|
||||
md_content = []
|
||||
@@ -89,10 +111,7 @@ def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional
|
||||
|
||||
if hasattr(shape, "has_table") and shape.has_table:
|
||||
if list_stack:
|
||||
md_content.append(
|
||||
"\n" + "\n".join([x for x in list_stack if x]) + "\n"
|
||||
)
|
||||
list_stack.clear()
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
table_md = convert_table_to_md_pptx(shape.table)
|
||||
md_content.append(table_md)
|
||||
@@ -104,20 +123,8 @@ def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional
|
||||
if pPr is not None:
|
||||
is_list = (
|
||||
para.level > 0
|
||||
or pPr.find(
|
||||
".//a:buChar",
|
||||
namespaces={
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
},
|
||||
)
|
||||
is not None
|
||||
or pPr.find(
|
||||
".//a:buAutoNum",
|
||||
namespaces={
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
},
|
||||
)
|
||||
is not None
|
||||
or pPr.find(".//a:buChar", namespaces=_A_NS) is not None
|
||||
or pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
|
||||
)
|
||||
|
||||
if is_list:
|
||||
@@ -128,16 +135,9 @@ def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional
|
||||
|
||||
text = extract_formatted_text_pptx(para.runs)
|
||||
if text:
|
||||
pPr = para._element.pPr
|
||||
is_ordered = (
|
||||
pPr is not None
|
||||
and pPr.find(
|
||||
".//a:buAutoNum",
|
||||
namespaces={
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
},
|
||||
)
|
||||
is not None
|
||||
and pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
|
||||
)
|
||||
marker = "1. " if is_ordered else "- "
|
||||
indent = " " * level
|
||||
@@ -149,20 +149,14 @@ def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional
|
||||
list_stack[i] = ""
|
||||
else:
|
||||
if list_stack:
|
||||
md_content.append(
|
||||
"\n"
|
||||
+ "\n".join([x for x in list_stack if x])
|
||||
+ "\n"
|
||||
)
|
||||
list_stack.clear()
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
text = extract_formatted_text_pptx(para.runs)
|
||||
if text:
|
||||
md_content.append(f"{text}\n")
|
||||
|
||||
if list_stack:
|
||||
md_content.append("\n" + "\n".join([x for x in list_stack if x]) + "\n")
|
||||
list_stack.clear()
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
md_content.append("---\n")
|
||||
|
||||
@@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from common import parse_with_docling, parse_with_markitdown
|
||||
from common import _unstructured_elements_to_markdown, parse_with_docling, parse_with_markitdown
|
||||
|
||||
|
||||
def parse_xlsx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
@@ -13,6 +13,23 @@ def parse_xlsx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str
|
||||
return parse_with_docling(file_path)
|
||||
|
||||
|
||||
def parse_xlsx_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 XLSX 文件"""
|
||||
try:
|
||||
from unstructured.partition.xlsx import partition_xlsx
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_xlsx(filename=file_path, infer_table_structure=True)
|
||||
content = _unstructured_elements_to_markdown(elements)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_xlsx_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析 XLSX 文件"""
|
||||
return parse_with_markitdown(file_path)
|
||||
@@ -68,58 +85,59 @@ def parse_xlsx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
|
||||
def parse_cell_value(cell: ET.Element, shared_strings: List[str]) -> str:
|
||||
cell_type = cell.attrib.get("t")
|
||||
|
||||
if cell_type == "inlineStr":
|
||||
is_elem = cell.find("main:is", xlsx_namespace)
|
||||
if is_elem is not None:
|
||||
t_elem = is_elem.find("main:t", xlsx_namespace)
|
||||
if t_elem is not None and t_elem.text:
|
||||
return t_elem.text.replace("\n", " ").replace("\r", "")
|
||||
return ""
|
||||
|
||||
cell_value_elem = cell.find("main:v", xlsx_namespace)
|
||||
if cell_value_elem is None or not cell_value_elem.text:
|
||||
return ""
|
||||
|
||||
if cell_value_elem is not None and cell_value_elem.text:
|
||||
cell_value = cell_value_elem.text
|
||||
cell_value = cell_value_elem.text
|
||||
|
||||
if cell_type == "s":
|
||||
try:
|
||||
idx = int(cell_value)
|
||||
if 0 <= idx < len(shared_strings):
|
||||
text = shared_strings[idx]
|
||||
return text.replace("\n", " ").replace("\r", "")
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return ""
|
||||
elif cell_type == "b":
|
||||
return "TRUE" if cell_value == "1" else "FALSE"
|
||||
elif cell_type == "str":
|
||||
return cell_value.replace("\n", " ").replace("\r", "")
|
||||
elif cell_type == "inlineStr":
|
||||
is_elem = cell.find("main:is", xlsx_namespace)
|
||||
if is_elem is not None:
|
||||
t_elem = is_elem.find("main:t", xlsx_namespace)
|
||||
if t_elem is not None and t_elem.text:
|
||||
return t_elem.text.replace("\n", " ").replace("\r", "")
|
||||
return ""
|
||||
elif cell_type == "e":
|
||||
error_codes = {
|
||||
"#NULL!": "空引用错误",
|
||||
"#DIV/0!": "除零错误",
|
||||
"#VALUE!": "值类型错误",
|
||||
"#REF!": "无效引用",
|
||||
"#NAME?": "名称错误",
|
||||
"#NUM!": "数值错误",
|
||||
"#N/A": "值不可用",
|
||||
}
|
||||
return error_codes.get(cell_value, f"错误: {cell_value}")
|
||||
elif cell_type == "d":
|
||||
return f"[日期] {cell_value}"
|
||||
elif cell_type == "n":
|
||||
if cell_type == "s":
|
||||
try:
|
||||
idx = int(cell_value)
|
||||
if 0 <= idx < len(shared_strings):
|
||||
text = shared_strings[idx]
|
||||
return text.replace("\n", " ").replace("\r", "")
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return ""
|
||||
elif cell_type == "b":
|
||||
return "TRUE" if cell_value == "1" else "FALSE"
|
||||
elif cell_type == "str":
|
||||
return cell_value.replace("\n", " ").replace("\r", "")
|
||||
elif cell_type == "e":
|
||||
_ERROR_CODES = {
|
||||
"#NULL!": "空引用错误",
|
||||
"#DIV/0!": "除零错误",
|
||||
"#VALUE!": "值类型错误",
|
||||
"#REF!": "无效引用",
|
||||
"#NAME?": "名称错误",
|
||||
"#NUM!": "数值错误",
|
||||
"#N/A": "值不可用",
|
||||
}
|
||||
return _ERROR_CODES.get(cell_value, f"错误: {cell_value}")
|
||||
elif cell_type == "d":
|
||||
return f"[日期] {cell_value}"
|
||||
elif cell_type == "n":
|
||||
return cell_value
|
||||
elif cell_type is None:
|
||||
try:
|
||||
float_val = float(cell_value)
|
||||
if float_val.is_integer():
|
||||
return str(int(float_val))
|
||||
return cell_value
|
||||
elif cell_type is None:
|
||||
try:
|
||||
float_val = float(cell_value)
|
||||
if float_val.is_integer():
|
||||
return str(int(float_val))
|
||||
return cell_value
|
||||
except ValueError:
|
||||
return cell_value
|
||||
else:
|
||||
except ValueError:
|
||||
return cell_value
|
||||
else:
|
||||
return ""
|
||||
return cell_value
|
||||
|
||||
def get_non_empty_columns(data: List[List[str]]) -> set:
|
||||
non_empty_cols = set()
|
||||
@@ -1,597 +0,0 @@
|
||||
# Document Parser 使用说明
|
||||
|
||||
一个模块化的文档解析器,支持将 DOCX、PPTX、XLSX 和 PDF 文件转换为 Markdown 格式。
|
||||
|
||||
## 概述
|
||||
|
||||
该解析器按优先级尝试多种解析方法,确保最大兼容性:
|
||||
|
||||
1. **Docling** (docling.document_converter) - 通用解析方案,优先覆盖 DOCX/PPTX/XLSX/PDF 并内置 OCR 能力
|
||||
2. **pypandoc-binary** (DOCX 专用,内置 Pandoc) - 生成结构化 Markdown
|
||||
3. **MarkItDown** (微软官方库) - 推荐使用,格式规范
|
||||
4. **python-docx / python-pptx / pandas** (成熟的 Python 库) - 输出最详细
|
||||
5. **unstructured / pypdf** (成熟的 PDF 库) - PDF 专用
|
||||
6. **XML 原生解析** (备选方案) - 无需依赖
|
||||
|
||||
### 特性
|
||||
|
||||
- 支持 DOCX、PPTX、XLSX 和 PDF 格式
|
||||
- 自动检测文件类型和有效性
|
||||
- 保留文本格式(粗体、斜体、下划线)
|
||||
- Docling 作为第一优先解析器,单一依赖即可覆盖全部格式并自动调用 OCR
|
||||
- 提取表格并转换为 Markdown 格式
|
||||
- 提取列表并保留层级结构
|
||||
- 多种输出模式(字数、行数、标题、搜索等)
|
||||
- 内容过滤和规范化
|
||||
- 模块化设计,易于维护和扩展
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── common.py # 公共函数和常量
|
||||
├── docx_parser.py # DOCX 文件解析
|
||||
├── pptx_parser.py # PPTX 文件解析
|
||||
├── xlsx_parser.py # XLSX 文件解析
|
||||
├── pdf_parser.py # PDF 文件解析
|
||||
├── parser.py # 命令行入口
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 依赖要求
|
||||
|
||||
### 基础运行(XML 解析)
|
||||
|
||||
```bash
|
||||
# Python 3.6+
|
||||
uv run parser.py file.docx
|
||||
```
|
||||
|
||||
### 使用 Docling(推荐)
|
||||
|
||||
```bash
|
||||
# 通用解析方案,覆盖 DOCX/PPTX/XLSX/PDF
|
||||
uv run --with docling parser.py file.docx
|
||||
uv run --with docling parser.py file.pptx
|
||||
uv run --with docling parser.py file.xlsx
|
||||
uv run --with docling parser.py file.pdf
|
||||
```
|
||||
|
||||
- Docling 是当前的默认第一优先级解析器,单一依赖即可获得统一输出。
|
||||
- 首次运行会自动下载 OCR/视觉模型到 `uv` 缓存目录,需保持网络连通。
|
||||
- 如果只需要 Docling,可无需安装其他解析依赖,脚本会在 Docling 失败时再回退至其他方案。
|
||||
|
||||
### 使用 pypandoc-binary(DOCX)
|
||||
|
||||
```bash
|
||||
# 使用 uv 自动安装
|
||||
uv run --with pypandoc-binary parser.py file.docx
|
||||
|
||||
# 或手动安装
|
||||
pip install pypandoc-binary
|
||||
```
|
||||
|
||||
### 使用 MarkItDown
|
||||
|
||||
```bash
|
||||
# 使用 uv 自动安装
|
||||
uv run --with markitdown parser.py file.docx
|
||||
uv run --with markitdown parser.py file.pptx
|
||||
uv run --with markitdown parser.py file.xlsx
|
||||
uv run --with "markitdown[pdf]" parser.py file.pdf
|
||||
|
||||
# 或手动安装
|
||||
pip install markitdown
|
||||
# 注意:PDF 支持需要额外安装
|
||||
pip install "markitdown[pdf]"
|
||||
```
|
||||
|
||||
### 使用专用库
|
||||
|
||||
```bash
|
||||
# 使用 uv 自动安装
|
||||
uv run --with python-docx parser.py file.docx
|
||||
uv run --with python-pptx parser.py file.pptx
|
||||
uv run --with pandas --with tabulate parser.py file.xlsx
|
||||
uv run --with unstructured parser.py file.pdf
|
||||
uv run --with pypdf parser.py file.pdf
|
||||
|
||||
# 或手动安装
|
||||
pip install python-docx
|
||||
pip install python-pptx
|
||||
pip install pandas tabulate
|
||||
pip install unstructured
|
||||
pip install pypdf
|
||||
```
|
||||
|
||||
### 所有依赖
|
||||
|
||||
```bash
|
||||
# 安装所有解析库
|
||||
uv run --with docling --with pypandoc-binary --with markitdown --with python-docx --with python-pptx --with pandas --with tabulate --with unstructured --with pypdf parser.py file.pdf
|
||||
```
|
||||
|
||||
## 命令行用法
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
uv run parser.py <file_path> [options]
|
||||
```
|
||||
|
||||
### 必需参数
|
||||
|
||||
- `file_path`: DOCX、PPTX、XLSX 或 PDF 文件的路径(相对或绝对路径)
|
||||
|
||||
### 可选参数(互斥组,一次只能使用一个)
|
||||
|
||||
| 参数 | 短选项 | 长选项 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| `-c` | `--count` | 返回解析后的 markdown 文档的总字数 |
|
||||
| `-l` | `--lines` | 返回解析后的 markdown 文档的总行数 |
|
||||
| `-t` | `--titles` | 返回解析后的 markdown 文档的标题行(1-6级) |
|
||||
| `-tc <name>` | `--title-content <name>` | 提取指定标题及其下级内容(不包含#号) |
|
||||
| `-s <pattern>` | `--search <pattern>` | 使用正则表达式搜索文档,返回所有匹配结果(用---分隔) |
|
||||
|
||||
### 搜索上下文参数
|
||||
|
||||
- `-n <num>` / `--context <num>`: 与 `-s` 配合使用,指定每个检索结果包含的前后行数(默认:2)
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 输出完整 Markdown 内容
|
||||
|
||||
```bash
|
||||
# 推荐:Docling 自动解析
|
||||
uv run --with docling parser.py report.docx
|
||||
uv run --with docling parser.py report.pdf
|
||||
|
||||
# 使用最佳可用解析器 (DOCX/PPTX/XLSX)
|
||||
uv run parser.py report.docx
|
||||
|
||||
# 使用最佳可用解析器 (PDF)
|
||||
uv run parser.py report.pdf
|
||||
|
||||
# 输出到文件
|
||||
uv run parser.py report.docx > output.md
|
||||
|
||||
# 使用特定依赖
|
||||
uv run --with pypandoc-binary parser.py report.docx > output.md
|
||||
uv run --with python-docx parser.py report.docx > output.md
|
||||
uv run --with pypdf parser.py report.pdf > output.md
|
||||
```
|
||||
|
||||
### 2. 统计文档信息
|
||||
|
||||
```bash
|
||||
# 统计字数
|
||||
uv run --with markitdown parser.py report.docx -c
|
||||
uv run --with unstructured parser.py report.pdf -c
|
||||
|
||||
# 统计行数
|
||||
uv run --with markitdown parser.py report.docx -l
|
||||
uv run --with pypdf parser.py report.pdf -l
|
||||
```
|
||||
|
||||
### 3. 提取标题
|
||||
|
||||
```bash
|
||||
# 提取所有标题
|
||||
uv run --with python-docx parser.py report.docx -t
|
||||
uv run --with unstructured parser.py report.pdf -t
|
||||
|
||||
# 输出示例(DOCX):
|
||||
# 第一章 概述
|
||||
## 1.1 背景
|
||||
## 1.2 目标
|
||||
# 第二章 实现
|
||||
|
||||
# 输出示例(PDF - 注意:PDF 通常不包含明确的标题层级):
|
||||
[内容提取成功,但 PDF 可能缺乏清晰的标题结构]
|
||||
```
|
||||
|
||||
### 4. 提取特定标题内容
|
||||
|
||||
```bash
|
||||
# 提取特定章节
|
||||
uv run --with python-docx parser.py report.docx -tc "第一章"
|
||||
uv run --with unstructured parser.py report.pdf -tc "第一章"
|
||||
|
||||
# 输出该标题及其所有子内容
|
||||
```
|
||||
|
||||
### 5. 搜索文档内容
|
||||
|
||||
```bash
|
||||
# 搜索关键词
|
||||
uv run --with markitdown parser.py report.docx -s "测试"
|
||||
uv run --with unstructured parser.py report.pdf -s "测试"
|
||||
|
||||
# 使用正则表达式
|
||||
uv run --with markitdown parser.py report.docx -s "章节\s+\d+"
|
||||
uv run --with pypdf parser.py report.pdf -s "章节\s+\d+"
|
||||
|
||||
# 带上下文搜索(前后各2行)
|
||||
uv run --with markitdown parser.py report.docx -s "重要内容" -n 2
|
||||
uv run --with "markitdown[pdf]" parser.py report.pdf -s "重要内容" -n 2
|
||||
|
||||
# 输出示例:
|
||||
---
|
||||
这是重要内容的前两行
|
||||
**重要内容**
|
||||
这是重要内容后两行
|
||||
---
|
||||
```
|
||||
|
||||
## 解析器对比
|
||||
|
||||
### DOCX 解析器
|
||||
|
||||
DOCX 文件会按以下优先级依次尝试解析:
|
||||
|
||||
1. Docling
|
||||
2. pypandoc-binary
|
||||
3. MarkItDown
|
||||
4. python-docx
|
||||
5. XML 原生
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | • 单一依赖覆盖所有 Office/PDF 格式<br>• 自动带 OCR,复杂文档召回率高<br>• 输出 Markdown 结构稳定 | • 首次运行需下载较大的模型<br>• 运行时内存占用相对更高 | • 需要“一键完成”解析<br>• 需要 OCR/多模态支持 |
|
||||
| **pypandoc-binary** | • 自带 Pandoc,可直接使用<br>• 输出 Markdown 结构整洁<br>• 错误信息清晰易排查 | • 仅适用于 DOCX<br>• 依赖包体积较大 | • 需要标准化 Markdown 输出<br>• Docling 不可用时的首选 |
|
||||
| **MarkItDown** | • 格式规范<br>• 微软官方支持<br>• 兼容性好 | • 需要安装<br>• 输出较简洁 | • 需要标准格式输出<br>• 自动化文档处理 |
|
||||
| **python-docx** | • 输出最详细<br>• 保留完整结构<br>• 支持复杂样式 | • 需要安装<br>• 可能包含多余空行 | • 需要精确控制输出<br>• 分析文档结构 |
|
||||
| **XML 原生** | • 无需依赖<br>• 运行速度快<br>• 输出原始内容 | • 格式可能不统一<br>• 样式处理有限 | • 依赖不可用时<br>• 快速提取内容 |
|
||||
|
||||
### PPTX 解析器
|
||||
|
||||
PPTX 文件会按以下优先级依次尝试解析:Docling → MarkItDown → python-pptx → XML 原生。
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | • 解析幻灯片文本、表格与图片 OCR<br>• 自动生成统一 Markdown,包含分页分隔符 | • 需要下载模型<br>• 细节控制少 | • 需要一次性转换全部幻灯片<br>• 有图片或扫描件的 PPTX |
|
||||
| **MarkItDown** | • 格式规范<br>• 自动添加 Slide 分隔<br>• 输出简洁 | • 需要安装<br>• 详细度较低 | • 快速预览幻灯片<br>• 提取主要内容 |
|
||||
| **python-pptx** | • 输出最详细<br>• 保留完整结构<br>• 支持层级列表 | • 需要安装<br>• 依赖私有 API | • 需要完整内容<br>• 分析演示结构 |
|
||||
| **XML 原生** | • 无需依赖<br>• 结构化输出<br>• 运行速度快 | • 格式可能不统一<br>• 幻灯片分组简单 | • 依赖不可用时<br>• 结构化提取 |
|
||||
|
||||
### XLSX 解析器
|
||||
|
||||
XLSX 文件会按以下优先级依次尝试解析:Docling → MarkItDown → pandas → XML 原生。
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | • 单次遍历导出全部工作表为 Markdown<br>• 自动处理合并单元格/图像 OCR | • 需要下载模型<br>• 对极大体积表可能较慢 | • 快速完成全表转 Markdown<br>• 含扫描图片的表格 |
|
||||
| **MarkItDown** | • 格式规范<br>• 支持多工作表<br>• 输出简洁 | • 需要安装<br>• 详细度较低 | • 快速预览表格<br>• 提取主要内容 |
|
||||
| **pandas** | • 功能强大<br>• 支持复杂表格<br>• 数据处理灵活 | • 需要安装<br>• 依赖较多 | • 数据分析<br>• 复杂表格处理 |
|
||||
| **XML 原生** | • 无需依赖<br>• 运行速度快<br>• 支持所有单元格类型 | • 格式可能不统一<br>• 无数据处理能力 | • 依赖不可用时<br>• 快速提取内容 |
|
||||
|
||||
### PDF 解析器
|
||||
|
||||
PDF 文件会按以下优先级依次尝试解析:Docling → MarkItDown → unstructured → pypdf。
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|---------|------|--------|---------|
|
||||
| **Docling** | • 内置 RapidOCR,可处理扫描版 PDF<br>• 输出结构化 Markdown,包含表格/图片占位 | • 模型下载体积大<br>• OCR 耗时较长 | • 需要 OCR、表格/图片识别<br>• 多语言 PDF |
|
||||
| **MarkItDown** | • 格式规范<br>• 微软官方支持<br>• 兼容性好 | • 需要安装 `markitdown[pdf]`<br>• 输出较简洁 | • 需要标准格式输出<br>• 自动化文档处理 |
|
||||
| **unstructured** | • 功能强大<br>• 支持表格提取<br>• 文本组织性好 | • 需要安装<br>• 可能包含页码标记 | • 需要完整内容<br>• 分析文档结构 |
|
||||
| **pypdf** | • 轻量级<br>• 速度快<br>• 安装简单 | • 需要安装<br>• 功能相对简单 | • 快速提取内容<br>• 简单文本提取 |
|
||||
|
||||
## 输出格式
|
||||
|
||||
### Markdown 输出结构
|
||||
|
||||
```markdown
|
||||
# 标题 1 (一级)
|
||||
|
||||
正文段落
|
||||
|
||||
## 标题 2 (二级)
|
||||
|
||||
- 列表项 1
|
||||
- 列表项 2
|
||||
|
||||
1. 有序列表项 1
|
||||
2. 有序列表项 2
|
||||
|
||||
| 列1 | 列2 | 列3 |
|
||||
|------|------|------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
|
||||
**粗体文本** *斜体文本* <u>下划线文本</u>
|
||||
```
|
||||
|
||||
### PPTX 特有格式
|
||||
|
||||
```markdown
|
||||
## Slide 1
|
||||
|
||||
幻灯片 1 的内容
|
||||
|
||||
## Slide 2
|
||||
|
||||
表格内容
|
||||
|
||||
幻灯片 2 的内容
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### XLSX 特有格式
|
||||
|
||||
```markdown
|
||||
# Excel数据转换结果
|
||||
|
||||
来源: /path/to/file.xlsx
|
||||
|
||||
## Sheet1
|
||||
|
||||
| 列1 | 列2 | 列3 |
|
||||
|------|------|------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
```
|
||||
|
||||
### PDF 特有格式
|
||||
|
||||
```markdown
|
||||
[PDF 文件的纯文本内容,按段落提取]
|
||||
|
||||
中电信粤亿迅〔2023〕3号
|
||||
|
||||
关于印发关于印发关于印发关于印发《《《《广东亿迅科技有限公司员工
|
||||
|
||||
[注:PDF 通常不包含明确的标题层级结构,内容以文本流形式呈现]
|
||||
```
|
||||
|
||||
### 标题格式
|
||||
|
||||
- 标题使用 Markdown 井号语法:`#` 到 `######`(1-6级)
|
||||
- 标题名称不包含井号
|
||||
- 段落通过空行分隔
|
||||
|
||||
### 表格格式
|
||||
|
||||
```markdown
|
||||
| 列1 | 列2 | 列3 |
|
||||
|------|------|------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
```
|
||||
|
||||
### 列表格式
|
||||
|
||||
- 无序列表:使用 `-` 前缀
|
||||
- 有序列表:使用 `1.` 前缀
|
||||
- 支持多层缩进(使用空格)
|
||||
|
||||
## 内容处理
|
||||
|
||||
### 自动处理
|
||||
|
||||
1. **图片移除**:自动删除 Markdown 图片语法
|
||||
2. **空行规范化**:合并连续空行为单个空行
|
||||
3. **样式标签过滤**:移除 HTML span 标签
|
||||
4. **RGB 颜色过滤**:移除颜色代码行
|
||||
|
||||
### 过滤规则(filter_markdown_content)
|
||||
|
||||
- 保留:文本、表格、列表、基本格式
|
||||
- 移除:
|
||||
- HTML 注释 (`<!-- ... -->`)
|
||||
- Markdown 图片 (``)
|
||||
- HTML 图片标签 (`<img>`, `</img>`)
|
||||
- 媒体链接 (`[text](file.ext)`)
|
||||
- RGB 颜色代码 (`R:255 G:255 B:255`)
|
||||
- 标准化:多余空格合并为单个空格
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 文件验证
|
||||
|
||||
```bash
|
||||
# 文件不存在
|
||||
错误: 文件不存在: missing.docx
|
||||
|
||||
# 无效格式
|
||||
错误: 不是有效的 DOCX、PPTX、XLSX 或 PDF 格式: invalid.txt
|
||||
```
|
||||
|
||||
### 解析器回退
|
||||
|
||||
脚本按优先级尝试解析器,如果失败则自动尝试下一个:
|
||||
|
||||
```
|
||||
所有解析方法均失败:
|
||||
- MarkItDown: 库未安装
|
||||
- python-docx: 解析失败: ...
|
||||
- XML 原生解析: document.xml 不存在或无法访问
|
||||
```
|
||||
|
||||
**PDF 回退示例**:
|
||||
|
||||
```
|
||||
所有解析方法均失败:
|
||||
- MarkItDown: MarkItDown 解析失败: ...
|
||||
- unstructured: unstructured 库未安装
|
||||
- pypdf: pypdf 库未安装
|
||||
```
|
||||
|
||||
所有解析方法均失败:
|
||||
|
||||
- MarkItDown: 库未安装
|
||||
- python-docx: 解析失败: ...
|
||||
- XML 原生解析: document.xml 不存在或无法访问
|
||||
|
||||
```
|
||||
|
||||
### 搜索错误
|
||||
|
||||
```bash
|
||||
# 无效正则
|
||||
错误: 正则表达式无效或未找到匹配: '[invalid'
|
||||
|
||||
# 标题未找到
|
||||
错误: 未找到标题 '不存在的标题'
|
||||
```
|
||||
|
||||
## 高级用法
|
||||
|
||||
### 结合 uv 运行
|
||||
|
||||
```bash
|
||||
# 自动安装依赖并运行
|
||||
uv run --with markitdown --with python-docx parser.py report.docx
|
||||
|
||||
# 输出到文件
|
||||
uv run --with python-docx parser.py report.docx > output.md
|
||||
```
|
||||
|
||||
### 批量处理
|
||||
|
||||
```bash
|
||||
# 使用 find 或 glob 批量处理
|
||||
for file in *.docx; do
|
||||
uv run --with markitdown parser.py "$file" > "${file%.docx}.md"
|
||||
done
|
||||
|
||||
# Windows PowerShell
|
||||
Get-ChildItem *.docx | ForEach-Object {
|
||||
uv run --with markitdown parser.py $_.FullName > ($_.BaseName + ".md")
|
||||
}
|
||||
```
|
||||
|
||||
### 管道使用
|
||||
|
||||
```bash
|
||||
# 进一步处理 Markdown 输出
|
||||
uv run --with markitdown parser.py report.docx | grep "重要" > important.md
|
||||
|
||||
# 统计处理
|
||||
uv run --with markitdown parser.py report.docx -l | awk '{print $1}'
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么有些内容没有提取到?
|
||||
|
||||
A: 不同解析器的输出详细度不同:
|
||||
|
||||
- `python-docx` / `python-pptx` 输出最详细
|
||||
- `MarkItDown` 输出较简洁
|
||||
- `XML 原生` 输出原始内容
|
||||
|
||||
如需完整内容,尝试使用专用库解析器。
|
||||
|
||||
### Q: PDF 文件没有标题层级?
|
||||
|
||||
A: PDF 是一种版面描述格式,通常不包含语义化的标题层级结构。与 DOCX/PPTX 不同,PDF 中的标题只是视觉上的文本样式,解析器无法准确识别标题层级。建议:
|
||||
|
||||
- 使用搜索功能查找特定内容
|
||||
- 使用 `-l` 统计行数了解文档长度
|
||||
- 使用 `-c` 统计字数了解文档规模
|
||||
|
||||
### Q: 表格格式不正确?
|
||||
|
||||
A: 确保原始文档中的表格结构完整。XML 解析器可能无法处理复杂表格。
|
||||
|
||||
### Q: 中文显示乱码?
|
||||
|
||||
A: 脚本输出使用 UTF-8 编码。确保终端支持 UTF-8:
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
# Windows PowerShell
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
```
|
||||
|
||||
### Q: 如何只使用特定解析器?
|
||||
|
||||
A: 当前版本自动选择最佳可用解析器。可以通过注释代码中的解析器列表来限制,或安装/卸载特定依赖。
|
||||
|
||||
### Q: MarkItDown 提示 PDF 依赖未安装?
|
||||
|
||||
A: MarkItDown 的 PDF 支持是可选依赖,需要使用 `markitdown[pdf]` 而非 `markitdown`:
|
||||
|
||||
```bash
|
||||
# 错误
|
||||
uv run --with markitdown parser.py file.pdf
|
||||
|
||||
# 正确
|
||||
uv run --with "markitdown[pdf]" parser.py file.pdf
|
||||
|
||||
# 或手动安装
|
||||
pip install "markitdown[pdf]"
|
||||
```
|
||||
|
||||
### Q: 大文件处理慢?
|
||||
|
||||
A: 大文件建议使用 XML 原生解析(最快),或在脚本外部处理。
|
||||
|
||||
## 性能参考
|
||||
|
||||
基于测试文件的参考数据:
|
||||
|
||||
> Docling 作为统一入口时,整体性能受 OCR/模型下载影响:首次运行略慢,缓存后与 MarkItDown 同量级,但在 PDF 场景中由于 OCR 会稍慢一些。
|
||||
|
||||
### DOCX (test.docx)
|
||||
|
||||
| 解析器 | 字符数 | 行数 | 相对速度 |
|
||||
|---------|--------|------|---------|
|
||||
| MarkItDown | ~8,500 | ~123 | 快 |
|
||||
| python-docx | ~8,500 | ~123 | 中 |
|
||||
| XML 原生 | ~8,500 | ~123 | 快 |
|
||||
|
||||
### PPTX (test.pptx)
|
||||
|
||||
| 解析器 | 字符数 | 行数 | 相对速度 |
|
||||
|---------|--------|------|---------|
|
||||
| MarkItDown | ~2,500 | ~257 | 快 |
|
||||
| python-pptx | ~2,500 | ~257 | 中 |
|
||||
| XML 原生 | ~2,500 | ~257 | 快 |
|
||||
|
||||
### XLSX (test.xlsx)
|
||||
|
||||
| 解析器 | 字符数 | 行数 | 相对速度 |
|
||||
|---------|--------|------|---------|
|
||||
| MarkItDown | ~6,000 | ~109 | 快 |
|
||||
| pandas | ~6,000 | ~109 | 中 |
|
||||
| XML 原生 | ~6,000 | ~109 | 快 |
|
||||
|
||||
### PDF (test.pdf)
|
||||
|
||||
| 解析器 | 字符数 | 行数 | 相对速度 |
|
||||
|---------|--------|------|---------|
|
||||
| MarkItDown | ~8,200 | ~1,120 | 快 |
|
||||
| unstructured | ~8,400 | ~600 | 中 |
|
||||
| pypdf | ~8,400 | ~600 | 快 |
|
||||
|
||||
## 代码风格
|
||||
|
||||
脚本遵循以下代码风格:
|
||||
|
||||
- Python 3.6+ 兼容
|
||||
- 遵循 PEP 8 规范
|
||||
- 所有公共 API 函数添加类型提示
|
||||
- 字符串优先内联使用,不提取为常量,除非被使用超过3次
|
||||
- 其他被多次使用的对象根据具体情况可考虑被提取为常量(如正则表达式)
|
||||
- 模块级和公共 API 函数保留文档字符串
|
||||
- 内部辅助函数不添加文档字符串(函数名足够描述)
|
||||
- 变量命名清晰,避免单字母变量名
|
||||
|
||||
## 许可证
|
||||
|
||||
脚本遵循 PEP 8 规范,Python 3.6+ 兼容。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 最新版本
|
||||
|
||||
- 新增 Docling 解析路径,统一处理 DOCX/PPTX/XLSX/PDF,并自动具备 OCR 能力
|
||||
- DOCX 解析新增 pypandoc-binary 方案并设置为最高优先级
|
||||
- 将单体脚本拆分为模块化结构(common.py, docx.py, pptx.py, xlsx.py, parser.py)
|
||||
- 添加 XLSX 文件支持
|
||||
- 添加 PDF 文件支持(MarkItDown、unstructured、pypdf)
|
||||
- 增强错误处理(文件存在性检查、无效格式检测)
|
||||
- 完善文档和示例
|
||||
- 使用 uv 进行依赖管理和运行
|
||||
- 所有模块通过语法检查和功能测试
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PDF 文件解析模块,提供三种解析方法。"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from common import parse_with_docling, parse_with_markitdown
|
||||
|
||||
|
||||
def parse_pdf_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析 PDF 文件"""
|
||||
return parse_with_docling(file_path)
|
||||
|
||||
|
||||
def parse_pdf_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析 PDF 文件"""
|
||||
return parse_with_markitdown(file_path)
|
||||
|
||||
|
||||
def parse_pdf_with_unstructured(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 PDF 文件"""
|
||||
try:
|
||||
from unstructured.partition.pdf import partition_pdf
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_pdf(
|
||||
filename=file_path,
|
||||
strategy="fast",
|
||||
infer_table_structure=True,
|
||||
extract_images_in_pdf=False,
|
||||
)
|
||||
|
||||
md_lines = []
|
||||
for element in elements:
|
||||
if hasattr(element, "text") and element.text and element.text.strip():
|
||||
text = element.text.strip()
|
||||
md_lines.append(text)
|
||||
md_lines.append("")
|
||||
|
||||
content = "\n".join(md_lines).strip()
|
||||
if not content:
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pdf_with_pypdf(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 pypdf 库解析 PDF 文件"""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
return None, "pypdf 库未安装"
|
||||
|
||||
try:
|
||||
reader = PdfReader(file_path)
|
||||
md_content = []
|
||||
|
||||
for page in reader.pages:
|
||||
text = page.extract_text(extraction_mode="plain")
|
||||
if text and text.strip():
|
||||
md_content.append(text.strip())
|
||||
md_content.append("")
|
||||
|
||||
content = "\n".join(md_content).strip()
|
||||
if not content:
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"pypdf 解析失败: {str(e)}"
|
||||
Reference in New Issue
Block a user