diff --git a/skills/lyxy-kb/SKILL.md b/skills/lyxy-kb/SKILL.md deleted file mode 100644 index 6ea653e..0000000 --- a/skills/lyxy-kb/SKILL.md +++ /dev/null @@ -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 ` | 初始化知识项目目录结构 | -| ingest | `/lyxy-kb-ingest ` | 解析 sources/ 中新文件,增量更新 project.md | -| rebuild | `/lyxy-kb-rebuild ` | 全量重新生成 project.md | -| ask | `/lyxy-kb-ask ` | 基于知识库进行会话问答 | - -## Workflow - -### 知识项目目录结构 - -``` -/ -├── project.md # 高度摘要 + 文件索引 -├── manifest.json # 增量追踪 -├── parsed/ # 解析后的 markdown -├── sources/ # 待处理区(用户放入原始文档) -└── archive/ # 原始文件备份(带时间戳) -``` - -### 基本工作流程 - -1. **初始化**:使用 `/lyxy-kb-init ` 创建项目目录结构 -2. **入库**:将文档放入 `sources/`,执行 `/lyxy-kb-ingest ` -3. **问答**:使用 `/lyxy-kb-ask ` 基于知识库回答问题 - -### 渐进式查询策略 - -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` | 渐进式查询策略、来源引用格式、依赖关系、限制说明 | diff --git a/skills/lyxy-kb/references/query-strategy.md b/skills/lyxy-kb/references/query-strategy.md deleted file mode 100644 index dd786c1..0000000 --- a/skills/lyxy-kb/references/query-strategy.md +++ /dev/null @@ -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 消耗较高 diff --git a/skills/lyxy-kb/references/structure.md b/skills/lyxy-kb/references/structure.md deleted file mode 100644 index 6eeac41..0000000 --- a/skills/lyxy-kb/references/structure.md +++ /dev/null @@ -1,125 +0,0 @@ -# 知识项目目录结构 - -## 目录结构 - -每个知识项目是当前工作目录(CWD)下的一个子目录,包含以下固定结构: - -``` -/ -├── project.md # 高度摘要 + 文件索引 -├── manifest.json # 增量追踪 -├── parsed/ # 解析后的 markdown(中间产物) -├── sources/ # 待处理区(用户放入原始文档) -└── archive/ # 原始文件备份(带时间戳) -``` - -### 各目录/文件职责 - -| 路径 | 职责 | -|------|------| -| `project.md` | 项目的高度摘要和文件索引,作为问答时的入口文件 | -| `manifest.json` | 记录已处理文件的元信息,用于增量检测和版本追踪 | -| `parsed/` | 存放解析后的 markdown 文件,便于大模型读取分析 | -| `sources/` | 用户放入待处理文档的目录,解析后文件会被移走 | -| `archive/` | 原始文件的备份,每个文件都带时间戳后缀 | - -### 结构完整性验证 - -执行任何 command(ingest / rebuild / ask)时,必须先验证项目目录结构是否完整,即以下 5 项是否全部存在: -- `/project.md` -- `/manifest.json` -- `/parsed/` -- `/sources/` -- `/archive/` - -若不完整,提示用户先执行 `/lyxy-kb-init `,终止当前操作。 - -## 项目名称规则 - -项目名称只允许使用以下字符: -- 中文字符 -- 英文字母(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` | 原始文件名(含扩展名) | -| `archived` | 对应的归档文件相对路径 | -| `parsed_at` | 解析时间(YYYY-MM-DD HH:mm 格式) | diff --git a/skills/lyxy-kb/references/workflow.md b/skills/lyxy-kb/references/workflow.md deleted file mode 100644 index 6f20439..0000000 --- a/skills/lyxy-kb/references/workflow.md +++ /dev/null @@ -1,86 +0,0 @@ -# 文档生命周期和处理策略 - -## 文档生命周期 - -``` -用户放入 sources/(支持子目录) - │ - ▼ - 检查文件(跳过空文件、检测冲突) - │ - ▼ - 解析文件内容(失败则保留在 sources/) - │ - ├──▶ 写入 parsed/<文件名>.md(含头部元信息) - │ - ├──▶ 移动原始文件到 archive/<文件名_YYYYMMDDHHmm>. - │ - ├──▶ 更新 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 条目 diff --git a/skills/lyxy-reader-html/SKILL.md b/skills/lyxy-reader-html/SKILL.md deleted file mode 100644 index 7f2336a..0000000 --- a/skills/lyxy-reader-html/SKILL.md +++ /dev/null @@ -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 ` | 提取指定标题的章节内容 | -| `-s ` | 正则表达式搜索 | -| `-n ` | 与 `-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` 获取完整的命令行参数和依赖安装指南。 diff --git a/skills/lyxy-reader-html/references/error-handling.md b/skills/lyxy-reader-html/references/error-handling.md deleted file mode 100644 index 12f0072..0000000 --- a/skills/lyxy-reader-html/references/error-handling.md +++ /dev/null @@ -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 渲染的情况 diff --git a/skills/lyxy-reader-html/references/examples.md b/skills/lyxy-reader-html/references/examples.md deleted file mode 100644 index a872e1a..0000000 --- a/skills/lyxy-reader-html/references/examples.md +++ /dev/null @@ -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 -``` diff --git a/skills/lyxy-reader-html/references/parsers.md b/skills/lyxy-reader-html/references/parsers.md deleted file mode 100644 index 3dc08a5..0000000 --- a/skills/lyxy-reader-html/references/parsers.md +++ /dev/null @@ -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 行)。 diff --git a/skills/lyxy-reader-html/scripts/README.md b/skills/lyxy-reader-html/scripts/README.md deleted file mode 100644 index 5a0c42f..0000000 --- a/skills/lyxy-reader-html/scripts/README.md +++ /dev/null @@ -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 [options] -``` - -`input` 可以是: -- 以 `http://` 或 `https://` 开头的 URL -- 本地 `.html` 或 `.htm` 文件路径 - -不带任何选项时输出完整 Markdown 内容。 - -### 参数说明 - -以下参数互斥,一次只能使用一个: - -| 短选项 | 长选项 | 说明 | -|--------|--------|------| -| `-c` | `--count` | 输出解析后文档的总字符数(不含换行符) | -| `-l` | `--lines` | 输出解析后文档的总行数 | -| `-t` | `--titles` | 输出所有标题行(1-6 级,含 `#` 前缀) | -| `-tc ` | `--title-content ` | 提取指定标题及其下级内容(`name` 不包含 `#` 号) | -| `-s ` | `--search ` | 使用正则表达式搜索文档,返回匹配结果 | - -搜索辅助参数(与 `-s` 配合使用): - -| 短选项 | 长选项 | 说明 | -|--------|--------|------| -| `-n ` | `--context ` | 每个匹配结果包含的前后非空行数(默认: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 属性 | -| 图片移除 | 删除 `![alt](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 # 本文档 -``` diff --git a/skills/lyxy-reader-html/scripts/common.py b/skills/lyxy-reader-html/scripts/common.py deleted file mode 100644 index c66aa71..0000000 --- a/skills/lyxy-reader-html/scripts/common.py +++ /dev/null @@ -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") diff --git a/skills/lyxy-reader-html/scripts/downloader.py b/skills/lyxy-reader-html/scripts/downloader.py deleted file mode 100644 index 5f2d71d..0000000 --- a/skills/lyxy-reader-html/scripts/downloader.py +++ /dev/null @@ -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 diff --git a/skills/lyxy-reader-html/scripts/html_parser.py b/skills/lyxy-reader-html/scripts/html_parser.py deleted file mode 100644 index ad7a88d..0000000 --- a/skills/lyxy-reader-html/scripts/html_parser.py +++ /dev/null @@ -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 diff --git a/skills/lyxy-reader-html/scripts/parser.py b/skills/lyxy-reader-html/scripts/parser.py deleted file mode 100644 index 093dce7..0000000 --- a/skills/lyxy-reader-html/scripts/parser.py +++ /dev/null @@ -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() diff --git a/skills/lyxy-reader-office/SKILL.md b/skills/lyxy-reader-office/SKILL.md deleted file mode 100644 index dcaaa5b..0000000 --- a/skills/lyxy-reader-office/SKILL.md +++ /dev/null @@ -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 ` | 提取指定标题的章节内容 | -| `-s ` | 正则表达式搜索 | -| `-n ` | 与 `-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` 获取完整的命令行参数和依赖安装指南。 diff --git a/skills/lyxy-reader-office/references/error-handling.md b/skills/lyxy-reader-office/references/error-handling.md deleted file mode 100644 index 75d5d44..0000000 --- a/skills/lyxy-reader-office/references/error-handling.md +++ /dev/null @@ -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 等旧格式 diff --git a/skills/lyxy-reader-office/references/examples.md b/skills/lyxy-reader-office/references/examples.md deleted file mode 100644 index 1edbcbb..0000000 --- a/skills/lyxy-reader-office/references/examples.md +++ /dev/null @@ -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 -``` diff --git a/skills/lyxy-reader-office/references/parsers.md b/skills/lyxy-reader-office/references/parsers.md deleted file mode 100644 index 9c3af71..0000000 --- a/skills/lyxy-reader-office/references/parsers.md +++ /dev/null @@ -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 或需要识别标题层级的场景。 diff --git a/skills/lyxy-reader-office/scripts/README.md b/skills/lyxy-reader-office/scripts/README.md deleted file mode 100644 index b4f2f3c..0000000 --- a/skills/lyxy-reader-office/scripts/README.md +++ /dev/null @@ -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 [options] -``` - -`file_path` 为 DOCX、PPTX、XLSX 或 PDF 文件路径(相对或绝对路径)。不带任何选项时输出完整 Markdown 内容。 - -### 参数说明 - -以下参数互斥,一次只能使用一个: - -| 短选项 | 长选项 | 说明 | -|--------|--------|------| -| `-c` | `--count` | 输出解析后文档的总字符数(不含换行符) | -| `-l` | `--lines` | 输出解析后文档的总行数 | -| `-t` | `--titles` | 输出所有标题行(1-6 级,含 `#` 前缀) | -| `-tc ` | `--title-content ` | 提取指定标题及其下级内容(`name` 不包含 `#` 号) | -| `-s ` | `--search ` | 使用正则表达式搜索文档,返回匹配结果 | - -搜索辅助参数(与 `-s` 配合使用): - -| 短选项 | 长选项 | 说明 | -|--------|--------|------| -| `-n ` | `--context ` | 每个匹配结果包含的前后非空行数(默认: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 | - -**粗体** *斜体* 下划线 -``` - -### 各格式特有结构 - -**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 策略可通过版面分析识别部分标题,但准确度取决于排版质量。 - -### 内容自动处理 - -输出前会自动进行以下处理: - -| 处理 | 说明 | -|------|------| -| 图片移除 | 删除 `![alt](url)` 语法 | -| 空行规范化 | 连续多个空行合并为一个 | -| 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 # 本文档 -``` diff --git a/skills/lyxy-reader-office/scripts/__pycache__/common.cpython-311.pyc b/skills/lyxy-reader-office/scripts/__pycache__/common.cpython-311.pyc deleted file mode 100644 index 3bcdf2c..0000000 Binary files a/skills/lyxy-reader-office/scripts/__pycache__/common.cpython-311.pyc and /dev/null differ diff --git a/skills/lyxy-reader-office/scripts/__pycache__/docx_parser.cpython-311.pyc b/skills/lyxy-reader-office/scripts/__pycache__/docx_parser.cpython-311.pyc deleted file mode 100644 index 5bfc924..0000000 Binary files a/skills/lyxy-reader-office/scripts/__pycache__/docx_parser.cpython-311.pyc and /dev/null differ diff --git a/skills/lyxy-reader-office/scripts/__pycache__/pdf_parser.cpython-311.pyc b/skills/lyxy-reader-office/scripts/__pycache__/pdf_parser.cpython-311.pyc deleted file mode 100644 index 415edb2..0000000 Binary files a/skills/lyxy-reader-office/scripts/__pycache__/pdf_parser.cpython-311.pyc and /dev/null differ diff --git a/skills/lyxy-reader-office/scripts/__pycache__/pptx_parser.cpython-311.pyc b/skills/lyxy-reader-office/scripts/__pycache__/pptx_parser.cpython-311.pyc deleted file mode 100644 index 104e349..0000000 Binary files a/skills/lyxy-reader-office/scripts/__pycache__/pptx_parser.cpython-311.pyc and /dev/null differ diff --git a/skills/lyxy-reader-office/scripts/__pycache__/xlsx_parser.cpython-311.pyc b/skills/lyxy-reader-office/scripts/__pycache__/xlsx_parser.cpython-311.pyc deleted file mode 100644 index a4bcec0..0000000 Binary files a/skills/lyxy-reader-office/scripts/__pycache__/xlsx_parser.cpython-311.pyc and /dev/null differ diff --git a/skills/lyxy-reader-office/scripts/common.py b/skills/lyxy-reader-office/scripts/common.py deleted file mode 100644 index b0746a5..0000000 --- a/skills/lyxy-reader-office/scripts/common.py +++ /dev/null @@ -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"![image]({path})") - else: - parts.append(text) - - return "\n\n".join(parts) diff --git a/skills/lyxy-reader-office/scripts/docx_parser.py b/skills/lyxy-reader-office/scripts/docx_parser.py deleted file mode 100644 index a5403bc..0000000 --- a/skills/lyxy-reader-office/scripts/docx_parser.py +++ /dev/null @@ -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"{text}" - 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)}" diff --git a/skills/lyxy-reader-office/scripts/parser.py b/skills/lyxy-reader-office/scripts/parser.py deleted file mode 100644 index f8c251a..0000000 --- a/skills/lyxy-reader-office/scripts/parser.py +++ /dev/null @@ -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() diff --git a/skills/lyxy-reader-office/scripts/pdf_parser.py b/skills/lyxy-reader-office/scripts/pdf_parser.py deleted file mode 100644 index 392bc76..0000000 --- a/skills/lyxy-reader-office/scripts/pdf_parser.py +++ /dev/null @@ -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)}" diff --git a/skills/lyxy-reader-office/scripts/pptx_parser.py b/skills/lyxy-reader-office/scripts/pptx_parser.py deleted file mode 100644 index dce07eb..0000000 --- a/skills/lyxy-reader-office/scripts/pptx_parser.py +++ /dev/null @@ -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)}" diff --git a/skills/lyxy-reader-office/scripts/xlsx_parser.py b/skills/lyxy-reader-office/scripts/xlsx_parser.py deleted file mode 100644 index 154307b..0000000 --- a/skills/lyxy-reader-office/scripts/xlsx_parser.py +++ /dev/null @@ -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)}"