extractor.py → MD-пайплайн (docx_to_markdown, pdf_to_markdown), obfuscator.py — новый пайплайн без docx-объектов
Deploy drhider / validate (push) Waiting to run

This commit is contained in:
2026-07-12 09:13:47 +04:00
parent 2ceeb05a8b
commit af2f2a9f71
2 changed files with 173 additions and 167 deletions
+156 -148
View File
@@ -1,95 +1,193 @@
""" """
Извлечение текста из документов разных форматов. Извлечение текста из документов — все форматы → Markdown.
Поддерживает: Поддерживает:
- .docx (через python-docx) - .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf (через pdfplumber) - .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc (бинарный — не парсится) - .doc (бинарный — не парсится, возвращает заглушку)
- .txt и прочие (как UTF-8) - .txt и прочие как есть
Также содержит: Также содержит:
- _expand_zips — распаковка ZIP с защитой от ZIP-бомб - expand_zips — распаковка ZIP с защитой от ZIP-бомб
- _convert_pdfs_to_docx — конвертация PDF → DOCX
""" """
import io import io
import os import os
import zipfile import zipfile
import logging import logging
from typing import Dict, List, Tuple, Optional from typing import List, Tuple
log = logging.getLogger("drhider") log = logging.getLogger("drhider")
def extract_text(fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]: # ═══════════════════════════════════════════════════════════════════════════
"""Извлечь текст из одного файла. # 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("")
# ── Таблицы ──
tables = page.extract_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)
# ═══════════════════════════════════════════════════════════════════════════
# Единая точка входа
# ═══════════════════════════════════════════════════════════════════════════
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
"""Извлечь текст из одного файла → Markdown.
Args: Args:
fname: Имя файла (с расширением) fname: Имя файла (с расширением)
content: Бинарное содержимое файла content: Бинарное содержимое файла
ctype: MIME-тип (не используется в текущей версии) ctype: MIME-тип (зарезервировано, не используется)
Returns: Returns:
(text, docx_document_or_None): Строка в формате Markdown
text — извлечённый текст (строка)
doc — объект python-docx Document или None
""" """
doc = None
text = ""
ext = os.path.splitext(fname)[1].lower() ext = os.path.splitext(fname)[1].lower()
# ── .docx: извлекаем текст + сохраняем Document для замен с форматированием ── # ── .docx: полный Markdown с форматированием ──
if ext == '.docx': if ext == '.docx':
try: try:
from docx import Document return docx_to_markdown(content)
except ImportError: except ImportError:
# Если python-docx не установлен — читаем как plain text return content.decode('utf-8', errors='replace')
text = content.decode('utf-8', errors='replace')
return text, None
doc = Document(io.BytesIO(content)) # ── .pdf: текст + таблицы ──
# Собираем текст из параграфов
paragraphs = [p.text for p in doc.paragraphs]
# Добавляем текст из таблиц
for table in doc.tables:
for row in table.rows:
row_text = " | ".join(cell.text for cell in row.cells)
paragraphs.append(row_text)
text = "\n".join(paragraphs)
# ── .pdf: извлекаем текст через pdfplumber ──
elif ext == '.pdf': elif ext == '.pdf':
try: try:
import pdfplumber return pdf_to_markdown(content)
except ImportError: except ImportError:
text = content.decode('utf-8', errors='replace') return content.decode('utf-8', errors='replace')
return text, None
parts = [] # ── .doc: бинарный — пока не поддерживается ──
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# Текст страницы
t = page.extract_text()
if t:
parts.append(t)
# Текст из таблиц
for table in page.extract_tables():
for row in table:
row_str = " | ".join(str(c) if c else "" for c in row)
parts.append(row_str)
text = "\n".join(parts)
# ── .doc: бинарный формат — без libreoffice не парсим ──
elif ext == '.doc': elif ext == '.doc':
text = "[DOC binary — not parsed]" return "[DOC binary — not supported. Convert to .docx first.]"
return text, None
# ── .txt и прочие: читаем как UTF-8 ── # ── .txt и прочие ──
else: else:
text = content.decode('utf-8', errors='replace') return content.decode('utf-8', errors='replace')
return text, doc
# ═══════════════════════════════════════════════════════════════════════════
# ZIP-распаковка (без изменений)
# ═══════════════════════════════════════════════════════════════════════════
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]: def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы в списке, заменив их содержимым. """Распаковать ZIP-файлы в списке, заменив их содержимым.
@@ -110,14 +208,12 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
result: List[Tuple[str, bytes, str]] = [] result: List[Tuple[str, bytes, str]] = []
for fname, content, ctype in files: for fname, content, ctype in files:
# Пропускаем не-ZIP
if not fname.lower().endswith('.zip'): if not fname.lower().endswith('.zip'):
result.append((fname, content, ctype)) result.append((fname, content, ctype))
continue continue
try: try:
with zipfile.ZipFile(io.BytesIO(content)) as zf: with zipfile.ZipFile(io.BytesIO(content)) as zf:
# Проверка: не более 500 файлов в архиве
if len(zf.infolist()) > 500: if len(zf.infolist()) > 500:
log.warning("ZIP too many files, skipping: %s", fname) log.warning("ZIP too many files, skipping: %s", fname)
result.append((fname, content, ctype)) result.append((fname, content, ctype))
@@ -126,28 +222,24 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
total_uncompressed = 0 total_uncompressed = 0
for info in zf.infolist(): for info in zf.infolist():
# Пропускаем директории
if info.is_dir(): if info.is_dir():
continue continue
# Проверка на ZIP-бомбу: ratio
if info.compress_size > 0: if info.compress_size > 0:
ratio = info.file_size / info.compress_size ratio = info.file_size / info.compress_size
if ratio > 100: # Файл сжимается более чем в 100 раз if ratio > 100:
log.warning( log.warning(
"ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname "ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname
) )
result.append((fname, content, ctype)) result.append((fname, content, ctype))
break # Пропускаем весь архив break
# Декодируем имя файла: cp437 → utf-8
name = info.filename name = info.filename
try: try:
name = name.encode("cp437").decode("utf-8", errors="replace") name = name.encode("cp437").decode("utf-8", errors="replace")
except (UnicodeDecodeError, UnicodeEncodeError): except (UnicodeDecodeError, UnicodeEncodeError):
pass pass
# Защита от path traversal
name = os.path.basename(name) name = os.path.basename(name)
if ( if (
not name not name
@@ -158,11 +250,10 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
): ):
continue continue
# Читаем и проверяем накопительный размер
inner_data = zf.read(info) inner_data = zf.read(info)
total_uncompressed += len(inner_data) total_uncompressed += len(inner_data)
if total_uncompressed > 500 * 1024 * 1024: # 500 MB if total_uncompressed > 500 * 1024 * 1024:
log.warning( log.warning(
"ZIP uncompressed limit exceeded, stopping: %s", fname "ZIP uncompressed limit exceeded, stopping: %s", fname
) )
@@ -175,86 +266,3 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
result.append((fname, content, ctype)) result.append((fname, content, ctype))
return result return result
def convert_pdfs_to_docx(
files: List[Tuple[str, bytes, str]],
) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF-файлы в DOCX через pdfplumber.
Не-PDF файлы проходят без изменений.
При совпадении имён к имени добавляется суффикс '_из_pdf'.
Конвертация:
- Текст страницы → параграфы DOCX
- Таблицы → таблицы DOCX со стилем 'Table Grid'
Args:
files: [(filename, content_bytes, content_type), ...]
Returns:
Новый список файлов (PDF заменены на DOCX)
"""
import pdfplumber
from docx import Document as DocxDocument
result: List[Tuple[str, bytes, str]] = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
# Пропускаем не-PDF
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
doc = DocxDocument()
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# ── Таблицы ──
tables = page.extract_tables()
for table in tables:
if not table:
continue
# Чистим строки: убираем полностью пустые
rows = [
[str(c or "").strip() for c in (row or [])]
for row in table
]
rows = [r for r in rows if any(r)]
if rows:
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
t.style = 'Table Grid'
for ri, row in enumerate(rows):
for ci, cell_text in enumerate(row):
t.rows[ri].cells[ci].text = cell_text
# ── Текст ──
text = page.extract_text()
if text:
for line in text.split('\n'):
line = line.strip()
if line:
doc.add_paragraph(line)
# Сохраняем DOCX в буфер
buf = io.BytesIO()
doc.save(buf)
# Формируем новое имя
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, buf.getvalue(), ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
# При ошибке — оставляем оригинальный PDF
result.append((fname, content, ctype))
return result
+17 -19
View File
@@ -8,6 +8,7 @@
""" """
import io import io
import os
import logging import logging
from typing import Dict, List, Tuple, Callable, Optional from typing import Dict, List, Tuple, Callable, Optional
@@ -54,32 +55,30 @@ class TwoPassObfuscator:
) -> Tuple[bytes, str]: ) -> Tuple[bytes, str]:
"""Обфусцировать список файлов. """Обфусцировать список файлов.
Все форматы → Markdown → замена → результат.
Args: Args:
files: [(filename, content_bytes, content_type), ...] files: [(filename, content_bytes, content_type), ...]
content_type — MIME-тип (может быть пустой строкой)
Returns: Returns:
(zip_bytes, csv_string): (zip_bytes, csv_string):
zip_bytes — ZIP-архив с обфусцированными файлами + mapping.csv zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv
csv_string — содержимое mapping.csv как строка csv_string — содержимое mapping.csv как строка
""" """
# ── Предобработка: распаковать ZIP, конвертировать PDF ── # ── Предобработка: распаковать ZIP ──
files = extractor.expand_zips(files) files = extractor.expand_zips(files)
files = extractor.convert_pdfs_to_docx(files)
try: try:
# ── Проход 1: сбор сущностей ── # ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла
all_texts: Dict[str, str] = {} all_texts: Dict[str, str] = {}
all_docx: Dict[str, object] = {}
for fname, content, ctype in files: for fname, content, ctype in files:
text, doc = extractor.extract_text(fname, content, ctype) text = extractor.extract_text(fname, content, ctype)
all_texts[fname] = text all_texts[fname] = text
if doc is not None:
all_docx[fname] = doc
# Regex-сканирование (быстрое, локальное) # Regex-сканирование (быстрое, локальное)
if text and text != "[DOC binary — not parsed]": if text and not text.startswith("[DOC binary"):
scanner.scan_regex(text, self._mapping) scanner.scan_regex(text, self._mapping)
# LLM-сканирование (медленное, сетевое — только если есть клиент) # LLM-сканирование (медленное, сетевое — только если есть клиент)
@@ -98,19 +97,18 @@ class TwoPassObfuscator:
obf_content = content # По умолчанию — без изменений obf_content = content # По умолчанию — без изменений
if fname.endswith('.doc'): if fname.endswith('.doc'):
# .doc — бинарный формат, оставляем как есть # .doc — бинарный формат, пока не поддерживается
pass pass
elif fname in all_docx:
# DOCX: замена с сохранением форматирования
obf_content = replacer.replace_in_docx(
all_docx[fname], self._mapping, self._sorted_keys
)
else: else:
# Plain text / PDF-текст: простая замена # Все форматы → Markdown → замена в тексте
txt = all_texts.get(fname, '') md_text = all_texts.get(fname, '')
obf_content = replacer.replace_in_text( replaced = replacer.apply_replacements(
txt, fname, self._mapping, self._sorted_keys md_text, self._mapping, self._sorted_keys
) )
# Имя файла: заменить расширение на .md
md_name = os.path.splitext(fname)[0] + '.md'
obf_content = replaced.encode('utf-8')
fname = md_name
results.append((fname, obf_content)) results.append((fname, obf_content))