# -*- 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'',
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)[^>]*>.*?(?: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)