Files
QMDSearch/app/core/file_parser.py
T
kplam 2ab8b56a01 feat: 完成全量功能开发,包括前端管理后台与后端服务优化
此提交实现了完整的知识库管理系统:
1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面
2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换
3. 调整默认嵌入模型配置为本地bge-m3模式
4. 优化入库任务去重逻辑与缓存清理机制
5. 完善Docker镜像构建与docker-compose部署配置
6. 修复多项测试用例与兼容性问题
7. 新增运行时配置API,支持动态调整系统参数
2026-07-31 12:05:25 +08:00

468 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多格式文件文本提取:按扩展名分发到对应解析器(插件化)
支持的扩展名:
- .txt / .mdUTF-8 解码(errors="replace" 兜底)
- .html / .htm:标准库 html.parser 剥离标签提取可见文本
- .pdfPDF 文本层提取插件(默认 pypdf);文本层为空(扫描件)时降级到 OCR 插件
- .docxDOCX 解析插件(默认 python-docx
插件化设计:
- PdfTextExtractor / DocxParser / OcrEngine 三个 Protocol
- 每种插件类型有注册表 + 默认实现 + 工厂
- 工厂根据 runtime_settings.parsers 选择具体插件
- 未注册或依赖缺失时降级到默认插件并告警
未识别扩展名抛 ValueError("不支持的文件类型: {ext}")
解析异常统一包装为 ValueError("文件解析失败: {detail}")。
"""
from __future__ import annotations
import io
from collections.abc import Callable
from html.parser import HTMLParser
from pathlib import Path
from typing import Any, Protocol, runtime_checkable
import structlog
from app.config import settings
from app.core.runtime_settings import get_runtime_settings
logger = structlog.get_logger()
# ---------------------------------------------------------------------------- #
# HTML / Text 解析(无插件化需求,保留原实现)
# ---------------------------------------------------------------------------- #
class _VisibleTextExtractor(HTMLParser):
"""HTMLParser 子类:累积可见文本,跳过 script/style 内容"""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._skip_depth = 0 # 在 script/style 标签内时 > 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag.lower() in {"script", "style"}:
self._skip_depth += 1
def handle_endtag(self, tag: str) -> None:
if tag.lower() in {"script", "style"} and self._skip_depth > 0:
self._skip_depth -= 1
def handle_data(self, data: str) -> None:
if self._skip_depth == 0:
self._parts.append(data)
def get_text(self) -> str:
text = " ".join(self._parts)
return " ".join(text.split())
def _decode_html(content: bytes) -> str:
try:
return content.decode("utf-8-sig")
except UnicodeDecodeError:
return content.decode("latin-1", errors="replace")
def _parse_text(content: bytes) -> str:
return content.decode("utf-8", errors="replace")
def _parse_html(content: bytes) -> str:
parser = _VisibleTextExtractor()
parser.feed(_decode_html(content))
parser.close()
return parser.get_text()
# ---------------------------------------------------------------------------- #
# 插件协议
# ---------------------------------------------------------------------------- #
@runtime_checkable
class PdfTextExtractor(Protocol):
"""PDF 文本层提取插件协议"""
def extract_text(self, content: bytes) -> str:
"""从 PDF 二进制内容提取文本层;无文本层返回空字符串"""
...
@runtime_checkable
class DocxParser(Protocol):
"""DOCX 解析插件协议"""
def extract_text(self, content: bytes) -> str:
"""从 DOCX 二进制内容提取段落文本"""
...
@runtime_checkable
class OcrEngine(Protocol):
"""OCR 引擎插件协议"""
def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
"""对 PDF 跑 OCR,返回识别文本;失败返回空字符串"""
...
# ---------------------------------------------------------------------------- #
# 默认 PDF 插件:pypdf
# ---------------------------------------------------------------------------- #
class PypdfTextExtractor:
"""pypdf 文本层提取(默认)"""
def extract_text(self, content: bytes) -> str:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
parts: list[str] = []
for page in reader.pages:
text = page.extract_text() or ""
if text:
parts.append(text)
return "\n".join(parts).strip()
# ---------------------------------------------------------------------------- #
# 备选 PDF 插件:pdfplumberlazy import,依赖缺失时不可用)
# ---------------------------------------------------------------------------- #
class PdfplumberTextExtractor:
"""pdfplumber 文本层提取(备选,对复杂排版更友好)"""
def extract_text(self, content: bytes) -> str:
import pdfplumber # type: ignore[import-untyped]
parts: list[str] = []
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
if text:
parts.append(text)
return "\n".join(parts).strip()
# ---------------------------------------------------------------------------- #
# 默认 DOCX 插件:python-docx
# ---------------------------------------------------------------------------- #
class PythonDocxParser:
"""python-docx 段落提取(默认)"""
def extract_text(self, content: bytes) -> str:
from docx import Document # type: ignore[import-untyped]
document = Document(io.BytesIO(content))
parts = [p.text for p in document.paragraphs if p.text and p.text.strip()]
return "\n".join(parts).strip()
# ---------------------------------------------------------------------------- #
# 默认 OCR 插件:rapidocr-onnxruntime + pypdfium2
# ---------------------------------------------------------------------------- #
class RapidocrOcrEngine:
"""rapidocr-onnxruntime OCR 引擎(默认)
流程:pypdfium2 渲染每页为 PIL Image → rapidocr 识别 → 拼接。
类级懒加载单例(_engine/_unavailable 为类属性,跨实例共享),
依赖缺失时降级返回空文本。
"""
_engine: Any | None = None
_unavailable: bool = False
def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
if self._unavailable:
return ""
# 1. 懒加载 OCR 引擎(类级单例,跨实例共享)
if self._engine is None:
try:
from rapidocr_onnxruntime import RapidOCR
RapidocrOcrEngine._engine = RapidOCR()
logger.info("PDF OCR 引擎已初始化", dpi=dpi)
except Exception:
RapidocrOcrEngine._unavailable = True
logger.warning(
"OCR 依赖不可用,扫描件 PDF 将无法提取文本", exc_info=True
)
return ""
# 2. 渲染并识别
try:
import pypdfium2 as pdfium
scale = max(1.0, dpi / 72.0)
pdf = pdfium.PdfDocument(io.BytesIO(content))
total = min(len(pdf), max(1, max_pages))
page_texts: list[str] = []
for i in range(total):
page = pdf[i]
pil_image = page.render(scale=scale).to_pil()
result, _ = self._engine(pil_image)
if result:
lines = [
item[1]
for item in result
if item and len(item) >= 2 and item[1]
]
if lines:
page_texts.append("\n".join(lines))
pdf.close()
return "\n".join(page_texts).strip()
except Exception as exc:
logger.warning(
"PDF OCR 失败,降级返回空文本", error=str(exc), exc_info=True
)
return ""
# ---------------------------------------------------------------------------- #
# 备选 OCR 插件:tesseractlazy import,依赖缺失时不可用)
# ---------------------------------------------------------------------------- #
class TesseractOcrEngine:
"""pytesseract + pdfium OCR(备选)
需要系统安装 tesseract 二进制与语言包。
类级 _unavailable 单例,跨实例共享。
"""
_unavailable: bool = False
def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
if self._unavailable:
return ""
try:
import pypdfium2 as pdfium
import pytesseract # type: ignore[import-untyped]
from PIL import Image # type: ignore[import-untyped]
except Exception:
TesseractOcrEngine._unavailable = True
logger.warning("tesseract 依赖不可用,降级返回空文本", exc_info=True)
return ""
try:
scale = max(1.0, dpi / 72.0)
pdf = pdfium.PdfDocument(io.BytesIO(content))
total = min(len(pdf), max(1, max_pages))
page_texts: list[str] = []
for i in range(total):
page = pdf[i]
pil_image = page.render(scale=scale).to_pil()
text = pytesseract.image_to_string(pil_image, lang="chi_sim+eng")
if text:
page_texts.append(text)
pdf.close()
return "\n".join(page_texts).strip()
except Exception as exc:
logger.warning(
"tesseract OCR 失败,降级返回空文本", error=str(exc), exc_info=True
)
return ""
class NoopOcrEngine:
"""关闭 OCR 的占位插件(strategy=none 时使用)"""
def ocr_pdf(self, content: bytes, *, max_pages: int, dpi: int) -> str:
return ""
# ---------------------------------------------------------------------------- #
# 插件注册表
# ---------------------------------------------------------------------------- #
_PDF_PLUGINS: dict[str, type[PdfTextExtractor]] = {
"pypdf": PypdfTextExtractor,
"pdfplumber": PdfplumberTextExtractor,
}
_DOCX_PLUGINS: dict[str, type[DocxParser]] = {
"python_docx": PythonDocxParser,
}
_OCR_PLUGINS: dict[str, type[OcrEngine]] = {
"rapidocr": RapidocrOcrEngine,
"tesseract": TesseractOcrEngine,
"none": NoopOcrEngine,
}
def list_pdf_plugins() -> list[str]:
return list(_PDF_PLUGINS.keys())
def list_docx_plugins() -> list[str]:
return list(_DOCX_PLUGINS.keys())
def list_ocr_plugins() -> list[str]:
return list(_OCR_PLUGINS.keys())
# ---------------------------------------------------------------------------- #
# 工厂
# ---------------------------------------------------------------------------- #
# 进程级插件单例缓存
_pdf_plugin_cache: dict[str, PdfTextExtractor] = {}
_docx_plugin_cache: dict[str, DocxParser] = {}
_ocr_plugin_cache: dict[str, OcrEngine] = {}
def _get_pdf_plugin() -> PdfTextExtractor:
rt = get_runtime_settings()
name = rt.parsers.pdf.plugin or "pypdf"
if name in _pdf_plugin_cache:
return _pdf_plugin_cache[name]
cls = _PDF_PLUGINS.get(name)
if cls is None:
logger.warning("未知 PDF 插件,降级到 pypdf", requested=name)
cls = PypdfTextExtractor
name = "pypdf"
instance = cls()
_pdf_plugin_cache[name] = instance
return instance
def _get_docx_plugin() -> DocxParser:
rt = get_runtime_settings()
name = rt.parsers.docx.plugin or "python_docx"
if name in _docx_plugin_cache:
return _docx_plugin_cache[name]
cls = _DOCX_PLUGINS.get(name)
if cls is None:
logger.warning("未知 DOCX 插件,降级到 python_docx", requested=name)
cls = PythonDocxParser
name = "python_docx"
instance = cls()
_docx_plugin_cache[name] = instance
return instance
def _get_ocr_plugin() -> OcrEngine:
rt = get_runtime_settings()
name = rt.parsers.ocr.plugin or "rapidocr"
if name in _ocr_plugin_cache:
return _ocr_plugin_cache[name]
cls = _OCR_PLUGINS.get(name)
if cls is None:
logger.warning("未知 OCR 插件,降级到 rapidocr", requested=name)
cls = RapidocrOcrEngine
name = "rapidocr"
instance = cls()
_ocr_plugin_cache[name] = instance
return instance
def invalidate_parser_plugin_cache(kind: str | None = None) -> None:
"""清除插件缓存(runtime_settings 更新后调用)"""
if kind is None or kind == "pdf":
_pdf_plugin_cache.clear()
if kind is None or kind == "docx":
_docx_plugin_cache.clear()
if kind is None or kind == "ocr":
_ocr_plugin_cache.clear()
# 重置 RapidocrOcrEngine 类级标记,允许重新初始化
RapidocrOcrEngine._engine = None
RapidocrOcrEngine._unavailable = False
TesseractOcrEngine._unavailable = False
# ---------------------------------------------------------------------------- #
# 顶层解析函数
# ---------------------------------------------------------------------------- #
def _parse_pdf(content: bytes) -> str:
"""PDF 解析:优先文本层插件;文本层为空时降级 OCR 插件"""
rt = get_runtime_settings()
pdf_plugin = _get_pdf_plugin()
try:
text_layer = pdf_plugin.extract_text(content)
except Exception as exc:
logger.warning(
"PDF 文本层提取失败,尝试 OCR 降级",
plugin=rt.parsers.pdf.plugin,
error=str(exc),
exc_info=True,
)
text_layer = ""
if text_layer:
return text_layer
# 文本层为空 → OCR 降级
if not settings.pdf_ocr_enabled:
return ""
ocr_plugin = _get_ocr_plugin()
return ocr_plugin.ocr_pdf(
content,
max_pages=rt.parsers.ocr.params.get("max_pages", settings.pdf_ocr_max_pages),
dpi=rt.parsers.ocr.params.get("dpi", settings.pdf_ocr_dpi),
)
def _parse_docx(content: bytes) -> str:
"""DOCX 解析"""
return _get_docx_plugin().extract_text(content)
# 扩展名 → 解析函数映射
_PARSERS: dict[str, Callable[[bytes], str]] = {
".txt": _parse_text,
".md": _parse_text,
".html": _parse_html,
".htm": _parse_html,
".pdf": _parse_pdf,
".docx": _parse_docx,
}
def parse_file(filename: str, content: bytes) -> str:
"""按扩展名分发解析器提取文本
Args:
filename: 文件名(用于判定扩展名)
content: 文件二进制内容
Returns:
提取的纯文本
Raises:
ValueError: 未识别扩展名 → "不支持的文件类型: {ext}"
解析失败 → "文件解析失败: {detail}"(保留原异常链)
"""
ext = Path(filename).suffix.lower()
parser = _PARSERS.get(ext)
if parser is None:
raise ValueError(f"不支持的文件类型: {ext or '(无扩展名)'}")
try:
return parser(content)
except ValueError:
raise
except Exception as exc:
raise ValueError(f"文件解析失败: {exc}") from exc
def supported_extensions() -> set[str]:
"""返回当前支持的扩展名集合(含点号,全小写)"""
return set(_PARSERS.keys())