Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

308 lines
12 KiB
Python

"""PaddleOCR API jobs parser."""
from __future__ import annotations
import json
import mimetypes
import os
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import requests
from yuxi.knowledge.parser.base import BaseDocumentProcessor, DocumentParserException
from yuxi.storage.minio import get_minio_client
from yuxi.utils import logger
DEFAULT_PADDLEOCR_API_URL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
PADDLEOCR_SUPPORTED_EXTENSIONS = [".pdf", ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"]
class PaddleOCRAPIParser(BaseDocumentProcessor):
"""Base parser for PaddleOCR cloud jobs API."""
model = ""
service_name = ""
default_optional_payload: dict[str, bool] = {}
def __init__(self, api_token: str | None = None, api_url: str | None = None):
self.api_token = api_token or os.getenv("PADDLEOCR_API_TOKEN")
self.api_url = (api_url or os.getenv("PADDLEOCR_API_URL") or DEFAULT_PADDLEOCR_API_URL).rstrip("/")
def get_service_name(self) -> str:
return self.service_name
def get_supported_extensions(self) -> list[str]:
return PADDLEOCR_SUPPORTED_EXTENSIONS
def check_health(self) -> dict[str, Any]:
if not self.api_token:
return {
"status": "unavailable",
"message": "PADDLEOCR_API_TOKEN 未配置",
"details": {"api_url": self.api_url, "model": self.model},
}
return {
"status": "configured",
"message": "PaddleOCR API token 已配置,将在解析时验证",
"details": {"api_url": self.api_url, "model": self.model},
}
def process_file(self, file_path: str, params: dict[str, Any] | None = None) -> str:
if not os.path.exists(file_path) and not file_path.startswith(("http://", "https://")):
raise DocumentParserException(f"文件不存在: {file_path}", self.get_service_name(), "file_not_found")
file_ext = self._file_extension(file_path)
if file_ext and not self.supports_file_type(file_ext):
raise DocumentParserException(
f"不支持的文件类型: {file_ext}", self.get_service_name(), "unsupported_file_type"
)
self._require_api_token()
params = params or {}
start_time = time.time()
try:
logger.info(f"PaddleOCR API 开始处理: {Path(file_path).name} ({self.model})")
job_id = self._submit_job(file_path, params)
result_url = self._poll_job_result(
job_id=job_id,
poll_interval_seconds=float(params.get("poll_interval_seconds") or 5),
max_wait_seconds=float(params.get("max_wait_seconds") or 600),
)
rows = self._download_jsonl(result_url)
text = self._extract_markdown(rows, params)
processing_time = time.time() - start_time
logger.info(
f"PaddleOCR API 处理成功: {Path(file_path).name} ({self.model}) - "
f"{len(text)} 字符 ({processing_time:.2f}s)"
)
return text
except DocumentParserException:
raise
except Exception as exc:
processing_time = time.time() - start_time
error_msg = f"PaddleOCR API 处理失败: {exc}"
logger.error(f"{error_msg} ({processing_time:.2f}s)")
raise DocumentParserException(error_msg, self.get_service_name(), "processing_failed") from exc
def _require_api_token(self) -> None:
if not self.api_token:
raise DocumentParserException("PADDLEOCR_API_TOKEN 未配置", self.get_service_name(), "missing_api_token")
def _headers(self) -> dict[str, str]:
return {"Authorization": f"bearer {self.api_token}"}
def _file_extension(self, file_path: str) -> str:
if file_path.startswith(("http://", "https://")):
return Path(urlparse(file_path).path).suffix.lower()
return Path(file_path).suffix.lower()
def _resolve_optional_payload(self, params: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = dict(self.default_optional_payload)
overrides = params.get("optional_payload")
if isinstance(overrides, dict):
for key in payload:
if key in overrides:
payload[key] = overrides[key]
return payload
def _submit_job(self, file_path: str, params: dict[str, Any]) -> str:
optional_payload = self._resolve_optional_payload(params)
headers = self._headers()
if file_path.startswith(("http://", "https://")):
response = requests.post(
self.api_url,
headers={**headers, "Content-Type": "application/json"},
json={"fileUrl": file_path, "model": self.model, "optionalPayload": optional_payload},
timeout=60,
)
else:
with open(file_path, "rb") as file:
response = requests.post(
self.api_url,
headers=headers,
data={"model": self.model, "optionalPayload": json.dumps(optional_payload, ensure_ascii=False)},
files={"file": file},
timeout=60,
)
if response.status_code != 200:
raise DocumentParserException(
f"提交 PaddleOCR 任务失败: HTTP {response.status_code} {response.text}",
self.get_service_name(),
"submit_failed",
)
body = response.json()
if body.get("code") not in (None, 0):
raise DocumentParserException(
f"提交 PaddleOCR 任务失败: {body.get('msg', '未知错误')}",
self.get_service_name(),
f"api_error_{body.get('code')}",
)
job_id = (body.get("data") or {}).get("jobId")
if not job_id:
raise DocumentParserException(
"提交 PaddleOCR 任务后未返回 jobId",
self.get_service_name(),
"missing_job_id",
)
return str(job_id)
def _poll_job_result(self, job_id: str, poll_interval_seconds: float, max_wait_seconds: float) -> str:
start_time = time.time()
while time.time() - start_time < max_wait_seconds:
response = requests.get(f"{self.api_url}/{job_id}", headers=self._headers(), timeout=30)
if response.status_code != 200:
raise DocumentParserException(
f"查询 PaddleOCR 任务失败: HTTP {response.status_code} {response.text}",
self.get_service_name(),
"status_query_failed",
)
body = response.json()
data = body.get("data") or {}
state = data.get("state")
if state == "done":
json_url = ((data.get("resultUrl") or {}).get("jsonUrl") or "").strip()
if not json_url:
raise DocumentParserException(
"PaddleOCR 任务完成但未返回 jsonUrl", self.get_service_name(), "missing_result_url"
)
return json_url
if state == "failed":
error_msg = data.get("errorMsg") or "未知错误"
raise DocumentParserException(f"PaddleOCR 任务失败: {error_msg}", self.get_service_name(), "job_failed")
if state not in {"pending", "running"}:
raise DocumentParserException(
f"PaddleOCR 任务状态异常: {state}", self.get_service_name(), "unknown_job_state"
)
time.sleep(poll_interval_seconds)
raise DocumentParserException("PaddleOCR 任务处理超时", self.get_service_name(), "timeout")
def _download_jsonl(self, json_url: str) -> list[dict[str, Any]]:
response = requests.get(json_url, timeout=60)
if response.status_code != 200:
raise DocumentParserException(
f"下载 PaddleOCR 结果失败: HTTP {response.status_code} {response.text}",
self.get_service_name(),
"download_failed",
)
rows: list[dict[str, Any]] = []
for line in response.text.splitlines():
line = line.strip()
if not line:
continue
rows.append(json.loads(line))
if not rows:
raise DocumentParserException("PaddleOCR 结果为空", self.get_service_name(), "empty_result")
return rows
def _extract_markdown(self, rows: list[dict[str, Any]], params: dict[str, Any]) -> str:
raise NotImplementedError
def _upload_markdown_image(self, image_url: str, image_path: str, params: dict[str, Any]) -> str:
response = requests.get(image_url, timeout=60)
if response.status_code != 200:
raise DocumentParserException(
f"下载 PaddleOCR Markdown 图片失败: HTTP {response.status_code}",
self.get_service_name(),
"image_download_failed",
)
image_bucket = params.get("image_bucket") or "public"
image_prefix = str(params.get("image_prefix") or "unknown/kb-images").strip("/") or "unknown/kb-images"
filename = Path(image_path).name or "paddleocr_image"
suffix = Path(filename).suffix
if not suffix:
content_type = response.headers.get("Content-Type") or ""
suffix = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".jpg"
filename = f"{filename}{suffix}"
object_name = f"{image_prefix}/{int(time.time() * 1000000)}_{filename}"
minio_client = get_minio_client()
minio_client.ensure_bucket_exists(image_bucket)
upload_result = minio_client.upload_file(
bucket_name=image_bucket,
object_name=object_name,
data=response.content,
)
return upload_result.url
class PaddleOCRVLParser(PaddleOCRAPIParser):
"""PaddleOCR-VL parser that returns layout Markdown."""
model = "PaddleOCR-VL-1.6"
service_name = "paddleocr_vl_1_6"
default_optional_payload = {
"useDocOrientationClassify": False,
"useDocUnwarping": False,
"useChartRecognition": False,
}
def _extract_markdown(self, rows: list[dict[str, Any]], params: dict[str, Any]) -> str:
markdown_parts: list[str] = []
for row in rows:
result = row.get("result") or {}
for item in result.get("layoutParsingResults") or []:
markdown = item.get("markdown") or {}
text = markdown.get("text")
if not isinstance(text, str):
continue
for image_path, image_url in (markdown.get("images") or {}).items():
if not image_path or not image_url:
continue
uploaded_url = self._upload_markdown_image(str(image_url), str(image_path), params)
text = text.replace(f"]({image_path})", f"]({uploaded_url})")
text = text.replace(str(image_url), uploaded_url)
if text.strip():
markdown_parts.append(text.strip())
return "\n\n".join(markdown_parts).strip()
class PaddleOCRPPOCRv6Parser(PaddleOCRAPIParser):
"""PP-OCRv6 parser that returns plain OCR text."""
model = "PP-OCRv6"
service_name = "paddleocr_pp_ocrv6"
default_optional_payload = {
"useDocOrientationClassify": False,
"useDocUnwarping": False,
"useTextlineOrientation": False,
}
def _extract_markdown(self, rows: list[dict[str, Any]], params: dict[str, Any]) -> str:
lines: list[str] = []
for row in rows:
result = row.get("result") or {}
for item in result.get("ocrResults") or []:
pruned_result = item.get("prunedResult") or {}
rec_texts = pruned_result.get("rec_texts") or []
for text in rec_texts:
if isinstance(text, str) and text.strip():
lines.append(text.strip())
return "\n".join(lines).strip()