移除未开发好的技能
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
---
|
||||
name: lyxy-kb
|
||||
description: 基于文件的个人知识库管理 skill。当用户说"创建知识库"、"初始化知识项目"、"入库文档"、"知识问答"、"基于文档回答"时使用。支持文档解析入库、增量摘要、渐进式问答。配合 /lyxy-kb-init、/lyxy-kb-ingest、/lyxy-kb-ask 等 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 并生成摘要
|
||||
- **知识问答**:基于已入库的文档回答问题,并标注信息来源
|
||||
|
||||
### 不适用场景
|
||||
- 需要语义搜索或向量化检索
|
||||
- 需要跨多个知识项目关联查询
|
||||
- 需要多人协作或权限控制
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 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>` | 基于知识库进行会话问答 |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 知识项目目录结构
|
||||
|
||||
```
|
||||
<project-name>/
|
||||
├── project.md # 高度摘要 + 文件索引
|
||||
├── manifest.json # 增量追踪
|
||||
├── parsed/ # 解析后的 markdown
|
||||
├── sources/ # 待处理区(用户放入原始文档)
|
||||
└── archive/ # 原始文件备份(带时间戳)
|
||||
```
|
||||
|
||||
### 基本工作流程
|
||||
|
||||
1. **初始化**:使用 `/lyxy-kb-init <name>` 创建项目目录结构
|
||||
2. **入库**:将文档放入 `sources/`,执行 `/lyxy-kb-ingest <name>`
|
||||
3. **问答**:使用 `/lyxy-kb-ask <name>` 基于知识库回答问题
|
||||
|
||||
### 渐进式查询策略
|
||||
|
||||
1. **读取 project.md**:获取项目概述和文件索引(低 token 开销)
|
||||
2. **判断相关文件**:根据用户问题和摘要判断需要查阅哪些 parsed 文件
|
||||
3. **按需加载**:读取相关 parsed 文件的全部或部分内容
|
||||
4. **回答并标注来源**:基于获取的信息回答问题
|
||||
|
||||
## References
|
||||
|
||||
详细文档请参阅 `references/` 目录:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `references/structure.md` | 目录结构规范、project.md 格式、manifest.json 结构、parsed 元信息标记 |
|
||||
| `references/workflow.md` | 文档生命周期、归档命名规则、冲突检测、解析策略 |
|
||||
| `references/query-strategy.md` | 渐进式查询策略、来源引用格式、依赖关系、限制说明 |
|
||||
@@ -1,43 +0,0 @@
|
||||
# 渐进式查询策略
|
||||
|
||||
## 分层加载策略
|
||||
|
||||
问答时采用分层加载策略,节省 token:
|
||||
|
||||
1. **读取 project.md**:获取项目概述和文件索引(低 token 开销)
|
||||
2. **判断相关文件**:根据用户问题和文件索引中的摘要,判断需要查阅哪些 parsed 文件
|
||||
3. **按需加载**:读取相关 parsed 文件的全部或部分内容
|
||||
4. **回答并标注来源**:基于获取的信息回答问题
|
||||
|
||||
## 来源引用格式
|
||||
|
||||
回答中引用具体信息时,使用以下格式标注来源:
|
||||
|
||||
```
|
||||
根据《文件名》(parsed/文件名.md),...
|
||||
```
|
||||
|
||||
多个来源时分别标注各信息点的来源文件。
|
||||
|
||||
## 无相关信息
|
||||
|
||||
当知识库中未找到与用户问题相关的信息时,明确告知用户,不编造答案。
|
||||
|
||||
## 空知识库
|
||||
|
||||
如果 project.md 文件索引为空(尚无已入库文件),应告知用户知识库为空,建议先使用 `/lyxy-kb-ingest` 入库文档。
|
||||
|
||||
## 依赖关系
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| lyxy-reader-office | 解析 .docx、.pdf、.pptx、.xlsx 文件为 markdown |
|
||||
| lyxy-runner-python | 通过 uv 执行 lyxy-reader-office 的 Python 解析脚本 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 不支持向量化语义搜索
|
||||
- 不支持跨知识项目关联查询
|
||||
- 不支持文档版本对比或 diff
|
||||
- 不支持多用户协作或权限控制
|
||||
- 大量文件全量重写时 token 消耗较高
|
||||
@@ -1,125 +0,0 @@
|
||||
# 知识项目目录结构
|
||||
|
||||
## 目录结构
|
||||
|
||||
每个知识项目是当前工作目录(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>`,终止当前操作。
|
||||
|
||||
## 项目名称规则
|
||||
|
||||
项目名称只允许使用以下字符:
|
||||
- 中文字符
|
||||
- 英文字母(a-z、A-Z)
|
||||
- 数字(0-9)
|
||||
- 短横线(-)
|
||||
- 下划线(_)
|
||||
|
||||
**不允许包含空格或其他特殊字符。** 不符合规则时应提示用户修改。
|
||||
|
||||
## project.md 格式规范
|
||||
|
||||
```markdown
|
||||
# <项目名称>
|
||||
|
||||
## 概述
|
||||
(高度总结的项目信息,几百字以内)
|
||||
|
||||
## 关键信息
|
||||
(从所有文档中提炼的核心要点)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件名 | 解析文件 | 最新归档 | 摘要 |
|
||||
|--------|----------|----------|------|
|
||||
| 需求文档 | parsed/需求文档.md | archive/需求文档_202602181600.docx | 简要摘要... |
|
||||
|
||||
## 更新记录
|
||||
- 2026-02-18 16:00: 解析 需求文档.docx
|
||||
```
|
||||
|
||||
## 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` | 该版本的入库时间 |
|
||||
|
||||
## 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 格式) |
|
||||
@@ -1,86 +0,0 @@
|
||||
# 文档生命周期和处理策略
|
||||
|
||||
## 文档生命周期
|
||||
|
||||
```
|
||||
用户放入 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。非冲突文件正常处理。
|
||||
|
||||
## 文件类型解析策略
|
||||
|
||||
| 文件类型 | 解析方式 |
|
||||
|----------|----------|
|
||||
| `.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/ 中不移动**,报告错误信息,继续处理其他文件。
|
||||
|
||||
## 更新策略
|
||||
|
||||
**增量追加**(默认,由 ingest 触发):
|
||||
- 新文件:在文件索引表追加新行,在更新记录追加条目
|
||||
- 已有文件更新:覆盖文件索引表中对应行的最新归档路径和摘要
|
||||
- 概述和关键信息部分**不**自动更新
|
||||
|
||||
**全量重写**(由 rebuild 触发):
|
||||
- 读取所有 parsed/*.md 文件
|
||||
- 重新生成概述、关键信息、文件索引
|
||||
- 保留历史更新记录,追加本次 rebuild 条目
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: lyxy-reader-html
|
||||
description: 解析 HTML 文件和 URL 网页内容的 skill,将 HTML 转换为 Markdown 格式,支持全文提取、标题提取、章节提取、正则搜索、字数统计、行数统计。URL 模式下自动下载网页内容,支持 JS 渲染。使用时请阅读 scripts/README.md 获取详细用法。
|
||||
compatibility: Requires Python 3.6+. 推荐通过 lyxy-runner-python skill 使用 uv 自动管理依赖。
|
||||
---
|
||||
|
||||
# HTML 网页解析 Skill
|
||||
|
||||
将 HTML 文件或 URL 网页内容解析为 Markdown 格式,支持多种查询模式。
|
||||
|
||||
## Purpose
|
||||
|
||||
**统一入口**:使用 `scripts/parser.py` 作为统一的命令行入口,自动识别输入类型(URL 或 HTML 文件)并执行解析。
|
||||
|
||||
**依赖选项**:此 skill 必须优先使用 lyxy-runner-python skill 执行,不可用时降级到直接 Python 执行。
|
||||
|
||||
## When to Use
|
||||
|
||||
任何需要读取或解析 HTML 文件、URL 网页内容的任务都应使用此 skill。
|
||||
|
||||
### 典型场景
|
||||
- **网页内容提取**:将 URL 或本地 HTML 文件转换为可读的 Markdown 文本
|
||||
- **文档元数据**:获取文档的字数、行数等信息
|
||||
- **标题分析**:提取文档的标题结构
|
||||
- **章节提取**:提取特定章节的内容
|
||||
- **内容搜索**:在文档中搜索关键词或模式
|
||||
|
||||
### 触发词
|
||||
- 中文:"读取/解析/打开 html/htm 网页/URL"
|
||||
- 英文:"read/parse/extract html/htm web page url"
|
||||
- 文件扩展名:`.html`、`.htm`
|
||||
- URL 模式:`http://`、`https://`
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| (无参数) | 输出完整 Markdown 内容 |
|
||||
| `-c` | 字数统计 |
|
||||
| `-l` | 行数统计 |
|
||||
| `-t` | 提取所有标题 |
|
||||
| `-tc <name>` | 提取指定标题的章节内容 |
|
||||
| `-s <pattern>` | 正则表达式搜索 |
|
||||
| `-n <num>` | 与 `-s` 配合,指定上下文行数 |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **检查依赖**:优先使用 lyxy-runner-python,否则降级到直接 Python 执行
|
||||
2. **识别输入**:自动判断是 URL 还是本地 HTML 文件
|
||||
3. **下载内容**:URL 模式下按 pyppeteer → selenium → httpx → urllib 优先级下载
|
||||
4. **清理 HTML**:移除 script/style/link/svg 等标签和 URL 属性
|
||||
5. **执行解析**:按 trafilatura → domscribe → MarkItDown → html2text 优先级解析
|
||||
6. **输出结果**:返回 Markdown 格式内容或统计信息
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
# 使用 lyxy-runner-python(推荐)
|
||||
uv run --with trafilatura --with domscribe --with markitdown --with html2text --with httpx --with pyppeteer --with selenium --with beautifulsoup4 scripts/parser.py https://example.com
|
||||
|
||||
# 降级到直接执行
|
||||
python3 scripts/parser.py https://example.com
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
详细文档请参阅 `references/` 目录:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `references/examples.md` | URL 和 HTML 文件的完整提取、字数统计、标题提取、章节提取、搜索等示例 |
|
||||
| `references/parsers.md` | 解析器说明、依赖安装、各解析器输出特点、能力说明 |
|
||||
| `references/error-handling.md` | 限制说明、最佳实践、依赖执行策略 |
|
||||
|
||||
> **详细用法**:请阅读 `scripts/README.md` 获取完整的命令行参数和依赖安装指南。
|
||||
@@ -1,54 +0,0 @@
|
||||
# 错误处理和限制说明
|
||||
|
||||
## 限制
|
||||
|
||||
- 不支持图片提取(仅纯文本)
|
||||
- 不支持复杂的格式保留(字体、颜色、布局等)
|
||||
- 不支持文档编辑或修改
|
||||
- 仅支持 URL、.html、.htm 格式
|
||||
- pyppeteer 和 selenium 需要额外配置环境变量
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **必须优先使用 lyxy-runner-python**:如果环境中存在,必须使用 lyxy-runner-python 执行脚本
|
||||
2. **查阅 README**:详细参数、依赖安装、下载器/解析器对比等信息请阅读 `scripts/README.md`
|
||||
3. **JS 渲染网页**:对于需要 JS 渲染的网页,确保安装 pyppeteer 或 selenium 并正确配置环境变量
|
||||
4. **轻量使用**:如果目标网页不需要 JS 渲染,可以只安装 httpx/urllib 以获得更快的下载速度
|
||||
5. **禁止自动安装**:降级到直接 Python 执行时,仅向用户提示安装依赖,不得自动执行 pip install
|
||||
|
||||
## 依赖执行策略
|
||||
|
||||
### 必须使用 lyxy-runner-python
|
||||
|
||||
如果环境中存在 lyxy-runner-python skill,**必须**使用它来执行 parser.py 脚本:
|
||||
- lyxy-runner-python 使用 uv 管理依赖,自动安装所需的第三方库
|
||||
- 环境隔离,不污染系统 Python
|
||||
- 跨平台兼容(Windows/macOS/Linux)
|
||||
|
||||
### 降级到直接执行
|
||||
|
||||
**仅当** lyxy-runner-python skill 不存在时,才降级到直接 Python 执行:
|
||||
- 需要用户手动安装依赖
|
||||
- 至少需要安装 html2text 和 beautifulsoup4
|
||||
- **禁止自动执行 pip install**,仅向用户提示安装建议
|
||||
|
||||
## JS 渲染配置
|
||||
|
||||
### pyppeteer 配置
|
||||
|
||||
- 首次运行会自动下载 Chromium(需要网络连接)
|
||||
- 或设置 `LYXY_CHROMIUM_BINARY` 环境变量指定 Chromium/Chrome 可执行文件路径
|
||||
|
||||
### selenium 配置
|
||||
|
||||
必须设置两个环境变量:
|
||||
- `LYXY_CHROMIUM_DRIVER` - ChromeDriver 可执行文件路径
|
||||
- `LYXY_CHROMIUM_BINARY` - Chromium/Chrome 可执行文件路径
|
||||
|
||||
## 不适用场景
|
||||
|
||||
- 需要提取图片内容(仅支持纯文本)
|
||||
- 需要保留复杂的格式信息(字体、颜色、布局)
|
||||
- 需要编辑或修改文档
|
||||
- 需要登录或认证才能访问的网页(需自行处理 Cookie/Token)
|
||||
- 需要处理动态内容加载但不使用 JS 渲染的情况
|
||||
@@ -1,59 +0,0 @@
|
||||
# 示例
|
||||
|
||||
## URL 输入 - 提取完整文档内容
|
||||
|
||||
```bash
|
||||
# 使用 uv(推荐)
|
||||
uv run --with trafilatura --with domscribe --with markitdown --with html2text --with httpx --with beautifulsoup4 scripts/parser.py https://example.com
|
||||
|
||||
# 直接使用 Python
|
||||
python scripts/parser.py https://example.com
|
||||
```
|
||||
|
||||
## HTML 文件输入 - 提取完整文档内容
|
||||
|
||||
```bash
|
||||
# 使用 uv(推荐)
|
||||
uv run --with trafilatura --with domscribe --with markitdown --with html2text --with beautifulsoup4 scripts/parser.py page.html
|
||||
|
||||
# 直接使用 Python
|
||||
python scripts/parser.py page.html
|
||||
```
|
||||
|
||||
## 获取文档字数
|
||||
|
||||
```bash
|
||||
uv run --with trafilatura --with html2text --with beautifulsoup4 scripts/parser.py -c https://example.com
|
||||
```
|
||||
|
||||
## 获取文档行数
|
||||
|
||||
```bash
|
||||
uv run --with trafilatura --with html2text --with beautifulsoup4 scripts/parser.py -l https://example.com
|
||||
```
|
||||
|
||||
## 提取所有标题
|
||||
|
||||
```bash
|
||||
uv run --with trafilatura --with html2text --with beautifulsoup4 scripts/parser.py -t https://example.com
|
||||
```
|
||||
|
||||
## 提取指定章节
|
||||
|
||||
```bash
|
||||
uv run --with trafilatura --with html2text --with beautifulsoup4 scripts/parser.py -tc "关于我们" https://example.com
|
||||
```
|
||||
|
||||
## 搜索关键词
|
||||
|
||||
```bash
|
||||
uv run --with trafilatura --with html2text --with beautifulsoup4 scripts/parser.py -s "关键词" -n 3 https://example.com
|
||||
```
|
||||
|
||||
## 降级到直接 Python 执行
|
||||
|
||||
仅当 lyxy-runner-python skill 不存在时使用:
|
||||
|
||||
```bash
|
||||
python3 scripts/parser.py https://example.com
|
||||
```
|
||||
@@ -1,68 +0,0 @@
|
||||
# 解析器说明和依赖安装
|
||||
|
||||
## 多策略解析降级
|
||||
|
||||
URL 下载器按 pyppeteer → selenium → httpx → urllib 优先级依次尝试;HTML 解析器按 trafilatura → domscribe → MarkItDown → html2text 优先级依次尝试。前一个失败自动回退到下一个。
|
||||
|
||||
详细的优先级和对比请查阅 `scripts/README.md`。
|
||||
|
||||
## 依赖安装
|
||||
|
||||
### 使用 uv(推荐)
|
||||
|
||||
```bash
|
||||
# 完整安装(所有下载器和解析器)
|
||||
uv run --with trafilatura --with domscribe --with markitdown --with html2text --with httpx --with pyppeteer --with selenium --with beautifulsoup4 scripts/parser.py https://example.com
|
||||
|
||||
# 轻量安装(仅 httpx + html2text)
|
||||
uv run --with html2text --with beautifulsoup4 scripts/parser.py https://example.com
|
||||
```
|
||||
|
||||
> **说明**:以上为推荐安装命令,包含所有组件以获得最佳兼容性。详细的优先级和对比请查阅 `scripts/README.md`。
|
||||
|
||||
## 下载器对比
|
||||
|
||||
| 下载器 | 优点 | 缺点 | 适用场景 |
|
||||
|--------|------|------|---------|
|
||||
| **pyppeteer** | 支持 JS 渲染;现代网页兼容性好 | 依赖重;首次需下载 Chromium | 需要 JS 渲染的现代网页 |
|
||||
| **selenium** | 支持 JS 渲染;成熟稳定 | 需配置 Chromium driver 和 binary | 需要 JS 渲染的现代网页 |
|
||||
| **httpx** | 轻量快速;现代 HTTP 客户端 | 不支持 JS 渲染 | 静态网页;快速下载 |
|
||||
| **urllib** | Python 标准库;无需安装 | 不支持 JS 渲染 | 静态网页;兜底方案 |
|
||||
|
||||
## 解析器对比
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 适用场景 |
|
||||
|--------|------|------|---------|
|
||||
| **trafilatura** | 专门用于网页正文提取;输出质量高 | 可能无法提取某些页面 | 大多数网页正文提取 |
|
||||
| **domscribe** | 专注内容提取 | 相对较新 | 网页内容提取 |
|
||||
| **MarkItDown** | 微软官方;格式规范 | 输出较简洁 | 标准格式转换 |
|
||||
| **html2text** | 经典库;兼容性好 | 作为兜底方案 | 兜底解析 |
|
||||
|
||||
## 能力说明
|
||||
|
||||
### 1. URL / HTML 文件输入
|
||||
支持两种输入方式:
|
||||
- URL:自动下载网页内容(支持 JS 渲染)
|
||||
- 本地 HTML 文件:直接读取并解析
|
||||
|
||||
### 2. 全文转换为 Markdown
|
||||
将完整 HTML 解析为 Markdown 格式,移除图片但保留文本格式(标题、列表、表格、粗体、斜体等)。
|
||||
|
||||
### 3. HTML 预处理清理
|
||||
解析前自动清理 HTML:
|
||||
- 移除 script/style/link/svg 标签
|
||||
- 移除 href/src/srcset/action 等 URL 属性
|
||||
- 移除 style 属性
|
||||
|
||||
### 4. 获取文档元信息
|
||||
- 字数统计(`-c` 参数)
|
||||
- 行数统计(`-l` 参数)
|
||||
|
||||
### 5. 标题列表提取
|
||||
提取文档中所有 1-6 级标题(`-t` 参数),按原始层级关系返回。
|
||||
|
||||
### 6. 指定章节内容提取
|
||||
根据标题名称提取特定章节的完整内容(`-tc` 参数),包含上级标题链和所有下级内容。
|
||||
|
||||
### 7. 正则表达式搜索
|
||||
在文档中搜索关键词或模式(`-s` 参数),支持自定义上下文行数(`-n` 参数,默认 2 行)。
|
||||
@@ -1,323 +0,0 @@
|
||||
# HTML Parser 使用说明
|
||||
|
||||
HTML/URL 解析器,将网页内容或本地 HTML 文件转换为 Markdown 格式。
|
||||
|
||||
支持两种输入源:URL(自动下载网页内容)或本地 HTML 文件。下载器按 pyppeteer → selenium → httpx → urllib 优先级依次尝试;解析器按 trafilatura → domscribe → MarkItDown → html2text 优先级依次尝试。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 最简运行(仅 urllib + html2text)
|
||||
python parser.py https://example.com
|
||||
|
||||
# 安装推荐依赖后运行
|
||||
pip install trafilatura domscribe markitdown html2text httpx beautifulsoup4
|
||||
python parser.py https://example.com
|
||||
|
||||
# 使用 uv 一键运行(自动安装依赖,无需手动 pip install)
|
||||
uv run --with trafilatura --with domscribe --with markitdown --with html2text --with httpx --with beautifulsoup4 parser.py https://example.com
|
||||
```
|
||||
|
||||
## 命令行用法
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
python parser.py <input> [options]
|
||||
```
|
||||
|
||||
`input` 可以是:
|
||||
- 以 `http://` 或 `https://` 开头的 URL
|
||||
- 本地 `.html` 或 `.htm` 文件路径
|
||||
|
||||
不带任何选项时输出完整 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) |
|
||||
|
||||
### 退出码
|
||||
|
||||
| 退出码 | 含义 |
|
||||
|--------|------|
|
||||
| `0` | 解析成功 |
|
||||
| `1` | 错误(文件不存在、格式无效、所有下载器失败、所有解析器失败、标题未找到、正则无效或无匹配) |
|
||||
|
||||
### 使用示例
|
||||
|
||||
**URL 输入:**
|
||||
|
||||
```bash
|
||||
# 输出完整 Markdown
|
||||
python parser.py https://example.com
|
||||
python parser.py https://example.com > output.md
|
||||
```
|
||||
|
||||
**HTML 文件输入:**
|
||||
|
||||
```bash
|
||||
# 输出完整 Markdown
|
||||
python parser.py page.html
|
||||
python parser.py page.html > output.md
|
||||
```
|
||||
|
||||
**统计信息(`-c` / `-l`):**
|
||||
|
||||
输出单个数字,适合管道处理。
|
||||
|
||||
```bash
|
||||
$ python parser.py https://example.com -c
|
||||
8500
|
||||
|
||||
$ python parser.py https://example.com -l
|
||||
215
|
||||
```
|
||||
|
||||
**提取标题(`-t`):**
|
||||
|
||||
每行一个标题,保留 `#` 前缀和层级。
|
||||
|
||||
```bash
|
||||
$ python parser.py https://example.com -t
|
||||
# 欢迎来到 Example
|
||||
## 关于我们
|
||||
## 联系方式
|
||||
```
|
||||
|
||||
**提取标题内容(`-tc`):**
|
||||
|
||||
输出指定标题及其下级内容。如果文档中有多个同名标题,用 `---` 分隔。每段输出包含上级标题链。
|
||||
|
||||
```bash
|
||||
$ python parser.py https://example.com -tc "关于我们"
|
||||
# 欢迎来到 Example
|
||||
## 关于我们
|
||||
这是关于我们的详细内容...
|
||||
```
|
||||
|
||||
**搜索(`-s`):**
|
||||
|
||||
支持 Python 正则表达式语法。多个匹配结果用 `---` 分隔。`-n` 控制上下文行数。
|
||||
|
||||
```bash
|
||||
$ python parser.py https://example.com -s "测试" -n 1
|
||||
上一行内容
|
||||
包含**测试**关键词的行
|
||||
下一行内容
|
||||
---
|
||||
另一处上一行
|
||||
另一处**测试**内容
|
||||
另一处下一行
|
||||
```
|
||||
|
||||
### 批量处理
|
||||
|
||||
```bash
|
||||
# Linux/Mac - 批量处理 HTML 文件
|
||||
for file in *.html; do
|
||||
python parser.py "$file" > "${file%.html}.md"
|
||||
done
|
||||
|
||||
# Windows PowerShell - 批量处理 HTML 文件
|
||||
Get-ChildItem *.html | ForEach-Object {
|
||||
python parser.py $_.FullName > ($_.BaseName + ".md")
|
||||
}
|
||||
```
|
||||
|
||||
### 管道使用
|
||||
|
||||
```bash
|
||||
# 过滤包含关键词的行
|
||||
python parser.py https://example.com | grep "重要" > important.md
|
||||
|
||||
# 统计标题数量
|
||||
python parser.py https://example.com -t | wc -l
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
脚本基于 Python 3.6+ 运行。下载器和解析器按优先级依次尝试,建议安装所有依赖以获得最佳兼容性。也可以只安装部分依赖,脚本会自动选择可用的组件。
|
||||
|
||||
### 完整安装(推荐)
|
||||
|
||||
```bash
|
||||
# pip
|
||||
pip install trafilatura domscribe markitdown html2text httpx pyppeteer selenium beautifulsoup4
|
||||
|
||||
# uv(一键运行,无需预安装)
|
||||
uv run --with trafilatura --with domscribe --with markitdown --with html2text --with httpx --with pyppeteer --with selenium --with beautifulsoup4 parser.py https://example.com
|
||||
```
|
||||
|
||||
### 最小安装
|
||||
|
||||
仅使用标准库(urllib + html2text):
|
||||
|
||||
```bash
|
||||
pip install html2text beautifulsoup4
|
||||
```
|
||||
|
||||
### 各组件说明
|
||||
|
||||
**下载器**:
|
||||
|
||||
| 下载器 | 优点 | 缺点 | 依赖 |
|
||||
|--------|------|------|------|
|
||||
| pyppeteer | 支持 JS 渲染,现代网页兼容性好 | 依赖重,需下载 Chromium | pyppeteer |
|
||||
| selenium | 支持 JS 渲染,成熟稳定 | 需配置 Chromium driver 和 binary | selenium |
|
||||
| httpx | 轻量快速,现代 HTTP 客户端 | 不支持 JS 渲染 | httpx |
|
||||
| urllib | Python 标准库,无需安装 | 不支持 JS 渲染 | (无) |
|
||||
|
||||
**解析器**:
|
||||
|
||||
| 解析器 | 优点 | 缺点 | 依赖 |
|
||||
|--------|------|------|------|
|
||||
| trafilatura | 专门用于网页正文提取,质量高 | 可能无法提取某些页面 | trafilatura |
|
||||
| domscribe | 专注内容提取 | 相对较新 | domscribe |
|
||||
| MarkItDown | 微软官方,格式规范 | 输出较简洁 | markitdown |
|
||||
| html2text | 经典库,兼容性好 | 作为兜底方案 | html2text |
|
||||
|
||||
**其他依赖**:
|
||||
|
||||
- `beautifulsoup4` - HTML 清理必需
|
||||
|
||||
### JS 渲染配置
|
||||
|
||||
pyppeteer 和 selenium 支持 JS 渲染,但需要额外配置:
|
||||
|
||||
**pyppeteer**:
|
||||
- 首次运行会自动下载 Chromium
|
||||
- 或设置 `LYXY_CHROMIUM_BINARY` 环境变量指定 Chromium 路径
|
||||
|
||||
**selenium**:
|
||||
- 必须设置两个环境变量:
|
||||
- `LYXY_CHROMIUM_DRIVER` - ChromeDriver 路径
|
||||
- `LYXY_CHROMIUM_BINARY` - Chromium/Chrome 路径
|
||||
|
||||
## 输出格式
|
||||
|
||||
### Markdown 文档结构
|
||||
|
||||
无选项时输出完整 Markdown,包含以下元素:
|
||||
|
||||
```markdown
|
||||
# 一级标题
|
||||
|
||||
正文段落
|
||||
|
||||
## 二级标题
|
||||
|
||||
- 无序列表项
|
||||
- 无序列表项
|
||||
|
||||
1. 有序列表项
|
||||
2. 有序列表项
|
||||
|
||||
| 列1 | 列2 | 列3 |
|
||||
|------|------|------|
|
||||
| 数据1 | 数据2 | 数据3 |
|
||||
|
||||
**粗体** *斜体*
|
||||
```
|
||||
|
||||
### 内容自动处理
|
||||
|
||||
输出前会自动进行以下处理:
|
||||
|
||||
| 处理 | 说明 |
|
||||
|------|------|
|
||||
| HTML 清理 | 移除 script/style/link/svg 标签和 URL 属性 |
|
||||
| 图片移除 | 删除 `` 语法 |
|
||||
| 空行规范化 | 连续多个空行合并为一个 |
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误消息
|
||||
|
||||
```bash
|
||||
# 输入不是 URL 也不是 HTML 文件
|
||||
$ python parser.py invalid.txt
|
||||
错误: 不是有效的 HTML 文件: invalid.txt
|
||||
|
||||
# 文件不存在
|
||||
$ python parser.py missing.html
|
||||
错误: 文件不存在: missing.html
|
||||
|
||||
# 所有下载器失败(URL 示例)
|
||||
$ python parser.py https://example.com
|
||||
所有下载方法均失败:
|
||||
- pyppeteer: pyppeteer 库未安装
|
||||
- selenium: selenium 库未安装
|
||||
- httpx: httpx 库未安装
|
||||
- urllib: HTTP 404
|
||||
|
||||
# 所有解析器失败
|
||||
$ python parser.py page.html
|
||||
所有解析方法均失败:
|
||||
- trafilatura: trafilatura 库未安装
|
||||
- domscribe: domscribe 库未安装
|
||||
- MarkItDown: MarkItDown 库未安装
|
||||
- html2text: html2text 库未安装
|
||||
|
||||
# 标题未找到
|
||||
$ python parser.py https://example.com -tc "不存在的标题"
|
||||
错误: 未找到标题 '不存在的标题'
|
||||
|
||||
# 无效正则或无匹配
|
||||
$ python parser.py https://example.com -s "[invalid"
|
||||
错误: 正则表达式无效或未找到匹配: '[invalid'
|
||||
```
|
||||
|
||||
### 解析器回退机制
|
||||
|
||||
脚本按优先级依次尝试各下载器和解析器。每个组件失败后记录原因(库未安装 / 下载失败 / 解析失败 / 文档为空),然后自动尝试下一个。全部失败时输出汇总信息并以退出码 1 退出。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么有些内容没有提取到?
|
||||
|
||||
不同解析器输出详细度不同。建议安装所有解析器依赖,脚本会自动选择优先级最高的可用解析器。
|
||||
|
||||
### 如何只使用特定下载器/解析器?
|
||||
|
||||
当前版本不支持指定,总是按优先级自动选择。可以通过只安装目标组件的依赖来间接控制——未安装的组件会被跳过。
|
||||
|
||||
### URL 下载慢或被反爬?
|
||||
|
||||
pyppeteer 和 selenium 支持 JS 渲染但较慢。如果目标网页不需要 JS 渲染,可以只安装 httpx 或 urllib,让脚本回退到这些轻量下载器。
|
||||
|
||||
### 中文显示乱码?
|
||||
|
||||
脚本输出 UTF-8 编码,确保终端支持:
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
# Windows PowerShell
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── common.py # 公共函数(HTML 清理、Markdown 处理)
|
||||
├── downloader.py # URL 下载模块
|
||||
├── html_parser.py # HTML 解析模块
|
||||
├── parser.py # 命令行入口
|
||||
└── README.md # 本文档
|
||||
```
|
||||
@@ -1,225 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML 解析器的公共模块,包含 HTML 清理、Markdown 处理等工具函数。"""
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]+\)")
|
||||
_CONSECUTIVE_BLANK_LINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def clean_html_content(html_content: str) -> str:
|
||||
"""清理 HTML 内容,移除 script/style/link/svg 标签和 URL 属性。"""
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
# Remove all script tags
|
||||
for script in soup.find_all("script"):
|
||||
script.decompose()
|
||||
|
||||
# Remove all style tags
|
||||
for style in soup.find_all("style"):
|
||||
style.decompose()
|
||||
|
||||
# Remove all svg tags
|
||||
for svg in soup.find_all("svg"):
|
||||
svg.decompose()
|
||||
|
||||
# Remove all link tags
|
||||
for link in soup.find_all("link"):
|
||||
link.decompose()
|
||||
|
||||
# Remove URLs from href and src attributes
|
||||
for tag in soup.find_all(True):
|
||||
if "href" in tag.attrs:
|
||||
del tag["href"]
|
||||
if "src" in tag.attrs:
|
||||
del tag["src"]
|
||||
if "srcset" in tag.attrs:
|
||||
del tag["srcset"]
|
||||
if "action" in tag.attrs:
|
||||
del tag["action"]
|
||||
data_attrs = [
|
||||
attr
|
||||
for attr in tag.attrs
|
||||
if attr.startswith("data-") and "src" in attr.lower()
|
||||
]
|
||||
for attr in data_attrs:
|
||||
del tag[attr]
|
||||
|
||||
# Remove all style attributes from all tags
|
||||
for tag in soup.find_all(True):
|
||||
if "style" in tag.attrs:
|
||||
del tag["style"]
|
||||
|
||||
# Remove data-href attributes
|
||||
for tag in soup.find_all(True):
|
||||
if "data-href" in tag.attrs:
|
||||
del tag["data-href"]
|
||||
|
||||
# Remove URLs from title attributes
|
||||
for tag in soup.find_all(True):
|
||||
if "title" in tag.attrs:
|
||||
title = tag["title"]
|
||||
cleaned_title = re.sub(r"https?://\S+", "", title, flags=re.IGNORECASE)
|
||||
tag["title"] = cleaned_title
|
||||
|
||||
# Remove class attributes that contain URL-like patterns
|
||||
for tag in soup.find_all(True):
|
||||
if "class" in tag.attrs:
|
||||
classes = tag["class"]
|
||||
cleaned_classes = [c for c in classes if not c.startswith("url ") and not "hyperlink-href:" in c]
|
||||
tag["class"] = cleaned_classes
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def remove_markdown_images(markdown_text: str) -> str:
|
||||
"""移除 Markdown 文本中的图片标记。"""
|
||||
return IMAGE_PATTERN.sub("", markdown_text)
|
||||
|
||||
|
||||
def normalize_markdown_whitespace(content: str) -> str:
|
||||
"""规范化 Markdown 空白字符,保留单行空行。"""
|
||||
return _CONSECUTIVE_BLANK_LINES.sub("\n\n", content)
|
||||
|
||||
|
||||
def get_heading_level(line: str) -> int:
|
||||
"""获取 Markdown 行的标题级别(1-6),非标题返回 0。"""
|
||||
stripped = line.lstrip()
|
||||
if not stripped.startswith("#"):
|
||||
return 0
|
||||
without_hash = stripped.lstrip("#")
|
||||
level = len(stripped) - len(without_hash)
|
||||
if not (1 <= level <= 6):
|
||||
return 0
|
||||
if len(stripped) == level:
|
||||
return level
|
||||
if stripped[level] != " ":
|
||||
return 0
|
||||
return level
|
||||
|
||||
|
||||
def extract_titles(markdown_text: str) -> List[str]:
|
||||
"""提取 markdown 文本中的所有标题行(1-6级)。"""
|
||||
title_lines = []
|
||||
for line in markdown_text.split("\n"):
|
||||
if get_heading_level(line) > 0:
|
||||
title_lines.append(line.lstrip())
|
||||
return title_lines
|
||||
|
||||
|
||||
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 match_num, idx in enumerate(match_indices):
|
||||
if match_num > 0:
|
||||
result_lines.append("\n---\n")
|
||||
|
||||
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:
|
||||
context_start_idx = max(0, start - context_lines)
|
||||
context_end_idx = min(len(non_empty_indices) - 1, end + context_lines)
|
||||
|
||||
start_line_idx = non_empty_indices[context_start_idx]
|
||||
end_line_idx = non_empty_indices[context_end_idx]
|
||||
|
||||
result_lines = [
|
||||
line
|
||||
for i, line in enumerate(lines)
|
||||
if start_line_idx <= i <= end_line_idx
|
||||
]
|
||||
results.append("\n".join(result_lines))
|
||||
|
||||
return "\n---\n".join(results)
|
||||
|
||||
|
||||
def is_url(input_str: str) -> bool:
|
||||
"""判断输入是否为 URL。"""
|
||||
return input_str.startswith("http://") or input_str.startswith("https://")
|
||||
|
||||
|
||||
def is_html_file(file_path: str) -> bool:
|
||||
"""判断文件是否为 HTML 文件(仅检查扩展名)。"""
|
||||
ext = file_path.lower()
|
||||
return ext.endswith(".html") or ext.endswith(".htm")
|
||||
@@ -1,263 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""URL 下载模块,按 pyppeteer → selenium → httpx → urllib 优先级尝试下载。"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
# 公共配置
|
||||
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
WINDOW_SIZE = "1920,1080"
|
||||
LANGUAGE_SETTING = "zh-CN,zh"
|
||||
|
||||
# Chrome 浏览器启动参数(pyppeteer 和 selenium 共用)
|
||||
CHROME_ARGS = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-default-apps",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--hide-scrollbars",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--blink-settings=imagesEnabled=false",
|
||||
"--disable-plugins",
|
||||
"--disable-ipc-flooding-protection",
|
||||
"--disable-renderer-backgrounding",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-hang-monitor",
|
||||
"--disable-prompt-on-repost",
|
||||
"--disable-client-side-phishing-detection",
|
||||
"--disable-component-update",
|
||||
"--disable-domain-reliability",
|
||||
"--disable-features=site-per-process",
|
||||
"--disable-features=IsolateOrigins",
|
||||
"--disable-features=VizDisplayCompositor",
|
||||
"--disable-features=WebRTC",
|
||||
f"--window-size={WINDOW_SIZE}",
|
||||
f"--lang={LANGUAGE_SETTING}",
|
||||
f"--user-agent={USER_AGENT}",
|
||||
]
|
||||
|
||||
# 隐藏自动化特征的脚本(pyppeteer 和 selenium 共用)
|
||||
HIDE_AUTOMATION_SCRIPT = """
|
||||
() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
|
||||
}
|
||||
"""
|
||||
|
||||
# pyppeteer 额外的隐藏自动化脚本(包含 notifications 处理)
|
||||
HIDE_AUTOMATION_SCRIPT_PUPPETEER = """
|
||||
() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh'] });
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications' ?
|
||||
Promise.resolve({ state: Notification.permission }) :
|
||||
originalQuery(parameters)
|
||||
);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def download_with_pyppeteer(url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 pyppeteer 下载 URL(支持 JS 渲染)。"""
|
||||
try:
|
||||
from pyppeteer import launch
|
||||
except ImportError:
|
||||
return None, "pyppeteer 库未安装"
|
||||
|
||||
async def _download():
|
||||
pyppeteer_temp_dir = os.path.join(tempfile.gettempdir(), "pyppeteer_home")
|
||||
chromium_path = os.environ.get("LYXY_CHROMIUM_BINARY")
|
||||
if not chromium_path:
|
||||
os.environ["PYPPETEER_HOME"] = pyppeteer_temp_dir
|
||||
executable_path = chromium_path if (chromium_path and os.path.exists(chromium_path)) else None
|
||||
|
||||
browser = None
|
||||
try:
|
||||
browser = await launch(
|
||||
headless=True,
|
||||
executablePath=executable_path,
|
||||
args=CHROME_ARGS
|
||||
)
|
||||
page = await browser.newPage()
|
||||
|
||||
await page.evaluateOnNewDocument(HIDE_AUTOMATION_SCRIPT_PUPPETEER)
|
||||
|
||||
await page.setJavaScriptEnabled(True)
|
||||
await page.goto(url, {"waitUntil": "networkidle2", "timeout": 30000})
|
||||
return await page.content()
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
content = asyncio.run(_download())
|
||||
if not content or not content.strip():
|
||||
return None, "下载内容为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"pyppeteer 下载失败: {str(e)}"
|
||||
|
||||
|
||||
def download_with_selenium(url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 selenium 下载 URL(支持 JS 渲染)。"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
except ImportError:
|
||||
return None, "selenium 库未安装"
|
||||
|
||||
driver_path = os.environ.get("LYXY_CHROMIUM_DRIVER")
|
||||
binary_path = os.environ.get("LYXY_CHROMIUM_BINARY")
|
||||
|
||||
if not driver_path or not os.path.exists(driver_path):
|
||||
return None, "LYXY_CHROMIUM_DRIVER 环境变量未设置或文件不存在"
|
||||
if not binary_path or not os.path.exists(binary_path):
|
||||
return None, "LYXY_CHROMIUM_BINARY 环境变量未设置或文件不存在"
|
||||
|
||||
chrome_options = Options()
|
||||
chrome_options.binary_location = binary_path
|
||||
chrome_options.add_argument("--headless=new")
|
||||
for arg in CHROME_ARGS:
|
||||
chrome_options.add_argument(arg)
|
||||
|
||||
# 隐藏自动化特征
|
||||
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
chrome_options.add_experimental_option("useAutomationExtension", False)
|
||||
|
||||
driver = None
|
||||
try:
|
||||
import time
|
||||
service = Service(driver_path)
|
||||
driver = webdriver.Chrome(service=service, options=chrome_options)
|
||||
|
||||
# 隐藏 webdriver 属性
|
||||
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
|
||||
"source": HIDE_AUTOMATION_SCRIPT
|
||||
})
|
||||
|
||||
driver.get(url)
|
||||
|
||||
# 等待页面内容稳定
|
||||
WebDriverWait(driver, 30).until(
|
||||
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||
)
|
||||
|
||||
last_len = 0
|
||||
stable_count = 0
|
||||
for _ in range(30):
|
||||
current_len = len(driver.page_source)
|
||||
if current_len == last_len:
|
||||
stable_count += 1
|
||||
if stable_count >= 2:
|
||||
break
|
||||
else:
|
||||
stable_count = 0
|
||||
last_len = current_len
|
||||
time.sleep(0.5)
|
||||
|
||||
content = driver.page_source
|
||||
if not content or not content.strip():
|
||||
return None, "下载内容为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"selenium 下载失败: {str(e)}"
|
||||
finally:
|
||||
if driver is not None:
|
||||
try:
|
||||
driver.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def download_with_httpx(url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 httpx 下载 URL(轻量级 HTTP 客户端)。"""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return None, "httpx 库未安装"
|
||||
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
content = response.text
|
||||
if not content or not content.strip():
|
||||
return None, "下载内容为空"
|
||||
return content, None
|
||||
return None, f"HTTP {response.status_code}"
|
||||
except Exception as e:
|
||||
return None, f"httpx 下载失败: {str(e)}"
|
||||
|
||||
|
||||
def download_with_urllib(url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 urllib 下载 URL(标准库,兜底方案)。"""
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT
|
||||
}
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
if response.status == 200:
|
||||
content = response.read().decode("utf-8")
|
||||
if not content or not content.strip():
|
||||
return None, "下载内容为空"
|
||||
return content, None
|
||||
return None, f"HTTP {response.status}"
|
||||
except Exception as e:
|
||||
return None, f"urllib 下载失败: {str(e)}"
|
||||
|
||||
|
||||
def download_html(url: str) -> Tuple[Optional[str], List[str]]:
|
||||
"""
|
||||
统一的 HTML 下载入口函数,按优先级尝试各下载器。
|
||||
|
||||
返回: (content, failures)
|
||||
- content: 成功时返回 HTML 内容,失败时返回 None
|
||||
- failures: 各下载器的失败原因列表
|
||||
"""
|
||||
failures = []
|
||||
content = None
|
||||
|
||||
# 按优先级尝试各下载器
|
||||
downloaders = [
|
||||
("pyppeteer", download_with_pyppeteer),
|
||||
("selenium", download_with_selenium),
|
||||
("httpx", download_with_httpx),
|
||||
("urllib", download_with_urllib),
|
||||
]
|
||||
|
||||
for name, func in downloaders:
|
||||
content, error = func(url)
|
||||
if content is not None:
|
||||
return content, failures
|
||||
else:
|
||||
failures.append(f"- {name}: {error}")
|
||||
|
||||
return None, failures
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML 解析模块,按 trafilatura → domscribe → MarkItDown → html2text 优先级尝试解析。"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def parse_with_trafilatura(html_content: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 trafilatura 解析 HTML。"""
|
||||
try:
|
||||
import trafilatura
|
||||
except ImportError:
|
||||
return None, "trafilatura 库未安装"
|
||||
|
||||
try:
|
||||
markdown_content = trafilatura.extract(
|
||||
html_content,
|
||||
output_format="markdown",
|
||||
include_formatting=True,
|
||||
include_links=True,
|
||||
include_images=False,
|
||||
include_tables=True,
|
||||
favor_recall=True,
|
||||
include_comments=True,
|
||||
)
|
||||
if markdown_content is None:
|
||||
return None, "trafilatura 返回 None"
|
||||
if not markdown_content.strip():
|
||||
return None, "解析内容为空"
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"trafilatura 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_with_domscribe(html_content: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 domscribe 解析 HTML。"""
|
||||
try:
|
||||
from domscribe import html_to_markdown
|
||||
except ImportError:
|
||||
return None, "domscribe 库未安装"
|
||||
|
||||
try:
|
||||
options = {
|
||||
'extract_main_content': True,
|
||||
}
|
||||
markdown_content = html_to_markdown(html_content, options)
|
||||
if not markdown_content.strip():
|
||||
return None, "解析内容为空"
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"domscribe 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_with_markitdown(html_content: str, temp_file_path: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 解析 HTML。"""
|
||||
try:
|
||||
from markitdown import MarkItDown
|
||||
except ImportError:
|
||||
return None, "MarkItDown 库未安装"
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
input_path = temp_file_path
|
||||
if not input_path or not os.path.exists(input_path):
|
||||
# 创建临时文件
|
||||
fd, input_path = tempfile.mkstemp(suffix='.html')
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
md = MarkItDown()
|
||||
result = md.convert(
|
||||
input_path,
|
||||
heading_style="ATX",
|
||||
strip=["img", "script", "style", "noscript"],
|
||||
)
|
||||
markdown_content = result.text_content
|
||||
|
||||
if not temp_file_path:
|
||||
try:
|
||||
os.unlink(input_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not markdown_content.strip():
|
||||
return None, "解析内容为空"
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"MarkItDown 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_with_html2text(html_content: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 html2text 解析 HTML(兜底方案)。"""
|
||||
try:
|
||||
import html2text
|
||||
except ImportError:
|
||||
return None, "html2text 库未安装"
|
||||
|
||||
try:
|
||||
converter = html2text.HTML2Text()
|
||||
converter.ignore_emphasis = False
|
||||
converter.ignore_links = False
|
||||
converter.ignore_images = True
|
||||
converter.body_width = 0
|
||||
converter.skip_internal_links = True
|
||||
markdown_content = converter.handle(html_content)
|
||||
if not markdown_content.strip():
|
||||
return None, "解析内容为空"
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"html2text 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_html(html_content: str, temp_file_path: Optional[str] = None) -> Tuple[Optional[str], List[str]]:
|
||||
"""
|
||||
统一的 HTML 解析入口函数,按优先级尝试各解析器。
|
||||
|
||||
返回: (content, failures)
|
||||
- content: 成功时返回 Markdown 内容,失败时返回 None
|
||||
- failures: 各解析器的失败原因列表
|
||||
"""
|
||||
failures = []
|
||||
content = None
|
||||
|
||||
# 按优先级尝试各解析器
|
||||
parsers = [
|
||||
("trafilatura", lambda c: parse_with_trafilatura(c)),
|
||||
("domscribe", lambda c: parse_with_domscribe(c)),
|
||||
("MarkItDown", lambda c: parse_with_markitdown(c, temp_file_path)),
|
||||
("html2text", lambda c: parse_with_html2text(c)),
|
||||
]
|
||||
|
||||
for name, func in parsers:
|
||||
content, error = func(html_content)
|
||||
if content is not None:
|
||||
return content, failures
|
||||
else:
|
||||
failures.append(f"- {name}: {error}")
|
||||
|
||||
return None, failures
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML 解析器命令行交互模块,提供命令行接口。支持 URL 和 HTML 文件。"""
|
||||
|
||||
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 downloader
|
||||
import html_parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将 URL 或 HTML 文件解析为 Markdown"
|
||||
)
|
||||
|
||||
parser.add_argument("input", help="URL 或 HTML 文件的路径")
|
||||
|
||||
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()
|
||||
|
||||
# 判断输入类型
|
||||
html_content = None
|
||||
temp_file_path = None
|
||||
|
||||
if common.is_url(args.input):
|
||||
# URL 模式
|
||||
html_content, download_failures = downloader.download_html(args.input)
|
||||
if html_content is None:
|
||||
print("所有下载方法均失败:")
|
||||
for failure in download_failures:
|
||||
print(failure)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# HTML 文件模式
|
||||
if not os.path.exists(args.input):
|
||||
print(f"错误: 文件不存在: {args.input}")
|
||||
sys.exit(1)
|
||||
if not common.is_html_file(args.input):
|
||||
print(f"错误: 不是有效的 HTML 文件: {args.input}")
|
||||
sys.exit(1)
|
||||
with open(args.input, "r", encoding="utf-8") as f:
|
||||
html_content = f.read()
|
||||
temp_file_path = args.input
|
||||
|
||||
# HTML 预处理清理
|
||||
cleaned_html = common.clean_html_content(html_content)
|
||||
|
||||
# 解析 HTML
|
||||
markdown_content, parse_failures = html_parser.parse_html(cleaned_html, temp_file_path)
|
||||
if markdown_content is None:
|
||||
print("所有解析方法均失败:")
|
||||
for failure in parse_failures:
|
||||
print(failure)
|
||||
sys.exit(1)
|
||||
|
||||
# Markdown 后处理
|
||||
markdown_content = common.remove_markdown_images(markdown_content)
|
||||
markdown_content = common.normalize_markdown_whitespace(markdown_content)
|
||||
|
||||
# 根据参数输出
|
||||
if args.count:
|
||||
print(len(markdown_content.replace("\n", "")))
|
||||
elif args.lines:
|
||||
print(len(markdown_content.split("\n")))
|
||||
elif args.titles:
|
||||
titles = common.extract_titles(markdown_content)
|
||||
for title in titles:
|
||||
print(title)
|
||||
elif args.title_content:
|
||||
title_content = common.extract_title_content(markdown_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 = common.search_markdown(markdown_content, args.search, args.context)
|
||||
if search_result is None:
|
||||
print(f"错误: 正则表达式无效或未找到匹配: '{args.search}'")
|
||||
sys.exit(1)
|
||||
print(search_result, end="")
|
||||
else:
|
||||
print(markdown_content, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
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 执行。
|
||||
|
||||
## When to Use
|
||||
|
||||
任何需要读取或解析 .docx、.xlsx、.pptx、.pdf 文件内容的任务都应使用此 skill。
|
||||
|
||||
### 典型场景
|
||||
- **文档内容提取**:将 Word/PPT/Excel/PDF 文档转换为可读的 Markdown 文本
|
||||
- **文档元数据**:获取文档的字数、行数等信息
|
||||
- **标题分析**:提取文档的标题结构
|
||||
- **章节提取**:提取特定章节的内容
|
||||
- **内容搜索**:在文档中搜索关键词或模式
|
||||
- **PDF OCR**:对扫描版 PDF 启用 OCR 高精度解析
|
||||
|
||||
### 触发词
|
||||
- 中文:"读取/解析/打开 docx/word/xlsx/excel/pptx/pdf 文档"
|
||||
- 英文:"read/parse/extract docx/word/xlsx/excel/pptx/powerpoint/pdf"
|
||||
- 文件扩展名:`.docx`、`.xlsx`、`.pptx`、`.pdf`
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| (无参数) | 输出完整 Markdown 内容 |
|
||||
| `-c` | 字数统计 |
|
||||
| `-l` | 行数统计 |
|
||||
| `-t` | 提取所有标题 |
|
||||
| `-tc <name>` | 提取指定标题的章节内容 |
|
||||
| `-s <pattern>` | 正则表达式搜索 |
|
||||
| `-n <num>` | 与 `-s` 配合,指定上下文行数 |
|
||||
| `--high-res` | PDF 专用,启用 OCR 版面分析 |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **检查依赖**:优先使用 lyxy-runner-python,否则降级到直接 Python 执行
|
||||
2. **选择格式**:根据文件扩展名自动识别格式
|
||||
3. **执行解析**:调用 `scripts/parser.py` 并传入参数
|
||||
4. **输出结果**:返回 Markdown 格式内容或统计信息
|
||||
|
||||
### 基本语法
|
||||
|
||||
```bash
|
||||
# 使用 lyxy-runner-python(推荐)
|
||||
uv run --with "markitdown[docx]" scripts/parser.py /path/to/file.docx
|
||||
|
||||
# 降级到直接执行
|
||||
python3 scripts/parser.py /path/to/file.docx
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
详细文档请参阅 `references/` 目录:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `references/examples.md` | 各格式完整提取、字数统计、标题提取、章节提取、搜索等示例 |
|
||||
| `references/parsers.md` | 解析器说明、依赖安装、各格式输出特点、能力说明 |
|
||||
| `references/error-handling.md` | 限制说明、最佳实践、依赖执行策略 |
|
||||
|
||||
> **详细用法**:请阅读 `scripts/README.md` 获取完整的命令行参数和依赖安装指南。
|
||||
@@ -1,41 +0,0 @@
|
||||
# 错误处理和限制说明
|
||||
|
||||
## 限制
|
||||
|
||||
- 不支持图片提取(仅纯文本)
|
||||
- 不支持复杂的格式保留(字体、颜色、布局等)
|
||||
- 不支持文档编辑或修改
|
||||
- 仅支持 .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
|
||||
|
||||
## 依赖执行策略
|
||||
|
||||
### 必须使用 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**,仅向用户提示安装建议
|
||||
|
||||
## 不适用场景
|
||||
|
||||
- 需要提取图片内容(仅支持纯文本)
|
||||
- 需要保留复杂的格式信息(字体、颜色、布局)
|
||||
- 需要编辑或修改文档
|
||||
- 需要处理 .doc、.xls、.ppt 等旧格式
|
||||
@@ -1,55 +0,0 @@
|
||||
# 示例
|
||||
|
||||
## 提取完整文档内容
|
||||
|
||||
```bash
|
||||
# DOCX
|
||||
uv run --with "markitdown[docx]" scripts/parser.py /path/to/report.docx
|
||||
|
||||
# PPTX
|
||||
uv run --with "markitdown[pptx]" scripts/parser.py /path/to/slides.pptx
|
||||
|
||||
# XLSX
|
||||
uv run --with "markitdown[xlsx]" scripts/parser.py /path/to/data.xlsx
|
||||
|
||||
# PDF
|
||||
uv run --with "markitdown[pdf]" --with pypdf scripts/parser.py /path/to/doc.pdf
|
||||
```
|
||||
|
||||
## 获取文档字数
|
||||
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" scripts/parser.py -c /path/to/report.docx
|
||||
```
|
||||
|
||||
## 提取所有标题
|
||||
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" scripts/parser.py -t /path/to/report.docx
|
||||
```
|
||||
|
||||
## 提取指定章节
|
||||
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" scripts/parser.py -tc "第一章" /path/to/report.docx
|
||||
```
|
||||
|
||||
## 搜索关键词
|
||||
|
||||
```bash
|
||||
uv run --with "markitdown[docx]" scripts/parser.py -s "关键词" -n 3 /path/to/report.docx
|
||||
```
|
||||
|
||||
## PDF OCR 高精度解析
|
||||
|
||||
```bash
|
||||
uv run --with docling --with pypdf scripts/parser.py /path/to/scanned.pdf --high-res
|
||||
```
|
||||
|
||||
## 降级到直接 Python 执行
|
||||
|
||||
仅当 lyxy-runner-python skill 不存在时使用:
|
||||
|
||||
```bash
|
||||
python3 scripts/parser.py /path/to/file.docx
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
# 解析器说明和依赖安装
|
||||
|
||||
## 多策略解析降级
|
||||
|
||||
每种文件格式配备多个解析器,按优先级依次尝试,前一个失败自动回退到下一个。
|
||||
|
||||
详细的解析器优先级和对比请查阅 `scripts/README.md`。
|
||||
|
||||
## 依赖安装
|
||||
|
||||
### 使用 uv(推荐)
|
||||
|
||||
```bash
|
||||
# DOCX - 全依赖
|
||||
uv run --with docling --with "unstructured[docx]" --with markdownify --with pypandoc-binary --with "markitdown[docx]" --with python-docx scripts/parser.py /path/to/file.docx
|
||||
|
||||
# PPTX - 全依赖
|
||||
uv run --with docling --with "unstructured[pptx]" --with markdownify --with "markitdown[pptx]" --with python-pptx scripts/parser.py /path/to/file.pptx
|
||||
|
||||
# XLSX - 全依赖
|
||||
uv run --with docling --with "unstructured[xlsx]" --with markdownify --with "markitdown[xlsx]" --with pandas --with tabulate scripts/parser.py /path/to/file.xlsx
|
||||
|
||||
# PDF - 全依赖(基础文本提取)
|
||||
uv run --with docling --with "unstructured[pdf]" --with markdownify --with "markitdown[pdf]" --with pypdf scripts/parser.py /path/to/file.pdf
|
||||
|
||||
# PDF 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 scripts/parser.py /path/to/file.pdf --high-res
|
||||
```
|
||||
|
||||
> **说明**:以上为全依赖安装命令,包含所有解析器以获得最佳兼容性。详细的解析器优先级和对比请查阅 `scripts/README.md`。
|
||||
|
||||
## 各格式输出特点
|
||||
|
||||
- **DOCX**:标准 Markdown 文档结构
|
||||
- **PPTX**:每张幻灯片以 `## Slide N` 为标题,幻灯片之间以 `---` 分隔
|
||||
- **XLSX**:以 `## SheetName` 区分工作表,数据以 Markdown 表格呈现
|
||||
- **PDF**:纯文本流,使用 `--high-res` 可启用 OCR 版面分析识别标题
|
||||
|
||||
## 能力说明
|
||||
|
||||
### 1. 全文转换为 Markdown
|
||||
将完整文档解析为 Markdown 格式,移除图片但保留文本格式(标题、列表、表格、粗体、斜体等)。
|
||||
|
||||
### 2. 获取文档元信息
|
||||
- 字数统计(`-c` 参数)
|
||||
- 行数统计(`-l` 参数)
|
||||
|
||||
### 3. 标题列表提取
|
||||
提取文档中所有 1-6 级标题(`-t` 参数),按原始层级关系返回。
|
||||
|
||||
### 4. 指定章节内容提取
|
||||
根据标题名称提取特定章节的完整内容(`-tc` 参数),包含上级标题链和所有下级内容。
|
||||
|
||||
### 5. 正则表达式搜索
|
||||
在文档中搜索关键词或模式(`-s` 参数),支持自定义上下文行数(`-n` 参数,默认 2 行)。
|
||||
|
||||
### 6. PDF OCR 高精度模式
|
||||
对 PDF 文件启用 OCR 版面分析(`--high-res` 参数),适用于扫描版 PDF 或需要识别标题层级的场景。
|
||||
@@ -1,503 +0,0 @@
|
||||
# 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
|
||||
|
||||
> **平台差异说明**:`--high-res` 模式在不同平台上需要不同的依赖配置,详见下方各平台说明。
|
||||
|
||||
#### Windows x86_64
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
> **Windows 说明**:hi_res 策略需要额外安装 `unstructured-paddleocr`、`paddlepaddle==2.6.2`、`ml-dtypes`。PaddlePaddle 必须锁定 2.x 版本,3.x 在 Windows 上有 OneDNN 兼容问题。
|
||||
|
||||
#### macOS x86_64 (Intel)
|
||||
|
||||
macOS x86_64 上 `docling-parse` 5.x 没有预编译 wheel,需要使用 4.0.0 版本;同时 `easyocr` 与 NumPy 2.x 不兼容,需要锁定 `numpy<2`。
|
||||
|
||||
```bash
|
||||
# uv - 基础文本提取
|
||||
uv run --python 3.12 --with "docling==2.40.0" --with "docling-parse==4.0.0" --with "numpy<2" --with "markitdown[pdf]" --with pypdf parser.py report.pdf
|
||||
|
||||
# uv - OCR 版面分析(--high-res)
|
||||
uv run --python 3.12 --with "docling==2.40.0" --with "docling-parse==4.0.0" --with "numpy<2" --with "markitdown[pdf]" --with pypdf parser.py report.pdf --high-res
|
||||
```
|
||||
|
||||
> **macOS x86_64 说明**:
|
||||
> - 必须指定 `--python 3.12`(PaddlePaddle 不支持 Python 3.14)
|
||||
> - `docling==2.40.0` 兼容 `docling-parse==4.0.0`(唯一有 x86_64 预编译 wheel 的版本)
|
||||
> - `numpy<2` 避免 easyocr 与 NumPy 2.x 的兼容性问题
|
||||
> - Docling 在 macOS 上使用 EasyOCR 作为 OCR 后端,无需安装 PaddleOCR
|
||||
|
||||
#### macOS arm64 (Apple Silicon) / Linux
|
||||
|
||||
```bash
|
||||
# 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 markdownify --with "markitdown[pdf]" --with pypdf parser.py report.pdf --high-res
|
||||
```
|
||||
|
||||
> **通用说明**:PDF 无内置 XML 原生解析,至少需要安装 pypdf。默认模式下 Docling 不启用 OCR,unstructured 使用 fast 策略。指定 `--high-res` 后,Docling 启用 OCR。
|
||||
|
||||
### 安装所有依赖
|
||||
|
||||
#### Windows x86_64
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### macOS x86_64 (Intel)
|
||||
|
||||
```bash
|
||||
# uv - 完整版(包含 PDF OCR)
|
||||
uv run --python 3.12 --with "docling==2.40.0" --with "docling-parse==4.0.0" --with "numpy<2" --with "markitdown[docx,pptx,xlsx,pdf]" --with pypandoc-binary --with python-docx --with python-pptx --with pandas --with tabulate --with pypdf parser.py file.docx
|
||||
```
|
||||
|
||||
> macOS x86_64 上由于依赖兼容性问题,无法使用 unstructured,但 docling 可以处理所有格式。
|
||||
|
||||
#### macOS arm64 (Apple Silicon) / Linux
|
||||
|
||||
```bash
|
||||
# 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 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.
@@ -1,337 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""文档解析器的公共模块,包含所有格式共享的工具函数和验证函数。"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
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,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析文件"""
|
||||
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_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析文件"""
|
||||
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 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def build_markdown_table(rows_data: List[List[str]]) -> str:
|
||||
"""将二维列表转换为 Markdown 表格格式"""
|
||||
if not rows_data or not rows_data[0]:
|
||||
return ""
|
||||
|
||||
md_lines = []
|
||||
for i, row_data in enumerate(rows_data):
|
||||
row_text = [cell if cell else "" for cell in row_data]
|
||||
md_lines.append("| " + " | ".join(row_text) + " |")
|
||||
if i == 0:
|
||||
md_lines.append("| " + " | ".join(["---"] * len(row_text)) + " |")
|
||||
return "\n".join(md_lines) + "\n\n"
|
||||
|
||||
|
||||
def flush_list_stack(list_stack: List[str], target: List[str]) -> None:
|
||||
"""将列表堆栈中的非空项添加到目标列表并清空堆栈"""
|
||||
for item in list_stack:
|
||||
if item:
|
||||
target.append(item + "\n")
|
||||
list_stack.clear()
|
||||
|
||||
|
||||
def safe_open_zip(zip_file: zipfile.ZipFile, name: str) -> Optional[zipfile.ZipExtFile]:
|
||||
"""安全地从 ZipFile 中打开文件,防止路径遍历攻击"""
|
||||
if not name:
|
||||
return None
|
||||
if name.startswith("/") or name.startswith(".."):
|
||||
return None
|
||||
if "/../" in name or name.endswith("/.."):
|
||||
return None
|
||||
if "\\" in name:
|
||||
return None
|
||||
return zip_file.open(name)
|
||||
|
||||
|
||||
_CONSECUTIVE_BLANK_LINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def normalize_markdown_whitespace(content: str) -> str:
|
||||
"""规范化 Markdown 空白字符,保留单行空行"""
|
||||
return _CONSECUTIVE_BLANK_LINES.sub("\n\n", content)
|
||||
|
||||
|
||||
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 格式"""
|
||||
return _is_valid_ooxml(file_path, _DOCX_REQUIRED)
|
||||
|
||||
|
||||
def is_valid_pptx(file_path: str) -> bool:
|
||||
"""验证文件是否为有效的 PPTX 格式"""
|
||||
return _is_valid_ooxml(file_path, _PPTX_REQUIRED)
|
||||
|
||||
|
||||
def is_valid_xlsx(file_path: str) -> bool:
|
||||
"""验证文件是否为有效的 XLSX 格式"""
|
||||
return _is_valid_ooxml(file_path, _XLSX_REQUIRED)
|
||||
|
||||
|
||||
def is_valid_pdf(file_path: str) -> bool:
|
||||
"""验证文件是否为有效的 PDF 格式"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header = f.read(4)
|
||||
return header == b"%PDF"
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def remove_markdown_images(markdown_text: str) -> str:
|
||||
"""移除 Markdown 文本中的图片标记"""
|
||||
return IMAGE_PATTERN.sub("", markdown_text)
|
||||
|
||||
|
||||
def get_heading_level(line: str) -> int:
|
||||
"""获取 Markdown 行的标题级别(1-6),非标题返回 0"""
|
||||
stripped = line.lstrip()
|
||||
if not stripped.startswith("#"):
|
||||
return 0
|
||||
without_hash = stripped.lstrip("#")
|
||||
level = len(stripped) - len(without_hash)
|
||||
if not (1 <= level <= 6):
|
||||
return 0
|
||||
if len(stripped) == level:
|
||||
return level
|
||||
if stripped[level] != " ":
|
||||
return 0
|
||||
return level
|
||||
|
||||
|
||||
def extract_titles(markdown_text: str) -> List[str]:
|
||||
"""提取 markdown 文本中的所有标题行(1-6级)"""
|
||||
title_lines = []
|
||||
for line in markdown_text.split("\n"):
|
||||
if get_heading_level(line) > 0:
|
||||
title_lines.append(line.lstrip())
|
||||
return title_lines
|
||||
|
||||
|
||||
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 match_num, idx in enumerate(match_indices):
|
||||
if match_num > 0:
|
||||
result_lines.append("\n---\n")
|
||||
|
||||
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:
|
||||
context_start_idx = max(0, start - context_lines)
|
||||
context_end_idx = min(len(non_empty_indices) - 1, end + context_lines)
|
||||
|
||||
start_line_idx = non_empty_indices[context_start_idx]
|
||||
end_line_idx = non_empty_indices[context_end_idx]
|
||||
|
||||
result_lines = [
|
||||
line
|
||||
for i, line in enumerate(lines)
|
||||
if start_line_idx <= i <= end_line_idx
|
||||
]
|
||||
results.append("\n".join(result_lines))
|
||||
|
||||
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)[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)
|
||||
@@ -1,308 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DOCX 文件解析模块,提供多种解析方法。"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
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,
|
||||
safe_open_zip,
|
||||
)
|
||||
|
||||
|
||||
def parse_docx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析 DOCX 文件"""
|
||||
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:
|
||||
import pypandoc
|
||||
except ImportError:
|
||||
return None, "pypandoc-binary 库未安装"
|
||||
|
||||
try:
|
||||
content = pypandoc.convert_file(
|
||||
source_file=file_path,
|
||||
to="md",
|
||||
format="docx",
|
||||
outputfile=None,
|
||||
extra_args=["--wrap=none"],
|
||||
)
|
||||
except OSError as exc:
|
||||
return None, f"pypandoc-binary 缺少 Pandoc 可执行文件: {exc}"
|
||||
except RuntimeError as exc:
|
||||
return None, f"pypandoc-binary 解析失败: {exc}"
|
||||
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
|
||||
|
||||
def parse_docx_with_markitdown(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析 DOCX 文件"""
|
||||
return parse_with_markitdown(file_path)
|
||||
|
||||
|
||||
def parse_docx_with_python_docx(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 python-docx 库解析 DOCX 文件"""
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
return None, "python-docx 库未安装"
|
||||
|
||||
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:
|
||||
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 in _LIST_STYLES:
|
||||
return _LIST_STYLES[style_name]
|
||||
if style_name.startswith("List Bullet"):
|
||||
return "bullet"
|
||||
if style_name.startswith("List Number"):
|
||||
return "number"
|
||||
return None
|
||||
|
||||
def convert_runs_to_markdown(runs: List[Any]) -> 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: Any) -> str:
|
||||
rows_data = []
|
||||
for row in table.rows:
|
||||
row_data = []
|
||||
for cell in row.cells:
|
||||
cell_text = cell.text.strip().replace("\n", " ")
|
||||
row_data.append(cell_text)
|
||||
rows_data.append(row_data)
|
||||
return build_markdown_table(rows_data)
|
||||
|
||||
markdown_lines = []
|
||||
prev_was_list = False
|
||||
|
||||
from docx.table import Table as DocxTable
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
for element in doc.element.body:
|
||||
if element.tag.endswith('}p'):
|
||||
para = Paragraph(element, doc)
|
||||
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}")
|
||||
prev_was_list = False
|
||||
else:
|
||||
list_style = get_list_style(para)
|
||||
if list_style == "bullet":
|
||||
if not prev_was_list and markdown_lines:
|
||||
markdown_lines.append("")
|
||||
markdown_lines.append(f"- {text}")
|
||||
prev_was_list = True
|
||||
elif list_style == "number":
|
||||
if not prev_was_list and markdown_lines:
|
||||
markdown_lines.append("")
|
||||
markdown_lines.append(f"1. {text}")
|
||||
prev_was_list = True
|
||||
else:
|
||||
if prev_was_list and markdown_lines:
|
||||
markdown_lines.append("")
|
||||
markdown_lines.append(text)
|
||||
markdown_lines.append("")
|
||||
prev_was_list = False
|
||||
|
||||
elif element.tag.endswith('}tbl'):
|
||||
table = DocxTable(element, doc)
|
||||
table_md = convert_table_to_markdown(table)
|
||||
if table_md:
|
||||
markdown_lines.append(table_md)
|
||||
markdown_lines.append("")
|
||||
prev_was_list = False
|
||||
|
||||
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_docx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 XML 原生解析 DOCX 文件"""
|
||||
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)
|
||||
|
||||
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: Any, 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: Any, namespaces: dict) -> str:
|
||||
rows = table_elem.findall(".//w:tr", namespaces=namespaces)
|
||||
if not rows:
|
||||
return ""
|
||||
rows_data = []
|
||||
for row in 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:
|
||||
rows_data.append(cell_texts)
|
||||
return build_markdown_table(rows_data)
|
||||
|
||||
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).getroot()
|
||||
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:
|
||||
style_name_lower = style_name.lower()
|
||||
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"
|
||||
):
|
||||
style_to_list[style_id] = "bullet"
|
||||
elif (
|
||||
style_name_lower.startswith("list number")
|
||||
or style_name_lower == "number"
|
||||
):
|
||||
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).getroot()
|
||||
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)}"
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""文档解析器命令行交互模块,提供命令行接口。支持 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
|
||||
import pdf_parser
|
||||
import pptx_parser
|
||||
import xlsx_parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将 DOCX、PPTX、XLSX 或 PDF 文件解析为 Markdown"
|
||||
)
|
||||
|
||||
parser.add_argument("file_path", help="DOCX、PPTX、XLSX 或 PDF 文件的绝对路径")
|
||||
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--context",
|
||||
type=int,
|
||||
default=2,
|
||||
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 文档的总字数"
|
||||
)
|
||||
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)
|
||||
|
||||
file_type = common.detect_file_type(args.file_path)
|
||||
if not file_type:
|
||||
print(f"错误: 不是有效的 DOCX、PPTX、XLSX 或 PDF 格式: {args.file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
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),
|
||||
("XML 原生解析", docx_parser.parse_docx_with_xml),
|
||||
]
|
||||
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),
|
||||
]
|
||||
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:
|
||||
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
|
||||
|
||||
for parser_name, parser_func in parsers:
|
||||
content, error = parser_func(args.file_path)
|
||||
if content is not None:
|
||||
content = common.remove_markdown_images(content)
|
||||
content = common.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 = common.extract_titles(content)
|
||||
for title in titles:
|
||||
print(title)
|
||||
elif args.title_content:
|
||||
title_content = common.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 = common.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()
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/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)}"
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PPTX 文件解析模块,提供三种解析方法。"""
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
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,
|
||||
parse_with_markitdown,
|
||||
)
|
||||
|
||||
|
||||
def parse_pptx_with_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析 PPTX 文件"""
|
||||
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)
|
||||
|
||||
|
||||
def extract_formatted_text_pptx(runs: List[Any]) -> str:
|
||||
"""从 PPTX 文本运行中提取带有格式的文本"""
|
||||
result = []
|
||||
for run in runs:
|
||||
if not run.text:
|
||||
continue
|
||||
|
||||
text = run.text
|
||||
|
||||
font = run.font
|
||||
is_bold = getattr(font, "bold", False) or False
|
||||
is_italic = getattr(font, "italic", False) or False
|
||||
|
||||
if is_bold and is_italic:
|
||||
text = f"***{text}***"
|
||||
elif is_bold:
|
||||
text = f"**{text}**"
|
||||
elif is_italic:
|
||||
text = f"*{text}*"
|
||||
|
||||
result.append(text)
|
||||
|
||||
return "".join(result).strip()
|
||||
|
||||
|
||||
def convert_table_to_md_pptx(table: Any) -> str:
|
||||
"""将 PPTX 表格转换为 Markdown 格式"""
|
||||
rows_data = []
|
||||
for row in table.rows:
|
||||
row_data = []
|
||||
for cell in row.cells:
|
||||
cell_content = []
|
||||
for para in cell.text_frame.paragraphs:
|
||||
text = extract_formatted_text_pptx(para.runs)
|
||||
if text:
|
||||
cell_content.append(text)
|
||||
cell_text = " ".join(cell_content).strip()
|
||||
row_data.append(cell_text if cell_text else "")
|
||||
rows_data.append(row_data)
|
||||
return build_markdown_table(rows_data)
|
||||
|
||||
|
||||
def parse_pptx_with_python_pptx(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 python-pptx 库解析 PPTX 文件"""
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
except ImportError:
|
||||
return None, "python-pptx 库未安装"
|
||||
|
||||
_A_NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
|
||||
|
||||
try:
|
||||
prs = Presentation(file_path)
|
||||
md_content = []
|
||||
|
||||
for slide_num, slide in enumerate(prs.slides, 1):
|
||||
md_content.append(f"\n## Slide {slide_num}\n")
|
||||
|
||||
list_stack = []
|
||||
|
||||
for shape in slide.shapes:
|
||||
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||||
continue
|
||||
|
||||
if hasattr(shape, "has_table") and shape.has_table:
|
||||
if list_stack:
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
table_md = convert_table_to_md_pptx(shape.table)
|
||||
md_content.append(table_md)
|
||||
|
||||
if hasattr(shape, "text_frame"):
|
||||
for para in shape.text_frame.paragraphs:
|
||||
pPr = para._element.pPr
|
||||
is_list = False
|
||||
if pPr is not None:
|
||||
is_list = (
|
||||
para.level > 0
|
||||
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:
|
||||
level = para.level
|
||||
|
||||
while len(list_stack) <= level:
|
||||
list_stack.append("")
|
||||
|
||||
text = extract_formatted_text_pptx(para.runs)
|
||||
if text:
|
||||
is_ordered = (
|
||||
pPr is not None
|
||||
and pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
|
||||
)
|
||||
marker = "1. " if is_ordered else "- "
|
||||
indent = " " * level
|
||||
list_stack[level] = f"{indent}{marker}{text}"
|
||||
|
||||
for i in range(len(list_stack)):
|
||||
if list_stack[i]:
|
||||
md_content.append(list_stack[i] + "\n")
|
||||
list_stack[i] = ""
|
||||
else:
|
||||
if list_stack:
|
||||
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:
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
md_content.append("---\n")
|
||||
|
||||
content = "\n".join(md_content)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"python-pptx 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_pptx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 XML 原生解析 PPTX 文件"""
|
||||
pptx_namespace = {
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
}
|
||||
|
||||
def extract_text_with_formatting_xml(text_elem: Any, namespaces: dict) -> str:
|
||||
result = []
|
||||
runs = text_elem.findall(".//a:r", namespaces=namespaces)
|
||||
for run in runs:
|
||||
t_elem = run.find(".//a:t", namespaces=namespaces)
|
||||
if t_elem is None or not t_elem.text:
|
||||
continue
|
||||
|
||||
text = t_elem.text
|
||||
|
||||
rPr = run.find(".//a:rPr", namespaces=namespaces)
|
||||
is_bold = False
|
||||
is_italic = False
|
||||
|
||||
if rPr is not None:
|
||||
is_bold = rPr.find(".//a:b", namespaces=namespaces) is not None
|
||||
is_italic = rPr.find(".//a:i", namespaces=namespaces) is not None
|
||||
|
||||
if is_bold and is_italic:
|
||||
text = f"***{text}***"
|
||||
elif is_bold:
|
||||
text = f"**{text}**"
|
||||
elif is_italic:
|
||||
text = f"*{text}*"
|
||||
|
||||
result.append(text)
|
||||
|
||||
return "".join(result).strip() if result else ""
|
||||
|
||||
def convert_table_to_md_xml(table_elem: Any, namespaces: dict) -> str:
|
||||
rows = table_elem.findall(".//a:tr", namespaces=namespaces)
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
rows_data = []
|
||||
for row in rows:
|
||||
cells = row.findall(".//a:tc", namespaces=namespaces)
|
||||
row_data = []
|
||||
for cell in cells:
|
||||
cell_text = extract_text_with_formatting_xml(cell, namespaces)
|
||||
if cell_text:
|
||||
cell_text = cell_text.replace("\n", " ").replace("\r", "")
|
||||
row_data.append(cell_text if cell_text else "")
|
||||
rows_data.append(row_data)
|
||||
return build_markdown_table(rows_data)
|
||||
|
||||
def is_list_item_xml(p_elem: Any, namespaces: dict) -> Tuple[bool, bool]:
|
||||
if p_elem is None:
|
||||
return False, False
|
||||
|
||||
pPr = p_elem.find(".//a:pPr", namespaces=namespaces)
|
||||
if pPr is None:
|
||||
return False, False
|
||||
|
||||
buChar = pPr.find(".//a:buChar", namespaces=namespaces)
|
||||
if buChar is not None:
|
||||
return True, False
|
||||
|
||||
buAutoNum = pPr.find(".//a:buAutoNum", namespaces=namespaces)
|
||||
if buAutoNum is not None:
|
||||
return True, True
|
||||
|
||||
return False, False
|
||||
|
||||
def get_indent_level_xml(p_elem: Any, namespaces: dict) -> int:
|
||||
if p_elem is None:
|
||||
return 0
|
||||
|
||||
pPr = p_elem.find(".//a:pPr", namespaces=namespaces)
|
||||
if pPr is None:
|
||||
return 0
|
||||
|
||||
lvl = pPr.get("lvl")
|
||||
return int(lvl) if lvl else 0
|
||||
|
||||
try:
|
||||
md_content = []
|
||||
|
||||
with zipfile.ZipFile(file_path) as zip_file:
|
||||
slide_files = [
|
||||
f
|
||||
for f in zip_file.namelist()
|
||||
if re.match(r"ppt/slides/slide\d+\.xml$", f)
|
||||
]
|
||||
slide_files.sort(
|
||||
key=lambda f: int(re.search(r"slide(\d+)\.xml$", f).group(1))
|
||||
)
|
||||
|
||||
for slide_idx, slide_file in enumerate(slide_files, 1):
|
||||
md_content.append("\n## Slide {}\n".format(slide_idx))
|
||||
|
||||
with zip_file.open(slide_file) as slide_xml:
|
||||
slide_root = ET.parse(slide_xml).getroot()
|
||||
|
||||
tx_bodies = slide_root.findall(
|
||||
".//p:sp/p:txBody", namespaces=pptx_namespace
|
||||
)
|
||||
|
||||
tables = slide_root.findall(".//a:tbl", namespaces=pptx_namespace)
|
||||
for table in tables:
|
||||
table_md = convert_table_to_md_xml(table, pptx_namespace)
|
||||
if table_md:
|
||||
md_content.append(table_md)
|
||||
|
||||
for tx_body in tx_bodies:
|
||||
paragraphs = tx_body.findall(
|
||||
".//a:p", namespaces=pptx_namespace
|
||||
)
|
||||
list_stack = []
|
||||
|
||||
for para in paragraphs:
|
||||
is_list, is_ordered = is_list_item_xml(para, pptx_namespace)
|
||||
|
||||
if is_list:
|
||||
level = get_indent_level_xml(para, pptx_namespace)
|
||||
|
||||
while len(list_stack) <= level:
|
||||
list_stack.append("")
|
||||
|
||||
text = extract_text_with_formatting_xml(
|
||||
para, pptx_namespace
|
||||
)
|
||||
if text:
|
||||
marker = "1. " if is_ordered else "- "
|
||||
indent = " " * level
|
||||
list_stack[level] = f"{indent}{marker}{text}"
|
||||
|
||||
for i in range(len(list_stack)):
|
||||
if list_stack[i]:
|
||||
md_content.append(list_stack[i] + "\n")
|
||||
list_stack[i] = ""
|
||||
else:
|
||||
if list_stack:
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
text = extract_text_with_formatting_xml(
|
||||
para, pptx_namespace
|
||||
)
|
||||
if text:
|
||||
md_content.append(f"{text}\n")
|
||||
|
||||
if list_stack:
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
md_content.append("---\n")
|
||||
|
||||
content = "\n".join(md_content)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"XML 解析失败: {str(e)}"
|
||||
@@ -1,286 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""XLSX 文件解析模块,提供三种解析方法。"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
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]]:
|
||||
"""使用 docling 库解析 XLSX 文件"""
|
||||
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)
|
||||
|
||||
|
||||
def parse_xlsx_with_pandas(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 pandas 库解析 XLSX 文件"""
|
||||
try:
|
||||
import pandas as pd
|
||||
from tabulate import tabulate
|
||||
except ImportError as e:
|
||||
missing_lib = "pandas" if "pandas" in str(e) else "tabulate"
|
||||
return None, f"{missing_lib} 库未安装"
|
||||
|
||||
try:
|
||||
sheets = pd.read_excel(file_path, sheet_name=None)
|
||||
|
||||
markdown_parts = []
|
||||
for sheet_name, df in sheets.items():
|
||||
if len(df) == 0:
|
||||
markdown_parts.append(f"## {sheet_name}\n\n*工作表为空*")
|
||||
continue
|
||||
|
||||
table_md = tabulate(
|
||||
df, headers="keys", tablefmt="pipe", showindex=True, missingval=""
|
||||
)
|
||||
markdown_parts.append(f"## {sheet_name}\n\n{table_md}")
|
||||
|
||||
if not markdown_parts:
|
||||
return None, "Excel 文件为空"
|
||||
|
||||
markdown_content = "# Excel数据转换结果\n\n" + "\n\n".join(markdown_parts)
|
||||
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"pandas 解析失败: {str(e)}"
|
||||
|
||||
|
||||
def parse_xlsx_with_xml(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 XML 原生解析 XLSX 文件"""
|
||||
xlsx_namespace = {
|
||||
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
}
|
||||
|
||||
def parse_col_index(cell_ref: str) -> int:
|
||||
col_index = 0
|
||||
for char in cell_ref:
|
||||
if char.isalpha():
|
||||
col_index = col_index * 26 + (ord(char) - ord("A") + 1)
|
||||
else:
|
||||
break
|
||||
return col_index - 1
|
||||
|
||||
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 ""
|
||||
|
||||
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 == "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
|
||||
except ValueError:
|
||||
return cell_value
|
||||
else:
|
||||
return cell_value
|
||||
|
||||
def get_non_empty_columns(data: List[List[str]]) -> set:
|
||||
non_empty_cols = set()
|
||||
for row in data:
|
||||
for col_idx, cell in enumerate(row):
|
||||
if cell and cell.strip():
|
||||
non_empty_cols.add(col_idx)
|
||||
return non_empty_cols
|
||||
|
||||
def filter_columns(row: List[str], non_empty_cols: set) -> List[str]:
|
||||
return [row[i] if i < len(row) else "" for i in sorted(non_empty_cols)]
|
||||
|
||||
def data_to_markdown(data: List[List[str]], sheet_name: str) -> str:
|
||||
if not data or not data[0]:
|
||||
return f"## {sheet_name}\n\n*工作表为空*"
|
||||
|
||||
md_lines = []
|
||||
md_lines.append(f"## {sheet_name}")
|
||||
md_lines.append("")
|
||||
|
||||
headers = data[0]
|
||||
|
||||
non_empty_cols = get_non_empty_columns(data)
|
||||
|
||||
if not non_empty_cols:
|
||||
return f"## {sheet_name}\n\n*工作表为空*"
|
||||
|
||||
filtered_headers = filter_columns(headers, non_empty_cols)
|
||||
header_line = "| " + " | ".join(filtered_headers) + " |"
|
||||
md_lines.append(header_line)
|
||||
|
||||
separator_line = "| " + " | ".join(["---"] * len(filtered_headers)) + " |"
|
||||
md_lines.append(separator_line)
|
||||
|
||||
for row in data[1:]:
|
||||
filtered_row = filter_columns(row, non_empty_cols)
|
||||
row_line = "| " + " | ".join(filtered_row) + " |"
|
||||
md_lines.append(row_line)
|
||||
|
||||
md_lines.append("")
|
||||
|
||||
return "\n".join(md_lines)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zip_file:
|
||||
sheet_names = []
|
||||
sheet_rids = []
|
||||
try:
|
||||
with zip_file.open("xl/workbook.xml") as f:
|
||||
root = ET.parse(f).getroot()
|
||||
rel_ns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
sheet_elements = root.findall(".//main:sheet", xlsx_namespace)
|
||||
for sheet in sheet_elements:
|
||||
sheet_name = sheet.attrib.get("name", "")
|
||||
rid = sheet.attrib.get(f"{{{rel_ns}}}id", "")
|
||||
if sheet_name:
|
||||
sheet_names.append(sheet_name)
|
||||
sheet_rids.append(rid)
|
||||
except KeyError:
|
||||
return None, "无法解析工作表名称"
|
||||
|
||||
if not sheet_names:
|
||||
return None, "未找到工作表"
|
||||
|
||||
rid_to_target = {}
|
||||
try:
|
||||
rels_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
with zip_file.open("xl/_rels/workbook.xml.rels") as f:
|
||||
rels_root = ET.parse(f).getroot()
|
||||
for rel in rels_root.findall(f"{{{rels_ns}}}Relationship"):
|
||||
rid = rel.attrib.get("Id", "")
|
||||
target = rel.attrib.get("Target", "")
|
||||
if rid and target:
|
||||
rid_to_target[rid] = target
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
shared_strings = []
|
||||
try:
|
||||
with zip_file.open("xl/sharedStrings.xml") as f:
|
||||
root = ET.parse(f).getroot()
|
||||
for si in root.findall(".//main:si", xlsx_namespace):
|
||||
t_elem = si.find(".//main:t", xlsx_namespace)
|
||||
if t_elem is not None and t_elem.text:
|
||||
shared_strings.append(t_elem.text)
|
||||
else:
|
||||
shared_strings.append("")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
markdown_content = "# Excel数据转换结果 (原生XML解析)\n\n"
|
||||
|
||||
for sheet_index, sheet_name in enumerate(sheet_names):
|
||||
rid = sheet_rids[sheet_index] if sheet_index < len(sheet_rids) else ""
|
||||
target = rid_to_target.get(rid, "")
|
||||
if target:
|
||||
if target.startswith("/"):
|
||||
worksheet_path = target.lstrip("/")
|
||||
else:
|
||||
worksheet_path = f"xl/{target}"
|
||||
else:
|
||||
worksheet_path = f"xl/worksheets/sheet{sheet_index + 1}.xml"
|
||||
|
||||
try:
|
||||
with zip_file.open(worksheet_path) as f:
|
||||
root = ET.parse(f).getroot()
|
||||
sheet_data = root.find("main:sheetData", xlsx_namespace)
|
||||
|
||||
rows = []
|
||||
if sheet_data is not None:
|
||||
row_elements = sheet_data.findall(
|
||||
"main:row", xlsx_namespace
|
||||
)
|
||||
|
||||
for row_elem in row_elements:
|
||||
cells = row_elem.findall("main:c", xlsx_namespace)
|
||||
|
||||
col_dict = {}
|
||||
for cell in cells:
|
||||
cell_ref = cell.attrib.get("r", "")
|
||||
if not cell_ref:
|
||||
continue
|
||||
|
||||
col_index = parse_col_index(cell_ref)
|
||||
cell_value = parse_cell_value(cell, shared_strings)
|
||||
col_dict[col_index] = cell_value
|
||||
|
||||
if col_dict:
|
||||
max_col = max(col_dict.keys())
|
||||
row_data = [
|
||||
col_dict.get(i, "") for i in range(max_col + 1)
|
||||
]
|
||||
rows.append(row_data)
|
||||
|
||||
table_md = data_to_markdown(rows, sheet_name)
|
||||
markdown_content += table_md + "\n\n"
|
||||
|
||||
except KeyError:
|
||||
markdown_content += f"## {sheet_name}\n\n*工作表解析失败*\n\n"
|
||||
|
||||
if not markdown_content.strip():
|
||||
return None, "解析结果为空"
|
||||
|
||||
return markdown_content, None
|
||||
except Exception as e:
|
||||
return None, f"XML 解析失败: {str(e)}"
|
||||
Reference in New Issue
Block a user