├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── docs.yml │ └── release.yml ├── winres ├── icon.png └── winres.json ├── docs ├── assets │ ├── faq1.png │ ├── faq2.png │ ├── fixed_btn1.jpg │ ├── fixed_btn2.jpg │ ├── screenshot1.png │ ├── screenshot10.png │ ├── screenshot11.png │ ├── screenshot12.png │ ├── screenshot13.png │ ├── screenshot14.png │ ├── screenshot2.png │ ├── screenshot3.png │ ├── screenshot4.png │ ├── screenshot5.png │ ├── screenshot6.png │ ├── screenshot7.png │ ├── screenshot8.png │ ├── screenshot9.png │ ├── app_screenshot1.png │ ├── enable_step1_macos.png │ ├── enable_step2_macos.png │ ├── system_proxy_macos.png │ └── channel_profile_page1.png ├── releases │ ├── 250425.md │ ├── 250514.md │ ├── 250808.md │ ├── 241030.md │ ├── 241102.md │ ├── 241022.md │ ├── 241101.md │ ├── 250913.md │ ├── 250424.md │ ├── 251203.md │ ├── 251027.md │ ├── 241106.md │ ├── 241016.md │ ├── 241011.md │ ├── 241104.md │ ├── 241031.md │ ├── 251202.md │ ├── 250215.md │ ├── 250621.md │ ├── 251201.md │ ├── 251130.md │ ├── 241216.md │ ├── 251213.md │ └── 251122.md ├── cli │ ├── version.md │ ├── uninstall.md │ ├── decrypt.md │ ├── proxy.md │ └── download.md ├── feature │ ├── live.md │ ├── filename.md │ ├── mp3.md │ ├── long_video.md │ ├── custom-menu.md │ └── event.md ├── faq │ ├── decrypt_fail.md │ ├── download_stuck.md │ ├── button_inject_failed.md │ ├── powershell.md │ └── network_failed.md ├── config │ ├── channel.md │ ├── script.md │ ├── proxy.md │ └── download.md ├── package.json ├── index.md ├── guide │ ├── step.md │ ├── start.md │ └── macos.md ├── .vitepress │ ├── theme │ │ └── index.ts │ ├── components │ │ ├── LatestIssues.vue │ │ ├── EnvInfo.vue │ │ └── DownloadButton.vue │ └── config.ts └── releases.md ├── pkg ├── util │ └── util.go ├── platform │ ├── platform.go │ ├── platform_linux.go │ ├── platform_darwin.go │ └── platform_windows.go ├── proxy │ ├── proxy.go │ ├── proxy_windows.go │ ├── proxy_darwin.go │ └── proxy_linux.go ├── argv │ └── argv.go ├── certificate │ ├── certs │ │ ├── SunnyRoot.cer │ │ └── private.key │ ├── certificate.go │ ├── certificate_darwin.go │ ├── certificate_linux.go │ └── certificate_windows.go ├── decrypt │ └── decrypt.go └── download │ └── download.go ├── .gitignore ├── internal ├── interceptor │ ├── inject │ │ ├── package.json │ │ ├── lib │ │ │ ├── mitt.umd.js │ │ │ ├── FileSaver.min.js │ │ │ └── floating-ui.dom.1.7.4.min.js │ │ ├── src │ │ │ ├── pagespy.js │ │ │ ├── live.js │ │ │ ├── utils.d.ts │ │ │ ├── components.js │ │ │ ├── error.js │ │ │ └── eventbus.js │ │ └── eslint.config.mjs │ ├── server.go │ ├── assets.go │ ├── types.go │ ├── interceptor.go │ └── settings.go ├── download │ ├── server.go │ └── download.go ├── manager │ ├── types.go │ ├── manager.go │ └── server.go └── application │ └── application.go ├── config ├── config.template.yaml ├── schema.go └── config.go ├── cmd ├── version.go ├── uninstall.go ├── decrypt.go ├── download.go └── root.go ├── main.go ├── go.mod ├── README.md ├── LICENSE ├── .goreleaser.yaml └── go.sum /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /winres/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/winres/icon.png -------------------------------------------------------------------------------- /docs/assets/faq1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/faq1.png -------------------------------------------------------------------------------- /docs/assets/faq2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/faq2.png -------------------------------------------------------------------------------- /docs/assets/fixed_btn1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/fixed_btn1.jpg -------------------------------------------------------------------------------- /docs/assets/fixed_btn2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/fixed_btn2.jpg -------------------------------------------------------------------------------- /docs/assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot1.png -------------------------------------------------------------------------------- /docs/assets/screenshot10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot10.png -------------------------------------------------------------------------------- /docs/assets/screenshot11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot11.png -------------------------------------------------------------------------------- /docs/assets/screenshot12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot12.png -------------------------------------------------------------------------------- /docs/assets/screenshot13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot13.png -------------------------------------------------------------------------------- /docs/assets/screenshot14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot14.png -------------------------------------------------------------------------------- /docs/assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot2.png -------------------------------------------------------------------------------- /docs/assets/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot3.png -------------------------------------------------------------------------------- /docs/assets/screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot4.png -------------------------------------------------------------------------------- /docs/assets/screenshot5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot5.png -------------------------------------------------------------------------------- /docs/assets/screenshot6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot6.png -------------------------------------------------------------------------------- /docs/assets/screenshot7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot7.png -------------------------------------------------------------------------------- /docs/assets/screenshot8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot8.png -------------------------------------------------------------------------------- /docs/assets/screenshot9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/screenshot9.png -------------------------------------------------------------------------------- /docs/assets/app_screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/app_screenshot1.png -------------------------------------------------------------------------------- /docs/releases/250425.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250425 3 | --- 4 | 5 | # v250425 6 | 7 | ## 主要变化 8 | 9 | - 修复了无法下载视频的问题 10 | -------------------------------------------------------------------------------- /docs/assets/enable_step1_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/enable_step1_macos.png -------------------------------------------------------------------------------- /docs/assets/enable_step2_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/enable_step2_macos.png -------------------------------------------------------------------------------- /docs/assets/system_proxy_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/system_proxy_macos.png -------------------------------------------------------------------------------- /docs/releases/250514.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250514 3 | --- 4 | 5 | # v250514 6 | 7 | ## 主要变化 8 | 9 | - 修复无法下载图片视频的问题 10 | -------------------------------------------------------------------------------- /docs/releases/250808.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250808 3 | --- 4 | 5 | # v250808 6 | 7 | ## 主要变化 8 | 9 | - 修复了微信新版本没有下载按钮的问题 10 | -------------------------------------------------------------------------------- /docs/assets/channel_profile_page1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ltaoo/wx_channels_download/HEAD/docs/assets/channel_profile_page1.png -------------------------------------------------------------------------------- /docs/releases/241030.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241030 3 | --- 4 | 5 | # v241030 6 | 7 | ## 主要变化 8 | 9 | - 当视频号内容是多张图片时,也会出现下载按钮。点击将会下载一个包含了全部图片的压缩包 10 | -------------------------------------------------------------------------------- /docs/releases/241102.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241102 3 | --- 4 | 5 | # v241102 6 | 7 | ## 主要变化 8 | 9 | - 在「更多」下拉菜单增加「下载视频」按钮,兼容不同详情页布局不同导致没有下载按钮的问题 10 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "strings" 4 | 5 | func Includes(str, substr string) bool { 6 | return strings.Contains(str, substr) 7 | } 8 | -------------------------------------------------------------------------------- /docs/cli/version.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 查看版本 3 | --- 4 | 5 | # 查看版本 6 | 7 | 用于查看当前下载器的版本号 8 | 9 | ## 用法 10 | 11 | ```sh 12 | wx_video_download version 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/releases/241022.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241022 3 | --- 4 | 5 | # v241022 6 | 7 | ## 主要变化 8 | 9 | - 当视频被删除时没有正确地显示「被删除」而是一直处于加载中状态 10 | - 下载按钮修改成和其他操作按钮相同的样式 11 | -------------------------------------------------------------------------------- /docs/releases/241101.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241101 3 | --- 4 | 5 | # v241101 6 | 7 | ## 主要变化 8 | 9 | - 现在无需手动下载证书并安装了 10 | - 修复了下载时提示找不到 `lib/jszip.min.js` 的问题 11 | -------------------------------------------------------------------------------- /docs/feature/live.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 下载直播 3 | --- 4 | 5 | # 下载直播 6 | 7 | > 需要已安装 `ffmpeg` 8 | 9 | 在直播页面,右上角会有「下载图标」按钮,点击后会在终端输出 `ffmpeg` 开头的命令,将命令复制到任意终端执行即可开始下载 10 | 11 | -------------------------------------------------------------------------------- /docs/releases/250913.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250913 3 | --- 4 | 5 | # v250913 6 | 7 | ## 主要变化 8 | 9 | - 视频号首页增加下载按钮 10 | - 增加 `uninstall` 命令,可卸载本工具安装的根证书 11 | - 修复打开长视频页面空白的问题 12 | -------------------------------------------------------------------------------- /docs/releases/250424.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250424 3 | --- 4 | 5 | # v250424 6 | 7 | ## 主要变化 8 | 9 | - 修复了下载按钮样式不一致的问题 10 | - 修复了更多按钮点击不显示更多菜单的问题 11 | - 增加了 windows 启动失败时提示是否以管理员身份运行 12 | -------------------------------------------------------------------------------- /docs/releases/251203.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251203 3 | --- 4 | 5 | # v251203 6 | 7 | ## 主要变化 8 | 9 | - 修复了双击打开时无法正确找到配置文件的问题 10 | - 修复了不存在配置文件仍尝试读取的问题 11 | - 修复了 `Windows` 构建包没有 `icon` 的问题 -------------------------------------------------------------------------------- /docs/releases/251027.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251027 3 | --- 4 | 5 | # v251027 6 | 7 | ## 主要变化 8 | 9 | - 优化了文件大小,现在只有8MB了 10 | - `download` 命令默认多线程下载 11 | - 直播详情页增加了下载按钮 12 | - 默认下载原始规格的视频 13 | -------------------------------------------------------------------------------- /docs/faq/decrypt_fail.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 解密失败 3 | --- 4 | 5 | # 解密失败 6 | 7 | - 尝试关闭全部视频页面、窗口。重新打开 8 | - 251202 之后的版本,解密失败仍会将视频下载至本地,尝试使用 `decrypt` 命令对已下载的文件进行解密,参考 [解密](../cli/decrypt.md) 9 | 10 | -------------------------------------------------------------------------------- /docs/faq/download_stuck.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 下载卡住 3 | --- 4 | 5 | # 下载卡住 6 | 7 | 先刷新视频号页面,然后重新下载 8 | 9 | 如果视频时长超过 30min,请使用 `download` 命令下载或配置本地中转服务,参考 [长视频下载](../feature/long_video.md) 10 | 11 | -------------------------------------------------------------------------------- /docs/feature/filename.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 指定下载的文件名 3 | --- 4 | 5 | # 指定下载的文件名 6 | 7 | ## 通过配置文件 8 | 9 | - [下载配置](../config/download.md#下载时的文件名称) 10 | 11 | 12 | ## 通过全局脚本 13 | 14 | - [全局脚本](../config/script.md#全局脚本) 15 | -------------------------------------------------------------------------------- /docs/releases/241106.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241106 3 | --- 4 | 5 | # v241106 6 | 7 | ## 主要变化 8 | 9 | - 修复了非首次打开的视频,下载下来都无法播放的问题 10 | 11 | 现在点击页面上「更多推荐」视频,下载下来的视频可以正常打开播放了。 12 | 13 | 当出现「解密失败,停止下载」的提示,关闭全部视频页面、窗口。重新打开,就可以下载。 14 | -------------------------------------------------------------------------------- /docs/cli/uninstall.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 卸载根证书命令 3 | --- 4 | 5 | # 卸载根证书 6 | 7 | 下载器首次使用时会自动安装根证书,该命令可以卸载掉安装的根证书 8 | 9 | ## 用法 10 | 11 | ```sh 12 | wx_video_download uninstall 13 | ``` 14 | 15 | ## 说明 16 | 17 | - 卸载后,在此使用下载器,会重新安装证书 18 | -------------------------------------------------------------------------------- /docs/faq/button_inject_failed.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 按钮注入失败 3 | --- 4 | 5 | # 按钮注入失败 6 | 7 | ## 排查步骤 8 | 9 | - 确认下载器已启动 10 | - 确认系统代理设置成功 11 | - 若配置不使用系统代理,请使用 Clash 等软件,视频号请求转发到端口 `127.0.0.1:2023` 12 | - 刷新页面或重新进入视频详情页 13 | - 升级到最新版本后重试。 14 | -------------------------------------------------------------------------------- /docs/faq/powershell.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: PowerShell 3 | --- 4 | 5 | # PowerShell 6 | 7 | ```bash 8 | exec:"powershell.exe":executable file not found in %PATH%。 9 | ``` 10 | 11 | 参考 https://github.com/ltaoo/wx_channels_download/issues/42 12 | -------------------------------------------------------------------------------- /docs/releases/241016.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241016 3 | --- 4 | 5 | # v241016 6 | 7 | ## 主要变化 8 | 9 | - 前一个版本又下载不了,改回在页面直接下载又正常了 10 | 11 | 是和微信客户端版本有关吗,对这块不了解。如果 241016 这个版本用不了,可以试试其他版本。 12 | 13 | 我目前微信客户端版本是 `Weixin 3.9.12.17`,可以正常下载的。 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.exe 3 | *.exe~ 4 | *.syso 5 | *.p8 6 | *.p12 7 | dist/ 8 | 9 | wx_video_download* 10 | wx_channel* 11 | 12 | config.yaml 13 | global.js 14 | 15 | node_modules 16 | docs/.vitepress/cache 17 | docs/.vitepress/dist 18 | -------------------------------------------------------------------------------- /docs/releases/241011.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241011 3 | --- 4 | 5 | # v241011 6 | 7 | ## 主要变化 8 | 9 | - 应该是视频号又改版了,不能直接在页面下载了。改成点击下载按钮复制视频链接到粘贴板,然后到谷歌或其他浏览器打开下载 10 | - 另外测试了很多视频都可以直接下载,没有加密了。所以如果有加密视频,新版本可能会下载失败 11 | 12 | > 在页面直接下载,理论上还是能实现,实现上要麻烦许多,后面再研究。 13 | -------------------------------------------------------------------------------- /docs/releases/241104.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241104 3 | --- 4 | 5 | # v241104 6 | 7 | ## 主要变化 8 | 9 | - 支持下载不同质量的视频 10 | - 修复了下载的视频无法拖动进度条的问题 11 | - 修复了长视频内容进度未加载就下载,导致视频无法播放或不完整的问题 12 | - 修复了某些视频误判断为图片导致无法下载的问题 13 | - 修复了直播间一直加载中的问题 14 | 15 | 关于不同质量的视频,详情见使用说明。 16 | -------------------------------------------------------------------------------- /docs/releases/241031.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241031 3 | --- 4 | 5 | # v241031 6 | 7 | ## 主要变化 8 | 9 | - 又遇到之前无法在页面下载的问题,这次改成了下载压缩包,视频在压缩包内的形式 10 | 11 | 目前是可行的,但无法保证之后仍然可行。 12 | 13 | 建议使用 [WechatVideoSniffer2.0](https://github.com/kanadeblisst00/WechatVideoSniffer2.0) 稳定性更高。 14 | -------------------------------------------------------------------------------- /docs/releases/251202.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251202 3 | --- 4 | 5 | # v251202 6 | 7 | ## 主要变化 8 | 9 | - 修复了不能下载封面的问题 10 | - 支持前端转换 `mp3` 文件,无需本地安装 `ffmpeg` 了 11 | - 支持配置是否在下载视频时暂停视频播放。参考 [配置](../config/download.md#是否在下载视频时暂停视频播放) 12 | - 优化了通过本地中转服务下载时的体验,现在不会打开新 tab 然后再关闭了 13 | -------------------------------------------------------------------------------- /pkg/platform/platform.go: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | func IsAdmin() bool { 4 | return is_admin() 5 | } 6 | 7 | func NeedAdminPermission() bool { 8 | return need_admin_permission() 9 | } 10 | 11 | func RequestAdminPermission() bool { 12 | return request_admin_permission() 13 | } 14 | -------------------------------------------------------------------------------- /pkg/platform/platform_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package platform 4 | 5 | func is_admin() bool { 6 | return false 7 | } 8 | 9 | func need_admin_permission() bool { 10 | return false 11 | } 12 | 13 | func request_admin_permission() bool { 14 | return false 15 | } 16 | 17 | -------------------------------------------------------------------------------- /docs/cli/decrypt.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 解密命令 3 | --- 4 | 5 | # 解密 6 | 7 | 用于对已下载的加密视频进行解密,解密后会覆盖原文件。 8 | 9 | ## 用法 10 | 11 | ```sh 12 | wx_video_download decrypt --filepath "/绝对路径/文件名.mp4" --key 123456 13 | ``` 14 | 15 | ## 参数 16 | 17 | - `--filepath` 本地加密视频文件绝对路径(必需) 18 | - `--key` 解密密钥(必需) 19 | -------------------------------------------------------------------------------- /docs/config/channel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 视频号 3 | --- 4 | 5 | # 视频号 6 | 7 | 对视频号本身进行一些配置 8 | 9 | ## 禁止从详情页重定向到首页 10 | 11 | 默认情况下,从「文件助手」打开视频号时,会自动重定向到首页 12 | 13 | ```yaml 14 | channel: 15 | disableLocationToHome: false 16 | ``` 17 | 18 | 如果想禁止这个行为,可以在配置文件中将 `disableLocationToHome` 设置为 `true`: 19 | -------------------------------------------------------------------------------- /internal/interceptor/inject/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wx_channels_download", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "lint": "eslint \"src/**/*.js\"" 7 | }, 8 | "devDependencies": { 9 | "eslint": "^9.14.0", 10 | "@eslint/js": "^9.14.0", 11 | "globals": "^15.12.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/releases/250215.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250215 3 | --- 4 | 5 | # v250215 6 | 7 | ## 主要变化 8 | 9 | - 在控制台显示下载进度,当获取不到进度时显示已下载的字节数 10 | - 在「更多」菜单中增加封面图片下载 11 | - 自动检测当前网络设备并代理 12 | - 支持命令行参数指定要代理的网络设备和程序使用的端口号 13 | 14 | ```bash 15 | ./wx_video_download_xxx --dev=Wi-Fi --port=1080 16 | ``` 17 | 18 | > 一般情况下无需手动指定设备与端口号,直接 ./wx_video_download_xxx 即可 19 | -------------------------------------------------------------------------------- /docs/faq/network_failed.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 网络无法访问 3 | --- 4 | 5 | # 网络无法访问 6 | 7 | ## 常见原因 8 | 9 | - 启动后设置了系统代理,影响其他代理软件或网络策略。 10 | 11 | ## 解决方案 12 | 13 | 取消系统代理即可 14 | 15 | 16 | ## 和 Clash 一起使用 17 | 18 | 如果使用了类似 `Clash`、`Sing-box` 等翻墙软件,可以在 `config.yaml` 设置 `proxy.system=false`,通过 Clash 将流量转发到下载器端口。 19 | 20 | 参考 [代理配置](../config/proxy.md#与-clash-协同) 21 | -------------------------------------------------------------------------------- /docs/cli/proxy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 启动代理服务命令 3 | --- 4 | 5 | # 启动代理服务 6 | 7 | 核心功能,启动一个代理服务,拦截并修改视频号网络请求,达到插入下载按钮的目的 8 | 9 | ## 用法 10 | 11 | ```sh 12 | wx_video_download --hostname 127.0.0.1 --port 2023 --debug 13 | ``` 14 | 15 | - `--hostname` 代理服务器主机名(默认 `127.0.0.1`) 16 | - `--port` 代理服务器端口(默认 `2023`) 17 | - `--debug` 是否开启调试输出 18 | 19 | 运行时会打印版本与问题反馈链接,并根据是否设置系统代理给出引导。 20 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wx_channels_download-docs", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vitepress dev", 8 | "build": "vitepress build", 9 | "preview": "vitepress preview" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "^24.10.1", 13 | "vitepress": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | title: wx_channels_download 4 | hero: 5 | name: 微信视频号下载器 6 | tagline: 一键下载视频号内容 7 | actions: 8 | - theme: brand 9 | text: 快速开始 10 | link: /guide/start 11 | features: 12 | - title: 跨平台 13 | details: 支持 macOS、Windows 14 | - title: 体积小 15 | details: 仅有 8MB,打开即可使用 16 | - title: 使用简单 17 | details: 直接在视频号页面下载,只需点击一下 18 | --- 19 | 20 | -------------------------------------------------------------------------------- /docs/releases/250621.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v250621 3 | --- 4 | 5 | # v250621 6 | 7 | ## 主要变化 8 | 9 | - 优化下载时的「进度」展示效果 10 | - 增加 `download` 命令,可在终端下载视频及解密。超过 1G 的视频建议使用该方式下载 11 | 12 | ```bash 13 | # 使用方式 14 | ./wx_video_download_xx download --url "视频地址" --key 解密key --filename "文件名" 15 | # 视频地址、文件名参数需要双引号包裹。解密key不用双引号 16 | # 将会下载视频到 `Downloads` 目录,然后解密 17 | ``` 18 | 19 | - 视频号「更多」菜单中增加「打印下载命令」按钮 20 | - 修复安装证书时有些错误提示不是中文的问题 21 | -------------------------------------------------------------------------------- /docs/cli/download.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 下载命令 3 | --- 4 | 5 | # 下载 6 | 7 | 用于长视频下载,下载完成后自动解密。 8 | 9 | ## 用法 10 | 11 | ```sh 12 | wx_video_download download --url "视频URL" --filename "文件名.mp4" --key 123456 13 | ``` 14 | 15 | ## 参数 16 | 17 | - `--url` 视频地址(必需) 18 | - `--filename` 目标文件名,默认使用当前时间命名到 `Downloads` 目录 19 | - `--key` 解密密钥(若视频未加密可不传) 20 | 21 | ## 说明 22 | 23 | - 程序会先下载到临时文件,再按需解密为目标文件并删除临时文件。 24 | - 长视频建议使用命令行下载,稳定性更好。 25 | -------------------------------------------------------------------------------- /internal/download/server.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "wx_channel/internal/manager" 5 | ) 6 | 7 | type DownloadServer struct { 8 | *manager.HTTPServer 9 | } 10 | 11 | func NewDownloadServer(addr string) *DownloadServer { 12 | srv := manager.NewHTTPServer("下载服务", "download", addr) 13 | proxy := NewMediaProxyWithDecrypt() 14 | srv.SetHandler(withCORS(proxy)) 15 | 16 | return &DownloadServer{ 17 | HTTPServer: srv, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /config/config.template.yaml: -------------------------------------------------------------------------------- 1 | debug: 2 | error: false 3 | 4 | pagespy: 5 | enabled: false 6 | 7 | download: 8 | defaultHighest: false 9 | filenameTemplate: "{{filename}}_{{spec}}" 10 | pauseWhenDownload: false 11 | localServer: 12 | enabled: false 13 | downloadLocalServerAddr: "127.0.0.1:8080" 14 | 15 | proxy: 16 | system: true 17 | hostname: "127.0.0.1" 18 | port: 2023 19 | 20 | channel: 21 | disableLocationToHome: false 22 | -------------------------------------------------------------------------------- /docs/releases/251201.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251201 3 | --- 4 | 5 | # v251201 6 | 7 | ## 主要变化 8 | 9 | - 增加了本地下载中转服务,开启后下载视频将不会阻塞页面操作。使用参考 [配置](../config/download.md#本地下载中转服务) 10 | - 支持使用本地下载中转服务,将视频转换成 `mp3` 并下载,参考 [mp3下载](../feature/mp3.md) 11 | - 支持禁止从详情页重定向到首页。使用参考 [配置](../config/channel.md#禁止从详情页重定向到首页) 12 | - 修复了视频号首页切换视频后,没有出现下载按钮的问题(现在仍有问题,出现按钮,但是来回切换后,下载的视频不是当前视频) 13 | - 修复了 `download` 命令没有指定 `key` 时,下载的文件名不是传入的文件名的问题 14 | - 修复了 `decrypt` 命令没有传入正确参数时,终端报错的问题 15 | -------------------------------------------------------------------------------- /docs/config/script.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 用户脚本 3 | --- 4 | 5 | # 用户脚本 6 | 7 | 目前有两种方式在视频号页面注入额外的 `js` 脚本 8 | 9 | ## 全局脚本 10 | 11 | 在和 `wx_video_download.exe` 同级目录,如果存在 `global.js`,则会将其插入视频号页面。目前可以通过其指定下载时的文件名称 12 | 13 | ```js 14 | // global.js 15 | function beforeFilename(filename, params) { 16 | return filename; 17 | } 18 | ``` 19 | 20 | 21 | ## 主脚本之后插入的脚本 22 | 23 | ```yaml 24 | inject: 25 | extraScript: 26 | afterJSMain: "./extra.js" 27 | ``` 28 | 29 | 可以用来自定义额外功能 30 | -------------------------------------------------------------------------------- /docs/feature/mp3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 下载mp3 3 | --- 4 | 5 | # 下载mp3 6 | 7 | 在「视频详情页」的「更多」菜单中,有「下载为mp3」菜单,点击即可下载 `mp3` 文件。目前有两种实现方式 8 | 9 | ## 前端下载 10 | 11 | 适用于短视频转换为 `mp3` 后下载,默认支持,无需额外配置 12 | 13 | ## 通过中转服务下载 14 | 15 | 适用于长视频转换为 `mp3` 后下载 16 | 17 | 该功能需要已经安装 `ffmpeg`,并配置到 `PATH` 环境变量中,在终端输入 `ffmpeg -version` 可以验证是否安装成功 18 | 19 | 确认 `ffmpeg` 已安装后,需要在 `config.yaml` 中开启 `download.localServer.enabled` 设置为 `true` 20 | 21 | 参考 [本地下载中转服务](../config/download.md#本地下载中转服务) 22 | 23 | -------------------------------------------------------------------------------- /docs/guide/step.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 步骤 3 | --- 4 | 5 | # 使用步骤 6 | 7 | 当下载器启用成功后,终端内容如下 8 | 9 | ![enabled](../assets/app_screenshot1.png) 10 | 11 | 此时可以打开视频号页面正常下载了 12 | 13 | ## 视频号详情页 14 | 15 | ![channel_profile_page1](../assets/channel_profile_page1.png) 16 | 17 | 左侧是视频,下面有操作按钮,右侧是推荐,这种页面即「视频号详情页」。默认会在操作按钮一栏插入下载按钮,点击即可下载 18 | 19 | > 当前仅在视频号详情页支持「更多」菜单,可以下载不同规格的视频,可以打印下载命令。可以通过右上角「个人」->「浏览记录」进入该页面 20 | 21 | ## 视频号首页 22 | 23 | 现在默认的视频播放页 24 | 25 | 同样有下载按钮,同样点击即可下载视频。 26 | 27 | -------------------------------------------------------------------------------- /docs/releases/251130.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251130 3 | --- 4 | 5 | # v251130 6 | 7 | ## 主要变化 8 | 9 | - 支持通过 `config.yaml` 配置下载时的文件名称: 10 | 11 | ```yaml 12 | download: 13 | filenameTemplate: "{{author}}_{{filename}}_{{spec}}_{{download_at}}" 14 | ``` 15 | 16 | 参考 [下载配置](../config/download.md) 17 | 18 | - 支持插入 `global.js` 脚本,对下载时的文件名进行配置 19 | 20 | ```js 21 | // global.js 22 | function beforeFilename(filename, params) { 23 | return filename; 24 | } 25 | ``` 26 | 27 | 参考 [脚本](../config/script.md) 28 | -------------------------------------------------------------------------------- /internal/manager/types.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | type ServerStatus string 4 | 5 | const ( 6 | StatusStopped ServerStatus = "stopped" 7 | StatusStarting ServerStatus = "starting" 8 | StatusRunning ServerStatus = "running" 9 | StatusStopping ServerStatus = "stopping" 10 | StatusError ServerStatus = "error" 11 | ) 12 | 13 | type Server interface { 14 | Name() string 15 | Addr() string 16 | Start() error 17 | Stop() error 18 | Status() ServerStatus 19 | HealthCheck() error 20 | } 21 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var version_cmd = &cobra.Command{ 10 | Use: "version", 11 | Short: "查看版本", 12 | Long: "查看当前应用版本", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | command := cmd.Name() 15 | if command != "version" { 16 | return 17 | } 18 | version_command() 19 | }, 20 | } 21 | 22 | func init() { 23 | root_cmd.AddCommand(version_cmd) 24 | } 25 | 26 | func version_command() { 27 | fmt.Println(Version) 28 | } 29 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import DefaultTheme from 'vitepress/theme' 3 | 4 | import DownloadButton from '../components/DownloadButton.vue' 5 | import EnvInfo from '../components/EnvInfo.vue' 6 | import LatestIssues from '../components/LatestIssues.vue' 7 | 8 | export default { 9 | ...DefaultTheme, 10 | enhanceApp({ app }) { 11 | app.component('DownloadButton', DownloadButton) 12 | app.component('EnvInfo', EnvInfo) 13 | app.component('LatestIssues', LatestIssues) 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /docs/releases/241216.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v241216 3 | --- 4 | 5 | # v241216 6 | 7 | ## 主要变化 8 | 9 | - 支持下载直播回放 10 | - 支持 macOS 系统 11 | 12 | 在微信 Version 3.8.9 (28564) 测试可用 13 | 14 | ## macOS 下使用说明 15 | 16 | ```bash 17 | chmod +x ./wx_video_download_darwin_xxx 18 | sudo ./wx_video_download_darwin_xxx 19 | ``` 20 | 21 | 此时会提示文件不能打开,需要到系统设置中允许,然后重新执行 `sudo ./wx_video_download_darwin_xxx`。 22 | 23 | 在安装证书的过程中会申请权限,同意即可。后续打开无需使用 `sudo`,只需要双击运行 24 | 25 | 关闭 `macOS` 终端时请使用 `Command + c` 的方式,否则可能会出现系统代理未取消,导致网络无法访问的问题 26 | 27 | > 当出现网络无法访问时请检查系统代理并手动取消即可。 28 | -------------------------------------------------------------------------------- /internal/interceptor/inject/lib/mitt.umd.js: -------------------------------------------------------------------------------- 1 | !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).mitt=n()}(this,function(){return function(e){return{all:e=e||new Map,on:function(n,t){var f=e.get(n);f?f.push(t):e.set(n,[t])},off:function(n,t){var f=e.get(n);f&&(t?f.splice(f.indexOf(t)>>>0,1):e.set(n,[]))},emit:function(n,t){var f=e.get(n);f&&f.slice().map(function(e){e(t)}),(f=e.get("*"))&&f.slice().map(function(e){e(n,t)})}}}}); 2 | //# sourceMappingURL=mitt.umd.js.map 3 | -------------------------------------------------------------------------------- /internal/interceptor/inject/src/pagespy.js: -------------------------------------------------------------------------------- 1 | var __wx_defaultConfig = { 2 | api: "debug.weixin.qq.com", 3 | clientOrigin: "https://debug.weixin.qq.com", 4 | }; 5 | const config = __wx_defaultConfig; 6 | if (WXU.config.pagespyServerAPI) { 7 | config.api = WXU.config.pagespyServerAPI; 8 | } 9 | if (WXU.config.pagespyServerProtocol) { 10 | config.clientOrigin = 11 | WXU.config.pagespyServerProtocol + "://" + config.api; 12 | } 13 | try { 14 | window.$pageSpy = new PageSpy({ 15 | ...config, 16 | project: "WXChannel", 17 | autoRender: true, 18 | title: "WXChannel Debug", 19 | }); 20 | } catch (err) { 21 | alert(err.message); 22 | } 23 | -------------------------------------------------------------------------------- /docs/guide/start.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 下载 3 | --- 4 | 5 | # 下载 6 | 7 |
8 | 9 | 10 |
11 | 12 |
13 |
14 | 15 | 下载对应平台的构建包: 16 | 17 | 20 | 21 | ## 如何选择构建包 22 | 23 | 根据上述环境信息,选择对应的构建包 24 | 25 | 1. macOS arm64 26 | 选择 darwin_arm64 后缀 27 | 28 | 2. macOS x86_64 29 | 选择 darwin_x86_64 后缀 30 | 31 | 3. Windows x86_64 32 | 选择 windows_x86_64 后缀 33 | 34 | ## 启用下载器 35 | 36 | 在 `Windows` 平台,解压后双击直接运行 `wx_video_download` 即可,首次使用会自动安装证书并设置系统代理。 37 | 38 | `macOS` 平台请参考 [macOS 启用](./macos.md) 39 | -------------------------------------------------------------------------------- /docs/releases/251213.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251213 3 | --- 4 | 5 | # v251213 6 | 7 | 非常大的一次更新,将原先在视频号脚本内的逻辑全部通过事件分发到外部,由外部处理,从而可以自己实现一些自定义的逻辑 8 | 9 | ## 更新内容 10 | 11 | - 修复首页推荐不展示下载按钮的问题 12 | - 修复终端窗口太窄时无法正确读取到证书的问题 13 | - 将之前「更多」菜单中的功能,移动到下载按钮中。现在首页推荐也有更多菜单了 14 | - 当没有找到操作栏时会在页面注入「悬浮下载按钮」 15 | - 支持自定义菜单功能 16 | - 支持监听「获取视频详情」、「开始下载」、「下载完成」等事件 17 | - 调整了 `config.yaml` 字段结构 18 | - 对 macOS 端构建包签名、公证 19 | 20 | ## 悬浮下载按钮 21 | 22 | | 首页推荐 | 视频详情页 | 23 | | --- | --- | 24 | | ![首页推荐](../assets/fixed_btn1.jpg) | ![视频详情页](../assets/fixed_btn2.jpg) | 25 | 26 | ## 自定义功能 27 | 28 | 目前包括两部分 29 | 30 | - [自定义菜单](../feature/custom-menu.md) 31 | - [监听事件](../feature/event.md) 32 | 33 | 34 | 其中事件功能,几乎可以实现任意的功能 35 | 36 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | 7 | "wx_channel/cmd" 8 | "wx_channel/internal/interceptor" 9 | "wx_channel/pkg/certificate" 10 | "wx_channel/pkg/platform" 11 | ) 12 | 13 | var AppVer = "251213" 14 | 15 | func main() { 16 | cfg, err := interceptor.NewInterceptorSettings() 17 | if err != nil { 18 | fmt.Printf("加载配置文件失败 %v", err.Error()) 19 | return 20 | } 21 | if cfg.ProxySetSystem && platform.NeedAdminPermission() && !platform.IsAdmin() { 22 | if !platform.RequestAdminPermission() { 23 | fmt.Println("启动失败,请右键选择「以管理员身份运行」") 24 | return 25 | } 26 | return 27 | } 28 | if err := cmd.Execute(AppVer, certificate.DefaultCertFiles, cfg); err != nil { 29 | fmt.Printf("初始化失败 %v\n", err.Error()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/guide/macos.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: macOS 启用 3 | --- 4 | 5 | # macOS 启用 6 | 7 | ## 251213更新 8 | 9 | 自 251213 之后,会对 `wx_video_download` 进行签名和公证,以避免 macOS 提示「文件不能打开」。但由于无法对二进制文件「钉证」,双击打开还会触发 `Gatekeeper` 保护,需要手动确认才能运行。 10 | 11 | 但是通过命令行运行就完全不会触发任何的校验 12 | 13 | 如果有问题,仍按照下面步骤进行 14 | 15 | 16 | ## 赋予执行权限 17 | 18 | ```sh 19 | chmod +x ./wx_video_download 20 | ``` 21 | 22 | ## 以管理员身份运行 23 | 24 | ```sh 25 | sudo ./wx_video_download 26 | ``` 27 | 28 | 29 | ## 允许来自未知来源的应用 30 | 31 | 若系统提示「文件不能打开」,在系统设置中允许来自未签名开发者的应用 32 | 33 | ![step1](../assets/enable_step1_macos.png) 34 | 35 | 再次执行 `./wx_video_download`,可能出现下面窗口,选择 `Open Anyway` 36 | 37 | ![step2](../assets/enable_step2_macos.png) 38 | 39 | 40 | ## 正常使用 41 | 42 | 只有首次打开需要经历上述步骤,之后可直接双击 `wx_video_download` 运行,无需繁琐步骤 43 | -------------------------------------------------------------------------------- /pkg/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | type ProxySettings struct { 4 | Device string 5 | Hostname string 6 | Port string 7 | } 8 | 9 | type HardwarePort struct { 10 | Device string 11 | Port string 12 | Interface string 13 | } 14 | 15 | func merge_default_settings(p ProxySettings) ProxySettings { 16 | if p.Device == "" { 17 | p.Device = "Wi-Fi" // 默认使用 Wi-Fi 设备 18 | device, err := get_network_interfaces() 19 | if err == nil { 20 | p.Device = device.Port 21 | } 22 | } 23 | if p.Hostname == "" { 24 | p.Hostname = "127.0.0.1" 25 | } 26 | if p.Port == "" { 27 | p.Port = "2023" 28 | } 29 | return p 30 | 31 | } 32 | 33 | func EnableProxy(arg ProxySettings) error { 34 | return enable_proxy(arg) 35 | } 36 | 37 | func DisableProxy(arg ProxySettings) error { 38 | return disable_proxy(arg) 39 | } 40 | -------------------------------------------------------------------------------- /config/schema.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | ) 6 | 7 | type ConfigType string 8 | 9 | const ( 10 | ConfigTypeString ConfigType = "string" 11 | ConfigTypeBool ConfigType = "boolean" 12 | ConfigTypeInt ConfigType = "number" 13 | ConfigTypeFloat ConfigType = "number" 14 | ConfigTypeSelect ConfigType = "select" 15 | ) 16 | 17 | type ConfigItem struct { 18 | Key string `json:"key"` 19 | Type ConfigType `json:"type"` 20 | Default interface{} `json:"default"` 21 | Description string `json:"description"` 22 | Title string `json:"title"` 23 | Group string `json:"group"` // e.g., "Network", "Download" 24 | Options []string `json:"options,omitempty"` // For select type 25 | } 26 | 27 | var Registry []ConfigItem 28 | 29 | func Register(item ConfigItem) { 30 | Registry = append(Registry, item) 31 | viper.SetDefault(item.Key, item.Default) 32 | } 33 | 34 | func GetSchema() []ConfigItem { 35 | return Registry 36 | } 37 | -------------------------------------------------------------------------------- /docs/config/proxy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 代理配置 3 | --- 4 | 5 | # 代理配置 6 | 7 | 通过 `config.yaml` 控制是否设置系统代理与代理服务端口。 8 | 9 | ```yaml 10 | proxy: 11 | system: true 12 | hostname: 127.0.0.1 13 | port: 2023 14 | ``` 15 | 16 | - `system` 是否设置系统代理 17 | - `hostname` 代理主机名 18 | - `port` 代理端口 19 | 20 | ## 与 Clash 协同 21 | 22 | 当不希望修改系统代理时,可将 `system` 设为 `false`,并在 Clash 中加入以下 `Global Extend Script`,将流量转发到下载器代理服务(端口默认 `2023`): 23 | 24 | ```js 25 | function main(config, profileName) { 26 | config["proxy-groups"].unshift({ 27 | name: 'ChannelsDownload', 28 | type: 'fallback', 29 | proxies: ["channels_download", "DIRECT"], 30 | interval: 5 31 | }); 32 | config["proxies"].unshift({ 33 | name: "channels_download", 34 | type: 'http', 35 | server: '127.0.0.1', 36 | port: 2023, 37 | }); 38 | config.rules.unshift(...[ 39 | "PROCESS-NAME,wx_video_download.exe,DIRECT", 40 | "PROCESS-NAME,wx_video_download,DIRECT", 41 | "DOMAIN-SUFFIX,qq.com,ChannelsDownload" 42 | ]); 43 | return config; 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /pkg/platform/platform_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package platform 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | ) 11 | 12 | func is_admin() bool { 13 | return os.Geteuid() == 0 14 | } 15 | 16 | func need_admin_permission() bool { 17 | // args := os.Args[1:] 18 | // if len(args) == 0 { 19 | // return true 20 | // } 21 | // if strings.Contains(args[0], "--") { 22 | // return true 23 | // } 24 | return false 25 | } 26 | 27 | func request_admin_permission() bool { 28 | exe, err := os.Executable() 29 | if err != nil { 30 | return false 31 | } 32 | 33 | params := strings.Join(os.Args[1:], " ") 34 | 35 | // Escape backslashes and double quotes for the AppleScript string 36 | cmdStr := fmt.Sprintf("%s %s", exe, params) 37 | cmdStr = strings.ReplaceAll(cmdStr, "\\", "\\\\") 38 | cmdStr = strings.ReplaceAll(cmdStr, "\"", "\\\"") 39 | 40 | script := fmt.Sprintf("do shell script \"%s\" with administrator privileges", cmdStr) 41 | 42 | cmd := exec.Command("osascript", "-e", script) 43 | err = cmd.Run() 44 | return err == nil 45 | } 46 | -------------------------------------------------------------------------------- /docs/releases/251122.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: v251122 3 | --- 4 | 5 | # v251122 6 | 7 | ## 主要变化 8 | 9 | - 支持通过 `config.yaml` 配置是否默认下载原始视频: 10 | 11 | ```yaml 12 | download: 13 | defaultHighest: false 14 | ``` 15 | 16 | - 支持指定是否设置系统代理,并配置端口: 17 | 18 | ```yaml 19 | proxy: 20 | system: true 21 | port: 2023 22 | ``` 23 | 24 | - 支持自动检测是否需要管理员权限并按需申请。 25 | 26 | ## Clash 转发示例 27 | 28 | 当关闭系统代理(`proxy.system=false`)时,可在 Clash 中添加如下脚本,将流量转发到下载器代理服务: 29 | 30 | ```js 31 | function main(config, profileName) { 32 | config["proxy-groups"].unshift({ 33 | name: 'ChannelsDownload', 34 | type: 'fallback', 35 | proxies: ["channels_download", "DIRECT"], 36 | interval: 5 37 | }); 38 | config["proxies"].unshift({ 39 | name: "channels_download", 40 | type: 'http', 41 | server: '127.0.0.1', 42 | port: 2023, 43 | }); 44 | config.rules.unshift(...[ 45 | "PROCESS-NAME,wx_video_download.exe,DIRECT", 46 | "PROCESS-NAME,wx_video_download,DIRECT", 47 | "DOMAIN-SUFFIX,qq.com,ChannelsDownload" 48 | ]); 49 | return config; 50 | } 51 | ``` 52 | 53 | ## 获取 54 | 55 | - 发布页: 56 | 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module wx_channel 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/fatih/color v1.17.0 7 | github.com/ltaoo/echo v0.5.2 8 | github.com/rs/zerolog v1.34.0 9 | github.com/spf13/cobra v1.9.1 10 | github.com/spf13/viper v1.21.0 11 | ) 12 | 13 | require ( 14 | github.com/andybalholm/brotli v1.2.0 // indirect 15 | github.com/fsnotify/fsnotify v1.9.0 // indirect 16 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 17 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 18 | github.com/klauspost/compress v1.17.11 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 22 | github.com/sagikazarmark/locafero v0.11.0 // indirect 23 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 24 | github.com/spf13/afero v1.15.0 // indirect 25 | github.com/spf13/cast v1.10.0 // indirect 26 | github.com/spf13/pflag v1.0.10 // indirect 27 | github.com/subosito/gotenv v1.6.0 // indirect 28 | go.yaml.in/yaml/v3 v3.0.4 // indirect 29 | golang.org/x/sys v0.31.0 // indirect 30 | golang.org/x/text v0.28.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /docs/config/download.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 下载配置 3 | --- 4 | 5 | # 下载配置 6 | 7 | 通过 `config.yaml` 控制下载行为。 8 | 9 | ## 默认下载原始视频 10 | 11 | ```yaml 12 | download: 13 | defaultHighest: false 14 | ``` 15 | 16 | `false` 表示「否」 17 | 18 | 19 | ## 下载时的文件名称 20 | 21 | ```yaml 22 | download: 23 | filenameTemplate: "{{filename}}_{{spec}}" 24 | ``` 25 | 26 | `filenameTemplate` 通过模板语法指定下载时的文件名称,默认「文件名+视频质量」 27 | 28 | 目前支持如下变量 29 | 30 | ```js 31 | type params = { 32 | /** 默认文件名,优先取 title,没有则取视频 id,仍没有则使用 当前时间秒数 */ 33 | filename: string; 34 | /** 视频 id */ 35 | id: string; 36 | /** 视频标题 */ 37 | title: string; 38 | /** 视频质量 original | 'xWT111' */ 39 | spec: string; 40 | /** 视频发布时间(单位秒) */ 41 | created_at: number; 42 | /** 视频下载时间(单位秒) */ 43 | download_at: number; 44 | /** up主名称 */ 45 | author: string; 46 | }; 47 | ``` 48 | 49 | ## 本地下载中转服务 50 | 51 | ```yaml 52 | download: 53 | localServer: 54 | enabled: false 55 | addr: "127.0.0.1:8080" 56 | ``` 57 | 58 | 开启 `localServer` 后,将通过本地服务进行下载,从而实现 59 | 60 | 1、在页面下载长视频不阻塞操作 61 |
62 | 2、将视频转换成 `mp3` 并下载 63 | 64 | ## 是否在下载视频时暂停视频播放 65 | 66 | ```yaml 67 | download: 68 | pauseVideoWhenDownload: false 69 | ``` 70 | 71 | -------------------------------------------------------------------------------- /internal/interceptor/server.go: -------------------------------------------------------------------------------- 1 | package interceptor 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "wx_channel/internal/manager" 7 | "wx_channel/pkg/certificate" 8 | ) 9 | 10 | type InterceptorServer struct { 11 | *manager.HTTPServer 12 | interceptor *Interceptor 13 | } 14 | 15 | func NewInterceptorServer(settings *InterceptorSettings, cert *certificate.CertFileAndKeyFile) *InterceptorServer { 16 | interceptor := NewInterceptor(settings, cert) 17 | addr := settings.ProxyServerHostname + ":" + strconv.Itoa(settings.ProxyServerPort) 18 | srv := manager.NewHTTPServer("代理服务", "interceptor", addr) 19 | srv.SetHandler(interceptor) 20 | 21 | return &InterceptorServer{ 22 | HTTPServer: srv, 23 | interceptor: interceptor, 24 | } 25 | } 26 | 27 | func (s *InterceptorServer) Start() error { 28 | if err := s.interceptor.Start(); err != nil { 29 | return fmt.Errorf("failed to start interceptor: %v", err) 30 | } 31 | return s.HTTPServer.Start() 32 | } 33 | 34 | func (s *InterceptorServer) Stop() error { 35 | // 先关闭代理设置,防止新流量进入 36 | if err := s.interceptor.Stop(); err != nil { 37 | return fmt.Errorf("failed to stop interceptor: %v", err) 38 | } 39 | return s.HTTPServer.Stop() 40 | } 41 | -------------------------------------------------------------------------------- /cmd/uninstall.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | "github.com/spf13/cobra" 8 | 9 | "wx_channel/pkg/certificate" 10 | "wx_channel/pkg/proxy" 11 | ) 12 | 13 | var uninstall_certificate_cmd = &cobra.Command{ 14 | Use: "uninstall", 15 | Short: "删除证书", 16 | Long: "删除初始化时自动安装的证书", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | command := cmd.Name() 19 | if command != "uninstall" { 20 | return 21 | } 22 | uninstall_certificate_command(&UninstallCertificateCommandArgs{ 23 | CertFiles: CertFiles, 24 | }) 25 | }, 26 | } 27 | 28 | func init() { 29 | root_cmd.AddCommand(uninstall_certificate_cmd) 30 | } 31 | 32 | type UninstallCertificateCommandArgs struct { 33 | CertFiles *certificate.CertFileAndKeyFile 34 | } 35 | 36 | func uninstall_certificate_command(args *UninstallCertificateCommandArgs) { 37 | settings := proxy.ProxySettings{} 38 | if err := proxy.DisableProxy(settings); err != nil { 39 | fmt.Printf("\nERROR 取消代理失败 %v\n", err.Error()) 40 | return 41 | } 42 | if err := certificate.UninstallCertificate(args.CertFiles.Name); err != nil { 43 | fmt.Printf("\nERROR 删除根证书失败 %v\n", err.Error()) 44 | return 45 | } 46 | color.Green(fmt.Sprintf("\n\n删除根证书 '%v' 成功\n", args.CertFiles.Name)) 47 | } 48 | -------------------------------------------------------------------------------- /docs/releases.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Releases 3 | --- 4 | 5 | # Releases 6 | 7 | 最新发布页: 8 | 9 | ## v251122 10 | 11 | - 支持通过 `config.yaml` 配置默认是否下载原始视频 12 | - 新增代理配置项,可指定是否设置系统代理 13 | - 支持自动检测并按需申请管理员权限 14 | 15 | 示例配置: 16 | 17 | ```yaml 18 | download: 19 | defaultHighest: false 20 | 21 | proxy: 22 | system: true 23 | port: 2023 24 | ``` 25 | 26 | 如需默认下载原始视频,将压缩包内 `config.yaml` 的 `defaultHighest` 改为 `true` 后重启下载器。 27 | 28 | 如需配合 Clash 使用,可将 `system` 改为 `false`,并在 Clash 配置中添加 `Global Extend Script`,转发到下载器的代理服务。 29 | 30 | ## v251027 31 | 32 | - 内置 `echo` 以替代 SunnyNet,二进制体积缩减到约 8M 33 | - `download` 命令默认启用多线程 34 | - 在直播详情页增加下载按钮,并在终端打印下载命令(`ffmpeg`) 35 | - Windows 平台通过设置系统代理注入下载按钮,科学上网可能失效 36 | 37 | > 提示:使用终端下载方式需要安装 `ffmpeg`。 38 | 39 | ## v250913 40 | 41 | - 视频号首页视频操作栏增加「下载」按钮 42 | - 增加 `uninstall` 命令,可卸载根证书 43 | - 增加 `decrypt` 命令,支持使用指定 key 对已下载视频解密 44 | 45 | 典型命令: 46 | 47 | ```sh 48 | wx_video_download_xx uninstall 49 | wx_video_download_xx decrypt --filepath <绝对路径> --key <解密key> 50 | ``` 51 | 52 | ## v250621 53 | 54 | - 新增 `download` 命令,将视频下载到 `Downloads` 并解密 55 | - 视频号「更多」菜单新增「打印下载命令」按钮 56 | - Windows 平台双击以管理员身份运行,二进制文件增加图标 57 | 58 | 示例: 59 | 60 | ```sh 61 | ./wx_video_download_xx download --url "视频地址" --key 解密key --filename "文件名" 62 | ``` 63 | -------------------------------------------------------------------------------- /pkg/platform/platform_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package platform 4 | 5 | import ( 6 | "os" 7 | "runtime" 8 | "strings" 9 | "syscall" 10 | "unsafe" 11 | ) 12 | 13 | var ( 14 | modshell32 = syscall.NewLazyDLL("shell32.dll") 15 | shell_execute = modshell32.NewProc("ShellExecuteW") 16 | ) 17 | 18 | func is_admin() bool { 19 | if runtime.GOOS != "windows" { 20 | return true 21 | } 22 | _, err := os.Open("\\\\.\\PHYSICALDRIVE0") 23 | return err == nil 24 | } 25 | func need_admin_permission() bool { 26 | args := os.Args[1:] 27 | os_env := runtime.GOOS 28 | if os_env == "windows" { 29 | if len(args) == 0 { 30 | return true 31 | } 32 | if strings.Contains(args[0], "--") { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | func request_admin_permission() bool { 39 | exe, _ := os.Executable() 40 | verb, _ := syscall.UTF16PtrFromString("runas") 41 | file, _ := syscall.UTF16PtrFromString(exe) 42 | 43 | params := "" 44 | for _, arg := range os.Args[1:] { 45 | params += arg + " " 46 | } 47 | paramPtr, _ := syscall.UTF16PtrFromString(params) 48 | 49 | ret, _, _ := shell_execute.Call( 50 | 0, 51 | uintptr(unsafe.Pointer(verb)), 52 | uintptr(unsafe.Pointer(file)), 53 | uintptr(unsafe.Pointer(paramPtr)), 54 | 0, 55 | 1, 56 | ) 57 | 58 | return ret > 32 59 | } 60 | -------------------------------------------------------------------------------- /docs/.vitepress/components/LatestIssues.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 35 | 36 | 42 | -------------------------------------------------------------------------------- /pkg/argv/argv.go: -------------------------------------------------------------------------------- 1 | package argv 2 | 3 | // https://github.com/levicook/argmapper/blob/master/argmapper.go 4 | type Map map[string]string 5 | 6 | func ArgsToMap(args []string) (m Map) { 7 | m = make(Map) 8 | if len(args) == 0 { 9 | return 10 | } 11 | 12 | nextopt: 13 | for i, s := range args { 14 | // does s look like an option? 15 | if len(s) > 1 && s[0] == '-' { 16 | k := "" 17 | v := "" 18 | 19 | num_minuses := 1 20 | if s[1] == '-' { 21 | num_minuses++ 22 | } 23 | 24 | k = s[num_minuses:] 25 | if len(k) == 0 || k[0] == '-' || k[0] == '=' { 26 | continue nextopt 27 | } 28 | 29 | for i := 1; i < len(k); i++ { // equals cannot be first 30 | if k[i] == '=' { 31 | v = k[i+1:] 32 | k = k[0:i] 33 | break 34 | } 35 | } 36 | 37 | // It must have a value, which might be the next arg, assuming the next arg isn't an option too. 38 | remaining := args[i+1:] 39 | if v == "" && len(remaining) > 0 && remaining[0][0] != '-' { 40 | v = remaining[0] 41 | } // value is the next arg 42 | m[k] = v 43 | } 44 | } 45 | return m 46 | } 47 | 48 | // 获取指定参数名的值,获取失败返回默认值(多个参数名则返回最先找到的值) 49 | func ArgsValue(margs Map, def string, keys ...string) (value string) { 50 | value = def // 默认值 51 | for _, key := range keys { 52 | if v, ok := margs[key]; ok && v != "" { // 找到参数 53 | value = v // 存储该值 54 | break 55 | } 56 | } 57 | return value 58 | } 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能建议 2 | description: 提交新功能或改进的建议 3 | title: "[Feature] 简洁描述你的需求" 4 | labels: 5 | - enhancement 6 | body: 7 | - type: checkboxes 8 | id: confirm_search 9 | attributes: 10 | label: 提交前确认 11 | description: 勾选后方可提交 12 | options: 13 | - label: 我已搜索过现有 Issues/讨论,未发现类似建议 14 | required: true 15 | - type: textarea 16 | id: summary 17 | attributes: 18 | label: 功能描述 19 | description: 详细说明你希望实现的功能与用途 20 | placeholder: 例如:支持从某页面下载指定内容 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: motivation 25 | attributes: 26 | label: 背景与动机 27 | description: 为什么需要此功能,解决了什么问题 28 | placeholder: 例如:当前流程的痛点与改进价值 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: solution 33 | attributes: 34 | label: 可能的实现方案 35 | description: 可选,提出你对实现的思路或伪代码 36 | placeholder: 例如:新增命令参数、扩展解析逻辑 37 | validations: 38 | required: false 39 | - type: textarea 40 | id: alternatives 41 | attributes: 42 | label: 备选方案与权衡 43 | description: 是否考虑过其他做法及其优缺点 44 | placeholder: 例如:使用外部工具或不同接口方案 45 | validations: 46 | required: false 47 | - type: textarea 48 | id: additional_context 49 | attributes: 50 | label: 其他上下文 51 | description: 相关链接、参考资料或截图 52 | placeholder: 例如:参考项目链接、API 文档、示例截图 53 | validations: 54 | required: false 55 | -------------------------------------------------------------------------------- /pkg/certificate/certs/SunnyRoot.cer: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDwjCCAqqgAwIBAgIRAQAAAAAAAAAAAAAAAAAAAAAwDQYJKoZIhvcNAQELBQAw 3 | ajELMAkGA1UEBhMCQ04xEDAOBgNVBAgTB0JlaUppbmcxEDAOBgNVBAcTB0JlaUpp 4 | bmcxETAPBgNVBAoTCFN1bm55TmV0MREwDwYDVQQLEwhTdW5ueU5ldDERMA8GA1UE 5 | AxMIU3VubnlOZXQwIBcNMjIxMTA0MDcwNTM0WhgPMjEyMjEwMTEwNzA1MzRaMGox 6 | CzAJBgNVBAYTAkNOMRAwDgYDVQQIEwdCZWlKaW5nMRAwDgYDVQQHEwdCZWlKaW5n 7 | MREwDwYDVQQKEwhTdW5ueU5ldDERMA8GA1UECxMIU3VubnlOZXQxETAPBgNVBAMT 8 | CFN1bm55TmV0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzU+hPfoE 9 | y+4+VZVUhfb0wKF7YSr79GyxNCo8/l8i1gI3pbaxv4PF/W5xWdE3LHND6b8FVmot 10 | pXqJcalx2FP48JdAKsmlzEZcl3MngHsKH7OPSvz8p76xvlHaFutVQjQFr8R3dX3B 11 | m8yNy6sNcP+3IrxOEUYsMWc5/lVHTyTYkruMAvCZIYzcc5Y2YXzExENbfPxwzNQh 12 | H/XsZlc4FGaZq6DV/0oMOXSSFOXcuJo2ULW/bOQho2jZ2zG1mf+Te3i8Psoanrrf 13 | sMXiOjB6ZH4tKv+O9NjJJi5o64Ulh35lt4qTHwGQD6pMs3yJn/l+N7kv85amLJzi 14 | fBSbJ1eYhjUpPQIDAQABo2EwXzAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYI 15 | KwYBBQUHAwIGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKC5 16 | TwkvGBAx7xu1CyvX5chP7zOdMA0GCSqGSIb3DQEBCwUAA4IBAQDDAl162QjUsv7H 17 | 1+pn7MT/RDcqXNqBAUEc9FF6ozkRnLxdWBMLWxI8KHKm8JoBQB+TLiokSkenfMtA 18 | 7eRX7xzCBghuLi2XjMDUlaoVVKp+HNNoPSyn+UE/lUlKoCJCFgyt5p+bp9MP+YDm 19 | pOnNjZTktyvwRj+Bgm1USzVY3IXlV+/H9la3vRi/G5n+yl3ZQMjwh6erbqwUzd6X 20 | 8j/L3BdoOkrOHzpodiAmp7Mf105Nh77EoUsh13TJy1CJLrIJzMDO1ryhzuVyxbJA 21 | evcsWTxTr9qR/P09XImwOFmFKNimKC8IGwP/xVxqdH9WapsX6VZV5NbRG8vnqaM5 22 | V6TbUzep 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /pkg/certificate/certificate.go: -------------------------------------------------------------------------------- 1 | package certificate 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed certs/SunnyRoot.cer 8 | var cert_file []byte 9 | 10 | //go:embed certs/private.key 11 | var private_key_file []byte 12 | 13 | type CertFileAndKeyFile struct { 14 | Name string 15 | Cert []byte 16 | PrivateKey []byte 17 | } 18 | 19 | var DefaultCertFiles = &CertFileAndKeyFile{ 20 | Name: "SunnyNet", 21 | Cert: cert_file, 22 | PrivateKey: private_key_file, 23 | } 24 | 25 | type CertificateSubject struct { 26 | // label 27 | CN string 28 | // cenc 29 | OU string 30 | // hpky 31 | O string 32 | // hpky 33 | L string 34 | // subj 35 | S string 36 | // cenc 37 | C string 38 | } 39 | type Certificate struct { 40 | Thumbprint string 41 | Subject CertificateSubject 42 | } 43 | 44 | // 获取所有证书 45 | func FetchCertificates() ([]Certificate, error) { 46 | return fetchCertificates() 47 | } 48 | 49 | // 根据名称检查是否存在指定证书 50 | func CheckHasCertificate(cert_name string) (bool, error) { 51 | certificates, err := fetchCertificates() 52 | if err != nil { 53 | return false, err 54 | } 55 | for _, cert := range certificates { 56 | if cert.Subject.CN == cert_name { 57 | return true, nil 58 | } 59 | } 60 | return false, nil 61 | } 62 | 63 | // 安装指定证书 64 | func InstallCertificate(cert_data []byte) error { 65 | return installCertificate(cert_data) 66 | } 67 | 68 | // 卸载指定证书 69 | func UninstallCertificate(name string) error { 70 | return uninstallCertificate(name) 71 | } 72 | -------------------------------------------------------------------------------- /docs/feature/long_video.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 长视频下载 3 | --- 4 | 5 | # 长视频下载 6 | 7 | 由于网页限制(下载超过一定时间会中断),超过30分钟的视频建议使用命令行 `download` 进行下载。 8 | 9 | ## 本地中转下载 10 | 11 | 参考 [本地下载中转服务](../config/download.md#本地下载中转服务) 12 | 13 | ## 命令行下载 14 | 15 | 可以多线程下载,速度更快 16 | 17 | 在「视频详情页」的「更多」菜单,如下所示,点击「打印下载命令」 18 | 19 | ![长视频下载建议](../assets/faq1.png) 20 | 21 | 在终端会打印下载命令 22 | 23 | ![长视频下载建议](../assets/faq2.png) 24 | 25 | 其中 `[FRONTEND]download --url "` 即下载命令,从 `download` 开始复制,直到结尾 26 | 27 | ```bash 28 | download --url "https://finder.video.qq.com/251/20302/stodownload?encfilekey=Cvvj5Ix3eez3Y79SxtvVL0L7CkPM6dFibFeI6caGYwFEC4864roibGczGjuApTjyib3umVSgzI8sLibE4EUwkVwbDKEymHBRUG34yjM6SGcv44EtQoD1EUKQ89KzkUojUNb7Mick3Rb0GC5mMxko1oaX1Sg&hy=SH&idx=1&m=3bd9398bcd242a67bf1efe7969fd9512&uzid=7a1b6&token=AxricY7RBHdVsH2yJZpBVrjHLkJZLfcm8ueibLvFWicoHL5UuJvfRVmj1iccsf8QqpaDsZdbNlPkdCcutAQHDVyFUcqEdSkyxBOfZ6MCZJkicWGyLia2tKbB3tccbUnmbAAdBTzicgdQ1dCz7yO7fYWUBIbnMKh5DblO5Z7PeMiarYRsZP8&basedata=CAESABoDeFYwIgAqBwiLKRAAGAI&sign=UfS5lzr9DtNQPWgtdPllIp8py4n7mS1iGm-x05-PRo9277wxdW-rKfXynPal2RYNHAT3UX0wIKZmpbwPTOJEJQ&ctsc=146&web=1&extg=10f0000&svrbypass=AAuL%2FQsFAAABAAAAAAD9m17FKaAt3BlsfrYqaRAAAADnaHZTnGbFfAj9RgZXfw6VOZcy4UmLLbWjFXTyPelvE6lGZXAEivENDjacRqyPSpY9djoTRJDFxNM%3D&svrnonce=1764406910&X-snsvideoflag=xWT111" --key 1233185028 --filename "#cosplay #走路摇 #永劫无间-xWT111.mp4" 29 | ``` 30 | 31 | 打开新的终端,将「下载器」拖动到终端,空格,粘贴,然后终端会类似这样 32 | 33 | ```bash 34 | wx_video_download download --url "https://xxxx 35 | ``` 36 | 37 | 回车即可开始下载 38 | 39 | 下载成功后,在终端会显示下载好的视频地址 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 故障反馈 2 | description: 遇到问题时使用此模板提交 Issue 3 | title: "[Bug] 简洁描述你的问题" 4 | labels: 5 | - bug 6 | body: 7 | - type: checkboxes 8 | id: confirm_search 9 | attributes: 10 | label: 提交前确认 11 | description: 勾选后方可提交 12 | options: 13 | - label: 我已搜索过现有 Issues/讨论,未发现类似问题 14 | required: true 15 | - type: textarea 16 | id: terminal 17 | attributes: 18 | label: 终端截图 / 输出 19 | description: 请粘贴相关命令与终端输出,并拖拽截图到此区域 20 | placeholder: | 21 | 例如:运行命令及输出 22 | wx_channels_download --version 23 | wx_channels_download download 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: system 28 | attributes: 29 | label: 系统代理截图 30 | description: 请粘贴系统代理设置截图 31 | placeholder: | 32 | 前往系统代理窗口查看并截图 33 | validations: 34 | required: true 35 | - type: textarea 36 | id: page_screenshot 37 | attributes: 38 | label: 视频号版本及页面截图 39 | description: 请对视频号页面进行截图,并拖拽到此区域 40 | placeholder: | 41 | 尝试通过 wx_video_download --debug 命令启动,并截图 42 | validations: 43 | required: true 44 | - type: input 45 | id: page_url 46 | attributes: 47 | label: 视频号页面地址 48 | description: 若能正常打开视频详情页,「更多」按钮中有复制功能 49 | placeholder: https://channels.weixin.qq.com/... 50 | validations: 51 | required: false 52 | - type: dropdown 53 | id: os 54 | attributes: 55 | label: 操作系统 56 | options: 57 | - macOS 58 | - Windows 59 | validations: 60 | required: false -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs (VitePress) 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: write 8 | 9 | concurrency: 10 | group: pages 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Detect docs-related changes 21 | id: changes 22 | uses: dorny/paths-filter@v3 23 | with: 24 | filters: | 25 | docs: 26 | - 'docs/**' 27 | 28 | - name: Skip if no docs changes 29 | if: steps.changes.outputs.docs != 'true' 30 | run: echo "No docs changes, skip deploy." 31 | 32 | - name: Setup Node 33 | if: steps.changes.outputs.docs == 'true' 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 20 37 | cache: npm 38 | cache-dependency-path: docs/package.json 39 | 40 | - name: Install dependencies 41 | if: steps.changes.outputs.docs == 'true' 42 | working-directory: docs 43 | run: npm ci 44 | 45 | - name: Build VitePress site 46 | if: steps.changes.outputs.docs == 'true' 47 | working-directory: docs 48 | run: npm run build 49 | 50 | - name: Deploy to docs branch 51 | if: steps.changes.outputs.docs == 'true' 52 | uses: peaceiris/actions-gh-pages@v4 53 | with: 54 | github_token: ${{ secrets.GITHUB_TOKEN }} 55 | publish_dir: docs/.vitepress/dist 56 | publish_branch: docs 57 | -------------------------------------------------------------------------------- /cmd/decrypt.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "wx_channel/pkg/decrypt" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var ( 12 | file_path string 13 | video_decrypt_key2 int 14 | ) 15 | var decrypt_cmd = &cobra.Command{ 16 | Use: "decrypt", 17 | Short: "解密视频", 18 | Long: "使用 key 对本地加密视频进行解密", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | command := cmd.Name() 21 | if command != "decrypt" { 22 | return 23 | } 24 | decrypt_command(DecryptCOmmandArgs{ 25 | Filepath: file_path, 26 | DecryptKey: video_decrypt_key2, 27 | }) 28 | }, 29 | } 30 | 31 | func init() { 32 | decrypt_cmd.Flags().StringVar(&file_path, "filepath", "", "视频文件地址(必需)") 33 | decrypt_cmd.Flags().IntVar(&video_decrypt_key2, "key", 0, "解密密钥(必需)") 34 | decrypt_cmd.MarkFlagRequired("filepath") 35 | 36 | root_cmd.AddCommand(decrypt_cmd) 37 | } 38 | 39 | type DecryptCOmmandArgs struct { 40 | Filepath string 41 | DecryptKey int 42 | } 43 | 44 | func decrypt_command(args DecryptCOmmandArgs) { 45 | if args.Filepath == "" { 46 | fmt.Printf("[ERROR]文件路径不能为空\n") 47 | return 48 | } 49 | if args.DecryptKey == 0 { 50 | fmt.Printf("[ERROR]解密密钥不能为空\n") 51 | return 52 | } 53 | fmt.Printf("开始对文件解密 %s\n", args.Filepath) 54 | length := uint32(131072) 55 | key := uint64(args.DecryptKey) 56 | data, err := os.ReadFile(args.Filepath) 57 | if err != nil { 58 | fmt.Printf("[ERROR]读取已下载的文件失败 %v\n", err.Error()) 59 | return 60 | } 61 | decrypt.DecryptData(data, length, key) 62 | err = os.WriteFile(args.Filepath, data, 0644) 63 | if err != nil { 64 | fmt.Printf("[ERROR]写入文件失败 %v\n", err.Error()) 65 | return 66 | } 67 | fmt.Printf("解密完成 %s\n", args.Filepath) 68 | } 69 | -------------------------------------------------------------------------------- /docs/.vitepress/components/EnvInfo.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 40 | 41 | 44 | -------------------------------------------------------------------------------- /internal/interceptor/assets.go: -------------------------------------------------------------------------------- 1 | package interceptor 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed inject/lib/FileSaver.min.js 8 | var js_file_saver []byte 9 | 10 | //go:embed inject/lib/jszip.min.js 11 | var js_zip []byte 12 | 13 | //go:embed inject/lib/floating-ui.core.1.7.4.min.js 14 | var js_floating_ui_core []byte 15 | 16 | //go:embed inject/lib/floating-ui.dom.1.7.4.min.js 17 | var js_floating_ui_dom []byte 18 | 19 | //go:embed inject/lib/mitt.umd.js 20 | var js_mitt []byte 21 | 22 | //go:embed inject/lib/weui.umd.js 23 | var js_weui []byte 24 | 25 | //go:embed inject/lib/recorder.min.js 26 | var js_recorder []byte 27 | 28 | //go:embed inject/lib/pagespy.min.js 29 | var js_pagespy []byte 30 | 31 | //go:embed inject/src/pagespy.js 32 | var js_debug []byte 33 | 34 | //go:embed inject/src/error.js 35 | var js_error []byte 36 | 37 | //go:embed inject/src/eventbus.js 38 | var js_eventbus []byte 39 | 40 | //go:embed inject/src/components.js 41 | var js_components []byte 42 | 43 | //go:embed inject/src/utils.js 44 | var js_utils []byte 45 | 46 | //go:embed inject/src/main.js 47 | var js_main []byte 48 | 49 | //go:embed inject/src/live.js 50 | var js_live_main []byte 51 | 52 | var Assets = &ChannelInjectedFiles{ 53 | JSFileSaver: js_file_saver, 54 | JSZip: js_zip, 55 | JSRecorder: js_recorder, 56 | JSPageSpy: js_pagespy, 57 | JSFloatingUICore: js_floating_ui_core, 58 | JSFloatingUIDOM: js_floating_ui_dom, 59 | JSWeui: js_weui, 60 | JSMitt: js_mitt, 61 | JSDebug: js_debug, 62 | JSError: js_error, 63 | JSEventBus: js_eventbus, 64 | JSComponents: js_components, 65 | JSUtils: js_utils, 66 | JSMain: js_main, 67 | JSLiveMain: js_live_main, 68 | } 69 | -------------------------------------------------------------------------------- /pkg/certificate/certs/private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAzU+hPfoEy+4+VZVUhfb0wKF7YSr79GyxNCo8/l8i1gI3pbax 3 | v4PF/W5xWdE3LHND6b8FVmotpXqJcalx2FP48JdAKsmlzEZcl3MngHsKH7OPSvz8 4 | p76xvlHaFutVQjQFr8R3dX3Bm8yNy6sNcP+3IrxOEUYsMWc5/lVHTyTYkruMAvCZ 5 | IYzcc5Y2YXzExENbfPxwzNQhH/XsZlc4FGaZq6DV/0oMOXSSFOXcuJo2ULW/bOQh 6 | o2jZ2zG1mf+Te3i8PsoanrrfsMXiOjB6ZH4tKv+O9NjJJi5o64Ulh35lt4qTHwGQ 7 | D6pMs3yJn/l+N7kv85amLJzifBSbJ1eYhjUpPQIDAQABAoIBAQC8e18Wq6GdqhE1 8 | torLFYVqFpVTBggaQ3KG5kPqbmJnv89gZZFWtV2dJLgQ8b3KI+N0Anae94kCQrVN 9 | UHaAV87Q6Lnyzf5Uwz+blg7sp4gKxGhHOmukf69jfndN1SwHRAT4cNAOX63PHwIJ 10 | uPX1B/0TeXXd6+MEU7Ts5VM6uCPOx5N4OlpL+A/QoPV+Uspm5C3YcZOXjTTPAtUY 11 | JgH2nCMbCRsVIBteJQXANlSsaJP7ZgRswYKVcolNeM/zsjoNjfQUiuJbhaM6rKIa 12 | xxV4j0TxorZpp3ablUF6HCeWoG3wRalNxVFSLd7YTXwuRcKyp3NE9KpGc4dxDEqm 13 | 4F7TmIVNAoGBAOUxLZdfMFsUmCklUikifezYkyS1eF9wjvcFJaH07KaDRPgB6VRC 14 | hcWM+Mn6flZKtUNG5DaykxZXsh+OO8HSK85DkMterpz83cAaTvG0QxvChUNHkWqB 15 | 5dwXSDSVqgP2tDm7CqyjC9vZY3sdCeE+dzvBlL6bGTsEFPggD7emJ2z3AoGBAOVT 16 | XmZ1yq4a1lozr2GMehYdcj7KKD18mXOlVicyl++PvJG6Hmci3RlG2sKk3PSLlRCZ 17 | 1CDhkmrNRlVwzov8uSxZOHAO+bOSqc64oRMMcyAB3uVDNicqUL2cVWiVGvcOmTc0 18 | SgRqSU/HASMxDtT9D5nX+y0t3SL1SYRe5iBIaNJrAoGBAN5UqYx5G7iPLthjSuN6 19 | gTu8EGmA3MeAsj8wsAP/S35wQvxvJkDF020DRujwZZQiHtqnr4TcEFGROsrfuFpa 20 | HoKWCqUuMSc7KYZMPx67pooMVighChCO+ENcFoBkWyxDKywBpOY5uKxJovZwAgCO 21 | Dy5ZqIiKfpxAZnMY7wZRWVebAoGAQEbCwdMoMO6CwBuWf7ABFCvCtsiwyLMgy6I+ 22 | 6JOstE/EWdAh72R9NjV+4WmWKNDqwhFrvJ+dC2Rn31DUA7adLEoBoJ8B7Awinjdv 23 | pkgqCIGduQLCre2VXd/wrHSGb1LfLPLyABTOYZb0walhb99SPRulYj9lqQO5TGnQ 24 | 9KF3B+sCgYACexhvn8ZzN637bfjaE7qge/uJcPE3CDFXxk4iYcYj6Vyv/aEBL1fJ 25 | DPxp+LrRqH4+9/GI5pKHZf3/MU1A2ea93QIo7SCRCL7+nCHZ6er4I+XFmpugd6wq 26 | +6JLOUgK6K0YSrzNgheVM3HWxMqT9qgrOV0CX530ia1uWSQzamhCIg== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /internal/interceptor/inject/src/live.js: -------------------------------------------------------------------------------- 1 | window.__wx_channels_live_store__ = {}; 2 | function __wx_copy_live_download_command() { 3 | var profile = __wx_channels_live_store__.profile; 4 | if (!profile) { 5 | WXU.error({ msg: "检测不到视频,请将本工具更新到最新版" }); 6 | return; 7 | } 8 | var filename = (() => { 9 | return new Date().valueOf(); 10 | })(); 11 | var _profile = { 12 | ...profile, 13 | }; 14 | var command = `ffmpeg -i "${_profile.url}" -c copy -y "live_${filename}.flv"`; 15 | WXU.log({ prefix: "", msg: "" }); 16 | WXU.log({ prefix: "", msg: "直播下载命令" }); 17 | WXU.log({ prefix: "", msg: command }); 18 | WXU.toast("请在终端查看下载命令"); 19 | } 20 | 21 | async function __wx_insert_live_download_btn() { 22 | var $elm1 = await WXU.find_elm(function () { 23 | return document.querySelector(".host__info .extra"); 24 | }); 25 | if ($elm1) { 26 | var relative_node = $elm1.children[0]; 27 | if (!relative_node) { 28 | return; 29 | } 30 | var __wx_channels_live_download_btn__ = download_btn4(); 31 | __wx_channels_live_download_btn__.onclick = function () { 32 | __wx_copy_live_download_command(); 33 | }; 34 | $elm1.insertBefore(__wx_channels_live_download_btn__, relative_node); 35 | return; 36 | } 37 | } 38 | 39 | (() => { 40 | var error_tip_timer = setTimeout(() => { 41 | WXU.error({ msg: "没有捕获到视频详情", alert: 0 }); 42 | }, 5000); 43 | var live_page_mounted = false; 44 | WXU.onFetchLiveProfile((feed) => { 45 | console.log("[live.js]onFetchLiveProfile", feed); 46 | if (live_page_mounted) { 47 | return; 48 | } 49 | live_page_mounted = true; 50 | clearTimeout(error_tip_timer); 51 | error_tip_timer = null; 52 | WXU.set_live_feed(feed); 53 | __wx_insert_live_download_btn(); 54 | }); 55 | })(); 56 | -------------------------------------------------------------------------------- /winres/winres.json: -------------------------------------------------------------------------------- 1 | { 2 | "RT_GROUP_ICON": { 3 | "APP": { 4 | "0000": [ 5 | "icon.png" 6 | ] 7 | } 8 | }, 9 | "RT_MANIFEST": { 10 | "#1": { 11 | "0409": { 12 | "identity": { 13 | "name": "wx_video_download", 14 | "version": "1.0.0" 15 | }, 16 | "description": "视频号视频下载工具", 17 | "minimum-os": "win7", 18 | "execution-level": "as invoker", 19 | "ui-access": false, 20 | "auto-elevate": true, 21 | "dpi-awareness": "system", 22 | "disable-theming": false, 23 | "disable-window-filtering": false, 24 | "high-resolution-scrolling-aware": false, 25 | "ultra-high-resolution-scrolling-aware": false, 26 | "long-path-aware": false, 27 | "printer-driver-isolation": false, 28 | "gdi-scaling": false, 29 | "segment-heap": false, 30 | "use-common-controls-v6": false 31 | } 32 | } 33 | }, 34 | "RT_VERSION": { 35 | "#1": { 36 | "0000": { 37 | "fixed": { 38 | "file_version": "1.0.0.0", 39 | "product_version": "1.0.0.0" 40 | }, 41 | "info": { 42 | "0409": { 43 | "Comments": "视频号视频下载工具", 44 | "CompanyName": "com.funzm", 45 | "FileDescription": "视频号视频下载工具", 46 | "FileVersion": "1.0.0.0", 47 | "InternalName": "wx_video_download", 48 | "LegalCopyright": "Copyright © 2024", 49 | "LegalTrademarks": "", 50 | "OriginalFilename": "wx_video_download.exe", 51 | "PrivateBuild": "", 52 | "ProductName": "视频号视频下载工具", 53 | "ProductVersion": "1.0.0.0", 54 | "SpecialBuild": "" 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /internal/manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | type ServerManager struct { 9 | servers map[string]Server 10 | mu sync.RWMutex 11 | } 12 | 13 | func NewServerManager() *ServerManager { 14 | return &ServerManager{ 15 | servers: make(map[string]Server), 16 | } 17 | } 18 | 19 | func (sm *ServerManager) RegisterServer(server Server) { 20 | sm.mu.Lock() 21 | defer sm.mu.Unlock() 22 | sm.servers[server.Name()] = server 23 | } 24 | 25 | func (sm *ServerManager) StartServer(name string) error { 26 | sm.mu.RLock() 27 | server, exists := sm.servers[name] 28 | sm.mu.RUnlock() 29 | 30 | if !exists { 31 | return fmt.Errorf("server %s not found", name) 32 | } 33 | 34 | return server.Start() 35 | } 36 | 37 | func (sm *ServerManager) StopServer(name string) error { 38 | sm.mu.RLock() 39 | server, exists := sm.servers[name] 40 | sm.mu.RUnlock() 41 | 42 | if !exists { 43 | return fmt.Errorf("server %s not found", name) 44 | } 45 | 46 | return server.Stop() 47 | } 48 | 49 | func (sm *ServerManager) GetStatus(name string) (ServerStatus, error) { 50 | sm.mu.RLock() 51 | server, exists := sm.servers[name] 52 | sm.mu.RUnlock() 53 | 54 | if !exists { 55 | return StatusStopped, fmt.Errorf("server %s not found", name) 56 | } 57 | 58 | return server.Status(), nil 59 | } 60 | 61 | func (sm *ServerManager) GetAllStatus() map[string]ServerStatus { 62 | sm.mu.RLock() 63 | defer sm.mu.RUnlock() 64 | 65 | statuses := make(map[string]ServerStatus) 66 | for name, server := range sm.servers { 67 | statuses[name] = server.Status() 68 | } 69 | return statuses 70 | } 71 | 72 | func (sm *ServerManager) ListServers() []string { 73 | sm.mu.RLock() 74 | defer sm.mu.RUnlock() 75 | 76 | names := make([]string, 0, len(sm.servers)) 77 | for name := range sm.servers { 78 | names = append(names, name) 79 | } 80 | return names 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 微信视频号下载器 2 | 3 | 体积小、使用简单、支持 macOS 和 Windows 系统。 4 | 5 | ## 使用说明 6 | 7 | 下载[构建包](https://github.com/ltaoo/wx_channels_download/releases),**以管理员身份运行**,首次打开会自动安装证书,然后启动服务。 8 | 9 | 当终端提示「代理服务启动成功」就说明可以使用了。 10 | 11 | ![正常使用](./docs/assets/app_screenshot1.png) 12 | 13 | > 已安装证书会跳过安装证书步骤。 14 | 15 | 打开微信 PC 端,点击需要下载的视频,在视频下方的操作按钮一栏,会多出一个下载按钮,如下所示 16 | 17 | ![视频下载按钮](./docs/assets/screenshot1.png) 18 | 19 | 如果没有,在页面侧边或底部会有悬浮按钮,拥有相同的功能 20 | 21 | | 首页推荐 | 视频详情页 | 22 | | --- | --- | 23 | | ![首页推荐](docs/assets/fixed_btn1.jpg) | ![视频详情页](docs/assets/fixed_btn2.jpg) | 24 | 25 | 26 | 等待视频开始播放,然后暂停视频,点击下载按扭即可下载视频。下载成功后,会在上方显示已下载的文件,下载文件名最后面会标志该视频质量。 27 | 28 | ![视频下载成功](./docs/assets/screenshot2.png) 29 | 30 | 下载按钮默认会下载视频号默认质量的视频(即当前播放的视频,一般都是体积最小的),可以在下拉菜单下载其他质量的视频 31 |
32 | 不同视频这里显示的选项是不同的,没有找到对 xWT111 具体的说明,属于什么分辨率、尺寸多大等等。 33 |
34 | 经过测试,如果原始视频有 104MB,这里尺寸最大的是 xWT111 为 17MB,最小的是 xWT98 为 7MB。 35 | 36 | ![不同质量视频尺寸统计](./docs/assets/screenshot14.png) 37 | 38 | 仅供参考。 39 | 40 | 41 | ## 开发说明 42 | 43 | 先以 管理员身份 启动终端,然后 `go run main.go` 即可。 44 | 45 | ## 打包 46 | 47 | ### windows 48 | 49 | ```bash 50 | go build -ldflags="-s -w" 51 | ``` 52 | 53 | 打包后可以使用 `upx` 压缩,进一步减小体积 54 | 55 | ```bash 56 | upx wx_channel 57 | ``` 58 | 59 | ### macOS 60 | 61 | ```bash 62 | CGO_ENABLED=1 GOOS=darwin SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags="-s -w" -o wx_video_download 63 | ``` 64 | 65 | ```bash 66 | CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags="-s -w" -o wx_video_download 67 | ``` 68 | 69 | ## 感谢 70 | 71 | 前端解密部分参考自 72 |
73 | https://github.com/kanadeblisst00/WechatVideoSniffer2.0 74 |
75 | 76 | 后端解密代码来自 77 |
78 | https://github.com/Hanson/WechatSphDecrypt 79 | 80 | 81 | ## ⚠️ 免责声明 82 | 83 | ```text 84 | 本项目为开源项目 85 | 仅用于技术交流学习和研究的目的 86 | 请遵守法律法规,请勿用作任何非法用途 87 | 否则造成一切后果自负 88 | 若您下载并使用即视为您知晓并同意 89 | ``` 90 | -------------------------------------------------------------------------------- /pkg/proxy/proxy_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package proxy 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | ) 10 | 11 | func enable_proxy(args ProxySettings) error { 12 | args = merge_default_settings(args) 13 | path := `HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings` 14 | proxy_server_url := fmt.Sprintf("%v:%v", args.Hostname, args.Port) 15 | // # 启用代理 16 | // Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -Name ProxyEnable -Value 1 17 | // Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -Name ProxyServer -Value "127.0.0.1:8080" 18 | cmd := fmt.Sprintf(`Set-ItemProperty -Path "%v" -Name ProxyEnable -Value 1`, path) 19 | ps := exec.Command("powershell.exe", "-Command", cmd) 20 | output, err := ps.CombinedOutput() 21 | if err != nil { 22 | return errors.New(fmt.Sprintf("设置系统代理时发生错误,%v\n", string(output))) 23 | } 24 | cmd = fmt.Sprintf(`Set-ItemProperty -Path "%v" -Name ProxyServer -Value %v`, path, proxy_server_url) 25 | ps = exec.Command("powershell.exe", "-Command", cmd) 26 | output, err = ps.CombinedOutput() 27 | if err != nil { 28 | return fmt.Errorf("设置 HTTP 代理失败,%v", string(output)) 29 | } 30 | return nil 31 | } 32 | 33 | func disable_proxy(args ProxySettings) error { 34 | path := `HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings` 35 | // # 禁用代理 36 | // Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings" -Name ProxyEnable -Value 0 37 | cmd := fmt.Sprintf(`Set-ItemProperty -Path "%v" -Name ProxyEnable -Value 0`, path) 38 | ps := exec.Command("powershell.exe", "-Command", cmd) 39 | output, err := ps.CombinedOutput() 40 | if err != nil { 41 | return fmt.Errorf("设置 HTTP 代理失败,%v", string(output)) 42 | } 43 | return nil 44 | } 45 | 46 | func get_network_interfaces() (*HardwarePort, error) { 47 | return nil, errors.New("not support") 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | "Commons Clause" License Condition v1.0 2 | 3 | License: MIT License 4 | Licensor: ltaoo 5 | Software: wx_channels_download (including binaries and distributions under the names "wx_channels_download.exe", "wx_channel", "wx_video_download", and any substantially similar names) 6 | 7 | The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. 8 | 9 | Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software. 10 | 11 | For the purposes of the foregoing, "Sell" means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration, a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any licensee who wishes to Sell the Software must obtain a separate license from the Licensor. 12 | 13 | --- 14 | 15 | MIT License 16 | 17 | Copyright (c) 2025 ltaoo 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a copy 20 | of this software and associated documentation files (the "Software"), to deal 21 | in the Software without restriction, including without limitation the rights 22 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 23 | copies of the Software, and to permit persons to whom the Software is 24 | furnished to do so, subject to the following conditions: 25 | 26 | The above copyright notice and this permission notice shall be included in all 27 | copies or substantial portions of the Software. 28 | 29 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 30 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 31 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 32 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 33 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 34 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 35 | SOFTWARE. 36 | -------------------------------------------------------------------------------- /internal/interceptor/inject/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | 4 | export default [ 5 | { 6 | ignores: ["lib/**", "docs/**", "dist/**", "node_modules/**"], 7 | }, 8 | { 9 | files: ["src/**/*.js"], 10 | languageOptions: { 11 | ecmaVersion: 2021, 12 | sourceType: "script", 13 | globals: { 14 | ...globals.browser, 15 | __wx_channels_store__: "writable", 16 | __wx_channels_tip__: "writable", 17 | __wx_channels_config__: "writable", 18 | __wx_channels_cur_video: "writable", 19 | __wx_download_btn_handler: "writable", 20 | __wx_channels_decrypt: "writable", 21 | __wx_channels_video_decrypt: "writable", 22 | __wx_channels_download: "writable", 23 | __wx_channels_download2: "writable", 24 | __wx_channels_download3: "writable", 25 | __wx_channels_download4: "writable", 26 | __wx_channels_handle_click_download__: "writable", 27 | __wx_channels_handle_print_download_command: "writable", 28 | __wx_channels_handle_download_cover: "writable", 29 | __wx_channels_handle_copy__: "writable", 30 | __wx_channels_live_store__: "writable", 31 | __wx_channels_live_download_btn__: "writable", 32 | __wx_channels_version__: "writable", 33 | WXU: "writable", 34 | WXE: "writable", 35 | render_extra_menu_items: "writable", 36 | download_icon1: "writable", 37 | download_icon2: "writable", 38 | download_icon3: "writable", 39 | download_icon4: "writable", 40 | download_btn1: "writable", 41 | download_btn2: "writable", 42 | download_btn3: "writable", 43 | download_btn4: "writable", 44 | EventBus: "writable", 45 | Module: "readonly", 46 | saveAs: "readonly", 47 | JSZip: "readonly", 48 | PageSpy: "readonly", 49 | Recorder: "readonly", 50 | mitt: "readonly", 51 | Weui: "readonly", 52 | }, 53 | }, 54 | rules: { 55 | ...js.configs.recommended.rules, 56 | "no-undef": "error", 57 | "no-unused-vars": "warn", 58 | "no-console": "off", 59 | "no-redeclare": "off", 60 | }, 61 | }, 62 | ]; 63 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - id: cross 20 | env: 21 | - CGO_ENABLED=0 22 | goos: 23 | - windows 24 | - linux 25 | main: . 26 | binary: wx_video_download 27 | ldflags: 28 | - -s -w 29 | ignore: 30 | - goos: darwin 31 | goarch: "386" 32 | - goos: windows 33 | goarch: "386" 34 | 35 | - id: macos 36 | env: 37 | - CGO_ENABLED=0 38 | goos: 39 | - darwin 40 | goarch: 41 | - amd64 42 | - arm64 43 | main: . 44 | binary: wx_video_download 45 | ldflags: 46 | - -s -w 47 | hooks: 48 | post: 49 | - rcodesign sign --p12-file {{ .Env.MAC_CERT_P12_FILE }} --p12-password {{ .Env.MAC_CERT_PASSWORD }} --code-signature-flags runtime "{{ .Path }}" 50 | 51 | archives: 52 | - id: default 53 | format: tar.gz 54 | name_template: "wx_video_download_{{ .Tag }}_{{ .Os }}_{{ if eq .Arch \"amd64\" }}x86_64{{ else if eq .Arch \"386\" }}x86{{ else }}{{ .Arch }}{{ end }}" 55 | files: 56 | - src: config/config.template.yaml 57 | dst: config.yaml 58 | - src: LICENSE 59 | - src: '!README*.md' 60 | format_overrides: 61 | - goos: windows 62 | format: zip 63 | - goos: darwin 64 | format: zip 65 | 66 | upx: 67 | - enabled: true 68 | ids: 69 | - cross 70 | compress: best 71 | lzma: true 72 | 73 | checksum: 74 | name_template: "wx_video_download_{{ .Tag }}_checksums.txt" 75 | 76 | changelog: 77 | sort: asc 78 | filters: 79 | exclude: 80 | - "^docs:" 81 | - "^test:" 82 | 83 | release: 84 | draft: true 85 | footer: >- 86 | 87 | --- 88 | 89 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 90 | -------------------------------------------------------------------------------- /internal/interceptor/types.go: -------------------------------------------------------------------------------- 1 | package interceptor 2 | 3 | type ChannelInjectedFiles struct { 4 | JSFileSaver []byte 5 | JSZip []byte 6 | JSRecorder []byte 7 | JSPageSpy []byte 8 | JSFloatingUICore []byte 9 | JSFloatingUIDOM []byte 10 | JSWeui []byte 11 | JSMitt []byte 12 | JSDebug []byte 13 | JSEventBus []byte 14 | JSComponents []byte 15 | JSUtils []byte 16 | JSError []byte 17 | JSMain []byte 18 | JSLiveMain []byte 19 | } 20 | 21 | type ChannelMediaSpec struct { 22 | FileFormat string `json:"file_format"` 23 | FirstLoadBytes int `json:"first_load_bytes"` 24 | BitRate int `json:"bit_rate"` 25 | CodingFormat string `json:"coding_format"` 26 | DynamicRangeType int `json:"dynamic_range_type"` 27 | Vfps int `json:"vfps"` 28 | Width int `json:"width"` 29 | Height int `json:"height"` 30 | DurationMs int `json:"duration_ms"` 31 | QualityScore float64 `json:"quality_score"` 32 | VideoBitrate int `json:"video_bitrate"` 33 | AudioBitrate int `json:"audio_bitrate"` 34 | LevelOrder int `json:"level_order"` 35 | Bypass string `json:"bypass"` 36 | Is3az int `json:"is3az"` 37 | } 38 | type ChannelPicture struct { 39 | URL string `json:"url"` 40 | } 41 | type ChannelContact struct { 42 | Id string `json:"id"` 43 | Nickname string `json:"nickname"` 44 | AvatarURL string `json:"avatar_url"` 45 | } 46 | type ChannelMediaProfile struct { 47 | Type string `json:"type"` // media | picture | live 48 | Id string `json:"id"` 49 | NonceId string `json:"nonce_id"` 50 | Title string `json:"title"` 51 | URL string `json:"url"` 52 | Key string `json:"key"` 53 | CoverURL string `json:"cover_url"` 54 | Contact ChannelContact `json:"contact"` 55 | Spec []ChannelMediaSpec `json:"spec"` 56 | Files []ChannelPicture `json:"files"` 57 | } 58 | type FrontendTip struct { 59 | End int `json:"end"` 60 | Replace int `json:"replace"` 61 | IgnorePrefix int `json:"ignore_prefix"` 62 | Prefix *string `json:"prefix"` 63 | Msg string `json:"msg"` 64 | } 65 | type FrontendErrorTip struct { 66 | Alert int `json:"alert"` 67 | Msg string `json:"msg"` 68 | } 69 | -------------------------------------------------------------------------------- /internal/interceptor/inject/lib/FileSaver.min.js: -------------------------------------------------------------------------------- 1 | (function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=f.navigator&&/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)}); 2 | -------------------------------------------------------------------------------- /pkg/proxy/proxy_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package proxy 4 | 5 | import ( 6 | "fmt" 7 | "os/exec" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | func enable_proxy(args ProxySettings) error { 13 | args = merge_default_settings(args) 14 | cmd1 := exec.Command("networksetup", "-setwebproxy", args.Device, args.Hostname, args.Port) 15 | _, err1 := cmd1.Output() 16 | if err1 != nil { 17 | return fmt.Errorf("设置 HTTP 代理失败,%v", err1.Error()) 18 | } 19 | cmd2 := exec.Command("networksetup", "-setsecurewebproxy", args.Device, args.Hostname, args.Port) 20 | output, err2 := cmd2.Output() 21 | if err2 != nil { 22 | return fmt.Errorf("设置 HTTPS 代理失败,%v", output) 23 | } 24 | return nil 25 | } 26 | 27 | func disable_proxy(args ProxySettings) error { 28 | args = merge_default_settings(args) 29 | cmd1 := exec.Command("networksetup", "-setwebproxystate", args.Device, "off") 30 | _, err1 := cmd1.Output() 31 | if err1 != nil { 32 | return fmt.Errorf("禁用 HTTP 代理失败,%v", err1.Error()) 33 | } 34 | cmd2 := exec.Command("networksetup", "-setsecurewebproxystate", args.Device, "off") 35 | _, err2 := cmd2.Output() 36 | if err2 != nil { 37 | return fmt.Errorf("禁用 HTTPS 代理失败,%v", err2.Error()) 38 | } 39 | return nil 40 | } 41 | 42 | func get_network_interfaces() (*HardwarePort, error) { 43 | // 获取所有硬件端口信息 44 | cmd := exec.Command("networksetup", "-listallhardwareports") 45 | output, err := cmd.Output() 46 | if err != nil { 47 | return nil, fmt.Errorf("执行 networksetup 命令失败: %v", err) 48 | } 49 | // 解析硬件端口信息 50 | var ports []HardwarePort 51 | lines := strings.Split(string(output), "\n") 52 | 53 | var cur_port HardwarePort 54 | for _, line := range lines { 55 | line = strings.TrimSpace(line) 56 | if strings.HasPrefix(line, "Hardware Port:") { 57 | if cur_port.Port != "" { 58 | ports = append(ports, cur_port) 59 | } 60 | cur_port = HardwarePort{} 61 | cur_port.Port = strings.TrimPrefix(line, "Hardware Port: ") 62 | } else if strings.HasPrefix(line, "Device:") { 63 | cur_port.Device = strings.TrimPrefix(line, "Device: ") 64 | } 65 | } 66 | if cur_port.Port != "" { 67 | ports = append(ports, cur_port) 68 | } 69 | // 获取网络接口信息 70 | cmd = exec.Command("scutil", "--nwi") 71 | output, err = cmd.Output() 72 | if err != nil { 73 | return nil, fmt.Errorf("执行 scutil 命令失败: %v", err) 74 | } 75 | // 使用正则解析接口信息 76 | re := regexp.MustCompile(`Network interfaces{0,1}: ([0-9a-zA-Z]{1,})`) 77 | matches := re.FindStringSubmatch(string(output)) 78 | // 将接口信息与硬件端口匹配 79 | if len(matches) >= 2 { 80 | for i := range ports { 81 | if ports[i].Device == matches[1] { 82 | return &ports[i], nil 83 | } 84 | } 85 | } 86 | return nil, fmt.Errorf("未找到硬件端口信息") 87 | } 88 | -------------------------------------------------------------------------------- /docs/feature/custom-menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自定义菜单 3 | --- 4 | 5 | # 自定义菜单 6 | 7 | 可以在现有「下载按钮」的悬浮菜单中,增加自己的功能 8 | 9 | 10 | ## 增加「下载视频和封面」 11 | 12 | 在和 `wx_video_download.exe` 相同目录下增加一个 `global.js` 文件,内容如下 13 | 14 | ```js 15 | // global.js 16 | WXU.unshiftMenuItems([ 17 | { 18 | label: "下载视频和封面", 19 | async onClick() { 20 | var [err, feed] = WXU.check_feed_existing({ 21 | silence: true, 22 | }); 23 | if (err) return; 24 | var filename = WXU.build_filename( 25 | feed, 26 | feed.spec[0], 27 | WXU.config.downloadFilenameTemplate 28 | ); 29 | if (WXU.config.downloadPauseWhenDownload) { 30 | WXU.pause_cur_video(); 31 | } 32 | var ins = WXU.loading(); 33 | var [err, response] = await WXU.fetch(feed.url); 34 | if (err) { 35 | WXU.error({ 36 | msg: err.message, 37 | }); 38 | return; 39 | } 40 | var media_blob = await WXU.download_with_progress(response, { 41 | onStart({ total_size }) { 42 | WXU.log({ 43 | msg: `总大小 ${WXU.bytes_to_size(total_size)}`, 44 | }); 45 | }, 46 | onProgress({ loaded_size, progress }) { 47 | WXU.log({ 48 | replace: 1, 49 | msg: 50 | progress === null 51 | ? `${WXU.bytes_to_size(loaded_size)}` 52 | : `${progress}%`, 53 | }); 54 | }, 55 | }); 56 | var media_buf = new Uint8Array(await media_blob.arrayBuffer()); 57 | if (feed.key) { 58 | WXU.log({ 59 | msg: "下载完成,开始解密", 60 | }); 61 | var [err, data] = await WXU.decrypt_video(media_buf, feed.key); 62 | if (err) { 63 | WXU.error({ msg: "解密失败," + err.message, alert: 0 }); 64 | WXU.error({ msg: "尝试使用 decrypt 命令解密", alert: 0 }); 65 | } else { 66 | WXU.log({ msg: "解密成功" }); 67 | media_buf = data; 68 | } 69 | } 70 | var decrypted_media_blob = new Blob([media_buf], { type: "video/mp4" }); 71 | var zip = await WXU.Zip(); 72 | zip.file(filename + ".mp4", decrypted_media_blob); 73 | var cover_url = feed.cover_url.replace(/^http/, "https"); 74 | var [err, cover_response] = await WXU.fetch(cover_url); 75 | if (err) { 76 | WXU.error({ 77 | msg: err.message, 78 | }); 79 | return; 80 | } 81 | var cover_blob = await cover_response.blob(); 82 | zip.file(filename + ".jpg", cover_blob); 83 | var zip_blob = await zip.generateAsync({ type: "blob" }); 84 | WXU.save(zip_blob, filename + ".zip"); 85 | ins.hide(); 86 | if (WXU.config.downloadPauseWhenDownload) { 87 | WXU.play_cur_video(); 88 | } 89 | }, 90 | }, 91 | ]); 92 | ``` 93 | -------------------------------------------------------------------------------- /pkg/certificate/certificate_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package certificate 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | func fetchCertificates() ([]Certificate, error) { 15 | cmd := exec.Command("security", "find-certificate", "-a") 16 | output, err2 := cmd.Output() 17 | if err2 != nil { 18 | return nil, errors.New(fmt.Sprintf("获取证书时发生错误,%v\n", err2.Error())) 19 | } 20 | var certificates []Certificate 21 | lines := strings.Split(string(output), "\n") 22 | for i := 0; i < len(lines)-1; i += 13 { 23 | if lines[i] == "" { 24 | continue 25 | } 26 | // if i > len(lines)-1 { 27 | // continue 28 | // } 29 | cenc := lines[i+5] 30 | ctyp := lines[i+6] 31 | hpky := lines[i+7] 32 | labl := lines[i+9] 33 | subj := lines[i+12] 34 | re := regexp.MustCompile(`="([^"]{1,})"`) 35 | // 找到匹配的字符串 36 | matches := re.FindStringSubmatch(labl) 37 | if len(matches) < 1 { 38 | continue 39 | } 40 | label := matches[1] 41 | certificates = append(certificates, Certificate{ 42 | Thumbprint: "", 43 | Subject: CertificateSubject{ 44 | CN: label, 45 | OU: cenc, 46 | O: ctyp, 47 | L: hpky, 48 | S: subj, 49 | C: cenc, 50 | }, 51 | }) 52 | } 53 | return certificates, nil 54 | } 55 | 56 | func installCertificate(cert_data []byte) error { 57 | cert_file, err := os.CreateTemp("", "SunnyRoot.cer") 58 | if err != nil { 59 | return errors.New(fmt.Sprintf("没有创建证书的权限,%v\n", err.Error())) 60 | } 61 | defer os.Remove(cert_file.Name()) 62 | if _, err := cert_file.Write(cert_data); err != nil { 63 | return errors.New(fmt.Sprintf("获取证书失败,%v\n", err.Error())) 64 | } 65 | if err := cert_file.Close(); err != nil { 66 | return errors.New(fmt.Sprintf("生成证书失败,%v\n", err.Error())) 67 | } 68 | cmd := fmt.Sprintf("security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain '%s'", cert_file.Name()) 69 | ps := exec.Command("bash", "-c", cmd) 70 | output, err2 := ps.CombinedOutput() 71 | if err2 != nil { 72 | return errors.New(fmt.Sprintf("安装证书时发生错误,%v\n", string(output))) 73 | } 74 | return nil 75 | } 76 | 77 | func uninstallCertificate(certificate_name string) error { 78 | certificates, err := fetchCertificates() 79 | if err != nil { 80 | return err 81 | } 82 | var matched *Certificate 83 | for _, cert := range certificates { 84 | if cert.Subject.CN == certificate_name { 85 | matched = &cert 86 | break 87 | } 88 | } 89 | if matched == nil { 90 | return errors.New("没有找到匹配的根证书") 91 | } 92 | cmd := fmt.Sprintf("security delete-certificate -c '%s'", certificate_name) 93 | ps := exec.Command("bash", "-c", cmd) 94 | output, err2 := ps.CombinedOutput() 95 | if err2 != nil { 96 | return errors.New(fmt.Sprintf("删除证书时发生错误,%v\n", string(output))) 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/certificate/certificate_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package certificate 4 | 5 | import ( 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "io/fs" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | ) 14 | 15 | func fetchCertificates() ([]Certificate, error) { 16 | var certs []Certificate 17 | paths := []string{"/etc/ssl/certs", "/usr/local/share/ca-certificates"} 18 | 19 | for _, dir := range paths { 20 | _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 21 | if err != nil || d.IsDir() { 22 | return nil 23 | } 24 | ext := filepath.Ext(path) 25 | if ext != ".crt" && ext != ".pem" { 26 | return nil 27 | } 28 | data, err := os.ReadFile(path) 29 | if err != nil { 30 | return nil 31 | } 32 | for { 33 | block, rest := pem.Decode(data) 34 | if block == nil { 35 | break 36 | } 37 | data = rest 38 | if block.Type != "CERTIFICATE" { 39 | continue 40 | } 41 | cert, err := x509.ParseCertificate(block.Bytes) 42 | if err != nil { 43 | continue 44 | } 45 | certs = append(certs, Certificate{ 46 | Subject: CertificateSubject{CN: cert.Subject.CommonName}, 47 | }) 48 | } 49 | return nil 50 | }) 51 | } 52 | return certs, nil 53 | } 54 | 55 | func installCertificate(cert []byte) error { 56 | certPath := "/usr/local/share/ca-certificates/WeChatAppEx_CA.crt" 57 | err := os.WriteFile(certPath, cert, 0644) 58 | if err != nil { 59 | return fmt.Errorf("写入证书失败: %v", err) 60 | } 61 | if output, err := exec.Command("update-ca-certificates", "--fresh").CombinedOutput(); err != nil { 62 | return fmt.Errorf("更新 OpenSSL 证书库失败: %v\n输出: %s", err, string(output)) 63 | } 64 | if _, err := exec.LookPath("certutil"); err == nil { 65 | exec.Command("certutil", "-d", "sql:/etc/pki/nssdb", "-D", "-n", "WeChatAppEx_CA").Run() 66 | exec.Command("certutil", "-d", "sql:/etc/pki/nssdb", "-A", "-n", "WeChatAppEx_CA", "-t", "CT,C,C", "-i", certPath).Run() 67 | if home, _ := os.UserHomeDir(); home != "" { 68 | userDB := filepath.Join(home, ".pki", "nssdb") 69 | os.MkdirAll(userDB, 0700) 70 | userDBSQL := "sql:" + userDB 71 | exec.Command("certutil", "-d", userDBSQL, "-D", "-n", "WeChatAppEx_CA").Run() 72 | exec.Command("certutil", "-d", userDBSQL, "-A", "-n", "WeChatAppEx_CA", "-t", "CT,C,C", "-i", certPath).Run() 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | func uninstallCertificate(name string) error { 79 | certPath := "/usr/local/share/ca-certificates/WeChatAppEx_CA.crt" 80 | _ = os.Remove(certPath) 81 | if output, err := exec.Command("update-ca-certificates", "--fresh").CombinedOutput(); err != nil { 82 | return fmt.Errorf("更新 OpenSSL 证书库失败: %v\n输出: %s", err, string(output)) 83 | } 84 | exec.Command("certutil", "-d", "sql:/etc/pki/nssdb", "-D", "-n", "WeChatAppEx_CA").Run() 85 | if home, _ := os.UserHomeDir(); home != "" { 86 | userDB := filepath.Join(home, ".pki", "nssdb") 87 | userDBSQL := "sql:" + userDB 88 | exec.Command("certutil", "-d", userDBSQL, "-D", "-n", "WeChatAppEx_CA").Run() 89 | } 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /cmd/download.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "wx_channel/pkg/decrypt" 13 | "wx_channel/pkg/download" 14 | ) 15 | 16 | var ( 17 | video_url string 18 | filename string 19 | video_decrypt_key int 20 | ) 21 | 22 | var download_cmd = &cobra.Command{ 23 | Use: "download", 24 | Short: "下载视频", 25 | Long: "从指定URL下载视频文件", 26 | Run: func(cmd *cobra.Command, args []string) { 27 | command := cmd.Name() 28 | if command != "download" { 29 | return 30 | } 31 | download_command(DownloadCommandArgs{ 32 | URL: video_url, 33 | DecryptKey: video_decrypt_key, 34 | Filename: filename, 35 | }) 36 | }, 37 | } 38 | 39 | func init() { 40 | now := int(time.Now().Unix()) 41 | download_cmd.Flags().StringVar(&video_url, "url", "", "视频URL(必需)") 42 | download_cmd.Flags().IntVar(&video_decrypt_key, "key", 0, "解密密钥(未加密的视频不用传该参数)") 43 | download_cmd.Flags().StringVar(&filename, "filename", strconv.Itoa(now)+".mp4", "下载后的文件名") 44 | download_cmd.MarkFlagRequired("url") 45 | 46 | root_cmd.AddCommand(download_cmd) 47 | } 48 | 49 | type DownloadCommandArgs struct { 50 | URL string 51 | Filename string 52 | DecryptKey int 53 | } 54 | 55 | func download_command(args DownloadCommandArgs) { 56 | url := args.URL 57 | homedir, err := os.UserHomeDir() 58 | if err != nil { 59 | fmt.Printf("[ERROR]获取下载路径失败 %v\n", err.Error()) 60 | return 61 | } 62 | tmp_filename := "tmp_wx_" + strconv.Itoa(int(time.Now().Unix())) 63 | tmp_dest_filepath := filepath.Join(homedir, "Downloads", tmp_filename) 64 | dest_filepath := filepath.Join(homedir, "Downloads", args.Filename) 65 | 66 | if args.DecryptKey == 0 { 67 | tmp_dest_filepath = dest_filepath 68 | } 69 | 70 | if err := download.MultiThreadingDownload(url, 4, tmp_dest_filepath, tmp_dest_filepath); err != nil { 71 | fmt.Printf("[ERROR]%v\n", err.Error()) 72 | return 73 | } 74 | 75 | if args.DecryptKey != 0 { 76 | fmt.Printf("下载完成!\n") 77 | fmt.Printf("开始对临时文件解密 %s\n", tmp_dest_filepath) 78 | length := uint32(131072) 79 | key := uint64(args.DecryptKey) 80 | data, err := os.ReadFile(tmp_dest_filepath) 81 | if err != nil { 82 | fmt.Printf("[ERROR]读取临时文件失败 %v\n", err.Error()) 83 | return 84 | } 85 | decrypt.DecryptData(data, length, key) 86 | err = os.WriteFile(dest_filepath, data, 0644) 87 | if err != nil { 88 | fmt.Printf("[ERROR]写入文件失败 %v\n", err.Error()) 89 | return 90 | } 91 | fmt.Printf("删除临时文件 %s\n", tmp_dest_filepath) 92 | if err := os.Remove(tmp_dest_filepath); err != nil { 93 | if os.IsNotExist(err) { 94 | fmt.Println("[ERROR]临时文件不存在") 95 | } else if os.IsPermission(err) { 96 | fmt.Println("[ERROR]没有权限删除临时文件") 97 | } else { 98 | fmt.Printf("[ERROR]临时文件删除失败 %v\n", err.Error()) 99 | } 100 | } 101 | fmt.Printf("解密完成,文件路径为 %s\n", dest_filepath) 102 | return 103 | } 104 | fmt.Printf("下载完成,文件路径为 %s\n", dest_filepath) 105 | } 106 | -------------------------------------------------------------------------------- /internal/manager/server.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | // HTTPServer 实现 12 | type HTTPServer struct { 13 | title string 14 | key string 15 | addr string 16 | status ServerStatus 17 | mux http.Handler 18 | server *http.Server 19 | mu sync.RWMutex 20 | stopChan chan struct{} 21 | } 22 | 23 | func NewHTTPServer(title string, key string, addr string) *HTTPServer { 24 | return &HTTPServer{ 25 | title: title, 26 | key: key, 27 | addr: addr, 28 | status: StatusStopped, 29 | stopChan: make(chan struct{}), 30 | } 31 | } 32 | 33 | func (s *HTTPServer) Name() string { 34 | return s.key 35 | } 36 | 37 | func (s *HTTPServer) Addr() string { 38 | return s.addr 39 | } 40 | 41 | func (s *HTTPServer) SetHandler(handler http.Handler) { 42 | s.mu.Lock() 43 | defer s.mu.Unlock() 44 | s.mux = handler 45 | } 46 | 47 | func (s *HTTPServer) Mux() http.Handler { 48 | return s.mux 49 | } 50 | 51 | func (s *HTTPServer) Start() error { 52 | s.mu.Lock() 53 | defer s.mu.Unlock() 54 | 55 | if s.status == StatusRunning || s.status == StatusStarting { 56 | return fmt.Errorf("server is already %s", s.status) 57 | } 58 | 59 | s.status = StatusStarting 60 | s.server = &http.Server{ 61 | Addr: s.addr, 62 | Handler: s.mux, 63 | } 64 | 65 | go func() { 66 | s.mu.Lock() 67 | s.status = StatusRunning 68 | s.mu.Unlock() 69 | 70 | // fmt.Printf("Server %s starting on addr %s\n", s.name, s.addr) 71 | if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 72 | s.mu.Lock() 73 | s.status = StatusError 74 | s.mu.Unlock() 75 | fmt.Printf("%s error: %v\n", s.title, err) 76 | return 77 | } 78 | 79 | s.mu.Lock() 80 | s.status = StatusStopped 81 | s.mu.Unlock() 82 | fmt.Printf("%s 已关闭\n", s.title) 83 | }() 84 | 85 | // 等待服务器启动 86 | time.Sleep(100 * time.Millisecond) 87 | return nil 88 | } 89 | 90 | func (s *HTTPServer) Stop() error { 91 | s.mu.Lock() 92 | defer s.mu.Unlock() 93 | 94 | if s.status != StatusRunning { 95 | return nil 96 | } 97 | 98 | s.status = StatusStopping 99 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 100 | defer cancel() 101 | 102 | if err := s.server.Shutdown(ctx); err != nil { 103 | s.status = StatusError 104 | return err 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (s *HTTPServer) Status() ServerStatus { 111 | s.mu.RLock() 112 | defer s.mu.RUnlock() 113 | return s.status 114 | } 115 | 116 | func (s *HTTPServer) HealthCheck() error { 117 | if s.Status() != StatusRunning { 118 | return fmt.Errorf("server not running") 119 | } 120 | 121 | resp, err := http.Get(fmt.Sprintf("http://%s/health", s.addr)) 122 | if err != nil { 123 | return err 124 | } 125 | defer resp.Body.Close() 126 | 127 | if resp.StatusCode != http.StatusOK { 128 | return fmt.Errorf("health check failed: %s", resp.Status) 129 | } 130 | 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | env: 16 | MAC_CERT_P12: ${{ secrets.MAC_CERT_P12 }} 17 | MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }} 18 | NOTARY_PRIVATE_KEY: ${{ secrets.NOTARY_PRIVATE_KEY }} 19 | NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} 20 | NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: '1.24' 31 | 32 | 33 | - name: Generate Windows resources (.syso) 34 | run: | 35 | set -e 36 | go run github.com/tc-hib/go-winres@latest make --in ./winres/winres.json --arch amd64 37 | go run github.com/tc-hib/go-winres@latest make --in ./winres/winres.json --arch arm64 38 | 39 | - name: Install rcodesign 40 | run: | 41 | set -e 42 | curl -L -o rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-x86_64-unknown-linux-musl.tar.gz 43 | tar -xvf rcodesign.tar.gz 44 | mv apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign /usr/local/bin/ 45 | rm -rf rcodesign.tar.gz apple-codesign-0.22.0-x86_64-unknown-linux-musl 46 | 47 | - name: Prepare secrets 48 | run: | 49 | set -e 50 | echo "${{ secrets.MAC_CERT_P12 }}" | base64 --decode > cert.p12 51 | echo "${{ secrets.NOTARY_PRIVATE_KEY }}" | base64 --decode > AuthKey.p8 52 | 53 | - name: Build with GoReleaser (skip publish & validate) 54 | uses: goreleaser/goreleaser-action@v6 55 | with: 56 | distribution: goreleaser 57 | version: latest 58 | args: release --skip=publish,validate --clean 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | MAC_CERT_P12_FILE: cert.p12 62 | MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }} 63 | 64 | - name: Create API key JSON for notarization 65 | run: | 66 | rcodesign encode-app-store-connect-api-key \ 67 | "${{ secrets.NOTARY_ISSUER_ID }}" \ 68 | "${{ secrets.NOTARY_KEY_ID }}" \ 69 | AuthKey.p8 \ 70 | > api-key.json 71 | 72 | - name: Notarize macOS archives 73 | run: | 74 | set -e 75 | for file in dist/*darwin*.zip; do 76 | if [ -f "$file" ]; then 77 | echo "Notarizing $file..." 78 | rcodesign notary-submit \ 79 | --api-key-path api-key.json \ 80 | --wait \ 81 | "$file" 82 | fi 83 | done 84 | 85 | - name: Create GitHub Release and upload assets 86 | uses: softprops/action-gh-release@v2 87 | with: 88 | draft: true 89 | files: | 90 | dist/*.tar.gz 91 | dist/*.zip 92 | dist/*checksums.txt 93 | -------------------------------------------------------------------------------- /internal/interceptor/inject/src/utils.d.ts: -------------------------------------------------------------------------------- 1 | type LogMsg = { 2 | /** 消息内容 */ 3 | msg: string; 4 | /** 日志前缀,默认是 [FRONTEND] */ 5 | prefix?: string; 6 | ignore_prefix?: 1; 7 | replace?: 1; 8 | end?: 1; 9 | }; 10 | type ErrorMsg = { 11 | /** 是否同时调用 alert */ 12 | alert?: 1; 13 | /** 错误消息内容 */ 14 | msg: string; 15 | }; 16 | type ChannelsConfig = { 17 | /** 下载按钮默认下载原始视频 */ 18 | defaultHighest: boolean; 19 | /** 下载文件名的模板,不带后缀 */ 20 | downloadFilenameTemplate: string; 21 | /** 下载时暂停播放 */ 22 | downloadPauseWhenDownload: boolean; 23 | downloadLocalServerEnabled: boolean; 24 | downloadLocalServerAddr: string; 25 | }; 26 | type DropdownMenuItemPayload = { 27 | label: string; 28 | onClick: (event: { feed: FeedProfile; href: string }) => void; 29 | }; 30 | 31 | /** 视频号原始的视频数据 */ 32 | type ChannelsFeed = { 33 | id: string; 34 | objectDesc: { 35 | /** 4视频 9直播 */ 36 | mediaType: number; 37 | description: string; 38 | media: ChannelsMedia[]; 39 | }; 40 | objectNonceId: string; 41 | objectStatus: number; 42 | createtime: number; 43 | /** 转发数 */ 44 | forwardCount: number; 45 | /** 点赞数 */ 46 | likeCount: number; 47 | /** 评论数 */ 48 | commentCount: number; 49 | favCount: number; 50 | /** 发布者 */ 51 | contact: { 52 | username: string; 53 | headUrl: string; 54 | nickname: string; 55 | signature: string; 56 | }; 57 | liveCover?: { 58 | imgUrl: string; 59 | imgUrlToken: string; 60 | }; 61 | liveInfo?: { 62 | streamUrl: string; 63 | }; 64 | anchorContact?: { 65 | username: string; 66 | nickname: string; 67 | headUrl: string; 68 | signature: string; 69 | liveCoverImgUrl: string; 70 | }; 71 | }; 72 | /** 视频号原始的 media */ 73 | type ChannelsMedia = { 74 | url: string; 75 | coverUrl: string; 76 | fileSize: number; 77 | decodeKey: string; 78 | /** 时长 */ 79 | videoPlayLen: number; 80 | width: number; 81 | height: number; 82 | spec: ChannelsMediaSpec[]; 83 | }; 84 | type ChannelsMediaSpec = { 85 | /** 规格值 */ 86 | fileFormat: string; 87 | }; 88 | /** 89 | * 对原始 feed 做了一些提取后的 90 | * 调用 WXU.check_profile_existing 获取到的就是这个类型的数据 91 | */ 92 | type FeedProfile = { 93 | type: "media" | "picture" | "live"; 94 | id: number; 95 | nonce_id: string; 96 | /** 标题 */ 97 | title: string; 98 | /** 下载地址 */ 99 | url: string; 100 | key: number; 101 | /** 封面地址 */ 102 | cover_url: string; 103 | /** 视频发布时间 */ 104 | createtime: number; 105 | /** 文件大小 */ 106 | size?: number; 107 | /** 视频时长 */ 108 | duration?: number; 109 | /** 图片列表,类型为 pictures 才有 */ 110 | files?: { url: string }[]; 111 | /** 规格列表,类型为 media 才有 */ 112 | spec?: ChannelsMediaSpec[]; 113 | /** 发布者 */ 114 | contact: { 115 | id: string; 116 | avatar_url: string; 117 | nickname: string; 118 | }; 119 | }; 120 | 121 | /** 122 | * 对 FeedProfile 又增加了用于下载的一些字段 123 | */ 124 | type FeedProfilePayload = FeedProfile & { 125 | /** 文件名 */ 126 | filename: string; 127 | /** 原始 URL */ 128 | original_url: string; 129 | /** 添加了 规格 后缀的视频下载地址 */ 130 | url: string; 131 | /** 目标规格 */ 132 | target_spec?: ChannelsMediaSpec; 133 | /** 源 URL */ 134 | source_url: string; 135 | /** 已播放的视频内容(用于下载当前视频) */ 136 | data?: ArrayBuffer; 137 | mp3: boolean; 138 | }; 139 | -------------------------------------------------------------------------------- /docs/feature/event.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 监听事件 3 | --- 4 | 5 | # 监听事件 6 | 7 | 可以在视频号发生事件时,触发自定义脚本。本下载器的「插入下载按钮」、「在终端打印访问的视频」,都是基于该能力实现的 8 | 9 | 目前支持的事件有 10 | 11 | - `onFeed`:加载了 feed(首页、详情、直播都会触发) 12 | - `onPCFlowLoaded`:首页推荐获取到视频列表 13 | - `onRecommendFeedsLoaded`:获取到推荐列表 14 | - `onUserFeedsLoaded`:获取到指定用户的部分视频列表 15 | - `onGotoNextFeed`:首页推荐切换到下一个视频 16 | - `onGotoPrevFeed`:首页推荐切换到上一个视频 17 | - `onFetchFeedProfile`:获取到视频详情 18 | - `onFetchLiveProfile`:获取到直播详情 19 | - `beforeDownloadMedia`:下载视频之前 20 | - `beforeDownloadCover`:下载封面之前 21 | - `onMediaDownloaded`:视频下载完成 22 | - `onMP3Downloaded`:MP3 下载完成 23 | - `onDOMContentLoaded`:DOM 完全加载和解析 24 | - `onDOMContentBeforeUnLoaded`:DOM 加载完成前(页面即将离开,DOM 仍存在) 25 | - `onWindowLoaded`:所有资源加载完成 26 | - `onWindowUnLoaded`:页面卸载完成(DOM 即将被销毁) 27 | 28 | 参数类型可以参考 [`utils.d.ts`](https://github.com/ltaoo/wx_channels_download/blob/main/internal/interceptor/inject/src/utils.d.ts) 29 | 30 | 点击下载 utils.d.ts 31 | 32 | 基于上面的事件,可以实现任意的功能,包括但不限于 33 | 34 | - 记录所有访问过的视频 35 | - 记录下载过的视频 36 | - 自动下载所有视频 37 | 38 | 下面给出一个「打印访问过的视频」功能代码示例 39 | 40 | ## 打印访问过的视频 41 | 42 | 在和 `wx_video_download.exe` 同目录下增加一个 `global.js` 文件,内容如下 43 | 44 | ```js 45 | // global.js 46 | WXU.onFeed(async (feed) => { 47 | const [err, res] = await WXU.request({ 48 | method: "POST", 49 | url: "http://127.0.0.1:1234/api/feed", 50 | body: feed, 51 | }); 52 | if (err) { 53 | WXU.error({ msg: err.message }); 54 | return; 55 | } 56 | WXU.log({ msg: JSON.stringify(res) }); 57 | }); 58 | ``` 59 | 60 | 该代码实现了在访问视频时,将视频的完整信息,提交到 `http://127.0.0.1:1234/api/feed` 这个服务 61 | 62 | ```go 63 | // main.go 64 | package main 65 | 66 | import ( 67 | "io" 68 | "log" 69 | "net/http" 70 | ) 71 | 72 | func setCORS(w http.ResponseWriter) { 73 | h := w.Header() 74 | h.Set("Access-Control-Allow-Origin", "*") 75 | h.Set("Access-Control-Allow-Methods", "POST, OPTIONS") 76 | h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 77 | } 78 | 79 | func feedHandler(w http.ResponseWriter, r *http.Request) { 80 | setCORS(w) 81 | if r.Method == http.MethodOptions { 82 | w.WriteHeader(http.StatusNoContent) 83 | return 84 | } 85 | if r.Method != http.MethodPost { 86 | http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) 87 | return 88 | } 89 | body, err := io.ReadAll(r.Body) 90 | if err != nil { 91 | log.Printf("read body error: %v", err) 92 | http.Error(w, "Bad Request", http.StatusBadRequest) 93 | return 94 | } 95 | defer r.Body.Close() 96 | log.Printf("feed: %s", string(body)) 97 | w.Header().Set("Content-Type", "application/json") 98 | w.WriteHeader(http.StatusOK) 99 | _, _ = w.Write([]byte(`{"ok":true}`)) 100 | } 101 | 102 | func main() { 103 | mux := http.NewServeMux() 104 | mux.HandleFunc("/api/feed", feedHandler) 105 | addr := "127.0.0.1:1234" 106 | log.Printf("HTTP server listening on %s", addr) 107 | if err := http.ListenAndServe(addr, mux); err != nil { 108 | log.Fatalf("server error: %v", err) 109 | } 110 | } 111 | ``` 112 | 113 | 上面代码中 114 | 115 | ```go 116 | log.Printf("feed: %s", string(body)) 117 | ``` 118 | 119 | 就是打印访问的视频完整信息。有了完整信息,可以自己实现保存到数据库、下载视频等任意功能。 120 | 121 | 如果要实现下载访问过的视频,只需要在上面 `log.Printf` 处,解析传过来的 `body`,拿到 `url` 和 `key`,下载并解密即可。 122 | 123 | 下载和解密功能,可以参考本项目 `cmd` 目录下代码 124 | -------------------------------------------------------------------------------- /internal/interceptor/interceptor.go: -------------------------------------------------------------------------------- 1 | package interceptor 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "strconv" 8 | 9 | "github.com/ltaoo/echo" 10 | "github.com/rs/zerolog" 11 | 12 | "wx_channel/pkg/certificate" 13 | "wx_channel/pkg/proxy" 14 | ) 15 | 16 | type Interceptor struct { 17 | Version string 18 | Debug bool 19 | Settings *InterceptorSettings 20 | Cert *certificate.CertFileAndKeyFile 21 | echo *echo.Echo 22 | PostPlugins []*echo.Plugin // echo 的插件,将在 echo 初始化后传给 echo 23 | log *zerolog.Logger 24 | } 25 | 26 | func NewInterceptor(payload *InterceptorSettings, cert *certificate.CertFileAndKeyFile) *Interceptor { 27 | log := zerolog.New(io.Discard).With().Timestamp().Str("component", "interceptor").Str("version", payload.Version).Logger() 28 | return &Interceptor{ 29 | Version: payload.Version, 30 | Debug: payload.DebugShowError, 31 | Settings: payload, 32 | Cert: cert, 33 | log: &log, 34 | echo: nil, 35 | } 36 | } 37 | 38 | func (c *Interceptor) Start() error { 39 | echo.SetLogEnabled(false) 40 | client, err := echo.NewEcho(c.Cert.Cert, c.Cert.PrivateKey) 41 | if err != nil { 42 | return err 43 | } 44 | client.AddPlugin(CreateChannelInterceptorPlugin(c.Version, Assets, c.Settings)) 45 | if c.Debug { 46 | client.AddPlugin(&echo.Plugin{ 47 | Match: "debug.weixin.qq.com", 48 | Target: &echo.TargetConfig{ 49 | Protocol: "http", 50 | Host: "127.0.0.1", 51 | Port: 6752, 52 | }, 53 | }) 54 | } 55 | if len(c.PostPlugins) != 0 { 56 | for _, plugin := range c.PostPlugins { 57 | client.AddPlugin(plugin) 58 | } 59 | } 60 | c.echo = client 61 | existing, err := certificate.CheckHasCertificate(c.Cert.Name) 62 | if err != nil { 63 | return fmt.Errorf("检查证书失败: %v", err) 64 | } 65 | if !existing { 66 | fmt.Printf("正在安装证书...\n") 67 | if err := certificate.InstallCertificate(c.Cert.Cert); err != nil { 68 | return fmt.Errorf("安装证书失败: %v", err) 69 | } 70 | } 71 | if c.Settings.ProxySetSystem { 72 | if err := proxy.EnableProxy(proxy.ProxySettings{ 73 | Device: c.Settings.ProxyDevice, 74 | Hostname: c.Settings.ProxyServerHostname, 75 | Port: strconv.Itoa(c.Settings.ProxyServerPort), 76 | }); err != nil { 77 | return fmt.Errorf("设置代理失败: %v", err) 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | func (c *Interceptor) Stop() error { 84 | if c.Settings.ProxySetSystem { 85 | arg := proxy.ProxySettings{ 86 | Device: c.Settings.ProxyDevice, 87 | Hostname: c.Settings.ProxyServerHostname, 88 | Port: strconv.Itoa(c.Settings.ProxyServerPort), 89 | } 90 | err := proxy.DisableProxy(arg) 91 | if err != nil { 92 | return fmt.Errorf("关闭系统代理失败: %v", err) 93 | } 94 | } 95 | return nil 96 | } 97 | 98 | func (c *Interceptor) SetVersion(v string) { 99 | c.Version = v 100 | } 101 | func (c *Interceptor) AddPostPlugin(plugin *echo.Plugin) { 102 | c.PostPlugins = append(c.PostPlugins, plugin) 103 | } 104 | func (c *Interceptor) AddPlugin(plugin *echo.Plugin) { 105 | if c.echo != nil { 106 | c.echo.AddPlugin(plugin) 107 | } 108 | } 109 | func (c *Interceptor) SetLog(writer io.Writer) { 110 | l := zerolog.New(writer).With().Timestamp().Str("component", "interceptor").Str("version", c.Version).Logger() 111 | c.log = &l 112 | } 113 | func (c *Interceptor) ServeHTTP(w http.ResponseWriter, r *http.Request) { 114 | c.echo.ServeHTTP(w, r) 115 | } 116 | -------------------------------------------------------------------------------- /pkg/certificate/certificate_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package certificate 4 | 5 | import ( 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | ) 13 | 14 | func fetchCertificates() ([]Certificate, error) { 15 | cmd := "$certs = Get-ChildItem Cert:\\LocalMachine\\Root | Select-Object Thumbprint, Subject; @($certs) | ConvertTo-Json -Compress" 16 | ps := exec.Command("powershell.exe", "-NoProfile", "-Command", cmd) 17 | output, err2 := ps.CombinedOutput() 18 | if err2 != nil { 19 | return nil, errors.New(fmt.Sprintf("获取证书时发生错误,%v\n", err2.Error())) 20 | } 21 | type WindowsCert struct { 22 | Thumbprint string `json:"Thumbprint"` 23 | Subject string `json:"Subject"` 24 | } 25 | var raw_arr []WindowsCert 26 | if err := json.Unmarshal(output, &raw_arr); err != nil { 27 | var one WindowsCert 28 | if err2 := json.Unmarshal(output, &one); err2 != nil { 29 | return nil, errors.New(fmt.Sprintf("解析证书列表失败,%v\n", err.Error())) 30 | } 31 | raw_arr = []WindowsCert{one} 32 | } 33 | var certificates []Certificate 34 | for _, pc := range raw_arr { 35 | subj := CertificateSubject{} 36 | pairs := strings.Split(pc.Subject, ",") 37 | for _, p := range pairs { 38 | kv := strings.SplitN(strings.TrimSpace(p), "=", 2) 39 | if len(kv) != 2 { 40 | continue 41 | } 42 | key := kv[0] 43 | value := kv[1] 44 | switch key { 45 | case "CN": 46 | subj.CN = value 47 | case "OU": 48 | subj.OU = value 49 | case "O": 50 | subj.O = value 51 | case "L": 52 | subj.L = value 53 | case "S": 54 | subj.S = value 55 | case "C": 56 | subj.C = value 57 | } 58 | } 59 | certificates = append(certificates, Certificate{ 60 | Thumbprint: pc.Thumbprint, 61 | Subject: subj, 62 | }) 63 | } 64 | return certificates, nil 65 | } 66 | 67 | func installCertificate(cert_data []byte) error { 68 | cert_file, err := os.CreateTemp("", "SunnyRoot.cer") 69 | if err != nil { 70 | return errors.New(fmt.Sprintf("没有创建证书的权限,%v\n", err.Error())) 71 | } 72 | defer os.Remove(cert_file.Name()) 73 | if _, err := cert_file.Write(cert_data); err != nil { 74 | return errors.New(fmt.Sprintf("获取证书失败,%v\n", err.Error())) 75 | } 76 | if err := cert_file.Close(); err != nil { 77 | return errors.New(fmt.Sprintf("生成证书失败,%v\n", err.Error())) 78 | } 79 | cmd := fmt.Sprintf("Import-Certificate -FilePath '%s' -CertStoreLocation Cert:\\LocalMachine\\Root", cert_file.Name()) 80 | ps := exec.Command("powershell.exe", "-Command", cmd) 81 | output, err2 := ps.CombinedOutput() 82 | if err2 != nil { 83 | return errors.New(fmt.Sprintf("安装证书时发生错误,%v\n", string(output))) 84 | } 85 | return nil 86 | } 87 | 88 | func uninstallCertificate(name string) error { 89 | fmt.Println(name) 90 | // Remove-Item "Cert:\LocalMachine\Root\D70CD039051F77C30673B8209FC15EFA650ED52C" 91 | certificates, err := fetchCertificates() 92 | if err != nil { 93 | return err 94 | } 95 | var matched *Certificate 96 | for _, cert := range certificates { 97 | if cert.Subject.CN == name { 98 | matched = &cert 99 | break 100 | } 101 | } 102 | if matched == nil { 103 | return errors.New("没有找到要删除的证书") 104 | } 105 | cmd := fmt.Sprintf("Get-ChildItem Cert:\\LocalMachine\\Root\\%v | Remove-Item", matched.Thumbprint) 106 | ps := exec.Command("powershell.exe", "-Command", cmd) 107 | output, err2 := ps.CombinedOutput() 108 | if err2 != nil { 109 | return errors.New(fmt.Sprintf("删除证书时发生错误,%v\n", string(output))) 110 | } 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | type Config struct { 15 | BaseDir string 16 | Filename string 17 | FullPath string 18 | Existing bool 19 | Error error 20 | Debug bool 21 | } 22 | 23 | func New() (*Config, error) { 24 | exe, _ := os.Executable() 25 | exe_dir := filepath.Dir(exe) 26 | base_dir := exe_dir 27 | var candidates []string 28 | candidates = append(candidates, exe_dir) 29 | if _, caller_file, _, ok := runtime.Caller(1); ok { 30 | caller_dir := filepath.Dir(caller_file) 31 | candidates = append(candidates, caller_dir) 32 | } 33 | if _, this_file, _, ok2 := runtime.Caller(0); ok2 { 34 | cfg_dir := filepath.Dir(this_file) 35 | proj_root := filepath.Dir(cfg_dir) 36 | candidates = append(candidates, proj_root) 37 | } 38 | var config_filepath string 39 | var has_config bool 40 | for _, dir := range candidates { 41 | p := filepath.Join(dir, "config.yaml") 42 | if _, err := os.Stat(p); err == nil { 43 | base_dir = dir 44 | config_filepath = p 45 | has_config = true 46 | break 47 | } 48 | } 49 | filename := "config.yaml" 50 | if config_filepath == "" { 51 | config_filepath = filepath.Join(base_dir, filename) 52 | } 53 | viper.SetConfigFile(config_filepath) 54 | c := &Config{ 55 | BaseDir: base_dir, 56 | Filename: filename, 57 | FullPath: config_filepath, 58 | Existing: has_config, 59 | } 60 | return c, nil 61 | } 62 | 63 | func (c *Config) LoadConfig() error { 64 | if c.Existing { 65 | // config.FilePath = config_filepath 66 | if err := viper.ReadInConfig(); err != nil { 67 | var nf viper.ConfigFileNotFoundError 68 | if !(errors.As(err, &nf) || errors.Is(err, os.ErrNotExist)) { 69 | c.Error = err 70 | return err 71 | } 72 | } 73 | } 74 | return nil 75 | } 76 | 77 | // GetDebugInfo returns debug information about how the base directory was determined 78 | func (c *Config) GetDebugInfo() map[string]string { 79 | exe, _ := os.Executable() 80 | exe_dir := filepath.Dir(exe) 81 | 82 | info := map[string]string{ 83 | "executable": exe, 84 | "exe_dir": exe_dir, 85 | "base_dir": c.BaseDir, 86 | "config_path": c.FullPath, 87 | "config_exists": fmt.Sprintf("%v", c.Existing), 88 | } 89 | 90 | // Determine run mode 91 | if filepath.Base(exe_dir) == "exe" || strings.Contains(exe, "go-build") { 92 | info["run_mode"] = "go run (development)" 93 | } else { 94 | info["run_mode"] = "compiled binary" 95 | } 96 | 97 | return info 98 | } 99 | 100 | func (c *Config) Update(key string, value interface{}) { 101 | viper.Set(key, value) 102 | } 103 | 104 | func (c *Config) Save() error { 105 | return viper.WriteConfigAs(c.FullPath) 106 | } 107 | 108 | func (c *Config) GetAll() map[string]interface{} { 109 | return viper.AllSettings() 110 | } 111 | 112 | func (c *Config) Get(key string) interface{} { 113 | return viper.Get(key) 114 | } 115 | 116 | // Typed getters with dotted path support, e.g. "a.b.c" 117 | func (c *Config) GetString(path string) string { return viper.GetString(path) } 118 | func (c *Config) GetInt(path string) int { return viper.GetInt(path) } 119 | func (c *Config) GetBool(path string) bool { return viper.GetBool(path) } 120 | func (c *Config) GetFloat64(path string) float64 { return viper.GetFloat64(path) } 121 | 122 | func EnsureDirIfMissing(path string) error { 123 | _, err := os.Stat(path) 124 | if err == nil { 125 | return nil 126 | } 127 | if os.IsNotExist(err) { 128 | return os.MkdirAll(path, 0755) 129 | } 130 | return err 131 | } 132 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | import { readdirSync } from "node:fs"; 3 | import { join, dirname } from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | // 动态读取 releases 目录生成发布日志项 7 | function getReleaseItems() { 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | const releasesDir = join(__dirname, "../releases"); 10 | const files = readdirSync(releasesDir); 11 | 12 | return files 13 | .filter((file: string) => file.endsWith(".md")) 14 | .map((file: string) => file.replace(".md", "")) 15 | .sort((a: string, b: string) => b.localeCompare(a)) // 按日期倒序排列 16 | .map((date: string) => ({ 17 | text: `v${date}`, 18 | link: `/releases/${date}`, 19 | })); 20 | } 21 | 22 | // 获取最新的 release 日期 23 | function getLatestRelease() { 24 | const __dirname = dirname(fileURLToPath(import.meta.url)); 25 | const releasesDir = join(__dirname, "../releases"); 26 | const files = readdirSync(releasesDir); 27 | 28 | const dates = files 29 | .filter((file: string) => file.endsWith(".md")) 30 | .map((file: string) => file.replace(".md", "")) 31 | .sort((a: string, b: string) => b.localeCompare(a)); 32 | 33 | return dates[0] || "251201"; // 如果没有文件,返回默认值 34 | } 35 | 36 | export default defineConfig({ 37 | lang: "zh-CN", 38 | title: "wx_channels_download", 39 | description: "微信视频号下载工具文档", 40 | base: "/wx_channels_download/", 41 | lastUpdated: true, 42 | themeConfig: { 43 | nav: [ 44 | { text: "首页", link: "/" }, 45 | { text: "Releases", link: `/releases/${getLatestRelease()}` }, 46 | { text: "FAQ", link: "/faq/button_inject_failed" }, 47 | ], 48 | sidebar: [ 49 | { 50 | text: "开始使用", 51 | items: [ 52 | { text: "下载并启用", link: "/guide/start" }, 53 | { text: "macOS 启用", link: "/guide/macos" }, 54 | { text: "使用步骤", link: "/guide/step" }, 55 | ], 56 | }, 57 | { 58 | text: "功能", 59 | items: [ 60 | { text: "长视频下载", link: "/feature/long_video" }, 61 | { text: "指定文件名", link: "/feature/filename" }, 62 | { text: "mp3下载", link: "/feature/mp3" }, 63 | { text: "直播下载", link: "/feature/live" }, 64 | { text: "自定义菜单", link: "/feature/custom-menu" }, 65 | { text: "监听事件", link: "/feature/event" }, 66 | ], 67 | }, 68 | { 69 | text: "命令行", 70 | items: [ 71 | { text: "代理服务", link: "/cli/proxy" }, 72 | { text: "下载", link: "/cli/download" }, 73 | { text: "解密", link: "/cli/decrypt" }, 74 | { text: "删除证书", link: "/cli/uninstall" }, 75 | { text: "查看版本", link: "/cli/version" }, 76 | ], 77 | }, 78 | { 79 | text: "配置", 80 | items: [ 81 | { text: "下载", link: "/config/download" }, 82 | { text: "代理", link: "/config/proxy" }, 83 | { text: "脚本", link: "/config/script" }, 84 | { text: "视频号", link: "/config/channel" }, 85 | ], 86 | }, 87 | { 88 | text: "常见问题", 89 | items: [ 90 | { text: "注入下载按钮失败", link: "/faq/button_inject_failed" }, 91 | { text: "下载卡住", link: "/faq/download_stuck" }, 92 | { text: "解密失败", link: "/faq/decrypt_fail" }, 93 | { text: "网络无法访问", link: "/faq/network_failed" }, 94 | { text: "PowerShell", link: "/faq/powershell" }, 95 | ], 96 | }, 97 | { 98 | text: "发布日志", 99 | items: getReleaseItems(), 100 | }, 101 | ], 102 | socialLinks: [ 103 | { icon: "github", link: "https://github.com/ltaoo/wx_channels_download" }, 104 | ], 105 | outline: "deep", 106 | }, 107 | }); 108 | -------------------------------------------------------------------------------- /pkg/decrypt/decrypt.go: -------------------------------------------------------------------------------- 1 | package decrypt 2 | 3 | // 代码来自 https://github.com/Hanson/WechatSphDecrypt/blob/main/decrypt.go 4 | 5 | import ( 6 | "encoding/binary" 7 | ) 8 | 9 | type RandCtx64 struct { 10 | RandCnt uint64 11 | Seed [256]uint64 12 | MM [256]uint64 13 | AA uint64 14 | BB uint64 15 | CC uint64 16 | } 17 | 18 | func CreateISAacInst(encKey uint64) *RandCtx64 { 19 | ctx := &RandCtx64{ 20 | RandCnt: 255, 21 | AA: 0, 22 | BB: 0, 23 | CC: 0, 24 | } 25 | rand64Init(ctx, encKey) 26 | return ctx 27 | } 28 | 29 | func (ctx *RandCtx64) ISAacRandom() uint64 { 30 | result := ctx.Seed[ctx.RandCnt] 31 | if ctx.RandCnt == 0 { 32 | ctx.isAAC64() 33 | ctx.RandCnt = 255 34 | } else { 35 | ctx.RandCnt-- 36 | } 37 | return result 38 | } 39 | 40 | func rand64Init(ctx *RandCtx64, encKey uint64) { 41 | const golden = uint64(0x9e3779b97f4a7c13) 42 | a, b, c, d := golden, golden, golden, golden 43 | e, f, g, h := golden, golden, golden, golden 44 | 45 | ctx.Seed[0] = encKey 46 | for i := 1; i < 256; i++ { 47 | ctx.Seed[i] = 0 48 | } 49 | 50 | for i := 0; i < 4; i++ { 51 | mix(&a, &b, &c, &d, &e, &f, &g, &h) 52 | } 53 | 54 | for i := 0; i < 256; i += 8 { 55 | a += ctx.Seed[i] 56 | b += ctx.Seed[i+1] 57 | c += ctx.Seed[i+2] 58 | d += ctx.Seed[i+3] 59 | e += ctx.Seed[i+4] 60 | f += ctx.Seed[i+5] 61 | g += ctx.Seed[i+6] 62 | h += ctx.Seed[i+7] 63 | mix(&a, &b, &c, &d, &e, &f, &g, &h) 64 | ctx.MM[i] = a 65 | ctx.MM[i+1] = b 66 | ctx.MM[i+2] = c 67 | ctx.MM[i+3] = d 68 | ctx.MM[i+4] = e 69 | ctx.MM[i+5] = f 70 | ctx.MM[i+6] = g 71 | ctx.MM[i+7] = h 72 | } 73 | 74 | for i := 0; i < 256; i += 8 { 75 | a += ctx.MM[i] 76 | b += ctx.MM[i+1] 77 | c += ctx.MM[i+2] 78 | d += ctx.MM[i+3] 79 | e += ctx.MM[i+4] 80 | f += ctx.MM[i+5] 81 | g += ctx.MM[i+6] 82 | h += ctx.MM[i+7] 83 | mix(&a, &b, &c, &d, &e, &f, &g, &h) 84 | ctx.MM[i] = a 85 | ctx.MM[i+1] = b 86 | ctx.MM[i+2] = c 87 | ctx.MM[i+3] = d 88 | ctx.MM[i+4] = e 89 | ctx.MM[i+5] = f 90 | ctx.MM[i+6] = g 91 | ctx.MM[i+7] = h 92 | } 93 | 94 | ctx.isAAC64() 95 | } 96 | 97 | func (ctx *RandCtx64) isAAC64() { 98 | ctx.CC++ 99 | ctx.BB += ctx.CC 100 | 101 | for i := 0; i < 256; i++ { 102 | switch i % 4 { 103 | case 0: 104 | ctx.AA = ^(ctx.AA ^ (ctx.AA << 21)) 105 | case 1: 106 | ctx.AA ^= ctx.AA >> 5 107 | case 2: 108 | ctx.AA ^= ctx.AA << 12 109 | case 3: 110 | ctx.AA ^= ctx.AA >> 33 111 | } 112 | 113 | ctx.AA += ctx.MM[(i+128)%256] 114 | x := ctx.MM[i] 115 | y := ctx.MM[(x>>3)%256] + ctx.AA + ctx.BB 116 | ctx.MM[i] = y 117 | ctx.BB = ctx.MM[(y>>11)%256] + x 118 | ctx.Seed[i] = ctx.BB 119 | } 120 | } 121 | 122 | func mix(a, b, c, d, e, f, g, h *uint64) { 123 | *a -= *e 124 | *f ^= *h >> 9 125 | *h += *a 126 | *b -= *f 127 | *g ^= *a << 9 128 | *a += *b 129 | *c -= *g 130 | *h ^= *b >> 23 131 | *b += *c 132 | *d -= *h 133 | *a ^= *c << 15 134 | *c += *d 135 | *e -= *a 136 | *b ^= *d >> 14 137 | *d += *e 138 | *f -= *b 139 | *c ^= *e << 20 140 | *e += *f 141 | *g -= *c 142 | *d ^= *f >> 17 143 | *f += *g 144 | *h -= *d 145 | *e ^= *g << 14 146 | *g += *h 147 | } 148 | 149 | func DecryptData(data []byte, encLen uint32, key uint64) { 150 | if len(data) == 0 || uint32(len(data)) < encLen { 151 | return 152 | } 153 | 154 | aaInst := CreateISAacInst(key) 155 | 156 | for i := uint32(0); i < encLen; i += 8 { 157 | randNumber := aaInst.ISAacRandom() 158 | tempNumber := make([]byte, 8) 159 | binary.BigEndian.PutUint64(tempNumber, randNumber) 160 | 161 | for j := 0; j < 8; j++ { 162 | realIndex := i + uint32(j) 163 | if realIndex >= encLen { 164 | return 165 | } 166 | data[realIndex] ^= tempNumber[j] 167 | } 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/fatih/color" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | 14 | "wx_channel/internal/download" 15 | "wx_channel/internal/interceptor" 16 | "wx_channel/internal/manager" 17 | "wx_channel/pkg/certificate" 18 | ) 19 | 20 | var ( 21 | Version string 22 | device string 23 | hostname string 24 | port int 25 | debug bool 26 | CertFiles *certificate.CertFileAndKeyFile 27 | Settings *interceptor.InterceptorSettings 28 | ) 29 | 30 | var root_cmd = &cobra.Command{ 31 | Use: "wx_video_download", 32 | Short: "启动下载程序", 33 | Long: "\n启动后将对网络请求进行代理,在微信视频号详情页面注入下载按钮", 34 | PreRun: func(cmd *cobra.Command, args []string) { 35 | }, 36 | Run: func(cmd *cobra.Command, args []string) { 37 | Settings.Version = Version 38 | Settings.DebugShowError = viper.GetBool("debug.error") 39 | Settings.ProxySetSystem = viper.GetBool("proxy.system") 40 | Settings.ProxyServerHostname = viper.GetString("proxy.hostname") 41 | Settings.ProxyServerPort = viper.GetInt("proxy.port") 42 | root_command(Settings) 43 | }, 44 | } 45 | 46 | func init() { 47 | root_cmd.PersistentFlags().StringVar(&device, "dev", "", "代理服务器网络设备") 48 | root_cmd.PersistentFlags().StringVar(&hostname, "hostname", "127.0.0.1", "代理服务器主机名") 49 | root_cmd.PersistentFlags().IntVar(&port, "port", 2023, "代理服务器端口") 50 | root_cmd.PersistentFlags().BoolVar(&debug, "debug", false, "是否开启调试") 51 | 52 | viper.BindPFlag("debug.error", root_cmd.PersistentFlags().Lookup("debug")) 53 | viper.BindPFlag("proxy.hostname", root_cmd.PersistentFlags().Lookup("hostname")) 54 | viper.BindPFlag("proxy.port", root_cmd.PersistentFlags().Lookup("port")) 55 | } 56 | 57 | func Execute(app_ver string, cert *certificate.CertFileAndKeyFile, settings *interceptor.InterceptorSettings) error { 58 | cobra.MousetrapHelpText = "" 59 | 60 | Version = app_ver 61 | CertFiles = cert 62 | Settings = settings 63 | 64 | return root_cmd.Execute() 65 | } 66 | func Register(cmd *cobra.Command) { 67 | root_cmd.AddCommand(cmd) 68 | } 69 | 70 | type RootCommandArg struct { 71 | } 72 | 73 | func root_command(args *interceptor.InterceptorSettings) { 74 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 75 | defer stop() 76 | 77 | fmt.Printf("\nv%v\n", Version) 78 | fmt.Printf("问题反馈 https://github.com/ltaoo/wx_channels_download/issues\n\n") 79 | if args.FilePath != "" { 80 | fmt.Printf("配置文件 %s\n", args.FilePath) 81 | } 82 | if script_byte := viper.Get("globalUserScript"); script_byte != nil { 83 | fmt.Printf("存在全局脚本\n\n") 84 | } 85 | mgr := manager.NewServerManager() 86 | 87 | interceptorServer := interceptor.NewInterceptorServer(args, CertFiles) 88 | mgr.RegisterServer(interceptorServer) 89 | downloadServer := download.NewDownloadServer(Settings.DownloadLocalServerAddr) 90 | mgr.RegisterServer(downloadServer) 91 | 92 | cleanup := func() { 93 | fmt.Printf("\n正在关闭服务...\n") 94 | if err := mgr.StopServer("interceptor"); err != nil { 95 | fmt.Printf("⚠️ 关闭代理服务失败: %v\n", err) 96 | } 97 | if err := mgr.StopServer("download"); err != nil { 98 | fmt.Printf("⚠️ 关闭下载服务失败: %v\n", err) 99 | } 100 | color.Green("服务已关闭") 101 | } 102 | if args.DownloadLocalServerEnabled { 103 | if err := mgr.StartServer("download"); err != nil { 104 | fmt.Printf("ERROR 启动下载服务失败: %v\n", err.Error()) 105 | cleanup() 106 | os.Exit(1) 107 | } 108 | color.Green("下载服务启动成功") 109 | } 110 | if err := mgr.StartServer("interceptor"); err != nil { 111 | fmt.Printf("ERROR 启动代理服务失败: %v\n", err.Error()) 112 | cleanup() 113 | os.Exit(1) 114 | } 115 | color.Green("代理服务启动成功") 116 | 117 | if !args.ProxySetSystem { 118 | color.Red(fmt.Sprintf("当前未设置系统代理,请通过软件将流量转发至 %v", interceptorServer.Addr())) 119 | color.Red("设置成功后再打开视频号页面下载") 120 | } else { 121 | color.Green(fmt.Sprintf("已修改系统代理为 %v", interceptorServer.Addr())) 122 | color.Green("请打开需要下载的视频号页面进行下载") 123 | } 124 | fmt.Println("\n按 Ctrl+C 退出...") 125 | <-ctx.Done() 126 | cleanup() 127 | } 128 | -------------------------------------------------------------------------------- /pkg/proxy/proxy_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package proxy 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | // EnableProxyInLinux sets GNOME / Deepin proxy with correct user context 15 | func enable_proxy(ps ProxySettings) error { 16 | if ps.Hostname == "" { 17 | ps.Hostname = "127.0.0.1" 18 | } 19 | if ps.Port == "" { 20 | ps.Port = "8888" 21 | } 22 | portInt, err := strconv.Atoi(ps.Port) 23 | if err != nil { 24 | return fmt.Errorf("无效端口: %s", ps.Port) 25 | } 26 | 27 | // 获取当前图形用户(非 root) 28 | loginUserBytes, err := exec.Command("logname").Output() 29 | if err != nil { 30 | return fmt.Errorf("获取登录用户失败(logname): %v", err) 31 | } 32 | loginUser := strings.TrimSpace(string(loginUserBytes)) 33 | 34 | // 获取 UID(用于构造 DBUS 路径) 35 | uidBytes, err := exec.Command("id", "-u", loginUser).Output() 36 | if err != nil { 37 | return fmt.Errorf("获取 UID 失败: %v", err) 38 | } 39 | uid := strings.TrimSpace(string(uidBytes)) 40 | 41 | // 构造 DBUS_SESSION_BUS_ADDRESS 42 | dbusEnv := "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + uid + "/bus" 43 | 44 | // 检测桌面环境(是否 Deepin) 45 | desktopEnv := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")) 46 | isDeepin := strings.Contains(desktopEnv, "deepin") 47 | 48 | // 构造代理设置命令 49 | cmds := [][]string{ 50 | {"gsettings", "set", "org.gnome.system.proxy", "mode", "manual"}, 51 | {"gsettings", "set", "org.gnome.system.proxy.http", "host", ps.Hostname}, 52 | {"gsettings", "set", "org.gnome.system.proxy.http", "port", fmt.Sprintf("%d", portInt)}, 53 | {"gsettings", "set", "org.gnome.system.proxy.https", "host", ps.Hostname}, 54 | {"gsettings", "set", "org.gnome.system.proxy.https", "port", fmt.Sprintf("%d", portInt)}, 55 | } 56 | 57 | if isDeepin { 58 | cmds = append(cmds, []string{ 59 | "dbus-send", "--session", "--dest=com.deepin.daemon.Proxy", 60 | "--type=method_call", "/com/deepin/daemon/Proxy", 61 | "com.deepin.daemon.Proxy.Apply", 62 | }) 63 | } 64 | 65 | // 用登录用户执行所有命令(带上 DBUS 环境) 66 | for _, c := range cmds { 67 | fullCmd := append([]string{"-u", loginUser, "env", dbusEnv, c[0]}, c[1:]...) 68 | cmd := exec.Command("sudo", fullCmd...) 69 | output, err := cmd.CombinedOutput() 70 | if err != nil { 71 | return fmt.Errorf("命令失败: sudo %s\n错误: %v\n输出: %s", strings.Join(fullCmd, " "), err, string(output)) 72 | } 73 | } 74 | 75 | fmt.Println("✅ 已成功设置系统代理(Linux GNOME / Deepin)") 76 | return nil 77 | } 78 | 79 | // DisableProxyInLinux 关闭 GNOME / Deepin 的系统代理 80 | func disable_proxy(arg ProxySettings) error { 81 | loginUserBytes, err := exec.Command("logname").Output() 82 | if err != nil { 83 | return fmt.Errorf("获取登录用户失败(logname): %v", err) 84 | } 85 | loginUser := strings.TrimSpace(string(loginUserBytes)) 86 | 87 | uidBytes, err := exec.Command("id", "-u", loginUser).Output() 88 | if err != nil { 89 | return fmt.Errorf("获取 UID 失败: %v", err) 90 | } 91 | uid := strings.TrimSpace(string(uidBytes)) 92 | dbusEnv := "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + uid + "/bus" 93 | 94 | cmds := [][]string{ 95 | {"gsettings", "set", "org.gnome.system.proxy", "mode", "none"}, 96 | } 97 | 98 | // 检测 Deepin 环境,添加刷新命令 99 | desktopEnv := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP")) 100 | isDeepin := strings.Contains(desktopEnv, "deepin") 101 | if isDeepin { 102 | cmds = append(cmds, []string{ 103 | "dbus-send", "--session", "--dest=com.deepin.daemon.Proxy", 104 | "--type=method_call", "/com/deepin/daemon/Proxy", 105 | "com.deepin.daemon.Proxy.Apply", 106 | }) 107 | } 108 | 109 | // 执行每条命令 110 | for _, c := range cmds { 111 | fullCmd := append([]string{"-u", loginUser, "env", dbusEnv, c[0]}, c[1:]...) 112 | cmd := exec.Command("sudo", fullCmd...) 113 | output, err := cmd.CombinedOutput() 114 | if err != nil { 115 | return fmt.Errorf("关闭代理命令失败: sudo %s\n错误: %v\n输出: %s", strings.Join(fullCmd, " "), err, string(output)) 116 | } 117 | } 118 | 119 | fmt.Println("✅ 已关闭系统代理(Linux)") 120 | return nil 121 | } 122 | 123 | func get_network_interfaces() (*HardwarePort, error) { 124 | return nil, errors.New("not support") 125 | } 126 | -------------------------------------------------------------------------------- /docs/.vitepress/components/DownloadButton.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 118 | 119 | 127 | -------------------------------------------------------------------------------- /internal/application/application.go: -------------------------------------------------------------------------------- 1 | // deprecated 2 | package application 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/fatih/color" 14 | 15 | "wx_channel/pkg/certificate" 16 | "wx_channel/pkg/decrypt" 17 | "wx_channel/pkg/proxy" 18 | ) 19 | 20 | type Biz struct { 21 | Version string 22 | Debug bool 23 | } 24 | 25 | func NewBiz(version string) *Biz { 26 | return &Biz{ 27 | Version: version, 28 | // Files: files, 29 | } 30 | } 31 | func (a *Biz) SetDebug(debug bool) { 32 | a.Debug = debug 33 | } 34 | 35 | func (a *Biz) UninstallCertificate(cert_file_name string) { 36 | settings := proxy.ProxySettings{} 37 | if err := proxy.DisableProxy(settings); err != nil { 38 | fmt.Printf("\nERROR 取消代理失败 %v\n", err.Error()) 39 | return 40 | } 41 | if err := certificate.UninstallCertificate(cert_file_name); err != nil { 42 | fmt.Printf("\nERROR 删除根证书失败 %v\n", err.Error()) 43 | return 44 | } 45 | color.Green(fmt.Sprintf("\n\n删除根证书 '%v' 成功\n", cert_file_name)) 46 | } 47 | 48 | type DecryptCOmmandArgs struct { 49 | FilePath string 50 | DecryptKey int 51 | } 52 | 53 | func (a *Biz) DecryptCannelFile(args DecryptCOmmandArgs) error { 54 | length := uint32(131072) 55 | key := uint64(args.DecryptKey) 56 | data, err := os.ReadFile(args.FilePath) 57 | if err != nil { 58 | return fmt.Errorf("[ERROR]读取已下载的文件失败 %v\n", err.Error()) 59 | } 60 | decrypt.DecryptData(data, length, key) 61 | err = os.WriteFile(args.FilePath, data, 0644) 62 | if err != nil { 63 | return fmt.Errorf("[ERROR]写入文件失败 %v\n", err.Error()) 64 | } 65 | return nil 66 | } 67 | 68 | type DownloadCommandArgs struct { 69 | URL string 70 | Filename string 71 | DecryptKey int 72 | } 73 | 74 | func (a *Biz) DownloadChannelFile(args DownloadCommandArgs) { 75 | resp, err := http.Get(args.URL) 76 | if err != nil { 77 | fmt.Printf("[ERROR]下载失败 %v\n", err.Error()) 78 | return 79 | } 80 | defer resp.Body.Close() 81 | homedir, err := os.UserHomeDir() 82 | if err != nil { 83 | fmt.Printf("[ERROR]获取下载路径失败 %v\n", err.Error()) 84 | return 85 | } 86 | tmp_filename := "wx_" + strconv.Itoa(int(time.Now().Unix())) 87 | tmp_dest_filepath := path.Join(homedir, "Downloads", tmp_filename) 88 | dest_filepath := path.Join(homedir, "Downloads", args.Filename) 89 | file, err := os.Create(tmp_dest_filepath) 90 | if err != nil { 91 | fmt.Printf("[ERROR]下载文件失败 %v\n", err.Error()) 92 | os.Exit(0) 93 | return 94 | } 95 | defer file.Close() 96 | content_length := resp.Header.Get("Content-Length") 97 | total_size := int64(-1) 98 | if content_length != "" { 99 | total_size, _ = strconv.ParseInt(content_length, 10, 64) 100 | } 101 | buf := make([]byte, 32*1024) // 32KB buffer 102 | var downloaded int64 = 0 103 | for { 104 | n, err := resp.Body.Read(buf) 105 | if n > 0 { 106 | _, werr := file.Write(buf[:n]) 107 | if werr != nil { 108 | fmt.Printf("[ERROR]写入文件失败 %v\n", werr.Error()) 109 | return 110 | } 111 | downloaded += int64(n) 112 | if total_size > 0 { 113 | percent := float64(downloaded) / float64(total_size) * 100 114 | fmt.Printf("\r\033[K已下载: %d/%d 字节 (%.2f%%)", downloaded, total_size, percent) 115 | } else { 116 | fmt.Printf("\r\033[K已下载: %d 字节", downloaded) 117 | } 118 | } 119 | if err == io.EOF { 120 | break 121 | } 122 | if err != nil { 123 | fmt.Printf("[ERROR]下载文件失败2 %v\n", err.Error()) 124 | return 125 | } 126 | } 127 | fmt.Println() 128 | if args.DecryptKey != 0 { 129 | fmt.Printf("开始对文件解密 %s", tmp_dest_filepath) 130 | length := uint32(131072) 131 | enclen_str := resp.Header.Get("X-enclen") 132 | if enclen_str != "" { 133 | v, err := strconv.ParseUint(enclen_str, 10, 32) 134 | if err == nil { 135 | length = uint32(v) 136 | } 137 | } 138 | key := uint64(args.DecryptKey) 139 | data, err := os.ReadFile(tmp_dest_filepath) 140 | if err != nil { 141 | fmt.Printf("[ERROR]读取已下载的文件失败 %v\n", err.Error()) 142 | return 143 | } 144 | decrypt.DecryptData(data, length, key) 145 | err = os.WriteFile(dest_filepath, data, 0644) 146 | if err != nil { 147 | fmt.Printf("[ERROR]写入文件失败 %v\n", err.Error()) 148 | return 149 | } 150 | file.Close() 151 | err = os.Remove(tmp_dest_filepath) 152 | if err != nil { 153 | if os.IsNotExist(err) { 154 | fmt.Println("[ERROR]临时文件不存在") 155 | } else if os.IsPermission(err) { 156 | fmt.Println("[ERROR]没有权限删除临时文件") 157 | } else { 158 | fmt.Printf("[ERROR]临时文件删除失败 %v\n", err.Error()) 159 | } 160 | } 161 | fmt.Printf("解密完成,文件路径为 %s\n", dest_filepath) 162 | return 163 | } 164 | file.Close() 165 | err = os.Rename(tmp_dest_filepath, dest_filepath) 166 | if err != nil { 167 | fmt.Printf("[ERROR]重命名文件失败 %v\n", err.Error()) 168 | return 169 | } 170 | fmt.Printf("下载完成,件路径为 %s\n", dest_filepath) 171 | } 172 | -------------------------------------------------------------------------------- /internal/interceptor/inject/src/components.js: -------------------------------------------------------------------------------- 1 | const inserted_style = ``; 81 | 82 | document.head.insertAdjacentHTML("beforeend", inserted_style); 83 | 84 | var download_icon1 = ``; 85 | var download_icon2 = 86 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAB9ElEQVR4AeyVa3LCMAwGk16scDLCyaAnc3ddy2PnzTDDrzIW8UP6VhIh+Ro+/PkHbjY8pXTZPNw5ON1SAdijWELTOcs8Jr4n9g7HKWARe6AWVT2ZhzEdbnzdih/T7XEILCIKCriO4zi3EfkrdseEWj3T9bELbGHjH0joQomzJ2ZLhQ7E2Y2FnxubQIJsn5UNiFmB/ruGX0AvxDtf+K8Cca4wInLWXE+NArUTOdl50AIIzMxsidB7EZjHnVqjpUbn2wFxEGZmZujN4boLcIGffwmTcrlm0RW1uvMOyEl2oCphQtlaHYvMWy/ijdUWv2UFknVUE9m1Gi/PgXqjCfWvUhOsQBSjugCz9faI5LO2ai3QdTg478wOYI6aLQtbxiXVvTaIKq1Qq+cZSETdaANmcwPdam+WmB/GByMDSyaKffu1ZsWn7UBAjv46P61eBpYNKwiRstVfgPr7ttAjmAK5CGLVH1pgzoTSFdVx1QicsBi7vmhZgJZhClYgCgZ70N3GOr1hcXfmYtSpQBdYtMsniQmw9fqwMswbyuq6tndAqrTCgFopcSnDU0r5rX5w1VeQtoCZegd0A2j+jZgLNgEDbc0Z01czzsfjhE43FsA4LWCD4o3uo+rQiHMYJzTk6nUTWD2YoOAb/ZThvjtOAXcVXjz8OPAXAAD//5jl7kwAAAAGSURBVAMA8H8MSLsb1AoAAAAASUVORK5CYII="; 87 | var download_icon3 = ``; 88 | var download_icon4 = ``; 89 | 90 | /** 91 | * @returns {HTMLDivElement} 92 | */ 93 | function download_btn1() { 94 | const icon_download_html = download_icon1; 95 | var $icon = document.createElement("div"); 96 | $icon.innerHTML = `
${icon_download_html}下载
`; 97 | return $icon.firstChild; 98 | } 99 | /** 100 | * @returns {HTMLDivElement} 101 | */ 102 | function download_btn2() { 103 | var icon_download_html = `
`; 104 | var $icon = document.createElement("div"); 105 | $icon.innerHTML = `
${icon_download_html}
下载
`; 106 | return $icon.firstChild; 107 | } 108 | /** 109 | * @returns {HTMLDivElement} 110 | */ 111 | function download_btn3() { 112 | var icon_download_html = download_icon3; 113 | var $icon = document.createElement("div"); 114 | $icon.innerHTML = `
${icon_download_html}
`; 115 | return $icon.firstChild; 116 | } 117 | /** 118 | * @returns {HTMLDivElement} 119 | */ 120 | function download_btn4() { 121 | var icon_download_html = download_icon4; 122 | var $icon = document.createElement("div"); 123 | $icon.innerHTML = `
${icon_download_html}
`; 124 | return $icon.firstChild; 125 | } 126 | 127 | /** 128 | * @param {DropdownMenuItemPayload[]} items 129 | * @param {{ hide: () => void }} $dropdown 130 | */ 131 | function render_extra_menu_items(items, $dropdown) { 132 | if (!window.Weui) { 133 | return []; 134 | } 135 | const { MenuItem } = window.Weui; 136 | return items 137 | .filter((item) => { 138 | return item.label && item.onClick; 139 | }) 140 | .map((item) => { 141 | return MenuItem({ 142 | label: item.label, 143 | async onClick(event) { 144 | await item.onClick(event); 145 | $dropdown.hide(); 146 | }, 147 | }); 148 | }); 149 | } 150 | -------------------------------------------------------------------------------- /internal/interceptor/inject/src/error.js: -------------------------------------------------------------------------------- 1 | class ErrorModal { 2 | constructor() { 3 | this.mounted = false; 4 | } 5 | insertElements() { 6 | // 创建样式 7 | var style = document.createElement("style"); 8 | style.textContent = ` 9 | .error-modal { 10 | position: fixed; 11 | top: 0; 12 | left: 0; 13 | width: 100%; 14 | height: 100%; 15 | background-color: rgba(0, 0, 0, 0.5); 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | z-index: 1000; 20 | opacity: 0; 21 | visibility: hidden; 22 | transition: opacity 0.3s ease, visibility 0.3s ease; 23 | } 24 | .error-modal.active { 25 | opacity: 1; 26 | visibility: visible; 27 | } 28 | .error-modal-content { 29 | background-color: #fff; 30 | border-radius: 8px; 31 | width: 90%; 32 | max-width: 400px; 33 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 34 | transform: translateY(-50px); 35 | transition: transform 0.3s ease; 36 | } 37 | 38 | .error-modal.active .error-modal-content { 39 | transform: translateY(0); 40 | } 41 | 42 | .error-modal-header { 43 | padding: 8px 12px; 44 | border-bottom: 1px solid #eee; 45 | display: flex; 46 | justify-content: space-between; 47 | align-items: center; 48 | } 49 | 50 | .error-modal-title { 51 | margin: 0; 52 | font-size: 1.25rem; 53 | color: #f44336; 54 | } 55 | 56 | .error-modal-close { 57 | background: none; 58 | border: none; 59 | font-size: 1.5rem; 60 | cursor: pointer; 61 | color: #666; 62 | padding: 0; 63 | line-height: 1; 64 | } 65 | 66 | .error-modal-close:hover { 67 | color: #333; 68 | } 69 | 70 | .error-modal-body { 71 | overflow-y: auto; 72 | padding: 12px; 73 | color: #333; 74 | line-height: 1.5; 75 | max-height:400px; 76 | } 77 | 78 | .error-modal-footer { 79 | padding: 8px 12px; 80 | border-top: 1px solid #eee; 81 | display: flex; 82 | justify-content: flex-end; 83 | } 84 | 85 | .error-modal-confirm { 86 | background-color: #f44336; 87 | color: white; 88 | border: none; 89 | padding: 8px 8px; 90 | border-radius: 4px; 91 | cursor: pointer; 92 | font-size: 0.875rem; 93 | transition: background-color 0.2s ease; 94 | } 95 | 96 | .error-modal-confirm:hover { 97 | background-color: #d32f2f; 98 | } 99 | 100 | @media (max-width: 480px) { 101 | .error-modal-content { 102 | width: 95%; 103 | } 104 | 105 | .error-modal-header, .error-modal-body, .error-modal-footer { 106 | padding: 12px 16px; 107 | } 108 | } 109 | `; 110 | document.head.appendChild(style); 111 | 112 | // 创建 DOM 结构 113 | var modal = document.createElement("div"); 114 | modal.id = "error-modal"; 115 | modal.className = "error-modal"; 116 | 117 | modal.innerHTML = ` 118 |
119 |
120 |

错误提示

121 | 122 |
123 |
124 |

这里显示错误信息

125 |
126 | 129 |
130 | `; 131 | document.body.appendChild(modal); 132 | } 133 | 134 | show(error) { 135 | if (this.mounted === false) { 136 | this.insertElements(); 137 | this.modal = document.getElementById("error-modal"); 138 | this.errorMessage = this.modal.querySelector(".error-message"); 139 | this.closeBtn = this.modal.querySelector(".error-modal-close"); 140 | this.confirmBtn = this.modal.querySelector(".error-modal-confirm"); 141 | this.closeBtn.addEventListener("click", () => this.hide()); 142 | this.confirmBtn.addEventListener("click", () => this.hide()); 143 | this.modal.addEventListener("click", (e) => { 144 | if (e.target === this.modal) { 145 | this.hide(); 146 | } 147 | }); 148 | this.mounted = true; 149 | } 150 | var text = 151 | typeof error === "string" ? error : error.message || "发生未知错误"; 152 | this.errorMessage.innerHTML = text; 153 | this.modal.classList.add("active"); 154 | document.body.style.overflow = "hidden"; 155 | } 156 | hide() { 157 | this.modal.classList.remove("active"); 158 | document.body.style.overflow = ""; 159 | } 160 | } 161 | 162 | window.errorModal = new ErrorModal(); 163 | var errors = []; 164 | window.addEventListener("error", function (event) { 165 | event.preventDefault(); 166 | var r = parse_error_stack(event.error.stack); 167 | if (r) { 168 | errors.push(r); 169 | } 170 | if (errors.length) { 171 | var text = render_errors(errors); 172 | window.errorModal.show(text); 173 | } 174 | }); 175 | window.addEventListener("unhandledrejection", function (event) { 176 | event.preventDefault(); 177 | var r = parse_error_stack(event.reason.stack); 178 | if (r) { 179 | errors.push(r); 180 | } 181 | if (errors.length) { 182 | var text = render_errors(errors); 183 | window.errorModal.show(text); 184 | } 185 | }); 186 | 187 | function render_errors(errors) { 188 | var result = []; 189 | for (let i = 0; i < errors.length; i += 1) { 190 | const e = errors[i]; 191 | var $type = document.createElement("div"); 192 | $type.style.cssText = "font-size: 18px"; 193 | $type.innerHTML = e.type; 194 | var $msg = document.createElement("div"); 195 | $msg.innerHTML = e.msg; 196 | /** @type {HTMLDivElement} */ 197 | var $source = document.createElement("div"); 198 | $source.style.cssText = "margin-left: 12px;"; 199 | $source.innerHTML = "at " + e.source; 200 | var $container = document.createElement("div"); 201 | $container.appendChild($type); 202 | $container.appendChild($msg); 203 | $container.appendChild($source); 204 | result.push($container.innerHTML); 205 | } 206 | return result.join(""); 207 | } 208 | function parse_error_stack(error_stack) { 209 | if (!error_stack) { 210 | return null; 211 | } 212 | var regexp = /^([a-zA-Z]{1,}):([\s\S]{1,})[\r\n ]{1,}at([\s\S]{1,})$/; 213 | var matched = error_stack.match(regexp); 214 | if (!matched) { 215 | return null; 216 | } 217 | return { 218 | type: matched[1], 219 | msg: matched[2], 220 | source: matched[3], 221 | }; 222 | } 223 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= 2 | github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 8 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 9 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 10 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 11 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 12 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 13 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 14 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 15 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 16 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 17 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 19 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 20 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 21 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 22 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 23 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 26 | github.com/ltaoo/echo v0.5.2 h1:hYawSpAhyfapfJb7Z8O/7BsOH2eGG26O5oHrf8w/Mdc= 27 | github.com/ltaoo/echo v0.5.2/go.mod h1:H+CK1PmdwBrOAB1ckMwL7RPd3KXgTNrKIPsxokuuvEw= 28 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 29 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 34 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 35 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 36 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 40 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 41 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 42 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 43 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 44 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 45 | github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 46 | github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 47 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= 48 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= 49 | github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= 50 | github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 51 | github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= 52 | github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 53 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 54 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 55 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 56 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 57 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 58 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 59 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 60 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 61 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 62 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 63 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 64 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 65 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 66 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 67 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 68 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 72 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 73 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 74 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 75 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 77 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /internal/interceptor/inject/src/eventbus.js: -------------------------------------------------------------------------------- 1 | var WXE = (() => { 2 | var eventbus = mitt(); 3 | var ChannelsEvents = { 4 | /** DOM 完全加载和解析 */ 5 | DOMContentLoaded: "DOMContentLoaded", 6 | /** DOM 加载完成前 */ 7 | DOMContentBeforeUnLoaded: "DOMContentBeforeUnLoaded", 8 | /** 所有资源加载完成 */ 9 | WindowLoaded: "WindowLoaded", 10 | /** 页面卸载完成 */ 11 | WindowUnLoaded: "WindowUnLoaded", 12 | /** 首页推荐获取到视频列表 */ 13 | PCFlowLoaded: "PCFlowLoaded", 14 | RecommendFeedsLoaded: "RecommendFeedsLoaded", 15 | UserFeedsLoaded: "UserFeedsLoaded", 16 | GotoNextFeed: "GotoNextFeed", 17 | GotoPrevFeed: "GotoPrevFeed", 18 | /** 获取到视频详情 */ 19 | FeedProfileLoaded: "OnFeedProfileLoaded", 20 | /** 获取到直播详情 */ 21 | LiveProfileLoaded: "OnLiveProfileLoaded", 22 | /** 视频下载之前 */ 23 | BeforeDownloadMedia: "BeforeDownloadMedia", 24 | /** 封面下载之前 */ 25 | BeforeDownloadCover: "BeforeDownloadCover", 26 | /** 视频下载完成 */ 27 | MediaDownloaded: "MediaDownloaded", 28 | /** MP3下载完成 */ 29 | MP3Downloaded: "MP3Downloaded", 30 | /** 加载了 feed */ 31 | Feed: "Feed", 32 | }; 33 | return { 34 | Events: ChannelsEvents, 35 | emit: eventbus.emit, 36 | /** DOM 完全加载和解析 */ 37 | onDOMContentLoaded(handler) { 38 | eventbus.on(ChannelsEvents.DOMContentLoaded, handler); 39 | return () => { 40 | eventbus.off(ChannelsEvents.DOMContentLoaded, handler); 41 | }; 42 | }, 43 | /** DOM 加载完成前 */ 44 | onDOMContentBeforeUnLoaded(handler) { 45 | eventbus.on(ChannelsEvents.DOMContentBeforeUnLoaded, handler); 46 | return () => { 47 | eventbus.off(ChannelsEvents.DOMContentBeforeUnLoaded, handler); 48 | }; 49 | }, 50 | /** 所有资源加载完成 */ 51 | onWindowLoaded(handler) { 52 | eventbus.on(ChannelsEvents.WindowLoaded, handler); 53 | return () => { 54 | eventbus.off(ChannelsEvents.WindowLoaded, handler); 55 | }; 56 | }, 57 | /** 页面卸载完成 */ 58 | onWindowUnLoaded(handler) { 59 | eventbus.on(ChannelsEvents.WindowUnLoaded, handler); 60 | return () => { 61 | eventbus.off(ChannelsEvents.WindowUnLoaded, handler); 62 | }; 63 | }, 64 | /** 65 | * 首页获取到视频列表 66 | * @param {(feeds: ChannelsFeed[]) => void} handler 67 | */ 68 | onPCFlowLoaded(handler) { 69 | eventbus.on(ChannelsEvents.PCFlowLoaded, handler); 70 | return () => { 71 | eventbus.off(ChannelsEvents.PCFlowLoaded, handler); 72 | }; 73 | }, 74 | /** 75 | * 首页推荐 切换到下一个视频 76 | * @param {(feed: ChannelsFeed) => void} handler 77 | */ 78 | onGotoNextFeed(handler) { 79 | eventbus.on(ChannelsEvents.GotoNextFeed, handler); 80 | return () => { 81 | eventbus.off(ChannelsEvents.GotoNextFeed, handler); 82 | }; 83 | }, 84 | /** 85 | * 首页推荐 切换到上一个视频 86 | * @param {(feed: ChannelsFeed) => void} handler 87 | */ 88 | onGotoPrevFeed(handler) { 89 | eventbus.on(ChannelsEvents.GotoPrevFeed, handler); 90 | return () => { 91 | eventbus.off(ChannelsEvents.GotoPrevFeed, handler); 92 | }; 93 | }, 94 | /** 95 | * 获取到推荐列表 96 | * @param {(feeds: ChannelsFeed[]) => void} handler 97 | */ 98 | onRecommendFeedsLoaded(handler) { 99 | eventbus.on(ChannelsEvents.RecommendFeedsLoaded, handler); 100 | return () => { 101 | eventbus.off(ChannelsEvents.RecommendFeedsLoaded, handler); 102 | }; 103 | }, 104 | /** 105 | * 获取到指定用户的部分视频列表 106 | * @param {(feeds: ChannelsFeed[]) => void} handler 107 | */ 108 | onUserFeedsLoaded(handler) { 109 | eventbus.on(ChannelsEvents.UserFeedsLoaded, handler); 110 | return () => { 111 | eventbus.off(ChannelsEvents.UserFeedsLoaded, handler); 112 | }; 113 | }, 114 | /** 115 | * 获取到视频详情 116 | * @param {(feed: ChannelsFeed) => void} handler 117 | */ 118 | onFetchFeedProfile(handler) { 119 | eventbus.on(ChannelsEvents.FeedProfileLoaded, handler); 120 | return () => { 121 | eventbus.off(ChannelsEvents.FeedProfileLoaded, handler); 122 | }; 123 | }, 124 | /** 125 | * 获取到直播详情 126 | * @param {(feed: ChannelsFeed) => void} handler 127 | */ 128 | onFetchLiveProfile(handler) { 129 | eventbus.on(ChannelsEvents.LiveProfileLoaded, handler); 130 | return () => { 131 | eventbus.off(ChannelsEvents.LiveProfileLoaded, handler); 132 | }; 133 | }, 134 | /** 135 | * 下载视频前 136 | * @param {(media: FeedProfilePayload) => void} handler 137 | */ 138 | beforeDownloadMedia(handler) { 139 | eventbus.on(ChannelsEvents.BeforeDownloadMedia, handler); 140 | return () => { 141 | eventbus.off(ChannelsEvents.BeforeDownloadMedia, handler); 142 | }; 143 | }, 144 | /** 145 | * 下载封面前 146 | * @param {(media: FeedProfilePayload) => void} handler 147 | */ 148 | beforeDownloadCover(handler) { 149 | eventbus.on(ChannelsEvents.BeforeDownloadCover, handler); 150 | return () => { 151 | eventbus.off(ChannelsEvents.BeforeDownloadCover, handler); 152 | }; 153 | }, 154 | /** 155 | * 视频下载完成 156 | * @param {(media: FeedProfilePayload) => void} handler 157 | */ 158 | onMediaDownloaded(handler) { 159 | eventbus.on(ChannelsEvents.MediaDownloaded, handler); 160 | return () => { 161 | eventbus.off(ChannelsEvents.MediaDownloaded, handler); 162 | }; 163 | }, 164 | /** 165 | * mp3 下载完成 166 | * @param {(media: FeedProfilePayload) => void} handler 167 | */ 168 | onMP3Downloaded(handler) { 169 | eventbus.on(ChannelsEvents.MP3Downloaded, handler); 170 | return () => { 171 | eventbus.off(ChannelsEvents.MP3Downloaded, handler); 172 | }; 173 | }, 174 | /** 175 | * 加载了 feed。包括首页推荐、上一个下一个;视频详情页;直播详情页 都会触发该事件 176 | * 可用于记录访问过的视频 177 | * @param {(feed: ChannelsFeed) => void} handler 178 | */ 179 | onFeed(handler) { 180 | eventbus.on(ChannelsEvents.Feed, handler); 181 | return () => { 182 | eventbus.off(ChannelsEvents.Feed, handler); 183 | }; 184 | }, 185 | }; 186 | })(); 187 | 188 | document.addEventListener("DOMContentLoaded", function () { 189 | WXE.emit(WXE.Events.DOMContentLoaded, { 190 | href: window.location.href, 191 | }); 192 | }); 193 | window.addEventListener("beforeunload", function () { 194 | // 用户即将离开页面时触发(DOM 还存在) 195 | WXE.emit(WXE.Events.DOMContentBeforeUnLoaded, { 196 | href: window.location.href, 197 | }); 198 | }); 199 | window.addEventListener("load", function () { 200 | WXE.emit(WXE.Events.WindowLoaded, { 201 | href: window.location.href, 202 | }); 203 | }); 204 | window.addEventListener("unload", function () { 205 | // 页面即将卸载时触发(DOM 即将被销毁) 206 | WXE.emit(WXE.Events.WindowUnLoaded, { 207 | href: window.location.href, 208 | }); 209 | }); 210 | 211 | WXE.onGotoNextFeed((feed) => { 212 | console.log("[eventbus.js]onGotoNextFeed", feed); 213 | WXE.emit(WXE.Events.Feed, feed); 214 | }); 215 | WXE.onGotoPrevFeed((feed) => { 216 | console.log("[eventbus.js]onGotoPrevFeed", feed); 217 | WXE.emit(WXE.Events.Feed, feed); 218 | }); 219 | console.log("[eventbus.js]before onFetchFeedProfile"); 220 | WXE.onFetchFeedProfile((feed) => { 221 | console.log("[eventbus.js]onFetchFeedProfile", feed); 222 | WXE.emit(WXE.Events.Feed, feed); 223 | }); 224 | WXE.onPCFlowLoaded((feeds) => { 225 | console.log("[eventbus.js]onPCFlowLoaded", feeds); 226 | WXE.emit(WXE.Events.Feed, feeds[0]); 227 | }); 228 | -------------------------------------------------------------------------------- /internal/interceptor/settings.go: -------------------------------------------------------------------------------- 1 | package interceptor 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | 8 | "github.com/spf13/viper" 9 | 10 | "wx_channel/config" 11 | ) 12 | 13 | type InterceptorSettings struct { 14 | Version string `json:"version"` 15 | FilePath string // 配置文件路径 16 | DownloadDefaultHighest bool `json:"defaultHighest"` // 默认下载最高画质 17 | DownloadFilenameTemplate string `json:"downloadFilenameTemplate"` // 下载文件名模板 18 | DownloadPauseWhenDownload bool `json:"downloadPauseWhenDownload"` // 下载时暂停播放 19 | DownloadLocalServerEnabled bool `json:"downloadLocalServerEnabled"` // 下载时是否使用本地服务器 20 | DownloadLocalServerAddr string `json:"downloadLocalServerAddr"` // 下载时本地服务器地址 21 | ProxyDevice string 22 | ProxySetSystem bool 23 | ProxyServerHostname string 24 | ProxyServerPort int 25 | PagespyEnabled bool 26 | PageppyServerProtocol string `json:"pagespyServerProtocol"` // pagespy调试地址协议,如 http 27 | PageppyServerAPI string `json:"pagespyServerAPI"` // pagespy调试地址,如 debug.weixin.qq.com 28 | DebugShowError bool 29 | ChannelDisableLocationToHome bool // 禁止从feed重定向到home 30 | InjectExtraScriptAfterJSMain string // 额外注入的 js 31 | InjectGlobalScript string // 全局用户脚本 32 | 33 | // CertFiles *certificate.CertFileAndKeyFile 34 | t *config.Config 35 | } 36 | 37 | func SetDefault(cfg *config.Config) { 38 | config.Register(config.ConfigItem{ 39 | Key: "download.defaultHighest", 40 | Type: config.ConfigTypeBool, 41 | Default: false, 42 | Description: "默认下载原始视频", 43 | Title: "原始视频", 44 | Group: "Download", 45 | }) 46 | config.Register(config.ConfigItem{ 47 | Key: "download.filenameTemplate", 48 | Type: config.ConfigTypeString, 49 | Default: "{{filename}}_{{spec}}", 50 | Description: "下载文件名模板,支持 {{filename}} 和 {{spec}} 变量", 51 | Title: "文件名模板", 52 | Group: "Download", 53 | }) 54 | config.Register(config.ConfigItem{ 55 | Key: "download.pauseWhenDownload", 56 | Type: config.ConfigTypeBool, 57 | Default: false, 58 | Description: "下载时暂停播放", 59 | Title: "暂停播放", 60 | Group: "Download", 61 | }) 62 | config.Register(config.ConfigItem{ 63 | Key: "download.localServer.enabled", 64 | Type: config.ConfigTypeBool, 65 | Default: false, 66 | Description: "是否开启本地服务器", 67 | Title: "本地服务器", 68 | Group: "Download", 69 | }) 70 | config.Register(config.ConfigItem{ 71 | Key: "download.localServer.addr", 72 | Type: config.ConfigTypeString, 73 | Default: "127.0.0.1:8080", 74 | Description: "本地服务器地址", 75 | Title: "本地服务器地址", 76 | Group: "Download", 77 | }) 78 | config.Register(config.ConfigItem{ 79 | Key: "proxy.system", 80 | Type: config.ConfigTypeBool, 81 | Default: true, 82 | Description: "是否设置系统代理", 83 | Title: "系统代理", 84 | Group: "Proxy", 85 | }) 86 | config.Register(config.ConfigItem{ 87 | Key: "proxy.hostname", 88 | Type: config.ConfigTypeString, 89 | Default: "127.0.0.1", 90 | Description: "代理主机名", 91 | Title: "代理主机", 92 | Group: "Proxy", 93 | }) 94 | config.Register(config.ConfigItem{ 95 | Key: "proxy.port", 96 | Type: config.ConfigTypeInt, 97 | Default: 2080, 98 | Description: "代理端口", 99 | Title: "代理端口", 100 | Group: "Proxy", 101 | }) 102 | config.Register(config.ConfigItem{ 103 | Key: "pagespy.enabled", 104 | Type: config.ConfigTypeSelect, 105 | Default: false, 106 | Description: "是否开启 PageSpy", 107 | Title: "启用", 108 | Group: "Pagespy", 109 | }) 110 | config.Register(config.ConfigItem{ 111 | Key: "pagespy.protocol", 112 | Type: config.ConfigTypeSelect, 113 | Default: "https", 114 | Options: []string{"http", "https"}, 115 | Description: "PageSpy 调试协议", 116 | Title: "协议头", 117 | Group: "Pagespy", 118 | }) 119 | config.Register(config.ConfigItem{ 120 | Key: "pagespy.api", 121 | Type: config.ConfigTypeString, 122 | Default: "debug.weixin.qq.com", 123 | Description: "PageSpy 调试 API 地址", 124 | Title: "API 地址", 125 | Group: "Pagespy", 126 | }) 127 | config.Register(config.ConfigItem{ 128 | Key: "debug.error", 129 | Type: config.ConfigTypeBool, 130 | Default: true, 131 | Description: "在弹窗展示错误信息", 132 | Title: "错误展示", 133 | Group: "Debug", 134 | }) 135 | config.Register(config.ConfigItem{ 136 | Key: "channel.disableLocationToHome", 137 | Type: config.ConfigTypeBool, 138 | Default: false, 139 | Description: "禁止从 Feed 重定向到 Home", 140 | Title: "禁止重定向", 141 | Group: "Channel", 142 | }) 143 | config.Register(config.ConfigItem{ 144 | Key: "inject.extraScript.afterJSMain", 145 | Type: config.ConfigTypeString, 146 | Default: "", 147 | Description: "额外注入的 JS 脚本路径", 148 | Title: "注入脚本", 149 | Group: "Inject", 150 | }) 151 | config.Register(config.ConfigItem{ 152 | Key: "inject.globalScript", 153 | Type: config.ConfigTypeString, 154 | Default: "", 155 | Description: "全局用户脚本", 156 | Title: "全局脚本", 157 | Group: "Inject", 158 | }) 159 | } 160 | 161 | func NewInterceptorSettings() (*InterceptorSettings, error) { 162 | c, err := config.New() 163 | if err != nil { 164 | return nil, err 165 | } 166 | SetDefault(c) 167 | err = c.LoadConfig() 168 | if err != nil { 169 | return nil, err 170 | } 171 | settings := &InterceptorSettings{ 172 | DownloadDefaultHighest: viper.GetBool("download.defaultHighest"), 173 | DownloadFilenameTemplate: viper.GetString("download.filenameTemplate"), 174 | DownloadPauseWhenDownload: viper.GetBool("download.pauseWhenDownload"), 175 | DownloadLocalServerEnabled: viper.GetBool("download.localServer.enabled"), 176 | DownloadLocalServerAddr: viper.GetString("download.localServer.addr"), 177 | ProxySetSystem: viper.GetBool("proxy.system"), 178 | ProxyServerPort: viper.GetInt("proxy.port"), 179 | ProxyServerHostname: viper.GetString("proxy.hostname"), 180 | PagespyEnabled: viper.GetBool("pagespy.enabled"), 181 | PageppyServerProtocol: viper.GetString("pagespy.protocol"), 182 | PageppyServerAPI: viper.GetString("pagespy.api"), 183 | DebugShowError: viper.GetBool("debug.error"), 184 | ChannelDisableLocationToHome: viper.GetBool("channel.disableLocationToHome"), 185 | InjectExtraScriptAfterJSMain: viper.GetString("inject.extraScript.afterJSMain"), 186 | InjectGlobalScript: viper.GetString("inject.globalScript"), 187 | // CertFiles: cert, 188 | t: c, 189 | } 190 | global_script_path := path.Join(c.BaseDir, "global.js") 191 | if _, err := os.Stat(global_script_path); err == nil { 192 | script_byte, err := os.ReadFile(global_script_path) 193 | if err == nil { 194 | settings.InjectGlobalScript = string(script_byte) 195 | } 196 | } 197 | extra_js_filepath := settings.InjectExtraScriptAfterJSMain 198 | if extra_js_filepath != "" { 199 | // If it's a relative path, resolve it against the current working directory 200 | if !filepath.IsAbs(extra_js_filepath) { 201 | extra_js_filepath = filepath.Join(c.BaseDir, extra_js_filepath) 202 | } 203 | if _, err := os.Stat(extra_js_filepath); err == nil { 204 | script_byte, err := os.ReadFile(extra_js_filepath) 205 | if err == nil { 206 | settings.InjectExtraScriptAfterJSMain = string(script_byte) 207 | } 208 | } 209 | } 210 | return settings, nil 211 | } 212 | -------------------------------------------------------------------------------- /internal/interceptor/inject/lib/floating-ui.dom.1.7.4.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("@floating-ui/core")):"function"==typeof define&&define.amd?define(["exports","@floating-ui/core"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).FloatingUIDOM={},t.FloatingUICore)}(this,(function(t,e){"use strict";const n=Math.min,o=Math.max,i=Math.round,r=Math.floor,c=t=>({x:t,y:t});function l(){return"undefined"!=typeof window}function s(t){return a(t)?(t.nodeName||"").toLowerCase():"#document"}function f(t){var e;return(null==t||null==(e=t.ownerDocument)?void 0:e.defaultView)||window}function u(t){var e;return null==(e=(a(t)?t.ownerDocument:t.document)||window.document)?void 0:e.documentElement}function a(t){return!!l()&&(t instanceof Node||t instanceof f(t).Node)}function d(t){return!!l()&&(t instanceof Element||t instanceof f(t).Element)}function h(t){return!!l()&&(t instanceof HTMLElement||t instanceof f(t).HTMLElement)}function p(t){return!(!l()||"undefined"==typeof ShadowRoot)&&(t instanceof ShadowRoot||t instanceof f(t).ShadowRoot)}const g=new Set(["inline","contents"]);function m(t){const{overflow:e,overflowX:n,overflowY:o,display:i}=E(t);return/auto|scroll|overlay|hidden|clip/.test(e+o+n)&&!g.has(i)}const y=new Set(["table","td","th"]);function w(t){return y.has(s(t))}const x=[":popover-open",":modal"];function v(t){return x.some((e=>{try{return t.matches(e)}catch(t){return!1}}))}const b=["transform","translate","scale","rotate","perspective"],T=["transform","translate","scale","rotate","perspective","filter"],L=["paint","layout","strict","content"];function R(t){const e=S(),n=d(t)?E(t):t;return b.some((t=>!!n[t]&&"none"!==n[t]))||!!n.containerType&&"normal"!==n.containerType||!e&&!!n.backdropFilter&&"none"!==n.backdropFilter||!e&&!!n.filter&&"none"!==n.filter||T.some((t=>(n.willChange||"").includes(t)))||L.some((t=>(n.contain||"").includes(t)))}function S(){return!("undefined"==typeof CSS||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}const C=new Set(["html","body","#document"]);function F(t){return C.has(s(t))}function E(t){return f(t).getComputedStyle(t)}function O(t){return d(t)?{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}:{scrollLeft:t.scrollX,scrollTop:t.scrollY}}function D(t){if("html"===s(t))return t;const e=t.assignedSlot||t.parentNode||p(t)&&t.host||u(t);return p(e)?e.host:e}function W(t){const e=D(t);return F(e)?t.ownerDocument?t.ownerDocument.body:t.body:h(e)&&m(e)?e:W(e)}function M(t,e,n){var o;void 0===e&&(e=[]),void 0===n&&(n=!0);const i=W(t),r=i===(null==(o=t.ownerDocument)?void 0:o.body),c=f(i);if(r){const t=H(c);return e.concat(c,c.visualViewport||[],m(i)?i:[],t&&n?M(t):[])}return e.concat(i,M(i,[],n))}function H(t){return t.parent&&Object.getPrototypeOf(t.parent)?t.frameElement:null}function P(t){const e=E(t);let n=parseFloat(e.width)||0,o=parseFloat(e.height)||0;const r=h(t),c=r?t.offsetWidth:n,l=r?t.offsetHeight:o,s=i(n)!==c||i(o)!==l;return s&&(n=c,o=l),{width:n,height:o,$:s}}function z(t){return d(t)?t:t.contextElement}function A(t){const e=z(t);if(!h(e))return c(1);const n=e.getBoundingClientRect(),{width:o,height:r,$:l}=P(e);let s=(l?i(n.width):n.width)/o,f=(l?i(n.height):n.height)/r;return s&&Number.isFinite(s)||(s=1),f&&Number.isFinite(f)||(f=1),{x:s,y:f}}const B=c(0);function V(t){const e=f(t);return S()&&e.visualViewport?{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}:B}function N(t,n,o,i){void 0===n&&(n=!1),void 0===o&&(o=!1);const r=t.getBoundingClientRect(),l=z(t);let s=c(1);n&&(i?d(i)&&(s=A(i)):s=A(t));const u=function(t,e,n){return void 0===e&&(e=!1),!(!n||e&&n!==f(t))&&e}(l,o,i)?V(l):c(0);let a=(r.left+u.x)/s.x,h=(r.top+u.y)/s.y,p=r.width/s.x,g=r.height/s.y;if(l){const t=f(l),e=i&&d(i)?f(i):i;let n=t,o=H(n);for(;o&&i&&e!==n;){const t=A(o),e=o.getBoundingClientRect(),i=E(o),r=e.left+(o.clientLeft+parseFloat(i.paddingLeft))*t.x,c=e.top+(o.clientTop+parseFloat(i.paddingTop))*t.y;a*=t.x,h*=t.y,p*=t.x,g*=t.y,a+=r,h+=c,n=f(o),o=H(n)}}return e.rectToClientRect({width:p,height:g,x:a,y:h})}function I(t,e){const n=O(t).scrollLeft;return e?e.left+n:N(u(t)).left+n}function k(t,e){const n=t.getBoundingClientRect();return{x:n.left+e.scrollLeft-I(t,n),y:n.top+e.scrollTop}}const q=new Set(["absolute","fixed"]);function U(t,n,i){let r;if("viewport"===n)r=function(t,e){const n=f(t),o=u(t),i=n.visualViewport;let r=o.clientWidth,c=o.clientHeight,l=0,s=0;if(i){r=i.width,c=i.height;const t=S();(!t||t&&"fixed"===e)&&(l=i.offsetLeft,s=i.offsetTop)}const a=I(o);if(a<=0){const t=o.ownerDocument,e=t.body,n=getComputedStyle(e),i="CSS1Compat"===t.compatMode&&parseFloat(n.marginLeft)+parseFloat(n.marginRight)||0,c=Math.abs(o.clientWidth-e.clientWidth-i);c<=25&&(r-=c)}else a<=25&&(r+=a);return{width:r,height:c,x:l,y:s}}(t,i);else if("document"===n)r=function(t){const e=u(t),n=O(t),i=t.ownerDocument.body,r=o(e.scrollWidth,e.clientWidth,i.scrollWidth,i.clientWidth),c=o(e.scrollHeight,e.clientHeight,i.scrollHeight,i.clientHeight);let l=-n.scrollLeft+I(t);const s=-n.scrollTop;return"rtl"===E(i).direction&&(l+=o(e.clientWidth,i.clientWidth)-r),{width:r,height:c,x:l,y:s}}(u(t));else if(d(n))r=function(t,e){const n=N(t,!0,"fixed"===e),o=n.top+t.clientTop,i=n.left+t.clientLeft,r=h(t)?A(t):c(1);return{width:t.clientWidth*r.x,height:t.clientHeight*r.y,x:i*r.x,y:o*r.y}}(n,i);else{const e=V(t);r={x:n.x-e.x,y:n.y-e.y,width:n.width,height:n.height}}return e.rectToClientRect(r)}function j(t,e){const n=D(t);return!(n===e||!d(n)||F(n))&&("fixed"===E(n).position||j(n,e))}function X(t,e,n){const o=h(e),i=u(e),r="fixed"===n,l=N(t,!0,r,e);let f={scrollLeft:0,scrollTop:0};const a=c(0);function d(){a.x=I(i)}if(o||!o&&!r)if(("body"!==s(e)||m(i))&&(f=O(e)),o){const t=N(e,!0,r,e);a.x=t.x+e.clientLeft,a.y=t.y+e.clientTop}else i&&d();r&&!o&&i&&d();const p=!i||o||r?c(0):k(i,f);return{x:l.left+f.scrollLeft-a.x-p.x,y:l.top+f.scrollTop-a.y-p.y,width:l.width,height:l.height}}function Y(t){return"static"===E(t).position}function $(t,e){if(!h(t)||"fixed"===E(t).position)return null;if(e)return e(t);let n=t.offsetParent;return u(t)===n&&(n=n.ownerDocument.body),n}function _(t,e){const n=f(t);if(v(t))return n;if(!h(t)){let e=D(t);for(;e&&!F(e);){if(d(e)&&!Y(e))return e;e=D(e)}return n}let o=$(t,e);for(;o&&w(o)&&Y(o);)o=$(o,e);return o&&F(o)&&Y(o)&&!R(o)?n:o||function(t){let e=D(t);for(;h(e)&&!F(e);){if(R(e))return e;if(v(e))return null;e=D(e)}return null}(t)||n}const G={convertOffsetParentRelativeRectToViewportRelativeRect:function(t){let{elements:e,rect:n,offsetParent:o,strategy:i}=t;const r="fixed"===i,l=u(o),f=!!e&&v(e.floating);if(o===l||f&&r)return n;let a={scrollLeft:0,scrollTop:0},d=c(1);const p=c(0),g=h(o);if((g||!g&&!r)&&(("body"!==s(o)||m(l))&&(a=O(o)),h(o))){const t=N(o);d=A(o),p.x=t.x+o.clientLeft,p.y=t.y+o.clientTop}const y=!l||g||r?c(0):k(l,a);return{width:n.width*d.x,height:n.height*d.y,x:n.x*d.x-a.scrollLeft*d.x+p.x+y.x,y:n.y*d.y-a.scrollTop*d.y+p.y+y.y}},getDocumentElement:u,getClippingRect:function(t){let{element:e,boundary:i,rootBoundary:r,strategy:c}=t;const l=[..."clippingAncestors"===i?v(e)?[]:function(t,e){const n=e.get(t);if(n)return n;let o=M(t,[],!1).filter((t=>d(t)&&"body"!==s(t))),i=null;const r="fixed"===E(t).position;let c=r?D(t):t;for(;d(c)&&!F(c);){const e=E(c),n=R(c);n||"fixed"!==e.position||(i=null),(r?!n&&!i:!n&&"static"===e.position&&i&&q.has(i.position)||m(c)&&!n&&j(t,c))?o=o.filter((t=>t!==c)):i=e,c=D(c)}return e.set(t,o),o}(e,this._c):[].concat(i),r],f=l[0],u=l.reduce(((t,i)=>{const r=U(e,i,c);return t.top=o(r.top,t.top),t.right=n(r.right,t.right),t.bottom=n(r.bottom,t.bottom),t.left=o(r.left,t.left),t}),U(e,f,c));return{width:u.right-u.left,height:u.bottom-u.top,x:u.left,y:u.top}},getOffsetParent:_,getElementRects:async function(t){const e=this.getOffsetParent||_,n=this.getDimensions,o=await n(t.floating);return{reference:X(t.reference,await e(t.floating),t.strategy),floating:{x:0,y:0,width:o.width,height:o.height}}},getClientRects:function(t){return Array.from(t.getClientRects())},getDimensions:function(t){const{width:e,height:n}=P(t);return{width:e,height:n}},getScale:A,isElement:d,isRTL:function(t){return"rtl"===E(t).direction}};function J(t,e){return t.x===e.x&&t.y===e.y&&t.width===e.width&&t.height===e.height}const K=e.detectOverflow,Q=e.offset,Z=e.autoPlacement,tt=e.shift,et=e.flip,nt=e.size,ot=e.hide,it=e.arrow,rt=e.inline,ct=e.limitShift;t.arrow=it,t.autoPlacement=Z,t.autoUpdate=function(t,e,i,c){void 0===c&&(c={});const{ancestorScroll:l=!0,ancestorResize:s=!0,elementResize:f="function"==typeof ResizeObserver,layoutShift:a="function"==typeof IntersectionObserver,animationFrame:d=!1}=c,h=z(t),p=l||s?[...h?M(h):[],...M(e)]:[];p.forEach((t=>{l&&t.addEventListener("scroll",i,{passive:!0}),s&&t.addEventListener("resize",i)}));const g=h&&a?function(t,e){let i,c=null;const l=u(t);function s(){var t;clearTimeout(i),null==(t=c)||t.disconnect(),c=null}return function f(u,a){void 0===u&&(u=!1),void 0===a&&(a=1),s();const d=t.getBoundingClientRect(),{left:h,top:p,width:g,height:m}=d;if(u||e(),!g||!m)return;const y={rootMargin:-r(p)+"px "+-r(l.clientWidth-(h+g))+"px "+-r(l.clientHeight-(p+m))+"px "+-r(h)+"px",threshold:o(0,n(1,a))||1};let w=!0;function x(e){const n=e[0].intersectionRatio;if(n!==a){if(!w)return f();n?f(!1,n):i=setTimeout((()=>{f(!1,1e-7)}),1e3)}1!==n||J(d,t.getBoundingClientRect())||f(),w=!1}try{c=new IntersectionObserver(x,{...y,root:l.ownerDocument})}catch(t){c=new IntersectionObserver(x,y)}c.observe(t)}(!0),s}(h,i):null;let m,y=-1,w=null;f&&(w=new ResizeObserver((t=>{let[n]=t;n&&n.target===h&&w&&(w.unobserve(e),cancelAnimationFrame(y),y=requestAnimationFrame((()=>{var t;null==(t=w)||t.observe(e)}))),i()})),h&&!d&&w.observe(h),w.observe(e));let x=d?N(t):null;return d&&function e(){const n=N(t);x&&!J(x,n)&&i();x=n,m=requestAnimationFrame(e)}(),i(),()=>{var t;p.forEach((t=>{l&&t.removeEventListener("scroll",i),s&&t.removeEventListener("resize",i)})),null==g||g(),null==(t=w)||t.disconnect(),w=null,d&&cancelAnimationFrame(m)}},t.computePosition=(t,n,o)=>{const i=new Map,r={platform:G,...o},c={...r.platform,_c:i};return e.computePosition(t,n,{...r,platform:c})},t.detectOverflow=K,t.flip=et,t.getOverflowAncestors=M,t.hide=ot,t.inline=rt,t.limitShift=ct,t.offset=Q,t.platform=G,t.shift=tt,t.size=nt})); -------------------------------------------------------------------------------- /pkg/download/download.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var file_mutex sync.Mutex 16 | 17 | type FileDownloadProgress struct { 18 | Current int64 19 | Total int64 20 | Speed float64 21 | } 22 | 23 | // 显示所有线程的进度 24 | func display_progress(progress_chans []chan FileDownloadProgress, stop chan bool) { 25 | // 存储每个线程的进度 26 | progresses := make([]FileDownloadProgress, len(progress_chans)) 27 | 28 | // 清屏并移动光标到左上角 29 | fmt.Print("\033[2J\033[H") 30 | 31 | ticker := time.NewTicker(100 * time.Millisecond) 32 | defer ticker.Stop() 33 | 34 | for { 35 | select { 36 | case <-stop: 37 | return 38 | case <-ticker.C: 39 | // 移动光标到左上角 40 | fmt.Print("\033[H") 41 | 42 | // 显示每个线程的进度 43 | total_downloaded := int64(0) 44 | for i, progress := range progresses { 45 | if progress.Total > 0 { 46 | percentage := float64(progress.Current) / float64(progress.Total) * 100 47 | fmt.Printf("线程 %d: [%-50s] %.1f%% (%.1f KB/s)\n", 48 | i+1, 49 | progress_bar(percentage, 50), 50 | percentage, 51 | progress.Speed/1024, 52 | ) 53 | total_downloaded += progress.Current 54 | } else { 55 | fmt.Printf("线程 %d: 等待开始...\n", i+1) 56 | } 57 | } 58 | 59 | // 显示总进度 60 | if len(progresses) > 0 && progresses[0].Total > 0 { 61 | totalSize := progresses[0].Total * int64(len(progresses)) 62 | totalPercentage := float64(total_downloaded) / float64(totalSize) * 100 63 | fmt.Printf("\n总进度: [%-50s] %.1f%%\n", 64 | progress_bar(totalPercentage, 50), 65 | totalPercentage, 66 | ) 67 | } 68 | 69 | // 更新进度信息 70 | for i, ch := range progress_chans { 71 | select { 72 | case progress := <-ch: 73 | progresses[i] = progress 74 | default: 75 | // 不阻塞,使用上次的进度 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | // 生成进度条字符串 83 | func progress_bar(percentage float64, width int) string { 84 | completed := int(percentage / 100 * float64(width)) 85 | if completed > width { 86 | completed = width 87 | } 88 | 89 | bar := "" 90 | for i := 0; i < width; i++ { 91 | if i < completed { 92 | bar += "=" 93 | } else if i == completed { 94 | bar += ">" 95 | } else { 96 | bar += " " 97 | } 98 | } 99 | return bar 100 | } 101 | 102 | func calculate_chunks(total_size, preferred_chunk_size int64) []struct{ start, end int64 } { 103 | const ( 104 | minChunkSize = 1 * 1024 * 1024 // 1MB最小分块 105 | maxChunks = 8 // 最大分块数 106 | minChunks = 2 // 最小分块数 107 | ) 108 | 109 | // 计算初始分块数 110 | num_chunks := total_size / preferred_chunk_size 111 | if num_chunks < minChunks { 112 | num_chunks = minChunks 113 | } else if num_chunks > maxChunks { 114 | num_chunks = maxChunks 115 | } 116 | 117 | // 重新计算分块大小 118 | chunk_size := total_size / num_chunks 119 | 120 | // 确保分块不小于最小值 121 | if chunk_size < minChunkSize { 122 | chunk_size = minChunkSize 123 | num_chunks = total_size / chunk_size 124 | if num_chunks == 0 { 125 | num_chunks = 1 126 | } 127 | } 128 | 129 | var chunks []struct{ start, end int64 } 130 | for i := int64(0); i < num_chunks; i++ { 131 | start := i * chunk_size 132 | end := start + chunk_size - 1 133 | 134 | // 最后一个分块包含剩余所有数据 135 | if i == num_chunks-1 { 136 | end = total_size - 1 137 | } 138 | 139 | chunks = append(chunks, struct{ start, end int64 }{start, end}) 140 | } 141 | 142 | return chunks 143 | } 144 | 145 | // 带进度显示的文件分块下载 146 | func download_part_with_progress(url string, file *os.File, start, end int64, thread_idx int, progress_chan chan<- FileDownloadProgress) error { 147 | client := &http.Client{Timeout: 0} // 无超时限制 148 | 149 | // 创建带Range头的请求 150 | req, err := http.NewRequest("GET", url, nil) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | range_header := fmt.Sprintf("bytes=%d-%d", start, end) 156 | req.Header.Add("Range", range_header) 157 | 158 | // 执行请求 159 | resp, err := client.Do(req) 160 | if err != nil { 161 | return err 162 | } 163 | defer resp.Body.Close() 164 | 165 | // 检查响应状态 166 | if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK { 167 | return fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode) 168 | } 169 | 170 | // 创建带进度统计的Reader 171 | total_size := end - start + 1 172 | progress_reader := &ProgressReader{ 173 | Reader: resp.Body, 174 | Total: total_size, 175 | Thread: thread_idx, 176 | Channel: progress_chan, 177 | } 178 | 179 | buf := new(bytes.Buffer) 180 | if _, err := io.Copy(buf, progress_reader); err != nil { 181 | return err 182 | } 183 | // 将下载的数据写入文件的指定位置 184 | 185 | // 同步写入文件 186 | file_mutex.Lock() 187 | defer file_mutex.Unlock() 188 | // 定位到文件的指定位置 189 | _, err = file.Seek(start, io.SeekStart) 190 | if err != nil { 191 | return err 192 | } 193 | if _, err := file.Write(buf.Bytes()); err != nil { 194 | return err 195 | } 196 | return nil 197 | } 198 | 199 | // 带进度统计的Reader 200 | type ProgressReader struct { 201 | Reader io.Reader 202 | Total int64 203 | Thread int 204 | Channel chan<- FileDownloadProgress 205 | read int64 206 | lastRead int64 207 | lastTime time.Time 208 | startTime time.Time 209 | } 210 | 211 | func (pr *ProgressReader) Read(p []byte) (int, error) { 212 | if pr.startTime.IsZero() { 213 | pr.startTime = time.Now() 214 | pr.lastTime = pr.startTime 215 | } 216 | 217 | n, err := pr.Reader.Read(p) 218 | pr.read += int64(n) 219 | 220 | // 计算下载速度 221 | now := time.Now() 222 | elapsed := now.Sub(pr.lastTime).Seconds() 223 | 224 | if elapsed >= 0.1 { // 每100ms更新一次进度 225 | speed := float64(pr.read-pr.lastRead) / elapsed 226 | 227 | // 发送进度信息 228 | select { 229 | case pr.Channel <- FileDownloadProgress{ 230 | Current: pr.read, 231 | Total: pr.Total, 232 | Speed: speed, 233 | }: 234 | default: 235 | // 不阻塞,如果通道满了就跳过 236 | } 237 | 238 | pr.lastRead = pr.read 239 | pr.lastTime = now 240 | } 241 | 242 | return n, err 243 | } 244 | 245 | type PartialFileDownloadProgress struct { 246 | DownloadedSize int64 247 | TotalSize int64 248 | Percent float64 249 | } 250 | 251 | func SingleThreadingDownload(url string, dest_filepath string, on_progress func(progress *PartialFileDownloadProgress)) error { 252 | resp, err := http.Get(url) 253 | if err != nil { 254 | return fmt.Errorf("下载失败 %v", err.Error()) 255 | } 256 | defer resp.Body.Close() 257 | file, err := os.Create(dest_filepath) 258 | if err != nil { 259 | return fmt.Errorf("创建文件失败 %v", err.Error()) 260 | } 261 | defer file.Close() 262 | content_length := resp.Header.Get("Content-Length") 263 | total_size := int64(-1) 264 | if content_length != "" { 265 | total_size, _ = strconv.ParseInt(content_length, 10, 64) 266 | } 267 | buf := make([]byte, 32*1024) // 32KB buffer 268 | var downloaded int64 = 0 269 | for { 270 | n, err := resp.Body.Read(buf) 271 | if n > 0 { 272 | _, werr := file.Write(buf[:n]) 273 | if werr != nil { 274 | return fmt.Errorf("写入文件失败 %v", werr.Error()) 275 | } 276 | downloaded += int64(n) 277 | if total_size > 0 { 278 | percent := float64(downloaded) / float64(total_size) * 100 279 | on_progress(&PartialFileDownloadProgress{ 280 | DownloadedSize: downloaded, 281 | TotalSize: total_size, 282 | Percent: percent, 283 | }) 284 | // fmt.Printf("\r\033[K已下载: %d/%d 字节 (%.2f%%)", downloaded, total_size, percent) 285 | } else { 286 | on_progress(&PartialFileDownloadProgress{ 287 | DownloadedSize: downloaded, 288 | TotalSize: 0, 289 | Percent: 0, 290 | }) 291 | // fmt.Printf("\r\033[K已下载: %d 字节", downloaded) 292 | } 293 | } 294 | if err == io.EOF { 295 | break 296 | } 297 | if err != nil { 298 | return fmt.Errorf("读取文件流失败 %v", err.Error()) 299 | } 300 | } 301 | return nil 302 | } 303 | 304 | func MultiThreadingDownload(url string, threads int, dest_filepath string, tmp_dest_filepath string) error { 305 | tr := &http.Transport{ 306 | TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), 307 | } 308 | client := &http.Client{Transport: tr, Timeout: 30 * time.Second} 309 | // 发送HEAD请求获取文件信息 310 | resp, err := client.Head(url) 311 | if err != nil { 312 | return fmt.Errorf("获取文件信息失败 %v", err.Error()) 313 | } 314 | defer resp.Body.Close() 315 | 316 | fmt.Print("\033c") 317 | 318 | // 检查是否支持断点续传 319 | if resp.Header.Get("Accept-Ranges") != "bytes" { 320 | return fmt.Errorf("服务器不支持并发下载") 321 | } 322 | content_length := resp.Header.Get("Content-Length") 323 | if content_length == "" { 324 | return fmt.Errorf("无法获取总文件大小,服务器不支持并发下载") 325 | } 326 | 327 | file_size, err := strconv.ParseInt(content_length, 10, 64) 328 | if err != nil { 329 | return fmt.Errorf("解析文件大小失败: %v", err) 330 | } 331 | 332 | // start_time := time.Now() 333 | var wg sync.WaitGroup 334 | errors := make(chan error, threads) 335 | 336 | // 计算每个分块的大小 337 | part_size := file_size / int64(threads) 338 | // remainder := file_size % int64(threads) 339 | 340 | chunks := calculate_chunks(file_size, part_size) 341 | // 创建进度通道,每个线程一个 342 | progress_chans := make([]chan FileDownloadProgress, len(chunks)) 343 | for i := range progress_chans { 344 | progress_chans[i] = make(chan FileDownloadProgress, 10) 345 | } 346 | 347 | // 启动进度显示器 348 | stop_progress := make(chan bool) 349 | go display_progress(progress_chans, stop_progress) 350 | 351 | file, err := os.Create(dest_filepath) 352 | if err != nil { 353 | return fmt.Errorf("创建文件失败 %v\n", err) 354 | } 355 | defer file.Close() 356 | if err := file.Truncate(file_size); err != nil { 357 | return fmt.Errorf("设置文件大小失败: %v", err) 358 | } 359 | 360 | // fmt.Println("the chunk size is", len(chunks)) 361 | // 启动并发下载 362 | for i, chunk := range chunks { 363 | wg.Add(1) 364 | // fmt.Println(i, chunk) 365 | go func(thread_idx int, start, end int64) { 366 | defer wg.Done() 367 | // file, err := os.Create(tmp_dest_filepath + "_" + strconv.Itoa(thread_idx)) 368 | // if err != nil { 369 | // errors <- fmt.Errorf("创建文件 %d 失败: %v", thread_idx+1, err) 370 | // return 371 | // } 372 | // defer file.Close() 373 | if err := download_part_with_progress( 374 | url, 375 | file, 376 | start, 377 | end, 378 | thread_idx, 379 | progress_chans[thread_idx], 380 | ); err != nil { 381 | errors <- fmt.Errorf("线程 %d 下载失败: %v", thread_idx+1, err) 382 | } 383 | }(i, chunk.start, chunk.end) 384 | } 385 | // 等待所有下载完成 386 | wg.Wait() 387 | close(errors) 388 | close(stop_progress) 389 | 390 | // 检查错误 391 | if len(errors) > 0 { 392 | // for err := range errors { 393 | // fmt.Println(err) 394 | // } 395 | return fmt.Errorf("下载失败,共%v个错误", len(errors)) 396 | } 397 | return nil 398 | } 399 | -------------------------------------------------------------------------------- /internal/download/download.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "encoding/binary" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os/exec" 12 | "path" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "wx_channel/pkg/decrypt" 18 | ) 19 | 20 | // 解密读取器 21 | type DecryptReader struct { 22 | reader io.Reader 23 | ctx *decrypt.RandCtx64 24 | limit uint64 25 | consumed uint64 26 | ks [8]byte 27 | ksPos int 28 | } 29 | 30 | func NewDecryptReader(reader io.Reader, key uint64, offset uint64, limit uint64) *DecryptReader { 31 | ctx := decrypt.CreateISAacInst(key) 32 | dr := &DecryptReader{ 33 | reader: reader, 34 | ctx: ctx, 35 | limit: limit, 36 | consumed: 0, 37 | ksPos: 8, 38 | } 39 | if limit > 0 { 40 | // 将 consumed 对齐到文件偏移,超出加密区则设置为加密区末尾 41 | if offset >= limit { 42 | dr.consumed = limit 43 | } else { 44 | dr.consumed = offset 45 | // 跳过完整的 8 字节块 46 | skipBlocks := offset / 8 47 | for i := uint64(0); i < skipBlocks; i++ { 48 | _ = dr.ctx.ISAacRandom() 49 | } 50 | // 生成当前块并设置起始位置 51 | randNumber := dr.ctx.ISAacRandom() 52 | binary.BigEndian.PutUint64(dr.ks[:], randNumber) 53 | dr.ksPos = int(offset % 8) 54 | } 55 | } 56 | return dr 57 | } 58 | 59 | func (dr *DecryptReader) Read(p []byte) (int, error) { 60 | n, err := dr.reader.Read(p) 61 | if n <= 0 { 62 | return n, err 63 | } 64 | if dr.limit == 0 || dr.consumed >= dr.limit { 65 | return n, err 66 | } 67 | 68 | toDecrypt := uint64(n) 69 | remaining := dr.limit - dr.consumed 70 | if toDecrypt > remaining { 71 | toDecrypt = remaining 72 | } 73 | // 逐字节异或,维护 keystream 位置 74 | for i := uint64(0); i < toDecrypt; i++ { 75 | if dr.ksPos >= 8 { 76 | randNumber := dr.ctx.ISAacRandom() 77 | binary.BigEndian.PutUint64(dr.ks[:], randNumber) 78 | dr.ksPos = 0 79 | } 80 | p[i] ^= dr.ks[dr.ksPos] 81 | dr.ksPos++ 82 | } 83 | dr.consumed += toDecrypt 84 | return n, err 85 | } 86 | 87 | type MediaProxyWithDecrypt struct { 88 | client *http.Client 89 | } 90 | 91 | func NewMediaProxyWithDecrypt() *MediaProxyWithDecrypt { 92 | tr := &http.Transport{ 93 | TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), 94 | MaxIdleConns: 100, 95 | MaxIdleConnsPerHost: 10, 96 | IdleConnTimeout: 90 * time.Second, 97 | } 98 | return &MediaProxyWithDecrypt{ 99 | client: &http.Client{Transport: tr}, 100 | } 101 | } 102 | 103 | func (mp *MediaProxyWithDecrypt) ServeHTTP(w http.ResponseWriter, r *http.Request) { 104 | q := r.URL.Query() 105 | targetURL := q.Get("url") 106 | if targetURL == "" { 107 | http.Error(w, "missing targetURL", http.StatusBadRequest) 108 | return 109 | } 110 | if !strings.HasPrefix(targetURL, "http") { 111 | targetURL = "https://" + targetURL 112 | } 113 | if _, err := url.Parse(targetURL); err != nil { 114 | http.Error(w, "Invalid URL", http.StatusBadRequest) 115 | return 116 | } 117 | filename := q.Get("filename") 118 | if filename == "" { 119 | if u, err := url.Parse(targetURL); err == nil { 120 | if base := path.Base(u.Path); base != "" && base != "/" { 121 | filename = base 122 | } 123 | } 124 | if filename == "" { 125 | filename = "download.mp4" 126 | } 127 | } 128 | decryptKeyStr := q.Get("key") 129 | toMP3 := q.Get("mp3") 130 | if decryptKeyStr != "" { 131 | decryptKey, err := strconv.ParseUint(decryptKeyStr, 0, 64) 132 | if err != nil { 133 | http.Error(w, "invalid decryptKey", http.StatusBadRequest) 134 | return 135 | } 136 | if toMP3 == "1" { 137 | mp.convertWithDecrypt(w, targetURL, decryptKey, 131072, filename) 138 | return 139 | } 140 | mp.decryptOnly(w, r, targetURL, decryptKey, 131072, filename) 141 | return 142 | } 143 | mp.convertOnly(targetURL, w, filename, "mp3") 144 | } 145 | 146 | func (mp *MediaProxyWithDecrypt) convertWithDecrypt(w http.ResponseWriter, targetURL string, key uint64, encLimit uint64, filename string) { 147 | req, err := mp.prepareRequest(http.MethodGet, targetURL, nil) 148 | if err != nil { 149 | http.Error(w, err.Error(), http.StatusBadGateway) 150 | return 151 | } 152 | resp, err := mp.client.Do(req) 153 | if err != nil { 154 | http.Error(w, err.Error(), http.StatusBadGateway) 155 | return 156 | } 157 | defer resp.Body.Close() 158 | 159 | decryptReader := NewDecryptReader(resp.Body, key, 0, encLimit) 160 | 161 | cmd := exec.Command("ffmpeg", 162 | "-i", "pipe:0", 163 | "-vn", 164 | "-acodec", "libmp3lame", 165 | "-ab", "192k", 166 | "-f", "mp3", 167 | "pipe:1", 168 | ) 169 | cmd.Stdin = decryptReader 170 | stdout, err := cmd.StdoutPipe() 171 | if err != nil { 172 | http.Error(w, err.Error(), http.StatusInternalServerError) 173 | return 174 | } 175 | if err := cmd.Start(); err != nil { 176 | http.Error(w, err.Error(), http.StatusInternalServerError) 177 | return 178 | } 179 | 180 | w.Header().Set("Content-Type", "audio/mpeg") 181 | // 设置下载文件名,确保使用 .mp3 扩展名 182 | downloadFilename := filename 183 | if !strings.HasSuffix(strings.ToLower(downloadFilename), ".mp3") { 184 | downloadFilename = strings.TrimSuffix(downloadFilename, path.Ext(downloadFilename)) + ".mp3" 185 | } 186 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", downloadFilename)) 187 | bw := bufio.NewWriterSize(w, 64*1024) 188 | defer bw.Flush() 189 | if _, err := io.Copy(bw, stdout); err != nil { 190 | _ = cmd.Process.Kill() 191 | http.Error(w, err.Error(), http.StatusInternalServerError) 192 | return 193 | } 194 | _ = cmd.Wait() 195 | } 196 | 197 | func (mp *MediaProxyWithDecrypt) decryptOnly(w http.ResponseWriter, r *http.Request, targetURL string, key uint64, encLimit uint64, filename string) { 198 | req, err := mp.prepareRequest(r.Method, targetURL, r.Header) 199 | if err != nil { 200 | http.Error(w, err.Error(), http.StatusBadGateway) 201 | return 202 | } 203 | 204 | resp, err := mp.client.Do(req) 205 | if err != nil { 206 | http.Error(w, err.Error(), http.StatusBadGateway) 207 | return 208 | } 209 | defer resp.Body.Close() 210 | 211 | var startOffset uint64 = 0 212 | if cr := resp.Header.Get("Content-Range"); cr != "" { 213 | parts := strings.Split(cr, " ") 214 | if len(parts) == 2 { 215 | rangePart := parts[1] 216 | dash := strings.Index(rangePart, "-") 217 | if dash > 0 { 218 | if v, err := strconv.ParseUint(rangePart[:dash], 10, 64); err == nil { 219 | startOffset = v 220 | } 221 | } 222 | } 223 | } 224 | decryptReader := NewDecryptReader(resp.Body, key, startOffset, encLimit) 225 | 226 | for k, v := range resp.Header { 227 | w.Header()[k] = v 228 | } 229 | w.Header().Set("Content-Type", "application/octet-stream") 230 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 231 | if w.Header().Get("Accept-Ranges") == "" { 232 | w.Header().Set("Accept-Ranges", "bytes") 233 | } 234 | 235 | w.WriteHeader(resp.StatusCode) 236 | if r.Method == http.MethodHead { 237 | return 238 | } 239 | io.Copy(w, decryptReader) 240 | } 241 | 242 | func (mp *MediaProxyWithDecrypt) convertOnly(targetURL string, w http.ResponseWriter, filename string, format string) { 243 | if format != "mp3" { 244 | mp.simpleProxy(targetURL, w, nil) 245 | return 246 | } 247 | req, err := mp.prepareRequest(http.MethodGet, targetURL, nil) 248 | if err != nil { 249 | http.Error(w, err.Error(), http.StatusBadGateway) 250 | return 251 | } 252 | resp, err := mp.client.Do(req) 253 | if err != nil { 254 | http.Error(w, err.Error(), http.StatusBadGateway) 255 | return 256 | } 257 | defer resp.Body.Close() 258 | cmd := exec.Command("ffmpeg", 259 | "-i", "pipe:0", 260 | "-vn", 261 | "-acodec", "libmp3lame", 262 | "-ab", "192k", 263 | "-f", "mp3", 264 | "pipe:1", 265 | ) 266 | cmd.Stdin = resp.Body 267 | stdout, err := cmd.StdoutPipe() 268 | if err != nil { 269 | http.Error(w, err.Error(), http.StatusInternalServerError) 270 | return 271 | } 272 | if err := cmd.Start(); err != nil { 273 | http.Error(w, err.Error(), http.StatusInternalServerError) 274 | return 275 | } 276 | w.Header().Set("Content-Type", "audio/mpeg") 277 | // 设置下载文件名,确保使用 .mp3 扩展名 278 | downloadFilename := filename 279 | if !strings.HasSuffix(strings.ToLower(downloadFilename), ".mp3") { 280 | downloadFilename = strings.TrimSuffix(downloadFilename, path.Ext(downloadFilename)) + ".mp3" 281 | } 282 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", downloadFilename)) 283 | bufferedWriter := bufio.NewWriterSize(w, 64*1024) 284 | defer bufferedWriter.Flush() 285 | if _, err := io.Copy(bufferedWriter, stdout); err != nil { 286 | cmd.Process.Kill() 287 | http.Error(w, err.Error(), http.StatusInternalServerError) 288 | return 289 | } 290 | _ = cmd.Wait() 291 | } 292 | 293 | func (mp *MediaProxyWithDecrypt) prepareRequest(method, targetURL string, header http.Header) (*http.Request, error) { 294 | if method != http.MethodGet && method != http.MethodHead { 295 | method = http.MethodGet 296 | } 297 | req, err := http.NewRequest(method, targetURL, nil) 298 | if err != nil { 299 | return nil, err 300 | } 301 | // Copy headers if provided 302 | if header != nil { 303 | if rangeHeader := header.Get("Range"); rangeHeader != "" { 304 | req.Header.Set("Range", rangeHeader) 305 | } 306 | } 307 | return req, nil 308 | } 309 | 310 | func (mp *MediaProxyWithDecrypt) simpleProxy(targetURL string, w http.ResponseWriter, r *http.Request) { 311 | var header http.Header 312 | method := http.MethodGet 313 | if r != nil { 314 | header = r.Header 315 | method = r.Method 316 | } 317 | req, err := mp.prepareRequest(method, targetURL, header) 318 | if err != nil { 319 | http.Error(w, err.Error(), http.StatusBadGateway) 320 | return 321 | } 322 | resp, err := mp.client.Do(req) 323 | if err != nil { 324 | http.Error(w, err.Error(), http.StatusBadGateway) 325 | return 326 | } 327 | defer resp.Body.Close() 328 | for k, v := range resp.Header { 329 | w.Header()[k] = v 330 | } 331 | if w.Header().Get("Accept-Ranges") == "" { 332 | w.Header().Set("Accept-Ranges", "bytes") 333 | } 334 | w.WriteHeader(resp.StatusCode) 335 | if method == http.MethodHead { 336 | return 337 | } 338 | io.Copy(w, resp.Body) 339 | } 340 | 341 | func withCORS(h http.Handler) http.Handler { 342 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 343 | w.Header().Set("Access-Control-Allow-Origin", "*") 344 | w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") 345 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Range, Accept, Origin, X-Requested-With") 346 | w.Header().Set("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Type, Content-Length, Content-Disposition") 347 | if r.Method == http.MethodOptions { 348 | w.WriteHeader(http.StatusNoContent) 349 | return 350 | } 351 | h.ServeHTTP(w, r) 352 | }) 353 | } 354 | --------------------------------------------------------------------------------