# -*- coding: utf-8 -*- """ 增强文件/网页解析模块 v4.0 支持 PPT、HTML、Markdown、CSV、JSON 等格式的深度解析, 与现有 document_parser.py 无缝集成。 新增能力: - PPTX 解析:文本 + 表格 + 备注 + 图片alt文本 - HTML 解析:清洗标签、保留表格结构、提取 meta/JSON-LD - Markdown 解析:章节结构、表格、代码块 - MultiFormatParser:自动检测格式,统一入口 """ import re import os import json import csv import io import html as html_module from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from datetime import datetime # ==================== 依赖检测 ==================== PPTX_AVAILABLE = False try: from pptx import Presentation # type: ignore PPTX_AVAILABLE = True except ImportError: pass HTML_PARSER_AVAILABLE = False try: from html.parser import HTMLParser as StdHTMLParser HTML_PARSER_AVAILABLE = True except ImportError: pass LXML_AVAILABLE = False try: import lxml.html # type: ignore LXML_AVAILABLE = True except ImportError: pass BS4_AVAILABLE = False try: from bs4 import BeautifulSoup # type: ignore BS4_AVAILABLE = True except ImportError: pass SKILL_DATA_DIR = Path(__file__).parent.parent / "data" SKILL_DATA_DIR.mkdir(parents=True, exist_ok=True) # ==================== PPTX 解析器 ==================== class PPTXParser: """PowerPoint 文件解析器,支持文本/表格/备注提取。""" def __init__(self): self.available = PPTX_AVAILABLE def parse(self, file_path: str) -> Dict[str, Any]: """ 解析 PPTX 文件。 返回: { file_type, file_path, slide_count, slides: [{slide_num, title, texts, tables, notes, images}], all_text, all_notes, all_tables_summary } """ result: Dict[str, Any] = { "file_type": "pptx", "file_path": file_path, "slide_count": 0, "slides": [], "all_text": "", "all_notes": "", "all_tables_summary": [], "metadata": {}, } if not self.available: result["error"] = "python-pptx 未安装,请运行: pip install python-pptx" return result try: prs = Presentation(file_path) # 提取文档属性 props = prs.core_properties result["metadata"] = { "title": getattr(props, 'title', '') or '', "author": getattr(props, 'author', '') or '', "created": str(getattr(props, 'created', '')) if getattr(props, 'created', None) else '', "modified": str(getattr(props, 'modified', '')) if getattr(props, 'modified', None) else '', "slide_count": len(prs.slides), } result["slide_count"] = len(prs.slides) all_text_parts = [] all_notes_parts = [] for slide_num, slide in enumerate(prs.slides, 1): slide_data = { "slide_num": slide_num, "title": "", "texts": [], "tables": [], "notes": "", "image_count": 0, } # 提取标题(第一个非空文本框视为标题) first_text_found = False for shape in slide.shapes: # 文本框 if shape.has_text_frame: text = shape.text_frame.text.strip() if text: if not first_text_found: slide_data["title"] = text[:200] first_text_found = True slide_data["texts"].append(text) all_text_parts.append(text) # 表格 if shape.has_table: table = shape.table table_data = { "rows": table.rows.__len__(), "cols": len(table.columns), "headers": [], "data": [], } for ri, row in enumerate(table.rows): row_data = [cell.text.strip() for cell in row.cells] if ri == 0: table_data["headers"] = row_data else: table_data["data"].append(row_data) slide_data["tables"].append(table_data) result["all_tables_summary"].append({ "slide": slide_num, "rows": table_data["rows"], "cols": table_data["cols"], "headers": table_data["headers"][:8], }) # 图片计数 if shape.shape_type == 13: # MSO_SHAPE_TYPE.PICTURE slide_data["image_count"] += 1 # 提取备注 if slide.has_notes_slide: notes_slide = slide.notes_slide notes_text = notes_slide.notes_text_frame.text.strip() if notes_slide.notes_text_frame else "" slide_data["notes"] = notes_text if notes_text: all_notes_parts.append(f"[Slide {slide_num}]\n{notes_text}") result["slides"].append(slide_data) result["all_text"] = "\n\n".join(all_text_parts) result["all_notes"] = "\n\n".join(all_notes_parts) result["text_content"] = result["all_text"] # 兼容 document_parser 接口 except Exception as e: result["error"] = f"PPTX 解析失败: {str(e)}" return result # ==================== HTML 解析器 ==================== class HTMLParser: """HTML 网页/文件解析器,支持清洗标签、提取表格/结构化数据。""" def __init__(self): self.available = True def parse(self, source: str, is_url: bool = False) -> Dict[str, Any]: """ 解析 HTML 内容。 Args: source: HTML 文本字符串或文件路径/URL is_url: 是否为 URL(需额外请求) 返回: { file_type, title, meta, text_content, tables, links, json_ld, headings, financial_data } """ result: Dict[str, Any] = { "file_type": "html", "title": "", "meta": {}, "text_content": "", "tables": [], "links": [], "json_ld": [], "headings": [], "financial_data": {}, } html_text = source # 如果是文件路径,读取文件 if not is_url and os.path.isfile(source): try: with open(source, 'r', encoding='utf-8') as f: html_text = f.read() result["file_path"] = source except UnicodeDecodeError: try: with open(source, 'r', encoding='gbk') as f: html_text = f.read() result["file_path"] = source except Exception as e: result["error"] = f"文件读取失败: {str(e)}" return result if not html_text or len(html_text.strip()) < 10: result["error"] = "HTML 内容为空" return result try: # 提取 title title_match = re.search(r']*>(.*?)', html_text, re.IGNORECASE | re.DOTALL) if title_match: result["title"] = self._clean_text(title_match.group(1)) # 提取 meta 标签 for meta_match in re.finditer( r']+)>', html_text, re.IGNORECASE ): attrs = meta_match.group(1) name_match = re.search(r'(?:name|property|http-equiv)\s*=\s*["\']([^"\']+)["\']', attrs, re.IGNORECASE) content_match = re.search(r'content\s*=\s*["\']([^"\']+)["\']', attrs, re.IGNORECASE) if name_match and content_match: result["meta"][name_match.group(1).lower()] = content_match.group(1) # 提取 JSON-LD 结构化数据 for ld_match in re.finditer( r']*type\s*=\s*["\']application/ld\+json["\'][^>]*>(.*?)', html_text, re.IGNORECASE | re.DOTALL ): try: ld_data = json.loads(ld_match.group(1)) result["json_ld"].append(ld_data) except json.JSONDecodeError: pass # 提取表格 result["tables"] = self._extract_tables(html_text) # 提取标题层级 for level in range(1, 7): for h_match in re.finditer( f']*>(.*?)', html_text, re.IGNORECASE | re.DOTALL ): text = self._clean_text(h_match.group(1)) if text: result["headings"].append({"level": level, "text": text[:200]}) # 提取链接 for a_match in re.finditer( r']*href\s*=\s*["\']([^"\']+)["\'][^>]*>(.*?)', html_text, re.IGNORECASE | re.DOTALL ): href = a_match.group(1).strip() text = self._clean_text(a_match.group(2)) if href and not href.startswith('#'): result["links"].append({"url": href, "text": text[:200]}) # 提取纯文本(去除标签) result["text_content"] = self._extract_text(html_text) # 尝试提取财务数据 result["financial_data"] = self._extract_financial_from_html(html_text) result["char_count"] = len(result["text_content"]) except Exception as e: result["error"] = f"HTML 解析异常: {str(e)}" return result def _clean_text(self, text: str) -> str: """清洗 HTML 实体和多余空白。""" text = re.sub(r'<[^>]+>', '', text) # 去掉嵌套标签 text = html_module.unescape(text) text = re.sub(r'\s+', ' ', text) return text.strip() def _extract_text(self, html_text: str) -> str: """从 HTML 提取纯文本(保留段落结构)。""" # 先处理换行标签 text = re.sub(r'<(?:br|BR)\s*/?>', '\n', html_text) text = re.sub(r'<(?:p|P|div|DIV|li|LI|tr|TR|h\d|H\d)[^>]*>', '\n', text) # 去除所有标签 text = re.sub(r'<[^>]+>', '', text) # 处理注释 text = re.sub(r'', '', text, flags=re.DOTALL) # 处理 script/style text = re.sub(r'<(?:script|style|SCRIPT|STYLE)[^>]*>.*?', '', text, flags=re.DOTALL) # 解码 HTML 实体 text = html_module.unescape(text) # 压缩空行 text = re.sub(r'\n\s*\n', '\n\n', text) text = re.sub(r'[ \t]{2,}', ' ', text) return text.strip() def _extract_tables(self, html_text: str) -> List[Dict[str, Any]]: """提取 HTML 中的表格。""" tables = [] for table_match in re.finditer(r']*>(.*?)', html_text, re.IGNORECASE | re.DOTALL): table_html = table_match.group(1) rows = [] for tr_match in re.finditer(r']*>(.*?)', table_html, re.IGNORECASE | re.DOTALL): row_html = tr_match.group(1) cells = [] for cell_match in re.finditer( r']*>(.*?)', row_html, re.IGNORECASE | re.DOTALL ): cells.append(self._clean_text(cell_match.group(1))) if cells: rows.append(cells) if rows: tables.append({ "rows": len(rows), "cols": max(len(r) for r in rows) if rows else 0, "headers": rows[0] if rows else [], "data": rows[1:] if len(rows) > 1 else [], }) return tables def _extract_financial_from_html(self, html_text: str) -> Dict[str, Any]: """从 HTML 文本中提取财务数据。""" fin_data: Dict[str, Any] = {} text = self._extract_text(html_text) patterns = { "营业收入": r'营业(?:总)?收入[::]\s*([\d,.]+\s*(?:亿|万|元)?)', "净利润": r'(?:归属.*)?净利润[::]\s*([\d,.]+\s*(?:亿|万|元)?)', "总资产": r'总资产[::]\s*([\d,.]+\s*(?:亿|万|元)?)', "每股收益": r'(?:基本)?每股收益[::]\s*([\d,.]+)', "净资产收益率": r'净资产收益率[::]\s*([\d,.]+)\s*%?', } for key, pat in patterns.items(): m = re.search(pat, text) if m: fin_data[key] = m.group(1).strip() return fin_data # ==================== Markdown 解析器 ==================== class MarkdownParser: """Markdown 文档解析器,支持章节/表格/代码块提取。""" def parse(self, source: str) -> Dict[str, Any]: """ 解析 Markdown 内容。 返回: {file_type, title, sections, tables, code_blocks, text_content, metadata} """ result: Dict[str, Any] = { "file_type": "markdown", "title": "", "sections": [], "tables": [], "code_blocks": [], "text_content": "", "metadata": {}, } if os.path.isfile(source): try: with open(source, 'r', encoding='utf-8') as f: text = f.read() result["file_path"] = source except Exception as e: result["error"] = f"文件读取失败: {str(e)}" return result else: text = source if not text.strip(): result["error"] = "内容为空" return result lines = text.split('\n') result["text_content"] = text # 提取 frontmatter fm = self._extract_frontmatter(text) if fm: result["metadata"] = fm # 提取标题 (第一个 # 标题) for line in lines: h1 = re.match(r'^#\s+(.+)', line) if h1: result["title"] = h1.group(1).strip()[:200] break if not result["title"] and result["metadata"].get("title"): result["title"] = result["metadata"]["title"] # 提取章节结构 current_section = None for line in lines: sec_match = re.match(r'^(#{1,6})\s+(.+)', line) if sec_match: level = len(sec_match.group(1)) title = sec_match.group(2).strip()[:200] section = {"level": level, "title": title, "content_lines": []} result["sections"].append(section) current_section = section elif current_section is not None: current_section["content_lines"].append(line) # 为每个章节生成摘要 for sec in result["sections"]: sec["preview"] = ' '.join(sec["content_lines"][:3])[:200] # 提取表格 result["tables"] = self._extract_md_tables(text) # 提取代码块 for code_match in re.finditer(r'```(\w*)\n(.*?)```', text, re.DOTALL): result["code_blocks"].append({ "language": code_match.group(1) or "text", "content": code_match.group(2).strip(), "line_count": len(code_match.group(2).strip().split('\n')), }) result["char_count"] = len(text) return result def _extract_frontmatter(self, text: str) -> Dict[str, Any]: """提取 YAML frontmatter。""" fm_match = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL) if not fm_match: return {} fm = {} try: import yaml return yaml.safe_load(fm_match.group(1)) or {} except ImportError: pass # 手动解析简单 YAML for line in fm_match.group(1).split('\n'): kv = re.match(r'^(\w[\w_-]*)\s*:\s*(.+)', line.strip()) if kv: key, val = kv.group(1), kv.group(2).strip().strip('"\'') fm[key] = val return fm def _extract_md_tables(self, text: str) -> List[Dict[str, Any]]: """提取 Markdown 表格。""" tables = [] lines = text.split('\n') i = 0 while i < len(lines): line = lines[i].strip() if '|' in line and line.count('|') >= 1: # 检查下一行是否是分隔行 if i + 1 < len(lines) and re.match(r'^[\|\s\-:]+$', lines[i + 1].strip()): # 这是一个表格 table_lines = [line] header = [c.strip() for c in line.split('|') if c.strip()] i += 2 # 跳过分隔行 data_rows = [] while i < len(lines) and '|' in lines[i]: row = [c.strip() for c in lines[i].split('|') if c.strip()] if row: data_rows.append(row) i += 1 tables.append({ "rows": 1 + len(data_rows), "cols": len(header), "headers": header, "data": data_rows, }) continue i += 1 return tables # ==================== CSV 解析器 ==================== class CSVParser: """CSV 文件解析器,支持多种编码自动检测。""" def parse(self, file_path: str) -> Dict[str, Any]: """解析 CSV 文件。""" result: Dict[str, Any] = { "file_type": "csv", "file_path": file_path, "headers": [], "rows": [], "row_count": 0, "col_count": 0, "text_content": "", } if not os.path.isfile(file_path): result["error"] = f"文件不存在: {file_path}" return result encodings = ['utf-8', 'utf-8-sig', 'gbk', 'gb2312', 'gb18030'] for enc in encodings: try: with open(file_path, 'r', encoding=enc) as f: reader = csv.reader(f) rows = list(reader) if rows: result["headers"] = rows[0] result["rows"] = rows[1:] result["row_count"] = len(rows) result["col_count"] = len(rows[0]) if rows else 0 result["text_content"] = '\n'.join([','.join(r) for r in rows]) result["encoding"] = enc return result except (UnicodeDecodeError, Exception): continue result["error"] = "无法识别 CSV 编码" return result # ==================== 多格式统一解析器 ==================== class MultiFormatParser: """ 多格式统一解析入口。 自动检测文件格式,分发到对应解析器。 """ SUPPORTED_EXTENSIONS = { '.pptx': 'pptx', '.ppt': 'pptx', '.html': 'html', '.htm': 'html', '.md': 'markdown', '.markdown': 'markdown', '.csv': 'csv', '.json': 'json', '.pdf': 'pdf', '.docx': 'docx', '.doc': 'docx', '.xlsx': 'xlsx', '.xls': 'xlsx', '.txt': 'text', } def __init__(self): self.pptx_parser = PPTXParser() self.html_parser = HTMLParser() self.md_parser = MarkdownParser() self.csv_parser = CSVParser() def parse(self, source: str, fmt: str = "auto") -> Dict[str, Any]: """ 统一解析入口。 Args: source: 文件路径、URL 或文本内容 fmt: 格式 — auto(自动检测) / pptx / html / markdown / csv 返回: 解析结果字典 """ # 自动检测 if fmt == "auto": if source.startswith(('http://', 'https://')): fmt = "html_url" elif os.path.isfile(source): ext = Path(source).suffix.lower() fmt = self.SUPPORTED_EXTENSIONS.get(ext, "text") else: # 启发式检测:是否为 HTML/Markdown 文本 if re.search(r'<(!DOCTYPE|html|head|body|div|table)', source[:500], re.IGNORECASE): fmt = "html" elif re.search(r'^#{1,6}\s', source[:500], re.MULTILINE): fmt = "markdown" else: fmt = "text" # 分发解析 if fmt == "pptx": return self.pptx_parser.parse(source) elif fmt in ("html", "html_url"): return self.html_parser.parse(source, is_url=(fmt == "html_url")) elif fmt == "markdown": return self.md_parser.parse(source) elif fmt == "csv": return self.csv_parser.parse(source) elif fmt == "json": return self._parse_json(source) elif fmt in ("pdf", "docx", "doc", "xlsx", "xls", "text"): # 委托给现有 document_parser try: from document_parser import parse_document return parse_document(source) except ImportError: return {"error": "document_parser 不可用", "file_path": source} else: return {"error": f"不支持的格式: {fmt}", "file_path": source} def _parse_json(self, source: str) -> Dict[str, Any]: """解析 JSON 文件。""" if os.path.isfile(source): try: with open(source, 'r', encoding='utf-8') as f: data = json.load(f) except Exception as e: return {"error": f"JSON 解析失败: {str(e)}", "file_path": source} else: try: data = json.loads(source) except json.JSONDecodeError as e: return {"error": f"JSON 解析失败: {str(e)}"} return { "file_type": "json", "file_path": source if os.path.isfile(source) else "", "data": data, "text_content": json.dumps(data, ensure_ascii=False, indent=2), "char_count": len(json.dumps(data, ensure_ascii=False)), } # ==================== 便捷函数 ==================== def parse_file_enhanced(file_path: str, fmt: str = "auto") -> Dict[str, Any]: """一键解析任意文件/网页。""" parser = MultiFormatParser() return parser.parse(file_path, fmt) def parse_pptx(file_path: str) -> Dict[str, Any]: """解析 PPTX 文件。""" return PPTXParser().parse(file_path) def parse_html(source: str, is_url: bool = False) -> Dict[str, Any]: """解析 HTML 内容。""" return HTMLParser().parse(source, is_url) def parse_markdown(source: str) -> Dict[str, Any]: """解析 Markdown 内容。""" return MarkdownParser().parse(source) def extract_tables_from_html(html_text: str) -> List[Dict[str, Any]]: """从 HTML 中提取所有表格。""" parser = HTMLParser() return parser._extract_tables(html_text) # ==================== CLI 入口 ==================== if __name__ == "__main__": import sys if len(sys.argv) < 2: print("用法: python enhanced_parser.py <文件路径> [格式]") print("格式: auto(默认) / pptx / html / markdown / csv / json") sys.exit(1) source = sys.argv[1] fmt = sys.argv[2] if len(sys.argv) > 2 else "auto" parser = MultiFormatParser() result = parser.parse(source, fmt) if result.get("error"): print(f"❌ {result['error']}") else: print(f"✅ 解析成功: {result.get('file_type', '?').upper()}") print(f" 字符数: {result.get('char_count', 0):,}") if result.get("title"): print(f" 标题: {result['title']}") if result.get("slide_count"): print(f" 幻灯片: {result['slide_count']} 页") if result.get("tables"): print(f" 表格: {len(result['tables'])} 个") if result.get("headings"): print(f" 章节: {len(result['headings'])} 个") print(f"\n--- 文本预览 (前500字) ---") preview = result.get("text_content", "")[:500] print(preview)