从零构建智能HTML转Markdown转换器:Python GUI开发实战

🚀 从零构建智能HTML转Markdown转换器:Python GUI开发实战

副标题: 用tkinter + BeautifulSoup4 + html2text打造专业级文档转换工具

成品截图

📖 项目背景

最近用了天工,天工在生成研究报告方面独树一帜,苦恼的是输出的是html文件(免费),其他格式需要消耗积分,不得已,自己开发了一个。今天,我将分享如何从零开始构建一个功能强大的HTML转Markdown转换器的完整过程。

项目亮点:

  • 🎨 现代化tkinter GUI界面
  • 🔍 智能HTML解析和格式识别
  • 📊 复杂内容智能处理
  • ⚡ 高性能批量转换

🏗️ 技术架构设计

核心技术栈

组件 选择理由 版本
tkinter Python内置,无需额外安装 3.6+
BeautifulSoup4 强大的HTML解析能力 4.12.3+
html2text 专业的转换引擎 2024.2.26+
chardet 智能编码检测 最新版

项目结构

复制代码
📁 html_2_markdown/
├── main.py              # 🎨 GUI主程序
├── html_converter.py    # 🔄 转换核心引擎
├── file_handler.py      # 📁 文件处理模块
└── requirements.txt     # 📦 依赖管理

💻 核心功能实现

1. 智能HTML解析引擎

转换器的核心是智能解析HTML内容并进行格式转换:

python 复制代码
class HtmlToMarkdownConverter:
    """HTML到Markdown转换器核心类"""
    
    def __init__(self):
        self.h2t = html2text.HTML2Text()
        self._setup_default_config()
    
    def _setup_default_config(self):
        """配置转换器参数"""
        self.h2t.ignore_links = False
        self.h2t.ignore_images = False
        self.h2t.body_width = 0          # 不限制行宽
        self.h2t.unicode_snob = True     # 支持中文
        self.h2t.inline_links = True     # 内联链接
        self.h2t.ul_item_mark = '-'      # 列表标记

关键设计思路:

  • body_width = 0 确保内容完整性
  • unicode_snob = True 优化中文处理
  • 灵活的配置系统支持不同场景

2. 特殊格式智能识别

这是项目的一大亮点 - 智能识别特殊CSS类并转换:

python 复制代码
def _process_special_spans(self, soup):
    """处理特殊格式的span标签"""
    
    # 🔆 高亮文本 → 行内代码
    for span in soup.find_all('span', class_='highlight'):
        span.name = 'code'
        if 'class' in span.attrs:
            del span.attrs['class']
    
    # ⭐ 重要文本 → 粗体
    for span in soup.find_all('span', class_='important'):
        span.name = 'strong'
        if 'class' in span.attrs:
            del span.attrs['class']
    
    # 📝 批量处理其他格式
    special_classes = {
        'emphasis': 'em',
        'italic': 'em', 
        'bold': 'strong',
        'code': 'code'
    }
    
    for class_name, tag_name in special_classes.items():
        for span in soup.find_all('span', class_=class_name):
            span.name = tag_name
            if 'class' in span.attrs:
                del span.attrs['class']

转换效果对比:

原始HTML:

html 复制代码
<p>这段文字包含<span class="highlight">高亮内容</span>和
<span class="important">重要信息</span>。</p>

转换结果:

markdown 复制代码
这段文字包含`高亮内容`和**重要信息**。

3. 图表内容智能处理

对于复杂的图表内容,生成详细的占位符说明:

python 复制代码
def _process_charts_and_scripts(self, soup):
    """处理图表内容,生成智能占位符"""
    
    chart_containers = soup.find_all(['div'], 
                                   {'class': lambda x: x and 'chart' in str(x).lower()})
    
    for container in chart_containers:
        chart_id = container.get('id', 'unknown')
        
        if 'chart1' in chart_id.lower():
            placeholder = self._create_detailed_placeholder(
                title="自由软件的四大核心自由",
                description="展示软件自由理念的核心架构",
                details=[
                    "**自由 0**:运行软件的自由",
                    "**自由 1**:研究和修改源代码的自由", 
                    "**自由 2**:重新分发拷贝的自由",
                    "**自由 3**:发布改进版本的自由"
                ]
            )
        else:
            placeholder = self._create_generic_placeholder(chart_id)
        
        container.clear()
        container.append(BeautifulSoup(placeholder, 'html.parser'))

生成的占位符效果:

markdown 复制代码
📊 交互式图表:自由软件的四大核心自由

下方图表展示了自由软件理念的核心架构,以"软件自由"为中心。

- **自由 0**:运行软件的自由
- **自由 1**:研究和修改源代码的自由
- **自由 2**:重新分发拷贝的自由
- **自由 3**:发布改进版本的自由

4. 现代化GUI界面

使用tkinter构建专业的用户界面:

python 复制代码
class HtmlToMarkdownApp:
    def __init__(self, root):
        self.root = root
        self.converter = HtmlToMarkdownConverter()
        self._setup_modern_ui()
    
    def _setup_modern_ui(self):
        """设置现代化界面"""
        self.root.title("HTML to Markdown 转换器")
        self.root.geometry("900x700")
        
        # 使用现代主题
        style = ttk.Style()
        style.theme_use('clam')
        
        # 创建主要组件
        self._create_file_selection()
        self._create_options_panel()
        self._create_preview_tabs()
        self._create_control_buttons()
    
    def _create_preview_tabs(self):
        """创建预览标签页"""
        self.notebook = ttk.Notebook(self.preview_frame)
        
        # HTML源码标签页
        html_frame = ttk.Frame(self.notebook)
        self.notebook.add(html_frame, text="HTML源码")
        self.html_text = scrolledtext.ScrolledText(
            html_frame, font=('Consolas', 9))
        
        # Markdown输出标签页  
        md_frame = ttk.Frame(self.notebook)
        self.notebook.add(md_frame, text="Markdown输出")
        self.markdown_text = scrolledtext.ScrolledText(
            md_frame, font=('Consolas', 9))

界面特色:

  • 📋 标签页式预览界面
  • 🎨 现代化ttk组件
  • 📝 代码编辑器风格的文本框
  • ⚙️ 直观的选项配置面板

🧠 核心算法详解

空标签智能清理

空的强调标签会产生多余的Markdown标记,需要智能清理:

python 复制代码
def _clean_empty_emphasis_tags(self, soup):
    """清理空的强调标签"""
    emphasis_tags = ['em', 'i', 'strong', 'b', 'u', 'code']
    
    for tag_name in emphasis_tags:
        for tag in soup.find_all(tag_name):
            if self._is_empty_tag(tag):
                # 智能处理空格,避免单词连接
                if tag.next_sibling and isinstance(tag.next_sibling, str):
                    replacement = ' ' if not tag.next_sibling.startswith(' ') else ''
                    tag.replace_with(replacement)
                else:
                    tag.decompose()

def _is_empty_tag(self, tag) -> bool:
    """判断标签是否为空"""
    text_content = tag.get_text(strip=True)
    return not text_content or text_content.strip() in ['', '\n', '\t', '\r']

多编码智能识别

处理不同编码的HTML文件:

python 复制代码
def read_file_with_encoding_detection(self, file_path: str) -> Optional[str]:
    """智能编码检测读取"""
    try:
        # 读取原始字节数据
        with open(file_path, 'rb') as f:
            raw_data = f.read()
        
        # 检测编码
        detected = chardet.detect(raw_data)
        encoding = detected['encoding']
        
        # 使用检测到的编码读取
        with open(file_path, 'r', encoding=encoding) as f:
            return f.read()
            
    except Exception as e:
        # 降级处理:尝试常见编码
        for encoding in ['utf-8', 'gbk', 'gb2312']:
            try:
                with open(file_path, 'r', encoding=encoding) as f:
                    return f.read()
            except:
                continue
        return None

🎯 开发中的关键挑战

挑战1:界面响应性

问题: 大文件转换时界面卡顿

解决方案: 多线程异步处理

python 复制代码
def _start_conversion_async(self):
    """异步转换,避免UI卡顿"""
    self.is_converting = True
    self._update_ui_state()
    
    # 后台线程执行转换
    thread = threading.Thread(target=self._conversion_worker)
    thread.daemon = True
    thread.start()

def _conversion_worker(self):
    """转换工作线程"""
    try:
        success = self._perform_conversion()
        # 安全更新UI
        self.root.after(0, self._conversion_completed, success)
    except Exception as e:
        self.root.after(0, self._conversion_error, str(e))

挑战2:复杂HTML结构

问题: 现实HTML文档结构复杂,包含大量无用元素

解决方案: 分层清理策略

python 复制代码
def _clean_html_step_by_step(self, html_content: str) -> str:
    """分步骤清理HTML"""
    soup = BeautifulSoup(html_content, 'html.parser')
    
    # 第一步:处理特殊格式
    self._process_special_spans(soup)
    
    # 第二步:清理空标签
    self._clean_empty_emphasis_tags(soup)
    
    # 第三步:处理复杂内容
    self._process_charts_and_scripts(soup)
    
    # 第四步:移除无用元素
    self._remove_unwanted_elements(soup)
    
    return str(soup)

📊 功能特性对比

功能特性 普通转换器 我们的转换器 优势
特殊格式识别 智能CSS类转换
图表处理 生成详细说明
内部链接 部分 完美锚点支持
编码检测 基础 智能多编码识别
实时预览 所见即所得
空标签清理 避免冗余标记

🎓 技术要点总结

Python GUI开发技巧

  1. 现代化界面设计:使用ttk组件和现代主题
  2. 响应式布局:合理使用grid和pack布局管理器
  3. 多线程编程:防止长时间操作阻塞UI
  4. 事件驱动设计:优雅的用户交互处理

HTML处理技术

  1. BeautifulSoup4高级用法:DOM树操作和元素查找
  2. 智能内容识别:基于CSS类的格式转换
  3. 编码处理:多编码自动检测和转换
  4. 性能优化:大文件的分块处理

软件工程实践

  1. 模块化设计:清晰的职责分离
  2. 错误处理:完善的异常捕获机制
  3. 用户体验:友好的进度提示和错误反馈
  4. 可扩展性:支持新格式和功能的架构设计

🚀 项目扩展方向

短期优化

  • 🎨 添加深色主题支持
  • ⚡ 缓存机制优化性能
  • 📊 更多图表类型支持
  • 🔧 自定义转换规则

长期发展

  • 🌐 Web版本开发
  • 📱 移动端适配
  • 🤖 AI内容理解
  • ☁️ 云端协作功能

💡 关键收获

通过这个项目,我们不仅构建了一个实用的工具,更重要的是掌握了:

  1. 系统性思考:从需求分析到架构设计的完整流程
  2. 技术整合:多个Python库的协同使用
  3. 用户体验:GUI应用的人性化设计
  4. 性能优化:大数据量处理的优化策略

这个HTML转Markdown转换器展示了Python在桌面应用开发中的强大能力。通过合理的架构设计和技术选择,我们可以用相对简单的代码实现复杂而实用的功能。

完整源码已开源,欢迎Star和贡献! 🌟

main.py

复制代码
"""
HTML to Markdown 转换器 - 主应用程序
基于tkinter的图形用户界面
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import os
import sys
import threading
from pathlib import Path
from typing import Optional, Dict, Any

# 导入自定义模块
try:
    from html_converter import HtmlToMarkdownConverter
    from file_handler import FileHandler
except ImportError as e:
    print(f"导入模块失败:{e}")
    print("请先安装依赖:pip install -r requirements.txt")
    sys.exit(1)


class HtmlToMarkdownApp:
    """HTML到Markdown转换器主应用类"""
    
    def __init__(self, root):
        """初始化应用"""
        self.root = root
        self.converter = HtmlToMarkdownConverter()
        self.file_handler = FileHandler()
        
        # 应用状态
        self.current_html_file = ""
        self.current_output_dir = ""
        self.is_converting = False
        
        self._setup_window()
        self._create_widgets()
        self._bind_events()
    
    def _setup_window(self):
        """设置主窗口"""
        self.root.title("HTML to Markdown 转换器")
        self.root.geometry("900x700")
        self.root.minsize(800, 600)
        
        # 设置窗口图标(如果有的话)
        try:
            # self.root.iconbitmap("icon.ico")
            pass
        except:
            pass
        
        # 配置样式
        style = ttk.Style()
        style.theme_use('clam')
    
    def _create_widgets(self):
        """创建界面组件"""
        # 创建主框架
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.grid(row=0, column=0, sticky="nsew")
        
        # 配置权重
        self.root.columnconfigure(0, weight=1)
        self.root.rowconfigure(0, weight=1)
        main_frame.columnconfigure(1, weight=1)
        
        # 标题
        title_label = ttk.Label(main_frame, text="HTML to Markdown 转换器", 
                               font=('Arial', 16, 'bold'))
        title_label.grid(row=0, column=0, columnspan=3, pady=(0, 20))
        
        # 文件选择区域
        self._create_file_selection_area(main_frame)
        
        # 转换选项区域
        self._create_conversion_options_area(main_frame)
        
        # 预览区域
        self._create_preview_area(main_frame)
        
        # 控制按钮区域
        self._create_control_buttons_area(main_frame)
        
        # 状态栏
        self._create_status_bar(main_frame)
    
    def _create_file_selection_area(self, parent):
        """创建文件选择区域"""
        # 文件选择框架
        file_frame = ttk.LabelFrame(parent, text="文件选择", padding="10")
        file_frame.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(0, 10))
        file_frame.columnconfigure(1, weight=1)
        
        # HTML文件选择
        ttk.Label(file_frame, text="HTML文件:").grid(row=0, column=0, sticky=tk.W, pady=2)
        self.html_file_var = tk.StringVar()
        html_entry = ttk.Entry(file_frame, textvariable=self.html_file_var, state='readonly')
        html_entry.grid(row=0, column=1, sticky="ew", padx=(10, 5), pady=2)
        ttk.Button(file_frame, text="浏览...", 
                  command=self._select_html_file).grid(row=0, column=2, pady=2)
        
        # 输出目录选择
        ttk.Label(file_frame, text="输出目录:").grid(row=1, column=0, sticky=tk.W, pady=2)
        self.output_dir_var = tk.StringVar()
        output_entry = ttk.Entry(file_frame, textvariable=self.output_dir_var, state='readonly')
        output_entry.grid(row=1, column=1, sticky="ew", padx=(10, 5), pady=2)
        ttk.Button(file_frame, text="浏览...", 
                  command=self._select_output_dir).grid(row=1, column=2, pady=2)
        
        # 文件信息
        self.file_info_var = tk.StringVar(value="请选择HTML文件")
        info_label = ttk.Label(file_frame, textvariable=self.file_info_var, 
                              foreground="gray", font=('Arial', 8))
        info_label.grid(row=2, column=0, columnspan=3, sticky=tk.W, pady=(5, 0))
    
    def _create_conversion_options_area(self, parent):
        """创建转换选项区域"""
        options_frame = ttk.LabelFrame(parent, text="转换选项", padding="10")
        options_frame.grid(row=2, column=0, columnspan=3, sticky="ew", pady=(0, 10))
        options_frame.columnconfigure(1, weight=1)
        
        # 预设选择
        ttk.Label(options_frame, text="预设:").grid(row=0, column=0, sticky=tk.W, pady=2)
        self.preset_var = tk.StringVar(value="default")
        preset_combo = ttk.Combobox(options_frame, textvariable=self.preset_var, 
                                   values=["default", "simple", "github"], 
                                   state="readonly", width=15)
        preset_combo.grid(row=0, column=1, sticky=tk.W, padx=(10, 0), pady=2)
        preset_combo.bind('<<ComboboxSelected>>', self._on_preset_changed)
        
        # 选项复选框
        checkbox_frame = ttk.Frame(options_frame)
        checkbox_frame.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(10, 0))
        
        # 创建选项变量
        self.ignore_links_var = tk.BooleanVar()
        self.ignore_images_var = tk.BooleanVar()
        self.ignore_emphasis_var = tk.BooleanVar()
        self.clean_html_var = tk.BooleanVar(value=True)
        self.clean_empty_emphasis_var = tk.BooleanVar(value=True)
        
        # 选项复选框
        ttk.Checkbutton(checkbox_frame, text="忽略链接", 
                       variable=self.ignore_links_var).grid(row=0, column=0, sticky=tk.W, padx=(0, 15))
        ttk.Checkbutton(checkbox_frame, text="忽略图片", 
                       variable=self.ignore_images_var).grid(row=0, column=1, sticky=tk.W, padx=(0, 15))
        ttk.Checkbutton(checkbox_frame, text="忽略强调", 
                       variable=self.ignore_emphasis_var).grid(row=0, column=2, sticky=tk.W, padx=(0, 15))
        ttk.Checkbutton(checkbox_frame, text="清理HTML", 
                       variable=self.clean_html_var).grid(row=0, column=3, sticky=tk.W, padx=(0, 15))
        ttk.Checkbutton(checkbox_frame, text="清理空标签", 
                       variable=self.clean_empty_emphasis_var).grid(row=0, column=4, sticky=tk.W)
    
    def _create_preview_area(self, parent):
        """创建预览区域"""
        preview_frame = ttk.LabelFrame(parent, text="预览", padding="10")
        preview_frame.grid(row=3, column=0, columnspan=3, sticky="nsew", pady=(0, 10))
        parent.rowconfigure(3, weight=1)
        preview_frame.columnconfigure(0, weight=1)
        preview_frame.rowconfigure(0, weight=1)
        
        # 创建Notebook用于标签页
        self.notebook = ttk.Notebook(preview_frame)
        self.notebook.grid(row=0, column=0, sticky="nsew")
        
        # HTML预览标签页
        html_frame = ttk.Frame(self.notebook)
        self.notebook.add(html_frame, text="HTML源码")
        html_frame.columnconfigure(0, weight=1)
        html_frame.rowconfigure(0, weight=1)
        
        self.html_text = scrolledtext.ScrolledText(html_frame, wrap=tk.WORD, 
                                                  height=15, font=('Consolas', 9))
        self.html_text.grid(row=0, column=0, sticky="nsew")
        
        # Markdown预览标签页
        md_frame = ttk.Frame(self.notebook)
        self.notebook.add(md_frame, text="Markdown输出")
        md_frame.columnconfigure(0, weight=1)
        md_frame.rowconfigure(0, weight=1)
        
        self.markdown_text = scrolledtext.ScrolledText(md_frame, wrap=tk.WORD, 
                                                      height=15, font=('Consolas', 9))
        self.markdown_text.grid(row=0, column=0, sticky="nsew")
    
    def _create_control_buttons_area(self, parent):
        """创建控制按钮区域"""
        button_frame = ttk.Frame(parent)
        button_frame.grid(row=4, column=0, columnspan=3, pady=(0, 10))
        
        # 按钮
        self.preview_btn = ttk.Button(button_frame, text="预览转换", 
                                     command=self._preview_conversion)
        self.preview_btn.pack(side=tk.LEFT, padx=(0, 10))
        
        self.convert_btn = ttk.Button(button_frame, text="开始转换", 
                                     command=self._start_conversion)
        self.convert_btn.pack(side=tk.LEFT, padx=(0, 10))
        
        self.clear_btn = ttk.Button(button_frame, text="清空", 
                                   command=self._clear_all)
        self.clear_btn.pack(side=tk.LEFT, padx=(0, 10))
        
        # 进度条
        self.progress_var = tk.DoubleVar()
        self.progress_bar = ttk.Progressbar(button_frame, variable=self.progress_var, 
                                           mode='indeterminate', length=200)
        self.progress_bar.pack(side=tk.LEFT, padx=(20, 0))
    
    def _create_status_bar(self, parent):
        """创建状态栏"""
        self.status_var = tk.StringVar(value="就绪")
        status_bar = ttk.Label(parent, textvariable=self.status_var, 
                              relief=tk.SUNKEN, anchor=tk.W)
        status_bar.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(5, 0))
    
    def _bind_events(self):
        """绑定事件"""
        # 窗口关闭事件
        self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
        
        # 注意:tkinter默认不支持文件拖拽,需要额外的库如tkinterdnd2
        # 这里暂时移除拖拽功能,如需要可以安装tkinterdnd2库后重新启用
    
    def _select_html_file(self):
        """选择HTML文件"""
        filename = filedialog.askopenfilename(
            title="选择HTML文件",
            filetypes=[
                ("HTML文件", "*.html *.htm"),
                ("所有文件", "*.*")
            ]
        )
        
        if filename:
            self.current_html_file = filename
            self.html_file_var.set(filename)
            self._update_file_info()
            self._load_html_preview()
    
    def _select_output_dir(self):
        """选择输出目录"""
        dirname = filedialog.askdirectory(title="选择输出目录")
        
        if dirname:
            self.current_output_dir = dirname
            self.output_dir_var.set(dirname)
    
    def _update_file_info(self):
        """更新文件信息"""
        if self.current_html_file:
            size = self.file_handler.get_file_size(self.current_html_file)
            filename = os.path.basename(self.current_html_file)
            self.file_info_var.set(f"文件: {filename} | 大小: {size}")
        else:
            self.file_info_var.set("请选择HTML文件")
    
    def _load_html_preview(self):
        """加载HTML预览"""
        if not self.current_html_file:
            return
        
        content = self.file_handler.read_html_file(self.current_html_file)
        if content:
            self.html_text.delete(1.0, tk.END)
            self.html_text.insert(1.0, content)
            self.status_var.set("HTML文件加载完成")
    
    def _on_preset_changed(self, event=None):
        """预设改变事件"""
        preset = self.preset_var.get()
        config = self.converter.create_preset_config(preset)
        
        # 更新复选框状态
        self.ignore_links_var.set(config.get('ignore_links', False))
        self.ignore_images_var.set(config.get('ignore_images', False))
        self.ignore_emphasis_var.set(config.get('ignore_emphasis', False))
    
    def _get_current_config(self) -> Dict[str, Any]:
        """获取当前配置"""
        return {
            'ignore_links': self.ignore_links_var.get(),
            'ignore_images': self.ignore_images_var.get(),
            'ignore_emphasis': self.ignore_emphasis_var.get(),
            'body_width': 0,
            'inline_links': True,
            'ul_item_mark': '-'
        }
    
    def _preview_conversion(self):
        """预览转换"""
        if not self.current_html_file:
            messagebox.showwarning("警告", "请先选择HTML文件")
            return
        
        self._set_ui_state(False)
        self.status_var.set("正在预览转换...")
        self.progress_bar.start(10)
        
        # 在线程中执行转换
        thread = threading.Thread(target=self._do_preview_conversion)
        thread.daemon = True
        thread.start()
    
    def _do_preview_conversion(self):
        """执行预览转换(在线程中)"""
        try:
            # 读取HTML内容
            html_content = self.file_handler.read_html_file(self.current_html_file)
            if not html_content:
                return
            
            # 配置转换器
            config = self._get_current_config()
            self.converter.configure(config)
            
            # 执行转换
            markdown_content = self.converter.convert(
                html_content, 
                clean_html=self.clean_html_var.get(),
                clean_empty_emphasis=self.clean_empty_emphasis_var.get()
            )
            
            if markdown_content:
                # 在主线程中更新UI
                self.root.after(0, self._update_preview, markdown_content)
            
        except Exception as e:
            self.root.after(0, lambda: messagebox.showerror("预览错误", f"预览转换失败:{str(e)}"))
        finally:
            self.root.after(0, self._preview_complete)
    
    def _update_preview(self, markdown_content: str):
        """更新预览内容"""
        self.markdown_text.delete(1.0, tk.END)
        self.markdown_text.insert(1.0, markdown_content)
        self.notebook.select(1)  # 切换到Markdown标签页
    
    def _preview_complete(self):
        """预览完成"""
        self.progress_bar.stop()
        self._set_ui_state(True)
        self.status_var.set("预览完成")
    
    def _start_conversion(self):
        """开始转换"""
        if not self.current_html_file:
            messagebox.showwarning("警告", "请先选择HTML文件")
            return
        
        # 确定输出路径
        if self.current_output_dir:
            output_file = self.file_handler.get_output_path(
                self.current_html_file, self.current_output_dir
            )
        else:
            output_file = self.file_handler.get_output_path(self.current_html_file)
        
        # 确认覆盖
        if os.path.exists(output_file):
            if not messagebox.askyesno("确认", f"文件已存在,是否覆盖?\n{output_file}"):
                return
        
        self._set_ui_state(False)
        self.status_var.set("正在转换...")
        self.progress_bar.start(10)
        
        # 在线程中执行转换
        thread = threading.Thread(target=self._do_conversion, args=(output_file,))
        thread.daemon = True
        thread.start()
    
    def _do_conversion(self, output_file: str):
        """执行转换(在线程中)"""
        try:
            # 读取HTML内容
            html_content = self.file_handler.read_html_file(self.current_html_file)
            if not html_content:
                return
            
            # 配置转换器
            config = self._get_current_config()
            self.converter.configure(config)
            
            # 执行转换
            markdown_content = self.converter.convert(
                html_content, 
                clean_html=self.clean_html_var.get(),
                clean_empty_emphasis=self.clean_empty_emphasis_var.get()
            )
            
            if markdown_content:
                # 写入文件
                success = self.file_handler.write_markdown_file(output_file, markdown_content)
                if success:
                    self.root.after(0, lambda: self._conversion_success(output_file))
                else:
                    self.root.after(0, lambda: messagebox.showerror("错误", "写入文件失败"))
            
        except Exception as e:
            self.root.after(0, lambda: messagebox.showerror("转换错误", f"转换失败:{str(e)}"))
        finally:
            self.root.after(0, self._conversion_complete)
    
    def _conversion_success(self, output_file: str):
        """转换成功"""
        messagebox.showinfo("成功", f"转换完成!\n输出文件:{output_file}")
        
        # 询问是否打开文件
        if messagebox.askyesno("打开文件", "是否打开生成的Markdown文件?"):
            try:
                os.startfile(output_file)  # Windows
            except:
                try:
                    os.system(f'open "{output_file}"')  # macOS
                except:
                    try:
                        os.system(f'xdg-open "{output_file}"')  # Linux
                    except:
                        pass
    
    def _conversion_complete(self):
        """转换完成"""
        self.progress_bar.stop()
        self._set_ui_state(True)
        self.status_var.set("转换完成")
    
    def _set_ui_state(self, enabled: bool):
        """设置UI状态"""
        state = tk.NORMAL if enabled else tk.DISABLED
        self.preview_btn.config(state=state)
        self.convert_btn.config(state=state)
        self.is_converting = not enabled
    
    def _clear_all(self):
        """清空所有内容"""
        if self.is_converting:
            messagebox.showwarning("警告", "转换进行中,无法清空")
            return
        
        self.current_html_file = ""
        self.current_output_dir = ""
        self.html_file_var.set("")
        self.output_dir_var.set("")
        self.html_text.delete(1.0, tk.END)
        self.markdown_text.delete(1.0, tk.END)
        self._update_file_info()
        self.status_var.set("已清空")
    
    def _on_closing(self):
        """窗口关闭事件"""
        if self.is_converting:
            if not messagebox.askyesno("确认", "转换正在进行中,确定要退出吗?"):
                return
        
        self.root.destroy()


def main():
    """主函数"""
    # 检查依赖
    try:
        import html2text
        import bs4
    except ImportError as e:
        print(f"缺少依赖模块:{e}")
        print("请运行:pip install -r requirements.txt")
        input("按Enter键退出...")
        return
    
    # 创建应用
    root = tk.Tk()
    app = HtmlToMarkdownApp(root)
    
    # 运行应用
    try:
        root.mainloop()
    except KeyboardInterrupt:
        print("应用被用户中断")
    except Exception as e:
        print(f"应用运行错误:{e}")


if __name__ == "__main__":
    main()

html_converter.py

复制代码
"""
HTML到Markdown转换模块
使用html2text和BeautifulSoup进行HTML到Markdown的转换
"""
import html2text
from bs4 import BeautifulSoup
import re
from typing import Optional, Dict, Any
import tkinter.messagebox as messagebox


class HtmlToMarkdownConverter:
    """HTML到Markdown转换器"""
    
    def __init__(self):
        """初始化转换器"""
        self.h2t = html2text.HTML2Text()
        self._setup_default_config()
    
    def _setup_default_config(self):
        """设置默认配置"""
        # 基本设置
        self.h2t.ignore_links = False
        self.h2t.ignore_images = False
        self.h2t.ignore_emphasis = False
        self.h2t.body_width = 0  # 不限制行宽
        self.h2t.unicode_snob = True  # 使用Unicode字符
        self.h2t.escape_snob = True  # 转义特殊字符
        
        # 链接处理
        self.h2t.inline_links = True  # 内联链接
        self.h2t.protect_links = True  # 保护链接
        
        # 列表处理
        self.h2t.ul_item_mark = '-'  # 无序列表标记
        
        # 表格处理
        self.h2t.ignore_tables = False
        
        # 其他设置
        self.h2t.unicode_snob = True  # 使用Unicode字符
    
    def configure(self, config: Dict[str, Any]):
        """
        配置转换器选项
        
        Args:
            config: 配置字典
        """
        for key, value in config.items():
            if hasattr(self.h2t, key):
                setattr(self.h2t, key, value)
    
    def convert(self, html_content: str, clean_html: bool = True, clean_empty_emphasis: bool = True) -> Optional[str]:
        """
        将HTML转换为Markdown
        
        Args:
            html_content: HTML内容
            clean_html: 是否清理HTML内容
            clean_empty_emphasis: 是否清理空的强调标签
            
        Returns:
            转换后的Markdown内容,失败返回None
        """
        try:
            if clean_html:
                html_content = self._clean_html(html_content, clean_empty_emphasis)
            
            # 执行转换
            markdown_content = self.h2t.handle(html_content)
            
            # 后处理
            markdown_content = self._post_process_markdown(markdown_content)
            
            return markdown_content
            
        except Exception as e:
            messagebox.showerror("转换错误", f"HTML转换失败:{str(e)}")
            return None
    
    def _clean_html(self, html_content: str, clean_empty_emphasis: bool = True) -> str:
        """
        清理HTML内容
        
        Args:
            html_content: 原始HTML内容
            clean_empty_emphasis: 是否清理空的强调标签
            
        Returns:
            清理后的HTML内容
        """
        try:
            # 使用BeautifulSoup解析和清理HTML
            soup = BeautifulSoup(html_content, 'html.parser')
            
            # 先处理特殊class的span标签,转换为对应的Markdown格式
            self._process_special_spans(soup)
            
            # 清理空的强调标签,避免产生多余的格式标记
            if clean_empty_emphasis:
                self._clean_empty_emphasis_tags(soup)
            
            # 处理图表区域,为不可转换的内容添加说明
            self._process_charts_and_scripts(soup)
            
            # 移除不需要的标签
            unwanted_tags = ['script', 'style', 'meta', 'link', 'noscript']
            for tag in unwanted_tags:
                for element in soup.find_all(tag):
                    element.decompose()
            
            # 移除注释
            for comment in soup.find_all(string=lambda text: isinstance(text, str) and text.strip().startswith('<!--')):
                comment.extract()
            
            # 清理属性(保留重要的)
            for tag in soup.find_all():
                # 保留重要属性
                important_attrs = ['href', 'src', 'alt', 'title', 'id', 'class']
                attrs_to_remove = []
                for attr in tag.attrs:
                    if attr not in important_attrs:
                        attrs_to_remove.append(attr)
                for attr in attrs_to_remove:
                    del tag.attrs[attr]
            
            return str(soup)
            
        except Exception as e:
            # 如果清理失败,返回原内容
            print(f"HTML清理失败:{e}")
            return html_content
    
    def _process_special_spans(self, soup):
        """
        处理特殊class的span标签,转换为对应的Markdown格式
        
        Args:
            soup: BeautifulSoup对象
        """
        # 处理高亮文本(highlight class)
        for span in soup.find_all('span', class_='highlight'):
            # 将高亮文本转换为行内代码样式(反引号)
            span.name = 'code'
            if 'class' in span.attrs:
                del span.attrs['class']
        
        # 处理重要文本(important class)
        for span in soup.find_all('span', class_='important'):
            # 将重要文本转换为粗体样式
            span.name = 'strong'
            if 'class' in span.attrs:
                del span.attrs['class']
        
        # 处理其他可能的特殊class
        special_classes = {
            'emphasis': 'em',
            'italic': 'em', 
            'bold': 'strong',
            'underline': 'u',
            'code': 'code'
        }
        
        for class_name, tag_name in special_classes.items():
            for span in soup.find_all('span', class_=class_name):
                span.name = tag_name
                if 'class' in span.attrs:
                    del span.attrs['class']
        
        # 处理内部锦点链接,确保正确转换
        self._process_internal_links(soup)
    
    def _clean_empty_emphasis_tags(self, soup):
        """
        清理空的强调标签,避免产生多余的格式标记
        
        Args:
            soup: BeautifulSoup对象
        """
        # 要清理的空标签类型
        emphasis_tags = ['em', 'i', 'strong', 'b', 'u', 'code']
        
        for tag_name in emphasis_tags:
            # 查找所有该类型的标签
            for tag in soup.find_all(tag_name):
                # 检查标签是否为空(没有文本内容且没有子元素包含文本)
                if self._is_empty_tag(tag):
                    # 移除空标签,但保留其可能的尾随空格
                    if tag.next_sibling and isinstance(tag.next_sibling, str):
                        # 如果后面紧跟文本,保留一个空格
                        tag.replace_with(' ' if not tag.next_sibling.startswith(' ') else '')
                    else:
                        tag.decompose()
    
    def _is_empty_tag(self, tag) -> bool:
        """
        检查标签是否为空(没有有意义的文本内容)
        
        Args:
            tag: BeautifulSoup标签对象
            
        Returns:
            bool: 如果标签为空则返回True
        """
        # 获取标签的文本内容,去除空白字符
        text_content = tag.get_text(strip=True)
        
        # 如果没有文本内容,认为是空标签
        if not text_content:
            return True
        
        # 如果只包含空白字符或特殊字符,也认为是空标签
        if text_content.strip() in ['', '\n', '\t', '\r']:
            return True
        
        return False
    
    def _process_internal_links(self, soup):
        """
        处理内部锦点链接,确保正确转换为Markdown格式
        
        Args:
            soup: BeautifulSoup对象
        """
        # 处理锦点链接(href以#开头的链接)
        for link in soup.find_all('a', href=True):
            href = link.get('href')
            if href and href.startswith('#'):
                # 确保链接属性保留,但清理其他不必要的属性
                # 保留href属性用于内部链接
                attrs_to_keep = {'href': href}
                if 'title' in link.attrs:
                    attrs_to_keep['title'] = link.attrs['title']
                
                # 清除所有属性并重新设置
                link.attrs.clear()
                link.attrs.update(attrs_to_keep)
                
                # 为内部链接添加标识(可选)
                # 这样html2text就能正确识别并转换为Markdown格式
                pass
        
        # 处理标题中的id属性,确保锦点目标存在
        for heading in soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], id=True):
            # 保留id属性用于锦点定位
            heading_id = heading.get('id')
            if heading_id:
                # 确保标题的id属性被保留
                heading.attrs = {'id': heading_id}
    
    def _process_charts_and_scripts(self, soup):
        """
        处理图表区域和脚本,为不可转换的内容添加说明
        
        Args:
            soup: BeautifulSoup对象
        """
        # 处理ECharts图表区域
        chart_wrappers = soup.find_all('div', class_='chart-wrapper')
        for wrapper in chart_wrappers:
            chart_id = wrapper.get('id', '未知')
            
            # 创建替换内容,根据图表ID提供更具体的说明
            if chart_id == 'chart1':
                replacement_text = """

## 📊 交互式图表:自由软件的四大核心自由

> **图表说明**:此处原为一个交互式关系图,展示了自由软件的四大核心自由之间的关系。
> 
> **图表内容**:
> - 🔵 **中心节点**:软件自由
> - 🔶 **自由 0**:运行软件的自由
> - 🟢 **自由 1**:研究和修改源代码的自由
> - 🟡 **自由 2**:重新分发拷贝的自由
> - 🟠 **自由 3**:发布改进版本的自由
> 
> **查看完整图表**:请参阅原始HTML文件中的交互式版本

"""
            elif chart_id == 'chart3':
                replacement_text = """

## 📈 条形图:2023年全球下载量最高的十大开源库

> **图表说明**:此处原为一个横向条形图,展示了开源项目的下载量数据。
> 
> **排名前五的开源库**:
> 1. **React** - 3,190M 下载量
> 2. **Vue** - 2,270M 下载量
> 3. **Lodash** - 1,900M 下载量
> 4. **jQuery** - 1,800M 下载量
> 5. **Angular** - 1,780M 下载量
> 
> **查看完整图表**:请参阅原始HTML文件中的交互式版本

"""
            else:
                replacement_text = f"""

> 📈 **图表占位符** 
> 
> 原文件包含一个交互式图表 (ID: {chart_id})
> 该图表由JavaScript生成,无法直接转换为Markdown格式
> 请查看原始HTML文件以获取完整图表信息

"""
            
            # 创建替换元素
            replacement = soup.new_tag('div')
            replacement.string = replacement_text
            
            # 替换图表包装器
            wrapper.replace_with(replacement)
        
        # 处理其他可能的图表类型
        chart_elements = soup.find_all(['canvas', 'svg'])
        for element in chart_elements:
            replacement = soup.new_tag('div')
            replacement.string = f"\n\n> 📊 **图形元素占位符** \n> \n> 原文件包含一个{element.name.upper()}图形元素\n> 该元素无法转换为Markdown格式\n\n"
            element.replace_with(replacement)
        
        # 处理内联JavaScript
        script_tags = soup.find_all('script')
        for script in script_tags:
            if script.string and 'chart' in script.string.lower():
                replacement = soup.new_tag('div') 
                replacement.string = "\n\n> ⚡ **JavaScript代码占位符** \n> \n> 原文件包含图表相关的JavaScript代码\n> 该代码用于生成交互式图表,无法转换为Markdown\n\n"
                script.replace_with(replacement)
    
    def _post_process_markdown(self, markdown_content: str) -> str:
        """
        后处理Markdown内容
        
        Args:
            markdown_content: 原始Markdown内容
            
        Returns:
            处理后的Markdown内容
        """
        # 移除多余的空行
        markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
        
        # 修复列表格式
        markdown_content = re.sub(r'\n\s*\n\s*-', '\n-', markdown_content)
        markdown_content = re.sub(r'\n\s*\n\s*(\d+\.)', r'\n\1', markdown_content)
        
        # 修复标题格式
        markdown_content = re.sub(r'\n\s*\n\s*#', '\n#', markdown_content)
        
        # 修复代码块格式
        markdown_content = re.sub(r'\n\s*\n\s*```', '\n```', markdown_content)
        
        # 修复引用块格式
        markdown_content = re.sub(r'\n\s*\n\s*>', '\n>', markdown_content)
        
        # 处理内部链接和目录结构
        markdown_content = self._fix_internal_links(markdown_content)
        
        # 处理特殊字符的转义
        markdown_content = self._fix_special_characters(markdown_content)
        
        # 移除行尾空格
        lines = markdown_content.split('\n')
        lines = [line.rstrip() for line in lines]
        markdown_content = '\n'.join(lines)
        
        # 确保文件末尾有换行符
        if not markdown_content.endswith('\n'):
            markdown_content += '\n'
        
        return markdown_content
    
    def _fix_special_characters(self, content: str) -> str:
        """
        修复特殊字符的转义
        
        Args:
            content: Markdown内容
            
        Returns:
            修复后的内容
        """
        # 修复HTML实体的转换
        entity_map = {
            '&lt;': '<',
            '&gt;': '>',
            '&amp;': '&',
            '&quot;': '"',
            '&#39;': "'",
            '&nbsp;': ' '
        }
        
        for entity, char in entity_map.items():
            content = content.replace(entity, char)
        
        return content
    
    def _fix_internal_links(self, content: str) -> str:
        """
        修复内部链接的格式
        
        Args:
            content: Markdown内容
            
        Returns:
            修复后的内容
        """
        # 修复目录结构中的内部链接格式
        # 确保锦点链接格式正确:[text](#anchor)
        
        # 处理嵌套列表中的链接格式
        # 将多行的链接合并为单行
        content = re.sub(r'\[([^\]]+)\]\(\s*\n\s*([^)]+)\)', r'[\1](\2)', content)
        
        # 修复链接中的空格
        content = re.sub(r'\[([^\]]+)\]\(\s*([^)\s]+)\s*\)', r'[\1](\2)', content)
        
        # 保证锦点ID的格式正确
        # 如果发现破损的锦点链接,尝试修复
        content = re.sub(r'\[(.*?)\]\(#([^)]*?)\)', 
                        lambda m: f'[{m.group(1)}](#{m.group(2).strip()})', 
                        content)
        
        return content
    
    def get_conversion_options(self) -> Dict[str, Dict[str, Any]]:
        """
        获取可用的转换选项
        
        Returns:
            转换选项字典
        """
        return {
            "基本选项": {
                "ignore_links": {"type": "bool", "default": False, "desc": "忽略链接"},
                "ignore_images": {"type": "bool", "default": False, "desc": "忽略图片"},
                "ignore_emphasis": {"type": "bool", "default": False, "desc": "忽略强调格式(包括空em标签)"},
                "body_width": {"type": "int", "default": 0, "desc": "行宽限制(0为不限制)"}
            },
            "链接选项": {
                "inline_links": {"type": "bool", "default": True, "desc": "使用内联链接"},
                "protect_links": {"type": "bool", "default": True, "desc": "保护链接格式"}
            },
            "列表选项": {
                "ul_item_mark": {"type": "str", "default": "-", "desc": "无序列表标记"}
            },
            "表格选项": {
                "ignore_tables": {"type": "bool", "default": False, "desc": "忽略表格"}
            },
            "高级选项": {
                "clean_empty_emphasis": {"type": "bool", "default": True, "desc": "清理空的强调标签(em,i,strong等)"}
            }
        }
    
    def create_preset_config(self, preset_name: str) -> Dict[str, Any]:
        """
        创建预设配置
        
        Args:
            preset_name: 预设名称
            
        Returns:
            配置字典
        """
        presets = {
            "default": {
                "ignore_links": False,
                "ignore_images": False,
                "ignore_emphasis": False,
                "body_width": 0,
                "inline_links": True,
                "ul_item_mark": "-"
            },
            "simple": {
                "ignore_links": True,
                "ignore_images": True,
                "ignore_emphasis": True,
                "body_width": 80,
                "inline_links": False,
                "ul_item_mark": "*"
            },
            "github": {
                "ignore_links": False,
                "ignore_images": False,
                "ignore_emphasis": False,
                "body_width": 0,
                "inline_links": True,
                "ul_item_mark": "-",
                "ignore_tables": False
            }
        }
        
        return presets.get(preset_name, presets["default"])

file_hander.py

复制代码
"""
文件处理模块
负责HTML和Markdown文件的读写操作
"""
import os
from pathlib import Path
from typing import Optional, Tuple
import tkinter.messagebox as messagebox


class FileHandler:
    """文件处理类"""
    
    @staticmethod
    def read_html_file(file_path: str) -> Optional[str]:
        """
        读取HTML文件内容
        
        Args:
            file_path: HTML文件路径
            
        Returns:
            文件内容字符串,失败返回None
        """
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                content = f.read()
            return content
        except UnicodeDecodeError:
            # 尝试其他编码
            try:
                with open(file_path, 'r', encoding='gbk') as f:
                    content = f.read()
                return content
            except UnicodeDecodeError:
                try:
                    with open(file_path, 'r', encoding='latin-1') as f:
                        content = f.read()
                    return content
                except Exception as e:
                    messagebox.showerror("编码错误", f"无法读取文件,编码不支持:{str(e)}")
                    return None
        except FileNotFoundError:
            messagebox.showerror("文件错误", f"找不到文件:{file_path}")
            return None
        except Exception as e:
            messagebox.showerror("读取错误", f"读取文件时发生错误:{str(e)}")
            return None
    
    @staticmethod
    def write_markdown_file(file_path: str, content: str) -> bool:
        """
        写入Markdown文件
        
        Args:
            file_path: 输出文件路径
            content: Markdown内容
            
        Returns:
            写入成功返回True,失败返回False
        """
        try:
            # 确保目录存在
            os.makedirs(os.path.dirname(file_path), exist_ok=True)
            
            with open(file_path, 'w', encoding='utf-8') as f:
                f.write(content)
            return True
        except Exception as e:
            messagebox.showerror("写入错误", f"写入文件时发生错误:{str(e)}")
            return False
    
    @staticmethod
    def get_output_path(input_path: str, output_dir: Optional[str] = None) -> str:
        """
        根据输入文件路径生成输出文件路径
        
        Args:
            input_path: 输入HTML文件路径
            output_dir: 输出目录,如果为None则使用输入文件所在目录
            
        Returns:
            输出的Markdown文件路径
        """
        input_file = Path(input_path)
        filename_without_ext = input_file.stem
        
        if output_dir:
            output_path = Path(output_dir) / f"{filename_without_ext}.md"
        else:
            output_path = input_file.parent / f"{filename_without_ext}.md"
        
        return str(output_path)
    
    @staticmethod
    def validate_file_path(file_path: str, file_type: str = "HTML") -> Tuple[bool, str]:
        """
        验证文件路径的有效性
        
        Args:
            file_path: 文件路径
            file_type: 文件类型描述
            
        Returns:
            (是否有效, 错误信息)
        """
        if not file_path:
            return False, f"请选择{file_type}文件"
        
        if not os.path.exists(file_path):
            return False, f"{file_type}文件不存在"
        
        if not os.path.isfile(file_path):
            return False, f"所选路径不是文件"
        
        return True, ""
    
    @staticmethod
    def get_file_size(file_path: str) -> str:
        """
        获取文件大小的人性化显示
        
        Args:
            file_path: 文件路径
            
        Returns:
            文件大小字符串
        """
        try:
            size = os.path.getsize(file_path)
            if size < 1024:
                return f"{size} B"
            elif size < 1024 * 1024:
                return f"{size / 1024:.1f} KB"
            else:
                return f"{size / (1024 * 1024):.1f} MB"
        except:
            return "未知大小"

相关推荐
爱隐身的官人2 小时前
Weblogic XMLDecoder 反序列化漏洞(CVE-2017-10271)
python·web安全·weblogic
偷心伊普西隆2 小时前
Python Excel 比较 sheet 之间的差异
python·excel
曾经的三心草2 小时前
基于阿里云系列平台的python微服务设计与DevOps实践
python·阿里云·微服务
java1234_小锋3 小时前
Scikit-learn Python机器学习 - 回归分析算法 - 弹性网络 (Elastic-Net)
python·算法·机器学习
kcoo3 小时前
Jupyter Lab 汉化
linux·开发语言·python
灿烂阳光g4 小时前
HWC的软硬件架构
android
2501_915921434 小时前
Charles 抓包 HTTPS 原理详解,从 CONNECT 到 SSL Proxying、常见问题与真机调试实战(含 Sniffmaster 补充方案)
android·网络协议·小程序·https·uni-app·iphone·ssl
冬天vs不冷4 小时前
Java基础(十三):内部类详解
android·java·python
AKA4 小时前
Bugly的使用
android·android studio