From 6b4fcf26471d992c5311c6a46d1e8785db7efdd4 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 8 Mar 2026 02:02:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E5=BB=BA=20lyxy-reader-html=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 skill: lyxy-reader-html,用于解析 HTML 文件和 URL 网页内容 - 支持 URL 下载(pyppeteer → selenium → httpx → urllib 优先级回退) - 支持 HTML 解析(trafilatura → domscribe → MarkItDown → html2text 优先级回退) - 支持查询功能:全文提取、字数统计、行数统计、标题提取、章节提取、正则搜索 - 新增 spec: html-document-parsing - 归档 change: create-lyxy-reader-html-skill --- .claude/settings.local.json | 11 +- .../.openspec.yaml | 2 + .../design.md | 124 +++++++ .../proposal.md | 26 ++ .../specs/html-document-parsing/spec.md | 137 ++++++++ .../tasks.md | 58 ++++ openspec/specs/html-document-parsing/spec.md | 137 ++++++++ skills/lyxy-reader-html/SKILL.md | 75 ++++ .../references/error-handling.md | 54 +++ .../lyxy-reader-html/references/examples.md | 59 ++++ skills/lyxy-reader-html/references/parsers.md | 68 ++++ skills/lyxy-reader-html/scripts/README.md | 323 ++++++++++++++++++ skills/lyxy-reader-html/scripts/common.py | 225 ++++++++++++ skills/lyxy-reader-html/scripts/downloader.py | 263 ++++++++++++++ .../lyxy-reader-html/scripts/html_parser.py | 140 ++++++++ skills/lyxy-reader-html/scripts/parser.py | 128 +++++++ 16 files changed, 1827 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/design.md create mode 100644 openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/proposal.md create mode 100644 openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/specs/html-document-parsing/spec.md create mode 100644 openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/tasks.md create mode 100644 openspec/specs/html-document-parsing/spec.md create mode 100644 skills/lyxy-reader-html/SKILL.md create mode 100644 skills/lyxy-reader-html/references/error-handling.md create mode 100644 skills/lyxy-reader-html/references/examples.md create mode 100644 skills/lyxy-reader-html/references/parsers.md create mode 100644 skills/lyxy-reader-html/scripts/README.md create mode 100644 skills/lyxy-reader-html/scripts/common.py create mode 100644 skills/lyxy-reader-html/scripts/downloader.py create mode 100644 skills/lyxy-reader-html/scripts/html_parser.py create mode 100644 skills/lyxy-reader-html/scripts/parser.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2beb587..9c847ac 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,11 +4,16 @@ "Bash(uv:*)", "Bash(openspec:*)", "WebSearch", - "WebFetch(domain:pypi.org)", - "WebFetch(domain:github.com)", + "WebFetch(*)", "Bash(pip index:*)", "Bash(pip show:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "Bash(ls:*)", + "Bash(git:*)", + "mcp__context7__resolve-library-id", + "mcp__context7__query-docs", + "mcp__exa__get_code_context_exa", + "mcp__exa__web_search_exa" ] } } diff --git a/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/.openspec.yaml b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/.openspec.yaml new file mode 100644 index 0000000..f1842c5 --- /dev/null +++ b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-07 diff --git a/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/design.md b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/design.md new file mode 100644 index 0000000..0ee9368 --- /dev/null +++ b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/design.md @@ -0,0 +1,124 @@ +## Context + +当前项目已有 `lyxy-reader-office` skill 用于解析办公文档,其架构设计成熟,包含统一命令行入口、多解析器回退机制、多功能查询等特性。现在需要创建 `lyxy-reader-html` skill,功能上类似但针对 HTML 内容,同时需支持 URL 下载能力。 + +**约束条件**: +- 与 `lyxy-reader-office` 保持相同的用户体验(参数、输出格式) +- 代码完全独立,不复用 `lyxy-reader-office` 的代码 +- 参考 `temp/downloader/download.py` 和 `temp/parser/parse.py` 的现有实现 + +## Goals / Non-Goals + +**Goals:** +- 创建完整的 `lyxy-reader-html` skill 目录结构 +- 实现统一的命令行入口 `parser.py`,支持 URL 和 HTML 文件输入 +- 实现下载器模块,按 pyppeteer → selenium → httpx → urllib 优先级回退 +- 实现解析器模块,按 trafilatura → domscribe → MarkItDown → html2text 优先级回退 +- 实现 HTML 预处理清理和 Markdown 后处理 +- 实现与 `lyxy-reader-office` 一致的查询功能(全文、字数、行数、标题、章节、搜索) + +**Non-Goals:** +- 不支持可配置的 HTML 清理选项 +- 不支持 JavaScript 渲染开关(默认启用完整下载链) +- 不支持正文/全文切换(默认使用解析器的正文提取) + +## Decisions + +### 1. 目录结构参考 lyxy-reader-office +**决策**:采用与 `lyxy-reader-office` 相同的目录结构 +``` +lyxy-reader-html/ +├── SKILL.md +├── scripts/ +│ ├── parser.py # 统一入口 +│ ├── common.py # 公共函数 +│ ├── downloader.py # URL 下载 +│ ├── html_parser.py # HTML 解析 +│ └── README.md +└── references/ + ├── examples.md + ├── parsers.md + └── error-handling.md +``` + +**理由**:保持项目一致性,降低用户学习成本 + +--- + +### 2. 下载器优先级:pyppeteer → selenium → httpx → urllib +**决策**:直接采用 `temp/downloader/download.py` 的优先级顺序 +- pyppeteer(支持 JS 渲染) +- selenium(支持 JS 渲染,作为 pyppeteer 的备选) +- httpx(轻量级 HTTP 客户端) +- urllib(标准库,兜底) + +**备选方案考虑**: +- httpx → urllib → pyppeteer → selenium(速度优先) +- 选择保持原顺序,因为 JS 渲染能力对许多现代网页很重要 + +--- + +### 3. 解析器优先级:trafilatura → domscribe → MarkItDown → html2text +**决策**:采用精简后的 4 个解析器,顺序参考 `temp/parser/parse.py` +1. trafilatura - 专门用于网页正文提取,质量高 +2. domscribe - 专注内容提取 +3. MarkItDown - 微软官方,格式规范 +4. html2text - 经典库,作为兜底 + +**备选方案考虑**: +- 保留原 6 个解析器(增加 markdownify 和 html-to-markdown) +- 选择精简为 4 个,减少维护复杂度 + +--- + +### 4. HTML 预处理清理默认开启且不可配置 +**决策**:解析前固定执行 HTML 清理,移除 script/style/link/svg 标签和 URL 属性 +- 使用 `temp/clean_html.py` 的清理逻辑 +- 不提供 `--no-clean` 参数 + +**理由**:简化设计,减少用户选择负担 + +--- + +### 5. Markdown 处理函数独立实现 +**决策**:在 `common.py` 中独立实现以下函数,不复用 `lyxy-reader-office`: +- `remove_markdown_images()` - 移除图片标记 +- `normalize_markdown_whitespace()` - 规范化空行 +- `extract_titles()` - 提取标题 +- `extract_title_content()` - 提取章节内容 +- `search_markdown()` - 正则搜索 + +**理由**:保持 skill 之间完全隔离 + +--- + +### 6. 命令行参数与 lyxy-reader-office 保持一致 +**决策**:支持以下参数,与 `lyxy-reader-office` 完全一致: +- (无参数):全文输出 +- `-c` / `--count`:字数统计 +- `-l` / `--lines`:行数统计 +- `-t` / `--titles`:提取标题 +- `-tc ` / `--title-content `:提取章节 +- `-s ` / `--search `:正则搜索 +- `-n ` / `--context `:搜索上下文行数 + +**不添加** HTML 专用参数 + +**理由**:统一用户体验 + +## Risks / Trade-offs + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| pyppeteer/selenium 依赖重,安装困难 | 中 | 提供 httpx/urllib 作为轻量备选 | +| trafilatura 可能提取不到正文 | 低 | 后续解析器会继续尝试 | +| 不同解析器输出质量差异大 | 中 | 用户可通过安装不同依赖来间接选择解析器 | +| URL 下载超时或被反爬 | 中 | 多下载器回退增加成功率 | + +## Migration Plan + +不适用 - 这是新 skill 创建,无迁移需求。 + +## Open Questions + +无 - 所有决策已明确。 diff --git a/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/proposal.md b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/proposal.md new file mode 100644 index 0000000..d4efea0 --- /dev/null +++ b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/proposal.md @@ -0,0 +1,26 @@ +## Why + +当前已有 `lyxy-reader-office` skill 用于解析办公文档,但缺少对 HTML 网页内容的解析能力。用户需要从 URL 或本地 HTML 文件中提取内容并转换为 Markdown 格式,同时支持标题提取、内容搜索等查询功能。 + +## What Changes + +- 创建新 skill `lyxy-reader-html`,目录结构参考 `lyxy-reader-office` +- 实现命令行工具 `scripts/parser.py`,接受 URL 或 HTML 文件作为输入 +- URL 模式下按优先级尝试下载器:pyppeteer → selenium → httpx → urllib +- HTML 解析按优先级尝试:trafilatura → domscribe → MarkItDown → html2text +- 支持 HTML 预处理清理(移除 script/style/link 等标签和 URL 属性) +- 实现查询功能:全文输出、字数统计、行数统计、标题提取、章节提取、正则搜索 + +## Capabilities + +### New Capabilities +- `html-document-parsing`: HTML 文档和 URL 内容解析能力,将 HTML 转换为 Markdown 并支持多种查询模式 + +### Modified Capabilities +(无) + +## Impact + +- 新增目录 `skills/lyxy-reader-html/` +- 新增 Python 脚本依赖:trafilatura、domscribe、markitdown、html2text、httpx、pyppeteer、selenium、beautifulsoup4 +- 与现有 skill 完全隔离,不影响其他功能 diff --git a/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/specs/html-document-parsing/spec.md b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/specs/html-document-parsing/spec.md new file mode 100644 index 0000000..a35b9c6 --- /dev/null +++ b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/specs/html-document-parsing/spec.md @@ -0,0 +1,137 @@ +## ADDED Requirements + +### Requirement: 优先使用 lyxy-reader-html 解析 HTML 内容 +大模型在遇到 URL、.html 或 .htm 文件时,SHALL 优先激活并使用 lyxy-reader-html skill 来读取内容。 + +#### Scenario: 用户请求读取 URL +- **WHEN** 用户提供以 http:// 或 https:// 开头的 URL +- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill +- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析 + +#### Scenario: 用户请求读取 HTML 文件 +- **WHEN** 用户提供的文件路径以 .html 或 .htm 结尾 +- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill +- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析 + +### Requirement: 必须通过 lyxy-runner-python 执行脚本 +当环境中存在 lyxy-runner-python skill 时,大模型 SHALL 必须使用该 skill 来运行 parser.py 脚本。 + +#### Scenario: lyxy-runner-python 可用 +- **WHEN** 大模型环境中存在 lyxy-runner-python skill +- **THEN** 大模型 SHALL 通过 lyxy-runner-python skill 执行 parser.py +- **AND** 利用 lyxy-runner-python 的自动依赖管理功能(uv)安装所需的 Python 包 + +#### Scenario: lyxy-runner-python 不可用 +- **WHEN** 大模型环境中不存在 lyxy-runner-python skill +- **THEN** 大模型 SHALL 降级到直接使用 Python 执行 parser.py +- **AND** 提示用户当前使用直接执行模式 +- **AND** 禁止自动执行 pip install 安装依赖 + +### Requirement: 引导阅读 README 获取详细用法 +大模型在需要了解 parser.py 的详细使用方式时,SHALL 阅读 `scripts/README.md` 文件。 + +#### Scenario: 首次使用 skill 执行解析 +- **WHEN** 大模型首次使用 lyxy-reader-html skill 或不确定具体参数用法 +- **THEN** 大模型 SHALL 阅读 `scripts/README.md` 获取命令行参数、依赖安装和使用示例等详细信息 + +#### Scenario: 遇到特殊参数需求 +- **WHEN** 用户请求使用特殊功能(如章节提取、正则搜索等) +- **THEN** 大模型 SHALL 参考 `scripts/README.md` 中的对应参数说明 + +### Requirement: 支持 URL 和 HTML 文件两种输入源 +系统 SHALL 支持从 URL 下载 HTML 内容或直接读取本地 HTML 文件两种输入方式。 + +#### Scenario: 输入为 URL +- **WHEN** 输入以 http:// 或 https:// 开头 +- **THEN** 系统 SHALL 识别为 URL 模式 +- **AND** 尝试下载 URL 对应的 HTML 内容 + +#### Scenario: 输入为本地 HTML 文件 +- **WHEN** 输入是存在的本地文件且扩展名为 .html 或 .htm +- **THEN** 系统 SHALL 识别为 HTML 文件模式 +- **AND** 直接读取文件内容进行解析 + +#### Scenario: 输入无法识别 +- **WHEN** 输入既不是 URL 也不是有效的 HTML 文件 +- **THEN** 系统 SHALL 输出错误信息 +- **AND** 以退出码 1 退出 + +### Requirement: URL 下载器按优先级降级 +系统 SHALL 按 pyppeteer → selenium → httpx → urllib 的优先级尝试下载 URL 内容。 + +#### Scenario: 下载器按优先级降级 +- **WHEN** 优先级最高的下载器不可用或下载失败 +- **THEN** 系统自动尝试下一优先级的下载器 +- **AND** 记录每个下载器的失败原因 + +#### Scenario: 所有下载器失败 +- **WHEN** 所有下载策略均失败 +- **THEN** 系统返回详细的失败信息 +- **AND** 列出每种下载策略的失败原因 +- **AND** 以退出码 1 退出 + +### Requirement: HTML 解析器按优先级降级 +系统 SHALL 按 trafilatura → domscribe → MarkItDown → html2text 的优先级尝试解析 HTML 内容。 + +#### Scenario: 解析器按优先级降级 +- **WHEN** 优先级最高的解析器不可用或解析失败 +- **THEN** 系统自动尝试下一优先级的解析器 +- **AND** 记录每个解析器的失败原因 + +#### Scenario: 所有解析器失败 +- **WHEN** 所有解析策略均失败 +- **THEN** 系统返回详细的失败信息 +- **AND** 列出每种解析策略的失败原因 +- **AND** 以退出码 1 退出 + +### Requirement: HTML 内容预处理清理 +系统 SHALL 在解析前对 HTML 内容进行预处理清理,移除噪声元素。 + +#### Scenario: 清理 script 和 style 标签 +- **WHEN** HTML 内容包含 <script>、<style>、<link>、<svg> 标签 +- **THEN** 系统 SHALL 移除这些标签及其内容 + +#### Scenario: 清理 URL 属性 +- **WHEN** HTML 标签包含 href、src、srcset、action、data-*src 等 URL 属性 +- **THEN** 系统 SHALL 移除这些属性 + +#### Scenario: 清理 style 属性 +- **WHEN** HTML 标签包含 style 属性 +- **THEN** 系统 SHALL 移除 style 属性 + +### Requirement: 支持统一的查询功能 +系统 SHALL 提供统一的查询接口,包括全文提取、元数据查询、标题提取、章节提取和正则搜索。 + +#### Scenario: 获取文档字数 +- **WHEN** 用户请求获取文档的字数 +- **THEN** 系统使用 `-c` 参数返回文档的总字符数 + +#### Scenario: 获取文档行数 +- **WHEN** 用户请求获取文档的行数 +- **THEN** 系统使用 `-l` 参数返回文档的总行数 + +#### Scenario: 提取所有标题 +- **WHEN** 用户请求提取文档的标题结构 +- **THEN** 系统使用 `-t` 参数返回所有 1-6 级标题 + +#### Scenario: 提取指定章节内容 +- **WHEN** 用户请求提取特定标题名称的章节内容 +- **THEN** 系统使用 `-tc` 参数返回该章节的完整内容 +- **AND** 包含完整的上级标题链和所有下级内容 + +#### Scenario: 正则表达式搜索 +- **WHEN** 用户请求在文档中搜索关键词或模式 +- **THEN** 系统使用 `-s` 参数返回所有匹配结果及上下文 +- **AND** 默认包含前后各 2 行非空行上下文 +- **AND** 支持 `-n` 参数自定义上下文行数 + +### Requirement: Markdown 后处理 +系统 SHALL 对解析后的 Markdown 内容进行后处理,提升可读性。 + +#### Scenario: 移除图片标记 +- **WHEN** 解析结果包含 Markdown 图片语法 `![alt](url)` +- **THEN** 系统 SHALL 移除这些图片标记 + +#### Scenario: 规范化空行 +- **WHEN** 解析结果包含连续 3 个或更多空行 +- **THEN** 系统 SHALL 将其合并为单个空行 diff --git a/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/tasks.md b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/tasks.md new file mode 100644 index 0000000..31a7f63 --- /dev/null +++ b/openspec/changes/archive/2026-03-08-create-lyxy-reader-html-skill/tasks.md @@ -0,0 +1,58 @@ +## 1. 初始化 Skill 目录结构 + +- [x] 1.1 创建 `skills/lyxy-reader-html/` 目录 +- [x] 1.2 创建 `skills/lyxy-reader-html/scripts/` 子目录 +- [x] 1.3 创建 `skills/lyxy-reader-html/references/` 子目录 + +## 2. 创建 SKILL.md 主文档 + +- [x] 2.1 编写 YAML 前置元数据(name、description、compatibility) +- [x] 2.2 编写 Purpose 章节 +- [x] 2.3 编写 When to Use 章节(含触发词) +- [x] 2.4 编写 Quick Reference 章节(参数表) +- [x] 2.5 编写 Workflow 章节 +- [x] 2.6 编写 References 章节 + +## 3. 实现 common.py 公共模块 + +- [x] 3.1 实现 HTML 清理函数 `clean_html_content()` +- [x] 3.2 实现 Markdown 图片移除函数 `remove_markdown_images()` +- [x] 3.3 实现 Markdown 空行规范化函数 `normalize_markdown_whitespace()` +- [x] 3.4 实现标题级别检测函数 `get_heading_level()` +- [x] 3.5 实现标题提取函数 `extract_titles()` +- [x] 3.6 实现章节内容提取函数 `extract_title_content()` +- [x] 3.7 实现正则搜索函数 `search_markdown()` + +## 4. 实现 downloader.py URL 下载模块 + +- [x] 4.1 实现 `download_with_pyppeteer()` 函数 +- [x] 4.2 实现 `download_with_selenium()` 函数 +- [x] 4.3 实现 `download_with_httpx()` 函数 +- [x] 4.4 实现 `download_with_urllib()` 函数 +- [x] 4.5 实现统一的 `download_html()` 入口函数,按优先级尝试各下载器 + +## 5. 实现 html_parser.py HTML 解析模块 + +- [x] 5.1 实现 `parse_with_trafilatura()` 函数 +- [x] 5.2 实现 `parse_with_domscribe()` 函数 +- [x] 5.3 实现 `parse_with_markitdown()` 函数 +- [x] 5.4 实现 `parse_with_html2text()` 函数 +- [x] 5.5 实现统一的 `parse_html()` 入口函数,按优先级尝试各解析器 + +## 6. 实现 parser.py 命令行入口 + +- [x] 6.1 实现命令行参数解析(argparse) +- [x] 6.2 实现输入源判断(URL / HTML 文件) +- [x] 6.3 实现 URL 下载流程(如需要) +- [x] 6.4 实现 HTML 清理流程 +- [x] 6.5 实现 HTML 解析流程 +- [x] 6.6 实现 Markdown 后处理(移除图片、规范化空行) +- [x] 6.7 实现各查询模式(全文、字数、行数、标题、章节、搜索) +- [x] 6.8 实现错误处理和退出码 + +## 7. 创建参考文档 + +- [x] 7.1 创建 `scripts/README.md` 详细使用文档 +- [x] 7.2 创建 `references/examples.md` 使用示例 +- [x] 7.3 创建 `references/parsers.md` 解析器说明 +- [x] 7.4 创建 `references/error-handling.md` 错误处理指南 diff --git a/openspec/specs/html-document-parsing/spec.md b/openspec/specs/html-document-parsing/spec.md new file mode 100644 index 0000000..73c511f --- /dev/null +++ b/openspec/specs/html-document-parsing/spec.md @@ -0,0 +1,137 @@ +## Requirements + +### Requirement: 优先使用 lyxy-reader-html 解析 HTML 内容 +大模型在遇到 URL、.html 或 .htm 文件时,SHALL 优先激活并使用 lyxy-reader-html skill 来读取内容。 + +#### Scenario: 用户请求读取 URL +- **WHEN** 用户提供以 http:// 或 https:// 开头的 URL +- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill +- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析 + +#### Scenario: 用户请求读取 HTML 文件 +- **WHEN** 用户提供的文件路径以 .html 或 .htm 结尾 +- **THEN** 大模型 SHALL 激活 lyxy-reader-html skill +- **AND** 使用 skill 目录下的 `scripts/parser.py` 执行解析 + +### Requirement: 必须通过 lyxy-runner-python 执行脚本 +当环境中存在 lyxy-runner-python skill 时,大模型 SHALL 必须使用该 skill 来运行 parser.py 脚本。 + +#### Scenario: lyxy-runner-python 可用 +- **WHEN** 大模型环境中存在 lyxy-runner-python skill +- **THEN** 大模型 SHALL 通过 lyxy-runner-python skill 执行 parser.py +- **AND** 利用 lyxy-runner-python 的自动依赖管理功能(uv)安装所需的 Python 包 + +#### Scenario: lyxy-runner-python 不可用 +- **WHEN** 大模型环境中不存在 lyxy-runner-python skill +- **THEN** 大模型 SHALL 降级到直接使用 Python 执行 parser.py +- **AND** 提示用户当前使用直接执行模式 +- **AND** 禁止自动执行 pip install 安装依赖 + +### Requirement: 引导阅读 README 获取详细用法 +大模型在需要了解 parser.py 的详细使用方式时,SHALL 阅读 `scripts/README.md` 文件。 + +#### Scenario: 首次使用 skill 执行解析 +- **WHEN** 大模型首次使用 lyxy-reader-html skill 或不确定具体参数用法 +- **THEN** 大模型 SHALL 阅读 `scripts/README.md` 获取命令行参数、依赖安装和使用示例等详细信息 + +#### Scenario: 遇到特殊参数需求 +- **WHEN** 用户请求使用特殊功能(如章节提取、正则搜索等) +- **THEN** 大模型 SHALL 参考 `scripts/README.md` 中的对应参数说明 + +### Requirement: 支持 URL 和 HTML 文件两种输入源 +系统 SHALL 支持从 URL 下载 HTML 内容或直接读取本地 HTML 文件两种输入方式。 + +#### Scenario: 输入为 URL +- **WHEN** 输入以 http:// 或 https:// 开头 +- **THEN** 系统 SHALL 识别为 URL 模式 +- **AND** 尝试下载 URL 对应的 HTML 内容 + +#### Scenario: 输入为本地 HTML 文件 +- **WHEN** 输入是存在的本地文件且扩展名为 .html 或 .htm +- **THEN** 系统 SHALL 识别为 HTML 文件模式 +- **AND** 直接读取文件内容进行解析 + +#### Scenario: 输入无法识别 +- **WHEN** 输入既不是 URL 也不是有效的 HTML 文件 +- **THEN** 系统 SHALL 输出错误信息 +- **AND** 以退出码 1 退出 + +### Requirement: URL 下载器按优先级降级 +系统 SHALL 按 pyppeteer → selenium → httpx → urllib 的优先级尝试下载 URL 内容。 + +#### Scenario: 下载器按优先级降级 +- **WHEN** 优先级最高的下载器不可用或下载失败 +- **THEN** 系统自动尝试下一优先级的下载器 +- **AND** 记录每个下载器的失败原因 + +#### Scenario: 所有下载器失败 +- **WHEN** 所有下载策略均失败 +- **THEN** 系统返回详细的失败信息 +- **AND** 列出每种下载策略的失败原因 +- **AND** 以退出码 1 退出 + +### Requirement: HTML 解析器按优先级降级 +系统 SHALL 按 trafilatura → domscribe → MarkItDown → html2text 的优先级尝试解析 HTML 内容。 + +#### Scenario: 解析器按优先级降级 +- **WHEN** 优先级最高的解析器不可用或解析失败 +- **THEN** 系统自动尝试下一优先级的解析器 +- **AND** 记录每个解析器的失败原因 + +#### Scenario: 所有解析器失败 +- **WHEN** 所有解析策略均失败 +- **THEN** 系统返回详细的失败信息 +- **AND** 列出每种解析策略的失败原因 +- **AND** 以退出码 1 退出 + +### Requirement: HTML 内容预处理清理 +系统 SHALL 在解析前对 HTML 内容进行预处理清理,移除噪声元素。 + +#### Scenario: 清理 script 和 style 标签 +- **WHEN** HTML 内容包含 <script>、<style>、<link>、<svg> 标签 +- **THEN** 系统 SHALL 移除这些标签及其内容 + +#### Scenario: 清理 URL 属性 +- **WHEN** HTML 标签包含 href、src、srcset、action、data-*src 等 URL 属性 +- **THEN** 系统 SHALL 移除这些属性 + +#### Scenario: 清理 style 属性 +- **WHEN** HTML 标签包含 style 属性 +- **THEN** 系统 SHALL 移除 style 属性 + +### Requirement: 支持统一的查询功能 +系统 SHALL 提供统一的查询接口,包括全文提取、元数据查询、标题提取、章节提取和正则搜索。 + +#### Scenario: 获取文档字数 +- **WHEN** 用户请求获取文档的字数 +- **THEN** 系统使用 `-c` 参数返回文档的总字符数 + +#### Scenario: 获取文档行数 +- **WHEN** 用户请求获取文档的行数 +- **THEN** 系统使用 `-l` 参数返回文档的总行数 + +#### Scenario: 提取所有标题 +- **WHEN** 用户请求提取文档的标题结构 +- **THEN** 系统使用 `-t` 参数返回所有 1-6 级标题 + +#### Scenario: 提取指定章节内容 +- **WHEN** 用户请求提取特定标题名称的章节内容 +- **THEN** 系统使用 `-tc` 参数返回该章节的完整内容 +- **AND** 包含完整的上级标题链和所有下级内容 + +#### Scenario: 正则表达式搜索 +- **WHEN** 用户请求在文档中搜索关键词或模式 +- **THEN** 系统使用 `-s` 参数返回所有匹配结果及上下文 +- **AND** 默认包含前后各 2 行非空行上下文 +- **AND** 支持 `-n` 参数自定义上下文行数 + +### Requirement: Markdown 后处理 +系统 SHALL 对解析后的 Markdown 内容进行后处理,提升可读性。 + +#### Scenario: 移除图片标记 +- **WHEN** 解析结果包含 Markdown 图片语法 `![alt](url)` +- **THEN** 系统 SHALL 移除这些图片标记 + +#### Scenario: 规范化空行 +- **WHEN** 解析结果包含连续 3 个或更多空行 +- **THEN** 系统 SHALL 将其合并为单个空行 diff --git a/skills/lyxy-reader-html/SKILL.md b/skills/lyxy-reader-html/SKILL.md new file mode 100644 index 0000000..7f2336a --- /dev/null +++ b/skills/lyxy-reader-html/SKILL.md @@ -0,0 +1,75 @@ +--- +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 new file mode 100644 index 0000000..12f0072 --- /dev/null +++ b/skills/lyxy-reader-html/references/error-handling.md @@ -0,0 +1,54 @@ +# 错误处理和限制说明 + +## 限制 + +- 不支持图片提取(仅纯文本) +- 不支持复杂的格式保留(字体、颜色、布局等) +- 不支持文档编辑或修改 +- 仅支持 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 new file mode 100644 index 0000000..a872e1a --- /dev/null +++ b/skills/lyxy-reader-html/references/examples.md @@ -0,0 +1,59 @@ +# 示例 + +## 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 new file mode 100644 index 0000000..3dc08a5 --- /dev/null +++ b/skills/lyxy-reader-html/references/parsers.md @@ -0,0 +1,68 @@ +# 解析器说明和依赖安装 + +## 多策略解析降级 + +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 new file mode 100644 index 0000000..5a0c42f --- /dev/null +++ b/skills/lyxy-reader-html/scripts/README.md @@ -0,0 +1,323 @@ +# 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 new file mode 100644 index 0000000..c66aa71 --- /dev/null +++ b/skills/lyxy-reader-html/scripts/common.py @@ -0,0 +1,225 @@ +#!/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 new file mode 100644 index 0000000..5f2d71d --- /dev/null +++ b/skills/lyxy-reader-html/scripts/downloader.py @@ -0,0 +1,263 @@ +#!/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 new file mode 100644 index 0000000..ad7a88d --- /dev/null +++ b/skills/lyxy-reader-html/scripts/html_parser.py @@ -0,0 +1,140 @@ +#!/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 new file mode 100644 index 0000000..093dce7 --- /dev/null +++ b/skills/lyxy-reader-html/scripts/parser.py @@ -0,0 +1,128 @@ +#!/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()