From af2f2a9f7195a81bdd83a8c4b49cf9fd5732a2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 12 Jul 2026 09:13:47 +0400 Subject: [PATCH] =?UTF-8?q?extractor.py=20=E2=86=92=20MD-=D0=BF=D0=B0?= =?UTF-8?q?=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=20(docx=5Fto=5Fmarkdown,?= =?UTF-8?q?=20pdf=5Fto=5Fmarkdown),=20obfuscator.py=20=E2=80=94=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20=D0=B1=D0=B5=D0=B7=20docx-=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- drhider/extractor.py | 304 ++++++++++++++++++++++-------------------- drhider/obfuscator.py | 36 +++-- 2 files changed, 173 insertions(+), 167 deletions(-) diff --git a/drhider/extractor.py b/drhider/extractor.py index f0a7983..3c5b353 100644 --- a/drhider/extractor.py +++ b/drhider/extractor.py @@ -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 diff --git a/drhider/obfuscator.py b/drhider/obfuscator.py index f53b9e1..c7a320f 100644 --- a/drhider/obfuscator.py +++ b/drhider/obfuscator.py @@ -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))