├── img └── 示例.png ├── requirements.txt ├── wxid.json ├── README.md └── wxapkg_gui.py /img/示例.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahao0150/wxapkg_gui/HEAD/img/示例.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 加密解密相关 2 | pycryptodome>=3.19.1 3 | 4 | # 网络请求 5 | requests>=2.31.0 6 | 7 | # GUI相关 - tkinter通常是Python标准库的一部分,不需要额外安装 8 | 9 | # JS压缩 10 | jsmin>=3.0.1 11 | 12 | # 图片处理 13 | Pillow>=10.0.0 -------------------------------------------------------------------------------- /wxid.json: -------------------------------------------------------------------------------- 1 | { 2 | "wx2ea687f4258401a9": { 3 | "wxid": "wx2ea687f4258401a9", 4 | "nickname": "同玩同聊", 5 | "principal_name": "深圳市腾讯计算机系统有限公司", 6 | "description": "做个简单的小游戏", 7 | "location": "C:/Users/admin/Documents/WeChat Files/Applet\\wx2ea687f4258401a9" 8 | }, 9 | "wx7095f7fa398a2f30": { 10 | "wxid": "wx7095f7fa398a2f30", 11 | "nickname": "CocosEngine3", 12 | "principal_name": "厦门雅基软件有限公司", 13 | "description": "Cocos 引擎插件", 14 | "location": "C:/Users/admin/Documents/WeChat Files/Applet\\wx7095f7fa398a2f30" 15 | }, 16 | "wx7c8d593b2c3a7703": { 17 | "wxid": "wx7c8d593b2c3a7703", 18 | "nickname": "跳一跳", 19 | "principal_name": "深圳市腾讯计算机系统有限公司", 20 | "description": "比比看,谁跳得更远", 21 | "location": "C:/Users/admin/Documents/WeChat Files/Applet\\wx7c8d593b2c3a7703" 22 | }, 23 | "wxd217679e05ab55b6": { 24 | "wxid": "wxd217679e05ab55b6", 25 | "nickname": "婆婆来找茬", 26 | "principal_name": "未知开发者", 27 | "description": "无描述信息" 28 | } 29 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wxapkg-gui 2 | 3 | > **免责声明**:此工具仅限于学习和研究软件内含的设计思想和原理,用户承担因使用此工具而导致的所有法律和相关责任!作者不承担任何法律责任! 4 | 5 | 基于 [wux1an/wxapkg](https://github.com/wux1an/wxapkg) 项目开发的图形化界面工具,使用Python + tkinter实现。 6 | 7 | ## 📝 功能特性 8 | 9 | - [x] 图形化界面操作,简单易用 10 | - [x] 自动扫描微信小程序目录 11 | - [x] 获取小程序信息(需要网络连接) 12 | - [x] 支持批量解包wxapkg文件 13 | - [x] 自动识别小游戏配置并重命名 14 | - [x] 支持自定义输出目录 15 | - [x] 实时显示解包进度 16 | - [x] 支持Windows/macOS 17 | 18 | ## 🎨 使用说明 19 | 20 | 1. 用PC版微信打开小程序,让微信下载小程序文件 21 | 2. 运行本工具,点击"扫描目录"按钮选择微信小程序目录(默认为`~/Documents/WeChat Files/Applet`) 22 | 3. 在列表中选择要解包的小程序 23 | 4. 确认或修改输出目录(默认为小程序目录下的unpack子目录) 24 | 5. 点击"解包选中"按钮开始解包 25 | 6. 解包完成后可以选择直接打开输出目录查看结果 26 | 27 | ## ⚙️ 开发环境 28 | 29 | - Python 3.8+ 30 | - tkinter 31 | - pycryptodome 32 | - requests 33 | 34 | ## 🛠️ 安装依赖 35 | 36 | ```bash 37 | pip install -r requirements.txt 38 | ``` 39 | 40 | 或者 41 | 42 | ```bash 43 | pip install pycryptodome requests 44 | ``` 45 | 46 | 47 | 48 | 49 | ## 🔨 开发者 50 | 51 | - [cursor](https://www.cursor.com/) 52 | 53 | ## 🤖 开发支持 54 | 55 | 本项目由以下AI模型提供开发支持: 56 | 57 | - Claude (Anthropic) 58 | - Deepseek 59 | 60 | ## 📷 软件截图 61 | 62 | ![image](./img/示例.png) 63 | 64 | ## 🔗 参考项目 65 | 66 | - [wux1an/wxapkg](https://github.com/wux1an/wxapkg) - 原始的命令行工具版本 67 | 68 | - [BlackTrace/pc_wxapkg_decrypt](https://github.com/BlackTrace/pc_wxapkg_decrypt) - 小程序解密参考 69 | - [Integ/wxapkg](https://gist.github.com/Integ/bcac5c21de5ea35b63b3db2c725f07ad) - 小程序解包参考 70 | 71 | ## 📄 许可证 72 | 73 | MIT License -------------------------------------------------------------------------------- /wxapkg_gui.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk, messagebox, filedialog 3 | import os 4 | import re 5 | import struct 6 | import threading 7 | from Crypto.Cipher import AES 8 | from Crypto.Protocol.KDF import PBKDF2 9 | import requests 10 | import json 11 | 12 | class WxapkgUnpacker: 13 | def __init__(self, root): 14 | self.root = root 15 | self.root.title("微信小程序解包工具") 16 | self.root.geometry("1344x720") 17 | 18 | # 初始化数据 19 | self.wxid_infos = [] 20 | self.selected_wxid = None 21 | self.output_dir = None # 添加输出目录变量 22 | 23 | # 设置默认扫描目录 24 | self.default_scan_dir = os.path.join(os.path.expanduser('~'), 'Documents', 'WeChat Files', 'Applet') 25 | 26 | # 添加缓存文件路径 27 | self.cache_file = "wxid.json" 28 | self.wxid_cache = self.load_wxid_cache() 29 | 30 | # 创建UI组件 31 | self.create_widgets() 32 | 33 | def create_widgets(self): 34 | # 顶部工具栏 35 | toolbar = ttk.Frame(self.root) 36 | toolbar.pack(fill=tk.X, padx=5, pady=5) 37 | 38 | self.scan_btn = ttk.Button(toolbar, text="扫描目录", command=self.scan_directory) 39 | self.scan_btn.pack(side=tk.LEFT, padx=2) 40 | 41 | self.unpack_btn = ttk.Button(toolbar, text="解包选中", command=self.unpack_selected, state=tk.DISABLED) 42 | self.unpack_btn.pack(side=tk.LEFT, padx=2) 43 | 44 | # 主内容区域 45 | main_frame = ttk.Frame(self.root) 46 | main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) 47 | 48 | # 小程序列表 49 | self.tree = ttk.Treeview(main_frame, columns=('nickname', 'developer', 'description'), show='headings') 50 | self.tree.heading('nickname', text='名称') 51 | self.tree.heading('developer', text='开发者') 52 | self.tree.heading('description', text='描述') 53 | self.tree.column('nickname', width=200) 54 | self.tree.column('developer', width=250) 55 | self.tree.column('description', width=300) 56 | self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 57 | 58 | # 详情面板 59 | detail_frame = ttk.Frame(main_frame) 60 | detail_frame.pack(side=tk.RIGHT, fill=tk.BOTH, padx=5) 61 | 62 | # 添加输出目录显示和编辑功能 63 | output_frame = ttk.Frame(detail_frame) 64 | output_frame.pack(fill=tk.X, pady=(0, 5)) 65 | 66 | ttk.Label(output_frame, text="输出目录:").pack(side=tk.LEFT) 67 | self.output_entry = ttk.Entry(output_frame) 68 | self.output_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 5)) 69 | 70 | self.browse_btn = ttk.Button(output_frame, text="浏览", command=self.browse_output) 71 | self.browse_btn.pack(side=tk.RIGHT) 72 | 73 | # 在输出目录框架下添加压缩选项 74 | compress_frame = ttk.Frame(detail_frame) 75 | compress_frame.pack(fill=tk.X, pady=(0, 5)) 76 | 77 | self.compress_var = tk.BooleanVar(value=False) 78 | self.compress_checkbox = ttk.Checkbutton( 79 | compress_frame, 80 | text="压缩JS/JSON文件", 81 | variable=self.compress_var 82 | ) 83 | self.compress_checkbox.pack(side=tk.LEFT) 84 | 85 | # 在压缩选项框架中添加图片压缩选项 86 | self.compress_png_var = tk.BooleanVar(value=False) 87 | self.compress_png_checkbox = ttk.Checkbutton( 88 | compress_frame, 89 | text="压缩PNG图片", 90 | variable=self.compress_png_var 91 | ) 92 | self.compress_png_checkbox.pack(side=tk.LEFT, padx=(10, 0)) 93 | 94 | self.detail_text = tk.Text(detail_frame, wrap=tk.WORD, height=15) 95 | self.detail_text.pack(fill=tk.BOTH, expand=True) 96 | 97 | # 进度条 98 | self.progress = ttk.Progressbar(self.root, orient=tk.HORIZONTAL, mode='determinate') 99 | self.progress.pack(fill=tk.X, padx=5, pady=5) 100 | 101 | # 绑定事件 102 | self.tree.bind('<>', self.on_select) 103 | 104 | def scan_directory(self): 105 | # 使用默认目录作为初始目录 106 | path = filedialog.askdirectory(initialdir=self.default_scan_dir) 107 | if not path: 108 | return 109 | 110 | self.wxid_infos = [] 111 | self.tree.delete(*self.tree.get_children()) 112 | 113 | # 在后台线程执行扫描 114 | threading.Thread(target=self.do_scan, args=(path,)).start() 115 | 116 | def do_scan(self, path): 117 | try: 118 | reg_appid = re.compile(r'(wx[0-9a-f]{16})') 119 | for entry in os.scandir(path): 120 | if entry.is_dir() and reg_appid.match(entry.name): 121 | wxid = reg_appid.findall(entry.name)[0] 122 | info = self.query_wxid_info(wxid) 123 | info['location'] = entry.path 124 | self.wxid_infos.append(info) 125 | 126 | # 更新UI 127 | self.root.after(0, self.update_tree, info) 128 | 129 | except Exception as e: 130 | self.root.after(0, messagebox.showerror, "扫描错误", str(e)) 131 | 132 | def load_wxid_cache(self): 133 | """加载wxid缓存""" 134 | try: 135 | if os.path.exists(self.cache_file): 136 | with open(self.cache_file, 'r', encoding='utf-8') as f: 137 | return json.load(f) 138 | except Exception as e: 139 | print(f"加载缓存失败: {str(e)}") 140 | return {} 141 | 142 | def save_wxid_cache(self): 143 | """保存wxid缓存""" 144 | try: 145 | with open(self.cache_file, 'w', encoding='utf-8') as f: 146 | json.dump(self.wxid_cache, f, ensure_ascii=False, indent=2) 147 | except Exception as e: 148 | print(f"保存缓存失败: {str(e)}") 149 | 150 | def query_wxid_info(self, wxid): 151 | """查询小程序信息,支持缓存""" 152 | # 先检查缓存 153 | if wxid in self.wxid_cache: 154 | return self.wxid_cache[wxid] 155 | 156 | try: 157 | # 尝试从网络获取信息 158 | headers = { 159 | 'Content-Type': 'application/json;charset=utf-8', 160 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 161 | } 162 | 163 | data = {'appid': wxid} 164 | response = requests.post( 165 | 'https://kainy.cn/api/weapp/info/', 166 | json=data, 167 | headers=headers 168 | ) 169 | 170 | if response.status_code == 200: 171 | result = response.json() 172 | if result['code'] == 0: 173 | print(result) 174 | info = { 175 | 'wxid': wxid, 176 | 'nickname': result['data']['nickname'] or wxid, 177 | 'principal_name': result['data']['principal_name'] or '未知开发者', 178 | 'description': result['data']['description'] or '无描述信息' 179 | } 180 | # 保存到缓存 181 | self.wxid_cache[wxid] = info 182 | self.save_wxid_cache() 183 | return info 184 | 185 | raise Exception(f"API返回错误: {response.text}") 186 | 187 | except Exception as e: 188 | print(f"获取小程序信息失败: {str(e)}") 189 | # 如果获取失败,使用wxid作为名称 190 | return { 191 | 'wxid': wxid, 192 | 'nickname': wxid, 193 | 'principal_name': '未知开发者', 194 | 'description': '无法获取小程序信息' 195 | } 196 | 197 | def update_tree(self, info): 198 | self.tree.insert('', 'end', values=( 199 | info['nickname'], 200 | info['principal_name'], 201 | info['description'] 202 | )) 203 | 204 | def browse_output(self): 205 | """浏览并选择输出目录""" 206 | new_dir = filedialog.askdirectory(initialdir=self.output_dir) 207 | if new_dir: 208 | self.output_dir = new_dir 209 | self.output_entry.delete(0, tk.END) 210 | self.output_entry.insert(0, self.output_dir) 211 | 212 | def on_select(self, event): 213 | selected = self.tree.focus() 214 | if not selected: 215 | return 216 | 217 | item = self.tree.item(selected) 218 | values = item['values'] 219 | wxid = [x for x in self.wxid_infos if x['nickname'] == values[0]][0] 220 | 221 | # 设置默认输出目录为小程序目录下的unpack子目录 222 | self.output_dir = os.path.join(wxid['location'], 'unpack') 223 | self.output_entry.delete(0, tk.END) 224 | self.output_entry.insert(0, self.output_dir) 225 | 226 | detail = f"名称: {wxid['nickname']}\n" 227 | detail += f"开发者: {wxid['principal_name']}\n" 228 | detail += f"描述: {wxid['description']}\n" 229 | detail += f"位置: {wxid['location']}" 230 | 231 | self.detail_text.delete(1.0, tk.END) 232 | self.detail_text.insert(tk.END, detail) 233 | self.unpack_btn['state'] = tk.NORMAL 234 | 235 | def decrypt_file(self, wxid, wxapkg_path): 236 | """解密wxapkg文件""" 237 | try: 238 | salt = b'saltiest' 239 | iv = b'the iv: 16 bytes' 240 | 241 | with open(wxapkg_path, 'rb') as f: 242 | encrypted_data = f.read() 243 | 244 | if len(encrypted_data) < 1030: # 6 + 1024 字节的最小长度要求 245 | raise ValueError(f"文件太小: {len(encrypted_data)} 字节") 246 | 247 | print(f"处理文件: {wxapkg_path}, 大小: {len(encrypted_data)} 字节") 248 | 249 | # 生成解密密钥 250 | dk = PBKDF2(wxid.encode(), salt, dkLen=32, count=1000) 251 | cipher = AES.new(dk, AES.MODE_CBC, iv) 252 | 253 | # 解密前1024字节,跳过前6个字节 254 | encrypted_header = encrypted_data[6:6+1024] 255 | header = cipher.decrypt(encrypted_header) 256 | 257 | # 处理剩余数据 258 | xor_key = ord(wxid[-2]) if len(wxid) >= 2 else 0x66 259 | body = bytearray(len(encrypted_data) - 1024 - 6) # 移除前6字节和1024字节头部 260 | 261 | # 从第1030字节开始处理剩余数据 262 | for i, b in enumerate(encrypted_data[1024+6:]): 263 | body[i] = b ^ xor_key 264 | 265 | # 合并数据 - 保持完整的header 266 | decrypted = header[:1023] + bytes(body) # 去掉header最后一个字节 267 | 268 | # 验证文件头标记 269 | if decrypted[0] != 0xBE or decrypted[13] != 0xED: 270 | print(f"文件头: {' '.join([f'{b:02x}' for b in decrypted[:16]])}") 271 | print(f"期望的标记: BE ... ED") 272 | print(f"实际的标记: {decrypted[0]:02x} ... {decrypted[13]:02x}") 273 | raise ValueError(f"无效的wxapkg文件标记: {decrypted[0]:02x} {decrypted[13]:02x}") 274 | 275 | print(f"解密完成: 头部大小={len(header[:1023])}, 主体大小={len(body)}, 总大小={len(decrypted)}") 276 | 277 | # 调试输出 278 | if b'app-config.json' in decrypted: 279 | print("发现app-config.json,检查其内容...") 280 | try: 281 | start = decrypted.index(b'{') 282 | end = decrypted.rindex(b'}') + 1 283 | config_data = decrypted[start:end] 284 | print(f"配置文件内容: {config_data.decode('utf-8')}") 285 | except Exception as e: 286 | print(f"提取配置文件内容失败: {str(e)}") 287 | 288 | return decrypted 289 | 290 | except Exception as e: 291 | raise Exception(f"解密失败: {str(e)}") 292 | 293 | def is_game_project(self, config_data): 294 | """判断是否为微信小游戏项目""" 295 | try: 296 | # 确保配置文件内容完整 297 | start = config_data.index(b'{') 298 | end = config_data.rindex(b'}') + 1 299 | config_data = config_data[start:end] 300 | 301 | # 将二进制数据解码为字符串 302 | config_str = config_data.decode('utf-8') 303 | print(f"解析的JSON内容: {config_str}") 304 | 305 | config = json.loads(config_str) 306 | 307 | # 检查是否包含游戏相关的配置项 308 | is_game = ( 309 | 'deviceOrientation' in config or # 游戏通常会设置屏幕方向 310 | 'openDataContext' in config or # 游戏排行榜相关 311 | 'workers' in config or # 游戏常用 worker 312 | config.get('subpackages', []) or # 游戏通常有分包 313 | 'plugins' in config # 游戏可能使用插件 314 | ) 315 | 316 | if is_game: 317 | print("检测到游戏特征:") 318 | print(f"- deviceOrientation: {'deviceOrientation' in config}") 319 | print(f"- openDataContext: {'openDataContext' in config}") 320 | print(f"- workers: {'workers' in config}") 321 | print(f"- subpackages: {bool(config.get('subpackages', []))}") 322 | print(f"- plugins: {'plugins' in config}") 323 | 324 | return is_game 325 | 326 | except Exception as e: 327 | print(f"解析配置文件失败: {str(e)}") 328 | if 'config_str' in locals(): 329 | print(f"JSON内容: {config_str}") 330 | return False 331 | 332 | def unpack(self, decrypted_data, output_dir): 333 | """解包已解密数据""" 334 | try: 335 | # 解析文件头 336 | if len(decrypted_data) < 14: 337 | raise ValueError("文件数据不完整") 338 | 339 | # 读取文件头信息 340 | first_mark = decrypted_data[0] 341 | info1 = struct.unpack('>L', decrypted_data[1:5])[0] 342 | index_info_length = struct.unpack('>L', decrypted_data[5:9])[0] 343 | body_info_length = struct.unpack('>L', decrypted_data[9:13])[0] 344 | last_mark = decrypted_data[13] 345 | 346 | if first_mark != 0xBE or last_mark != 0xED: 347 | raise ValueError(f"无效的wxapkg文件标记: {first_mark:02x} {last_mark:02x}") 348 | 349 | # 读取文件数量 350 | current_pos = 14 351 | file_count = struct.unpack('>L', decrypted_data[current_pos:current_pos+4])[0] 352 | current_pos += 4 353 | 354 | print(f"文件头: first_mark={first_mark:02x}, last_mark={last_mark:02x}") 355 | print(f"文件数量: {file_count}") 356 | print(f"索引长度: {index_info_length}, 数据长度: {body_info_length}") 357 | 358 | # 解析文件索引 359 | files = [] 360 | for _ in range(file_count): 361 | try: 362 | # 读取文件名长度 363 | name_len = struct.unpack('>L', decrypted_data[current_pos:current_pos+4])[0] 364 | current_pos += 4 365 | 366 | # 读取文件名 - 添加错误处理 367 | try: 368 | name = decrypted_data[current_pos:current_pos+name_len].decode('utf-8') 369 | except UnicodeDecodeError: 370 | # 如果UTF-8解码失败,尝试其他编码或使用替代字符 371 | try: 372 | name = decrypted_data[current_pos:current_pos+name_len].decode('utf-8', errors='replace') 373 | except: 374 | name = f"unknown_file_{len(files)}" 375 | current_pos += name_len 376 | 377 | # 读取文件偏移和大小 378 | offset = struct.unpack('>L', decrypted_data[current_pos:current_pos+4])[0] 379 | current_pos += 4 380 | size = struct.unpack('>L', decrypted_data[current_pos:current_pos+4])[0] 381 | current_pos += 4 382 | 383 | files.append((name, offset, size)) 384 | print(f"找到文件: {name}, 偏移: {offset}, 大小: {size}") 385 | except Exception as e: 386 | print(f"解析文件索引时出错: {str(e)}") 387 | current_pos += name_len + 8 # 跳过这个文件的索引 388 | continue 389 | 390 | # 创建输出目录 391 | os.makedirs(output_dir, exist_ok=True) 392 | 393 | # 保存文件 394 | total = 0 395 | for name, offset, size in files: 396 | try: 397 | # 提取文件数据 398 | file_data = decrypted_data[offset:offset+size] 399 | if len(file_data) != size: 400 | print(f"警告: 文件 {name} 大小不匹配, 预期: {size}, 实际: {len(file_data)}") 401 | 402 | # 检查是否需要重命名 app-config.json 403 | if name.endswith('/app-config.json') or name == 'app-config.json': 404 | try: 405 | print(f"检查配置文件: {name}") 406 | if self.is_game_project(file_data): 407 | new_name = name.replace('app-config.json', 'game.json') 408 | print(f"检测到小游戏配置,将 {name} 重命名为: {new_name}") 409 | name = new_name 410 | else: 411 | print("不是小游戏配置文件") 412 | except Exception as e: 413 | print(f"检查配置文件时出错: {str(e)}") 414 | 415 | # 规范化文件路径 416 | file_path = os.path.join(output_dir, name.lstrip('/')) 417 | 418 | # 使用新的保存函数 419 | if self.save_file_content(file_path, file_data): 420 | print(f"已保存文件: {file_path}") 421 | total += 1 422 | self.update_progress(total/file_count) 423 | 424 | except Exception as e: 425 | print(f"处理文件 {name} 时出错: {str(e)}") 426 | continue 427 | 428 | return total 429 | 430 | except Exception as e: 431 | messagebox.showerror("解包错误", f"解包过程出错: {str(e)}") 432 | return 0 433 | 434 | def update_progress(self, value): 435 | """更新进度条""" 436 | self.progress['value'] = value * 100 437 | self.root.update_idletasks() 438 | 439 | def unpack_selected(self): 440 | selected = self.tree.focus() 441 | if not selected: 442 | return 443 | 444 | item = self.tree.item(selected) 445 | values = item['values'] 446 | wx_info = next(x for x in self.wxid_infos if x['nickname'] == values[0]) 447 | 448 | # 直接使用当前设置的输出目录 449 | output_dir = self.output_entry.get() 450 | if not output_dir: 451 | messagebox.showerror("错误", "请指定输出目录") 452 | return 453 | 454 | print(f"使用输出目录: {output_dir}") 455 | 456 | # 在后台执行解包 457 | def do_unpack(): 458 | try: 459 | wxid = wx_info['wxid'] 460 | wxapp_dir = wx_info['location'] 461 | 462 | print(f"开始处理小程序: {wxid}") 463 | print(f"小程序目录: {wxapp_dir}") 464 | 465 | # 扫描所有.wxapkg文件 466 | pkg_files = [] 467 | for root, dirs, files in os.walk(wxapp_dir): 468 | for file in files: 469 | if file.endswith('.wxapkg'): 470 | pkg_files.append(os.path.join(root, file)) 471 | 472 | print(f"找到 {len(pkg_files)} 个wxapkg文件") 473 | 474 | total_files = 0 475 | for pkg in pkg_files: 476 | print(f"\n处理文件包: {pkg}") 477 | decrypted = self.decrypt_file(wxid, pkg) 478 | count = self.unpack(decrypted, output_dir) 479 | total_files += count 480 | print(f"该包解包完成,解出 {count} 个文件") 481 | 482 | if messagebox.askquestion("完成", 483 | f"成功解包{total_files}个文件\n输出目录: {output_dir}\n\n是否打开输出目录?", 484 | icon='info') == 'yes': 485 | # 根据操作系统打开文件夹 486 | if os.name == 'nt': # Windows 487 | os.startfile(output_dir) 488 | elif os.name == 'posix': # macOS 和 Linux 489 | try: 490 | os.system(f'open "{output_dir}"') # macOS 491 | except: 492 | os.system(f'xdg-open "{output_dir}"') # Linux 493 | 494 | except Exception as e: 495 | messagebox.showerror("错误", str(e)) 496 | finally: 497 | self.progress['value'] = 0 498 | 499 | threading.Thread(target=do_unpack).start() 500 | 501 | def minify_js(self, content): 502 | """压缩JS代码""" 503 | try: 504 | import jsmin 505 | return jsmin.jsmin(content) 506 | except ImportError: 507 | print("jsmin模块未安装,跳过JS压缩") 508 | return content 509 | 510 | def minify_json(self, content): 511 | """压缩JSON内容""" 512 | try: 513 | data = json.loads(content) 514 | return json.dumps(data, separators=(',', ':')) 515 | except: 516 | return content 517 | 518 | def minify_png(self, content): 519 | """压缩PNG图片""" 520 | try: 521 | from PIL import Image 522 | import io 523 | 524 | # 将二进制内容转换为图片对象 525 | input_buffer = io.BytesIO(content) 526 | image = Image.open(input_buffer) 527 | 528 | # 如果不是PNG格式,直接返回原内容 529 | if image.format != 'PNG': 530 | return content 531 | 532 | # 获取原始大小 533 | original_size = len(content) 534 | 535 | # 创建输出缓冲区 536 | output = io.BytesIO() 537 | 538 | # 保持原始模式,使用优化参数 539 | image.save(output, 540 | format='PNG', 541 | optimize=True, 542 | quality=85, # 质量参数 543 | compress_level=9) # 最大压缩级别 544 | 545 | # 获取压缩后的内容 546 | compressed_content = output.getvalue() 547 | compressed_size = len(compressed_content) 548 | 549 | # 如果压缩后反而变大,则返回原始内容 550 | if compressed_size >= original_size: 551 | print(f"PNG压缩后变大 ({original_size} -> {compressed_size}),保持原始大小") 552 | return content 553 | 554 | print(f"PNG压缩成功: {original_size} -> {compressed_size} bytes ({(compressed_size/original_size*100):.1f}%)") 555 | return compressed_content 556 | 557 | except Exception as e: 558 | print(f"PNG压缩失败: {str(e)}") 559 | return content 560 | 561 | def should_compress(self, filename): 562 | """判断文件是否需要压缩""" 563 | if filename.endswith(('.js', '.json')): 564 | return self.compress_var.get() 565 | elif filename.endswith('.png'): 566 | return self.compress_png_var.get() 567 | return False 568 | 569 | def save_file_content(self, file_path, content): 570 | """保存文件内容,根据需要进行压缩""" 571 | try: 572 | if self.should_compress(file_path): 573 | if file_path.endswith('.js'): 574 | content = self.minify_js(content.decode('utf-8')).encode('utf-8') 575 | elif file_path.endswith('.json'): 576 | content = self.minify_json(content.decode('utf-8')).encode('utf-8') 577 | elif file_path.endswith('.png'): 578 | content = self.minify_png(content) 579 | 580 | # 确保目录存在 581 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 582 | 583 | # 写入文件 584 | with open(file_path, 'wb') as f: 585 | f.write(content) 586 | 587 | return True 588 | except Exception as e: 589 | print(f"保存文件 {file_path} 时出错: {str(e)}") 590 | return False 591 | 592 | if __name__ == '__main__': 593 | root = tk.Tk() 594 | app = WxapkgUnpacker(root) 595 | root.mainloop() --------------------------------------------------------------------------------