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)
- .pdf (через pdfplumber)
- .doc (бинарный — не парсится)
- .txt и прочие (как UTF-8)
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc (бинарный — не парсится, возвращает заглушку)
- .txt и прочие как есть
Также содержит:
- _expand_zips — распаковка ZIP с защитой от ZIP-бомб
- _convert_pdfs_to_docx — конвертация PDF → DOCX
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
"""
import io
import os
import zipfile
import logging
from typing import Dict, List, Tuple, Optional
from typing import List, Tuple
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:
fname: Имя файла (с расширением)
content: Бинарное содержимое файла
ctype: MIME-тип (не используется в текущей версии)
ctype: MIME-тип (зарезервировано, не используется)
Returns:
(text, docx_document_or_None):
text — извлечённый текст (строка)
doc — объект python-docx Document или None
Строка в формате Markdown
"""
doc = None
text = ""
ext = os.path.splitext(fname)[1].lower()
# ── .docx: извлекаем текст + сохраняем Document для замен с форматированием ──
# ── .docx: полный Markdown с форматированием ──
if ext == '.docx':
try:
from docx import Document
return docx_to_markdown(content)
except ImportError:
# Если python-docx не установлен — читаем как plain text
text = content.decode('utf-8', errors='replace')
return text, None
return content.decode('utf-8', errors='replace')
doc = Document(io.BytesIO(content))
# Собираем текст из параграфов
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 ──
# ── .pdf: текст + таблицы ──
elif ext == '.pdf':
try:
import pdfplumber
return pdf_to_markdown(content)
except ImportError:
text = content.decode('utf-8', errors='replace')
return text, None
return content.decode('utf-8', errors='replace')
parts = []
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 не парсим ──
# ── .doc: бинарный — пока не поддерживается ──
elif ext == '.doc':
text = "[DOC binary — not parsed]"
return text, None
return "[DOC binary — not supported. Convert to .docx first.]"
# ── .txt и прочие: читаем как UTF-8 ──
# ── .txt и прочие ──
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]]:
"""Распаковать 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]] = []
for fname, content, ctype in files:
# Пропускаем не-ZIP
if not fname.lower().endswith('.zip'):
result.append((fname, content, ctype))
continue
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
# Проверка: не более 500 файлов в архиве
if len(zf.infolist()) > 500:
log.warning("ZIP too many files, skipping: %s", fname)
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
for info in zf.infolist():
# Пропускаем директории
if info.is_dir():
continue
# Проверка на ZIP-бомбу: ratio
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > 100: # Файл сжимается более чем в 100 раз
if ratio > 100:
log.warning(
"ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname
)
result.append((fname, content, ctype))
break # Пропускаем весь архив
break
# Декодируем имя файла: cp437 → utf-8
name = info.filename
try:
name = name.encode("cp437").decode("utf-8", errors="replace")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
# Защита от path traversal
name = os.path.basename(name)
if (
not name
@@ -158,11 +250,10 @@ def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, s
):
continue
# Читаем и проверяем накопительный размер
inner_data = zf.read(info)
total_uncompressed += len(inner_data)
if total_uncompressed > 500 * 1024 * 1024: # 500 MB
if total_uncompressed > 500 * 1024 * 1024:
log.warning(
"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))
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 os
import logging
from typing import Dict, List, Tuple, Callable, Optional
@@ -54,32 +55,30 @@ class TwoPassObfuscator:
) -> Tuple[bytes, str]:
"""Обфусцировать список файлов.
Все форматы → Markdown → замена → результат.
Args:
files: [(filename, content_bytes, content_type), ...]
content_type — MIME-тип (может быть пустой строкой)
Returns:
(zip_bytes, csv_string):
zip_bytes — ZIP-архив с обфусцированными файлами + mapping.csv
zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv
csv_string — содержимое mapping.csv как строка
"""
# ── Предобработка: распаковать ZIP, конвертировать PDF ──
# ── Предобработка: распаковать ZIP ──
files = extractor.expand_zips(files)
files = extractor.convert_pdfs_to_docx(files)
try:
# ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла
all_texts: Dict[str, str] = {}
all_docx: Dict[str, object] = {}
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
if doc is not None:
all_docx[fname] = doc
# Regex-сканирование (быстрое, локальное)
if text and text != "[DOC binary — not parsed]":
if text and not text.startswith("[DOC binary"):
scanner.scan_regex(text, self._mapping)
# LLM-сканирование (медленное, сетевое — только если есть клиент)
@@ -98,19 +97,18 @@ class TwoPassObfuscator:
obf_content = content # По умолчанию — без изменений
if fname.endswith('.doc'):
# .doc — бинарный формат, оставляем как есть
# .doc — бинарный формат, пока не поддерживается
pass
elif fname in all_docx:
# DOCX: замена с сохранением форматирования
obf_content = replacer.replace_in_docx(
all_docx[fname], self._mapping, self._sorted_keys
)
else:
# Plain text / PDF-текст: простая замена
txt = all_texts.get(fname, '')
obf_content = replacer.replace_in_text(
txt, fname, self._mapping, self._sorted_keys
# Все форматы → Markdown → замена в тексте
md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements(
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))