Files
drhider/drhider/extractor.py
T

358 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.
"""
Извлечение текста из документов — все форматы → Markdown.
Поддерживает:
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
- .txt и прочие → как есть
Также содержит:
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
"""
import io
import os
import zipfile
import tempfile
import shutil
import subprocess
import logging
from typing import List, Tuple
log = logging.getLogger("drhider")
# ═══════════════════════════════════════════════════════════════════════════
# DOCX → Markdown
# ═══════════════════════════════════════════════════════════════════════════
def docx_to_markdown(content: bytes) -> str:
"""Конвертировать DOCX в Markdown с сохранением базового форматирования.
Маппинг стилей:
- Heading 1/2/3 → # / ## / ###
- Bold → **текст**
- Italic → *текст*
- Таблицы → | col1 | col2 | (с разделителем)
Args:
content: Бинарное содержимое .docx файла
Returns:
Строка в формате Markdown
"""
from docx import Document
doc = Document(io.BytesIO(content))
lines = []
for para in doc.paragraphs:
if not para.text.strip():
lines.append("") # Пустая строка — разделитель абзацев
continue
text = para.text
# Применяем форматирование из runs (bold, italic)
if para.runs:
formatted_parts = []
for run in para.runs:
part = run.text
if run.bold:
part = f"**{part}**"
if run.italic:
part = f"*{part}*"
formatted_parts.append(part)
text = "".join(formatted_parts)
# Стиль абзаца
style_name = para.style.name if para.style else ""
if style_name.startswith("Heading"):
# Heading 1 → #, Heading 2 → ##, ...
try:
level = int(style_name.split()[-1])
except (ValueError, IndexError):
level = 1
prefix = "#" * min(level, 6)
lines.append(f"{prefix} {text}")
else:
lines.append(text)
# ── Таблицы ──
for table in doc.tables:
lines.append("") # Отступ перед таблицей
for ri, row in enumerate(table.rows):
cells = [cell.text.replace("\n", " ").strip() for cell in row.cells]
lines.append("| " + " | ".join(cells) + " |")
# Разделитель после первой строки (заголовка таблицы)
if ri == 0:
lines.append("|" + "|".join(["---"] * len(cells)) + "|")
lines.append("") # Отступ после таблицы
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# PDF → Markdown
# ═══════════════════════════════════════════════════════════════════════════
def pdf_to_markdown(content: bytes) -> str:
"""Конвертировать PDF в Markdown (только текст + таблицы, без форматирования).
pdfplumber не различает стили (bold/italic/heading), поэтому
всё извлекается как plain text.
Args:
content: Бинарное содержимое .pdf файла
Returns:
Строка в формате Markdown (plain text + таблицы)
"""
import pdfplumber
lines = []
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# ── Текст страницы ──
text = page.extract_text()
if text:
lines.append(text)
lines.append("")
# ── Таблицы ──
# Быстрый фильтр: если на странице нет линий/кривых/прямоугольников,
# таблиц нет (pdfplumber ищет таблицы только по векторным линиям).
# Включая page.rects — таблицы на цветном фоне (заливка).
# Пропускаем extract_tables() на «чистых» страницах (скан/текст) — это
# основная стоимость на сканах, где линий нет.
if page.lines or page.curves or page.rects:
tables = page.extract_tables()
else:
tables = []
for table in tables:
if not table:
continue
# Чистим строки таблицы
clean_rows = [
[str(c or "").strip() for c in (row or [])]
for row in table
]
clean_rows = [r for r in clean_rows if any(r)]
if not clean_rows:
continue
# Выводим таблицу в MD
for ri, row in enumerate(clean_rows):
lines.append("| " + " | ".join(row) + " |")
if ri == 0:
lines.append("|" + "|".join(["---"] * len(row)) + "|")
lines.append("")
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# DOC → Markdown (через сервис liberta)
# ═══════════════════════════════════════════════════════════════════════════
def doc_to_markdown(content: bytes) -> str:
"""Конвертировать бинарный .doc → .docx (сервис liberta) → Markdown.
Отправляет .doc на HTTP-сервис конвертации, получает .docx,
затем прогоняет через штатный docx_to_markdown().
Args:
content: Бинарное содержимое .doc файла
Returns:
Строка в формате Markdown (или сообщение об ошибке)
"""
import httpx
url = os.environ.get(
"CONVERT_SERVICE_URL",
"https://liberta.containerk8s.dev.nubes.ru/convert"
)
try:
with httpx.Client(timeout=120) as client:
resp = client.post(
url,
files={"file": ("input.doc", content, "application/msword")}
)
if resp.status_code == 200:
return docx_to_markdown(resp.content)
err = resp.json().get("error", f"HTTP {resp.status_code}")
log.warning("liberta conversion failed: %s", err)
return f"[DOC — conversion failed: {err}]"
except httpx.TimeoutException:
return "[DOC — conversion timed out (>120s)]"
except Exception as e:
log.warning("liberta error: %s", e)
return f"[DOC — conversion error: {e}]"
# ═══════════════════════════════════════════════════════════════════════════
# Единая точка входа
# ═══════════════════════════════════════════════════════════════════════════
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
"""Извлечь текст из одного файла → Markdown.
Args:
fname: Имя файла (с расширением)
content: Бинарное содержимое файла
ctype: MIME-тип (зарезервировано, не используется)
Returns:
Строка в формате Markdown
"""
ext = os.path.splitext(fname)[1].lower()
# ── .docx: полный Markdown с форматированием ──
if ext == '.docx':
try:
return docx_to_markdown(content)
except ImportError:
return content.decode('utf-8', errors='replace')
# ── .pdf: текст + таблицы ──
elif ext == '.pdf':
try:
return pdf_to_markdown(content)
except ImportError:
return content.decode('utf-8', errors='replace')
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
elif ext == '.doc':
return doc_to_markdown(content)
# ── .txt и прочие ──
else:
return content.decode('utf-8', errors='replace')
# ═══════════════════════════════════════════════════════════════════════════
# ZIP-распаковка (без изменений)
# ═══════════════════════════════════════════════════════════════════════════
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы в списке, заменив их содержимым.
Не-ZIP файлы проходят без изменений.
Защита от ZIP-бомб:
- Максимум 500 файлов в архиве
- Ratio file_size/compress_size не более 100:1
- Накопительный размер распакованных данных не более 500 MB
Args:
files: [(filename, content_bytes, content_type), ...]
Returns:
Новый список файлов (ZIP раскрыты, остальные как есть)
"""
# Лимиты защиты от ZIP-бомб (глобально на весь вызов)
MAX_FILES_IN_ARCHIVE = 500
MAX_UNCOMPRESSED = 500 * 1024 * 1024 # 500 MB
MAX_RATIO = 100
result: List[Tuple[str, bytes, str]] = []
queue: List[Tuple[str, bytes, str]] = list(files)
total_uncompressed = 0
def _decode_name(name: str, info) -> str:
"""Декодировать имя из ZIP.
Если флаг UTF-8 (bit 11) выставлен — имя уже корректное (берём как есть).
Иначе zipfile декодировал имя как CP437. Пытаемся восстановить исходные
байты (encode cp437) и декодировать:
1) как UTF-8 — если результат в кириллице/ASCII (Info-ZIP/Linux-зипы
пишут UTF-8 без флага; случайная коллизия CP866→UTF-8 отсекается
проверкой диапазона);
2) как CP866 — реальные 1С-выгрузки.
"""
def _cyr_ok(s: str) -> bool:
# Все символы — ASCII или кириллица (U+0400U+04FF)
return all(ord(c) < 128 or 0x0400 <= ord(c) <= 0x04FF for c in s)
if not (info.flag_bits & 0x800) and any(ord(c) > 127 for c in name):
try:
raw = name.encode("cp437")
except UnicodeEncodeError:
return name
# 1) UTF-8 (Info-ZIP/Linux без флага)
try:
dec = raw.decode("utf-8")
if _cyr_ok(dec):
return dec
except UnicodeDecodeError:
pass
# 2) CP866 (1С)
try:
return raw.decode("cp866")
except UnicodeDecodeError:
pass
return name
while queue:
fname, content, ctype = queue.pop(0)
if not fname.lower().endswith('.zip'):
result.append((fname, content, ctype))
continue
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
infos = [i for i in zf.infolist() if not i.is_dir()]
if len(infos) > MAX_FILES_IN_ARCHIVE:
log.warning("ZIP too many files, skipping: %s", fname)
result.append((fname, content, ctype))
continue
# Проверяем лимиты ДО чтения содержимого (защита от бомб)
bomb = False
for info in infos:
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > MAX_RATIO:
bomb = True
break
if total_uncompressed + info.file_size > MAX_UNCOMPRESSED:
bomb = True
break
if bomb:
log.warning("ZIP bomb/limit, skipping: %s", fname)
result.append((fname, content, ctype))
continue
for info in infos:
name = _decode_name(info.filename, info)
name = os.path.basename(name)
if not name:
continue
# Синхронизация с фронтом (listZipFiles): из zip берём ТОЛЬКО документы
low = name.lower()
if not (low.endswith('.zip') or low.endswith(('.pdf', '.doc', '.docx', '.txt', '.md'))):
continue
inner_data = zf.read(info)
total_uncompressed += len(inner_data)
if total_uncompressed > MAX_UNCOMPRESSED:
log.warning("ZIP uncompressed limit exceeded (mid-read): %s", fname)
break
# Вложенные ZIP добавляем в очередь на повторную распаковку
queue.append((name, inner_data, ""))
except Exception as e:
log.warning("Failed to expand ZIP %s: %s", fname, e)
result.append((fname, content, ctype))
return result