Add parser engine: base with subprocess isolation, HTML/PDF/email/cable/text parsers
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from .base import ParserManager
|
||||
from .html_parser import HTMLDocParser
|
||||
from .pdf_parser import PDFDocParser
|
||||
from .email_parser import EmailDocParser
|
||||
from .cable_parser import CableDocParser
|
||||
from .text_parser import TextDocParser
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
"""Base parser with subprocess isolation for Mosaic document processing."""
|
||||
import os
|
||||
import logging
|
||||
import multiprocessing
|
||||
from multiprocessing import Process, Queue
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default limits
|
||||
DEFAULT_MEMORY_LIMIT_MB = 512
|
||||
DEFAULT_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
"""Result from a parser subprocess."""
|
||||
success: bool = False
|
||||
text: str = ""
|
||||
metadata: dict = field(default_factory=dict)
|
||||
doc_type: str = ""
|
||||
title: str = ""
|
||||
char_count: int = 0
|
||||
error: Optional[str] = None
|
||||
needs_ocr: bool = False
|
||||
|
||||
|
||||
class ParserManager:
|
||||
"""Manages document parsing with subprocess isolation.
|
||||
|
||||
All parsers run in subprocess workers with:
|
||||
- Memory limits (resource.setrlimit)
|
||||
- Timeouts
|
||||
- No access to config, SQLite, or network
|
||||
"""
|
||||
|
||||
def __init__(self, memory_limit_mb: int = DEFAULT_MEMORY_LIMIT_MB,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
min_chars_per_page: int = 50,
|
||||
max_pdf_pages: int = 200):
|
||||
self.memory_limit_mb = memory_limit_mb
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.min_chars_per_page = min_chars_per_page
|
||||
self.max_pdf_pages = max_pdf_pages
|
||||
self._parsers = {}
|
||||
self._register_parsers()
|
||||
|
||||
def _register_parsers(self):
|
||||
"""Register available parsers by doc_type."""
|
||||
from .html_parser import HTMLDocParser
|
||||
from .pdf_parser import PDFDocParser
|
||||
from .email_parser import EmailDocParser
|
||||
from .cable_parser import CableDocParser
|
||||
from .text_parser import TextDocParser
|
||||
|
||||
self._parsers = {
|
||||
'html': HTMLDocParser(),
|
||||
'pdf': PDFDocParser(max_pages=self.max_pdf_pages,
|
||||
min_chars_per_page=self.min_chars_per_page),
|
||||
'email': EmailDocParser(),
|
||||
'cable': CableDocParser(),
|
||||
'text': TextDocParser(),
|
||||
}
|
||||
|
||||
def parse(self, file_path: str, doc_type: str,
|
||||
content_hash: Optional[str] = None) -> ParseResult:
|
||||
"""Parse a document in a subprocess worker.
|
||||
|
||||
Args:
|
||||
file_path: Path to the document file
|
||||
doc_type: Document type (html, pdf, email, cable, text)
|
||||
content_hash: Expected hash for integrity verification
|
||||
|
||||
Returns:
|
||||
ParseResult with extracted text and metadata
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
return ParseResult(error=f"File not found: {file_path}")
|
||||
|
||||
# Verify hash before parsing
|
||||
if content_hash:
|
||||
from utils.cache import DownloadCache
|
||||
actual_hash = DownloadCache.compute_hash(file_path)
|
||||
if actual_hash != content_hash:
|
||||
return ParseResult(
|
||||
error=f"Hash mismatch: expected {content_hash}, got {actual_hash}"
|
||||
)
|
||||
|
||||
parser = self._parsers.get(doc_type)
|
||||
if not parser:
|
||||
# Try to detect type
|
||||
parser = self._detect_parser(file_path)
|
||||
if not parser:
|
||||
return ParseResult(error=f"No parser for doc_type: {doc_type}")
|
||||
|
||||
# Run in subprocess
|
||||
result_queue = Queue()
|
||||
proc = Process(
|
||||
target=self._worker,
|
||||
args=(parser, file_path, result_queue, self.memory_limit_mb)
|
||||
)
|
||||
proc.start()
|
||||
proc.join(timeout=self.timeout_seconds)
|
||||
|
||||
if proc.is_alive():
|
||||
proc.terminate()
|
||||
proc.join(timeout=5)
|
||||
if proc.is_alive():
|
||||
proc.kill()
|
||||
return ParseResult(error=f"Parser timed out after {self.timeout_seconds}s")
|
||||
|
||||
if result_queue.empty():
|
||||
return ParseResult(error="Parser process returned no result")
|
||||
|
||||
result = result_queue.get_nowait()
|
||||
|
||||
# Check for OCR needed (low char count in PDFs)
|
||||
if doc_type == 'pdf' and result.success:
|
||||
if result.char_count > 0 and result.metadata.get('page_count', 1) > 0:
|
||||
chars_per_page = result.char_count / result.metadata.get('page_count', 1)
|
||||
if chars_per_page < self.min_chars_per_page:
|
||||
result.needs_ocr = True
|
||||
logger.info("Document may need OCR: %.1f chars/page", chars_per_page)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _worker(parser, file_path: str, result_queue: Queue, memory_limit_mb: int):
|
||||
"""Subprocess worker that runs the parser with resource limits."""
|
||||
try:
|
||||
# Set memory limit
|
||||
try:
|
||||
import resource
|
||||
mem_bytes = memory_limit_mb * 1024 * 1024
|
||||
resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
|
||||
except (ImportError, ValueError, OSError) as e:
|
||||
# resource module may not be available on all platforms
|
||||
pass
|
||||
|
||||
result = parser.parse(file_path)
|
||||
result_queue.put(result)
|
||||
except MemoryError:
|
||||
result_queue.put(ParseResult(error="Parser exceeded memory limit"))
|
||||
except Exception as e:
|
||||
result_queue.put(ParseResult(error=f"Parser error: {str(e)}"))
|
||||
|
||||
def _detect_parser(self, file_path: str) -> Optional[object]:
|
||||
"""Try to detect the right parser from file content."""
|
||||
try:
|
||||
from utils.sanitize import validate_content_type
|
||||
content_type = validate_content_type(file_path)
|
||||
if 'html' in content_type:
|
||||
return self._parsers['html']
|
||||
elif 'pdf' in content_type:
|
||||
return self._parsers['pdf']
|
||||
elif 'text' in content_type:
|
||||
return self._parsers['text']
|
||||
except Exception:
|
||||
pass
|
||||
return self._parsers.get('text')
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Diplomatic cable parser for Mosaic — structured field extraction.
|
||||
|
||||
Cables have a specific format with fields like SUBJECT, ORIGIN,
|
||||
CLASSIFICATION, TAGS, and a multi-section body.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import ParseResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cable field patterns
|
||||
CABLE_FIELDS = {
|
||||
'VZCZCXYZ': 'transmission_id',
|
||||
'DE ': 'from_code',
|
||||
'ZNR': 'precedence',
|
||||
'ZNY': 'precedence',
|
||||
'FM ': 'from',
|
||||
'TO ': 'to',
|
||||
'INFO ': 'info',
|
||||
'BT': 'break',
|
||||
'UNCLAS': 'classification',
|
||||
'CONFIDENTIAL': 'classification',
|
||||
'SECRET': 'classification',
|
||||
'SIPDIS': 'distribution',
|
||||
'E.O.': 'executive_order',
|
||||
'TAGS:': 'tags',
|
||||
'SUBJECT:': 'subject',
|
||||
'REF:': 'reference',
|
||||
'REFS:': 'reference',
|
||||
}
|
||||
|
||||
# Classification levels
|
||||
CLASSIFICATIONS = ['UNCLASSIFIED', 'CONFIDENTIAL', 'SECRET', 'SECRET//NOFORN',
|
||||
'CONFIDENTIAL//NOFORN', 'UNCLAS', 'UNCLAS SECTION']
|
||||
|
||||
# Section markers in cable bodies
|
||||
SECTION_PATTERN = re.compile(r'^\d+\.\s+\((?:U|C|S|SBU)\)', re.MULTILINE)
|
||||
|
||||
# ANSI escape pattern
|
||||
ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07')
|
||||
|
||||
|
||||
class CableDocParser:
|
||||
"""Parse diplomatic cables into structured fields."""
|
||||
|
||||
def parse(self, file_path: str) -> ParseResult:
|
||||
"""Parse a diplomatic cable.
|
||||
|
||||
Extracts structured fields (SUBJECT, ORIGIN, CLASSIFICATION, TAGS, etc.)
|
||||
as discrete fields BEFORE any chunking.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if this is HTML (WikiLeaks format) or raw cable
|
||||
if '<html' in content.lower()[:200] or '<!doctype' in content.lower()[:200]:
|
||||
return self._parse_html_cable(content)
|
||||
else:
|
||||
return self._parse_raw_cable(content)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"Cable parse error: {str(e)}")
|
||||
|
||||
def _parse_raw_cable(self, content: str) -> ParseResult:
|
||||
"""Parse a raw-format diplomatic cable."""
|
||||
metadata = {}
|
||||
|
||||
lines = content.split('\n')
|
||||
body_start = 0
|
||||
|
||||
# Extract header fields
|
||||
for i, line in enumerate(lines):
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
continue
|
||||
|
||||
# Classification
|
||||
for cls in CLASSIFICATIONS:
|
||||
if line_stripped.startswith(cls):
|
||||
metadata['CLASSIFICATION'] = cls
|
||||
break
|
||||
|
||||
# Named fields
|
||||
if line_stripped.startswith('SUBJECT:'):
|
||||
metadata['SUBJECT'] = line_stripped[8:].strip()
|
||||
elif line_stripped.startswith('TAGS:'):
|
||||
metadata['TAGS'] = line_stripped[5:].strip()
|
||||
elif line_stripped.startswith('REF:') or line_stripped.startswith('REFS:'):
|
||||
ref_start = 4 if line_stripped.startswith('REF:') else 5
|
||||
metadata['REFERENCE'] = line_stripped[ref_start:].strip()
|
||||
elif line_stripped.startswith('E.O.'):
|
||||
metadata['EXECUTIVE_ORDER'] = line_stripped[4:].strip()
|
||||
elif line_stripped.startswith('FM '):
|
||||
metadata['ORIGIN'] = line_stripped[3:].strip()
|
||||
elif line_stripped.startswith('TO '):
|
||||
metadata.setdefault('TO', []).append(line_stripped[3:].strip())
|
||||
|
||||
# Detect body start (after numbered paragraph or after header block)
|
||||
if SECTION_PATTERN.match(line_stripped):
|
||||
body_start = i
|
||||
break
|
||||
if line_stripped == 'BT' and i > 5:
|
||||
body_start = i + 1
|
||||
break
|
||||
|
||||
if body_start == 0:
|
||||
# Fallback: body starts after a double blank line
|
||||
for i in range(len(lines) - 1):
|
||||
if not lines[i].strip() and not lines[i+1].strip():
|
||||
body_start = i + 2
|
||||
break
|
||||
|
||||
body = '\n'.join(lines[body_start:]).strip()
|
||||
|
||||
# Extract sections from body
|
||||
sections = self._extract_sections(body)
|
||||
if sections:
|
||||
metadata['sections'] = sections
|
||||
|
||||
# Build formatted text
|
||||
text_parts = []
|
||||
for key in ('SUBJECT', 'ORIGIN', 'CLASSIFICATION', 'TAGS', 'REFERENCE'):
|
||||
if key in metadata:
|
||||
val = metadata[key]
|
||||
if isinstance(val, list):
|
||||
val = ', '.join(val)
|
||||
text_parts.append(f"{key}: {val}")
|
||||
|
||||
text_parts.append('')
|
||||
text_parts.append(body)
|
||||
|
||||
text = '\n'.join(text_parts)
|
||||
text = ANSI_ESCAPE.sub('', text)
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
doc_type='cable',
|
||||
title=metadata.get('SUBJECT', ''),
|
||||
char_count=len(text),
|
||||
)
|
||||
|
||||
def _parse_html_cable(self, content: str) -> ParseResult:
|
||||
"""Parse a WikiLeaks HTML-format cable page."""
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
|
||||
metadata = {}
|
||||
|
||||
# WikiLeaks cable pages have specific CSS classes/structures
|
||||
# Try common patterns
|
||||
title_el = soup.find('title')
|
||||
if title_el:
|
||||
metadata['SUBJECT'] = title_el.get_text(strip=True)
|
||||
|
||||
# Look for cable metadata in page
|
||||
for label in ('origin', 'classification', 'tags', 'subject', 'date'):
|
||||
el = soup.find(class_=re.compile(label, re.I))
|
||||
if el:
|
||||
metadata[label.upper()] = el.get_text(strip=True)
|
||||
|
||||
# Extract main content
|
||||
body_el = (soup.find(id='cable-text') or
|
||||
soup.find(class_='cable-text') or
|
||||
soup.find('pre') or
|
||||
soup.find(class_='content'))
|
||||
|
||||
if body_el:
|
||||
body = body_el.get_text(separator='\n', strip=True)
|
||||
else:
|
||||
body = soup.get_text(separator='\n', strip=True)
|
||||
|
||||
# Build text
|
||||
text_parts = []
|
||||
for key in ('SUBJECT', 'ORIGIN', 'CLASSIFICATION', 'TAGS'):
|
||||
if key in metadata:
|
||||
text_parts.append(f"{key}: {metadata[key]}")
|
||||
text_parts.append('')
|
||||
text_parts.append(body)
|
||||
|
||||
text = '\n'.join(text_parts)
|
||||
text = ANSI_ESCAPE.sub('', text)
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
doc_type='cable',
|
||||
title=metadata.get('SUBJECT', ''),
|
||||
char_count=len(text),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"HTML cable parse error: {str(e)}")
|
||||
|
||||
def _extract_sections(self, body: str) -> list[dict]:
|
||||
"""Extract numbered sections from cable body."""
|
||||
sections = []
|
||||
current_num = None
|
||||
current_text = []
|
||||
|
||||
for line in body.split('\n'):
|
||||
match = SECTION_PATTERN.match(line.strip())
|
||||
if match:
|
||||
if current_num is not None:
|
||||
sections.append({
|
||||
'number': current_num,
|
||||
'text': '\n'.join(current_text).strip()
|
||||
})
|
||||
# Extract section number and classification
|
||||
current_num = line.strip().split('.')[0]
|
||||
current_text = [line]
|
||||
elif current_num is not None:
|
||||
current_text.append(line)
|
||||
|
||||
if current_num is not None:
|
||||
sections.append({
|
||||
'number': current_num,
|
||||
'text': '\n'.join(current_text).strip()
|
||||
})
|
||||
|
||||
return sections
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Email document parser for Mosaic — stdlib email parsing.
|
||||
|
||||
SECURITY: Attachments are metadata-only. Never execute, open, or auto-extract
|
||||
attachment contents.
|
||||
"""
|
||||
import email
|
||||
import email.policy
|
||||
import re
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import ParseResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Suspicious MIME types that warrant flagging
|
||||
SUSPICIOUS_MIME_TYPES = {
|
||||
'application/x-executable',
|
||||
'application/x-msdos-program',
|
||||
'application/x-msdownload',
|
||||
'application/vnd.ms-excel.sheet.macroEnabled.12',
|
||||
'application/vnd.ms-word.document.macroEnabled.12',
|
||||
'application/x-shellscript',
|
||||
'application/javascript',
|
||||
'application/x-bat',
|
||||
'application/x-com',
|
||||
'application/x-dosexec',
|
||||
}
|
||||
|
||||
# ANSI escape pattern
|
||||
ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07')
|
||||
|
||||
|
||||
class EmailDocParser:
|
||||
"""Parse email documents — headers + body, attachments as metadata only."""
|
||||
|
||||
def parse(self, file_path: str) -> ParseResult:
|
||||
"""Parse an email file (EML/MBOX format or HTML email page)."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
# Try standard email parsing first
|
||||
msg = email.message_from_string(content, policy=email.policy.default)
|
||||
|
||||
# If it doesn't look like a real email, try HTML extraction
|
||||
if not msg['From'] and not msg['Subject']:
|
||||
return self._parse_html_email(content, file_path)
|
||||
|
||||
return self._parse_email_message(msg)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"Email parse error: {str(e)}")
|
||||
|
||||
def _parse_email_message(self, msg) -> ParseResult:
|
||||
"""Parse a standard email.Message object."""
|
||||
# Extract headers
|
||||
headers = {}
|
||||
for key in ('From', 'To', 'Cc', 'Bcc', 'Subject', 'Date',
|
||||
'Message-ID', 'In-Reply-To', 'References'):
|
||||
val = msg.get(key)
|
||||
if val:
|
||||
headers[key] = str(val)
|
||||
|
||||
# Extract body
|
||||
body_parts = []
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
disposition = str(part.get('Content-Disposition', ''))
|
||||
|
||||
if 'attachment' in disposition:
|
||||
continue # Skip attachments (metadata extracted separately)
|
||||
|
||||
if content_type == 'text/plain':
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
body_parts.append(payload.decode('utf-8', errors='replace'))
|
||||
elif content_type == 'text/html':
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
# Simple HTML → text
|
||||
text = self._html_to_text(payload.decode('utf-8', errors='replace'))
|
||||
body_parts.append(text)
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
body_parts.append(payload.decode('utf-8', errors='replace'))
|
||||
|
||||
body = '\n\n'.join(body_parts)
|
||||
|
||||
# Extract attachment metadata (NEVER the content)
|
||||
attachments = self._extract_attachment_metadata(msg)
|
||||
|
||||
# Build formatted output
|
||||
text_parts = []
|
||||
for key, val in headers.items():
|
||||
text_parts.append(f"{key}: {val}")
|
||||
text_parts.append('') # blank line
|
||||
text_parts.append(body)
|
||||
|
||||
text = '\n'.join(text_parts)
|
||||
# Strip ANSI
|
||||
text = ANSI_ESCAPE.sub('', text)
|
||||
|
||||
metadata = {
|
||||
'headers': headers,
|
||||
'attachments': attachments,
|
||||
'attachment_count': len(attachments),
|
||||
'has_suspicious_attachments': any(
|
||||
a.get('suspicious') for a in attachments
|
||||
),
|
||||
}
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
doc_type='email',
|
||||
title=headers.get('Subject', ''),
|
||||
char_count=len(text),
|
||||
)
|
||||
|
||||
def _extract_attachment_metadata(self, msg) -> list[dict]:
|
||||
"""Extract attachment metadata ONLY — never content."""
|
||||
attachments = []
|
||||
if not msg.is_multipart():
|
||||
return attachments
|
||||
|
||||
for part in msg.walk():
|
||||
disposition = str(part.get('Content-Disposition', ''))
|
||||
if 'attachment' not in disposition:
|
||||
continue
|
||||
|
||||
filename = part.get_filename() or 'unnamed'
|
||||
content_type = part.get_content_type()
|
||||
payload = part.get_payload(decode=True)
|
||||
size = len(payload) if payload else 0
|
||||
md5 = hashlib.md5(payload).hexdigest() if payload else ''
|
||||
|
||||
att = {
|
||||
'filename': filename,
|
||||
'content_type': content_type,
|
||||
'size': size,
|
||||
'md5': md5,
|
||||
'suspicious': content_type in SUSPICIOUS_MIME_TYPES,
|
||||
}
|
||||
|
||||
if att['suspicious']:
|
||||
logger.warning("Suspicious attachment: %s (%s)", filename, content_type)
|
||||
|
||||
attachments.append(att)
|
||||
|
||||
return attachments
|
||||
|
||||
def _parse_html_email(self, content: str, file_path: str) -> ParseResult:
|
||||
"""Parse an HTML page that contains email content (e.g., WikiLeaks email viewer)."""
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
|
||||
# Try to extract email fields from HTML structure
|
||||
metadata = {}
|
||||
text_parts = []
|
||||
|
||||
# Look for common WikiLeaks email display patterns
|
||||
for field in ('from', 'to', 'cc', 'subject', 'date'):
|
||||
el = soup.find(class_=re.compile(field, re.I))
|
||||
if el:
|
||||
metadata[field] = el.get_text(strip=True)
|
||||
text_parts.append(f"{field.title()}: {metadata[field]}")
|
||||
|
||||
# Get body
|
||||
body = soup.find(class_=re.compile('body|content|message', re.I))
|
||||
if body:
|
||||
text_parts.append('')
|
||||
text_parts.append(body.get_text(separator='\n', strip=True))
|
||||
else:
|
||||
# Fallback: get all text
|
||||
text_parts.append('')
|
||||
text_parts.append(soup.get_text(separator='\n', strip=True))
|
||||
|
||||
text = '\n'.join(text_parts)
|
||||
text = ANSI_ESCAPE.sub('', text)
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text,
|
||||
metadata={'headers': metadata, 'attachments': [], 'parsed_from_html': True},
|
||||
doc_type='email',
|
||||
title=metadata.get('subject', ''),
|
||||
char_count=len(text),
|
||||
)
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"HTML email parse error: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _html_to_text(html: str) -> str:
|
||||
"""Simple HTML to text conversion."""
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
return soup.get_text(separator='\n', strip=True)
|
||||
except Exception:
|
||||
# Fallback: strip tags with regex
|
||||
return re.sub(r'<[^>]+>', '', html)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""HTML document parser for Mosaic — BS4 with html.parser backend."""
|
||||
import re
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .base import ParseResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Elements to remove (navigation, scripts, styles)
|
||||
REMOVE_TAGS = ['script', 'style', 'nav', 'footer', 'header', 'aside',
|
||||
'noscript', 'iframe', 'svg']
|
||||
|
||||
# ANSI escape sequence pattern
|
||||
ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07')
|
||||
|
||||
|
||||
class HTMLDocParser:
|
||||
"""Parse HTML documents into structured text."""
|
||||
|
||||
def parse(self, file_path: str) -> ParseResult:
|
||||
"""Parse an HTML file.
|
||||
|
||||
Uses BeautifulSoup with 'html.parser' backend (explicitly specified,
|
||||
not auto-detected) to extract structured text.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
raw_html = f.read()
|
||||
|
||||
soup = BeautifulSoup(raw_html, 'html.parser')
|
||||
|
||||
# Extract title
|
||||
title = ""
|
||||
title_tag = soup.find('title')
|
||||
if title_tag:
|
||||
title = title_tag.get_text(strip=True)
|
||||
|
||||
# Extract metadata
|
||||
metadata = self._extract_metadata(soup)
|
||||
|
||||
# Remove unwanted elements
|
||||
for tag_name in REMOVE_TAGS:
|
||||
for tag in soup.find_all(tag_name):
|
||||
tag.decompose()
|
||||
|
||||
# Extract text preserving structure
|
||||
text = self._extract_structured_text(soup)
|
||||
|
||||
# Strip ANSI escape sequences
|
||||
text = ANSI_ESCAPE.sub('', text)
|
||||
|
||||
# Extract tables separately
|
||||
tables = self._extract_tables(soup)
|
||||
if tables:
|
||||
metadata['tables'] = tables
|
||||
|
||||
char_count = len(text)
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
doc_type='html',
|
||||
title=title,
|
||||
char_count=char_count,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"HTML parse error: {str(e)}")
|
||||
|
||||
def _extract_metadata(self, soup: BeautifulSoup) -> dict:
|
||||
"""Extract metadata from HTML meta tags."""
|
||||
metadata = {}
|
||||
for meta in soup.find_all('meta'):
|
||||
name = meta.get('name', meta.get('property', ''))
|
||||
content = meta.get('content', '')
|
||||
if name and content:
|
||||
metadata[name] = content
|
||||
return metadata
|
||||
|
||||
def _extract_structured_text(self, soup: BeautifulSoup) -> str:
|
||||
"""Extract text preserving heading structure."""
|
||||
parts = []
|
||||
|
||||
for element in soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p',
|
||||
'li', 'td', 'th', 'pre', 'blockquote', 'div']):
|
||||
text = element.get_text(separator=' ', strip=True)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
tag = element.name
|
||||
if tag.startswith('h'):
|
||||
level = int(tag[1])
|
||||
prefix = '#' * level
|
||||
parts.append(f"\n{prefix} {text}\n")
|
||||
elif tag == 'li':
|
||||
parts.append(f" - {text}")
|
||||
elif tag == 'pre':
|
||||
parts.append(f"\n```\n{text}\n```\n")
|
||||
elif tag == 'blockquote':
|
||||
parts.append(f"> {text}")
|
||||
else:
|
||||
parts.append(text)
|
||||
|
||||
result = '\n'.join(parts)
|
||||
# Collapse excessive whitespace
|
||||
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||
return result.strip()
|
||||
|
||||
def _extract_tables(self, soup: BeautifulSoup) -> list[dict]:
|
||||
"""Extract tables as structured data."""
|
||||
tables = []
|
||||
for table in soup.find_all('table'):
|
||||
rows = []
|
||||
for tr in table.find_all('tr'):
|
||||
cells = []
|
||||
for td in tr.find_all(['td', 'th']):
|
||||
cells.append(td.get_text(strip=True))
|
||||
if cells:
|
||||
rows.append(cells)
|
||||
if rows:
|
||||
tables.append({'rows': rows})
|
||||
return tables
|
||||
@@ -0,0 +1,82 @@
|
||||
"""PDF document parser for Mosaic — PyMuPDF text extraction."""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import ParseResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PDFDocParser:
|
||||
"""Parse PDF documents using PyMuPDF (fitz) for text extraction."""
|
||||
|
||||
def __init__(self, max_pages: int = 200, min_chars_per_page: int = 50):
|
||||
self.max_pages = max_pages
|
||||
self.min_chars_per_page = min_chars_per_page
|
||||
|
||||
def parse(self, file_path: str) -> ParseResult:
|
||||
"""Parse a PDF file.
|
||||
|
||||
Extracts text page by page. Large docs (>max_pages) are split.
|
||||
After extraction, checks chars/page ratio for OCR detection.
|
||||
"""
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
return ParseResult(error="PyMuPDF (fitz) not installed")
|
||||
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
total_pages = len(doc)
|
||||
metadata = {
|
||||
'page_count': total_pages,
|
||||
'format': doc.metadata.get('format', ''),
|
||||
'title': doc.metadata.get('title', ''),
|
||||
'author': doc.metadata.get('author', ''),
|
||||
'subject': doc.metadata.get('subject', ''),
|
||||
'creator': doc.metadata.get('creator', ''),
|
||||
}
|
||||
|
||||
title = metadata['title'] or ''
|
||||
|
||||
# Limit pages
|
||||
pages_to_process = min(total_pages, self.max_pages)
|
||||
if total_pages > self.max_pages:
|
||||
logger.warning("PDF has %d pages, processing first %d",
|
||||
total_pages, self.max_pages)
|
||||
metadata['truncated'] = True
|
||||
metadata['truncated_at_page'] = self.max_pages
|
||||
|
||||
# Extract text page by page with form feed separators
|
||||
text_parts = []
|
||||
for page_num in range(pages_to_process):
|
||||
page = doc[page_num]
|
||||
page_text = page.get_text("text")
|
||||
if page_text.strip():
|
||||
text_parts.append(page_text.strip())
|
||||
|
||||
doc.close()
|
||||
|
||||
text = '\f'.join(text_parts) # Form feed between pages
|
||||
char_count = len(text)
|
||||
|
||||
# Check for OCR need
|
||||
needs_ocr = False
|
||||
if pages_to_process > 0:
|
||||
chars_per_page = char_count / pages_to_process
|
||||
if chars_per_page < self.min_chars_per_page:
|
||||
needs_ocr = True
|
||||
metadata['chars_per_page'] = chars_per_page
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
doc_type='pdf',
|
||||
title=title,
|
||||
char_count=char_count,
|
||||
needs_ocr=needs_ocr,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"PDF parse error: {str(e)}")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Plain text / markdown parser for Mosaic."""
|
||||
import re
|
||||
import logging
|
||||
|
||||
from .base import ParseResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ANSI escape pattern
|
||||
ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07')
|
||||
|
||||
|
||||
class TextDocParser:
|
||||
"""Parse plain text and markdown documents."""
|
||||
|
||||
def parse(self, file_path: str) -> ParseResult:
|
||||
"""Parse a plain text file."""
|
||||
try:
|
||||
# Try UTF-8 first, fall back to latin-1
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
except UnicodeDecodeError:
|
||||
with open(file_path, 'r', encoding='latin-1') as f:
|
||||
text = f.read()
|
||||
|
||||
# Strip ANSI escape sequences
|
||||
text = ANSI_ESCAPE.sub('', text)
|
||||
|
||||
# Detect if it's markdown
|
||||
is_markdown = self._detect_markdown(text, file_path)
|
||||
|
||||
# Extract basic metadata
|
||||
metadata = {
|
||||
'is_markdown': is_markdown,
|
||||
}
|
||||
|
||||
# Try to extract a title
|
||||
title = self._extract_title(text)
|
||||
|
||||
# Basic section detection
|
||||
sections = self._detect_sections(text)
|
||||
if sections:
|
||||
metadata['section_count'] = len(sections)
|
||||
|
||||
return ParseResult(
|
||||
success=True,
|
||||
text=text.strip(),
|
||||
metadata=metadata,
|
||||
doc_type='text',
|
||||
title=title,
|
||||
char_count=len(text),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(error=f"Text parse error: {str(e)}")
|
||||
|
||||
def _detect_markdown(self, text: str, file_path: str) -> bool:
|
||||
"""Detect if text is markdown."""
|
||||
if file_path.endswith(('.md', '.markdown', '.mkd')):
|
||||
return True
|
||||
# Check for markdown indicators
|
||||
md_patterns = [
|
||||
r'^#{1,6}\s', # Headers
|
||||
r'^\*\*[^*]+\*\*', # Bold
|
||||
r'^\- ', # List items
|
||||
r'^\d+\. ', # Numbered list
|
||||
r'\[.+\]\(.+\)', # Links
|
||||
]
|
||||
matches = sum(1 for p in md_patterns if re.search(p, text[:2000], re.MULTILINE))
|
||||
return matches >= 2
|
||||
|
||||
def _extract_title(self, text: str) -> str:
|
||||
"""Extract title from first heading or first line."""
|
||||
lines = text.strip().split('\n')
|
||||
for line in lines[:5]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Markdown heading
|
||||
match = re.match(r'^#{1,3}\s+(.+)', line)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
# First non-empty line
|
||||
if len(line) < 200:
|
||||
return line
|
||||
break
|
||||
return ""
|
||||
|
||||
def _detect_sections(self, text: str) -> list[str]:
|
||||
"""Detect section headings in text."""
|
||||
sections = []
|
||||
for line in text.split('\n'):
|
||||
line = line.strip()
|
||||
# Markdown headings
|
||||
if re.match(r'^#{1,6}\s+', line):
|
||||
sections.append(line)
|
||||
# All-caps headings
|
||||
elif (line.isupper() and 3 < len(line) < 100 and
|
||||
not line.startswith(('---', '===', '***'))):
|
||||
sections.append(line)
|
||||
return sections
|
||||
Reference in New Issue
Block a user