refactor: 重构解析器架构并添加编码检测和配置管理
简化 parse_input() 为纯调度器,通过遍历 readers 的 supports() 方法识别输入类型,移除 URL 特殊处理和文件检查逻辑。各 reader 的 parse() 方法负责完整验证(文件存在、格式有效性)。 新增功能: - 添加 chardet 编码自动检测,支持多种中文编码回退机制 - 创建统一配置类管理编码、下载超时、日志等级等配置项 - HTML reader 支持本地文件编码检测和 URL 统一处理 安全性改进: - 修复 safe_open_zip() 路径遍历漏洞,使用 pathlib 规范化路径 - 添加边界检查,search_markdown() 检查负数参数 其他改进: - 修复类型注解(argparse.Namespace) - 日志系统仅输出 ERROR 级别,避免干扰 Markdown 输出 - 更新 BaseReader 接口文档,明确 supports() 和 parse() 职责划分 - 同步 delta specs 到主 specs(document-reading、html-reader、configuration、encoding-detection)
This commit is contained in:
19
config.py
Normal file
19
config.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""统一配置类,集中管理所有配置项。"""
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""统一配置类"""
|
||||||
|
|
||||||
|
# 编码检测
|
||||||
|
# 回退编码列表,当 chardet 检测失败时依次尝试
|
||||||
|
FALLBACK_ENCODINGS = ['utf-8', 'gbk', 'gb2312', 'latin-1']
|
||||||
|
|
||||||
|
# HTML 下载
|
||||||
|
# 下载超时时间(秒)
|
||||||
|
DOWNLOAD_TIMEOUT = 30
|
||||||
|
# HTTP User-Agent 标识
|
||||||
|
USER_AGENT = "lyxy-document/0.1.0"
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
# 日志等级,默认只输出 ERROR 级别避免干扰 Markdown 输出
|
||||||
|
LOG_LEVEL = "ERROR"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]+\)")
|
IMAGE_PATTERN = re.compile(r"!\[[^\]]*\]\([^)]+\)")
|
||||||
@@ -75,13 +76,18 @@ def safe_open_zip(zip_file: zipfile.ZipFile, name: str) -> Optional[zipfile.ZipE
|
|||||||
"""安全地从 ZipFile 中打开文件,防止路径遍历攻击"""
|
"""安全地从 ZipFile 中打开文件,防止路径遍历攻击"""
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
if name.startswith("/") or name.startswith(".."):
|
|
||||||
|
try:
|
||||||
|
normalized = Path(name).as_posix()
|
||||||
|
# 检查是否包含父目录引用
|
||||||
|
if ".." in Path(normalized).parts:
|
||||||
|
return None
|
||||||
|
# 检查是否为绝对路径
|
||||||
|
if Path(normalized).is_absolute():
|
||||||
|
return None
|
||||||
|
return zip_file.open(name)
|
||||||
|
except (ValueError, OSError):
|
||||||
return None
|
return None
|
||||||
if "/../" in name or name.endswith("/.."):
|
|
||||||
return None
|
|
||||||
if "\\" in name:
|
|
||||||
return None
|
|
||||||
return zip_file.open(name)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_markdown_whitespace(content: str) -> str:
|
def normalize_markdown_whitespace(content: str) -> str:
|
||||||
@@ -171,6 +177,13 @@ def search_markdown(
|
|||||||
content: str, pattern: str, context_lines: int = 0
|
content: str, pattern: str, context_lines: int = 0
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""使用正则表达式搜索 markdown 文档,返回匹配结果及其上下文"""
|
"""使用正则表达式搜索 markdown 文档,返回匹配结果及其上下文"""
|
||||||
|
# 边界检查
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if context_lines < 0:
|
||||||
|
raise ValueError("context_lines 必须为非负整数")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
regex = re.compile(pattern)
|
regex = re.compile(pattern)
|
||||||
except re.error:
|
except re.error:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""统一解析调度器,负责根据输入类型选择合适的 reader 进行解析。"""
|
"""统一解析调度器,负责根据输入类型选择合适的 reader 进行解析。"""
|
||||||
|
|
||||||
import os
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ from core.markdown import (
|
|||||||
remove_markdown_images,
|
remove_markdown_images,
|
||||||
)
|
)
|
||||||
from readers import BaseReader
|
from readers import BaseReader
|
||||||
from utils import detect_file_type, is_html_file, is_url
|
|
||||||
|
|
||||||
|
|
||||||
def parse_input(
|
def parse_input(
|
||||||
@@ -18,7 +17,7 @@ def parse_input(
|
|||||||
readers: List[BaseReader],
|
readers: List[BaseReader],
|
||||||
) -> Tuple[Optional[str], List[str]]:
|
) -> Tuple[Optional[str], List[str]]:
|
||||||
"""
|
"""
|
||||||
统一解析入口函数,根据输入类型自动选择合适的 reader。
|
统一解析入口函数,遍历 readers 选择合适的 reader 进行解析。
|
||||||
|
|
||||||
返回: (content, failures)
|
返回: (content, failures)
|
||||||
- content: 成功时返回 Markdown 内容,失败时返回 None
|
- content: 成功时返回 Markdown 内容,失败时返回 None
|
||||||
@@ -27,35 +26,11 @@ def parse_input(
|
|||||||
if not input_path:
|
if not input_path:
|
||||||
raise FileDetectionError("输入路径不能为空")
|
raise FileDetectionError("输入路径不能为空")
|
||||||
|
|
||||||
# 检测是否为 URL
|
for reader in readers:
|
||||||
if is_url(input_path):
|
if reader.supports(input_path):
|
||||||
# URL 交给 HTML reader
|
return reader.parse(input_path)
|
||||||
for reader in readers:
|
|
||||||
if hasattr(reader, "download_and_parse"):
|
|
||||||
return reader.download_and_parse(input_path)
|
|
||||||
raise ReaderNotFoundError("未找到支持 URL 下载的 reader")
|
|
||||||
|
|
||||||
# 检测文件是否存在
|
raise ReaderNotFoundError(f"未找到支持的 reader: {input_path}")
|
||||||
if not os.path.exists(input_path):
|
|
||||||
raise FileDetectionError(f"文件不存在: {input_path}")
|
|
||||||
|
|
||||||
# 检测文件类型
|
|
||||||
file_type = detect_file_type(input_path)
|
|
||||||
|
|
||||||
if file_type:
|
|
||||||
# Office/PDF 文件
|
|
||||||
for reader in readers:
|
|
||||||
if reader.supports(input_path):
|
|
||||||
return reader.parse(input_path)
|
|
||||||
raise ReaderNotFoundError(f"未找到支持 {file_type.upper()} 格式的 reader")
|
|
||||||
elif is_html_file(input_path):
|
|
||||||
# HTML 文件
|
|
||||||
for reader in readers:
|
|
||||||
if reader.supports(input_path):
|
|
||||||
return reader.parse(input_path)
|
|
||||||
raise ReaderNotFoundError("未找到支持 HTML 格式的 reader")
|
|
||||||
else:
|
|
||||||
raise FileDetectionError(f"无法识别的文件类型: {input_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def process_content(content: str) -> str:
|
def process_content(content: str) -> str:
|
||||||
@@ -67,7 +42,7 @@ def process_content(content: str) -> str:
|
|||||||
|
|
||||||
def output_result(
|
def output_result(
|
||||||
content: str,
|
content: str,
|
||||||
args: any,
|
args: argparse.Namespace,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""根据命令行参数输出结果"""
|
"""根据命令行参数输出结果"""
|
||||||
if args.count:
|
if args.count:
|
||||||
|
|||||||
62
encoding_detection.py
Normal file
62
encoding_detection.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""文件编码自动检测模块。"""
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def detect_encoding(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
检测文件编码。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(encoding, error): 成功时返回 (编码名称, None),失败时返回 (None, 错误信息)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import chardet
|
||||||
|
except ImportError:
|
||||||
|
return None, "chardet 库未安装"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
raw_data = f.read()
|
||||||
|
result = chardet.detect(raw_data)
|
||||||
|
return result['encoding'], None
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"编码检测失败: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def read_text_file(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
读取文本文件,自动检测编码。
|
||||||
|
|
||||||
|
首先使用 chardet 检测编码,如果失败则尝试配置的回退编码列表。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(content, error): 成功时返回 (文件内容, None),失败时返回 (None, 错误信息)
|
||||||
|
"""
|
||||||
|
# 尝试使用 chardet 检测编码
|
||||||
|
encoding, error = detect_encoding(file_path)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
# chardet 失败,使用回退编码列表
|
||||||
|
for enc in Config.FALLBACK_ENCODINGS:
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding=enc) as f:
|
||||||
|
return f.read(), None
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return None, "无法识别文件编码"
|
||||||
|
|
||||||
|
# 使用检测到的编码读取文件
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding=encoding) as f:
|
||||||
|
return f.read(), None
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"读取文件失败: {str(e)}"
|
||||||
@@ -12,7 +12,13 @@ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
|||||||
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
||||||
os.environ["TQDM_DISABLE"] = "1"
|
os.environ["TQDM_DISABLE"] = "1"
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
logging.disable(logging.WARNING)
|
|
||||||
|
# 配置日志系统,只输出 ERROR 级别
|
||||||
|
logging.basicConfig(level=logging.ERROR, format='%(levelname)s: %(message)s')
|
||||||
|
|
||||||
|
# 设置第三方库日志等级
|
||||||
|
logging.getLogger('docling').setLevel(logging.ERROR)
|
||||||
|
logging.getLogger('unstructured').setLevel(logging.ERROR)
|
||||||
|
|
||||||
from core import (
|
from core import (
|
||||||
FileDetectionError,
|
FileDetectionError,
|
||||||
|
|||||||
83
openspec/specs/configuration/spec.md
Normal file
83
openspec/specs/configuration/spec.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
统一配置管理能力,提供集中的配置类管理所有配置项。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 统一配置类
|
||||||
|
系统 SHALL 提供统一的配置类,集中管理所有配置项。
|
||||||
|
|
||||||
|
#### Scenario: 配置类在独立文件
|
||||||
|
- **WHEN** 使用配置项
|
||||||
|
- **THEN** 从 config.py 模块导入 Config 类
|
||||||
|
|
||||||
|
#### Scenario: 配置类为简单常量类
|
||||||
|
- **WHEN** 访问配置项
|
||||||
|
- **THEN** 通过 Config.ITEM_NAME 直接访问类属性
|
||||||
|
|
||||||
|
### Requirement: 编码配置
|
||||||
|
系统 SHALL 在配置类中提供编码相关配置项。
|
||||||
|
|
||||||
|
#### Scenario: 提供回退编码列表
|
||||||
|
- **WHEN** 需要回退编码列表
|
||||||
|
- **THEN** 系统提供 Config.FALLBACK_ENCODINGS 配置项
|
||||||
|
|
||||||
|
#### Scenario: 回退编码列表为列表类型
|
||||||
|
- **WHEN** 访问 Config.FALLBACK_ENCODINGS
|
||||||
|
- **THEN** 返回字符串列表,包含编码名称
|
||||||
|
|
||||||
|
#### Scenario: 回退编码列表默认值
|
||||||
|
- **WHEN** 使用默认配置
|
||||||
|
- **THEN** Config.FALLBACK_ENCODINGS 默认为 ['utf-8', 'gbk', 'gb2312', 'latin-1']
|
||||||
|
|
||||||
|
### Requirement: HTML 下载配置
|
||||||
|
系统 SHALL 在配置类中提供 HTML 下载相关配置项。
|
||||||
|
|
||||||
|
#### Scenario: 提供下载超时配置
|
||||||
|
- **WHEN** 需要下载超时设置
|
||||||
|
- **THEN** 系统提供 Config.DOWNLOAD_TIMEOUT 配置项
|
||||||
|
|
||||||
|
#### Scenario: 下载超时为整数秒数
|
||||||
|
- **WHEN** 访问 Config.DOWNLOAD_TIMEOUT
|
||||||
|
- **THEN** 返回整数,表示超时秒数
|
||||||
|
|
||||||
|
#### Scenario: 下载超时默认值
|
||||||
|
- **WHEN** 使用默认配置
|
||||||
|
- **THEN** Config.DOWNLOAD_TIMEOUT 默认为 30 秒
|
||||||
|
|
||||||
|
#### Scenario: 提供 User-Agent 配置
|
||||||
|
- **WHEN** 需要 HTTP User-Agent
|
||||||
|
- **THEN** 系统提供 Config.USER_AGENT 配置项
|
||||||
|
|
||||||
|
#### Scenario: User-Agent 默认值
|
||||||
|
- **WHEN** 使用默认配置
|
||||||
|
- **THEN** Config.USER_AGENT 默认为 "lyxy-document/0.1.0"
|
||||||
|
|
||||||
|
### Requirement: 日志配置
|
||||||
|
系统 SHALL 在配置类中提供日志相关配置项。
|
||||||
|
|
||||||
|
#### Scenario: 提供日志等级配置
|
||||||
|
- **WHEN** 需要日志等级设置
|
||||||
|
- **THEN** 系统提供 Config.LOG_LEVEL 配置项
|
||||||
|
|
||||||
|
#### Scenario: 日志等级为字符串
|
||||||
|
- **WHEN** 访问 Config.LOG_LEVEL
|
||||||
|
- **THEN** 返回字符串,表示日志等级名称
|
||||||
|
|
||||||
|
#### Scenario: 日志等级默认值
|
||||||
|
- **WHEN** 使用默认配置
|
||||||
|
- **THEN** Config.LOG_LEVEL 默认为 "ERROR"
|
||||||
|
|
||||||
|
### Requirement: 配置项类型安全
|
||||||
|
系统 SHALL 确保配置项具有明确的类型,便于 IDE 提供自动补全。
|
||||||
|
|
||||||
|
#### Scenario: 配置项有类型注解
|
||||||
|
- **WHEN** 在 IDE 中访问配置项
|
||||||
|
- **THEN** IDE 提供准确的类型提示和自动补全
|
||||||
|
|
||||||
|
### Requirement: 配置项文档
|
||||||
|
系统 SHALL 为每个配置项提供清晰的注释说明。
|
||||||
|
|
||||||
|
#### Scenario: 配置项有注释
|
||||||
|
- **WHEN** 查看配置类源码
|
||||||
|
- **THEN** 每个配置项都有注释说明其用途和默认值
|
||||||
@@ -16,31 +16,109 @@
|
|||||||
- **THEN** 系统识别输入类型并解析文档
|
- **THEN** 系统识别输入类型并解析文档
|
||||||
|
|
||||||
### Requirement: 输入类型自动识别
|
### Requirement: 输入类型自动识别
|
||||||
系统 SHALL 自动识别输入类型,包括 URL 和本地文件。
|
系统 SHALL 通过遍历所有 reader 并调用其 supports() 方法来识别输入类型,而非在 parse_input() 中进行特殊判断。
|
||||||
|
|
||||||
#### Scenario: 识别 HTTP URL
|
#### Scenario: 遍历 readers 判断支持
|
||||||
- **WHEN** 输入以 `http://` 或 `https://` 开头
|
- **WHEN** 调用 parse_input(input_path, readers)
|
||||||
- **THEN** 系统识别为 URL,使用 html-reader 处理
|
- **THEN** 系统遍历 readers 列表,对每个 reader 调用 supports(input_path)
|
||||||
|
|
||||||
#### Scenario: 识别 DOCX 文件
|
#### Scenario: 找到支持的 reader
|
||||||
- **WHEN** 输入文件扩展名为 .docx 或 .doc,或文件内容为 OOXML Word 格式
|
- **WHEN** 某个 reader 的 supports() 返回 True
|
||||||
- **THEN** 系统识别为 DOCX,使用 docx-reader 处理
|
- **THEN** 系统调用该 reader 的 parse() 方法
|
||||||
|
|
||||||
#### Scenario: 识别 XLSX 文件
|
#### Scenario: 无 reader 支持时抛出异常
|
||||||
- **WHEN** 输入文件扩展名为 .xlsx、.xls 或 .xlsm,或文件内容为 OOXML Excel 格式
|
- **WHEN** 所有 reader 的 supports() 均返回 False
|
||||||
- **THEN** 系统识别为 XLSX,使用 xlsx-reader 处理
|
- **THEN** 系统抛出 ReaderNotFoundError 异常
|
||||||
|
|
||||||
#### Scenario: 识别 PPTX 文件
|
#### Scenario: URL 由 HTML reader 判断
|
||||||
- **WHEN** 输入文件扩展名为 .pptx 或 .ppt,或文件内容为 OOXML PowerPoint 格式
|
- **WHEN** 输入为 URL
|
||||||
- **THEN** 系统识别为 PPTX,使用 pptx-reader 处理
|
- **THEN** HTML reader 的 supports() 返回 True,由其处理
|
||||||
|
|
||||||
#### Scenario: 识别 PDF 文件
|
#### Scenario: 文件类型由各 reader 判断
|
||||||
- **WHEN** 输入文件扩展名为 .pdf,或文件头为 %PDF
|
- **WHEN** 输入为本地文件
|
||||||
- **THEN** 系统识别为 PDF,使用 pdf-reader 处理
|
- **THEN** 各 reader 的 supports() 根据扩展名判断是否支持
|
||||||
|
|
||||||
#### Scenario: 识别 HTML 文件
|
### Requirement: parse_input() 职责简化
|
||||||
- **WHEN** 输入文件扩展名为 .html、.htm 或 .xhtml
|
系统 SHALL 将 parse_input() 简化为纯粹的 reader 遍历和调度器,移除 URL 特殊处理和文件存在检查。
|
||||||
- **THEN** 系统识别为 HTML,使用 html-reader 处理
|
|
||||||
|
#### Scenario: 仅检查输入是否为空
|
||||||
|
- **WHEN** 调用 parse_input(input_path, readers)
|
||||||
|
- **THEN** 系统仅检查 input_path 是否为空,为空则抛出 FileDetectionError
|
||||||
|
|
||||||
|
#### Scenario: 不检查文件是否存在
|
||||||
|
- **WHEN** 调用 parse_input() 时
|
||||||
|
- **THEN** 系统不检查文件是否存在,由各 reader 的 parse() 方法自行检查
|
||||||
|
|
||||||
|
#### Scenario: 不进行文件类型检测
|
||||||
|
- **WHEN** 调用 parse_input() 时
|
||||||
|
- **THEN** 系统不调用 detect_file_type(),由各 reader 的 parse() 方法自行验证
|
||||||
|
|
||||||
|
#### Scenario: 不特殊处理 URL
|
||||||
|
- **WHEN** 输入为 URL 时
|
||||||
|
- **THEN** 系统不使用 hasattr 检查 download_and_parse,而是通过 supports() 和 parse() 统一处理
|
||||||
|
|
||||||
|
### Requirement: BaseReader 接口语义明确
|
||||||
|
系统 SHALL 明确 BaseReader 的 supports() 和 parse() 方法的职责划分。
|
||||||
|
|
||||||
|
#### Scenario: supports() 为轻量检查
|
||||||
|
- **WHEN** 调用 reader.supports(file_path)
|
||||||
|
- **THEN** 方法仅做轻量检查(如扩展名、URL 模式),不访问文件系统
|
||||||
|
|
||||||
|
#### Scenario: parse() 为完整验证
|
||||||
|
- **WHEN** 调用 reader.parse(file_path)
|
||||||
|
- **THEN** 方法进行完整验证(文件存在、格式有效性)并执行解析
|
||||||
|
|
||||||
|
#### Scenario: BaseReader 文档字符串更新
|
||||||
|
- **WHEN** 查看 BaseReader 源码
|
||||||
|
- **THEN** supports() 和 parse() 的文档字符串明确说明职责划分
|
||||||
|
|
||||||
|
### Requirement: 边界检查改进
|
||||||
|
系统 SHALL 对无效参数进行边界检查,抛出明确异常。
|
||||||
|
|
||||||
|
#### Scenario: search_markdown 检查空内容
|
||||||
|
- **WHEN** 调用 search_markdown(content, pattern, context_lines) 且 content 为空
|
||||||
|
- **THEN** 系统返回 None
|
||||||
|
|
||||||
|
#### Scenario: search_markdown 检查负数 context_lines
|
||||||
|
- **WHEN** 调用 search_markdown() 且 context_lines < 0
|
||||||
|
- **THEN** 系统抛出 ValueError 异常,消息为 "context_lines 必须为非负整数"
|
||||||
|
|
||||||
|
### Requirement: safe_open_zip 安全性修复
|
||||||
|
系统 SHALL 使用 pathlib 规范化路径并检查路径遍历攻击。
|
||||||
|
|
||||||
|
#### Scenario: 使用 pathlib 规范化路径
|
||||||
|
- **WHEN** 调用 safe_open_zip(zip_file, name)
|
||||||
|
- **THEN** 系统使用 Path(name).as_posix() 规范化路径
|
||||||
|
|
||||||
|
#### Scenario: 检查父目录引用
|
||||||
|
- **WHEN** 路径包含 ".." 在 Path.parts 中
|
||||||
|
- **THEN** 系统返回 None,拒绝打开
|
||||||
|
|
||||||
|
#### Scenario: 检查绝对路径
|
||||||
|
- **WHEN** 路径为绝对路径
|
||||||
|
- **THEN** 系统返回 None,拒绝打开
|
||||||
|
|
||||||
|
#### Scenario: 处理路径异常
|
||||||
|
- **WHEN** Path() 抛出 ValueError 或 OSError
|
||||||
|
- **THEN** 系统捕获异常并返回 None
|
||||||
|
|
||||||
|
### Requirement: 类型注解修复
|
||||||
|
系统 SHALL 修复类型注解不一致问题。
|
||||||
|
|
||||||
|
#### Scenario: output_result 使用正确类型
|
||||||
|
- **WHEN** 定义 output_result(content, args)
|
||||||
|
- **THEN** args 参数类型注解为 argparse.Namespace
|
||||||
|
|
||||||
|
### Requirement: 日志系统改进
|
||||||
|
系统 SHALL 只输出 ERROR 级别日志,避免干扰 Markdown 输出。
|
||||||
|
|
||||||
|
#### Scenario: 配置日志等级为 ERROR
|
||||||
|
- **WHEN** 系统启动时
|
||||||
|
- **THEN** 配置 logging.basicConfig(level=logging.ERROR)
|
||||||
|
|
||||||
|
#### Scenario: 禁用第三方库日志
|
||||||
|
- **WHEN** 系统启动时
|
||||||
|
- **THEN** 设置第三方库(docling、unstructured 等)日志等级为 ERROR
|
||||||
|
|
||||||
### Requirement: 输出完整 Markdown
|
### Requirement: 输出完整 Markdown
|
||||||
系统 SHALL 能够输出解析后的完整 Markdown 内容。
|
系统 SHALL 能够输出解析后的完整 Markdown 内容。
|
||||||
|
|||||||
88
openspec/specs/encoding-detection/spec.md
Normal file
88
openspec/specs/encoding-detection/spec.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
文件编码自动检测能力,支持多种编码格式的自动识别和回退机制。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 文件编码自动检测
|
||||||
|
系统 SHALL 使用 chardet 库自动检测文件编码,支持多种常见编码格式。
|
||||||
|
|
||||||
|
#### Scenario: 使用 chardet 检测编码
|
||||||
|
- **WHEN** 需要读取文本文件
|
||||||
|
- **THEN** 系统使用 chardet 库检测文件编码
|
||||||
|
|
||||||
|
#### Scenario: 检测成功返回编码
|
||||||
|
- **WHEN** chardet 成功检测编码
|
||||||
|
- **THEN** 系统返回检测到的编码名称
|
||||||
|
|
||||||
|
#### Scenario: chardet 库未安装
|
||||||
|
- **WHEN** chardet 库未安装
|
||||||
|
- **THEN** 系统返回错误信息 "chardet 库未安装"
|
||||||
|
|
||||||
|
#### Scenario: 检测失败返回错误
|
||||||
|
- **WHEN** chardet 检测失败
|
||||||
|
- **THEN** 系统返回错误信息包含失败原因
|
||||||
|
|
||||||
|
### Requirement: 编码回退机制
|
||||||
|
系统 SHALL 在 chardet 检测失败时,按配置的编码列表依次尝试读取文件。
|
||||||
|
|
||||||
|
#### Scenario: chardet 失败时尝试回退编码
|
||||||
|
- **WHEN** chardet 检测失败或返回错误
|
||||||
|
- **THEN** 系统按 Config.FALLBACK_ENCODINGS 列表依次尝试编码
|
||||||
|
|
||||||
|
#### Scenario: 回退编码成功
|
||||||
|
- **WHEN** 某个回退编码成功读取文件
|
||||||
|
- **THEN** 系统返回文件内容
|
||||||
|
|
||||||
|
#### Scenario: 所有编码均失败
|
||||||
|
- **WHEN** 所有回退编码均无法读取文件
|
||||||
|
- **THEN** 系统返回错误信息 "无法识别文件编码"
|
||||||
|
|
||||||
|
### Requirement: 统一文本文件读取接口
|
||||||
|
系统 SHALL 提供统一的文本文件读取函数,自动处理编码检测和回退。
|
||||||
|
|
||||||
|
#### Scenario: 调用 read_text_file 读取文件
|
||||||
|
- **WHEN** 调用 read_text_file(file_path)
|
||||||
|
- **THEN** 系统自动检测编码并返回文件内容
|
||||||
|
|
||||||
|
#### Scenario: 读取成功返回内容
|
||||||
|
- **WHEN** 文件读取成功
|
||||||
|
- **THEN** 系统返回 (content, None)
|
||||||
|
|
||||||
|
#### Scenario: 读取失败返回错误
|
||||||
|
- **WHEN** 文件读取失败
|
||||||
|
- **THEN** 系统返回 (None, error_message)
|
||||||
|
|
||||||
|
### Requirement: 支持常见中文编码
|
||||||
|
系统 SHALL 在回退编码列表中包含常见中文编码格式。
|
||||||
|
|
||||||
|
#### Scenario: 回退编码包含 UTF-8
|
||||||
|
- **WHEN** 使用回退编码列表
|
||||||
|
- **THEN** 列表包含 'utf-8'
|
||||||
|
|
||||||
|
#### Scenario: 回退编码包含 GBK
|
||||||
|
- **WHEN** 使用回退编码列表
|
||||||
|
- **THEN** 列表包含 'gbk'
|
||||||
|
|
||||||
|
#### Scenario: 回退编码包含 GB2312
|
||||||
|
- **WHEN** 使用回退编码列表
|
||||||
|
- **THEN** 列表包含 'gb2312'
|
||||||
|
|
||||||
|
#### Scenario: 回退编码包含 Latin-1
|
||||||
|
- **WHEN** 使用回退编码列表
|
||||||
|
- **THEN** 列表包含 'latin-1'
|
||||||
|
|
||||||
|
### Requirement: 编码检测模块独立
|
||||||
|
系统 SHALL 将编码检测功能实现为独立模块,便于复用。
|
||||||
|
|
||||||
|
#### Scenario: 编码检测在独立文件
|
||||||
|
- **WHEN** 使用编码检测功能
|
||||||
|
- **THEN** 从独立的 encoding_detection.py 模块导入
|
||||||
|
|
||||||
|
#### Scenario: 提供 detect_encoding 函数
|
||||||
|
- **WHEN** 需要仅检测编码不读取内容
|
||||||
|
- **THEN** 系统提供 detect_encoding(file_path) 函数
|
||||||
|
|
||||||
|
#### Scenario: 提供 read_text_file 函数
|
||||||
|
- **WHEN** 需要检测编码并读取内容
|
||||||
|
- **THEN** 系统提供 read_text_file(file_path) 函数
|
||||||
@@ -5,7 +5,7 @@ HTML/URL 文档解析能力,支持多种解析方法。
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: HTML 文档解析
|
### Requirement: HTML 文档解析
|
||||||
系统 SHALL 支持解析 HTML 格式文档和 URL 网页,按优先级尝试多个解析器。
|
系统 SHALL 支持解析 HTML 格式文档和 URL 网页,URL 处理逻辑在 HTML reader 内部统一处理。
|
||||||
|
|
||||||
#### Scenario: 按优先级尝试解析器
|
#### Scenario: 按优先级尝试解析器
|
||||||
- **WHEN** 解析 HTML 内容
|
- **WHEN** 解析 HTML 内容
|
||||||
@@ -20,7 +20,15 @@ HTML/URL 文档解析能力,支持多种解析方法。
|
|||||||
- **THEN** 系统返回失败列表并退出非零状态码
|
- **THEN** 系统返回失败列表并退出非零状态码
|
||||||
|
|
||||||
### Requirement: URL 下载
|
### Requirement: URL 下载
|
||||||
系统 SHALL 支持从 URL 下载网页内容,按优先级尝试多个下载器。
|
系统 SHALL 在 HTML reader 内部处理 URL 下载,而非在 parse_input() 中特殊处理。
|
||||||
|
|
||||||
|
#### Scenario: HTML reader 支持 URL
|
||||||
|
- **WHEN** 调用 HtmlReader.supports(input_path) 且 input_path 为 URL
|
||||||
|
- **THEN** 返回 True
|
||||||
|
|
||||||
|
#### Scenario: HTML reader 解析 URL
|
||||||
|
- **WHEN** 调用 HtmlReader.parse(input_path) 且 input_path 为 URL
|
||||||
|
- **THEN** 系统调用内部的 download_and_parse() 方法
|
||||||
|
|
||||||
#### Scenario: 按优先级尝试下载器
|
#### Scenario: 按优先级尝试下载器
|
||||||
- **WHEN** 输入为 URL
|
- **WHEN** 输入为 URL
|
||||||
@@ -190,3 +198,48 @@ HTML/URL 文档解析能力,支持多种解析方法。
|
|||||||
#### Scenario: html2text 解析器在独立文件
|
#### Scenario: html2text 解析器在独立文件
|
||||||
- **WHEN** 使用 html2text 解析器
|
- **WHEN** 使用 html2text 解析器
|
||||||
- **THEN** 从 readers/html/html2text.py 导入
|
- **THEN** 从 readers/html/html2text.py 导入
|
||||||
|
|
||||||
|
### Requirement: HTML 文件编码检测
|
||||||
|
系统 SHALL 在读取本地 HTML 文件时使用编码自动检测。
|
||||||
|
|
||||||
|
#### Scenario: 读取 HTML 文件使用编码检测
|
||||||
|
- **WHEN** 解析本地 HTML 文件
|
||||||
|
- **THEN** 系统使用 encoding_detection.read_text_file() 读取文件
|
||||||
|
|
||||||
|
#### Scenario: 编码检测成功
|
||||||
|
- **WHEN** 编码检测成功读取文件
|
||||||
|
- **THEN** 系统继续解析 HTML 内容
|
||||||
|
|
||||||
|
#### Scenario: 编码检测失败
|
||||||
|
- **WHEN** 编码检测失败
|
||||||
|
- **THEN** 系统返回错误信息包含编码检测失败原因
|
||||||
|
|
||||||
|
### Requirement: HTML reader 统一处理 URL 和文件
|
||||||
|
系统 SHALL 在 HTML reader 的 parse() 方法中统一处理 URL 和本地文件。
|
||||||
|
|
||||||
|
#### Scenario: parse() 判断输入类型
|
||||||
|
- **WHEN** 调用 HtmlReader.parse(file_path)
|
||||||
|
- **THEN** 系统判断 file_path 是 URL 还是本地文件
|
||||||
|
|
||||||
|
#### Scenario: URL 调用 download_and_parse
|
||||||
|
- **WHEN** file_path 为 URL
|
||||||
|
- **THEN** 系统调用 self.download_and_parse(file_path)
|
||||||
|
|
||||||
|
#### Scenario: 本地文件读取并解析
|
||||||
|
- **WHEN** file_path 为本地文件
|
||||||
|
- **THEN** 系统使用编码检测读取文件,然后解析 HTML 内容
|
||||||
|
|
||||||
|
### Requirement: HTML reader supports() 支持 URL
|
||||||
|
系统 SHALL 在 HTML reader 的 supports() 方法中同时支持 URL 和 HTML 文件扩展名。
|
||||||
|
|
||||||
|
#### Scenario: supports() 识别 URL
|
||||||
|
- **WHEN** 调用 HtmlReader.supports(file_path) 且 file_path 以 http:// 或 https:// 开头
|
||||||
|
- **THEN** 返回 True
|
||||||
|
|
||||||
|
#### Scenario: supports() 识别 HTML 文件
|
||||||
|
- **WHEN** 调用 HtmlReader.supports(file_path) 且 file_path 以 .html 或 .htm 结尾
|
||||||
|
- **THEN** 返回 True
|
||||||
|
|
||||||
|
#### Scenario: supports() 不支持其他类型
|
||||||
|
- **WHEN** 调用 HtmlReader.supports(file_path) 且 file_path 既非 URL 也非 HTML 文件
|
||||||
|
- **THEN** 返回 False
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ version = "0.1.0"
|
|||||||
description = "帮助AI工具读取转换文档到markdown的skill"
|
description = "帮助AI工具读取转换文档到markdown的skill"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"chardet>=5.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
docx = [
|
docx = [
|
||||||
|
|||||||
@@ -16,7 +16,19 @@ class BaseReader(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def supports(self, file_path: str) -> bool:
|
def supports(self, file_path: str) -> bool:
|
||||||
"""判断是否支持给定的文件。"""
|
"""
|
||||||
|
判断是否支持给定的输入(轻量检查)。
|
||||||
|
|
||||||
|
仅做初步判断(如扩展名、URL 模式),不进行完整验证。
|
||||||
|
完整验证(文件存在、格式有效性)在 parse() 中进行。
|
||||||
|
不访问文件系统,不打开文件。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径或 URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 如果可能支持,False 否则
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -24,7 +36,12 @@ class BaseReader(ABC):
|
|||||||
"""
|
"""
|
||||||
解析文件并返回 Markdown 内容。
|
解析文件并返回 Markdown 内容。
|
||||||
|
|
||||||
返回: (content, failures)
|
需要检查文件存在和格式有效性,然后执行实际解析逻辑。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径或 URL
|
||||||
|
|
||||||
|
Returns: (content, failures)
|
||||||
- content: 成功时返回 Markdown 内容,失败时返回 None
|
- content: 成功时返回 Markdown 内容,失败时返回 None
|
||||||
- failures: 各解析器的失败原因列表
|
- failures: 各解析器的失败原因列表
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""DOCX 文件阅读器,支持多种解析方法。"""
|
"""DOCX 文件阅读器,支持多种解析方法。"""
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from readers.base import BaseReader
|
from readers.base import BaseReader
|
||||||
@@ -31,10 +32,19 @@ class DocxReader(BaseReader):
|
|||||||
return [".docx"]
|
return [".docx"]
|
||||||
|
|
||||||
def supports(self, file_path: str) -> bool:
|
def supports(self, file_path: str) -> bool:
|
||||||
return is_valid_docx(file_path)
|
return file_path.endswith('.docx')
|
||||||
|
|
||||||
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
||||||
failures = []
|
failures = []
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return None, ["文件不存在"]
|
||||||
|
|
||||||
|
# 验证文件格式
|
||||||
|
if not is_valid_docx(file_path):
|
||||||
|
return None, ["不是有效的 DOCX 文件"]
|
||||||
|
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
for parser_name, parser_func in PARSERS:
|
for parser_name, parser_func in PARSERS:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import os
|
|||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from readers.base import BaseReader
|
from readers.base import BaseReader
|
||||||
from utils import is_html_file, is_url
|
from utils import is_url
|
||||||
|
import encoding_detection
|
||||||
|
|
||||||
from . import cleaner
|
from . import cleaner
|
||||||
from . import downloader
|
from . import downloader
|
||||||
@@ -30,7 +31,7 @@ class HtmlReader(BaseReader):
|
|||||||
return [".html", ".htm"]
|
return [".html", ".htm"]
|
||||||
|
|
||||||
def supports(self, file_path: str) -> bool:
|
def supports(self, file_path: str) -> bool:
|
||||||
return is_url(file_path) or is_html_file(file_path)
|
return is_url(file_path) or file_path.endswith(('.html', '.htm'))
|
||||||
|
|
||||||
def download_and_parse(self, url: str) -> Tuple[Optional[str], List[str]]:
|
def download_and_parse(self, url: str) -> Tuple[Optional[str], List[str]]:
|
||||||
"""下载 URL 并解析"""
|
"""下载 URL 并解析"""
|
||||||
@@ -69,15 +70,14 @@ class HtmlReader(BaseReader):
|
|||||||
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
||||||
all_failures = []
|
all_failures = []
|
||||||
|
|
||||||
|
# 判断输入类型
|
||||||
if is_url(file_path):
|
if is_url(file_path):
|
||||||
return self.download_and_parse(file_path)
|
return self.download_and_parse(file_path)
|
||||||
|
|
||||||
# 读取 HTML 文件
|
# 读取本地 HTML 文件,使用编码检测
|
||||||
try:
|
html_content, error = encoding_detection.read_text_file(file_path)
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
if error:
|
||||||
html_content = f.read()
|
return None, [f"- {error}"]
|
||||||
except Exception as e:
|
|
||||||
return None, [f"- 读取文件失败: {str(e)}"]
|
|
||||||
|
|
||||||
# 清理 HTML
|
# 清理 HTML
|
||||||
html_content = cleaner.clean_html_content(html_content)
|
html_content = cleaner.clean_html_content(html_content)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""PDF 文件阅读器,支持多种解析方法(OCR 优先)。"""
|
"""PDF 文件阅读器,支持多种解析方法(OCR 优先)。"""
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from readers.base import BaseReader
|
from readers.base import BaseReader
|
||||||
@@ -31,10 +32,19 @@ class PdfReader(BaseReader):
|
|||||||
return [".pdf"]
|
return [".pdf"]
|
||||||
|
|
||||||
def supports(self, file_path: str) -> bool:
|
def supports(self, file_path: str) -> bool:
|
||||||
return is_valid_pdf(file_path)
|
return file_path.endswith('.pdf')
|
||||||
|
|
||||||
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
||||||
failures = []
|
failures = []
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return None, ["文件不存在"]
|
||||||
|
|
||||||
|
# 验证文件格式
|
||||||
|
if not is_valid_pdf(file_path):
|
||||||
|
return None, ["不是有效的 PDF 文件"]
|
||||||
|
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
for parser_name, parser_func in PARSERS:
|
for parser_name, parser_func in PARSERS:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""PPTX 文件阅读器,支持多种解析方法。"""
|
"""PPTX 文件阅读器,支持多种解析方法。"""
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from readers.base import BaseReader
|
from readers.base import BaseReader
|
||||||
@@ -29,10 +30,19 @@ class PptxReader(BaseReader):
|
|||||||
return [".pptx"]
|
return [".pptx"]
|
||||||
|
|
||||||
def supports(self, file_path: str) -> bool:
|
def supports(self, file_path: str) -> bool:
|
||||||
return is_valid_pptx(file_path)
|
return file_path.endswith('.pptx')
|
||||||
|
|
||||||
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
||||||
failures = []
|
failures = []
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return None, ["文件不存在"]
|
||||||
|
|
||||||
|
# 验证文件格式
|
||||||
|
if not is_valid_pptx(file_path):
|
||||||
|
return None, ["不是有效的 PPTX 文件"]
|
||||||
|
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
for parser_name, parser_func in PARSERS:
|
for parser_name, parser_func in PARSERS:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""XLSX 文件阅读器,支持多种解析方法。"""
|
"""XLSX 文件阅读器,支持多种解析方法。"""
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from readers.base import BaseReader
|
from readers.base import BaseReader
|
||||||
@@ -29,10 +30,19 @@ class XlsxReader(BaseReader):
|
|||||||
return [".xlsx"]
|
return [".xlsx"]
|
||||||
|
|
||||||
def supports(self, file_path: str) -> bool:
|
def supports(self, file_path: str) -> bool:
|
||||||
return is_valid_xlsx(file_path)
|
return file_path.endswith('.xlsx')
|
||||||
|
|
||||||
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]:
|
||||||
failures = []
|
failures = []
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return None, ["文件不存在"]
|
||||||
|
|
||||||
|
# 验证文件格式
|
||||||
|
if not is_valid_xlsx(file_path):
|
||||||
|
return None, ["不是有效的 XLSX 文件"]
|
||||||
|
|
||||||
content = None
|
content = None
|
||||||
|
|
||||||
for parser_name, parser_func in PARSERS:
|
for parser_name, parser_func in PARSERS:
|
||||||
|
|||||||
Reference in New Issue
Block a user