Spaces:
Sleeping
Sleeping
| """ | |
| 邮件解析模块 - 解析 .msg 文件,提取关键字段 | |
| """ | |
| import io | |
| import re | |
| import logging | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Optional | |
| import extract_msg | |
| import html2text | |
| logger = logging.getLogger(__name__) | |
| class ParsedEmail: | |
| """解析后的邮件数据结构""" | |
| filename: str | |
| sender: str | |
| sender_domain: str | |
| date: Optional[datetime] | |
| date_str: str | |
| subject: str | |
| body_text: str | |
| body_preview: str # 前200字符,用于展示 | |
| raw_path: str = "" | |
| parse_error: str = "" | |
| def is_valid(self) -> bool: | |
| return not self.parse_error | |
| class EmailParseResult: | |
| """批量解析结果""" | |
| success: list = field(default_factory=list) | |
| failed: list = field(default_factory=list) | |
| def total(self) -> int: | |
| return len(self.success) + len(self.failed) | |
| def _extract_sender_domain(sender: str) -> str: | |
| """从发件人邮箱地址提取域名""" | |
| match = re.search(r'@([\w.\-]+)', sender) | |
| if match: | |
| domain = match.group(1).lower() | |
| # 去掉顶级域名,取主域名(如 homelight.com -> homelight) | |
| parts = domain.split(".") | |
| if len(parts) >= 2: | |
| return parts[-2] | |
| return parts[0] | |
| return "" | |
| def _html_to_text(html_content: str) -> str: | |
| """将 HTML 正文转为纯文本""" | |
| converter = html2text.HTML2Text() | |
| converter.ignore_links = True | |
| converter.ignore_images = True | |
| converter.ignore_emphasis = False | |
| converter.body_width = 0 # 不换行 | |
| try: | |
| text = converter.handle(html_content) | |
| # 清理多余空行 | |
| lines = [line.rstrip() for line in text.splitlines()] | |
| cleaned = "\n".join(line for line in lines if line.strip()) | |
| return cleaned | |
| except Exception as e: | |
| logger.warning(f"HTML 转文本失败: {e}") | |
| # 降级:用正则去掉 HTML 标签 | |
| return re.sub(r'<[^>]+>', ' ', html_content) | |
| def _clean_body(text: str) -> str: | |
| """清理邮件正文,去除多余空白""" | |
| if not text: | |
| return "" | |
| # 规范化换行 | |
| text = text.replace('\r\n', '\n').replace('\r', '\n') | |
| # 去除连续3个以上空行 | |
| text = re.sub(r'\n{3,}', '\n\n', text) | |
| return text.strip() | |
| def parse_msg_file(file_path: str) -> ParsedEmail: | |
| """ | |
| 解析单个 .msg 文件 | |
| Args: | |
| file_path: .msg 文件路径(或字节数据文件路径) | |
| Returns: | |
| ParsedEmail 对象 | |
| """ | |
| filename = Path(file_path).name | |
| try: | |
| msg = extract_msg.Message(file_path) | |
| # 发件人 | |
| sender_raw = msg.sender or "" | |
| # 尝试提取纯邮箱地址 | |
| email_match = re.search(r'[\w.+\-]+@[\w.\-]+', sender_raw) | |
| sender = email_match.group(0).lower() if email_match else sender_raw.strip() | |
| sender_domain = _extract_sender_domain(sender) | |
| # 日期 | |
| date_obj = None | |
| date_str = "" | |
| try: | |
| if msg.date: | |
| if isinstance(msg.date, datetime): | |
| date_obj = msg.date | |
| else: | |
| from dateutil import parser as dateutil_parser | |
| date_obj = dateutil_parser.parse(str(msg.date)) | |
| date_str = date_obj.strftime("%Y-%m-%d") | |
| except Exception as e: | |
| logger.warning(f"日期解析失败 ({filename}): {e}") | |
| date_str = str(msg.date) if msg.date else "" | |
| # 主题 | |
| subject = (msg.subject or "").strip() | |
| # 正文:优先使用纯文本,否则转换 HTML | |
| body_text = "" | |
| if msg.body: | |
| body_text = _clean_body(msg.body) | |
| if not body_text and msg.htmlBody: | |
| html_content = msg.htmlBody | |
| if isinstance(html_content, bytes): | |
| html_content = html_content.decode("utf-8", errors="ignore") | |
| body_text = _clean_body(_html_to_text(html_content)) | |
| # 若两者都为空,记录警告 | |
| if not body_text: | |
| logger.warning(f"邮件正文为空: {filename}") | |
| body_text = "[邮件正文为空]" | |
| # 预览(前200字符) | |
| body_preview = body_text[:200].replace('\n', ' ') + ("..." if len(body_text) > 200 else "") | |
| msg.close() | |
| return ParsedEmail( | |
| filename=filename, | |
| sender=sender, | |
| sender_domain=sender_domain, | |
| date=date_obj, | |
| date_str=date_str, | |
| subject=subject, | |
| body_text=body_text, | |
| body_preview=body_preview, | |
| raw_path=file_path, | |
| ) | |
| except Exception as e: | |
| logger.error(f"解析 .msg 文件失败 ({filename}): {e}", exc_info=True) | |
| return ParsedEmail( | |
| filename=filename, | |
| sender="", | |
| sender_domain="", | |
| date=None, | |
| date_str="", | |
| subject="", | |
| body_text="", | |
| body_preview="", | |
| raw_path=file_path, | |
| parse_error=str(e), | |
| ) | |
| def parse_msg_bytes(file_bytes: bytes, filename: str) -> ParsedEmail: | |
| """ | |
| 从字节流解析 .msg 文件(Gradio 上传的场景) | |
| Args: | |
| file_bytes: 文件字节内容 | |
| filename: 原始文件名 | |
| Returns: | |
| ParsedEmail 对象 | |
| """ | |
| import tempfile | |
| import os | |
| # 写入临时文件(extract_msg 需要文件路径) | |
| with tempfile.NamedTemporaryFile(suffix=".msg", delete=False) as tmp: | |
| tmp.write(file_bytes) | |
| tmp_path = tmp.name | |
| try: | |
| result = parse_msg_file(tmp_path) | |
| result.filename = filename # 恢复原始文件名 | |
| return result | |
| finally: | |
| try: | |
| os.unlink(tmp_path) | |
| except Exception: | |
| pass | |
| def batch_parse_msg_files(file_paths: list[str]) -> EmailParseResult: | |
| """ | |
| 批量解析多个 .msg 文件 | |
| Args: | |
| file_paths: .msg 文件路径列表 | |
| Returns: | |
| EmailParseResult 对象(含成功/失败列表) | |
| """ | |
| result = EmailParseResult() | |
| for path in file_paths: | |
| parsed = parse_msg_file(path) | |
| if parsed.is_valid: | |
| result.success.append(parsed) | |
| else: | |
| result.failed.append(parsed) | |
| return result | |