Files
contracts-app/site/parser.py
T

243 lines
8.0 KiB
Python

"""
Парсер документов — извлекает ВСЕ элементы без фильтрации и потерь.
Поддерживает: docx, pdf, .doc (через libreoffice), zip.
Возвращает полный слепок документа: абзацы (со стилями) + таблицы (все строки).
"""
import io
import zipfile
import subprocess
import tempfile
import os
def parse(file_bytes: bytes, mime_type: str) -> dict:
"""
Принимает сырые байты файла и MIME-тип.
Возвращает {"elements": [...], "errors": [...]}.
elements — все абзацы и таблицы в порядке документа.
"""
result = {"elements": [], "errors": []}
if mime_type == "application/zip":
return _parse_zip(file_bytes)
if mime_type in ("application/vnd.openxmlformats-officedocument.wordprocessingml.document",):
return _parse_docx(file_bytes)
if mime_type in ("application/msword",):
return _parse_doc(file_bytes)
if mime_type == "application/pdf":
return _parse_pdf(file_bytes)
result["errors"].append(f"unsupported mime_type: {mime_type}")
return result
# ── docx ──
def _parse_docx(file_bytes: bytes) -> dict:
import docx
result = {"elements": [], "errors": []}
try:
doc = docx.Document(io.BytesIO(file_bytes))
# Собираем все параграфы и таблицы в порядке документа
body = doc.element.body
para_idx = 0
table_idx = 0
for child in body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "p":
# Параграф
para = doc.paragraphs[para_idx]
para_idx += 1
if para.text.strip():
result["elements"].append({
"type": "paragraph",
"style": para.style.name if para.style else "",
"text": para.text,
})
elif tag == "tbl":
# Таблица
table = doc.tables[table_idx]
table_idx += 1
rows = []
for row in table.rows:
cells = [cell.text for cell in row.cells]
rows.append(cells)
result["elements"].append({
"type": "table",
"rows": rows,
})
except Exception as e:
result["errors"].append(f"docx parse error: {e}")
return result
# ── .doc (через libreoffice) ──
def _parse_doc(file_bytes: bytes) -> dict:
result = {"elements": [], "errors": []}
try:
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp_doc:
tmp_doc.write(file_bytes)
tmp_doc_path = tmp_doc.name
tmp_dir = tempfile.mkdtemp()
subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmp_dir, tmp_doc_path],
timeout=30, capture_output=True,
)
# Находим получившийся docx
docx_files = [f for f in os.listdir(tmp_dir) if f.endswith(".docx")]
if docx_files:
with open(os.path.join(tmp_dir, docx_files[0]), "rb") as f:
result = _parse_docx(f.read())
else:
result["errors"].append("libreoffice conversion produced no docx")
os.unlink(tmp_doc_path)
for f in os.listdir(tmp_dir):
os.unlink(os.path.join(tmp_dir, f))
os.rmdir(tmp_dir)
except FileNotFoundError:
result["errors"].append("libreoffice not installed")
except Exception as e:
result["errors"].append(f"doc parse error: {e}")
return result
# ── PDF ──
def _parse_pdf(file_bytes: bytes) -> dict:
"""
Извлечь текст и таблицы из PDF через pdfplumber.
Таблицы: только с видимыми рамками (lines). Безрамковые (text)
дают слишком много ложных срабатываний — не используем.
Отбрасываем таблицы где >50% ячеек пусты или где все ячейки
содержат фрагменты одной строки текста.
"""
result = {"elements": [], "errors": []}
try:
import pdfplumber
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
for page in pdf.pages:
pn = page.page_number
# ── Текст: каждая непустая строка → paragraph ──
text = page.extract_text()
if text:
for line in text.split("\n"):
line = line.strip()
if line:
result["elements"].append({
"type": "paragraph",
"style": "",
"text": line,
"page": pn,
})
# ── Таблицы: только с рамками ──
tables = page.extract_tables()
if tables:
for tbl in tables:
if tbl and _table_has_content(tbl):
result["elements"].append({
"type": "table",
"rows": tbl,
"page": pn,
})
except Exception as e:
result["errors"].append(f"pdfplumber: {e}")
return result
def _table_has_content(tbl: list, min_fill_ratio: float = 0.0) -> bool:
"""
Проверить что таблица содержит осмысленные ТАБЛИЧНЫЕ данные.
Отбрасываем:
- Полностью пустые
- Одна строка (обычно это текст, не таблица)
- Менее 40% ячеек заполнены
"""
if not tbl or not tbl[0]:
return False
# Одна строка — не таблица, а текст
if len(tbl) <= 1:
return False
total = 0
filled = 0
for row in tbl:
if not row:
continue
for cell in row:
total += 1
cell_str = str(cell).strip() if cell else ""
if cell_str:
filled += 1
if filled == 0:
return False
# Не менее 40% ячеек должны быть заполнены
if total > 0 and filled / total < 0.4:
return False
return True
# ── ZIP ──
def _parse_zip(file_bytes: bytes) -> dict:
result = {"elements": [], "errors": [], "files": []}
try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
for name in zf.namelist():
if name.endswith("/"):
continue
file_data = zf.read(name)
ext = name.lower().split(".")[-1] if "." in name else ""
mime_map = {
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc": "application/msword",
"pdf": "application/pdf",
}
mime = mime_map.get(ext, "")
if mime:
parsed = parse(file_data, mime)
result["files"].append({
"filename": name,
"elements": parsed["elements"],
"errors": parsed["errors"],
})
else:
result["files"].append({
"filename": name,
"elements": [],
"errors": [f"unsupported extension: {ext}"],
})
except Exception as e:
result["errors"].append(f"zip parse error: {e}")
return result