Files

705 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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'<title[^>]*>(.*?)</title>', 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'<meta\s+([^>]+)>', 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'<script[^>]*type\s*=\s*["\']application/ld\+json["\'][^>]*>(.*?)</script>',
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'<h{level}[^>]*>(.*?)</h{level}>', 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'<a\s+[^>]*href\s*=\s*["\']([^"\']+)["\'][^>]*>(.*?)</a>',
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'<table[^>]*>(.*?)</table>', html_text, re.IGNORECASE | re.DOTALL):
table_html = table_match.group(1)
rows = []
for tr_match in re.finditer(r'<tr[^>]*>(.*?)</tr>', table_html, re.IGNORECASE | re.DOTALL):
row_html = tr_match.group(1)
cells = []
for cell_match in re.finditer(
r'<t[dh][^>]*>(.*?)</t[dh]>', 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)