""" VBA代码提取工具 从xlsm文件中提取模块和类模块代码,分类保存到VBA文件夹 自动清理Attribute信息并生成元数据JSON文件 """ import os import sys import re from pathlib import Path from typing import Tuple, Dict # 加载 .env 配置文件 try: from dotenv import load_dotenv load_dotenv() except ImportError: print("警告: 未安装 python-dotenv 库,将使用默认配置") print("建议运行: pip install python-dotenv") # ==================== 配置区域 ==================== # 从 .env 文件读取配置,如果未设置则使用 None(交互模式) TARGET_XLSM_FILE = os.getenv("TARGET_XLSM_FILE", "").strip() or None # VBA代码输出目录(如果未设置,则使用源文件同目录下的VBA文件夹) VBA_OUTPUT_DIR = os.getenv("VBA_OUTPUT_DIR", "").strip() or None # ================================================= # VBA项目相关常量 STANDARD_MODULE_DIR = "Modules" CLASS_MODULE_DIR = "ClassModules" DOCUMENT_MODULE_DIR = "DocumentModules" FORMS_DIR = "Forms" class VBAExtractor: """VBA代码提取器""" def __init__(self, xlsm_path: str, output_dir: str = None): """ 初始化VBA提取器 Args: xlsm_path: xlsm文件路径 output_dir: 输出目录(如果未指定,则使用VBA_OUTPUT_DIR配置或源文件同目录) """ self.xlsm_path = Path(xlsm_path) # 确定输出目录的优先级: # 1. 参数指定的 output_dir # 2. 环境变量配置的 VBA_OUTPUT_DIR # 3. 默认:源文件同目录下的VBA文件夹 if output_dir is not None: self.output_dir = Path(output_dir) elif VBA_OUTPUT_DIR is not None: self.output_dir = Path(VBA_OUTPUT_DIR) else: # 使用目标文件同目录下的VBA文件夹 self.output_dir = self.xlsm_path.parent / "VBA" # 创建输出目录结构 self.modules_dir = self.output_dir / STANDARD_MODULE_DIR self.class_modules_dir = self.output_dir / CLASS_MODULE_DIR self.document_modules_dir = self.output_dir / DOCUMENT_MODULE_DIR self.forms_dir = self.output_dir / FORMS_DIR self.modules_dir.mkdir(parents=True, exist_ok=True) self.class_modules_dir.mkdir(parents=True, exist_ok=True) self.document_modules_dir.mkdir(parents=True, exist_ok=True) self.forms_dir.mkdir(parents=True, exist_ok=True) def parse_attributes(self, code: str) -> Tuple[Dict[str, str], str]: """ 解析VBA代码中的Attribute信息 Args: code: VBA代码(包含Attribute行) Returns: (attributes_dict, clean_code) - 属性字典和清理后的代码 """ attributes = {} # [修复] 使用 splitlines() 自动处理 \r\n,避免保留 \r 导致的多余空行 lines = code.splitlines() clean_lines = [] in_attributes = True for line in lines: # 检查是否为Attribute行 attr_match = re.match(r'^Attribute\s+(\w+)\s*=\s*(.+)$', line.strip()) if attr_match: attr_name = attr_match.group(1) attr_value = attr_match.group(2).strip().strip('"') attributes[attr_name] = attr_value # 继续收集Attribute,暂不添加到clean_lines continue # 遇到非Attribute行,Attribute收集结束 if not line.strip().startswith('Attribute'): in_attributes = False # 添加到清理后的代码(跳过空行和Attribute) if not in_attributes or (line.strip() and not line.strip().startswith('Attribute')): if not in_attributes: # 使用 rstrip() 去除行尾可能存在的空白符,保持代码整洁 clean_lines.append(line.rstrip()) # 去除开头的空行 while clean_lines and not clean_lines[0].strip(): clean_lines.pop(0) clean_code = '\n'.join(clean_lines) return attributes, clean_code def extract_vba_modules_olevba(self): """ 使用olevba库提取VBA代码 需要安装: pip install oletools """ try: from oletools.olevba import VBA_Parser except ImportError: print("错误: 未安装oletools库") print("请运行: pip install oletools") return False print(f"正在解析文件: {self.xlsm_path.name}") try: vba_parser = VBA_Parser(str(self.xlsm_path)) if vba_parser.detect_vba_macros(): print("发现VBA代码,开始提取...\n") # 遍历所有VBA模块 for (filename, stream_path, vba_filename, vba_code) in vba_parser.extract_macros(): self._process_module(vba_filename, vba_code, stream_path) vba_parser.close() print(f"\n提取完成!") print(f"- 标准模块: {self.modules_dir}") print(f"- 类模块: {self.class_modules_dir}") print(f"- 文档模块: {self.document_modules_dir}") return True else: print("未在文件中发现VBA代码") vba_parser.close() return False except Exception as e: print(f"提取VBA代码时出错: {e}") return False def extract_vba_modules_com(self): """ 使用COM接口提取VBA代码(需要安装Excel) 优点: 更可靠,支持更多特性 缺点: 需要安装Microsoft Excel """ try: import win32com.client as win32 except ImportError: print("错误: 未安装pywin32库") print("请运行: pip install pywin32") return False print(f"正在使用COM接口解析: {self.xlsm_path.name}") try: excel = win32.Dispatch("Excel.Application") excel.Visible = False excel.DisplayAlerts = False workbook = excel.Workbooks.Open(str(self.xlsm_path.absolute())) # 获取VBA项目 if not workbook.VBProject: print("错误: 无法访问VBA项目") print("请确保: 1) Excel信任中心设置'信任对VBA工程对象模型的访问'") print(" 2) 文件中包含VBA代码") workbook.Close(False) excel.Quit() return False vb_project = workbook.VBProject print("开始提取VBA组件...\n") # 遍历所有VBA组件 for component in vb_project.VBComponents: module_name = component.Name module_type = component.Type # 获取代码 code_module = component.CodeModule line_count = code_module.CountOfLines if line_count > 0: vba_code = code_module.Lines(1, line_count) else: vba_code = "" # 根据类型分类保存 # 1=标准模块, 2=类模块, 3=MSForm, 11=Document/工作表/工作簿 type_name = { 1: STANDARD_MODULE_DIR, 2: CLASS_MODULE_DIR, 3: FORMS_DIR, 11: DOCUMENT_MODULE_DIR }.get(module_type, "Unknown") target_dir = { 1: self.modules_dir, 2: self.class_modules_dir, 3: self.forms_dir, 11: self.document_modules_dir }.get(module_type, self.modules_dir) target_dir.mkdir(exist_ok=True) self._process_module(module_name, vba_code, type_name) workbook.Close(False) excel.Quit() print(f"\n提取完成!") print(f"- 标准模块: {self.modules_dir}") print(f"- 类模块: {self.class_modules_dir}") print(f"- 文档模块: {self.document_modules_dir}") return True except Exception as e: print(f"使用COM提取VBA代码时出错: {e}") print("\n提示:") print("1. 确保已安装Microsoft Excel") print("2. 打开Excel -> 文件 -> 选项 -> 信任中心 -> 信任中心设置") print("3. 勾选'信任对VBA工程对象模型的访问'") try: excel.Quit() except: pass return False def _determine_module_type(self, module_name: str, stream_path: str) -> str: """ 根据模块名称和流路径确定模块类型 Args: module_name: 模块名称 stream_path: 流路径 Returns: 模块类型: Modules, ClassModules, DocumentModules, Forms """ name_lower = module_name.lower() # 工作表和工作簿模块 if name_lower.startswith('sheet') or name_lower == 'thisworkbook': return DOCUMENT_MODULE_DIR # 标准模块 if name_lower.startswith('mod_') or name_lower.startswith('mod'): return STANDARD_MODULE_DIR # 类模块 if name_lower.startswith('cls') or name_lower.startswith('class'): return CLASS_MODULE_DIR # 窗体模块 if name_lower.startswith('userform') or name_lower.startswith('frm_') or name_lower.startswith('frm'): return FORMS_DIR # 根据stream_path判断 if stream_path: path_lower = stream_path.lower() if 'sheet' in path_lower or 'thisworkbook' in path_lower: return DOCUMENT_MODULE_DIR elif 'class' in path_lower or 'cls' in path_lower: return CLASS_MODULE_DIR elif 'form' in path_lower or 'userform' in path_lower: return FORMS_DIR # 默认为标准模块 return STANDARD_MODULE_DIR def _process_module(self, module_name: str, vba_code: str, category: str): """ 处理模块:解析属性、清理代码、保存文件 Args: module_name: 模块名称 vba_code: VBA代码内容 category: 模块类别(可能是stream_path或类型名称) """ # 解析Attribute信息 attributes, clean_code = self.parse_attributes(vba_code) # 检查是否有实际代码内容(跳过空模块) if not clean_code.strip(): print(f" [跳过] {module_name} - 无实际代码内容") return # 确定实际的模块类型 module_type = self._determine_module_type(module_name, category) # 确定目标目录 target_dir = { STANDARD_MODULE_DIR: self.modules_dir, CLASS_MODULE_DIR: self.class_modules_dir, DOCUMENT_MODULE_DIR: self.document_modules_dir, FORMS_DIR: self.forms_dir }.get(module_type, self.modules_dir) # 确定文件扩展名 if module_type == FORMS_DIR: ext = '.frm' elif module_type in [CLASS_MODULE_DIR, DOCUMENT_MODULE_DIR]: ext = '.cls' else: ext = '.bas' # 清理文件名(移除已有扩展名) clean_name = module_name.replace('/', '_').replace('\\', '_') # 移除已存在的扩展名 for suffix in ['.cls', '.bas', '.frm']: if clean_name.endswith(suffix): clean_name = clean_name[:-len(suffix)] break clean_name += ext # 保存清理后的代码 file_path = target_dir / clean_name with open(file_path, 'w', encoding='utf-8') as f: f.write(clean_code) print(f" [OK] 已保存: {clean_name} ({module_type})") def main(): """主函数""" print("=" * 60) print("VBA代码提取工具") print("=" * 60) print() # 检查是否配置了目标文件 if TARGET_XLSM_FILE and TARGET_XLSM_FILE.strip(): # 使用配置的文件路径 script_dir = Path(__file__).parent target_path = Path(TARGET_XLSM_FILE) # 如果是相对路径,则相对于脚本所在目录 if not target_path.is_absolute(): target_path = script_dir / target_path if not target_path.exists(): print(f"错误: 配置的文件不存在: {target_path}") return if not target_path.suffix.lower() == '.xlsm': print(f"警告: 文件扩展名不是.xlsm: {target_path.name}") xlsm_file = target_path print(f"使用配置文件: {xlsm_file.name}") print() else: # 交互模式:查找xlsm文件 excel_dir = Path("Excel") if not excel_dir.exists(): print("错误: 未找到Excel文件夹") return xlsm_files = list(excel_dir.glob("*.xlsm")) if not xlsm_files: print("错误: Excel文件夹中没有xlsm文件") return # 如果有多个文件,让用户选择 if len(xlsm_files) > 1: print("发现多个xlsm文件:") for i, f in enumerate(xlsm_files, 1): print(f" {i}. {f.name}") print() choice = input("请选择文件编号 (直接回车选择第1个): ").strip() if not choice: xlsm_file = xlsm_files[0] else: try: idx = int(choice) - 1 xlsm_file = xlsm_files[idx] except: print("无效选择,使用第一个文件") xlsm_file = xlsm_files[0] else: xlsm_file = xlsm_files[0] print() print(f"选择文件: {xlsm_file.name}") print() # 创建提取器 # 输出目录优先级: 1. .env中的VBA_OUTPUT_DIR配置 2. 源文件同目录下的VBA文件夹 extractor = VBAExtractor(str(xlsm_file)) # 显示输出目录信息 print(f"输出目录: {extractor.output_dir}") print() # 选择提取方法 print("请选择提取方法:") print(" 1. COM接口 (推荐 - 需要安装Excel)") print(" 2. olevba库 (不需要Excel)") print() method = input("请选择 (直接回车使用方法1): ").strip() if method == "2": print("\n使用olevba库提取...") success = extractor.extract_vba_modules_olevba() else: print("\n使用COM接口提取...") success = extractor.extract_vba_modules_com() if success: print("\n" + "=" * 60) print("提取成功完成!") print("=" * 60) else: print("\n" + "=" * 60) print("提取失败") print("=" * 60) if __name__ == "__main__": main()