从零构建智能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 "未知大小"

相关推荐
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
敏编程1 天前
一天一个Python库:jsonschema - JSON 数据验证利器
python
前端付豪1 天前
LangChain记忆:通过Memory记住上次的对话细节
人工智能·python·langchain
databook1 天前
ManimCE v0.20.1 发布:LaTeX 渲染修复与动画稳定性提升
python·动效
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
花酒锄作田2 天前
使用 pkgutil 实现动态插件系统
python
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
前端付豪2 天前
LangChain链 写一篇完美推文?用SequencialChain链接不同的组件
人工智能·python·langchain