- xlsx, docx, pptx, odt, ods, odp, odoc, osheet 지원 - LibreOffice --convert-to txt로 텍스트 추출 (60s timeout) - 추가 의존성 없음 (Docker에 이미 설치된 LibreOffice 사용) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
111 lines
4.2 KiB
Python
111 lines
4.2 KiB
Python
"""텍스트 추출 워커 — kordoc / LibreOffice / 직접 읽기"""
|
|
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.config import settings
|
|
from core.utils import setup_logger
|
|
from models.document import Document
|
|
|
|
logger = setup_logger("extract_worker")
|
|
|
|
# kordoc으로 파싱 가능한 포맷
|
|
KORDOC_FORMATS = {"hwp", "hwpx", "pdf"}
|
|
# 직접 읽기 가능한 텍스트 포맷
|
|
TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
|
|
# LibreOffice로 텍스트 추출 가능한 포맷
|
|
OFFICE_FORMATS = {"xlsx", "xls", "docx", "doc", "pptx", "ppt", "odt", "ods", "odp", "odoc", "osheet"}
|
|
# OCR 필요 이미지 포맷 (Phase 2)
|
|
IMAGE_FORMATS = {"jpg", "jpeg", "png", "tiff", "tif", "bmp", "gif"}
|
|
|
|
EXTRACTOR_VERSION = "kordoc@1.7"
|
|
|
|
|
|
async def process(document_id: int, session: AsyncSession) -> None:
|
|
"""문서 텍스트 추출"""
|
|
doc = await session.get(Document, document_id)
|
|
if not doc:
|
|
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
|
|
|
fmt = doc.file_format.lower()
|
|
full_path = Path(settings.nas_mount_path) / doc.file_path
|
|
|
|
# 텍스트 파일 — 직접 읽기
|
|
if fmt in TEXT_FORMATS:
|
|
if not full_path.exists():
|
|
raise FileNotFoundError(f"파일 없음: {full_path}")
|
|
text = full_path.read_text(encoding="utf-8", errors="replace")
|
|
doc.extracted_text = text
|
|
doc.extracted_at = datetime.now(timezone.utc)
|
|
doc.extractor_version = "direct_read"
|
|
logger.info(f"[텍스트] {doc.file_path} ({len(text)}자)")
|
|
return
|
|
|
|
# 이미지 — 스킵 (Phase 2 OCR)
|
|
if fmt in IMAGE_FORMATS:
|
|
doc.extracted_text = ""
|
|
doc.extracted_at = datetime.now(timezone.utc)
|
|
doc.extractor_version = "skip_image"
|
|
logger.info(f"[이미지] {doc.file_path} — OCR 미구현, 스킵")
|
|
return
|
|
|
|
# kordoc 파싱 (HWP/HWPX/PDF)
|
|
if fmt in KORDOC_FORMATS:
|
|
# 컨테이너 내부 경로: /documents/{file_path}
|
|
container_path = f"/documents/{doc.file_path}"
|
|
async with httpx.AsyncClient(timeout=60) as client:
|
|
resp = await client.post(
|
|
f"{settings.kordoc_endpoint}/parse",
|
|
json={"filePath": container_path},
|
|
)
|
|
|
|
if resp.status_code == 404:
|
|
raise FileNotFoundError(f"kordoc: 파일 없음 — {container_path}")
|
|
if resp.status_code == 422:
|
|
raise ValueError(f"kordoc: 파싱 실패 — {resp.json().get('error', 'unknown')}")
|
|
resp.raise_for_status()
|
|
|
|
data = resp.json()
|
|
doc.extracted_text = data.get("markdown", "")
|
|
doc.extracted_at = datetime.now(timezone.utc)
|
|
doc.extractor_version = EXTRACTOR_VERSION
|
|
logger.info(f"[kordoc] {doc.file_path} ({len(doc.extracted_text)}자)")
|
|
return
|
|
|
|
# 오피스 포맷 — LibreOffice 텍스트 변환
|
|
if fmt in OFFICE_FORMATS:
|
|
if not full_path.exists():
|
|
raise FileNotFoundError(f"파일 없음: {full_path}")
|
|
|
|
tmp_dir = Path("/tmp/extract_work")
|
|
tmp_dir.mkdir(exist_ok=True)
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["libreoffice", "--headless", "--convert-to", "txt:Text", "--outdir", str(tmp_dir), str(full_path)],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
txt_file = tmp_dir / f"{full_path.stem}.txt"
|
|
if txt_file.exists():
|
|
text = txt_file.read_text(encoding="utf-8", errors="replace")
|
|
doc.extracted_text = text[:15000]
|
|
doc.extracted_at = datetime.now(timezone.utc)
|
|
doc.extractor_version = "libreoffice"
|
|
txt_file.unlink()
|
|
logger.info(f"[LibreOffice] {doc.file_path} ({len(text)}자)")
|
|
return
|
|
else:
|
|
raise RuntimeError(f"LibreOffice 변환 결과물 없음: {result.stderr[:200]}")
|
|
except subprocess.TimeoutExpired:
|
|
raise RuntimeError(f"LibreOffice 텍스트 추출 timeout (60s)")
|
|
|
|
# 미지원 포맷
|
|
doc.extracted_text = ""
|
|
doc.extracted_at = datetime.now(timezone.utc)
|
|
doc.extractor_version = f"unsupported_{fmt}"
|
|
logger.warning(f"[미지원] {doc.file_path} (format={fmt})")
|